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.
@@ -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.29",
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 new Function("$scope", ...Object.keys(extras ?? {}), `with ($scope) { return (${expr}); }`)(
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
- const returned = factory({ $data: scope, $effect: effect, ...instanceHelpers })
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 => {