diff --git a/packages/svelte/src/compiler/index.js b/packages/svelte/src/compiler/index.js index 97dcc364f0..ba312a1a63 100644 --- a/packages/svelte/src/compiler/index.js +++ b/packages/svelte/src/compiler/index.js @@ -190,5 +190,5 @@ export function walk() { } export { CompileError } from './errors.js'; - export { VERSION } from '../version.js'; +export { migrate } from './migrate/index.js'; diff --git a/packages/svelte/src/compiler/migrate/index.js b/packages/svelte/src/compiler/migrate/index.js new file mode 100644 index 0000000000..d5e17153e5 --- /dev/null +++ b/packages/svelte/src/compiler/migrate/index.js @@ -0,0 +1,369 @@ +import MagicString from 'magic-string'; +import { walk } from 'zimmerframe'; +import { parse } from '../phases/1-parse/index.js'; +import { analyze_component } from '../phases/2-analyze/index.js'; +import { validate_component_options } from '../validate-options.js'; +import { get_rune } from '../phases/scope.js'; +import { reset_warnings } from '../warnings.js'; + +/** + * Does a best-effort migration of Svelte code towards using runes, event attributes and render tags. + * @param {string} source + * @returns {string} + */ +export function migrate(source) { + try { + reset_warnings({ source, filename: 'migrate.svelte' }); + + let parsed = parse(source); + + const { customElement: customElementOptions, ...parsed_options } = parsed.options || {}; + + /** @type {import('#compiler').ValidatedCompileOptions} */ + const combined_options = { + ...validate_component_options({}, ''), + ...parsed_options, + customElementOptions + }; + + const str = new MagicString(source); + const analysis = analyze_component(parsed, source, combined_options); + + /** @type {State} */ + let state = { + scope: analysis.instance.scope, + analysis, + str, + props: [], + props_insertion_point: 0, + has_props_rune: false, + props_name: analysis.root.unique('props').name, + rest_props_name: analysis.root.unique('rest').name + }; + + if (parsed.instance) { + walk(parsed.instance.content, state, instance_script); + } + + state = { ...state, scope: analysis.template.scope }; + walk(parsed.fragment, state, template); + + if (state.props.length > 0 || analysis.uses_rest_props || analysis.uses_props) { + let props = ''; + if (analysis.uses_props) { + props = `...${state.props_name}`; + } else { + props = state.props + .map((prop) => { + let prop_str = + prop.local === prop.exported ? prop.local : `${prop.exported}: ${prop.local}`; + if (prop.bindable) { + prop_str += ` = $bindable(${prop.init})`; + } else if (prop.init) { + prop_str += ` = ${prop.init}`; + } + return prop_str; + }) + .join(', '); + + if (analysis.uses_rest_props) { + props += `, ...${state.rest_props_name}`; + } + } + + if (state.has_props_rune) { + // some render tags or forwarded event attributes to add + str.appendRight(state.props_insertion_point, ` ${props},`); + } else { + const props_declaration = `let { ${props} } = $props();`; + if (parsed.instance) { + if (state.props_insertion_point === 0) { + // no regular props found, but render tags or events to forward found, $props() will be first in the script tag + str.appendRight( + /** @type {number} */ (parsed.instance.content.start), + `\n\t${props_declaration}` + ); + } else { + str.appendRight(state.props_insertion_point, props_declaration); + } + } else { + str.prepend(``); + } + } + } + + return str.toString(); + } catch (e) { + console.error('Error while migrating Svelte code'); + throw e; + } +} + +/** + * @typedef {{ + * scope: import('../phases/scope.js').Scope; + * str: MagicString; + * analysis: import('../phases/types.js').ComponentAnalysis; + * props: Array<{ local: string; exported: string; init: string; bindable: boolean }>; + * props_insertion_point: number; + * has_props_rune: boolean; + * props_name: string; + * rest_props_name: string; + * }} State + */ + +/** @type {import('zimmerframe').Visitors} */ +const instance_script = { + Identifier(node, { state }) { + handle_identifier(node, state); + }, + VariableDeclaration(node, { state, path }) { + if (state.scope !== state.analysis.instance.scope) { + return; + } + + let nr_of_props = 0; + + for (const declarator of node.declarations) { + if (state.analysis.runes) { + if (get_rune(declarator.init, state.scope) === '$props') { + state.props_insertion_point = /** @type {number} */ (declarator.id.start) + 1; + state.has_props_rune = true; + } + continue; + } + + const bindings = state.scope.get_bindings(declarator); + const has_state = bindings.some((binding) => binding.kind === 'state'); + const has_props = bindings.some((binding) => binding.kind === 'bindable_prop'); + + if (!has_state && !has_props) { + continue; + } + + if (has_props) { + nr_of_props++; + + if (declarator.id.type !== 'Identifier') { + // TODO + // Turn export let into props. It's really really weird because export let { x: foo, z: [bar]} = .. + // means that foo and bar are the props (i.e. the leafs are the prop names), not x and z. + // const tmp = state.scope.generate('tmp'); + // const paths = extract_paths(declarator.id); + // state.props_pre.push( + // b.declaration('const', b.id(tmp), visit(declarator.init!) as Expression) + // ); + // for (const path of paths) { + // const name = (path.node as Identifier).name; + // const binding = state.scope.get(name)!; + // const value = path.expression!(b.id(tmp)); + // if (binding.kind === 'bindable_prop' || binding.kind === 'rest_prop') { + // state.props.push({ + // local: name, + // exported: binding.prop_alias ? binding.prop_alias : name, + // init: value + // }); + // state.props_insertion_point = /** @type {number} */(declarator.end); + // } else { + // declarations.push(b.declarator(path.node, value)); + // } + // } + continue; + } + + const binding = /** @type {import('#compiler').Binding} */ ( + state.scope.get(declarator.id.name) + ); + + if ( + state.analysis.uses_props && + (declarator.init || binding.mutated || binding.reassigned) + ) { + throw new Error( + '$$props is used together with named props in a way that cannot be automatically migrated.' + ); + } + + state.props.push({ + local: declarator.id.name, + exported: binding.prop_alias ? binding.prop_alias : declarator.id.name, + init: declarator.init + ? state.str.original.substring( + /** @type {number} */ (declarator.init.start), + /** @type {number} */ (declarator.init.end) + ) + : '', + bindable: binding.mutated || binding.reassigned + }); + state.props_insertion_point = /** @type {number} */ (declarator.end); + state.str.update( + /** @type {number} */ (declarator.start), + /** @type {number} */ (declarator.end), + '' + ); + + continue; + } + + // state + if (declarator.init) { + state.str.prependLeft(/** @type {number} */ (declarator.init.start), '$state('); + state.str.appendRight(/** @type {number} */ (declarator.init.end), ')'); + } else { + state.str.prependLeft(/** @type {number} */ (declarator.id.end), ' = $state()'); + } + } + + if (nr_of_props === node.declarations.length) { + let start = /** @type {number} */ (node.start); + let end = /** @type {number} */ (node.end); + + const parent = path.at(-1); + if (parent?.type === 'ExportNamedDeclaration') { + start = /** @type {number} */ (parent.start); + end = /** @type {number} */ (parent.end); + } + state.str.update(start, end, ''); + } + }, + LabeledStatement(node, { path, state }) { + if (state.analysis.runes) return; + if (path.length > 1) return; + if (node.label.name !== '$') return; + + if ( + node.body.type === 'ExpressionStatement' && + node.body.expression.type === 'AssignmentExpression' + ) { + // $derived + // TODO $: ({ x } = ...) + state.str.update( + /** @type {number} */ (node.start), + /** @type {number} */ (node.body.expression.start), + 'let ' + ); + state.str.prependLeft(/** @type {number} */ (node.body.expression.right.start), '$derived('); + state.str.appendRight(/** @type {number} */ (node.body.expression.right.end), ')'); + } else { + // $effect.pre, to be precise, but we gloss over that + // TODO try to find out if we can use $derived.by instead? + // TODO SSR mode variant needed + state.str.update( + /** @type {number} */ (node.start), + /** @type {number} */ (node.body.start), + '$effect(() => {' + ); + state.str.appendRight(/** @type {number} */ (node.end), '})'); + } + } +}; + +/** @type {import('zimmerframe').Visitors} */ +const template = { + Identifier(node, { state }) { + handle_identifier(node, state); + }, + OnDirective(node, { state, path }) { + const parent = path.at(-1); + if ( + parent?.type === 'SvelteSelf' || + parent?.type === 'SvelteComponent' || + parent?.type === 'Component' + ) { + return; + } + + if (node.expression) { + // remove : from on:click + state.str.update(node.start, node.start + 3, 'on'); + } else { + // turn on:click into a prop + // Check if prop already set, could happen when on:click on different elements + // TODO what to do when this results in a variable name clash? + if (!state.props.some((prop) => prop.local === node.name)) { + state.props.push({ + local: node.name, + exported: node.name, + init: '', + bindable: false + }); + } + + state.str.update(node.start, node.end, `{${node.name}}`); + } + }, + SlotElement(node, { state }) { + let name = 'children'; + let slot_props = '{ '; + + for (const attr of node.attributes) { + if (attr.type === 'SpreadAttribute') { + slot_props += `...${state.str.original.substring(/** @type {number} */ (attr.expression.start), attr.expression.end)}, `; + } else if (attr.type === 'Attribute') { + if (attr.name === 'name') { + name = /** @type {any} */ (attr.value)[0].data; + } else { + const value = + attr.value !== true + ? state.str.original.substring( + attr.value[0].start, + attr.value[attr.value.length - 1].end + ) + : 'true'; + slot_props += value === attr.name ? `${value}, ` : `${attr.name}: ${value}, `; + } + } + } + + slot_props += '}'; + if (slot_props === '{ }') { + slot_props = ''; + } + + state.props.push({ + local: name, + exported: name, + init: '', + bindable: false + }); + + if (node.fragment.nodes.length > 0) { + state.str.update( + node.start, + node.fragment.nodes[0].start, + `{#if ${name}}{@render ${name}(${slot_props})}{:else}` + ); + state.str.update(node.fragment.nodes[node.fragment.nodes.length - 1].end, node.end, '{/if}'); + } else { + state.str.update(node.start, node.end, `{@render ${name}?.(${slot_props})}`); + } + } +}; + +/** + * @param {import('estree').Identifier} node + * @param {State} state + */ +function handle_identifier(node, state) { + if (state.analysis.uses_props) { + if (node.name === '$$props' || node.name === '$$restProps') { + // not 100% correct for $$restProps but it'll do + state.str.update( + /** @type {number} */ (node.start), + /** @type {number} */ (node.end), + state.props_name + ); + } else { + const binding = state.scope.get(node.name); + if (binding?.kind === 'bindable_prop') { + state.str.prependLeft(/** @type {number} */ (node.start), `${state.props_name}.`); + } + } + } else if (node.name === '$$restProps' && state.analysis.uses_rest_props) { + state.str.update( + /** @type {number} */ (node.start), + /** @type {number} */ (node.end), + state.rest_props_name + ); + } +} diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 9540b52e57..dc5d4009f9 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1086,6 +1086,10 @@ declare module 'svelte/compiler' { * https://svelte.dev/docs/svelte-compiler#svelte-version * */ export const VERSION: string; + /** + * Does a best-effort migration of Svelte code towards using runes, event attributes and render tags. + * */ + export function migrate(source: string): string; class Scope { constructor(root: ScopeRoot, parent: Scope | null, porous: boolean); diff --git a/playgrounds/sandbox/run.js b/playgrounds/sandbox/run.js index d292d640ba..a6589e993d 100644 --- a/playgrounds/sandbox/run.js +++ b/playgrounds/sandbox/run.js @@ -3,7 +3,7 @@ import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import glob from 'tiny-glob/sync.js'; import minimist from 'minimist'; -import { compile, compileModule, parse } from 'svelte/compiler'; +import { compile, compileModule, parse, migrate } from 'svelte/compiler'; const argv = minimist(process.argv.slice(2)); @@ -47,6 +47,13 @@ for (const generate of ['client', 'server']) { }); fs.writeFileSync(`${cwd}/output/${file}.json`, JSON.stringify(ast, null, '\t')); + + try { + const migrated = migrate(source); + fs.writeFileSync(`${cwd}/output/${file}.migrated.svelte`, migrated); + } catch (e) { + console.warn(`Error migrating ${file}`, e); + } } const compiled = compile(source, { diff --git a/sites/svelte-5-preview/src/lib/Input/ComponentSelector.svelte b/sites/svelte-5-preview/src/lib/Input/ComponentSelector.svelte index 71cd9241b8..c63ca416cf 100644 --- a/sites/svelte-5-preview/src/lib/Input/ComponentSelector.svelte +++ b/sites/svelte-5-preview/src/lib/Input/ComponentSelector.svelte @@ -3,6 +3,7 @@ import { get_full_filename } from '$lib/utils.js'; import { createEventDispatcher, tick } from 'svelte'; import RunesInfo from './RunesInfo.svelte'; + import Migrate from './Migrate.svelte'; /** @type {boolean} */ export let show_modified; @@ -296,6 +297,8 @@
+ +
diff --git a/sites/svelte-5-preview/src/lib/Output/Compiler.js b/sites/svelte-5-preview/src/lib/Output/Compiler.js index da11186a2f..ae36368241 100644 --- a/sites/svelte-5-preview/src/lib/Output/Compiler.js +++ b/sites/svelte-5-preview/src/lib/Output/Compiler.js @@ -67,6 +67,24 @@ export default class Compiler { }); } + /** + * @param {import('$lib/types').File} file + * @returns {Promise} + */ + migrate(file) { + return new Promise((fulfil) => { + const id = uid++; + + this.handlers.set(id, fulfil); + + this.worker.postMessage({ + id, + type: 'migrate', + source: file.source + }); + }); + } + destroy() { this.worker.terminate(); } diff --git a/sites/svelte-5-preview/src/lib/Repl.svelte b/sites/svelte-5-preview/src/lib/Repl.svelte index fd30aca10e..40f03d1730 100644 --- a/sites/svelte-5-preview/src/lib/Repl.svelte +++ b/sites/svelte-5-preview/src/lib/Repl.svelte @@ -137,6 +137,7 @@ EDITOR_STATE_MAP, rebundle, + migrate, clear_state, go_to_warning_pos, handle_change, @@ -156,6 +157,27 @@ resolver(); } + async function migrate() { + if (!compiler || $selected?.type !== 'svelte') return; + + const result = await compiler.migrate($selected); + if (result.error) { + // TODO show somehow + return; + } + + const new_files = $files.map((file) => { + if (file.name === $selected?.name) { + return { + ...file, + source: result.source + }; + } + return file; + }); + set({ files: new_files }); + } + let is_select_changing = false; /** diff --git a/sites/svelte-5-preview/src/lib/types.d.ts b/sites/svelte-5-preview/src/lib/types.d.ts index 7f978aec69..a758846d29 100644 --- a/sites/svelte-5-preview/src/lib/types.d.ts +++ b/sites/svelte-5-preview/src/lib/types.d.ts @@ -65,6 +65,7 @@ export type ReplContext = { // Methods rebundle(): Promise; + migrate(): Promise; handle_select(filename: string): Promise; handle_change( event: CustomEvent<{ diff --git a/sites/svelte-5-preview/src/lib/workers/compiler/index.js b/sites/svelte-5-preview/src/lib/workers/compiler/index.js index d264e702a0..8f691f0cec 100644 --- a/sites/svelte-5-preview/src/lib/workers/compiler/index.js +++ b/sites/svelte-5-preview/src/lib/workers/compiler/index.js @@ -41,6 +41,11 @@ self.addEventListener( await ready; postMessage(compile(event.data)); break; + + case 'migrate': + await ready; + postMessage(migrate(event.data)); + break; } } ); @@ -127,3 +132,24 @@ function compile({ id, source, options, return_ast }) { }; } } + +/** @param {import("../workers").MigrateMessageData} param0 */ +function migrate({ id, source }) { + try { + source = svelte.migrate(source); + + return { + id, + source + }; + } catch (err) { + // @ts-ignore + let message = `/*\nError migrating ${err.filename ?? 'component'}:\n${err.message}\n*/`; + + return { + id, + source, + error: message + }; + } +} diff --git a/sites/svelte-5-preview/src/lib/workers/workers.d.ts b/sites/svelte-5-preview/src/lib/workers/workers.d.ts index 47238b4812..0903194839 100644 --- a/sites/svelte-5-preview/src/lib/workers/workers.d.ts +++ b/sites/svelte-5-preview/src/lib/workers/workers.d.ts @@ -26,3 +26,9 @@ export type BundleMessageData = { svelte_url: string; files: File[]; }; + +export type MigrateMessageData = { + id: number; + source: string; + error?: string; +};