diff --git a/.changeset/beige-seas-share.md b/.changeset/beige-seas-share.md new file mode 100644 index 0000000000..3758385c55 --- /dev/null +++ b/.changeset/beige-seas-share.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +breaking: disallow binding to component exports in runes mode diff --git a/.changeset/brown-geckos-fry.md b/.changeset/brown-geckos-fry.md new file mode 100644 index 0000000000..e3b3e086af --- /dev/null +++ b/.changeset/brown-geckos-fry.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: correctly interpret empty aria- attribute diff --git a/.changeset/cool-poems-watch.md b/.changeset/cool-poems-watch.md new file mode 100644 index 0000000000..34527ad16f --- /dev/null +++ b/.changeset/cool-poems-watch.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: disallow mixing on:click and onclick syntax diff --git a/.changeset/cyan-squids-drive.md b/.changeset/cyan-squids-drive.md new file mode 100644 index 0000000000..ae01d66fed --- /dev/null +++ b/.changeset/cyan-squids-drive.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: make hr, script and template valid select children diff --git a/.changeset/itchy-panthers-shave.md b/.changeset/itchy-panthers-shave.md new file mode 100644 index 0000000000..c47ab1f61c --- /dev/null +++ b/.changeset/itchy-panthers-shave.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: disallow mounting a snippet diff --git a/.changeset/lucky-geckos-swim.md b/.changeset/lucky-geckos-swim.md new file mode 100644 index 0000000000..c11b3b57fd --- /dev/null +++ b/.changeset/lucky-geckos-swim.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: remove `bind_prop` in runes mode diff --git a/.changeset/nervous-berries-boil.md b/.changeset/nervous-berries-boil.md new file mode 100644 index 0000000000..c14195f32d --- /dev/null +++ b/.changeset/nervous-berries-boil.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +feat: only inject push/init/pop when necessary diff --git a/.changeset/orange-zoos-heal.md b/.changeset/orange-zoos-heal.md new file mode 100644 index 0000000000..f134a4b8a2 --- /dev/null +++ b/.changeset/orange-zoos-heal.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +feat: provide migration helper diff --git a/.changeset/pink-goats-promise.md b/.changeset/pink-goats-promise.md new file mode 100644 index 0000000000..fa04e56ef2 --- /dev/null +++ b/.changeset/pink-goats-promise.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +feat: introduce types to express bindability diff --git a/.changeset/plenty-starfishes-dress.md b/.changeset/plenty-starfishes-dress.md new file mode 100644 index 0000000000..38b2201e9e --- /dev/null +++ b/.changeset/plenty-starfishes-dress.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: avoid hoisting error by using 'let' instead of 'var' diff --git a/.changeset/pre.json b/.changeset/pre.json index 6a3cec722e..a3c2cd1621 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -19,6 +19,7 @@ "beige-flies-wash", "beige-mirrors-listen", "beige-rabbits-shave", + "beige-seas-share", "big-cars-serve", "big-eggs-flash", "big-eyes-carry", @@ -214,6 +215,7 @@ "lovely-items-turn", "lovely-rules-eat", "lucky-colts-remember", + "lucky-geckos-swim", "lucky-schools-hang", "lucky-toes-begin", "many-rockets-give", @@ -261,7 +263,9 @@ "orange-dingos-poke", "orange-yaks-protect", "pink-bikes-agree", + "pink-goats-promise", "pink-mayflies-tie", + "plenty-starfishes-dress", "polite-dolphins-care", "polite-pumpkins-guess", "polite-ravens-study", @@ -429,6 +433,7 @@ "tidy-chefs-taste", "tidy-starfishes-allow", "tiny-kings-whisper", + "tiny-meals-deliver", "tough-radios-punch", "twelve-dragons-join", "twelve-onions-juggle", diff --git a/.changeset/tiny-meals-deliver.md b/.changeset/tiny-meals-deliver.md new file mode 100644 index 0000000000..94969362d0 --- /dev/null +++ b/.changeset/tiny-meals-deliver.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: mark `accessors` and `immutable` as deprecated diff --git a/.prettierignore b/.prettierignore index 48d37dc02f..0567f05cd8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,14 @@ packages/**/dist/*.js packages/**/build/*.js packages/**/npm/**/* packages/**/config/*.js +packages/svelte/messages/**/*.md +packages/svelte/src/compiler/errors.js +packages/svelte/src/compiler/warnings.js +packages/svelte/src/internal/client/errors.js +packages/svelte/src/internal/client/warnings.js +packages/svelte/src/internal/shared/errors.js +packages/svelte/src/internal/shared/warnings.js +packages/svelte/src/internal/server/errors.js packages/svelte/tests/**/*.svelte packages/svelte/tests/**/_expected* packages/svelte/tests/**/_actual* diff --git a/eslint.config.js b/eslint.config.js index 75fcc7fa06..569d237cd8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -33,13 +33,19 @@ export default [ ignores: [ '**/*.d.ts', '**/tests', + 'packages/svelte/scripts/process-messages/templates/*.js', + 'packages/svelte/src/compiler/errors.js', + 'packages/svelte/src/internal/client/errors.js', + 'packages/svelte/src/internal/client/warnings.js', + 'packages/svelte/src/internal/shared/warnings.js', 'packages/svelte/compiler/index.js', // documentation can contain invalid examples 'documentation', // contains a fork of the REPL which doesn't adhere to eslint rules 'sites/svelte-5-preview/**', // wasn't checked previously, reenable at some point - 'sites/svelte.dev/**' + 'sites/svelte.dev/**', + 'tmp/**' ] } ]; diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index a5a654eedb..bceeb209b0 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,31 @@ # svelte +## 5.0.0-next.115 + +### Patch Changes + +- fix: remove `bind_prop` in runes mode ([#11321](https://github.com/sveltejs/svelte/pull/11321)) + +- fix: mark `accessors` and `immutable` as deprecated ([#11277](https://github.com/sveltejs/svelte/pull/11277)) + +## 5.0.0-next.114 + +### Patch Changes + +- feat: introduce types to express bindability ([#11225](https://github.com/sveltejs/svelte/pull/11225)) + +## 5.0.0-next.113 + +### Patch Changes + +- breaking: disallow binding to component exports in runes mode ([#11238](https://github.com/sveltejs/svelte/pull/11238)) + +## 5.0.0-next.112 + +### Patch Changes + +- fix: avoid hoisting error by using 'let' instead of 'var' ([#11291](https://github.com/sveltejs/svelte/pull/11291)) + ## 5.0.0-next.111 ### Patch Changes diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md new file mode 100644 index 0000000000..aeeb133226 --- /dev/null +++ b/packages/svelte/messages/client-errors/errors.md @@ -0,0 +1,67 @@ +## bind_invalid_checkbox_value + +> Using `bind:value` together with a checkbox input is not allowed. Use `bind:checked` instead + +## bind_invalid_export + +> Component %component% has an export named `%key%` that a consumer component is trying to access using `bind:%key%`, which is disallowed. Instead, use `bind:this` (e.g. `<%name% bind:this={component} />`) and then access the property on the bound component instance (e.g. `component.%key%`) + +## bind_not_bindable + +> A component is attempting to bind to a non-bindable property `%key%` belonging to %component% (i.e. `<%name% bind:%key%={...}>`). To mark a property as bindable: `let { %key% = $bindable() } = $props()` + +## each_key_duplicate + +> Keyed each block has duplicate key at indexes %a% and %b% + +> Keyed each block has duplicate key `%value%` at indexes %a% and %b% + +## effect_in_teardown + +> `%rune%` cannot be used inside an effect cleanup function + +## effect_orphan + +> `%rune%` can only be used inside an effect (e.g. during component initialisation) + +## effect_update_depth_exceeded + +> Maximum update depth exceeded. This can happen when a reactive block or effect repeatedly sets a new value. Svelte limits the number of nested updates to prevent infinite loops + +## hydration_missing_marker_close + +> Missing hydration closing marker + +## hydration_missing_marker_open + +> Missing hydration opening marker + +## lifecycle_legacy_only + +> `%name%(...)` cannot be used in runes mode + +## props_invalid_value + +> Cannot do `bind:%key%={undefined}` when `%key%` has a fallback value + +## props_rest_readonly + +> Rest element properties of `$props()` such as `%property%` are readonly + +## rune_outside_svelte + +> The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files + +## state_prototype_fixed + +> Cannot set prototype of `$state` object + +## state_unsafe_mutation + +> Unsafe mutations during Svelte's render or derived phase are not permitted in runes mode. This can lead to unexpected errors and possibly cause infinite loops. +> +> If the object is not meant to be reactive, declare it without `$state` + +## svelte_component_invalid_this_value + +> The `this={...}` property of a `` must be a Svelte component, if defined diff --git a/packages/svelte/messages/client-warnings/warnings.md b/packages/svelte/messages/client-warnings/warnings.md new file mode 100644 index 0000000000..1f77457f94 --- /dev/null +++ b/packages/svelte/messages/client-warnings/warnings.md @@ -0,0 +1,21 @@ +## hydration_attribute_changed + +> The `%attribute%` attribute on `%html%` changed its value between server and client renders. The client value, `%value%`, will be ignored in favour of the server value + +## hydration_mismatch + +> Hydration failed because the initial UI does not match what was rendered on the server + +## lifecycle_double_unmount + +> Tried to unmount a component that was not mounted + +## ownership_invalid_binding + +> %parent% passed a value to %child% with `bind:`, but the value is owned by %owner%. Consider creating a binding between %owner% and %parent% + +## ownership_invalid_mutation + +> Mutating a value outside the component that created it is strongly discouraged. Consider passing values to child components with `bind:`, or use a callback instead + +> %component% mutated a value owned by %owner%. This is strongly discouraged. Consider passing values to child components with `bind:`, or use a callback instead diff --git a/packages/svelte/messages/compile-errors/options.md b/packages/svelte/messages/compile-errors/options.md new file mode 100644 index 0000000000..0709f0d3f6 --- /dev/null +++ b/packages/svelte/messages/compile-errors/options.md @@ -0,0 +1,11 @@ +## options_invalid_value + +> Invalid compiler option: %details% + +## options_removed + +> Invalid compiler option: %details% + +## options_unrecognised + +> Unrecognised compiler option %keypath% diff --git a/packages/svelte/messages/compile-errors/script.md b/packages/svelte/messages/compile-errors/script.md new file mode 100644 index 0000000000..861ac2f56d --- /dev/null +++ b/packages/svelte/messages/compile-errors/script.md @@ -0,0 +1,135 @@ +## bindable_invalid_location + +> `$bindable()` can only be used inside a `$props()` declaration + +## constant_assignment + +> Cannot assign to %thing% + +## constant_binding + +> Cannot bind to %thing% + +## declaration_duplicate + +> `%name%` has already been declared + +## declaration_duplicate_module_import + +> Cannot declare same variable name which is imported inside `\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(`\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} */ +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} */ +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} */ + 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); +} diff --git a/packages/svelte/src/compiler/phases/1-parse/index.js b/packages/svelte/src/compiler/phases/1-parse/index.js index 5c77d36ea6..69889bfce8 100644 --- a/packages/svelte/src/compiler/phases/1-parse/index.js +++ b/packages/svelte/src/compiler/phases/1-parse/index.js @@ -4,7 +4,7 @@ import fragment from './state/fragment.js'; import { regex_whitespace } from '../patterns.js'; import { reserved } from './utils/names.js'; import full_char_code_at from './utils/full_char_code_at.js'; -import { error } from '../../errors.js'; +import * as e from '../../errors.js'; import { create_fragment } from './utils/create.js'; import read_options from './read/options.js'; import { getLocator } from 'locate-character'; @@ -92,15 +92,15 @@ export class Parser { if (current.type === 'RegularElement') { current.end = current.start + 1; - error(current, 'unclosed-element', current.name); + e.element_unclosed(current, current.name); } else { current.end = current.start + 1; - error(current, 'unclosed-block'); + e.block_unclosed(current); } } if (state !== fragment) { - error(this.index, 'unexpected-eof'); + e.unexpected_eof(this.index); } if (this.root.fragment.nodes.length) { @@ -158,7 +158,7 @@ export class Parser { * @returns {never} */ acorn_error(err) { - error(err.pos, 'js-parse-error', err.message.replace(regex_position_indicator, '')); + e.js_parse_error(err.pos, err.message.replace(regex_position_indicator, '')); } /** @@ -172,11 +172,7 @@ export class Parser { } if (required) { - if (this.index === this.template.length) { - error(this.index, 'unexpected-eof', str); - } else { - error(this.index, 'expected-token', str); - } + e.expected_token(this.index, str); } return false; @@ -241,7 +237,7 @@ export class Parser { const identifier = this.template.slice(this.index, (this.index = i)); if (!allow_reserved && reserved.includes(identifier)) { - error(start, 'unexpected-reserved-word', identifier); + e.unexpected_reserved_word(start, identifier); } return identifier; @@ -250,7 +246,7 @@ export class Parser { /** @param {RegExp} pattern */ read_until(pattern) { if (this.index >= this.template.length) { - error(this.template.length, 'unexpected-eof'); + e.unexpected_eof(this.template.length); } const start = this.index; @@ -267,7 +263,7 @@ export class Parser { require_whitespace() { if (!regex_whitespace.test(this.template[this.index])) { - error(this.index, 'missing-whitespace'); + e.expected_whitespace(this.index); } this.allow_whitespace(); diff --git a/packages/svelte/src/compiler/phases/1-parse/read/context.js b/packages/svelte/src/compiler/phases/1-parse/read/context.js index b69c16d320..10cdaa44b7 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/context.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/context.js @@ -9,7 +9,7 @@ import { } from '../utils/bracket.js'; import { parse_expression_at } from '../acorn.js'; import { regex_not_newline_characters } from '../../patterns.js'; -import { error } from '../../../errors.js'; +import * as e from '../../../errors.js'; /** * @param {import('../index.js').Parser} parser @@ -36,7 +36,7 @@ export default function read_pattern(parser, optional_allowed = false) { } if (!is_bracket_open(code)) { - error(i, 'expected-pattern'); + e.expected_pattern(i); } const bracket_stack = [code]; @@ -49,11 +49,7 @@ export default function read_pattern(parser, optional_allowed = false) { } else if (is_bracket_close(code)) { const popped = /** @type {number} */ (bracket_stack.pop()); if (!is_bracket_pair(popped, code)) { - error( - i, - 'expected-token', - String.fromCharCode(/** @type {number} */ (get_bracket_close(popped))) - ); + e.expected_token(i, String.fromCharCode(/** @type {number} */ (get_bracket_close(popped)))); } if (bracket_stack.length === 0) { i += code <= 0xffff ? 1 : 2; diff --git a/packages/svelte/src/compiler/phases/1-parse/read/expression.js b/packages/svelte/src/compiler/phases/1-parse/read/expression.js index 21d4bb96fe..506fc354fb 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/expression.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/expression.js @@ -1,6 +1,6 @@ import { parse_expression_at } from '../acorn.js'; import { regex_whitespace } from '../../patterns.js'; -import { error } from '../../../errors.js'; +import * as e from '../../../errors.js'; /** * @param {import('../index.js').Parser} parser @@ -23,7 +23,7 @@ export default function read_expression(parser) { if (char === ')') { num_parens -= 1; } else if (!regex_whitespace.test(char)) { - error(index, 'expected-token', ')'); + e.expected_token(index, ')'); } index += 1; diff --git a/packages/svelte/src/compiler/phases/1-parse/read/options.js b/packages/svelte/src/compiler/phases/1-parse/read/options.js index c9262c54a8..9879d774ef 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/options.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/options.js @@ -1,5 +1,5 @@ import { namespace_svg } from '../../../../constants.js'; -import { error } from '../../../errors.js'; +import * as e from '../../../errors.js'; const regex_valid_tag_name = /^[a-zA-Z][a-zA-Z0-9]*-[a-zA-Z0-9-]+$/; @@ -22,24 +22,18 @@ export default function read_options(node) { for (const attribute of node.attributes) { if (attribute.type !== 'Attribute') { - error(attribute, 'invalid-svelte-option-attribute'); + e.svelte_options_invalid_attribute(attribute); } const { name } = attribute; switch (name) { case 'runes': { - const value = get_static_value(attribute, () => - error(attribute, 'invalid-svelte-option-runes') - ); - if (typeof value !== 'boolean') { - error(attribute, 'invalid-svelte-option-runes'); - } - component_options.runes = value; + component_options.runes = get_boolean_value(attribute); break; } case 'tag': { - error(attribute, 'tag-option-deprecated'); + e.svelte_options_deprecated_tag(attribute); break; // eslint doesn't know this is unnecessary } case 'customElement': { @@ -48,9 +42,9 @@ export default function read_options(node) { const { value } = attribute; if (value === true) { - error(attribute, 'invalid-svelte-option-customElement'); + e.svelte_options_invalid_customelement(attribute); } else if (value[0].type === 'Text') { - const tag = get_static_value(attribute, () => error(attribute, 'invalid-tag-property')); + const tag = get_static_value(attribute); validate_tag(attribute, tag); ce.tag = tag; component_options.customElement = ce; @@ -61,7 +55,7 @@ export default function read_options(node) { if (value[0].expression.type === 'Literal' && value[0].expression.value === null) { break; } - error(attribute, 'invalid-svelte-option-customElement'); + e.svelte_options_invalid_customelement(attribute); } /** @type {Array<[string, any]>} */ @@ -72,7 +66,7 @@ export default function read_options(node) { property.computed || property.key.type !== 'Identifier' ) { - error(attribute, 'invalid-svelte-option-customElement'); + e.svelte_options_invalid_customelement(attribute); } properties.push([property.key.name, property.value]); } @@ -83,13 +77,13 @@ export default function read_options(node) { validate_tag(tag, tag_value); ce.tag = tag_value; } else { - error(attribute, 'invalid-svelte-option-customElement'); + e.svelte_options_invalid_customelement(attribute); } const props = properties.find(([name]) => name === 'props')?.[1]; if (props) { if (props.type !== 'ObjectExpression') { - error(attribute, 'invalid-customElement-props-attribute'); + e.svelte_options_invalid_customelement_props(attribute); } ce.props = {}; for (const property of /** @type {import('estree').ObjectExpression} */ (props) @@ -100,7 +94,7 @@ export default function read_options(node) { property.key.type !== 'Identifier' || property.value.type !== 'ObjectExpression' ) { - error(attribute, 'invalid-customElement-props-attribute'); + e.svelte_options_invalid_customelement_props(attribute); } ce.props[property.key.name] = {}; for (const prop of property.value.properties) { @@ -110,7 +104,7 @@ export default function read_options(node) { prop.key.type !== 'Identifier' || prop.value.type !== 'Literal' ) { - error(attribute, 'invalid-customElement-props-attribute'); + e.svelte_options_invalid_customelement_props(attribute); } if (prop.key.name === 'type') { @@ -119,21 +113,21 @@ export default function read_options(node) { /** @type {string} */ (prop.value.value) ) === -1 ) { - error(attribute, 'invalid-customElement-props-attribute'); + e.svelte_options_invalid_customelement_props(attribute); } ce.props[property.key.name].type = /** @type {any} */ (prop.value.value); } else if (prop.key.name === 'reflect') { if (typeof prop.value.value !== 'boolean') { - error(attribute, 'invalid-customElement-props-attribute'); + e.svelte_options_invalid_customelement_props(attribute); } ce.props[property.key.name].reflect = prop.value.value; } else if (prop.key.name === 'attribute') { if (typeof prop.value.value !== 'string') { - error(attribute, 'invalid-customElement-props-attribute'); + e.svelte_options_invalid_customelement_props(attribute); } ce.props[property.key.name].attribute = prop.value.value; } else { - error(attribute, 'invalid-customElement-props-attribute'); + e.svelte_options_invalid_customelement_props(attribute); } } } @@ -143,7 +137,7 @@ export default function read_options(node) { if (shadow) { const shadowdom = shadow?.value; if (shadowdom !== 'open' && shadowdom !== 'none') { - error(shadow, 'invalid-customElement-shadow-attribute'); + e.svelte_options_invalid_customelement_shadow(shadow); } ce.shadow = shadowdom; } @@ -157,55 +151,32 @@ export default function read_options(node) { break; } case 'namespace': { - const value = get_static_value(attribute, () => - error(attribute, 'invalid-svelte-option-namespace') - ); - if (typeof value !== 'string') { - error(attribute, 'invalid-svelte-option-namespace'); - } + const value = get_static_value(attribute); if (value === namespace_svg) { component_options.namespace = 'svg'; } else if (value === 'html' || value === 'svg' || value === 'foreign') { component_options.namespace = value; } else { - error(attribute, 'invalid-svelte-option-namespace'); + e.svelte_options_invalid_attribute_value(attribute, `"html", "svg" or "foreign"`); } break; } case 'immutable': { - const value = get_static_value(attribute, () => - error(attribute, 'invalid-svelte-option-immutable') - ); - if (typeof value !== 'boolean') { - error(attribute, 'invalid-svelte-option-immutable'); - } - component_options.immutable = value; + component_options.immutable = get_boolean_value(attribute); break; } case 'preserveWhitespace': { - const value = get_static_value(attribute, () => - error(attribute, 'invalid-svelte-option-preserveWhitespace') - ); - if (typeof value !== 'boolean') { - error(attribute, 'invalid-svelte-option-preserveWhitespace'); - } - component_options.preserveWhitespace = value; + component_options.preserveWhitespace = get_boolean_value(attribute); break; } case 'accessors': { - const value = get_static_value(attribute, () => - error(attribute, 'invalid-svelte-option-accessors') - ); - if (typeof value !== 'boolean') { - error(attribute, 'invalid-svelte-option-accessors'); - } - component_options.accessors = value; + component_options.accessors = get_boolean_value(attribute); break; } default: - error(attribute, 'unknown-svelte-option-attribute', name); + e.svelte_options_unknown_attribute(attribute, name); } } @@ -214,33 +185,43 @@ export default function read_options(node) { /** * @param {any} attribute - * @param {(attribute: any) => never} error */ -function get_static_value(attribute, error) { +function get_static_value(attribute) { const { value } = attribute; const chunk = value[0]; if (!chunk) return true; if (value.length > 1) { - error(attribute); + return null; } if (chunk.type === 'Text') return chunk.data; if (chunk.expression.type !== 'Literal') { - error(attribute); + return null; } return chunk.expression.value; } /** * @param {any} attribute - * @param {string} tag + */ +function get_boolean_value(attribute) { + const value = get_static_value(attribute); + if (typeof value !== 'boolean') { + e.svelte_options_invalid_attribute_value(attribute, 'true or false'); + } + return value; +} + +/** + * @param {any} attribute + * @param {string | null} tag * @returns {asserts tag is string} */ function validate_tag(attribute, tag) { - if (typeof tag !== 'string' && tag !== null) { - error(attribute, 'invalid-tag-property'); + if (typeof tag !== 'string') { + e.svelte_options_invalid_tagname(attribute); } if (tag && !regex_valid_tag_name.test(tag)) { - error(attribute, 'invalid-tag-property'); + e.svelte_options_invalid_tagname(attribute); } // TODO do we still need this? // if (tag && !component.compile_options.customElement) { diff --git a/packages/svelte/src/compiler/phases/1-parse/read/script.js b/packages/svelte/src/compiler/phases/1-parse/read/script.js index cd9129e8b5..19137fea34 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/script.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/script.js @@ -1,6 +1,6 @@ import * as acorn from '../acorn.js'; import { regex_not_newline_characters } from '../../patterns.js'; -import { error } from '../../../errors.js'; +import * as e from '../../../errors.js'; const regex_closing_script_tag = /<\/script\s*>/; const regex_starts_with_closing_script_tag = /^<\/script\s*>/; @@ -16,13 +16,13 @@ function get_context(attributes) { if (!context) return 'default'; if (context.value.length !== 1 || context.value[0].type !== 'Text') { - error(context.start, 'invalid-script-context'); + e.script_invalid_context(context.start); } const value = context.value[0].data; if (value !== 'module') { - error(context.start, 'invalid-script-context'); + e.script_invalid_context(context.start); } return value; @@ -38,7 +38,7 @@ export function read_script(parser, start, attributes) { const script_start = parser.index; const data = parser.read_until(regex_closing_script_tag); if (parser.index >= parser.template.length) { - error(parser.template.length, 'unclosed-element', 'script'); + e.element_unclosed(parser.template.length, 'script'); } const source = diff --git a/packages/svelte/src/compiler/phases/1-parse/read/style.js b/packages/svelte/src/compiler/phases/1-parse/read/style.js index 8ec00daff1..358baea77a 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/style.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/style.js @@ -1,4 +1,4 @@ -import { error } from '../../../errors.js'; +import * as e from '../../../errors.js'; const REGEX_MATCHER = /^[~^$*|]?=/; const REGEX_CLOSING_BRACKET = /[\s\]]/; @@ -64,7 +64,7 @@ function read_body(parser, close) { } } - error(parser.template.length, 'expected-token', close); + e.expected_token(parser.template.length, close); } /** @@ -154,7 +154,7 @@ function read_selector_list(parser, inside_pseudo_class = false) { } } - error(parser.template.length, 'unexpected-eof'); + e.unexpected_eof(parser.template.length); } /** @@ -352,7 +352,7 @@ function read_selector(parser, inside_pseudo_class = false) { if (combinator) { if (relative_selector.selectors.length === 0) { if (!inside_pseudo_class) { - error(start, 'invalid-css-selector'); + e.css_selector_invalid(start); } } else { relative_selector.end = index; @@ -365,12 +365,12 @@ function read_selector(parser, inside_pseudo_class = false) { parser.allow_whitespace(); if (parser.match(',') || (inside_pseudo_class ? parser.match(')') : parser.match('{'))) { - error(parser.index, 'invalid-css-selector'); + e.css_selector_invalid(parser.index); } } } - error(parser.template.length, 'unexpected-eof'); + e.unexpected_eof(parser.template.length); } /** @@ -471,12 +471,13 @@ function read_declaration(parser) { const property = parser.read_until(REGEX_WHITESPACE_OR_COLON); parser.allow_whitespace(); parser.eat(':'); + let index = parser.index; parser.allow_whitespace(); const value = read_value(parser); if (!value && !property.startsWith('--')) { - error(parser.index, 'invalid-css-declaration'); + e.css_empty_declaration({ start, end: index }); } const end = parser.index; @@ -531,7 +532,7 @@ function read_value(parser) { parser.index++; } - error(parser.template.length, 'unexpected-eof'); + e.unexpected_eof(parser.template.length); } /** @@ -564,7 +565,7 @@ function read_attribute_value(parser) { parser.index++; } - error(parser.template.length, 'unexpected-eof'); + e.unexpected_eof(parser.template.length); } /** @@ -577,7 +578,7 @@ function read_identifier(parser) { let identifier = ''; if (parser.match('--') || parser.match_regex(REGEX_LEADING_HYPHEN_OR_DIGIT)) { - error(start, 'invalid-css-identifier'); + e.css_expected_identifier(start); } let escaped = false; @@ -602,7 +603,7 @@ function read_identifier(parser) { } if (identifier === '') { - error(start, 'invalid-css-identifier'); + e.css_expected_identifier(start); } return identifier; diff --git a/packages/svelte/src/compiler/phases/1-parse/state/element.js b/packages/svelte/src/compiler/phases/1-parse/state/element.js index bfaee9572d..80edeca6eb 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/element.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js @@ -1,11 +1,9 @@ -import { extract_svelte_ignore } from '../../../utils/extract_svelte_ignore.js'; -import fuzzymatch from '../utils/fuzzymatch.js'; import { is_void } from '../utils/names.js'; import read_expression from '../read/expression.js'; import { read_script } from '../read/script.js'; import read_style from '../read/style.js'; import { closing_tag_omitted, decode_character_references } from '../utils/html.js'; -import { error } from '../../../errors.js'; +import * as e from '../../../errors.js'; import { create_fragment } from '../utils/create.js'; import { create_attribute } from '../../nodes.js'; @@ -87,8 +85,7 @@ export default function tag(parser) { type: 'Comment', start, end: parser.index, - data, - ignores: extract_svelte_ignore(data) + data }); return; @@ -104,19 +101,18 @@ export default function tag(parser) { ['svelte:options', 'svelte:window', 'svelte:body', 'svelte:document'].includes(name) && /** @type {import('#compiler').ElementLike} */ (parent).fragment.nodes.length ) { - error( + e.svelte_meta_invalid_content( /** @type {import('#compiler').ElementLike} */ (parent).fragment.nodes[0].start, - 'invalid-element-content', name ); } } else { if (name in parser.meta_tags) { - error(start, 'duplicate-svelte-element', name); + e.svelte_meta_duplicate(start, name); } if (parent.type !== 'Root') { - error(start, 'invalid-svelte-element-placement', name); + e.svelte_meta_invalid_placement(start, name); } parser.meta_tags[name] = true; @@ -168,7 +164,7 @@ export default function tag(parser) { if (is_closing_tag) { if (is_void(name)) { - error(start, 'invalid-void-content'); + e.void_element_invalid_content(start); } parser.eat('>', true); @@ -177,14 +173,9 @@ export default function tag(parser) { while (/** @type {import('#compiler').RegularElement} */ (parent).name !== name) { if (parent.type !== 'RegularElement') { if (parser.last_auto_closed_tag && parser.last_auto_closed_tag.tag === name) { - error( - start, - 'invalid-closing-tag-after-autoclose', - name, - parser.last_auto_closed_tag.reason - ); + e.element_invalid_closing_tag_autoclosed(start, name, parser.last_auto_closed_tag.reason); } else { - error(start, 'invalid-closing-tag', name); + e.element_invalid_closing_tag(start, name); } } @@ -225,7 +216,7 @@ export default function tag(parser) { while ((attribute = read(parser))) { if (attribute.type === 'Attribute' || attribute.type === 'BindDirective') { if (unique_names.includes(attribute.name)) { - error(attribute.start, 'duplicate-attribute'); + e.attribute_duplicate(attribute.start); // is allowed } else if (attribute.name !== 'this') { unique_names.push(attribute.name); @@ -242,7 +233,7 @@ export default function tag(parser) { (attr) => attr.type === 'Attribute' && attr.name === 'this' ); if (index === -1) { - error(start, 'missing-svelte-component-definition'); + e.svelte_component_missing_this(start); } const definition = /** @type {import('#compiler').Attribute} */ ( @@ -253,7 +244,7 @@ export default function tag(parser) { definition.value.length !== 1 || definition.value[0].type === 'Text' ) { - error(definition.start, 'invalid-svelte-component-definition'); + e.svelte_component_invalid_this(definition.start); } element.expression = definition.value[0].expression; @@ -265,14 +256,14 @@ export default function tag(parser) { (attr) => attr.type === 'Attribute' && attr.name === 'this' ); if (index === -1) { - error(start, 'missing-svelte-element-definition'); + e.svelte_element_missing_this(start); } const definition = /** @type {import('#compiler').Attribute} */ ( element.attributes.splice(index, 1)[0] ); if (definition.value === true || definition.value.length !== 1) { - error(definition.start, 'invalid-svelte-element-definition'); + e.svelte_element_invalid_this(definition.start); } const chunk = definition.value[0]; element.tag = @@ -311,17 +302,17 @@ export default function tag(parser) { } if (content.context === 'module') { - if (current.module) error(start, 'duplicate-script-element'); + if (current.module) e.script_duplicate(start); current.module = content; } else { - if (current.instance) error(start, 'duplicate-script-element'); + if (current.instance) e.script_duplicate(start); current.instance = content; } } else { const content = read_style(parser, start, element.attributes); content.content.comment = prev_comment; - if (current.css) error(start, 'duplicate-style-element'); + if (current.css) e.style_duplicate(start); current.css = content; } return; @@ -396,7 +387,7 @@ function read_tag_name(parser) { } if (!legal) { - error(start, 'invalid-self-placement'); + e.svelte_self_invalid_placement(start); } return 'svelte:self'; @@ -412,12 +403,12 @@ function read_tag_name(parser) { if (meta_tags.has(name)) return name; if (name.startsWith('svelte:')) { - const match = fuzzymatch(name.slice(7), valid_meta_tags); - error(start, 'invalid-svelte-tag', valid_meta_tags, match); + const list = `${valid_meta_tags.slice(0, -1).join(', ')} or ${valid_meta_tags[valid_meta_tags.length - 1]}`; + e.svelte_meta_invalid_tag(start, list); } if (!valid_tag_name.test(name)) { - error(start, 'invalid-tag-name'); + e.element_invalid_tag_name(start); } return name; @@ -445,7 +436,7 @@ function read_static_attribute(parser) { parser.allow_whitespace(); let raw = parser.match_regex(regex_attribute_value); if (!raw) { - error(parser.index, 'missing-attribute-value'); + e.expected_attribute_value(parser.index); } parser.index += raw.length; @@ -468,7 +459,7 @@ function read_static_attribute(parser) { } if (parser.match_regex(regex_starts_with_quote_characters)) { - error(parser.index, 'expected-token', '='); + e.expected_token(parser.index, '='); } return create_attribute(name, start, parser.index, value); @@ -509,7 +500,7 @@ function read_attribute(parser) { const name = parser.read_identifier(); if (name === null) { - error(start, 'empty-attribute-shorthand'); + e.attribute_empty_shorthand(start); } parser.allow_whitespace(); @@ -554,14 +545,14 @@ function read_attribute(parser) { value = read_attribute_value(parser); end = parser.index; } else if (parser.match_regex(regex_starts_with_quote_characters)) { - error(parser.index, 'expected-token', '='); + e.expected_token(parser.index, '='); } if (type) { const [directive_name, ...modifiers] = name.slice(colon_index + 1).split('|'); if (directive_name === '') { - error(start + colon_index + 1, 'empty-directive-name', type); + e.directive_missing_name({ start, end: start + colon_index + 1 }, name); } if (type === 'StyleDirective') { @@ -586,7 +577,7 @@ function read_attribute(parser) { const attribute_contains_text = /** @type {any[]} */ (value).length > 1 || first_value.type === 'Text'; if (attribute_contains_text) { - error(/** @type {number} */ (first_value.start), 'invalid-directive-value'); + e.directive_invalid_value(/** @type {number} */ (first_value.start)); } else { expression = first_value.expression; } @@ -679,22 +670,22 @@ function read_attribute_value(parser) { }, 'in attribute value' ); - } catch (/** @type {any} e */ e) { - if (e.code === 'js-parse-error') { + } catch (/** @type {any} */ error) { + if (error.code === 'js_parse_error') { // if the attribute value didn't close + self-closing tag // eg: `` // acorn may throw a `Unterminated regular expression` because of `/>` - const pos = e.position?.[0]; + const pos = error.position?.[0]; if (pos !== undefined && parser.template.slice(pos - 1, pos + 1) === '/>') { parser.index = pos; - error(pos, 'unclosed-attribute-value', quote_mark || '}'); + e.expected_token(pos, quote_mark || '}'); } } - throw e; + throw error; } if (value.length === 0 && !quote_mark) { - error(parser.index, 'missing-attribute-value'); + e.expected_attribute_value(parser.index); } if (quote_mark) parser.index += 1; @@ -741,12 +732,12 @@ function read_sequence(parser, done, location) { const index = parser.index - 1; parser.eat('#'); const name = parser.read_until(/[^a-z]/); - error(index, 'invalid-block-placement', location, name); + e.block_invalid_placement(index, name, location); } else if (parser.match('@')) { const index = parser.index - 1; parser.eat('@'); const name = parser.read_until(/[^a-z]/); - error(index, 'invalid-tag-placement', location, name); + e.tag_invalid_placement(index, name, location); } flush(parser.index - 1); @@ -784,5 +775,5 @@ function read_sequence(parser, done, location) { } } - error(parser.template.length, 'unexpected-eof'); + e.unexpected_eof(parser.template.length); } diff --git a/packages/svelte/src/compiler/phases/1-parse/state/tag.js b/packages/svelte/src/compiler/phases/1-parse/state/tag.js index 1c73427463..bf6adccccf 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/tag.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/tag.js @@ -1,6 +1,6 @@ import read_pattern from '../read/context.js'; import read_expression from '../read/expression.js'; -import { error } from '../../../errors.js'; +import * as e from '../../../errors.js'; import { create_fragment } from '../utils/create.js'; import { walk } from 'zimmerframe'; @@ -147,7 +147,7 @@ function open(parser) { parser.allow_whitespace(); index = parser.read_identifier(); if (!index) { - error(parser.index, 'expected-identifier'); + e.expected_identifier(parser.index); } parser.allow_whitespace(); @@ -265,7 +265,7 @@ function open(parser) { const name_end = parser.index; if (name === null) { - error(parser.index, 'expected-identifier'); + e.expected_identifier(parser.index); } parser.eat('(', true); @@ -320,7 +320,7 @@ function open(parser) { return; } - error(parser.index, 'expected-block-type'); + e.expected_block_type(parser.index); } /** @param {import('../index.js').Parser} parser */ @@ -330,8 +330,8 @@ function next(parser) { const block = parser.current(); // TODO type should not be TemplateNode, that's much too broad if (block.type === 'IfBlock') { - if (!parser.eat('else')) error(start, 'expected-token', '{:else} or {:else if}'); - if (parser.eat('if')) error(start, 'invalid-elseif'); + if (!parser.eat('else')) e.expected_token(start, '{:else} or {:else if}'); + if (parser.eat('if')) e.block_invalid_elseif(start); parser.allow_whitespace(); @@ -373,7 +373,7 @@ function next(parser) { } if (block.type === 'EachBlock') { - if (!parser.eat('else')) error(start, 'expected-token', '{:else}'); + if (!parser.eat('else')) e.expected_token(start, '{:else}'); parser.allow_whitespace(); parser.eat('}', true); @@ -389,7 +389,7 @@ function next(parser) { if (block.type === 'AwaitBlock') { if (parser.eat('then')) { if (block.then) { - error(start, 'duplicate-block-part', '{:then}'); + e.block_duplicate_clause(start, '{:then}'); } if (!parser.eat('}')) { @@ -408,7 +408,7 @@ function next(parser) { if (parser.eat('catch')) { if (block.catch) { - error(start, 'duplicate-block-part', '{:catch}'); + e.block_duplicate_clause(start, '{:catch}'); } if (!parser.eat('}')) { @@ -425,10 +425,10 @@ function next(parser) { return; } - error(start, 'expected-token', '{:then ...} or {:catch ...}'); + e.expected_token(start, '{:then ...} or {:catch ...}'); } - error(start, 'invalid-continuing-block-placement'); + e.block_invalid_continuation_placement(start); } /** @param {import('../index.js').Parser} parser */ @@ -466,11 +466,11 @@ function close(parser) { case 'RegularElement': // TODO handle implicitly closed elements - error(start, 'unexpected-block-close'); + e.block_unexpected_close(start); break; default: - error(start, 'unexpected-block-close'); + e.block_unexpected_close(start); } parser.allow_whitespace(); @@ -522,7 +522,7 @@ function special(parser) { identifiers.forEach( /** @param {any} node */ (node) => { if (node.type !== 'Identifier') { - error(/** @type {number} */ (node.start), 'invalid-debug'); + e.debug_tag_invalid_arguments(/** @type {number} */ (node.start)); } } ); @@ -583,7 +583,7 @@ function special(parser) { expression.expression.type !== 'CallExpression' || !expression.expression.optional) ) { - error(expression, 'invalid-render-expression'); + e.render_tag_invalid_expression(expression); } parser.allow_whitespace(); diff --git a/packages/svelte/src/compiler/phases/2-analyze/a11y.js b/packages/svelte/src/compiler/phases/2-analyze/a11y.js index eb87b5ddbf..a45c3c1f97 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/a11y.js +++ b/packages/svelte/src/compiler/phases/2-analyze/a11y.js @@ -7,11 +7,12 @@ import { regex_starts_with_vowel, regex_whitespaces } from '../patterns.js'; -import { warn } from '../../warnings.js'; +import * as w from '../../warnings.js'; import fuzzymatch from '../1-parse/utils/fuzzymatch.js'; import { is_event_attribute, is_text_attribute } from '../../utils/ast.js'; import { ContentEditableBindings } from '../constants.js'; import { walk } from 'zimmerframe'; +import { list } from '../../utils/string.js'; const aria_roles = roles_map.keys(); const abstract_roles = aria_roles.filter((role) => roles_map.get(role)?.abstract); @@ -102,7 +103,7 @@ function has_disabled_attribute(attribute_map) { const aria_disabled_attr = attribute_map.get('aria-disabled'); if (aria_disabled_attr) { const aria_disabled_attr_value = get_static_value(aria_disabled_attr); - if (aria_disabled_attr_value === true) { + if (aria_disabled_attr_value === 'true') { return true; } } @@ -593,40 +594,52 @@ function is_parent(parent, elements) { } /** + * @param {import('#compiler').Attribute} attribute + * @param {import('aria-query').ARIAProperty} name * @param {import('aria-query').ARIAPropertyDefinition} schema - * @param {string | boolean} value - * @returns {boolean} + * @param {string | true | null} value */ -function is_valid_aria_attribute_value(schema, value) { - switch (schema.type) { - case 'boolean': - return typeof value === 'boolean'; - case 'string': - case 'id': - return typeof value === 'string'; - case 'tristate': - return typeof value === 'boolean' || value === 'mixed'; - case 'integer': - case 'number': - return typeof value !== 'boolean' && isNaN(Number(value)) === false; - case 'token': // single token - return ( - (schema.values || []).indexOf(typeof value === 'string' ? value.toLowerCase() : value) > -1 - ); - case 'idlist': // if list of ids, split each - return ( - typeof value === 'string' && - value.split(regex_whitespaces).every((id) => typeof id === 'string') +function validate_aria_attribute_value(attribute, name, schema, value) { + const type = schema.type; + + if (value === null) return; + if (value === true) value = ''; + + if (type === 'boolean' && value !== 'true' && value !== 'false') { + w.a11y_incorrect_aria_attribute_type_boolean(attribute, name); + } else if (type === 'integer' && (value === '' || !Number.isInteger(+value))) { + w.a11y_incorrect_aria_attribute_type_integer(attribute, name); + } else if (type === 'number' && (value === '' || isNaN(+value))) { + w.a11y_incorrect_aria_attribute_type(attribute, name, 'number'); + } else if ((type === 'string' || type === 'id') && value === '') { + w.a11y_incorrect_aria_attribute_type(attribute, name, 'non-empty string'); + } else if (type === 'idlist' && value === '') { + w.a11y_incorrect_aria_attribute_type_idlist(attribute, name); + } else if (type === 'token') { + const values = (schema.values ?? []).map((value) => value.toString()); + if (!values.includes(value.toLowerCase())) { + w.a11y_incorrect_aria_attribute_type_token( + attribute, + name, + list(values.map((v) => `"${v}"`)) ); - case 'tokenlist': // if list of tokens, split each - return ( - typeof value === 'string' && - value - .split(regex_whitespaces) - .every((token) => (schema.values || []).indexOf(token.toLowerCase()) > -1) + } + } else if (type === 'tokenlist') { + const values = (schema.values ?? []).map((value) => value.toString()); + if ( + value + .toLowerCase() + .split(regex_whitespaces) + .some((value) => !values.includes(value)) + ) { + w.a11y_incorrect_aria_attribute_type_tokenlist( + attribute, + name, + list(values.map((v) => `"${v}"`)) ); - default: - return false; + } + } else if (type === 'tristate' && value !== 'true' && value !== 'false' && value !== 'mixed') { + w.a11y_incorrect_aria_attribute_type_tristate(attribute, name); } } @@ -642,7 +655,8 @@ function warn_missing_attribute(node, attributes, name = node.name) { attributes.length > 1 ? attributes.slice(0, -1).join(', ') + ` or ${attributes[attributes.length - 1]}` : attributes[0]; - return /** @type {const} */ ([node, 'a11y-missing-attribute', name, article, sequence]); + + w.a11y_missing_attribute(node, name, article, sequence); } /** @@ -667,22 +681,11 @@ function get_static_text_value(attribute) { /** * @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} node * @param {import('./types.js').AnalysisState} state - * @param {import('#compiler').SvelteNode[]} path */ -function check_element(node, state, path) { +function check_element(node, state) { // foreign namespace means elements can have completely different meanings, therefore we don't check them if (state.options.namespace === 'foreign') return; - /** - * @template {keyof import('../../warnings.js').AllWarnings} T - * @param {{ start?: number, end?: number }} node - * @param {T} code - * @param {Parameters} args - * @returns {void} - */ - const push_warning = (node, code, ...args) => - warn(state.analysis.warnings, node, path, code, ...args); - /** @type {Map} */ const attribute_map = new Map(); @@ -729,28 +732,30 @@ function check_element(node, state, path) { if (name.startsWith('aria-')) { if (invisible_elements.includes(node.name)) { // aria-unsupported-elements - push_warning(attribute, 'a11y-aria-attributes', node.name); + w.a11y_aria_attributes(attribute, node.name); } const type = name.slice(5); if (!aria_attributes.includes(type)) { const match = fuzzymatch(type, aria_attributes); - push_warning(attribute, 'a11y-unknown-aria-attribute', type, match); + w.a11y_unknown_aria_attribute(attribute, type, match); } if (name === 'aria-hidden' && regex_heading_tags.test(node.name)) { - push_warning(attribute, 'a11y-hidden', node.name); + w.a11y_hidden(attribute, node.name); } // aria-proptypes let value = get_static_value(attribute); - if (value === 'true') value = true; - if (value === 'false') value = false; - if (value !== null && value !== undefined) { - const schema = aria.get(/** @type {import('aria-query').ARIAProperty} */ (name)); - if (schema !== undefined && !is_valid_aria_attribute_value(schema, value)) { - push_warning(attribute, 'a11y-incorrect-aria-attribute-type', schema, name); - } + + const schema = aria.get(/** @type {import('aria-query').ARIAProperty} */ (name)); + if (schema !== undefined) { + validate_aria_attribute_value( + attribute, + /** @type {import('aria-query').ARIAProperty} */ (name), + schema, + value + ); } // aria-activedescendant-has-tabindex @@ -760,7 +765,7 @@ function check_element(node, state, path) { !is_interactive_element(node.name, attribute_map) && !attribute_map.has('tabindex') ) { - push_warning(attribute, 'a11y-aria-activedescendant-has-tabindex'); + w.a11y_aria_activedescendant_has_tabindex(attribute); } } @@ -768,7 +773,7 @@ function check_element(node, state, path) { if (name === 'role') { if (invisible_elements.includes(node.name)) { // aria-unsupported-elements - push_warning(attribute, 'a11y-misplaced-role', node.name); + w.a11y_misplaced_role(attribute, node.name); } const value = get_static_value(attribute); @@ -778,10 +783,10 @@ function check_element(node, state, path) { /** @type {import('aria-query').ARIARoleDefinitionKey} current_role */ (c_r); if (current_role && is_abstract_role(current_role)) { - push_warning(attribute, 'a11y-no-abstract-role', current_role); + w.a11y_no_abstract_role(attribute, current_role); } else if (current_role && !aria_roles.includes(current_role)) { const match = fuzzymatch(current_role, aria_roles); - push_warning(attribute, 'a11y-unknown-role', current_role, match); + w.a11y_unknown_role(attribute, current_role, match); } // no-redundant-roles @@ -790,7 +795,7 @@ function check_element(node, state, path) { //