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