@nativele/ts-dom 0.5.2 → 0.5.4

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,9 +1,9 @@
1
1
  #!/usr/bin/env node
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-CV3nBgBn.mjs";
3
- import { c as lintUsage, n as UNUSED_MARKUP_RULES, o as formatIssue, s as lintDataFlow, t as DATA_FLOW_RULES } from "./lint-Bwytj7L8.mjs";
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";
4
4
  import { createRequire } from "node:module";
5
5
  import { cpSync, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
6
- import { dirname, join, relative, resolve } from "node:path";
6
+ import { dirname, join, relative, resolve, sep } from "node:path";
7
7
  import { spawnSync } from "node:child_process";
8
8
  import { fileURLToPath } from "node:url";
9
9
  // src/lib/tsconfig.ts
@@ -164,13 +164,22 @@ function mapTscErrors(errors, cwd) {
164
164
  }
165
165
  };
166
166
  const brokenClasses = /* @__PURE__ */ new Set();
167
+ const brokenBodies = [];
167
168
  for (const e of errors) {
168
169
  if (!/missingStateKeys|conflictingKeys|reservedPropNames|nativeEventNames/.test(e.message)) continue;
169
- const line = readFileSync(resolve(cwd, e.file), "utf8").split("\n")[e.line - 1] ?? "";
170
- const name = /class\s+([A-Za-z_$][\w$]*)\s+extends\s+Component\s*\(/.exec(line)?.[1];
171
- if (name) brokenClasses.add(name);
170
+ const text = readFileSync(resolve(cwd, e.file), "utf8");
171
+ const lines = text.split("\n");
172
+ const m = /class\s+([A-Za-z_$][\w$]*)\s+extends\s+Component\s*\(/.exec(lines[e.line - 1] ?? "");
173
+ if (!m?.[1]) continue;
174
+ brokenClasses.add(m[1]);
175
+ 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
+ if (end !== -1) brokenBodies.push({
177
+ file: rel(resolve(cwd, e.file)),
178
+ from: e.line,
179
+ to: text.slice(0, end).split("\n").length
180
+ });
172
181
  }
173
- const followsBroken = (message) => [...brokenClasses].some((c) => message.includes(`'${c}'`) || message.includes(`'typeof ${c}'`));
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);
174
183
  for (const e of errors) {
175
184
  const file = resolve(cwd, e.file);
176
185
  const where = `${rel(file)}:${e.line}:${e.col}`;
@@ -208,9 +217,31 @@ function mapTscErrors(errors, cwd) {
208
217
  named = true;
209
218
  issue(rule, `${e.message.split("\n")[0]} Fix: ${fix}.`);
210
219
  }
211
- if (!named) issue("tsc", `${e.code}: ${e.message} Fix: change the TypeScript at this location so the types agree; ts-dom rules --explain=tsc.`);
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.`);
222
+ else issue("tsc", `${e.code}: ${e.message} Fix: change the TypeScript at this location so the types agree; ts-dom rules --explain=tsc.`);
223
+ }
224
+ return out.filter((i) => i.rule !== "tsc" || !followsBroken(i));
225
+ }
226
+ /** 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
+ function classBodyEnd(text, open) {
228
+ let depth = 0;
229
+ let inBody = false;
230
+ for (let i = open; i < text.length; i++) {
231
+ const c = text[i];
232
+ if (c === "\"" || c === "'" || c === "`") {
233
+ for (i++; i < text.length && text[i] !== c; i++) if (text[i] === "\\") i++;
234
+ } else if (c === "/" && text[i + 1] === "/") i = text.indexOf("\n", i) === -1 ? text.length : text.indexOf("\n", i);
235
+ else if (c === "/" && text[i + 1] === "*") i = text.indexOf("*/", i) === -1 ? text.length : text.indexOf("*/", i) + 1;
236
+ else if ("([{".includes(c)) {
237
+ if (c === "{" && depth === 0) inBody = true;
238
+ depth++;
239
+ } else if (")]}".includes(c)) {
240
+ depth--;
241
+ if (depth === 0 && inBody) return i;
242
+ }
212
243
  }
213
- return out.filter((i) => i.rule !== "tsc" || !followsBroken(i.message));
244
+ return -1;
214
245
  }
215
246
  function findTsc(cwd) {
216
247
  const req = createRequire(join(cwd, "package.json"));
@@ -272,16 +303,22 @@ function runCheck(options) {
272
303
  message: e.message.replace(/^[^[]*\[duplicate-define\]\s*/, "")
273
304
  });
274
305
  }
275
- if (options.write === false) for (const dir of dirs) for (const s of staleGenerated(dir, generate)) issues.push({
276
- file: rel(s.file),
277
- line: 1,
278
- col: 1,
279
- rule: "stale-generated",
280
- severity: "error",
281
- 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).`
306
+ for (const d of crossDirDuplicates(dirs)) issues.push({
307
+ ...d,
308
+ file: rel(d.file)
282
309
  });
283
- else for (const dir of dirs) try {
284
- generateAll(dir, generate);
310
+ if (options.write === false) {
311
+ const defs = scanDefines(dirs);
312
+ for (const dir of dirs) for (const s of staleGenerated(dir, generate, defs, dirs)) issues.push({
313
+ file: rel(s.file),
314
+ line: 1,
315
+ col: 1,
316
+ rule: "stale-generated",
317
+ severity: "error",
318
+ 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).`
319
+ });
320
+ } else try {
321
+ generateDirs(dirs, generate);
285
322
  } catch (e) {
286
323
  if (!(e instanceof CodegenError)) throw e;
287
324
  }
