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