foldkit 0.147.0 → 0.148.1

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 (63) hide show
  1. package/dist/buildToken.d.ts +3 -0
  2. package/dist/buildToken.d.ts.map +1 -0
  3. package/dist/buildToken.js +21 -0
  4. package/dist/controlledDomState.d.ts +25 -0
  5. package/dist/controlledDomState.d.ts.map +1 -0
  6. package/dist/controlledDomState.js +240 -0
  7. package/dist/cssStyleProperties.d.ts +6 -0
  8. package/dist/cssStyleProperties.d.ts.map +1 -0
  9. package/dist/cssStyleProperties.js +91 -0
  10. package/dist/domReflection.d.ts +73 -0
  11. package/dist/domReflection.d.ts.map +1 -0
  12. package/dist/domReflection.js +557 -0
  13. package/dist/experimental/server/host.d.ts +102 -8
  14. package/dist/experimental/server/host.d.ts.map +1 -1
  15. package/dist/experimental/server/host.js +203 -13
  16. package/dist/experimental/server/public.d.ts +2 -2
  17. package/dist/experimental/server/public.d.ts.map +1 -1
  18. package/dist/experimental/server/public.js +1 -1
  19. package/dist/experimental/server/serialize.d.ts +15 -5
  20. package/dist/experimental/server/serialize.d.ts.map +1 -1
  21. package/dist/experimental/server/serialize.js +373 -144
  22. package/dist/experimental/server/server.d.ts +55 -14
  23. package/dist/experimental/server/server.d.ts.map +1 -1
  24. package/dist/experimental/server/server.js +546 -22
  25. package/dist/experimental/server/template.d.ts +8 -0
  26. package/dist/experimental/server/template.d.ts.map +1 -1
  27. package/dist/experimental/server/template.js +437 -2
  28. package/dist/html/index.d.ts.map +1 -1
  29. package/dist/html/index.js +436 -29
  30. package/dist/hydrate.d.ts +1 -1
  31. package/dist/hydrate.d.ts.map +1 -1
  32. package/dist/hydrate.js +449 -121
  33. package/dist/hydrationMarkers.d.ts +15 -0
  34. package/dist/hydrationMarkers.d.ts.map +1 -0
  35. package/dist/hydrationMarkers.js +72 -0
  36. package/dist/nativeInnerHtml.d.ts +13 -0
  37. package/dist/nativeInnerHtml.d.ts.map +1 -0
  38. package/dist/nativeInnerHtml.js +30 -0
  39. package/dist/propertyProvenance.d.ts +33 -0
  40. package/dist/propertyProvenance.d.ts.map +1 -0
  41. package/dist/propertyProvenance.js +78 -0
  42. package/dist/propsModule.d.ts.map +1 -1
  43. package/dist/propsModule.js +149 -15
  44. package/dist/runtime/public.d.ts +1 -1
  45. package/dist/runtime/public.d.ts.map +1 -1
  46. package/dist/runtime/runtime.d.ts +33 -6
  47. package/dist/runtime/runtime.d.ts.map +1 -1
  48. package/dist/runtime/runtime.js +240 -27
  49. package/dist/snabbdom/attributes.d.ts.map +1 -1
  50. package/dist/snabbdom/attributes.js +65 -37
  51. package/dist/snabbdom/class.d.ts.map +1 -1
  52. package/dist/snabbdom/class.js +61 -5
  53. package/dist/snabbdom/style.d.ts.map +1 -1
  54. package/dist/snabbdom/style.js +57 -34
  55. package/dist/test/apps/attributes.d.ts +1 -0
  56. package/dist/test/apps/attributes.d.ts.map +1 -1
  57. package/dist/test/apps/attributes.js +8 -1
  58. package/dist/test/apps/login.js +1 -1
  59. package/dist/test/matchers.d.ts.map +1 -1
  60. package/dist/test/matchers.js +2 -1
  61. package/dist/test/scene.d.ts.map +1 -1
  62. package/dist/test/scene.js +2 -1
  63. package/package.json +2 -2
