UNPKG

svelte

Version:

Cybernetically enhanced web apps

824 lines (714 loc) 23 kB
/** @import { Expression } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { Parser } from '../index.js' */ import { is_void } 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, create_expression_metadata, 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 { regex_whitespace } from '../../patterns.js'; const regex_invalid_unquoted_attribute_value = /^(\/>|[\s"'=<>`])/; const regex_closing_textarea_tag = /^<\/textarea(\s[^>]*)?>/i; 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 = /^["']/; const regex_attribute_value = /^(?:"([^"]*)"|'([^'])*'|([^>\s]+))/; const regex_valid_element_name = /^(?:![a-zA-Z]+|[a-zA-Z](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?|[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9])$/; 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; } const is_closing_tag = parser.eat('/'); const name = parser.read_until(regex_whitespace_or_slash_or_closing_tag); if (is_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' && !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; } if (name.startsWith('svelte:') && !meta_tags.has(name)) { const bounds = { start: start + 1, end: start + 1 + name.length }; e.svelte_meta_invalid_tag(bounds, list(Array.from(meta_tags.keys()))); } if (!regex_valid_element_name.test(name) && !regex_valid_component_name.test(name)) { // <div. -> in the middle of typing -> allow in loose mode if (!parser.loose || !name.endsWith('.')) { const bounds = { start: start + 1, end: start + 1 + name.length }; e.tag_invalid_name(bounds); } } if (root_only_meta_tags.has(name)) { if (name in parser.meta_tags) { e.svelte_meta_duplicate(start, name); } if (parent.type !== 'Root') { e.svelte_meta_invalid_placement(start, name); } parser.meta_tags[name] = true; } const type = meta_tags.has(name) ? meta_tags.get(name) : regex_valid_component_name.test(name) || (parser.loose && name.endsWith('.')) ? 'Component' : 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 name === 'slot' && !parent_is_shadowroot_template(parser.stack) ? 'SlotElement' : 'RegularElement'; /** @type {AST.ElementLike} */ const element = type === 'RegularElement' ? { type, start, end: -1, name, attributes: [], fragment: create_fragment(true), metadata: { svg: false, mathml: false, scoped: false, has_spread: false, path: [] } } : /** @type {AST.ElementLike} */ ({ type, start, end: -1, name, attributes: [], fragment: create_fragment(true), metadata: { // unpopulated at first, differs between types } }); parser.allow_whitespace(); if (parent.type === 'RegularElement' && closing_tag_omitted(parent.name, name)) { parent.end = start; parser.pop(); parser.last_auto_closed_tag = { tag: parent.name, reason: name, depth: parser.stack.length }; } /** @type {string[]} */ const unique_names = []; const current = parser.current(); const is_top_level_script_or_style = (name === 'script' || 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 === '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); } 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); } } 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 (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(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 (parser.index === parser.template.length) { while ( parser.index < parser.template_untrimmed.length && regex_whitespace.test(parser.template_untrimmed[parser.index]) ) { parser.index++; } } } } } if (self_closing || !closed) { // don't push self-closing elements onto the stack element.end = parser.index; } else if (name === 'textarea') { // special case element.fragment.nodes = read_sequence( parser, () => regex_closing_textarea_tag.test(parser.template.slice(parser.index)), 'inside <textarea>' ); parser.read(regex_closing_textarea_tag); element.end = parser.index; } else if (name === 'script' || name === 'style') { // special case const start = parser.index; const data = parser.read_until(new RegExp(`</${name}>`)); const end = parser.index; /** @type {AST.Text} */ const node = { start, end, type: 'Text', data, raw: data }; element.fragment.nodes.push(node); parser.eat(`</${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 name = parser.read_until(regex_token_ending_character); if (!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(name, start, parser.index, value); } /** * @param {Parser} parser * @returns {AST.Attribute | AST.SpreadAttribute | AST.Directive | null} */ function read_attribute(parser) { const start = parser.index; if (parser.eat('{')) { parser.allow_whitespace(); 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: create_expression_metadata() } }; return spread; } else { const value_start = parser.index; let name = parser.read_identifier(); if (name === null) { 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 name = ''; } else { e.attribute_empty_shorthand(start); } } parser.allow_whitespace(); parser.eat('}', true); /** @type {AST.ExpressionTag} */ const expression = { type: 'ExpressionTag', start: value_start, end: value_start + name.length, expression: { start: value_start, end: value_start + name.length, type: 'Identifier', name }, metadata: { expression: create_expression_metadata() } }; return create_attribute(name, start, parser.index, expression); } } const name = parser.read_until(regex_token_ending_character); if (!name) return null; let end = parser.index; parser.allow_whitespace(); const colon_index = name.indexOf(':'); const type = colon_index !== -1 && get_directive_type(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] = name.slice(colon_index + 1).split('|'); if (directive_name === '') { e.directive_missing_name({ start, end: start + colon_index + 1 }, name); } if (type === 'StyleDirective') { return { start, end, type, name: directive_name, modifiers: /** @type {Array<'important'>} */ (modifiers), value, metadata: { expression: create_expression_metadata() } }; } 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; } } /** @type {AST.Directive} */ const directive = { start, end, type, name: directive_name, expression, metadata: { expression: create_expression_metadata() } }; // @ts-expect-error we do this separately from the declaration to avoid upsetting typescript directive.modifiers = modifiers; if (directive.type === 'TransitionDirective') { const direction = 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(name, start, end, value); } /** * @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 (current_chunk.raw) { 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: create_expression_metadata() } }; chunks.push(chunk); current_chunk = { start: parser.index, end: -1, type: 'Text', raw: '', data: '' }; } else { current_chunk.raw += parser.template[parser.index++]; } } if (parser.loose) { return chunks; } else { e.unexpected_eof(parser.template.length); } }