jq79 0.3.30 → 0.3.32

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.30",
3
+ "version": "0.3.32",
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"
@@ -763,6 +763,25 @@ const runSetupScript = (code: string, scope: Record<string, any>, effect: (run:
763
763
  result.catch(error => console.error("jq79: error in :setup script", error))
764
764
  }
765
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
+
766
785
  // default-import interop for factory scripts: real modules expose .default,
767
786
  // while importing an .html component resolves to the Component79 itself
768
787
  const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.default : mod)
@@ -776,7 +795,7 @@ const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.def
776
795
  // resolve later and the template updates reactively
777
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 = {}) => {
778
797
  const helpers = { ...SETUP_HELPERS, ...instanceHelpers }
779
- 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 } = {}
780
799
  const result: Promise<void> = new Function(
781
800
  "$__exports", "$__default", "$__import", ...Object.keys(helpers),
782
801
  `return (async () => { "use strict";\n${code}\n;$__exports.done = true })()${sourceUrlComment(at.filename, at.index ?? 0)}`
@@ -795,7 +814,10 @@ const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run
795
814
  // the sync path is invoked straight from render(), so a throwing factory
796
815
  // must be caught here too - not just by the `result` rejection handler
797
816
  try {
798
- 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 })
799
821
  if (returned instanceof Promise) returned.then(merge).catch(logError)
800
822
  else merge(returned)
801
823
  } catch (error) {
@@ -1100,11 +1122,13 @@ export class Component79 {
1100
1122
  const at: ScriptLocation = { filename: this.filename, index }
1101
1123
  const factoryCode = transformFactoryScript(script.content)
1102
1124
  if (factoryCode !== null) {
1125
+ declareProps(store, parseFactoryProps(script.content))
1103
1126
  const body = ":mounted" in script.attrs ? defer(factoryCode) : factoryCode
1104
1127
  runFactoryScript(body, store, fx.effect, instanceHelpers, $import, at)
1105
1128
  return
1106
1129
  }
1107
1130
  const { vars, code } = transformSetupScript(script.content)
1131
+ declareProps(store, parsePropsPattern(script.attrs[":setup"]))
1108
1132
  // pre-declare script vars on the store so `with` resolves assignments
1109
1133
  // to them (and reads of them) through the reactive proxy
1110
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 => {