foldkit 0.147.0 → 0.148.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/buildToken.d.ts +3 -0
- package/dist/buildToken.d.ts.map +1 -0
- package/dist/buildToken.js +21 -0
- package/dist/controlledDomState.d.ts +25 -0
- package/dist/controlledDomState.d.ts.map +1 -0
- package/dist/controlledDomState.js +240 -0
- package/dist/cssStyleProperties.d.ts +6 -0
- package/dist/cssStyleProperties.d.ts.map +1 -0
- package/dist/cssStyleProperties.js +91 -0
- package/dist/domReflection.d.ts +73 -0
- package/dist/domReflection.d.ts.map +1 -0
- package/dist/domReflection.js +557 -0
- package/dist/experimental/server/host.d.ts +102 -8
- package/dist/experimental/server/host.d.ts.map +1 -1
- package/dist/experimental/server/host.js +203 -13
- package/dist/experimental/server/public.d.ts +2 -2
- package/dist/experimental/server/public.d.ts.map +1 -1
- package/dist/experimental/server/public.js +1 -1
- package/dist/experimental/server/serialize.d.ts +15 -5
- package/dist/experimental/server/serialize.d.ts.map +1 -1
- package/dist/experimental/server/serialize.js +367 -144
- package/dist/experimental/server/server.d.ts +55 -14
- package/dist/experimental/server/server.d.ts.map +1 -1
- package/dist/experimental/server/server.js +544 -21
- package/dist/experimental/server/template.d.ts +8 -0
- package/dist/experimental/server/template.d.ts.map +1 -1
- package/dist/experimental/server/template.js +437 -2
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/index.js +436 -29
- package/dist/hydrate.d.ts +1 -1
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +449 -121
- package/dist/hydrationMarkers.d.ts +15 -0
- package/dist/hydrationMarkers.d.ts.map +1 -0
- package/dist/hydrationMarkers.js +70 -0
- package/dist/nativeInnerHtml.d.ts +13 -0
- package/dist/nativeInnerHtml.d.ts.map +1 -0
- package/dist/nativeInnerHtml.js +30 -0
- package/dist/propertyProvenance.d.ts +33 -0
- package/dist/propertyProvenance.d.ts.map +1 -0
- package/dist/propertyProvenance.js +78 -0
- package/dist/propsModule.d.ts.map +1 -1
- package/dist/propsModule.js +149 -15
- package/dist/runtime/public.d.ts +1 -1
- package/dist/runtime/public.d.ts.map +1 -1
- package/dist/runtime/runtime.d.ts +30 -6
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +230 -27
- package/dist/snabbdom/attributes.d.ts.map +1 -1
- package/dist/snabbdom/attributes.js +65 -37
- package/dist/snabbdom/style.d.ts.map +1 -1
- package/dist/snabbdom/style.js +53 -34
- package/dist/test/apps/attributes.d.ts +1 -0
- package/dist/test/apps/attributes.d.ts.map +1 -1
- package/dist/test/apps/attributes.js +8 -1
- package/dist/test/apps/login.js +1 -1
- package/dist/test/matchers.d.ts.map +1 -1
- package/dist/test/matchers.js +2 -1
- package/dist/test/scene.d.ts.map +1 -1
- package/dist/test/scene.js +2 -1
- 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,
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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
|
'&': '&',
|
|
178
131
|
'"': '"',
|
|
179
132
|
'<': '<',
|
|
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': ' ',
|
|
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 ` `
|
|
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) =>
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
|
262
|
-
// default
|
|
263
|
-
//
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
320
|
+
setAttribute(attributes, name, serializedHtmlPropertyValue(tagName, name, value));
|
|
284
321
|
}
|
|
285
322
|
}
|
|
286
323
|
};
|
|
287
324
|
const collectStyleAttribute = (attributes, style) => {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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(
|
|
403
|
-
.
|
|
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 {
|
|
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
|
|
417
|
-
if (!TAG_NAME_PATTERN.test(
|
|
418
|
-
throw new Error(`[foldkit] Cannot serialize the invalid tag name "${
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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,27 @@ 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 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
|
+
}
|
|
452
634
|
output.push(`<${tagName}`);
|
|
453
635
|
serializeAttributes(output, attributes);
|
|
454
636
|
output.push('>');
|
|
@@ -466,11 +648,30 @@ const serializeElement = (output, node, selector, depth, extraAttributes, select
|
|
|
466
648
|
? undefined
|
|
467
649
|
: selectValueForChildren(tagName, node, selectValue);
|
|
468
650
|
const isHtmlRawText = !isForeignNamespace && RAW_TEXT_ELEMENTS.has(tagName);
|
|
651
|
+
const isHtmlRcdata = !isForeignNamespace && RCDATA_ELEMENTS.has(tagName);
|
|
469
652
|
const innerHtml = data?.props?.['innerHTML'];
|
|
470
|
-
if (typeof innerHtml === 'string') {
|
|
471
|
-
|
|
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) {
|
|
472
659
|
assertRawTextIsSafe(tagName, innerHtml);
|
|
473
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
|
+
// (` `, `
`, `
`) 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
|
+
}
|
|
474
675
|
output.push(innerHtml);
|
|
475
676
|
}
|
|
476
677
|
else if (!isForeignNamespace && tagName === 'textarea') {
|
|
@@ -485,7 +686,7 @@ const serializeElement = (output, node, selector, depth, extraAttributes, select
|
|
|
485
686
|
if (leadingTextOf(node)?.startsWith('\n')) {
|
|
486
687
|
output.push('\n');
|
|
487
688
|
}
|
|
488
|
-
serializeChildren(output, node, depth, childSelectValue);
|
|
689
|
+
serializeChildren(output, context, node, depth, childSelectValue);
|
|
489
690
|
}
|
|
490
691
|
}
|
|
491
692
|
else if (!isForeignNamespace && tagName === 'output') {
|
|
@@ -497,26 +698,40 @@ const serializeElement = (output, node, selector, depth, extraAttributes, select
|
|
|
497
698
|
output.push(escapeText(content));
|
|
498
699
|
}
|
|
499
700
|
else {
|
|
500
|
-
serializeChildren(output, node, depth, childSelectValue);
|
|
701
|
+
serializeChildren(output, context, node, depth, childSelectValue);
|
|
501
702
|
}
|
|
502
703
|
}
|
|
503
704
|
else if (!isForeignNamespace && NEWLINE_DROPPING_ELEMENTS.has(tagName)) {
|
|
504
705
|
if (leadingTextOf(node)?.startsWith('\n')) {
|
|
505
706
|
output.push('\n');
|
|
506
707
|
}
|
|
507
|
-
serializeChildren(output, node, depth, childSelectValue);
|
|
708
|
+
serializeChildren(output, context, node, depth, childSelectValue);
|
|
508
709
|
}
|
|
509
710
|
else if (isHtmlRawText) {
|
|
510
711
|
const rawText = collectRawText(node);
|
|
511
712
|
assertRawTextIsSafe(tagName, rawText);
|
|
713
|
+
if (tagName === 'noscript') {
|
|
714
|
+
assertNoscriptTextIsSafe(rawText);
|
|
715
|
+
}
|
|
512
716
|
output.push(rawText);
|
|
513
717
|
}
|
|
514
718
|
else {
|
|
515
|
-
serializeChildren(output, node, depth, childSelectValue);
|
|
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`.');
|
|
516
731
|
}
|
|
517
732
|
output.push(`</${tagName}>`);
|
|
518
733
|
};
|
|
519
|
-
const serializeNode = (output, node, depth, extraAttributes, selectValue) => {
|
|
734
|
+
const serializeNode = (output, context, node, depth, extraAttributes, selectValue) => {
|
|
520
735
|
if (depth > MAX_RENDER_DEPTH) {
|
|
521
736
|
throw new Error(`[foldkit] renderToString exceeded the maximum render depth of ${MAX_RENDER_DEPTH}. ` +
|
|
522
737
|
'A view nesting elements this deeply, often from mapping untrusted ' +
|
|
@@ -543,13 +758,18 @@ const serializeNode = (output, node, depth, extraAttributes, selectValue) => {
|
|
|
543
758
|
output.push(`<!--${commentText}-->`);
|
|
544
759
|
return;
|
|
545
760
|
}
|
|
546
|
-
serializeElement(output, node, selector, depth, extraAttributes, selectValue);
|
|
761
|
+
serializeElement(output, context, node, selector, depth, extraAttributes, selectValue);
|
|
547
762
|
};
|
|
548
|
-
/** Serializes a view-produced vnode tree to an HTML string. Event handlers
|
|
549
|
-
* hooks
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
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.
|
|
553
773
|
*
|
|
554
774
|
* @internal Not part of the `foldkit/experimental/server` surface; `renderToString` is the public entry to serialization.
|
|
555
775
|
*/
|
|
@@ -557,7 +777,10 @@ export const serializeHtml = (root, options) => {
|
|
|
557
777
|
if (root === null) {
|
|
558
778
|
return '<!---->';
|
|
559
779
|
}
|
|
780
|
+
const context = {
|
|
781
|
+
emitHydrationMarkers: options?.emitHydrationMarkers ?? false,
|
|
782
|
+
};
|
|
560
783
|
const output = [];
|
|
561
|
-
serializeNode(output, root, 0, options?.rootAttributes);
|
|
784
|
+
serializeNode(output, context, root, 0, options?.rootAttributes);
|
|
562
785
|
return output.join('');
|
|
563
786
|
};
|