feat(frontend): scaffold SvelteKit with TS 7.0 (native-preview) and tsgo

This commit is contained in:
2026-04-28 05:16:19 +02:00
parent 943463fff4
commit f9c721d841
2014 changed files with 415452 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
/** @import { FlipParams, AnimationConfig } from './public.js' */
import { cubicOut } from '../easing/index.js';
/**
* The flip function calculates the start and end position of an element and animates between them, translating the x and y values.
* `flip` stands for [First, Last, Invert, Play](https://aerotwist.com/blog/flip-your-animations/).
*
* @param {Element} node
* @param {{ from: DOMRect; to: DOMRect }} fromTo
* @param {FlipParams} params
* @returns {AnimationConfig}
*/
export function flip(node, { from, to }, params = {}) {
var { delay = 0, duration = (d) => Math.sqrt(d) * 120, easing = cubicOut } = params;
var style = getComputedStyle(node);
// find the transform origin, expressed as a pair of values between 0 and 1
var transform = style.transform === 'none' ? '' : style.transform;
var [ox, oy] = style.transformOrigin.split(' ').map(parseFloat);
ox /= node.clientWidth;
oy /= node.clientHeight;
// calculate effect of parent transforms and zoom
var zoom = get_zoom(node); // https://drafts.csswg.org/css-viewport/#effective-zoom
var sx = node.clientWidth / to.width / zoom;
var sy = node.clientHeight / to.height / zoom;
// find the starting position of the transform origin
var fx = from.left + from.width * ox;
var fy = from.top + from.height * oy;
// find the ending position of the transform origin
var tx = to.left + to.width * ox;
var ty = to.top + to.height * oy;
// find the translation at the start of the transform
var dx = (fx - tx) * sx;
var dy = (fy - ty) * sy;
// find the relative scale at the start of the transform
var dsx = from.width / to.width;
var dsy = from.height / to.height;
return {
delay,
duration: typeof duration === 'function' ? duration(Math.sqrt(dx * dx + dy * dy)) : duration,
easing,
css: (t, u) => {
var x = u * dx;
var y = u * dy;
var sx = t + u * dsx;
var sy = t + u * dsy;
return `transform: ${transform} translate(${x}px, ${y}px) scale(${sx}, ${sy});`;
}
};
}
/**
* @param {Element} element
*/
function get_zoom(element) {
if ('currentCSSZoom' in element) {
return /** @type {number} */ (element.currentCSSZoom);
}
/** @type {Element | null} */
var current = element;
var zoom = 1;
while (current !== null) {
zoom *= +getComputedStyle(current).zoom;
current = /** @type {Element | null} */ (current.parentElement);
}
return zoom;
}
+113
View File
@@ -0,0 +1,113 @@
/** @import { Action, ActionReturn } from '../action/public' */
/** @import { Attachment } from './public' */
import { noop, render_effect } from 'svelte/internal/client';
import { ATTACHMENT_KEY } from '../constants.js';
import { untrack } from '../index-client.js';
import { teardown } from '../internal/client/reactivity/effects.js';
/**
* Creates an object key that will be recognised as an attachment when the object is spread onto an element,
* as a programmatic alternative to using `{@attach ...}`. This can be useful for library authors, though
* is generally not needed when building an app.
*
* ```svelte
* <script>
* import { createAttachmentKey } from 'svelte/attachments';
*
* const props = {
* class: 'cool',
* onclick: () => alert('clicked'),
* [createAttachmentKey()]: (node) => {
* node.textContent = 'attached!';
* }
* };
* </script>
*
* <button {...props}>click me</button>
* ```
* @since 5.29
*/
export function createAttachmentKey() {
return Symbol(ATTACHMENT_KEY);
}
/**
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
* It's useful if you want to start using attachments on components but you have actions provided by a library.
*
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
* action function, not the argument itself.
*
* ```svelte
* <!-- with an action -->
* <div use:foo={bar}>...</div>
*
* <!-- with an attachment -->
* <div {@attach fromAction(foo, () => bar)}>...</div>
* ```
* @template {EventTarget} E
* @template {unknown} T
* @overload
* @param {Action<E, T> | ((element: E, arg: T) => void | ActionReturn<T>)} action The action function
* @param {() => T} fn A function that returns the argument for the action
* @returns {Attachment<E>}
*/
/**
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
* It's useful if you want to start using attachments on components but you have actions provided by a library.
*
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
* action function, not the argument itself.
*
* ```svelte
* <!-- with an action -->
* <div use:foo={bar}>...</div>
*
* <!-- with an attachment -->
* <div {@attach fromAction(foo, () => bar)}>...</div>
* ```
* @template {EventTarget} E
* @overload
* @param {Action<E, void> | ((element: E) => void | ActionReturn<void>)} action The action function
* @returns {Attachment<E>}
*/
/**
* Converts an [action](https://svelte.dev/docs/svelte/use) into an [attachment](https://svelte.dev/docs/svelte/@attach) keeping the same behavior.
* It's useful if you want to start using attachments on components but you have actions provided by a library.
*
* Note that the second argument, if provided, must be a function that _returns_ the argument to the
* action function, not the argument itself.
*
* ```svelte
* <!-- with an action -->
* <div use:foo={bar}>...</div>
*
* <!-- with an attachment -->
* <div {@attach fromAction(foo, () => bar)}>...</div>
* ```
*
* @template {EventTarget} E
* @template {unknown} T
* @param {Action<E, T> | ((element: E, arg: T) => void | ActionReturn<T>)} action The action function
* @param {() => T} fn A function that returns the argument for the action
* @returns {Attachment<E>}
* @since 5.32
*/
export function fromAction(action, fn = /** @type {() => T} */ (noop)) {
return (element) => {
const { update, destroy } = untrack(() => action(element, fn()) ?? {});
if (update) {
var ran = false;
render_effect(() => {
const arg = fn();
if (ran) update(arg);
});
ran = true;
}
if (destroy) {
teardown(destroy);
}
};
}
File diff suppressed because it is too large Load Diff
+201
View File
@@ -0,0 +1,201 @@
/** @import { LegacyRoot } from './types/legacy-nodes.js' */
/** @import { CompileOptions, CompileResult, ValidatedCompileOptions, ModuleCompileOptions } from '#compiler' */
/** @import { AST } from './public.js' */
import { walk as zimmerframe_walk } from 'zimmerframe';
import { convert } from './legacy.js';
import { parse as _parse, Parser } from './phases/1-parse/index.js';
import { remove_typescript_nodes } from './phases/1-parse/remove_typescript_nodes.js';
import { parse_stylesheet } from './phases/1-parse/read/style.js';
import { analyze_component, analyze_module } from './phases/2-analyze/index.js';
import { transform_component, transform_module } from './phases/3-transform/index.js';
import { validate_component_options, validate_module_options } from './validate-options.js';
import * as state from './state.js';
export { default as preprocess } from './preprocess/index.js';
export { print } from './print/index.js';
/**
* `compile` converts your `.svelte` source code into a JavaScript module that exports a component
*
* @param {string} source The component source code
* @param {CompileOptions} options The compiler options
* @returns {CompileResult}
*/
export function compile(source, options) {
source = remove_bom(source);
state.reset({ warning: options.warningFilter, filename: options.filename });
const validated = validate_component_options(options, '');
let parsed = _parse(source);
const { customElement: customElementOptions, ...parsed_options } = parsed.options || {};
/** @type {ValidatedCompileOptions} */
const combined_options = {
...validated,
...parsed_options,
customElementOptions,
css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : validated.css,
runes: 'runes' in parsed_options ? () => parsed_options.runes : validated.runes
};
if (parsed.metadata.ts) {
parsed = {
...parsed,
fragment: parsed.fragment && remove_typescript_nodes(parsed.fragment),
instance: parsed.instance && remove_typescript_nodes(parsed.instance),
module: parsed.module && remove_typescript_nodes(parsed.module)
};
if (combined_options.customElementOptions?.extend) {
combined_options.customElementOptions.extend = remove_typescript_nodes(
combined_options.customElementOptions?.extend
);
}
}
const analysis = analyze_component(parsed, source, combined_options);
const result = transform_component(analysis, source, combined_options);
result.ast = to_public_ast(source, parsed, options.modernAst);
return result;
}
/**
* `compileModule` takes your JavaScript source code containing runes, and turns it into a JavaScript module.
*
* @param {string} source The component source code
* @param {ModuleCompileOptions} options
* @returns {CompileResult}
*/
export function compileModule(source, options) {
source = remove_bom(source);
state.reset({ warning: options.warningFilter, filename: options.filename });
const validated = validate_module_options(options, '');
const analysis = analyze_module(source, validated);
return transform_module(analysis, source, validated);
}
/**
* The parse function parses a component, returning only its abstract syntax tree.
*
* The `modern` option (`false` by default in Svelte 5) makes the parser return a modern AST instead of the legacy AST.
* `modern` will become `true` by default in Svelte 6, and the option will be removed in Svelte 7.
*
* @overload
* @param {string} source
* @param {{ filename?: string; modern: true; loose?: boolean }} options
* @returns {AST.Root}
*/
/**
* The parse function parses a component, returning only its abstract syntax tree.
*
* The `modern` option (`false` by default in Svelte 5) makes the parser return a modern AST instead of the legacy AST.
* `modern` will become `true` by default in Svelte 6, and the option will be removed in Svelte 7.
*
* @overload
* @param {string} source
* @param {{ filename?: string; modern?: false; loose?: boolean }} [options]
* @returns {Record<string, any>}
*/
// TODO 6.0 remove unused `filename`
/**
* The parse function parses a component, returning only its abstract syntax tree.
*
* The `modern` option (`false` by default in Svelte 5) makes the parser return a modern AST instead of the legacy AST.
* `modern` will become `true` by default in Svelte 6, and the option will be removed in Svelte 7.
*
* The `loose` option, available since 5.13.0, tries to always return an AST even if the input will not successfully compile.
*
* The `filename` option is unused and will be removed in Svelte 6.0.
*
* @param {string} source
* @param {{ filename?: string; rootDir?: string; modern?: boolean; loose?: boolean }} [options]
* @returns {AST.Root | LegacyRoot}
*/
export function parse(source, { modern, loose } = {}) {
source = remove_bom(source);
state.reset({ warning: () => false, filename: undefined });
const ast = _parse(source, loose);
return to_public_ast(source, ast, modern);
}
/**
* The parseCss function parses a CSS stylesheet, returning its abstract syntax tree.
*
* @param {string} source The CSS source code
* @returns {AST.CSS.StyleSheetFile}
*/
export function parseCss(source) {
source = remove_bom(source);
state.reset({ warning: () => false, filename: undefined });
state.set_source(source);
const parser = Parser.forCss(source);
const children = parse_stylesheet(parser);
return {
type: 'StyleSheetFile',
start: 0,
end: source.length,
children
};
}
/**
* @param {string} source
* @param {AST.Root} ast
* @param {boolean | undefined} modern
*/
function to_public_ast(source, ast, modern) {
if (modern) {
const clean = (/** @type {any} */ node) => {
delete node.metadata;
};
ast.options?.attributes.forEach((attribute) => {
clean(attribute);
clean(attribute.value);
if (Array.isArray(attribute.value)) {
attribute.value.forEach(clean);
}
});
// remove things that we don't want to treat as public API
return zimmerframe_walk(ast, null, {
_(node, { next }) {
clean(node);
next();
}
});
}
return convert(source, ast);
}
/**
* Remove the byte order mark from a string if it's present since it would mess with our template generation logic
* @param {string} source
*/
function remove_bom(source) {
if (source.charCodeAt(0) === 0xfeff) {
return source.slice(1);
}
return source;
}
/**
* @deprecated Replace this with `import { walk } from 'estree-walker'`
* @returns {never}
*/
export function walk() {
throw new Error(
`'svelte/compiler' no longer exports a \`walk\` utility — please import it directly from 'estree-walker' instead`
);
}
export { VERSION } from '../version.js';
export { migrate } from './migrate/index.js';
+637
View File
@@ -0,0 +1,637 @@
/** @import { Expression } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import * as Legacy from './types/legacy-nodes.js' */
import { walk } from 'zimmerframe';
import {
regex_ends_with_whitespaces,
regex_not_whitespace,
regex_starts_with_whitespaces
} from './phases/patterns.js';
import { extract_svelte_ignore } from './utils/extract_svelte_ignore.js';
/**
* Some of the legacy Svelte AST nodes remove whitespace from the start and end of their children.
* @param {AST.TemplateNode[]} nodes
*/
function remove_surrounding_whitespace_nodes(nodes) {
const first = nodes.at(0);
const last = nodes.at(-1);
if (first?.type === 'Text') {
if (!regex_not_whitespace.test(first.data)) {
nodes.shift();
} else {
first.data = first.data.replace(regex_starts_with_whitespaces, '');
}
}
if (last?.type === 'Text') {
if (!regex_not_whitespace.test(last.data)) {
nodes.pop();
} else {
last.data = last.data.replace(regex_ends_with_whitespaces, '');
}
}
}
/**
* Transform our nice modern AST into the monstrosity emitted by Svelte 4
* @param {string} source
* @param {AST.Root} ast
* @returns {Legacy.LegacyRoot}
*/
export function convert(source, ast) {
const root = /** @type {AST.SvelteNode | Legacy.LegacySvelteNode} */ (ast);
return /** @type {Legacy.LegacyRoot} */ (
walk(root, null, {
_(node, { next }) {
// @ts-ignore
delete node.metadata;
next();
},
// @ts-ignore
Root(node, { visit }) {
const { instance, module, options } = node;
// Insert svelte:options back into the root nodes
if (/** @type {any} */ (options)?.__raw__) {
let idx = node.fragment.nodes.findIndex(
(node) => /** @type {any} */ (options).end <= node.start
);
if (idx === -1) {
idx = node.fragment.nodes.length;
}
node.fragment.nodes.splice(idx, 0, /** @type {any} */ (options).__raw__);
}
/** @type {number | null} */
let start = null;
/** @type {number | null} */
let end = null;
if (node.fragment.nodes.length > 0) {
const first = /** @type {AST.BaseNode} */ (node.fragment.nodes.at(0));
const last = /** @type {AST.BaseNode} */ (node.fragment.nodes.at(-1));
start = first.start;
end = last.end;
while (/\s/.test(source[start])) start += 1;
while (/\s/.test(source[end - 1])) end -= 1;
}
if (instance) {
// @ts-ignore
delete instance.attributes;
}
if (module) {
// @ts-ignore
delete module.attributes;
}
return {
html: {
type: 'Fragment',
start,
end,
children: node.fragment.nodes.map((child) => visit(child))
},
instance,
module,
css: ast.css ? visit(ast.css) : undefined,
// put it on _comments not comments because the latter is checked by prettier and then fails
// if we don't adjust stuff accordingly in our prettier plugin, and so it would be kind of an
// indirect breaking change for people updating their Svelte version but not their prettier plugin version.
// We can keep it as comments for the modern AST because the modern AST is not used in the plugin yet.
_comments: ast.comments?.length > 0 ? ast.comments : undefined
};
},
AnimateDirective(node) {
return { ...node, type: 'Animation' };
},
// @ts-ignore
AwaitBlock(node, { visit }) {
let pendingblock = {
type: 'PendingBlock',
/** @type {number | null} */
start: null,
/** @type {number | null} */
end: null,
children: node.pending?.nodes.map((child) => visit(child)) ?? [],
skip: true
};
let thenblock = {
type: 'ThenBlock',
/** @type {number | null} */
start: null,
/** @type {number | null} */
end: null,
children: node.then?.nodes.map((child) => visit(child)) ?? [],
skip: true
};
let catchblock = {
type: 'CatchBlock',
/** @type {number | null} */
start: null,
/** @type {number | null} */
end: null,
children: node.catch?.nodes.map((child) => visit(child)) ?? [],
skip: true
};
if (node.pending) {
const first = node.pending.nodes.at(0);
const last = node.pending.nodes.at(-1);
pendingblock.start = first?.start ?? source.indexOf('}', node.expression.end) + 1;
pendingblock.end = last?.end ?? pendingblock.start;
pendingblock.skip = false;
}
if (node.then) {
const first = node.then.nodes.at(0);
const last = node.then.nodes.at(-1);
thenblock.start =
pendingblock.end ?? first?.start ?? source.indexOf('}', node.expression.end) + 1;
thenblock.end =
last?.end ?? source.lastIndexOf('}', pendingblock.end ?? node.expression.end) + 1;
thenblock.skip = false;
}
if (node.catch) {
const first = node.catch.nodes.at(0);
const last = node.catch.nodes.at(-1);
catchblock.start =
thenblock.end ??
pendingblock.end ??
first?.start ??
source.indexOf('}', node.expression.end) + 1;
catchblock.end =
last?.end ??
source.lastIndexOf('}', thenblock.end ?? pendingblock.end ?? node.expression.end) + 1;
catchblock.skip = false;
}
return {
type: 'AwaitBlock',
start: node.start,
end: node.end,
expression: node.expression,
value: node.value,
error: node.error,
pending: pendingblock,
then: thenblock,
catch: catchblock
};
},
BindDirective(node) {
return { ...node, type: 'Binding' };
},
ClassDirective(node) {
return { ...node, type: 'Class' };
},
Comment(node) {
return {
...node,
ignores: extract_svelte_ignore(node.start, node.data, false)
};
},
ComplexSelector(node, { next }) {
next(); // delete inner metadata/parent properties
const children = [];
for (const child of node.children) {
if (child.combinator) {
children.push(child.combinator);
}
children.push(...child.selectors);
}
return {
type: 'Selector',
start: node.start,
end: node.end,
children
};
},
Component(node, { visit }) {
return {
type: 'InlineComponent',
start: node.start,
end: node.end,
name: node.name,
attributes: node.attributes.map(
(child) => /** @type {Legacy.LegacyAttributeLike} */ (visit(child))
),
children: node.fragment.nodes.map(
(child) => /** @type {Legacy.LegacyElementLike} */ (visit(child))
)
};
},
// @ts-ignore
ConstTag(node) {
if (/** @type {Legacy.LegacyConstTag} */ (node).expression !== undefined) {
return node;
}
const modern_node = /** @type {AST.ConstTag} */ (node);
const { id: left } = { ...modern_node.declaration.declarations[0] };
// @ts-ignore
delete left.typeAnnotation;
return {
type: 'ConstTag',
start: modern_node.start,
end: node.end,
expression: {
type: 'AssignmentExpression',
start: (modern_node.declaration.start ?? 0) + 'const '.length,
end: modern_node.declaration.end ?? 0,
operator: '=',
left,
right: modern_node.declaration.declarations[0].init
}
};
},
// @ts-ignore
KeyBlock(node, { visit }) {
remove_surrounding_whitespace_nodes(node.fragment.nodes);
return {
type: 'KeyBlock',
start: node.start,
end: node.end,
expression: node.expression,
children: node.fragment.nodes.map(
(child) => /** @type {Legacy.LegacyElementLike} */ (visit(child))
)
};
},
// @ts-ignore
EachBlock(node, { visit }) {
let elseblock = undefined;
if (node.fallback) {
const first = node.fallback.nodes.at(0);
const end = source.lastIndexOf('{', /** @type {number} */ (node.end) - 1);
const start = first?.start ?? end;
remove_surrounding_whitespace_nodes(node.fallback.nodes);
elseblock = {
type: 'ElseBlock',
start,
end,
children: node.fallback.nodes.map((child) => visit(child))
};
}
remove_surrounding_whitespace_nodes(node.body.nodes);
return {
type: 'EachBlock',
start: node.start,
end: node.end,
children: node.body.nodes.map((child) => visit(child)),
context: node.context,
expression: node.expression,
index: node.index,
key: node.key,
else: elseblock
};
},
ExpressionTag(node, { path }) {
const parent = path.at(-1);
if (parent?.type === 'Attribute') {
if (source[parent.start] === '{') {
return {
type: 'AttributeShorthand',
start: node.start,
end: node.end,
expression: node.expression
};
}
}
return {
type: 'MustacheTag',
start: node.start,
end: node.end,
expression: node.expression
};
},
HtmlTag(node) {
return { ...node, type: 'RawMustacheTag' };
},
// @ts-ignore
IfBlock(node, { visit }) {
let elseblock = undefined;
if (node.alternate) {
let nodes = node.alternate.nodes;
if (nodes.length === 1 && nodes[0].type === 'IfBlock' && nodes[0].elseif) {
nodes = nodes[0].consequent.nodes;
}
const end = source.lastIndexOf('{', /** @type {number} */ (node.end) - 1);
const start = nodes.at(0)?.start ?? end;
remove_surrounding_whitespace_nodes(node.alternate.nodes);
elseblock = {
type: 'ElseBlock',
start,
end: end,
children: node.alternate.nodes.map(
(child) => /** @type {Legacy.LegacyElementLike} */ (visit(child))
)
};
}
const start = node.elseif
? node.consequent.nodes[0]?.start ??
source.lastIndexOf('{', /** @type {number} */ (node.end) - 1)
: node.start;
remove_surrounding_whitespace_nodes(node.consequent.nodes);
return {
type: 'IfBlock',
start,
end: node.end,
expression: node.test,
children: node.consequent.nodes.map(
(child) => /** @type {Legacy.LegacyElementLike} */ (visit(child))
),
else: elseblock,
elseif: node.elseif ? true : undefined
};
},
OnDirective(node) {
return { ...node, type: 'EventHandler' };
},
// @ts-expect-error
SnippetBlock(node, { visit }) {
remove_surrounding_whitespace_nodes(node.body.nodes);
return {
type: 'SnippetBlock',
start: node.start,
end: node.end,
expression: node.expression,
parameters: node.parameters,
children: node.body.nodes.map((child) => visit(child)),
typeParams: node.typeParams
};
},
// @ts-expect-error
SvelteBoundary(node, { visit }) {
remove_surrounding_whitespace_nodes(node.fragment.nodes);
return {
type: 'SvelteBoundary',
name: 'svelte:boundary',
start: node.start,
end: node.end,
attributes: node.attributes.map(
(child) => /** @type {Legacy.LegacyAttributeLike} */ (visit(child))
),
children: node.fragment.nodes.map((child) => visit(child))
};
},
RegularElement(node, { visit }) {
return {
type: 'Element',
start: node.start,
end: node.end,
name: node.name,
attributes: node.attributes.map((child) => visit(child)),
children: node.fragment.nodes.map((child) => visit(child))
};
},
SlotElement(node, { visit }) {
return {
type: 'Slot',
start: node.start,
end: node.end,
name: node.name,
attributes: node.attributes.map(
(child) => /** @type {Legacy.LegacyAttributeLike} */ (visit(child))
),
children: node.fragment.nodes.map(
(child) => /** @type {Legacy.LegacyElementLike} */ (visit(child))
)
};
},
Attribute(node, { visit, next, path }) {
if (node.value !== true && !Array.isArray(node.value)) {
path.push(node);
const value = /** @type {Legacy.LegacyAttribute['value']} */ ([visit(node.value)]);
path.pop();
return {
...node,
value
};
} else {
return next();
}
},
StyleDirective(node, { visit, next, path }) {
if (node.value !== true && !Array.isArray(node.value)) {
path.push(node);
const value = /** @type {Legacy.LegacyStyleDirective['value']} */ ([visit(node.value)]);
path.pop();
return {
...node,
value
};
} else {
return next();
}
},
SpreadAttribute(node) {
return { ...node, type: 'Spread' };
},
// @ts-ignore
StyleSheet(node, context) {
return {
...node,
...context.next(),
type: 'Style'
};
},
SvelteBody(node, { visit }) {
return {
type: 'Body',
name: 'svelte:body',
start: node.start,
end: node.end,
attributes: node.attributes.map(
(child) => /** @type {Legacy.LegacyAttributeLike} */ (visit(child))
),
children: node.fragment.nodes.map(
(child) => /** @type {Legacy.LegacyElementLike} */ (visit(child))
)
};
},
SvelteComponent(node, { visit }) {
return {
type: 'InlineComponent',
name: 'svelte:component',
start: node.start,
end: node.end,
expression: node.expression,
attributes: node.attributes.map(
(child) => /** @type {Legacy.LegacyAttributeLike} */ (visit(child))
),
children: node.fragment.nodes.map(
(child) => /** @type {Legacy.LegacyElementLike} */ (visit(child))
)
};
},
SvelteDocument(node, { visit }) {
return {
type: 'Document',
name: 'svelte:document',
start: node.start,
end: node.end,
attributes: node.attributes.map(
(child) => /** @type {Legacy.LegacyAttributeLike} */ (visit(child))
),
children: node.fragment.nodes.map(
(child) => /** @type {Legacy.LegacyElementLike} */ (visit(child))
)
};
},
SvelteElement(node, { visit }) {
/** @type {Expression | string} */
let tag = node.tag;
if (
tag.type === 'Literal' &&
typeof tag.value === 'string' &&
source[/** @type {number} */ (node.tag.start) - 1] !== '{'
) {
tag = tag.value;
}
return {
type: 'Element',
name: 'svelte:element',
start: node.start,
end: node.end,
tag,
attributes: node.attributes.map((child) => visit(child)),
children: node.fragment.nodes.map((child) => visit(child))
};
},
SvelteFragment(node, { visit }) {
return {
type: 'SlotTemplate',
name: 'svelte:fragment',
start: node.start,
end: node.end,
attributes: node.attributes.map(
(a) => /** @type {Legacy.LegacyAttributeLike} */ (visit(a))
),
children: node.fragment.nodes.map(
(child) => /** @type {Legacy.LegacyElementLike} */ (visit(child))
)
};
},
SvelteHead(node, { visit }) {
return {
type: 'Head',
name: 'svelte:head',
start: node.start,
end: node.end,
attributes: node.attributes.map(
(child) => /** @type {Legacy.LegacyAttributeLike} */ (visit(child))
),
children: node.fragment.nodes.map(
(child) => /** @type {Legacy.LegacyElementLike} */ (visit(child))
)
};
},
SvelteOptions(node, { visit }) {
return {
type: 'Options',
name: 'svelte:options',
start: node.start,
end: node.end,
attributes: node.attributes.map(
(child) => /** @type {Legacy.LegacyAttributeLike} */ (visit(child))
)
};
},
SvelteSelf(node, { visit }) {
return {
type: 'InlineComponent',
name: 'svelte:self',
start: node.start,
end: node.end,
attributes: node.attributes.map(
(child) => /** @type {Legacy.LegacyAttributeLike} */ (visit(child))
),
children: node.fragment.nodes.map(
(child) => /** @type {Legacy.LegacyElementLike} */ (visit(child))
)
};
},
SvelteWindow(node, { visit }) {
return {
type: 'Window',
name: 'svelte:window',
start: node.start,
end: node.end,
attributes: node.attributes.map(
(child) => /** @type {Legacy.LegacyAttributeLike} */ (visit(child))
),
children: node.fragment.nodes.map(
(child) => /** @type {Legacy.LegacyElementLike} */ (visit(child))
)
};
},
Text(node, { path }) {
const parent = path.at(-1);
if (parent?.type === 'RegularElement' && parent.name === 'style') {
// these text nodes are missing `raw` for some dumb reason
return /** @type {AST.Text} */ ({
type: 'Text',
start: node.start,
end: node.end,
data: node.data
});
}
},
TitleElement(node, { visit }) {
return {
type: 'Title',
name: 'title',
start: node.start,
end: node.end,
attributes: node.attributes.map(
(child) => /** @type {Legacy.LegacyAttributeLike} */ (visit(child))
),
children: node.fragment.nodes.map(
(child) => /** @type {Legacy.LegacyElementLike} */ (visit(child))
)
};
},
TransitionDirective(node) {
return { ...node, type: 'Transition' };
},
UseDirective(node) {
return { ...node, type: 'Action' };
},
LetDirective(node) {
return { ...node, type: 'Let' };
}
})
);
}
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
/** @import { Comment, Program } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { Parser } from './index.js' */
import * as acorn from 'acorn';
import { walk } from 'zimmerframe';
import { tsPlugin } from '@sveltejs/acorn-typescript';
import * as e from '../../errors.js';
const JSParser = acorn.Parser;
const TSParser = JSParser.extend(tsPlugin());
/**
* @typedef {Comment & {
* start: number;
* end: number;
* }} CommentWithLocation
*/
/**
* @param {string} source
* @param {AST.JSComment[]} comments
* @param {boolean} typescript
* @param {boolean} [is_script]
*/
export function parse(source, comments, typescript, is_script) {
const acorn = typescript ? TSParser : JSParser;
const { onComment, add_comments } = get_comment_handlers(
source,
/** @type {CommentWithLocation[]} */ (comments)
);
// @ts-expect-error
const parse_statement = acorn.prototype.parseStatement;
// If we're dealing with a <script> then it might contain an export
// for something that doesn't exist directly inside but is inside the
// component instead, so we need to ensure that Acorn doesn't throw
// an error in these cases
if (is_script) {
// @ts-ignore
acorn.prototype.parseStatement = function (...args) {
const v = parse_statement.call(this, ...args);
// @ts-ignore
this.undefinedExports = {};
return v;
};
}
try {
const ast = acorn.parse(source, {
onComment,
sourceType: 'module',
ecmaVersion: 16,
locations: true
});
add_comments(ast);
return /** @type {Program} */ (ast);
} catch (err) {
// TODO the `return` in necessary for TS<7 due to a bug; otherwise
// the `finally` block is regarded as unreachable
return handle_parse_error(err);
} finally {
if (is_script) {
// @ts-expect-error
acorn.prototype.parseStatement = parse_statement;
}
}
}
/**
* @param {Parser} parser
* @param {string} source
* @param {number} index
* @returns {acorn.Expression & { leadingComments?: CommentWithLocation[]; trailingComments?: CommentWithLocation[]; }}
*/
export function parse_expression_at(parser, source, index) {
const acorn = parser.ts ? TSParser : JSParser;
const { onComment, add_comments } = get_comment_handlers(source, parser.root.comments, index);
try {
const ast = acorn.parseExpressionAt(source, index, {
onComment,
sourceType: 'module',
ecmaVersion: 16,
locations: true,
preserveParens: true
});
add_comments(ast);
return ast;
} catch (e) {
handle_parse_error(e);
}
}
const regex_position_indicator = / \(\d+:\d+\)$/;
/**
* @param {any} err
* @returns {never}
*/
function handle_parse_error(err) {
e.js_parse_error(err.pos, err.message.replace(regex_position_indicator, ''));
}
/**
* @param {acorn.Expression} node
* @returns {acorn.Expression}
*/
export function remove_parens(node) {
return walk(node, null, {
ParenthesizedExpression(node, context) {
return context.visit(node.expression);
}
});
}
/**
* Acorn doesn't add comments to the AST by itself. This factory returns the capabilities
* to add them after the fact. They are needed in order to support `svelte-ignore` comments
* in JS code and so that `prettier-plugin-svelte` doesn't remove all comments when formatting.
* @param {string} source
* @param {CommentWithLocation[]} comments
* @param {number} index
*/
function get_comment_handlers(source, comments, index = 0) {
return {
/**
* @param {boolean} block
* @param {string} value
* @param {number} start
* @param {number} end
* @param {import('acorn').Position} [start_loc]
* @param {import('acorn').Position} [end_loc]
*/
onComment: (block, value, start, end, start_loc, end_loc) => {
if (block && /\n/.test(value)) {
let a = start;
while (a > 0 && source[a - 1] !== '\n') a -= 1;
let b = a;
while (/[ \t]/.test(source[b])) b += 1;
const indentation = source.slice(a, b);
value = value.replace(new RegExp(`^${indentation}`, 'gm'), '');
}
comments.push({
type: block ? 'Block' : 'Line',
value,
start,
end,
loc: {
start: /** @type {import('acorn').Position} */ (start_loc),
end: /** @type {import('acorn').Position} */ (end_loc)
}
});
},
/** @param {acorn.Node & { leadingComments?: CommentWithLocation[]; trailingComments?: CommentWithLocation[]; }} ast */
add_comments(ast) {
if (comments.length === 0) return;
comments = comments
.filter((comment) => comment.start >= index)
.map(({ type, value, start, end }) => ({ type, value, start, end }));
walk(ast, null, {
_(node, { next, path }) {
let comment;
while (comments[0] && comments[0].start < node.start) {
comment = /** @type {CommentWithLocation} */ (comments.shift());
(node.leadingComments ||= []).push(comment);
}
next();
if (comments[0]) {
const parent = /** @type {any} */ (path.at(-1));
if (parent === undefined || node.end !== parent.end) {
const slice = source.slice(node.end, comments[0].start);
const is_last_in_body =
((parent?.type === 'BlockStatement' || parent?.type === 'Program') &&
parent.body.indexOf(node) === parent.body.length - 1) ||
(parent?.type === 'ArrayExpression' &&
parent.elements.indexOf(node) === parent.elements.length - 1) ||
(parent?.type === 'ObjectExpression' &&
parent.properties.indexOf(node) === parent.properties.length - 1);
if (is_last_in_body) {
// Special case: There can be multiple trailing comments after the last node in a block,
// and they can be separated by newlines
let end = node.end;
while (comments.length) {
const comment = comments[0];
if (parent && comment.start >= parent.end) break;
(node.trailingComments ||= []).push(comment);
comments.shift();
end = comment.end;
}
} else if (node.end <= comments[0].start && /^[,) \t]*$/.test(slice)) {
node.trailingComments = [/** @type {CommentWithLocation} */ (comments.shift())];
}
}
}
}
});
// Special case: Trailing comments after the root node (which can only happen for expression tags or for Program nodes).
// Adding them ensures that we can later detect the end of the expression tag correctly.
if (comments.length > 0 && (comments[0].start >= ast.end || ast.type === 'Program')) {
(ast.trailingComments ||= []).push(...comments.splice(0));
}
}
};
}
+338
View File
@@ -0,0 +1,338 @@
/** @import { AST } from '#compiler' */
/** @import { Location } from 'locate-character' */
/** @import * as ESTree from 'estree' */
// @ts-expect-error acorn type definitions are borked in the release we use
import { isIdentifierStart, isIdentifierChar } from 'acorn';
import fragment from './state/fragment.js';
import * as e from '../../errors.js';
import { create_fragment } from './utils/create.js';
import read_options from './read/options.js';
import { is_reserved } from '../../../utils.js';
import { disallow_children } from '../2-analyze/visitors/shared/special-element.js';
import * as state from '../../state.js';
/** @param {number} cc */
function is_whitespace(cc) {
// fast path for common whitespace
if (cc === 32 || (cc <= 13 && cc >= 9)) return true;
// rare whitespace — \u00a0, \u1680, \u2000-\u200a, \u2028, \u2029, \u202f, \u205f, \u3000, \ufeff
if (cc < 160) return false;
return (
cc === 160 ||
cc === 5760 ||
(cc >= 8192 && cc <= 8202) ||
cc === 8232 ||
cc === 8233 ||
cc === 8239 ||
cc === 8287 ||
cc === 12288 ||
cc === 65279
);
}
const regex_lang_attribute =
/<!--[^]*?-->|<script\s+(?:[^>]*|(?:[^=>'"/]+=(?:"[^"]*"|'[^']*'|[^>\s]+)\s+)*)lang=(["'])?([^"' >]+)\1[^>]*>/g;
export class Parser {
/**
* @readonly
* @type {string}
*/
template;
/**
* Whether or not we're in loose parsing mode, in which
* case we try to continue parsing as much as possible
* @type {boolean}
*/
loose;
/** */
index = 0;
/**
* Creates a minimal parser instance for CSS-only parsing.
* Skips Svelte component parsing setup.
* @param {string} source
* @returns {Parser}
*/
static forCss(source) {
const parser = Object.create(Parser.prototype);
parser.template = source;
parser.index = 0;
parser.loose = false;
return parser;
}
/** Whether we're parsing in TypeScript mode */
ts = false;
/** @type {AST.TemplateNode[]} */
stack = [];
/** @type {AST.Fragment[]} */
fragments = [];
/** @type {AST.Root} */
root;
/** @type {Record<string, boolean>} */
meta_tags = {};
/** @type {LastAutoClosedTag | undefined} */
last_auto_closed_tag;
/**
* @param {string} template
* @param {boolean} loose
*/
constructor(template, loose) {
if (typeof template !== 'string') {
throw new TypeError('Template must be a string');
}
this.loose = loose;
this.template = template.trimEnd();
let match_lang;
do match_lang = regex_lang_attribute.exec(template);
while (match_lang && match_lang[0][1] !== 's'); // ensure it starts with '<s' to match script tags
regex_lang_attribute.lastIndex = 0; // reset matched index to pass tests - otherwise declare the regex inside the constructor
this.ts = match_lang?.[2] === 'ts';
this.root = {
css: null,
js: [],
// @ts-ignore
start: null,
// @ts-ignore
end: null,
type: 'Root',
fragment: create_fragment(),
options: null,
comments: [],
metadata: {
ts: this.ts
}
};
this.stack.push(this.root);
this.fragments.push(this.root.fragment);
/** @type {ParserState} */
let state = fragment;
while (this.index < this.template.length) {
state = state(this) || fragment;
}
if (this.stack.length > 1) {
const current = this.current();
if (this.loose) {
current.end = this.template.length;
} else if (current.type === 'RegularElement') {
current.end = current.start + 1;
e.element_unclosed(current, current.name);
} else {
current.end = current.start + 1;
e.block_unclosed(current);
}
}
if (state !== fragment) {
e.unexpected_eof(this.index);
}
this.root.start = 0;
this.root.end = template.length;
const options_index = this.root.fragment.nodes.findIndex(
/** @param {any} thing */
(thing) => thing.type === 'SvelteOptions'
);
if (options_index !== -1) {
const options = /** @type {AST.SvelteOptionsRaw} */ (this.root.fragment.nodes[options_index]);
this.root.fragment.nodes.splice(options_index, 1);
this.root.options = read_options(options);
disallow_children(options);
// We need this for the old AST format
Object.defineProperty(this.root.options, '__raw__', {
value: options,
enumerable: false
});
}
}
current() {
return this.stack[this.stack.length - 1];
}
/**
* @param {string} str
* @param {boolean} required
* @param {boolean} required_in_loose
*/
eat(str, required = false, required_in_loose = true) {
if (this.match(str)) {
this.index += str.length;
return true;
}
if (required && (!this.loose || required_in_loose)) {
e.expected_token(this.index, str);
}
return false;
}
/** @param {string} str */
match(str) {
const length = str.length;
if (length === 1) {
// more performant than slicing
return this.template[this.index] === str;
}
return this.template.startsWith(str, this.index);
}
/**
* Match a regex at the current index
* @param {RegExp} pattern Should have the sticky (`y`) flag so that it only matches at the current index
*/
match_regex(pattern) {
pattern.lastIndex = this.index;
const match = pattern.exec(this.template);
if (!match || match.index !== this.index) return null;
return match[0];
}
allow_whitespace() {
while (
this.index < this.template.length &&
is_whitespace(this.template.charCodeAt(this.index))
) {
this.index++;
}
}
/**
* Search for a regex starting at the current index and return the result if it matches
* @param {RegExp} pattern Should have a ^ anchor at the start so the regex doesn't search past the beginning, resulting in worse performance
*/
read(pattern) {
const result = this.match_regex(pattern);
if (result) this.index += result.length;
return result;
}
/**
* @returns {ESTree.Identifier & { start: number, end: number, loc: { start: Location, end: Location } }}
*/
read_identifier() {
const start = this.index;
let end = start;
let name = '';
const code = /** @type {number} */ (this.template.codePointAt(this.index));
if (isIdentifierStart(code, true)) {
let i = this.index;
end += code <= 0xffff ? 1 : 2;
while (end < this.template.length) {
const code = /** @type {number} */ (this.template.codePointAt(end));
if (!isIdentifierChar(code, true)) break;
end += code <= 0xffff ? 1 : 2;
}
name = this.template.slice(start, end);
this.index = end;
if (is_reserved(name)) {
e.unexpected_reserved_word(start, name);
}
}
return {
type: 'Identifier',
name,
start,
end,
loc: {
start: state.locator(start),
end: state.locator(end)
}
};
}
/** @param {RegExp} pattern */
read_until(pattern) {
if (this.index >= this.template.length) {
if (this.loose) return '';
e.unexpected_eof(this.template.length);
}
const start = this.index;
const match = pattern.exec(this.template.slice(start));
if (match) {
this.index = start + match.index;
return this.template.slice(start, this.index);
}
this.index = this.template.length;
return this.template.slice(start);
}
require_whitespace() {
if (!is_whitespace(this.template.charCodeAt(this.index))) {
e.expected_whitespace(this.index);
}
this.allow_whitespace();
}
pop() {
this.fragments.pop();
return this.stack.pop();
}
/**
* @template {AST.Fragment['nodes'][number]} T
* @param {T} node
* @returns {T}
*/
append(node) {
this.fragments.at(-1)?.nodes.push(node);
return node;
}
}
/**
* @param {string} template
* @param {boolean} [loose]
* @returns {AST.Root}
*/
export function parse(template, loose = false) {
state.set_source(template);
const parser = new Parser(template, loose);
return parser.root;
}
/** @typedef {(parser: Parser) => ParserState | void} ParserState */
/** @typedef {Object} LastAutoClosedTag
* @property {string} tag
* @property {string} reason
* @property {number} depth
*/
@@ -0,0 +1,110 @@
/** @import { Pattern } from 'estree' */
/** @import { Parser } from '../index.js' */
import { match_bracket } from '../utils/bracket.js';
import { parse_expression_at, remove_parens } from '../acorn.js';
import { regex_not_newline_characters } from '../../patterns.js';
import * as e from '../../../errors.js';
/**
* @param {Parser} parser
* @returns {Pattern}
*/
export default function read_pattern(parser) {
const start = parser.index;
let i = parser.index;
const id = parser.read_identifier();
if (id.name !== '') {
const annotation = read_type_annotation(parser);
return {
...id,
typeAnnotation: annotation
};
}
const char = parser.template[i];
if (char !== '{' && char !== '[') {
e.expected_pattern(i);
}
i = match_bracket(parser, start);
parser.index = i;
const pattern_string = parser.template.slice(start, i);
// the length of the `space_with_newline` has to be start - 1
// because we added a `(` in front of the pattern_string,
// which shifted the entire string to right by 1
// so we offset it by removing 1 character in the `space_with_newline`
// to achieve that, we remove the 1st space encountered,
// so it will not affect the `column` of the node
let space_with_newline = parser.template
.slice(0, start)
.replace(regex_not_newline_characters, ' ');
const first_space = space_with_newline.indexOf(' ');
space_with_newline =
space_with_newline.slice(0, first_space) + space_with_newline.slice(first_space + 1);
/** @type {any} */
let expression = remove_parens(
parse_expression_at(parser, `${space_with_newline}(${pattern_string} = 1)`, start - 1)
);
expression = expression.left;
expression.typeAnnotation = read_type_annotation(parser);
if (expression.typeAnnotation) {
expression.end = expression.typeAnnotation.end;
}
return expression;
}
/**
* @param {Parser} parser
* @returns {any}
*/
function read_type_annotation(parser) {
const start = parser.index;
parser.allow_whitespace();
if (!parser.eat(':')) {
parser.index = start;
return undefined;
}
// we need to trick Acorn into parsing the type annotation
const insert = '_ as ';
let a = parser.index - insert.length;
const template =
parser.template.slice(0, a).replace(/[^\n]/g, ' ') +
insert +
// If this is a type annotation for a function parameter, Acorn-TS will treat subsequent
// parameters as part of a sequence expression instead, and will then error on optional
// parameters (`?:`). Therefore replace that sequence with something that will not error.
parser.template.slice(parser.index).replace(/\?\s*:/g, ':');
let expression = remove_parens(parse_expression_at(parser, template, a));
// `foo: bar = baz` gets mangled — fix it
if (expression.type === 'AssignmentExpression') {
let b = expression.right.start;
while (template[b] !== '=') b -= 1;
expression = remove_parens(parse_expression_at(parser, template.slice(0, b), a));
}
// `array as item: string, index` becomes `string, index`, which is mistaken as a sequence expression - fix that
if (expression.type === 'SequenceExpression') {
expression = expression.expressions[0];
}
parser.index = /** @type {number} */ (expression.end);
return {
type: 'TSTypeAnnotation',
start,
end: parser.index,
typeAnnotation: /** @type {any} */ (expression).typeAnnotation
};
}
@@ -0,0 +1,59 @@
/** @import { Expression } from 'estree' */
/** @import { Parser } from '../index.js' */
import { parse_expression_at, remove_parens } from '../acorn.js';
import { regex_whitespace } from '../../patterns.js';
import * as e from '../../../errors.js';
import { find_matching_bracket } from '../utils/bracket.js';
/**
* @param {Parser} parser
* @param {string} [opening_token]
* @returns {Expression | undefined}
*/
export function get_loose_identifier(parser, opening_token) {
// Find the next } and treat it as the end of the expression
const end = find_matching_bracket(parser.template, parser.index, opening_token ?? '{');
if (end) {
const start = parser.index;
parser.index = end;
// We don't know what the expression is and signal this by returning an empty identifier
return {
type: 'Identifier',
start,
end,
name: ''
};
}
}
/**
* @param {Parser} parser
* @param {string} [opening_token]
* @param {boolean} [disallow_loose]
* @returns {Expression}
*/
export default function read_expression(parser, opening_token, disallow_loose) {
try {
const node = parse_expression_at(parser, parser.template, parser.index);
let index = /** @type {number} */ (node.end);
const last_comment = parser.root.comments.at(-1);
if (last_comment && last_comment.end > index) index = last_comment.end;
parser.index = index;
return /** @type {Expression} */ (remove_parens(node));
} catch (err) {
// If we are in an each loop we need the error to be thrown in cases like
// `as { y = z }` so we still throw and handle the error there
if (parser.loose && !disallow_loose) {
const expression = get_loose_identifier(parser, opening_token);
if (expression) {
return expression;
}
}
throw err;
}
}
@@ -0,0 +1,263 @@
/** @import { ObjectExpression } from 'estree' */
/** @import { AST } from '#compiler' */
import { NAMESPACE_MATHML, NAMESPACE_SVG } from '../../../../constants.js';
import * as e from '../../../errors.js';
/**
* @param {AST.SvelteOptionsRaw} node
* @returns {AST.Root['options']}
*/
export default function read_options(node) {
/** @type {AST.SvelteOptions} */
const component_options = {
start: node.start,
end: node.end,
// @ts-ignore
attributes: node.attributes
};
if (!node) {
return component_options;
}
for (const attribute of node.attributes) {
if (attribute.type !== 'Attribute') {
e.svelte_options_invalid_attribute(attribute);
}
const { name } = attribute;
switch (name) {
case 'runes': {
component_options.runes = get_boolean_value(attribute);
break;
}
case 'tag': {
e.svelte_options_deprecated_tag(attribute);
break; // eslint doesn't know this is unnecessary
}
case 'customElement': {
/** @type {AST.SvelteOptions['customElement']} */
const ce = {};
const { value: v } = attribute;
const value = v === true || Array.isArray(v) ? v : [v];
if (value === true) {
e.svelte_options_invalid_customelement(attribute);
} else if (value[0].type === 'Text') {
const tag = get_static_value(attribute);
validate_tag(attribute, tag);
ce.tag = tag;
component_options.customElement = ce;
break;
} else if (value[0].expression.type !== 'ObjectExpression') {
// Before Svelte 4 it was necessary to explicitly set customElement to null or else you'd get a warning.
// This is no longer necessary, but for backwards compat just skip in this case now.
if (value[0].expression.type === 'Literal' && value[0].expression.value === null) {
break;
}
e.svelte_options_invalid_customelement(attribute);
}
/** @type {Array<[string, any]>} */
const properties = [];
for (const property of value[0].expression.properties) {
if (
property.type !== 'Property' ||
property.computed ||
property.key.type !== 'Identifier'
) {
e.svelte_options_invalid_customelement(attribute);
}
properties.push([property.key.name, property.value]);
}
const tag = properties.find(([name]) => name === 'tag');
if (tag) {
const tag_value = tag[1]?.value;
validate_tag(tag, tag_value);
ce.tag = tag_value;
}
const props = properties.find(([name]) => name === 'props')?.[1];
if (props) {
if (props.type !== 'ObjectExpression') {
e.svelte_options_invalid_customelement_props(attribute);
}
ce.props = {};
for (const property of /** @type {ObjectExpression} */ (props).properties) {
if (
property.type !== 'Property' ||
property.computed ||
property.key.type !== 'Identifier' ||
property.value.type !== 'ObjectExpression'
) {
e.svelte_options_invalid_customelement_props(attribute);
}
ce.props[property.key.name] = {};
for (const prop of property.value.properties) {
if (
prop.type !== 'Property' ||
prop.computed ||
prop.key.type !== 'Identifier' ||
prop.value.type !== 'Literal'
) {
e.svelte_options_invalid_customelement_props(attribute);
}
if (prop.key.name === 'type') {
if (
['String', 'Number', 'Boolean', 'Array', 'Object'].indexOf(
/** @type {string} */ (prop.value.value)
) === -1
) {
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') {
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') {
e.svelte_options_invalid_customelement_props(attribute);
}
ce.props[property.key.name].attribute = prop.value.value;
} else {
e.svelte_options_invalid_customelement_props(attribute);
}
}
}
}
const shadow = properties.find(([name]) => name === 'shadow')?.[1];
if (shadow) {
if (shadow.type === 'Literal' && (shadow.value === 'open' || shadow.value === 'none')) {
ce.shadow = shadow.value;
} else if (shadow.type === 'ObjectExpression') {
ce.shadow = shadow;
} else {
e.svelte_options_invalid_customelement_shadow(attribute);
}
}
const extend = properties.find(([name]) => name === 'extend')?.[1];
if (extend) {
ce.extend = extend;
}
component_options.customElement = ce;
break;
}
case 'namespace': {
const value = get_static_value(attribute);
if (value === NAMESPACE_SVG) {
component_options.namespace = 'svg';
} else if (value === NAMESPACE_MATHML) {
component_options.namespace = 'mathml';
} else if (value === 'html' || value === 'mathml' || value === 'svg') {
component_options.namespace = value;
} else {
e.svelte_options_invalid_attribute_value(attribute, `"html", "mathml" or "svg"`);
}
break;
}
case 'css': {
const value = get_static_value(attribute);
if (value === 'injected') {
component_options.css = value;
} else {
e.svelte_options_invalid_attribute_value(attribute, `"injected"`);
}
break;
}
case 'immutable': {
component_options.immutable = get_boolean_value(attribute);
break;
}
case 'preserveWhitespace': {
component_options.preserveWhitespace = get_boolean_value(attribute);
break;
}
case 'accessors': {
component_options.accessors = get_boolean_value(attribute);
break;
}
default:
e.svelte_options_unknown_attribute(attribute, name);
}
}
return component_options;
}
/**
* @param {any} attribute
*/
function get_static_value(attribute) {
const { value } = attribute;
if (value === true) return true;
const chunk = Array.isArray(value) ? value[0] : value;
if (!chunk) return true;
if (value.length > 1) {
return null;
}
if (chunk.type === 'Text') return chunk.data;
if (chunk.expression.type !== 'Literal') {
return null;
}
return chunk.expression.value;
}
/**
* @param {any} attribute
*/
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;
}
// https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
const tag_name_char =
'[a-z0-9_.\xB7\xC0-\xD6\xD8-\xF6\xF8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u{10000}-\u{EFFFF}-]';
const regex_valid_tag_name = new RegExp(`^[a-z]${tag_name_char}*-${tag_name_char}*$`, 'u');
const reserved_tag_names = [
'annotation-xml',
'color-profile',
'font-face',
'font-face-src',
'font-face-uri',
'font-face-format',
'font-face-name',
'missing-glyph'
];
/**
* @param {any} attribute
* @param {string | null} tag
* @returns {asserts tag is string}
*/
function validate_tag(attribute, tag) {
if (typeof tag !== 'string') {
e.svelte_options_invalid_tagname(attribute);
}
if (tag) {
if (!regex_valid_tag_name.test(tag)) {
e.svelte_options_invalid_tagname(attribute);
} else if (reserved_tag_names.includes(tag)) {
e.svelte_options_reserved_tagname(attribute);
}
}
}
@@ -0,0 +1,90 @@
/** @import { Program } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { Parser } from '../index.js' */
import * as acorn from '../acorn.js';
import { regex_not_newline_characters } from '../../patterns.js';
import * as e from '../../../errors.js';
import * as w from '../../../warnings.js';
import { is_text_attribute } from '../../../utils/ast.js';
import { locator } from '../../../state.js';
const regex_closing_script_tag = /<\/script\s*>/;
const regex_starts_with_closing_script_tag = /<\/script\s*>/y;
const RESERVED_ATTRIBUTES = ['server', 'client', 'worker', 'test', 'default'];
const ALLOWED_ATTRIBUTES = ['context', 'generics', 'lang', 'module'];
/**
* @param {Parser} parser
* @param {number} start
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.Directive | AST.AttachTag>} attributes
* @returns {AST.Script}
*/
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) {
e.element_unclosed(parser.template.length, 'script');
}
const source =
parser.template.slice(0, script_start).replace(regex_not_newline_characters, ' ') + data;
parser.read(regex_starts_with_closing_script_tag);
const ast = acorn.parse(source, parser.root.comments, parser.ts, true);
ast.start = script_start;
if (ast.loc) {
// Acorn always uses `0` as the start of a `Program`, but for sourcemap purposes
// we need it to be the start of the `<script>` contents
({ line: ast.loc.start.line, column: ast.loc.start.column } = locator(start));
({ line: ast.loc.end.line, column: ast.loc.end.column } = locator(parser.index));
}
/** @type {'default' | 'module'} */
let context = 'default';
for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) {
if (RESERVED_ATTRIBUTES.includes(attribute.name)) {
e.script_reserved_attribute(attribute, attribute.name);
}
if (!ALLOWED_ATTRIBUTES.includes(attribute.name)) {
w.script_unknown_attribute(attribute);
}
if (attribute.name === 'module') {
if (attribute.value !== true) {
// Deliberately a generic code to future-proof for potential other attributes
e.script_invalid_attribute_value(attribute, attribute.name);
}
context = 'module';
}
if (attribute.name === 'context') {
if (attribute.value === true || !is_text_attribute(attribute)) {
e.script_invalid_context(attribute);
}
const value = attribute.value[0].data;
if (value !== 'module') {
e.script_invalid_context(attribute);
}
context = 'module';
}
}
return {
type: 'Script',
start,
end: parser.index,
context,
content: ast,
// @ts-ignore
attributes
};
}
+637
View File
@@ -0,0 +1,637 @@
/** @import { AST } from '#compiler' */
/** @import { Parser } from '../index.js' */
import * as e from '../../../errors.js';
const REGEX_MATCHER = /[~^$*|]?=/y;
const REGEX_CLOSING_BRACKET = /[\s\]]/;
const REGEX_ATTRIBUTE_FLAGS = /[a-zA-Z]+/y; // only `i` and `s` are valid today, but make it future-proof
const REGEX_COMBINATOR = /(\+|~|>|\|\|)/y;
const REGEX_PERCENTAGE = /\d+(\.\d+)?%/y;
const REGEX_NTH_OF =
/(even|odd|\+?(\d+|\d*n(\s*[+-]\s*\d+)?)|-\d*n(\s*\+\s*\d+))((?=\s*[,)])|\s+of\s+)/y;
const REGEX_WHITESPACE_OR_COLON = /[\s:]/;
const REGEX_LEADING_HYPHEN_OR_DIGIT = /-?\d/y;
const REGEX_VALID_IDENTIFIER_CHAR = /[a-zA-Z0-9_-]/;
const REGEX_UNICODE_SEQUENCE = /\\[0-9a-fA-F]{1,6}(\r\n|\s)?/y;
const REGEX_COMMENT_CLOSE = /\*\//;
const REGEX_HTML_COMMENT_CLOSE = /-->/;
/**
* @param {Parser} parser
* @param {number} start
* @param {Array<AST.Attribute | AST.SpreadAttribute | AST.Directive | AST.AttachTag>} attributes
* @returns {AST.CSS.StyleSheet}
*/
export default function read_style(parser, start, attributes) {
const content_start = parser.index;
const children = read_body(parser, (p) => p.match('</style') || p.index >= p.template.length);
const content_end = parser.index;
parser.eat('</style', true);
parser.read(/\s*>/y);
return {
type: 'StyleSheet',
start,
end: parser.index,
attributes,
children,
content: {
start: content_start,
end: content_end,
styles: parser.template.slice(content_start, content_end),
comment: null
}
};
}
/**
* @param {Parser} parser
* @param {(parser: Parser) => boolean} finished
* @returns {Array<AST.CSS.Rule | AST.CSS.Atrule>}
*/
function read_body(parser, finished) {
/** @type {Array<AST.CSS.Rule | AST.CSS.Atrule>} */
const children = [];
while ((allow_comment_or_whitespace(parser), !finished(parser))) {
if (parser.match('@')) {
children.push(read_at_rule(parser));
} else {
children.push(read_rule(parser));
}
}
return children;
}
/**
* @param {Parser} parser
* @returns {AST.CSS.Atrule}
*/
function read_at_rule(parser) {
const start = parser.index;
parser.eat('@', true);
const name = read_identifier(parser);
const prelude = read_value(parser);
/** @type {AST.CSS.Block | null} */
let block = null;
if (parser.match('{')) {
// e.g. `@media (...) {...}`
block = read_block(parser);
} else {
// e.g. `@import '...'`
parser.eat(';', true);
}
return {
type: 'Atrule',
start,
end: parser.index,
name,
prelude,
block
};
}
/**
* @param {Parser} parser
* @returns {AST.CSS.Rule}
*/
function read_rule(parser) {
const start = parser.index;
return {
type: 'Rule',
prelude: read_selector_list(parser),
block: read_block(parser),
start,
end: parser.index,
metadata: {
parent_rule: null,
has_local_selectors: false,
has_global_selectors: false,
is_global_block: false
}
};
}
/**
* @param {Parser} parser
* @param {boolean} [inside_pseudo_class]
* @returns {AST.CSS.SelectorList}
*/
function read_selector_list(parser, inside_pseudo_class = false) {
/** @type {AST.CSS.ComplexSelector[]} */
const children = [];
allow_comment_or_whitespace(parser);
const start = parser.index;
while (parser.index < parser.template.length) {
children.push(read_selector(parser, inside_pseudo_class));
const end = parser.index;
allow_comment_or_whitespace(parser);
if (inside_pseudo_class ? parser.match(')') : parser.match('{')) {
return {
type: 'SelectorList',
start,
end,
children
};
} else {
parser.eat(',', true);
allow_comment_or_whitespace(parser);
}
}
e.unexpected_eof(parser.template.length);
}
/**
* @param {Parser} parser
* @param {boolean} [inside_pseudo_class]
* @returns {AST.CSS.ComplexSelector}
*/
function read_selector(parser, inside_pseudo_class = false) {
const list_start = parser.index;
/** @type {AST.CSS.RelativeSelector[]} */
const children = [];
/**
* @param {AST.CSS.Combinator | null} combinator
* @param {number} start
* @returns {AST.CSS.RelativeSelector}
*/
function create_selector(combinator, start) {
return {
type: 'RelativeSelector',
combinator,
selectors: [],
start,
end: -1,
metadata: {
is_global: false,
is_global_like: false,
scoped: false
}
};
}
/** @type {AST.CSS.RelativeSelector} */
let relative_selector = create_selector(null, parser.index);
while (parser.index < parser.template.length) {
let start = parser.index;
if (parser.eat('&')) {
relative_selector.selectors.push({
type: 'NestingSelector',
name: '&',
start,
end: parser.index
});
} else if (parser.eat('*')) {
let name = '*';
if (parser.eat('|')) {
// * is the namespace (which we ignore)
name = read_identifier(parser);
}
relative_selector.selectors.push({
type: 'TypeSelector',
name,
start,
end: parser.index
});
} else if (parser.eat('#')) {
relative_selector.selectors.push({
type: 'IdSelector',
name: read_identifier(parser),
start,
end: parser.index
});
} else if (parser.eat('.')) {
relative_selector.selectors.push({
type: 'ClassSelector',
name: read_identifier(parser),
start,
end: parser.index
});
} else if (parser.eat('::')) {
relative_selector.selectors.push({
type: 'PseudoElementSelector',
name: read_identifier(parser),
start,
end: parser.index
});
// We read the inner selectors of a pseudo element to ensure it parses correctly,
// but we don't do anything with the result.
if (parser.eat('(')) {
read_selector_list(parser, true);
parser.eat(')', true);
}
} else if (parser.eat(':')) {
const name = read_identifier(parser);
/** @type {null | AST.CSS.SelectorList} */
let args = null;
if (parser.eat('(')) {
args = read_selector_list(parser, true);
parser.eat(')', true);
}
relative_selector.selectors.push({
type: 'PseudoClassSelector',
name,
args,
start,
end: parser.index
});
} else if (parser.eat('[')) {
parser.allow_whitespace();
const name = read_identifier(parser);
parser.allow_whitespace();
/** @type {string | null} */
let value = null;
const matcher = parser.read(REGEX_MATCHER);
if (matcher) {
parser.allow_whitespace();
value = read_attribute_value(parser);
}
parser.allow_whitespace();
const flags = parser.read(REGEX_ATTRIBUTE_FLAGS);
parser.allow_whitespace();
parser.eat(']', true);
relative_selector.selectors.push({
type: 'AttributeSelector',
start,
end: parser.index,
name,
matcher,
value,
flags
});
} else if (inside_pseudo_class && parser.match_regex(REGEX_NTH_OF)) {
// nth of matcher must come before combinator matcher to prevent collision else the '+' in '+2n-1' would be parsed as a combinator
relative_selector.selectors.push({
type: 'Nth',
value: /**@type {string} */ (parser.read(REGEX_NTH_OF)),
start,
end: parser.index
});
} else if (parser.match_regex(REGEX_PERCENTAGE)) {
relative_selector.selectors.push({
type: 'Percentage',
value: /** @type {string} */ (parser.read(REGEX_PERCENTAGE)),
start,
end: parser.index
});
} else if (!parser.match_regex(REGEX_COMBINATOR)) {
let name = read_identifier(parser);
if (parser.eat('|')) {
// we ignore the namespace when trying to find matching element classes
name = read_identifier(parser);
}
relative_selector.selectors.push({
type: 'TypeSelector',
name,
start,
end: parser.index
});
}
const index = parser.index;
allow_comment_or_whitespace(parser);
if (parser.match(',') || (inside_pseudo_class ? parser.match(')') : parser.match('{'))) {
// rewind, so we know whether to continue building the selector list
parser.index = index;
relative_selector.end = index;
children.push(relative_selector);
return {
type: 'ComplexSelector',
start: list_start,
end: index,
children,
metadata: {
rule: null,
is_global: false,
used: false
}
};
}
parser.index = index;
const combinator = read_combinator(parser);
if (combinator) {
if (relative_selector.selectors.length > 0) {
relative_selector.end = index;
children.push(relative_selector);
}
// ...and start a new one
relative_selector = create_selector(combinator, combinator.start);
parser.allow_whitespace();
if (parser.match(',') || (inside_pseudo_class ? parser.match(')') : parser.match('{'))) {
e.css_selector_invalid(parser.index);
}
}
}
e.unexpected_eof(parser.template.length);
}
/**
* @param {Parser} parser
* @returns {AST.CSS.Combinator | null}
*/
function read_combinator(parser) {
const start = parser.index;
parser.allow_whitespace();
const index = parser.index;
const name = parser.read(REGEX_COMBINATOR);
if (name) {
const end = parser.index;
parser.allow_whitespace();
return {
type: 'Combinator',
name,
start: index,
end
};
}
if (parser.index !== start) {
return {
type: 'Combinator',
name: ' ',
start,
end: parser.index
};
}
return null;
}
/**
* @param {Parser} parser
* @returns {AST.CSS.Block}
*/
function read_block(parser) {
const start = parser.index;
parser.eat('{', true);
/** @type {Array<AST.CSS.Declaration | AST.CSS.Rule | AST.CSS.Atrule>} */
const children = [];
while (parser.index < parser.template.length) {
allow_comment_or_whitespace(parser);
if (parser.match('}')) {
break;
} else {
children.push(read_block_item(parser));
}
}
parser.eat('}', true);
return {
type: 'Block',
start,
end: parser.index,
children
};
}
/**
* Reads a declaration, rule or at-rule
*
* @param {Parser} parser
* @returns {AST.CSS.Declaration | AST.CSS.Rule | AST.CSS.Atrule}
*/
function read_block_item(parser) {
if (parser.match('@')) {
return read_at_rule(parser);
}
// read ahead to understand whether we're dealing with a declaration or a nested rule.
// this involves some duplicated work, but avoids a try-catch that would disguise errors
const start = parser.index;
read_value(parser);
const char = parser.template[parser.index];
parser.index = start;
return char === '{' ? read_rule(parser) : read_declaration(parser);
}
/**
* @param {Parser} parser
* @returns {AST.CSS.Declaration}
*/
function read_declaration(parser) {
const start = parser.index;
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('--')) {
e.css_empty_declaration({ start, end: index });
}
const end = parser.index;
if (!parser.match('}')) {
parser.eat(';', true);
}
return {
type: 'Declaration',
start,
end,
property,
value
};
}
/**
* @param {Parser} parser
* @returns {string}
*/
function read_value(parser) {
let value = '';
let escaped = false;
let in_url = false;
/** @type {null | '"' | "'"} */
let quote_mark = null;
while (parser.index < parser.template.length) {
const char = parser.template[parser.index];
if (escaped) {
value += '\\' + char;
escaped = false;
parser.index++;
continue;
} else if (char === '\\') {
escaped = true;
parser.index++;
continue;
} else if (char === quote_mark) {
quote_mark = null;
} else if (char === ')') {
in_url = false;
} else if (quote_mark === null && (char === '"' || char === "'")) {
quote_mark = char;
} else if (char === '(' && value.slice(-3) === 'url') {
in_url = true;
} else if ((char === ';' || char === '{' || char === '}') && !in_url && !quote_mark) {
return value.trim();
}
value += char;
parser.index++;
}
e.unexpected_eof(parser.template.length);
}
/**
* Read a property that may or may not be quoted, e.g.
* `foo` or `'foo bar'` or `"foo bar"`
* @param {Parser} parser
*/
function read_attribute_value(parser) {
let value = '';
let escaped = false;
const quote_mark = parser.eat('"') ? '"' : parser.eat("'") ? "'" : null;
while (parser.index < parser.template.length) {
const char = parser.template[parser.index];
if (escaped) {
value += '\\' + char;
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (quote_mark ? char === quote_mark : REGEX_CLOSING_BRACKET.test(char)) {
if (quote_mark) {
parser.eat(quote_mark, true);
}
return value.trim();
} else {
value += char;
}
parser.index++;
}
e.unexpected_eof(parser.template.length);
}
/**
* @see {@link https://www.w3.org/TR/css-syntax-3/#ident-token-diagram CSS Syntax Module Level 3}
* @param {Parser} parser
*/
function read_identifier(parser) {
const start = parser.index;
let identifier = '';
if (parser.match_regex(REGEX_LEADING_HYPHEN_OR_DIGIT)) {
e.css_expected_identifier(start);
}
while (parser.index < parser.template.length) {
const char = parser.template[parser.index];
if (char === '\\') {
const sequence = parser.match_regex(REGEX_UNICODE_SEQUENCE);
if (sequence) {
identifier += String.fromCodePoint(parseInt(sequence.slice(1), 16));
parser.index += sequence.length;
} else {
identifier += '\\' + parser.template[parser.index + 1];
parser.index += 2;
}
} else if (
/** @type {number} */ (char.codePointAt(0)) >= 160 ||
REGEX_VALID_IDENTIFIER_CHAR.test(char)
) {
identifier += char;
parser.index++;
} else {
break;
}
}
if (identifier === '') {
e.css_expected_identifier(start);
}
return identifier;
}
/** @param {Parser} parser */
function allow_comment_or_whitespace(parser) {
parser.allow_whitespace();
while (parser.match('/*') || parser.match('<!--')) {
if (parser.eat('/*')) {
parser.read_until(REGEX_COMMENT_CLOSE);
parser.eat('*/', true);
}
if (parser.eat('<!--')) {
parser.read_until(REGEX_HTML_COMMENT_CLOSE);
parser.eat('-->', true);
}
parser.allow_whitespace();
}
}
/**
* Parse standalone CSS content (not wrapped in `<style>`).
* @param {Parser} parser
* @returns {Array<AST.CSS.Rule | AST.CSS.Atrule>}
*/
export function parse_stylesheet(parser) {
return read_body(parser, (p) => p.index >= p.template.length);
}
@@ -0,0 +1,182 @@
/** @import { Context, Visitors } from 'zimmerframe' */
/** @import { FunctionExpression, FunctionDeclaration } from 'estree' */
import { walk } from 'zimmerframe';
import * as b from '#compiler/builders';
import * as e from '../../errors.js';
/**
* @param {FunctionExpression | FunctionDeclaration} node
* @param {Context<any, any>} context
*/
function remove_this_param(node, context) {
if (node.params[0]?.type === 'Identifier' && node.params[0].name === 'this') {
node.params.shift();
}
return context.next();
}
/** @type {Visitors<any, null>} */
const visitors = {
_(node, context) {
const n = context.next() ?? node;
// TODO there may come a time when we decide to preserve type annotations.
// until that day comes, we just delete them so they don't confuse esrap
delete n.typeAnnotation;
delete n.typeParameters;
delete n.typeArguments;
delete n.returnType;
delete n.accessibility;
delete n.readonly;
delete n.definite;
delete n.override;
},
Decorator(node) {
e.typescript_invalid_feature(node, 'decorators (related TSC proposal is not stage 4 yet)');
},
ImportDeclaration(node) {
if (node.importKind === 'type') return b.empty;
if (node.specifiers?.length > 0) {
const specifiers = node.specifiers.filter((/** @type {any} */ s) => s.importKind !== 'type');
if (specifiers.length === 0) return b.empty;
return { ...node, specifiers };
}
return node;
},
ExportNamedDeclaration(node, context) {
if (node.exportKind === 'type') return b.empty;
if (node.declaration) {
const result = context.next();
if (result?.declaration?.type === 'EmptyStatement') {
return b.empty;
}
return result;
}
if (node.specifiers) {
const specifiers = node.specifiers.filter((/** @type {any} */ s) => s.exportKind !== 'type');
if (specifiers.length === 0) return b.empty;
return { ...node, specifiers };
}
return node;
},
ExportDefaultDeclaration(node) {
if (node.exportKind === 'type') return b.empty;
return node;
},
ExportAllDeclaration(node) {
if (node.exportKind === 'type') return b.empty;
return node;
},
PropertyDefinition(node, { next }) {
if (node.accessor) {
e.typescript_invalid_feature(
node,
'accessor fields (related TSC proposal is not stage 4 yet)'
);
}
return next();
},
TSAsExpression(node, context) {
return context.visit(node.expression);
},
TSSatisfiesExpression(node, context) {
return context.visit(node.expression);
},
TSNonNullExpression(node, context) {
return context.visit(node.expression);
},
TSInterfaceDeclaration() {
return b.empty;
},
TSTypeAliasDeclaration() {
return b.empty;
},
TSTypeAssertion(node, context) {
return context.visit(node.expression);
},
TSEnumDeclaration(node) {
e.typescript_invalid_feature(node, 'enums');
},
TSParameterProperty(node, context) {
if ((node.readonly || node.accessibility) && context.path.at(-2)?.kind === 'constructor') {
e.typescript_invalid_feature(node, 'accessibility modifiers on constructor parameters');
}
return context.visit(node.parameter);
},
TSInstantiationExpression(node, context) {
return context.visit(node.expression);
},
FunctionExpression: remove_this_param,
FunctionDeclaration: remove_this_param,
TSDeclareFunction() {
return b.empty;
},
ClassBody(node, context) {
const body = [];
for (const _child of node.body) {
const child = context.visit(_child);
if (child.type !== 'PropertyDefinition' || !child.declare) {
body.push(child);
}
}
return {
...node,
body
};
},
ClassDeclaration(node, context) {
if (node.declare) {
return b.empty;
}
delete node.abstract;
delete node.implements;
delete node.superTypeArguments;
delete node.superTypeParameters;
return context.next();
},
ClassExpression(node, context) {
delete node.implements;
delete node.superTypeArguments;
delete node.superTypeParameters;
return context.next();
},
MethodDefinition(node, context) {
if (node.abstract) {
return b.empty;
}
return context.next();
},
VariableDeclaration(node, context) {
if (node.declare) {
return b.empty;
}
return context.next();
},
TSModuleDeclaration(node, context) {
if (!node.body) return b.empty;
// namespaces can contain non-type nodes
const cleaned = /** @type {any[]} */ (node.body.body).map((entry) => context.visit(entry));
if (cleaned.some((entry) => entry !== b.empty)) {
e.typescript_invalid_feature(node, 'namespaces with non-type nodes');
}
return b.empty;
}
};
/**
* @template T
* @param {T} ast
* @returns {T}
*/
export function remove_typescript_nodes(ast) {
return walk(ast, null, visitors);
}
@@ -0,0 +1,950 @@
/** @import { Expression, Identifier, SourceLocation } from 'estree' */
/** @import { Location } from 'locate-character' */
/** @import { AST } from '#compiler' */
/** @import { Parser } from '../index.js' */
import { is_void, REGEX_VALID_TAG_NAME } from '../../../../utils.js';
import read_expression from '../read/expression.js';
import { read_script } from '../read/script.js';
import read_style from '../read/style.js';
import { decode_character_references } from '../utils/html.js';
import * as e from '../../../errors.js';
import * as w from '../../../warnings.js';
import { create_fragment } from '../utils/create.js';
import { create_attribute, ExpressionMetadata, is_element_node } from '../../nodes.js';
import { get_attribute_expression, is_expression_attribute } from '../../../utils/ast.js';
import { closing_tag_omitted } from '../../../../html-tree-validation.js';
import { list } from '../../../utils/string.js';
import { locator } from '../../../state.js';
import * as b from '#compiler/builders';
const regex_invalid_unquoted_attribute_value = /(\/>|[\s"'=<>`])/y;
const regex_closing_textarea_tag = /<\/textarea(\s[^>]*)?>/iy;
const regex_closing_comment = /-->/;
const regex_whitespace_or_slash_or_closing_tag = /(\s|\/|>)/;
const regex_token_ending_character = /[\s=/>"']/;
const regex_starts_with_quote_characters = /["']/y;
const regex_attribute_value = /(?:"([^"]*)"|'([^'])*'|([^>\s]+))/y;
const regex_doctype_name = /^![a-zA-Z]+$/;
const regex_namespaced_name = /^[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9]$/;
/** @param {string} name */
function is_valid_element_name(name) {
// DOCTYPE (e.g. !DOCTYPE)
if (regex_doctype_name.test(name)) return true;
// svelte:* meta tags (e.g. svelte:element, svelte:head)
if (regex_namespaced_name.test(name)) return true;
// standard HTML/SVG/MathML elements and custom elements
return REGEX_VALID_TAG_NAME.test(name);
}
export const regex_valid_component_name =
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#identifiers adjusted for our needs
// (must start with uppercase letter if no dots, can contain dots)
/^(?:\p{Lu}[$\u200c\u200d\p{ID_Continue}.]*|\p{ID_Start}[$\u200c\u200d\p{ID_Continue}]*(?:\.[$\u200c\u200d\p{ID_Continue}]+)+)$/u;
/** @type {Map<string, AST.ElementLike['type']>} */
const root_only_meta_tags = new Map([
['svelte:head', 'SvelteHead'],
['svelte:options', 'SvelteOptions'],
['svelte:window', 'SvelteWindow'],
['svelte:document', 'SvelteDocument'],
['svelte:body', 'SvelteBody']
]);
/** @type {Map<string, AST.ElementLike['type']>} */
const meta_tags = new Map([
...root_only_meta_tags,
['svelte:element', 'SvelteElement'],
['svelte:component', 'SvelteComponent'],
['svelte:self', 'SvelteSelf'],
['svelte:fragment', 'SvelteFragment'],
['svelte:boundary', 'SvelteBoundary']
]);
/** @param {Parser} parser */
export default function element(parser) {
const start = parser.index++;
let parent = parser.current();
if (parser.eat('!--')) {
const data = parser.read_until(regex_closing_comment);
parser.eat('-->', true);
parser.append({
type: 'Comment',
start,
end: parser.index,
data
});
return;
}
if (parser.eat('/')) {
const name = parser.read_until(regex_whitespace_or_slash_or_closing_tag);
parser.allow_whitespace();
parser.eat('>', true);
if (is_void(name)) {
e.void_element_invalid_content(start);
}
// close any elements that don't have their own closing tags, e.g. <div><p></div>
while (/** @type {AST.RegularElement} */ (parent).name !== name) {
if (parser.loose) {
// If the previous element did interpret the next opening tag as an attribute, backtrack
if (is_element_node(parent)) {
const last = parent.attributes.at(-1);
if (last?.type === 'Attribute' && last.name === `<${name}`) {
parser.index = last.start;
parent.attributes.pop();
break;
}
}
}
if (parent.type === 'RegularElement') {
if (!parser.last_auto_closed_tag || parser.last_auto_closed_tag.tag !== name) {
const end = parent.fragment.nodes[0]?.start ?? start;
w.element_implicitly_closed(
{ start: parent.start, end },
`</${name}>`,
`</${parent.name}>`
);
}
} else if (!parser.loose) {
if (parser.last_auto_closed_tag && parser.last_auto_closed_tag.tag === name) {
e.element_invalid_closing_tag_autoclosed(start, name, parser.last_auto_closed_tag.reason);
} else {
e.element_invalid_closing_tag(start, name);
}
}
parent.end = start;
parser.pop();
parent = parser.current();
}
parent.end = parser.index;
parser.pop();
if (parser.last_auto_closed_tag && parser.stack.length < parser.last_auto_closed_tag.depth) {
parser.last_auto_closed_tag = undefined;
}
return;
}
const tag = read_tag(parser, regex_whitespace_or_slash_or_closing_tag);
if (tag.name.startsWith('svelte:') && !meta_tags.has(tag.name)) {
const bounds = { start: start + 1, end: start + 1 + tag.name.length };
e.svelte_meta_invalid_tag(bounds, list(Array.from(meta_tags.keys())));
}
if (!is_valid_element_name(tag.name) && !regex_valid_component_name.test(tag.name)) {
// <div. -> in the middle of typing -> allow in loose mode
if (!parser.loose || !tag.name.endsWith('.')) {
const bounds = { start: start + 1, end: start + 1 + tag.name.length };
e.tag_invalid_name(bounds);
}
}
if (root_only_meta_tags.has(tag.name)) {
if (tag.name in parser.meta_tags) {
e.svelte_meta_duplicate(start, tag.name);
}
if (parent.type !== 'Root') {
e.svelte_meta_invalid_placement(start, tag.name);
}
parser.meta_tags[tag.name] = true;
}
const type = meta_tags.has(tag.name)
? meta_tags.get(tag.name)
: regex_valid_component_name.test(tag.name) || (parser.loose && tag.name.endsWith('.'))
? 'Component'
: tag.name === 'title' && parent_is_head(parser.stack)
? 'TitleElement'
: // TODO Svelte 6/7: once slots are removed in favor of snippets, always keep slot as a regular element
tag.name === 'slot' && !parent_is_shadowroot_template(parser.stack)
? 'SlotElement'
: 'RegularElement';
/** @type {AST.ElementLike} */
const element =
type === 'RegularElement'
? {
type,
start,
end: -1,
name: tag.name,
name_loc: tag.loc,
attributes: [],
fragment: create_fragment(true),
metadata: {
svg: false,
mathml: false,
scoped: false,
has_spread: false,
path: [],
synthetic_value_node: null
}
}
: /** @type {AST.ElementLike} */ ({
type,
start,
end: -1,
name: tag.name,
name_loc: tag.loc,
attributes: [],
fragment: create_fragment(true),
metadata: {
// unpopulated at first, differs between types
}
});
parser.allow_whitespace();
if (parent.type === 'RegularElement' && closing_tag_omitted(parent.name, tag.name)) {
const end = parent.fragment.nodes[0]?.start ?? start;
w.element_implicitly_closed({ start: parent.start, end }, `<${tag.name}>`, `</${parent.name}>`);
parent.end = start;
parser.pop();
parser.last_auto_closed_tag = {
tag: parent.name,
reason: tag.name,
depth: parser.stack.length
};
}
/** @type {string[]} */
const unique_names = [];
const current = parser.current();
const is_top_level_script_or_style =
(tag.name === 'script' || tag.name === 'style') && current.type === 'Root';
const read = is_top_level_script_or_style ? read_static_attribute : read_attribute;
let attribute;
while ((attribute = read(parser))) {
// animate and transition can only be specified once per element so no need
// to check here, use can be used multiple times, same for the on directive
// finally let already has error handling in case of duplicate variable names
if (
attribute.type === 'Attribute' ||
attribute.type === 'BindDirective' ||
attribute.type === 'StyleDirective' ||
attribute.type === 'ClassDirective'
) {
// `bind:attribute` and `attribute` are just the same but `class:attribute`,
// `style:attribute` and `attribute` are different and should be allowed together
// so we concatenate the type while normalizing the type for BindDirective
const type = attribute.type === 'BindDirective' ? 'Attribute' : attribute.type;
if (unique_names.includes(type + attribute.name)) {
e.attribute_duplicate(attribute);
// <svelte:element bind:this this=..> is allowed
} else if (attribute.name !== 'this') {
unique_names.push(type + attribute.name);
}
}
element.attributes.push(attribute);
parser.allow_whitespace();
}
if (element.type === 'Component') {
element.metadata.expression = new ExpressionMetadata();
}
if (element.type === 'SvelteComponent') {
const index = element.attributes.findIndex(
/** @param {any} attr */
(attr) => attr.type === 'Attribute' && attr.name === 'this'
);
if (index === -1) {
e.svelte_component_missing_this(start);
}
const definition = /** @type {AST.Attribute} */ (element.attributes.splice(index, 1)[0]);
if (!is_expression_attribute(definition)) {
e.svelte_component_invalid_this(definition.start);
}
element.expression = get_attribute_expression(definition);
element.metadata.expression = new ExpressionMetadata();
}
if (element.type === 'SvelteElement') {
const index = element.attributes.findIndex(
/** @param {any} attr */
(attr) => attr.type === 'Attribute' && attr.name === 'this'
);
if (index === -1) {
e.svelte_element_missing_this(start);
}
const definition = /** @type {AST.Attribute} */ (element.attributes.splice(index, 1)[0]);
if (definition.value === true) {
e.svelte_element_missing_this(definition);
}
if (!is_expression_attribute(definition)) {
w.svelte_element_invalid_this(definition);
// note that this is wrong, in the case of e.g. `this="h{n}"` — it will result in `<h>`.
// it would be much better to just error here, but we are preserving the existing buggy
// Svelte 4 behaviour out of an overabundance of caution regarding breaking changes.
// TODO in 6.0, error
const chunk = /** @type {Array<AST.ExpressionTag | AST.Text>} */ (definition.value)[0];
element.tag =
chunk.type === 'Text'
? {
type: 'Literal',
value: chunk.data,
raw: `'${chunk.raw}'`,
start: chunk.start,
end: chunk.end
}
: chunk.expression;
} else {
element.tag = get_attribute_expression(definition);
}
element.metadata.expression = new ExpressionMetadata();
}
if (is_top_level_script_or_style) {
parser.eat('>', true);
/** @type {AST.Comment | null} */
let prev_comment = null;
for (let i = current.fragment.nodes.length - 1; i >= 0; i--) {
const node = current.fragment.nodes[i];
if (i === current.fragment.nodes.length - 1 && node.end !== start) {
break;
}
if (node.type === 'Comment') {
prev_comment = node;
break;
} else if (node.type !== 'Text' || node.data.trim()) {
break;
}
}
if (tag.name === 'script') {
const content = read_script(parser, start, element.attributes);
if (prev_comment) {
// We take advantage of the fact that the root will never have leadingComments set,
// and set the previous comment to it so that the warning mechanism can later
// inspect the root and see if there was a html comment before it silencing specific warnings.
content.content.leadingComments = [{ type: 'Line', value: prev_comment.data }];
}
if (content.context === 'module') {
if (current.module) e.script_duplicate(start);
current.module = content;
} else {
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) e.style_duplicate(start);
current.css = content;
}
return;
}
parser.append(element);
const self_closing = parser.eat('/') || is_void(tag.name);
const closed = parser.eat('>', true, false);
// Loose parsing mode
if (!closed) {
// We may have eaten an opening `<` of the next element and treated it as an attribute...
const last = element.attributes.at(-1);
if (last?.type === 'Attribute' && last.name === '<') {
parser.index = last.start;
element.attributes.pop();
} else {
// ... or we may have eaten part of a following block ...
const prev_1 = parser.template[parser.index - 1];
const prev_2 = parser.template[parser.index - 2];
const current = parser.template[parser.index];
if (prev_2 === '{' && prev_1 === '/') {
parser.index -= 2;
} else if (prev_1 === '{' && (current === '#' || current === '@' || current === ':')) {
parser.index -= 1;
} else {
// ... or we're followed by whitespace, for example near the end of the template,
// which we want to take in so that language tools has more room to work with
parser.allow_whitespace();
}
}
}
if (self_closing || !closed) {
// don't push self-closing elements onto the stack
element.end = parser.index;
} else if (tag.name === 'textarea') {
// special case
element.fragment.nodes = read_sequence(
parser,
() => {
regex_closing_textarea_tag.lastIndex = parser.index;
return regex_closing_textarea_tag.test(parser.template);
},
'inside <textarea>'
);
parser.read(regex_closing_textarea_tag);
element.end = parser.index;
} else if (tag.name === 'script' || tag.name === 'style') {
// special case
const start = parser.index;
const close_tag = `</${tag.name}>`;
const close_index = parser.template.indexOf(close_tag, parser.index);
const data = parser.template.slice(
parser.index,
close_index === -1 ? parser.template.length : close_index
);
parser.index = close_index === -1 ? parser.template.length : close_index;
const end = parser.index;
/** @type {AST.Text} */
const node = {
start,
end,
type: 'Text',
data,
raw: data
};
element.fragment.nodes.push(node);
parser.eat(`</${tag.name}>`, true);
element.end = parser.index;
} else {
parser.stack.push(element);
parser.fragments.push(element.fragment);
}
}
/** @param {AST.TemplateNode[]} stack */
function parent_is_head(stack) {
let i = stack.length;
while (i--) {
const { type } = stack[i];
if (type === 'SvelteHead') return true;
if (type === 'RegularElement' || type === 'Component') return false;
}
return false;
}
/** @param {AST.TemplateNode[]} stack */
function parent_is_shadowroot_template(stack) {
// https://developer.chrome.com/docs/css-ui/declarative-shadow-dom#building_a_declarative_shadow_root
let i = stack.length;
while (i--) {
if (
stack[i].type === 'RegularElement' &&
/** @type {AST.RegularElement} */ (stack[i]).attributes.some(
(a) => a.type === 'Attribute' && a.name === 'shadowrootmode'
)
) {
return true;
}
}
return false;
}
/**
* @param {Parser} parser
* @returns {AST.Attribute | null}
*/
function read_static_attribute(parser) {
const start = parser.index;
const tag = read_tag(parser, regex_token_ending_character);
if (!tag.name) return null;
/** @type {true | Array<AST.Text | AST.ExpressionTag>} */
let value = true;
if (parser.eat('=')) {
parser.allow_whitespace();
let raw = parser.match_regex(regex_attribute_value);
if (!raw) {
e.expected_attribute_value(parser.index);
}
parser.index += raw.length;
const quoted = raw[0] === '"' || raw[0] === "'";
if (quoted) {
raw = raw.slice(1, -1);
}
value = [
{
start: parser.index - raw.length - (quoted ? 1 : 0),
end: quoted ? parser.index - 1 : parser.index,
type: 'Text',
raw: raw,
data: decode_character_references(raw, true)
}
];
}
if (parser.match_regex(regex_starts_with_quote_characters)) {
e.expected_token(parser.index, '=');
}
return create_attribute(tag.name, tag.loc, start, parser.index, value);
}
/**
* @param {Parser} parser
* @returns {AST.Attribute | AST.SpreadAttribute | AST.Directive | AST.AttachTag | null}
*/
function read_attribute(parser) {
/** @type {AST.JSComment | null} */
// eslint-disable-next-line no-useless-assignment -- it is, in fact, eslint that is useless
let comment = null;
while ((comment = read_comment(parser))) {
parser.root.comments.push(comment);
parser.allow_whitespace();
}
const start = parser.index;
if (parser.eat('{')) {
parser.allow_whitespace();
if (parser.eat('@attach')) {
parser.require_whitespace();
const expression = read_expression(parser);
parser.allow_whitespace();
parser.eat('}', true);
/** @type {AST.AttachTag} */
const attachment = {
type: 'AttachTag',
start,
end: parser.index,
expression,
metadata: {
expression: new ExpressionMetadata()
}
};
return attachment;
}
if (parser.eat('...')) {
const expression = read_expression(parser);
parser.allow_whitespace();
parser.eat('}', true);
/** @type {AST.SpreadAttribute} */
const spread = {
type: 'SpreadAttribute',
start,
end: parser.index,
expression,
metadata: {
expression: new ExpressionMetadata()
}
};
return spread;
} else {
const id = parser.read_identifier();
if (id.name === '') {
if (
parser.loose &&
(parser.match('#') || parser.match('/') || parser.match('@') || parser.match(':'))
) {
// We're likely in an unclosed opening tag and did read part of a block.
// Return null to not crash the parser so it can continue with closing the tag.
return null;
} else if (parser.loose && parser.match('}')) {
// Likely in the middle of typing, just created the shorthand
} else {
e.attribute_empty_shorthand(start);
}
}
parser.allow_whitespace();
parser.eat('}', true);
/** @type {AST.ExpressionTag} */
const expression = {
type: 'ExpressionTag',
start: id.start,
end: id.end,
expression: id,
metadata: {
expression: new ExpressionMetadata()
}
};
return create_attribute(id.name, id.loc, start, parser.index, expression);
}
}
const tag = read_tag(parser, regex_token_ending_character);
if (!tag.name) return null;
let end = parser.index;
parser.allow_whitespace();
const colon_index = tag.name.indexOf(':');
const type = colon_index !== -1 && get_directive_type(tag.name.slice(0, colon_index));
/** @type {true | AST.ExpressionTag | Array<AST.Text | AST.ExpressionTag>} */
let value = true;
if (parser.eat('=')) {
parser.allow_whitespace();
if (parser.template[parser.index] === '/' && parser.template[parser.index + 1] === '>') {
const char_start = parser.index;
parser.index++; // consume '/'
value = [
{
start: char_start,
end: char_start + 1,
type: 'Text',
raw: '/',
data: '/'
}
];
end = parser.index;
} else {
value = read_attribute_value(parser);
end = parser.index;
}
} else if (parser.match_regex(regex_starts_with_quote_characters)) {
e.expected_token(parser.index, '=');
}
if (type) {
const [directive_name, ...modifiers] = tag.name.slice(colon_index + 1).split('|');
if (directive_name === '') {
e.directive_missing_name({ start, end: start + colon_index + 1 }, tag.name);
}
if (type === 'StyleDirective') {
return {
start,
end,
type,
name: directive_name,
name_loc: tag.loc,
modifiers: /** @type {Array<'important'>} */ (modifiers),
value,
metadata: {
expression: new ExpressionMetadata()
}
};
}
const first_value = value === true ? undefined : Array.isArray(value) ? value[0] : value;
/** @type {Expression | null} */
let expression = null;
if (first_value) {
const attribute_contains_text =
/** @type {any[]} */ (value).length > 1 || first_value.type === 'Text';
if (attribute_contains_text) {
e.directive_invalid_value(/** @type {number} */ (first_value.start));
} else {
// TODO throw a parser error in a future version here if this `[ExpressionTag]` instead of `ExpressionTag`,
// which means stringified value, which isn't allowed for some directives?
expression = first_value.expression;
}
}
const directive = /** @type {AST.Directive} */ ({
start,
end,
type,
name: directive_name,
name_loc: tag.loc,
expression,
metadata: {
expression: new ExpressionMetadata()
}
});
// @ts-expect-error we do this separately from the declaration to avoid upsetting typescript
directive.modifiers = modifiers;
if (directive.type === 'TransitionDirective') {
const direction = tag.name.slice(0, colon_index);
directive.intro = direction === 'in' || direction === 'transition';
directive.outro = direction === 'out' || direction === 'transition';
}
// Directive name is expression, e.g. <p class:isRed />
if (
(directive.type === 'BindDirective' || directive.type === 'ClassDirective') &&
!directive.expression
) {
directive.expression = /** @type {any} */ ({
start: start + colon_index + 1,
end,
type: 'Identifier',
name: directive.name
});
}
return directive;
}
return create_attribute(tag.name, tag.loc, start, end, value);
}
/**
* @param {Parser} parser
* @returns {AST.JSComment | null}
*/
function read_comment(parser) {
const start = parser.index;
if (parser.eat('//')) {
const value = parser.read_until(/\n/);
const end = parser.index;
return {
type: 'Line',
start,
end,
value,
loc: {
start: locator(start),
end: locator(end)
}
};
}
if (parser.eat('/*')) {
const value = parser.read_until(/\*\//);
parser.eat('*/');
const end = parser.index;
return {
type: 'Block',
start,
end,
value,
loc: {
start: locator(start),
end: locator(end)
}
};
}
return null;
}
/**
* @param {string} name
* @returns {any}
*/
function get_directive_type(name) {
if (name === 'use') return 'UseDirective';
if (name === 'animate') return 'AnimateDirective';
if (name === 'bind') return 'BindDirective';
if (name === 'class') return 'ClassDirective';
if (name === 'style') return 'StyleDirective';
if (name === 'on') return 'OnDirective';
if (name === 'let') return 'LetDirective';
if (name === 'in' || name === 'out' || name === 'transition') return 'TransitionDirective';
return false;
}
/**
* @param {Parser} parser
* @return {AST.ExpressionTag | Array<AST.ExpressionTag | AST.Text>}
*/
function read_attribute_value(parser) {
const quote_mark = parser.eat("'") ? "'" : parser.eat('"') ? '"' : null;
if (quote_mark && parser.eat(quote_mark)) {
return [
{
start: parser.index - 1,
end: parser.index - 1,
type: 'Text',
raw: '',
data: ''
}
];
}
/** @type {Array<AST.ExpressionTag | AST.Text>} */
let value;
try {
value = read_sequence(
parser,
() => {
// handle common case of quote marks existing outside of regex for performance reasons
if (quote_mark) return parser.match(quote_mark);
return !!parser.match_regex(regex_invalid_unquoted_attribute_value);
},
'in attribute value'
);
} catch (/** @type {any} */ error) {
if (error.code === 'js_parse_error') {
// if the attribute value didn't close + self-closing tag
// eg: `<Component test={{a:1} />`
// acorn may throw a `Unterminated regular expression` because of `/>`
const pos = error.position?.[0];
if (pos !== undefined && parser.template.slice(pos - 1, pos + 1) === '/>') {
parser.index = pos;
e.expected_token(pos, quote_mark || '}');
}
}
throw error;
}
if (value.length === 0 && !quote_mark) {
e.expected_attribute_value(parser.index);
}
if (quote_mark) parser.index += 1;
if (quote_mark || value.length > 1 || value[0].type === 'Text') {
return value;
} else {
return value[0];
}
}
/**
* @param {Parser} parser
* @param {() => boolean} done
* @param {string} location
* @returns {any[]}
*/
function read_sequence(parser, done, location) {
/** @type {AST.Text} */
let current_chunk = {
start: parser.index,
end: -1,
type: 'Text',
raw: '',
data: ''
};
/** @type {Array<AST.Text | AST.ExpressionTag>} */
const chunks = [];
/** @param {number} end */
function flush(end) {
if (end > current_chunk.start) {
current_chunk.raw = parser.template.slice(current_chunk.start, end);
current_chunk.data = decode_character_references(current_chunk.raw, true);
current_chunk.end = end;
chunks.push(current_chunk);
}
}
while (parser.index < parser.template.length) {
const index = parser.index;
if (done()) {
flush(parser.index);
return chunks;
} else if (parser.eat('{')) {
if (parser.match('#')) {
const index = parser.index - 1;
parser.eat('#');
const name = parser.read_until(/[^a-z]/);
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]/);
e.tag_invalid_placement(index, name, location);
}
flush(parser.index - 1);
parser.allow_whitespace();
const expression = read_expression(parser);
parser.allow_whitespace();
parser.eat('}', true);
/** @type {AST.ExpressionTag} */
const chunk = {
type: 'ExpressionTag',
start: index,
end: parser.index,
expression,
metadata: {
expression: new ExpressionMetadata()
}
};
chunks.push(chunk);
current_chunk = {
start: parser.index,
end: -1,
type: 'Text',
raw: '',
data: ''
};
} else {
parser.index++;
}
}
if (parser.loose) {
return chunks;
} else {
e.unexpected_eof(parser.template.length);
}
}
/**
* @param {Parser} parser
* @param {RegExp} regex
* @returns {Identifier & { start: number, end: number, loc: SourceLocation }}
*/
function read_tag(parser, regex) {
const start = parser.index;
const name = parser.read_until(regex);
const end = parser.index;
return {
type: 'Identifier',
name,
start,
end,
loc: {
start: locator(start),
end: locator(end)
}
};
}
@@ -0,0 +1,17 @@
/** @import { Parser } from '../index.js' */
import element from './element.js';
import tag from './tag.js';
import text from './text.js';
/** @param {Parser} parser */
export default function fragment(parser) {
if (parser.match('<')) {
return element;
}
if (parser.match('{')) {
return tag;
}
return text;
}
+746
View File
@@ -0,0 +1,746 @@
/** @import { ArrowFunctionExpression, Expression, Identifier, Pattern } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { Parser } from '../index.js' */
import { walk } from 'zimmerframe';
import * as e from '../../../errors.js';
import { ExpressionMetadata } from '../../nodes.js';
import { parse_expression_at } from '../acorn.js';
import read_pattern from '../read/context.js';
import read_expression, { get_loose_identifier } from '../read/expression.js';
import { create_fragment } from '../utils/create.js';
import { match_bracket } from '../utils/bracket.js';
const regex_whitespace_with_closing_curly_brace = /\s*}/y;
const pointy_bois = { '<': '>' };
/** @param {Parser} parser */
export default function tag(parser) {
const start = parser.index;
parser.index += 1;
parser.allow_whitespace();
if (parser.eat('#')) return open(parser);
if (parser.eat(':')) return next(parser);
if (parser.eat('@')) return special(parser);
if (parser.match('/')) {
if (!parser.match('/*') && !parser.match('//')) {
parser.eat('/');
return close(parser);
}
}
const expression = read_expression(parser);
parser.allow_whitespace();
parser.eat('}', true);
parser.append({
type: 'ExpressionTag',
start,
end: parser.index,
expression,
metadata: {
expression: new ExpressionMetadata()
}
});
}
/** @param {Parser} parser */
function open(parser) {
let start = parser.index - 2;
while (parser.template[start] !== '{') start -= 1;
if (parser.eat('if')) {
parser.require_whitespace();
/** @type {AST.IfBlock} */
const block = parser.append({
type: 'IfBlock',
elseif: false,
start,
end: -1,
test: read_expression(parser),
consequent: create_fragment(),
alternate: null,
metadata: {
expression: new ExpressionMetadata()
}
});
parser.allow_whitespace();
parser.eat('}', true);
parser.stack.push(block);
parser.fragments.push(block.consequent);
return;
}
if (parser.eat('each')) {
parser.require_whitespace();
const template = parser.template;
let end = parser.template.length;
/** @type {Expression | undefined} */
let expression;
// we have to do this loop because `{#each x as { y = z }}` fails to parse —
// the `as { y = z }` is treated as an Expression but it's actually a Pattern.
// the 'fix' is to backtrack and hide everything from the `as` onwards, until
// we get a valid expression
while (!expression) {
try {
expression = read_expression(parser, undefined, true);
} catch (err) {
end = /** @type {any} */ (err).position[0] - 2;
while (end > start && parser.template.slice(end, end + 2) !== 'as') {
end -= 1;
}
if (end <= start) {
if (parser.loose) {
expression = get_loose_identifier(parser);
if (expression) {
break;
}
}
throw err;
}
// @ts-expect-error parser.template is meant to be readonly, this is a special case
parser.template = template.slice(0, end);
}
}
// @ts-expect-error
parser.template = template;
parser.allow_whitespace();
// {#each} blocks must declare a context {#each list as item}
if (!parser.match('as')) {
// this could be a TypeScript assertion that was erroneously eaten.
if (expression.type === 'SequenceExpression') {
expression = expression.expressions[0];
}
let assertion = null;
let end = expression.end;
expression = walk(expression, null, {
// @ts-expect-error
TSAsExpression(node, context) {
if (node.end === /** @type {Expression} */ (expression).end) {
assertion = node;
end = node.expression.end;
return node.expression;
}
context.next();
}
});
expression.end = end;
if (assertion) {
// we can't reset `parser.index` to `expression.expression.end` because
// it will ignore any parentheses — we need to jump through this hoop
let end = /** @type {any} */ (/** @type {any} */ (assertion).typeAnnotation).start - 2;
while (parser.template.slice(end, end + 2) !== 'as') end -= 1;
parser.index = end;
}
}
/** @type {Pattern | null} */
let context = null;
let index;
let key;
if (parser.eat('as')) {
parser.require_whitespace();
context = read_pattern(parser);
} else {
// {#each Array.from({ length: 10 }), i} is read as a sequence expression,
// which is set back above - we now gotta reset the index as a consequence
// to properly read the , i part
parser.index = /** @type {number} */ (expression.end);
}
parser.allow_whitespace();
if (parser.eat(',')) {
parser.allow_whitespace();
index = parser.read_identifier().name;
if (!index) {
e.expected_identifier(parser.index);
}
parser.allow_whitespace();
}
if (parser.eat('(')) {
parser.allow_whitespace();
key = read_expression(parser, '(');
parser.allow_whitespace();
parser.eat(')', true);
parser.allow_whitespace();
}
const matches = parser.eat('}', true, false);
if (!matches) {
// Parser may have read the `as` as part of the expression (e.g. in `{#each foo. as x}`)
if (parser.template.slice(parser.index - 4, parser.index) === ' as ') {
const prev_index = parser.index;
context = read_pattern(parser);
parser.eat('}', true);
expression = {
type: 'Identifier',
name: '',
start: expression.start,
end: prev_index - 4
};
} else {
parser.eat('}', true); // rerun to produce the parser error
}
}
/** @type {AST.EachBlock} */
const block = parser.append({
type: 'EachBlock',
start,
end: -1,
expression,
body: create_fragment(),
context,
index,
key,
metadata: /** @type {any} */ (null) // filled in later
});
parser.stack.push(block);
parser.fragments.push(block.body);
return;
}
if (parser.eat('await')) {
parser.require_whitespace();
const expression = read_expression(parser);
parser.allow_whitespace();
/** @type {AST.AwaitBlock} */
const block = parser.append({
type: 'AwaitBlock',
start,
end: -1,
expression,
value: null,
error: null,
pending: null,
then: null,
catch: null,
metadata: {
expression: new ExpressionMetadata()
}
});
if (parser.eat('then')) {
if (parser.match_regex(regex_whitespace_with_closing_curly_brace)) {
parser.allow_whitespace();
} else {
parser.require_whitespace();
block.value = read_pattern(parser);
parser.allow_whitespace();
}
block.then = create_fragment();
parser.fragments.push(block.then);
} else if (parser.eat('catch')) {
if (parser.match_regex(regex_whitespace_with_closing_curly_brace)) {
parser.allow_whitespace();
} else {
parser.require_whitespace();
block.error = read_pattern(parser);
parser.allow_whitespace();
}
block.catch = create_fragment();
parser.fragments.push(block.catch);
} else {
block.pending = create_fragment();
parser.fragments.push(block.pending);
}
const matches = parser.eat('}', true, false);
// Parser may have read the `then/catch` as part of the expression (e.g. in `{#await foo. then x}`)
if (!matches) {
if (parser.template.slice(parser.index - 6, parser.index) === ' then ') {
const prev_index = parser.index;
block.value = read_pattern(parser);
parser.eat('}', true);
block.expression = {
type: 'Identifier',
name: '',
start: expression.start,
end: prev_index - 6
};
block.then = block.pending;
block.pending = null;
} else if (parser.template.slice(parser.index - 7, parser.index) === ' catch ') {
const prev_index = parser.index;
block.error = read_pattern(parser);
parser.eat('}', true);
block.expression = {
type: 'Identifier',
name: '',
start: expression.start,
end: prev_index - 7
};
block.catch = block.pending;
block.pending = null;
} else {
parser.eat('}', true); // rerun to produce the parser error
}
}
parser.stack.push(block);
return;
}
if (parser.eat('key')) {
parser.require_whitespace();
const expression = read_expression(parser);
parser.allow_whitespace();
parser.eat('}', true);
/** @type {AST.KeyBlock} */
const block = parser.append({
type: 'KeyBlock',
start,
end: -1,
expression,
fragment: create_fragment(),
metadata: {
expression: new ExpressionMetadata()
}
});
parser.stack.push(block);
parser.fragments.push(block.fragment);
return;
}
if (parser.eat('snippet')) {
parser.require_whitespace();
const id = parser.read_identifier();
if (id.name === '' && !parser.loose) {
e.expected_identifier(parser.index);
}
parser.allow_whitespace();
const params_start = parser.index;
// snippets could have a generic signature, e.g. `#snippet foo<T>(...)`
/** @type {string | undefined} */
let type_params;
// if we match a generic opening
if (parser.ts && parser.match('<')) {
const start = parser.index;
const end = match_bracket(parser, start, pointy_bois);
type_params = parser.template.slice(start + 1, end - 1);
parser.index = end;
}
parser.allow_whitespace();
const matched = parser.eat('(', true, false);
if (matched) {
let parentheses = 1;
while (parser.index < parser.template.length && (!parser.match(')') || parentheses !== 1)) {
if (parser.match('(')) parentheses++;
if (parser.match(')')) parentheses--;
parser.index += 1;
}
parser.eat(')', true);
}
const prelude = parser.template.slice(0, params_start).replace(/\S/g, ' ');
const params = parser.template.slice(params_start, parser.index);
let function_expression = matched
? /** @type {ArrowFunctionExpression} */ (
parse_expression_at(parser, prelude + `${params} => {}`, params_start)
)
: { params: [] };
parser.allow_whitespace();
parser.eat('}', true);
/** @type {AST.SnippetBlock} */
const block = parser.append({
type: 'SnippetBlock',
start,
end: -1,
expression: id,
typeParams: type_params,
parameters: function_expression.params,
body: create_fragment(),
metadata: {
can_hoist: false,
sites: new Set()
}
});
parser.stack.push(block);
parser.fragments.push(block.body);
return;
}
e.expected_block_type(parser.index);
}
/** @param {Parser} parser */
function next(parser) {
const start = parser.index - 1;
const block = parser.current(); // TODO type should not be TemplateNode, that's much too broad
if (block.type === 'IfBlock') {
if (!parser.eat('else')) e.expected_token(start, '{:else} or {:else if}');
if (parser.eat('if')) e.block_invalid_elseif(start);
parser.allow_whitespace();
parser.fragments.pop();
block.alternate = create_fragment();
parser.fragments.push(block.alternate);
// :else if
if (parser.eat('if')) {
parser.require_whitespace();
const expression = read_expression(parser);
parser.allow_whitespace();
parser.eat('}', true);
let elseif_start = start - 1;
while (parser.template[elseif_start] !== '{') elseif_start -= 1;
/** @type {AST.IfBlock} */
const child = parser.append({
start: elseif_start,
end: -1,
type: 'IfBlock',
elseif: true,
test: expression,
consequent: create_fragment(),
alternate: null,
metadata: {
expression: new ExpressionMetadata()
}
});
parser.stack.push(child);
parser.fragments.pop();
parser.fragments.push(child.consequent);
} else {
// :else
parser.allow_whitespace();
parser.eat('}', true);
}
return;
}
if (block.type === 'EachBlock') {
if (!parser.eat('else')) e.expected_token(start, '{:else}');
parser.allow_whitespace();
parser.eat('}', true);
block.fallback = create_fragment();
parser.fragments.pop();
parser.fragments.push(block.fallback);
return;
}
if (block.type === 'AwaitBlock') {
if (parser.eat('then')) {
if (block.then) {
e.block_duplicate_clause(start, '{:then}');
}
if (!parser.eat('}')) {
parser.require_whitespace();
block.value = read_pattern(parser);
parser.allow_whitespace();
parser.eat('}', true);
}
block.then = create_fragment();
parser.fragments.pop();
parser.fragments.push(block.then);
return;
}
if (parser.eat('catch')) {
if (block.catch) {
e.block_duplicate_clause(start, '{:catch}');
}
if (!parser.eat('}')) {
parser.require_whitespace();
block.error = read_pattern(parser);
parser.allow_whitespace();
parser.eat('}', true);
}
block.catch = create_fragment();
parser.fragments.pop();
parser.fragments.push(block.catch);
return;
}
e.expected_token(start, '{:then ...} or {:catch ...}');
}
e.block_invalid_continuation_placement(start);
}
/** @param {Parser} parser */
function close(parser) {
const start = parser.index - 1;
let block = parser.current();
/** Only relevant/reached for loose parsing mode */
let matched;
switch (block.type) {
case 'IfBlock':
matched = parser.eat('if', true, false);
if (!matched) {
block.end = start - 1;
parser.pop();
close(parser);
return;
}
parser.allow_whitespace();
parser.eat('}', true);
while (block.elseif) {
block.end = parser.index;
parser.stack.pop();
block = /** @type {AST.IfBlock} */ (parser.current());
}
block.end = parser.index;
parser.pop();
return;
case 'EachBlock':
matched = parser.eat('each', true, false);
break;
case 'KeyBlock':
matched = parser.eat('key', true, false);
break;
case 'AwaitBlock':
matched = parser.eat('await', true, false);
break;
case 'SnippetBlock':
matched = parser.eat('snippet', true, false);
break;
case 'RegularElement':
if (parser.loose) {
matched = false;
} else {
// TODO handle implicitly closed elements
e.block_unexpected_close(start);
}
break;
default:
e.block_unexpected_close(start);
}
if (!matched) {
block.end = start - 1;
parser.pop();
close(parser);
return;
}
parser.allow_whitespace();
parser.eat('}', true);
block.end = parser.index;
parser.pop();
}
/** @param {Parser} parser */
function special(parser) {
let start = parser.index;
while (parser.template[start] !== '{') start -= 1;
if (parser.eat('html')) {
// {@html content} tag
parser.require_whitespace();
const expression = read_expression(parser);
parser.allow_whitespace();
parser.eat('}', true);
parser.append({
type: 'HtmlTag',
start,
end: parser.index,
expression,
metadata: {
expression: new ExpressionMetadata()
}
});
return;
}
if (parser.eat('debug')) {
/** @type {Identifier[]} */
let identifiers;
// Implies {@debug} which indicates "debug all"
if (parser.read(regex_whitespace_with_closing_curly_brace)) {
identifiers = [];
} else {
const expression = read_expression(parser);
identifiers =
expression.type === 'SequenceExpression'
? /** @type {Identifier[]} */ (expression.expressions)
: [/** @type {Identifier} */ (expression)];
identifiers.forEach(
/** @param {any} node */ (node) => {
if (node.type !== 'Identifier') {
e.debug_tag_invalid_arguments(/** @type {number} */ (node.start));
}
}
);
parser.allow_whitespace();
parser.eat('}', true);
}
parser.append({
type: 'DebugTag',
start,
end: parser.index,
identifiers
});
return;
}
if (parser.eat('const')) {
parser.require_whitespace();
const id = read_pattern(parser);
parser.allow_whitespace();
parser.eat('=', true);
parser.allow_whitespace();
const expression_start = parser.index;
const init = read_expression(parser);
if (
init.type === 'SequenceExpression' &&
!parser.template.substring(expression_start, init.start).includes('(')
) {
// const a = (b, c) is allowed but a = b, c = d is not;
e.const_tag_invalid_expression(init);
}
parser.allow_whitespace();
parser.eat('}', true);
parser.append({
type: 'ConstTag',
start,
end: parser.index,
declaration: {
type: 'VariableDeclaration',
kind: 'const',
declarations: [{ type: 'VariableDeclarator', id, init, start: id.start, end: init.end }],
start: start + 2, // start at const, not at @const
end: parser.index - 1
},
metadata: {
expression: new ExpressionMetadata()
}
});
return;
}
if (parser.eat('render')) {
// {@render foo(...)}
parser.require_whitespace();
const expression = read_expression(parser);
if (
expression.type !== 'CallExpression' &&
(expression.type !== 'ChainExpression' || expression.expression.type !== 'CallExpression')
) {
e.render_tag_invalid_expression(expression);
}
parser.allow_whitespace();
parser.eat('}', true);
parser.append({
type: 'RenderTag',
start,
end: parser.index,
expression: /** @type {AST.RenderTag['expression']} */ (expression),
metadata: {
expression: new ExpressionMetadata(),
dynamic: false,
arguments: [],
path: [],
snippets: new Set()
}
});
return;
}
e.expected_tag(parser.index);
}
+23
View File
@@ -0,0 +1,23 @@
/** @import { AST } from '#compiler' */
/** @import { Parser } from '../index.js' */
import { decode_character_references } from '../utils/html.js';
/** @param {Parser} parser */
export default function text(parser) {
const start = parser.index;
while (parser.index < parser.template.length && !parser.match('<') && !parser.match('{')) {
parser.index++;
}
const data = parser.template.slice(start, parser.index);
/** @type {AST.Text} */
parser.append({
type: 'Text',
start,
end: parser.index,
raw: data,
data: decode_character_references(data, false)
});
}
@@ -0,0 +1,215 @@
/** @import { Parser } from '../index.js' */
import * as e from '../../../errors.js';
/**
* @param {number} num
* @returns {number} Infinity if {@link num} is negative, else {@link num}.
*/
function infinity_if_negative(num) {
if (num < 0) {
return Infinity;
}
return num;
}
/**
* @param {string} string The string to search.
* @param {number} search_start_index The index to start searching at.
* @param {"'" | '"' | '`'} string_start_char The character that started this string.
* @returns {number} The index of the end of this string expression, or `Infinity` if not found.
*/
function find_string_end(string, search_start_index, string_start_char) {
let string_to_search;
if (string_start_char === '`') {
string_to_search = string;
} else {
// we could slice at the search start index, but this way the index remains valid
string_to_search = string.slice(
0,
infinity_if_negative(string.indexOf('\n', search_start_index))
);
}
return find_unescaped_char(string_to_search, search_start_index, string_start_char);
}
/**
* @param {string} string The string to search.
* @param {number} search_start_index The index to start searching at.
* @returns {number} The index of the end of this regex expression, or `Infinity` if not found.
*/
function find_regex_end(string, search_start_index) {
return find_unescaped_char(string, search_start_index, '/');
}
/**
*
* @param {string} string The string to search.
* @param {number} search_start_index The index to begin the search at.
* @param {string} char The character to search for.
* @returns {number} The index of the first unescaped instance of {@link char}, or `Infinity` if not found.
*/
function find_unescaped_char(string, search_start_index, char) {
let i = search_start_index;
while (true) {
const found_index = string.indexOf(char, i);
if (found_index === -1) {
return Infinity;
}
if (count_leading_backslashes(string, found_index - 1) % 2 === 0) {
return found_index;
}
i = found_index + 1;
}
}
/**
* Count consecutive leading backslashes before {@link search_start_index}.
*
* @example
* ```js
* count_leading_backslashes('\\\\\\foo', 2); // 3 (the backslashes have to be escaped in the string literal, there are three in reality)
* ```
*
* @param {string} string The string to search.
* @param {number} search_start_index The index to begin the search at.
*/
function count_leading_backslashes(string, search_start_index) {
let i = search_start_index;
let count = 0;
while (string[i] === '\\') {
count++;
i--;
}
return count;
}
/**
* Finds the corresponding closing bracket, ignoring brackets found inside comments, strings, or regex expressions.
* @param {string} template The string to search.
* @param {number} index The index to begin the search at.
* @param {string} open The opening bracket (ex: `'{'` will search for `'}'`).
* @returns {number | undefined} The index of the closing bracket, or undefined if not found.
*/
export function find_matching_bracket(template, index, open) {
const close = default_brackets[open];
let brackets = 1;
let i = index;
while (brackets > 0 && i < template.length) {
const char = template[i];
switch (char) {
case "'":
case '"':
case '`':
i = find_string_end(template, i + 1, char) + 1;
continue;
case '/': {
const next_char = template[i + 1];
if (!next_char) continue;
if (next_char === '/') {
i = infinity_if_negative(template.indexOf('\n', i + 1)) + '\n'.length;
continue;
}
if (next_char === '*') {
i = infinity_if_negative(template.indexOf('*/', i + 1)) + '*/'.length;
continue;
}
i = find_regex_end(template, i + 1) + '/'.length;
continue;
}
default: {
const char = template[i];
if (char === open) {
brackets++;
} else if (char === close) {
brackets--;
}
if (brackets === 0) {
return i;
}
i++;
}
}
}
return undefined;
}
/** @type {Record<string, string>} */
const default_brackets = {
'{': '}',
'(': ')',
'[': ']'
};
const default_close = new Set(Object.values(default_brackets));
/**
* @param {Parser} parser
* @param {number} start
* @param {Record<string, string>} brackets
*/
export function match_bracket(parser, start, brackets = default_brackets) {
const close = brackets === default_brackets ? default_close : new Set(Object.values(brackets));
const bracket_stack = [];
let i = start;
while (i < parser.template.length) {
let char = parser.template[i++];
if (char === "'" || char === '"' || char === '`') {
i = match_quote(parser, i, char);
continue;
}
if (char in brackets) {
bracket_stack.push(char);
} else if (close.has(char)) {
const popped = /** @type {string} */ (bracket_stack.pop());
const expected = /** @type {string} */ (brackets[popped]);
if (char !== expected) {
e.expected_token(i - 1, expected);
}
if (bracket_stack.length === 0) {
return i;
}
}
}
e.unexpected_eof(parser.template.length);
}
/**
* @param {Parser} parser
* @param {number} start
* @param {string} quote
*/
function match_quote(parser, start, quote) {
let is_escaped = false;
let i = start;
while (i < parser.template.length) {
const char = parser.template[i++];
if (is_escaped) {
is_escaped = false;
continue;
}
if (char === quote) {
return i;
}
if (char === '\\') {
is_escaped = true;
}
if (quote === '`' && char === '$' && parser.template[i] === '{') {
i = match_bracket(parser, i);
}
}
e.unterminated_string_constant(start);
}
@@ -0,0 +1,16 @@
/** @import { AST } from '#compiler' */
/**
* @param {any} transparent
* @returns {AST.Fragment}
*/
export function create_fragment(transparent = false) {
return {
type: 'Fragment',
nodes: [],
metadata: {
transparent,
dynamic: false
}
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,281 @@
/**
* @param {string} name
* @param {string[]} names
* @returns {string | null}
*/
export default function fuzzymatch(name, names) {
if (names.length === 0) return null;
const set = new FuzzySet(names);
const matches = set.get(name);
return matches && matches[0][0] > 0.7 ? matches[0][1] : null;
}
// adapted from https://github.com/Glench/fuzzyset.js/blob/master/lib/fuzzyset.js in 2016
// BSD Licensed (see https://github.com/Glench/fuzzyset.js/issues/10)
const GRAM_SIZE_LOWER = 2;
const GRAM_SIZE_UPPER = 3;
// return an edit distance from 0 to 1
/**
* @param {string} str1
* @param {string} str2
*/
function _distance(str1, str2) {
if (str1 === null && str2 === null) {
throw 'Trying to compare two null values';
}
if (str1 === null || str2 === null) return 0;
str1 = String(str1);
str2 = String(str2);
const distance = levenshtein(str1, str2);
return 1 - distance / Math.max(str1.length, str2.length);
}
// helper functions
/**
* @param {string} str1
* @param {string} str2
*/
function levenshtein(str1, str2) {
/** @type {number[]} */
const current = [];
let prev = 0;
for (let i = 0; i <= str2.length; i++) {
for (let j = 0; j <= str1.length; j++) {
let value;
if (i && j) {
if (str1.charAt(j - 1) === str2.charAt(i - 1)) {
value = prev;
} else {
value = Math.min(current[j], current[j - 1], prev) + 1;
}
} else {
value = i + j;
}
prev = current[j];
current[j] = value;
}
}
return /** @type {number} */ (current.pop());
}
const non_word_regex = /[^\w, ]+/;
/**
* @param {string} value
* @param {any} gram_size
*/
function iterate_grams(value, gram_size = 2) {
const simplified = '-' + value.toLowerCase().replace(non_word_regex, '') + '-';
const len_diff = gram_size - simplified.length;
const results = [];
if (len_diff > 0) {
for (let i = 0; i < len_diff; ++i) {
value += '-';
}
}
for (let i = 0; i < simplified.length - gram_size + 1; ++i) {
results.push(simplified.slice(i, i + gram_size));
}
return results;
}
/**
* @param {string} value
* @param {any} gram_size
*/
function gram_counter(value, gram_size = 2) {
// return an object where key=gram, value=number of occurrences
/** @type {Record<string, number>} */
const result = {};
const grams = iterate_grams(value, gram_size);
let i = 0;
for (i; i < grams.length; ++i) {
if (grams[i] in result) {
result[grams[i]] += 1;
} else {
result[grams[i]] = 1;
}
}
return result;
}
/**
* @param {MatchTuple} a
* @param {MatchTuple} b
*/
function sort_descending(a, b) {
return b[0] - a[0];
}
class FuzzySet {
/** @type {Record<string, string>} */
exact_set = {};
/** @type {Record<string, [number, number][]>} */
match_dict = {};
/** @type {Record<string, number[]>} */
items = {};
/** @param {string[]} arr */
constructor(arr) {
// initialisation
for (let i = GRAM_SIZE_LOWER; i < GRAM_SIZE_UPPER + 1; ++i) {
this.items[i] = [];
}
// add all the items to the set
for (let i = 0; i < arr.length; ++i) {
this.add(arr[i]);
}
}
/** @param {string} value */
add(value) {
const normalized_value = value.toLowerCase();
if (normalized_value in this.exact_set) {
return false;
}
let i = GRAM_SIZE_LOWER;
for (i; i < GRAM_SIZE_UPPER + 1; ++i) {
this._add(value, i);
}
}
/**
* @param {string} value
* @param {number} gram_size
*/
_add(value, gram_size) {
const normalized_value = value.toLowerCase();
const items = this.items[gram_size] || [];
const index = items.length;
items.push(0);
const gram_counts = gram_counter(normalized_value, gram_size);
let sum_of_square_gram_counts = 0;
let gram;
let gram_count;
for (gram in gram_counts) {
gram_count = gram_counts[gram];
sum_of_square_gram_counts += Math.pow(gram_count, 2);
if (gram in this.match_dict) {
this.match_dict[gram].push([index, gram_count]);
} else {
this.match_dict[gram] = [[index, gram_count]];
}
}
const vector_normal = Math.sqrt(sum_of_square_gram_counts);
// @ts-ignore no idea what this code is doing
items[index] = [vector_normal, normalized_value];
this.items[gram_size] = items;
this.exact_set[normalized_value] = value;
}
/** @param {string} value */
get(value) {
const normalized_value = value.toLowerCase();
const result = this.exact_set[normalized_value];
if (result) {
return /** @type {MatchTuple[]} */ ([[1, result]]);
}
// start with high gram size and if there are no results, go to lower gram sizes
for (let gram_size = GRAM_SIZE_UPPER; gram_size >= GRAM_SIZE_LOWER; --gram_size) {
const results = this.__get(value, gram_size);
if (results.length > 0) return results;
}
return null;
}
/**
* @param {string} value
* @param {number} gram_size
* @returns {MatchTuple[]}
*/
__get(value, gram_size) {
const normalized_value = value.toLowerCase();
/** @type {Record<string, number>} */
const matches = {};
const gram_counts = gram_counter(normalized_value, gram_size);
const items = this.items[gram_size];
let sum_of_square_gram_counts = 0;
let gram;
let gram_count;
let i;
let index;
let other_gram_count;
for (gram in gram_counts) {
gram_count = gram_counts[gram];
sum_of_square_gram_counts += Math.pow(gram_count, 2);
if (gram in this.match_dict) {
for (i = 0; i < this.match_dict[gram].length; ++i) {
index = this.match_dict[gram][i][0];
other_gram_count = this.match_dict[gram][i][1];
if (index in matches) {
matches[index] += gram_count * other_gram_count;
} else {
matches[index] = gram_count * other_gram_count;
}
}
}
}
const vector_normal = Math.sqrt(sum_of_square_gram_counts);
/** @type {MatchTuple[]} */
let results = [];
let match_score;
// build a results list of [score, str]
for (const match_index in matches) {
match_score = matches[match_index];
// @ts-ignore no idea what this code is doing
results.push([match_score / (vector_normal * items[match_index][0]), items[match_index][1]]);
}
results.sort(sort_descending);
/** @type {MatchTuple[]} */
let new_results = [];
const end_index = Math.min(50, results.length);
// truncate somewhat arbitrarily to 50
for (let i = 0; i < end_index; ++i) {
// @ts-ignore no idea what this code is doing
new_results.push([_distance(results[i][1], normalized_value), results[i][1]]);
}
results = new_results;
results.sort(sort_descending);
new_results = [];
for (let i = 0; i < results.length; ++i) {
if (results[i][0] === results[0][0]) {
// @ts-ignore no idea what this code is doing
new_results.push([results[i][0], this.exact_set[results[i][1]]]);
}
}
return new_results;
}
}
/** @typedef {[score: number, match: string]} MatchTuple */
+127
View File
@@ -0,0 +1,127 @@
import entities from './entities.js';
const windows_1252 = [
8364, 129, 8218, 402, 8222, 8230, 8224, 8225, 710, 8240, 352, 8249, 338, 141, 381, 143, 144, 8216,
8217, 8220, 8221, 8226, 8211, 8212, 732, 8482, 353, 8250, 339, 157, 382, 376
];
/**
* @param {string} entity_name
* @param {boolean} is_attribute_value
*/
function reg_exp_entity(entity_name, is_attribute_value) {
// https://html.spec.whatwg.org/multipage/parsing.html#named-character-reference-state
// doesn't decode the html entity which not ends with ; and next character is =, number or alphabet in attribute value.
if (is_attribute_value && !entity_name.endsWith(';')) {
return `${entity_name}\\b(?!=)`;
}
return entity_name;
}
/** @param {boolean} is_attribute_value */
function get_entity_pattern(is_attribute_value) {
const reg_exp_num = '#(?:x[a-fA-F\\d]+|\\d+)(?:;)?';
const reg_exp_entities = Object.keys(entities).map(
/** @param {any} entity_name */ (entity_name) => reg_exp_entity(entity_name, is_attribute_value)
);
const entity_pattern = new RegExp(`&(${reg_exp_num}|${reg_exp_entities.join('|')})`, 'g');
return entity_pattern;
}
const entity_pattern_content = get_entity_pattern(false);
const entity_pattern_attr_value = get_entity_pattern(true);
/**
* @param {string} html
* @param {boolean} is_attribute_value
*/
export function decode_character_references(html, is_attribute_value) {
const entity_pattern = is_attribute_value ? entity_pattern_attr_value : entity_pattern_content;
return html.replace(
entity_pattern,
/**
* @param {any} match
* @param {keyof typeof entities} entity
*/ (match, entity) => {
let code;
// Handle named entities
if (entity[0] !== '#') {
code = entities[entity];
} else if (entity[1] === 'x') {
code = parseInt(entity.substring(2), 16);
} else {
code = parseInt(entity.substring(1), 10);
}
if (!code) {
return match;
}
return String.fromCodePoint(validate_code(code));
}
);
}
const NUL = 0;
// some code points are verboten. If we were inserting HTML, the browser would replace the illegal
// code points with alternatives in some cases - since we're bypassing that mechanism, we need
// to replace them ourselves
//
// Source: http://en.wikipedia.org/wiki/Character_encodings_in_HTML#Illegal_characters
// Also see: https://en.wikipedia.org/wiki/Plane_(Unicode)
// Also see: https://html.spec.whatwg.org/multipage/parsing.html#preprocessing-the-input-stream
/** @param {number} code */
function validate_code(code) {
// line feed becomes generic whitespace
if (code === 10) {
return 32;
}
// ASCII range. (Why someone would use HTML entities for ASCII characters I don't know, but...)
if (code < 128) {
return code;
}
// code points 128-159 are dealt with leniently by browsers, but they're incorrect. We need
// to correct the mistake or we'll end up with missing € signs and so on
if (code <= 159) {
return windows_1252[code - 128];
}
// basic multilingual plane
if (code < 55296) {
return code;
}
// UTF-16 surrogate halves
if (code <= 57343) {
return NUL;
}
// rest of the basic multilingual plane
if (code <= 65535) {
return code;
}
// supplementary multilingual plane 0x10000 - 0x1ffff
if (code >= 65536 && code <= 131071) {
return code;
}
// supplementary ideographic plane 0x20000 - 0x2ffff
if (code >= 131072 && code <= 196607) {
return code;
}
// supplementary special-purpose plane 0xe0000 - 0xe07f and 0xe0100 - 0xe01ef
if ((code >= 917504 && code <= 917631) || (code >= 917760 && code <= 917999)) {
return code;
}
return NUL;
}
@@ -0,0 +1,331 @@
/** @import { ComponentAnalysis } from '../../types.js' */
/** @import { AST } from '#compiler' */
/** @import { Visitors } from 'zimmerframe' */
import { walk } from 'zimmerframe';
import * as e from '../../../errors.js';
import { is_keyframes_node } from '../../css.js';
import { is_global, is_unscoped_pseudo_class } from './utils.js';
/**
* @typedef {{
* keyframes: string[];
* rule: AST.CSS.Rule | null;
* analysis: ComponentAnalysis;
* }} CssState
*/
/**
* @typedef {Visitors<AST.CSS.Node, CssState>} CssVisitors
*/
/**
* True if is `:global`
* @param {AST.CSS.SimpleSelector} simple_selector
*/
function is_global_block_selector(simple_selector) {
return (
simple_selector.type === 'PseudoClassSelector' &&
simple_selector.name === 'global' &&
simple_selector.args === null
);
}
/**
* @param {AST.SvelteNode[]} path
*/
function is_unscoped(path) {
return path
.filter((node) => node.type === 'Rule')
.every((node) => node.metadata.has_global_selectors);
}
/**
*
* @param {Array<AST.CSS.Node>} path
*/
function is_in_global_block(path) {
return path.some((node) => node.type === 'Rule' && node.metadata.is_global_block);
}
/** @type {CssVisitors} */
const css_visitors = {
Atrule(node, context) {
if (is_keyframes_node(node)) {
if (!node.prelude.startsWith('-global-') && !is_in_global_block(context.path)) {
context.state.keyframes.push(node.prelude);
} else if (node.prelude.startsWith('-global-')) {
// we don't check if the block.children.length because the keyframe is still added even if empty
context.state.analysis.css.has_global ||= is_unscoped(context.path);
}
}
context.next();
},
ComplexSelector(node, context) {
context.next(); // analyse relevant selectors first
{
const global = node.children.find(is_global);
if (global) {
const is_nested = context.path.at(-2)?.type === 'PseudoClassSelector';
if (is_nested && !global.selectors[0].args) {
e.css_global_block_invalid_placement(global.selectors[0]);
}
const idx = node.children.indexOf(global);
if (global.selectors[0].args !== null && idx !== 0 && idx !== node.children.length - 1) {
// ensure `:global(...)` is not used in the middle of a selector (but multiple `global(...)` in sequence are ok)
for (let i = idx + 1; i < node.children.length; i++) {
if (!is_global(node.children[i])) {
e.css_global_invalid_placement(global.selectors[0]);
}
}
}
}
}
// ensure `:global(...)` do not lead to invalid css after `:global()` is removed
for (const relative_selector of node.children) {
for (let i = 0; i < relative_selector.selectors.length; i++) {
const selector = relative_selector.selectors[i];
if (selector.type === 'PseudoClassSelector' && selector.name === 'global') {
const child = selector.args?.children[0].children[0];
// ensure `:global(element)` to be at the first position in a compound selector
if (child?.selectors[0].type === 'TypeSelector' && i !== 0) {
e.css_global_invalid_selector_list(selector);
}
// ensure `:global(.class)` is not followed by a type selector, eg: `:global(.class)element`
if (relative_selector.selectors[i + 1]?.type === 'TypeSelector') {
e.css_type_selector_invalid_placement(relative_selector.selectors[i + 1]);
}
// ensure `:global(...)`contains a single selector
// (standalone :global() with multiple selectors is OK)
if (
selector.args !== null &&
selector.args.children.length > 1 &&
(node.children.length > 1 || relative_selector.selectors.length > 1)
) {
e.css_global_invalid_selector(selector);
}
}
}
}
node.metadata.rule = context.state.rule;
node.metadata.is_global = node.children.every(
({ metadata }) => metadata.is_global || metadata.is_global_like
);
node.metadata.used ||= node.metadata.is_global;
if (
node.metadata.rule?.metadata.parent_rule &&
node.children[0]?.selectors[0]?.type === 'NestingSelector'
) {
const first = node.children[0]?.selectors[1];
const no_nesting_scope =
first?.type !== 'PseudoClassSelector' || is_unscoped_pseudo_class(first);
const parent_is_global = node.metadata.rule.metadata.parent_rule.prelude.children.some(
(child) => child.children.length === 1 && child.children[0].metadata.is_global
);
// mark `&:hover` in `:global(.foo) { &:hover { color: green }}` as used
if (no_nesting_scope && parent_is_global) {
node.metadata.used = true;
}
}
},
RelativeSelector(node, context) {
const parent = /** @type {AST.CSS.ComplexSelector} */ (context.path.at(-1));
if (
node.combinator != null &&
!context.state.rule?.metadata.parent_rule &&
parent.children[0] === node &&
context.path.at(-3)?.type !== 'PseudoClassSelector'
) {
e.css_selector_invalid(node.combinator);
}
node.metadata.is_global = node.selectors.length >= 1 && is_global(node);
if (
node.selectors.length >= 1 &&
node.selectors.every(
(selector) =>
selector.type === 'PseudoClassSelector' || selector.type === 'PseudoElementSelector'
)
) {
const first = node.selectors[0];
node.metadata.is_global_like ||=
(first.type === 'PseudoClassSelector' && first.name === 'host') ||
(first.type === 'PseudoElementSelector' &&
[
'view-transition',
'view-transition-group',
'view-transition-old',
'view-transition-new',
'view-transition-image-pair'
].includes(first.name));
}
node.metadata.is_global_like ||=
node.selectors.some(
(child) => child.type === 'PseudoClassSelector' && child.name === 'root'
) &&
// :root.y:has(.x) is not a global selector because while .y is unscoped, .x inside `:has(...)` should be scoped
!node.selectors.some((child) => child.type === 'PseudoClassSelector' && child.name === 'has');
if (node.metadata.is_global_like || node.metadata.is_global) {
// So that nested selectors like `:root:not(.x)` are not marked as unused
for (const child of node.selectors) {
walk(/** @type {AST.CSS.Node} */ (child), null, {
ComplexSelector(node, context) {
node.metadata.used = true;
context.next();
}
});
}
}
context.next();
},
Rule(node, context) {
node.metadata.parent_rule = context.state.rule;
// We gotta allow :global x, :global y because CSS preprocessors might generate that from :global { x, y {...} }
for (const complex_selector of node.prelude.children) {
let is_global_block = false;
for (let selector_idx = 0; selector_idx < complex_selector.children.length; selector_idx++) {
const child = complex_selector.children[selector_idx];
const idx = child.selectors.findIndex(is_global_block_selector);
if (is_global_block) {
// All selectors after :global are unscoped
child.metadata.is_global_like = true;
}
if (idx === 0) {
if (
child.selectors.length > 1 &&
selector_idx === 0 &&
node.metadata.parent_rule === null
) {
e.css_global_block_invalid_modifier_start(child.selectors[1]);
} else {
// `child` starts with `:global`
node.metadata.is_global_block = is_global_block = true;
for (let i = 1; i < child.selectors.length; i++) {
walk(/** @type {AST.CSS.Node} */ (child.selectors[i]), null, {
ComplexSelector(node) {
node.metadata.used = true;
}
});
}
if (child.combinator && child.combinator.name !== ' ') {
e.css_global_block_invalid_combinator(child, child.combinator.name);
}
const declaration = node.block.children.find((child) => child.type === 'Declaration');
const is_lone_global =
complex_selector.children.length === 1 &&
complex_selector.children[0].selectors.length === 1; // just `:global`, not e.g. `:global x`
if (is_lone_global && node.prelude.children.length > 1) {
// `:global, :global x { z { ... } }` would become `x { z { ... } }` which means `z` is always
// constrained by `x`, which is not what the user intended
e.css_global_block_invalid_list(node.prelude);
}
if (
declaration &&
// :global { color: red; } is invalid, but foo :global { color: red; } is valid
node.prelude.children.length === 1 &&
is_lone_global
) {
e.css_global_block_invalid_declaration(declaration);
}
}
} else if (idx !== -1) {
e.css_global_block_invalid_modifier(child.selectors[idx]);
}
}
if (node.metadata.is_global_block && !is_global_block) {
e.css_global_block_invalid_list(node.prelude);
}
}
const state = { ...context.state, rule: node };
// visit selector list first, to populate child selector metadata
context.visit(node.prelude, state);
for (const selector of node.prelude.children) {
node.metadata.has_global_selectors ||= selector.metadata.is_global;
node.metadata.has_local_selectors ||= !selector.metadata.is_global;
}
// if this rule has a ComplexSelector whose RelativeSelector children are all
// `:global(...)`, and the rule contains declarations (rather than just
// nested rules) then the component as a whole includes global CSS
context.state.analysis.css.has_global ||=
node.metadata.has_global_selectors &&
node.block.children.filter((child) => child.type === 'Declaration').length > 0 &&
is_unscoped(context.path);
// visit block list, so parent rule metadata is populated
context.visit(node.block, state);
},
NestingSelector(node, context) {
const rule = /** @type {AST.CSS.Rule} */ (context.state.rule);
const parent_rule = rule.metadata.parent_rule;
if (!parent_rule) {
// https://developer.mozilla.org/en-US/docs/Web/CSS/Nesting_selector#using_outside_nested_rule
const children = rule.prelude.children;
const selectors = children[0].children[0].selectors;
if (
children.length > 1 ||
selectors.length > 1 ||
selectors[0].type !== 'PseudoClassSelector' ||
selectors[0].name !== 'global' ||
selectors[0].args?.children[0]?.children[0].selectors[0] !== node
) {
e.css_nesting_selector_invalid_placement(node);
}
} else if (
// :global { &.foo { ... } } is invalid
parent_rule.metadata.is_global_block &&
!parent_rule.metadata.parent_rule &&
parent_rule.prelude.children[0].children.length === 1 &&
parent_rule.prelude.children[0].children[0].selectors.length === 1
) {
e.css_global_block_invalid_modifier_start(node);
}
context.next();
}
};
/**
* @param {AST.CSS.StyleSheet} stylesheet
* @param {ComponentAnalysis} analysis
*/
export function analyze_css(stylesheet, analysis) {
/** @type {CssState} */
const css_state = {
keyframes: analysis.css.keyframes,
rule: null,
analysis
};
walk(stylesheet, css_state, css_visitors);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
/** @import { Visitors } from 'zimmerframe' */
/** @import { AST } from '#compiler' */
import { walk } from 'zimmerframe';
import * as w from '../../../warnings.js';
import { is_keyframes_node } from '../../css.js';
/**
* @param {AST.CSS.StyleSheet} stylesheet
*/
export function warn_unused(stylesheet) {
walk(stylesheet, { stylesheet }, visitors);
}
/** @type {Visitors<AST.CSS.Node, { stylesheet: AST.CSS.StyleSheet }>} */
const visitors = {
Atrule(node, context) {
if (!is_keyframes_node(node)) {
context.next();
}
},
PseudoClassSelector(node, context) {
if (node.name === 'is' || node.name === 'where') {
context.next();
}
},
ComplexSelector(node, context) {
if (
!node.metadata.used &&
// prevent double-marking of `.unused:is(.unused)`
(context.path.at(-2)?.type !== 'PseudoClassSelector' ||
/** @type {AST.CSS.ComplexSelector} */ (context.path.at(-4))?.metadata.used)
) {
const content = context.state.stylesheet.content;
const text = content.styles.substring(node.start - content.start, node.end - content.start);
w.css_unused_selector(node, text);
}
context.next();
},
Rule(node, context) {
if (node.metadata.is_global_block) {
context.visit(node.prelude);
} else {
context.next();
}
}
};
+177
View File
@@ -0,0 +1,177 @@
/** @import { AST } from '#compiler' */
/** @import { Node } from 'estree' */
const UNKNOWN = {};
/**
* @param {Node} node
* @param {boolean} is_class
* @param {Set<any>} set
* @param {boolean} is_nested
*/
function gather_possible_values(node, is_class, set, is_nested = false) {
if (set.has(UNKNOWN)) {
// no point traversing any further
return;
}
if (node.type === 'Literal') {
set.add(String(node.value));
} else if (node.type === 'ConditionalExpression') {
gather_possible_values(node.consequent, is_class, set, is_nested);
gather_possible_values(node.alternate, is_class, set, is_nested);
} else if (node.type === 'LogicalExpression') {
if (node.operator === '&&') {
// && is a special case, because the only way the left
// hand value can be included is if it's falsy. this is
// a bit of extra work but it's worth it because
// `class={[condition && 'blah']}` is common,
// and we don't want to deopt on `condition`
const left = new Set();
gather_possible_values(node.left, is_class, left, is_nested);
if (left.has(UNKNOWN)) {
// add all non-nullish falsy values, unless this is a `class` attribute that
// will be processed by cslx, in which case falsy values are removed, unless
// they're not inside an array/object (TODO 6.0 remove that last part)
if (!is_class || !is_nested) {
set.add('');
set.add(false);
set.add(NaN);
set.add(0); // -0 and 0n are also falsy, but stringify to '0'
}
} else {
for (const value of left) {
if (!value && value != undefined && (!is_class || !is_nested)) {
set.add(value);
}
}
}
gather_possible_values(node.right, is_class, set, is_nested);
} else {
gather_possible_values(node.left, is_class, set, is_nested);
gather_possible_values(node.right, is_class, set, is_nested);
}
} else if (is_class && node.type === 'ArrayExpression') {
for (const entry of node.elements) {
if (entry) {
gather_possible_values(entry, is_class, set, true);
}
}
} else if (is_class && node.type === 'ObjectExpression') {
for (const property of node.properties) {
if (
property.type === 'Property' &&
!property.computed &&
(property.key.type === 'Identifier' || property.key.type === 'Literal')
) {
set.add(
property.key.type === 'Identifier' ? property.key.name : String(property.key.value)
);
} else {
set.add(UNKNOWN);
}
}
} else {
set.add(UNKNOWN);
}
}
/**
* @param {AST.Text | AST.ExpressionTag} chunk
* @param {boolean} is_class
* @returns {string[] | null}
*/
export function get_possible_values(chunk, is_class) {
const values = new Set();
if (chunk.type === 'Text') {
values.add(chunk.data);
} else {
gather_possible_values(chunk.expression, is_class, values);
}
if (values.has(UNKNOWN)) return null;
return [...values].map((value) => String(value));
}
/**
* Returns all parent rules; root is last
* @param {AST.CSS.Rule | null} rule
*/
export function get_parent_rules(rule) {
const rules = [];
while (rule) {
rules.push(rule);
rule = rule.metadata.parent_rule;
}
return rules;
}
/**
* True if is `:global(...)` or `:global` and no pseudo class that is scoped.
* @param {AST.CSS.RelativeSelector} relative_selector
* @returns {relative_selector is AST.CSS.RelativeSelector & { selectors: [AST.CSS.PseudoClassSelector, ...Array<AST.CSS.PseudoClassSelector | AST.CSS.PseudoElementSelector>] }}
*/
export function is_global(relative_selector) {
const first = relative_selector.selectors[0];
return (
first.type === 'PseudoClassSelector' &&
first.name === 'global' &&
(first.args === null ||
// Only these two selector types keep the whole selector global, because e.g.
// :global(button).x means that the selector is still scoped because of the .x
relative_selector.selectors.every(
(selector) =>
is_unscoped_pseudo_class(selector) || selector.type === 'PseudoElementSelector'
))
);
}
/**
* `true` if is a pseudo class that cannot be or is not scoped
* @param {AST.CSS.SimpleSelector} selector
*/
export function is_unscoped_pseudo_class(selector) {
return (
selector.type === 'PseudoClassSelector' &&
// These make the selector scoped
((selector.name !== 'has' &&
selector.name !== 'is' &&
selector.name !== 'where' &&
// Not is special because we want to scope as specific as possible, but because :not
// inverses the result, we want to leave the unscoped, too. The exception is more than
// one selector in the :not (.e.g :not(.x .y)), then .x and .y should be scoped
(selector.name !== 'not' ||
selector.args === null ||
selector.args.children.every((c) => c.children.length === 1))) ||
// selectors with has/is/where/not can also be global if all their children are global
selector.args === null ||
selector.args.children.every((c) => c.children.every((r) => is_global(r))))
);
}
/**
* True if is `:global(...)` or `:global`, irrespective of whether or not there are any pseudo classes that are scoped.
* Difference to `is_global`: `:global(x):has(y)` is `true` for `is_outer_global` but `false` for `is_global`.
* @param {AST.CSS.RelativeSelector} relative_selector
* @returns {relative_selector is AST.CSS.RelativeSelector & { selectors: [AST.CSS.PseudoClassSelector, ...Array<AST.CSS.PseudoClassSelector | AST.CSS.PseudoElementSelector>] }}
*/
export function is_outer_global(relative_selector) {
const first = relative_selector.selectors[0];
return (
first.type === 'PseudoClassSelector' &&
first.name === 'global' &&
(first.args === null ||
// Only these two selector types can keep the whole selector global, because e.g.
// :global(button).x means that the selector is still scoped because of the .x
relative_selector.selectors.every(
(selector) =>
selector.type === 'PseudoClassSelector' || selector.type === 'PseudoElementSelector'
))
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
/**
* @template T
* @param {Array<[T, T]>} edges
* @returns {Array<T>|undefined}
*/
export default function check_graph_for_cycles(edges) {
/** @type {Map<T, T[]>} */
const graph = edges.reduce((g, edge) => {
const [u, v] = edge;
if (!g.has(u)) g.set(u, []);
if (!g.has(v)) g.set(v, []);
g.get(u).push(v);
return g;
}, new Map());
const visited = new Set();
/** @type {Set<T>} */
const on_stack = new Set();
/** @type {Array<Array<T>>} */
const cycles = [];
/**
* @param {T} v
*/
function visit(v) {
visited.add(v);
on_stack.add(v);
graph.get(v)?.forEach((w) => {
if (!visited.has(w)) {
visit(w);
} else if (on_stack.has(w)) {
cycles.push([...on_stack, w]);
}
});
on_stack.delete(v);
}
graph.forEach((_, v) => {
if (!visited.has(v)) {
visit(v);
}
});
return cycles[0];
}
@@ -0,0 +1,15 @@
/** @import { Context } from '../types' */
/** @import { AST } from '#compiler'; */
import * as e from '../../../errors.js';
/**
* @param {AST.AnimateDirective} node
* @param {Context} context
*/
export function AnimateDirective(node, context) {
context.next({ ...context.state, expression: node.metadata.expression });
if (node.metadata.expression.has_await) {
e.illegal_await_expression(node);
}
}
@@ -0,0 +1,11 @@
/** @import { ArrowFunctionExpression } from 'estree' */
/** @import { Context } from '../types' */
import { visit_function } from './shared/function.js';
/**
* @param {ArrowFunctionExpression} node
* @param {Context} context
*/
export function ArrowFunctionExpression(node, context) {
visit_function(node, context);
}
@@ -0,0 +1,31 @@
/** @import { AssignmentExpression } from 'estree' */
/** @import { Context } from '../types' */
import { extract_identifiers, object } from '../../../utils/ast.js';
import { validate_assignment } from './shared/utils.js';
/**
* @param {AssignmentExpression} node
* @param {Context} context
*/
export function AssignmentExpression(node, context) {
validate_assignment(node, node.left, context);
if (context.state.reactive_statement) {
const id = node.left.type === 'MemberExpression' ? object(node.left) : node.left;
if (id !== null) {
for (const id of extract_identifiers(node.left)) {
const binding = context.state.scope.get(id.name);
if (binding) {
context.state.reactive_statement.assignments.add(binding);
}
}
}
}
if (context.state.expression) {
context.state.expression.has_assignment = true;
}
context.next();
}
@@ -0,0 +1,17 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { mark_subtree_dynamic } from './shared/fragment.js';
import * as e from '../../../errors.js';
/**
* @param {AST.AttachTag} node
* @param {Context} context
*/
export function AttachTag(node, context) {
mark_subtree_dynamic(context.path);
context.next({ ...context.state, expression: node.metadata.expression });
if (node.metadata.expression.has_await) {
e.illegal_await_expression(node);
}
}
@@ -0,0 +1,66 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { cannot_be_set_statically, can_delegate_event } from '../../../../utils.js';
import { get_attribute_chunks, is_event_attribute } from '../../../utils/ast.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
* @param {AST.Attribute} node
* @param {Context} context
*/
export function Attribute(node, context) {
context.next();
const parent = /** @type {AST.SvelteNode} */ (context.path.at(-1));
if (parent.type === 'RegularElement') {
// special case <option value="" />
if (node.name === 'value' && parent.name === 'option') {
mark_subtree_dynamic(context.path);
}
}
if (is_event_attribute(node)) {
mark_subtree_dynamic(context.path);
}
if (cannot_be_set_statically(node.name)) {
mark_subtree_dynamic(context.path);
}
// class={[...]} or class={{...}} or `class={x}` need clsx to resolve the classes
if (
node.name === 'class' &&
!Array.isArray(node.value) &&
node.value !== true &&
node.value.expression.type !== 'Literal' &&
node.value.expression.type !== 'TemplateLiteral' &&
node.value.expression.type !== 'BinaryExpression'
) {
mark_subtree_dynamic(context.path);
node.metadata.needs_clsx = true;
}
if (node.value !== true) {
for (const chunk of get_attribute_chunks(node.value)) {
if (chunk.type !== 'ExpressionTag') continue;
if (
chunk.expression.type === 'FunctionExpression' ||
chunk.expression.type === 'ArrowFunctionExpression'
) {
continue;
}
}
if (is_event_attribute(node)) {
const parent = context.path.at(-1);
if (parent?.type === 'RegularElement' || parent?.type === 'SvelteElement') {
context.state.analysis.uses_event_attributes = true;
}
node.metadata.delegated =
parent?.type === 'RegularElement' && can_delegate_event(node.name.slice(2));
}
}
}
@@ -0,0 +1,48 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { validate_block_not_empty, validate_opening_tag } from './shared/utils.js';
import * as e from '../../../errors.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
* @param {AST.AwaitBlock} node
* @param {Context} context
*/
export function AwaitBlock(node, context) {
validate_block_not_empty(node.pending, context);
validate_block_not_empty(node.then, context);
validate_block_not_empty(node.catch, context);
if (context.state.analysis.runes) {
validate_opening_tag(node, context.state, '#');
if (node.value) {
const start = /** @type {number} */ (node.value.start);
const match = context.state.analysis.source
.substring(start - 10, start)
.match(/{(\s*):then\s+$/);
if (match && match[1] !== '') {
e.block_unexpected_character({ start: start - 10, end: start }, ':');
}
}
if (node.error) {
const start = /** @type {number} */ (node.error.start);
const match = context.state.analysis.source
.substring(start - 10, start)
.match(/{(\s*):catch\s+$/);
if (match && match[1] !== '') {
e.block_unexpected_character({ start: start - 10, end: start }, ':');
}
}
}
mark_subtree_dynamic(context.path);
context.visit(node.expression, { ...context.state, expression: node.metadata.expression });
if (node.pending) context.visit(node.pending);
if (node.then) context.visit(node.then);
if (node.catch) context.visit(node.catch);
}
@@ -0,0 +1,150 @@
/** @import { AwaitExpression, Expression, SpreadElement, Property } from 'estree' */
/** @import { Context } from '../types' */
/** @import { AST } from '#compiler' */
import * as e from '../../../errors.js';
/**
* @param {AwaitExpression} node
* @param {Context} context
*/
export function AwaitExpression(node, context) {
const tla = context.state.ast_type === 'instance' && context.state.function_depth === 1;
// preserve context for awaits that precede other expressions in template or `$derived(...)`
if (
is_reactive_expression(
context.path,
context.state.derived_function_depth === context.state.function_depth
) &&
!is_last_evaluated_expression(context.path, node)
) {
context.state.analysis.pickled_awaits.add(node);
}
let suspend = tla;
if (context.state.expression) {
context.state.expression.has_await = true;
suspend = true;
}
// disallow top-level `await` or `await` in template expressions
// unless a) in runes mode and b) opted into `experimental.async`
if (suspend) {
if (!context.state.options.experimental.async) {
e.experimental_async(node);
}
if (!context.state.analysis.runes) {
e.legacy_await_invalid(node);
}
}
context.next();
}
/**
* @param {AST.SvelteNode[]} path
* @param {boolean} in_derived
*/
export function is_reactive_expression(path, in_derived) {
if (in_derived) return true;
let i = path.length;
while (i--) {
const parent = path[i];
if (
parent.type === 'ArrowFunctionExpression' ||
parent.type === 'FunctionExpression' ||
parent.type === 'FunctionDeclaration'
) {
// No reactive expression found between function and await
return false;
}
// @ts-expect-error we could probably use a neater/more robust mechanism
if (parent.metadata) {
return true;
}
}
return false;
}
/**
* @param {AST.SvelteNode[]} path
* @param {Expression | SpreadElement | Property} node
*/
function is_last_evaluated_expression(path, node) {
let i = path.length;
while (i--) {
const parent = path[i];
if (parent.type === 'ConstTag') {
// {@const ...} tags are treated as deriveds and its contents should all get the preserve-reactivity treatment
return false;
}
// @ts-expect-error we could probably use a neater/more robust mechanism
if (parent.metadata) {
return true;
}
switch (parent.type) {
case 'ArrayExpression':
if (node !== parent.elements.at(-1)) return false;
break;
case 'AssignmentExpression':
case 'BinaryExpression':
case 'LogicalExpression':
if (node === parent.left) return false;
break;
case 'CallExpression':
case 'NewExpression':
if (node !== parent.arguments.at(-1)) return false;
break;
case 'ConditionalExpression':
if (node === parent.test) return false;
break;
case 'MemberExpression':
if (parent.computed && node === parent.object) return false;
break;
case 'ObjectExpression':
if (node !== parent.properties.at(-1)) return false;
break;
case 'Property':
if (node === parent.key) return false;
break;
case 'SequenceExpression':
if (node !== parent.expressions.at(-1)) return false;
break;
case 'TaggedTemplateExpression':
if (node !== parent.quasi.expressions.at(-1)) return false;
break;
case 'TemplateLiteral':
if (node !== parent.expressions.at(-1)) return false;
break;
case 'VariableDeclarator':
return true;
default:
return false;
}
node = parent;
}
}
@@ -0,0 +1,280 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import {
extract_all_identifiers_from_expression,
is_text_attribute,
object
} from '../../../utils/ast.js';
import { validate_assignment } from './shared/utils.js';
import * as e from '../../../errors.js';
import * as w from '../../../warnings.js';
import { binding_properties } from '../../bindings.js';
import fuzzymatch from '../../1-parse/utils/fuzzymatch.js';
import { is_content_editable_binding, is_svg } from '../../../../utils.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
* @param {AST.BindDirective} node
* @param {Context} context
*/
export function BindDirective(node, context) {
const parent = context.path.at(-1);
if (
parent?.type === 'RegularElement' ||
parent?.type === 'SvelteElement' ||
parent?.type === 'SvelteWindow' ||
parent?.type === 'SvelteDocument' ||
parent?.type === 'SvelteBody'
) {
if (node.name in binding_properties) {
const property = binding_properties[node.name];
if (property.valid_elements && !property.valid_elements.includes(parent.name)) {
e.bind_invalid_target(
node,
node.name,
property.valid_elements.map((valid_element) => `\`<${valid_element}>\``).join(', ')
);
}
if (property.invalid_elements && property.invalid_elements.includes(parent.name)) {
const valid_bindings = Object.entries(binding_properties)
.filter(([_, binding_property]) => {
return (
binding_property.valid_elements?.includes(parent.name) ||
(!binding_property.valid_elements &&
!binding_property.invalid_elements?.includes(parent.name))
);
})
.map(([property_name]) => property_name)
.sort();
e.bind_invalid_name(
node,
node.name,
`Possible bindings for <${parent.name}> are ${valid_bindings.join(', ')}`
);
}
if (parent.name === 'input' && node.name !== 'this') {
const type = /** @type {AST.Attribute | undefined} */ (
parent.attributes.find((a) => a.type === 'Attribute' && a.name === 'type')
);
if (type && !is_text_attribute(type)) {
if (node.name !== 'value' || type.value === true) {
e.attribute_invalid_type(type);
}
} else {
if (node.name === 'checked' && type?.value[0].data !== 'checkbox') {
e.bind_invalid_target(
node,
node.name,
`\`<input type="checkbox">\`${type?.value[0].data === 'radio' ? ` — for \`<input type="radio">\`, use \`bind:group\`` : ''}`
);
}
if (node.name === 'files' && type?.value[0].data !== 'file') {
e.bind_invalid_target(node, node.name, '`<input type="file">`');
}
}
}
if (parent.name === 'select' && node.name !== 'this') {
const multiple = parent.attributes.find(
(a) =>
a.type === 'Attribute' &&
a.name === 'multiple' &&
!is_text_attribute(a) &&
a.value !== true
);
if (multiple) {
e.attribute_invalid_multiple(multiple);
}
}
if (node.name === 'offsetWidth' && is_svg(parent.name)) {
e.bind_invalid_target(
node,
node.name,
`non-\`<svg>\` elements. Use \`bind:clientWidth\` for \`<svg>\` instead`
);
}
if (is_content_editable_binding(node.name)) {
const contenteditable = /** @type {AST.Attribute} */ (
parent.attributes.find((a) => a.type === 'Attribute' && a.name === 'contenteditable')
);
if (!contenteditable) {
e.attribute_contenteditable_missing(node);
} else if (!is_text_attribute(contenteditable) && contenteditable.value !== true) {
e.attribute_contenteditable_dynamic(contenteditable);
}
}
} else {
const match = fuzzymatch(node.name, Object.keys(binding_properties));
if (match) {
const property = binding_properties[match];
if (!property.valid_elements || property.valid_elements.includes(parent.name)) {
e.bind_invalid_name(node, node.name, `Did you mean '${match}'?`);
}
}
e.bind_invalid_name(node, node.name);
}
}
// When dealing with bind getters/setters skip the specific binding validation
// Group bindings aren't supported for getter/setters so we don't need to handle
// the metadata
if (node.expression.type === 'SequenceExpression') {
if (node.name === 'group') {
e.bind_group_invalid_expression(node);
}
let i = /** @type {number} */ (node.expression.start);
let leading_comments_start = /**@type {any}*/ (node.expression.leadingComments?.at(0))?.start;
let leading_comments_end = /**@type {any}*/ (node.expression.leadingComments?.at(-1))?.end;
while (context.state.analysis.source[--i] !== '{') {
if (
context.state.analysis.source[i] === '(' &&
// if the parenthesis is in a leading comment we don't need to throw the error
!(
leading_comments_start &&
leading_comments_end &&
i <= leading_comments_end &&
i >= leading_comments_start
)
) {
e.bind_invalid_parens(node, node.name);
}
}
if (node.expression.expressions.length !== 2) {
e.bind_invalid_expression(node);
}
mark_subtree_dynamic(context.path);
const [get, set] = node.expression.expressions;
// We gotta jump across the getter/setter functions to avoid the expression metadata field being reset to null
// as we want to collect the functions' blocker/async info
context.visit(get.type === 'ArrowFunctionExpression' ? get.body : get, {
...context.state,
expression: node.metadata.expression
});
context.visit(set.type === 'ArrowFunctionExpression' ? set.body : set, {
...context.state,
expression: node.metadata.expression
});
if (node.metadata.expression.has_await) {
e.illegal_await_expression(node);
}
return;
}
validate_assignment(node, node.expression, context);
const assignee = node.expression;
const left = object(assignee);
if (left === null) {
e.bind_invalid_expression(node);
}
const binding = context.state.scope.get(left.name);
node.metadata.binding = binding;
if (assignee.type === 'Identifier') {
// reassignment
if (
node.name !== 'this' && // bind:this also works for regular variables
(!binding ||
(binding.kind !== 'state' &&
binding.kind !== 'raw_state' &&
binding.kind !== 'prop' &&
binding.kind !== 'bindable_prop' &&
binding.kind !== 'each' &&
binding.kind !== 'store_sub' &&
!binding.updated)) // TODO wut?
) {
e.bind_invalid_value(node.expression);
}
}
if (node.name === 'group') {
if (!binding) {
throw new Error('Cannot find declaration for bind:group');
}
if (binding.kind === 'snippet') {
e.bind_group_invalid_snippet_parameter(node);
}
// Traverse the path upwards and find all EachBlocks who are (indirectly) contributing to bind:group,
// i.e. one of their declarations is referenced in the binding. This allows group bindings to work
// correctly when referencing a variable declared in an EachBlock by using the index of the each block
// entries as keys.
const each_blocks = [];
const [keypath, expression_ids] = extract_all_identifiers_from_expression(node.expression);
let ids = expression_ids;
let i = context.path.length;
while (i--) {
const parent = context.path[i];
if (parent.type === 'EachBlock') {
const references = ids.filter((id) => parent.metadata.declarations.has(id.name));
if (references.length > 0) {
parent.metadata.contains_group_binding = true;
each_blocks.push(parent);
ids = ids.filter((id) => !references.includes(id));
ids.push(...extract_all_identifiers_from_expression(parent.expression)[1]);
}
}
}
// The identifiers that make up the binding expression form they key for the binding group.
// If the same identifiers in the same order are used in another bind:group, they will be in the same group.
// (there's an edge case where `bind:group={a[i]}` will be in a different group than `bind:group={a[j]}` even when i == j,
// but this is a limitation of the current static analysis we do; it also never worked in Svelte 4)
const bindings = expression_ids.map((id) => context.state.scope.get(id.name));
let group_name;
outer: for (const [[key, b], group] of context.state.analysis.binding_groups) {
if (b.length !== bindings.length || key !== keypath) continue;
for (let i = 0; i < bindings.length; i++) {
if (bindings[i] !== b[i]) continue outer;
}
group_name = group;
}
if (!group_name) {
group_name = context.state.scope.root.unique('binding_group');
context.state.analysis.binding_groups.set([keypath, bindings], group_name);
}
node.metadata = {
binding_group_name: group_name,
parent_each_blocks: each_blocks,
expression: node.metadata.expression
};
}
if (binding?.kind === 'each' && binding.metadata?.inside_rest) {
w.bind_invalid_each_rest(binding.node, binding.node.name);
}
context.next({ ...context.state, expression: node.metadata.expression });
if (node.metadata.expression.has_await) {
e.illegal_await_expression(node);
}
}
@@ -0,0 +1,339 @@
/** @import { ArrowFunctionExpression, CallExpression, Expression, FunctionDeclaration, FunctionExpression, Identifier, VariableDeclarator } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { get_rune } from '../../scope.js';
import * as e from '../../../errors.js';
import { get_parent } from '../../../utils/ast.js';
import { is_pure, is_safe_identifier } from './shared/utils.js';
import { dev, locate_node, source } from '../../../state.js';
import * as b from '#compiler/builders';
import { ExpressionMetadata } from '../../nodes.js';
/**
* @param {CallExpression} node
* @param {Context} context
*/
export function CallExpression(node, context) {
const parent = /** @type {AST.SvelteNode} */ (get_parent(context.path, -1));
const rune = get_rune(node, context.state.scope);
if (rune && rune !== '$inspect') {
for (const arg of node.arguments) {
if (arg.type === 'SpreadElement') {
e.rune_invalid_spread(node, rune);
}
}
}
switch (rune) {
case null:
if (!is_safe_identifier(node.callee, context.state.scope)) {
context.state.analysis.needs_context = true;
}
break;
case '$bindable':
if (node.arguments.length > 1) {
e.rune_invalid_arguments_length(node, '$bindable', 'zero or one arguments');
}
if (
parent.type !== 'AssignmentPattern' ||
context.path.at(-3)?.type !== 'ObjectPattern' ||
context.path.at(-4)?.type !== 'VariableDeclarator' ||
get_rune(
/** @type {VariableDeclarator} */ (context.path.at(-4)).init,
context.state.scope
) !== '$props'
) {
e.bindable_invalid_location(node);
}
// We need context in case the bound prop is stale
context.state.analysis.needs_context = true;
break;
case '$host':
if (node.arguments.length > 0) {
e.rune_invalid_arguments(node, '$host');
} else if (context.state.ast_type === 'module' || !context.state.analysis.custom_element) {
e.host_invalid_placement(node);
}
break;
case '$props':
if (context.state.has_props_rune) {
e.props_duplicate(node, rune);
}
context.state.has_props_rune = true;
if (
parent.type !== 'VariableDeclarator' ||
context.state.ast_type !== 'instance' ||
context.state.scope !== context.state.analysis.instance.scope
) {
e.props_invalid_placement(node);
}
if (node.arguments.length > 0) {
e.rune_invalid_arguments(node, rune);
}
break;
case '$props.id': {
const grand_parent = get_parent(context.path, -2);
if (context.state.analysis.props_id) {
e.props_duplicate(node, rune);
}
if (
parent.type !== 'VariableDeclarator' ||
parent.id.type !== 'Identifier' ||
context.state.ast_type !== 'instance' ||
context.state.scope !== context.state.analysis.instance.scope ||
grand_parent.type !== 'VariableDeclaration'
) {
e.props_id_invalid_placement(node);
}
if (node.arguments.length > 0) {
e.rune_invalid_arguments(node, rune);
}
context.state.analysis.props_id = parent.id;
break;
}
case '$state':
case '$state.raw':
case '$derived':
case '$derived.by': {
const valid =
is_variable_declaration(parent, context) ||
is_class_property_definition(parent) ||
is_class_property_assignment_at_constructor_root(parent, context);
if (!valid) {
e.state_invalid_placement(node, rune);
}
if ((rune === '$derived' || rune === '$derived.by') && node.arguments.length !== 1) {
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
} else if (node.arguments.length > 1) {
e.rune_invalid_arguments_length(node, rune, 'zero or one arguments');
}
break;
}
case '$effect':
case '$effect.pre':
if (parent.type !== 'ExpressionStatement') {
e.effect_invalid_placement(node);
}
if (node.arguments.length !== 1) {
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
}
// `$effect` needs context because Svelte needs to know whether it should re-run
// effects that invalidate themselves, and that's determined by whether we're in runes mode
context.state.analysis.needs_context = true;
break;
case '$effect.tracking':
if (node.arguments.length !== 0) {
e.rune_invalid_arguments(node, rune);
}
break;
case '$effect.root':
if (node.arguments.length !== 1) {
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
}
break;
case '$effect.pending':
if (context.state.expression) {
context.state.expression.has_state = true;
}
break;
case '$inspect':
if (node.arguments.length < 1) {
e.rune_invalid_arguments_length(node, rune, 'one or more arguments');
}
break;
case '$inspect().with':
if (node.arguments.length !== 1) {
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
}
break;
case '$inspect.trace': {
if (node.arguments.length > 1) {
e.rune_invalid_arguments_length(node, rune, 'zero or one arguments');
}
const grand_parent = context.path.at(-2);
const fn = context.path.at(-3);
if (
parent.type !== 'ExpressionStatement' ||
grand_parent?.type !== 'BlockStatement' ||
!(
fn?.type === 'FunctionDeclaration' ||
fn?.type === 'FunctionExpression' ||
fn?.type === 'ArrowFunctionExpression'
) ||
grand_parent.body[0] !== parent
) {
e.inspect_trace_invalid_placement(node);
}
if (fn.generator) {
e.inspect_trace_generator(node);
}
if (dev) {
if (node.arguments[0]) {
context.state.scope.tracing = b.thunk(/** @type {Expression} */ (node.arguments[0]));
} else {
const label = get_function_label(context.path.slice(0, -2)) ?? 'trace';
const loc = `(${locate_node(fn)})`;
context.state.scope.tracing = b.thunk(b.literal(label + ' ' + loc));
}
context.state.analysis.tracing = true;
}
break;
}
case '$state.eager':
if (node.arguments.length !== 1) {
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
}
break;
case '$state.snapshot':
if (node.arguments.length !== 1) {
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
}
break;
}
// `$inspect(foo)` or `$derived(foo) should not trigger the `static-state-reference` warning
if (rune === '$derived') {
const expression = new ExpressionMetadata();
context.next({
...context.state,
function_depth: context.state.function_depth + 1,
derived_function_depth: context.state.function_depth + 1,
expression
});
if (expression.has_await) {
context.state.analysis.async_deriveds.add(node);
}
} else if (rune === '$inspect') {
context.next({ ...context.state, function_depth: context.state.function_depth + 1 });
} else {
context.next();
}
if (context.state.expression) {
// TODO We assume that any dependencies are stateful, which isn't necessarily the case — see
// https://github.com/sveltejs/svelte/issues/13266. This check also includes dependencies
// outside the call expression itself (e.g. `{blah && pure()}`) resulting in additional
// false positives, but for now we accept that trade-off
if (!is_pure(node.callee, context) || context.state.expression.dependencies.size > 0) {
context.state.expression.has_call = true;
context.state.expression.has_state = true;
}
}
}
/**
* @param {AST.SvelteNode[]} nodes
*/
function get_function_label(nodes) {
const fn = /** @type {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression} */ (
nodes.at(-1)
);
if ((fn.type === 'FunctionDeclaration' || fn.type === 'FunctionExpression') && fn.id != null) {
return fn.id.name;
}
const parent = nodes.at(-2);
if (!parent) return;
if (parent.type === 'CallExpression') {
return source.slice(parent.callee.start, parent.callee.end) + '(...)';
}
if (parent.type === 'Property' && !parent.computed) {
return /** @type {Identifier} */ (parent.key).name;
}
if (parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier') {
return parent.id.name;
}
}
/**
* @param {AST.SvelteNode} parent
* @param {Context} context
*/
function is_variable_declaration(parent, context) {
return parent.type === 'VariableDeclarator' && get_parent(context.path, -3).type !== 'ConstTag';
}
/**
* @param {AST.SvelteNode} parent
*/
function is_class_property_definition(parent) {
return parent.type === 'PropertyDefinition' && !parent.static && !parent.computed;
}
/**
* @param {AST.SvelteNode} node
* @param {Context} context
*/
function is_class_property_assignment_at_constructor_root(node, context) {
if (
node.type === 'AssignmentExpression' &&
node.operator === '=' &&
node.left.type === 'MemberExpression' &&
node.left.object.type === 'ThisExpression' &&
((node.left.property.type === 'Identifier' && !node.left.computed) ||
node.left.property.type === 'PrivateIdentifier' ||
node.left.property.type === 'Literal')
) {
// MethodDefinition (-5) -> FunctionExpression (-4) -> BlockStatement (-3) -> ExpressionStatement (-2) -> AssignmentExpression (-1)
const parent = get_parent(context.path, -5);
return parent?.type === 'MethodDefinition' && parent.kind === 'constructor';
}
return false;
}
@@ -0,0 +1,156 @@
/** @import { AssignmentExpression, CallExpression, ClassBody, PropertyDefinition, Expression, PrivateIdentifier, MethodDefinition } from 'estree' */
/** @import { StateField } from '#compiler' */
/** @import { Context } from '../types' */
import * as b from '#compiler/builders';
import { get_rune } from '../../scope.js';
import * as e from '../../../errors.js';
import { is_state_creation_rune } from '../../../../utils.js';
import { get_name } from '../../nodes.js';
import { regex_invalid_identifier_chars } from '../../patterns.js';
/**
* @param {ClassBody} node
* @param {Context} context
*/
export function ClassBody(node, context) {
if (!context.state.analysis.runes) {
context.next();
return;
}
/** @type {string[]} */
const private_ids = [];
for (const prop of node.body) {
if (
(prop.type === 'MethodDefinition' || prop.type === 'PropertyDefinition') &&
prop.key.type === 'PrivateIdentifier'
) {
private_ids.push(prop.key.name);
}
}
/** @type {Map<string, StateField>} */
const state_fields = new Map();
/** @type {Map<string, Array<MethodDefinition['kind'] | 'prop' | 'assigned_prop'>>} */
const fields = new Map();
context.state.analysis.classes.set(node, state_fields);
/** @type {MethodDefinition | null} */
let constructor = null;
/**
* @param {PropertyDefinition | AssignmentExpression} node
* @param {Expression | PrivateIdentifier} key
* @param {Expression | null | undefined} value
*/
function handle(node, key, value) {
const name = get_name(key);
if (name === null) return;
const rune = get_rune(value, context.state.scope);
if (rune && is_state_creation_rune(rune)) {
if (state_fields.has(name)) {
e.state_field_duplicate(node, name);
}
const _key = (node.type === 'AssignmentExpression' || !node.static ? '' : '@') + name;
const field = fields.get(_key);
// if there's already a method or assigned field, error
if (field && !(field.length === 1 && field[0] === 'prop')) {
e.duplicate_class_field(node, _key);
}
state_fields.set(name, {
node,
type: rune,
// @ts-expect-error for public state this is filled out in a moment
key: key.type === 'PrivateIdentifier' ? key : null,
value: /** @type {CallExpression} */ (value)
});
}
}
for (const child of node.body) {
if (child.type === 'PropertyDefinition' && !child.computed && !child.static) {
handle(child, child.key, child.value);
const key = /** @type {string} */ (get_name(child.key));
const field = fields.get(key);
if (!field) {
fields.set(key, [child.value ? 'assigned_prop' : 'prop']);
continue;
}
e.duplicate_class_field(child, key);
}
if (child.type === 'MethodDefinition') {
if (child.kind === 'constructor') {
constructor = child;
} else if (!child.computed) {
const key = (child.static ? '@' : '') + get_name(child.key);
const field = fields.get(key);
if (!field) {
fields.set(key, [child.kind]);
continue;
}
if (
field.includes(child.kind) ||
field.includes('prop') ||
field.includes('assigned_prop')
) {
e.duplicate_class_field(child, key);
}
if (child.kind === 'get') {
if (field.length === 1 && field[0] === 'set') {
field.push('get');
continue;
}
} else if (child.kind === 'set') {
if (field.length === 1 && field[0] === 'get') {
field.push('set');
continue;
}
} else {
field.push(child.kind);
continue;
}
e.duplicate_class_field(child, key);
}
}
}
if (constructor) {
for (const statement of constructor.value.body.body) {
if (statement.type !== 'ExpressionStatement') continue;
if (statement.expression.type !== 'AssignmentExpression') continue;
const { left, right } = statement.expression;
if (left.type !== 'MemberExpression') continue;
if (left.object.type !== 'ThisExpression') continue;
if (left.computed && left.property.type !== 'Literal') continue;
handle(statement.expression, left.property, right);
}
}
for (const [name, field] of state_fields) {
if (name[0] === '#') {
continue;
}
let deconflicted = name.replace(regex_invalid_identifier_chars, '_');
while (private_ids.includes(deconflicted)) {
deconflicted = '_' + deconflicted;
}
private_ids.push(deconflicted);
field.key = b.private_id(deconflicted);
}
context.next({ ...context.state, state_fields });
}
@@ -0,0 +1,25 @@
/** @import { ClassDeclaration } from 'estree' */
/** @import { Context } from '../types' */
import * as w from '../../../warnings.js';
import { validate_identifier_name } from './shared/utils.js';
/**
* @param {ClassDeclaration} node
* @param {Context} context
*/
export function ClassDeclaration(node, context) {
if (context.state.analysis.runes && node.id !== null) {
validate_identifier_name(context.state.scope.get(node.id.name));
}
// In modules, we allow top-level module scope only, in components, we allow the component scope,
// which is function_depth of 1. With the exception of `new class` which is also not allowed at
// component scope level either.
const allowed_depth = context.state.ast_type === 'module' ? 0 : 1;
if (context.state.scope.function_depth > allowed_depth) {
w.perf_avoid_nested_class(node);
}
context.next();
}
@@ -0,0 +1,13 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
* @param {AST.ClassDirective} node
* @param {Context} context
*/
export function ClassDirective(node, context) {
mark_subtree_dynamic(context.path);
context.next({ ...context.state, expression: node.metadata.expression });
}
@@ -0,0 +1,26 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { visit_component } from './shared/component.js';
/**
* @param {AST.Component} node
* @param {Context} context
*/
export function Component(node, context) {
const binding = context.state.scope.get(
node.name.includes('.') ? node.name.slice(0, node.name.indexOf('.')) : node.name
);
node.metadata.dynamic =
context.state.analysis.runes && // Svelte 4 required you to use svelte:component to switch components
binding !== null &&
(binding.kind !== 'normal' || node.name.includes('.'));
if (binding) {
node.metadata.expression.has_state = node.metadata.dynamic;
node.metadata.expression.dependencies.add(binding);
node.metadata.expression.references.add(binding);
}
visit_component(node, context);
}
@@ -0,0 +1,71 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import * as b from '#compiler/builders';
import { validate_opening_tag } from './shared/utils.js';
/**
* @param {AST.ConstTag} node
* @param {Context} context
*/
export function ConstTag(node, context) {
if (context.state.analysis.runes) {
validate_opening_tag(node, context.state, '@');
}
const parent = context.path.at(-1);
const grand_parent = context.path.at(-2);
if (
parent?.type !== 'Fragment' ||
(grand_parent?.type !== 'IfBlock' &&
grand_parent?.type !== 'SvelteFragment' &&
grand_parent?.type !== 'Component' &&
grand_parent?.type !== 'SvelteComponent' &&
grand_parent?.type !== 'EachBlock' &&
grand_parent?.type !== 'AwaitBlock' &&
grand_parent?.type !== 'SnippetBlock' &&
grand_parent?.type !== 'SvelteBoundary' &&
grand_parent?.type !== 'KeyBlock' &&
((grand_parent?.type !== 'RegularElement' && grand_parent?.type !== 'SvelteElement') ||
!grand_parent.attributes.some((a) => a.type === 'Attribute' && a.name === 'slot')))
) {
e.const_tag_invalid_placement(node);
}
const declaration = node.declaration.declarations[0];
context.visit(declaration.id);
context.visit(declaration.init, {
...context.state,
expression: node.metadata.expression,
// We're treating this like a $derived under the hood
function_depth: context.state.function_depth + 1,
derived_function_depth: context.state.function_depth + 1
});
const has_await = node.metadata.expression.has_await;
const blockers = [...node.metadata.expression.dependencies]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id);
if (has_await || context.state.async_consts || blockers.length > 0) {
const run = (context.state.async_consts ??= {
id: context.state.analysis.root.unique('promises'),
declaration_count: 0
});
node.metadata.promises_id = run.id;
const bindings = context.state.scope.get_bindings(declaration);
// keep the counter in sync with the number of thunks pushed in ConstTag in transform
// TODO 6.0 once non-async and non-runes mode is gone investigate making this more robust
// via something like the approach in https://github.com/sveltejs/svelte/pull/18032
const length = run.declaration_count + (blockers.length > 0 ? 1 : 0);
run.declaration_count += blockers.length > 0 ? 2 : 1;
const blocker = b.member(run.id, b.literal(length), true);
for (const binding of bindings) {
binding.blocker = blocker;
}
}
}
@@ -0,0 +1,15 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { validate_opening_tag } from './shared/utils.js';
/**
* @param {AST.DebugTag} node
* @param {Context} context
*/
export function DebugTag(node, context) {
if (context.state.analysis.runes) {
validate_opening_tag(node, context.state, '@');
}
context.next();
}
@@ -0,0 +1,97 @@
/** @import { Expression } from 'estree' */
/** @import { AST, Binding } from '#compiler' */
/** @import { Context } from '../types' */
/** @import { Scope } from '../../scope' */
import * as e from '../../../errors.js';
import { extract_identifiers } from '../../../utils/ast.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
import { validate_block_not_empty, validate_opening_tag } from './shared/utils.js';
/**
* @param {AST.EachBlock} node
* @param {Context} context
*/
export function EachBlock(node, context) {
validate_opening_tag(node, context.state, '#');
validate_block_not_empty(node.body, context);
validate_block_not_empty(node.fallback, context);
const id = node.context;
if (id?.type === 'Identifier' && (id.name === '$state' || id.name === '$derived')) {
// TODO weird that this is necessary
e.state_invalid_placement(node, id.name);
}
if (node.key) {
// treat `{#each items as item, i (i)}` as a normal indexed block, everything else as keyed
node.metadata.keyed =
node.key.type !== 'Identifier' || !node.index || node.key.name !== node.index;
}
if (node.metadata.keyed && !node.context) {
e.each_key_without_as(/** @type {Expression} */ (node.key));
}
// evaluate expression in parent scope
context.visit(node.expression, {
...context.state,
expression: node.metadata.expression,
scope: /** @type {Scope} */ (context.state.scope.parent)
});
context.visit(node.body);
if (node.key) context.visit(node.key);
if (node.fallback) context.visit(node.fallback);
if (!context.state.analysis.runes) {
let mutated =
!!node.context &&
extract_identifiers(node.context).some((id) => {
const binding = context.state.scope.get(id.name);
return !!binding?.mutated;
});
// collect transitive dependencies...
for (const binding of node.metadata.expression.dependencies) {
if (binding.declaration_kind !== 'function') {
collect_transitive_dependencies(binding, node.metadata.transitive_deps);
}
}
// ...and ensure they are marked as state, so they can be turned
// into mutable sources and invalidated
if (mutated) {
for (const binding of node.metadata.transitive_deps) {
if (
binding.kind === 'normal' &&
(binding.declaration_kind === 'const' ||
binding.declaration_kind === 'let' ||
binding.declaration_kind === 'var')
) {
binding.kind = 'state';
}
}
}
}
mark_subtree_dynamic(context.path);
}
/**
* @param {Binding} binding
* @param {Set<Binding>} bindings
* @returns {void}
*/
function collect_transitive_dependencies(binding, bindings) {
if (bindings.has(binding)) {
return;
}
bindings.add(binding);
if (binding.kind === 'legacy_reactive') {
for (const dep of binding.legacy_dependencies) {
collect_transitive_dependencies(dep, bindings);
}
}
}
@@ -0,0 +1,20 @@
/** @import { ExportDefaultDeclaration } from 'estree' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import { validate_export } from './shared/utils.js';
/**
* @param {ExportDefaultDeclaration} node
* @param {Context} context
*/
export function ExportDefaultDeclaration(node, context) {
if (!context.state.ast_type /* .svelte.js module */) {
if (node.declaration.type === 'Identifier') {
validate_export(node, context.state.scope, node.declaration.name);
}
} else {
e.module_illegal_default_export(node);
}
context.next();
}
@@ -0,0 +1,70 @@
/** @import { ExportNamedDeclaration, Identifier } from 'estree' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import { extract_identifiers } from '../../../utils/ast.js';
/**
* @param {ExportNamedDeclaration} node
* @param {Context} context
*/
export function ExportNamedDeclaration(node, context) {
// visit children, so bindings are correctly initialised
context.next();
if (
context.state.ast_type &&
node.specifiers.some((specifier) =>
specifier.exported.type === 'Identifier'
? specifier.exported.name === 'default'
: specifier.exported.value === 'default'
)
) {
e.module_illegal_default_export(node);
}
if (node.declaration?.type === 'VariableDeclaration') {
// in runes mode, forbid `export let`
if (
context.state.analysis.runes &&
context.state.ast_type === 'instance' &&
node.declaration.kind === 'let'
) {
e.legacy_export_invalid(node);
}
for (const declarator of node.declaration.declarations) {
for (const id of extract_identifiers(declarator.id)) {
const binding = context.state.scope.get(id.name);
if (!binding) continue;
if (binding.kind === 'derived') {
e.derived_invalid_export(node);
}
if ((binding.kind === 'state' || binding.kind === 'raw_state') && binding.reassigned) {
e.state_invalid_export(node);
}
}
}
}
if (context.state.analysis.runes) {
if (node.declaration && context.state.ast_type === 'instance') {
if (
node.declaration.type === 'FunctionDeclaration' ||
node.declaration.type === 'ClassDeclaration'
) {
context.state.analysis.exports.push({
name: /** @type {Identifier} */ (node.declaration.id).name,
alias: null
});
} else if (node.declaration.kind === 'const') {
for (const declarator of node.declaration.declarations) {
for (const node of extract_identifiers(declarator.id)) {
context.state.analysis.exports.push({ name: node.name, alias: null });
}
}
}
}
}
}
@@ -0,0 +1,30 @@
/** @import { ExportSpecifier } from 'estree' */
/** @import { Context } from '../types' */
import { validate_export } from './shared/utils.js';
/**
* @param {ExportSpecifier} node
* @param {Context} context
*/
export function ExportSpecifier(node, context) {
const local_name =
node.local.type === 'Identifier' ? node.local.name : /** @type {string} */ (node.local.value);
const exported_name =
node.exported.type === 'Identifier'
? node.exported.name
: /** @type {string} */ (node.exported.value);
if (context.state.ast_type === 'instance') {
if (context.state.analysis.runes) {
context.state.analysis.exports.push({
name: local_name,
alias: exported_name
});
const binding = context.state.scope.get(local_name);
if (binding) binding.reassigned = true;
}
} else {
validate_export(node, context.state.scope, local_name);
}
}
@@ -0,0 +1,38 @@
/** @import { ExpressionStatement, ImportDeclaration } from 'estree' */
/** @import { Context } from '../types' */
import * as w from '../../../warnings.js';
/**
* @param {ExpressionStatement} node
* @param {Context} context
*/
export function ExpressionStatement(node, context) {
// warn on `new Component({ target: ... })` if imported from a `.svelte` file
if (
node.expression.type === 'NewExpression' &&
node.expression.callee.type === 'Identifier' &&
node.expression.arguments.length === 1 &&
node.expression.arguments[0].type === 'ObjectExpression' &&
node.expression.arguments[0].properties.some(
(p) => p.type === 'Property' && p.key.type === 'Identifier' && p.key.name === 'target'
)
) {
const binding = context.state.scope.get(node.expression.callee.name);
if (binding?.kind === 'normal' && binding.declaration_kind === 'import') {
const declaration = /** @type {ImportDeclaration} */ (binding.initial);
// Theoretically someone could import a class from a `.svelte.js` module, but that's too rare to worry about
if (
/** @type {string} */ (declaration.source.value).endsWith('.svelte') &&
declaration.specifiers.find(
(s) => s.local.name === binding.node.name && s.type === 'ImportDefaultSpecifier'
)
) {
w.legacy_component_creation(node.expression);
}
}
}
context.next();
}
@@ -0,0 +1,26 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { is_tag_valid_with_parent } from '../../../../html-tree-validation.js';
import * as e from '../../../errors.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
* @param {AST.ExpressionTag} node
* @param {Context} context
*/
export function ExpressionTag(node, context) {
const in_template = context.path.at(-1)?.type === 'Fragment';
if (in_template && context.state.parent_element) {
const message = is_tag_valid_with_parent('#text', context.state.parent_element);
if (message) {
e.node_invalid_placement(node, message);
}
}
// TODO ideally we wouldn't do this here, we'd just do it on encountering
// an `Identifier` within the tag. But we currently need to handle `{42}` etc
mark_subtree_dynamic(context.path);
context.next({ ...context.state, expression: node.metadata.expression });
}
@@ -0,0 +1,10 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types.js' */
/**
* @param {AST.Fragment} node
* @param {Context} context
*/
export function Fragment(node, context) {
context.next({ ...context.state, fragment: node, async_consts: undefined });
}
@@ -0,0 +1,16 @@
/** @import { FunctionDeclaration } from 'estree' */
/** @import { Context } from '../types' */
import { visit_function } from './shared/function.js';
import { validate_identifier_name } from './shared/utils.js';
/**
* @param {FunctionDeclaration} node
* @param {Context} context
*/
export function FunctionDeclaration(node, context) {
if (context.state.analysis.runes && node.id !== null) {
validate_identifier_name(context.state.scope.get(node.id.name));
}
visit_function(node, context);
}
@@ -0,0 +1,11 @@
/** @import { FunctionExpression } from 'estree' */
/** @import { Context } from '../types' */
import { visit_function } from './shared/function.js';
/**
* @param {FunctionExpression} node
* @param {Context} context
*/
export function FunctionExpression(node, context) {
visit_function(node, context);
}
@@ -0,0 +1,19 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { mark_subtree_dynamic } from './shared/fragment.js';
import { validate_opening_tag } from './shared/utils.js';
/**
* @param {AST.HtmlTag} node
* @param {Context} context
*/
export function HtmlTag(node, context) {
if (context.state.analysis.runes) {
validate_opening_tag(node, context.state, '@');
}
// unfortunately this is necessary in order to fix invalid HTML
mark_subtree_dynamic(context.path);
context.next({ ...context.state, expression: node.metadata.expression });
}
@@ -0,0 +1,194 @@
/** @import { Expression, Identifier } from 'estree' */
/** @import { Context } from '../types' */
import is_reference from 'is-reference';
import { should_proxy } from '../../3-transform/client/utils.js';
import * as e from '../../../errors.js';
import * as w from '../../../warnings.js';
import { is_rune } from '../../../../utils.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
import { get_rune } from '../../scope.js';
import { is_component_node } from '../../nodes.js';
/**
* @param {Identifier} node
* @param {Context} context
*/
export function Identifier(node, context) {
let i = context.path.length;
let parent = /** @type {Expression} */ (context.path[--i]);
if (!is_reference(node, parent)) {
return;
}
mark_subtree_dynamic(context.path);
// If we are using arguments outside of a function, then throw an error
if (
node.name === 'arguments' &&
!context.path.some((n) => n.type === 'FunctionDeclaration' || n.type === 'FunctionExpression')
) {
e.invalid_arguments_usage(node);
}
// `$$slots` exists even in runes mode
if (node.name === '$$slots') {
context.state.analysis.uses_slots = true;
}
if (context.state.analysis.runes) {
if (
is_rune(node.name) &&
context.state.scope.get(node.name) === null &&
context.state.scope.get(node.name.slice(1))?.kind !== 'store_sub'
) {
/** @type {Expression} */
let current = node;
let name = node.name;
while (parent.type === 'MemberExpression') {
if (parent.computed) e.rune_invalid_computed_property(parent);
name += `.${/** @type {Identifier} */ (parent.property).name}`;
current = parent;
parent = /** @type {Expression} */ (context.path[--i]);
if (!is_rune(name)) {
if (name === '$effect.active') {
e.rune_renamed(parent, '$effect.active', '$effect.tracking');
}
if (name === '$state.frozen') {
e.rune_renamed(parent, '$state.frozen', '$state.raw');
}
if (name === '$state.is') {
e.rune_removed(parent, '$state.is');
}
e.rune_invalid_name(parent, name);
}
}
if (parent.type !== 'CallExpression') {
e.rune_missing_parentheses(current);
}
}
}
let binding = context.state.scope.get(node.name);
if (!context.state.analysis.runes) {
if (node.name === '$$props') {
context.state.analysis.uses_props = true;
}
if (node.name === '$$restProps') {
context.state.analysis.uses_rest_props = true;
}
}
if (binding) {
if (context.state.expression) {
context.state.expression.dependencies.add(binding);
context.state.expression.references.add(binding);
context.state.expression.has_state ||=
binding.kind !== 'static' &&
(binding.kind === 'prop' ||
binding.kind === 'bindable_prop' ||
binding.kind === 'rest_prop' ||
!binding.is_function()) &&
!context.state.scope.evaluate(node).is_known;
}
if (
context.state.analysis.runes &&
node !== binding.node &&
context.state.function_depth === binding.scope.function_depth &&
// If we have $state that can be proxied or frozen and isn't re-assigned, then that means
// it's likely not using a primitive value and thus this warning isn't that helpful.
((binding.kind === 'state' &&
(binding.reassigned ||
(binding.initial?.type === 'CallExpression' &&
binding.initial.arguments.length === 1 &&
binding.initial.arguments[0].type !== 'SpreadElement' &&
!should_proxy(binding.initial.arguments[0], context.state.scope)))) ||
binding.kind === 'raw_state' ||
binding.kind === 'derived' ||
binding.kind === 'prop' ||
binding.kind === 'rest_prop') &&
// We're only concerned with reads here
(parent.type !== 'AssignmentExpression' || parent.left !== node) &&
parent.type !== 'UpdateExpression'
) {
let type = 'closure';
let i = context.path.length;
while (i--) {
const parent = context.path[i];
if (
parent.type === 'ArrowFunctionExpression' ||
parent.type === 'FunctionDeclaration' ||
parent.type === 'FunctionExpression'
) {
break;
}
if (
parent.type === 'CallExpression' &&
parent.arguments.includes(/** @type {any} */ (context.path[i + 1]))
) {
const rune = get_rune(parent, context.state.scope);
if (rune === '$state' || rune === '$state.raw') {
type = 'derived';
break;
}
}
}
w.state_referenced_locally(node, node.name, type);
}
if (
context.state.reactive_statement &&
binding.scope === context.state.analysis.module.scope &&
binding.reassigned
) {
w.reactive_declaration_module_script_dependency(node);
}
if (binding.metadata?.is_template_declaration && context.state.options.experimental.async) {
let snippet_name;
// Find out if this references a {@const ...} declaration of an implicit children snippet
// when it is itself inside a snippet block at the same level. If so, error.
for (let i = context.path.length - 1; i >= 0; i--) {
const parent = context.path[i];
const grand_parent = context.path[i - 1];
if (parent.type === 'SnippetBlock') {
snippet_name = parent.expression.name;
} else if (
snippet_name &&
grand_parent &&
parent.type === 'Fragment' &&
(is_component_node(grand_parent) ||
(grand_parent.type === 'SvelteBoundary' &&
(snippet_name === 'failed' || snippet_name === 'pending')))
) {
if (
is_component_node(grand_parent)
? grand_parent.metadata.scopes.default === binding.scope
: context.state.scopes.get(parent) === binding.scope
) {
e.const_tag_invalid_reference(node, node.name);
} else {
break;
}
}
}
}
}
}
@@ -0,0 +1,46 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { mark_subtree_dynamic } from './shared/fragment.js';
import { validate_block_not_empty, validate_opening_tag } from './shared/utils.js';
/**
* @param {AST.IfBlock} node
* @param {Context} context
*/
export function IfBlock(node, context) {
validate_block_not_empty(node.consequent, context);
validate_block_not_empty(node.alternate, context);
if (context.state.analysis.runes) {
validate_opening_tag(node, context.state, node.elseif ? ':' : '#');
}
mark_subtree_dynamic(context.path);
context.visit(node.test, {
...context.state,
expression: node.metadata.expression
});
context.visit(node.consequent);
if (node.alternate) context.visit(node.alternate);
// Check if we can flatten branches
const alt = node.alternate;
if (alt && alt.nodes.length === 1 && alt.nodes[0].type === 'IfBlock' && alt.nodes[0].elseif) {
const elseif = alt.nodes[0];
// Don't flatten if this else-if has an await expression or new blockers
// TODO would be nice to check the await expression itself to see if it's awaiting the same thing as a previous if expression
if (
!elseif.metadata.expression.has_await &&
!elseif.metadata.expression.has_more_blockers_than(node.metadata.expression)
) {
// Roll the existing flattened branches (if any) into this one, then delete those of the else-if block
// to avoid processing them multiple times as we walk down the chain during code transformation.
node.metadata.flattened = [elseif, ...(elseif.metadata.flattened ?? [])];
elseif.metadata.flattened = undefined;
}
}
}
@@ -0,0 +1,31 @@
/** @import { ImportDeclaration } from 'estree' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
/**
* @param {ImportDeclaration} node
* @param {Context} context
*/
export function ImportDeclaration(node, context) {
if (context.state.analysis.runes) {
const source = /** @type {string} */ (node.source.value);
if (source.startsWith('svelte/internal')) {
e.import_svelte_internal_forbidden(node);
}
if (source === 'svelte') {
for (const specifier of node.specifiers) {
if (specifier.type === 'ImportSpecifier') {
if (
specifier.imported.type === 'Identifier' &&
(specifier.imported.name === 'beforeUpdate' ||
specifier.imported.name === 'afterUpdate')
) {
e.runes_mode_invalid_import(specifier, specifier.imported.name);
}
}
}
}
}
}
@@ -0,0 +1,21 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { mark_subtree_dynamic } from './shared/fragment.js';
import { validate_block_not_empty, validate_opening_tag } from './shared/utils.js';
/**
* @param {AST.KeyBlock} node
* @param {Context} context
*/
export function KeyBlock(node, context) {
validate_block_not_empty(node.fragment, context);
if (context.state.analysis.runes) {
validate_opening_tag(node, context.state, '#');
}
mark_subtree_dynamic(context.path);
context.visit(node.expression, { ...context.state, expression: node.metadata.expression });
context.visit(node.fragment);
}
@@ -0,0 +1,95 @@
/** @import { Expression, LabeledStatement } from 'estree' */
/** @import { AST, ReactiveStatement } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import { extract_identifiers, object } from '../../../utils/ast.js';
import * as w from '../../../warnings.js';
/**
* @param {LabeledStatement} node
* @param {Context} context
*/
export function LabeledStatement(node, context) {
if (node.label.name === '$') {
const parent = /** @type {AST.SvelteNode} */ (context.path.at(-1));
const is_reactive_statement =
context.state.ast_type === 'instance' && parent.type === 'Program';
if (is_reactive_statement) {
if (context.state.analysis.runes) {
e.legacy_reactive_statement_invalid(node);
}
// Find all dependencies of this `$: {...}` statement
/** @type {ReactiveStatement} */
const reactive_statement = {
assignments: new Set(),
dependencies: []
};
context.next({
...context.state,
reactive_statement,
function_depth: context.state.scope.function_depth + 1
});
// Every referenced binding becomes a dependency, unless it's on
// the left-hand side of an `=` assignment
for (const [name, nodes] of context.state.scope.references) {
const binding = context.state.scope.get(name);
if (binding === null) continue;
for (const { node, path } of nodes) {
/** @type {Expression} */
let left = node;
let i = path.length - 1;
let parent = /** @type {Expression} */ (path.at(i));
while (parent.type === 'MemberExpression') {
left = parent;
parent = /** @type {Expression} */ (path.at(--i));
}
if (
parent.type === 'AssignmentExpression' &&
parent.operator === '=' &&
parent.left === left
) {
continue;
}
reactive_statement.dependencies.push(binding);
break;
}
}
context.state.analysis.reactive_statements.set(node, reactive_statement);
if (
node.body.type === 'ExpressionStatement' &&
node.body.expression.type === 'AssignmentExpression'
) {
let ids = extract_identifiers(node.body.expression.left);
if (node.body.expression.left.type === 'MemberExpression') {
const id = object(node.body.expression.left);
if (id !== null) {
ids = [id];
}
}
for (const id of ids) {
const binding = context.state.scope.get(id.name);
if (binding?.kind === 'legacy_reactive') {
// TODO does this include `let double; $: double = x * 2`?
binding.legacy_dependencies = Array.from(reactive_statement.dependencies);
}
}
}
} else if (!context.state.analysis.runes) {
w.reactive_declaration_invalid_placement(node);
}
}
context.next();
}
@@ -0,0 +1,24 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
/**
* @param {AST.LetDirective} node
* @param {Context} context
*/
export function LetDirective(node, context) {
const parent = context.path.at(-1);
if (
parent === undefined ||
(parent.type !== 'Component' &&
parent.type !== 'RegularElement' &&
parent.type !== 'SlotElement' &&
parent.type !== 'SvelteElement' &&
parent.type !== 'SvelteComponent' &&
parent.type !== 'SvelteSelf' &&
parent.type !== 'SvelteFragment')
) {
e.let_directive_invalid_placement(node);
}
}
@@ -0,0 +1,14 @@
/** @import { Literal } from 'estree' */
import * as w from '../../../warnings.js';
import { regex_bidirectional_control_characters } from '../../patterns.js';
/**
* @param {Literal} node
*/
export function Literal(node) {
if (typeof node.value === 'string') {
if (regex_bidirectional_control_characters.test(node.value)) {
w.bidirectional_control_characters(node);
}
}
}
@@ -0,0 +1,28 @@
/** @import { MemberExpression } from 'estree' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import { is_pure, is_safe_identifier } from './shared/utils.js';
/**
* @param {MemberExpression} node
* @param {Context} context
*/
export function MemberExpression(node, context) {
if (node.object.type === 'Identifier' && node.property.type === 'Identifier') {
const binding = context.state.scope.get(node.object.name);
if (binding?.kind === 'rest_prop' && node.property.name.startsWith('$$')) {
e.props_illegal_name(node.property);
}
}
if (context.state.expression) {
context.state.expression.has_member_expression = true;
context.state.expression.has_state ||= !is_pure(node, context);
}
if (!is_safe_identifier(node, context.state.scope)) {
context.state.analysis.needs_context = true;
}
context.next();
}
@@ -0,0 +1,17 @@
/** @import { NewExpression } from 'estree' */
/** @import { Context } from '../types' */
import * as w from '../../../warnings.js';
/**
* @param {NewExpression} node
* @param {Context} context
*/
export function NewExpression(node, context) {
if (node.callee.type === 'ClassExpression' && context.state.scope.function_depth > 0) {
w.perf_avoid_inline_class(node);
}
context.state.analysis.needs_context = true;
context.next();
}
@@ -0,0 +1,28 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as w from '../../../warnings.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
* @param {AST.OnDirective} node
* @param {Context} context
*/
export function OnDirective(node, context) {
if (context.state.analysis.runes) {
const parent_type = context.path.at(-1)?.type;
// Don't warn on component events; these might not be under the author's control so the warning would be unactionable
if (parent_type === 'RegularElement' || parent_type === 'SvelteElement') {
w.event_directive_deprecated(node, node.name);
}
}
const parent = context.path.at(-1);
if (parent?.type === 'SvelteElement' || parent?.type === 'RegularElement') {
context.state.analysis.event_directive_node ??= node;
}
mark_subtree_dynamic(context.path);
context.next({ ...context.state, expression: node.metadata.expression });
}
@@ -0,0 +1,21 @@
/** @import { PropertyDefinition } from 'estree' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import { get_name } from '../../nodes.js';
/**
* @param {PropertyDefinition} node
* @param {Context} context
*/
export function PropertyDefinition(node, context) {
const name = get_name(node.key);
const field = name && context.state.state_fields.get(name);
if (field && node !== field.node && node.value) {
if (/** @type {number} */ (node.start) < /** @type {number} */ (field.node.start)) {
e.state_field_invalid_assignment(node);
}
}
context.next();
}
@@ -0,0 +1,240 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { is_mathml, is_svg, is_void } from '../../../../utils.js';
import {
is_tag_valid_with_ancestor,
is_tag_valid_with_parent
} from '../../../../html-tree-validation.js';
import * as e from '../../../errors.js';
import * as w from '../../../warnings.js';
import {
create_attribute,
is_custom_element_node,
is_customizable_select_element
} from '../../nodes.js';
import { regex_starts_with_newline } from '../../patterns.js';
import { check_element } from './shared/a11y/index.js';
import { validate_element } from './shared/element.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
import { object } from '../../../utils/ast.js';
import { runes } from '../../../state.js';
/**
* @param {AST.RegularElement} node
* @param {Context} context
*/
export function RegularElement(node, context) {
validate_element(node, context);
check_element(node, context);
node.metadata.path = [...context.path];
context.state.analysis.elements.push(node);
// Special case: Move the children of <textarea> into a value attribute if they are dynamic
if (node.name === 'textarea' && node.fragment.nodes.length > 0) {
for (const attribute of node.attributes) {
if (attribute.type === 'Attribute' && attribute.name === 'value') {
e.textarea_invalid_content(node);
}
}
if (node.fragment.nodes.length > 1 || node.fragment.nodes[0].type !== 'Text') {
const first = node.fragment.nodes[0];
if (first.type === 'Text') {
// The leading newline character needs to be stripped because of a qirk:
// It is ignored by browsers if the tag and its contents are set through
// innerHTML, but we're now setting it through the value property at which
// point it is _not_ ignored, so we need to strip it ourselves.
// see https://html.spec.whatwg.org/multipage/syntax.html#element-restrictions
// see https://html.spec.whatwg.org/multipage/grouping-content.html#the-pre-element
first.data = first.data.replace(regex_starts_with_newline, '');
first.raw = first.raw.replace(regex_starts_with_newline, '');
}
node.attributes.push(
create_attribute(
'value',
null,
-1,
-1,
// @ts-ignore
node.fragment.nodes
)
);
node.fragment.nodes = [];
}
}
// Special case: `<select bind:value={foo}><option>{bar}</option>`
// means we need to invalidate `bar` whenever `foo` is mutated
if (node.name === 'select' && !runes) {
for (const attribute of node.attributes) {
if (
attribute.type === 'BindDirective' &&
attribute.name === 'value' &&
attribute.expression.type !== 'SequenceExpression'
) {
const identifier = object(attribute.expression);
const binding = identifier && context.state.scope.get(identifier.name);
if (binding) {
for (const name of context.state.scope.references.keys()) {
if (name === binding.node.name) continue;
const indirect = context.state.scope.get(name);
if (indirect) {
binding.legacy_indirect_bindings.add(indirect);
}
}
}
break;
}
}
}
// Special case: single expression tag child of option element -> add "fake" attribute
// to ensure that value types are the same (else for example numbers would be strings)
if (
node.name === 'option' &&
node.fragment.nodes?.length === 1 &&
node.fragment.nodes[0].type === 'ExpressionTag' &&
!node.attributes.some(
(attribute) => attribute.type === 'Attribute' && attribute.name === 'value'
)
) {
const child = node.fragment.nodes[0];
node.metadata.synthetic_value_node = child;
}
// Special case: <select>, <option> or <optgroup> with rich content needs special hydration handling
// We mark the subtree as dynamic so parent elements properly include the child init code
if (is_customizable_select_element(node) || node.name === 'selectedcontent') {
// Mark the element's own fragment as dynamic so it's not treated as static
node.fragment.metadata.dynamic = true;
// Also mark ancestor fragments so parents properly include the child init code
mark_subtree_dynamic(context.path);
}
const binding = context.state.scope.get(node.name);
if (
binding !== null &&
binding.declaration_kind === 'import' &&
binding.references.length === 0
) {
w.component_name_lowercase(node, node.name);
}
node.metadata.has_spread = node.attributes.some(
(attribute) => attribute.type === 'SpreadAttribute'
);
const is_svg_element = () => {
if (is_svg(node.name)) {
return true;
}
if (node.name === 'a' || node.name === 'title') {
let i = context.path.length;
while (i--) {
const ancestor = context.path[i];
if (ancestor.type === 'RegularElement') {
return ancestor.metadata.svg;
}
}
}
return false;
};
node.metadata.svg = is_svg_element();
node.metadata.mathml = is_mathml(node.name);
if (is_custom_element_node(node) && node.attributes.length > 0) {
// we're setting all attributes on custom elements through properties
mark_subtree_dynamic(context.path);
}
if (context.state.parent_element) {
let past_parent = false;
let only_warn = false;
const ancestors = [context.state.parent_element];
for (let i = context.path.length - 1; i >= 0; i--) {
const ancestor = context.path[i];
if (
ancestor.type === 'IfBlock' ||
ancestor.type === 'EachBlock' ||
ancestor.type === 'AwaitBlock' ||
ancestor.type === 'KeyBlock'
) {
// We're creating a separate template string inside blocks, which means client-side this would work
only_warn = true;
}
if (!past_parent) {
if (ancestor.type === 'RegularElement' && ancestor.name === context.state.parent_element) {
const message = is_tag_valid_with_parent(node.name, context.state.parent_element);
if (message) {
if (only_warn) {
w.node_invalid_placement_ssr(node, message);
} else {
e.node_invalid_placement(node, message);
}
}
past_parent = true;
}
} else if (ancestor.type === 'RegularElement') {
ancestors.push(ancestor.name);
const message = is_tag_valid_with_ancestor(node.name, ancestors);
if (message) {
if (only_warn) {
w.node_invalid_placement_ssr(node, message);
} else {
e.node_invalid_placement(node, message);
}
}
} else if (
ancestor.type === 'Component' ||
ancestor.type === 'SvelteComponent' ||
ancestor.type === 'SvelteElement' ||
ancestor.type === 'SvelteSelf' ||
ancestor.type === 'SnippetBlock'
) {
break;
}
}
}
// Strip off any namespace from the beginning of the node name.
const node_name = node.name.replace(/[a-zA-Z-]*:/g, '');
if (
context.state.analysis.source[node.end - 2] === '/' &&
!is_void(node_name) &&
!is_svg(node_name) &&
!is_mathml(node_name)
) {
w.element_invalid_self_closing_tag(node, node.name);
}
context.next({ ...context.state, parent_element: node.name });
// Special case: <a> tags are valid in both the SVG and HTML namespace.
// If there's no parent, look downwards to see if it's the parent of a SVG or HTML element.
if (node.name === 'a' && !context.state.parent_element) {
for (const child of node.fragment.nodes) {
if (child.type === 'RegularElement') {
if (child.metadata.svg && child.name !== 'svg') {
node.metadata.svg = true;
break;
}
}
}
}
}
@@ -0,0 +1,68 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { unwrap_optional } from '../../../utils/ast.js';
import * as e from '../../../errors.js';
import { validate_opening_tag } from './shared/utils.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
import { is_resolved_snippet } from './shared/snippets.js';
import { ExpressionMetadata } from '../../nodes.js';
/**
* @param {AST.RenderTag} node
* @param {Context} context
*/
export function RenderTag(node, context) {
validate_opening_tag(node, context.state, '@');
node.metadata.path = [...context.path];
const expression = unwrap_optional(node.expression);
const callee = expression.callee;
const binding = callee.type === 'Identifier' ? context.state.scope.get(callee.name) : null;
node.metadata.dynamic = binding?.kind !== 'normal';
/**
* If we can't unambiguously resolve this to a declaration, we
* must assume the worst and link the render tag to every snippet
*/
let resolved = callee.type === 'Identifier' && is_resolved_snippet(binding);
if (binding?.initial?.type === 'SnippetBlock') {
// if this render tag unambiguously references a local snippet, our job is easy
node.metadata.snippets.add(binding.initial);
}
context.state.analysis.snippet_renderers.set(node, resolved);
context.state.analysis.uses_render_tags = true;
const raw_args = unwrap_optional(node.expression).arguments;
for (const arg of raw_args) {
if (arg.type === 'SpreadElement') {
e.render_tag_invalid_spread_argument(arg);
}
}
if (
callee.type === 'MemberExpression' &&
callee.property.type === 'Identifier' &&
['bind', 'apply', 'call'].includes(callee.property.name)
) {
e.render_tag_invalid_call_expression(node);
}
mark_subtree_dynamic(context.path);
context.visit(callee, { ...context.state, expression: node.metadata.expression });
for (const arg of expression.arguments) {
const metadata = new ExpressionMetadata();
node.metadata.arguments.push(metadata);
context.visit(arg, {
...context.state,
expression: metadata
});
}
}
@@ -0,0 +1,42 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { is_text_attribute } from '../../../utils/ast.js';
import * as e from '../../../errors.js';
import * as w from '../../../warnings.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
* @param {AST.SlotElement} node
* @param {Context} context
*/
export function SlotElement(node, context) {
if (context.state.analysis.runes && !context.state.analysis.custom_element) {
w.slot_element_deprecated(node);
}
mark_subtree_dynamic(context.path);
/** @type {string} */
let name = 'default';
for (const attribute of node.attributes) {
if (attribute.type === 'Attribute') {
if (attribute.name === 'name') {
if (!is_text_attribute(attribute)) {
e.slot_element_invalid_name(attribute);
}
name = attribute.value[0].data;
if (name === 'default') {
e.slot_element_invalid_name_default(attribute);
}
}
} else if (attribute.type !== 'SpreadAttribute' && attribute.type !== 'LetDirective') {
e.slot_element_invalid_attribute(attribute);
}
}
context.state.analysis.slot_names.set(name, node);
context.next();
}
@@ -0,0 +1,113 @@
/** @import { AST, Binding } from '#compiler' */
/** @import { Scope } from '../../scope' */
/** @import { Context } from '../types' */
import { validate_block_not_empty, validate_opening_tag } from './shared/utils.js';
import * as e from '../../../errors.js';
/**
* @param {AST.SnippetBlock} node
* @param {Context} context
*/
export function SnippetBlock(node, context) {
context.state.analysis.snippets.add(node);
validate_block_not_empty(node.body, context);
if (context.state.analysis.runes) {
validate_opening_tag(node, context.state, '#');
}
for (const arg of node.parameters) {
if (arg.type === 'RestElement') {
e.snippet_invalid_rest_parameter(arg);
}
}
context.next({ ...context.state, parent_element: null });
const can_hoist =
context.path.length === 1 &&
context.path[0].type === 'Fragment' &&
can_hoist_snippet(context.state.scope, context.state.scopes);
const name = node.expression.name;
if (can_hoist) {
const binding = /** @type {Binding} */ (context.state.scope.get(name));
context.state.analysis.module.scope.declarations.set(name, binding);
}
node.metadata.can_hoist = can_hoist;
const { path } = context;
const parent = path.at(-2);
if (!parent) return;
if (
parent.type === 'Component' &&
parent.attributes.some(
(attribute) =>
(attribute.type === 'Attribute' || attribute.type === 'BindDirective') &&
attribute.name === node.expression.name
)
) {
e.snippet_shadowing_prop(node, node.expression.name);
}
if (node.expression.name !== 'children') return;
if (
parent.type === 'Component' ||
parent.type === 'SvelteComponent' ||
parent.type === 'SvelteSelf'
) {
if (
parent.fragment.nodes.some(
(node) =>
node.type !== 'SnippetBlock' &&
(node.type !== 'Text' || node.data.trim()) &&
node.type !== 'Comment'
)
) {
e.snippet_conflict(node);
}
}
}
/**
* @param {Map<AST.SvelteNode, Scope>} scopes
* @param {Scope} scope
*/
function can_hoist_snippet(scope, scopes, visited = new Set()) {
for (const [reference] of scope.references) {
const binding = scope.get(reference);
if (!binding) continue;
if (binding.blocker) {
return false;
}
if (binding.scope.function_depth === 0) {
continue;
}
// ignore bindings declared inside the snippet (e.g. the snippet's own parameters)
if (binding.scope.function_depth >= scope.function_depth) {
continue;
}
if (binding.initial?.type === 'SnippetBlock') {
if (visited.has(binding)) continue;
visited.add(binding);
const snippet_scope = /** @type {Scope} */ (scopes.get(binding.initial));
if (can_hoist_snippet(snippet_scope, scopes, visited)) {
continue;
}
}
return false;
}
return true;
}
@@ -0,0 +1,13 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
* @param {AST.SpreadAttribute} node
* @param {Context} context
*/
export function SpreadAttribute(node, context) {
mark_subtree_dynamic(context.path);
context.next({ ...context.state, expression: node.metadata.expression });
}
@@ -0,0 +1,16 @@
/** @import { SpreadElement } from 'estree' */
/** @import { Context } from '../types' */
/**
* @param {SpreadElement} node
* @param {Context} context
*/
export function SpreadElement(node, context) {
if (context.state.expression) {
// treat e.g. `[...x]` the same as `[...x.values()]`
context.state.expression.has_call = true;
context.state.expression.has_state = true;
}
context.next();
}
@@ -0,0 +1,39 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import { get_attribute_chunks } from '../../../utils/ast.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
* @param {AST.StyleDirective} node
* @param {Context} context
*/
export function StyleDirective(node, context) {
if (node.modifiers.length > 1 || (node.modifiers.length && node.modifiers[0] !== 'important')) {
e.style_directive_invalid_modifier(node);
}
mark_subtree_dynamic(context.path);
if (node.value === true) {
// get the binding for node.name and change the binding to state
let binding = context.state.scope.get(node.name);
if (binding) {
if (binding.kind !== 'normal') {
node.metadata.expression.has_state = true;
}
if (binding.blocker) {
node.metadata.expression.dependencies.add(binding);
}
}
} else {
context.next();
for (const chunk of get_attribute_chunks(node.value)) {
if (chunk.type !== 'ExpressionTag') continue;
node.metadata.expression.merge(chunk.metadata.expression);
}
}
}
@@ -0,0 +1,22 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import { is_event_attribute } from '../../../utils/ast.js';
import { disallow_children } from './shared/special-element.js';
/**
* @param {AST.SvelteBody} node
* @param {Context} context
*/
export function SvelteBody(node, context) {
disallow_children(node);
for (const attribute of node.attributes) {
if (
attribute.type === 'SpreadAttribute' ||
(attribute.type === 'Attribute' && !is_event_attribute(attribute))
) {
e.svelte_body_illegal_attribute(attribute);
}
}
context.next();
}
@@ -0,0 +1,30 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
const valid = ['onerror', 'failed', 'pending'];
/**
* @param {AST.SvelteBoundary} node
* @param {Context} context
*/
export function SvelteBoundary(node, context) {
for (const attribute of node.attributes) {
if (attribute.type !== 'Attribute' || !valid.includes(attribute.name)) {
e.svelte_boundary_invalid_attribute(attribute);
}
if (
attribute.value === true ||
(Array.isArray(attribute.value) &&
(attribute.value.length !== 1 || attribute.value[0].type !== 'ExpressionTag'))
) {
e.svelte_boundary_invalid_attribute_value(attribute);
}
}
mark_subtree_dynamic(context.path);
context.next();
}
@@ -0,0 +1,18 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as w from '../../../warnings.js';
import { visit_component } from './shared/component.js';
/**
* @param {AST.SvelteComponent} node
* @param {Context} context
*/
export function SvelteComponent(node, context) {
if (context.state.analysis.runes) {
w.svelte_component_deprecated(node);
}
context.visit(node.expression, { ...context.state, expression: node.metadata.expression });
visit_component(node, context);
}
@@ -0,0 +1,24 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { disallow_children } from './shared/special-element.js';
import * as e from '../../../errors.js';
import { is_event_attribute } from '../../../utils/ast.js';
/**
* @param {AST.SvelteDocument} node
* @param {Context} context
*/
export function SvelteDocument(node, context) {
disallow_children(node);
for (const attribute of node.attributes) {
if (
attribute.type === 'SpreadAttribute' ||
(attribute.type === 'Attribute' && !is_event_attribute(attribute))
) {
e.illegal_element_attribute(attribute, 'svelte:document');
}
}
context.next();
}
@@ -0,0 +1,78 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { NAMESPACE_MATHML, NAMESPACE_SVG } from '../../../../constants.js';
import { is_text_attribute } from '../../../utils/ast.js';
import { check_element } from './shared/a11y/index.js';
import { validate_element } from './shared/element.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
* @param {AST.SvelteElement} node
* @param {Context} context
*/
export function SvelteElement(node, context) {
validate_element(node, context);
check_element(node, context);
node.metadata.path = [...context.path];
context.state.analysis.elements.push(node);
const xmlns = /** @type {AST.Attribute & { value: [AST.Text] } | undefined} */ (
node.attributes.find(
(a) => a.type === 'Attribute' && a.name === 'xmlns' && is_text_attribute(a)
)
);
if (xmlns) {
node.metadata.svg = xmlns.value[0].data === NAMESPACE_SVG;
node.metadata.mathml = xmlns.value[0].data === NAMESPACE_MATHML;
} else {
let i = context.path.length;
while (i--) {
const ancestor = context.path[i];
if (
ancestor.type === 'Component' ||
ancestor.type === 'SvelteComponent' ||
ancestor.type === 'SvelteFragment' ||
ancestor.type === 'SnippetBlock' ||
i === 0
) {
// Root element, or inside a slot or a snippet -> this resets the namespace, so assume the component namespace
node.metadata.svg = context.state.options.namespace === 'svg';
node.metadata.mathml = context.state.options.namespace === 'mathml';
break;
}
if (ancestor.type === 'SvelteElement' || ancestor.type === 'RegularElement') {
node.metadata.svg =
ancestor.type === 'RegularElement' && ancestor.name === 'foreignObject'
? false
: ancestor.metadata.svg;
node.metadata.mathml =
ancestor.type === 'RegularElement' && ancestor.name === 'foreignObject'
? false
: ancestor.metadata.mathml;
break;
}
}
}
mark_subtree_dynamic(context.path);
context.visit(node.tag, {
...context.state,
expression: node.metadata.expression
});
for (const attribute of node.attributes) {
context.visit(attribute);
}
context.visit(node.fragment, {
...context.state,
parent_element: null
});
}
@@ -0,0 +1,27 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import { validate_slot_attribute } from './shared/attribute.js';
/**
* @param {AST.SvelteFragment} node
* @param {Context} context
*/
export function SvelteFragment(node, context) {
const parent = context.path.at(-2);
if (parent?.type !== 'Component' && parent?.type !== 'SvelteComponent') {
e.svelte_fragment_invalid_placement(node);
}
for (const attribute of node.attributes) {
if (attribute.type === 'Attribute') {
if (attribute.name === 'slot') {
validate_slot_attribute(context, attribute);
}
} else if (attribute.type !== 'LetDirective') {
e.svelte_fragment_invalid_attribute(attribute);
}
}
context.next({ ...context.state, parent_element: null });
}
@@ -0,0 +1,18 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
* @param {AST.SvelteHead} node
* @param {Context} context
*/
export function SvelteHead(node, context) {
for (const attribute of node.attributes) {
e.svelte_head_illegal_attribute(attribute);
}
mark_subtree_dynamic(context.path);
context.next();
}
@@ -0,0 +1,36 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { visit_component } from './shared/component.js';
import * as e from '../../../errors.js';
import * as w from '../../../warnings.js';
import { filename, UNKNOWN_FILENAME } from '../../../state.js';
/**
* @param {AST.SvelteSelf} node
* @param {Context} context
*/
export function SvelteSelf(node, context) {
const valid = context.path.some(
(node) =>
node.type === 'IfBlock' ||
node.type === 'EachBlock' ||
node.type === 'Component' ||
node.type === 'SnippetBlock'
);
if (!valid) {
e.svelte_self_invalid_placement(node);
}
if (context.state.analysis.runes) {
const name = filename === UNKNOWN_FILENAME ? 'Self' : context.state.analysis.name;
const basename =
filename === UNKNOWN_FILENAME
? 'Self.svelte'
: /** @type {string} */ (filename.split(/[/\\]/).pop());
w.svelte_self_deprecated(node, name, basename);
}
visit_component(node, context);
}
@@ -0,0 +1,24 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { disallow_children } from './shared/special-element.js';
import * as e from '../../../errors.js';
import { is_event_attribute } from '../../../utils/ast.js';
/**
* @param {AST.SvelteWindow} node
* @param {Context} context
*/
export function SvelteWindow(node, context) {
disallow_children(node);
for (const attribute of node.attributes) {
if (
attribute.type === 'SpreadAttribute' ||
(attribute.type === 'Attribute' && !is_event_attribute(attribute))
) {
e.illegal_element_attribute(attribute, 'svelte:window');
}
}
context.next();
}
@@ -0,0 +1,16 @@
/** @import { TaggedTemplateExpression } from 'estree' */
/** @import { Context } from '../types' */
import { is_pure } from './shared/utils.js';
/**
* @param {TaggedTemplateExpression} node
* @param {Context} context
*/
export function TaggedTemplateExpression(node, context) {
if (context.state.expression && !is_pure(node.tag, context)) {
context.state.expression.has_call = true;
context.state.expression.has_state = true;
}
context.next();
}
@@ -0,0 +1,12 @@
/** @import { TemplateElement } from 'estree' */
import * as w from '../../../warnings.js';
import { regex_bidirectional_control_characters } from '../../patterns.js';
/**
* @param {TemplateElement} node
*/
export function TemplateElement(node) {
if (regex_bidirectional_control_characters.test(node.value.cooked ?? '')) {
w.bidirectional_control_characters(node);
}
}
@@ -0,0 +1,52 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { is_tag_valid_with_parent } from '../../../../html-tree-validation.js';
import { regex_bidirectional_control_characters, regex_not_whitespace } from '../../patterns.js';
import * as e from '../../../errors.js';
import * as w from '../../../warnings.js';
import { extract_svelte_ignore } from '../../../utils/extract_svelte_ignore.js';
/**
* @param {AST.Text} node
* @param {Context} context
*/
export function Text(node, context) {
const parent = /** @type {AST.SvelteNode} */ (context.path.at(-1));
if (
parent.type === 'Fragment' &&
context.state.parent_element &&
regex_not_whitespace.test(node.data)
) {
const message = is_tag_valid_with_parent('#text', context.state.parent_element);
if (message) {
e.node_invalid_placement(node, message);
}
}
regex_bidirectional_control_characters.lastIndex = 0;
for (const match of node.data.matchAll(regex_bidirectional_control_characters)) {
let is_ignored = false;
// if we have a svelte-ignore comment earlier in the text, bail
// (otherwise we can only use svelte-ignore on parent elements/blocks)
if (parent.type === 'Fragment') {
for (const child of parent.nodes) {
if (child === node) break;
if (child.type === 'Comment') {
is_ignored ||= extract_svelte_ignore(
child.start + 4,
child.data,
context.state.analysis.runes
).includes('bidirectional_control_characters');
}
}
}
if (!is_ignored) {
let start = match.index + node.start;
w.bidirectional_control_characters({ start, end: start + match[0].length });
}
}
}
@@ -0,0 +1,21 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
/**
* @param {AST.TitleElement} node
* @param {Context} context
*/
export function TitleElement(node, context) {
for (const attribute of node.attributes) {
e.title_illegal_attribute(attribute);
}
for (const child of node.fragment.nodes) {
if (child.type !== 'Text' && child.type !== 'ExpressionTag') {
e.title_invalid_content(child);
}
}
context.next();
}
@@ -0,0 +1,19 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as e from '../../../errors.js';
import { mark_subtree_dynamic } from './shared/fragment.js';
/**
* @param {AST.TransitionDirective} node
* @param {Context} context
*/
export function TransitionDirective(node, context) {
mark_subtree_dynamic(context.path);
context.next({ ...context.state, expression: node.metadata.expression });
if (node.metadata.expression.has_await) {
e.illegal_await_expression(node);
}
}
@@ -0,0 +1,29 @@
/** @import { UpdateExpression } from 'estree' */
/** @import { Context } from '../types' */
import { object } from '../../../utils/ast.js';
import { validate_assignment } from './shared/utils.js';
/**
* @param {UpdateExpression} node
* @param {Context} context
*/
export function UpdateExpression(node, context) {
validate_assignment(node, node.argument, context);
if (context.state.reactive_statement) {
const id = node.argument.type === 'MemberExpression' ? object(node.argument) : node.argument;
if (id?.type === 'Identifier') {
const binding = context.state.scope.get(id.name);
if (binding) {
context.state.reactive_statement.assignments.add(binding);
}
}
}
if (context.state.expression) {
context.state.expression.has_assignment = true;
}
context.next();
}
@@ -0,0 +1,18 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { mark_subtree_dynamic } from './shared/fragment.js';
import * as e from '../../../errors.js';
/**
* @param {AST.UseDirective} node
* @param {Context} context
*/
export function UseDirective(node, context) {
mark_subtree_dynamic(context.path);
context.next({ ...context.state, expression: node.metadata.expression });
if (node.metadata.expression.has_await) {
e.illegal_await_expression(node);
}
}
@@ -0,0 +1,160 @@
/** @import { Expression, Identifier, Literal, VariableDeclarator } from 'estree' */
/** @import { Binding } from '#compiler' */
/** @import { Context } from '../types' */
import { get_rune } from '../../scope.js';
import { ensure_no_module_import_conflict, validate_identifier_name } from './shared/utils.js';
import * as e from '../../../errors.js';
import * as w from '../../../warnings.js';
import { extract_paths } from '../../../utils/ast.js';
import { equal } from '../../../utils/assert.js';
import * as b from '#compiler/builders';
/**
* @param {VariableDeclarator} node
* @param {Context} context
*/
export function VariableDeclarator(node, context) {
ensure_no_module_import_conflict(node, context.state);
if (context.state.analysis.runes) {
const init = node.init;
const rune = get_rune(init, context.state.scope);
const { paths } = extract_paths(node.id, b.id('dummy'));
for (const path of paths) {
validate_identifier_name(context.state.scope.get(/** @type {Identifier} */ (path.node).name));
}
// TODO feels like this should happen during scope creation?
if (
rune === '$state' ||
rune === '$state.raw' ||
rune === '$derived' ||
rune === '$derived.by' ||
rune === '$props'
) {
for (const path of paths) {
// @ts-ignore this fails in CI for some insane reason
const binding = /** @type {Binding} */ (context.state.scope.get(path.node.name));
binding.kind =
rune === '$state'
? 'state'
: rune === '$state.raw'
? 'raw_state'
: rune === '$derived' || rune === '$derived.by'
? 'derived'
: path.is_rest
? 'rest_prop'
: 'prop';
if (rune === '$props' && binding.kind === 'rest_prop' && node.id.type === 'ObjectPattern') {
const { properties } = node.id;
/** @type {string[]} */
const exclude_props = [];
for (const property of properties) {
if (property.type === 'RestElement') {
continue;
}
const key = /** @type {Identifier | Literal & { value: string | number }} */ (
property.key
);
exclude_props.push(key.type === 'Identifier' ? key.name : key.value.toString());
}
(binding.metadata ??= {}).exclude_props = exclude_props;
}
}
}
if (rune === '$props') {
if (node.id.type !== 'ObjectPattern' && node.id.type !== 'Identifier') {
e.props_invalid_identifier(node);
}
if (
context.state.analysis.custom_element &&
context.state.options.customElementOptions?.props == null
) {
let warn_on;
if (
node.id.type === 'Identifier' ||
(warn_on = node.id.properties.find((p) => p.type === 'RestElement')) != null
) {
w.custom_element_props_identifier(warn_on ?? node.id);
}
}
context.state.analysis.needs_props = true;
if (node.id.type === 'Identifier') {
const binding = /** @type {Binding} */ (context.state.scope.get(node.id.name));
binding.initial = null; // else would be $props()
binding.kind = 'rest_prop';
} else {
equal(node.id.type, 'ObjectPattern');
for (const property of node.id.properties) {
if (property.type !== 'Property') continue;
if (property.computed) {
e.props_invalid_pattern(property);
}
if (property.key.type === 'Identifier' && property.key.name.startsWith('$$')) {
e.props_illegal_name(property);
}
const value =
property.value.type === 'AssignmentPattern' ? property.value.left : property.value;
if (value.type !== 'Identifier') {
e.props_invalid_pattern(property);
}
const alias =
property.key.type === 'Identifier'
? property.key.name
: String(/** @type {Literal} */ (property.key).value);
let initial = property.value.type === 'AssignmentPattern' ? property.value.right : null;
const binding = /** @type {Binding} */ (context.state.scope.get(value.name));
binding.prop_alias = alias;
// rewire initial from $props() to the actual initial value, stripping $bindable() if necessary
if (
initial?.type === 'CallExpression' &&
initial.callee.type === 'Identifier' &&
initial.callee.name === '$bindable'
) {
binding.initial = /** @type {Expression | null} */ (initial.arguments[0] ?? null);
binding.kind = 'bindable_prop';
} else {
binding.initial = initial;
}
}
}
}
} else {
if (node.init?.type === 'CallExpression') {
const callee = node.init.callee;
if (
callee.type === 'Identifier' &&
(callee.name === '$state' || callee.name === '$derived' || callee.name === '$props') &&
context.state.scope.get(callee.name)?.kind !== 'store_sub'
) {
e.rune_invalid_usage(node.init, callee.name);
}
}
}
if (node.init && get_rune(node.init, context.state.scope) === '$props') {
// prevent erroneous `state_referenced_locally` warnings on prop fallbacks
context.visit(node.id, {
...context.state,
function_depth: context.state.function_depth + 1
});
context.visit(node.init);
} else {
context.next();
}
}
@@ -0,0 +1,334 @@
/** @import { ARIARoleRelationConcept } from 'aria-query' */
import { roles as roles_map, elementRoles } from 'aria-query';
// @ts-expect-error package doesn't provide typings
import { AXObjects, elementAXObjects } from 'axobject-query';
export const aria_attributes =
'activedescendant atomic autocomplete busy checked colcount colindex colspan controls current describedby description details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount rowindex rowspan selected setsize sort valuemax valuemin valuenow valuetext'.split(
' '
);
/** @type {Record<string, string[]>} */
export const a11y_required_attributes = {
a: ['href'],
area: ['alt', 'aria-label', 'aria-labelledby'],
// html-has-lang
html: ['lang'],
// iframe-has-title
iframe: ['title'],
img: ['alt'],
object: ['title', 'aria-label', 'aria-labelledby']
};
export const a11y_distracting_elements = ['blink', 'marquee'];
// this excludes `<a>` and `<button>` because they are handled separately
export const a11y_required_content = [
// heading-has-content
'h1',
'h2',
'h3',
'h4',
'h5',
'h6'
];
export const a11y_labelable = [
'button',
'input',
'keygen',
'meter',
'output',
'progress',
'select',
'textarea'
];
export const a11y_interactive_handlers = [
// Keyboard events
'keypress',
'keydown',
'keyup',
// Click events
'click',
'contextmenu',
'dblclick',
'drag',
'dragend',
'dragenter',
'dragexit',
'dragleave',
'dragover',
'dragstart',
'drop',
'mousedown',
'mouseenter',
'mouseleave',
'mousemove',
'mouseout',
'mouseover',
'mouseup',
// Pointer events
'pointerdown',
'pointerup',
'pointermove',
'pointerenter',
'pointerleave',
'pointerover',
'pointerout',
'pointercancel',
// Touch events
'touchstart',
'touchend',
'touchmove',
'touchcancel'
];
export const a11y_recommended_interactive_handlers = [
'click',
'mousedown',
'mouseup',
'keypress',
'keydown',
'keyup'
];
export const a11y_nested_implicit_semantics = new Map([
['header', 'banner'],
['footer', 'contentinfo']
]);
export const a11y_implicit_semantics = new Map([
['a', 'link'],
['area', 'link'],
['article', 'article'],
['aside', 'complementary'],
['body', 'document'],
['button', 'button'],
['datalist', 'listbox'],
['dd', 'definition'],
['dfn', 'term'],
['dialog', 'dialog'],
['details', 'group'],
['dt', 'term'],
['fieldset', 'group'],
['figure', 'figure'],
['form', 'form'],
['h1', 'heading'],
['h2', 'heading'],
['h3', 'heading'],
['h4', 'heading'],
['h5', 'heading'],
['h6', 'heading'],
['hr', 'separator'],
['img', 'img'],
['li', 'listitem'],
['link', 'link'],
['main', 'main'],
['menu', 'list'],
['meter', 'progressbar'],
['nav', 'navigation'],
['ol', 'list'],
['option', 'option'],
['optgroup', 'group'],
['output', 'status'],
['progress', 'progressbar'],
['section', 'region'],
['summary', 'button'],
['table', 'table'],
['tbody', 'rowgroup'],
['textarea', 'textbox'],
['tfoot', 'rowgroup'],
['thead', 'rowgroup'],
['tr', 'row'],
['ul', 'list']
]);
export const menuitem_type_to_implicit_role = new Map([
['command', 'menuitem'],
['checkbox', 'menuitemcheckbox'],
['radio', 'menuitemradio']
]);
export const input_type_to_implicit_role = new Map([
['button', 'button'],
['image', 'button'],
['reset', 'button'],
['submit', 'button'],
['checkbox', 'checkbox'],
['radio', 'radio'],
['range', 'slider'],
['number', 'spinbutton'],
['email', 'textbox'],
['search', 'searchbox'],
['tel', 'textbox'],
['text', 'textbox'],
['url', 'textbox']
]);
/**
* Exceptions to the rule which follows common A11y conventions
* TODO make this configurable by the user
* @type {Record<string, string[]>}
*/
export const a11y_non_interactive_element_to_interactive_role_exceptions = {
ul: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'],
ol: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'],
menu: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'],
li: ['menuitem', 'option', 'row', 'tab', 'treeitem'],
table: ['grid'],
td: ['gridcell'],
fieldset: ['radiogroup', 'presentation']
};
export const combobox_if_list = ['email', 'search', 'tel', 'text', 'url'];
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofilling-form-controls:-the-autocomplete-attribute
export const address_type_tokens = ['shipping', 'billing'];
export const autofill_field_name_tokens = [
'',
'on',
'off',
'name',
'honorific-prefix',
'given-name',
'additional-name',
'family-name',
'honorific-suffix',
'nickname',
'username',
'new-password',
'current-password',
'one-time-code',
'organization-title',
'organization',
'street-address',
'address-line1',
'address-line2',
'address-line3',
'address-level4',
'address-level3',
'address-level2',
'address-level1',
'country',
'country-name',
'postal-code',
'cc-name',
'cc-given-name',
'cc-additional-name',
'cc-family-name',
'cc-number',
'cc-exp',
'cc-exp-month',
'cc-exp-year',
'cc-csc',
'cc-type',
'transaction-currency',
'transaction-amount',
'language',
'bday',
'bday-day',
'bday-month',
'bday-year',
'sex',
'url',
'photo'
];
export const contact_type_tokens = ['home', 'work', 'mobile', 'fax', 'pager'];
export const autofill_contact_field_name_tokens = [
'tel',
'tel-country-code',
'tel-national',
'tel-area-code',
'tel-local',
'tel-local-prefix',
'tel-local-suffix',
'tel-extension',
'email',
'impp'
];
export const ElementInteractivity = /** @type {const} */ ({
Interactive: 'interactive',
NonInteractive: 'non-interactive',
Static: 'static'
});
export const invisible_elements = ['meta', 'html', 'script', 'style'];
export const aria_roles = roles_map.keys();
export const abstract_roles = aria_roles.filter((role) => roles_map.get(role)?.abstract);
const non_abstract_roles = aria_roles.filter((name) => !abstract_roles.includes(name));
export const non_interactive_roles = non_abstract_roles
.filter((name) => {
const role = roles_map.get(name);
return (
// 'toolbar' does not descend from widget, but it does support
// aria-activedescendant, thus in practice we treat it as a widget.
// focusable tabpanel elements are recommended if any panels in a set contain content where the first element in the panel is not focusable.
// 'generic' is meant to have no semantic meaning.
// 'cell' is treated as CellRole by the AXObject which is interactive, so we treat 'cell' it as interactive as well.
!['toolbar', 'tabpanel', 'generic', 'cell'].includes(name) &&
!role?.superClass.some((classes) => classes.includes('widget') || classes.includes('window'))
);
})
.concat(
// The `progressbar` is descended from `widget`, but in practice, its
// value is always `readonly`, so we treat it as a non-interactive role.
'progressbar'
);
export const interactive_roles = non_abstract_roles.filter(
(name) =>
!non_interactive_roles.includes(name) &&
// 'generic' is meant to have no semantic meaning.
name !== 'generic'
);
export const presentation_roles = ['presentation', 'none'];
/** @type {ARIARoleRelationConcept[]} */
export const non_interactive_element_role_schemas = [];
/** @type {ARIARoleRelationConcept[]} */
export const interactive_element_role_schemas = [];
for (const [schema, roles] of elementRoles.entries()) {
if ([...roles].every((role) => role !== 'generic' && non_interactive_roles.includes(role))) {
non_interactive_element_role_schemas.push(schema);
}
if ([...roles].every((role) => interactive_roles.includes(role))) {
interactive_element_role_schemas.push(schema);
}
}
const interactive_ax_objects = [...AXObjects.keys()].filter(
(name) => AXObjects.get(name).type === 'widget'
);
/** @type {ARIARoleRelationConcept[]} */
export const interactive_element_ax_object_schemas = [];
/** @type {ARIARoleRelationConcept[]} */
export const non_interactive_element_ax_object_schemas = [];
const non_interactive_ax_objects = [...AXObjects.keys()].filter((name) =>
['windows', 'structure'].includes(AXObjects.get(name).type)
);
for (const [schema, ax_object] of elementAXObjects.entries()) {
if ([...ax_object].every((role) => interactive_ax_objects.includes(role))) {
interactive_element_ax_object_schemas.push(schema);
}
if ([...ax_object].every((role) => non_interactive_ax_objects.includes(role))) {
non_interactive_element_ax_object_schemas.push(schema);
}
}
@@ -0,0 +1,954 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../../../types.js' */
/** @import { ARIARoleDefinitionKey, ARIARoleRelationConcept, ARIAProperty, ARIAPropertyDefinition, ARIARoleDefinition } from 'aria-query' */
import {
a11y_distracting_elements,
a11y_implicit_semantics,
a11y_interactive_handlers,
a11y_labelable,
a11y_nested_implicit_semantics,
a11y_non_interactive_element_to_interactive_role_exceptions,
a11y_recommended_interactive_handlers,
a11y_required_attributes,
a11y_required_content,
abstract_roles,
address_type_tokens,
aria_attributes,
aria_roles,
autofill_contact_field_name_tokens,
autofill_field_name_tokens,
combobox_if_list,
contact_type_tokens,
ElementInteractivity,
input_type_to_implicit_role,
interactive_element_ax_object_schemas,
interactive_element_role_schemas,
interactive_roles,
invisible_elements,
menuitem_type_to_implicit_role,
non_interactive_element_ax_object_schemas,
non_interactive_element_role_schemas,
non_interactive_roles,
presentation_roles
} from './constants.js';
import { roles as roles_map, aria } from 'aria-query';
// @ts-expect-error package doesn't provide typings
import { AXObjectRoles, elementAXObjects } from 'axobject-query';
import {
regex_heading_tags,
regex_js_prefix,
regex_not_whitespace,
regex_redundant_img_alt,
regex_starts_with_vowel,
regex_whitespaces
} from '../../../../patterns.js';
import { is_event_attribute, is_text_attribute } from '../../../../../utils/ast.js';
import { list } from '../../../../../utils/string.js';
import { walk } from 'zimmerframe';
import fuzzymatch from '../../../../1-parse/utils/fuzzymatch.js';
import { is_content_editable_binding } from '../../../../../../utils.js';
import * as w from '../../../../../warnings.js';
/**
* @param {AST.RegularElement | AST.SvelteElement} node
* @param {Context} context
*/
export function check_element(node, context) {
/** @type {Map<string, AST.Attribute>} */
const attribute_map = new Map();
/** @type {Set<string>} */
const handlers = new Set();
/** @type {AST.Attribute[]} */
const attributes = [];
const is_dynamic_element = node.type === 'SvelteElement';
let has_spread = false;
let has_contenteditable_attr = false;
let has_contenteditable_binding = false;
for (const attribute of node.attributes) {
switch (attribute.type) {
case 'Attribute': {
if (is_event_attribute(attribute)) {
handlers.add(attribute.name.slice(2));
} else {
attributes.push(attribute);
attribute_map.set(attribute.name, attribute);
if (attribute.name === 'contenteditable') {
has_contenteditable_attr = true;
}
}
break;
}
case 'SpreadAttribute': {
has_spread = true;
break;
}
case 'BindDirective': {
if (is_content_editable_binding(attribute.name)) {
has_contenteditable_binding = true;
}
break;
}
case 'OnDirective': {
handlers.add(attribute.name);
break;
}
}
}
const interactivity = element_interactivity(node.name, attribute_map);
const is_interactive = interactivity === ElementInteractivity.Interactive;
const is_non_interactive = interactivity === ElementInteractivity.NonInteractive;
const is_static = interactivity === ElementInteractivity.Static;
for (const attribute of node.attributes) {
if (attribute.type !== 'Attribute') continue;
const name = attribute.name.toLowerCase();
// aria-props
if (name.startsWith('aria-')) {
if (invisible_elements.includes(node.name)) {
// aria-unsupported-elements
w.a11y_aria_attributes(attribute, node.name);
}
const type = name.slice(5);
if (!aria_attributes.includes(type)) {
const match = fuzzymatch(type, aria_attributes);
w.a11y_unknown_aria_attribute(attribute, type, match);
}
if (name === 'aria-hidden' && regex_heading_tags.test(node.name)) {
w.a11y_hidden(attribute, node.name);
}
// aria-proptypes
let value = get_static_value(attribute);
const schema = aria.get(/** @type {ARIAProperty} */ (name));
if (schema !== undefined) {
validate_aria_attribute_value(attribute, /** @type {ARIAProperty} */ (name), schema, value);
}
// aria-activedescendant-has-tabindex
if (
name === 'aria-activedescendant' &&
!is_dynamic_element &&
!is_interactive &&
!attribute_map.has('tabindex') &&
!has_spread
) {
w.a11y_aria_activedescendant_has_tabindex(attribute);
}
}
switch (name) {
// aria-role
case 'role': {
if (invisible_elements.includes(node.name)) {
// aria-unsupported-elements
w.a11y_misplaced_role(attribute, node.name);
}
const value = get_static_value(attribute);
if (typeof value !== 'string') {
break;
}
for (const c_r of value.split(regex_whitespaces)) {
const current_role = /** @type {ARIARoleDefinitionKey} current_role */ (c_r);
if (current_role && is_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);
w.a11y_unknown_role(attribute, current_role, match);
}
// no-redundant-roles
if (
current_role === get_implicit_role(node.name, attribute_map) &&
// <ul role="list"> is ok because CSS list-style:none removes the semantics and this is a way to bring them back
!['ul', 'ol', 'li', 'menu'].includes(node.name) &&
// <a role="link" /> is ok because without href the a tag doesn't have a role of link
!(node.name === 'a' && !attribute_map.has('href'))
) {
w.a11y_no_redundant_roles(attribute, current_role);
}
// Footers and headers are special cases, and should not have redundant roles unless they are the children of sections or articles.
const is_parent_section_or_article = is_parent(context.path, ['section', 'article']);
if (!is_parent_section_or_article) {
const has_nested_redundant_role =
current_role === a11y_nested_implicit_semantics.get(node.name);
if (has_nested_redundant_role) {
w.a11y_no_redundant_roles(attribute, current_role);
}
}
// role-has-required-aria-props
if (
!is_dynamic_element &&
!is_semantic_role_element(current_role, node.name, attribute_map)
) {
const role = roles_map.get(current_role);
if (role) {
const required_role_props = Object.keys(role.requiredProps);
const has_missing_props =
!has_spread &&
required_role_props.some((prop) => !attributes.find((a) => a.name === prop));
if (has_missing_props) {
w.a11y_role_has_required_aria_props(
attribute,
current_role,
list(
required_role_props.map((v) => `"${v}"`),
'and'
)
);
}
}
}
// interactive-supports-focus
if (
!has_spread &&
!has_disabled_attribute(attribute_map) &&
!is_hidden_from_screen_reader(node.name, attribute_map) &&
!is_presentation_role(current_role) &&
is_interactive_roles(current_role) &&
is_static &&
!attribute_map.get('tabindex')
) {
const has_interactive_handlers = [...handlers].some((handler) =>
a11y_interactive_handlers.includes(handler)
);
if (has_interactive_handlers) {
w.a11y_interactive_supports_focus(node, current_role);
}
}
// no-interactive-element-to-noninteractive-role
if (
!has_spread &&
is_interactive &&
(is_non_interactive_roles(current_role) || is_presentation_role(current_role))
) {
w.a11y_no_interactive_element_to_noninteractive_role(node, node.name, current_role);
}
// no-noninteractive-element-to-interactive-role
if (
!has_spread &&
is_non_interactive &&
is_interactive_roles(current_role) &&
!a11y_non_interactive_element_to_interactive_role_exceptions[node.name]?.includes(
current_role
)
) {
w.a11y_no_noninteractive_element_to_interactive_role(node, node.name, current_role);
}
}
break;
}
// no-access-key
case 'accesskey': {
w.a11y_accesskey(attribute);
break;
}
// no-autofocus
case 'autofocus': {
if (node.name !== 'dialog' && !is_parent(context.path, ['dialog'])) {
w.a11y_autofocus(attribute);
}
break;
}
// scope
case 'scope': {
if (!is_dynamic_element && node.name !== 'th') {
w.a11y_misplaced_scope(attribute);
}
break;
}
// tabindex-no-positive
case 'tabindex': {
const value = get_static_value(attribute);
// @ts-ignore todo is tabindex=true correct case?
if (!isNaN(value) && +value > 0) {
w.a11y_positive_tabindex(attribute);
}
break;
}
}
}
const role = attribute_map.get('role');
const role_static_value = /** @type {ARIARoleDefinitionKey} */ (get_static_text_value(role));
// click-events-have-key-events
if (handlers.has('click')) {
const is_non_presentation_role =
role_static_value !== null && !is_presentation_role(role_static_value);
if (
!is_dynamic_element &&
!is_hidden_from_screen_reader(node.name, attribute_map) &&
(!role || is_non_presentation_role) &&
!is_interactive &&
!has_spread
) {
const has_key_event =
handlers.has('keydown') || handlers.has('keyup') || handlers.has('keypress');
if (!has_key_event) {
w.a11y_click_events_have_key_events(node);
}
}
}
const role_value = /** @type {ARIARoleDefinitionKey} */ (
role ? role_static_value : get_implicit_role(node.name, attribute_map)
);
// no-noninteractive-tabindex
if (!is_dynamic_element && !is_interactive && !is_interactive_roles(role_static_value)) {
const tab_index = attribute_map.get('tabindex');
const tab_index_value = get_static_text_value(tab_index);
if (tab_index && (tab_index_value === null || Number(tab_index_value) >= 0)) {
w.a11y_no_noninteractive_tabindex(node);
}
}
// role-supports-aria-props
if (typeof role_value === 'string' && roles_map.has(role_value)) {
const { props } = /** @type {ARIARoleDefinition} */ (roles_map.get(role_value));
const invalid_aria_props = aria.keys().filter((attribute) => !(attribute in props));
const is_implicit = role_value && role === undefined;
for (const attr of attributes) {
if (invalid_aria_props.includes(/** @type {ARIAProperty} */ (attr.name))) {
if (is_implicit) {
w.a11y_role_supports_aria_props_implicit(attr, attr.name, role_value, node.name);
} else {
w.a11y_role_supports_aria_props(attr, attr.name, role_value);
}
}
}
}
// no-noninteractive-element-interactions
if (
!has_spread &&
!has_contenteditable_attr &&
!is_hidden_from_screen_reader(node.name, attribute_map) &&
!is_presentation_role(role_static_value) &&
((!is_interactive && is_non_interactive_roles(role_static_value)) ||
(is_non_interactive && !role))
) {
const has_interactive_handlers = [...handlers].some((handler) =>
a11y_recommended_interactive_handlers.includes(handler)
);
if (has_interactive_handlers) {
w.a11y_no_noninteractive_element_interactions(node, node.name);
}
}
// no-static-element-interactions
if (
!has_spread &&
(!role || role_static_value !== null) &&
!is_hidden_from_screen_reader(node.name, attribute_map) &&
!is_presentation_role(role_static_value) &&
!is_interactive &&
!is_interactive_roles(role_static_value) &&
!is_non_interactive &&
!is_non_interactive_roles(role_static_value) &&
!is_abstract_role(role_static_value)
) {
const interactive_handlers = [...handlers].filter((handler) =>
a11y_interactive_handlers.includes(handler)
);
if (interactive_handlers.length > 0) {
w.a11y_no_static_element_interactions(node, node.name, list(interactive_handlers));
}
}
if (!has_spread && handlers.has('mouseover') && !handlers.has('focus')) {
w.a11y_mouse_events_have_key_events(node, 'mouseover', 'focus');
}
if (!has_spread && handlers.has('mouseout') && !handlers.has('blur')) {
w.a11y_mouse_events_have_key_events(node, 'mouseout', 'blur');
}
// element-specific checks
const is_labelled =
attribute_map.has('aria-label') ||
attribute_map.has('aria-labelledby') ||
attribute_map.has('title');
switch (node.name) {
case 'a':
case 'button': {
const is_hidden =
get_static_value(attribute_map.get('aria-hidden')) === 'true' ||
get_static_value(attribute_map.get('inert')) !== null;
if (!has_spread && !is_hidden && !is_labelled && !has_content(node)) {
w.a11y_consider_explicit_label(node);
}
if (node.name === 'button') {
break;
}
const href = attribute_map.get('href') || attribute_map.get('xlink:href');
if (href) {
const href_value = get_static_text_value(href);
if (href_value !== null) {
if (href_value === '' || href_value === '#' || regex_js_prefix.test(href_value)) {
w.a11y_invalid_attribute(href, href_value, href.name);
}
}
} else if (!has_spread) {
const id_attribute = get_static_value(attribute_map.get('id'));
const name_attribute = get_static_value(attribute_map.get('name'));
const aria_disabled_attribute = get_static_value(attribute_map.get('aria-disabled'));
if (!id_attribute && !name_attribute && aria_disabled_attribute !== 'true') {
warn_missing_attribute(node, ['href']);
}
}
break;
}
case 'input': {
const type = attribute_map.get('type');
const type_value = get_static_text_value(type);
if (type_value === 'image' && !has_spread) {
const required_attributes = ['alt', 'aria-label', 'aria-labelledby'];
const has_attribute = required_attributes.some((name) => attribute_map.has(name));
if (!has_attribute) {
warn_missing_attribute(node, required_attributes, 'input type="image"');
}
}
// autocomplete-valid
const autocomplete = attribute_map.get('autocomplete');
if (type && autocomplete) {
const autocomplete_value = get_static_value(autocomplete);
if (!is_valid_autocomplete(autocomplete_value)) {
w.a11y_autocomplete_valid(
autocomplete,
/** @type {string} */ (autocomplete_value),
type_value ?? '...'
);
}
}
break;
}
case 'img': {
const alt_attribute = get_static_text_value(attribute_map.get('alt'));
const aria_hidden = get_static_value(attribute_map.get('aria-hidden'));
if (alt_attribute && !aria_hidden && !has_spread) {
if (regex_redundant_img_alt.test(alt_attribute)) {
w.a11y_img_redundant_alt(node);
}
}
break;
}
case 'label': {
/** @param {AST.TemplateNode} node */
const has_input_child = (node) => {
let has = false;
walk(
node,
{},
{
_(node, { next }) {
if (
node.type === 'SvelteElement' ||
node.type === 'SlotElement' ||
node.type === 'Component' ||
node.type === 'RenderTag' ||
(node.type === 'RegularElement' &&
(a11y_labelable.includes(node.name) || node.name === 'slot'))
) {
has = true;
} else {
next();
}
}
}
);
return has;
};
if (!has_spread && !attribute_map.has('for') && !has_input_child(node)) {
w.a11y_label_has_associated_control(node);
}
break;
}
case 'video': {
const aria_hidden_attribute = attribute_map.get('aria-hidden');
const aria_hidden_exist = aria_hidden_attribute && get_static_value(aria_hidden_attribute);
if (attribute_map.has('muted') || aria_hidden_exist === 'true' || has_spread) {
return;
}
if (!attribute_map.has('src')) {
// don't warn about missing captions if `<video>` has no `src` —
// could e.g. be playing a MediaStream
return;
}
let has_caption = false;
const track = /** @type {AST.RegularElement | undefined} */ (
node.fragment.nodes.find((i) => i.type === 'RegularElement' && i.name === 'track')
);
if (track) {
has_caption = track.attributes.some(
(a) =>
a.type === 'SpreadAttribute' ||
(a.type === 'Attribute' && a.name === 'kind' && get_static_value(a) === 'captions')
);
}
if (!has_caption) {
w.a11y_media_has_caption(node);
}
break;
}
case 'figcaption': {
if (!is_parent(context.path, ['figure'])) {
w.a11y_figcaption_parent(node);
}
break;
}
case 'figure': {
const children = node.fragment.nodes.filter((node) => {
if (node.type === 'Comment') return false;
if (node.type === 'Text') return regex_not_whitespace.test(node.data);
return true;
});
const index = children.findIndex(
(child) => child.type === 'RegularElement' && child.name === 'figcaption'
);
if (index !== -1 && index !== 0 && index !== children.length - 1) {
w.a11y_figcaption_index(children[index]);
}
break;
}
}
if (!has_spread && node.name !== 'a') {
const required_attributes = a11y_required_attributes[node.name];
if (required_attributes) {
const has_attribute = required_attributes.some((name) => attribute_map.has(name));
if (!has_attribute) {
warn_missing_attribute(node, required_attributes);
}
}
}
if (a11y_distracting_elements.includes(node.name)) {
// no-distracting-elements
w.a11y_distracting_elements(node, node.name);
}
// Check content
if (
!has_spread &&
!is_labelled &&
!has_contenteditable_binding &&
a11y_required_content.includes(node.name) &&
!has_content(node)
) {
w.a11y_missing_content(node, node.name);
}
}
/**
* @param {ARIARoleDefinitionKey} role
*/
function is_presentation_role(role) {
return presentation_roles.includes(role);
}
/**
* @param {string} tag_name
* @param {Map<string, AST.Attribute>} attribute_map
*/
function is_hidden_from_screen_reader(tag_name, attribute_map) {
if (tag_name === 'input') {
const type = get_static_value(attribute_map.get('type'));
if (type === 'hidden') {
return true;
}
}
const aria_hidden = attribute_map.get('aria-hidden');
if (!aria_hidden) return false;
const aria_hidden_value = get_static_value(aria_hidden);
if (aria_hidden_value === null) return true;
return aria_hidden_value === true || aria_hidden_value === 'true';
}
/**
* @param {Map<string, AST.Attribute>} attribute_map
*/
function has_disabled_attribute(attribute_map) {
const disabled_attr_value = get_static_value(attribute_map.get('disabled'));
if (disabled_attr_value) {
return true;
}
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') {
return true;
}
}
return false;
}
/**
* @param {string} tag_name
* @param {Map<string, AST.Attribute>} attribute_map
* @returns {typeof ElementInteractivity[keyof typeof ElementInteractivity]}
*/
function element_interactivity(tag_name, attribute_map) {
if (
interactive_element_role_schemas.some((schema) => match_schema(schema, tag_name, attribute_map))
) {
return ElementInteractivity.Interactive;
}
if (
tag_name !== 'header' &&
non_interactive_element_role_schemas.some((schema) =>
match_schema(schema, tag_name, attribute_map)
)
) {
return ElementInteractivity.NonInteractive;
}
if (
interactive_element_ax_object_schemas.some((schema) =>
match_schema(schema, tag_name, attribute_map)
)
) {
return ElementInteractivity.Interactive;
}
if (
non_interactive_element_ax_object_schemas.some((schema) =>
match_schema(schema, tag_name, attribute_map)
)
) {
return ElementInteractivity.NonInteractive;
}
return ElementInteractivity.Static;
}
/**
* @param {ARIARoleDefinitionKey} role
* @param {string} tag_name
* @param {Map<string, AST.Attribute>} attribute_map
*/
function is_semantic_role_element(role, tag_name, attribute_map) {
for (const [schema, ax_object] of elementAXObjects.entries()) {
if (
schema.name === tag_name &&
(!schema.attributes ||
schema.attributes.every(
/** @param {any} attr */
(attr) =>
attribute_map.has(attr.name) &&
get_static_value(attribute_map.get(attr.name)) === attr.value
))
) {
for (const name of ax_object) {
const roles = AXObjectRoles.get(name);
if (roles) {
for (const { name } of roles) {
if (name === role) {
return true;
}
}
}
}
}
}
return false;
}
/**
* @param {null | true | string} autocomplete
*/
function is_valid_autocomplete(autocomplete) {
if (autocomplete === true) {
return false;
} else if (!autocomplete) {
return true; // dynamic value
}
const tokens = autocomplete.trim().toLowerCase().split(regex_whitespaces);
if (typeof tokens[0] === 'string' && tokens[0].startsWith('section-')) {
tokens.shift();
}
if (address_type_tokens.includes(tokens[0])) {
tokens.shift();
}
if (autofill_field_name_tokens.includes(tokens[0])) {
tokens.shift();
} else {
if (contact_type_tokens.includes(tokens[0])) {
tokens.shift();
}
if (autofill_contact_field_name_tokens.includes(tokens[0])) {
tokens.shift();
} else {
return false;
}
}
if (tokens[0] === 'webauthn') {
tokens.shift();
}
return tokens.length === 0;
}
/** @param {Map<string, AST.Attribute>} attribute_map */
function input_implicit_role(attribute_map) {
const type_attribute = attribute_map.get('type');
if (!type_attribute) return;
const type = get_static_text_value(type_attribute);
if (!type) return;
const list_attribute_exists = attribute_map.has('list');
if (list_attribute_exists && combobox_if_list.includes(type)) {
return 'combobox';
}
return input_type_to_implicit_role.get(type);
}
/** @param {Map<string, AST.Attribute>} attribute_map */
function menuitem_implicit_role(attribute_map) {
const type_attribute = attribute_map.get('type');
if (!type_attribute) return;
const type = get_static_text_value(type_attribute);
if (!type) return;
return menuitem_type_to_implicit_role.get(type);
}
/**
* @param {string} name
* @param {Map<string, AST.Attribute>} attribute_map
*/
function get_implicit_role(name, attribute_map) {
if (name === 'menuitem') {
return menuitem_implicit_role(attribute_map);
} else if (name === 'input') {
return input_implicit_role(attribute_map);
} else {
return a11y_implicit_semantics.get(name);
}
}
/**
* @param {ARIARoleDefinitionKey} role
*/
function is_non_interactive_roles(role) {
return non_interactive_roles.includes(role);
}
/**
* @param {ARIARoleDefinitionKey} role
*/
function is_interactive_roles(role) {
return interactive_roles.includes(role);
}
/**
* @param {ARIARoleDefinitionKey} role
*/
function is_abstract_role(role) {
return abstract_roles.includes(role);
}
/**
* @param {AST.Attribute | undefined} attribute
*/
function get_static_text_value(attribute) {
const value = get_static_value(attribute);
if (value === true) return null;
return value;
}
/**
* @param {AST.Attribute | undefined} attribute
*/
function get_static_value(attribute) {
if (!attribute) return null;
if (attribute.value === true) return true;
if (is_text_attribute(attribute)) return attribute.value[0].data;
return null;
}
/**
* @param {AST.RegularElement | AST.SvelteElement} element
*/
function has_content(element) {
for (const node of element.fragment.nodes) {
if (node.type === 'Text') {
if (node.data.trim() === '') {
continue;
}
}
if (node.type === 'RegularElement' || node.type === 'SvelteElement') {
if (node.attributes.some((a) => a.type === 'Attribute' && a.name === 'popover')) {
continue;
}
if (
node.name === 'img' &&
node.attributes.some((node) => node.type === 'Attribute' && node.name === 'alt')
) {
return true;
}
if (node.name === 'selectedcontent') {
return true;
}
if (!has_content(node)) {
continue;
}
}
// assume everything else has content — this will result in false positives
// (e.g. an empty `{#if ...}{/if}`) but that's probably fine
return true;
}
}
/**
* @param {ARIARoleRelationConcept} schema
* @param {string} tag_name
* @param {Map<string, AST.Attribute>} attribute_map
*/
function match_schema(schema, tag_name, attribute_map) {
if (schema.name !== tag_name) return false;
if (!schema.attributes) return true;
return schema.attributes.every((schema_attribute) => {
const attribute = attribute_map.get(schema_attribute.name);
if (!attribute) return false;
if (schema_attribute.value && schema_attribute.value !== get_static_text_value(attribute)) {
return false;
}
return true;
});
}
/**
* @param {AST.SvelteNode[]} path
* @param {string[]} elements
*/
function is_parent(path, elements) {
let i = path.length;
while (i--) {
const parent = path[i];
if (parent.type === 'SvelteElement') return true; // unknown, play it safe, so we don't warn
if (parent.type === 'RegularElement') {
return elements.includes(parent.name);
}
}
return false;
}
/**
* @param {AST.Attribute} attribute
* @param {ARIAProperty} name
* @param {ARIAPropertyDefinition} schema
* @param {string | true | null} value
*/
function validate_aria_attribute_value(attribute, name, schema, value) {
const type = schema.type;
if (value === null) return;
if (value === true) value = '';
switch (type) {
case 'id':
case 'string': {
if (value === '') {
w.a11y_incorrect_aria_attribute_type(attribute, name, 'non-empty string');
}
break;
}
case 'number': {
if (value === '' || isNaN(+value)) {
w.a11y_incorrect_aria_attribute_type(attribute, name, 'number');
}
break;
}
case 'boolean': {
if (value !== 'true' && value !== 'false') {
w.a11y_incorrect_aria_attribute_type_boolean(attribute, name);
}
break;
}
case 'idlist': {
if (value === '') {
w.a11y_incorrect_aria_attribute_type_idlist(attribute, name);
}
break;
}
case 'integer': {
if (value === '' || !Number.isInteger(+value)) {
w.a11y_incorrect_aria_attribute_type_integer(attribute, name);
}
break;
}
case '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}"`))
);
}
break;
}
case '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}"`))
);
}
break;
}
case 'tristate': {
if (value !== 'true' && value !== 'false' && value !== 'mixed') {
w.a11y_incorrect_aria_attribute_type_tristate(attribute, name);
}
break;
}
}
}
/**
* @param {AST.RegularElement |AST.SvelteElement} node
* @param {string[]} attributes
* @param {string} name
*/
function warn_missing_attribute(node, attributes, name = node.name) {
const article =
regex_starts_with_vowel.test(attributes[0]) || attributes[0] === 'href' ? 'an' : 'a';
const sequence =
attributes.length > 1
? attributes.slice(0, -1).join(', ') + ` or ${attributes[attributes.length - 1]}`
: attributes[0];
w.a11y_missing_attribute(node, name, article, sequence);
}
@@ -0,0 +1,125 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../../types' */
import * as e from '../../../../errors.js';
import { is_text_attribute } from '../../../../utils/ast.js';
import * as w from '../../../../warnings.js';
import { is_custom_element_node } from '../../../nodes.js';
import { regex_only_whitespaces } from '../../../patterns.js';
/**
* @param {AST.Attribute} attribute
*/
export function validate_attribute_name(attribute) {
if (
attribute.name.includes(':') &&
!attribute.name.startsWith('xmlns:') &&
!attribute.name.startsWith('xlink:') &&
!attribute.name.startsWith('xml:')
) {
w.attribute_illegal_colon(attribute);
}
}
/**
* @param {AST.Attribute} attribute
* @param {AST.ElementLike} parent
*/
export function validate_attribute(attribute, parent) {
if (
Array.isArray(attribute.value) &&
attribute.value.length === 1 &&
attribute.value[0].type === 'ExpressionTag' &&
(parent.type === 'Component' ||
parent.type === 'SvelteComponent' ||
parent.type === 'SvelteSelf' ||
(parent.type === 'RegularElement' && is_custom_element_node(parent)))
) {
w.attribute_quoted(attribute);
}
if (attribute.value === true || !Array.isArray(attribute.value) || attribute.value.length === 1) {
return;
}
const is_quoted = attribute.value.at(-1)?.end !== attribute.end;
if (!is_quoted) {
e.attribute_unquoted_sequence(attribute);
}
}
/**
* @param {Context} context
* @param {AST.Attribute} attribute
* @param {boolean} is_component
*/
export function validate_slot_attribute(context, attribute, is_component = false) {
const parent = context.path.at(-2);
let owner = undefined;
if (parent?.type === 'SnippetBlock') {
if (!is_text_attribute(attribute)) {
e.slot_attribute_invalid(attribute);
}
return;
}
let i = context.path.length;
while (i--) {
const ancestor = context.path[i];
if (
!owner &&
(ancestor.type === 'Component' ||
ancestor.type === 'SvelteComponent' ||
ancestor.type === 'SvelteSelf' ||
ancestor.type === 'SvelteElement' ||
(ancestor.type === 'RegularElement' && is_custom_element_node(ancestor)))
) {
owner = ancestor;
}
}
if (owner) {
if (
owner.type === 'Component' ||
owner.type === 'SvelteComponent' ||
owner.type === 'SvelteSelf'
) {
if (owner !== parent) {
if (!is_component) {
e.slot_attribute_invalid_placement(attribute);
}
} else {
if (!is_text_attribute(attribute)) {
e.slot_attribute_invalid(attribute);
}
const name = attribute.value[0].data;
if (context.state.component_slots.has(name)) {
e.slot_attribute_duplicate(attribute, name, owner.name);
}
context.state.component_slots.add(name);
if (name === 'default') {
for (const node of owner.fragment.nodes) {
if (node.type === 'Text' && regex_only_whitespaces.test(node.data)) {
continue;
}
if (node.type === 'RegularElement' || node.type === 'SvelteFragment') {
if (node.attributes.some((a) => a.type === 'Attribute' && a.name === 'slot')) {
continue;
}
}
e.slot_default_duplicate(node);
}
}
}
}
} else if (!is_component) {
e.slot_attribute_invalid_placement(attribute);
}
}
@@ -0,0 +1,177 @@
/** @import { Expression } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { AnalysisState, Context } from '../../types' */
import * as e from '../../../../errors.js';
import { get_attribute_expression, is_expression_attribute } from '../../../../utils/ast.js';
import { determine_slot } from '../../../../utils/slot.js';
import {
validate_attribute,
validate_attribute_name,
validate_slot_attribute
} from './attribute.js';
import { mark_subtree_dynamic } from './fragment.js';
import { is_resolved_snippet } from './snippets.js';
/**
* @param {AST.Component | AST.SvelteComponent | AST.SvelteSelf} node
* @param {Context} context
*/
export function visit_component(node, context) {
node.metadata.path = [...context.path];
// link this node to all the snippets that it could render, so that we can prune CSS correctly
node.metadata.snippets = new Set();
// 'resolved' means we know which snippets this component might render. if it is `false`,
// then `node.metadata.snippets` is populated with every locally defined snippet
// once analysis is complete
let resolved = true;
for (const attribute of node.attributes) {
if (attribute.type === 'SpreadAttribute' || attribute.type === 'BindDirective') {
resolved = false;
continue;
}
if (attribute.type !== 'Attribute' || !is_expression_attribute(attribute)) {
continue;
}
const expression = get_attribute_expression(attribute);
// given an attribute like `foo={bar}`, if `bar` resolves to an import or a prop
// then we know it doesn't reference a locally defined snippet. if it resolves
// to a `{#snippet bar()}` then we know _which_ snippet it resolves to. in all
// other cases, we can't know (without much more complex static analysis) which
// snippets the component might render, so we treat the component as unresolved
if (expression.type === 'Identifier') {
const binding = context.state.scope.get(expression.name);
resolved &&= is_resolved_snippet(binding);
if (binding?.initial?.type === 'SnippetBlock') {
node.metadata.snippets.add(binding.initial);
}
} else if (expression.type !== 'Literal') {
resolved = false;
}
}
if (resolved) {
for (const child of node.fragment.nodes) {
if (child.type === 'SnippetBlock') {
node.metadata.snippets.add(child);
}
}
}
context.state.analysis.snippet_renderers.set(node, resolved);
mark_subtree_dynamic(context.path);
for (const attribute of node.attributes) {
if (
attribute.type !== 'Attribute' &&
attribute.type !== 'SpreadAttribute' &&
attribute.type !== 'LetDirective' &&
attribute.type !== 'OnDirective' &&
attribute.type !== 'BindDirective' &&
attribute.type !== 'AttachTag'
) {
e.component_invalid_directive(attribute);
}
if (
attribute.type === 'OnDirective' &&
(attribute.modifiers.length > 1 || attribute.modifiers.some((m) => m !== 'once'))
) {
e.event_handler_invalid_component_modifier(attribute);
}
if (attribute.type === 'Attribute') {
if (context.state.analysis.runes) {
validate_attribute(attribute, node);
if (is_expression_attribute(attribute)) {
disallow_unparenthesized_sequences(
get_attribute_expression(attribute),
context.state.analysis.source
);
}
}
validate_attribute_name(attribute);
if (attribute.name === 'slot') {
validate_slot_attribute(context, attribute, true);
}
}
if (attribute.type === 'BindDirective' && attribute.name !== 'this') {
context.state.analysis.uses_component_bindings = true;
}
if (attribute.type === 'AttachTag') {
disallow_unparenthesized_sequences(attribute.expression, context.state.analysis.source);
}
}
// If the component has a slot attribute — `<Foo slot="whatever" .../>` —
// then `let:` directives apply to other attributes, instead of just the
// top-level contents of the component. Yes, this is very weird.
const default_state = determine_slot(node)
? context.state
: { ...context.state, scope: node.metadata.scopes.default };
for (const attribute of node.attributes) {
context.visit(attribute, attribute.type === 'LetDirective' ? default_state : context.state);
}
/** @type {AST.Comment[]} */
let comments = [];
/** @type {Record<string, AST.Fragment['nodes']>} */
const nodes = { default: [] };
for (const child of node.fragment.nodes) {
if (child.type === 'Comment') {
comments.push(child);
continue;
}
const slot_name = determine_slot(child) ?? 'default';
(nodes[slot_name] ??= []).push(...comments, child);
if (slot_name !== 'default') comments = [];
}
/** @type {Set<string>} */
const component_slots = new Set();
for (const slot_name in nodes) {
/** @type {AnalysisState} */
const state = {
...context.state,
scope: node.metadata.scopes[slot_name],
parent_element: null,
component_slots
};
context.visit({ ...node.fragment, nodes: nodes[slot_name] }, state);
}
}
/**
* @param {Expression} expression
* @param {string} source
*/
function disallow_unparenthesized_sequences(expression, source) {
if (expression.type === 'SequenceExpression') {
let i = /** @type {number} */ (expression.start);
while (--i > 0) {
const char = source[i];
if (char === '(') break; // parenthesized sequence expressions are ok
if (char === '{') e.attribute_invalid_sequence_expression(expression);
}
}
}
@@ -0,0 +1,160 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../../types' */
import { get_attribute_expression, is_expression_attribute } from '../../../../utils/ast.js';
import { regex_illegal_attribute_character } from '../../../patterns.js';
import * as e from '../../../../errors.js';
import * as w from '../../../../warnings.js';
import {
validate_attribute,
validate_attribute_name,
validate_slot_attribute
} from './attribute.js';
const EVENT_MODIFIERS = [
'preventDefault',
'stopPropagation',
'stopImmediatePropagation',
'capture',
'once',
'passive',
'nonpassive',
'self',
'trusted'
];
/**
* @param {AST.RegularElement | AST.SvelteElement} node
* @param {Context} context
*/
export function validate_element(node, context) {
let has_animate_directive = false;
/** @type {AST.TransitionDirective | null} */
let in_transition = null;
/** @type {AST.TransitionDirective | null} */
let out_transition = null;
for (const attribute of node.attributes) {
if (attribute.type === 'Attribute') {
const is_expression = is_expression_attribute(attribute);
if (context.state.analysis.runes) {
validate_attribute(attribute, node);
if (is_expression) {
const expression = get_attribute_expression(attribute);
if (expression.type === 'SequenceExpression') {
let i = /** @type {number} */ (expression.start);
while (--i > 0) {
const char = context.state.analysis.source[i];
if (char === '(') break; // parenthesized sequence expressions are ok
if (char === '{') e.attribute_invalid_sequence_expression(expression);
}
}
}
}
if (regex_illegal_attribute_character.test(attribute.name)) {
e.attribute_invalid_name(attribute, attribute.name);
}
if (attribute.name.startsWith('on') && attribute.name.length > 2) {
if (!is_expression) {
e.attribute_invalid_event_handler(attribute);
}
const value = get_attribute_expression(attribute);
if (
value.type === 'Identifier' &&
value.name === attribute.name &&
!context.state.scope.get(value.name)
) {
w.attribute_global_event_reference(attribute, attribute.name);
}
}
if (attribute.name === 'slot') {
/** @type {AST.RegularElement | AST.SvelteElement | AST.Component | AST.SvelteComponent | AST.SvelteSelf | undefined} */
validate_slot_attribute(context, attribute);
}
if (attribute.name === 'is') {
w.attribute_avoid_is(attribute);
}
const correct_name = react_attributes.get(attribute.name);
if (correct_name) {
w.attribute_invalid_property_name(attribute, attribute.name, correct_name);
}
validate_attribute_name(attribute);
} else if (attribute.type === 'AnimateDirective') {
const parent = context.path.at(-2);
if (parent?.type !== 'EachBlock') {
e.animation_invalid_placement(attribute);
} else if (!parent.key) {
e.animation_missing_key(attribute);
} else if (
parent.body.nodes.filter(
(n) =>
n.type !== 'Comment' &&
n.type !== 'ConstTag' &&
(n.type !== 'Text' || n.data.trim() !== '')
).length > 1
) {
e.animation_invalid_placement(attribute);
}
if (has_animate_directive) {
e.animation_duplicate(attribute);
} else {
has_animate_directive = true;
}
} else if (attribute.type === 'TransitionDirective') {
const existing = /** @type {AST.TransitionDirective | null} */ (
(attribute.intro && in_transition) || (attribute.outro && out_transition)
);
if (existing) {
const a = existing.intro ? (existing.outro ? 'transition' : 'in') : 'out';
const b = attribute.intro ? (attribute.outro ? 'transition' : 'in') : 'out';
if (a === b) {
e.transition_duplicate(attribute, a);
} else {
e.transition_conflict(attribute, a, b);
}
}
if (attribute.intro) in_transition = attribute;
if (attribute.outro) out_transition = attribute;
} else if (attribute.type === 'OnDirective') {
let has_passive_modifier = false;
let conflicting_passive_modifier = '';
for (const modifier of attribute.modifiers) {
if (!EVENT_MODIFIERS.includes(modifier)) {
const list = `${EVENT_MODIFIERS.slice(0, -1).join(', ')} or ${EVENT_MODIFIERS.at(-1)}`;
e.event_handler_invalid_modifier(attribute, list);
}
if (modifier === 'passive') {
has_passive_modifier = true;
} else if (modifier === 'nonpassive' || modifier === 'preventDefault') {
conflicting_passive_modifier = modifier;
}
if (has_passive_modifier && conflicting_passive_modifier) {
e.event_handler_invalid_modifier_combination(
attribute,
'passive',
conflicting_passive_modifier
);
}
}
}
}
}
const react_attributes = new Map([
['className', 'class'],
['htmlFor', 'for']
]);
@@ -0,0 +1,15 @@
/** @import { AST } from '#compiler' */
/**
* @param {AST.SvelteNode[]} path
*/
export function mark_subtree_dynamic(path) {
let i = path.length;
while (i--) {
const node = path[i];
if (node.type === 'Fragment') {
if (node.metadata.dynamic) return;
node.metadata.dynamic = true;
}
}
}
@@ -0,0 +1,24 @@
/** @import { ArrowFunctionExpression, FunctionDeclaration, FunctionExpression } from 'estree' */
/** @import { Context } from '../../types' */
/**
* @param {ArrowFunctionExpression | FunctionExpression | FunctionDeclaration} node
* @param {Context} context
*/
export function visit_function(node, context) {
if (context.state.expression) {
for (const [name] of context.state.scope.references) {
const binding = context.state.scope.get(name);
if (binding && binding.scope !== context.state.scope) {
context.state.expression.references.add(binding);
}
}
}
context.next({
...context.state,
function_depth: context.state.function_depth + 1,
expression: null
});
}
@@ -0,0 +1,17 @@
/** @import { Binding } from '#compiler' */
/**
* Returns `true` if a binding unambiguously resolves to a specific
* snippet declaration, or is external to the current component
* @param {Binding | null} binding
*/
export function is_resolved_snippet(binding) {
return (
!binding ||
binding.declaration_kind === 'import' ||
binding.kind === 'prop' ||
binding.kind === 'rest_prop' ||
binding.kind === 'bindable_prop' ||
binding?.initial?.type === 'SnippetBlock'
);
}
@@ -0,0 +1,16 @@
/** @import { AST } from '#compiler' */
import * as e from '../../../../errors.js';
/**
* @param {AST.SvelteBody | AST.SvelteDocument | AST.SvelteOptionsRaw | AST.SvelteWindow} node
*/
export function disallow_children(node) {
const { nodes } = node.fragment;
if (nodes.length > 0) {
const first = nodes[0];
const last = nodes[nodes.length - 1];
e.svelte_meta_invalid_content({ start: first.start, end: last.end }, node.name);
}
}
@@ -0,0 +1,300 @@
/** @import { AssignmentExpression, Expression, Literal, Node, Pattern, Super, UpdateExpression, VariableDeclarator } from 'estree' */
/** @import { AST, Binding } from '#compiler' */
/** @import { AnalysisState, Context } from '../../types' */
/** @import { Scope } from '../../../scope' */
/** @import { NodeLike } from '../../../../errors.js' */
import * as e from '../../../../errors.js';
import { extract_identifiers, get_parent } from '../../../../utils/ast.js';
import * as w from '../../../../warnings.js';
import * as b from '#compiler/builders';
import { get_rune } from '../../../scope.js';
import { get_name } from '../../../nodes.js';
/**
* @param {AssignmentExpression | UpdateExpression | AST.BindDirective} node
* @param {Pattern | Expression} argument
* @param {Context} context
*/
export function validate_assignment(node, argument, context) {
validate_no_const_assignment(node, argument, context.state.scope, node.type === 'BindDirective');
if (argument.type === 'Identifier') {
const binding = context.state.scope.get(argument.name);
if (context.state.analysis.runes) {
if (
context.state.analysis.props_id != null &&
binding?.node === context.state.analysis.props_id
) {
e.constant_assignment(node, '$props.id()');
}
if (binding?.kind === 'each') {
e.each_item_invalid_assignment(node);
}
}
if (binding?.kind === 'snippet') {
e.snippet_parameter_assignment(node);
}
}
if (argument.type === 'MemberExpression' && argument.object.type === 'ThisExpression') {
const name =
argument.computed && argument.property.type !== 'Literal'
? null
: get_name(argument.property);
const field = name !== null && context.state.state_fields?.get(name);
// check we're not assigning to a state field before its declaration in the constructor
if (field && field.node.type === 'AssignmentExpression' && node !== field.node) {
let i = context.path.length;
while (i--) {
const parent = context.path[i];
if (
parent.type === 'FunctionDeclaration' ||
parent.type === 'FunctionExpression' ||
parent.type === 'ArrowFunctionExpression'
) {
const grandparent = get_parent(context.path, i - 1);
if (
grandparent.type === 'MethodDefinition' &&
grandparent.kind === 'constructor' &&
/** @type {number} */ (node.start) < /** @type {number} */ (field.node.start)
) {
e.state_field_invalid_assignment(node);
}
break;
}
}
}
}
}
/**
* @param {NodeLike} node
* @param {Pattern | Expression} argument
* @param {Scope} scope
* @param {boolean} is_binding
*/
export function validate_no_const_assignment(node, argument, scope, is_binding) {
if (argument.type === 'ArrayPattern') {
for (const element of argument.elements) {
if (element) {
validate_no_const_assignment(node, element, scope, is_binding);
}
}
} else if (argument.type === 'ObjectPattern') {
for (const element of argument.properties) {
if (element.type === 'Property') {
validate_no_const_assignment(node, element.value, scope, is_binding);
}
}
} else if (argument.type === 'Identifier') {
const binding = scope.get(argument.name);
if (
binding?.declaration_kind === 'import' ||
(binding?.declaration_kind === 'const' && binding.kind !== 'each')
) {
// e.invalid_const_assignment(
// node,
// is_binding,
// // This takes advantage of the fact that we don't assign initial for let directives and then/catch variables.
// // If we start doing that, we need another property on the binding to differentiate, or give up on the more precise error message.
// binding.kind !== 'state' &&
// binding.kind !== 'raw_state' &&
// (binding.kind !== 'normal' || !binding.initial)
// );
// TODO have a more specific error message for assignments to things like `{:then foo}`
const thing = binding.declaration_kind === 'import' ? 'import' : 'constant';
if (is_binding) {
e.constant_binding(node, thing);
} else {
e.constant_assignment(node, thing);
}
}
}
}
/**
* Validates that the opening of a control flow block is `{` immediately followed by the expected character.
* In legacy mode whitespace is allowed inbetween. TODO remove once legacy mode is gone and move this into parser instead.
* @param {{start: number; end: number}} node
* @param {AnalysisState} state
* @param {string} expected
*/
export function validate_opening_tag(node, state, expected) {
if (state.analysis.source[node.start + 1] !== expected) {
// avoid a sea of red and only mark the first few characters
e.block_unexpected_character({ start: node.start, end: node.start + 5 }, expected);
}
}
/**
* @param {AST.Fragment | null | undefined} node
* @param {Context} context
*/
export function validate_block_not_empty(node, context) {
if (!node) return;
// Assumption: If the block has zero elements, someone's in the middle of typing it out,
// so don't warn in that case because it would be distracting.
if (node.nodes.length === 1 && node.nodes[0].type === 'Text' && !node.nodes[0].raw.trim()) {
w.block_empty(node.nodes[0]);
}
}
/**
* @param {VariableDeclarator} node
* @param {AnalysisState} state
*/
export function ensure_no_module_import_conflict(node, state) {
const ids = extract_identifiers(node.id);
for (const id of ids) {
if (
state.ast_type === 'instance' &&
state.scope === state.analysis.instance.scope &&
state.analysis.module.scope.get(id.name)?.declaration_kind === 'import'
) {
e.declaration_duplicate_module_import(node.id);
}
}
}
/**
* A 'safe' identifier means that the `foo` in `foo.bar` or `foo()` will not
* call functions that require component context to exist
* @param {Expression | Super} expression
* @param {Scope} scope
*/
export function is_safe_identifier(expression, scope) {
let node = expression;
while (node.type === 'MemberExpression') node = node.object;
if (node.type !== 'Identifier') return false;
const binding = scope.get(node.name);
if (!binding) return true;
if (binding.kind === 'store_sub') {
return is_safe_identifier({ name: node.name.slice(1), type: 'Identifier' }, scope);
}
return (
binding.declaration_kind !== 'import' &&
binding.kind !== 'prop' &&
binding.kind !== 'bindable_prop' &&
binding.kind !== 'rest_prop'
);
}
/**
* @param {Expression | Literal | Super} node
* @param {Context} context
* @returns {boolean}
*/
export function is_pure(node, context) {
if (node.type === 'Literal') {
return true;
}
if (node.type === 'CallExpression') {
if (!is_pure(node.callee, context)) {
return false;
}
for (let arg of node.arguments) {
if (!is_pure(arg.type === 'SpreadElement' ? arg.argument : arg, context)) {
return false;
}
}
return true;
}
if (node.type !== 'Identifier' && node.type !== 'MemberExpression') {
return false;
}
if (get_rune(b.call(node), context.state.scope) === '$effect.tracking') {
return false;
}
/** @type {Expression | Super | null} */
let left = node;
while (left.type === 'MemberExpression') {
left = left.object;
}
if (!left) return false;
if (left.type === 'Identifier') {
const binding = context.state.scope.get(left.name);
if (binding === null) return true; // globals are assumed to be safe
} else if (is_pure(left, context)) {
return true;
}
// TODO add more cases (safe Svelte imports, etc)
return false;
}
/**
* Checks if the name is valid, which it is when it's not starting with (or is) a dollar sign or if it's a function parameter.
* The second argument is the depth of the scope, which is there for backwards compatibility reasons: In Svelte 4, you
* were allowed to define `$`-prefixed variables anywhere below the top level of components. Once legacy mode is gone, this
* argument can be removed / the call sites adjusted accordingly.
* @param {Binding | null} binding
* @param {number | undefined} [function_depth]
*/
export function validate_identifier_name(binding, function_depth) {
if (!binding) return;
const declaration_kind = binding.declaration_kind;
if (
declaration_kind !== 'synthetic' &&
declaration_kind !== 'param' &&
declaration_kind !== 'rest_param' &&
(!function_depth || function_depth <= 1)
) {
const node = binding.node;
if (node.name === '$') {
e.dollar_binding_invalid(node);
} else if (
node.name.startsWith('$') &&
// import type { $Type } from "" - these are normally already filtered out,
// but for the migration they aren't, and throwing here is preventing the migration to complete
// TODO -> once migration script is gone we can remove this check
!(
binding.initial?.type === 'ImportDeclaration' &&
/** @type {any} */ (binding.initial).importKind === 'type'
)
) {
e.dollar_prefix_invalid(node);
}
}
}
/**
* Checks that the exported name is not a derived or reassigned state variable.
* @param {Node} node
* @param {Scope} scope
* @param {string} name
*/
export function validate_export(node, scope, name) {
const binding = scope.get(name);
if (!binding) return;
if (binding.kind === 'derived') {
e.derived_invalid_export(node);
}
if ((binding.kind === 'state' || binding.kind === 'raw_state') && binding.reassigned) {
e.state_invalid_export(node);
}
}
@@ -0,0 +1,719 @@
/** @import * as ESTree from 'estree' */
/** @import { AST, ValidatedCompileOptions, ValidatedModuleCompileOptions } from '#compiler' */
/** @import { ComponentAnalysis, Analysis } from '../../types' */
/** @import { Visitors, ComponentClientTransformState, ClientTransformState } from './types' */
import { walk } from 'zimmerframe';
import * as b from '#compiler/builders';
import { build_getter, is_state_source } from './utils.js';
import { render_stylesheet } from '../css/index.js';
import { dev, filename } from '../../../state.js';
import { AnimateDirective } from './visitors/AnimateDirective.js';
import { ArrowFunctionExpression } from './visitors/ArrowFunctionExpression.js';
import { AssignmentExpression } from './visitors/AssignmentExpression.js';
import { Attribute } from './visitors/Attribute.js';
import { AwaitBlock } from './visitors/AwaitBlock.js';
import { AwaitExpression } from './visitors/AwaitExpression.js';
import { BinaryExpression } from './visitors/BinaryExpression.js';
import { BindDirective } from './visitors/BindDirective.js';
import { BlockStatement } from './visitors/BlockStatement.js';
import { BreakStatement } from './visitors/BreakStatement.js';
import { CallExpression } from './visitors/CallExpression.js';
import { ClassBody } from './visitors/ClassBody.js';
import { Comment } from './visitors/Comment.js';
import { Component } from './visitors/Component.js';
import { ConstTag } from './visitors/ConstTag.js';
import { DebugTag } from './visitors/DebugTag.js';
import { EachBlock } from './visitors/EachBlock.js';
import { ExportNamedDeclaration } from './visitors/ExportNamedDeclaration.js';
import { ExpressionStatement } from './visitors/ExpressionStatement.js';
import { ForOfStatement } from './visitors/ForOfStatement.js';
import { Fragment } from './visitors/Fragment.js';
import { FunctionDeclaration } from './visitors/FunctionDeclaration.js';
import { FunctionExpression } from './visitors/FunctionExpression.js';
import { HtmlTag } from './visitors/HtmlTag.js';
import { Identifier } from './visitors/Identifier.js';
import { IfBlock } from './visitors/IfBlock.js';
import { KeyBlock } from './visitors/KeyBlock.js';
import { LabeledStatement } from './visitors/LabeledStatement.js';
import { LetDirective } from './visitors/LetDirective.js';
import { MemberExpression } from './visitors/MemberExpression.js';
import { OnDirective } from './visitors/OnDirective.js';
import { Program } from './visitors/Program.js';
import { RegularElement } from './visitors/RegularElement.js';
import { RenderTag } from './visitors/RenderTag.js';
import { SlotElement } from './visitors/SlotElement.js';
import { SnippetBlock } from './visitors/SnippetBlock.js';
import { SpreadAttribute } from './visitors/SpreadAttribute.js';
import { SvelteBody } from './visitors/SvelteBody.js';
import { SvelteComponent } from './visitors/SvelteComponent.js';
import { SvelteDocument } from './visitors/SvelteDocument.js';
import { SvelteElement } from './visitors/SvelteElement.js';
import { SvelteFragment } from './visitors/SvelteFragment.js';
import { SvelteBoundary } from './visitors/SvelteBoundary.js';
import { SvelteHead } from './visitors/SvelteHead.js';
import { SvelteSelf } from './visitors/SvelteSelf.js';
import { SvelteWindow } from './visitors/SvelteWindow.js';
import { TitleElement } from './visitors/TitleElement.js';
import { TransitionDirective } from './visitors/TransitionDirective.js';
import { UpdateExpression } from './visitors/UpdateExpression.js';
import { UseDirective } from './visitors/UseDirective.js';
import { AttachTag } from './visitors/AttachTag.js';
import { VariableDeclaration } from './visitors/VariableDeclaration.js';
/** @type {Visitors} */
const visitors = {
_: function set_scope(node, { next, state }) {
const scope = state.scopes.get(node);
if (scope && scope !== state.scope) {
const transform = { ...state.transform };
for (const [name, binding] of scope.declarations) {
if (
binding.kind === 'normal' ||
// Reads of `$state(...)` declarations are not
// transformed if they are never reassigned
(binding.kind === 'state' && !is_state_source(binding, state.analysis))
) {
delete transform[name];
}
}
next({ ...state, transform, scope });
} else {
next();
}
},
AnimateDirective,
ArrowFunctionExpression,
AssignmentExpression,
Attribute,
AwaitBlock,
AwaitExpression,
BinaryExpression,
BindDirective,
BlockStatement,
BreakStatement,
CallExpression,
ClassBody,
Comment,
Component,
ConstTag,
DebugTag,
EachBlock,
ExportNamedDeclaration,
ExpressionStatement,
ForOfStatement,
Fragment,
FunctionDeclaration,
FunctionExpression,
HtmlTag,
Identifier,
IfBlock,
KeyBlock,
LabeledStatement,
LetDirective,
MemberExpression,
OnDirective,
Program,
RegularElement,
RenderTag,
SlotElement,
SnippetBlock,
SpreadAttribute,
SvelteBody,
SvelteComponent,
SvelteDocument,
SvelteElement,
SvelteFragment,
SvelteBoundary,
SvelteHead,
SvelteSelf,
SvelteWindow,
TitleElement,
TransitionDirective,
UpdateExpression,
UseDirective,
AttachTag,
VariableDeclaration
};
/**
* @param {ComponentAnalysis} analysis
* @param {ValidatedCompileOptions} options
* @returns {ESTree.Program}
*/
export function client_component(analysis, options) {
/** @type {ComponentClientTransformState} */
const state = {
analysis,
options,
scope: analysis.module.scope,
scopes: analysis.module.scopes,
is_instance: false,
hoisted: [b.import_all('$', 'svelte/internal/client'), ...analysis.instance_body.hoisted],
node: /** @type {any} */ (null), // populated by the root node
legacy_reactive_imports: [],
legacy_reactive_statements: new Map(),
metadata: {
namespace: options.namespace,
bound_contenteditable: false
},
events: new Set(),
preserve_whitespace: options.preserveWhitespace,
state_fields: new Map(),
transform: {},
in_constructor: false,
instance_level_snippets: [],
module_level_snippets: [],
is_standalone: false,
// these are set inside the `Fragment` visitor, and cannot be used until then
init: /** @type {any} */ (null),
consts: /** @type {any} */ (null),
snippets: /** @type {any} */ (null),
let_directives: /** @type {any} */ (null),
update: /** @type {any} */ (null),
after_update: /** @type {any} */ (null),
template: /** @type {any} */ (null),
memoizer: /** @type {any} */ (null)
};
const module = /** @type {ESTree.Program} */ (
walk(/** @type {AST.SvelteNode} */ (analysis.module.ast), state, visitors)
);
const instance_state = {
...state,
transform: { ...state.transform },
scope: analysis.instance.scope,
scopes: analysis.instance.scopes,
is_instance: true
};
const instance = /** @type {ESTree.Program} */ (
walk(/** @type {AST.SvelteNode} */ (analysis.instance.ast), instance_state, visitors)
);
const template = /** @type {ESTree.Program} */ (
walk(
/** @type {AST.SvelteNode} */ (analysis.template.ast),
{
...state,
transform: instance_state.transform,
scope: analysis.instance.scope,
scopes: analysis.template.scopes
},
visitors
)
);
module.body.unshift(...state.legacy_reactive_imports);
/** @type {ESTree.Statement[]} */
const store_setup = [];
/** @type {ESTree.Statement} */
let store_init = b.empty;
/** @type {ESTree.VariableDeclaration[]} */
const legacy_reactive_declarations = [];
let needs_store_cleanup = false;
for (const [name, binding] of analysis.instance.scope.declarations) {
if (binding.kind === 'legacy_reactive') {
legacy_reactive_declarations.push(
b.const(
name,
b.call('$.mutable_source', undefined, analysis.immutable ? b.true : undefined)
)
);
}
if (binding.kind === 'store_sub') {
if (store_setup.length === 0) {
needs_store_cleanup = true;
store_init = b.const(
b.array_pattern([b.id('$$stores'), b.id('$$cleanup')]),
b.call('$.setup_stores')
);
}
// We're creating an arrow function that gets the store value which minifies better for two or more references
const store_reference = build_getter(b.id(name.slice(1)), instance_state);
const store_get = b.call('$.store_get', store_reference, b.literal(name), b.id('$$stores'));
store_setup.push(
b.const(
binding.node,
dev
? b.thunk(
b.sequence([
b.call('$.validate_store', store_reference, b.literal(name.slice(1))),
store_get
])
)
: b.thunk(store_get)
)
);
}
}
for (const [node] of analysis.reactive_statements) {
const statement = [...state.legacy_reactive_statements].find(([n]) => n === node);
if (statement === undefined) {
throw new Error('Could not find reactive statement');
}
instance.body.push(statement[1]);
}
if (analysis.reactive_statements.size > 0) {
instance.body.push(b.stmt(b.call('$.legacy_pre_effect_reset')));
}
/**
* Used to store the group nodes
* @type {ESTree.VariableDeclaration[]}
*/
const group_binding_declarations = [];
for (const group of analysis.binding_groups.values()) {
group_binding_declarations.push(b.const(group.name, b.array([])));
}
/** @type {Array<ESTree.Property | ESTree.SpreadElement>} */
const component_returned_object = analysis.exports.flatMap(({ name, alias }) => {
const binding = instance_state.scope.get(name);
const expression = build_getter(b.id(name), instance_state);
const getter = b.get(alias ?? name, [b.return(expression)]);
if (expression.type === 'Identifier') {
if (binding?.declaration_kind === 'let' || binding?.declaration_kind === 'var') {
return [
getter,
b.set(alias ?? name, [b.stmt(b.assignment('=', expression, b.id('$$value')))])
];
} else if (!dev) {
return b.init(alias ?? name, expression);
}
}
if (binding?.kind === 'prop' || binding?.kind === 'bindable_prop') {
return [getter, b.set(alias ?? name, [b.stmt(b.call(name, b.id('$$value')))])];
}
if (binding?.kind === 'state' || binding?.kind === 'raw_state') {
const value = binding.kind === 'state' ? b.call('$.proxy', b.id('$$value')) : b.id('$$value');
return [getter, b.set(alias ?? name, [b.stmt(b.call('$.set', b.id(name), value))])];
}
return getter;
});
const properties = [...analysis.instance.scope.declarations].filter(
([name, binding]) =>
(binding.kind === 'prop' || binding.kind === 'bindable_prop') && !name.startsWith('$$')
);
if (analysis.accessors) {
for (const [name, binding] of properties) {
const key = binding.prop_alias ?? name;
const getter = b.get(key, [b.return(b.call(b.id(name)))]);
const setter = b.set(key, [
b.stmt(b.call(b.id(name), b.id('$$value'))),
b.stmt(b.call('$.flush'))
]);
if (analysis.runes && binding.initial) {
// turn `set foo($$value)` into `set foo($$value = expression)`
setter.value.params[0] = {
type: 'AssignmentPattern',
left: b.id('$$value'),
right: /** @type {ESTree.Expression} */ (binding.initial)
};
}
component_returned_object.push(getter, setter);
}
}
if (options.compatibility.componentApi === 4) {
component_returned_object.push(
b.init('$set', b.id('$.update_legacy_props')),
b.init(
'$on',
b.arrow(
[b.id('$$event_name'), b.id('$$event_cb')],
b.call(
'$.add_legacy_event_listener',
b.id('$$props'),
b.id('$$event_name'),
b.id('$$event_cb')
)
)
)
);
} else if (dev) {
component_returned_object.unshift(b.spread(b.call(b.id('$.legacy_api'))));
}
const push_args = [b.id('$$props'), b.literal(analysis.runes)];
if (dev) push_args.push(b.id(analysis.name));
let component_block = b.block([
store_init,
...legacy_reactive_declarations,
...group_binding_declarations
]);
const should_inject_context =
dev ||
analysis.needs_context ||
analysis.reactive_statements.size > 0 ||
component_returned_object.length > 0;
component_block.body.push(
...state.instance_level_snippets,
.../** @type {ESTree.Statement[]} */ (instance.body)
);
if (should_inject_context && component_returned_object.length > 0) {
component_block.body.push(b.var('$$exports', b.object(component_returned_object)));
}
component_block.body.unshift(...store_setup);
if (!analysis.runes && analysis.needs_context) {
component_block.body.push(b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined)));
}
component_block.body.push(.../** @type {ESTree.Statement[]} */ (template.body));
if (analysis.needs_mutation_validation) {
component_block.body.unshift(
b.var('$$ownership_validator', b.call('$.create_ownership_validator', b.id('$$props')))
);
}
let should_inject_props =
should_inject_context ||
analysis.needs_props ||
analysis.uses_props ||
analysis.uses_rest_props ||
analysis.uses_slots ||
analysis.slot_names.size > 0;
// trick esrap into including comments
component_block.loc = instance.loc;
if (!analysis.runes) {
// Bind static exports to props so that people can access them with bind:x
for (const { name, alias } of analysis.exports) {
component_block.body.push(
b.stmt(
b.call(
'$.bind_prop',
b.id('$$props'),
b.literal(alias ?? name),
build_getter(b.id(name), instance_state)
)
)
);
}
}
if (analysis.css.ast !== null && analysis.inject_styles) {
const hash = b.literal(analysis.css.hash);
const code = b.literal(render_stylesheet(analysis.source, analysis, options).code);
state.hoisted.push(b.const('$$css', b.object([b.init('hash', hash), b.init('code', code)])));
component_block.body.unshift(
b.stmt(b.call('$.append_styles', b.id('$$anchor'), b.id('$$css')))
);
}
// we want the cleanup function for the stores to run as the very last thing
// so that it can effectively clean up the store subscription even after the user effects runs
if (should_inject_context) {
component_block.body.unshift(b.stmt(b.call('$.push', ...push_args)));
let to_push;
if (component_returned_object.length > 0) {
let pop_call = b.call('$.pop', b.id('$$exports'));
to_push = needs_store_cleanup ? b.var('$$pop', pop_call) : b.return(pop_call);
} else {
to_push = b.stmt(b.call('$.pop'));
}
component_block.body.push(to_push);
}
if (needs_store_cleanup) {
component_block.body.push(b.stmt(b.call('$$cleanup')));
if (component_returned_object.length > 0) {
component_block.body.push(b.return(b.id('$$pop')));
}
}
if (analysis.uses_rest_props) {
const named_props = analysis.exports.map(({ name, alias }) => alias ?? name);
for (const [name, binding] of analysis.instance.scope.declarations) {
if (binding.kind === 'bindable_prop') named_props.push(binding.prop_alias ?? name);
}
component_block.body.unshift(
b.const(
'$$restProps',
b.call(
'$.legacy_rest_props',
b.id('$$sanitized_props'),
b.array(named_props.map((name) => b.literal(name)))
)
)
);
}
if (analysis.uses_props || analysis.uses_rest_props) {
const to_remove = [
b.literal('children'),
b.literal('$$slots'),
b.literal('$$events'),
b.literal('$$legacy')
];
if (analysis.custom_element) {
to_remove.push(b.literal('$$host'));
}
component_block.body.unshift(
b.const(
'$$sanitized_props',
b.call('$.legacy_rest_props', b.id('$$props'), b.array(to_remove))
)
);
}
if (analysis.uses_slots) {
component_block.body.unshift(b.const('$$slots', b.call('$.sanitize_slots', b.id('$$props'))));
}
// Merge hoisted statements into module body.
// Ensure imports are on top, with the order preserved, then module body, then hoisted statements
/** @type {ESTree.ImportDeclaration[]} */
const imports = [];
/** @type {ESTree.Program['body']} */
let body = [];
for (const entry of [...module.body, ...state.hoisted]) {
if (entry.type === 'ImportDeclaration') {
imports.push(entry);
} else {
body.push(entry);
}
}
body = [...imports, ...state.module_level_snippets, ...body];
const component = b.function_declaration(
b.id(analysis.name),
should_inject_props ? [b.id('$$anchor'), b.id('$$props')] : [b.id('$$anchor')],
component_block
);
if (options.hmr) {
const id = b.id(analysis.name);
const accept_fn_body = [
b.stmt(b.call(b.member(b.member(id, b.id('$.HMR'), true), 'update'), b.id('module.default')))
];
if (analysis.css.hash) {
// remove existing `<style>` element, in case CSS changed
accept_fn_body.unshift(b.stmt(b.call('$.cleanup_styles', b.literal(analysis.css.hash))));
}
const hmr = b.block([
b.stmt(b.assignment('=', id, b.call('$.hmr', id))),
b.stmt(b.call('import.meta.hot.accept', b.arrow([b.id('module')], b.block(accept_fn_body))))
]);
body.push(component, b.if(b.id('import.meta.hot'), hmr), b.export_default(b.id(analysis.name)));
} else {
body.push(b.export_default(component));
}
if (dev) {
// add `App[$.FILENAME] = 'App.svelte'` so that we can print useful messages later
body.unshift(
b.stmt(
b.assignment('=', b.member(b.id(analysis.name), '$.FILENAME', true), b.literal(filename))
)
);
}
if (options.experimental.async) {
body.unshift(b.imports([], 'svelte/internal/flags/async'));
}
if (!analysis.runes) {
body.unshift(b.imports([], 'svelte/internal/flags/legacy'));
}
if (analysis.tracing) {
body.unshift(b.imports([], 'svelte/internal/flags/tracing'));
}
if (options.discloseVersion) {
body.unshift(b.imports([], 'svelte/internal/disclose-version'));
}
if (options.compatibility.componentApi === 4) {
body.unshift(b.imports([['createClassComponent', '$$_createClassComponent']], 'svelte/legacy'));
component_block.body.unshift(
b.if(
b.id('new.target'),
b.return(
b.call(
'$$_createClassComponent',
// When called with new, the first argument is the constructor options
b.object([b.init('component', b.id(analysis.name)), b.spread(b.id('$$anchor'))])
)
)
)
);
} else if (dev) {
component_block.body.unshift(b.stmt(b.call('$.check_target', b.id('new.target'))));
}
if (analysis.props_id) {
// need to be placed on first line of the component for hydration
component_block.body.unshift(b.const(analysis.props_id, b.call('$.props_id')));
}
if (state.events.size > 0) {
body.push(
b.stmt(b.call('$.delegate', b.array(Array.from(state.events).map((name) => b.literal(name)))))
);
}
const ce = analysis.custom_element;
if (ce) {
const ce_props = typeof ce === 'boolean' ? {} : ce.props || {};
/** @type {ESTree.Property[]} */
const props_str = [];
for (const [name, prop_def] of Object.entries(ce_props)) {
const binding = analysis.instance.scope.get(name);
const key = binding?.prop_alias ?? name;
if (
!prop_def.type &&
binding?.initial?.type === 'Literal' &&
typeof binding?.initial.value === 'boolean'
) {
prop_def.type = 'Boolean';
}
const value = b.object(
/** @type {ESTree.Property[]} */ (
[
prop_def.attribute ? b.init('attribute', b.literal(prop_def.attribute)) : undefined,
prop_def.reflect ? b.init('reflect', b.true) : undefined,
prop_def.type ? b.init('type', b.literal(prop_def.type)) : undefined
].filter(Boolean)
)
);
props_str.push(b.init(key, value));
}
for (const [name, binding] of properties) {
const key = binding.prop_alias ?? name;
if (ce_props[key]) continue;
props_str.push(b.init(key, b.object([])));
}
const slots_str = b.array([...analysis.slot_names.keys()].map((name) => b.literal(name)));
const accessors_str = b.array(
analysis.exports.map(({ name, alias }) => b.literal(alias ?? name))
);
/** @type {ESTree.ObjectExpression | undefined} */
let shadow_root_init;
if (typeof ce === 'boolean' || ce.shadow === 'open' || ce.shadow === undefined) {
shadow_root_init = b.object([b.init('mode', b.literal('open'))]);
} else if (ce.shadow === 'none') {
shadow_root_init = undefined;
} else {
shadow_root_init = ce.shadow;
}
const create_ce = b.call(
'$.create_custom_element',
b.id(analysis.name),
b.object(props_str),
slots_str,
accessors_str,
shadow_root_init,
/** @type {any} */ (typeof ce !== 'boolean' ? ce.extend : undefined)
);
// If a tag name is provided, call `customElements.define`, otherwise leave to the user
if (typeof ce !== 'boolean' && typeof ce.tag === 'string') {
const define = b.stmt(b.call('customElements.define', b.literal(ce.tag), create_ce));
if (options.hmr) {
body.push(
b.if(b.binary('==', b.call('customElements.get', b.literal(ce.tag)), b.null), define)
);
} else {
body.push(define);
}
} else {
body.push(b.stmt(create_ce));
}
}
return {
type: 'Program',
sourceType: 'module',
body
};
}
/**
* @param {Analysis} analysis
* @param {ValidatedModuleCompileOptions} options
* @returns {ESTree.Program}
*/
export function client_module(analysis, options) {
/** @type {ClientTransformState} */
const state = {
analysis,
options,
scope: analysis.module.scope,
scopes: analysis.module.scopes,
state_fields: new Map(),
transform: {},
in_constructor: false,
is_instance: false
};
const module = /** @type {ESTree.Program} */ (
walk(/** @type {AST.SvelteNode} */ (analysis.module.ast), state, visitors)
);
const body = [b.import_all('$', 'svelte/internal/client')];
if (analysis.tracing) {
body.push(b.imports([], 'svelte/internal/flags/tracing'));
}
return {
type: 'Program',
sourceType: 'module',
body: [...body, ...module.body]
};
}

Some files were not shown because too many files have changed in this diff Show More