@webjsdev/core 0.7.1
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/README.md +55 -0
- package/index.d.ts +28 -0
- package/index.js +39 -0
- package/package.json +46 -0
- package/src/component.d.ts +120 -0
- package/src/component.js +1061 -0
- package/src/context.js +327 -0
- package/src/css.js +54 -0
- package/src/directives.js +454 -0
- package/src/escape.js +15 -0
- package/src/expose.js +90 -0
- package/src/html.js +30 -0
- package/src/lazy-loader.js +107 -0
- package/src/nav.js +45 -0
- package/src/registry.js +190 -0
- package/src/render-client.js +1717 -0
- package/src/render-server.js +1128 -0
- package/src/repeat.js +37 -0
- package/src/rich-fetch.js +70 -0
- package/src/router-client.js +1434 -0
- package/src/serialize.js +452 -0
- package/src/signal.js +684 -0
- package/src/slot.js +860 -0
- package/src/suspense.js +46 -0
- package/src/task.js +386 -0
- package/src/testing.js +157 -0
- package/src/webjs-frame.js +65 -0
- package/src/websocket-client.js +111 -0
|
@@ -0,0 +1,1128 @@
|
|
|
1
|
+
import { isTemplate } from './html.js';
|
|
2
|
+
import { escapeText, escapeAttr } from './escape.js';
|
|
3
|
+
import { lookup, lookupModuleUrl, allTags } from './registry.js';
|
|
4
|
+
import { stylesToString, isCSS } from './css.js';
|
|
5
|
+
import { isRepeat } from './repeat.js';
|
|
6
|
+
import { isSuspense } from './suspense.js';
|
|
7
|
+
import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
|
|
8
|
+
import { stringify, parse } from './serialize.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Render a TemplateResult (or any renderable value) to an HTML string.
|
|
12
|
+
*
|
|
13
|
+
* Async by design: template holes may be Promises, components' `render()`
|
|
14
|
+
* methods may be async, and data-fetching inside nested components is
|
|
15
|
+
* awaited before the final string is emitted.
|
|
16
|
+
*
|
|
17
|
+
* If `opts.suspenseCtx` is provided, Suspense boundaries encountered during
|
|
18
|
+
* the render will push `{ id, promise }` into `opts.suspenseCtx.pending`
|
|
19
|
+
* and their fallback HTML is emitted immediately. The caller is responsible
|
|
20
|
+
* for streaming each resolved promise afterwards. Without a suspenseCtx,
|
|
21
|
+
* Suspense still works but we fall back to emitting only the fallback
|
|
22
|
+
* (the promise is dropped: appropriate for static pre-render).
|
|
23
|
+
*
|
|
24
|
+
* @typedef {{ pending: {id: string, promise: Promise<unknown>}[], nextId: number }} SuspenseCtx
|
|
25
|
+
*
|
|
26
|
+
* @param {unknown} value
|
|
27
|
+
* @param {{ ssr?: boolean, suspenseCtx?: SuspenseCtx }} [opts]
|
|
28
|
+
* @returns {Promise<string>}
|
|
29
|
+
*/
|
|
30
|
+
export async function renderToString(value, opts = { ssr: true }) {
|
|
31
|
+
const ctx = opts && opts.suspenseCtx;
|
|
32
|
+
const html = await render(value, ctx);
|
|
33
|
+
return opts && opts.ssr === false ? html : await injectDSD(html, ctx);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {unknown} value
|
|
38
|
+
* @param {SuspenseCtx} [ctx]
|
|
39
|
+
* @returns {Promise<string>}
|
|
40
|
+
*/
|
|
41
|
+
async function render(value, ctx) {
|
|
42
|
+
if (value == null || value === false || value === true) return '';
|
|
43
|
+
if (value && typeof /** @type any */ (value).then === 'function') {
|
|
44
|
+
value = await value;
|
|
45
|
+
return render(value, ctx);
|
|
46
|
+
}
|
|
47
|
+
// unsafeHTML: inject raw HTML string without escaping.
|
|
48
|
+
if (isUnsafeHTML(value)) {
|
|
49
|
+
return String(/** @type any */ (value).value ?? '');
|
|
50
|
+
}
|
|
51
|
+
// live() on the server just unwraps and renders the inner value.
|
|
52
|
+
if (isLive(value)) {
|
|
53
|
+
return render(/** @type any */ (value).value, ctx);
|
|
54
|
+
}
|
|
55
|
+
// watch() on the server reads the signal once and inlines the
|
|
56
|
+
// result. Subscription is a client-only concern; the SSR HTML
|
|
57
|
+
// freezes a snapshot of the current value.
|
|
58
|
+
if (isWatch(value)) {
|
|
59
|
+
return render(/** @type any */ (value).signal.get(), ctx);
|
|
60
|
+
}
|
|
61
|
+
// keyed() on the server: render the wrapped template; key is client-only.
|
|
62
|
+
if (isKeyed(value)) {
|
|
63
|
+
return render(/** @type any */ (value).value, ctx);
|
|
64
|
+
}
|
|
65
|
+
// guard() on the server: always invoke the value function (no cache on SSR).
|
|
66
|
+
if (isGuard(value)) {
|
|
67
|
+
return render(/** @type any */ (value).fn(), ctx);
|
|
68
|
+
}
|
|
69
|
+
// templateContent() on the server: emit the template's innerHTML verbatim.
|
|
70
|
+
if (isTemplateContent(value)) {
|
|
71
|
+
const tpl = /** @type any */ (value).template;
|
|
72
|
+
return String(tpl?.innerHTML ?? '');
|
|
73
|
+
}
|
|
74
|
+
// ref() on the server: no-op (no DOM yet). Returns empty string.
|
|
75
|
+
if (isRef(value)) {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
// cache() on the server: pass-through to the inner value.
|
|
79
|
+
if (isCache(value)) {
|
|
80
|
+
return render(/** @type any */ (value).value, ctx);
|
|
81
|
+
}
|
|
82
|
+
// until() on the server: render the first synchronous candidate, or
|
|
83
|
+
// await the first Promise to settle when all candidates are Promises.
|
|
84
|
+
// Rejections are swallowed (treated as "no value"); if every candidate
|
|
85
|
+
// rejects, render empty rather than crash the SSR pipeline.
|
|
86
|
+
if (isUntil(value)) {
|
|
87
|
+
const args = /** @type any */ (value).args;
|
|
88
|
+
for (const a of args) {
|
|
89
|
+
if (!a || typeof (/** @type any */ (a).then) !== 'function') {
|
|
90
|
+
return render(a, ctx);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (args.length > 0) {
|
|
94
|
+
try {
|
|
95
|
+
const winner = await Promise.race(args.map((p) => Promise.resolve(p).catch(() => undefined)));
|
|
96
|
+
return render(winner, ctx);
|
|
97
|
+
} catch {
|
|
98
|
+
return '';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
103
|
+
// asyncAppend / asyncReplace on the server: render empty. Full
|
|
104
|
+
// streaming is a follow-up; pages should use Suspense for streaming.
|
|
105
|
+
if (isAsyncAppend(value) || isAsyncReplace(value)) {
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
108
|
+
if (Array.isArray(value)) {
|
|
109
|
+
const parts = await Promise.all(value.map((v) => render(v, ctx)));
|
|
110
|
+
return parts.join('');
|
|
111
|
+
}
|
|
112
|
+
if (isRepeat(value)) {
|
|
113
|
+
const r = /** @type any */ (value);
|
|
114
|
+
const parts = await Promise.all(r.items.map((it, i) => render(r.templateFn(it, i), ctx)));
|
|
115
|
+
return parts.join('');
|
|
116
|
+
}
|
|
117
|
+
if (isSuspense(value)) {
|
|
118
|
+
const s = /** @type any */ (value);
|
|
119
|
+
const fallback = await render(s.fallback, ctx);
|
|
120
|
+
if (ctx) {
|
|
121
|
+
const id = `s${ctx.nextId++}`;
|
|
122
|
+
ctx.pending.push({ id, promise: Promise.resolve(s.children) });
|
|
123
|
+
return `<webjs-boundary id="${id}">${fallback}</webjs-boundary>`;
|
|
124
|
+
}
|
|
125
|
+
return fallback;
|
|
126
|
+
}
|
|
127
|
+
if (isTemplate(value)) return renderTemplate(/** @type any */ (value), ctx);
|
|
128
|
+
return escapeText(String(value));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {import('./html.js').TemplateResult} tr
|
|
133
|
+
* @param {SuspenseCtx} [ctx]
|
|
134
|
+
* @returns {Promise<string>}
|
|
135
|
+
*/
|
|
136
|
+
async function renderTemplate(tr, ctx) {
|
|
137
|
+
const { strings, values } = tr;
|
|
138
|
+
let out = '';
|
|
139
|
+
let state = 'text';
|
|
140
|
+
let attrName = '';
|
|
141
|
+
let attrStart = 0;
|
|
142
|
+
let attrQuote = '';
|
|
143
|
+
let commentDashes = 0;
|
|
144
|
+
let currentTag = ''; // lowercased tag name currently being parsed
|
|
145
|
+
let rawTail = ''; // rolling lowercased tail, tracks </script>/</style>
|
|
146
|
+
|
|
147
|
+
for (let i = 0; i < strings.length; i++) {
|
|
148
|
+
const s = strings[i];
|
|
149
|
+
for (let j = 0; j < s.length; j++) {
|
|
150
|
+
const c = s[j];
|
|
151
|
+
switch (state) {
|
|
152
|
+
case 'text':
|
|
153
|
+
out += c;
|
|
154
|
+
if (c === '<') state = 'tag-open';
|
|
155
|
+
break;
|
|
156
|
+
case 'tag-open':
|
|
157
|
+
out += c;
|
|
158
|
+
if (c === '!') state = 'bang-1';
|
|
159
|
+
else if (c === '/') { state = 'tag-name'; currentTag = ''; }
|
|
160
|
+
else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
|
|
161
|
+
else state = 'text';
|
|
162
|
+
break;
|
|
163
|
+
case 'bang-1':
|
|
164
|
+
out += c;
|
|
165
|
+
state = c === '-' ? 'bang-dash' : 'tag-name';
|
|
166
|
+
break;
|
|
167
|
+
case 'bang-dash':
|
|
168
|
+
out += c;
|
|
169
|
+
if (c === '-') { state = 'comment'; commentDashes = 0; }
|
|
170
|
+
else state = 'tag-name';
|
|
171
|
+
break;
|
|
172
|
+
case 'comment':
|
|
173
|
+
out += c;
|
|
174
|
+
if (c === '-') commentDashes += 1;
|
|
175
|
+
else if (c === '>' && commentDashes >= 2) { state = 'text'; commentDashes = 0; }
|
|
176
|
+
else commentDashes = 0;
|
|
177
|
+
break;
|
|
178
|
+
case 'tag-name':
|
|
179
|
+
out += c;
|
|
180
|
+
if (c === '>') {
|
|
181
|
+
state = isRawtextTag(currentTag) ? 'rawtext' : 'text';
|
|
182
|
+
if (state === 'rawtext') rawTail = '';
|
|
183
|
+
} else if (/\s/.test(c)) state = 'in-tag';
|
|
184
|
+
else currentTag += c.toLowerCase();
|
|
185
|
+
break;
|
|
186
|
+
case 'in-tag':
|
|
187
|
+
out += c;
|
|
188
|
+
if (c === '>') {
|
|
189
|
+
state = isRawtextTag(currentTag) ? 'rawtext' : 'text';
|
|
190
|
+
if (state === 'rawtext') rawTail = '';
|
|
191
|
+
} else if (!/\s/.test(c) && c !== '/') {
|
|
192
|
+
state = 'attr-name';
|
|
193
|
+
attrName = c;
|
|
194
|
+
attrStart = out.length - 1;
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
case 'rawtext':
|
|
198
|
+
out += c;
|
|
199
|
+
rawTail = (rawTail + c.toLowerCase()).slice(-9);
|
|
200
|
+
if (rawTail.endsWith('</script>') || rawTail.endsWith('</style>')) {
|
|
201
|
+
state = 'text';
|
|
202
|
+
rawTail = '';
|
|
203
|
+
currentTag = '';
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
case 'attr-name':
|
|
207
|
+
if (c === '=') { state = 'after-eq'; out += c; }
|
|
208
|
+
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
|
|
209
|
+
else if (c === '>') { state = 'text'; attrName = ''; out += c; }
|
|
210
|
+
else { attrName += c; out += c; }
|
|
211
|
+
break;
|
|
212
|
+
case 'after-eq':
|
|
213
|
+
if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; out += c; }
|
|
214
|
+
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
|
|
215
|
+
else if (c === '>') { state = 'text'; attrName = ''; out += c; }
|
|
216
|
+
else { state = 'attr-unquoted'; out += c; }
|
|
217
|
+
break;
|
|
218
|
+
case 'attr-unquoted':
|
|
219
|
+
if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
|
|
220
|
+
else if (c === '>') { state = 'text'; attrName = ''; out += c; }
|
|
221
|
+
else out += c;
|
|
222
|
+
break;
|
|
223
|
+
case 'attr-quoted':
|
|
224
|
+
out += c;
|
|
225
|
+
if (c === attrQuote) { state = 'in-tag'; attrName = ''; }
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (i < values.length) {
|
|
231
|
+
let val = values[i];
|
|
232
|
+
// Resolve promises anywhere in the value graph.
|
|
233
|
+
if (val && typeof /** @type any */ (val).then === 'function') {
|
|
234
|
+
val = await val;
|
|
235
|
+
}
|
|
236
|
+
if (state === 'comment') {
|
|
237
|
+
// Holes inside <!-- comments --> are emitted raw (no escaping; comments
|
|
238
|
+
// are inert and not rendered by browsers).
|
|
239
|
+
out += String(val ?? '');
|
|
240
|
+
commentDashes = 0;
|
|
241
|
+
} else if (state === 'rawtext') {
|
|
242
|
+
// Inside <script> / <style>: emit the value as-is (no HTML escaping).
|
|
243
|
+
// Author is responsible for not closing the tag with user-controlled
|
|
244
|
+
// data: the usual caveat for CSS/JS interpolation.
|
|
245
|
+
out += String(val ?? '');
|
|
246
|
+
rawTail = '';
|
|
247
|
+
} else if (state === 'text') {
|
|
248
|
+
out += await render(val, ctx);
|
|
249
|
+
} else if (state === 'after-eq') {
|
|
250
|
+
const prefix = attrName[0];
|
|
251
|
+
const name = attrName.slice(1);
|
|
252
|
+
if (prefix === '@') {
|
|
253
|
+
// Event listener. Client-only behaviour, drop at SSR.
|
|
254
|
+
out = out.slice(0, attrStart);
|
|
255
|
+
state = 'in-tag';
|
|
256
|
+
attrName = '';
|
|
257
|
+
} else if (prefix === '.') {
|
|
258
|
+
// Property binding. Only meaningful on custom elements (which
|
|
259
|
+
// have a hyphen in the tag name and a WebComponent subclass
|
|
260
|
+
// that knows how to apply + strip data-webjs-prop-* on
|
|
261
|
+
// hydration). For native elements (`<input .value=${v}>`)
|
|
262
|
+
// the attribute would be dead weight (nothing consumes it),
|
|
263
|
+
// so we drop it the same way the old behaviour did. The
|
|
264
|
+
// client renderer still applies the property when the
|
|
265
|
+
// template runs in the browser, which is the only place a
|
|
266
|
+
// page-level `.prop` on a native element could have set the
|
|
267
|
+
// property to begin with.
|
|
268
|
+
out = out.slice(0, attrStart);
|
|
269
|
+
if (!currentTag.includes('-')) {
|
|
270
|
+
state = 'in-tag';
|
|
271
|
+
attrName = '';
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
// `undefined` has no meaningful HTML representation. Drop
|
|
275
|
+
// silently so the consumer falls back to its constructor
|
|
276
|
+
// default. `null` is preserved because it's a real value
|
|
277
|
+
// distinct from "not set".
|
|
278
|
+
if (val === undefined) {
|
|
279
|
+
state = 'in-tag';
|
|
280
|
+
attrName = '';
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const encoded = await stringify(val);
|
|
285
|
+
out += `data-webjs-prop-${kebabCase(name)}="${escapeAttr(encoded)}"`;
|
|
286
|
+
} catch (e) {
|
|
287
|
+
// Unserializable value (function, class instance with
|
|
288
|
+
// private state, DOM node, etc.). Drop with a warning so
|
|
289
|
+
// SSR does not crash. Same constraint as Next.js RSC.
|
|
290
|
+
console.warn(
|
|
291
|
+
`[webjs] property binding .${name} has an unserializable `
|
|
292
|
+
+ `value during SSR. Dropping. The browser will see the `
|
|
293
|
+
+ `property as undefined. Detail: ${e && e.message}`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
state = 'in-tag';
|
|
297
|
+
attrName = '';
|
|
298
|
+
} else if (prefix === '?') {
|
|
299
|
+
out = out.slice(0, attrStart);
|
|
300
|
+
if (val) out += `${name}=""`;
|
|
301
|
+
state = 'in-tag';
|
|
302
|
+
attrName = '';
|
|
303
|
+
} else {
|
|
304
|
+
out += `"${escapeAttr(String(val ?? ''))}"`;
|
|
305
|
+
state = 'in-tag';
|
|
306
|
+
attrName = '';
|
|
307
|
+
}
|
|
308
|
+
} else if (state === 'attr-quoted' || state === 'attr-unquoted') {
|
|
309
|
+
out += escapeAttr(String(val ?? ''));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Scan an HTML string for registered custom elements and inject
|
|
318
|
+
* Declarative Shadow DOM (`<template shadowrootmode="open">`).
|
|
319
|
+
* Awaits each component's render() so async components are fully resolved.
|
|
320
|
+
*
|
|
321
|
+
* @param {string} html
|
|
322
|
+
* @param {SuspenseCtx} [ctx]
|
|
323
|
+
* @returns {Promise<string>}
|
|
324
|
+
*/
|
|
325
|
+
async function injectDSD(html, ctx) {
|
|
326
|
+
const tags = allTags();
|
|
327
|
+
if (!tags.length) return html;
|
|
328
|
+
// Sort longest tag name first so the regex alternation tries the most
|
|
329
|
+
// specific match before its prefixes. Combined with the (?=[\s>/])
|
|
330
|
+
// lookahead this prevents `my-card` from spuriously matching the prefix
|
|
331
|
+
// of `<my-card-2>` (or `slot-ssr-1` matching `<slot-ssr-14>`, etc).
|
|
332
|
+
// Attribute section is "anything that isn't `>`, with quoted values as a
|
|
333
|
+
// single unit" so slashes in URL-valued attrs (e.g. then="/dashboard") don't
|
|
334
|
+
// prevent the match. Non-greedy so self-closing `/>` still captures into the
|
|
335
|
+
// third group.
|
|
336
|
+
const sortedTags = [...tags].sort((a, b) => b.length - a.length);
|
|
337
|
+
const pattern = new RegExp(
|
|
338
|
+
`<(${sortedTags.map(escapeRegex).join('|')})(?=[\\s>/])((?:"[^"]*"|'[^']*'|[^>])*?)(/?)>`,
|
|
339
|
+
'g'
|
|
340
|
+
);
|
|
341
|
+
/** @type {{start:number, end:number, text:string}[]} */
|
|
342
|
+
const edits = [];
|
|
343
|
+
for (const m of html.matchAll(pattern)) {
|
|
344
|
+
const [match, tag, attrs, selfClose] = m;
|
|
345
|
+
const Cls = lookup(tag);
|
|
346
|
+
if (!Cls) continue;
|
|
347
|
+
// Track which custom elements actually appeared: used by SSR to emit
|
|
348
|
+
// `<link rel="modulepreload">` hints for their module URLs.
|
|
349
|
+
if (ctx && ctx.usedComponents) ctx.usedComponents.add(tag);
|
|
350
|
+
const opening = selfClose ? `<${tag}${attrs}>` : match;
|
|
351
|
+
try {
|
|
352
|
+
const isShadow = /** @type any */ (Cls).shadow === true;
|
|
353
|
+
const instance = new /** @type any */ (Cls)();
|
|
354
|
+
const attrMap = parseAttrs(attrs);
|
|
355
|
+
// Decode `data-webjs-prop-*` attributes first (rich-typed values
|
|
356
|
+
// emitted for `.prop=${val}` bindings in the parent template),
|
|
357
|
+
// then coerce the ordinary string attributes by `static
|
|
358
|
+
// properties` type. Property bindings take priority on a name
|
|
359
|
+
// collision because they preserve the original JS reference.
|
|
360
|
+
const propValues = consumePropAttrs(attrMap);
|
|
361
|
+
applyAttrsToInstance(instance, attrMap, Cls);
|
|
362
|
+
for (const [k, v] of Object.entries(propValues)) instance[k] = v;
|
|
363
|
+
let tpl = instance.render ? instance.render() : '';
|
|
364
|
+
if (tpl && typeof tpl.then === 'function') tpl = await tpl;
|
|
365
|
+
// Render the template to HTML. injectDSD recurses on the result so
|
|
366
|
+
// nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
|
|
367
|
+
// get their own DSD pass.
|
|
368
|
+
const rawInner = await render(tpl, ctx);
|
|
369
|
+
|
|
370
|
+
if (isShadow) {
|
|
371
|
+
// Shadow DOM: native <slot> stays as-is in the DSD template. The
|
|
372
|
+
// browser handles projection from the host's light-DOM children
|
|
373
|
+
// into the shadow tree natively. No framework substitution here.
|
|
374
|
+
const innerProcessed = await injectDSD(rawInner, ctx);
|
|
375
|
+
const rawStyles = /** @type any */ (Cls).styles;
|
|
376
|
+
const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
|
|
377
|
+
const styleStr = stylesToString(styleList);
|
|
378
|
+
edits.push({
|
|
379
|
+
start: m.index,
|
|
380
|
+
end: m.index + match.length,
|
|
381
|
+
text: `${opening}<template shadowrootmode="open">${styleStr}${innerProcessed}</template>`,
|
|
382
|
+
});
|
|
383
|
+
} else {
|
|
384
|
+
// Light DOM. When the component has a non-empty rendered template,
|
|
385
|
+
// run the slot pipeline so behaviour matches shadow DOM: authored
|
|
386
|
+
// children are visible only where projected through <slot>; any
|
|
387
|
+
// child without a matching slot is dropped.
|
|
388
|
+
//
|
|
389
|
+
// When rendered template is empty (Base-extending decorator
|
|
390
|
+
// components that have no render() method, or render() that
|
|
391
|
+
// returns an empty template), the host acts as a transparent
|
|
392
|
+
// wrapper: authored children stay in place adjacent to the
|
|
393
|
+
// (empty) hydration marker. This preserves the kit's
|
|
394
|
+
// decorator-pattern components (those extending Base from the
|
|
395
|
+
// ui package's lib/utils.ts) without forcing a render() rewrite.
|
|
396
|
+
const renderedIsEmpty = rawInner.trim() === '';
|
|
397
|
+
if (renderedIsEmpty) {
|
|
398
|
+
edits.push({
|
|
399
|
+
start: m.index,
|
|
400
|
+
end: m.index + match.length,
|
|
401
|
+
text: `${opening}<!--webjs-hydrate-->`,
|
|
402
|
+
});
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
//
|
|
406
|
+
// 1. Find the matching closing tag in the source HTML (depth-
|
|
407
|
+
// tracked for nested same-tag elements).
|
|
408
|
+
// 2. Extract authored inner HTML, partition by slot="" attr.
|
|
409
|
+
// 3. Substitute each <slot> in the rendered output with a
|
|
410
|
+
// framework-marked <slot data-webjs-light data-projection
|
|
411
|
+
// ="actual|fallback"> element carrying projection or
|
|
412
|
+
// fallback content per first-wins rule.
|
|
413
|
+
// 4. Recursively run injectDSD on the substituted output so
|
|
414
|
+
// nested custom elements (inside projected children) get
|
|
415
|
+
// their own DSD pass.
|
|
416
|
+
let authoredInner = '';
|
|
417
|
+
let closeEnd = m.index + match.length;
|
|
418
|
+
if (!selfClose) {
|
|
419
|
+
const innerStart = m.index + match.length;
|
|
420
|
+
const closeIdx = findClosingTagInString(html, innerStart, tag);
|
|
421
|
+
if (closeIdx !== -1) {
|
|
422
|
+
authoredInner = html.slice(innerStart, closeIdx);
|
|
423
|
+
const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
|
|
424
|
+
const tail = html.slice(closeIdx);
|
|
425
|
+
const closeMatch = closeRe.exec(tail);
|
|
426
|
+
const closeLen = closeMatch ? closeMatch[0].length : `</${tag}>`.length;
|
|
427
|
+
closeEnd = closeIdx + closeLen;
|
|
428
|
+
} else {
|
|
429
|
+
// Unclosed in source. Take rest of html as authored content
|
|
430
|
+
// and synthesize a closing tag on output.
|
|
431
|
+
authoredInner = html.slice(innerStart);
|
|
432
|
+
closeEnd = html.length;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const partitioned = partitionAuthoredBySlot(authoredInner);
|
|
436
|
+
const innerWithSlots = substituteSlotsInRender(rawInner, partitioned);
|
|
437
|
+
const innerProcessed = await injectDSD(innerWithSlots, ctx);
|
|
438
|
+
edits.push({
|
|
439
|
+
start: m.index,
|
|
440
|
+
end: closeEnd,
|
|
441
|
+
text: `${opening}<!--webjs-hydrate-->${innerProcessed}</${tag}>`,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
} catch (e) {
|
|
445
|
+
console.error(`[webjs] SSR failed for <${tag}>:`, e);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (!edits.length) return html;
|
|
449
|
+
|
|
450
|
+
// Drop edits whose range lives inside an earlier edit's range. This
|
|
451
|
+
// happens when an outer custom element with <slot> in its render takes
|
|
452
|
+
// an edit that spans its opening + closing tags (covering inner custom
|
|
453
|
+
// elements among authored children); the inner matches were enumerated
|
|
454
|
+
// independently against the original html, but those inner elements
|
|
455
|
+
// are processed by the recursive injectDSD call on innerWithSlots.
|
|
456
|
+
// Keeping both edits would double-process them and corrupt the output.
|
|
457
|
+
edits.sort((a, b) => a.start - b.start);
|
|
458
|
+
/** @type {{start:number, end:number, text:string}[]} */
|
|
459
|
+
const filtered = [];
|
|
460
|
+
let consumedTo = -1;
|
|
461
|
+
for (const e of edits) {
|
|
462
|
+
if (e.start >= consumedTo) {
|
|
463
|
+
filtered.push(e);
|
|
464
|
+
consumedTo = e.end;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// Apply edits from last to first so indices stay stable.
|
|
468
|
+
let out = html;
|
|
469
|
+
for (let i = filtered.length - 1; i >= 0; i--) {
|
|
470
|
+
const { start, end, text } = filtered[i];
|
|
471
|
+
out = out.slice(0, start) + text + out.slice(end);
|
|
472
|
+
}
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
// Slot SSR helpers
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
const VOID_ELEMENTS = new Set([
|
|
481
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
482
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
483
|
+
]);
|
|
484
|
+
|
|
485
|
+
/** @param {string} tag @returns {boolean} */
|
|
486
|
+
function isVoidElement(tag) {
|
|
487
|
+
return VOID_ELEMENTS.has(tag.toLowerCase());
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Find the position of the matching closing tag for `tagName` starting
|
|
492
|
+
* from `fromIndex` in `html`. Handles nested same-tag elements via depth
|
|
493
|
+
* tracking. Returns the index of the `<` of `</tagName>`, or -1 if
|
|
494
|
+
* unclosed.
|
|
495
|
+
*
|
|
496
|
+
* @param {string} html
|
|
497
|
+
* @param {number} fromIndex
|
|
498
|
+
* @param {string} tagName
|
|
499
|
+
* @returns {number}
|
|
500
|
+
*/
|
|
501
|
+
function findClosingTagInString(html, fromIndex, tagName) {
|
|
502
|
+
const esc = escapeRegex(tagName);
|
|
503
|
+
// Match same-name opening tags. Followed by a name-boundary character
|
|
504
|
+
// so we don't accept <table> as opening <tab>.
|
|
505
|
+
const openRe = new RegExp(`<${esc}(?:[\\s>/])`, 'gi');
|
|
506
|
+
const closeRe = new RegExp(`</${esc}\\s*>`, 'gi');
|
|
507
|
+
openRe.lastIndex = fromIndex;
|
|
508
|
+
closeRe.lastIndex = fromIndex;
|
|
509
|
+
let depth = 1;
|
|
510
|
+
while (depth > 0) {
|
|
511
|
+
const o = openRe.exec(html);
|
|
512
|
+
const c = closeRe.exec(html);
|
|
513
|
+
if (!c) return -1;
|
|
514
|
+
if (o && o.index < c.index) {
|
|
515
|
+
depth++;
|
|
516
|
+
closeRe.lastIndex = o.index + 1;
|
|
517
|
+
} else {
|
|
518
|
+
depth--;
|
|
519
|
+
if (depth === 0) return c.index;
|
|
520
|
+
openRe.lastIndex = c.index + 1;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return -1;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Extract the `slot` attribute value from an attribute string. Returns
|
|
528
|
+
* null when the attribute is absent.
|
|
529
|
+
*
|
|
530
|
+
* @param {string} attrsRaw
|
|
531
|
+
* @returns {string | null}
|
|
532
|
+
*/
|
|
533
|
+
function extractSlotAttr(attrsRaw) {
|
|
534
|
+
const m = /\bslot\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
|
|
535
|
+
if (!m) return null;
|
|
536
|
+
const value = m[1] ?? m[2] ?? m[3] ?? '';
|
|
537
|
+
// Per shadow DOM spec, slot="" (empty) and missing slot attribute
|
|
538
|
+
// both route to the default slot.
|
|
539
|
+
return value === '' ? null : value;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Partition authored inner HTML by each top-level child's `slot=""`
|
|
544
|
+
* attribute. Text nodes, comment nodes, and elements without `slot=""`
|
|
545
|
+
* all route to the default-slot key (null).
|
|
546
|
+
*
|
|
547
|
+
* Returns a Map keyed by slot name (null for default) whose values are
|
|
548
|
+
* the concatenated HTML strings for that slot in source order.
|
|
549
|
+
*
|
|
550
|
+
* @param {string} html
|
|
551
|
+
* @returns {Map<string|null, string>}
|
|
552
|
+
*/
|
|
553
|
+
function partitionAuthoredBySlot(html) {
|
|
554
|
+
/** @type {Map<string|null, string>} */
|
|
555
|
+
const groups = new Map();
|
|
556
|
+
let defaultBuf = '';
|
|
557
|
+
let cursor = 0;
|
|
558
|
+
while (cursor < html.length) {
|
|
559
|
+
const lt = html.indexOf('<', cursor);
|
|
560
|
+
if (lt === -1) {
|
|
561
|
+
defaultBuf += html.slice(cursor);
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
if (lt > cursor) defaultBuf += html.slice(cursor, lt);
|
|
565
|
+
const rest = html.slice(lt);
|
|
566
|
+
if (rest.startsWith('<!--')) {
|
|
567
|
+
const end = html.indexOf('-->', lt + 4);
|
|
568
|
+
if (end === -1) {
|
|
569
|
+
defaultBuf += rest;
|
|
570
|
+
cursor = html.length;
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
defaultBuf += html.slice(lt, end + 3);
|
|
574
|
+
cursor = end + 3;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (rest.startsWith('<!') || rest.startsWith('</')) {
|
|
578
|
+
const end = html.indexOf('>', lt);
|
|
579
|
+
if (end === -1) {
|
|
580
|
+
defaultBuf += rest;
|
|
581
|
+
cursor = html.length;
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
defaultBuf += html.slice(lt, end + 1);
|
|
585
|
+
cursor = end + 1;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
const tagMatch = /^<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/.exec(rest);
|
|
589
|
+
if (!tagMatch) {
|
|
590
|
+
defaultBuf += '<';
|
|
591
|
+
cursor = lt + 1;
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const [tagFull, tagName, attrsRaw, selfCloseSlash] = tagMatch;
|
|
595
|
+
const lower = tagName.toLowerCase();
|
|
596
|
+
const isSelfClose = !!selfCloseSlash || isVoidElement(lower);
|
|
597
|
+
const slotAttr = extractSlotAttr(attrsRaw);
|
|
598
|
+
let elemEnd;
|
|
599
|
+
if (isSelfClose) {
|
|
600
|
+
elemEnd = lt + tagFull.length;
|
|
601
|
+
} else {
|
|
602
|
+
const innerStart = lt + tagFull.length;
|
|
603
|
+
const closeIdx = findClosingTagInString(html, innerStart, lower);
|
|
604
|
+
if (closeIdx === -1) {
|
|
605
|
+
// Unclosed element. Take to end of html.
|
|
606
|
+
const elementHTML = html.slice(lt);
|
|
607
|
+
if (slotAttr !== null) appendStringToMap(groups, slotAttr, elementHTML);
|
|
608
|
+
else defaultBuf += elementHTML;
|
|
609
|
+
cursor = html.length;
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
const closeRe = new RegExp(`</${escapeRegex(lower)}\\s*>`, 'i');
|
|
613
|
+
const tail = html.slice(closeIdx);
|
|
614
|
+
const closeMatch = closeRe.exec(tail);
|
|
615
|
+
const closeLen = closeMatch ? closeMatch[0].length : `</${lower}>`.length;
|
|
616
|
+
elemEnd = closeIdx + closeLen;
|
|
617
|
+
}
|
|
618
|
+
const elementHTML = html.slice(lt, elemEnd);
|
|
619
|
+
if (slotAttr !== null) appendStringToMap(groups, slotAttr, elementHTML);
|
|
620
|
+
else defaultBuf += elementHTML;
|
|
621
|
+
cursor = elemEnd;
|
|
622
|
+
}
|
|
623
|
+
if (defaultBuf.length > 0) groups.set(null, defaultBuf);
|
|
624
|
+
return groups;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Append a string to a Map<K, string>, concatenating if the key exists. */
|
|
628
|
+
function appendStringToMap(map, key, value) {
|
|
629
|
+
const existing = map.get(key);
|
|
630
|
+
if (existing !== undefined) map.set(key, existing + value);
|
|
631
|
+
else map.set(key, value);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Substitute every `<slot>` tag in `rendered` with a framework-marked
|
|
636
|
+
* `<slot data-webjs-light data-projection="actual|fallback">` element
|
|
637
|
+
* carrying either the projected children for that slot (from
|
|
638
|
+
* `partitioned`) or the slot's authored fallback content. Multiple
|
|
639
|
+
* slots with the same name follow the first-wins rule per spec; later
|
|
640
|
+
* same-named slots fall back regardless of available projection.
|
|
641
|
+
*
|
|
642
|
+
* @param {string} rendered
|
|
643
|
+
* @param {Map<string|null, string>} partitioned
|
|
644
|
+
* @returns {string}
|
|
645
|
+
*/
|
|
646
|
+
function substituteSlotsInRender(rendered, partitioned) {
|
|
647
|
+
/** @type {Set<string|null>} */
|
|
648
|
+
const consumedNames = new Set();
|
|
649
|
+
let result = '';
|
|
650
|
+
let cursor = 0;
|
|
651
|
+
const slotRe = /<slot((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/gi;
|
|
652
|
+
let m;
|
|
653
|
+
while ((m = slotRe.exec(rendered)) !== null) {
|
|
654
|
+
result += rendered.slice(cursor, m.index);
|
|
655
|
+
const [fullOpen, attrsRaw, selfCloseSlash] = m;
|
|
656
|
+
const isSelfClose = !!selfCloseSlash;
|
|
657
|
+
const nameMatch = /\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
|
|
658
|
+
const name = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : null;
|
|
659
|
+
// Strip the `name` attribute from the carried-through attribute
|
|
660
|
+
// string so we can re-add it (with escaping) on the framework slot.
|
|
661
|
+
const otherAttrs = attrsRaw.replace(/\bname\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i, '').trim();
|
|
662
|
+
let fallback = '';
|
|
663
|
+
let totalEnd;
|
|
664
|
+
if (isSelfClose) {
|
|
665
|
+
totalEnd = m.index + fullOpen.length;
|
|
666
|
+
} else {
|
|
667
|
+
const innerStart = m.index + fullOpen.length;
|
|
668
|
+
const closeIdx = findClosingTagInString(rendered, innerStart, 'slot');
|
|
669
|
+
if (closeIdx === -1) {
|
|
670
|
+
fallback = rendered.slice(innerStart);
|
|
671
|
+
totalEnd = rendered.length;
|
|
672
|
+
} else {
|
|
673
|
+
fallback = rendered.slice(innerStart, closeIdx);
|
|
674
|
+
const closeRe = /<\/slot\s*>/i;
|
|
675
|
+
const tail = rendered.slice(closeIdx);
|
|
676
|
+
const closeMatch = closeRe.exec(tail);
|
|
677
|
+
const closeLen = closeMatch ? closeMatch[0].length : '</slot>'.length;
|
|
678
|
+
totalEnd = closeIdx + closeLen;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
const projected = partitioned.get(name);
|
|
682
|
+
const nameAttr = name !== null ? ` name="${escapeAttr(name)}"` : '';
|
|
683
|
+
const extraAttrs = otherAttrs ? ` ${otherAttrs}` : '';
|
|
684
|
+
if (projected !== undefined && !consumedNames.has(name)) {
|
|
685
|
+
consumedNames.add(name);
|
|
686
|
+
result += `<slot data-webjs-light data-projection="actual"${nameAttr}${extraAttrs}>${projected}</slot>`;
|
|
687
|
+
} else {
|
|
688
|
+
result += `<slot data-webjs-light data-projection="fallback"${nameAttr}${extraAttrs}>${fallback}</slot>`;
|
|
689
|
+
}
|
|
690
|
+
cursor = totalEnd;
|
|
691
|
+
slotRe.lastIndex = totalEnd;
|
|
692
|
+
}
|
|
693
|
+
result += rendered.slice(cursor);
|
|
694
|
+
return result;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** @param {string} s */
|
|
698
|
+
function escapeRegex(s) {
|
|
699
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** @param {string} tag */
|
|
703
|
+
function isRawtextTag(tag) {
|
|
704
|
+
return tag === 'script' || tag === 'style';
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Minimal attribute string parser.
|
|
709
|
+
* @param {string} attrStr
|
|
710
|
+
* @returns {Record<string,string>}
|
|
711
|
+
*/
|
|
712
|
+
function parseAttrs(attrStr) {
|
|
713
|
+
/** @type {Record<string,string>} */
|
|
714
|
+
const out = {};
|
|
715
|
+
const re = /([a-zA-Z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
716
|
+
let m;
|
|
717
|
+
while ((m = re.exec(attrStr)) !== null) {
|
|
718
|
+
out[m[1]] = m[2] ?? m[3] ?? m[4] ?? '';
|
|
719
|
+
}
|
|
720
|
+
return out;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Coerce attribute strings to typed properties on a component instance
|
|
725
|
+
* based on its static `properties` declaration.
|
|
726
|
+
*/
|
|
727
|
+
function applyAttrsToInstance(instance, attrs, Cls) {
|
|
728
|
+
const props = Cls.properties || {};
|
|
729
|
+
for (const [key, raw] of Object.entries(attrs)) {
|
|
730
|
+
const def = props[key] || props[camelCase(key)];
|
|
731
|
+
const propName = props[key] ? key : camelCase(key);
|
|
732
|
+
if (!def) {
|
|
733
|
+
instance[propName] = raw;
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (def.type === Number) instance[propName] = Number(raw);
|
|
737
|
+
else if (def.type === Boolean) instance[propName] = raw !== 'false';
|
|
738
|
+
else if (def.type === Object || def.type === Array) {
|
|
739
|
+
try { instance[propName] = JSON.parse(raw); } catch { instance[propName] = raw; }
|
|
740
|
+
} else instance[propName] = raw;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** @param {string} s */
|
|
745
|
+
function camelCase(s) {
|
|
746
|
+
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Inverse of camelCase. `userName` -> `user-name`, `userID` -> `user-i-d`.
|
|
751
|
+
* Used to serialize property-binding names into HTML attribute names,
|
|
752
|
+
* which are case-insensitive in the parser. The original JS property
|
|
753
|
+
* name is recovered via camelCase() on the consumer side.
|
|
754
|
+
*
|
|
755
|
+
* @param {string} s
|
|
756
|
+
*/
|
|
757
|
+
function kebabCase(s) {
|
|
758
|
+
return s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Reverse `escapeAttr` on a server-side attribute value. Needed
|
|
763
|
+
* because `parseAttrs` returns the literal characters between the
|
|
764
|
+
* quote marks; HTML entities are not decoded by the regex. The
|
|
765
|
+
* browser handles this automatically, so client-side reads via
|
|
766
|
+
* `getAttribute()` do not need the same step.
|
|
767
|
+
*
|
|
768
|
+
* @param {string} s
|
|
769
|
+
*/
|
|
770
|
+
function unescapeAttr(s) {
|
|
771
|
+
return s
|
|
772
|
+
.replace(/</g, '<')
|
|
773
|
+
.replace(/"/g, '"')
|
|
774
|
+
.replace(/&/g, '&');
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Decode `data-webjs-prop-<kebab>` attributes from a parsed attribute
|
|
779
|
+
* map, returning a map of camelCase property name to decoded value.
|
|
780
|
+
* Mutates `attrs` by deleting the consumed entries so they do not
|
|
781
|
+
* appear in the rendered output a second time.
|
|
782
|
+
*
|
|
783
|
+
* @param {Record<string,string>} attrs
|
|
784
|
+
* @returns {Record<string, unknown>}
|
|
785
|
+
*/
|
|
786
|
+
function consumePropAttrs(attrs) {
|
|
787
|
+
/** @type {Record<string, unknown>} */
|
|
788
|
+
const props = {};
|
|
789
|
+
for (const key of Object.keys(attrs)) {
|
|
790
|
+
if (!key.startsWith('data-webjs-prop-')) continue;
|
|
791
|
+
const propName = camelCase(key.slice('data-webjs-prop-'.length));
|
|
792
|
+
try {
|
|
793
|
+
props[propName] = parse(unescapeAttr(attrs[key]));
|
|
794
|
+
} catch {
|
|
795
|
+
// Malformed payload. Skip silently so the rest of the component
|
|
796
|
+
// can still render. The client-side hydration will also try and
|
|
797
|
+
// fail, which is fine: undefined-prop semantics.
|
|
798
|
+
}
|
|
799
|
+
delete attrs[key];
|
|
800
|
+
}
|
|
801
|
+
return props;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ---------------------------------------------------------------------------
|
|
805
|
+
// Streaming renderer
|
|
806
|
+
// ---------------------------------------------------------------------------
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Render a TemplateResult (or any renderable value) to a `ReadableStream`
|
|
810
|
+
* that yields HTML chunks as strings.
|
|
811
|
+
*
|
|
812
|
+
* Works identically to {@link renderToString} but streams partial HTML as
|
|
813
|
+
* it is rendered: avoiding buffering the entire page in memory. For
|
|
814
|
+
* Suspense boundaries, the fallback is yielded immediately and resolved
|
|
815
|
+
* content is streamed afterwards at the end of the response.
|
|
816
|
+
*
|
|
817
|
+
* **AI hint:** Use `renderToStream` when you want to pipe SSR output
|
|
818
|
+
* directly into a `Response` for streaming delivery (e.g. HTTP chunked
|
|
819
|
+
* transfer). It accepts the same arguments as `renderToString`.
|
|
820
|
+
*
|
|
821
|
+
* @param {unknown} value A TemplateResult, string, array, or any renderable.
|
|
822
|
+
* @param {{ ssr?: boolean, suspenseCtx?: SuspenseCtx }} [opts]
|
|
823
|
+
* @returns {ReadableStream<string>}
|
|
824
|
+
*/
|
|
825
|
+
export function renderToStream(value, opts = { ssr: true }) {
|
|
826
|
+
const ctx = opts && opts.suspenseCtx;
|
|
827
|
+
return new ReadableStream({
|
|
828
|
+
async start(controller) {
|
|
829
|
+
try {
|
|
830
|
+
if (opts && opts.ssr === false) {
|
|
831
|
+
// No DSD injection: just stream the raw rendered chunks.
|
|
832
|
+
await streamRender(value, ctx, controller);
|
|
833
|
+
} else {
|
|
834
|
+
// Render to string first to run DSD injection (which operates on
|
|
835
|
+
// the full HTML), then enqueue the result. This matches the
|
|
836
|
+
// semantics of renderToString but still gives us a stream.
|
|
837
|
+
const html = await render(value, ctx);
|
|
838
|
+
const full = await injectDSD(html, ctx);
|
|
839
|
+
controller.enqueue(full);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Stream resolved Suspense boundaries after the main content.
|
|
843
|
+
if (ctx && ctx.pending.length) {
|
|
844
|
+
await streamSuspenseBoundaries(ctx, controller);
|
|
845
|
+
}
|
|
846
|
+
controller.close();
|
|
847
|
+
} catch (err) {
|
|
848
|
+
controller.error(err);
|
|
849
|
+
}
|
|
850
|
+
},
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Recursively render a value, enqueuing HTML chunks into the stream
|
|
856
|
+
* controller as they become available.
|
|
857
|
+
*
|
|
858
|
+
* @param {unknown} value
|
|
859
|
+
* @param {SuspenseCtx} [ctx]
|
|
860
|
+
* @param {ReadableStreamDefaultController<string>} controller
|
|
861
|
+
*/
|
|
862
|
+
async function streamRender(value, ctx, controller) {
|
|
863
|
+
if (value == null || value === false || value === true) return;
|
|
864
|
+
if (value && typeof /** @type any */ (value).then === 'function') {
|
|
865
|
+
value = await value;
|
|
866
|
+
return streamRender(value, ctx, controller);
|
|
867
|
+
}
|
|
868
|
+
if (isUnsafeHTML(value)) {
|
|
869
|
+
controller.enqueue(String(/** @type any */ (value).value ?? ''));
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
if (isLive(value)) {
|
|
873
|
+
return streamRender(/** @type any */ (value).value, ctx, controller);
|
|
874
|
+
}
|
|
875
|
+
if (isWatch(value)) {
|
|
876
|
+
return streamRender(/** @type any */ (value).signal.get(), ctx, controller);
|
|
877
|
+
}
|
|
878
|
+
if (isKeyed(value)) {
|
|
879
|
+
return streamRender(/** @type any */ (value).value, ctx, controller);
|
|
880
|
+
}
|
|
881
|
+
if (isGuard(value)) {
|
|
882
|
+
return streamRender(/** @type any */ (value).fn(), ctx, controller);
|
|
883
|
+
}
|
|
884
|
+
if (isTemplateContent(value)) {
|
|
885
|
+
const tpl = /** @type any */ (value).template;
|
|
886
|
+
controller.enqueue(String(tpl?.innerHTML ?? ''));
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
if (isRef(value)) {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (isCache(value)) {
|
|
893
|
+
return streamRender(/** @type any */ (value).value, ctx, controller);
|
|
894
|
+
}
|
|
895
|
+
if (isUntil(value)) {
|
|
896
|
+
const args = /** @type any */ (value).args;
|
|
897
|
+
for (const a of args) {
|
|
898
|
+
if (!a || typeof (/** @type any */ (a).then) !== 'function') {
|
|
899
|
+
return streamRender(a, ctx, controller);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if (args.length > 0) {
|
|
903
|
+
try {
|
|
904
|
+
const winner = await Promise.race(args.map((p) => Promise.resolve(p).catch(() => undefined)));
|
|
905
|
+
return streamRender(winner, ctx, controller);
|
|
906
|
+
} catch {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (isAsyncAppend(value) || isAsyncReplace(value)) {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (Array.isArray(value)) {
|
|
916
|
+
for (const v of value) await streamRender(v, ctx, controller);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (isRepeat(value)) {
|
|
920
|
+
const r = /** @type any */ (value);
|
|
921
|
+
for (let i = 0; i < r.items.length; i++) {
|
|
922
|
+
await streamRender(r.templateFn(r.items[i], i), ctx, controller);
|
|
923
|
+
}
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (isSuspense(value)) {
|
|
927
|
+
const s = /** @type any */ (value);
|
|
928
|
+
if (ctx) {
|
|
929
|
+
const id = `s${ctx.nextId++}`;
|
|
930
|
+
controller.enqueue(`<webjs-boundary id="${id}">`);
|
|
931
|
+
await streamRender(s.fallback, ctx, controller);
|
|
932
|
+
controller.enqueue(`</webjs-boundary>`);
|
|
933
|
+
ctx.pending.push({ id, promise: Promise.resolve(s.children) });
|
|
934
|
+
} else {
|
|
935
|
+
await streamRender(s.fallback, ctx, controller);
|
|
936
|
+
}
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (isTemplate(value)) {
|
|
940
|
+
await streamTemplate(/** @type any */ (value), ctx, controller);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
controller.enqueue(escapeText(String(value)));
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Stream a TemplateResult by yielding each static string piece and
|
|
948
|
+
* processing each value hole incrementally.
|
|
949
|
+
*
|
|
950
|
+
* @param {import('./html.js').TemplateResult} tr
|
|
951
|
+
* @param {SuspenseCtx} [ctx]
|
|
952
|
+
* @param {ReadableStreamDefaultController<string>} controller
|
|
953
|
+
*/
|
|
954
|
+
async function streamTemplate(tr, ctx, controller) {
|
|
955
|
+
const { strings, values } = tr;
|
|
956
|
+
let state = 'text';
|
|
957
|
+
let attrName = '';
|
|
958
|
+
let attrStart = 0;
|
|
959
|
+
let attrQuote = '';
|
|
960
|
+
let commentDashes = 0;
|
|
961
|
+
let currentTag = '';
|
|
962
|
+
let rawTail = '';
|
|
963
|
+
// Buffer used for attribute handling where we may need to backtrack.
|
|
964
|
+
let buf = '';
|
|
965
|
+
|
|
966
|
+
for (let i = 0; i < strings.length; i++) {
|
|
967
|
+
const s = strings[i];
|
|
968
|
+
for (let j = 0; j < s.length; j++) {
|
|
969
|
+
const c = s[j];
|
|
970
|
+
switch (state) {
|
|
971
|
+
case 'text':
|
|
972
|
+
buf += c;
|
|
973
|
+
if (c === '<') state = 'tag-open';
|
|
974
|
+
break;
|
|
975
|
+
case 'tag-open':
|
|
976
|
+
buf += c;
|
|
977
|
+
if (c === '!') state = 'bang-1';
|
|
978
|
+
else if (c === '/') { state = 'tag-name'; currentTag = ''; }
|
|
979
|
+
else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
|
|
980
|
+
else state = 'text';
|
|
981
|
+
break;
|
|
982
|
+
case 'bang-1':
|
|
983
|
+
buf += c;
|
|
984
|
+
state = c === '-' ? 'bang-dash' : 'tag-name';
|
|
985
|
+
break;
|
|
986
|
+
case 'bang-dash':
|
|
987
|
+
buf += c;
|
|
988
|
+
if (c === '-') { state = 'comment'; commentDashes = 0; }
|
|
989
|
+
else state = 'tag-name';
|
|
990
|
+
break;
|
|
991
|
+
case 'comment':
|
|
992
|
+
buf += c;
|
|
993
|
+
if (c === '-') commentDashes += 1;
|
|
994
|
+
else if (c === '>' && commentDashes >= 2) { state = 'text'; commentDashes = 0; }
|
|
995
|
+
else commentDashes = 0;
|
|
996
|
+
break;
|
|
997
|
+
case 'tag-name':
|
|
998
|
+
buf += c;
|
|
999
|
+
if (c === '>') {
|
|
1000
|
+
state = isRawtextTag(currentTag) ? 'rawtext' : 'text';
|
|
1001
|
+
if (state === 'rawtext') rawTail = '';
|
|
1002
|
+
} else if (/\s/.test(c)) state = 'in-tag';
|
|
1003
|
+
else currentTag += c.toLowerCase();
|
|
1004
|
+
break;
|
|
1005
|
+
case 'in-tag':
|
|
1006
|
+
buf += c;
|
|
1007
|
+
if (c === '>') {
|
|
1008
|
+
state = isRawtextTag(currentTag) ? 'rawtext' : 'text';
|
|
1009
|
+
if (state === 'rawtext') rawTail = '';
|
|
1010
|
+
} else if (!/\s/.test(c) && c !== '/') {
|
|
1011
|
+
state = 'attr-name';
|
|
1012
|
+
attrName = c;
|
|
1013
|
+
attrStart = buf.length - 1;
|
|
1014
|
+
}
|
|
1015
|
+
break;
|
|
1016
|
+
case 'rawtext':
|
|
1017
|
+
buf += c;
|
|
1018
|
+
rawTail = (rawTail + c.toLowerCase()).slice(-9);
|
|
1019
|
+
if (rawTail.endsWith('</script>') || rawTail.endsWith('</style>')) {
|
|
1020
|
+
state = 'text';
|
|
1021
|
+
rawTail = '';
|
|
1022
|
+
currentTag = '';
|
|
1023
|
+
}
|
|
1024
|
+
break;
|
|
1025
|
+
case 'attr-name':
|
|
1026
|
+
if (c === '=') { state = 'after-eq'; buf += c; }
|
|
1027
|
+
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
|
|
1028
|
+
else if (c === '>') { state = 'text'; attrName = ''; buf += c; }
|
|
1029
|
+
else { attrName += c; buf += c; }
|
|
1030
|
+
break;
|
|
1031
|
+
case 'after-eq':
|
|
1032
|
+
if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; buf += c; }
|
|
1033
|
+
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
|
|
1034
|
+
else if (c === '>') { state = 'text'; attrName = ''; buf += c; }
|
|
1035
|
+
else { state = 'attr-unquoted'; buf += c; }
|
|
1036
|
+
break;
|
|
1037
|
+
case 'attr-unquoted':
|
|
1038
|
+
if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
|
|
1039
|
+
else if (c === '>') { state = 'text'; attrName = ''; buf += c; }
|
|
1040
|
+
else buf += c;
|
|
1041
|
+
break;
|
|
1042
|
+
case 'attr-quoted':
|
|
1043
|
+
buf += c;
|
|
1044
|
+
if (c === attrQuote) { state = 'in-tag'; attrName = ''; }
|
|
1045
|
+
break;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Flush the buffer before processing the value hole: but only when
|
|
1050
|
+
// we're in text state (in attribute states we may need the buffer for
|
|
1051
|
+
// backtracking).
|
|
1052
|
+
if (i < values.length) {
|
|
1053
|
+
let val = values[i];
|
|
1054
|
+
if (val && typeof /** @type any */ (val).then === 'function') {
|
|
1055
|
+
val = await val;
|
|
1056
|
+
}
|
|
1057
|
+
if (state === 'comment') {
|
|
1058
|
+
buf += String(val ?? '');
|
|
1059
|
+
commentDashes = 0;
|
|
1060
|
+
} else if (state === 'rawtext') {
|
|
1061
|
+
buf += String(val ?? '');
|
|
1062
|
+
rawTail = '';
|
|
1063
|
+
} else if (state === 'text') {
|
|
1064
|
+
// Flush the buffered static content before streaming the value.
|
|
1065
|
+
if (buf) { controller.enqueue(buf); buf = ''; }
|
|
1066
|
+
await streamRender(val, ctx, controller);
|
|
1067
|
+
} else if (state === 'after-eq') {
|
|
1068
|
+
const prefix = attrName[0];
|
|
1069
|
+
const name = attrName.slice(1);
|
|
1070
|
+
if (prefix === '@' || prefix === '.') {
|
|
1071
|
+
buf = buf.slice(0, attrStart);
|
|
1072
|
+
state = 'in-tag';
|
|
1073
|
+
attrName = '';
|
|
1074
|
+
} else if (prefix === '?') {
|
|
1075
|
+
buf = buf.slice(0, attrStart);
|
|
1076
|
+
if (val) buf += `${name}=""`;
|
|
1077
|
+
state = 'in-tag';
|
|
1078
|
+
attrName = '';
|
|
1079
|
+
} else {
|
|
1080
|
+
buf += `"${escapeAttr(String(val ?? ''))}"`;
|
|
1081
|
+
state = 'in-tag';
|
|
1082
|
+
attrName = '';
|
|
1083
|
+
}
|
|
1084
|
+
} else if (state === 'attr-quoted' || state === 'attr-unquoted') {
|
|
1085
|
+
buf += escapeAttr(String(val ?? ''));
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Flush any remaining buffer content.
|
|
1091
|
+
if (buf) controller.enqueue(buf);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* After the main HTML has been streamed, resolve pending Suspense promises
|
|
1096
|
+
* and stream their replacement content as out-of-order `<template>` tags
|
|
1097
|
+
* with tiny inline scripts that swap the fallback for the resolved HTML.
|
|
1098
|
+
*
|
|
1099
|
+
* @param {SuspenseCtx} ctx
|
|
1100
|
+
* @param {ReadableStreamDefaultController<string>} controller
|
|
1101
|
+
*/
|
|
1102
|
+
async function streamSuspenseBoundaries(ctx, controller) {
|
|
1103
|
+
while (ctx.pending.length) {
|
|
1104
|
+
const batch = ctx.pending.splice(0);
|
|
1105
|
+
await Promise.all(
|
|
1106
|
+
batch.map(async ({ id, promise }) => {
|
|
1107
|
+
try {
|
|
1108
|
+
const resolved = await promise;
|
|
1109
|
+
const html = await render(resolved, ctx);
|
|
1110
|
+
const full = await injectDSD(html, ctx);
|
|
1111
|
+
controller.enqueue(
|
|
1112
|
+
`<template data-webjs-resolve="${id}">${full}</template>` +
|
|
1113
|
+
`<script>` +
|
|
1114
|
+
`(function(){` +
|
|
1115
|
+
`var t=document.currentScript.previousElementSibling;` +
|
|
1116
|
+
`var b=document.getElementById("${id}");` +
|
|
1117
|
+
`if(b&&t){b.innerHTML=t.innerHTML;t.remove()}` +
|
|
1118
|
+
`document.currentScript.remove()` +
|
|
1119
|
+
`})()` +
|
|
1120
|
+
`</script>`
|
|
1121
|
+
);
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
console.error(`[webjs] Suspense boundary "${id}" rejected:`, err);
|
|
1124
|
+
}
|
|
1125
|
+
})
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
}
|