@nativele/ts-dom 0.5.4 → 0.6.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +6 -1
  3. package/README.zh-TW.md +6 -1
  4. package/dist/{a11y-CiEJj_e6.mjs → a11y-KXbo-8o3.mjs} +119 -27
  5. package/dist/cli.mjs +273 -50
  6. package/dist/{lint-Did4w-NL.mjs → lint-DUsdDKZ-.mjs} +33 -8
  7. package/dist/oxlint-plugin.mjs +3 -3
  8. package/dist/vite-plugin.mjs +1 -1
  9. package/docs/architecture.md +188 -0
  10. package/docs/components.md +367 -0
  11. package/docs/contributing.md +80 -0
  12. package/docs/design.md +55 -0
  13. package/docs/lists.md +150 -0
  14. package/docs/performance.md +24 -0
  15. package/docs/recipes.md +189 -0
  16. package/docs/templates.md +73 -0
  17. package/docs/tooling.md +251 -0
  18. package/llms.txt +5 -5
  19. package/package.json +2 -1
  20. package/skills/README.md +51 -14
  21. package/skills/nativele-angular/SKILL.md +15 -11
  22. package/skills/nativele-architecture/SKILL.md +29 -16
  23. package/skills/nativele-ci/SKILL.md +148 -0
  24. package/skills/nativele-core/SKILL.md +16 -13
  25. package/skills/nativele-formatting/SKILL.md +167 -0
  26. package/skills/nativele-integration/SKILL.md +12 -9
  27. package/skills/nativele-linting/SKILL.md +174 -0
  28. package/skills/nativele-monorepo/SKILL.md +298 -0
  29. package/skills/nativele-react/SKILL.md +13 -10
  30. package/skills/nativele-state-management/SKILL.md +6 -6
  31. package/skills/nativele-svelte/SKILL.md +10 -8
  32. package/skills/nativele-tailwind/SKILL.md +8 -7
  33. package/skills/nativele-tanstack-query/SKILL.md +5 -5
  34. package/skills/nativele-testing/SKILL.md +22 -16
  35. package/skills/nativele-typescript/SKILL.md +186 -0
  36. package/skills/nativele-vite/SKILL.md +21 -17
  37. package/skills/nativele-vue/SKILL.md +10 -8
  38. package/skills/nativele-zod/SKILL.md +6 -6
package/dist/cli.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { _ as staleGenerated, a as analyze, c as formatDiagnostics, f as isSelected, g as scanDefines, h as renderTags, i as CodegenError, l as generateDirs, m as listTs, n as checkA11y, o as checkGenerated, p as listHtml, r as CODEGEN_RULES, s as crossDirDuplicates, t as A11Y_RULES, v as watchAll } from "./a11y-CiEJj_e6.mjs";
3
- import { c as lintUsage, n as UNUSED_MARKUP_RULES, o as formatIssue, s as lintDataFlow, t as DATA_FLOW_RULES } from "./lint-Did4w-NL.mjs";
2
+ import { _ as scanTags, a as analyze, c as formatDiagnostics, f as isSelected, g as scanDefines, h as renderTags, i as CodegenError, l as generateDirs, m as listTs, n as checkA11y, o as checkGenerated, p as listHtml, r as CODEGEN_RULES, s as crossDirDuplicates, t as A11Y_RULES, v as staleGenerated, y as watchAll } from "./a11y-KXbo-8o3.mjs";
3
+ import { c as lintDataFlow, l as lintUsage, n as UNUSED_MARKUP_RULES, s as formatIssue, t as DATA_FLOW_RULES } from "./lint-DUsdDKZ-.mjs";
4
4
  import { createRequire } from "node:module";
