jq79 0.5.0 → 0.5.1

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.5.0",
3
+ "version": "0.5.1",
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
@@ -689,6 +689,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
689
689
  // or an undeclared name would be filtered on the first render and reappear
690
690
  // on the next update
691
691
  const declared = declaredPropSet(instance.scripts)
692
+ warnUndeclared(node, key, Object.keys(props), declared)
692
693
  const seed = pickDeclared(untracked(resolveProps), declared)
693
694
  // mounting into a fragment attaches no shadow root of its own: a
694
695
  // shadow-rendered child keeps its <style> elements inline, next to the DOM
@@ -819,6 +820,41 @@ const normalizeAllowUrl = (policy: any): AllowUrl => {
819
820
  return () => false
820
821
  }
821
822
 
823
+ // HTML's boolean attributes, verbatim from the spec's list. Presence is the
824
+ // whole message for these: `disabled="false"` and `disabled="0"` both disable,
825
+ // so the value they carry is noise. This is a table of a fact, not of a jq79
826
+ // convention - nobody in this repo decides what belongs in it, which is what
827
+ // earns it a place in a codebase that otherwise has no name tables
828
+ const BOOLEAN_ATTRS = new Set([
829
+ "allowfullscreen", "async", "autofocus", "autoplay", "checked", "controls",
830
+ "default", "defer", "disabled", "formnovalidate", "inert", "ismap",
831
+ "itemscope", "loop", "multiple", "muted", "nomodule", "novalidate", "open",
832
+ "playsinline", "readonly", "required", "reversed", "selected",
833
+ ])
834
+
835
+ // the one value rule, shared by `:attr="expr"` and `:attrs` so the two forms
836
+ // can never disagree:
837
+ //
838
+ // - a boolean attribute is removed by ANY falsy value and set to "" when
839
+ // truthy, so `:disabled="items.length"` enables the button on an empty list
840
+ // (with `value !== false` as the only test, 0 set the attribute and disabled
841
+ // it - the trap renderComponent.test.ts used to pin);
842
+ // - every other attribute is removed only by null/undefined, so `false`, `0`
843
+ // and `""` are written. `aria-expanded="false"` and a `data-` flag mean
844
+ // something that absent cannot say.
845
+ //
846
+ // Asking the DOM which family a name belongs to (`typeof el[name] ===
847
+ // "boolean"`) is deliberately not what this does: jsdom and Chrome disagree on
848
+ // `autofocus` and every `aria-*`, so the tests would pin a semantics the
849
+ // browser doesn't have - and `readonly`/`novalidate`/`ismap` reflect under
850
+ // camelCase property names no kebab->camel pass can produce, failing toward
851
+ // `readonly="false"`, which is read-only
852
+ const applyAttr = (el: Element, name: string, value: any) => {
853
+ const boolean = BOOLEAN_ATTRS.has(name)
854
+ if (boolean ? !value : value == null) el.removeAttribute(name)
855
+ else el.setAttribute(name, boolean ? "" : String(value))
856
+ }
857
+
822
858
  // renders a single element node: static attrs, @event listeners, a reactive
823
859
  // :attrs object, and its content - :text/:html override the element's own
824
860
  // children with a reactive textContent/innerHTML, otherwise children render
@@ -848,7 +884,11 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
848
884
  // imported component after `await`). Watch for the key: the effect tracks
849
885
  // no deps, so it only re-runs on the store's new-key sweep, and swaps the
850
886
  // placeholder element for the component exactly once
