mirror of https://github.com/sveltejs/svelte
feat: provide migration function (#11334)
Provides 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 #9239pull/11347/head
parent
f1986da755
commit
cd798077b4
@ -0,0 +1,5 @@
|
||||
---
|
||||
"svelte": patch
|
||||
---
|
||||
|
||||
feat: provide migration helper
|
@ -0,0 +1,770 @@
|
||||
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';
|
||||
import { extract_identifiers } from '../utils/ast.js';
|
||||
import { regex_is_valid_identifier } from '../phases/patterns.js';
|
||||
|
||||
/**
|
||||
* Does a best-effort migration of Svelte code towards using runes, event attributes and render tags.
|
||||
* May throw an error if the code is too complex to migrate automatically.
|
||||
*
|
||||
* @param {string} source
|
||||
* @returns {{ code: 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);
|
||||
const indent = guess_indent(source);
|
||||
|
||||
/** @type {State} */
|
||||
let state = {
|
||||
scope: analysis.instance.scope,
|
||||
analysis,
|
||||
str,
|
||||
indent,
|
||||
props: [],
|
||||
props_insertion_point: parsed.instance?.content.start ?? 0,
|
||||
has_props_rune: false,
|
||||
props_name: analysis.root.unique('props').name,
|
||||
rest_props_name: analysis.root.unique('rest').name,
|
||||
end: source.length,
|
||||
run_name: analysis.root.unique('run').name,
|
||||
needs_run: false
|
||||
};
|
||||
|
||||
if (parsed.instance) {
|
||||
walk(parsed.instance.content, state, instance_script);
|
||||
}
|
||||
|
||||
state = { ...state, scope: analysis.template.scope };
|
||||
walk(parsed.fragment, state, template);
|
||||
|
||||
const run_import = `import { run${state.run_name === 'run' ? '' : `as ${state.run_name}`} } from 'svelte/legacy';`;
|
||||
let added_legacy_import = false;
|
||||
|
||||
if (state.props.length > 0 || analysis.uses_rest_props || analysis.uses_props) {
|
||||
const has_many_props = state.props.length > 3;
|
||||
const props_separator = has_many_props ? `\n${indent}${indent}` : ' ';
|
||||
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(`,${props_separator}`);
|
||||
|
||||
if (analysis.uses_rest_props) {
|
||||
props += `,${props_separator}...${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 uses_ts = parsed.instance?.attributes.some(
|
||||
(attr) => attr.name === 'lang' && /** @type {any} */ (attr).value[0].data === 'ts'
|
||||
);
|
||||
const type_name = state.scope.root.unique('Props').name;
|
||||
let type = '';
|
||||
if (uses_ts) {
|
||||
if (analysis.uses_props || analysis.uses_rest_props) {
|
||||
type = `interface ${type_name} { [key: string]: any }`;
|
||||
} else {
|
||||
type = `interface ${type_name} {${props_separator}${state.props
|
||||
.map((prop) => {
|
||||
return `${prop.exported}${prop.optional ? '?' : ''}: ${prop.type}`;
|
||||
})
|
||||
.join(`,${props_separator}`)}${has_many_props ? `\n${indent}` : ' '}}`;
|
||||
}
|
||||
} else {
|
||||
if (analysis.uses_props || analysis.uses_rest_props) {
|
||||
type = `{Record<string, any>}`;
|
||||
} else {
|
||||
type = `{${state.props
|
||||
.map((prop) => {
|
||||
return `${prop.exported}${prop.optional ? '?' : ''}: ${prop.type}`;
|
||||
})
|
||||
.join(`, `)}}`;
|
||||
}
|
||||
}
|
||||
|
||||
let props_declaration = `let {${props_separator}${props}${has_many_props ? `\n${indent}` : ' '}}`;
|
||||
if (uses_ts) {
|
||||
props_declaration = `${type}\n\n${indent}${props_declaration}`;
|
||||
props_declaration = `${props_declaration}${type ? `: ${type_name}` : ''} = $props();`;
|
||||
} else {
|
||||
props_declaration = `/** @type {${type}} */\n${indent}${props_declaration}`;
|
||||
props_declaration = `${props_declaration} = $props();`;
|
||||
}
|
||||
|
||||
if (parsed.instance) {
|
||||
props_declaration = `\n${indent}${props_declaration}`;
|
||||
str.appendRight(state.props_insertion_point, props_declaration);
|
||||
} else {
|
||||
const imports = state.needs_run ? `${indent}${run_import}\n` : '';
|
||||
str.prepend(`<script>\n${imports}${indent}${props_declaration}\n</script>\n\n`);
|
||||
added_legacy_import = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.needs_run && !added_legacy_import) {
|
||||
if (parsed.instance) {
|
||||
str.appendRight(
|
||||
/** @type {number} */ (parsed.instance.content.start),
|
||||
`\n${indent}${run_import}\n`
|
||||
);
|
||||
} else {
|
||||
str.prepend(`<script>\n${indent}${run_import}\n</script>\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
return { code: str.toString() };
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error while migrating Svelte code');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* scope: import('../phases/scope.js').Scope;
|
||||
* str: MagicString;
|
||||
* analysis: import('../phases/types.js').ComponentAnalysis;
|
||||
* indent: string;
|
||||
* props: Array<{ local: string; exported: string; init: string; bindable: boolean; optional: boolean; type: string }>;
|
||||
* props_insertion_point: number;
|
||||
* has_props_rune: boolean;
|
||||
* props_name: string;
|
||||
* rest_props_name: string;
|
||||
* end: number;
|
||||
* run_name: string;
|
||||
* needs_run: boolean;
|
||||
* }} State
|
||||
*/
|
||||
|
||||
/** @type {import('zimmerframe').Visitors<import('../types/template.js').SvelteNode, State>} */
|
||||
const instance_script = {
|
||||
Identifier(node, { state }) {
|
||||
handle_identifier(node, state);
|
||||
},
|
||||
ExportNamedDeclaration(node, { state, next }) {
|
||||
if (node.declaration) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
let count_removed = 0;
|
||||
for (const specifier of node.specifiers) {
|
||||
const binding = state.scope.get(specifier.local.name);
|
||||
if (binding?.kind === 'bindable_prop') {
|
||||
state.str.remove(
|
||||
/** @type {number} */ (specifier.start),
|
||||
/** @type {number} */ (specifier.end)
|
||||
);
|
||||
count_removed++;
|
||||
}
|
||||
}
|
||||
if (count_removed === node.specifiers.length) {
|
||||
state.str.remove(/** @type {number} */ (node.start), /** @type {number} */ (node.end));
|
||||
}
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
let bindings;
|
||||
try {
|
||||
bindings = state.scope.get_bindings(declarator);
|
||||
} catch (e) {
|
||||
// no bindings, so we can skip this
|
||||
continue;
|
||||
}
|
||||
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 invest time in this?
|
||||
throw new Error(
|
||||
'Encountered an export declaration pattern that is not supported for automigration.'
|
||||
);
|
||||
// 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));
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
: '',
|
||||
optional: !!declarator.init,
|
||||
type: extract_type(declarator, state.str, path),
|
||||
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);
|
||||
}
|
||||
while (state.str.original[start] !== '\n') start--;
|
||||
while (state.str.original[end] !== '\n') end++;
|
||||
state.str.update(start, end, '');
|
||||
}
|
||||
},
|
||||
BreakStatement(node, { state, path }) {
|
||||
if (path[1].type !== 'LabeledStatement') return;
|
||||
if (node.label?.name !== '$') return;
|
||||
state.str.update(
|
||||
/** @type {number} */ (node.start),
|
||||
/** @type {number} */ (node.end),
|
||||
'return;'
|
||||
);
|
||||
},
|
||||
LabeledStatement(node, { path, state, next }) {
|
||||
if (state.analysis.runes) return;
|
||||
if (path.length > 1) return;
|
||||
if (node.label.name !== '$') return;
|
||||
|
||||
next();
|
||||
|
||||
if (
|
||||
node.body.type === 'ExpressionStatement' &&
|
||||
node.body.expression.type === 'AssignmentExpression'
|
||||
) {
|
||||
const ids = extract_identifiers(node.body.expression.left);
|
||||
const bindings = ids.map((id) => state.scope.get(id.name));
|
||||
const reassigned_bindings = bindings.filter((b) => b?.reassigned);
|
||||
if (reassigned_bindings.length === 0 && !bindings.some((b) => b?.kind === 'store_sub')) {
|
||||
// $derived
|
||||
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.update(
|
||||
/** @type {number} */ (node.body.expression.right.end),
|
||||
/** @type {number} */ (node.end),
|
||||
');'
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
for (const binding of reassigned_bindings) {
|
||||
if (binding && ids.includes(binding.node)) {
|
||||
// implicitly-declared variable which we need to make explicit
|
||||
state.str.prependLeft(
|
||||
/** @type {number} */ (node.start),
|
||||
`let ${binding.node.name}${binding.kind === 'state' ? ' = $state()' : ''};\n${state.indent}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.needs_run = true;
|
||||
const is_block_stmt = node.body.type === 'BlockStatement';
|
||||
const start_end = /** @type {number} */ (node.body.start);
|
||||
// TODO try to find out if we can use $derived.by instead?
|
||||
if (is_block_stmt) {
|
||||
state.str.update(
|
||||
/** @type {number} */ (node.start),
|
||||
start_end + 1,
|
||||
`${state.run_name}(() => {`
|
||||
);
|
||||
const end = /** @type {number} */ (node.body.end);
|
||||
state.str.update(end - 1, end, '});');
|
||||
} else {
|
||||
state.str.update(
|
||||
/** @type {number} */ (node.start),
|
||||
start_end,
|
||||
`${state.run_name}(() => {\n${state.indent}`
|
||||
);
|
||||
state.str.indent(state.indent, {
|
||||
exclude: [
|
||||
[0, /** @type {number} */ (node.body.start)],
|
||||
[/** @type {number} */ (node.body.end), state.end]
|
||||
]
|
||||
});
|
||||
state.str.appendRight(/** @type {number} */ (node.end), `\n${state.indent}});`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** @type {import('zimmerframe').Visitors<import('../types/template.js').SvelteNode, State>} */
|
||||
const template = {
|
||||
Identifier(node, { state }) {
|
||||
handle_identifier(node, state);
|
||||
},
|
||||
RegularElement(node, { state, next }) {
|
||||
handle_events(node, state);
|
||||
next();
|
||||
},
|
||||
SvelteElement(node, { state, next }) {
|
||||
handle_events(node, state);
|
||||
next();
|
||||
},
|
||||
SvelteWindow(node, { state, next }) {
|
||||
handle_events(node, state);
|
||||
next();
|
||||
},
|
||||
SvelteBody(node, { state, next }) {
|
||||
handle_events(node, state);
|
||||
next();
|
||||
},
|
||||
SvelteDocument(node, { state, next }) {
|
||||
handle_events(node, state);
|
||||
next();
|
||||
},
|
||||
SlotElement(node, { state, next }) {
|
||||
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 = state.scope.generate(/** @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,
|
||||
optional: true,
|
||||
type: `import('svelte').${slot_props ? 'Snippet<[any]>' : 'Snippet'}`
|
||||
});
|
||||
|
||||
if (node.fragment.nodes.length > 0) {
|
||||
next();
|
||||
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').VariableDeclarator} declarator
|
||||
* @param {MagicString} str
|
||||
* @param {import('#compiler').SvelteNode[]} path
|
||||
*/
|
||||
function extract_type(declarator, str, path) {
|
||||
if (declarator.id.typeAnnotation) {
|
||||
let start = declarator.id.typeAnnotation.start + 1; // skip the colon
|
||||
while (str.original[start] === ' ') {
|
||||
start++;
|
||||
}
|
||||
return str.original.substring(start, declarator.id.typeAnnotation.end);
|
||||
}
|
||||
|
||||
// try to find a comment with a type annotation, hinting at jsdoc
|
||||
const parent = path.at(-1);
|
||||
if (parent?.type === 'ExportNamedDeclaration' && parent.leadingComments) {
|
||||
const last = parent.leadingComments[parent.leadingComments.length - 1];
|
||||
if (last.type === 'Block') {
|
||||
const match = /@type {([^}]+)}/.exec(last.value);
|
||||
if (match) {
|
||||
str.update(/** @type {any} */ (last).start, /** @type {any} */ (last).end, '');
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// try to infer it from the init
|
||||
if (declarator.init?.type === 'Literal') {
|
||||
const type = typeof declarator.init.value;
|
||||
if (type === 'string' || type === 'number' || type === 'boolean') {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
return 'any';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('#compiler').RegularElement | import('#compiler').SvelteElement | import('#compiler').SvelteWindow | import('#compiler').SvelteDocument | import('#compiler').SvelteBody} node
|
||||
* @param {State} state
|
||||
*/
|
||||
function handle_events(node, state) {
|
||||
/** @type {Map<string, import('#compiler').OnDirective[]>} */
|
||||
const handlers = new Map();
|
||||
for (const attribute of node.attributes) {
|
||||
if (attribute.type !== 'OnDirective') continue;
|
||||
|
||||
let name = `on${attribute.name}`;
|
||||
if (attribute.modifiers.includes('capture')) {
|
||||
name += 'capture';
|
||||
}
|
||||
|
||||
const nodes = handlers.get(name) || [];
|
||||
nodes.push(attribute);
|
||||
handlers.set(name, nodes);
|
||||
}
|
||||
|
||||
for (const [name, nodes] of handlers) {
|
||||
// turn on:click into a prop
|
||||
let exported = name;
|
||||
if (!regex_is_valid_identifier.test(name)) {
|
||||
exported = `'${exported}'`;
|
||||
}
|
||||
// Check if prop already set, could happen when on:click on different elements
|
||||
let local = state.props.find((prop) => prop.exported === exported)?.local;
|
||||
|
||||
const last = nodes[nodes.length - 1];
|
||||
const payload_name =
|
||||
last.expression?.type === 'ArrowFunctionExpression' &&
|
||||
last.expression.params[0]?.type === 'Identifier'
|
||||
? last.expression.params[0].name
|
||||
: generate_event_name(last, state);
|
||||
let prepend = '';
|
||||
|
||||
for (let i = 0; i < nodes.length - 1; i += 1) {
|
||||
const node = nodes[i];
|
||||
if (node.expression) {
|
||||
let body = '';
|
||||
if (node.expression.type === 'ArrowFunctionExpression') {
|
||||
body = state.str.original.substring(
|
||||
/** @type {number} */ (node.expression.body.start),
|
||||
/** @type {number} */ (node.expression.body.end)
|
||||
);
|
||||
} else {
|
||||
body = `${state.str.original.substring(
|
||||
/** @type {number} */ (node.expression.start),
|
||||
/** @type {number} */ (node.expression.end)
|
||||
)}();`;
|
||||
}
|
||||
// TODO check how many indents needed
|
||||
for (const modifier of node.modifiers) {
|
||||
if (modifier === 'stopPropagation') {
|
||||
body = `\n${state.indent}${payload_name}.stopPropagation();\n${body}`;
|
||||
} else if (modifier === 'preventDefault') {
|
||||
body = `\n${state.indent}${payload_name}.preventDefault();\n${body}`;
|
||||
} else if (modifier === 'stopImmediatePropagation') {
|
||||
body = `\n${state.indent}${payload_name}.stopImmediatePropagation();\n${body}`;
|
||||
} else {
|
||||
body = `\n${state.indent}// @migration-task: incorporate ${modifier} modifier\n${body}`;
|
||||
}
|
||||
}
|
||||
prepend += `\n${state.indent}${body}\n`;
|
||||
} else {
|
||||
if (!local) {
|
||||
local = state.scope.generate(`on${node.name}`);
|
||||
state.props.push({
|
||||
local,
|
||||
exported,
|
||||
init: '',
|
||||
bindable: false,
|
||||
optional: true,
|
||||
type: '(event: any) => void'
|
||||
});
|
||||
}
|
||||
prepend += `\n${state.indent}${local}?.(${payload_name});\n`;
|
||||
}
|
||||
|
||||
state.str.remove(node.start, node.end);
|
||||
}
|
||||
|
||||
if (last.expression) {
|
||||
// remove : from on:click
|
||||
state.str.remove(last.start + 2, last.start + 3);
|
||||
// remove modifiers
|
||||
if (last.modifiers.length > 0) {
|
||||
state.str.remove(
|
||||
last.start + last.name.length + 3,
|
||||
state.str.original.indexOf('=', last.start)
|
||||
);
|
||||
}
|
||||
if (last.modifiers.includes('capture')) {
|
||||
state.str.appendRight(last.start + last.name.length + 3, 'capture');
|
||||
}
|
||||
|
||||
for (const modifier of last.modifiers) {
|
||||
if (modifier === 'stopPropagation') {
|
||||
prepend += `\n${state.indent}${payload_name}.stopPropagation();\n`;
|
||||
} else if (modifier === 'preventDefault') {
|
||||
prepend += `\n${state.indent}${payload_name}.preventDefault();\n`;
|
||||
} else if (modifier === 'stopImmediatePropagation') {
|
||||
prepend += `\n${state.indent}${payload_name}.stopImmediatePropagation();\n`;
|
||||
} else if (modifier !== 'capture') {
|
||||
prepend += `\n${state.indent}// @migration-task: incorporate ${modifier} modifier\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (prepend) {
|
||||
let pos = last.expression.start;
|
||||
if (last.expression.type === 'ArrowFunctionExpression') {
|
||||
pos = last.expression.body.start;
|
||||
if (
|
||||
last.expression.params.length > 0 &&
|
||||
last.expression.params[0].type !== 'Identifier'
|
||||
) {
|
||||
const start = /** @type {number} */ (last.expression.params[0].start);
|
||||
const end = /** @type {number} */ (last.expression.params[0].end);
|
||||
// replace event payload with generated one that others use,
|
||||
// then destructure generated payload param into what the user wrote
|
||||
state.str.overwrite(start, end, payload_name);
|
||||
prepend = `let ${state.str.original.substring(
|
||||
start,
|
||||
end
|
||||
)} = ${payload_name};\n${prepend}`;
|
||||
} else if (last.expression.params.length === 0) {
|
||||
// add generated payload param to arrow function
|
||||
const pos = state.str.original.lastIndexOf(')', last.expression.body.start);
|
||||
state.str.prependLeft(pos, payload_name);
|
||||
}
|
||||
|
||||
const needs_curlies = last.expression.body.type !== 'BlockStatement';
|
||||
state.str.prependRight(
|
||||
/** @type {number} */ (pos) + (needs_curlies ? 0 : 1),
|
||||
`${needs_curlies ? '{' : ''}${prepend}${state.indent}`
|
||||
);
|
||||
state.str.appendRight(
|
||||
/** @type {number} */ (last.expression.body.end) - (needs_curlies ? 0 : 1),
|
||||
`\n${needs_curlies ? '}' : ''}`
|
||||
);
|
||||
} else {
|
||||
state.str.update(
|
||||
/** @type {number} */ (last.expression.start),
|
||||
/** @type {number} */ (last.expression.end),
|
||||
`(${payload_name}) => {${prepend}\n${state.indent}${state.str.original.substring(
|
||||
/** @type {number} */ (last.expression.start),
|
||||
/** @type {number} */ (last.expression.end)
|
||||
)}?.(${payload_name});\n}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// turn on:click into a prop
|
||||
// Check if prop already set, could happen when on:click on different elements
|
||||
if (!local) {
|
||||
local = state.scope.generate(`on${last.name}`);
|
||||
state.props.push({
|
||||
local,
|
||||
exported,
|
||||
init: '',
|
||||
bindable: false,
|
||||
optional: true,
|
||||
type: '(event: any) => void'
|
||||
});
|
||||
}
|
||||
|
||||
let replacement = '';
|
||||
if (!prepend) {
|
||||
if (exported === local) {
|
||||
replacement = `{${name}}`;
|
||||
} else {
|
||||
replacement = `${name}={${local}}`;
|
||||
}
|
||||
} else {
|
||||
replacement = `${name}={(${payload_name}) => {${prepend}\n${state.indent}${local}?.(${payload_name});\n}}`;
|
||||
}
|
||||
|
||||
state.str.update(last.start, last.end, replacement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('#compiler').OnDirective} last
|
||||
* @param {State} state
|
||||
*/
|
||||
function generate_event_name(last, state) {
|
||||
const scope =
|
||||
(last.expression && state.analysis.template.scopes.get(last.expression)) || state.scope;
|
||||
|
||||
let name = 'event';
|
||||
if (!scope.get(name)) return name;
|
||||
|
||||
let i = 1;
|
||||
while (scope.get(`${name}${i}`)) i += 1;
|
||||
return `${name}${i}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} content */
|
||||
function guess_indent(content) {
|
||||
const lines = content.split('\n');
|
||||
|
||||
const tabbed = lines.filter((line) => /^\t+/.test(line));
|
||||
const spaced = lines.filter((line) => /^ {2,}/.test(line));
|
||||
|
||||
if (tabbed.length === 0 && spaced.length === 0) {
|
||||
return '\t';
|
||||
}
|
||||
|
||||
// More lines tabbed than spaced? Assume tabs, and
|
||||
// default to tabs in the case of a tie (or nothing
|
||||
// to go on)
|
||||
if (tabbed.length >= spaced.length) {
|
||||
return '\t';
|
||||
}
|
||||
|
||||
// Otherwise, we need to guess the multiple
|
||||
const min = spaced.reduce((previous, current) => {
|
||||
const count = /^ +/.exec(current)?.[0].length ?? 0;
|
||||
return Math.min(count, previous);
|
||||
}, Infinity);
|
||||
|
||||
return ' '.repeat(min);
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
<script>
|
||||
let count = 0;
|
||||
$: doubled = count * 2;
|
||||
$: ({ quadrupled } = { quadrupled: count * 4 });
|
||||
</script>
|
||||
|
||||
{count} / {doubled} / {quadrupled}
|
@ -0,0 +1,7 @@
|
||||
<script>
|
||||
let count = 0;
|
||||
let doubled = $derived(count * 2);
|
||||
let { quadrupled } = $derived({ quadrupled: count * 4 });
|
||||
</script>
|
||||
|
||||
{count} / {doubled} / {quadrupled}
|
@ -0,0 +1,13 @@
|
||||
<script>
|
||||
let count = 0;
|
||||
$: console.log(count);
|
||||
$: if (count > 10) {
|
||||
alert('too high')
|
||||
}
|
||||
$: {
|
||||
console.log('foo');
|
||||
if (x) break $;
|
||||
console.log('bar');
|
||||
}
|
||||
$: $count = 1;
|
||||
</script>
|
@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
let count = 0;
|
||||
run(() => {
|
||||
console.log(count);
|
||||
});
|
||||
run(() => {
|
||||
if (count > 10) {
|
||||
alert('too high')
|
||||
}
|
||||
});
|
||||
run(() => {
|
||||
console.log('foo');
|
||||
if (x) return;
|
||||
console.log('bar');
|
||||
});
|
||||
run(() => {
|
||||
$count = 1;
|
||||
});
|
||||
</script>
|
@ -0,0 +1,17 @@
|
||||
<button on:click={() => console.log('hi')} on:click>click me</button>
|
||||
<button on:click={() => console.log('before')} on:click on:click={() => console.log('after')}>click me</button>
|
||||
<button on:click on:click={foo}>click me</button>
|
||||
<button on:click>click me</button>
|
||||
|
||||
<button on:dblclick={() => console.log('hi')}>click me</button>
|
||||
<button on:toggle>click me</button>
|
||||
<button on:custom-event={() => 'hi'}>click me</button>
|
||||
<button on:custom-event-bubble>click me</button>
|
||||
|
||||
<button on:click|preventDefault={() => ''}>click me</button>
|
||||
<button on:click|stopPropagation={() => {}}>click me</button>
|
||||
<button on:click|stopImmediatePropagation={() => ''}>click me</button>
|
||||
<button on:click|capture={() => ''}>click me</button>
|
||||
<button on:click|self={() => ''}>click me</button>
|
||||
|
||||
<Button on:click={() => 'leave untouched'} on:click>click me</Button>
|
@ -0,0 +1,47 @@
|
||||
<script>
|
||||
/** @type {{onclick?: (event: any) => void, ontoggle?: (event: any) => void, 'oncustom-event-bubble'?: (event: any) => void}} */
|
||||
let { onclick, ontoggle, 'oncustom-event-bubble': oncustom_event_bubble } = $props();
|
||||
</script>
|
||||
|
||||
<button onclick={(event) => {
|
||||
console.log('hi')
|
||||
|
||||
onclick?.(event);
|
||||
}}>click me</button>
|
||||
<button onclick={(event) => {
|
||||
console.log('before')
|
||||
|
||||
onclick?.(event);
|
||||
console.log('after')
|
||||
}}>click me</button>
|
||||
<button onclick={(event) => {
|
||||
onclick?.(event);
|
||||
|
||||
foo?.(event);
|
||||
}}>click me</button>
|
||||
<button {onclick}>click me</button>
|
||||
|
||||
<button ondblclick={() => console.log('hi')}>click me</button>
|
||||
<button {ontoggle}>click me</button>
|
||||
<button oncustom-event={() => 'hi'}>click me</button>
|
||||
<button oncustom-event-bubble={oncustom_event_bubble}>click me</button>
|
||||
|
||||
<button onclick={(event) => {
|
||||
event.preventDefault();
|
||||
''
|
||||
}}>click me</button>
|
||||
<button onclick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
}}>click me</button>
|
||||
<button onclick={(event) => {
|
||||
event.stopImmediatePropagation();
|
||||
''
|
||||
}}>click me</button>
|
||||
<button onclickcapture={() => ''}>click me</button>
|
||||
<button onclick={(event) => {
|
||||
// @migration-task: incorporate self modifier
|
||||
''
|
||||
}}>click me</button>
|
||||
|
||||
<Button on:click={() => 'leave untouched'} on:click>click me</Button>
|
@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
let klass = '';
|
||||
export { klass as class }
|
||||
</script>
|
||||
|
||||
{klass}
|
@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
interface Props { class?: string }
|
||||
|
||||
let { class: klass = '' }: Props = $props();
|
||||
|
||||
</script>
|
||||
|
||||
{klass}
|
@ -0,0 +1,5 @@
|
||||
<script>
|
||||
export let foo;
|
||||
</script>
|
||||
|
||||
<button {foo} {...$$restProps}>click me</button>
|
@ -0,0 +1,6 @@
|
||||
<script>
|
||||
/** @type {{Record<string, any>}} */
|
||||
let { foo, ...rest } = $props();
|
||||
</script>
|
||||
|
||||
<button {foo} {...rest}>click me</button>
|
@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
export let readonly: number;
|
||||
export let optional = 'foo';
|
||||
export let binding: string;
|
||||
export let bindingOptional: string | undefined = 'bar';
|
||||
</script>
|
||||
|
||||
{readonly}
|
||||
{optional}
|
||||
<input bind:value={binding} />
|
||||
<input bind:value={bindingOptional} />
|
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
readonly: number,
|
||||
optional?: string,
|
||||
binding: string,
|
||||
bindingOptional?: string | undefined
|
||||
}
|
||||
|
||||
let {
|
||||
readonly,
|
||||
optional = 'foo',
|
||||
binding = $bindable(),
|
||||
bindingOptional = $bindable('bar')
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{readonly}
|
||||
{optional}
|
||||
<input bind:value={binding} />
|
||||
<input bind:value={bindingOptional} />
|
@ -0,0 +1,11 @@
|
||||
<script>
|
||||
export let readonly;
|
||||
export let optional = 'foo';
|
||||
export let binding;
|
||||
export let bindingOptional = 'bar';
|
||||
</script>
|
||||
|
||||
{readonly}
|
||||
{optional}
|
||||
<input bind:value={binding} />
|
||||
<input bind:value={bindingOptional} />
|
@ -0,0 +1,14 @@
|
||||
<script>
|
||||
/** @type {{readonly: any, optional?: string, binding: any, bindingOptional?: string}} */
|
||||
let {
|
||||
readonly,
|
||||
optional = 'foo',
|
||||
binding = $bindable(),
|
||||
bindingOptional = $bindable('bar')
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{readonly}
|
||||
{optional}
|
||||
<input bind:value={binding} />
|
||||
<input bind:value={bindingOptional} />
|
@ -0,0 +1,7 @@
|
||||
<button><slot /></button>
|
||||
|
||||
{#if foo}
|
||||
<slot name="foo" {foo} />
|
||||
{/if}
|
||||
|
||||
<slot name="dashed-name" />
|
@ -0,0 +1,12 @@
|
||||
<script>
|
||||
/** @type {{children?: import('svelte').Snippet, foo_1?: import('svelte').Snippet<[any]>, dashed_name?: import('svelte').Snippet}} */
|
||||
let { children, foo_1, dashed_name } = $props();
|
||||
</script>
|
||||
|
||||
<button>{@render children?.()}</button>
|
||||
|
||||
{#if foo}
|
||||
{@render foo_1?.({ foo, })}
|
||||
{/if}
|
||||
|
||||
{@render dashed_name?.()}
|
@ -0,0 +1,30 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { assert } from 'vitest';
|
||||
import { migrate } from 'svelte/compiler';
|
||||
import { try_read_file } from '../helpers.js';
|
||||
import { suite, type BaseTest } from '../suite.js';
|
||||
|
||||
interface ParserTest extends BaseTest {}
|
||||
|
||||
const { test, run } = suite<ParserTest>(async (config, cwd) => {
|
||||
const input = fs
|
||||
.readFileSync(`${cwd}/input.svelte`, 'utf-8')
|
||||
.replace(/\s+$/, '')
|
||||
.replace(/\r/g, '');
|
||||
|
||||
const actual = migrate(input).code;
|
||||
|
||||
// run `UPDATE_SNAPSHOTS=true pnpm test migrate` to update parser tests
|
||||
if (process.env.UPDATE_SNAPSHOTS || !fs.existsSync(`${cwd}/output.svelte`)) {
|
||||
fs.writeFileSync(`${cwd}/output.svelte`, actual);
|
||||
} else {
|
||||
fs.writeFileSync(`${cwd}/_actual.svelte`, actual);
|
||||
|
||||
const expected = try_read_file(`${cwd}/output.svelte`);
|
||||
assert.deepEqual(actual, expected);
|
||||
}
|
||||
});
|
||||
|
||||
export { test };
|
||||
|
||||
await run(__dirname);
|
@ -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>
|
Loading…
Reference in new issue