@nativele/ts-dom 0.3.0 → 0.4.6

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,1173 @@
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-CeZcA10z.mjs";
3
- import { c as lintUsage, n as UNUSED_MARKUP_RULES, o as formatIssue, s as lintDataFlow, t as DATA_FLOW_RULES } from "./lint-DS-IOjkf.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-CP_i4VPA.mjs";
4
+ import { createRequire } from "node:module";
5
+ import { existsSync, readFileSync, statSync } from "node:fs";
6
+ import { dirname, join, relative, resolve } from "node:path";
7
+ import { spawnSync } from "node:child_process";
8
+ // src/lib/tsconfig.ts
9
+ /** tsconfig is JSONC: drop comments and trailing commas before parsing. */
10
+ function parseJsonc(text) {
11
+ const noComments = text.replace(/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, str) => str ?? "");
12
+ return JSON.parse(noComments.replace(/,(\s*[}\]])/g, "$1"));
13
+ }
14
+ function resolveExtends(from, ext) {
15
+ const dir = dirname(from);
16
+ if (ext.startsWith(".")) {
17
+ const target = resolve(dir, ext);
18
+ return existsSync(target) ? target : existsSync(`${target}.json`) ? `${target}.json` : null;
19
+ }
20
+ const req = createRequire(from);
21
+ for (const spec of [
22
+ ext,
23
+ `${ext}.json`,
24
+ `${ext}/tsconfig.json`
25
+ ]) try {
26
+ return req.resolve(spec);
27
+ } catch {}
28
+ return null;
29
+ }
30
+ /** Read a tsconfig and the ones it extends (a relative path, a package, or an array of them), the later and nearer one winning. */
31
+ function readTsConfig(file, depth = 0) {
32
+ const json = parseJsonc(readFileSync(file, "utf8"));
33
+ let merged = { compilerOptions: {} };
34
+ const exts = typeof json.extends === "string" ? [json.extends] : Array.isArray(json.extends) ? json.extends : [];
35
+ for (const ext of depth < 5 ? exts : []) {
36
+ const target = resolveExtends(file, ext);
37
+ if (!target) continue;
38
+ const base = readTsConfig(target, depth + 1);
39
+ merged = {
40
+ compilerOptions: {
41
+ ...merged.compilerOptions,
42
+ ...base.compilerOptions
43
+ },
44
+ ...base.include ?? merged.include ? { include: base.include ?? merged.include } : {}
45
+ };
46
+ }
47
+ const own = json.include;
48
+ const include = own ? own.map((p) => resolve(dirname(file), p).split("\\").join("/")) : merged.include;
49
+ return {
50
+ compilerOptions: {
51
+ ...merged.compilerOptions,
52
+ ...json.compilerOptions
53
+ },
54
+ ...include ? { include } : {}
55
+ };
56
+ }
57
+ /**
58
+ * The tsconfig files tsc would check for a project: `project`, or tsconfig.json in `cwd`.
59
+ * A solution-style config (`"files": []` with `references`) checks nothing itself, so its referenced projects are returned instead.
60
+ */
61
+ function projectConfigs(cwd, project) {
62
+ const root = resolve(cwd, project ?? "tsconfig.json");
63
+ if (!existsSync(root)) return [];
64
+ const json = parseJsonc(readFileSync(root, "utf8"));
65
+ const refs = Array.isArray(json.references) ? json.references.map((r) => r.path).filter((p) => typeof p === "string") : [];
66
+ if (!(refs.length > 0 && Array.isArray(json.files) && json.files.length === 0 && json.include === void 0)) return [root];
67
+ return refs.map((ref) => {
68
+ const abs = resolve(dirname(root), ref);
69
+ return existsSync(abs) && statSync(abs).isDirectory() ? join(abs, "tsconfig.json") : abs;
70
+ }).filter((f) => existsSync(f));
71
+ }
72
+ // src/lib/check.ts
73
+ const CHECK_RULES = {
74
+ "stale-generated": "a generated file is missing, outdated or orphaned, so the type errors may not match the templates",
75
+ "missing-binding": "a {{ hole }} or data-if names nothing in state, props or derive (found by tsc)",
76
+ "conflicting-keys": "the same name is declared in more than one of state, props and derive (found by tsc)",
77
+ "reserved-prop": "a prop is named like a member of HTMLElement or ts-dom (found by tsc)",
78
+ "native-event-name": "an emitted event is named like a native DOM event (found by tsc)",
79
+ "item-prop-missing": "the data-prop of a list is not a prop of its item component (found by tsc)",
80
+ "item-not-in-tag-map": "the item component is defined in a scanned folder but missing from ts-dom-tags.ts (found by tsc)",
81
+ "item-class-not-exported": "the item component class passed to define() is not exported (found by tsc)",
82
+ tsc: "any other TypeScript error, as tsc reports it",
83
+ "tsc-not-found": "TypeScript is not installed in the project, so types were not checked",
84
+ "dir-not-found": "a folder given on the command line does not exist, so nothing in it was checked",
85
+ "tsc-failed": "tsc did not run to completion (no tsconfig, a bad option, a crash), so types were not checked"
86
+ };
87
+ /** Parse `tsc --pretty false` output; indented lines continue the previous message. */
88
+ function parseTscOutput(out) {
89
+ const errors = [];
90
+ for (const line of out.split(/\r?\n/)) {
91
+ const m = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
92
+ if (m) errors.push({
93
+ file: m[1],
94
+ line: Number(m[2]),
95
+ col: Number(m[3]),
96
+ code: m[4],
97
+ message: m[5]
98
+ });
99
+ else if (/^\s+\S/.test(line) && errors.length) errors.at(-1).message += `\n${line.trim()}`;
100
+ }
101
+ return errors;
102
+ }
103
+ const CONTRACT = [
104
+ [
105
+ "conflictingKeys",
106
+ "conflicting-keys",
107
+ "keep each name in only one of state, props and derive"
108
+ ],
109
+ [
110
+ "reservedPropNames",
111
+ "reserved-prop",
112
+ "rename the prop (for example title -> titleText)"
113
+ ],
114
+ [
115
+ "nativeEventNames",
116
+ "native-event-name",
117
+ "rename the event, for example with a prefix such as item-"
118
+ ],
119
+ [
120
+ "dataPropNotOnItemComponent",
121
+ "item-prop-missing",
122
+ "declare the property in the item component's props, or change data-prop"
123
+ ],
124
+ [
125
+ "itemComponentMissingFromTagMap",
126
+ "item-not-in-tag-map",
127
+ "run ts-dom gen so ts-dom-tags.ts lists the item component"
128
+ ],
129
+ [
130
+ "itemComponentClassNotExported",
131
+ "item-class-not-exported",
132
+ "export the class passed to define() so its prop types can be read"
133
+ ]
134
+ ];
135
+ const namesAfter = (message, key) => {
136
+ const m = new RegExp(`${key}: ((?:"[^"]+"(?:\\s*\\|\\s*)?)+)`).exec(message);
137
+ return m ? [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) : [];
138
+ };
139
+ const templatesOf = (tsFile) => {
140
+ if (!existsSync(tsFile)) return [];
141
+ return [...readFileSync(tsFile, "utf8").matchAll(/from\s+["'](\.{1,2}\/[^"']+\.html)\.js["']/g)].map((m) => resolve(dirname(tsFile), m[1])).filter((f) => existsSync(f));
142
+ };
143
+ /**
144
+ * 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.
145
+ * Other errors are kept as `tsc` issues.
146
+ */
147
+ function mapTscErrors(errors, cwd) {
148
+ const rel = (f) => relative(cwd, f);
149
+ const out = [];
150
+ const analyses = /* @__PURE__ */ new Map();
151
+ const analysisOf = (html) => {
152
+ let a = analyses.get(html);
153
+ if (!a) analyses.set(html, a = analyze(readFileSync(html, "utf8")));
154
+ return a;
155
+ };
156
+ const locate = (tsFile, keys) => {
157
+ for (const html of templatesOf(tsFile)) for (const key of keys) {
158
+ const loc = analysisOf(html).locs.get(key);
159
+ if (loc) return {
160
+ file: html,
161
+ ...loc
162
+ };
163
+ }
164
+ };
165
+ const brokenClasses = /* @__PURE__ */ new Set();
166
+ for (const e of errors) {
167
+ if (!/missingStateKeys|conflictingKeys|reservedPropNames|nativeEventNames/.test(e.message)) continue;
168
+ const line = readFileSync(resolve(cwd, e.file), "utf8").split("\n")[e.line - 1] ?? "";
169
+ const name = /class\s+([A-Za-z_$][\w$]*)\s+extends\s+Component\s*\(/.exec(line)?.[1];
170
+ if (name) brokenClasses.add(name);
171
+ }
172
+ const followsBroken = (message) => [...brokenClasses].some((c) => message.includes(`'${c}'`) || message.includes(`'typeof ${c}'`));
173
+ for (const e of errors) {
174
+ const file = resolve(cwd, e.file);
175
+ const where = `${rel(file)}:${e.line}:${e.col}`;
176
+ const issue = (rule, message, at) => out.push({
177
+ ...at ?? {
178
+ file: rel(file),
179
+ line: e.line,
180
+ col: e.col
181
+ },
182
+ rule,
183
+ severity: "error",
184
+ message
185
+ });
186
+ let named = false;
187
+ for (const name of namesAfter(e.message, "missingStateKeys")) {
188
+ named = true;
189
+ const found = locate(file, [`text:${name}`, `if:${name}`]);
190
+ 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 && {
191
+ file: rel(found.file),
192
+ line: found.line,
193
+ col: found.col
194
+ });
195
+ }
196
+ for (const name of namesAfter(e.message, "missingHandlers")) {
197
+ named = true;
198
+ const found = locate(file, [`event:${name}`]);
199
+ 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 && {
200
+ file: rel(found.file),
201
+ line: found.line,
202
+ col: found.col
203
+ });
204
+ }
205
+ for (const [key, rule, fix] of CONTRACT) {
206
+ if (!e.message.includes(key)) continue;
207
+ named = true;
208
+ issue(rule, `${e.message.split("\n")[0]} Fix: ${fix}.`);
209
+ }
210
+ if (!named) issue("tsc", `${e.code}: ${e.message} Fix: change the TypeScript at this location so the types agree; ts-dom rules --explain=tsc.`);
211
+ }
212
+ return out.filter((i) => i.rule !== "tsc" || !followsBroken(i.message));
213
+ }
214
+ function findTsc(cwd) {
215
+ try {
216
+ const pkgPath = createRequire(join(cwd, "package.json")).resolve("typescript/package.json");
217
+ const bin = JSON.parse(readFileSync(pkgPath, "utf8")).bin?.tsc;
218
+ return bin ? resolve(dirname(pkgPath), bin) : null;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+ /**
224
+ * Everything an agent needs in one pass: template diagnostics, generated files, TypeScript errors mapped back to the templates, and the lint rules.
225
+ * Returns the issues sorted by file and position.
226
+ */
227
+ function runCheck(options) {
228
+ const cwd = options.cwd ?? process.cwd();
229
+ const generate = options.generate ?? {};
230
+ const issues = [];
231
+ const rel = (f) => relative(cwd, f);
232
+ const dirs = [];
233
+ for (const d of options.dirs.map((x) => resolve(cwd, x))) if (existsSync(d)) dirs.push(d);
234
+ else issues.push({
235
+ file: rel(d) || ".",
236
+ line: 1,
237
+ col: 1,
238
+ rule: "dir-not-found",
239
+ severity: "error",
240
+ 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).`
241
+ });
242
+ for (const dir of dirs) for (const f of listHtml(dir)) {
243
+ if (!isSelected(dir, f, generate)) continue;
244
+ for (const d of analyze(readFileSync(f, "utf8")).diagnostics) issues.push({
245
+ file: rel(f),
246
+ ...d
247
+ });
248
+ }
249
+ for (const dir of dirs) try {
250
+ renderTags(dir);
251
+ } catch (e) {
252
+ if (!(e instanceof CodegenError)) throw e;
253
+ issues.push({
254
+ file: rel(dir),
255
+ line: 1,
256
+ col: 1,
257
+ rule: "duplicate-define",
258
+ severity: "error",
259
+ message: e.message.replace(/^[^[]*\[duplicate-define\]\s*/, "")
260
+ });
261
+ }
262
+ if (options.write === false) for (const dir of dirs) for (const s of staleGenerated(dir, generate)) issues.push({
263
+ file: rel(s.file),
264
+ line: 1,
265
+ col: 1,
266
+ rule: "stale-generated",
267
+ severity: "error",
268
+ 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).`
269
+ });
270
+ else for (const dir of dirs) try {
271
+ generateAll(dir, generate);
272
+ } catch (e) {
273
+ if (!(e instanceof CodegenError)) throw e;
274
+ }
275
+ const tsc = findTsc(cwd);
276
+ if (!tsc) issues.push({
277
+ file: ".",
278
+ line: 1,
279
+ col: 1,
280
+ rule: "tsc-not-found",
281
+ severity: "warning",
282
+ message: "TypeScript is not installed in this project, so types were not checked. Fix: npm i -D typescript."
283
+ });
284
+ else {
285
+ const configs = projectConfigs(cwd, options.project);
286
+ const runs = configs.length ? configs.map((c) => ["-p", c]) : [options.project ? ["-p", options.project] : []];
287
+ const parsed = [];
288
+ for (const extra of runs) {
289
+ const run = spawnSync(process.execPath, [
290
+ tsc,
291
+ "--noEmit",
292
+ "--pretty",
293
+ "false",
294
+ ...extra
295
+ ], {
296
+ cwd,
297
+ encoding: "utf8",
298
+ maxBuffer: 268435456
299
+ });
300
+ const output = `${run.stdout ?? ""}\n${run.stderr ?? ""}`;
301
+ const errors = parseTscOutput(output);
302
+ parsed.push(...errors);
303
+ if (run.error || run.status !== 0 && !errors.length) {
304
+ const detail = run.error ? String(run.error.message) : output.trim().split("\n").slice(0, 5).join(" ");
305
+ issues.push({
306
+ file: extra[1] ? rel(extra[1]) : ".",
307
+ line: 1,
308
+ col: 1,
309
+ rule: "tsc-failed",
310
+ severity: "error",
311
+ message: `tsc did not check the types: ${detail} Fix: make \`npx tsc --noEmit\` run in this folder (a tsconfig.json, or --project=<path>).`
312
+ });
313
+ }
314
+ }
315
+ issues.push(...mapTscErrors(parsed, cwd));
316
+ }
317
+ const tsFiles = dirs.flatMap(listTs);
318
+ const lint = [...lintUsage(dirs, tsFiles), ...lintDataFlow(dirs, tsFiles)].map((i) => ({
319
+ ...i,
320
+ file: rel(resolve(cwd, i.file))
321
+ }));
322
+ for (const i of lint) {
323
+ 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;
324
+ issues.push(i);
325
+ }
326
+ if (options.a11y) for (const dir of dirs) for (const f of listHtml(dir)) for (const d of checkA11y(readFileSync(f, "utf8"))) issues.push({
327
+ file: rel(f),
328
+ ...d
329
+ });
330
+ const seen = /* @__PURE__ */ new Set();
331
+ return issues.filter((i) => {
332
+ const k = `${i.file}:${i.line}:${i.col}:${i.rule}:${i.message}`;
333
+ return !seen.has(k) && seen.add(k) !== void 0;
334
+ }).sort((x, y) => x.file.localeCompare(y.file) || x.line - y.line || x.col - y.col);
335
+ }
336
+ // src/lib/doctor.ts
337
+ const DOCTOR_RULES = {
338
+ "dir-not-found": "a folder given on the command line does not exist, so nothing in it was checked",
339
+ "no-tsconfig": "there is no tsconfig.json, so tsc cannot check the templates against the components",
340
+ "tsconfig-not-strict": "strict is off, so tsc lets wrong names and null values through",
341
+ "tsconfig-no-dom-lib": "lib leaves out DOM, so element types are missing",
342
+ "tsconfig-classic-resolution": "moduleResolution classic cannot resolve ./x.html.js to the generated x.html.ts",
343
+ "tsconfig-misses-templates": "include leaves out a folder with templates, so their generated types are not checked",
344
+ "runtime-not-installed": "the runtime package the generated files import cannot be resolved",
345
+ "template-import-without-js": "a template is imported as ./x.html instead of ./x.html.js",
346
+ "stale-generated": "a generated file is missing, outdated or orphaned",
347
+ "vite-plugin-missing": "the Vite config does not use the ts-dom plugin, so templates are not regenerated while the dev server runs",
348
+ "oxlint-plugin-missing": "the lint config does not load the ts-dom oxlint plugin, so the template rules do not run in the editor",
349
+ "formatter-whitespace": "a formatter is set up while templates keep their whitespace, so reformatting can change the rendered spacing"
350
+ };
351
+ const readText = (file) => existsSync(file) ? readFileSync(file, "utf8") : null;
352
+ /** Whether an include pattern (absolute, forward slashes) makes tsc read the files directly in `dir`. */
353
+ function covers(pattern, dir) {
354
+ const d = dir.split("\\").join("/");
355
+ const star = pattern.indexOf("*");
356
+ if (star === -1) return /\.[cm]?tsx?$/.test(pattern) ? false : d === pattern || d.startsWith(`${pattern}/`);
357
+ const prefix = pattern.slice(0, star).replace(/\/$/, "");
358
+ if (pattern.slice(star).startsWith("**")) return d === prefix || d.startsWith(`${prefix}/`);
359
+ return d === prefix;
360
+ }
361
+ const templateFolders = (dir) => [...new Set(listHtml(dir).map((f) => dirname(f)))];
362
+ const hasTemplates = (dir) => listHtml(dir).length > 0;
363
+ /** Check the project set-up an agent or a developer could get wrong; every issue names its rule and ends with a Fix hint. */
364
+ function runDoctor(options) {
365
+ const cwd = options.cwd ?? process.cwd();
366
+ const generate = options.generate ?? {};
367
+ const runtime = generate.runtime ?? "@nativele/ts-dom";
368
+ const rel = (f) => relative(cwd, f) || ".";
369
+ const issues = [];
370
+ const add = (rule, severity, message, at = {}) => issues.push({
371
+ rule,
372
+ severity,
373
+ message,
374
+ ...at
375
+ });
376
+ const dirs = [];
377
+ for (const d of options.dirs.map((x) => resolve(cwd, x))) if (existsSync(d)) dirs.push(d);
378
+ 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) });
379
+ const configs = projectConfigs(cwd);
380
+ let paths = {};
381
+ 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\").");
382
+ const includes = [];
383
+ let everyConfigIncludes = true;
384
+ for (const configPath of configs) {
385
+ const config = readTsConfig(configPath);
386
+ const o = config.compilerOptions;
387
+ const file = rel(configPath);
388
+ paths = {
389
+ ...paths,
390
+ ...o.paths
391
+ };
392
+ 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 });
393
+ const lib = o.lib;
394
+ 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 });
395
+ 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 });
396
+ if (config.include) includes.push(...config.include);
397
+ else everyConfigIncludes = false;
398
+ }
399
+ if (configs.length && everyConfigIncludes) {
400
+ const missed = /* @__PURE__ */ new Set();
401
+ for (const d of dirs) for (const folder of templateFolders(d)) if (!includes.some((p) => covers(p, folder))) missed.add(rel(folder));
402
+ 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]) });
403
+ }
404
+ let resolvable = Object.keys(paths).includes(runtime);
405
+ if (!resolvable) try {
406
+ createRequire(join(cwd, "package.json")).resolve(`${runtime}/package.json`);
407
+ resolvable = true;
408
+ } catch {
409
+ resolvable = false;
410
+ }
411
+ if (!resolvable) add("runtime-not-installed", "error", `The generated files import "${runtime}", which cannot be resolved from this project. Fix: npm i ${runtime}.`);
412
+ for (const d of dirs) {
413
+ for (const f of listTs(d)) readFileSync(f, "utf8").split("\n").forEach((text, i) => {
414
+ const m = /from\s+["'](\.{1,2}\/[^"']+\.html)["']/.exec(text);
415
+ 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).`, {
416
+ file: rel(f),
417
+ line: i + 1
418
+ });
419
+ });
420
+ 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) });
421
+ }
422
+ const viteConfig = [
423
+ "vite.config.ts",
424
+ "vite.config.mts",
425
+ "vite.config.js",
426
+ "vite.config.mjs"
427
+ ].map((n) => join(cwd, n)).find((f) => existsSync(f));
428
+ const vite = viteConfig ? readText(viteConfig) : null;
429
+ 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) });
430
+ const oxlintrc = [".oxlintrc.json", "oxlintrc.json"].map((n) => join(cwd, n)).find((f) => existsSync(f));
431
+ const lintConfigs = [oxlintrc ? readText(oxlintrc) : null, vite && /\blint\s*:/.test(vite) ? vite : null].filter((t) => t !== null);
432
+ 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) });
433
+ const pkgText = readText(join(cwd, "package.json"));
434
+ const pkg = pkgText ? JSON.parse(pkgText) : {};
435
+ const deps = {
436
+ ...pkg.dependencies,
437
+ ...pkg.devDependencies
438
+ };
439
+ const scripts = Object.values(pkg.scripts ?? {});
440
+ const ignoredHtml = /(^|\n)\s*(\*\*\/)?\*\.html\s*($|\n)/.test(readText(join(cwd, ".prettierignore")) ?? "");
441
+ 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 && ([
442
+ ".prettierrc",
443
+ ".prettierrc.json",
444
+ ".prettierrc.js",
445
+ "prettier.config.js",
446
+ "prettier.config.mjs"
447
+ ].some((n) => existsSync(join(cwd, n))) || "prettier" in deps || scripts.some((c) => /(^|[\s;&|])prettier(\s|$)/.test(c)));
448
+ 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")));
449
+ 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\" })).");
450
+ return issues;
451
+ }
452
+ function formatDoctorIssue(i) {
453
+ return `${i.file ?? "."}:${i.line ?? 1}:1 ${i.severity} [${i.rule}] ${i.message}`;
454
+ }
455
+ // src/lib/rule-docs.ts
456
+ const RULE_DOCS = {
457
+ "invalid-hole": {
458
+ 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.",
459
+ bad: `<!-- card.html -->
460
+ <h2>{{ user.name }}</h2>
461
+ <p>{{ count + 1 }}</p>`,
462
+ good: `<!-- card.html -->
463
+ <h2>{{ userName }}</h2>
464
+ <p>{{ nextCount }}</p>
465
+ // card.ts: compute the value in TypeScript, then fill the named hole
466
+ this.set({ userName: user.name, nextCount: count + 1 });`
467
+ },
468
+ "attr-hole": {
469
+ 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.",
470
+ bad: `<!-- card.html -->
471
+ <a href="/user/{{ id }}">profile</a>`,
472
+ good: `<!-- card.html -->
473
+ <a data-ref="link">profile</a>
474
+ // card.ts
475
+ this.refs.link.href = "/user/" + id;`
476
+ },
477
+ "invalid-event-name": {
478
+ 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.",
479
+ bad: `<form data-on-submit.prevent="save">...</form>`,
480
+ good: `<!-- form.html -->
481
+ <form data-on-submit="save">...</form>
482
+ // form.ts
483
+ save(e: Event): void { e.preventDefault(); }`
484
+ },
485
+ "invalid-handler": {
486
+ 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.",
487
+ bad: `<button data-on-click="removeItem(item.id)">x</button>`,
488
+ good: `<button data-on-click="removeItem">x</button>
489
+ // card.ts: inside a list, hit.key is the item's key
490
+ removeItem(_e: Event, hit: EventHit) { this.set((s) => ({ rows: s.rows.filter((r) => r.id !== hit.key) })); }`
491
+ },
492
+ "reserved-handler": {
493
+ 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.",
494
+ bad: `<button data-on-click="render">refresh</button>`,
495
+ good: `<button data-on-click="refresh">refresh</button>`
496
+ },
497
+ "empty-ref": {
498
+ why: "data-ref names an element so TypeScript can reach it through this.refs. An empty name gives codegen nothing to generate.",
499
+ bad: `<input data-ref="">`,
500
+ good: `<input data-ref="name">
501
+ // card.ts
502
+ this.refs.name.focus();`
503
+ },
504
+ "duplicate-ref": {
505
+ 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.",
506
+ bad: `<button data-ref="btn">ok</button>
507
+ <button data-ref="btn">cancel</button>`,
508
+ good: `<button data-ref="ok">ok</button>
509
+ <button data-ref="cancel">cancel</button>`
510
+ },
511
+ "empty-if": {
512
+ why: "data-if names a condition that TypeScript toggles through this.set. Without a name there is nothing to toggle.",
513
+ bad: `<p data-if="">Saved</p>`,
514
+ good: `<p data-if="saved">Saved</p>
515
+ // card.ts
516
+ this.set({ saved: true });`
517
+ },
518
+ "if-on-item-root": {
519
+ 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.",
520
+ bad: `<template data-each="rows">
521
+ <li data-if="visible">{{ label }}</li>
522
+ </template>`,
523
+ good: `<template data-each="rows">
524
+ <li><span data-if="visible">{{ label }}</span></li>
525
+ </template>`
526
+ },
527
+ "slot-in-list": {
528
+ 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.",
529
+ bad: `<template data-each="rows">
530
+ <li><slot></slot></li>
531
+ </template>`,
532
+ good: `<slot></slot>
533
+ <template data-each="rows">
534
+ <li>{{ label }}</li>
535
+ </template>`
536
+ },
537
+ "style-not-top-level": {
538
+ 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.",
539
+ bad: `<div>
540
+ <style>p { color: red; }</style>
541
+ <p>hi</p>
542
+ </div>`,
543
+ good: `<style>p { color: red; }</style>
544
+ <div>
545
+ <p>hi</p>
546
+ </div>`
547
+ },
548
+ "duplicate-slot": {
549
+ 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.",
550
+ bad: `<slot name="actions"></slot>
551
+ <slot name="actions"></slot>`,
552
+ good: `<slot name="actions"></slot>
553
+ <slot name="footer"></slot>`
554
+ },
555
+ "each-not-template": {
556
+ why: "A list needs an inert prototype that is cloned for each item. Only a <template> provides that without being rendered itself.",
557
+ bad: `<ul data-each="todos">
558
+ <li>{{ title }}</li>
559
+ </ul>`,
560
+ good: `<ul>
561
+ <template data-each="todos">
562
+ <li>{{ title }}</li>
563
+ </template>
564
+ </ul>`
565
+ },
566
+ "empty-each": {
567
+ why: "The data-each name becomes this.lists.<name>. An empty name gives codegen no list to generate.",
568
+ bad: `<template data-each="">
569
+ <li>{{ title }}</li>
570
+ </template>`,
571
+ good: `<template data-each="todos">
572
+ <li>{{ title }}</li>
573
+ </template>`
574
+ },
575
+ "nested-each": {
576
+ 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.",
577
+ bad: `<template data-each="groups">
578
+ <li>
579
+ <template data-each="items"><span>{{ label }}</span></template>
580
+ </li>
581
+ </template>`,
582
+ good: `<template data-each="groups" data-key="id" data-prop="group">
583
+ <group-row></group-row>
584
+ </template>
585
+ <!-- group-row.html has its own data-each="items" -->`
586
+ },
587
+ "duplicate-each": {
588
+ why: "Each data-each name becomes one entry of this.lists, so two lists with one name would be the same list in TypeScript.",
589
+ bad: `<template data-each="rows"><li>{{ a }}</li></template>
590
+ <template data-each="rows"><li>{{ b }}</li></template>`,
591
+ good: `<template data-each="rows"><li>{{ a }}</li></template>
592
+ <template data-each="extras"><li>{{ b }}</li></template>`
593
+ },
594
+ "each-binding-incomplete": {
595
+ 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.",
596
+ bad: `<template data-each="tasks" data-key="id">
597
+ <task-row></task-row>
598
+ </template>`,
599
+ good: `<template data-each="tasks" data-key="id" data-prop="task">
600
+ <task-row></task-row>
601
+ </template>`
602
+ },
603
+ "invalid-each-binding": {
604
+ 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.",
605
+ bad: `<template data-each="tasks" data-key="meta.id" data-prop="the-task">
606
+ <task-row></task-row>
607
+ </template>`,
608
+ good: `<template data-each="tasks" data-key="id" data-prop="task">
609
+ <task-row></task-row>
610
+ </template>`
611
+ },
612
+ "each-binding-root": {
613
+ 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.",
614
+ bad: `<template data-each="tasks" data-key="id" data-prop="task">
615
+ <li>{{ title }}</li>
616
+ </template>`,
617
+ good: `<template data-each="tasks" data-key="id" data-prop="task">
618
+ <task-row></task-row>
619
+ </template>`
620
+ },
621
+ "each-root-count": {
622
+ 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.",
623
+ bad: `<template data-each="rows">
624
+ <dt>{{ term }}</dt>
625
+ <dd>{{ detail }}</dd>
626
+ </template>`,
627
+ good: `<template data-each="rows">
628
+ <div><dt>{{ term }}</dt><dd>{{ detail }}</dd></div>
629
+ </template>`
630
+ },
631
+ "document-tag": {
632
+ why: "A template is parsed as a fragment, and the browser drops <html>, <head> and <body> from fragments, so the markup would silently vanish.",
633
+ bad: `<body>
634
+ <h1>{{ title }}</h1>
635
+ </body>`,
636
+ good: `<h1>{{ title }}</h1>`
637
+ },
638
+ "forbidden-tag": {
639
+ why: "A script in a template would run code from markup. Behavior belongs to TypeScript, and pages with Trusted Types enabled would block it.",
640
+ bad: `<script>alert(1)<\/script>`,
641
+ good: `// card.ts
642
+ connected() { /* put the behavior in the class */ }`
643
+ },
644
+ "forbidden-attribute": {
645
+ 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.",
646
+ bad: `<button onclick="save()">save</button>`,
647
+ good: `<button data-on-click="save">save</button>`
648
+ },
649
+ "unsafe-url": {
650
+ 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.",
651
+ bad: `<a href="javascript:void(0)">more</a>`,
652
+ good: `<button data-on-click="more">more</button>`
653
+ },
654
+ "duplicate-define": {
655
+ 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.",
656
+ bad: `// a.ts
657
+ define("user-card", CardA);
658
+ // b.ts
659
+ define("user-card", CardB);`,
660
+ good: `// a.ts
661
+ define("user-card", CardA);
662
+ // b.ts
663
+ define("user-badge", CardB);`
664
+ },
665
+ "unused-ref": {
666
+ 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.",
667
+ bad: `<!-- card.html -->
668
+ <input data-ref="name">
669
+ // card.ts does not use this.refs.name`,
670
+ good: `<input data-ref="name">
671
+ // card.ts
672
+ this.refs.name.focus();`
673
+ },
674
+ "unused-if": {
675
+ why: "A data-if that is never toggled stays in its initial state forever, which usually means the condition was never wired up.",
676
+ bad: `<p data-if="error">Failed</p>
677
+ // card.ts never sets "error"`,
678
+ good: `<p data-if="error">Failed</p>
679
+ // card.ts
680
+ this.set({ error: true });`
681
+ },
682
+ "unused-text": {
683
+ why: "A hole that no code fills renders empty, so the page shows nothing where a value was expected.",
684
+ bad: `<h2>{{ title }}</h2>
685
+ // card.ts never sets "title"`,
686
+ good: `<h2>{{ title }}</h2>
687
+ // card.ts
688
+ this.set({ title: "Hello" });`
689
+ },
690
+ "unused-list": {
691
+ why: "A data-each that the class never assigns or fills always renders an empty list.",
692
+ bad: `<template data-each="rows"><li>{{ label }}</li></template>
693
+ // card.ts never uses this.lists.rows`,
694
+ good: `<template data-each="rows"><li>{{ label }}</li></template>
695
+ // card.ts
696
+ this.lists.rows.replace(rows, (r) => r.id, (r, { text }) => (text.label = r.label));`
697
+ },
698
+ "unused-item-name": {
699
+ why: "A name inside a list item that no code fills or reads is dead markup, usually left over after the item shape changed.",
700
+ bad: `<template data-each="rows"><li>{{ label }} {{ note }}</li></template>
701
+ // card.ts
702
+ this.lists.rows.replace(rows, (r) => r.id, (r, { text }) => (text.label = r.label));`,
703
+ good: `<template data-each="rows"><li>{{ label }}</li></template>
704
+ // card.ts
705
+ this.lists.rows.replace(rows, (r) => r.id, (r, { text }) => (text.label = r.label));`
706
+ },
707
+ "missing-handler": {
708
+ 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.",
709
+ bad: `<button data-on-click="save">save</button>
710
+ // card.ts defines no save()`,
711
+ good: `<button data-on-click="save">save</button>
712
+ // card.ts
713
+ save() { /* ... */ }`
714
+ },
715
+ "no-importer": {
716
+ why: "A template that no TypeScript file imports never becomes a component, so it is dead code or an import that was forgotten.",
717
+ bad: `// card.html exists, but no .ts file contains
718
+ // import tpl from "./card.html.js"`,
719
+ good: `// card.ts
720
+ import tpl from "./card.html.js";`
721
+ },
722
+ "prop-forwarding": {
723
+ 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.",
724
+ bad: `// panel.ts (props: { name: string })
725
+ render(view) { this.refs.kid.name = view.name; }`,
726
+ good: `// panel.ts: fill the child from data this component owns
727
+ render(view) { this.refs.kid.name = this.state.label; }`
728
+ },
729
+ "emit-forwarding": {
730
+ 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.",
731
+ bad: `// panel.ts
732
+ onChange(e) { this.emit("change", e.detail); }`,
733
+ good: `// panel.ts
734
+ onChange(e) { this.set({ value: e.detail.value }); }`
735
+ },
736
+ "props-mutation": {
737
+ 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.",
738
+ bad: `// counter.ts (props: { count: number })
739
+ inc() { this.count = this.count + 1; }`,
740
+ good: `// counter.ts
741
+ inc() { this.emit("count-change", { value: this.props.count + 1 }); }`
742
+ },
743
+ "custom-event-on-native": {
744
+ 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.",
745
+ bad: `<div data-on-quantity-change="update">
746
+ <qty-input></qty-input>
747
+ </div>`,
748
+ good: `<div>
749
+ <qty-input data-on-quantity-change="update"></qty-input>
750
+ </div>`
751
+ },
752
+ "unknown-prop": {
753
+ 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.",
754
+ bad: `<x-kid lable="hi"></x-kid>`,
755
+ good: `<x-kid label="hi"></x-kid>
756
+ // x-kid.ts: Component(tpl, { props: { label: "" } })`
757
+ },
758
+ "invalid-prop-value": {
759
+ 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.",
760
+ bad: `<x-list items="a,b"></x-list>
761
+ // x-list.ts: props: { items: [] as string[] }`,
762
+ good: `<x-list data-ref="list"></x-list>
763
+ // panel.ts
764
+ this.refs.list.items = ["a", "b"];`
765
+ },
766
+ "unknown-event": {
767
+ 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.",
768
+ bad: `<qty-input data-on-quantity-chnage="update"></qty-input>`,
769
+ good: `<qty-input data-on-quantity-change="update"></qty-input>
770
+ // qty-input.ts: emits: { "quantity-change": event<{ value: number }>() }`
771
+ },
772
+ "img-alt": {
773
+ why: "Without alt, screen readers cannot describe the image, or read out its file name.",
774
+ bad: `<img src="logo.png">`,
775
+ good: `<img src="logo.png" alt="Nativele logo">
776
+ <img src="divider.png" alt="">`
777
+ },
778
+ "control-name": {
779
+ 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.",
780
+ bad: `<button data-on-click="close"><svg></svg></button>`,
781
+ good: `<button data-on-click="close" aria-label="Close"><svg></svg></button>`
782
+ },
783
+ "form-label": {
784
+ why: "A form control without a label cannot be identified by screen readers, and clicking the label text will not focus it.",
785
+ bad: `<input data-ref="email">`,
786
+ good: `<label>Email <input data-ref="email"></label>`
787
+ },
788
+ "iframe-title": {
789
+ why: "A screen reader announces an iframe by its title. Without one the user cannot tell what it contains.",
790
+ bad: `<iframe src="/map"></iframe>`,
791
+ good: `<iframe src="/map" title="Office location map"></iframe>`
792
+ },
793
+ "dialog-name": {
794
+ why: "A dialog without a name is announced with no context when it opens.",
795
+ bad: `<dialog><p>Delete?</p></dialog>`,
796
+ good: `<dialog aria-label="Confirm delete"><p>Delete?</p></dialog>`
797
+ },
798
+ "heading-empty": {
799
+ why: "An empty heading adds an empty stop to the heading navigation of assistive technology.",
800
+ bad: `<h2></h2>`,
801
+ good: `<h2>{{ title }}</h2>`
802
+ },
803
+ "aria-role": {
804
+ 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.",
805
+ bad: `<div role="buton">save</div>`,
806
+ good: `<div role="button" tabindex="0">save</div>`
807
+ },
808
+ "aria-attr": {
809
+ 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.",
810
+ bad: `<button aria-pressd="true">bold</button>`,
811
+ good: `<button aria-pressed="true">bold</button>`
812
+ },
813
+ "role-tabindex": {
814
+ why: "An element with an interactive role promises keyboard use, but a non-focusable element cannot be reached with the keyboard.",
815
+ bad: `<div role="button" data-on-click="save">save</div>`,
816
+ good: `<button data-on-click="save">save</button>`
817
+ },
818
+ "tabindex-positive": {
819
+ why: "A positive tabindex pulls an element ahead of the natural order and makes the focus order differ from the reading order.",
820
+ bad: `<button tabindex="3">save</button>`,
821
+ good: `<button tabindex="0">save</button>`
822
+ },
823
+ "hidden-focusable": {
824
+ why: "A focusable element inside aria-hidden=\"true\" can be reached by keyboard but is invisible to screen readers.",
825
+ bad: `<div aria-hidden="true"><button>save</button></div>`,
826
+ good: `<div><button>save</button></div>`
827
+ },
828
+ "label-for": {
829
+ why: "A <label for> that points to a missing id labels nothing, so the control stays unnamed.",
830
+ bad: `<label for="email">Email</label>
831
+ <input id="mail">`,
832
+ good: `<label for="email">Email</label>
833
+ <input id="email">`
834
+ },
835
+ "id-in-list": {
836
+ why: "A data-each template is cloned for every item, so an id inside it is repeated and no longer unique in the document.",
837
+ bad: `<template data-each="rows">
838
+ <li id="row">{{ label }}</li>
839
+ </template>`,
840
+ good: `<template data-each="rows">
841
+ <li data-ref="row">{{ label }}</li>
842
+ </template>`
843
+ },
844
+ "duplicate-id": {
845
+ why: "Ids must be unique, or label, aria-labelledby and similar references point at the wrong element.",
846
+ bad: `<input id="name">
847
+ <input id="name">`,
848
+ good: `<input id="first-name">
849
+ <input id="last-name">`
850
+ },
851
+ "aria-idref": {
852
+ 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.",
853
+ bad: `<section aria-labelledby="heading">
854
+ <h2 id="title">{{ title }}</h2>
855
+ </section>`,
856
+ good: `<section aria-labelledby="title">
857
+ <h2 id="title">{{ title }}</h2>
858
+ </section>`
859
+ },
860
+ "heading-order": {
861
+ why: "Skipping heading levels breaks the outline that screen reader users navigate by.",
862
+ bad: `<h1>{{ title }}</h1>
863
+ <h3>{{ subtitle }}</h3>`,
864
+ good: `<h1>{{ title }}</h1>
865
+ <h2>{{ subtitle }}</h2>`
866
+ },
867
+ "table-header": {
868
+ why: "Without header cells, a screen reader cannot tell which column or row a data cell belongs to.",
869
+ bad: `<table><tr><td>Name</td><td>Age</td></tr></table>`,
870
+ good: `<table><tr><th>Name</th><th>Age</th></tr></table>`
871
+ },
872
+ "list-structure": {
873
+ why: "Screen readers announce lists by counting their items, which only works when <ul>/<ol> contain <li> and <li> sits inside a list.",
874
+ bad: `<ul><div>one</div></ul>`,
875
+ good: `<ul><li>one</li></ul>`
876
+ },
877
+ "landmark-label": {
878
+ why: "When several landmarks of one kind exist, users navigating by landmark hear identical names and cannot tell them apart.",
879
+ bad: `<nav>...</nav>
880
+ <nav>...</nav>`,
881
+ good: `<nav aria-label="Main">...</nav>
882
+ <nav aria-label="Footer">...</nav>`
883
+ },
884
+ "redundant-role": {
885
+ why: "A role that repeats the native role adds nothing and can drift from the element if it is changed later.",
886
+ bad: `<button role="button">save</button>`,
887
+ good: `<button>save</button>`
888
+ },
889
+ "click-semantics": {
890
+ 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.",
891
+ bad: `<div data-on-click="open">open</div>`,
892
+ good: `<button data-on-click="open">open</button>`
893
+ },
894
+ "stale-generated": {
895
+ 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.",
896
+ bad: `# card.html was edited, card.html.ts was not regenerated
897
+ npx ts-dom check --no-write`,
898
+ good: `npx ts-dom gen
899
+ # or: npx ts-dom check (it regenerates first)`
900
+ },
901
+ "missing-binding": {
902
+ 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.",
903
+ bad: `<!-- card.html -->
904
+ <h2>{{ title }}</h2>
905
+ // card.ts
906
+ Component(tpl, { state: { name: "" } })`,
907
+ good: `// card.ts
908
+ Component(tpl, { state: { title: "" } })`
909
+ },
910
+ "conflicting-keys": {
911
+ 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.",
912
+ bad: `Component(tpl, {
913
+ state: { total: 0 },
914
+ derive: { total: (s) => s.count * 2 },
915
+ })`,
916
+ good: `Component(tpl, {
917
+ state: { count: 0 },
918
+ derive: { total: (s) => s.count * 2 },
919
+ })`
920
+ },
921
+ "reserved-prop": {
922
+ why: "A prop becomes a property on the element, and names such as title, hidden or render already belong to HTMLElement or ts-dom.",
923
+ bad: `Component(tpl, { props: { title: "" } })`,
924
+ good: `Component(tpl, { props: { heading: "" } })`
925
+ },
926
+ "native-event-name": {
927
+ 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.",
928
+ bad: `Component(tpl, { emits: { click: event() } })`,
929
+ good: `Component(tpl, { emits: { "item-select": event() } })`
930
+ },
931
+ "item-prop-missing": {
932
+ 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.",
933
+ bad: `<template data-each="tasks" data-key="id" data-prop="task"><task-row></task-row></template>
934
+ // task-row.ts
935
+ Component(tpl, { props: { item: {} as Task } })`,
936
+ good: `// task-row.ts
937
+ Component(tpl, { props: { task: {} as Task } })`
938
+ },
939
+ "item-not-in-tag-map": {
940
+ 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.",
941
+ bad: `// task-row.ts defines the tag, but ts-dom-tags.ts is out of date
942
+ define("task-row", TaskRow);`,
943
+ good: `npx ts-dom gen # regenerates ts-dom-tags.ts`
944
+ },
945
+ "item-class-not-exported": {
946
+ 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.",
947
+ bad: `class TaskRow extends Component(tpl, {}) {}
948
+ define("task-row", TaskRow);`,
949
+ good: `export class TaskRow extends Component(tpl, {}) {}
950
+ define("task-row", TaskRow);`
951
+ },
952
+ tsc: {
953
+ 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.",
954
+ bad: `// card.ts
955
+ const n: number = "1";`,
956
+ good: `// card.ts
957
+ const n: number = 1;`
958
+ },
959
+ "tsc-failed": {
960
+ 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.",
961
+ bad: `npx ts-dom check --project=tsconfig.app.json # the file does not exist`,
962
+ good: `npx ts-dom check --project=tsconfig.json`
963
+ },
964
+ "dir-not-found": {
965
+ 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.",
966
+ bad: `npx ts-dom check ap # the templates live in app/`,
967
+ good: `npx ts-dom check app`
968
+ },
969
+ "tsc-not-found": {
970
+ why: "ts-dom check runs the TypeScript compiler of the project. Without it installed, nothing can be type-checked.",
971
+ bad: `npx ts-dom check # typescript is not installed`,
972
+ good: `npm i -D typescript
973
+ npx ts-dom check`
974
+ },
975
+ "no-tsconfig": {
976
+ 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.",
977
+ bad: `# project root has no tsconfig.json`,
978
+ good: `// tsconfig.json
979
+ { "compilerOptions": { "strict": true, "lib": ["ES2022", "DOM"] }, "include": ["src"] }`
980
+ },
981
+ "tsconfig-not-strict": {
982
+ why: "Without strict, tsc lets wrong names and null values through, which removes most of what the generated types are for.",
983
+ bad: `// tsconfig.json
984
+ { "compilerOptions": { "strict": false } }`,
985
+ good: `// tsconfig.json
986
+ { "compilerOptions": { "strict": true } }`
987
+ },
988
+ "tsconfig-no-dom-lib": {
989
+ why: "Components extend HTMLElement and refs are element types. Without the DOM lib those types do not exist.",
990
+ bad: `// tsconfig.json
991
+ { "compilerOptions": { "lib": ["ES2022"] } }`,
992
+ good: `// tsconfig.json
993
+ { "compilerOptions": { "lib": ["ES2022", "DOM"] } }`
994
+ },
995
+ "tsconfig-classic-resolution": {
996
+ 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.",
997
+ bad: `// tsconfig.json
998
+ { "compilerOptions": { "moduleResolution": "classic" } }`,
999
+ good: `// tsconfig.json
1000
+ { "compilerOptions": { "moduleResolution": "bundler" } }`
1001
+ },
1002
+ "tsconfig-misses-templates": {
1003
+ why: "tsc only checks files under include. A template folder left out means its generated types are never checked against the components.",
1004
+ bad: `// tsconfig.json
1005
+ { "include": ["src/server"] } // templates are in src/ui`,
1006
+ good: `// tsconfig.json
1007
+ { "include": ["src"] }`
1008
+ },
1009
+ "runtime-not-installed": {
1010
+ why: "Generated files import the runtime package. If it cannot be resolved from the project, they fail to compile and to run.",
1011
+ bad: `// card.html.ts imports "@nativele/ts-dom", which is not installed`,
1012
+ good: `npm i @nativele/ts-dom`
1013
+ },
1014
+ "template-import-without-js": {
1015
+ 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.",
1016
+ bad: `import tpl from "./card.html";`,
1017
+ good: `import tpl from "./card.html.js";`
1018
+ },
1019
+ "vite-plugin-missing": {
1020
+ 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.",
1021
+ bad: `// vite.config.ts
1022
+ export default defineConfig({ plugins: [] });`,
1023
+ good: `// vite.config.ts
1024
+ import { tsDom } from "@nativele/ts-dom/vite";
1025
+ export default defineConfig({ plugins: [tsDom()] });`
1026
+ },
1027
+ "oxlint-plugin-missing": {
1028
+ why: "The oxlint plugin runs the template rules (unused markup, data flow, accessibility) in the editor. Without it they only run from the CLI.",
1029
+ bad: `// .oxlintrc.json
1030
+ { "rules": {} }`,
1031
+ good: `// .oxlintrc.json
1032
+ {
1033
+ "jsPlugins": [{ "name": "ts-dom", "specifier": "@nativele/ts-dom/oxlint" }],
1034
+ "rules": { "ts-dom/unused-markup": "warn" }
1035
+ }`
1036
+ },
1037
+ "formatter-whitespace": {
1038
+ 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.",
1039
+ bad: `# formatter is configured, templates keep their whitespace
1040
+ npx ts-dom gen`,
1041
+ good: `npx ts-dom gen --whitespace=collapse
1042
+ // or in vite.config.ts: tsDom({ whitespace: "collapse" })`
1043
+ }
1044
+ };
6
1045
  // src/cli.ts
