on_the_money 0.3.3 → 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/CHANGELOG.md CHANGED
@@ -4,6 +4,93 @@ 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.1] — 2026-05-23
8
+
9
+ ### Documentation
10
+
11
+ - **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.
12
+
13
+ ## [0.5.0] — 2026-05-23
14
+
15
+ ### Breaking
16
+
17
+ - **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)
18
+ - **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)
19
+
20
+ ### Added
21
+
22
+ - **`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)
23
+ - **`$.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)
24
+
25
+ ### Documentation
26
+
27
+ - **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)
28
+ - **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)
29
+ - **`_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)
30
+ - **Key naming convention** documented under `the(...)` — kebab-case is the canonical surface.
31
+
32
+ ## [0.4.0] — 2026-05-23
33
+
34
+ ### Breaking
35
+
36
+ - **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?".
37
+ - **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.
38
+ - **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.
39
+ - **Removed `--conformance` flag from `otm-lint`.** Exit code already encodes violation count; the flag duplicated that signal in output without distinct value.
40
+
41
+ ### Added
42
+
43
+ - **`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.
44
+
45
+ ### Documentation
46
+
47
+ - **README**: ARIA inclusion criterion documented (closed set, no future expansion).
48
+ - **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."
49
+ - **README**: plain attribute writes (`href`, `value`, `rel`) explicitly use `setAttribute` directly; the framework deliberately doesn't wrap them.
50
+ - **README**: dynamic `<title>` and other `<head>` slot hydration via the body-rooted `the()` is documented as the canonical pattern.
51
+
52
+ ### Why this release
53
+
54
+ 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.
55
+
56
+ ## [0.3.6] — 2026-05-23
57
+
58
+ ### Added
59
+
60
+ - **`otm-lint` cross-file checks.** Three new rules that no per-format tool can do:
61
+ - **HTML-101 — orphan templates.** `<template id="X">` declared but never referenced by `$.clone(_, "#X")`. Catches dead-template drift after refactors. (#59.1)
62
+ - **HTML-102 — missing i18n keys.** `data-i18n="K"` references a key not in any locale dictionary loaded via the file's `<meta name="i18n">`. Previously silent-fallback-to-key was a runtime UX bug; now caught at lint time. (#59.3)
63
+ - **HTML-103 — placeholder token mismatch.** `data-i18n-{var}` attr has no matching `{var}` token in the dictionary entry. The token is silently dropped at runtime; the lint surfaces it. (#59.4)
64
+ - **`--conformance` flag.** Appends a single machine-grep-able line: `OTM conformance: N violations`. Useful for CI gates and LLM tooling. (#59.5)
65
+
66
+ ### Changed
67
+
68
+ - **`otm-lint` now walks `.html`, `.js`, and locale `.json` files** (was `.html` only). JS files contribute `$.clone(_, "#id")` references for orphan-template analysis. Locale JSONs load per HTML file's own `<meta name="i18n">` so multi-app scans (e.g. `examples/blog` + `examples/todo` with different locale sets) don't cross-contaminate.
69
+
70
+ ## [0.3.5] — 2026-05-23
71
+
72
+ ### Added
73
+
74
+ - **`otm/no-server-dom`** — bans imports of `linkedom`, `jsdom`, `cheerio`, `parse5`, `htmlparser2`, `happy-dom`, `node-html-parser`, `parse5-htmlparser2-tree-adapter`, `fast-html-parser`. Catches the "render HTML server-side" instinct at the import line. (#58)
75
+ - **`otm/no-document-query`** — bans `document.querySelector`, `getElementById`, `createElement`, `write`, etc. Forces consumers through `$()` / `$$()` / `$.clone()`. Bridge code (MutationObserver targets, etc.) uses `// eslint-disable-next-line otm/no-document-query`. (#58)
76
+
77
+ ### Changed
78
+
79
+ - **ESLint config now matches `.ts`/`.tsx`/`.mts`/`.cts` files** in addition to `.js` variants. Consumers using Node 25 native TS execution (or any project with `.ts` files) no longer have OTM rules silently skip server-side code. README note: matching syntax still requires a TS-aware parser like `@typescript-eslint/parser`. (#57)
80
+ - **All ESLint rule messages now include "Common LLM mistake: ..." hints** — turns rule output into a teaching surface. LLMs read rule messages more reliably than README sections, so corrections plant at the point of failure. (#59.6)
81
+
82
+ ## [0.3.4] — 2026-05-23
83
+
84
+ ### Added
85
+
86
+ - **`the.title(str)`** — sets `document.title` and returns the `<title>` element. Closes the head-hydration gap; `the()` only walks body, so dynamic page titles previously required `document.title = "..."`. (#60)
87
+ - **`the.attr(el, name, val)` / `the.attr(el, { attrs })`** — plain attribute writes for non-`data-*`/non-ARIA attributes (`href`, `value`, `rel`, `for`, etc.). Object form for batch. Lets app code stop calling `el.setAttribute(...)` directly. (#62)
88
+ - **Extended ARIA mapping** in `the()`: adds `invalid`, `required`, `readonly`, `pressed`, `current` → `aria-*`. Common form/widget states no longer require `setAttribute` fallback. (#61)
89
+
90
+ ### Changed
91
+
92
+ - **README preamble** explicitly signals that the file is the authoring context for AI agents. **`package.json` description** ends with "See README.md for the authoring context." Reduces inference cost for LLMs walking package metadata. (#59.7, modified — no satellite `LLMS.md` file)
93
+
7
94
  ## [0.3.3] — 2026-05-22
8
95
 
9
96
  ### Added
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # on_the_money.js
2
2
 
3
+ > _This README is the authoring context for AI agents using `on_the_money`. Read top-to-bottom for a complete picture; everything you need to write an OTM app is here. No satellite docs._
4
+
3
5
  Opinionated, attribute-driven, standards-oriented modern framework for the web. <2KB gzip. Native browser APIs only. ESNext.
4
6
 
5
- 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.
6
8
 
7
9
  ## Install
8
10
 
@@ -65,7 +67,7 @@ Then write semantic HTML (`<main>`, `<nav>`, `<article>`, `<form>`, `<button>`,
65
67
  import { on, the, _t, route, $, $$ } from "on_the_money";
66
68
  ```
67
69
 
68
- Aliases on `the`: `the.t === _t`, `the.route === route`, `the.form(formEl)`, `the.flat(obj, sep?)`, `the.boot(options?)`.
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`.
69
71
  Live accessors on `the`: `the.dictionary`, `the.locale`.
70
72
 
71
73
  ## API
@@ -103,13 +105,17 @@ Polymorphic on a single disambiguator: `args[0] instanceof Element`. Three call
103
105
  | Call | Behavior | Returns |
104
106
  | --- | --- | --- |
105
107
  | `the(key)` | Read body `data-KEY` (or aria-mapped equivalent). | `string \| null` |
106
- | `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` |
107
109
  | `the({ k: v, ... })` | Batch global write. | `document.body` |
108
110
  | `the(el, key)` | Read scoped attribute on `el`. | `string \| null` |
109
111
  | `the(el, key, val)` | Write scoped attribute on `el` + descendant `[data-text="key"]`. | `el` |
110
112
  | `the(el, { k: v, ... })` | Batch scoped write. | `el` |
111
113
 
112
- - **ARIA mapping** (key attribute): `expanded`, `selected`, `hidden`, `checked`, `disabled` `aria-*`. All other keys `data-*`.
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.
113
119
  - **Booleans coerce** to `"true"`/`"false"` inside the setter. `the(el, "checked", true)` writes `"true"`.
114
120
  - **Values MUST be flat primitives.** Pass nested objects through `the.flat(...)` first.
115
121
  - **`the(key, undefined)` throws.** Two args means set; missing val is a contract violation.
@@ -144,7 +150,7 @@ Throws on non-object input.
144
150
  Importing the module does **nothing**. Call `the.boot()` at the consumer's entry point.
145
151
 
146
152
  ```javascript
147
- await the.boot({ signal, locales, dictionary, namespace, defaultLocale });
153
+ await the.boot({ signal, locales, dictionary, namespace, defaultLocale, persistKeys });
148
154
  ```
149
155
 
150
156
  | Option | Type | Behavior |
@@ -154,7 +160,7 @@ await the.boot({ signal, locales, dictionary, namespace, defaultLocale });
154
160
  | `dictionary` | `object` | Inline dictionary; skips the fetch entirely. |
155
161
  | `namespace` | `string` | Sets `localStorage` prefix to `${namespace}:` (default `otm:`). Must be set before any state ops. |
156
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. |
157
- | `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. |
158
164
 
159
165
  Boot sequence:
160
166
  1. Resolve locale: `?lang=` query → `localStorage["${prefix}lang"]` → `navigator.language`. Writes `the.locale`.
@@ -207,6 +213,23 @@ route((pathname, search, hash) => {
207
213
 
208
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.
209
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
+
210
233
  ### `$(context, selector)` / `$$(context, selector)` — context-aware DOM query
211
234
 
212
235
  ```javascript
@@ -216,13 +239,26 @@ $(".child"); // shorthand: context = document
216
239
  $$(".child");
217
240
  ```
218
241
 
219
- ### `$.clone(parent, selector)` — template instantiation
242
+ ### `$.clone(parent, selector, options?)` — template instantiation
220
243
 
221
244
  ```javascript
222
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
223
248
  ```
224
249
 
225
- 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.
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.
226
262
 
227
263
  ## Patterns
228
264
 
@@ -298,6 +334,52 @@ on.emit(document.body, "modal-closed");
298
334
  off(); // detach when modal is destroyed
299
335
  ```
300
336
 
337
+ ### Routing with `the.match`
338
+
339
+ ```javascript
340
+ route(() => {
341
+ const post = the.match("/posts/:slug");
342
+ if (post) return renderPost(post.slug);
343
+
344
+ const profile = the.match("/@:user");
345
+ if (profile) return renderProfile(profile.user);
346
+
347
+ if (location.pathname === "/") return renderHome();
348
+ });
349
+ ```
350
+
351
+ ### Reacting to state changes (MutationObserver)
352
+
353
+ The framework deliberately doesn't broadcast events on every `the()` write. To react to attribute changes, use the platform's primitive directly. This is the canonical pattern for state-driven UI hooks the framework can't express declaratively (modal orchestration, focus management, etc.):
354
+
355
+ ```javascript
356
+ new MutationObserver(() => {
357
+ const wantModal = the("modal");
358
+ for (const dialog of $$("dialog[open]")) {
359
+ if (dialog.id !== `${wantModal}-modal`) dialog.close();
360
+ }
361
+ if (wantModal) $(`#${wantModal}-modal`)?.showModal();
362
+ }).observe(document.body, { attributes: true, attributeFilter: ["data-modal"] });
363
+ ```
364
+
365
+ `document.body` is the allowed identifier under `otm/no-document-query`. Filter by `attributeFilter` to scope the observer narrowly.
366
+
367
+ ### Working around `_t()` with an empty dictionary
368
+
369
+ 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:
370
+
371
+ ```javascript
372
+ // Template literal — the platform's interpolation primitive
373
+ const greeting = `Hello, ${name}!`;
374
+
375
+ // Or wrap once for callers that need a uniform API across locales
376
+ function greet(name) {
377
+ return the.dictionary.greeting
378
+ ? _t("greeting", { name })
379
+ : `Hello, ${name}!`;
380
+ }
381
+ ```
382
+
301
383
  ### Server-side rendering (no extra API)
302
384
 
303
385
  There's no SSR shim. Emit `data-*` attributes from the server:
@@ -347,9 +429,13 @@ export default [
347
429
  | `otm/flat-state` | bundled in `on_the_money` | Ban nested objects/arrays in `the()` calls. |
348
430
  | `otm/prefer-submit` | bundled in `on_the_money` | Warn on `on(btn, "click", ...)` for form data. |
349
431
  | `otm/no-style-mutation` | bundled in `on_the_money` | Ban `el.style.* = ...`. |
432
+ | `otm/no-server-dom` | bundled in `on_the_money` | Ban imports of `linkedom`, `jsdom`, `cheerio`, `parse5`, etc. — emit JSON and let the OTM client hydrate. |
433
+ | `otm/no-document-query` | bundled in `on_the_money` | Ban `document.querySelector`/`getElementById`/`createElement`/etc.; use `$`/`$$`/`$.clone`. Use `// eslint-disable-next-line otm/no-document-query` for legitimate bridge code (MutationObserver targets, etc.). |
350
434
  | `no-unsanitized/no-inner-html` | `eslint-plugin-no-unsanitized` | Ban `innerHTML`/`outerHTML`. |
351
435
  | `no-unsanitized/method` | `eslint-plugin-no-unsanitized` | Ban `document.write`, `insertAdjacentHTML`. |
352
436
 
437
+ The recommended config matches `**/*.{js,mjs,cjs,ts,mts,cts,tsx,jsx}` — your TypeScript files are covered if you bring a TS-aware parser like `@typescript-eslint/parser`. OTM's rules read AST nodes that don't depend on type info, but the parser still has to understand the syntax.
438
+
353
439
  ### 2. CSS — Stylelint + bundled plugin
354
440
 
355
441
  Same pattern: the Stylelint plugin and config ship inside `on_the_money`. No separate `stylelint-plugin-otm` package.
@@ -385,8 +471,21 @@ npx otm-lint --check ./src
385
471
  | **HTML-017** | `<div data-action="...">` without `role`/`tabindex` | Use a `<button>` or other interactive element. |
386
472
  | **HTML-023** | `data-i18n="..."` without `<meta name="i18n">` | Declare the i18n endpoint. |
387
473
  | **HTML-024** | `data-available="..."` doesn't match locales folder | Keep the manifest aligned with the actual locale files. |
474
+ | **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. |
475
+ | **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. |
476
+ | **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. |
477
+
478
+ `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.
479
+
480
+ ### Lint-rule scope
388
481
 
389
- `otm-lint` default-excludes `node_modules`, `dist`, `.git`, and dotdirs. It only scans `.html` everything else delegates to the layers above.
482
+ These rules are **discipline aids, not enforcement boundaries.** Each catches the obvious shape; determined consumers can route around them:
483
+
484
+ - `otm/no-server-dom` matches static `import` only. Dynamic `await import("linkedom")` and CJS `require()` are not caught.
485
+ - `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`.
486
+ - `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.
487
+
488
+ 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.
390
489
 
391
490
  ### Recommended companions (not shipped)
392
491
 
@@ -397,6 +496,30 @@ npx otm-lint --check ./src
397
496
  | Runtime a11y | `axe-core` via Playwright/Cypress against the rendered page |
398
497
  | Supply-chain | `npm audit`, `osv-scanner` |
399
498
 
499
+ ## Production CSS purging
500
+
501
+ 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:
502
+
503
+ ```javascript
504
+ // postcss.config.js
505
+ import purgecss from "@fullhuman/postcss-purgecss";
506
+
507
+ export default {
508
+ plugins: [
509
+ purgecss({
510
+ content: ["src/**/*.html", "src/**/*.js"],
511
+ defaultExtractor: (s) => s.match(/[A-Za-z0-9_-]+/g) || [],
512
+ safelist: {
513
+ standard: [/^aria-/, "hidden"],
514
+ deep: [/^\[data-/, /^aria-/, /role-/],
515
+ },
516
+ }),
517
+ ],
518
+ };
519
+ ```
520
+
521
+ 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.
522
+
400
523
  ## Suggested project layout
401
524
 
402
525
  ```
@@ -1 +1 @@
1
- var w=class{static on(t,e,o,n){let r=typeof t=="string"?document.querySelector(t):t||document.body,a=i=>{let s=i.target.closest(o);s&&r.contains(s)&&n.call(s,i,s)};return r.addEventListener(e,a),()=>r.removeEventListener(e,a)}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 l=class c{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?c.#e(t[0],t.slice(1)):c.#e(document.body,t)}static#e(t,e){let[o,n]=e,r=t===document.body;if(e.length===1&&typeof o=="string")return c.#o(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 c.#t(t,o,n),r&&!c.ephemeralKeys.has(o)&&localStorage.setItem(`${c.prefix}${o}`,n),t}if(e.length===1&&o?.constructor===Object){for(let[a,i]of Object.entries(o))c.#t(t,a,i),r&&!c.ephemeralKeys.has(a)&&localStorage.setItem(`${c.prefix}${a}`,i);return t}throw new TypeError(`the(): unrecognized call shape (${e.map(a=>typeof a).join(", ")})`)}static flat(t,e="_"){if(t===null||typeof t!="object")throw new TypeError("the.flat: input must be an object");let o={},n=(r,a)=>{if(r===null||typeof r!="object"){o[a]=r;return}for(let[i,s]of Object.entries(r))n(s,a?`${a}${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 a=(n.type||"text").toLowerCase();if(a==="submit"||a==="button"||a==="reset"||(a==="checkbox"||a==="radio")&&!(n.checked??n.hasAttribute("checked")))continue;let i=n.value??n.getAttribute("value")??"";c.#n(e,r,i)}return e}static#n(t,e,o){let n=e.split(/[\[\]]/).filter(Boolean),r=t;for(let a=0;a<n.length;a++){let i=n[a];a===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 a=t.querySelectorAll?[t,...t.querySelectorAll("[data-i18n]")]:[t];for(let i of a)if(i.hasAttribute?.("data-i18n")){let s=i.getAttribute("data-i18n"),p={};for(let y of i.attributes)if(y.name.startsWith("data-i18n-")&&y.name!=="data-i18n-type"){let x=y.name.replace("data-i18n-","");p[x]=y.value}let h=i.getAttribute("data-i18n-type");i.textContent=c._t(s,{...p,type:h})}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 a=e.type==="currency"?new Intl.NumberFormat(c.locale,{style:"currency",currency:"USD"}):new Intl.DateTimeFormat(c.locale);return e.type==="date"?a.format(new Date(e.val)):a.format(Number(e.val))}return t}if(typeof n=="object"){let a=e.qty!==void 0?Number(e.qty):0,i=new Intl.PluralRules(c.locale).select(a);n=n[i]||n.other||t}if(typeof n!="string")return t;let r=n;for(let[a,i]of Object.entries(e)){let s=i;if(a==="val"&&e.type){let p=Number(i),h=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"?h.format(new Date(i)):h.format(p)}r=r.replace(`{${a}}`,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",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#o(t,e){let n={expanded:"aria-expanded",selected:"aria-selected",hidden:"aria-hidden",checked:"aria-checked",disabled:"aria-disabled"}[e]||`data-${e}`;return t.getAttribute(n)}static#t(t,e,o){let r={expanded:"aria-expanded",selected:"aria-selected",hidden:"aria-hidden",checked:"aria-checked",disabled:"aria-disabled"}[e]||`data-${e}`,a=typeof o=="boolean"?o?"true":"false":o;if(t.setAttribute(r,a),t.querySelectorAll){let i=t.querySelectorAll(`[data-text="${e}"]`);for(let s of i)s.textContent=a}t.getAttribute?.("data-text")===e&&(t.textContent=a)}static async boot({signal:t,locales:e,dictionary:o,namespace:n,defaultLocale:r,ephemeralKeys:a}={}){n&&(c.prefix=`${n}:`),a&&(c.ephemeralKeys=new Set(a));let i=typeof window<"u"&&window.location?window.location.search:"",s=new URLSearchParams(i),p=(typeof navigator<"u"?navigator.language:null)||document.documentElement.lang||"en";c.locale=s.get("lang")||localStorage.getItem(`${c.prefix}lang`)||p;let h=r||document.documentElement.lang,y=c.locale.toLowerCase().split("-")[0],x=h?.toLowerCase().split("-")[0],A=!!x&&y===x;if(!A)if(o)c.dictionary=o;else{let u=document.querySelector('meta[name="i18n"]'),b=e||u?.getAttribute("content");if(b){let g=u?.getAttribute("data-fallback")||"en",$=(u?.getAttribute("data-available")||"").split(",").map(f=>f.trim().toLowerCase()),v=c.locale.toLowerCase(),E=v.split("-")[0],S=g;$.includes(v)?S=v:$.includes(E)&&(S=E);try{let f=await fetch(`${b}/${S}.json`,{signal:t});f.ok&&(c.dictionary=await f.json())}catch(f){if(t?.aborted)throw f;console.warn("otm: i18n fetch failed",f)}}}for(let u=0;u<localStorage.length;u++){let b=localStorage.key(u);if(b.startsWith(c.prefix)){let g=b.slice(c.prefix.length);if(g!=="lang"&&!c.ephemeralKeys.has(g)){let $=localStorage.getItem(b);c.#t(document.body,g,$)}}}A||c._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;l._t(r),o.appendChild(r);let a=new CustomEvent("mounted",{bubbles:!0,detail:{parent:o}});return r.dispatchEvent(a),r}};var q=w.on;q.emit=w.emit;var d=l.the,L=l._t,j=l.route;d.t=L;d.route=j;d.form=l.form;d.flat=l.flat;d.boot=l.boot;Object.defineProperty(d,"dictionary",{get:()=>l.dictionary,set:c=>{l.dictionary=c}});Object.defineProperty(d,"locale",{get:()=>l.locale,set:c=>{l.locale=c}});var N=m.$;N.clone=m.clone;var C=m.$$,P={on:q,the:d,$:N,$$:C,_t:L};export{N as $,C as $$,L as _t,P as default,q as on,j as route,d 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,7 +1,7 @@
1
1
  {
2
2
  "name": "on_the_money",
3
- "version": "0.3.3",
4
- "description": "Opinionated, attribute-driven, standards-oriented modern framework. <2KB gzip. Native browser APIs only.",
3
+ "version": "0.5.1",
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",
7
7
  "module": "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,6 +60,30 @@ export default class The {
47
60
  );
48
61
  }
49
62
 
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
+ },
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;
85
+ }
86
+
50
87
  static flat(obj, sep = "_") {
51
88
  if (obj === null || typeof obj !== "object") {
52
89
  throw new TypeError("the.flat: input must be an object");
@@ -233,32 +270,36 @@ export default class The {
233
270
  navigate();
234
271
  }
235
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.
276
+ static #ariaMap = {
277
+ expanded: "aria-expanded",
278
+ selected: "aria-selected",
279
+ hidden: "aria-hidden",
280
+ checked: "aria-checked",
281
+ disabled: "aria-disabled",
282
+ };
283
+
236
284
  static #get(el, key) {
237
- const ariaMap = {
238
- expanded: "aria-expanded",
239
- selected: "aria-selected",
240
- hidden: "aria-hidden",
241
- checked: "aria-checked",
242
- disabled: "aria-disabled",
243
- };
244
- const attr = ariaMap[key] || `data-${key}`;
285
+ const attr = The.#ariaMap[key] || `data-${key}`;
245
286
  return el.getAttribute(attr);
246
287
  }
247
288
 
248
289
  static #set(el, key, val) {
249
- const ariaMap = {
250
- expanded: "aria-expanded",
251
- selected: "aria-selected",
252
- hidden: "aria-hidden",
253
- checked: "aria-checked",
254
- disabled: "aria-disabled",
255
- };
256
- const attr = ariaMap[key] || `data-${key}`;
290
+ const attr = The.#ariaMap[key] || `data-${key}`;
257
291
  const out = typeof val === "boolean" ? (val ? "true" : "false") : val;
258
292
  el.setAttribute(attr, out);
259
293
 
260
- if (el.querySelectorAll) {
261
- 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}"]`);
262
303
  for (const item of items) item.textContent = out;
263
304
  }
264
305
  if (el.getAttribute?.("data-text") === key) el.textContent = out;
@@ -270,10 +311,11 @@ export default class The {
270
311
  dictionary,
271
312
  namespace,
272
313
  defaultLocale,
273
- ephemeralKeys,
314
+ persistKeys,
274
315
  } = {}) {
275
316
  if (namespace) The.prefix = `${namespace}:`;
276
- if (ephemeralKeys) The.ephemeralKeys = new Set(ephemeralKeys);
317
+ if (persistKeys)
318
+ The.persistKeys = new Set(persistKeys.map((k) => The.#kebab(k)));
277
319
  const search =
278
320
  typeof window !== "undefined" && window.location
279
321
  ? window.location.search
@@ -328,7 +370,7 @@ export default class The {
328
370
  const fullKey = localStorage.key(i);
329
371
  if (fullKey.startsWith(The.prefix)) {
330
372
  const key = fullKey.slice(The.prefix.length);
331
- if (key !== "lang" && !The.ephemeralKeys.has(key)) {
373
+ if (key !== "lang" && The.persistKeys.has(key)) {
332
374
  const val = localStorage.getItem(fullKey);
333
375
  The.#set(document.body, key, val);
334
376
  }
package/src/core/index.js CHANGED
@@ -12,6 +12,7 @@ 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
17
 
17
18
  Object.defineProperty(the, "dictionary", {
@@ -2,6 +2,7 @@ import plugin from "./plugin.js";
2
2
 
3
3
  const recommended = [
4
4
  {
5
+ files: ["**/*.{js,mjs,cjs,ts,mts,cts,tsx,jsx}"],
5
6
  plugins: { otm: plugin },
6
7
  rules: {
7
8
  "otm/prefer-on": "error",
@@ -9,6 +10,8 @@ const recommended = [
9
10
  "otm/flat-state": "error",
10
11
  "otm/prefer-submit": "warn",
11
12
  "otm/no-style-mutation": "error",
13
+ "otm/no-server-dom": "error",
14
+ "otm/no-document-query": "error",
12
15
  },
13
16
  },
14
17
  ];
@@ -1,6 +1,6 @@
1
1
  const meta = {
2
2
  name: "eslint-plugin-otm",
3
- version: "0.3.0",
3
+ version: "0.3.4",
4
4
  };
5
5
 
6
6
  const preferOn = {
@@ -12,7 +12,7 @@ const preferOn = {
12
12
  },
13
13
  messages: {
14
14
  useOn:
15
- "Direct addEventListener is forbidden. Use on() for event delegation.",
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.",
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.",
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.",
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(). Use the.flat() to compose.",
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.",
68
68
  },
69
69
  schema: [],
70
70
  },
@@ -114,7 +114,7 @@ const preferSubmit = {
114
114
  },
115
115
  messages: {
116
116
  useSubmit:
117
- "Prefer using <form> submit events over direct button click listeners.",
117
+ "Prefer using <form> submit events over direct button click listeners. Common LLM mistake: per-button click handlers — one form submit handler captures every input route.",
118
118
  },
119
119
  schema: [],
120
120
  },
@@ -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.",
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.",
151
151
  },
152
152
  schema: [],
153
153
  },
@@ -189,12 +189,100 @@ const noStyleMutation = {
189
189
  },
190
190
  };
191
191
 
192
+ const noServerDom = {
193
+ meta: {
194
+ type: "problem",
195
+ docs: {
196
+ description: "Disallow imports of server-side DOM libraries.",
197
+ },
198
+ messages: {
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.",
201
+ },
202
+ schema: [],
203
+ },
204
+ create(context) {
205
+ const banned = new Set([
206
+ "linkedom",
207
+ "jsdom",
208
+ "cheerio",
209
+ "parse5",
210
+ "htmlparser2",
211
+ "happy-dom",
212
+ "node-html-parser",
213
+ "parse5-htmlparser2-tree-adapter",
214
+ "fast-html-parser",
215
+ ]);
216
+ return {
217
+ ImportDeclaration(node) {
218
+ const src = node.source?.value;
219
+ if (typeof src === "string" && banned.has(src)) {
220
+ context.report({
221
+ node,
222
+ messageId: "noServerDom",
223
+ data: { name: src },
224
+ });
225
+ }
226
+ },
227
+ };
228
+ },
229
+ };
230
+
231
+ const noDocumentQuery = {
232
+ meta: {
233
+ type: "problem",
234
+ docs: {
235
+ description:
236
+ "Disallow direct document.* DOM queries; use $/$$/$.clone instead.",
237
+ },
238
+ messages: {
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.",
241
+ },
242
+ schema: [],
243
+ },
244
+ create(context) {
245
+ const banned = new Set([
246
+ "querySelector",
247
+ "querySelectorAll",
248
+ "getElementById",
249
+ "getElementsByClassName",
250
+ "getElementsByTagName",
251
+ "getElementsByName",
252
+ "createElement",
253
+ "createTextNode",
254
+ "createDocumentFragment",
255
+ "write",
256
+ "writeln",
257
+ ]);
258
+ return {
259
+ CallExpression(node) {
260
+ const callee = node.callee;
261
+ if (
262
+ callee?.type === "MemberExpression" &&
263
+ callee.object?.type === "Identifier" &&
264
+ callee.object.name === "document" &&
265
+ banned.has(callee.property?.name)
266
+ ) {
267
+ context.report({
268
+ node,
269
+ messageId: "noDocumentQuery",
270
+ data: { method: callee.property.name },
271
+ });
272
+ }
273
+ },
274
+ };
275
+ },
276
+ };
277
+
192
278
  const rules = {
193
279
  "prefer-on": preferOn,
194
280
  "prefer-the-set": preferTheSet,
195
281
  "flat-state": flatState,
196
282
  "prefer-submit": preferSubmit,
197
283
  "no-style-mutation": noStyleMutation,
284
+ "no-server-dom": noServerDom,
285
+ "no-document-query": noDocumentQuery,
198
286
  };
199
287
 
200
288
  const plugin = {
@@ -211,6 +299,8 @@ plugin.configs = {
211
299
  "otm/flat-state": "error",
212
300
  "otm/prefer-submit": "warn",
213
301
  "otm/no-style-mutation": "error",
302
+ "otm/no-server-dom": "error",
303
+ "otm/no-document-query": "error",
214
304
  },
215
305
  },
216
306
  };
@@ -119,6 +119,123 @@ export default class Linter {
119
119
  }
120
120
  }
121
121
 
122
+ static crossCheck({ htmlSources, jsSources }) {
123
+ const violations = [];
124
+
125
+ // HTML-101: orphan templates — <template id="X"> never referenced by $.clone(_, "#X")
126
+ const templates = new Map();
127
+ for (const { file, source } of htmlSources) {
128
+ const document = parse5.parse(source, { sourceCodeLocationInfo: true });
129
+ Linter.#traverse(document, (node) => {
130
+ if (node.nodeName === "template") {
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;
136
+ if (idAttr?.value) {
137
+ const loc = node.sourceCodeLocation || {
138
+ startLine: 1,
139
+ startCol: 1,
140
+ };
141
+ templates.set(idAttr.value, {
142
+ file,
143
+ line: loc.startLine,
144
+ column: loc.startCol,
145
+ });
146
+ }
147
+ }
148
+ });
149
+ }
150
+
151
+ const cloneRefs = new Set();
152
+ for (const { source } of jsSources) {
153
+ const matches = source.matchAll(
154
+ /\$\.clone\s*\([^,)]+,\s*['"]#([^'"]+)['"]/g,
155
+ );
156
+ for (const m of matches) cloneRefs.add(m[1]);
157
+ }
158
+
159
+ for (const [id, info] of templates) {
160
+ if (!cloneRefs.has(id)) {
161
+ Linter.#addViolation(
162
+ violations,
163
+ info.file,
164
+ { line: info.line, column: info.column },
165
+ "HTML-101",
166
+ `Orphan template: <template id="${id}"> is never referenced by $.clone(). Remove the template or add a clone call.`,
167
+ );
168
+ }
169
+ }
170
+
171
+ // HTML-102: data-i18n keys missing from the html file's own locale dictionaries
172
+ // HTML-103: data-i18n-{var} attrs reference template tokens that don't exist in the dictionary entry
173
+ for (const { file, source, dicts } of htmlSources) {
174
+ if (!dicts || dicts.length === 0) continue;
175
+
176
+ const document = parse5.parse(source, { sourceCodeLocationInfo: true });
177
+ Linter.#traverse(document, (node) => {
178
+ const attrs = node.attrs
179
+ ? Object.fromEntries(node.attrs.map((a) => [a.name, a.value]))
180
+ : {};
181
+ if (!attrs["data-i18n"]) return;
182
+
183
+ const key = attrs["data-i18n"];
184
+ const params = [];
185
+ for (const attr of node.attrs) {
186
+ if (
187
+ attr.name.startsWith("data-i18n-") &&
188
+ attr.name !== "data-i18n-type"
189
+ ) {
190
+ params.push(attr.name.replace("data-i18n-", ""));
191
+ }
192
+ }
193
+ const loc = node.sourceCodeLocation?.attrs?.["data-i18n"] ||
194
+ node.sourceCodeLocation || { startLine: 1, startCol: 1 };
195
+ const where = { line: loc.startLine, column: loc.startCol };
196
+
197
+ const foundIn = dicts.filter((d) => key in d.dict);
198
+ if (foundIn.length === 0) {
199
+ Linter.#addViolation(
200
+ violations,
201
+ file,
202
+ where,
203
+ "HTML-102",
204
+ `i18n key "${key}" is not declared in any locale dictionary (${dicts.map((d) => d.locale).join(", ")}).`,
205
+ );
206
+ return;
207
+ }
208
+
209
+ for (const { locale, dict } of foundIn) {
210
+ const entry = dict[key];
211
+ let template;
212
+ if (typeof entry === "string") template = entry;
213
+ else if (typeof entry === "object" && entry !== null) {
214
+ template = entry.other || entry.one || Object.values(entry)[0];
215
+ }
216
+ if (typeof template !== "string") continue;
217
+
218
+ const tokens = new Set();
219
+ for (const m of template.matchAll(/\{([^}]+)\}/g)) tokens.add(m[1]);
220
+
221
+ for (const param of params) {
222
+ if (!tokens.has(param)) {
223
+ Linter.#addViolation(
224
+ violations,
225
+ file,
226
+ where,
227
+ "HTML-103",
228
+ `data-i18n-${param} has no matching {${param}} token in dictionary entry "${key}" (locale "${locale}").`,
229
+ );
230
+ }
231
+ }
232
+ }
233
+ });
234
+ }
235
+
236
+ return violations;
237
+ }
238
+
122
239
  static #traverse(node, visitor) {
123
240
  visitor(node);
124
241
  const children = node.childNodes || node.content?.childNodes;
package/src/linter/cli.js CHANGED
@@ -19,33 +19,69 @@ export default class Cli {
19
19
  return 0;
20
20
  }
21
21
 
22
+ static #excludeDirs = new Set(["node_modules", "dist", ".git"]);
23
+
24
+ static async getFiles(dir, exts = [".html"]) {
25
+ const entries = await fs.readdir(dir, { withFileTypes: true });
26
+ const files = await Promise.all(
27
+ entries.map((res) => {
28
+ const resPath = path.resolve(dir, res.name);
29
+ if (res.isDirectory()) {
30
+ if (Cli.#excludeDirs.has(res.name) || res.name.startsWith("."))
31
+ return [];
32
+ return Cli.getFiles(resPath, exts);
33
+ }
34
+ return resPath;
35
+ }),
36
+ );
37
+ return Array.prototype.concat
38
+ .apply([], files)
39
+ .filter((f) => exts.includes(path.extname(f)));
40
+ }
41
+
22
42
  static async scan(dir) {
23
- const files = await Cli.getFiles(dir);
43
+ const allFiles = await Cli.getFiles(dir, [".html", ".js", ".json"]);
44
+ const htmlPaths = allFiles.filter((f) => f.endsWith(".html"));
45
+ const jsPaths = allFiles.filter((f) => f.endsWith(".js"));
24
46
  let totalViolations = 0;
25
47
 
26
- for (const file of files) {
48
+ const htmlSources = [];
49
+ for (const file of htmlPaths) {
27
50
  const source = await fs.readFile(file, "utf-8");
28
51
  let availableLocales = null;
52
+ const dicts = [];
29
53
 
30
- if (file.endsWith(".html")) {
31
- const match = source.match(/<meta\s+name="i18n"\s+content="([^"]+)"/);
32
- if (match) {
33
- const localesDir = path.resolve(
34
- path.dirname(file),
35
- match[1].startsWith("/") ? `.${match[1]}` : match[1],
36
- );
37
- try {
38
- const entries = await fs.readdir(localesDir);
39
- availableLocales = entries
40
- .filter((f) => f.endsWith(".json"))
41
- .map((f) => f.replace(".json", ""));
42
- } catch {
43
- // Folder not found or unreadable
54
+ const match = source.match(/<meta\s+name="i18n"\s+content="([^"]+)"/);
55
+ if (match) {
56
+ const localesDir = path.resolve(
57
+ path.dirname(file),
58
+ match[1].startsWith("/") ? `.${match[1]}` : match[1],
59
+ );
60
+ try {
61
+ const entries = await fs.readdir(localesDir);
62
+ availableLocales = entries
63
+ .filter((f) => f.endsWith(".json"))
64
+ .map((f) => f.replace(".json", ""));
65
+ for (const entry of entries) {
66
+ if (!entry.endsWith(".json")) continue;
67
+ const locale = entry.replace(".json", "");
68
+ try {
69
+ const raw = await fs.readFile(
70
+ path.join(localesDir, entry),
71
+ "utf-8",
72
+ );
73
+ dicts.push({ locale, dict: JSON.parse(raw) });
74
+ } catch {
75
+ // Skip unparseable locale file
76
+ }
44
77
  }
78
+ } catch {
79
+ // Folder not found or unreadable
45
80
  }
46
81
  }
47
82
 
48
83
  const violations = Linter.check(file, source, availableLocales);
84
+ htmlSources.push({ file, source, dicts });
49
85
 
50
86
  if (violations.length > 0) {
51
87
  totalViolations += violations.length;
@@ -53,34 +89,36 @@ export default class Cli {
53
89
  }
54
90
  }
55
91
 
92
+ const jsSources = [];
93
+ for (const file of jsPaths) {
94
+ const source = await fs.readFile(file, "utf-8");
95
+ jsSources.push({ file, source });
96
+ }
97
+
98
+ const crossViolations = Linter.crossCheck({
99
+ htmlSources,
100
+ jsSources,
101
+ });
102
+
103
+ if (crossViolations.length > 0) {
104
+ const byFile = new Map();
105
+ for (const v of crossViolations) {
106
+ if (!byFile.has(v.file)) byFile.set(v.file, []);
107
+ byFile.get(v.file).push(v);
108
+ }
109
+ for (const [file, vs] of byFile) Cli.report(file, vs);
110
+ totalViolations += crossViolations.length;
111
+ }
112
+
56
113
  if (totalViolations === 0) {
57
114
  console.log("✔ No violations found.");
58
115
  } else {
59
116
  console.log(
60
- `\n✖ Found ${totalViolations} violations across ${files.length} files.`,
117
+ `\n✖ Found ${totalViolations} violations across ${htmlPaths.length} HTML files.`,
61
118
  );
62
119
  }
63
- return totalViolations;
64
- }
65
120
 
66
- static #excludeDirs = new Set(["node_modules", "dist", ".git"]);
67
-
68
- static async getFiles(dir) {
69
- const entries = await fs.readdir(dir, { withFileTypes: true });
70
- const files = await Promise.all(
71
- entries.map((res) => {
72
- const resPath = path.resolve(dir, res.name);
73
- if (res.isDirectory()) {
74
- if (Cli.#excludeDirs.has(res.name) || res.name.startsWith("."))
75
- return [];
76
- return Cli.getFiles(resPath);
77
- }
78
- return resPath;
79
- }),
80
- );
81
- return Array.prototype.concat
82
- .apply([], files)
83
- .filter((f) => path.extname(f) === ".html");
121
+ return totalViolations;
84
122
  }
85
123
 
86
124
  static report(file, violations) {