jq79 0.4.11 → 0.4.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jq79",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
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
@@ -96,13 +96,14 @@ const interpolate = (template: string, scope: Record<string, any>): string =>
96
96
  template.replace(/{{\s*([\s\S]+?)\s*}}/g, (_, expr) => evalExpr(expr, scope) ?? "")
97
97
 
98
98
 
99
- const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed"])
99
+ const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed", ":props"])
100
100
 
101
101
  // a control attribute is one the static-attr loop and nested-component prop
102
102
  // collection must skip. The set holds the fixed names; `:class.<name>` (the
103
- // single-flag shorthand) is open-ended, so it's matched by prefix - it can't be
104
- // enumerated into the set
105
- const isControlAttr = (attr: string): boolean => CONTROL_ATTRS.has(attr) || attr.startsWith(":class.")
103
+ // single-flag shorthand) and `:props.<n>` (one spread among several) are
104
+ // open-ended, so they're matched by prefix - they can't be enumerated into the set
105
+ const isControlAttr = (attr: string): boolean =>
106
+ CONTROL_ATTRS.has(attr) || attr.startsWith(":class.") || attr.startsWith(":props.")
106
107
  // `item in items`, `item, i in items`, `(value, key) in props` - the second
107
108
  // binding is the array index or the object key, parens optional (Vue-style).
108
109
  // The list expression can span lines, so it matches [\s\S] rather than `.`
@@ -241,10 +242,24 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
241
242
  const props: Record<string, string> = {} // prop name -> expression in parent scope
242
243
  const models: Record<string, string> = {} // model name -> assignable expression in parent scope
243
244
  const events: Array<[string, string]> = [] // @attr (modifiers included) -> handler expression
245
+ // named props AND spreads in source order - what a :props merge folds over so
246
+ // precedence follows the JS object-spread rule (later wins). `name` absent
247
+ // marks a spread: the whole object's properties, not one binding
248
+ const sources: Array<{ name?: string; expr: string }> = []
249
+ let hasSpread = false
244
250
  Object.entries(node.attrs).forEach(([attr, value]) => {
245
251
  // the parent's scope stamp is stamped on every template element, this tag
246
252
  // included - it's not a prop, and the child renders under its own scope
247
- if (attr === SCOPE_ATTR || isControlAttr(attr)) return
253
+ if (attr === SCOPE_ATTR) return
254
+ if (attr === ":props" || attr.startsWith(":props.")) {
255
+ // :props="obj" spreads obj's own properties as props; :props.<n> is one
256
+ // spread among several (the `...obj` sugar rewrites to it - see
257
+ // expandPropsSpread), the suffix only keeping the attribute names distinct
258
+ hasSpread = true
259
+ sources.push({ expr: value })
260
+ return
261
+ }
262
+ if (isControlAttr(attr)) return
248
263
  if (attr.startsWith("@")) {
249
264
  events.push([attr, value])
250
265
  } else if (attr === ":model" || attr.startsWith(":model.")) {
@@ -257,8 +272,12 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
257
272
  } else if (attr.startsWith(":")) {
258
273
  const name = kebabToCamel(attr.slice(1))
259
274
  props[name] = value || name
275
+ sources.push({ name, expr: value || name })
260
276
  } else {
261
- props[kebabToCamel(attr)] = JSON.stringify(value)
277
+ const name = kebabToCamel(attr)
278
+ const expr = JSON.stringify(value)
279
+ props[name] = expr
280
+ sources.push({ name, expr })
262
281
  }
263
282
  })
264
283
 
@@ -287,6 +306,25 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
287
306
  }
288
307
  })
289
308
 
309
+ // the full prop set the child gets, resolved in source order: each named prop
310
+ // sets one key, each spread merges an object's own properties, later sources
311
+ // overwriting earlier (the JS object-spread rule). :model bindings apply last,
312
+ // so they win - the same precedence the collision warning above promises. A
313
+ // spread expression that isn't an object contributes nothing (fail closed,
314
+ // like :with), so an `await`-pending object spreads once it resolves
315
+ const resolveProps = (): Record<string, any> => {
316
+ const out: Record<string, any> = {}
317
+ sources.forEach(({ name, expr }) => {
318
+ if (name !== undefined) out[name] = evalExpr(expr, scope)
319
+ else {
320
+ const obj = evalExpr(expr, scope)
321
+ if (obj !== null && typeof obj === "object") Object.assign(out, obj)
322
+ }
323
+ })
324
+ Object.entries(models).forEach(([name, expr]) => { out[modelProp(name)] = evalExpr(expr, scope) })
325
+ return out
326
+ }
327
+
290
328
  let current: Component79 | null = null
291
329
  let currentDef: Component79 | null = null
292
330
  let childFx: EffectScope | null = null
@@ -352,9 +390,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
352
390
  // explicit re-emit)
353
391
  events.forEach(([attr, expr]) => wireTagEvent(instance, attr, expr, scope))
354
392
 
355
- const seed = untracked(() =>
356
- Object.fromEntries(Object.entries(props).map(([name, expr]) => [name, evalExpr(expr, scope)]))
357
- )
393
+ const seed = untracked(resolveProps)
358
394
  // mounting into a fragment attaches no shadow root of its own: a
359
395
  // shadow-rendered child keeps its <style> elements inline, next to the DOM