5
5
  import { cpSync, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
6
6
  import { dirname, join, relative, resolve, sep } from "node:path";
7
7
  import { spawnSync } from "node:child_process";
8
+ import { homedir } from "node:os";
8
9
  import { fileURLToPath } from "node:url";
9
10
  // src/lib/tsconfig.ts
10
11
  /** tsconfig is JSONC: drop comments and trailing commas before parsing. */
@@ -29,30 +30,71 @@ function resolveExtends(from, ext) {
29
30
  return null;
30
31
  }
31
32
  /** 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
+ function readTsConfig(file) {
34
+ const config = readChain(file, 0);
35
+ return config.include === void 0 && config.files ? {
36
+ ...config,
37
+ include: []
38
+ } : config;
39
+ }
40
+ /** include, exclude and files are each taken from the nearest config that sets it, as tsc does; the default include is decided only for the whole chain. */
41
+ function readChain(file, depth) {
33
42
  const json = parseJsonc(readFileSync(file, "utf8"));
34
43
  let merged = { compilerOptions: {} };
35
44
  const exts = typeof json.extends === "string" ? [json.extends] : Array.isArray(json.extends) ? json.extends : [];
36
45
  for (const ext of depth < 5 ? exts : []) {
37
46
  const target = resolveExtends(file, ext);
38
47
  if (!target) continue;
39
- const base = readTsConfig(target, depth + 1);
48
+ const base = readChain(target, depth + 1);
40
49
  merged = {
50
+ ...merged,
51
+ ...base,
41
52
  compilerOptions: {
42
53
  ...merged.compilerOptions,
43
54
  ...base.compilerOptions
44
- },
45
- ...base.include ?? merged.include ? { include: base.include ?? merged.include } : {}
55
+ }
46
56
  };
47
57
  }
48
- const own = json.include;
49
- const include = own ? own.map((p) => resolve(dirname(file), p).split("\\").join("/")) : merged.include ?? (Array.isArray(json.files) ? [] : void 0);
58
+ const own = (key) => Array.isArray(json[key]) ? json[key].map((p) => resolve(dirname(file), p).split("\\").join("/")) : merged[key];
59
+ const [include, exclude, files] = [
60
+ own("include"),
61
+ own("exclude"),
62
+ own("files")
63
+ ];
50
64
  return {
51
65
  compilerOptions: {
52
66
  ...merged.compilerOptions,
53
67
  ...json.compilerOptions
54
68
  },
55
- ...include ? { include } : {}
69
+ ...include ? { include } : {},
70
+ ...exclude ? { exclude } : {},
71
+ ...files ? { files } : {}
72
+ };
73
+ }
74
+ /** A tsconfig include or exclude pattern (absolute, forward slashes) as tsc matches it: `**` crosses folders, `*` and `?` stay within one, a last segment without a wildcard or an extension names a folder, and an exclude also removes everything under a folder it matches. */
75
+ function globRegExp(pattern, exclude) {
76
+ const last = pattern.slice(pattern.lastIndexOf("/") + 1);
77
+ const segments = (!exclude && !/[*?]/.test(last) && !/\.[^.]+$/.test(last) ? `${pattern.replace(/\/$/, "")}/**/*` : pattern).split("/");
78
+ const body = segments.map((seg, i) => {
79
+ const end = i === segments.length - 1;
80
+ if (seg === "**") return end ? ".*" : "(?:[^/]+/)*";
81
+ const text = seg.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]");
82
+ return end ? text : `${text}/`;
83
+ }).join("");
84
+ return new RegExp(`^${body}${exclude ? "(?:$|/)" : "$"}`);
85
+ }
86
+ /**
87
+ * Why tsc leaves `file` out of the program of a config whose folder is `configDir`, unless another file imports it: include does not match it, or an exclude pattern removes it.
88
+ * null when files, or include without a matching exclude, take it in.
89
+ */
90
+ function leftOutBy(config, configDir, file) {
91
+ const f = file.split("\\").join("/");
92
+ if (config.files?.includes(f)) return null;
93
+ if (!(config.include ?? [`${configDir.split("\\").join("/")}/**/*`]).some((p) => globRegExp(p, false).test(f))) return { by: "include" };
94
+ const pattern = config.exclude?.find((p) => globRegExp(p, true).test(f));
95
+ return pattern === void 0 ? null : {
96
+ by: "exclude",
97
+ pattern
56
98
  };
57
99
  }
58
100
  /**
@@ -81,7 +123,7 @@ const CHECK_RULES = {
81
123
  "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
124
  "item-class-not-exported": "the item component class passed to define() is not exported (found by tsc)",
83
125
  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",
126
+ "tsc-not-found": "TypeScript is not installed in the project, so types were not checked and the check fails",
85
127
  "dir-not-found": "a folder given on the command line does not exist, so nothing in it was checked",
86
128
  "tsc-failed": "tsc did not run to completion (no tsconfig, a bad option, a crash), so types were not checked"
87
129
  };
@@ -125,7 +167,7 @@ const CONTRACT = [
125
167
  [
126
168
  "itemComponentMissingFromTagMap",
127
169
  "item-not-in-tag-map",
128
- "run ts-dom gen so ts-dom-tags.ts lists the item component"
170
+ "run ts-dom gen so ts-dom-tags.ts lists the item component; if it already does, the tsconfig include or exclude leaves ts-dom-tags.ts out of the program (ts-dom doctor reports it as tsconfig-misses-tag-map): make include match it"
129
171
  ],
130
172
  [
131
173
  "itemComponentClassNotExported",
@@ -163,7 +205,7 @@ function mapTscErrors(errors, cwd) {
163
205
  };
164
206
  }
165
207
  };
166
- const brokenClasses = /* @__PURE__ */ new Set();
208
+ const brokenClasses = /* @__PURE__ */ new Map();
167
209
  const brokenBodies = [];
