Skip to content

feat: functional template generation #15538

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 75 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
de8a38b
feat: templateless template generation
paoloricciuti Mar 18, 2025
575908a
fix: auto import fumble
paoloricciuti Mar 18, 2025
8a737f1
feat: add option `preventTemplateCloning` and functions transformation
paoloricciuti Mar 18, 2025
7c7a857
feat: make all tests pass with functional templates
paoloricciuti Mar 20, 2025
8424395
chore: remove `prevent-template-cloning` test
paoloricciuti Mar 20, 2025
c75c429
chore: run test suite on both `functional` and `string` templating
paoloricciuti Mar 20, 2025
64219ed
chore: change options from `boolean` to `list` (templatingMode: "func…
paoloricciuti Mar 20, 2025
95ce311
chore: revert unneeded change to `package.json`
paoloricciuti Mar 20, 2025
59902cc
chore: simplify `process_children`
paoloricciuti Mar 20, 2025
132ea2b
Merge remote-tracking branch 'origin/main' into templateless-template…
paoloricciuti Mar 20, 2025
403b17e
chore: update snapshots
paoloricciuti Mar 20, 2025
ba237c7
chore: don't write different `rendered`
paoloricciuti Mar 20, 2025
4daa63a
fix: snapshot test runner
paoloricciuti Mar 20, 2025
72f93e3
chore: sprinkle comments here and there
paoloricciuti Mar 21, 2025
ad56847
fix: silly goose am i 🪿
paoloricciuti Mar 21, 2025
d8afd8e
fix (this broke the sandbox)
Rich-Harris Mar 21, 2025
a48df4a
Merge remote-tracking branch 'origin/main' into templateless-template…
paoloricciuti Mar 24, 2025
be39867
chore: alterative functional templating syntax (#15599)
paoloricciuti Apr 1, 2025
eaf2aaf
Merge branch 'main' into templateless-template-generation
Rich-Harris Apr 17, 2025
97817a9
fix snapshot tests
Rich-Harris Apr 17, 2025
15c6306
Merge remote-tracking branch 'origin/main' into templateless-template…
paoloricciuti Apr 22, 2025
084f7f9
chore: update purity snapshot
paoloricciuti Apr 22, 2025
084e313
chore: update purity snapshot, this time in the proper way
paoloricciuti Apr 22, 2025
d294a66
chore: add `each-index-non-null` snapshot
paoloricciuti Apr 22, 2025
2d0db55
Merge remote-tracking branch 'origin/main' into templateless-template…
paoloricciuti Apr 22, 2025
7f732bf
chore: remove export of `seen`
paoloricciuti May 21, 2025
d3ee992
fix: revert exporting `seen`
paoloricciuti May 21, 2025
2e24fdc
chore: use more sane to-string code
paoloricciuti May 21, 2025
67183ce
Merge branch 'main' into templateless-template-generation
Rich-Harris May 21, 2025
173a7f7
update snapshots
Rich-Harris May 21, 2025
d9d24a2
this is already copied, no need to do it explicitly
Rich-Harris May 21, 2025
5b1f5e5
do the escaping inside `template_to_string`
Rich-Harris May 21, 2025
1c4503c
Apply suggestions from code review
Rich-Harris May 21, 2025
e81c669
chore: refactor `to-functions` to be sane
paoloricciuti May 21, 2025
324bd8a
use proper discriminated unions (TODO replace args with node-specific…
Rich-Harris May 21, 2025
4a3fc9d
remove instruction.args
Rich-Harris May 21, 2025
76d05e0
make process_children unaware of templating mode - the less visitors …
Rich-Harris May 21, 2025
22cf914
generate functional output in sandbox
Rich-Harris May 21, 2025
a9f2d6f
tidy up
Rich-Harris May 21, 2025
36c01ca
fix
Rich-Harris May 21, 2025
68de6b4
no longer need to pass `is_functional_template_mode` to `clean_nodes`
Rich-Harris May 21, 2025
d9237e2
simplify state
Rich-Harris May 21, 2025
17f1b6a
WIP
Rich-Harris May 21, 2025
1e6d85b
fixes
Rich-Harris May 21, 2025
f677792
fix
Rich-Harris May 21, 2025
a59f18a
fix
Rich-Harris May 21, 2025
8c819e5
unused
Rich-Harris May 21, 2025
448cff1
remove indirection
Rich-Harris May 21, 2025
747d24a
lint
Rich-Harris May 21, 2025
60813bc
tweak
Rich-Harris May 21, 2025
3afd814
tweak
Rich-Harris May 21, 2025
9106533
doh
Rich-Harris May 21, 2025
310f82d
move stuff off `state.metadata.context` and onto `state.template`
Rich-Harris May 21, 2025
98fa3c0
simplify
Rich-Harris May 21, 2025
f36df5f
remove unused arg
Rich-Harris May 21, 2025
ab5f15c
tweak
Rich-Harris May 21, 2025
bcc11ce
tweak
Rich-Harris May 21, 2025
92cc175
merge main
Rich-Harris May 21, 2025
af78465
unused
Rich-Harris May 21, 2025
40be734
put locations on template, instead of on the side
Rich-Harris May 22, 2025
7edf0c2
simplify
Rich-Harris May 22, 2025
941f266
simplify
Rich-Harris May 22, 2025
faf1822
optimise for the common case
Rich-Harris May 22, 2025
d8637fb
tweak
Rich-Harris May 22, 2025
84f15d0
tweak
Rich-Harris May 22, 2025
1be9120
tweak
Rich-Harris May 22, 2025
07aed41
colocate
Rich-Harris May 22, 2025
4039f9e
remove test duplication
Rich-Harris May 22, 2025
546fa28
more
Rich-Harris May 22, 2025
93c4d84
tweak
Rich-Harris May 22, 2025
99b7afc
undo
Rich-Harris May 22, 2025
78e3405
undo
Rich-Harris May 22, 2025
0cb475e
undo
Rich-Harris May 22, 2025
f432305
one snapshot test is probably enough
Rich-Harris May 22, 2025
32d2b4c
add a runtime test, we can always add more later if we need to
Rich-Harris May 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smart-boats-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: functional template generation
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,6 @@ export function client_component(analysis, options) {
legacy_reactive_imports: [],
legacy_reactive_statements: new Map(),
metadata: {
context: {
template_needs_import_node: false,
template_contains_script_tag: false
},
namespace: options.namespace,
bound_contenteditable: false
},
Expand All @@ -174,8 +170,7 @@ export function client_component(analysis, options) {
update: /** @type {any} */ (null),
expressions: /** @type {any} */ (null),
after_update: /** @type {any} */ (null),
template: /** @type {any} */ (null),
locations: /** @type {any} */ (null)
template: /** @type {any} */ (null)
};

const module = /** @type {ESTree.Program} */ (
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/** @import { Location } from 'locate-character' */
/** @import { Namespace } from '#compiler' */
/** @import { ComponentClientTransformState } from '../types.js' */
/** @import { Node } from './types.js' */
import { dev, locator } from '../../../../state.js';
import * as b from '../../../../utils/builders.js';

/**
*
* @param {Namespace} namespace
* @param {ComponentClientTransformState} state
* @returns
*/
function get_template_function(namespace, state) {
const contains_script_tag = state.template.contains_script_tag;
return (
namespace === 'svg'
? contains_script_tag
? '$.svg_template_with_script'
: '$.ns_template'
: namespace === 'mathml'
? '$.mathml_template'
: contains_script_tag
? '$.template_with_script'
: '$.template'
).concat(state.options.templatingMode === 'functional' ? '_fn' : '');
}

/**
* @param {Node[]} nodes
*/
function build_locations(nodes) {
const array = b.array([]);

for (const node of nodes) {
if (node.type !== 'element') continue;

const { line, column } = /** @type {Location} */ (locator(node.start));

const expression = b.array([b.literal(line), b.literal(column)]);
const children = build_locations(node.children);

if (children.elements.length > 0) {
expression.elements.push(children);
}

array.elements.push(expression);
}

return array;
}

/**
* @param {ComponentClientTransformState} state
* @param {Namespace} namespace
* @param {number} [flags]
*/
export function transform_template(state, namespace, flags) {
const expression =
state.options.templatingMode === 'functional'
? state.template.as_objects()
: state.template.as_string();

let call = b.call(
get_template_function(namespace, state),
expression,
flags ? b.literal(flags) : undefined
);

if (dev) {
return b.call(
'$.add_locations',
call,
b.member(b.id(state.analysis.name), '$.FILENAME', true),
build_locations(state.template.nodes)
);
}

return call;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/** @import { AST } from '#compiler' */
/** @import { Node, Element } from './types'; */
import { escape_html } from '../../../../../escaping.js';
import { is_void } from '../../../../../utils.js';
import * as b from '#compiler/builders';
import fix_attribute_casing from './fix-attribute-casing.js';
import { regex_starts_with_newline } from '../../../patterns.js';

export class Template {
/**
* `true` if HTML template contains a `<script>` tag. In this case we need to invoke a special
* template instantiation function (see `create_fragment_with_script_from_html` for more info)
*/
contains_script_tag = false;

/** `true` if the HTML template needs to be instantiated with `importNode` */
needs_import_node = false;

/** @type {Node[]} */
nodes = [];

/** @type {Node[][]} */
#stack = [this.nodes];

/** @type {Element | undefined} */
#element;

#fragment = this.nodes;

/**
* @param {string} name
* @param {number} start
*/
create_element(name, start) {
this.#element = {
type: 'element',
name,
attributes: {},
children: [],
start
};

this.#fragment.push(this.#element);
}

/** @param {string} [data] */
create_anchor(data) {
this.#fragment.push({ type: 'anchor', data });
}

/** @param {AST.Text[]} nodes */
create_text(nodes) {
this.#fragment.push({ type: 'text', nodes });
}

push_element() {
this.#fragment = /** @type {Element} */ (this.#element).children;
this.#stack.push(this.#fragment);
}

pop_element() {
this.#stack.pop();
this.#fragment = /** @type {Node[]} */ (this.#stack.at(-1));
}

/**
* @param {string} key
* @param {string | undefined} value
*/
set_prop(key, value) {
/** @type {Element} */ (this.#element).attributes[key] = value;
}

as_string() {
return b.template([b.quasi(this.nodes.map(stringify).join(''), true)], []);
}

as_objects() {
// if the first item is a comment we need to add another comment for effect.start
if (this.nodes[0].type === 'anchor') {
this.nodes.unshift({ type: 'anchor', data: undefined });
}

return b.array(this.nodes.map(objectify));
}
}

/**
* @param {Node} item
*/
function stringify(item) {
if (item.type === 'text') {
return item.nodes.map((node) => node.raw).join('');
}

if (item.type === 'anchor') {
return item.data ? `<!--${item.data}-->` : '<!>';
}

let str = `<${item.name}`;

for (const key in item.attributes) {
const value = item.attributes[key];

str += ` ${key}`;
if (value !== undefined) str += `="${escape_html(value, true)}"`;
}

str += `>`;
str += item.children.map(stringify).join('');

if (!is_void(item.name)) {
str += `</${item.name}>`;
}

return str;
}

/** @param {Node} item */
function objectify(item) {
if (item.type === 'text') {
return b.literal(item.nodes.map((node) => node.data).join(''));
}

if (item.type === 'anchor') {
return item.data ? b.array([b.literal(`// ${item.data}`)]) : null;
}

const element = b.array([b.literal(item.name)]);

const attributes = b.object([]);

for (const key in item.attributes) {
const value = item.attributes[key];

attributes.properties.push(
b.prop(
'init',
b.key(fix_attribute_casing(key)),
value === undefined ? b.void0 : b.literal(value)
)
);
}

if (attributes.properties.length > 0 || item.children.length > 0) {
element.elements.push(attributes.properties.length > 0 ? attributes : b.null);
}

if (item.children.length > 0) {
const children = item.children.map(objectify);
element.elements.push(...children);

// special case — strip leading newline from `<pre>` and `<textarea>`
if (item.name === 'pre' || item.name === 'textarea') {
const first = children[0];
if (first?.type === 'Literal') {
first.value = /** @type {string} */ (first.value).replace(regex_starts_with_newline, '');
}
}
}

return element;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { AST } from '#compiler';

export interface Element {
type: 'element';
name: string;
attributes: Record<string, string | undefined>;
children: Node[];
/** used for populating __svelte_meta */
start: number;
}

export interface Text {
type: 'text';
nodes: AST.Text[];
}

export interface Anchor {
type: 'anchor';
data: string | undefined;
}

export type Node = Element | Text | Anchor;
23 changes: 3 additions & 20 deletions packages/svelte/src/compiler/phases/3-transform/client/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@ import type {
Statement,
LabeledStatement,
Identifier,
PrivateIdentifier,
Expression,
AssignmentExpression,
UpdateExpression,
VariableDeclaration
} from 'estree';
import type { AST, Namespace, StateField, ValidatedCompileOptions } from '#compiler';
import type { AST, Namespace, ValidatedCompileOptions } from '#compiler';
import type { TransformState } from '../types.js';
import type { ComponentAnalysis } from '../../types.js';
import type { SourceLocation } from '#shared';
import type { Template } from './transform-template/template.js';

export interface ClientTransformState extends TransformState {
/**
Expand Down Expand Up @@ -53,26 +52,10 @@ export interface ComponentClientTransformState extends ClientTransformState {
/** Expressions used inside the render effect */
readonly expressions: Expression[];
/** The HTML template string */
readonly template: Array<string | Expression>;
readonly locations: SourceLocation[];
readonly template: Template;
readonly metadata: {
namespace: Namespace;
bound_contenteditable: boolean;
/**
* Stuff that is set within the children of one `Fragment` visitor that is relevant
* to said fragment. Shouldn't be destructured or otherwise spread unless inside the
* `Fragment` visitor to keep the object reference intact (it's also nested
* within `metadata` for this reason).
*/
context: {
/** `true` if the HTML template needs to be instantiated with `importNode` */
template_needs_import_node: boolean;
/**
* `true` if HTML template contains a `<script>` tag. In this case we need to invoke a special
* template instantiation function (see `create_fragment_with_script_from_html` for more info)
*/
template_contains_script_tag: boolean;
};
};
readonly preserve_whitespace: boolean;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.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)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.create_anchor(node.data);
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function EachBlock(node, context) {
);

if (!each_node_meta.is_controlled) {
context.state.template.push('<!>');
context.state.template.create_anchor();
}

let flags = 0;
Expand Down
Loading