on_the_money 0.3.0 → 0.3.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 +21 -0
- package/README.md +38 -20
- package/dist/on_the_money.min.js +1 -1
- package/package.json +1 -1
- package/src/core/The.js +39 -26
- package/src/linter/Linter.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.3.2] — 2026-05-20
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`the.boot({ defaultLocale })`** — when the resolved locale base matches this value, the dictionary fetch and the boot-time `_t()` hydration pass are skipped entirely. No network, no FOUC for default-locale visitors. Auto-detected from `<html lang>` if the option is omitted, so the no-config case Just Works for any page that correctly declares its language. (#52)
|
|
12
|
+
- **localStorage state replay** still runs in the short-circuit path; only the i18n machinery is skipped.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **README** now recommends including source-language fallback text inside every `data-i18n` element (e.g. `<h1 data-i18n="title">Welcome</h1>`). Combined with the `defaultLocale` short-circuit, default-locale visitors see correct text immediately with zero hydration pass.
|
|
17
|
+
|
|
18
|
+
## [0.3.1] — 2026-05-19
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **README lint-stack tables** no longer refer to `eslint-plugin-otm` and `stylelint-plugin-otm` as if they were standalone packages. They ship bundled inside `on_the_money` via subpath exports; the rule-source columns now read "bundled in `on_the_money`". Anyone following the README literally was tempted to `npm install -D eslint-plugin-otm`, which 404s. (#49)
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **HTML-004 diagnostic** now hints toward `<dl>/<dt>/<dd>` for label/value patterns. The full message reads: "Naked strings in HTML are forbidden. Use data-i18n or wrap in a semantic tag (for label/value pairs, prefer `<dl>`/`<dt>`/`<dd>`)." Pushes consumers toward the semantically meaningful restructure rather than a cosmetic `<span>` wrap. (#50)
|
|
27
|
+
|
|
7
28
|
## [0.3.0] — 2026-05-18
|
|
8
29
|
|
|
9
30
|
### Breaking
|
package/README.md
CHANGED
|
@@ -20,13 +20,13 @@ A complete two-file app. `index.html` carries semantic structure, `app.js` carri
|
|
|
20
20
|
<head>
|
|
21
21
|
<meta charset="UTF-8">
|
|
22
22
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
23
|
-
<title data-i18n="app_title"
|
|
23
|
+
<title data-i18n="app_title">My App</title>
|
|
24
24
|
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@2/css/pico.classless.min.css">
|
|
25
25
|
<script type="module" src="./app.js"></script>
|
|
26
26
|
</head>
|
|
27
27
|
<body>
|
|
28
28
|
<main>
|
|
29
|
-
<h1 data-i18n="app_title"
|
|
29
|
+
<h1 data-i18n="app_title">My App</h1>
|
|
30
30
|
<p>Hello, <strong data-text="user">friend</strong>.</p>
|
|
31
31
|
<button data-action="greet">Greet</button>
|
|
32
32
|
</main>
|
|
@@ -144,7 +144,7 @@ Throws on non-object input.
|
|
|
144
144
|
Importing the module does **nothing**. Call `the.boot()` at the consumer's entry point.
|
|
145
145
|
|
|
146
146
|
```javascript
|
|
147
|
-
await the.boot({ signal, locales, dictionary, namespace });
|
|
147
|
+
await the.boot({ signal, locales, dictionary, namespace, defaultLocale });
|
|
148
148
|
```
|
|
149
149
|
|
|
150
150
|
| Option | Type | Behavior |
|
|
@@ -153,12 +153,26 @@ await the.boot({ signal, locales, dictionary, namespace });
|
|
|
153
153
|
| `locales` | `string` | Override the `<meta name="i18n" content>` path. |
|
|
154
154
|
| `dictionary` | `object` | Inline dictionary; skips the fetch entirely. |
|
|
155
155
|
| `namespace` | `string` | Sets `localStorage` prefix to `${namespace}:` (default `otm:`). Must be set before any state ops. |
|
|
156
|
+
| `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. |
|
|
156
157
|
|
|
157
158
|
Boot sequence:
|
|
158
159
|
1. Resolve locale: `?lang=` query → `localStorage["${prefix}lang"]` → `navigator.language`. Writes `the.locale`.
|
|
159
|
-
2.
|
|
160
|
-
3.
|
|
161
|
-
4.
|
|
160
|
+
2. **Short-circuit check.** If resolved locale base matches `defaultLocale` (or `<html lang>` if not provided), skip steps 3 and 5. Static HTML already serves the right text.
|
|
161
|
+
3. Resolve dictionary: inline `dictionary` → `fetch(${path}/${target}.json)` if `<meta name="i18n">` or `locales` option is present. Falls back through full → base → `data-fallback`.
|
|
162
|
+
4. Replay `localStorage` entries matching the prefix (except `${prefix}lang`) back onto body `data-*` and `[data-text]`.
|
|
163
|
+
5. Run `_t()` to hydrate `[data-i18n]`.
|
|
164
|
+
|
|
165
|
+
**Writing `data-i18n` elements:** always include the source-language text inside as a fallback:
|
|
166
|
+
|
|
167
|
+
```html
|
|
168
|
+
<!-- good — SEO fallback, no-JS fallback, no-flash default -->
|
|
169
|
+
<h1 data-i18n="title">Welcome to the app</h1>
|
|
170
|
+
|
|
171
|
+
<!-- avoid — page is blank until hydration -->
|
|
172
|
+
<h1 data-i18n="title"></h1>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The framework preserves existing `textContent` when a dictionary key is missing, so source-language text inside `data-i18n` elements stays visible if `_t()` runs against an empty dictionary (the short-circuit case above, or any environment without the i18n fetch).
|
|
162
176
|
|
|
163
177
|
### `_t(key, options)` — `Intl` localization
|
|
164
178
|
|
|
@@ -175,11 +189,11 @@ _t(); // hydrate document.body
|
|
|
175
189
|
|
|
176
190
|
Missing dictionary keys preserve existing `textContent` (SEO fallback).
|
|
177
191
|
|
|
178
|
-
`[data-i18n]` element binding:
|
|
192
|
+
`[data-i18n]` element binding (always include source-language fallback text inside):
|
|
179
193
|
|
|
180
194
|
```html
|
|
181
|
-
<span data-i18n="cart_items" data-i18n-qty="3"
|
|
182
|
-
<span data-i18n="price" data-i18n-val="9.99" data-i18n-type="currency"
|
|
195
|
+
<span data-i18n="cart_items" data-i18n-qty="3">3 items</span>
|
|
196
|
+
<span data-i18n="price" data-i18n-val="9.99" data-i18n-type="currency">$9.99</span>
|
|
183
197
|
```
|
|
184
198
|
|
|
185
199
|
### `route(callback)` — pushState router
|
|
@@ -306,10 +320,12 @@ The framework reads existing attributes via `the(key)` without modification, so
|
|
|
306
320
|
|
|
307
321
|
on_the_money ships a three-tool stack. Each layer covers what the others can't.
|
|
308
322
|
|
|
309
|
-
### 1. JavaScript — ESLint +
|
|
323
|
+
### 1. JavaScript — ESLint + bundled plugin
|
|
324
|
+
|
|
325
|
+
The ESLint plugin and config ship inside `on_the_money` itself. No separate `eslint-plugin-otm` package — import via subpath.
|
|
310
326
|
|
|
311
327
|
```bash
|
|
312
|
-
npm install -D eslint
|
|
328
|
+
npm install -D eslint eslint-plugin-no-unsanitized
|
|
313
329
|
```
|
|
314
330
|
|
|
315
331
|
```javascript
|
|
@@ -325,15 +341,17 @@ export default [
|
|
|
325
341
|
|
|
326
342
|
| Rule | Source | Behavior |
|
|
327
343
|
| --- | --- | --- |
|
|
328
|
-
| `otm/prefer-on` |
|
|
329
|
-
| `otm/prefer-the-set` |
|
|
330
|
-
| `otm/flat-state` |
|
|
331
|
-
| `otm/prefer-submit` |
|
|
332
|
-
| `otm/no-style-mutation` |
|
|
333
|
-
| `no-unsanitized/no-inner-html` |
|
|
334
|
-
| `no-unsanitized/method` |
|
|
344
|
+
| `otm/prefer-on` | bundled in `on_the_money` | Ban `addEventListener`; use `on()`. |
|
|
345
|
+
| `otm/prefer-the-set` | bundled in `on_the_money` | Ban `textContent`/`innerText`/`nodeValue` assignment. |
|
|
346
|
+
| `otm/flat-state` | bundled in `on_the_money` | Ban nested objects/arrays in `the()` calls. |
|
|
347
|
+
| `otm/prefer-submit` | bundled in `on_the_money` | Warn on `on(btn, "click", ...)` for form data. |
|
|
348
|
+
| `otm/no-style-mutation` | bundled in `on_the_money` | Ban `el.style.* = ...`. |
|
|
349
|
+
| `no-unsanitized/no-inner-html` | `eslint-plugin-no-unsanitized` | Ban `innerHTML`/`outerHTML`. |
|
|
350
|
+
| `no-unsanitized/method` | `eslint-plugin-no-unsanitized` | Ban `document.write`, `insertAdjacentHTML`. |
|
|
351
|
+
|
|
352
|
+
### 2. CSS — Stylelint + bundled plugin
|
|
335
353
|
|
|
336
|
-
|
|
354
|
+
Same pattern: the Stylelint plugin and config ship inside `on_the_money`. No separate `stylelint-plugin-otm` package.
|
|
337
355
|
|
|
338
356
|
```bash
|
|
339
357
|
npm install -D stylelint stylelint-config-standard
|
|
@@ -351,7 +369,7 @@ export default {
|
|
|
351
369
|
|
|
352
370
|
| Rule | Source | Behavior |
|
|
353
371
|
| --- | --- | --- |
|
|
354
|
-
| `otm/prefer-attribute-selector` |
|
|
372
|
+
| `otm/prefer-attribute-selector` | bundled in `on_the_money` | Ban `.class` selectors; use `[data-state="..."]`. |
|
|
355
373
|
| `declaration-no-important` | stylelint built-in | Ban `!important`. |
|
|
356
374
|
|
|
357
375
|
### 3. HTML / cross-file — `otm-lint`
|
package/dist/on_the_money.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var w=class{static on(t,o,n,e){let r=typeof t=="string"?document.querySelector(t):t||document.body,a=c=>{let s=c.target.closest(n);s&&r.contains(s)&&e.call(s,c,s)};return r.addEventListener(o,a),()=>r.removeEventListener(o,a)}static emit(t,o,n){let e=typeof t=="string"?document.querySelector(t):t,r=new CustomEvent(o,{bubbles:!0,cancelable:!0,detail:n});e.dispatchEvent(r)}};var l=class i{static dictionary={};static locale=typeof navigator<"u"?navigator.language:"en-US";static prefix="otm:";static the(...t){if(t.length===0)throw new TypeError("the(): missing args");return t[0]instanceof Element?i.#e(t[0],t.slice(1)):i.#e(document.body,t)}static#e(t,o){let[n,e]=o,r=t===document.body;if(o.length===1&&typeof n=="string")return i.#n(t,n);if(o.length===2&&typeof n=="string"){if(typeof e>"u")throw new TypeError(`the(${JSON.stringify(n)}, undefined): val is required for set`);return i.#t(t,n,e),r&&localStorage.setItem(`${i.prefix}${n}`,e),t}if(o.length===1&&n?.constructor===Object){for(let[a,c]of Object.entries(n))i.#t(t,a,c),r&&localStorage.setItem(`${i.prefix}${a}`,c);return t}throw new TypeError(`the(): unrecognized call shape (${o.map(a=>typeof a).join(", ")})`)}static flat(t,o="_"){if(t===null||typeof t!="object")throw new TypeError("the.flat: input must be an object");let n={},e=(r,a)=>{if(r===null||typeof r!="object"){n[a]=r;return}for(let[c,s]of Object.entries(r))e(s,a?`${a}${o}${c}`:c)};return e(t,""),n}static form(t){let o={},n=t.querySelectorAll("input, select, textarea");for(let e of n){let r=e.name;if(!r||e.disabled)continue;let a=(e.type||"text").toLowerCase();if(a==="submit"||a==="button"||a==="reset"||(a==="checkbox"||a==="radio")&&!(e.checked??e.hasAttribute("checked")))continue;let c=e.value??e.getAttribute("value")??"";i.#o(o,r,c)}return o}static#o(t,o,n){let e=o.split(/[\[\]]/).filter(Boolean),r=t;for(let a=0;a<e.length;a++){let c=e[a];a===e.length-1?r[c]!==void 0?(Array.isArray(r[c])||(r[c]=[r[c]]),r[c].push(n)):r[c]=n:(r[c]=r[c]||{},r=r[c])}}static _t(t,o={}){if(typeof Node<"u"?t instanceof Node:t?.nodeType){let a=t.querySelectorAll?[t,...t.querySelectorAll("[data-i18n]")]:[t];for(let c of a)if(c.hasAttribute?.("data-i18n")){let s=c.getAttribute("data-i18n"),h={};for(let u of c.attributes)if(u.name.startsWith("data-i18n-")&&u.name!=="data-i18n-type"){let g=u.name.replace("data-i18n-","");h[g]=u.value}let b=c.getAttribute("data-i18n-type");c.textContent=i._t(s,{...h,type:b})}return t}if(!t)return i._t(document.body),"";let e=i.dictionary[t];if(!e)return t;if(typeof e=="object"){let a=o.qty!==void 0?Number(o.qty):0,c=new Intl.PluralRules(i.locale).select(a);e=e[c]||e.other||t}if(typeof e!="string")return t;let r=e;for(let[a,c]of Object.entries(o)){let s=c;if(a==="val"&&o.type){let h=Number(c),b=o.type==="currency"?new Intl.NumberFormat(i.locale,{style:"currency",currency:"USD"}):o.type==="date"?new Intl.DateTimeFormat(i.locale):new Intl.NumberFormat(i.locale);s=o.type==="date"?b.format(new Date(c)):b.format(h)}r=r.replace(`{${a}}`,s)}return r}static route(t){if(typeof window>"u")return;let o=()=>t(window.location.pathname,window.location.search,window.location.hash);window.addEventListener("popstate",o),window.addEventListener("hashchange",o),document.addEventListener("click",n=>{let e=n.target.closest("a");if(!e||!e.getAttribute("href")||e.hasAttribute("data-external")||e.target==="_blank")return;let r=new URL(e.getAttribute("href"),window.location.href);r.origin===window.location.origin&&(r.pathname===window.location.pathname&&r.search===window.location.search&&r.hash||(n.preventDefault(),window.history.pushState({},"",r.href),o()))}),o()}static#n(t,o){let e={expanded:"aria-expanded",selected:"aria-selected",hidden:"aria-hidden",checked:"aria-checked",disabled:"aria-disabled"}[o]||`data-${o}`;return t.getAttribute(e)}static#t(t,o,n){let r={expanded:"aria-expanded",selected:"aria-selected",hidden:"aria-hidden",checked:"aria-checked",disabled:"aria-disabled"}[o]||`data-${o}`,a=typeof n=="boolean"?n?"true":"false":n;if(t.setAttribute(r,a),t.querySelectorAll){let c=t.querySelectorAll(`[data-text="${o}"]`);for(let s of c)s.textContent=a}t.getAttribute?.("data-text")===o&&(t.textContent=a)}static async boot({signal:t,locales:o,dictionary:n,namespace:e,defaultLocale:r}={}){e&&(i.prefix=`${e}:`);let a=typeof window<"u"&&window.location?window.location.search:"",c=new URLSearchParams(a),s=(typeof navigator<"u"?navigator.language:null)||document.documentElement.lang||"en";i.locale=c.get("lang")||localStorage.getItem(`${i.prefix}lang`)||s;let h=r||document.documentElement.lang,b=i.locale.toLowerCase().split("-")[0],u=h?.toLowerCase().split("-")[0],g=!!u&&b===u;if(!g)if(n)i.dictionary=n;else{let f=document.querySelector('meta[name="i18n"]'),y=o||f?.getAttribute("content");if(y){let x=f?.getAttribute("data-fallback")||"en",$=(f?.getAttribute("data-available")||"").split(",").map(m=>m.trim().toLowerCase()),v=i.locale.toLowerCase(),S=v.split("-")[0],A=x;$.includes(v)?A=v:$.includes(S)&&(A=S);try{let m=await fetch(`${y}/${A}.json`,{signal:t});m.ok&&(i.dictionary=await m.json())}catch(m){if(t?.aborted)throw m;console.warn("otm: i18n fetch failed",m)}}}for(let f=0;f<localStorage.length;f++){let y=localStorage.key(f);if(y.startsWith(i.prefix)){let x=y.slice(i.prefix.length);if(x!=="lang"){let $=localStorage.getItem(y);i.#t(document.body,x,$)}}}g||i._t()}};var p=class{static $(t,o){let n=t,e=o;return typeof n=="string"&&(e=n,n=document),n.querySelector(e)}static $$(t,o){let n=t,e=o;return typeof n=="string"&&(e=n,n=document),Array.from(n.querySelectorAll(e))}static clone(t,o){let n=typeof t=="string"?document.querySelector(t):t,e=document.querySelector(o);if(!n)throw new Error(`Parent not found: ${t}`);if(!e)throw new Error(`Template not found: ${o}`);let r=e.content.cloneNode(!0).firstElementChild;l._t(r),n.appendChild(r);let a=new CustomEvent("mounted",{bubbles:!0,detail:{parent:n}});return r.dispatchEvent(a),r}};var E=w.on;E.emit=w.emit;var d=l.the,q=l._t,j=l.route;d.t=q;d.route=j;d.form=l.form;d.flat=l.flat;d.boot=l.boot;Object.defineProperty(d,"dictionary",{get:()=>l.dictionary,set:i=>{l.dictionary=i}});Object.defineProperty(d,"locale",{get:()=>l.locale,set:i=>{l.locale=i}});var L=p.$;L.clone=p.clone;var C=p.$$,D={on:E,the:d,$:L,$$:C,_t:q};export{L as $,C as $$,q as _t,D as default,E as on,j as route,d as the};
|
package/package.json
CHANGED
package/src/core/The.js
CHANGED
|
@@ -244,7 +244,13 @@ export default class The {
|
|
|
244
244
|
if (el.getAttribute?.("data-text") === key) el.textContent = out;
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
static async boot({
|
|
247
|
+
static async boot({
|
|
248
|
+
signal,
|
|
249
|
+
locales,
|
|
250
|
+
dictionary,
|
|
251
|
+
namespace,
|
|
252
|
+
defaultLocale,
|
|
253
|
+
} = {}) {
|
|
248
254
|
if (namespace) The.prefix = `${namespace}:`;
|
|
249
255
|
const search =
|
|
250
256
|
typeof window !== "undefined" && window.location
|
|
@@ -261,30 +267,37 @@ export default class The {
|
|
|
261
267
|
localStorage.getItem(`${The.prefix}lang`) ||
|
|
262
268
|
browserLoc;
|
|
263
269
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (
|
|
287
|
-
|
|
270
|
+
const sourceLang = defaultLocale || document.documentElement.lang;
|
|
271
|
+
const localeBase = The.locale.toLowerCase().split("-")[0];
|
|
272
|
+
const sourceBase = sourceLang?.toLowerCase().split("-")[0];
|
|
273
|
+
const skipI18n = Boolean(sourceBase) && localeBase === sourceBase;
|
|
274
|
+
|
|
275
|
+
if (!skipI18n) {
|
|
276
|
+
if (dictionary) {
|
|
277
|
+
The.dictionary = dictionary;
|
|
278
|
+
} else {
|
|
279
|
+
const meta = document.querySelector('meta[name="i18n"]');
|
|
280
|
+
const path = locales || meta?.getAttribute("content");
|
|
281
|
+
if (path) {
|
|
282
|
+
const fallback = meta?.getAttribute("data-fallback") || "en";
|
|
283
|
+
const available = (meta?.getAttribute("data-available") || "")
|
|
284
|
+
.split(",")
|
|
285
|
+
.map((s) => s.trim().toLowerCase());
|
|
286
|
+
|
|
287
|
+
const full = The.locale.toLowerCase();
|
|
288
|
+
const base = full.split("-")[0];
|
|
289
|
+
|
|
290
|
+
let target = fallback;
|
|
291
|
+
if (available.includes(full)) target = full;
|
|
292
|
+
else if (available.includes(base)) target = base;
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const res = await fetch(`${path}/${target}.json`, { signal });
|
|
296
|
+
if (res.ok) The.dictionary = await res.json();
|
|
297
|
+
} catch (e) {
|
|
298
|
+
if (signal?.aborted) throw e;
|
|
299
|
+
console.warn("otm: i18n fetch failed", e);
|
|
300
|
+
}
|
|
288
301
|
}
|
|
289
302
|
}
|
|
290
303
|
}
|
|
@@ -300,6 +313,6 @@ export default class The {
|
|
|
300
313
|
}
|
|
301
314
|
}
|
|
302
315
|
|
|
303
|
-
The._t();
|
|
316
|
+
if (!skipI18n) The._t();
|
|
304
317
|
}
|
|
305
318
|
}
|
package/src/linter/Linter.js
CHANGED
|
@@ -92,7 +92,7 @@ export default class Linter {
|
|
|
92
92
|
file,
|
|
93
93
|
{ line: loc.startLine, column: loc.startCol },
|
|
94
94
|
"HTML-004",
|
|
95
|
-
"Naked strings in HTML are forbidden. Use data-i18n or wrap in a semantic tag.",
|
|
95
|
+
"Naked strings in HTML are forbidden. Use data-i18n or wrap in a semantic tag (for label/value pairs, prefer <dl>/<dt>/<dd>).",
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
98
|
}
|