@nativele/ts-dom 0.4.0 → 0.5.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/dist/index.d.mts CHANGED
@@ -176,7 +176,15 @@ export declare function event<T = void>(opts?: {
176
176
  type DetailOf<D> = D extends EventDecl<infer T> ? T : never;
177
177
  type EmitArgs<D> = [DetailOf<D>] extends [void] ? [detail?: undefined] : [detail: DetailOf<D>];
178
178
  /** The data passed to render(): state, props and derived values merged. */
179
- type View<D, R> = Readonly<D> & Readonly<R>;
179
+ /**
180
+ * Read-only all the way down: arrays, objects, Maps and Sets inside it cannot be changed through it.
181
+ * State, props and derived values are typed this way, so changing an object in place instead of passing a new one is a tsc error.
182
+ */
183
+ type DeepReadonly<T> = T extends ((...args: never[]) => unknown) ? T : T extends Node | Date | RegExp | Promise<unknown> ? T : T extends ReadonlyMap<infer K, infer V> ? ReadonlyMap<K, DeepReadonly<V>> : T extends ReadonlySet<infer V> ? ReadonlySet<DeepReadonly<V>> : T extends readonly unknown[] ? { readonly [K in keyof T]: DeepReadonly<T[K]>; } : T extends object ? IsPlainObject<T> extends true ? { readonly [K in keyof T]: DeepReadonly<T[K]>; } : T : T;
184
+ type IsPlainObject<T> = { [K in keyof T]: T[K]; } extends T ? true : false;
185
+ /** Props as the element exposes them: the parent assigns them, but cannot change the objects inside through them. */
186
+ type PropAccessors<P> = { -readonly [K in keyof P]: DeepReadonly<P[K]>; };
187
+ type View<D, R> = DeepReadonly<D> & DeepReadonly<R>;
180
188
  interface DataOptions<St extends object, P extends object, R, E extends Record<string, EventDecl<unknown>>> extends ComponentOptions {
181
189
  /** Data owned by the component and changed only through set(). Fields named like a {{ hole }} or data-if in the HTML bind automatically. */
182
190
  state?: St;
@@ -190,7 +198,7 @@ interface DataOptions<St extends object, P extends object, R, E extends Record<s
190
198
  * Derived values: computed from state and props, usable directly by {{ holes }}, data-if and render().
191
199
  * Recomputed after every data change (there is no dependency tracking).
192
200
  */
193
- derive?: { [K in keyof R]: (s: Readonly<St & P>) => R[K]; };
201
+ derive?: { [K in keyof R]: (s: DeepReadonly<St & P>) => R[K]; };
194
202
  /** The events this component emits. */
195
203
  emits?: E;
196
204
  }
@@ -210,15 +218,25 @@ type Problems<S extends Schema, St, P, R, E> = ([keyof St | keyof P | keyof R] e
210
218
  });
211
219
  type IsClean<T> = unknown extends T ? true : false;
212
220
  type Lists<S extends Schema> = { [K in keyof S["lists"]]: List<S["lists"][K]>; };
213
- /** The second argument received by a data-on-* handler method. */
214
- interface EventHit<Item = unknown> {
221
+ /**
222
+ * The second argument received by a data-on-* handler method.
223
+ * In a handler for events inside list items, name the item type and its key field, e.g. `hit: EventHit<Todo, "id">`: `hit.item` is then a `Todo` and `hit.key` a `Todo["id"]`.
224
+ */
225
+ type EventHit<Item = unknown, KeyField extends keyof Item = never> = [KeyField] extends [never] ? {
215
226
  /** The element that carries the data-on-* attribute */
216
227
  readonly el: Element;
217
228
  /** When the event happened inside a list item: the key of that item */
218
229
  readonly key?: PropertyKey;
219
- /** When the event happened inside a list item: the data last passed to update() for that item. Annotate the handler, e.g. `hit: EventHit<Todo>`, to type it. */
230
+ /** When the event happened inside a list item: the data of that item. Annotate the handler, e.g. `hit: EventHit<Todo, "id">`, to type it. */
220
231
  readonly item?: Item;
221
- }
232
+ } : {
233
+ /** The element that carries the data-on-* attribute */
234
+ readonly el: Element;
235
+ /** The key of the list item the event happened in */
236
+ readonly key: Item[KeyField];
237
+ /** The data of the list item the event happened in */
238
+ readonly item: Item;
239
+ };
222
240
  interface ComponentInstance<S extends Schema> extends HTMLElement {
223
241
  /** Type inference only (define() uses it to check that data-on-* methods exist); does not exist at runtime. */
224
242
  readonly "~events"?: S["events"];
@@ -246,21 +264,21 @@ interface ComponentInstance<S extends Schema> extends HTMLElement {
246
264
  /** Members added when the `state` / `props` / `derive` / `emits` options are used. */
247
265
  interface StatefulInstance<S extends Schema, St extends object, P extends object, R, E extends Record<string, EventDecl<unknown>>> extends ComponentInstance<S> {
248
266
  /** The component's own data (read-only). Use set() to change it. */
249
- readonly state: Readonly<St>;
267
+ readonly state: DeepReadonly<St>;
250
268
  /** Data provided by the parent (read-only). Do not assign it inside the component; use emit() to notify the parent. */
251
- readonly props: Readonly<P>;
269
+ readonly props: DeepReadonly<P>;
252
270
  /**
253
271
  * The only way to change state: shallow-merges the patch, and does nothing when no field actually changed.
254
272
  * When something changed it synchronously updates the same-named {{ holes }} and data-if, then calls render(). It cannot change props.
255
273
  */
256
- set(patch: Partial<St> | ((s: Readonly<St>) => Partial<St>)): void;
274
+ set(patch: Partial<DeepReadonly<St>> | ((s: DeepReadonly<St>) => Partial<DeepReadonly<St>>)): void;
257
275
  /** Coalesce several set() calls and prop changes: the view is updated and render() is called once, after fn returns. */
258
276
  batch(fn: () => void): void;
259
277
  /**
260
278
  * Override it to handle updates that automatic binding cannot do (lists, attributes, focus, ...).
261
- * `view` is state, props and derived values merged; `changed` holds the fields that changed (all of them on the first render).
279
+ * `view` is state, props and derived values merged; `changed` holds the names of the state fields, props and derived values that changed (all of them on the first render).
262
280
  */
263
- render(view: View<St & P, R>, changed: ReadonlySet<keyof St | keyof P>): void;
281
+ render(view: View<St & P, R>, changed: ReadonlySet<keyof St | keyof P | keyof R>): void;
264
282
  /** Emit an event declared in `emits` (it does not bubble by default and only reaches this element itself). */
265
283
  emit<K extends keyof E & string>(name: K, ...detail: EmitArgs<E[K]>): void;
266
284
  /** Type inference only (EmitEvent uses it to get the detail type of an event); does not exist at runtime. */
@@ -284,7 +302,7 @@ export declare function Component<S extends Schema>(tpl: Template<S>, opts?: Com
284
302
  * `reservedPropNames` (a prop shares a name with HTMLElement or a ts-dom member), `nativeEventNames` (emits shares a name with a native event).
285
303
  * (The check lives in the return type, not in the parameter: in the parameter it prevents TypeScript from inferring the type of derive.)
286
304
  */
287
- export declare function Component<S extends Schema, St extends object = Record<never, never>, P extends object = Record<never, never>, R = Record<never, never>, E extends Record<string, EventDecl<unknown>> = Record<never, never>>(tpl: Template<S>, opts: DataOptions<St, P, R, E>): IsClean<Problems<S, St, P, R, E>> extends true ? new () => StatefulInstance<S, St, P, R, E> & P : Problems<S, St, P, R, E>;
305
+ export declare function Component<S extends Schema, St extends object = Record<never, never>, P extends object = Record<never, never>, R = Record<never, never>, E extends Record<string, EventDecl<unknown>> = Record<never, never>>(tpl: Template<S>, opts: DataOptions<St, P, R, E>): IsClean<Problems<S, St, P, R, E>> extends true ? new () => StatefulInstance<S, St, P, R, E> & PropAccessors<P> : Problems<S, St, P, R, E>;
288
306
  type CheckHandlers<I> = I extends {
289
307
  readonly "~events"?: infer E;
290
308
  } ? [Exclude<NonNullable<E>, keyof I>] extends [never] ? unknown : {
@@ -295,4 +313,4 @@ type CheckHandlers<I> = I extends {
295
313
  * component as a public method, otherwise tsc reports an error that lists the missing names (`missingHandlers`).
296
314
  */
297
315
  export declare function define<C extends abstract new (...args: never[]) => HTMLElement>(name: string, ctor: C & CheckHandlers<InstanceType<C>>): void;
298
- export type { ComponentInstance, ComponentOptions, DataOptions, El, EmitEvent, EventDecl, EventHit, ItemContext, ListItem, ListSchema, ReadableStore, ReplaceOptions, Schema, StatefulInstance, Store, Template, TemplateInstance, TextOf, TextValue, Update, View };
316
+ export type { ComponentInstance, ComponentOptions, DataOptions, DeepReadonly, El, EmitEvent, EventDecl, EventHit, ItemContext, ListItem, ListSchema, PropAccessors, ReadableStore, ReplaceOptions, Schema, StatefulInstance, Store, Template, TemplateInstance, TextOf, TextValue, Update, View };
package/dist/index.mjs CHANGED
@@ -228,7 +228,7 @@ var List = class {
228
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
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
230
  if (items.length) this.#checkProp();
231
- this.replace(items, this.#keyOf, this.#assign, { skipUnchanged: true });
231
+ this.#reconcile(items, this.#keyOf, this.#assign, true, true);
232
232
  }
233
233
  #checkProp() {
234
234
  const prop = this.binding?.prop;
@@ -263,7 +263,7 @@ var List = class {
263
263
  return this.#items.get(key);
264
264
  }
265
265
  #create(key) {
266
- const el = this.proto.cloneNode(true);
266
+ const el = this.binding ? document.importNode(this.proto, true) : this.proto.cloneNode(true);
267
267
  const holder = document.createDocumentFragment();
268
268
  holder.append(el);
269
269
  const conds = mountConds(holder);
@@ -324,6 +324,10 @@ var List = class {
324
324
  }
325
325
  /** Keyed reconciliation: remove extra items, create missing ones, reorder, and call update() for every item. */
326
326
  replace(items, key, update, opts = {}) {
327
+ this.#reconcile(items, key, update, opts.skipUnchanged === true, false);
328
+ }
329
+ /** `updateFirst`: a new item is updated before it is inserted, so an item component is connected with its prop already set. */
330
+ #reconcile(items, key, update, skipUnchanged, updateFirst) {
327
331
  const next = /* @__PURE__ */ new Set();
328
332
  for (const it of items) {
329
333
  const k = key(it);
@@ -340,13 +344,14 @@ var List = class {
340
344
  if (!entry) {
341
345
  entry = this.#create(k);
342
346
  this.#items.set(k, entry);
347
+ if (updateFirst) this.#run(entry, k, it, update);
343
348
  }
344
349
  if (entry.el.nextSibling !== after || entry.el.parentNode !== parent) {
345
350
  if (entry.el.parentNode === parent && parent?.moveBefore) parent.moveBefore(entry.el, after);
346
351
  else parent?.insertBefore(entry.el, after);
347
352
  }
348
353
  after = entry.el;
349
- this.#run(entry, k, it, update, opts.skipUnchanged);
354
+ this.#run(entry, k, it, update, skipUnchanged);
350
355
  }
351
356
  }
352
357
  };
@@ -657,7 +662,10 @@ function Component(tpl, opts = {}) {
657
662
  for (const [name, fn] of Object.entries(opts.derive ?? {})) {
658
663
  const v = fn(to);
659
664
  derived[name] = v;
660
- if (!from || !Object.is(this.#derived[name], v)) bindName(name, v);
665
+ if (!from || !Object.is(this.#derived[name], v)) {
666
+ bindName(name, v);
667
+ keys.push(name);
668
+ }
661
669
  }
662
670
  this.#derived = derived;
663
671
  this.render({
@@ -1,4 +1,4 @@
1
- import { a as analyze, d as listHtml, f as scanDefines } from "./a11y-Bn9pDvWV.mjs";
1
+ import { a as analyze, f as listHtml, h as scanDefines } from "./a11y-wWL9rie7.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { basename, dirname, relative } from "node:path";
4
4
  // src/lib/lint.ts
@@ -43,7 +43,8 @@ function componentClasses(code, htmlSpec) {
43
43
  const bodyEnd = matchClose(code, bodyStart, "{", "}");
44
44
  if (bodyEnd > 0) out.push({
45
45
  call: code.slice(call, callEnd + 1),
46
- body: code.slice(bodyStart, bodyEnd + 1)
46
+ body: code.slice(bodyStart, bodyEnd + 1),
47
+ end: bodyEnd
47
48
  });
48
49
  }
49
50
  return out;
@@ -144,7 +145,8 @@ const DATA_FLOW_RULES = {
144
145
  "custom-event-on-native": "a custom event belongs on the child component tag that emits it, not on a native element",
145
146
  "unknown-prop": "the attribute is not a declared prop of the child component",
146
147
  "invalid-prop-value": "the attribute value cannot be converted to the prop's type",
147
- "unknown-event": "the child component does not emit this event"
148
+ "unknown-event": "the child component does not emit this event",
149
+ "unguarded-input-value": "render() writes a form control's value without comparing it first, which moves the caret while the user types"
148
150
  };
149
151
  /** Common native events. A data-on-* on a native element whose event is not listed here is treated as a custom event. */
150
152
  const NATIVE_EVENTS = new Set(`click dblclick auxclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave contextmenu wheel keydown keyup keypress input beforeinput change submit reset invalid select focus blur focusin focusout scroll resize load unload error abort toggle cancel close drag dragstart dragend dragenter dragleave dragover drop copy cut paste compositionstart compositionupdate compositionend touchstart touchmove touchend touchcancel pointerdown pointerup pointermove pointerover pointerout pointerenter pointerleave pointercancel gotpointercapture lostpointercapture animationstart animationend animationiteration transitionstart transitionend transitionrun transitioncancel play pause ended playing timeupdate volumechange seeking seeked loadeddata loadedmetadata canplay waiting slotchange fullscreenchange online offline hashchange popstate storage message`.split(/\s+/));
@@ -307,6 +309,20 @@ function dataFlow(html, classes, ignore = [], contracts = /* @__PURE__ */ new Ma
307
309
  const tag = /El<"([^"]+)">/.exec(type)?.[1];
308
310
  if (tag?.includes("-")) customRefs.set(name, tag);
309
311
  }
312
+ const formControls = new Set([...a.scope.refs].filter(([, type]) => /El<"(input|textarea|select)">/.test(type)).map(([name]) => name));
313
+ for (const { body } of classes) {
314
+ const render = methodBody(body, "render");
315
+ if (!render || !formControls.size) continue;
316
+ const lines = render.split("\n");
317
+ lines.forEach((line, i) => {
318
+ for (const m of line.matchAll(/\brefs\.(\w+)\.value\s*=(?!=)/g)) {
319
+ const ref = m[1];
320
+ if (!formControls.has(ref)) continue;
321
+ if ([line, lines[i - 1] ?? ""].some((l) => new RegExp(`refs\\.${esc(ref)}\\.value\\s*!==?`).test(l))) continue;
322
+ report("unguarded-input-value", `render() assigns this.refs.${ref}.value without comparing it first. Writing a form control's value while the user types moves the caret to the end. Fix: if (this.refs.${ref}.value !== next) this.refs.${ref}.value = next;`, `ref:${ref}`);
323
+ }
324
+ });
325
+ }
310
326
  for (const { call, body } of classes) {
311
327
  const props = optionKeys(call, "props");
312
328
  for (const k of props) if (new RegExp(`\\bthis\\.${esc(k)}\\s*(?:[-+*/%]?=(?!=)|\\+\\+|--)`).test(body)) report("props-mutation", `The prop "${k}" is controlled by the parent component; do not assign this.${k} inside the component. Fix: emit an event so the parent changes it, or keep the value in state instead.`);
@@ -4,6 +4,13 @@ interface ImportNode {
4
4
  value: string;
5
5
  };
6
6
  }
7
+ interface Fixer {
8
+ insertTextBeforeRange(range: [number, number], text: string): unknown;
9
+ }
10
+ interface Suggestion {
11
+ desc: string;
12
+ fix(fixer: Fixer): unknown;
13
+ }
7
14
  interface Context {
8
15
  options?: [{
9
16
  ignore?: string[];
@@ -15,6 +22,7 @@ interface Context {
15
22
  report(d: {
16
23
  message: string;
17
24
  node: unknown;
25
+ suggest?: Suggestion[];
18
26
  }): void;
19
27
  }
20
28
  declare const _default: {
@@ -22,7 +30,7 @@ declare const _default: {
22
30
  name: string;
23
31
  };
24
32
  rules: {
25
- /** The HTML declares markup that this file never uses. Always on when enabled; reported as errors. */
33
+ /** The HTML declares markup that this file never uses. Reported as errors. */
26
34
  "unused-markup": {
27
35
  meta: {
28
36
  type: "suggestion";
@@ -41,6 +49,7 @@ declare const _default: {
41
49
  };
42
50
  additionalProperties: boolean;
43
51
  }[];
52
+ hasSuggestions: boolean;
44
53
  };
45
54
  create(context: Context): {
46
55
  ImportDeclaration(node: ImportNode): void;
@@ -65,6 +74,7 @@ declare const _default: {
65
74
  };
66
75
  additionalProperties: boolean;
67
76
  }[];
77
+ hasSuggestions: boolean;
68
78
  };
69
79
  create(context: Context): {
70
80
  ImportDeclaration(node: ImportNode): void;
@@ -89,6 +99,7 @@ declare const _default: {
89
99
  };
90
100
  additionalProperties: boolean;
91
101
  }[];
102
+ hasSuggestions: boolean;
92
103
  };
93
104
  create(context: Context): {
94
105
  ImportDeclaration(node: ImportNode): void;
@@ -1,5 +1,5 @@
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";
1
+ import { n as checkA11y, t as A11Y_RULES } from "./a11y-wWL9rie7.mjs";
2
+ import { a as dataFlow, i as contractsFor, l as templateCode, n as UNUSED_MARKUP_RULES, r as componentClasses, t as DATA_FLOW_RULES, u as unusedMarkup } from "./lint-5vo_ifIc.mjs";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { dirname, resolve } from "node:path";
5
5
  // src/lib/oxlint-plugin.ts
@@ -14,46 +14,86 @@ const ignoreSchema = [{
14
14
  /** Child component contracts, scanned once per process from the working directory. */
15
15
  let contracts = null;
16
16
  const getContracts = () => contracts ??= contractsFor([process.cwd()]);
17
+ const cache = /* @__PURE__ */ new Map();
18
+ function groupDiagnostics(group, html, code, spec, filename) {
19
+ const key = `${group}\0${filename}\0${spec}`;
20
+ const hit = cache.get(key);
21
+ if (hit && hit.code === code && hit.html === html) return hit.diags;
22
+ const diags = group === "unused-markup" ? unusedMarkup(html, templateCode(code, spec)) : group === "a11y" ? checkA11y(html) : dataFlow(html, componentClasses(code, spec), [], getContracts());
23
+ cache.set(key, {
24
+ code,
25
+ html,
26
+ diags
27
+ });
28
+ return diags;
29
+ }
30
+ function readTemplate(filename, spec) {
31
+ try {
32
+ return readFileSync(resolve(dirname(filename), spec.slice(0, -3)), "utf8");
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+ /** A missing handler can be added to the class that uses the template: offered as a suggestion, since the method body is the developer's to write. */
38
+ function suggestionsFor(d, code, spec) {
39
+ if (d.rule !== "missing-handler") return void 0;
40
+ const name = /="([^"]+)"/.exec(d.message)?.[1];
41
+ const target = componentClasses(code, spec)[0];
42
+ if (!name || !target) return void 0;
43
+ return [{
44
+ desc: `Add a method ${name}() to the component class`,
45
+ fix: (fixer) => fixer.insertTextBeforeRange([target.end, target.end], `\n ${name}(_e: Event): void {}\n`)
46
+ }];
47
+ }
17
48
  /**
18
- * Build an oxlint rule that runs `check` on every `import x from "./name.html.js"` and reports the findings
49
+ * Build an oxlint rule that checks every `import x from "./name.html.js"` and reports the findings
19
50
  * on the import line (an oxlint plugin can only report inside the file being linted).
20
51
  * The message starts with the HTML location, the rule id and what to do: `./x.html.js:3:5 [unused-ref] ...`.
52
+ * `only` limits the rule to one rule id of the group; without it the rule reports the whole group, minus `ignore`.
21
53
  */
22
- function templateRule(description, check) {
54
+ function templateRule(group, description, only) {
23
55
  return {
24
56
  meta: {
25
57
  type: "suggestion",
26
58
  docs: { description },
27
- schema: ignoreSchema
59
+ schema: only ? [] : ignoreSchema,
60
+ hasSuggestions: true
28
61
  },
29
62
  create(context) {
30
63
  return { ImportDeclaration(node) {
31
64
  const spec = node.source.value;
32
65
  if (!spec.endsWith(".html.js")) return;
33
- let html;
34
- try {
35
- html = readFileSync(resolve(dirname(context.filename), spec.slice(0, -3)), "utf8");
36
- } catch {
37
- return;
66
+ const html = readTemplate(context.filename, spec);
67
+ if (html === null) return;
68
+ const code = context.sourceCode.text;
69
+ const ignore = context.options?.[0]?.ignore ?? [];
70
+ for (const d of groupDiagnostics(group, html, code, spec, context.filename)) {
71
+ if (only ? d.rule !== only : ignore.includes(d.rule)) continue;
72
+ const suggest = suggestionsFor(d, code, spec);
73
+ context.report({
74
+ message: `${spec}:${d.line}:${d.col} [${d.rule}] ${d.message}`,
75
+ node,
76
+ ...suggest ? { suggest } : {}
77
+ });
38
78
  }
39
- for (const d of check(html, context, spec)) context.report({
40
- message: `${spec}:${d.line}:${d.col} [${d.rule}] ${d.message}`,
41
- node
42
- });
43
79
  } };
44
80
  }
45
81
  };
46
82
  }
47
- const ignored = (c) => c.options?.[0]?.ignore ?? [];
83
+ const perRule = (group, rules) => Object.fromEntries(Object.entries(rules).map(([id, description]) => [id, templateRule(group, description, id)]));
84
+ const { "no-importer": _noImporter, ...unusedMarkupRules } = UNUSED_MARKUP_RULES;
48
85
  var oxlint_plugin_default = {
49
86
  meta: { name: "ts-dom" },
50
87
  rules: {
51
- /** The HTML declares markup that this file never uses. Always on when enabled; reported as errors. */
52
- "unused-markup": templateRule("Markup declared in the HTML (data-ref, data-if, data-each, {{ }}, data-on-*) must be used by the component class", (html, c, spec) => unusedMarkup(html, templateCode(c.sourceCode.text, spec)).filter((d) => !ignored(c).includes(d.rule))),
88
+ /** The HTML declares markup that this file never uses. Reported as errors. */
89
+ "unused-markup": templateRule("unused-markup", "Markup declared in the HTML (data-ref, data-if, data-each, {{ }}, data-on-*) must be used by the component class"),
53
90
  /** ARIA and basic accessibility problems in the template. Off by default. */
54
- a11y: templateRule("Check the template for ARIA and basic accessibility problems", (html, c) => checkA11y(html, ignored(c))),
91
+ a11y: templateRule("a11y", "Check the template for ARIA and basic accessibility problems"),
55
92
  /** Data flow suggestions and child component contract checks. Off by default. */
56
- "data-flow": templateRule("Keep props flowing down and events flowing up one level at a time, and check child component attributes and events", (html, c, spec) => dataFlow(html, componentClasses(c.sourceCode.text, spec), ignored(c), getContracts()))
93
+ "data-flow": templateRule("data-flow", "Keep props flowing down and events flowing up one level at a time, and check child component attributes and events"),
94
+ ...perRule("unused-markup", unusedMarkupRules),
95
+ ...perRule("data-flow", DATA_FLOW_RULES),
96
+ ...perRule("a11y", Object.fromEntries(Object.entries(A11Y_RULES).map(([k, v]) => [k, v.split(". Fix:")[0]])))
57
97
  }
58
98
  };
59
99
  export { oxlint_plugin_default as default };
@@ -1,4 +1,4 @@
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";
1
+ import { c as generateAll, d as isSelected, f as listHtml, h as scanDefines, i as CodegenError, l as generateFile, n as checkA11y, s as formatDiagnostics, u as isFullPage } from "./a11y-wWL9rie7.mjs";
2
2
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
3
3
  import { resolve, sep } from "node:path";
4
4
  // src/lib/vite-plugin.ts
package/llms.txt CHANGED
@@ -1,8 +1,8 @@
1
1
  # Nativele
2
2
 
3
- > A TypeScript-driven, HTML-first UI library. Bind HTML templates to TypeScript custom elements. HTML describes structure and where data goes; TypeScript owns state and behavior; the DOM is the only view. Fully typed by tsc, no virtual DOM, no bundler required.
3
+ > Designed for AI agents. Driven by TypeScript. Defined in vanilla HTML. A lightweight library for building web components: bind HTML templates to TypeScript custom elements. HTML describes structure and where data goes; TypeScript owns state and behavior; the DOM is the only view. Fully typed by tsc, no virtual DOM, no bundler required.
4
4
 
5
- Workflow: write `x.html` + `x.ts`, run `npx ts-dom gen` (writes `x.html.ts`), then `npx tsc`. Import a template as `import tpl from "./x.html.js"` (note the `.js`). Lint with `npx ts-dom lint` (add `--a11y`). `npx ts-dom rules` lists every rule id. Every diagnostic is `file:line:col severity [rule-id] message` and ends with a `Fix:` hint: read the hint and apply it.
5
+ Workflow: write `x.html` + `x.ts`, run `npx ts-dom check` (regenerates `x.html.ts`, runs tsc and lint, points errors at the template). Import a template as `import tpl from "./x.html.js"` (note the `.js`). Lint with `npx ts-dom lint` (add `--a11y`). `npx ts-dom rules` lists every rule id. Every diagnostic is `file:line:col severity [rule-id] message` and ends with a `Fix:` hint: read the hint and apply it.
6
6
 
7
7
  ## Template syntax (HTML only names things; no expressions)
8
8
  - `{{ name }}`: text hole, a single identifier only. NOT `{{ a.b }}` / `{{ a + 1 }}`. Not allowed in attributes.
@@ -38,11 +38,14 @@ define("my-counter", MyCounter);
38
38
  Rules the compiler enforces: every `{{ hole }}` / `data-if` name must exist in state, props or derive (error `missingStateKeys`); state/props/derive names must be unique (`conflictingKeys`); a prop must not be named like an HTMLElement/ts-dom member such as `title`, `hidden`, `render` (`reservedPropNames`); emitted event names must not be native events (`nativeEventNames`); every `data-on-*` method must exist as a public method (`missingHandlers`, reported by `define()`).
39
39
 
40
40
  ## Data flow (one direction)
41
+ - `state`, `props`, `render`'s `view` and `derive` arguments are `DeepReadonly`: changing an object or array inside them is a tsc error. Replace with new objects (`s.items.map((i) => (i.id === id ? { ...i, done: true } : i))`). Annotate with read-only types (`readonly Item[]`, `DeepReadonly<State>`).
41
42
  - State: changed only by its owner with `this.set(patch | s => patch)`; no change => no render. `this.batch(fn)` merges several updates. Rendering is synchronous.
42
43
  - Props go DOWN: the parent sets them with an attribute or a property (`this.refs.child.value = v.x`, typed). A component never assigns its own props; to ask for a change it `emit`s an event.
43
44
  - Events go UP: `emit` does not bubble; the parent handles it with `data-on-<event>` on the child tag, casting with `EmitEvent<Child, "event-name">`.
44
45
  - Data needed several levels away: share a `createStore` module and `this.bind(store, fn)`; do not forward props or re-emit events through middle components (lint rules `prop-forwarding`, `emit-forwarding`).
45
- - Writing form values back: only assign `input.value` when it differs (`if (input.value !== v.value) input.value = v.value`), otherwise the caret jumps.
46
+ - `store.set` REPLACES the value (a component's `this.set` merges): `store.set((s) => ({ ...s, status: "loading" }))`. In a component with state/props/derive every `{{ hole }}` must be one of them, so copy store data into state in the `bind` callback: `this.bind(store, (s) => this.set({ total: s.items.length }))`.
47
+ - Loading/error states, modal dialogs (a public `open()` called through a typed ref), form validation messages, filtered lists: `docs/recipes.md` and `examples/recipes/` in the repository.
48
+ - Writing form values back (lint rule `unguarded-input-value`): only assign `input.value` when it differs (`if (input.value !== v.value) input.value = v.value`), otherwise the caret jumps.
46
49
 
47
50
  ## Minimal example
48
51
  ```html
@@ -68,13 +71,21 @@ define("my-counter", MyCounter);
68
71
  `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
72
 
70
73
  ## 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.
74
+ - Rule: an item with anything of its own (state, events, a nested list) is a component filled with `items =`; an item of a few texts and `data-if` is plain elements filled with `replace()`.
75
+ - Subscribe in `connected()`, never in the constructor: without `moveBefore` a moved item is disconnected (its `bind`/`on`/`onCleanup` released) and connected again. Data every item needs goes in a `createStore` the item binds.
76
+ - Full example (add, remove, sort, toggle, child emit): `examples/readme/task-board.ts` and `task-row.ts` in the repository.
77
+ - Items that are components: `<template data-each="todos" data-key="id" data-prop="todo"><todo-row></todo-row></template>`, then `this.lists.todos.items = view.todos` (inside `render`, guarded by `changed.has("todos")`; `changed` holds the state, prop and derived names that changed, so a derived list is guarded by its own name). Keeps the element of each key, creates and removes the rest, sets the `todo` prop of each changed item; `hit.key` in handlers (`hit: EventHit<Todo, "id">` types `hit.key` and `hit.item`). The item component declares `todo` in its `props`; `tsc` checks the items against that prop type and the `data-key` field; an item component defined in a scanned folder must be exported and listed in `ts-dom-tags.ts` (run `ts-dom gen`), otherwise the assignment is a type error that names the problem.
72
78
  - Items that are plain elements: `<template data-each="x">` with `this.lists.x.replace(items, keyFn, (item, { text, show }) => ...)`.
73
79
  - `data-each` is never nested: a list inside an item is the item component's own `data-each`.
74
80
 
75
81
  ## 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.
82
+ - Skills: `npx ts-dom skills` lists the task procedures shipped with the package (`nativele-core` for building components, `nativele-react` for React 19, ...), each with verified versions; `npx ts-dom skills <name>` prints one, `npx ts-dom skills add <name>` copies it into `.claude/skills/` or `.agents/skills/`, and `npx ts-dom doctor` suggests the ones that match the project's dependencies.
83
+ - `npx ts-dom rules --explain=<id>` (or `rules --json`) explains any rule id from a diagnostic: why it exists, a wrong example and the fix.
84
+ - Markup well-formedness is a formatter's job: `oxfmt --check` reports misnested tags that the library's parser would silently repair. Generate with `--whitespace=collapse` when a formatter is used.
85
+ - If checks behave oddly (no types, a template not found), run `npx ts-dom doctor --json` first: it reports tsconfig, import path, runtime, generated file and plugin problems with a `Fix:` each.
86
+ - `npx ts-dom gen --check` (writes nothing, exit 1) tells whether the generated files are stale before you trust `tsc` errors.
87
+ - Never edit `x.html.ts` or `ts-dom-tags.ts` (generated). After any `.html` or component change run `npx ts-dom check --json`: it regenerates, runs tsc, maps template contract errors to the template line/col (`missing-binding`, `missing-handler`, item rules), and runs lint. Fix every issue by its `Fix:` hint; never cast a type error away.
88
+ - No expressions in HTML; computed values go in `derive`. Change state only with `this.set()`. Lists, attributes and focus go in `render(view, changed)`; check `changed.has("items")` before rebuilding a list, and annotate `changed` with the names you check (`ReadonlySet<"items" | "visible">`), not `ReadonlySet<string>`, so tsc checks them.
78
89
  - Props go down (parent sets them), events go up (`emit`, handled with `data-on-<event>` on the child tag). `data-each` cannot be nested: make the inner list its own component.
79
90
 
80
91
  ## Setup
@@ -87,9 +98,11 @@ In a browser without a bundler map the bare name once: `<script type="importmap"
87
98
  ## Pitfalls
88
99
  - Import templates with `./x.html.js`, not `./x.html`. Run `ts-dom gen` before `tsc` (it creates `x.html.ts` and `ts-dom-tags.ts`; both are generated, do not edit or commit).
89
100
  - Exports: `export class` the component to get typed `refs.child` in parent templates (`define("tag", Class)` is scanned).
90
- - `data-if` content is hidden until the name is truthy; `{{ hole }}` stays empty until filled.
101
+ - `data-if` content is hidden until the name is truthy (it stays in memory, so its `refs` still work); `{{ hole }}` stays empty until filled.
102
+ - With Vite, `package.json` needs `"type": "module"`.
91
103
  - `slotchange()` is Light-DOM only; with `shadow: true` use the native `slotchange` event.
92
104
 
93
105
  ## Docs
94
- - [README](README.md): full guide. [README.zh-TW.md](README.zh-TW.md): same in Traditional Chinese.
106
+ - [README](README.md): introduction, the short version, rules for agents, quick start. [README.zh-TW.md](README.zh-TW.md): the same in Traditional Chinese.
107
+ - Guides (English): [templates](docs/templates.md), [components and data flow](docs/components.md), [lists](docs/lists.md), [setup, checks and lint](docs/tooling.md), [performance](docs/performance.md), [design](docs/design.md), [developing Nativele](docs/contributing.md).
95
108
  - `npx ts-dom rules`: every rule id with a description.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nativele/ts-dom",
3
- "version": "0.4.0",
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.",
3
+ "version": "0.5.0",
4
+ "description": "Designed for AI agents. Driven by TypeScript. Defined in vanilla HTML. A lightweight library for building web components: typed templates, native custom elements, no virtual DOM, no template expressions, no bundler required.",
5
5
  "keywords": [
6
6
  "html",
7
7
  "typescript",
@@ -49,6 +49,8 @@
49
49
  "README.md",
50
50
  "README.zh-TW.md",
51
51
  "llms.txt",
52
+ "skills/README.md",
53
+ "skills/*/SKILL.md",
52
54
  "LICENSE",
53
55
  "CHANGELOG.md"
54
56
  ],
@@ -68,7 +70,8 @@
68
70
  "test:browser": "vp test run browser/",
69
71
  "verify:pack": "vp pack --publint --attw",
70
72
  "smoke": "bash scripts/consumer-smoke.sh",
71
- "smoke:no-bundler": "bash scripts/no-bundler-smoke.sh"
73
+ "smoke:no-bundler": "bash scripts/no-bundler-smoke.sh",
74
+ "bench": "node scripts/bench.ts"
72
75
  },
73
76
  "dependencies": {
74
77
  "parse5": "^8.1.0"
@@ -0,0 +1,114 @@
1
+ # Nativele Skills
2
+
3
+ Skills extend what Nativele can be used with, without growing the library: each one is a task procedure for a coding agent, verified with exact versions. This page is the specification every Skill follows and the guide for contributing one.
4
+
5
+ ## What a Skill is
6
+
7
+ A Skill is a task procedure for a coding agent: how to do one integration or one kind of work with Nativele, step by step, with a complete example, the known limitations, and commands that prove it worked. It is not reference documentation: facts about the library's API live in `docs/`, and a Skill links to them instead of restating them.
8
+
9
+ A Skill never changes the library's runtime and never adds a dependency to the library's core. Third-party packages a Skill uses are dependencies of the user's project, installed by the Skill's own steps.
10
+
11
+ ## Layout
12
+
13
+ ```
14
+ skills/
15
+ ├── README.md # this specification and the contribution guide
16
+ ├── nativele-core/
17
+ │ └── SKILL.md
18
+ ├── nativele-react/
19
+ │ ├── SKILL.md
20
+ │ └── example/ # Official only: the project the Skill is verified with
21
+ │ ├── package.json # exact versions of every tool the Skill names
22
+ │ ├── ... # the minimal app the SKILL.md example comes from
23
+ │ └── verify.mjs # the browser checks `npm run verify` ends with
24
+ └── ...
25
+ ```
26
+
27
+ The npm package ships every `SKILL.md` (so the CLI can list and read them offline), not the `example/` folders.
28
+
29
+ ## SKILL.md format
30
+
31
+ `SKILL.md` follows the Agent Skills format (YAML frontmatter with `name` and `description`, then Markdown), so Claude Code and other agents that read skills can load it as it is. Nativele's own fields go under `metadata`:
32
+
33
+ ```markdown
34
+ ---
35
+ name: nativele-react
36
+ description: "Use Nativele web components inside a React 19 app: props, events, children and slots, lifecycle, and TSX types. Use when a project has react and @nativele/ts-dom."
37
+ metadata:
38
+ tier: official # official | community | experimental
39
+ nativele: ">=0.5.0 <0.6.0" # the library versions the Skill was verified with
40
+ verified: "2026-11-05" # date of the last verification
41
+ tools: "react@19.3.1 react-dom@19.3.1 vite@8.3.5 @vitejs/plugin-react@6.2.1 typescript@7.0.2"
42
+ author: "Nativele maintainers" # for community Skills: the contributor
43
+ detects: "react" # package.json dependencies that make ts-dom doctor recommend this Skill
44
+ ---
45
+ ```
46
+
47
+ Quote the `description` and any value that contains `: `, so the frontmatter stays valid YAML for every agent that loads it.
48
+
49
+ Required sections, in this order:
50
+
51
+ 1. **When to use**: the situation and the signals (dependencies, files) that call for this Skill, and when not to use it.
52
+ 2. **Versions**: a table of every tool and the exact version it was verified with, and the date. If the project uses a different major version of a tool, the Skill says so and stops treating its steps as verified.
53
+ 3. **Set-up**: numbered steps with the exact commands and file contents.
54
+ 4. **Example**: a complete, minimal working example (every file it needs, short).
55
+ 5. **Known limitations**: what does not work or needs care, each with what to do instead. An agent must never have to guess these.
56
+ 6. **Verify**: the commands that prove the integration works in the user's project (for example `npx ts-dom check`, `npx tsc --noEmit`, the project's build, a test), with the output to expect.
57
+ 7. **Troubleshooting**: symptoms seen while verifying, their cause and fix.
58
+ 8. **See also**: links to the guides in `docs/` for the facts used.
59
+
60
+ Rules for the content:
61
+
62
+ - Only what was run: every step, example and limitation comes from the verified example, not from memory of how a tool used to work.
63
+ - Version-bound statements say so ("React 19 passes object props as properties; React 18 passes everything as attributes").
64
+ - No promises beyond the verified versions.
65
+ - English, plain and concrete; no restating of `docs/`.
66
+
67
+ ## Tiers
68
+
69
+ | Tier | Who maintains it | Requirements | Shown by `ts-dom skills` |
70
+ |---|---|---|---|
71
+ | Official | the Nativele maintainers | an `example/` project that `npm run verify` checks in CI; verified within the last 6 months | as official |
72
+ | Community | its author | the full SKILL.md format, `author` and `verified` filled in, the verification commands run by the author | as community, with the author |
73
+ | Experimental | its author | the SKILL.md format; verification not complete | as experimental, with a warning |
74
+
75
+ - A new contribution starts as Experimental. It becomes Community when a reviewer has run its verification commands with the stated versions, and Official when the maintainers adopt it with an `example/` project in CI.
76
+ - A Skill whose `verified` date is older than 6 months, or whose `nativele` range does not include the installed version, is shown as stale. A stale Official Skill whose CI check fails is moved to Experimental until fixed.
77
+
78
+ ## Delivering Skills to projects
79
+
80
+ The library already lives in the user's `node_modules`, so its Skills travel with it:
81
+
82
+ - `ts-dom skills` lists the Skills shipped with the installed version: name, tier, description, verified date and versions, whether it is stale, and its path inside `node_modules`. `--json` prints the same as data for agents.
83
+ - `ts-dom skills <name>` prints that Skill (so an agent can read it without knowing the path).
84
+ - `ts-dom skills add <name...>` copies Skills into the project: into `.claude/skills/<name>/` when the project has a `.claude` folder, otherwise into `.agents/skills/<name>/` (`--to=<dir>` for another folder), where agents that load skills pick them up. It only copies; it never installs the third-party packages a Skill mentions.
85
+ - `ts-dom doctor` reads the project's `package.json` and, for each Skill whose `detects` dependency is present and that the project has no copy of, reports it as an `info` issue (`skill-available`); a copy that differs from the shipped one is reported as `skill-outdated`, for example: `package.json:1:1 info [skill-available] react is a dependency: the nativele-react Skill covers using Nativele components in React. Fix: run ts-dom skills add nativele-react, or read it with ts-dom skills nativele-react.` Info issues never change the exit code.
86
+
87
+ None of this adds a runtime dependency: listing and copying read the Markdown files with Node's file API, and the frontmatter is parsed with a small reader for the fields above.
88
+
89
+ ## Verification of Official Skills
90
+
91
+ - `skills/<name>/example/package.json` pins every tool version the Skill names, depends on the library through `file:../../..` (the library in the same repository), and has `npm run verify`, which runs `ts-dom check`, `ts-dom doctor`, `tsc`, the build, and a short real-browser test of what the Skill promises.
92
+ - `nativele-core` has no example of its own: the library's CI, which checks every example in the repository, verifies it.
93
+ - The workflow `.github/workflows/skills.yml` runs `npm run verify` for each Official Skill when anything under that Skill changes, when the library's runtime changes, and once a month, so a third-party release that breaks a Skill is found. It is not a required check for `main`.
94
+ - The monthly run opens nothing by itself; a failure is reported in the workflow run, and the Skill is fixed or moved to Experimental.
95
+
96
+ ## Contributing a Skill
97
+
98
+ - Copy `skills/_template/SKILL.md`, fill in every section, set `tier: experimental`.
99
+ - Run the Skill's verification commands in a fresh project with the versions you list, and paste their output in the PR description.
100
+ - The PR touches only `skills/<name>/`; a PR that changes the library and adds a Skill is split. Community and Experimental Skills live in this repository, in the same `skills/` folder, so they ship with the package and `ts-dom skills` lists them with their tier.
101
+ - Review checklist: frontmatter complete; every step reproducible; limitations listed; verification commands and their output present; no restating of `docs/`; nothing that would add a dependency to the core.
102
+
103
+ ## Skills
104
+
105
+ | Skill | Tier | Status | Covers |
106
+ |---|---|---|---|
107
+ | `nativele-core` | Official | available | building with Nativele itself: the `ts-dom check` loop, components, lists, data flow |
108
+ | `nativele-react` | Official | available | Nativele components in React 19: props, events, children and slots, lifecycle, TSX types |
109
+ | `nativele-integration` | Official | planned | what every framework integration shares: properties and attributes, events, slots, lifecycle, types |
110
+ | `nativele-vite` | Official | planned | the Vite plugin, `root`, hot updates |
111
+ | `nativele-vue` | Official | planned | Nativele components in Vue |
112
+ | `nativele-state-management` | Official | planned | `createStore`, and Zustand or Redux through `bind` |
113
+ | `nativele-testing` | Official | planned | Vitest and Playwright |
114
+ | Svelte, Angular, Tailwind CSS, TanStack Query, Zod | Experimental or Community | open for contributions | |