@webjsdev/core 0.7.64 → 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.
- package/dist/webjs-core-browser.js +4 -4
- package/dist/webjs-core-browser.js.map +2 -2
- package/dist/webjs-core-testing.js +21 -22
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +6 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -1
- package/index.d.ts +2 -2
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/attribute-reader.js +20 -0
- package/src/component/lifecycle.js +90 -48
- package/src/escape.js +12 -2
- package/src/form-action.js +7 -7
- package/src/optimistic.js +3 -3
- package/src/registry.d.ts +5 -0
- package/src/registry.js +35 -4
- package/src/render-client/parts.js +2 -2
- package/src/render-server/attrs.js +5 -4
- package/src/render-server/compile.js +419 -0
- package/src/render-server/dsd.js +11 -435
- package/src/render-server/errors.js +84 -0
- package/src/render-server/expand.js +319 -0
- package/src/render-server/html-scan.js +19 -358
- package/src/render-server/stream.js +11 -13
- package/src/render-server/template-renderer.js +13 -936
- package/src/render-server/walk.js +758 -0
- package/src/router-client/snapshot-cache.js +2 -12
- package/src/slot.js +1 -1
- package/src/render-server/slots.js +0 -213
|
@@ -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
|
+
}
|