@nativele/ts-dom 0.3.0 → 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 CHANGED
@@ -10,6 +10,32 @@ 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
+
13
39
  ## 0.3.0
14
40
 
15
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.
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)
@@ -670,6 +695,33 @@ There is no host element, so `data-on-*` attributes do nothing here (listen on `
670
695
 
671
696
  ## Lists
672
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
+
673
725
  ```html
674
726
  <ul>
675
727
  <template data-each="tags">
@@ -726,7 +778,7 @@ Notes:
726
778
 
727
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.
728
780
 
729
- 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), `<html>`, `<head>` and `<body>` inside a template, 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.
730
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.
731
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.
732
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).
@@ -783,7 +835,7 @@ npx ts-dom lint --a11y [--strict]
783
835
  | `heading-empty` | `h1`–`h6` must not be empty |
784
836
  | `aria-role` | `role` must be a valid ARIA role |
785
837
  | `aria-attr` | `aria-*` must be a valid ARIA attribute |
786
- | `role-tabindex` | A non-ts-domy-focusable element with an interactive role (button, tab, checkbox…) needs `tabindex` |
838
+ | `role-tabindex` | A non-natively-focusable element with an interactive role (button, tab, checkbox…) needs `tabindex` |
787
839
  | `tabindex-positive` | Do not use a `tabindex` greater than 0 |
788
840
  | `hidden-focusable` | A focusable element must not have `aria-hidden="true"` |
789
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
- 安裝時建議先裝 `vite-plus`,再裝 `ts-dom`(npm 同一次安裝時可能不會把 `vite` 提升到頂層)。
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
 
