@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,445 +1,21 @@
1
- import { lookup, allTags } from '../registry.js';
2
- import { stylesToString, isCSS } from '../css.js';
3
- import { unsafeHTML } from '../directives.js';
4
- import { render, defaultSSRErrorTemplate, browserMemberHint } from './template-renderer.js';
5
- import { escapeRegex, findClosingTagInString, inertAt, inertRanges } from './html-scan.js';
6
- import { decodeAttrEntities } from './text.js';
7
- import {
8
- appendReflectedAttrs, applyAttrsToInstance, consumePropAttrs, parseAttrs, seedServerAttrs,
9
- withHostMarker,
10
- } from './attrs.js';
11
- import { partitionAuthoredBySlot, substituteSlotsInRender } from './slots.js';
1
+ import { expandHtml } from './walk.js';
12
2
 
13
3
  /**
14
- * Declarative Shadow DOM injection: the SSR pass that walks rendered HTML for
15
- * registered custom elements, instantiates each one, renders it, and splices
16
- * the result back in.
17
- *
18
- * The string-scanning primitives live in `html-scan.js`, the attribute plumbing
19
- * in `attrs.js`, the light-DOM slot projection in `slots.js`, and name-case /
20
- * entity decoding in `text.js`. This module owns the walk itself and the
21
- * `<webjs-suspense>` pass that runs ahead of it.
22
- *
23
- * The dependency on `template-renderer.js` is one-way. That module reaches back
24
- * only for `isRawtextTag` and `kebabCase`, and takes both from the leaves
25
- * above, which is what keeps this directory acyclic.
26
- */
27
-
28
- /**
29
- * Scan an HTML string for registered custom elements and inject
30
- * Declarative Shadow DOM (`<template shadowrootmode="open">`).
31
- * Awaits each component's render() so async components are fully resolved.
4
+ * Declarative Shadow DOM injection over an HTML STRING: the string-input path
5
+ * of the renderer. Since #1646 a page's custom elements are expanded inside
6
+ * the template walk itself (`walk.js` + `expand.js`), and this entry remains
7
+ * for markup that only exists as a string, which `streamSuspenseBoundaries`
8
+ * uses for a streamed boundary's children. It tokenizes the string through
9
+ * the same compiler the templates go through, so a tag inside a comment, a
10
+ * raw-text element or an attribute value is text here too (#1128).
32
11
  *
33
12
  * @param {string} html
34
- * @param {SuspenseCtx} [ctx]
13
+ * @param {import('./stream.js').SuspenseCtx} [ctx]
35
14
  * @param {any[]} [ancestors]
36
15
  * @param {boolean} [dev] server dev flag, threaded to the per-component error
37
16
  * template for prod-silence (#483); undefined falls back to NODE_ENV
38
17
  * @returns {Promise<string>}
39
18
  */
