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,786 @@
1
+ import { Array as Array_ } from 'effect';
2
+ import { BOOLEAN_PROPERTIES, FOREIGN_REPRESENTABLE_PROPERTIES, GLOBAL_ATTRIBUTE_PROPERTIES, HTML_NAMESPACE, PASSTHROUGH_PROPERTIES, RENAMED_PROPERTY_ATTRIBUTES, assertStyleIsRepresentable, htmlAttributeValue, isHtmlPropertyRepresentable, parsedAttributeName, serializedHtmlPropertyValue, serializedStyleAttribute, } from '../../domReflection.js';
3
+ import { HYDRATION_IDENTITY_ATTRIBUTE, HYDRATION_KEY_ATTRIBUTE, hydrationIdentityMarker, hydrationKeyMarker, } from '../../hydrationMarkers.js';
4
+ import { hasTrustedInnerHtml, isClientOnlyProperty, } from '../../propertyProvenance.js';
5
+ import { tagNameFromSelector } from '../../tagName.js';
6
+ const VOID_ELEMENTS = new Set([
7
+ 'area',
8
+ 'base',
9
+ 'br',
10
+ 'col',
11
+ 'embed',
12
+ 'hr',
13
+ 'img',
14
+ 'input',
15
+ 'link',
16
+ 'meta',
17
+ 'param',
18
+ 'source',
19
+ 'track',
20
+ 'wbr',
21
+ ]);
22
+ const RAW_TEXT_ELEMENTS = new Set([
23
+ 'iframe',
24
+ 'noembed',
25
+ 'noframes',
26
+ 'noscript',
27
+ 'script',
28
+ 'style',
29
+ 'xmp',
30
+ ]);
31
+ // RCDATA elements decode entities but still end at their own closing tag, so
32
+ // escaping their text is correct while a closing sequence inside trusted raw
33
+ // HTML would break out of the element exactly as it does for raw text.
34
+ const RCDATA_ELEMENTS = new Set(['textarea', 'title']);
35
+ // NOTE: raw-text elements parse their content as text until the first
36
+ // `</tagname`, so HTML entities are not decoded and a closing-tag sequence in
37
+ // the content ends the element early. Escaping a text child, correct for a
38
+ // normal element, is wrong here: the browser reads the escaped `&lt;` back as
39
+ // the literal characters, not `<`, so the server DOM text disagrees with the
40
+ // view. The content is therefore emitted verbatim, and a closing-tag sequence,
41
+ // which would break out into live markup on the server, is a hard error since
42
+ // there is no valid escaping for it. `<noscript>` is raw text only while
43
+ // scripting is enabled, the hydrating client's state, so plain text round-trips
44
+ // verbatim. With scripting disabled, the state noscript targets, a browser
45
+ // parses noscript content as ordinary HTML, so a `<` in its text would become
46
+ // live markup; text carrying one is refused (see `assertNoscriptTextIsSafe`).
47
+ // Element children it cannot represent still fail the structure check, and
48
+ // intended fallback markup is authored through `h.InnerHTML`.
49
+ const rawTextClosingSequence = (tagName) => new RegExp(`</${tagName}(?=[\\t\\n\\f\\r />]|$)`, 'i');
50
+ // NOTE: `<script>` content has more parser states than the closing-tag check
51
+ // covers. A `<!--` sequence moves the tokenizer into the script-data-escaped
52
+ // state, and a following `<script` moves it into script-data-double-escaped,
53
+ // where the serializer's own `</script>` no longer closes the element and
54
+ // the rest of the document is swallowed as script text. There is no escaping
55
+ // for it, so it is refused the same way the closing-tag sequence is.
56
+ const SCRIPT_ESCAPE_OPENER = /<!--/;
57
+ const assertRawTextIsSafe = (tagName, content) => {
58
+ if (rawTextClosingSequence(tagName).test(content)) {
59
+ throw new Error(`[foldkit] <${tagName}> content contains a </${tagName} sequence, ` +
60
+ 'which cannot be represented in a raw-text element and would break ' +
61
+ 'out of the tag when the HTML is parsed. Remove the closing-tag ' +
62
+ 'sequence from the content.');
63
+ }
64
+ if (tagName.toLowerCase() === 'script' &&
65
+ SCRIPT_ESCAPE_OPENER.test(content)) {
66
+ throw new Error('[foldkit] <script> content contains a <!-- sequence, which moves the ' +
67
+ 'HTML parser into a script-data-escaped state where the closing ' +
68
+ '</script> tag no longer ends the element. Remove the <!-- sequence ' +
69
+ 'from the content.');
70
+ }
71
+ assertRepresentable(content, `<${tagName}> content`);
72
+ assertNoCarriageReturn(content, `<${tagName}> content`);
73
+ };
74
+ // NOTE: `<noscript>` is raw text only with scripting enabled. With scripting
75
+ // disabled, the state noscript exists for, a browser parses its content as HTML,
76
+ // so markup-significant text (a `<meta>`, `<style>`, `<iframe>`, `<form>`, or an
77
+ // image with an `onerror`) would become live for exactly the users noscript
78
+ // targets. Plain text without a `<` is identical in both parser modes and safe;
79
+ // text carrying a `<` is refused. Intended fallback markup is authored through
80
+ // `h.InnerHTML`, which is trusted and rendered as raw HTML.
81
+ // A `<` opens a tag, comment, or end tag only when followed by an ASCII letter,
82
+ // `!`, `/`, or `?`. A bare `<` (as in `a < b` or `<3`) is literal text in both
83
+ // parser modes, so it is left alone.
84
+ const NOSCRIPT_MARKUP_OPENER = /<[A-Za-z!/?]/;
85
+ const assertNoscriptTextIsSafe = (content) => {
86
+ if (NOSCRIPT_MARKUP_OPENER.test(content)) {
87
+ throw new Error('[foldkit] <noscript> text content contains markup (a "<" that opens a ' +
88
+ 'tag or comment), which a browser with scripting disabled parses as ' +
89
+ 'live markup. Remove the markup, or author trusted fallback markup with ' +
90
+ 'h.InnerHTML.');
91
+ }
92
+ };
93
+ // NOTE: comment text has no escaping either: it ends at the first `-->` or
94
+ // `--!>`, and must not start with `>` or `->` nor end with `<!-`. A
95
+ // terminating sequence in the text would break out of the comment into live
96
+ // markup on the server, so it is refused the same way raw-text content is.
97
+ const COMMENT_TERMINATOR = /--!?>/;
98
+ const assertCommentTextIsSafe = (text) => {
99
+ assertRepresentable(text, 'comment content');
100
+ assertNoCarriageReturn(text, 'comment content');
101
+ if (text.startsWith('>') ||
102
+ text.startsWith('->') ||
103
+ COMMENT_TERMINATOR.test(text) ||
104
+ text.endsWith('<!-')) {
105
+ throw new Error('[foldkit] comment content contains a sequence that would terminate ' +
106
+ 'the comment when the HTML is parsed. Remove the sequence from the ' +
107
+ 'content.');
108
+ }
109
+ };
110
+ const CAPS_REGEX = /[A-Z]/g;
111
+ const ATTRIBUTE_NAME_PATTERN = /^[A-Za-z_:][A-Za-z0-9_.:-]*$/;
112
+ // NOTE: the tag name is written verbatim into the opening and closing markup,
113
+ // so it must not carry markup-significant characters. CustomElement.define
114
+ // only checks that a tag contains a hyphen, so a string such as
115
+ // `x-a><script>` would pass validation and inject live elements. This is the
116
+ // serializer's own defense: any tag it cannot represent as a bare name is a
117
+ // hard error rather than emitted markup.
118
+ const TAG_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._-]*$/;
119
+ // NOTE: `\r` is escaped because the HTML parser normalizes CR and CRLF to LF
120
+ // before tokenization; a verbatim carriage return would read back as a
121
+ // different string and guarantee a hydration mismatch. The entity survives
122
+ // tokenization and decodes back to the original character.
123
+ const TEXT_ESCAPES = {
124
+ '&': '&amp;',
125
+ '<': '&lt;',
126
+ '>': '&gt;',
127
+ '\r': '&#13;',
128
+ };
129
+ const ATTRIBUTE_ESCAPES = {
130
+ '&': '&amp;',
131
+ '"': '&quot;',
132
+ '<': '&lt;',
133
+ // As in text, a carriage return is normalized away (CR and CRLF collapse to LF
134
+ // before tokenization) unless encoded as a character reference, which decodes
135
+ // back to the original after normalization.
136
+ '\r': '&#13;',
137
+ };
138
+ // NUL (U+0000) has no HTML representation: the tokenizer replaces it with U+FFFD
139
+ // or drops it, so it can never round-trip. It is rejected wherever a value is
140
+ // escaped for output rather than silently corrupted.
141
+ const assertNoNul = (value, context) => {
142
+ if (value.includes('\u0000')) {
143
+ throw new Error(`[foldkit] ${context} contains a NUL (U+0000) character, which has no ` +
144
+ 'HTML representation and cannot round-trip. Remove it from the value.');
145
+ }
146
+ };
147
+ // A high surrogate with no low one after it, or a low surrogate with no high one
148
+ // before it. Such a code unit is not a character: encoding the page as UTF-8,
149
+ // which every HTTP response and generated file does, replaces it with U+FFFD, so
150
+ // what a visitor receives is not what the view rendered.
151
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
152
+ const assertNoLoneSurrogate = (value, context) => {
153
+ if (LONE_SURROGATE.test(value)) {
154
+ throw new Error(`[foldkit] ${context} contains an unpaired surrogate code unit, which ` +
155
+ 'is not a character. Encoding the page as UTF-8 replaces it with ' +
156
+ 'U+FFFD, so the served text would not be the text the view rendered. ' +
157
+ 'Remove it from the value.');
158
+ }
159
+ };
160
+ // Everything a serialized value must satisfy to survive the trip through an
161
+ // HTML parser and a UTF-8 encoder unchanged.
162
+ const assertRepresentable = (value, context) => {
163
+ assertNoNul(value, context);
164
+ assertNoLoneSurrogate(value, context);
165
+ };
166
+ // A carriage return in a sink that has no escaping. Escaped text emits `&#13;`
167
+ // so a `\r` round-trips; raw text and comment content are emitted verbatim,
168
+ // where a browser's input preprocessing turns CR and CRLF into LF before the
169
+ // tokenizer ever sees them. The character cannot be represented there, so it is
170
+ // refused rather than silently normalized.
171
+ const assertNoCarriageReturn = (value, context) => {
172
+ if (value.includes('\r')) {
173
+ throw new Error(`[foldkit] ${context} contains a carriage return, which HTML input ` +
174
+ 'preprocessing turns into a line feed before parsing and which has no ' +
175
+ 'escape in this position. Remove it from the value.');
176
+ }
177
+ };
178
+ /** Escapes a string for use as HTML text content.
179
+ *
180
+ * @internal Shared with the template injector; not part of the `foldkit/experimental/server` surface.
181
+ */
182
+ export const escapeText = (value) => {
183
+ assertRepresentable(value, 'text content');
184
+ return value.replace(/[&<>\r]/g, character => TEXT_ESCAPES[character] ?? character);
185
+ };
186
+ /** Escapes a string for use inside a double-quoted HTML attribute value.
187
+ *
188
+ * @internal Shared with the template injector; not part of the `foldkit/experimental/server` surface.
189
+ */
190
+ export const escapeAttributeValue = (value) => {
191
+ assertRepresentable(value, 'attribute value');
192
+ return value.replace(/[&"<\r]/g, character => ATTRIBUTE_ESCAPES[character] ?? character);
193
+ };
194
+ const toKebabCase = (value) => value.replace(CAPS_REGEX, '-$&').toLowerCase();
195
+ const setAttribute = (attributes, name, value) => {
196
+ if (!ATTRIBUTE_NAME_PATTERN.test(name)) {
197
+ throw new Error(`[foldkit] Cannot serialize the invalid attribute name "${name}". ` +
198
+ 'Attribute names must use letters, numbers, underscores, periods, colons, and hyphens, and must not start with a number.');
199
+ }
200
+ attributes.set(name, value);
201
+ };
202
+ const appendClass = (attributes, addition) => {
203
+ const existing = attributes.get('class');
204
+ const nextClass = existing === undefined || existing === ''
205
+ ? addition
206
+ : `${existing} ${addition}`;
207
+ attributes.set('class', nextClass);
208
+ };
209
+ const appendStyle = (attributes, addition) => {
210
+ const existing = attributes.get('style');
211
+ const separator = existing === undefined || existing === '' || existing.endsWith(';')
212
+ ? ''
213
+ : '; ';
214
+ attributes.set('style', existing === undefined ? addition : `${existing}${separator}${addition}`);
215
+ };
216
+ const collectDataAttributes = (attributes, data) => {
217
+ const namespace = data.ns ?? HTML_NAMESPACE;
218
+ const plainAttributes = data.attrs;
219
+ if (plainAttributes !== undefined) {
220
+ const normalizedAttributes = new Map();
221
+ for (const rawName of Object.keys(plainAttributes)) {
222
+ const name = parsedAttributeName(namespace, rawName);
223
+ const value = plainAttributes[rawName];
224
+ if (value !== undefined) {
225
+ normalizedAttributes.set(name, value);
226
+ }
227
+ }
228
+ for (const [name, value] of normalizedAttributes) {
229
+ if (value === true) {
230
+ setAttribute(attributes, name, '');
231
+ }
232
+ else if (value !== false && value !== undefined) {
233
+ if (name === 'class') {
234
+ appendClass(attributes, String(value));
235
+ }
236
+ else if (name === 'style') {
237
+ appendStyle(attributes, String(value));
238
+ }
239
+ else {
240
+ setAttribute(attributes, name, String(value));
241
+ }
242
+ }
243
+ }
244
+ }
245
+ const classes = data.class;
246
+ if (classes !== undefined) {
247
+ const activeClasses = Object.keys(classes).filter(className => classes[className] === true);
248
+ if (Array_.isArrayNonEmpty(activeClasses)) {
249
+ appendClass(attributes, activeClasses.join(' '));
250
+ }
251
+ }
252
+ const dataset = data.dataset;
253
+ if (dataset !== undefined) {
254
+ for (const name of Object.keys(dataset)) {
255
+ const value = dataset[name];
256
+ if (value !== undefined) {
257
+ setAttribute(attributes, `data-${toKebabCase(name)}`, value);
258
+ }
259
+ }
260
+ }
261
+ };
262
+ const collectPropertyAttributes = (attributes, tagName, properties, isCustomElement, isForeignNamespace) => {
263
+ for (const name of Object.keys(properties)) {
264
+ const value = properties[name];
265
+ if (value === undefined || name === 'innerHTML') {
266
+ continue;
267
+ }
268
+ if (isCustomElement &&
269
+ !(GLOBAL_ATTRIBUTE_PROPERTIES.has(name) &&
270
+ !isClientOnlyProperty(properties, name))) {
271
+ continue;
272
+ }
273
+ if (!isForeignNamespace &&
274
+ !isCustomElement &&
275
+ !isHtmlPropertyRepresentable(tagName, name)) {
276
+ continue;
277
+ }
278
+ // An SVG or MathML element has the HTML interface members below only where
279
+ // the measured table says so. Emitting an attribute for one it does not have
280
+ // would serve state the client cannot reach: assigning the property there
281
+ // sets an expando, or throws on a readonly `SVGAnimatedString`. The builders
282
+ // refuse to build such a view, and this is the same rule on the serializing
283
+ // side.
284
+ if (isForeignNamespace && !FOREIGN_REPRESENTABLE_PROPERTIES.has(name)) {
285
+ continue;
286
+ }
287
+ // NOTE: `value` on textarea/output/select is not a serializable attribute
288
+ // (textarea and output render it as text content, select reflects the
289
+ // selected option), and on an input an empty value is the element's own
290
+ // default. In every one of those cases the property still decides, so a raw
291
+ // `h.Attribute('value', ...)` written alongside it is removed rather than
292
+ // left standing: the client assigns the property over whatever the
293
+ // attribute said, and leaving the attribute would serve a value the
294
+ // hydrated page immediately contradicts.
295
+ if (name === 'value' &&
296
+ (tagName === 'textarea' || tagName === 'output' || tagName === 'select')) {
297
+ attributes.delete('value');
298
+ continue;
299
+ }
300
+ if (BOOLEAN_PROPERTIES.has(name)) {
301
+ // A property set to `false` is not an absent property. The client leaves
302
+ // the element unchecked, closed, or enabled, so a raw attribute saying
303
+ // otherwise has to go: serving it made the browser check a box the Model
304
+ // says is clear, and submit it before hydration could correct anything.
305
+ const attributeName = RENAMED_PROPERTY_ATTRIBUTES[name] ?? name;
306
+ if (value === true) {
307
+ setAttribute(attributes, attributeName, '');
308
+ }
309
+ else {
310
+ attributes.delete(attributeName);
311
+ }
312
+ }
313
+ else if (name === 'draggable') {
314
+ setAttribute(attributes, name, value === true ? 'true' : 'false');
315
+ }
316
+ else if (name in RENAMED_PROPERTY_ATTRIBUTES) {
317
+ setAttribute(attributes, RENAMED_PROPERTY_ATTRIBUTES[name] ?? name, serializedHtmlPropertyValue(tagName, name, value));
318
+ }
319
+ else if (PASSTHROUGH_PROPERTIES.has(name)) {
320
+ setAttribute(attributes, name, serializedHtmlPropertyValue(tagName, name, value));
321
+ }
322
+ }
323
+ };
324
+ const collectStyleAttribute = (attributes, style) => {
325
+ assertStyleIsRepresentable(style);
326
+ const serialized = serializedStyleAttribute(style);
327
+ if (serialized !== undefined) {
328
+ appendStyle(attributes, serialized);
329
+ }
330
+ };
331
+ const serializeAttributes = (output, attributes) => {
332
+ for (const [name, value] of attributes) {
333
+ output.push(` ${name}="${escapeAttributeValue(value)}"`);
334
+ }
335
+ };
336
+ // The text a controlled `<textarea>` or `<output>` serializes: its `value` prop
337
+ // when set, or `undefined` when the element is uncontrolled and its children are
338
+ // its content. Exported so the render-time structure check can predict the text
339
+ // content the serializer produces without re-deriving the rule.
340
+ export const controlledValueContent = (properties) => {
341
+ const value = properties?.['value'];
342
+ if (typeof value === 'string') {
343
+ return value;
344
+ }
345
+ return undefined;
346
+ };
347
+ const collectRawText = (node) => {
348
+ if (node.text !== undefined) {
349
+ return node.text;
350
+ }
351
+ if (node.children === undefined) {
352
+ return '';
353
+ }
354
+ const parts = [];
355
+ for (const child of node.children) {
356
+ if (typeof child === 'string') {
357
+ parts.push(child);
358
+ }
359
+ else if (child.text !== undefined) {
360
+ parts.push(child.text);
361
+ }
362
+ }
363
+ return parts.join('');
364
+ };
365
+ // NOTE: the HTML parser drops a single newline immediately after the start
366
+ // tag of <textarea>, <pre>, and <listing>, so serialized content that starts
367
+ // with one needs an extra newline to survive the round-trip.
368
+ const NEWLINE_DROPPING_ELEMENTS = new Set([
369
+ 'pre',
370
+ 'listing',
371
+ ]);
372
+ // The same set plus <textarea>, which the ordinary path handles in its own
373
+ // branch but which trusted raw HTML reaches through the shared one.
374
+ const LEADING_NEWLINE_DROPPING_ELEMENTS = new Set([
375
+ 'listing',
376
+ 'pre',
377
+ 'textarea',
378
+ ]);
379
+ // The text the serialized content begins with, used to decide whether a
380
+ // newline-dropping element (`pre`, `listing`, `textarea`) needs an extra
381
+ // leading newline to survive the parser's leading-newline strip. Empty string
382
+ // children and empty text VNodes serialize to nothing, so they are skipped to
383
+ // reach the first run that actually emits text. The scan stops at the first
384
+ // element or comment: past it the serialized content no longer begins with
385
+ // text, so no padding is needed.
386
+ const leadingTextOf = (node) => {
387
+ if (node.text !== undefined) {
388
+ return node.text;
389
+ }
390
+ for (const child of node.children ?? []) {
391
+ if (typeof child === 'string') {
392
+ if (child !== '') {
393
+ return child;
394
+ }
395
+ continue;
396
+ }
397
+ const selector = child.sel;
398
+ if (selector === undefined || selector === '') {
399
+ const text = child.text ?? '';
400
+ if (text !== '') {
401
+ return text;
402
+ }
403
+ continue;
404
+ }
405
+ return undefined;
406
+ }
407
+ return undefined;
408
+ };
409
+ // NOTE: the serializer recurses per element, so a tree nested thousands of
410
+ // elements deep, typically from mapping untrusted hierarchical data straight
411
+ // to markup, would exhaust the call stack. The depth is bounded well above any
412
+ // real view and refused past the limit, so a hostile input becomes a typed
413
+ // SerializationError rather than a stack overflow.
414
+ const MAX_RENDER_DEPTH = 1000;
415
+ const serializeChildren = (output, context, node, depth, selectValue) => {
416
+ const children = node.children;
417
+ if (children !== undefined) {
418
+ for (const child of children) {
419
+ serializeNode(output, context, child, depth + 1, undefined, selectValue);
420
+ }
421
+ }
422
+ else if (node.text !== undefined) {
423
+ output.push(escapeText(node.text));
424
+ }
425
+ };
426
+ // NOTE: an option without a value prop falls back to its label the way the
427
+ // DOM does: `option.value` reflects the text content with ASCII whitespace
428
+ // stripped and internal runs collapsed, so a label wrapped across source
429
+ // lines still matches the select value.
430
+ // NOTE: ASCII whitespace only, which is what the platform's option-value
431
+ // algorithm strips and collapses. `String.prototype.trim` also removes a
432
+ // non-breaking space and other Unicode whitespace, so an option labelled with
433
+ // one computed a different value here than in a browser and the select's
434
+ // controlled value matched the wrong option or none at all.
435
+ const ASCII_WHITESPACE_RUN = /[\t\n\f\r ]+/g;
436
+ const LEADING_ASCII_WHITESPACE = /^[\t\n\f\r ]+/;
437
+ const TRAILING_ASCII_WHITESPACE = /[\t\n\f\r ]+$/;
438
+ // The state an element is in once it exists, read from the typed property and
439
+ // the raw attribute together rather than from either alone.
440
+ //
441
+ // A typed property decides, because the client assigns it over whatever the
442
+ // attribute said and the serializer writes it over whatever the attribute said.
443
+ // A raw attribute stands only where no typed property claims the same name, and
444
+ // it is matched ASCII-case-insensitively, because that is how a parser and
445
+ // `setAttribute` read an HTML attribute name: `h.Attribute('MULTIPLE', '')`
446
+ // names the same attribute as `h.Attribute('multiple', '')`, and reading only
447
+ // the literal spelling took a select a browser renders multi-line for a
448
+ // single-line one.
449
+ const effectiveAttribute = (node, name) => {
450
+ const raw = htmlAttributeValue(node.data?.attrs, name);
451
+ return typeof raw === 'string' ? raw : undefined;
452
+ };
453
+ const effectiveBoolean = (node, propertyName, attributeName) => {
454
+ const property = node.data?.props?.[propertyName];
455
+ if (typeof property === 'boolean') {
456
+ return property;
457
+ }
458
+ return htmlAttributeValue(node.data?.attrs, attributeName) !== undefined;
459
+ };
460
+ // A non-negative integer attribute the way a browser parses one: leading ASCII
461
+ // whitespace is skipped, the leading run of ASCII digits is the number, and the
462
+ // rest is ignored, so `1.5` is 1 and ` 4 ` is 4. A value past the unsigned long
463
+ // range leaves the attribute invalid and the element on its own default.
464
+ // `Number.parseInt` alone read `0x10` as 16 and `4e9` as 4, neither of which a
465
+ // browser reads that way here. Every case below was measured in Chromium.
466
+ const LEADING_DIGIT_RUN = /^[\t\n\f\r ]*([0-9]+)/;
467
+ const UNSIGNED_LONG_MAXIMUM = 4294967295;
468
+ const parsedNonNegativeInteger = (text) => {
469
+ const digits = LEADING_DIGIT_RUN.exec(text)?.[1];
470
+ if (digits === undefined) {
471
+ return undefined;
472
+ }
473
+ const parsed = Number(digits);
474
+ return parsed > UNSIGNED_LONG_MAXIMUM ? undefined : parsed;
475
+ };
476
+ const optionValue = (node) => {
477
+ const value = node.data?.props?.['value'];
478
+ if (typeof value === 'string') {
479
+ return value;
480
+ }
481
+ const rawValue = effectiveAttribute(node, 'value');
482
+ if (rawValue !== undefined) {
483
+ return rawValue;
484
+ }
485
+ return collectRawText(node)
486
+ .replace(ASCII_WHITESPACE_RUN, ' ')
487
+ .replace(LEADING_ASCII_WHITESPACE, '')
488
+ .replace(TRAILING_ASCII_WHITESPACE, '');
489
+ };
490
+ // A `<select>` that is `multiple`, or that shows more than one row, can render
491
+ // with nothing selected. A plain single-line select cannot: HTML gives the
492
+ // first option the selection when no option carries `selected`, so a controlled
493
+ // value naming no option has no representation in source markup.
494
+ // Whether the element can render with nothing selected. A raw attribute counts
495
+ // as much as a typed property: the browser reads either the same way, so
496
+ // analysing only `data.props` accepted a controlled missing value on a select
497
+ // that a browser renders single-line.
498
+ //
499
+ // A select with no options at all is included. Both a served empty select and a
500
+ // freshly built one hold no selection, so a controlled value matching nothing
501
+ // is not a disagreement there.
502
+ const displayedRowCount = (node, isMultiple) => {
503
+ const property = node.data?.props?.['size'];
504
+ const size = typeof property === 'number'
505
+ ? property
506
+ : (() => {
507
+ const raw = effectiveAttribute(node, 'size');
508
+ return raw === undefined ? undefined : parsedNonNegativeInteger(raw);
509
+ })();
510
+ if (size !== undefined && size > 0) {
511
+ return size;
512
+ }
513
+ // A `size` of zero, absent, or unreadable leaves the browser's own default,
514
+ // which is four rows for a multiple select and one for a single-line one.
515
+ return isMultiple ? MULTIPLE_SELECT_DEFAULT_ROWS : 1;
516
+ };
517
+ const MULTIPLE_SELECT_DEFAULT_ROWS = 4;
518
+ const hasOptionDescendant = (node) => (node.children ?? []).some(child => {
519
+ if (typeof child === 'string' || child.sel === undefined) {
520
+ return false;
521
+ }
522
+ const tagName = tagNameFromSelector(child.sel).toLowerCase();
523
+ return tagName === 'option' || hasOptionDescendant(child);
524
+ });
525
+ const allowsNoSelection = (node) => {
526
+ const isMultiple = effectiveBoolean(node, 'multiple', 'multiple');
527
+ return (isMultiple ||
528
+ displayedRowCount(node, isMultiple) > 1 ||
529
+ !hasOptionDescendant(node));
530
+ };
531
+ const selectValueForChildren = (tagName, node, inherited) => {
532
+ if (tagName !== 'select') {
533
+ return inherited;
534
+ }
535
+ const value = node.data?.props?.['value'];
536
+ if (typeof value === 'string') {
537
+ return {
538
+ value,
539
+ consumed: false,
540
+ allowsNoSelection: allowsNoSelection(node),
541
+ };
542
+ }
543
+ return undefined;
544
+ };
545
+ const serializeElement = (output, context, node, selector, depth, extraAttributes, selectValue) => {
546
+ const authoredTagName = tagNameFromSelector(selector);
547
+ if (!TAG_NAME_PATTERN.test(authoredTagName)) {
548
+ throw new Error(`[foldkit] Cannot serialize the invalid tag name "${authoredTagName}". Tag ` +
549
+ 'names must start with a letter and use only letters, numbers, ' +
550
+ 'hyphens, dots, and underscores.');
551
+ }
552
+ const data = node.data;
553
+ const isForeignNamespace = data?.ns !== undefined;
554
+ // NOTE: HTML tag names are ASCII case-insensitive. Canonicalize them before
555
+ // applying element-specific serialization rules so a dynamic `SELECT`
556
+ // behaves like the `HTMLSelectElement` a fresh client render creates. SVG
557
+ // and MathML names remain case-sensitive; the parse-equivalence check rejects
558
+ // a spelling that the HTML parser would adjust.
559
+ const tagName = isForeignNamespace
560
+ ? authoredTagName
561
+ : authoredTagName.toLowerCase();
562
+ const isCustomElement = !isForeignNamespace && tagName.includes('-');
563
+ const attributes = new Map();
564
+ if (data !== undefined) {
565
+ collectDataAttributes(attributes, data);
566
+ const properties = data.props;
567
+ if (properties !== undefined) {
568
+ collectPropertyAttributes(attributes, tagName, properties, isCustomElement, isForeignNamespace);
569
+ }
570
+ const style = data.style;
571
+ if (style !== undefined) {
572
+ if (htmlAttributeValue(data.attrs, 'style') !== undefined) {
573
+ throw new Error('[foldkit] An element was given both typed style data and a raw ' +
574
+ 'style attribute. Keep one owner for the inline declaration block.');
575
+ }
576
+ collectStyleAttribute(attributes, style);
577
+ }
578
+ }
579
+ // A controlled `<select>` owns which option is selected, the way the DOM
580
+ // `value` setter does: it takes the first option whose value matches and
581
+ // leaves every other one unselected. An option's own `selected` is therefore
582
+ // cleared inside a controlled select, since keeping both would let a browser
583
+ // give the later `selected` option ownership while a fresh client render
584
+ // reasserted the select's value and chose the earlier one.
585
+ if (!isForeignNamespace &&
586
+ tagName === 'option' &&
587
+ selectValue !== undefined) {
588
+ const optionProperties = node.data?.props;
589
+ if (hasTrustedInnerHtml(optionProperties) &&
590
+ optionProperties?.['value'] === undefined) {
591
+ throw new Error('[foldkit] An <option> inside a controlled <select> was given ' +
592
+ 'h.InnerHTML and no value. The option\u2019s value would come from ' +
593
+ 'text this render cannot see inside the raw fragment, so the select ' +
594
+ 'could select the wrong option or none at all. Give the option an ' +
595
+ 'explicit h.Value.');
596
+ }
597
+ if (!selectValue.consumed && optionValue(node) === selectValue.value) {
598
+ setAttribute(attributes, 'selected', '');
599
+ selectValue.consumed = true;
600
+ }
601
+ else {
602
+ attributes.delete('selected');
603
+ }
604
+ }
605
+ if (extraAttributes !== undefined) {
606
+ for (const name of Object.keys(extraAttributes)) {
607
+ const value = extraAttributes[name];
608
+ if (value !== undefined) {
609
+ setAttribute(attributes, name, value);
610
+ }
611
+ }
612
+ }
613
+ // The server HTML does not otherwise carry a vnode's key or identity, so
614
+ // hydration would adopt keyed children positionally. Stamp a digest of each so
615
+ // hydration can verify it is adopting the same logical entity without the raw
616
+ // key or source identity appearing in public markup; it strips the markers as
617
+ // it adopts.
618
+ if (context.emitHydrationMarkers) {
619
+ if (node.key !== undefined) {
620
+ const keyMarker = hydrationKeyMarker(node.key);
621
+ if (keyMarker === undefined) {
622
+ throw new Error('[foldkit] Cannot server-render an element keyed by a symbol. A ' +
623
+ 'symbol key cannot be compared across the server and the client ' +
624
+ '(a local symbol is a new value in every realm, so hydration ' +
625
+ 'could not tell two rows apart). Key hydratable elements by a ' +
626
+ 'string or a number.');
627
+ }
628
+ setAttribute(attributes, HYDRATION_KEY_ATTRIBUTE, keyMarker);
629
+ }
630
+ if (node.identity !== undefined) {
631
+ setAttribute(attributes, HYDRATION_IDENTITY_ATTRIBUTE, hydrationIdentityMarker(node.identity));
632
+ }
633
+ }
634
+ output.push(`<${tagName}`);
635
+ serializeAttributes(output, attributes);
636
+ output.push('>');
637
+ // NOTE: void elements have no closing tag only in the HTML namespace. In
638
+ // SVG or MathML foreign content the same names (`input`, `br`) are ordinary
639
+ // elements, so omitting the closing tag would let a following sibling parse
640
+ // as a child. Void handling, raw-text handling, and the form-control text
641
+ // rules below are all gated on the element being in the HTML namespace;
642
+ // foreign content always closes and serializes its children through
643
+ // escaping.
644
+ if (!isForeignNamespace && VOID_ELEMENTS.has(tagName)) {
645
+ return;
646
+ }
647
+ const childSelectValue = isForeignNamespace
648
+ ? undefined
649
+ : selectValueForChildren(tagName, node, selectValue);
650
+ const isHtmlRawText = !isForeignNamespace && RAW_TEXT_ELEMENTS.has(tagName);
651
+ const isHtmlRcdata = !isForeignNamespace && RCDATA_ELEMENTS.has(tagName);
652
+ const innerHtml = data?.props?.['innerHTML'];
653
+ if (typeof innerHtml === 'string' && hasTrustedInnerHtml(data?.props)) {
654
+ // Trusted raw HTML still cannot end the element that holds it. An RCDATA
655
+ // element ends at its own closing tag exactly as a raw-text one does, so
656
+ // markup written into a <textarea> or <title> would otherwise close it and
657
+ // put the rest of the fragment in the document.
658
+ if (isHtmlRawText || isHtmlRcdata) {
659
+ assertRawTextIsSafe(tagName, innerHtml);
660
+ }
661
+ // NOTE: one literal newline, always, for the elements whose parser drops
662
+ // one. The document parser consumes exactly this prefix, so whatever the
663
+ // fragment itself begins with survives unchanged, which is what assigning
664
+ // the same string to `element.innerHTML` on the client produces.
665
+ //
666
+ // Testing whether the fragment starts with a literal `\n` is not enough:
667
+ // tokenization also produces a leading newline from a character reference
668
+ // (`&#10;`, `&#xA;`, `&NewLine;`) and from input preprocessing turning CR
669
+ // or CRLF into LF, none of which is a literal newline in the source bytes.
670
+ // An unconditional prefix covers every one of them, and costs an empty
671
+ // fragment nothing.
672
+ if (!isForeignNamespace && LEADING_NEWLINE_DROPPING_ELEMENTS.has(tagName)) {
673
+ output.push('\n');
674
+ }
675
+ output.push(innerHtml);
676
+ }
677
+ else if (!isForeignNamespace && tagName === 'textarea') {
678
+ const content = controlledValueContent(data?.props);
679
+ if (content !== undefined) {
680
+ if (content.startsWith('\n')) {
681
+ output.push('\n');
682
+ }
683
+ output.push(escapeText(content));
684
+ }
685
+ else {
686
+ if (leadingTextOf(node)?.startsWith('\n')) {
687
+ output.push('\n');
688
+ }
689
+ serializeChildren(output, context, node, depth, childSelectValue);
690
+ }
691
+ }
692
+ else if (!isForeignNamespace && tagName === 'output') {
693
+ // A controlled <output> reflects its value prop as text content the same
694
+ // way a textarea does, but as ordinary PCDATA with no leading-newline
695
+ // handling. An uncontrolled output serializes its children.
696
+ const content = controlledValueContent(data?.props);
697
+ if (content !== undefined) {
698
+ output.push(escapeText(content));
699
+ }
700
+ else {
701
+ serializeChildren(output, context, node, depth, childSelectValue);
702
+ }
703
+ }
704
+ else if (!isForeignNamespace && NEWLINE_DROPPING_ELEMENTS.has(tagName)) {
705
+ if (leadingTextOf(node)?.startsWith('\n')) {
706
+ output.push('\n');
707
+ }
708
+ serializeChildren(output, context, node, depth, childSelectValue);
709
+ }
710
+ else if (isHtmlRawText) {
711
+ const rawText = collectRawText(node);
712
+ assertRawTextIsSafe(tagName, rawText);
713
+ if (tagName === 'noscript') {
714
+ assertNoscriptTextIsSafe(rawText);
715
+ }
716
+ output.push(rawText);
717
+ }
718
+ else {
719
+ serializeChildren(output, context, node, depth, childSelectValue);
720
+ }
721
+ if (childSelectValue !== undefined &&
722
+ childSelectValue !== selectValue &&
723
+ !childSelectValue.consumed &&
724
+ !childSelectValue.allowsNoSelection) {
725
+ throw new Error(`[foldkit] A <select> has the controlled value "${childSelectValue.value}" ` +
726
+ 'but no option carries it. A single-line select cannot render with ' +
727
+ 'nothing selected: HTML gives the first option the selection, while ' +
728
+ 'the client sets `value` and lands on no selection at all, so the ' +
729
+ 'served page and the hydrated one would disagree. Render an option ' +
730
+ 'with that value, add a placeholder option, or use `multiple`.');
731
+ }
732
+ output.push(`</${tagName}>`);
733
+ };
734
+ const serializeNode = (output, context, node, depth, extraAttributes, selectValue) => {
735
+ if (depth > MAX_RENDER_DEPTH) {
736
+ throw new Error(`[foldkit] renderToString exceeded the maximum render depth of ${MAX_RENDER_DEPTH}. ` +
737
+ 'A view nesting elements this deeply, often from mapping untrusted ' +
738
+ 'hierarchical data straight to markup, is refused to protect the ' +
739
+ 'render stack.');
740
+ }
741
+ if (node === null) {
742
+ return;
743
+ }
744
+ if (typeof node === 'string') {
745
+ output.push(escapeText(node));
746
+ return;
747
+ }
748
+ const selector = node.sel;
749
+ if (selector === undefined || selector === '') {
750
+ if (node.text !== undefined) {
751
+ output.push(escapeText(node.text));
752
+ }
753
+ return;
754
+ }
755
+ if (selector === '!') {
756
+ const commentText = node.text ?? '';
757
+ assertCommentTextIsSafe(commentText);
758
+ output.push(`<!--${commentText}-->`);
759
+ return;
760
+ }
761
+ serializeElement(output, context, node, selector, depth, extraAttributes, selectValue);
762
+ };
763
+ /** Serializes a view-produced vnode tree to an HTML string. Event handlers and
764
+ * hooks are behavior, not markup, and are skipped; attrs, class, dataset,
765
+ * prop-backed attributes, and inline style are emitted in that order. A `null`
766
+ * tree serializes to an empty comment, mirroring how the runtime patches
767
+ * `null` as a comment node.
768
+ *
769
+ * A hydratable render also stamps a digest of each vnode's key and identity, so
770
+ * hydration can tell one logical entity from another; the raw key and the
771
+ * compiler's source identity never appear in the markup. A render that is not
772
+ * hydratable emits neither.
773
+ *
774
+ * @internal Not part of the `foldkit/experimental/server` surface; `renderToString` is the public entry to serialization.
775
+ */
776
+ export const serializeHtml = (root, options) => {
777
+ if (root === null) {
778
+ return '<!---->';
779
+ }
780
+ const context = {
781
+ emitHydrationMarkers: options?.emitHydrationMarkers ?? false,
782
+ };
783
+ const output = [];
784
+ serializeNode(output, context, root, 0, options?.rootAttributes);
785
+ return output.join('');
786
+ };