168
210
  for (const e of errors) {
169
211
  if (!/missingStateKeys|conflictingKeys|reservedPropNames|nativeEventNames/.test(e.message)) continue;
@@ -171,7 +213,7 @@ function mapTscErrors(errors, cwd) {
171
213
  const lines = text.split("\n");
172
214
  const m = /class\s+([A-Za-z_$][\w$]*)\s+extends\s+Component\s*\(/.exec(lines[e.line - 1] ?? "");
173
215
  if (!m?.[1]) continue;
174
- brokenClasses.add(m[1]);
216
+ if (!brokenClasses.has(m[1])) brokenClasses.set(m[1], e);
175
217
  const end = classBodyEnd(text, lines.slice(0, e.line - 1).join("\n").length + (e.line > 1 ? 1 : 0) + m.index + m[0].length - 1);
176
218
  if (end !== -1) brokenBodies.push({
177
219
  file: rel(resolve(cwd, e.file)),
@@ -179,8 +221,9 @@ function mapTscErrors(errors, cwd) {
179
221
  to: text.slice(0, end).split("\n").length
180
222
  });
181
223
  }
182
- const followsBroken = (i) => [...brokenClasses].some((c) => i.message.includes(`'${c}'`) || i.message.includes(`'typeof ${c}'`)) || brokenBodies.some((b) => b.file === i.file && i.line > b.from && i.line <= b.to);
224
+ const firstIssueOf = /* @__PURE__ */ new Map();
183
225
  for (const e of errors) {
226
+ const before = out.length;
184
227
  const file = resolve(cwd, e.file);
185
228
  const where = `${rel(file)}:${e.line}:${e.col}`;
186
229
  const issue = (rule, message, at) => out.push({
@@ -217,11 +260,50 @@ function mapTscErrors(errors, cwd) {
217
260
  named = true;
218
261
  issue(rule, `${e.message.split("\n")[0]} Fix: ${fix}.`);
219
262
  }
220
- if (named) continue;
221
- if (file.endsWith(".html.ts") || file.endsWith(`${sep}ts-dom-tags.ts`)) issue("tsc", `${e.code}: ${e.message} This file is generated, so an edit to it is overwritten. Fix: run ts-dom gen for the folder that holds it; if it is left over from a template or folder that is no longer scanned, delete it.`);
263
+ if (named) {
264
+ firstIssueOf.set(e, out[before]);
265
+ continue;
266
+ }
267
+ const tag = /Property '"([^"]+)"' must be of type/.exec(e.message)?.[1];
268
+ if (e.code === "TS2717" && tag && file.endsWith(`${sep}ts-dom-tags.ts`)) issue("duplicate-define", `The tag <${tag}> is declared with another class by a second tag map in the same program (another generated ts-dom-tags.ts, from a folder that is scanned separately or from a package), so two components share one custom element name, and at runtime only the class whose define() runs first is registered: the other define() is skipped without an error (tsc ${e.code} at ${where}). Fix: rename the tag in one of the define() calls and in the templates that use it; editing this generated file does not help.`);
269
+ else if (file.endsWith(".html.ts") || file.endsWith(`${sep}ts-dom-tags.ts`)) issue("tsc", `${e.code}: ${e.message} This file is generated, so an edit to it is overwritten. Fix: run ts-dom gen for the folder that holds it; if it is left over from a template or folder that is no longer scanned, delete it.`);
222
270
  else issue("tsc", `${e.code}: ${e.message} Fix: change the TypeScript at this location so the types agree; ts-dom rules --explain=tsc.`);
223
271
  }
224
- return out.filter((i) => i.rule !== "tsc" || !followsBroken(i));
272
+ const brokenFiles = new Set([...brokenClasses.values()].map((e) => rel(resolve(cwd, e.file))));
273
+ const classIn = (message) => {
274
+ const types = [...message.matchAll(/'([^']*)'/g)].flatMap((m) => topLevelMembers(m[1]).map((t) => t.replace(/^typeof\s+/, "")));
275
+ return [...brokenClasses.keys()].find((c) => types.includes(c));
276
+ };
277
+ return out.flatMap((i) => {
278
+ const cls = i.rule === "tsc" ? classIn(i.message) : void 0;
279
+ if (i.rule === "tsc" && brokenBodies.some((b) => b.file === i.file && i.line > b.from && i.line <= b.to)) return [];
280
+ if (cls === void 0) return [i];
281
+ if (brokenFiles.has(i.file) || /(^|[\\/])node_modules[\\/]/.test(i.file)) return [];
282
+ const cause = brokenClasses.get(cls);
283
+ const first = firstIssueOf.get(cause);
284
+ const at = first ? `${first.file}:${first.line}:${first.col} [${first.rule}]` : `${rel(resolve(cwd, cause.file))}:${cause.line}:${cause.col}`;
285
+ return [{
286
+ ...i,
287
+ message: `${i.message.replace(/ Fix: .*$/s, "")} This error follows from the contract error of ${cls} at ${at}, which makes tsc type ${cls} as that error instead of an element. Fix: fix that error first; this one then goes away.`
288
+ }];
289
+ });
290
+ }
291
+ /** The members of a union or intersection type as tsc prints it (`A & { x: number; }` gives `A` and `{ x: number; }`); any other type is its only member. */
292
+ function topLevelMembers(type) {
293
+ const out = [];
294
+ let depth = 0;
295
+ let from = 0;
296
+ for (let i = 0; i < type.length; i++) {
297
+ const c = type[i];
298
+ if ("({[<".includes(c)) depth++;
299
+ else if (")}]".includes(c) || c === ">" && type[i - 1] !== "=") depth--;
300
+ else if (depth === 0 && (c === "&" || c === "|")) {
301
+ out.push(type.slice(from, i));
302
+ from = i + 1;
303
+ }
304
+ }
305
+ out.push(type.slice(from));
306
+ return out.map((t) => t.trim());
225
307
  }
226
308
  /** The offset of the `}` that closes a class body, given the offset of the `(` of its `extends Component(`; -1 when it cannot be found. Strings and comments are skipped. */
227
309
  function classBodyEnd(text, open) {
@@ -328,8 +410,8 @@ function runCheck(options) {
328
410
  line: 1,
329
411
  col: 1,
330
412
  rule: "tsc-not-found",
331
- severity: "warning",
332
- message: "TypeScript is not installed in this project, so types were not checked. Fix: npm i -D typescript."
413
+ severity: "error",
414
+ message: "TypeScript is not installed in this project, so no types were checked, and a check without them would pass with nothing looked at. Fix: npm i -D typescript (in CI, install the dev dependencies before ts-dom check)."
333
415
  });
334
416
  else {
335
417
  const configs = projectConfigs(cwd, options.project);
@@ -475,10 +557,11 @@ function libraryVersion(root = skillsRoot()) {
475
557
  const DOCTOR_RULES = {
476
558
  "dir-not-found": "a folder given on the command line does not exist, so nothing in it was checked",
477
559
  "no-tsconfig": "there is no tsconfig.json, so tsc cannot check the templates against the components",
478
- "tsconfig-not-strict": "strict is off, so tsc lets wrong names and null values through",
560
+ "tsconfig-not-strict": "strict is off (set to false, or left out on TypeScript 5 or a version that cannot be determined), so tsc does not report possibly null values or implicit any",
479
561
  "tsconfig-no-dom-lib": "lib leaves out DOM, so element types are missing",
480
- "tsconfig-classic-resolution": "moduleResolution classic cannot resolve ./x.html.js to the generated x.html.ts",
562
+ "tsconfig-classic-resolution": "modules resolve the classic way (moduleResolution classic, or the TypeScript 5 default for module ES2015 to ESNext), which cannot resolve the runtime package the generated files import; TypeScript 6 deprecates it and 7 removes it",
481
563
  "tsconfig-misses-templates": "include leaves out a folder with templates, so their generated types are not checked",
564
+ "tsconfig-misses-tag-map": "include or exclude leaves ts-dom-tags.ts out of the program and no file imports it, so custom element tags are typed as plain HTMLElement",
482
565
  "runtime-not-installed": "the runtime package the generated files import cannot be resolved",
483
566
  "template-import-without-js": "a template is imported as ./x.html instead of ./x.html.js",
484
567
  "stale-generated": "a generated file is missing, outdated or orphaned",
@@ -518,6 +601,83 @@ function scriptSteps(scripts, name, depth = 0) {
518
601
  ...scriptSteps(scripts, `post${name}`, depth + 1)
519
602
  ];
520
603
  }
604
+ /** The version of the TypeScript the project resolves, or null when it has none. */
605
+ function typescriptVersion(cwd) {
606
+ try {
607
+ return JSON.parse(readFileSync(createRequire(join(cwd, "package.json")).resolve("typescript/package.json"), "utf8")).version ?? null;
608
+ } catch {
609
+ return null;
610
+ }
611
+ }
612
+ const TS5_CLASSIC_MODULES = /* @__PURE__ */ new Set([
613
+ "es6",
614
+ "es2015",
615
+ "es2020",
616
+ "es2022",
617
+ "esnext",
618
+ "amd",
619
+ "umd",
620
+ "system",
621
+ "none"
622
+ ]);
623
+ const implicitlyClassic = (o) => {
624
+ if (o.module !== void 0) return TS5_CLASSIC_MODULES.has(String(o.module).toLowerCase());
625
+ return ![
626
+ "",
627
+ "es3",
628
+ "es5"
629
+ ].includes(String(o.target ?? "").toLowerCase());
630
+ };
631
+ const readPackageJson = (file) => {
632
+ try {
633
+ return JSON.parse(readFileSync(file, "utf8"));
634
+ } catch {
635
+ return {};
636
+ }
637
+ };
638
+ /** `cwd` and the folders above it up to the workspace root: the nearest folder with a package.json "workspaces" field or a pnpm-workspace.yaml, else the git root. Only `cwd` when neither is found below the home directory or the filesystem root. */
639
+ function upToWorkspaceRoot(cwd) {
640
+ const start = resolve(cwd);
641
+ const home = homedir();
642
+ const chain = [];
643
+ for (let dir = start;; dir = dirname(dir)) {
644
+ if (dir === home && dir !== start) return [start];
645
+ chain.push(dir);
646
+ if (existsSync(join(dir, "pnpm-workspace.yaml")) || "workspaces" in readPackageJson(join(dir, "package.json")) || existsSync(join(dir, ".git"))) return chain;
647
+ if (dirname(dir) === dir) return [start];
648
+ }
649
+ }
650
+ const SCRIPT_EXTENSIONS = [
651
+ "js",
652
+ "cjs",
653
+ "mjs",
654
+ "ts",
655
+ "cts",
656
+ "mts"
657
+ ];
658
+ const OXFMT_CONFIGS = [
659
+ ".oxfmtrc.json",
660
+ ".oxfmtrc.jsonc",
661
+ ...SCRIPT_EXTENSIONS.map((e) => `oxfmt.config.${e}`)
662
+ ];
663
+ const PRETTIER_CONFIGS = [
664
+ ".prettierrc",
665
+ ...[
666
+ "json",
667
+ "json5",
668
+ "yaml",
669
+ "yml",
670
+ "toml",
671
+ ...SCRIPT_EXTENSIONS
672
+ ].map((e) => `.prettierrc.${e}`),
673
+ ...SCRIPT_EXTENSIONS.map((e) => `prettier.config.${e}`)
674
+ ];
675
+ const MODULE_SPECIFIERS = /\b(?:from|import)\s*\(?\s*["'](\.{1,2}\/[^"']+)["']|<reference\s+path\s*=\s*["']([^"']+)["']/g;
676
+ /** Whether `file` imports, re-exports or references `tagMap` through a relative path. */
677
+ function importsTagMap(file, tagMap) {
678
+ const target = tagMap.replace(/\.ts$/, "");
679
+ return [...(readText(file) ?? "").matchAll(MODULE_SPECIFIERS)].some((m) => resolve(dirname(file), m[1] ?? m[2]).replace(/\.[jt]s$/, "") === target);
680
+ }
521
681
  const templateFolders = (dir) => [...new Set(listHtml(dir).map((f) => dirname(f)))];
522
682
  const hasTemplates = (dir) => listHtml(dir).length > 0;
523
683
  /** Check the project set-up an agent or a developer could get wrong; every issue names its rule and ends with a Fix hint. */
@@ -536,9 +696,14 @@ function runDoctor(options) {
536
696
  const dirs = [];
537
697
  for (const d of options.dirs.map((x) => resolve(cwd, x))) if (existsSync(d)) dirs.push(d);
538
698
  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) });
539
- const configs = projectConfigs(cwd);
699
+ const configs = projectConfigs(cwd, options.project);
540
700
  let paths = {};
541
- 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\").");
701
+ if (!configs.length) {
702
+ if (options.project) add("no-tsconfig", "error", `The tsconfig ${options.project} given with --project does not exist. Fix: pass the path of the tsconfig the project type-checks with.`);
703
+ else add("no-tsconfig", "error", "There is no tsconfig.json. Fix: add one with \"strict\": true, \"lib\": [\"ES2022\", \"DOM\"] and \"moduleResolution\": \"bundler\" (or \"NodeNext\").");
704
+ }
705
+ const tsVersion = typescriptVersion(cwd);
706
+ const tsMajor = tsVersion === null ? null : Number.parseInt(tsVersion, 10);
542
707
  const includes = [];
543
708
  let everyConfigIncludes = true;
544
709
  const scanned = dirs.map((dir) => ({
@@ -561,16 +726,42 @@ function runDoctor(options) {
561
726
  if (config.include) includes.push(...config.include);
562
727
  else everyConfigIncludes = false;
563
728
  if (covering.length && !covering.some((r) => r.path === configPath)) continue;
564
- 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 });
729
+ const lost = "so tsc does not report values that may be null (a querySelector result or an optional ref used without a check) or parameters and variables that are implicitly any (a handler parameter without a type); the names in the templates are checked either way";
730
+ if (o.strict === false) add("tsconfig-not-strict", "warning", `compilerOptions.strict is false, ${lost}. Fix: set "strict": true.`, { file });
731
+ else if (o.strict === void 0 && tsMajor === null) add("tsconfig-not-strict", "warning", `compilerOptions.strict is not set, and the TypeScript version could not be determined (typescript is not resolvable from this project): TypeScript 5 leaves strict off by default (6 and later turn it on), ${lost}. Fix: set "strict": true.`, { file });
732
+ else if (o.strict === void 0 && tsMajor !== null && tsMajor < 6) add("tsconfig-not-strict", "warning", `compilerOptions.strict is not set, and TypeScript ${tsVersion} leaves it off by default (6 and later turn it on), ${lost}. Fix: set "strict": true.`, { file });
565
733
  const lib = o.lib;
566
734
  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 });
567
- 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 });
735
+ const cannotResolve = `which cannot resolve the package "${runtime}" that every generated file imports (TS2792 Cannot find module), so no component gets its types`;
736
+ if (String(o.moduleResolution ?? "").toLowerCase() === "classic") add("tsconfig-classic-resolution", "error", `moduleResolution is "classic", ${tsMajor === null ? `${cannotResolve}; TypeScript 6 deprecates it (TS5107) and 7 removes it (TS5108)` : tsMajor >= 7 ? `which TypeScript ${tsVersion} no longer supports, so tsc stops with TS5108 before checking anything` : tsMajor === 6 ? `which TypeScript ${tsVersion} deprecates, so tsc stops with TS5107 before checking anything` : cannotResolve}. Fix: use "bundler" (or "NodeNext" with "module": "NodeNext").`, { file });
737
+ else if (o.moduleResolution === void 0 && tsMajor !== null && tsMajor < 6 && implicitlyClassic(o)) add("tsconfig-classic-resolution", "error", `${o.module !== void 0 ? `"module": ${JSON.stringify(o.module)}` : `"target": ${JSON.stringify(o.target)} (which makes module ES2015 or later)`} without "moduleResolution" makes TypeScript ${tsVersion} resolve modules the classic way, ${cannotResolve}. Fix: add "moduleResolution": "bundler" (or set "module" and "moduleResolution" to "NodeNext").`, { file });
568
738
  }
569
739
  if (configs.length && everyConfigIncludes) {
570
740
  const missed = /* @__PURE__ */ new Set();
571
741
  for (const folder of scanned.flatMap((s) => s.folders)) if (!includes.some((p) => covers(p, folder))) missed.add(rel(folder));
572
742
  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]) });