@@ -445,9 +482,11 @@ const DOCTOR_RULES = {
445
482
  "runtime-not-installed": "the runtime package the generated files import cannot be resolved",
446
483
  "template-import-without-js": "a template is imported as ./x.html instead of ./x.html.js",
447
484
  "stale-generated": "a generated file is missing, outdated or orphaned",
448
- "vite-plugin-missing": "the Vite config does not use the ts-dom plugin, so templates are not regenerated while the dev server runs",
485
+ "framework-template": "a scanned .html is an Angular component's templateUrl, not a Nativele template",
486
+ "typecheck-before-gen": "a package.json script type-checks before ts-dom gen while *.html.ts is git-ignored, so it fails on a clean checkout",
487
+ "vite-plugin-missing": "the Vite config, or a vitest.config that replaces it for Vitest, does not use the ts-dom plugin, so templates are not generated by the dev server or the tests",
449
488
  "oxlint-plugin-missing": "the lint config does not load the ts-dom oxlint plugin, so the template rules do not run in the editor",
450
- "formatter-whitespace": "a formatter is set up while templates keep their whitespace, so reformatting can change the rendered spacing",
489
+ "formatter-whitespace": "a formatter is set up while templates keep their whitespace, so reformatting can change the rendered spacing; or the scripts or Vite config ask for collapse while the generated files were written with preserve",
451
490
  "skill-available": "the project uses a tool that a Nativele Skill covers, and the project has no copy of that Skill",
452
491
  "skill-outdated": "the project's copy of a Nativele Skill differs from the one shipped with the installed version"
453
492
  };
@@ -461,6 +500,24 @@ function covers(pattern, dir) {
461
500
  if (pattern.slice(star).startsWith("**")) return d === prefix || d.startsWith(`${prefix}/`);
462
501
  return d === prefix;
463
502
  }
503
+ const TYPECHECK = /(?:^|\s)(?:npx\s+)?(tsc|vue-tsc|svelte-check|ng\s+(?:build|serve|test))(?=\s|$)/;
504
+ const TSC_PROJECT = /(?:^|\s)(?:npx\s+)?(?:vue-)?tsc(?:\s.*?)?\s(?:-p|--project)[\s=]+(\S+)/;
505
+ const GENERATES = /(?:^|\s)ts-dom\s+(?:gen|check)(?=\s|$)/;
506
+ const RUN_SCRIPT = /^(?:npm\s+run(?:-script)?|pnpm(?:\s+run)?|yarn(?:\s+run)?|bun\s+run)\s+([\w:.-]+)/;
507
+ /** The commands a script runs in order, with its pre and post scripts and the scripts it runs through npm run (or pnpm, yarn, bun) spelled out. */
508
+ function scriptSteps(scripts, name, depth = 0) {
509
+ const command = scripts[name];
510
+ if (depth > 5 || command === void 0) return [];
511
+ const own = command.split(/&&|;/).flatMap((step) => {
512
+ const ref = RUN_SCRIPT.exec(step.trim())?.[1];
513
+ return ref && scripts[ref] !== void 0 ? scriptSteps(scripts, ref, depth + 1) : [step.trim()];
514
+ });
515
+ return [
516
+ ...scriptSteps(scripts, `pre${name}`, depth + 1),
517
+ ...own,
518
+ ...scriptSteps(scripts, `post${name}`, depth + 1)
519
+ ];
520
+ }
464
521
  const templateFolders = (dir) => [...new Set(listHtml(dir).map((f) => dirname(f)))];
465
522
  const hasTemplates = (dir) => listHtml(dir).length > 0;
466
523
  /** Check the project set-up an agent or a developer could get wrong; every issue names its rule and ends with a Fix hint. */
@@ -522,6 +579,7 @@ function runDoctor(options) {
522
579
  resolvable = false;
523
580
  }
524
581
  if (!resolvable) add("runtime-not-installed", "error", `The generated files import "${runtime}", which cannot be resolved from this project. Fix: npm i ${runtime}.`);
582
+ const defs = scanDefines(dirs);
525
583
  for (const d of dirs) {
526
584
  for (const f of listTs(d)) readFileSync(f, "utf8").split("\n").forEach((text, i) => {
527
585
  const m = /from\s+["'](\.{1,2}\/[^"']+\.html)["']/.exec(text);
@@ -530,7 +588,14 @@ function runDoctor(options) {
530
588
  line: i + 1
531
589
  });
532
590
  });