40
- export async function injectDSD(html, ctx, ancestors = [], dev) {
41
- // Resolve <webjs-suspense> boundaries first (#471): in a streaming context
42
- // each becomes a fallback placeholder now, with its children pushed for
43
- // out-of-order streaming; without a streaming context the children render
44
- // inline (blocking). Run before the custom-element walk so a streamed
45
- // boundary's children leave the main flow and are not double-processed.
46
- html = await processSuspenseElements(html, ctx, ancestors, dev);
47
- const tags = allTags();
48
- if (!tags.length) return html;
49
- // Sort longest tag name first so the regex alternation tries the most
50
- // specific match before its prefixes. Combined with the (?=[\s>/])
51
- // lookahead this prevents `my-card` from spuriously matching the prefix
52
- // of `<my-card-2>` (or `slot-ssr-1` matching `<slot-ssr-14>`, etc).
53
- // Attribute section is "anything that isn't `>`, with quoted values as a
54
- // single unit" so slashes in URL-valued attrs (e.g. then="/dashboard") don't
55
- // prevent the match. Non-greedy so self-closing `/>` still captures into the
56
- // third group.
57
- const sortedTags = [...tags].sort((a, b) => b.length - a.length);
58
- const pattern = new RegExp(
59
- `<(${sortedTags.map(escapeRegex).join('|')})(?=[\\s>/])((?:"[^"]*"|'[^']*'|[^>])*?)(/?)>`,
60
- 'g'
61
- );
62
- /** @type {{start:number, end:number, text:string}[]} */
63
- const edits = [];
64
- // A tag name inside a comment, a script, a style, RCDATA, or another tag's
65
- // attribute value is text, not an element (#1128).
66
- const inert = inertRanges(html);
67
- const isInert = inertAt(inert);
68
- for (const m of html.matchAll(pattern)) {
69
- const [match, tag, attrs, selfClose] = m;
70
- const Cls = lookup(tag);
71
- if (!Cls) continue;
72
- if (isInert(m.index)) continue;
73
- // Track which custom elements actually appeared: used by SSR to emit
74
- // `<link rel="modulepreload">` hints for their module URLs.
75
- if (ctx && ctx.usedComponents) ctx.usedComponents.add(tag);
76
- let opening = selfClose ? `<${tag}${attrs}>` : match;
77
- // Hoisted so the per-component error boundary (#469) can ask the failed
78
- // instance for its renderError() output.
79
- let instance = null;
80
- try {
81
- const isShadow = /** @type any */ (Cls).shadow === true;
82
- instance = new /** @type any */ (Cls)();
83
- // Thread the ancestor chain (the enclosing custom-element instances)
84
- // so the server element shim's closest() can resolve a parent at SSR.
85
- // Set before performServerUpdate so a willUpdate() that reads a parent
86
- // via closest() sees the chain. Each child recursion below extends it.
87
- instance.__ssrTag = tag;
88
- instance.__ssrAncestors = ancestors;
89
- const attrMap = parseAttrs(attrs);
90
- // Decode `data-webjs-prop-*` attributes first (rich-typed values
91
- // emitted for `.prop=${val}` bindings in the parent template),
92
- // then coerce the ordinary string attributes by `static
93
- // properties` type. Property bindings take priority on a name
94
- // collision because they preserve the original JS reference.
95
- const propValues = consumePropAttrs(attrMap);
96
- // Names already present in the source opening tag (including the
97
- // data-webjs-prop-* bindings, which were stripped from attrMap above
98
- // but remain in the emitted `attrs` string). Reflected/added
99
- // attributes are appended only when their name is NOT already here, so
100
- // existing output stays byte-identical when nothing reflects.
101
- const presentAttrNames = new Set(Object.keys(parseAttrs(attrs)).map((n) => n.toLowerCase()));
102
- // Seed the server attribute shim so `this.getAttribute(...)` /
103
- // `this.hasAttribute(...)` in willUpdate / render read the source
104
- // attributes (a lit muscle-memory pattern) instead of reading empty.
105
- seedServerAttrs(instance, attrMap);
106
- applyAttrsToInstance(instance, attrMap, Cls);
107
- for (const [k, v] of Object.entries(propValues)) instance[k] = v;
108
- // Extract the authored inner HTML BEFORE the render (the injectDSD
109
- // reorder): the source scan needs no render output, and hoisting it lets
110
- // the light-DOM branch below project the authored children into the
111
- // rendered slots. The shadow branch is a read-only peek (its authored
112
- // children stay in place for native projection, and its edit keeps the
113
- // old end).
114
- let authoredInner = '';
115
- let closeEnd = m.index + match.length;
116
- if (!selfClose) {
117
- const innerStart = m.index + match.length;
118
- const closeIdx = findClosingTagInString(html, innerStart, tag, inert);
119
- if (closeIdx !== -1) {
120
- authoredInner = html.slice(innerStart, closeIdx);
121
- const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
122
- const tail = html.slice(closeIdx);
123
- const closeMatch = closeRe.exec(tail);
124
- const closeLen = closeMatch ? closeMatch[0].length : `</${tag}>`.length;
125
- closeEnd = closeIdx + closeLen;
126
- } else {
127
- // Unclosed in source. Take rest of html as authored content
128
- // and synthesize a closing tag on output.
129
- authoredInner = html.slice(innerStart);
130
- closeEnd = html.length;
131
- }
132
- }
133
- const partitioned = partitionAuthoredBySlot(authoredInner);
134
- // Run the pre-render lifecycle (willUpdate, controllers' hostUpdate,
135
- // then reflect reflect:true props) so derived state computed there is
136
- // correct in the SSR'd HTML, matching how lit runs the update cycle at
137
- // SSR. WebComponent instances expose performServerUpdate; bare
138
- // Base-extending kit components (no lifecycle) do not, so it is guarded.
139
- if (typeof instance.performServerUpdate === 'function') instance.performServerUpdate();
140
- let tpl = instance.render ? instance.render() : '';
141
- if (tpl && typeof tpl.then === 'function') tpl = await tpl;
142
- // Surface attributes the component set up to and including render()
143
- // that were not already in the source tag: reflected reflect:true
144
- // props, an explicit this.setAttribute in the constructor / willUpdate,
145
- // or a host-attribute mutation inside render() itself (a light-DOM
146
- // compound-component pattern, e.g. this.dataset.state / this.className /
147
- // this.hidden on the host). Reading after render() captures all three.
148
- // Appending keeps the original tag byte-identical when nothing changed.
149
- opening = appendReflectedAttrs(opening, instance, presentAttrNames);
150
- // Mark LIGHT-DOM component hosts so the framework default
151
- // `@layer webjs-host { :where([data-wj-host]) { display: block } }`
152
- // (injected once in the document head) applies at first paint. A custom
153
- // element is `display:inline` by default, which collapses a component used
154
- // as a block container (a board / card) until an author style intervenes.
155
- // The low-priority `@layer` keeps it overridable by any author style,
156
- // INCLUDING Tailwind's layered utilities (`class="flex"` wins). Emitted
157
- // uniformly regardless of elision, so the elision on-vs-off differential is
158
- // preserved.
159
- //
160
- // Shadow hosts are NOT marked: a document-level rule targeting the host
161
- // beats the shadow tree's own `:host { display: … }` (the encapsulation-
162
- // context criterion outranks both layer and specificity for normal
163
- // declarations), so marking them would silently override the shadow
164
- // author's `:host` display. Shadow components set their own host display
165
- // via `:host` in `static styles` (the idiomatic mechanism), which the
166
- // framework must not clobber.
167
- if (!isShadow) opening = withHostMarker(opening);
168
- // Render the template to HTML. injectDSD recurses on the result so
169
- // nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
170
- // get their own DSD pass.
171
- // This is a SEPARATE render pass over one component's own template,
172
- // driven by walking the already-emitted HTML, so it has no idea whether
173
- // the host tag sits inside a `<form>`. It does not need to: a bound
174
- // submitter carries its whole submission (#1307), so nothing here asks
175
- // about an enclosing form.
176
- const rawInner = await render(tpl, ctx);
177
-
178
- if (isShadow) {
179
- // Shadow DOM: native <slot> stays as-is in the DSD template. The
180
- // browser handles projection from the host's light-DOM children
181
- // into the shadow tree natively. No framework substitution here.
182
- const innerProcessed = await injectDSD(rawInner, ctx, [...ancestors, instance], dev);
183
- const rawStyles = /** @type any */ (Cls).styles;
184
- const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
185
- const styleStr = stylesToString(styleList);
186
- edits.push({
187
- start: m.index,
188
- end: m.index + match.length,
189
- text: `${opening}<template shadowrootmode="open">${styleStr}${innerProcessed}</template>`,
190
- });
191
- } else {
192
- // Light DOM. When the component has a non-empty rendered template,
193
- // run the slot pipeline so behaviour matches shadow DOM: authored
194
- // children are visible only where projected through <slot>; any
195
- // child without a matching slot is dropped.
196
- //
197
- // When rendered template is empty (Base-extending decorator
198
- // components that have no render() method, or render() that
199
- // returns an empty template), the host acts as a transparent
200
- // wrapper: authored children stay in place adjacent to the
201
- // (empty) hydration marker. This preserves the kit's
202
- // decorator-pattern components (those extending Base from the
203
- // ui package's lib/utils.ts) without forcing a render() rewrite.
204
- const renderedIsEmpty = rawInner.trim() === '';
205
- if (renderedIsEmpty) {
206
- edits.push({
207
- start: m.index,
208
- end: m.index + match.length,
209
- text: `${opening}<!--webjs-hydrate-->`,
210
- });
211
- continue;
212
- }
213
- //
214
- // The authored inner HTML + slot partition were extracted BEFORE
215
- // the render (the #1015 reorder, see above), so here:
216
- // 1. Substitute each <slot> in the rendered output with a
217
- // framework-marked <slot data-webjs-light data-projection
218
- // ="actual|fallback"> element carrying projection or
219
- // fallback content per first-wins rule.
220
- // 2. Recursively run injectDSD on the substituted output so
221
- // nested custom elements (inside projected children) get
222
- // their own DSD pass.
223
- const innerWithSlots = substituteSlotsInRender(rawInner, partitioned, tag);
224
- const innerProcessed = await injectDSD(innerWithSlots, ctx, [...ancestors, instance], dev);
225
- edits.push({
226
- start: m.index,
227
- end: closeEnd,
228
- text: `${opening}<!--webjs-hydrate-->${innerProcessed}</${tag}>`,
229
- });
230
- }
231
- } catch (e) {
232
- const hint = browserMemberHint(e);
233
- if (hint) {
234
- console.error(
235
- `[webjs] SSR failed for <${tag}>: ${hint} It was touched in the component's constructor or render(), which run during SSR. Move browser-only work to connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor only from server-known inputs (attributes / props).`,
236
- e,
237
- );
238
- } else {
239
- console.error(`[webjs] SSR failed for <${tag}>:`, e);
240
- }
241
- // Per-component error isolation (#469). A render that throws (most
242
- // commonly a rejected `await getData()` in an async render, but any
243
- // render throw) is caught HERE, per component: the loop continues so
244
- // siblings render normally, and this element renders a component-scoped
245
- // error state instead of bubbling to the route error.js or leaving its
246
- // raw, unprocessed children in the output. renderError() customizes the
247
- // error UI; the default surfaces the message in dev and renders an empty
248
- // (silent, isolated) element in prod so no internal detail leaks.
249
- const err = e instanceof Error ? e : new Error(String(e));
250
- let errorInner = '';
251
- try {
252
- let errTpl;
253
- if (instance && typeof instance.renderError === 'function') {
254
- errTpl = instance.renderError(err);
255
- }
256
- if (errTpl === undefined) errTpl = defaultSSRErrorTemplate(tag, err, dev);
257
- errorInner = await render(errTpl, ctx);
258
- if (errorInner.trim()) {
259
- errorInner = await injectDSD(errorInner, ctx, instance ? [...ancestors, instance] : ancestors, dev);
260
- }
261
- } catch (renderErrorThrew) {
262
- console.error(`[webjs] renderError() for <${tag}> also threw:`, renderErrorThrew);
263
- errorInner = '';
264
- }
265
- // Replace the element (opening tag through its matching close) with the
266
- // error state plus a hydration marker, so the client error boundary
267
- // (component.js renderError) can take over on hydration.
268
- let closeEnd = m.index + match.length;
269
- if (!selfClose) {
270
- const innerStart = m.index + match.length;
271
- const closeIdx = findClosingTagInString(html, innerStart, tag, inert);
272
- if (closeIdx !== -1) {
273
- const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
274
- const cm = closeRe.exec(html.slice(closeIdx));
275
- closeEnd = closeIdx + (cm ? cm[0].length : `</${tag}>`.length);
276
- } else {
277
- closeEnd = html.length;
278
- }
279
- }
280
- // A shadow component renders into a shadow root on the client, so its
281
- // SSR error state must ride a DSD template too (matching the success
282
- // path), not land in light DOM. Otherwise the client renders the error
283
- // into the shadow root while the light error box lingers underneath.
284
- const isShadowErr = /** @type any */ (Cls).shadow === true;
285
- // Mark the LIGHT host here too, so a component whose SSR render() throws
286
- // paints its error state as display:block (not the inline default),
287
- // matching the success path. When an `async render()` rejects, it throws
288
- // before the success-path withHostMarker (above) ran, so `opening` is still
289
- // unmarked; when a later template render throws, the success marker already
290
- // ran and this call is a no-op (withHostMarker is idempotent). Shadow hosts
291
- // stay unmarked (their :host must win).
292
- if (!isShadowErr) opening = withHostMarker(opening);
293
- let text;
294
- if (isShadowErr) {
295
- const rawStyles = /** @type any */ (Cls).styles;
296
- const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
297
- const styleStr = stylesToString(styleList);
298
- text = `${opening}<template shadowrootmode="open">${styleStr}${errorInner}</template>`;
299
- } else {
300
- text = `${opening}<!--webjs-hydrate-->${errorInner}</${tag}>`;
301
- }
302
- edits.push({ start: m.index, end: closeEnd, text });
303
- }
304
- }
305
- if (!edits.length) return html;
306
-
307
- // Drop edits whose range lives inside an earlier edit's range. This
308
- // happens when an outer custom element with <slot> in its render takes
309
- // an edit that spans its opening + closing tags (covering inner custom
310
- // elements among authored children); the inner matches were enumerated
311
- // independently against the original html, but those inner elements
312
- // are processed by the recursive injectDSD call on innerWithSlots.
313
- // Keeping both edits would double-process them and corrupt the output.
314
- // A consequence: a nested instance's render() runs once per chain depth
315
- // (the discarded top-level pass sees an empty ancestor chain, so its
316
- // closest() reads null; the kept recursive pass has the real chain). The
317
- // kept pass is the only output, and closest() is a read, so render() must
318
- // stay pure at SSR (the standard SSR contract), not branch on side effects.
319
- edits.sort((a, b) => a.start - b.start);
320
- /** @type {{start:number, end:number, text:string}[]} */
321
- const filtered = [];
322
- let consumedTo = -1;
323
- for (const e of edits) {
324
- if (e.start >= consumedTo) {
325
- filtered.push(e);
326
- consumedTo = e.end;
327
- }
328
- }
329
- // Apply edits from last to first so indices stay stable.
330
- let out = html;
331
- for (let i = filtered.length - 1; i >= 0; i--) {
332
- const { start, end, text } = filtered[i];
333
- out = out.slice(0, start) + text + out.slice(end);
334
- }
335
- return out;
336
- }
337
-
338
- // ---------------------------------------------------------------------------
339
- // Slot SSR helpers
340
- // ---------------------------------------------------------------------------
341
-
342
- /**
343
- * Resolve `<webjs-suspense>` boundaries in an HTML string (#471). For each
344
- * top-level boundary (nested ones are handled by the recursive injectDSD that
345
- * processes a boundary's streamed children):
346
- *
347
- * - Streaming (a SuspenseCtx is present): emit the boundary as
348
- * `<webjs-suspense id="sN">FALLBACK</webjs-suspense>` and push the raw inner
349
- * children to `ctx.pending` (wrapped in `unsafeHTML` so the streaming pass
350
- * renders them as HTML, not escaped text, then runs injectDSD over them).
351
- * `streamSuspenseBoundaries` later streams the resolved children as a
352
- * `<template data-webjs-resolve="sN">` plus the swap script. Multiple
353
- * boundaries resolve via `Promise.all`, so their data fetches run
354
- * concurrently. The placeholder is the boundary's `.fallback`
355
- * (carried as `data-webjs-fallback`); a boundary without one shows empty.
356
- * - Blocking (no ctx): render the children inline now and drop the fallback,
357
- * so a non-streaming `renderToString` returns the real content. The
358
- * `<webjs-suspense>` wrapper stays as an inert inline element.
359
- *
360
- * @param {string} html
361
- * @param {SuspenseCtx} [ctx]
362
- * @param {any[]} [ancestors]
363
- * @param {boolean} [dev] server dev flag for prod-silence of a throwing
364
- * component in an inline (ctx-absent) boundary (#483)
365
- * @returns {Promise<string>}
366
- */
367
- export async function processSuspenseElements(html, ctx, ancestors = [], dev) {
368
- if (html.indexOf('<webjs-suspense') === -1) return html;
369
- const OPEN = /<webjs-suspense((?:"[^"]*"|'[^']*'|[^>])*?)>/i;
370
- // A commented-out boundary is text, not an element (#1128). This scanner is
371
- // the reason the comment fix cannot live in injectDSD alone: it runs FIRST
372
- // and hands the boundary's children to a fresh injectDSD as a standalone
373
- // string, which has no idea those bytes came from inside a comment. Under
374
- // streaming it is worse than a stray render, because the children's data
375
- // fetches run and the swap script targets an id that only exists inside a
376
- // comment, so it can never resolve. Ranges are computed against the FULL
377
- // input once, and `consumed` maps the shrinking `rest` back onto it.
378
- const inert = inertRanges(html);
379
- const isInert = inertAt(inert);
380
- let consumed = 0;
381
- let result = '';
382
- let rest = html;
383
- // Bounded loop: each iteration consumes at least the opening tag.
384
- for (let guard = 0; guard < 10000; guard++) {
385
- const m = OPEN.exec(rest);
386
- if (!m) {
387
- result += rest;
388
- break;
389
- }
390
- if (isInert(consumed + m.index)) {
391
- // Emit through the end of this match and keep scanning after it.
392
- const skipTo = m.index + m[0].length;
393
- result += rest.slice(0, skipTo);
394
- rest = rest.slice(skipTo);
395
- consumed += skipTo;
396
- continue;
397
- }
398
- const openStart = m.index;
399
- const openEnd = m.index + m[0].length;
400
- result += rest.slice(0, openStart);
401
- const attrs = m[1] || '';
402
- const fbMatch = /data-webjs-fallback="([^"]*)"/i.exec(attrs);
403
- const fallbackHtml = fbMatch ? decodeAttrEntities(fbMatch[1]) : '';
404
-
405
- // Pass the FULL-input ranges shifted into `rest` coordinates rather than
406
- // letting the helper re-tokenize the suffix. `rest` can begin mid-comment
407
- // or mid-raw-text after the skip path above, and a tokenizer restarted
408
- // there is in the wrong state: a text-only opener named later in that same
409
- // comment would read as a real element and mark everything to EOF inert,
410
- // so the boundary's real close tag was skipped and the trailing markup
411
- // folded into the boundary. The shifted view keeps the full-string truth,
412
- // including a first range that starts before `rest` does.
413
- const shifted = [];
414
- for (const [s, e] of inert) {
415
- if (e <= consumed) continue;
416
- shifted.push([Math.max(0, s - consumed), e - consumed]);
417
- }
418
- const closeIdx = findClosingTagInString(rest, openEnd, 'webjs-suspense', shifted);
419
- let inner;
420
- let afterClose;
421
- if (closeIdx === -1) {
422
- inner = rest.slice(openEnd);
423
- afterClose = '';
424
- } else {
425
- inner = rest.slice(openEnd, closeIdx);
426
- const cm = /<\/webjs-suspense\s*>/i.exec(rest.slice(closeIdx));
427
- afterClose = rest.slice(closeIdx + (cm ? cm[0].length : '</webjs-suspense>'.length));
428
- }
429
-
430
- if (ctx) {
431
- const id = `s${ctx.nextId++}`;
432
- // Raw children stream in later. unsafeHTML so the streaming pass emits
433
- // the markup verbatim (then injectDSD runs over it, resolving the async
434
- // components and any nested boundaries).
435
- ctx.pending.push({ id, promise: Promise.resolve(unsafeHTML(inner)) });
436
- result += `<webjs-suspense id="${id}">${fallbackHtml}</webjs-suspense>`;
437
- } else {
438
- const innerProcessed = await injectDSD(inner, ctx, ancestors, dev);
439
- result += `<webjs-suspense>${innerProcessed}</webjs-suspense>`;
440
- }
441
- consumed += rest.length - afterClose.length;
442
- rest = afterClose;
443
- }
444
- return result;
19
+ export function injectDSD(html, ctx, ancestors = [], dev) {
20
+ return expandHtml(html, ctx, { ancestors, dev });
445
21
  }
