@webjsdev/core 0.7.49 → 0.7.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +21 -21
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -1
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/attribute-reader.js +178 -0
- package/src/component/lifecycle.js +1481 -0
- package/src/component/reactive.js +171 -0
- package/src/component/server-element.js +248 -0
- package/src/component.d.ts +10 -0
- package/src/component.js +2 -1931
- package/src/html-entities.js +2179 -0
- package/src/render-client/parts.js +2007 -0
- package/src/render-client/reconciler.js +388 -0
- package/src/render-client/template-compiler.js +525 -0
- package/src/render-client.js +1 -2939
- package/src/render-server/attrs.js +160 -0
- package/src/render-server/dsd.js +445 -0
- package/src/render-server/html-scan.js +402 -0
- package/src/render-server/slots.js +213 -0
- package/src/render-server/stream.js +163 -0
- package/src/render-server/template-renderer.js +923 -0
- package/src/render-server/text.js +163 -0
- package/src/render-server.js +6 -2334
- package/src/router-client/anchors.js +44 -0
- package/src/router-client/boundaries.js +221 -0
- package/src/router-client/constants.js +187 -0
- package/src/router-client/diagnostics.js +377 -0
- package/src/router-client/dom-differ.js +709 -0
- package/src/router-client/dom-parse.js +150 -0
- package/src/router-client/events.js +139 -0
- package/src/router-client/fetch-apply.js +387 -0
- package/src/router-client/form-encoder.js +135 -0
- package/src/router-client/frames.js +329 -0
- package/src/router-client/head-merge.js +344 -0
- package/src/router-client/nav-error.js +105 -0
- package/src/router-client/navigator.js +781 -0
- package/src/router-client/prefetch.js +824 -0
- package/src/router-client/scroll.js +259 -0
- package/src/router-client/snapshot-cache.js +97 -0
- package/src/router-client/state.js +92 -0
- package/src/router-client/stream.js +206 -0
- package/src/router-client/swap.js +451 -0
- package/src/router-client/upgrade.js +261 -0
- package/src/router-client/view-transition.js +223 -0
- package/src/router-client.d.ts +10 -1
- package/src/router-client.js +75 -5345
- package/src/slot/assignment.js +304 -0
- package/src/slot/interception.js +558 -0
- package/src/slot/polyfills.js +365 -0
- package/src/slot/project.js +432 -0
- package/src/slot/sensors.js +303 -0
- package/src/slot/state.js +245 -0
- package/src/slot/symbols.js +85 -0
- package/src/slot.js +7 -2199
- package/src/webjs-config.d.ts +13 -2
package/src/render-server.js
CHANGED
|
@@ -1,2334 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
assertConvergentBoundForm, assertSubmitterHasNoName, assertSubmitterHasNoValue,
|
|
8
|
-
assertSubmitterHasNoFormAttribute,
|
|
9
|
-
assertSingleSubmitterAction, bindSubmitterStartTag, parseStartTagAttrs,
|
|
10
|
-
isSubmitterReflectedProp, FORM_ACTION_FIELD,
|
|
11
|
-
} from './form-action.js';
|
|
12
|
-
import { lookup, lookupModuleUrl, allTags } from './registry.js';
|
|
13
|
-
import { stylesToString, isCSS } from './css.js';
|
|
14
|
-
import { isRepeat } from './repeat.js';
|
|
15
|
-
import { isSuspense } from './suspense.js';
|
|
16
|
-
import { unsafeHTML, isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
|
|
17
|
-
import { stringify, parse } from './serialize.js';
|
|
18
|
-
import { cspNonce } from './csp-nonce.js';
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Render a TemplateResult (or any renderable value) to an HTML string.
|
|
22
|
-
*
|
|
23
|
-
* Async by design: template holes may be Promises, components' `render()`
|
|
24
|
-
* methods may be async, and data-fetching inside nested components is
|
|
25
|
-
* awaited before the final string is emitted.
|
|
26
|
-
*
|
|
27
|
-
* If `opts.suspenseCtx` is provided, Suspense boundaries encountered during
|
|
28
|
-
* the render will push `{ id, promise }` into `opts.suspenseCtx.pending`
|
|
29
|
-
* and their fallback HTML is emitted immediately. The caller is responsible
|
|
30
|
-
* for streaming each resolved promise afterwards. Without a suspenseCtx,
|
|
31
|
-
* Suspense still works but we fall back to emitting only the fallback
|
|
32
|
-
* (the promise is dropped: appropriate for static pre-render).
|
|
33
|
-
*
|
|
34
|
-
* @typedef {{ pending: {id: string, promise: Promise<unknown>}[], nextId: number }} SuspenseCtx
|
|
35
|
-
*
|
|
36
|
-
* A boundary's enclosing form scope used to be recorded here and threaded back
|
|
37
|
-
* in (#1207), because the page pipeline in `@webjsdev/server` drains
|
|
38
|
-
* `ctx.pending` and re-renders each resolved child through a FRESH scan with no
|
|
39
|
-
* view of the shell it belongs to, so a `<button formaction=${fn}>` inside a
|
|
40
|
-
* bound form's Suspense boundary read as form-less and was refused. Gone with
|
|
41
|
-
* #1307: a bound submitter carries its own `formmethod` and enctype, so no
|
|
42
|
-
* renderer needs to know what encloses it and there is nothing left to thread.
|
|
43
|
-
*
|
|
44
|
-
* @param {unknown} value
|
|
45
|
-
* @param {{ ssr?: boolean, suspenseCtx?: SuspenseCtx, dev?: boolean }} [opts]
|
|
46
|
-
* @returns {Promise<string>}
|
|
47
|
-
*/
|
|
48
|
-
export async function renderToString(value, opts = { ssr: true }) {
|
|
49
|
-
const ctx = opts && opts.suspenseCtx;
|
|
50
|
-
// The server `dev` flag drives prod-silence of SSR error states (#483).
|
|
51
|
-
// `opts.dev` wins; else inherit a `dev` already stamped on the ctx (so a
|
|
52
|
-
// streamed sub-context inherits it); back-fill the ctx so downstream renders
|
|
53
|
-
// sharing it see the same flag. Undefined stays undefined (NODE_ENV fallback).
|
|
54
|
-
const dev = opts && opts.dev !== undefined ? opts.dev : ctx && ctx.dev;
|
|
55
|
-
if (ctx && ctx.dev === undefined && dev !== undefined) ctx.dev = dev;
|
|
56
|
-
const html = await render(value, ctx);
|
|
57
|
-
return opts && opts.ssr === false ? html : await injectDSD(html, ctx, [], dev);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* @param {unknown} value
|
|
62
|
-
* @param {SuspenseCtx} [ctx]
|
|
63
|
-
* @returns {Promise<string>}
|
|
64
|
-
*/
|
|
65
|
-
async function render(value, ctx) {
|
|
66
|
-
if (value == null || value === false || value === true) return '';
|
|
67
|
-
if (value && typeof /** @type any */ (value).then === 'function') {
|
|
68
|
-
value = await value;
|
|
69
|
-
return render(value, ctx);
|
|
70
|
-
}
|
|
71
|
-
// unsafeHTML: inject raw HTML string without escaping.
|
|
72
|
-
if (isUnsafeHTML(value)) {
|
|
73
|
-
return String(/** @type any */ (value).value ?? '');
|
|
74
|
-
}
|
|
75
|
-
// live() on the server just unwraps and renders the inner value.
|
|
76
|
-
if (isLive(value)) {
|
|
77
|
-
return render(/** @type any */ (value).value, ctx);
|
|
78
|
-
}
|
|
79
|
-
// watch() on the server reads the signal once and inlines the
|
|
80
|
-
// result. Subscription is a client-only concern; the SSR HTML
|
|
81
|
-
// freezes a snapshot of the current value.
|
|
82
|
-
if (isWatch(value)) {
|
|
83
|
-
return render(/** @type any */ (value).signal.get(), ctx);
|
|
84
|
-
}
|
|
85
|
-
// keyed() on the server: render the wrapped template; key is client-only.
|
|
86
|
-
if (isKeyed(value)) {
|
|
87
|
-
return render(/** @type any */ (value).value, ctx);
|
|
88
|
-
}
|
|
89
|
-
// guard() on the server: always invoke the value function (no cache on SSR).
|
|
90
|
-
if (isGuard(value)) {
|
|
91
|
-
return render(/** @type any */ (value).fn(), ctx);
|
|
92
|
-
}
|
|
93
|
-
// templateContent() on the server: emit the template's innerHTML verbatim.
|
|
94
|
-
if (isTemplateContent(value)) {
|
|
95
|
-
const tpl = /** @type any */ (value).template;
|
|
96
|
-
return String(tpl?.innerHTML ?? '');
|
|
97
|
-
}
|
|
98
|
-
// ref() on the server: no-op (no DOM yet). Returns empty string.
|
|
99
|
-
if (isRef(value)) {
|
|
100
|
-
return '';
|
|
101
|
-
}
|
|
102
|
-
// cache() on the server: pass-through to the inner value.
|
|
103
|
-
if (isCache(value)) {
|
|
104
|
-
return render(/** @type any */ (value).value, ctx);
|
|
105
|
-
}
|
|
106
|
-
// until() on the server: render the first synchronous candidate, or
|
|
107
|
-
// await the first Promise to settle when all candidates are Promises.
|
|
108
|
-
// Rejections are swallowed (treated as "no value"); if every candidate
|
|
109
|
-
// rejects, render empty rather than crash the SSR pipeline.
|
|
110
|
-
if (isUntil(value)) {
|
|
111
|
-
const args = /** @type any */ (value).args;
|
|
112
|
-
for (const a of args) {
|
|
113
|
-
if (!a || typeof (/** @type any */ (a).then) !== 'function') {
|
|
114
|
-
return render(a, ctx);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
if (args.length > 0) {
|
|
118
|
-
try {
|
|
119
|
-
const winner = await Promise.race(args.map((p) => Promise.resolve(p).catch(() => undefined)));
|
|
120
|
-
return render(winner, ctx);
|
|
121
|
-
} catch {
|
|
122
|
-
return '';
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return '';
|
|
126
|
-
}
|
|
127
|
-
// asyncAppend / asyncReplace on the server: render empty. Full
|
|
128
|
-
// streaming is a follow-up; pages should use Suspense for streaming.
|
|
129
|
-
if (isAsyncAppend(value) || isAsyncReplace(value)) {
|
|
130
|
-
return '';
|
|
131
|
-
}
|
|
132
|
-
if (Array.isArray(value)) {
|
|
133
|
-
const parts = await Promise.all(value.map((v) => render(v, ctx)));
|
|
134
|
-
return parts.join('');
|
|
135
|
-
}
|
|
136
|
-
if (isRepeat(value)) {
|
|
137
|
-
const r = /** @type any */ (value);
|
|
138
|
-
const parts = await Promise.all(r.items.map((it, i) => render(r.templateFn(it, i), ctx)));
|
|
139
|
-
return parts.join('');
|
|
140
|
-
}
|
|
141
|
-
if (isSuspense(value)) {
|
|
142
|
-
const s = /** @type any */ (value);
|
|
143
|
-
const fallback = await render(s.fallback, ctx);
|
|
144
|
-
if (ctx) {
|
|
145
|
-
const id = `s${ctx.nextId++}`;
|
|
146
|
-
ctx.pending.push({ id, promise: Promise.resolve(s.children) });
|
|
147
|
-
return `<webjs-boundary id="${id}">${fallback}</webjs-boundary>`;
|
|
148
|
-
}
|
|
149
|
-
return fallback;
|
|
150
|
-
}
|
|
151
|
-
if (isTemplate(value)) return renderTemplate(/** @type any */ (value), ctx);
|
|
152
|
-
return escapeText(String(value));
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* @param {import('./html.js').TemplateResult} tr
|
|
157
|
-
* @param {SuspenseCtx} [ctx]
|
|
158
|
-
* @returns {Promise<string>}
|
|
159
|
-
*/
|
|
160
|
-
async function renderTemplate(tr, ctx) {
|
|
161
|
-
const { strings, values } = tr;
|
|
162
|
-
let out = '';
|
|
163
|
-
let state = 'text';
|
|
164
|
-
let attrName = '';
|
|
165
|
-
let attrStart = 0;
|
|
166
|
-
let attrQuote = '';
|
|
167
|
-
let commentDashes = 0;
|
|
168
|
-
let currentTag = ''; // lowercased tag name currently being parsed
|
|
169
|
-
let rawTail = ''; // rolling lowercased tail, tracks </script>/</style>
|
|
170
|
-
let tagStart = -1; // index in `out` of the `<` opening the current tag
|
|
171
|
-
/** @type {string | null} */
|
|
172
|
-
let pendingActionId = null; // identity of a bound form action, until the tag closes
|
|
173
|
-
/** @type {string | null} */
|
|
174
|
-
let pendingSubmitterTag = null; // tag of a bound submitter, until it closes
|
|
175
|
-
// Shapes on the CURRENT start tag that a bound form may not carry (#1155).
|
|
176
|
-
// Collected as the tag is scanned and judged at its `>`, because the action
|
|
177
|
-
// hole may come after them.
|
|
178
|
-
let pendingActionCount = 0;
|
|
179
|
-
/** @type {string[]} */
|
|
180
|
-
let pendingPropAttrs = [];
|
|
181
|
-
/** @type {string[]} */
|
|
182
|
-
let pendingSubmitterProps = [];
|
|
183
|
-
// Whether the tag stream is currently inside a form that BOUND an action
|
|
184
|
-
// (#1207), as THIS scan can see it. Three states, and the third is the point:
|
|
185
|
-
// 'bound' an enclosing <form> opened here and bound an action
|
|
186
|
-
// 'unbound' an enclosing <form> opened here and bound nothing
|
|
187
|
-
// 'none' there is conclusively no enclosing <form>
|
|
188
|
-
// 'unknown' there may be one, but this scan cannot see it
|
|
189
|
-
//
|
|
190
|
-
// The last two look alike and must not be merged, which is what a boolean did.
|
|
191
|
-
// A component's template is rendered by a SEPARATE pass (`injectDSD` calls
|
|
192
|
-
// `render` on it), so a `<button formaction=${fn}>` inside a component inside
|
|
193
|
-
// a bound form read as "no form", was refused, and in production vanished
|
|
194
|
-
// from a page that still returned 200. That pass now says 'unknown' and the
|
|
195
|
-
// boundness question is skipped, exactly as the client skips it when it
|
|
196
|
-
// cannot reach the form, so both renderers are best effort in the same place
|
|
197
|
-
// and for the same reason. A top-level scan that simply contains no form
|
|
198
|
-
// stays 'none' and is still refused, because there the answer IS known.
|
|
199
|
-
//
|
|
200
|
-
let isCloseTag = false;
|
|
201
|
-
|
|
202
|
-
// A bound `action=${fn}` is committed at its hole, but the edits it implies
|
|
203
|
-
// (forcing `method` / `enctype`, and the hidden identity field) are only
|
|
204
|
-
// possible once the whole start tag is known: an attribute the author wrote
|
|
205
|
-
// AFTER the action hole still counts, and the hidden field belongs INSIDE
|
|
206
|
-
// the form, after the `>`. So the hole records the identity and this runs at
|
|
207
|
-
// the `>`, rewriting the start tag that was just emitted.
|
|
208
|
-
const closeBoundFormTag = () => {
|
|
209
|
-
// Reset per tag whether or not this one was bound, so a later form is never
|
|
210
|
-
// judged on an earlier tag's shapes.
|
|
211
|
-
const propAttrs = pendingPropAttrs;
|
|
212
|
-
const submitterProps = pendingSubmitterProps;
|
|
213
|
-
const duplicateAction = pendingActionCount > 1;
|
|
214
|
-
const submitterTag = pendingSubmitterTag;
|
|
215
|
-
pendingPropAttrs = [];
|
|
216
|
-
pendingSubmitterProps = [];
|
|
217
|
-
pendingActionCount = 0;
|
|
218
|
-
pendingSubmitterTag = null;
|
|
219
|
-
if (pendingActionId != null) {
|
|
220
|
-
assertConvergentBoundForm({ duplicateAction, propAttrs });
|
|
221
|
-
const bound = bindFormActionStartTag(out.slice(tagStart), pendingActionId);
|
|
222
|
-
out = out.slice(0, tagStart) + bound.tag + bound.hidden;
|
|
223
|
-
pendingActionId = null;
|
|
224
|
-
}
|
|
225
|
-
if (submitterTag != null) {
|
|
226
|
-
// #1307: a bound submitter carries its WHOLE submission, so `formmethod`
|
|
227
|
-
// and the enctype are injected onto the button here rather than inherited
|
|
228
|
-
// from a form this scan may not even be able to see. That is what removed
|
|
229
|
-
// the enclosing-form question, and with it the four-state scope tracking
|
|
230
|
-
// that could never answer it for a button inside a component.
|
|
231
|
-
out = out.slice(0, tagStart)
|
|
232
|
-
+ bindSubmitterStartTag(out.slice(tagStart), submitterTag, { duplicateAction, propAttrs: submitterProps });
|
|
233
|
-
}
|
|
234
|
-
};
|
|
235
|
-
// #1155: a `.method` / `.enctype` / `.encoding` prop on a form is dropped
|
|
236
|
-
// here but applied for real in the browser, where all three are reflected IDL
|
|
237
|
-
// attributes, so a bound form carrying one submits differently with JS than
|
|
238
|
-
// without it. Recorded and refused at the `>`, once the tag's action hole is
|
|
239
|
-
// known.
|
|
240
|
-
const notePropAttr = (name, tag) => {
|
|
241
|
-
const t = String(tag).toLowerCase();
|
|
242
|
-
if (t === 'button' || t === 'input') {
|
|
243
|
-
// #1207: the submitter twin. `name` / `value` / `formAction` / `formMethod`
|
|
244
|
-
// / `formEnctype` all reflect on a submitter, so a `.prop` spelling is
|
|
245
|
-
// dropped here and written to the attribute in the browser.
|
|
246
|
-
if (isSubmitterReflectedProp(name)) pendingSubmitterProps.push(String(name));
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
if (t !== 'form') return;
|
|
250
|
-
let n = String(name).toLowerCase();
|
|
251
|
-
if (n === 'encoding') n = 'enctype';
|
|
252
|
-
if (n === 'method' || n === 'enctype') pendingPropAttrs.push(String(name));
|
|
253
|
-
};
|
|
254
|
-
const noteActionHole = (name, tag) => {
|
|
255
|
-
const t = String(tag).toLowerCase();
|
|
256
|
-
const n = String(name).toLowerCase();
|
|
257
|
-
if ((t === 'form' && n === 'action') ||
|
|
258
|
-
((t === 'button' || t === 'input') && n === 'formaction')) {
|
|
259
|
-
pendingActionCount += 1;
|
|
260
|
-
}
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
// Every `>` in a tag state funnels through here, so the bound-form bookkeeping
|
|
264
|
-
// stays in one place rather than at five call sites.
|
|
265
|
-
//
|
|
266
|
-
// `allowRawtext` is NOT a preference. Only two of those five call sites ever
|
|
267
|
-
// entered rawtext: the `tag-name` and `in-tag` exits. The three attribute
|
|
268
|
-
// exits (`attr-name`, `after-eq`, `attr-unquoted`) always forced `text`, so
|
|
269
|
-
// `<script defer>` and `<style media=print>`, whose start tags end on a bare
|
|
270
|
-
// or unquoted attribute, escaped their bodies. Switching them to rawtext here
|
|
271
|
-
// would silently turn `<script defer>${userInput}</script>` from escaped into
|
|
272
|
-
// raw script, which is an XSS mitigation this change has no business
|
|
273
|
-
// touching. Whether that escaping is the RIGHT behaviour is a separate
|
|
274
|
-
// question from #1207; this preserves it exactly.
|
|
275
|
-
const handleTagEnd = (allowRawtext) => {
|
|
276
|
-
closeBoundFormTag();
|
|
277
|
-
isCloseTag = false;
|
|
278
|
-
state = allowRawtext && isRawtextTag(currentTag) ? 'rawtext' : 'text';
|
|
279
|
-
if (state === 'rawtext') rawTail = '';
|
|
280
|
-
};
|
|
281
|
-
|
|
282
|
-
for (let i = 0; i < strings.length; i++) {
|
|
283
|
-
const s = strings[i];
|
|
284
|
-
for (let j = 0; j < s.length; j++) {
|
|
285
|
-
const c = s[j];
|
|
286
|
-
switch (state) {
|
|
287
|
-
case 'text':
|
|
288
|
-
out += c;
|
|
289
|
-
if (c === '<') { state = 'tag-open'; tagStart = out.length - 1; isCloseTag = false; }
|
|
290
|
-
break;
|
|
291
|
-
case 'tag-open':
|
|
292
|
-
out += c;
|
|
293
|
-
if (c === '!') state = 'bang-1';
|
|
294
|
-
else if (c === '/') { state = 'tag-name'; currentTag = ''; isCloseTag = true; }
|
|
295
|
-
else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
|
|
296
|
-
else state = 'text';
|
|
297
|
-
break;
|
|
298
|
-
case 'bang-1':
|
|
299
|
-
out += c;
|
|
300
|
-
state = c === '-' ? 'bang-dash' : 'tag-name';
|
|
301
|
-
break;
|
|
302
|
-
case 'bang-dash':
|
|
303
|
-
out += c;
|
|
304
|
-
if (c === '-') { state = 'comment'; commentDashes = 0; }
|
|
305
|
-
else state = 'tag-name';
|
|
306
|
-
break;
|
|
307
|
-
case 'comment':
|
|
308
|
-
out += c;
|
|
309
|
-
if (c === '-') commentDashes += 1;
|
|
310
|
-
else if (c === '>' && commentDashes >= 2) { state = 'text'; commentDashes = 0; }
|
|
311
|
-
else commentDashes = 0;
|
|
312
|
-
break;
|
|
313
|
-
case 'tag-name':
|
|
314
|
-
out += c;
|
|
315
|
-
if (c === '>') {
|
|
316
|
-
handleTagEnd(true);
|
|
317
|
-
} else if (/\s/.test(c)) state = 'in-tag';
|
|
318
|
-
else currentTag += c.toLowerCase();
|
|
319
|
-
break;
|
|
320
|
-
case 'in-tag':
|
|
321
|
-
out += c;
|
|
322
|
-
if (c === '>') {
|
|
323
|
-
handleTagEnd(true);
|
|
324
|
-
} else if (!/\s/.test(c) && c !== '/') {
|
|
325
|
-
state = 'attr-name';
|
|
326
|
-
attrName = c;
|
|
327
|
-
attrStart = out.length - 1;
|
|
328
|
-
}
|
|
329
|
-
break;
|
|
330
|
-
case 'rawtext':
|
|
331
|
-
out += c;
|
|
332
|
-
rawTail = (rawTail + c.toLowerCase()).slice(-9);
|
|
333
|
-
if (rawTail.endsWith('</script>') || rawTail.endsWith('</style>')) {
|
|
334
|
-
state = 'text';
|
|
335
|
-
rawTail = '';
|
|
336
|
-
currentTag = '';
|
|
337
|
-
}
|
|
338
|
-
break;
|
|
339
|
-
case 'attr-name':
|
|
340
|
-
if (c === '=') { state = 'after-eq'; out += c; }
|
|
341
|
-
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
|
|
342
|
-
else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); }
|
|
343
|
-
else { attrName += c; out += c; }
|
|
344
|
-
break;
|
|
345
|
-
case 'after-eq':
|
|
346
|
-
if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; out += c; }
|
|
347
|
-
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
|
|
348
|
-
else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); }
|
|
349
|
-
else { state = 'attr-unquoted'; out += c; }
|
|
350
|
-
break;
|
|
351
|
-
case 'attr-unquoted':
|
|
352
|
-
if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
|
|
353
|
-
else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); }
|
|
354
|
-
else out += c;
|
|
355
|
-
break;
|
|
356
|
-
case 'attr-quoted':
|
|
357
|
-
out += c;
|
|
358
|
-
if (c === attrQuote) { state = 'in-tag'; attrName = ''; }
|
|
359
|
-
break;
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
if (i < values.length) {
|
|
364
|
-
let val = values[i];
|
|
365
|
-
// Resolve promises anywhere in the value graph.
|
|
366
|
-
if (val && typeof /** @type any */ (val).then === 'function') {
|
|
367
|
-
val = await val;
|
|
368
|
-
}
|
|
369
|
-
if (state === 'comment') {
|
|
370
|
-
// Holes inside <!-- comments --> are emitted raw (no escaping; comments
|
|
371
|
-
// are inert and not rendered by browsers).
|
|
372
|
-
out += String(val ?? '');
|
|
373
|
-
commentDashes = 0;
|
|
374
|
-
} else if (state === 'rawtext') {
|
|
375
|
-
// Inside <script> / <style>: emit the value as-is (no HTML escaping).
|
|
376
|
-
// Author is responsible for not closing the tag with user-controlled
|
|
377
|
-
// data: the usual caveat for CSS/JS interpolation.
|
|
378
|
-
out += String(val ?? '');
|
|
379
|
-
rawTail = '';
|
|
380
|
-
} else if (state === 'text') {
|
|
381
|
-
out += await render(val, ctx);
|
|
382
|
-
} else if (state === 'after-eq') {
|
|
383
|
-
const prefix = attrName[0];
|
|
384
|
-
const name = attrName.slice(1);
|
|
385
|
-
const kind = BINDING_PREFIXES[prefix];
|
|
386
|
-
if (kind === 'event') {
|
|
387
|
-
// Event listener. Client-only behaviour, drop at SSR.
|
|
388
|
-
out = out.slice(0, attrStart);
|
|
389
|
-
state = 'in-tag';
|
|
390
|
-
attrName = '';
|
|
391
|
-
} else if (kind === 'prop') {
|
|
392
|
-
// Property binding. Only meaningful on custom elements (which
|
|
393
|
-
// have a hyphen in the tag name and a WebComponent subclass
|
|
394
|
-
// that knows how to apply + strip data-webjs-prop-* on
|
|
395
|
-
// hydration). For native elements (`<input .value=${v}>`)
|
|
396
|
-
// the attribute would be dead weight (nothing consumes it),
|
|
397
|
-
// so we drop it the same way the old behaviour did. The
|
|
398
|
-
// client renderer still applies the property when the
|
|
399
|
-
// template runs in the browser, which is the only place a
|
|
400
|
-
// page-level `.prop` on a native element could have set the
|
|
401
|
-
// property to begin with.
|
|
402
|
-
out = out.slice(0, attrStart);
|
|
403
|
-
// `<webjs-suspense .fallback=${html`...`}>` (#471). This element is
|
|
404
|
-
// defined only in the browser, so the injectDSD walk skips it
|
|
405
|
-
// (`lookup(tag)` finds no class) and no server-side instance runs
|
|
406
|
-
// consumePropAttrs. A normal data-webjs-prop-* binding would then
|
|
407
|
-
// land at connectedCallback, too late for the streaming placeholder.
|
|
408
|
-
// So render the fallback to HTML now and carry it as
|
|
409
|
-
// data-webjs-fallback, which the injectDSD streaming pre-pass reads
|
|
410
|
-
// as the boundary placeholder. (The value itself would serialize
|
|
411
|
-
// fine: a TemplateResult is a plain {strings, values} object.)
|
|
412
|
-
if (currentTag === 'webjs-suspense' && name === 'fallback') {
|
|
413
|
-
const fbHtml = await render(val, ctx);
|
|
414
|
-
out += `data-webjs-fallback="${escapeAttr(fbHtml)}"`;
|
|
415
|
-
state = 'in-tag';
|
|
416
|
-
attrName = '';
|
|
417
|
-
continue;
|
|
418
|
-
}
|
|
419
|
-
if (!currentTag.includes('-')) {
|
|
420
|
-
// A native element's `.prop` is dropped at SSR, so this path never
|
|
421
|
-
// leaked here. It still refuses a function where the property is a
|
|
422
|
-
// REFLECTED IDL attribute (`.action` on a form, `.formAction` on a
|
|
423
|
-
// button or input), so the rule does not depend on which renderer
|
|
424
|
-
// sees it first: the client sets that property for real and the
|
|
425
|
-
// reflection writes the source into the DOM. A page that renders
|
|
426
|
-
// clean on the server and throws on hydration is a worse failure
|
|
427
|
-
// than one that refuses at the earliest point. Elsewhere the
|
|
428
|
-
// property is a plain expando that reflects nothing, so refusing
|
|
429
|
-
// it would be a false positive.
|
|
430
|
-
assertNotFunctionReflectedActionProp(val, name, currentTag);
|
|
431
|
-
notePropAttr(name, currentTag);
|
|
432
|
-
state = 'in-tag';
|
|
433
|
-
attrName = '';
|
|
434
|
-
continue;
|
|
435
|
-
}
|
|
436
|
-
// `undefined` has no meaningful HTML representation. Drop
|
|
437
|
-
// silently so the consumer falls back to its constructor
|
|
438
|
-
// default. `null` is preserved because it's a real value
|
|
439
|
-
// distinct from "not set".
|
|
440
|
-
if (val === undefined) {
|
|
441
|
-
state = 'in-tag';
|
|
442
|
-
attrName = '';
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
try {
|
|
446
|
-
const encoded = await stringify(val);
|
|
447
|
-
out += `data-webjs-prop-${kebabCase(name)}="${escapeAttr(encoded)}"`;
|
|
448
|
-
} catch (e) {
|
|
449
|
-
// Unserializable value (function, class instance with
|
|
450
|
-
// private state, DOM node, etc.). Drop with a warning so
|
|
451
|
-
// SSR does not crash. Same constraint as Next.js RSC.
|
|
452
|
-
console.warn(
|
|
453
|
-
`[webjs] property binding .${name} has an unserializable `
|
|
454
|
-
+ `value during SSR. Dropping. The browser will see the `
|
|
455
|
-
+ `property as undefined. Detail: ${e && e.message}`
|
|
456
|
-
);
|
|
457
|
-
}
|
|
458
|
-
state = 'in-tag';
|
|
459
|
-
attrName = '';
|
|
460
|
-
} else if (kind === 'bool') {
|
|
461
|
-
// Never leaked (a boolean binding stringifies nothing), but
|
|
462
|
-
// `?action=${fn}` is meaningless in every case and refusing it keeps
|
|
463
|
-
// the rule true for every sigil rather than only the quoted ones.
|
|
464
|
-
assertNotFunctionActionAttr(val, name, currentTag);
|
|
465
|
-
out = out.slice(0, attrStart);
|
|
466
|
-
if (val) out += `${name}=""`;
|
|
467
|
-
state = 'in-tag';
|
|
468
|
-
attrName = '';
|
|
469
|
-
} else if (isBoundFormAction(val, attrName, currentTag)) {
|
|
470
|
-
noteActionHole(attrName, currentTag);
|
|
471
|
-
if (currentTag === 'form') {
|
|
472
|
-
// #1155: the form-level binding. Drop the `action=` attribute
|
|
473
|
-
// entirely so the form posts to the page's own url (an omitted
|
|
474
|
-
// attribute, not `action=""`, which the spec calls a conformance
|
|
475
|
-
// error), and remember the identity so the `>` can force the
|
|
476
|
-
// submission attributes and emit the hidden field.
|
|
477
|
-
pendingActionId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
|
|
478
|
-
// Trailing whitespace goes with the attribute: every injected
|
|
479
|
-
// attribute carries its own leading space, so keeping the old one
|
|
480
|
-
// would double it in the emitted tag.
|
|
481
|
-
out = out.slice(0, attrStart).replace(/\s+$/, '');
|
|
482
|
-
} else {
|
|
483
|
-
// #1207: the submitter binding. The identity replaces the
|
|
484
|
-
// `formaction=` hole IN PLACE with the button's own name/value
|
|
485
|
-
// pair, the one channel a browser submits for the pressed button
|
|
486
|
-
// alone. No `formaction` url is emitted, so the submission still
|
|
487
|
-
// targets the page and the form-level identity is simply overridden
|
|
488
|
-
// by this later entry.
|
|
489
|
-
//
|
|
490
|
-
// Refused here rather than at the `>` only where the answer cannot
|
|
491
|
-
// change later: boundness of the ENCLOSING form is already decided
|
|
492
|
-
// (its start tag is emitted), and an attribute written BEFORE the
|
|
493
|
-
// hole is already in `out`. Everything else waits for the close,
|
|
494
|
-
// where `assertSubmitterStartTag` sees the whole tag.
|
|
495
|
-
// A second binding hole on this same tag, refused here so the
|
|
496
|
-
// author gets the duplicate message rather than a confusing
|
|
497
|
-
// complaint about the `name` the FIRST hole just injected.
|
|
498
|
-
assertSingleSubmitterAction(pendingSubmitterTag != null, currentTag);
|
|
499
|
-
const attrs = parseStartTagAttrs(out.slice(tagStart));
|
|
500
|
-
if (attrs.has('name')) assertSubmitterHasNoName(attrs.get('name') || '', currentTag, false);
|
|
501
|
-
if (attrs.has('value')) assertSubmitterHasNoValue(currentTag);
|
|
502
|
-
if (attrs.has('form')) assertSubmitterHasNoFormAttribute(currentTag);
|
|
503
|
-
const subId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
|
|
504
|
-
pendingSubmitterTag = currentTag;
|
|
505
|
-
out = out.slice(0, attrStart) + `name="${FORM_ACTION_FIELD}" value="${escapeAttr(subId)}"`;
|
|
506
|
-
}
|
|
507
|
-
state = 'in-tag';
|
|
508
|
-
attrName = '';
|
|
509
|
-
} else {
|
|
510
|
-
// A second `action` hole that resolved to a plain url still COUNTS,
|
|
511
|
-
// so the duplicate refusal fires whatever the values happen to be.
|
|
512
|
-
noteActionHole(attrName, currentTag);
|
|
513
|
-
// #1154: never stringify a function into action=/formaction= (it
|
|
514
|
-
// would serialize a server action's source into the served HTML).
|
|
515
|
-
assertNotFunctionActionAttr(val, attrName, currentTag);
|
|
516
|
-
out += `"${escapeAttr(String(val ?? ''))}"`;
|
|
517
|
-
state = 'in-tag';
|
|
518
|
-
attrName = '';
|
|
519
|
-
}
|
|
520
|
-
} else if (state === 'attr-quoted' || state === 'attr-unquoted') {
|
|
521
|
-
// Same guard for a hole inside a quoted/unquoted value, the
|
|
522
|
-
// `action="${fn}"` and mixed `action="/x/${fn}"` shapes (#1154).
|
|
523
|
-
assertNotFunctionActionAttr(val, attrName, currentTag);
|
|
524
|
-
out += escapeAttr(String(val ?? ''));
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
return out;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// Browser-only names whose absence during SSR produces a recognisable error.
|
|
532
|
-
// Mirrors the `no-browser-globals-in-render` webjs check rule, which catches
|
|
533
|
-
// these at edit time; this turns the runtime SSR crash into the same guidance.
|
|
534
|
-
const SSR_BROWSER_GLOBALS = new Set([
|
|
535
|
-
'document', 'window', 'localStorage', 'sessionStorage', 'navigator',
|
|
536
|
-
'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
|
|
537
|
-
'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
|
|
538
|
-
]);
|
|
539
|
-
// Attribute methods (get/set/has/remove/toggleAttribute), the event methods
|
|
540
|
-
// (add/removeEventListener, dispatchEvent), and attachInternals are backed by
|
|
541
|
-
// the server-side element shim and work at SSR, so they are NOT listed here.
|
|
542
|
-
// What remains is the genuinely browser-only HTMLElement surface that still
|
|
543
|
-
// has no server stand-in and throws at SSR.
|
|
544
|
-
const SSR_HTMLELEMENT_METHODS = new Set([
|
|
545
|
-
'attachShadow', 'querySelector', 'querySelectorAll',
|
|
546
|
-
'getBoundingClientRect', 'focus', 'blur', 'scrollIntoView',
|
|
547
|
-
]);
|
|
548
|
-
|
|
549
|
-
/**
|
|
550
|
-
* If `e` is the recognisable failure of touching a browser-only API during
|
|
551
|
-
* SSR (a `ReferenceError` for a browser global, or a `TypeError` calling an
|
|
552
|
-
* HTMLElement method that does not exist on the bare server-side instance),
|
|
553
|
-
* return an actionable, member-naming hint; otherwise null.
|
|
554
|
-
* @param {unknown} e
|
|
555
|
-
* @returns {string | null}
|
|
556
|
-
*/
|
|
557
|
-
function browserMemberHint(e) {
|
|
558
|
-
const msg = e && typeof (/** @type any */ (e).message) === 'string' ? /** @type any */ (e).message : '';
|
|
559
|
-
// Match on a word boundary, NOT end-of-string: V8 (Node) ends the message at
|
|
560
|
-
// "is not defined" / "is not a function", but JSC (Bun) appends a detail
|
|
561
|
-
// clause (e.g. ". (In '({}).querySelector(\"p\")', '...' is undefined)"), so an
|
|
562
|
-
// anchored `$` would miss the Bun message and drop the actionable hint.
|
|
563
|
-
let m = /^(\w+) is not defined\b/.exec(msg);
|
|
564
|
-
if (e instanceof ReferenceError && m && SSR_BROWSER_GLOBALS.has(m[1])) {
|
|
565
|
-
return `\`${m[1]}\` is a browser-only global and is undefined during SSR.`;
|
|
566
|
-
}
|
|
567
|
-
m = /\.(\w+) is not a function\b/.exec(msg);
|
|
568
|
-
if (e instanceof TypeError && m && SSR_HTMLELEMENT_METHODS.has(m[1])) {
|
|
569
|
-
return `\`${m[1]}\` is an HTMLElement method that does not exist on the server-side component instance during SSR.`;
|
|
570
|
-
}
|
|
571
|
-
return null;
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
/** True in a production build (no dev error surfacing). */
|
|
575
|
-
function isProd() {
|
|
576
|
-
return typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production';
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
/**
|
|
580
|
-
* Default component-scoped error state for an async/sync render that threw
|
|
581
|
-
* during SSR, used when the component does not define renderError() (#469).
|
|
582
|
-
* Dev surfaces the tag + message loudly so the failure is obvious; prod
|
|
583
|
-
* renders an empty (silent, isolated) element so no internal detail leaks.
|
|
584
|
-
*
|
|
585
|
-
* The prod-silence signal is the SERVER's `dev` flag, threaded through the SSR
|
|
586
|
-
* render context (#483). WebJs keys prod on the CLI `dev` flag, not `NODE_ENV`,
|
|
587
|
-
* and `webjs start` does not export `NODE_ENV=production`, so a bare prod launch
|
|
588
|
-
* would otherwise leak the message. When `dev` is undefined (a context-free
|
|
589
|
-
* `renderToString` with no server signal, e.g. a bare unit test) it falls back
|
|
590
|
-
* to `isProd()` / `NODE_ENV`, preserving the prior behaviour for that path.
|
|
591
|
-
*
|
|
592
|
-
* @param {string} tag
|
|
593
|
-
* @param {Error} err
|
|
594
|
-
* @param {boolean} [dev] server dev flag; undefined falls back to NODE_ENV
|
|
595
|
-
* @returns {unknown} a TemplateResult (dev) or '' (prod)
|
|
596
|
-
*/
|
|
597
|
-
function defaultSSRErrorTemplate(tag, err, dev) {
|
|
598
|
-
const surface = dev === undefined ? !isProd() : !!dev;
|
|
599
|
-
if (!surface) return '';
|
|
600
|
-
const msg = err && err.message ? err.message : String(err);
|
|
601
|
-
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">
|
|
602
|
-
<strong><${tag}> failed to render</strong>
|
|
603
|
-
<div style="margin-top:4px;white-space:pre-wrap">${msg}</div>
|
|
604
|
-
</div>`;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
|
-
* Index just past the end of the comment starting at `start`, or -1 if it is
|
|
609
|
-
* unterminated. Shared so every scanner that has to decide where a comment
|
|
610
|
-
* stops agrees, including on the spec short forms.
|
|
611
|
-
*
|
|
612
|
-
* @param {string} html
|
|
613
|
-
* @param {number} start index of the `<` of `<!--`
|
|
614
|
-
* @returns {number}
|
|
615
|
-
*/
|
|
616
|
-
function endOfComment(html, start) {
|
|
617
|
-
let p = start + 4;
|
|
618
|
-
// `<!-->` and `<!--->` are comments whose data is empty (spec short forms).
|
|
619
|
-
if (html[p] === '>') return p + 1;
|
|
620
|
-
if (html.startsWith('->', p)) return p + 2;
|
|
621
|
-
while (p < html.length) {
|
|
622
|
-
// `--!>` is the spec's "abrupt closing" form and closes just like `-->`.
|
|
623
|
-
if (html.startsWith('-->', p)) return p + 3;
|
|
624
|
-
if (html.startsWith('--!>', p)) return p + 4;
|
|
625
|
-
p += 1;
|
|
626
|
-
}
|
|
627
|
-
return -1;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* Index of the `</script` that really closes a `<script>` whose content starts
|
|
632
|
-
* at `from`, or -1 when unterminated (#1134).
|
|
633
|
-
*
|
|
634
|
-
* Script data is not plain raw text: once the content contains `<!--` followed
|
|
635
|
-
* by `<script`, the tokenizer is in the script-data-double-escaped state, where
|
|
636
|
-
* a `</script>` is TEXT (it only steps back to the escaped state) and the
|
|
637
|
-
* element ends at the NEXT `</script>`. The legacy comment-wrapped inline
|
|
638
|
-
* script that document.writes a script tag is the pattern that produces this.
|
|
639
|
-
* Stopping at the first `</script>` there re-opened the original #1128 bug in
|
|
640
|
-
* the one element the scanner most explicitly claims to handle.
|
|
641
|
-
*
|
|
642
|
-
* @param {string} html
|
|
643
|
-
* @param {number} from index just past the opening tag's `>`
|
|
644
|
-
* @returns {number}
|
|
645
|
-
*/
|
|
646
|
-
function endOfScriptContent(html, from) {
|
|
647
|
-
const re = /<!--|-->|<\/script(?=[\s/>])|<script(?=[\s/>])/gi;
|
|
648
|
-
re.lastIndex = from;
|
|
649
|
-
let escaped = false;
|
|
650
|
-
let dbl = false;
|
|
651
|
-
let m;
|
|
652
|
-
while ((m = re.exec(html)) !== null) {
|
|
653
|
-
const t = m[0];
|
|
654
|
-
if (t === '<!--') {
|
|
655
|
-
// The token's own trailing `--` puts the tokenizer in a dash-dash state
|
|
656
|
-
// REGARDLESS of what state it was in (`<!` are inert bytes in escaped and
|
|
657
|
-
// double-escaped states too), and every dash-dash state exits straight
|
|
658
|
-
// back to plain script data on `>`. So `<!-->`, `<!--->`, and any dash
|
|
659
|
-
// run followed by `>` clear BOTH flags: entering fresh it cancels the
|
|
660
|
-
// escape before it starts, and inside an escaped or double-escaped body
|
|
661
|
-
// it is the exit a browser honours, after which the element ends at the
|
|
662
|
-
// next `</script>`.
|
|
663
|
-
let q = m.index + 4;
|
|
664
|
-
while (html[q] === '-') q += 1;
|
|
665
|
-
if (html[q] === '>') { escaped = false; dbl = false; re.lastIndex = q + 1; }
|
|
666
|
-
else if (!escaped) escaped = true;
|
|
667
|
-
}
|
|
668
|
-
else if (t === '-->') { escaped = false; dbl = false; }
|
|
669
|
-
else if (t[1] === '/') {
|
|
670
|
-
if (dbl) dbl = false;
|
|
671
|
-
else return m.index;
|
|
672
|
-
} else if (escaped) dbl = true;
|
|
673
|
-
}
|
|
674
|
-
return -1;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
/**
|
|
678
|
-
* Byte ranges of `html` where a tag-shaped match is NOT an element (#1128).
|
|
679
|
-
*
|
|
680
|
-
* The element scanners below match tags with a flat regex over already-
|
|
681
|
-
* assembled markup, which has no notion of an HTML context. So a registered tag
|
|
682
|
-
* name written inside a comment used to be constructed and rendered as a real
|
|
683
|
-
* element, and the replacement consumed the rest of the comment INCLUDING its
|
|
684
|
-
* closing `-->`, leaving an unterminated comment that swallowed every following
|
|
685
|
-
* byte. Whether it happened depended on whether the name in the comment was a
|
|
686
|
-
* registered component, which is what made it look random.
|
|
687
|
-
*
|
|
688
|
-
* This is a single left-to-right pass rather than a search for `<!--`, because
|
|
689
|
-
* the naive version introduces failures worse than the bug: an `<!--` inside an
|
|
690
|
-
* attribute value (`title="use <!-- here"`) or inside RCDATA would open a region
|
|
691
|
-
* that never closes, and every component after it would silently stop rendering.
|
|
692
|
-
* Deciding that requires knowing the context, which means tokenizing, so the
|
|
693
|
-
* pass tracks the same states the HTML parser does for these purposes:
|
|
694
|
-
*
|
|
695
|
-
* - **Comments**, including the spec's short forms. `<!-->` and `<!--->` close
|
|
696
|
-
* immediately, `--!>` closes as well as `-->`, and an unterminated comment
|
|
697
|
-
* runs to EOF, exactly as a browser would treat the same bytes.
|
|
698
|
-
* - **Markup declarations and bogus comments** (`<!doctype …>`, `<![CDATA[…]]>`),
|
|
699
|
-
* which end at the next `>`.
|
|
700
|
-
* - **Tags**, consumed with their quoted attribute values, so `<` and `<!--`
|
|
701
|
-
* inside an attribute are inert rather than context-changing.
|
|
702
|
-
* - **Text-only elements**, whose content the HTML tokenizer never reads as
|
|
703
|
-
* markup: raw text (`script`, `style`, `iframe`, `xmp`, `noembed`,
|
|
704
|
-
* `noframes`, `plaintext`) and RCDATA (`textarea`, `title`). Their content is
|
|
705
|
-
* returned as a skip range too, because a component tag inside a `<style>`
|
|
706
|
-
* comment or an `<iframe>` fallback hit the identical markup-destroying path,
|
|
707
|
-
* so excluding them would leave half the bug live.
|
|
708
|
-
*
|
|
709
|
-
* Two deliberate exclusions. `<template>` content IS parsed and legitimately
|
|
710
|
-
* carries components (Declarative Shadow DOM and the streamed swap templates
|
|
711
|
-
* both depend on that). `<noscript>` content is parsed as markup when
|
|
712
|
-
* scripting is disabled, which for a progressive-enhancement framework is the
|
|
713
|
-
* case that matters, so components inside it must keep rendering.
|
|
714
|
-
*
|
|
715
|
-
* @param {string} html
|
|
716
|
-
* @returns {[number, number][]} ascending, non-overlapping `[start, end)` pairs
|
|
717
|
-
*/
|
|
718
|
-
function inertRanges(html) {
|
|
719
|
-
/** @type {[number, number][]} */
|
|
720
|
-
const ranges = [];
|
|
721
|
-
const n = html.length;
|
|
722
|
-
let i = 0;
|
|
723
|
-
while (i < n) {
|
|
724
|
-
const lt = html.indexOf('<', i);
|
|
725
|
-
if (lt === -1) break;
|
|
726
|
-
if (html.startsWith('<!--', lt)) {
|
|
727
|
-
const end = endOfComment(html, lt);
|
|
728
|
-
const stop = end === -1 ? n : end;
|
|
729
|
-
ranges.push([lt, stop]);
|
|
730
|
-
i = stop;
|
|
731
|
-
continue;
|
|
732
|
-
}
|
|
733
|
-
if (html.startsWith('<!', lt) || html.startsWith('<?', lt)) {
|
|
734
|
-
// Doctype / bogus comment / processing instruction: ends at the next `>`.
|
|
735
|
-
const close = html.indexOf('>', lt);
|
|
736
|
-
const end = close === -1 ? n : close + 1;
|
|
737
|
-
ranges.push([lt, end]);
|
|
738
|
-
i = end;
|
|
739
|
-
continue;
|
|
740
|
-
}
|
|
741
|
-
const name = /^<\/?([a-zA-Z][^\s/>]*)/.exec(html.slice(lt, lt + 64));
|
|
742
|
-
if (!name) {
|
|
743
|
-
// `</` followed by anything that is not an ASCII letter is the third
|
|
744
|
-
// bogus-comment form (`</1`, `</<`, `</ `), which the spec also runs to
|
|
745
|
-
// the next `>`. Without this branch the bytes after it are scanned as
|
|
746
|
-
// markup and a tag inside gets instantiated, which is the original bug.
|
|
747
|
-
if (html.startsWith('</', lt)) {
|
|
748
|
-
const close = html.indexOf('>', lt);
|
|
749
|
-
const end = close === -1 ? n : close + 1;
|
|
750
|
-
ranges.push([lt, end]);
|
|
751
|
-
i = end;
|
|
752
|
-
continue;
|
|
753
|
-
}
|
|
754
|
-
i = lt + 1;
|
|
755
|
-
continue;
|
|
756
|
-
}
|
|
757
|
-
// Consume the tag, honouring quoted attribute values so a `<` or `<!--`
|
|
758
|
-
// inside one cannot be mistaken for markup.
|
|
759
|
-
//
|
|
760
|
-
// A quote only OPENS a value when it directly follows `=`. That condition
|
|
761
|
-
// is load-bearing rather than pedantic: `escapeAttr` does not escape `'`,
|
|
762
|
-
// so an interpolated apostrophe in a single-quoted attribute emits three
|
|
763
|
-
// unbalanced quotes (`title='don't'`). Treating every quote as a delimiter
|
|
764
|
-
// left the scanner stuck inside a value to EOF, which returned a truncated
|
|
765
|
-
// range list and silently re-enabled this whole bug for the rest of the
|
|
766
|
-
// page. A browser recovers at the `>`, and so does this: after the value
|
|
767
|
-
// closes, the stray `'` is just an attribute-name character.
|
|
768
|
-
let p = lt + 1;
|
|
769
|
-
let quote = '';
|
|
770
|
-
// `expectValue` is set by `=` and cleared by the first non-whitespace
|
|
771
|
-
// character after it. Only THAT character can open a quoted value, which is
|
|
772
|
-
// what the spec does: before-attribute-value reconsumes anything else in
|
|
773
|
-
// attribute-value-unquoted state. Keying off "the previous character was
|
|
774
|
-
// `=`" instead re-opens the hole on `<a title==">`, where the `"` is an
|
|
775
|
-
// ordinary value character; an odd quote count then ran the scan to EOF and
|
|
776
|
-
// returned one giant inert range, silently disabling this whole fix for the
|
|
777
|
-
// rest of the page.
|
|
778
|
-
let expectValue = false;
|
|
779
|
-
// Unquoted values need their own state for two reasons the spec spells out
|
|
780
|
-
// and a simpler scan gets wrong: `>` ends the tag from here (so `attr=>` is
|
|
781
|
-
// a missing value, not a value of `>`), and `/` is an ordinary value
|
|
782
|
-
// character, so an unquoted URL ending in `/` is NOT a self-closing solidus.
|
|
783
|
-
let inUnquoted = false;
|
|
784
|
-
let selfClosing = false;
|
|
785
|
-
while (p < n) {
|
|
786
|
-
const c = html[p];
|
|
787
|
-
const isSpace = c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f';
|
|
788
|
-
if (quote) {
|
|
789
|
-
if (c === quote) quote = '';
|
|
790
|
-
} else if (c === '>') {
|
|
791
|
-
// Checked before the value branches: a `>` arriving where a value was
|
|
792
|
-
// expected terminates the tag (`<a href=>`). Consuming it as a value
|
|
793
|
-
// character ran the scan on to the NEXT `>`, which swallowed the real
|
|
794
|
-
// tag end and re-armed the original bug for what followed.
|
|
795
|
-
selfClosing = !inUnquoted && html[p - 1] === '/';
|
|
796
|
-
p += 1;
|
|
797
|
-
break;
|
|
798
|
-
} else if (expectValue) {
|
|
799
|
-
// The first non-whitespace character after `=` decides the value form.
|
|
800
|
-
if (!isSpace) {
|
|
801
|
-
if (c === '"' || c === "'") quote = c;
|
|
802
|
-
else inUnquoted = true;
|
|
803
|
-
expectValue = false;
|
|
804
|
-
}
|
|
805
|
-
} else if (inUnquoted) {
|
|
806
|
-
if (isSpace) inUnquoted = false;
|
|
807
|
-
} else if (c === '=') {
|
|
808
|
-
expectValue = true;
|
|
809
|
-
}
|
|
810
|
-
p += 1;
|
|
811
|
-
}
|
|
812
|
-
// The tag's interior is not markup either. A component tag written inside
|
|
813
|
-
// an attribute value (`title="renders a <my-card> element"`) was otherwise
|
|
814
|
-
// instantiated in place, destroying the rest of the document exactly like
|
|
815
|
-
// the comment case. Start at lt+1 so the tag's OWN opening `<` still
|
|
816
|
-
// matches; only what is nested inside it is inert.
|
|
817
|
-
if (p > lt + 1) ranges.push([lt + 1, p]);
|
|
818
|
-
i = p;
|
|
819
|
-
const tag = name[1].toLowerCase();
|
|
820
|
-
const isClose = html[lt + 1] === '/';
|
|
821
|
-
// A self-closing start tag has no content to skip. In HTML the `/` is
|
|
822
|
-
// ignored, but in SVG and MathML foreign content it genuinely closes the
|
|
823
|
-
// element, and `<svg><title/></svg>` otherwise finds no `</title`, runs the
|
|
824
|
-
// range to EOF, and makes every component in the rest of the document
|
|
825
|
-
// inert. Honouring `/>` costs only the malformed-HTML case (`<style/>`,
|
|
826
|
-
// already broken authoring) and fails in the direction where components
|
|
827
|
-
// keep rendering rather than silently vanishing.
|
|
828
|
-
if (!isClose && !selfClosing && isTextOnlyTag(tag)) {
|
|
829
|
-
// Everything up to the matching close tag is text, not markup.
|
|
830
|
-
let contentEnd;
|
|
831
|
-
if (tag === 'plaintext') {
|
|
832
|
-
// `<plaintext>` has no end tag at all: the rest of the document is text.
|
|
833
|
-
contentEnd = n;
|
|
834
|
-
} else if (tag === 'script') {
|
|
835
|
-
// Script data has the double-escaped state (#1134), so its real end is
|
|
836
|
-
// not necessarily the first `</script`.
|
|
837
|
-
const end = endOfScriptContent(html, p);
|
|
838
|
-
contentEnd = end === -1 ? n : end;
|
|
839
|
-
} else {
|
|
840
|
-
const close = new RegExp(`</${tag}(?=[\\s/>])`, 'i').exec(html.slice(p));
|
|
841
|
-
contentEnd = close ? p + close.index : n;
|
|
842
|
-
}
|
|
843
|
-
if (contentEnd > p) ranges.push([p, contentEnd]);
|
|
844
|
-
i = contentEnd;
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
return ranges;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
/**
|
|
851
|
-
* Random-access membership test over ascending, non-overlapping ranges, for
|
|
852
|
-
* callers whose queries are NOT monotonic (findClosingTagInString resets its
|
|
853
|
-
* regex cursors backward while pairing opens with closes). O(ranges) per call;
|
|
854
|
-
* monotonic callers use `inertAt` below instead.
|
|
855
|
-
*
|
|
856
|
-
* @param {[number, number][]} ranges
|
|
857
|
-
* @param {number} index
|
|
858
|
-
* @returns {boolean}
|
|
859
|
-
*/
|
|
860
|
-
function inRanges(ranges, index) {
|
|
861
|
-
for (const [start, end] of ranges) {
|
|
862
|
-
if (start > index) return false;
|
|
863
|
-
if (index < end) return true;
|
|
864
|
-
}
|
|
865
|
-
return false;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
/**
|
|
869
|
-
* A left-to-right membership test over ascending, non-overlapping ranges.
|
|
870
|
-
*
|
|
871
|
-
* Returns a function that answers "is this index inert?" and REMEMBERS how far
|
|
872
|
-
* it has walked, so a caller scanning matches in increasing order pays O(ranges)
|
|
873
|
-
* across the whole scan instead of O(ranges) per match. Restarting each time is
|
|
874
|
-
* an O(tags x components) term, which is measurable on a large page: holding a
|
|
875
|
-
* document at 40k tags and raising the component count adds hundreds of
|
|
876
|
-
* milliseconds that the cursor removes.
|
|
877
|
-
*
|
|
878
|
-
* The cursor only ever moves forward, so callers MUST query in non-decreasing
|
|
879
|
-
* index order. All three call sites do (`matchAll`, and the two loops that
|
|
880
|
-
* consume their input left to right). A caller that needs random access should
|
|
881
|
-
* scan `ranges` directly rather than reusing this.
|
|
882
|
-
*
|
|
883
|
-
* @param {[number, number][]} ranges
|
|
884
|
-
* @returns {(index: number) => boolean}
|
|
885
|
-
*/
|
|
886
|
-
function inertAt(ranges) {
|
|
887
|
-
let cursor = 0;
|
|
888
|
-
return (index) => {
|
|
889
|
-
while (cursor < ranges.length && ranges[cursor][1] <= index) cursor += 1;
|
|
890
|
-
if (cursor >= ranges.length) return false;
|
|
891
|
-
return index >= ranges[cursor][0];
|
|
892
|
-
};
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
/**
|
|
896
|
-
* Scan an HTML string for registered custom elements and inject
|
|
897
|
-
* Declarative Shadow DOM (`<template shadowrootmode="open">`).
|
|
898
|
-
* Awaits each component's render() so async components are fully resolved.
|
|
899
|
-
*
|
|
900
|
-
* @param {string} html
|
|
901
|
-
* @param {SuspenseCtx} [ctx]
|
|
902
|
-
* @param {any[]} [ancestors]
|
|
903
|
-
* @param {boolean} [dev] server dev flag, threaded to the per-component error
|
|
904
|
-
* template for prod-silence (#483); undefined falls back to NODE_ENV
|
|
905
|
-
* @returns {Promise<string>}
|
|
906
|
-
*/
|
|
907
|
-
async function injectDSD(html, ctx, ancestors = [], dev) {
|
|
908
|
-
// Resolve <webjs-suspense> boundaries first (#471): in a streaming context
|
|
909
|
-
// each becomes a fallback placeholder now, with its children pushed for
|
|
910
|
-
// out-of-order streaming; without a streaming context the children render
|
|
911
|
-
// inline (blocking). Run before the custom-element walk so a streamed
|
|
912
|
-
// boundary's children leave the main flow and are not double-processed.
|
|
913
|
-
html = await processSuspenseElements(html, ctx, ancestors, dev);
|
|
914
|
-
const tags = allTags();
|
|
915
|
-
if (!tags.length) return html;
|
|
916
|
-
// Sort longest tag name first so the regex alternation tries the most
|
|
917
|
-
// specific match before its prefixes. Combined with the (?=[\s>/])
|
|
918
|
-
// lookahead this prevents `my-card` from spuriously matching the prefix
|
|
919
|
-
// of `<my-card-2>` (or `slot-ssr-1` matching `<slot-ssr-14>`, etc).
|
|
920
|
-
// Attribute section is "anything that isn't `>`, with quoted values as a
|
|
921
|
-
// single unit" so slashes in URL-valued attrs (e.g. then="/dashboard") don't
|
|
922
|
-
// prevent the match. Non-greedy so self-closing `/>` still captures into the
|
|
923
|
-
// third group.
|
|
924
|
-
const sortedTags = [...tags].sort((a, b) => b.length - a.length);
|
|
925
|
-
const pattern = new RegExp(
|
|
926
|
-
`<(${sortedTags.map(escapeRegex).join('|')})(?=[\\s>/])((?:"[^"]*"|'[^']*'|[^>])*?)(/?)>`,
|
|
927
|
-
'g'
|
|
928
|
-
);
|
|
929
|
-
/** @type {{start:number, end:number, text:string}[]} */
|
|
930
|
-
const edits = [];
|
|
931
|
-
// A tag name inside a comment, a script, a style, RCDATA, or another tag's
|
|
932
|
-
// attribute value is text, not an element (#1128).
|
|
933
|
-
const inert = inertRanges(html);
|
|
934
|
-
const isInert = inertAt(inert);
|
|
935
|
-
for (const m of html.matchAll(pattern)) {
|
|
936
|
-
const [match, tag, attrs, selfClose] = m;
|
|
937
|
-
const Cls = lookup(tag);
|
|
938
|
-
if (!Cls) continue;
|
|
939
|
-
if (isInert(m.index)) continue;
|
|
940
|
-
// Track which custom elements actually appeared: used by SSR to emit
|
|
941
|
-
// `<link rel="modulepreload">` hints for their module URLs.
|
|
942
|
-
if (ctx && ctx.usedComponents) ctx.usedComponents.add(tag);
|
|
943
|
-
let opening = selfClose ? `<${tag}${attrs}>` : match;
|
|
944
|
-
// Hoisted so the per-component error boundary (#469) can ask the failed
|
|
945
|
-
// instance for its renderError() output.
|
|
946
|
-
let instance = null;
|
|
947
|
-
try {
|
|
948
|
-
const isShadow = /** @type any */ (Cls).shadow === true;
|
|
949
|
-
instance = new /** @type any */ (Cls)();
|
|
950
|
-
// Thread the ancestor chain (the enclosing custom-element instances)
|
|
951
|
-
// so the server element shim's closest() can resolve a parent at SSR.
|
|
952
|
-
// Set before performServerUpdate so a willUpdate() that reads a parent
|
|
953
|
-
// via closest() sees the chain. Each child recursion below extends it.
|
|
954
|
-
instance.__ssrTag = tag;
|
|
955
|
-
instance.__ssrAncestors = ancestors;
|
|
956
|
-
const attrMap = parseAttrs(attrs);
|
|
957
|
-
// Decode `data-webjs-prop-*` attributes first (rich-typed values
|
|
958
|
-
// emitted for `.prop=${val}` bindings in the parent template),
|
|
959
|
-
// then coerce the ordinary string attributes by `static
|
|
960
|
-
// properties` type. Property bindings take priority on a name
|
|
961
|
-
// collision because they preserve the original JS reference.
|
|
962
|
-
const propValues = consumePropAttrs(attrMap);
|
|
963
|
-
// Names already present in the source opening tag (including the
|
|
964
|
-
// data-webjs-prop-* bindings, which were stripped from attrMap above
|
|
965
|
-
// but remain in the emitted `attrs` string). Reflected/added
|
|
966
|
-
// attributes are appended only when their name is NOT already here, so
|
|
967
|
-
// existing output stays byte-identical when nothing reflects.
|
|
968
|
-
const presentAttrNames = new Set(Object.keys(parseAttrs(attrs)).map((n) => n.toLowerCase()));
|
|
969
|
-
// Seed the server attribute shim so `this.getAttribute(...)` /
|
|
970
|
-
// `this.hasAttribute(...)` in willUpdate / render read the source
|
|
971
|
-
// attributes (a lit muscle-memory pattern) instead of reading empty.
|
|
972
|
-
seedServerAttrs(instance, attrMap);
|
|
973
|
-
applyAttrsToInstance(instance, attrMap, Cls);
|
|
974
|
-
for (const [k, v] of Object.entries(propValues)) instance[k] = v;
|
|
975
|
-
// Extract the authored inner HTML BEFORE the render (the injectDSD
|
|
976
|
-
// reorder): the source scan needs no render output, and hoisting it lets
|
|
977
|
-
// the light-DOM branch below project the authored children into the
|
|
978
|
-
// rendered slots. The shadow branch is a read-only peek (its authored
|
|
979
|
-
// children stay in place for native projection, and its edit keeps the
|
|
980
|
-
// old end).
|
|
981
|
-
let authoredInner = '';
|
|
982
|
-
let closeEnd = m.index + match.length;
|
|
983
|
-
if (!selfClose) {
|
|
984
|
-
const innerStart = m.index + match.length;
|
|
985
|
-
const closeIdx = findClosingTagInString(html, innerStart, tag, inert);
|
|
986
|
-
if (closeIdx !== -1) {
|
|
987
|
-
authoredInner = html.slice(innerStart, closeIdx);
|
|
988
|
-
const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
|
|
989
|
-
const tail = html.slice(closeIdx);
|
|
990
|
-
const closeMatch = closeRe.exec(tail);
|
|
991
|
-
const closeLen = closeMatch ? closeMatch[0].length : `</${tag}>`.length;
|
|
992
|
-
closeEnd = closeIdx + closeLen;
|
|
993
|
-
} else {
|
|
994
|
-
// Unclosed in source. Take rest of html as authored content
|
|
995
|
-
// and synthesize a closing tag on output.
|
|
996
|
-
authoredInner = html.slice(innerStart);
|
|
997
|
-
closeEnd = html.length;
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
const partitioned = partitionAuthoredBySlot(authoredInner);
|
|
1001
|
-
// Run the pre-render lifecycle (willUpdate, controllers' hostUpdate,
|
|
1002
|
-
// then reflect reflect:true props) so derived state computed there is
|
|
1003
|
-
// correct in the SSR'd HTML, matching how lit runs the update cycle at
|
|
1004
|
-
// SSR. WebComponent instances expose performServerUpdate; bare
|
|
1005
|
-
// Base-extending kit components (no lifecycle) do not, so it is guarded.
|
|
1006
|
-
if (typeof instance.performServerUpdate === 'function') instance.performServerUpdate();
|
|
1007
|
-
let tpl = instance.render ? instance.render() : '';
|
|
1008
|
-
if (tpl && typeof tpl.then === 'function') tpl = await tpl;
|
|
1009
|
-
// Surface attributes the component set up to and including render()
|
|
1010
|
-
// that were not already in the source tag: reflected reflect:true
|
|
1011
|
-
// props, an explicit this.setAttribute in the constructor / willUpdate,
|
|
1012
|
-
// or a host-attribute mutation inside render() itself (a light-DOM
|
|
1013
|
-
// compound-component pattern, e.g. this.dataset.state / this.className /
|
|
1014
|
-
// this.hidden on the host). Reading after render() captures all three.
|
|
1015
|
-
// Appending keeps the original tag byte-identical when nothing changed.
|
|
1016
|
-
opening = appendReflectedAttrs(opening, instance, presentAttrNames);
|
|
1017
|
-
// Mark LIGHT-DOM component hosts so the framework default
|
|
1018
|
-
// `@layer webjs-host { :where([data-wj-host]) { display: block } }`
|
|
1019
|
-
// (injected once in the document head) applies at first paint. A custom
|
|
1020
|
-
// element is `display:inline` by default, which collapses a component used
|
|
1021
|
-
// as a block container (a board / card) until an author style intervenes.
|
|
1022
|
-
// The low-priority `@layer` keeps it overridable by any author style,
|
|
1023
|
-
// INCLUDING Tailwind's layered utilities (`class="flex"` wins). Emitted
|
|
1024
|
-
// uniformly regardless of elision, so the elision on-vs-off differential is
|
|
1025
|
-
// preserved.
|
|
1026
|
-
//
|
|
1027
|
-
// Shadow hosts are NOT marked: a document-level rule targeting the host
|
|
1028
|
-
// beats the shadow tree's own `:host { display: … }` (the encapsulation-
|
|
1029
|
-
// context criterion outranks both layer and specificity for normal
|
|
1030
|
-
// declarations), so marking them would silently override the shadow
|
|
1031
|
-
// author's `:host` display. Shadow components set their own host display
|
|
1032
|
-
// via `:host` in `static styles` (the idiomatic mechanism), which the
|
|
1033
|
-
// framework must not clobber.
|
|
1034
|
-
if (!isShadow) opening = withHostMarker(opening);
|
|
1035
|
-
// Render the template to HTML. injectDSD recurses on the result so
|
|
1036
|
-
// nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
|
|
1037
|
-
// get their own DSD pass.
|
|
1038
|
-
// 'unknown' for the form scope (#1207): this is a SEPARATE render pass
|
|
1039
|
-
// over one component's own template, driven by walking the already-emitted
|
|
1040
|
-
// HTML, so it has no idea whether the host tag sits inside a bound
|
|
1041
|
-
// `<form>`. Passing the default 'none' claimed there was no form at all,
|
|
1042
|
-
// which refused a perfectly good `<button formaction=${fn}>` in a
|
|
1043
|
-
// component inside a bound form and, because component SSR errors are
|
|
1044
|
-
// isolated, made the button vanish from a page that still returned 200.
|
|
1045
|
-
const rawInner = await render(tpl, ctx, 'unknown');
|
|
1046
|
-
|
|
1047
|
-
if (isShadow) {
|
|
1048
|
-
// Shadow DOM: native <slot> stays as-is in the DSD template. The
|
|
1049
|
-
// browser handles projection from the host's light-DOM children
|
|
1050
|
-
// into the shadow tree natively. No framework substitution here.
|
|
1051
|
-
const innerProcessed = await injectDSD(rawInner, ctx, [...ancestors, instance], dev);
|
|
1052
|
-
const rawStyles = /** @type any */ (Cls).styles;
|
|
1053
|
-
const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
|
|
1054
|
-
const styleStr = stylesToString(styleList);
|
|
1055
|
-
edits.push({
|
|
1056
|
-
start: m.index,
|
|
1057
|
-
end: m.index + match.length,
|
|
1058
|
-
text: `${opening}<template shadowrootmode="open">${styleStr}${innerProcessed}</template>`,
|
|
1059
|
-
});
|
|
1060
|
-
} else {
|
|
1061
|
-
// Light DOM. When the component has a non-empty rendered template,
|
|
1062
|
-
// run the slot pipeline so behaviour matches shadow DOM: authored
|
|
1063
|
-
// children are visible only where projected through <slot>; any
|
|
1064
|
-
// child without a matching slot is dropped.
|
|
1065
|
-
//
|
|
1066
|
-
// When rendered template is empty (Base-extending decorator
|
|
1067
|
-
// components that have no render() method, or render() that
|
|
1068
|
-
// returns an empty template), the host acts as a transparent
|
|
1069
|
-
// wrapper: authored children stay in place adjacent to the
|
|
1070
|
-
// (empty) hydration marker. This preserves the kit's
|
|
1071
|
-
// decorator-pattern components (those extending Base from the
|
|
1072
|
-
// ui package's lib/utils.ts) without forcing a render() rewrite.
|
|
1073
|
-
const renderedIsEmpty = rawInner.trim() === '';
|
|
1074
|
-
if (renderedIsEmpty) {
|
|
1075
|
-
edits.push({
|
|
1076
|
-
start: m.index,
|
|
1077
|
-
end: m.index + match.length,
|
|
1078
|
-
text: `${opening}<!--webjs-hydrate-->`,
|
|
1079
|
-
});
|
|
1080
|
-
continue;
|
|
1081
|
-
}
|
|
1082
|
-
//
|
|
1083
|
-
// The authored inner HTML + slot partition were extracted BEFORE
|
|
1084
|
-
// the render (the #1015 reorder, see above), so here:
|
|
1085
|
-
// 1. Substitute each <slot> in the rendered output with a
|
|
1086
|
-
// framework-marked <slot data-webjs-light data-projection
|
|
1087
|
-
// ="actual|fallback"> element carrying projection or
|
|
1088
|
-
// fallback content per first-wins rule.
|
|
1089
|
-
// 2. Recursively run injectDSD on the substituted output so
|
|
1090
|
-
// nested custom elements (inside projected children) get
|
|
1091
|
-
// their own DSD pass.
|
|
1092
|
-
const innerWithSlots = substituteSlotsInRender(rawInner, partitioned, tag);
|
|
1093
|
-
const innerProcessed = await injectDSD(innerWithSlots, ctx, [...ancestors, instance], dev);
|
|
1094
|
-
edits.push({
|
|
1095
|
-
start: m.index,
|
|
1096
|
-
end: closeEnd,
|
|
1097
|
-
text: `${opening}<!--webjs-hydrate-->${innerProcessed}</${tag}>`,
|
|
1098
|
-
});
|
|
1099
|
-
}
|
|
1100
|
-
} catch (e) {
|
|
1101
|
-
const hint = browserMemberHint(e);
|
|
1102
|
-
if (hint) {
|
|
1103
|
-
console.error(
|
|
1104
|
-
`[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).`,
|
|
1105
|
-
e,
|
|
1106
|
-
);
|
|
1107
|
-
} else {
|
|
1108
|
-
console.error(`[webjs] SSR failed for <${tag}>:`, e);
|
|
1109
|
-
}
|
|
1110
|
-
// Per-component error isolation (#469). A render that throws (most
|
|
1111
|
-
// commonly a rejected `await getData()` in an async render, but any
|
|
1112
|
-
// render throw) is caught HERE, per component: the loop continues so
|
|
1113
|
-
// siblings render normally, and this element renders a component-scoped
|
|
1114
|
-
// error state instead of bubbling to the route error.js or leaving its
|
|
1115
|
-
// raw, unprocessed children in the output. renderError() customizes the
|
|
1116
|
-
// error UI; the default surfaces the message in dev and renders an empty
|
|
1117
|
-
// (silent, isolated) element in prod so no internal detail leaks.
|
|
1118
|
-
const err = e instanceof Error ? e : new Error(String(e));
|
|
1119
|
-
let errorInner = '';
|
|
1120
|
-
try {
|
|
1121
|
-
let errTpl;
|
|
1122
|
-
if (instance && typeof instance.renderError === 'function') {
|
|
1123
|
-
errTpl = instance.renderError(err);
|
|
1124
|
-
}
|
|
1125
|
-
if (errTpl === undefined) errTpl = defaultSSRErrorTemplate(tag, err, dev);
|
|
1126
|
-
errorInner = await render(errTpl, ctx);
|
|
1127
|
-
if (errorInner.trim()) {
|
|
1128
|
-
errorInner = await injectDSD(errorInner, ctx, instance ? [...ancestors, instance] : ancestors, dev);
|
|
1129
|
-
}
|
|
1130
|
-
} catch (renderErrorThrew) {
|
|
1131
|
-
console.error(`[webjs] renderError() for <${tag}> also threw:`, renderErrorThrew);
|
|
1132
|
-
errorInner = '';
|
|
1133
|
-
}
|
|
1134
|
-
// Replace the element (opening tag through its matching close) with the
|
|
1135
|
-
// error state plus a hydration marker, so the client error boundary
|
|
1136
|
-
// (component.js renderError) can take over on hydration.
|
|
1137
|
-
let closeEnd = m.index + match.length;
|
|
1138
|
-
if (!selfClose) {
|
|
1139
|
-
const innerStart = m.index + match.length;
|
|
1140
|
-
const closeIdx = findClosingTagInString(html, innerStart, tag, inert);
|
|
1141
|
-
if (closeIdx !== -1) {
|
|
1142
|
-
const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
|
|
1143
|
-
const cm = closeRe.exec(html.slice(closeIdx));
|
|
1144
|
-
closeEnd = closeIdx + (cm ? cm[0].length : `</${tag}>`.length);
|
|
1145
|
-
} else {
|
|
1146
|
-
closeEnd = html.length;
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
// A shadow component renders into a shadow root on the client, so its
|
|
1150
|
-
// SSR error state must ride a DSD template too (matching the success
|
|
1151
|
-
// path), not land in light DOM. Otherwise the client renders the error
|
|
1152
|
-
// into the shadow root while the light error box lingers underneath.
|
|
1153
|
-
const isShadowErr = /** @type any */ (Cls).shadow === true;
|
|
1154
|
-
// Mark the LIGHT host here too, so a component whose SSR render() throws
|
|
1155
|
-
// paints its error state as display:block (not the inline default),
|
|
1156
|
-
// matching the success path. When an `async render()` rejects, it throws
|
|
1157
|
-
// before the success-path withHostMarker (above) ran, so `opening` is still
|
|
1158
|
-
// unmarked; when a later template render throws, the success marker already
|
|
1159
|
-
// ran and this call is a no-op (withHostMarker is idempotent). Shadow hosts
|
|
1160
|
-
// stay unmarked (their :host must win).
|
|
1161
|
-
if (!isShadowErr) opening = withHostMarker(opening);
|
|
1162
|
-
let text;
|
|
1163
|
-
if (isShadowErr) {
|
|
1164
|
-
const rawStyles = /** @type any */ (Cls).styles;
|
|
1165
|
-
const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
|
|
1166
|
-
const styleStr = stylesToString(styleList);
|
|
1167
|
-
text = `${opening}<template shadowrootmode="open">${styleStr}${errorInner}</template>`;
|
|
1168
|
-
} else {
|
|
1169
|
-
text = `${opening}<!--webjs-hydrate-->${errorInner}</${tag}>`;
|
|
1170
|
-
}
|
|
1171
|
-
edits.push({ start: m.index, end: closeEnd, text });
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
if (!edits.length) return html;
|
|
1175
|
-
|
|
1176
|
-
// Drop edits whose range lives inside an earlier edit's range. This
|
|
1177
|
-
// happens when an outer custom element with <slot> in its render takes
|
|
1178
|
-
// an edit that spans its opening + closing tags (covering inner custom
|
|
1179
|
-
// elements among authored children); the inner matches were enumerated
|
|
1180
|
-
// independently against the original html, but those inner elements
|
|
1181
|
-
// are processed by the recursive injectDSD call on innerWithSlots.
|
|
1182
|
-
// Keeping both edits would double-process them and corrupt the output.
|
|
1183
|
-
// A consequence: a nested instance's render() runs once per chain depth
|
|
1184
|
-
// (the discarded top-level pass sees an empty ancestor chain, so its
|
|
1185
|
-
// closest() reads null; the kept recursive pass has the real chain). The
|
|
1186
|
-
// kept pass is the only output, and closest() is a read, so render() must
|
|
1187
|
-
// stay pure at SSR (the standard SSR contract), not branch on side effects.
|
|
1188
|
-
edits.sort((a, b) => a.start - b.start);
|
|
1189
|
-
/** @type {{start:number, end:number, text:string}[]} */
|
|
1190
|
-
const filtered = [];
|
|
1191
|
-
let consumedTo = -1;
|
|
1192
|
-
for (const e of edits) {
|
|
1193
|
-
if (e.start >= consumedTo) {
|
|
1194
|
-
filtered.push(e);
|
|
1195
|
-
consumedTo = e.end;
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
// Apply edits from last to first so indices stay stable.
|
|
1199
|
-
let out = html;
|
|
1200
|
-
for (let i = filtered.length - 1; i >= 0; i--) {
|
|
1201
|
-
const { start, end, text } = filtered[i];
|
|
1202
|
-
out = out.slice(0, start) + text + out.slice(end);
|
|
1203
|
-
}
|
|
1204
|
-
return out;
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
// ---------------------------------------------------------------------------
|
|
1208
|
-
// Slot SSR helpers
|
|
1209
|
-
// ---------------------------------------------------------------------------
|
|
1210
|
-
|
|
1211
|
-
const VOID_ELEMENTS = new Set([
|
|
1212
|
-
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
1213
|
-
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
1214
|
-
]);
|
|
1215
|
-
|
|
1216
|
-
/** @param {string} tag @returns {boolean} */
|
|
1217
|
-
function isVoidElement(tag) {
|
|
1218
|
-
return VOID_ELEMENTS.has(tag.toLowerCase());
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
/**
|
|
1222
|
-
* Resolve `<webjs-suspense>` boundaries in an HTML string (#471). For each
|
|
1223
|
-
* top-level boundary (nested ones are handled by the recursive injectDSD that
|
|
1224
|
-
* processes a boundary's streamed children):
|
|
1225
|
-
*
|
|
1226
|
-
* - Streaming (a SuspenseCtx is present): emit the boundary as
|
|
1227
|
-
* `<webjs-suspense id="sN">FALLBACK</webjs-suspense>` and push the raw inner
|
|
1228
|
-
* children to `ctx.pending` (wrapped in `unsafeHTML` so the streaming pass
|
|
1229
|
-
* renders them as HTML, not escaped text, then runs injectDSD over them).
|
|
1230
|
-
* `streamSuspenseBoundaries` later streams the resolved children as a
|
|
1231
|
-
* `<template data-webjs-resolve="sN">` plus the swap script. Multiple
|
|
1232
|
-
* boundaries resolve via `Promise.all`, so their data fetches run
|
|
1233
|
-
* concurrently. The placeholder is the boundary's `.fallback`
|
|
1234
|
-
* (carried as `data-webjs-fallback`); a boundary without one shows empty.
|
|
1235
|
-
* - Blocking (no ctx): render the children inline now and drop the fallback,
|
|
1236
|
-
* so a non-streaming `renderToString` returns the real content. The
|
|
1237
|
-
* `<webjs-suspense>` wrapper stays as an inert inline element.
|
|
1238
|
-
*
|
|
1239
|
-
* @param {string} html
|
|
1240
|
-
* @param {SuspenseCtx} [ctx]
|
|
1241
|
-
* @param {any[]} [ancestors]
|
|
1242
|
-
* @param {boolean} [dev] server dev flag for prod-silence of a throwing
|
|
1243
|
-
* component in an inline (ctx-absent) boundary (#483)
|
|
1244
|
-
* @returns {Promise<string>}
|
|
1245
|
-
*/
|
|
1246
|
-
async function processSuspenseElements(html, ctx, ancestors = [], dev) {
|
|
1247
|
-
if (html.indexOf('<webjs-suspense') === -1) return html;
|
|
1248
|
-
const OPEN = /<webjs-suspense((?:"[^"]*"|'[^']*'|[^>])*?)>/i;
|
|
1249
|
-
// A commented-out boundary is text, not an element (#1128). This scanner is
|
|
1250
|
-
// the reason the comment fix cannot live in injectDSD alone: it runs FIRST
|
|
1251
|
-
// and hands the boundary's children to a fresh injectDSD as a standalone
|
|
1252
|
-
// string, which has no idea those bytes came from inside a comment. Under
|
|
1253
|
-
// streaming it is worse than a stray render, because the children's data
|
|
1254
|
-
// fetches run and the swap script targets an id that only exists inside a
|
|
1255
|
-
// comment, so it can never resolve. Ranges are computed against the FULL
|
|
1256
|
-
// input once, and `consumed` maps the shrinking `rest` back onto it.
|
|
1257
|
-
const inert = inertRanges(html);
|
|
1258
|
-
const isInert = inertAt(inert);
|
|
1259
|
-
let consumed = 0;
|
|
1260
|
-
let result = '';
|
|
1261
|
-
let rest = html;
|
|
1262
|
-
// Bounded loop: each iteration consumes at least the opening tag.
|
|
1263
|
-
for (let guard = 0; guard < 10000; guard++) {
|
|
1264
|
-
const m = OPEN.exec(rest);
|
|
1265
|
-
if (!m) {
|
|
1266
|
-
result += rest;
|
|
1267
|
-
break;
|
|
1268
|
-
}
|
|
1269
|
-
if (isInert(consumed + m.index)) {
|
|
1270
|
-
// Emit through the end of this match and keep scanning after it.
|
|
1271
|
-
const skipTo = m.index + m[0].length;
|
|
1272
|
-
result += rest.slice(0, skipTo);
|
|
1273
|
-
rest = rest.slice(skipTo);
|
|
1274
|
-
consumed += skipTo;
|
|
1275
|
-
continue;
|
|
1276
|
-
}
|
|
1277
|
-
const openStart = m.index;
|
|
1278
|
-
const openEnd = m.index + m[0].length;
|
|
1279
|
-
result += rest.slice(0, openStart);
|
|
1280
|
-
const attrs = m[1] || '';
|
|
1281
|
-
const fbMatch = /data-webjs-fallback="([^"]*)"/i.exec(attrs);
|
|
1282
|
-
const fallbackHtml = fbMatch ? unescapeAttr(fbMatch[1]) : '';
|
|
1283
|
-
|
|
1284
|
-
// Pass the FULL-input ranges shifted into `rest` coordinates rather than
|
|
1285
|
-
// letting the helper re-tokenize the suffix. `rest` can begin mid-comment
|
|
1286
|
-
// or mid-raw-text after the skip path above, and a tokenizer restarted
|
|
1287
|
-
// there is in the wrong state: a text-only opener named later in that same
|
|
1288
|
-
// comment would read as a real element and mark everything to EOF inert,
|
|
1289
|
-
// so the boundary's real close tag was skipped and the trailing markup
|
|
1290
|
-
// folded into the boundary. The shifted view keeps the full-string truth,
|
|
1291
|
-
// including a first range that starts before `rest` does.
|
|
1292
|
-
const shifted = [];
|
|
1293
|
-
for (const [s, e] of inert) {
|
|
1294
|
-
if (e <= consumed) continue;
|
|
1295
|
-
shifted.push([Math.max(0, s - consumed), e - consumed]);
|
|
1296
|
-
}
|
|
1297
|
-
const closeIdx = findClosingTagInString(rest, openEnd, 'webjs-suspense', shifted);
|
|
1298
|
-
let inner;
|
|
1299
|
-
let afterClose;
|
|
1300
|
-
if (closeIdx === -1) {
|
|
1301
|
-
inner = rest.slice(openEnd);
|
|
1302
|
-
afterClose = '';
|
|
1303
|
-
} else {
|
|
1304
|
-
inner = rest.slice(openEnd, closeIdx);
|
|
1305
|
-
const cm = /<\/webjs-suspense\s*>/i.exec(rest.slice(closeIdx));
|
|
1306
|
-
afterClose = rest.slice(closeIdx + (cm ? cm[0].length : '</webjs-suspense>'.length));
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
if (ctx) {
|
|
1310
|
-
const id = `s${ctx.nextId++}`;
|
|
1311
|
-
// Raw children stream in later. unsafeHTML so the streaming pass emits
|
|
1312
|
-
// the markup verbatim (then injectDSD runs over it, resolving the async
|
|
1313
|
-
// components and any nested boundaries).
|
|
1314
|
-
ctx.pending.push({ id, promise: Promise.resolve(unsafeHTML(inner)) });
|
|
1315
|
-
result += `<webjs-suspense id="${id}">${fallbackHtml}</webjs-suspense>`;
|
|
1316
|
-
} else {
|
|
1317
|
-
const innerProcessed = await injectDSD(inner, ctx, ancestors, dev);
|
|
1318
|
-
result += `<webjs-suspense>${innerProcessed}</webjs-suspense>`;
|
|
1319
|
-
}
|
|
1320
|
-
consumed += rest.length - afterClose.length;
|
|
1321
|
-
rest = afterClose;
|
|
1322
|
-
}
|
|
1323
|
-
return result;
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
/**
|
|
1327
|
-
* Find the position of the matching closing tag for `tagName` starting from
|
|
1328
|
-
* `fromIndex` in `html`. Handles nested same-tag elements via depth tracking.
|
|
1329
|
-
* Returns the index of the `<` of `</tagName>`, or -1 if unclosed.
|
|
1330
|
-
*
|
|
1331
|
-
* @param {string} html
|
|
1332
|
-
* @param {number} fromIndex
|
|
1333
|
-
* @param {string} tagName
|
|
1334
|
-
* @returns {number}
|
|
1335
|
-
*/
|
|
1336
|
-
function findClosingTagInString(html, fromIndex, tagName, inert) {
|
|
1337
|
-
const esc = escapeRegex(tagName);
|
|
1338
|
-
// Match same-name opening tags. Followed by a name-boundary character
|
|
1339
|
-
// so we don't accept <table> as opening <tab>.
|
|
1340
|
-
const openRe = new RegExp(`<${esc}(?:[\\s>/])`, 'gi');
|
|
1341
|
-
const closeRe = new RegExp(`</${esc}\\s*>`, 'gi');
|
|
1342
|
-
// A tag inside a comment, raw text, RCDATA, or an attribute value is text
|
|
1343
|
-
// and must count for NEITHER side of the depth ledger (#1133). Counting a
|
|
1344
|
-
// commented `<my-card>` as a nested open meant depth never returned to zero,
|
|
1345
|
-
// and matching a commented `</my-card>` as the close truncated the authored
|
|
1346
|
-
// children at the comment, so the projected content ended with an
|
|
1347
|
-
// unterminated `<!--` that a browser read as commenting out the real close
|
|
1348
|
-
// tags. Callers that already computed the ranges for this exact string pass
|
|
1349
|
-
// them; a caller that did not gets them computed here.
|
|
1350
|
-
const ranges = inert === undefined ? inertRanges(html) : inert;
|
|
1351
|
-
const next = (re) => {
|
|
1352
|
-
let m;
|
|
1353
|
-
while ((m = re.exec(html)) !== null) {
|
|
1354
|
-
if (ranges.length === 0 || !inRanges(ranges, m.index)) return m;
|
|
1355
|
-
}
|
|
1356
|
-
return null;
|
|
1357
|
-
};
|
|
1358
|
-
openRe.lastIndex = fromIndex;
|
|
1359
|
-
closeRe.lastIndex = fromIndex;
|
|
1360
|
-
let depth = 1;
|
|
1361
|
-
while (depth > 0) {
|
|
1362
|
-
const o = next(openRe);
|
|
1363
|
-
const c = next(closeRe);
|
|
1364
|
-
if (!c) return -1;
|
|
1365
|
-
if (o && o.index < c.index) {
|
|
1366
|
-
depth++;
|
|
1367
|
-
closeRe.lastIndex = o.index + 1;
|
|
1368
|
-
} else {
|
|
1369
|
-
depth--;
|
|
1370
|
-
if (depth === 0) return c.index;
|
|
1371
|
-
openRe.lastIndex = c.index + 1;
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
return -1;
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
/**
|
|
1378
|
-
* Extract the `slot` attribute value from an attribute string. Returns
|
|
1379
|
-
* null when the attribute is absent.
|
|
1380
|
-
*
|
|
1381
|
-
* @param {string} attrsRaw
|
|
1382
|
-
* @returns {string | null}
|
|
1383
|
-
*/
|
|
1384
|
-
function extractSlotAttr(attrsRaw) {
|
|
1385
|
-
const m = /\bslot\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
|
|
1386
|
-
if (!m) return null;
|
|
1387
|
-
const value = m[1] ?? m[2] ?? m[3] ?? '';
|
|
1388
|
-
// Per shadow DOM spec, slot="" (empty) and missing slot attribute both
|
|
1389
|
-
// route to the default slot. `default` is the framework's reserved alias
|
|
1390
|
-
// for it (#1015: the client record normalizes it identically, so both
|
|
1391
|
-
// sides agree end to end).
|
|
1392
|
-
return value === '' || value === 'default' ? null : value;
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
/**
|
|
1396
|
-
* Partition authored inner HTML by each top-level child's `slot=""`
|
|
1397
|
-
* attribute. Text nodes, comment nodes, and elements without `slot=""`
|
|
1398
|
-
* all route to the default-slot key (null).
|
|
1399
|
-
*
|
|
1400
|
-
* Returns a Map keyed by slot name (null for default) whose values are
|
|
1401
|
-
* the concatenated HTML strings for that slot in source order.
|
|
1402
|
-
*
|
|
1403
|
-
* @param {string} html
|
|
1404
|
-
* @returns {Map<string|null, string>}
|
|
1405
|
-
*/
|
|
1406
|
-
function partitionAuthoredBySlot(html) {
|
|
1407
|
-
/** @type {Map<string|null, string>} */
|
|
1408
|
-
const groups = new Map();
|
|
1409
|
-
let defaultBuf = '';
|
|
1410
|
-
let cursor = 0;
|
|
1411
|
-
while (cursor < html.length) {
|
|
1412
|
-
const lt = html.indexOf('<', cursor);
|
|
1413
|
-
if (lt === -1) {
|
|
1414
|
-
defaultBuf += html.slice(cursor);
|
|
1415
|
-
break;
|
|
1416
|
-
}
|
|
1417
|
-
if (lt > cursor) defaultBuf += html.slice(cursor, lt);
|
|
1418
|
-
const rest = html.slice(lt);
|
|
1419
|
-
if (rest.startsWith('<!--')) {
|
|
1420
|
-
// Find the comment's end the same way inertRanges does, rather than with
|
|
1421
|
-
// a bare `indexOf('-->')`. The two helpers both decide where a comment
|
|
1422
|
-
// stops, so a bare search makes them DISAGREE on the spec short forms
|
|
1423
|
-
// (`--!>`, `<!-->`, `<!--->`): this one would run past the real end and
|
|
1424
|
-
// swallow the slotted children that follow, silently routing a
|
|
1425
|
-
// `slot="head"` child into the default slot.
|
|
1426
|
-
const commentEnd = endOfComment(html, lt);
|
|
1427
|
-
if (commentEnd === -1) {
|
|
1428
|
-
defaultBuf += rest;
|
|
1429
|
-
cursor = html.length;
|
|
1430
|
-
break;
|
|
1431
|
-
}
|
|
1432
|
-
defaultBuf += html.slice(lt, commentEnd);
|
|
1433
|
-
cursor = commentEnd;
|
|
1434
|
-
continue;
|
|
1435
|
-
}
|
|
1436
|
-
if (rest.startsWith('<!') || rest.startsWith('</')) {
|
|
1437
|
-
const end = html.indexOf('>', lt);
|
|
1438
|
-
if (end === -1) {
|
|
1439
|
-
defaultBuf += rest;
|
|
1440
|
-
cursor = html.length;
|
|
1441
|
-
break;
|
|
1442
|
-
}
|
|
1443
|
-
defaultBuf += html.slice(lt, end + 1);
|
|
1444
|
-
cursor = end + 1;
|
|
1445
|
-
continue;
|
|
1446
|
-
}
|
|
1447
|
-
const tagMatch = /^<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/.exec(rest);
|
|
1448
|
-
if (!tagMatch) {
|
|
1449
|
-
defaultBuf += '<';
|
|
1450
|
-
cursor = lt + 1;
|
|
1451
|
-
continue;
|
|
1452
|
-
}
|
|
1453
|
-
const [tagFull, tagName, attrsRaw, selfCloseSlash] = tagMatch;
|
|
1454
|
-
const lower = tagName.toLowerCase();
|
|
1455
|
-
const isSelfClose = !!selfCloseSlash || isVoidElement(lower);
|
|
1456
|
-
const slotAttr = extractSlotAttr(attrsRaw);
|
|
1457
|
-
let elemEnd;
|
|
1458
|
-
if (isSelfClose) {
|
|
1459
|
-
elemEnd = lt + tagFull.length;
|
|
1460
|
-
} else {
|
|
1461
|
-
const innerStart = lt + tagFull.length;
|
|
1462
|
-
const closeIdx = findClosingTagInString(html, innerStart, lower);
|
|
1463
|
-
if (closeIdx === -1) {
|
|
1464
|
-
// Unclosed element. Take to end of html.
|
|
1465
|
-
const elementHTML = html.slice(lt);
|
|
1466
|
-
if (slotAttr !== null) appendStringToMap(groups, slotAttr, elementHTML);
|
|
1467
|
-
else defaultBuf += elementHTML;
|
|
1468
|
-
cursor = html.length;
|
|
1469
|
-
continue;
|
|
1470
|
-
}
|
|
1471
|
-
const closeRe = new RegExp(`</${escapeRegex(lower)}\\s*>`, 'i');
|
|
1472
|
-
const tail = html.slice(closeIdx);
|
|
1473
|
-
const closeMatch = closeRe.exec(tail);
|
|
1474
|
-
const closeLen = closeMatch ? closeMatch[0].length : `</${lower}>`.length;
|
|
1475
|
-
elemEnd = closeIdx + closeLen;
|
|
1476
|
-
}
|
|
1477
|
-
const elementHTML = html.slice(lt, elemEnd);
|
|
1478
|
-
if (slotAttr !== null) appendStringToMap(groups, slotAttr, elementHTML);
|
|
1479
|
-
else defaultBuf += elementHTML;
|
|
1480
|
-
cursor = elemEnd;
|
|
1481
|
-
}
|
|
1482
|
-
if (defaultBuf.length > 0) groups.set(null, defaultBuf);
|
|
1483
|
-
return groups;
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
/** Append a string to a Map<K, string>, concatenating if the key exists. */
|
|
1487
|
-
function appendStringToMap(map, key, value) {
|
|
1488
|
-
const existing = map.get(key);
|
|
1489
|
-
if (existing !== undefined) map.set(key, existing + value);
|
|
1490
|
-
else map.set(key, value);
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
/**
|
|
1494
|
-
* Substitute every `<slot>` tag in `rendered` with a framework-marked
|
|
1495
|
-
* `<slot data-webjs-light data-projection="actual|fallback">` element
|
|
1496
|
-
* carrying either the projected children for that slot (from
|
|
1497
|
-
* `partitioned`) or the slot's authored fallback content. Multiple
|
|
1498
|
-
* slots with the same name follow the first-wins rule per spec; later
|
|
1499
|
-
* same-named slots fall back regardless of available projection.
|
|
1500
|
-
*
|
|
1501
|
-
* The `ownerTag` (the tag of the component whose template rendered these
|
|
1502
|
-
* slots) is emitted as `data-wj-slot-owner` so the client resolves template
|
|
1503
|
-
* ownership on hydration the same way the client renderer stamps SLOT_OWNER,
|
|
1504
|
-
* which is what makes a FORWARDED slot (rendered by this component but nested
|
|
1505
|
-
* inside a child) route to this component and not the child (#1023).
|
|
1506
|
-
*
|
|
1507
|
-
* @param {string} rendered
|
|
1508
|
-
* @param {Map<string|null, string>} partitioned
|
|
1509
|
-
* @param {string} ownerTag
|
|
1510
|
-
* @returns {string}
|
|
1511
|
-
*/
|
|
1512
|
-
function substituteSlotsInRender(rendered, partitioned, ownerTag) {
|
|
1513
|
-
const ownerAttr = ownerTag ? ` data-wj-slot-owner="${escapeAttr(ownerTag)}"` : '';
|
|
1514
|
-
/** @type {Set<string|null>} */
|
|
1515
|
-
const consumedNames = new Set();
|
|
1516
|
-
let result = '';
|
|
1517
|
-
let cursor = 0;
|
|
1518
|
-
const slotRe = /<slot((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/gi;
|
|
1519
|
-
// A `<slot>` written inside a comment is documentation, not a slot (#1128).
|
|
1520
|
-
// Substituting one is worse here than in the element walk: a commented slot
|
|
1521
|
-
// has no `</slot>`, so the fallback scan below swallows the rest of the
|
|
1522
|
-
// template, the component's REAL slot is never substituted, and the authored
|
|
1523
|
-
// children are dropped from the page entirely.
|
|
1524
|
-
const inert = inertRanges(rendered);
|
|
1525
|
-
const isInert = inertAt(inert);
|
|
1526
|
-
let m;
|
|
1527
|
-
while ((m = slotRe.exec(rendered)) !== null) {
|
|
1528
|
-
if (isInert(m.index)) continue;
|
|
1529
|
-
result += rendered.slice(cursor, m.index);
|
|
1530
|
-
const [fullOpen, attrsRaw, selfCloseSlash] = m;
|
|
1531
|
-
const isSelfClose = !!selfCloseSlash;
|
|
1532
|
-
const nameMatch = /\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
|
|
1533
|
-
const name = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : null;
|
|
1534
|
-
// Strip the `name` attribute from the carried-through attribute
|
|
1535
|
-
// string so we can re-add it (with escaping) on the framework slot.
|
|
1536
|
-
const otherAttrs = attrsRaw.replace(/\bname\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i, '').trim();
|
|
1537
|
-
let fallback = '';
|
|
1538
|
-
let totalEnd;
|
|
1539
|
-
if (isSelfClose) {
|
|
1540
|
-
totalEnd = m.index + fullOpen.length;
|
|
1541
|
-
} else {
|
|
1542
|
-
const innerStart = m.index + fullOpen.length;
|
|
1543
|
-
const closeIdx = findClosingTagInString(rendered, innerStart, 'slot', inert);
|
|
1544
|
-
if (closeIdx === -1) {
|
|
1545
|
-
fallback = rendered.slice(innerStart);
|
|
1546
|
-
totalEnd = rendered.length;
|
|
1547
|
-
} else {
|
|
1548
|
-
fallback = rendered.slice(innerStart, closeIdx);
|
|
1549
|
-
const closeRe = /<\/slot\s*>/i;
|
|
1550
|
-
const tail = rendered.slice(closeIdx);
|
|
1551
|
-
const closeMatch = closeRe.exec(tail);
|
|
1552
|
-
const closeLen = closeMatch ? closeMatch[0].length : '</slot>'.length;
|
|
1553
|
-
totalEnd = closeIdx + closeLen;
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1556
|
-
// `default` and `''` are the reserved aliases for the default slot
|
|
1557
|
-
// (#1015), matching the client's keyOfName exactly: the LOOKUP key
|
|
1558
|
-
// normalizes, while the emitted name attribute stays as authored so the
|
|
1559
|
-
// output bytes are unchanged for every other app.
|
|
1560
|
-
const slotKey = name === 'default' || name === '' ? null : name;
|
|
1561
|
-
const projected = partitioned.get(slotKey);
|
|
1562
|
-
const nameAttr = name !== null ? ` name="${escapeAttr(name)}"` : '';
|
|
1563
|
-
const extraAttrs = otherAttrs ? ` ${otherAttrs}` : '';
|
|
1564
|
-
if (projected !== undefined && !consumedNames.has(slotKey)) {
|
|
1565
|
-
consumedNames.add(slotKey);
|
|
1566
|
-
result += `<slot data-webjs-light data-projection="actual"${ownerAttr}${nameAttr}${extraAttrs}>${projected}</slot>`;
|
|
1567
|
-
} else {
|
|
1568
|
-
result += `<slot data-webjs-light data-projection="fallback"${ownerAttr}${nameAttr}${extraAttrs}>${fallback}</slot>`;
|
|
1569
|
-
}
|
|
1570
|
-
cursor = totalEnd;
|
|
1571
|
-
slotRe.lastIndex = totalEnd;
|
|
1572
|
-
}
|
|
1573
|
-
result += rendered.slice(cursor);
|
|
1574
|
-
return result;
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
/** @param {string} s */
|
|
1578
|
-
function escapeRegex(s) {
|
|
1579
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
/** @param {string} tag */
|
|
1583
|
-
function isRawtextTag(tag) {
|
|
1584
|
-
return tag === 'script' || tag === 'style';
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
/**
|
|
1588
|
-
* RCDATA elements: their content is text (character references aside), so a
|
|
1589
|
-
* tag-shaped string inside one is not markup. Kept next to `isRawtextTag` so
|
|
1590
|
-
* the two lists stay together rather than drifting apart.
|
|
1591
|
-
* @param {string} tag
|
|
1592
|
-
* @returns {boolean}
|
|
1593
|
-
*/
|
|
1594
|
-
function isRcdataTag(tag) {
|
|
1595
|
-
return tag === 'textarea' || tag === 'title';
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
/**
|
|
1599
|
-
* Elements whose content the HTML tokenizer never reads as markup, for the
|
|
1600
|
-
* purposes of `inertRanges` only (#1128).
|
|
1601
|
-
*
|
|
1602
|
-
* Deliberately NOT `isRawtextTag`, even though it overlaps: that predicate is
|
|
1603
|
-
* shared with the template tokenizer, where widening it would change how holes
|
|
1604
|
-
* inside those elements are escaped. This one answers a narrower question,
|
|
1605
|
-
* "can a tag-shaped string in here be a real element", and the answer is no for
|
|
1606
|
-
* every raw-text and RCDATA element, not just the two the template path cares
|
|
1607
|
-
* about. `<iframe>` with fallback markup is the realistic trigger.
|
|
1608
|
-
*
|
|
1609
|
-
* `<noscript>` is excluded on purpose: its content IS parsed as markup when
|
|
1610
|
-
* scripting is disabled, which for a progressive-enhancement framework is the
|
|
1611
|
-
* case that matters, so components inside it must keep rendering.
|
|
1612
|
-
*
|
|
1613
|
-
* @param {string} tag
|
|
1614
|
-
* @returns {boolean}
|
|
1615
|
-
*/
|
|
1616
|
-
function isTextOnlyTag(tag) {
|
|
1617
|
-
return isRawtextTag(tag) || isRcdataTag(tag)
|
|
1618
|
-
|| tag === 'iframe' || tag === 'xmp' || tag === 'noembed'
|
|
1619
|
-
|| tag === 'noframes' || tag === 'plaintext';
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
/**
|
|
1623
|
-
* Minimal attribute string parser.
|
|
1624
|
-
* @param {string} attrStr
|
|
1625
|
-
* @returns {Record<string,string>}
|
|
1626
|
-
*/
|
|
1627
|
-
function parseAttrs(attrStr) {
|
|
1628
|
-
/** @type {Record<string,string>} */
|
|
1629
|
-
const out = {};
|
|
1630
|
-
const re = /([a-zA-Z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
1631
|
-
let m;
|
|
1632
|
-
while ((m = re.exec(attrStr)) !== null) {
|
|
1633
|
-
out[m[1]] = m[2] ?? m[3] ?? m[4] ?? '';
|
|
1634
|
-
}
|
|
1635
|
-
return out;
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
/**
|
|
1639
|
-
* Seed the element's attributes from the source opening tag so reads like
|
|
1640
|
-
* `this.getAttribute(name)` / `this.hasAttribute(name)` inside willUpdate /
|
|
1641
|
-
* render return the real value during SSR. Goes through `setAttribute`, which
|
|
1642
|
-
* both the server element shim (Node SSR) and a real `HTMLElement`
|
|
1643
|
-
* (renderToString called in a browser, e.g. tests) implement, so the path
|
|
1644
|
-
* does not depend on the shim's internal store. A bare Base-extending kit
|
|
1645
|
-
* component without `setAttribute` is skipped.
|
|
1646
|
-
*
|
|
1647
|
-
* @param {any} instance
|
|
1648
|
-
* @param {Record<string,string>} attrs parsed source attributes (data-webjs-prop-* already removed)
|
|
1649
|
-
*/
|
|
1650
|
-
function seedServerAttrs(instance, attrs) {
|
|
1651
|
-
if (!instance || typeof instance.setAttribute !== 'function') return;
|
|
1652
|
-
for (const [name, raw] of Object.entries(attrs)) {
|
|
1653
|
-
instance.setAttribute(name, unescapeAttr(raw));
|
|
1654
|
-
}
|
|
1655
|
-
}
|
|
1656
|
-
|
|
1657
|
-
/**
|
|
1658
|
-
* Append attributes the component set before render (reflected reflect:true
|
|
1659
|
-
* properties, or an explicit `this.setAttribute` in the constructor /
|
|
1660
|
-
* willUpdate) to the element's opening tag, skipping any name already present
|
|
1661
|
-
* in the source tag. Reads via the standard `getAttributeNames` /
|
|
1662
|
-
* `getAttribute` API so it works whether the instance is the server shim or a
|
|
1663
|
-
* real `HTMLElement`. Returns the opening tag unchanged when there is nothing
|
|
1664
|
-
* to add, so existing SSR output stays byte-identical when no component
|
|
1665
|
-
* reflects, which preserves the elision on-vs-off differential invariant.
|
|
1666
|
-
*
|
|
1667
|
-
* @param {string} opening the element's opening tag, ending in `>`
|
|
1668
|
-
* @param {any} instance
|
|
1669
|
-
* @param {Set<string>} presentAttrNames lowercased names already in the source tag
|
|
1670
|
-
* @returns {string}
|
|
1671
|
-
*/
|
|
1672
|
-
/**
|
|
1673
|
-
* Add the component host marker (`data-wj-host`) to an opening tag, unless it
|
|
1674
|
-
* is already present. Insert before the closing `>` the same way
|
|
1675
|
-
* `appendReflectedAttrs` does. Idempotent so a re-processed tag is unchanged.
|
|
1676
|
-
* @param {string} opening the element's opening tag, ending in `>`
|
|
1677
|
-
* @returns {string}
|
|
1678
|
-
*/
|
|
1679
|
-
function withHostMarker(opening) {
|
|
1680
|
-
if (/\sdata-wj-host(?=[\s>=])/i.test(opening)) return opening;
|
|
1681
|
-
return `${opening.slice(0, -1)} data-wj-host>`;
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
function appendReflectedAttrs(opening, instance, presentAttrNames) {
|
|
1685
|
-
if (!instance || typeof instance.getAttributeNames !== 'function') return opening;
|
|
1686
|
-
let extra = '';
|
|
1687
|
-
for (const rawName of instance.getAttributeNames()) {
|
|
1688
|
-
const name = String(rawName).toLowerCase();
|
|
1689
|
-
if (presentAttrNames.has(name)) continue;
|
|
1690
|
-
const value = instance.getAttribute(rawName);
|
|
1691
|
-
extra += value === '' ? ` ${name}` : ` ${name}="${escapeAttr(String(value))}"`;
|
|
1692
|
-
}
|
|
1693
|
-
if (!extra) return opening;
|
|
1694
|
-
// Insert before the closing `>` (the opening tag is normalised to end in
|
|
1695
|
-
// `>`; a self-closing source tag was already rewritten without the slash).
|
|
1696
|
-
return `${opening.slice(0, -1)}${extra}>`;
|
|
1697
|
-
}
|
|
1698
|
-
|
|
1699
|
-
/**
|
|
1700
|
-
* Coerce attribute strings to typed properties on a component instance
|
|
1701
|
-
* based on its static `properties` declaration.
|
|
1702
|
-
*/
|
|
1703
|
-
function applyAttrsToInstance(instance, attrs, Cls) {
|
|
1704
|
-
const props = Cls.properties || {};
|
|
1705
|
-
for (const [key, raw] of Object.entries(attrs)) {
|
|
1706
|
-
// Resolve the source attribute name to its property: a custom `attribute`
|
|
1707
|
-
// option wins, else the kebab-cased property name, else the camelCase of
|
|
1708
|
-
// the attribute (the common case). Mirrors the client attributeChangedCallback
|
|
1709
|
-
// so a custom-attribute prop gets the right value in the SSR'd first paint.
|
|
1710
|
-
let propName, rawDef;
|
|
1711
|
-
for (const [k, decl] of Object.entries(props)) {
|
|
1712
|
-
const d = typeof decl === 'object' ? decl : { type: decl };
|
|
1713
|
-
if ((d.attribute || hyphenate(k)) === key) { propName = k; rawDef = decl; break; }
|
|
1714
|
-
}
|
|
1715
|
-
if (rawDef === undefined) {
|
|
1716
|
-
rawDef = props[key] || props[camelCase(key)];
|
|
1717
|
-
propName = props[key] ? key : camelCase(key);
|
|
1718
|
-
}
|
|
1719
|
-
if (!rawDef) {
|
|
1720
|
-
instance[propName] = raw;
|
|
1721
|
-
continue;
|
|
1722
|
-
}
|
|
1723
|
-
// The factory accepts a bare constructor shorthand (`{ expanded: Boolean }`)
|
|
1724
|
-
// alongside the long form (`{ expanded: { type: Boolean } }`); normalize so
|
|
1725
|
-
// the type-based coercion below sees a `{ type }` object either way.
|
|
1726
|
-
const def = typeof rawDef === 'object' ? rawDef : { type: rawDef };
|
|
1727
|
-
if (def.type === Number) instance[propName] = Number(raw);
|
|
1728
|
-
else if (def.type === Boolean) instance[propName] = raw !== 'false';
|
|
1729
|
-
else if (def.type === Object || def.type === Array) {
|
|
1730
|
-
// `raw` is the entity-encoded attribute text (parseAttrs returns the
|
|
1731
|
-
// literal characters between the quotes), so decode the HTML entities
|
|
1732
|
-
// before JSON.parse. A JSON attribute carries `"` for every `"`;
|
|
1733
|
-
// parsing it raw throws, and the prop then falls to the failure value
|
|
1734
|
-
// below rather than to the object the author wrote.
|
|
1735
|
-
//
|
|
1736
|
-
// An unparseable attribute falls back to `null`, NOT to the raw string
|
|
1737
|
-
// (#1253), matching this branch's counterpart in
|
|
1738
|
-
// `attributeChangedCallback`. They have to agree, or the same
|
|
1739
|
-
// `<my-el cfg="oops">` SSRs holding a string and re-renders holding
|
|
1740
|
-
// something else the moment the element upgrades. `null` is the right
|
|
1741
|
-
// value to agree on: a string is never a valid value for a property the
|
|
1742
|
-
// author declared `Object` or `Array`. This function iterates the
|
|
1743
|
-
// attributes PRESENT on the source tag, so an absent one is not read here
|
|
1744
|
-
// at all and its property keeps whatever the constructor gave it; the
|
|
1745
|
-
// agreement being asserted is about a present, unparseable attribute.
|
|
1746
|
-
//
|
|
1747
|
-
// That agreement is about this FALLBACK, not a guarantee that the two
|
|
1748
|
-
// readers see the same attributes in the first place. This function
|
|
1749
|
-
// walks the parsed source tag while the client goes through
|
|
1750
|
-
// `observedAttributes` and the browser's own name-lowercasing, and
|
|
1751
|
-
// hand-written markup can land in the gaps between those routes (the
|
|
1752
|
-
// entity decoding just below is one, since it reverses far less than a
|
|
1753
|
-
// browser does). Those gaps predate #1253 and it neither causes nor
|
|
1754
|
-
// closes any of them; no attempt is made here to enumerate them,
|
|
1755
|
-
// because every attempt so far has been incomplete.
|
|
1756
|
-
//
|
|
1757
|
-
// Scoped to the DEFAULT converter, which is all this branch is. The
|
|
1758
|
-
// client reader tries `converter.fromAttribute` FIRST and only falls
|
|
1759
|
-
// through to here when there is none, while this function has no
|
|
1760
|
-
// converter arm at all, so a prop declaring one is read differently by
|
|
1761
|
-
// the two sides regardless of what this line does. That gap predates
|
|
1762
|
-
// #1253 and is left alone rather than widened.
|
|
1763
|
-
try { instance[propName] = JSON.parse(unescapeAttr(raw)); } catch { instance[propName] = null; }
|
|
1764
|
-
} else instance[propName] = raw;
|
|
1765
|
-
}
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
|
-
/** @param {string} s */
|
|
1769
|
-
function camelCase(s) {
|
|
1770
|
-
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
/** Kebab-case a property name for its default HTML attribute (matches component.js). @param {string} s */
|
|
1774
|
-
function hyphenate(s) {
|
|
1775
|
-
return s.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
1776
|
-
}
|
|
1777
|
-
|
|
1778
|
-
/**
|
|
1779
|
-
* Inverse of camelCase. `userName` -> `user-name`, `userID` -> `user-i-d`.
|
|
1780
|
-
* Used to serialize property-binding names into HTML attribute names,
|
|
1781
|
-
* which are case-insensitive in the parser. The original JS property
|
|
1782
|
-
* name is recovered via camelCase() on the consumer side.
|
|
1783
|
-
*
|
|
1784
|
-
* @param {string} s
|
|
1785
|
-
*/
|
|
1786
|
-
function kebabCase(s) {
|
|
1787
|
-
return s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
/**
|
|
1791
|
-
* Reverse `escapeAttr` on a server-side attribute value. Needed
|
|
1792
|
-
* because `parseAttrs` returns the literal characters between the
|
|
1793
|
-
* quote marks; HTML entities are not decoded by the regex. The
|
|
1794
|
-
* browser handles this automatically, so client-side reads via
|
|
1795
|
-
* `getAttribute()` do not need the same step.
|
|
1796
|
-
*
|
|
1797
|
-
* @param {string} s
|
|
1798
|
-
*/
|
|
1799
|
-
function unescapeAttr(s) {
|
|
1800
|
-
return s
|
|
1801
|
-
.replace(/</g, '<')
|
|
1802
|
-
.replace(/"/g, '"')
|
|
1803
|
-
.replace(/&/g, '&');
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
/**
|
|
1807
|
-
* Decode `data-webjs-prop-<kebab>` attributes from a parsed attribute
|
|
1808
|
-
* map, returning a map of camelCase property name to decoded value.
|
|
1809
|
-
* Mutates `attrs` by deleting the consumed entries so they do not
|
|
1810
|
-
* appear in the rendered output a second time.
|
|
1811
|
-
*
|
|
1812
|
-
* @param {Record<string,string>} attrs
|
|
1813
|
-
* @returns {Record<string, unknown>}
|
|
1814
|
-
*/
|
|
1815
|
-
function consumePropAttrs(attrs) {
|
|
1816
|
-
/** @type {Record<string, unknown>} */
|
|
1817
|
-
const props = {};
|
|
1818
|
-
for (const key of Object.keys(attrs)) {
|
|
1819
|
-
if (!key.startsWith('data-webjs-prop-')) continue;
|
|
1820
|
-
const propName = camelCase(key.slice('data-webjs-prop-'.length));
|
|
1821
|
-
try {
|
|
1822
|
-
props[propName] = parse(unescapeAttr(attrs[key]));
|
|
1823
|
-
} catch {
|
|
1824
|
-
// Malformed payload. Skip silently so the rest of the component
|
|
1825
|
-
// can still render. The client-side hydration will also try and
|
|
1826
|
-
// fail, which is fine: undefined-prop semantics.
|
|
1827
|
-
}
|
|
1828
|
-
delete attrs[key];
|
|
1829
|
-
}
|
|
1830
|
-
return props;
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
// ---------------------------------------------------------------------------
|
|
1834
|
-
// Streaming renderer
|
|
1835
|
-
// ---------------------------------------------------------------------------
|
|
1836
|
-
|
|
1837
|
-
/**
|
|
1838
|
-
* Render a TemplateResult (or any renderable value) to a `ReadableStream`
|
|
1839
|
-
* that yields HTML chunks as strings.
|
|
1840
|
-
*
|
|
1841
|
-
* Works identically to {@link renderToString} but streams partial HTML as
|
|
1842
|
-
* it is rendered: avoiding buffering the entire page in memory. For
|
|
1843
|
-
* Suspense boundaries, the fallback is yielded immediately and resolved
|
|
1844
|
-
* content is streamed afterwards at the end of the response.
|
|
1845
|
-
*
|
|
1846
|
-
* **AI hint:** Use `renderToStream` when you want to pipe SSR output
|
|
1847
|
-
* directly into a `Response` for streaming delivery (e.g. HTTP chunked
|
|
1848
|
-
* transfer). It accepts the same arguments as `renderToString`.
|
|
1849
|
-
*
|
|
1850
|
-
* @param {unknown} value A TemplateResult, string, array, or any renderable.
|
|
1851
|
-
* @param {{ ssr?: boolean, suspenseCtx?: SuspenseCtx }} [opts]
|
|
1852
|
-
* @returns {ReadableStream<string>}
|
|
1853
|
-
*/
|
|
1854
|
-
export function renderToStream(value, opts = { ssr: true }) {
|
|
1855
|
-
const ctx = opts && opts.suspenseCtx;
|
|
1856
|
-
// Server dev flag for prod-silence of SSR error states (#483), same sourcing
|
|
1857
|
-
// as renderToString: opts.dev wins, else inherit from the ctx, else undefined
|
|
1858
|
-
// (NODE_ENV fallback). Back-fill the ctx so the streamed sub-renders share it.
|
|
1859
|
-
const dev = opts && opts.dev !== undefined ? opts.dev : ctx && ctx.dev;
|
|
1860
|
-
if (ctx && ctx.dev === undefined && dev !== undefined) ctx.dev = dev;
|
|
1861
|
-
return new ReadableStream({
|
|
1862
|
-
async start(controller) {
|
|
1863
|
-
try {
|
|
1864
|
-
if (opts && opts.ssr === false) {
|
|
1865
|
-
// No DSD injection: just stream the raw rendered chunks.
|
|
1866
|
-
await streamRender(value, ctx, controller, 'none');
|
|
1867
|
-
} else {
|
|
1868
|
-
// Render to string first to run DSD injection (which operates on
|
|
1869
|
-
// the full HTML), then enqueue the result. This matches the
|
|
1870
|
-
// semantics of renderToString but still gives us a stream.
|
|
1871
|
-
const html = await render(value, ctx);
|
|
1872
|
-
const full = await injectDSD(html, ctx, [], dev);
|
|
1873
|
-
controller.enqueue(full);
|
|
1874
|
-
}
|
|
1875
|
-
|
|
1876
|
-
// Stream resolved Suspense boundaries after the main content.
|
|
1877
|
-
if (ctx && ctx.pending.length) {
|
|
1878
|
-
await streamSuspenseBoundaries(ctx, controller, dev);
|
|
1879
|
-
}
|
|
1880
|
-
controller.close();
|
|
1881
|
-
} catch (err) {
|
|
1882
|
-
controller.error(err);
|
|
1883
|
-
}
|
|
1884
|
-
},
|
|
1885
|
-
});
|
|
1886
|
-
}
|
|
1887
|
-
|
|
1888
|
-
/**
|
|
1889
|
-
* Recursively render a value, enqueuing HTML chunks into the stream
|
|
1890
|
-
* controller as they become available.
|
|
1891
|
-
*
|
|
1892
|
-
* @param {unknown} value
|
|
1893
|
-
* @param {SuspenseCtx} [ctx]
|
|
1894
|
-
* @param {ReadableStreamDefaultController<string>} controller
|
|
1895
|
-
*/
|
|
1896
|
-
async function streamRender(value, ctx, controller) {
|
|
1897
|
-
if (value == null || value === false || value === true) return;
|
|
1898
|
-
if (value && typeof /** @type any */ (value).then === 'function') {
|
|
1899
|
-
value = await value;
|
|
1900
|
-
return streamRender(value, ctx, controller);
|
|
1901
|
-
}
|
|
1902
|
-
if (isUnsafeHTML(value)) {
|
|
1903
|
-
controller.enqueue(String(/** @type any */ (value).value ?? ''));
|
|
1904
|
-
return;
|
|
1905
|
-
}
|
|
1906
|
-
if (isLive(value)) {
|
|
1907
|
-
return streamRender(/** @type any */ (value).value, ctx, controller);
|
|
1908
|
-
}
|
|
1909
|
-
if (isWatch(value)) {
|
|
1910
|
-
return streamRender(/** @type any */ (value).signal.get(), ctx, controller);
|
|
1911
|
-
}
|
|
1912
|
-
if (isKeyed(value)) {
|
|
1913
|
-
return streamRender(/** @type any */ (value).value, ctx, controller);
|
|
1914
|
-
}
|
|
1915
|
-
if (isGuard(value)) {
|
|
1916
|
-
return streamRender(/** @type any */ (value).fn(), ctx, controller);
|
|
1917
|
-
}
|
|
1918
|
-
if (isTemplateContent(value)) {
|
|
1919
|
-
const tpl = /** @type any */ (value).template;
|
|
1920
|
-
controller.enqueue(String(tpl?.innerHTML ?? ''));
|
|
1921
|
-
return;
|
|
1922
|
-
}
|
|
1923
|
-
if (isRef(value)) {
|
|
1924
|
-
return;
|
|
1925
|
-
}
|
|
1926
|
-
if (isCache(value)) {
|
|
1927
|
-
return streamRender(/** @type any */ (value).value, ctx, controller);
|
|
1928
|
-
}
|
|
1929
|
-
if (isUntil(value)) {
|
|
1930
|
-
const args = /** @type any */ (value).args;
|
|
1931
|
-
for (const a of args) {
|
|
1932
|
-
if (!a || typeof (/** @type any */ (a).then) !== 'function') {
|
|
1933
|
-
return streamRender(a, ctx, controller);
|
|
1934
|
-
}
|
|
1935
|
-
}
|
|
1936
|
-
if (args.length > 0) {
|
|
1937
|
-
try {
|
|
1938
|
-
const winner = await Promise.race(args.map((p) => Promise.resolve(p).catch(() => undefined)));
|
|
1939
|
-
return streamRender(winner, ctx, controller);
|
|
1940
|
-
} catch {
|
|
1941
|
-
return;
|
|
1942
|
-
}
|
|
1943
|
-
}
|
|
1944
|
-
return;
|
|
1945
|
-
}
|
|
1946
|
-
if (isAsyncAppend(value) || isAsyncReplace(value)) {
|
|
1947
|
-
return;
|
|
1948
|
-
}
|
|
1949
|
-
if (Array.isArray(value)) {
|
|
1950
|
-
for (const v of value) await streamRender(v, ctx, controller);
|
|
1951
|
-
return;
|
|
1952
|
-
}
|
|
1953
|
-
if (isRepeat(value)) {
|
|
1954
|
-
const r = /** @type any */ (value);
|
|
1955
|
-
for (let i = 0; i < r.items.length; i++) {
|
|
1956
|
-
await streamRender(r.templateFn(r.items[i], i), ctx, controller);
|
|
1957
|
-
}
|
|
1958
|
-
return;
|
|
1959
|
-
}
|
|
1960
|
-
if (isSuspense(value)) {
|
|
1961
|
-
const s = /** @type any */ (value);
|
|
1962
|
-
if (ctx) {
|
|
1963
|
-
const id = `s${ctx.nextId++}`;
|
|
1964
|
-
controller.enqueue(`<webjs-boundary id="${id}">`);
|
|
1965
|
-
await streamRender(s.fallback, ctx, controller);
|
|
1966
|
-
controller.enqueue(`</webjs-boundary>`);
|
|
1967
|
-
ctx.pending.push({ id, promise: Promise.resolve(s.children) });
|
|
1968
|
-
} else {
|
|
1969
|
-
await streamRender(s.fallback, ctx, controller);
|
|
1970
|
-
}
|
|
1971
|
-
return;
|
|
1972
|
-
}
|
|
1973
|
-
if (isTemplate(value)) {
|
|
1974
|
-
await streamTemplate(/** @type any */ (value), ctx, controller);
|
|
1975
|
-
return;
|
|
1976
|
-
}
|
|
1977
|
-
controller.enqueue(escapeText(String(value)));
|
|
1978
|
-
}
|
|
1979
|
-
|
|
1980
|
-
/**
|
|
1981
|
-
* Stream a TemplateResult by yielding each static string piece and
|
|
1982
|
-
* processing each value hole incrementally.
|
|
1983
|
-
*
|
|
1984
|
-
* @param {import('./html.js').TemplateResult} tr
|
|
1985
|
-
* @param {SuspenseCtx} [ctx]
|
|
1986
|
-
* @param {ReadableStreamDefaultController<string>} controller
|
|
1987
|
-
*/
|
|
1988
|
-
async function streamTemplate(tr, ctx, controller) {
|
|
1989
|
-
const { strings, values } = tr;
|
|
1990
|
-
let state = 'text';
|
|
1991
|
-
let attrName = '';
|
|
1992
|
-
let attrStart = 0;
|
|
1993
|
-
let attrQuote = '';
|
|
1994
|
-
let commentDashes = 0;
|
|
1995
|
-
let currentTag = '';
|
|
1996
|
-
let rawTail = '';
|
|
1997
|
-
// Buffer used for attribute handling where we may need to backtrack.
|
|
1998
|
-
let buf = '';
|
|
1999
|
-
let tagStart = -1;
|
|
2000
|
-
/** @type {string | null} */
|
|
2001
|
-
let pendingActionId = null;
|
|
2002
|
-
// Shapes on the CURRENT start tag that a bound form may not carry (#1155).
|
|
2003
|
-
// Collected as the tag is scanned and judged at its `>`, because the action
|
|
2004
|
-
// hole may come after them.
|
|
2005
|
-
let pendingActionCount = 0;
|
|
2006
|
-
/** @type {string[]} */
|
|
2007
|
-
let pendingPropAttrs = [];
|
|
2008
|
-
/** @type {string[]} */
|
|
2009
|
-
let pendingSubmitterProps = [];
|
|
2010
|
-
/** @type {string | null} */
|
|
2011
|
-
let pendingSubmitterTag = null; // tag of a bound submitter, until it closes
|
|
2012
|
-
// See the buffered machine: seeded from the caller so a nested template
|
|
2013
|
-
// rendered into a bound form's children knows it is inside one.
|
|
2014
|
-
let isCloseTag = false;
|
|
2015
|
-
|
|
2016
|
-
// See the buffered machine for why this runs at the `>` rather than at the
|
|
2017
|
-
// hole. `tagStart` indexes into `buf`, which is safe because `buf` is only
|
|
2018
|
-
// flushed on a `text`-state hole and a start tag contains none.
|
|
2019
|
-
const closeBoundFormTag = () => {
|
|
2020
|
-
// Reset per tag whether or not this one was bound, so a later form is never
|
|
2021
|
-
// judged on an earlier tag's shapes.
|
|
2022
|
-
const propAttrs = pendingPropAttrs;
|
|
2023
|
-
const submitterProps = pendingSubmitterProps;
|
|
2024
|
-
const duplicateAction = pendingActionCount > 1;
|
|
2025
|
-
const submitterTag = pendingSubmitterTag;
|
|
2026
|
-
pendingPropAttrs = [];
|
|
2027
|
-
pendingSubmitterProps = [];
|
|
2028
|
-
pendingActionCount = 0;
|
|
2029
|
-
pendingSubmitterTag = null;
|
|
2030
|
-
if (pendingActionId != null) {
|
|
2031
|
-
assertConvergentBoundForm({ duplicateAction, propAttrs });
|
|
2032
|
-
const bound = bindFormActionStartTag(buf.slice(tagStart), pendingActionId);
|
|
2033
|
-
buf = buf.slice(0, tagStart) + bound.tag + bound.hidden;
|
|
2034
|
-
pendingActionId = null;
|
|
2035
|
-
}
|
|
2036
|
-
if (submitterTag != null) {
|
|
2037
|
-
// #1307, the SAME injection as the buffered machine, through the same
|
|
2038
|
-
// helper, so this second state machine cannot drift from it.
|
|
2039
|
-
buf = buf.slice(0, tagStart)
|
|
2040
|
-
+ bindSubmitterStartTag(buf.slice(tagStart), submitterTag, { duplicateAction, propAttrs: submitterProps });
|
|
2041
|
-
}
|
|
2042
|
-
};
|
|
2043
|
-
// #1155: a `.method` / `.enctype` / `.encoding` prop on a form is dropped
|
|
2044
|
-
// here but applied for real in the browser, where all three are reflected IDL
|
|
2045
|
-
// attributes, so a bound form carrying one submits differently with JS than
|
|
2046
|
-
// without it. Recorded and refused at the `>`, once the tag's action hole is
|
|
2047
|
-
// known.
|
|
2048
|
-
const notePropAttr = (name, tag) => {
|
|
2049
|
-
const t = String(tag).toLowerCase();
|
|
2050
|
-
if (t === 'button' || t === 'input') {
|
|
2051
|
-
// #1207: the submitter twin. `name` / `value` / `formAction` / `formMethod`
|
|
2052
|
-
// / `formEnctype` all reflect on a submitter, so a `.prop` spelling is
|
|
2053
|
-
// dropped here and written to the attribute in the browser.
|
|
2054
|
-
if (isSubmitterReflectedProp(name)) pendingSubmitterProps.push(String(name));
|
|
2055
|
-
return;
|
|
2056
|
-
}
|
|
2057
|
-
if (t !== 'form') return;
|
|
2058
|
-
let n = String(name).toLowerCase();
|
|
2059
|
-
if (n === 'encoding') n = 'enctype';
|
|
2060
|
-
if (n === 'method' || n === 'enctype') pendingPropAttrs.push(String(name));
|
|
2061
|
-
};
|
|
2062
|
-
const noteActionHole = (name, tag) => {
|
|
2063
|
-
const t = String(tag).toLowerCase();
|
|
2064
|
-
const n = String(name).toLowerCase();
|
|
2065
|
-
if ((t === 'form' && n === 'action') ||
|
|
2066
|
-
((t === 'button' || t === 'input') && n === 'formaction')) {
|
|
2067
|
-
pendingActionCount += 1;
|
|
2068
|
-
}
|
|
2069
|
-
};
|
|
2070
|
-
|
|
2071
|
-
// Same contract as the buffered machine, `allowRawtext` included: only the
|
|
2072
|
-
// `tag-name` and `in-tag` exits ever entered rawtext here either, so a start
|
|
2073
|
-
// tag ending on a bare or unquoted attribute must keep escaping its body.
|
|
2074
|
-
const handleTagEnd = (allowRawtext) => {
|
|
2075
|
-
closeBoundFormTag();
|
|
2076
|
-
isCloseTag = false;
|
|
2077
|
-
state = allowRawtext && isRawtextTag(currentTag) ? 'rawtext' : 'text';
|
|
2078
|
-
if (state === 'rawtext') rawTail = '';
|
|
2079
|
-
};
|
|
2080
|
-
|
|
2081
|
-
for (let i = 0; i < strings.length; i++) {
|
|
2082
|
-
const s = strings[i];
|
|
2083
|
-
for (let j = 0; j < s.length; j++) {
|
|
2084
|
-
const c = s[j];
|
|
2085
|
-
switch (state) {
|
|
2086
|
-
case 'text':
|
|
2087
|
-
buf += c;
|
|
2088
|
-
if (c === '<') { state = 'tag-open'; tagStart = buf.length - 1; isCloseTag = false; }
|
|
2089
|
-
break;
|
|
2090
|
-
case 'tag-open':
|
|
2091
|
-
buf += c;
|
|
2092
|
-
if (c === '!') state = 'bang-1';
|
|
2093
|
-
else if (c === '/') { state = 'tag-name'; currentTag = ''; isCloseTag = true; }
|
|
2094
|
-
else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
|
|
2095
|
-
else state = 'text';
|
|
2096
|
-
break;
|
|
2097
|
-
case 'bang-1':
|
|
2098
|
-
buf += c;
|
|
2099
|
-
state = c === '-' ? 'bang-dash' : 'tag-name';
|
|
2100
|
-
break;
|
|
2101
|
-
case 'bang-dash':
|
|
2102
|
-
buf += c;
|
|
2103
|
-
if (c === '-') { state = 'comment'; commentDashes = 0; }
|
|
2104
|
-
else state = 'tag-name';
|
|
2105
|
-
break;
|
|
2106
|
-
case 'comment':
|
|
2107
|
-
buf += c;
|
|
2108
|
-
if (c === '-') commentDashes += 1;
|
|
2109
|
-
else if (c === '>' && commentDashes >= 2) { state = 'text'; commentDashes = 0; }
|
|
2110
|
-
else commentDashes = 0;
|
|
2111
|
-
break;
|
|
2112
|
-
case 'tag-name':
|
|
2113
|
-
buf += c;
|
|
2114
|
-
if (c === '>') {
|
|
2115
|
-
handleTagEnd(true);
|
|
2116
|
-
} else if (/\s/.test(c)) state = 'in-tag';
|
|
2117
|
-
else currentTag += c.toLowerCase();
|
|
2118
|
-
break;
|
|
2119
|
-
case 'in-tag':
|
|
2120
|
-
buf += c;
|
|
2121
|
-
if (c === '>') {
|
|
2122
|
-
handleTagEnd(true);
|
|
2123
|
-
} else if (!/\s/.test(c) && c !== '/') {
|
|
2124
|
-
state = 'attr-name';
|
|
2125
|
-
attrName = c;
|
|
2126
|
-
attrStart = buf.length - 1;
|
|
2127
|
-
}
|
|
2128
|
-
break;
|
|
2129
|
-
case 'rawtext':
|
|
2130
|
-
buf += c;
|
|
2131
|
-
rawTail = (rawTail + c.toLowerCase()).slice(-9);
|
|
2132
|
-
if (rawTail.endsWith('</script>') || rawTail.endsWith('</style>')) {
|
|
2133
|
-
state = 'text';
|
|
2134
|
-
rawTail = '';
|
|
2135
|
-
currentTag = '';
|
|
2136
|
-
}
|
|
2137
|
-
break;
|
|
2138
|
-
case 'attr-name':
|
|
2139
|
-
if (c === '=') { state = 'after-eq'; buf += c; }
|
|
2140
|
-
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
|
|
2141
|
-
else if (c === '>') { state = 'text'; attrName = ''; buf += c; handleTagEnd(false); }
|
|
2142
|
-
else { attrName += c; buf += c; }
|
|
2143
|
-
break;
|
|
2144
|
-
case 'after-eq':
|
|
2145
|
-
if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; buf += c; }
|
|
2146
|
-
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
|
|
2147
|
-
else if (c === '>') { state = 'text'; attrName = ''; buf += c; handleTagEnd(false); }
|
|
2148
|
-
else { state = 'attr-unquoted'; buf += c; }
|
|
2149
|
-
break;
|
|
2150
|
-
case 'attr-unquoted':
|
|
2151
|
-
if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
|
|
2152
|
-
else if (c === '>') { state = 'text'; attrName = ''; buf += c; handleTagEnd(false); }
|
|
2153
|
-
else buf += c;
|
|
2154
|
-
break;
|
|
2155
|
-
case 'attr-quoted':
|
|
2156
|
-
buf += c;
|
|
2157
|
-
if (c === attrQuote) { state = 'in-tag'; attrName = ''; }
|
|
2158
|
-
break;
|
|
2159
|
-
}
|
|
2160
|
-
}
|
|
2161
|
-
|
|
2162
|
-
// Flush the buffer before processing the value hole: but only when
|
|
2163
|
-
// we're in text state (in attribute states we may need the buffer for
|
|
2164
|
-
// backtracking).
|
|
2165
|
-
if (i < values.length) {
|
|
2166
|
-
let val = values[i];
|
|
2167
|
-
if (val && typeof /** @type any */ (val).then === 'function') {
|
|
2168
|
-
val = await val;
|
|
2169
|
-
}
|
|
2170
|
-
if (state === 'comment') {
|
|
2171
|
-
buf += String(val ?? '');
|
|
2172
|
-
commentDashes = 0;
|
|
2173
|
-
} else if (state === 'rawtext') {
|
|
2174
|
-
buf += String(val ?? '');
|
|
2175
|
-
rawTail = '';
|
|
2176
|
-
} else if (state === 'text') {
|
|
2177
|
-
// Flush the buffered static content before streaming the value.
|
|
2178
|
-
if (buf) { controller.enqueue(buf); buf = ''; }
|
|
2179
|
-
await streamRender(val, ctx, controller);
|
|
2180
|
-
} else if (state === 'after-eq') {
|
|
2181
|
-
const prefix = attrName[0];
|
|
2182
|
-
const name = attrName.slice(1);
|
|
2183
|
-
const kind = BINDING_PREFIXES[prefix];
|
|
2184
|
-
if (kind === 'event' || kind === 'prop') {
|
|
2185
|
-
// Guard `prop` ONLY, matching the buffered machine. An `@action`
|
|
2186
|
-
// event binding is dropped here and never stringified, and a
|
|
2187
|
-
// function is the LEGITIMATE value for one (`<my-el @action=${fn}>`
|
|
2188
|
-
// listens for an `action` event), so refusing it would be a false
|
|
2189
|
-
// positive. `.action` differs where the property REFLECTS: on a form
|
|
2190
|
-
// (and `.formAction` on a button or input) the client assignment
|
|
2191
|
-
// writes the source into the DOM, so refusing at SSR keeps a page
|
|
2192
|
-
// from rendering clean on the server and throwing on hydration.
|
|
2193
|
-
// Elsewhere the property reflects nothing and a function stays
|
|
2194
|
-
// legal, which is why the check below is gated on a hyphen-free
|
|
2195
|
-
// tag. A custom element is excluded for a different reason than
|
|
2196
|
-
// "it does not reflect": a prop declared `reflect: true` DOES
|
|
2197
|
-
// reflect there, and used to write the source from its own setter.
|
|
2198
|
-
// #1169 guards that at the setter (a function removes the
|
|
2199
|
-
// attribute; an array carrying one does too, except under an
|
|
2200
|
-
// Object/Array type, where JSON drops it losslessly), so it
|
|
2201
|
-
// needs no commit-site check here.
|
|
2202
|
-
//
|
|
2203
|
-
// Unlike the buffered machine this drops EVERY prop, including
|
|
2204
|
-
// `<webjs-suspense .fallback>`: there is no injectDSD pre-pass on
|
|
2205
|
-
// this path (it is reached only through `renderToStream(v, { ssr:
|
|
2206
|
-
// false })`), so there is no consumer for a `data-webjs-fallback`
|
|
2207
|
-
// and emitting one would put an attribute in the markup that nothing
|
|
2208
|
-
// reads.
|
|
2209
|
-
if (kind === 'prop' && !currentTag.includes('-')) {
|
|
2210
|
-
assertNotFunctionReflectedActionProp(val, name, currentTag);
|
|
2211
|
-
notePropAttr(name, currentTag);
|
|
2212
|
-
}
|
|
2213
|
-
buf = buf.slice(0, attrStart);
|
|
2214
|
-
state = 'in-tag';
|
|
2215
|
-
attrName = '';
|
|
2216
|
-
} else if (kind === 'bool') {
|
|
2217
|
-
// A boolean binding stringifies nothing, so this never leaked, but
|
|
2218
|
-
// `?action=${fn}` is meaningless in every case (a truthy function
|
|
2219
|
-
// emits a bare `action=""`), and refusing it keeps the rule the docs
|
|
2220
|
-
// state true for every sigil rather than true only when quoted.
|
|
2221
|
-
assertNotFunctionActionAttr(val, name, currentTag);
|
|
2222
|
-
buf = buf.slice(0, attrStart);
|
|
2223
|
-
if (val) buf += `${name}=""`;
|
|
2224
|
-
state = 'in-tag';
|
|
2225
|
-
attrName = '';
|
|
2226
|
-
} else if (isBoundFormAction(val, attrName, currentTag)) {
|
|
2227
|
-
noteActionHole(attrName, currentTag);
|
|
2228
|
-
// The SAME bindings as the buffered renderer (#1155, #1207), in the
|
|
2229
|
-
// second machine, so `renderToStream(v, { ssr: false })` emits an
|
|
2230
|
-
// identical form rather than refusing one the page renderer accepts.
|
|
2231
|
-
if (currentTag === 'form') {
|
|
2232
|
-
pendingActionId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
|
|
2233
|
-
buf = buf.slice(0, attrStart).replace(/\s+$/, '');
|
|
2234
|
-
} else {
|
|
2235
|
-
assertSingleSubmitterAction(pendingSubmitterTag != null, currentTag);
|
|
2236
|
-
const attrs = parseStartTagAttrs(buf.slice(tagStart));
|
|
2237
|
-
if (attrs.has('name')) assertSubmitterHasNoName(attrs.get('name') || '', currentTag, false);
|
|
2238
|
-
if (attrs.has('value')) assertSubmitterHasNoValue(currentTag);
|
|
2239
|
-
if (attrs.has('form')) assertSubmitterHasNoFormAttribute(currentTag);
|
|
2240
|
-
const subId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
|
|
2241
|
-
pendingSubmitterTag = currentTag;
|
|
2242
|
-
buf = buf.slice(0, attrStart) + `name="${FORM_ACTION_FIELD}" value="${escapeAttr(subId)}"`;
|
|
2243
|
-
}
|
|
2244
|
-
state = 'in-tag';
|
|
2245
|
-
attrName = '';
|
|
2246
|
-
} else {
|
|
2247
|
-
noteActionHole(attrName, currentTag);
|
|
2248
|
-
// The SAME guard as the buffered renderer above. This is a second,
|
|
2249
|
-
// independent state machine, so it inherits nothing from that one;
|
|
2250
|
-
// a change to the rule has to land in both. Reached only via
|
|
2251
|
-
// `renderToStream(v, { ssr: false })`, which no page render uses, so
|
|
2252
|
-
// this covers the public API surface rather than a page leak.
|
|
2253
|
-
assertNotFunctionActionAttr(val, attrName, currentTag);
|
|
2254
|
-
buf += `"${escapeAttr(String(val ?? ''))}"`;
|
|
2255
|
-
state = 'in-tag';
|
|
2256
|
-
attrName = '';
|
|
2257
|
-
}
|
|
2258
|
-
} else if (state === 'attr-quoted' || state === 'attr-unquoted') {
|
|
2259
|
-
assertNotFunctionActionAttr(val, attrName, currentTag);
|
|
2260
|
-
buf += escapeAttr(String(val ?? ''));
|
|
2261
|
-
}
|
|
2262
|
-
}
|
|
2263
|
-
}
|
|
2264
|
-
|
|
2265
|
-
// Flush any remaining buffer content.
|
|
2266
|
-
if (buf) controller.enqueue(buf);
|
|
2267
|
-
}
|
|
2268
|
-
|
|
2269
|
-
/**
|
|
2270
|
-
* After the main HTML has been streamed, resolve pending Suspense promises
|
|
2271
|
-
* and stream their replacement content as out-of-order `<template>` tags
|
|
2272
|
-
* with tiny inline scripts that swap the fallback for the resolved HTML.
|
|
2273
|
-
*
|
|
2274
|
-
* @param {SuspenseCtx} ctx
|
|
2275
|
-
* @param {ReadableStreamDefaultController<string>} controller
|
|
2276
|
-
* @param {boolean} [dev] server dev flag for prod-silence of a rejected
|
|
2277
|
-
* streamed boundary (#483); undefined falls back to NODE_ENV
|
|
2278
|
-
*/
|
|
2279
|
-
async function streamSuspenseBoundaries(ctx, controller, dev) {
|
|
2280
|
-
// Resolve the per-request nonce once per call. The provider in
|
|
2281
|
-
// @webjsdev/server sources it from AsyncLocalStorage; outside a
|
|
2282
|
-
// request scope (or in the browser) the helper returns '' and we
|
|
2283
|
-
// emit the script unnonced, which is fine on documents not under
|
|
2284
|
-
// strict CSP and matches the no-nonce case for the rest of the
|
|
2285
|
-
// SSR pipeline.
|
|
2286
|
-
const nonce = cspNonce();
|
|
2287
|
-
const nonceAttr = nonce ? ` nonce="${escapeAttr(nonce)}"` : '';
|
|
2288
|
-
while (ctx.pending.length) {
|
|
2289
|
-
const batch = ctx.pending.splice(0);
|
|
2290
|
-
await Promise.all(
|
|
2291
|
-
batch.map(async ({ id, promise }) => {
|
|
2292
|
-
try {
|
|
2293
|
-
const resolved = await promise;
|
|
2294
|
-
const html = await render(resolved, ctx);
|
|
2295
|
-
const full = await injectDSD(html, ctx, [], dev);
|
|
2296
|
-
controller.enqueue(
|
|
2297
|
-
`<template data-webjs-resolve="${id}">${full}</template>` +
|
|
2298
|
-
`<script${nonceAttr}>` +
|
|
2299
|
-
`(function(){` +
|
|
2300
|
-
`var t=document.currentScript.previousElementSibling;` +
|
|
2301
|
-
`var b=document.getElementById("${id}");` +
|
|
2302
|
-
`if(b&&t){b.replaceWith(t.content.cloneNode(true));t.remove()}` +
|
|
2303
|
-
`document.currentScript.remove()` +
|
|
2304
|
-
`})()` +
|
|
2305
|
-
`</script>`
|
|
2306
|
-
);
|
|
2307
|
-
} catch (err) {
|
|
2308
|
-
console.error(`[webjs] Suspense boundary "${id}" rejected:`, err);
|
|
2309
|
-
// Render a boundary-scoped error state rather than leaving the
|
|
2310
|
-
// fallback stuck forever (#471). Dev surfaces the message; prod
|
|
2311
|
-
// renders a silent empty element (no leak). A failure HERE (the
|
|
2312
|
-
// error render itself throwing) leaves the fallback in place.
|
|
2313
|
-
try {
|
|
2314
|
-
const e = err instanceof Error ? err : new Error(String(err));
|
|
2315
|
-
const errHtml = await injectDSD(await render(defaultSSRErrorTemplate('webjs-suspense', e, dev), ctx), ctx, [], dev);
|
|
2316
|
-
controller.enqueue(
|
|
2317
|
-
`<template data-webjs-resolve="${id}">${errHtml}</template>` +
|
|
2318
|
-
`<script${nonceAttr}>` +
|
|
2319
|
-
`(function(){` +
|
|
2320
|
-
`var t=document.currentScript.previousElementSibling;` +
|
|
2321
|
-
`var b=document.getElementById("${id}");` +
|
|
2322
|
-
`if(b&&t){b.replaceWith(t.content.cloneNode(true));t.remove()}` +
|
|
2323
|
-
`document.currentScript.remove()` +
|
|
2324
|
-
`})()` +
|
|
2325
|
-
`</script>`
|
|
2326
|
-
);
|
|
2327
|
-
} catch (errorRenderThrew) {
|
|
2328
|
-
console.error(`[webjs] Suspense boundary "${id}" error render also threw:`, errorRenderThrew);
|
|
2329
|
-
}
|
|
2330
|
-
}
|
|
2331
|
-
})
|
|
2332
|
-
);
|
|
2333
|
-
}
|
|
2334
|
-
}
|
|
1
|
+
// Public surface, byte-identical to the pre-split module. `injectDSD` is
|
|
2
|
+
// deliberately NOT re-exported: it is internal to `render-server/`, nothing
|
|
3
|
+
// outside imports it, and `render-server.d.ts` does not declare it. This file
|
|
4
|
+
// backs the published `@webjsdev/core/server` subpath, so re-exporting it would
|
|
5
|
+
// widen the package's public API for a helper no consumer asked for.
|
|
6
|
+
export { renderToString, renderToStream } from './render-server/stream.js';
|