on_the_money 0.3.2 → 0.3.6

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,54 @@ 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.3.6] — 2026-05-23
8
+
9
+ ### Added
10
+
11
+ - **`otm-lint` cross-file checks.** Three new rules that no per-format tool can do:
12
+ - **HTML-101 — orphan templates.** `<template id="X">` declared but never referenced by `$.clone(_, "#X")`. Catches dead-template drift after refactors. (#59.1)
13
+ - **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)
14
+ - **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)
15
+ - **`--conformance` flag.** Appends a single machine-grep-able line: `OTM conformance: N violations`. Useful for CI gates and LLM tooling. (#59.5)
16
+
17
+ ### Changed
18
+
19
+ - **`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.
20
+
21
+ ## [0.3.5] — 2026-05-23
22
+
23
+ ### Added
24
+
25
+ - **`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)
26
+ - **`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)
27
+
28
+ ### Changed
29
+
30
+ - **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)
31
+ - **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)
32
+
33
+ ## [0.3.4] — 2026-05-23
34
+
35
+ ### Added
36
+
37
+ - **`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)
38
+ - **`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)
39
+ - **Extended ARIA mapping** in `the()`: adds `invalid`, `required`, `readonly`, `pressed`, `current` → `aria-*`. Common form/widget states no longer require `setAttribute` fallback. (#61)
40
+
41
+ ### Changed
42
+
43
+ - **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)
44
+
45
+ ## [0.3.3] — 2026-05-22
46
+
47
+ ### Added
48
+
49
+ - **`the.boot({ ephemeralKeys })`** — declares state keys that should not persist to `localStorage`. Writes still update DOM and mirror to `[data-text]`, but skip storage; boot replay also skips them. Targets transient signals like `modal`, `loading`, `toast` that shouldn't survive page navigation. (#55)
50
+
51
+ ### Changed
52
+
53
+ - **`_t(key, options)`** now Intl-formats `options.val` even when the dictionary has no entry for `key`, provided `options.type` is `"currency"` or `"date"`. Default-locale apps (those using `defaultLocale` to skip the dictionary fetch) can now use `data-i18n-val` formatting without needing a placeholder dictionary entry. Missing entries with no `type` still return the key, as before. (#53)
54
+
7
55
  ## [0.3.2] — 2026-05-20
8
56
 
9
57
  ### Added
package/README.md CHANGED
@@ -1,5 +1,7 @@
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
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.
@@ -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.boot(options?)`, `the.title(str)`, `the.attr(el, ...)`. Live accessors: `the.dictionary`, `the.locale`.
69
71
  Live accessors on `the`: `the.dictionary`, `the.locale`.
70
72
 
71
73
  ## API
@@ -109,7 +111,7 @@ Polymorphic on a single disambiguator: `args[0] instanceof Element`. Three call
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
+ - **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-*`.
113
115
  - **Booleans coerce** to `"true"`/`"false"` inside the setter. `the(el, "checked", true)` writes `"true"`.
114
116
  - **Values MUST be flat primitives.** Pass nested objects through `the.flat(...)` first.
115
117
  - **`the(key, undefined)` throws.** Two args means set; missing val is a contract violation.
@@ -127,6 +129,27 @@ the.form(form);
127
129
 
128
130
  Returns a **nested** object (matches browser submission semantics). Compose with `the.flat` before feeding to `the(el, {...})`.
129
131
 
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
+
130
153
  ### `the.flat(obj, sep = "_")` — nested-to-flat
131
154
 
132
155
  ```javascript
@@ -154,6 +177,7 @@ await the.boot({ signal, locales, dictionary, namespace, defaultLocale });
154
177
  | `dictionary` | `object` | Inline dictionary; skips the fetch entirely. |
155
178
  | `namespace` | `string` | Sets `localStorage` prefix to `${namespace}:` (default `otm:`). Must be set before any state ops. |
156
179
  | `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. |
157
181
 
158
182
  Boot sequence:
159
183
  1. Resolve locale: `?lang=` query → `localStorage["${prefix}lang"]` → `navigator.language`. Writes `the.locale`.
@@ -187,7 +211,7 @@ _t(node); // hydrate every [data-i18n]
187
211
  _t(); // hydrate document.body
188
212
  ```
