on_the_money 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,305 @@
1
+ export default class The {
2
+ static dictionary = {};
3
+ static locale =
4
+ typeof navigator !== "undefined" ? navigator.language : "en-US";
5
+ static prefix = "otm:";
6
+
7
+ static the(...args) {
8
+ if (args.length === 0) {
9
+ throw new TypeError("the(): missing args");
10
+ }
11
+ if (args[0] instanceof Element) {
12
+ return The.#on(args[0], args.slice(1));
13
+ }
14
+ return The.#on(document.body, args);
15
+ }
16
+
17
+ static #on(el, args) {
18
+ const [a, b] = args;
19
+ const isGlobal = el === document.body;
20
+
21
+ if (args.length === 1 && typeof a === "string") {
22
+ return The.#get(el, a);
23
+ }
24
+ if (args.length === 2 && typeof a === "string") {
25
+ if (typeof b === "undefined") {
26
+ throw new TypeError(
27
+ `the(${JSON.stringify(a)}, undefined): val is required for set`,
28
+ );
29
+ }
30
+ The.#set(el, a, b);
31
+ if (isGlobal) localStorage.setItem(`${The.prefix}${a}`, b);
32
+ return el;
33
+ }
34
+ if (args.length === 1 && a?.constructor === Object) {
35
+ for (const [k, v] of Object.entries(a)) {
36
+ The.#set(el, k, v);
37
+ if (isGlobal) localStorage.setItem(`${The.prefix}${k}`, v);
38
+ }
39
+ return el;
40
+ }
41
+
42
+ throw new TypeError(
43
+ `the(): unrecognized call shape (${args.map((x) => typeof x).join(", ")})`,
44
+ );
45
+ }
46
+
47
+ static flat(obj, sep = "_") {
48
+ if (obj === null || typeof obj !== "object") {
49
+ throw new TypeError("the.flat: input must be an object");
50
+ }
51
+ const out = {};
52
+ const walk = (val, prefix) => {
53
+ if (val === null || typeof val !== "object") {
54
+ out[prefix] = val;
55
+ return;
56
+ }
57
+ for (const [k, v] of Object.entries(val)) {
58
+ walk(v, prefix ? `${prefix}${sep}${k}` : k);
59
+ }
60
+ };
61
+ walk(obj, "");
62
+ return out;
63
+ }
64
+
65
+ static form(formEl) {
66
+ const obj = {};
67
+ const controls = formEl.querySelectorAll("input, select, textarea");
68
+
69
+ for (const el of controls) {
70
+ const name = el.name;
71
+ if (!name || el.disabled) continue;
72
+
73
+ const type = (el.type || "text").toLowerCase();
74
+ if (type === "submit" || type === "button" || type === "reset") continue;
75
+
76
+ if (type === "checkbox" || type === "radio") {
77
+ const checked = el.checked ?? el.hasAttribute("checked");
78
+ if (!checked) continue;
79
+ }
80
+
81
+ const value = el.value ?? el.getAttribute("value") ?? "";
82
+ The.#assign(obj, name, value);
83
+ }
84
+ return obj;
85
+ }
86
+
87
+ static #assign(obj, key, value) {
88
+ const parts = key.split(/[\[\]]/).filter(Boolean);
89
+ let current = obj;
90
+ for (let i = 0; i < parts.length; i++) {
91
+ const part = parts[i];
92
+ const isLast = i === parts.length - 1;
93
+ if (isLast) {
94
+ if (current[part] !== undefined) {
95
+ if (!Array.isArray(current[part])) current[part] = [current[part]];
96
+ current[part].push(value);
97
+ } else {
98
+ current[part] = value;
99
+ }
100
+ } else {
101
+ current[part] = current[part] || {};
102
+ current = current[part];
103
+ }
104
+ }
105
+ }
106
+
107
+ static _t(key, options = {}) {
108
+ const isNode =
109
+ typeof Node !== "undefined" ? key instanceof Node : key?.nodeType;
110
+ if (isNode) {
111
+ const elements = key.querySelectorAll
112
+ ? [key, ...key.querySelectorAll("[data-i18n]")]
113
+ : [key];
114
+ for (const el of elements) {
115
+ if (el.hasAttribute?.("data-i18n")) {
116
+ const k = el.getAttribute("data-i18n");
117
+ const params = {};
118
+ for (const attr of el.attributes) {
119
+ if (
120
+ attr.name.startsWith("data-i18n-") &&
121
+ attr.name !== "data-i18n-type"
122
+ ) {
123
+ const paramName = attr.name.replace("data-i18n-", "");
124
+ params[paramName] = attr.value;
125
+ }
126
+ }
127
+ const type = el.getAttribute("data-i18n-type");
128
+ el.textContent = The._t(k, { ...params, type });
129
+ }
130
+ }
131
+ return key;
132
+ }
133
+
134
+ if (!key) {
135
+ The._t(document.body);
136
+ return "";
137
+ }
138
+
139
+ let entry = The.dictionary[key];
140
+ if (!entry) return key;
141
+
142
+ if (typeof entry === "object") {
143
+ const qty = options.qty !== undefined ? Number(options.qty) : 0;
144
+ const rule = new Intl.PluralRules(The.locale).select(qty);
145
+ entry = entry[rule] || entry.other || key;
146
+ }
147
+
148
+ if (typeof entry !== "string") return key;
149
+
150
+ let result = entry;
151
+ for (const [k, v] of Object.entries(options)) {
152
+ let val = v;
153
+ if (k === "val" && options.type) {
154
+ const num = Number(v);
155
+ const fmt =
156
+ options.type === "currency"
157
+ ? new Intl.NumberFormat(The.locale, {
158
+ style: "currency",
159
+ currency: "USD",
160
+ })
161
+ : options.type === "date"
162
+ ? new Intl.DateTimeFormat(The.locale)
163
+ : new Intl.NumberFormat(The.locale);
164
+ val =
165
+ options.type === "date" ? fmt.format(new Date(v)) : fmt.format(num);
166
+ }
167
+ result = result.replace(`{${k}}`, val);
168
+ }
169
+
170
+ return result;
171
+ }
172
+
173
+ static route(callback) {
174
+ if (typeof window === "undefined") return;
175
+
176
+ const navigate = () =>
177
+ callback(
178
+ window.location.pathname,
179
+ window.location.search,
180
+ window.location.hash,
181
+ );
182
+
183
+ window.addEventListener("popstate", navigate);
184
+ window.addEventListener("hashchange", navigate);
185
+
186
+ document.addEventListener("click", (e) => {
187
+ const link = e.target.closest("a");
188
+ if (
189
+ !link ||
190
+ !link.getAttribute("href") ||
191
+ link.hasAttribute("data-external") ||
192
+ link.target === "_blank"
193
+ ) {
194
+ return;
195
+ }
196
+
197
+ const url = new URL(link.getAttribute("href"), window.location.href);
198
+ if (url.origin !== window.location.origin) return;
199
+
200
+ if (
201
+ url.pathname === window.location.pathname &&
202
+ url.search === window.location.search &&
203
+ url.hash
204
+ ) {
205
+ return;
206
+ }
207
+
208
+ e.preventDefault();
209
+ window.history.pushState({}, "", url.href);
210
+ navigate();
211
+ });
212
+
213
+ navigate();
214
+ }
215
+
216
+ static #get(el, key) {
217
+ const ariaMap = {
218
+ expanded: "aria-expanded",
219
+ selected: "aria-selected",
220
+ hidden: "aria-hidden",
221
+ checked: "aria-checked",
222
+ disabled: "aria-disabled",
223
+ };
224
+ const attr = ariaMap[key] || `data-${key}`;
225
+ return el.getAttribute(attr);
226
+ }
227
+
228
+ static #set(el, key, val) {
229
+ const ariaMap = {
230
+ expanded: "aria-expanded",
231
+ selected: "aria-selected",
232
+ hidden: "aria-hidden",
233
+ checked: "aria-checked",
234
+ disabled: "aria-disabled",
235
+ };
236
+ const attr = ariaMap[key] || `data-${key}`;
237
+ const out = typeof val === "boolean" ? (val ? "true" : "false") : val;
238
+ el.setAttribute(attr, out);
239
+
240
+ if (el.querySelectorAll) {
241
+ const items = el.querySelectorAll(`[data-text="${key}"]`);
242
+ for (const item of items) item.textContent = out;
243
+ }
244
+ if (el.getAttribute?.("data-text") === key) el.textContent = out;
245
+ }
246
+
247
+ static async boot({ signal, locales, dictionary, namespace } = {}) {
248
+ if (namespace) The.prefix = `${namespace}:`;
249
+ const search =
250
+ typeof window !== "undefined" && window.location
251
+ ? window.location.search
252
+ : "";
253
+ const params = new URLSearchParams(search);
254
+ const browserLoc =
255
+ (typeof navigator !== "undefined" ? navigator.language : null) ||
256
+ document.documentElement.lang ||
257
+ "en";
258
+
259
+ The.locale =
260
+ params.get("lang") ||
261
+ localStorage.getItem(`${The.prefix}lang`) ||
262
+ browserLoc;
263
+
264
+ if (dictionary) {
265
+ The.dictionary = dictionary;
266
+ } else {
267
+ const meta = document.querySelector('meta[name="i18n"]');
268
+ const path = locales || meta?.getAttribute("content");
269
+ if (path) {
270
+ const fallback = meta?.getAttribute("data-fallback") || "en";
271
+ const available = (meta?.getAttribute("data-available") || "")
272
+ .split(",")
273
+ .map((s) => s.trim().toLowerCase());
274
+
275
+ const full = The.locale.toLowerCase();
276
+ const base = full.split("-")[0];
277
+
278
+ let target = fallback;
279
+ if (available.includes(full)) target = full;
280
+ else if (available.includes(base)) target = base;
281
+
282
+ try {
283
+ const res = await fetch(`${path}/${target}.json`, { signal });
284
+ if (res.ok) The.dictionary = await res.json();
285
+ } catch (e) {
286
+ if (signal?.aborted) throw e;
287
+ console.warn("otm: i18n fetch failed", e);
288
+ }
289
+ }
290
+ }
291
+
292
+ for (let i = 0; i < localStorage.length; i++) {
293
+ const fullKey = localStorage.key(i);
294
+ if (fullKey.startsWith(The.prefix)) {
295
+ const key = fullKey.slice(The.prefix.length);
296
+ if (key !== "lang") {
297
+ const val = localStorage.getItem(fullKey);
298
+ The.#set(document.body, key, val);
299
+ }
300
+ }
301
+ }
302
+
303
+ The._t();
304
+ }
305
+ }
@@ -0,0 +1,36 @@
1
+ import On from "./On.js";
2
+ import Select from "./Select.js";
3
+ import The from "./The.js";
4
+
5
+ export const on = On.on;
6
+ on.emit = On.emit;
7
+
8
+ export const the = The.the;
9
+ export const _t = The._t;
10
+ export const route = The.route;
11
+ the.t = _t;
12
+ the.route = route;
13
+ the.form = The.form;
14
+ the.flat = The.flat;
15
+ the.boot = The.boot;
16
+
17
+ Object.defineProperty(the, "dictionary", {
18
+ get: () => The.dictionary,
19
+ set: (v) => {
20
+ The.dictionary = v;
21
+ },
22
+ });
23
+
24
+ Object.defineProperty(the, "locale", {
25
+ get: () => The.locale,
26
+ set: (v) => {
27
+ The.locale = v;
28
+ },
29
+ });
30
+
31
+ export const $ = Select.$;
32
+ $.clone = Select.clone;
33
+
34
+ export const $$ = Select.$$;
35
+
36
+ export default { on, the, $, $$, _t };
@@ -0,0 +1,16 @@
1
+ import plugin from "./plugin.js";
2
+
3
+ const recommended = [
4
+ {
5
+ plugins: { otm: plugin },
6
+ rules: {
7
+ "otm/prefer-on": "error",
8
+ "otm/prefer-the-set": "error",
9
+ "otm/flat-state": "error",
10
+ "otm/prefer-submit": "warn",
11
+ "otm/no-style-mutation": "error",
12
+ },
13
+ },
14
+ ];
15
+
16
+ export default recommended;
@@ -0,0 +1,218 @@
1
+ const meta = {
2
+ name: "eslint-plugin-otm",
3
+ version: "0.3.0",
4
+ };
5
+
6
+ const preferOn = {
7
+ meta: {
8
+ type: "problem",
9
+ docs: {
10
+ description:
11
+ "Disallow direct addEventListener; use on() from on_the_money for event delegation.",
12
+ },
13
+ messages: {
14
+ useOn:
15
+ "Direct addEventListener is forbidden. Use on() for event delegation.",
16
+ },
17
+ schema: [],
18
+ },
19
+ create(context) {
20
+ return {
21
+ CallExpression(node) {
22
+ if (node.callee?.property?.name === "addEventListener") {
23
+ context.report({ node, messageId: "useOn" });
24
+ }
25
+ },
26
+ };
27
+ },
28
+ };
29
+
30
+ const preferTheSet = {
31
+ meta: {
32
+ type: "problem",
33
+ docs: {
34
+ description:
35
+ "Disallow direct assignment to textContent/innerText/nodeValue; use the() instead.",
36
+ },
37
+ messages: {
38
+ useThe:
39
+ "Direct text manipulation is forbidden. Use the() or [data-text] binding instead.",
40
+ },
41
+ schema: [],
42
+ },
43
+ create(context) {
44
+ const banned = new Set(["textContent", "innerText", "nodeValue"]);
45
+ return {
46
+ AssignmentExpression(node) {
47
+ if (
48
+ node.left?.type === "MemberExpression" &&
49
+ banned.has(node.left.property?.name)
50
+ ) {
51
+ context.report({ node, messageId: "useThe" });
52
+ }
53
+ },
54
+ };
55
+ },
56
+ };
57
+
58
+ const flatState = {
59
+ meta: {
60
+ type: "problem",
61
+ docs: {
62
+ description:
63
+ "Disallow nested objects or arrays passed to the(); state must be flat primitives.",
64
+ },
65
+ messages: {
66
+ notFlat:
67
+ "State must be flat. Nested objects or arrays are forbidden in the(). Use the.flat() to compose.",
68
+ },
69
+ schema: [],
70
+ },
71
+ create(context) {
72
+ const isObjectOrArray = (n) =>
73
+ n?.type === "ObjectExpression" || n?.type === "ArrayExpression";
74
+
75
+ const checkPrimitive = (node) => {
76
+ if (!node) return;
77
+ if (isObjectOrArray(node)) {
78
+ context.report({ node, messageId: "notFlat" });
79
+ }
80
+ };
81
+
82
+ return {
83
+ CallExpression(node) {
84
+ const name = node.callee?.name || node.callee?.property?.name;
85
+ if (name !== "the") return;
86
+
87
+ const args = node.arguments;
88
+ if (args.length === 1 && args[0].type === "ObjectExpression") {
89
+ for (const prop of args[0].properties) {
90
+ if (prop.type === "Property") checkPrimitive(prop.value);
91
+ }
92
+ } else if (args.length === 2) {
93
+ if (args[0].type === "Literal") {
94
+ checkPrimitive(args[1]);
95
+ } else if (args[1].type === "ObjectExpression") {
96
+ for (const prop of args[1].properties) {
97
+ if (prop.type === "Property") checkPrimitive(prop.value);
98
+ }
99
+ }
100
+ } else if (args.length === 3) {
101
+ checkPrimitive(args[2]);
102
+ }
103
+ },
104
+ };
105
+ },
106
+ };
107
+
108
+ const preferSubmit = {
109
+ meta: {
110
+ type: "suggestion",
111
+ docs: {
112
+ description:
113
+ "Prefer form submit events over button click listeners for data gathering.",
114
+ },
115
+ messages: {
116
+ useSubmit:
117
+ "Prefer using <form> submit events over direct button click listeners.",
118
+ },
119
+ schema: [],
120
+ },
121
+ create(context) {
122
+ const isButtonSelector = (n) =>
123
+ n?.type === "Literal" &&
124
+ typeof n.value === "string" &&
125
+ (n.value === "button" || n.value.includes("button"));
126
+
127
+ return {
128
+ CallExpression(node) {
129
+ const isOn =
130
+ node.callee?.name === "on" || node.callee?.property?.name === "on";
131
+ if (!isOn) return;
132
+ const [, evt, sel] = node.arguments;
133
+ if (evt?.value === "click" && isButtonSelector(sel)) {
134
+ context.report({ node, messageId: "useSubmit" });
135
+ }
136
+ },
137
+ };
138
+ },
139
+ };
140
+
141
+ const noStyleMutation = {
142
+ meta: {
143
+ type: "problem",
144
+ docs: {
145
+ description:
146
+ "Disallow direct style mutation; drive transitions from attribute selectors instead.",
147
+ },
148
+ messages: {
149
+ noStyle:
150
+ "Direct style manipulation is forbidden. Use the() with attribute selectors instead.",
151
+ },
152
+ schema: [],
153
+ },
154
+ create(context) {
155
+ const isStyleMember = (node) => {
156
+ let current = node;
157
+ while (current?.type === "MemberExpression") {
158
+ if (
159
+ current.object?.type === "MemberExpression" &&
160
+ current.object.property?.name === "style"
161
+ ) {
162
+ return true;
163
+ }
164
+ if (current.property?.name === "style") {
165
+ return current === node;
166
+ }
167
+ current = current.object;
168
+ }
169
+ return false;
170
+ };
171
+
172
+ return {
173
+ AssignmentExpression(node) {
174
+ if (node.left?.type !== "MemberExpression") return;
175
+ if (
176
+ node.left.object?.type === "MemberExpression" &&
177
+ node.left.object.property?.name === "style"
178
+ ) {
179
+ context.report({ node, messageId: "noStyle" });
180
+ }
181
+ },
182
+ CallExpression(node) {
183
+ const callee = node.callee;
184
+ if (callee?.property?.name === "setProperty" && isStyleMember(callee)) {
185
+ context.report({ node, messageId: "noStyle" });
186
+ }
187
+ },
188
+ };
189
+ },
190
+ };
191
+
192
+ const rules = {
193
+ "prefer-on": preferOn,
194
+ "prefer-the-set": preferTheSet,
195
+ "flat-state": flatState,
196
+ "prefer-submit": preferSubmit,
197
+ "no-style-mutation": noStyleMutation,
198
+ };
199
+
200
+ const plugin = {
201
+ meta,
202
+ rules,
203
+ };
204
+
205
+ plugin.configs = {
206
+ recommended: {
207
+ plugins: { otm: plugin },
208
+ rules: {
209
+ "otm/prefer-on": "error",
210
+ "otm/prefer-the-set": "error",
211
+ "otm/flat-state": "error",
212
+ "otm/prefer-submit": "warn",
213
+ "otm/no-style-mutation": "error",
214
+ },
215
+ },
216
+ };
217
+
218
+ export default plugin;
@@ -0,0 +1,139 @@
1
+ import * as parse5 from "parse5";
2
+
3
+ export default class Linter {
4
+ static check(file, source, availableLocales = null) {
5
+ if (!file.endsWith(".html")) return [];
6
+ const violations = [];
7
+ const document = parse5.parse(source, { sourceCodeLocationInfo: true });
8
+ Linter.#checkHtmlRules(document, violations, file, availableLocales);
9
+ return violations;
10
+ }
11
+
12
+ static #checkHtmlRules(document, violations, file, availableLocales) {
13
+ let hasI18nMeta = false;
14
+ let manifestMatches = true;
15
+ let usesI18n = false;
16
+ let isFullDocument = false;
17
+
18
+ Linter.#traverse(document, (node) => {
19
+ if (
20
+ (node.nodeName === "html" || node.nodeName === "#documentType") &&
21
+ node.sourceCodeLocation
22
+ ) {
23
+ isFullDocument = true;
24
+ }
25
+
26
+ const attrs = node.attrs
27
+ ? Object.fromEntries(node.attrs.map((a) => [a.name, a.value]))
28
+ : {};
29
+
30
+ if (node.nodeName === "meta" && attrs.name === "i18n") {
31
+ hasI18nMeta = true;
32
+ if (availableLocales) {
33
+ const manifest = (attrs["data-available"] || "")
34
+ .split(",")
35
+ .map((s) => s.trim())
36
+ .filter(Boolean);
37
+ if (
38
+ manifest.length !== availableLocales.length ||
39
+ !availableLocales.every((l) => manifest.includes(l))
40
+ ) {
41
+ manifestMatches = false;
42
+ }
43
+ }
44
+ }
45
+ if (attrs["data-i18n"]) usesI18n = true;
46
+
47
+ if (attrs["data-action"]) {
48
+ const interactiveTags = [
49
+ "button",
50
+ "a",
51
+ "input",
52
+ "select",
53
+ "textarea",
54
+ "form",
55
+ ];
56
+ const isSemantic = interactiveTags.includes(node.nodeName);
57
+ const hasA11y = attrs.role && attrs.tabindex !== undefined;
58
+
59
+ if (!isSemantic && !hasA11y) {
60
+ const loc = node.sourceCodeLocation?.attrs?.["data-action"] ||
61
+ node.sourceCodeLocation || { startLine: 1, startCol: 1 };
62
+ Linter.#addViolation(
63
+ violations,
64
+ file,
65
+ { line: loc.startLine, column: loc.startCol },
66
+ "HTML-017",
67
+ "Non-semantic interactive element. Use a <button> or add role and tabindex.",
68
+ );
69
+ }
70
+ }
71
+
72
+ if (node.nodeName === "#text") {
73
+ const parent = node.parentNode;
74
+ const parentName = parent?.nodeName;
75
+ const hasI18n = parent?.attrs?.some((a) => a.name === "data-i18n");
76
+ const leafTextParents = new Set([
77
+ "script",
78
+ "style",
79
+ "option",
80
+ "textarea",
81
+ "title",
82
+ ]);
83
+
84
+ if (
85
+ !leafTextParents.has(parentName) &&
86
+ node.value.trim() !== "" &&
87
+ !hasI18n
88
+ ) {
89
+ const loc = node.sourceCodeLocation || { startLine: 1, startCol: 1 };
90
+ Linter.#addViolation(
91
+ violations,
92
+ file,
93
+ { line: loc.startLine, column: loc.startCol },
94
+ "HTML-004",
95
+ "Naked strings in HTML are forbidden. Use data-i18n or wrap in a semantic tag.",
96
+ );
97
+ }
98
+ }
99
+ });
100
+
101
+ if (isFullDocument && usesI18n) {
102
+ if (!hasI18nMeta) {
103
+ Linter.#addViolation(
104
+ violations,
105
+ file,
106
+ { line: 1, column: 1 },
107
+ "HTML-023",
108
+ "Localization detected, but missing <meta name='i18n' ...>.",
109
+ );
110
+ } else if (!manifestMatches) {
111
+ Linter.#addViolation(
112
+ violations,
113
+ file,
114
+ { line: 1, column: 1 },
115
+ "HTML-024",
116
+ "The data-available attribute on <meta name='i18n'> does not match the locales folder.",
117
+ );
118
+ }
119
+ }
120
+ }
121
+
122
+ static #traverse(node, visitor) {
123
+ visitor(node);
124
+ const children = node.childNodes || node.content?.childNodes;
125
+ if (children) {
126
+ for (const child of children) Linter.#traverse(child, visitor);
127
+ }
128
+ }
129
+
130
+ static #addViolation(violations, file, loc, ruleId, message) {
131
+ violations.push({
132
+ file,
133
+ ruleId,
134
+ line: loc.line || loc.startLine,
135
+ column: loc.column || loc.startCol,
136
+ message,
137
+ });
138
+ }
139
+ }