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