189
213
 
190
- Missing dictionary keys preserve existing `textContent` (SEO fallback).
214
+ Missing dictionary keys preserve existing `textContent` (SEO fallback). When `options.type` is `"currency"` or `"date"`, `Intl` formatting of `options.val` runs regardless of whether the dictionary has an entry — useful in default-locale apps that skip the fetch.
191
215
 
192
216
  `[data-i18n]` element binding (always include source-language fallback text inside):
193
217
 
@@ -346,9 +370,13 @@ export default [
346
370
  | `otm/flat-state` | bundled in `on_the_money` | Ban nested objects/arrays in `the()` calls. |
347
371
  | `otm/prefer-submit` | bundled in `on_the_money` | Warn on `on(btn, "click", ...)` for form data. |
348
372
  | `otm/no-style-mutation` | bundled in `on_the_money` | Ban `el.style.* = ...`. |
373
+ | `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. |
374
+ | `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.). |
349
375
  | `no-unsanitized/no-inner-html` | `eslint-plugin-no-unsanitized` | Ban `innerHTML`/`outerHTML`. |
350
376
  | `no-unsanitized/method` | `eslint-plugin-no-unsanitized` | Ban `document.write`, `insertAdjacentHTML`. |
351
377
 
378
+ 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.
379
+
352
380
  ### 2. CSS — Stylelint + bundled plugin
353
381
 
354
382
  Same pattern: the Stylelint plugin and config ship inside `on_the_money`. No separate `stylelint-plugin-otm` package.
@@ -384,8 +412,23 @@ npx otm-lint --check ./src
384
412
  | **HTML-017** | `<div data-action="...">` without `role`/`tabindex` | Use a `<button>` or other interactive element. |
385
413
  | **HTML-023** | `data-i18n="..."` without `<meta name="i18n">` | Declare the i18n endpoint. |
386
414
  | **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. |
416
+ | **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
+ | **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
+
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.
420
+
421
+ ### `--conformance` flag
422
+
423
+ Append `--conformance` for a single machine-grep-able summary line at the end of output:
424
+
425
+ ```
426
+ $ npx otm-lint --check ./src --conformance
427
+ ... violations ...
428
+ OTM conformance: 3 violations
429
+ ```
387
430
 
388
- `otm-lint` default-excludes `node_modules`, `dist`, `.git`, and dotdirs. It only scans `.html` everything else delegates to the layers above.
431
+ Useful for CI gates and LLM tooling that wants a one-line signal.
389
432
 
390
433
  ### Recommended companions (not shipped)
391
434
 
