@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/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, p as watchAll, r as CODEGEN_RULES, s as generateAll, t as A11Y_RULES } from "./a11y-Bn9pDvWV.mjs";
3
- import { c as lintUsage, n as UNUSED_MARKUP_RULES, o as formatIssue, s as lintDataFlow, t as DATA_FLOW_RULES } from "./lint-CqWk4U5P.mjs";
4
- import { readFileSync, readdirSync, statSync } from "node:fs";
5
- import { join, relative } from "node:path";
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 tsFiles(dir) {
25
- return readdirSync(dir).flatMap((n) => {
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
- for (const [name, note, rules] of groups) {
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(24)} ${desc}`);
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: flags.has("--whitespace=collapse") ? "collapse" : "preserve"
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(tsFiles);
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;