feat: templateless template generation

templateless-template-generation
paoloricciuti 2 weeks ago
parent 0af6f20c77
commit de8a38ba0e

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: templateless template generation

@ -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);
}

@ -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;
}

@ -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;

@ -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)));

@ -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] });
}

@ -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;

@ -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)));
}

@ -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(

@ -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));

@ -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));

@ -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' });
}
/**

@ -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);

@ -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 = [];

@ -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
);

@ -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 = [];

@ -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)));
}

@ -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);

Loading…
Cancel
Save