573
743
  }
744
+ for (const { dir, folders } of scanned) {
745
+ const tags = scanTags(dir).map((t) => `<${t.tag}>`);
746
+ if (!tags.length) continue;
747
+ const tagMap = join(dir, "ts-dom-tags.ts");
748
+ const left = read.filter(({ config: { include } }) => !include || (folders.length ? folders : [dir]).some((t) => include.some((p) => covers(p, t)))).map(({ path, config }) => ({
749
+ path,
750
+ config,
751
+ why: leftOutBy(config, dirname(path), tagMap)
752
+ }));
753
+ const first = left[0];
754
+ if (!first?.why || left.some((l) => !l.why)) continue;
755
+ const configDir = dirname(first.path);
756
+ if ([
757
+ ...listTs(dir),
758
+ ...first.config.files ?? [],
759
+ ...listTs(configDir).filter((f) => !leftOutBy(first.config, configDir, f))
760
+ ].some((f) => importsTagMap(f, tagMap))) continue;
761
+ const own = (p) => relative(configDir, p).split("\\").join("/") || ".";
762
+ const effect = `so tsc types the tags defined in ${rel(dir)} (${tags.slice(0, 3).join(", ")}${tags.length > 3 ? ", ..." : ""}) as plain HTMLElement in document.createElement, querySelector and refs, and every list of item components fails with item-not-in-tag-map`;
763
+ add("tsconfig-misses-tag-map", "error", first.why.by === "include" ? `The include of ${rel(first.path)} does not match ${rel(tagMap)}, and no file imports it, ${effect}. Fix: add "${own(tagMap)}" to include.` : `The exclude pattern "${own(first.why.pattern)}" in ${rel(first.path)} removes ${rel(tagMap)} from the program, and no file imports it, ${effect}. Fix: narrow the pattern so it leaves ts-dom-tags.ts in, or add "${own(tagMap)}" to files (files are kept even when exclude matches them).`, { file: rel(first.path) });
764
+ }
574
765
  let resolvable = Object.keys(paths).includes(runtime);
