foldkit 0.146.0 → 0.148.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/dist/buildToken.d.ts +3 -0
- package/dist/buildToken.d.ts.map +1 -0
- package/dist/buildToken.js +21 -0
- package/dist/controlledDomState.d.ts +25 -0
- package/dist/controlledDomState.d.ts.map +1 -0
- package/dist/controlledDomState.js +240 -0
- package/dist/cssStyleProperties.d.ts +6 -0
- package/dist/cssStyleProperties.d.ts.map +1 -0
- package/dist/cssStyleProperties.js +91 -0
- package/dist/customElement/index.d.ts.map +1 -1
- package/dist/customElement/index.js +23 -0
- package/dist/domReflection.d.ts +73 -0
- package/dist/domReflection.d.ts.map +1 -0
- package/dist/domReflection.js +557 -0
- package/dist/experimental/index.d.ts +1 -0
- package/dist/experimental/index.d.ts.map +1 -1
- package/dist/experimental/index.js +1 -0
- package/dist/experimental/machine/machine.d.ts +22 -3
- package/dist/experimental/machine/machine.d.ts.map +1 -1
- package/dist/experimental/machine/machine.js +8 -0
- package/dist/experimental/server/entry.d.ts +73 -0
- package/dist/experimental/server/entry.d.ts.map +1 -0
- package/dist/experimental/server/entry.js +41 -0
- package/dist/experimental/server/host.d.ts +136 -0
- package/dist/experimental/server/host.d.ts.map +1 -0
- package/dist/experimental/server/host.js +359 -0
- package/dist/experimental/server/index.d.ts +5 -0
- package/dist/experimental/server/index.d.ts.map +1 -0
- package/dist/experimental/server/index.js +4 -0
- package/dist/experimental/server/public.d.ts +3 -0
- package/dist/experimental/server/public.d.ts.map +1 -0
- package/dist/experimental/server/public.js +1 -0
- package/dist/experimental/server/serialize.d.ts +43 -0
- package/dist/experimental/server/serialize.d.ts.map +1 -0
- package/dist/experimental/server/serialize.js +786 -0
- package/dist/experimental/server/server.d.ts +242 -0
- package/dist/experimental/server/server.d.ts.map +1 -0
- package/dist/experimental/server/server.js +946 -0
- package/dist/experimental/server/template.d.ts +53 -0
- package/dist/experimental/server/template.d.ts.map +1 -0
- package/dist/experimental/server/template.js +617 -0
- package/dist/html/index.d.ts +5 -0
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/index.js +474 -33
- package/dist/hydrate.d.ts +4 -0
- package/dist/hydrate.d.ts.map +1 -0
- package/dist/hydrate.js +850 -0
- package/dist/hydrationMarker.d.ts +10 -0
- package/dist/hydrationMarker.d.ts.map +1 -0
- package/dist/hydrationMarker.js +9 -0
- package/dist/hydrationMarkers.d.ts +15 -0
- package/dist/hydrationMarkers.d.ts.map +1 -0
- package/dist/hydrationMarkers.js +70 -0
- package/dist/nativeInnerHtml.d.ts +13 -0
- package/dist/nativeInnerHtml.d.ts.map +1 -0
- package/dist/nativeInnerHtml.js +30 -0
- package/dist/propertyProvenance.d.ts +33 -0
- package/dist/propertyProvenance.d.ts.map +1 -0
- package/dist/propertyProvenance.js +78 -0
- package/dist/propsModule.d.ts.map +1 -1
- package/dist/propsModule.js +149 -15
- package/dist/runtime/public.d.ts +2 -2
- package/dist/runtime/public.d.ts.map +1 -1
- package/dist/runtime/public.js +1 -1
- package/dist/runtime/runtime.d.ts +99 -36
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +412 -71
- package/dist/snabbdom/attributes.d.ts.map +1 -1
- package/dist/snabbdom/attributes.js +65 -37
- package/dist/snabbdom/h.d.ts +1 -0
- package/dist/snabbdom/h.d.ts.map +1 -1
- package/dist/snabbdom/h.js +85 -4
- package/dist/snabbdom/style.d.ts.map +1 -1
- package/dist/snabbdom/style.js +53 -34
- package/dist/snabbdom/tovnode.d.ts.map +1 -1
- package/dist/snabbdom/tovnode.js +5 -1
- package/dist/tagName.d.ts +6 -0
- package/dist/tagName.d.ts.map +1 -0
- package/dist/tagName.js +11 -0
- package/dist/test/apps/attributes.d.ts +1 -0
- package/dist/test/apps/attributes.d.ts.map +1 -1
- package/dist/test/apps/attributes.js +8 -1
- package/dist/test/apps/login.js +1 -1
- package/dist/test/matchers.d.ts.map +1 -1
- package/dist/test/matchers.js +2 -1
- package/dist/test/scene.d.ts.map +1 -1
- package/dist/test/scene.js +2 -1
- package/dist/vdom.d.ts.map +1 -1
- package/dist/vdom.js +25 -1
- package/package.json +9 -2
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
import { Array as Array_, Context, Data, Effect, Option, Predicate, Schema, pipe, } from 'effect';
|
|
2
|
+
import { html as Parse5Html, defaultTreeAdapter, parse, parseFragment, } from 'parse5';
|
|
3
|
+
import { HYDRATION_BUILD_ATTRIBUTE } from '../../buildToken.js';
|
|
4
|
+
import { MATHML_NAMESPACE, SVG_NAMESPACE, parsedAttributeName, } from '../../domReflection.js';
|
|
5
|
+
import { beginRender, createBoundaryRegistry } from '../../html/boundary.js';
|
|
6
|
+
import { __htmlBuilder as htmlBuilderFor, textDirectionToAttribute, } from '../../html/index.js';
|
|
7
|
+
import { clearRuntime, setRuntime, } from '../../html/runtimeSingleton.js';
|
|
8
|
+
import { FOLDKIT_APP_ATTRIBUTE, FOLDKIT_FLAGS_ATTRIBUTE, } from '../../hydrationMarker.js';
|
|
9
|
+
import { HYDRATION_IDENTITY_ATTRIBUTE, HYDRATION_KEY_ATTRIBUTE, } from '../../hydrationMarkers.js';
|
|
10
|
+
import { hasTrustedInnerHtml } from '../../propertyProvenance.js';
|
|
11
|
+
import { tagNameFromSelector } from '../../tagName.js';
|
|
12
|
+
import { fromString } from '../../url/index.js';
|
|
13
|
+
import { controlledValueContent, escapeAttributeValue, serializeHtml, } from './serialize.js';
|
|
14
|
+
const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
|
|
15
|
+
const isParse5Element = (node) => 'tagName' in node;
|
|
16
|
+
// Only ASCII whitespace is inter-element whitespace an HTML parser may add or
|
|
17
|
+
// drop without changing meaning. Trimming with String.prototype.trim would also
|
|
18
|
+
// treat a non-breaking space or other Unicode whitespace as ignorable, letting
|
|
19
|
+
// a visible text node the parser foster-parented out of the root (a stray
|
|
20
|
+
// ` ` a <table> spills before itself) slip past the single-root guard.
|
|
21
|
+
const ASCII_WHITESPACE_ONLY = /^[ \t\n\f\r]*$/;
|
|
22
|
+
const isIgnorableText = (node) => node.nodeName === '#text' &&
|
|
23
|
+
'value' in node &&
|
|
24
|
+
ASCII_WHITESPACE_ONLY.test(node.value);
|
|
25
|
+
// The children a vnode declares, with consecutive text merged into one run.
|
|
26
|
+
// The serializer emits adjacent text children back to back, so the parser
|
|
27
|
+
// reads them as a single text node; merging both sides makes them comparable.
|
|
28
|
+
// A zero-length run is dropped: the serializer emits no node for empty text,
|
|
29
|
+
// so the parser produces none either.
|
|
30
|
+
const normalizeVnodeChildren = (vnode) => {
|
|
31
|
+
const children = vnode.children;
|
|
32
|
+
if (children === undefined) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const items = [];
|
|
36
|
+
let text = '';
|
|
37
|
+
const flush = () => {
|
|
38
|
+
if (text !== '') {
|
|
39
|
+
items.push({ kind: 'Text', text });
|
|
40
|
+
}
|
|
41
|
+
text = '';
|
|
42
|
+
};
|
|
43
|
+
for (const child of children) {
|
|
44
|
+
if (typeof child === 'string') {
|
|
45
|
+
text += child;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const selector = child.sel;
|
|
49
|
+
if (selector === undefined || selector === '') {
|
|
50
|
+
text += child.text ?? '';
|
|
51
|
+
}
|
|
52
|
+
else if (selector === '!') {
|
|
53
|
+
flush();
|
|
54
|
+
items.push({ kind: 'Comment', text: child.text ?? '' });
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
flush();
|
|
58
|
+
items.push({ kind: 'Element', vnode: child });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
flush();
|
|
62
|
+
return items;
|
|
63
|
+
};
|
|
64
|
+
const normalizeParsedChildren = (node) => {
|
|
65
|
+
const items = [];
|
|
66
|
+
let text = '';
|
|
67
|
+
const flush = () => {
|
|
68
|
+
if (text !== '') {
|
|
69
|
+
items.push({ kind: 'Text', text });
|
|
70
|
+
}
|
|
71
|
+
text = '';
|
|
72
|
+
};
|
|
73
|
+
for (const child of node.childNodes) {
|
|
74
|
+
if (isParse5Element(child)) {
|
|
75
|
+
flush();
|
|
76
|
+
items.push({ kind: 'Element', element: child });
|
|
77
|
+
}
|
|
78
|
+
else if (child.nodeName === '#text' && 'value' in child) {
|
|
79
|
+
text += child.value;
|
|
80
|
+
}
|
|
81
|
+
else if (child.nodeName === '#comment' && 'data' in child) {
|
|
82
|
+
flush();
|
|
83
|
+
items.push({ kind: 'Comment', text: child.data });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
flush();
|
|
87
|
+
return items;
|
|
88
|
+
};
|
|
89
|
+
// The children a controlled `<textarea>` or `<output>` serializes to. Both emit
|
|
90
|
+
// their `value` prop as text content (for a textarea the serializer's
|
|
91
|
+
// leading-newline padding and the parser's leading-newline strip cancel, so the
|
|
92
|
+
// parsed text is the value verbatim), which the walk represents as a single text
|
|
93
|
+
// run, empty value omitted. An uncontrolled element serializes its own children,
|
|
94
|
+
// so they are validated normally, rejecting element children the parser folds
|
|
95
|
+
// into text.
|
|
96
|
+
const expectedControlledChildren = (vnode) => {
|
|
97
|
+
const content = controlledValueContent(vnode.data?.props);
|
|
98
|
+
if (content === undefined) {
|
|
99
|
+
return normalizeVnodeChildren(vnode);
|
|
100
|
+
}
|
|
101
|
+
return content === '' ? [] : [{ kind: 'Text', text: content }];
|
|
102
|
+
};
|
|
103
|
+
// <noscript> and <template> parse their children into a place the differ never
|
|
104
|
+
// walks (noscript content is raw text while scripting is enabled; template
|
|
105
|
+
// children live in a separate content fragment), so a view that puts elements in
|
|
106
|
+
// either can never hydrate. They surface here as an ordinary child-structure
|
|
107
|
+
// mismatch, so name the real cause rather than the generic table guidance.
|
|
108
|
+
const structureMismatch = (parsed) => {
|
|
109
|
+
const tagName = parsed.tagName.toLowerCase();
|
|
110
|
+
if (tagName === 'noscript') {
|
|
111
|
+
return new Error('[foldkit] <noscript> content cannot be server-rendered as elements. A ' +
|
|
112
|
+
'browser parses <noscript> as raw text while scripting is enabled, so ' +
|
|
113
|
+
'the child elements the view declares arrive as one text node and ' +
|
|
114
|
+
'hydration cannot converge. A <noscript> with plain text works; put ' +
|
|
115
|
+
'richer fallback markup in the HTML shell instead.');
|
|
116
|
+
}
|
|
117
|
+
if (tagName === 'template') {
|
|
118
|
+
return new Error('[foldkit] <template> content cannot be server-rendered. A browser holds ' +
|
|
119
|
+
'template children in a separate content fragment the differ does not ' +
|
|
120
|
+
'walk, so hydration cannot reconcile them. Keep <template> markup in ' +
|
|
121
|
+
'the HTML shell rather than the view.');
|
|
122
|
+
}
|
|
123
|
+
return new Error(`[foldkit] HTML parsing changed the child structure inside ` +
|
|
124
|
+
`<${parsed.tagName}>. It inserts, moves, or drops nodes the view did ` +
|
|
125
|
+
'not write (a <tbody> around a bare <tr> in a <table>, text ' +
|
|
126
|
+
'foster-parented out of a <table>), which hydration would rebuild as a ' +
|
|
127
|
+
'mismatch. Write the structure HTML parsing produces, such as explicit ' +
|
|
128
|
+
'table sections.');
|
|
129
|
+
};
|
|
130
|
+
const expectedParsedTagName = (vnode) => {
|
|
131
|
+
const tagName = tagNameFromSelector(vnode.sel ?? '');
|
|
132
|
+
return vnode.data?.ns === undefined ? tagName.toLowerCase() : tagName;
|
|
133
|
+
};
|
|
134
|
+
const foreignTagNameMismatch = (parsedTagName, authoredTagName) => new Error(`[foldkit] HTML parsing changed the foreign-content tag ` +
|
|
135
|
+
`<${authoredTagName}> to <${parsedTagName}>. SVG and MathML tag names ` +
|
|
136
|
+
'are case-sensitive when the client creates them. Use the canonical ' +
|
|
137
|
+
'tag spelling so the served and fresh client DOM agree.');
|
|
138
|
+
// Compare the child structure the browser parsed against the structure the
|
|
139
|
+
// view declared, recursively. The top-level check rejects a root that splits
|
|
140
|
+
// into siblings; this rejects a parser correction inside the root, whether an
|
|
141
|
+
// inserted element (the `<tbody>` a browser adds around a bare `<tr>`) or
|
|
142
|
+
// foster-parented text, which hydration would otherwise see as a whole-subtree
|
|
143
|
+
// mismatch.
|
|
144
|
+
const assertStructureMatches = (parsed, vnode) => {
|
|
145
|
+
// `h.InnerHTML` owns an opaque, parser-produced subtree that the vnode does
|
|
146
|
+
// not model as children, so it is left unwalked. A property that merely
|
|
147
|
+
// carries the name is not that: the serializer emits the vnode's own children
|
|
148
|
+
// for it, so they are walked like any others.
|
|
149
|
+
if (hasTrustedInnerHtml(vnode.data?.props)) {
|
|
150
|
+
assertInnerHtmlContextMatches(parsed, vnode);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const parsedChildren = normalizeParsedChildren(parsed);
|
|
154
|
+
const parsedTag = parsed.tagName.toLowerCase();
|
|
155
|
+
const vnodeChildren = parsedTag === 'textarea' || parsedTag === 'output'
|
|
156
|
+
? expectedControlledChildren(vnode)
|
|
157
|
+
: normalizeVnodeChildren(vnode);
|
|
158
|
+
if (parsedChildren.length !== vnodeChildren.length) {
|
|
159
|
+
throw structureMismatch(parsed);
|
|
160
|
+
}
|
|
161
|
+
for (const [parsedChild, vnodeChild] of Array_.zip(parsedChildren, vnodeChildren)) {
|
|
162
|
+
if (parsedChild.kind !== vnodeChild.kind) {
|
|
163
|
+
throw structureMismatch(parsed);
|
|
164
|
+
}
|
|
165
|
+
if (parsedChild.kind === 'Element' && vnodeChild.kind === 'Element') {
|
|
166
|
+
const expectedTag = expectedParsedTagName(vnodeChild.vnode);
|
|
167
|
+
const expectedNamespace = typeof vnodeChild.vnode.data?.ns === 'string'
|
|
168
|
+
? vnodeChild.vnode.data.ns
|
|
169
|
+
: HTML_NAMESPACE;
|
|
170
|
+
if (expectedNamespace !== HTML_NAMESPACE &&
|
|
171
|
+
parsedChild.element.namespaceURI === expectedNamespace &&
|
|
172
|
+
parsedChild.element.tagName !== expectedTag) {
|
|
173
|
+
throw foreignTagNameMismatch(parsedChild.element.tagName, expectedTag);
|
|
174
|
+
}
|
|
175
|
+
if (parsedChild.element.tagName !== expectedTag ||
|
|
176
|
+
parsedChild.element.namespaceURI !== expectedNamespace) {
|
|
177
|
+
throw new Error(`[foldkit] HTML parsing produced <${parsedChild.element.tagName}> ` +
|
|
178
|
+
`where the view declared <${expectedTag}> inside ` +
|
|
179
|
+
`<${parsed.tagName}>. The browser inserts or reorders elements (a ` +
|
|
180
|
+
'<tbody> around a bare <tr> in a <table>) that hydration would ' +
|
|
181
|
+
'rebuild as a mismatch. Write the structure HTML parsing produces.');
|
|
182
|
+
}
|
|
183
|
+
assertStructureMatches(parsedChild.element, vnodeChild.vnode);
|
|
184
|
+
}
|
|
185
|
+
else if (parsedChild.kind === 'Text' && vnodeChild.kind === 'Text') {
|
|
186
|
+
if (parsedChild.text !== vnodeChild.text) {
|
|
187
|
+
throw structureMismatch(parsed);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else if (parsedChild.kind === 'Comment' &&
|
|
191
|
+
vnodeChild.kind === 'Comment') {
|
|
192
|
+
if (parsedChild.text !== vnodeChild.text) {
|
|
193
|
+
throw structureMismatch(parsed);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
// injectIntoTemplate splices the rendered root where <div id="root"></div> was,
|
|
199
|
+
// so a browser parses the served markup in a <div> (in body) insertion context.
|
|
200
|
+
// parseFragment defaults to a <template> context, whose insertion mode keeps
|
|
201
|
+
// table-section tags (<td>, <tr>, <caption>, ...) that an in-body parse
|
|
202
|
+
// foster-parents or drops. Reusing a real <div> context node makes this check
|
|
203
|
+
// model the true insertion point, so a table-section root is rejected here
|
|
204
|
+
// instead of silently escaping the application root on the served page.
|
|
205
|
+
const buildDivFragmentContext = () => {
|
|
206
|
+
const [onlyChild] = parseFragment('<div></div>').childNodes;
|
|
207
|
+
if (onlyChild === undefined || !isParse5Element(onlyChild)) {
|
|
208
|
+
throw new Error('[foldkit] internal: could not build the <div> parse context');
|
|
209
|
+
}
|
|
210
|
+
return onlyChild;
|
|
211
|
+
};
|
|
212
|
+
const DIV_FRAGMENT_CONTEXT = buildDivFragmentContext();
|
|
213
|
+
// After serialization, confirm the root closes cleanly. An unterminated element
|
|
214
|
+
// inside the root (an unclosed `<textarea>`, `<script>`, `<style>`, comment, or
|
|
215
|
+
// `<plaintext>`, typically from an incomplete `InnerHTML` fragment) does not end
|
|
216
|
+
// at the root's own close tag, so it would swallow the Flags payload, the client
|
|
217
|
+
// entry, and everything after the root when the served document is parsed. The
|
|
218
|
+
// per-element structure walk skips `InnerHTML` subtrees, so this parses the root
|
|
219
|
+
// markup with a trailing sentinel comment and requires the sentinel to survive
|
|
220
|
+
// as a top-level sibling: if the root consumed it, so would the rest of the page.
|
|
221
|
+
//
|
|
222
|
+
// NOTE: the parse runs twice, because `<noscript>` has two parser modes and the
|
|
223
|
+
// document has to survive both. With scripting enabled, the state a hydrating
|
|
224
|
+
// visitor's browser is in, noscript content is raw text. With scripting
|
|
225
|
+
// disabled, the state noscript exists to serve, the same content parses as HTML,
|
|
226
|
+
// so an unterminated element inside a noscript fallback swallows the rest of the
|
|
227
|
+
// page for exactly the visitors the fallback was written for. Both modes are
|
|
228
|
+
// checked, so a fallback that frames cleanly for one visitor cannot break the
|
|
229
|
+
// document for the other.
|
|
230
|
+
const TRAILING_SENTINEL_DATA = 'foldkit-trailing-boundary';
|
|
231
|
+
const TRAILING_SENTINEL = `<!--${TRAILING_SENTINEL_DATA}-->`;
|
|
232
|
+
const closesCleanly = (html, isScriptingEnabled) => {
|
|
233
|
+
const fragment = parseFragment(DIV_FRAGMENT_CONTEXT, html + TRAILING_SENTINEL, {
|
|
234
|
+
scriptingEnabled: isScriptingEnabled,
|
|
235
|
+
});
|
|
236
|
+
const significant = fragment.childNodes.filter(node => !isIgnorableText(node));
|
|
237
|
+
const last = significant[significant.length - 1];
|
|
238
|
+
return (last !== undefined &&
|
|
239
|
+
last.nodeName === '#comment' &&
|
|
240
|
+
'data' in last &&
|
|
241
|
+
last.data === TRAILING_SENTINEL_DATA);
|
|
242
|
+
};
|
|
243
|
+
// Framing is not the only thing `<noscript>` can break. An element the fallback
|
|
244
|
+
// leaves open does not have to reach the end of the document to do damage: with
|
|
245
|
+
// scripting disabled an unclosed `<form>` or `<table>` stops at the root's close
|
|
246
|
+
// tag, so the document still frames cleanly, while everything between it and
|
|
247
|
+
// that close tag is pulled inside the fallback and disappears from the page a
|
|
248
|
+
// visitor without JavaScript sees.
|
|
249
|
+
//
|
|
250
|
+
// The check is a comparison rather than a scan, because the two parser modes
|
|
251
|
+
// have to agree on everything except the one place they are meant to differ.
|
|
252
|
+
// The serialized root is parsed both ways and the trees compared, descending
|
|
253
|
+
// into every element except `<noscript>`, whose content is raw text in one mode
|
|
254
|
+
// and markup in the other by design. What must match is where each `<noscript>`
|
|
255
|
+
// sits and what follows it. Comparing the parsed output rather than the declared
|
|
256
|
+
// vnodes also covers a `<noscript>` that arrives inside an `h.InnerHTML`
|
|
257
|
+
// fragment, which no walk over the view's own children can see.
|
|
258
|
+
// `<noscript>` means something to the parser only in the HTML namespace. Inside
|
|
259
|
+
// SVG or MathML an element of that name is an ordinary foreign element whose
|
|
260
|
+
// content parses the same way in both modes, so skipping it there would leave a
|
|
261
|
+
// real HTML `<noscript>` nested below it unchecked. `<template>` holds its
|
|
262
|
+
// children in a separate content fragment rather than in `childNodes`, so its
|
|
263
|
+
// content is traversed explicitly; a fallback inside one is invisible otherwise,
|
|
264
|
+
// and declarative shadow DOM makes that content live in the page.
|
|
265
|
+
const isHtmlNoscript = (element) => element.tagName.toLowerCase() === 'noscript' &&
|
|
266
|
+
element.namespaceURI === HTML_NAMESPACE;
|
|
267
|
+
const traversableContent = (element) => {
|
|
268
|
+
if (element.tagName.toLowerCase() === 'template' &&
|
|
269
|
+
element.namespaceURI === HTML_NAMESPACE &&
|
|
270
|
+
'content' in element) {
|
|
271
|
+
const content = element.content;
|
|
272
|
+
if (Predicate.isObject(content) &&
|
|
273
|
+
Predicate.hasProperty(content, 'childNodes')) {
|
|
274
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
275
|
+
return content;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return element;
|
|
279
|
+
};
|
|
280
|
+
const parsedChildrenAgree = (scripted, unscripted) => {
|
|
281
|
+
const scriptedChildren = normalizeParsedChildren(scripted);
|
|
282
|
+
const unscriptedChildren = normalizeParsedChildren(unscripted);
|
|
283
|
+
if (scriptedChildren.length !== unscriptedChildren.length) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
for (const [scriptedChild, unscriptedChild] of Array_.zip(scriptedChildren, unscriptedChildren)) {
|
|
287
|
+
if (scriptedChild.kind !== unscriptedChild.kind) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
if (scriptedChild.kind === 'Element' &&
|
|
291
|
+
unscriptedChild.kind === 'Element') {
|
|
292
|
+
const tagName = scriptedChild.element.tagName.toLowerCase();
|
|
293
|
+
if (tagName !== unscriptedChild.element.tagName.toLowerCase() ||
|
|
294
|
+
scriptedChild.element.namespaceURI !==
|
|
295
|
+
unscriptedChild.element.namespaceURI ||
|
|
296
|
+
!parsedAttributesAgree(scriptedChild.element, unscriptedChild.element)) {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
if (isHtmlNoscript(scriptedChild.element)) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (!parsedChildrenAgree(traversableContent(scriptedChild.element), traversableContent(unscriptedChild.element))) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
else if (scriptedChild.kind === 'Text' &&
|
|
307
|
+
unscriptedChild.kind === 'Text') {
|
|
308
|
+
if (scriptedChild.text !== unscriptedChild.text) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
else if (scriptedChild.kind === 'Comment' &&
|
|
313
|
+
unscriptedChild.kind === 'Comment') {
|
|
314
|
+
if (scriptedChild.text !== unscriptedChild.text) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return true;
|
|
320
|
+
};
|
|
321
|
+
const parsedAttributesAgree = (left, right) => {
|
|
322
|
+
const signature = (element) => element.attrs
|
|
323
|
+
.map(attribute => JSON.stringify([
|
|
324
|
+
attribute.namespace ?? '',
|
|
325
|
+
attribute.prefix ?? '',
|
|
326
|
+
attribute.name,
|
|
327
|
+
attribute.value,
|
|
328
|
+
]))
|
|
329
|
+
.sort();
|
|
330
|
+
const leftAttributes = signature(left);
|
|
331
|
+
const rightAttributes = signature(right);
|
|
332
|
+
return (leftAttributes.length === rightAttributes.length &&
|
|
333
|
+
leftAttributes.every((attribute, index) => attribute === rightAttributes[index]));
|
|
334
|
+
};
|
|
335
|
+
const assertInnerHtmlContextMatches = (parsed, vnode) => {
|
|
336
|
+
const innerHtml = vnode.data?.props?.['innerHTML'];
|
|
337
|
+
if (typeof innerHtml !== 'string') {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const context = defaultTreeAdapter.createElement(parsed.tagName, parsed.namespaceURI, parsed.attrs.map(attribute => ({ ...attribute })));
|
|
341
|
+
const freshChildren = parseFragment(context, innerHtml, {
|
|
342
|
+
scriptingEnabled: true,
|
|
343
|
+
});
|
|
344
|
+
if (parsedChildrenAgree(traversableContent(parsed), freshChildren)) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
throw new Error(`[foldkit] h.InnerHTML on <${parsed.tagName}> parses differently when ` +
|
|
348
|
+
'assigned to a fresh element than when the serialized page is parsed ' +
|
|
349
|
+
'under its ancestors. Ancestor parser state can insert, move, or drop ' +
|
|
350
|
+
'nodes from the server DOM (for example, a nested <form> is dropped ' +
|
|
351
|
+
'under an existing form), so the server and client cannot describe one ' +
|
|
352
|
+
'tree. Move the fragment outside that context or build the content with ' +
|
|
353
|
+
'ordinary view children.');
|
|
354
|
+
};
|
|
355
|
+
const assertNoscriptModesAgree = (html) => {
|
|
356
|
+
const scripted = parseFragment(DIV_FRAGMENT_CONTEXT, html, {
|
|
357
|
+
scriptingEnabled: true,
|
|
358
|
+
});
|
|
359
|
+
const unscripted = parseFragment(DIV_FRAGMENT_CONTEXT, html, {
|
|
360
|
+
scriptingEnabled: false,
|
|
361
|
+
});
|
|
362
|
+
if (parsedChildrenAgree(scripted, unscripted)) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
throw new Error('[foldkit] <noscript> content changes the rest of the page when a browser ' +
|
|
366
|
+
'parses it with scripting disabled. Its content is raw text while ' +
|
|
367
|
+
'scripting is enabled and ordinary HTML when it is not, so fallback ' +
|
|
368
|
+
'markup that leaves an element open (a <form>, <table>, <textarea>, ' +
|
|
369
|
+
'<style>, or comment) pulls the markup that follows the <noscript> ' +
|
|
370
|
+
'inside it, and the visitors the fallback was written for never see it. ' +
|
|
371
|
+
'Ensure the fallback is complete and balanced.');
|
|
372
|
+
};
|
|
373
|
+
// Declarative shadow DOM is refused rather than modeled. A browser turns a
|
|
374
|
+
// `<template shadowrootmode>` into a shadow root as it parses, moving its
|
|
375
|
+
// content out of the light DOM entirely, which neither the parser these checks
|
|
376
|
+
// use nor hydration's own probe reproduces. The served page and the tree
|
|
377
|
+
// hydration reconciles would stop describing the same ownership structure.
|
|
378
|
+
//
|
|
379
|
+
// The scan runs on the rendered markup, so it covers a fragment that arrived
|
|
380
|
+
// through `h.InnerHTML` as well as elements the view declared. It descends into
|
|
381
|
+
// template content, where a nested declaration would otherwise sit unseen, and
|
|
382
|
+
// it parses both scripting modes, because a `<noscript>` holds raw text in one
|
|
383
|
+
// and live markup in the other.
|
|
384
|
+
const DECLARATIVE_SHADOW_ROOT_ATTRIBUTES = [
|
|
385
|
+
'shadowrootmode',
|
|
386
|
+
'shadowroot',
|
|
387
|
+
];
|
|
388
|
+
const declaresShadowRoot = (element) => element.tagName.toLowerCase() === 'template' &&
|
|
389
|
+
element.namespaceURI === HTML_NAMESPACE &&
|
|
390
|
+
DECLARATIVE_SHADOW_ROOT_ATTRIBUTES.some(name => element.attrs.some(attribute => attribute.name === name));
|
|
391
|
+
// NOTE: one recursion per element. `traversableContent` returns a template's
|
|
392
|
+
// content fragment and the element itself for everything else, so descending
|
|
393
|
+
// into both it and the element visits an ordinary subtree twice, which doubles
|
|
394
|
+
// the work at every level. A tree the serializer accepts (depth up to 1000) took
|
|
395
|
+
// most of a second at depth 24 and would have blocked the event loop well before
|
|
396
|
+
// the depth limit.
|
|
397
|
+
const hasDeclarativeShadowRoot = (node) => node.childNodes.some(child => isParse5Element(child) &&
|
|
398
|
+
(declaresShadowRoot(child) ||
|
|
399
|
+
hasDeclarativeShadowRoot(traversableContent(child))));
|
|
400
|
+
export const __assertNoDeclarativeShadowRoot = (html) => {
|
|
401
|
+
const declaresInEitherMode = [true, false].some(scriptingEnabled => hasDeclarativeShadowRoot(parseFragment(DIV_FRAGMENT_CONTEXT, html, { scriptingEnabled })));
|
|
402
|
+
if (!declaresInEitherMode) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
throw new Error('[foldkit] A rendered <template> declares a shadow root, which server ' +
|
|
406
|
+
'rendering does not support. A browser turns it into a shadow root ' +
|
|
407
|
+
'while parsing, moving its content out of the light DOM, so the served ' +
|
|
408
|
+
'page and the tree hydration reconciles no longer describe the same ' +
|
|
409
|
+
'thing. Attach shadow roots from a custom element instead.');
|
|
410
|
+
};
|
|
411
|
+
// A <base> parsed anywhere in the live HTML tree changes how every relative URL
|
|
412
|
+
// after it resolves, including the client entry a host writes after the rendered
|
|
413
|
+
// application. The browser applies a base element in body before hydration can
|
|
414
|
+
// remove it, so a view could redirect its own bootstrap module to another
|
|
415
|
+
// origin. Template content is inert and does not participate in URL resolution;
|
|
416
|
+
// declarative shadow templates are refused separately.
|
|
417
|
+
const hasLiveBaseElement = (node) => {
|
|
418
|
+
for (const child of node.childNodes) {
|
|
419
|
+
if (!isParse5Element(child)) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const tagName = child.tagName.toLowerCase();
|
|
423
|
+
if (child.namespaceURI === HTML_NAMESPACE && tagName === 'base') {
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
if (child.namespaceURI === HTML_NAMESPACE && tagName === 'template') {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (hasLiveBaseElement(child)) {
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return false;
|
|
434
|
+
};
|
|
435
|
+
export const __assertNoLiveBaseElement = (html) => {
|
|
436
|
+
const hasBaseInEitherMode = [true, false].some(scriptingEnabled => hasLiveBaseElement(parseFragment(DIV_FRAGMENT_CONTEXT, html, { scriptingEnabled })));
|
|
437
|
+
if (!hasBaseInEitherMode) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
throw new Error('[foldkit] Rendered application markup contains a live HTML <base> ' +
|
|
441
|
+
'element. A browser applies it before hydration, so it changes every ' +
|
|
442
|
+
'relative URL that follows the application, including the client entry ' +
|
|
443
|
+
'module, and can redirect startup to another origin. Put <base> in the ' +
|
|
444
|
+
'HTML template head under host control instead.');
|
|
445
|
+
};
|
|
446
|
+
// A page the rendered markup is placed into, with a probe element standing
|
|
447
|
+
// where the application root goes. Parsing the whole document is what makes a
|
|
448
|
+
// structural escape visible: `<html>` and `<body>` start tags inside the markup
|
|
449
|
+
// are dropped by a fragment parse, so a check that only ever sees a fragment
|
|
450
|
+
// reads them as harmless. A browser instead merges their attributes onto the
|
|
451
|
+
// document's own elements and hoists their content, which puts them outside the
|
|
452
|
+
// application's ownership entirely.
|
|
453
|
+
const DOCUMENT_PROBE_ID = 'foldkit-document-probe';
|
|
454
|
+
const DOCUMENT_PROBE_PREFIX = '<!doctype html><html><head><title>probe</title></head><body><div id="' +
|
|
455
|
+
DOCUMENT_PROBE_ID +
|
|
456
|
+
'">';
|
|
457
|
+
const DOCUMENT_PROBE_SUFFIX = '</div></body></html>';
|
|
458
|
+
const childElementsOf = (node) => node.childNodes.filter(isParse5Element);
|
|
459
|
+
const findByTag = (node, tagName) => childElementsOf(node).find(element => element.tagName.toLowerCase() === tagName);
|
|
460
|
+
const escapesDocumentStructure = (html, scriptingEnabled) => {
|
|
461
|
+
const document = parse(`${DOCUMENT_PROBE_PREFIX}${html}${DOCUMENT_PROBE_SUFFIX}`, { scriptingEnabled });
|
|
462
|
+
const documentElement = findByTag(document, 'html');
|
|
463
|
+
if (documentElement === undefined) {
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
const head = findByTag(documentElement, 'head');
|
|
467
|
+
const body = findByTag(documentElement, 'body');
|
|
468
|
+
if (head === undefined || body === undefined) {
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
const probe = childElementsOf(body)[0];
|
|
472
|
+
return (documentElement.attrs.length > 0 ||
|
|
473
|
+
body.attrs.length > 0 ||
|
|
474
|
+
head.attrs.length > 0 ||
|
|
475
|
+
childElementsOf(head).length !== 1 ||
|
|
476
|
+
childElementsOf(body).length !== 1 ||
|
|
477
|
+
probe === undefined ||
|
|
478
|
+
probe.attrs.find(attribute => attribute.name === 'id')?.value !==
|
|
479
|
+
DOCUMENT_PROBE_ID);
|
|
480
|
+
};
|
|
481
|
+
export const __assertNoDocumentStructureEscape = (html) => {
|
|
482
|
+
if (![true, false].some(mode => escapesDocumentStructure(html, mode))) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
throw new Error('[foldkit] The rendered markup changes the document it is placed into. ' +
|
|
486
|
+
'An <html>, <head>, <body>, or <frameset> tag inside it, typically from ' +
|
|
487
|
+
'an InnerHTML fragment, is not rendered where it is written: a browser ' +
|
|
488
|
+
"merges its attributes onto the page's own elements and hoists its " +
|
|
489
|
+
'content out of the application root, so the result is neither the ' +
|
|
490
|
+
'markup the view wrote nor anything the application owns. Remove those ' +
|
|
491
|
+
'tags from the fragment.');
|
|
492
|
+
};
|
|
493
|
+
const assertRootClosesCleanly = (html) => {
|
|
494
|
+
if (closesCleanly(html, true) && closesCleanly(html, false)) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
throw new Error('[foldkit] The rendered root does not close cleanly. An unterminated ' +
|
|
498
|
+
'element (an unclosed <textarea>, <script>, <style>, comment, or ' +
|
|
499
|
+
'<plaintext>, typically inside an InnerHTML fragment) would swallow the ' +
|
|
500
|
+
'Flags payload, the client entry, and the rest of the document when the ' +
|
|
501
|
+
'page is parsed. The check covers <noscript> fallback markup with ' +
|
|
502
|
+
'scripting disabled too, where the content parses as HTML rather than as ' +
|
|
503
|
+
'raw text. Ensure InnerHTML fragments are complete and balanced.');
|
|
504
|
+
};
|
|
505
|
+
// The tags that name a document's own structure. A browser builds these from
|
|
506
|
+
// the served document, not from markup spliced into it: a `<body>` or `<head>`
|
|
507
|
+
// start tag encountered inside the document body is dropped and its children
|
|
508
|
+
// are hoisted, an `<html>` start tag only merges its attributes onto the
|
|
509
|
+
// document element, and `<frameset>` replaces the body outright. A view rooted
|
|
510
|
+
// at one of them therefore serializes to markup no template can hold, so it is
|
|
511
|
+
// refused by name rather than surfacing later as a parser-rearranged root.
|
|
512
|
+
const DOCUMENT_STRUCTURE_TAGS = new Set([
|
|
513
|
+
'body',
|
|
514
|
+
'frameset',
|
|
515
|
+
'head',
|
|
516
|
+
'html',
|
|
517
|
+
]);
|
|
518
|
+
const assertRootIsNotDocumentStructure = (root) => {
|
|
519
|
+
const tagName = tagNameFromSelector(root.sel ?? '').toLowerCase();
|
|
520
|
+
if (!DOCUMENT_STRUCTURE_TAGS.has(tagName)) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
throw new Error(`[foldkit] The view root is <${tagName}>, which names the structure of a ` +
|
|
524
|
+
'document rather than content placed into one. A browser builds those ' +
|
|
525
|
+
'elements from the document it parses, so the tag is dropped, merged, ' +
|
|
526
|
+
'or replaces the body when the rendered markup is spliced into a ' +
|
|
527
|
+
'template, and the served root is not the element the view wrote. This ' +
|
|
528
|
+
'holds for static output too. Root the view at an ordinary element ' +
|
|
529
|
+
'such as a <div> or <main>, and set the document title, lang, and dir ' +
|
|
530
|
+
'through the Document the view returns.');
|
|
531
|
+
};
|
|
532
|
+
const RESERVED_HANDOFF_ATTRIBUTES = new Set([
|
|
533
|
+
FOLDKIT_APP_ATTRIBUTE,
|
|
534
|
+
HYDRATION_BUILD_ATTRIBUTE,
|
|
535
|
+
FOLDKIT_FLAGS_ATTRIBUTE,
|
|
536
|
+
HYDRATION_KEY_ATTRIBUTE,
|
|
537
|
+
HYDRATION_IDENTITY_ATTRIBUTE,
|
|
538
|
+
]);
|
|
539
|
+
const reservedHandoffAttributeIn = (node) => {
|
|
540
|
+
for (const child of node.childNodes) {
|
|
541
|
+
if (!isParse5Element(child)) {
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const marker = child.attrs.find(attribute => RESERVED_HANDOFF_ATTRIBUTES.has(attribute.name));
|
|
545
|
+
if (marker !== undefined) {
|
|
546
|
+
return marker.name;
|
|
547
|
+
}
|
|
548
|
+
const nested = reservedHandoffAttributeIn(traversableContent(child));
|
|
549
|
+
if (nested !== undefined) {
|
|
550
|
+
return nested;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return undefined;
|
|
554
|
+
};
|
|
555
|
+
const hasScriptElement = (node) => node.childNodes.some(child => isParse5Element(child) &&
|
|
556
|
+
(child.tagName.toLowerCase() === 'script' ||
|
|
557
|
+
hasScriptElement(traversableContent(child))));
|
|
558
|
+
const assertViewDoesNotAuthorReservedContent = (node) => {
|
|
559
|
+
const attrs = node.data?.attrs;
|
|
560
|
+
if (attrs !== undefined) {
|
|
561
|
+
for (const name of Object.keys(attrs)) {
|
|
562
|
+
const parsedName = parsedAttributeName(node.data?.ns, name);
|
|
563
|
+
if (RESERVED_HANDOFF_ATTRIBUTES.has(parsedName)) {
|
|
564
|
+
throw new Error(`[foldkit] The view authored ${parsedName}, which is reserved for ` +
|
|
565
|
+
'Foldkit\u2019s server-to-client hydration handoff. Application ' +
|
|
566
|
+
'markup cannot own root, build, Flags, key, or identity markers. ' +
|
|
567
|
+
'Remove the attribute.');
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const innerHtml = node.data?.props?.['innerHTML'];
|
|
572
|
+
if (typeof innerHtml === 'string' && hasTrustedInnerHtml(node.data?.props)) {
|
|
573
|
+
const tagName = expectedParsedTagName(node);
|
|
574
|
+
const namespace = node.data?.ns === SVG_NAMESPACE
|
|
575
|
+
? Parse5Html.NS.SVG
|
|
576
|
+
: node.data?.ns === MATHML_NAMESPACE
|
|
577
|
+
? Parse5Html.NS.MATHML
|
|
578
|
+
: Parse5Html.NS.HTML;
|
|
579
|
+
const context = defaultTreeAdapter.createElement(tagName, namespace, []);
|
|
580
|
+
const parsedFragments = [true, false].map(scriptingEnabled => parseFragment(context, innerHtml, { scriptingEnabled }));
|
|
581
|
+
const marker = parsedFragments
|
|
582
|
+
.map(reservedHandoffAttributeIn)
|
|
583
|
+
.find(candidate => candidate !== undefined);
|
|
584
|
+
if (marker !== undefined) {
|
|
585
|
+
throw new Error(`[foldkit] h.InnerHTML on <${tagName}> authored ${marker}, which is ` +
|
|
586
|
+
'reserved for Foldkit\u2019s server-to-client hydration handoff. ' +
|
|
587
|
+
'Application markup cannot own root, build, Flags, key, or identity ' +
|
|
588
|
+
'markers. Remove the attribute.');
|
|
589
|
+
}
|
|
590
|
+
if (parsedFragments.some(hasScriptElement)) {
|
|
591
|
+
throw new Error(`[foldkit] h.InnerHTML on <${tagName}> contains a <script> element. ` +
|
|
592
|
+
'A script element created while parsing served HTML is not ' +
|
|
593
|
+
'equivalent to one created by assigning innerHTML. Executable forms ' +
|
|
594
|
+
'can run only on the server path, and other script types have ' +
|
|
595
|
+
'type-specific processing that Foldkit does not model. Foldkit ' +
|
|
596
|
+
'therefore refuses classic, module, import-map, speculation-rules, ' +
|
|
597
|
+
'and data-block scripts at this boundary. Build the script as an ' +
|
|
598
|
+
'ordinary view element or move it to the HTML template instead.');
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
for (const child of node.children ?? []) {
|
|
602
|
+
if (typeof child !== 'string') {
|
|
603
|
+
assertViewDoesNotAuthorReservedContent(child);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
const elementsAtAndBelow = (element) => {
|
|
608
|
+
const elements = [element];
|
|
609
|
+
for (const child of traversableContent(element).childNodes) {
|
|
610
|
+
if (isParse5Element(child)) {
|
|
611
|
+
elements.push(...elementsAtAndBelow(child));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return elements;
|
|
615
|
+
};
|
|
616
|
+
const elementsCarrying = (root, attributeName) => elementsAtAndBelow(root).filter(element => element.attrs.some(attribute => attribute.name === attributeName));
|
|
617
|
+
const assertNoReservedHandoffMarkers = (root) => {
|
|
618
|
+
const maybeMarker = Array_.findFirst(Array_.fromIterable(RESERVED_HANDOFF_ATTRIBUTES), attributeName => elementsCarrying(root, attributeName).length > 0);
|
|
619
|
+
if (Option.isNone(maybeMarker)) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
throw new Error(`[foldkit] Static output contains ${maybeMarker.value}, which is reserved for ` +
|
|
623
|
+
'Foldkit\u2019s server-to-client hydration handoff. A render that no ' +
|
|
624
|
+
'client will hydrate must not carry hydration markers.');
|
|
625
|
+
};
|
|
626
|
+
// After serialization, parse the stamped root markup with the same HTML parser
|
|
627
|
+
// a browser uses and require it to describe exactly one element: the stamped
|
|
628
|
+
// root, with the tag and namespace the view produced. A view can serialize a
|
|
629
|
+
// structurally invalid shape the parser rearranges (a block element inside a
|
|
630
|
+
// `<p>`, a stray element inside a `<table>`, an HTML element inside `<svg>`),
|
|
631
|
+
// moving nodes outside the element the client hydrates. Hydration owns only
|
|
632
|
+
// that root, so it cannot remove escaped siblings; rejecting here keeps the
|
|
633
|
+
// invariant that the served root parses back to the single intended element.
|
|
634
|
+
// The single element the served markup parses back to, or a refusal naming what
|
|
635
|
+
// the parser did to it instead.
|
|
636
|
+
const parsedRootOf = (html, root) => {
|
|
637
|
+
const fragment = parseFragment(DIV_FRAGMENT_CONTEXT, html, {});
|
|
638
|
+
const significant = fragment.childNodes.filter(node => !isIgnorableText(node));
|
|
639
|
+
const only = significant[0];
|
|
640
|
+
if (significant.length !== 1 ||
|
|
641
|
+
only === undefined ||
|
|
642
|
+
!isParse5Element(only)) {
|
|
643
|
+
throw new Error('[foldkit] The rendered root serialized to markup that HTML parsing ' +
|
|
644
|
+
'splits into more than one top-level node. An element the parser ' +
|
|
645
|
+
'moves out of its parent (a block element inside a <p>, a stray ' +
|
|
646
|
+
'element inside a <table>, or an HTML element inside an <svg>) ' +
|
|
647
|
+
'leaves content outside the application root. Keep the view root a ' +
|
|
648
|
+
'single, structurally valid element tree.');
|
|
649
|
+
}
|
|
650
|
+
const expectedTag = expectedParsedTagName(root);
|
|
651
|
+
const expectedNamespace = typeof root.data?.ns === 'string' ? root.data.ns : HTML_NAMESPACE;
|
|
652
|
+
if (expectedNamespace !== HTML_NAMESPACE &&
|
|
653
|
+
only.namespaceURI === expectedNamespace &&
|
|
654
|
+
only.tagName !== expectedTag) {
|
|
655
|
+
throw foreignTagNameMismatch(only.tagName, expectedTag);
|
|
656
|
+
}
|
|
657
|
+
if (only.tagName !== expectedTag || only.namespaceURI !== expectedNamespace) {
|
|
658
|
+
throw new Error('[foldkit] HTML parsing reinterpreted the rendered root as a ' +
|
|
659
|
+
`<${only.tagName}>, not the view's <${expectedTag}>. Keep the view ` +
|
|
660
|
+
'root a single, structurally valid element.');
|
|
661
|
+
}
|
|
662
|
+
return only;
|
|
663
|
+
};
|
|
664
|
+
// After serialization, parse the root markup with the same HTML parser a browser
|
|
665
|
+
// uses and require it to describe the tree the view wrote. A view can serialize
|
|
666
|
+
// a structurally invalid shape the parser rearranges (a block element inside a
|
|
667
|
+
// `<p>`, a bare `<tr>` in a `<table>`, text foster-parented out of a table),
|
|
668
|
+
// moving or dropping nodes the view declared.
|
|
669
|
+
//
|
|
670
|
+
// This runs for a static render too. Hydration is what would otherwise rebuild a
|
|
671
|
+
// reshaped subtree, so without it a dropped subtree is simply lost, with nothing
|
|
672
|
+
// left to notice. What stays conditional is the marker check below: only a
|
|
673
|
+
// hydratable render emits a stamp for the parser to move.
|
|
674
|
+
const assertParsedRootMatchesView = (html, root) => {
|
|
675
|
+
const parsed = parsedRootOf(html, root);
|
|
676
|
+
assertNoReservedHandoffMarkers(parsed);
|
|
677
|
+
assertStructureMatches(parsed, root);
|
|
678
|
+
};
|
|
679
|
+
const assertSingleStampedRoot = (html, runtimeId, buildId, root) => {
|
|
680
|
+
const only = parsedRootOf(html, root);
|
|
681
|
+
const applicationMarkers = elementsCarrying(only, FOLDKIT_APP_ATTRIBUTE);
|
|
682
|
+
const buildMarkers = elementsCarrying(only, HYDRATION_BUILD_ATTRIBUTE);
|
|
683
|
+
const flagsMarkers = elementsCarrying(only, FOLDKIT_FLAGS_ATTRIBUTE);
|
|
684
|
+
const stamp = only.attrs.find(attribute => attribute.name === FOLDKIT_APP_ATTRIBUTE);
|
|
685
|
+
const buildStamp = only.attrs.find(attribute => attribute.name === HYDRATION_BUILD_ATTRIBUTE);
|
|
686
|
+
if (applicationMarkers.length !== 1 ||
|
|
687
|
+
applicationMarkers[0] !== only ||
|
|
688
|
+
stamp?.value !== runtimeId ||
|
|
689
|
+
buildMarkers.length !== 1 ||
|
|
690
|
+
buildMarkers[0] !== only ||
|
|
691
|
+
buildStamp?.value !== buildId ||
|
|
692
|
+
flagsMarkers.length !== 0) {
|
|
693
|
+
throw new Error('[foldkit] The rendered root does not carry exactly one Foldkit root ' +
|
|
694
|
+
'and build marker on the root itself, or application markup contains ' +
|
|
695
|
+
'a reserved root, build, or Flags marker below it. The client could ' +
|
|
696
|
+
'resolve an ambiguous root or payload and refuse the page. Remove ' +
|
|
697
|
+
'application-authored data-foldkit handoff markers.');
|
|
698
|
+
}
|
|
699
|
+
assertStructureMatches(only, root);
|
|
700
|
+
};
|
|
701
|
+
export { FOLDKIT_APP_ATTRIBUTE, FOLDKIT_FLAGS_ATTRIBUTE };
|
|
702
|
+
const DEFAULT_RUNTIME_ID = 'app';
|
|
703
|
+
/** Failure of a routing render whose `url` option cannot be parsed.
|
|
704
|
+
*
|
|
705
|
+
* @experimental Ships from `foldkit/experimental/server`; expect breaking changes while the API settles.
|
|
706
|
+
*/
|
|
707
|
+
export class InvalidUrl extends Data.TaggedError('InvalidUrl') {
|
|
708
|
+
}
|
|
709
|
+
/** Failure producing the Flags payload: the Schema encode step rejected the
|
|
710
|
+
* Flags value, the encoded value could not be serialized to JSON, or the
|
|
711
|
+
* encoded value could not be decoded back for the hydration-consistent
|
|
712
|
+
* render.
|
|
713
|
+
*
|
|
714
|
+
* @experimental Ships from `foldkit/experimental/server`; expect breaking changes while the API settles.
|
|
715
|
+
*/
|
|
716
|
+
export class FlagsEncodeError extends Data.TaggedError('FlagsEncodeError') {
|
|
717
|
+
}
|
|
718
|
+
/** Failure serializing the view-produced vnode tree to safe HTML.
|
|
719
|
+
*
|
|
720
|
+
* @experimental Ships from `foldkit/experimental/server`; expect breaking changes while the API settles.
|
|
721
|
+
*/
|
|
722
|
+
export class SerializationError extends Data.TaggedError('SerializationError') {
|
|
723
|
+
}
|
|
724
|
+
/** Failure of a render whose `runtimeId` is empty.
|
|
725
|
+
*
|
|
726
|
+
* @experimental Ships from `foldkit/experimental/server`; expect breaking changes while the API settles.
|
|
727
|
+
*/
|
|
728
|
+
export class InvalidRuntimeId extends Data.TaggedError('InvalidRuntimeId') {
|
|
729
|
+
}
|
|
730
|
+
/** Failure of a hydratable render whose view did not return an element root.
|
|
731
|
+
* Text, comments, and an empty body cannot carry the hydration marker the
|
|
732
|
+
* client runtime uses to adopt the server-rendered DOM.
|
|
733
|
+
*
|
|
734
|
+
* @experimental Ships from `foldkit/experimental/server`; expect breaking changes while the API settles.
|
|
735
|
+
*/
|
|
736
|
+
export class InvalidHydrationRoot extends Data.TaggedError('InvalidHydrationRoot') {
|
|
737
|
+
}
|
|
738
|
+
/** Failure of a hydratable render that was given no build id. Hydration
|
|
739
|
+
* compares the id on the served root with the client's own to refuse a page
|
|
740
|
+
* from another deployment, so a render that carries none has no such
|
|
741
|
+
* protection. Supply one from a value the deployment already has, such as a
|
|
742
|
+
* commit or a release tag, through `@foldkit/vite-plugin`'s `buildId` option or
|
|
743
|
+
* the `FOLDKIT_BUILD_ID` environment variable, and pass
|
|
744
|
+
* `import.meta.env.FOLDKIT_BUILD_ID` to `renderToString` and `Runtime.hydrate`.
|
|
745
|
+
* A render that nothing will hydrate (`isHydratable: false`) needs none.
|
|
746
|
+
*
|
|
747
|
+
* @experimental Ships from `foldkit/experimental/server`; expect breaking changes while the API settles.
|
|
748
|
+
*/
|
|
749
|
+
export class MissingBuildId extends Data.TaggedError('MissingBuildId') {
|
|
750
|
+
}
|
|
751
|
+
const noOpDispatch = () => { };
|
|
752
|
+
// NOTE: the html builder reads its dispatch context from a process-wide
|
|
753
|
+
// frame stack (`setRuntime` / `clearRuntime`) rather than an argument, so
|
|
754
|
+
// this push/render/pop bracket must never interleave with another render's.
|
|
755
|
+
// It cannot: JavaScript switches tasks only at async boundaries, and the
|
|
756
|
+
// bracket is fully synchronous (`view` returns a Document without awaiting),
|
|
757
|
+
// so it runs to completion before any other render can start. That atomicity
|
|
758
|
+
// is why no per-request context (AsyncLocalStorage) is needed. The Scene
|
|
759
|
+
// test harness and the client runtime drive a view the same way. A `view`
|
|
760
|
+
// that suspended mid-render would break the invariant; views are pure and
|
|
761
|
+
// cannot.
|
|
762
|
+
const runView = (view, model) => {
|
|
763
|
+
const boundaryRegistry = createBoundaryRegistry();
|
|
764
|
+
beginRender(boundaryRegistry);
|
|
765
|
+
setRuntime(noOpDispatch, Context.empty(), boundaryRegistry);
|
|
766
|
+
try {
|
|
767
|
+
return view(model, htmlBuilderFor());
|
|
768
|
+
}
|
|
769
|
+
finally {
|
|
770
|
+
clearRuntime();
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
// NOTE: `<` becomes `\u003c` inside the payload so no embedded value can
|
|
774
|
+
// form a `</script>` sequence and close the element early. The escape is
|
|
775
|
+
// JSON-native, so `JSON.parse` restores the original character during
|
|
776
|
+
// hydration.
|
|
777
|
+
const escapeJsonForScriptElement = (json) => json.replace(/</g, '\\u003c');
|
|
778
|
+
const flagsPayloadScript = (runtimeId, json) => `<script type="application/json" ${FOLDKIT_FLAGS_ATTRIBUTE}="${escapeAttributeValue(runtimeId)}">${escapeJsonForScriptElement(json)}</script>`;
|
|
779
|
+
const encodeFlagsHandoff = (FlagsCodec, flags, runtimeId) => Effect.gen(function* () {
|
|
780
|
+
const FlagsJsonCodec = Schema.toCodecJson(FlagsCodec);
|
|
781
|
+
const encodedFlags = yield* pipe(flags, Schema.encodeEffect(FlagsJsonCodec), Effect.mapError(cause => new FlagsEncodeError({ cause })));
|
|
782
|
+
const json = yield* Effect.try({
|
|
783
|
+
try: () => JSON.stringify(encodedFlags),
|
|
784
|
+
catch: cause => new FlagsEncodeError({ cause }),
|
|
785
|
+
});
|
|
786
|
+
if (!Predicate.isString(json)) {
|
|
787
|
+
return yield* Effect.fail(new FlagsEncodeError({
|
|
788
|
+
cause: new Error('Flags encoded to a value JSON cannot represent, so no payload can be embedded'),
|
|
789
|
+
}));
|
|
790
|
+
}
|
|
791
|
+
// NOTE: the hydrating client reconstructs Flags by parsing the payload
|
|
792
|
+
// JSON and decoding it synchronously, so the server render must call init
|
|
793
|
+
// with that same value. Decoding the in-memory encoded value instead would
|
|
794
|
+
// diverge wherever JSON is not the identity (a -0 serializes to 0, a
|
|
795
|
+
// non-finite number to null), and decoding through an Effect would accept an
|
|
796
|
+
// asynchronous codec the synchronous client cannot, deferring the failure to
|
|
797
|
+
// a client crash. Parsing json and decoding it the same synchronous way the
|
|
798
|
+
// client does avoids both: the served DOM matches the client's first render,
|
|
799
|
+
// and an incompatible codec fails here as a typed error.
|
|
800
|
+
const hydrationFlags = yield* Effect.try({
|
|
801
|
+
try: () => Schema.decodeUnknownSync(FlagsJsonCodec)(JSON.parse(json)),
|
|
802
|
+
catch: cause => new FlagsEncodeError({ cause }),
|
|
803
|
+
});
|
|
804
|
+
return {
|
|
805
|
+
payloadScript: flagsPayloadScript(runtimeId, json),
|
|
806
|
+
hydrationFlags,
|
|
807
|
+
};
|
|
808
|
+
});
|
|
809
|
+
const parseUrl = (url) => Option.match(fromString(url), {
|
|
810
|
+
onNone: () => Effect.fail(new InvalidUrl({ url })),
|
|
811
|
+
onSome: Effect.succeed,
|
|
812
|
+
});
|
|
813
|
+
// The client defaults canonical to `origin + pathname + search` of the current
|
|
814
|
+
// location, which drops the fragment and normalizes host case and default
|
|
815
|
+
// ports. Building the server default with the WHATWG URL parser reproduces that
|
|
816
|
+
// exact string, so the metadata a crawler reads before hydration matches what
|
|
817
|
+
// the hydrated page computes.
|
|
818
|
+
const normalizedRequestUrl = (rawUrl) => {
|
|
819
|
+
if (rawUrl === undefined) {
|
|
820
|
+
return undefined;
|
|
821
|
+
}
|
|
822
|
+
try {
|
|
823
|
+
const parsed = new URL(rawUrl);
|
|
824
|
+
return `${parsed.origin}${parsed.pathname}${parsed.search}`;
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
return undefined;
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
const validateHydrationRoot = (body) => {
|
|
831
|
+
if (body === null) {
|
|
832
|
+
return Effect.fail(new InvalidHydrationRoot({ rootKind: 'Empty' }));
|
|
833
|
+
}
|
|
834
|
+
if (body.sel === undefined || body.sel === '') {
|
|
835
|
+
return Effect.fail(new InvalidHydrationRoot({ rootKind: 'Text' }));
|
|
836
|
+
}
|
|
837
|
+
if (body.sel === '!') {
|
|
838
|
+
return Effect.fail(new InvalidHydrationRoot({ rootKind: 'Comment' }));
|
|
839
|
+
}
|
|
840
|
+
return Effect.void;
|
|
841
|
+
};
|
|
842
|
+
export function renderToString(config, options) {
|
|
843
|
+
return Effect.gen(function* () {
|
|
844
|
+
const runtimeId = options?.runtimeId ?? DEFAULT_RUNTIME_ID;
|
|
845
|
+
if (runtimeId === '') {
|
|
846
|
+
return yield* Effect.fail(new InvalidRuntimeId({
|
|
847
|
+
runtimeId,
|
|
848
|
+
}));
|
|
849
|
+
}
|
|
850
|
+
const hasRouting = config.routing !== undefined;
|
|
851
|
+
const FlagsCodec = config.Flags;
|
|
852
|
+
const isHydratable = options?.isHydratable ?? true;
|
|
853
|
+
// A hydratable render must say which deployment it came from. Deriving it
|
|
854
|
+
// here is not possible: the render cannot see the sources, the
|
|
855
|
+
// configuration, or the dependencies that decide what the view produced, so
|
|
856
|
+
// an id it invented would either differ between the client and server
|
|
857
|
+
// builds of one deployment or be shared by two that render differently.
|
|
858
|
+
// Refusing is the only honest answer, and it names what to supply.
|
|
859
|
+
const configuredBuildId = options?.buildId;
|
|
860
|
+
if (isHydratable &&
|
|
861
|
+
(configuredBuildId === undefined || configuredBuildId === '')) {
|
|
862
|
+
return yield* Effect.fail(new MissingBuildId());
|
|
863
|
+
}
|
|
864
|
+
const buildId = configuredBuildId ?? '';
|
|
865
|
+
const url = hasRouting ? yield* parseUrl(options?.url ?? '') : undefined;
|
|
866
|
+
const flagsHandoff = isHydratable && FlagsCodec !== undefined
|
|
867
|
+
? yield* encodeFlagsHandoff(FlagsCodec, options?.flags, runtimeId)
|
|
868
|
+
: undefined;
|
|
869
|
+
const flagsForInit = flagsHandoff !== undefined ? flagsHandoff.hydrationFlags : options?.flags;
|
|
870
|
+
const initReturn = (() => {
|
|
871
|
+
if (FlagsCodec !== undefined) {
|
|
872
|
+
return hasRouting
|
|
873
|
+
? config.init(flagsForInit, url)
|
|
874
|
+
: config.init(flagsForInit);
|
|
875
|
+
}
|
|
876
|
+
return hasRouting ? config.init(url) : config.init();
|
|
877
|
+
})();
|
|
878
|
+
const [model] = initReturn;
|
|
879
|
+
const nextDocument = runView(config.view, model);
|
|
880
|
+
if (isHydratable) {
|
|
881
|
+
yield* validateHydrationRoot(nextDocument.body);
|
|
882
|
+
}
|
|
883
|
+
const rootHtml = yield* Effect.try({
|
|
884
|
+
try: () => {
|
|
885
|
+
// Checked for every render, hydratable or not: static markup is placed
|
|
886
|
+
// into a document the same way, so a document-structure root is
|
|
887
|
+
// rearranged by the parser either way.
|
|
888
|
+
if (nextDocument.body !== null) {
|
|
889
|
+
assertRootIsNotDocumentStructure(nextDocument.body);
|
|
890
|
+
assertViewDoesNotAuthorReservedContent(nextDocument.body);
|
|
891
|
+
}
|
|
892
|
+
// The build id rides on the root so hydration can refuse a page from
|
|
893
|
+
// another deployment before it adopts any of its DOM.
|
|
894
|
+
const html = serializeHtml(nextDocument.body, isHydratable
|
|
895
|
+
? {
|
|
896
|
+
rootAttributes: {
|
|
897
|
+
[FOLDKIT_APP_ATTRIBUTE]: runtimeId,
|
|
898
|
+
[HYDRATION_BUILD_ATTRIBUTE]: buildId,
|
|
899
|
+
},
|
|
900
|
+
emitHydrationMarkers: true,
|
|
901
|
+
}
|
|
902
|
+
: {});
|
|
903
|
+
// Framing is checked for every render, hydratable or not. Static markup
|
|
904
|
+
// is placed into a document the same way, so a root that does not close
|
|
905
|
+
// cleanly swallows whatever the host writes after it either way.
|
|
906
|
+
assertRootClosesCleanly(html);
|
|
907
|
+
assertNoscriptModesAgree(html);
|
|
908
|
+
__assertNoDeclarativeShadowRoot(html);
|
|
909
|
+
__assertNoDocumentStructureEscape(html);
|
|
910
|
+
__assertNoLiveBaseElement(html);
|
|
911
|
+
if (nextDocument.body !== null) {
|
|
912
|
+
if (isHydratable) {
|
|
913
|
+
assertSingleStampedRoot(html, runtimeId, buildId, nextDocument.body);
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
assertParsedRootMatchesView(html, nextDocument.body);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return html;
|
|
920
|
+
},
|
|
921
|
+
catch: cause => new SerializationError({ cause }),
|
|
922
|
+
});
|
|
923
|
+
const flagsPayload = flagsHandoff !== undefined ? flagsHandoff.payloadScript : '';
|
|
924
|
+
// Mirror the client's document-metadata defaults so the served HTML a
|
|
925
|
+
// crawler reads carries the same canonical and Open Graph URL the hydrated
|
|
926
|
+
// page computes: canonical falls back to the request URL, and ogUrl to the
|
|
927
|
+
// resolved canonical, the chain the runtime applies on the client. A
|
|
928
|
+
// non-routing render has no request URL, so it inherits only an explicitly
|
|
929
|
+
// set canonical.
|
|
930
|
+
const resolvedCanonical = nextDocument.canonical ??
|
|
931
|
+
(hasRouting ? normalizedRequestUrl(options?.url) : undefined);
|
|
932
|
+
const resolvedOgUrl = nextDocument.ogUrl ?? resolvedCanonical;
|
|
933
|
+
return {
|
|
934
|
+
html: `${rootHtml}${flagsPayload}`,
|
|
935
|
+
title: nextDocument.title,
|
|
936
|
+
...(nextDocument.lang !== undefined ? { lang: nextDocument.lang } : {}),
|
|
937
|
+
...(nextDocument.dir !== undefined
|
|
938
|
+
? { dir: textDirectionToAttribute(nextDocument.dir) }
|
|
939
|
+
: {}),
|
|
940
|
+
...(resolvedCanonical !== undefined
|
|
941
|
+
? { canonical: resolvedCanonical }
|
|
942
|
+
: {}),
|
|
943
|
+
...(resolvedOgUrl !== undefined ? { ogUrl: resolvedOgUrl } : {}),
|
|
944
|
+
};
|
|
945
|
+
});
|
|
946
|
+
}
|