@nativele/ts-dom 0.5.2 → 0.5.3
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/CHANGELOG.md +18 -0
- package/README.md +2 -0
- package/README.zh-TW.md +2 -0
- package/dist/{a11y-CV3nBgBn.mjs → a11y-Bw-8ZIAK.mjs} +65 -21
- package/dist/cli.mjs +24 -16
- package/dist/{lint-Bwytj7L8.mjs → lint-qEPgmmMa.mjs} +1 -1
- package/dist/oxlint-plugin.mjs +2 -2
- package/dist/vite-plugin.mjs +17 -25
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/skills/README.md +1 -0
- package/skills/nativele-architecture/SKILL.md +541 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,24 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
|
|
|
10
10
|
- Adding a lint rule is a minor change; lint rules are warnings or opt-in and do not count as breaking.
|
|
11
11
|
- Before every release run `npm run verify:pack` (`publint` and `attw`), `tsc`, the unit tests, the browser tests, `npm run smoke` and `npm run smoke:no-bundler`; all of them run in `.github/workflows/ci.yml`.
|
|
12
12
|
|
|
13
|
+
## 0.5.3
|
|
14
|
+
|
|
15
|
+
How to lay out an app with several pages: a new architecture guide, an Official Skill that applies it, and a medium example app checked in CI. Building the example found a codegen bug with several scanned folders, fixed here.
|
|
16
|
+
|
|
17
|
+
### Guide and Skill
|
|
18
|
+
|
|
19
|
+
- New guide `docs/architecture.md`:
|
|
20
|
+
- feature folders with their components, stores and tests, and a shared folder whose components' interface is their props and events;
|
|
21
|
+
- pages as custom elements mounted by an external router: route params as props, leaving a page, lazy page modules;
|
|
22
|
+
- where state lives at each level, and the split between unit, real-browser and end-to-end tests;
|
|
23
|
+
- one scanned folder for the generated files, checks and tsconfig.
|
|
24
|
+
- New `nativele-architecture` (Official): the procedure for starting a multi-page Nativele app or restructuring one into this layout. The recommended router uses the Navigation API and `URLPattern` with no dependency; universal-router is the alternative. It covers the abort check after a lazy `import()` and the test split.
|
|
25
|
+
- Its example, a project tracker with lazy pages, shared components, feature and app-wide stores, unit, browser and end-to-end tests, runs in CI.
|
|
26
|
+
|
|
27
|
+
### Fixes
|
|
28
|
+
|
|
29
|
+
- When several folders are scanned (`ts-dom gen a b`, `ts-dom check a b`, `ts-dom doctor a b`, the Vite plugin's `dirs`), each folder was generated on its own, so a template in one folder that used a component defined in another got no import of it and the build silently left the component out, while the types still passed. The folders now share their `define()` calls: the template imports the child by its relative path, `gen --check` and `doctor` agree with what `gen` writes, and the Vite plugin keeps the imports across folders when a template is saved or a `define()` is added or removed. A tag defined with different classes in two scanned folders is now reported as `duplicate-define` at the second `define()` call.
|
|
30
|
+
|
|
13
31
|
## 0.5.2
|
|
14
32
|
|
|
15
33
|
Skills for the libraries a Nativele app most often sits next to: state stores, testing, data fetching, form validation and Tailwind CSS. Each was accepted through agent trials, and the trials found two bugs in the library, fixed here.
|
package/README.md
CHANGED
|
@@ -65,6 +65,7 @@ npx ts-dom skills add nativele-core # copy it into .claude/skills/ (when the
|
|
|
65
65
|
| `nativele-vue` | Nativele components in Vue 3: props, events, `v-model`, slots, `<KeepAlive>`, template types |
|
|
66
66
|
| `nativele-svelte` | Nativele components in Svelte 5: props, events, slots, template types |
|
|
67
67
|
| `nativele-angular` | Nativele components in Angular: bindings, change detection, the build, content projection (Experimental) |
|
|
68
|
+
| `nativele-architecture` | app layout: feature folders, shared components and stores, pages mounted by a router, lazy pages, the test split |
|
|
68
69
|
| `nativele-vite` | Nativele in a Vite app: the plugin and `root`, multi-page builds, hot updates |
|
|
69
70
|
| `nativele-integration` | Nativele components in a framework without its own Skill (Preact, Solid, ...): properties, events, slots, lifecycle, template types (Experimental) |
|
|
70
71
|
| `nativele-state-management` | where state lives; `createStore`, and Zustand, Redux Toolkit or nanostores through `bind` |
|
|
@@ -228,6 +229,7 @@ The full guide is in English:
|
|
|
228
229
|
- [Components and data flow: state, props, derive, events, composition, stores](https://github.com/Pixmi/nativele/blob/main/docs/components.md)
|
|
229
230
|
- [Recipes: loading and error states, store data, filtered lists, dialogs, form validation](https://github.com/Pixmi/nativele/blob/main/docs/recipes.md)
|
|
230
231
|
- [Lists: lists of components with `items =`, plain lists with `replace()`, moving items](https://github.com/Pixmi/nativele/blob/main/docs/lists.md)
|
|
232
|
+
- [Architecture: feature folders, shared components and stores, pages mounted by a router, where state lives, testing strategy](https://github.com/Pixmi/nativele/blob/main/docs/architecture.md)
|
|
231
233
|
- [Setup, checks and lint: installation, Vite, oxlint, `ts-dom check`, `ts-dom doctor`, accessibility](https://github.com/Pixmi/nativele/blob/main/docs/tooling.md)
|
|
232
234
|
- [Performance: the large list benchmark](https://github.com/Pixmi/nativele/blob/main/docs/performance.md)
|
|
233
235
|
- [Design: principles, deliberate decisions, limitations](https://github.com/Pixmi/nativele/blob/main/docs/design.md)
|
package/README.zh-TW.md
CHANGED
|
@@ -65,6 +65,7 @@ npx ts-dom skills add nativele-core # 複製到 .claude/skills/(專案有 .
|
|
|
65
65
|
| `nativele-vue` | 在 Vue 3 中使用 Nativele 元件:props、事件、`v-model`、slot、`<KeepAlive>`、範本型別 |
|
|
66
66
|
| `nativele-svelte` | 在 Svelte 5 中使用 Nativele 元件:props、事件、slot、範本型別 |
|
|
67
67
|
| `nativele-angular` | 在 Angular 中使用 Nativele 元件:綁定、變更偵測、建置流程、內容投影(Experimental) |
|
|
68
|
+
| `nativele-architecture` | 應用的結構:功能資料夾、共用元件與 store、由 router 掛載的頁面、延遲載入、測試分層 |
|
|
68
69
|
| `nativele-vite` | 在 Vite 應用中使用 Nativele:外掛與 `root`、多頁建置、熱更新 |
|
|
69
70
|
| `nativele-integration` | 在沒有專屬 Skill 的框架(Preact、Solid 等)中使用 Nativele 元件:property、事件、slot、生命週期、範本型別(Experimental) |
|
|
70
71
|
| `nativele-state-management` | 狀態放在哪裡;`createStore`,以及透過 `bind` 接上 Zustand、Redux Toolkit、nanostores |
|
|
@@ -228,6 +229,7 @@ define("task-board", TaskBoard);
|
|
|
228
229
|
- [Components and data flow: state, props, derive, events, composition, stores](https://github.com/Pixmi/nativele/blob/main/docs/components.md)
|
|
229
230
|
- [Recipes: loading and error states, store data, filtered lists, dialogs, form validation](https://github.com/Pixmi/nativele/blob/main/docs/recipes.md)
|
|
230
231
|
- [Lists: lists of components with `items =`, plain lists with `replace()`, moving items](https://github.com/Pixmi/nativele/blob/main/docs/lists.md)
|
|
232
|
+
- [Architecture: feature folders, shared components and stores, pages mounted by a router, where state lives, testing strategy](https://github.com/Pixmi/nativele/blob/main/docs/architecture.md)
|
|
231
233
|
- [Setup, checks and lint: installation, Vite, oxlint, `ts-dom check`, `ts-dom doctor`, accessibility](https://github.com/Pixmi/nativele/blob/main/docs/tooling.md)
|
|
232
234
|
- [Performance: the large list benchmark](https://github.com/Pixmi/nativele/blob/main/docs/performance.md)
|
|
233
235
|
- [Design: principles, deliberate decisions, limitations](https://github.com/Pixmi/nativele/blob/main/docs/design.md)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { i as RESERVED_NAMES, n as IDENT, r as holeName, t as HOLE_ANY } from "./holes-DTBtTZF_.mjs";
|
|
2
2
|
import { existsSync, readFileSync, readdirSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join, relative, sep } from "node:path";
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { parseFragment } from "parse5";
|
|
5
5
|
// src/lib/codegen.ts
|
|
6
6
|
/** Every diagnostic that `ts-dom gen` can report while analyzing a template. */
|
|
@@ -29,7 +29,7 @@ const CODEGEN_RULES = {
|
|
|
29
29
|
"forbidden-tag": "<script> cannot be in a template (it would run code)",
|
|
30
30
|
"forbidden-attribute": "on* event attributes and srcdoc cannot be in a template (they run code or are blocked by Trusted Types)",
|
|
31
31
|
"unsafe-url": "href / src / action / formaction cannot start with javascript:",
|
|
32
|
-
"duplicate-define": "a custom element tag can be defined only once
|
|
32
|
+
"duplicate-define": "a custom element tag can be defined only once across the scanned directories"
|
|
33
33
|
};
|
|
34
34
|
const RESERVED_ELEMENT_NAMES = /* @__PURE__ */ new Set([
|
|
35
35
|
"annotation-xml",
|
|
@@ -406,13 +406,15 @@ function nonCodeRanges(code) {
|
|
|
406
406
|
return out;
|
|
407
407
|
}
|
|
408
408
|
/**
|
|
409
|
-
* Scan
|
|
409
|
+
* Scan one or more directories for every `define("my-tag", MyClass)` call; a file under two of them is read once.
|
|
410
410
|
* Calls that only appear inside a string or a comment (for example sample code in a test or in documentation) are ignored.
|
|
411
411
|
*/
|
|
412
|
-
function scanDefines(
|
|
412
|
+
function scanDefines(dirs) {
|
|
413
413
|
const defs = [];
|
|
414
414
|
const re = /\bdefine\(\s*(["'])([a-z][a-z0-9]*(?:-[a-z0-9]+)+)\1\s*,\s*([A-Za-z_$][\w$]*)\s*\)/g;
|
|
415
|
-
|
|
415
|
+
const files = /* @__PURE__ */ new Map();
|
|
416
|
+
for (const dir of typeof dirs === "string" ? [dirs] : dirs) for (const f of listTs(dir)) if (!files.has(resolve(f))) files.set(resolve(f), f);
|
|
417
|
+
for (const file of files.values()) {
|
|
416
418
|
const code = readFileSync(file, "utf8");
|
|
417
419
|
const skip = nonCodeRanges(code);
|
|
418
420
|
const inCode = (pos) => !skip.some(([a, b]) => pos >= a && pos < b);
|
|
@@ -422,12 +424,15 @@ function scanDefines(dir) {
|
|
|
422
424
|
const [, , tag, className] = m;
|
|
423
425
|
const named = has(new RegExp(`export\\s+class\\s+${className}\\b`, "g")) || has(new RegExp(`export\\s*\\{[^}]*\\b${className}\\b[^}]*\\}`, "g"));
|
|
424
426
|
const isDefault = has(new RegExp(`export\\s+default\\s+class\\s+${className}\\b`, "g"));
|
|
427
|
+
const before = code.slice(0, m.index);
|
|
425
428
|
defs.push({
|
|
426
429
|
tag,
|
|
427
430
|
className,
|
|
428
431
|
file,
|
|
429
432
|
exported: named || isDefault,
|
|
430
|
-
isDefault
|
|
433
|
+
isDefault,
|
|
434
|
+
line: before.split("\n").length,
|
|
435
|
+
col: m.index - before.lastIndexOf("\n")
|
|
431
436
|
});
|
|
432
437
|
}
|
|
433
438
|
}
|
|
@@ -437,6 +442,30 @@ const relImport = (fromDir, file) => {
|
|
|
437
442
|
const r = relative(fromDir, file).split(sep).join("/").replace(/\.ts$/, ".js");
|
|
438
443
|
return r.startsWith(".") ? r : `./${r}`;
|
|
439
444
|
};
|
|
445
|
+
/**
|
|
446
|
+
* A tag that two of the directories define with different classes, reported at its second define() call.
|
|
447
|
+
* A tag defined twice within one directory is reported by `generateTags` instead.
|
|
448
|
+
*/
|
|
449
|
+
function crossDirDuplicates(dirs) {
|
|
450
|
+
const byTag = /* @__PURE__ */ new Map();
|
|
451
|
+
for (const d of scanDefines(dirs)) byTag.set(d.tag, [...byTag.get(d.tag) ?? [], d]);
|
|
452
|
+
const inside = (dir, file) => resolve(file).startsWith(resolve(dir) + sep);
|
|
453
|
+
const out = [];
|
|
454
|
+
for (const [tag, defs] of byTag) {
|
|
455
|
+
const [first, second] = defs;
|
|
456
|
+
if (defs.length < 2 || dirs.some((dir) => defs.every((d) => inside(dir, d.file)))) continue;
|
|
457
|
+
const at = (d) => `${relative(process.cwd(), d.file)}:${d.line ?? 1}`;
|
|
458
|
+
out.push({
|
|
459
|
+
file: second.file,
|
|
460
|
+
line: second.line ?? 1,
|
|
461
|
+
col: second.col ?? 1,
|
|
462
|
+
severity: "error",
|
|
463
|
+
rule: "duplicate-define",
|
|
464
|
+
message: `The tag <${tag}> is defined more than once in the scanned folders (${at(first)} and ${at(second)}), so the second define() fails at runtime. Fix: each custom element name can be defined only once; rename one of them.`
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
440
469
|
/** Scan `define("my-tag", MyClass)` calls and return the components a type can be generated for (the class must be exported). */
|
|
441
470
|
function scanTags(dir) {
|
|
442
471
|
return scanDefines(dir).filter((d) => d.exported).map((d) => ({
|
|
@@ -489,10 +518,9 @@ function renderTags(dir) {
|
|
|
489
518
|
* Everything `generateAll` would write for a directory, in memory: generated path to content, or null for a file that should not exist.
|
|
490
519
|
* Templates with error diagnostics are left out and their messages returned in `errors`.
|
|
491
520
|
*/
|
|
492
|
-
function planGenerated(dir, options = {}) {
|
|
521
|
+
function planGenerated(dir, options = {}, defs = scanDefines(dir)) {
|
|
493
522
|
const files = /* @__PURE__ */ new Map();
|
|
494
523
|
const errors = [];
|
|
495
|
-
const defs = scanDefines(dir);
|
|
496
524
|
for (const f of listHtml(dir)) {
|
|
497
525
|
if (!isSelected(dir, f, options)) continue;
|
|
498
526
|
try {
|
|
@@ -517,12 +545,12 @@ function planGenerated(dir, options = {}) {
|
|
|
517
545
|
};
|
|
518
546
|
}
|
|
519
547
|
/** Generated files under `dir` that differ from what `ts-dom gen` would write now. */
|
|
520
|
-
function staleGenerated(dir, options = {}) {
|
|
521
|
-
return checkGenerated(dir, options).stale;
|
|
548
|
+
function staleGenerated(dir, options = {}, defs) {
|
|
549
|
+
return checkGenerated(dir, options, defs).stale;
|
|
522
550
|
}
|
|
523
551
|
/** `staleGenerated`, plus the errors that would make `ts-dom gen` fail (template diagnostics, a tag defined twice). */
|
|
524
|
-
function checkGenerated(dir, options = {}) {
|
|
525
|
-
const plan = planGenerated(dir, options);
|
|
552
|
+
function checkGenerated(dir, options = {}, defs) {
|
|
553
|
+
const plan = planGenerated(dir, options, defs);
|
|
526
554
|
const out = [];
|
|
527
555
|
for (const [file, next] of plan.files) {
|
|
528
556
|
const prev = existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
@@ -555,11 +583,13 @@ function listGenerated(dir) {
|
|
|
555
583
|
}
|
|
556
584
|
return out;
|
|
557
585
|
}
|
|
558
|
-
/**
|
|
559
|
-
|
|
586
|
+
/**
|
|
587
|
+
* Generate every `x.html.ts` (and the tag map) under a directory; collects all errors and throws them together.
|
|
588
|
+
* `defs` are the define() calls templates import child components from (default: those under `dir`).
|
|
589
|
+
*/
|
|
590
|
+
function generateAll(dir, options = {}, defs = scanDefines(dir)) {
|
|
560
591
|
const written = [];
|
|
561
592
|
const errors = [];
|
|
562
|
-
const defs = scanDefines(dir);
|
|
563
593
|
for (const f of listHtml(dir)) {
|
|
564
594
|
if (!isSelected(dir, f, options)) continue;
|
|
565
595
|
try {
|
|
@@ -580,17 +610,31 @@ function generateAll(dir, options = {}) {
|
|
|
580
610
|
if (errors.length) throw new CodegenError(errors.join("\n"));
|
|
581
611
|
return written;
|
|
582
612
|
}
|
|
613
|
+
/** `generateAll` for every directory, with the define() calls of all of them: a template imports a child component defined in another directory. */
|
|
614
|
+
function generateDirs(dirs, options = {}) {
|
|
615
|
+
const defs = scanDefines(dirs);
|
|
616
|
+
const written = [];
|
|
617
|
+
const errors = [];
|
|
618
|
+
for (const dir of dirs) try {
|
|
619
|
+
written.push(...generateAll(dir, options, defs));
|
|
620
|
+
} catch (e) {
|
|
621
|
+
if (e instanceof CodegenError) errors.push(e.message);
|
|
622
|
+
else throw e;
|
|
623
|
+
}
|
|
624
|
+
for (const d of crossDirDuplicates(dirs)) errors.push(formatDiagnostics(d.file, [d]));
|
|
625
|
+
if (errors.length) throw new CodegenError(errors.join("\n"));
|
|
626
|
+
return written;
|
|
627
|
+
}
|
|
583
628
|
/** Watch .html (and define() calls in .ts) under the directories and regenerate; returns a stop function. Errors go to onError and never stop the watcher. */
|
|
584
629
|
function watchAll(dirs, opts = {}) {
|
|
585
630
|
const timers = /* @__PURE__ */ new Map();
|
|
586
631
|
const watchers = dirs.map((dir) => watch(dir, { recursive: true }, (_event, name) => {
|
|
587
632
|
if (!name || name.includes("node_modules")) return;
|
|
588
633
|
if (name.endsWith(".ts") && !name.endsWith(".html.ts") && !name.endsWith(".d.ts") && !name.endsWith(TAGS_FILE)) {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
timers.set(key, setTimeout(() => {
|
|
634
|
+
clearTimeout(timers.get("tags"));
|
|
635
|
+
timers.set("tags", setTimeout(() => {
|
|
592
636
|
try {
|
|
593
|
-
for (const f of
|
|
637
|
+
for (const f of generateDirs(dirs, opts)) opts.onGenerate?.(f);
|
|
594
638
|
} catch (e) {
|
|
595
639
|
if (e instanceof CodegenError) opts.onError?.(e.message);
|
|
596
640
|
else throw e;
|
|
@@ -604,7 +648,7 @@ function watchAll(dirs, opts = {}) {
|
|
|
604
648
|
timers.set(file, setTimeout(() => {
|
|
605
649
|
try {
|
|
606
650
|
if (!existsSync(file) || !isSelected(dir, file, opts) || isFullPage(readFileSync(file, "utf8"))) return;
|
|
607
|
-
opts.onGenerate?.(generateFile(file, opts,
|
|
651
|
+
opts.onGenerate?.(generateFile(file, opts, scanDefines(dirs)));
|
|
608
652
|
} catch (e) {
|
|
609
653
|
if (e instanceof CodegenError) opts.onError?.(e.message);
|
|
610
654
|
else throw e;
|
|
@@ -814,4 +858,4 @@ function checkA11y(html, ignore = []) {
|
|
|
814
858
|
for (const { id, el } of labelFor) if (!ids.has(id)) report("label-for", el, `<label for="${id}"> points to an id that does not exist in this template. Fix: add id="${id}" to the control, or wrap the control in the label.`);
|
|
815
859
|
return out.sort((a, b) => a.line - b.line || a.col - b.col);
|
|
816
860
|
}
|
|
817
|
-
export {
|
|
861
|
+
export { staleGenerated as _, analyze as a, formatDiagnostics as c, isFullPage as d, isSelected as f, scanDefines as g, renderTags as h, CodegenError as i, generateDirs as l, listTs as m, checkA11y as n, checkGenerated as o, listHtml as p, CODEGEN_RULES as r, crossDirDuplicates as s, A11Y_RULES as t, generateFile as u, watchAll as v };
|
package/dist/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { _ as
|
|
3
|
-
import { c as lintUsage, n as UNUSED_MARKUP_RULES, o as formatIssue, s as lintDataFlow, t as DATA_FLOW_RULES } from "./lint-
|
|
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-Bw-8ZIAK.mjs";
|
|
3
|
+
import { c as lintUsage, n as UNUSED_MARKUP_RULES, o as formatIssue, s as lintDataFlow, t as DATA_FLOW_RULES } from "./lint-qEPgmmMa.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 } from "node:path";
|
|
@@ -272,16 +272,22 @@ function runCheck(options) {
|
|
|
272
272
|
message: e.message.replace(/^[^[]*\[duplicate-define\]\s*/, "")
|
|
273
273
|
});
|
|
274
274
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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).`
|
|
275
|
+
for (const d of crossDirDuplicates(dirs)) issues.push({
|
|
276
|
+
...d,
|
|
277
|
+
file: rel(d.file)
|
|
282
278
|
});
|
|
283
|
-
|
|
284
|
-
|
|
279
|
+
if (options.write === false) {
|
|
280
|
+
const defs = scanDefines(dirs);
|
|
281
|
+
for (const dir of dirs) for (const s of staleGenerated(dir, generate, defs)) issues.push({
|
|
282
|
+
file: rel(s.file),
|
|
283
|
+
line: 1,
|
|
284
|
+
col: 1,
|
|
285
|
+
rule: "stale-generated",
|
|
286
|
+
severity: "error",
|
|
287
|
+
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).`
|
|
288
|
+
});
|
|
289
|
+
} else try {
|
|
290
|
+
generateDirs(dirs, generate);
|
|
285
291
|
} catch (e) {
|
|
286
292
|
if (!(e instanceof CodegenError)) throw e;
|
|
287
293
|
}
|
|
@@ -522,6 +528,7 @@ function runDoctor(options) {
|
|
|
522
528
|
resolvable = false;
|
|
523
529
|
}
|
|
524
530
|
if (!resolvable) add("runtime-not-installed", "error", `The generated files import "${runtime}", which cannot be resolved from this project. Fix: npm i ${runtime}.`);
|
|
531
|
+
const defs = scanDefines(dirs);
|
|
525
532
|
for (const d of dirs) {
|
|
526
533
|
for (const f of listTs(d)) readFileSync(f, "utf8").split("\n").forEach((text, i) => {
|
|
527
534
|
const m = /from\s+["'](\.{1,2}\/[^"']+\.html)["']/.exec(text);
|
|
@@ -530,7 +537,7 @@ function runDoctor(options) {
|
|
|
530
537
|
line: i + 1
|
|
531
538
|
});
|
|
532
539
|
});
|
|
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) });
|
|
540
|
+
for (const s of staleGenerated(d, generate, defs)) 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) });
|
|
534
541
|
}
|
|
535
542
|
const viteConfig = [
|
|
536
543
|
"vite.config.ts",
|
|
@@ -1355,9 +1362,10 @@ const dirs = args.filter((a) => !a.startsWith("--"));
|
|
|
1355
1362
|
if (!dirs.length) dirs.push("src");
|
|
1356
1363
|
try {
|
|
1357
1364
|
if (cmd === "gen" && flags.has("--check")) {
|
|
1358
|
-
const
|
|
1365
|
+
const defs = scanDefines(dirs);
|
|
1366
|
+
const results = dirs.map((d) => checkGenerated(d, generate, defs));
|
|
1359
1367
|
const stale = results.flatMap((r) => r.stale);
|
|
1360
|
-
const errors = results.flatMap((r) => r.errors);
|
|
1368
|
+
const errors = [...results.flatMap((r) => r.errors), ...crossDirDuplicates(dirs).map((d) => formatDiagnostics(d.file, [d]))];
|
|
1361
1369
|
if (flags.has("--json")) console.log(JSON.stringify({
|
|
1362
1370
|
stale: stale.map((s) => ({
|
|
1363
1371
|
...s,
|
|
@@ -1373,8 +1381,8 @@ try {
|
|
|
1373
1381
|
if (stale.length || errors.length) process.exitCode = 1;
|
|
1374
1382
|
} else if (cmd === "gen") {
|
|
1375
1383
|
let failed = false;
|
|
1376
|
-
|
|
1377
|
-
|
|
1384
|
+
try {
|
|
1385
|
+
generateDirs(dirs, generate);
|
|
1378
1386
|
} catch (e) {
|
|
1379
1387
|
if (!(e instanceof CodegenError) || !flags.has("--watch")) throw e;
|
|
1380
1388
|
console.error(e.message);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as analyze,
|
|
1
|
+
import { a as analyze, g as scanDefines, p as listHtml } from "./a11y-Bw-8ZIAK.mjs";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, relative } from "node:path";
|
|
4
4
|
// src/lib/lint.ts
|
package/dist/oxlint-plugin.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { n as checkA11y, t as A11Y_RULES } from "./a11y-
|
|
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-
|
|
1
|
+
import { n as checkA11y, t as A11Y_RULES } from "./a11y-Bw-8ZIAK.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-qEPgmmMa.mjs";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
5
5
|
// src/lib/oxlint-plugin.ts
|
package/dist/vite-plugin.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as
|
|
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-Bw-8ZIAK.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
|
|
@@ -19,16 +19,10 @@ function tsDom(opts = {}) {
|
|
|
19
19
|
const a11y = opts.a11y === true ? {} : opts.a11y || null;
|
|
20
20
|
let dirs = (opts.dirs ?? ["src"]).map((d) => resolve(d));
|
|
21
21
|
const shown = /* @__PURE__ */ new Map();
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
};
|
|
22
|
+
let defs;
|
|
23
|
+
const existing = () => dirs.filter((d) => existsSync(d));
|
|
24
|
+
const allDefs = () => defs ??= scanDefines(existing());
|
|
25
|
+
const isDefineFile = (file) => allDefs().some((d) => resolve(d.file) === file);
|
|
32
26
|
const dirOf = (file) => dirs.find((d) => file.startsWith(d + sep));
|
|
33
27
|
const warnA11y = (warn) => {
|
|
34
28
|
if (!a11y) return;
|
|
@@ -48,7 +42,7 @@ function tsDom(opts = {}) {
|
|
|
48
42
|
const out = `${file}.ts`;
|
|
49
43
|
const before = read(out);
|
|
50
44
|
try {
|
|
51
|
-
generateFile(file, generate,
|
|
45
|
+
generateFile(file, generate, allDefs());
|
|
52
46
|
} catch (e) {
|
|
53
47
|
if (e instanceof CodegenError) logger.error(e.message);
|
|
54
48
|
else throw e;
|
|
@@ -57,16 +51,16 @@ function tsDom(opts = {}) {
|
|
|
57
51
|
if (a11y) for (const diag of checkA11y(html, a11y.ignore)) logger.warn(formatDiagnostics(file, [diag]));
|
|
58
52
|
return read(out) !== before;
|
|
59
53
|
};
|
|
60
|
-
/** Regenerate
|
|
61
|
-
const
|
|
62
|
-
const before = new Map(
|
|
54
|
+
/** Regenerate after a define() call may have changed: the tag maps and the child imports of every template. */
|
|
55
|
+
const regenerateAll = (logger) => {
|
|
56
|
+
const before = new Map(existing().flatMap(listHtml).map((f) => [`${f}.ts`, read(`${f}.ts`)]));
|
|
63
57
|
try {
|
|
64
|
-
|
|
58
|
+
generateDirs(existing(), generate);
|
|
65
59
|
} catch (e) {
|
|
66
60
|
if (e instanceof CodegenError) logger.error(e.message);
|
|
67
61
|
else throw e;
|
|
68
62
|
}
|
|
69
|
-
|
|
63
|
+
defs = scanDefines(existing());
|
|
70
64
|
for (const [out, content] of before) {
|
|
71
65
|
const now = read(out);
|
|
72
66
|
if (now !== null && now !== content) shown.set(out, now);
|
|
@@ -90,10 +84,11 @@ function tsDom(opts = {}) {
|
|
|
90
84
|
enforce: "pre",
|
|
91
85
|
configResolved(config) {
|
|
92
86
|
dirs = (opts.dirs ?? ["src"]).map((d) => resolve(config.root, d));
|
|
87
|
+
defs = void 0;
|
|
93
88
|
},
|
|
94
89
|
buildStart() {
|
|
95
90
|
try {
|
|
96
|
-
|
|
91
|
+
generateDirs(existing(), generate);
|
|
97
92
|
} catch (e) {
|
|
98
93
|
if (e instanceof CodegenError) this.error(e.message);
|
|
99
94
|
throw e;
|
|
@@ -105,15 +100,13 @@ function tsDom(opts = {}) {
|
|
|
105
100
|
server.watcher.add(dirs);
|
|
106
101
|
server.watcher.on("add", (f) => {
|
|
107
102
|
const file = resolve(f);
|
|
108
|
-
const dir = dirOf(file);
|
|
109
103
|
if (file.endsWith(".html")) regenerate(file, logger);
|
|
110
|
-
else if (
|
|
104
|
+
else if (dirOf(file) && isSourceTs(file) && read(file)?.includes("define(")) regenerateAll(logger);
|
|
111
105
|
});
|
|
112
106
|
server.watcher.on("unlink", (f) => {
|
|
113
107
|
const file = resolve(f);
|
|
114
|
-
const dir = dirOf(file);
|
|
115
108
|
if (file.endsWith(".html")) removeGenerated(file);
|
|
116
|
-
else if (
|
|
109
|
+
else if (dirOf(file) && isDefineFile(file)) regenerateAll(logger);
|
|
117
110
|
});
|
|
118
111
|
},
|
|
119
112
|
handleHotUpdate(ctx) {
|
|
@@ -130,9 +123,8 @@ function tsDom(opts = {}) {
|
|
|
130
123
|
if (regenerate(file, logger)) reload(ctx.server, `${file}.ts`);
|
|
131
124
|
return [];
|
|
132
125
|
}
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
if (read(file)?.includes("define(") || isDefineFile(file)) regenerateDir(dir, logger);
|
|
126
|
+
if (!dirOf(file) || !isSourceTs(file)) return;
|
|
127
|
+
if (read(file)?.includes("define(") || isDefineFile(file)) regenerateAll(logger);
|
|
136
128
|
}
|
|
137
129
|
};
|
|
138
130
|
}
|
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.
|
|
@@ -104,5 +104,5 @@ In a browser without a bundler map the bare name once: `<script type="importmap"
|
|
|
104
104
|
|
|
105
105
|
## Docs
|
|
106
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).
|
|
107
|
+
- Guides (English): [templates](docs/templates.md), [components and data flow](docs/components.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
108
|
- `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.
|
|
3
|
+
"version": "0.5.3",
|
|
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
|
@@ -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 | |
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nativele-architecture
|
|
3
|
+
description: "Lay out an app built with Nativele that has several pages: feature folders with their components, stores and tests, shared components and app-wide stores, pages as custom elements that a router mounts (the Navigation API with URLPattern, or universal-router), lazy-loaded page modules, one scanned folder for the generated files, and which behavior goes to unit, real-browser and end-to-end tests. Use when starting such an app or restructuring one, or when a project depends on @nativele/ts-dom and universal-router."
|
|
4
|
+
metadata:
|
|
5
|
+
tier: official
|
|
6
|
+
nativele: ">=0.5.3 <0.6.0"
|
|
7
|
+
verified: "2026-11-05"
|
|
8
|
+
tools: "vite@8.3.6 typescript@7.0.2 vitest@5.1.0 happy-dom@21.0.4 @vitest/browser-playwright@5.1.0 playwright@1.64.1 @playwright/test@1.64.1 create-vite@9.3.4 universal-router@10.0.3"
|
|
9
|
+
author: "Nativele maintainers"
|
|
10
|
+
detects: "universal-router"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Nativele app architecture
|
|
14
|
+
|
|
15
|
+
## When to use
|
|
16
|
+
|
|
17
|
+
- A Vite app that already uses Nativele (`@nativele/ts-dom`), set up as in `nativele-vite` (started with `npm create vite@latest -- --template vanilla-ts`), that has or will have several pages: you are starting its structure, or restructuring a flat `src/nv/` folder into features.
|
|
18
|
+
- Signals: more than one page or URL, a component folder that has grown past a screen of files, the same component or store wanted by two parts of the app, a router dependency such as `universal-router`.
|
|
19
|
+
- The reasoning behind every step (why features, where state lives, why one scanned folder) is in the architecture guide: https://github.com/Pixmi/nativele/blob/main/docs/architecture.md. This Skill is the procedure.
|
|
20
|
+
- Not for writing components (`nativele-core`), setting up Vite (`nativele-vite`), choosing or connecting a state library (`nativele-state-management`), or writing the tests themselves (`nativele-testing`); this Skill uses all four and links to them.
|
|
21
|
+
- Not for an app whose pages are rendered by a UI framework (React, Vue): there the framework's router mounts the pages; see that framework's Skill.
|
|
22
|
+
|
|
23
|
+
`ts-dom doctor` suggests this Skill when `universal-router` is a dependency. The recommended router needs no dependency, so there is nothing else in `package.json` that tells a multi-page app from a single widget; detecting `@nativele/ts-dom` itself would suggest it to every project, which `nativele-core` already covers.
|
|
24
|
+
|
|
25
|
+
## Versions
|
|
26
|
+
|
|
27
|
+
| Tool | Verified version |
|
|
28
|
+
|---|---|
|
|
29
|
+
| @nativele/ts-dom | 0.5.3 |
|
|
30
|
+
| vite | 8.3.6 |
|
|
31
|
+
| typescript | 7.0.2 |
|
|
32
|
+
| create-vite (`vanilla-ts` template, the starting point) | 9.3.4 |
|
|
33
|
+
| vitest | 5.1.0 |
|
|
34
|
+
| happy-dom | 21.0.4 |
|
|
35
|
+
| @vitest/browser-playwright | 5.1.0 |
|
|
36
|
+
| playwright | 1.64.1 (drives the installed Chrome; no browser is downloaded) |
|
|
37
|
+
| @playwright/test | 1.64.1 |
|
|
38
|
+
| universal-router | 10.0.3 (checked by hand with the example's tests, see step 5; the example project does not run it) |
|
|
39
|
+
|
|
40
|
+
Verified on 2026-11-05 with Node 24 and Google Chrome 148, with the example project in the Nativele repository (https://github.com/Pixmi/nativele/tree/main/skills/nativele-architecture/example; it is not in the npm package), which CI runs again when the example or the library changes and once a month. The router relies on the Navigation API and `URLPattern`, verified in Chrome only. With another major version of any tool above, treat every step as unverified.
|
|
41
|
+
|
|
42
|
+
## Set-up
|
|
43
|
+
|
|
44
|
+
1. **Make the folders** under `src/`:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
src/main.ts # imports the shell, starts the router
|
|
48
|
+
src/app/ # the router, the shell, stores of the app itself
|
|
49
|
+
src/shared/ # components and stores used by more than one feature
|
|
50
|
+
src/shared/stores/
|
|
51
|
+
src/features/<feature>/ # each feature's pages, components, store and tests
|
|
52
|
+
e2e/ # tests of the built app
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Restructuring: move each page component and the components only it uses into its feature folder, each component used by two features into `src/shared/`, and the header or navigation into `src/app/`. Templates need no change (the import of a child component is generated); fix the relative `import` paths in the `.ts` files, and `ts-dom check src` (step 2) reports the ones you missed as `TS2307`. A feature never imports another feature: move what both need to `src/shared/`.
|
|
56
|
+
|
|
57
|
+
2. **Scan one folder, `src`, everywhere.** In `vite.config.ts`, `tsDom({ dirs: ["src"] })`; in every script and command, `ts-dom check src` and `ts-dom doctor src`. One folder gives one `ts-dom-tags.ts` and one argument for every command (limitation 1). When the app used another folder before (`src/nv` from `nativele-vite`), delete the `ts-dom-tags.ts` left in it, and the folder itself once its components have moved (Troubleshooting).
|
|
58
|
+
|
|
59
|
+
3. **Set `tsconfig.json`'s `include`** to `["src", "e2e"]`: `src` covers the app, the tests next to the code and `ts-dom-tags.ts`, and `e2e` the end-to-end specs, so `ts-dom check src` type-checks all of them. Keep `"strict": true` from `nativele-vite`.
|
|
60
|
+
|
|
61
|
+
4. **Put the shell and the outlet in `index.html`**, and start the router from `src/main.ts`:
|
|
62
|
+
|
|
63
|
+
```html
|
|
64
|
+
<app-nav></app-nav>
|
|
65
|
+
<main id="outlet"></main>
|
|
66
|
+
<script type="module" src="/src/main.ts"></script>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// src/main.ts
|
|
71
|
+
import "./app/app-nav.ts";
|
|
72
|
+
import { startRouter } from "./app/router.ts";
|
|
73
|
+
|
|
74
|
+
void startRouter(document.querySelector("#outlet")!);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
5. **Add the router**, `src/app/router.ts` from the Example, with one `page(path, tag, () => import(module), params => props)` per route. Keep three things from it: the page module is imported before `document.createElement(tag)`, the route params are set as props, and `if (signal?.aborted) return;` comes after the import (limitation 3). If the app already has a router, keep it and call the same `mount()` from its route handler. With `universal-router` 10 (`npm i universal-router@10.0.3`) and the History API, keep the imports, `Tag`, `Params` and `mount()`, and replace `Route`, `page()`, `routes`, `loaded()`, `show()` and `startRouter()` with:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import UniversalRouter from "universal-router";
|
|
81
|
+
|
|
82
|
+
interface Match {
|
|
83
|
+
tag: Tag;
|
|
84
|
+
load: () => Promise<unknown>;
|
|
85
|
+
props: object;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function page<K extends Tag>(path: string, tag: K, load: () => Promise<unknown>, props: (params: Params) => Partial<HTMLElementTagNameMap[K]> = () => ({})) {
|
|
89
|
+
return { path, action: ({ params }: { params: Record<string, unknown> }): Match => ({ tag, load, props: props(params as Params) }) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const router = new UniversalRouter<Match | null>(
|
|
93
|
+
[
|
|
94
|
+
page("/", "projects-page", () => import("../features/projects/projects-page.ts")),
|
|
95
|
+
page("/projects/:id", "project-page", () => import("../features/projects/project-page.ts"), (p) => ({ projectId: p.id ?? "" })),
|
|
96
|
+
page("/settings", "settings-page", () => import("../features/settings/settings-page.ts")),
|
|
97
|
+
],
|
|
98
|
+
{ errorHandler: () => null },
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
async function loaded(match: Match): Promise<boolean> {
|
|
102
|
+
try {
|
|
103
|
+
await match.load();
|
|
104
|
+
return true;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function show(outlet: Element, url: string, signal?: AbortSignal): Promise<void> {
|
|
111
|
+
const path = new URL(url).pathname;
|
|
112
|
+
const match = await router.resolve(path);
|
|
113
|
+
const ok = match != null && (await loaded(match));
|
|
114
|
+
if (signal?.aborted) return;
|
|
115
|
+
if (match && ok) mount(outlet, match.tag, match.props);
|
|
116
|
+
else if (match) mount(outlet, "route-message", { heading: "This page could not be loaded", detail: "Check the connection and reload the page." });
|
|
117
|
+
else mount(outlet, "route-message", { heading: "Page not found", detail: `Nothing lives at ${path}.` });
|
|
118
|
+
currentPath.set(path);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let pending: AbortController | undefined;
|
|
122
|
+
|
|
123
|
+
export function startRouter(outlet: Element): Promise<void> {
|
|
124
|
+
const go = () => {
|
|
125
|
+
pending?.abort();
|
|
126
|
+
pending = new AbortController();
|
|
127
|
+
return show(outlet, location.href, pending.signal);
|
|
128
|
+
};
|
|
129
|
+
document.addEventListener("click", (e) => {
|
|
130
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
131
|
+
const a = e.target instanceof Element ? e.target.closest("a") : null;
|
|
132
|
+
if (!a || a.target || a.hasAttribute("download") || a.origin !== location.origin) return;
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
history.pushState(null, "", a.href);
|
|
135
|
+
void go();
|
|
136
|
+
});
|
|
137
|
+
addEventListener("popstate", () => void go());
|
|
138
|
+
return go();
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`router.resolve()` returns `undefined` when an action returns nothing, so test the match with `!= null`. With this router, the example's unit tests and its end-to-end tests pass except the one for a browser without the Navigation API (links are always handled by the click handler) and the one that calls `navigation.navigate()`, which passes when it clicks a link instead; its browser router tests use the Navigation API and do not apply.
|
|
143
|
+
|
|
144
|
+
6. **Write each page as a component** in its feature folder: route params arrive as string props (`props: { projectId: "" }`), the page shows what an unknown id means, and it binds its stores in `connected()`. Everything a page must keep after the user leaves goes into a store: leaving removes the page, which ends its `bind` subscriptions, and coming back creates a new element.
|
|
145
|
+
|
|
146
|
+
7. **Put state at the lowest level that holds it** (the table in the guide): a component's `state` for what one component shows; a feature store (`src/features/<feature>/<feature>-store.ts`: `createStore` plus its actions as exported functions and a `seed...()` function that returns fresh initial data, for tests) for what a feature's pages share; an app-wide store in `src/shared/stores/` for what the shell or several features read. For an existing Zustand, Redux or nanostores store, follow `nativele-state-management`.
|
|
147
|
+
|
|
148
|
+
8. **Make shared components take props and emit events** only; they never import a feature or a feature store. The feature decides what an event means (`text-entry` emits `entry-submit`; the projects page adds a project, the project page a task).
|
|
149
|
+
|
|
150
|
+
9. **Add the tests** as in `nativele-testing`, with the patterns for tests next to the code:
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
npm i -D vitest@5.1.0 happy-dom@21.0.4 @vitest/browser-playwright@5.1.0 playwright@1.64.1 @playwright/test@1.64.1
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- `src/**/*.test.ts` in happy-dom: store actions, components with props, events and stores, pages given their params as props, and the route table through `show()`.
|
|
157
|
+
- `src/**/*.browser.test.ts` in Chrome: the router with the Navigation API, and moves and focus in reordered lists.
|
|
158
|
+
- `e2e/*.spec.ts` with Playwright Test against `vite preview` of the build: deep links, navigation, back and forward, lazy chunks, data across pages.
|
|
159
|
+
|
|
160
|
+
10. **Set the scripts**:
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
npm pkg set scripts.build="ts-dom check src && vite build"
|
|
164
|
+
npm pkg set scripts.test="vitest run --project unit"
|
|
165
|
+
npm pkg set scripts.test:browser="vitest run --project browser"
|
|
166
|
+
npm pkg set scripts.test:e2e="playwright test"
|
|
167
|
+
npm pkg set scripts.verify="npm run build && ts-dom doctor src && npm test && npm run test:browser && npm run test:e2e"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Add `test-results` and `playwright-report` to `.gitignore`, next to `*.html.ts` and `ts-dom-tags.ts`.
|
|
171
|
+
|
|
172
|
+
## Example
|
|
173
|
+
|
|
174
|
+
The example project is a project tracker: a projects list (`/`), a project page with its tasks (`/projects/:id`) and a settings page (`/settings`).
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
index.html package.json tsconfig.json vite.config.ts playwright.config.ts
|
|
178
|
+
src/main.ts
|
|
179
|
+
src/app/router.ts src/app/current-path.ts src/app/app-nav.* src/app/route-message.* src/app/*.test.ts src/app/router.browser.test.ts
|
|
180
|
+
src/shared/text-entry.* src/shared/progress-meter.* src/shared/text-entry.test.ts src/shared/stores/preferences.ts
|
|
181
|
+
src/features/projects/projects-store.ts projects-page.* project-page.* project-card.* task-row.* *.test.ts project-page.browser.test.ts
|
|
182
|
+
src/features/settings/settings-page.* settings-page.test.ts
|
|
183
|
+
e2e/app.spec.ts
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The router (Navigation API and `URLPattern`, no dependency):
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
// src/app/router.ts
|
|
190
|
+
import { currentPath } from "./current-path.ts";
|
|
191
|
+
import "./route-message.ts";
|
|
192
|
+
|
|
193
|
+
type Tag = keyof HTMLElementTagNameMap;
|
|
194
|
+
type Params = Record<string, string | undefined>;
|
|
195
|
+
|
|
196
|
+
interface Route {
|
|
197
|
+
pattern: URLPattern;
|
|
198
|
+
tag: Tag;
|
|
199
|
+
load: () => Promise<unknown>;
|
|
200
|
+
props: (params: Params) => object;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function page<K extends Tag>(
|
|
204
|
+
pathname: string,
|
|
205
|
+
tag: K,
|
|
206
|
+
load: () => Promise<unknown>,
|
|
207
|
+
props: (params: Params) => Partial<HTMLElementTagNameMap[K]> = () => ({}),
|
|
208
|
+
): Route {
|
|
209
|
+
return { pattern: new URLPattern({ pathname }), tag, load, props };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const routes: Route[] = [
|
|
213
|
+
page("/", "projects-page", () => import("../features/projects/projects-page.ts")),
|
|
214
|
+
page("/projects/:id", "project-page", () => import("../features/projects/project-page.ts"), (p) => ({ projectId: p.id ?? "" })),
|
|
215
|
+
page("/settings", "settings-page", () => import("../features/settings/settings-page.ts")),
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
function mount<K extends Tag>(outlet: Element, tag: K, props: Partial<HTMLElementTagNameMap[K]>): void {
|
|
219
|
+
const el = document.createElement(tag);
|
|
220
|
+
Object.assign(el, props);
|
|
221
|
+
outlet.replaceChildren(el);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function loaded(route: Route): Promise<boolean> {
|
|
225
|
+
try {
|
|
226
|
+
await route.load();
|
|
227
|
+
return true;
|
|
228
|
+
} catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function show(outlet: Element, url: string, signal?: AbortSignal): Promise<void> {
|
|
234
|
+
const route = routes.find((r) => r.pattern.test(url));
|
|
235
|
+
const ok = route !== undefined && (await loaded(route));
|
|
236
|
+
if (signal?.aborted) return;
|
|
237
|
+
const path = new URL(url).pathname;
|
|
238
|
+
if (route && ok) mount(outlet, route.tag, route.props(route.pattern.exec(url)?.pathname.groups ?? {}));
|
|
239
|
+
else if (route) mount(outlet, "route-message", { heading: "This page could not be loaded", detail: "Check the connection and reload the page." });
|
|
240
|
+
else mount(outlet, "route-message", { heading: "Page not found", detail: `Nothing lives at ${path}.` });
|
|
241
|
+
currentPath.set(path);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function startRouter(outlet: Element): Promise<void> {
|
|
245
|
+
if ("navigation" in window) {
|
|
246
|
+
navigation.addEventListener("navigate", (e) => {
|
|
247
|
+
if (!e.canIntercept || e.hashChange || e.downloadRequest !== null || e.formData !== null) return;
|
|
248
|
+
e.intercept({ handler: () => show(outlet, e.destination.url, e.signal) });
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return show(outlet, location.href);
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
// src/app/current-path.ts: an app store the router sets and the shell binds to mark the current link
|
|
257
|
+
import { createStore } from "@nativele/ts-dom";
|
|
258
|
+
|
|
259
|
+
export const currentPath = createStore(location.pathname);
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`route-message` is a component with the props `heading` and `detail`. A wrong tag or prop in the route table is a `tsc` error, because the types come from `ts-dom-tags.ts`: `TS2345: Argument of type '"projects-pag"' is not assignable to parameter of type 'keyof HTMLElementTagNameMap'`, or `Type 'number' is not assignable to type 'string'` for `projectId`.
|
|
263
|
+
|
|
264
|
+
A feature store and a page of that feature:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
// src/features/projects/projects-store.ts (abridged: the example adds addProject and more seed data)
|
|
268
|
+
import { createStore } from "@nativele/ts-dom";
|
|
269
|
+
|
|
270
|
+
export interface Project { id: string; name: string }
|
|
271
|
+
export interface Task { id: string; projectId: string; title: string; done: boolean }
|
|
272
|
+
export interface ProjectsState { projects: readonly Project[]; tasks: readonly Task[]; nextId: number }
|
|
273
|
+
|
|
274
|
+
export function seedProjects(): ProjectsState {
|
|
275
|
+
return { projects: [{ id: "p1", name: "Website" }], tasks: [{ id: "t1", projectId: "p1", title: "Publish", done: false }], nextId: 2 };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const projects = createStore<ProjectsState>(seedProjects());
|
|
279
|
+
|
|
280
|
+
export function addTask(projectId: string, title: string): void {
|
|
281
|
+
projects.set((s) => ({ ...s, tasks: [...s.tasks, { id: `t${s.nextId}`, projectId, title, done: false }], nextId: s.nextId + 1 }));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function toggleTask(id: string): void {
|
|
285
|
+
projects.set((s) => ({ ...s, tasks: s.tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) }));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function tasksOf(s: ProjectsState, projectId: string): readonly Task[] {
|
|
289
|
+
return s.tasks.filter((t) => t.projectId === projectId);
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
```html
|
|
294
|
+
<!-- src/features/projects/project-page.html -->
|
|
295
|
+
<p><a href="/">All projects</a></p>
|
|
296
|
+
<section data-if="found">
|
|
297
|
+
<h1>{{ name }}</h1>
|
|
298
|
+
<progress-meter data-ref="meter"></progress-meter>
|
|
299
|
+
<text-entry label="New task" action="Add task" data-on-entry-submit="onAdd"></text-entry>
|
|
300
|
+
<ul>
|
|
301
|
+
<template data-each="tasks" data-key="id" data-prop="task">
|
|
302
|
+
<task-row role="listitem" data-on-task-toggle="onToggle"></task-row>
|
|
303
|
+
</template>
|
|
304
|
+
</ul>
|
|
305
|
+
<p data-if="someHidden">{{ hiddenCount }} done tasks are hidden.</p>
|
|
306
|
+
</section>
|
|
307
|
+
<p data-if="missing">There is no project with this id.</p>
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
// src/features/projects/project-page.ts
|
|
312
|
+
import { Component, define } from "@nativele/ts-dom";
|
|
313
|
+
import type { DeepReadonly, EmitEvent, EventHit } from "@nativele/ts-dom";
|
|
314
|
+
import { preferences } from "../../shared/stores/preferences.ts";
|
|
315
|
+
import type { TextEntry } from "../../shared/text-entry.ts";
|
|
316
|
+
import { addTask, projects, tasksOf, toggleTask, type ProjectsState, type Task } from "./projects-store.ts";
|
|
317
|
+
import tpl from "./project-page.html.js";
|
|
318
|
+
|
|
319
|
+
type View = DeepReadonly<{ projectId: string; data: ProjectsState; showDone: boolean }>;
|
|
320
|
+
|
|
321
|
+
const project = (v: View) => v.data.projects.find((p) => p.id === v.projectId);
|
|
322
|
+
const all = (v: View) => tasksOf(v.data, v.projectId);
|
|
323
|
+
const visible = (v: View) => all(v).filter((t) => v.showDone || !t.done);
|
|
324
|
+
|
|
325
|
+
export class ProjectPage extends Component(tpl, {
|
|
326
|
+
props: { projectId: "" },
|
|
327
|
+
state: { data: { projects: [], tasks: [], nextId: 0 } as ProjectsState, showDone: true },
|
|
328
|
+
derive: {
|
|
329
|
+
found: (v) => project(v) !== undefined,
|
|
330
|
+
missing: (v) => project(v) === undefined,
|
|
331
|
+
name: (v) => project(v)?.name ?? "",
|
|
332
|
+
tasks: (v) => [...visible(v)].sort((a, b) => Number(a.done) - Number(b.done)),
|
|
333
|
+
doneCount: (v) => all(v).filter((t) => t.done).length,
|
|
334
|
+
totalCount: (v) => all(v).length,
|
|
335
|
+
hiddenCount: (v) => all(v).length - visible(v).length,
|
|
336
|
+
someHidden: (v) => all(v).length > visible(v).length,
|
|
337
|
+
},
|
|
338
|
+
}) {
|
|
339
|
+
override connected(): void {
|
|
340
|
+
this.bind(projects, (data) => this.set({ data }));
|
|
341
|
+
this.bind(preferences, (p) => this.set({ showDone: p.showDone }));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
onAdd(e: Event): void {
|
|
345
|
+
addTask(this.props.projectId, (e as EmitEvent<TextEntry, "entry-submit">).detail.value);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
onToggle(_e: Event, hit: EventHit<Task, "id">): void {
|
|
349
|
+
toggleTask(hit.key);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
override render(
|
|
353
|
+
view: { tasks: readonly Task[]; doneCount: number; totalCount: number },
|
|
354
|
+
changed: ReadonlySet<"tasks" | "doneCount" | "totalCount">,
|
|
355
|
+
): void {
|
|
356
|
+
if (changed.has("tasks")) this.lists.tasks.items = view.tasks;
|
|
357
|
+
if (changed.has("doneCount")) this.refs.meter.done = view.doneCount;
|
|
358
|
+
if (changed.has("totalCount")) this.refs.meter.total = view.totalCount;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
define("project-page", ProjectPage);
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
`preferences` is the app-wide store (`createStore<Preferences>({ userName: "", showDone: true })` in `src/shared/stores/preferences.ts`), written by the settings page and read by the shell and this page. `text-entry` and `progress-meter` come from `src/shared/`; `task-row` is this feature's own item component. The page's template uses their tags, and the generated `project-page.html.ts` imports all three.
|
|
365
|
+
|
|
366
|
+
The configuration (the browser project and the Playwright config follow `nativele-testing`, with this project's ports):
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
// vite.config.ts
|
|
370
|
+
import { playwright } from "@vitest/browser-playwright";
|
|
371
|
+
import { defineConfig } from "vitest/config";
|
|
372
|
+
import { tsDom } from "@nativele/ts-dom/vite";
|
|
373
|
+
|
|
374
|
+
const chrome = process.env.CHROME_BIN ?? "/usr/bin/google-chrome";
|
|
375
|
+
|
|
376
|
+
export default defineConfig({
|
|
377
|
+
plugins: [tsDom({ dirs: ["src"] })],
|
|
378
|
+
server: { port: 5481, strictPort: true },
|
|
379
|
+
preview: { port: 4481, strictPort: true },
|
|
380
|
+
test: {
|
|
381
|
+
projects: [
|
|
382
|
+
{
|
|
383
|
+
extends: true,
|
|
384
|
+
test: { name: "unit", include: ["src/**/*.test.ts"], exclude: ["src/**/*.browser.test.ts"], environment: "happy-dom" },
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
extends: true,
|
|
388
|
+
test: {
|
|
389
|
+
name: "browser",
|
|
390
|
+
include: ["src/**/*.browser.test.ts"],
|
|
391
|
+
api: { port: 63481, strictPort: true },
|
|
392
|
+
browser: {
|
|
393
|
+
enabled: true,
|
|
394
|
+
headless: true,
|
|
395
|
+
provider: playwright({ launchOptions: { executablePath: chrome, args: ["--no-sandbox", "--no-zygote", "--disable-gpu", "--disable-dev-shm-usage"] } }),
|
|
396
|
+
instances: [{ browser: "chromium" }],
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
// playwright.config.ts: the build comes from npm run build, which verify runs first
|
|
407
|
+
import { defineConfig } from "@playwright/test";
|
|
408
|
+
|
|
409
|
+
const chrome = process.env.CHROME_BIN ?? "/usr/bin/google-chrome";
|
|
410
|
+
|
|
411
|
+
export default defineConfig({
|
|
412
|
+
testDir: "e2e",
|
|
413
|
+
use: {
|
|
414
|
+
baseURL: "http://localhost:4481",
|
|
415
|
+
launchOptions: { executablePath: chrome, args: ["--no-sandbox", "--no-zygote", "--disable-gpu", "--disable-dev-shm-usage"] },
|
|
416
|
+
},
|
|
417
|
+
webServer: { command: "npx vite preview", url: "http://localhost:4481" },
|
|
418
|
+
});
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
One test of each kind:
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
// src/features/projects/project-page.test.ts (happy-dom)
|
|
425
|
+
beforeEach(() => {
|
|
426
|
+
projects.set(seedProjects());
|
|
427
|
+
preferences.set(defaultPreferences);
|
|
428
|
+
});
|
|
429
|
+
afterEach(() => document.body.replaceChildren());
|
|
430
|
+
|
|
431
|
+
test("leaving the page ends its subscriptions: a removed page no longer renders on store changes", () => {
|
|
432
|
+
const el = document.createElement("project-page");
|
|
433
|
+
el.projectId = "p1";
|
|
434
|
+
document.body.append(el);
|
|
435
|
+
const render = vi.spyOn(ProjectPage.prototype, "render");
|
|
436
|
+
preferences.set({ ...preferences.get(), showDone: false });
|
|
437
|
+
expect(render).toHaveBeenCalledTimes(1);
|
|
438
|
+
render.mockClear();
|
|
439
|
+
el.remove();
|
|
440
|
+
projects.set(seedProjects());
|
|
441
|
+
preferences.set({ ...preferences.get(), showDone: true });
|
|
442
|
+
expect(render).not.toHaveBeenCalled();
|
|
443
|
+
});
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
```ts
|
|
447
|
+
// src/app/router.browser.test.ts (Chrome)
|
|
448
|
+
const start = location.href;
|
|
449
|
+
const outlet = document.createElement("main");
|
|
450
|
+
document.body.append(outlet);
|
|
451
|
+
const ready = startRouter(outlet);
|
|
452
|
+
|
|
453
|
+
afterAll(() => navigation.navigate(start, { history: "replace" }).finished);
|
|
454
|
+
|
|
455
|
+
test("the Navigation API carries same-document navigations to the router", async () => {
|
|
456
|
+
await ready;
|
|
457
|
+
await navigation.navigate("/settings").finished;
|
|
458
|
+
expect(outlet.firstElementChild?.localName).toBe("settings-page");
|
|
459
|
+
});
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
// e2e/app.spec.ts (Playwright Test, built app)
|
|
464
|
+
test("each page module is loaded only when its route is first shown", async ({ page }) => {
|
|
465
|
+
const scripts: string[] = [];
|
|
466
|
+
page.on("request", (r) => {
|
|
467
|
+
if (r.resourceType() === "script") scripts.push(new URL(r.url()).pathname.replace(/-[\w-]{8}\.js$/, ".js"));
|
|
468
|
+
});
|
|
469
|
+
await page.goto("/settings");
|
|
470
|
+
await expect(page.locator("#outlet h1")).toHaveText("Settings");
|
|
471
|
+
expect(scripts.sort()).toEqual(["/assets/index.js", "/assets/settings-page.js"]);
|
|
472
|
+
});
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
## Known limitations
|
|
476
|
+
|
|
477
|
+
1. **Scan one folder.** Several folders work too (`dirs: ["src/shared", "src/features"]`): a template imports a child defined in any scanned folder, and a tag defined in two of them is `duplicate-define`. But every folder then needs to be named in `tsDom({ dirs })`, `ts-dom check`, `ts-dom doctor` and the tsconfig `include` alike, and a folder left out of one of them is silently not generated or not checked there. One folder, `src`, avoids that. A component outside every scanned folder is not imported automatically: import its module where it is used.
|
|
478
|
+
2. **Browser support.** The router needs `URLPattern`: without it the app stops at start-up with `URLPattern is not defined`. Without the Navigation API, `startRouter` attaches nothing, every link loads a new document, and the router renders the route at start-up (verified in Chrome with `navigation` removed). Only Chrome 148 was verified; check your target browsers, or use a History API router (step 5).
|
|
479
|
+
3. **Keep the abort check.** Without `if (signal?.aborted) return;` after the import, a page module that loads slowly mounts over the page of a later navigation, and the address bar and the page disagree. The example's end-to-end test delays a chunk to prove the check, for both routers.
|
|
480
|
+
4. **Every app path must return `index.html`.** Deep links and reloads (and every link, without the Navigation API) ask the server for the page's path. `vite dev` and `vite preview` answer with `index.html`; a production server must be set up the same way (not verified here).
|
|
481
|
+
5. **A page is a new element on every navigation.** Its `state` starts over, also on back and forward, and also between `/projects/p1` and `/projects/p2`. Keep what must survive in a store.
|
|
482
|
+
6. **Feature stores live as long as the document.** A store created in a lazily loaded module is created on the first visit to the feature and kept until a reload; reset it in tests with `store.set(seed())` in `beforeEach`.
|
|
483
|
+
7. **happy-dom has no Navigation API and no `moveBefore`.** A test that uses `navigation` fails there with `ReferenceError: navigation is not defined`, and a reordered list item runs `disconnected()` and loses focus where Chrome keeps both. Put them in `*.browser.test.ts`; the route table can still be tested in happy-dom through `show()`.
|
|
484
|
+
8. Not verified: routers other than the two in step 5, nested routes and layouts, route guards, query strings and hashes as props (the `URLPattern` here matches the path only), scroll restoration and focus after a navigation, server-side rendering, browsers other than Chrome, production servers, and oxlint with tests next to the components (the `ts-dom/unused-markup` rule counts only the component's own class, see `nativele-testing`).
|
|
485
|
+
|
|
486
|
+
## Verify
|
|
487
|
+
|
|
488
|
+
Run in the project:
|
|
489
|
+
|
|
490
|
+
```sh
|
|
491
|
+
npm run build # expect: ts-dom check: no issues, then ✓ built, with one chunk per page module
|
|
492
|
+
npx ts-dom doctor src # expect: ts-dom doctor: the set-up looks right (info lines are suggestions)
|
|
493
|
+
npm test # expect: every src/**/*.test.ts passes in happy-dom
|
|
494
|
+
npm run test:browser # expect: every src/**/*.browser.test.ts passes in headless Chrome
|
|
495
|
+
npm run test:e2e # expect: every e2e spec passes against vite preview on the built app
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
The example project's `npm run verify` runs exactly these, from a clean checkout with nothing generated; its output (the `info` lines left out):
|
|
499
|
+
|
|
500
|
+
```
|
|
501
|
+
ts-dom check: no issues
|
|
502
|
+
dist/index.html 0.49 kB
|
|
503
|
+
dist/assets/settings-page-CdmdUBHw.js 0.72 kB
|
|
504
|
+
dist/assets/projects-page-C9cYN-pF.js 1.20 kB
|
|
505
|
+
dist/assets/text-entry-C1mgKfH1.js 1.54 kB
|
|
506
|
+
dist/assets/project-page-CqUTMG_P.js 1.98 kB
|
|
507
|
+
dist/assets/index-B8nptlG1.js 21.00 kB
|
|
508
|
+
✓ built in 100ms
|
|
509
|
+
ts-dom doctor: the set-up looks right
|
|
510
|
+
Test Files 7 passed (7)
|
|
511
|
+
Tests 19 passed (19)
|
|
512
|
+
Test Files 2 passed (2)
|
|
513
|
+
Tests 4 passed (4)
|
|
514
|
+
8 passed (3.9s)
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
The end-to-end tests cover: a deep link, links without a new document and a store keeping data across pages, the app-wide store reaching the shell and another feature, page chunks loaded on demand, a superseded navigation, a page chunk that fails to load, an unknown path, and a browser without the Navigation API.
|
|
518
|
+
|
|
519
|
+
## Troubleshooting
|
|
520
|
+
|
|
521
|
+
| Symptom | Cause | Fix |
|
|
522
|
+
|---|---|---|
|
|
523
|
+
| A shared component's tag stays empty on a page, with no error from `check`, `doctor`, `lint` or the build | the component is defined outside every scanned folder, so the generated module does not import it | scan the folder that holds it (`tsDom({ dirs: ["src"] })`, `ts-dom check src`), or import the component's module yourself |
|
|
524
|
+
| `TS2307: Cannot find module './x.js'` in `src/nv/ts-dom-tags.ts` after moving components | a `ts-dom-tags.ts` left in the folder that was scanned before | delete it, and the old folder once it is empty; only `src/ts-dom-tags.ts` is generated now |
|
|
525
|
+
| `[duplicate-define] the tag <x> is defined more than once` after merging folders into one scan | two features define the same tag | rename one; tags share one registry |
|
|
526
|
+
| The page of an earlier link appears after a later one, and the address bar shows the later URL | the abort check after the import is missing | `if (signal?.aborted) return;` after `await` in `show()` |
|
|
527
|
+
| `URLPattern is not defined` at start-up | the browser has no `URLPattern` | use a History API router (step 5) for those browsers |
|
|
528
|
+
| `ReferenceError: navigation is not defined` in a Vitest test | the test runs in happy-dom | rename it `*.browser.test.ts`, or test `show()` instead |
|
|
529
|
+
| 404 on a reload or a deep link in production | the server does not answer app paths with `index.html` | configure the server's fallback to `index.html` |
|
|
530
|
+
| `TS2345: ... is not assignable to parameter of type 'keyof HTMLElementTagNameMap'` in the route table | the tag is misspelled | use the tag the page module passes to `define()` |
|
|
531
|
+
| `TS2345: ... has no properties in common with type 'Partial<...>'` in the route table | the props function names a prop the page does not declare | use the page's prop name |
|
|
532
|
+
|
|
533
|
+
## See also
|
|
534
|
+
|
|
535
|
+
- Why the layout, the routing contract and the state levels: https://github.com/Pixmi/nativele/blob/main/docs/architecture.md
|
|
536
|
+
- `bind`, `createStore`, props and events: https://github.com/Pixmi/nativele/blob/main/docs/components.md
|
|
537
|
+
- Lists of item components (`data-key`, `data-prop`) and moving items: https://github.com/Pixmi/nativele/blob/main/docs/lists.md
|
|
538
|
+
- What `ts-dom check`, `doctor` and `gen` check and write: https://github.com/Pixmi/nativele/blob/main/docs/tooling.md#checks
|
|
539
|
+
- Setting up Vite: `npx ts-dom skills nativele-vite`
|
|
540
|
+
- State libraries through `bind`: `npx ts-dom skills nativele-state-management`
|
|
541
|
+
- Writing the tests: `npx ts-dom skills nativele-testing`
|