@nativele/ts-dom 0.4.6 → 0.5.1

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.
@@ -482,7 +482,7 @@ function renderTags(dir) {
482
482
  const entries = [...seen.values()].map((d) => ` ${JSON.stringify(d.tag)}: ${alias(d)};`);
483
483
  return {
484
484
  out,
485
- next: `${TAGS_HEADER}\n${imports.join("\n")}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n${entries.join("\n")}\n }\n}\n\nexport {};\n`
485
+ next: `${TAGS_HEADER}\n${imports.join("\n")}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n${entries.join("\n")}\n }\n}\n`
486
486
  };
487
487
  }
488
488
  /**
package/dist/cli.mjs CHANGED
@@ -1,10 +1,11 @@
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-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";
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-DfUAh7t3.mjs";
4
4
  import { createRequire } from "node:module";
5
- import { existsSync, readFileSync, statSync } from "node:fs";
5
+ import { cpSync, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
6
6
  import { dirname, join, relative, resolve } from "node:path";
7
7
  import { spawnSync } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
8
9
  // src/lib/tsconfig.ts
9
10
  /** tsconfig is JSONC: drop comments and trailing commas before parsing. */
10
11
  function parseJsonc(text) {
@@ -45,7 +46,7 @@ function readTsConfig(file, depth = 0) {
45
46
  };
46
47
  }
47
48
  const own = json.include;
48
- const include = own ? own.map((p) => resolve(dirname(file), p).split("\\").join("/")) : merged.include;
49
+ const include = own ? own.map((p) => resolve(dirname(file), p).split("\\").join("/")) : merged.include ?? (Array.isArray(json.files) ? [] : void 0);
49
50
  return {
50
51
  compilerOptions: {
51
52
  ...merged.compilerOptions,
@@ -212,13 +213,25 @@ function mapTscErrors(errors, cwd) {
212
213
  return out.filter((i) => i.rule !== "tsc" || !followsBroken(i.message));
213
214
  }
214
215
  function findTsc(cwd) {
216
+ const req = createRequire(join(cwd, "package.json"));
217
+ let deps = {};
215
218
  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
- }
219
+ const json = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
220
+ deps = {
221
+ ...json.dependencies,
222
+ ...json.devDependencies
223
+ };
224
+ } catch {}
225
+ const candidates = "vue-tsc" in deps ? [["vue-tsc", "vue-tsc"], ["typescript", "tsc"]] : [["typescript", "tsc"]];
226
+ for (const [pkg, name] of candidates) try {
227
+ const pkgPath = req.resolve(`${pkg}/package.json`);
228
+ const bin = JSON.parse(readFileSync(pkgPath, "utf8")).bin?.[name];
229
+ if (bin) return {
230
+ name,
231
+ bin: resolve(dirname(pkgPath), bin)
232
+ };
233
+ } catch {}
234
+ return null;
222
235
  }
