@nativele/ts-dom 0.5.4 → 0.6.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +6 -1
  3. package/README.zh-TW.md +6 -1
  4. package/dist/{a11y-CiEJj_e6.mjs → a11y-KXbo-8o3.mjs} +119 -27
  5. package/dist/cli.mjs +273 -50
  6. package/dist/{lint-Did4w-NL.mjs → lint-DUsdDKZ-.mjs} +33 -8
  7. package/dist/oxlint-plugin.mjs +3 -3
  8. package/dist/vite-plugin.mjs +1 -1
  9. package/docs/architecture.md +188 -0
  10. package/docs/components.md +367 -0
  11. package/docs/contributing.md +80 -0
  12. package/docs/design.md +55 -0
  13. package/docs/lists.md +150 -0
  14. package/docs/performance.md +24 -0
  15. package/docs/recipes.md +189 -0
  16. package/docs/templates.md +73 -0
  17. package/docs/tooling.md +251 -0
  18. package/llms.txt +5 -5
  19. package/package.json +2 -1
  20. package/skills/README.md +51 -14
  21. package/skills/nativele-angular/SKILL.md +15 -11
  22. package/skills/nativele-architecture/SKILL.md +29 -16
  23. package/skills/nativele-ci/SKILL.md +148 -0
  24. package/skills/nativele-core/SKILL.md +16 -13
  25. package/skills/nativele-formatting/SKILL.md +167 -0
  26. package/skills/nativele-integration/SKILL.md +12 -9
  27. package/skills/nativele-linting/SKILL.md +174 -0
  28. package/skills/nativele-monorepo/SKILL.md +298 -0
  29. package/skills/nativele-react/SKILL.md +13 -10
  30. package/skills/nativele-state-management/SKILL.md +6 -6
  31. package/skills/nativele-svelte/SKILL.md +10 -8
  32. package/skills/nativele-tailwind/SKILL.md +8 -7
  33. package/skills/nativele-tanstack-query/SKILL.md +5 -5
  34. package/skills/nativele-testing/SKILL.md +22 -16
  35. package/skills/nativele-typescript/SKILL.md +186 -0
  36. package/skills/nativele-vite/SKILL.md +21 -17
  37. package/skills/nativele-vue/SKILL.md +10 -8
  38. package/skills/nativele-zod/SKILL.md +6 -6
