UNPKG

svelte

Version:

Cybernetically enhanced web apps

1,525 lines (1,397 loc) • 64.1 kB
/** @import { VariableDeclarator, Node, Identifier, AssignmentExpression, LabeledStatement, ExpressionStatement } from 'estree' */ /** @import { Visitors } from 'zimmerframe' */ /** @import { ComponentAnalysis } from '../phases/types.js' */ /** @import { Scope } from '../phases/scope.js' */ /** @import { AST, Binding, ValidatedCompileOptions } from '#compiler' */ import MagicString from 'magic-string'; import { walk } from 'zimmerframe'; import { parse } from '../phases/1-parse/index.js'; import { regex_valid_component_name } from '../phases/1-parse/state/element.js'; import { analyze_component } from '../phases/2-analyze/index.js'; import { get_rune } from '../phases/scope.js'; import { reset, reset_warning_filter } from '../state.js'; import { extract_identifiers, extract_all_identifiers_from_expression, is_text_attribute } from '../utils/ast.js'; import { migrate_svelte_ignore } from '../utils/extract_svelte_ignore.js'; import { validate_component_options } from '../validate-options.js'; import { is_reserved, is_svg, is_void } from '../../utils.js'; import { regex_is_valid_identifier } from '../phases/patterns.js'; const regex_style_tags = /(<style[^>]+>)([\S\s]*?)(<\/style>)/g; const style_placeholder = '/*$$__STYLE_CONTENT__$$*/'; let has_migration_task = false; class MigrationError extends Error { /** * @param {string} msg */ constructor(msg) { super(msg); } } /** * * @param {State} state */ function migrate_css(state) { if (!state.analysis.css.ast?.start) return; const css_contents = state.str .snip(state.analysis.css.ast.start, /** @type {number} */ (state.analysis.css.ast?.end)) .toString(); let code = css_contents; let starting = 0; // since we already blank css we can't work directly on `state.str` so we will create a copy that we can update const str = new MagicString(code); while (code) { if ( code.startsWith(':has') || code.startsWith(':is') || code.startsWith(':where') || code.startsWith(':not') ) { let start = code.indexOf('(') + 1; let is_global = false; const global_str = ':global'; const next_global = code.indexOf(global_str); const str_between = code.substring(start, next_global); if (!str_between.trim()) { is_global = true; start += global_str.length; } else { const prev_global = css_contents.lastIndexOf(global_str, starting); if (prev_global > -1) { const end = find_closing_parenthesis(css_contents.indexOf('(', prev_global) + 1, css_contents) - starting; if (end > start) { starting += end; code = code.substring(end); continue; } } } const end = find_closing_parenthesis(start, code); if (start && end) { if (!is_global && !code.startsWith(':not')) { str.prependLeft(starting + start, ':global('); str.appendRight(starting + end - 1, ')'); } starting += end - 1; code = code.substring(end - 1); continue; } } starting++; code = code.substring(1); } state.str.update(state.analysis.css.ast?.start, state.analysis.css.ast?.end, str.toString()); } /** * @param {number} start * @param {string} code */ function find_closing_parenthesis(start, code) { let parenthesis = 1; let end = start; let char = code[end]; // find the closing parenthesis while (parenthesis !== 0 && char) { if (char === '(') parenthesis++; if (char === ')') parenthesis--; end++; char = code[end]; } return end; } /** * 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 * @param {{ filename?: string, use_ts?: boolean }} [options] * @returns {{ code: string; }} */ export function migrate(source, { filename, use_ts } = {}) { let og_source = source; try { has_migration_task = false; // Blank CSS, could contain SCSS or similar that needs a preprocessor. // Since we don't care about CSS in this migration, we'll just ignore it. /** @type {Array<[number, string]>} */ const style_contents = []; source = source.replace(regex_style_tags, (_, start, content, end, idx) => { style_contents.push([idx + start.length, content]); return start + style_placeholder + end; }); reset_warning_filter(() => false); reset(source, { filename: filename ?? '(unknown)' }); let parsed = parse(source); const { customElement: customElementOptions, ...parsed_options } = parsed.options || {}; /** @type {ValidatedCompileOptions} */ const combined_options = { ...validate_component_options({}, ''), ...parsed_options, customElementOptions, filename: filename ?? '(unknown)' }; const str = new MagicString(source); const analysis = analyze_component(parsed, source, combined_options); const indent = guess_indent(source); str.replaceAll(/(<svelte:options\s.*?\s?)accessors\s?/g, (_, $1) => $1); for (const content of style_contents) { str.overwrite(content[0], content[0] + style_placeholder.length, content[1]); } /** @type {State} */ let state = { scope: analysis.instance.scope, analysis, filename, str, indent, props: [], props_insertion_point: parsed.instance?.content.start ?? 0, has_props_rune: false, has_type_or_fallback: false, end: source.length, names: { props: analysis.root.unique('props').name, rest: analysis.root.unique('rest').name, // event stuff run: analysis.root.unique('run').name, handlers: analysis.root.unique('handlers').name, stopImmediatePropagation: analysis.root.unique('stopImmediatePropagation').name, preventDefault: analysis.root.unique('preventDefault').name, stopPropagation: analysis.root.unique('stopPropagation').name, once: analysis.root.unique('once').name, self: analysis.root.unique('self').name, trusted: analysis.root.unique('trusted').name, createBubbler: analysis.root.unique('createBubbler').name, bubble: analysis.root.unique('bubble').name, passive: analysis.root.unique('passive').name, nonpassive: analysis.root.unique('nonpassive').name }, legacy_imports: new Set(), script_insertions: new Set(), derived_components: new Map(), derived_conflicting_slots: new Map(), derived_labeled_statements: new Set(), has_svelte_self: false, uses_ts: // Some people could use jsdoc but have a tsconfig.json, so double-check file for jsdoc indicators (use_ts && !source.includes('@type {')) || !!parsed.instance?.attributes.some( (attr) => attr.name === 'lang' && /** @type {any} */ (attr).value[0].data === 'ts' ) }; if (parsed.module) { const context = parsed.module.attributes.find((attr) => attr.name === 'context'); if (context) { state.str.update(context.start, context.end, 'module'); } } if (parsed.instance) { walk(parsed.instance.content, state, instance_script); } state = { ...state, scope: analysis.template.scope }; walk(parsed.fragment, state, template); let insertion_point = parsed.instance ? /** @type {number} */ (parsed.instance.content.start) : 0; const need_script = state.legacy_imports.size > 0 || state.derived_components.size > 0 || state.derived_conflicting_slots.size > 0 || state.script_insertions.size > 0 || state.props.length > 0 || analysis.uses_rest_props || analysis.uses_props || state.has_svelte_self; const need_ts_tag = state.uses_ts && (!parsed.instance || !parsed.instance.attributes.some((attr) => attr.name === 'lang')); if (!parsed.instance && need_script) { str.appendRight(0, need_ts_tag ? '<script lang="ts">' : '<script>'); } if (state.has_svelte_self && filename) { const file = filename.split('/').pop(); str.appendRight( insertion_point, `\n${indent}import ${state.analysis.name} from './${file}';` ); } const specifiers = [...state.legacy_imports].map((imported) => { const local = state.names[imported]; return imported === local ? imported : `${imported} as ${local}`; }); const legacy_import = `import { ${specifiers.join(', ')} } from 'svelte/legacy';\n`; if (state.legacy_imports.size > 0) { str.appendRight(insertion_point, `\n${indent}${legacy_import}`); } if (state.script_insertions.size > 0) { str.appendRight( insertion_point, `\n${indent}${[...state.script_insertions].join(`\n${indent}`)}` ); } insertion_point = state.props_insertion_point; /** * @param {"derived"|"props"|"bindable"} rune */ function check_rune_binding(rune) { const has_rune_binding = !!state.scope.get(rune); if (has_rune_binding) { throw new MigrationError( `migrating this component would require adding a \`$${rune}\` rune but there's already a variable named ${rune}.\n Rename the variable and try again or migrate by hand.` ); } } if (state.props.length > 0 || analysis.uses_rest_props || analysis.uses_props) { const has_many_props = state.props.length > 3; const newline_separator = `\n${indent}${indent}`; const props_separator = has_many_props ? newline_separator : ' '; let props = ''; if (analysis.uses_props) { props = `...${state.names.props}`; } else { props = state.props .filter((prop) => !prop.type_only) .map((prop) => { let prop_str = prop.local === prop.exported ? prop.local : `${prop.exported}: ${prop.local}`; if (prop.bindable) { check_rune_binding('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 += `${state.props.length > 0 ? `,${props_separator}` : ''}...${state.names.rest}`; } } if (state.has_props_rune) { // some render tags or forwarded event attributes to add str.appendRight(insertion_point, ` ${props},`); } else { const type_name = state.scope.root.unique('Props').name; let type = ''; // Try to infer when we don't want to add types (e.g. user doesn't use types, or this is a zero-types +page.svelte) if (state.has_type_or_fallback || state.props.every((prop) => prop.slot_name)) { if (state.uses_ts) { type = `interface ${type_name} {${newline_separator}${state.props .map((prop) => { const comment = prop.comment ? `${prop.comment}${newline_separator}` : ''; return `${comment}${prop.exported}${prop.optional ? '?' : ''}: ${prop.type};${prop.trailing_comment ? ' ' + prop.trailing_comment : ''}`; }) .join(newline_separator)}`; if (analysis.uses_props || analysis.uses_rest_props) { type += `${state.props.length > 0 ? newline_separator : ''}[key: string]: any`; } type += `\n${indent}}`; } else { type = `/**\n${indent} * @typedef {Object} ${type_name}${state.props .map((prop) => { return `\n${indent} * @property {${prop.type}} ${prop.optional ? `[${prop.exported}]` : prop.exported}${prop.comment ? ` - ${prop.comment}` : ''}${prop.trailing_comment ? ` - ${prop.trailing_comment.trim()}` : ''}`; }) .join(``)}\n${indent} */`; } } let props_declaration = `let {${props_separator}${props}${has_many_props ? `\n${indent}` : ' '}}`; if (state.uses_ts) { if (type) { props_declaration = `${type}\n\n${indent}${props_declaration}`; } check_rune_binding('props'); props_declaration = `${props_declaration}${type ? `: ${type_name}` : ''} = $props();`; } else { if (type) { props_declaration = `${state.props.length > 0 ? `${type}\n\n${indent}` : ''}/** @type {${state.props.length > 0 ? type_name : ''}${analysis.uses_props || analysis.uses_rest_props ? `${state.props.length > 0 ? ' & ' : ''}{ [key: string]: any }` : ''}} */\n${indent}${props_declaration}`; } check_rune_binding('props'); props_declaration = `${props_declaration} = $props();`; } props_declaration = `\n${indent}${props_declaration}`; str.appendRight(insertion_point, props_declaration); } if (parsed.instance && need_ts_tag) { str.appendRight(parsed.instance.start + '<script'.length, ' lang="ts"'); } } /** * If true, then we need to move all reactive statements to the end of the script block, * in their correct order. Svelte 4 reordered reactive statements, $derived/$effect.pre * don't have this behavior. */ let needs_reordering = false; for (const [node, { dependencies }] of state.analysis.reactive_statements) { /** @type {Binding[]} */ let ids = []; if ( node.body.type === 'ExpressionStatement' && node.body.expression.type === 'AssignmentExpression' ) { ids = extract_identifiers(node.body.expression.left) .map((id) => state.scope.get(id.name)) .filter((id) => !!id); } if ( dependencies.some( (dep) => !ids.includes(dep) && (dep.kind === 'prop' || dep.kind === 'bindable_prop' ? state.props_insertion_point : /** @type {number} */ (dep.node.start)) > /** @type {number} */ (node.start) ) ) { needs_reordering = true; break; } } if (needs_reordering) { const nodes = Array.from(state.analysis.reactive_statements.keys()); for (const node of nodes) { const { start, end } = get_node_range(source, node); str.appendLeft(end, '\n'); str.move(start, end, /** @type {number} */ (parsed.instance?.content.end)); str.update(start - (source[start - 2] === '\r' ? 2 : 1), start, ''); } } insertion_point = parsed.instance ? /** @type {number} */ (parsed.instance.content.end) : insertion_point; if (state.derived_components.size > 0) { check_rune_binding('derived'); str.appendRight( insertion_point, `\n${indent}${[...state.derived_components.entries()].map(([init, name]) => `const ${name} = $derived(${init});`).join(`\n${indent}`)}\n` ); } if (state.derived_conflicting_slots.size > 0) { check_rune_binding('derived'); str.appendRight( insertion_point, `\n${indent}${[...state.derived_conflicting_slots.entries()].map(([name, init]) => `const ${name} = $derived(${init});`).join(`\n${indent}`)}\n` ); } if (state.props.length > 0 && state.analysis.accessors) { str.appendRight( insertion_point, `\n${indent}export {${state.props.reduce((acc, prop) => (prop.slot_name || prop.type_only ? acc : `${acc}\n${indent}\t${prop.local},`), '')}\n${indent}}\n` ); } if (!parsed.instance && need_script) { str.appendRight(insertion_point, '\n</script>\n\n'); } migrate_css(state); return { code: str.toString() }; } catch (e) { if (!(e instanceof MigrationError)) { // eslint-disable-next-line no-console console.error('Error while migrating Svelte code', e); } has_migration_task = true; return { code: `<!-- @migration-task Error while migrating Svelte code: ${/** @type {any} */ (e).message} -->\n${og_source}` }; } finally { if (has_migration_task) { // eslint-disable-next-line no-console console.log( `One or more \`@migration-task\` comments were added to ${filename ? `\`${filename}\`` : "a file (unfortunately we don't know the name)"}, please check them and complete the migration manually.` ); } } } /** * @typedef {{ * scope: Scope; * str: MagicString; * analysis: ComponentAnalysis; * filename?: string; * indent: string; * props: Array<{ local: string; exported: string; init: string; bindable: boolean; slot_name?: string; optional: boolean; type: string; comment?: string; trailing_comment?: string; type_only?: boolean; needs_refine_type?: boolean; }>; * props_insertion_point: number; * has_props_rune: boolean; * has_type_or_fallback: boolean; * end: number; * names: Record<string, string>; * legacy_imports: Set<string>; * script_insertions: Set<string>; * derived_components: Map<string, string>; * derived_conflicting_slots: Map<string, string>; * derived_labeled_statements: Set<LabeledStatement>; * has_svelte_self: boolean; * uses_ts: boolean; * }} State */ /** @type {Visitors<AST.SvelteNode, State>} */ const instance_script = { _(node, { state, next }) { // @ts-expect-error const comments = node.leadingComments; if (comments) { for (const comment of comments) { if (comment.type === 'Line') { const migrated = migrate_svelte_ignore(comment.value); if (migrated !== comment.value) { state.str.overwrite(comment.start + '//'.length, comment.end, migrated); } } } } next(); }, Identifier(node, { state, path }) { handle_identifier(node, state, path); }, ImportDeclaration(node, { state }) { state.props_insertion_point = node.end ?? state.props_insertion_point; if (node.source.value === 'svelte') { let illegal_specifiers = []; let removed_specifiers = 0; for (let specifier of node.specifiers) { if ( specifier.type === 'ImportSpecifier' && specifier.imported.type === 'Identifier' && ['beforeUpdate', 'afterUpdate'].includes(specifier.imported.name) ) { const references = state.scope.references.get(specifier.local.name); if (!references) { let end = /** @type {number} */ ( state.str.original.indexOf(',', specifier.end) !== -1 && state.str.original.indexOf(',', specifier.end) < state.str.original.indexOf('}', specifier.end) ? state.str.original.indexOf(',', specifier.end) + 1 : specifier.end ); while (state.str.original[end].trim() === '') end++; state.str.remove(/** @type {number} */ (specifier.start), end); removed_specifiers++; continue; } illegal_specifiers.push(specifier.imported.name); } } if (removed_specifiers === node.specifiers.length) { state.str.remove(/** @type {number} */ (node.start), /** @type {number} */ (node.end)); } if (illegal_specifiers.length > 0) { throw new MigrationError( `Can't migrate code with ${illegal_specifiers.join(' and ')}. Please migrate by hand.` ); } } }, ExportNamedDeclaration(node, { state, next }) { if (node.declaration) { next(); return; } let count_removed = 0; for (const specifier of node.specifiers) { if (specifier.local.type !== 'Identifier') continue; 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, visit, next }) { if (state.scope !== state.analysis.instance.scope) { return; } let nr_of_props = 0; for (let i = 0; i < node.declarations.length; i++) { const declarator = node.declarations[i]; 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 next(); 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) { next(); continue; } if (has_props) { nr_of_props++; if (declarator.id.type !== 'Identifier') { // TODO invest time in this? throw new MigrationError( '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 name = declarator.id.name; const binding = /** @type {Binding} */ (state.scope.get(name)); if (state.analysis.uses_props && (declarator.init || binding.updated)) { throw new MigrationError( '$$props is used together with named props in a way that cannot be automatically migrated.' ); } const prop = state.props.find((prop) => prop.exported === (binding.prop_alias || name)); if (prop) { next(); // $$Props type was used prop.init = declarator.init ? state.str .snip( /** @type {number} */ (declarator.init.start), /** @type {number} */ (declarator.init.end) ) .toString() : ''; prop.bindable = binding.updated; prop.exported = binding.prop_alias || name; prop.type_only = false; } else { next(); state.props.push({ local: name, exported: binding.prop_alias ? binding.prop_alias : name, init: declarator.init ? state.str .snip( /** @type {number} */ (declarator.init.start), /** @type {number} */ (declarator.init.end) ) .toString() : '', optional: !!declarator.init, bindable: binding.updated, ...extract_type_and_comment(declarator, state, path) }); } let start = /** @type {number} */ (declarator.start); let end = /** @type {number} */ (declarator.end); // handle cases like let a,b,c; where only some are exported if (node.declarations.length > 1) { // move the insertion point after the node itself; state.props_insertion_point = /** @type {number} */ (node.end); // if it's not the first declaration remove from the , of the previous declaration if (i !== 0) { start = state.str.original.indexOf( ',', /** @type {number} */ (node.declarations[i - 1].end) ); } // if it's not the last declaration remove either from up until the // start of the next declaration (if it's the first declaration) or // up until the last index of , from the next declaration if (i !== node.declarations.length - 1) { if (i === 0) { end = /** @type {number} */ (node.declarations[i + 1].start); } else { end = state.str.original.lastIndexOf( ',', /** @type {number} */ (node.declarations[i + 1].start) ); } } } else { state.props_insertion_point = /** @type {number} */ (declarator.end); } state.str.update(start, end, ''); continue; } /** * @param {"state"|"derived"} rune */ function check_rune_binding(rune) { const has_rune_binding = !!state.scope.get(rune); if (has_rune_binding) { throw new MigrationError( `can't migrate \`${state.str.original.substring(/** @type {number} */ (node.start), node.end)}\` to \`$${rune}\` because there's a variable named ${rune}.\n Rename the variable and try again or migrate by hand.` ); } } // state if (declarator.init) { let { start, end } = /** @type {{ start: number, end: number }} */ (declarator.init); if (declarator.init.type === 'SequenceExpression') { while (state.str.original[start] !== '(') start -= 1; while (state.str.original[end - 1] !== ')') end += 1; } check_rune_binding('state'); state.str.prependLeft(start, '$state('); state.str.appendRight(end, ')'); } else { /** * @type {AssignmentExpression | undefined} */ let assignment_in_labeled; /** * @type {LabeledStatement | undefined} */ let labeled_statement; // Analyze declaration bindings to see if they're exclusively updated within a single reactive statement const possible_derived = bindings.every((binding) => binding.references.every((reference) => { const declaration = reference.path.find((el) => el.type === 'VariableDeclaration'); const assignment = reference.path.find((el) => el.type === 'AssignmentExpression'); const update = reference.path.find((el) => el.type === 'UpdateExpression'); const labeled = /** @type {LabeledStatement | undefined} */ ( reference.path.find((el) => el.type === 'LabeledStatement' && el.label.name === '$') ); if ( assignment && labeled && // ensure that $: foo = bar * 2 is not counted as a reassignment of bar (labeled.body.type !== 'ExpressionStatement' || labeled.body.expression !== assignment || (assignment.left.type === 'Identifier' && assignment.left.name === binding.node.name)) ) { if (assignment_in_labeled) return false; assignment_in_labeled = /** @type {AssignmentExpression} */ (assignment); labeled_statement = labeled; } return ( !update && ((declaration && binding.initial) || (labeled && assignment) || (!labeled && !assignment)) ); }) ); const labeled_has_single_assignment = labeled_statement?.body.type === 'BlockStatement' && labeled_statement.body.body.length === 1 && labeled_statement.body.body[0].type === 'ExpressionStatement'; const is_expression_assignment = labeled_statement?.body.type === 'ExpressionStatement' && labeled_statement.body.expression.type === 'AssignmentExpression'; let should_be_state = false; if (is_expression_assignment) { const body = /**@type {ExpressionStatement}*/ (labeled_statement?.body); const expression = /**@type {AssignmentExpression}*/ (body.expression); const [, ids] = extract_all_identifiers_from_expression(expression.right); if (ids.length === 0) { should_be_state = true; state.derived_labeled_statements.add( /** @type {LabeledStatement} */ (labeled_statement) ); } } if ( !should_be_state && possible_derived && assignment_in_labeled && labeled_statement && (labeled_has_single_assignment || is_expression_assignment) ) { const indent = state.str.original.substring( state.str.original.lastIndexOf('\n', /** @type {number} */ (node.start)) + 1, /** @type {number} */ (node.start) ); // transfer all the leading comments if ( labeled_statement.body.type === 'BlockStatement' && labeled_statement.body.body[0].leadingComments ) { for (let comment of labeled_statement.body.body[0].leadingComments) { state.str.prependLeft( /** @type {number} */ (node.start), comment.type === 'Block' ? `/*${comment.value}*/\n${indent}` : `// ${comment.value}\n${indent}` ); } } check_rune_binding('derived'); // Someone wrote a `$: { ... }` statement which we can turn into a `$derived` state.str.appendRight( /** @type {number} */ (declarator.id.typeAnnotation?.end ?? declarator.id.end), ' = $derived(' ); visit(assignment_in_labeled.right); state.str.appendRight( /** @type {number} */ (declarator.id.typeAnnotation?.end ?? declarator.id.end), state.str .snip( /** @type {number} */ (assignment_in_labeled.right.start), /** @type {number} */ (assignment_in_labeled.right.end) ) .toString() ); state.str.remove( /** @type {number} */ (labeled_statement.start), /** @type {number} */ (labeled_statement.end) ); state.str.appendRight( /** @type {number} */ (declarator.id.typeAnnotation?.end ?? declarator.id.end), ')' ); state.derived_labeled_statements.add(labeled_statement); // transfer all the trailing comments if ( labeled_statement.body.type === 'BlockStatement' && labeled_statement.body.body[0].trailingComments ) { for (let comment of labeled_statement.body.body[0].trailingComments) { state.str.appendRight( /** @type {number} */ (declarator.id.typeAnnotation?.end ?? declarator.id.end), comment.type === 'Block' ? `\n${indent}/*${comment.value}*/` : `\n${indent}// ${comment.value}` ); } } } else { check_rune_binding('state'); state.str.prependLeft( /** @type {number} */ (declarator.id.typeAnnotation?.end ?? declarator.id.end), ' = $state(' ); if (should_be_state) { // someone wrote a `$: foo = ...` statement which we can turn into `let foo = $state(...)` state.str.appendRight( /** @type {number} */ (declarator.id.typeAnnotation?.end ?? declarator.id.end), state.str .snip( /** @type {number} */ ( /** @type {AssignmentExpression} */ (assignment_in_labeled).right.start ), /** @type {number} */ ( /** @type {AssignmentExpression} */ (assignment_in_labeled).right.end ) ) .toString() ); state.str.remove( /** @type {number} */ (/** @type {LabeledStatement} */ (labeled_statement).start), /** @type {number} */ (/** @type {LabeledStatement} */ (labeled_statement).end) ); } state.str.appendRight( /** @type {number} */ (declarator.id.typeAnnotation?.end ?? declarator.id.end), ')' ); } } } 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; if (state.derived_labeled_statements.has(node)) return; next(); /** * @param {"state"|"derived"} rune */ function check_rune_binding(rune) { const has_rune_binding = state.scope.get(rune); if (has_rune_binding) { throw new MigrationError( `can't migrate \`$: ${state.str.original.substring(/** @type {number} */ (node.body.start), node.body.end)}\` to \`$${rune}\` because there's a variable named ${rune}.\n Rename the variable and try again or migrate by hand.` ); } } if ( node.body.type === 'ExpressionStatement' && node.body.expression.type === 'AssignmentExpression' ) { const { left, right } = node.body.expression; const ids = extract_identifiers(left); const [, expression_ids] = extract_all_identifiers_from_expression(right); const bindings = ids.map((id) => /** @type {Binding} */ (state.scope.get(id.name))); if (bindings.every((b) => b.kind === 'legacy_reactive')) { if ( right.type !== 'Literal' && bindings.every((b) => b.kind !== 'store_sub') && left.type !== 'MemberExpression' ) { let { start, end } = /** @type {{ start: number, end: number }} */ (right); check_rune_binding('derived'); // $derived state.str.update( /** @type {number} */ (node.start), /** @type {number} */ (node.body.expression.start), 'let ' ); if (right.type === 'SequenceExpression') { while (state.str.original[start] !== '(') start -= 1; while (state.str.original[end - 1] !== ')') end += 1; } state.str.prependRight(start, `$derived(`); // in a case like `$: ({ a } = b())`, there's already a trailing parenthesis. // otherwise, we need to add one if (state.str.original[/** @type {number} */ (node.body.start)] !== '(') { state.str.appendLeft(end, `)`); } return; } for (const binding of bindings) { if (binding.reassigned && (ids.includes(binding.node) || expression_ids.length === 0)) { check_rune_binding('state'); const init = binding.kind === 'state' ? ' = $state()' : expression_ids.length === 0 ? ` = $state(${state.str.original.substring(/** @type {number} */ (right.start), right.end)})` : ''; // implicitly-declared variable which we need to make explicit state.str.prependLeft( /** @type {number} */ (node.start), `let ${binding.node.name}${init};\n${state.indent}` ); } } if (expression_ids.length === 0 && bindings.every((b) => b.kind !== 'store_sub')) { state.str.remove(/** @type {number} */ (node.start), /** @type {number} */ (node.end)); return; } } } state.legacy_imports.add('run'); 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.names.run}(() => {` ); const end = /** @type {number} */ (node.body.end); state.str.update(end - 1, end, '});'); } else { state.str.update( /** @type {number} */ (node.start), start_end, `${state.names.run}(() => {\n${state.indent}` ); state.str.indent(state.indent, { exclude: [ [0, /** @type {number} */ (node.body.start)], [/** @type {number} */ (node.body.end), state.end] ] }); state.str.appendLeft(/** @type {number} */ (node.end), `\n${state.indent}});`); } } }; /** * * @param {State} state * @param {number} start * @param {number} end */ function trim_block(state, start, end) { const original = state.str.snip(start, end).toString(); const without_parens = original.substring(1, original.length - 1); if (without_parens.trim().length !== without_parens.length) { state.str.update(start + 1, end - 1, without_parens.trim()); } } /** @type {Visitors<AST.SvelteNode, State>} */ const template = { Identifier(node, { state, path }) { handle_identifier(node, state, path); }, RegularElement(node, { state, path, next }) { migrate_slot_usage(node, path, state); handle_events(node, state); // Strip off any namespace from the beginning of the node name. const node_name = node.name.replace(/[a-zA-Z-]*:/g, ''); if (state.analysis.source[node.end - 2] === '/' && !is_void(node_name) && !is_svg(node_name)) { let trimmed_position = node.end - 2; while (state.str.original.charAt(trimmed_position - 1) === ' ') trimmed_position--; state.str.remove(trimmed_position, node.end - 1); state.str.appendRight(node.end, `</${node.name}>`); } next(); }, SvelteSelf(node, { state, next }) { const source = state.str.original.substring(node.start, node.end); if (!state.filename) { const indent = guess_indent(source); has_migration_task = true; state.str.prependRight( node.start, `<!-- @migration-task: svelte:self is deprecated, import this Svelte file into itself instead -->\n${indent}` ); next(); return; } // overwrite the open tag state.str.overwrite( node.start + 1, node.start + 1 + 'svelte:self'.length, `${state.analysis.name}` ); // if it has a fragment we need to overwrite the closing tag too if (node.fragment.nodes.length > 0) { state.str.overwrite( state.str.original.lastIndexOf('<', node.end) + 2, node.end - 1, `${state.analysis.name}` ); } else if (!source.endsWith('/>')) { // special case for case `<svelte:self></svelte:self>` it has no fragment but // we still need to overwrite the end tag state.str.overwrite( node.start + source.lastIndexOf('</', node.end) + 2, node.end - 1, `${state.analysis.name}` ); } state.has_svelte_self = true; next(); }, SvelteElement(node, { state, path, next }) { migrate_slot_usage(node, path, state); if (node.tag.type === 'Literal') { let is_static = true; let a = /** @type {number} */ (node.tag.start); let b = /** @type {number} */ (node.tag.end); let quote_mark = state.str.original[a - 1]; while (state.str.original[--a] !== '=') { if (state.str.original[a] === '{') { is_static = false; break; } } if (is_static && state.str.original[b] === quote_mark) { state.str.prependLeft(a + 1, '{'); state.str.appendRight(/** @type {number} */ (node.tag.end) + 1, '}'); } } handle_events(node, state); next(); }, Component(node, { state, path, next }) { next(); migrate_slot_usage(node, path, state); }, SvelteComponent(node, { state, next, path }) { next(); migrate_slot_usage(node, path, state); let expression = state.str .snip( /** @type {number} */ (node.expression.start), /** @type {number} */ (node.expression.end) ) .toString(); if ( (node.expression.type !== 'Identifier' && node.expression.type !== 'MemberExpression') || !regex_valid_component_name.test(expression) ) { let current_expression = expression; expression = state.scope.generate('SvelteComponent'); let needs_derived = true; for (let i = path.length - 1; i >= 0; i--) { const part = path[i]; if ( part.type === 'EachBlock' || part.type === 'AwaitBlock' || part.type === 'IfBlock' || part.type === 'SnippetBlock' || part.type === 'Component' || part.type === 'SvelteComponent' ) { let position = node.start; if (i !== path.length - 1) { for (let modifier = 1; modifier < path.length - i; modifier++) { const path_part = path[i + modifier]; if ('start' in path_part) { position = /** @type {number} */ (path_part.start); break; } } } const indent = state.str.original.substring( state.str.original.lastIndexOf('\n', position) + 1, position ); state.str.appendRight( position, `{@const ${expression} = ${current_expression}}\n${indent}` ); needs_derived = false; break; } } if (needs_derived) { if (state.derived_components.has(current_expression)) { expression = /** @type {string} */ (state.derived_components.get(current_expression)); } else { state.derived_components.set(current_expression, expression); } } } state.str.overwrite(node.start + 1, node.start + node.name.length + 1, expression); if (state.str.original.substring(node.end - node.name.length - 1, node.end - 1) === node.name) { state.str.overwrite(node.end - node.name.length - 1, node.end - 1, expression); } let this_pos = state.str.original.lastIndexOf('this', node.expression.start); while (!state.str.original.charAt(this_pos - 1).trim()) this_pos--; const end_pos = state.str.original.indexOf('}', node.expression.end) + 1; state.str.remove(this_pos, end_pos); }, SvelteFragment(node, { state, path, next }) { migrate_slot_usage(node, path, 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, path, next, visit }) { migrate_slot_usage(node, path, state); if (state.analysis.custom_element) return; let name = 'children'; let slot_name = 'default'; let slot_props = '{ '; let aliased_slot_name; 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 === 'slot') { continue; } if (attr.name === 'name') { slot_name = /** @type {any} */ (attr.value)[0].data; // if some of the parents or this node itself har a slot // attribute with the sane name of this slot // we want to create a derived or the migrated snippet // will shadow the slot prop if ( path.some( (parent) => (parent.type === 'RegularElement' || parent.type === 'SvelteElement' || parent.type === 'Component' || parent.type === 'SvelteComponent' || parent.type === 'SvelteFragment') && parent.attributes.some( (attribute) => attribute.type === 'Attribute' && attribute.name === 'slot' && is_text_attribute(attribute) && attribute.value[0].data === slot_name ) ) || node.attributes.some( (attribute) => attribute.type === 'Attribute' && attribute.name === 'slot' && is_text_attribute(attribute) && attribute.value[0].data === slot_name ) ) { aliased_slot_name = `${slot_name}_render`; state.derived_conflicting_slots.set(aliased_slot_name, slot_name); } } else { const attr_value = attr.value === true || Array.isArray(attr.value) ? attr.value : [attr.value]; let value = 'true'; if (attr_value !== true) { const first = attr_value[0]; const last = attr_value[attr_value.length - 1]; for (const attr of attr_value) { visit(attr); } value = state.str .snip( first.type === 'Text' ? first.start - 1 : /** @type {number} */ (first.expression.start), last.type === 'Text' ? last.end + 1 : /** @type {number} */ (last.expression.end) ) .toString(); } slot_props += value === attr.name ? `${value}, ` : `${attr.name}: ${value}, `; } } } slot_props += '}'; if (slot_props === '{ }') { slot_props = ''; } const existing_prop = state.props.find((prop) => prop.slot_name === slot_name); if (existing_prop) { name = existing_prop.local; } else if (slot_name !== 'default') { name = state.scope.generate(slot_name); if (name !== slot_name) { throw new MigrationError( 'This migration would change the name of a slot making the component unusable' ); } } if (!existing_prop) { state.props.push({ local: name, exported: name, init: '', bindable: false, optional: true, slot_name, type: `import('svelte').${slot_props ? 'Snippet<[any]>' : 'Snippet'}` }); } else if (existing_prop.needs_refine_type) { existing_prop.type = `import('svelte').${slot_props ? 'Snippet<[any]>' : 'Snippet'}`; existing_prop.needs_refine_type = false; } if ( slot_name === 'default' && path.some( (parent) => (parent.type === 'SvelteComponent' || parent.type === 'Component' || parent.type === 'RegularElement' || parent.type === 'SvelteElement' || parent.type === 'SvelteFragment') && parent.attributes.some((attr) => attr.type === 'LetDirective') ) ) { aliased_slot_name = `${name}_render`; state.derived_conflicting_slots.set(aliased_slot_name, name); } name = aliased_slot_name ?? name; if (node.fragment.nodes.length > 0) { next(); state.str.update( node.start, node.fragment.nodes[0].start, `{#if ${name}}{@render ${state.analysis.uses_props ? `${state.names.props}.` : ''}${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 ${state.analysis.uses_props ? `${state.names.props}.` : ''}${name}?.(${slot_props})}` ); } }, Comment(node, { state }) { const migrated = migrate_svelte_ignore(node.data); if (migrated !== node.data) { state.str.overwrite(node.start + '<!--'.length, node.end - '-->'.length, migrated); } }, HtmlTag(node, { state, next }) { trim_block(state, node.start, node.end); next(); }, ConstTag(node, { state, next }) { trim_block(state, node.start, node.end); next(); }, IfBlock(node, { state, next }) { const start = node.start; const end = state.str.original.indexOf('}', node.test.end) + 1; trim_block(state, start, end); next(); }, AwaitBlock(node, { state, next }) { const start = node.start; const end = state.str.original.indexOf( '}', node.pending !== null ? node.expression.end : node.value?.end ) + 1; trim_block(state, start, end); if (node.pending !== null) { const start = state.str.original.lastIndexOf('{', node.value?.start); const end = state.str.original.indexOf('}', node.value?.end) + 1; trim_block(state, start, end); } if (node.catch !== null) { const start = state.str.original.lastIndexOf('{', node.error?.start); const end = state.str.original.indexOf('}', node.error?.end) + 1; trim_block(state, start, end); } next(); }, KeyBlock(node, { state, next }) { const start = node.start; const end = state.str.original.indexOf('}', node.expression.end) + 1; trim_block(state, start, end); next(); } }; /** * @param {AST.RegularElement | AST.SvelteElement | AST.SvelteComponent | AST.Component | AST.SlotElement | AST.SvelteFragment} node * @param {AST.SvelteNode[]} path * @param {State} state */ function migrate_slot_usage(node, path, state) { const parent = path.at(-2); // Bail on custom element slot usage if ( parent?.type !== 'Component' && parent?.type !== 'SvelteComponent' && node.type !== 'Component' && node.type !== 'SvelteComponent' ) { return; } let snippet_name = 'children'; let snippet_props = []; // if we stop the transform because the name is not correct we don't want to // remove the let directive and they could come before the name let removal_queue = []; for (let attribute of node.attributes) { if ( attribute.type === 'Attribute' && attribute.name === 'slot' && is_text_attribute(attribute) ) { snippet_name = attribute.value[0].data; // the default slot in svelte 4 if what the children slot is for svelte 5 if (snippet_name === 'default') { snippet_name = 'children'; } if (!regex_is_valid_identifier.test(snippet_name) || is_reserved(snippet_name)) { has_migration_task = true; state.str.appendLeft( node.start, `<!-- @migration-task: migrate this slot by hand, \`${snippet_name}\` is an invalid identifier -->\n${state.indent}` ); return; } if (parent?.type === 'Component' || parent?.type === 'SvelteComponent') { for (let attribute of parent.attributes) { if (attribute.type === 'Attribute' || attribute.type === 'BindDirective') { if (attribute.name === snippet_name) { state.str.appendLeft( node.start, `<!-- @migration-task: migrate this slot by hand, \`${snippet_name}\` would shadow a prop on the parent component -->\n${state.indent}` ); return; } } } } // flush the queue after we found the name for (let remove_let of removal_queue) { remove_let(); } state.str.remove(attribute.start, attribute.end); } if (attribute.type === 'LetDirective') { snippet_props.push( attribute.name + (attribute.expression ? `: ${state.str.original.substring(/** @type {number} */ (attribute.expression.start), /** @type {number} */ (attribute.expression.end))}` : '') ); // we just add to the queue to remove them after we found if we need to migrate or we bail removal_queue.push(() => state.str.remove(attribute.start, attribute.end)); } } if (removal_queue.length > 0) { for (let remove_let of removal_queue) { remove_let(); } } if (node.type === 'SvelteFragment' && node.fragment.nodes.length > 0) { // remove node itself, keep content state.str.remove(node.start, node.fragment.nodes[0].start); state.str.remove(node.fragment.nodes[node.fragment.nodes.length - 1].end, node.end); } const props = snippet_props.length > 0 ? `{ ${snippet_props.join(', ')} }` : ''; if (snippet_name === 'children' && node.type !== 'SvelteFragment') { if (snippet_props.length === 0) return; // nothing to do let inner_start = 0; let inner_end = 0; for (let i = 0; i < node.fragment.nodes.length; i++) { const inner = node.fragment.nodes[i]; const is_empty_text = inner.type === 'Text' && !inner.data.trim(); if ( (inner.type === 'RegularElement' || inner.type === 'SvelteElement' || inner.type === 'Component' || inner.type === 'SvelteComponent' || inner.type === 'SlotElement' || inner.type === 'SvelteFragment') && inner.attributes.some((attr) => attr.type === 'Attribute' && attr.name === 'slot') ) { if (inner_start && !inner_end) { // End of default slot content inner_end = inner.start; } } else if (!inner_start && !is_empty_text) { // Start of default slot content inner_start = inner.start; } else if (inner_end && !is_empty_text) { // There was default slot content before, then some named slot content, now so