Skip to content

Commit d1141a1

Browse files
feat: functional template generation (#15538)
Co-authored-by: Rich Harris <rich.harris@vercel.com>
1 parent 5a84098 commit d1141a1

File tree

61 files changed

+666
-288
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+666
-288
lines changed

.changeset/forty-llamas-unite.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': minor
3+
---
4+
5+
feat: XHTML compliance

.changeset/smart-boats-accept.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': minor
3+
---
4+
5+
feat: add `fragments: 'html' | 'tree'` option for wider CSP compliance

packages/svelte/src/compiler/phases/3-transform/client/transform-client.js

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,6 @@ export function client_component(analysis, options) {
154154
legacy_reactive_imports: [],
155155
legacy_reactive_statements: new Map(),
156156
metadata: {
157-
context: {
158-
template_needs_import_node: false,
159-
template_contains_script_tag: false
160-
},
161157
namespace: options.namespace,
162158
bound_contenteditable: false
163159
},
@@ -174,8 +170,7 @@ export function client_component(analysis, options) {
174170
update: /** @type {any} */ (null),
175171
expressions: /** @type {any} */ (null),
176172
after_update: /** @type {any} */ (null),
177-
template: /** @type {any} */ (null),
178-
locations: /** @type {any} */ (null)
173+
template: /** @type {any} */ (null)
179174
};
180175

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

packages/svelte/src/compiler/phases/3-transform/client/transform-template/fix-attribute-casing.js

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/** @import { Location } from 'locate-character' */
2+
/** @import { Namespace } from '#compiler' */
3+
/** @import { ComponentClientTransformState } from '../types.js' */
4+
/** @import { Node } from './types.js' */
5+
import { TEMPLATE_USE_MATHML, TEMPLATE_USE_SVG } from '../../../../../constants.js';
6+
import { dev, locator } from '../../../../state.js';
7+
import * as b from '../../../../utils/builders.js';
8+
9+
/**
10+
* @param {Node[]} nodes
11+
*/
12+
function build_locations(nodes) {
13+
const array = b.array([]);
14+
15+
for (const node of nodes) {
16+
if (node.type !== 'element') continue;
17+
18+
const { line, column } = /** @type {Location} */ (locator(node.start));
19+
20+
const expression = b.array([b.literal(line), b.literal(column)]);
21+
const children = build_locations(node.children);
22+
23+
if (children.elements.length > 0) {
24+
expression.elements.push(children);
25+
}
26+
27+
array.elements.push(expression);
28+
}
29+
30+
return array;
31+
}
32+
33+
/**
34+
* @param {ComponentClientTransformState} state
35+
* @param {Namespace} namespace
36+
* @param {number} [flags]
37+
*/
38+
export function transform_template(state, namespace, flags = 0) {
39+
const tree = state.options.fragments === 'tree';
40+
41+
const expression = tree ? state.template.as_tree() : state.template.as_html();
42+
43+
if (tree) {
44+
if (namespace === 'svg') flags |= TEMPLATE_USE_SVG;
45+
if (namespace === 'mathml') flags |= TEMPLATE_USE_MATHML;
46+
}
47+
48+
let call = b.call(
49+
tree ? `$.from_tree` : `$.from_${namespace}`,
50+
expression,
51+
flags ? b.literal(flags) : undefined
52+
);
53+
54+
if (state.template.contains_script_tag) {
55+
call = b.call(`$.with_script`, call);
56+
}
57+
58+
if (dev) {
59+
call = b.call(
60+
'$.add_locations',
61+
call,
62+
b.member(b.id(state.analysis.name), '$.FILENAME', true),
63+
build_locations(state.template.nodes)
64+
);
65+
}
66+
67+
return call;
68+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/** @import { AST } from '#compiler' */
2+
/** @import { Node, Element } from './types'; */
3+
import { escape_html } from '../../../../../escaping.js';
4+
import { is_void } from '../../../../../utils.js';
5+
import * as b from '#compiler/builders';
6+
import fix_attribute_casing from './fix-attribute-casing.js';
7+
import { regex_starts_with_newline } from '../../../patterns.js';
8+
9+
export class Template {
10+
/**
11+
* `true` if HTML template contains a `<script>` tag. In this case we need to invoke a special
12+
* template instantiation function (see `create_fragment_with_script_from_html` for more info)
13+
*/
14+
contains_script_tag = false;
15+
16+
/** `true` if the HTML template needs to be instantiated with `importNode` */
17+
needs_import_node = false;
18+
19+
/** @type {Node[]} */
20+
nodes = [];
21+
22+
/** @type {Node[][]} */
23+
#stack = [this.nodes];
24+
25+
/** @type {Element | undefined} */
26+
#element;
27+
28+
#fragment = this.nodes;
29+
30+
/**
31+
* @param {string} name
32+
* @param {number} start
33+
*/
34+
push_element(name, start) {
35+
this.#element = {
36+
type: 'element',
37+
name,
38+
attributes: {},
39+
children: [],
40+
start
41+
};
42+
43+
this.#fragment.push(this.#element);
44+
45+
this.#fragment = /** @type {Element} */ (this.#element).children;
46+
this.#stack.push(this.#fragment);
47+
}
48+
49+
/** @param {string} [data] */
50+
push_comment(data) {
51+
this.#fragment.push({ type: 'comment', data });
52+
}
53+
54+
/** @param {AST.Text[]} nodes */
55+
push_text(nodes) {
56+
this.#fragment.push({ type: 'text', nodes });
57+
}
58+
59+
pop_element() {
60+
this.#stack.pop();
61+
this.#fragment = /** @type {Node[]} */ (this.#stack.at(-1));
62+
}
63+
64+
/**
65+
* @param {string} key
66+
* @param {string | undefined} value
67+
*/
68+
set_prop(key, value) {
69+
/** @type {Element} */ (this.#element).attributes[key] = value;
70+
}
71+
72+
as_html() {
73+
return b.template([b.quasi(this.nodes.map(stringify).join(''), true)], []);
74+
}
75+
76+
as_tree() {
77+
// if the first item is a comment we need to add another comment for effect.start
78+
if (this.nodes[0].type === 'comment') {
79+
this.nodes.unshift({ type: 'comment', data: undefined });
80+
}
81+
82+
return b.array(this.nodes.map(objectify));
83+
}
84+
}
85+
86+
/**
87+
* @param {Node} item
88+
*/
89+
function stringify(item) {
90+
if (item.type === 'text') {
91+
return item.nodes.map((node) => node.raw).join('');
92+
}
93+
94+
if (item.type === 'comment') {
95+
return item.data ? `<!--${item.data}-->` : '<!>';
96+
}
97+
98+
let str = `<${item.name}`;
99+
100+
for (const key in item.attributes) {
101+
const value = item.attributes[key];
102+
103+
str += ` ${key}`;
104+
if (value !== undefined) str += `="${escape_html(value, true)}"`;
105+
}
106+
107+
if (is_void(item.name)) {
108+
str += '/>'; // XHTML compliance
109+
} else {
110+
str += `>`;
111+
str += item.children.map(stringify).join('');
112+
str += `</${item.name}>`;
113+
}
114+
115+
return str;
116+
}
117+
118+
/** @param {Node} item */
119+
function objectify(item) {
120+
if (item.type === 'text') {
121+
return b.literal(item.nodes.map((node) => node.data).join(''));
122+
}
123+
124+
if (item.type === 'comment') {
125+
return item.data ? b.array([b.literal(`// ${item.data}`)]) : null;
126+
}
127+
128+
const element = b.array([b.literal(item.name)]);
129+
130+
const attributes = b.object([]);
131+
132+
for (const key in item.attributes) {
133+
const value = item.attributes[key];
134+
135+
attributes.properties.push(
136+
b.prop(
137+
'init',
138+
b.key(fix_attribute_casing(key)),
139+
value === undefined ? b.void0 : b.literal(value)
140+
)
141+
);
142+
}
143+
144+
if (attributes.properties.length > 0 || item.children.length > 0) {
145+
element.elements.push(attributes.properties.length > 0 ? attributes : b.null);
146+
}
147+
148+
if (item.children.length > 0) {
149+
const children = item.children.map(objectify);
150+
element.elements.push(...children);
151+
152+
// special case — strip leading newline from `<pre>` and `<textarea>`
153+
if (item.name === 'pre' || item.name === 'textarea') {
154+
const first = children[0];
155+
if (first?.type === 'Literal') {
156+
first.value = /** @type {string} */ (first.value).replace(regex_starts_with_newline, '');
157+
}
158+
}
159+
}
160+
161+
return element;
162+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { AST } from '#compiler';
2+
3+
export interface Element {
4+
type: 'element';
5+
name: string;
6+
attributes: Record<string, string | undefined>;
7+
children: Node[];
8+
/** used for populating __svelte_meta */
9+
start: number;
10+
}
11+
12+
export interface Text {
13+
type: 'text';
14+
nodes: AST.Text[];
15+
}
16+
17+
export interface Comment {
18+
type: 'comment';
19+
data: string | undefined;
20+
}
21+
22+
export type Node = Element | Text | Comment;

packages/svelte/src/compiler/phases/3-transform/client/types.d.ts

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,15 @@ import type {
33
Statement,
44
LabeledStatement,
55
Identifier,
6-
PrivateIdentifier,
76
Expression,
87
AssignmentExpression,
98
UpdateExpression,
109
VariableDeclaration
1110
} from 'estree';
12-
import type { AST, Namespace, StateField, ValidatedCompileOptions } from '#compiler';
11+
import type { AST, Namespace, ValidatedCompileOptions } from '#compiler';
1312
import type { TransformState } from '../types.js';
1413
import type { ComponentAnalysis } from '../../types.js';
15-
import type { SourceLocation } from '#shared';
14+
import type { Template } from './transform-template/template.js';
1615

1716
export interface ClientTransformState extends TransformState {
1817
/**
@@ -53,26 +52,10 @@ export interface ComponentClientTransformState extends ClientTransformState {
5352
/** Expressions used inside the render effect */
5453
readonly expressions: Expression[];
5554
/** The HTML template string */
56-
readonly template: Array<string | Expression>;
57-
readonly locations: SourceLocation[];
55+
readonly template: Template;
5856
readonly metadata: {
5957
namespace: Namespace;
6058
bound_contenteditable: boolean;
61-
/**
62-
* Stuff that is set within the children of one `Fragment` visitor that is relevant
63-
* to said fragment. Shouldn't be destructured or otherwise spread unless inside the
64-
* `Fragment` visitor to keep the object reference intact (it's also nested
65-
* within `metadata` for this reason).
66-
*/
67-
context: {
68-
/** `true` if the HTML template needs to be instantiated with `importNode` */
69-
template_needs_import_node: boolean;
70-
/**
71-
* `true` if HTML template contains a `<script>` tag. In this case we need to invoke a special
72-
* template instantiation function (see `create_fragment_with_script_from_html` for more info)
73-
*/
74-
template_contains_script_tag: boolean;
75-
};
7659
};
7760
readonly preserve_whitespace: boolean;
7861

packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { get_value } from './shared/declarations.js';
1111
* @param {ComponentContext} context
1212
*/
1313
export function AwaitBlock(node, context) {
14-
context.state.template.push('<!>');
14+
context.state.template.push_comment();
1515

1616
// Visit {#await <expression>} first to ensure that scopes are in the correct order
1717
const expression = b.thunk(/** @type {Expression} */ (context.visit(node.expression)));

packages/svelte/src/compiler/phases/3-transform/client/visitors/Comment.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@
77
*/
88
export function Comment(node, context) {
99
// We'll only get here if comments are not filtered out, which they are unless preserveComments is true
10-
context.state.template.push(`<!--${node.data}-->`);
10+
context.state.template.push_comment(node.data);
1111
}

packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function EachBlock(node, context) {
3232
);
3333

3434
if (!each_node_meta.is_controlled) {
35-
context.state.template.push('<!>');
35+
context.state.template.push_comment();
3636
}
3737

3838
let flags = 0;

0 commit comments

Comments
 (0)