@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.
@@ -1,946 +1,23 @@
1
- import { html, isTemplate } from '../html.js';
2
- import { BINDING_PREFIXES, 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 { isRawtextTag } from './html-scan.js';
17
- import { kebabCase } from './text.js';
18
-
19
- /** True in a production build (no dev error surfacing). */
20
- export function isProd() {
21
- return typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production';
22
- }
23
-
24
- /**
25
- * If `e` is the recognisable failure of touching a browser-only API during
26
- * SSR (a `ReferenceError` for a browser global, or a `TypeError` calling an
27
- * HTMLElement method that does not exist on the bare server-side instance),
28
- * return an actionable, member-naming hint; otherwise null.
29
- * @param {unknown} e
30
- * @returns {string | null}
31
- */
32
- export function browserMemberHint(e) {
33
- const msg = e && typeof (/** @type any */ (e).message) === 'string' ? /** @type any */ (e).message : '';
34
- // Match on a word boundary, NOT end-of-string: V8 (Node) ends the message at
35
- // "is not defined" / "is not a function", but JSC (Bun) appends a detail
36
- // clause (e.g. ". (In '({}).querySelector(\"p\")', '...' is undefined)"), so an
37
- // anchored `$` would miss the Bun message and drop the actionable hint.
38
- let m = /^(\w+) is not defined\b/.exec(msg);
39
- if (e instanceof ReferenceError && m && SSR_BROWSER_GLOBALS.has(m[1])) {
40
- return `\`${m[1]}\` is a browser-only global and is undefined during SSR.`;
41
- }
42
- m = /\.(\w+) is not a function\b/.exec(msg);
43
- if (e instanceof TypeError && m && SSR_HTMLELEMENT_METHODS.has(m[1])) {
44
- return `\`${m[1]}\` is an HTMLElement method that does not exist on the server-side component instance during SSR.`;
45
- }
46
- return null;
47
- }
48
-
49
- // Browser-only names whose absence during SSR produces a recognisable error.
50
- // Mirrors the `no-browser-globals-in-render` webjs check rule, which catches
51
- // these at edit time; this turns the runtime SSR crash into the same guidance.
52
- const SSR_BROWSER_GLOBALS = new Set([
53
- 'document', 'window', 'localStorage', 'sessionStorage', 'navigator',
54
- 'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
55
- 'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
56
- ]);
57
-
58
- // Attribute methods (get/set/has/remove/toggleAttribute), the event methods
59
- // (add/removeEventListener, dispatchEvent), and attachInternals are backed by
60
- // the server-side element shim and work at SSR, so they are NOT listed here.
61
- // What remains is the genuinely browser-only HTMLElement surface that still
62
- // has no server stand-in and throws at SSR.
63
- const SSR_HTMLELEMENT_METHODS = new Set([
64
- 'attachShadow', 'querySelector', 'querySelectorAll',
65
- 'getBoundingClientRect', 'focus', 'blur', 'scrollIntoView',
66
- ]);
67
-
68
- /**
69
- * Default component-scoped error state for an async/sync render that threw
70
- * during SSR, used when the component does not define renderError() (#469).
71
- * Dev surfaces the tag + message loudly so the failure is obvious; prod
72
- * renders an empty (silent, isolated) element so no internal detail leaks.
73
- *
74
- * The prod-silence signal is the SERVER's `dev` flag, threaded through the SSR
75
- * render context (#483). WebJs keys prod on the CLI `dev` flag, not `NODE_ENV`,
76
- * and `webjs start` does not export `NODE_ENV=production`, so a bare prod launch
77
- * would otherwise leak the message. When `dev` is undefined (a context-free
78
- * `renderToString` with no server signal, e.g. a bare unit test) it falls back
79
- * to `isProd()` / `NODE_ENV`, preserving the prior behaviour for that path.
80
- *
81
- * @param {string} tag
82
- * @param {Error} err
83
- * @param {boolean} [dev] server dev flag; undefined falls back to NODE_ENV
84
- * @returns {unknown} a TemplateResult (dev) or '' (prod)
85
- */
86
- export function defaultSSRErrorTemplate(tag, err, dev) {
87
- const surface = dev === undefined ? !isProd() : !!dev;
88
- if (!surface) return '';
89
- const msg = err && err.message ? err.message : String(err);
90
- return html`<div data-webjs-error="${tag}" style="border:1px solid #f5c2c7;background:#f8d7da;color:#842029;padding:8px 12px;border-radius:6px;font:13px/1.4 system-ui,sans-serif">
91
- <strong>&lt;${tag}&gt; failed to render</strong>
92
- <div style="margin-top:4px;white-space:pre-wrap">${msg}</div>
93
- </div>`;
94
- }
95
-
96
- /**
97
- * @param {unknown} value
98
- * @param {SuspenseCtx} [ctx]
99
- * @returns {Promise<string>}
100
- */
101
- export async function render(value, ctx) {
102
- if (value == null || value === false || value === true) return '';
103
- if (value && typeof /** @type any */ (value).then === 'function') {
104
- value = await value;
105
- return render(value, ctx);
106
- }
107
- // unsafeHTML: inject raw HTML string without escaping.
108
- if (isUnsafeHTML(value)) {
109
- return String(/** @type any */ (value).value ?? '');
110
- }
111
- // live() on the server just unwraps and renders the inner value.
112
- if (isLive(value)) {
113
- return render(/** @type any */ (value).value, ctx);
114
- }
115
- // watch() on the server reads the signal once and inlines the
116
- // result. Subscription is a client-only concern; the SSR HTML
117
- // freezes a snapshot of the current value.
118
- if (isWatch(value)) {
119
- return render(/** @type any */ (value).signal.get(), ctx);
120
- }
121
- // keyed() on the server: render the wrapped template; key is client-only.
122
- if (isKeyed(value)) {
123
- return render(/** @type any */ (value).value, ctx);
124
- }
125
- // guard() on the server: always invoke the value function (no cache on SSR).
126
- if (isGuard(value)) {
127
- return render(/** @type any */ (value).fn(), ctx);
128
- }
129
- // templateContent() on the server: emit the template's innerHTML verbatim.
130
- if (isTemplateContent(value)) {
131
- const tpl = /** @type any */ (value).template;
132
- return String(tpl?.innerHTML ?? '');
133
- }
134
- // ref() on the server: no-op (no DOM yet). Returns empty string.
135
- if (isRef(value)) {
136
- return '';
137
- }
138
- // cache() on the server: pass-through to the inner value.
139
- if (isCache(value)) {
140
- return render(/** @type any */ (value).value, ctx);
141
- }
142
- // until() on the server: render the first synchronous candidate, or
143
- // await the first Promise to settle when all candidates are Promises.
144
- // Rejections are swallowed (treated as "no value"); if every candidate
145
- // rejects, render empty rather than crash the SSR pipeline.
146
- if (isUntil(value)) {
147
- const args = /** @type any */ (value).args;
148
- for (const a of args) {
149
- if (!a || typeof (/** @type any */ (a).then) !== 'function') {
150
- return render(a, ctx);
151
- }
152
- }
153
- if (args.length > 0) {
154
- try {
155
- const winner = await Promise.race(args.map((p) => Promise.resolve(p).catch(() => undefined)));
156
- return render(winner, ctx);
157
- } catch {
158
- return '';
159
- }
160
- }
161
- return '';
162
- }
163
- // asyncAppend / asyncReplace on the server: render empty. Full
164
- // streaming is a follow-up; pages should use Suspense for streaming.
165
- if (isAsyncAppend(value) || isAsyncReplace(value)) {
166
- return '';
167
- }
168
- if (Array.isArray(value)) {
169
- const parts = await Promise.all(value.map((v) => render(v, ctx)));
170
- return parts.join('');
171
- }
172
- if (isRepeat(value)) {
173
- const r = /** @type any */ (value);
174
- const parts = await Promise.all(r.items.map((it, i) => render(r.templateFn(it, i), ctx)));
175
- return parts.join('');
176
- }
177
- if (isSuspense(value)) {
178
- const s = /** @type any */ (value);
179
- const fallback = await render(s.fallback, ctx);
180
- if (ctx) {
181
- const id = `s${ctx.nextId++}`;
182
- ctx.pending.push({ id, promise: Promise.resolve(s.children) });
183
- return `<webjs-boundary id="${id}">${fallback}</webjs-boundary>`;
184
- }
185
- return fallback;
186
- }
187
- if (isTemplate(value)) return renderTemplate(/** @type any */ (value), ctx);
188
- return escapeText(String(value));
189
- }
1
+ import { streamValue } from './walk.js';
190
2
 
191
3
  /**
192
- * @param {import('../html.js').TemplateResult} tr
193
- * @param {SuspenseCtx} [ctx]
194
- * @returns {Promise<string>}
4
+ * The value-rendering entry points of the server renderer. Since #1646 the
5
+ * work happens in `compile.js` (one tokenizer pass per template, cached per
6
+ * `TemplateStringsArray` identity) and `walk.js` (one op interpreter with a
7
+ * buffered and a streaming sink, expanding custom elements in the same pass).
8
+ * This module keeps the error helpers and the no-expansion streaming entry
9
+ * `stream.js` uses for `ssr: false`.
195
10
  */
196
- export async function renderTemplate(tr, ctx) {
197
- const { strings, values } = tr;
198
- let out = '';
199
- let state = 'text';
200
- let attrName = '';
201
- let attrStart = 0;
202
- let attrQuote = '';
203
- let commentDashes = 0;
204
- let currentTag = '';
205
- let rawTail = '';
206
- let tagStart = -1;
207
- /** @type {string | null} */
208
- let pendingActionId = null;
209
- /** @type {string | null} */
210
- let pendingSubmitterTag = null;
211
- // Shapes on the CURRENT start tag that a bound form may not carry (#1155).
212
- // Collected as the tag is scanned and judged at its `>`, because the action
213
- // hole may come after them.
214
- let pendingActionCount = 0;
215
- /** @type {string[]} */
216
- let pendingPropAttrs = [];
217
- /** @type {string[]} */
218
- let pendingSubmitterProps = [];
219
-
220
- // A bound `action=${fn}` is committed at its hole, but the edits it implies
221
- // (forcing `method` / `enctype`, and the hidden identity field) are only
222
- // possible once the whole start tag is known: an attribute the author wrote
223
- // AFTER the action hole still counts, and the hidden field belongs INSIDE
224
- // the form, after the `>`. So the hole records the identity and this runs at
225
- // the `>`, rewriting the start tag that was just emitted.
226
- const closeBoundFormTag = () => {
227
- // Reset per tag whether or not this one was bound, so a later form is never
228
- // judged on an earlier tag's shapes.
229
- const propAttrs = pendingPropAttrs;
230
- const submitterProps = pendingSubmitterProps;
231
- const duplicateAction = pendingActionCount > 1;
232
- const submitterTag = pendingSubmitterTag;
233
- pendingPropAttrs = [];
234
- pendingSubmitterProps = [];
235
- pendingActionCount = 0;
236
- pendingSubmitterTag = null;
237
- if (pendingActionId != null) {
238
- assertConvergentBoundForm({ duplicateAction, propAttrs });
239
- const bound = bindFormActionStartTag(out.slice(tagStart), pendingActionId);
240
- out = out.slice(0, tagStart) + bound.tag + bound.hidden;
241
- pendingActionId = null;
242
- }
243
- if (submitterTag != null) {
244
- // #1307: a bound submitter carries its WHOLE submission, so `formmethod`
245
- // and the enctype are injected onto the button here rather than inherited
246
- // from a form this scan may not even be able to see. That is what removed
247
- // the enclosing-form question, and with it the four-state scope tracking
248
- // that could never answer it for a button inside a component.
249
- out = out.slice(0, tagStart)
250
- + bindSubmitterStartTag(out.slice(tagStart), submitterTag, { duplicateAction, propAttrs: submitterProps });
251
- }
252
- };
253
-
254
- // #1155: a `.method` / `.enctype` / `.encoding` prop on a form is dropped
255
- // here but applied for real in the browser, where all three are reflected IDL
256
- // attributes, so a bound form carrying one submits differently with JS than
257
- // without it. Recorded and refused at the `>`, once the tag's action hole is
258
- // known.
259
- const notePropAttr = (name, tag) => {
260
- const t = String(tag).toLowerCase();
261
- if (t === 'button' || t === 'input') {
262
- // #1207: the submitter twin. `name` / `value` / `formAction` / `formMethod`
263
- // / `formEnctype` all reflect on a submitter, so a `.prop` spelling is
264
- // dropped here and written to the attribute in the browser.
265
- if (isSubmitterReflectedProp(name)) pendingSubmitterProps.push(String(name));
266
- return;
267
- }
268
- if (t !== 'form') return;
269
- let n = String(name).toLowerCase();
270
- if (n === 'encoding') n = 'enctype';
271
- if (n === 'method' || n === 'enctype') pendingPropAttrs.push(String(name));
272
- };
273
- const noteActionHole = (name, tag) => {
274
- const t = String(tag).toLowerCase();
275
- const n = String(name).toLowerCase();
276
- if ((t === 'form' && n === 'action') ||
277
- ((t === 'button' || t === 'input') && n === 'formaction')) {
278
- pendingActionCount += 1;
279
- }
280
- };
281
-
282
- // Every `>` in a tag state funnels through here, so the bound-form bookkeeping
283
- // stays in one place rather than at five call sites.
284
- //
285
- // `allowRawtext` is NOT a preference. Only two of those five call sites ever
286
- // entered rawtext: the `tag-name` and `in-tag` exits. The three attribute
287
- // exits (`attr-name`, `after-eq`, `attr-unquoted`) always forced `text`, so
288
- // `<script defer>` and `<style media=print>`, whose start tags end on a bare
289
- // or unquoted attribute, escaped their bodies. Switching them to rawtext here
290
- // would silently turn `<script defer>${userInput}</script>` from escaped into
291
- // raw script, which is an XSS mitigation this change has no business
292
- // touching. Whether that escaping is the RIGHT behaviour is a separate
293
- // question from #1207; this preserves it exactly.
294
- const handleTagEnd = (allowRawtext) => {
295
- closeBoundFormTag();
296
- state = allowRawtext && isRawtextTag(currentTag) ? 'rawtext' : 'text';
297
- if (state === 'rawtext') rawTail = '';
298
- };
299
11
 
300
- for (let i = 0; i < strings.length; i++) {
301
- const s = strings[i];
302
- for (let j = 0; j < s.length; j++) {
303
- const c = s[j];
304
- switch (state) {
305
- case 'text':
306
- out += c;
307
- if (c === '<') { state = 'tag-open'; tagStart = out.length - 1; }
308
- break;
309
- case 'tag-open':
310
- out += c;
311
- if (c === '!') state = 'bang-1';
312
- else if (c === '/') { state = 'tag-name'; currentTag = ''; }
313
- else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
314
- else state = 'text';
315
- break;
316
- case 'bang-1':
317
- out += c;
318
- state = c === '-' ? 'bang-dash' : 'tag-name';
319
- break;
320
- case 'bang-dash':
321
- out += c;
322
- if (c === '-') { state = 'comment'; commentDashes = 0; }
323
- else state = 'tag-name';
324
- break;
325
- case 'comment':
326
- out += c;
327
- if (c === '-') commentDashes += 1;
328
- else if (c === '>' && commentDashes >= 2) { state = 'text'; commentDashes = 0; }
329
- else commentDashes = 0;
330
- break;
331
- case 'tag-name':
332
- out += c;
333
- if (c === '>') {
334
- handleTagEnd(true);
335
- } else if (/\s/.test(c)) state = 'in-tag';
336
- else currentTag += c.toLowerCase();
337
- break;
338
- case 'in-tag':
339
- out += c;
340
- if (c === '>') {
341
- handleTagEnd(true);
342
- } else if (!/\s/.test(c) && c !== '/') {
343
- state = 'attr-name';
344
- attrName = c;
345
- attrStart = out.length - 1;
346
- }
347
- break;
348
- case 'rawtext':
349
- out += c;
350
- rawTail = (rawTail + c.toLowerCase()).slice(-9);
351
- if (rawTail.endsWith('</script>') || rawTail.endsWith('</style>')) {
352
- state = 'text';
353
- rawTail = '';
354
- currentTag = '';
355
- }
356
- break;
357
- case 'attr-name':
358
- if (c === '=') { state = 'after-eq'; out += c; }
359
- else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
360
- else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); }
361
- else { attrName += c; out += c; }
362
- break;
363
- case 'after-eq':
364
- if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; out += c; }
365
- else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
366
- else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); }
367
- else { state = 'attr-unquoted'; out += c; }
368
- break;
369
- case 'attr-unquoted':
370
- if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
371
- else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); }
372
- else out += c;
373
- break;
374
- case 'attr-quoted':
375
- out += c;
376
- if (c === attrQuote) { state = 'in-tag'; attrName = ''; }
377
- break;
378
- }
379
- }
380
-
381
- if (i < values.length) {
382
- let val = values[i];
383
- // Resolve promises anywhere in the value graph.
384
- if (val && typeof /** @type any */ (val).then === 'function') {
385
- val = await val;
386
- }
387
- // Unwrap live() once, here, before the position dispatch, exactly where
388
- // the client's applyPart() unwraps it (render-client/parts.js). The
389
- // directive's only job is to dirty-check against the LIVE DOM value, a
390
- // client-only concern, so on the server it is transparent and every
391
- // position must see the inner value. Doing it per-position instead let
392
- // the two renderers drift: the wrapper is truthy, so a `?bool=${live(v)}`
393
- // emitted its attribute whatever v was, and `attr=${live(v)}` stringified
394
- // to `[object Object]` (#1443). render() / streamRender() keep their own
395
- // isLive branch below for a live() nested inside an array child, which
396
- // this hole-level unwrap never sees. AFTER the await: a hole may hold a
397
- // promise that resolves TO a live().
398
- if (isLive(val)) val = /** @type any */ (val).value;
399
- if (state === 'comment') {
400
- // Holes inside <!-- comments --> are emitted raw (no escaping; comments
401
- // are inert and not rendered by browsers).
402
- out += String(val ?? '');
403
- commentDashes = 0;
404
- } else if (state === 'rawtext') {
405
- // Inside <script> / <style>: emit the value as-is (no HTML escaping).
406
- // Author is responsible for not closing the tag with user-controlled
407
- // data: the usual caveat for CSS/JS interpolation.
408
- out += String(val ?? '');
409
- rawTail = '';
410
- } else if (state === 'text') {
411
- out += await render(val, ctx);
412
- } else if (state === 'after-eq') {
413
- const prefix = attrName[0];
414
- const name = attrName.slice(1);
415
- const kind = BINDING_PREFIXES[prefix];
416
- if (kind === 'event') {
417
- // Event listener. Client-only behaviour, drop at SSR.
418
- out = out.slice(0, attrStart);
419
- state = 'in-tag';
420
- attrName = '';
421
- } else if (kind === 'prop') {
422
- // Property binding. Only meaningful on custom elements (which
423
- // have a hyphen in the tag name and a WebComponent subclass
424
- // that knows how to apply + strip data-webjs-prop-* on
425
- // hydration). For native elements (`<input .value=${v}>`)
426
- // the attribute would be dead weight (nothing consumes it),
427
- // so we drop it the same way the old behaviour did. The
428
- // client renderer still applies the property when the
429
- // template runs in the browser, which is the only place a
430
- // page-level `.prop` on a native element could have set the
431
- // property to begin with.
432
- out = out.slice(0, attrStart);
433
- // `<webjs-suspense .fallback=${html`...`}>` (#471). This element is
434
- // defined only in the browser, so the injectDSD walk skips it
435
- // (`lookup(tag)` finds no class) and no server-side instance runs
436
- // consumePropAttrs. A normal data-webjs-prop-* binding would then
437
- // land at connectedCallback, too late for the streaming placeholder.
438
- // So render the fallback to HTML now and carry it as
439
- // data-webjs-fallback, which the injectDSD streaming pre-pass reads
440
- // as the boundary placeholder. (The value itself would serialize
441
- // fine: a TemplateResult is a plain {strings, values} object.)
442
- if (currentTag === 'webjs-suspense' && name === 'fallback') {
443
- const fbHtml = await render(val, ctx);
444
- out += `data-webjs-fallback="${escapeAttr(fbHtml)}"`;
445
- state = 'in-tag';
446
- attrName = '';
447
- continue;
448
- }
449
- if (!currentTag.includes('-')) {
450
- // A native element's `.prop` is dropped at SSR, so this path never
451
- // leaked here. It still refuses a function where the property is a
452
- // REFLECTED IDL attribute (`.action` on a form, `.formAction` on a
453
- // button or input), so the rule does not depend on which renderer
454
- // sees it first: the client sets that property for real and the
455
- // reflection writes the source into the DOM. A page that renders
456
- // clean on the server and throws on hydration is a worse failure
457
- // than one that refuses at the earliest point. Elsewhere the
458
- // property is a plain expando that reflects nothing, so refusing
459
- // it would be a false positive.
460
- assertNotFunctionReflectedActionProp(val, name, currentTag);
461
- notePropAttr(name, currentTag);
462
- state = 'in-tag';
463
- attrName = '';
464
- continue;
465
- }
466
- // `undefined` has no meaningful HTML representation. Drop
467
- // silently so the consumer falls back to its constructor
468
- // default. `null` is preserved because it's a real value
469
- // distinct from "not set".
470
- if (val === undefined) {
471
- state = 'in-tag';
472
- attrName = '';
473
- continue;
474
- }
475
- try {
476
- const encoded = await stringify(val);
477
- out += `data-webjs-prop-${kebabCase(name)}="${escapeAttr(encoded)}"`;
478
- } catch (e) {
479
- // Unserializable value (function, class instance with
480
- // private state, DOM node, etc.). Drop with a warning so
481
- // SSR does not crash. Same constraint as Next.js RSC.
482
- console.warn(
483
- `[webjs] property binding .${name} has an unserializable `
484
- + `value during SSR. Dropping. The browser will see the `
485
- + `property as undefined. Detail: ${e && e.message}`
486
- );
487
- }
488
- state = 'in-tag';
489
- attrName = '';
490
- } else if (kind === 'bool') {
491
- // Never leaked (a boolean binding stringifies nothing), but
492
- // `?action=${fn}` is meaningless in every case and refusing it keeps
493
- // the rule true for every sigil rather than only the quoted ones.
494
- assertNotFunctionActionAttr(val, name, currentTag);
495
- out = out.slice(0, attrStart);
496
- if (val) out += `${name}=""`;
497
- state = 'in-tag';
498
- attrName = '';
499
- } else if (isBoundFormAction(val, attrName, currentTag)) {
500
- noteActionHole(attrName, currentTag);
501
- if (currentTag === 'form') {
502
- // #1155: the form-level binding. Drop the `action=` attribute
503
- // entirely so the form posts to the page's own url (an omitted
504
- // attribute, not `action=""`, which the spec calls a conformance
505
- // error), and remember the identity so the `>` can force the
506
- // submission attributes and emit the hidden field.
507
- pendingActionId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
508
- // Trailing whitespace goes with the attribute: every injected
509
- // attribute carries its own leading space, so keeping the old one
510
- // would double it in the emitted tag.
511
- out = out.slice(0, attrStart).replace(/\s+$/, '');
512
- } else {
513
- // #1207: the submitter binding. The identity replaces the
514
- // `formaction=` hole IN PLACE with the button's own name/value
515
- // pair, the one channel a browser submits for the pressed button
516
- // alone. No `formaction` url is emitted, so the submission targets
517
- // whatever the FORM targets, and a form-level identity is simply
518
- // overridden by this later entry.
519
- //
520
- // Refused here rather than at the `>` only where the answer cannot
521
- // change later: an attribute written BEFORE the hole is already in
522
- // `out`. Everything else waits for the close,
523
- // where `assertSubmitterStartTag` sees the whole tag.
524
- // A second binding hole on this same tag, refused here so the
525
- // author gets the duplicate message rather than a confusing
526
- // complaint about the `name` the FIRST hole just injected.
527
- assertSingleSubmitterAction(pendingSubmitterTag != null, currentTag);
528
- const attrs = parseStartTagAttrs(out.slice(tagStart));
529
- if (attrs.has('name')) assertSubmitterHasNoName(attrs.get('name') || '', currentTag, false);
530
- if (attrs.has('value')) assertSubmitterHasNoValue(currentTag);
531
- if (attrs.has('form')) assertSubmitterHasNoFormAttribute(currentTag);
532
- const subId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
533
- pendingSubmitterTag = currentTag;
534
- out = out.slice(0, attrStart) + `name="${FORM_ACTION_FIELD}" value="${escapeAttr(subId)}"`;
535
- }
536
- state = 'in-tag';
537
- attrName = '';
538
- } else {
539
- // A second `action` hole that resolved to a plain url still COUNTS,
540
- // so the duplicate refusal fires whatever the values happen to be.
541
- noteActionHole(attrName, currentTag);
542
- // #1154: never stringify a function into action=/formaction= (it
543
- // would serialize a server action's source into the served HTML).
544
- assertNotFunctionActionAttr(val, attrName, currentTag);
545
- // #1573: the client's rule, not a stringify. `null` / `undefined`
546
- // (and `false` off an aria-* name) omit the attribute, which is
547
- // already in `out` from `attrStart`, so slice it back off.
548
- const written = attrHoleValue(attrName, val);
549
- if (written === null) out = out.slice(0, attrStart);
550
- else out += `"${escapeAttr(written)}"`;
551
- state = 'in-tag';
552
- attrName = '';
553
- }
554
- } else if (state === 'attr-quoted' || state === 'attr-unquoted') {
555
- // Same guard for a hole inside a quoted/unquoted value, the
556
- // `action="${fn}"` and mixed `action="/x/${fn}"` shapes (#1154).
557
- assertNotFunctionActionAttr(val, attrName, currentTag);
558
- out += escapeAttr(String(val ?? ''));
559
- }
560
- }
561
- }
562
- return out;
563
- }
12
+ export { isProd, browserMemberHint, defaultSSRErrorTemplate } from './errors.js';
564
13
 