575
766
  if (!resolvable) try {
576
767
  createRequire(join(cwd, "package.json")).resolve(`${runtime}/package.json`);
@@ -615,7 +806,15 @@ function runDoctor(options) {
615
806
  ].map((ext) => join(cwd, `vitest.config.${ext}`)).find((f) => existsSync(f));
616
807
  const vitest = vitestConfig ? readText(vitestConfig) : null;
617
808
  if (vitestConfig && vitest !== null && !/@nativele\/ts-dom\/vite|vite-plugin(\.ts)?["']/.test(vitest) && !/["']\.\/vite\.config(\.[cm]?[jt]s)?["']/.test(vitest)) add("vite-plugin-missing", "warning", `${rel(vitestConfig)} replaces the Vite config for Vitest and does not use the ts-dom plugin, so the templates are not generated when the tests start and a clean checkout fails to resolve ./x.html.js. Fix: add tsDom() to its plugins, or merge the Vite config into it: mergeConfig(viteConfig, defineConfig({ test: { ... } })) with viteConfig imported from "./vite.config".`, { file: rel(vitestConfig) });
618
- const oxlintrc = [".oxlintrc.json", "oxlintrc.json"].map((n) => join(cwd, n)).find((f) => existsSync(f));
809
+ const workspace = upToWorkspaceRoot(cwd);
810
+ const nearest = (names) => workspace.flatMap((d) => names.map((n) => join(d, n))).find((f) => existsSync(f));
811
+ const oxlintrc = nearest([
812
+ ".oxlintrc.json",
813
+ ".oxlintrc.jsonc",
814
+ "oxlintrc.json",
815
+ "oxlint.config.ts",
816
+ "oxlint.config.mts"
817
+ ]);
619
818
  const lintConfigs = [oxlintrc ? readText(oxlintrc) : null, vite && /\blint\s*:/.test(vite) ? vite : null].filter((t) => t !== null);
620
819
  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) });
