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.
Files changed (91) hide show
  1. package/README.md +5 -2
  2. package/dist/buildToken.d.ts +3 -0
  3. package/dist/buildToken.d.ts.map +1 -0
  4. package/dist/buildToken.js +21 -0
  5. package/dist/controlledDomState.d.ts +25 -0
  6. package/dist/controlledDomState.d.ts.map +1 -0
  7. package/dist/controlledDomState.js +240 -0
  8. package/dist/cssStyleProperties.d.ts +6 -0
  9. package/dist/cssStyleProperties.d.ts.map +1 -0
  10. package/dist/cssStyleProperties.js +91 -0
  11. package/dist/customElement/index.d.ts.map +1 -1
  12. package/dist/customElement/index.js +23 -0
  13. package/dist/domReflection.d.ts +73 -0
  14. package/dist/domReflection.d.ts.map +1 -0
  15. package/dist/domReflection.js +557 -0
  16. package/dist/experimental/index.d.ts +1 -0
  17. package/dist/experimental/index.d.ts.map +1 -1
  18. package/dist/experimental/index.js +1 -0
  19. package/dist/experimental/machine/machine.d.ts +22 -3
  20. package/dist/experimental/machine/machine.d.ts.map +1 -1
  21. package/dist/experimental/machine/machine.js +8 -0
  22. package/dist/experimental/server/entry.d.ts +73 -0
  23. package/dist/experimental/server/entry.d.ts.map +1 -0
  24. package/dist/experimental/server/entry.js +41 -0
  25. package/dist/experimental/server/host.d.ts +136 -0
  26. package/dist/experimental/server/host.d.ts.map +1 -0
  27. package/dist/experimental/server/host.js +359 -0
  28. package/dist/experimental/server/index.d.ts +5 -0
  29. package/dist/experimental/server/index.d.ts.map +1 -0
  30. package/dist/experimental/server/index.js +4 -0
  31. package/dist/experimental/server/public.d.ts +3 -0
  32. package/dist/experimental/server/public.d.ts.map +1 -0
  33. package/dist/experimental/server/public.js +1 -0
  34. package/dist/experimental/server/serialize.d.ts +43 -0
  35. package/dist/experimental/server/serialize.d.ts.map +1 -0
  36. package/dist/experimental/server/serialize.js +786 -0
  37. package/dist/experimental/server/server.d.ts +242 -0
  38. package/dist/experimental/server/server.d.ts.map +1 -0
  39. package/dist/experimental/server/server.js +946 -0
  40. package/dist/experimental/server/template.d.ts +53 -0
  41. package/dist/experimental/server/template.d.ts.map +1 -0
  42. package/dist/experimental/server/template.js +617 -0
  43. package/dist/html/index.d.ts +5 -0
  44. package/dist/html/index.d.ts.map +1 -1
  45. package/dist/html/index.js +474 -33
  46. package/dist/hydrate.d.ts +4 -0
  47. package/dist/hydrate.d.ts.map +1 -0
  48. package/dist/hydrate.js +850 -0
  49. package/dist/hydrationMarker.d.ts +10 -0
  50. package/dist/hydrationMarker.d.ts.map +1 -0
  51. package/dist/hydrationMarker.js +9 -0
  52. package/dist/hydrationMarkers.d.ts +15 -0
  53. package/dist/hydrationMarkers.d.ts.map +1 -0
  54. package/dist/hydrationMarkers.js +70 -0
  55. package/dist/nativeInnerHtml.d.ts +13 -0
  56. package/dist/nativeInnerHtml.d.ts.map +1 -0
  57. package/dist/nativeInnerHtml.js +30 -0
  58. package/dist/propertyProvenance.d.ts +33 -0
  59. package/dist/propertyProvenance.d.ts.map +1 -0
  60. package/dist/propertyProvenance.js +78 -0
  61. package/dist/propsModule.d.ts.map +1 -1
  62. package/dist/propsModule.js +149 -15
  63. package/dist/runtime/public.d.ts +2 -2
  64. package/dist/runtime/public.d.ts.map +1 -1
  65. package/dist/runtime/public.js +1 -1
  66. package/dist/runtime/runtime.d.ts +99 -36
  67. package/dist/runtime/runtime.d.ts.map +1 -1
  68. package/dist/runtime/runtime.js +412 -71
  69. package/dist/snabbdom/attributes.d.ts.map +1 -1
  70. package/dist/snabbdom/attributes.js +65 -37
  71. package/dist/snabbdom/h.d.ts +1 -0
  72. package/dist/snabbdom/h.d.ts.map +1 -1
  73. package/dist/snabbdom/h.js +85 -4
  74. package/dist/snabbdom/style.d.ts.map +1 -1
  75. package/dist/snabbdom/style.js +53 -34
  76. package/dist/snabbdom/tovnode.d.ts.map +1 -1
  77. package/dist/snabbdom/tovnode.js +5 -1
  78. package/dist/tagName.d.ts +6 -0
  79. package/dist/tagName.d.ts.map +1 -0
  80. package/dist/tagName.js +11 -0
  81. package/dist/test/apps/attributes.d.ts +1 -0
  82. package/dist/test/apps/attributes.d.ts.map +1 -1
  83. package/dist/test/apps/attributes.js +8 -1
  84. package/dist/test/apps/login.js +1 -1
  85. package/dist/test/matchers.d.ts.map +1 -1
  86. package/dist/test/matchers.js +2 -1
  87. package/dist/test/scene.d.ts.map +1 -1
  88. package/dist/test/scene.js +2 -1
  89. package/dist/vdom.d.ts.map +1 -1
  90. package/dist/vdom.js +25 -1
  91. package/package.json +9 -2
