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/README.md +3 -2
- package/dev/vite.ts +152 -10
- 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 +27 -13
- 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 +10 -0
- package/dist/source.d.ts +65 -0
- package/dist/vite.cjs +85 -4
- package/dist/vite.cjs.map +1 -1
- package/dist/vite.d.ts +3 -0
- package/dist/vite.js +86 -5
- 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 +705 -363
- package/src/precompile.ts +238 -0
- package/src/reactive.ts +178 -9
- package/src/source.ts +389 -0
- package/src/sw.ts +87 -0
- package/src/transform.ts +1 -1
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.
|