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 +24 -0
- package/README.md +3 -2
- package/dist/on_the_money.min.js +1 -1
- package/package.json +43 -14
- package/src/clipboard/index.d.ts +8 -0
- package/src/core/The.js +22 -5
- package/src/core/index.d.ts +100 -0
- package/src/eslint/config.d.ts +2 -0
- package/src/eslint/plugin.d.ts +2 -0
- package/src/linter/Linter.js +27 -13
- package/src/linter/Markup.js +14 -0
- package/src/linter/cli.js +4 -4
- package/src/live/Live.js +6 -1
- package/src/live/index.d.ts +46 -0
- package/src/stylelint/config.d.ts +2 -0
- package/src/stylelint/plugin.d.ts +2 -0
- package/src/test/index.d.ts +10 -0
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
|
|
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
|
|
package/dist/on_the_money.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var g=class{static on(t,e,
|
|
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.
|
|
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": "
|
|
9
|
+
"otm-lint": "src/linter/main.js"
|
|
10
10
|
},
|
|
11
11
|
"exports": {
|
|
12
|
-
".":
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"./
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
"
|
|
65
|
-
"
|
|
93
|
+
"stylelint": "17.11.1",
|
|
94
|
+
"typescript": "5.9.2"
|
|
66
95
|
},
|
|
67
96
|
"license": "MIT"
|
|
68
97
|
}
|
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.#
|
|
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}`,
|
|
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
|
-
|
|
379
|
-
|
|
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;
|
package/src/linter/Linter.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
359
|
+
for (const m of markup.matchAll(/\[data-([a-z0-9-]+)/g))
|
|
349
360
|
consumed.add(m[1]);
|
|
350
|
-
for (const m of
|
|
361
|
+
for (const m of markup.matchAll(/data-text=["']([\w-]+)["']/g))
|
|
351
362
|
consumed.add(m[1]);
|
|
352
|
-
for (const m of
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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;
|