851
- if (el instanceof HTMLUnknownElement || node.tag.includes("-")) {
887
+ // dashes included, because findComponentKey matches them case-insensitively
888
+ // with dashes stripped: <drop-area> resolves DropArea, so a dashed tag is a
889
+ // possible component too, not only a custom element
890
+ const mayUpgrade = el instanceof HTMLUnknownElement || node.tag.includes("-")
891
+ if (mayUpgrade) {
852
892
  let upgraded = false
853
893
  fx.effect(() => {
854
894
  if (upgraded) return
@@ -871,10 +911,30 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
871
911
  // the native-element form is parked there). Warn on a real element, but
872
912
  // not on a tag that may still upgrade into a component - the upgrade
873
913
  // re-renders through renderNestedComponent, models and all
874
- if (!(el instanceof HTMLUnknownElement || node.tag.includes("-"))) {
914
+ if (!mayUpgrade) {
875
915
  console.warn(`jq79: ${key} on <${node.tag}> does nothing - :model binds component tags only (for now)`)
876
916
  }
877
- } else if (!isControlAttr(key)) el.setAttribute(key, value)
917
+ } else if (isControlAttr(key)) {
918
+ // a directive of its own, bound further down (or by renderNodes)
919
+ } else if (key.startsWith(":")) {
920
+ // :name="expr" binds that one attribute, reactively - the single-key
921
+ // case :attrs="{ name: expr }" was carrying. `:name` alone is shorthand
922
+ // for `:name="name"`, like props and :model.<name>, and the shorthand
923
+ // reads the camelCase variable while the attribute keeps its written
924
+ // (kebab) name: `:aria-expanded` binds `ariaExpanded`, because
925
+ // `aria-expanded` as an expression is a subtraction.
926
+ //
927
+ // On a tag that may still upgrade this is a *parameter*, not an
928
+ // attribute: leave it written verbatim, as before, so the upgrade's
929
+ // renderNestedComponent still finds it. A component tag has no single
930
+ // root for an attribute to land on anyway (TODOS/2026-07-15.class-directive.md)
931
+ if (mayUpgrade) el.setAttribute(key, value)
932
+ else {
933
+ const name = key.slice(1)
934
+ const expr = value || kebabToCamel(name)
935
+ fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
936
+ }
937
+ } else el.setAttribute(key, value)
878
938
  })
879
939
 
880
940
  const bindExpr = node.attrs[":attrs"]
@@ -885,10 +945,7 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
885
945
  boundKeys.forEach(key => el.removeAttribute(key))
886
946
  const bound = evalExpr(bindExpr, scope)
887
947
  boundKeys = bound && typeof bound === "object" ? Object.keys(bound) : []
888
- boundKeys.forEach(key => {
889
- const value = bound[key]
890
- if (value != null && value !== false) el.setAttribute(key, String(value))
891
- })
948
+ boundKeys.forEach(key => applyAttr(el, key, bound[key]))
892
949
  })
893
950
  }
894
951
 
@@ -1646,6 +1703,22 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
1646
1703
  })
1647
1704
  }
1648
1705
 
1706
+ // a setup script's signature. A bare `<script :setup>` is a CLOSED signature -
1707
+ // the same as `<script :setup="{}">`, declaring zero props and taking none -
1708
+ // because the difference between "takes nothing" and "takes anything" should
1709
+ // not be a pair of braces somebody didn't type. Permissive is still reachable,
1710
+ // it just has to be asked for: `<script :setup="_">`, the same `_` convention
1711
+ // factory scripts already use, which parsePropsPattern reads as no signature.
1712
+ //
1713
+ // Only the empty *value* is closed. An absent attribute (a factory <script>
1714
+ // with no :setup at all) stays `null`, so its signature is still read from the
1715
+ // factory's first parameter
1716
+ const setupSignature = (script: TagBlock): PropDecl[] | null => {
1717
+ const pattern = script.attrs[":setup"]
1718
+ if (pattern === undefined) return null
1719
+ return pattern.trim() === "" ? [] : parsePropsPattern(pattern)
1720
+ }
1721
+
1649
1722
  // every prop name a component's scripts declare, across both script modes.
1650
1723
  // Read before the store exists, because what a component declares decides
1651
1724
  // which of its file's sibling components it can still see: declaring a name
@@ -1654,7 +1727,7 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
1654
1727
  const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
1655
1728
  const names = new Set<string>()