565
14
  /**
566
- * Recursively render a value, enqueuing HTML chunks into the stream
567
- * controller as they become available.
568
- *
15
+ * Stream a value's HTML chunks into a stream controller, without expansion:
16
+ * the `renderToStream(v, { ssr: false })` path.
569
17
  * @param {unknown} value
570
- * @param {SuspenseCtx} [ctx]
571
- * @param {ReadableStreamDefaultController<string>} controller
572
- */
573
- export async function streamRender(value, ctx, controller) {
574
- if (value == null || value === false || value === true) return;
575
- if (value && typeof /** @type any */ (value).then === 'function') {
576
- value = await value;
577
- return streamRender(value, ctx, controller);
578
- }
579
- if (isUnsafeHTML(value)) {
580
- controller.enqueue(String(/** @type any */ (value).value ?? ''));
581
- return;
582
- }
583
- if (isLive(value)) {
584
- return streamRender(/** @type any */ (value).value, ctx, controller);
585
- }
586
- if (isWatch(value)) {
587
- return streamRender(/** @type any */ (value).signal.get(), ctx, controller);
588
- }
589
- if (isKeyed(value)) {
590
- return streamRender(/** @type any */ (value).value, ctx, controller);
591
- }
592
- if (isGuard(value)) {
593
- return streamRender(/** @type any */ (value).fn(), ctx, controller);
594
- }
595
- if (isTemplateContent(value)) {
596
- const tpl = /** @type any */ (value).template;
597
- controller.enqueue(String(tpl?.innerHTML ?? ''));
598
- return;
599
- }
600
- if (isRef(value)) {
601
- return;
602
- }
603
- if (isCache(value)) {
604
- return streamRender(/** @type any */ (value).value, ctx, controller);
605
- }
606
- if (isUntil(value)) {
607
- const args = /** @type any */ (value).args;
608
- for (const a of args) {
609
- if (!a || typeof (/** @type any */ (a).then) !== 'function') {
610
- return streamRender(a, ctx, controller);
611
- }
612
- }
613
- if (args.length > 0) {
614
- try {
615
- const winner = await Promise.race(args.map((p) => Promise.resolve(p).catch(() => undefined)));
616
- return streamRender(winner, ctx, controller);
617
- } catch {
618
- return;
619
- }
620
- }
621
- return;
622
- }
623
- if (isAsyncAppend(value) || isAsyncReplace(value)) {
624
- return;
625
- }
626
- if (Array.isArray(value)) {
627
- for (const v of value) await streamRender(v, ctx, controller);
628
- return;
629
- }
630
- if (isRepeat(value)) {
631
- const r = /** @type any */ (value);
632
- for (let i = 0; i < r.items.length; i++) {
633
- await streamRender(r.templateFn(r.items[i], i), ctx, controller);
634
- }
635
- return;
636
- }
637
- if (isSuspense(value)) {
638
- const s = /** @type any */ (value);
639
- if (ctx) {
640
- const id = `s${ctx.nextId++}`;
641
- controller.enqueue(`<webjs-boundary id="${id}">`);
642
- await streamRender(s.fallback, ctx, controller);
643
- controller.enqueue(`</webjs-boundary>`);
644
- ctx.pending.push({ id, promise: Promise.resolve(s.children) });
645
- } else {
646
- await streamRender(s.fallback, ctx, controller);
647
- }
648
- return;
649
- }
650
- if (isTemplate(value)) {
651
- await streamTemplate(/** @type any */ (value), ctx, controller);
652
- return;
653
- }
654
- controller.enqueue(escapeText(String(value)));
655
- }
656
-
657
- /**
658
- * Stream a TemplateResult by yielding each static string piece and
659
- * processing each value hole incrementally.
660
- *
661
- * @param {import('../html.js').TemplateResult} tr
662
- * @param {SuspenseCtx} [ctx]
18
+ * @param {import('./stream.js').SuspenseCtx | undefined} ctx
663
19
  * @param {ReadableStreamDefaultController<string>} controller
664
20
  */