7
1046
  const HELP = `ts-dom <command> [dirs...] [flags]
8
1047
 
9
1048
  Commands
10
1049
  gen [dirs...] Generate x.html.ts for every x.html, plus ts-dom-tags.ts (default dir: src)
11
1050
  --watch keep watching and regenerate on change
1051
+ --check write nothing; list generated files that are missing, outdated or orphaned, exit 1 if any
12
1052
  --include=<glob> only these templates (repeatable; relative to each dir, ** crosses folders)
13
1053
  --exclude=<glob> leave these templates out (repeatable)
14
1054
  --whitespace=collapse collapse whitespace in text and drop line-break-only text between tags
1055
+ --whitespace=preserve keep whitespace as written; without either, the mode the generated files were
1056
+ last written with is kept (gen, gen --check, check and doctor all follow it)
15
1057
  lint [dirs...] Check templates against the code that uses them (default dir: src)
1058
+ --a11y also check ARIA / accessibility (warnings)
1059
+ --strict exit with code 1 on warnings too (errors always do)
1060
+ --json print the issues as JSON instead of text (also for gen --check)
1061
+ check [dirs...] One pass for agents and CI: template diagnostics, generated files, tsc errors mapped back
1062
+ to the templates, and the lint rules (default dir: src)
1063
+ --no-write do not regenerate; report stale generated files instead
1064
+ --project=<path> tsconfig for tsc (default: tsc's own lookup)
16
1065
  --a11y also check ARIA / accessibility (warnings)
17
1066
  --strict exit with code 1 on warnings too (errors always do)
18
1067
  --json print the issues as JSON instead of text
1068
+ doctor [dirs...] Check the project set-up: tsconfig, template imports, generated files, the runtime package,
1069
+ and the Vite, oxlint and formatter configuration (default dir: src)
1070
+ --json print the issues as JSON instead of text
19
1071
  rules List every rule id with its description
1072
+ --explain=<id> why the rule exists, a wrong example and the fix (the id as printed in [brackets])
1073
+ --json print every rule as JSON: { id, group, description, why, bad, good }
20
1074
 
21
1075
  Output format: file:line:col severity [rule-id] message. Each message says what is wrong and how to fix it.
22
1076
  Exit code: 0 = clean, 1 = errors found (or warnings with --strict).
23
1077
  `;
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 = [
1078
+ function ruleGroups() {
1079
+ return [
34
1080
  [
35
1081
  "gen",
36
1082
  "reported by `ts-dom gen` while analyzing a template (errors stop code generation)",
37
1083
  CODEGEN_RULES
38
1084
  ],
1085
+ [
1086
+ "check",
1087
+ "reported by `ts-dom check` (tsc errors mapped back to the templates, and generated file state)",
1088
+ CHECK_RULES
1089
+ ],
1090
+ [
1091
+ "doctor",
1092
+ "reported by `ts-dom doctor` (project set-up)",
1093
+ DOCTOR_RULES
1094
+ ],
39
1095
  [
40
1096
  "lint: unused-markup",
41
- "errors from `ts-dom lint` / oxlint rule ts-dom/unused-markup",
1097
+ "errors from `ts-dom lint` / oxlint rule ts-dom/unused-markup (or ts-dom/<id>)",
42
1098
  UNUSED_MARKUP_RULES
43
1099
  ],
44
1100
  [
45
1101
  "lint: data-flow",
46
- "warnings from `ts-dom lint` / oxlint rule ts-dom/data-flow",
1102
+ "warnings from `ts-dom lint` / oxlint rule ts-dom/data-flow (or ts-dom/<id>)",
47
1103
  DATA_FLOW_RULES
48
1104
  ],
49
1105
  [
50
1106
  "lint: a11y",
51
- "warnings from `ts-dom lint --a11y` / oxlint rule ts-dom/a11y",
1107
+ "warnings from `ts-dom lint --a11y` / oxlint rule ts-dom/a11y (or ts-dom/<id>)",
52
1108
  Object.fromEntries(Object.entries(A11Y_RULES).map(([k, v]) => [k, v.split(". Fix:")[0]]))
53
1109
  ]
54
1110
  ];
55
- for (const [name, note, rules] of groups) {
1111
+ }
1112
+ function printRules() {
1113
+ const explain = args.find((a) => a.startsWith("--explain="))?.slice(10);
1114
+ if (explain !== void 0) {
1115
+ const doc = RULE_DOCS[explain];
1116
+ const where = ruleGroups().filter(([, , rules]) => explain in rules);
1117
+ if (!doc || !where.length) {
1118
+ console.error(`Unknown rule id "${explain}". Fix: run ts-dom rules to see every id.`);
1119
+ process.exitCode = 1;
1120
+ return;
1121
+ }
1122
+ 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}`);
1123
+ return;
1124
+ }
1125
+ if (flags.has("--json")) {
1126
+ const rules = ruleGroups().flatMap(([group, , rules]) => Object.entries(rules).map(([id, description]) => ({
1127
+ id,
1128
+ group,
1129
+ description,
1130
+ ...RULE_DOCS[id]
1131
+ })));
1132
+ console.log(JSON.stringify({ rules }, null, 2));
1133
+ return;
1134
+ }
1135
+ for (const [name, note, rules] of ruleGroups()) {
56
1136
  console.log(`\n${name}: ${note}`);
57
- for (const [id, desc] of Object.entries(rules)) console.log(` ${id.padEnd(24)} ${desc}`);
1137
+ for (const [id, desc] of Object.entries(rules)) console.log(` ${id.padEnd(28)} ${desc}`);
58
1138
  }
1139
+ console.log("\nts-dom rules --explain=<id> shows why a rule exists, with a wrong and a right example.");
59
1140
  }
60
1141
  const [cmd, ...args] = process.argv.slice(2);
61
1142
  const flags = new Set(args.filter((a) => a.startsWith("--")));
62
1143
  const globs = (name) => args.filter((a) => a.startsWith(`--${name}=`)).map((a) => a.slice(name.length + 3));
1144
+ const whitespace = flags.has("--whitespace=collapse") ? "collapse" : flags.has("--whitespace=preserve") ? "preserve" : void 0;
63
1145
  const generate = {
64
1146
  include: globs("include"),
65
1147
  exclude: globs("exclude"),
66
- whitespace: flags.has("--whitespace=collapse") ? "collapse" : "preserve"
1148
+ ...whitespace ? { whitespace } : {}
67
1149
  };
68
1150
  const dirs = args.filter((a) => !a.startsWith("--"));
69
1151
  if (!dirs.length) dirs.push("src");
70
1152
  try {
71
- if (cmd === "gen") {
1153
+ if (cmd === "gen" && flags.has("--check")) {
1154
+ const results = dirs.map((d) => checkGenerated(d, generate));
1155
+ const stale = results.flatMap((r) => r.stale);
1156
+ const errors = results.flatMap((r) => r.errors);
1157
+ if (flags.has("--json")) console.log(JSON.stringify({
1158
+ stale: stale.map((s) => ({
1159
+ ...s,
1160
+ file: relative(process.cwd(), s.file)
1161
+ })),
1162
+ errors
1163
+ }, null, 2));
1164
+ else {
1165
+ 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.`);
1166
+ for (const e of errors) console.error(e);
1167
+ if (!stale.length && !errors.length) console.log("ts-dom gen --check: generated files are up to date");
1168
+ }
1169
+ if (stale.length || errors.length) process.exitCode = 1;
1170
+ } else if (cmd === "gen") {
72
1171
  let failed = false;
73
1172
  for (const d of dirs) try {
74
1173
  generateAll(d, generate);
@@ -86,8 +1185,30 @@ try {
86
1185
  });
87
1186
  await new Promise(() => {});
88
1187
  }
