on_the_money 0.3.3 → 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 +38 -0
- package/README.md +45 -3
- package/dist/on_the_money.min.js +1 -1
- package/package.json +2 -2
- package/src/core/The.js +34 -16
- package/src/core/index.js +2 -0
- package/src/eslint/config.js +3 -0
- package/src/eslint/plugin.js +96 -6
- package/src/linter/Linter.js +113 -0
- package/src/linter/cli.js +83 -38
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,44 @@ 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
|
+
|
|
7
45
|
## [0.3.3] — 2026-05-22
|
|
8
46
|
|
|
9
47
|
### 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
|
|
@@ -347,9 +370,13 @@ export default [
|
|
|
347
370
|
| `otm/flat-state` | bundled in `on_the_money` | Ban nested objects/arrays in `the()` calls. |
|
|
348
371
|
| `otm/prefer-submit` | bundled in `on_the_money` | Warn on `on(btn, "click", ...)` for form data. |
|
|
349
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.). |
|
|
350
375
|
| `no-unsanitized/no-inner-html` | `eslint-plugin-no-unsanitized` | Ban `innerHTML`/`outerHTML`. |
|
|
351
376
|
| `no-unsanitized/method` | `eslint-plugin-no-unsanitized` | Ban `document.write`, `insertAdjacentHTML`. |
|
|
352
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
|
+
|
|
353
380
|
### 2. CSS — Stylelint + bundled plugin
|
|
354
381
|
|
|
355
382
|
Same pattern: the Stylelint plugin and config ship inside `on_the_money`. No separate `stylelint-plugin-otm` package.
|
|
@@ -385,8 +412,23 @@ npx otm-lint --check ./src
|
|
|
385
412
|
| **HTML-017** | `<div data-action="...">` without `role`/`tabindex` | Use a `<button>` or other interactive element. |
|
|
386
413
|
| **HTML-023** | `data-i18n="..."` without `<meta name="i18n">` | Declare the i18n endpoint. |
|
|
387
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
|
+
```
|
|
388
430
|
|
|
389
|
-
|
|
431
|
+
Useful for CI gates and LLM tooling that wants a one-line signal.
|
|
390
432
|
|
|
391
433
|
### Recommended companions (not shipped)
|
|
392
434
|
|
package/dist/on_the_money.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
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.
|
|
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
|
@@ -47,6 +47,25 @@ export default class The {
|
|
|
47
47
|
);
|
|
48
48
|
}
|
|
49
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
|
+
|
|
50
69
|
static flat(obj, sep = "_") {
|
|
51
70
|
if (obj === null || typeof obj !== "object") {
|
|
52
71
|
throw new TypeError("the.flat: input must be an object");
|
|
@@ -233,27 +252,26 @@ export default class The {
|
|
|
233
252
|
navigate();
|
|
234
253
|
}
|
|
235
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
|
+
|
|
236
268
|
static #get(el, key) {
|
|
237
|
-
const
|
|
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}`;
|
|
269
|
+
const attr = The.#ariaMap[key] || `data-${key}`;
|
|
245
270
|
return el.getAttribute(attr);
|
|
246
271
|
}
|
|
247
272
|
|
|
248
273
|
static #set(el, key, val) {
|
|
249
|
-
const
|
|
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}`;
|
|
274
|
+
const attr = The.#ariaMap[key] || `data-${key}`;
|
|
257
275
|
const out = typeof val === "boolean" ? (val ? "true" : "false") : val;
|
|
258
276
|
el.setAttribute(attr, out);
|
|
259
277
|
|
package/src/core/index.js
CHANGED
package/src/eslint/config.js
CHANGED
|
@@ -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
|
];
|
package/src/eslint/plugin.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const meta = {
|
|
2
2
|
name: "eslint-plugin-otm",
|
|
3
|
-
version: "0.3.
|
|
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().
|
|
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
|
};
|
package/src/linter/Linter.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 ${
|
|
120
|
+
`\n✖ Found ${totalViolations} violations across ${htmlPaths.length} HTML files.`,
|
|
61
121
|
);
|
|
62
122
|
}
|
|
63
|
-
return totalViolations;
|
|
64
|
-
}
|
|
65
123
|
|
|
66
|
-
|
|
124
|
+
if (opts.wantConformance) {
|
|
125
|
+
console.log(`OTM conformance: ${totalViolations} violations`);
|
|
126
|
+
}
|
|
67
127
|
|
|
68
|
-
|
|
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) {
|