jq79 0.4.17 → 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.17",
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
@@ -182,6 +182,12 @@ const wireTagEvent = (instance: Component79, attr: string, expr: string, scope:
182
182
 
183
183
  const kebabToCamel = (name: string) => name.replace(/-(\w)/g, (_, c: string) => c.toUpperCase())
184
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
+
185
191
  // the stable boundaries of a rendered chunk. An element is its own handle, but
186
192
  // a fragment (a nested component: two anchors with the instance's DOM between
187
193
  // them) empties itself into the parent on insertion - after that its identity
@@ -289,10 +295,11 @@ type SlotMap = Record<string, SlotRenderer>
289
295
  // nested component is handed
290
296
  const SLOTS = Symbol("jq79.slots")
291
297
 
292
- // <slot>, <slot.header-bar>: the hole and its name. Names are kebab-case where
293
- // written (the HTML parser lowercases tag names and attribute modifiers alike)
294
- // and camelCase where read - <slot.header-bar> is :slot.header-bar is
295
- // $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
296
303
  const isSlotTag = (tag: string): boolean => tag === "slot" || tag.startsWith("slot.")
297
304
 
298
305
  const slotName = (suffix: string): string => (suffix ? kebabToCamel(suffix) : "default")
@@ -515,8 +522,8 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
515
522
  } else if (attr === ":model" || attr.startsWith(":model.")) {
516
523
  // :model[.name]="expr" - two-way: a prop down plus a writeback listener
517
524
  // (wired below, once the instance exists). The modifier arrives
518
- // lowercased from the HTML parser, so names are declared kebab-case;
519
- // 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"
520
527
  const name = attr === ":model" ? "default" : kebabToCamel(attr.slice(":model.".length))
521
528
  models[name] = value || (attr === ":model" ? "model" : name)
522
529
  } else if (attr.startsWith(":")) {
@@ -673,7 +680,12 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
673
680
  // explicit re-emit)
674
681
  events.forEach(([attr, expr]) => wireTagEvent(instance, attr, expr, scope))
675
682
 
676
- 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)
677
689
  // mounting into a fragment attaches no shadow root of its own: a
678
690
  // shadow-rendered child keeps its <style> elements inline, next to the DOM
679
691
  // they style, and the parent's shadow root is what scopes both
@@ -709,7 +721,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
709
721
  if (hasSpread) {
710
722
  let written: string[] = []
711
723
  syncFx.effect(() => {
712
- const next = resolveProps()
724
+ const next = pickDeclared(resolveProps(), declared)
713
725
  const nextKeys = Object.keys(next)
714
726
  written.forEach(key => { if (!(key in next)) (instance.data as Record<string, any>)[key] = undefined })
715
727
  nextKeys.forEach(key => { (instance.data as Record<string, any>)[key] = next[key] })
@@ -717,6 +729,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
717
729
  })
718
730
  } else {
719
731
  Object.entries(props).forEach(([name, expr]) => {
732
+ if (declared !== null && !declared.has(name)) return
720
733
  syncFx.effect(() => { (instance.data as Record<string, any>)[name] = evalExpr(expr, scope) })
721
734
  })
722
735
  }
@@ -1277,6 +1290,53 @@ const expandPropsSpread = (src: string): string =>
1277
1290
  )
1278
1291
  .join("")
1279
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
+
1280
1340
  // <style scoped> support. Every element of the component's own template is
1281
1341
  // stamped with data-jq79="<hash>" and the style's selectors are rewritten to
1282
1342
  // require that attribute, so its rules can't reach anything the component
@@ -1367,10 +1427,13 @@ const parseComponentString = (component: string): ComponentParts => {
1367
1427
  // </style>
1368
1428
 
1369
1429
  // parsed as the content of a <template> so leading <script>/<style> tags
1370
- // aren't reparented into <head> by the HTML parser. Both pre-DOM string
1371
- // rewrites run here: `...expr` -> :props.<n>="expr" first (it reads the raw
1372
- // camelCase before the parser can lowercase names), then self-closing tags
1373
- 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)))
1374
1437
  const parsedDOM = new DOMParser().parseFromString(`<template>${prepared}</template>`, "text/html")
1375
1438
  const root = parsedDOM.querySelector("template") as HTMLTemplateElement
1376
1439
 
@@ -1593,6 +1656,36 @@ const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
1593
1656
  return names
1594
1657
  }
1595
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
+
1596
1689
  // the sibling components this one resolves by name, or null when there are
1597
1690
  // none left to resolve. They go on the store's *prototype* rather than in it:
1598
1691
  // the component-key scan walks the chain, so <Row> resolves; they stay out of