1656
1729
  scripts.forEach(script => {
1657
- const declarations = parseFactoryProps(script.content) ?? parsePropsPattern(script.attrs[":setup"])
1730
+ const declarations = parseFactoryProps(script.content) ?? setupSignature(script)
1658
1731
  declarations?.forEach(({ name }) => names.add(name))
1659
1732
  })
1660
1733
  return names
@@ -1662,13 +1735,13 @@ const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
1662
1735
 
1663
1736
  // the same names, but null when NO script declared a signature at all - the
1664
1737
  // distinction declareProps already keeps, and the only one that can decide
1665
- // whether to filter what a parent passes: `<script :setup>` declares nothing
1666
- // and stays permissive, `<script :setup="{}">` is a closed signature that
1667
- // declares zero props and takes none
1738
+ // whether to filter what a parent passes. `<script :setup>` and
1739
+ // `<script :setup="{}">` are both closed signatures that take nothing (see
1740
+ // setupSignature); `<script :setup="_">` is the permissive one
1668
1741
  const declaredPropSet = (scripts: TagBlock[]): Set<string> | null => {
1669
1742
  let names: Set<string> | null = null
1670
1743
  scripts.forEach(script => {
1671
- const declarations = parseFactoryProps(script.content) ?? parsePropsPattern(script.attrs[":setup"])
1744
+ const declarations = parseFactoryProps(script.content) ?? setupSignature(script)
1672
1745
  if (!declarations) return
1673
1746
  const into = (names ??= new Set())
1674
1747
  declarations.forEach(({ name }) => into.add(name))
@@ -1690,6 +1763,30 @@ const pickDeclared = (props: Record<string, any>, declared: Set<string> | null):
1690
1763
  return out
1691
1764
  }
1692
1765
 
1766
+ // names already reported by warnUndeclared, keyed by the template node - which
1767
+ // is the usage site itself, built once and shared by every instance it ever
1768
+ // renders. So a :each over 200 rows says it once, not once per row, and a
1769
+ // definition swap doesn't repeat what the last one already said
1770
+ const undeclaredWarned = new WeakMap<TemplateNode, Set<string>>()
1771
+
1772
+ // a parameter the child's signature doesn't declare is dropped by pickDeclared
1773
+ // and never reaches its store - `{{ bar }}` renders empty at the other end of
1774
+ // the file. Written parameters only: this is handed the named ones (`:bar`,
1775
+ // and the prop each :model binds), never a `:props` spread's keys, because a
1776
+ // spread of an object wider than the component is the documented, intended use
1777
+ // and taking only the declared few is its point - see pickDeclared. A
1778
+ // component with no signature at all declares nothing to compare against
1779
+ const warnUndeclared = (node: TemplateNode, name: string, written: string[], declared: Set<string> | null) => {
1780
+ if (declared === null) return
1781
+ const said = undeclaredWarned.get(node) ?? new Set<string>()
1782
+ undeclaredWarned.set(node, said)
1783
+ written.forEach(prop => {
1784
+ if (declared.has(prop) || said.has(prop)) return
1785
+ said.add(prop)
1786
+ console.warn(`jq79: :${prop} is not declared by <${name}> - add it to the :setup signature, or drop it`)
1787
+ })
1788
+ }
1789
+
1693
1790
  // the sibling components this one resolves by name, or null when there are
1694
1791
  // none left to resolve. They go on the store's *prototype* rather than in it:
1695
1792
  // the component-key scan walks the chain, so <Row> resolves; they stay out of
@@ -2217,7 +2314,7 @@ export class Component79 {
2217
2314
  return
2218
2315
  }
2219
2316
  const { vars, code } = transformSetupScript(script.content)
2220
- declareProps(store, parsePropsPattern(script.attrs[":setup"]))
2317
+ declareProps(store, setupSignature(script))
2221
2318
  // pre-declare script vars on the store so `with` resolves assignments
2222
2319
  // to them (and reads of them) through the reactive proxy
2223
2320
  vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })