@webjsdev/core 0.7.63 → 0.7.65

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.
@@ -0,0 +1,758 @@
1
+ import { isTemplate } from '../html.js';
2
+ import { attrHoleValue } from '../binding-prefixes.js';
3
+ import { escapeText, escapeAttr } from '../escape.js';
4
+ import {
5
+ assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp,
6
+ assertIdentifiableAction, bindFormActionStartTag, isBoundFormAction, resolveFormActionId,
7
+ assertConvergentBoundForm, assertSubmitterHasNoName, assertSubmitterHasNoValue,
8
+ assertSubmitterHasNoFormAttribute,
9
+ assertSingleSubmitterAction, bindSubmitterStartTag, parseStartTagAttrs,
10
+ isSubmitterReflectedProp, FORM_ACTION_FIELD,
11
+ } from '../form-action.js';
12
+ import { isRepeat } from '../repeat.js';
13
+ import { isSuspense } from '../suspense.js';
14
+ import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from '../directives.js';
15
+ import { stringify } from '../serialize.js';
16
+ import { lookup } from '../registry.js';
17
+ import { kebabCase, decodeAttrEntities } from './text.js';
18
+ import { isTextOnlyTag } from './html-scan.js';
19
+ import { compile, compileRaw, TEXT, OPEN, TAG_END, CLOSE, HOLE, CHILD, RAW, ATTR, VALUE, isVoidElement } from './compile.js';
20
+ import { beginComponent, settleRender, finishComponent, finishSlot, finishSuspense, extractSlotAttr } from './expand.js';
21
+
22
+ /**
23
+ * The SSR op walker (#1646): ONE interpreter over the compiled ops that
24
+ * replaces the two per-character state machines (`renderTemplate` and
25
+ * `streamTemplate`) and the output rescans (`inertRanges`, the `injectDSD`
26
+ * regex walk, `substituteSlotsInRender`). A page is built front to back into
27
+ * one buffer, values are rendered where their holes sit, and a registered
28
+ * custom element is expanded in place as its ops go by, so nothing is
29
+ * scanned twice and every component instance renders exactly once.
30
+ *
31
+ * Two sinks share the one interpreter. The buffered sink is `this.buf`. The
32
+ * streaming sink (`controller` set, used by `renderToStream(v, { ssr: false })`)
33
+ * flushes `buf` to the controller before every child hole and at the end of
34
+ * every template, and hands a primitive value over as its own chunk, which
35
+ * are exactly the chunk boundaries the old `streamTemplate` produced.
36
+ *
37
+ * `expand` is the one mode switch: off, no element is instantiated and a
38
+ * `<webjs-suspense>` keeps its tag and loses its `.fallback`, which is the
39
+ * `ssr: false` contract. On, component frames, light-DOM slot projection,
40
+ * Declarative Shadow DOM, and `<webjs-suspense>` boundaries are produced here.
41
+ *
42
+ * Frames. A frame is pushed at a start tag the walker takes over (a
43
+ * registered custom element, a streamed or blocking `<webjs-suspense>`, a
44
+ * `<slot>` inside a light-DOM component's own template) and holds the buffer
45
+ * that was being written (`outer`) while the element's children are captured
46
+ * into a fresh one. The matching end tag finishes the frame, which emits the
47
+ * element and appends it to `outer`. A light-DOM component frame also tracks
48
+ * element depth so its authored children are partitioned by top-level
49
+ * `slot=""` attribute STRUCTURALLY, from the segments the walk saw, rather
50
+ * than by rescanning the captured string.
51
+ *
52
+ * `inert` holds the text-only elements (`title`, `textarea`, `script`, ...)
53
+ * currently open: while one is, a start or end tag from a nested VALUE is
54
+ * text, as the compiler already guarantees for the static template around it.
55
+ * `defer` is set while a streamed boundary's children are captured, so they
56
+ * leave the main flow with their components unexpanded, which is what lets
57
+ * their data fetches run after the first flush (#471).
58
+ */
59
+
60
+ /** @typedef {import('./stream.js').SuspenseCtx & { usedComponents?: Set<string>, dev?: boolean }} Ctx */
61
+
62
+ export class Walker {
63
+ /**
64
+ * @param {Ctx | undefined} ctx
65
+ * @param {{ expand?: boolean, dev?: boolean, controller?: ReadableStreamDefaultController<string> | null, ancestors?: any[] }} [opts]
66
+ */
67
+ constructor(ctx, opts = {}) {
68
+ this.ctx = ctx;
69
+ this.expand = opts.expand !== false;
70
+ this.dev = opts.dev;
71
+ this.controller = opts.controller || null;
72
+ this.buf = '';
73
+ /** @type {any[]} */
74
+ this.frames = [];
75
+ /** @type {any[]} */
76
+ this.ancestors = opts.ancestors ? opts.ancestors.slice() : [];
77
+ /** @type {{ tag: string, partition: Map<string|null, string>, consumed: Set<string|null> } | null} */
78
+ this.owner = null;
79
+ /** @type {string[]} */
80
+ this.inert = [];
81
+ this.defer = 0;
82
+ /** @type {any} */
83
+ this.tagState = null;
84
+ }
85
+
86
+ // -------------------------------------------------------------------------
87
+ // Sinks
88
+ // -------------------------------------------------------------------------
89
+
90
+ /** Streaming only: hand the buffered static text to the controller. */
91
+ flush() {
92
+ if (this.controller && this.buf) {
93
+ this.controller.enqueue(this.buf);
94
+ this.buf = '';
95
+ }
96
+ }
97
+
98
+ /** A string a VALUE produced, which is its own chunk when streaming. */
99
+ emit(s) {
100
+ if (this.controller) {
101
+ this.flush();
102
+ this.controller.enqueue(s);
103
+ } else {
104
+ this.buf += s;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Run `fn` against a fresh buffer and frame stack and return what it wrote,
110
+ * restoring everything after, including on a throw (so a failed component
111
+ * template leaves no partial bytes behind). The per-start-tag state is
112
+ * saved too, because a `.fallback=${html\`...\`}` hole renders a template
113
+ * while its own start tag is still being read.
114
+ *
115
+ * The open text-only elements, the deferred-region depth and the ancestor
116
+ * chain are restored as well. A throw abandons the frames and text-only
117
+ * elements opened inside `fn` without finishing them, and the per-component
118
+ * error isolation (#469) catches that throw and keeps walking, so without
119
+ * the restore one failed component left every later element on the page
120
+ * unexpanded (a stale `inert` or `defer`) or resolving `closest()` against
121
+ * an instance it does not sit inside (a stale ancestor).
122
+ * @param {() => Promise<void>} fn
123
+ * @returns {Promise<string>}
124
+ */
125
+ async capture(fn) {
126
+ const buf = this.buf;
127
+ const frames = this.frames;
128
+ const tagState = this.tagState;
129
+ const controller = this.controller;
130
+ const inert = this.inert;
131
+ const defer = this.defer;
132
+ const ancestorDepth = this.ancestors.length;
133
+ this.buf = '';
134
+ this.frames = [];
135
+ this.tagState = null;
136
+ this.controller = null;
137
+ this.inert = inert.slice();
138
+ try {
139
+ // Called through the comma form so the no-free-identifiers guard, which
140
+ // does not read method parameters, does not mistake `fn` for a lost import.
141
+ await (0, fn)();
142
+ await this.finishOpenFrames();
143
+ return this.buf;
144
+ } finally {
145
+ this.buf = buf;
146
+ this.frames = frames;
147
+ this.tagState = tagState;
148
+ this.controller = controller;
149
+ this.inert = inert;
150
+ this.defer = defer;
151
+ this.ancestors.length = ancestorDepth;
152
+ }
153
+ }
154
+
155
+ // -------------------------------------------------------------------------
156
+ // Values
157
+ // -------------------------------------------------------------------------
158
+
159
+ /**
160
+ * Render any value at a child position. The ladder is the one `render()`
161
+ * and `streamRender()` each carried before; a nested template recurses into
162
+ * the op loop on the same sink.
163
+ * @param {unknown} value
164
+ */
165
+ async value(value) {
166
+ if (value == null || value === false || value === true) return;
167
+ if (typeof (/** @type any */ (value).then) === 'function') {
168
+ return this.value(await value);
169
+ }
170
+ if (typeof value === 'string') { this.emit(escapeText(value)); return; }
171
+ if (isTemplate(value)) return this.template(/** @type any */ (value));
172
+ if (Array.isArray(value)) {
173
+ for (const v of value) await this.value(v);
174
+ return;
175
+ }
176
+ // An unsafeHTML value is raw markup. With expansion on, the components it
177
+ // carries render too (the output rescan used to find them), through the
178
+ // same tokenizer the template went through, so a tag written inside one
179
+ // of its comments or attribute values stays text (#1128).
180
+ if (isUnsafeHTML(value)) {
181
+ const s = String(/** @type any */ (value).value ?? '');
182
+ if (this.expand) await this.ops(compileRaw(s), EMPTY);
183
+ else this.emit(s);
184
+ return;
185
+ }
186
+ // live() is transparent on the server, since the dirty-check it carries
187
+ // is a client-only concern (#1443).
188
+ if (isLive(value)) return this.value(/** @type any */ (value).value);
189
+ // watch() reads the signal once; subscription is a client-only concern.
190
+ if (isWatch(value)) return this.value(/** @type any */ (value).signal.get());
191
+ if (isKeyed(value)) return this.value(/** @type any */ (value).value);
192
+ if (isGuard(value)) return this.value(/** @type any */ (value).fn());
193
+ if (isTemplateContent(value)) {
194
+ const tpl = /** @type any */ (value).template;
195
+ const s = String(tpl?.innerHTML ?? '');
196
+ if (this.expand) await this.ops(compileRaw(s), EMPTY);
197
+ else this.emit(s);
198
+ return;
199
+ }
200
+ if (isRef(value)) return;
201
+ if (isCache(value)) return this.value(/** @type any */ (value).value);
202
+ // until() renders the first synchronous candidate, else the first promise
203
+ // to settle; a rejection counts as "no value" rather than crashing the render.
204
+ if (isUntil(value)) {
205
+ const args = /** @type any */ (value).args;
206
+ for (const a of args) {
207
+ if (!a || typeof (/** @type any */ (a).then) !== 'function') return this.value(a);
208
+ }
209
+ if (args.length > 0) {
210
+ try {
211
+ const winner = await Promise.race(args.map((p) => Promise.resolve(p).catch(() => undefined)));
212
+ return this.value(winner);
213
+ } catch {
214
+ return;
215
+ }
216
+ }
217
+ return;
218
+ }
219
+ // asyncAppend / asyncReplace render nothing at SSR; Suspense streams.
220
+ if (isAsyncAppend(value) || isAsyncReplace(value)) return;
221
+ if (isRepeat(value)) {
222
+ const r = /** @type any */ (value);
223
+ for (let i = 0; i < r.items.length; i++) await this.value(r.templateFn(r.items[i], i));
224
+ return;
225
+ }
226
+ if (isSuspense(value)) {
227
+ const s = /** @type any */ (value);
228
+ const ctx = this.ctx;
229
+ if (ctx) {
230
+ const id = `s${ctx.nextId++}`;
231
+ this.emit(`<webjs-boundary id="${id}">`);
232
+ await this.value(s.fallback);
233
+ this.emit('</webjs-boundary>');
234
+ ctx.pending.push({ id, promise: Promise.resolve(s.children) });
235
+ } else {
236
+ await this.value(s.fallback);
237
+ }
238
+ return;
239
+ }
240
+ this.emit(escapeText(String(value)));
241
+ }
242
+
243
+ /** @param {import('../html.js').TemplateResult} tr */
244
+ async template(tr) {
245
+ await this.ops(compile(tr.strings), tr.values);
246
+ this.flush();
247
+ }
248
+
249
+ // -------------------------------------------------------------------------
250
+ // The op loop
251
+ // -------------------------------------------------------------------------
252
+
253
+ /**
254
+ * @param {import('./compile.js').Op[]} ops
255
+ * @param {unknown[]} values
256
+ */
257
+ async ops(ops, values) {
258
+ let vi = 0;
259
+ for (let i = 0; i < ops.length; i++) {
260
+ const op = ops[i];
261
+ switch (op.t) {
262
+ case TEXT:
263
+ this.buf += op.s;
264
+ break;
265
+ case OPEN:
266
+ this.open(/** @type string */ (op.tag));
267
+ break;
268
+ case TAG_END: {
269
+ // Sync for an ordinary element; a frame-taking tag returns a promise.
270
+ const p = this.tagEnd(op);
271
+ if (p) await p;
272
+ break;
273
+ }
274
+ case CLOSE: {
275
+ const p = this.close(op);
276
+ if (p) await p;
277
+ break;
278
+ }
279
+ case HOLE: {
280
+ let val = values[vi++];
281
+ if (val && typeof (/** @type any */ (val).then) === 'function') val = await val;
282
+ // Unwrap live() once, here, before the position dispatch, exactly
283
+ // where the client's applyPart() unwraps it (#1443). AFTER the
284
+ // await, since a hole may hold a promise that resolves TO a live().
285
+ if (isLive(val)) val = /** @type any */ (val).value;
286
+ switch (op.k) {
287
+ case CHILD:
288
+ this.flush();
289
+ // The common primitive child needs no promise.
290
+ if (typeof val === 'string') this.emit(escapeText(val));
291
+ else if (typeof val === 'number') this.emit(escapeText(String(val)));
292
+ else await this.value(val);
293
+ break;
294
+ case RAW:
295
+ // Inside a comment, a <script> or a <style>: emitted as-is. The
296
+ // author owns what closes the tag, the usual caveat for CSS/JS.
297
+ this.buf += String(val ?? '');
298
+ break;
299
+ case ATTR:
300
+ await this.attr(op, val);
301
+ break;
302
+ case VALUE:
303
+ // A hole inside a quoted/unquoted value, the `action="${fn}"` and
304
+ // mixed `action="/x/${fn}"` shapes (#1154).
305
+ assertNotFunctionActionAttr(val, op.name, op.tag);
306
+ this.buf += escapeAttr(String(val ?? ''));
307
+ break;
308
+ default:
309
+ break;
310
+ }
311
+ break;
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ // -------------------------------------------------------------------------
318
+ // Start tags
319
+ // -------------------------------------------------------------------------
320
+
321
+ /** @param {string} tag */
322
+ open(tag) {
323
+ // A top-level child of a light-DOM component: the segment it occupies in
324
+ // the captured children is what the slot partition is built from.
325
+ const top = this.frames[this.frames.length - 1];
326
+ if (top && top.partition && top.depth === 0 && !this.inert.length) {
327
+ top.seg = { start: this.buf.length, end: 0, slot: null };
328
+ }
329
+ // A tag the walker will take over (a registered element, a boundary, a
330
+ // slot inside a light-DOM template) accumulates its start tag in a FRESH
331
+ // buffer, so taking it at the `>` never slices the page built so far:
332
+ // on JavaScriptCore that slice materialised the whole rope each time.
333
+ let outer;
334
+ let formOuter;
335
+ if (this.expand && this.defer === 0 && !this.inert.length
336
+ && (tag === 'webjs-suspense' || (tag === 'slot' && this.owner !== null) || (tag.includes('-') && lookup(tag)))) {
337
+ outer = this.buf;
338
+ this.buf = '';
339
+ } else if (tag === 'form' || tag === 'button' || tag === 'input') {
340
+ // The tags a form action can bind to. The bound-form edits rewrite the
341
+ // start tag (strip the action attribute's trailing whitespace, parse the
342
+ // attributes, splice in method / enctype and the identity), so that tag
343
+ // is built in its own buffer and joined back at its `>`: rewriting it in
344
+ // place copied the whole page built so far, once per bound form.
345
+ formOuter = this.buf;
346
+ this.buf = '';
347
+ }
348
+ this.tagState = {
349
+ tag,
350
+ start: this.buf.length,
351
+ outer,
352
+ formOuter,
353
+ // #1155 / #1207 bookkeeping, judged at the `>` because the action hole
354
+ // may come after the shapes it refuses.
355
+ actionId: /** @type {string | null} */ (null),
356
+ submitterTag: /** @type {string | null} */ (null),
357
+ actionCount: 0,
358
+ /** @type {string[] | null} */ propAttrs: null,
359
+ /** @type {string[] | null} */ submitterProps: null,
360
+ /** @type {string | null} */ fallback: null,
361
+ };
362
+ }
363
+
364
+ /**
365
+ * @param {import('./compile.js').Op} op
366
+ * @param {unknown} val
367
+ */
368
+ async attr(op, val) {
369
+ const ts = this.tagState;
370
+ const name = /** @type string */ (op.name);
371
+ const tag = /** @type string */ (op.tag);
372
+ if (op.kind === 'event') {
373
+ // An event listener is client-only behaviour, dropped at SSR.
374
+ return;
375
+ }
376
+ if (op.kind === 'prop') {
377
+ // `<webjs-suspense .fallback=${html`...`}>` (#471) is the boundary's
378
+ // placeholder, rendered now so the walker can emit it at the tag. Only
379
+ // a streamed boundary (a ctx) shows it: a blocking one renders its
380
+ // children inline, so expanding the fallback's components there would
381
+ // run their renders (and their data fetches) for bytes that are dropped.
382
+ if (tag === 'webjs-suspense' && name === 'fallback') {
383
+ if (this.expand && this.ctx && ts) ts.fallback = await this.capture(() => this.value(val));
384
+ return;
385
+ }
386
+ if (!tag.includes('-')) {
387
+ // A native element's `.prop` is dropped at SSR. It still refuses a
388
+ // function where the property is a REFLECTED IDL attribute (`.action`
389
+ // on a form, `.formAction` on a button or input), and records the
390
+ // form-shape props judged at the `>`.
391
+ assertNotFunctionReflectedActionProp(val, name, tag);
392
+ if (ts) notePropAttr(ts, name, tag);
393
+ return;
394
+ }
395
+ // `ssr: false` drops every prop, since nothing consumes the carrier.
396
+ if (!this.expand) return;
397
+ // `undefined` has no HTML representation; `null` is a real value.
398
+ if (val === undefined) return;
399
+ try {
400
+ const encoded = await stringify(val);
401
+ this.buf += `data-webjs-prop-${kebabCase(name)}="${escapeAttr(encoded)}"`;
402
+ } catch (e) {
403
+ console.warn(
404
+ `[webjs] property binding .${name} has an unserializable `
405
+ + `value during SSR. Dropping. The browser will see the `
406
+ + `property as undefined. Detail: ${e && /** @type any */ (e).message}`
407
+ );
408
+ }
409
+ return;
410
+ }
411
+ if (op.kind === 'bool') {
412
+ // `?action=${fn}` is meaningless in every case; refusing it keeps the
413
+ // rule true for every sigil rather than only the quoted ones.
414
+ assertNotFunctionActionAttr(val, name, tag);
415
+ if (val) this.buf += `${name}=""`;
416
+ return;
417
+ }
418
+ // A plain `name=${value}` hole.
419
+ if (isBoundFormAction(val, name, tag)) {
420
+ if (ts) noteActionHole(ts, name, tag);
421
+ if (tag === 'form') {
422
+ // #1155, the form-level binding. The `action=` attribute is gone
423
+ // already (its lead was never emitted), so the form posts to the
424
+ // page's own url; the identity is remembered for the `>`. Trailing
425
+ // whitespace goes with the attribute, since every injected attribute
426
+ // carries its own leading space.
427
+ if (ts) ts.actionId = assertIdentifiableAction(await resolveFormActionId(val), tag);
428
+ this.buf = this.buf.replace(/\s+$/, '');
429
+ } else {
430
+ // #1207, the submitter binding. The identity replaces the
431
+ // `formaction=` hole IN PLACE with the button's own name/value pair.
432
+ // Refused here only where the answer cannot change later.
433
+ assertSingleSubmitterAction(!!ts && ts.submitterTag != null, tag);
434
+ const attrs = parseStartTagAttrs(ts ? this.buf.slice(ts.start) : '');
435
+ if (attrs.has('name')) assertSubmitterHasNoName(attrs.get('name') || '', tag, false);
436
+ if (attrs.has('value')) assertSubmitterHasNoValue(tag);
437
+ if (attrs.has('form')) assertSubmitterHasNoFormAttribute(tag);
438
+ const subId = assertIdentifiableAction(await resolveFormActionId(val), tag);
439
+ if (ts) ts.submitterTag = tag;
440
+ this.buf += `name="${FORM_ACTION_FIELD}" value="${escapeAttr(subId)}"`;
441
+ }
442
+ return;
443
+ }
444
+ // A second `action` hole that resolved to a plain url still COUNTS.
445
+ if (ts) noteActionHole(ts, name, tag);
446
+ // #1154: never stringify a function into action= / formaction=.
447
+ assertNotFunctionActionAttr(val, name, tag);
448
+ // #1573: `null` / `undefined` (and `false` off an aria-* name) omit the
449
+ // attribute, whose lead was never emitted.
450
+ const written = attrHoleValue(name, val);
451
+ if (written !== null) this.buf += `${op.lead}"${escapeAttr(written)}"`;
452
+ }
453
+
454
+ /**
455
+ * The `>` of a start tag. Returns a promise only when the tag takes a
456
+ * frame, so the op loop stays synchronous across ordinary markup.
457
+ * @param {import('./compile.js').Op} op
458
+ * @returns {Promise<void> | undefined}
459
+ */
460
+ tagEnd(op) {
461
+ const ts = this.tagState;
462
+ this.tagState = null;
463
+ // A boundary met while a streamed boundary's children are being captured
464
+ // is expanded later, from the string: its rendered `.fallback` rides the
465
+ // tag as `data-webjs-fallback` (what the old machine emitted for every
466
+ // boundary) and `openSuspense` reads it back at stream time.
467
+ if (ts && ts.fallback != null && this.defer > 0 && ts.tag === 'webjs-suspense') {
468
+ this.buf += ` data-webjs-fallback="${escapeAttr(ts.fallback)}"`;
469
+ }
470
+ this.buf += op.close;
471
+ if (!ts) return;
472
+ const tag = ts.tag;
473
+ if (ts.actionId != null || ts.submitterTag != null) this.closeBoundFormTag(ts);
474
+ if (ts.formOuter !== undefined) {
475
+ // Rejoin a form-capable start tag built in its own buffer (see `open`).
476
+ ts.start = ts.formOuter.length;
477
+ this.buf = ts.formOuter + this.buf;
478
+ }
479
+
480
+ if (this.inert.length) return;
481
+ const selfClose = !!op.selfClose;
482
+ if (isTextOnlyTag(tag) && !selfClose) this.inert.push(tag);
483
+
484
+ const top = this.frames[this.frames.length - 1];
485
+ if (ts.outer !== undefined) {
486
+ if (tag === 'webjs-suspense') return this.openSuspense(op, ts, top, selfClose);
487
+ if (tag === 'slot') return this.openSlot(op, ts, top, selfClose);
488
+ return this.openComponent(op, ts, top, selfClose, lookup(tag));
489
+ }
490
+ // An ordinary element. One that shares its tag with an open frame (a
491
+ // boundary or a component inside a deferred region, a same-tag element
492
+ // left as text) nests inside that frame, so its end tag must not close
493
+ // the frame: count it on the nearest frame with that tag.
494
+ if (!selfClose && !isVoidElement(tag)) {
495
+ for (let k = this.frames.length - 1; k >= 0; k--) {
496
+ if (this.frames[k].tag === tag) { this.frames[k].nested = (this.frames[k].nested || 0) + 1; break; }
497
+ }
498
+ }
499
+ if (top && top.partition && top.seg && top.depth === 0) {
500
+ const startTag = this.buf.slice(ts.start);
501
+ top.seg.slot = extractSlotAttr(startTag.slice(1 + tag.length, startTag.length - op.close.length));
502
+ if (selfClose || isVoidElement(tag)) this.endSeg(top);
503
+ else top.depth += 1;
504
+ } else if (top && top.partition && !selfClose && !isVoidElement(tag)) {
505
+ top.depth += 1;
506
+ }
507
+ }
508
+
509
+ async openSuspense(op, ts, top, selfClose) {
510
+ this.closeSeg(top, ts);
511
+ let fallback = ts.fallback;
512
+ if (fallback === null) {
513
+ // A boundary arriving as a string (a streamed parent's children) carries
514
+ // its fallback as `data-webjs-fallback`; expand it now, as the old scan
515
+ // did over the placeholder after reading it.
516
+ const m = /data-webjs-fallback="([^"]*)"/i.exec(this.buf);
517
+ if (m) {
518
+ const raw = decodeAttrEntities(m[1]);
519
+ fallback = await this.capture(() => this.ops(compileRaw(raw), EMPTY));
520
+ }
521
+ }
522
+ const frame = { kind: 'suspense', tag: ts.tag, outer: ts.outer, fallback: fallback || '', id: /** @type {string | null} */ (null) };
523
+ if (this.ctx) frame.id = `s${this.ctx.nextId++}`;
524
+ this.buf = '';
525
+ if (this.ctx) this.defer += 1;
526
+ if (selfClose) { await this.finish(frame); return; }
527
+ this.frames.push(frame);
528
+ }
529
+
530
+ async openSlot(op, ts, top, selfClose) {
531
+ const tag = ts.tag;
532
+ this.closeSeg(top, ts);
533
+ const startTag = this.buf;
534
+ const attrsRaw = startTag.slice(1 + tag.length, startTag.length - op.close.length);
535
+ const frame = { kind: 'slot', tag, outer: ts.outer, attrsRaw, owner: this.owner };
536
+ this.buf = '';
537
+ if (selfClose) { await this.finish(frame); return; }
538
+ this.frames.push(frame);
539
+ }
540
+
541
+ async openComponent(op, ts, top, selfClose, Cls) {
542
+ const tag = ts.tag;
543
+ this.closeSeg(top, ts);
544
+ const startTag = this.buf;
545
+ // The opening tag as the DSD walk had it, `<tag attrs>`, with a
546
+ // self-closing solidus dropped.
547
+ const opening = op.selfClose ? startTag.slice(0, startTag.length - op.close.length) + '>' : startTag;
548
+ const attrs = startTag.slice(1 + tag.length, startTag.length - op.close.length);
549
+ this.buf = '';
550
+ const frame = beginComponent(this, tag, Cls, opening, attrs);
551
+ if (frame.pending) await settleRender(frame);
552
+ frame.outer = ts.outer;
553
+ if (frame.instance) { this.ancestors.push(frame.instance); frame.pushedAncestor = true; }
554
+ if (selfClose) { await this.finish(frame); return; }
555
+ this.frames.push(frame);
556
+ }
557
+
558
+ /**
559
+ * A frame-taking start tag that is itself a top-level child of a light-DOM
560
+ * component: its segment's slot name is read now, so the finished element
561
+ * lands in the right slot.
562
+ */
563
+ closeSeg(top, ts) {
564
+ if (!(top && top.partition && top.seg && top.depth === 0)) return;
565
+ const startTag = this.buf;
566
+ top.seg.slot = extractSlotAttr(startTag.slice(1 + ts.tag.length).replace(/\/?>$/, ''));
567
+ top.pendingSeg = top.seg;
568
+ top.seg = null;
569
+ }
570
+
571
+ /** A top-level child segment of a light-DOM component ended. */
572
+ endSeg(frame) {
573
+ const seg = frame.seg;
574
+ frame.seg = null;
575
+ if (!seg || seg.slot === null) return;
576
+ seg.end = this.buf.length;
577
+ (frame.segments ??= []).push(seg);
578
+ }
579
+
580
+ /**
581
+ * The bound-form edits that need the WHOLE start tag: forcing `method` /
582
+ * `enctype` and placing the hidden identity field (#1155), and a bound
583
+ * submitter's own `formmethod` / `formenctype` (#1307), through the same
584
+ * helpers the old machines called, over the same start-tag text.
585
+ */
586
+ closeBoundFormTag(ts) {
587
+ const duplicateAction = ts.actionCount > 1;
588
+ if (ts.actionId != null) {
589
+ assertConvergentBoundForm({ duplicateAction, propAttrs: ts.propAttrs || [] });
590
+ const bound = bindFormActionStartTag(this.buf.slice(ts.start), ts.actionId);
591
+ this.buf = this.buf.slice(0, ts.start) + bound.tag + bound.hidden;
592
+ }
593
+ if (ts.submitterTag != null) {
594
+ this.buf = this.buf.slice(0, ts.start)
595
+ + bindSubmitterStartTag(this.buf.slice(ts.start), ts.submitterTag, { duplicateAction, propAttrs: ts.submitterProps || [] });
596
+ }
597
+ }
598
+
599
+ // -------------------------------------------------------------------------
600
+ // End tags and frames
601
+ // -------------------------------------------------------------------------
602
+
603
+ /**
604
+ * An end tag. Returns a promise only when it finishes a frame.
605
+ * @param {import('./compile.js').Op} op
606
+ * @returns {Promise<void> | undefined}
607
+ */
608
+ close(op) {
609
+ const tag = /** @type string */ (op.tag);
610
+ if (this.inert.length) {
611
+ if (this.inert[this.inert.length - 1] !== tag) { this.buf += op.text; return; }
612
+ this.inert.pop();
613
+ this.buf += op.text;
614
+ this.ordinaryClose();
615
+ return;
616
+ }
617
+ // The nearest frame this end tag closes; frames above it are unclosed.
618
+ // A same-tag element nested inside that frame closes itself instead.
619
+ for (let k = this.frames.length - 1; k >= 0; k--) {
620
+ if (this.frames[k].tag !== tag) continue;
621
+ if (this.frames[k].nested > 0) { this.frames[k].nested -= 1; break; }
622
+ return this.closeFrames(k, op.text);
623
+ }
624
+ this.buf += op.text;
625
+ this.ordinaryClose();
626
+ }
627
+
628
+ async closeFrames(k, closeText) {
629
+ while (this.frames.length - 1 > k) await this.finish(this.frames.pop());
630
+ const frame = this.frames.pop();
631
+ frame.closeText = closeText;
632
+ await this.finish(frame);
633
+ }
634
+
635
+ /** Depth bookkeeping for an ordinary element's end tag. */
636
+ ordinaryClose() {
637
+ const top = this.frames[this.frames.length - 1];
638
+ if (!(top && top.partition)) return;
639
+ if (top.depth > 0) top.depth -= 1;
640
+ if (top.depth === 0 && top.seg) this.endSeg(top);
641
+ }
642
+
643
+ /** Close every frame still open at the end of a walk (unclosed in source). */
644
+ async finishOpenFrames() {
645
+ while (this.frames.length) await this.finish(this.frames.pop());
646
+ }
647
+
648
+ /**
649
+ * Emit a finished frame into its outer buffer.
650
+ * @param {any} frame
651
+ */
652
+ async finish(frame) {
653
+ if (frame.kind === 'suspense' && this.ctx && frame.id) this.defer -= 1;
654
+ const children = this.buf;
655
+ this.buf = frame.outer;
656
+ let out;
657
+ if (frame.kind === 'component') {
658
+ if (frame.seg) this.endSegIn(frame, children);
659
+ out = await finishComponent(this, frame, children);
660
+ if (frame.pushedAncestor) this.ancestors.pop();
661
+ } else if (frame.kind === 'slot') {
662
+ out = finishSlot(frame, children);
663
+ } else {
664
+ out = await finishSuspense(this, frame, children);
665
+ }
666
+ this.buf += out;
667
+ // The element was a top-level child of a light-DOM component: close the
668
+ // segment it occupied.
669
+ const top = this.frames[this.frames.length - 1];
670
+ if (top && top.partition && top.pendingSeg) {
671
+ top.seg = top.pendingSeg;
672
+ top.pendingSeg = null;
673
+ this.endSeg(top);
674
+ }
675
+ }
676
+
677
+ /** An unclosed top-level child at the end of a component's children runs to the end. */
678
+ endSegIn(frame, children) {
679
+ const seg = frame.seg;
680
+ frame.seg = null;
681
+ if (seg.slot === null) return;
682
+ seg.end = children.length;
683
+ (frame.segments ??= []).push(seg);
684
+ }
685
+ }
686
+
687
+ const EMPTY = Object.freeze([]);
688
+
689
+ /**
690
+ * #1155: a `.method` / `.enctype` / `.encoding` prop on a form is dropped at
691
+ * SSR but applied for real in the browser, where all three are reflected IDL
692
+ * attributes, so a bound form carrying one submits differently with JS than
693
+ * without it. Recorded and refused at the `>`, once the tag's action hole is
694
+ * known. #1207 adds the submitter twin.
695
+ */
696
+ function notePropAttr(ts, name, tag) {
697
+ const t = String(tag).toLowerCase();
698
+ if (t === 'button' || t === 'input') {
699
+ if (isSubmitterReflectedProp(name)) (ts.submitterProps ??= []).push(String(name));
700
+ return;
701
+ }
702
+ if (t !== 'form') return;
703
+ let n = String(name).toLowerCase();
704
+ if (n === 'encoding') n = 'enctype';
705
+ if (n === 'method' || n === 'enctype') (ts.propAttrs ??= []).push(String(name));
706
+ }
707
+
708
+ function noteActionHole(ts, name, tag) {
709
+ const t = String(tag).toLowerCase();
710
+ const n = String(name).toLowerCase();
711
+ if ((t === 'form' && n === 'action') ||
712
+ ((t === 'button' || t === 'input') && n === 'formaction')) {
713
+ ts.actionCount += 1;
714
+ }
715
+ }
716
+
717
+ /**
718
+ * Render a value to a string.
719
+ * @param {unknown} value
720
+ * @param {Ctx} [ctx]
721
+ * @param {{ expand?: boolean, dev?: boolean, ancestors?: any[] }} [opts]
722
+ * @returns {Promise<string>}
723
+ */
724
+ export async function renderValue(value, ctx, opts) {
725
+ const w = new Walker(ctx, opts);
726
+ await w.value(value);
727
+ await w.finishOpenFrames();
728
+ return w.buf;
729
+ }
730
+
731
+ /**
732
+ * Render a raw HTML string with component expansion (the string-input path:
733
+ * a streamed boundary's children, a legacy `injectDSD` caller).
734
+ * @param {string} html
735
+ * @param {Ctx} [ctx]
736
+ * @param {{ dev?: boolean, ancestors?: any[] }} [opts]
737
+ * @returns {Promise<string>}
738
+ */
739
+ export async function expandHtml(html, ctx, opts) {
740
+ const w = new Walker(ctx, { ...opts, expand: true });
741
+ await w.ops(compileRaw(html), EMPTY);
742
+ await w.finishOpenFrames();
743
+ return w.buf;
744
+ }
745
+
746
+ /**
747
+ * Stream a value without expansion, chunk boundaries as `streamTemplate` had
748
+ * them: static text up to each child hole, every primitive value, and the
749
+ * tail of every template.
750
+ * @param {unknown} value
751
+ * @param {Ctx | undefined} ctx
752
+ * @param {ReadableStreamDefaultController<string>} controller
753
+ */
754
+ export async function streamValue(value, ctx, controller) {
755
+ const w = new Walker(ctx, { expand: false, controller });
756
+ await w.value(value);
757
+ w.flush();
758
+ }