jq79 0.7.2 → 0.7.4

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/src/source.ts ADDED
@@ -0,0 +1,389 @@
1
+ // ---------------------------------------------------------------------------
2
+ // what a component's text says, before there is a DOM
3
+ //
4
+ // The half of the runtime that reads a component as text: the rewrites that
5
+ // run before the HTML parser sees it, the directive grammar the renderer reads
6
+ // out of attributes and texts, and the exact text each expression and script
7
+ // compiles to. Nothing here touches a document, which is what lets it serve
8
+ // two entries - the runtime (jq79.ts), and precompile (precompile.ts), which
9
+ // has to read a component as the runtime reads it, on node or in a worker, and
10
+ // produce exactly what the runtime compiles. Moved out of jq79.ts verbatim, so
11
+ // that sharing the code is what keeps the two in step - and so a page that
12
+ // never precompiles doesn't carry the generator (RECORD/2026-09-23.no-unsafe-eval.md)
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import { freeIdentifiers, parsePropsPattern, parseFactoryProps, type PropDecl } from "./transform"
16
+
17
+ export type TemplateNode = {
18
+ tag: string
19
+ attrs: Record<string, string>
20
+ children: (TemplateNode | string)[]
21
+ // the tag as the author capitalized it, present only when they wrote it
22
+ // uppercase-initial - i.e. when they meant a component. `tag` cannot answer
23
+ // this: the HTML parser lowercases it, so the claim is captured before the
24
+ // parse (see stampComponentTag) and lifted off attrs here, where it stops
25
+ // looking like an attribute to every loop downstream
26
+ component?: string
27
+ // the element's namespace, present only when it is NOT HTML - an <svg>
28
+ // subtree, or MathML. Read straight off the parsed tree, because the HTML
29
+ // parser has already run the foreign-content algorithm over it and knows
30
+ // things a tag name cannot say: whether this <title> is SVG's or HTML's, and
31
+ // where a <foreignObject> hands the namespace back. Absent is the common
32
+ // case and means HTML, so an ordinary node is exactly the shape it was
33
+ ns?: string
34
+ }
35
+
36
+ export type TagBlock = {
37
+ attrs: Record<string, string>
38
+ content: string
39
+ // <style scoped> only: `content` rewritten to require the component's scope
40
+ // attribute. Kept beside the original rather than replacing it, because a
41
+ // shadow root doesn't want it - see headStyle()
42
+ scoped?: string
43
+ }
44
+
45
+ export const kebabToCamel = (name: string) => name.replace(/-(\w)/g, (_, c: string) => c.toUpperCase())
46
+
47
+ // the inverse, used only by the pre-parse name rewrite (see expandNameCase):
48
+ // uppercase ASCII letters only, never digits - `:props.0` is a generated
49
+ // attribute name and splitting on digits would mangle it. Round-trips through
50
+ // kebabToCamel, acronyms included: userID -> user-i-d -> userID
51
+ export const camelToKebab = (name: string) => name.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)
52
+
53
+ export const VOID_ELEMENTS = new Set([
54
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
55
+ "link", "meta", "param", "source", "track", "wbr",
56
+ ])
57
+
58
+ // a self-closing tag with its attributes; quoted attribute values are matched
59
+ // as whole chunks so a "/>" inside one doesn't end the tag early. The tag name
60
+ // admits a dot for the named forms of a tag - <slot.header /> - which is a
61
+ // legal HTML tag name (the tokenizer reads to the first space, "/" or ">")
62
+ export const SELF_CLOSING_RE = /<([A-Za-z][\w.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)\/>/g
63
+ export const RAW_BLOCK_RE = /(<script[\s\S]*?<\/script\s*>|<style[\s\S]*?<\/style\s*>)/gi
64
+
65
+ // expands self-closing tags (<MyComponent />, <div />) into explicit
66
+ // open+close pairs BEFORE DOM parsing. The HTML parser ignores the slash and
67
+ // would treat them as unclosed, swallowing the following siblings. Void
68
+ // elements keep their native behavior, and <script>/<style> contents are
69
+ // passed through untouched so code inside them is never rewritten
70
+ export const expandSelfClosingTags = (src: string): string =>
71
+ src
72
+ .split(RAW_BLOCK_RE)
73
+ .map((chunk, i) =>
74
+ i % 2 === 1 // odd chunks are the captured script/style blocks
75
+ ? chunk
76
+ : chunk.replace(SELF_CLOSING_RE, (match, tag: string, attrs: string) =>
77
+ VOID_ELEMENTS.has(tag.toLowerCase()) ? match : `<${tag}${attrs}></${tag}>`
78
+ )
79
+ )
80
+ .join("")
81
+
82
+ // a start tag with its attributes, quote-aware so a ">" inside a value doesn't
83
+ // end it early; and a single spread attribute in name position (preceded by
84
+ // start-or-whitespace), its expression an identifier or member path
85
+ export const OPEN_TAG_RE = /<([A-Za-z][\w.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
86
+ export const ATTR_SPREAD_RE = /"[^"]*"|'[^']*'|(^|\s)\.\.\.([A-Za-z_$][\w$.]*)/g
87
+
88
+ // `...expr` as an attribute is sugar for :props="expr" (spread an object's
89
+ // properties as props - see renderNestedComponent). Rewritten BEFORE DOM
90
+ // parsing, into a value-based :props.<n>, because the HTML parser lowercases
91
+ // attribute *names*: with the expression in the name, `...userData` would arrive
92
+ // as `...userdata` and resolve to nothing. Moving it into a value - which the
93
+ // parser leaves untouched - keeps camelCase intact. Same pre-parse string move
94
+ // as expandSelfClosingTags, with the same defenses against rewriting code that
95
+ // only looks like a spread: <script>/<style> bodies are split out (a JS `...rest`
96
+ // there is not an attribute), only a start tag's interior is scanned (text
97
+ // between tags is safe), and quoted values are consumed whole so a genuine JS
98
+ // spread in a value (@click="f(...args)", :x="{ ...a }") is skipped. The <n>
99
+ // suffix (per tag) only keeps several spreads' attribute names distinct. A call
100
+ // (`...getProps()`) stops at the paren and is left alone - use :props="expr()"
101
+ export const expandPropsSpread = (src: string): string =>
102
+ src
103
+ .split(RAW_BLOCK_RE)
104
+ .map((chunk, i) =>
105
+ i % 2 === 1
106
+ ? chunk
107
+ : chunk.replace(OPEN_TAG_RE, (_match, tag: string, attrs: string) => {
108
+ let n = 0
109
+ const rewritten = attrs.replace(ATTR_SPREAD_RE, (whole, space: string | undefined, expr: string | undefined) =>
110
+ expr === undefined ? whole : `${space}:props.${n++}="${expr}"`
111
+ )
112
+ return `<${tag}${rewritten}>`
113
+ })
114
+ )
115
+ .join("")
116
+
117
+ // a `:`-prefixed attribute name in name position, and a </slot.name> closing
118
+ // tag. Both quote-aware for the same reason ATTR_SPREAD_RE is: a colon inside
119
+ // a value (@click="a ? b : c", style="color: red") is not an attribute name
120
+ export const ATTR_NAME_RE = /"[^"]*"|'[^']*'|(^|\s)(:[\w.$-]+)/g
121
+ export const CLOSE_SLOT_RE = /<\/slot\.([\w.$-]+)(\s*)>/gi
122
+ export const SLOT_TAG_RE = /^slot\./i
123
+
124
+ // camelCase -> kebab-case for every name the HTML parser would lowercase,
125
+ // BEFORE it gets the chance: `:firstName` would arrive as `:firstname` and
126
+ // kebabToCamel (which is what reads these names back out) would have nothing
127
+ // to un-kebab, so the prop, model or slot would silently land under the wrong
128
+ // key. Rewriting to `:first-name` here means both spellings converge on the
129
+ // same camelCase name downstream - the author picks, the runtime doesn't care.
130
+ //
131
+ // Runs FIRST among the pre-parse passes, which is what keeps it simple: it
132
+ // never sees the `:props.<n>` that expandPropsSpread generates, and a
133
+ // <slot.firstName /> is still one occurrence rather than the open+close pair
134
+ // expandSelfClosingTags turns it into. Same defenses as the passes after it -
135
+ // <script>/<style> bodies split out, only start-tag interiors scanned, quoted
136
+ // values consumed whole.
137
+ //
138
+ // Three name positions, not one: attribute names (`:model.firstName`), the
139
+ // dotted tag names (`<slot.firstName>`) and component tags (`<UserCard>`,
140
+ // renamed by componentTagName below). The last two have closing halves that are
141
+ // rewritten too, or the parser sees a mismatched pair
142
+ export const kebabTagName = (tag: string): string =>
143
+ SLOT_TAG_RE.test(tag) ? `slot.${camelToKebab(tag.slice("slot.".length))}` : tag
144
+
145
+ // the same pass records what it declined to rewrite. An uppercase-initial tag
146
+ // is a claim about a component: HTML's own elements are matched
147
+ // case-insensitively but nobody writes <DIV> by accident, and a custom element
148
+ // may not be spelled that way at all. So <UserCard> is a name the author
149
+ // expected to resolve - which is what lets renderNode throw when it doesn't
150
+ // (see unresolvedComponent).
151
+ //
152
+ // Carried in a *value* rather than left in the tag name, because the value is
153
+ // the one place the HTML parser preserves case - the same move expandPropsSpread
154
+ // makes for `...userData`, and for the same reason. elementToAST lifts it
155
+ // straight off attrs into a field, so no attribute loop downstream ever sees
156
+ // it - and since that lift is unconditional, the name has to be one no author
157
+ // would write: a plain `:component` would eat the prop of that name off
158
+ // <Card :component="Widget" />
159
+ export const COMPONENT_TAG_ATTR = ":jq79-component"
160
+ export const COMPONENT_TAG_RE = /^[A-Z]/
161
+
162
+ // A component tag is renamed to a name the HTML parser cannot resolve to an
163
+ // element, because a PascalCase tag is lowercased by the parser and what comes
164
+ // out is *the native element of that name*: <Circle /> inside an <svg> is a
165
+ // circle, <Tr /> is a row placed inside its <tbody>, and 70 of 90 ordinary
166
+ // one-word component names collide the same way. The claim the author made -
167
+ // this is a component - survives in the stamp, and the tag stops being a name
168
+ // anything downstream can mistake for an element's.
169
+ //
170
+ // <Circle /> -> <c79-circle :jq79-component="Circle" />
171
+ // </UserCard> -> </c79-user-card>
172
+ //
173
+ // Hyphenated, and that is not cosmetic: `c79-circle` is a valid custom element
174
+ // name, so the parser builds an HTMLElement for it, where `c79circle` would be
175
+ // an HTMLUnknownElement. The hyphen is the shape the platform reserves for what
176
+ // is not native, which is the principle this rests on applied to our own tags -
177
+ // and it reads for itself in the inspector, where a component that resolves to
178
+ // nothing leaves <c79-circle> rather than a plausible-looking <circle>.
179
+ //
180
+ // Every capitalized tag, not only the colliding ones: today's safe name is
181
+ // tomorrow's element. See RECORD/2026-08-25.component-tag-prefix.md
182
+ export const COMPONENT_TAG_PREFIX = "c79-"
183
+
184
+ export const componentTagName = (tag: string): string =>
185
+ `${COMPONENT_TAG_PREFIX}${camelToKebab(tag[0].toLowerCase() + tag.slice(1))}`
186
+
187
+ export const rewriteTagName = (tag: string): string =>
188
+ COMPONENT_TAG_RE.test(tag) ? componentTagName(tag) : kebabTagName(tag)
189
+
190
+ // the closing half of the rename. OPEN_TAG_RE matches open tags only, which was
191
+ // fine while both ends lowercased to the same name; rename one end and not the
192
+ // other and `<c79-circle>` gets closed by `</circle>`, nesting everything that
193
+ // follows inside it. </slot.x> keeps its own pass - it is lowercase and
194
+ // unaffected by this one
195
+ export const CLOSE_COMPONENT_RE = /<\/([A-Z][\w.-]*)(\s*)>/g
196
+
197
+ // appends the stamp inside the tag, *before* a self-closing slash: this pass
198
+ // runs first and expandSelfClosingTags still has to recognize the `/>` that
199
+ // OPEN_TAG_RE swept into the attributes. A slash inside a quoted value can't be
200
+ // mistaken for it - only a trailing one is matched
201
+ export const TRAILING_SLASH_RE = /\/\s*$/
202
+
203
+ export const stampComponentTag = (tag: string, attrs: string): string => {
204
+ if (!COMPONENT_TAG_RE.test(tag)) return attrs
205
+ const stamp = ` ${COMPONENT_TAG_ATTR}="${tag}"`
206
+ const slash = TRAILING_SLASH_RE.exec(attrs)
207
+ return slash ? `${attrs.slice(0, slash.index)}${stamp}${slash[0]}` : `${attrs}${stamp}`
208
+ }
209
+
210
+ export const expandNameCase = (src: string): string =>
211
+ src
212
+ .split(RAW_BLOCK_RE)
213
+ .map((chunk, i) =>
214
+ i % 2 === 1
215
+ ? chunk
216
+ : chunk
217
+ .replace(OPEN_TAG_RE, (_match, tag: string, attrs: string) => {
218
+ const rewritten = attrs.replace(ATTR_NAME_RE, (whole, space: string | undefined, name: string | undefined) =>
219
+ name === undefined ? whole : `${space}${camelToKebab(name)}`
220
+ )
221
+ return `<${rewriteTagName(tag)}${stampComponentTag(tag, rewritten)}>`
222
+ })
223
+ .replace(CLOSE_SLOT_RE, (_match, suffix: string, space: string) => `</slot.${camelToKebab(suffix)}${space}>`)
224
+ .replace(CLOSE_COMPONENT_RE, (_match, tag: string, space: string) => `</${componentTagName(tag)}${space}>`)
225
+ )
226
+ .join("")
227
+
228
+ // all three pre-parse rewrites, in their load-bearing order (see
229
+ // parseComponentString) - shared with precompile, which reads the same text
230
+ export const prepareSource = (component: string): string => expandSelfClosingTags(expandPropsSpread(expandNameCase(component)))
231
+
232
+ // a component name has to be PascalCase to be usable: findComponentKey only
233
+ // ever considers capitalized scope keys, so a lowercase name would declare a
234
+ // component no tag could reference. It is also what keeps the named exports
235
+ // from colliding with a definition's own fields, which are all lowercase
236
+ export const COMPONENT_NAME_RE = /^[A-Z][A-Za-z0-9]*$/
237
+
238
+ // [\s\S] rather than `.` so an expression can span lines, like the ones in
239
+ // directive attributes (which reach evalExpr wrapped in parens either way)
240
+ export const INTERPOLATION_RE = /{{\s*([\s\S]+?)\s*}}/g
241
+
242
+ // A text template split once into its literal and expression parts. The split
243
+ // used to happen on every run of every instance - `String.replace` over the
244
+ // whole text, a fresh match object and a callback per expression - and a
245
+ // :each over 1,000 rows runs it 1,000 times per text node to reach the same
246
+ // answer about the same string. Keyed by the template text, like compileExpr's
247
+ // cache and bounded the same way: by how many distinct texts the source holds
248
+ //
249
+ // An expression part is boxed so a literal `"x"` and an expression `x` stay
250
+ // distinguishable without a second array
251
+ export type TextPart = string | { expr: string }
252
+
253
+ export const textParts = new Map<string, TextPart[]>()
254
+
255
+ export const splitText = (template: string): TextPart[] => {
256
+ const cached = textParts.get(template)
257
+ if (cached) return cached
258
+ const parts: TextPart[] = []
259
+ let at = 0
260
+ INTERPOLATION_RE.lastIndex = 0
261
+ for (let match = INTERPOLATION_RE.exec(template); match; match = INTERPOLATION_RE.exec(template)) {
262
+ if (match.index > at) parts.push(template.slice(at, match.index))
263
+ parts.push({ expr: match[1] })
264
+ at = match.index + match[0].length
265
+ }
266
+ if (at < template.length) parts.push(template.slice(at))
267
+ textParts.set(template, parts)
268
+ return parts
269
+ }
270
+
271
+ export const CONTROL_ATTRS = new Set([":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed", ":props"])
272
+
273
+ // a control attribute is one the static-attr loop and nested-component prop
274
+ // collection must skip. The set holds the fixed names; `:class.<name>` (the
275
+ // single-flag shorthand) and `:props.<n>` (one spread among several) are
276
+ // open-ended, so they're matched by prefix - they can't be enumerated into the set
277
+ export const isControlAttr = (attr: string): boolean =>
278
+ CONTROL_ATTRS.has(attr) || attr.startsWith(":class.") || attr.startsWith(":props.") ||
279
+ attr === ":slot" || attr.startsWith(":slot.")
280
+ // `item in items`, `item, i in items`, `(value, key) in props` - the second
281
+ // binding is the array index or the object key, parens optional (Vue-style).
282
+ // The list expression can span lines, so it matches [\s\S] rather than `.`
283
+ export const EACH_PATTERN = /^\s*\(?\s*(\w+)\s*(?:,\s*(\w+))?\s*\)?\s+in\s+([\s\S]+)$/
284
+
285
+ // <slot>, <slot.header-bar>: the hole and its name. Names arrive kebab-case
286
+ // whichever way they were authored (the HTML parser lowercases tag names and
287
+ // attribute modifiers alike, so expandNameCase normalizes camelCase to kebab
288
+ // before parsing) and are camelCase where read - <slot.header-bar> and
289
+ // <slot.headerBar> are :slot.header-bar is $slots.headerBar
290
+ export const isSlotTag = (tag: string): boolean => tag === "slot" || tag.startsWith("slot.")
291
+
292
+ export const slotName = (suffix: string): string => (suffix ? kebabToCamel(suffix) : "default")
293
+
294
+ // a :model's way back up: its expression as an assignment target. The newline
295
+ // keeps `= $value` out of a trailing line comment in the expression
296
+ // (:model="uname // the username") - glued on the same line, the assignment
297
+ // would vanish into the comment and compile as a bare read, dropping every
298
+ // update without a word
299
+ export const assignment = (expr: string) => `${expr}\n= $value`
300
+
301
+ // the newline before `)` ends a trailing line comment in the expression
302
+ // ({{ msg // greeting }}); ASI doesn't apply inside parens, so everything else
303
+ // is untouched. Without it the comment eats the rest of this single-line body
304
+ // and the expression never compiles
305
+ // the text of the two forms an expression compiles to, and the parameters in
306
+ // front of its extras - one place, because precompile has to produce exactly
307
+ // what the runtime hands makeFunction, or its functions are never looked up
308
+ export const EXPR_PARAMS = ["$scope", "$r"]
309
+
310
+ export const withBody = (expr: string): string => `with ($scope) { return (${expr}\n); }`
311
+
312
+ // the scoped form: each free name read off the scope in a `const` prologue -
313
+ // see compileScoped (jq79.ts) for what it buys and what it risks. null where
314
+ // freeIdentifiers won't vouch for the names - the `with` form's case
315
+ export const scopedBody = (expr: string, params: string[]): string | null => {
316
+ const free = freeIdentifiers(expr)
317
+ if (free === null) return null
318
+ // an extra is already a parameter of this function: declaring it again would
319
+ // shadow the value the caller passed in
320
+ const names = free.filter(name => !params.includes(name))
321
+ const prologue = names.length === 0 ? "" : `let $t; ${names.map(name =>
322
+ `const ${name} = ($t = $scope.${name}) !== undefined ? $t : $r($scope, ${JSON.stringify(name)});`).join(" ")}`
323
+ return `${prologue} return (${expr}\n);`
324
+ }
325
+
326
+ // a parameter name can't contain a newline, so the key is unambiguous
327
+ export const functionKey = (params: string[], body: string): string => `${params.join(",")}\n${body}`
328
+
329
+ // a `:mounted` script is deferred by prepending the await on the code's own
330
+ // first line, so deferring doesn't shift the lines devtools reports for it
331
+ export const defer = (code: string) => `await $mounted();${code}`
332
+
333
+ // what the two kinds of script compile to, shared with precompile for the
334
+ // reason the expression forms are
335
+ export const setupParams = (helperNames: string[]): string[] => ["$scope", "$__effect", "$__import", "$__state", ...helperNames]
336
+ export const setupBody = (code: string): string => `return (async () => { with ($scope) { ${code} }\n;$__state.done = true })()`
337
+ export const factoryParams = (helperNames: string[]): string[] => ["$__exports", "$__default", "$__import", ...helperNames]
338
+ export const factoryBody = (code: string): string => `return (async () => { "use strict";\n${code}\n;$__exports.done = true })()`
339
+
340
+ // the same answer without the warning - what precompile reads, which has
341
+ // nobody to warn and would otherwise say it once per build
342
+ export const readSetupSignature = (script: TagBlock): PropDecl[] | null => {
343
+ const pattern = script.attrs[":setup"]
344
+ if (pattern === undefined) return null
345
+ if (pattern.trim() === "") return []
346
+ return parsePropsPattern(pattern)
347
+ }
348
+
349
+ // every prop name a component's scripts declare, across both script modes.
350
+ // Read before the store exists, because what a component declares decides
351
+ // which of its file's sibling components it can still see: declaring a name
352
+ // says it comes from the parent, so the file's own definition of that name is
353
+ // deliberately not in this component's scope. The runtime reads the setup
354
+ // signatures with setupSignature, which warns; precompile, with readSetupSignature
355
+ export const declaredPropNames = (scripts: TagBlock[], signature: (script: TagBlock) => PropDecl[] | null): Set<string> => {
356
+ const names = new Set<string>()
357
+ scripts.forEach(script => {
358
+ const declarations = parseFactoryProps(script.content) ?? signature(script)
359
+ declarations?.forEach(({ name }) => names.add(name))
360
+ })
361
+ return names
362
+ }
363
+
364
+ // the names every setup and factory script is compiled with, in the order
365
+ // renderWith passes them: SETUP_HELPERS (whose values live in jq79.ts, beside
366
+ // the functions they are) and then the per-instance ones it builds -
367
+ // $mounted, $destroyed, $attached, $detached, $computed, $self and $$self,
368
+ // then the injected
369
+ // $emit, $updateModel and $slots. They are positional parameters, so the order is part of what
370
+ // precompile has to reproduce; change one side and precompiled scripts stop
371
+ // matching, which tests/precompile.test.ts and `npm run check:precompile` catch
372
+ export const SETUP_HELPER_NAMES = ["$", "$$", "$create", "$reactive", "$toRaw", "Component79"]
373
+ export const INSTANCE_HELPER_NAMES = ["$mounted", "$destroyed", "$attached", "$detached", "$computed", "$self", "$$self", "$emit", "$updateModel", "$slots"]
374
+
375
+ // the global a precompiled script leaves its functions on, for the runtime to
376
+ // drain: (self.__jq79precompiled = self.__jq79precompiled || []).push([params,
377
+ // body, fn], ...) - a global rather than a call, so the script can load before
378
+ // the library or after it, and serve either build of it
379
+ export const PRECOMPILED_QUEUE = "__jq79precompiled"
380
+
381
+ // the query parameter that asks safe mode's service worker for a component's
382
+ // precompiled script rather than the component: /Card.html?jq79-precompiled
383
+ export const PRECOMPILED_PARAM = "jq79-precompiled"
384
+
385
+ // a function's text laid out as `new Function` lays it out, so that a
386
+ // precompiled or nonce-built function is named as eval's was, and devtools
387
+ // reports the same line numbers for it
388
+ export const functionText = (params: string[], body: string): string =>
389
+ `function anonymous(${params.join(",")}\n) {\n${body}\n}`
package/src/sw.ts ADDED
@@ -0,0 +1,87 @@
1
+ // ---------------------------------------------------------------------------
2
+ // jq79-sw.js: the service worker behind `await Component79.safeEval()`
3
+ //
4
+ // A page with no bundler and a CSP without 'unsafe-eval' still needs every
5
+ // component's functions as code the browser loaded, and a CSP judges a script
6
+ // by where it came from. This worker is where: served from the site's own
7
+ // origin, it answers `/Card.html?jq79-precompiled` - a request the runtime
8
+ // makes with a <script src> - by fetching /Card.html itself and responding
9
+ // with the script that registers its precompiled functions. `script-src
10
+ // 'self'` admits it because the URL is the site's own; the .html files stay
11
+ // exactly what gets deployed and fetched (RECORD/2026-09-23.no-unsafe-eval.md).
12
+ //
13
+ // What it compiles is only ever text it fetched from its own origin: the
14
+ // request contributes a path and nothing else, and a request for another
15
+ // origin is left alone. And it writes only functions that parse as one
16
+ // function - checked with a JavaScript parser, because a worker has no eval
17
+ // either - so no expression's text can close its function early and run
18
+ // when the script loads. A file someone else managed to put on the origin
19
+ // compiles into functions that are registered and never called, unless the
20
+ // page renders that file as a component.
21
+ //
22
+ // Built by tsup as dist/jq79-sw.js, a classic worker script with the
23
+ // generator and the parser in it. It has to be served from the site - a
24
+ // service worker can't come from another origin - at the root by default,
25
+ // which is also what makes its scope the whole site.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ import { parse } from "acorn"
29
+ import { precompile, precompiledScript } from "./precompile"
30
+ import { PRECOMPILED_PARAM, functionText } from "./source"
31
+
32
+ // the text parses, and parses as exactly this one function - nothing before
33
+ // it, nothing after it. Wrapped in parens so a function *declaration* can't
34
+ // stand in, and read back by position so a body that closes the function
35
+ // early and opens another can't either
36
+ export const parsesAsOneFunction = (params: string[], body: string): boolean => {
37
+ const text = `(${functionText(params, body)})`
38
+ try {
39
+ const program = parse(text, { ecmaVersion: "latest", sourceType: "script" })
40
+ const [statement] = program.body
41
+ return (
42
+ program.body.length === 1 &&
43
+ statement.type === "ExpressionStatement" &&
44
+ statement.expression.type === "FunctionExpression" &&
45
+ statement.expression.start === 1 &&
46
+ statement.expression.end === text.length - 1
47
+ )
48
+ } catch {
49
+ return false
50
+ }
51
+ }
52
+
53
+ const SCRIPT_HEADERS = { "Content-Type": "text/javascript", "Cache-Control": "no-cache" }
54
+
55
+ // the answer to a `?jq79-precompiled` request: the component fetched from the
56
+ // same URL without the parameter, and its functions as a script. A component
57
+ // that isn't there answers with its status, so the <script> that asked fails
58
+ // and the runtime says which file
59
+ export const precompiledResponse = async (url: URL, fetchSource: (url: string) => Promise<Response> = fetch): Promise<Response> => {
60
+ const source = new URL(url.href)
61
+ source.searchParams.delete(PRECOMPILED_PARAM)
62
+ const response = await fetchSource(source.href)
63
+ if (!response.ok) {
64
+ return new Response(`/* jq79: ${source.pathname} answered ${response.status} */\n`, { status: response.status, headers: SCRIPT_HEADERS })
65
+ }
66
+ return new Response(precompiledScript(precompile(await response.text()), parsesAsOneFunction), { headers: SCRIPT_HEADERS })
67
+ }
68
+
69
+ // only when this file runs as a service worker - imported anywhere else (the
70
+ // tests), it registers nothing
71
+ const scope = globalThis as any
72
+ if (typeof scope.ServiceWorkerGlobalScope === "function" && scope instanceof scope.ServiceWorkerGlobalScope) {
73
+ scope.addEventListener("install", () => scope.skipWaiting())
74
+ // the page that registered it is waiting to be controlled: claim it now,
75
+ // rather than on its next load
76
+ scope.addEventListener("activate", (event: any) => event.waitUntil(scope.clients.claim()))
77
+ // a page loaded past the worker - a hard reload - asks to be claimed
78
+ scope.addEventListener("message", (event: any) => {
79
+ if (event.data === "jq79:claim") event.waitUntil(scope.clients.claim())
80
+ })
81
+ scope.addEventListener("fetch", (event: any) => {
82
+ if (event.request.method !== "GET") return
83
+ const url = new URL(event.request.url)
84
+ if (url.origin !== scope.location.origin || !url.searchParams.has(PRECOMPILED_PARAM)) return
85
+ event.respondWith(precompiledResponse(url))
86
+ })
87
+ }
package/src/transform.ts CHANGED
@@ -401,7 +401,7 @@ export const transformSetupScript = (src: string): SetupTransform => {
401
401
  // plain lexical module instead of a `with`-scoped setup script: no implicit
402
402
  // reactivity, no `$:` labels - standard JS that editors and type-checkers
403
403
  // understand. The default export is called with the instance context
404
- // ({ $data, $effect, $emit, $mounted, $self, $$self }) and a returned object
404
+ // ({ $data, $effect, $emit, $mounted, $destroyed, $attached, $detached, $computed, $self, $$self }) and a returned object
405
405
  // is merged into the reactive store for the template to use.
406
406
  // Detection is backwards-safe: `export default` is a SyntaxError inside a
407
407
  // setup script, so no previously-working component can change behavior.