@webjsdev/core 0.7.50 → 0.7.51
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/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +20 -20
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -1
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/component/lifecycle.js +1481 -0
- package/src/component/reactive.js +171 -0
- package/src/component/server-element.js +248 -0
- package/src/component.js +2 -1896
- package/src/render-client/parts.js +2007 -0
- package/src/render-client/reconciler.js +388 -0
- package/src/render-client/template-compiler.js +525 -0
- package/src/render-client.js +1 -2938
- package/src/render-server/attrs.js +160 -0
- package/src/render-server/dsd.js +445 -0
- package/src/render-server/html-scan.js +402 -0
- package/src/render-server/slots.js +213 -0
- package/src/render-server/stream.js +163 -0
- package/src/render-server/template-renderer.js +923 -0
- package/src/render-server/text.js +163 -0
- package/src/render-server.js +6 -2405
- package/src/router-client/anchors.js +44 -0
- package/src/router-client/boundaries.js +221 -0
- package/src/router-client/constants.js +187 -0
- package/src/router-client/diagnostics.js +377 -0
- package/src/router-client/dom-differ.js +709 -0
- package/src/router-client/dom-parse.js +150 -0
- package/src/router-client/events.js +139 -0
- package/src/router-client/fetch-apply.js +387 -0
- package/src/router-client/form-encoder.js +135 -0
- package/src/router-client/frames.js +329 -0
- package/src/router-client/head-merge.js +344 -0
- package/src/router-client/nav-error.js +105 -0
- package/src/router-client/navigator.js +781 -0
- package/src/router-client/prefetch.js +824 -0
- package/src/router-client/scroll.js +259 -0
- package/src/router-client/snapshot-cache.js +97 -0
- package/src/router-client/state.js +92 -0
- package/src/router-client/stream.js +206 -0
- package/src/router-client/swap.js +451 -0
- package/src/router-client/upgrade.js +261 -0
- package/src/router-client/view-transition.js +223 -0
- package/src/router-client.d.ts +10 -1
- package/src/router-client.js +75 -5345
- package/src/slot/assignment.js +304 -0
- package/src/slot/interception.js +558 -0
- package/src/slot/polyfills.js +365 -0
- package/src/slot/project.js +432 -0
- package/src/slot/sensors.js +303 -0
- package/src/slot/state.js +245 -0
- package/src/slot/symbols.js +85 -0
- package/src/slot.js +7 -2199
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML string scanning primitives shared by the SSR passes.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is a pure function of its arguments: no component registry,
|
|
5
|
+
* no renderer, no DOM. That is what keeps this module a leaf, which is what
|
|
6
|
+
* lets `template-renderer.js` take `isRawtextTag` from here instead of from
|
|
7
|
+
* `dsd.js`, which is what breaks the render-server import cycle.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const VOID_ELEMENTS = new Set([
|
|
11
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
12
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
/** @param {string} tag @returns {boolean} */
|
|
16
|
+
export function isVoidElement(tag) {
|
|
17
|
+
return VOID_ELEMENTS.has(tag.toLowerCase());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Index just past the end of the comment starting at `start`, or -1 if it is
|
|
22
|
+
* unterminated. Shared so every scanner that has to decide where a comment
|
|
23
|
+
* stops agrees, including on the spec short forms.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} html
|
|
26
|
+
* @param {number} start index of the `<` of `<!--`
|
|
27
|
+
* @returns {number}
|
|
28
|
+
*/
|
|
29
|
+
export function endOfComment(html, start) {
|
|
30
|
+
let p = start + 4;
|
|
31
|
+
// `<!-->` and `<!--->` are comments whose data is empty (spec short forms).
|
|
32
|
+
if (html[p] === '>') return p + 1;
|
|
33
|
+
if (html.startsWith('->', p)) return p + 2;
|
|
34
|
+
while (p < html.length) {
|
|
35
|
+
// `--!>` is the spec's "abrupt closing" form and closes just like `-->`.
|
|
36
|
+
if (html.startsWith('-->', p)) return p + 3;
|
|
37
|
+
if (html.startsWith('--!>', p)) return p + 4;
|
|
38
|
+
p += 1;
|
|
39
|
+
}
|
|
40
|
+
return -1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Index of the `</script` that really closes a `<script>` whose content starts
|
|
45
|
+
* at `from`, or -1 when unterminated (#1134).
|
|
46
|
+
*
|
|
47
|
+
* Script data is not plain raw text: once the content contains `<!--` followed
|
|
48
|
+
* by `<script`, the tokenizer is in the script-data-double-escaped state, where
|
|
49
|
+
* a `</script>` is TEXT (it only steps back to the escaped state) and the
|
|
50
|
+
* element ends at the NEXT `</script>`. The legacy comment-wrapped inline
|
|
51
|
+
* script that document.writes a script tag is the pattern that produces this.
|
|
52
|
+
* Stopping at the first `</script>` there re-opened the original #1128 bug in
|
|
53
|
+
* the one element the scanner most explicitly claims to handle.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} html
|
|
56
|
+
* @param {number} from index just past the opening tag's `>`
|
|
57
|
+
* @returns {number}
|
|
58
|
+
*/
|
|
59
|
+
export function endOfScriptContent(html, from) {
|
|
60
|
+
const re = /<!--|-->|<\/script(?=[\s/>])|<script(?=[\s/>])/gi;
|
|
61
|
+
re.lastIndex = from;
|
|
62
|
+
let escaped = false;
|
|
63
|
+
let dbl = false;
|
|
64
|
+
let m;
|
|
65
|
+
while ((m = re.exec(html)) !== null) {
|
|
66
|
+
const t = m[0];
|
|
67
|
+
if (t === '<!--') {
|
|
68
|
+
// The token's own trailing `--` puts the tokenizer in a dash-dash state
|
|
69
|
+
// REGARDLESS of what state it was in (`<!` are inert bytes in escaped and
|
|
70
|
+
// double-escaped states too), and every dash-dash state exits straight
|
|
71
|
+
// back to plain script data on `>`. So `<!-->`, `<!--->`, and any dash
|
|
72
|
+
// run followed by `>` clear BOTH flags: entering fresh it cancels the
|
|
73
|
+
// escape before it starts, and inside an escaped or double-escaped body
|
|
74
|
+
// it is the exit a browser honours, after which the element ends at the
|
|
75
|
+
// next `</script>`.
|
|
76
|
+
let q = m.index + 4;
|
|
77
|
+
while (html[q] === '-') q += 1;
|
|
78
|
+
if (html[q] === '>') { escaped = false; dbl = false; re.lastIndex = q + 1; }
|
|
79
|
+
else if (!escaped) escaped = true;
|
|
80
|
+
}
|
|
81
|
+
else if (t === '-->') { escaped = false; dbl = false; }
|
|
82
|
+
else if (t[1] === '/') {
|
|
83
|
+
if (dbl) dbl = false;
|
|
84
|
+
else return m.index;
|
|
85
|
+
} else if (escaped) dbl = true;
|
|
86
|
+
}
|
|
87
|
+
return -1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Byte ranges of `html` where a tag-shaped match is NOT an element (#1128).
|
|
92
|
+
*
|
|
93
|
+
* The element scanners below match tags with a flat regex over already-
|
|
94
|
+
* assembled markup, which has no notion of an HTML context. So a registered tag
|
|
95
|
+
* name written inside a comment used to be constructed and rendered as a real
|
|
96
|
+
* element, and the replacement consumed the rest of the comment INCLUDING its
|
|
97
|
+
* closing `-->`, leaving an unterminated comment that swallowed every following
|
|
98
|
+
* byte. Whether it happened depended on whether the name in the comment was a
|
|
99
|
+
* registered component, which is what made it look random.
|
|
100
|
+
*
|
|
101
|
+
* This is a single left-to-right pass rather than a search for `<!--`, because
|
|
102
|
+
* the naive version introduces failures worse than the bug: an `<!--` inside an
|
|
103
|
+
* attribute value (`title="use <!-- here"`) or inside RCDATA would open a region
|
|
104
|
+
* that never closes, and every component after it would silently stop rendering.
|
|
105
|
+
* Deciding that requires knowing the context, which means tokenizing, so the
|
|
106
|
+
* pass tracks the same states the HTML parser does for these purposes:
|
|
107
|
+
*
|
|
108
|
+
* - **Comments**, including the spec's short forms. `<!-->` and `<!--->` close
|
|
109
|
+
* immediately, `--!>` closes as well as `-->`, and an unterminated comment
|
|
110
|
+
* runs to EOF, exactly as a browser would treat the same bytes.
|
|
111
|
+
* - **Markup declarations and bogus comments** (`<!doctype …>`, `<![CDATA[…]]>`),
|
|
112
|
+
* which end at the next `>`.
|
|
113
|
+
* - **Tags**, consumed with their quoted attribute values, so `<` and `<!--`
|
|
114
|
+
* inside an attribute are inert rather than context-changing.
|
|
115
|
+
* - **Text-only elements**, whose content the HTML tokenizer never reads as
|
|
116
|
+
* markup: raw text (`script`, `style`, `iframe`, `xmp`, `noembed`,
|
|
117
|
+
* `noframes`, `plaintext`) and RCDATA (`textarea`, `title`). Their content is
|
|
118
|
+
* returned as a skip range too, because a component tag inside a `<style>`
|
|
119
|
+
* comment or an `<iframe>` fallback hit the identical markup-destroying path,
|
|
120
|
+
* so excluding them would leave half the bug live.
|
|
121
|
+
*
|
|
122
|
+
* Two deliberate exclusions. `<template>` content IS parsed and legitimately
|
|
123
|
+
* carries components (Declarative Shadow DOM and the streamed swap templates
|
|
124
|
+
* both depend on that). `<noscript>` content is parsed as markup when
|
|
125
|
+
* scripting is disabled, which for a progressive-enhancement framework is the
|
|
126
|
+
* case that matters, so components inside it must keep rendering.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} html
|
|
129
|
+
* @returns {[number, number][]} ascending, non-overlapping `[start, end)` pairs
|
|
130
|
+
*/
|
|
131
|
+
export function inertRanges(html) {
|
|
132
|
+
/** @type {[number, number][]} */
|
|
133
|
+
const ranges = [];
|
|
134
|
+
const n = html.length;
|
|
135
|
+
let i = 0;
|
|
136
|
+
while (i < n) {
|
|
137
|
+
const lt = html.indexOf('<', i);
|
|
138
|
+
if (lt === -1) break;
|
|
139
|
+
if (html.startsWith('<!--', lt)) {
|
|
140
|
+
const end = endOfComment(html, lt);
|
|
141
|
+
const stop = end === -1 ? n : end;
|
|
142
|
+
ranges.push([lt, stop]);
|
|
143
|
+
i = stop;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (html.startsWith('<!', lt) || html.startsWith('<?', lt)) {
|
|
147
|
+
// Doctype / bogus comment / processing instruction: ends at the next `>`.
|
|
148
|
+
const close = html.indexOf('>', lt);
|
|
149
|
+
const end = close === -1 ? n : close + 1;
|
|
150
|
+
ranges.push([lt, end]);
|
|
151
|
+
i = end;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const name = /^<\/?([a-zA-Z][^\s/>]*)/.exec(html.slice(lt, lt + 64));
|
|
155
|
+
if (!name) {
|
|
156
|
+
// `</` followed by anything that is not an ASCII letter is the third
|
|
157
|
+
// bogus-comment form (`</1`, `</<`, `</ `), which the spec also runs to
|
|
158
|
+
// the next `>`. Without this branch the bytes after it are scanned as
|
|
159
|
+
// markup and a tag inside gets instantiated, which is the original bug.
|
|
160
|
+
if (html.startsWith('</', lt)) {
|
|
161
|
+
const close = html.indexOf('>', lt);
|
|
162
|
+
const end = close === -1 ? n : close + 1;
|
|
163
|
+
ranges.push([lt, end]);
|
|
164
|
+
i = end;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
i = lt + 1;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
// Consume the tag, honouring quoted attribute values so a `<` or `<!--`
|
|
171
|
+
// inside one cannot be mistaken for markup.
|
|
172
|
+
//
|
|
173
|
+
// A quote only OPENS a value when it directly follows `=`. That condition
|
|
174
|
+
// is load-bearing rather than pedantic: `escapeAttr` does not escape `'`,
|
|
175
|
+
// so an interpolated apostrophe in a single-quoted attribute emits three
|
|
176
|
+
// unbalanced quotes (`title='don't'`). Treating every quote as a delimiter
|
|
177
|
+
// left the scanner stuck inside a value to EOF, which returned a truncated
|
|
178
|
+
// range list and silently re-enabled this whole bug for the rest of the
|
|
179
|
+
// page. A browser recovers at the `>`, and so does this: after the value
|
|
180
|
+
// closes, the stray `'` is just an attribute-name character.
|
|
181
|
+
let p = lt + 1;
|
|
182
|
+
let quote = '';
|
|
183
|
+
// `expectValue` is set by `=` and cleared by the first non-whitespace
|
|
184
|
+
// character after it. Only THAT character can open a quoted value, which is
|
|
185
|
+
// what the spec does: before-attribute-value reconsumes anything else in
|
|
186
|
+
// attribute-value-unquoted state. Keying off "the previous character was
|
|
187
|
+
// `=`" instead re-opens the hole on `<a title==">`, where the `"` is an
|
|
188
|
+
// ordinary value character; an odd quote count then ran the scan to EOF and
|
|
189
|
+
// returned one giant inert range, silently disabling this whole fix for the
|
|
190
|
+
// rest of the page.
|
|
191
|
+
let expectValue = false;
|
|
192
|
+
// Unquoted values need their own state for two reasons the spec spells out
|
|
193
|
+
// and a simpler scan gets wrong: `>` ends the tag from here (so `attr=>` is
|
|
194
|
+
// a missing value, not a value of `>`), and `/` is an ordinary value
|
|
195
|
+
// character, so an unquoted URL ending in `/` is NOT a self-closing solidus.
|
|
196
|
+
let inUnquoted = false;
|
|
197
|
+
let selfClosing = false;
|
|
198
|
+
while (p < n) {
|
|
199
|
+
const c = html[p];
|
|
200
|
+
const isSpace = c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f';
|
|
201
|
+
if (quote) {
|
|
202
|
+
if (c === quote) quote = '';
|
|
203
|
+
} else if (c === '>') {
|
|
204
|
+
// Checked before the value branches: a `>` arriving where a value was
|
|
205
|
+
// expected terminates the tag (`<a href=>`). Consuming it as a value
|
|
206
|
+
// character ran the scan on to the NEXT `>`, which swallowed the real
|
|
207
|
+
// tag end and re-armed the original bug for what followed.
|
|
208
|
+
selfClosing = !inUnquoted && html[p - 1] === '/';
|
|
209
|
+
p += 1;
|
|
210
|
+
break;
|
|
211
|
+
} else if (expectValue) {
|
|
212
|
+
// The first non-whitespace character after `=` decides the value form.
|
|
213
|
+
if (!isSpace) {
|
|
214
|
+
if (c === '"' || c === "'") quote = c;
|
|
215
|
+
else inUnquoted = true;
|
|
216
|
+
expectValue = false;
|
|
217
|
+
}
|
|
218
|
+
} else if (inUnquoted) {
|
|
219
|
+
if (isSpace) inUnquoted = false;
|
|
220
|
+
} else if (c === '=') {
|
|
221
|
+
expectValue = true;
|
|
222
|
+
}
|
|
223
|
+
p += 1;
|
|
224
|
+
}
|
|
225
|
+
// The tag's interior is not markup either. A component tag written inside
|
|
226
|
+
// an attribute value (`title="renders a <my-card> element"`) was otherwise
|
|
227
|
+
// instantiated in place, destroying the rest of the document exactly like
|
|
228
|
+
// the comment case. Start at lt+1 so the tag's OWN opening `<` still
|
|
229
|
+
// matches; only what is nested inside it is inert.
|
|
230
|
+
if (p > lt + 1) ranges.push([lt + 1, p]);
|
|
231
|
+
i = p;
|
|
232
|
+
const tag = name[1].toLowerCase();
|
|
233
|
+
const isClose = html[lt + 1] === '/';
|
|
234
|
+
// A self-closing start tag has no content to skip. In HTML the `/` is
|
|
235
|
+
// ignored, but in SVG and MathML foreign content it genuinely closes the
|
|
236
|
+
// element, and `<svg><title/></svg>` otherwise finds no `</title`, runs the
|
|
237
|
+
// range to EOF, and makes every component in the rest of the document
|
|
238
|
+
// inert. Honouring `/>` costs only the malformed-HTML case (`<style/>`,
|
|
239
|
+
// already broken authoring) and fails in the direction where components
|
|
240
|
+
// keep rendering rather than silently vanishing.
|
|
241
|
+
if (!isClose && !selfClosing && isTextOnlyTag(tag)) {
|
|
242
|
+
// Everything up to the matching close tag is text, not markup.
|
|
243
|
+
let contentEnd;
|
|
244
|
+
if (tag === 'plaintext') {
|
|
245
|
+
// `<plaintext>` has no end tag at all: the rest of the document is text.
|
|
246
|
+
contentEnd = n;
|
|
247
|
+
} else if (tag === 'script') {
|
|
248
|
+
// Script data has the double-escaped state (#1134), so its real end is
|
|
249
|
+
// not necessarily the first `</script`.
|
|
250
|
+
const end = endOfScriptContent(html, p);
|
|
251
|
+
contentEnd = end === -1 ? n : end;
|
|
252
|
+
} else {
|
|
253
|
+
const close = new RegExp(`</${tag}(?=[\\s/>])`, 'i').exec(html.slice(p));
|
|
254
|
+
contentEnd = close ? p + close.index : n;
|
|
255
|
+
}
|
|
256
|
+
if (contentEnd > p) ranges.push([p, contentEnd]);
|
|
257
|
+
i = contentEnd;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return ranges;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Random-access membership test over ascending, non-overlapping ranges, for
|
|
265
|
+
* callers whose queries are NOT monotonic (findClosingTagInString resets its
|
|
266
|
+
* regex cursors backward while pairing opens with closes). O(ranges) per call;
|
|
267
|
+
* monotonic callers use `inertAt` below instead.
|
|
268
|
+
*
|
|
269
|
+
* @param {[number, number][]} ranges
|
|
270
|
+
* @param {number} index
|
|
271
|
+
* @returns {boolean}
|
|
272
|
+
*/
|
|
273
|
+
export function inRanges(ranges, index) {
|
|
274
|
+
for (const [start, end] of ranges) {
|
|
275
|
+
if (start > index) return false;
|
|
276
|
+
if (index < end) return true;
|
|
277
|
+
}
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* A left-to-right membership test over ascending, non-overlapping ranges.
|
|
283
|
+
*
|
|
284
|
+
* Returns a function that answers "is this index inert?" and REMEMBERS how far
|
|
285
|
+
* it has walked, so a caller scanning matches in increasing order pays O(ranges)
|
|
286
|
+
* across the whole scan instead of O(ranges) per match. Restarting each time is
|
|
287
|
+
* an O(tags x components) term, which is measurable on a large page: holding a
|
|
288
|
+
* document at 40k tags and raising the component count adds hundreds of
|
|
289
|
+
* milliseconds that the cursor removes.
|
|
290
|
+
*
|
|
291
|
+
* The cursor only ever moves forward, so callers MUST query in non-decreasing
|
|
292
|
+
* index order. All three call sites do (`matchAll`, and the two loops that
|
|
293
|
+
* consume their input left to right). A caller that needs random access should
|
|
294
|
+
* scan `ranges` directly rather than reusing this.
|
|
295
|
+
*
|
|
296
|
+
* @param {[number, number][]} ranges
|
|
297
|
+
* @returns {(index: number) => boolean}
|
|
298
|
+
*/
|
|
299
|
+
export function inertAt(ranges) {
|
|
300
|
+
let cursor = 0;
|
|
301
|
+
return (index) => {
|
|
302
|
+
while (cursor < ranges.length && ranges[cursor][1] <= index) cursor += 1;
|
|
303
|
+
if (cursor >= ranges.length) return false;
|
|
304
|
+
return index >= ranges[cursor][0];
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Find the position of the matching closing tag for `tagName` starting from
|
|
310
|
+
* `fromIndex` in `html`. Handles nested same-tag elements via depth tracking.
|
|
311
|
+
* Returns the index of the `<` of `</tagName>`, or -1 if unclosed.
|
|
312
|
+
*
|
|
313
|
+
* @param {string} html
|
|
314
|
+
* @param {number} fromIndex
|
|
315
|
+
* @param {string} tagName
|
|
316
|
+
* @returns {number}
|
|
317
|
+
*/
|
|
318
|
+
export function findClosingTagInString(html, fromIndex, tagName, inert) {
|
|
319
|
+
const esc = escapeRegex(tagName);
|
|
320
|
+
// Match same-name opening tags. Followed by a name-boundary character
|
|
321
|
+
// so we don't accept <table> as opening <tab>.
|
|
322
|
+
const openRe = new RegExp(`<${esc}(?:[\\s>/])`, 'gi');
|
|
323
|
+
const closeRe = new RegExp(`</${esc}\\s*>`, 'gi');
|
|
324
|
+
// A tag inside a comment, raw text, RCDATA, or an attribute value is text
|
|
325
|
+
// and must count for NEITHER side of the depth ledger (#1133). Counting a
|
|
326
|
+
// commented `<my-card>` as a nested open meant depth never returned to zero,
|
|
327
|
+
// and matching a commented `</my-card>` as the close truncated the authored
|
|
328
|
+
// children at the comment, so the projected content ended with an
|
|
329
|
+
// unterminated `<!--` that a browser read as commenting out the real close
|
|
330
|
+
// tags. Callers that already computed the ranges for this exact string pass
|
|
331
|
+
// them; a caller that did not gets them computed here.
|
|
332
|
+
const ranges = inert === undefined ? inertRanges(html) : inert;
|
|
333
|
+
const next = (re) => {
|
|
334
|
+
let m;
|
|
335
|
+
while ((m = re.exec(html)) !== null) {
|
|
336
|
+
if (ranges.length === 0 || !inRanges(ranges, m.index)) return m;
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
};
|
|
340
|
+
openRe.lastIndex = fromIndex;
|
|
341
|
+
closeRe.lastIndex = fromIndex;
|
|
342
|
+
let depth = 1;
|
|
343
|
+
while (depth > 0) {
|
|
344
|
+
const o = next(openRe);
|
|
345
|
+
const c = next(closeRe);
|
|
346
|
+
if (!c) return -1;
|
|
347
|
+
if (o && o.index < c.index) {
|
|
348
|
+
depth++;
|
|
349
|
+
closeRe.lastIndex = o.index + 1;
|
|
350
|
+
} else {
|
|
351
|
+
depth--;
|
|
352
|
+
if (depth === 0) return c.index;
|
|
353
|
+
openRe.lastIndex = c.index + 1;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return -1;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** @param {string} s */
|
|
360
|
+
export function escapeRegex(s) {
|
|
361
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** @param {string} tag */
|
|
365
|
+
export function isRawtextTag(tag) {
|
|
366
|
+
return tag === 'script' || tag === 'style';
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* RCDATA elements: their content is text (character references aside), so a
|
|
371
|
+
* tag-shaped string inside one is not markup. Kept next to `isRawtextTag` so
|
|
372
|
+
* the two lists stay together rather than drifting apart.
|
|
373
|
+
* @param {string} tag
|
|
374
|
+
* @returns {boolean}
|
|
375
|
+
*/
|
|
376
|
+
export function isRcdataTag(tag) {
|
|
377
|
+
return tag === 'textarea' || tag === 'title';
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Elements whose content the HTML tokenizer never reads as markup, for the
|
|
382
|
+
* purposes of `inertRanges` only (#1128).
|
|
383
|
+
*
|
|
384
|
+
* Deliberately NOT `isRawtextTag`, even though it overlaps: that predicate is
|
|
385
|
+
* shared with the template tokenizer, where widening it would change how holes
|
|
386
|
+
* inside those elements are escaped. This one answers a narrower question,
|
|
387
|
+
* "can a tag-shaped string in here be a real element", and the answer is no for
|
|
388
|
+
* every raw-text and RCDATA element, not just the two the template path cares
|
|
389
|
+
* about. `<iframe>` with fallback markup is the realistic trigger.
|
|
390
|
+
*
|
|
391
|
+
* `<noscript>` is excluded on purpose: its content IS parsed as markup when
|
|
392
|
+
* scripting is disabled, which for a progressive-enhancement framework is the
|
|
393
|
+
* case that matters, so components inside it must keep rendering.
|
|
394
|
+
*
|
|
395
|
+
* @param {string} tag
|
|
396
|
+
* @returns {boolean}
|
|
397
|
+
*/
|
|
398
|
+
export function isTextOnlyTag(tag) {
|
|
399
|
+
return isRawtextTag(tag) || isRcdataTag(tag)
|
|
400
|
+
|| tag === 'iframe' || tag === 'xmp' || tag === 'noembed'
|
|
401
|
+
|| tag === 'noframes' || tag === 'plaintext';
|
|
402
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { escapeAttr } from '../escape.js';
|
|
2
|
+
import {
|
|
3
|
+
endOfComment, escapeRegex, findClosingTagInString, inertAt, inertRanges, isVoidElement,
|
|
4
|
+
} from './html-scan.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SSR light-DOM slot projection: partition a component's authored children by
|
|
8
|
+
* their `slot=""` attribute, then substitute each `<slot>` in the rendered
|
|
9
|
+
* template with the projected children or the authored fallback.
|
|
10
|
+
*
|
|
11
|
+
* String-level throughout, so it depends on `html-scan.js` and nothing else in
|
|
12
|
+
* this directory.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Extract the `slot` attribute value from an attribute string. Returns
|
|
17
|
+
* null when the attribute is absent.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} attrsRaw
|
|
20
|
+
* @returns {string | null}
|
|
21
|
+
*/
|
|
22
|
+
function extractSlotAttr(attrsRaw) {
|
|
23
|
+
const m = /\bslot\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
|
|
24
|
+
if (!m) return null;
|
|
25
|
+
const value = m[1] ?? m[2] ?? m[3] ?? '';
|
|
26
|
+
// Per shadow DOM spec, slot="" (empty) and missing slot attribute both
|
|
27
|
+
// route to the default slot. `default` is the framework's reserved alias
|
|
28
|
+
// for it (#1015: the client record normalizes it identically, so both
|
|
29
|
+
// sides agree end to end).
|
|
30
|
+
return value === '' || value === 'default' ? null : value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Partition authored inner HTML by each top-level child's `slot=""`
|
|
35
|
+
* attribute. Text nodes, comment nodes, and elements without `slot=""`
|
|
36
|
+
* all route to the default-slot key (null).
|
|
37
|
+
*
|
|
38
|
+
* Returns a Map keyed by slot name (null for default) whose values are
|
|
39
|
+
* the concatenated HTML strings for that slot in source order.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} html
|
|
42
|
+
* @returns {Map<string|null, string>}
|
|
43
|
+
*/
|
|
44
|
+
export function partitionAuthoredBySlot(html) {
|
|
45
|
+
/** @type {Map<string|null, string>} */
|
|
46
|
+
const groups = new Map();
|
|
47
|
+
let defaultBuf = '';
|
|
48
|
+
let cursor = 0;
|
|
49
|
+
while (cursor < html.length) {
|
|
50
|
+
const lt = html.indexOf('<', cursor);
|
|
51
|
+
if (lt === -1) {
|
|
52
|
+
defaultBuf += html.slice(cursor);
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
if (lt > cursor) defaultBuf += html.slice(cursor, lt);
|
|
56
|
+
const rest = html.slice(lt);
|
|
57
|
+
if (rest.startsWith('<!--')) {
|
|
58
|
+
// Find the comment's end the same way inertRanges does, rather than with
|
|
59
|
+
// a bare `indexOf('-->')`. The two helpers both decide where a comment
|
|
60
|
+
// stops, so a bare search makes them DISAGREE on the spec short forms
|
|
61
|
+
// (`--!>`, `<!-->`, `<!--->`): this one would run past the real end and
|
|
62
|
+
// swallow the slotted children that follow, silently routing a
|
|
63
|
+
// `slot="head"` child into the default slot.
|
|
64
|
+
const commentEnd = endOfComment(html, lt);
|
|
65
|
+
if (commentEnd === -1) {
|
|
66
|
+
defaultBuf += rest;
|
|
67
|
+
cursor = html.length;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
defaultBuf += html.slice(lt, commentEnd);
|
|
71
|
+
cursor = commentEnd;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (rest.startsWith('<!') || rest.startsWith('</')) {
|
|
75
|
+
const end = html.indexOf('>', lt);
|
|
76
|
+
if (end === -1) {
|
|
77
|
+
defaultBuf += rest;
|
|
78
|
+
cursor = html.length;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
defaultBuf += html.slice(lt, end + 1);
|
|
82
|
+
cursor = end + 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const tagMatch = /^<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/.exec(rest);
|
|
86
|
+
if (!tagMatch) {
|
|
87
|
+
defaultBuf += '<';
|
|
88
|
+
cursor = lt + 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const [tagFull, tagName, attrsRaw, selfCloseSlash] = tagMatch;
|
|
92
|
+
const lower = tagName.toLowerCase();
|
|
93
|
+
const isSelfClose = !!selfCloseSlash || isVoidElement(lower);
|
|
94
|
+
const slotAttr = extractSlotAttr(attrsRaw);
|
|
95
|
+
let elemEnd;
|
|
96
|
+
if (isSelfClose) {
|
|
97
|
+
elemEnd = lt + tagFull.length;
|
|
98
|
+
} else {
|
|
99
|
+
const innerStart = lt + tagFull.length;
|
|
100
|
+
const closeIdx = findClosingTagInString(html, innerStart, lower);
|
|
101
|
+
if (closeIdx === -1) {
|
|
102
|
+
// Unclosed element. Take to end of html.
|
|
103
|
+
const elementHTML = html.slice(lt);
|
|
104
|
+
if (slotAttr !== null) appendStringToMap(groups, slotAttr, elementHTML);
|
|
105
|
+
else defaultBuf += elementHTML;
|
|
106
|
+
cursor = html.length;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const closeRe = new RegExp(`</${escapeRegex(lower)}\\s*>`, 'i');
|
|
110
|
+
const tail = html.slice(closeIdx);
|
|
111
|
+
const closeMatch = closeRe.exec(tail);
|
|
112
|
+
const closeLen = closeMatch ? closeMatch[0].length : `</${lower}>`.length;
|
|
113
|
+
elemEnd = closeIdx + closeLen;
|
|
114
|
+
}
|
|
115
|
+
const elementHTML = html.slice(lt, elemEnd);
|
|
116
|
+
if (slotAttr !== null) appendStringToMap(groups, slotAttr, elementHTML);
|
|
117
|
+
else defaultBuf += elementHTML;
|
|
118
|
+
cursor = elemEnd;
|
|
119
|
+
}
|
|
120
|
+
if (defaultBuf.length > 0) groups.set(null, defaultBuf);
|
|
121
|
+
return groups;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Append a string to a Map<K, string>, concatenating if the key exists. */
|
|
125
|
+
function appendStringToMap(map, key, value) {
|
|
126
|
+
const existing = map.get(key);
|
|
127
|
+
if (existing !== undefined) map.set(key, existing + value);
|
|
128
|
+
else map.set(key, value);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Substitute every `<slot>` tag in `rendered` with a framework-marked
|
|
133
|
+
* `<slot data-webjs-light data-projection="actual|fallback">` element
|
|
134
|
+
* carrying either the projected children for that slot (from
|
|
135
|
+
* `partitioned`) or the slot's authored fallback content. Multiple
|
|
136
|
+
* slots with the same name follow the first-wins rule per spec; later
|
|
137
|
+
* same-named slots fall back regardless of available projection.
|
|
138
|
+
*
|
|
139
|
+
* The `ownerTag` (the tag of the component whose template rendered these
|
|
140
|
+
* slots) is emitted as `data-wj-slot-owner` so the client resolves template
|
|
141
|
+
* ownership on hydration the same way the client renderer stamps SLOT_OWNER,
|
|
142
|
+
* which is what makes a FORWARDED slot (rendered by this component but nested
|
|
143
|
+
* inside a child) route to this component and not the child (#1023).
|
|
144
|
+
*
|
|
145
|
+
* @param {string} rendered
|
|
146
|
+
* @param {Map<string|null, string>} partitioned
|
|
147
|
+
* @param {string} ownerTag
|
|
148
|
+
* @returns {string}
|
|
149
|
+
*/
|
|
150
|
+
export function substituteSlotsInRender(rendered, partitioned, ownerTag) {
|
|
151
|
+
const ownerAttr = ownerTag ? ` data-wj-slot-owner="${escapeAttr(ownerTag)}"` : '';
|
|
152
|
+
/** @type {Set<string|null>} */
|
|
153
|
+
const consumedNames = new Set();
|
|
154
|
+
let result = '';
|
|
155
|
+
let cursor = 0;
|
|
156
|
+
const slotRe = /<slot((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/gi;
|
|
157
|
+
// A `<slot>` written inside a comment is documentation, not a slot (#1128).
|
|
158
|
+
// Substituting one is worse here than in the element walk: a commented slot
|
|
159
|
+
// has no `</slot>`, so the fallback scan below swallows the rest of the
|
|
160
|
+
// template, the component's REAL slot is never substituted, and the authored
|
|
161
|
+
// children are dropped from the page entirely.
|
|
162
|
+
const inert = inertRanges(rendered);
|
|
163
|
+
const isInert = inertAt(inert);
|
|
164
|
+
let m;
|
|
165
|
+
while ((m = slotRe.exec(rendered)) !== null) {
|
|
166
|
+
if (isInert(m.index)) continue;
|
|
167
|
+
result += rendered.slice(cursor, m.index);
|
|
168
|
+
const [fullOpen, attrsRaw, selfCloseSlash] = m;
|
|
169
|
+
const isSelfClose = !!selfCloseSlash;
|
|
170
|
+
const nameMatch = /\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
|
|
171
|
+
const name = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : null;
|
|
172
|
+
// Strip the `name` attribute from the carried-through attribute
|
|
173
|
+
// string so we can re-add it (with escaping) on the framework slot.
|
|
174
|
+
const otherAttrs = attrsRaw.replace(/\bname\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i, '').trim();
|
|
175
|
+
let fallback = '';
|
|
176
|
+
let totalEnd;
|
|
177
|
+
if (isSelfClose) {
|
|
178
|
+
totalEnd = m.index + fullOpen.length;
|
|
179
|
+
} else {
|
|
180
|
+
const innerStart = m.index + fullOpen.length;
|
|
181
|
+
const closeIdx = findClosingTagInString(rendered, innerStart, 'slot', inert);
|
|
182
|
+
if (closeIdx === -1) {
|
|
183
|
+
fallback = rendered.slice(innerStart);
|
|
184
|
+
totalEnd = rendered.length;
|
|
185
|
+
} else {
|
|
186
|
+
fallback = rendered.slice(innerStart, closeIdx);
|
|
187
|
+
const closeRe = /<\/slot\s*>/i;
|
|
188
|
+
const tail = rendered.slice(closeIdx);
|
|
189
|
+
const closeMatch = closeRe.exec(tail);
|
|
190
|
+
const closeLen = closeMatch ? closeMatch[0].length : '</slot>'.length;
|
|
191
|
+
totalEnd = closeIdx + closeLen;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// `default` and `''` are the reserved aliases for the default slot
|
|
195
|
+
// (#1015), matching the client's keyOfName exactly: the LOOKUP key
|
|
196
|
+
// normalizes, while the emitted name attribute stays as authored so the
|
|
197
|
+
// output bytes are unchanged for every other app.
|
|
198
|
+
const slotKey = name === 'default' || name === '' ? null : name;
|
|
199
|
+
const projected = partitioned.get(slotKey);
|
|
200
|
+
const nameAttr = name !== null ? ` name="${escapeAttr(name)}"` : '';
|
|
201
|
+
const extraAttrs = otherAttrs ? ` ${otherAttrs}` : '';
|
|
202
|
+
if (projected !== undefined && !consumedNames.has(slotKey)) {
|
|
203
|
+
consumedNames.add(slotKey);
|
|
204
|
+
result += `<slot data-webjs-light data-projection="actual"${ownerAttr}${nameAttr}${extraAttrs}>${projected}</slot>`;
|
|
205
|
+
} else {
|
|
206
|
+
result += `<slot data-webjs-light data-projection="fallback"${ownerAttr}${nameAttr}${extraAttrs}>${fallback}</slot>`;
|
|
207
|
+
}
|
|
208
|
+
cursor = totalEnd;
|
|
209
|
+
slotRe.lastIndex = totalEnd;
|
|
210
|
+
}
|
|
211
|
+
result += rendered.slice(cursor);
|
|
212
|
+
return result;
|
|
213
|
+
}
|