From 8299da167a4f4a6f8ae32760b5e8ea02a3ba8e7a Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 30 May 2023 21:02:45 +0200 Subject: [PATCH] feat: add Svelte 4 migration (#9729) Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> --- .changeset/lucky-coins-hunt.md | 5 + packages/migrate/migrations/svelte-4/index.js | 71 +++++++ .../migrate/migrations/svelte-4/migrate.js | 200 ++++++++++++++++++ .../migrations/svelte-4/migrate.spec.js | 197 +++++++++++++++++ packages/migrate/package.json | 3 +- pnpm-lock.yaml | 56 +++-- 6 files changed, 518 insertions(+), 14 deletions(-) create mode 100644 .changeset/lucky-coins-hunt.md create mode 100644 packages/migrate/migrations/svelte-4/index.js create mode 100644 packages/migrate/migrations/svelte-4/migrate.js create mode 100644 packages/migrate/migrations/svelte-4/migrate.spec.js diff --git a/.changeset/lucky-coins-hunt.md b/.changeset/lucky-coins-hunt.md new file mode 100644 index 000000000000..78d11ddc8907 --- /dev/null +++ b/.changeset/lucky-coins-hunt.md @@ -0,0 +1,5 @@ +--- +'svelte-migrate': minor +--- + +feat: add Svelte 4 migration diff --git a/packages/migrate/migrations/svelte-4/index.js b/packages/migrate/migrations/svelte-4/index.js new file mode 100644 index 000000000000..a1acfa7c34a1 --- /dev/null +++ b/packages/migrate/migrations/svelte-4/index.js @@ -0,0 +1,71 @@ +import colors from 'kleur'; +import fs from 'node:fs'; +import prompts from 'prompts'; +import glob from 'tiny-glob/sync.js'; +import { bail, check_git } from '../../utils.js'; +import { update_js_file, update_svelte_file } 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(colors.bold().yellow('\nThis will update files in the current directory\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 { 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']; + // TODO read tsconfig/jsconfig if available? src/** will be good for 99% of cases + const files = glob('src/**', { filesOnly: true, dot: true }).map((file) => + file.replace(/\\/g, '/') + ); + + for (const file of files) { + if (extensions.some((ext) => file.endsWith(ext))) { + if (svelte_extensions.some((ext) => file.endsWith(ext))) { + update_svelte_file(file); + } else { + update_js_file(file); + } + } + } + + 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 4"'), + 'Review the migration guide at TODO', + 'Read the updated docs at https://svelte.dev/docs' + ].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`); + } +} diff --git a/packages/migrate/migrations/svelte-4/migrate.js b/packages/migrate/migrations/svelte-4/migrate.js new file mode 100644 index 000000000000..5609167f230e --- /dev/null +++ b/packages/migrate/migrations/svelte-4/migrate.js @@ -0,0 +1,200 @@ +import fs from 'node:fs'; +import { Project, ts, Node } from 'ts-morph'; + +/** @param {string} file_path */ +export function update_svelte_file(file_path) { + const content = fs.readFileSync(file_path, 'utf-8'); + const updated = content.replace( + /([^]+?)<\/script>(\n*)/g, + (_match, attrs, contents, whitespace) => { + return `${transform_code(contents)}${whitespace}`; + } + ); + fs.writeFileSync(file_path, transform_svelte_code(updated), 'utf-8'); +} + +/** @param {string} file_path */ +export function update_js_file(file_path) { + const content = fs.readFileSync(file_path, 'utf-8'); + const updated = transform_code(content); + fs.writeFileSync(file_path, updated, 'utf-8'); +} + +/** @param {string} code */ +export function transform_code(code) { + const project = new Project({ useInMemoryFileSystem: true }); + const source = project.createSourceFile('svelte.ts', code); + update_imports(source); + update_typeof_svelte_component(source); + update_action_types(source); + update_action_return_types(source); + return source.getFullText(); +} + +/** @param {string} code */ +export function transform_svelte_code(code) { + return update_transitions(update_svelte_options(code)); +} + +/** + * -> + * @param {string} code + */ +function update_svelte_options(code) { + return code.replace(//, (match) => { + return match.replace('tag=', 'customElement='); + }); +} + +/** + * transition/in/out:x -> transition/in/out:x|global + * @param {string} code + */ +function update_transitions(code) { + return code.replace(/(\s)(transition:|in:|out:)(\w+)(?=[\s>=])/g, '$1$2$3|global'); +} + +/** + * Action -> Action + * @param {import('ts-morph').SourceFile} source + */ +function update_action_types(source) { + const imports = get_imports(source, 'svelte/action', 'Action'); + for (const namedImport of imports) { + const identifiers = find_identifiers(source, namedImport.getAliasNode()?.getText() ?? 'Action'); + for (const id of identifiers) { + const parent = id.getParent(); + if (Node.isTypeReference(parent)) { + const type_args = parent.getTypeArguments(); + if (type_args.length === 1) { + parent.addTypeArgument('any'); + } else if (type_args.length === 0) { + parent.addTypeArgument('HTMLElement'); + parent.addTypeArgument('any'); + } + } + } + } +} + +/** + * ActionReturn -> ActionReturn + * @param {import('ts-morph').SourceFile} source + */ +function update_action_return_types(source) { + const imports = get_imports(source, 'svelte/action', 'ActionReturn'); + for (const namedImport of imports) { + const identifiers = find_identifiers( + source, + namedImport.getAliasNode()?.getText() ?? 'ActionReturn' + ); + for (const id of identifiers) { + const parent = id.getParent(); + if (Node.isTypeReference(parent)) { + const type_args = parent.getTypeArguments(); + if (type_args.length === 0) { + parent.addTypeArgument('any'); + } + } + } + } +} + +/** + * SvelteComponentTyped -> SvelteComponent + * @param {import('ts-morph').SourceFile} source + */ +function update_imports(source) { + const identifiers = find_identifiers(source, 'SvelteComponent'); + const can_rename = identifiers.every((id) => { + const parent = id.getParent(); + return ( + (Node.isImportSpecifier(parent) && + !parent.getAliasNode() && + parent.getParent().getParent().getParent().getModuleSpecifier().getText() === 'svelte') || + !is_declaration(parent) + ); + }); + + const imports = get_imports(source, 'svelte', 'SvelteComponentTyped'); + for (const namedImport of imports) { + if (can_rename) { + namedImport.renameAlias('SvelteComponent'); + if ( + namedImport + .getParent() + .getElements() + .some((e) => !e.getAliasNode() && e.getNameNode().getText() === 'SvelteComponent') + ) { + namedImport.remove(); + } else { + namedImport.setName('SvelteComponent'); + namedImport.removeAlias(); + } + } else { + namedImport.renameAlias('SvelteComponentTyped'); + namedImport.setName('SvelteComponent'); + } + } +} + +/** + * typeof SvelteComponent -> typeof SvelteComponent + * @param {import('ts-morph').SourceFile} source + */ +function update_typeof_svelte_component(source) { + const imports = get_imports(source, 'svelte', 'SvelteComponent'); + + for (const type of imports) { + if (type) { + const name = type.getAliasNode() ?? type.getNameNode(); + name.findReferencesAsNodes().forEach((ref) => { + const parent = ref.getParent(); + if (parent && Node.isTypeQuery(parent)) { + const id = parent.getFirstChildByKind(ts.SyntaxKind.Identifier); + if (id?.getText() === name.getText()) { + const typeArguments = parent.getTypeArguments(); + if (typeArguments.length === 0) { + parent.addTypeArgument('any'); + } + } + } + }); + } + } +} + +/** + * @param {import('ts-morph').SourceFile} source + * @param {string} from + * @param {string} name + */ +function get_imports(source, from, name) { + return source + .getImportDeclarations() + .filter((i) => i.getModuleSpecifierValue() === from) + .flatMap((i) => i.getNamedImports()) + .filter((i) => i.getName() === name); +} + +/** + * @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); +} + +/** + * Does not include imports + * @param {Node} node + */ +function is_declaration(node) { + return ( + Node.isVariableDeclaration(node) || + Node.isFunctionDeclaration(node) || + Node.isClassDeclaration(node) || + Node.isTypeAliasDeclaration(node) || + Node.isInterfaceDeclaration(node) + ); +} diff --git a/packages/migrate/migrations/svelte-4/migrate.spec.js b/packages/migrate/migrations/svelte-4/migrate.spec.js new file mode 100644 index 000000000000..d2a01a07e517 --- /dev/null +++ b/packages/migrate/migrations/svelte-4/migrate.spec.js @@ -0,0 +1,197 @@ +import { assert, test } from 'vitest'; +import { transform_code, transform_svelte_code } from './migrate.js'; + +test('Updates SvelteComponentTyped #1', () => { + const result = transform_code( + `import { SvelteComponentTyped } from 'svelte'; + +export class Foo extends SvelteComponentTyped<{}> {} + +const bar: SvelteComponentTyped = null;` + ); + assert.equal( + result, + `import { SvelteComponent } from 'svelte'; + +export class Foo extends SvelteComponent<{}> {} + +const bar: SvelteComponent = null;` + ); +}); + +test('Updates SvelteComponentTyped #2', () => { + const result = transform_code( + `import { SvelteComponentTyped, SvelteComponent } from 'svelte'; + +export class Foo extends SvelteComponentTyped<{}> {} + +const bar: SvelteComponentTyped = null; +const baz: SvelteComponent = null;` + ); + assert.equal( + result, + `import { SvelteComponent } from 'svelte'; + +export class Foo extends SvelteComponent<{}> {} + +const bar: SvelteComponent = null; +const baz: SvelteComponent = null;` + ); +}); + +test('Updates SvelteComponentTyped #3', () => { + const result = transform_code( + `import { SvelteComponentTyped } from 'svelte'; + +interface SvelteComponent {} + +export class Foo extends SvelteComponentTyped<{}> {} + +const bar: SvelteComponentTyped = null; +const baz: SvelteComponent = null;` + ); + assert.equal( + result, + `import { SvelteComponent as SvelteComponentTyped } from 'svelte'; + +interface SvelteComponent {} + +export class Foo extends SvelteComponentTyped<{}> {} + +const bar: SvelteComponentTyped = null; +const baz: SvelteComponent = null;` + ); +}); + +test('Updates typeof SvelteComponent', () => { + const result = transform_code( + `import { SvelteComponent } from 'svelte'; + import { SvelteComponent as C } from 'svelte'; + + const a: typeof SvelteComponent = null; + function b(c: typeof SvelteComponent) {} + const c: typeof SvelteComponent = null; + const d: typeof C = null; + ` + ); + assert.equal( + result, + `import { SvelteComponent } from 'svelte'; + import { SvelteComponent as C } from 'svelte'; + + const a: typeof SvelteComponent = null; + function b(c: typeof SvelteComponent) {} + const c: typeof SvelteComponent = null; + const d: typeof C = null; + ` + ); +}); + +test('Updates Action and ActionReturn', () => { + const result = transform_code( + `import { Action, ActionReturn } from 'svelte/action'; + + const a: Action = () => {}; + const b: Action = () => {}; + const c: Action = () => {}; + const d: Action = () => {}; + const e: ActionReturn = () => {}; + const f: ActionReturn = () => {}; + const g: ActionReturn = () => {}; + ` + ); + assert.equal( + result, + + `import { Action, ActionReturn } from 'svelte/action'; + + const a: Action = () => {}; + const b: Action = () => {}; + const c: Action = () => {}; + const d: Action = () => {}; + const e: ActionReturn = () => {}; + const f: ActionReturn = () => {}; + const g: ActionReturn = () => {}; + ` + ); +}); + +test('Updates svelte:options #1', () => { + const result = transform_svelte_code( + ` + +
hi
` + ); + assert.equal( + result, + ` + +
hi
` + ); +}); + +test('Updates svelte:options #2', () => { + const result = transform_svelte_code( + ` + + + +
hi
` + ); + assert.equal( + result, + ` + + + +
hi
` + ); +}); + +test('Updates transitions', () => { + const result = transform_svelte_code( + `
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ ` + ); + assert.equal( + result, + `
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ ` + ); +}); diff --git a/packages/migrate/package.json b/packages/migrate/package.json index ac4b2bfc226d..c17a4cdb48dd 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -28,7 +28,8 @@ "magic-string": "^0.30.0", "prompts": "^2.4.2", "tiny-glob": "^0.2.9", - "typescript": "^4.9.4" + "ts-morph": "^18.0.0", + "typescript": "^5.0.4" }, "devDependencies": { "@types/node": "^16.18.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c34bd0dcf74a..390bb7b3478a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -938,9 +938,12 @@ importers: tiny-glob: specifier: ^0.2.9 version: 0.2.9 + ts-morph: + specifier: ^18.0.0 + version: 18.0.0 typescript: - specifier: ^4.9.4 - version: 4.9.4 + specifier: ^5.0.4 + version: 5.0.4 devDependencies: '@types/node': specifier: ^16.18.6 @@ -1647,12 +1650,10 @@ packages: dependencies: '@nodelib/fs.stat': 2.0.5 run-parallel: 1.2.0 - dev: true /@nodelib/fs.stat@2.0.5: resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} engines: {node: '>= 8'} - dev: true /@nodelib/fs.walk@1.2.8: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} @@ -1660,7 +1661,6 @@ packages: dependencies: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 - dev: true /@playwright/test@1.30.0: resolution: {integrity: sha512-SVxkQw1xvn/Wk/EvBnqWIq6NLo1AppwbYOjNLmyU0R1RoQ3rLEBtmjTnElcnz8VEtn11fptj1ECxK0tgURhajw==} @@ -1824,6 +1824,15 @@ packages: - encoding dev: true + /@ts-morph/common@0.19.0: + resolution: {integrity: sha512-Unz/WHmd4pGax91rdIKWi51wnVUW11QttMEPpBiBgIewnc9UQIX7UDLxr5vRlqeByXCwhkF6VabSsI0raWcyAQ==} + dependencies: + fast-glob: 3.2.12 + minimatch: 7.4.6 + mkdirp: 2.1.6 + path-browserify: 1.0.1 + dev: false + /@types/chai-subset@1.3.3: resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==} dependencies: @@ -2638,6 +2647,10 @@ packages: engines: {node: '>=0.8'} dev: true + /code-block-writer@12.0.0: + resolution: {integrity: sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==} + dev: false + /code-point-at@1.1.0: resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==} engines: {node: '>=0.10.0'} @@ -3457,7 +3470,6 @@ packages: glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.5 - dev: true /fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -3471,7 +3483,6 @@ packages: resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} dependencies: reusify: 1.0.4 - dev: true /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} @@ -4382,7 +4393,6 @@ packages: /merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - dev: true /micromatch@4.0.5: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} @@ -4422,6 +4432,13 @@ packages: dependencies: brace-expansion: 2.0.1 + /minimatch@7.4.6: + resolution: {integrity: sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 2.0.1 + dev: false + /minimatch@9.0.0: resolution: {integrity: sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==} engines: {node: '>=16 || 14 >=14.17'} @@ -4484,6 +4501,12 @@ packages: hasBin: true dev: false + /mkdirp@2.1.6: + resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==} + engines: {node: '>=10'} + hasBin: true + dev: false + /mlly@1.2.0: resolution: {integrity: sha512-+c7A3CV0KGdKcylsI6khWyts/CYrGTrRVo4R/I7u/cUsy0Conxa6LUhiEzVKIw14lc2L5aiO4+SeVe4TeGRKww==} dependencies: @@ -4779,6 +4802,10 @@ packages: tslib: 2.4.1 dev: false + /path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + dev: false + /path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -5038,7 +5065,6 @@ packages: /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: true /quick-lru@4.0.1: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} @@ -5205,7 +5231,6 @@ packages: /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: true /rework@1.0.1: resolution: {integrity: sha512-eEjL8FdkdsxApd0yWVZgBGzfCQiT8yqSc2H1p4jpZpQdtz7ohETiDMoje5PlM8I9WgkqkreVxFUKYOiJdVWDXw==} @@ -5253,7 +5278,6 @@ packages: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: queue-microtask: 1.2.3 - dev: true /sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} @@ -5380,7 +5404,7 @@ packages: '@typescript/twoslash': 3.1.0 '@typescript/vfs': 1.3.4 shiki: 0.10.1 - typescript: 5.0.2 + typescript: 5.0.4 transitivePeerDependencies: - supports-color dev: true @@ -5958,6 +5982,13 @@ packages: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} dev: true + /ts-morph@18.0.0: + resolution: {integrity: sha512-Kg5u0mk19PIIe4islUI/HWRvm9bC1lHejK4S0oh1zaZ77TMZAEmQC0sHQYiu2RgCQFZKXz1fMVi/7nOOeirznA==} + dependencies: + '@ts-morph/common': 0.19.0 + code-block-writer: 12.0.0 + dev: false + /tsconfig-paths@3.14.2: resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==} dependencies: @@ -6052,7 +6083,6 @@ packages: resolution: {integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==} engines: {node: '>=12.20'} hasBin: true - dev: true /ufo@1.1.2: resolution: {integrity: sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==}