jq79 0.4.16 → 0.4.18

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.16",
3
+ "version": "0.4.18",
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
@@ -7,6 +7,14 @@ import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseF
7
7
 
8
8
  export { $, $$, $create } from "./dom"
9
9
  export { $reactive } from "./reactive"
10
+
11
+ // the package version, substituted at build time (tsup/vitest `define`, read
12
+ // from package.json - releases bump it there and nowhere else). The typeof
13
+ // guard is what keeps the raw source runnable: tests and any bundler that
14
+ // doesn't define it see a bare identifier, not a ReferenceError
15
+ declare const __JQ79_VERSION__: string
16
+ const VERSION = typeof __JQ79_VERSION__ === "string" ? __JQ79_VERSION__ : "0.0.0-dev"
17
+
10
18
  type TemplateNode = {
11
19
  tag: string
12
20
  attrs: Record<string, string>
@@ -174,6 +182,12 @@ const wireTagEvent = (instance: Component79, attr: string, expr: string, scope:
174
182
 
175
183
  const kebabToCamel = (name: string) => name.replace(/-(\w)/g, (_, c: string) => c.toUpperCase())
176
184
 
185
+ // the inverse, used only by the pre-parse name rewrite (see expandNameCase):
186
+ // uppercase ASCII letters only, never digits - `:props.0` is a generated
187
+ // attribute name and splitting on digits would mangle it. Round-trips through
188
+ // kebabToCamel, acronyms included: userID -> user-i-d -> userID
189
+ const camelToKebab = (name: string) => name.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)
190
+
177
191
  // the stable boundaries of a rendered chunk. An element is its own handle, but
178
192
  // a fragment (a nested component: two anchors with the instance's DOM between
179
193
  // them) empties itself into the parent on insertion - after that its identity
@@ -281,10 +295,11 @@ type SlotMap = Record<string, SlotRenderer>
281
295
  // nested component is handed
282
296
  const SLOTS = Symbol("jq79.slots")
283
297
 
284
- // <slot>, <slot.header-bar>: the hole and its name. Names are kebab-case where
285
- // written (the HTML parser lowercases tag names and attribute modifiers alike)
286
- // and camelCase where read - <slot.header-bar> is :slot.header-bar is
287
- // $slots.headerBar
298
+ // <slot>, <slot.header-bar>: the hole and its name. Names arrive kebab-case
299
+ // whichever way they were authored (the HTML parser lowercases tag names and
300
+ // attribute modifiers alike, so expandNameCase normalizes camelCase to kebab
301
+ // before parsing) and are camelCase where read - <slot.header-bar> and
302
+ // <slot.headerBar> are :slot.header-bar is $slots.headerBar
288
303
  const isSlotTag = (tag: string): boolean => tag === "slot" || tag.startsWith("slot.")
289
304
 
290
305
  const slotName = (suffix: string): string => (suffix ? kebabToCamel(suffix) : "default")
@@ -507,8 +522,8 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
507
522
  } else if (attr === ":model" || attr.startsWith(":model.")) {
508
523
  // :model[.name]="expr" - two-way: a prop down plus a writeback listener
509
524
  // (wired below, once the instance exists). The modifier arrives
510
- // lowercased from the HTML parser, so names are declared kebab-case;
511
- // the bare :model binds the name "default"
525
+ // kebab-case whichever way it was authored (expandNameCase rewrote any
526
+ // camelCase before parsing); the bare :model binds the name "default"
512
527
  const name = attr === ":model" ? "default" : kebabToCamel(attr.slice(":model.".length))
513
528
  models[name] = value || (attr === ":model" ? "model" : name)
514
529
  } else if (attr.startsWith(":")) {
@@ -665,7 +680,12 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
665
680
  // explicit re-emit)
666
681
  events.forEach(([attr, expr]) => wireTagEvent(instance, attr, expr, scope))
667
682
 
668
- const seed = untracked(resolveProps)
683
+ // what this component actually takes, decided by its signature. Applied to
684
+ // every path that writes a prop - the seed here and both sync paths below -
685
+ // or an undeclared name would be filtered on the first render and reappear
686
+ // on the next update
687
+ const declared = declaredPropSet(instance.scripts)
688
+ const seed = pickDeclared(untracked(resolveProps), declared)
669
689
  // mounting into a fragment attaches no shadow root of its own: a
670
690
  // shadow-rendered child keeps its <style> elements inline, next to the DOM
671
691
  // they style, and the parent's shadow root is what scopes both
@@ -701,7 +721,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
701
721
  if (hasSpread) {
702
722
  let written: string[] = []
703
723
  syncFx.effect(() => {
704
- const next = resolveProps()
724
+ const next = pickDeclared(resolveProps(), declared)
705
725
  const nextKeys = Object.keys(next)
706
726
  written.forEach(key => { if (!(key in next)) (instance.data as Record<string, any>)[key] = undefined })
707
727
  nextKeys.forEach(key => { (instance.data as Record<string, any>)[key] = next[key] })
@@ -709,6 +729,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
709
729
  })
710
730
  } else {
711
731
  Object.entries(props).forEach(([name, expr]) => {
732
+ if (declared !== null && !declared.has(name)) return
712
733
  syncFx.effect(() => { (instance.data as Record<string, any>)[name] = evalExpr(expr, scope) })
713
734
  })
714
735
  }
@@ -1269,6 +1290,53 @@ const expandPropsSpread = (src: string): string =>
1269
1290
  )
