@nativele/ts-dom 0.0.0-stage → 0.2.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.
- package/CHANGELOG.md +59 -0
- package/LICENSE +21 -0
- package/README.md +881 -2
- package/README.zh-TW.md +887 -0
- package/dist/a11y-DZvViTVA.mjs +649 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +112 -0
- package/dist/holes-DTBtTZF_.mjs +15 -0
- package/dist/index.d.mts +242 -0
- package/dist/index.mjs +728 -0
- package/dist/lint-B7SIRo0c.mjs +356 -0
- package/dist/oxlint-plugin.d.mts +99 -0
- package/dist/oxlint-plugin.mjs +59 -0
- package/dist/vite-plugin.d.mts +40 -0
- package/dist/vite-plugin.mjs +61 -0
- package/llms.txt +85 -0
- package/package.json +92 -4
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { a as analyze, d as listHtml, f as scanDefines } from "./a11y-DZvViTVA.mjs";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, relative } from "node:path";
|
|
4
|
+
// src/lib/lint.ts
|
|
5
|
+
const esc = (s) => s.replace(/[$]/g, "\\$&");
|
|
6
|
+
/** Marker that templateCode() appends when it sees `Component(tpl, { state | props | derive })`. */
|
|
7
|
+
const STATEFUL_MARK = "/*ts-dom:stateful*/";
|
|
8
|
+
/** Find the local name of `import X from "<spec>"`. */
|
|
9
|
+
function defaultImportName(code, spec) {
|
|
10
|
+
return new RegExp(`import\\s+([A-Za-z_$][\\w$]*)\\s+from\\s+["']${esc(spec)}["']`).exec(code)?.[1] ?? null;
|
|
11
|
+
}
|
|
12
|
+
/** From `start`, find the position that closes `open` (skipping strings and comments). */
|
|
13
|
+
function matchClose(code, start, open, close) {
|
|
14
|
+
let depth = 0;
|
|
15
|
+
for (let i = start; i < code.length; i++) {
|
|
16
|
+
const c = code[i];
|
|
17
|
+
if (c === "\"" || c === "'" || c === "`") {
|
|
18
|
+
for (i++; i < code.length && code[i] !== c; i++) if (code[i] === "\\") i++;
|
|
19
|
+
} else if (c === "/" && code[i + 1] === "/") {
|
|
20
|
+
i = code.indexOf("\n", i);
|
|
21
|
+
if (i < 0) return -1;
|
|
22
|
+
} else if (c === "/" && code[i + 1] === "*") {
|
|
23
|
+
i = code.indexOf("*/", i + 2);
|
|
24
|
+
if (i < 0) return -1;
|
|
25
|
+
i++;
|
|
26
|
+
} else if (c === open) depth++;
|
|
27
|
+
else if (c === close && --depth === 0) return i;
|
|
28
|
+
}
|
|
29
|
+
return -1;
|
|
30
|
+
}
|
|
31
|
+
/** Find every `class X extends Component(<tpl> ...)` and return its call and class body. Empty when none is found. */
|
|
32
|
+
function componentClasses(code, htmlSpec) {
|
|
33
|
+
const local = defaultImportName(code, htmlSpec);
|
|
34
|
+
if (!local) return [];
|
|
35
|
+
const re = new RegExp(`extends\\s+Component\\s*\\(\\s*${esc(local)}\\b`, "g");
|
|
36
|
+
const out = [];
|
|
37
|
+
for (let m = re.exec(code); m; m = re.exec(code)) {
|
|
38
|
+
const call = code.indexOf("(", m.index);
|
|
39
|
+
const callEnd = matchClose(code, call, "(", ")");
|
|
40
|
+
if (callEnd < 0) continue;
|
|
41
|
+
const bodyStart = code.indexOf("{", callEnd);
|
|
42
|
+
if (bodyStart < 0) continue;
|
|
43
|
+
const bodyEnd = matchClose(code, bodyStart, "{", "}");
|
|
44
|
+
if (bodyEnd > 0) out.push({
|
|
45
|
+
call: code.slice(call, callEnd + 1),
|
|
46
|
+
body: code.slice(bodyStart, bodyEnd + 1)
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
const DATA_OPTION = /\b(?:state|props|derive)\b\s*[:,}]/;
|
|
52
|
+
/**
|
|
53
|
+
* Return only the text of the classes that extend `Component(<tpl> ...)`,
|
|
54
|
+
* so that a file importing several templates checks each one against its own class only.
|
|
55
|
+
* Falls back to the whole file when no class can be found (for example when Component is wrapped in a helper).
|
|
56
|
+
*/
|
|
57
|
+
function templateCode(code, htmlSpec) {
|
|
58
|
+
const classes = componentClasses(code, htmlSpec);
|
|
59
|
+
if (classes.length) return classes.map((c) => `${DATA_OPTION.test(c.call) ? `${STATEFUL_MARK}\n` : ""}${c.body}`).join("\n");
|
|
60
|
+
return /Component\s*\([^;]*?\b(?:state|props|derive)\b\s*[:,}]/s.test(code) ? `${STATEFUL_MARK}\n${code}` : code;
|
|
61
|
+
}
|
|
62
|
+
function importsHtml(src, htmlName) {
|
|
63
|
+
return new RegExp(`from\\s+["']\\./${esc(htmlName)}\\.js["']`).test(src);
|
|
64
|
+
}
|
|
65
|
+
const UNUSED_MARKUP_RULES = {
|
|
66
|
+
"unused-ref": "data-ref is never used by the component class",
|
|
67
|
+
"unused-if": "data-if is never toggled",
|
|
68
|
+
"unused-text": "{{ hole }} is never filled",
|
|
69
|
+
"unused-list": "data-each is never rendered",
|
|
70
|
+
"unused-item-name": "a name inside a data-each template is never referenced",
|
|
71
|
+
"missing-handler": "data-on-* refers to a method the class does not define",
|
|
72
|
+
"no-importer": "no TypeScript file imports this template"
|
|
73
|
+
};
|
|
74
|
+
const at = (a, key) => a.locs.get(key) ?? {
|
|
75
|
+
line: 1,
|
|
76
|
+
col: 1
|
|
77
|
+
};
|
|
78
|
+
/** Markup that the HTML declares but `code` (the component class text) does not use. */
|
|
79
|
+
function unusedMarkup(html, code) {
|
|
80
|
+
const used = (re) => new RegExp(re).test(code);
|
|
81
|
+
const out = [];
|
|
82
|
+
const a = analyze(html);
|
|
83
|
+
const add = (rule, message, key) => out.push({
|
|
84
|
+
rule,
|
|
85
|
+
severity: "error",
|
|
86
|
+
message,
|
|
87
|
+
...at(a, key)
|
|
88
|
+
});
|
|
89
|
+
const dynamic = (obj) => used(`\\b${obj}\\[\\s*[^"'\\s\\]]`);
|
|
90
|
+
const dynRefs = dynamic("refs");
|
|
91
|
+
const dynText = dynamic("text");
|
|
92
|
+
const dynLists = dynamic("lists");
|
|
93
|
+
const stateful = code.includes(STATEFUL_MARK);
|
|
94
|
+
const eventOf = new Map(a.eventSites.map((s) => [s.method, s.event]));
|
|
95
|
+
for (const m of a.events) if (!new RegExp(`(?:^|[\\s;{}])(?:(?:public|override|async|static|readonly)\\s+)*${esc(m)}\\s*[(<=]`, "m").test(code)) add("missing-handler", `data-on-${eventOf.get(m)}="${m}" refers to a method the component class does not define. Fix: add a public method \`${m}(e: Event, hit: EventHit)\` to the class (it must be public so that define() can verify it).`, `event:${m}`);
|
|
96
|
+
for (const n of dynRefs ? [] : a.scope.refs.keys()) if (!used(`refs\\.${esc(n)}\\b|refs\\[\\s*["']${esc(n)}["']|\\b${esc(n)}\\b\\s*[,}][^;]*=\\s*this\\.refs`)) add("unused-ref", `data-ref="${n}" is never used by the component class. Fix: use this.refs.${n}, or remove the attribute if the element needs no TypeScript access.`, `ref:${n}`);
|
|
97
|
+
for (const n of stateful ? [] : a.scope.ifs) if (!used(`show\\(\\s*["']${esc(n)}["']`)) add("unused-if", `data-if="${n}" is never toggled, and data-if content is hidden by default, so it will never appear. Fix: call this.show("${n}", true) when it should be visible, or declare "${n}" in state/props/derive so it binds automatically.`, `if:${n}`);
|
|
98
|
+
for (const n of dynText || stateful ? [] : a.scope.text) if (!used(`text\\.${esc(n)}\\b|text\\[\\s*["']${esc(n)}["']`)) add("unused-text", `{{ ${n} }} is never filled and will render empty. Fix: assign this.text.${n} = value, or declare "${n}" in state/props/derive so it binds automatically.`, `text:${n}`);
|
|
99
|
+
for (const [list, s] of a.lists) {
|
|
100
|
+
if (!dynLists && !used(`lists\\.${esc(list)}\\b|lists\\[\\s*["']${esc(list)}["']`)) {
|
|
101
|
+
add("unused-list", `data-each="${list}" is never rendered, so the list stays empty. Fix: call this.lists.${list}.replace(items, (item) => item.id, (item, ctx) => { ... }), for example inside render().`, `each:${list}`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const items = [
|
|
105
|
+
...[...s.refs.keys()].map((n) => [n, `ref:${list}/${n}`]),
|
|
106
|
+
...[...s.text].map((n) => [n, `text:${list}/${n}`]),
|
|
107
|
+
...[...s.ifs].map((n) => [n, `if:${list}/${n}`])
|
|
108
|
+
];
|
|
109
|
+
for (const [n, key] of items) if (!used(`\\b${esc(n)}\\b`)) add("unused-item-name", `"${n}" inside data-each="${list}" is never referenced in the class. Fix: use it in the update callback of this.lists.${list}.replace(...) (refs.${n}, text.${n} or show("${n}", ...)), or remove it from the template.`, key);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/** Check every HTML template under `dirs` against the TypeScript files that import it. */
|
|
114
|
+
function lintUsage(dirs, tsFiles) {
|
|
115
|
+
const sources = new Map(tsFiles.map((f) => [f, readFileSync(f, "utf8")]));
|
|
116
|
+
const issues = [];
|
|
117
|
+
for (const dir of dirs) for (const html of listHtml(dir)) {
|
|
118
|
+
const name = basename(html);
|
|
119
|
+
const importers = [...sources].filter(([f, src]) => dirname(f) === dirname(html) && importsHtml(src, name));
|
|
120
|
+
const file = relative(process.cwd(), html);
|
|
121
|
+
if (!importers.length) {
|
|
122
|
+
issues.push({
|
|
123
|
+
file,
|
|
124
|
+
rule: "no-importer",
|
|
125
|
+
severity: "error",
|
|
126
|
+
line: 1,
|
|
127
|
+
col: 1,
|
|
128
|
+
message: `No TypeScript file imports this template. Fix: add \`import tpl from "./${name}.js"\` and \`class X extends Component(tpl)\` next to it, or delete the file.`
|
|
129
|
+
});
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const code = importers.map(([, s]) => templateCode(s, `./${name}.js`)).join("\n");
|
|
133
|
+
for (const d of unusedMarkup(readFileSync(html, "utf8"), code)) issues.push({
|
|
134
|
+
file,
|
|
135
|
+
...d
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return issues;
|
|
139
|
+
}
|
|
140
|
+
const DATA_FLOW_RULES = {
|
|
141
|
+
"prop-forwarding": "do not pass your own prop straight through to a child component (prop drilling)",
|
|
142
|
+
"emit-forwarding": "do not re-emit a child's event unchanged (emit chain)",
|
|
143
|
+
"props-mutation": "do not modify props inside the component",
|
|
144
|
+
"custom-event-on-native": "a custom event belongs on the child component tag that emits it, not on a native element",
|
|
145
|
+
"unknown-prop": "the attribute is not a declared prop of the child component",
|
|
146
|
+
"invalid-prop-value": "the attribute value cannot be converted to the prop's type",
|
|
147
|
+
"unknown-event": "the child component does not emit this event"
|
|
148
|
+
};
|
|
149
|
+
/** Common native events. A data-on-* on a native element whose event is not listed here is treated as a custom event. */
|
|
150
|
+
const NATIVE_EVENTS = new Set(`click dblclick auxclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave contextmenu wheel keydown keyup keypress input beforeinput change submit reset invalid select focus blur focusin focusout scroll resize load unload error abort toggle cancel close drag dragstart dragend dragenter dragleave dragover drop copy cut paste compositionstart compositionupdate compositionend touchstart touchmove touchend touchcancel pointerdown pointerup pointermove pointerover pointerout pointerenter pointerleave pointercancel gotpointercapture lostpointercapture animationstart animationend animationiteration transitionstart transitionend transitionrun transitioncancel play pause ended playing timeupdate volumechange seeking seeked loadeddata loadedmetadata canplay waiting slotchange fullscreenchange online offline hashchange popstate storage message`.split(/\s+/));
|
|
151
|
+
/** Attributes that are valid on any element and are never props. */
|
|
152
|
+
const GLOBAL_ATTRS = new Set("class id style slot title hidden lang dir tabindex role contenteditable draggable inert part exportparts is translate spellcheck autofocus accesskey nonce popover itemprop itemscope itemtype itemid".split(" "));
|
|
153
|
+
/** Entries (key and raw value text) at the top level of an object literal (braces included). */
|
|
154
|
+
function topLevelEntries(obj) {
|
|
155
|
+
const out = [];
|
|
156
|
+
let depth = 0;
|
|
157
|
+
let expectKey = false;
|
|
158
|
+
let current = null;
|
|
159
|
+
const close = (end) => {
|
|
160
|
+
if (current) out.push({
|
|
161
|
+
key: current.key,
|
|
162
|
+
value: obj.slice(current.start, end).trim()
|
|
163
|
+
});
|
|
164
|
+
current = null;
|
|
165
|
+
};
|
|
166
|
+
for (let i = 0; i < obj.length; i++) {
|
|
167
|
+
const c = obj[i];
|
|
168
|
+
if (depth === 1 && expectKey && !/\s/.test(c)) {
|
|
169
|
+
expectKey = false;
|
|
170
|
+
const m = /^(?:([A-Za-z_$][\w$]*)|"([^"]*)"|'([^']*)')\s*(?=[:,}(])/.exec(obj.slice(i));
|
|
171
|
+
if (m) {
|
|
172
|
+
const key = m[1] ?? m[2] ?? m[3] ?? "";
|
|
173
|
+
i += m[0].length - 1;
|
|
174
|
+
const rest = obj.slice(i + 1);
|
|
175
|
+
const colon = /^\s*:/.exec(rest);
|
|
176
|
+
current = {
|
|
177
|
+
key,
|
|
178
|
+
start: colon ? i + 1 + colon[0].length : i + 1
|
|
179
|
+
};
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (c === "\"" || c === "'" || c === "`") {
|
|
184
|
+
for (i++; i < obj.length && obj[i] !== c; i++) if (obj[i] === "\\") i++;
|
|
185
|
+
} else if (c === "{" || c === "[" || c === "(") {
|
|
186
|
+
depth++;
|
|
187
|
+
if (depth === 1) expectKey = true;
|
|
188
|
+
} else if (c === "}" || c === "]" || c === ")") {
|
|
189
|
+
if (depth === 1 && c === "}") close(i);
|
|
190
|
+
depth--;
|
|
191
|
+
} else if (c === "," && depth === 1) {
|
|
192
|
+
close(i);
|
|
193
|
+
expectKey = true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
/** Entries of the `name: { ... }` option inside a `Component(tpl, { ... })` call. */
|
|
199
|
+
function optionEntries(call, name) {
|
|
200
|
+
const m = new RegExp(`\\b${name}\\s*:\\s*\\{`).exec(call);
|
|
201
|
+
if (!m) return [];
|
|
202
|
+
const start = call.indexOf("{", m.index);
|
|
203
|
+
const end = matchClose(call, start, "{", "}");
|
|
204
|
+
return end > 0 ? topLevelEntries(call.slice(start, end + 1)) : [];
|
|
205
|
+
}
|
|
206
|
+
const optionKeys = (call, name) => optionEntries(call, name).map((e) => e.key);
|
|
207
|
+
/** Body text of a method (braces included), or null when the method is not found. */
|
|
208
|
+
function methodBody(body, name) {
|
|
209
|
+
const m = new RegExp(`(?:^|[\\s;{}])(?:(?:public|override|async)\\s+)*${esc(name)}\\s*\\(`, "m").exec(body);
|
|
210
|
+
if (!m) return null;
|
|
211
|
+
const close = matchClose(body, body.indexOf("(", m.index), "(", ")");
|
|
212
|
+
if (close < 0) return null;
|
|
213
|
+
const start = body.indexOf("{", close);
|
|
214
|
+
if (start < 0) return null;
|
|
215
|
+
const end = matchClose(body, start, "{", "}");
|
|
216
|
+
return end > 0 ? body.slice(start, end + 1) : null;
|
|
217
|
+
}
|
|
218
|
+
function kindOf(valueText) {
|
|
219
|
+
const v = valueText.replace(/\s+as\s[\s\S]*$/, "").trim();
|
|
220
|
+
if (/^(["'`])/.test(v)) return "string";
|
|
221
|
+
if (/^-?\d/.test(v)) return "number";
|
|
222
|
+
if (/^(true|false)$/.test(v)) return "boolean";
|
|
223
|
+
return "other";
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Read the declared props and emitted events of every component defined (via define("tag", Class)) under `dirs`.
|
|
227
|
+
* This is what lets the linter check the attributes and data-on-* written on a child tag in a parent template.
|
|
228
|
+
*/
|
|
229
|
+
function contractsFor(dirs) {
|
|
230
|
+
const out = /* @__PURE__ */ new Map();
|
|
231
|
+
for (const dir of dirs) for (const def of scanDefines(dir)) {
|
|
232
|
+
const code = readFileSync(def.file, "utf8");
|
|
233
|
+
const m = new RegExp(`class\\s+${esc(def.className)}\\s+extends\\s+Component\\s*\\(`).exec(code);
|
|
234
|
+
if (!m) continue;
|
|
235
|
+
const open = code.indexOf("(", m.index + m[0].length - 1);
|
|
236
|
+
const close = matchClose(code, open, "(", ")");
|
|
237
|
+
if (close < 0) continue;
|
|
238
|
+
const call = code.slice(open, close + 1);
|
|
239
|
+
out.set(def.tag, {
|
|
240
|
+
props: new Map(optionEntries(call, "props").map((e) => [e.key, kindOf(e.value)])),
|
|
241
|
+
emits: new Set(optionKeys(call, "emits"))
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
const kebab = (s) => s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
|
247
|
+
const camel = (s) => s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
248
|
+
function distance(a, b) {
|
|
249
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array.from({ length: b.length }, () => 0)]);
|
|
250
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
251
|
+
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
252
|
+
return d[a.length][b.length];
|
|
253
|
+
}
|
|
254
|
+
const suggest = (name, options) => {
|
|
255
|
+
const best = options.map((o) => ({
|
|
256
|
+
o,
|
|
257
|
+
d: distance(name, o)
|
|
258
|
+
})).sort((x, y) => x.d - y.d)[0];
|
|
259
|
+
return best && best.d <= 2 ? ` Did you mean "${best.o}"?` : "";
|
|
260
|
+
};
|
|
261
|
+
/**
|
|
262
|
+
* Data-flow checks (suggestions, not errors).
|
|
263
|
+
* - prop-forwarding: a child's prop is assigned straight from one of this component's own props
|
|
264
|
+
* - emit-forwarding: the handler of a child's event only emits the event again
|
|
265
|
+
* - props-mutation: a prop is assigned inside the component
|
|
266
|
+
* - custom-event-on-native: a custom event is written on a native element's data-on-*
|
|
267
|
+
* - unknown-prop / invalid-prop-value / unknown-event: the attributes and events written on a child tag do not match
|
|
268
|
+
* what the child component declares (needs `contracts`, see contractsFor)
|
|
269
|
+
*/
|
|
270
|
+
function dataFlow(html, classes, ignore = [], contracts = /* @__PURE__ */ new Map()) {
|
|
271
|
+
const out = [];
|
|
272
|
+
const skip = new Set(ignore);
|
|
273
|
+
const a = analyze(html);
|
|
274
|
+
const report = (rule, message, key, loc) => {
|
|
275
|
+
if (!skip.has(rule)) out.push({
|
|
276
|
+
rule,
|
|
277
|
+
severity: "warning",
|
|
278
|
+
message,
|
|
279
|
+
...loc ?? (key ? at(a, key) : {
|
|
280
|
+
line: 1,
|
|
281
|
+
col: 1
|
|
282
|
+
})
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
for (const site of a.eventSites) if (!site.tag.includes("-") && !NATIVE_EVENTS.has(site.event)) report("custom-event-on-native", `data-on-${site.event} is on the native element <${site.tag}>, but "${site.event}" is not a native event. Events emitted by a child component do not bubble by default, so only the child's own tag receives them. Fix: put data-on-${site.event} on the child component tag that emits it.`, `event:${site.method}`);
|
|
286
|
+
for (const use of a.customUses) {
|
|
287
|
+
const contract = contracts.get(use.tag);
|
|
288
|
+
if (!contract) continue;
|
|
289
|
+
const propNames = [...contract.props.keys()];
|
|
290
|
+
for (const attrName of use.attrs) {
|
|
291
|
+
if (attrName.startsWith("data-") || attrName.startsWith("aria-") || attrName.startsWith("on") || GLOBAL_ATTRS.has(attrName)) continue;
|
|
292
|
+
const prop = camel(attrName);
|
|
293
|
+
const kind = contract.props.get(prop);
|
|
294
|
+
if (kind === void 0) {
|
|
295
|
+
const declared = propNames.length ? `Declared props: ${propNames.map((p) => kebab(p)).join(", ")}.` : "It declares no props.";
|
|
296
|
+
report("unknown-prop", `<${use.tag} ${attrName}> is not a declared prop of <${use.tag}>. ${declared}${suggest(prop, propNames)} Fix: declare "${prop}" in the child's props option, or remove the attribute.`, void 0, use);
|
|
297
|
+
} else if (kind === "other") report("invalid-prop-value", `The prop "${prop}" of <${use.tag}> is not a string, number or boolean, so it cannot be set from the HTML attribute ${attrName}. Fix: assign it from TypeScript, e.g. this.refs.x.${prop} = value.`, void 0, use);
|
|
298
|
+
}
|
|
299
|
+
for (const ev of use.events) {
|
|
300
|
+
if (contract.emits.has(ev) || NATIVE_EVENTS.has(ev)) continue;
|
|
301
|
+
const emitted = [...contract.emits];
|
|
302
|
+
report("unknown-event", `<${use.tag}> does not emit "${ev}", so data-on-${ev} will never fire. ${emitted.length ? `It emits: ${emitted.join(", ")}.` : "It declares no events."}${suggest(ev, emitted)} Fix: declare the event in the child's emits option (emits: { "${ev}": event<...>() }), or fix the name.`, void 0, use);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const customRefs = /* @__PURE__ */ new Map();
|
|
306
|
+
for (const refs of [a.scope.refs, ...[...a.lists.values()].map((l) => l.refs)]) for (const [name, type] of refs) {
|
|
307
|
+
const tag = /El<"([^"]+)">/.exec(type)?.[1];
|
|
308
|
+
if (tag?.includes("-")) customRefs.set(name, tag);
|
|
309
|
+
}
|
|
310
|
+
for (const { call, body } of classes) {
|
|
311
|
+
const props = optionKeys(call, "props");
|
|
312
|
+
for (const k of props) if (new RegExp(`\\bthis\\.${esc(k)}\\s*(?:[-+*/%]?=(?!=)|\\+\\+|--)`).test(body)) report("props-mutation", `The prop "${k}" is controlled by the parent component; do not assign this.${k} inside the component. Fix: emit an event so the parent changes it, or keep the value in state instead.`);
|
|
313
|
+
const view = /\brender\s*\(\s*([A-Za-z_$][\w$]*)/.exec(body)?.[1];
|
|
314
|
+
for (const m of body.matchAll(/\brefs\.(\w+)\.(\w+)\s*=\s*([^;\n]+)/g)) {
|
|
315
|
+
const [, ref, target, rhsRaw] = m;
|
|
316
|
+
const tag = customRefs.get(ref);
|
|
317
|
+
if (!tag) continue;
|
|
318
|
+
const rhs = rhsRaw.trim().replace(/,$/, "");
|
|
319
|
+
const prop = /^this\.props\.(\w+)$/.exec(rhs)?.[1] ?? /^this\.(\w+)$/.exec(rhs)?.[1] ?? (view ? new RegExp(`^${esc(view)}\\.(\\w+)$`).exec(rhs)?.[1] : void 0);
|
|
320
|
+
if (prop && props.includes(prop)) report("prop-forwarding", `The prop "${prop}" is passed unchanged to <${tag}> (${ref}.${target}). Passing data through intermediate components makes the data flow hard to follow. Fix: for data needed several levels down, share a createStore from a module and bind to it where it is used (this.bind(store, fn)); keep passing props only one level.`, `ref:${ref}`);
|
|
321
|
+
}
|
|
322
|
+
for (const site of a.eventSites) {
|
|
323
|
+
if (!site.tag.includes("-")) continue;
|
|
324
|
+
const fn = methodBody(body, site.method);
|
|
325
|
+
if (!fn) continue;
|
|
326
|
+
const inner = fn.slice(1, -1).trim();
|
|
327
|
+
const forwardsSame = new RegExp(`this\\.emit\\(\\s*["']${esc(site.event)}["']`).test(fn);
|
|
328
|
+
const pureRelay = /^this\.emit\([\s\S]*\);?$/.test(inner) && (inner.match(/this\.emit\(/g) ?? []).length === 1 && inner.split(";").filter((x) => x.trim()).length === 1;
|
|
329
|
+
if (forwardsSame || pureRelay) report("emit-forwarding", `${site.method}() only forwards the "${site.event}" event of <${site.tag}>. Re-emitting events level by level hides where they are handled. Fix: handle the event in this component, or let the component that needs it subscribe to a shared store instead.`, `event:${site.method}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
/** Run the data-flow checks on every template under `dirs` against the TypeScript files that import it. */
|
|
335
|
+
function lintDataFlow(dirs, tsFiles) {
|
|
336
|
+
const sources = new Map(tsFiles.map((f) => [f, readFileSync(f, "utf8")]));
|
|
337
|
+
const contracts = contractsFor(dirs);
|
|
338
|
+
const issues = [];
|
|
339
|
+
for (const dir of dirs) for (const html of listHtml(dir)) {
|
|
340
|
+
const name = basename(html);
|
|
341
|
+
const spec = `./${name}.js`;
|
|
342
|
+
const file = relative(process.cwd(), html);
|
|
343
|
+
let classes = [];
|
|
344
|
+
for (const [f, src] of sources) if (dirname(f) === dirname(html) && importsHtml(src, name)) classes = classes.concat(componentClasses(src, spec));
|
|
345
|
+
for (const d of dataFlow(readFileSync(html, "utf8"), classes, [], contracts)) issues.push({
|
|
346
|
+
file,
|
|
347
|
+
...d
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return issues;
|
|
351
|
+
}
|
|
352
|
+
/** `file:line:col severity [rule] message` for a lint issue. */
|
|
353
|
+
function formatIssue(i) {
|
|
354
|
+
return `${i.file}:${i.line}:${i.col} ${i.severity} [${i.rule}] ${i.message}`;
|
|
355
|
+
}
|
|
356
|
+
export { dataFlow as a, lintUsage as c, contractsFor as i, templateCode as l, UNUSED_MARKUP_RULES as n, formatIssue as o, componentClasses as r, lintDataFlow as s, DATA_FLOW_RULES as t, unusedMarkup as u };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
interface ImportNode {
|
|
2
|
+
type: "ImportDeclaration";
|
|
3
|
+
source: {
|
|
4
|
+
value: string;
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
interface Context {
|
|
8
|
+
options?: [{
|
|
9
|
+
ignore?: string[];
|
|
10
|
+
}?];
|
|
11
|
+
filename: string;
|
|
12
|
+
sourceCode: {
|
|
13
|
+
text: string;
|
|
14
|
+
};
|
|
15
|
+
report(d: {
|
|
16
|
+
message: string;
|
|
17
|
+
node: unknown;
|
|
18
|
+
}): void;
|
|
19
|
+
}
|
|
20
|
+
declare const _default: {
|
|
21
|
+
meta: {
|
|
22
|
+
name: string;
|
|
23
|
+
};
|
|
24
|
+
rules: {
|
|
25
|
+
/** The HTML declares markup that this file never uses. Always on when enabled; reported as errors. */
|
|
26
|
+
"unused-markup": {
|
|
27
|
+
meta: {
|
|
28
|
+
type: "suggestion";
|
|
29
|
+
docs: {
|
|
30
|
+
description: string;
|
|
31
|
+
};
|
|
32
|
+
schema: {
|
|
33
|
+
type: string;
|
|
34
|
+
properties: {
|
|
35
|
+
ignore: {
|
|
36
|
+
type: string;
|
|
37
|
+
items: {
|
|
38
|
+
type: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
additionalProperties: boolean;
|
|
43
|
+
}[];
|
|
44
|
+
};
|
|
45
|
+
create(context: Context): {
|
|
46
|
+
ImportDeclaration(node: ImportNode): void;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
/** ARIA and basic accessibility problems in the template. Off by default. */
|
|
50
|
+
a11y: {
|
|
51
|
+
meta: {
|
|
52
|
+
type: "suggestion";
|
|
53
|
+
docs: {
|
|
54
|
+
description: string;
|
|
55
|
+
};
|
|
56
|
+
schema: {
|
|
57
|
+
type: string;
|
|
58
|
+
properties: {
|
|
59
|
+
ignore: {
|
|
60
|
+
type: string;
|
|
61
|
+
items: {
|
|
62
|
+
type: string;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
additionalProperties: boolean;
|
|
67
|
+
}[];
|
|
68
|
+
};
|
|
69
|
+
create(context: Context): {
|
|
70
|
+
ImportDeclaration(node: ImportNode): void;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
/** Data flow suggestions and child component contract checks. Off by default. */
|
|
74
|
+
"data-flow": {
|
|
75
|
+
meta: {
|
|
76
|
+
type: "suggestion";
|
|
77
|
+
docs: {
|
|
78
|
+
description: string;
|
|
79
|
+
};
|
|
80
|
+
schema: {
|
|
81
|
+
type: string;
|
|
82
|
+
properties: {
|
|
83
|
+
ignore: {
|
|
84
|
+
type: string;
|
|
85
|
+
items: {
|
|
86
|
+
type: string;
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
additionalProperties: boolean;
|
|
91
|
+
}[];
|
|
92
|
+
};
|
|
93
|
+
create(context: Context): {
|
|
94
|
+
ImportDeclaration(node: ImportNode): void;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
export { _default as default };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { n as checkA11y } from "./a11y-DZvViTVA.mjs";
|
|
2
|
+
import { a as dataFlow, i as contractsFor, l as templateCode, r as componentClasses, u as unusedMarkup } from "./lint-B7SIRo0c.mjs";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
// src/lib/oxlint-plugin.ts
|
|
6
|
+
const ignoreSchema = [{
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: { ignore: {
|
|
9
|
+
type: "array",
|
|
10
|
+
items: { type: "string" }
|
|
11
|
+
} },
|
|
12
|
+
additionalProperties: false
|
|
13
|
+
}];
|
|
14
|
+
/** Child component contracts, scanned once per process from the working directory. */
|
|
15
|
+
let contracts = null;
|
|
16
|
+
const getContracts = () => contracts ??= contractsFor([process.cwd()]);
|
|
17
|
+
/**
|
|
18
|
+
* Build an oxlint rule that runs `check` on every `import x from "./name.html.js"` and reports the findings
|
|
19
|
+
* on the import line (an oxlint plugin can only report inside the file being linted).
|
|
20
|
+
* The message starts with the HTML location, the rule id and what to do: `./x.html.js:3:5 [unused-ref] ...`.
|
|
21
|
+
*/
|
|
22
|
+
function templateRule(description, check) {
|
|
23
|
+
return {
|
|
24
|
+
meta: {
|
|
25
|
+
type: "suggestion",
|
|
26
|
+
docs: { description },
|
|
27
|
+
schema: ignoreSchema
|
|
28
|
+
},
|
|
29
|
+
create(context) {
|
|
30
|
+
return { ImportDeclaration(node) {
|
|
31
|
+
const spec = node.source.value;
|
|
32
|
+
if (!spec.endsWith(".html.js")) return;
|
|
33
|
+
let html;
|
|
34
|
+
try {
|
|
35
|
+
html = readFileSync(resolve(dirname(context.filename), spec.slice(0, -3)), "utf8");
|
|
36
|
+
} catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const d of check(html, context, spec)) context.report({
|
|
40
|
+
message: `${spec}:${d.line}:${d.col} [${d.rule}] ${d.message}`,
|
|
41
|
+
node
|
|
42
|
+
});
|
|
43
|
+
} };
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const ignored = (c) => c.options?.[0]?.ignore ?? [];
|
|
48
|
+
var oxlint_plugin_default = {
|
|
49
|
+
meta: { name: "ts-dom" },
|
|
50
|
+
rules: {
|
|
51
|
+
/** The HTML declares markup that this file never uses. Always on when enabled; reported as errors. */
|
|
52
|
+
"unused-markup": templateRule("Markup declared in the HTML (data-ref, data-if, data-each, {{ }}, data-on-*) must be used by the component class", (html, c, spec) => unusedMarkup(html, templateCode(c.sourceCode.text, spec)).filter((d) => !ignored(c).includes(d.rule))),
|
|
53
|
+
/** ARIA and basic accessibility problems in the template. Off by default. */
|
|
54
|
+
a11y: templateRule("Check the template for ARIA and basic accessibility problems", (html, c) => checkA11y(html, ignored(c))),
|
|
55
|
+
/** Data flow suggestions and child component contract checks. Off by default. */
|
|
56
|
+
"data-flow": templateRule("Keep props flowing down and events flowing up one level at a time, and check child component attributes and events", (html, c, spec) => dataFlow(html, componentClasses(c.sourceCode.text, spec), ignored(c), getContracts()))
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
export { oxlint_plugin_default as default };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface TsDomPluginOptions {
|
|
2
|
+
/** Directories to scan for .html files and generate .html.ts into. Default: ["src"] */
|
|
3
|
+
dirs?: string[];
|
|
4
|
+
/** Module name the generated files import the runtime from. Default: "@nativele/ts-dom" */
|
|
5
|
+
runtime?: string;
|
|
6
|
+
/** How text nodes keep their whitespace: "preserve" (default) or "collapse" (see `GenerateOptions`). */
|
|
7
|
+
whitespace?: "preserve" | "collapse";
|
|
8
|
+
/** Glob patterns (relative to each directory) of the templates to generate. Default: all. */
|
|
9
|
+
include?: string[];
|
|
10
|
+
/** Glob patterns (relative to each directory) of templates to leave out. */
|
|
11
|
+
exclude?: string[];
|
|
12
|
+
/** Show ARIA / accessibility warnings in the terminal during builds. true enables every check; an object can list rule ids to ignore. Default: off. */
|
|
13
|
+
a11y?: boolean | {
|
|
14
|
+
ignore?: string[];
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Optional Vite plugin: generates `.html.ts` when the build starts and regenerates it while the dev server runs.
|
|
19
|
+
* ts-dom does not depend on it: `ts-dom gen` (or `--watch`) together with tsc is enough.
|
|
20
|
+
*/
|
|
21
|
+
export declare function tsDom(opts?: TsDomPluginOptions): {
|
|
22
|
+
name: string;
|
|
23
|
+
enforce: "pre";
|
|
24
|
+
buildStart(this: {
|
|
25
|
+
error(m: string): never;
|
|
26
|
+
warn(m: string): void;
|
|
27
|
+
}): void;
|
|
28
|
+
configureServer(server: {
|
|
29
|
+
watcher: {
|
|
30
|
+
add(p: string | string[]): void;
|
|
31
|
+
on(ev: string, fn: (f: string) => void): void;
|
|
32
|
+
};
|
|
33
|
+
config: {
|
|
34
|
+
logger: {
|
|
35
|
+
error(m: string): void;
|
|
36
|
+
warn(m: string): void;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
}): void;
|
|
40
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { c as generateFile, d as listHtml, i as CodegenError, l as isFullPage, n as checkA11y, o as formatDiagnostics, s as generateAll, u as isSelected } from "./a11y-DZvViTVA.mjs";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { resolve, sep } from "node:path";
|
|
4
|
+
// src/lib/vite-plugin.ts
|
|
5
|
+
/**
|
|
6
|
+
* Optional Vite plugin: generates `.html.ts` when the build starts and regenerates it while the dev server runs.
|
|
7
|
+
* ts-dom does not depend on it: `ts-dom gen` (or `--watch`) together with tsc is enough.
|
|
8
|
+
*/
|
|
9
|
+
function tsDom(opts = {}) {
|
|
10
|
+
const generate = {
|
|
11
|
+
runtime: opts.runtime,
|
|
12
|
+
whitespace: opts.whitespace,
|
|
13
|
+
include: opts.include,
|
|
14
|
+
exclude: opts.exclude
|
|
15
|
+
};
|
|
16
|
+
const dirs = opts.dirs ?? ["src"];
|
|
17
|
+
const a11y = opts.a11y === true ? {} : opts.a11y || null;
|
|
18
|
+
const warnA11y = (warn) => {
|
|
19
|
+
if (!a11y) return;
|
|
20
|
+
for (const d of dirs) {
|
|
21
|
+
if (!existsSync(d)) continue;
|
|
22
|
+
for (const f of listHtml(d)) {
|
|
23
|
+
if (!isSelected(d, f, generate)) continue;
|
|
24
|
+
for (const diag of checkA11y(readFileSync(f, "utf8"), a11y.ignore)) warn(formatDiagnostics(f, [diag]));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
name: "ts-dom",
|
|
30
|
+
enforce: "pre",
|
|
31
|
+
buildStart() {
|
|
32
|
+
try {
|
|
33
|
+
for (const d of dirs) if (existsSync(d)) generateAll(d, generate);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if (e instanceof CodegenError) this.error(e.message);
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
warnA11y((m) => this.warn(m));
|
|
39
|
+
},
|
|
40
|
+
configureServer(server) {
|
|
41
|
+
const regen = (file) => {
|
|
42
|
+
if (!file.endsWith(".html") || file.includes("node_modules") || !existsSync(file)) return;
|
|
43
|
+
const html = readFileSync(file, "utf8");
|
|
44
|
+
if (isFullPage(html)) return;
|
|
45
|
+
const dir = dirs.find((d) => file.startsWith(resolve(d) + sep));
|
|
46
|
+
if (!dir || !isSelected(resolve(dir), file, generate)) return;
|
|
47
|
+
try {
|
|
48
|
+
generateFile(file, generate, dir);
|
|
49
|
+
if (a11y) for (const diag of checkA11y(html, a11y.ignore)) server.config.logger.warn(formatDiagnostics(file, [diag]));
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (e instanceof CodegenError) server.config.logger.error(e.message);
|
|
52
|
+
else throw e;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
server.watcher.add(dirs);
|
|
56
|
+
server.watcher.on("change", regen);
|
|
57
|
+
server.watcher.on("add", regen);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export { tsDom };
|