@@ -1,4 +1,7 @@
1
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';
2
5
  import { tagNameFromSelector } from '../../tagName.js';
3
6
  const VOID_ELEMENTS = new Set([
4
7
  'area',
@@ -18,10 +21,17 @@ const VOID_ELEMENTS = new Set([
18
21
  ]);
19
22
  const RAW_TEXT_ELEMENTS = new Set([
20
23
  'iframe',
24
+ 'noembed',
25
+ 'noframes',
21
26
  'noscript',
22
27
  'script',
23
28
  'style',
29
+ 'xmp',
24
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']);
25
35
  // NOTE: raw-text elements parse their content as text until the first
26
36
  // `</tagname`, so HTML entities are not decoded and a closing-tag sequence in
27
37
  // the content ends the element early. Escaping a text child, correct for a
@@ -30,10 +40,12 @@ const RAW_TEXT_ELEMENTS = new Set([
30
40
  // view. The content is therefore emitted verbatim, and a closing-tag sequence,
31
41
  // which would break out into live markup on the server, is a hard error since
32
42
  // there is no valid escaping for it. `<noscript>` is raw text only while
33
- // scripting is enabled, which is exactly the hydrating client's state, so it
34
- // serializes the same way; its text (including `&`, `<`, and `>`) round-trips
35
- // verbatim, and element children it cannot represent still fail the structure
36
- // check.
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`.
37
49
  const rawTextClosingSequence = (tagName) => new RegExp(`</${tagName}(?=[\\t\\n\\f\\r />]|$)`, 'i');
38
50
  // NOTE: `<script>` content has more parser states than the closing-tag check
39
51
  // covers. A `<!--` sequence moves the tokenizer into the script-data-escaped
@@ -56,6 +68,27 @@ const assertRawTextIsSafe = (tagName, content) => {
56
68
  '</script> tag no longer ends the element. Remove the <!-- sequence ' +
57
69
  'from the content.');
58
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
+ }
59
92
  };
60
93
  // NOTE: comment text has no escaping either: it ends at the first `-->` or
61
94
  // `--!>`, and must not start with `>` or `->` nor end with `<!-`. A
@@ -63,6 +96,8 @@ const assertRawTextIsSafe = (tagName, content) => {
63
96
  // markup on the server, so it is refused the same way raw-text content is.
64
97
  const COMMENT_TERMINATOR = /--!?>/;
65
98
  const assertCommentTextIsSafe = (text) => {
99
+ assertRepresentable(text, 'comment content');
100
+ assertNoCarriageReturn(text, 'comment content');
66
101
  if (text.startsWith('>') ||
67
102
  text.startsWith('->') ||
68
103
  COMMENT_TERMINATOR.test(text) ||
@@ -72,88 +107,6 @@ const assertCommentTextIsSafe = (text) => {
72
107
  'content.');
73
108
  }
74
109
  };
75
- const BOOLEAN_PROPERTIES = new Set([
76
- 'autofocus',
77
- 'autoplay',
78
- 'checked',
79
- 'controls',
80
- 'disabled',
81
- 'formNoValidate',
82
- 'hidden',
83
- 'inert',
84
- 'isMap',
85
- 'loop',
86
- 'multiple',
87
- 'muted',
88
- 'noValidate',
89
- 'open',
90
- 'playsInline',
91
- 'readOnly',
92
- 'required',
93
- 'reversed',
94
- 'selected',
95
- ]);
96
- const RENAMED_PROPERTY_ATTRIBUTES = {
97
- colSpan: 'colspan',
98
- dateTime: 'datetime',
99
- formAction: 'formaction',
100
- formEnctype: 'formenctype',
101
- formMethod: 'formmethod',
102
- formNoValidate: 'formnovalidate',
103
- formTarget: 'formtarget',
104
- htmlFor: 'for',
105
- isMap: 'ismap',
106
- maxLength: 'maxlength',
107
- minLength: 'minlength',
108
- noValidate: 'novalidate',
109
- playsInline: 'playsinline',
110
- readOnly: 'readonly',
111
- rowSpan: 'rowspan',
112
- tabIndex: 'tabindex',
113
- };
114
- const PASSTHROUGH_PROPERTIES = new Set([
115
- 'accept',
116
- 'action',
117
- 'alt',
118
- 'autocomplete',
119
- 'cite',
120
- 'cols',
121
- 'dir',
122
- 'download',
123
- 'enctype',
124
- 'high',
125
- 'href',
126
- 'id',
127
- 'label',
128
- 'lang',
129
- 'low',
130
- 'max',
131
- 'method',
132
- 'min',
133
- 'name',
134
- 'optimum',
135
- 'pattern',
136
- 'placeholder',
137
- 'poster',
138
- 'preload',
139
- 'rel',
140
- 'rows',
141
- 'size',
142
- 'span',
143
- 'src',
144
- 'start',
145
- 'step',
146
- 'target',
147
- 'title',
148
- 'type',
149
- 'value',
150
- 'wrap',
151
- ]);
152
- const STYLE_LIFECYCLE_KEYS = new Set([
153
- 'delayed',
154
- 'remove',
155
- 'destroy',
156
- ]);
157
110
  const CAPS_REGEX = /[A-Z]/g;
158
111
  const ATTRIBUTE_NAME_PATTERN = /^[A-Za-z_:][A-Za-z0-9_.:-]*$/;
159
112
  // NOTE: the tag name is written verbatim into the opening and closing markup,
@@ -177,17 +130,67 @@ const ATTRIBUTE_ESCAPES = {
177
130
  '&': '&amp;',
178
131
  '"': '&quot;',
179
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
+ }
180
177
  };
181
178
  /** Escapes a string for use as HTML text content.
182
179
  *
183
180
  * @internal Shared with the template injector; not part of the `foldkit/experimental/server` surface.
184
181
  */
185
- export const escapeText = (value) => value.replace(/[&<>\r]/g, character => TEXT_ESCAPES[character] ?? character);
182
+ export const escapeText = (value) => {
183
+ assertRepresentable(value, 'text content');
184
+ return value.replace(/[&<>\r]/g, character => TEXT_ESCAPES[character] ?? character);
185
+ };
186
186
  /** Escapes a string for use inside a double-quoted HTML attribute value.
187
187
  *
188
188
  * @internal Shared with the template injector; not part of the `foldkit/experimental/server` surface.
189
189
  */
190
- export const escapeAttributeValue = (value) => value.replace(/[&"<]/g, character => ATTRIBUTE_ESCAPES[character] ?? character);
190
+ export const escapeAttributeValue = (value) => {
191
+ assertRepresentable(value, 'attribute value');
192
+ return value.replace(/[&"<\r]/g, character => ATTRIBUTE_ESCAPES[character] ?? character);
193
+ };
191
194
  const toKebabCase = (value) => value.replace(CAPS_REGEX, '-$&').toLowerCase();
192
195
  const setAttribute = (attributes, name, value) => {
193
196
  if (!ATTRIBUTE_NAME_PATTERN.test(name)) {
@@ -211,12 +214,18 @@ const appendStyle = (attributes, addition) => {
211
214
  attributes.set('style', existing === undefined ? addition : `${existing}${separator}${addition}`);
212
215
  };
213
216
  const collectDataAttributes = (attributes, data) => {
214
- const isForeignNamespace = data.ns !== undefined;
217
+ const namespace = data.ns ?? HTML_NAMESPACE;
215
218
  const plainAttributes = data.attrs;
216
219
  if (plainAttributes !== undefined) {
220
+ const normalizedAttributes = new Map();
217
221
  for (const rawName of Object.keys(plainAttributes)) {
222
+ const name = parsedAttributeName(namespace, rawName);
218
223
  const value = plainAttributes[rawName];
219
- const name = isForeignNamespace ? rawName : rawName.toLowerCase();
224
+ if (value !== undefined) {
225
+ normalizedAttributes.set(name, value);
226
+ }
227
+ }
228
+ for (const [name, value] of normalizedAttributes) {
220
229
  if (value === true) {
221
230
  setAttribute(attributes, name, '');
222
231
  }
@@ -250,55 +259,73 @@ const collectDataAttributes = (attributes, data) => {
250
259
  }
251
260
  }
252
261
  };
253
- const collectPropertyAttributes = (attributes, tagName, properties) => {
262
+ const collectPropertyAttributes = (attributes, tagName, properties, isCustomElement, isForeignNamespace) => {
254
263
  for (const name of Object.keys(properties)) {
255
264
  const value = properties[name];
256
265
  if (value === undefined || name === 'innerHTML') {
257
266
  continue;
258
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
+ }
259
287
  // NOTE: `value` on textarea/output/select is not a serializable attribute
260
288
  // (textarea and output render it as text content, select reflects the
261
- // selected option), and on an input an empty value matches the element's
262
- // default so the attribute is redundant. It stays meaningful elsewhere,
263
- // e.g. `<option value="">`.
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.
264
295
  if (name === 'value' &&
265
- (tagName === 'textarea' ||
266
- tagName === 'output' ||
267
- tagName === 'select' ||
268
- (tagName === 'input' && value === ''))) {
296
+ (tagName === 'textarea' || tagName === 'output' || tagName === 'select')) {
297
+ attributes.delete('value');
269
298
  continue;
270
299
  }
271
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;
272
306
  if (value === true) {
273
- setAttribute(attributes, RENAMED_PROPERTY_ATTRIBUTES[name] ?? name, '');
307
+ setAttribute(attributes, attributeName, '');
308
+ }
309
+ else {
310
+ attributes.delete(attributeName);
274
311
  }
275
312
  }
276
313
  else if (name === 'draggable') {
277
314
  setAttribute(attributes, name, value === true ? 'true' : 'false');
278
315
  }
279
316
  else if (name in RENAMED_PROPERTY_ATTRIBUTES) {
280
- setAttribute(attributes, RENAMED_PROPERTY_ATTRIBUTES[name] ?? name, String(value));
317
+ setAttribute(attributes, RENAMED_PROPERTY_ATTRIBUTES[name] ?? name, serializedHtmlPropertyValue(tagName, name, value));
281
318
  }
282
319
  else if (PASSTHROUGH_PROPERTIES.has(name)) {
283
- setAttribute(attributes, name, String(value));
320
+ setAttribute(attributes, name, serializedHtmlPropertyValue(tagName, name, value));
284
321
  }
285
322
  }
286
323
  };
287
324
  const collectStyleAttribute = (attributes, style) => {
288
- const declarations = [];
289
- for (const name of Object.keys(style)) {
290
- if (STYLE_LIFECYCLE_KEYS.has(name)) {
291
- continue;
292
- }
293
- const value = style[name];
294
- if (typeof value !== 'string' || value === '') {
295
- continue;
296
- }
297
- const propertyName = name.startsWith('--') ? name : toKebabCase(name);
298
- declarations.push(`${propertyName}: ${value}`);
299
- }
300
- if (Array_.isArrayNonEmpty(declarations)) {
301
- appendStyle(attributes, declarations.join('; '));
325
+ assertStyleIsRepresentable(style);
326
+ const serialized = serializedStyleAttribute(style);
327
+ if (serialized !== undefined) {
328
+ appendStyle(attributes, serialized);
302
329
  }
303
330
  };
304
331
  const serializeAttributes = (output, attributes) => {
@@ -342,6 +369,13 @@ const NEWLINE_DROPPING_ELEMENTS = new Set([
342
369
  'pre',
343
370
  'listing',
344
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
+ ]);
345
379
  // The text the serialized content begins with, used to decide whether a
346
380
  // newline-dropping element (`pre`, `listing`, `textarea`) needs an extra
347
381
  // leading newline to survive the parser's leading-newline strip. Empty string
@@ -378,11 +412,11 @@ const leadingTextOf = (node) => {
378
412
  // real view and refused past the limit, so a hostile input becomes a typed
379
413
  // SerializationError rather than a stack overflow.
380
414
  const MAX_RENDER_DEPTH = 1000;
381
- const serializeChildren = (output, node, depth, selectValue) => {
415
+ const serializeChildren = (output, context, node, depth, selectValue) => {
382
416
  const children = node.children;
383
417
  if (children !== undefined) {
384
418
  for (const child of children) {
385
- serializeNode(output, child, depth + 1, undefined, selectValue);
419
+ serializeNode(output, context, child, depth + 1, undefined, selectValue);
386
420
  }
387
421
  }
388
422
  else if (node.text !== undefined) {
@@ -393,14 +427,106 @@ const serializeChildren = (output, node, depth, selectValue) => {
393
427
  // DOM does: `option.value` reflects the text content with ASCII whitespace
394
428
  // stripped and internal runs collapsed, so a label wrapped across source
395
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
+ };
396
476
  const optionValue = (node) => {
397
477
  const value = node.data?.props?.['value'];
398
478
  if (typeof value === 'string') {
399
479
  return value;
400
480
  }
481
+ const rawValue = effectiveAttribute(node, 'value');
482
+ if (rawValue !== undefined) {
483
+ return rawValue;
484
+ }
401
485
  return collectRawText(node)
402
- .replace(/[\t\n\f\r ]+/g, ' ')
403
- .trim();
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));
404
530
  };
405
531
  const selectValueForChildren = (tagName, node, inherited) => {
406
532
  if (tagName !== 'select') {
@@ -408,38 +534,73 @@ const selectValueForChildren = (tagName, node, inherited) => {
408
534
  }
409
535
  const value = node.data?.props?.['value'];
410
536
  if (typeof value === 'string') {
411
- return { value, consumed: false };
537
+ return {
538
+ value,
539
+ consumed: false,
540
+ allowsNoSelection: allowsNoSelection(node),
541
+ };
412
542
  }
413
543
  return undefined;
414
544
  };
415
- const serializeElement = (output, node, selector, depth, extraAttributes, selectValue) => {
416
- const tagName = tagNameFromSelector(selector);
417
- if (!TAG_NAME_PATTERN.test(tagName)) {
418
- throw new Error(`[foldkit] Cannot serialize the invalid tag name "${tagName}". Tag ` +
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 ` +
419
549
  'names must start with a letter and use only letters, numbers, ' +
420
550
  'hyphens, dots, and underscores.');
421
551
  }
422
552
  const data = node.data;
423
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('-');
424
563
  const attributes = new Map();
425
564
  if (data !== undefined) {
426
565
  collectDataAttributes(attributes, data);
427
566
  const properties = data.props;
428
567
  if (properties !== undefined) {
429
- collectPropertyAttributes(attributes, tagName, properties);
568
+ collectPropertyAttributes(attributes, tagName, properties, isCustomElement, isForeignNamespace);
430
569
  }
431
570
  const style = data.style;
432
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
+ }
433
576
  collectStyleAttribute(attributes, style);
434
577
  }
435
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.
436
585
  if (!isForeignNamespace &&
437
586
  tagName === 'option' &&
438
- selectValue !== undefined &&
439
- !selectValue.consumed &&
440
- optionValue(node) === selectValue.value) {
441
- setAttribute(attributes, 'selected', '');
442
- selectValue.consumed = true;
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
+ }
443
604
  }
444
605
  if (extraAttributes !== undefined) {
445
606
  for (const name of Object.keys(extraAttributes)) {
@@ -449,6 +610,33 @@ const serializeElement = (output, node, selector, depth, extraAttributes, select
449
610
  }
450
611
  }
451
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 fingerprint of
615
+ // each so hydration can verify it is adopting the same logical entity without
616
+ // the raw key or source identity appearing in public markup; it strips the
617
+ // markers as it adopts.
618
+ if (context.emitHydrationMarkers) {
619
+ if (node.key !== undefined) {
620
+ const keyMarker = hydrationKeyMarker(node.key);
621
+ if (keyMarker === undefined) {
622
+ if (typeof node.key === 'number' && Number.isNaN(node.key)) {
623
+ throw new Error('[foldkit] Cannot server-render an element keyed by NaN as ' +
624
+ 'hydratable. NaN is not equal to itself, so hydration cannot ' +
625
+ 'tell whether the server and client mean the same element. ' +
626
+ 'Key hydratable elements by a string or a number other than NaN.');
627
+ }
628
+ throw new Error('[foldkit] Cannot server-render an element keyed by a symbol. A ' +
629
+ 'symbol key cannot be compared across the server and the client ' +
630
+ '(a local symbol is a new value in every realm, so hydration ' +
631
+ 'could not tell two rows apart). Key hydratable elements by a ' +
632
+ 'string or a number.');
633
+ }
634
+ setAttribute(attributes, HYDRATION_KEY_ATTRIBUTE, keyMarker);
635
+ }
636
+ if (node.identity !== undefined) {
637
+ setAttribute(attributes, HYDRATION_IDENTITY_ATTRIBUTE, hydrationIdentityMarker(node.identity));
638
+ }
639
+ }
452
640
  output.push(`<${tagName}`);
453
641
  serializeAttributes(output, attributes);
454
642
  output.push('>');
@@ -466,11 +654,30 @@ const serializeElement = (output, node, selector, depth, extraAttributes, select
466
654
  ? undefined
467
655
  : selectValueForChildren(tagName, node, selectValue);
468
656
  const isHtmlRawText = !isForeignNamespace && RAW_TEXT_ELEMENTS.has(tagName);
657
+ const isHtmlRcdata = !isForeignNamespace && RCDATA_ELEMENTS.has(tagName);
469
658
  const innerHtml = data?.props?.['innerHTML'];
470
- if (typeof innerHtml === 'string') {
471
- if (isHtmlRawText) {
659
+ if (typeof innerHtml === 'string' && hasTrustedInnerHtml(data?.props)) {
660
+ // Trusted raw HTML still cannot end the element that holds it. An RCDATA
661
+ // element ends at its own closing tag exactly as a raw-text one does, so
662
+ // markup written into a <textarea> or <title> would otherwise close it and
663
+ // put the rest of the fragment in the document.
664
+ if (isHtmlRawText || isHtmlRcdata) {
472
665
  assertRawTextIsSafe(tagName, innerHtml);
473
666
  }
667
+ // NOTE: one literal newline, always, for the elements whose parser drops
668
+ // one. The document parser consumes exactly this prefix, so whatever the
669
+ // fragment itself begins with survives unchanged, which is what assigning
670
+ // the same string to `element.innerHTML` on the client produces.
671
+ //
672
+ // Testing whether the fragment starts with a literal `\n` is not enough:
673
+ // tokenization also produces a leading newline from a character reference
674
+ // (`&#10;`, `&#xA;`, `&NewLine;`) and from input preprocessing turning CR
675
+ // or CRLF into LF, none of which is a literal newline in the source bytes.
676
+ // An unconditional prefix covers every one of them, and costs an empty
677
+ // fragment nothing.
678
+ if (!isForeignNamespace && LEADING_NEWLINE_DROPPING_ELEMENTS.has(tagName)) {
679
+ output.push('\n');
680
+ }
474
681
  output.push(innerHtml);
475
682
  }
476
683
  else if (!isForeignNamespace && tagName === 'textarea') {
@@ -485,7 +692,7 @@ const serializeElement = (output, node, selector, depth, extraAttributes, select
485
692
  if (leadingTextOf(node)?.startsWith('\n')) {
486
693
  output.push('\n');
487
694
  }
488
- serializeChildren(output, node, depth, childSelectValue);
695
+ serializeChildren(output, context, node, depth, childSelectValue);
489
696
  }
490
697
  }
491
698
  else if (!isForeignNamespace && tagName === 'output') {
@@ -497,26 +704,40 @@ const serializeElement = (output, node, selector, depth, extraAttributes, select
497
704
  output.push(escapeText(content));
498
705
  }
499
706
  else {
500
- serializeChildren(output, node, depth, childSelectValue);
707
+ serializeChildren(output, context, node, depth, childSelectValue);
501
708
  }
502
709
  }
503
710
  else if (!isForeignNamespace && NEWLINE_DROPPING_ELEMENTS.has(tagName)) {
504
711
  if (leadingTextOf(node)?.startsWith('\n')) {
505
712
  output.push('\n');
506
713
  }
507
- serializeChildren(output, node, depth, childSelectValue);
714
+ serializeChildren(output, context, node, depth, childSelectValue);
508
715
  }
509
716
  else if (isHtmlRawText) {
510
717
  const rawText = collectRawText(node);
511
718
  assertRawTextIsSafe(tagName, rawText);
719
+ if (tagName === 'noscript') {
720
+ assertNoscriptTextIsSafe(rawText);
721
+ }
512
722
  output.push(rawText);
513
723
  }
514
724
  else {
515
- serializeChildren(output, node, depth, childSelectValue);
725
+ serializeChildren(output, context, node, depth, childSelectValue);
726
+ }
727
+ if (childSelectValue !== undefined &&
728
+ childSelectValue !== selectValue &&
729
+ !childSelectValue.consumed &&
730
+ !childSelectValue.allowsNoSelection) {
731
+ throw new Error(`[foldkit] A <select> has the controlled value "${childSelectValue.value}" ` +
732
+ 'but no option carries it. A single-line select cannot render with ' +
733
+ 'nothing selected: HTML gives the first option the selection, while ' +
734
+ 'the client sets `value` and lands on no selection at all, so the ' +
735
+ 'served page and the hydrated one would disagree. Render an option ' +
736
+ 'with that value, add a placeholder option, or use `multiple`.');
516
737
  }
517
738
  output.push(`</${tagName}>`);
518
739
  };
519
- const serializeNode = (output, node, depth, extraAttributes, selectValue) => {
740
+ const serializeNode = (output, context, node, depth, extraAttributes, selectValue) => {
520
741
  if (depth > MAX_RENDER_DEPTH) {
521
742
  throw new Error(`[foldkit] renderToString exceeded the maximum render depth of ${MAX_RENDER_DEPTH}. ` +
522
743
  'A view nesting elements this deeply, often from mapping untrusted ' +
@@ -543,13 +764,18 @@ const serializeNode = (output, node, depth, extraAttributes, selectValue) => {
543
764
  output.push(`<!--${commentText}-->`);
544
765
  return;
545
766
  }
546
- serializeElement(output, node, selector, depth, extraAttributes, selectValue);
767
+ serializeElement(output, context, node, selector, depth, extraAttributes, selectValue);
547
768
  };
548
- /** Serializes a view-produced vnode tree to an HTML string. Event handlers,
549
- * hooks, keys, and identity are behavior, not markup, and are skipped;
550
- * attrs, class, dataset, prop-backed attributes, and inline style are
551
- * emitted in that order. A `null` tree serializes to an empty comment,
552
- * mirroring how the runtime patches `null` as a comment node.
769
+ /** Serializes a view-produced vnode tree to an HTML string. Event handlers and
770
+ * hooks are behavior, not markup, and are skipped; attrs, class, dataset,
771
+ * prop-backed attributes, and inline style are emitted in that order. A `null`
772
+ * tree serializes to an empty comment, mirroring how the runtime patches
773
+ * `null` as a comment node.
774
+ *
775
+ * A hydratable render also stamps a fingerprint of each vnode's key and
776
+ * identity, so hydration can tell one logical entity from another; the raw key
777
+ * and the compiler's source identity never appear in the markup. A render that
778
+ * is not hydratable emits neither.
553
779
  *
554
780
  * @internal Not part of the `foldkit/experimental/server` surface; `renderToString` is the public entry to serialization.
555
781
  */
@@ -557,7 +783,10 @@ export const serializeHtml = (root, options) => {
557
783
  if (root === null) {
558
784
  return '<!---->';
559
785
  }
786
+ const context = {
787
+ emitHydrationMarkers: options?.emitHydrationMarkers ?? false,
788
+ };
560
789
  const output = [];
561
- serializeNode(output, root, 0, options?.rootAttributes);
790
+ serializeNode(output, context, root, 0, options?.rootAttributes);
562
791
  return output.join('');
563
792
  };