Skip to content

Commit

Permalink
feat: add Svelte 5 migration (#12519)
Browse files Browse the repository at this point in the history
  • Loading branch information
dummdidumm authored Sep 27, 2024
1 parent e9ed772 commit 05624c3
Show file tree
Hide file tree
Showing 5 changed files with 463 additions and 5 deletions.
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'
)
);
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))
});
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, [
['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'],
['@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

0 comments on commit 05624c3

Please sign in to comment.