223
236
  /**
224
237
  * Everything an agent needs in one pass: template diagnostics, generated files, TypeScript errors mapped back to the templates, and the lint rules.
@@ -287,7 +300,7 @@ function runCheck(options) {
287
300
  const parsed = [];
288
301
  for (const extra of runs) {
289
302
  const run = spawnSync(process.execPath, [
290
- tsc,
303
+ tsc.bin,
291
304
  "--noEmit",
292
305
  "--pretty",
293
306
  "false",
@@ -308,7 +321,7 @@ function runCheck(options) {
308
321
  col: 1,
309
322
  rule: "tsc-failed",
310
323
  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>).`
324
+ message: `${tsc.name} did not check the types: ${detail} Fix: make \`npx ${tsc.name} --noEmit\` run in this folder (a tsconfig.json, or --project=<path>).`
312
325
  });
313
326
  }
314
327
  }
@@ -333,6 +346,94 @@ function runCheck(options) {
333
346
  return !seen.has(k) && seen.add(k) !== void 0;
334
347
  }).sort((x, y) => x.file.localeCompare(y.file) || x.line - y.line || x.col - y.col);
335
348
  }
349
+ // src/lib/skills.ts
350
+ const STALE_DAYS = 183;
351
+ /** The skills folder shipped with this package (next to its package.json). */
352
+ function skillsRoot() {
353
+ let dir = dirname(fileURLToPath(import.meta.url));
354
+ while (!existsSync(join(dir, "package.json")) || !existsSync(join(dir, "skills"))) {
355
+ const up = dirname(dir);
356
+ if (up === dir) return join(dirname(fileURLToPath(import.meta.url)), "skills");
357
+ dir = up;
358
+ }
359
+ return join(dir, "skills");
360
+ }
361
+ const unquote = (v) => v.trim().replace(/^(["'])(.*)\1$/, "$2");
362
+ /** Read the frontmatter fields Nativele uses; returns null when the file has none. */
363
+ function parseSkill(text, dir) {
364
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
365
+ if (!m) return null;
366
+ const top = {};
367
+ const meta = {};
368
+ for (const line of m[1].split(/\r?\n/)) {
369
+ const kv = /^(\s*)([\w-]+):\s?(.*)$/.exec(line);
370
+ if (!kv) continue;
371
+ const value = unquote(kv[3].replace(/\s+#.*$/, ""));
372
+ (kv[1] ? meta : top)[kv[2]] = value;
373
+ }
374
+ if (!top.name) return null;
375
+ const tier = meta.tier === "official" || meta.tier === "community" ? meta.tier : "experimental";
376
+ return {
377
+ name: top.name,
378
+ description: top.description ?? "",
379
+ tier,
380
+ nativele: meta.nativele ?? "",
381
+ verified: meta.verified ?? "",
382
+ tools: meta.tools ?? "",
383
+ author: meta.author ?? "",
384
+ detects: (meta.detects ?? "").split(/[\s,]+/).filter(Boolean),
385
+ dir
386
+ };
387
+ }
388
+ function listSkills(root = skillsRoot()) {
389
+ if (!existsSync(root)) return [];
390
+ return readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith("_") && existsSync(join(root, e.name, "SKILL.md"))).map((e) => parseSkill(readFileSync(join(root, e.name, "SKILL.md"), "utf8"), join(root, e.name))).filter((s) => s !== null).sort((a, b) => a.name.localeCompare(b.name));
391
+ }
392
+ const parts = (v) => v.split(/[.-]/).slice(0, 3).map((n) => Number.parseInt(n, 10) || 0);
393
+ const compare = (a, b) => {
394
+ const x = parts(a);
395
+ const y = parts(b);
396
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
397
+ return 0;
398
+ };
399
+ /** Whether `version` satisfies a range of space-separated comparators such as ">=0.5.0 <0.6.0". */
400
+ function inRange(version, range) {
401
+ return range.split(/\s+/).filter(Boolean).every((c) => {
402
+ const m = /^(>=|<=|>|<|=)?v?(\d[\w.-]*)$/.exec(c);
403
+ if (!m) return false;
404
+ const d = compare(version, m[2]);
405
+ return m[1] === ">=" ? d >= 0 : m[1] === "<=" ? d <= 0 : m[1] === ">" ? d > 0 : m[1] === "<" ? d < 0 : d === 0;
406
+ });
407
+ }
408
+ /** Why a Skill should not be trusted as verified for this version, or null when it is current. */
409
+ function staleReason(skill, version, today = /* @__PURE__ */ new Date()) {
410
+ if (!skill.nativele || !inRange(version, skill.nativele)) return `verified for Nativele ${skill.nativele || "(no range)"}, installed ${version}`;
411
+ const verified = Date.parse(skill.verified);
412
+ if (Number.isNaN(verified)) return "no verification date";
413
+ if ((today.getTime() - verified) / 864e5 > STALE_DAYS) return `last verified ${skill.verified}, more than six months ago`;
414
+ return null;
415
+ }
416
+ /** The folder a project keeps its skills in: .claude/skills when the project has .claude, otherwise .agents/skills. */
417
+ function skillsTarget(cwd, to) {
418
+ if (to) return resolve(cwd, to);
419
+ return existsSync(join(cwd, ".claude")) ? join(cwd, ".claude", "skills") : join(cwd, ".agents", "skills");
420
+ }
421
+ /** Every place a project may already hold a copy of a Skill. */
422
+ function installedCopies(cwd, name) {
423
+ return [join(cwd, ".claude", "skills", name), join(cwd, ".agents", "skills", name)].filter((d) => existsSync(join(d, "SKILL.md")));
424
+ }
425
+ /** Copy a Skill (its files, not its example project) into `target/<name>`; returns the folder written. */
426
+ function addSkill(skill, target) {
427
+ const dest = join(target, skill.name);
428
+ cpSync(skill.dir, dest, {
429
+ recursive: true,
430
+ filter: (src) => !src.startsWith(join(skill.dir, "example"))
431
+ });
432
+ return dest;
433
+ }
434
+ function libraryVersion(root = skillsRoot()) {
435
+ return JSON.parse(readFileSync(join(dirname(root), "package.json"), "utf8")).version ?? "0.0.0";
436
+ }
336
437
  // src/lib/doctor.ts
337
438
  const DOCTOR_RULES = {
338
439
  "dir-not-found": "a folder given on the command line does not exist, so nothing in it was checked",
@@ -346,7 +447,9 @@ const DOCTOR_RULES = {
346
447
  "stale-generated": "a generated file is missing, outdated or orphaned",
347
448
  "vite-plugin-missing": "the Vite config does not use the ts-dom plugin, so templates are not regenerated while the dev server runs",
348
449
  "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"
450
+ "formatter-whitespace": "a formatter is set up while templates keep their whitespace, so reformatting can change the rendered spacing",
451
+ "skill-available": "the project uses a tool that a Nativele Skill covers, and the project has no copy of that Skill",
452
+ "skill-outdated": "the project's copy of a Nativele Skill differs from the one shipped with the installed version"
350
453
  };
351
454
  const readText = (file) => existsSync(file) ? readFileSync(file, "utf8") : null;
352
455
  /** Whether an include pattern (absolute, forward slashes) makes tsc read the files directly in `dir`. */
@@ -381,24 +484,34 @@ function runDoctor(options) {
381
484
  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
485
  const includes = [];
383
486
  let everyConfigIncludes = true;
384
- for (const configPath of configs) {
385
- const config = readTsConfig(configPath);
487
+ const scanned = dirs.map((dir) => ({
488
+ dir,
489
+ folders: templateFolders(dir)
490
+ }));
491
+ const targets = scanned.flatMap(({ dir, folders }) => folders.length ? folders : [dir]);
492
+ const read = configs.map((path) => ({
493
+ path,
494
+ config: readTsConfig(path)
495
+ }));
496
+ const covering = read.filter(({ config: { include } }) => !include || targets.some((t) => include.some((p) => covers(p, t))));
497
+ for (const { path: configPath, config } of read) {
386
498
  const o = config.compilerOptions;
387
499
  const file = rel(configPath);
388
500
  paths = {
389
501
  ...paths,
390
502
  ...o.paths
391
503
  };
504
+ if (config.include) includes.push(...config.include);
505
+ else everyConfigIncludes = false;
506
+ if (covering.length && !covering.some((r) => r.path === configPath)) continue;
392
507
  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
508
  const lib = o.lib;
394
509
  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
510
  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
511
  }
399
512
  if (configs.length && everyConfigIncludes) {
400
513
  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));
514
+ for (const folder of scanned.flatMap((s) => s.folders)) if (!includes.some((p) => covers(p, folder))) missed.add(rel(folder));
402
515
  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
516
  }
404
517
  let resolvable = Object.keys(paths).includes(runtime);
@@ -447,6 +560,13 @@ function runDoctor(options) {
447
560
  ].some((n) => existsSync(join(cwd, n))) || "prettier" in deps || scripts.some((c) => /(^|[\s;&|])prettier(\s|$)/.test(c)));
448
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")));
449
562
  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
+ for (const skill of listSkills(options.skillsDir ?? skillsRoot())) {
564
+ const copies = installedCopies(cwd, skill.name);
565
+ const shipped = readFileSync(join(skill.dir, "SKILL.md"), "utf8");
566
+ 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")) });
567
+ const found = skill.detects.filter((d) => d in deps);
568
+ if (!copies.length && found.length) add("skill-available", "info", `${found.join(", ")} ${found.length > 1 ? "are dependencies" : "is a dependency"}, and the ${skill.name} Skill covers it: ${skill.description} Fix: run ts-dom skills add ${skill.name}, or read it with ts-dom skills ${skill.name}.`, { file: "package.json" });
569
+ }
450
570
  return issues;
451
571
  }
452
572
  function formatDoctorIssue(i) {
@@ -739,6 +859,17 @@ onChange(e) { this.set({ value: e.detail.value }); }`
739
859
  inc() { this.count = this.count + 1; }`,
740
860
  good: `// counter.ts
741
861
  inc() { this.emit("count-change", { value: this.props.count + 1 }); }`
862
+ },
863
+ "unguarded-input-value": {
864
+ why: "render() runs on every change, including the change the user is typing. Assigning a form control's value, even to the same text, moves the caret to the end, so the write must be skipped when nothing differs.",
865
+ bad: `// form.ts
866
+ override render(view: { name: string }) {
867
+ this.refs.name.value = view.name;
868
+ }`,
869
+ good: `// form.ts
870
+ override render(view: { name: string }) {
871
+ if (this.refs.name.value !== view.name) this.refs.name.value = view.name;
872
+ }`
742
873
  },
743
874
  "custom-event-on-native": {
744
875
  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.",
@@ -1040,6 +1171,17 @@ export default defineConfig({ plugins: [tsDom()] });`
1040
1171
  npx ts-dom gen`,
1041
1172
  good: `npx ts-dom gen --whitespace=collapse
1042
1173
  // or in vite.config.ts: tsDom({ whitespace: "collapse" })`
1174
+ },
1175
+ "skill-available": {
1176
+ 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.",
1177
+ bad: `// package.json lists "react", and the project has no copy of the nativele-react Skill`,
1178
+ good: `npx ts-dom skills add nativele-react # copies it into .claude/skills/ or .agents/skills/
1179
+ npx ts-dom skills nativele-react # or just read it`
1180
+ },
1181
+ "skill-outdated": {
1182
+ why: "A copied Skill does not update with the package. After an upgrade the copy may describe an older version of Nativele or of the tool it covers.",
1183
+ bad: `// .claude/skills/nativele-core/SKILL.md was copied from an older @nativele/ts-dom`,
1184
+ good: `npx ts-dom skills add nativele-core # replaces the copy with the one shipped with the installed version`
1043
1185
  }
1044
1186
  };
1045
1187
  // src/cli.ts
@@ -1059,8 +1201,9 @@ Commands
1059
1201
  --strict exit with code 1 on warnings too (errors always do)
1060
1202
  --json print the issues as JSON instead of text (also for gen --check)
1061
1203
  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
1204
+ to the templates, and the lint rules (default dir: src). It rewrites the generated files first.
1205
+ --no-write write nothing: report stale generated files instead (for a CI job that must not
1206
+ change the working tree; gen --check only checks the generated files)
1064
1207
  --project=<path> tsconfig for tsc (default: tsc's own lookup)
1065
1208
  --a11y also check ARIA / accessibility (warnings)
1066
1209
  --strict exit with code 1 on warnings too (errors always do)
@@ -1068,6 +1211,13 @@ Commands
1068
1211
  doctor [dirs...] Check the project set-up: tsconfig, template imports, generated files, the runtime package,
1069
1212
  and the Vite, oxlint and formatter configuration (default dir: src)
1070
1213
  --json print the issues as JSON instead of text
1214
+ skills List the Nativele Skills shipped with this version: task procedures for coding agents
1215
+ (set-up, a complete example, known limitations, verification), with the versions verified
1216
+ <name> print one Skill
1217
+ add <name...> copy Skills into the project: .claude/skills/ when the project has .claude,
1218
+ otherwise .agents/skills/
1219
+ --to=<dir> copy into this folder instead
1220
+ --json print the list as JSON
1071
1221
  rules List every rule id with its description
1072
1222
  --explain=<id> why the rule exists, a wrong example and the fix (the id as printed in [brackets])
1073
1223
  --json print every rule as JSON: { id, group, description, why, bad, good }
@@ -1138,6 +1288,60 @@ function printRules() {
1138
1288
  }
1139
1289
  console.log("\nts-dom rules --explain=<id> shows why a rule exists, with a wrong and a right example.");
1140
1290
  }
1291
+ function skillsCommand() {
1292
+ const skills = listSkills();
1293
+ const version = libraryVersion();
1294
+ const names = args.filter((a) => !a.startsWith("--"));
1295
+ const find = (name) => {
1296
+ const skill = skills.find((s) => s.name === name);
1297
+ if (!skill) {
1298
+ console.error(`Unknown Skill "${name}". Fix: run ts-dom skills to see the Skills shipped with this version.`);
1299
+ process.exitCode = 1;
1300
+ }
1301
+ return skill;
1302
+ };
1303
+ if (names[0] === "add") {
1304
+ if (names.length < 2) {
1305
+ console.error("Name the Skills to add. Fix: ts-dom skills add <name...> (ts-dom skills lists them).");
1306
+ process.exitCode = 1;
1307
+ return;
1308
+ }
1309
+ const to = args.find((a) => a.startsWith("--to="))?.slice(5);
1310
+ const target = skillsTarget(process.cwd(), to);
1311
+ for (const name of names.slice(1)) {
1312
+ const skill = find(name);
1313
+ if (skill) console.log(`added ${relative(process.cwd(), addSkill(skill, target))}`);
1314
+ }
1315
+ return;
1316
+ }
1317
+ if (names.length) {
1318
+ for (const name of names) {
1319
+ const skill = find(name);
1320
+ if (skill) console.log(readFileSync(`${skill.dir}/SKILL.md`, "utf8"));
1321
+ }
1322
+ return;
1323
+ }
1324
+ const rows = skills.map((skill) => {
1325
+ const { dir: _dir, ...s } = skill;
1326
+ return {
1327
+ ...s,
1328
+ stale: staleReason(skill, version)
1329
+ };
1330
+ });
1331
+ if (flags.has("--json")) {
1332
+ console.log(JSON.stringify({
1333
+ version,
1334
+ skills: rows
1335
+ }, null, 2));
1336
+ return;
1337
+ }
1338
+ console.log(`Nativele Skills shipped with @nativele/ts-dom ${version}:\n`);
1339
+ for (const s of rows) {
1340
+ console.log(` ${s.name.padEnd(28)} ${s.tier.padEnd(13)} verified ${s.verified}${s.stale ? ` (stale: ${s.stale})` : ""}`);
1341
+ console.log(` ${"".padEnd(28)} ${s.description}`);
1342
+ }
1343
+ console.log("\nts-dom skills <name> prints one; ts-dom skills add <name> copies it into the project for your agent.");
1344
+ }
1141
1345
  const [cmd, ...args] = process.argv.slice(2);
1142
1346
  const flags = new Set(args.filter((a) => a.startsWith("--")));
1143
1347
  const globs = (name) => args.filter((a) => a.startsWith(`--${name}=`)).map((a) => a.slice(name.length + 3));
@@ -1204,9 +1408,9 @@ try {
1204
1408
  generate
1205
1409
  });
1206
1410
  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));
