on_the_money 0.3.6 → 0.5.2

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/CHANGELOG.md CHANGED
@@ -4,6 +4,67 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.5.2] — 2026-05-23
8
+
9
+ ### Documentation
10
+
11
+ - **Added the "Discipline" section to the README.** Names the framework's three state-response mechanisms explicitly: (1) CSS attribute selectors for state→visual, (2) imperative API calls colocated with state writes for one-off platform side effects, (3) `MutationObserver` for DRY of imperative responses across many call sites. Lists the right-vs-wrong matrix for `MutationObserver` (imperative-only responses: yes; CSS's job: no). Ships the "deletion test" self-check. Documents the element-as-state-carrier pattern (`<dialog open>`, `<details open>`, etc.) where state lives on the element with native CSS hooks. (#80)
12
+ - **Reframed the existing "Reacting to state changes" pattern** as "Imperative dispatch from many sites (mechanism #3)" — same example, sharper framing as a case of the three-mechanism vocabulary, with an explicit "before reaching for this pattern, check whether CSS can do the job" warning.
13
+ - **ESLint rule messages now reference §The Discipline** so consumers hitting a lint failure can read the architectural why in one place, not just the local rule hint.
14
+
15
+ ### Why
16
+
17
+ The framework rejects subscriber/signal/effect patterns but the discipline was implicit. LLM pretraining is heavily biased toward those patterns; naming the trap explicitly (with the three-mechanism vocabulary as the alternative) gives consumers and LLM authors a concrete framework to reach for instead. The element-as-state-carrier section is the highest-leverage addition — most consumers don't realize `<dialog open>` is already a state attribute with CSS hooks.
18
+
19
+ ## [0.5.1] — 2026-05-23
20
+
21
+ ### Documentation
22
+
23
+ - **Clarified the "DOM is the database" mandate.** Reframed to acknowledge that the database claim applies to UI signal state (theme, page, modal-open, form-error) — not to structured data, which lives in JS. The original line implied more than the framework actually does; the examples already show comments stored in a `Map` and posts in an array. Removes an overclaim without losing the soul.
24
+
25
+ ## [0.5.0] — 2026-05-23
26
+
27
+ ### Breaking
28
+
29
+ - **Persistence is now opt-in.** Default behavior writes attributes only — nothing persists to `localStorage`. Declare `the.boot({ persistKeys: [...] })` to opt specific keys in. Replaces `ephemeralKeys` (the deny-list polarity). Consumers writing theme/locale-flavored apps pass `persistKeys: ["theme", "lang"]`; consumers writing page-render apps pass nothing or just `["lang"]`. The deny-list polarity was wrong for modern apps — the default punished state-as-signal patterns. (#69)
30
+ - **Keys auto-convert to kebab-case** for attribute writes and `[data-text]` lookups. `the("chapterHasNav", x)` writes `data-chapter-has-nav`; `the("chapter_has_nav", x)` also normalizes to kebab. Round-trips via the platform's `dataset.chapterHasNav` accessor. CSS selectors and `[data-text="..."]` slot values must use kebab-case to match what JS writes. Single-word and already-kebab keys are unchanged. Single-word ARIA shortcut keys (`expanded`, etc.) still map correctly. (#70)
31
+
32
+ ### Added
33
+
34
+ - **`the.match(pattern, path?)`** — Express-style `:name` segment extraction. Returns `{name: value}` (URI-decoded) or `null`. Scope: `:name` only — no optional, no regex constraints, no wildcards. (#71)
35
+ - **`$.clone(parent, selector, { position })`** — pass `afterbegin` / `beforeend` (default) / `beforebegin` / `afterend` to insert at any position via `insertAdjacentElement`. The `mounted` event fires *after* insertion at the final position. (#72)
36
+
37
+ ### Documentation
38
+
39
+ - **Production CSS purging** — README adds a "Production CSS purging" section with a PurgeCSS safelist pattern. No preset shipped; documenting the pattern keeps the framework agnostic about which purger consumers use. (#67)
40
+ - **Reacting to state changes** — README documents `MutationObserver` against `document.body` as the canonical pattern for state-driven side effects (modal orchestration, focus management). The framework deliberately doesn't broadcast events on every write — the platform's primitive is the right tool. (#68)
41
+ - **`_t()` with empty dictionary** — README documents the template-literal workaround for default-locale apps that want interpolation. No `options.template` API added; template literals are the platform's interpolation primitive. (#63)
42
+ - **Key naming convention** documented under `the(...)` — kebab-case is the canonical surface.
43
+
44
+ ## [0.4.0] — 2026-05-23
45
+
46
+ ### Breaking
47
+
48
+ - **Removed `the.attr(el, name, val)`.** It was an aesthetic helper that wrapped `el.setAttribute` for non-state attributes. The platform's `setAttribute` is the right primitive for HTML structure attributes; `the()` is for state. Two distinct concerns, two distinct primitives. No call-site uniformity gain was worth the API duplication and the consumer-confusion cost of "which writes go through OTM?".
49
+ - **Reverted ARIA mapping to its closed 5-entry set:** `expanded`, `selected`, `hidden`, `checked`, `disabled`. **Criterion: HTML5 widget/form boolean states only.** No future expansion. The five added in 0.3.4 (`invalid`, `required`, `readonly`, `pressed`, `current`) go through `el.setAttribute("aria-...", val)` like any other HTML attribute. Without a stated criterion the ARIA map would grow forever; with one, the line is clear.
50
+ - **Removed `the.title(str)`.** Title isn't state; making it a state-method was special-casing for symmetry's sake. Replaced by extending the global setter to walk the entire document (not just body) when looking for `[data-text="key"]` slots. Put `<title data-text="title">Default</title>` in `<head>` and `the("title", "X")` updates it — same pattern as every other reactive slot, no special-case API.
51
+ - **Removed `--conformance` flag from `otm-lint`.** Exit code already encodes violation count; the flag duplicated that signal in output without distinct value.
52
+
53
+ ### Added
54
+
55
+ - **`data-otm-dynamic` opt-out attribute for HTML-101.** `<template id="X" data-otm-dynamic>` is skipped by the orphan-template check. Use when a template is instantiated via dynamic IDs, aliased `$.clone` calls, or other indirection the regex-based detection misses.
56
+
57
+ ### Documentation
58
+
59
+ - **README**: ARIA inclusion criterion documented (closed set, no future expansion).
60
+ - **README**: lint-rule scope acknowledged honestly — `no-server-dom`, `no-document-query`, and `HTML-101` each have real loopholes; the README now describes them as "discipline aids, not enforcement boundaries."
61
+ - **README**: plain attribute writes (`href`, `value`, `rel`) explicitly use `setAttribute` directly; the framework deliberately doesn't wrap them.
62
+ - **README**: dynamic `<title>` and other `<head>` slot hydration via the body-rooted `the()` is documented as the canonical pattern.
63
+
64
+ ### Why this release
65
+
66
+ A self-audit of 0.3.4–0.3.6 surfaced six items that violated the leanness mandate: aesthetic helpers, special-case methods, growing-without-criterion API surfaces, and performative lint rules without honest scope documentation. Each is resolved here in favor of clearer, leaner patterns. See #74.
67
+
7
68
  ## [0.3.6] — 2026-05-23
8
69
 
9
70
  ### Added
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Opinionated, attribute-driven, standards-oriented modern framework for the web. <2KB gzip. Native browser APIs only. ESNext.
6
6
 
7
- State lives in DOM attributes. Reactivity comes from `[data-text]` and `[data-i18n]` selectors. Events use one delegated listener per `(parent, type)` pair. Routing uses the History API. Localization uses `Intl`. There is no virtual DOM, no JSX, no transpilation step, no proprietary tooling. The framework is a thin layer of conventions over the platform.
7
+ UI signal state — theme, page, modal-open, form-error, the boolean and enum flags that drive visual transitions — lives in DOM attributes; structured data (lists of records, collections, async results) lives in JS like everywhere else. Reactivity comes from `[data-text]` and `[data-i18n]` selectors and from CSS attribute-selector rules. Events use one delegated listener per `(parent, type)` pair. Routing uses the History API. Localization uses `Intl`. There is no virtual DOM, no JSX, no transpilation step, no proprietary tooling. The framework is a thin layer of conventions over the platform.
8
8
 
9
9
  ## Install
10
10
 
@@ -67,7 +67,7 @@ Then write semantic HTML (`<main>`, `<nav>`, `<article>`, `<form>`, `<button>`,
67
67
  import { on, the, _t, route, $, $$ } from "on_the_money";
68
68
  ```
69
69
 
70
- Aliases on `the`: `the.t === _t`, `the.route === route`, `the.form(formEl)`, `the.flat(obj, sep?)`, `the.boot(options?)`, `the.title(str)`, `the.attr(el, ...)`. Live accessors: `the.dictionary`, `the.locale`.
70
+ Aliases on `the`: `the.t === _t`, `the.route === route`, `the.form(formEl)`, `the.flat(obj, sep?)`, `the.match(pattern, path?)`, `the.boot(options?)`. Live accessors: `the.dictionary`, `the.locale`.
71
71
  Live accessors on `the`: `the.dictionary`, `the.locale`.
72
72
 
73
73
  ## API
@@ -105,13 +105,17 @@ Polymorphic on a single disambiguator: `args[0] instanceof Element`. Three call
105
105
  | Call | Behavior | Returns |
106
106
  | --- | --- | --- |
107
107
  | `the(key)` | Read body `data-KEY` (or aria-mapped equivalent). | `string \| null` |
108
- | `the(key, val)` | Write body attribute + `localStorage["otm:KEY"]` + descendant `[data-text="key"]`. | `document.body` |
108
+ | `the(key, val)` | Write body attribute + descendant `[data-text="key"]`. Persists to `localStorage` only if `key` is in `persistKeys`. | `document.body` |
109
109
  | `the({ k: v, ... })` | Batch global write. | `document.body` |
110
110
  | `the(el, key)` | Read scoped attribute on `el`. | `string \| null` |
111
111
  | `the(el, key, val)` | Write scoped attribute on `el` + descendant `[data-text="key"]`. | `el` |
112
112
  | `the(el, { k: v, ... })` | Batch scoped write. | `el` |
113
113
 
114
- - **ARIA mapping** (key attribute): `expanded`, `selected`, `hidden`, `checked`, `disabled`, `invalid`, `required`, `readonly`, `pressed`, `current` `aria-*`. All other keys `data-*`. Note: HTML `hidden` and `aria-hidden` are different concepts (layout vs accessibility tree); the mapping here is always to `aria-*`.
114
+ - **Key naming.** Keys auto-convert to kebab-case for attribute writes and `[data-text]` lookups. `the("chapterHasNav", x)` writes `data-chapter-has-nav`. snake_case (`chapter_has_nav`) normalizes to kebab too. Single-word keys (`theme`, `user`) pass through unchanged. Round-trips via the platform's `dataset` API: `el.dataset.chapterHasNav` reads the same attribute. CSS selectors and `[data-text="..."]` slot values must use kebab-case to match what JS writes.
115
+ - **Persistence is opt-in.** Default behavior writes attributes only — nothing persists to `localStorage`. Declare `the.boot({ persistKeys: ["theme", "lang"] })` to opt specific keys in. The boot replay loads only those keys back. Common persistKeys for an i18n + theme app: `["theme", "lang"]`.
116
+ - **ARIA mapping** (closed set, key → attribute): `expanded`, `selected`, `hidden`, `checked`, `disabled` → `aria-*`. **Criterion: HTML5 widget/form boolean states only.** No future expansion. Other ARIA attributes (`aria-invalid`, `aria-controls`, `aria-describedby`, etc.) go through `el.setAttribute("aria-...", val)` like any other HTML attribute. Non-ARIA keys → `data-*` (after kebab conversion).
117
+ - **Dynamic `<title>` and other `<head>` slots:** global `the(key, val)` writes walk the entire document for `[data-text="key"]` matches, not just body. Put `<title data-text="title">Default</title>` in `<head>` and `the("title", "X")` updates it.
118
+ - **Plain HTML attributes (href, value, rel, etc.)** use `el.setAttribute(name, val)` directly. The framework deliberately doesn't wrap these — `the()` is for state; `setAttribute()` is for structure. Two distinct concerns, two distinct primitives.
115
119
  - **Booleans coerce** to `"true"`/`"false"` inside the setter. `the(el, "checked", true)` writes `"true"`.
116
120
  - **Values MUST be flat primitives.** Pass nested objects through `the.flat(...)` first.
117
121
  - **`the(key, undefined)` throws.** Two args means set; missing val is a contract violation.
@@ -129,27 +133,6 @@ the.form(form);
129
133
 
130
134
  Returns a **nested** object (matches browser submission semantics). Compose with `the.flat` before feeding to `the(el, {...})`.
131
135
 
132
- ### `the.title(str)` — set `document.title`
133
-
134
- `<title>` lives in `<head>`, outside the body subtree that `the()` walks for `[data-text]` mirroring. Use `the.title(str)` for dynamic page titles instead of `document.title = "..."`.
135
-
136
- ```javascript
137
- the.title(`${user.name} — Dashboard`);
138
- ```
139
-
140
- Returns the `<title>` element.
141
-
142
- ### `the.attr(el, name, val)` / `the.attr(el, { ...attrs })` — plain attribute writes
143
-
144
- Writes attributes that aren't `data-*` or ARIA — `href`, `value`, `rel`, `for`, etc. Use this instead of `el.setAttribute(...)` so all DOM writes in app code go through the OTM surface.
145
-
146
- ```javascript
147
- the.attr($("#post-link"), "href", `/posts/${slug}`);
148
- the.attr($("#prev"), { href: prevUrl, rel: "prev" });
149
- ```
150
-
151
- Returns `el`. Throws if the second arg is neither a string nor a plain object.
152
-
153
136
  ### `the.flat(obj, sep = "_")` — nested-to-flat
154
137
 
155
138
  ```javascript
@@ -167,7 +150,7 @@ Throws on non-object input.
167
150
  Importing the module does **nothing**. Call `the.boot()` at the consumer's entry point.
168
151
 
169
152
  ```javascript
170
- await the.boot({ signal, locales, dictionary, namespace, defaultLocale });
153
+ await the.boot({ signal, locales, dictionary, namespace, defaultLocale, persistKeys });
171
154
  ```
172
155
 
173
156
  | Option | Type | Behavior |
@@ -177,7 +160,7 @@ await the.boot({ signal, locales, dictionary, namespace, defaultLocale });
177
160
  | `dictionary` | `object` | Inline dictionary; skips the fetch entirely. |
178
161
  | `namespace` | `string` | Sets `localStorage` prefix to `${namespace}:` (default `otm:`). Must be set before any state ops. |
179
162
  | `defaultLocale` | `string` | Locale your static HTML is already written in. When the resolved locale base matches this, the dictionary fetch and `_t()` hydration pass are skipped entirely — no network, no FOUC. Auto-detected from `<html lang>` if omitted. |
180
- | `ephemeralKeys` | `string[]` | Global state keys that **should not** persist to `localStorage`. Writes to these keys still update the body attribute and any `[data-text]` mirrors, but skip the storage write; the boot replay also skips them. Use for transient signals like `modal`, `loading`, `toast`. Scoped state (`the(el, ...)`) never persists regardless. |
163
+ | `persistKeys` | `string[]` | Global state keys that **should** persist to `localStorage`. Default: empty nothing persists. Writes still update the body attribute and any `[data-text]` mirrors regardless. Boot replay rehydrates only these keys. Use for stable preferences: `["theme", "lang"]`. Scoped state (`the(el, ...)`) never persists regardless. |
181
164
 
182
165
  Boot sequence:
183
166
  1. Resolve locale: `?lang=` query → `localStorage["${prefix}lang"]` → `navigator.language`. Writes `the.locale`.
@@ -230,6 +213,23 @@ route((pathname, search, hash) => {
230
213
 
231
214
  Fires the callback on initial mount, on `popstate`, on `hashchange`, and on intercepted internal `<a>` clicks. Skips interception for `data-external`, `target="_blank"`, and cross-origin hrefs. Hash-only same-page links are left to the browser; `hashchange` still triggers the callback.
232
215
 
216
+ ### `the.match(pattern, path?)` — pattern matching with named segments
217
+
218
+ Express-style colon syntax. Extracts named segments from `path` (defaults to `window.location.pathname`). Decodes URI-encoded segments. Returns `{name: value}` on match, `null` on no match.
219
+
220
+ ```javascript
221
+ the.match("/@:user/:work/:chapter", "/@alice/great-work/chapter-1");
222
+ // → { user: "alice", work: "great-work", chapter: "chapter-1" }
223
+
224
+ the.match("/about", "/about");
225
+ // → {} (matched, no params)
226
+
227
+ the.match("/:slug", "/about/extra");
228
+ // → null (segment count doesn't match)
229
+ ```
230
+
231
+ Scope: `:name` segments only. No optional segments, regex constraints, wildcards, or nested patterns. If your routing needs those, bring `path-to-regexp` or similar — `the.match` is the 80%-case helper, not a router framework.
232
+
233
233
  ### `$(context, selector)` / `$$(context, selector)` — context-aware DOM query
234
234
 
235
235
  ```javascript
@@ -239,13 +239,117 @@ $(".child"); // shorthand: context = document
239
239
  $$(".child");
240
240
  ```
241
241
 
242
- ### `$.clone(parent, selector)` — template instantiation
242
+ ### `$.clone(parent, selector, options?)` — template instantiation
243
243
 
244
244
  ```javascript
245
245
  const el = $.clone("#list", "#tmp");
246
+ const head = $.clone("#list", "#tmp", { position: "afterbegin" }); // prepend
247
+ const sib = $.clone(anchorEl, "#tmp", { position: "beforebegin" }); // insert before anchor
248
+ ```
249
+
250
+ Clones the first element of `<template selector>`, runs `_t(el)` for i18n hydration, inserts at `position` (default `beforeend` — append inside `parent`), dispatches a bubbling `mounted` CustomEvent (`detail: { parent }`) **after** insertion so the element is at its final position when the event fires. Returns the mounted element. Throws if `parent`/template is missing.
251
+
252
+ `position` values mirror [`insertAdjacentElement`](https://developer.mozilla.org/en-US/docs/Web/API/Element/insertAdjacentElement):
253
+
254
+ | Position | Where |
255
+ | --- | --- |
256
+ | `beforeend` (default) | Inside `parent`, after its last child. |
257
+ | `afterbegin` | Inside `parent`, before its first child. |
258
+ | `beforebegin` | As `parent`'s previous sibling. |
259
+ | `afterend` | As `parent`'s next sibling. |
260
+
261
+ For `beforebegin` and `afterend`, the first argument is a sibling reference, not a true parent.
262
+
263
+ ## The Discipline
264
+
265
+ on_the_money has no reactivity primitives. No signal, effect, autorun, watch, atom, store, derived state, or subscription. There is also no event broadcast on `the()` writes. If you find yourself reaching for any of these — including importing them from another library — you're solving a problem the framework rejects.
266
+
267
+ Instead, the framework has **three state-response mechanisms**, each with one job:
268
+
269
+ ### 1. CSS attribute selectors handle state → visual
270
+
271
+ Default. Anything visual goes through `[data-key="value"]` selectors in your stylesheet.
272
+
273
+ ```css
274
+ body[data-modal="session-expired"] #session-expired-modal { display: block }
275
+ body[data-page="home"] main > section[data-route="home"] { display: block }
276
+ [data-state="loading"] .spinner { display: inline }
277
+ ```
278
+
279
+ ```javascript
280
+ the("modal", "session-expired"); // CSS rule above runs the visual change
246
281
  ```
247
282
 
248
- Clones the first element of `<template selector>`, runs `_t(el)` for i18n hydration, appends to `parent`, dispatches a bubbling `mounted` CustomEvent (`detail: { parent }`), and returns the mounted element. Throws if `parent` or template is missing.
283
+ No JS step between the `the()` write and the visual update. The CSS rule **is** the correspondence. This is the framework's reason for existing DOM-as-state-database is valuable specifically because CSS can read that database without JS.
284
+
285
+ ### 2. Imperative API calls colocated with state writes
286
+
287
+ For one-off platform side effects (`dialog.showModal()`, `el.focus()`, `el.scrollIntoView()`, media `.play()`, animation triggers), the imperative call lives in the same `on()` handler that wrote the state.
288
+
289
+ ```javascript
290
+ on("button.open", "click", () => {
291
+ the("modal", "session-expired");
292
+ $("#session-expired-modal").showModal();
293
+ });
294
+ ```
295
+
296
+ Cause (user click) and imperative effect (`showModal()`) are colocated. The `the()` write records the state; the platform call performs the imperative action. No subscriber between them.
297
+
298
+ ### 3. `MutationObserver` for DRY of imperative responses across many call sites
299
+
300
+ When the same state can be written from multiple places (user click, fetch handler, boot replay, postMessage) and they all need the same imperative response, observe the attribute once:
301
+
302
+ ```javascript
303
+ new MutationObserver(() => {
304
+ const want = the("modal") ?? "";
305
+ for (const open of $$("dialog[open]")) {
306
+ if (open.id !== `${want}-modal`) open.close();
307
+ }
308
+ if (want) $(`#${want}-modal`)?.showModal();
309
+ }).observe(document.body, { attributes: true, attributeFilter: ["data-modal"] });
310
+ ```
311
+
312
+ The observer doesn't enable reactivity — it deduplicates the imperative response. Any place in the codebase that writes `data-modal` gets the same dispatch logic without copy-pasting.
313
+
314
+ ### When `MutationObserver` is right vs wrong
315
+
316
+ | Right (the platform call does work CSS can't reach) | Wrong (CSS's job) |
317
+ | --- | --- |
318
+ | `el.focus()`, `el.blur()` — moving keyboard focus | Showing/hiding via `display: none` |
319
+ | `el.scrollIntoView()`, `window.scrollTo(...)` — viewport movement | Changing color, layout, transform |
320
+ | `media.play()`, `media.pause()` — playback state | Adding/removing a class |
321
+ | `el.animate(...)` — Web Animations timing | Conditional opacity / visibility |
322
+ | `el.requestFullscreen()` — browser mode | Animation triggers expressible as CSS transitions |
323
+ | `navigator.clipboard.writeText(...)` — out-of-DOM side effects | Toggling text content (use `[data-text]`) |
324
+
325
+ `dialog.showModal()` is a borderline case: it shows the dialog (visual) AND traps focus, paints the backdrop, registers an Escape handler, and announces modal semantics to assistive tech (behavioral + a11y). If you want only the visual, `dialog[open]` + CSS suffices. If you want the bundled behavior, call `showModal()` — and reach for `MutationObserver` only if `data-modal` can be set from multiple call sites that all need the same dispatch.
326
+
327
+ If your `MutationObserver` callback is setting styles, toggling classes, or changing `textContent`, you've smuggled a JS reactivity layer into a project that explicitly rejected one. The CSS attribute selector was already doing that job — declaratively, with zero JS, with the right perf shape.
328
+
329
+ ### The deletion test
330
+
331
+ The single best self-check: strip every JS function except `on()` handlers, the imperative calls inside them, and any `MutationObserver` that handles a many-sites imperative response. Open the page; mutate `body` `data-*` attributes in DevTools.
332
+
333
+ - Does the page respond correctly? You're idiomatic.
334
+ - Does it not? You're missing either a CSS rule (most likely) or an imperative call at a state-write site (occasionally).
335
+
336
+ ### Element-as-state-carrier
337
+
338
+ Many platform elements already carry state in their own attributes, with native CSS hooks. When the state genuinely lives on a specific element, use that — don't add a `body[data-x]` indirection:
339
+
340
+ | Element | Native state attribute | What you do |
341
+ | --- | --- | --- |
342
+ | `<dialog>` | `[open]` | `el.setAttribute("open", "")` + Pico's `dialog[open]` CSS, or `el.showModal()` if you need the backdrop and focus trap |
343
+ | `<details>` | `[open]` | Same shape; browser handles the disclosure UX |
344
+ | `<input>`, `<textarea>` | `[aria-invalid]`, `[aria-required]`, `[disabled]` | Direct `setAttribute`; CSS targets `:invalid`, `[disabled]` |
345
+ | `<button>` | `[aria-pressed]` (toggle state), `[disabled]` | Native focus/style for free |
346
+ | `<select>` | The current `value` | Read via `el.value`; no body indirection needed |
347
+
348
+ The `body[data-modal]` indirection is correct when the state is a named enum the consumer controls (which modal is open, current page, theme). When the state genuinely lives on an element (is THIS dialog open?), let it live there.
349
+
350
+ ### Banned vocabulary, in spirit
351
+
352
+ If you're searching online for "how to subscribe to OTM state changes" or "OTM reactive system" — you're looking for something the framework doesn't have and won't add. The three mechanisms above are the whole story. Reactivity-by-CSS, imperative-by-handler, DRY-by-observer.
249
353
 
250
354
  ## Patterns
251
355
 
@@ -321,6 +425,54 @@ on.emit(document.body, "modal-closed");
321
425
  off(); // detach when modal is destroyed
322
426
  ```
323
427
 
428
+ ### Routing with `the.match`
429
+
430
+ ```javascript
431
+ route(() => {
432
+ const post = the.match("/posts/:slug");
433
+ if (post) return renderPost(post.slug);
434
+
435
+ const profile = the.match("/@:user");
436
+ if (profile) return renderProfile(profile.user);
437
+
438
+ if (location.pathname === "/") return renderHome();
439
+ });
440
+ ```
441
+
442
+ ### Imperative dispatch from many sites (mechanism #3)
443
+
444
+ This is the case-3 pattern from [The Discipline](#the-discipline): the same state can be written from several places (user click, fetch error handler, boot replay, etc.) and all of them need the same imperative response. Observe the attribute once, dispatch from there:
445
+
446
+ ```javascript
447
+ new MutationObserver(() => {
448
+ const wantModal = the("modal");
449
+ for (const dialog of $$("dialog[open]")) {
450
+ if (dialog.id !== `${wantModal}-modal`) dialog.close();
451
+ }
452
+ if (wantModal) $(`#${wantModal}-modal`)?.showModal();
453
+ }).observe(document.body, { attributes: true, attributeFilter: ["data-modal"] });
454
+ ```
455
+
456
+ `document.body` is the allowed identifier under `otm/no-document-query`. Filter by `attributeFilter` to scope the observer narrowly.
457
+
458
+ **Before reaching for this pattern, check whether CSS can do the job.** If the response is purely visual (show/hide, color, layout), the CSS attribute selector is the right tool and the observer is overhead. Reserve `MutationObserver` for imperative APIs CSS can't express (`showModal`, `focus`, `scrollIntoView`, media controls, animation triggers).
459
+
460
+ ### Working around `_t()` with an empty dictionary
461
+
462
+ When `the.boot({ defaultLocale })` skips the dictionary fetch, programmatic `_t(key, options)` calls return the key (no template to interpolate against). For default-locale interpolation, use template literals or a small wrapper:
463
+
464
+ ```javascript
465
+ // Template literal — the platform's interpolation primitive
466
+ const greeting = `Hello, ${name}!`;
467
+
468
+ // Or wrap once for callers that need a uniform API across locales
469
+ function greet(name) {
470
+ return the.dictionary.greeting
471
+ ? _t("greeting", { name })
472
+ : `Hello, ${name}!`;
473
+ }
474
+ ```
475
+
324
476
  ### Server-side rendering (no extra API)
325
477
 
326
478
  There's no SSR shim. Emit `data-*` attributes from the server:
@@ -412,23 +564,21 @@ npx otm-lint --check ./src
412
564
  | **HTML-017** | `<div data-action="...">` without `role`/`tabindex` | Use a `<button>` or other interactive element. |
413
565
  | **HTML-023** | `data-i18n="..."` without `<meta name="i18n">` | Declare the i18n endpoint. |
414
566
  | **HTML-024** | `data-available="..."` doesn't match locales folder | Keep the manifest aligned with the actual locale files. |
415
- | **HTML-101** | `<template id="X">` is never referenced by `$.clone(_, "#X")` | Either delete the orphan template or add the missing clone call. Catches dead-template drift after refactors. |
567
+ | **HTML-101** | `<template id="X">` is never referenced by `$.clone(_, "#X")` | Either delete the orphan template or add the missing clone call. Catches dead-template drift after refactors. Detection is regex-based and matches the literal `$.clone(_, "#id")` shape — dynamic IDs (`` `#${id}` ``), aliased calls, or templates instantiated through indirection are missed. Use `data-otm-dynamic` on the `<template>` to opt out. |
416
568
  | **HTML-102** | `data-i18n="K"` references a key not in any locale dictionary | Add the key to your locale files, or fix the typo. Catches the silent-fallback-to-key UX bug at lint time. |
417
569
  | **HTML-103** | `data-i18n-{var}` attr has no matching `{var}` placeholder in the dictionary template | The token is silently dropped at runtime. Either remove the unused attr or add `{var}` to the template. |
418
570
 
419
- `otm-lint` walks `.html`, `.js`, and locale `.json` files. HTML files get the per-file rules above; `.js` files contribute `$.clone` references for HTML-101; locale dicts get loaded per HTML file's `<meta name="i18n">` for HTML-102/103. Default excludes: `node_modules`, `dist`, `.git`, dotdirs.
571
+ `otm-lint` walks `.html`, `.js`, and locale `.json` files. HTML files get the per-file rules above; `.js` files contribute `$.clone` references for HTML-101; locale dicts get loaded per HTML file's `<meta name="i18n">` for HTML-102/103. Default excludes: `node_modules`, `dist`, `.git`, dotdirs. Exit code is the violation count.
420
572
 
421
- ### `--conformance` flag
573
+ ### Lint-rule scope
422
574
 
423
- Append `--conformance` for a single machine-grep-able summary line at the end of output:
575
+ These rules are **discipline aids, not enforcement boundaries.** Each catches the obvious shape; determined consumers can route around them:
424
576
 
425
- ```
426
- $ npx otm-lint --check ./src --conformance
427
- ... violations ...
428
- OTM conformance: 3 violations
429
- ```
577
+ - `otm/no-server-dom` matches static `import` only. Dynamic `await import("linkedom")` and CJS `require()` are not caught.
578
+ - `otm/no-document-query` catches `document.querySelector(...)` and friends at AST level. Chained calls (`document.body.querySelector(...)`, `document.getElementById("x").querySelector(...)`) are not caught — the call site has moved off `document`.
579
+ - `HTML-101` regex-matches `$.clone(parent, "#id")` in `.js` files. Dynamic IDs and aliased calls are missed; use `data-otm-dynamic` to opt out of the check for templates instantiated indirectly.
430
580
 
431
- Useful for CI gates and LLM tooling that wants a one-line signal.
581
+ The rules' job is to catch the *first* mistake and surface a teaching message ("Common LLM mistake: ..."). Strong patterns + good editor feedback do the rest.
432
582
 
433
583
  ### Recommended companions (not shipped)
434
584
 
@@ -439,6 +589,30 @@ Useful for CI gates and LLM tooling that wants a one-line signal.
439
589
  | Runtime a11y | `axe-core` via Playwright/Cypress against the rendered page |
440
590
  | Supply-chain | `npm audit`, `osv-scanner` |
441
591
 
592
+ ## Production CSS purging
593
+
594
+ OTM's CSS depends on attribute selectors written at runtime (`body[data-page="home"]`, `[data-state="active"]`, etc.). Default tree-shakers like PurgeCSS scan HTML for tokens and can't see attribute values that don't exist at build time — they'll strip your state-driven rules. Configure the safelist to preserve them:
595
+
596
+ ```javascript
597
+ // postcss.config.js
598
+ import purgecss from "@fullhuman/postcss-purgecss";
599
+
600
+ export default {
601
+ plugins: [
602
+ purgecss({
603
+ content: ["src/**/*.html", "src/**/*.js"],
604
+ defaultExtractor: (s) => s.match(/[A-Za-z0-9_-]+/g) || [],
605
+ safelist: {
606
+ standard: [/^aria-/, "hidden"],
607
+ deep: [/^\[data-/, /^aria-/, /role-/],
608
+ },
609
+ }),
610
+ ],
611
+ };
612
+ ```
613
+
614
+ The `defaultExtractor` tweak ensures attribute names tokenize correctly (the default extractor treats `id="foo"` as one opaque token). The `deep` safelist preserves any selector containing `[data-*]` or `aria-*` from being purged. Without these, every `[data-page="home"] main > section` style gets stripped in production.
615
+
442
616
  ## Suggested project layout
443
617
 
444
618
  ```
@@ -1 +1 @@
1
- var b=class{static on(t,e,o,n){let r=typeof t=="string"?document.querySelector(t):t||document.body,c=i=>{let l=i.target.closest(o);l&&r.contains(l)&&n.call(l,i,l)};return r.addEventListener(e,c),()=>r.removeEventListener(e,c)}static emit(t,e,o){let n=typeof t=="string"?document.querySelector(t):t,r=new CustomEvent(e,{bubbles:!0,cancelable:!0,detail:o});n.dispatchEvent(r)}};var s=class a{static dictionary={};static locale=typeof navigator<"u"?navigator.language:"en-US";static prefix="otm:";static ephemeralKeys=new Set;static the(...t){if(t.length===0)throw new TypeError("the(): missing args");return t[0]instanceof Element?a.#e(t[0],t.slice(1)):a.#e(document.body,t)}static#e(t,e){let[o,n]=e,r=t===document.body;if(e.length===1&&typeof o=="string")return a.#r(t,o);if(e.length===2&&typeof o=="string"){if(typeof n>"u")throw new TypeError(`the(${JSON.stringify(o)}, undefined): val is required for set`);return a.#t(t,o,n),r&&!a.ephemeralKeys.has(o)&&localStorage.setItem(`${a.prefix}${o}`,n),t}if(e.length===1&&o?.constructor===Object){for(let[c,i]of Object.entries(o))a.#t(t,c,i),r&&!a.ephemeralKeys.has(c)&&localStorage.setItem(`${a.prefix}${c}`,i);return t}throw new TypeError(`the(): unrecognized call shape (${e.map(c=>typeof c).join(", ")})`)}static title(t){return document.title=t,document.querySelector("title")}static attr(t,e,o){if(typeof e=="string")return t.setAttribute(e,o),t;if(e?.constructor===Object){for(let[n,r]of Object.entries(e))t.setAttribute(n,r);return t}throw new TypeError("the.attr: second arg must be a string or plain object")}static flat(t,e="_"){if(t===null||typeof t!="object")throw new TypeError("the.flat: input must be an object");let o={},n=(r,c)=>{if(r===null||typeof r!="object"){o[c]=r;return}for(let[i,l]of Object.entries(r))n(l,c?`${c}${e}${i}`:i)};return n(t,""),o}static form(t){let e={},o=t.querySelectorAll("input, select, textarea");for(let n of o){let r=n.name;if(!r||n.disabled)continue;let c=(n.type||"text").toLowerCase();if(c==="submit"||c==="button"||c==="reset"||(c==="checkbox"||c==="radio")&&!(n.checked??n.hasAttribute("checked")))continue;let i=n.value??n.getAttribute("value")??"";a.#o(e,r,i)}return e}static#o(t,e,o){let n=e.split(/[\[\]]/).filter(Boolean),r=t;for(let c=0;c<n.length;c++){let i=n[c];c===n.length-1?r[i]!==void 0?(Array.isArray(r[i])||(r[i]=[r[i]]),r[i].push(o)):r[i]=o:(r[i]=r[i]||{},r=r[i])}}static _t(t,e={}){if(typeof Node<"u"?t instanceof Node:t?.nodeType){let c=t.querySelectorAll?[t,...t.querySelectorAll("[data-i18n]")]:[t];for(let i of c)if(i.hasAttribute?.("data-i18n")){let l=i.getAttribute("data-i18n"),p={};for(let y of i.attributes)if(y.name.startsWith("data-i18n-")&&y.name!=="data-i18n-type"){let v=y.name.replace("data-i18n-","");p[v]=y.value}let h=i.getAttribute("data-i18n-type");i.textContent=a._t(l,{...p,type:h})}return t}if(!t)return a._t(document.body),"";let n=a.dictionary[t];if(!n){if(e.val!==void 0&&(e.type==="currency"||e.type==="date")){let c=e.type==="currency"?new Intl.NumberFormat(a.locale,{style:"currency",currency:"USD"}):new Intl.DateTimeFormat(a.locale);return e.type==="date"?c.format(new Date(e.val)):c.format(Number(e.val))}return t}if(typeof n=="object"){let c=e.qty!==void 0?Number(e.qty):0,i=new Intl.PluralRules(a.locale).select(c);n=n[i]||n.other||t}if(typeof n!="string")return t;let r=n;for(let[c,i]of Object.entries(e)){let l=i;if(c==="val"&&e.type){let p=Number(i),h=e.type==="currency"?new Intl.NumberFormat(a.locale,{style:"currency",currency:"USD"}):e.type==="date"?new Intl.DateTimeFormat(a.locale):new Intl.NumberFormat(a.locale);l=e.type==="date"?h.format(new Date(i)):h.format(p)}r=r.replace(`{${c}}`,l)}return r}static route(t){if(typeof window>"u")return;let e=()=>t(window.location.pathname,window.location.search,window.location.hash);window.addEventListener("popstate",e),window.addEventListener("hashchange",e),document.addEventListener("click",o=>{let n=o.target.closest("a");if(!n||!n.getAttribute("href")||n.hasAttribute("data-external")||n.target==="_blank")return;let r=new URL(n.getAttribute("href"),window.location.href);r.origin===window.location.origin&&(r.pathname===window.location.pathname&&r.search===window.location.search&&r.hash||(o.preventDefault(),window.history.pushState({},"",r.href),e()))}),e()}static#n={expanded:"aria-expanded",selected:"aria-selected",hidden:"aria-hidden",checked:"aria-checked",disabled:"aria-disabled",invalid:"aria-invalid",required:"aria-required",readonly:"aria-readonly",pressed:"aria-pressed",current:"aria-current"};static#r(t,e){let o=a.#n[e]||`data-${e}`;return t.getAttribute(o)}static#t(t,e,o){let n=a.#n[e]||`data-${e}`,r=typeof o=="boolean"?o?"true":"false":o;if(t.setAttribute(n,r),t.querySelectorAll){let c=t.querySelectorAll(`[data-text="${e}"]`);for(let i of c)i.textContent=r}t.getAttribute?.("data-text")===e&&(t.textContent=r)}static async boot({signal:t,locales:e,dictionary:o,namespace:n,defaultLocale:r,ephemeralKeys:c}={}){n&&(a.prefix=`${n}:`),c&&(a.ephemeralKeys=new Set(c));let i=typeof window<"u"&&window.location?window.location.search:"",l=new URLSearchParams(i),p=(typeof navigator<"u"?navigator.language:null)||document.documentElement.lang||"en";a.locale=l.get("lang")||localStorage.getItem(`${a.prefix}lang`)||p;let h=r||document.documentElement.lang,y=a.locale.toLowerCase().split("-")[0],v=h?.toLowerCase().split("-")[0],A=!!v&&y===v;if(!A)if(o)a.dictionary=o;else{let d=document.querySelector('meta[name="i18n"]'),w=e||d?.getAttribute("content");if(w){let g=d?.getAttribute("data-fallback")||"en",x=(d?.getAttribute("data-available")||"").split(",").map(f=>f.trim().toLowerCase()),$=a.locale.toLowerCase(),E=$.split("-")[0],S=g;x.includes($)?S=$:x.includes(E)&&(S=E);try{let f=await fetch(`${w}/${S}.json`,{signal:t});f.ok&&(a.dictionary=await f.json())}catch(f){if(t?.aborted)throw f;console.warn("otm: i18n fetch failed",f)}}}for(let d=0;d<localStorage.length;d++){let w=localStorage.key(d);if(w.startsWith(a.prefix)){let g=w.slice(a.prefix.length);if(g!=="lang"&&!a.ephemeralKeys.has(g)){let x=localStorage.getItem(w);a.#t(document.body,g,x)}}}A||a._t()}};var m=class{static $(t,e){let o=t,n=e;return typeof o=="string"&&(n=o,o=document),o.querySelector(n)}static $$(t,e){let o=t,n=e;return typeof o=="string"&&(n=o,o=document),Array.from(o.querySelectorAll(n))}static clone(t,e){let o=typeof t=="string"?document.querySelector(t):t,n=document.querySelector(e);if(!o)throw new Error(`Parent not found: ${t}`);if(!n)throw new Error(`Template not found: ${e}`);let r=n.content.cloneNode(!0).firstElementChild;s._t(r),o.appendChild(r);let c=new CustomEvent("mounted",{bubbles:!0,detail:{parent:o}});return r.dispatchEvent(c),r}};var q=b.on;q.emit=b.emit;var u=s.the,L=s._t,j=s.route;u.t=L;u.route=j;u.form=s.form;u.flat=s.flat;u.boot=s.boot;u.title=s.title;u.attr=s.attr;Object.defineProperty(u,"dictionary",{get:()=>s.dictionary,set:a=>{s.dictionary=a}});Object.defineProperty(u,"locale",{get:()=>s.locale,set:a=>{s.locale=a}});var N=m.$;N.clone=m.clone;var C=m.$$,U={on:q,the:u,$:N,$$:C,_t:L};export{N as $,C as $$,L as _t,U as default,q as on,j as route,u as the};
1
+ var b=class{static on(t,e,a,n){let r=typeof t=="string"?document.querySelector(t):t||document.body,o=i=>{let s=i.target.closest(a);s&&r.contains(s)&&n.call(s,i,s)};return r.addEventListener(e,o),()=>r.removeEventListener(e,o)}static emit(t,e,a){let n=typeof t=="string"?document.querySelector(t):t,r=new CustomEvent(e,{bubbles:!0,cancelable:!0,detail:a});n.dispatchEvent(r)}};var l=class c{static dictionary={};static locale=typeof navigator<"u"?navigator.language:"en-US";static prefix="otm:";static persistKeys=new Set;static#t(t){return t.replace(/_/g,"-").replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}static the(...t){if(t.length===0)throw new TypeError("the(): missing args");return t[0]instanceof Element?c.#n(t[0],t.slice(1)):c.#n(document.body,t)}static#n(t,e){let[a,n]=e,r=t===document.body;if(e.length===1&&typeof a=="string")return c.#a(t,c.#t(a));if(e.length===2&&typeof a=="string"){if(typeof n>"u")throw new TypeError(`the(${JSON.stringify(a)}, undefined): val is required for set`);let o=c.#t(a);return c.#e(t,o,n),r&&c.persistKeys.has(o)&&localStorage.setItem(`${c.prefix}${o}`,n),t}if(e.length===1&&a?.constructor===Object){for(let[o,i]of Object.entries(a)){let s=c.#t(o);c.#e(t,s,i),r&&c.persistKeys.has(s)&&localStorage.setItem(`${c.prefix}${s}`,i)}return t}throw new TypeError(`the(): unrecognized call shape (${e.map(o=>typeof o).join(", ")})`)}static match(t,e){let a=e??(typeof window<"u"&&window.location?window.location.pathname:""),n=[],o=t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&").replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g,(d,u)=>(n.push(u),"([^/]+)")),i=a.match(new RegExp(`^${o}$`));if(!i)return null;let s={};return n.forEach((d,u)=>{s[d]=decodeURIComponent(i[u+1])}),s}static flat(t,e="_"){if(t===null||typeof t!="object")throw new TypeError("the.flat: input must be an object");let a={},n=(r,o)=>{if(r===null||typeof r!="object"){a[o]=r;return}for(let[i,s]of Object.entries(r))n(s,o?`${o}${e}${i}`:i)};return n(t,""),a}static form(t){let e={},a=t.querySelectorAll("input, select, textarea");for(let n of a){let r=n.name;if(!r||n.disabled)continue;let o=(n.type||"text").toLowerCase();if(o==="submit"||o==="button"||o==="reset"||(o==="checkbox"||o==="radio")&&!(n.checked??n.hasAttribute("checked")))continue;let i=n.value??n.getAttribute("value")??"";c.#r(e,r,i)}return e}static#r(t,e,a){let n=e.split(/[\[\]]/).filter(Boolean),r=t;for(let o=0;o<n.length;o++){let i=n[o];o===n.length-1?r[i]!==void 0?(Array.isArray(r[i])||(r[i]=[r[i]]),r[i].push(a)):r[i]=a:(r[i]=r[i]||{},r=r[i])}}static _t(t,e={}){if(typeof Node<"u"?t instanceof Node:t?.nodeType){let o=t.querySelectorAll?[t,...t.querySelectorAll("[data-i18n]")]:[t];for(let i of o)if(i.hasAttribute?.("data-i18n")){let s=i.getAttribute("data-i18n"),d={};for(let y of i.attributes)if(y.name.startsWith("data-i18n-")&&y.name!=="data-i18n-type"){let $=y.name.replace("data-i18n-","");d[$]=y.value}let u=i.getAttribute("data-i18n-type");i.textContent=c._t(s,{...d,type:u})}return t}if(!t)return c._t(document.body),"";let n=c.dictionary[t];if(!n){if(e.val!==void 0&&(e.type==="currency"||e.type==="date")){let o=e.type==="currency"?new Intl.NumberFormat(c.locale,{style:"currency",currency:"USD"}):new Intl.DateTimeFormat(c.locale);return e.type==="date"?o.format(new Date(e.val)):o.format(Number(e.val))}return t}if(typeof n=="object"){let o=e.qty!==void 0?Number(e.qty):0,i=new Intl.PluralRules(c.locale).select(o);n=n[i]||n.other||t}if(typeof n!="string")return t;let r=n;for(let[o,i]of Object.entries(e)){let s=i;if(o==="val"&&e.type){let d=Number(i),u=e.type==="currency"?new Intl.NumberFormat(c.locale,{style:"currency",currency:"USD"}):e.type==="date"?new Intl.DateTimeFormat(c.locale):new Intl.NumberFormat(c.locale);s=e.type==="date"?u.format(new Date(i)):u.format(d)}r=r.replace(`{${o}}`,s)}return r}static route(t){if(typeof window>"u")return;let e=()=>t(window.location.pathname,window.location.search,window.location.hash);window.addEventListener("popstate",e),window.addEventListener("hashchange",e),document.addEventListener("click",a=>{let n=a.target.closest("a");if(!n||!n.getAttribute("href")||n.hasAttribute("data-external")||n.target==="_blank")return;let r=new URL(n.getAttribute("href"),window.location.href);r.origin===window.location.origin&&(r.pathname===window.location.pathname&&r.search===window.location.search&&r.hash||(a.preventDefault(),window.history.pushState({},"",r.href),e()))}),e()}static#o={expanded:"aria-expanded",selected:"aria-selected",hidden:"aria-hidden",checked:"aria-checked",disabled:"aria-disabled"};static#a(t,e){let a=c.#o[e]||`data-${e}`;return t.getAttribute(a)}static#e(t,e,a){let n=c.#o[e]||`data-${e}`,r=typeof a=="boolean"?a?"true":"false":a;t.setAttribute(n,r);let o=t===document.body&&typeof document<"u"?document.documentElement||document:t;if(o.querySelectorAll){let i=o.querySelectorAll(`[data-text="${e}"]`);for(let s of i)s.textContent=r}t.getAttribute?.("data-text")===e&&(t.textContent=r)}static async boot({signal:t,locales:e,dictionary:a,namespace:n,defaultLocale:r,persistKeys:o}={}){n&&(c.prefix=`${n}:`),o&&(c.persistKeys=new Set(o.map(f=>c.#t(f))));let i=typeof window<"u"&&window.location?window.location.search:"",s=new URLSearchParams(i),d=(typeof navigator<"u"?navigator.language:null)||document.documentElement.lang||"en";c.locale=s.get("lang")||localStorage.getItem(`${c.prefix}lang`)||d;let u=r||document.documentElement.lang,y=c.locale.toLowerCase().split("-")[0],$=u?.toLowerCase().split("-")[0],S=!!$&&y===$;if(!S)if(a)c.dictionary=a;else{let f=document.querySelector('meta[name="i18n"]'),w=e||f?.getAttribute("content");if(w){let g=f?.getAttribute("data-fallback")||"en",x=(f?.getAttribute("data-available")||"").split(",").map(p=>p.trim().toLowerCase()),v=c.locale.toLowerCase(),E=v.split("-")[0],A=g;x.includes(v)?A=v:x.includes(E)&&(A=E);try{let p=await fetch(`${w}/${A}.json`,{signal:t});p.ok&&(c.dictionary=await p.json())}catch(p){if(t?.aborted)throw p;console.warn("otm: i18n fetch failed",p)}}}for(let f=0;f<localStorage.length;f++){let w=localStorage.key(f);if(w.startsWith(c.prefix)){let g=w.slice(c.prefix.length);if(g!=="lang"&&c.persistKeys.has(g)){let x=localStorage.getItem(w);c.#e(document.body,g,x)}}}S||c._t()}};var h=class{static $(t,e){let a=t,n=e;return typeof a=="string"&&(n=a,a=document),a.querySelector(n)}static $$(t,e){let a=t,n=e;return typeof a=="string"&&(n=a,a=document),Array.from(a.querySelectorAll(n))}static clone(t,e,{position:a="beforeend"}={}){let n=typeof t=="string"?document.querySelector(t):t,r=document.querySelector(e);if(!n)throw new Error(`Parent not found: ${t}`);if(!r)throw new Error(`Template not found: ${e}`);let o=r.content.cloneNode(!0).firstElementChild;l._t(o),n.insertAdjacentElement(a,o);let i=new CustomEvent("mounted",{bubbles:!0,detail:{parent:n}});return o.dispatchEvent(i),o}};var L=b.on;L.emit=b.emit;var m=l.the,q=l._t,N=l.route;m.t=q;m.route=N;m.form=l.form;m.flat=l.flat;m.match=l.match;m.boot=l.boot;Object.defineProperty(m,"dictionary",{get:()=>l.dictionary,set:c=>{l.dictionary=c}});Object.defineProperty(m,"locale",{get:()=>l.locale,set:c=>{l.locale=c}});var j=h.$;j.clone=h.clone;var C=h.$$,F={on:L,the:m,$:j,$$:C,_t:q};export{j as $,C as $$,q as _t,F as default,L as on,N as route,m as the};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "on_the_money",
3
- "version": "0.3.6",
3
+ "version": "0.5.2",
4
4
  "description": "Opinionated, attribute-driven, standards-oriented modern framework. <2KB gzip. Native browser APIs only. See README.md for the authoring context.",
5
5
  "type": "module",
6
6
  "main": "dist/on_the_money.min.js",
@@ -28,7 +28,7 @@ export default class Select {
28
28
  * @param {string} selector - The template ID/selector
29
29
  * @returns {HTMLElement} The mounted element
30
30
  */
31
- static clone(parent, selector) {
31
+ static clone(parent, selector, { position = "beforeend" } = {}) {
32
32
  const container =
33
33
  typeof parent === "string" ? document.querySelector(parent) : parent;
34
34
  const template = document.querySelector(selector);
@@ -38,12 +38,10 @@ export default class Select {
38
38
 
39
39
  const el = template.content.cloneNode(true).firstElementChild;
40
40
 
41
- // Surgical hydration of the new fragment before mounting
42
41
  The._t(el);
43
42
 
44
- container.appendChild(el);
43
+ container.insertAdjacentElement(position, el);
45
44
 
46
- // Trigger a 'mounted' custom event for lifecycle hooks
47
45
  const event = new CustomEvent("mounted", {
48
46
  bubbles: true,
49
47
  detail: { parent: container },
package/src/core/The.js CHANGED
@@ -3,7 +3,18 @@ export default class The {
3
3
  static locale =
4
4
  typeof navigator !== "undefined" ? navigator.language : "en-US";
5
5
  static prefix = "otm:";
6
- static ephemeralKeys = new Set();
6
+ // Default: nothing persists. Opt in per key via the.boot({ persistKeys }).
7
+ static persistKeys = new Set();
8
+
9
+ // Normalize keys to kebab-case for DOM attributes and [data-text] lookups.
10
+ // chapterHasNav → chapter-has-nav. chapter_has_nav → chapter-has-nav.
11
+ // expanded / mounted / theme → unchanged.
12
+ static #kebab(key) {
13
+ return key
14
+ .replace(/_/g, "-")
15
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
16
+ .toLowerCase();
17
+ }
7
18
 
8
19
  static the(...args) {
9
20
  if (args.length === 0) {
@@ -20,7 +31,7 @@ export default class The {
20
31
  const isGlobal = el === document.body;
21
32
 
22
33
  if (args.length === 1 && typeof a === "string") {
23
- return The.#get(el, a);
34
+ return The.#get(el, The.#kebab(a));
24
35
  }
25
36
  if (args.length === 2 && typeof a === "string") {
26
37
  if (typeof b === "undefined") {
@@ -28,15 +39,17 @@ export default class The {
28
39
  `the(${JSON.stringify(a)}, undefined): val is required for set`,
29
40
  );
30
41
  }
31
- The.#set(el, a, b);
32
- if (isGlobal && !The.ephemeralKeys.has(a))
33
- localStorage.setItem(`${The.prefix}${a}`, b);
42
+ const k = The.#kebab(a);
43
+ The.#set(el, k, b);
44
+ if (isGlobal && The.persistKeys.has(k))
45
+ localStorage.setItem(`${The.prefix}${k}`, b);
34
46
  return el;
35
47
  }
36
48
  if (args.length === 1 && a?.constructor === Object) {
37
- for (const [k, v] of Object.entries(a)) {
49
+ for (const [rawKey, v] of Object.entries(a)) {
50
+ const k = The.#kebab(rawKey);
38
51
  The.#set(el, k, v);
39
- if (isGlobal && !The.ephemeralKeys.has(k))
52
+ if (isGlobal && The.persistKeys.has(k))
40
53
  localStorage.setItem(`${The.prefix}${k}`, v);
41
54
  }
42
55
  return el;
@@ -47,23 +60,28 @@ export default class The {
47
60
  );
48
61
  }
49
62
 
50
- static title(str) {
51
- document.title = str;
52
- return document.querySelector("title");
53
- }
54
-
55
- static attr(el, nameOrObj, val) {
56
- if (typeof nameOrObj === "string") {
57
- el.setAttribute(nameOrObj, val);
58
- return el;
59
- }
60
- if (nameOrObj?.constructor === Object) {
61
- for (const [k, v] of Object.entries(nameOrObj)) el.setAttribute(k, v);
62
- return el;
63
- }
64
- throw new TypeError(
65
- "the.attr: second arg must be a string or plain object",
63
+ static match(pattern, path) {
64
+ const subject =
65
+ path ??
66
+ (typeof window !== "undefined" && window.location
67
+ ? window.location.pathname
68
+ : "");
69
+ const paramNames = [];
70
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
71
+ const regexSource = escaped.replace(
72
+ /:([a-zA-Z_][a-zA-Z0-9_]*)/g,
73
+ (_, name) => {
74
+ paramNames.push(name);
75
+ return "([^/]+)";
76
+ },
66
77
  );
78
+ const m = subject.match(new RegExp(`^${regexSource}$`));
79
+ if (!m) return null;
80
+ const out = {};
81
+ paramNames.forEach((name, i) => {
82
+ out[name] = decodeURIComponent(m[i + 1]);
83
+ });
84
+ return out;
67
85
  }
68
86
 
69
87
  static flat(obj, sep = "_") {
@@ -252,17 +270,15 @@ export default class The {
252
270
  navigate();
253
271
  }
254
272
 
273
+ // ARIA mapping: HTML5 widget/form boolean states only. Closed set; no future
274
+ // expansion. Other ARIA attributes (aria-invalid, aria-controls, etc.) go
275
+ // through el.setAttribute("aria-...", val) like any HTML attribute.
255
276
  static #ariaMap = {
256
277
  expanded: "aria-expanded",
257
278
  selected: "aria-selected",
258
279
  hidden: "aria-hidden",
259
280
  checked: "aria-checked",
260
281
  disabled: "aria-disabled",
261
- invalid: "aria-invalid",
262
- required: "aria-required",
263
- readonly: "aria-readonly",
264
- pressed: "aria-pressed",
265
- current: "aria-current",
266
282
  };
267
283
 
268
284
  static #get(el, key) {
@@ -275,8 +291,15 @@ export default class The {
275
291
  const out = typeof val === "boolean" ? (val ? "true" : "false") : val;
276
292
  el.setAttribute(attr, out);
277
293
 
278
- if (el.querySelectorAll) {
279
- const items = el.querySelectorAll(`[data-text="${key}"]`);
294
+ // Global writes (body) walk the whole document so [data-text="key"]
295
+ // slots in <head> (e.g. <title data-text="title">) hydrate too.
296
+ // Scoped writes stay within el.
297
+ const root =
298
+ el === document.body && typeof document !== "undefined"
299
+ ? document.documentElement || document
300
+ : el;
301
+ if (root.querySelectorAll) {
302
+ const items = root.querySelectorAll(`[data-text="${key}"]`);
280
303
  for (const item of items) item.textContent = out;
281
304
  }
282
305
  if (el.getAttribute?.("data-text") === key) el.textContent = out;
@@ -288,10 +311,11 @@ export default class The {
288
311
  dictionary,
289
312
  namespace,
290
313
  defaultLocale,
291
- ephemeralKeys,
314
+ persistKeys,
292
315
  } = {}) {
293
316
  if (namespace) The.prefix = `${namespace}:`;
294
- if (ephemeralKeys) The.ephemeralKeys = new Set(ephemeralKeys);
317
+ if (persistKeys)
318
+ The.persistKeys = new Set(persistKeys.map((k) => The.#kebab(k)));
295
319
  const search =
296
320
  typeof window !== "undefined" && window.location
297
321
  ? window.location.search
@@ -346,7 +370,7 @@ export default class The {
346
370
  const fullKey = localStorage.key(i);
347
371
  if (fullKey.startsWith(The.prefix)) {
348
372
  const key = fullKey.slice(The.prefix.length);
349
- if (key !== "lang" && !The.ephemeralKeys.has(key)) {
373
+ if (key !== "lang" && The.persistKeys.has(key)) {
350
374
  const val = localStorage.getItem(fullKey);
351
375
  The.#set(document.body, key, val);
352
376
  }
package/src/core/index.js CHANGED
@@ -12,9 +12,8 @@ the.t = _t;
12
12
  the.route = route;
13
13
  the.form = The.form;
14
14
  the.flat = The.flat;
15
+ the.match = The.match;
15
16
  the.boot = The.boot;
16
- the.title = The.title;
17
- the.attr = The.attr;
18
17
 
19
18
  Object.defineProperty(the, "dictionary", {
20
19
  get: () => The.dictionary,
@@ -12,7 +12,7 @@ const preferOn = {
12
12
  },
13
13
  messages: {
14
14
  useOn:
15
- "Direct addEventListener is forbidden. Use on() for event delegation. Common LLM mistake: copying jQuery / React tutorial patterns — on() is the single delegated-listener idiom.",
15
+ "Direct addEventListener is forbidden. Use on() for event delegation. Common LLM mistake: copying jQuery / React tutorial patterns — on() is the single delegated-listener idiom. See README §The Discipline.",
16
16
  },
17
17
  schema: [],
18
18
  },
@@ -36,7 +36,7 @@ const preferTheSet = {
36
36
  },
37
37
  messages: {
38
38
  useThe:
39
- "Direct text manipulation is forbidden. Use the() or [data-text] binding instead. Common LLM mistake: doing this server-side via linkedom/jsdom — same fix, emit JSON and let the OTM client render.",
39
+ "Direct text manipulation is forbidden. Use the() or [data-text] binding instead. Common LLM mistake: doing this server-side via linkedom/jsdom — same fix, emit JSON and let the OTM client render. See README §The Discipline.",
40
40
  },
41
41
  schema: [],
42
42
  },
@@ -64,7 +64,7 @@ const flatState = {
64
64
  },
65
65
  messages: {
66
66
  notFlat:
67
- "State must be flat. Nested objects or arrays are forbidden in the(). Common LLM mistake: passing a nested object — use the.flat(obj) to compose first.",
67
+ "State must be flat. Nested objects or arrays are forbidden in the(). Common LLM mistake: passing a nested object — use the.flat(obj) to compose first. See README §The Discipline.",
68
68
  },
69
69
  schema: [],
70
70
  },
@@ -147,7 +147,7 @@ const noStyleMutation = {
147
147
  },
148
148
  messages: {
149
149
  noStyle:
150
- "Direct style manipulation is forbidden. Use the() with attribute selectors instead. Common LLM mistake: copying React/Vue inline-style patterns — CSS rules keyed off [data-state=...] handle state-driven styling cleanly.",
150
+ "Direct style manipulation is forbidden. Use the() with attribute selectors instead. Common LLM mistake: copying React/Vue inline-style patterns — CSS rules keyed off [data-state=...] handle state-driven styling cleanly. See README §The Discipline.",
151
151
  },
152
152
  schema: [],
153
153
  },
@@ -197,7 +197,7 @@ const noServerDom = {
197
197
  },
198
198
  messages: {
199
199
  noServerDom:
200
- "Server-side DOM library '{{name}}' is forbidden. Common LLM mistake: rendering HTML server-side instead of emitting JSON. The OTM client hydrates static templates via $.clone() + the() — keep server output to data.",
200
+ "Server-side DOM library '{{name}}' is forbidden. Common LLM mistake: rendering HTML server-side instead of emitting JSON. The OTM client hydrates static templates via $.clone() + the() — keep server output to data. See README §The Discipline.",
201
201
  },
202
202
  schema: [],
203
203
  },
@@ -237,7 +237,7 @@ const noDocumentQuery = {
237
237
  },
238
238
  messages: {
239
239
  noDocumentQuery:
240
- "Direct document.{{method}} is forbidden. Common LLM mistake: querying through document instead of OTM's selectors. Use $(selector) for one, $$(selector) for many, or $.clone(parent, '#tmpl') for templates.",
240
+ "Direct document.{{method}} is forbidden. Common LLM mistake: querying through document instead of OTM's selectors. Use $(selector) for one, $$(selector) for many, or $.clone(parent, '#tmpl') for templates. See README §The Discipline.",
241
241
  },
242
242
  schema: [],
243
243
  },
@@ -129,6 +129,10 @@ export default class Linter {
129
129
  Linter.#traverse(document, (node) => {
130
130
  if (node.nodeName === "template") {
131
131
  const idAttr = node.attrs?.find((a) => a.name === "id");
132
+ const dynamic = node.attrs?.some(
133
+ (a) => a.name === "data-otm-dynamic",
134
+ );
135
+ if (dynamic) return;
132
136
  if (idAttr?.value) {
133
137
  const loc = node.sourceCodeLocation || {
134
138
  startLine: 1,
package/src/linter/cli.js CHANGED
@@ -6,14 +6,11 @@ export default class Cli {
6
6
  static async run(args = []) {
7
7
  console.log("on_the_money.js Linter (Experimental)");
8
8
 
9
- const wantConformance = args.includes("--conformance");
10
-
11
9
  if (args.includes("--check")) {
12
10
  const dirIndex = args.indexOf("--check") + 1;
13
11
  const targetDir = args[dirIndex] || ".";
14
12
  try {
15
- const total = await Cli.scan(targetDir, { wantConformance });
16
- return total;
13
+ return await Cli.scan(targetDir);
17
14
  } catch (e) {
18
15
  console.error(`Error scanning ${targetDir}: ${e.message}`);
19
16
  return -1;
@@ -42,7 +39,7 @@ export default class Cli {
42
39
  .filter((f) => exts.includes(path.extname(f)));
43
40
  }
44
41
 
45
- static async scan(dir, opts = {}) {
42
+ static async scan(dir) {
46
43
  const allFiles = await Cli.getFiles(dir, [".html", ".js", ".json"]);
47
44
  const htmlPaths = allFiles.filter((f) => f.endsWith(".html"));
48
45
  const jsPaths = allFiles.filter((f) => f.endsWith(".js"));
@@ -121,10 +118,6 @@ export default class Cli {
121
118
  );
122
119
  }
123
120
 
124
- if (opts.wantConformance) {
125
- console.log(`OTM conformance: ${totalViolations} violations`);
126
- }
127
-
128
121
  return totalViolations;
129
122
  }
130
123