jq79 0.7.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dev/vite.ts +192 -24
- package/dist/dom.d.ts +22 -4
- package/dist/html.d.ts +9 -0
- package/dist/jq79-sw.js +32 -0
- package/dist/jq79.cjs +18 -14
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +15 -12
- package/dist/jq79.global.js +18 -14
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +18 -14
- package/dist/jq79.js.map +1 -1
- package/dist/precompile.cjs +24 -0
- package/dist/precompile.cjs.map +1 -0
- package/dist/precompile.d.ts +3 -0
- package/dist/precompile.js +24 -0
- package/dist/precompile.js.map +1 -0
- package/dist/reactive.d.ts +6 -0
- package/dist/source.d.ts +65 -0
- package/dist/vite.cjs +102 -11
- package/dist/vite.cjs.map +1 -1
- package/dist/vite.d.ts +3 -0
- package/dist/vite.js +103 -12
- package/dist/vite.js.map +1 -1
- package/package.json +12 -2
- package/src/dom.ts +28 -4
- package/src/html.ts +289 -0
- package/src/jq79.ts +551 -366
- package/src/precompile.ts +237 -0
- package/src/reactive.ts +149 -9
- package/src/source.ts +388 -0
- package/src/sw.ts +87 -0
package/src/jq79.ts
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
|
|
2
2
|
import { $, $$, $create, sanitizeHTML, allowedHosts } from "./dom"
|
|
3
|
-
import type { AllowUrl } from "./dom"
|
|
4
|
-
import { $reactive, $toRaw, untracked, createEffectScope, ALSO_WAKEN_BY } from "./reactive"
|
|
3
|
+
import type { AllowUrl, QueryAll, QueryOne } from "./dom"
|
|
4
|
+
import { $reactive, $toRaw, untracked, createEffectScope, ALSO_WAKEN_BY, OWNER } from "./reactive"
|
|
5
5
|
import type { ReactiveDeepData, EffectScope } from "./reactive"
|
|
6
|
-
import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseFactoryProps,
|
|
6
|
+
import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseFactoryProps, type PropDecl } from "./transform"
|
|
7
|
+
import {
|
|
8
|
+
COMPONENT_NAME_RE, COMPONENT_TAG_ATTR, CONTROL_ATTRS, EACH_PATTERN, EXPR_PARAMS, PRECOMPILED_PARAM, PRECOMPILED_QUEUE,
|
|
9
|
+
componentTagName, functionText,
|
|
10
|
+
assignment, declaredPropNames, defer, factoryBody, factoryParams, functionKey, isControlAttr, isSlotTag,
|
|
11
|
+
kebabToCamel, prepareSource, readSetupSignature, scopedBody, setupBody, setupParams, slotName, splitText, withBody,
|
|
12
|
+
type TagBlock, type TemplateNode, type TextPart,
|
|
13
|
+
} from "./source"
|
|
7
14
|
|
|
8
15
|
export { $, $$, $create } from "./dom"
|
|
16
|
+
export type { QueryOne, QueryAll } from "./dom"
|
|
9
17
|
export { $reactive, $toRaw } from "./reactive"
|
|
10
18
|
|
|
11
19
|
// the package version, substituted at build time (tsup/vitest `define`, read
|
|
@@ -15,34 +23,6 @@ export { $reactive, $toRaw } from "./reactive"
|
|
|
15
23
|
declare const __JQ79_VERSION__: string
|
|
16
24
|
const VERSION = typeof __JQ79_VERSION__ === "string" ? __JQ79_VERSION__ : "0.0.0-dev"
|
|
17
25
|
|
|
18
|
-
type TemplateNode = {
|
|
19
|
-
tag: string
|
|
20
|
-
attrs: Record<string, string>
|
|
21
|
-
children: (TemplateNode | string)[]
|
|
22
|
-
// the tag as the author capitalized it, present only when they wrote it
|
|
23
|
-
// uppercase-initial - i.e. when they meant a component. `tag` cannot answer
|
|
24
|
-
// this: the HTML parser lowercases it, so the claim is captured before the
|
|
25
|
-
// parse (see stampComponentTag) and lifted off attrs here, where it stops
|
|
26
|
-
// looking like an attribute to every loop downstream
|
|
27
|
-
component?: string
|
|
28
|
-
// the element's namespace, present only when it is NOT HTML - an <svg>
|
|
29
|
-
// subtree, or MathML. Read straight off the parsed tree, because the HTML
|
|
30
|
-
// parser has already run the foreign-content algorithm over it and knows
|
|
31
|
-
// things a tag name cannot say: whether this <title> is SVG's or HTML's, and
|
|
32
|
-
// where a <foreignObject> hands the namespace back. Absent is the common
|
|
33
|
-
// case and means HTML, so an ordinary node is exactly the shape it was
|
|
34
|
-
ns?: string
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
type TagBlock = {
|
|
38
|
-
attrs: Record<string, string>
|
|
39
|
-
content: string
|
|
40
|
-
// <style scoped> only: `content` rewritten to require the component's scope
|
|
41
|
-
// attribute. Kept beside the original rather than replacing it, because a
|
|
42
|
-
// shadow root doesn't want it - see headStyle()
|
|
43
|
-
scoped?: string
|
|
44
|
-
}
|
|
45
|
-
|
|
46
26
|
const elementAttrs = (el: Element): Record<string, string> =>
|
|
47
27
|
Object.fromEntries(Array.from(el.attributes).map(attr => [attr.name, attr.value]))
|
|
48
28
|
|
|
@@ -111,6 +91,164 @@ type CompiledExpr = { fn: Function | null; scoped: boolean }
|
|
|
111
91
|
|
|
112
92
|
const compiled = new Map<string, CompiledExpr>()
|
|
113
93
|
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// safe eval
|
|
96
|
+
//
|
|
97
|
+
// Every function the runtime builds out of a component's text - an expression,
|
|
98
|
+
// a setup script, a factory script - is built by makeFunction. By default that
|
|
99
|
+
// is `new Function`, exactly as it always was. Two things stand in front of it:
|
|
100
|
+
//
|
|
101
|
+
// - precompiled functions, which scripts the browser loaded *as code* push onto
|
|
102
|
+
// a global queue, keyed by the very (params, body) the runtime would have
|
|
103
|
+
// handed `new Function` - so a hit is the same function by construction. A
|
|
104
|
+
// global rather than a call, so such a script can load before the library or
|
|
105
|
+
// after it, and serve either build of it (the module, or the CDN global)
|
|
106
|
+
// - safe mode (Component79.safeEval()), in which the runtime never calls
|
|
107
|
+
// `new Function`. That is what a CSP without 'unsafe-eval' demands, and
|
|
108
|
+
// where one throws EvalError that compileWith would swallow as a syntax
|
|
109
|
+
// error, safe mode says what is missing instead of rendering empty in silence.
|
|
110
|
+
// With `{ nonce: true }` a miss is built anyway - as a <script> carrying the
|
|
111
|
+
// page's nonce, which the CSP admits where it refuses eval (buildWithNonce)
|
|
112
|
+
//
|
|
113
|
+
// See RECORD/2026-09-23.no-unsafe-eval.md
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
// `fn: null` is a body that did not compile, cached as the syntax error it is
|
|
117
|
+
type PrecompiledEntry = [params: string[], body: string, fn: Function | null]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
const precompiled = new Map<string, Function | null>()
|
|
121
|
+
let safeEvalOn = false
|
|
122
|
+
// set by safeEval({ nonce: true }), and then never unset: safe mode is one-way
|
|
123
|
+
let safeEvalNonce: string | undefined
|
|
124
|
+
|
|
125
|
+
// what safe mode could not provide. `reason` completes "<the code> ..." and
|
|
126
|
+
// `hint` says why, so an expression and a script report it the same way
|
|
127
|
+
class SafeEvalMiss extends Error {
|
|
128
|
+
constructor(readonly reason: string, readonly hint: string) {
|
|
129
|
+
super(`${reason}. ${hint}`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const notPrecompiled = () => new SafeEvalMiss(
|
|
134
|
+
"was not precompiled",
|
|
135
|
+
"Safe mode never builds code at runtime: the component's precompiled functions did not reach the page."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
// drains the queue first: a precompiled script may have loaded since the last
|
|
139
|
+
// lookup. With nothing registered - every page that never precompiled anything
|
|
140
|
+
// - the answer is known without building a key
|
|
141
|
+
const lookupPrecompiled = (params: string[], body: string): Function | null | undefined => {
|
|
142
|
+
const queue = (globalThis as any)[PRECOMPILED_QUEUE]
|
|
143
|
+
if (Array.isArray(queue) && queue.length > 0) {
|
|
144
|
+
for (const [entryParams, entryBody, fn] of queue.splice(0) as PrecompiledEntry[]) {
|
|
145
|
+
precompiled.set(functionKey(entryParams, entryBody), fn)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return precompiled.size === 0 ? undefined : precompiled.get(functionKey(params, body))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// `suffix` goes to `new Function` only and is not part of the key: it is the
|
|
152
|
+
// //# sourceURL that names a script for devtools, which a precompiled script
|
|
153
|
+
// has no use for (it has a URL of its own) and a generator couldn't spell the
|
|
154
|
+
// way the runtime does - it depends on how the component was loaded
|
|
155
|
+
const makeFunction = (params: string[], body: string, suffix = ""): Function => {
|
|
156
|
+
const hit = lookupPrecompiled(params, body)
|
|
157
|
+
if (hit === null) throw new SyntaxError("jq79: this code did not compile when it was precompiled")
|
|
158
|
+
if (hit !== undefined) return hit
|
|
159
|
+
if (safeEvalNonce !== undefined) return buildWithNonce(params, body, suffix, safeEvalNonce)
|
|
160
|
+
if (safeEvalOn) throw notPrecompiled()
|
|
161
|
+
return new Function(...params, body + suffix)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// the page's nonce, read off a script that carries one. The `.nonce` property
|
|
165
|
+
// and not the attribute: once the element is in a document under a CSP header,
|
|
166
|
+
// the browser blanks the attribute (so a stylesheet's attribute selector can't
|
|
167
|
+
// leak it) and keeps the value on the property - checked in Chromium, where
|
|
168
|
+
// getAttribute("nonce") read "" and .nonce the real value. The attribute is
|
|
169
|
+
// the fallback for an environment with no such property (jsdom).
|
|
170
|
+
//
|
|
171
|
+
// Searched for, because the library may have no script of its own to read: a
|
|
172
|
+
// module has no document.currentScript. A CSP that works by nonce always has
|
|
173
|
+
// one - the script that loaded the page
|
|
174
|
+
const pageNonce = (): string | undefined => {
|
|
175
|
+
if (typeof document === "undefined") return undefined
|
|
176
|
+
for (const script of Array.from(document.querySelectorAll<HTMLScriptElement>("script[nonce]"))) {
|
|
177
|
+
const nonce = script.nonce || script.getAttribute("nonce")
|
|
178
|
+
if (nonce) return nonce
|
|
179
|
+
}
|
|
180
|
+
return undefined
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// builds what `new Function` would, as a classic <script> the CSP admits
|
|
184
|
+
// because it carries the page's nonce. Classic scripts are sloppy, so `with`
|
|
185
|
+
// compiles exactly as it does under `new Function`; the text is laid out as
|
|
186
|
+
// `new Function` lays it out ("function anonymous(params\n) {\nbody\n}"), so
|
|
187
|
+
// the function is named the same and devtools reports the same line numbers,
|
|
188
|
+
// and the //# sourceURL suffix names the script as it names the function today.
|
|
189
|
+
//
|
|
190
|
+
// An inline script runs synchronously when it is inserted, so this is a
|
|
191
|
+
// drop-in for the synchronous `new Function`. The function comes back on the
|
|
192
|
+
// element itself (document.currentScript) rather than through a global: nothing
|
|
193
|
+
// for the page to collide with, and nothing to clean up.
|
|
194
|
+
//
|
|
195
|
+
// Two ways it can fail, told apart by what the insertion left behind:
|
|
196
|
+
// - a syntax error is reported to window's `error` event rather than thrown to
|
|
197
|
+
// the inserter. The listener takes it and cancels the report, and it is
|
|
198
|
+
// rethrown here - where `new Function` threw it, and where compileWith
|
|
199
|
+
// caches it as the syntax error it always has been;
|
|
200
|
+
// - a nonce the CSP refuses leaves nothing at all: no function, no error
|
|
201
|
+
// event, only a violation the browser logs. That one says so.
|
|
202
|
+
//
|
|
203
|
+
// A body that closes the function's brace early (`a) } alert(1); { (`) is one
|
|
204
|
+
// place this differs: `new Function` refuses it, and a script runs what comes
|
|
205
|
+
// after the brace. The text is the component's own, which can hold a <script>
|
|
206
|
+
// anyway, so this admits nothing a component couldn't already do.
|
|
207
|
+
//
|
|
208
|
+
// Built functions go into the precompiled map: they hold no state, and a
|
|
209
|
+
// setup script - built once per *instance* - would otherwise insert a script
|
|
210
|
+
// per instance
|
|
211
|
+
const NONCE_BUILT = "__jq79built"
|
|
212
|
+
|
|
213
|
+
const buildWithNonce = (params: string[], body: string, suffix: string, nonce: string): Function => {
|
|
214
|
+
const script = document.createElement("script")
|
|
215
|
+
script.setAttribute("nonce", nonce)
|
|
216
|
+
script.textContent = `document.currentScript.${NONCE_BUILT} = ${functionText(params, body)}${suffix}`
|
|
217
|
+
let syntaxError: unknown
|
|
218
|
+
const onError = (event: ErrorEvent) => {
|
|
219
|
+
syntaxError = event.error ?? new SyntaxError(event.message)
|
|
220
|
+
event.preventDefault()
|
|
221
|
+
}
|
|
222
|
+
window.addEventListener("error", onError)
|
|
223
|
+
try {
|
|
224
|
+
(document.head ?? document.documentElement).append(script)
|
|
225
|
+
} finally {
|
|
226
|
+
window.removeEventListener("error", onError)
|
|
227
|
+
script.remove()
|
|
228
|
+
}
|
|
229
|
+
const fn = (script as any)[NONCE_BUILT]
|
|
230
|
+
if (typeof fn === "function") {
|
|
231
|
+
precompiled.set(functionKey(params, body), fn)
|
|
232
|
+
return fn
|
|
233
|
+
}
|
|
234
|
+
if (syntaxError !== undefined) throw syntaxError
|
|
235
|
+
throw new SafeEvalMiss(
|
|
236
|
+
"could not be built",
|
|
237
|
+
// the value stays out of the message: a nonce has no business in a log
|
|
238
|
+
"The page's CSP refused a <script> carrying the nonce jq79 read from the page."
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// once per expression, like reportFailedExpr: a :each over 1000 rows misses
|
|
243
|
+
// 1000 times per render
|
|
244
|
+
const reportedSafeEvalMisses = new Set<string>()
|
|
245
|
+
|
|
246
|
+
const reportSafeEvalMiss = (expr: string, miss: SafeEvalMiss) => {
|
|
247
|
+
if (reportedSafeEvalMisses.has(expr)) return
|
|
248
|
+
reportedSafeEvalMisses.add(expr)
|
|
249
|
+
console.error(`jq79: safeEval() is on, and "${expr}" ${miss.reason}, so it rendered as nothing. ${miss.hint}`)
|
|
250
|
+
}
|
|
251
|
+
|
|
114
252
|
// Resolves a name the `const` prologue could not: its fast read came back
|
|
115
253
|
// undefined, which means one of three different things. `with` told them apart
|
|
116
254
|
// by consulting [[HasProperty]] on every read of every name; this consults it
|
|
@@ -128,14 +266,13 @@ const resolveName = (scope: Record<string, any>, name: string): any => {
|
|
|
128
266
|
throw new ReferenceError(`${name} is not defined`)
|
|
129
267
|
}
|
|
130
268
|
|
|
131
|
-
// the newline before `)` ends a trailing line comment in the expression
|
|
132
|
-
// ({{ msg // greeting }}); ASI doesn't apply inside parens, so everything else
|
|
133
|
-
// is untouched. Without it the comment eats the rest of this single-line body
|
|
134
|
-
// and the expression never compiles
|
|
135
269
|
const compileWith = (expr: string, params: string[]): Function | null => {
|
|
136
270
|
try {
|
|
137
|
-
return
|
|
138
|
-
} catch {
|
|
271
|
+
return makeFunction([...EXPR_PARAMS, ...params], withBody(expr))
|
|
272
|
+
} catch (error) {
|
|
273
|
+
// nothing precompiled, in a mode that won't compile: not a syntax error,
|
|
274
|
+
// though it is cached like one - it will not start compiling later either
|
|
275
|
+
if (error instanceof SafeEvalMiss) reportSafeEvalMiss(expr, error)
|
|
139
276
|
return null // a syntax error: it will never compile, so don't try again
|
|
140
277
|
}
|
|
141
278
|
}
|
|
@@ -162,15 +299,13 @@ const compileWith = (expr: string, params: string[]): Function | null => {
|
|
|
162
299
|
// differential in tests/expressions.test.ts is what stands behind it
|
|
163
300
|
const compileScoped = (expr: string, params: string[]): Function | null => {
|
|
164
301
|
if (!debugFlags.scopedNames) return null
|
|
165
|
-
const
|
|
166
|
-
if (
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
const prologue = names.length === 0 ? "" : `let $t; ${names.map(name =>
|
|
171
|
-
`const ${name} = ($t = $scope.${name}) !== undefined ? $t : $r($scope, ${JSON.stringify(name)});`).join(" ")}`
|
|
302
|
+
const body = scopedBody(expr, params)
|
|
303
|
+
if (body === null) return null
|
|
304
|
+
// a safe-mode miss lands here too, and stays quiet: the caller falls back to
|
|
305
|
+
// the `with` form, which may well be the one that was precompiled - and
|
|
306
|
+
// reports if it wasn't
|
|
172
307
|
try {
|
|
173
|
-
return
|
|
308
|
+
return makeFunction([...EXPR_PARAMS, ...params], body)
|
|
174
309
|
} catch {
|
|
175
310
|
return null
|
|
176
311
|
}
|
|
@@ -374,39 +509,6 @@ const evalHandler = (expr: string, scope: Record<string, any>, extras: Record<st
|
|
|
374
509
|
}
|
|
375
510
|
}
|
|
376
511
|
|
|
377
|
-
// [\s\S] rather than `.` so an expression can span lines, like the ones in
|
|
378
|
-
// directive attributes (which reach evalExpr wrapped in parens either way)
|
|
379
|
-
const INTERPOLATION_RE = /{{\s*([\s\S]+?)\s*}}/g
|
|
380
|
-
|
|
381
|
-
// A text template split once into its literal and expression parts. The split
|
|
382
|
-
// used to happen on every run of every instance - `String.replace` over the
|
|
383
|
-
// whole text, a fresh match object and a callback per expression - and a
|
|
384
|
-
// :each over 1,000 rows runs it 1,000 times per text node to reach the same
|
|
385
|
-
// answer about the same string. Keyed by the template text, like compileExpr's
|
|
386
|
-
// cache and bounded the same way: by how many distinct texts the source holds
|
|
387
|
-
//
|
|
388
|
-
// An expression part is boxed so a literal `"x"` and an expression `x` stay
|
|
389
|
-
// distinguishable without a second array
|
|
390
|
-
type TextPart = string | { expr: string }
|
|
391
|
-
|
|
392
|
-
const textParts = new Map<string, TextPart[]>()
|
|
393
|
-
|
|
394
|
-
const splitText = (template: string): TextPart[] => {
|
|
395
|
-
const cached = textParts.get(template)
|
|
396
|
-
if (cached) return cached
|
|
397
|
-
const parts: TextPart[] = []
|
|
398
|
-
let at = 0
|
|
399
|
-
INTERPOLATION_RE.lastIndex = 0
|
|
400
|
-
for (let match = INTERPOLATION_RE.exec(template); match; match = INTERPOLATION_RE.exec(template)) {
|
|
401
|
-
if (match.index > at) parts.push(template.slice(at, match.index))
|
|
402
|
-
parts.push({ expr: match[1] })
|
|
403
|
-
at = match.index + match[0].length
|
|
404
|
-
}
|
|
405
|
-
if (at < template.length) parts.push(template.slice(at))
|
|
406
|
-
textParts.set(template, parts)
|
|
407
|
-
return parts
|
|
408
|
-
}
|
|
409
|
-
|
|
410
512
|
// what an interpolated text node renders to, from the parts. `?? ""` on each
|
|
411
513
|
// expression, and String() over the join, is what template.replace did: a
|
|
412
514
|
// nullish value contributes nothing and everything else is coerced
|
|
@@ -421,20 +523,6 @@ const renderText = (parts: TextPart[], scope: Record<string, any>): string => {
|
|
|
421
523
|
}
|
|
422
524
|
|
|
423
525
|
|
|
424
|
-
const CONTROL_ATTRS = new Set([":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed", ":props"])
|
|
425
|
-
|
|
426
|
-
// a control attribute is one the static-attr loop and nested-component prop
|
|
427
|
-
// collection must skip. The set holds the fixed names; `:class.<name>` (the
|
|
428
|
-
// single-flag shorthand) and `:props.<n>` (one spread among several) are
|
|
429
|
-
// open-ended, so they're matched by prefix - they can't be enumerated into the set
|
|
430
|
-
const isControlAttr = (attr: string): boolean =>
|
|
431
|
-
CONTROL_ATTRS.has(attr) || attr.startsWith(":class.") || attr.startsWith(":props.") ||
|
|
432
|
-
attr === ":slot" || attr.startsWith(":slot.")
|
|
433
|
-
// `item in items`, `item, i in items`, `(value, key) in props` - the second
|
|
434
|
-
// binding is the array index or the object key, parens optional (Vue-style).
|
|
435
|
-
// The list expression can span lines, so it matches [\s\S] rather than `.`
|
|
436
|
-
const EACH_PATTERN = /^\s*\(?\s*(\w+)\s*(?:,\s*(\w+))?\s*\)?\s+in\s+([\s\S]+)$/
|
|
437
|
-
|
|
438
526
|
type ConditionalBranch = { expr?: string; node: TemplateNode }
|
|
439
527
|
|
|
440
528
|
|
|
@@ -491,14 +579,6 @@ const wireTagEvent = (instance: Component79, attr: string, expr: string, scope:
|
|
|
491
579
|
instance.on(name, listener)
|
|
492
580
|
}
|
|
493
581
|
|
|
494
|
-
const kebabToCamel = (name: string) => name.replace(/-(\w)/g, (_, c: string) => c.toUpperCase())
|
|
495
|
-
|
|
496
|
-
// the inverse, used only by the pre-parse name rewrite (see expandNameCase):
|
|
497
|
-
// uppercase ASCII letters only, never digits - `:props.0` is a generated
|
|
498
|
-
// attribute name and splitting on digits would mangle it. Round-trips through
|
|
499
|
-
// kebabToCamel, acronyms included: userID -> user-i-d -> userID
|
|
500
|
-
const camelToKebab = (name: string) => name.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)
|
|
501
|
-
|
|
502
582
|
// the stable boundaries of a rendered chunk. An element is its own handle, but
|
|
503
583
|
// a fragment (a nested component: two anchors with the instance's DOM between
|
|
504
584
|
// them) empties itself into the parent on insertion - after that its identity
|
|
@@ -626,9 +706,9 @@ const plainScopes = new WeakSet<object>()
|
|
|
626
706
|
// opened and closed by hand rather than by a wrapper taking a callback: a
|
|
627
707
|
// component that renders itself through :each stacks one renderEach per level,
|
|
628
708
|
// and a callback would add a frame to each of them. The cyclic-component test
|
|
629
|
-
// cuts off at
|
|
630
|
-
// difference between cutting off and a RangeError - the same reason
|
|
631
|
-
// keeps its own shape (see reactive.ts)
|
|
709
|
+
// cuts off at MAX_NESTING_DEPTH levels, and on a CI runner that frame per level
|
|
710
|
+
// was the difference between cutting off and a RangeError - the same reason
|
|
711
|
+
// $effect keeps its own shape (see reactive.ts)
|
|
632
712
|
type RenderPass = { memo: Map<string, string | null> | null; base: object | null }
|
|
633
713
|
|
|
634
714
|
const openRenderPass = (base: Record<string, any>): RenderPass => {
|
|
@@ -714,8 +794,15 @@ const unresolvedComponent = (tag: string, scope: Record<string, any>): Error =>
|
|
|
714
794
|
|
|
715
795
|
// how deep a component may nest inside itself before the runtime calls it a
|
|
716
796
|
// cycle. Deeper than any real tree, shallower than the JS stack: a truncated
|
|
717
|
-
// render with an error on the console beats a stack overflow with none
|
|
718
|
-
|
|
797
|
+
// render with an error on the console beats a stack overflow with none.
|
|
798
|
+
//
|
|
799
|
+
// It was 200, which is roughly where the stack actually gives out - measured at
|
|
800
|
+
// ~196 levels for the cyclic-data test on macOS arm64 - so the guard lost to the
|
|
801
|
+
// stack whenever a frame on the render path grew, and it did, three times. The
|
|
802
|
+
// ceiling is a property of the host (arm64 vs x64, worker vs main thread), so no
|
|
803
|
+
// number near it can be right everywhere; this one is half of the one ceiling
|
|
804
|
+
// that was measured. See RECORD/2026-09-06.the-depth-guard-lost-its-margin.md
|
|
805
|
+
const MAX_NESTING_DEPTH = 100
|
|
719
806
|
let nestingDepth = 0
|
|
720
807
|
|
|
721
808
|
// ---------------------------------------------------------------------------
|
|
@@ -770,15 +857,6 @@ type SlotMap = Record<string, SlotRenderer>
|
|
|
770
857
|
// nested component is handed
|
|
771
858
|
const SLOTS = Symbol("jq79.slots")
|
|
772
859
|
|
|
773
|
-
// <slot>, <slot.header-bar>: the hole and its name. Names arrive kebab-case
|
|
774
|
-
// whichever way they were authored (the HTML parser lowercases tag names and
|
|
775
|
-
// attribute modifiers alike, so expandNameCase normalizes camelCase to kebab
|
|
776
|
-
// before parsing) and are camelCase where read - <slot.header-bar> and
|
|
777
|
-
// <slot.headerBar> are :slot.header-bar is $slots.headerBar
|
|
778
|
-
const isSlotTag = (tag: string): boolean => tag === "slot" || tag.startsWith("slot.")
|
|
779
|
-
|
|
780
|
-
const slotName = (suffix: string): string => (suffix ? kebabToCamel(suffix) : "default")
|
|
781
|
-
|
|
782
860
|
// the content of one slot, as written at the usage site
|
|
783
861
|
type SlotContent = { nodes: (TemplateNode | string)[]; binder?: string }
|
|
784
862
|
|
|
@@ -896,6 +974,9 @@ const makeSlotRenderer = (content: SlotContent, parentScope: Record<string, any>
|
|
|
896
974
|
Object.defineProperty(scope, ALSO_WAKEN_BY, { value: [...inherited, slotScope] })
|
|
897
975
|
|
|
898
976
|
const contentFx = createEffectScope(scope)
|
|
977
|
+
// and the row the <slot> sits in, when it is a keyed one: the slot props
|
|
978
|
+
// read that row's names, so a new item there has to reach this content
|
|
979
|
+
contentFx.ownedBy(slotScope)
|
|
899
980
|
// rule 3: the <slot> is the content's lifetime. When the child's subtree at
|
|
900
981
|
// this position goes - an :if turning false, the instance being replaced,
|
|
901
982
|
// the whole child being destroyed - the content's effects go with it
|
|
@@ -1094,11 +1175,6 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
1094
1175
|
// initial value would never reach the child
|
|
1095
1176
|
const modelAttr = (name: string) => (name === "default" ? ":model" : `:model.${name}`)
|
|
1096
1177
|
const modelProp = (name: string) => (name === "default" ? "model" : name)
|
|
1097
|
-
// the newline keeps `= $value` out of a trailing line comment in the
|
|
1098
|
-
// expression (:model="uname // the username") - glued on the same line,
|
|
1099
|
-
// the assignment would vanish into the comment and compile as a bare read,
|
|
1100
|
-
// dropping every update without a word
|
|
1101
|
-
const assignment = (expr: string) => `${expr}\n= $value`
|
|
1102
1178
|
// the models whose expression will never take an update, decided here rather
|
|
1103
1179
|
// than at update time: an assignment that landed and one that was dropped
|
|
1104
1180
|
// both evaluate to the value assigned, so the result can't tell them apart -
|
|
@@ -2128,6 +2204,12 @@ const renderConditional = (branches: ConditionalBranch[], scope: Record<string,
|
|
|
2128
2204
|
current = boundsOf(rendered)
|
|
2129
2205
|
anchor.parentNode!.insertBefore(rendered, anchor.nextSibling)
|
|
2130
2206
|
})
|
|
2207
|
+
// the branch lives inside whatever holds this chain: when that goes - an
|
|
2208
|
+
// outer :if turning false, a :each row removed - the branch's effects go
|
|
2209
|
+
// with it. Left to this chain's own effect, they outlived it, and a write
|
|
2210
|
+
// that tore the outer branch down still woke them against the value that
|
|
2211
|
+
// had just emptied it (`toast = null` evaluating `toast.action.label`)
|
|
2212
|
+
fx.onDispose(() => branchFx?.dispose())
|
|
2131
2213
|
|
|
2132
2214
|
return wrapper
|
|
2133
2215
|
}
|
|
@@ -2209,10 +2291,14 @@ const isPlainObject = (value: any): value is Record<string, any> => {
|
|
|
2209
2291
|
// :each="item in items" (or "item, i in items" / "(value, key) in props"),
|
|
2210
2292
|
// optionally keyed with :key="expr". Only depends on what the list expression
|
|
2211
2293
|
// reads, and on each run diffs by key: unchanged items (same key, same item
|
|
2212
|
-
// reference) keep their DOM/effects,
|
|
2213
|
-
//
|
|
2294
|
+
// reference) keep their DOM/effects, added ones are rendered, removed ones are
|
|
2295
|
+
// disposed. A changed item - a different object under the same key - keeps its
|
|
2296
|
+
// row when the key was given with :key, which is what :key says: this is the
|
|
2297
|
+
// same row. Its DOM stays, and its bindings re-run against the new item
|
|
2298
|
+
// (see EffectScope.rerun). Without :key, an array uses position - fine for
|
|
2214
2299
|
// append-only lists, wasteful for reordering - and an object uses the
|
|
2215
|
-
// property key, which is already the stable identity
|
|
2300
|
+
// property key, which is already the stable identity; there a changed item is
|
|
2301
|
+
// rendered again. Each item gets its own
|
|
2216
2302
|
// scope via Object.create(scope), so the bindings and `$index` shadow
|
|
2217
2303
|
// same-named outer names without copying the parent scope's keys
|
|
2218
2304
|
// does anything in this subtree name one of `names` as an identifier? Attribute
|
|
@@ -2230,6 +2316,17 @@ const mentionsAny = (node: TemplateNode | string, names: string[]): boolean => {
|
|
|
2230
2316
|
node.children.some(child => mentionsAny(child, names))
|
|
2231
2317
|
}
|
|
2232
2318
|
|
|
2319
|
+
// what renders into an effect scope of its own: a conditional chain, a list, a
|
|
2320
|
+
// component (a capitalized tag, or a dashed one - nothing else can resolve to
|
|
2321
|
+
// one, see componentKeyOf) and a <slot>, whose content does. Over-approximating
|
|
2322
|
+
// costs a defineProperty per row; missing one leaves its bindings on the old item
|
|
2323
|
+
const rendersScopes = (node: TemplateNode | string): boolean =>
|
|
2324
|
+
typeof node !== "string" && (
|
|
2325
|
+
":if" in node.attrs || ":elseif" in node.attrs || ":else" in node.attrs || ":each" in node.attrs ||
|
|
2326
|
+
node.component !== undefined || node.tag.includes("-") || isSlotTag(node.tag) ||
|
|
2327
|
+
node.children.some(rendersScopes)
|
|
2328
|
+
)
|
|
2329
|
+
|
|
2233
2330
|
// `name` as a whole word: `$index` must not match inside `$indexes`, and `i`
|
|
2234
2331
|
// must not match inside `items`. `$` counts as a word character here, which is
|
|
2235
2332
|
// why the boundaries are checked by hand rather than with \b - \b treats `$`
|
|
@@ -2267,6 +2364,9 @@ type EachPlan = {
|
|
|
2267
2364
|
// can either loop name be mistaken for a component? Decided from the
|
|
2268
2365
|
// template, so the item scope can be marked plain without being scanned
|
|
2269
2366
|
namesComponent: boolean
|
|
2367
|
+
// can a row render anything into an effect scope of its own? Only then does
|
|
2368
|
+
// a keyed row need the OWNER mark that lets its re-run reach them
|
|
2369
|
+
nestsScopes: boolean
|
|
2270
2370
|
}
|
|
2271
2371
|
|
|
2272
2372
|
const eachPlans = new WeakMap<TemplateNode, EachPlan | null>()
|
|
@@ -2313,7 +2413,8 @@ const eachPlanOf = (node: TemplateNode): EachPlan | null => {
|
|
|
2313
2413
|
const readsPosition = mentionsAny(itemNode, positionalNames)
|
|
2314
2414
|
|
|
2315
2415
|
const namesComponent = /^[A-Z]/.test(itemName) || (atName !== undefined && /^[A-Z]/.test(atName))
|
|
2316
|
-
const
|
|
2416
|
+
const nestsScopes = keyExpr !== undefined && rendersScopes(itemNode)
|
|
2417
|
+
const plan: EachPlan = { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent, nestsScopes }
|
|
2317
2418
|
eachPlans.set(node, plan)
|
|
2318
2419
|
return plan
|
|
2319
2420
|
}
|
|
@@ -2322,7 +2423,7 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
2322
2423
|
const plan = eachPlanOf(node)
|
|
2323
2424
|
if (!plan) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
|
|
2324
2425
|
|
|
2325
|
-
const { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent } = plan
|
|
2426
|
+
const { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent, nestsScopes } = plan
|
|
2326
2427
|
|
|
2327
2428
|
// `:key="row.id"`, or the loop variable itself, is what a key almost always
|
|
2328
2429
|
// is - and reading one needs neither a scope to resolve names against nor a
|
|
@@ -2378,6 +2479,8 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
2378
2479
|
|
|
2379
2480
|
const seen = new Set<any>()
|
|
2380
2481
|
const moved: EachEntry[] = []
|
|
2482
|
+
// keyed rows handed a new item: kept, and re-run once they are in place
|
|
2483
|
+
const swapped: EachEntry[] = []
|
|
2381
2484
|
const nextEntries: EachEntry[] = []
|
|
2382
2485
|
// each entry's position in the previous pass, in the new order, for the
|
|
2383
2486
|
// positioning walk below - -1 for one rendered here, which has none
|
|
@@ -2440,6 +2543,25 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
2440
2543
|
continue
|
|
2441
2544
|
}
|
|
2442
2545
|
|
|
2546
|
+
// the same row with a new item in it: `rows = items.map(i => ({ ...i }))`
|
|
2547
|
+
// hands every row a new object on every pass, and rendering them all
|
|
2548
|
+
// again threw away the DOM - and with it focus, scroll and anything a
|
|
2549
|
+
// user had typed - of a list whose content had not changed. Writing the
|
|
2550
|
+
// loop name is a plain assignment, for the reason the reuse path above
|
|
2551
|
+
// gives; the re-run that brings the bindings over to the new item waits
|
|
2552
|
+
// until the rows are in place
|
|
2553
|
+
if (existing && keyExpr !== undefined) {
|
|
2554
|
+
const entryScope = existing.scope
|
|
2555
|
+
entryScope[itemName] = item
|
|
2556
|
+
if (entryScope.$index !== index) entryScope.$index = index
|
|
2557
|
+
if (atName && entryScope[atName] !== at) entryScope[atName] = at
|
|
2558
|
+
existing.item = item
|
|
2559
|
+
swapped.push(existing)
|
|
2560
|
+
nextEntries.push(existing)
|
|
2561
|
+
positions.push(existing.pos)
|
|
2562
|
+
continue
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2443
2565
|
if (existing) {
|
|
2444
2566
|
existing.fx.dispose()
|
|
2445
2567
|
removeRange(existing.range)
|
|
@@ -2452,6 +2574,12 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
2452
2574
|
// one WeakSet write per row against one Object.keys per element in it
|
|
2453
2575
|
if (!namesComponent) plainScopes.add(itemScope)
|
|
2454
2576
|
const itemFx = createEffectScope(scope)
|
|
2577
|
+
// a keyed row can be handed a new item later, and then everything
|
|
2578
|
+
// rendered inside it re-runs - including what lives in scopes of its
|
|
2579
|
+
// own, which register with the row through this (see OWNER). A row
|
|
2580
|
+
// with none has nothing to register: a defineProperty per row was
|
|
2581
|
+
// +3.9% on replacing 1,000 of them
|
|
2582
|
+
if (nestsScopes) Object.defineProperty(itemScope, OWNER, { value: itemFx })
|
|
2455
2583
|
// bounds captured before the positioning pass inserts the entry: a
|
|
2456
2584
|
// component entry is a fragment, which empties on insertion (see boundsOf)
|
|
2457
2585
|
const range = boundsOf(renderNode(itemNode, itemScope, itemFx, shadow))
|
|
@@ -2487,12 +2615,19 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
2487
2615
|
// the named key tracked nothing - refresh them so the move reaches those
|
|
2488
2616
|
// too. Untracked, so these runs don't feed this list effect's own deps
|
|
2489
2617
|
moved.forEach(entry => untracked(() => entry.fx.refresh()))
|
|
2618
|
+
// after the moves, so a binding that measures its row finds it where it
|
|
2619
|
+
// ends up. A row both moved and swapped is re-run here alone: rerun
|
|
2620
|
+
// covers everything refresh does
|
|
2621
|
+
swapped.forEach(entry => untracked(() => entry.fx.rerun()))
|
|
2490
2622
|
|
|
2491
2623
|
entries = nextEntries
|
|
2492
2624
|
} finally {
|
|
2493
2625
|
closeRenderPass(pass)
|
|
2494
2626
|
}
|
|
2495
2627
|
})
|
|
2628
|
+
// the rows live inside whatever holds this list, as a :if branch does (see
|
|
2629
|
+
// renderConditional): torn down with it, not left subscribed to the store
|
|
2630
|
+
fx.onDispose(() => entries.forEach(entry => entry.fx.dispose()))
|
|
2496
2631
|
|
|
2497
2632
|
return wrapper
|
|
2498
2633
|
}
|
|
@@ -2862,181 +2997,6 @@ type ComponentParts = {
|
|
|
2862
2997
|
name?: string
|
|
2863
2998
|
}
|
|
2864
2999
|
|
|
2865
|
-
const VOID_ELEMENTS = new Set([
|
|
2866
|
-
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
2867
|
-
"link", "meta", "param", "source", "track", "wbr",
|
|
2868
|
-
])
|
|
2869
|
-
|
|
2870
|
-
// a self-closing tag with its attributes; quoted attribute values are matched
|
|
2871
|
-
// as whole chunks so a "/>" inside one doesn't end the tag early. The tag name
|
|
2872
|
-
// admits a dot for the named forms of a tag - <slot.header /> - which is a
|
|
2873
|
-
// legal HTML tag name (the tokenizer reads to the first space, "/" or ">")
|
|
2874
|
-
const SELF_CLOSING_RE = /<([A-Za-z][\w.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)\/>/g
|
|
2875
|
-
const RAW_BLOCK_RE = /(<script[\s\S]*?<\/script\s*>|<style[\s\S]*?<\/style\s*>)/gi
|
|
2876
|
-
|
|
2877
|
-
// expands self-closing tags (<MyComponent />, <div />) into explicit
|
|
2878
|
-
// open+close pairs BEFORE DOM parsing. The HTML parser ignores the slash and
|
|
2879
|
-
// would treat them as unclosed, swallowing the following siblings. Void
|
|
2880
|
-
// elements keep their native behavior, and <script>/<style> contents are
|
|
2881
|
-
// passed through untouched so code inside them is never rewritten
|
|
2882
|
-
const expandSelfClosingTags = (src: string): string =>
|
|
2883
|
-
src
|
|
2884
|
-
.split(RAW_BLOCK_RE)
|
|
2885
|
-
.map((chunk, i) =>
|
|
2886
|
-
i % 2 === 1 // odd chunks are the captured script/style blocks
|
|
2887
|
-
? chunk
|
|
2888
|
-
: chunk.replace(SELF_CLOSING_RE, (match, tag: string, attrs: string) =>
|
|
2889
|
-
VOID_ELEMENTS.has(tag.toLowerCase()) ? match : `<${tag}${attrs}></${tag}>`
|
|
2890
|
-
)
|
|
2891
|
-
)
|
|
2892
|
-
.join("")
|
|
2893
|
-
|
|
2894
|
-
// a start tag with its attributes, quote-aware so a ">" inside a value doesn't
|
|
2895
|
-
// end it early; and a single spread attribute in name position (preceded by
|
|
2896
|
-
// start-or-whitespace), its expression an identifier or member path
|
|
2897
|
-
const OPEN_TAG_RE = /<([A-Za-z][\w.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
|
|
2898
|
-
const ATTR_SPREAD_RE = /"[^"]*"|'[^']*'|(^|\s)\.\.\.([A-Za-z_$][\w$.]*)/g
|
|
2899
|
-
|
|
2900
|
-
// `...expr` as an attribute is sugar for :props="expr" (spread an object's
|
|
2901
|
-
// properties as props - see renderNestedComponent). Rewritten BEFORE DOM
|
|
2902
|
-
// parsing, into a value-based :props.<n>, because the HTML parser lowercases
|
|
2903
|
-
// attribute *names*: with the expression in the name, `...userData` would arrive
|
|
2904
|
-
// as `...userdata` and resolve to nothing. Moving it into a value - which the
|
|
2905
|
-
// parser leaves untouched - keeps camelCase intact. Same pre-parse string move
|
|
2906
|
-
// as expandSelfClosingTags, with the same defenses against rewriting code that
|
|
2907
|
-
// only looks like a spread: <script>/<style> bodies are split out (a JS `...rest`
|
|
2908
|
-
// there is not an attribute), only a start tag's interior is scanned (text
|
|
2909
|
-
// between tags is safe), and quoted values are consumed whole so a genuine JS
|
|
2910
|
-
// spread in a value (@click="f(...args)", :x="{ ...a }") is skipped. The <n>
|
|
2911
|
-
// suffix (per tag) only keeps several spreads' attribute names distinct. A call
|
|
2912
|
-
// (`...getProps()`) stops at the paren and is left alone - use :props="expr()"
|
|
2913
|
-
const expandPropsSpread = (src: string): string =>
|
|
2914
|
-
src
|
|
2915
|
-
.split(RAW_BLOCK_RE)
|
|
2916
|
-
.map((chunk, i) =>
|
|
2917
|
-
i % 2 === 1
|
|
2918
|
-
? chunk
|
|
2919
|
-
: chunk.replace(OPEN_TAG_RE, (_match, tag: string, attrs: string) => {
|
|
2920
|
-
let n = 0
|
|
2921
|
-
const rewritten = attrs.replace(ATTR_SPREAD_RE, (whole, space: string | undefined, expr: string | undefined) =>
|
|
2922
|
-
expr === undefined ? whole : `${space}:props.${n++}="${expr}"`
|
|
2923
|
-
)
|
|
2924
|
-
return `<${tag}${rewritten}>`
|
|
2925
|
-
})
|
|
2926
|
-
)
|
|
2927
|
-
.join("")
|
|
2928
|
-
|
|
2929
|
-
// a `:`-prefixed attribute name in name position, and a </slot.name> closing
|
|
2930
|
-
// tag. Both quote-aware for the same reason ATTR_SPREAD_RE is: a colon inside
|
|
2931
|
-
// a value (@click="a ? b : c", style="color: red") is not an attribute name
|
|
2932
|
-
const ATTR_NAME_RE = /"[^"]*"|'[^']*'|(^|\s)(:[\w.$-]+)/g
|
|
2933
|
-
const CLOSE_SLOT_RE = /<\/slot\.([\w.$-]+)(\s*)>/gi
|
|
2934
|
-
const SLOT_TAG_RE = /^slot\./i
|
|
2935
|
-
|
|
2936
|
-
// camelCase -> kebab-case for every name the HTML parser would lowercase,
|
|
2937
|
-
// BEFORE it gets the chance: `:firstName` would arrive as `:firstname` and
|
|
2938
|
-
// kebabToCamel (which is what reads these names back out) would have nothing
|
|
2939
|
-
// to un-kebab, so the prop, model or slot would silently land under the wrong
|
|
2940
|
-
// key. Rewriting to `:first-name` here means both spellings converge on the
|
|
2941
|
-
// same camelCase name downstream - the author picks, the runtime doesn't care.
|
|
2942
|
-
//
|
|
2943
|
-
// Runs FIRST among the pre-parse passes, which is what keeps it simple: it
|
|
2944
|
-
// never sees the `:props.<n>` that expandPropsSpread generates, and a
|
|
2945
|
-
// <slot.firstName /> is still one occurrence rather than the open+close pair
|
|
2946
|
-
// expandSelfClosingTags turns it into. Same defenses as the passes after it -
|
|
2947
|
-
// <script>/<style> bodies split out, only start-tag interiors scanned, quoted
|
|
2948
|
-
// values consumed whole.
|
|
2949
|
-
//
|
|
2950
|
-
// Three name positions, not one: attribute names (`:model.firstName`), the
|
|
2951
|
-
// dotted tag names (`<slot.firstName>`) and component tags (`<UserCard>`,
|
|
2952
|
-
// renamed by componentTagName below). The last two have closing halves that are
|
|
2953
|
-
// rewritten too, or the parser sees a mismatched pair
|
|
2954
|
-
const kebabTagName = (tag: string): string =>
|
|
2955
|
-
SLOT_TAG_RE.test(tag) ? `slot.${camelToKebab(tag.slice("slot.".length))}` : tag
|
|
2956
|
-
|
|
2957
|
-
// the same pass records what it declined to rewrite. An uppercase-initial tag
|
|
2958
|
-
// is a claim about a component: HTML's own elements are matched
|
|
2959
|
-
// case-insensitively but nobody writes <DIV> by accident, and a custom element
|
|
2960
|
-
// may not be spelled that way at all. So <UserCard> is a name the author
|
|
2961
|
-
// expected to resolve - which is what lets renderNode throw when it doesn't
|
|
2962
|
-
// (see unresolvedComponent).
|
|
2963
|
-
//
|
|
2964
|
-
// Carried in a *value* rather than left in the tag name, because the value is
|
|
2965
|
-
// the one place the HTML parser preserves case - the same move expandPropsSpread
|
|
2966
|
-
// makes for `...userData`, and for the same reason. elementToAST lifts it
|
|
2967
|
-
// straight off attrs into a field, so no attribute loop downstream ever sees
|
|
2968
|
-
// it - and since that lift is unconditional, the name has to be one no author
|
|
2969
|
-
// would write: a plain `:component` would eat the prop of that name off
|
|
2970
|
-
// <Card :component="Widget" />
|
|
2971
|
-
const COMPONENT_TAG_ATTR = ":jq79-component"
|
|
2972
|
-
const COMPONENT_TAG_RE = /^[A-Z]/
|
|
2973
|
-
|
|
2974
|
-
// A component tag is renamed to a name the HTML parser cannot resolve to an
|
|
2975
|
-
// element, because a PascalCase tag is lowercased by the parser and what comes
|
|
2976
|
-
// out is *the native element of that name*: <Circle /> inside an <svg> is a
|
|
2977
|
-
// circle, <Tr /> is a row placed inside its <tbody>, and 70 of 90 ordinary
|
|
2978
|
-
// one-word component names collide the same way. The claim the author made -
|
|
2979
|
-
// this is a component - survives in the stamp, and the tag stops being a name
|
|
2980
|
-
// anything downstream can mistake for an element's.
|
|
2981
|
-
//
|
|
2982
|
-
// <Circle /> -> <c79-circle :jq79-component="Circle" />
|
|
2983
|
-
// </UserCard> -> </c79-user-card>
|
|
2984
|
-
//
|
|
2985
|
-
// Hyphenated, and that is not cosmetic: `c79-circle` is a valid custom element
|
|
2986
|
-
// name, so the parser builds an HTMLElement for it, where `c79circle` would be
|
|
2987
|
-
// an HTMLUnknownElement. The hyphen is the shape the platform reserves for what
|
|
2988
|
-
// is not native, which is the principle this rests on applied to our own tags -
|
|
2989
|
-
// and it reads for itself in the inspector, where a component that resolves to
|
|
2990
|
-
// nothing leaves <c79-circle> rather than a plausible-looking <circle>.
|
|
2991
|
-
//
|
|
2992
|
-
// Every capitalized tag, not only the colliding ones: today's safe name is
|
|
2993
|
-
// tomorrow's element. See RECORD/2026-08-25.component-tag-prefix.md
|
|
2994
|
-
const COMPONENT_TAG_PREFIX = "c79-"
|
|
2995
|
-
|
|
2996
|
-
const componentTagName = (tag: string): string =>
|
|
2997
|
-
`${COMPONENT_TAG_PREFIX}${camelToKebab(tag[0].toLowerCase() + tag.slice(1))}`
|
|
2998
|
-
|
|
2999
|
-
const rewriteTagName = (tag: string): string =>
|
|
3000
|
-
COMPONENT_TAG_RE.test(tag) ? componentTagName(tag) : kebabTagName(tag)
|
|
3001
|
-
|
|
3002
|
-
// the closing half of the rename. OPEN_TAG_RE matches open tags only, which was
|
|
3003
|
-
// fine while both ends lowercased to the same name; rename one end and not the
|
|
3004
|
-
// other and `<c79-circle>` gets closed by `</circle>`, nesting everything that
|
|
3005
|
-
// follows inside it. </slot.x> keeps its own pass - it is lowercase and
|
|
3006
|
-
// unaffected by this one
|
|
3007
|
-
const CLOSE_COMPONENT_RE = /<\/([A-Z][\w.-]*)(\s*)>/g
|
|
3008
|
-
|
|
3009
|
-
// appends the stamp inside the tag, *before* a self-closing slash: this pass
|
|
3010
|
-
// runs first and expandSelfClosingTags still has to recognize the `/>` that
|
|
3011
|
-
// OPEN_TAG_RE swept into the attributes. A slash inside a quoted value can't be
|
|
3012
|
-
// mistaken for it - only a trailing one is matched
|
|
3013
|
-
const TRAILING_SLASH_RE = /\/\s*$/
|
|
3014
|
-
|
|
3015
|
-
const stampComponentTag = (tag: string, attrs: string): string => {
|
|
3016
|
-
if (!COMPONENT_TAG_RE.test(tag)) return attrs
|
|
3017
|
-
const stamp = ` ${COMPONENT_TAG_ATTR}="${tag}"`
|
|
3018
|
-
const slash = TRAILING_SLASH_RE.exec(attrs)
|
|
3019
|
-
return slash ? `${attrs.slice(0, slash.index)}${stamp}${slash[0]}` : `${attrs}${stamp}`
|
|
3020
|
-
}
|
|
3021
|
-
|
|
3022
|
-
const expandNameCase = (src: string): string =>
|
|
3023
|
-
src
|
|
3024
|
-
.split(RAW_BLOCK_RE)
|
|
3025
|
-
.map((chunk, i) =>
|
|
3026
|
-
i % 2 === 1
|
|
3027
|
-
? chunk
|
|
3028
|
-
: chunk
|
|
3029
|
-
.replace(OPEN_TAG_RE, (_match, tag: string, attrs: string) => {
|
|
3030
|
-
const rewritten = attrs.replace(ATTR_NAME_RE, (whole, space: string | undefined, name: string | undefined) =>
|
|
3031
|
-
name === undefined ? whole : `${space}${camelToKebab(name)}`
|
|
3032
|
-
)
|
|
3033
|
-
return `<${rewriteTagName(tag)}${stampComponentTag(tag, rewritten)}>`
|
|
3034
|
-
})
|
|
3035
|
-
.replace(CLOSE_SLOT_RE, (_match, suffix: string, space: string) => `</slot.${camelToKebab(suffix)}${space}>`)
|
|
3036
|
-
.replace(CLOSE_COMPONENT_RE, (_match, tag: string, space: string) => `</${componentTagName(tag)}${space}>`)
|
|
3037
|
-
)
|
|
3038
|
-
.join("")
|
|
3039
|
-
|
|
3040
3000
|
// <style scoped> support. Every element of the component's own template is
|
|
3041
3001
|
// stamped with data-jq79="<hash>" and the style's selectors are rewritten to
|
|
3042
3002
|
// require that attribute, so its rules can't reach anything the component
|
|
@@ -3193,12 +3153,6 @@ const scopeCss = (css: string, scope: string): string => {
|
|
|
3193
3153
|
return Array.from(sheet.cssRules).map(rule => rule.cssText).join("\n")
|
|
3194
3154
|
}
|
|
3195
3155
|
|
|
3196
|
-
// a component name has to be PascalCase to be usable: findComponentKey only
|
|
3197
|
-
// ever considers capitalized scope keys, so a lowercase name would declare a
|
|
3198
|
-
// component no tag could reference. It is also what keeps the named exports
|
|
3199
|
-
// from colliding with a definition's own fields, which are all lowercase
|
|
3200
|
-
const COMPONENT_NAME_RE = /^[A-Z][A-Za-z0-9]*$/
|
|
3201
|
-
|
|
3202
3156
|
// converts a string of HTML into an AST representation of the component:
|
|
3203
3157
|
// - template: the non-script/style top-level elements, as TemplateNodes
|
|
3204
3158
|
// - scripts/styles: { attrs, content } blocks in source order
|
|
@@ -3227,7 +3181,7 @@ const parseComponentString = (component: string): ComponentParts => {
|
|
|
3227
3181
|
// self-closing tag is still one occurrence), then `...expr` -> :props.<n>
|
|
3228
3182
|
// (which reads the raw camelCase before the parser can lowercase names),
|
|
3229
3183
|
// then self-closing tags
|
|
3230
|
-
const prepared =
|
|
3184
|
+
const prepared = prepareSource(component)
|
|
3231
3185
|
const parsedDOM = new DOMParser().parseFromString(`<template>${prepared}</template>`, "text/html")
|
|
3232
3186
|
const root = parsedDOM.querySelector("template") as HTMLTemplateElement
|
|
3233
3187
|
|
|
@@ -3279,6 +3233,19 @@ const parseComponentString = (component: string): ComponentParts => {
|
|
|
3279
3233
|
return parts
|
|
3280
3234
|
}
|
|
3281
3235
|
|
|
3236
|
+
// the media types that mark a script as TypeScript, the other spelling of
|
|
3237
|
+
// `lang="ts"` (dev/vite.ts, TS_TYPE_RE, says why there are two). The plugin
|
|
3238
|
+
// compiles a block marked either way, so either mark still here means the same
|
|
3239
|
+
// thing
|
|
3240
|
+
const TS_SCRIPT_TYPE_RE = /^(?:text|application)\/(?:x-)?typescript$/i
|
|
3241
|
+
|
|
3242
|
+
// the mark a script is still carrying that says the plugin never compiled it,
|
|
3243
|
+
// spelled as the author spelled it
|
|
3244
|
+
const uncompiledScriptMark = (attrs: Record<string, string>): string | null =>
|
|
3245
|
+
"lang" in attrs ? `lang="${attrs.lang}"`
|
|
3246
|
+
: TS_SCRIPT_TYPE_RE.test(attrs.type?.trim() ?? "") ? `type="${attrs.type}"`
|
|
3247
|
+
: null
|
|
3248
|
+
|
|
3282
3249
|
// the script/style/markup split of one component's top-level elements, with
|
|
3283
3250
|
// <style scoped> resolved against the source those elements came from - the
|
|
3284
3251
|
// whole file for its own component, a <template>'s contents for a named one,
|
|
@@ -3296,16 +3263,17 @@ const componentPartsFrom = (elements: Element[], hashSource: string): ComponentP
|
|
|
3296
3263
|
else template.push(elementToAST(el))
|
|
3297
3264
|
})
|
|
3298
3265
|
|
|
3299
|
-
//
|
|
3300
|
-
// below, and a
|
|
3266
|
+
// a TypeScript script is compiled by the jq79/vite plugin, like <style lang>
|
|
3267
|
+
// below, and a mark still here means the same thing: this component never
|
|
3301
3268
|
// went through the bundler. It matters more on a script, because the failure
|
|
3302
3269
|
// is not always loud - `interface`/`as`/generics throw at compile time, but
|
|
3303
3270
|
// `let x: T = v` is a valid labeled statement, so it runs and leaves x
|
|
3304
3271
|
// undeclared and non-reactive with nothing in the console
|
|
3305
3272
|
scripts.forEach(script => {
|
|
3306
|
-
|
|
3273
|
+
const mark = uncompiledScriptMark(script.attrs)
|
|
3274
|
+
if (mark) {
|
|
3307
3275
|
console.warn(
|
|
3308
|
-
`jq79: <script
|
|
3276
|
+
`jq79: <script ${mark}> needs the jq79/vite plugin to compile it. ` +
|
|
3309
3277
|
"This component didn't go through the bundler, so its types were never stripped: the script " +
|
|
3310
3278
|
"will throw, or - for a plain `let x: T = ...` - silently fail to declare x."
|
|
3311
3279
|
)
|
|
@@ -3431,6 +3399,20 @@ type ScriptRun = { settled: Promise<unknown>; sync: boolean }
|
|
|
3431
3399
|
const sourceUrlComment = (filename: string | undefined, index: number): string =>
|
|
3432
3400
|
filename ? `\n//# sourceURL=${filename}?jq79-script=${index}` : ""
|
|
3433
3401
|
|
|
3402
|
+
// a script's function, named for devtools. A safe-mode miss throws, as a
|
|
3403
|
+
// script that doesn't compile always has - but saying which script, and why
|
|
3404
|
+
const compileScript = (params: string[], body: string, at: ScriptLocation): Function => {
|
|
3405
|
+
try {
|
|
3406
|
+
return makeFunction(params, body, sourceUrlComment(at.filename, at.index ?? 0))
|
|
3407
|
+
} catch (error) {
|
|
3408
|
+
if (!(error instanceof SafeEvalMiss)) throw error
|
|
3409
|
+
throw new Error(
|
|
3410
|
+
`jq79: safeEval() is on, and script ${at.index ?? 0} of ${at.filename ?? "a component built from a string"} ` +
|
|
3411
|
+
`${error.reason}. ${error.hint}`
|
|
3412
|
+
)
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3434
3416
|
// what a <style> block injects into document.head: the scoped rewrite when it
|
|
3435
3417
|
// has one, the source otherwise. A shadow root uses `content` directly instead
|
|
3436
3418
|
// - scoping is what a shadow root already does, and doing both would break the
|
|
@@ -3453,9 +3435,51 @@ const headStyle = (style: TagBlock): string => style.scoped ?? style.content
|
|
|
3453
3435
|
// isConnected check is what makes it survive a head somebody emptied
|
|
3454
3436
|
const WRAPPER_STYLE = `:where([${COMPONENT_BOX_ATTR}]) { display: contents }`
|
|
3455
3437
|
|
|
3438
|
+
// ---------------------------------------------------------------------------
|
|
3439
|
+
// styles under safe mode
|
|
3440
|
+
//
|
|
3441
|
+
// A <style> element is inline style to a CSP, and a `style-src` without
|
|
3442
|
+
// 'unsafe-inline' refuses it - checked in Chromium, for a component's <style>
|
|
3443
|
+
// and <style scoped> alike, and for the wrapper rule every component box
|
|
3444
|
+
// needs. A constructed stylesheet adopted by the document or a shadow root is
|
|
3445
|
+
// CSSOM, which no CSP directive governs: in the same browser, under
|
|
3446
|
+
// `style-src 'self'`, it applied - in the document, in a shadow root, and
|
|
3447
|
+
// after an insertRule - with no violation. So under safe mode, component
|
|
3448
|
+
// styles are adopted sheets.
|
|
3449
|
+
//
|
|
3450
|
+
// Only under safe mode: an adopted sheet cascades after every stylesheet of
|
|
3451
|
+
// the document, <link> and <style> alike, which is a different place from the
|
|
3452
|
+
// end of <head> - a page that didn't ask keeps the order it has. And only
|
|
3453
|
+
// where the browser adopts sheets at all; elsewhere it is <style> elements, as
|
|
3454
|
+
// before (RECORD/2026-09-23.no-unsafe-eval.md)
|
|
3455
|
+
// ---------------------------------------------------------------------------
|
|
3456
|
+
|
|
3457
|
+
const adoptsStyles = (): boolean =>
|
|
3458
|
+
safeEvalOn && typeof Document !== "undefined" && "adoptedStyleSheets" in Document.prototype
|
|
3459
|
+
|
|
3460
|
+
const constructedSheet = (css: string): CSSStyleSheet => {
|
|
3461
|
+
const sheet = new CSSStyleSheet()
|
|
3462
|
+
sheet.replaceSync(css)
|
|
3463
|
+
return sheet
|
|
3464
|
+
}
|
|
3465
|
+
|
|
3466
|
+
const adoptSheet = (target: Document | ShadowRoot, sheet: CSSStyleSheet) => {
|
|
3467
|
+
if (!target.adoptedStyleSheets.includes(sheet)) target.adoptedStyleSheets = [...target.adoptedStyleSheets, sheet]
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
const unadoptSheet = (target: Document | ShadowRoot, sheet: CSSStyleSheet) => {
|
|
3471
|
+
target.adoptedStyleSheets = target.adoptedStyleSheets.filter(adopted => adopted !== sheet)
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3456
3474
|
let wrapperStyleEl: HTMLStyleElement | null = null
|
|
3475
|
+
// one sheet serves every root that adopts it: the document, and each shadow root
|
|
3476
|
+
let wrapperSheet: CSSStyleSheet | null = null
|
|
3457
3477
|
|
|
3458
3478
|
const ensureWrapperStyle = () => {
|
|
3479
|
+
if (adoptsStyles()) {
|
|
3480
|
+
adoptSheet(document, wrapperSheet ??= constructedSheet(WRAPPER_STYLE))
|
|
3481
|
+
return
|
|
3482
|
+
}
|
|
3459
3483
|
if (wrapperStyleEl?.isConnected) return
|
|
3460
3484
|
wrapperStyleEl = document.createElement("style")
|
|
3461
3485
|
wrapperStyleEl.textContent = WRAPPER_STYLE
|
|
@@ -3464,16 +3488,23 @@ const ensureWrapperStyle = () => {
|
|
|
3464
3488
|
|
|
3465
3489
|
// document.head styles are shared by content and refcounted, so N instances
|
|
3466
3490
|
// of the same component (e.g. one per :each item) inject a single <style> tag
|
|
3467
|
-
// that goes away when the last instance is destroyed
|
|
3468
|
-
|
|
3491
|
+
// that goes away when the last instance is destroyed - or, under safe mode, a
|
|
3492
|
+
// single adopted sheet
|
|
3493
|
+
const styleRegistry = new Map<string, { el?: HTMLStyleElement; sheet?: CSSStyleSheet; count: number }>()
|
|
3469
3494
|
|
|
3470
3495
|
const acquireStyle = (content: string) => {
|
|
3471
3496
|
let entry = styleRegistry.get(content)
|
|
3472
3497
|
if (!entry) {
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3498
|
+
if (adoptsStyles()) {
|
|
3499
|
+
const sheet = constructedSheet(content)
|
|
3500
|
+
adoptSheet(document, sheet)
|
|
3501
|
+
entry = { sheet, count: 0 }
|
|
3502
|
+
} else {
|
|
3503
|
+
const el = document.createElement("style")
|
|
3504
|
+
el.textContent = content
|
|
3505
|
+
document.head.appendChild(el)
|
|
3506
|
+
entry = { el, count: 0 }
|
|
3507
|
+
}
|
|
3477
3508
|
styleRegistry.set(content, entry)
|
|
3478
3509
|
}
|
|
3479
3510
|
entry.count++
|
|
@@ -3482,7 +3513,8 @@ const acquireStyle = (content: string) => {
|
|
|
3482
3513
|
const releaseStyle = (content: string) => {
|
|
3483
3514
|
const entry = styleRegistry.get(content)
|
|
3484
3515
|
if (entry && --entry.count <= 0) {
|
|
3485
|
-
entry.
|
|
3516
|
+
if (entry.sheet) unadoptSheet(document, entry.sheet)
|
|
3517
|
+
else entry.el?.remove()
|
|
3486
3518
|
styleRegistry.delete(content)
|
|
3487
3519
|
}
|
|
3488
3520
|
}
|
|
@@ -3519,10 +3551,9 @@ const runSetupScript = (code: string, scope: Record<string, any>, effect: (run:
|
|
|
3519
3551
|
(Reflect.has(target, key) || !(key in globalThis) && !(key in helpers)),
|
|
3520
3552
|
})
|
|
3521
3553
|
const state: { done?: boolean } = {}
|
|
3522
|
-
const result: Promise<void> =
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
)(scriptScope, effect, importer, state, ...Object.values(helpers))
|
|
3554
|
+
const result: Promise<void> = compileScript(setupParams(Object.keys(helpers)), setupBody(code), at)(
|
|
3555
|
+
scriptScope, effect, importer, state, ...Object.values(helpers)
|
|
3556
|
+
)
|
|
3526
3557
|
result.catch(error => console.error("jq79: error in :setup script", error))
|
|
3527
3558
|
trackScript(result)
|
|
3528
3559
|
return { settled: result, sync: state.done === true }
|
|
@@ -3558,11 +3589,8 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
|
|
|
3558
3589
|
// with no :setup at all) stays `null`, so its signature is still read from the
|
|
3559
3590
|
// factory's first parameter
|
|
3560
3591
|
const setupSignature = (script: TagBlock): PropDecl[] | null => {
|
|
3561
|
-
const
|
|
3562
|
-
if (
|
|
3563
|
-
if (pattern.trim() === "") return []
|
|
3564
|
-
const props = parsePropsPattern(pattern)
|
|
3565
|
-
if (!props) warnUnreadableSignature(script, pattern)
|
|
3592
|
+
const props = readSetupSignature(script)
|
|
3593
|
+
if (!props && script.attrs[":setup"] !== undefined) warnUnreadableSignature(script, script.attrs[":setup"])
|
|
3566
3594
|
return props
|
|
3567
3595
|
}
|
|
3568
3596
|
|
|
@@ -3591,20 +3619,6 @@ const warnUnreadableSignature = (script: TagBlock, pattern: string) => {
|
|
|
3591
3619
|
)
|
|
3592
3620
|
}
|
|
3593
3621
|
|
|
3594
|
-
// every prop name a component's scripts declare, across both script modes.
|
|
3595
|
-
// Read before the store exists, because what a component declares decides
|
|
3596
|
-
// which of its file's sibling components it can still see: declaring a name
|
|
3597
|
-
// says it comes from the parent, so the file's own definition of that name is
|
|
3598
|
-
// deliberately not in this component's scope
|
|
3599
|
-
const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
|
|
3600
|
-
const names = new Set<string>()
|
|
3601
|
-
scripts.forEach(script => {
|
|
3602
|
-
const declarations = parseFactoryProps(script.content) ?? setupSignature(script)
|
|
3603
|
-
declarations?.forEach(({ name }) => names.add(name))
|
|
3604
|
-
})
|
|
3605
|
-
return names
|
|
3606
|
-
}
|
|
3607
|
-
|
|
3608
3622
|
// the same names, but null when NO script declared a signature at all - the
|
|
3609
3623
|
// distinction declareProps already keeps, and the only one that can decide
|
|
3610
3624
|
// whether to filter what a parent passes. `<script :setup>` and
|
|
@@ -3718,10 +3732,9 @@ const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.def
|
|
|
3718
3732
|
const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}, importer: (url: string) => Promise<any> = importResource, at: ScriptLocation = {}): ScriptRun => {
|
|
3719
3733
|
const helpers = { ...SETUP_HELPERS, ...instanceHelpers }
|
|
3720
3734
|
const $__exports: { default?: (props: Record<string, any>, ctx: Record<string, any>) => any; done?: boolean } = {}
|
|
3721
|
-
const result: Promise<void> =
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
)($__exports, interopDefault, importer, ...Object.values(helpers))
|
|
3735
|
+
const result: Promise<void> = compileScript(factoryParams(Object.keys(helpers)), factoryBody(code), at)(
|
|
3736
|
+
$__exports, interopDefault, importer, ...Object.values(helpers)
|
|
3737
|
+
)
|
|
3725
3738
|
|
|
3726
3739
|
const logError = (error: any) => console.error("jq79: error in factory script", error)
|
|
3727
3740
|
let invoked = false
|
|
@@ -3889,11 +3902,84 @@ const warnIfStuck = (component: Component79, gates: Promise<void>[]) => {
|
|
|
3889
3902
|
}
|
|
3890
3903
|
|
|
3891
3904
|
const fetchComponent = async (url: string): Promise<Component79> => {
|
|
3892
|
-
|
|
3893
|
-
|
|
3905
|
+
// under safeEval()'s worker, the component's functions arrive beside it,
|
|
3906
|
+
// and both have to be in before anyone can render it
|
|
3907
|
+
const [text] = await Promise.all([
|
|
3908
|
+
fetch(url).then(response => {
|
|
3909
|
+
if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
|
|
3910
|
+
return response.text()
|
|
3911
|
+
}),
|
|
3912
|
+
safeEvalWorker?.then(() => loadPrecompiled(url)),
|
|
3913
|
+
])
|
|
3894
3914
|
// the URL names the component's scripts in devtools, and is where the
|
|
3895
3915
|
// browser will look for the source when a breakpoint lands in one
|
|
3896
|
-
return new Component79(
|
|
3916
|
+
return new Component79(text, { filename: url })
|
|
3917
|
+
}
|
|
3918
|
+
|
|
3919
|
+
// ---------------------------------------------------------------------------
|
|
3920
|
+
// safe eval's worker
|
|
3921
|
+
//
|
|
3922
|
+
// Without a bundler, a component's functions come from jq79-sw.js (src/sw.ts):
|
|
3923
|
+
// asked for `<url>?jq79-precompiled`, it fetches the component from the site
|
|
3924
|
+
// itself and answers with the script that registers its functions. Asked for
|
|
3925
|
+
// with a <script src>, which the CSP judges by URL - the site's own - and
|
|
3926
|
+
// carrying the page's nonce where there is one, for a CSP that works by nonce.
|
|
3927
|
+
// ---------------------------------------------------------------------------
|
|
3928
|
+
|
|
3929
|
+
// set by safeEval() when it registers the worker: settles once the worker
|
|
3930
|
+
// controls the page, which is when a `?jq79-precompiled` request reaches it
|
|
3931
|
+
let safeEvalWorker: Promise<void> | undefined
|
|
3932
|
+
|
|
3933
|
+
const DEFAULT_WORKER_URL = "/jq79-sw.js"
|
|
3934
|
+
|
|
3935
|
+
// registers the worker and waits until it controls this page. On a first visit
|
|
3936
|
+
// it claims the page as it activates; a page loaded past it (a hard reload)
|
|
3937
|
+
// isn't controlled until it asks, so it asks
|
|
3938
|
+
const startWorker = async (url: string): Promise<void> => {
|
|
3939
|
+
const container = typeof navigator === "undefined" ? undefined : navigator.serviceWorker
|
|
3940
|
+
if (!container) {
|
|
3941
|
+
throw new Error(
|
|
3942
|
+
"jq79: safeEval() compiles components in a service worker, and this page can't have one - service workers " +
|
|
3943
|
+
"need https (or localhost). Precompile with the jq79/vite plugin instead, or use safeEval({ nonce: true }) " +
|
|
3944
|
+
"on a page whose server issues a nonce."
|
|
3945
|
+
)
|
|
3946
|
+
}
|
|
3947
|
+
let registration: ServiceWorkerRegistration
|
|
3948
|
+
try {
|
|
3949
|
+
registration = await container.register(url)
|
|
3950
|
+
} catch (error) {
|
|
3951
|
+
throw new Error(
|
|
3952
|
+
`jq79: safeEval() couldn't register its service worker at ${url} (${(error as Error).message}). ` +
|
|
3953
|
+
"Serve jq79-sw.js from the jq79 package at your site's root, or pass its URL: safeEval({ worker: \"/path/jq79-sw.js\" })."
|
|
3954
|
+
)
|
|
3955
|
+
}
|
|
3956
|
+
await container.ready
|
|
3957
|
+
if (container.controller) return
|
|
3958
|
+
await new Promise<void>(resolve => {
|
|
3959
|
+
container.addEventListener("controllerchange", () => resolve(), { once: true })
|
|
3960
|
+
if (container.controller) resolve()
|
|
3961
|
+
else registration.active?.postMessage("jq79:claim")
|
|
3962
|
+
})
|
|
3963
|
+
}
|
|
3964
|
+
|
|
3965
|
+
// a component's precompiled script: the component's own URL (no fragment)
|
|
3966
|
+
// with the worker's parameter on it, loaded as a classic <script>
|
|
3967
|
+
const loadPrecompiled = (url: string): Promise<void> => {
|
|
3968
|
+
const at = new URL(url, document.baseURI)
|
|
3969
|
+
at.hash = ""
|
|
3970
|
+
at.searchParams.set(PRECOMPILED_PARAM, "")
|
|
3971
|
+
return new Promise((resolve, reject) => {
|
|
3972
|
+
const script = document.createElement("script")
|
|
3973
|
+
const nonce = pageNonce()
|
|
3974
|
+
if (nonce) script.setAttribute("nonce", nonce)
|
|
3975
|
+
script.src = at.href
|
|
3976
|
+
script.onload = () => { script.remove(); resolve() }
|
|
3977
|
+
script.onerror = () => {
|
|
3978
|
+
script.remove()
|
|
3979
|
+
reject(new Error(`jq79: the precompiled functions of ${url} did not load, and safeEval() can't render it without them`))
|
|
3980
|
+
}
|
|
3981
|
+
document.head.append(script)
|
|
3982
|
+
})
|
|
3897
3983
|
}
|
|
3898
3984
|
|
|
3899
3985
|
// a parsed single-file component. Typical lifecycle:
|
|
@@ -3953,6 +4039,11 @@ export class Component79 {
|
|
|
3953
4039
|
// shadow rendering keeps per-instance <style> elements; head rendering goes
|
|
3954
4040
|
// through the shared refcounted styleRegistry instead
|
|
3955
4041
|
private styleEls: HTMLStyleElement[] = []
|
|
4042
|
+
// under safe mode, a shadow root's styles: the CSS, and the sheets built from
|
|
4043
|
+
// it once there is a shadow root to adopt them (see placeShadowStyles)
|
|
4044
|
+
private shadowCss: string[] | null = null
|
|
4045
|
+
private shadowSheets: CSSStyleSheet[] = []
|
|
4046
|
+
private sheetRoot: ShadowRoot | null = null
|
|
3956
4047
|
private ownsSharedStyles = false
|
|
3957
4048
|
private useShadow = false
|
|
3958
4049
|
private mountRoot: Element | ShadowRoot | DocumentFragment | null = null
|
|
@@ -4052,7 +4143,7 @@ export class Component79 {
|
|
|
4052
4143
|
|
|
4053
4144
|
// shadow styles live inline, right before the DOM they style (attach()
|
|
4054
4145
|
// appends them ahead of the content), so they go back the same way
|
|
4055
|
-
if (shadow) this.
|
|
4146
|
+
if (shadow) this.placeShadowStyles(parent, before)
|
|
4056
4147
|
parent.insertBefore(this.content!, before)
|
|
4057
4148
|
this.mountRoot = parent
|
|
4058
4149
|
this.settleMounted()
|
|
@@ -4099,6 +4190,48 @@ export class Component79 {
|
|
|
4099
4190
|
return { ...debugFlags }
|
|
4100
4191
|
}
|
|
4101
4192
|
|
|
4193
|
+
// for a page whose CSP has no 'unsafe-eval': from here on the runtime never
|
|
4194
|
+
// calls `new Function`. Opt-in, so a page that doesn't call it behaves
|
|
4195
|
+
// exactly as before; global and one-way, like the CSP it exists for.
|
|
4196
|
+
//
|
|
4197
|
+
// await Component79.safeEval() // precompiled by the worker: a miss is reported
|
|
4198
|
+
// await Component79.safeEval({ nonce: true }) // no worker: every function built with the page's nonce
|
|
4199
|
+
//
|
|
4200
|
+
// Precompiled functions come first either way (RECORD/2026-09-23.no-unsafe-eval.md).
|
|
4201
|
+
//
|
|
4202
|
+
// The plain form registers jq79-sw.js - served from the site's root, or
|
|
4203
|
+
// wherever `worker` says - and resolves once it controls the page. From
|
|
4204
|
+
// then on a component fetched by URL (Component79.fetch, an import() of an
|
|
4205
|
+
// .html from a script) arrives with its functions, compiled by the worker
|
|
4206
|
+
// from the same file. A component built from a string has no file to
|
|
4207
|
+
// compile, and its misses are reported. `worker: false` registers nothing,
|
|
4208
|
+
// for a page whose functions reach it another way - the jq79/vite plugin's.
|
|
4209
|
+
//
|
|
4210
|
+
// `{ nonce: true }` is for a page whose server issues a fresh nonce per
|
|
4211
|
+
// response: it still turns the component's text into code in the browser, as
|
|
4212
|
+
// eval would, only through a door just jq79 holds the key to. A static host's
|
|
4213
|
+
// nonce never changes, and a nonce everyone knows protects nothing. It
|
|
4214
|
+
// registers no worker unless `worker` asks for one too.
|
|
4215
|
+
//
|
|
4216
|
+
// With no nonce on the page, or no worker to be had, the promise rejects -
|
|
4217
|
+
// and safe mode stays on: a page that asked for no eval never falls back to it
|
|
4218
|
+
static safeEval(options: { nonce?: boolean; worker?: string | false } = {}): Promise<void> {
|
|
4219
|
+
safeEvalOn = true
|
|
4220
|
+
const steps: Promise<void>[] = []
|
|
4221
|
+
if (options.nonce) {
|
|
4222
|
+
const nonce = pageNonce()
|
|
4223
|
+
if (nonce === undefined) {
|
|
4224
|
+
steps.push(Promise.reject(new Error(
|
|
4225
|
+
"jq79: safeEval({ nonce: true }) found no nonce on this page - no <script> carries one. " +
|
|
4226
|
+
"The nonce belongs on the script that loads the page, the one the CSP names."
|
|
4227
|
+
)))
|
|
4228
|
+
} else safeEvalNonce = nonce
|
|
4229
|
+
}
|
|
4230
|
+
const worker = options.worker ?? (options.nonce ? false : DEFAULT_WORKER_URL)
|
|
4231
|
+
if (worker !== false) steps.push(safeEvalWorker ??= startWorker(worker))
|
|
4232
|
+
return Promise.all(steps).then(() => undefined)
|
|
4233
|
+
}
|
|
4234
|
+
|
|
4102
4235
|
static fetch(url: string): PendingComponent79 {
|
|
4103
4236
|
if (Array.isArray(url)) throw new TypeError("Component79.fetch takes one URL; use fetchAll for an array")
|
|
4104
4237
|
return new PendingComponent79(fetchComponent(url))
|
|
@@ -4143,7 +4276,7 @@ export class Component79 {
|
|
|
4143
4276
|
// what this component can see of its file's other components, and which of
|
|
4144
4277
|
// its declared props arrived empty - both decided by the signature, before
|
|
4145
4278
|
// the store exists (see siblingsInScope / UNFILLED_PROPS)
|
|
4146
|
-
const declared = declaredPropNames(this.scripts)
|
|
4279
|
+
const declared = declaredPropNames(this.scripts, setupSignature)
|
|
4147
4280
|
const siblingScope = siblingsInScope(this.siblings, declared)
|
|
4148
4281
|
const raw: Record<string, any> = siblingScope
|
|
4149
4282
|
? Object.assign(Object.create(siblingScope), data)
|
|
@@ -4224,7 +4357,9 @@ export class Component79 {
|
|
|
4224
4357
|
// though the template renders after the scripts run, so they only find
|
|
4225
4358
|
// something from post-await code or callbacks
|
|
4226
4359
|
const endMarker = this.endMarker
|
|
4227
|
-
|
|
4360
|
+
// typed as $ / $$ are (QueryOne / QueryAll in dom.ts): a tag name gives
|
|
4361
|
+
// its element, anything else an HTMLElement unless told
|
|
4362
|
+
const $$self = ((selector: string): Element[] => {
|
|
4228
4363
|
const found: Element[] = []
|
|
4229
4364
|
for (let node: Node | null = marker.nextSibling; node && node !== endMarker; node = node.nextSibling) {
|
|
4230
4365
|
if (node instanceof Element) {
|
|
@@ -4233,8 +4368,8 @@ export class Component79 {
|
|
|
4233
4368
|
}
|
|
4234
4369
|
}
|
|
4235
4370
|
return found
|
|
4236
|
-
}
|
|
4237
|
-
const $self = (selector: string): Element | null => $$self(selector)[0] ?? null
|
|
4371
|
+
}) as QueryAll
|
|
4372
|
+
const $self = ((selector: string): Element | null => $$self(selector)[0] ?? null) as QueryOne
|
|
4238
4373
|
|
|
4239
4374
|
// import() calls whose specifier was pre-resolved by a bundler (the
|
|
4240
4375
|
// modules map) get the bundled module; everything else falls back to the
|
|
@@ -4281,11 +4416,9 @@ export class Component79 {
|
|
|
4281
4416
|
})
|
|
4282
4417
|
|
|
4283
4418
|
// scripts run before the template renders so `$:` values are initialized;
|
|
4284
|
-
// a `:mounted` script defers entirely until mount() instead. A
|
|
4285
|
-
// `export default` switches the script to factory mode (plain
|
|
4286
|
-
//
|
|
4287
|
-
// first line, so deferring doesn't shift the lines devtools reports for it
|
|
4288
|
-
const defer = (code: string) => `await $mounted();${code}`
|
|
4419
|
+
// a `:mounted` script defers entirely until mount() instead (see defer). A
|
|
4420
|
+
// top-level `export default` switches the script to factory mode (plain
|
|
4421
|
+
// lexical JS)
|
|
4289
4422
|
|
|
4290
4423
|
// what the first render is still waiting for. A script holds the template
|
|
4291
4424
|
// back until it returns or calls $mounted() - whichever comes first - so
|
|
@@ -4418,13 +4551,18 @@ export class Component79 {
|
|
|
4418
4551
|
// the position carries nothing: :where() has no specificity, so an author
|
|
4419
4552
|
// rule wins wherever it sits. What it does buy is that "the shadow root's
|
|
4420
4553
|
// style" still means the component's own
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4554
|
+
if (adoptsStyles()) {
|
|
4555
|
+
// sheets, and only once there is a shadow root to adopt them
|
|
4556
|
+
this.shadowCss = [...this.styles.map(style => style.content), WRAPPER_STYLE]
|
|
4557
|
+
} else {
|
|
4558
|
+
const wrapperEl = document.createElement("style")
|
|
4559
|
+
wrapperEl.textContent = WRAPPER_STYLE
|
|
4560
|
+
this.styleEls = [...this.styles.map(style => {
|
|
4561
|
+
const el = document.createElement("style")
|
|
4562
|
+
el.textContent = style.content // the source: a shadow root scopes it already
|
|
4563
|
+
return el
|
|
4564
|
+
}), wrapperEl]
|
|
4565
|
+
}
|
|
4428
4566
|
} else {
|
|
4429
4567
|
this.styles.forEach(style => acquireStyle(headStyle(style)))
|
|
4430
4568
|
this.ownsSharedStyles = true
|
|
@@ -4461,13 +4599,35 @@ export class Component79 {
|
|
|
4461
4599
|
const root = this.useShadow && target instanceof Element
|
|
4462
4600
|
? target.shadowRoot ?? target.attachShadow({ mode: "open" })
|
|
4463
4601
|
: target
|
|
4464
|
-
if (this.useShadow) this.
|
|
4602
|
+
if (this.useShadow) this.placeShadowStyles(root, null)
|
|
4465
4603
|
root.appendChild(this.content!)
|
|
4466
4604
|
this.mountRoot = root
|
|
4467
4605
|
this.settleMounted()
|
|
4468
4606
|
return this
|
|
4469
4607
|
}
|
|
4470
4608
|
|
|
4609
|
+
// where a shadow-rendered component's styles go: <style> elements ahead of
|
|
4610
|
+
// its content, as always - or, under safe mode, sheets adopted by the shadow
|
|
4611
|
+
// root. Built on first placement, since only then is there a root; one that
|
|
4612
|
+
// isn't a ShadowRoot (a fragment) can't adopt, and gets elements after all
|
|
4613
|
+
private placeShadowStyles(root: Node, before: Node | null) {
|
|
4614
|
+
if (this.shadowCss && typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot) {
|
|
4615
|
+
if (this.sheetRoot && this.sheetRoot !== root) this.shadowSheets.forEach(sheet => unadoptSheet(this.sheetRoot!, sheet))
|
|
4616
|
+
if (this.shadowSheets.length === 0) this.shadowSheets = this.shadowCss.map(css => css === WRAPPER_STYLE ? (wrapperSheet ??= constructedSheet(css)) : constructedSheet(css))
|
|
4617
|
+
this.shadowSheets.forEach(sheet => adoptSheet(root, sheet))
|
|
4618
|
+
this.sheetRoot = root
|
|
4619
|
+
return
|
|
4620
|
+
}
|
|
4621
|
+
if (this.shadowCss && this.styleEls.length === 0) {
|
|
4622
|
+
this.styleEls = this.shadowCss.map(css => {
|
|
4623
|
+
const el = document.createElement("style")
|
|
4624
|
+
el.textContent = css
|
|
4625
|
+
return el
|
|
4626
|
+
})
|
|
4627
|
+
}
|
|
4628
|
+
this.styleEls.forEach(el => root.insertBefore(el, before))
|
|
4629
|
+
}
|
|
4630
|
+
|
|
4471
4631
|
// `await $mounted()` means "rendered and on the page", so it waits for both -
|
|
4472
4632
|
// whichever lands last calls this. In the ordinary synchronous flow the render
|
|
4473
4633
|
// is already done and this is the attach; for a component whose first render
|
|
@@ -4504,6 +4664,12 @@ export class Component79 {
|
|
|
4504
4664
|
this.data?.$dispose()
|
|
4505
4665
|
this.styleEls.forEach(el => el.parentNode?.removeChild(el))
|
|
4506
4666
|
this.styleEls = []
|
|
4667
|
+
// the wrapper sheet stays: other boxes in that root may need it, as the
|
|
4668
|
+
// wrapper <style> stays in the document
|
|
4669
|
+
if (this.sheetRoot) this.shadowSheets.forEach(sheet => { if (sheet !== wrapperSheet) unadoptSheet(this.sheetRoot!, sheet) })
|
|
4670
|
+
this.shadowCss = null
|
|
4671
|
+
this.shadowSheets = []
|
|
4672
|
+
this.sheetRoot = null
|
|
4507
4673
|
if (this.ownsSharedStyles) {
|
|
4508
4674
|
this.styles.forEach(style => releaseStyle(headStyle(style)))
|
|
4509
4675
|
this.ownsSharedStyles = false
|
|
@@ -4604,11 +4770,30 @@ export class PendingComponent79 {
|
|
|
4604
4770
|
|
|
4605
4771
|
export { Component79 as C79 }
|
|
4606
4772
|
|
|
4773
|
+
// a component that takes the props P: what a signature writes for a component
|
|
4774
|
+
// it takes as a prop - `:setup="{ Button }: { Button: Component<{ label: string }> }"` -
|
|
4775
|
+
// so a type-checker can hold the tags that use it, and the parents that pass
|
|
4776
|
+
// one, to P (RECORD/2026-10-01.component-as-prop.md). "~props" is a phantom:
|
|
4777
|
+
// never set, only in the types. A function of P, because props are passed *to*
|
|
4778
|
+
// a component: one that takes more than P (optionally) is a Component<P>, one
|
|
4779
|
+
// that requires something P doesn't have is not
|
|
4780
|
+
// E and S name the events a signature listens for and the slots it fills
|
|
4781
|
+
// (RECORD/2026-10-01.component-prop-events-and-slots.md): functions of them
|
|
4782
|
+
// too, so a component that emits or renders more is one, one that misses a
|
|
4783
|
+
// name is not. Their default, never, asks nothing
|
|
4784
|
+
export type Component<P = any, E extends string = never, S extends string = never> = Component79 & {
|
|
4785
|
+
readonly "~props"?: (props: P) => void
|
|
4786
|
+
readonly "~emits"?: (event: E) => void
|
|
4787
|
+
readonly "~slots"?: (slot: S) => void
|
|
4788
|
+
}
|
|
4789
|
+
|
|
4607
4790
|
export const parseComponent = (component: string): Component79 => new Component79(component)
|
|
4608
4791
|
|
|
4609
4792
|
// library helpers injected into setup scripts. They behave like extra
|
|
4610
4793
|
// globals: a same-named scope property (render data or a top-level
|
|
4611
|
-
// declaration) shadows them
|
|
4794
|
+
// declaration) shadows them. Their names, in this order, are also
|
|
4795
|
+
// SETUP_HELPER_NAMES (source.ts) - what precompile compiles scripts with, so
|
|
4796
|
+
// the two change together
|
|
4612
4797
|
const SETUP_HELPERS: Record<string, any> = { $, $$, $create, $reactive, $toRaw, Component79 }
|
|
4613
4798
|
|
|
4614
4799
|
// the hot-reload handshake. jq79/dev serves a classic script that sets the flag
|