533
- 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) });
591
+ for (const s of staleGenerated(d, generate, defs, dirs)) 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) });
592
+ }
593
+ const scannedHtml = new Set(dirs.flatMap(listHtml));
594
+ if (scannedHtml.size) for (const f of listTs(cwd)) for (const m of readFileSync(f, "utf8").matchAll(/templateUrl\s*:\s*["'`]([^"'`]+\.html)["'`]/g)) {
595
+ const html = resolve(dirname(f), m[1]);
596
+ if (!scannedHtml.has(html)) continue;
597
+ scannedHtml.delete(html);
598
+ add("framework-template", "error", `${rel(html)} is the Angular template of ${rel(f)} (templateUrl), not a Nativele template, so ts-dom gen reports its Angular syntax as errors. Fix: scan only the folder with the Nativele templates (for example ts-dom gen src/nv, and the same folder for check, doctor and tsDom({ dirs })).`, { file: rel(html) });
534
599
  }
535
600
  const viteConfig = [
536
601
  "vite.config.ts",
@@ -540,6 +605,16 @@ function runDoctor(options) {
540
605
  ].map((n) => join(cwd, n)).find((f) => existsSync(f));
541
606
  const vite = viteConfig ? readText(viteConfig) : null;
542
607
  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) });
608
+ const vitestConfig = [
609
+ "ts",
610
+ "mts",
611
+ "cts",
612
+ "js",
613
+ "mjs",
614
+ "cjs"
615
+ ].map((ext) => join(cwd, `vitest.config.${ext}`)).find((f) => existsSync(f));
616
+ const vitest = vitestConfig ? readText(vitestConfig) : null;
617
+ 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) });
543
618
  const oxlintrc = [".oxlintrc.json", "oxlintrc.json"].map((n) => join(cwd, n)).find((f) => existsSync(f));
544
619
  const lintConfigs = [oxlintrc ? readText(oxlintrc) : null, vite && /\blint\s*:/.test(vite) ? vite : null].filter((t) => t !== null);
545
620
  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) });
@@ -550,6 +625,31 @@ function runDoctor(options) {
550
625
  ...pkg.devDependencies
551
626
  };
552
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)) {
629
+ const all = pkg.scripts ?? {};
630
+ const checksTemplates = (step) => {
631
+ const project = TSC_PROJECT.exec(step)?.[1];
632
+ if (!project && !/(?:^|\s)(?:npx\s+)?(?:vue-)?tsc(?=\s|$)/.test(step)) return true;
633
+ const config = resolve(cwd, project ?? "tsconfig.json");
634
+ const configs = projectConfigs(cwd, existsSync(config) && statSync(config).isDirectory() ? join(config, "tsconfig.json") : config);
635
+ return !configs.length || configs.some((c) => {
636
+ const include = readTsConfig(c).include;
637
+ return !include || targets.some((t) => include.some((p) => covers(p, t)));
638
+ });
639
+ };
640
+ const dirArgs = options.dirs.join(" ");
641
+ for (const name of Object.keys(all)) {
642
+ if (/^(pre|post)./.test(name) && all[name.replace(/^(pre|post)/, "")] !== void 0) continue;
643
+ const step = scriptSteps(all, name).find((s) => GENERATES.test(s) || TYPECHECK.test(s) && checksTemplates(s));
644
+ if (step === void 0 || GENERATES.test(step)) continue;
645
+ const tool = TYPECHECK.exec(step)[1]?.replace(/\s+/g, " ");
646
+ const line = pkgText.split("\n").findIndex((l) => l.includes(`${JSON.stringify(name)}:`)) + 1;
647
+ add("typecheck-before-gen", "warning", `The script "${name}" runs ${tool} before any ts-dom gen, and .gitignore leaves out *.html.ts, so on a clean checkout ${tool} fails with TS2307 (cannot find module './x.html.js'). Fix: put "ts-dom gen ${dirArgs} && " in front of it, or use "ts-dom check ${dirArgs}" as the type check when the project uses plain tsc.`, {
648
+ file: "package.json",
649
+ ...line ? { line } : {}
650
+ });
651
+ }
652
+ }
553
653
  const ignoredHtml = /(^|\n)\s*(\*\*\/)?\*\.html\s*($|\n)/.test(readText(join(cwd, ".prettierignore")) ?? "");
554
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 && ([
555
655
  ".prettierrc",
@@ -558,7 +658,22 @@ function runDoctor(options) {
558
658
  "prettier.config.js",
559
659
  "prettier.config.mjs"
560
660
  ].some((n) => existsSync(join(cwd, n))) || "prettier" in deps || scripts.some((c) => /(^|[\s;&|])prettier(\s|$)/.test(c)));
561
- 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")));
661
+ const collapseScript = Object.entries(pkg.scripts ?? {}).find(([, c]) => c.includes("--whitespace=collapse"))?.[0];
662
+ const collapseVite = vite !== null && /whitespace\s*:\s*["']collapse["']/.test(vite);
663
+ const generatedModes = dirs.flatMap(listHtml).flatMap((f) => {
664
+ const head = (readText(`${f}.ts`) ?? "").split("\n", 3);
665
+ return head[0]?.startsWith("// Generated by ts-dom.") ? [{
666
+ file: `${f}.ts`,
667
+ collapse: head.includes("// whitespace: collapse")
668
+ }] : [];
669
+ });
670
+ const preserved = generatedModes.filter((g) => !g.collapse);
671
+ if ((collapseScript || collapseVite) && preserved.length) {
672
+ const asker = collapseScript ? `The script "${collapseScript}"` : "The Vite config";
673
+ const rerun = collapseScript ? `npm run ${collapseScript}` : `ts-dom gen ${options.dirs.join(" ")} --whitespace=collapse`;
674
+ 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
+ }
676
+ const collapsed = collapseScript !== void 0 || collapseVite || generatedModes.some((g) => g.collapse);
562
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\" })).");
563
678
  for (const skill of listSkills(options.skillsDir ?? skillsRoot())) {
564
679
  const copies = installedCopies(cwd, skill.name);
@@ -1023,7 +1138,7 @@ this.refs.list.items = ["a", "b"];`
1023
1138
  good: `<button data-on-click="open">open</button>`
1024
1139
  },
1025
1140
  "stale-generated": {
1026
- 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.",
1141
+ why: "Types come from the generated *.html.ts files. If one is missing, outdated or orphaned (its template was deleted, or a ts-dom-tags.ts was left in a subfolder that is no longer scanned and declares its tags a second time), tsc checks against old templates and its errors may not match the HTML. ts-dom doctor reports the same condition as a warning.",
1027
1142
  bad: `# card.html was edited, card.html.ts was not regenerated
1028
1143
  npx ts-dom check --no-write`,
1029
1144
  good: `npx ts-dom gen
@@ -1093,9 +1208,11 @@ const n: number = 1;`
1093
1208
  good: `npx ts-dom check --project=tsconfig.json`
1094
1209
  },
1095
1210
  "dir-not-found": {
1096
- 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.",
1097
- bad: `npx ts-dom check ap # the templates live in app/`,
1098
- good: `npx ts-dom check app`
1211
+ why: "A folder given to ts-dom check or doctor, or to tsDom({ dirs }) in the Vite plugin, that does not exist would otherwise be skipped silently, and a clean result would only mean that nothing was looked at. The Vite plugin resolves dirs against Vite's root, not the working directory.",
1212
+ bad: `npx ts-dom check ap # the templates live in app/
1213
+ // vite.config.ts with root: "web": tsDom({ dirs: ["web/src"] }) looks for web/web/src`,
1214
+ good: `npx ts-dom check app
1215
+ // vite.config.ts with root: "web": tsDom({ dirs: ["src"] })`
1099
1216
  },
1100
1217
  "tsc-not-found": {
1101
1218
  why: "ts-dom check runs the TypeScript compiler of the project. Without it installed, nothing can be type-checked.",
@@ -1147,13 +1264,35 @@ npx ts-dom check`
1147
1264
  bad: `import tpl from "./card.html";`,
1148
1265
  good: `import tpl from "./card.html.js";`
1149
1266
  },
1267
+ "framework-template": {
1268
+ why: "Every .html in a scanned folder is read as a Nativele template. An Angular component's templateUrl file uses Angular syntax ({{ user.name }}, *ngIf, (click)), so gen reports errors whose hints ask to rewrite a file that belongs to another framework.",
1269
+ bad: `// src/app/app.ts
1270
+ @Component({ selector: "app-root", templateUrl: "./app.html" })
1271
+ # package.json
1272
+ "build": "ts-dom gen src && ng build"`,
1273
+ good: `# keep the Nativele components in their own folder and scan only it
1274
+ "build": "ts-dom gen src/nv && ng build"`
1275
+ },
1276
+ "typecheck-before-gen": {
1277
+ why: "The generated *.html.ts files are not committed. A script that runs tsc (or vue-tsc, svelte-check, ng build) before ts-dom gen works on a machine that has generated them once, and fails on a clean checkout or in CI with TS2307 for every ./x.html.js import.",
1278
+ bad: `// package.json, with *.html.ts in .gitignore
1279
+ "build": "tsc && vite build"`,
1280
+ good: `"build": "ts-dom gen src && tsc && vite build"
1281
+ // or, with plain tsc, let check generate and type-check in one step:
1282
+ "typecheck": "ts-dom check src"`
1283
+ },
1150
1284
  "vite-plugin-missing": {
1151
- 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.",
1285
+ 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. A vitest.config file replaces vite.config for Vitest, so unless it has the plugin too or merges vite.config, the tests start without generated files and fail on a clean checkout.",
1152
1286
  bad: `// vite.config.ts
1153
- export default defineConfig({ plugins: [] });`,
1287
+ export default defineConfig({ plugins: [] });
1288
+ // vitest.config.ts next to a vite.config.ts that has tsDom()
1289
+ export default defineConfig({ test: { environment: "happy-dom" } });`,
1154
1290
  good: `// vite.config.ts
1155
1291
  import { tsDom } from "@nativele/ts-dom/vite";
1156
- export default defineConfig({ plugins: [tsDom()] });`
1292
+ export default defineConfig({ plugins: [tsDom()] });
1293
+ // vitest.config.ts
1294
+ import viteConfig from "./vite.config";
1295
+ export default mergeConfig(viteConfig, defineConfig({ test: { environment: "happy-dom" } }));`
1157
1296
  },
1158
1297
  "oxlint-plugin-missing": {
1159
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.",
@@ -1166,11 +1305,14 @@ export default defineConfig({ plugins: [tsDom()] });`
1166
1305
  }`
1167
1306
  },
1168
1307
  "formatter-whitespace": {
1169
- 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.",
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.",
1170
1309
  bad: `# formatter is configured, templates keep their whitespace
1171
- npx ts-dom gen`,
1310
+ npx ts-dom gen
1311
+ # or: package.json has "gen": "ts-dom gen src --whitespace=collapse", but the files came from a plain run
1312
+ npx ts-dom check src`,
1172
1313
  good: `npx ts-dom gen --whitespace=collapse
1173
- // or in vite.config.ts: tsDom({ whitespace: "collapse" })`
1314
+ // or in vite.config.ts: tsDom({ whitespace: "collapse" })
1315
+ # and after a plain run: npm run gen (the script with the flag) once`
1174
1316
  },
1175
1317
  "skill-available": {
1176
1318
  why: "A Nativele Skill gives an agent the verified steps, limitations and checks for a tool the project already uses. This is a suggestion and never changes the exit code.",
@@ -1205,11 +1347,14 @@ Commands
1205
1347
  --no-write write nothing: report stale generated files instead (for a CI job that must not
1206
1348
  change the working tree; gen --check only checks the generated files)
1207
1349
  --project=<path> tsconfig for tsc (default: tsc's own lookup)
1350
+ --include=<glob>, --exclude=<glob>, --whitespace=collapse|preserve as for gen
1208
1351
  --a11y also check ARIA / accessibility (warnings)
1209
1352
  --strict exit with code 1 on warnings too (errors always do)
1210
1353
  --json print the issues as JSON instead of text
1211
1354
  doctor [dirs...] Check the project set-up: tsconfig, template imports, generated files, the runtime package,
1212
1355
  and the Vite, oxlint and formatter configuration (default dir: src)
1356
+ --include=<glob>, --exclude=<glob>, --whitespace=collapse|preserve as for gen (they decide which
1357
+ generated files are stale)
1213
1358
  --json print the issues as JSON instead of text
1214
1359
  skills List the Nativele Skills shipped with this version: task procedures for coding agents
1215
1360
  (set-up, a complete example, known limitations, verification), with the versions verified
@@ -1355,9 +1500,10 @@ const dirs = args.filter((a) => !a.startsWith("--"));
1355
1500
  if (!dirs.length) dirs.push("src");
1356
1501
  try {
1357
1502
  if (cmd === "gen" && flags.has("--check")) {
1358
- const results = dirs.map((d) => checkGenerated(d, generate));
1503
+ const defs = scanDefines(dirs);
1504
+ const results = dirs.map((d) => checkGenerated(d, generate, defs, dirs));
1359
1505
  const stale = results.flatMap((r) => r.stale);
1360
- const errors = results.flatMap((r) => r.errors);
1506
+ const errors = [...results.flatMap((r) => r.errors), ...crossDirDuplicates(dirs).map((d) => formatDiagnostics(d.file, [d]))];
1361
1507
  if (flags.has("--json")) console.log(JSON.stringify({
1362
1508
  stale: stale.map((s) => ({
1363
1509
  ...s,
@@ -1373,8 +1519,8 @@ try {
1373
1519
  if (stale.length || errors.length) process.exitCode = 1;
1374
1520
  } else if (cmd === "gen") {
1375
1521
  let failed = false;
1376
- for (const d of dirs) try {
1377
- generateAll(d, generate);
1522
+ try {
1523
+ generateDirs(dirs, generate);
1378
1524
  } catch (e) {
1379
1525
  if (!(e instanceof CodegenError) || !flags.has("--watch")) throw e;
1380
1526
  console.error(e.message);
@@ -1,4 +1,4 @@
1
- import { a as analyze, f as listHtml, h as scanDefines } from "./a11y-CV3nBgBn.mjs";
1
+ import { a as analyze, g as scanDefines, p as listHtml } from "./a11y-CiEJj_e6.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { basename, dirname, relative } from "node:path";
4
4
  // src/lib/lint.ts
@@ -126,7 +126,7 @@ function lintUsage(dirs, tsFiles) {
126
126
  severity: "error",
127
127
  line: 1,
128
128
  col: 1,
129
- message: `No TypeScript file imports this template. Fix: add \`import tpl from "./${name}.js"\` and \`class X extends Component(tpl)\` next to it, or delete the file.`
129
+ message: `No TypeScript file imports this template. Fix: add \`import tpl from "./${name}.js"\` and \`class X extends Component(tpl)\` next to it, or delete the file; if it is not a Nativele template, this folder is not meant to be scanned: scan only the folder with the Nativele templates.`
130
130
  });
131
131
  continue;
132
132
  }
@@ -1,5 +1,5 @@
1
- import { n as checkA11y, t as A11Y_RULES } from "./a11y-CV3nBgBn.mjs";
2
- import { a as dataFlow, i as contractsFor, l as templateCode, n as UNUSED_MARKUP_RULES, r as componentClasses, t as DATA_FLOW_RULES, u as unusedMarkup } from "./lint-Bwytj7L8.mjs";
1
+ import { n as checkA11y, t as A11Y_RULES } from "./a11y-CiEJj_e6.mjs";
2
+ import { a as dataFlow, i as contractsFor, l as templateCode, n as UNUSED_MARKUP_RULES, r as componentClasses, t as DATA_FLOW_RULES, u as unusedMarkup } from "./lint-Did4w-NL.mjs";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { dirname, resolve } from "node:path";
5
5
  // src/lib/oxlint-plugin.ts
@@ -1,6 +1,6 @@
1
- import { c as generateAll, d as isSelected, f as listHtml, h as scanDefines, i as CodegenError, l as generateFile, n as checkA11y, s as formatDiagnostics, u as isFullPage } from "./a11y-CV3nBgBn.mjs";
1
+ import { c as formatDiagnostics, d as isFullPage, f as isSelected, g as scanDefines, i as CodegenError, l as generateDirs, n as checkA11y, p as listHtml, u as generateFile } from "./a11y-CiEJj_e6.mjs";
2
2
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
3
- import { resolve, sep } from "node:path";
3
+ import { relative, resolve, sep } from "node:path";
4
4
  // src/lib/vite-plugin.ts
5
5
  const GENERATED_HEADER = "// Generated by ts-dom.";
6
6
  const slashes = (file) => file.split(sep).join("/");
@@ -17,18 +17,13 @@ function tsDom(opts = {}) {
17
17
  exclude: opts.exclude
18
18
  };
19
19
  const a11y = opts.a11y === true ? {} : opts.a11y || null;
20
+ let root = process.cwd();
20
21
  let dirs = (opts.dirs ?? ["src"]).map((d) => resolve(d));
21
22
  const shown = /* @__PURE__ */ new Map();
22
- const defsByDir = /* @__PURE__ */ new Map();
23
- const defsOf = (dir) => {
24
- let defs = defsByDir.get(dir);
25
- if (!defs) defsByDir.set(dir, defs = existsSync(dir) ? scanDefines(dir) : []);
26
- return defs;
27
- };
28
- const isDefineFile = (file) => {
29
- const dir = dirOf(file);
30
- return dir !== void 0 && defsOf(dir).some((d) => resolve(d.file) === file);
31
- };
23
+ let defs;
24
+ const existing = () => dirs.filter((d) => existsSync(d));
25
+ const allDefs = () => defs ??= scanDefines(existing());
26
+ const isDefineFile = (file) => allDefs().some((d) => resolve(d.file) === file);
32
27
  const dirOf = (file) => dirs.find((d) => file.startsWith(d + sep));
33
28
  const warnA11y = (warn) => {
34
29
  if (!a11y) return;
@@ -48,7 +43,7 @@ function tsDom(opts = {}) {
48
43
  const out = `${file}.ts`;
49
44
  const before = read(out);
50
45
  try {
51
- generateFile(file, generate, defsOf(dir));
46
+ generateFile(file, generate, allDefs());
52
47
  } catch (e) {
53
48
  if (e instanceof CodegenError) logger.error(e.message);
54
49
  else throw e;
@@ -57,16 +52,16 @@ function tsDom(opts = {}) {
57
52
  if (a11y) for (const diag of checkA11y(html, a11y.ignore)) logger.warn(formatDiagnostics(file, [diag]));
58
53
  return read(out) !== before;
59
54
  };
60
- /** Regenerate a directory after a define() call may have changed: the tag map and the child imports of every template. */
61
- const regenerateDir = (dir, logger) => {
62
- const before = new Map(listHtml(dir).map((f) => [`${f}.ts`, read(`${f}.ts`)]));
55
+ /** Regenerate after a define() call may have changed: the tag maps and the child imports of every template. */
56
+ const regenerateAll = (logger) => {
57
+ const before = new Map(existing().flatMap(listHtml).map((f) => [`${f}.ts`, read(`${f}.ts`)]));
63
58
  try {
64
- generateAll(dir, generate);
59
+ generateDirs(existing(), generate);
65
60
  } catch (e) {
66
61
  if (e instanceof CodegenError) logger.error(e.message);
67
62
  else throw e;
68
63
  }
69
- defsByDir.set(dir, scanDefines(dir));
64
+ defs = scanDefines(existing());
70
65
  for (const [out, content] of before) {
71
66
  const now = read(out);
72
67
  if (now !== null && now !== content) shown.set(out, now);
@@ -89,11 +84,14 @@ function tsDom(opts = {}) {
89
84
  name: "ts-dom",
90
85
  enforce: "pre",
91
86
  configResolved(config) {
87
+ root = config.root;
92
88
  dirs = (opts.dirs ?? ["src"]).map((d) => resolve(config.root, d));
89
+ defs = void 0;
93
90
  },
94
91
  buildStart() {
92
+ for (const d of dirs) if (!existsSync(d)) this.warn(`${relative(process.cwd(), d) || "."}:1:1 warning [dir-not-found] The folder ${d} given in tsDom({ dirs }) does not exist, so no templates in it are generated. Fix: dirs are resolved against Vite's root (${root}), not the working directory; give the folder relative to that root, or an absolute path.`);
95
93
  try {
96
- for (const d of dirs) if (existsSync(d)) generateAll(d, generate);
94
+ generateDirs(existing(), generate);
97
95
  } catch (e) {
98
96
  if (e instanceof CodegenError) this.error(e.message);
99
97
  throw e;
@@ -105,15 +103,13 @@ function tsDom(opts = {}) {
105
103
  server.watcher.add(dirs);
106
104
  server.watcher.on("add", (f) => {
107
105
  const file = resolve(f);
108
- const dir = dirOf(file);
109
106
  if (file.endsWith(".html")) regenerate(file, logger);
110
- else if (dir && isSourceTs(file) && read(file)?.includes("define(")) regenerateDir(dir, logger);
107
+ else if (dirOf(file) && isSourceTs(file) && read(file)?.includes("define(")) regenerateAll(logger);
111
108
  });
112
109
  server.watcher.on("unlink", (f) => {
113
110
  const file = resolve(f);
114
- const dir = dirOf(file);
115
111
  if (file.endsWith(".html")) removeGenerated(file);
116
- else if (dir && isDefineFile(file)) regenerateDir(dir, logger);
112
+ else if (dirOf(file) && isDefineFile(file)) regenerateAll(logger);
117
113
  });
118
114
  },
119
115
  handleHotUpdate(ctx) {
@@ -130,9 +126,8 @@ function tsDom(opts = {}) {
130
126
  if (regenerate(file, logger)) reload(ctx.server, `${file}.ts`);
131
127
  return [];
132
128
  }
133
- const dir = dirOf(file);
134
- if (!dir || !isSourceTs(file)) return;
135
- if (read(file)?.includes("define(") || isDefineFile(file)) regenerateDir(dir, logger);
129
+ if (!dirOf(file) || !isSourceTs(file)) return;
130
+ if (read(file)?.includes("define(") || isDefineFile(file)) regenerateAll(logger);
136
131
  }
137
132
  };
138
133
  }
package/llms.txt CHANGED
@@ -79,7 +79,7 @@ define("my-counter", MyCounter);
79
79
  - `data-each` is never nested: a list inside an item is the item component's own `data-each`.
80
80
 
81
81
  ## Rules for agents
82
- - Skills: `npx ts-dom skills` lists the task procedures shipped with the package (`nativele-core` for building components, `nativele-react`, `nativele-vue`, `nativele-svelte` and `nativele-angular` for those frameworks, `nativele-vite` for Vite, `nativele-integration` for other frameworks, `nativele-state-management`, `nativele-testing`, `nativele-tanstack-query`, `nativele-zod` and `nativele-tailwind` for those tools, ...), each with verified versions; `npx ts-dom skills <name>` prints one, `npx ts-dom skills add <name>` copies it into `.claude/skills/` or `.agents/skills/`, and `npx ts-dom doctor` suggests the ones that match the project's dependencies.
82
+ - Skills: `npx ts-dom skills` lists the task procedures shipped with the package (`nativele-core` for building components, `nativele-react`, `nativele-vue`, `nativele-svelte` and `nativele-angular` for those frameworks, `nativele-vite` for Vite, `nativele-integration` for other frameworks, `nativele-state-management`, `nativele-testing`, `nativele-tanstack-query`, `nativele-zod` and `nativele-tailwind` for those tools, `nativele-architecture` for laying out an app with several pages, ...), each with verified versions; `npx ts-dom skills <name>` prints one, `npx ts-dom skills add <name>` copies it into `.claude/skills/` or `.agents/skills/`, and `npx ts-dom doctor` suggests the ones that match the project's dependencies.
83
83
  - `npx ts-dom rules --explain=<id>` (or `rules --json`) explains any rule id from a diagnostic: why it exists, a wrong example and the fix.
84
84
  - Markup well-formedness is a formatter's job: `oxfmt --check` reports misnested tags that the library's parser would silently repair. Generate with `--whitespace=collapse` when a formatter is used.
85
85
  - If checks behave oddly (no types, a template not found), run `npx ts-dom doctor --json` first: it reports tsconfig, import path, runtime, generated file and plugin problems with a `Fix:` each.
@@ -101,8 +101,10 @@ In a browser without a bundler map the bare name once: `<script type="importmap"
101
101
  - `data-if` content is hidden until the name is truthy (it stays in memory, so its `refs` still work); `{{ hole }}` stays empty until filled.
102
102
  - With Vite, `package.json` needs `"type": "module"`.
103
103
  - `slotchange()` is Light-DOM only; with `shadow: true` use the native `slotchange` event.
104
+ - A Light DOM component builds its content when connected (a `shadow: true` one in the constructor), so a new Light element has no children, `refs` or `text` until it is appended.
105
+ - Inside a framework (React, Vue, Svelte, ...), children the framework later inserts, removes or reorders in a Light DOM host with slots break or land in the wrong slot: use `shadow: true`, or one stable element per slot.
104
106
 
105
107
  ## Docs
106
108
  - [README](README.md): introduction, the short version, rules for agents, quick start. [README.zh-TW.md](README.zh-TW.md): the same in Traditional Chinese.
107
- - Guides (English): [templates](docs/templates.md), [components and data flow](docs/components.md), [lists](docs/lists.md), [setup, checks and lint](docs/tooling.md), [performance](docs/performance.md), [design](docs/design.md), [developing Nativele](docs/contributing.md).
109
+ - Guides (English): [templates](docs/templates.md), [components and data flow](docs/components.md), [recipes](docs/recipes.md), [lists](docs/lists.md), [architecture](docs/architecture.md), [setup, checks and lint](docs/tooling.md), [performance](docs/performance.md), [design](docs/design.md), [developing Nativele](docs/contributing.md).
108
110
  - `npx ts-dom rules`: every rule id with a description.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nativele/ts-dom",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Designed for AI agents. Driven by TypeScript. Defined in vanilla HTML. A lightweight library for building web components: typed templates, native custom elements, no virtual DOM, no template expressions, no bundler required.",
5
5
  "keywords": [
6
6
  "html",
package/skills/README.md CHANGED
@@ -22,7 +22,7 @@ skills/
22
22
  │ └── example/ # Official only: the project the Skill is verified with
23
23
  │ ├── package.json # exact versions of every tool the Skill names
24
24
  │ ├── ... # the minimal app the SKILL.md example comes from
25
- │ └── verify.mjs # the browser checks `npm run verify` ends with
25
+ │ └── verify.mjs # browser checks run by `npm run verify` (or the project's own tests, see below)
26
26
  └── ...
27
27
  ```
28
28
 
@@ -90,7 +90,7 @@ None of this adds a runtime dependency: listing and copying read the Markdown fi
90
90
 
91
91
  ## Verification of Official Skills
92
92
 
93
- - `skills/<name>/example/package.json` pins every tool version the Skill names, depends on the library through `file:../../..` (the library in the same repository), and has `npm run verify`, which runs `ts-dom check`, `ts-dom doctor`, `tsc`, the build, and a short real-browser test of what the Skill promises.
93
+ - `skills/<name>/example/package.json` pins every tool version the Skill names, depends on the library through `file:../../..` (the library in the same repository), and has `npm run verify`, which runs `ts-dom check`, `ts-dom doctor`, `tsc`, the build, and a real-browser test of what the Skill promises: usually a `verify.mjs` script; an example whose Skill sets up tests (`nativele-testing`, `nativele-architecture`) runs its own Vitest and Playwright Test suites instead.
94
94
  - `nativele-core` has no example of its own: the library's CI, which checks every example in the repository, verifies it.
95
95
  - The workflow `.github/workflows/skills.yml` runs `npm run verify` for each Official Skill when an `example/` changes, when the library's runtime changes, and once a month, so a third-party release that breaks a Skill is found. A change to `SKILL.md` runs only `.github/workflows/skills-format.yml`, which checks every `SKILL.md` against this specification. Neither is a required check for `main`.
96
96
  - The monthly run opens nothing by itself; a failure is reported in the workflow run, and the Skill is fixed or moved to Experimental.
@@ -135,4 +135,5 @@ Otherwise the maintainers send the gaps back to the contributor as review commen
135
135
  | `nativele-tanstack-query` | Experimental | available | `@tanstack/query-core` observers through `bind`: loading and error states, keys from props, mutations |
136
136
  | `nativele-zod` | Experimental | available | form validation with Zod or Valibot: per-field messages, nested and array fields, `aria-invalid` |
137
137
  | `nativele-tailwind` | Experimental | available | Tailwind CSS v4 in templates: source detection, `shadow: true` with adopted style sheets, `<style scoped>` layers |
138
+ | `nativele-architecture` | Official | available | app layout: feature folders, shared components and stores, pages as custom elements mounted by a router (Navigation API or universal-router), lazy pages, one scanned folder, unit/browser/end-to-end split |
138
139
  | Other libraries | Experimental or Community | open for contributions | |
@@ -274,9 +274,7 @@ export class App {
274
274
  1. **`CUSTOM_ELEMENTS_SCHEMA` turns off binding checks for unknown elements.** With it, Angular's compiler accepts any property on any hyphenated tag: `[item]="42"`, `[count]="'x'"` and `[nope]="1"` on `<info-card>` all build without an error. Angular has no mechanism to type-check bindings on custom elements, so keep the values typed in the Angular class (`signal<Item>(...)`) and check the Nativele side with `ts-dom check src/nv`.
275
275
  2. **`$event` is typed `Event`.** Angular's template type checker types `$event` of a DOM event it does not know as `Event`, so `select($event.detail)` or a handler parameter typed `CustomEvent<...>` fails with `TS2339: Property 'detail' does not exist on type 'Event'` or `TS2345: Argument of type 'Event' is not assignable to parameter of type 'CustomEvent<...>'`. Take `Event` and cast it with `EmitEvent<Component, 'event-name'>` from `@nativele/ts-dom`; a wrong event name there becomes `never` and fails with `Property 'detail' does not exist on type 'never'`. Nothing checks that the cast names the element the listener is on.
276
276
  3. **Bind properties, not attributes, for objects.** `[attr.item]="item()"` writes the attribute `item="[object Object]"` and the prop stays `null`. `[attr.count]` works for a number prop (the attribute text is converted), but `[count]` is simpler.
277
- 4. **Light DOM slots and Angular-managed children do not mix.** In the default Light DOM mode (`Component(tpl)` without `shadow`), Nativele moves the host's children into its slot positions. When `@if` or `@for` sit directly inside the tag, Angular still adds and removes nodes, but a node it re-inserts lands in the wrong place: a `<span slot="title">` brought back by `@if` ends up in the default slot while the title shows its fallback, and `assignedNodes()` stops matching the page. Angular reports no error. Use one of:
278
- - `shadow: true` on the component; `@if` and `@for` children can then change freely;
279
- - one stable element per slot that Angular never removes, with `@if`/`@for` inside it (the `light-card` above). A stable but empty wrapper still fills its slot, so the slot's fallback content does not show; when the fallback matters, use `shadow: true`.
277
+ 4. **Light DOM slots and Angular-managed children do not mix** (https://github.com/Pixmi/nativele/blob/main/docs/components.md#slots-and-children-managed-by-a-framework). When `@if` or `@for` sit directly inside a Light DOM host with slots, Angular still adds and removes nodes, but a node it re-inserts lands in the wrong place: a `<span slot="title">` brought back by `@if` ends up in the default slot while the title shows its fallback, and `assignedNodes()` stops matching the page. Angular reports no error. Use `shadow: true`, or one stable element per slot that Angular never removes, with `@if`/`@for` inside it (the `light-card` above).
280
278
  5. **Zoneless rendering is scheduled.** In the CLI's default zoneless app, Angular updates the DOM after a click in a later task, so code that calls `button.click()` and reads the DOM right away sees the old values; with `zone.js` the DOM is already updated when `click()` returns. A test that reads the DOM right after dispatching an event must wait (Playwright: wait for the expected text; TestBed: `await fixture.whenStable()`). The Nativele component itself renders synchronously as soon as Angular sets the property.
281
279
  6. **State changed outside Angular's listeners.** In a zoneless app, a plain field set from a Nativele store subscription (`session.subscribe(...)` in an Angular class) or a `setTimeout` does not re-render the Angular view; put the value in a signal. Nativele components that `bind` the store update themselves either way.
282
280
  7. **`ng serve` started before the modules were generated stays broken.** It fails with `Could not resolve "./info-card.html.js"` and does not rebuild when `ts-dom gen` creates the files later. Use `npm start` (step 4 runs `gen` first), or restart `ng serve`, or save one of the component `.ts` files.