@@ -0,0 +1,53 @@
1
+ import { type RenderedApplication } from './server.js';
2
+ /** Options for {@link injectIntoTemplate}.
3
+ *
4
+ * @experimental Ships from `foldkit/experimental/server`; expect breaking changes while the API settles.
5
+ */
6
+ export type InjectIntoTemplateOptions = Readonly<{
7
+ /** The `id` of the exact `<div id="..."></div>` placeholder the rendered
8
+ * markup replaces. Defaults to `'root'`. */
9
+ containerId?: string;
10
+ }>;
11
+ /**
12
+ * Places a rendered page into an HTML template.
13
+ *
14
+ * The rendered markup (root element plus the flags payload script) replaces
15
+ * the empty container element, so the booting runtime finds the root by its
16
+ * `data-foldkit-app` stamp and hydrates in place. The `Document` head fields
17
+ * are stamped into the shell so the served HTML is correct before the runtime
18
+ * boots: `title` replaces the `<title>` text, `lang` and `dir` are set on the
19
+ * `<html>` element, `canonical` replaces the `href` of a
20
+ * `<link rel="canonical">` element, and `ogUrl` replaces the `content` of a
21
+ * `<meta property="og:url">` element. A field the render omits, or a head
22
+ * element the template does not carry, leaves the template untouched at that
23
+ * spot.
24
+ *
25
+ * The template is parsed with an HTML tokenizer, so every mutation targets a
26
+ * real element rather than a byte pattern. The container contract is
27
+ * deliberately exact: the template must contain one `<div id="root"></div>`
28
+ * placeholder, or the equivalent for `containerId`, with no additional
29
+ * attributes and no content. It must also contain exactly one `<title>`
30
+ * element in its head. Throws when either required location is missing or
31
+ * appears more than once.
32
+ *
33
+ * Pass the `RenderedApplication` returned by `renderToString` unchanged. A
34
+ * hydratable value must parse as exactly one top-level element carrying one
35
+ * nonempty root stamp and build stamp, optionally followed by one matching
36
+ * top-level JSON Flags script. Static output may contain one element, text, or
37
+ * comment root, or no body output. The helper rejects additional top-level
38
+ * content, ambiguous handoff markers, and source that the HTML parser drops,
39
+ * splits, moves, or reconstructs before insertion.
40
+ *
41
+ * This helper is pure with no module state, so a host process may import it
42
+ * directly even when the render itself must stay inside the server entry's
43
+ * module graph.
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * const page = injectIntoTemplate(template, rendered)
48
+ * ```
49
+ *
50
+ * @experimental Ships from `foldkit/experimental/server`; expect breaking changes while the API settles.
51
+ */
52
+ export declare const injectIntoTemplate: (template: string, rendered: RenderedApplication, options?: InjectIntoTemplateOptions) => string;
53
+ //# sourceMappingURL=template.d.ts.map
@@ -0,0 +1 @@
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"}
@@ -0,0 +1,617 @@
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';
4
+ import { escapeAttributeValue, escapeText } from './serialize.js';
5
+ import { __assertNoDeclarativeShadowRoot, __assertNoDocumentStructureEscape, __assertNoLiveBaseElement, } from './server.js';
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>';
14
+ const isElement = (node) => 'tagName' in node;
15
+ const collectMatching = (root, predicate) => {
16
+ const found = [];
17
+ const walk = (node) => {
18
+ for (const child of node.childNodes) {
19
+ if (isElement(child)) {
20
+ if (predicate(child)) {
21
+ found.push(child);
22
+ }
23
+ walk(child);
24
+ }
25
+ }
26
+ };
27
+ walk(root);
28
+ return found;
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
+ };
49
+ const firstMatching = (root, predicate) => collectMatching(root, predicate)[0];
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
+ };
280
+ const withAttribute = (attributes, name, value) => {
281
+ const exists = attributes.some(attribute => attribute.name === name);
282
+ if (exists) {
283
+ return attributes.map(attribute => attribute.name === name ? { name, value } : attribute);
284
+ }
285
+ return [...attributes, { name, value }];
286
+ };
287
+ const renderAttributes = (attributes) => attributes
288
+ .map(attribute => ` ${attribute.name}="${escapeAttributeValue(attribute.value)}"`)
289
+ .join('');
290
+ const renderStartTag = (tagName, attributes) => `<${tagName}${renderAttributes(attributes)}>`;
291
+ const renderVoidElement = (tagName, attributes) => `<${tagName}${renderAttributes(attributes)} />`;
292
+ const applyMutations = (template, mutations) => {
293
+ const ordered = [...mutations].sort((left, right) => right.start - left.start);
294
+ let result = template;
295
+ for (const mutation of ordered) {
296
+ result =
297
+ result.slice(0, mutation.start) +
298
+ mutation.replacement +
299
+ result.slice(mutation.end);
300
+ }
301
+ return result;
302
+ };
303
+ // A valid container is `<div id="{containerId}"></div>` with no other
304
+ // attributes and no children, so the rendered application replaces the exact
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
+ };
432
+ const isContainerPlaceholder = (element, containerId) => element.tagName === 'div' &&
433
+ element.attrs.length === 1 &&
434
+ element.attrs[0]?.name === 'id' &&
435
+ element.attrs[0]?.value === containerId &&
436
+ element.childNodes.length === 0;
437
+ /**
438
+ * Places a rendered page into an HTML template.
439
+ *
440
+ * The rendered markup (root element plus the flags payload script) replaces
441
+ * the empty container element, so the booting runtime finds the root by its
442
+ * `data-foldkit-app` stamp and hydrates in place. The `Document` head fields
443
+ * are stamped into the shell so the served HTML is correct before the runtime
444
+ * boots: `title` replaces the `<title>` text, `lang` and `dir` are set on the
445
+ * `<html>` element, `canonical` replaces the `href` of a
446
+ * `<link rel="canonical">` element, and `ogUrl` replaces the `content` of a
447
+ * `<meta property="og:url">` element. A field the render omits, or a head
448
+ * element the template does not carry, leaves the template untouched at that
449
+ * spot.
450
+ *
451
+ * The template is parsed with an HTML tokenizer, so every mutation targets a
452
+ * real element rather than a byte pattern. The container contract is
453
+ * deliberately exact: the template must contain one `<div id="root"></div>`
454
+ * placeholder, or the equivalent for `containerId`, with no additional
455
+ * attributes and no content. It must also contain exactly one `<title>`
456
+ * element in its head. Throws when either required location is missing or
457
+ * appears more than once.
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
+ *
467
+ * This helper is pure with no module state, so a host process may import it
468
+ * directly even when the render itself must stay inside the server entry's
469
+ * module graph.
470
+ *
471
+ * @example
472
+ * ```typescript
473
+ * const page = injectIntoTemplate(template, rendered)
474
+ * ```
475
+ *
476
+ * @experimental Ships from `foldkit/experimental/server`; expect breaking changes while the API settles.
477
+ */
478
+ export const injectIntoTemplate = (template, rendered, options) => {
479
+ const containerId = options?.containerId ?? DEFAULT_CONTAINER_ID;
480
+ const document = parse(template, { sourceCodeLocationInfo: true });
481
+ const containers = collectMatching(document, element => isContainerPlaceholder(element, containerId));
482
+ if (containers.length === 0) {
483
+ throw new Error(`[foldkit] injectIntoTemplate found no exact <div id="${containerId}"></div> placeholder in the template. ` +
484
+ 'Add that markup where the application root belongs, or pass the container id the template uses.');
485
+ }
486
+ if (containers.length > 1) {
487
+ throw new Error(`[foldkit] injectIntoTemplate found more than one <div id="${containerId}"></div> placeholder in the template. ` +
488
+ 'Keep exactly one placeholder for each application root.');
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
+ }
522
+ const idMatches = collectMatching(document, element => attributeValue(element, 'id') === containerId);
523
+ if (idMatches.length > 1) {
524
+ throw new Error(`[foldkit] injectIntoTemplate found more than one element with id="${containerId}" in the template. ` +
525
+ 'The runtime finds the application root by this id once the placeholder is replaced, so a second element sharing it would resolve to the wrong element. Keep the id unique to the placeholder.');
526
+ }
527
+ const head = firstMatching(document, element => element.tagName === 'head');
528
+ const titles = head === undefined
529
+ ? []
530
+ : collectMatching(head, element => element.tagName === 'title');
531
+ if (titles.length === 0) {
532
+ throw new Error('[foldkit] injectIntoTemplate found no <title> element in the template head. ' +
533
+ 'Add exactly one <title> where the rendered Document title belongs.');
534
+ }
535
+ if (titles.length > 1) {
536
+ throw new Error('[foldkit] injectIntoTemplate found more than one <title> element in the template head. ' +
537
+ 'Keep exactly one title for the rendered Document.');
538
+ }
539
+ const mutations = [];
540
+ const container = containers[0];
541
+ assertNeutralInsertionContext(container, containerId);
542
+ __assertNoDeclarativeShadowRoot(rendered.html);
543
+ __assertNoDocumentStructureEscape(rendered.html);
544
+ __assertNoLiveBaseElement(rendered.html);
545
+ const containerLocation = container.sourceCodeLocation;
546
+ if (containerLocation != null) {
547
+ mutations.push({
548
+ start: containerLocation.startOffset,
549
+ end: containerLocation.endOffset,
550
+ replacement: rendered.html,
551
+ });
552
+ }
553
+ const title = titles[0];
554
+ const titleLocation = title.sourceCodeLocation;
555
+ if (titleLocation?.startTag != null && titleLocation.endTag != null) {
556
+ mutations.push({
557
+ start: titleLocation.startTag.endOffset,
558
+ end: titleLocation.endTag.startOffset,
559
+ replacement: escapeText(rendered.title),
560
+ });
561
+ }
562
+ const html = firstMatching(document, element => element.tagName === 'html');
563
+ if (rendered.lang !== undefined || rendered.dir !== undefined) {
564
+ // HTML lets the <html> start tag be omitted, in which case parse5 builds an
565
+ // implicit html element with no start-tag location to mutate. Rather than
566
+ // silently drop the language or direction the page requested, fail so the
567
+ // author adds an explicit tag.
568
+ const startTag = html?.sourceCodeLocation?.startTag;
569
+ if (html === undefined || startTag == null) {
570
+ throw new Error('[foldkit] injectIntoTemplate cannot stamp the language or direction ' +
571
+ 'because the template has no explicit <html> start tag to mutate. ' +
572
+ 'Add an <html> tag to the template so lang and dir can be set.');
573
+ }
574
+ let attributes = html.attrs;
575
+ if (rendered.lang !== undefined) {
576
+ attributes = withAttribute(attributes, 'lang', rendered.lang);
577
+ }
578
+ if (rendered.dir !== undefined) {
579
+ attributes = withAttribute(attributes, 'dir', rendered.dir);
580
+ }
581
+ mutations.push({
582
+ start: startTag.startOffset,
583
+ end: startTag.endOffset,
584
+ replacement: renderStartTag('html', attributes),
585
+ });
586
+ }
587
+ if (rendered.canonical !== undefined && head !== undefined) {
588
+ const canonical = firstMatching(head, element => element.tagName === 'link' &&
589
+ attributeValue(element, 'rel')?.toLowerCase() === 'canonical');
590
+ if (canonical?.sourceCodeLocation != null) {
591
+ mutations.push({
592
+ start: canonical.sourceCodeLocation.startOffset,
593
+ end: canonical.sourceCodeLocation.endOffset,
594
+ replacement: renderVoidElement('link', withAttribute(canonical.attrs, 'href', rendered.canonical)),
595
+ });
596
+ }
597
+ }
598
+ if (rendered.ogUrl !== undefined && head !== undefined) {
599
+ const ogUrl = firstMatching(head, element => element.tagName === 'meta' &&
600
+ attributeValue(element, 'property')?.toLowerCase() === 'og:url');
601
+ if (ogUrl?.sourceCodeLocation != null) {
602
+ mutations.push({
603
+ start: ogUrl.sourceCodeLocation.startOffset,
604
+ end: ogUrl.sourceCodeLocation.endOffset,
605
+ replacement: renderVoidElement('meta', withAttribute(ogUrl.attrs, 'content', rendered.ogUrl)),
606
+ });
607
+ }
608
+ }
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;
617
+ };