feat: provide migration function

Provides the start of a migration function, exported as `migrate` from `svelte/compiler`, which tries its best to automatically migrate towards runes, render tags (instead of slots) and event attributes (instead of event handlers)

The preview REPL was updated with a migrate button so people can try it out in the playground.

closes #9239
pull/11334/head
Simon Holthausen 2 years ago
parent 6ad5cd4461
commit 9886329fd8

@ -190,5 +190,5 @@ export function walk() {
} }
export { CompileError } from './errors.js'; export { CompileError } from './errors.js';
export { VERSION } from '../version.js'; export { VERSION } from '../version.js';
export { migrate } from './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(`<script>${props_declaration}</script>`);
}
}
}
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<import('../types/template.js').SvelteNode, State>} */
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<import('../types/template.js').SvelteNode, State>} */
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
);
}
}

@ -1086,6 +1086,10 @@ declare module 'svelte/compiler' {
* https://svelte.dev/docs/svelte-compiler#svelte-version * https://svelte.dev/docs/svelte-compiler#svelte-version
* */ * */
export const VERSION: string; 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 { class Scope {
constructor(root: ScopeRoot, parent: Scope | null, porous: boolean); constructor(root: ScopeRoot, parent: Scope | null, porous: boolean);

@ -3,7 +3,7 @@ import * as path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import glob from 'tiny-glob/sync.js'; import glob from 'tiny-glob/sync.js';
import minimist from 'minimist'; 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)); 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')); 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, { const compiled = compile(source, {

@ -3,6 +3,7 @@
import { get_full_filename } from '$lib/utils.js'; import { get_full_filename } from '$lib/utils.js';
import { createEventDispatcher, tick } from 'svelte'; import { createEventDispatcher, tick } from 'svelte';
import RunesInfo from './RunesInfo.svelte'; import RunesInfo from './RunesInfo.svelte';
import Migrate from './Migrate.svelte';
/** @type {boolean} */ /** @type {boolean} */
export let show_modified; export let show_modified;
@ -296,6 +297,8 @@
</button> </button>
<div class="runes-info"><RunesInfo {runes} /></div> <div class="runes-info"><RunesInfo {runes} /></div>
<div class="migrate-info"><Migrate /></div>
</div> </div>
<style> <style>
@ -413,6 +416,13 @@
justify-content: flex-end; justify-content: flex-end;
} }
.migrate-info {
flex: 0 1 0;
display: flex;
align-items: center;
justify-content: flex-end;
}
.drag-handle { .drag-handle {
cursor: move; cursor: move;
width: 5px; width: 5px;

@ -0,0 +1,22 @@
<script>
import { get_repl_context } from '$lib/context.js';
const { migrate } = get_repl_context();
</script>
<div class="container">
<button on:click={migrate} title="Migrate this component towards the new syntax">migrate</button>
</div>
<style>
button {
position: relative;
display: flex;
text-transform: uppercase;
font-size: 1.4rem;
padding: 0.8rem;
gap: 0.5rem;
margin-right: 0.3rem;
z-index: 9999;
}
</style>

@ -67,6 +67,24 @@ export default class Compiler {
}); });
} }
/**
* @param {import('$lib/types').File} file
* @returns {Promise<import('$lib/workers/workers').MigrateMessageData>}
*/
migrate(file) {
return new Promise((fulfil) => {
const id = uid++;
this.handlers.set(id, fulfil);
this.worker.postMessage({
id,
type: 'migrate',
source: file.source
});
});
}
destroy() { destroy() {
this.worker.terminate(); this.worker.terminate();
} }

@ -137,6 +137,7 @@
EDITOR_STATE_MAP, EDITOR_STATE_MAP,
rebundle, rebundle,
migrate,
clear_state, clear_state,
go_to_warning_pos, go_to_warning_pos,
handle_change, handle_change,
@ -156,6 +157,27 @@
resolver(); 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; let is_select_changing = false;
/** /**

@ -65,6 +65,7 @@ export type ReplContext = {
// Methods // Methods
rebundle(): Promise<void>; rebundle(): Promise<void>;
migrate(): Promise<void>;
handle_select(filename: string): Promise<void>; handle_select(filename: string): Promise<void>;
handle_change( handle_change(
event: CustomEvent<{ event: CustomEvent<{

@ -41,6 +41,11 @@ self.addEventListener(
await ready; await ready;
postMessage(compile(event.data)); postMessage(compile(event.data));
break; 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
};
}
}

@ -26,3 +26,9 @@ export type BundleMessageData = {
svelte_url: string; svelte_url: string;
files: File[]; files: File[];
}; };
export type MigrateMessageData = {
id: number;
source: string;
error?: string;
};

Loading…
Cancel
Save