@nativele/ts-dom 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -1
- package/README.md +64 -6
- package/README.zh-TW.md +64 -6
- package/dist/{a11y-DZvViTVA.mjs → a11y-Bn9pDvWV.mjs} +75 -9
- package/dist/cli.mjs +2 -2
- package/dist/index.d.mts +59 -3
- package/dist/index.mjs +71 -6
- package/dist/{lint-B7SIRo0c.mjs → lint-CqWk4U5P.mjs} +2 -2
- package/dist/oxlint-plugin.mjs +2 -2
- package/dist/vite-plugin.d.mts +29 -6
- package/dist/vite-plugin.mjs +98 -19
- package/llms.txt +11 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,38 @@ 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.4.0
|
|
14
|
+
|
|
15
|
+
### Lists of components
|
|
16
|
+
|
|
17
|
+
- `<template data-each="todos" data-key="id" data-prop="todo"><todo-row role="listitem"></todo-row></template>` and `this.lists.todos.items = todos`: each item is one component that receives its item as a property, matched by key. An element is kept for a key that stays (moved with `moveBefore`), created for a new key and removed for a key that is gone; reading `items` returns the items in document order.
|
|
18
|
+
- `tsc` checks the assigned items against the property type the item component declares and the `data-key` field (a string or a number). An item component defined in a scanned folder must be exported and listed in `ts-dom-tags.ts`, otherwise the assignment is a type error that names the cause; only components from outside the scanned folders get a loose type.
|
|
19
|
+
- New codegen rules `each-binding-incomplete`, `invalid-each-binding` and `each-binding-root`, and runtime errors with a `Fix:` hint for what types cannot see (`invalid-items`, `invalid-item`, `missing-key`, `duplicate-key`, `list-not-bound`, `unknown-prop`). A wrong `data-prop` throws before the list changes.
|
|
20
|
+
- The accessibility check `list-structure` accepts a custom element with `role="listitem"` as a list item.
|
|
21
|
+
- `replace()` stays for lists of plain elements, and `data-each` still cannot be nested: a list inside an item is that component's own list.
|
|
22
|
+
|
|
23
|
+
### Vite dev server
|
|
24
|
+
|
|
25
|
+
- Editing a template regenerates its `x.html.ts` and reloads the page once if a page uses it; a save that changes nothing does not reload. This works under Vite and Vite+ (which reports changed files by relative path).
|
|
26
|
+
- Deleting a template removes its generated module, and adding or removing a `define()` updates `ts-dom-tags.ts` and the child imports of the templates.
|
|
27
|
+
- A generated module rewritten by something else (`ts-dom gen --watch`, a checkout) reloads the page through Vite; duplicate change events and identical rewrites by other dev servers no longer reload it twice.
|
|
28
|
+
- `dirs` are resolved against Vite's `root` instead of the working directory. The `define()` scan is cached and redone only when a `.ts` file with a `define()` changes. Messages go to the logger of the server that saw the change.
|
|
29
|
+
|
|
30
|
+
### Fixes
|
|
31
|
+
|
|
32
|
+
- The accessibility messages say "natively" again where it had been replaced by "ts-domy".
|
|
33
|
+
|
|
34
|
+
### Breaking changes
|
|
35
|
+
|
|
36
|
+
- The Vite plugin's `dirs` are relative to Vite's `root`; a project whose `root` differs from the working directory must adjust them.
|
|
37
|
+
- A project whose item component class is not exported, or whose `ts-dom-tags.ts` is stale, now gets a type error on `items`.
|
|
38
|
+
|
|
39
|
+
## 0.3.0
|
|
40
|
+
|
|
41
|
+
- `ts-dom gen` rejects `<html>`, `<head>` and `<body>` inside a template (rule `document-tag`) instead of silently dropping them; a file that starts with `<!doctype html>` or `<html>` is still a page entry whatever its name.
|
|
42
|
+
- `tpl.instantiate().rootElement` is the template's only top-level element, typed by its tag; the type has it only for a template with exactly one top-level element (generated `x.html.ts` files carry a `rootElement` field, so regenerate them with `ts-dom gen`).
|
|
43
|
+
- Non-bubbling events such as media events (`canplay`, `loadeddata`, ...), `close`, `cancel` and `invalid` now reach `data-on-*` handlers; they were listened for in the bubble phase and never fired.
|
|
44
|
+
|
|
13
45
|
## 0.2.1
|
|
14
46
|
|
|
15
47
|
- Releases are published through npm Trusted Publishing: the release workflow stages the version without an npm token, and a maintainer approves it on npm. No library code changes.
|
|
@@ -37,7 +69,6 @@ First release.
|
|
|
37
69
|
- `whitespace: "collapse"` (CLI `--whitespace=collapse`, Vite plugin option) collapses whitespace runs and drops line-break-only text between tags; the default keeps text as written.
|
|
38
70
|
- `replace()` throws `[duplicate-key]` for two items with the same key and leaves the list untouched. `EventHit<Item>` types `hit.item` when the handler annotates it. `bind` accepts any source with `get` and `subscribe` (`ReadableStore`).
|
|
39
71
|
- `ts-dom gen --include=<glob> --exclude=<glob>` (and the Vite plugin options) generate only the selected templates, for migrating a project in steps.
|
|
40
|
-
|
|
41
72
|
### Composition
|
|
42
73
|
|
|
43
74
|
- Child components used in a template are imported automatically by the generated `x.html.ts`; HTML is the only source of dependencies.
|
package/README.md
CHANGED
|
@@ -31,11 +31,34 @@ define("user-card", UserCard);
|
|
|
31
31
|
|
|
32
32
|
If the HTML uses a name that `state` does not have, or `data-on-click` points to a method that does not exist, `tsc` reports an error.
|
|
33
33
|
|
|
34
|
+
## The Short Version
|
|
35
|
+
|
|
36
|
+
Six things cover most components:
|
|
37
|
+
|
|
38
|
+
1. **HTML only names things**: `{{ name }}` (text), `data-ref="x"` (an element you need), `data-if="x"` (shown or not), `<template data-each="x">` (a list), `data-on-click="method"` (an event), `<slot>`. There are no expressions; compute in TypeScript.
|
|
39
|
+
2. **Run `npx ts-dom gen` before `tsc`** whenever a `.html` changes. It writes the typed `x.html.ts` that `import tpl from "./x.html.js"` needs; until then `tsc` can only say the module is missing.
|
|
40
|
+
3. **Data lives in the class**: `state` (yours), `props` (the parent's), `derive` (computed). Change state only with `this.set()`; `{{ }}` and `data-if` with the same name update by themselves.
|
|
41
|
+
4. **Everything else goes in `render(view, changed)`**: lists, attributes, focus. A list of components is one assignment, `if (changed.has("todos")) this.lists.todos.items = view.todos`, with `data-key` and `data-prop` on its `<template data-each>`.
|
|
42
|
+
5. **Props go down, events go up**: the parent sets props; the child calls `this.emit("name", detail)` and the parent handles it with `data-on-name` on the child's tag.
|
|
43
|
+
6. **A handler is a public method** `(event, hit)`; inside a list item `hit.key` is the item's key.
|
|
44
|
+
|
|
45
|
+
## Rules for AI Agents
|
|
46
|
+
|
|
47
|
+
Paste these into your project's agent instructions (`AGENTS.md`, `CLAUDE.md` or similar):
|
|
48
|
+
|
|
49
|
+
- Write the `.html` and the `.ts` of a component together; never edit `x.html.ts` or `ts-dom-tags.ts`, which `ts-dom gen` writes.
|
|
50
|
+
- After changing any `.html`, run `npx ts-dom gen && npx tsc`, then fix every error the types report (a missing name in `state`, a missing handler method) instead of casting it away.
|
|
51
|
+
- Import templates as `./x.html.js`.
|
|
52
|
+
- No expressions in HTML: put computed values in `derive`, and change state only with `this.set()`.
|
|
53
|
+
- Every diagnostic from `ts-dom gen` and `ts-dom lint` ends with a `Fix:` hint; follow it. `npx ts-dom rules` lists all rule ids, and `npx ts-dom lint --a11y` also checks accessibility.
|
|
54
|
+
- A `data-each` list cannot be nested: put the inner list in its own `.html` as a child component.
|
|
55
|
+
- For a list of components, write `data-key` and `data-prop` on the `<template data-each>`, give the item component `role="listitem"` inside `<ul>` or `<ol>`, assign `this.lists.x.items = ...` with new objects for changed items (never mutate an assigned item), and let `tsc` check the items.
|
|
56
|
+
|
|
34
57
|
## Quick Start
|
|
35
58
|
|
|
36
59
|
```sh
|
|
37
60
|
npm i -D typescript && npm i @nativele/ts-dom
|
|
38
|
-
npx ts-dom gen # generate x.html.ts (fully typed) for every x.html
|
|
61
|
+
npx ts-dom gen # generate x.html.ts (fully typed) for every x.html; run it again after every .html change
|
|
39
62
|
npx tsc # type-check and compile; no Vite or other bundler needed
|
|
40
63
|
```
|
|
41
64
|
|
|
@@ -297,6 +320,8 @@ export default defineConfig({
|
|
|
297
320
|
});
|
|
298
321
|
```
|
|
299
322
|
|
|
323
|
+
While the dev server runs, the plugin keeps the generated files in step: adding or editing a template regenerates its `x.html.ts` and reloads the page once if a page uses it (a save that changes nothing does not reload), deleting a template removes its `x.html.ts`, and adding or removing a `define()` updates `ts-dom-tags.ts` and the child imports of the templates. `dirs` are relative to Vite's `root`. The page reloads rather than hot-swapping the component because a custom element tag can be defined only once, so a new class cannot replace the running one.
|
|
324
|
+
|
|
300
325
|
When installing, we recommend installing `vite-plus` first and then `ts-dom` (when both are installed in the same npm run, `vite` may not be hoisted to the top level).
|
|
301
326
|
|
|
302
327
|
### Lint (optional)
|
|
@@ -352,7 +377,7 @@ export default template<{
|
|
|
352
377
|
}>([["h2", {}, ["Hello, {{ name }}!"]], /* ... */]);
|
|
353
378
|
```
|
|
354
379
|
|
|
355
|
-
Whole-page documents
|
|
380
|
+
Whole-page documents are page entry points, not templates, and are not processed. A file is a page when its content starts with `<!doctype html>` or `<html>` (comments before it are fine); the file name does not matter, so `index.html`, `main.html` and `login.html` all work. A template is a fragment of a component, so `<html>`, `<head>` and `<body>` are rejected in it (rule `document-tag`): the browser would drop them from the fragment and whatever you put on them would have no effect. To keep a file out of generation in another way, use `--exclude`.
|
|
356
381
|
|
|
357
382
|
## HTML Syntax
|
|
358
383
|
|
|
@@ -656,18 +681,47 @@ const off = store.subscribe((s) => console.log(s)); // returns an unsubscribe fu
|
|
|
656
681
|
```ts
|
|
657
682
|
import itemTpl from "./menu-item.html.js";
|
|
658
683
|
|
|
659
|
-
function menuItem(label: string):
|
|
684
|
+
function menuItem(label: string): HTMLLIElement {
|
|
660
685
|
const view = itemTpl.instantiate();
|
|
661
686
|
view.text.label = label;
|
|
662
687
|
view.refs.button.addEventListener("click", () => select(label));
|
|
663
|
-
return view.
|
|
688
|
+
return view.rootElement; // typed by the template's top-level tag
|
|
664
689
|
}
|
|
665
690
|
```
|
|
666
691
|
|
|
692
|
+
`rootElement` is the template's only top-level element (a top-level `<style>` does not count); it exists in the type only when the template has exactly one, so a template that later gains a second root element makes `tsc` report the use.
|
|
693
|
+
|
|
667
694
|
There is no host element, so `data-on-*` attributes do nothing here (listen on `refs`) and `<slot>` is not projected; use a component when you need those.
|
|
668
695
|
|
|
669
696
|
## Lists
|
|
670
697
|
|
|
698
|
+
There are two ways to fill a list. Use components when an item has anything of its own (state, events, a nested list), and `replace()` for small items made of plain elements.
|
|
699
|
+
|
|
700
|
+
### Items as components: `items = ...`
|
|
701
|
+
|
|
702
|
+
Name the field that identifies an item and the property that receives it, on the `<template data-each>`; the item is one custom element:
|
|
703
|
+
|
|
704
|
+
```html
|
|
705
|
+
<ul>
|
|
706
|
+
<template data-each="todos" data-key="id" data-prop="todo">
|
|
707
|
+
<todo-row role="listitem" data-on-toggled="toggle"></todo-row>
|
|
708
|
+
</template>
|
|
709
|
+
</ul>
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
```ts
|
|
713
|
+
render(view, changed) {
|
|
714
|
+
if (changed.has("todos")) this.lists.todos.items = view.todos;
|
|
715
|
+
}
|
|
716
|
+
toggle(_e: Event, hit: EventHit) { /* hit.key is the item's id */ }
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
Assigning `items` keeps the element of each key that stays (moved with `moveBefore` where the browser has it, so focus and state survive), creates one for a new key, removes the ones that are gone, and sets `todo` on each element whose item is not the same object as last time. Use immutable updates: changing a field of an object you already assigned and assigning the same object again does not update the item component, because props are compared by reference; assign a new object instead, for example `{ ...todo, done: true }`. The item component declares `todo` in its `props`, shows it however it likes, and tells the parent with `emit`; the parent reads `hit.key` in the handler. Reading `items` returns what was last assigned. `tsc` checks the assignment: each item must have the type of the `todo` property that `<todo-row>` declares and a `data-key` field (`id`), and the item component must declare the `data-prop` property; a list without `data-key` and `data-prop` takes no items. This relies on the component being registered in `ts-dom-tags.ts` (an exported `define("todo-row", TodoRow)` in a scanned directory). When the component is defined in a scanned directory but its class is not exported, or `ts-dom-tags.ts` does not list it yet, the assignment is a type error that says so (`itemComponentClassNotExported`, `itemComponentMissingFromTagMap`); only a component from outside the scanned directories accepts any item with the `id` field. At runtime, assigning throws when the value is not an array of objects, when an item has no string or number `id`, when two items share one, or when the template has no `data-key` and `data-prop`.
|
|
720
|
+
|
|
721
|
+
Inside a `<ul>` or `<ol>` the item component needs `role="listitem"`, because a custom element is not an `<li>`; the accessibility check (`list-structure`) asks for it. `data-each` cannot be nested, on purpose: an item is one component, so a list inside an item is that component's own `data-each`. Every template then holds one level, with its own `refs`, `text` and event handling.
|
|
722
|
+
|
|
723
|
+
### Items as plain elements: `replace()`
|
|
724
|
+
|
|
671
725
|
```html
|
|
672
726
|
<ul>
|
|
673
727
|
<template data-each="tags">
|
|
@@ -704,6 +758,10 @@ Notes:
|
|
|
704
758
|
- `data-each` cannot be nested. Split it into two templates: put the inner list in its own `.html` (a child component with its own `data-each`) and use that component's tag inside the outer item.
|
|
705
759
|
- `<slot>` cannot be placed inside an item.
|
|
706
760
|
|
|
761
|
+
### Order of list updates
|
|
762
|
+
|
|
763
|
+
`replace()` and `upsert()` put a new item's element into the document first and then call `update`, so a child component placed in an item is connected before the parent has set its properties. Build per-item state in the child when it first receives data (for example in a property setter), and guard a render in `connected()` with the data it needs.
|
|
764
|
+
|
|
707
765
|
## Trusted Types, Translations and Icons
|
|
708
766
|
|
|
709
767
|
- Trusted Types: pages that enforce `require-trusted-types-for 'script'` work with no policy, because `ts-dom gen` writes the markup as data and the runtime builds it with DOM calls. Your own code must avoid `innerHTML` too.
|
|
@@ -720,7 +778,7 @@ Notes:
|
|
|
720
778
|
|
|
721
779
|
Four complementary checks. **Every message has a stable rule id and explains what is wrong, why, and how to fix it**; `npx ts-dom rules` lists all rules.
|
|
722
780
|
|
|
723
|
-
1. **codegen diagnostics** (`ts-dom gen` and the Vite plugin, with line and column numbers; on errors they abort and write no files): duplicate `data-ref`, `data-each` without a single root element, `data-each` not on a `<template>`, nested `data-each`, `data-if` on an item root element, `<slot>` inside an item, `<style>` not at the first level, invalid holes, holes inside attributes, invalid `data-on-*` (including names that collide with DOM or ts-dom members), and markup that runs code or Trusted Types blocks: `<script>`, `on*` attributes, `srcdoc`, and `javascript:` URLs.
|
|
781
|
+
1. **codegen diagnostics** (`ts-dom gen` and the Vite plugin, with line and column numbers; on errors they abort and write no files): duplicate `data-ref`, `data-each` without a single root element, `data-each` not on a `<template>`, nested `data-each`, an incomplete or invalid `data-key` / `data-prop`, a `data-key` list whose item is not one custom element, `data-if` on an item root element, `<slot>` inside an item, `<style>` not at the first level, invalid holes, holes inside attributes, invalid `data-on-*` (including names that collide with DOM or ts-dom members), `<html>`, `<head>` and `<body>` inside a template, and markup that runs code or Trusted Types blocks: `<script>`, `on*` attributes, `srcdoc`, and `javascript:` URLs.
|
|
724
782
|
2. **tsc**: an error when TypeScript uses a name the HTML does not declare (`this.refs.nope`, `this.show("nope", true)`); all constraints between `state`, `props`, `derive`, `emits` and the HTML (see "Data Flow"); child component prop names and types; `define()` checks that the methods for `data-on-*` exist.
|
|
725
783
|
3. **Runtime**: `Component()` and components, at definition, connection, and use, throw errors that carry a `[rule id]` and a fix for the same problems.
|
|
726
784
|
4. **lint** (`ts-dom/unused-markup`, `ts-dom/data-flow`, `ts-dom/a11y`): markup declared in the HTML but never used (errors, with the HTML location), data flow suggestions and child component contracts (warnings), ARIA (warnings).
|
|
@@ -777,7 +835,7 @@ npx ts-dom lint --a11y [--strict]
|
|
|
777
835
|
| `heading-empty` | `h1`–`h6` must not be empty |
|
|
778
836
|
| `aria-role` | `role` must be a valid ARIA role |
|
|
779
837
|
| `aria-attr` | `aria-*` must be a valid ARIA attribute |
|
|
780
|
-
| `role-tabindex` | A non-
|
|
838
|
+
| `role-tabindex` | A non-natively-focusable element with an interactive role (button, tab, checkbox…) needs `tabindex` |
|
|
781
839
|
| `tabindex-positive` | Do not use a `tabindex` greater than 0 |
|
|
782
840
|
| `hidden-focusable` | A focusable element must not have `aria-hidden="true"` |
|
|
783
841
|
| `label-for` | `<label for>` must point to an `id` that exists in the template |
|
package/README.zh-TW.md
CHANGED
|
@@ -31,11 +31,34 @@ define("user-card", UserCard);
|
|
|
31
31
|
|
|
32
32
|
HTML 用了 `state` 沒有的名稱、`data-on-click` 指向不存在的方法,`tsc` 都會報錯。
|
|
33
33
|
|
|
34
|
+
## 精簡版
|
|
35
|
+
|
|
36
|
+
六件事涵蓋大多數元件:
|
|
37
|
+
|
|
38
|
+
1. **HTML 只放名稱**:`{{ name }}`(文字)、`data-ref="x"`(需要用到的元素)、`data-if="x"`(顯示與否)、`<template data-each="x">`(清單)、`data-on-click="method"`(事件)、`<slot>`。沒有表達式,需要計算就在 TypeScript 做。
|
|
39
|
+
2. **`.html` 有改動就先跑 `npx ts-dom gen` 再跑 `tsc`**:它會產生 `import tpl from "./x.html.js"` 需要的型別檔 `x.html.ts`;在那之前 `tsc` 只會告訴你找不到模組。
|
|
40
|
+
3. **資料放在類別裡**:`state`(自己的)、`props`(父元件給的)、`derive`(算出來的)。state 只用 `this.set()` 改;同名的 `{{ }}` 與 `data-if` 會自己更新。
|
|
41
|
+
4. **其他都寫在 `render(view, changed)`**:清單、屬性、焦點。元件組成的清單只要一次指派,`if (changed.has("todos")) this.lists.todos.items = view.todos`,並在 `<template data-each>` 上寫 `data-key` 與 `data-prop`。
|
|
42
|
+
5. **props 往下、事件往上**:父元件設定 props;子元件呼叫 `this.emit("name", detail)`,父元件在子元件的標籤上用 `data-on-name` 處理。
|
|
43
|
+
6. **處理方法是公開方法** `(event, hit)`;在清單項目內 `hit.key` 就是該項目的 key。
|
|
44
|
+
|
|
45
|
+
## 給 AI agent 的規則
|
|
46
|
+
|
|
47
|
+
把這些貼進專案的 agent 指引檔(`AGENTS.md`、`CLAUDE.md` 等):
|
|
48
|
+
|
|
49
|
+
- 元件的 `.html` 與 `.ts` 一起寫;不要編輯 `x.html.ts` 與 `ts-dom-tags.ts`,它們是 `ts-dom gen` 產生的。
|
|
50
|
+
- 改了任何 `.html` 之後,執行 `npx ts-dom gen && npx tsc`,然後把型別回報的每個錯誤(`state` 少了某個名稱、少了處理方法)修好,不要用型別斷言蓋掉。
|
|
51
|
+
- 範本一律以 `./x.html.js` 匯入。
|
|
52
|
+
- HTML 裡不放表達式:計算值放進 `derive`,state 只用 `this.set()` 改。
|
|
53
|
+
- `ts-dom gen` 與 `ts-dom lint` 的每則診斷結尾都有 `Fix:` 提示,照做。`npx ts-dom rules` 列出所有規則編號,`npx ts-dom lint --a11y` 另外檢查無障礙。
|
|
54
|
+
- `data-each` 清單不能巢狀:把內層清單放進自己的 `.html`,當成子元件。
|
|
55
|
+
- 元件組成的清單:在 `<template data-each>` 寫 `data-key` 與 `data-prop`,在 `<ul>` 或 `<ol>` 裡的項目元件加 `role="listitem"`,以 `this.lists.x.items = ...` 指派,變動的項目要給新物件(不要就地修改已指派的物件),並讓 `tsc` 檢查項目型別。
|
|
56
|
+
|
|
34
57
|
## 快速開始
|
|
35
58
|
|
|
36
59
|
```sh
|
|
37
60
|
npm i -D typescript && npm i @nativele/ts-dom
|
|
38
|
-
npx ts-dom gen # 為每個 x.html 產生 x.html.ts
|
|
61
|
+
npx ts-dom gen # 為每個 x.html 產生 x.html.ts(含型別);每次改了 .html 都要再跑一次
|
|
39
62
|
npx tsc # 型別檢查並編譯;不需要 Vite 或其他 bundler
|
|
40
63
|
```
|
|
41
64
|
|
|
@@ -299,7 +322,9 @@ export default defineConfig({
|
|
|
299
322
|
});
|
|
300
323
|
```
|
|
301
324
|
|
|
302
|
-
|
|
325
|
+
dev server 執行期間,外掛會讓產生檔保持同步:新增或編輯範本會重新產生它的 `x.html.ts`,若有頁面用到就重新載入一次(存檔但內容沒變不會重載);刪除範本會移除它的 `x.html.ts`;新增或移除 `define()` 會更新 `ts-dom-tags.ts` 與範本的子元件匯入。`dirs` 相對於 Vite 的 `root`。這裡是重新載入而不是熱替換元件,因為同一個自訂元素標籤只能定義一次,新的類別無法取代正在執行的那個。
|
|
326
|
+
|
|
327
|
+
安裝時建議先裝 `vite-plus`,再裝 `@nativele/ts-dom`(npm 同一次安裝時可能不會把 `vite` 提升到頂層)。
|
|
303
328
|
|
|
304
329
|
### Lint(選用)
|
|
305
330
|
|
|
@@ -354,7 +379,7 @@ export default template<{
|
|
|
354
379
|
}>([["h2", {}, ["Hello, {{ name }}!"]], /* ... */]);
|
|
355
380
|
```
|
|
356
381
|
|
|
357
|
-
|
|
382
|
+
整頁文件是頁面入口,不是範本,不會被處理。判斷依據是檔案內容:以 `<!doctype html>` 或 `<html>` 開頭(前面可以有註解)就是頁面,與檔名無關,所以 `index.html`、`main.html`、`login.html` 都可以。範本是元件的片段,因此其中的 `<html>`、`<head>`、`<body>` 會被拒絕(規則 `document-tag`):瀏覽器會把它們從片段中丟掉,放在上面的任何東西都不會有效果。想用別的方式讓檔案不被處理,可以用 `--exclude`。
|
|
358
383
|
|
|
359
384
|
## HTML 語法
|
|
360
385
|
|
|
@@ -658,18 +683,47 @@ const off = store.subscribe((s) => console.log(s)); // 回傳取消函式
|
|
|
658
683
|
```ts
|
|
659
684
|
import itemTpl from "./menu-item.html.js";
|
|
660
685
|
|
|
661
|
-
function menuItem(label: string):
|
|
686
|
+
function menuItem(label: string): HTMLLIElement {
|
|
662
687
|
const view = itemTpl.instantiate();
|
|
663
688
|
view.text.label = label;
|
|
664
689
|
view.refs.button.addEventListener("click", () => select(label));
|
|
665
|
-
return view.
|
|
690
|
+
return view.rootElement; // 型別由範本第一層的標籤決定
|
|
666
691
|
}
|
|
667
692
|
```
|
|
668
693
|
|
|
694
|
+
`rootElement` 是範本第一層唯一的元素(第一層的 `<style>` 不算);只有範本剛好一個根元素時型別上才有它,所以範本日後多出第二個根元素時,`tsc` 會在使用處報錯。
|
|
695
|
+
|
|
669
696
|
這種用法沒有宿主元素,所以 `data-on-*` 不會生效(請在 `refs` 上自行監聽),`<slot>` 也不會投影;需要這些時請用元件。
|
|
670
697
|
|
|
671
698
|
## 清單
|
|
672
699
|
|
|
700
|
+
填清單有兩種方式。項目有自己的東西(狀態、事件、內層清單)就用元件;只是幾個純元素組成的小項目就用 `replace()`。
|
|
701
|
+
|
|
702
|
+
### 項目是元件:`items = ...`
|
|
703
|
+
|
|
704
|
+
在 `<template data-each>` 上寫出識別項目的欄位名稱與接收項目的屬性名稱,項目是一個自訂元素:
|
|
705
|
+
|
|
706
|
+
```html
|
|
707
|
+
<ul>
|
|
708
|
+
<template data-each="todos" data-key="id" data-prop="todo">
|
|
709
|
+
<todo-row role="listitem" data-on-toggled="toggle"></todo-row>
|
|
710
|
+
</template>
|
|
711
|
+
</ul>
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
```ts
|
|
715
|
+
render(view, changed) {
|
|
716
|
+
if (changed.has("todos")) this.lists.todos.items = view.todos;
|
|
717
|
+
}
|
|
718
|
+
toggle(_e: Event, hit: EventHit) { /* hit.key 就是該項目的 id */ }
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
指派 `items` 時:key 還在的項目保留原本的元素(瀏覽器支援時用 `moveBefore` 搬動,所以焦點與狀態不會丟)、新 key 建立元素、消失的 key 移除,並對「與上次不是同一個物件」的項目設定元素的 `todo` 屬性。請用不可變更新:就地修改已指派過的物件欄位再指派同一個物件,項目元件不會更新,因為 props 以參考比較;請改指派新物件,例如 `{ ...todo, done: true }`。項目元件在自己的 `props` 宣告 `todo`,愛怎麼顯示都可以,用 `emit` 通知父元件,父元件在處理方法裡讀 `hit.key`。讀取 `items` 會回傳最近一次指派的內容。`tsc` 會檢查這個指派:每個項目要符合 `<todo-row>` 宣告的 `todo` 屬性型別並帶有 `data-key` 欄位(`id`),項目元件必須宣告 `data-prop` 指定的屬性;沒有 `data-key` 與 `data-prop` 的清單不接受指派。這要靠元件註冊在 `ts-dom-tags.ts`(掃描目錄內有匯出的 `define("todo-row", TodoRow)`)。元件定義在掃描目錄內、但類別沒有匯出,或 `ts-dom-tags.ts` 還沒列出它時,指派會是型別錯誤並說明原因(`itemComponentClassNotExported`、`itemComponentMissingFromTagMap`);只有掃描目錄以外的元件,才接受任何帶有 `id` 欄位的項目。執行期則在指派的不是物件陣列、項目沒有字串或數字的 `id`、兩個項目 key 重複、或範本沒有 `data-key` 與 `data-prop` 時丟錯。
|
|
722
|
+
|
|
723
|
+
在 `<ul>` 或 `<ol>` 裡,項目元件要加 `role="listitem"`,因為自訂元素不是 `<li>`;無障礙檢查(`list-structure`)會要求這點。`data-each` 刻意不能巢狀:一個項目就是一個元件,項目裡的清單就是該元件自己的 `data-each`。這樣每套範本都只有一層,有自己的 `refs`、`text` 與事件處理。
|
|
724
|
+
|
|
725
|
+
### 項目是純元素:`replace()`
|
|
726
|
+
|
|
673
727
|
```html
|
|
674
728
|
<ul>
|
|
675
729
|
<template data-each="tags">
|
|
@@ -706,6 +760,10 @@ this.lists.tags.replace(
|
|
|
706
760
|
- `data-each` 不能巢狀,要分成兩套模板:把內層清單放進自己的 `.html`(有自己 `data-each` 的子元件),在外層項目裡使用該元件的標籤。
|
|
707
761
|
- `<slot>` 不能放在項目內。
|
|
708
762
|
|
|
763
|
+
### 清單更新的順序
|
|
764
|
+
|
|
765
|
+
`replace()` 與 `upsert()` 會先把新項目的元素放進文件,再呼叫 `update`,所以放在項目內的子元件會在父元件設定屬性之前就已經連接。每列自己的狀態請等子元件第一次拿到資料時再建立(例如在屬性的 setter 裡),`connected()` 裡的渲染要先確認需要的資料已經存在。
|
|
766
|
+
|
|
709
767
|
## Trusted Types、翻譯與圖示
|
|
710
768
|
|
|
711
769
|
- Trusted Types:啟用 `require-trusted-types-for 'script'` 的頁面不需要設定任何 policy 就能用,因為 `ts-dom gen` 把標記寫成資料,執行期只用 DOM 呼叫建立。你自己的程式碼也要避免 `innerHTML`。
|
|
@@ -722,7 +780,7 @@ this.lists.tags.replace(
|
|
|
722
780
|
|
|
723
781
|
四道檢查,互補。**每一則訊息都有穩定的規則代號,並說明哪裡錯、為什麼、怎麼修**;`npx ts-dom rules` 列出全部規則。
|
|
724
782
|
|
|
725
|
-
1. **codegen 診斷**(`ts-dom gen` 與 Vite 外掛,帶行列號,有錯誤時中斷、不寫檔):重複的 `data-ref`、`data-each` 沒有單一根元素、`data-each` 不在 `<template>` 上、`data-each` 巢狀、`data-if` 在項目根元素、`<slot>` 在項目內、`<style>` 不在第一層、不合法的洞、屬性內的洞、不合法的 `data-on-*`(含與 DOM 或 ts-dom
|
|
783
|
+
1. **codegen 診斷**(`ts-dom gen` 與 Vite 外掛,帶行列號,有錯誤時中斷、不寫檔):重複的 `data-ref`、`data-each` 沒有單一根元素、`data-each` 不在 `<template>` 上、`data-each` 巢狀、`data-key` 與 `data-prop` 不完整或不合法、有 `data-key` 的清單其項目不是單一自訂元素、`data-if` 在項目根元素、`<slot>` 在項目內、`<style>` 不在第一層、不合法的洞、屬性內的洞、不合法的 `data-on-*`(含與 DOM 或 ts-dom 成員同名),範本內的 `<html>`、`<head>`、`<body>`,以及會執行程式碼或被 Trusted Types 擋下的標記:`<script>`、`on*` 屬性、`srcdoc`、`javascript:` 網址。
|
|
726
784
|
2. **tsc**:TypeScript 使用了 HTML 沒有宣告的名稱就報錯(`this.refs.nope`、`this.show("nope", true)`);`state`、`props`、`derive`、`emits` 與 HTML 之間的所有約束(見「資料流」);子元件 prop 的名稱與型別;`define()` 檢查 `data-on-*` 的方法存在。
|
|
727
785
|
3. **執行期**:`Component()` 與元件在定義、連接與使用時,對同樣的問題丟出帶 `[規則代號]` 與修正方式的錯誤。
|
|
728
786
|
4. **lint**(`ts-dom/unused-markup`、`ts-dom/data-flow`、`ts-dom/a11y`):HTML 宣告了卻沒被使用的標記(錯誤,附 HTML 位置)、資料流建議與子元件契約(警告)、ARIA(警告)。
|
|
@@ -21,12 +21,27 @@ const CODEGEN_RULES = {
|
|
|
21
21
|
"empty-each": "data-each must not be empty",
|
|
22
22
|
"nested-each": "data-each templates cannot be nested",
|
|
23
23
|
"duplicate-each": "data-each names must be unique",
|
|
24
|
+
"each-binding-incomplete": "data-key and data-prop go together on a data-each template",
|
|
25
|
+
"invalid-each-binding": "data-key and data-prop must each be a single identifier",
|
|
26
|
+
"each-binding-root": "with data-key and data-prop the item must be one custom element",
|
|
24
27
|
"each-root-count": "a data-each template needs exactly one root element",
|
|
28
|
+
"document-tag": "<html>, <head> and <body> cannot be in a template (the browser drops them from fragments)",
|
|
25
29
|
"forbidden-tag": "<script> cannot be in a template (it would run code)",
|
|
26
30
|
"forbidden-attribute": "on* event attributes and srcdoc cannot be in a template (they run code or are blocked by Trusted Types)",
|
|
27
31
|
"unsafe-url": "href / src / action / formaction cannot start with javascript:",
|
|
28
32
|
"duplicate-define": "a custom element tag can be defined only once per scanned directory"
|
|
29
33
|
};
|
|
34
|
+
const RESERVED_ELEMENT_NAMES = /* @__PURE__ */ new Set([
|
|
35
|
+
"annotation-xml",
|
|
36
|
+
"color-profile",
|
|
37
|
+
"font-face",
|
|
38
|
+
"font-face-src",
|
|
39
|
+
"font-face-uri",
|
|
40
|
+
"font-face-format",
|
|
41
|
+
"font-face-name",
|
|
42
|
+
"missing-glyph"
|
|
43
|
+
]);
|
|
44
|
+
const isCustomElementName = (tag) => /^[a-z][a-z0-9._]*-[a-z0-9._-]*$/.test(tag) && !RESERVED_ELEMENT_NAMES.has(tag);
|
|
30
45
|
const URL_ATTRS = /^(href|src|action|formaction|xlink:href)$/i;
|
|
31
46
|
const HTML_NS = "http://www.w3.org/1999/xhtml";
|
|
32
47
|
const newScope = () => ({
|
|
@@ -51,6 +66,7 @@ function analyze(html) {
|
|
|
51
66
|
scope: newScope(),
|
|
52
67
|
lists: /* @__PURE__ */ new Map(),
|
|
53
68
|
slots: /* @__PURE__ */ new Set(),
|
|
69
|
+
boundLists: /* @__PURE__ */ new Map(),
|
|
54
70
|
events: /* @__PURE__ */ new Set(),
|
|
55
71
|
eventSites: [],
|
|
56
72
|
customTags: /* @__PURE__ */ new Set(),
|
|
@@ -66,6 +82,17 @@ function analyze(html) {
|
|
|
66
82
|
...locOf(n)
|
|
67
83
|
});
|
|
68
84
|
};
|
|
85
|
+
const blanked = html.replace(/<!--[\s\S]*?-->/g, (c) => c.replace(/[^\n]/g, " "));
|
|
86
|
+
for (const m of blanked.matchAll(/<(html|head|body)(?=[\s/>])/gi)) {
|
|
87
|
+
const before = blanked.slice(0, m.index);
|
|
88
|
+
a.diagnostics.push({
|
|
89
|
+
rule: "document-tag",
|
|
90
|
+
severity: "error",
|
|
91
|
+
message: `<${m[1]?.toLowerCase()}> cannot be in a template: a template is a fragment of a component, and the browser drops <html>, <head> and <body> from fragments, so nothing you put on them has any effect. Fix: remove the tag and keep its content. If this file is a whole page, start it with <!doctype html> and it is treated as a page entry and skipped (or leave it out with --exclude).`,
|
|
92
|
+
line: before.split("\n").length,
|
|
93
|
+
col: m.index - before.lastIndexOf("\n")
|
|
94
|
+
});
|
|
95
|
+
}
|
|
69
96
|
const mark = (key, n) => {
|
|
70
97
|
if (!a.locs.has(key)) a.locs.set(key, locOf(n));
|
|
71
98
|
};
|
|
@@ -157,6 +184,21 @@ function analyze(html) {
|
|
|
157
184
|
else {
|
|
158
185
|
const items = n.content.childNodes.filter((c) => isEl$1(c) || c.nodeName === "#text" && c.value.trim());
|
|
159
186
|
if (items.length !== 1 || !isEl$1(items[0])) report("each-root-count", "error", `data-each="${each}" must contain exactly one root element (found ${items.length} root nodes). Fix: wrap the item content in a single element such as <li>.`, n);
|
|
187
|
+
const key = attr$1(n, "data-key");
|
|
188
|
+
const prop = attr$1(n, "data-prop");
|
|
189
|
+
if (key === void 0 !== (prop === void 0)) report("each-binding-incomplete", "error", `data-each="${each}" has ${key === void 0 ? "data-prop" : "data-key"} but not ${key === void 0 ? "data-key" : "data-prop"}. Fix: write both, e.g. <template data-each="${each}" data-key="id" data-prop="item">, or neither to fill the items yourself with replace().`, n);
|
|
190
|
+
else if (key !== void 0 && prop !== void 0) {
|
|
191
|
+
if (!IDENT.test(key.trim()) || !IDENT.test(prop.trim())) report("invalid-each-binding", "error", `data-key="${key}" and data-prop="${prop}" must each be a single identifier: the item field that identifies it, and the property of the item element that receives the item. Fix: write data-key="id" data-prop="item".`, n);
|
|
192
|
+
else if (items.length === 1 && isEl$1(items[0])) {
|
|
193
|
+
const root = items[0];
|
|
194
|
+
if (root.namespaceURI === HTML_NS && isCustomElementName(root.tagName)) a.boundLists.set(each, {
|
|
195
|
+
tag: root.tagName,
|
|
196
|
+
prop: prop.trim(),
|
|
197
|
+
key: key.trim()
|
|
198
|
+
});
|
|
199
|
+
else report("each-binding-root", "error", `data-each="${each}" has data-key and data-prop, so each item is one component that receives the item as a property, but its root is <${root.tagName}>. Fix: make the item a custom element such as <${each}-row></${each}-row> and put the item's markup in that component's own .html.`, n);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
160
202
|
const s = newScope();
|
|
161
203
|
a.lists.set(each, s);
|
|
162
204
|
mark(`each:${each}`, n);
|
|
@@ -176,6 +218,15 @@ const union = (xs) => {
|
|
|
176
218
|
return arr.length ? arr.map(q).join(" | ") : "never";
|
|
177
219
|
};
|
|
178
220
|
const obj = (m) => `{ ${[...m].map(([n, t]) => `${q(n)}: ${t}`).join("; ")} }`;
|
|
221
|
+
/** The type of the template's only top-level element (a `<style>` does not count); `never` when there is not exactly one, or other text sits beside it. */
|
|
222
|
+
function rootElementType(html) {
|
|
223
|
+
const tops = parseFragment(html).childNodes.filter((n) => n.nodeName !== "#comment" && !(n.nodeName === "#text" && !n.value.trim()));
|
|
224
|
+
const elements = tops.filter((n) => isEl$1(n) && !(n.tagName === "style" && n.namespaceURI === HTML_NS) && !(n.tagName === "template" && n.attrs.some((a) => a.name === "data-each")));
|
|
225
|
+
const others = tops.filter((n) => !(isEl$1(n) && (n.tagName === "style" || n.tagName === "template")));
|
|
226
|
+
if (elements.length !== 1 || others.length !== 1) return "never";
|
|
227
|
+
const el = elements[0];
|
|
228
|
+
return el.namespaceURI === HTML_NS ? `El<${JSON.stringify(el.tagName)}>` : "SVGElement";
|
|
229
|
+
}
|
|
179
230
|
function globToRegExp(glob) {
|
|
180
231
|
let re = "";
|
|
181
232
|
for (let i = 0; i < glob.length; i++) {
|
|
@@ -231,15 +282,22 @@ function toNodes(parent, collapse = false) {
|
|
|
231
282
|
* tsc (or any bundler) can compile it directly: no plugin and no allowArbitraryExtensions needed.
|
|
232
283
|
* Consumers import it as `import tpl from "./x.html.js"` (TypeScript maps it to x.html.ts).
|
|
233
284
|
*/
|
|
234
|
-
function generateModule(html, options = {}, imports = []) {
|
|
285
|
+
function generateModule(html, options = {}, imports = [], defs = []) {
|
|
235
286
|
const runtime = options.runtime ?? "@nativele/ts-dom";
|
|
236
287
|
const a = analyze(html);
|
|
237
|
-
const lists = [...a.lists].map(([n, s]) =>
|
|
288
|
+
const lists = [...a.lists].map(([n, s]) => {
|
|
289
|
+
const bound = a.boundLists.get(n);
|
|
290
|
+
const def = bound && defs.find((d) => d.tag === bound.tag);
|
|
291
|
+
const source = !def ? "external" : def.exported ? "local" : "unexported";
|
|
292
|
+
const item = bound ? `; item: { tag: ${q(bound.tag)}; prop: ${q(bound.prop)}; key: ${q(bound.key)}; source: ${q(source)} }` : "";
|
|
293
|
+
return `${q(n)}: { refs: ${obj(s.refs)}; text: ${union(s.text)}; ifs: ${union(s.ifs)}${item} }`;
|
|
294
|
+
}).join("; ");
|
|
238
295
|
const type = `{
|
|
239
296
|
refs: ${obj(a.scope.refs)};
|
|
240
297
|
lists: { ${lists} };
|
|
241
298
|
ifs: ${union(a.scope.ifs)};
|
|
242
299
|
slots: ${union(a.slots)};
|
|
300
|
+
rootElement: ${rootElementType(html)};
|
|
243
301
|
text: ${union(a.scope.text)};
|
|
244
302
|
events: ${union(a.events)};
|
|
245
303
|
}`;
|
|
@@ -276,7 +334,7 @@ function generateFile(htmlPath, options = {}, defs) {
|
|
|
276
334
|
const here = dirname(htmlPath);
|
|
277
335
|
const imports = [...new Set(known.filter((d) => analysis.customTags.has(d.tag)).map((d) => relImport(here, d.file)))].sort();
|
|
278
336
|
const out = modulePathFor(htmlPath);
|
|
279
|
-
const next = generateModule(html, options, imports);
|
|
337
|
+
const next = generateModule(html, options, imports, known);
|
|
280
338
|
let prev = "";
|
|
281
339
|
try {
|
|
282
340
|
prev = readFileSync(out, "utf8");
|
|
@@ -476,10 +534,10 @@ const A11Y_RULES = {
|
|
|
476
534
|
"aria-idref": "This ARIA attribute refers to an id that does not exist in this template. Fix: add the id, or ignore this rule if it refers to an element outside the component.",
|
|
477
535
|
"heading-order": "Heading levels are skipped. Fix: do not jump levels (for example h1 straight to h3); use the next level down.",
|
|
478
536
|
"table-header": "<table> has no <th> header cells. Fix: add <th> cells, or set role=\"presentation\" if the table is only used for layout.",
|
|
479
|
-
"list-structure": "Invalid list structure. Fix: <ul>/<ol> may only contain <li
|
|
537
|
+
"list-structure": "Invalid list structure. Fix: <ul>/<ol> may only contain <li> (or a custom element with role=\"listitem\"), and <li> must be inside <ul>, <ol> or <menu>.",
|
|
480
538
|
"landmark-label": "There are several landmarks of the same kind. Fix: give each <nav> / <aside> a distinct aria-label.",
|
|
481
539
|
"redundant-role": "The role repeats the element's native role. Fix: remove the role attribute.",
|
|
482
|
-
"click-semantics": "data-on-click is on an element that is not
|
|
540
|
+
"click-semantics": "data-on-click is on an element that is not natively operable, so keyboard and assistive technology users cannot use it. Fix: use <button> or <a href>, or add an interactive role plus tabindex and keyboard handling."
|
|
483
541
|
};
|
|
484
542
|
const ROLES = new Set(`alert alertdialog application article banner blockquote button caption cell checkbox code columnheader combobox complementary contentinfo definition deletion dialog directory document emphasis feed figure form generic grid gridcell group heading img insertion link list listbox listitem log main mark marquee math menu menubar menuitem menuitemcheckbox menuitemradio meter navigation none note option paragraph presentation progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider spinbutton status strong subscript superscript switch tab table tablist tabpanel term textbox time timer toolbar tooltip tree treegrid treeitem`.split(" "));
|
|
485
543
|
const ARIA_ATTRS = new Set(`activedescendant atomic autocomplete braillelabel brailleroledescription busy checked colcount colindex colindextext colspan controls current describedby description details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount rowindex rowindextext rowspan selected setsize sort valuemax valuemin valuenow valuetext`.split(" ").map((n) => `aria-${n}`));
|
|
@@ -607,10 +665,10 @@ function checkA11y(html, ignore = []) {
|
|
|
607
665
|
if (role !== void 0 && !ROLES.has(role) && !role.startsWith("doc-") && !role.startsWith("graphics-")) report("aria-role", el, `role="${role}" is not a valid ARIA role. Fix: check the spelling against the WAI-ARIA roles.`);
|
|
608
666
|
for (const a of el.attrs) if (a.name.startsWith("aria-") && !ARIA_ATTRS.has(a.name)) report("aria-attr", el, `${a.name} is not a valid ARIA attribute. Fix: check the spelling against the WAI-ARIA attributes.`);
|
|
609
667
|
const tabindex = attr(el, "tabindex");
|
|
610
|
-
if (role && INTERACTIVE_ROLES.has(role) && !focusable(el) && tabindex === void 0 && !managed) report("role-tabindex", el, `<${tag} role="${role}"> is not
|
|
668
|
+
if (role && INTERACTIVE_ROLES.has(role) && !focusable(el) && tabindex === void 0 && !managed) report("role-tabindex", el, `<${tag} role="${role}"> is not natively focusable. Fix: add tabindex="0" and handle keyboard events, or use a native element such as <button>.`);
|
|
611
669
|
if (tabindex !== void 0 && Number(tabindex) > 0) report("tabindex-positive", el, `tabindex="${tabindex}" disturbs the natural focus order. Fix: use tabindex="0" or "-1".`);
|
|
612
670
|
if (attr(el, "aria-hidden") === "true" && (focusable(el) || tabindex !== void 0 && Number(tabindex) >= 0)) report("hidden-focusable", el);
|
|
613
|
-
if (has(el, "data-on-click") && !focusable(el) && tag !== "label" && !(role && INTERACTIVE_ROLES.has(role) && tabindex !== void 0)) report("click-semantics", el, `data-on-click is on <${tag}>, which is not
|
|
671
|
+
if (has(el, "data-on-click") && !focusable(el) && tag !== "label" && !(role && INTERACTIVE_ROLES.has(role) && tabindex !== void 0)) report("click-semantics", el, `data-on-click is on <${tag}>, which is not natively operable, so keyboard and assistive technology users cannot use it. Fix: use <button> or <a href>, or add an interactive role plus tabindex and keyboard handling.`);
|
|
614
672
|
if (/^h[1-6]$/.test(tag)) {
|
|
615
673
|
const level = Number(tag[1]);
|
|
616
674
|
if (lastHeading && level > lastHeading + 1) report("heading-order", el, `<${tag}> skips a heading level (the previous heading is h${lastHeading}). Fix: use h${lastHeading + 1} here, or restructure the headings.`);
|
|
@@ -621,8 +679,16 @@ function checkA11y(html, ignore = []) {
|
|
|
621
679
|
if (!isEl(c) || c.tagName === "slot" || c.tagName === "script") continue;
|
|
622
680
|
if (c.tagName === "template") {
|
|
623
681
|
if (!has(c, "data-each")) continue;
|
|
624
|
-
for (const item of c.content.childNodes) if (isEl(item)
|
|
625
|
-
} else
|
|
682
|
+
for (const item of c.content.childNodes) if (isEl(item)) listItemProblem(item, tag, "The root element of a data-each item");
|
|
683
|
+
} else listItemProblem(c, tag, `A child of <${tag}>`);
|
|
684
|
+
}
|
|
685
|
+
function listItemProblem(item, listTag, what) {
|
|
686
|
+
if (item.tagName === "li") return;
|
|
687
|
+
if (item.tagName.includes("-")) {
|
|
688
|
+
if (attr(item, "role") !== "listitem") report("list-structure", item, `${what} inside <${listTag}> is the component <${item.tagName}>, which has no list item role. Fix: add role="listitem" to <${item.tagName}>, or make its root an <li>.`);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
report("list-structure", item, `${what} inside <${listTag}> must be <li> or a custom element with role="listitem", found <${item.tagName}>. Fix: use <li> as the item root.`);
|
|
626
692
|
}
|
|
627
693
|
if (tag === "li" && parent && ![
|
|
628
694
|
"ul",
|
package/dist/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { d as listHtml, i as CodegenError, n as checkA11y, p as watchAll, r as CODEGEN_RULES, s as generateAll, t as A11Y_RULES } from "./a11y-
|
|
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 { d as listHtml, i as CodegenError, n as checkA11y, p as watchAll, r as CODEGEN_RULES, s as generateAll, t as A11Y_RULES } from "./a11y-Bn9pDvWV.mjs";
|
|
3
|
+
import { c as lintUsage, n as UNUSED_MARKUP_RULES, o as formatIssue, s as lintDataFlow, t as DATA_FLOW_RULES } from "./lint-CqWk4U5P.mjs";
|
|
4
4
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
5
5
|
import { join, relative } from "node:path";
|
|
6
6
|
// src/cli.ts
|
package/dist/index.d.mts
CHANGED
|
@@ -3,7 +3,48 @@ interface ListSchema {
|
|
|
3
3
|
refs: Record<string, Element>;
|
|
4
4
|
text: string;
|
|
5
5
|
ifs: string;
|
|
6
|
+
/**
|
|
7
|
+
* Set for a list whose items are components (`data-key` and `data-prop`): the item tag, the property that receives the item, the field that identifies it,
|
|
8
|
+
* and where codegen found the component: defined and exported in a scanned directory, defined there without export, or not found (a component from elsewhere).
|
|
9
|
+
*/
|
|
10
|
+
item?: {
|
|
11
|
+
tag: string;
|
|
12
|
+
prop: string;
|
|
13
|
+
key: string;
|
|
14
|
+
source?: "local" | "unexported" | "external";
|
|
15
|
+
};
|
|
6
16
|
}
|
|
17
|
+
type ItemOf<L> = L extends {
|
|
18
|
+
item: {
|
|
19
|
+
tag: infer T;
|
|
20
|
+
prop: infer P;
|
|
21
|
+
};
|
|
22
|
+
} ? T extends keyof HTMLElementTagNameMap ? P extends keyof HTMLElementTagNameMap[T] ? NonNullable<HTMLElementTagNameMap[T][P]> : {
|
|
23
|
+
dataPropNotOnItemComponent: P;
|
|
24
|
+
} : L extends {
|
|
25
|
+
item: {
|
|
26
|
+
source: "local";
|
|
27
|
+
};
|
|
28
|
+
} ? {
|
|
29
|
+
itemComponentMissingFromTagMap: T;
|
|
30
|
+
fix: "run ts-dom gen so ts-dom-tags.ts lists it";
|
|
31
|
+
} : L extends {
|
|
32
|
+
item: {
|
|
33
|
+
source: "unexported";
|
|
34
|
+
};
|
|
35
|
+
} ? {
|
|
36
|
+
itemComponentClassNotExported: T;
|
|
37
|
+
fix: "export the class passed to define() so its type can be read";
|
|
38
|
+
} : {
|
|
39
|
+
[field: string]: unknown;
|
|
40
|
+
} : never;
|
|
41
|
+
type KeyField<L> = L extends {
|
|
42
|
+
item: {
|
|
43
|
+
key: infer K extends string;
|
|
44
|
+
};
|
|
45
|
+
} ? { [Q in K]: string | number; } : unknown;
|
|
46
|
+
/** What `items` takes for a list: the type of the item component's `data-prop` property, with the `data-key` field. */
|
|
47
|
+
type ListItem<L> = ItemOf<L> & KeyField<L>;
|
|
7
48
|
type TextOf<N extends string> = { [K in N]: TextValue; };
|
|
8
49
|
/** The per-item context passed to update(). */
|
|
9
50
|
interface ItemContext<L extends ListSchema> {
|
|
@@ -31,9 +72,19 @@ export declare class List<L extends ListSchema = ListSchema> {
|
|
|
31
72
|
private readonly proto;
|
|
32
73
|
/** Called when a new item is created (the component uses it to register data-on-* inside the item) */
|
|
33
74
|
private readonly onCreate?;
|
|
75
|
+
private readonly binding?;
|
|
34
76
|
constructor(anchor: Comment, proto: Element,
|
|
35
77
|
/** Called when a new item is created (the component uses it to register data-on-* inside the item) */
|
|
36
|
-
onCreate?: ((el: Element) => void) | undefined
|
|
78
|
+
onCreate?: ((el: Element) => void) | undefined, binding?: {
|
|
79
|
+
readonly key: string;
|
|
80
|
+
readonly prop: string;
|
|
81
|
+
} | undefined);
|
|
82
|
+
/**
|
|
83
|
+
* The items shown, in order. Assigning shows them: an element is kept for each key that stays, created for a new key and removed for a key that is gone, and the `data-prop` property receives each item that is not the same object as last time.
|
|
84
|
+
* @throws When the template has no `data-key` and `data-prop`, when the value is not an array of objects, when an item has no string or number `data-key` field, or when two items have the same key
|
|
85
|
+
*/
|
|
86
|
+
get items(): readonly ListItem<L>[];
|
|
87
|
+
set items(items: readonly ListItem<L>[]);
|
|
37
88
|
/** From any node inside an item, find the key and data of the item it belongs to (used by event handlers). */
|
|
38
89
|
itemOf(node: Node | null): {
|
|
39
90
|
key: PropertyKey;
|
|
@@ -68,6 +119,8 @@ interface Schema {
|
|
|
68
119
|
events: string;
|
|
69
120
|
ifs: string;
|
|
70
121
|
slots: string;
|
|
122
|
+
/** The template's only top-level element (a `<style>` does not count), or `never` when there is not exactly one. */
|
|
123
|
+
rootElement: Element;
|
|
71
124
|
}
|
|
72
125
|
/**
|
|
73
126
|
* A template's parsed markup, written by codegen: a string is a text node, `["#comment", text]` a comment,
|
|
@@ -75,7 +128,10 @@ interface Schema {
|
|
|
75
128
|
*/
|
|
76
129
|
type TemplateNode = string | readonly [tag: string, attrs: Readonly<Record<string, string>>, children: readonly TemplateNode[], ns?: string] | readonly ["#comment", string];
|
|
77
130
|
/** A template built without a component: the nodes, and the typed handles to the places the template names. */
|
|
78
|
-
|
|
131
|
+
type TemplateInstance<S extends Schema> = TemplateInstanceBase<S> & ([S["rootElement"]] extends [never] ? unknown : {
|
|
132
|
+
readonly rootElement: S["rootElement"];
|
|
133
|
+
});
|
|
134
|
+
interface TemplateInstanceBase<S extends Schema> {
|
|
79
135
|
/** The built nodes; insert it (or its children) where it should show. */
|
|
80
136
|
readonly root: DocumentFragment;
|
|
81
137
|
/** The elements marked with `data-ref`. */
|
|
@@ -239,4 +295,4 @@ type CheckHandlers<I> = I extends {
|
|
|
239
295
|
* component as a public method, otherwise tsc reports an error that lists the missing names (`missingHandlers`).
|
|
240
296
|
*/
|
|
241
297
|
export declare function define<C extends abstract new (...args: never[]) => HTMLElement>(name: string, ctor: C & CheckHandlers<InstanceType<C>>): void;
|
|
242
|
-
export type { ComponentInstance, ComponentOptions, DataOptions, El, EmitEvent, EventDecl, EventHit, ItemContext, ListSchema, ReadableStore, ReplaceOptions, Schema, StatefulInstance, Store, Template, TemplateInstance, TextOf, TextValue, Update, View };
|
|
298
|
+
export type { ComponentInstance, ComponentOptions, DataOptions, El, EmitEvent, EventDecl, EventHit, ItemContext, ListItem, ListSchema, ReadableStore, ReplaceOptions, Schema, StatefulInstance, Store, Template, TemplateInstance, TextOf, TextValue, Update, View };
|
package/dist/index.mjs
CHANGED
|
@@ -208,13 +208,44 @@ var List = class {
|
|
|
208
208
|
anchor;
|
|
209
209
|
proto;
|
|
210
210
|
onCreate;
|
|
211
|
+
binding;
|
|
211
212
|
#items = /* @__PURE__ */ new Map();
|
|
212
213
|
#byEl = /* @__PURE__ */ new WeakMap();
|
|
213
|
-
constructor(anchor, proto, onCreate) {
|
|
214
|
+
constructor(anchor, proto, onCreate, binding) {
|
|
214
215
|
this.anchor = anchor;
|
|
215
216
|
this.proto = proto;
|
|
216
217
|
this.onCreate = onCreate;
|
|
218
|
+
this.binding = binding;
|
|
217
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* The items shown, in order. Assigning shows them: an element is kept for each key that stays, created for a new key and removed for a key that is gone, and the `data-prop` property receives each item that is not the same object as last time.
|
|
222
|
+
* @throws When the template has no `data-key` and `data-prop`, when the value is not an array of objects, when an item has no string or number `data-key` field, or when two items have the same key
|
|
223
|
+
*/
|
|
224
|
+
get items() {
|
|
225
|
+
return [...this.#items.values()].sort((a, b) => a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1).map((entry) => entry.item);
|
|
226
|
+
}
|
|
227
|
+
set items(items) {
|
|
228
|
+
if (!this.binding) throw new Error("[list-not-bound] this list has no data-key and data-prop, so its items cannot be assigned. Fix: add them to the <template data-each>, e.g. data-key=\"id\" data-prop=\"item\", or call replace().");
|
|
229
|
+
if (!Array.isArray(items)) throw new Error(`[invalid-items] items must be an array of objects, but got ${items === null ? "null" : typeof items}. Fix: assign an array, for example items = view.todos ?? [].`);
|
|
230
|
+
if (items.length) this.#checkProp();
|
|
231
|
+
this.replace(items, this.#keyOf, this.#assign, { skipUnchanged: true });
|
|
232
|
+
}
|
|
233
|
+
#checkProp() {
|
|
234
|
+
const prop = this.binding?.prop;
|
|
235
|
+
const tag = this.proto.localName;
|
|
236
|
+
if (!customElements.get(tag)) return;
|
|
237
|
+
if (!(prop in (this.#items.values().next().value?.el ?? document.importNode(this.proto, false)))) throw new Error(`[unknown-prop] <${tag}> has no "${prop}" property (data-prop). Fix: declare ${prop} in the component's props, or change data-prop.`);
|
|
238
|
+
}
|
|
239
|
+
#keyOf = (item) => {
|
|
240
|
+
const field = this.binding?.key;
|
|
241
|
+
if (typeof item !== "object" || item === null) throw new Error(`[invalid-item] an item is ${item === null ? "null" : typeof item}, but items must be objects with a "${field}" field (data-key). Fix: pass objects, or fill a list of plain values with replace().`);
|
|
242
|
+
const key = item[field];
|
|
243
|
+
if (typeof key !== "string" && typeof key !== "number") throw new Error(`[missing-key] an item has no usable "${field}" field (data-key): it is ${key === void 0 ? "missing" : typeof key}. Fix: give every item a string or number ${field}, or change data-key.`);
|
|
244
|
+
return key;
|
|
245
|
+
};
|
|
246
|
+
#assign = (item, { el }) => {
|
|
247
|
+
el[this.binding?.prop] = item;
|
|
248
|
+
};
|
|
218
249
|
/** From any node inside an item, find the key and data of the item it belongs to (used by event handlers). */
|
|
219
250
|
itemOf(node) {
|
|
220
251
|
for (let n = node; n; n = n.parentNode) {
|
|
@@ -272,13 +303,13 @@ var List = class {
|
|
|
272
303
|
}
|
|
273
304
|
#run(entry, key, item, update, skip = false) {
|
|
274
305
|
if (skip && !entry.fresh && Object.is(entry.item, item)) return;
|
|
275
|
-
entry.item = item;
|
|
276
|
-
entry.fresh = false;
|
|
277
306
|
this.#byEl.set(entry.el, {
|
|
278
307
|
key,
|
|
279
308
|
item
|
|
280
309
|
});
|
|
281
310
|
update(item, entry);
|
|
311
|
+
entry.item = item;
|
|
312
|
+
entry.fresh = false;
|
|
282
313
|
}
|
|
283
314
|
remove(key) {
|
|
284
315
|
const entry = this.#items.get(key);
|
|
@@ -296,7 +327,7 @@ var List = class {
|
|
|
296
327
|
const next = /* @__PURE__ */ new Set();
|
|
297
328
|
for (const it of items) {
|
|
298
329
|
const k = key(it);
|
|
299
|
-
if (next.has(k)) throw new Error(`[duplicate-key]
|
|
330
|
+
if (next.has(k)) throw new Error(`[duplicate-key] two items have the key ${String(k)}, so one element cannot show both. Fix: make the key unique for each item (the key() you pass to replace(), or the data-key field).`);
|
|
300
331
|
next.add(k);
|
|
301
332
|
}
|
|
302
333
|
for (const k of this.#items.keys()) if (!next.has(k)) this.remove(k);
|
|
@@ -332,7 +363,12 @@ function mountTemplate(frag, onItem) {
|
|
|
332
363
|
if (!proto) throw new Error(`[each-root-count] data-each="${name}" needs exactly one root element. Fix: wrap the item content in a single element such as <li>.`);
|
|
333
364
|
const anchor = document.createComment(`each:${name}`);
|
|
334
365
|
t.replaceWith(anchor);
|
|
335
|
-
|
|
366
|
+
const key = t.getAttribute("data-key")?.trim();
|
|
367
|
+
const prop = t.getAttribute("data-prop")?.trim();
|
|
368
|
+
lists[name] = new List(anchor, proto, onItem, key && prop ? {
|
|
369
|
+
key,
|
|
370
|
+
prop
|
|
371
|
+
} : void 0);
|
|
336
372
|
}
|
|
337
373
|
const refs = {};
|
|
338
374
|
for (const el of frag.querySelectorAll("[data-ref]")) refs[el.getAttribute("data-ref")] = el;
|
|
@@ -357,7 +393,33 @@ const NON_BUBBLING = /* @__PURE__ */ new Set([
|
|
|
357
393
|
"load",
|
|
358
394
|
"error",
|
|
359
395
|
"scroll",
|
|
360
|
-
"
|
|
396
|
+
"scrollend",
|
|
397
|
+
"toggle",
|
|
398
|
+
"beforetoggle",
|
|
399
|
+
"cancel",
|
|
400
|
+
"close",
|
|
401
|
+
"invalid",
|
|
402
|
+
"abort",
|
|
403
|
+
"canplay",
|
|
404
|
+
"canplaythrough",
|
|
405
|
+
"durationchange",
|
|
406
|
+
"emptied",
|
|
407
|
+
"ended",
|
|
408
|
+
"loadeddata",
|
|
409
|
+
"loadedmetadata",
|
|
410
|
+
"loadstart",
|
|
411
|
+
"pause",
|
|
412
|
+
"play",
|
|
413
|
+
"playing",
|
|
414
|
+
"progress",
|
|
415
|
+
"ratechange",
|
|
416
|
+
"seeked",
|
|
417
|
+
"seeking",
|
|
418
|
+
"stalled",
|
|
419
|
+
"suspend",
|
|
420
|
+
"timeupdate",
|
|
421
|
+
"volumechange",
|
|
422
|
+
"waiting"
|
|
361
423
|
]);
|
|
362
424
|
function Component(tpl, opts = {}) {
|
|
363
425
|
const shadow = opts.shadow === true;
|
|
@@ -698,10 +760,13 @@ function template(nodes) {
|
|
|
698
760
|
build: () => buildNodes(nodes),
|
|
699
761
|
instantiate() {
|
|
700
762
|
const root = buildNodes(nodes);
|
|
763
|
+
const tops = [...root.children].filter((el) => el.localName !== "style" && !(el instanceof HTMLTemplateElement && el.hasAttribute("data-each")));
|
|
764
|
+
const rootElement = tops.length === 1 ? tops[0] : void 0;
|
|
701
765
|
const { refs, lists, text, conds } = mountTemplate(root);
|
|
702
766
|
conds.hideAll();
|
|
703
767
|
return {
|
|
704
768
|
root,
|
|
769
|
+
rootElement,
|
|
705
770
|
refs,
|
|
706
771
|
lists,
|
|
707
772
|
text,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as analyze, d as listHtml, f as scanDefines } from "./a11y-
|
|
1
|
+
import { a as analyze, d as listHtml, f as scanDefines } from "./a11y-Bn9pDvWV.mjs";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, relative } from "node:path";
|
|
4
4
|
// src/lib/lint.ts
|
|
@@ -98,7 +98,7 @@ function unusedMarkup(html, code) {
|
|
|
98
98
|
for (const n of dynText || stateful ? [] : a.scope.text) if (!used(`text\\.${esc(n)}\\b|text\\[\\s*["']${esc(n)}["']`)) add("unused-text", `{{ ${n} }} is never filled and will render empty. Fix: assign this.text.${n} = value, or declare "${n}" in state/props/derive so it binds automatically.`, `text:${n}`);
|
|
99
99
|
for (const [list, s] of a.lists) {
|
|
100
100
|
if (!dynLists && !used(`lists\\.${esc(list)}\\b|lists\\[\\s*["']${esc(list)}["']`)) {
|
|
101
|
-
add("unused-list", `data-each="${list}" is never rendered, so the list stays empty. Fix: call this.lists.${list}.replace(items, (item) => item.id, (item, ctx) => { ... }), for example inside render().`, `each:${list}`);
|
|
101
|
+
add("unused-list", `data-each="${list}" is never rendered, so the list stays empty. Fix: ${a.boundLists.has(list) ? `assign this.lists.${list}.items = items` : `call this.lists.${list}.replace(items, (item) => item.id, (item, ctx) => { ... })`}, for example inside render().`, `each:${list}`);
|
|
102
102
|
continue;
|
|
103
103
|
}
|
|
104
104
|
const items = [
|
package/dist/oxlint-plugin.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { n as checkA11y } from "./a11y-
|
|
2
|
-
import { a as dataFlow, i as contractsFor, l as templateCode, r as componentClasses, u as unusedMarkup } from "./lint-
|
|
1
|
+
import { n as checkA11y } from "./a11y-Bn9pDvWV.mjs";
|
|
2
|
+
import { a as dataFlow, i as contractsFor, l as templateCode, r as componentClasses, u as unusedMarkup } from "./lint-CqWk4U5P.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.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export interface TsDomPluginOptions {
|
|
2
|
-
/** Directories to scan for .html files and generate .html.ts into. Default: ["src"] */
|
|
2
|
+
/** Directories to scan for .html files and generate .html.ts into, relative to Vite's root. Default: ["src"] */
|
|
3
3
|
dirs?: string[];
|
|
4
4
|
/** Module name the generated files import the runtime from. Default: "@nativele/ts-dom" */
|
|
5
5
|
runtime?: string;
|
|
@@ -14,6 +14,24 @@ export interface TsDomPluginOptions {
|
|
|
14
14
|
ignore?: string[];
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
+
interface Logger {
|
|
18
|
+
error(m: string): void;
|
|
19
|
+
warn(m: string): void;
|
|
20
|
+
}
|
|
21
|
+
interface HotServer {
|
|
22
|
+
config: {
|
|
23
|
+
logger: Logger;
|
|
24
|
+
};
|
|
25
|
+
moduleGraph: {
|
|
26
|
+
getModulesByFile(f: string): Set<unknown> | undefined;
|
|
27
|
+
invalidateModule(m: unknown): void;
|
|
28
|
+
};
|
|
29
|
+
ws: {
|
|
30
|
+
send(payload: {
|
|
31
|
+
type: "full-reload";
|
|
32
|
+
}): void;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
17
35
|
/**
|
|
18
36
|
* Optional Vite plugin: generates `.html.ts` when the build starts and regenerates it while the dev server runs.
|
|
19
37
|
* ts-dom does not depend on it: `ts-dom gen` (or `--watch`) together with tsc is enough.
|
|
@@ -21,6 +39,9 @@ export interface TsDomPluginOptions {
|
|
|
21
39
|
export declare function tsDom(opts?: TsDomPluginOptions): {
|
|
22
40
|
name: string;
|
|
23
41
|
enforce: "pre";
|
|
42
|
+
configResolved(config: {
|
|
43
|
+
root: string;
|
|
44
|
+
}): void;
|
|
24
45
|
buildStart(this: {
|
|
25
46
|
error(m: string): never;
|
|
26
47
|
warn(m: string): void;
|
|
@@ -31,10 +52,12 @@ export declare function tsDom(opts?: TsDomPluginOptions): {
|
|
|
31
52
|
on(ev: string, fn: (f: string) => void): void;
|
|
32
53
|
};
|
|
33
54
|
config: {
|
|
34
|
-
logger:
|
|
35
|
-
error(m: string): void;
|
|
36
|
-
warn(m: string): void;
|
|
37
|
-
};
|
|
55
|
+
logger: Logger;
|
|
38
56
|
};
|
|
39
57
|
}): void;
|
|
40
|
-
|
|
58
|
+
handleHotUpdate(ctx: {
|
|
59
|
+
file: string;
|
|
60
|
+
server: HotServer;
|
|
61
|
+
}): never[] | undefined;
|
|
62
|
+
};
|
|
63
|
+
export {};
|
package/dist/vite-plugin.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { c as generateFile, d as listHtml, i as CodegenError, l as isFullPage, n as checkA11y, o as formatDiagnostics, s as generateAll, u as isSelected } from "./a11y-
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { c as generateFile, d as listHtml, f as scanDefines, i as CodegenError, l as isFullPage, n as checkA11y, o as formatDiagnostics, s as generateAll, u as isSelected } from "./a11y-Bn9pDvWV.mjs";
|
|
2
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { resolve, sep } from "node:path";
|
|
4
4
|
// src/lib/vite-plugin.ts
|
|
5
|
+
const GENERATED_HEADER = "// Generated by ts-dom.";
|
|
6
|
+
const slashes = (file) => file.split(sep).join("/");
|
|
7
|
+
const read = (file) => existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
5
8
|
/**
|
|
6
9
|
* Optional Vite plugin: generates `.html.ts` when the build starts and regenerates it while the dev server runs.
|
|
7
10
|
* ts-dom does not depend on it: `ts-dom gen` (or `--watch`) together with tsc is enough.
|
|
@@ -13,8 +16,20 @@ function tsDom(opts = {}) {
|
|
|
13
16
|
include: opts.include,
|
|
14
17
|
exclude: opts.exclude
|
|
15
18
|
};
|
|
16
|
-
const dirs = opts.dirs ?? ["src"];
|
|
17
19
|
const a11y = opts.a11y === true ? {} : opts.a11y || null;
|
|
20
|
+
let dirs = (opts.dirs ?? ["src"]).map((d) => resolve(d));
|
|
21
|
+
const shown = /* @__PURE__ */ new Map();
|
|
22
|
+
const defsByDir = /* @__PURE__ */ new Map();
|
|
23
|
+
const defsOf = (dir) => {
|
|
24
|
+
let defs = defsByDir.get(dir);
|
|
25
|
+
if (!defs) defsByDir.set(dir, defs = existsSync(dir) ? scanDefines(dir) : []);
|
|
26
|
+
return defs;
|
|
27
|
+
};
|
|
28
|
+
const isDefineFile = (file) => {
|
|
29
|
+
const dir = dirOf(file);
|
|
30
|
+
return dir !== void 0 && defsOf(dir).some((d) => resolve(d.file) === file);
|
|
31
|
+
};
|
|
32
|
+
const dirOf = (file) => dirs.find((d) => file.startsWith(d + sep));
|
|
18
33
|
const warnA11y = (warn) => {
|
|
19
34
|
if (!a11y) return;
|
|
20
35
|
for (const d of dirs) {
|
|
@@ -25,9 +40,57 @@ function tsDom(opts = {}) {
|
|
|
25
40
|
}
|
|
26
41
|
}
|
|
27
42
|
};
|
|
43
|
+
/** Regenerate one template. Returns whether its generated module changed. */
|
|
44
|
+
const regenerate = (file, logger) => {
|
|
45
|
+
const dir = dirOf(file);
|
|
46
|
+
const html = read(file);
|
|
47
|
+
if (!dir || html === null || file.includes(`${sep}node_modules${sep}`) || isFullPage(html) || !isSelected(dir, file, generate)) return false;
|
|
48
|
+
const out = `${file}.ts`;
|
|
49
|
+
const before = read(out);
|
|
50
|
+
try {
|
|
51
|
+
generateFile(file, generate, defsOf(dir));
|
|
52
|
+
} catch (e) {
|
|
53
|
+
if (e instanceof CodegenError) logger.error(e.message);
|
|
54
|
+
else throw e;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (a11y) for (const diag of checkA11y(html, a11y.ignore)) logger.warn(formatDiagnostics(file, [diag]));
|
|
58
|
+
return read(out) !== before;
|
|
59
|
+
};
|
|
60
|
+
/** Regenerate a directory after a define() call may have changed: the tag map and the child imports of every template. */
|
|
61
|
+
const regenerateDir = (dir, logger) => {
|
|
62
|
+
const before = new Map(listHtml(dir).map((f) => [`${f}.ts`, read(`${f}.ts`)]));
|
|
63
|
+
try {
|
|
64
|
+
generateAll(dir, generate);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (e instanceof CodegenError) logger.error(e.message);
|
|
67
|
+
else throw e;
|
|
68
|
+
}
|
|
69
|
+
defsByDir.set(dir, scanDefines(dir));
|
|
70
|
+
for (const [out, content] of before) {
|
|
71
|
+
const now = read(out);
|
|
72
|
+
if (now !== null && now !== content) shown.set(out, now);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const removeGenerated = (file) => {
|
|
76
|
+
const out = `${file}.ts`;
|
|
77
|
+
if (dirOf(file) && read(out)?.startsWith(GENERATED_HEADER)) unlinkSync(out);
|
|
78
|
+
};
|
|
79
|
+
const reload = (server, out) => {
|
|
80
|
+
const now = read(out);
|
|
81
|
+
if (now === null || shown.get(out) === now) return;
|
|
82
|
+
shown.set(out, now);
|
|
83
|
+
const loaded = server.moduleGraph.getModulesByFile(slashes(out));
|
|
84
|
+
if (!loaded?.size) return;
|
|
85
|
+
for (const m of loaded) server.moduleGraph.invalidateModule(m);
|
|
86
|
+
server.ws.send({ type: "full-reload" });
|
|
87
|
+
};
|
|
28
88
|
return {
|
|
29
89
|
name: "ts-dom",
|
|
30
90
|
enforce: "pre",
|
|
91
|
+
configResolved(config) {
|
|
92
|
+
dirs = (opts.dirs ?? ["src"]).map((d) => resolve(config.root, d));
|
|
93
|
+
},
|
|
31
94
|
buildStart() {
|
|
32
95
|
try {
|
|
33
96
|
for (const d of dirs) if (existsSync(d)) generateAll(d, generate);
|
|
@@ -38,24 +101,40 @@ function tsDom(opts = {}) {
|
|
|
38
101
|
warnA11y((m) => this.warn(m));
|
|
39
102
|
},
|
|
40
103
|
configureServer(server) {
|
|
41
|
-
const
|
|
42
|
-
if (!file.endsWith(".html") || file.includes("node_modules") || !existsSync(file)) return;
|
|
43
|
-
const html = readFileSync(file, "utf8");
|
|
44
|
-
if (isFullPage(html)) return;
|
|
45
|
-
const dir = dirs.find((d) => file.startsWith(resolve(d) + sep));
|
|
46
|
-
if (!dir || !isSelected(resolve(dir), file, generate)) return;
|
|
47
|
-
try {
|
|
48
|
-
generateFile(file, generate, dir);
|
|
49
|
-
if (a11y) for (const diag of checkA11y(html, a11y.ignore)) server.config.logger.warn(formatDiagnostics(file, [diag]));
|
|
50
|
-
} catch (e) {
|
|
51
|
-
if (e instanceof CodegenError) server.config.logger.error(e.message);
|
|
52
|
-
else throw e;
|
|
53
|
-
}
|
|
54
|
-
};
|
|
104
|
+
const logger = server.config.logger;
|
|
55
105
|
server.watcher.add(dirs);
|
|
56
|
-
server.watcher.on("
|
|
57
|
-
|
|
106
|
+
server.watcher.on("add", (f) => {
|
|
107
|
+
const file = resolve(f);
|
|
108
|
+
const dir = dirOf(file);
|
|
109
|
+
if (file.endsWith(".html")) regenerate(file, logger);
|
|
110
|
+
else if (dir && isSourceTs(file) && read(file)?.includes("define(")) regenerateDir(dir, logger);
|
|
111
|
+
});
|
|
112
|
+
server.watcher.on("unlink", (f) => {
|
|
113
|
+
const file = resolve(f);
|
|
114
|
+
const dir = dirOf(file);
|
|
115
|
+
if (file.endsWith(".html")) removeGenerated(file);
|
|
116
|
+
else if (dir && isDefineFile(file)) regenerateDir(dir, logger);
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
handleHotUpdate(ctx) {
|
|
120
|
+
const file = resolve(ctx.file);
|
|
121
|
+
const logger = ctx.server.config.logger;
|
|
122
|
+
if (file.endsWith(".html.ts")) {
|
|
123
|
+
const now = read(file);
|
|
124
|
+
if (now !== null && shown.get(file) === now) return [];
|
|
125
|
+
if (now !== null) shown.set(file, now);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (file.endsWith(".html")) {
|
|
129
|
+
if (!dirOf(file)) return;
|
|
130
|
+
if (regenerate(file, logger)) reload(ctx.server, `${file}.ts`);
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
const dir = dirOf(file);
|
|
134
|
+
if (!dir || !isSourceTs(file)) return;
|
|
135
|
+
if (read(file)?.includes("define(") || isDefineFile(file)) regenerateDir(dir, logger);
|
|
58
136
|
}
|
|
59
137
|
};
|
|
60
138
|
}
|
|
139
|
+
const isSourceTs = (file) => file.endsWith(".ts") && !file.endsWith(".d.ts") && !file.endsWith(".html.ts") && !file.endsWith(`${sep}ts-dom-tags.ts`);
|
|
61
140
|
export { tsDom };
|
package/llms.txt
CHANGED
|
@@ -65,7 +65,17 @@ define("my-counter", MyCounter);
|
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
## Template without a component
|
|
68
|
-
`const view = tpl.instantiate()` returns `{ root: DocumentFragment, refs, lists, text, show }` typed like a component, for pieces created in code. No host: `data-on-*` is inert (use `refs.x.addEventListener`) and `<slot>` is not projected.
|
|
68
|
+
`const view = tpl.instantiate()` returns `{ root: DocumentFragment, rootElement, refs, lists, text, show }` typed like a component (`rootElement` exists in the type only when the template has exactly one top-level element, `<style>` excluded), for pieces created in code. No host: `data-on-*` is inert (use `refs.x.addEventListener`) and `<slot>` is not projected.
|
|
69
|
+
|
|
70
|
+
## Lists
|
|
71
|
+
- 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.
|
|
72
|
+
- Items that are plain elements: `<template data-each="x">` with `this.lists.x.replace(items, keyFn, (item, { text, show }) => ...)`.
|
|
73
|
+
- `data-each` is never nested: a list inside an item is the item component's own `data-each`.
|
|
74
|
+
|
|
75
|
+
## Rules for agents
|
|
76
|
+
- Never edit `x.html.ts` or `ts-dom-tags.ts` (generated). After any `.html` change run `npx ts-dom gen && npx tsc` and fix every type error instead of casting it away. Until `gen` has run, `tsc` only reports that `./x.html.js` is missing.
|
|
77
|
+
- 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.
|
|
78
|
+
- 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.
|
|
69
79
|
|
|
70
80
|
## Setup
|
|
71
81
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nativele/ts-dom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A TypeScript-driven, HTML-first UI library. Bind HTML to TypeScript: HTML describes the structure, TypeScript owns behavior and state, and the DOM is the single source of truth. Fully typed, no virtual DOM, no bundler required.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"html",
|