@webjsdev/core 0.7.64 → 0.8.0

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