@nativele/ts-dom 0.4.0 → 0.5.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 +84 -0
- package/README.md +95 -811
- package/README.zh-TW.md +95 -816
- package/dist/{a11y-Bn9pDvWV.mjs → a11y-wWL9rie7.mjs} +123 -21
- package/dist/cli.mjs +1326 -22
- package/dist/index.d.mts +31 -13
- package/dist/index.mjs +12 -4
- package/dist/{lint-CqWk4U5P.mjs → lint-5vo_ifIc.mjs} +19 -3
- package/dist/oxlint-plugin.d.mts +12 -1
- package/dist/oxlint-plugin.mjs +59 -19
- package/dist/vite-plugin.mjs +1 -1
- package/llms.txt +21 -8
- package/package.json +6 -3
- package/skills/README.md +114 -0
- package/skills/_template/SKILL.md +57 -0
- package/skills/nativele-core/SKILL.md +92 -0
- package/skills/nativele-react/SKILL.md +197 -0
package/dist/cli.mjs
CHANGED
|
@@ -1,74 +1,1355 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { d as listHtml, i as CodegenError, n as checkA11y,
|
|
3
|
-
import { c as lintUsage, n as UNUSED_MARKUP_RULES, o as formatIssue, s as lintDataFlow, t as DATA_FLOW_RULES } from "./lint-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { _ as watchAll, a as analyze, c as generateAll, d as isSelected, f as listHtml, g as staleGenerated, i as CodegenError, m as renderTags, n as checkA11y, o as checkGenerated, p as listTs, r as CODEGEN_RULES, t as A11Y_RULES } from "./a11y-wWL9rie7.mjs";
|
|
3
|
+
import { c as lintUsage, n as UNUSED_MARKUP_RULES, o as formatIssue, s as lintDataFlow, t as DATA_FLOW_RULES } from "./lint-5vo_ifIc.mjs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { cpSync, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
6
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
// src/lib/tsconfig.ts
|
|
10
|
+
/** tsconfig is JSONC: drop comments and trailing commas before parsing. */
|
|
11
|
+
function parseJsonc(text) {
|
|
12
|
+
const noComments = text.replace(/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, str) => str ?? "");
|
|
13
|
+
return JSON.parse(noComments.replace(/,(\s*[}\]])/g, "$1"));
|
|
14
|
+
}
|
|
15
|
+
function resolveExtends(from, ext) {
|
|
16
|
+
const dir = dirname(from);
|
|
17
|
+
if (ext.startsWith(".")) {
|
|
18
|
+
const target = resolve(dir, ext);
|
|
19
|
+
return existsSync(target) ? target : existsSync(`${target}.json`) ? `${target}.json` : null;
|
|
20
|
+
}
|
|
21
|
+
const req = createRequire(from);
|
|
22
|
+
for (const spec of [
|
|
23
|
+
ext,
|
|
24
|
+
`${ext}.json`,
|
|
25
|
+
`${ext}/tsconfig.json`
|
|
26
|
+
]) try {
|
|
27
|
+
return req.resolve(spec);
|
|
28
|
+
} catch {}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
/** Read a tsconfig and the ones it extends (a relative path, a package, or an array of them), the later and nearer one winning. */
|
|
32
|
+
function readTsConfig(file, depth = 0) {
|
|
33
|
+
const json = parseJsonc(readFileSync(file, "utf8"));
|
|
34
|
+
let merged = { compilerOptions: {} };
|
|
35
|
+
const exts = typeof json.extends === "string" ? [json.extends] : Array.isArray(json.extends) ? json.extends : [];
|
|
36
|
+
for (const ext of depth < 5 ? exts : []) {
|
|
37
|
+
const target = resolveExtends(file, ext);
|
|
38
|
+
if (!target) continue;
|
|
39
|
+
const base = readTsConfig(target, depth + 1);
|
|
40
|
+
merged = {
|
|
41
|
+
compilerOptions: {
|
|
42
|
+
...merged.compilerOptions,
|
|
43
|
+
...base.compilerOptions
|
|
44
|
+
},
|
|
45
|
+
...base.include ?? merged.include ? { include: base.include ?? merged.include } : {}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const own = json.include;
|
|
49
|
+
const include = own ? own.map((p) => resolve(dirname(file), p).split("\\").join("/")) : merged.include;
|
|
50
|
+
return {
|
|
51
|
+
compilerOptions: {
|
|
52
|
+
...merged.compilerOptions,
|
|
53
|
+
...json.compilerOptions
|
|
54
|
+
},
|
|
55
|
+
...include ? { include } : {}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The tsconfig files tsc would check for a project: `project`, or tsconfig.json in `cwd`.
|
|
60
|
+
* A solution-style config (`"files": []` with `references`) checks nothing itself, so its referenced projects are returned instead.
|
|
61
|
+
*/
|
|
62
|
+
function projectConfigs(cwd, project) {
|
|
63
|
+
const root = resolve(cwd, project ?? "tsconfig.json");
|
|
64
|
+
if (!existsSync(root)) return [];
|
|
65
|
+
const json = parseJsonc(readFileSync(root, "utf8"));
|
|
66
|
+
const refs = Array.isArray(json.references) ? json.references.map((r) => r.path).filter((p) => typeof p === "string") : [];
|
|
67
|
+
if (!(refs.length > 0 && Array.isArray(json.files) && json.files.length === 0 && json.include === void 0)) return [root];
|
|
68
|
+
return refs.map((ref) => {
|
|
69
|
+
const abs = resolve(dirname(root), ref);
|
|
70
|
+
return existsSync(abs) && statSync(abs).isDirectory() ? join(abs, "tsconfig.json") : abs;
|
|
71
|
+
}).filter((f) => existsSync(f));
|
|
72
|
+
}
|
|
73
|
+
// src/lib/check.ts
|
|
74
|
+
const CHECK_RULES = {
|
|
75
|
+
"stale-generated": "a generated file is missing, outdated or orphaned, so the type errors may not match the templates",
|
|
76
|
+
"missing-binding": "a {{ hole }} or data-if names nothing in state, props or derive (found by tsc)",
|
|
77
|
+
"conflicting-keys": "the same name is declared in more than one of state, props and derive (found by tsc)",
|
|
78
|
+
"reserved-prop": "a prop is named like a member of HTMLElement or ts-dom (found by tsc)",
|
|
79
|
+
"native-event-name": "an emitted event is named like a native DOM event (found by tsc)",
|
|
80
|
+
"item-prop-missing": "the data-prop of a list is not a prop of its item component (found by tsc)",
|
|
81
|
+
"item-not-in-tag-map": "the item component is defined in a scanned folder but missing from ts-dom-tags.ts (found by tsc)",
|
|
82
|
+
"item-class-not-exported": "the item component class passed to define() is not exported (found by tsc)",
|
|
83
|
+
tsc: "any other TypeScript error, as tsc reports it",
|
|
84
|
+
"tsc-not-found": "TypeScript is not installed in the project, so types were not checked",
|
|
85
|
+
"dir-not-found": "a folder given on the command line does not exist, so nothing in it was checked",
|
|
86
|
+
"tsc-failed": "tsc did not run to completion (no tsconfig, a bad option, a crash), so types were not checked"
|
|
87
|
+
};
|
|
88
|
+
/** Parse `tsc --pretty false` output; indented lines continue the previous message. */
|
|
89
|
+
function parseTscOutput(out) {
|
|
90
|
+
const errors = [];
|
|
91
|
+
for (const line of out.split(/\r?\n/)) {
|
|
92
|
+
const m = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
|
|
93
|
+
if (m) errors.push({
|
|
94
|
+
file: m[1],
|
|
95
|
+
line: Number(m[2]),
|
|
96
|
+
col: Number(m[3]),
|
|
97
|
+
code: m[4],
|
|
98
|
+
message: m[5]
|
|
99
|
+
});
|
|
100
|
+
else if (/^\s+\S/.test(line) && errors.length) errors.at(-1).message += `\n${line.trim()}`;
|
|
101
|
+
}
|
|
102
|
+
return errors;
|
|
103
|
+
}
|
|
104
|
+
const CONTRACT = [
|
|
105
|
+
[
|
|
106
|
+
"conflictingKeys",
|
|
107
|
+
"conflicting-keys",
|
|
108
|
+
"keep each name in only one of state, props and derive"
|
|
109
|
+
],
|
|
110
|
+
[
|
|
111
|
+
"reservedPropNames",
|
|
112
|
+
"reserved-prop",
|
|
113
|
+
"rename the prop (for example title -> titleText)"
|
|
114
|
+
],
|
|
115
|
+
[
|
|
116
|
+
"nativeEventNames",
|
|
117
|
+
"native-event-name",
|
|
118
|
+
"rename the event, for example with a prefix such as item-"
|
|
119
|
+
],
|
|
120
|
+
[
|
|
121
|
+
"dataPropNotOnItemComponent",
|
|
122
|
+
"item-prop-missing",
|
|
123
|
+
"declare the property in the item component's props, or change data-prop"
|
|
124
|
+
],
|
|
125
|
+
[
|
|
126
|
+
"itemComponentMissingFromTagMap",
|
|
127
|
+
"item-not-in-tag-map",
|
|
128
|
+
"run ts-dom gen so ts-dom-tags.ts lists the item component"
|
|
129
|
+
],
|
|
130
|
+
[
|
|
131
|
+
"itemComponentClassNotExported",
|
|
132
|
+
"item-class-not-exported",
|
|
133
|
+
"export the class passed to define() so its prop types can be read"
|
|
134
|
+
]
|
|
135
|
+
];
|
|
136
|
+
const namesAfter = (message, key) => {
|
|
137
|
+
const m = new RegExp(`${key}: ((?:"[^"]+"(?:\\s*\\|\\s*)?)+)`).exec(message);
|
|
138
|
+
return m ? [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) : [];
|
|
139
|
+
};
|
|
140
|
+
const templatesOf = (tsFile) => {
|
|
141
|
+
if (!existsSync(tsFile)) return [];
|
|
142
|
+
return [...readFileSync(tsFile, "utf8").matchAll(/from\s+["'](\.{1,2}\/[^"']+\.html)\.js["']/g)].map((m) => resolve(dirname(tsFile), m[1])).filter((f) => existsSync(f));
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Turn the tsc errors that come from the template contract into issues that point at the template, or at the line to change, with a Fix hint.
|
|
146
|
+
* Other errors are kept as `tsc` issues.
|
|
147
|
+
*/
|
|
148
|
+
function mapTscErrors(errors, cwd) {
|
|
149
|
+
const rel = (f) => relative(cwd, f);
|
|
150
|
+
const out = [];
|
|
151
|
+
const analyses = /* @__PURE__ */ new Map();
|
|
152
|
+
const analysisOf = (html) => {
|
|
153
|
+
let a = analyses.get(html);
|
|
154
|
+
if (!a) analyses.set(html, a = analyze(readFileSync(html, "utf8")));
|
|
155
|
+
return a;
|
|
156
|
+
};
|
|
157
|
+
const locate = (tsFile, keys) => {
|
|
158
|
+
for (const html of templatesOf(tsFile)) for (const key of keys) {
|
|
159
|
+
const loc = analysisOf(html).locs.get(key);
|
|
160
|
+
if (loc) return {
|
|
161
|
+
file: html,
|
|
162
|
+
...loc
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const brokenClasses = /* @__PURE__ */ new Set();
|
|
167
|
+
for (const e of errors) {
|
|
168
|
+
if (!/missingStateKeys|conflictingKeys|reservedPropNames|nativeEventNames/.test(e.message)) continue;
|
|
169
|
+
const line = readFileSync(resolve(cwd, e.file), "utf8").split("\n")[e.line - 1] ?? "";
|
|
170
|
+
const name = /class\s+([A-Za-z_$][\w$]*)\s+extends\s+Component\s*\(/.exec(line)?.[1];
|
|
171
|
+
if (name) brokenClasses.add(name);
|
|
172
|
+
}
|
|
173
|
+
const followsBroken = (message) => [...brokenClasses].some((c) => message.includes(`'${c}'`) || message.includes(`'typeof ${c}'`));
|
|
174
|
+
for (const e of errors) {
|
|
175
|
+
const file = resolve(cwd, e.file);
|
|
176
|
+
const where = `${rel(file)}:${e.line}:${e.col}`;
|
|
177
|
+
const issue = (rule, message, at) => out.push({
|
|
178
|
+
...at ?? {
|
|
179
|
+
file: rel(file),
|
|
180
|
+
line: e.line,
|
|
181
|
+
col: e.col
|
|
182
|
+
},
|
|
183
|
+
rule,
|
|
184
|
+
severity: "error",
|
|
185
|
+
message
|
|
186
|
+
});
|
|
187
|
+
let named = false;
|
|
188
|
+
for (const name of namesAfter(e.message, "missingStateKeys")) {
|
|
189
|
+
named = true;
|
|
190
|
+
const found = locate(file, [`text:${name}`, `if:${name}`]);
|
|
191
|
+
issue("missing-binding", `"${name}" is used by {{ ${name} }} or data-if="${name}" but the component declares no state, prop or derived value of that name (tsc at ${where}). Fix: add "${name}" to state, props or derive, or remove it from the template.`, found && {
|
|
192
|
+
file: rel(found.file),
|
|
193
|
+
line: found.line,
|
|
194
|
+
col: found.col
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
for (const name of namesAfter(e.message, "missingHandlers")) {
|
|
198
|
+
named = true;
|
|
199
|
+
const found = locate(file, [`event:${name}`]);
|
|
200
|
+
issue("missing-handler", `data-on-*="${name}" refers to a method the component class does not define (tsc at ${where}). Fix: add a public method \`${name}(e: Event, hit: EventHit)\` to the class.`, found && {
|
|
201
|
+
file: rel(found.file),
|
|
202
|
+
line: found.line,
|
|
203
|
+
col: found.col
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
for (const [key, rule, fix] of CONTRACT) {
|
|
207
|
+
if (!e.message.includes(key)) continue;
|
|
208
|
+
named = true;
|
|
209
|
+
issue(rule, `${e.message.split("\n")[0]} Fix: ${fix}.`);
|
|
210
|
+
}
|
|
211
|
+
if (!named) issue("tsc", `${e.code}: ${e.message} Fix: change the TypeScript at this location so the types agree; ts-dom rules --explain=tsc.`);
|
|
212
|
+
}
|
|
213
|
+
return out.filter((i) => i.rule !== "tsc" || !followsBroken(i.message));
|
|
214
|
+
}
|
|
215
|
+
function findTsc(cwd) {
|
|
216
|
+
try {
|
|
217
|
+
const pkgPath = createRequire(join(cwd, "package.json")).resolve("typescript/package.json");
|
|
218
|
+
const bin = JSON.parse(readFileSync(pkgPath, "utf8")).bin?.tsc;
|
|
219
|
+
return bin ? resolve(dirname(pkgPath), bin) : null;
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Everything an agent needs in one pass: template diagnostics, generated files, TypeScript errors mapped back to the templates, and the lint rules.
|
|
226
|
+
* Returns the issues sorted by file and position.
|
|
227
|
+
*/
|
|
228
|
+
function runCheck(options) {
|
|
229
|
+
const cwd = options.cwd ?? process.cwd();
|
|
230
|
+
const generate = options.generate ?? {};
|
|
231
|
+
const issues = [];
|
|
232
|
+
const rel = (f) => relative(cwd, f);
|
|
233
|
+
const dirs = [];
|
|
234
|
+
for (const d of options.dirs.map((x) => resolve(cwd, x))) if (existsSync(d)) dirs.push(d);
|
|
235
|
+
else issues.push({
|
|
236
|
+
file: rel(d) || ".",
|
|
237
|
+
line: 1,
|
|
238
|
+
col: 1,
|
|
239
|
+
rule: "dir-not-found",
|
|
240
|
+
severity: "error",
|
|
241
|
+
message: `The folder ${rel(d) || "."} does not exist, so nothing in it was checked. Fix: pass the folder that holds the templates and components (default: src).`
|
|
242
|
+
});
|
|
243
|
+
for (const dir of dirs) for (const f of listHtml(dir)) {
|
|
244
|
+
if (!isSelected(dir, f, generate)) continue;
|
|
245
|
+
for (const d of analyze(readFileSync(f, "utf8")).diagnostics) issues.push({
|
|
246
|
+
file: rel(f),
|
|
247
|
+
...d
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
for (const dir of dirs) try {
|
|
251
|
+
renderTags(dir);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
if (!(e instanceof CodegenError)) throw e;
|
|
254
|
+
issues.push({
|
|
255
|
+
file: rel(dir),
|
|
256
|
+
line: 1,
|
|
257
|
+
col: 1,
|
|
258
|
+
rule: "duplicate-define",
|
|
259
|
+
severity: "error",
|
|
260
|
+
message: e.message.replace(/^[^[]*\[duplicate-define\]\s*/, "")
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
if (options.write === false) for (const dir of dirs) for (const s of staleGenerated(dir, generate)) issues.push({
|
|
264
|
+
file: rel(s.file),
|
|
265
|
+
line: 1,
|
|
266
|
+
col: 1,
|
|
267
|
+
rule: "stale-generated",
|
|
268
|
+
severity: "error",
|
|
269
|
+
message: `The generated file is ${s.reason}, so the type errors below may not match the templates. Fix: run ts-dom gen (or ts-dom check without --no-write).`
|
|
270
|
+
});
|
|
271
|
+
else for (const dir of dirs) try {
|
|
272
|
+
generateAll(dir, generate);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
if (!(e instanceof CodegenError)) throw e;
|
|
275
|
+
}
|
|
276
|
+
const tsc = findTsc(cwd);
|
|
277
|
+
if (!tsc) issues.push({
|
|
278
|
+
file: ".",
|
|
279
|
+
line: 1,
|
|
280
|
+
col: 1,
|
|
281
|
+
rule: "tsc-not-found",
|
|
282
|
+
severity: "warning",
|
|
283
|
+
message: "TypeScript is not installed in this project, so types were not checked. Fix: npm i -D typescript."
|
|
284
|
+
});
|
|
285
|
+
else {
|
|
286
|
+
const configs = projectConfigs(cwd, options.project);
|
|
287
|
+
const runs = configs.length ? configs.map((c) => ["-p", c]) : [options.project ? ["-p", options.project] : []];
|
|
288
|
+
const parsed = [];
|
|
289
|
+
for (const extra of runs) {
|
|
290
|
+
const run = spawnSync(process.execPath, [
|
|
291
|
+
tsc,
|
|
292
|
+
"--noEmit",
|
|
293
|
+
"--pretty",
|
|
294
|
+
"false",
|
|
295
|
+
...extra
|
|
296
|
+
], {
|
|
297
|
+
cwd,
|
|
298
|
+
encoding: "utf8",
|
|
299
|
+
maxBuffer: 268435456
|
|
300
|
+
});
|
|
301
|
+
const output = `${run.stdout ?? ""}\n${run.stderr ?? ""}`;
|
|
302
|
+
const errors = parseTscOutput(output);
|
|
303
|
+
parsed.push(...errors);
|
|
304
|
+
if (run.error || run.status !== 0 && !errors.length) {
|
|
305
|
+
const detail = run.error ? String(run.error.message) : output.trim().split("\n").slice(0, 5).join(" ");
|
|
306
|
+
issues.push({
|
|
307
|
+
file: extra[1] ? rel(extra[1]) : ".",
|
|
308
|
+
line: 1,
|
|
309
|
+
col: 1,
|
|
310
|
+
rule: "tsc-failed",
|
|
311
|
+
severity: "error",
|
|
312
|
+
message: `tsc did not check the types: ${detail} Fix: make \`npx tsc --noEmit\` run in this folder (a tsconfig.json, or --project=<path>).`
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
issues.push(...mapTscErrors(parsed, cwd));
|
|
317
|
+
}
|
|
318
|
+
const tsFiles = dirs.flatMap(listTs);
|
|
319
|
+
const lint = [...lintUsage(dirs, tsFiles), ...lintDataFlow(dirs, tsFiles)].map((i) => ({
|
|
320
|
+
...i,
|
|
321
|
+
file: rel(resolve(cwd, i.file))
|
|
322
|
+
}));
|
|
323
|
+
for (const i of lint) {
|
|
324
|
+
if (i.rule === "missing-handler" && issues.some((x) => x.rule === "missing-handler" && x.file === i.file && x.line === i.line && x.col === i.col)) continue;
|
|
325
|
+
issues.push(i);
|
|
326
|
+
}
|
|
327
|
+
if (options.a11y) for (const dir of dirs) for (const f of listHtml(dir)) for (const d of checkA11y(readFileSync(f, "utf8"))) issues.push({
|
|
328
|
+
file: rel(f),
|
|
329
|
+
...d
|
|
330
|
+
});
|
|
331
|
+
const seen = /* @__PURE__ */ new Set();
|
|
332
|
+
return issues.filter((i) => {
|
|
333
|
+
const k = `${i.file}:${i.line}:${i.col}:${i.rule}:${i.message}`;
|
|
334
|
+
return !seen.has(k) && seen.add(k) !== void 0;
|
|
335
|
+
}).sort((x, y) => x.file.localeCompare(y.file) || x.line - y.line || x.col - y.col);
|
|
336
|
+
}
|
|
337
|
+
// src/lib/skills.ts
|
|
338
|
+
const STALE_DAYS = 183;
|
|
339
|
+
/** The skills folder shipped with this package (next to its package.json). */
|
|
340
|
+
function skillsRoot() {
|
|
341
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
342
|
+
while (!existsSync(join(dir, "package.json")) || !existsSync(join(dir, "skills"))) {
|
|
343
|
+
const up = dirname(dir);
|
|
344
|
+
if (up === dir) return join(dirname(fileURLToPath(import.meta.url)), "skills");
|
|
345
|
+
dir = up;
|
|
346
|
+
}
|
|
347
|
+
return join(dir, "skills");
|
|
348
|
+
}
|
|
349
|
+
const unquote = (v) => v.trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
350
|
+
/** Read the frontmatter fields Nativele uses; returns null when the file has none. */
|
|
351
|
+
function parseSkill(text, dir) {
|
|
352
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
353
|
+
if (!m) return null;
|
|
354
|
+
const top = {};
|
|
355
|
+
const meta = {};
|
|
356
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
357
|
+
const kv = /^(\s*)([\w-]+):\s?(.*)$/.exec(line);
|
|
358
|
+
if (!kv) continue;
|
|
359
|
+
const value = unquote(kv[3].replace(/\s+#.*$/, ""));
|
|
360
|
+
(kv[1] ? meta : top)[kv[2]] = value;
|
|
361
|
+
}
|
|
362
|
+
if (!top.name) return null;
|
|
363
|
+
const tier = meta.tier === "official" || meta.tier === "community" ? meta.tier : "experimental";
|
|
364
|
+
return {
|
|
365
|
+
name: top.name,
|
|
366
|
+
description: top.description ?? "",
|
|
367
|
+
tier,
|
|
368
|
+
nativele: meta.nativele ?? "",
|
|
369
|
+
verified: meta.verified ?? "",
|
|
370
|
+
tools: meta.tools ?? "",
|
|
371
|
+
author: meta.author ?? "",
|
|
372
|
+
detects: (meta.detects ?? "").split(/[\s,]+/).filter(Boolean),
|
|
373
|
+
dir
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
function listSkills(root = skillsRoot()) {
|
|
377
|
+
if (!existsSync(root)) return [];
|
|
378
|
+
return readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith("_") && existsSync(join(root, e.name, "SKILL.md"))).map((e) => parseSkill(readFileSync(join(root, e.name, "SKILL.md"), "utf8"), join(root, e.name))).filter((s) => s !== null).sort((a, b) => a.name.localeCompare(b.name));
|
|
379
|
+
}
|
|
380
|
+
const parts = (v) => v.split(/[.-]/).slice(0, 3).map((n) => Number.parseInt(n, 10) || 0);
|
|
381
|
+
const compare = (a, b) => {
|
|
382
|
+
const x = parts(a);
|
|
383
|
+
const y = parts(b);
|
|
384
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
|
|
385
|
+
return 0;
|
|
386
|
+
};
|
|
387
|
+
/** Whether `version` satisfies a range of space-separated comparators such as ">=0.5.0 <0.6.0". */
|
|
388
|
+
function inRange(version, range) {
|
|
389
|
+
return range.split(/\s+/).filter(Boolean).every((c) => {
|
|
390
|
+
const m = /^(>=|<=|>|<|=)?v?(\d[\w.-]*)$/.exec(c);
|
|
391
|
+
if (!m) return false;
|
|
392
|
+
const d = compare(version, m[2]);
|
|
393
|
+
return m[1] === ">=" ? d >= 0 : m[1] === "<=" ? d <= 0 : m[1] === ">" ? d > 0 : m[1] === "<" ? d < 0 : d === 0;
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
/** Why a Skill should not be trusted as verified for this version, or null when it is current. */
|
|
397
|
+
function staleReason(skill, version, today = /* @__PURE__ */ new Date()) {
|
|
398
|
+
if (!skill.nativele || !inRange(version, skill.nativele)) return `verified for Nativele ${skill.nativele || "(no range)"}, installed ${version}`;
|
|
399
|
+
const verified = Date.parse(skill.verified);
|
|
400
|
+
if (Number.isNaN(verified)) return "no verification date";
|
|
401
|
+
if ((today.getTime() - verified) / 864e5 > STALE_DAYS) return `last verified ${skill.verified}, more than six months ago`;
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
/** The folder a project keeps its skills in: .claude/skills when the project has .claude, otherwise .agents/skills. */
|
|
405
|
+
function skillsTarget(cwd, to) {
|
|
406
|
+
if (to) return resolve(cwd, to);
|
|
407
|
+
return existsSync(join(cwd, ".claude")) ? join(cwd, ".claude", "skills") : join(cwd, ".agents", "skills");
|
|
408
|
+
}
|
|
409
|
+
/** Every place a project may already hold a copy of a Skill. */
|
|
410
|
+
function installedCopies(cwd, name) {
|
|
411
|
+
return [join(cwd, ".claude", "skills", name), join(cwd, ".agents", "skills", name)].filter((d) => existsSync(join(d, "SKILL.md")));
|
|
412
|
+
}
|
|
413
|
+
/** Copy a Skill (its files, not its example project) into `target/<name>`; returns the folder written. */
|
|
414
|
+
function addSkill(skill, target) {
|
|
415
|
+
const dest = join(target, skill.name);
|
|
416
|
+
cpSync(skill.dir, dest, {
|
|
417
|
+
recursive: true,
|
|
418
|
+
filter: (src) => !src.startsWith(join(skill.dir, "example"))
|
|
419
|
+
});
|
|
420
|
+
return dest;
|
|
421
|
+
}
|
|
422
|
+
function libraryVersion(root = skillsRoot()) {
|
|
423
|
+
return JSON.parse(readFileSync(join(dirname(root), "package.json"), "utf8")).version ?? "0.0.0";
|
|
424
|
+
}
|
|
425
|
+
// src/lib/doctor.ts
|
|
426
|
+
const DOCTOR_RULES = {
|
|
427
|
+
"dir-not-found": "a folder given on the command line does not exist, so nothing in it was checked",
|
|
428
|
+
"no-tsconfig": "there is no tsconfig.json, so tsc cannot check the templates against the components",
|
|
429
|
+
"tsconfig-not-strict": "strict is off, so tsc lets wrong names and null values through",
|
|
430
|
+
"tsconfig-no-dom-lib": "lib leaves out DOM, so element types are missing",
|
|
431
|
+
"tsconfig-classic-resolution": "moduleResolution classic cannot resolve ./x.html.js to the generated x.html.ts",
|
|
432
|
+
"tsconfig-misses-templates": "include leaves out a folder with templates, so their generated types are not checked",
|
|
433
|
+
"runtime-not-installed": "the runtime package the generated files import cannot be resolved",
|
|
434
|
+
"template-import-without-js": "a template is imported as ./x.html instead of ./x.html.js",
|
|
435
|
+
"stale-generated": "a generated file is missing, outdated or orphaned",
|
|
436
|
+
"vite-plugin-missing": "the Vite config does not use the ts-dom plugin, so templates are not regenerated while the dev server runs",
|
|
437
|
+
"oxlint-plugin-missing": "the lint config does not load the ts-dom oxlint plugin, so the template rules do not run in the editor",
|
|
438
|
+
"formatter-whitespace": "a formatter is set up while templates keep their whitespace, so reformatting can change the rendered spacing",
|
|
439
|
+
"skill-available": "the project uses a tool that a Nativele Skill covers, and the project has no copy of that Skill",
|
|
440
|
+
"skill-outdated": "the project's copy of a Nativele Skill differs from the one shipped with the installed version"
|
|
441
|
+
};
|
|
442
|
+
const readText = (file) => existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
443
|
+
/** Whether an include pattern (absolute, forward slashes) makes tsc read the files directly in `dir`. */
|
|
444
|
+
function covers(pattern, dir) {
|
|
445
|
+
const d = dir.split("\\").join("/");
|
|
446
|
+
const star = pattern.indexOf("*");
|
|
447
|
+
if (star === -1) return /\.[cm]?tsx?$/.test(pattern) ? false : d === pattern || d.startsWith(`${pattern}/`);
|
|
448
|
+
const prefix = pattern.slice(0, star).replace(/\/$/, "");
|
|
449
|
+
if (pattern.slice(star).startsWith("**")) return d === prefix || d.startsWith(`${prefix}/`);
|
|
450
|
+
return d === prefix;
|
|
451
|
+
}
|
|
452
|
+
const templateFolders = (dir) => [...new Set(listHtml(dir).map((f) => dirname(f)))];
|
|
453
|
+
const hasTemplates = (dir) => listHtml(dir).length > 0;
|
|
454
|
+
/** Check the project set-up an agent or a developer could get wrong; every issue names its rule and ends with a Fix hint. */
|
|
455
|
+
function runDoctor(options) {
|
|
456
|
+
const cwd = options.cwd ?? process.cwd();
|
|
457
|
+
const generate = options.generate ?? {};
|
|
458
|
+
const runtime = generate.runtime ?? "@nativele/ts-dom";
|
|
459
|
+
const rel = (f) => relative(cwd, f) || ".";
|
|
460
|
+
const issues = [];
|
|
461
|
+
const add = (rule, severity, message, at = {}) => issues.push({
|
|
462
|
+
rule,
|
|
463
|
+
severity,
|
|
464
|
+
message,
|
|
465
|
+
...at
|
|
466
|
+
});
|
|
467
|
+
const dirs = [];
|
|
468
|
+
for (const d of options.dirs.map((x) => resolve(cwd, x))) if (existsSync(d)) dirs.push(d);
|
|
469
|
+
else add("dir-not-found", "error", `The folder ${rel(d)} does not exist, so nothing in it was checked. Fix: pass the folder that holds the templates and components (default: src).`, { file: rel(d) });
|
|
470
|
+
const configs = projectConfigs(cwd);
|
|
471
|
+
let paths = {};
|
|
472
|
+
if (!configs.length) add("no-tsconfig", "error", "There is no tsconfig.json. Fix: add one with \"strict\": true, \"lib\": [\"ES2022\", \"DOM\"] and \"moduleResolution\": \"bundler\" (or \"NodeNext\").");
|
|
473
|
+
const includes = [];
|
|
474
|
+
let everyConfigIncludes = true;
|
|
475
|
+
for (const configPath of configs) {
|
|
476
|
+
const config = readTsConfig(configPath);
|
|
477
|
+
const o = config.compilerOptions;
|
|
478
|
+
const file = rel(configPath);
|
|
479
|
+
paths = {
|
|
480
|
+
...paths,
|
|
481
|
+
...o.paths
|
|
482
|
+
};
|
|
483
|
+
if (o.strict !== true) add("tsconfig-not-strict", "warning", "compilerOptions.strict is not true, so tsc lets wrong names and null values through. Fix: set \"strict\": true.", { file });
|
|
484
|
+
const lib = o.lib;
|
|
485
|
+
if (lib && !lib.some((l) => l.toLowerCase() === "dom")) add("tsconfig-no-dom-lib", "error", "compilerOptions.lib leaves out DOM, so element types such as HTMLButtonElement are missing. Fix: add \"DOM\" to lib.", { file });
|
|
486
|
+
if (String(o.moduleResolution ?? "").toLowerCase() === "classic") add("tsconfig-classic-resolution", "error", "moduleResolution \"classic\" cannot resolve ./x.html.js to the generated x.html.ts. Fix: use \"bundler\" or \"NodeNext\".", { file });
|
|
487
|
+
if (config.include) includes.push(...config.include);
|
|
488
|
+
else everyConfigIncludes = false;
|
|
489
|
+
}
|
|
490
|
+
if (configs.length && everyConfigIncludes) {
|
|
491
|
+
const missed = /* @__PURE__ */ new Set();
|
|
492
|
+
for (const d of dirs) for (const folder of templateFolders(d)) if (!includes.some((p) => covers(p, folder))) missed.add(rel(folder));
|
|
493
|
+
for (const folder of missed) add("tsconfig-misses-templates", "error", `No tsconfig include covers ${folder}, which has templates, so their generated types are never checked. Fix: add "${folder === "." ? "**/*" : `${folder}/**/*`}" to include.`, { file: rel(configs[0]) });
|
|
494
|
+
}
|
|
495
|
+
let resolvable = Object.keys(paths).includes(runtime);
|
|
496
|
+
if (!resolvable) try {
|
|
497
|
+
createRequire(join(cwd, "package.json")).resolve(`${runtime}/package.json`);
|
|
498
|
+
resolvable = true;
|
|
499
|
+
} catch {
|
|
500
|
+
resolvable = false;
|
|
501
|
+
}
|
|
502
|
+
if (!resolvable) add("runtime-not-installed", "error", `The generated files import "${runtime}", which cannot be resolved from this project. Fix: npm i ${runtime}.`);
|
|
503
|
+
for (const d of dirs) {
|
|
504
|
+
for (const f of listTs(d)) readFileSync(f, "utf8").split("\n").forEach((text, i) => {
|
|
505
|
+
const m = /from\s+["'](\.{1,2}\/[^"']+\.html)["']/.exec(text);
|
|
506
|
+
if (m) add("template-import-without-js", "error", `${m[1]} is imported without .js, which works only through a bundler plugin that Nativele does not provide. Fix: import "${m[1]}.js" (TypeScript maps it to the generated ${m[1]}.ts).`, {
|
|
507
|
+
file: rel(f),
|
|
508
|
+
line: i + 1
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
for (const s of staleGenerated(d, generate)) add("stale-generated", "warning", `The generated file is ${s.reason}. Fix: run ts-dom gen (or ts-dom check, which regenerates).`, { file: rel(s.file) });
|
|
512
|
+
}
|
|
513
|
+
const viteConfig = [
|
|
514
|
+
"vite.config.ts",
|
|
515
|
+
"vite.config.mts",
|
|
516
|
+
"vite.config.js",
|
|
517
|
+
"vite.config.mjs"
|
|
518
|
+
].map((n) => join(cwd, n)).find((f) => existsSync(f));
|
|
519
|
+
const vite = viteConfig ? readText(viteConfig) : null;
|
|
520
|
+
if (viteConfig && vite !== null && !/@nativele\/ts-dom\/vite|vite-plugin(\.ts)?["']/.test(vite)) add("vite-plugin-missing", "warning", "The Vite config does not use the ts-dom plugin, so templates are not regenerated while the dev server runs. Fix: import { tsDom } from \"@nativele/ts-dom/vite\" and add tsDom() to plugins.", { file: rel(viteConfig) });
|
|
521
|
+
const oxlintrc = [".oxlintrc.json", "oxlintrc.json"].map((n) => join(cwd, n)).find((f) => existsSync(f));
|
|
522
|
+
const lintConfigs = [oxlintrc ? readText(oxlintrc) : null, vite && /\blint\s*:/.test(vite) ? vite : null].filter((t) => t !== null);
|
|
523
|
+
if (lintConfigs.length && !lintConfigs.some((t) => /@nativele\/ts-dom\/oxlint|oxlint-plugin(\.ts)?["']/.test(t))) add("oxlint-plugin-missing", "warning", "The lint config does not load the ts-dom oxlint plugin, so unused markup, data flow and accessibility rules do not run in the editor. Fix: add { name: \"ts-dom\", specifier: \"@nativele/ts-dom/oxlint\" } to jsPlugins and enable \"ts-dom/unused-markup\".", { file: rel(oxlintrc ?? viteConfig) });
|
|
524
|
+
const pkgText = readText(join(cwd, "package.json"));
|
|
525
|
+
const pkg = pkgText ? JSON.parse(pkgText) : {};
|
|
526
|
+
const deps = {
|
|
527
|
+
...pkg.dependencies,
|
|
528
|
+
...pkg.devDependencies
|
|
529
|
+
};
|
|
530
|
+
const scripts = Object.values(pkg.scripts ?? {});
|
|
531
|
+
const ignoredHtml = /(^|\n)\s*(\*\*\/)?\*\.html\s*($|\n)/.test(readText(join(cwd, ".prettierignore")) ?? "");
|
|
532
|
+
const formatter = [".oxfmtrc.json", ".oxfmtrc.jsonc"].some((n) => existsSync(join(cwd, n))) || "oxfmt" in deps || scripts.some((c) => /(^|[\s;&|])(oxfmt|vp fmt)(\s|$)/.test(c)) || vite !== null && /\bfmt\s*:/.test(vite) || !ignoredHtml && ([
|
|
533
|
+
".prettierrc",
|
|
534
|
+
".prettierrc.json",
|
|
535
|
+
".prettierrc.js",
|
|
536
|
+
"prettier.config.js",
|
|
537
|
+
"prettier.config.mjs"
|
|
538
|
+
].some((n) => existsSync(join(cwd, n))) || "prettier" in deps || scripts.some((c) => /(^|[\s;&|])prettier(\s|$)/.test(c)));
|
|
539
|
+
const collapsed = scripts.some((c) => c.includes("--whitespace=collapse")) || vite !== null && /whitespace\s*:\s*["']collapse["']/.test(vite) || dirs.some((d) => listHtml(d).some((f) => (readText(`${f}.ts`) ?? "").split("\n", 3).includes("// whitespace: collapse")));
|
|
540
|
+
if (formatter && !collapsed && dirs.some(hasTemplates)) add("formatter-whitespace", "warning", "A formatter (oxfmt or Prettier) is set up, and templates keep their whitespace as written, so reformatting a template can change the spacing on the page. Fix: generate with whitespace \"collapse\" (ts-dom gen --whitespace=collapse, or tsDom({ whitespace: \"collapse\" })).");
|
|
541
|
+
for (const skill of listSkills(options.skillsDir ?? skillsRoot())) {
|
|
542
|
+
const copies = installedCopies(cwd, skill.name);
|
|
543
|
+
const shipped = readFileSync(join(skill.dir, "SKILL.md"), "utf8");
|
|
544
|
+
for (const copy of copies) if (readFileSync(join(copy, "SKILL.md"), "utf8") !== shipped) add("skill-outdated", "info", `${rel(copy)} differs from the ${skill.name} Skill shipped with the installed Nativele. Fix: run ts-dom skills add ${skill.name} to replace it with the shipped version.`, { file: rel(join(copy, "SKILL.md")) });
|
|
545
|
+
const found = skill.detects.filter((d) => d in deps);
|
|
546
|
+
if (!copies.length && found.length) add("skill-available", "info", `${found.join(", ")} ${found.length > 1 ? "are dependencies" : "is a dependency"}, and the ${skill.name} Skill covers it: ${skill.description} Fix: run ts-dom skills add ${skill.name}, or read it with ts-dom skills ${skill.name}.`, { file: "package.json" });
|
|
547
|
+
}
|
|
548
|
+
return issues;
|
|
549
|
+
}
|
|
550
|
+
function formatDoctorIssue(i) {
|
|
551
|
+
return `${i.file ?? "."}:${i.line ?? 1}:1 ${i.severity} [${i.rule}] ${i.message}`;
|
|
552
|
+
}
|
|
553
|
+
// src/lib/rule-docs.ts
|
|
554
|
+
const RULE_DOCS = {
|
|
555
|
+
"invalid-hole": {
|
|
556
|
+
why: "HTML only names things: a hole is a single identifier that TypeScript fills in. Paths and expressions would turn the template into a language, and codegen could not derive a field name from them.",
|
|
557
|
+
bad: `<!-- card.html -->
|
|
558
|
+
<h2>{{ user.name }}</h2>
|
|
559
|
+
<p>{{ count + 1 }}</p>`,
|
|
560
|
+
good: `<!-- card.html -->
|
|
561
|
+
<h2>{{ userName }}</h2>
|
|
562
|
+
<p>{{ nextCount }}</p>
|
|
563
|
+
// card.ts: compute the value in TypeScript, then fill the named hole
|
|
564
|
+
this.set({ userName: user.name, nextCount: count + 1 });`
|
|
565
|
+
},
|
|
566
|
+
"attr-hole": {
|
|
567
|
+
why: "Holes are only text slots. An attribute value is not a text node, so it cannot be filled the same way and would be left as literal braces.",
|
|
568
|
+
bad: `<!-- card.html -->
|
|
569
|
+
<a href="/user/{{ id }}">profile</a>`,
|
|
570
|
+
good: `<!-- card.html -->
|
|
571
|
+
<a data-ref="link">profile</a>
|
|
572
|
+
// card.ts
|
|
573
|
+
this.refs.link.href = "/user/" + id;`
|
|
574
|
+
},
|
|
575
|
+
"invalid-event-name": {
|
|
576
|
+
why: "The part after data-on- becomes the name of a DOM event listener, so it must be a plain event name. Modifiers or other characters (as other frameworks allow) would make it listen for an event that never fires.",
|
|
577
|
+
bad: `<form data-on-submit.prevent="save">...</form>`,
|
|
578
|
+
good: `<!-- form.html -->
|
|
579
|
+
<form data-on-submit="save">...</form>
|
|
580
|
+
// form.ts
|
|
581
|
+
save(e: Event): void { e.preventDefault(); }`
|
|
582
|
+
},
|
|
583
|
+
"invalid-handler": {
|
|
584
|
+
why: "data-on-* only names a method; HTML holds no expressions or calls. A call such as save(1) would not be run, and the type of the method could not be checked.",
|
|
585
|
+
bad: `<button data-on-click="removeItem(item.id)">x</button>`,
|
|
586
|
+
good: `<button data-on-click="removeItem">x</button>
|
|
587
|
+
// card.ts: inside a list, hit.key is the item's key
|
|
588
|
+
removeItem(_e: Event, hit: EventHit) { this.set((s) => ({ rows: s.rows.filter((r) => r.id !== hit.key) })); }`
|
|
589
|
+
},
|
|
590
|
+
"reserved-handler": {
|
|
591
|
+
why: "A handler becomes a method on the component class. A name such as render, click or remove would override a member of HTMLElement or ts-dom and break the component in ways tsc cannot always see.",
|
|
592
|
+
bad: `<button data-on-click="render">refresh</button>`,
|
|
593
|
+
good: `<button data-on-click="refresh">refresh</button>`
|
|
594
|
+
},
|
|
595
|
+
"empty-ref": {
|
|
596
|
+
why: "data-ref names an element so TypeScript can reach it through this.refs. An empty name gives codegen nothing to generate.",
|
|
597
|
+
bad: `<input data-ref="">`,
|
|
598
|
+
good: `<input data-ref="name">
|
|
599
|
+
// card.ts
|
|
600
|
+
this.refs.name.focus();`
|
|
601
|
+
},
|
|
602
|
+
"duplicate-ref": {
|
|
603
|
+
why: "Each data-ref becomes one field of this.refs, so two elements with the same name would be indistinguishable and one would be unreachable.",
|
|
604
|
+
bad: `<button data-ref="btn">ok</button>
|
|
605
|
+
<button data-ref="btn">cancel</button>`,
|
|
606
|
+
good: `<button data-ref="ok">ok</button>
|
|
607
|
+
<button data-ref="cancel">cancel</button>`
|
|
608
|
+
},
|
|
609
|
+
"empty-if": {
|
|
610
|
+
why: "data-if names a condition that TypeScript toggles through this.set. Without a name there is nothing to toggle.",
|
|
611
|
+
bad: `<p data-if="">Saved</p>`,
|
|
612
|
+
good: `<p data-if="saved">Saved</p>
|
|
613
|
+
// card.ts
|
|
614
|
+
this.set({ saved: true });`
|
|
615
|
+
},
|
|
616
|
+
"if-on-item-root": {
|
|
617
|
+
why: "The root element of a data-each item is the item itself, created and removed by the list. A data-if on it would fight the list over whether the item exists.",
|
|
618
|
+
bad: `<template data-each="rows">
|
|
619
|
+
<li data-if="visible">{{ label }}</li>
|
|
620
|
+
</template>`,
|
|
621
|
+
good: `<template data-each="rows">
|
|
622
|
+
<li><span data-if="visible">{{ label }}</span></li>
|
|
623
|
+
</template>`
|
|
624
|
+
},
|
|
625
|
+
"slot-in-list": {
|
|
626
|
+
why: "A slot is filled once by the light DOM of the component, but a data-each template is cloned for every item, so the same slot would appear many times.",
|
|
627
|
+
bad: `<template data-each="rows">
|
|
628
|
+
<li><slot></slot></li>
|
|
629
|
+
</template>`,
|
|
630
|
+
good: `<slot></slot>
|
|
631
|
+
<template data-each="rows">
|
|
632
|
+
<li>{{ label }}</li>
|
|
633
|
+
</template>`
|
|
634
|
+
},
|
|
635
|
+
"style-not-top-level": {
|
|
636
|
+
why: "The component collects its styles from the top level of the template and applies them once. A style nested inside other markup would not be picked up.",
|
|
637
|
+
bad: `<div>
|
|
638
|
+
<style>p { color: red; }</style>
|
|
639
|
+
<p>hi</p>
|
|
640
|
+
</div>`,
|
|
641
|
+
good: `<style>p { color: red; }</style>
|
|
642
|
+
<div>
|
|
643
|
+
<p>hi</p>
|
|
644
|
+
</div>`
|
|
645
|
+
},
|
|
646
|
+
"duplicate-slot": {
|
|
647
|
+
why: "Two slots with the same name receive the same light DOM, and the browser shows it in only one of them. This is a warning because it is sometimes a mistake of copying markup.",
|
|
648
|
+
bad: `<slot name="actions"></slot>
|
|
649
|
+
<slot name="actions"></slot>`,
|
|
650
|
+
good: `<slot name="actions"></slot>
|
|
651
|
+
<slot name="footer"></slot>`
|
|
652
|
+
},
|
|
653
|
+
"each-not-template": {
|
|
654
|
+
why: "A list needs an inert prototype that is cloned for each item. Only a <template> provides that without being rendered itself.",
|
|
655
|
+
bad: `<ul data-each="todos">
|
|
656
|
+
<li>{{ title }}</li>
|
|
657
|
+
</ul>`,
|
|
658
|
+
good: `<ul>
|
|
659
|
+
<template data-each="todos">
|
|
660
|
+
<li>{{ title }}</li>
|
|
661
|
+
</template>
|
|
662
|
+
</ul>`
|
|
663
|
+
},
|
|
664
|
+
"empty-each": {
|
|
665
|
+
why: "The data-each name becomes this.lists.<name>. An empty name gives codegen no list to generate.",
|
|
666
|
+
bad: `<template data-each="">
|
|
667
|
+
<li>{{ title }}</li>
|
|
668
|
+
</template>`,
|
|
669
|
+
good: `<template data-each="todos">
|
|
670
|
+
<li>{{ title }}</li>
|
|
671
|
+
</template>`
|
|
672
|
+
},
|
|
673
|
+
"nested-each": {
|
|
674
|
+
why: "Updates are explicit and local; nested lists would need a tree of lists to keep in step. Give the inner list to a child component instead.",
|
|
675
|
+
bad: `<template data-each="groups">
|
|
676
|
+
<li>
|
|
677
|
+
<template data-each="items"><span>{{ label }}</span></template>
|
|
678
|
+
</li>
|
|
679
|
+
</template>`,
|
|
680
|
+
good: `<template data-each="groups" data-key="id" data-prop="group">
|
|
681
|
+
<group-row></group-row>
|
|
682
|
+
</template>
|
|
683
|
+
<!-- group-row.html has its own data-each="items" -->`
|
|
684
|
+
},
|
|
685
|
+
"duplicate-each": {
|
|
686
|
+
why: "Each data-each name becomes one entry of this.lists, so two lists with one name would be the same list in TypeScript.",
|
|
687
|
+
bad: `<template data-each="rows"><li>{{ a }}</li></template>
|
|
688
|
+
<template data-each="rows"><li>{{ b }}</li></template>`,
|
|
689
|
+
good: `<template data-each="rows"><li>{{ a }}</li></template>
|
|
690
|
+
<template data-each="extras"><li>{{ b }}</li></template>`
|
|
691
|
+
},
|
|
692
|
+
"each-binding-incomplete": {
|
|
693
|
+
why: "A list of components needs both the field that identifies an item (data-key) and the prop that receives it (data-prop). With only one, codegen cannot type the items assignment.",
|
|
694
|
+
bad: `<template data-each="tasks" data-key="id">
|
|
695
|
+
<task-row></task-row>
|
|
696
|
+
</template>`,
|
|
697
|
+
good: `<template data-each="tasks" data-key="id" data-prop="task">
|
|
698
|
+
<task-row></task-row>
|
|
699
|
+
</template>`
|
|
700
|
+
},
|
|
701
|
+
"invalid-each-binding": {
|
|
702
|
+
why: "data-key and data-prop are names, not paths: the key is a field of the item and the prop is a property of the item component. Both must be single identifiers so tsc can check them.",
|
|
703
|
+
bad: `<template data-each="tasks" data-key="meta.id" data-prop="the-task">
|
|
704
|
+
<task-row></task-row>
|
|
705
|
+
</template>`,
|
|
706
|
+
good: `<template data-each="tasks" data-key="id" data-prop="task">
|
|
707
|
+
<task-row></task-row>
|
|
708
|
+
</template>`
|
|
709
|
+
},
|
|
710
|
+
"each-binding-root": {
|
|
711
|
+
why: "With data-key and data-prop the item is a component that receives the data through its prop, so the item root must be exactly one custom element.",
|
|
712
|
+
bad: `<template data-each="tasks" data-key="id" data-prop="task">
|
|
713
|
+
<li>{{ title }}</li>
|
|
714
|
+
</template>`,
|
|
715
|
+
good: `<template data-each="tasks" data-key="id" data-prop="task">
|
|
716
|
+
<task-row></task-row>
|
|
717
|
+
</template>`
|
|
718
|
+
},
|
|
719
|
+
"each-root-count": {
|
|
720
|
+
why: "Each item is cloned as one element so the list can move and remove it as a unit. Zero or several roots would leave no single element to key.",
|
|
721
|
+
bad: `<template data-each="rows">
|
|
722
|
+
<dt>{{ term }}</dt>
|
|
723
|
+
<dd>{{ detail }}</dd>
|
|
724
|
+
</template>`,
|
|
725
|
+
good: `<template data-each="rows">
|
|
726
|
+
<div><dt>{{ term }}</dt><dd>{{ detail }}</dd></div>
|
|
727
|
+
</template>`
|
|
728
|
+
},
|
|
729
|
+
"document-tag": {
|
|
730
|
+
why: "A template is parsed as a fragment, and the browser drops <html>, <head> and <body> from fragments, so the markup would silently vanish.",
|
|
731
|
+
bad: `<body>
|
|
732
|
+
<h1>{{ title }}</h1>
|
|
733
|
+
</body>`,
|
|
734
|
+
good: `<h1>{{ title }}</h1>`
|
|
735
|
+
},
|
|
736
|
+
"forbidden-tag": {
|
|
737
|
+
why: "A script in a template would run code from markup. Behavior belongs to TypeScript, and pages with Trusted Types enabled would block it.",
|
|
738
|
+
bad: `<script>alert(1)<\/script>`,
|
|
739
|
+
good: `// card.ts
|
|
740
|
+
connected() { /* put the behavior in the class */ }`
|
|
741
|
+
},
|
|
742
|
+
"forbidden-attribute": {
|
|
743
|
+
why: "on* attributes and srcdoc run code from markup or are blocked by Trusted Types. Events are attached by data-on-* to a named method instead.",
|
|
744
|
+
bad: `<button onclick="save()">save</button>`,
|
|
745
|
+
good: `<button data-on-click="save">save</button>`
|
|
746
|
+
},
|
|
747
|
+
"unsafe-url": {
|
|
748
|
+
why: "A javascript: URL in href, src, action or formaction runs code when followed, and Trusted Types pages block it. Use a handler for behavior.",
|
|
749
|
+
bad: `<a href="javascript:void(0)">more</a>`,
|
|
750
|
+
good: `<button data-on-click="more">more</button>`
|
|
751
|
+
},
|
|
752
|
+
"duplicate-define": {
|
|
753
|
+
why: "A custom element tag can be registered only once, so two define() calls with one tag in the scanned directories would make the second fail at runtime and make the generated tag map ambiguous.",
|
|
754
|
+
bad: `// a.ts
|
|
755
|
+
define("user-card", CardA);
|
|
756
|
+
// b.ts
|
|
757
|
+
define("user-card", CardB);`,
|
|
758
|
+
good: `// a.ts
|
|
759
|
+
define("user-card", CardA);
|
|
760
|
+
// b.ts
|
|
761
|
+
define("user-badge", CardB);`
|
|
762
|
+
},
|
|
763
|
+
"unused-ref": {
|
|
764
|
+
why: "A data-ref that no code reads is dead markup, and it often means the code that was meant to use the element was removed or misspelled.",
|
|
765
|
+
bad: `<!-- card.html -->
|
|
766
|
+
<input data-ref="name">
|
|
767
|
+
// card.ts does not use this.refs.name`,
|
|
768
|
+
good: `<input data-ref="name">
|
|
769
|
+
// card.ts
|
|
770
|
+
this.refs.name.focus();`
|
|
771
|
+
},
|
|
772
|
+
"unused-if": {
|
|
773
|
+
why: "A data-if that is never toggled stays in its initial state forever, which usually means the condition was never wired up.",
|
|
774
|
+
bad: `<p data-if="error">Failed</p>
|
|
775
|
+
// card.ts never sets "error"`,
|
|
776
|
+
good: `<p data-if="error">Failed</p>
|
|
777
|
+
// card.ts
|
|
778
|
+
this.set({ error: true });`
|
|
779
|
+
},
|
|
780
|
+
"unused-text": {
|
|
781
|
+
why: "A hole that no code fills renders empty, so the page shows nothing where a value was expected.",
|
|
782
|
+
bad: `<h2>{{ title }}</h2>
|
|
783
|
+
// card.ts never sets "title"`,
|
|
784
|
+
good: `<h2>{{ title }}</h2>
|
|
785
|
+
// card.ts
|
|
786
|
+
this.set({ title: "Hello" });`
|
|
787
|
+
},
|
|
788
|
+
"unused-list": {
|
|
789
|
+
why: "A data-each that the class never assigns or fills always renders an empty list.",
|
|
790
|
+
bad: `<template data-each="rows"><li>{{ label }}</li></template>
|
|
791
|
+
// card.ts never uses this.lists.rows`,
|
|
792
|
+
good: `<template data-each="rows"><li>{{ label }}</li></template>
|
|
793
|
+
// card.ts
|
|
794
|
+
this.lists.rows.replace(rows, (r) => r.id, (r, { text }) => (text.label = r.label));`
|
|
795
|
+
},
|
|
796
|
+
"unused-item-name": {
|
|
797
|
+
why: "A name inside a list item that no code fills or reads is dead markup, usually left over after the item shape changed.",
|
|
798
|
+
bad: `<template data-each="rows"><li>{{ label }} {{ note }}</li></template>
|
|
799
|
+
// card.ts
|
|
800
|
+
this.lists.rows.replace(rows, (r) => r.id, (r, { text }) => (text.label = r.label));`,
|
|
801
|
+
good: `<template data-each="rows"><li>{{ label }}</li></template>
|
|
802
|
+
// card.ts
|
|
803
|
+
this.lists.rows.replace(rows, (r) => r.id, (r, { text }) => (text.label = r.label));`
|
|
804
|
+
},
|
|
805
|
+
"missing-handler": {
|
|
806
|
+
why: "data-on-* names a method, and a missing method means the event does nothing. The same check runs in tsc through the generated types and in lint on the class text.",
|
|
807
|
+
bad: `<button data-on-click="save">save</button>
|
|
808
|
+
// card.ts defines no save()`,
|
|
809
|
+
good: `<button data-on-click="save">save</button>
|
|
810
|
+
// card.ts
|
|
811
|
+
save() { /* ... */ }`
|
|
812
|
+
},
|
|
813
|
+
"no-importer": {
|
|
814
|
+
why: "A template that no TypeScript file imports never becomes a component, so it is dead code or an import that was forgotten.",
|
|
815
|
+
bad: `// card.html exists, but no .ts file contains
|
|
816
|
+
// import tpl from "./card.html.js"`,
|
|
817
|
+
good: `// card.ts
|
|
818
|
+
import tpl from "./card.html.js";`
|
|
819
|
+
},
|
|
820
|
+
"prop-forwarding": {
|
|
821
|
+
why: "Props go down one level. Handing your own prop straight to a child is prop drilling and hides where the data really comes from; use a shared store or have the parent that owns the data set the child.",
|
|
822
|
+
bad: `// panel.ts (props: { name: string })
|
|
823
|
+
render(view) { this.refs.kid.name = view.name; }`,
|
|
824
|
+
good: `// panel.ts: fill the child from data this component owns
|
|
825
|
+
render(view) { this.refs.kid.name = this.state.label; }`
|
|
826
|
+
},
|
|
827
|
+
"emit-forwarding": {
|
|
828
|
+
why: "Events go up, one level at a time, and a parent that only re-emits a child's event builds an implicit chain across layers. Handle the event where it matters, or use a shared store.",
|
|
829
|
+
bad: `// panel.ts
|
|
830
|
+
onChange(e) { this.emit("change", e.detail); }`,
|
|
831
|
+
good: `// panel.ts
|
|
832
|
+
onChange(e) { this.set({ value: e.detail.value }); }`
|
|
833
|
+
},
|
|
834
|
+
"props-mutation": {
|
|
835
|
+
why: "Props are read-only inside the component: the parent owns them and sets them. Assigning one here would be overwritten at the next parent update and breaks one-way data flow.",
|
|
836
|
+
bad: `// counter.ts (props: { count: number })
|
|
837
|
+
inc() { this.count = this.count + 1; }`,
|
|
838
|
+
good: `// counter.ts
|
|
839
|
+
inc() { this.emit("count-change", { value: this.props.count + 1 }); }`
|
|
840
|
+
},
|
|
841
|
+
"unguarded-input-value": {
|
|
842
|
+
why: "render() runs on every change, including the change the user is typing. Assigning a form control's value, even to the same text, moves the caret to the end, so the write must be skipped when nothing differs.",
|
|
843
|
+
bad: `// form.ts
|
|
844
|
+
override render(view: { name: string }) {
|
|
845
|
+
this.refs.name.value = view.name;
|
|
846
|
+
}`,
|
|
847
|
+
good: `// form.ts
|
|
848
|
+
override render(view: { name: string }) {
|
|
849
|
+
if (this.refs.name.value !== view.name) this.refs.name.value = view.name;
|
|
850
|
+
}`
|
|
851
|
+
},
|
|
852
|
+
"custom-event-on-native": {
|
|
853
|
+
why: "A child's custom event does not bubble, so a handler on a native element would never receive it. Events are handled on the tag of the component that emits them.",
|
|
854
|
+
bad: `<div data-on-quantity-change="update">
|
|
855
|
+
<qty-input></qty-input>
|
|
856
|
+
</div>`,
|
|
857
|
+
good: `<div>
|
|
858
|
+
<qty-input data-on-quantity-change="update"></qty-input>
|
|
859
|
+
</div>`
|
|
860
|
+
},
|
|
861
|
+
"unknown-prop": {
|
|
862
|
+
why: "An attribute on a child tag that the child does not declare in props is ignored at runtime, so a typo silently sets nothing.",
|
|
863
|
+
bad: `<x-kid lable="hi"></x-kid>`,
|
|
864
|
+
good: `<x-kid label="hi"></x-kid>
|
|
865
|
+
// x-kid.ts: Component(tpl, { props: { label: "" } })`
|
|
866
|
+
},
|
|
867
|
+
"invalid-prop-value": {
|
|
868
|
+
why: "An attribute is always a string, so it can only set string, number or boolean props. Other types can only be passed as JS properties.",
|
|
869
|
+
bad: `<x-list items="a,b"></x-list>
|
|
870
|
+
// x-list.ts: props: { items: [] as string[] }`,
|
|
871
|
+
good: `<x-list data-ref="list"></x-list>
|
|
872
|
+
// panel.ts
|
|
873
|
+
this.refs.list.items = ["a", "b"];`
|
|
874
|
+
},
|
|
875
|
+
"unknown-event": {
|
|
876
|
+
why: "The child does not declare this event in emits and it is not a native event, so the handler could never run. The check catches typos and removed events.",
|
|
877
|
+
bad: `<qty-input data-on-quantity-chnage="update"></qty-input>`,
|
|
878
|
+
good: `<qty-input data-on-quantity-change="update"></qty-input>
|
|
879
|
+
// qty-input.ts: emits: { "quantity-change": event<{ value: number }>() }`
|
|
880
|
+
},
|
|
881
|
+
"img-alt": {
|
|
882
|
+
why: "Without alt, screen readers cannot describe the image, or read out its file name.",
|
|
883
|
+
bad: `<img src="logo.png">`,
|
|
884
|
+
good: `<img src="logo.png" alt="Nativele logo">
|
|
885
|
+
<img src="divider.png" alt="">`
|
|
886
|
+
},
|
|
887
|
+
"control-name": {
|
|
888
|
+
why: "A button or link with no text, hole, slot or aria-label has no accessible name, so assistive technology announces it as an unnamed control.",
|
|
889
|
+
bad: `<button data-on-click="close"><svg></svg></button>`,
|
|
890
|
+
good: `<button data-on-click="close" aria-label="Close"><svg></svg></button>`
|
|
891
|
+
},
|
|
892
|
+
"form-label": {
|
|
893
|
+
why: "A form control without a label cannot be identified by screen readers, and clicking the label text will not focus it.",
|
|
894
|
+
bad: `<input data-ref="email">`,
|
|
895
|
+
good: `<label>Email <input data-ref="email"></label>`
|
|
896
|
+
},
|
|
897
|
+
"iframe-title": {
|
|
898
|
+
why: "A screen reader announces an iframe by its title. Without one the user cannot tell what it contains.",
|
|
899
|
+
bad: `<iframe src="/map"></iframe>`,
|
|
900
|
+
good: `<iframe src="/map" title="Office location map"></iframe>`
|
|
901
|
+
},
|
|
902
|
+
"dialog-name": {
|
|
903
|
+
why: "A dialog without a name is announced with no context when it opens.",
|
|
904
|
+
bad: `<dialog><p>Delete?</p></dialog>`,
|
|
905
|
+
good: `<dialog aria-label="Confirm delete"><p>Delete?</p></dialog>`
|
|
906
|
+
},
|
|
907
|
+
"heading-empty": {
|
|
908
|
+
why: "An empty heading adds an empty stop to the heading navigation of assistive technology.",
|
|
909
|
+
bad: `<h2></h2>`,
|
|
910
|
+
good: `<h2>{{ title }}</h2>`
|
|
911
|
+
},
|
|
912
|
+
"aria-role": {
|
|
913
|
+
why: "An invalid role is ignored by browsers, so the element loses the meaning you meant to give it. This is almost always a misspelling.",
|
|
914
|
+
bad: `<div role="buton">save</div>`,
|
|
915
|
+
good: `<div role="button" tabindex="0">save</div>`
|
|
916
|
+
},
|
|
917
|
+
"aria-attr": {
|
|
918
|
+
why: "An unknown aria-* attribute is ignored by assistive technology, so the state you meant to expose is lost. This is almost always a misspelling.",
|
|
919
|
+
bad: `<button aria-pressd="true">bold</button>`,
|
|
920
|
+
good: `<button aria-pressed="true">bold</button>`
|
|
921
|
+
},
|
|
922
|
+
"role-tabindex": {
|
|
923
|
+
why: "An element with an interactive role promises keyboard use, but a non-focusable element cannot be reached with the keyboard.",
|
|
924
|
+
bad: `<div role="button" data-on-click="save">save</div>`,
|
|
925
|
+
good: `<button data-on-click="save">save</button>`
|
|
926
|
+
},
|
|
927
|
+
"tabindex-positive": {
|
|
928
|
+
why: "A positive tabindex pulls an element ahead of the natural order and makes the focus order differ from the reading order.",
|
|
929
|
+
bad: `<button tabindex="3">save</button>`,
|
|
930
|
+
good: `<button tabindex="0">save</button>`
|
|
931
|
+
},
|
|
932
|
+
"hidden-focusable": {
|
|
933
|
+
why: "A focusable element inside aria-hidden=\"true\" can be reached by keyboard but is invisible to screen readers.",
|
|
934
|
+
bad: `<div aria-hidden="true"><button>save</button></div>`,
|
|
935
|
+
good: `<div><button>save</button></div>`
|
|
936
|
+
},
|
|
937
|
+
"label-for": {
|
|
938
|
+
why: "A <label for> that points to a missing id labels nothing, so the control stays unnamed.",
|
|
939
|
+
bad: `<label for="email">Email</label>
|
|
940
|
+
<input id="mail">`,
|
|
941
|
+
good: `<label for="email">Email</label>
|
|
942
|
+
<input id="email">`
|
|
943
|
+
},
|
|
944
|
+
"id-in-list": {
|
|
945
|
+
why: "A data-each template is cloned for every item, so an id inside it is repeated and no longer unique in the document.",
|
|
946
|
+
bad: `<template data-each="rows">
|
|
947
|
+
<li id="row">{{ label }}</li>
|
|
948
|
+
</template>`,
|
|
949
|
+
good: `<template data-each="rows">
|
|
950
|
+
<li data-ref="row">{{ label }}</li>
|
|
951
|
+
</template>`
|
|
952
|
+
},
|
|
953
|
+
"duplicate-id": {
|
|
954
|
+
why: "Ids must be unique, or label, aria-labelledby and similar references point at the wrong element.",
|
|
955
|
+
bad: `<input id="name">
|
|
956
|
+
<input id="name">`,
|
|
957
|
+
good: `<input id="first-name">
|
|
958
|
+
<input id="last-name">`
|
|
959
|
+
},
|
|
960
|
+
"aria-idref": {
|
|
961
|
+
why: "An ARIA attribute such as aria-labelledby refers to an id that is not in this template, so the reference is dead. If the id lives outside the component, ignore the rule for that file.",
|
|
962
|
+
bad: `<section aria-labelledby="heading">
|
|
963
|
+
<h2 id="title">{{ title }}</h2>
|
|
964
|
+
</section>`,
|
|
965
|
+
good: `<section aria-labelledby="title">
|
|
966
|
+
<h2 id="title">{{ title }}</h2>
|
|
967
|
+
</section>`
|
|
968
|
+
},
|
|
969
|
+
"heading-order": {
|
|
970
|
+
why: "Skipping heading levels breaks the outline that screen reader users navigate by.",
|
|
971
|
+
bad: `<h1>{{ title }}</h1>
|
|
972
|
+
<h3>{{ subtitle }}</h3>`,
|
|
973
|
+
good: `<h1>{{ title }}</h1>
|
|
974
|
+
<h2>{{ subtitle }}</h2>`
|
|
975
|
+
},
|
|
976
|
+
"table-header": {
|
|
977
|
+
why: "Without header cells, a screen reader cannot tell which column or row a data cell belongs to.",
|
|
978
|
+
bad: `<table><tr><td>Name</td><td>Age</td></tr></table>`,
|
|
979
|
+
good: `<table><tr><th>Name</th><th>Age</th></tr></table>`
|
|
980
|
+
},
|
|
981
|
+
"list-structure": {
|
|
982
|
+
why: "Screen readers announce lists by counting their items, which only works when <ul>/<ol> contain <li> and <li> sits inside a list.",
|
|
983
|
+
bad: `<ul><div>one</div></ul>`,
|
|
984
|
+
good: `<ul><li>one</li></ul>`
|
|
985
|
+
},
|
|
986
|
+
"landmark-label": {
|
|
987
|
+
why: "When several landmarks of one kind exist, users navigating by landmark hear identical names and cannot tell them apart.",
|
|
988
|
+
bad: `<nav>...</nav>
|
|
989
|
+
<nav>...</nav>`,
|
|
990
|
+
good: `<nav aria-label="Main">...</nav>
|
|
991
|
+
<nav aria-label="Footer">...</nav>`
|
|
992
|
+
},
|
|
993
|
+
"redundant-role": {
|
|
994
|
+
why: "A role that repeats the native role adds nothing and can drift from the element if it is changed later.",
|
|
995
|
+
bad: `<button role="button">save</button>`,
|
|
996
|
+
good: `<button>save</button>`
|
|
997
|
+
},
|
|
998
|
+
"click-semantics": {
|
|
999
|
+
why: "A div or span with data-on-click cannot be focused or activated by keyboard, and assistive technology does not know it is clickable.",
|
|
1000
|
+
bad: `<div data-on-click="open">open</div>`,
|
|
1001
|
+
good: `<button data-on-click="open">open</button>`
|
|
1002
|
+
},
|
|
1003
|
+
"stale-generated": {
|
|
1004
|
+
why: "Types come from the generated *.html.ts files. If one is missing, outdated or orphaned, tsc checks against old templates and its errors may not match the HTML. ts-dom doctor reports the same condition as a warning.",
|
|
1005
|
+
bad: `# card.html was edited, card.html.ts was not regenerated
|
|
1006
|
+
npx ts-dom check --no-write`,
|
|
1007
|
+
good: `npx ts-dom gen
|
|
1008
|
+
# or: npx ts-dom check (it regenerates first)`
|
|
1009
|
+
},
|
|
1010
|
+
"missing-binding": {
|
|
1011
|
+
why: "Every {{ hole }} and data-if must be backed by a field in state, props or derive. tsc finds the ones that are not, which usually means a typo or a forgotten declaration.",
|
|
1012
|
+
bad: `<!-- card.html -->
|
|
1013
|
+
<h2>{{ title }}</h2>
|
|
1014
|
+
// card.ts
|
|
1015
|
+
Component(tpl, { state: { name: "" } })`,
|
|
1016
|
+
good: `// card.ts
|
|
1017
|
+
Component(tpl, { state: { title: "" } })`
|
|
1018
|
+
},
|
|
1019
|
+
"conflicting-keys": {
|
|
1020
|
+
why: "state, props and derive share one namespace in the view. The same name in two of them would make it unclear who owns the value, against one-way data flow.",
|
|
1021
|
+
bad: `Component(tpl, {
|
|
1022
|
+
state: { total: 0 },
|
|
1023
|
+
derive: { total: (s) => s.count * 2 },
|
|
1024
|
+
})`,
|
|
1025
|
+
good: `Component(tpl, {
|
|
1026
|
+
state: { count: 0 },
|
|
1027
|
+
derive: { total: (s) => s.count * 2 },
|
|
1028
|
+
})`
|
|
1029
|
+
},
|
|
1030
|
+
"reserved-prop": {
|
|
1031
|
+
why: "A prop becomes a property on the element, and names such as title, hidden or render already belong to HTMLElement or ts-dom.",
|
|
1032
|
+
bad: `Component(tpl, { props: { title: "" } })`,
|
|
1033
|
+
good: `Component(tpl, { props: { heading: "" } })`
|
|
1034
|
+
},
|
|
1035
|
+
"native-event-name": {
|
|
1036
|
+
why: "An emitted event with a native name such as click or input would be confused with the native event and handled by the wrong listeners.",
|
|
1037
|
+
bad: `Component(tpl, { emits: { click: event() } })`,
|
|
1038
|
+
good: `Component(tpl, { emits: { "item-select": event() } })`
|
|
1039
|
+
},
|
|
1040
|
+
"item-prop-missing": {
|
|
1041
|
+
why: "A list with data-prop assigns each item to that property of the item component. If the component does not declare it, the items go nowhere.",
|
|
1042
|
+
bad: `<template data-each="tasks" data-key="id" data-prop="task"><task-row></task-row></template>
|
|
1043
|
+
// task-row.ts
|
|
1044
|
+
Component(tpl, { props: { item: {} as Task } })`,
|
|
1045
|
+
good: `// task-row.ts
|
|
1046
|
+
Component(tpl, { props: { task: {} as Task } })`
|
|
1047
|
+
},
|
|
1048
|
+
"item-not-in-tag-map": {
|
|
1049
|
+
why: "tsc learns which class a tag stands for from ts-dom-tags.ts. If the item component is missing there, its items cannot be typed.",
|
|
1050
|
+
bad: `// task-row.ts defines the tag, but ts-dom-tags.ts is out of date
|
|
1051
|
+
define("task-row", TaskRow);`,
|
|
1052
|
+
good: `npx ts-dom gen # regenerates ts-dom-tags.ts`
|
|
1053
|
+
},
|
|
1054
|
+
"item-class-not-exported": {
|
|
1055
|
+
why: "The tag map imports the class from the file that calls define(), so the class must be exported or tsc cannot see its props.",
|
|
1056
|
+
bad: `class TaskRow extends Component(tpl, {}) {}
|
|
1057
|
+
define("task-row", TaskRow);`,
|
|
1058
|
+
good: `export class TaskRow extends Component(tpl, {}) {}
|
|
1059
|
+
define("task-row", TaskRow);`
|
|
1060
|
+
},
|
|
1061
|
+
tsc: {
|
|
1062
|
+
why: "Any TypeScript error that none of the specific rules recognizes is passed through as tsc prints it, at its TypeScript location, so one command shows every problem.",
|
|
1063
|
+
bad: `// card.ts
|
|
1064
|
+
const n: number = "1";`,
|
|
1065
|
+
good: `// card.ts
|
|
1066
|
+
const n: number = 1;`
|
|
1067
|
+
},
|
|
1068
|
+
"tsc-failed": {
|
|
1069
|
+
why: "When tsc stops before checking (no tsconfig, a bad option, a crash), a check that only looked for type errors would pass with none. This rule makes that visible.",
|
|
1070
|
+
bad: `npx ts-dom check --project=tsconfig.app.json # the file does not exist`,
|
|
1071
|
+
good: `npx ts-dom check --project=tsconfig.json`
|
|
1072
|
+
},
|
|
1073
|
+
"dir-not-found": {
|
|
1074
|
+
why: "A folder given to ts-dom check or doctor that does not exist would otherwise be skipped silently, and a clean result would only mean that nothing was looked at.",
|
|
1075
|
+
bad: `npx ts-dom check ap # the templates live in app/`,
|
|
1076
|
+
good: `npx ts-dom check app`
|
|
1077
|
+
},
|
|
1078
|
+
"tsc-not-found": {
|
|
1079
|
+
why: "ts-dom check runs the TypeScript compiler of the project. Without it installed, nothing can be type-checked.",
|
|
1080
|
+
bad: `npx ts-dom check # typescript is not installed`,
|
|
1081
|
+
good: `npm i -D typescript
|
|
1082
|
+
npx ts-dom check`
|
|
1083
|
+
},
|
|
1084
|
+
"no-tsconfig": {
|
|
1085
|
+
why: "The core value of the library is that tsc checks HTML against TypeScript, and tsc needs a tsconfig.json to know what to check.",
|
|
1086
|
+
bad: `# project root has no tsconfig.json`,
|
|
1087
|
+
good: `// tsconfig.json
|
|
1088
|
+
{ "compilerOptions": { "strict": true, "lib": ["ES2022", "DOM"] }, "include": ["src"] }`
|
|
1089
|
+
},
|
|
1090
|
+
"tsconfig-not-strict": {
|
|
1091
|
+
why: "Without strict, tsc lets wrong names and null values through, which removes most of what the generated types are for.",
|
|
1092
|
+
bad: `// tsconfig.json
|
|
1093
|
+
{ "compilerOptions": { "strict": false } }`,
|
|
1094
|
+
good: `// tsconfig.json
|
|
1095
|
+
{ "compilerOptions": { "strict": true } }`
|
|
1096
|
+
},
|
|
1097
|
+
"tsconfig-no-dom-lib": {
|
|
1098
|
+
why: "Components extend HTMLElement and refs are element types. Without the DOM lib those types do not exist.",
|
|
1099
|
+
bad: `// tsconfig.json
|
|
1100
|
+
{ "compilerOptions": { "lib": ["ES2022"] } }`,
|
|
1101
|
+
good: `// tsconfig.json
|
|
1102
|
+
{ "compilerOptions": { "lib": ["ES2022", "DOM"] } }`
|
|
1103
|
+
},
|
|
1104
|
+
"tsconfig-classic-resolution": {
|
|
1105
|
+
why: "Templates are imported as ./x.html.js and TypeScript must map that to the generated x.html.ts. The classic resolution does not do that mapping.",
|
|
1106
|
+
bad: `// tsconfig.json
|
|
1107
|
+
{ "compilerOptions": { "moduleResolution": "classic" } }`,
|
|
1108
|
+
good: `// tsconfig.json
|
|
1109
|
+
{ "compilerOptions": { "moduleResolution": "bundler" } }`
|
|
1110
|
+
},
|
|
1111
|
+
"tsconfig-misses-templates": {
|
|
1112
|
+
why: "tsc only checks files under include. A template folder left out means its generated types are never checked against the components.",
|
|
1113
|
+
bad: `// tsconfig.json
|
|
1114
|
+
{ "include": ["src/server"] } // templates are in src/ui`,
|
|
1115
|
+
good: `// tsconfig.json
|
|
1116
|
+
{ "include": ["src"] }`
|
|
1117
|
+
},
|
|
1118
|
+
"runtime-not-installed": {
|
|
1119
|
+
why: "Generated files import the runtime package. If it cannot be resolved from the project, they fail to compile and to run.",
|
|
1120
|
+
bad: `// card.html.ts imports "@nativele/ts-dom", which is not installed`,
|
|
1121
|
+
good: `npm i @nativele/ts-dom`
|
|
1122
|
+
},
|
|
1123
|
+
"template-import-without-js": {
|
|
1124
|
+
why: "Without a bundler plugin, ./x.html is not a module. ./x.html.js is what TypeScript maps to the generated x.html.ts, and it works with or without Vite.",
|
|
1125
|
+
bad: `import tpl from "./card.html";`,
|
|
1126
|
+
good: `import tpl from "./card.html.js";`
|
|
1127
|
+
},
|
|
1128
|
+
"vite-plugin-missing": {
|
|
1129
|
+
why: "The Vite plugin regenerates the .html.ts files while the dev server runs. Without it, edits to a template are not reflected until gen is run by hand.",
|
|
1130
|
+
bad: `// vite.config.ts
|
|
1131
|
+
export default defineConfig({ plugins: [] });`,
|
|
1132
|
+
good: `// vite.config.ts
|
|
1133
|
+
import { tsDom } from "@nativele/ts-dom/vite";
|
|
1134
|
+
export default defineConfig({ plugins: [tsDom()] });`
|
|
1135
|
+
},
|
|
1136
|
+
"oxlint-plugin-missing": {
|
|
1137
|
+
why: "The oxlint plugin runs the template rules (unused markup, data flow, accessibility) in the editor. Without it they only run from the CLI.",
|
|
1138
|
+
bad: `// .oxlintrc.json
|
|
1139
|
+
{ "rules": {} }`,
|
|
1140
|
+
good: `// .oxlintrc.json
|
|
1141
|
+
{
|
|
1142
|
+
"jsPlugins": [{ "name": "ts-dom", "specifier": "@nativele/ts-dom/oxlint" }],
|
|
1143
|
+
"rules": { "ts-dom/unused-markup": "warn" }
|
|
1144
|
+
}`
|
|
1145
|
+
},
|
|
1146
|
+
"formatter-whitespace": {
|
|
1147
|
+
why: "Text whitespace in a template is kept as written. A formatter that re-indents the HTML can then change the spaces rendered on the page.",
|
|
1148
|
+
bad: `# formatter is configured, templates keep their whitespace
|
|
1149
|
+
npx ts-dom gen`,
|
|
1150
|
+
good: `npx ts-dom gen --whitespace=collapse
|
|
1151
|
+
// or in vite.config.ts: tsDom({ whitespace: "collapse" })`
|
|
1152
|
+
},
|
|
1153
|
+
"skill-available": {
|
|
1154
|
+
why: "A Nativele Skill gives an agent the verified steps, limitations and checks for a tool the project already uses. This is a suggestion and never changes the exit code.",
|
|
1155
|
+
bad: `// package.json lists "react", and the project has no copy of the nativele-react Skill`,
|
|
1156
|
+
good: `npx ts-dom skills add nativele-react # copies it into .claude/skills/ or .agents/skills/
|
|
1157
|
+
npx ts-dom skills nativele-react # or just read it`
|
|
1158
|
+
},
|
|
1159
|
+
"skill-outdated": {
|
|
1160
|
+
why: "A copied Skill does not update with the package. After an upgrade the copy may describe an older version of Nativele or of the tool it covers.",
|
|
1161
|
+
bad: `// .claude/skills/nativele-core/SKILL.md was copied from an older @nativele/ts-dom`,
|
|
1162
|
+
good: `npx ts-dom skills add nativele-core # replaces the copy with the one shipped with the installed version`
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
6
1165
|
// src/cli.ts
|
|
7
1166
|
const HELP = `ts-dom <command> [dirs...] [flags]
|
|
8
1167
|
|
|
9
1168
|
Commands
|
|
10
1169
|
gen [dirs...] Generate x.html.ts for every x.html, plus ts-dom-tags.ts (default dir: src)
|
|
11
1170
|
--watch keep watching and regenerate on change
|
|
1171
|
+
--check write nothing; list generated files that are missing, outdated or orphaned, exit 1 if any
|
|
12
1172
|
--include=<glob> only these templates (repeatable; relative to each dir, ** crosses folders)
|
|
13
1173
|
--exclude=<glob> leave these templates out (repeatable)
|
|
14
1174
|
--whitespace=collapse collapse whitespace in text and drop line-break-only text between tags
|
|
1175
|
+
--whitespace=preserve keep whitespace as written; without either, the mode the generated files were
|
|
1176
|
+
last written with is kept (gen, gen --check, check and doctor all follow it)
|
|
15
1177
|
lint [dirs...] Check templates against the code that uses them (default dir: src)
|
|
16
1178
|
--a11y also check ARIA / accessibility (warnings)
|
|
17
1179
|
--strict exit with code 1 on warnings too (errors always do)
|
|
1180
|
+
--json print the issues as JSON instead of text (also for gen --check)
|
|
1181
|
+
check [dirs...] One pass for agents and CI: template diagnostics, generated files, tsc errors mapped back
|
|
1182
|
+
to the templates, and the lint rules (default dir: src). It rewrites the generated files first.
|
|
1183
|
+
--no-write write nothing: report stale generated files instead (for a CI job that must not
|
|
1184
|
+
change the working tree; gen --check only checks the generated files)
|
|
1185
|
+
--project=<path> tsconfig for tsc (default: tsc's own lookup)
|
|
1186
|
+
--a11y also check ARIA / accessibility (warnings)
|
|
1187
|
+
--strict exit with code 1 on warnings too (errors always do)
|
|
1188
|
+
--json print the issues as JSON instead of text
|
|
1189
|
+
doctor [dirs...] Check the project set-up: tsconfig, template imports, generated files, the runtime package,
|
|
1190
|
+
and the Vite, oxlint and formatter configuration (default dir: src)
|
|
18
1191
|
--json print the issues as JSON instead of text
|
|
1192
|
+
skills List the Nativele Skills shipped with this version: task procedures for coding agents
|
|
1193
|
+
(set-up, a complete example, known limitations, verification), with the versions verified
|
|
1194
|
+
<name> print one Skill
|
|
1195
|
+
add <name...> copy Skills into the project: .claude/skills/ when the project has .claude,
|
|
1196
|
+
otherwise .agents/skills/
|
|
1197
|
+
--to=<dir> copy into this folder instead
|
|
1198
|
+
--json print the list as JSON
|
|
19
1199
|
rules List every rule id with its description
|
|
1200
|
+
--explain=<id> why the rule exists, a wrong example and the fix (the id as printed in [brackets])
|
|
1201
|
+
--json print every rule as JSON: { id, group, description, why, bad, good }
|
|
20
1202
|
|
|
21
1203
|
Output format: file:line:col severity [rule-id] message. Each message says what is wrong and how to fix it.
|
|
22
1204
|
Exit code: 0 = clean, 1 = errors found (or warnings with --strict).
|
|
23
1205
|
`;
|
|
24
|
-
function
|
|
25
|
-
return
|
|
26
|
-
if (n === "node_modules") return [];
|
|
27
|
-
const f = join(dir, n);
|
|
28
|
-
if (statSync(f).isDirectory()) return tsFiles(f);
|
|
29
|
-
return n.endsWith(".ts") && !n.endsWith(".d.ts") && !n.endsWith(".html.ts") && n !== "ts-dom-tags.ts" ? [f] : [];
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
function printRules() {
|
|
33
|
-
const groups = [
|
|
1206
|
+
function ruleGroups() {
|
|
1207
|
+
return [
|
|
34
1208
|
[
|
|
35
1209
|
"gen",
|
|
36
1210
|
"reported by `ts-dom gen` while analyzing a template (errors stop code generation)",
|
|
37
1211
|
CODEGEN_RULES
|
|
38
1212
|
],
|
|
1213
|
+
[
|
|
1214
|
+
"check",
|
|
1215
|
+
"reported by `ts-dom check` (tsc errors mapped back to the templates, and generated file state)",
|
|
1216
|
+
CHECK_RULES
|
|
1217
|
+
],
|
|
1218
|
+
[
|
|
1219
|
+
"doctor",
|
|
1220
|
+
"reported by `ts-dom doctor` (project set-up)",
|
|
1221
|
+
DOCTOR_RULES
|
|
1222
|
+
],
|
|
39
1223
|
[
|
|
40
1224
|
"lint: unused-markup",
|
|
41
|
-
"errors from `ts-dom lint` / oxlint rule ts-dom/unused-markup",
|
|
1225
|
+
"errors from `ts-dom lint` / oxlint rule ts-dom/unused-markup (or ts-dom/<id>)",
|
|
42
1226
|
UNUSED_MARKUP_RULES
|
|
43
1227
|
],
|
|
44
1228
|
[
|
|
45
1229
|
"lint: data-flow",
|
|
46
|
-
"warnings from `ts-dom lint` / oxlint rule ts-dom/data-flow",
|
|
1230
|
+
"warnings from `ts-dom lint` / oxlint rule ts-dom/data-flow (or ts-dom/<id>)",
|
|
47
1231
|
DATA_FLOW_RULES
|
|
48
1232
|
],
|
|
49
1233
|
[
|
|
50
1234
|
"lint: a11y",
|
|
51
|
-
"warnings from `ts-dom lint --a11y` / oxlint rule ts-dom/a11y",
|
|
1235
|
+
"warnings from `ts-dom lint --a11y` / oxlint rule ts-dom/a11y (or ts-dom/<id>)",
|
|
52
1236
|
Object.fromEntries(Object.entries(A11Y_RULES).map(([k, v]) => [k, v.split(". Fix:")[0]]))
|
|
53
1237
|
]
|
|
54
1238
|
];
|
|
55
|
-
|
|
1239
|
+
}
|
|
1240
|
+
function printRules() {
|
|
1241
|
+
const explain = args.find((a) => a.startsWith("--explain="))?.slice(10);
|
|
1242
|
+
if (explain !== void 0) {
|
|
1243
|
+
const doc = RULE_DOCS[explain];
|
|
1244
|
+
const where = ruleGroups().filter(([, , rules]) => explain in rules);
|
|
1245
|
+
if (!doc || !where.length) {
|
|
1246
|
+
console.error(`Unknown rule id "${explain}". Fix: run ts-dom rules to see every id.`);
|
|
1247
|
+
process.exitCode = 1;
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
console.log(`[${explain}] ${where.map(([g, , r]) => `${g}: ${r[explain]}`).join("; ")}\n\nWhy: ${doc.why}\n\nWrong:\n${doc.bad}\n\nRight:\n${doc.good}`);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
if (flags.has("--json")) {
|
|
1254
|
+
const rules = ruleGroups().flatMap(([group, , rules]) => Object.entries(rules).map(([id, description]) => ({
|
|
1255
|
+
id,
|
|
1256
|
+
group,
|
|
1257
|
+
description,
|
|
1258
|
+
...RULE_DOCS[id]
|
|
1259
|
+
})));
|
|
1260
|
+
console.log(JSON.stringify({ rules }, null, 2));
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
for (const [name, note, rules] of ruleGroups()) {
|
|
56
1264
|
console.log(`\n${name}: ${note}`);
|
|
57
|
-
for (const [id, desc] of Object.entries(rules)) console.log(` ${id.padEnd(
|
|
1265
|
+
for (const [id, desc] of Object.entries(rules)) console.log(` ${id.padEnd(28)} ${desc}`);
|
|
58
1266
|
}
|
|
1267
|
+
console.log("\nts-dom rules --explain=<id> shows why a rule exists, with a wrong and a right example.");
|
|
1268
|
+
}
|
|
1269
|
+
function skillsCommand() {
|
|
1270
|
+
const skills = listSkills();
|
|
1271
|
+
const version = libraryVersion();
|
|
1272
|
+
const names = args.filter((a) => !a.startsWith("--"));
|
|
1273
|
+
const find = (name) => {
|
|
1274
|
+
const skill = skills.find((s) => s.name === name);
|
|
1275
|
+
if (!skill) {
|
|
1276
|
+
console.error(`Unknown Skill "${name}". Fix: run ts-dom skills to see the Skills shipped with this version.`);
|
|
1277
|
+
process.exitCode = 1;
|
|
1278
|
+
}
|
|
1279
|
+
return skill;
|
|
1280
|
+
};
|
|
1281
|
+
if (names[0] === "add") {
|
|
1282
|
+
if (names.length < 2) {
|
|
1283
|
+
console.error("Name the Skills to add. Fix: ts-dom skills add <name...> (ts-dom skills lists them).");
|
|
1284
|
+
process.exitCode = 1;
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
const to = args.find((a) => a.startsWith("--to="))?.slice(5);
|
|
1288
|
+
const target = skillsTarget(process.cwd(), to);
|
|
1289
|
+
for (const name of names.slice(1)) {
|
|
1290
|
+
const skill = find(name);
|
|
1291
|
+
if (skill) console.log(`added ${relative(process.cwd(), addSkill(skill, target))}`);
|
|
1292
|
+
}
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (names.length) {
|
|
1296
|
+
for (const name of names) {
|
|
1297
|
+
const skill = find(name);
|
|
1298
|
+
if (skill) console.log(readFileSync(`${skill.dir}/SKILL.md`, "utf8"));
|
|
1299
|
+
}
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const rows = skills.map((skill) => {
|
|
1303
|
+
const { dir: _dir, ...s } = skill;
|
|
1304
|
+
return {
|
|
1305
|
+
...s,
|
|
1306
|
+
stale: staleReason(skill, version)
|
|
1307
|
+
};
|
|
1308
|
+
});
|
|
1309
|
+
if (flags.has("--json")) {
|
|
1310
|
+
console.log(JSON.stringify({
|
|
1311
|
+
version,
|
|
1312
|
+
skills: rows
|
|
1313
|
+
}, null, 2));
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
console.log(`Nativele Skills shipped with @nativele/ts-dom ${version}:\n`);
|
|
1317
|
+
for (const s of rows) {
|
|
1318
|
+
console.log(` ${s.name.padEnd(28)} ${s.tier.padEnd(13)} verified ${s.verified}${s.stale ? ` (stale: ${s.stale})` : ""}`);
|
|
1319
|
+
console.log(` ${"".padEnd(28)} ${s.description}`);
|
|
1320
|
+
}
|
|
1321
|
+
console.log("\nts-dom skills <name> prints one; ts-dom skills add <name> copies it into the project for your agent.");
|
|
59
1322
|
}
|
|
60
1323
|
const [cmd, ...args] = process.argv.slice(2);
|
|
61
1324
|
const flags = new Set(args.filter((a) => a.startsWith("--")));
|
|
62
1325
|
const globs = (name) => args.filter((a) => a.startsWith(`--${name}=`)).map((a) => a.slice(name.length + 3));
|
|
1326
|
+
const whitespace = flags.has("--whitespace=collapse") ? "collapse" : flags.has("--whitespace=preserve") ? "preserve" : void 0;
|
|
63
1327
|
const generate = {
|
|
64
1328
|
include: globs("include"),
|
|
65
1329
|
exclude: globs("exclude"),
|
|
66
|
-
whitespace
|
|
1330
|
+
...whitespace ? { whitespace } : {}
|
|
67
1331
|
};
|
|
68
1332
|
const dirs = args.filter((a) => !a.startsWith("--"));
|
|
69
1333
|
if (!dirs.length) dirs.push("src");
|
|
70
1334
|
try {
|
|
71
|
-
if (cmd === "gen") {
|
|
1335
|
+
if (cmd === "gen" && flags.has("--check")) {
|
|
1336
|
+
const results = dirs.map((d) => checkGenerated(d, generate));
|
|
1337
|
+
const stale = results.flatMap((r) => r.stale);
|
|
1338
|
+
const errors = results.flatMap((r) => r.errors);
|
|
1339
|
+
if (flags.has("--json")) console.log(JSON.stringify({
|
|
1340
|
+
stale: stale.map((s) => ({
|
|
1341
|
+
...s,
|
|
1342
|
+
file: relative(process.cwd(), s.file)
|
|
1343
|
+
})),
|
|
1344
|
+
errors
|
|
1345
|
+
}, null, 2));
|
|
1346
|
+
else {
|
|
1347
|
+
for (const s of stale) console.error(`${relative(process.cwd(), s.file)}:1:1 error [stale-generated] The generated file is ${s.reason}. Fix: run ts-dom gen.`);
|
|
1348
|
+
for (const e of errors) console.error(e);
|
|
1349
|
+
if (!stale.length && !errors.length) console.log("ts-dom gen --check: generated files are up to date");
|
|
1350
|
+
}
|
|
1351
|
+
if (stale.length || errors.length) process.exitCode = 1;
|
|
1352
|
+
} else if (cmd === "gen") {
|
|
72
1353
|
let failed = false;
|
|
73
1354
|
for (const d of dirs) try {
|
|
74
1355
|
generateAll(d, generate);
|
|
@@ -86,8 +1367,30 @@ try {
|
|
|
86
1367
|
});
|
|
87
1368
|
await new Promise(() => {});
|
|
88
1369
|
}
|
|
1370
|
+
} else if (cmd === "check") {
|
|
1371
|
+
const project = args.find((a) => a.startsWith("--project="))?.slice(10);
|
|
1372
|
+
const issues = runCheck({
|
|
1373
|
+
dirs,
|
|
1374
|
+
generate,
|
|
1375
|
+
write: !flags.has("--no-write"),
|
|
1376
|
+
a11y: flags.has("--a11y"),
|
|
1377
|
+
...project ? { project } : {}
|
|
1378
|
+
});
|
|
1379
|
+
if (flags.has("--json")) console.log(JSON.stringify({ issues }, null, 2));
|
|
1380
|
+
else for (const i of issues) (i.severity === "error" ? console.error : console.warn)(formatIssue(i));
|
|
1381
|
+
if (issues.filter((i) => i.severity === "error").length || flags.has("--strict") && issues.length) process.exitCode = 1;
|
|
1382
|
+
if (!flags.has("--json") && !issues.length) console.log("ts-dom check: no issues");
|
|
1383
|
+
} else if (cmd === "doctor") {
|
|
1384
|
+
const issues = runDoctor({
|
|
1385
|
+
dirs,
|
|
1386
|
+
generate
|
|
1387
|
+
});
|
|
1388
|
+
if (flags.has("--json")) console.log(JSON.stringify({ issues }, null, 2));
|
|
1389
|
+
else for (const i of issues) (i.severity === "error" ? console.error : i.severity === "warning" ? console.warn : console.log)(formatDoctorIssue(i));
|
|
1390
|
+
if (issues.some((i) => i.severity === "error")) process.exitCode = 1;
|
|
1391
|
+
if (!flags.has("--json") && !issues.some((i) => i.severity !== "info")) console.log("ts-dom doctor: the set-up looks right");
|
|
89
1392
|
} else if (cmd === "lint") {
|
|
90
|
-
const files = dirs.flatMap(
|
|
1393
|
+
const files = dirs.flatMap(listTs);
|
|
91
1394
|
const issues = [...lintUsage(dirs, files), ...lintDataFlow(dirs, files)];
|
|
92
1395
|
if (flags.has("--a11y")) for (const dir of dirs) for (const f of listHtml(dir)) for (const d of checkA11y(readFileSync(f, "utf8"))) issues.push({
|
|
93
1396
|
file: relative(process.cwd(), f),
|
|
@@ -99,6 +1402,7 @@ try {
|
|
|
99
1402
|
if (issues.filter((i) => i.severity === "error").length || flags.has("--strict") && issues.length) process.exitCode = 1;
|
|
100
1403
|
if (!flags.has("--json") && !issues.length) console.log("ts-dom lint: no issues");
|
|
101
1404
|
} else if (cmd === "rules") printRules();
|
|
1405
|
+
else if (cmd === "skills") skillsCommand();
|
|
102
1406
|
else {
|
|
103
1407
|
console.log(HELP);
|
|
104
1408
|
if (cmd && cmd !== "--help" && cmd !== "-h" && cmd !== "help") process.exitCode = 1;
|