1270
1291
  .join("")
1271
1292
 
1293
+ // a `:`-prefixed attribute name in name position, and a </slot.name> closing
1294
+ // tag. Both quote-aware for the same reason ATTR_SPREAD_RE is: a colon inside
1295
+ // a value (@click="a ? b : c", style="color: red") is not an attribute name
1296
+ const ATTR_NAME_RE = /"[^"]*"|'[^']*'|(^|\s)(:[\w.$-]+)/g
1297
+ const CLOSE_SLOT_RE = /<\/slot\.([\w.$-]+)(\s*)>/gi
1298
+ const SLOT_TAG_RE = /^slot\./i
1299
+
1300
+ // camelCase -> kebab-case for every name the HTML parser would lowercase,
1301
+ // BEFORE it gets the chance: `:firstName` would arrive as `:firstname` and
1302
+ // kebabToCamel (which is what reads these names back out) would have nothing
1303
+ // to un-kebab, so the prop, model or slot would silently land under the wrong
1304
+ // key. Rewriting to `:first-name` here means both spellings converge on the
1305
+ // same camelCase name downstream - the author picks, the runtime doesn't care.
1306
+ //
1307
+ // Runs FIRST among the pre-parse passes, which is what keeps it simple: it
1308
+ // never sees the `:props.<n>` that expandPropsSpread generates, and a
1309
+ // <slot.firstName /> is still one occurrence rather than the open+close pair
1310
+ // expandSelfClosingTags turns it into. Same defenses as the passes after it -
1311
+ // <script>/<style> bodies split out, only start-tag interiors scanned, quoted
1312
+ // values consumed whole.
1313
+ //
1314
+ // Two name positions, not one: attribute names (`:model.firstName`) and the
1315
+ // dotted tag names (`<slot.firstName>`), whose closing halves are rewritten
1316
+ // too or the parser sees a mismatched pair. Component tags are deliberately
1317
+ // left alone - findComponentKey already matches them case-insensitively with
1318
+ // dashes stripped, so <UserCard> needs no help and rewriting it would only
1319
+ // obscure what the author wrote
1320
+ const kebabTagName = (tag: string): string =>
1321
+ SLOT_TAG_RE.test(tag) ? `slot.${camelToKebab(tag.slice("slot.".length))}` : tag
1322
+
1323
+ const expandNameCase = (src: string): string =>
1324
+ src
1325
+ .split(RAW_BLOCK_RE)
1326
+ .map((chunk, i) =>
1327
+ i % 2 === 1
1328
+ ? chunk
1329
+ : chunk
1330
+ .replace(OPEN_TAG_RE, (_match, tag: string, attrs: string) => {
1331
+ const rewritten = attrs.replace(ATTR_NAME_RE, (whole, space: string | undefined, name: string | undefined) =>
1332
+ name === undefined ? whole : `${space}${camelToKebab(name)}`
1333
+ )
1334
+ return `<${kebabTagName(tag)}${rewritten}>`
1335
+ })
1336
+ .replace(CLOSE_SLOT_RE, (_match, suffix: string, space: string) => `</slot.${camelToKebab(suffix)}${space}>`)
1337
+ )
1338
+ .join("")
1339
+
1272
1340
  // <style scoped> support. Every element of the component's own template is