621
820
  const pkgText = readText(join(cwd, "package.json"));
@@ -624,8 +823,7 @@ function runDoctor(options) {
624
823
  ...pkg.dependencies,
625
824
  ...pkg.devDependencies
626
825
  };
627
- const scripts = Object.values(pkg.scripts ?? {});
628
- if ((readText(join(cwd, ".gitignore")) ?? "").split("\n").some((l) => !l.trim().startsWith("!") && /\*\.html\.ts\s*$/.test(l)) && dirs.some(hasTemplates)) {
826
+ if (workspace.some((d) => (readText(join(d, ".gitignore")) ?? "").split("\n").some((l) => !l.trim().startsWith("!") && /\*\.html\.ts\s*$/.test(l))) && dirs.some(hasTemplates)) {
629
827
  const all = pkg.scripts ?? {};
630
828
  const checksTemplates = (step) => {
631
829
  const project = TSC_PROJECT.exec(step)?.[1];
@@ -650,14 +848,21 @@ function runDoctor(options) {
650
848
  });
651
849
  }
652
850
  }
653
- const ignoredHtml = /(^|\n)\s*(\*\*\/)?\*\.html\s*($|\n)/.test(readText(join(cwd, ".prettierignore")) ?? "");
654
- 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 && ([
655
- ".prettierrc",
656
- ".prettierrc.json",
657
- ".prettierrc.js",
658
- "prettier.config.js",
659
- "prettier.config.mjs"
660
- ].some((n) => existsSync(join(cwd, n))) || "prettier" in deps || scripts.some((c) => /(^|[\s;&|])prettier(\s|$)/.test(c)));
851
+ const prettierIgnore = nearest([".prettierignore"]);
852
+ const ignoredHtml = /(^|\n)\s*(\*\*\/)?\*\.html\s*($|\n)/.test(prettierIgnore ? readText(prettierIgnore) ?? "" : "");
853
+ const formatterIn = (d) => {
854
+ const file = join(d, "package.json");
855
+ const json = readPackageJson(file);
856
+ const ds = {
857
+ ...json.dependencies,
858
+ ...json.devDependencies
859
+ };
860
+ const runs = (tool) => Object.values(json.scripts ?? {}).some((c) => tool.test(c));
861
+ const oxfmt = OXFMT_CONFIGS.map((n) => join(d, n)).find((f) => existsSync(f)) ?? ("oxfmt" in ds || runs(/(^|[\s;&|])(oxfmt|vp fmt)(\s|$)/) ? file : void 0);
862
+ if (oxfmt || ignoredHtml) return oxfmt;
863
+ return PRETTIER_CONFIGS.map((n) => join(d, n)).find((f) => existsSync(f)) ?? ("prettier" in json || "prettier" in ds || runs(/(^|[\s;&|])prettier(\s|$)/) ? file : void 0);
864
+ };
865
+ const formatter = workspace.map(formatterIn).find((f) => f !== void 0) ?? (vite !== null && /\bfmt\s*:/.test(vite) ? viteConfig : void 0);
661
866
  const collapseScript = Object.entries(pkg.scripts ?? {}).find(([, c]) => c.includes("--whitespace=collapse"))?.[0];
