feat(v4): settle the attribute grammar, and unify its four parsers - #859
Conversation
Four families had grown the same declarative shape with four independent parsers, and the thing that had to be settled before unifying them is whether the separators mean one thing each. Each looked like it meant two: a colon introducing the subject declared (`data-on:click`) or a variant of the thing on its left (`data-option-columns:s`), a dot introducing modifiers (`click.prevent`) or a name (`data-bind:prop.value`). The ruling is that the colon means one thing — pick one member of the vocabulary the namespace declares — and that the asymmetry is in what a namespace is. A namespace is fixed, written in a module, or generated, one per declared option, so `columns` owns `data-option-columns` and the colon after it picks a breakpoint exactly as the colon after `data-on` picks an event. What falls out is a checkable invariant: at most one colon per attribute, which makes `data-on:click:s` ill-formed rather than merely unimplemented and keeps the parse an `indexOf`. Aligning options onto `data-option:columns` was weighed and refused. It needs a second separator for the breakpoint, and both answers cost more than the uniformity buys: two colons give up the invariant, and `data-option:columns.s` moves the ambiguity onto the dot, which would then mean modifier, name and breakpoint. Dropping the prefix for `data-columns` was refused outright — `Action` declares an option called `on`, so `data-on` would be both the option and the handler namespace, and `isOptionAttribute()` could no longer tell an undeclared option from an attribute that was never ours. The ruling earns its keep by deciding the mechanism, which the flat "finite or open" framing could not: a generated namespace is enumerable because it comes from a declaration, so it is registered in the one `attributeFilter`, which is the argument gap 33 already made when it rejected `watchAttributes()` for responsive options. `RESPONSIVE_SEPARATOR` becomes `QUALIFIER_SEPARATOR` — one meaning, one name — and `attributes.ts` gains the predicates the grammar needs, so `isComponentAttribute` and the option cascade stop each spelling the prefix test themselves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
`Action.mounted()` and `AbstractTrack.mounted()` were the same fifteen lines down to the justifying comments — scan the attributes, key each binding by the name that produced it, watch the prefix, release the map on teardown — written independently by two ports of two unrelated families. `Data` was the same shape one generation behind, memoising its `data-bind:*` parse so an attribute rewritten in place kept its first reading forever: the exact bug `watchAttributes()` was built to fix and which the other two already consumed. `watchAttributeNamespace(el, namespace, bind, options?)` is that block, once. Declare the prefix, return each binding's release from the binder, and get the per-element observation, the keyed bindings, the declaration order preserved across a rewrite, and the teardown. All three families lose their scan, their watcher and their `#bind()`; `Action` keeps one release of its own for the binding derived from the `on`/`target`/`effect` triple, which is the one thing a namespace cannot own because it comes from three options rather than one attribute. `Data` gets the live rebinding it lacked: a `data-bind:*` rewritten, added or removed now takes effect on the value already in force. The mechanism follows from whether the whole set of names is enumerable, not from whether the qualifier vocabulary is finite, and `data-bind` is what settles that. Its six binding types are finite while the class, property or attribute name after the dot is not, so the names cannot be listed and the namespace must be watched. Validation is therefore an independent axis: an optional finite head vocabulary turns `data-bind:txet` from an attribute that silently did nothing into `attribute.unknown-qualifier`, the typo warning three of the four families had no version of. Responsive options keep their own registration rather than being wrapped in a mechanism selector. They are the only enumerable namespace, and their cascade, negation and `setBreakpoints()` replacement generalise to nothing — a switch with one caller on one side would be a facade. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
`ActionEvent` and `TrackEvent` each split the same modifier vocabulary over `'.'`, and Track's was Action's plus `throttle` — a superset, not a variant. The barrels already carried the evidence: `Track/index.ts` re-exported its `Modifier` as `TrackModifier` with a comment explaining the collision it was avoiding. `migration/event-modifiers.ts` is the one implementation. It stays in ui deliberately: core owns when a declaration is re-parsed and how the attribute is observed, while `prevent` and `throttle200` are product vocabulary, so what the parts of a qualifier mean belongs to whoever declared the namespace. The defaults are what actually differed between the two, so the parser reports only the delay an author wrote and each family keeps its own fallback — `Action` debounces at 100 and `Track` at 300 from the same modifier. `modifiers` becomes a `ReadonlySet`, which is what every consumer was doing with the array anyway. An unknown modifier now warns instead of being pushed through as if it were real, so a typo stops being silent on a listener that still bound. The migration report records J1, J2 and J3 as gap 44. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
Export sizeBundled per export with peer dependencies left external, dynamic imports excluded and the output minified; sizes are gzipped. @studiometa/js-toolkit-v4
Unchanged (372)@studiometa/js-toolkit
@studiometa/js-toolkit-v4
|
Code ReviewRisk: Low — No concrete defects were found in the reviewed source changes; the MR is safe to merge aside from any issues in files not opened here. The MR introduces a shared attribute-namespace watcher, settles qualified attribute parsing around a single colon, and unifies event modifier parsing for Action and Track. It also adds live rebinding for Data bindings, updates responsive option handling, and exposes the new primitives through package entry points. Reviewed: Notes:
Still open from earlier reviews (2 findings):
Review usage: 167,923 in (136,309 cached) / 2,323 out tokens — $0.0335 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 18f9193. Previous review runsPrevious run archived 2026-08-19T11:32:09ZCode ReviewRisk: Medium — issues that should be addressed before merge. The MR introduces a shared attribute-namespace watcher, settles namespace and qualifier parsing, and consolidates event modifier parsing across Action and Track. It also adds live rebinding for DataBind and updates the public exports and diagnostics contract. 1 issue found:
Notes:
Still open from earlier reviews (1 finding):
Review usage: 88,870 in (47,893 cached) / 2,293 out tokens — $0.0349 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 389a7b8. Previous run archived 2026-08-19T11:24:24ZCode ReviewRisk: Low — The change is safe to merge; no concrete defects were found in the reviewed implementation. This MR establishes the unified attribute grammar, adds the shared namespace watcher, migrates DataBind, Action, and Track to it, and consolidates event-modifier parsing. The reviewed code also preserves responsive option handling while renaming the separator terminology. Reviewed files: Still open from earlier reviews (1 finding):
Review usage: 82,316 in (58,557 cached) / 1,757 out tokens — $0.0230 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 0aaa2d1. Previous run archived 2026-08-19T10:08:04ZCode ReviewRisk: Medium — issues that should be addressed before merge. This change settles the attribute grammar, introduces a shared namespace watcher for Action, DataBind, and Track, and consolidates event modifier parsing. I reviewed 1 issue found:
Review usage: 121,291 in (86,317 cached) / 1,999 out tokens — $0.0322 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 88d8d4b. |
| continue; | ||
| } | ||
|
|
||
| if (!MODIFIER_NAMES.includes(part)) { |
There was a problem hiding this comment.
issue: Reject malformed timed modifier delays
Any part beginning with debounce or throttle is accepted as a timed modifier, and Number.parseInt can return NaN for inputs such as click.debounceoops or scroll.throttlex. The resulting handler is still enabled with a NaN timeout, which browsers treat as an immediate timer, so a malformed modifier silently changes event timing instead of being warned and ignored. Validate that the suffix is either empty or an integer before adding the modifier and delay.
Confidence: high.
Reviewed by @weareikko/code-review v0.9.5 for commit 88d8d4b.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #859 +/- ##
=======================================
Coverage 97.04% 97.04%
=======================================
Files 175 175
Lines 4535 4535
Branches 1323 1322 -1
=======================================
Hits 4401 4401
Misses 122 122
Partials 12 12
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
v4 mount benchmarksBase and head measured on this runner, alternating over 3 rounds each; every value is the median of the round medians. Running both sides on one machine is what removes cross-machine noise — a cached baseline from another runner would put it back. A move under 25%, or on a benchmark under 5 ms, is not reported as a change: it is inside the measured noise of a shared runner. No benchmark moved beyond the noise floor. Within noise (18)
|
v3 mount benchmarksBase and head measured on this runner, alternating over 3 rounds each; every value is the median of the round medians. Running both sides on one machine is what removes cross-machine noise — a cached baseline from another runner would put it back. A move under 25%, or on a benchmark under 5 ms, is not reported as a change: it is inside the measured noise of a shared runner. No benchmark moved beyond the noise floor. Within noise (10)
|
hasComponentAttribute() rebuilt the exact prefix check isInNamespace() already expresses, one call site left over from before the grammar was named. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
`check:package` keeps its own copy of the root export count, in `test/package-node-consumer.js`, deliberately separate from `exports.spec.ts` because `npm test`'s vitest run does not exercise it. Missed when the count moved to 84 for `watchAttributeNamespace` and `namespaceQualifier`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
|
|
||
| const rebind = (attribute: string, value: string | null): void => { | ||
| // Release first, whatever comes next: a rewritten attribute must not leave | ||
| // its previous binding attached, which is the whole point of watching. |
There was a problem hiding this comment.
issue: Reject qualifiers containing a second colon
namespaceQualifier() returns the entire suffix without validating it, so data-on:click:s is passed to the binder as qualifier click:s. With the open data-on namespace, Action consequently binds an event named click:s even though the settled grammar requires at most one colon and declares this spelling ill-formed. Validate and ignore or diagnose qualifiers containing QUALIFIER_SEPARATOR before invoking bind.
Confidence: high.
Reviewed by @weareikko/code-review v0.9.5 for commit 389a7b8.
Two findings from automated review, both confirmed against the code.
`namespaceQualifier()` returns everything after the first colon
unexamined, so `data-on:click:s` reached the binder as qualifier
`click:s`. With no declared vocabulary — `Action`'s case — nothing
stopped it: the settled grammar makes a second colon ill-formed, but
only `attributes.spec.ts` checked the invariant, not the primitive that
reads it. `watchAttributeNamespace()` now rejects a qualifier holding a
second `QUALIFIER_SEPARATOR` before it reaches a binder, warning once
through the same `attribute.unknown-qualifier` code the vocabulary check
already uses.
`parseEventDefinition()` matched a timed modifier on `startsWith`, so
`click.debounceoops` matched `debounce`, `Number.parseInt('oops', 10)`
produced `NaN`, and `delay('debounce')` returned `NaN` — not `undefined`,
so the `?? DEFAULT_DEBOUNCE_DELAY` fallback never ran. `setTimeout(fn,
NaN)` runs immediately in a browser, so a typo silently turned a
debounced listener into an unthrottled one. The prefix match becomes a
whole-token regex requiring the suffix to be digits or nothing, so a
malformed suffix falls through to the existing unknown-modifier warning
instead of parsing to `NaN`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
Closes J1, J2 and J3 of #780 — the declarative attribute language.
Four families had grown the same shape,
data-<ns>[-<subject>]:<qualifier>[.<part>…], with four independent parsers, one of them core's. This settles the grammar, replaces the duplicated mechanism with one primitive, and replaces the two modifier parsers with one.J1 — the grammar
Each separator looked like it meant two things: a colon introducing the subject declared (
data-on:click) or a variant of the thing on its left (data-option-columns:s); a dot introducing modifiers (click.prevent) or a name (data-bind:prop.value).The ruling is that the colon means one thing — pick one member of the vocabulary the namespace declares — and that the asymmetry is in what a namespace is, not in the separator. A namespace is fixed, written in a module (
data-component,data-on,data-bind), or generated, one per declared option, socolumnsownsdata-option-columnsand the colon after it picks a breakpoint exactly as the colon afterdata-onpicks an event.What falls out is a checkable invariant — at most one colon per attribute — which makes
data-on:click:sill-formed rather than merely unimplemented, keeps the parse anindexOf, and is pinned by a spec. The dot splits the qualifier into parts and core reads none of them: core owns when a declaration is re-parsed and how the attribute is observed, the namespace's owner owns what the string means.RESPONSIVE_SEPARATORbecomesQUALIFIER_SEPARATOR. One meaning, one name.Two alternatives were weighed and refused
data-option:columnsneeds a second separator for the breakpoint, and both answers cost more than the uniformity buys.data-option:columns:sgives up the invariant;data-option:columns.smoves the ambiguity onto the dot, which would then mean modifier, name and breakpoint. It also puts a colon on every option instead of on the rare scoped spelling, costingdataset.optionColumnsfor the base value, escaping in selectors, and a special character in every template that sets an option.data-columnsclaims the wholedata-*space.Actiondeclares an option calledon, sodata-onwould be both the option and the handler namespace; it collides with everything else on the page that writesdata-*; and it deletesisOptionAttribute(), so shape stops telling an undeclared option from an attribute that was never ours.J2 —
watchAttributeNamespace()Action.mounted()andAbstractTrack.mounted()were the same fifteen lines down to the justifying comments, written independently by two ports of two unrelated families.Datawas the same shape one generation behind, memoising itsdata-bind:*parse so an attribute rewritten in place kept its first reading forever — the exact bugwatchAttributes()was built to fix, and which the other two already consumed.Declare the prefix, return each binding's release from the binder, and get the per-element observation, the bindings keyed by the attribute that produced them, the declaration order preserved across a rewrite, and the teardown. One code path covers all three shapes a change takes: added attaches with nothing to release, changed releases then attaches, removed releases with nothing to attach.
#bind().Actionkeeps one release of its own for the binding derived from theon/target/effecttriple — the one thing a namespace cannot own, because it comes from three options rather than one attribute.Datagets the live rebinding it lacked: adata-bind:*rewritten, added or removed now takes effect on the value already in force. Three specs on it.One correction to the issue's framing
J2 asked for "declare whether the qualifiers are finite or open, and get the mechanism chosen for you". That is not the axis, and
data-bindis what settles it: its six binding types are finite while the class, property or attribute name after the dot is not, so its names cannot be listed and it must watch-and-rebind despite a finite vocabulary.The real rule is enumerability of the whole name, and it follows from J1's two kinds of namespace:
data-option-columnsattribute × breakpoint— enumerableattributeFilterdata-componentattributeFilterdata-on,data-trackwatchAttributeNamespace()data-bindwatchAttributeNamespace()A generated namespace is enumerable because it comes from a declaration, which is the argument gap 33 already made when it rejected
watchAttributes()for responsive options. So responsive options keep their own registration and consume only the grammar helpers: they are the only enumerable namespace, and their cascade, negation andsetBreakpoints()replacement generalise to nothing — a mechanism selector with one caller on one side would be a facade.Validation is an independent axis, available to a watched namespace and a registered one alike. An optional finite head vocabulary turns
data-bind:txetfrom an attribute that silently does nothing intoattribute.unknown-qualifier— the typo warning three of the four families had no version of.G6 (gap 28) is absorbed by the same ruling:
with<Service>names one hook per class, so a component whose subscriptions are declared in markup cannot use it, and a namespace is how those subscriptions are read instead.J3 — one modifier parser in ui
ActionEventandTrackEventeach split the same vocabulary over'.', and Track's was Action's plusthrottle— a superset, not a variant. The barrels already carried the evidence:Track/index.tsre-exported itsModifierasTrackModifierwith a comment explaining the collision it was avoiding. That collision is gone.migration/event-modifiers.tsstays in ui deliberately —preventandthrottle200are product vocabulary, not framework concepts.The defaults are what actually differed, so the parser reports only the delay an author wrote and each family keeps its own fallback:
Actiondebounces at 100 andTrackat 300 from the same modifier.modifiersbecomes aReadonlySet, which is what every consumer was doing with the array anyway. An unknown modifier now warns instead of being pushed through as if it were real.Verification
npm test -w @studiometa/js-toolkit-v4— 1409 passed, 91 filestsc -p tsconfig.json && tsc -p tsconfig.coexistence.json— cleanoxlint --type-aware packages/v4— clean (the onesrc/context.tswarning is pre-existing and untouched)oxfmt --check .andnpm run subpaths:check— cleannpm test -w @studiometa/eslint-plugin-js-toolkit— 55 passedNew specs: the one-colon invariant and the grammar predicates in
attributes.spec.ts, ten on the primitive inattribute-namespaces.spec.ts(including declaration order across a rewrite and warn-once), three on livedata-bindrebinding, and the parser's own inTrackEvent.spec.ts.The root entry goes from 82 to 84 exports (
watchAttributeNamespace,namespaceQualifier), each with its generated subpath.🤖 Generated with Claude Code
https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9