on_the_money 0.3.1 → 0.3.3
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 +25 -10
- package/dist/on_the_money.min.js +1 -1
- package/package.json +1 -1
- package/src/core/The.js +65 -30
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.3] — 2026-05-22
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`the.boot({ ephemeralKeys })`** — declares state keys that should not persist to `localStorage`. Writes still update DOM and mirror to `[data-text]`, but skip storage; boot replay also skips them. Targets transient signals like `modal`, `loading`, `toast` that shouldn't survive page navigation. (#55)
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **`_t(key, options)`** now Intl-formats `options.val` even when the dictionary has no entry for `key`, provided `options.type` is `"currency"` or `"date"`. Default-locale apps (those using `defaultLocale` to skip the dictionary fetch) can now use `data-i18n-val` formatting without needing a placeholder dictionary entry. Missing entries with no `type` still return the key, as before. (#53)
|
|
16
|
+
|
|
17
|
+
## [0.3.2] — 2026-05-20
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`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)
|
|
22
|
+
- **localStorage state replay** still runs in the short-circuit path; only the i18n machinery is skipped.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **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.
|
|
27
|
+
|
|
7
28
|
## [0.3.1] — 2026-05-19
|
|
8
29
|
|
|
9
30
|
### Fixed
|
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,27 @@ 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. |
|
|
157
|
+
| `ephemeralKeys` | `string[]` | Global state keys that **should not** persist to `localStorage`. Writes to these keys still update the body attribute and any `[data-text]` mirrors, but skip the storage write; the boot replay also skips them. Use for transient signals like `modal`, `loading`, `toast`. Scoped state (`the(el, ...)`) never persists regardless. |
|
|
156
158
|
|
|
157
159
|
Boot sequence:
|
|
158
160
|
1. Resolve locale: `?lang=` query → `localStorage["${prefix}lang"]` → `navigator.language`. Writes `the.locale`.
|
|
159
|
-
2.
|
|
160
|
-
3.
|
|
161
|
-
4.
|
|
161
|
+
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.
|
|
162
|
+
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`.
|
|
163
|
+
4. Replay `localStorage` entries matching the prefix (except `${prefix}lang`) back onto body `data-*` and `[data-text]`.
|
|
164
|
+
5. Run `_t()` to hydrate `[data-i18n]`.
|
|
165
|
+
|
|
166
|
+
**Writing `data-i18n` elements:** always include the source-language text inside as a fallback:
|
|
167
|
+
|
|
168
|
+
```html
|
|
169
|
+
<!-- good — SEO fallback, no-JS fallback, no-flash default -->
|
|
170
|
+
<h1 data-i18n="title">Welcome to the app</h1>
|
|
171
|
+
|
|
172
|
+
<!-- avoid — page is blank until hydration -->
|
|
173
|
+
<h1 data-i18n="title"></h1>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
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
177
|
|
|
163
178
|
### `_t(key, options)` — `Intl` localization
|
|
164
179
|
|
|
@@ -173,13 +188,13 @@ _t(node); // hydrate every [data-i18n]
|
|
|
173
188
|
_t(); // hydrate document.body
|
|
174
189
|
```
|
|
175
190
|
|
|
176
|
-
Missing dictionary keys preserve existing `textContent` (SEO fallback).
|
|
191
|
+
Missing dictionary keys preserve existing `textContent` (SEO fallback). When `options.type` is `"currency"` or `"date"`, `Intl` formatting of `options.val` runs regardless of whether the dictionary has an entry — useful in default-locale apps that skip the fetch.
|
|
177
192
|
|
|
178
|
-
`[data-i18n]` element binding:
|
|
193
|
+
`[data-i18n]` element binding (always include source-language fallback text inside):
|
|
179
194
|
|
|
180
195
|
```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"
|
|
196
|
+
<span data-i18n="cart_items" data-i18n-qty="3">3 items</span>
|
|
197
|
+
<span data-i18n="price" data-i18n-val="9.99" data-i18n-type="currency">$9.99</span>
|
|
183
198
|
```
|
|
184
199
|
|
|
185
200
|
### `route(callback)` — pushState router
|
package/dist/on_the_money.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var w=class{static on(t,e,o,n){let r=typeof t=="string"?document.querySelector(t):t||document.body,a=i=>{let s=i.target.closest(o);s&&r.contains(s)&&n.call(s,i,s)};return r.addEventListener(e,a),()=>r.removeEventListener(e,a)}static emit(t,e,o){let n=typeof t=="string"?document.querySelector(t):t,r=new CustomEvent(e,{bubbles:!0,cancelable:!0,detail:o});n.dispatchEvent(r)}};var l=class c{static dictionary={};static locale=typeof navigator<"u"?navigator.language:"en-US";static prefix="otm:";static ephemeralKeys=new Set;static the(...t){if(t.length===0)throw new TypeError("the(): missing args");return t[0]instanceof Element?c.#e(t[0],t.slice(1)):c.#e(document.body,t)}static#e(t,e){let[o,n]=e,r=t===document.body;if(e.length===1&&typeof o=="string")return c.#o(t,o);if(e.length===2&&typeof o=="string"){if(typeof n>"u")throw new TypeError(`the(${JSON.stringify(o)}, undefined): val is required for set`);return c.#t(t,o,n),r&&!c.ephemeralKeys.has(o)&&localStorage.setItem(`${c.prefix}${o}`,n),t}if(e.length===1&&o?.constructor===Object){for(let[a,i]of Object.entries(o))c.#t(t,a,i),r&&!c.ephemeralKeys.has(a)&&localStorage.setItem(`${c.prefix}${a}`,i);return t}throw new TypeError(`the(): unrecognized call shape (${e.map(a=>typeof a).join(", ")})`)}static flat(t,e="_"){if(t===null||typeof t!="object")throw new TypeError("the.flat: input must be an object");let o={},n=(r,a)=>{if(r===null||typeof r!="object"){o[a]=r;return}for(let[i,s]of Object.entries(r))n(s,a?`${a}${e}${i}`:i)};return n(t,""),o}static form(t){let e={},o=t.querySelectorAll("input, select, textarea");for(let n of o){let r=n.name;if(!r||n.disabled)continue;let a=(n.type||"text").toLowerCase();if(a==="submit"||a==="button"||a==="reset"||(a==="checkbox"||a==="radio")&&!(n.checked??n.hasAttribute("checked")))continue;let i=n.value??n.getAttribute("value")??"";c.#n(e,r,i)}return e}static#n(t,e,o){let n=e.split(/[\[\]]/).filter(Boolean),r=t;for(let a=0;a<n.length;a++){let i=n[a];a===n.length-1?r[i]!==void 0?(Array.isArray(r[i])||(r[i]=[r[i]]),r[i].push(o)):r[i]=o:(r[i]=r[i]||{},r=r[i])}}static _t(t,e={}){if(typeof Node<"u"?t instanceof Node:t?.nodeType){let 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"),p={};for(let y of i.attributes)if(y.name.startsWith("data-i18n-")&&y.name!=="data-i18n-type"){let x=y.name.replace("data-i18n-","");p[x]=y.value}let h=i.getAttribute("data-i18n-type");i.textContent=c._t(s,{...p,type:h})}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 a=e.type==="currency"?new Intl.NumberFormat(c.locale,{style:"currency",currency:"USD"}):new Intl.DateTimeFormat(c.locale);return e.type==="date"?a.format(new Date(e.val)):a.format(Number(e.val))}return t}if(typeof n=="object"){let a=e.qty!==void 0?Number(e.qty):0,i=new Intl.PluralRules(c.locale).select(a);n=n[i]||n.other||t}if(typeof n!="string")return t;let r=n;for(let[a,i]of Object.entries(e)){let s=i;if(a==="val"&&e.type){let p=Number(i),h=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"?h.format(new Date(i)):h.format(p)}r=r.replace(`{${a}}`,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",o=>{let n=o.target.closest("a");if(!n||!n.getAttribute("href")||n.hasAttribute("data-external")||n.target==="_blank")return;let r=new URL(n.getAttribute("href"),window.location.href);r.origin===window.location.origin&&(r.pathname===window.location.pathname&&r.search===window.location.search&&r.hash||(o.preventDefault(),window.history.pushState({},"",r.href),e()))}),e()}static#o(t,e){let n={expanded:"aria-expanded",selected:"aria-selected",hidden:"aria-hidden",checked:"aria-checked",disabled:"aria-disabled"}[e]||`data-${e}`;return t.getAttribute(n)}static#t(t,e,o){let r={expanded:"aria-expanded",selected:"aria-selected",hidden:"aria-hidden",checked:"aria-checked",disabled:"aria-disabled"}[e]||`data-${e}`,a=typeof o=="boolean"?o?"true":"false":o;if(t.setAttribute(r,a),t.querySelectorAll){let i=t.querySelectorAll(`[data-text="${e}"]`);for(let s of i)s.textContent=a}t.getAttribute?.("data-text")===e&&(t.textContent=a)}static async boot({signal:t,locales:e,dictionary:o,namespace:n,defaultLocale:r,ephemeralKeys:a}={}){n&&(c.prefix=`${n}:`),a&&(c.ephemeralKeys=new Set(a));let i=typeof window<"u"&&window.location?window.location.search:"",s=new URLSearchParams(i),p=(typeof navigator<"u"?navigator.language:null)||document.documentElement.lang||"en";c.locale=s.get("lang")||localStorage.getItem(`${c.prefix}lang`)||p;let h=r||document.documentElement.lang,y=c.locale.toLowerCase().split("-")[0],x=h?.toLowerCase().split("-")[0],A=!!x&&y===x;if(!A)if(o)c.dictionary=o;else{let u=document.querySelector('meta[name="i18n"]'),b=e||u?.getAttribute("content");if(b){let g=u?.getAttribute("data-fallback")||"en",$=(u?.getAttribute("data-available")||"").split(",").map(f=>f.trim().toLowerCase()),v=c.locale.toLowerCase(),E=v.split("-")[0],S=g;$.includes(v)?S=v:$.includes(E)&&(S=E);try{let f=await fetch(`${b}/${S}.json`,{signal:t});f.ok&&(c.dictionary=await f.json())}catch(f){if(t?.aborted)throw f;console.warn("otm: i18n fetch failed",f)}}}for(let u=0;u<localStorage.length;u++){let b=localStorage.key(u);if(b.startsWith(c.prefix)){let g=b.slice(c.prefix.length);if(g!=="lang"&&!c.ephemeralKeys.has(g)){let $=localStorage.getItem(b);c.#t(document.body,g,$)}}}A||c._t()}};var m=class{static $(t,e){let o=t,n=e;return typeof o=="string"&&(n=o,o=document),o.querySelector(n)}static $$(t,e){let o=t,n=e;return typeof o=="string"&&(n=o,o=document),Array.from(o.querySelectorAll(n))}static clone(t,e){let o=typeof t=="string"?document.querySelector(t):t,n=document.querySelector(e);if(!o)throw new Error(`Parent not found: ${t}`);if(!n)throw new Error(`Template not found: ${e}`);let r=n.content.cloneNode(!0).firstElementChild;l._t(r),o.appendChild(r);let a=new CustomEvent("mounted",{bubbles:!0,detail:{parent:o}});return r.dispatchEvent(a),r}};var q=w.on;q.emit=w.emit;var d=l.the,L=l._t,j=l.route;d.t=L;d.route=j;d.form=l.form;d.flat=l.flat;d.boot=l.boot;Object.defineProperty(d,"dictionary",{get:()=>l.dictionary,set:c=>{l.dictionary=c}});Object.defineProperty(d,"locale",{get:()=>l.locale,set:c=>{l.locale=c}});var N=m.$;N.clone=m.clone;var C=m.$$,P={on:q,the:d,$:N,$$:C,_t:L};export{N as $,C as $$,L as _t,P as default,q as on,j as route,d as the};
|
package/package.json
CHANGED
package/src/core/The.js
CHANGED
|
@@ -3,6 +3,7 @@ export default class The {
|
|
|
3
3
|
static locale =
|
|
4
4
|
typeof navigator !== "undefined" ? navigator.language : "en-US";
|
|
5
5
|
static prefix = "otm:";
|
|
6
|
+
static ephemeralKeys = new Set();
|
|
6
7
|
|
|
7
8
|
static the(...args) {
|
|
8
9
|
if (args.length === 0) {
|
|
@@ -28,13 +29,15 @@ export default class The {
|
|
|
28
29
|
);
|
|
29
30
|
}
|
|
30
31
|
The.#set(el, a, b);
|
|
31
|
-
if (isGlobal
|
|
32
|
+
if (isGlobal && !The.ephemeralKeys.has(a))
|
|
33
|
+
localStorage.setItem(`${The.prefix}${a}`, b);
|
|
32
34
|
return el;
|
|
33
35
|
}
|
|
34
36
|
if (args.length === 1 && a?.constructor === Object) {
|
|
35
37
|
for (const [k, v] of Object.entries(a)) {
|
|
36
38
|
The.#set(el, k, v);
|
|
37
|
-
if (isGlobal
|
|
39
|
+
if (isGlobal && !The.ephemeralKeys.has(k))
|
|
40
|
+
localStorage.setItem(`${The.prefix}${k}`, v);
|
|
38
41
|
}
|
|
39
42
|
return el;
|
|
40
43
|
}
|
|
@@ -137,7 +140,24 @@ export default class The {
|
|
|
137
140
|
}
|
|
138
141
|
|
|
139
142
|
let entry = The.dictionary[key];
|
|
140
|
-
if (!entry)
|
|
143
|
+
if (!entry) {
|
|
144
|
+
if (
|
|
145
|
+
options.val !== undefined &&
|
|
146
|
+
(options.type === "currency" || options.type === "date")
|
|
147
|
+
) {
|
|
148
|
+
const fmt =
|
|
149
|
+
options.type === "currency"
|
|
150
|
+
? new Intl.NumberFormat(The.locale, {
|
|
151
|
+
style: "currency",
|
|
152
|
+
currency: "USD",
|
|
153
|
+
})
|
|
154
|
+
: new Intl.DateTimeFormat(The.locale);
|
|
155
|
+
return options.type === "date"
|
|
156
|
+
? fmt.format(new Date(options.val))
|
|
157
|
+
: fmt.format(Number(options.val));
|
|
158
|
+
}
|
|
159
|
+
return key;
|
|
160
|
+
}
|
|
141
161
|
|
|
142
162
|
if (typeof entry === "object") {
|
|
143
163
|
const qty = options.qty !== undefined ? Number(options.qty) : 0;
|
|
@@ -244,8 +264,16 @@ export default class The {
|
|
|
244
264
|
if (el.getAttribute?.("data-text") === key) el.textContent = out;
|
|
245
265
|
}
|
|
246
266
|
|
|
247
|
-
static async boot({
|
|
267
|
+
static async boot({
|
|
268
|
+
signal,
|
|
269
|
+
locales,
|
|
270
|
+
dictionary,
|
|
271
|
+
namespace,
|
|
272
|
+
defaultLocale,
|
|
273
|
+
ephemeralKeys,
|
|
274
|
+
} = {}) {
|
|
248
275
|
if (namespace) The.prefix = `${namespace}:`;
|
|
276
|
+
if (ephemeralKeys) The.ephemeralKeys = new Set(ephemeralKeys);
|
|
249
277
|
const search =
|
|
250
278
|
typeof window !== "undefined" && window.location
|
|
251
279
|
? window.location.search
|
|
@@ -261,30 +289,37 @@ export default class The {
|
|
|
261
289
|
localStorage.getItem(`${The.prefix}lang`) ||
|
|
262
290
|
browserLoc;
|
|
263
291
|
|
|
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
|
-
|
|
292
|
+
const sourceLang = defaultLocale || document.documentElement.lang;
|
|
293
|
+
const localeBase = The.locale.toLowerCase().split("-")[0];
|
|
294
|
+
const sourceBase = sourceLang?.toLowerCase().split("-")[0];
|
|
295
|
+
const skipI18n = Boolean(sourceBase) && localeBase === sourceBase;
|
|
296
|
+
|
|
297
|
+
if (!skipI18n) {
|
|
298
|
+
if (dictionary) {
|
|
299
|
+
The.dictionary = dictionary;
|
|
300
|
+
} else {
|
|
301
|
+
const meta = document.querySelector('meta[name="i18n"]');
|
|
302
|
+
const path = locales || meta?.getAttribute("content");
|
|
303
|
+
if (path) {
|
|
304
|
+
const fallback = meta?.getAttribute("data-fallback") || "en";
|
|
305
|
+
const available = (meta?.getAttribute("data-available") || "")
|
|
306
|
+
.split(",")
|
|
307
|
+
.map((s) => s.trim().toLowerCase());
|
|
308
|
+
|
|
309
|
+
const full = The.locale.toLowerCase();
|
|
310
|
+
const base = full.split("-")[0];
|
|
311
|
+
|
|
312
|
+
let target = fallback;
|
|
313
|
+
if (available.includes(full)) target = full;
|
|
314
|
+
else if (available.includes(base)) target = base;
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const res = await fetch(`${path}/${target}.json`, { signal });
|
|
318
|
+
if (res.ok) The.dictionary = await res.json();
|
|
319
|
+
} catch (e) {
|
|
320
|
+
if (signal?.aborted) throw e;
|
|
321
|
+
console.warn("otm: i18n fetch failed", e);
|
|
322
|
+
}
|
|
288
323
|
}
|
|
289
324
|
}
|
|
290
325
|
}
|
|
@@ -293,13 +328,13 @@ export default class The {
|
|
|
293
328
|
const fullKey = localStorage.key(i);
|
|
294
329
|
if (fullKey.startsWith(The.prefix)) {
|
|
295
330
|
const key = fullKey.slice(The.prefix.length);
|
|
296
|
-
if (key !== "lang") {
|
|
331
|
+
if (key !== "lang" && !The.ephemeralKeys.has(key)) {
|
|
297
332
|
const val = localStorage.getItem(fullKey);
|
|
298
333
|
The.#set(document.body, key, val);
|
|
299
334
|
}
|
|
300
335
|
}
|
|
301
336
|
}
|
|
302
337
|
|
|
303
|
-
The._t();
|
|
338
|
+
if (!skipI18n) The._t();
|
|
304
339
|
}
|
|
305
340
|
}
|