1273
1341
  // stamped with data-jq79="<hash>" and the style's selectors are rewritten to
1274
1342
  // require that attribute, so its rules can't reach anything the component
@@ -1359,10 +1427,13 @@ const parseComponentString = (component: string): ComponentParts => {
1359
1427
  // </style>
1360
1428
 
1361
1429
  // parsed as the content of a <template> so leading <script>/<style> tags
1362
- // aren't reparented into <head> by the HTML parser. Both pre-DOM string
1363
- // rewrites run here: `...expr` -> :props.<n>="expr" first (it reads the raw
1364
- // camelCase before the parser can lowercase names), then self-closing tags
1365
- const prepared = expandSelfClosingTags(expandPropsSpread(component))
1430
+ // aren't reparented into <head> by the HTML parser. All three pre-DOM string
1431
+ // rewrites run here, and the order is load-bearing: camelCase names ->
1432
+ // kebab-case first (before `:props.<n>` exists to be mangled and while a
1433
+ // self-closing tag is still one occurrence), then `...expr` -> :props.<n>
1434
+ // (which reads the raw camelCase before the parser can lowercase names),
1435
+ // then self-closing tags
1436
+ const prepared = expandSelfClosingTags(expandPropsSpread(expandNameCase(component)))
1366
1437
  const parsedDOM = new DOMParser().parseFromString(`<template>${prepared}</template>`, "text/html")
1367
1438
  const root = parsedDOM.querySelector("template") as HTMLTemplateElement
1368
1439
 
@@ -1585,6 +1656,36 @@ const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
1585
1656
  return names
1586
1657
  }
1587
1658
 
1659
+ // the same names, but null when NO script declared a signature at all - the
1660
+ // distinction declareProps already keeps, and the only one that can decide
1661
+ // whether to filter what a parent passes: `<script :setup>` declares nothing
1662
+ // and stays permissive, `<script :setup="{}">` is a closed signature that
1663
+ // declares zero props and takes none
1664
+ const declaredPropSet = (scripts: TagBlock[]): Set<string> | null => {
1665
+ let names: Set<string> | null = null
1666
+ scripts.forEach(script => {
1667
+ const declarations = parseFactoryProps(script.content) ?? parsePropsPattern(script.attrs[":setup"])
1668
+ if (!declarations) return
1669
+ const into = (names ??= new Set())
1670
+ declarations.forEach(({ name }) => into.add(name))
1671
+ })
1672
+ return names
1673
+ }
1674
+
1675
+ // drops the props a component didn't declare, so an undeclared name is simply
1676
+ // absent from its store rather than quietly present: `{{ label }}` renders
1677
+ // empty and `{{ user.name }}` throws on the member access, both at the usage
1678
+ // site that got the name wrong. A null signature keeps everything - see
1679
+ // declaredPropSet. Silent by design: the main source of extra keys is a
1680
+ // `:props` spread of an object wider than the component (`...sdk`), where
1681
+ // taking only the declared few is the point, not a mistake to report
1682
+ const pickDeclared = (props: Record<string, any>, declared: Set<string> | null): Record<string, any> => {
1683
+ if (declared === null) return props
1684
+ const out: Record<string, any> = {}
1685
+ Object.keys(props).forEach(key => { if (declared.has(key)) out[key] = props[key] })
1686
+ return out
1687
+ }
1688
+
1588
1689
  // the sibling components this one resolves by name, or null when there are
1589
1690
  // none left to resolve. They go on the store's *prototype* rather than in it:
1590
1691
  // the component-key scan walks the chain, so <Row> resolves; they stay out of
@@ -1775,6 +1876,10 @@ const fetchComponent = async (url: string): Promise<Component79> => {
1775
1876
  // jq79.detach() // detach, keeping state - mount() re-attaches
1776
1877
  // .destroy() // dispose effects and remove styles
1777
1878
  export class Component79 {
1879
+ // the version of jq79 this class came from, so a page can tell which build it
1880
+ // loaded (a CDN <script> pins nothing on its own)
1881
+ static readonly version: string = VERSION
1882
+
1778
1883
  template: TemplateNode[]
1779
1884
  scripts: TagBlock[]
1780
1885
  styles: TagBlock[]