@@ -1 +1 @@
1
- var w=class{static on(t,o,n,e){let r=typeof t=="string"?document.querySelector(t):t||document.body,a=c=>{let s=c.target.closest(n);s&&r.contains(s)&&e.call(s,c,s)};return r.addEventListener(o,a),()=>r.removeEventListener(o,a)}static emit(t,o,n){let e=typeof t=="string"?document.querySelector(t):t,r=new CustomEvent(o,{bubbles:!0,cancelable:!0,detail:n});e.dispatchEvent(r)}};var l=class i{static dictionary={};static locale=typeof navigator<"u"?navigator.language:"en-US";static prefix="otm:";static the(...t){if(t.length===0)throw new TypeError("the(): missing args");return t[0]instanceof Element?i.#e(t[0],t.slice(1)):i.#e(document.body,t)}static#e(t,o){let[n,e]=o,r=t===document.body;if(o.length===1&&typeof n=="string")return i.#n(t,n);if(o.length===2&&typeof n=="string"){if(typeof e>"u")throw new TypeError(`the(${JSON.stringify(n)}, undefined): val is required for set`);return i.#t(t,n,e),r&&localStorage.setItem(`${i.prefix}${n}`,e),t}if(o.length===1&&n?.constructor===Object){for(let[a,c]of Object.entries(n))i.#t(t,a,c),r&&localStorage.setItem(`${i.prefix}${a}`,c);return t}throw new TypeError(`the(): unrecognized call shape (${o.map(a=>typeof a).join(", ")})`)}static flat(t,o="_"){if(t===null||typeof t!="object")throw new TypeError("the.flat: input must be an object");let n={},e=(r,a)=>{if(r===null||typeof r!="object"){n[a]=r;return}for(let[c,s]of Object.entries(r))e(s,a?`${a}${o}${c}`:c)};return e(t,""),n}static form(t){let o={},n=t.querySelectorAll("input, select, textarea");for(let e of n){let r=e.name;if(!r||e.disabled)continue;let a=(e.type||"text").toLowerCase();if(a==="submit"||a==="button"||a==="reset"||(a==="checkbox"||a==="radio")&&!(e.checked??e.hasAttribute("checked")))continue;let c=e.value??e.getAttribute("value")??"";i.#o(o,r,c)}return o}static#o(t,o,n){let e=o.split(/[\[\]]/).filter(Boolean),r=t;for(let a=0;a<e.length;a++){let c=e[a];a===e.length-1?r[c]!==void 0?(Array.isArray(r[c])||(r[c]=[r[c]]),r[c].push(n)):r[c]=n:(r[c]=r[c]||{},r=r[c])}}static _t(t,o={}){if(typeof Node<"u"?t instanceof Node:t?.nodeType){let a=t.querySelectorAll?[t,...t.querySelectorAll("[data-i18n]")]:[t];for(let c of a)if(c.hasAttribute?.("data-i18n")){let s=c.getAttribute("data-i18n"),h={};for(let u of c.attributes)if(u.name.startsWith("data-i18n-")&&u.name!=="data-i18n-type"){let g=u.name.replace("data-i18n-","");h[g]=u.value}let b=c.getAttribute("data-i18n-type");c.textContent=i._t(s,{...h,type:b})}return t}if(!t)return i._t(document.body),"";let e=i.dictionary[t];if(!e)return t;if(typeof e=="object"){let a=o.qty!==void 0?Number(o.qty):0,c=new Intl.PluralRules(i.locale).select(a);e=e[c]||e.other||t}if(typeof e!="string")return t;let r=e;for(let[a,c]of Object.entries(o)){let s=c;if(a==="val"&&o.type){let h=Number(c),b=o.type==="currency"?new Intl.NumberFormat(i.locale,{style:"currency",currency:"USD"}):o.type==="date"?new Intl.DateTimeFormat(i.locale):new Intl.NumberFormat(i.locale);s=o.type==="date"?b.format(new Date(c)):b.format(h)}r=r.replace(`{${a}}`,s)}return r}static route(t){if(typeof window>"u")return;let o=()=>t(window.location.pathname,window.location.search,window.location.hash);window.addEventListener("popstate",o),window.addEventListener("hashchange",o),document.addEventListener("click",n=>{let e=n.target.closest("a");if(!e||!e.getAttribute("href")||e.hasAttribute("data-external")||e.target==="_blank")return;let r=new URL(e.getAttribute("href"),window.location.href);r.origin===window.location.origin&&(r.pathname===window.location.pathname&&r.search===window.location.search&&r.hash||(n.preventDefault(),window.history.pushState({},"",r.href),o()))}),o()}static#n(t,o){let e={expanded:"aria-expanded",selected:"aria-selected",hidden:"aria-hidden",checked:"aria-checked",disabled:"aria-disabled"}[o]||`data-${o}`;return t.getAttribute(e)}static#t(t,o,n){let r={expanded:"aria-expanded",selected:"aria-selected",hidden:"aria-hidden",checked:"aria-checked",disabled:"aria-disabled"}[o]||`data-${o}`,a=typeof n=="boolean"?n?"true":"false":n;if(t.setAttribute(r,a),t.querySelectorAll){let c=t.querySelectorAll(`[data-text="${o}"]`);for(let s of c)s.textContent=a}t.getAttribute?.("data-text")===o&&(t.textContent=a)}static async boot({signal:t,locales:o,dictionary:n,namespace:e,defaultLocale:r}={}){e&&(i.prefix=`${e}:`);let a=typeof window<"u"&&window.location?window.location.search:"",c=new URLSearchParams(a),s=(typeof navigator<"u"?navigator.language:null)||document.documentElement.lang||"en";i.locale=c.get("lang")||localStorage.getItem(`${i.prefix}lang`)||s;let h=r||document.documentElement.lang,b=i.locale.toLowerCase().split("-")[0],u=h?.toLowerCase().split("-")[0],g=!!u&&b===u;if(!g)if(n)i.dictionary=n;else{let f=document.querySelector('meta[name="i18n"]'),y=o||f?.getAttribute("content");if(y){let x=f?.getAttribute("data-fallback")||"en",$=(f?.getAttribute("data-available")||"").split(",").map(m=>m.trim().toLowerCase()),v=i.locale.toLowerCase(),S=v.split("-")[0],A=x;$.includes(v)?A=v:$.includes(S)&&(A=S);try{let m=await fetch(`${y}/${A}.json`,{signal:t});m.ok&&(i.dictionary=await m.json())}catch(m){if(t?.aborted)throw m;console.warn("otm: i18n fetch failed",m)}}}for(let f=0;f<localStorage.length;f++){let y=localStorage.key(f);if(y.startsWith(i.prefix)){let x=y.slice(i.prefix.length);if(x!=="lang"){let $=localStorage.getItem(y);i.#t(document.body,x,$)}}}g||i._t()}};var p=class{static $(t,o){let n=t,e=o;return typeof n=="string"&&(e=n,n=document),n.querySelector(e)}static $$(t,o){let n=t,e=o;return typeof n=="string"&&(e=n,n=document),Array.from(n.querySelectorAll(e))}static clone(t,o){let n=typeof t=="string"?document.querySelector(t):t,e=document.querySelector(o);if(!n)throw new Error(`Parent not found: ${t}`);if(!e)throw new Error(`Template not found: ${o}`);let r=e.content.cloneNode(!0).firstElementChild;l._t(r),n.appendChild(r);let a=new CustomEvent("mounted",{bubbles:!0,detail:{parent:n}});return r.dispatchEvent(a),r}};var E=w.on;E.emit=w.emit;var d=l.the,q=l._t,j=l.route;d.t=q;d.route=j;d.form=l.form;d.flat=l.flat;d.boot=l.boot;Object.defineProperty(d,"dictionary",{get:()=>l.dictionary,set:i=>{l.dictionary=i}});Object.defineProperty(d,"locale",{get:()=>l.locale,set:i=>{l.locale=i}});var L=p.$;L.clone=p.clone;var C=p.$$,D={on:E,the:d,$:L,$$:C,_t:q};export{L as $,C as $$,q as _t,D as default,E as on,j as route,d as the};
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};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "on_the_money",
3
- "version": "0.3.2",
4
- "description": "Opinionated, attribute-driven, standards-oriented modern framework. <2KB gzip. Native browser APIs only.",
3
+ "version": "0.3.6",
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",
package/src/core/The.js CHANGED
@@ -3,6 +3,7 @@ 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
7
 
7
8
  static the(...args) {
8
9
  if (args.length === 0) {
@@ -28,13 +29,15 @@ export default class The {
28
29
  );
29
30
  }
30
31
  The.#set(el, a, b);
31
- if (isGlobal) localStorage.setItem(`${The.prefix}${a}`, b);
32
+ if (isGlobal && !The.ephemeralKeys.has(a))
33
+ localStorage.setItem(`${The.prefix}${a}`, b);
32
34
  return el;
33
35
  }
34
36
  if (args.length === 1 && a?.constructor === Object) {
35
37
  for (const [k, v] of Object.entries(a)) {
36
38
  The.#set(el, k, v);
37
- if (isGlobal) localStorage.setItem(`${The.prefix}${k}`, v);
39
+ if (isGlobal && !The.ephemeralKeys.has(k))
40
+ localStorage.setItem(`${The.prefix}${k}`, v);
38
41
  }
39
42
  return el;
40
43
  }
@@ -44,6 +47,25 @@ export default class The {
44
47
  );
45
48
  }