@@ -672,6 +697,33 @@ function menuItem(label: string): HTMLLIElement {
672
697
 
673
698
  ## 清單
674
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
+
675
727
  ```html
676
728
  <ul>
677
729
  <template data-each="tags">
@@ -728,7 +780,7 @@ this.lists.tags.replace(
728
780
 
729
781
  四道檢查,互補。**每一則訊息都有穩定的規則代號,並說明哪裡錯、為什麼、怎麼修**;`npx ts-dom rules` 列出全部規則。
730
782
 
731
- 1. **codegen 診斷**(`ts-dom gen` 與 Vite 外掛,帶行列號,有錯誤時中斷、不寫檔):重複的 `data-ref`、`data-each` 沒有單一根元素、`data-each` 不在 `<template>` 上、`data-each` 巢狀、`data-if` 在項目根元素、`<slot>` 在項目內、`<style>` 不在第一層、不合法的洞、屬性內的洞、不合法的 `data-on-*`(含與 DOM 或 ts-dom 成員同名),範本內的 `<html>`、`<head>`、`<body>`,以及會執行程式碼或被 Trusted Types 擋下的標記:`<script>`、`on*` 屬性、`srcdoc`、`javascript:` 網址。
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:` 網址。
732
784
  2. **tsc**:TypeScript 使用了 HTML 沒有宣告的名稱就報錯(`this.refs.nope`、`this.show("nope", true)`);`state`、`props`、`derive`、`emits` 與 HTML 之間的所有約束(見「資料流」);子元件 prop 的名稱與型別;`define()` 檢查 `data-on-*` 的方法存在。
733
785
  3. **執行期**:`Component()` 與元件在定義、連接與使用時,對同樣的問題丟出帶 `[規則代號]` 與修正方式的錯誤。
734
786
  4. **lint**(`ts-dom/unused-markup`、`ts-dom/data-flow`、`ts-dom/a11y`):HTML 宣告了卻沒被使用的標記(錯誤,附 HTML 位置)、資料流建議與子元件契約(警告)、ARIA(警告)。
@@ -21,6 +21,9 @@ 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",
25
28
  "document-tag": "<html>, <head> and <body> cannot be in a template (the browser drops them from fragments)",
26
29
  "forbidden-tag": "<script> cannot be in a template (it would run code)",
@@ -28,6 +31,17 @@ const CODEGEN_RULES = {
28
31
  "unsafe-url": "href / src / action / formaction cannot start with javascript:",
29
32
  "duplicate-define": "a custom element tag can be defined only once per scanned directory"
30
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);
31
45
  const URL_ATTRS = /^(href|src|action|formaction|xlink:href)$/i;
32
46
  const HTML_NS = "http://www.w3.org/1999/xhtml";
33
47
  const newScope = () => ({
@@ -52,6 +66,7 @@ function analyze(html) {
52
66
  scope: newScope(),
53
67
  lists: /* @__PURE__ */ new Map(),
54
68
  slots: /* @__PURE__ */ new Set(),
69
+ boundLists: /* @__PURE__ */ new Map(),
55
70
  events: /* @__PURE__ */ new Set(),
56
71
  eventSites: [],
57
72
  customTags: /* @__PURE__ */ new Set(),
@@ -169,6 +184,21 @@ function analyze(html) {
169
184
  else {
170
185
  const items = n.content.childNodes.filter((c) => isEl$1(c) || c.nodeName === "#text" && c.value.trim());
171
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
+ }
172
202
  const s = newScope();
173
203
  a.lists.set(each, s);
174
204
  mark(`each:${each}`, n);
@@ -252,10 +282,16 @@ function toNodes(parent, collapse = false) {
252
282
  * tsc (or any bundler) can compile it directly: no plugin and no allowArbitraryExtensions needed.
253
283
  * Consumers import it as `import tpl from "./x.html.js"` (TypeScript maps it to x.html.ts).
254
284
  */
255
- function generateModule(html, options = {}, imports = []) {
285
+ function generateModule(html, options = {}, imports = [], defs = []) {
256
286
  const runtime = options.runtime ?? "@nativele/ts-dom";
257
287
  const a = analyze(html);
258
- const lists = [...a.lists].map(([n, s]) => `${q(n)}: { refs: ${obj(s.refs)}; text: ${union(s.text)}; ifs: ${union(s.ifs)} }`).join("; ");
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("; ");
259
295
  const type = `{
260
296
  refs: ${obj(a.scope.refs)};
261
297
  lists: { ${lists} };
@@ -298,7 +334,7 @@ function generateFile(htmlPath, options = {}, defs) {
298
334
  const here = dirname(htmlPath);
299
335
  const imports = [...new Set(known.filter((d) => analysis.customTags.has(d.tag)).map((d) => relImport(here, d.file)))].sort();
300
336
  const out = modulePathFor(htmlPath);
301
- const next = generateModule(html, options, imports);
337
+ const next = generateModule(html, options, imports, known);
302
338
  let prev = "";
303
339
  try {
304
340
  prev = readFileSync(out, "utf8");
@@ -498,10 +534,10 @@ const A11Y_RULES = {
498
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.",
499
535
  "heading-order": "Heading levels are skipped. Fix: do not jump levels (for example h1 straight to h3); use the next level down.",
500
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.",
501
- "list-structure": "Invalid list structure. Fix: <ul>/<ol> may only contain <li>, and <li> must be inside <ul>, <ol> or <menu>.",
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>.",
502
538
  "landmark-label": "There are several landmarks of the same kind. Fix: give each <nav> / <aside> a distinct aria-label.",
503
539
  "redundant-role": "The role repeats the element's native role. Fix: remove the role attribute.",
504
- "click-semantics": "data-on-click is on an element that is not ts-domy 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."
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."
505
541
  };
506
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(" "));
507
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}`));
@@ -629,10 +665,10 @@ function checkA11y(html, ignore = []) {
629
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.`);
630
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.`);
631
667
  const tabindex = attr(el, "tabindex");
632
- if (role && INTERACTIVE_ROLES.has(role) && !focusable(el) && tabindex === void 0 && !managed) report("role-tabindex", el, `<${tag} role="${role}"> is not ts-domy focusable. Fix: add tabindex="0" and handle keyboard events, or use a native element such as <button>.`);
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>.`);
633
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".`);
634
670
  if (attr(el, "aria-hidden") === "true" && (focusable(el) || tabindex !== void 0 && Number(tabindex) >= 0)) report("hidden-focusable", el);
635
- 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 ts-domy 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.`);
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.`);
636
672
  if (/^h[1-6]$/.test(tag)) {
637
673
  const level = Number(tag[1]);
638
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.`);
@@ -643,8 +679,16 @@ function checkA11y(html, ignore = []) {
643
679
  if (!isEl(c) || c.tagName === "slot" || c.tagName === "script") continue;
644
680
  if (c.tagName === "template") {
645
681
  if (!has(c, "data-each")) continue;
646
- for (const item of c.content.childNodes) if (isEl(item) && item.tagName !== "li") report("list-structure", item, `The root element of a data-each item inside <${tag}> must be <li>, found <${item.tagName}>. Fix: use <li> as the item root.`);
647
- } else if (c.tagName !== "li") report("list-structure", c, `<${tag}> may only contain <li> children, found <${c.tagName}>. Fix: wrap the content in <li>.`);
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.`);
648
692
  }
649
693
  if (tag === "li" && parent && ![
650
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-CeZcA10z.mjs";
3
- import { c as lintUsage, n as UNUSED_MARKUP_RULES, o as formatIssue, s as lintDataFlow, t as DATA_FLOW_RULES } from "./lint-DS-IOjkf.mjs";
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;
@@ -244,4 +295,4 @@ type CheckHandlers<I> = I extends {
244
295
  * component as a public method, otherwise tsc reports an error that lists the missing names (`missingHandlers`).
245
296
  */
246
297
  export declare function define<C extends abstract new (...args: never[]) => HTMLElement>(name: string, ctor: C & CheckHandlers<InstanceType<C>>): void;
247
- 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] replace() received two items with the key ${String(k)}, so one element cannot show both. Fix: make key() return a unique value for each item.`);
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
- lists[name] = new List(anchor, proto, onItem);
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;
@@ -1,4 +1,4 @@
1
- import { a as analyze, d as listHtml, f as scanDefines } from "./a11y-CeZcA10z.mjs";
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 = [
@@ -1,5 +1,5 @@
1
- import { n as checkA11y } from "./a11y-CeZcA10z.mjs";
2
- import { a as dataFlow, i as contractsFor, l as templateCode, r as componentClasses, u as unusedMarkup } from "./lint-DS-IOjkf.mjs";
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
@@ -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 {};
@@ -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-CeZcA10z.mjs";
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 regen = (file) => {
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("change", regen);
57
- server.watcher.on("add", regen);
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
@@ -67,6 +67,16 @@ define("my-counter", MyCounter);
67
67
  ## Template without a component
68
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
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.
79
+
70
80
  ## Setup
71
81
  ```sh
72
82
  npm i -D typescript && npm i @nativele/ts-dom
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nativele/ts-dom",
3
- "version": "0.3.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",