@@ -0,0 +1,84 @@
1
+ import { html } from '../html.js';
2
+
3
+ /**
4
+ * The SSR error helpers: the prod gate, the browser-member hint, and the
5
+ * default per-component error state (#469, #483). A leaf, so `expand.js`
6
+ * can take them without reaching back through the renderer entry.
7
+ */
8
+
9
+ /** True in a production build (no dev error surfacing). */
10
+ export function isProd() {
11
+ return typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production';
12
+ }
13
+
14
+ /**
15
+ * If `e` is the recognisable failure of touching a browser-only API during
16
+ * SSR (a `ReferenceError` for a browser global, or a `TypeError` calling an
17
+ * HTMLElement method that does not exist on the bare server-side instance),
18
+ * return an actionable, member-naming hint; otherwise null.
19
+ * @param {unknown} e
20
+ * @returns {string | null}
21
+ */
22
+ export function browserMemberHint(e) {
23
+ const msg = e && typeof (/** @type any */ (e).message) === 'string' ? /** @type any */ (e).message : '';
24
+ // Match on a word boundary, NOT end-of-string: V8 (Node) ends the message at
25
+ // "is not defined" / "is not a function", but JSC (Bun) appends a detail
26
+ // clause (e.g. ". (In '({}).querySelector(\"p\")', '...' is undefined)"), so an
27
+ // anchored `$` would miss the Bun message and drop the actionable hint.
28
+ let m = /^(\w+) is not defined\b/.exec(msg);
29
+ if (e instanceof ReferenceError && m && SSR_BROWSER_GLOBALS.has(m[1])) {
30
+ return `\`${m[1]}\` is a browser-only global and is undefined during SSR.`;
31
+ }
32
+ m = /\.(\w+) is not a function\b/.exec(msg);
33
+ if (e instanceof TypeError && m && SSR_HTMLELEMENT_METHODS.has(m[1])) {
34
+ return `\`${m[1]}\` is an HTMLElement method that does not exist on the server-side component instance during SSR.`;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ // Browser-only names whose absence during SSR produces a recognisable error.
40
+ // Mirrors the `no-browser-globals-in-render` webjs check rule, which catches
41
+ // these at edit time; this turns the runtime SSR crash into the same guidance.
42
+ const SSR_BROWSER_GLOBALS = new Set([
43
+ 'document', 'window', 'localStorage', 'sessionStorage', 'navigator',
44
+ 'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
45
+ 'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
46
+ ]);
47
+
48
+ // Attribute methods (get/set/has/remove/toggleAttribute), the event methods
49
+ // (add/removeEventListener, dispatchEvent), and attachInternals are backed by
50
+ // the server-side element shim and work at SSR, so they are NOT listed here.
51
+ // What remains is the genuinely browser-only HTMLElement surface that still
52
+ // has no server stand-in and throws at SSR.
53
+ const SSR_HTMLELEMENT_METHODS = new Set([
54
+ 'attachShadow', 'querySelector', 'querySelectorAll',
55
+ 'getBoundingClientRect', 'focus', 'blur', 'scrollIntoView',
56
+ ]);
57
+
58
+ /**
59
+ * Default component-scoped error state for an async/sync render that threw
60
+ * during SSR, used when the component does not define renderError() (#469).
61
+ * Dev surfaces the tag + message loudly so the failure is obvious; prod
62
+ * renders an empty (silent, isolated) element so no internal detail leaks.
63
+ *
64
+ * The prod-silence signal is the SERVER's `dev` flag, threaded through the SSR
65
+ * render context (#483). WebJs keys prod on the CLI `dev` flag, not `NODE_ENV`,
66
+ * and `webjs start` does not export `NODE_ENV=production`, so a bare prod launch
67
+ * would otherwise leak the message. When `dev` is undefined (a context-free
68
+ * `renderToString` with no server signal, e.g. a bare unit test) it falls back
69
+ * to `isProd()` / `NODE_ENV`, preserving the prior behaviour for that path.
70
+ *
71
+ * @param {string} tag
72
+ * @param {Error} err
73
+ * @param {boolean} [dev] server dev flag; undefined falls back to NODE_ENV
74
+ * @returns {unknown} a TemplateResult (dev) or '' (prod)
75
+ */
76
+ export function defaultSSRErrorTemplate(tag, err, dev) {
77
+ const surface = dev === undefined ? !isProd() : !!dev;
78
+ if (!surface) return '';
79
+ const msg = err && err.message ? err.message : String(err);
80
+ 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">
81
+ <strong>&lt;${tag}&gt; failed to render</strong>
82
+ <div style="margin-top:4px;white-space:pre-wrap">${msg}</div>
83
+ </div>`;
84
+ }