662
867
  const collapseVite = vite !== null && /whitespace\s*:\s*["']collapse["']/.test(vite);
663
868
  const generatedModes = dirs.flatMap(listHtml).flatMap((f) => {
@@ -674,9 +879,9 @@ function runDoctor(options) {
674
879
  add("formatter-whitespace", "warning", `${asker} asks for whitespace "collapse", but ${preserved.length === 1 ? "this generated file was" : `${preserved.length} generated files (this one first) were`} written with "preserve" (by a gen or check run without the flag, for example), and a run without --whitespace keeps the mode a file was last written with, so the page gets the whitespace as written instead of collapsed. Fix: regenerate with the flag once: ${rerun}.`, { file: rel(preserved[0].file) });
675
880
  }
676
881
  const collapsed = collapseScript !== void 0 || collapseVite || generatedModes.some((g) => g.collapse);
677
- 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\" })).");
882
+ 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\" })).", { file: rel(formatter) });
678
883
  for (const skill of listSkills(options.skillsDir ?? skillsRoot())) {
679
- const copies = installedCopies(cwd, skill.name);
884
+ const copies = workspace.flatMap((d) => installedCopies(d, skill.name));
680
885
  const shipped = readFileSync(join(skill.dir, "SKILL.md"), "utf8");
681
886
  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")) });
682
887
  const found = skill.detects.filter((d) => d in deps);
@@ -887,7 +1092,7 @@ connected() { /* put the behavior in the class */ }`
887
1092
  good: `<button data-on-click="more">more</button>`
888
1093
  },
889
1094
  "duplicate-define": {
890
- 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.",
1095
+ why: "A custom element tag can be registered only once. define() skips a tag that is already registered, so with two classes for one tag the class whose define() runs first wins and the other is dropped without an error, and the tag map cannot say which class the tag stands for. ts-dom check also reports it when tsc finds two tag maps in one program that declare the tag with different classes (TS2717 in ts-dom-tags.ts), for example a folder scanned separately or a package that ships its own tag map.",
891
1096
  bad: `// a.ts
892
1097
  define("user-card", CardA);
893
1098
  // b.ts
@@ -1186,7 +1391,9 @@ Component(tpl, { props: { task: {} as Task } })`
1186
1391
  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.",
1187
1392
  bad: `// task-row.ts defines the tag, but ts-dom-tags.ts is out of date
1188
1393
  define("task-row", TaskRow);`,
1189
- good: `npx ts-dom gen # regenerates ts-dom-tags.ts`
1394
+ good: `npx ts-dom gen # regenerates ts-dom-tags.ts
1395
+ // and keep ts-dom-tags.ts in the program: tsconfig.json
1396
+ { "include": ["src"] } // not ["src/**/*.tsx"], and no exclude that matches it`
1190
1397
  },
1191
1398
  "item-class-not-exported": {
1192
1399
  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.",
@@ -1215,7 +1422,7 @@ const n: number = 1;`
1215
1422
  // vite.config.ts with root: "web": tsDom({ dirs: ["src"] })`
1216
1423
  },
1217
1424
  "tsc-not-found": {
1218
- why: "ts-dom check runs the TypeScript compiler of the project. Without it installed, nothing can be type-checked.",
1425
+ why: "ts-dom check runs the TypeScript compiler of the project. Without it installed, nothing can be type-checked, so this is an error: otherwise a CI job that omits the dev dependencies would pass without any type check.",
1219
1426
  bad: `npx ts-dom check # typescript is not installed`,
1220
1427
  good: `npm i -D typescript
1221
1428
  npx ts-dom check`
@@ -1227,9 +1434,10 @@ npx ts-dom check`
1227
1434
  { "compilerOptions": { "strict": true, "lib": ["ES2022", "DOM"] }, "include": ["src"] }`
1228
1435
  },
1229
1436
  "tsconfig-not-strict": {
1230
- why: "Without strict, tsc lets wrong names and null values through, which removes most of what the generated types are for.",
1437
+ why: "The names in the templates are checked with or without strict, but without it tsc does not report values that may be null (a querySelector result, an optional ref) or parameters that are implicitly any (a handler parameter without a type). TypeScript 6 and later turn strict on when it is left out; TypeScript 5 leaves it off, so there it has to be set. When the project's TypeScript cannot be resolved, its version is unknown, and a missing strict is reported as on TypeScript 5.",
1231
1438
  bad: `// tsconfig.json
1232
- { "compilerOptions": { "strict": false } }`,
1439
+ { "compilerOptions": { "strict": false } }
1440
+ // or, with TypeScript 5, no "strict" at all`,
1233
1441
  good: `// tsconfig.json
1234
1442
  { "compilerOptions": { "strict": true } }`
1235
1443
  },
@@ -1241,9 +1449,11 @@ npx ts-dom check`
1241
1449
  { "compilerOptions": { "lib": ["ES2022", "DOM"] } }`
1242
1450
  },
1243
1451
  "tsconfig-classic-resolution": {
1244
- 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.",
1452
+ why: "Every generated x.html.ts imports the runtime package (@nativele/ts-dom), and the classic resolution does not look in node_modules, so tsc reports TS2792 (Cannot find module) and no component gets its types. TypeScript 5 also resolves the classic way when moduleResolution is left out and module is ES2015 to ESNext (or target is ES2015 or later without module). TypeScript 6 deprecates classic (TS5107) and 7 removes it (TS5108); both stop before checking anything.",
1245
1453
  bad: `// tsconfig.json
1246
- { "compilerOptions": { "moduleResolution": "classic" } }`,
1454
+ { "compilerOptions": { "moduleResolution": "classic" } }
1455
+ // or, with TypeScript 5
1456
+ { "compilerOptions": { "module": "ESNext" } }`,
1247
1457
  good: `// tsconfig.json
1248
1458
  { "compilerOptions": { "moduleResolution": "bundler" } }`
1249
1459
  },
@@ -1253,6 +1463,15 @@ npx ts-dom check`
1253
1463
  { "include": ["src/server"] } // templates are in src/ui`,
1254
1464
  good: `// tsconfig.json
1255
1465
  { "include": ["src"] }`
1466
+ },
1467
+ "tsconfig-misses-tag-map": {
1468
+ why: "ts-dom-tags.ts registers the components in HTMLElementTagNameMap. Usually no file imports it, so it is in the program only when include matches it and exclude does not remove it; a file in the program that imports or re-exports it (a package entry with export type {} from \"./ts-dom-tags.js\") brings it in as well, and then nothing is reported. Left out, tags are typed as plain HTMLElement and lists of item components fail with item-not-in-tag-map. The x.html.ts files do not have this problem: their components import them. include, exclude and files are read through extends as tsc reads them: each from the nearest config that sets it.",
1469
+ bad: `// tsconfig.json
1470
+ { "include": ["src/**/*.tsx"] }
1471
+ { "include": ["src"], "exclude": ["**/ts-dom-tags.ts"] }`,
1472
+ good: `// tsconfig.json
1473
+ { "include": ["src"] }
1474
+ { "include": ["src/**/*.tsx", "src/ts-dom-tags.ts"] }`
1256
1475
  },
