jq79 0.3.29 → 0.3.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/jq79.cjs +9 -8
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.global.js +9 -8
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +9 -8
- package/dist/jq79.js.map +1 -1
- package/dist/transform.d.ts +6 -0
- package/package.json +1 -1
- package/src/jq79.ts +54 -7
- package/src/transform.ts +187 -0
package/dist/transform.d.ts
CHANGED
|
@@ -3,5 +3,11 @@ type SetupTransform = {
|
|
|
3
3
|
code: string;
|
|
4
4
|
};
|
|
5
5
|
export declare const transformSetupScript: (src: string) => SetupTransform;
|
|
6
|
+
export type PropDecl = {
|
|
7
|
+
name: string;
|
|
8
|
+
default?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare const parsePropsPattern: (pattern: string | undefined) => PropDecl[] | null;
|
|
11
|
+
export declare const parseFactoryProps: (src: string) => PropDecl[] | null;
|
|
6
12
|
export declare const transformFactoryScript: (src: string) => string | null;
|
|
7
13
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jq79",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.31",
|
|
4
4
|
"description": "Mini reactive component library: single-file components, Svelte-style setup scripts, fine-grained proxy reactivity. Single-file build, zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"reactive",
|
package/src/jq79.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { $, $$, $create, sanitizeHTML } from "./dom"
|
|
3
3
|
import { $reactive, untracked, createEffectScope } from "./reactive"
|
|
4
4
|
import type { ReactiveDeepData, EffectScope } from "./reactive"
|
|
5
|
-
import { transformSetupScript, transformFactoryScript } from "./transform"
|
|
5
|
+
import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseFactoryProps, type PropDecl } from "./transform"
|
|
6
6
|
|
|
7
7
|
export { $, $$, $create } from "./dom"
|
|
8
8
|
export { $reactive } from "./reactive"
|
|
@@ -51,12 +51,35 @@ const elementToAST = (el: Element): TemplateNode => ({
|
|
|
51
51
|
// precise instead of "read everything up front". `extras` are passed as
|
|
52
52
|
// function parameters (outside the `with`), so scope keys still win but names
|
|
53
53
|
// like $event resolve when the scope doesn't shadow them
|
|
54
|
+
//
|
|
55
|
+
// Compiled functions are cached: an expression is re-evaluated on every effect
|
|
56
|
+
// run - once per interpolation, once per :each item - while the set of distinct
|
|
57
|
+
// expressions is fixed by the source. The `extras` names are part of the key,
|
|
58
|
+
// not just the expression: they become the function's parameters, so the same
|
|
59
|
+
// expression compiled with and without $event is two different functions. A
|
|
60
|
+
// syntactically invalid expression caches its failure (null) so it isn't
|
|
61
|
+
// recompiled, and rethrown as undefined, exactly as before
|
|
62
|
+
const compiled = new Map<string, Function | null>()
|
|
63
|
+
|
|
64
|
+
const compileExpr = (expr: string, params: string[]): Function | null => {
|
|
65
|
+
const key = `${params.join(",")}|${expr}`
|
|
66
|
+
let fn = compiled.get(key)
|
|
67
|
+
if (fn === undefined) {
|
|
68
|
+
try {
|
|
69
|
+
fn = new Function("$scope", ...params, `with ($scope) { return (${expr}); }`)
|
|
70
|
+
} catch {
|
|
71
|
+
fn = null // a syntax error: it will never compile, so don't try again
|
|
72
|
+
}
|
|
73
|
+
compiled.set(key, fn)
|
|
74
|
+
}
|
|
75
|
+
return fn
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
const evalExpr = (expr: string, scope: Record<string, any>, extras?: Record<string, any>): any => {
|
|
79
|
+
const fn = compileExpr(expr, extras ? Object.keys(extras) : [])
|
|
80
|
+
if (!fn) return undefined
|
|
55
81
|
try {
|
|
56
|
-
return
|
|
57
|
-
scope,
|
|
58
|
-
...Object.values(extras ?? {})
|
|
59
|
-
)
|
|
82
|
+
return fn(scope, ...(extras ? Object.values(extras) : []))
|
|
60
83
|
} catch {
|
|
61
84
|
return undefined
|
|
62
85
|
}
|
|
@@ -740,6 +763,25 @@ const runSetupScript = (code: string, scope: Record<string, any>, effect: (run:
|
|
|
740
763
|
result.catch(error => console.error("jq79: error in :setup script", error))
|
|
741
764
|
}
|
|
742
765
|
|
|
766
|
+
// puts a component's declared props on the store, before any script runs and
|
|
767
|
+
// before the first render: the names, so the template can bind to them even
|
|
768
|
+
// when the parent passes nothing, and the defaults, so it binds to something.
|
|
769
|
+
//
|
|
770
|
+
// A prop the parent *did* pass is already on the store (render() seeds it), so
|
|
771
|
+
// a default only fills an `undefined` - which is also what JS destructuring
|
|
772
|
+
// does with the same pattern, so both modes agree. It happens once, at setup:
|
|
773
|
+
// re-applying a default later would need an effect that reads and writes the
|
|
774
|
+
// same key, and that effect would wake itself forever.
|
|
775
|
+
//
|
|
776
|
+
// `null` props means the component declared no signature at all, which is not
|
|
777
|
+
// the same as declaring an empty one: it keeps today's permissive behavior
|
|
778
|
+
const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
|
|
779
|
+
props?.forEach(({ name, default: expr }) => {
|
|
780
|
+
if (store[name] !== undefined) return
|
|
781
|
+
store[name] = expr === undefined ? undefined : evalExpr(expr, store)
|
|
782
|
+
})
|
|
783
|
+
}
|
|
784
|
+
|
|
743
785
|
// default-import interop for factory scripts: real modules expose .default,
|
|
744
786
|
// while importing an .html component resolves to the Component79 itself
|
|
745
787
|
const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.default : mod)
|
|
@@ -753,7 +795,7 @@ const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.def
|
|
|
753
795
|
// resolve later and the template updates reactively
|
|
754
796
|
const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}, importer: (url: string) => Promise<any> = importResource, at: ScriptLocation = {}) => {
|
|
755
797
|
const helpers = { ...SETUP_HELPERS, ...instanceHelpers }
|
|
756
|
-
const $__exports: { default?: (ctx: Record<string, any>) => any; done?: boolean } = {}
|
|
798
|
+
const $__exports: { default?: (props: Record<string, any>, ctx: Record<string, any>) => any; done?: boolean } = {}
|
|
757
799
|
const result: Promise<void> = new Function(
|
|
758
800
|
"$__exports", "$__default", "$__import", ...Object.keys(helpers),
|
|
759
801
|
`return (async () => { "use strict";\n${code}\n;$__exports.done = true })()${sourceUrlComment(at.filename, at.index ?? 0)}`
|
|
@@ -772,7 +814,10 @@ const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run
|
|
|
772
814
|
// the sync path is invoked straight from render(), so a throwing factory
|
|
773
815
|
// must be caught here too - not just by the `result` rejection handler
|
|
774
816
|
try {
|
|
775
|
-
|
|
817
|
+
// props first, ctx second. Both are the store: the pattern destructures
|
|
818
|
+
// the props it declared (copying, as destructuring does - $props is the
|
|
819
|
+
// live view for a primitive the parent reassigns later)
|
|
820
|
+
const returned = factory(scope, { $data: scope, $props: scope, $effect: effect, ...instanceHelpers })
|
|
776
821
|
if (returned instanceof Promise) returned.then(merge).catch(logError)
|
|
777
822
|
else merge(returned)
|
|
778
823
|
} catch (error) {
|
|
@@ -1077,11 +1122,13 @@ export class Component79 {
|
|
|
1077
1122
|
const at: ScriptLocation = { filename: this.filename, index }
|
|
1078
1123
|
const factoryCode = transformFactoryScript(script.content)
|
|
1079
1124
|
if (factoryCode !== null) {
|
|
1125
|
+
declareProps(store, parseFactoryProps(script.content))
|
|
1080
1126
|
const body = ":mounted" in script.attrs ? defer(factoryCode) : factoryCode
|
|
1081
1127
|
runFactoryScript(body, store, fx.effect, instanceHelpers, $import, at)
|
|
1082
1128
|
return
|
|
1083
1129
|
}
|
|
1084
1130
|
const { vars, code } = transformSetupScript(script.content)
|
|
1131
|
+
declareProps(store, parsePropsPattern(script.attrs[":setup"]))
|
|
1085
1132
|
// pre-declare script vars on the store so `with` resolves assignments
|
|
1086
1133
|
// to them (and reads of them) through the reactive proxy
|
|
1087
1134
|
vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })
|
package/src/transform.ts
CHANGED
|
@@ -235,6 +235,193 @@ const staticImportToAwait = (clause: string | undefined, spec: string, n: number
|
|
|
235
235
|
return `const ${bindings.join(", ")}`
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// the component's prop signature
|
|
240
|
+
//
|
|
241
|
+
// A component declares the props it takes as a destructuring pattern, in the
|
|
242
|
+
// place each script mode already puts its inputs: the `:setup` attribute's
|
|
243
|
+
// value, or the factory's *first* parameter (the ctx moved to the second).
|
|
244
|
+
// Position is fixed, so the signature is read straight from the source string -
|
|
245
|
+
// no parser, no execution - and the runtime can seed the defaults on the store
|
|
246
|
+
// before the first render, which is what makes them reach the template even in
|
|
247
|
+
// factory mode (where JS would only apply them inside the function body).
|
|
248
|
+
//
|
|
249
|
+
// <script :setup="{ label = 'Total', step = 1 }">
|
|
250
|
+
// export default ({ label = "Total" }, { $data }) => {}
|
|
251
|
+
//
|
|
252
|
+
// An object pattern *is* the declaration; anything else (`_`, a plain
|
|
253
|
+
// identifier, no attribute at all) declares nothing and stays permissive.
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
export type PropDecl = { name: string; default?: string }
|
|
257
|
+
|
|
258
|
+
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/
|
|
259
|
+
|
|
260
|
+
// index of the first `ch` at bracket depth 0, skipping strings and comments
|
|
261
|
+
const indexOfTopLevel = (src: string, ch: string): number => {
|
|
262
|
+
let depth = 0
|
|
263
|
+
let i = 0
|
|
264
|
+
while (i < src.length) {
|
|
265
|
+
const c = src[i]
|
|
266
|
+
if (c === "'" || c === '"' || c === "`") { i = skipString(src, i); continue }
|
|
267
|
+
if (c === "/" && src[i + 1] === "/") { i = skipLineComment(src, i); continue }
|
|
268
|
+
if (c === "/" && src[i + 1] === "*") { i = skipBlockComment(src, i); continue }
|
|
269
|
+
if ("([{".includes(c)) depth++
|
|
270
|
+
else if (")]}".includes(c)) depth--
|
|
271
|
+
else if (depth === 0 && c === ch) return i
|
|
272
|
+
i++
|
|
273
|
+
}
|
|
274
|
+
return -1
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const splitTopLevel = (src: string): string[] => {
|
|
278
|
+
const parts: string[] = []
|
|
279
|
+
let depth = 0
|
|
280
|
+
let start = 0
|
|
281
|
+
let i = 0
|
|
282
|
+
while (i <= src.length) {
|
|
283
|
+
const ch = src[i]
|
|
284
|
+
if (ch === "'" || ch === '"' || ch === "`") { i = skipString(src, i); continue }
|
|
285
|
+
if (ch === "/" && src[i + 1] === "/") { i = skipLineComment(src, i); continue }
|
|
286
|
+
if (ch === "/" && src[i + 1] === "*") { i = skipBlockComment(src, i); continue }
|
|
287
|
+
if (ch !== undefined && "([{".includes(ch)) depth++
|
|
288
|
+
else if (ch !== undefined && ")]}".includes(ch)) depth--
|
|
289
|
+
else if (i === src.length || (ch === "," && depth === 0)) {
|
|
290
|
+
const part = src.slice(start, i).trim()
|
|
291
|
+
if (part) parts.push(part)
|
|
292
|
+
start = i + 1
|
|
293
|
+
}
|
|
294
|
+
i++
|
|
295
|
+
}
|
|
296
|
+
return parts
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// the `=` that opens a default value: the first one at depth 0 that isn't part
|
|
300
|
+
// of `==`/`===` or an arrow's `=>` (so `{ format = a => a }` keeps its default)
|
|
301
|
+
const defaultAssignIndex = (src: string): number => {
|
|
302
|
+
let from = 0
|
|
303
|
+
while (from < src.length) {
|
|
304
|
+
const at = indexOfTopLevel(src.slice(from), "=")
|
|
305
|
+
if (at === -1) return -1
|
|
306
|
+
const i = from + at
|
|
307
|
+
if (src[i + 1] !== "=" && src[i + 1] !== ">" && src[i - 1] !== "=" && src[i - 1] !== "!") return i
|
|
308
|
+
from = i + 1
|
|
309
|
+
}
|
|
310
|
+
return -1
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// a destructuring pattern -> the props it declares. null means "no signature":
|
|
314
|
+
// the pattern isn't an object one (`_`, `props`, nothing at all), so the
|
|
315
|
+
// component declares nothing and keeps the permissive, undeclared behavior.
|
|
316
|
+
// `{}` parses to [] - a closed signature that declares zero props
|
|
317
|
+
export const parsePropsPattern = (pattern: string | undefined): PropDecl[] | null => {
|
|
318
|
+
const src = (pattern ?? "").trim()
|
|
319
|
+
if (!src.startsWith("{")) return null
|
|
320
|
+
|
|
321
|
+
// to the `}` that closes the pattern, so a parameter's own default value
|
|
322
|
+
// (`({ label } = {})`) is left out of it
|
|
323
|
+
let depth = 0
|
|
324
|
+
let close = 0
|
|
325
|
+
while (close < src.length) {
|
|
326
|
+
const ch = src[close]
|
|
327
|
+
if (ch === "'" || ch === '"' || ch === "`") { close = skipString(src, close); continue }
|
|
328
|
+
if ("([{".includes(ch)) depth++
|
|
329
|
+
else if (")]}".includes(ch) && --depth === 0) break
|
|
330
|
+
close++
|
|
331
|
+
}
|
|
332
|
+
if (close >= src.length) return null // unbalanced: not a pattern we can read
|
|
333
|
+
|
|
334
|
+
const props: PropDecl[] = []
|
|
335
|
+
for (const part of splitTopLevel(src.slice(1, close))) {
|
|
336
|
+
if (part.startsWith("...")) continue // a rest element names no prop
|
|
337
|
+
const assign = defaultAssignIndex(part)
|
|
338
|
+
const named = assign === -1 ? part : part.slice(0, assign)
|
|
339
|
+
const fallback = assign === -1 ? undefined : part.slice(assign + 1).trim()
|
|
340
|
+
// `{ user: { id } }` and `{ user: renamed }` both declare `user`: what the
|
|
341
|
+
// store holds is the key, whatever the pattern binds it to
|
|
342
|
+
const colon = indexOfTopLevel(named, ":")
|
|
343
|
+
const name = (colon === -1 ? named : named.slice(0, colon)).trim()
|
|
344
|
+
if (!IDENTIFIER_RE.test(name)) continue
|
|
345
|
+
props.push(fallback === undefined ? { name } : { name, default: fallback })
|
|
346
|
+
}
|
|
347
|
+
return props
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// index just past a top-level `export default`, or -1
|
|
351
|
+
const findExportDefault = (src: string): number => {
|
|
352
|
+
let i = 0
|
|
353
|
+
let depth = 0
|
|
354
|
+
let atStatementStart = true
|
|
355
|
+
while (i < src.length) {
|
|
356
|
+
const ch = src[i]
|
|
357
|
+
if (ch === "'" || ch === '"' || ch === "`") { i = skipString(src, i); atStatementStart = false; continue }
|
|
358
|
+
if (ch === "/" && src[i + 1] === "/") { i = skipLineComment(src, i); continue }
|
|
359
|
+
if (ch === "/" && src[i + 1] === "*") { i = skipBlockComment(src, i); continue }
|
|
360
|
+
|
|
361
|
+
if (ch === "e" && depth === 0 && atStatementStart && (i === 0 || !/[\w$.]/.test(src[i - 1]))) {
|
|
362
|
+
EXPORT_DEFAULT_RE.lastIndex = i
|
|
363
|
+
const found = EXPORT_DEFAULT_RE.exec(src)
|
|
364
|
+
if (found) return i + found[0].length
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if ("([{".includes(ch)) depth++
|
|
368
|
+
else if (")]}".includes(ch)) depth = Math.max(0, depth - 1)
|
|
369
|
+
if (ch === "\n" || ch === ";" || ch === "}") atStatementStart = true
|
|
370
|
+
else if (!/\s/.test(ch)) atStatementStart = false
|
|
371
|
+
i++
|
|
372
|
+
}
|
|
373
|
+
return -1
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const ASYNC_RE = /^async(?![\w$])/
|
|
377
|
+
const FUNCTION_RE = /^function(?![\w$])\s*\*?\s*[A-Za-z_$][\w$]*|^function(?![\w$])\s*\*?/
|
|
378
|
+
|
|
379
|
+
// source text of the exported function's first parameter, or null when there
|
|
380
|
+
// is no parameter list to read (`export default Factory`, `export default
|
|
381
|
+
// props => ...`, an exported object) - all of which declare nothing
|
|
382
|
+
const firstParameterSource = (src: string): string | null => {
|
|
383
|
+
const start = findExportDefault(src)
|
|
384
|
+
if (start === -1) return null
|
|
385
|
+
|
|
386
|
+
let i = skipToToken(src, start)
|
|
387
|
+
const rest = src.slice(i)
|
|
388
|
+
if (ASYNC_RE.test(rest)) i = skipToToken(src, i + "async".length)
|
|
389
|
+
const fn = FUNCTION_RE.exec(src.slice(i))
|
|
390
|
+
if (fn) i = skipToToken(src, i + fn[0].length)
|
|
391
|
+
if (src[i] !== "(") return null
|
|
392
|
+
|
|
393
|
+
// the parameter list runs to the `)` that closes this `(`
|
|
394
|
+
let depth = 0
|
|
395
|
+
let end = i
|
|
396
|
+
while (end < src.length) {
|
|
397
|
+
const ch = src[end]
|
|
398
|
+
if (ch === "'" || ch === '"' || ch === "`") { end = skipString(src, end); continue }
|
|
399
|
+
if ("([{".includes(ch)) depth++
|
|
400
|
+
else if (")]}".includes(ch) && --depth === 0) break
|
|
401
|
+
end++
|
|
402
|
+
}
|
|
403
|
+
return splitTopLevel(src.slice(i + 1, end))[0] ?? ""
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// the props declared by a factory script's first parameter. Throws the
|
|
407
|
+
// migration error when it finds the pre-0.4 signature there - the ctx used to
|
|
408
|
+
// be the first parameter, and the change is silent otherwise ($data would just
|
|
409
|
+
// come back undefined). `$` is what tells them apart: what carries one comes
|
|
410
|
+
// from the library, what doesn't comes from the parent, everywhere in jq79
|
|
411
|
+
export const parseFactoryProps = (src: string): PropDecl[] | null => {
|
|
412
|
+
const first = firstParameterSource(src)
|
|
413
|
+
if (first === null) return null
|
|
414
|
+
const props = parsePropsPattern(first)
|
|
415
|
+
const ctxName = props?.find(prop => prop.name.startsWith("$"))?.name
|
|
416
|
+
if (ctxName) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
`jq79: the factory signature is (props, ctx), so \`${ctxName}\` can't be destructured from the first parameter. ` +
|
|
419
|
+
`Write \`export default (props, { ${ctxName} }) => …\`, or \`_\` in place of props if the component takes none.`
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
return props
|
|
423
|
+
}
|
|
424
|
+
|
|
238
425
|
// rewrites a factory script into a Function body, or returns null when the
|
|
239
426
|
// script has no top-level `export default` (i.e. it's a regular setup script)
|
|
240
427
|
export const transformFactoryScript = (src: string): string | null => {
|