1188
+ } else if (cmd === "check") {
1189
+ const project = args.find((a) => a.startsWith("--project="))?.slice(10);
1190
+ const issues = runCheck({
1191
+ dirs,
1192
+ generate,
1193
+ write: !flags.has("--no-write"),
1194
+ a11y: flags.has("--a11y"),
1195
+ ...project ? { project } : {}
1196
+ });
1197
+ if (flags.has("--json")) console.log(JSON.stringify({ issues }, null, 2));
1198
+ else for (const i of issues) (i.severity === "error" ? console.error : console.warn)(formatIssue(i));
1199
+ if (issues.filter((i) => i.severity === "error").length || flags.has("--strict") && issues.length) process.exitCode = 1;
1200
+ if (!flags.has("--json") && !issues.length) console.log("ts-dom check: no issues");
1201
+ } else if (cmd === "doctor") {
1202
+ const issues = runDoctor({
1203
+ dirs,
1204
+ generate
1205
+ });
1206
+ if (flags.has("--json")) console.log(JSON.stringify({ issues }, null, 2));
1207
+ else for (const i of issues) (i.severity === "error" ? console.error : console.warn)(formatDoctorIssue(i));
1208
+ if (issues.some((i) => i.severity === "error")) process.exitCode = 1;
1209
+ if (!flags.has("--json") && !issues.length) console.log("ts-dom doctor: the set-up looks right");
89
1210
  } else if (cmd === "lint") {
90
- const files = dirs.flatMap(tsFiles);
1211
+ const files = dirs.flatMap(listTs);
91
1212
  const issues = [...lintUsage(dirs, files), ...lintDataFlow(dirs, files)];
92
1213
  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
1214
  file: relative(process.cwd(), f),