46
49
 
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",
66
+ );
67
+ }
68
+
47
69
  static flat(obj, sep = "_") {
48
70
  if (obj === null || typeof obj !== "object") {
49
71
  throw new TypeError("the.flat: input must be an object");
@@ -137,7 +159,24 @@ export default class The {
137
159
  }
138
160
 
139
161
  let entry = The.dictionary[key];
140
- if (!entry) return key;
162
+ if (!entry) {
163
+ if (
164
+ options.val !== undefined &&
165
+ (options.type === "currency" || options.type === "date")
166
+ ) {
167
+ const fmt =
168
+ options.type === "currency"
169
+ ? new Intl.NumberFormat(The.locale, {
170
+ style: "currency",
171
+ currency: "USD",
172
+ })
173
+ : new Intl.DateTimeFormat(The.locale);
174
+ return options.type === "date"
175
+ ? fmt.format(new Date(options.val))
176
+ : fmt.format(Number(options.val));
177
+ }
178
+ return key;
179
+ }
141
180
 
142
181
  if (typeof entry === "object") {
143
182
  const qty = options.qty !== undefined ? Number(options.qty) : 0;
@@ -213,27 +252,26 @@ export default class The {
213
252
  navigate();
214
253
  }
215
254
 
255
+ static #ariaMap = {
256
+ expanded: "aria-expanded",
257
+ selected: "aria-selected",
258
+ hidden: "aria-hidden",
259
+ checked: "aria-checked",
260
+ disabled: "aria-disabled",
261
+ invalid: "aria-invalid",
262
+ required: "aria-required",
263
+ readonly: "aria-readonly",
264
+ pressed: "aria-pressed",
265
+ current: "aria-current",
266
+ };
267
+
216
268
  static #get(el, key) {
217
- const ariaMap = {
218
- expanded: "aria-expanded",
219
- selected: "aria-selected",
220
- hidden: "aria-hidden",
221
- checked: "aria-checked",
222
- disabled: "aria-disabled",
223
- };
224
- const attr = ariaMap[key] || `data-${key}`;
269
+ const attr = The.#ariaMap[key] || `data-${key}`;
225
270
  return el.getAttribute(attr);
226
271
  }
227
272
 
228
273
  static #set(el, key, val) {
229
- const ariaMap = {
230
- expanded: "aria-expanded",
231
- selected: "aria-selected",
232
- hidden: "aria-hidden",
233
- checked: "aria-checked",
234
- disabled: "aria-disabled",
235
- };
236
- const attr = ariaMap[key] || `data-${key}`;
274
+ const attr = The.#ariaMap[key] || `data-${key}`;
237
275
  const out = typeof val === "boolean" ? (val ? "true" : "false") : val;
238
276
  el.setAttribute(attr, out);
239
277
 
@@ -250,8 +288,10 @@ export default class The {
250
288
  dictionary,
251
289
  namespace,
252
290
  defaultLocale,
291
+ ephemeralKeys,
253
292
  } = {}) {
254
293
  if (namespace) The.prefix = `${namespace}:`;
294
+ if (ephemeralKeys) The.ephemeralKeys = new Set(ephemeralKeys);
255
295
  const search =
256
296
  typeof window !== "undefined" && window.location
257
297
  ? window.location.search
@@ -306,7 +346,7 @@ export default class The {
306
346
  const fullKey = localStorage.key(i);
307
347
  if (fullKey.startsWith(The.prefix)) {
308
348
  const key = fullKey.slice(The.prefix.length);
309
- if (key !== "lang") {
349
+ if (key !== "lang" && !The.ephemeralKeys.has(key)) {
310
350
  const val = localStorage.getItem(fullKey);
311
351
  The.#set(document.body, key, val);
312
352
  }
package/src/core/index.js CHANGED
@@ -13,6 +13,8 @@ the.route = route;
13
13
  the.form = The.form;
14
14
  the.flat = The.flat;
15
15
  the.boot = The.boot;
16
+ the.title = The.title;
17
+ the.attr = The.attr;
16
18
 
17
19
  Object.defineProperty(the, "dictionary", {
18
20
  get: () => 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,119 @@ 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
+ if (idAttr?.value) {
133
+ const loc = node.sourceCodeLocation || {
134
+ startLine: 1,
135
+ startCol: 1,
136
+ };
137
+ templates.set(idAttr.value, {
138
+ file,
139
+ line: loc.startLine,
140
+ column: loc.startCol,
141
+ });
142
+ }
143
+ }
144
+ });
145
+ }
146
+
147
+ const cloneRefs = new Set();
148
+ for (const { source } of jsSources) {
149
+ const matches = source.matchAll(
150
+ /\$\.clone\s*\([^,)]+,\s*['"]#([^'"]+)['"]/g,
151
+ );
152
+ for (const m of matches) cloneRefs.add(m[1]);
153
+ }
154
+
155
+ for (const [id, info] of templates) {
156
+ if (!cloneRefs.has(id)) {
157
+ Linter.#addViolation(
158
+ violations,
159
+ info.file,
160
+ { line: info.line, column: info.column },
161
+ "HTML-101",
162
+ `Orphan template: <template id="${id}"> is never referenced by $.clone(). Remove the template or add a clone call.`,
163
+ );
164
+ }
165
+ }
166
+
167
+ // HTML-102: data-i18n keys missing from the html file's own locale dictionaries
168
+ // HTML-103: data-i18n-{var} attrs reference template tokens that don't exist in the dictionary entry
169
+ for (const { file, source, dicts } of htmlSources) {
170
+ if (!dicts || dicts.length === 0) continue;
171
+
172
+ const document = parse5.parse(source, { sourceCodeLocationInfo: true });
173
+ Linter.#traverse(document, (node) => {
174
+ const attrs = node.attrs
175
+ ? Object.fromEntries(node.attrs.map((a) => [a.name, a.value]))
176
+ : {};
177
+ if (!attrs["data-i18n"]) return;
178
+
179
+ const key = attrs["data-i18n"];
180
+ const params = [];
181
+ for (const attr of node.attrs) {
182
+ if (
183
+ attr.name.startsWith("data-i18n-") &&
184
+ attr.name !== "data-i18n-type"
185
+ ) {
186
+ params.push(attr.name.replace("data-i18n-", ""));
187
+ }
188
+ }
189
+ const loc = node.sourceCodeLocation?.attrs?.["data-i18n"] ||
190
+ node.sourceCodeLocation || { startLine: 1, startCol: 1 };
191
+ const where = { line: loc.startLine, column: loc.startCol };
192
+
193
+ const foundIn = dicts.filter((d) => key in d.dict);
194
+ if (foundIn.length === 0) {
195
+ Linter.#addViolation(
196
+ violations,
197
+ file,
198
+ where,
199
+ "HTML-102",
200
+ `i18n key "${key}" is not declared in any locale dictionary (${dicts.map((d) => d.locale).join(", ")}).`,
201
+ );
202
+ return;
203
+ }
204
+
205
+ for (const { locale, dict } of foundIn) {
206
+ const entry = dict[key];
207
+ let template;
208
+ if (typeof entry === "string") template = entry;
209
+ else if (typeof entry === "object" && entry !== null) {
210
+ template = entry.other || entry.one || Object.values(entry)[0];
211
+ }
212
+ if (typeof template !== "string") continue;
213
+
214
+ const tokens = new Set();
215
+ for (const m of template.matchAll(/\{([^}]+)\}/g)) tokens.add(m[1]);
216
+
217
+ for (const param of params) {
218
+ if (!tokens.has(param)) {
219
+ Linter.#addViolation(
220
+ violations,
221
+ file,
222
+ where,
223
+ "HTML-103",
224
+ `data-i18n-${param} has no matching {${param}} token in dictionary entry "${key}" (locale "${locale}").`,
225
+ );
226
+ }
227
+ }
228
+ }
229
+ });
230
+ }
231
+
232
+ return violations;
233
+ }
234
+
122
235
  static #traverse(node, visitor) {