1411
+ else for (const i of issues) (i.severity === "error" ? console.error : i.severity === "warning" ? console.warn : console.log)(formatDoctorIssue(i));
1208
1412
  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");
1413
+ if (!flags.has("--json") && !issues.some((i) => i.severity !== "info")) console.log("ts-dom doctor: the set-up looks right");
1210
1414
  } else if (cmd === "lint") {
1211
1415
  const files = dirs.flatMap(listTs);
1212
1416
  const issues = [...lintUsage(dirs, files), ...lintDataFlow(dirs, files)];
@@ -1220,6 +1424,7 @@ try {
1220
1424
  if (issues.filter((i) => i.severity === "error").length || flags.has("--strict") && issues.length) process.exitCode = 1;
1221
1425
  if (!flags.has("--json") && !issues.length) console.log("ts-dom lint: no issues");
1222
1426
  } else if (cmd === "rules") printRules();
1427
+ else if (cmd === "skills") skillsCommand();
1223
1428
  else {
1224
1429
  console.log(HELP);
1225
1430
  if (cmd && cmd !== "--help" && cmd !== "-h" && cmd !== "help") process.exitCode = 1;
package/dist/index.d.mts CHANGED
@@ -218,15 +218,25 @@ type Problems<S extends Schema, St, P, R, E> = ([keyof St | keyof P | keyof R] e
218
218
  });
219
219
  type IsClean<T> = unknown extends T ? true : false;
220
220
  type Lists<S extends Schema> = { [K in keyof S["lists"]]: List<S["lists"][K]>; };
221
- /** The second argument received by a data-on-* handler method. */
222
- interface EventHit<Item = unknown> {
221
+ /**
222
+ * The second argument received by a data-on-* handler method.
223
+ * In a handler for events inside list items, name the item type and its key field, e.g. `hit: EventHit<Todo, "id">`: `hit.item` is then a `Todo` and `hit.key` a `Todo["id"]`.
224
+ */
225
+ type EventHit<Item = unknown, KeyField extends keyof Item = never> = [KeyField] extends [never] ? {
223
226
  /** The element that carries the data-on-* attribute */
224
227
  readonly el: Element;
225
228
  /** When the event happened inside a list item: the key of that item */
226
229
  readonly key?: PropertyKey;
227
- /** When the event happened inside a list item: the data last passed to update() for that item. Annotate the handler, e.g. `hit: EventHit<Todo>`, to type it. */
230
+ /** When the event happened inside a list item: the data of that item. Annotate the handler, e.g. `hit: EventHit<Todo, "id">`, to type it. */
228
231
  readonly item?: Item;
229
- }
232
+ } : {
233
+ /** The element that carries the data-on-* attribute */
234
+ readonly el: Element;
235
+ /** The key of the list item the event happened in */
236
+ readonly key: Item[KeyField];
237
+ /** The data of the list item the event happened in */
238
+ readonly item: Item;
239
+ };
230
240
  interface ComponentInstance<S extends Schema> extends HTMLElement {
231
241
  /** Type inference only (define() uses it to check that data-on-* methods exist); does not exist at runtime. */
232
242
  readonly "~events"?: S["events"];
@@ -266,9 +276,9 @@ interface StatefulInstance<S extends Schema, St extends object, P extends object
266
276
  batch(fn: () => void): void;
267
277
  /**
268
278
  * Override it to handle updates that automatic binding cannot do (lists, attributes, focus, ...).
269
- * `view` is state, props and derived values merged; `changed` holds the fields that changed (all of them on the first render).
279
+ * `view` is state, props and derived values merged; `changed` holds the names of the state fields, props and derived values that changed (all of them on the first render).
270
280
  */
271
- render(view: View<St & P, R>, changed: ReadonlySet<keyof St | keyof P>): void;
281
+ render(view: View<St & P, R>, changed: ReadonlySet<keyof St | keyof P | keyof R>): void;
272
282
  /** Emit an event declared in `emits` (it does not bubble by default and only reaches this element itself). */
273
283
  emit<K extends keyof E & string>(name: K, ...detail: EmitArgs<E[K]>): void;
274
284
  /** Type inference only (EmitEvent uses it to get the detail type of an event); does not exist at runtime. */
package/dist/index.mjs CHANGED
@@ -662,7 +662,10 @@ function Component(tpl, opts = {}) {
662
662
  for (const [name, fn] of Object.entries(opts.derive ?? {})) {
663
663
  const v = fn(to);
664
664
  derived[name] = v;
665
- if (!from || !Object.is(this.#derived[name], v)) bindName(name, v);
665
+ if (!from || !Object.is(this.#derived[name], v)) {
666
+ bindName(name, v);
667
+ keys.push(name);
668
+ }
666
669
  }
667
670
  this.#derived = derived;
668
671
  this.render({
@@ -1,4 +1,4 @@
1
- import { a as analyze, f as listHtml, h as scanDefines } from "./a11y-wWL9rie7.mjs";
1
+ import { a as analyze, f as listHtml, h as scanDefines } from "./a11y-CV3nBgBn.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { basename, dirname, relative } from "node:path";
4
4
  // src/lib/lint.ts
@@ -145,7 +145,8 @@ const DATA_FLOW_RULES = {
145
145
  "custom-event-on-native": "a custom event belongs on the child component tag that emits it, not on a native element",
146
146
  "unknown-prop": "the attribute is not a declared prop of the child component",
147
147
  "invalid-prop-value": "the attribute value cannot be converted to the prop's type",
148
- "unknown-event": "the child component does not emit this event"
148
+ "unknown-event": "the child component does not emit this event",
149
+ "unguarded-input-value": "render() writes a form control's value without comparing it first, which moves the caret while the user types"
149
150
  };
150
151
  /** Common native events. A data-on-* on a native element whose event is not listed here is treated as a custom event. */
151
152
  const NATIVE_EVENTS = new Set(`click dblclick auxclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave contextmenu wheel keydown keyup keypress input beforeinput change submit reset invalid select focus blur focusin focusout scroll resize load unload error abort toggle cancel close drag dragstart dragend dragenter dragleave dragover drop copy cut paste compositionstart compositionupdate compositionend touchstart touchmove touchend touchcancel pointerdown pointerup pointermove pointerover pointerout pointerenter pointerleave pointercancel gotpointercapture lostpointercapture animationstart animationend animationiteration transitionstart transitionend transitionrun transitioncancel play pause ended playing timeupdate volumechange seeking seeked loadeddata loadedmetadata canplay waiting slotchange fullscreenchange online offline hashchange popstate storage message`.split(/\s+/));
@@ -308,6 +309,20 @@ function dataFlow(html, classes, ignore = [], contracts = /* @__PURE__ */ new Ma
308
309
  const tag = /El<"([^"]+)">/.exec(type)?.[1];
309
310
  if (tag?.includes("-")) customRefs.set(name, tag);
310
311
  }
312
+ const formControls = new Set([...a.scope.refs].filter(([, type]) => /El<"(input|textarea|select)">/.test(type)).map(([name]) => name));
313
+ for (const { body } of classes) {
314
+ const render = methodBody(body, "render");
315
+ if (!render || !formControls.size) continue;
316
+ const lines = render.split("\n");
317
+ lines.forEach((line, i) => {
318
+ for (const m of line.matchAll(/\brefs\.(\w+)\.value\s*=(?!=)/g)) {
319
+ const ref = m[1];
320
+ if (!formControls.has(ref)) continue;
321
+ if ([line, lines[i - 1] ?? ""].some((l) => new RegExp(`refs\\.${esc(ref)}\\.value\\s*!==?`).test(l))) continue;
322
+ report("unguarded-input-value", `render() assigns this.refs.${ref}.value without comparing it first. Writing a form control's value while the user types moves the caret to the end. Fix: if (this.refs.${ref}.value !== next) this.refs.${ref}.value = next;`, `ref:${ref}`);
323
+ }
324
+ });
325
+ }
311
326
  for (const { call, body } of classes) {
312
327
  const props = optionKeys(call, "props");
313
328
  for (const k of props) if (new RegExp(`\\bthis\\.${esc(k)}\\s*(?:[-+*/%]?=(?!=)|\\+\\+|--)`).test(body)) report("props-mutation", `The prop "${k}" is controlled by the parent component; do not assign this.${k} inside the component. Fix: emit an event so the parent changes it, or keep the value in state instead.`);
@@ -1,5 +1,5 @@
1
- import { n as checkA11y, t as A11Y_RULES } from "./a11y-wWL9rie7.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-CP_i4VPA.mjs";
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-DfUAh7t3.mjs";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { dirname, resolve } from "node:path";
5
5
  // src/lib/oxlint-plugin.ts
@@ -1,4 +1,4 @@
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-wWL9rie7.mjs";
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";
2
2
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
3
3
  import { resolve, sep } from "node:path";
4
4
  // src/lib/vite-plugin.ts
package/llms.txt CHANGED
@@ -1,8 +1,8 @@
1
1
  # Nativele
2
2
 
3
- > A TypeScript-driven, HTML-first UI library built for type safety and AI agents. Bind HTML templates to TypeScript custom elements. HTML describes structure and where data goes; TypeScript owns state and behavior; the DOM is the only view. Fully typed by tsc, no virtual DOM, no bundler required.
3
+ > Designed for AI agents. Driven by TypeScript. Defined in vanilla HTML. A lightweight library for building web components: bind HTML templates to TypeScript custom elements. HTML describes structure and where data goes; TypeScript owns state and behavior; the DOM is the only view. Fully typed by tsc, no virtual DOM, no bundler required.
4
4
 
5
- Workflow: write `x.html` + `x.ts`, run `npx ts-dom check` (regenerates `x.html.ts`, runs tsc and lint, points errors at the template). Import a template as `import tpl from "./x.html.js"` (note the `.js`). Lint with `npx ts-dom lint` (add `--a11y`). `npx ts-dom rules` lists every rule id. Every diagnostic is `file:line:col severity [rule-id] message` and ends with a `Fix:` hint: read the hint and apply it.
5
+ Workflow: write `x.html` + `x.ts`, run `npx ts-dom check` (regenerates `x.html.ts`, runs tsc, or vue-tsc when the project has it, and lint, points errors at the template). Import a template as `import tpl from "./x.html.js"` (note the `.js`). Lint with `npx ts-dom lint` (add `--a11y`). `npx ts-dom rules` lists every rule id. Every diagnostic is `file:line:col severity [rule-id] message` and ends with a `Fix:` hint: read the hint and apply it.
6
6
 
7
7
  ## Template syntax (HTML only names things; no expressions)
8
8
  - `{{ name }}`: text hole, a single identifier only. NOT `{{ a.b }}` / `{{ a + 1 }}`. Not allowed in attributes.
@@ -43,7 +43,9 @@ Rules the compiler enforces: every `{{ hole }}` / `data-if` name must exist in s
43
43
  - Props go DOWN: the parent sets them with an attribute or a property (`this.refs.child.value = v.x`, typed). A component never assigns its own props; to ask for a change it `emit`s an event.
44
44
  - Events go UP: `emit` does not bubble; the parent handles it with `data-on-<event>` on the child tag, casting with `EmitEvent<Child, "event-name">`.
45
45
  - Data needed several levels away: share a `createStore` module and `this.bind(store, fn)`; do not forward props or re-emit events through middle components (lint rules `prop-forwarding`, `emit-forwarding`).
46
- - Writing form values back: only assign `input.value` when it differs (`if (input.value !== v.value) input.value = v.value`), otherwise the caret jumps.
46
+ - `store.set` REPLACES the value (a component's `this.set` merges): `store.set((s) => ({ ...s, status: "loading" }))`. In a component with state/props/derive every `{{ hole }}` must be one of them, so copy store data into state in the `bind` callback: `this.bind(store, (s) => this.set({ total: s.items.length }))`.
47
+ - Loading/error states, modal dialogs (a public `open()` called through a typed ref), form validation messages, filtered lists: `docs/recipes.md` and `examples/recipes/` in the repository.
48
+ - Writing form values back (lint rule `unguarded-input-value`): only assign `input.value` when it differs (`if (input.value !== v.value) input.value = v.value`), otherwise the caret jumps.
47
49
 
48
50
  ## Minimal example
49
51
  ```html
@@ -72,17 +74,18 @@ define("my-counter", MyCounter);
72
74
  - Rule: an item with anything of its own (state, events, a nested list) is a component filled with `items =`; an item of a few texts and `data-if` is plain elements filled with `replace()`.
73
75
  - Subscribe in `connected()`, never in the constructor: without `moveBefore` a moved item is disconnected (its `bind`/`on`/`onCleanup` released) and connected again. Data every item needs goes in a `createStore` the item binds.
74
76
  - Full example (add, remove, sort, toggle, child emit): `examples/readme/task-board.ts` and `task-row.ts` in the repository.
75
- - Items that are components: `<template data-each="todos" data-key="id" data-prop="todo"><todo-row></todo-row></template>`, then `this.lists.todos.items = view.todos` (inside `render`, guarded by `changed.has("todos")`). Keeps the element of each key, creates and removes the rest, sets the `todo` prop of each changed item; `hit.key` in handlers. The item component declares `todo` in its `props`; `tsc` checks the items against that prop type and the `data-key` field; an item component defined in a scanned folder must be exported and listed in `ts-dom-tags.ts` (run `ts-dom gen`), otherwise the assignment is a type error that names the problem.
77
+ - Items that are components: `<template data-each="todos" data-key="id" data-prop="todo"><todo-row></todo-row></template>`, then `this.lists.todos.items = view.todos` (inside `render`, guarded by `changed.has("todos")`; `changed` holds the state, prop and derived names that changed, so a derived list is guarded by its own name). Keeps the element of each key, creates and removes the rest, sets the `todo` prop of each changed item; `hit.key` in handlers (`hit: EventHit<Todo, "id">` types `hit.key` and `hit.item`). The item component declares `todo` in its `props`; `tsc` checks the items against that prop type and the `data-key` field; an item component defined in a scanned folder must be exported and listed in `ts-dom-tags.ts` (run `ts-dom gen`), otherwise the assignment is a type error that names the problem.
76
78
  - Items that are plain elements: `<template data-each="x">` with `this.lists.x.replace(items, keyFn, (item, { text, show }) => ...)`.
77
79
  - `data-each` is never nested: a list inside an item is the item component's own `data-each`.
78
80
 
79
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, ...), 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.
80
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.
81
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.
82
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.
83
86
  - `npx ts-dom gen --check` (writes nothing, exit 1) tells whether the generated files are stale before you trust `tsc` errors.
84
87
  - Never edit `x.html.ts` or `ts-dom-tags.ts` (generated). After any `.html` or component change run `npx ts-dom check --json`: it regenerates, runs tsc, maps template contract errors to the template line/col (`missing-binding`, `missing-handler`, item rules), and runs lint. Fix every issue by its `Fix:` hint; never cast a type error away.
85
- - No expressions in HTML; computed values go in `derive`. Change state only with `this.set()`. Lists, attributes and focus go in `render(view, changed)`; check `changed.has("items")` before rebuilding a list.
88
+ - No expressions in HTML; computed values go in `derive`. Change state only with `this.set()`. Lists, attributes and focus go in `render(view, changed)`; check `changed.has("items")` before rebuilding a list, and annotate `changed` with the names you check (`ReadonlySet<"items" | "visible">`), not `ReadonlySet<string>`, so tsc checks them.
86
89
  - Props go down (parent sets them), events go up (`emit`, handled with `data-on-<event>` on the child tag). `data-each` cannot be nested: make the inner list its own component.
87
90
 
88
91
  ## Setup
@@ -95,9 +98,11 @@ In a browser without a bundler map the bare name once: `<script type="importmap"
95
98
  ## Pitfalls
96
99
  - Import templates with `./x.html.js`, not `./x.html`. Run `ts-dom gen` before `tsc` (it creates `x.html.ts` and `ts-dom-tags.ts`; both are generated, do not edit or commit).
97
100
  - Exports: `export class` the component to get typed `refs.child` in parent templates (`define("tag", Class)` is scanned).
98
- - `data-if` content is hidden until the name is truthy; `{{ hole }}` stays empty until filled.
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
+ - With Vite, `package.json` needs `"type": "module"`.
99
103
  - `slotchange()` is Light-DOM only; with `shadow: true` use the native `slotchange` event.
100
104
 
101
105
  ## Docs
102
- - [README](README.md): full guide. [README.zh-TW.md](README.zh-TW.md): same in Traditional Chinese.
106
+ - [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).
103
108
  - `npx ts-dom rules`: every rule id with a description.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nativele/ts-dom",
3
- "version": "0.4.6",
4
- "description": "A TypeScript-driven, HTML-first UI library built for type safety and AI agents: HTML describes the structure, TypeScript owns behavior and state, the native DOM renders it. No virtual DOM, no template expressions, no bundler required.",
3
+ "version": "0.5.1",
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",
7
7
  "typescript",
@@ -49,6 +49,8 @@
49
49
  "README.md",
50
50
  "README.zh-TW.md",
51
51
  "llms.txt",
52
+ "skills/README.md",
53
+ "skills/*/SKILL.md",
52
54
  "LICENSE",
53
55
  "CHANGELOG.md"
54
56
  ],