on_the_money 0.7.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ 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.8.1] — 2026-07-25
8
+
9
+ ### Fixed
10
+
11
+ - Restored the `otm-lint` executable in the npm manifest by using npm 11's normalized bin target path. `0.8.0` omitted the bin during publication.
12
+
13
+ ## [0.8.0] — 2026-07-25
14
+
15
+ ### Added
16
+
17
+ - Public TypeScript declarations for every package subpath, compiler-checked against representative consumer calls.
18
+ - Real-browser contract tests across Chromium, Firefox, and WebKit.
19
+ - `live()` `onError(error, raw)` hook for malformed JSON frames; without a hook the error warns, and either way the connection remains open.
20
+
21
+ ### Changed
22
+
23
+ - **Breaking:** `the()` now enforces flat state at runtime: strings, finite numbers, booleans, and `null` deletion only. Previously objects and other unsupported values reached the DOM through implicit string coercion.
24
+ - The shipped Stylelint plugin's direct `postcss-selector-parser` import is now a declared runtime dependency.
25
+
26
+ ### Fixed
27
+
28
+ - `the.form()` rejects `__proto__`, `prototype`, and `constructor` bracket-path segments, preventing form field names from traversing or mutating inherited prototypes.
29
+ - EJS code and comment tags are neutralized before markup linting, so raw HTML tokens inside template code cannot push parse5 into RAWTEXT mode and hide the rest of the file. (#146)
30
+
7
31
  ## [0.7.2] — 2026-07-20
8
32
 
9
33
  ### Changed
package/README.md CHANGED
@@ -152,12 +152,12 @@ Polymorphic on a single disambiguator: `args[0] instanceof Element`. Three call
152
152
 
153
153
  - **Truly one-off attributes** still use `el.setAttribute(name, val)` directly, colocated with the write (Discipline #2). `data-bind` is for attributes that carry per-element *state-derived data* (every card's link); `setAttribute` is for structural one-offs. If you're writing the same `setAttribute` line in every clone loop, that's a `data-bind`.
154
154
  - **Booleans coerce** to `"true"`/`"false"` inside the setter. `the(el, "checked", true)` writes `"true"`.
155
- - **Values MUST be flat primitives.** Pass nested objects through `the.flat(...)` first.
155
+ - **Values MUST be strings, finite numbers, booleans, or `null`.** Numbers and booleans stringify; `null` deletes. Objects, arrays, functions, symbols, bigint, `NaN`, and infinities throw at runtime. Pass nested objects through `the.flat(...)` first.
156
156
  - **`the(key, undefined)` throws.** Two args means set; missing val is a contract violation.
157
157
 
158
158
  ### `the.form(formEl)` — form extraction
159
159
 
160
- Walks `input, select, textarea` descendants. Skips unnamed, disabled, submit/button/reset controls, file inputs, and unchecked checkboxes/radios. Parses bracket-notation names into a nested object:
160
+ Walks `input, select, textarea` descendants. Skips unnamed, disabled, submit/button/reset controls, file inputs, and unchecked checkboxes/radios. Parses bracket-notation names into a nested object. Unsafe path segments (`__proto__`, `prototype`, `constructor`) throw instead of entering the result graph:
161
161
 
162
162
  ```javascript
163
163
  the.form(form);
@@ -358,6 +358,7 @@ const reply = await ch.request({ type: "filter", q: "cats" }, { takeLatest: true
358
358
  | Option | Behavior |
359
359
  | --- | --- |
360
360
  | `onMessage(type, data, at)` | Every non-reply frame. Unknown types must be ignorable — the vocabulary is append-only. |
361
+ | `onError(error, raw)` | Malformed JSON frame. Without this callback, `live()` warns; either way the connection remains open. |
361
362
  | `onDown()` / `onUp()` | Link state hooks. Fail closed in `onDown`: a dead socket should surface the degraded truth. |
362
363
  | `signal` | `AbortSignal`; abort closes the socket and stops reconnecting. |
363
364
 
@@ -1 +1 @@
1
- var g=class{static on(t,e,n,c){let o=typeof t=="string"?document.querySelector(t):t||document.body;if(!o)throw new Error(`on(): parent selector matched nothing: ${t}`);let r=i=>{let s=i.target.closest(n);s&&o.contains(s)&&c.call(s,i,s)};return o.addEventListener(e,r),()=>o.removeEventListener(e,r)}static emit(t,e,n){let c=typeof t=="string"?document.querySelector(t):t,o=new CustomEvent(e,{bubbles:!0,cancelable:!0,detail:n});c.dispatchEvent(o)}};var l=class a{static dictionary={};static locale=typeof navigator<"u"?navigator.language:"en";static prefix="otm:";static persistKeys=new Set;static#e(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?a.#o(t[0],t.slice(1)):a.#o(document.body,t)}static#o(t,e){let[n,c]=e,o=t===document.body;if(e.length===1&&typeof n=="string")return a.#d(t,a.#e(n));if(e.length===2&&typeof n=="string"){if(typeof c>"u")throw new TypeError(`the(${JSON.stringify(n)}, undefined): val is required for set`);return a.#i(t,o,n,c),t}if(e.length===1&&n?.constructor===Object){for(let[r,i]of Object.entries(n))a.#i(t,o,r,i);return t}throw new TypeError(`the(): unrecognized call shape (${e.map(r=>typeof r).join(", ")})`)}static match(t,e){let n=e??(typeof window<"u"&&window.location?window.location.pathname:""),c=[],r=t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&").replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g,(u,f)=>(c.push(f),"([^/]+)")),i=n.match(new RegExp(`^${r}$`));if(!i)return null;let s={};return c.forEach((u,f)=>{s[u]=decodeURIComponent(i[f+1])}),s}static flat(t,e="_"){if(t===null||typeof t!="object")throw new TypeError("the.flat: input must be an object");let n={},c=(o,r)=>{if(o===null||typeof o!="object"){n[r]=o;return}for(let[i,s]of Object.entries(o))c(s,r?`${r}${e}${i}`:i)};return c(t,""),n}static form(t){let e={},n=t.querySelectorAll("input, select, textarea");for(let c of n){let o=c.name;if(!o||c.disabled)continue;let r=(c.type||"text").toLowerCase();if(r==="submit"||r==="button"||r==="reset"||r==="file"||(r==="checkbox"||r==="radio")&&!(c.checked??c.hasAttribute("checked")))continue;if(c.tagName==="SELECT"&&c.hasAttribute("multiple")){let s="selectedOptions"in c?c.selectedOptions:c.querySelectorAll("option[selected]");for(let u of s)a.#r(e,o,u.value);continue}let i=c.value??c.getAttribute("value")??"";a.#r(e,o,i)}return e}static#r(t,e,n){let c=e.endsWith("[]"),o=e.split(/[\[\]]/).filter(Boolean),r=t;for(let i=0;i<o.length;i++){let s=o[i];i===o.length-1?c?(r[s]||=[],r[s].push(n)):r[s]!==void 0?(Array.isArray(r[s])||(r[s]=[r[s]]),r[s].push(n)):r[s]=n:(r[s]=r[s]||{},r=r[s])}}static _t(t,e={}){if(typeof Node<"u"?t instanceof Node:t?.nodeType){let c=t.querySelectorAll?[t,...t.querySelectorAll("[data-i18n]")]:[t];for(let o of c)if(o.hasAttribute?.("data-i18n")){let r=o.getAttribute("data-i18n"),i={};for(let f of o.attributes)if(f.name.startsWith("data-i18n-")&&f.name!=="data-i18n-type"){let A=f.name.replace("data-i18n-","");i[A]=f.value}let s=o.getAttribute("data-i18n-type"),u=a.#a(r,{...i,type:s});u!==null&&(o.textContent=u)}return t}return t?a.#a(t,e)??t:(a._t(document.body),"")}static#c(t){if(!t.currency)throw new TypeError('_t: type "currency" requires an explicit currency (options.currency or data-i18n-currency)');return t.currency}static#a(t,e){let n=a.dictionary[t];if(!n){if(e.val!==void 0&&(e.type==="currency"||e.type==="date")){let o=e.type==="currency"?new Intl.NumberFormat(a.locale,{style:"currency",currency:a.#c(e)}):new Intl.DateTimeFormat(a.locale);return e.type==="date"?o.format(new Date(e.val)):o.format(Number(e.val))}return null}if(typeof n=="object"){let o=e.qty!==void 0?Number(e.qty):0,r=new Intl.PluralRules(a.locale).select(o);n=n[r]||n.other||t}if(typeof n!="string")return t;let c=n;for(let[o,r]of Object.entries(e)){let i=r;if(o==="val"&&e.type){let s=Number(r),u=e.type==="currency"?new Intl.NumberFormat(a.locale,{style:"currency",currency:a.#c(e)}):e.type==="date"?new Intl.DateTimeFormat(a.locale):new Intl.NumberFormat(a.locale);i=e.type==="date"?u.format(new Date(r)):u.format(s)}c=c.replaceAll(`{${o}}`,()=>i)}return c}static#t=null;static route(t,{match:e}={}){if(typeof window>"u")return;if(a.#t)throw new Error("route(): a router is already active \u2014 call its unsubscribe first");let n=()=>t(window.location.pathname,window.location.search,window.location.hash);a.#t=n;let c=o=>{if(o.metaKey||o.ctrlKey||o.shiftKey||o.altKey||o.button>0)return;let r=o.target.closest("a");if(!r||!r.getAttribute("href")||r.hasAttribute("data-external")||r.hasAttribute("download")||r.target==="_blank"||e&&!r.matches(e))return;let i=new URL(r.getAttribute("href"),window.location.href);i.origin===window.location.origin&&(i.pathname===window.location.pathname&&i.search===window.location.search&&i.hash||(o.preventDefault(),i.href!==window.location.href&&(window.history.pushState({},"",i.href),n())))};return window.addEventListener("popstate",n),window.addEventListener("hashchange",n),document.addEventListener("click",c),n(),()=>{window.removeEventListener("popstate",n),window.removeEventListener("hashchange",n),document.removeEventListener("click",c),a.#t=null}}static go(t){if(typeof window>"u")return;if(!a.#t)throw new Error("route.go(): no active router \u2014 call route() first");let e=new URL(t,window.location.href);e.href!==window.location.href&&(window.history.pushState({},"",e.href),a.#t())}static#f={expanded:"aria-expanded",selected:"aria-selected",hidden:"aria-hidden",checked:"aria-checked",disabled:"aria-disabled"};static#n(t,e){return t===document.body?`data-${e}`:a.#f[e]||`data-${e}`}static#i(t,e,n,c){let o=a.#e(n);if(c===null){a.#m(t,o),e&&a.persistKeys.has(o)&&localStorage.removeItem(`${a.prefix}${o}`);return}a.#s(t,o,c),e&&a.persistKeys.has(o)&&localStorage.setItem(`${a.prefix}${o}`,c)}static#d(t,e){return t.getAttribute(a.#n(t,e))}static#s(t,e,n){let c=typeof n=="boolean"?n?"true":"false":n;t.setAttribute(a.#n(t,e),c),a.#l(t,e,c,!1)}static#m(t,e){t.removeAttribute(a.#n(t,e)),a.#l(t,e,"",!0)}static#l(t,e,n,c){let o=t===document.body&&typeof document<"u"?document.documentElement||document:t;if(o.querySelectorAll){for(let r of o.querySelectorAll(`[data-text="${e}"]`))r.textContent=n;for(let r of o.querySelectorAll(`[data-bind*="${e}"]`))a.#u(r,e,n,c)}t.getAttribute?.("data-text")===e&&(t.textContent=n),a.#u(t,e,n,c)}static#u(t,e,n,c){let o=t.getAttribute?.("data-bind");if(o)for(let r of o.split(/\s+/)){let i=r.indexOf(":");if(i<1||r.slice(i+1)!==e)continue;let s=r.slice(0,i);c?t.removeAttribute(s):t.setAttribute(s,n)}}static async boot({signal:t,locales:e,dictionary:n,namespace:c,defaultLocale:o,persistKeys:r}={}){c&&(a.prefix=`${c}:`),r&&(a.persistKeys=new Set(r.map(d=>a.#e(d))));let i=typeof window<"u"&&window.location?window.location.search:"",s=new URLSearchParams(i),u=document.documentElement.lang||(typeof navigator<"u"?navigator.language:null)||"en";a.locale=s.get("lang")||localStorage.getItem(`${a.prefix}lang`)||u;let f=o||document.documentElement.lang,A=a.locale.toLowerCase().split("-")[0],S=f?.toLowerCase().split("-")[0],L=!!S&&A===S;if(n)a.dictionary=n;else if(!L){let d=document.querySelector('meta[name="i18n"]'),y=e||d?.getAttribute("content");if(y){let h=d?.getAttribute("data-fallback")||"en",v=(d?.getAttribute("data-available")||"").split(",").map(w=>w.trim().toLowerCase()),E=a.locale.toLowerCase(),q=E.split("-")[0],$=h;v.includes(E)?$=E:v.includes(q)&&($=q);let C=async w=>{let b=await fetch(`${y}/${w}.json`,{signal:t});if(!b.ok)throw new Error(`HTTP ${b.status} for ${w}.json`);return b.json()};try{a.dictionary=await C($)}catch(w){if(t?.aborted)throw w;if(console.warn(`otm: i18n fetch failed for "${$}"`,w),$!==h)try{a.dictionary=await C(h)}catch(b){if(t?.aborted)throw b;console.warn(`otm: i18n fallback "${h}" failed`,b)}}}}for(let d=0;d<localStorage.length;d++){let y=localStorage.key(d);if(y.startsWith(a.prefix)){let h=y.slice(a.prefix.length);if(h!=="lang"&&a.persistKeys.has(h)){let v=localStorage.getItem(y);a.#s(document.body,h,v)}}}L||a._t()}};var p=class a{static $(t,e){let n=t,c=e;return typeof n=="string"&&(c=n,n=document),n.querySelector(c)}static $$(t,e){let n=t,c=e;return typeof n=="string"&&(c=n,n=document),Array.from(n.querySelectorAll(c))}static clone(t,e,{position:n="beforeend"}={}){let c=typeof t=="string"?document.querySelector(t):t,o=document.querySelector(e);if(!c)throw new Error(`Parent not found: ${t}`);if(!o)throw new Error(`Template not found: ${e}`);let r=o.content.cloneNode(!0).firstElementChild;l._t(r),c.insertAdjacentElement(n,r);let i=new CustomEvent("mounted",{bubbles:!0,detail:{parent:c}});return r.dispatchEvent(i),r}static cloneEach(t,e,n,c){let o=typeof t=="string"?document.querySelector(t):t;if(!o)throw new Error(`Parent not found: ${t}`);o.replaceChildren();let r=[],i=0;for(let s of n){let u=a.clone(o,e);c?.(u,s,i++),r.push(u)}return r}};var N=g.on;N.emit=g.emit;var m=l.the,j=l._t,T=l.route;T.go=l.go;m.t=j;m.route=T;m.form=l.form;m.flat=l.flat;m.match=l.match;m.boot=l.boot;Object.defineProperty(m,"dictionary",{get:()=>l.dictionary,set:a=>{l.dictionary=a}});Object.defineProperty(m,"locale",{get:()=>l.locale,set:a=>{l.locale=a}});var x=p.$;x.clone=p.clone;x.cloneEach=p.cloneEach;var I=p.$$,z={on:N,the:m,$:x,$$:I,_t:j};export{x as $,I as $$,j as _t,z as default,N as on,T as route,m as the};
1
+ var g=class{static on(t,e,r,a){let n=typeof t=="string"?document.querySelector(t):t||document.body;if(!n)throw new Error(`on(): parent selector matched nothing: ${t}`);let c=i=>{let s=i.target.closest(r);s&&n.contains(s)&&a.call(s,i,s)};return n.addEventListener(e,c),()=>n.removeEventListener(e,c)}static emit(t,e,r){let a=typeof t=="string"?document.querySelector(t):t,n=new CustomEvent(e,{bubbles:!0,cancelable:!0,detail:r});a.dispatchEvent(n)}};var u=class o{static dictionary={};static locale=typeof navigator<"u"?navigator.language:"en";static prefix="otm:";static persistKeys=new Set;static#e(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?o.#r(t[0],t.slice(1)):o.#r(document.body,t)}static#r(t,e){let[r,a]=e,n=t===document.body;if(e.length===1&&typeof r=="string")return o.#m(t,o.#e(r));if(e.length===2&&typeof r=="string"){if(typeof a>"u")throw new TypeError(`the(${JSON.stringify(r)}, undefined): val is required for set`);return o.#i(t,n,r,a),t}if(e.length===1&&r?.constructor===Object){for(let[c,i]of Object.entries(r))o.#i(t,n,c,i);return t}throw new TypeError(`the(): unrecognized call shape (${e.map(c=>typeof c).join(", ")})`)}static match(t,e){let r=e??(typeof window<"u"&&window.location?window.location.pathname:""),a=[],c=t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&").replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g,(l,f)=>(a.push(f),"([^/]+)")),i=r.match(new RegExp(`^${c}$`));if(!i)return null;let s={};return a.forEach((l,f)=>{s[l]=decodeURIComponent(i[f+1])}),s}static flat(t,e="_"){if(t===null||typeof t!="object")throw new TypeError("the.flat: input must be an object");let r={},a=(n,c)=>{if(n===null||typeof n!="object"){r[c]=n;return}for(let[i,s]of Object.entries(n))a(s,c?`${c}${e}${i}`:i)};return a(t,""),r}static form(t){let e={},r=t.querySelectorAll("input, select, textarea");for(let a of r){let n=a.name;if(!n||a.disabled)continue;let c=(a.type||"text").toLowerCase();if(c==="submit"||c==="button"||c==="reset"||c==="file"||(c==="checkbox"||c==="radio")&&!(a.checked??a.hasAttribute("checked")))continue;if(a.tagName==="SELECT"&&a.hasAttribute("multiple")){let s="selectedOptions"in a?a.selectedOptions:a.querySelectorAll("option[selected]");for(let l of s)o.#o(e,n,l.value);continue}let i=a.value??a.getAttribute("value")??"";o.#o(e,n,i)}return e}static#o(t,e,r){let a=e.endsWith("[]"),n=e.split(/[\[\]]/).filter(Boolean),c=n.find(s=>["__proto__","prototype","constructor"].includes(s));if(c)throw new TypeError(`the.form: unsafe field name ${JSON.stringify(e)} contains forbidden path segment ${JSON.stringify(c)}`);let i=t;for(let s=0;s<n.length;s++){let l=n[s];s===n.length-1?a?(i[l]||=[],i[l].push(r)):i[l]!==void 0?(Array.isArray(i[l])||(i[l]=[i[l]]),i[l].push(r)):i[l]=r:(i[l]=i[l]||{},i=i[l])}}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 n of a)if(n.hasAttribute?.("data-i18n")){let c=n.getAttribute("data-i18n"),i={};for(let f of n.attributes)if(f.name.startsWith("data-i18n-")&&f.name!=="data-i18n-type"){let A=f.name.replace("data-i18n-","");i[A]=f.value}let s=n.getAttribute("data-i18n-type"),l=o.#a(c,{...i,type:s});l!==null&&(n.textContent=l)}return t}return t?o.#a(t,e)??t:(o._t(document.body),"")}static#c(t){if(!t.currency)throw new TypeError('_t: type "currency" requires an explicit currency (options.currency or data-i18n-currency)');return t.currency}static#a(t,e){let r=o.dictionary[t];if(!r){if(e.val!==void 0&&(e.type==="currency"||e.type==="date")){let n=e.type==="currency"?new Intl.NumberFormat(o.locale,{style:"currency",currency:o.#c(e)}):new Intl.DateTimeFormat(o.locale);return e.type==="date"?n.format(new Date(e.val)):n.format(Number(e.val))}return null}if(typeof r=="object"){let n=e.qty!==void 0?Number(e.qty):0,c=new Intl.PluralRules(o.locale).select(n);r=r[c]||r.other||t}if(typeof r!="string")return t;let a=r;for(let[n,c]of Object.entries(e)){let i=c;if(n==="val"&&e.type){let s=Number(c),l=e.type==="currency"?new Intl.NumberFormat(o.locale,{style:"currency",currency:o.#c(e)}):e.type==="date"?new Intl.DateTimeFormat(o.locale):new Intl.NumberFormat(o.locale);i=e.type==="date"?l.format(new Date(c)):l.format(s)}a=a.replaceAll(`{${n}}`,()=>i)}return a}static#t=null;static route(t,{match:e}={}){if(typeof window>"u")return;if(o.#t)throw new Error("route(): a router is already active \u2014 call its unsubscribe first");let r=()=>t(window.location.pathname,window.location.search,window.location.hash);o.#t=r;let a=n=>{if(n.metaKey||n.ctrlKey||n.shiftKey||n.altKey||n.button>0)return;let c=n.target.closest("a");if(!c||!c.getAttribute("href")||c.hasAttribute("data-external")||c.hasAttribute("download")||c.target==="_blank"||e&&!c.matches(e))return;let i=new URL(c.getAttribute("href"),window.location.href);i.origin===window.location.origin&&(i.pathname===window.location.pathname&&i.search===window.location.search&&i.hash||(n.preventDefault(),i.href!==window.location.href&&(window.history.pushState({},"",i.href),r())))};return window.addEventListener("popstate",r),window.addEventListener("hashchange",r),document.addEventListener("click",a),r(),()=>{window.removeEventListener("popstate",r),window.removeEventListener("hashchange",r),document.removeEventListener("click",a),o.#t=null}}static go(t){if(typeof window>"u")return;if(!o.#t)throw new Error("route.go(): no active router \u2014 call route() first");let e=new URL(t,window.location.href);e.href!==window.location.href&&(window.history.pushState({},"",e.href),o.#t())}static#f={expanded:"aria-expanded",selected:"aria-selected",hidden:"aria-hidden",checked:"aria-checked",disabled:"aria-disabled"};static#n(t,e){return t===document.body?`data-${e}`:o.#f[e]||`data-${e}`}static#i(t,e,r,a){let n=o.#e(r);if(a===null){o.#p(t,n),e&&o.persistKeys.has(n)&&localStorage.removeItem(`${o.prefix}${n}`);return}let c=o.#d(n,a);o.#s(t,n,c),e&&o.persistKeys.has(n)&&localStorage.setItem(`${o.prefix}${n}`,c)}static#d(t,e){if(typeof e=="string")return e;if(typeof e=="boolean")return e?"true":"false";if(typeof e=="number"&&Number.isFinite(e))return String(e);throw new TypeError(`the(${JSON.stringify(t)}, ...): state value must be a string, finite number, boolean, or null`)}static#m(t,e){return t.getAttribute(o.#n(t,e))}static#s(t,e,r){t.setAttribute(o.#n(t,e),r),o.#l(t,e,r,!1)}static#p(t,e){t.removeAttribute(o.#n(t,e)),o.#l(t,e,"",!0)}static#l(t,e,r,a){let n=t===document.body&&typeof document<"u"?document.documentElement||document:t;if(n.querySelectorAll){for(let c of n.querySelectorAll(`[data-text="${e}"]`))c.textContent=r;for(let c of n.querySelectorAll(`[data-bind*="${e}"]`))o.#u(c,e,r,a)}t.getAttribute?.("data-text")===e&&(t.textContent=r),o.#u(t,e,r,a)}static#u(t,e,r,a){let n=t.getAttribute?.("data-bind");if(n)for(let c of n.split(/\s+/)){let i=c.indexOf(":");if(i<1||c.slice(i+1)!==e)continue;let s=c.slice(0,i);a?t.removeAttribute(s):t.setAttribute(s,r)}}static async boot({signal:t,locales:e,dictionary:r,namespace:a,defaultLocale:n,persistKeys:c}={}){a&&(o.prefix=`${a}:`),c&&(o.persistKeys=new Set(c.map(d=>o.#e(d))));let i=typeof window<"u"&&window.location?window.location.search:"",s=new URLSearchParams(i),l=document.documentElement.lang||(typeof navigator<"u"?navigator.language:null)||"en";o.locale=s.get("lang")||localStorage.getItem(`${o.prefix}lang`)||l;let f=n||document.documentElement.lang,A=o.locale.toLowerCase().split("-")[0],x=f?.toLowerCase().split("-")[0],L=!!x&&A===x;if(r)o.dictionary=r;else if(!L){let d=document.querySelector('meta[name="i18n"]'),y=e||d?.getAttribute("content");if(y){let p=d?.getAttribute("data-fallback")||"en",E=(d?.getAttribute("data-available")||"").split(",").map(w=>w.trim().toLowerCase()),v=o.locale.toLowerCase(),q=v.split("-")[0],$=p;E.includes(v)?$=v:E.includes(q)&&($=q);let N=async w=>{let b=await fetch(`${y}/${w}.json`,{signal:t});if(!b.ok)throw new Error(`HTTP ${b.status} for ${w}.json`);return b.json()};try{o.dictionary=await N($)}catch(w){if(t?.aborted)throw w;if(console.warn(`otm: i18n fetch failed for "${$}"`,w),$!==p)try{o.dictionary=await N(p)}catch(b){if(t?.aborted)throw b;console.warn(`otm: i18n fallback "${p}" failed`,b)}}}}for(let d=0;d<localStorage.length;d++){let y=localStorage.key(d);if(y.startsWith(o.prefix)){let p=y.slice(o.prefix.length);if(p!=="lang"&&o.persistKeys.has(p)){let E=localStorage.getItem(y);o.#s(document.body,p,E)}}}L||o._t()}};var h=class o{static $(t,e){let r=t,a=e;return typeof r=="string"&&(a=r,r=document),r.querySelector(a)}static $$(t,e){let r=t,a=e;return typeof r=="string"&&(a=r,r=document),Array.from(r.querySelectorAll(a))}static clone(t,e,{position:r="beforeend"}={}){let a=typeof t=="string"?document.querySelector(t):t,n=document.querySelector(e);if(!a)throw new Error(`Parent not found: ${t}`);if(!n)throw new Error(`Template not found: ${e}`);let c=n.content.cloneNode(!0).firstElementChild;u._t(c),a.insertAdjacentElement(r,c);let i=new CustomEvent("mounted",{bubbles:!0,detail:{parent:a}});return c.dispatchEvent(i),c}static cloneEach(t,e,r,a){let n=typeof t=="string"?document.querySelector(t):t;if(!n)throw new Error(`Parent not found: ${t}`);n.replaceChildren();let c=[],i=0;for(let s of r){let l=o.clone(n,e);a?.(l,s,i++),c.push(l)}return c}};var C=g.on;C.emit=g.emit;var m=u.the,j=u._t,T=u.route;T.go=u.go;m.t=j;m.route=T;m.form=u.form;m.flat=u.flat;m.match=u.match;m.boot=u.boot;Object.defineProperty(m,"dictionary",{get:()=>u.dictionary,set:o=>{u.dictionary=o}});Object.defineProperty(m,"locale",{get:()=>u.locale,set:o=>{u.locale=o}});var S=h.$;S.clone=h.clone;S.cloneEach=h.cloneEach;var O=h.$$,z={on:C,the:m,$:S,$$:O,_t:j};export{S as $,O as $$,j as _t,z as default,C as on,T as route,m as the};
package/package.json CHANGED
@@ -1,25 +1,50 @@
1
1
  {
2
2
  "name": "on_the_money",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
4
4
  "description": "Opinionated, attribute-driven, standards-oriented modern framework. Native browser APIs only. See README.md for the authoring context.",
5
5
  "type": "module",
6
6
  "main": "src/core/index.js",
7
7
  "module": "src/core/index.js",
8
8
  "bin": {
9
- "otm-lint": "./src/linter/main.js"
9
+ "otm-lint": "src/linter/main.js"
10
10
  },
11
11
  "exports": {
12
- ".": "./src/core/index.js",
13
- "./live": "./src/live/index.js",
14
- "./clipboard": "./src/clipboard/index.js",
15
- "./test": "./src/test/index.js",
16
- "./eslint-plugin": "./src/eslint/plugin.js",
17
- "./eslint-config": "./src/eslint/config.js",
18
- "./stylelint-plugin": "./src/stylelint/plugin.js",
19
- "./stylelint-config": "./src/stylelint/config.js"
12
+ ".": {
13
+ "types": "./src/core/index.d.ts",
14
+ "default": "./src/core/index.js"
15
+ },
16
+ "./live": {
17
+ "types": "./src/live/index.d.ts",
18
+ "default": "./src/live/index.js"
19
+ },
20
+ "./clipboard": {
21
+ "types": "./src/clipboard/index.d.ts",
22
+ "default": "./src/clipboard/index.js"
23
+ },
24
+ "./test": {
25
+ "types": "./src/test/index.d.ts",
26
+ "default": "./src/test/index.js"
27
+ },
28
+ "./eslint-plugin": {
29
+ "types": "./src/eslint/plugin.d.ts",
30
+ "default": "./src/eslint/plugin.js"
31
+ },
32
+ "./eslint-config": {
33
+ "types": "./src/eslint/config.d.ts",
34
+ "default": "./src/eslint/config.js"
35
+ },
36
+ "./stylelint-plugin": {
37
+ "types": "./src/stylelint/plugin.d.ts",
38
+ "default": "./src/stylelint/plugin.js"
39
+ },
40
+ "./stylelint-config": {
41
+ "types": "./src/stylelint/config.d.ts",
42
+ "default": "./src/stylelint/config.js"
43
+ }
20
44
  },
21
45
  "files": [
22
46
  "src/**/*.js",
47
+ "src/**/*.d.ts",
23
48
  "!src/**/*.test.js",
24
49
  "dist",
25
50
  "README.md",
@@ -29,8 +54,10 @@
29
54
  "scripts": {
30
55
  "build": "esbuild src/core/index.js --bundle --minify --format=esm --outfile=dist/on_the_money.min.js",
31
56
  "test": "node --test --test-concurrency=1 src/core/*.test.js src/live/*.test.js src/clipboard/*.test.js src/linter/*.test.js src/eslint/*.test.js src/stylelint/*.test.js test/*.test.js",
57
+ "test:browser": "playwright test",
58
+ "test:types": "tsc --project tsconfig.types.json",
32
59
  "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/live/*.test.js src/clipboard/*.test.js src/linter/*.test.js src/eslint/*.test.js src/stylelint/*.test.js test/*.test.js",
33
- "check": "npm run lint && npm run lint:examples && npm run build && npm run test && npm run test:coverage",
60
+ "check": "npm run lint && npm run lint:examples && npm run build && npm run test && npm run test:coverage && npm run test:types && npm run test:browser",
34
61
  "lint": "biome check ./src ./test ./examples",
35
62
  "lint:examples": "eslint examples/ && stylelint \"examples/**/*.css\"",
36
63
  "lint:fix": "biome check --write ./src ./test ./examples",
@@ -42,7 +69,8 @@
42
69
  "node": ">=24.0.0"
43
70
  },
44
71
  "dependencies": {
45
- "parse5": "7.2.1"
72
+ "parse5": "7.2.1",
73
+ "postcss-selector-parser": "7.1.1"
46
74
  },
47
75
  "peerDependencies": {
48
76
  "linkedom": ">=0.18",
@@ -58,11 +86,12 @@
58
86
  },
59
87
  "devDependencies": {
60
88
  "@biomejs/biome": "1.9.4",
89
+ "@playwright/test": "1.62.0",
61
90
  "esbuild": "0.25.0",
62
91
  "eslint": "10.4.0",
63
92
  "linkedom": "0.18.6",
64
- "postcss-selector-parser": "7.1.1",
65
- "stylelint": "17.11.1"
93
+ "stylelint": "17.11.1",
94
+ "typescript": "5.9.2"
66
95
  },
67
96
  "license": "MIT"
68
97
  }
@@ -0,0 +1,8 @@
1
+ export interface ClipboardOptions {
2
+ resetMs?: number;
3
+ }
4
+
5
+ export function clipboard(options?: ClipboardOptions): (() => void) | null;
6
+
7
+ declare const api: { clipboard: typeof clipboard };
8
+ export default api;
package/src/core/The.js CHANGED
@@ -133,6 +133,14 @@ export default class The {
133
133
  // depend on cardinality.
134
134
  const isArrayLeaf = key.endsWith("[]");
135
135
  const parts = key.split(/[\[\]]/).filter(Boolean);
136
+ const forbidden = parts.find((part) =>
137
+ ["__proto__", "prototype", "constructor"].includes(part),
138
+ );
139
+ if (forbidden) {
140
+ throw new TypeError(
141
+ `the.form: unsafe field name ${JSON.stringify(key)} contains forbidden path segment ${JSON.stringify(forbidden)}`,
142
+ );
143
+ }
136
144
  let current = obj;
137
145
  for (let i = 0; i < parts.length; i++) {
138
146
  const part = parts[i];
@@ -365,9 +373,19 @@ export default class The {
365
373
  localStorage.removeItem(`${The.prefix}${k}`);
366
374
  return;
367
375
  }
368
- The.#set(el, k, v);
376
+ const out = The.#stateValue(k, v);
377
+ The.#set(el, k, out);
369
378
  if (isGlobal && The.persistKeys.has(k))
370
- localStorage.setItem(`${The.prefix}${k}`, v);
379
+ localStorage.setItem(`${The.prefix}${k}`, out);
380
+ }
381
+
382
+ static #stateValue(key, val) {
383
+ if (typeof val === "string") return val;
384
+ if (typeof val === "boolean") return val ? "true" : "false";
385
+ if (typeof val === "number" && Number.isFinite(val)) return String(val);
386
+ throw new TypeError(
387
+ `the(${JSON.stringify(key)}, ...): state value must be a string, finite number, boolean, or null`,
388
+ );
371
389
  }
372
390
 
373
391
  static #get(el, key) {
@@ -375,9 +393,8 @@ export default class The {
375
393
  }
376
394
 
377
395
  static #set(el, key, val) {
378
- const out = typeof val === "boolean" ? (val ? "true" : "false") : val;
379
- el.setAttribute(The.#attr(el, key), out);
380
- The.#mirror(el, key, out, false);
396
+ el.setAttribute(The.#attr(el, key), val);
397
+ The.#mirror(el, key, val, false);
381
398
  }
382
399
 
383
400
  static #delete(el, key) {
@@ -0,0 +1,100 @@
1
+ export type StateValue = string | number | boolean | null;
2
+ export type State = Record<string, StateValue>;
3
+ export type DictionaryEntry = string | Record<string, string>;
4
+ export type Dictionary = Record<string, DictionaryEntry>;
5
+
6
+ export interface TranslationOptions {
7
+ [key: string]: unknown;
8
+ type?: "currency" | "date" | string;
9
+ currency?: string;
10
+ qty?: number | string;
11
+ val?: unknown;
12
+ }
13
+
14
+ export interface BootOptions {
15
+ signal?: AbortSignal;
16
+ locales?: string;
17
+ dictionary?: Dictionary;
18
+ namespace?: string;
19
+ defaultLocale?: string;
20
+ persistKeys?: string[];
21
+ }
22
+
23
+ export interface The {
24
+ (key: string): string | null;
25
+ (key: string, value: StateValue): HTMLElement;
26
+ (values: State): HTMLElement;
27
+ <T extends Element>(element: T, key: string): string | null;
28
+ <T extends Element>(element: T, key: string, value: StateValue): T;
29
+ <T extends Element>(element: T, values: State): T;
30
+ t: typeof _t;
31
+ route: Route;
32
+ form(form: HTMLFormElement): Record<string, unknown>;
33
+ flat(object: object, separator?: string): Record<string, unknown>;
34
+ match(pattern: string, path?: string): Record<string, string> | null;
35
+ boot(options?: BootOptions): Promise<void>;
36
+ dictionary: Dictionary;
37
+ locale: string;
38
+ }
39
+
40
+ export interface Route {
41
+ (
42
+ callback: (pathname: string, search: string, hash: string) => void,
43
+ options?: { match?: string },
44
+ ): (() => void) | undefined;
45
+ go(path: string): void;
46
+ }
47
+
48
+ export interface Select {
49
+ <E extends Element = Element>(selector: string): E | null;
50
+ <E extends Element = Element>(
51
+ context: ParentNode,
52
+ selector: string,
53
+ ): E | null;
54
+ clone(
55
+ parent: Element | string,
56
+ selector: string,
57
+ options?: { position?: InsertPosition },
58
+ ): HTMLElement;
59
+ cloneEach<T>(
60
+ parent: Element | string,
61
+ selector: string,
62
+ items: Iterable<T>,
63
+ fill?: (element: HTMLElement, item: T, index: number) => void,
64
+ ): HTMLElement[];
65
+ }
66
+
67
+ export interface SelectAll {
68
+ <E extends Element = Element>(selector: string): E[];
69
+ <E extends Element = Element>(context: ParentNode, selector: string): E[];
70
+ }
71
+
72
+ export interface On {
73
+ (
74
+ parent: Element | Document | string | null,
75
+ event: string,
76
+ selector: string,
77
+ handler: (this: Element, event: Event, target: Element) => void,
78
+ ): () => void;
79
+ emit(element: Element | string, event: string, detail?: unknown): void;
80
+ }
81
+
82
+ export function _t(key: string, options?: TranslationOptions): string;
83
+ export function _t<T extends Node>(node: T): T;
84
+ export function _t(): string;
85
+
86
+ export const the: The;
87
+ export const route: Route;
88
+ export const $: Select;
89
+ export const $$: SelectAll;
90
+ export const on: On;
91
+
92
+ declare const api: {
93
+ on: On;
94
+ the: The;
95
+ $: Select;
96
+ $$: SelectAll;
97
+ _t: typeof _t;
98
+ };
99
+
100
+ export default api;
@@ -0,0 +1,2 @@
1
+ declare const config: object[];
2
+ export default config;
@@ -0,0 +1,2 @@
1
+ declare const plugin: object;
2
+ export default plugin;
@@ -1,10 +1,12 @@
1
- import * as parse5 from "parse5";
1
+ import Markup from "./Markup.js";
2
2
 
3
3
  export default class Linter {
4
4
  static check(file, source, availableLocales = null) {
5
5
  if (!file.endsWith(".html")) return [];
6
6
  const violations = [];
7
- const document = parse5.parse(source, { sourceCodeLocationInfo: true });
7
+ const document = Markup.parse(file, source, {
8
+ sourceCodeLocationInfo: true,
9
+ });
8
10
  Linter.#checkHtmlRules(document, violations, file, availableLocales);
9
11
  return violations;
10
12
  }
@@ -142,7 +144,9 @@ export default class Linter {
142
144
  // HTML-101: orphan templates — <template id="X"> never referenced by $.clone(_, "#X")
143
145
  const templates = new Map();
144
146
  for (const { file, source } of htmlSources) {
145
- const document = parse5.parse(source, { sourceCodeLocationInfo: true });
147
+ const document = Markup.parse(file, source, {
148
+ sourceCodeLocationInfo: true,
149
+ });
146
150
  Linter.#traverse(document, (node) => {
147
151
  if (node.nodeName === "template") {
148
152
  const idAttr = node.attrs?.find((a) => a.name === "id");
@@ -190,7 +194,9 @@ export default class Linter {
190
194
  for (const { file, source, dicts } of htmlSources) {
191
195
  if (!dicts || dicts.length === 0) continue;
192
196
 
193
- const document = parse5.parse(source, { sourceCodeLocationInfo: true });
197
+ const document = Markup.parse(file, source, {
198
+ sourceCodeLocationInfo: true,
199
+ });
194
200
  Linter.#traverse(document, (node) => {
195
201
  const attrs = node.attrs
196
202
  ? Object.fromEntries(node.attrs.map((a) => [a.name, a.value]))
@@ -257,7 +263,9 @@ export default class Linter {
257
263
  const globalWriteKeys = Linter.#globalWriteKeys(jsSources);
258
264
 
259
265
  for (const { file, source } of htmlSources) {
260
- const document = parse5.parse(source, { sourceCodeLocationInfo: true });
266
+ const document = Markup.parse(file, source, {
267
+ sourceCodeLocationInfo: true,
268
+ });
261
269
  Linter.#traverse(document, (node) => {
262
270
  if (node.nodeName !== "template") return;
263
271
  Linter.#traverse(node, (inner) => {
@@ -304,7 +312,9 @@ export default class Linter {
304
312
 
305
313
  const actionValues = new Set();
306
314
  for (const { file, source } of htmlSources) {
307
- const document = parse5.parse(source, { sourceCodeLocationInfo: true });
315
+ const document = Markup.parse(file, source, {
316
+ sourceCodeLocationInfo: true,
317
+ });
308
318
  Linter.#traverse(document, (node) => {
309
319
  const action = node.attrs?.find((a) => a.name === "data-action");
310
320
  if (!action?.value) return;
@@ -343,13 +353,14 @@ export default class Linter {
343
353
  for (const m of source.matchAll(/\[data-([a-z0-9-]+)/g))
344
354
  consumed.add(m[1]);
345
355
  }
346
- for (const { source } of htmlSources) {
356
+ for (const { file, source } of htmlSources) {
357
+ const markup = Markup.source(file, source);
347
358
  // Inline <style> blocks ride along in the raw HTML source.
348
- for (const m of source.matchAll(/\[data-([a-z0-9-]+)/g))
359
+ for (const m of markup.matchAll(/\[data-([a-z0-9-]+)/g))
349
360
  consumed.add(m[1]);
350
- for (const m of source.matchAll(/data-text=["']([\w-]+)["']/g))
361
+ for (const m of markup.matchAll(/data-text=["']([\w-]+)["']/g))
351
362
  consumed.add(m[1]);
352
- for (const m of source.matchAll(/data-bind=["']([^"']+)["']/g)) {
363
+ for (const m of markup.matchAll(/data-bind=["']([^"']+)["']/g)) {
353
364
  for (const pair of m[1].split(/\s+/)) {
354
365
  const i = pair.indexOf(":");
355
366
  if (i >= 1) consumed.add(pair.slice(i + 1));
@@ -383,7 +394,9 @@ export default class Linter {
383
394
  const spanPairs = new Set(); // includes data-otm-dynamic spans — they
384
395
  // still satisfy the css→span direction; only the span→css check is waived
385
396
  for (const { file, source } of htmlSources) {
386
- const document = parse5.parse(source, { sourceCodeLocationInfo: true });
397
+ const document = Markup.parse(file, source, {
398
+ sourceCodeLocationInfo: true,
399
+ });
387
400
  Linter.#traverse(document, (node) => {
388
401
  for (const attr of node.attrs ?? []) {
389
402
  const m = attr.name.match(/^data-([a-z0-9-]+)-key$/);
@@ -412,7 +425,8 @@ export default class Linter {
412
425
  const spanRefs = [];
413
426
  const refPairs = new Set();
414
427
  for (const { file, source } of styleTexts) {
415
- for (const m of source.matchAll(
428
+ const styleSource = Markup.source(file, source);
429
+ for (const m of styleSource.matchAll(
416
430
  /\[data-([a-z0-9-]+?)-key=["']?([\w-]+)["']?\]/g,
417
431
  )) {
418
432
  refPairs.add(`${m[1]} ${m[2]}`);
@@ -420,7 +434,7 @@ export default class Linter {
420
434
  group: m[1],
421
435
  value: m[2],
422
436
  file,
423
- line: Linter.#lineOf(source, m.index),
437
+ line: Linter.#lineOf(styleSource, m.index),
424
438
  });
425
439
  }
426
440
  }
@@ -0,0 +1,14 @@
1
+ import * as parse5 from "parse5";
2
+
3
+ export default class Markup {
4
+ static source(file, source) {
5
+ if (!file.endsWith(".ejs")) return source;
6
+ return source.replace(/<%[\s\S]*?%>/g, (tag) =>
7
+ tag.replace(/[^\r\n]/g, " "),
8
+ );
9
+ }
10
+
11
+ static parse(file, source, options) {
12
+ return parse5.parse(Markup.source(file, source), options);
13
+ }
14
+ }
package/src/linter/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import * as parse5 from "parse5";
4
3
  import Linter from "./Linter.js";
4
+ import Markup from "./Markup.js";
5
5
 
6
6
  export default class Cli {
7
7
  static async run(args = []) {
@@ -23,8 +23,8 @@ export default class Cli {
23
23
  static #excludeDirs = new Set(["node_modules", "dist", ".git"]);
24
24
 
25
25
  // parse5, not regex — attribute order must not matter.
26
- static #findI18nMeta(source) {
27
- const doc = parse5.parse(source);
26
+ static #findI18nMeta(file, source) {
27
+ const doc = Markup.parse(file, source);
28
28
  let content = null;
29
29
  const walk = (node) => {
30
30
  if (content) return;
@@ -85,7 +85,7 @@ export default class Cli {
85
85
  let availableLocales = null;
86
86
  const dicts = [];
87
87
 
88
- const metaContent = Cli.#findI18nMeta(source);
88
+ const metaContent = Cli.#findI18nMeta(file, source);
89
89
  if (metaContent) {
90
90
  const localesDir = path.resolve(
91
91
  path.dirname(file),
package/src/live/Live.js CHANGED
@@ -131,7 +131,12 @@ export default class Live {
131
131
  let msg;
132
132
  try {
133
133
  msg = JSON.parse(raw);
134
- } catch {
134
+ } catch (cause) {
135
+ const error = new SyntaxError("otm live: malformed JSON frame", {
136
+ cause,
137
+ });
138
+ if (this.#options.onError) this.#options.onError(error, raw);
139
+ else console.warn(error, raw);
135
140
  return;
136
141
  }
137
142
  const pending = msg?.req_id != null ? this.#pending.get(msg.req_id) : null;
@@ -0,0 +1,46 @@
1
+ export interface LiveFrame {
2
+ [key: string]: unknown;
3
+ type?: string;
4
+ data?: unknown;
5
+ at?: unknown;
6
+ req_id?: number;
7
+ }
8
+
9
+ export interface LiveOptions {
10
+ onMessage?: (type: string | undefined, data: unknown, at: unknown) => void;
11
+ onError?: (error: SyntaxError, raw: string) => void;
12
+ onDown?: () => void;
13
+ onUp?: () => void;
14
+ signal?: AbortSignal;
15
+ }
16
+
17
+ export interface LiveChannel {
18
+ send(frame: LiveFrame): void;
19
+ request<T extends LiveFrame = LiveFrame>(
20
+ frame: LiveFrame,
21
+ options?: { timeoutMs?: number; takeLatest?: boolean },
22
+ ): Promise<T | null>;
23
+ }
24
+
25
+ export interface SseOptions {
26
+ types?: string[];
27
+ onMessage?: (type: string, data: unknown, lastEventId: string) => void;
28
+ onDown?: () => void;
29
+ onUp?: () => void;
30
+ signal?: AbortSignal;
31
+ }
32
+
33
+ export function backoffDelay(attempt: number, random?: () => number): number;
34
+ export function live(channel: string, options?: LiveOptions): LiveChannel;
35
+ export function live(
36
+ channel: { fromState: string },
37
+ options?: LiveOptions,
38
+ ): LiveChannel | null;
39
+ export function sse(path: string, options?: SseOptions): object;
40
+
41
+ declare const api: {
42
+ live: typeof live;
43
+ sse: typeof sse;
44
+ };
45
+
46
+ export default api;
@@ -0,0 +1,2 @@
1
+ declare const config: object;
2
+ export default config;
@@ -0,0 +1,2 @@
1
+ declare const plugin: object;
2
+ export default plugin;
@@ -0,0 +1,10 @@
1
+ export interface DOMSetup {
2
+ document: Document;
3
+ window: Window;
4
+ [key: string]: unknown;
5
+ }
6
+
7
+ export function setupDOM(
8
+ html?: string,
9
+ options?: { url?: string; language?: string },
10
+ ): DOMSetup;