foldkit 0.146.0 → 0.147.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 (56) hide show
  1. package/README.md +5 -2
  2. package/dist/customElement/index.d.ts.map +1 -1
  3. package/dist/customElement/index.js +23 -0
  4. package/dist/experimental/index.d.ts +1 -0
  5. package/dist/experimental/index.d.ts.map +1 -1
  6. package/dist/experimental/index.js +1 -0
  7. package/dist/experimental/machine/machine.d.ts +22 -3
  8. package/dist/experimental/machine/machine.d.ts.map +1 -1
  9. package/dist/experimental/machine/machine.js +8 -0
  10. package/dist/experimental/server/entry.d.ts +73 -0
  11. package/dist/experimental/server/entry.d.ts.map +1 -0
  12. package/dist/experimental/server/entry.js +41 -0
  13. package/dist/experimental/server/host.d.ts +42 -0
  14. package/dist/experimental/server/host.d.ts.map +1 -0
  15. package/dist/experimental/server/host.js +169 -0
  16. package/dist/experimental/server/index.d.ts +5 -0
  17. package/dist/experimental/server/index.d.ts.map +1 -0
  18. package/dist/experimental/server/index.js +4 -0
  19. package/dist/experimental/server/public.d.ts +3 -0
  20. package/dist/experimental/server/public.d.ts.map +1 -0
  21. package/dist/experimental/server/public.js +1 -0
  22. package/dist/experimental/server/serialize.d.ts +33 -0
  23. package/dist/experimental/server/serialize.d.ts.map +1 -0
  24. package/dist/experimental/server/serialize.js +563 -0
  25. package/dist/experimental/server/server.d.ts +201 -0
  26. package/dist/experimental/server/server.d.ts.map +1 -0
  27. package/dist/experimental/server/server.js +423 -0
  28. package/dist/experimental/server/template.d.ts +45 -0
  29. package/dist/experimental/server/template.d.ts.map +1 -0
  30. package/dist/experimental/server/template.js +182 -0
  31. package/dist/html/index.d.ts +5 -0
  32. package/dist/html/index.d.ts.map +1 -1
  33. package/dist/html/index.js +38 -4
  34. package/dist/hydrate.d.ts +4 -0
  35. package/dist/hydrate.d.ts.map +1 -0
  36. package/dist/hydrate.js +522 -0
  37. package/dist/hydrationMarker.d.ts +10 -0
  38. package/dist/hydrationMarker.d.ts.map +1 -0
  39. package/dist/hydrationMarker.js +9 -0
  40. package/dist/runtime/public.d.ts +2 -2
  41. package/dist/runtime/public.d.ts.map +1 -1
  42. package/dist/runtime/public.js +1 -1
  43. package/dist/runtime/runtime.d.ts +75 -36
  44. package/dist/runtime/runtime.d.ts.map +1 -1
  45. package/dist/runtime/runtime.js +209 -71
  46. package/dist/snabbdom/h.d.ts +1 -0
  47. package/dist/snabbdom/h.d.ts.map +1 -1
  48. package/dist/snabbdom/h.js +85 -4
  49. package/dist/snabbdom/tovnode.d.ts.map +1 -1
  50. package/dist/snabbdom/tovnode.js +5 -1
  51. package/dist/tagName.d.ts +6 -0
  52. package/dist/tagName.d.ts.map +1 -0
  53. package/dist/tagName.js +11 -0
  54. package/dist/vdom.d.ts.map +1 -1
  55. package/dist/vdom.js +25 -1
  56. package/package.json +8 -1