123
236
  visitor(node);
124
237
  const children = node.childNodes || node.content?.childNodes;
package/src/linter/cli.js CHANGED
@@ -6,11 +6,14 @@ 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
+
9
11
  if (args.includes("--check")) {
10
12
  const dirIndex = args.indexOf("--check") + 1;
11
13
  const targetDir = args[dirIndex] || ".";
12
14
  try {
13
- return await Cli.scan(targetDir);
15
+ const total = await Cli.scan(targetDir, { wantConformance });
16
+ return total;
14
17
  } catch (e) {
15
18
  console.error(`Error scanning ${targetDir}: ${e.message}`);
16
19
  return -1;
@@ -19,33 +22,69 @@ export default class Cli {
19
22
  return 0;
20
23
  }
21
24
 
22
- static async scan(dir) {
23
- const files = await Cli.getFiles(dir);
25
+ static #excludeDirs = new Set(["node_modules", "dist", ".git"]);
26
+
27
+ static async getFiles(dir, exts = [".html"]) {
28
+ const entries = await fs.readdir(dir, { withFileTypes: true });
29
+ const files = await Promise.all(
30
+ entries.map((res) => {
31
+ const resPath = path.resolve(dir, res.name);
32
+ if (res.isDirectory()) {
33
+ if (Cli.#excludeDirs.has(res.name) || res.name.startsWith("."))
34
+ return [];
35
+ return Cli.getFiles(resPath, exts);
36
+ }
37
+ return resPath;
38
+ }),
39
+ );
40
+ return Array.prototype.concat
41
+ .apply([], files)
42
+ .filter((f) => exts.includes(path.extname(f)));
43
+ }
44
+
45
+ static async scan(dir, opts = {}) {
46
+ const allFiles = await Cli.getFiles(dir, [".html", ".js", ".json"]);
47
+ const htmlPaths = allFiles.filter((f) => f.endsWith(".html"));
48
+ const jsPaths = allFiles.filter((f) => f.endsWith(".js"));
24
49
  let totalViolations = 0;
25
50
 
26
- for (const file of files) {
51
+ const htmlSources = [];
52
+ for (const file of htmlPaths) {
27
53
  const source = await fs.readFile(file, "utf-8");
28
54
  let availableLocales = null;
55
+ const dicts = [];
29
56
 
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
57
+ const match = source.match(/<meta\s+name="i18n"\s+content="([^"]+)"/);
58
+ if (match) {
59
+ const localesDir = path.resolve(
60
+ path.dirname(file),
61
+ match[1].startsWith("/") ? `.${match[1]}` : match[1],
62
+ );
63
+ try {
64
+ const entries = await fs.readdir(localesDir);
65
+ availableLocales = entries
66
+ .filter((f) => f.endsWith(".json"))
67
+ .map((f) => f.replace(".json", ""));
68
+ for (const entry of entries) {
69
+ if (!entry.endsWith(".json")) continue;
70
+ const locale = entry.replace(".json", "");
71
+ try {
72
+ const raw = await fs.readFile(
73
+ path.join(localesDir, entry),
74
+ "utf-8",
75
+ );
76
+ dicts.push({ locale, dict: JSON.parse(raw) });
77
+ } catch {
78
+ // Skip unparseable locale file
79
+ }
44
80
  }
81
+ } catch {
82
+ // Folder not found or unreadable
45
83
  }
46
84
  }
47
85
 
48
86
  const violations = Linter.check(file, source, availableLocales);
87
+ htmlSources.push({ file, source, dicts });
49
88
 
50
89
  if (violations.length > 0) {
51
90
  totalViolations += violations.length;
@@ -53,34 +92,40 @@ export default class Cli {
53
92
  }
54
93
  }
55
94
 
95
+ const jsSources = [];
96
+ for (const file of jsPaths) {
97
+ const source = await fs.readFile(file, "utf-8");
98
+ jsSources.push({ file, source });
99
+ }
100
+
101
+ const crossViolations = Linter.crossCheck({
102
+ htmlSources,
103
+ jsSources,
104
+ });
105
+
106
+ if (crossViolations.length > 0) {
107
+ const byFile = new Map();
108
+ for (const v of crossViolations) {
109
+ if (!byFile.has(v.file)) byFile.set(v.file, []);
110
+ byFile.get(v.file).push(v);
111
+ }
112
+ for (const [file, vs] of byFile) Cli.report(file, vs);
113
+ totalViolations += crossViolations.length;
114
+ }
115
+
56
116
  if (totalViolations === 0) {
57
117
  console.log("✔ No violations found.");
58
118
  } else {
59
119
  console.log(
60
- `\n✖ Found ${totalViolations} violations across ${files.length} files.`,
120
+ `\n✖ Found ${totalViolations} violations across ${htmlPaths.length} HTML files.`,
61
121
  );
62
122
  }
63
- return totalViolations;
64
- }
65
123
 
66
- static #excludeDirs = new Set(["node_modules", "dist", ".git"]);
124
+ if (opts.wantConformance) {
125
+ console.log(`OTM conformance: ${totalViolations} violations`);
126
+ }
67
127
 
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");
128
+ return totalViolations;
84
129
  }
85
130
 
86
131
  static report(file, violations) {