665
- export async function streamTemplate(tr, ctx, controller) {
666
- const { strings, values } = tr;
667
- let state = 'text';
668
- let attrName = '';
669
- let attrStart = 0;
670
- let attrQuote = '';
671
- let commentDashes = 0;
672
- let currentTag = '';
673
- let rawTail = '';
674
- // Buffer used for attribute handling where we may need to backtrack.
675
- let buf = '';
676
- let tagStart = -1;
677
- /** @type {string | null} */
678
- let pendingActionId = null;
679
- // Shapes on the CURRENT start tag that a bound form may not carry (#1155).
680
- // Collected as the tag is scanned and judged at its `>`, because the action
681
- // hole may come after them.
682
- let pendingActionCount = 0;
683
- /** @type {string[]} */
684
- let pendingPropAttrs = [];
685
- /** @type {string[]} */
686
- let pendingSubmitterProps = [];
687
- /** @type {string | null} */
688
- let pendingSubmitterTag = null;
689
-
690
- // See the buffered machine for why this runs at the `>` rather than at the
691
- // hole. `tagStart` indexes into `buf`, which is safe because `buf` is only
692
- // flushed on a `text`-state hole and a start tag contains none.
693
- const closeBoundFormTag = () => {
694
- // Reset per tag whether or not this one was bound, so a later form is never
695
- // judged on an earlier tag's shapes.
696
- const propAttrs = pendingPropAttrs;
697
- const submitterProps = pendingSubmitterProps;
698
- const duplicateAction = pendingActionCount > 1;
699
- const submitterTag = pendingSubmitterTag;
700
- pendingPropAttrs = [];
701
- pendingSubmitterProps = [];
702
- pendingActionCount = 0;
703
- pendingSubmitterTag = null;
704
- if (pendingActionId != null) {
705
- assertConvergentBoundForm({ duplicateAction, propAttrs });
706
- const bound = bindFormActionStartTag(buf.slice(tagStart), pendingActionId);
707
- buf = buf.slice(0, tagStart) + bound.tag + bound.hidden;
708
- pendingActionId = null;
709
- }
710
- if (submitterTag != null) {
711
- // #1307, the SAME injection as the buffered machine, through the same
712
- // helper, so this second state machine cannot drift from it.
713
- buf = buf.slice(0, tagStart)
714
- + bindSubmitterStartTag(buf.slice(tagStart), submitterTag, { duplicateAction, propAttrs: submitterProps });
715
- }
716
- };
717
- // #1155: a `.method` / `.enctype` / `.encoding` prop on a form is dropped
718
- // here but applied for real in the browser, where all three are reflected IDL
719
- // attributes, so a bound form carrying one submits differently with JS than
720
- // without it. Recorded and refused at the `>`, once the tag's action hole is
721
- // known.
722
- const notePropAttr = (name, tag) => {
723
- const t = String(tag).toLowerCase();
724
- if (t === 'button' || t === 'input') {
725
- // #1207: the submitter twin. `name` / `value` / `formAction` / `formMethod`
726
- // / `formEnctype` all reflect on a submitter, so a `.prop` spelling is
727
- // dropped here and written to the attribute in the browser.
728
- if (isSubmitterReflectedProp(name)) pendingSubmitterProps.push(String(name));
729
- return;
730
- }
731
- if (t !== 'form') return;
732
- let n = String(name).toLowerCase();
733
- if (n === 'encoding') n = 'enctype';
734
- if (n === 'method' || n === 'enctype') pendingPropAttrs.push(String(name));
735
- };
736
- const noteActionHole = (name, tag) => {
737
- const t = String(tag).toLowerCase();
738
- const n = String(name).toLowerCase();
739
- if ((t === 'form' && n === 'action') ||
740
- ((t === 'button' || t === 'input') && n === 'formaction')) {
741
- pendingActionCount += 1;
742
- }
743
- };
744
-
745
- // Same contract as the buffered machine, `allowRawtext` included: only the
746
- // `tag-name` and `in-tag` exits ever entered rawtext here either, so a start
747
- // tag ending on a bare or unquoted attribute must keep escaping its body.
748
- const handleTagEnd = (allowRawtext) => {
749
- closeBoundFormTag();
750
- state = allowRawtext && isRawtextTag(currentTag) ? 'rawtext' : 'text';
751
- if (state === 'rawtext') rawTail = '';
752
- };
753
-
754
- for (let i = 0; i < strings.length; i++) {
755
- const s = strings[i];
756
- for (let j = 0; j < s.length; j++) {
757
- const c = s[j];
758
- switch (state) {
759
- case 'text':
760
- buf += c;
761
- if (c === '<') { state = 'tag-open'; tagStart = buf.length - 1; }
762
- break;
763
- case 'tag-open':
764
- buf += c;
765
- if (c === '!') state = 'bang-1';
766
- else if (c === '/') { state = 'tag-name'; currentTag = ''; }
767
- else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
768
- else state = 'text';
769
- break;
770
- case 'bang-1':
771
- buf += c;
772
- state = c === '-' ? 'bang-dash' : 'tag-name';
773
- break;
774
- case 'bang-dash':
775
- buf += c;
776
- if (c === '-') { state = 'comment'; commentDashes = 0; }
777
- else state = 'tag-name';
778
- break;
779
- case 'comment':
780
- buf += c;
781
- if (c === '-') commentDashes += 1;
782
- else if (c === '>' && commentDashes >= 2) { state = 'text'; commentDashes = 0; }
783
- else commentDashes = 0;
784
- break;
785
- case 'tag-name':
786
- buf += c;
787
- if (c === '>') {
788
- handleTagEnd(true);
789
- } else if (/\s/.test(c)) state = 'in-tag';
790
- else currentTag += c.toLowerCase();
791
- break;
792
- case 'in-tag':
793
- buf += c;
794
- if (c === '>') {
795
- handleTagEnd(true);
796
- } else if (!/\s/.test(c) && c !== '/') {
797
- state = 'attr-name';
798
- attrName = c;
799
- attrStart = buf.length - 1;
800
- }
801
- break;
802
- case 'rawtext':
803
- buf += c;
804
- rawTail = (rawTail + c.toLowerCase()).slice(-9);
805
- if (rawTail.endsWith('</script>') || rawTail.endsWith('</style>')) {
806
- state = 'text';
807
- rawTail = '';
808
- currentTag = '';
809
- }
810
- break;
811
- case 'attr-name':
812
- if (c === '=') { state = 'after-eq'; buf += c; }
813
- else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
814
- else if (c === '>') { state = 'text'; attrName = ''; buf += c; handleTagEnd(false); }
815
- else { attrName += c; buf += c; }
816
- break;
817
- case 'after-eq':
818
- if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; buf += c; }
819
- else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
820
- else if (c === '>') { state = 'text'; attrName = ''; buf += c; handleTagEnd(false); }
821
- else { state = 'attr-unquoted'; buf += c; }
822
- break;
823
- case 'attr-unquoted':
824
- if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
825
- else if (c === '>') { state = 'text'; attrName = ''; buf += c; handleTagEnd(false); }
826
- else buf += c;
827
- break;
828
- case 'attr-quoted':
829
- buf += c;
830
- if (c === attrQuote) { state = 'in-tag'; attrName = ''; }
831
- break;
832
- }
833
- }
834
-
835
- // Flush the buffer before processing the value hole: but only when
836
- // we're in text state (in attribute states we may need the buffer for
837
- // backtracking).
838
- if (i < values.length) {
839
- let val = values[i];
840
- if (val && typeof /** @type any */ (val).then === 'function') {
841
- val = await val;
842
- }
843
- // Same unwrap as the buffered machine above (#1443). The two machines
844
- // must emit identical bytes, so this is not optional here.
845
- if (isLive(val)) val = /** @type any */ (val).value;
846
- if (state === 'comment') {
847
- buf += String(val ?? '');
848
- commentDashes = 0;
849
- } else if (state === 'rawtext') {
850
- buf += String(val ?? '');
851
- rawTail = '';
852
- } else if (state === 'text') {
853
- // Flush the buffered static content before streaming the value.
854
- if (buf) { controller.enqueue(buf); buf = ''; }
855
- await streamRender(val, ctx, controller);
856
- } else if (state === 'after-eq') {
857
- const prefix = attrName[0];
858
- const name = attrName.slice(1);
859
- const kind = BINDING_PREFIXES[prefix];
860
- if (kind === 'event' || kind === 'prop') {
861
- // Guard `prop` ONLY, matching the buffered machine. An `@action`
862
- // event binding is dropped here and never stringified, and a
863
- // function is the LEGITIMATE value for one (`<my-el @action=${fn}>`
864
- // listens for an `action` event), so refusing it would be a false
865
- // positive. `.action` differs where the property REFLECTS: on a form
866
- // (and `.formAction` on a button or input) the client assignment
867
- // writes the source into the DOM, so refusing at SSR keeps a page
868
- // from rendering clean on the server and throwing on hydration.
869
- // Elsewhere the property reflects nothing and a function stays
870
- // legal, which is why the check below is gated on a hyphen-free
871
- // tag. A custom element is excluded for a different reason than
872
- // "it does not reflect": a prop declared `reflect: true` DOES
873
- // reflect there, and used to write the source from its own setter.
874
- // #1169 guards that at the setter (a function removes the
875
- // attribute; an array carrying one does too, except under an
876
- // Object/Array type, where JSON drops it losslessly), so it
877
- // needs no commit-site check here.
878
- //
879
- // Unlike the buffered machine this drops EVERY prop, including
880
- // `<webjs-suspense .fallback>`: there is no injectDSD pre-pass on
881
- // this path (it is reached only through `renderToStream(v, { ssr:
882
- // false })`), so there is no consumer for a `data-webjs-fallback`
883
- // and emitting one would put an attribute in the markup that nothing
884
- // reads.
885
- if (kind === 'prop' && !currentTag.includes('-')) {
886
- assertNotFunctionReflectedActionProp(val, name, currentTag);
887
- notePropAttr(name, currentTag);
888
- }
889
- buf = buf.slice(0, attrStart);
890
- state = 'in-tag';
891
- attrName = '';
892
- } else if (kind === 'bool') {
893
- // A boolean binding stringifies nothing, so this never leaked, but
894
- // `?action=${fn}` is meaningless in every case (a truthy function
895
- // emits a bare `action=""`), and refusing it keeps the rule the docs
896
- // state true for every sigil rather than true only when quoted.
897
- assertNotFunctionActionAttr(val, name, currentTag);
898
- buf = buf.slice(0, attrStart);
899
- if (val) buf += `${name}=""`;
900
- state = 'in-tag';
901
- attrName = '';
902
- } else if (isBoundFormAction(val, attrName, currentTag)) {
903
- noteActionHole(attrName, currentTag);
904
- // The SAME bindings as the buffered renderer (#1155, #1207), in the
905
- // second machine, so `renderToStream(v, { ssr: false })` emits an
906
- // identical form rather than refusing one the page renderer accepts.
907
- if (currentTag === 'form') {
908
- pendingActionId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
909
- buf = buf.slice(0, attrStart).replace(/\s+$/, '');
910
- } else {
911
- assertSingleSubmitterAction(pendingSubmitterTag != null, currentTag);
912
- const attrs = parseStartTagAttrs(buf.slice(tagStart));
913
- if (attrs.has('name')) assertSubmitterHasNoName(attrs.get('name') || '', currentTag, false);
914
- if (attrs.has('value')) assertSubmitterHasNoValue(currentTag);
915
- if (attrs.has('form')) assertSubmitterHasNoFormAttribute(currentTag);
916
- const subId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
917
- pendingSubmitterTag = currentTag;
918
- buf = buf.slice(0, attrStart) + `name="${FORM_ACTION_FIELD}" value="${escapeAttr(subId)}"`;
919
- }
920
- state = 'in-tag';
921
- attrName = '';
922
- } else {
923
- noteActionHole(attrName, currentTag);
924
- // The SAME guard as the buffered renderer above. This is a second,
925
- // independent state machine, so it inherits nothing from that one;
926
- // a change to the rule has to land in both. Reached only via
927
- // `renderToStream(v, { ssr: false })`, which no page render uses, so
928
- // this covers the public API surface rather than a page leak.
929
- assertNotFunctionActionAttr(val, attrName, currentTag);
930
- // #1573: the same omission rule as the buffered renderer above.
931
- const written = attrHoleValue(attrName, val);
932
- if (written === null) buf = buf.slice(0, attrStart);
933
- else buf += `"${escapeAttr(written)}"`;
934
- state = 'in-tag';
935
- attrName = '';
936
- }
937
- } else if (state === 'attr-quoted' || state === 'attr-unquoted') {
938
- assertNotFunctionActionAttr(val, attrName, currentTag);
939
- buf += escapeAttr(String(val ?? ''));
940
- }
941
- }
942
- }
943
-
944
- // Flush any remaining buffer content.
945
- if (buf) controller.enqueue(buf);
21
+ export function streamRender(value, ctx, controller) {
22
+ return streamValue(value, ctx, controller);
946
23
  }