360
396
  // they style, and the parent's shadow root is what scopes both
@@ -363,9 +399,27 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
363
399
  endAnchor.parentNode!.insertBefore(holder, endAnchor)
364
400
 
365
401
  const syncFx = createEffectScope(scope)
366
- Object.entries(props).forEach(([name, expr]) => {
367
- syncFx.effect(() => { (instance.data as Record<string, any>)[name] = evalExpr(expr, scope) })
368
- })
402
+ // without a spread the prop set is fixed and known: one effect per prop, so
403
+ // a change to one prop re-syncs only that prop. A spread's key set is
404
+ // dynamic and its precedence is positional, so it can't be resolved a key at
405
+ // a time across independent effects (whichever re-ran last would win) - one
406
+ // effect re-merges everything in order and writes the diff, clearing keys a
407
+ // spread has dropped since last run. Named props are always in the merge, so
408
+ // they're never cleared; the extra cost is confined to spread-using tags
409
+ if (hasSpread) {
410
+ let written: string[] = []
411
+ syncFx.effect(() => {
412
+ const next = resolveProps()
413
+ const nextKeys = Object.keys(next)
414
+ written.forEach(key => { if (!(key in next)) (instance.data as Record<string, any>)[key] = undefined })
415
+ nextKeys.forEach(key => { (instance.data as Record<string, any>)[key] = next[key] })
416
+ written = nextKeys
417
+ })
418
+ } else {
419
+ Object.entries(props).forEach(([name, expr]) => {
420
+ syncFx.effect(() => { (instance.data as Record<string, any>)[name] = evalExpr(expr, scope) })
421
+ })
422
+ }
369
423
 
370
424
  childFx = syncFx
371
425
  current = instance
@@ -864,6 +918,41 @@ const expandSelfClosingTags = (src: string): string =>
864
918
  )
865
919
  .join("")
866
920
 
921
+ // a start tag with its attributes, quote-aware so a ">" inside a value doesn't
922
+ // end it early; and a single spread attribute in name position (preceded by
923
+ // start-or-whitespace), its expression an identifier or member path
924
+ const OPEN_TAG_RE = /<([A-Za-z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
925
+ const ATTR_SPREAD_RE = /"[^"]*"|'[^']*'|(^|\s)\.\.\.([A-Za-z_$][\w$.]*)/g
926
+
927
+ // `...expr` as an attribute is sugar for :props="expr" (spread an object's
928
+ // properties as props - see renderNestedComponent). Rewritten BEFORE DOM
929
+ // parsing, into a value-based :props.<n>, because the HTML parser lowercases
930
+ // attribute *names*: with the expression in the name, `...userData` would arrive
931
+ // as `...userdata` and resolve to nothing. Moving it into a value - which the
932
+ // parser leaves untouched - keeps camelCase intact. Same pre-parse string move
933
+ // as expandSelfClosingTags, with the same defenses against rewriting code that
934
+ // only looks like a spread: <script>/<style> bodies are split out (a JS `...rest`
935
+ // there is not an attribute), only a start tag's interior is scanned (text
936
+ // between tags is safe), and quoted values are consumed whole so a genuine JS
937
+ // spread in a value (@click="f(...args)", :x="{ ...a }") is skipped. The <n>
938
+ // suffix (per tag) only keeps several spreads' attribute names distinct. A call
939
+ // (`...getProps()`) stops at the paren and is left alone - use :props="expr()"
940
+ const expandPropsSpread = (src: string): string =>
941
+ src
942
+ .split(RAW_BLOCK_RE)
943
+ .map((chunk, i) =>
944
+ i % 2 === 1
945
+ ? chunk
946
+ : chunk.replace(OPEN_TAG_RE, (_match, tag: string, attrs: string) => {
947
+ let n = 0
948
+ const rewritten = attrs.replace(ATTR_SPREAD_RE, (whole, space: string | undefined, expr: string | undefined) =>
949
+ expr === undefined ? whole : `${space}:props.${n++}="${expr}"`
950
+ )
951
+ return `<${tag}${rewritten}>`
952
+ })
953
+ )
954
+ .join("")
955
+
867
956
  // <style scoped> support. Every element of the component's own template is
868
957
  // stamped with data-jq79="<hash>" and the style's selectors are rewritten to
869
958
  // require that attribute, so its rules can't reach anything the component
@@ -947,8 +1036,11 @@ const parseComponentString = (component: string): ComponentParts => {
947
1036
  // </style>
948
1037
 
949
1038
  // parsed as the content of a <template> so leading <script>/<style> tags
950
- // aren't reparented into <head> by the HTML parser
951
- const parsedDOM = new DOMParser().parseFromString(`<template>${expandSelfClosingTags(component)}</template>`, "text/html")
1039
+ // aren't reparented into <head> by the HTML parser. Both pre-DOM string
1040
+ // rewrites run here: `...expr` -> :props.<n>="expr" first (it reads the raw
1041
+ // camelCase before the parser can lowercase names), then self-closing tags
1042
+ const prepared = expandSelfClosingTags(expandPropsSpread(component))
1043
+ const parsedDOM = new DOMParser().parseFromString(`<template>${prepared}</template>`, "text/html")
952
1044
  const root = parsedDOM.querySelector("template") as HTMLTemplateElement
953
1045
 
954
1046
  const scripts: TagBlock[] = []