foldkit 0.147.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/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/domReflection.d.ts +73 -0
- package/dist/domReflection.d.ts.map +1 -0
- package/dist/domReflection.js +557 -0
- package/dist/experimental/server/host.d.ts +102 -8
- package/dist/experimental/server/host.d.ts.map +1 -1
- package/dist/experimental/server/host.js +203 -13
- package/dist/experimental/server/public.d.ts +2 -2
- package/dist/experimental/server/public.d.ts.map +1 -1
- package/dist/experimental/server/public.js +1 -1
- package/dist/experimental/server/serialize.d.ts +15 -5
- package/dist/experimental/server/serialize.d.ts.map +1 -1
- package/dist/experimental/server/serialize.js +367 -144
- package/dist/experimental/server/server.d.ts +55 -14
- package/dist/experimental/server/server.d.ts.map +1 -1
- package/dist/experimental/server/server.js +544 -21
- package/dist/experimental/server/template.d.ts +8 -0
- package/dist/experimental/server/template.d.ts.map +1 -1
- package/dist/experimental/server/template.js +437 -2
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/index.js +436 -29
- package/dist/hydrate.d.ts +1 -1
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +449 -121
- 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 +1 -1
- package/dist/runtime/public.d.ts.map +1 -1
- package/dist/runtime/runtime.d.ts +30 -6
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +230 -27
- package/dist/snabbdom/attributes.d.ts.map +1 -1
- package/dist/snabbdom/attributes.js +65 -37
- package/dist/snabbdom/style.d.ts.map +1 -1
- package/dist/snabbdom/style.js +53 -34
- 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/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../../../src/experimental/server/template.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../../../src/experimental/server/template.ts"],"names":[],"mappings":"AAcA,OAAO,EACL,KAAK,mBAAmB,EAIzB,MAAM,aAAa,CAAA;AA6oBpB;;;GAGG;AACH,MAAM,MAAM,yBAAyB,GAAG,QAAQ,CAAC;IAC/C;iDAC6C;IAC7C,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAC,CAAA;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,kBAAkB,GAC7B,UAAU,MAAM,EAChB,UAAU,mBAAmB,EAC7B,UAAU,yBAAyB,KAClC,MA0MF,CAAA"}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
import { parse } from 'parse5';
|
|
1
|
+
import { html as Parse5Html, defaultTreeAdapter, parse, parseFragment, } from 'parse5';
|
|
2
|
+
import { HYDRATION_BUILD_ATTRIBUTE } from '../../buildToken.js';
|
|
3
|
+
import { FOLDKIT_APP_ATTRIBUTE, FOLDKIT_FLAGS_ATTRIBUTE, } from '../../hydrationMarker.js';
|
|
2
4
|
import { escapeAttributeValue, escapeText } from './serialize.js';
|
|
5
|
+
import { __assertNoDeclarativeShadowRoot, __assertNoDocumentStructureEscape, __assertNoLiveBaseElement, } from './server.js';
|
|
3
6
|
const DEFAULT_CONTAINER_ID = 'root';
|
|
7
|
+
const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
|
|
8
|
+
const RENDERED_FRAGMENT_CONTEXT = defaultTreeAdapter.createElement('div', Parse5Html.NS.HTML, []);
|
|
9
|
+
// NOTE: assembled by concatenation rather than through String.replace, whose
|
|
10
|
+
// replacement string reads `$&` and `` $` `` as patterns. Rendered markup is
|
|
11
|
+
// application data and may contain either.
|
|
12
|
+
const NEUTRAL_CONTEXT_PREFIX = '<!doctype html><html><head><title>x</title></head><body>';
|
|
13
|
+
const NEUTRAL_CONTEXT_SUFFIX = '</body></html>';
|
|
4
14
|
const isElement = (node) => 'tagName' in node;
|
|
5
15
|
const collectMatching = (root, predicate) => {
|
|
6
16
|
const found = [];
|
|
@@ -17,8 +27,256 @@ const collectMatching = (root, predicate) => {
|
|
|
17
27
|
walk(root);
|
|
18
28
|
return found;
|
|
19
29
|
};
|
|
30
|
+
const collectMatchingIncludingTemplateContent = (root, predicate) => {
|
|
31
|
+
const found = [];
|
|
32
|
+
const walk = (node) => {
|
|
33
|
+
for (const child of node.childNodes) {
|
|
34
|
+
if (!isElement(child)) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (predicate(child)) {
|
|
38
|
+
found.push(child);
|
|
39
|
+
}
|
|
40
|
+
walk(child);
|
|
41
|
+
if (child.tagName === 'template' && 'content' in child) {
|
|
42
|
+
walk(child.content);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
walk(root);
|
|
47
|
+
return found;
|
|
48
|
+
};
|
|
20
49
|
const firstMatching = (root, predicate) => collectMatching(root, predicate)[0];
|
|
21
50
|
const attributeValue = (element, name) => element.attrs.find(attribute => attribute.name === name)?.value;
|
|
51
|
+
const elementChildren = (node) => node.childNodes.filter(isElement);
|
|
52
|
+
const parentOf = (element) => {
|
|
53
|
+
const parent = element.parentNode;
|
|
54
|
+
return parent !== null && 'tagName' in parent ? parent : undefined;
|
|
55
|
+
};
|
|
56
|
+
// Each step names the element itself and where it sits among its parent's
|
|
57
|
+
// element children, outermost last.
|
|
58
|
+
const ancestorPath = (element) => {
|
|
59
|
+
const path = [];
|
|
60
|
+
let current = element;
|
|
61
|
+
while (current !== undefined) {
|
|
62
|
+
const parent = parentOf(current);
|
|
63
|
+
path.push({
|
|
64
|
+
tagName: current.tagName.toLowerCase(),
|
|
65
|
+
index: parent === undefined ? 0 : elementChildren(parent).indexOf(current),
|
|
66
|
+
});
|
|
67
|
+
if (parent === undefined) {
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
current = parent;
|
|
71
|
+
}
|
|
72
|
+
return path;
|
|
73
|
+
};
|
|
74
|
+
// Each step is resolved by the index it recorded, not by the first element that
|
|
75
|
+
// happens to carry the tag name. Two sibling `<section>`s are an ordinary
|
|
76
|
+
// template, and taking the first would both reject the valid one and let an
|
|
77
|
+
// earlier same-tag sibling stand in for the position the placeholder actually
|
|
78
|
+
// held, so a corrupted injection could be validated against an intact copy.
|
|
79
|
+
const elementAtPath = (document, path) => {
|
|
80
|
+
let node;
|
|
81
|
+
let children = elementChildren(document);
|
|
82
|
+
for (const step of [...path].reverse()) {
|
|
83
|
+
const child = children[step.index];
|
|
84
|
+
if (child === undefined ||
|
|
85
|
+
child.tagName.toLowerCase() !== step.tagName ||
|
|
86
|
+
(node !== undefined && child.namespaceURI !== node.namespaceURI)) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
node = child;
|
|
90
|
+
children = elementChildren(child);
|
|
91
|
+
}
|
|
92
|
+
return node;
|
|
93
|
+
};
|
|
94
|
+
const shapeOfElement = (element) => ({
|
|
95
|
+
kind: 'Element',
|
|
96
|
+
tagName: element.tagName.toLowerCase(),
|
|
97
|
+
namespace: element.namespaceURI,
|
|
98
|
+
attributes: [...element.attrs]
|
|
99
|
+
.map(attribute => [attribute.name, attribute.value])
|
|
100
|
+
.sort(([left], [right]) => left.localeCompare(right)),
|
|
101
|
+
children: shapesOf(element),
|
|
102
|
+
});
|
|
103
|
+
const shapesOf = (node) => {
|
|
104
|
+
const shapes = [];
|
|
105
|
+
let text = '';
|
|
106
|
+
const flush = () => {
|
|
107
|
+
if (text !== '') {
|
|
108
|
+
shapes.push({ kind: 'Text', text });
|
|
109
|
+
}
|
|
110
|
+
text = '';
|
|
111
|
+
};
|
|
112
|
+
for (const child of node.childNodes) {
|
|
113
|
+
if (isElement(child)) {
|
|
114
|
+
flush();
|
|
115
|
+
shapes.push(shapeOfElement(child));
|
|
116
|
+
}
|
|
117
|
+
else if (child.nodeName === '#text' && 'value' in child) {
|
|
118
|
+
text += child.value;
|
|
119
|
+
}
|
|
120
|
+
else if (child.nodeName === '#comment' && 'data' in child) {
|
|
121
|
+
flush();
|
|
122
|
+
shapes.push({ kind: 'Comment', text: child.data });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
flush();
|
|
126
|
+
return shapes;
|
|
127
|
+
};
|
|
128
|
+
const isSameShape = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
129
|
+
// The insertion contexts this API supports, stated rather than inferred. A
|
|
130
|
+
// placeholder reached from `<body>` through nothing but flow containers parses
|
|
131
|
+
// the same way a body does, which is the context `renderToString` already
|
|
132
|
+
// validated the rendered markup against. Every other context (a `<form>`, a
|
|
133
|
+
// `<table>`, a `<select>`, foreign content, template content) makes the final
|
|
134
|
+
// parse depend on where the markup landed, and is refused with a clear error
|
|
135
|
+
// rather than modeled.
|
|
136
|
+
const NEUTRAL_ANCESTOR_TAGS = new Set([
|
|
137
|
+
'article',
|
|
138
|
+
'aside',
|
|
139
|
+
'body',
|
|
140
|
+
'div',
|
|
141
|
+
'footer',
|
|
142
|
+
'header',
|
|
143
|
+
'main',
|
|
144
|
+
'section',
|
|
145
|
+
]);
|
|
146
|
+
const assertNeutralInsertionContext = (container, containerId) => {
|
|
147
|
+
let current = parentOf(container);
|
|
148
|
+
while (current !== undefined) {
|
|
149
|
+
const tagName = current.tagName.toLowerCase();
|
|
150
|
+
if (tagName === 'html') {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (!NEUTRAL_ANCESTOR_TAGS.has(tagName) ||
|
|
154
|
+
current.namespaceURI !== HTML_NAMESPACE) {
|
|
155
|
+
throw new Error(`[foldkit] injectIntoTemplate found the <div id="${containerId}"></div> placeholder inside <${tagName}>, which is not a supported insertion context. ` +
|
|
156
|
+
'HTML parsing rearranges what an element with a restrictive content ' +
|
|
157
|
+
'model may hold, so the rendered application could be moved, dropped, ' +
|
|
158
|
+
'or reshaped once it sat there. Place the placeholder in the body or ' +
|
|
159
|
+
'inside plain flow containers (div, main, section, article, aside, ' +
|
|
160
|
+
'header, footer).');
|
|
161
|
+
}
|
|
162
|
+
current = parentOf(current);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
// Declarative shadow DOM is refused by `renderToString` itself, which is where
|
|
166
|
+
// every rendered page passes through. The same check runs here as well, since
|
|
167
|
+
// `injectIntoTemplate` accepts any `RenderedApplication` a caller hands it,
|
|
168
|
+
// including one this process did not produce.
|
|
169
|
+
// The rendered markup describes a subtree. Whether the finished page still
|
|
170
|
+
// describes it depends on where the placeholder sat: HTML parsing drops a
|
|
171
|
+
// `<form>` nested in another `<form>`, foster-parents a `<div>` out of a
|
|
172
|
+
// `<table>`, and empties a `<select>` of anything that is not an option. The
|
|
173
|
+
// splice itself is always correct, so the only way to see any of that is to
|
|
174
|
+
// parse the finished page and compare.
|
|
175
|
+
//
|
|
176
|
+
// The comparison is of the placeholder's whole parent, not of the root alone.
|
|
177
|
+
// What the template says that parent should hold after injection is its own
|
|
178
|
+
// children with the placeholder replaced by what the rendered markup describes
|
|
179
|
+
// on its own, in the neutral context `renderToString` already validated it
|
|
180
|
+
// against. Comparing the whole parent covers the rendered subtree, the nodes
|
|
181
|
+
// around it, and the boundaries between them, and it identifies the injection by
|
|
182
|
+
// the position its placeholder held rather than by any attribute, so a marker
|
|
183
|
+
// authored inside the markup cannot stand in for a root the parser dropped, and
|
|
184
|
+
// another application's root elsewhere in the document is not counted against
|
|
185
|
+
// this one.
|
|
186
|
+
//
|
|
187
|
+
// Both parses run with scripting enabled and disabled, because a `<noscript>`
|
|
188
|
+
// in the rendered markup changes the tree for exactly the visitors it targets.
|
|
189
|
+
const renderedShapes = (renderedHtml, isScriptingEnabled) => {
|
|
190
|
+
const neutral = parse(`${NEUTRAL_CONTEXT_PREFIX}${renderedHtml}${NEUTRAL_CONTEXT_SUFFIX}`, { scriptingEnabled: isScriptingEnabled });
|
|
191
|
+
const body = firstMatching(neutral, element => element.tagName === 'body');
|
|
192
|
+
return body === undefined ? [] : shapesOf(body);
|
|
193
|
+
};
|
|
194
|
+
// Adjacent text runs merge, because a parser reads back-to-back text as one
|
|
195
|
+
// node however it was written. Splicing the rendered shapes into the template's
|
|
196
|
+
// own children can put text beside text, so the result is merged the same way
|
|
197
|
+
// before it is compared.
|
|
198
|
+
const mergeAdjacentText = (shapes) => {
|
|
199
|
+
const merged = [];
|
|
200
|
+
for (const shape of shapes) {
|
|
201
|
+
const previous = merged[merged.length - 1];
|
|
202
|
+
if (shape.kind === 'Text' && previous?.kind === 'Text') {
|
|
203
|
+
merged[merged.length - 1] = {
|
|
204
|
+
kind: 'Text',
|
|
205
|
+
text: previous.text + shape.text,
|
|
206
|
+
};
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
merged.push(shape);
|
|
210
|
+
}
|
|
211
|
+
return merged.filter(shape => shape.kind !== 'Text' || shape.text !== '');
|
|
212
|
+
};
|
|
213
|
+
// The shapes of a node's children, plus where in that list a given child lands.
|
|
214
|
+
const shapesAround = (parent, child) => {
|
|
215
|
+
const shapes = shapesOf(parent);
|
|
216
|
+
let index = 0;
|
|
217
|
+
let isTextOpen = false;
|
|
218
|
+
for (const candidate of parent.childNodes) {
|
|
219
|
+
if (isElement(candidate)) {
|
|
220
|
+
if (candidate === child) {
|
|
221
|
+
return { shapes, index };
|
|
222
|
+
}
|
|
223
|
+
isTextOpen = false;
|
|
224
|
+
index += 1;
|
|
225
|
+
}
|
|
226
|
+
else if (candidate.nodeName === '#text' && 'value' in candidate) {
|
|
227
|
+
if (!isTextOpen && candidate.value !== '') {
|
|
228
|
+
isTextOpen = true;
|
|
229
|
+
index += 1;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else if (candidate.nodeName === '#comment') {
|
|
233
|
+
isTextOpen = false;
|
|
234
|
+
index += 1;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { shapes, index };
|
|
238
|
+
};
|
|
239
|
+
const assertInjectionIsFaithful = (page, template, renderedHtml, containerId, isScriptingEnabled) => {
|
|
240
|
+
const parsedTemplate = parse(template, {
|
|
241
|
+
scriptingEnabled: isScriptingEnabled,
|
|
242
|
+
});
|
|
243
|
+
const container = firstMatching(parsedTemplate, element => isContainerPlaceholder(element, containerId));
|
|
244
|
+
const containerParent = container === undefined ? undefined : parentOf(container);
|
|
245
|
+
if (container === undefined || containerParent === undefined) {
|
|
246
|
+
// The placeholder does not survive this parser mode at all, so whatever
|
|
247
|
+
// replaced it does not either: markup before it swallowed the rest of the
|
|
248
|
+
// shell. Losing the application root for the visitors of one mode is the
|
|
249
|
+
// failure this check exists to catch, not a reason to skip it.
|
|
250
|
+
throw new Error(`[foldkit] The <div id="${containerId}"></div> placeholder does not survive when a browser parses the template with scripting ${isScriptingEnabled ? 'enabled' : 'disabled'}. ` +
|
|
251
|
+
'Markup before it leaves an element open (an unterminated <textarea>, ' +
|
|
252
|
+
'<style>, or comment inside a <noscript> fallback), so the application ' +
|
|
253
|
+
'root and the rest of the shell are swallowed as its content. Ensure ' +
|
|
254
|
+
'the template is complete and balanced in both parser modes.');
|
|
255
|
+
}
|
|
256
|
+
const { shapes: templateShapes, index } = shapesAround(containerParent, container);
|
|
257
|
+
const expected = mergeAdjacentText([
|
|
258
|
+
...templateShapes.slice(0, index),
|
|
259
|
+
...renderedShapes(renderedHtml, isScriptingEnabled),
|
|
260
|
+
...templateShapes.slice(index + 1),
|
|
261
|
+
]);
|
|
262
|
+
const parsedPage = parse(page, { scriptingEnabled: isScriptingEnabled });
|
|
263
|
+
const pageParent = elementAtPath(parsedPage, ancestorPath(containerParent));
|
|
264
|
+
const actual = pageParent === undefined ? [] : mergeAdjacentText(shapesOf(pageParent));
|
|
265
|
+
const isFaithful = expected.length === actual.length &&
|
|
266
|
+
expected.every((shape, offset) => {
|
|
267
|
+
const candidate = actual[offset];
|
|
268
|
+
return candidate !== undefined && isSameShape(shape, candidate);
|
|
269
|
+
});
|
|
270
|
+
if (!isFaithful) {
|
|
271
|
+
throw new Error(`[foldkit] injectIntoTemplate produced a page that no longer describes the rendered markup${isScriptingEnabled ? '' : ' when a browser parses it with scripting disabled'}. ` +
|
|
272
|
+
'HTML parsing moved, dropped, or reshaped the rendered nodes once they ' +
|
|
273
|
+
`sat where the <div id="${containerId}"></div> placeholder was, which ` +
|
|
274
|
+
'happens when the placeholder is nested inside an element with a ' +
|
|
275
|
+
'restrictive content model (a <form> inside another <form>, anything ' +
|
|
276
|
+
'inside a <table> or <select>, or foreign content such as <svg> or ' +
|
|
277
|
+
'<math>). Move the placeholder to the body or another neutral container.');
|
|
278
|
+
}
|
|
279
|
+
};
|
|
22
280
|
const withAttribute = (attributes, name, value) => {
|
|
23
281
|
const exists = attributes.some(attribute => attribute.name === name);
|
|
24
282
|
if (exists) {
|
|
@@ -45,6 +303,132 @@ const applyMutations = (template, mutations) => {
|
|
|
45
303
|
// A valid container is `<div id="{containerId}"></div>` with no other
|
|
46
304
|
// attributes and no children, so the rendered application replaces the exact
|
|
47
305
|
// placeholder and nothing else.
|
|
306
|
+
const ASCII_WHITESPACE_ONLY = /^[ \t\n\f\r]*$/;
|
|
307
|
+
const isIgnorableTopLevelText = (node) => node.nodeName === '#text' &&
|
|
308
|
+
'value' in node &&
|
|
309
|
+
ASCII_WHITESPACE_ONLY.test(node.value);
|
|
310
|
+
const sourceLocationOf = (node) => {
|
|
311
|
+
if (!('sourceCodeLocation' in node)) {
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
const location = node.sourceCodeLocation;
|
|
315
|
+
return location === null || location === undefined ? undefined : location;
|
|
316
|
+
};
|
|
317
|
+
const hasGeneratedDescendant = (node) => {
|
|
318
|
+
for (const child of node.childNodes) {
|
|
319
|
+
if (sourceLocationOf(child) === undefined) {
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
if (isElement(child)) {
|
|
323
|
+
if (hasGeneratedDescendant(child)) {
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
if (child.tagName === 'template' &&
|
|
327
|
+
'content' in child &&
|
|
328
|
+
hasGeneratedDescendant(child.content)) {
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return false;
|
|
334
|
+
};
|
|
335
|
+
const assertRenderedSourceIsPreserved = (renderedHtml, parsed) => {
|
|
336
|
+
let endOffset = 0;
|
|
337
|
+
for (const child of parsed.childNodes) {
|
|
338
|
+
const location = sourceLocationOf(child);
|
|
339
|
+
if (location === undefined || location.startOffset < endOffset) {
|
|
340
|
+
throw new Error('[foldkit] injectIntoTemplate received rendered markup that a browser ' +
|
|
341
|
+
'reshapes before insertion. The rendered application must be one ' +
|
|
342
|
+
'complete parser-stable root, with only its optional Flags payload ' +
|
|
343
|
+
'beside it.');
|
|
344
|
+
}
|
|
345
|
+
if (!ASCII_WHITESPACE_ONLY.test(renderedHtml.slice(endOffset, location.startOffset))) {
|
|
346
|
+
throw new Error('[foldkit] injectIntoTemplate received rendered markup whose source ' +
|
|
347
|
+
'does not survive parsing. An element was dropped, moved, or split ' +
|
|
348
|
+
'before the application reached the template.');
|
|
349
|
+
}
|
|
350
|
+
endOffset = location.endOffset;
|
|
351
|
+
}
|
|
352
|
+
if (!ASCII_WHITESPACE_ONLY.test(renderedHtml.slice(endOffset))) {
|
|
353
|
+
throw new Error('[foldkit] injectIntoTemplate received rendered markup whose trailing ' +
|
|
354
|
+
'source does not survive parsing. An element was dropped, moved, or ' +
|
|
355
|
+
'split before the application reached the template.');
|
|
356
|
+
}
|
|
357
|
+
if (hasGeneratedDescendant(parsed)) {
|
|
358
|
+
throw new Error('[foldkit] injectIntoTemplate received rendered markup that makes the ' +
|
|
359
|
+
'HTML parser insert or reconstruct nodes. Pass the unchanged output ' +
|
|
360
|
+
'of renderToString rather than hand-authored or parser-unstable markup.');
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
// The runtime id the rendered markup stamps on its root, or `undefined` when it
|
|
364
|
+
// carries no stamp (a render that nothing will hydrate).
|
|
365
|
+
const runtimeIdInMode = (renderedHtml, isScriptingEnabled) => {
|
|
366
|
+
const parsed = parseFragment(RENDERED_FRAGMENT_CONTEXT, renderedHtml, {
|
|
367
|
+
scriptingEnabled: isScriptingEnabled,
|
|
368
|
+
sourceCodeLocationInfo: true,
|
|
369
|
+
});
|
|
370
|
+
assertRenderedSourceIsPreserved(renderedHtml, parsed);
|
|
371
|
+
const applicationMarkers = collectMatchingIncludingTemplateContent(parsed, element => attributeValue(element, FOLDKIT_APP_ATTRIBUTE) !== undefined);
|
|
372
|
+
const flagsMarkers = collectMatchingIncludingTemplateContent(parsed, element => attributeValue(element, FOLDKIT_FLAGS_ATTRIBUTE) !== undefined);
|
|
373
|
+
const buildMarkers = collectMatchingIncludingTemplateContent(parsed, element => attributeValue(element, HYDRATION_BUILD_ATTRIBUTE) !== undefined);
|
|
374
|
+
if (applicationMarkers.length === 0 && flagsMarkers.length === 0) {
|
|
375
|
+
if (buildMarkers.length > 0) {
|
|
376
|
+
throw new Error('[foldkit] injectIntoTemplate received a build marker without a ' +
|
|
377
|
+
'matching server-rendered application root.');
|
|
378
|
+
}
|
|
379
|
+
const significant = parsed.childNodes.filter(node => !isIgnorableTopLevelText(node));
|
|
380
|
+
if (significant.length > 1) {
|
|
381
|
+
throw new Error('[foldkit] injectIntoTemplate received static rendered markup with ' +
|
|
382
|
+
'more than one top-level node. renderToString produces one static ' +
|
|
383
|
+
'root, one text or comment root, or no body output.');
|
|
384
|
+
}
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
const root = applicationMarkers.at(0);
|
|
388
|
+
const runtimeId = root === undefined ? undefined : attributeValue(root, FOLDKIT_APP_ATTRIBUTE);
|
|
389
|
+
const isTopLevelRoot = root !== undefined && parsed.childNodes.some(node => node === root);
|
|
390
|
+
const areFlagsTopLevel = flagsMarkers.every(element => parsed.childNodes.some(node => node === element));
|
|
391
|
+
const isFlagsPairValid = flagsMarkers.every(element => element.tagName === 'script' &&
|
|
392
|
+
attributeValue(element, 'type') === 'application/json' &&
|
|
393
|
+
attributeValue(element, FOLDKIT_FLAGS_ATTRIBUTE) === runtimeId);
|
|
394
|
+
const buildId = root === undefined
|
|
395
|
+
? undefined
|
|
396
|
+
: attributeValue(root, HYDRATION_BUILD_ATTRIBUTE);
|
|
397
|
+
const isBuildPairValid = buildId !== undefined &&
|
|
398
|
+
buildId !== '' &&
|
|
399
|
+
buildMarkers.length === 1 &&
|
|
400
|
+
buildMarkers.at(0) === root;
|
|
401
|
+
const significant = parsed.childNodes.filter(node => !isIgnorableTopLevelText(node));
|
|
402
|
+
const hasOnlyOwnedTopLevelNodes = significant.every(node => node === root || (isElement(node) && flagsMarkers.includes(node)));
|
|
403
|
+
if (runtimeId === undefined ||
|
|
404
|
+
runtimeId === '' ||
|
|
405
|
+
applicationMarkers.length !== 1 ||
|
|
406
|
+
!isTopLevelRoot ||
|
|
407
|
+
flagsMarkers.length > 1 ||
|
|
408
|
+
!areFlagsTopLevel ||
|
|
409
|
+
!isFlagsPairValid ||
|
|
410
|
+
!isBuildPairValid ||
|
|
411
|
+
!hasOnlyOwnedTopLevelNodes ||
|
|
412
|
+
significant.length !== 1 + flagsMarkers.length) {
|
|
413
|
+
throw new Error('[foldkit] injectIntoTemplate received rendered markup with ambiguous ' +
|
|
414
|
+
'Foldkit root or Flags markers. A rendered application must carry ' +
|
|
415
|
+
'exactly one top-level data-foldkit-app root with a nonempty ' +
|
|
416
|
+
'data-foldkit-build marker, at most one matching top-level ' +
|
|
417
|
+
'script[type="application/json"][data-foldkit-flags] payload, and no ' +
|
|
418
|
+
'other top-level content. Application content cannot author any of ' +
|
|
419
|
+
'those markers.');
|
|
420
|
+
}
|
|
421
|
+
return runtimeId;
|
|
422
|
+
};
|
|
423
|
+
const runtimeIdOf = (renderedHtml) => {
|
|
424
|
+
const enabled = runtimeIdInMode(renderedHtml, true);
|
|
425
|
+
const disabled = runtimeIdInMode(renderedHtml, false);
|
|
426
|
+
if (enabled !== disabled) {
|
|
427
|
+
throw new Error('[foldkit] injectIntoTemplate received rendered markup whose root and ' +
|
|
428
|
+
'Flags ownership changes when scripting is disabled.');
|
|
429
|
+
}
|
|
430
|
+
return enabled;
|
|
431
|
+
};
|
|
48
432
|
const isContainerPlaceholder = (element, containerId) => element.tagName === 'div' &&
|
|
49
433
|
element.attrs.length === 1 &&
|
|
50
434
|
element.attrs[0]?.name === 'id' &&
|
|
@@ -72,6 +456,14 @@ const isContainerPlaceholder = (element, containerId) => element.tagName === 'di
|
|
|
72
456
|
* element in its head. Throws when either required location is missing or
|
|
73
457
|
* appears more than once.
|
|
74
458
|
*
|
|
459
|
+
* Pass the `RenderedApplication` returned by `renderToString` unchanged. A
|
|
460
|
+
* hydratable value must parse as exactly one top-level element carrying one
|
|
461
|
+
* nonempty root stamp and build stamp, optionally followed by one matching
|
|
462
|
+
* top-level JSON Flags script. Static output may contain one element, text, or
|
|
463
|
+
* comment root, or no body output. The helper rejects additional top-level
|
|
464
|
+
* content, ambiguous handoff markers, and source that the HTML parser drops,
|
|
465
|
+
* splits, moves, or reconstructs before insertion.
|
|
466
|
+
*
|
|
75
467
|
* This helper is pure with no module state, so a host process may import it
|
|
76
468
|
* directly even when the render itself must stay inside the server entry's
|
|
77
469
|
* module graph.
|
|
@@ -95,6 +487,38 @@ export const injectIntoTemplate = (template, rendered, options) => {
|
|
|
95
487
|
throw new Error(`[foldkit] injectIntoTemplate found more than one <div id="${containerId}"></div> placeholder in the template. ` +
|
|
96
488
|
'Keep exactly one placeholder for each application root.');
|
|
97
489
|
}
|
|
490
|
+
// The runtime id names an application across the whole page: it pairs a root
|
|
491
|
+
// with its Flags payload, and it keys the Model and scroll position HMR
|
|
492
|
+
// preserves. Two roots sharing one is not a second application, it is the
|
|
493
|
+
// same application claimed twice, so the second injection is refused rather
|
|
494
|
+
// than producing a page whose parts silently take each other's state. This
|
|
495
|
+
// holds whether or not either application declares Flags.
|
|
496
|
+
//
|
|
497
|
+
// Distinct ids are not a licence to hydrate two applications on one page.
|
|
498
|
+
// Each page-owning application rewrites the document's metadata and installs
|
|
499
|
+
// its own navigation listeners, which no id divides between them.
|
|
500
|
+
const renderedRuntimeId = runtimeIdOf(rendered.html);
|
|
501
|
+
if (renderedRuntimeId !== undefined) {
|
|
502
|
+
const existing = collectMatching(document, element => attributeValue(element, FOLDKIT_APP_ATTRIBUTE) === renderedRuntimeId);
|
|
503
|
+
if (existing.length > 0) {
|
|
504
|
+
throw new Error(`[foldkit] injectIntoTemplate is placing an application stamped ` +
|
|
505
|
+
`"${renderedRuntimeId}", but the page already holds a root with ` +
|
|
506
|
+
'that id. A runtime id names one application for the whole page: it ' +
|
|
507
|
+
'pairs a root with its Flags payload and keys the Model and scroll ' +
|
|
508
|
+
'position hot reloading preserves, so two roots sharing one would ' +
|
|
509
|
+
"take each other's state. Give each application its own " +
|
|
510
|
+
'`runtimeId` when rendering.');
|
|
511
|
+
}
|
|
512
|
+
const existingFlags = collectMatching(document, element => attributeValue(element, FOLDKIT_FLAGS_ATTRIBUTE) === renderedRuntimeId);
|
|
513
|
+
if (existingFlags.length > 0) {
|
|
514
|
+
throw new Error(`[foldkit] injectIntoTemplate is placing an application stamped ` +
|
|
515
|
+
`"${renderedRuntimeId}", but the page already holds a Flags ` +
|
|
516
|
+
'payload with that id. A runtime id pairs exactly one root with at ' +
|
|
517
|
+
'most one generated payload, so a stale or application-authored ' +
|
|
518
|
+
'marker would make hydration ambiguous. Remove the conflicting ' +
|
|
519
|
+
'script[data-foldkit-flags].');
|
|
520
|
+
}
|
|
521
|
+
}
|
|
98
522
|
const idMatches = collectMatching(document, element => attributeValue(element, 'id') === containerId);
|
|
99
523
|
if (idMatches.length > 1) {
|
|
100
524
|
throw new Error(`[foldkit] injectIntoTemplate found more than one element with id="${containerId}" in the template. ` +
|
|
@@ -114,6 +538,10 @@ export const injectIntoTemplate = (template, rendered, options) => {
|
|
|
114
538
|
}
|
|
115
539
|
const mutations = [];
|
|
116
540
|
const container = containers[0];
|
|
541
|
+
assertNeutralInsertionContext(container, containerId);
|
|
542
|
+
__assertNoDeclarativeShadowRoot(rendered.html);
|
|
543
|
+
__assertNoDocumentStructureEscape(rendered.html);
|
|
544
|
+
__assertNoLiveBaseElement(rendered.html);
|
|
117
545
|
const containerLocation = container.sourceCodeLocation;
|
|
118
546
|
if (containerLocation != null) {
|
|
119
547
|
mutations.push({
|
|
@@ -178,5 +606,12 @@ export const injectIntoTemplate = (template, rendered, options) => {
|
|
|
178
606
|
});
|
|
179
607
|
}
|
|
180
608
|
}
|
|
181
|
-
|
|
609
|
+
const page = applyMutations(template, mutations);
|
|
610
|
+
// Checked for every render, hydratable or not. Static markup is placed into a
|
|
611
|
+
// document the same way, and markup the parser drops is lost with no
|
|
612
|
+
// hydration to rebuild it.
|
|
613
|
+
for (const isScriptingEnabled of [true, false]) {
|
|
614
|
+
assertInjectionIsFaithful(page, template, rendered.html, containerId, isScriptingEnabled);
|
|
615
|
+
}
|
|
616
|
+
return page;
|
|
182
617
|
};
|