1257
1476
  "runtime-not-installed": {
1258
1477
  why: "Generated files import the runtime package. If it cannot be resolved from the project, they fail to compile and to run.",
@@ -1295,7 +1514,7 @@ import viteConfig from "./vite.config";
1295
1514
  export default mergeConfig(viteConfig, defineConfig({ test: { environment: "happy-dom" } }));`
1296
1515
  },
1297
1516
  "oxlint-plugin-missing": {
1298
- why: "The oxlint plugin runs the template rules (unused markup, data flow, accessibility) in the editor. Without it they only run from the CLI.",
1517
+ why: "The oxlint plugin runs the template rules (unused markup, data flow, accessibility) in the editor. Without it they only run from the CLI. In a package of a workspace, the nearest lint config up to the workspace root is read.",
1299
1518
  bad: `// .oxlintrc.json
1300
1519
  { "rules": {} }`,
1301
1520
  good: `// .oxlintrc.json
@@ -1305,7 +1524,7 @@ export default mergeConfig(viteConfig, defineConfig({ test: { environment: "happ
1305
1524
  }`
1306
1525
  },
1307
1526
  "formatter-whitespace": {
1308
- 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. The same warning is given when the scripts or the Vite config ask for collapse but the generated files were written with preserve: a run without --whitespace keeps the mode a file was last written with, so one run without the flag leaves the project on preserve.",
1527
+ 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. The same warning is given when the scripts or the Vite config ask for collapse but the generated files were written with preserve: a run without --whitespace keeps the mode a file was last written with, so one run without the flag leaves the project on preserve. In a package of a workspace, the formatter's config files, the prettier key, dependencies and scripts in package.json, and .prettierignore are looked up to the workspace root, nearest first.",
1309
1528
  bad: `# formatter is configured, templates keep their whitespace
1310
1529
  npx ts-dom gen
1311
1530
  # or: package.json has "gen": "ts-dom gen src --whitespace=collapse", but the files came from a plain run
@@ -1353,8 +1572,10 @@ Commands
1353
1572
  --json print the issues as JSON instead of text
1354
1573
  doctor [dirs...] Check the project set-up: tsconfig, template imports, generated files, the runtime package,
1355
1574
  and the Vite, oxlint and formatter configuration (default dir: src)
1575
+ --project=<path> tsconfig to check (default: tsconfig.json)
1356
1576
  --include=<glob>, --exclude=<glob>, --whitespace=collapse|preserve as for gen (they decide which
1357
1577
  generated files are stale)
1578
+ --strict exit with code 1 on warnings too (errors always do; info never does)
1358
1579
  --json print the issues as JSON instead of text
1359
1580
  skills List the Nativele Skills shipped with this version: task procedures for coding agents
1360
1581
  (set-up, a complete example, known limitations, verification), with the versions verified
@@ -1389,7 +1610,7 @@ function ruleGroups() {
1389
1610
  ],
1390
1611
  [
1391
1612
  "lint: unused-markup",
1392
- "errors from `ts-dom lint` / oxlint rule ts-dom/unused-markup (or ts-dom/<id>)",
1613
+ "errors from `ts-dom lint` / oxlint rule ts-dom/unused-markup (or ts-dom/<id>); no-importer only from `ts-dom lint`, as the oxlint plugin sees one file at a time and cannot tell that no file imports a template",
1393
1614
  UNUSED_MARKUP_RULES
1394
1615
  ],
1395
1616
  [
@@ -1549,13 +1770,15 @@ try {
1549
1770
  if (issues.filter((i) => i.severity === "error").length || flags.has("--strict") && issues.length) process.exitCode = 1;
1550
1771
  if (!flags.has("--json") && !issues.length) console.log("ts-dom check: no issues");
1551
1772
  } else if (cmd === "doctor") {
1773
+ const project = args.find((a) => a.startsWith("--project="))?.slice(10);
1552
1774
  const issues = runDoctor({
1553
1775
  dirs,
1554
- generate
1776
+ generate,
1777
+ ...project ? { project } : {}
1555
1778
  });
1556
1779
  if (flags.has("--json")) console.log(JSON.stringify({ issues }, null, 2));
1557
1780
  else for (const i of issues) (i.severity === "error" ? console.error : i.severity === "warning" ? console.warn : console.log)(formatDoctorIssue(i));
1558
- if (issues.some((i) => i.severity === "error")) process.exitCode = 1;
1781
+ if (issues.some((i) => i.severity === "error" || flags.has("--strict") && i.severity === "warning")) process.exitCode = 1;
1559
1782
  if (!flags.has("--json") && !issues.some((i) => i.severity !== "info")) console.log("ts-dom doctor: the set-up looks right");
1560
1783
  } else if (cmd === "lint") {
1561
1784
  const files = dirs.flatMap(listTs);