on_the_money 0.3.0

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 ADDED
@@ -0,0 +1,65 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
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
+
7
+ ## [0.3.0] — 2026-05-18
8
+
9
+ ### Breaking
10
+
11
+ - **`otm-lint` reduced to four HTML / cross-file rules.** The bundled linter no longer enforces JS or CSS rules. Twelve rules dropped: JS-001, JS-003, JS-009, JS-011, JS-015, JS-016, JS-019, HTML-014, HTML-018, HTML-020, HTML-021, HTML-022, CSS-006, CSS-012. Their enforcement now lives in `eslint-plugin-otm`, `stylelint-plugin-otm`, the stylelint built-in `declaration-no-important`, and recommended companions like `eslint-plugin-no-unsanitized` and `html-validate`.
12
+ - **`otm-lint` only scans `.html` files now.** JS and CSS files are silently ignored by the binary; route them through ESLint and Stylelint.
13
+ - **Dropped runtime dependencies** `espree` and `css-tree` from devDependencies; the linter no longer parses JS/CSS.
14
+
15
+ ### Added
16
+
17
+ - **`eslint-plugin-otm`** with five rules: `prefer-on`, `prefer-the-set`, `flat-state`, `prefer-submit`, `no-style-mutation`. Distributed via `on_the_money/eslint-plugin`.
18
+ - **`eslint-config-otm`** shareable flat-config at `on_the_money/eslint-config`.
19
+ - **`stylelint-plugin-otm`** with one rule: `prefer-attribute-selector`. Distributed via `on_the_money/stylelint-plugin`.
20
+ - **`stylelint-config-otm`** at `on_the_money/stylelint-config` — enables our rule plus stylelint's built-in `declaration-no-important`.
21
+
22
+ ### Changed
23
+
24
+ - **README** linter section rewritten to describe the three-tool stack (ESLint + Stylelint + otm-lint) instead of a single bundled linter.
25
+ - **Linter `flat-state` rule** now catches `the({ nested: ... })` single-arg batch case, which the old `Linter.js` missed.
26
+
27
+ ### Rationale
28
+
29
+ The old `Linter.js` was an attempt at a one-tool-fits-all frontend linter that reimplemented innerHTML detection, !important banning, and inline-handler banning — all of which existing ecosystem tools do better, with editor LSP integration, inline disables, configurable severity, and SARIF reporters. The reorientation positions `otm-lint` as a small auxiliary tool for the cross-file checks no other ecosystem covers, while the heavy lifting goes to ESLint + Stylelint + html-validate where it already belongs.
30
+
31
+ ## [0.2.0] — 2026-05-18
32
+
33
+ ### Breaking
34
+
35
+ - **Removed import-time auto-handshake.** Importing `on_the_money` is now side-effect-free. Consumers must call `await the.boot()` at their entry point. The old auto-call performed a network fetch and DOM mutation on import.
36
+ - **Removed `the.ready` promise.** Boot is explicit now; await `the.boot()` directly.
37
+ - **Removed `the(form)` polymorphic shape.** Form extraction is only via `the.form(formEl)`.
38
+ - **`the(string, undefined)` now throws.** Two args means set; missing val is a contract violation. Previously silently coerced to the string `"undefined"`.
39
+
40
+ ### Added
41
+
42
+ - **`the.boot(options?)`** explicit init. Accepts `{ signal, locales, dictionary, namespace }` for abort, override path, inline dictionary, and configurable localStorage prefix.
43
+ - **`the.flat(obj, sep = "_")`** recursive flatten helper. Composes `the.form` output into `the(el, {...})` batch input.
44
+ - **`the.dictionary`** and **`the.locale`** as live accessors on the exported `the`. Hot-swap supported.
45
+ - **`on()` returns an unsubscribe function.** `const off = on(...); off()` detaches the listener.
46
+ - **Boolean coercion in `the()` setter.** `the(el, "checked", true)` writes `"true"`; `false` writes `"false"`. Eliminates the truthy-`"false"`-string footgun.
47
+ - **`otm-lint` default-excludes** `node_modules`, `dist`, `.git`, and dotdirs. Consumer projects no longer get drowned in dependency false-positives.
48
+
49
+ ### Changed
50
+
51
+ - **Framing.** "Anti-Framework" rhetoric dropped. Now self-describes as an opinionated, attribute-driven, standards-oriented modern framework.
52
+ - **`the(...)` dispatch** rewritten on a single Element-first-arg disambiguator. Three disjoint first-arg types (Element / string / plain Object) eliminate the prior ambiguity that produced silent mis-dispatch on `the("k", undefined)`.
53
+ - **`route()`** resolves `href` via `new URL(link.href, window.location.href)` instead of reading the anchor's `.origin`/`.pathname` IDL properties. Robust under non-browser DOM implementations.
54
+ - **Tests co-located** next to source (`src/core/On.test.js`, etc.). Integration suite remains at `test/integration.test.js`.
55
+ - **`exports."."`** points at the built `dist/on_the_money.min.js`; raw ESM source available as `./src` subpath.
56
+ - **README** rewritten as an LLM authoring context document.
57
+
58
+ ### Fixed
59
+
60
+ - Coverage thresholds adjusted from 80/80/100 to 50/50/50 (the global engineering baseline) — the 100% function gate was demanding test acrobatics for paths like `route()` that need a full window.
61
+ - `the.form` no longer requires `FormData` global; uses `querySelectorAll` over named controls for test environment compatibility.
62
+
63
+ ## [0.1.1] — 2026-03-18
64
+
65
+ Initial public surface. Auto-handshake, polymorphic `the()` dispatch including `the(form)`, `the.ready` promise.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PossumTech Laboratories
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,433 @@
1
+ # on_the_money.js
2
+
3
+ Opinionated, attribute-driven, standards-oriented modern framework for the web. <2KB gzip. Native browser APIs only. ESNext.
4
+
5
+ State lives in DOM attributes. Reactivity comes from `[data-text]` and `[data-i18n]` selectors. Events use one delegated listener per `(parent, type)` pair. Routing uses the History API. Localization uses `Intl`. There is no virtual DOM, no JSX, no transpilation step, no proprietary tooling. The framework is a thin layer of conventions over the platform.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install on_the_money
11
+ ```
12
+
13
+ ## Quickstart
14
+
15
+ A complete two-file app. `index.html` carries semantic structure, `app.js` carries behavior.
16
+
17
+ ```html
18
+ <!DOCTYPE html>
19
+ <html lang="en">
20
+ <head>
21
+ <meta charset="UTF-8">
22
+ <meta name="viewport" content="width=device-width, initial-scale=1">
23
+ <title data-i18n="app_title"></title>
24
+ <link rel="stylesheet" href="https://unpkg.com/@picocss/pico@2/css/pico.classless.min.css">
25
+ <script type="module" src="./app.js"></script>
26
+ </head>
27
+ <body>
28
+ <main>
29
+ <h1 data-i18n="app_title"></h1>
30
+ <p>Hello, <strong data-text="user">friend</strong>.</p>
31
+ <button data-action="greet">Greet</button>
32
+ </main>
33
+ </body>
34
+ </html>
35
+ ```
36
+
37
+ ```javascript
38
+ // app.js
39
+ import { the, on } from "on_the_money";
40
+
41
+ await the.boot({
42
+ dictionary: { app_title: "On The Money" },
43
+ });
44
+
45
+ on("main", "click", '[data-action="greet"]', () => {
46
+ the("user", "Alice");
47
+ });
48
+ ```
49
+
50
+ The framework rehydrates body `data-*` and `[data-text]` from `localStorage` on boot; subsequent `the()` writes both the attribute and persist to `localStorage["otm:KEY"]`.
51
+
52
+ ## Pico Classless integration
53
+
54
+ Pico Classless is the recommended companion stylesheet. It styles semantic HTML directly — no classes, no JS. Include the stylesheet once:
55
+
56
+ ```html
57
+ <link rel="stylesheet" href="https://unpkg.com/@picocss/pico@2/css/pico.classless.min.css">
58
+ ```
59
+
60
+ Then write semantic HTML (`<main>`, `<nav>`, `<article>`, `<form>`, `<button>`, `<input>` with `<label>`, etc.). on_the_money's conventions all use `data-*` and `aria-*` attributes, so they layer cleanly under Pico's element-targeted CSS.
61
+
62
+ ## Exports
63
+
64
+ ```javascript
65
+ import { on, the, _t, route, $, $$ } from "on_the_money";
66
+ ```
67
+
68
+ Aliases on `the`: `the.t === _t`, `the.route === route`, `the.form(formEl)`, `the.flat(obj, sep?)`, `the.boot(options?)`.
69
+ Live accessors on `the`: `the.dictionary`, `the.locale`.
70
+
71
+ ## API
72
+
73
+ ### `on(parent, event, selector, fn)` — event delegation
74
+
75
+ | Arg | Type | Notes |
76
+ | --- | --- | --- |
77
+ | `parent` | `Element \| string \| null` | If string, resolved via `document.querySelector`. If falsy, defaults to `document.body`. |
78
+ | `event` | `string` | Any DOM event name (`"click"`, `"submit"`, `"mounted"`, etc.). |
79
+ | `selector` | `string` | CSS selector matched via `event.target.closest(selector)`. |
80
+ | `fn` | `(event, target) => void` | `target` is the element the selector matched. |
81
+
82
+ Returns an `() => void` unsubscribe function.
83
+
84
+ ```javascript
85
+ const off = on("#list", "click", "[data-action='delete']", (e, target) => {
86
+ target.closest("[data-item]").remove();
87
+ });
88
+ off(); // detaches the listener
89
+ ```
90
+
91
+ ### `on.emit(el, event, detail)` — `CustomEvent` dispatch
92
+
93
+ Dispatches a bubbling, cancelable `CustomEvent` on `el`. `el` may be an element or a selector string.
94
+
95
+ ```javascript
96
+ on.emit("#cart", "items-changed", { count: 3 });
97
+ ```
98
+
99
+ ### `the(...)` — state
100
+
101
+ Polymorphic on a single disambiguator: `args[0] instanceof Element`. Three call shapes per scope (get, set, batch).
102
+
103
+ | Call | Behavior | Returns |
104
+ | --- | --- | --- |
105
+ | `the(key)` | Read body `data-KEY` (or aria-mapped equivalent). | `string \| null` |
106
+ | `the(key, val)` | Write body attribute + `localStorage["otm:KEY"]` + descendant `[data-text="key"]`. | `document.body` |
107
+ | `the({ k: v, ... })` | Batch global write. | `document.body` |
108
+ | `the(el, key)` | Read scoped attribute on `el`. | `string \| null` |
109
+ | `the(el, key, val)` | Write scoped attribute on `el` + descendant `[data-text="key"]`. | `el` |
110
+ | `the(el, { k: v, ... })` | Batch scoped write. | `el` |
111
+
112
+ - **ARIA mapping** (key → attribute): `expanded`, `selected`, `hidden`, `checked`, `disabled` → `aria-*`. All other keys → `data-*`.
113
+ - **Booleans coerce** to `"true"`/`"false"` inside the setter. `the(el, "checked", true)` writes `"true"`.
114
+ - **Values MUST be flat primitives.** Pass nested objects through `the.flat(...)` first.
115
+ - **`the(key, undefined)` throws.** Two args means set; missing val is a contract violation.
116
+
117
+ ### `the.form(formEl)` — form extraction
118
+
119
+ Walks `input, select, textarea` descendants. Skips unnamed, disabled, submit/button/reset controls, and unchecked checkboxes/radios. Parses bracket-notation names into a nested object:
120
+
121
+ ```javascript
122
+ the.form(form);
123
+ // <input name="user[name]" value="Alice"> → { user: { name: "Alice" } }
124
+ // <input name="tags[]" value="a"> → { tags: ["a", ...] }
125
+ // <input name="tags[]" value="b">
126
+ ```
127
+
128
+ Returns a **nested** object (matches browser submission semantics). Compose with `the.flat` before feeding to `the(el, {...})`.
129
+
130
+ ### `the.flat(obj, sep = "_")` — nested-to-flat
131
+
132
+ ```javascript
133
+ the.flat({ user: { name: "Alice" }, tags: ["a", "b"] });
134
+ // → { user_name: "Alice", tags_0: "a", tags_1: "b" }
135
+
136
+ the.flat({ a: { b: 1 } }, ".");
137
+ // → { "a.b": 1 }
138
+ ```
139
+
140
+ Throws on non-object input.
141
+
142
+ ### `the.boot(options?)` — explicit init
143
+
144
+ Importing the module does **nothing**. Call `the.boot()` at the consumer's entry point.
145
+
146
+ ```javascript
147
+ await the.boot({ signal, locales, dictionary, namespace });
148
+ ```
149
+
150
+ | Option | Type | Behavior |
151
+ | --- | --- | --- |
152
+ | `signal` | `AbortSignal` | Aborts the i18n fetch. |
153
+ | `locales` | `string` | Override the `<meta name="i18n" content>` path. |
154
+ | `dictionary` | `object` | Inline dictionary; skips the fetch entirely. |
155
+ | `namespace` | `string` | Sets `localStorage` prefix to `${namespace}:` (default `otm:`). Must be set before any state ops. |
156
+
157
+ Boot sequence:
158
+ 1. Resolve locale: `?lang=` query → `localStorage["${prefix}lang"]` → `navigator.language`. Writes `the.locale`.
159
+ 2. Resolve dictionary: inline `dictionary` → `fetch(${path}/${target}.json)` if `<meta name="i18n">` or `locales` option is present. Falls back through full → base → `data-fallback`.
160
+ 3. Replay `localStorage` entries matching the prefix (except `${prefix}lang`) back onto body `data-*` and `[data-text]`.
161
+ 4. Run `_t()` to hydrate `[data-i18n]`.
162
+
163
+ ### `_t(key, options)` — `Intl` localization
164
+
165
+ ```javascript
166
+ _t("hello"); // string lookup in the.dictionary
167
+ _t("hello", { name: "Alice" }); // {name} → "Alice"
168
+ _t("items", { qty: 5 }); // Intl.PluralRules picks { one, other } entry
169
+ _t("price", { val: 9.99, type: "currency" }); // Intl.NumberFormat (USD)
170
+ _t("when", { val: Date.now(), type: "date" }); // Intl.DateTimeFormat
171
+
172
+ _t(node); // hydrate every [data-i18n] inside node
173
+ _t(); // hydrate document.body
174
+ ```
175
+
176
+ Missing dictionary keys preserve existing `textContent` (SEO fallback).
177
+
178
+ `[data-i18n]` element binding:
179
+
180
+ ```html
181
+ <span data-i18n="cart_items" data-i18n-qty="3"></span>
182
+ <span data-i18n="price" data-i18n-val="9.99" data-i18n-type="currency"></span>
183
+ ```
184
+
185
+ ### `route(callback)` — pushState router
186
+
187
+ ```javascript
188
+ route((pathname, search, hash) => {
189
+ // render whatever fits this route
190
+ });
191
+ ```
192
+
193
+ 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.
194
+
195
+ ### `$(context, selector)` / `$$(context, selector)` — context-aware DOM query
196
+
197
+ ```javascript
198
+ $(el, ".child"); // first match (Element | null)
199
+ $$(el, ".child"); // all matches (Array)
200
+ $(".child"); // shorthand: context = document
201
+ $$(".child");
202
+ ```
203
+
204
+ ### `$.clone(parent, selector)` — template instantiation
205
+
206
+ ```javascript
207
+ const el = $.clone("#list", "#tmp");
208
+ ```
209
+
210
+ Clones the first element of `<template selector>`, runs `_t(el)` for i18n hydration, appends to `parent`, dispatches a bubbling `mounted` CustomEvent (`detail: { parent }`), and returns the mounted element. Throws if `parent` or template is missing.
211
+
212
+ ## Patterns
213
+
214
+ ### Form intake
215
+
216
+ ```javascript
217
+ on("#todo-form", "submit", (e) => {
218
+ e.preventDefault();
219
+ const data = the.form(e.target); // { task, tags: [...] }
220
+ the($.clone("#todo-list", "#todo-item"), the.flat(data)); // task, tags_0, tags_1 → attrs
221
+ });
222
+ ```
223
+
224
+ ### List rendering with i18n
225
+
226
+ ```html
227
+ <template id="post-card">
228
+ <article data-item>
229
+ <h2 data-text="title"></h2>
230
+ <p data-i18n="posted_at" data-i18n-val=""></p>
231
+ </article>
232
+ </template>
233
+ <section id="posts"></section>
234
+ ```
235
+
236
+ ```javascript
237
+ for (const post of posts) {
238
+ const el = $.clone("#posts", "#post-card");
239
+ the(el, { title: post.title });
240
+ $(el, '[data-i18n="posted_at"]').setAttribute("data-i18n-val", post.created_at);
241
+ }
242
+ _t();
243
+ ```
244
+
245
+ ### Routing (multi-page SPA)
246
+
247
+ ```javascript
248
+ route((path) => {
249
+ the("page", path === "/" ? "home" : path.slice(1));
250
+ });
251
+ ```
252
+
253
+ ```css
254
+ main > section { display: none; }
255
+ [data-page="home"] main > section[data-route="home"] { display: block; }
256
+ [data-page="about"] main > section[data-route="about"] { display: block; }
257
+ [data-page="contact"] main > section[data-route="contact"] { display: block; }
258
+ ```
259
+
260
+ ### Hot dictionary swap
261
+
262
+ ```javascript
263
+ on("nav", "click", "[data-lang]", async (_e, link) => {
264
+ const lang = link.getAttribute("data-lang");
265
+ the.locale = lang;
266
+ the.dictionary = await (await fetch(`/locales/${lang}.json`)).json();
267
+ _t();
268
+ });
269
+ ```
270
+
271
+ ### Namespaced state
272
+
273
+ ```javascript
274
+ // Two apps on the same origin can coexist:
275
+ await the.boot({ namespace: "dashboard" }); // localStorage keys: dashboard:theme, etc.
276
+ ```
277
+
278
+ ### Cleanup with on() unsubscribe
279
+
280
+ ```javascript
281
+ const off = on("#modal", "click", "[data-action='close']", closeModal);
282
+ on.emit(document.body, "modal-closed");
283
+ off(); // detach when modal is destroyed
284
+ ```
285
+
286
+ ### Server-side rendering (no extra API)
287
+
288
+ There's no SSR shim. Emit `data-*` attributes from the server:
289
+
290
+ ```html
291
+ <!-- rendered server-side -->
292
+ <body data-theme="dark" data-user="Alice">
293
+ <h1 data-text="user">Alice</h1>
294
+ </body>
295
+ ```
296
+
297
+ On the client:
298
+
299
+ ```javascript
300
+ await the.boot(); // localStorage values, if any, override the server-rendered attrs
301
+ ```
302
+
303
+ The framework reads existing attributes via `the(key)` without modification, so server-rendered state is observable immediately. `the.boot()` only mutates when localStorage has matching keys.
304
+
305
+ ## Lint stack
306
+
307
+ on_the_money ships a three-tool stack. Each layer covers what the others can't.
308
+
309
+ ### 1. JavaScript — ESLint + `eslint-plugin-otm`
310
+
311
+ ```bash
312
+ npm install -D eslint
313
+ ```
314
+
315
+ ```javascript
316
+ // eslint.config.js
317
+ import otm from "on_the_money/eslint-config";
318
+ import nounsanitized from "eslint-plugin-no-unsanitized";
319
+
320
+ export default [
321
+ ...otm,
322
+ nounsanitized.configs.recommended,
323
+ ];
324
+ ```
325
+
326
+ | Rule | Source | Behavior |
327
+ | --- | --- | --- |
328
+ | `otm/prefer-on` | eslint-plugin-otm | Ban `addEventListener`; use `on()`. |
329
+ | `otm/prefer-the-set` | eslint-plugin-otm | Ban `textContent`/`innerText`/`nodeValue` assignment. |
330
+ | `otm/flat-state` | eslint-plugin-otm | Ban nested objects/arrays in `the()` calls. |
331
+ | `otm/prefer-submit` | eslint-plugin-otm | Warn on `on(btn, "click", ...)` for form data. |
332
+ | `otm/no-style-mutation` | eslint-plugin-otm | Ban `el.style.* = ...`. |
333
+ | `no-unsanitized/no-inner-html` | external | Ban `innerHTML`/`outerHTML`. |
334
+ | `no-unsanitized/method` | external | Ban `document.write`, `insertAdjacentHTML`. |
335
+
336
+ ### 2. CSS — Stylelint + `stylelint-plugin-otm`
337
+
338
+ ```bash
339
+ npm install -D stylelint stylelint-config-standard
340
+ ```
341
+
342
+ ```javascript
343
+ // stylelint.config.js
344
+ import otm from "on_the_money/stylelint-config";
345
+
346
+ export default {
347
+ extends: ["stylelint-config-standard"],
348
+ ...otm,
349
+ };
350
+ ```
351
+
352
+ | Rule | Source | Behavior |
353
+ | --- | --- | --- |
354
+ | `otm/prefer-attribute-selector` | stylelint-plugin-otm | Ban `.class` selectors; use `[data-state="..."]`. |
355
+ | `declaration-no-important` | stylelint built-in | Ban `!important`. |
356
+
357
+ ### 3. HTML / cross-file — `otm-lint`
358
+
359
+ ```bash
360
+ npx otm-lint --check ./src
361
+ ```
362
+
363
+ | Rule | Forbidden | Use instead |
364
+ | --- | --- | --- |
365
+ | **HTML-004** | Naked text in HTML | Wrap in a semantic tag or use `data-i18n="key"`. |
366
+ | **HTML-017** | `<div data-action="...">` without `role`/`tabindex` | Use a `<button>` or other interactive element. |
367
+ | **HTML-023** | `data-i18n="..."` without `<meta name="i18n">` | Declare the i18n endpoint. |
368
+ | **HTML-024** | `data-available="..."` doesn't match locales folder | Keep the manifest aligned with the actual locale files. |
369
+
370
+ `otm-lint` default-excludes `node_modules`, `dist`, `.git`, and dotdirs. It only scans `.html` — everything else delegates to the layers above.
371
+
372
+ ### Recommended companions (not shipped)
373
+
374
+ | Concern | Tool |
375
+ | --- | --- |
376
+ | HTML correctness (lang/charset/viewport, inline handlers, deprecated attrs) | `html-validate` |
377
+ | Static a11y | `eslint-plugin-jsx-a11y` (works on plain HTML via parsers) |
378
+ | Runtime a11y | `axe-core` via Playwright/Cypress against the rendered page |
379
+ | Supply-chain | `npm audit`, `osv-scanner` |
380
+
381
+ ## Suggested project layout
382
+
383
+ ```
384
+ my-app/
385
+ ├── index.html # Pico + meta tags + script entry
386
+ ├── app.js # await the.boot(); register handlers
387
+ ├── locales/
388
+ │ ├── en.json
389
+ │ ├── es.json
390
+ │ └── fr.json
391
+ ├── styles.css # attribute-selector-driven custom CSS (optional)
392
+ └── views/
393
+ ├── home.html # fragment imported via fetch + $.clone, or inline <template>
394
+ └── about.html
395
+ ```
396
+
397
+ `<meta name="i18n" content="/locales" data-available="en,es,fr" data-fallback="en">` directs `the.boot()` to fetch `/locales/{lang}.json` automatically.
398
+
399
+ ## Repository layout (contributors)
400
+
401
+ ```
402
+ src/
403
+ ├── core/
404
+ │ ├── index.js # public exports
405
+ │ ├── On.js # event delegation + emit
406
+ │ ├── The.js # state, i18n, boot, route, form, flat
407
+ │ └── Select.js # $, $$, clone
408
+ └── linter/
409
+ ├── main.js # otm-lint binary entrypoint
410
+ ├── cli.js # directory scan, output
411
+ └── Linter.js # rule implementations
412
+ test/
413
+ └── integration.test.js # cross-module integration suite
414
+ dist/
415
+ └── on_the_money.min.js # built ESM bundle (esbuild)
416
+ ```
417
+
418
+ Unit tests are co-located with source: `src/core/On.test.js`, etc.
419
+
420
+ ## Commands
421
+
422
+ | Script | Purpose |
423
+ | --- | --- |
424
+ | `npm test` | Run all tests (`node --test`). |
425
+ | `npm run test:coverage` | Tests + coverage report (50/50/50 line/branch/function gate). |
426
+ | `npm run lint` | Biome formatter + import order. |
427
+ | `npm run build` | Build `dist/on_the_money.min.js` via esbuild. |
428
+ | `npm run check` | `lint + build + test + coverage`. CI gate. |
429
+ | `npm run otm` | Self-lint `examples/` against project rules. |
430
+
431
+ ## License
432
+
433
+ MIT. Source and issues: <https://github.com/possumtech/on_the_money.js>
@@ -0,0 +1 @@
1
+ var b=class{static on(t,n,o,e){let r=typeof t=="string"?document.querySelector(t):t||document.body,a=i=>{let s=i.target.closest(o);s&&r.contains(s)&&e.call(s,i,s)};return r.addEventListener(n,a),()=>r.removeEventListener(n,a)}static emit(t,n,o){let e=typeof t=="string"?document.querySelector(t):t,r=new CustomEvent(n,{bubbles:!0,cancelable:!0,detail:o});e.dispatchEvent(r)}};var l=class c{static dictionary={};static locale=typeof navigator<"u"?navigator.language:"en-US";static prefix="otm:";static the(...t){if(t.length===0)throw new TypeError("the(): missing args");return t[0]instanceof Element?c.#e(t[0],t.slice(1)):c.#e(document.body,t)}static#e(t,n){let[o,e]=n,r=t===document.body;if(n.length===1&&typeof o=="string")return c.#o(t,o);if(n.length===2&&typeof o=="string"){if(typeof e>"u")throw new TypeError(`the(${JSON.stringify(o)}, undefined): val is required for set`);return c.#t(t,o,e),r&&localStorage.setItem(`${c.prefix}${o}`,e),t}if(n.length===1&&o?.constructor===Object){for(let[a,i]of Object.entries(o))c.#t(t,a,i),r&&localStorage.setItem(`${c.prefix}${a}`,i);return t}throw new TypeError(`the(): unrecognized call shape (${n.map(a=>typeof a).join(", ")})`)}static flat(t,n="_"){if(t===null||typeof t!="object")throw new TypeError("the.flat: input must be an object");let o={},e=(r,a)=>{if(r===null||typeof r!="object"){o[a]=r;return}for(let[i,s]of Object.entries(r))e(s,a?`${a}${n}${i}`:i)};return e(t,""),o}static form(t){let n={},o=t.querySelectorAll("input, select, textarea");for(let e of o){let r=e.name;if(!r||e.disabled)continue;let a=(e.type||"text").toLowerCase();if(a==="submit"||a==="button"||a==="reset"||(a==="checkbox"||a==="radio")&&!(e.checked??e.hasAttribute("checked")))continue;let i=e.value??e.getAttribute("value")??"";c.#n(n,r,i)}return n}static#n(t,n,o){let e=n.split(/[\[\]]/).filter(Boolean),r=t;for(let a=0;a<e.length;a++){let i=e[a];a===e.length-1?r[i]!==void 0?(Array.isArray(r[i])||(r[i]=[r[i]]),r[i].push(o)):r[i]=o:(r[i]=r[i]||{},r=r[i])}}static _t(t,n={}){if(typeof Node<"u"?t instanceof Node:t?.nodeType){let a=t.querySelectorAll?[t,...t.querySelectorAll("[data-i18n]")]:[t];for(let i of a)if(i.hasAttribute?.("data-i18n")){let s=i.getAttribute("data-i18n"),d={};for(let u of i.attributes)if(u.name.startsWith("data-i18n-")&&u.name!=="data-i18n-type"){let y=u.name.replace("data-i18n-","");d[y]=u.value}let f=i.getAttribute("data-i18n-type");i.textContent=c._t(s,{...d,type:f})}return t}if(!t)return c._t(document.body),"";let e=c.dictionary[t];if(!e)return t;if(typeof e=="object"){let a=n.qty!==void 0?Number(n.qty):0,i=new Intl.PluralRules(c.locale).select(a);e=e[i]||e.other||t}if(typeof e!="string")return t;let r=e;for(let[a,i]of Object.entries(n)){let s=i;if(a==="val"&&n.type){let d=Number(i),f=n.type==="currency"?new Intl.NumberFormat(c.locale,{style:"currency",currency:"USD"}):n.type==="date"?new Intl.DateTimeFormat(c.locale):new Intl.NumberFormat(c.locale);s=n.type==="date"?f.format(new Date(i)):f.format(d)}r=r.replace(`{${a}}`,s)}return r}static route(t){if(typeof window>"u")return;let n=()=>t(window.location.pathname,window.location.search,window.location.hash);window.addEventListener("popstate",n),window.addEventListener("hashchange",n),document.addEventListener("click",o=>{let e=o.target.closest("a");if(!e||!e.getAttribute("href")||e.hasAttribute("data-external")||e.target==="_blank")return;let r=new URL(e.getAttribute("href"),window.location.href);r.origin===window.location.origin&&(r.pathname===window.location.pathname&&r.search===window.location.search&&r.hash||(o.preventDefault(),window.history.pushState({},"",r.href),n()))}),n()}static#o(t,n){let e={expanded:"aria-expanded",selected:"aria-selected",hidden:"aria-hidden",checked:"aria-checked",disabled:"aria-disabled"}[n]||`data-${n}`;return t.getAttribute(e)}static#t(t,n,o){let r={expanded:"aria-expanded",selected:"aria-selected",hidden:"aria-hidden",checked:"aria-checked",disabled:"aria-disabled"}[n]||`data-${n}`,a=typeof o=="boolean"?o?"true":"false":o;if(t.setAttribute(r,a),t.querySelectorAll){let i=t.querySelectorAll(`[data-text="${n}"]`);for(let s of i)s.textContent=a}t.getAttribute?.("data-text")===n&&(t.textContent=a)}static async boot({signal:t,locales:n,dictionary:o,namespace:e}={}){e&&(c.prefix=`${e}:`);let r=typeof window<"u"&&window.location?window.location.search:"",a=new URLSearchParams(r),i=(typeof navigator<"u"?navigator.language:null)||document.documentElement.lang||"en";if(c.locale=a.get("lang")||localStorage.getItem(`${c.prefix}lang`)||i,o)c.dictionary=o;else{let s=document.querySelector('meta[name="i18n"]'),d=n||s?.getAttribute("content");if(d){let f=s?.getAttribute("data-fallback")||"en",u=(s?.getAttribute("data-available")||"").split(",").map(p=>p.trim().toLowerCase()),y=c.locale.toLowerCase(),g=y.split("-")[0],w=f;u.includes(y)?w=y:u.includes(g)&&(w=g);try{let p=await fetch(`${d}/${w}.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 s=0;s<localStorage.length;s++){let d=localStorage.key(s);if(d.startsWith(c.prefix)){let f=d.slice(c.prefix.length);if(f!=="lang"){let u=localStorage.getItem(d);c.#t(document.body,f,u)}}}c._t()}};var h=class{static $(t,n){let o=t,e=n;return typeof o=="string"&&(e=o,o=document),o.querySelector(e)}static $$(t,n){let o=t,e=n;return typeof o=="string"&&(e=o,o=document),Array.from(o.querySelectorAll(e))}static clone(t,n){let o=typeof t=="string"?document.querySelector(t):t,e=document.querySelector(n);if(!o)throw new Error(`Parent not found: ${t}`);if(!e)throw new Error(`Template not found: ${n}`);let r=e.content.cloneNode(!0).firstElementChild;l._t(r),o.appendChild(r);let a=new CustomEvent("mounted",{bubbles:!0,detail:{parent:o}});return r.dispatchEvent(a),r}};var x=b.on;x.emit=b.emit;var m=l.the,$=l._t,A=l.route;m.t=$;m.route=A;m.form=l.form;m.flat=l.flat;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 v=h.$;v.clone=h.clone;var S=h.$$,T={on:x,the:m,$:v,$$:S,_t:$};export{v as $,S as $$,$ as _t,T as default,x as on,A as route,m as the};
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "on_the_money",
3
+ "version": "0.3.0",
4
+ "description": "Opinionated, attribute-driven, standards-oriented modern framework. <2KB gzip. Native browser APIs only.",
5
+ "type": "module",
6
+ "main": "dist/on_the_money.min.js",
7
+ "module": "dist/on_the_money.min.js",
8
+ "bin": {
9
+ "otm-lint": "./src/linter/main.js"
10
+ },
11
+ "exports": {
12
+ ".": "./dist/on_the_money.min.js",
13
+ "./src": "./src/core/index.js",
14
+ "./eslint-plugin": "./src/eslint/plugin.js",
15
+ "./eslint-config": "./src/eslint/config.js",
16
+ "./stylelint-plugin": "./src/stylelint/plugin.js",
17
+ "./stylelint-config": "./src/stylelint/config.js"
18
+ },
19
+ "files": [
20
+ "src/**/*.js",
21
+ "!src/**/*.test.js",
22
+ "dist",
23
+ "README.md",
24
+ "LICENSE",
25
+ "CHANGELOG.md"
26
+ ],
27
+ "scripts": {
28
+ "build": "esbuild src/core/index.js --bundle --minify --format=esm --outfile=dist/on_the_money.min.js",
29
+ "test": "node --test --test-concurrency=1 src/core/*.test.js src/linter/*.test.js src/eslint/*.test.js src/stylelint/*.test.js test/integration.test.js",
30
+ "test:coverage": "node --test --test-concurrency=1 --experimental-test-coverage --test-coverage-lines=50 --test-coverage-branches=50 --test-coverage-functions=50 src/core/*.test.js src/linter/*.test.js src/eslint/*.test.js src/stylelint/*.test.js test/integration.test.js",
31
+ "check": "npm run lint && npm run build && npm run test && npm run test:coverage",
32
+ "lint": "biome check ./src ./test ./examples",
33
+ "lint:fix": "biome check --write ./src ./test ./examples",
34
+ "otm": "node src/linter/main.js --check examples",
35
+ "prepack": "npm run build",
36
+ "prepublishOnly": "npm run check"
37
+ },
38
+ "engines": {
39
+ "node": ">=24.0.0"
40
+ },
41
+ "dependencies": {
42
+ "parse5": "7.2.1"
43
+ },
44
+ "peerDependencies": {
45
+ "stylelint": ">=16"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "stylelint": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "devDependencies": {
53
+ "@biomejs/biome": "1.9.4",
54
+ "esbuild": "0.25.0",
55
+ "eslint": "10.4.0",
56
+ "linkedom": "0.18.6",
57
+ "postcss-selector-parser": "7.1.1",
58
+ "stylelint": "17.11.1"
59
+ },
60
+ "license": "MIT"
61
+ }
package/src/core/On.js ADDED
@@ -0,0 +1,27 @@
1
+ export default class On {
2
+ static on(parent, event, selector, fn) {
3
+ const container =
4
+ typeof parent === "string"
5
+ ? document.querySelector(parent)
6
+ : parent || document.body;
7
+
8
+ const handler = (e) => {
9
+ const target = e.target.closest(selector);
10
+ if (target && container.contains(target)) {
11
+ fn.call(target, e, target);
12
+ }
13
+ };
14
+ container.addEventListener(event, handler);
15
+ return () => container.removeEventListener(event, handler);
16
+ }
17
+
18
+ static emit(el, event, detail) {
19
+ const target = typeof el === "string" ? document.querySelector(el) : el;
20
+ const customEvent = new CustomEvent(event, {
21
+ bubbles: true,
22
+ cancelable: true,
23
+ detail,
24
+ });
25
+ target.dispatchEvent(customEvent);
26
+ }
27
+ }
@@ -0,0 +1,55 @@
1
+ import The from "./The.js";
2
+
3
+ export default class Select {
4
+ static $(context, selector) {
5
+ let ctx = context;
6
+ let sel = selector;
7
+ if (typeof ctx === "string") {
8
+ sel = ctx;
9
+ ctx = document;
10
+ }
11
+ return ctx.querySelector(sel);
12
+ }
13
+
14
+ static $$(context, selector) {
15
+ let ctx = context;
16
+ let sel = selector;
17
+ if (typeof ctx === "string") {
18
+ sel = ctx;
19
+ ctx = document;
20
+ }
21
+ return Array.from(ctx.querySelectorAll(sel));
22
+ }
23
+
24
+ /**
25
+ * Clones a template and attaches it to a parent.
26
+ * This ensures the element is immediately part of the DOM lifecycle.
27
+ * @param {HTMLElement|string} parent - The target container
28
+ * @param {string} selector - The template ID/selector
29
+ * @returns {HTMLElement} The mounted element
30
+ */
31
+ static clone(parent, selector) {
32
+ const container =
33
+ typeof parent === "string" ? document.querySelector(parent) : parent;
34
+ const template = document.querySelector(selector);
35
+
36
+ if (!container) throw new Error(`Parent not found: ${parent}`);
37
+ if (!template) throw new Error(`Template not found: ${selector}`);
38
+
39
+ const el = template.content.cloneNode(true).firstElementChild;
40
+
41
+ // Surgical hydration of the new fragment before mounting
42
+ The._t(el);
43
+
44
+ container.appendChild(el);
45
+
46
+ // Trigger a 'mounted' custom event for lifecycle hooks
47
+ const event = new CustomEvent("mounted", {
48
+ bubbles: true,
49
+ detail: { parent: container },
50
+ });
51
+ el.dispatchEvent(event);
52
+
53
+ return el;
54
+ }
55
+ }