@@ -0,0 +1,33 @@
1
+ import type { VNode } from '../../snabbdom/vnode.js';
2
+ /** Extra attributes appended to the serialized root element, used by
3
+ * `renderToString` to stamp the hydration marker without threading
4
+ * server-only concerns through view code. Root attributes are applied after
5
+ * the vnode's own data, so the stamp always wins over a same-named
6
+ * attribute in the view.
7
+ *
8
+ * @internal Not part of the `foldkit/experimental/server` surface; `renderToString` is the public entry to serialization.
9
+ */
10
+ export type SerializeOptions = Readonly<{
11
+ rootAttributes?: Readonly<Record<string, string>>;
12
+ }>;
13
+ /** Escapes a string for use as HTML text content.
14
+ *
15
+ * @internal Shared with the template injector; not part of the `foldkit/experimental/server` surface.
16
+ */
17
+ export declare const escapeText: (value: string) => string;
18
+ /** Escapes a string for use inside a double-quoted HTML attribute value.
19
+ *
20
+ * @internal Shared with the template injector; not part of the `foldkit/experimental/server` surface.
21
+ */
22
+ export declare const escapeAttributeValue: (value: string) => string;
23
+ export declare const controlledValueContent: (properties: Readonly<Record<string, unknown>> | undefined) => string | undefined;
24
+ /** Serializes a view-produced vnode tree to an HTML string. Event handlers,
25
+ * hooks, keys, and identity are behavior, not markup, and are skipped;
26
+ * attrs, class, dataset, prop-backed attributes, and inline style are
27
+ * emitted in that order. A `null` tree serializes to an empty comment,
28
+ * mirroring how the runtime patches `null` as a comment node.
29
+ *
30
+ * @internal Not part of the `foldkit/experimental/server` surface; `renderToString` is the public entry to serialization.
31
+ */
32
+ export declare const serializeHtml: (root: VNode | null, options?: SerializeOptions) => string;
33
+ //# sourceMappingURL=serialize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serialize.d.ts","sourceRoot":"","sources":["../../../src/experimental/server/serialize.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAA;AAGpD;;;;;;;GAOG;AACH,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CAAC;IACtC,cAAc,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CAClD,CAAC,CAAA;AA8MF;;;GAGG;AACH,eAAO,MAAM,UAAU,GAAI,OAAO,MAAM,KAAG,MACmC,CAAA;AAE9E;;;GAGG;AACH,eAAO,MAAM,oBAAoB,GAAI,OAAO,MAAM,KAAG,MAIlD,CAAA;AAuKH,eAAO,MAAM,sBAAsB,GACjC,YAAY,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,SAAS,KACxD,MAAM,GAAG,SAMX,CAAA;AAmRD;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,GACxB,MAAM,KAAK,GAAG,IAAI,EAClB,UAAU,gBAAgB,KACzB,MAOF,CAAA"}
@@ -0,0 +1,563 @@
1
+ import { Array as Array_ } from 'effect';
2
+ import { tagNameFromSelector } from '../../tagName.js';
3
+ const VOID_ELEMENTS = new Set([
4
+ 'area',
5
+ 'base',
6
+ 'br',
7
+ 'col',
8
+ 'embed',
9
+ 'hr',
10
+ 'img',
11
+ 'input',
12
+ 'link',
13
+ 'meta',
14
+ 'param',
15
+ 'source',
16
+ 'track',
17
+ 'wbr',
18
+ ]);
19
+ const RAW_TEXT_ELEMENTS = new Set([
20
+ 'iframe',
21
+ 'noscript',
22
+ 'script',
23
+ 'style',
24
+ ]);
25
+ // NOTE: raw-text elements parse their content as text until the first
26
+ // `</tagname`, so HTML entities are not decoded and a closing-tag sequence in
27
+ // the content ends the element early. Escaping a text child, correct for a
28
+ // normal element, is wrong here: the browser reads the escaped `&lt;` back as
29
+ // the literal characters, not `<`, so the server DOM text disagrees with the
30
+ // view. The content is therefore emitted verbatim, and a closing-tag sequence,
31
+ // which would break out into live markup on the server, is a hard error since
32
+ // 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.
37
+ const rawTextClosingSequence = (tagName) => new RegExp(`</${tagName}(?=[\\t\\n\\f\\r />]|$)`, 'i');
38
+ // NOTE: `<script>` content has more parser states than the closing-tag check
39
+ // covers. A `<!--` sequence moves the tokenizer into the script-data-escaped
40
+ // state, and a following `<script` moves it into script-data-double-escaped,
41
+ // where the serializer's own `</script>` no longer closes the element and
42
+ // the rest of the document is swallowed as script text. There is no escaping
43
+ // for it, so it is refused the same way the closing-tag sequence is.
44
+ const SCRIPT_ESCAPE_OPENER = /<!--/;
45
+ const assertRawTextIsSafe = (tagName, content) => {
46
+ if (rawTextClosingSequence(tagName).test(content)) {
47
+ throw new Error(`[foldkit] <${tagName}> content contains a </${tagName} sequence, ` +
48
+ 'which cannot be represented in a raw-text element and would break ' +
49
+ 'out of the tag when the HTML is parsed. Remove the closing-tag ' +
50
+ 'sequence from the content.');
51
+ }
52
+ if (tagName.toLowerCase() === 'script' &&
53
+ SCRIPT_ESCAPE_OPENER.test(content)) {
54
+ throw new Error('[foldkit] <script> content contains a <!-- sequence, which moves the ' +
55
+ 'HTML parser into a script-data-escaped state where the closing ' +
56
+ '</script> tag no longer ends the element. Remove the <!-- sequence ' +
57
+ 'from the content.');
58
+ }
59
+ };
60
+ // NOTE: comment text has no escaping either: it ends at the first `-->` or
61
+ // `--!>`, and must not start with `>` or `->` nor end with `<!-`. A
62
+ // terminating sequence in the text would break out of the comment into live
63
+ // markup on the server, so it is refused the same way raw-text content is.
64
+ const COMMENT_TERMINATOR = /--!?>/;
65
+ const assertCommentTextIsSafe = (text) => {
66
+ if (text.startsWith('>') ||
67
+ text.startsWith('->') ||
68
+ COMMENT_TERMINATOR.test(text) ||
69
+ text.endsWith('<!-')) {
70
+ throw new Error('[foldkit] comment content contains a sequence that would terminate ' +
71
+ 'the comment when the HTML is parsed. Remove the sequence from the ' +
72
+ 'content.');
73
+ }
74
+ };
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
+ const CAPS_REGEX = /[A-Z]/g;
158
+ const ATTRIBUTE_NAME_PATTERN = /^[A-Za-z_:][A-Za-z0-9_.:-]*$/;
159
+ // NOTE: the tag name is written verbatim into the opening and closing markup,
160
+ // so it must not carry markup-significant characters. CustomElement.define
161
+ // only checks that a tag contains a hyphen, so a string such as
162
+ // `x-a><script>` would pass validation and inject live elements. This is the
163
+ // serializer's own defense: any tag it cannot represent as a bare name is a
164
+ // hard error rather than emitted markup.
165
+ const TAG_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._-]*$/;
166
+ // NOTE: `\r` is escaped because the HTML parser normalizes CR and CRLF to LF
167
+ // before tokenization; a verbatim carriage return would read back as a
168
+ // different string and guarantee a hydration mismatch. The entity survives
169
+ // tokenization and decodes back to the original character.
170
+ const TEXT_ESCAPES = {
171
+ '&': '&amp;',
172
+ '<': '&lt;',
173
+ '>': '&gt;',
174
+ '\r': '&#13;',
175
+ };
176
+ const ATTRIBUTE_ESCAPES = {
177
+ '&': '&amp;',
178
+ '"': '&quot;',
179
+ '<': '&lt;',
180
+ };
181
+ /** Escapes a string for use as HTML text content.
182
+ *
183
+ * @internal Shared with the template injector; not part of the `foldkit/experimental/server` surface.
184
+ */
185
+ export const escapeText = (value) => value.replace(/[&<>\r]/g, character => TEXT_ESCAPES[character] ?? character);
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) => value.replace(/[&"<]/g, character => ATTRIBUTE_ESCAPES[character] ?? character);
191
+ const toKebabCase = (value) => value.replace(CAPS_REGEX, '-$&').toLowerCase();
192
+ const setAttribute = (attributes, name, value) => {
193
+ if (!ATTRIBUTE_NAME_PATTERN.test(name)) {
194
+ throw new Error(`[foldkit] Cannot serialize the invalid attribute name "${name}". ` +
195
+ 'Attribute names must use letters, numbers, underscores, periods, colons, and hyphens, and must not start with a number.');
196
+ }
197
+ attributes.set(name, value);
198
+ };
199
+ const appendClass = (attributes, addition) => {
200
+ const existing = attributes.get('class');
201
+ const nextClass = existing === undefined || existing === ''
202
+ ? addition
203
+ : `${existing} ${addition}`;
204
+ attributes.set('class', nextClass);
205
+ };
206
+ const appendStyle = (attributes, addition) => {
207
+ const existing = attributes.get('style');
208
+ const separator = existing === undefined || existing === '' || existing.endsWith(';')
209
+ ? ''
210
+ : '; ';
211
+ attributes.set('style', existing === undefined ? addition : `${existing}${separator}${addition}`);
212
+ };
213
+ const collectDataAttributes = (attributes, data) => {
214
+ const isForeignNamespace = data.ns !== undefined;
215
+ const plainAttributes = data.attrs;
216
+ if (plainAttributes !== undefined) {
217
+ for (const rawName of Object.keys(plainAttributes)) {
218
+ const value = plainAttributes[rawName];
219
+ const name = isForeignNamespace ? rawName : rawName.toLowerCase();
220
+ if (value === true) {
221
+ setAttribute(attributes, name, '');
222
+ }
223
+ else if (value !== false && value !== undefined) {
224
+ if (name === 'class') {
225
+ appendClass(attributes, String(value));
226
+ }
227
+ else if (name === 'style') {
228
+ appendStyle(attributes, String(value));
229
+ }
230
+ else {
231
+ setAttribute(attributes, name, String(value));
232
+ }
233
+ }
234
+ }
235
+ }
236
+ const classes = data.class;
237
+ if (classes !== undefined) {
238
+ const activeClasses = Object.keys(classes).filter(className => classes[className] === true);
239
+ if (Array_.isArrayNonEmpty(activeClasses)) {
240
+ appendClass(attributes, activeClasses.join(' '));
241
+ }
242
+ }
243
+ const dataset = data.dataset;
244
+ if (dataset !== undefined) {
245
+ for (const name of Object.keys(dataset)) {
246
+ const value = dataset[name];
247
+ if (value !== undefined) {
248
+ setAttribute(attributes, `data-${toKebabCase(name)}`, value);
249
+ }
250
+ }
251
+ }
252
+ };
253
+ const collectPropertyAttributes = (attributes, tagName, properties) => {
254
+ for (const name of Object.keys(properties)) {
255
+ const value = properties[name];
256
+ if (value === undefined || name === 'innerHTML') {
257
+ continue;
258
+ }
259
+ // NOTE: `value` on textarea/output/select is not a serializable attribute
260
+ // (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="">`.
264
+ if (name === 'value' &&
265
+ (tagName === 'textarea' ||
266
+ tagName === 'output' ||
267
+ tagName === 'select' ||
268
+ (tagName === 'input' && value === ''))) {
269
+ continue;
270
+ }
271
+ if (BOOLEAN_PROPERTIES.has(name)) {
272
+ if (value === true) {
273
+ setAttribute(attributes, RENAMED_PROPERTY_ATTRIBUTES[name] ?? name, '');
274
+ }
275
+ }
276
+ else if (name === 'draggable') {
277
+ setAttribute(attributes, name, value === true ? 'true' : 'false');
278
+ }
279
+ else if (name in RENAMED_PROPERTY_ATTRIBUTES) {
280
+ setAttribute(attributes, RENAMED_PROPERTY_ATTRIBUTES[name] ?? name, String(value));
281
+ }
282
+ else if (PASSTHROUGH_PROPERTIES.has(name)) {
283
+ setAttribute(attributes, name, String(value));
284
+ }
285
+ }
286
+ };
287
+ 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('; '));
302
+ }
303
+ };
304
+ const serializeAttributes = (output, attributes) => {
305
+ for (const [name, value] of attributes) {
306
+ output.push(` ${name}="${escapeAttributeValue(value)}"`);
307
+ }
308
+ };
309
+ // The text a controlled `<textarea>` or `<output>` serializes: its `value` prop
310
+ // when set, or `undefined` when the element is uncontrolled and its children are
311
+ // its content. Exported so the render-time structure check can predict the text
312
+ // content the serializer produces without re-deriving the rule.
313
+ export const controlledValueContent = (properties) => {
314
+ const value = properties?.['value'];
315
+ if (typeof value === 'string') {
316
+ return value;
317
+ }
318
+ return undefined;
319
+ };
320
+ const collectRawText = (node) => {
321
+ if (node.text !== undefined) {
322
+ return node.text;
323
+ }
324
+ if (node.children === undefined) {
325
+ return '';
326
+ }
327
+ const parts = [];
328
+ for (const child of node.children) {
329
+ if (typeof child === 'string') {
330
+ parts.push(child);
331
+ }
332
+ else if (child.text !== undefined) {
333
+ parts.push(child.text);
334
+ }
335
+ }
336
+ return parts.join('');
337
+ };
338
+ // NOTE: the HTML parser drops a single newline immediately after the start
339
+ // tag of <textarea>, <pre>, and <listing>, so serialized content that starts
340
+ // with one needs an extra newline to survive the round-trip.
341
+ const NEWLINE_DROPPING_ELEMENTS = new Set([
342
+ 'pre',
343
+ 'listing',
344
+ ]);
345
+ // The text the serialized content begins with, used to decide whether a
346
+ // newline-dropping element (`pre`, `listing`, `textarea`) needs an extra
347
+ // leading newline to survive the parser's leading-newline strip. Empty string
348
+ // children and empty text VNodes serialize to nothing, so they are skipped to
349
+ // reach the first run that actually emits text. The scan stops at the first
350
+ // element or comment: past it the serialized content no longer begins with
351
+ // text, so no padding is needed.
352
+ const leadingTextOf = (node) => {
353
+ if (node.text !== undefined) {
354
+ return node.text;
355
+ }
356
+ for (const child of node.children ?? []) {
357
+ if (typeof child === 'string') {
358
+ if (child !== '') {
359
+ return child;
360
+ }
361
+ continue;
362
+ }
363
+ const selector = child.sel;
364
+ if (selector === undefined || selector === '') {
365
+ const text = child.text ?? '';
366
+ if (text !== '') {
367
+ return text;
368
+ }
369
+ continue;
370
+ }
371
+ return undefined;
372
+ }
373
+ return undefined;
374
+ };
375
+ // NOTE: the serializer recurses per element, so a tree nested thousands of
376
+ // elements deep, typically from mapping untrusted hierarchical data straight
377
+ // to markup, would exhaust the call stack. The depth is bounded well above any
378
+ // real view and refused past the limit, so a hostile input becomes a typed
379
+ // SerializationError rather than a stack overflow.
380
+ const MAX_RENDER_DEPTH = 1000;
381
+ const serializeChildren = (output, node, depth, selectValue) => {
382
+ const children = node.children;
383
+ if (children !== undefined) {
384
+ for (const child of children) {
385
+ serializeNode(output, child, depth + 1, undefined, selectValue);
386
+ }
387
+ }
388
+ else if (node.text !== undefined) {
389
+ output.push(escapeText(node.text));
390
+ }
391
+ };
392
+ // NOTE: an option without a value prop falls back to its label the way the
393
+ // DOM does: `option.value` reflects the text content with ASCII whitespace
394
+ // stripped and internal runs collapsed, so a label wrapped across source
395
+ // lines still matches the select value.
396
+ const optionValue = (node) => {
397
+ const value = node.data?.props?.['value'];
398
+ if (typeof value === 'string') {
399
+ return value;
400
+ }
401
+ return collectRawText(node)
402
+ .replace(/[\t\n\f\r ]+/g, ' ')
403
+ .trim();
404
+ };
405
+ const selectValueForChildren = (tagName, node, inherited) => {
406
+ if (tagName !== 'select') {
407
+ return inherited;
408
+ }
409
+ const value = node.data?.props?.['value'];
410
+ if (typeof value === 'string') {
411
+ return { value, consumed: false };
412
+ }
413
+ return undefined;
414
+ };
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 ` +
419
+ 'names must start with a letter and use only letters, numbers, ' +
420
+ 'hyphens, dots, and underscores.');
421
+ }
422
+ const data = node.data;
423
+ const isForeignNamespace = data?.ns !== undefined;
424
+ const attributes = new Map();
425
+ if (data !== undefined) {
426
+ collectDataAttributes(attributes, data);
427
+ const properties = data.props;
428
+ if (properties !== undefined) {
429
+ collectPropertyAttributes(attributes, tagName, properties);
430
+ }
431
+ const style = data.style;
432
+ if (style !== undefined) {
433
+ collectStyleAttribute(attributes, style);
434
+ }
435
+ }
436
+ if (!isForeignNamespace &&
437
+ tagName === 'option' &&
438
+ selectValue !== undefined &&
439
+ !selectValue.consumed &&
440
+ optionValue(node) === selectValue.value) {
441
+ setAttribute(attributes, 'selected', '');
442
+ selectValue.consumed = true;
443
+ }
444
+ if (extraAttributes !== undefined) {
445
+ for (const name of Object.keys(extraAttributes)) {
446
+ const value = extraAttributes[name];
447
+ if (value !== undefined) {
448
+ setAttribute(attributes, name, value);
449
+ }
450
+ }
451
+ }
452
+ output.push(`<${tagName}`);
453
+ serializeAttributes(output, attributes);
454
+ output.push('>');
455
+ // NOTE: void elements have no closing tag only in the HTML namespace. In
456
+ // SVG or MathML foreign content the same names (`input`, `br`) are ordinary
457
+ // elements, so omitting the closing tag would let a following sibling parse
458
+ // as a child. Void handling, raw-text handling, and the form-control text
459
+ // rules below are all gated on the element being in the HTML namespace;
460
+ // foreign content always closes and serializes its children through
461
+ // escaping.
462
+ if (!isForeignNamespace && VOID_ELEMENTS.has(tagName)) {
463
+ return;
464
+ }
465
+ const childSelectValue = isForeignNamespace
466
+ ? undefined
467
+ : selectValueForChildren(tagName, node, selectValue);
468
+ const isHtmlRawText = !isForeignNamespace && RAW_TEXT_ELEMENTS.has(tagName);
469
+ const innerHtml = data?.props?.['innerHTML'];
470
+ if (typeof innerHtml === 'string') {
471
+ if (isHtmlRawText) {
472
+ assertRawTextIsSafe(tagName, innerHtml);
473
+ }
474
+ output.push(innerHtml);
475
+ }
476
+ else if (!isForeignNamespace && tagName === 'textarea') {
477
+ const content = controlledValueContent(data?.props);
478
+ if (content !== undefined) {
479
+ if (content.startsWith('\n')) {
480
+ output.push('\n');
481
+ }
482
+ output.push(escapeText(content));
483
+ }
484
+ else {
485
+ if (leadingTextOf(node)?.startsWith('\n')) {
486
+ output.push('\n');
487
+ }
488
+ serializeChildren(output, node, depth, childSelectValue);
489
+ }
490
+ }
491
+ else if (!isForeignNamespace && tagName === 'output') {
492
+ // A controlled <output> reflects its value prop as text content the same
493
+ // way a textarea does, but as ordinary PCDATA with no leading-newline
494
+ // handling. An uncontrolled output serializes its children.
495
+ const content = controlledValueContent(data?.props);
496
+ if (content !== undefined) {
497
+ output.push(escapeText(content));
498
+ }
499
+ else {
500
+ serializeChildren(output, node, depth, childSelectValue);
501
+ }
502
+ }
503
+ else if (!isForeignNamespace && NEWLINE_DROPPING_ELEMENTS.has(tagName)) {
504
+ if (leadingTextOf(node)?.startsWith('\n')) {
505
+ output.push('\n');
506
+ }
507
+ serializeChildren(output, node, depth, childSelectValue);
508
+ }
509
+ else if (isHtmlRawText) {
510
+ const rawText = collectRawText(node);
511
+ assertRawTextIsSafe(tagName, rawText);
512
+ output.push(rawText);
513
+ }
514
+ else {
515
+ serializeChildren(output, node, depth, childSelectValue);
516
+ }
517
+ output.push(`</${tagName}>`);
518
+ };
519
+ const serializeNode = (output, node, depth, extraAttributes, selectValue) => {
520
+ if (depth > MAX_RENDER_DEPTH) {
521
+ throw new Error(`[foldkit] renderToString exceeded the maximum render depth of ${MAX_RENDER_DEPTH}. ` +
522
+ 'A view nesting elements this deeply, often from mapping untrusted ' +
523
+ 'hierarchical data straight to markup, is refused to protect the ' +
524
+ 'render stack.');
525
+ }
526
+ if (node === null) {
527
+ return;
528
+ }
529
+ if (typeof node === 'string') {
530
+ output.push(escapeText(node));
531
+ return;
532
+ }
533
+ const selector = node.sel;
534
+ if (selector === undefined || selector === '') {
535
+ if (node.text !== undefined) {
536
+ output.push(escapeText(node.text));
537
+ }
538
+ return;
539
+ }
540
+ if (selector === '!') {
541
+ const commentText = node.text ?? '';
542
+ assertCommentTextIsSafe(commentText);
543
+ output.push(`<!--${commentText}-->`);
544
+ return;
545
+ }
546
+ serializeElement(output, node, selector, depth, extraAttributes, selectValue);
547
+ };
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.
553
+ *
554
+ * @internal Not part of the `foldkit/experimental/server` surface; `renderToString` is the public entry to serialization.
555
+ */
556
+ export const serializeHtml = (root, options) => {
557
+ if (root === null) {
558
+ return '<!---->';
559
+ }
560
+ const output = [];
561
+ serializeNode(output, root, 0, options?.rootAttributes);
562
+ return output.join('');
563
+ };