@webjsdev/core 0.7.63 → 0.7.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,319 @@
1
+ import { stylesToString, isCSS } from '../css.js';
2
+ import { unsafeHTML } from '../directives.js';
3
+ import { escapeAttr } from '../escape.js';
4
+ import { defaultSSRErrorTemplate, browserMemberHint } from './errors.js';
5
+ import {
6
+ appendReflectedAttrs, applyAttrsToInstance, consumePropAttrs, parseAttrs, seedServerAttrs,
7
+ withHostMarker,
8
+ } from './attrs.js';
9
+
10
+ /**
11
+ * Custom-element expansion inside the op walk (#1646): what `injectDSD`
12
+ * used to do by rescanning the rendered string, moved here and driven by the
13
+ * walker's frames instead. `beginComponent` runs at a registered element's
14
+ * `>` (instantiate, seed and apply the source attributes, run the pre-render
15
+ * lifecycle, call `render()`, surface reflected attributes, mark a light
16
+ * host), and `finishComponent` runs at its end tag with the captured
17
+ * authored children, walking the instance's template with the light-DOM slot
18
+ * projection in scope or wrapping it in a Declarative Shadow DOM template.
19
+ * Every instance renders exactly once.
20
+ *
21
+ * The attribute plumbing stays in `attrs.js`, the light-DOM slot emission is
22
+ * `finishSlot` below (what `substituteSlotsInRender` emitted per slot), and
23
+ * `finishSuspense` is the `<webjs-suspense>` boundary (#471).
24
+ */
25
+
26
+ // Stops at the first non-whitespace character; `trim()` copied the whole
27
+ // rendered template per component to answer the same question.
28
+ const NON_BLANK = /\S/;
29
+
30
+ /**
31
+ * Instantiate and pre-render a registered custom element at its start tag.
32
+ * A throw anywhere here is caught into the frame (#469): the walker still
33
+ * consumes the authored children, and `finishComponent` emits the error state
34
+ * in the element's place while siblings render normally.
35
+ *
36
+ * @param {import('./walk.js').Walker} w
37
+ * @param {string} tag
38
+ * @param {any} Cls
39
+ * @param {string} opening the opening tag as written, `<tag attrs>`
40
+ * @param {string} attrs the attribute text between the tag name and `>`
41
+ * @returns {any} the frame; `pending` is set when `render()` returned a
42
+ * promise, and the caller awaits `settleRender` before using it
43
+ */
44
+ export function beginComponent(w, tag, Cls, opening, attrs) {
45
+ if (w.ctx && w.ctx.usedComponents) w.ctx.usedComponents.add(tag);
46
+ const isShadow = Cls.shadow === true;
47
+ const frame = {
48
+ kind: 'component', tag, Cls, isShadow, instance: null, opening, tpl: undefined,
49
+ pending: /** @type {Promise<unknown> | null} */ (null), presentAttrNames: /** @type {Set<string> | null} */ (null),
50
+ error: /** @type {unknown} */ (null), outer: '',
51
+ // Light-DOM children partition: element depth, the open top-level
52
+ // segment, and the named-slot segments seen so far.
53
+ partition: !isShadow, depth: 0, seg: null, pendingSeg: null,
54
+ /** @type {{ start: number, end: number, slot: string }[] | null} */ segments: null,
55
+ closeText: '',
56
+ };
57
+ try {
58
+ const instance = new Cls();
59
+ frame.instance = instance;
60
+ // Thread the ancestor chain (the enclosing custom-element instances) so
61
+ // the server element shim's closest() can resolve a parent at SSR. Set
62
+ // before performServerUpdate so a willUpdate() that reads a parent via
63
+ // closest() sees the chain.
64
+ instance.__ssrTag = tag;
65
+ instance.__ssrAncestors = w.ancestors.slice();
66
+ const attrMap = attrs ? parseAttrs(attrs) : {};
67
+ // Names already in the source opening tag (the data-webjs-prop-* bindings
68
+ // included, which the consume step below strips from attrMap but which
69
+ // stay in the emitted text). Reflected/added attributes are appended only
70
+ // when their name is NOT already here, so output stays byte-identical
71
+ // when nothing reflects.
72
+ const presentAttrNames = new Set();
73
+ for (const n in attrMap) presentAttrNames.add(n.toLowerCase());
74
+ // Decode `data-webjs-prop-*` first (rich-typed values emitted for
75
+ // `.prop=${val}` bindings in the parent template), then coerce the
76
+ // ordinary string attributes by declared type. Property bindings take
77
+ // priority on a name collision because they preserve the JS reference.
78
+ const propValues = consumePropAttrs(attrMap);
79
+ seedServerAttrs(instance, attrMap);
80
+ applyAttrsToInstance(instance, attrMap, Cls);
81
+ for (const k in propValues) instance[k] = propValues[k];
82
+ // The pre-render lifecycle (willUpdate, controllers' hostUpdate, then
83
+ // reflect:true reflection), matching lit's update cycle at SSR. Bare
84
+ // Base-extending kit components have no lifecycle, so it is guarded.
85
+ if (typeof instance.performServerUpdate === 'function') instance.performServerUpdate();
86
+ const tpl = instance.render ? instance.render() : '';
87
+ frame.presentAttrNames = presentAttrNames;
88
+ if (tpl && typeof tpl.then === 'function') {
89
+ // An async render() is awaited by the walker through `settleRender`.
90
+ frame.pending = tpl;
91
+ return frame;
92
+ }
93
+ frame.tpl = tpl;
94
+ afterRender(frame);
95
+ } catch (e) {
96
+ frame.error = e;
97
+ }
98
+ return frame;
99
+ }
100
+
101
+ /**
102
+ * Finish `beginComponent` for an async `render()`.
103
+ * @param {any} frame
104
+ */
105
+ export async function settleRender(frame) {
106
+ try {
107
+ frame.tpl = await frame.pending;
108
+ afterRender(frame);
109
+ } catch (e) {
110
+ frame.error = e;
111
+ }
112
+ frame.pending = null;
113
+ }
114
+
115
+ function afterRender(frame) {
116
+ // Surface attributes the component set up to and including render() that
117
+ // were not in the source tag (reflected props, an explicit setAttribute,
118
+ // a host mutation inside render()). Reading after render() captures all.
119
+ frame.opening = appendReflectedAttrs(frame.opening, frame.instance, frame.presentAttrNames);
120
+ // Mark LIGHT-DOM hosts for the `@layer webjs-host` display:block default.
121
+ // Shadow hosts are NOT marked: a document-level rule on the host beats the
122
+ // shadow tree's own `:host { display }`.
123
+ if (!frame.isShadow) frame.opening = withHostMarker(frame.opening);
124
+ }
125
+
126
+ /**
127
+ * Emit a finished component frame.
128
+ *
129
+ * @param {import('./walk.js').Walker} w
130
+ * @param {any} frame
131
+ * @param {string} children the captured authored children, expanded
132
+ * @returns {Promise<string>}
133
+ */
134
+ export async function finishComponent(w, frame, children) {
135
+ const { tag, Cls, isShadow } = frame;
136
+ if (frame.error === null) {
137
+ try {
138
+ const inner = await renderOwnTemplate(w, frame, children);
139
+ if (isShadow) {
140
+ // Native <slot> stays as-is in the DSD template: the browser projects
141
+ // the host's light children natively. The authored children stay in
142
+ // place after the template, and the source end tag is kept.
143
+ return `${frame.opening}<template shadowrootmode="open">${styleString(Cls)}${inner}</template>${children}${frame.closeText}`;
144
+ }
145
+ if (!NON_BLANK.test(inner)) {
146
+ // An empty rendered template (a decorator component with no render(),
147
+ // or a render() returning an empty template): the host is a
148
+ // transparent wrapper and the authored children stay in place next to
149
+ // the hydration marker.
150
+ return `${frame.opening}<!--webjs-hydrate-->${children}${frame.closeText}`;
151
+ }
152
+ return `${frame.opening}<!--webjs-hydrate-->${inner}</${tag}>`;
153
+ } catch (e) {
154
+ frame.error = e;
155
+ }
156
+ }
157
+ return errorState(w, frame);
158
+ }
159
+
160
+ /**
161
+ * Walk the instance's own template. For a light-DOM component the slot
162
+ * projection is in scope: the authored children partitioned by their
163
+ * top-level `slot=""` attribute (from the segments the walk recorded, no
164
+ * rescan), first-wins per name, owner stamped as this tag so a FORWARDED slot
165
+ * routes to the host that rendered it (#1023).
166
+ */
167
+ async function renderOwnTemplate(w, frame, children) {
168
+ const owner = w.owner;
169
+ w.owner = frame.isShadow ? null : { tag: frame.tag, partition: partitionChildren(children, frame.segments), consumed: null };
170
+ try {
171
+ return await w.capture(() => w.value(frame.tpl));
172
+ } finally {
173
+ w.owner = owner;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * The partition `partitionAuthoredBySlot` built by rescanning, built here from
179
+ * the recorded segments: text, comments and unslotted elements route to the
180
+ * default slot (null) in source order, each named segment to its name.
181
+ * @param {string} children
182
+ * @param {{ start: number, end: number, slot: string }[]} segments
183
+ * @returns {Map<string|null, string>}
184
+ */
185
+ function partitionChildren(children, segments) {
186
+ /** @type {Map<string|null, string>} */
187
+ const groups = new Map();
188
+ if (!segments) {
189
+ if (children.length > 0) groups.set(null, children);
190
+ return groups;
191
+ }
192
+ let def = '';
193
+ let cursor = 0;
194
+ for (const seg of segments) {
195
+ def += children.slice(cursor, seg.start);
196
+ const existing = groups.get(seg.slot);
197
+ groups.set(seg.slot, existing === undefined ? children.slice(seg.start, seg.end) : existing + children.slice(seg.start, seg.end));
198
+ cursor = seg.end;
199
+ }
200
+ def += children.slice(cursor);
201
+ if (def.length > 0) groups.set(null, def);
202
+ return groups;
203
+ }
204
+
205
+ function styleString(Cls) {
206
+ const rawStyles = Cls.styles;
207
+ const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
208
+ return stylesToString(styleList);
209
+ }
210
+
211
+ /**
212
+ * Per-component error isolation (#469). The element renders a
213
+ * component-scoped error state in its place (renderError() customises it;
214
+ * the default surfaces the message in dev and renders empty in prod, #483)
215
+ * plus a hydration marker so the client boundary can take over. A shadow
216
+ * component's error state rides a DSD template too, matching its success
217
+ * path.
218
+ */
219
+ async function errorState(w, frame) {
220
+ const { tag, Cls, instance, isShadow } = frame;
221
+ const e = frame.error;
222
+ const hint = browserMemberHint(e);
223
+ if (hint) {
224
+ console.error(
225
+ `[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).`,
226
+ e,
227
+ );
228
+ } else {
229
+ console.error(`[webjs] SSR failed for <${tag}>:`, e);
230
+ }
231
+ const err = e instanceof Error ? e : new Error(String(e));
232
+ let errorInner = '';
233
+ try {
234
+ let errTpl;
235
+ if (instance && typeof instance.renderError === 'function') errTpl = instance.renderError(err);
236
+ if (errTpl === undefined) errTpl = defaultSSRErrorTemplate(tag, err, w.dev);
237
+ errorInner = await w.capture(() => w.value(errTpl));
238
+ } catch (renderErrorThrew) {
239
+ console.error(`[webjs] renderError() for <${tag}> also threw:`, renderErrorThrew);
240
+ errorInner = '';
241
+ }
242
+ let opening = frame.opening;
243
+ if (!isShadow) opening = withHostMarker(opening);
244
+ if (isShadow) return `${opening}<template shadowrootmode="open">${styleString(Cls)}${errorInner}</template>`;
245
+ return `${opening}<!--webjs-hydrate-->${errorInner}</${tag}>`;
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Slots
250
+ // ---------------------------------------------------------------------------
251
+
252
+ /**
253
+ * The `slot` attribute value of an element's attribute text, or null when
254
+ * absent. `slot=""` and the reserved alias `default` both mean the default
255
+ * slot (#1015), matching the client record.
256
+ * @param {string} attrsRaw
257
+ * @returns {string | null}
258
+ */
259
+ export function extractSlotAttr(attrsRaw) {
260
+ const m = /\bslot\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
261
+ if (!m) return null;
262
+ const value = m[1] ?? m[2] ?? m[3] ?? '';
263
+ return value === '' || value === 'default' ? null : value;
264
+ }
265
+
266
+ /**
267
+ * Emit one `<slot>` of a light-DOM component's template as the framework
268
+ * slot: `data-webjs-light`, `data-projection="actual|fallback"`, the owner
269
+ * stamp, the name, and the projected children or the authored fallback.
270
+ * First-wins per name; a later same-named slot falls back regardless.
271
+ * @param {any} frame
272
+ * @param {string} fallback the slot's own captured children
273
+ * @returns {string}
274
+ */
275
+ export function finishSlot(frame, fallback) {
276
+ const { attrsRaw, owner } = frame;
277
+ const ownerAttr = owner.tag ? ` data-wj-slot-owner="${escapeAttr(owner.tag)}"` : '';
278
+ let name = null;
279
+ let otherAttrs = '';
280
+ if (NON_BLANK.test(attrsRaw)) {
281
+ const nameMatch = /\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
282
+ name = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : null;
283
+ otherAttrs = attrsRaw.replace(/\bname\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i, '').trim();
284
+ }
285
+ const slotKey = name === 'default' || name === '' ? null : name;
286
+ const projected = owner.partition.get(slotKey);
287
+ const nameAttr = name !== null ? ` name="${escapeAttr(name)}"` : '';
288
+ const extraAttrs = otherAttrs ? ` ${otherAttrs}` : '';
289
+ if (projected !== undefined && !(owner.consumed && owner.consumed.has(slotKey))) {
290
+ (owner.consumed ??= new Set()).add(slotKey);
291
+ return `<slot data-webjs-light data-projection="actual"${ownerAttr}${nameAttr}${extraAttrs}>${projected}</slot>`;
292
+ }
293
+ return `<slot data-webjs-light data-projection="fallback"${ownerAttr}${nameAttr}${extraAttrs}>${fallback}</slot>`;
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // <webjs-suspense>
298
+ // ---------------------------------------------------------------------------
299
+
300
+ /**
301
+ * A `<webjs-suspense>` boundary (#471). Streaming (a ctx is present): the
302
+ * placeholder carries the rendered `.fallback` and the boundary id, and the
303
+ * children, captured with their components unexpanded, are pushed to
304
+ * `ctx.pending` as raw markup for `streamSuspenseBoundaries` to resolve
305
+ * after the main flush (so every boundary's data fetches run concurrently).
306
+ * Blocking: the children rendered inline, the fallback dropped, the wrapper
307
+ * kept as an inert element.
308
+ * @param {import('./walk.js').Walker} w
309
+ * @param {any} frame
310
+ * @param {string} children
311
+ * @returns {Promise<string>}
312
+ */
313
+ export async function finishSuspense(w, frame, children) {
314
+ if (w.ctx && frame.id) {
315
+ w.ctx.pending.push({ id: frame.id, promise: Promise.resolve(unsafeHTML(children)) });
316
+ return `<webjs-suspense id="${frame.id}">${frame.fallback}</webjs-suspense>`;
317
+ }
318
+ return `<webjs-suspense>${children}</webjs-suspense>`;
319
+ }