@webjsdev/core 0.7.50 → 0.7.53

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 (64) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +20 -20
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +7 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +1 -1
  8. package/index.d.ts +3 -3
  9. package/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/component/lifecycle.js +1481 -0
  12. package/src/component/reactive.js +171 -0
  13. package/src/component/server-element.js +248 -0
  14. package/src/component.js +2 -1896
  15. package/src/css.d.ts +9 -0
  16. package/src/escape.d.ts +2 -0
  17. package/src/html.d.ts +9 -0
  18. package/src/render-client/parts.js +2039 -0
  19. package/src/render-client/reconciler.js +388 -0
  20. package/src/render-client/template-compiler.js +525 -0
  21. package/src/render-client.js +1 -2938
  22. package/src/render-server/attrs.js +160 -0
  23. package/src/render-server/dsd.js +445 -0
  24. package/src/render-server/html-scan.js +402 -0
  25. package/src/render-server/slots.js +213 -0
  26. package/src/render-server/stream.js +163 -0
  27. package/src/render-server/template-renderer.js +938 -0
  28. package/src/render-server/text.js +163 -0
  29. package/src/render-server.js +6 -2405
  30. package/src/repeat.d.ts +15 -0
  31. package/src/rich-fetch.d.ts +4 -0
  32. package/src/router-client/anchors.js +44 -0
  33. package/src/router-client/boundaries.js +221 -0
  34. package/src/router-client/constants.js +206 -0
  35. package/src/router-client/diagnostics.js +377 -0
  36. package/src/router-client/dom-differ.js +709 -0
  37. package/src/router-client/dom-parse.js +150 -0
  38. package/src/router-client/events.js +182 -0
  39. package/src/router-client/fetch-apply.js +432 -0
  40. package/src/router-client/form-encoder.js +135 -0
  41. package/src/router-client/frames.js +329 -0
  42. package/src/router-client/head-merge.js +344 -0
  43. package/src/router-client/nav-error.js +105 -0
  44. package/src/router-client/navigator.js +952 -0
  45. package/src/router-client/prefetch.js +831 -0
  46. package/src/router-client/scroll.js +308 -0
  47. package/src/router-client/snapshot-cache.js +113 -0
  48. package/src/router-client/state.js +155 -0
  49. package/src/router-client/stream.js +206 -0
  50. package/src/router-client/swap.js +454 -0
  51. package/src/router-client/upgrade.js +261 -0
  52. package/src/router-client/view-transition.js +223 -0
  53. package/src/router-client.d.ts +11 -2
  54. package/src/router-client.js +78 -5345
  55. package/src/slot/assignment.js +304 -0
  56. package/src/slot/interception.js +558 -0
  57. package/src/slot/polyfills.js +365 -0
  58. package/src/slot/project.js +432 -0
  59. package/src/slot/sensors.js +303 -0
  60. package/src/slot/state.js +245 -0
  61. package/src/slot/symbols.js +85 -0
  62. package/src/slot.js +7 -2199
  63. package/src/suspense.d.ts +9 -0
  64. package/src/websocket-client.d.ts +22 -0
@@ -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
+ }