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.
Files changed (61) hide show
  1. package/dist/buildToken.d.ts +3 -0
  2. package/dist/buildToken.d.ts.map +1 -0
  3. package/dist/buildToken.js +21 -0
  4. package/dist/controlledDomState.d.ts +25 -0
  5. package/dist/controlledDomState.d.ts.map +1 -0
  6. package/dist/controlledDomState.js +240 -0
  7. package/dist/cssStyleProperties.d.ts +6 -0
  8. package/dist/cssStyleProperties.d.ts.map +1 -0
  9. package/dist/cssStyleProperties.js +91 -0
  10. package/dist/domReflection.d.ts +73 -0
  11. package/dist/domReflection.d.ts.map +1 -0
  12. package/dist/domReflection.js +557 -0
  13. package/dist/experimental/server/host.d.ts +102 -8
  14. package/dist/experimental/server/host.d.ts.map +1 -1
  15. package/dist/experimental/server/host.js +203 -13
  16. package/dist/experimental/server/public.d.ts +2 -2
  17. package/dist/experimental/server/public.d.ts.map +1 -1
  18. package/dist/experimental/server/public.js +1 -1
  19. package/dist/experimental/server/serialize.d.ts +15 -5
  20. package/dist/experimental/server/serialize.d.ts.map +1 -1
  21. package/dist/experimental/server/serialize.js +367 -144
  22. package/dist/experimental/server/server.d.ts +55 -14
  23. package/dist/experimental/server/server.d.ts.map +1 -1
  24. package/dist/experimental/server/server.js +544 -21
  25. package/dist/experimental/server/template.d.ts +8 -0
  26. package/dist/experimental/server/template.d.ts.map +1 -1
  27. package/dist/experimental/server/template.js +437 -2
  28. package/dist/html/index.d.ts.map +1 -1
  29. package/dist/html/index.js +436 -29
  30. package/dist/hydrate.d.ts +1 -1
  31. package/dist/hydrate.d.ts.map +1 -1
  32. package/dist/hydrate.js +449 -121
  33. package/dist/hydrationMarkers.d.ts +15 -0
  34. package/dist/hydrationMarkers.d.ts.map +1 -0
  35. package/dist/hydrationMarkers.js +70 -0
  36. package/dist/nativeInnerHtml.d.ts +13 -0
  37. package/dist/nativeInnerHtml.d.ts.map +1 -0
  38. package/dist/nativeInnerHtml.js +30 -0
  39. package/dist/propertyProvenance.d.ts +33 -0
  40. package/dist/propertyProvenance.d.ts.map +1 -0
  41. package/dist/propertyProvenance.js +78 -0
  42. package/dist/propsModule.d.ts.map +1 -1
  43. package/dist/propsModule.js +149 -15
  44. package/dist/runtime/public.d.ts +1 -1
  45. package/dist/runtime/public.d.ts.map +1 -1
  46. package/dist/runtime/runtime.d.ts +30 -6
  47. package/dist/runtime/runtime.d.ts.map +1 -1
  48. package/dist/runtime/runtime.js +230 -27
  49. package/dist/snabbdom/attributes.d.ts.map +1 -1
  50. package/dist/snabbdom/attributes.js +65 -37
  51. package/dist/snabbdom/style.d.ts.map +1 -1
  52. package/dist/snabbdom/style.js +53 -34
  53. package/dist/test/apps/attributes.d.ts +1 -0
  54. package/dist/test/apps/attributes.d.ts.map +1 -1
  55. package/dist/test/apps/attributes.js +8 -1
  56. package/dist/test/apps/login.js +1 -1
  57. package/dist/test/matchers.d.ts.map +1 -1
  58. package/dist/test/matchers.js +2 -1
  59. package/dist/test/scene.d.ts.map +1 -1
  60. package/dist/test/scene.js +2 -1
  61. package/package.json +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../../../src/experimental/server/template.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,mBAAmB,EAAE,MAAM,aAAa,CAAA;AAgHtD;;;GAGG;AACH,MAAM,MAAM,yBAAyB,GAAG,QAAQ,CAAC;IAC/C;iDAC6C;IAC7C,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAC,CAAA;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,eAAO,MAAM,kBAAkB,GAC7B,UAAU,MAAM,EAChB,UAAU,mBAAmB,EAC7B,UAAU,yBAAyB,KAClC,MA0IF,CAAA"}
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
- return applyMutations(template, mutations);
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
  };