package/docs/lists.md ADDED
@@ -0,0 +1,150 @@
1
+ # Lists
2
+
3
+ Part of the [Nativele](../README.md) documentation.
4
+
5
+ ## Example: Todo list (lists, event delegation, `hit.key`)
6
+
7
+ ```html
8
+ <form data-on-submit="add">
9
+ <input data-ref="input" aria-label="New todo" />
10
+ <button type="submit">Add</button>
11
+ </form>
12
+ <ul>
13
+ <template data-each="items">
14
+ <li>
15
+ <input type="checkbox" data-ref="check" data-on-change="toggle" />
16
+ <span>{{ title }}</span>
17
+ <button type="button" data-on-click="removeItem">Delete</button>
18
+ </li>
19
+ </template>
20
+ </ul>
21
+ <p>{{ remaining }} left</p>
22
+ ```
23
+
24
+ ```ts
25
+ import { Component, define } from "@nativele/ts-dom";
26
+ import type { EventHit } from "@nativele/ts-dom";
27
+ import tpl from "./todo-list.html.js";
28
+
29
+ interface Item {
30
+ id: number;
31
+ title: string;
32
+ done: boolean;
33
+ }
34
+
35
+ export class TodoList extends Component(tpl, {
36
+ state: { items: [] as Item[] },
37
+ derive: { remaining: (s) => s.items.filter((i) => !i.done).length },
38
+ }) {
39
+ #nextId = 1;
40
+
41
+ add(e: Event): void {
42
+ e.preventDefault();
43
+ const title = this.refs.input.value.trim();
44
+ this.refs.input.value = "";
45
+ if (title) this.set((s) => ({ items: [...s.items, { id: this.#nextId++, title, done: false }] }));
46
+ }
47
+ toggle(_e: Event, hit: EventHit): void {
48
+ this.set((s) => ({ items: s.items.map((i) => (i.id === hit.key ? { ...i, done: !i.done } : i)) }));
49
+ }
50
+ removeItem(_e: Event, hit: EventHit): void {
51
+ this.set((s) => ({ items: s.items.filter((i) => i.id !== hit.key) }));
52
+ }
53
+
54
+ // Automatic binding handles {{ remaining }}; the list is filled here.
55
+ override render(view: { items: readonly Item[] }, changed: ReadonlySet<"items">): void {
56
+ if (!changed.has("items")) return;
57
+ this.lists.items.replace(
58
+ view.items,
59
+ (i) => i.id,
60
+ (i, { text, refs }) => {
61
+ text.title = i.title;
62
+ refs.check.checked = i.done;
63
+ refs.check.setAttribute("aria-label", `Done: ${i.title}`);
64
+ },
65
+ { skipUnchanged: true },
66
+ );
67
+ }
68
+ }
69
+ define("todo-list", TodoList);
70
+ ```
71
+
72
+ - The `<template>` with `data-each` is the list item; automatic binding does not handle lists, so the list is filled in `render` with `replace`.
73
+ - Events use delegation: for `data-on-change` and `data-on-click` inside a list item, the `hit.key` they receive is that item's key.
74
+ - `{{ remaining }}` is a derived value and updates automatically.
75
+
76
+ ## Lists
77
+
78
+ There are two ways to fill a list, and one rule decides between them: **an item that has anything of its own (state, events, a nested list) is a component, filled with `items =`; an item that is a few texts and `data-if` is plain elements, filled with `replace()`.** The README's second example shows the first, and the example at the top of this page the second.
79
+
80
+ ### Items as components: `items = ...`
81
+
82
+ Name the field that identifies an item and the property that receives it, on the `<template data-each>`; the item is one custom element:
83
+
84
+ ```html
85
+ <ul>
86
+ <template data-each="todos" data-key="id" data-prop="todo">
87
+ <todo-row role="listitem" data-on-toggled="toggle"></todo-row>
88
+ </template>
89
+ </ul>
90
+ ```
91
+
92
+ ```ts
93
+ render(view, changed) {
94
+ if (changed.has("todos")) this.lists.todos.items = view.todos;
95
+ }
96
+ toggle(_e: Event, hit: EventHit<Todo, "id">) { /* hit.key is the item's id, typed Todo["id"]; hit.item is the Todo */ }
97
+ ```
98
+
99
+ 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 }`. Each item component receives its whole item through that one property; to give it more (whether it is selected, its rank), map the items to objects that carry it before assigning, for example `items.map((t) => ({ ...t, selected: t.id === selectedId }))`. 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`); a component from outside the scanned directories has no item type to check against, so the list accepts an object literal or a type alias with the `id` field, but rejects items typed by an interface (`Index signature for type 'string' is missing in type 'Tag'`): map them to new objects (`items.map((t) => ({ ...t }))`), or bring the component's tag map into the program (a component package exports it from its entry). 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`.
100
+
101
+ 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.
102
+
103
+ ### Items as plain elements: `replace()`
104
+
105
+ ```html
106
+ <ul>
107
+ <template data-each="tags">
108
+ <li><span>{{ label }}</span> <b data-if="hot">🔥</b></li>
109
+ </template>
110
+ </ul>
111
+ ```
112
+
113
+ ```ts
114
+ this.lists.tags.replace(
115
+ tags,
116
+ (t) => t.id, // key
117
+ (t, { text, refs, show, el }) => {
118
+ text.label = t.label;
119
+ show("hot", t.hot === true);
120
+ },
121
+ );
122
+ ```
123
+
124
+ | Method | Description |
125
+ |---|---|
126
+ | `replace(items, key, update, opts?)` | Keyed reconciliation: remove the extra items, create the missing ones, reorder in sequence, and call `update` for each item. With `opts.skipUnchanged: true`, `update` is skipped when the item reference is the same as last time (off by default: it is safe only when you use immutable updates; if you mutate objects directly, the view stays on the old value) |
127
+ | `upsert(key, item, update)` | Add (at the end) or update a single item |
128
+ | `remove(key)` | Remove a single item |
129
+ | `clear()` | Clear everything |
130
+ | `has(key)` / `get(key)` / `size` | Queries |
131
+
132
+ `replace` only does one-dimensional key matching; the DOM nodes of existing items are kept and moved, and subtrees are not compared. The content of an item is entirely decided by your `update`.
133
+
134
+ Notes:
135
+
136
+ - `data-ref`, holes, and `data-if` inside an item belong to that item and do not appear in the component-level `refs` / `text`.
137
+ - `data-if` cannot be placed on an item's root element; wrap it in one more layer.
138
+ - `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.
139
+ - `<slot>` cannot be placed inside an item.
140
+
141
+ ### Moving items, connecting and subscriptions
142
+
143
+ - In a browser with `moveBefore`, a moved item stays connected: neither `disconnected()` nor `connected()` runs, and its focus, scroll position and subscriptions are untouched.
144
+ - Without it (older browsers, and DOM implementations for tests such as happy-dom and jsdom), a moved item is removed and inserted again: `disconnected()` runs and releases everything registered with `bind`, `on` and `onCleanup`, then `connected()` runs again, and a focused input in it loses focus. Its element, props and state stay the same.
145
+ - So subscribe in `connected()` (`this.bind(store, ...)`, `this.on(...)`), not in the constructor or a field initializer: then the subscription is made again after a move in every browser. A subscription made in the constructor is released by the first move and never comes back.
146
+ - Data that every item needs (a theme, the current user, a registry) belongs in a `createStore` that the item component binds in `connected()`, not copied into every item object.
147
+
148
+ ### Order of list updates
149
+
150
+ Assigning `items` sets the `data-prop` property of a new item before its element goes into the document, so the item component is connected with its item already set and its first render shows it. `replace()` and `upsert()` put a new item's element into the document first and then call `update`, so a child component placed in such an item is connected before the parent has set its properties; there, build per-item state when the child first receives data (for example in a property setter), and guard a render in `connected()` with the data it needs.
@@ -0,0 +1,24 @@
1
+ # Performance
2
+
3
+ Part of the [Nativele](../README.md) documentation.
4
+
5
+ ## Performance
6
+
7
+ `npm run bench` runs a large list benchmark in real Chrome and prints this table. Each item shows one text; the times are the median of 5 runs and count script execution only (no layout or paint). They were measured on one machine (Chrome on WSL), so read them as proportions, not absolute numbers. "Direct DOM" is a hand-written keyed list with plain DOM calls, the floor any library adds to.
8
+
9
+ | Approach | Items | Create | Update 10% | Reverse | Remove half | Append 1,000 |
10
+ |---|---|---|---|---|---|---|
11
+ | Components (`items =`) | 1,000 | 15.4 ms | 0.5 ms | 1.8 ms | 1.1 ms | 14.6 ms |
12
+ | Components (`items =`) | 5,000 | 80.5 ms | 1.6 ms | 10 ms | 5.4 ms | 17.6 ms |
13
+ | Components (`items =`) | 10,000 | 160.6 ms | 3.2 ms | 21 ms | 11.3 ms | 16 ms |
14
+ | Plain elements (`replace()`) | 1,000 | 2.8 ms | 0.1 ms | 0.7 ms | 0.2 ms | 2.9 ms |
15
+ | Plain elements (`replace()`) | 5,000 | 15.8 ms | 0.7 ms | 4.3 ms | 1.7 ms | 3.7 ms |
16
+ | Plain elements (`replace()`) | 10,000 | 42 ms | 1.4 ms | 8.6 ms | 3.3 ms | 4.4 ms |
17
+ | Direct DOM (baseline) | 1,000 | 0.8 ms | 0.2 ms | 1 ms | 0.3 ms | 0.8 ms |
18
+ | Direct DOM (baseline) | 5,000 | 4 ms | 0.8 ms | 4.3 ms | 1.6 ms | 1.1 ms |
19
+ | Direct DOM (baseline) | 10,000 | 8.7 ms | 1.7 ms | 10.9 ms | 3.9 ms | 1.6 ms |
20
+
21
+ - Creating component items costs about 15 µs per item, because each item is a custom element with its own template, refs and lifecycle; plain elements cost about 3–4 µs and direct DOM about 1 µs.
22
+ - Once the items exist, updating, reordering and removing them stay within about two times of direct DOM: only items that are a new object are touched, and kept elements are moved, not rebuilt.
23
+ - A frame has about 16 ms. A list of a few thousand component items is comfortable; for tens of thousands, use plain elements with `replace()`, show a page or a window of the list at a time, or keep component items for the rows that need state and events.
24
+ - Event delegation adds no listeners as the list grows.
@@ -0,0 +1,189 @@
1
+ # Recipes
2
+
3
+ Patterns for common screens, built only from the library's own pieces. Every snippet comes from [examples/recipes](../examples/recipes), a contacts screen that `npm run check` type-checks and a real Chrome test drives.
4
+
5
+ ## Loading and error states
6
+
7
+ Keep the request status as a union, derive one boolean per state you show, and let `data-if` show the matching block. `set` renders synchronously, so each step of the request shows at once.
8
+
9
+ ```ts
10
+ // contacts.ts: a shared store, so several components can show the same data
11
+ export type Status = "idle" | "loading" | "error" | "ready";
12
+ export const contacts = createStore({ status: "idle" as Status, error: "", items: [] as readonly Contact[], nextId: 1 });
13
+
14
+ export async function loadContacts(): Promise<void> {
15
+ contacts.set((s) => ({ ...s, status: "loading", error: "" }));
16
+ try {
17
+ const res = await fetch("/api/contacts");
18
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
19
+ const items = (await res.json()) as Contact[];
20
+ contacts.set((s) => ({ ...s, status: "ready", items }));
21
+ } catch (e) {
22
+ contacts.set((s) => ({ ...s, status: "error", error: e instanceof Error ? e.message : String(e) }));
23
+ }
24
+ }
25
+ ```
26
+
27
+ ```html
28
+ <!-- contact-app.html -->
29
+ <p data-if="loading">Loading...</p>
30
+ <p role="alert" data-if="failed">Could not load: {{ error }} <button type="button" data-on-click="retry">Retry</button></p>
31
+ ```
32
+
33
+ ```ts
34
+ // contact-app.ts
35
+ export class ContactApp extends Component(tpl, {
36
+ state: { status: "idle", error: "", contacts: [], query: "" } as State,
37
+ derive: {
38
+ loading: (s) => s.status === "loading",
39
+ failed: (s) => s.status === "error",
40
+ // ...
41
+ },
42
+ }) {
43
+ override connected(): void {
44
+ this.bind(contacts, (c) => this.set({ status: c.status, error: c.error, contacts: c.items }));
45
+ if (contacts.get().status === "idle") void loadContacts();
46
+ }
47
+ retry(): void {
48
+ void loadContacts();
49
+ }
50
+ }
51
+ ```
52
+
53
+ A store's `set` replaces the whole value, unlike a component's `this.set`, which merges; spread the rest yourself as above.
54
+
55
+ ## Showing data from a store
56
+
57
+ When a component has `state`, `props` or `derive`, every `{{ hole }}` and `data-if` in its template must be one of them, and tsc reports any other name as `missing-binding`. To show a value from a store, `bind` the store and copy what the template shows into state:
58
+
59
+ ```html
60
+ <!-- contact-stats.html -->
61
+ <p>{{ total }} contacts</p>
62
+ ```
63
+
64
+ ```ts
65
+ export class ContactStats extends Component(tpl, { state: { total: 0 } }) {
66
+ override connected(): void {
67
+ this.bind(contacts, (c) => this.set({ total: c.items.length }));
68
+ }
69
+ }
70
+ ```
71
+
72
+ The state holds only what this component shows; `bind` keeps it current and unsubscribes when the element leaves the page. A component with no options at all can write `this.text.total = ...` in the `bind` callback instead.
73
+
74
+ ## A filtered list
75
+
76
+ Derive the visible items and assign them in `render`, guarded by the derived name: `changed` lists the derived values that changed as well as state and props. When two derived values need the same calculation, put it in a plain function, because a derived value cannot read another one.
77
+
78
+ ```ts
79
+ const matching = (s: DeepReadonly<State>) => s.contacts.filter((c) => c.name.toLowerCase().includes(s.query.toLowerCase()));
80
+
81
+ export class ContactApp extends Component(tpl, {
82
+ state: { status: "idle", error: "", contacts: [], query: "" } as State,
83
+ derive: {
84
+ visible: (s) => matching(s),
85
+ empty: (s) => s.status === "ready" && matching(s).length === 0,
86
+ },
87
+ }) {
88
+ search(): void {
89
+ this.set({ query: this.refs.search.value });
90
+ }
91
+ override render(view: { visible: readonly Contact[] }, changed: ReadonlySet<"visible">): void {
92
+ if (changed.has("visible")) this.lists.visible.items = view.visible;
93
+ }
94
+ }
95
+ ```
96
+
97
+ Annotate `changed` with the names you check, not `ReadonlySet<string>`: a misspelled name, or one that is not state, a prop or a derived value, is then a tsc error.
98
+
99
+ ## Events from list items
100
+
101
+ Name the item type and its key field in the handler's `EventHit`, and `hit.key` and `hit.item` are typed without a cast:
102
+
103
+ ```ts
104
+ removeRow(_e: Event, hit: EventHit<Contact, "id">): void {
105
+ removeContact(hit.key); // number
106
+ }
107
+ ```
108
+
109
+ Use this form only for handlers on elements inside list items; elsewhere `hit.key` and `hit.item` are `undefined`.
110
+
111
+ ## A modal dialog
112
+
113
+ Put a native `<dialog>` in the child's template and give the child a public method that opens it. The parent calls the method through its typed ref: a command (open, focus, reset) goes down as a method call, data goes down as props, and results come up as events.
114
+
115
+ ```html
116
+ <!-- contact-form.html -->
117
+ <dialog data-ref="dialog" aria-labelledby="contact-form-title">
118
+ <form data-ref="form" data-on-submit="submit" novalidate>
119
+ <h2 id="contact-form-title">New contact</h2>
120
+ ...
121
+ <button type="submit">Add</button>
122
+ <button type="button" data-on-click="cancel">Cancel</button>
123
+ </form>
124
+ </dialog>
125
+ ```
126
+
127
+ ```ts
128
+ // contact-form.ts
129
+ export class ContactForm extends Component(tpl, {
130
+ state: { nameError: "", emailError: "" },
131
+ emits: { "contact-add": event<{ name: string; email: string }>() },
132
+ }) {
133
+ open(): void {
134
+ this.refs.form.reset();
135
+ this.clearErrors();
136
+ this.refs.dialog.showModal();
137
+ }
138
+ cancel(): void {
139
+ this.refs.dialog.close();
140
+ }
141
+ }
142
+ ```
143
+
144
+ ```html
145
+ <!-- contact-app.html -->
146
+ <button type="button" data-on-click="openForm">Add contact</button>
147
+ <contact-form data-ref="form" data-on-contact-add="addRow"></contact-form>
148
+ ```
149
+
150
+ ```ts
151
+ // contact-app.ts
152
+ openForm(): void {
153
+ this.refs.form.open(); // refs.form is typed as ContactForm
154
+ }
155
+ addRow(e: Event): void {
156
+ const { name, email } = (e as EmitEvent<ContactForm, "contact-add">).detail;
157
+ addContact(name, email);
158
+ }
159
+ ```
160
+
161
+ ## Form validation
162
+
163
+ Keep each message in state, show it with `data-if`, and clear the messages as the user types. `novalidate` leaves validation to this code, and `role="alert"` announces the messages.
164
+
165
+ ```html
166
+ <label>Name <input data-ref="name" name="name" data-on-input="clearErrors" /></label>
167
+ <p role="alert" data-if="nameError">{{ nameError }}</p>
168
+ <label>Email <input data-ref="email" name="email" type="email" data-on-input="clearErrors" /></label>
169
+ <p role="alert" data-if="emailError">{{ emailError }}</p>
170
+ ```
171
+
172
+ ```ts
173
+ clearErrors(): void {
174
+ this.set({ nameError: "", emailError: "" });
175
+ }
176
+ submit(e: Event): void {
177
+ e.preventDefault();
178
+ const name = this.refs.name.value.trim();
179
+ const email = this.refs.email.value.trim();
180
+ const nameError = name ? "" : "Name is required";
181
+ const emailError = /^\S+@\S+\.\S+$/.test(email) ? "" : "Enter a valid email";
182
+ this.set({ nameError, emailError });
183
+ if (nameError || emailError) return;
184
+ this.emit("contact-add", { name, email });
185
+ this.refs.dialog.close();
186
+ }
187
+ ```
188
+
189
+ For larger forms, a schema library can produce the messages; the component still keeps them in state and shows them the same way. The `nativele-zod` Skill (`npx ts-dom skills nativele-zod`) does it with Zod or Valibot, including nested and array fields. Two details hold for any form: `aria-invalid` must be `"true"` (an empty `aria-invalid=""` counts as not invalid), and validating on blur needs `data-on-focusout` on the form, because `blur` does not bubble.
@@ -0,0 +1,73 @@
1
+ # Templates
2
+
3
+ Part of the [Nativele](../README.md) documentation.
4
+
5
+ ## HTML Syntax
6
+
7
+ There are only a few kinds of markup, plus one kind of hole.
8
+
9
+ | Markup | Meaning | TS side |
10
+ |---|---|---|
11
+ | `data-ref="name"` | Get the element; the type is derived from the tag | `this.refs.name` |
12
+ | `data-if="name"` | Conditional block, **hidden by default** (removed from the DOM); shown only when TS says so | `this.show("name", bool)` |
13
+ | `<template data-each="name">` | List; the content must be exactly one root element | `this.lists.name` |
14
+ | `<slot name="x">` | Slot | The user provides child nodes with `slot="x"` |
15
+ | `{{ name }}` | Text hole; the same name may appear in multiple places | `this.text.name = value`, or bound automatically from `state` |
16
+ | `data-on-click="method"` | Event: calls the component method of that name (event names must be lowercase) | `method(e, hit)`, see [Events](components.md#events-data-on-) |
17
+
18
+ Rules for holes:
19
+
20
+ - A hole must be a single identifier. `{{ data.name }}` and `{{ a + 1 }}` cause an error at codegen time.
21
+ - Holes are only for text nodes; they are not supported inside attributes (`title="{{ x }}"` is an error). When needed, get the element with `data-ref` and set it in TS.
22
+ - Content inside `<style>` and `<script>` is not treated as holes.
23
+ - A value of `null`, `undefined`, or `false` is displayed as an empty string.
24
+
25
+ A hidden `data-if` block is kept in memory, not destroyed: `refs` and holes inside it stay usable while it is hidden, so you can fill a form before showing it, and showing it again puts back the same elements with their state.
26
+
27
+ ## How Types Are Generated
28
+
29
+ Next to each `x.html`, an `x.html.ts` is generated (a `ts-dom-tags.ts` is generated as well, see [Composition](components.md#composition-using-multiple-components-together)); add `*.html.ts` and `ts-dom-tags.ts` to `.gitignore`, since both are rebuilt by `ts-dom gen`. It is a real TS module that contains both the parsed markup of this template (as data that the runtime turns into DOM nodes with DOM calls, so no HTML string is parsed in the browser) and its schema, so `tsc` can compile and check it directly.
30
+
31
+ ```ts
32
+ // user-card.html.ts (auto-generated)
33
+ import { template } from "@nativele/ts-dom";
34
+ import type { El } from "@nativele/ts-dom";
35
+ export default template<{
36
+ refs: { "toggle": El<"button"> };
37
+ lists: { "tags": { refs: Record<never, never>; text: "label"; ifs: "hot" } };
38
+ ifs: "admin";
39
+ slots: "extra";
40
+ text: "name";
41
+ }>([["h2", {}, ["Hello, {{ name }}!"]], /* ... */]);
42
+ ```
43
+
44
+ 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`.
45
+
46
+ ## Templates Without a Component
47
+
48
+ `tpl.instantiate()` builds a template without a custom element and returns the same typed handles a component has: `root` (a `DocumentFragment`), `refs`, `lists`, `text` and `show`. Use it for small pieces created in code, such as a menu item added to a list:
49
+
50
+ ```ts
51
+ import itemTpl from "./menu-item.html.js";
52
+
53
+ function menuItem(label: string): HTMLLIElement {
54
+ const view = itemTpl.instantiate();
55
+ view.text.label = label;
56
+ view.refs.button.addEventListener("click", () => select(label));
57
+ return view.rootElement; // typed by the template's top-level tag
58
+ }
59
+ ```
60
+
61
+ `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.
62
+
63
+ 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.
64
+
65
+ ## Trusted Types, Translations and Icons
66
+
67
+ - 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.
68
+ - Translations: ts-dom has no i18n. Write the translated string into a hole, `this.text.title = t("title")`, or into `render()` for attributes; the HTML stays free of words if you want it that way.
69
+ - Icons: ts-dom does not manage icons. Make an icon a custom element of your own that builds its SVG with `createElementNS` from whatever source you use, and place it in a template as a child component:
70
+
71
+ ```html
72
+ <app-icon name="send" class="size-4"></app-icon>
73
+ ```