From de8a38ba0e3bc46b54cac0817aec26ca73f73fbf Mon Sep 17 00:00:00 2001 From: paoloricciuti <ricciutipaolo@gmail.com> Date: Tue, 18 Mar 2025 18:16:18 +0100 Subject: [PATCH] feat: templateless template generation --- .changeset/smart-boats-accept.md | 5 + .../client/transform-template/index.js | 12 ++ .../client/transform-template/to-string.js | 170 ++++++++++++++++++ .../phases/3-transform/client/types.d.ts | 14 +- .../3-transform/client/visitors/AwaitBlock.js | 2 +- .../3-transform/client/visitors/Comment.js | 2 +- .../3-transform/client/visitors/EachBlock.js | 2 +- .../3-transform/client/visitors/Fragment.js | 10 +- .../3-transform/client/visitors/HtmlTag.js | 2 +- .../3-transform/client/visitors/IfBlock.js | 2 +- .../3-transform/client/visitors/KeyBlock.js | 2 +- .../client/visitors/RegularElement.js | 36 ++-- .../3-transform/client/visitors/RenderTag.js | 2 +- .../client/visitors/SlotElement.js | 2 +- .../client/visitors/SvelteBoundary.js | 2 +- .../client/visitors/SvelteElement.js | 2 +- .../client/visitors/shared/component.js | 25 ++- .../client/visitors/shared/fragment.js | 11 +- 18 files changed, 265 insertions(+), 38 deletions(-) create mode 100644 .changeset/smart-boats-accept.md create mode 100644 packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js create mode 100644 packages/svelte/src/compiler/phases/3-transform/client/transform-template/to-string.js diff --git a/.changeset/smart-boats-accept.md b/.changeset/smart-boats-accept.md new file mode 100644 index 0000000000..22be423e1f --- /dev/null +++ b/.changeset/smart-boats-accept.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: templateless template generation diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js new file mode 100644 index 0000000000..a2854abad2 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js @@ -0,0 +1,12 @@ +/** + * @import { TemplateOperations } from "../types.js" + */ +import { template_to_string } from './to-string'; + +/** + * @param {TemplateOperations} items + */ +export function transform_template(items) { + // here we will check if we need to use `$.template` or create a series of `document.createElement` calls + return template_to_string(items); +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/to-string.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/to-string.js new file mode 100644 index 0000000000..c4a0b1565c --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/to-string.js @@ -0,0 +1,170 @@ +/** + * @import { TemplateOperations } from "../types.js" + */ +import { is_void } from '../../../../../utils.js'; + +/** + * @param {TemplateOperations} items + */ +export function template_to_string(items) { + let elements = []; + + /** + * @type {Array<Element>} + */ + let elements_stack = []; + + /** + * @type {Element | undefined} + */ + let last_current_element; + + for (let instruction of items) { + if (instruction.kind === 'push_element' && last_current_element) { + elements_stack.push(last_current_element); + continue; + } + if (instruction.kind === 'pop_element') { + elements_stack.pop(); + continue; + } + /** + * @type {Node | void} + */ + // @ts-expect-error we can't be here if `swap_current_element` but TS doesn't know that + const value = map[instruction.kind]( + ...[ + ...(instruction.kind === 'set_prop' ? [last_current_element] : []), + ...(instruction.args ?? []) + ] + ); + if (instruction.kind !== 'set_prop') { + if (elements_stack.length >= 1 && value) { + map.insert(/** @type {Element} */ (elements_stack.at(-1)), value); + } else if (value) { + elements.push(value); + } + if (instruction.kind === 'create_element') { + last_current_element = /** @type {Element} */ (value); + } + } + } + + return elements.map((el) => stringify(el)).join(''); +} + +/** + * @typedef {{ kind: "element", element: string, props?: Record<string, string>, children?: Array<Node> }} Element + */ + +/** + * @typedef {{ kind: "anchor", data?: string }} Anchor + */ + +/** + * @typedef {{ kind: "text", value?: string }} Text + */ + +/** + * @typedef { Element | Anchor| Text } Node + */ + +/** + * + * @param {string} element + * @returns {Element} + */ +function create_element(element) { + return { + kind: 'element', + element + }; +} + +/** + * @param {string} data + * @returns {Anchor} + */ +function create_anchor(data) { + return { + kind: 'anchor', + data + }; +} + +/** + * @param {string} value + * @returns {Text} + */ +function create_text(value) { + return { + kind: 'text', + value + }; +} + +/** + * + * @param {Element} el + * @param {string} prop + * @param {string} value + */ +function set_prop(el, prop, value) { + el.props ??= {}; + el.props[prop] = value; +} + +/** + * + * @param {Element} el + * @param {Node} child + * @param {Node} [anchor] + */ +function insert(el, child, anchor) { + el.children ??= []; + el.children.push(child); +} + +let map = { + create_element, + create_text, + create_anchor, + set_prop, + insert +}; + +/** + * + * @param {Node} el + * @returns + */ +function stringify(el) { + let str = ``; + if (el.kind === 'element') { + str += `<${el.element}`; + for (let [prop, value] of Object.entries(el.props ?? {})) { + if (value == null) { + str += ` ${prop}`; + } else { + str += ` ${prop}="${value}"`; + } + } + str += `>`; + for (let child of el.children ?? []) { + str += stringify(child); + } + if (!is_void(el.element)) { + str += `</${el.element}>`; + } + } else if (el.kind === 'text') { + str += el.value; + } else if (el.kind === 'anchor') { + if (el.data) { + str += `<!--${el.data}-->`; + } else { + str += `<!>`; + } + } + + return str; +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts index 63fe3223cf..98917e7372 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts @@ -39,6 +39,18 @@ export interface ClientTransformState extends TransformState { >; } +type TemplateOperationsKind = + | 'create_element' + | 'create_text' + | 'create_anchor' + | 'set_prop' + | 'push_element' + | 'pop_element'; + +type TemplateOperations = Array<{ + kind: TemplateOperationsKind; + args?: Array<string>; +}>; export interface ComponentClientTransformState extends ClientTransformState { readonly analysis: ComponentAnalysis; readonly options: ValidatedCompileOptions; @@ -56,7 +68,7 @@ export interface ComponentClientTransformState extends ClientTransformState { /** Expressions used inside the render effect */ readonly expressions: Expression[]; /** The HTML template string */ - readonly template: Array<string | Expression>; + readonly template: TemplateOperations; readonly locations: SourceLocation[]; readonly metadata: { namespace: Namespace; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js index 7588b24280..404124762f 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js @@ -11,7 +11,7 @@ import { get_value } from './shared/declarations.js'; * @param {ComponentContext} context */ export function AwaitBlock(node, context) { - context.state.template.push('<!>'); + context.state.template.push({ kind: 'create_anchor' }); // Visit {#await <expression>} first to ensure that scopes are in the correct order const expression = b.thunk(/** @type {Expression} */ (context.visit(node.expression))); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Comment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Comment.js index 24011e62aa..758abc6a67 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Comment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Comment.js @@ -7,5 +7,5 @@ */ export function Comment(node, context) { // We'll only get here if comments are not filtered out, which they are unless preserveComments is true - context.state.template.push(`<!--${node.data}-->`); + context.state.template.push({ kind: 'create_anchor', args: [node.data] }); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js index 629cacda01..9cdb6f1b4c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js @@ -32,7 +32,7 @@ export function EachBlock(node, context) { ); if (!each_node_meta.is_controlled) { - context.state.template.push('<!>'); + context.state.template.push({ kind: 'create_anchor' }); } let flags = 0; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js index 389a694741..27ad894ccf 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js @@ -7,6 +7,7 @@ import { dev } from '../../../../state.js'; import * as b from '../../../../utils/builders.js'; import { sanitize_template_string } from '../../../../utils/sanitize_template_string.js'; import { clean_nodes, infer_namespace } from '../../utils.js'; +import { transform_template } from '../transform-template/index.js'; import { process_children } from './shared/fragment.js'; import { build_render_statement } from './shared/utils.js'; @@ -118,7 +119,7 @@ export function Fragment(node, context) { }); /** @type {Expression[]} */ - const args = [join_template(state.template)]; + const args = [b.template([b.quasi(transform_template(state.template), true)], [])]; if (state.metadata.context.template_needs_import_node) { args.push(b.literal(TEMPLATE_USE_IMPORT_NODE)); @@ -168,11 +169,14 @@ export function Fragment(node, context) { flags |= TEMPLATE_USE_IMPORT_NODE; } - if (state.template.length === 1 && state.template[0] === '<!>') { + if (state.template.length === 1 && state.template[0].kind === 'create_anchor') { // special case — we can use `$.comment` instead of creating a unique template body.push(b.var(id, b.call('$.comment'))); } else { - add_template(template_name, [join_template(state.template), b.literal(flags)]); + add_template(template_name, [ + b.template([b.quasi(transform_template(state.template), true)], []), + b.literal(flags) + ]); body.push(b.var(id, b.call(template_name))); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js index 32439879de..fd2256f16b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js @@ -9,7 +9,7 @@ import * as b from '../../../../utils/builders.js'; * @param {ComponentContext} context */ export function HtmlTag(node, context) { - context.state.template.push('<!>'); + context.state.template.push({ kind: 'create_anchor' }); // push into init, so that bindings run afterwards, which might trigger another run and override hydration context.state.init.push( diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js index fdd21b2b7e..a7735c65b8 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js @@ -8,7 +8,7 @@ import * as b from '../../../../utils/builders.js'; * @param {ComponentContext} context */ export function IfBlock(node, context) { - context.state.template.push('<!>'); + context.state.template.push({ kind: 'create_anchor' }); const statements = []; const consequent = /** @type {BlockStatement} */ (context.visit(node.consequent)); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js index a013827f60..04ef6195e7 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js @@ -8,7 +8,7 @@ import * as b from '../../../../utils/builders.js'; * @param {ComponentContext} context */ export function KeyBlock(node, context) { - context.state.template.push('<!>'); + context.state.template.push({ kind: 'create_anchor' }); const key = /** @type {Expression} */ (context.visit(node.expression)); const body = /** @type {Expression} */ (context.visit(node.fragment)); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index 45a594af1f..78cfd4d222 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -52,7 +52,10 @@ export function RegularElement(node, context) { } if (node.name === 'noscript') { - context.state.template.push('<noscript></noscript>'); + context.state.template.push({ + kind: 'create_element', + args: ['noscript'] + }); return; } @@ -72,7 +75,10 @@ export function RegularElement(node, context) { context.state.metadata.context.template_contains_script_tag = true; } - context.state.template.push(`<${node.name}`); + context.state.template.push({ + kind: 'create_element', + args: [node.name] + }); /** @type {Array<AST.Attribute | AST.SpreadAttribute>} */ const attributes = []; @@ -110,7 +116,10 @@ export function RegularElement(node, context) { const { value } = build_attribute_value(attribute.value, context); if (value.type === 'Literal' && typeof value.value === 'string') { - context.state.template.push(` is="${escape_html(value.value, true)}"`); + context.state.template.push({ + kind: 'set_prop', + args: ['is', escape_html(value.value, true)] + }); continue; } } @@ -286,13 +295,14 @@ export function RegularElement(node, context) { } if (name !== 'class' || value) { - context.state.template.push( - ` ${attribute.name}${ + context.state.template.push({ + kind: 'set_prop', + args: [attribute.name].concat( is_boolean_attribute(name) && value === true - ? '' - : `="${value === true ? '' : escape_html(value, true)}"` - }` - ); + ? [] + : [value === true ? '' : escape_html(value, true)] + ) + }); } } else if (name === 'autofocus') { let { value } = build_attribute_value(attribute.value, context); @@ -324,8 +334,7 @@ export function RegularElement(node, context) { ) { context.state.after_update.push(b.stmt(b.call('$.replay_events', node_id))); } - - context.state.template.push('>'); + context.state.template.push({ kind: 'push_element' }); const metadata = { ...context.state.metadata, @@ -446,10 +455,7 @@ export function RegularElement(node, context) { // @ts-expect-error location.push(state.locations); } - - if (!is_void(node.name)) { - context.state.template.push(`</${node.name}>`); - } + context.state.template.push({ kind: 'pop_element' }); } /** diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js index 33ae6d4d2b..a48adaf6c5 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js @@ -9,7 +9,7 @@ import * as b from '../../../../utils/builders.js'; * @param {ComponentContext} context */ export function RenderTag(node, context) { - context.state.template.push('<!>'); + context.state.template.push({ kind: 'create_anchor' }); const expression = unwrap_optional(node.expression); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js index c6f4ba1ed3..c6e7badfa5 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js @@ -11,7 +11,7 @@ import { memoize_expression } from './shared/utils.js'; */ export function SlotElement(node, context) { // <slot {a}>fallback</slot> --> $.slot($$slots.default, { get a() { .. } }, () => ...fallback); - context.state.template.push('<!>'); + context.state.template.push({ kind: 'create_anchor' }); /** @type {Property[]} */ const props = []; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js index 9228df9703..40dde11e6e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js @@ -88,7 +88,7 @@ export function SvelteBoundary(node, context) { b.call('$.boundary', context.state.node, props, b.arrow([b.id('$$anchor')], block)) ); - context.state.template.push('<!>'); + context.state.template.push({ kind: 'create_anchor' }); context.state.init.push( external_statements.length > 0 ? b.block([...external_statements, boundary]) : boundary ); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js index 115eb6ccc1..90a5b7ec27 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js @@ -13,7 +13,7 @@ import { build_render_statement, get_expression_id } from './shared/utils.js'; * @param {ComponentContext} context */ export function SvelteElement(node, context) { - context.state.template.push(`<!>`); + context.state.template.push({ kind: 'create_anchor' }); /** @type {Array<AST.Attribute | AST.SpreadAttribute>} */ const attributes = []; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js index 2bae4486dc..90fe4d93e1 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js @@ -422,11 +422,24 @@ export function build_component(node, component_name, context, anchor = context. } if (Object.keys(custom_css_props).length > 0) { - context.state.template.push( - context.state.metadata.namespace === 'svg' - ? '<g><!></g>' - : '<svelte-css-wrapper style="display: contents"><!></svelte-css-wrapper>' - ); + /** + * @type {typeof context.state.template} + */ + const template_operations = []; + if (context.state.metadata.namespace === 'svg') { + template_operations.push({ kind: 'create_element', args: ['g'] }); + template_operations.push({ kind: 'push_element' }); + template_operations.push({ kind: 'create_anchor' }); + template_operations.push({ kind: 'pop_element' }); + } else { + template_operations.push({ kind: 'create_element', args: ['svelte-css-wrapper'] }); + template_operations.push({ kind: 'set_prop', args: ['style', 'display: contents'] }); + template_operations.push({ kind: 'push_element' }); + template_operations.push({ kind: 'create_anchor' }); + template_operations.push({ kind: 'pop_element' }); + } + + context.state.template.push(...template_operations); statements.push( b.stmt(b.call('$.css_props', anchor, b.thunk(b.object(custom_css_props)))), @@ -434,7 +447,7 @@ export function build_component(node, component_name, context, anchor = context. b.stmt(b.call('$.reset', anchor)) ); } else { - context.state.template.push('<!>'); + context.state.template.push({ kind: 'create_anchor' }); statements.push(b.stmt(fn(anchor))); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js index f076d7c11e..025d39b2e9 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js @@ -64,11 +64,16 @@ export function process_children(nodes, initial, is_element, { visit, state }) { function flush_sequence(sequence) { if (sequence.every((node) => node.type === 'Text')) { skipped += 1; - state.template.push(sequence.map((node) => node.raw).join('')); + state.template.push({ + kind: 'create_text', + args: [sequence.map((node) => node.raw).join('')] + }); return; } - - state.template.push(' '); + state.template.push({ + kind: 'create_text', + args: [' '] + }); const { has_state, value } = build_template_chunk(sequence, visit, state);