Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Svelte 5 migration #12519

Merged
merged 20 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hip-kings-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte-migrate": minor
---

feat: add Svelte 5 migration
187 changes: 187 additions & 0 deletions packages/migrate/migrations/svelte-5/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { resolve } from 'import-meta-resolve';
import colors from 'kleur';
import { execSync } from 'node:child_process';
import process from 'node:process';
import fs from 'node:fs';
import { dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import prompts from 'prompts';
import semver from 'semver';
import glob from 'tiny-glob/sync.js';
import { bail, check_git, update_js_file, update_svelte_file } from '../../utils.js';
import { migrate as migrate_svelte_4 } from '../svelte-4/index.js';
import { transform_module_code, transform_svelte_code, update_pkg_json } from './migrate.js';

export async function migrate() {
if (!fs.existsSync('package.json')) {
bail('Please re-run this script in a directory with a package.json');
}

console.log(
'This migration is experimental — please report any bugs to https://github.com/sveltejs/svelte/issues'
);

const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const svelte_dep = pkg.devDependencies?.svelte ?? pkg.dependencies?.svelte;
if (svelte_dep && semver.validRange(svelte_dep) && semver.gtr('4.0.0', svelte_dep)) {
console.log(
colors
.bold()
.yellow(
'\nDetected Svelte 3. We recommend running the `svelte-4` migration first (`npx svelte-migrate svelte-4`).\n'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you sure they're not running svelte 1 or 2? 🤣

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what-year-is-it.jpg

)
);
const response = await prompts({
type: 'confirm',
name: 'value',
message: 'Run `svelte-4` migration now?',
initial: false
});
if (!response.value) {
process.exit(1);
} else {
await migrate_svelte_4();
console.log(
colors.bold().green('`svelte-4` migration complete. Continue with `svelte-5` migration?\n')
);
const response = await prompts({
type: 'confirm',
name: 'value',
message: 'Continue?',
initial: false
});
if (!response.value) {
process.exit(1);
}
}
}

let migrate;
try {
try {
({ migrate } = await import_from_cwd('svelte/compiler'));
if (!migrate) throw new Error('found Svelte 4');
} catch {
// TODO replace with svelte@5 once it's released
execSync('npm install svelte@next --no-save', {
stdio: 'inherit',
cwd: dirname(fileURLToPath(import.meta.url))
benmccann marked this conversation as resolved.
Show resolved Hide resolved
});
const url = resolve('svelte/compiler', import.meta.url);
({ migrate } = await import(url));
}
} catch (e) {
console.log(e);
console.log(
colors
.bold()
.red(
'❌ Could not install Svelte. Manually bump the dependency to version 5 in your package.json, install it, then try again.'
)
);
return;
}

console.log(
colors
.bold()
.yellow(
'\nThis will update files in the current directory\n' +
"If you're inside a monorepo, don't run this in the root directory, rather run it in all projects independently.\n"
)
);

const use_git = check_git();

const response = await prompts({
type: 'confirm',
name: 'value',
message: 'Continue?',
initial: false
});

if (!response.value) {
process.exit(1);
}

const folders = await prompts({
type: 'multiselect',
name: 'value',
message: 'Which folders should be migrated?',
choices: fs
.readdirSync('.')
.filter(
(dir) => fs.statSync(dir).isDirectory() && dir !== 'node_modules' && !dir.startsWith('.')
)
.map((dir) => ({ title: dir, value: dir, selected: true }))
});

if (!folders.value?.length) {
process.exit(1);
}

update_pkg_json();

// const { default: config } = fs.existsSync('svelte.config.js')
// ? await import(pathToFileURL(path.resolve('svelte.config.js')).href)
// : { default: {} };

/** @type {string[]} */
const svelte_extensions = /* config.extensions ?? - disabled because it would break .svx */ [
'.svelte'
];
const extensions = [...svelte_extensions, '.ts', '.js'];
// For some reason {folders.value.join(',')} as part of the glob doesn't work and returns less files
const files = folders.value.flatMap(
/** @param {string} folder */ (folder) =>
glob(`${folder}/**`, { filesOnly: true, dot: true })
.map((file) => file.replace(/\\/g, '/'))
.filter((file) => !file.includes('/node_modules/'))
);

for (const file of files) {
if (extensions.some((ext) => file.endsWith(ext))) {
if (svelte_extensions.some((ext) => file.endsWith(ext))) {
update_svelte_file(file, transform_module_code, (code) =>
transform_svelte_code(code, migrate)
);
} else {
update_js_file(file, transform_module_code);
}
}
}

console.log(colors.bold().green('✔ Your project has been migrated'));

console.log('\nRecommended next steps:\n');

const cyan = colors.bold().cyan;

const tasks = [
"install the updated dependencies ('npm i' / 'pnpm i' / etc) " +
'(note that there may be peer dependency issues when not all your libraries officially support Svelte 5 yet. In this case try installing with the --force option)',
use_git && cyan('git commit -m "migration to Svelte 5"'),
'Review the breaking changes at https://svelte-5-preview.vercel.app/docs/breaking-changes'
// replace with this once it's live:
// 'Review the migration guide at https://svelte.dev/docs/svelte/v5-migration-guide',
// 'Read the updated docs at https://svelte.dev/docs/svelte'
].filter(Boolean);

tasks.forEach((task, i) => {
console.log(` ${i + 1}: ${task}`);
});

console.log('');

if (use_git) {
console.log(`Run ${cyan('git diff')} to review changes.\n`);
}
}

/** @param {string} name */
function import_from_cwd(name) {
const cwd = pathToFileURL(process.cwd()).href;
const url = resolve(name, cwd + '/x.js');

return import(url);
}
134 changes: 134 additions & 0 deletions packages/migrate/migrations/svelte-5/migrate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import fs from 'node:fs';
import { Project, ts, Node } from 'ts-morph';
import { update_pkg } from '../../utils.js';

export function update_pkg_json() {
fs.writeFileSync(
'package.json',
update_pkg_json_content(fs.readFileSync('package.json', 'utf8'))
);
}

/**
* @param {string} content
*/
export function update_pkg_json_content(content) {
return update_pkg(content, [
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
['svelte', '^5.0.0'],
['svelte-check', '^4.0.0'],
['svelte-preprocess', '^6.0.0'],
['@sveltejs/enhanced-img', '^0.3.6'],
['@sveltejs/kit', '^2.5.27'],
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
['@sveltejs/vite-plugin-svelte', '^4.0.0'],
[
'svelte-loader',
'^3.2.3',
' (if you are still on webpack 4, you need to update to webpack 5)'
],
['rollup-plugin-svelte', '^7.2.2'],
['prettier', '^3.1.0'],
['prettier-plugin-svelte', '^3.2.6'],
['eslint-plugin-svelte', '^2.43.0'],
[
'eslint-plugin-svelte3',
'^4.0.0',
' (this package is deprecated, use eslint-plugin-svelte instead. More info: https://svelte.dev/docs/v4-migration-guide#new-eslint-package)'
],
[
'typescript',
'^5.5.0',
' (this might introduce new type errors due to breaking changes within TypeScript)'
],
['vite', '^5.4.4']
]);
}

/**
* @param {string} code
*/
export function transform_module_code(code) {
const project = new Project({ useInMemoryFileSystem: true });
const source = project.createSourceFile('svelte.ts', code);
update_component_instantiation(source);
return source.getFullText();
}

/**
* @param {string} code
* @param {(source: code) => { code: string }} transform_code
*/
export function transform_svelte_code(code, transform_code) {
return transform_code(code).code;
}

/**
* new Component(...) -> mount(Component, ...)
* @param {import('ts-morph').SourceFile} source
*/
function update_component_instantiation(source) {
const imports = source
.getImportDeclarations()
.filter((i) => i.getModuleSpecifierValue().endsWith('.svelte'))
.flatMap((i) => i.getDefaultImport() || []);

for (const defaultImport of imports) {
const identifiers = find_identifiers(source, defaultImport.getText());

for (const id of identifiers) {
const parent = id.getParent();

if (Node.isNewExpression(parent)) {
const args = parent.getArguments();

if (args.length === 1) {
const method =
Node.isObjectLiteralExpression(args[0]) && !!args[0].getProperty('hydrate')
? 'hydrate'
: 'mount';

if (method === 'hydrate') {
/** @type {import('ts-morph').ObjectLiteralExpression} */ (args[0])
.getProperty('hydrate')
?.remove();
}

if (source.getImportDeclaration('svelte')) {
source.getImportDeclaration('svelte')?.addNamedImport(method);
} else {
source.addImportDeclaration({
moduleSpecifier: 'svelte',
namedImports: [method]
});
}

const declaration = parent
.getParentIfKind(ts.SyntaxKind.VariableDeclaration)
?.getNameNode();
if (Node.isIdentifier(declaration)) {
const usages = declaration.findReferencesAsNodes();
for (const usage of usages) {
const parent = usage.getParent();
if (Node.isPropertyAccessExpression(parent) && parent.getName() === '$destroy') {
const call_expr = parent.getParentIfKind(ts.SyntaxKind.CallExpression);
if (call_expr) {
call_expr.replaceWithText(`unmount(${usage.getText()})`);
source.getImportDeclaration('svelte')?.addNamedImport('unmount');
}
}
}
}

parent.replaceWithText(`${method}(${id.getText()}, ${args[0].getText()})`);
}
}
}
}
}

/**
* @param {import('ts-morph').SourceFile} source
* @param {string} name
*/
function find_identifiers(source, name) {
return source.getDescendantsOfKind(ts.SyntaxKind.Identifier).filter((i) => i.getText() === name);
}
Loading
Loading