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 4 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
175 changes: 175 additions & 0 deletions packages/migrate/migrations/svelte-5/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { resolve } from 'import-meta-resolve';
import colors from 'kleur';
import { execSync } from 'node:child_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 } 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');
}

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 {
execSync('npm install svelte@5 --no-save', {
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
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_js_file(file, (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 = [
use_git && cyan('git commit -m "migration to Svelte 5"'),
'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);
}
122 changes: 122 additions & 0 deletions packages/migrate/migrations/svelte-5/migrate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import fs from 'node:fs';
import { Project, ts, Node } from 'ts-morph';
import { log_on_ts_modification, 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/kit', '^2.5.18'],
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)'
]
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
]);
}

/**
* @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 logger = log_on_ts_modification(
source,
'Updates component instantiation: https://svelte.dev/docs/svelte/TODO (you may need to update usages of the component instance)'
);

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]
});
}

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

logger();
}

/**
* @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);
}
75 changes: 75 additions & 0 deletions packages/migrate/migrations/svelte-5/migrate.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { assert, test } from 'vitest';
import { transform_module_code, update_pkg_json_content } from './migrate.js';

test('Updates component creation #1', () => {
const result = transform_module_code(
`import App from './App.svelte'

const app = new App({
target: document.getElementById('app')!
})

export default app`
);
assert.equal(
result,
`import App from './App.svelte'
import { mount } from "svelte";

const app = mount(App, {
target: document.getElementById('app')!
})

export default app`
);
});

test('Updates component creation #2', () => {
const result = transform_module_code(
`import App from './App.svelte'

new App({
target: document.getElementById('app')!,
hydrate: true
})`
);
assert.equal(
result,
`import App from './App.svelte'
import { hydrate } from "svelte";

hydrate(App, {
target: document.getElementById('app')!
})`
);
});

test('Update package.json', () => {
const result = update_pkg_json_content(`{
"name": "svelte-app",
"version": "1.0.0",
"devDependencies": {
"svelte": "^4.0.0",
"svelte-check": "^3.0.0",
"svelte-preprocess": "^5.0.0"
},
"dependencies": {
"@sveltejs/kit": "^2.0.0"
}
}`);
assert.equal(
result,
`{
"name": "svelte-app",
"version": "1.0.0",
"devDependencies": {
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-preprocess": "^6.0.0"
},
"dependencies": {
"@sveltejs/kit": "^2.5.18"
}
}`
);
});
Loading