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 +87 -0
- package/README.md +132 -9
- package/dist/on_the_money.min.js +1 -1
- package/package.json +2 -2
- package/src/core/Select.js +2 -4
- package/src/core/The.js +70 -28
- package/src/core/index.js +1 -0
- package/src/eslint/config.js +3 -0
- package/src/eslint/plugin.js +96 -6
- package/src/linter/Linter.js +117 -0
- package/src/linter/cli.js +75 -37
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
|
-
|
|
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 +
|
|
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
|
-
- **
|
|
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
|
-
| `
|
|
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,
|
|
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
|
-
|
|
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
|
```
|
package/dist/on_the_money.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
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.
|
|
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",
|
package/src/core/Select.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.#
|
|
32
|
-
|
|
33
|
-
|
|
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 [
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
314
|
+
persistKeys,
|
|
274
315
|
} = {}) {
|
|
275
316
|
if (namespace) The.prefix = `${namespace}:`;
|
|
276
|
-
if (
|
|
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" &&
|
|
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
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,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
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 ${
|
|
117
|
+
`\n✖ Found ${totalViolations} violations across ${htmlPaths.length} HTML files.`,
|
|
61
118
|
);
|
|
62
119
|
}
|
|
63
|
-
return totalViolations;
|
|
64
|
-
}
|
|
65
120
|
|
|
66
|
-
|
|
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) {
|