@nativele/ts-dom 0.3.0 → 0.4.6

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
@@ -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;
@@ -125,7 +176,15 @@ export declare function event<T = void>(opts?: {
125
176
  type DetailOf<D> = D extends EventDecl<infer T> ? T : never;
126
177
  type EmitArgs<D> = [DetailOf<D>] extends [void] ? [detail?: undefined] : [detail: DetailOf<D>];
127
178
  /** The data passed to render(): state, props and derived values merged. */
128
- 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>;
129
188
  interface DataOptions<St extends object, P extends object, R, E extends Record<string, EventDecl<unknown>>> extends ComponentOptions {
130
189
  /** Data owned by the component and changed only through set(). Fields named like a {{ hole }} or data-if in the HTML bind automatically. */
131
190
  state?: St;
@@ -139,7 +198,7 @@ interface DataOptions<St extends object, P extends object, R, E extends Record<s
139
198
  * Derived values: computed from state and props, usable directly by {{ holes }}, data-if and render().
140
199
  * Recomputed after every data change (there is no dependency tracking).
141
200
  */
142
- derive?: { [K in keyof R]: (s: Readonly<St & P>) => R[K]; };
201
+ derive?: { [K in keyof R]: (s: DeepReadonly<St & P>) => R[K]; };
143
202
  /** The events this component emits. */
144
203
  emits?: E;
145
204
  }
@@ -195,14 +254,14 @@ interface ComponentInstance<S extends Schema> extends HTMLElement {
195
254
  /** Members added when the `state` / `props` / `derive` / `emits` options are used. */
196
255
  interface StatefulInstance<S extends Schema, St extends object, P extends object, R, E extends Record<string, EventDecl<unknown>>> extends ComponentInstance<S> {
197
256
  /** The component's own data (read-only). Use set() to change it. */
198
- readonly state: Readonly<St>;
257
+ readonly state: DeepReadonly<St>;
199
258
  /** Data provided by the parent (read-only). Do not assign it inside the component; use emit() to notify the parent. */
200
- readonly props: Readonly<P>;
259
+ readonly props: DeepReadonly<P>;
201
260
  /**
202
261
  * The only way to change state: shallow-merges the patch, and does nothing when no field actually changed.
203
262
  * When something changed it synchronously updates the same-named {{ holes }} and data-if, then calls render(). It cannot change props.
204
263
  */
205
- set(patch: Partial<St> | ((s: Readonly<St>) => Partial<St>)): void;
264
+ set(patch: Partial<DeepReadonly<St>> | ((s: DeepReadonly<St>) => Partial<DeepReadonly<St>>)): void;
206
265
  /** Coalesce several set() calls and prop changes: the view is updated and render() is called once, after fn returns. */
207
266
  batch(fn: () => void): void;
208
267
  /**
@@ -233,7 +292,7 @@ export declare function Component<S extends Schema>(tpl: Template<S>, opts?: Com
233
292
  * `reservedPropNames` (a prop shares a name with HTMLElement or a ts-dom member), `nativeEventNames` (emits shares a name with a native event).
234
293
  * (The check lives in the return type, not in the parameter: in the parameter it prevents TypeScript from inferring the type of derive.)
235
294
  */
236
- 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>;
295
+ 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>;
237
296
  type CheckHandlers<I> = I extends {
238
297
  readonly "~events"?: infer E;
239
298
  } ? [Exclude<NonNullable<E>, keyof I>] extends [never] ? unknown : {
@@ -244,4 +303,4 @@ type CheckHandlers<I> = I extends {
244
303
  * component as a public method, otherwise tsc reports an error that lists the missing names (`missingHandlers`).
245
304
  */
246
305
  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 };
306
+ 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
@@ -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.#reconcile(items, this.#keyOf, this.#assign, true, 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) {
@@ -232,7 +263,7 @@ var List = class {
232
263
  return this.#items.get(key);
233
264
  }
234
265
  #create(key) {
235
- const el = this.proto.cloneNode(true);
266
+ const el = this.binding ? document.importNode(this.proto, true) : this.proto.cloneNode(true);
236
267
  const holder = document.createDocumentFragment();
237
268
  holder.append(el);
238
269
  const conds = mountConds(holder);
@@ -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);
@@ -293,10 +324,14 @@ var List = class {
293
324
  }
294
325
  /** Keyed reconciliation: remove extra items, create missing ones, reorder, and call update() for every item. */
295
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) {
296
331
  const next = /* @__PURE__ */ new Set();
297
332
  for (const it of items) {
298
333
  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.`);
334
+ 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
335
  next.add(k);
301
336
  }
302
337
  for (const k of this.#items.keys()) if (!next.has(k)) this.remove(k);
@@ -309,13 +344,14 @@ var List = class {
309
344
  if (!entry) {
310
345
  entry = this.#create(k);
311
346
  this.#items.set(k, entry);
347
+ if (updateFirst) this.#run(entry, k, it, update);
312
348
  }
313
349
  if (entry.el.nextSibling !== after || entry.el.parentNode !== parent) {
314
350
  if (entry.el.parentNode === parent && parent?.moveBefore) parent.moveBefore(entry.el, after);
315
351
  else parent?.insertBefore(entry.el, after);
316
352
  }
317
353
  after = entry.el;
318
- this.#run(entry, k, it, update, opts.skipUnchanged);
354
+ this.#run(entry, k, it, update, skipUnchanged);
319
355
  }
320
356
  }
321
357
  };
@@ -332,7 +368,12 @@ function mountTemplate(frag, onItem) {
332
368
  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
369
  const anchor = document.createComment(`each:${name}`);
334
370
  t.replaceWith(anchor);
335
- lists[name] = new List(anchor, proto, onItem);
371
+ const key = t.getAttribute("data-key")?.trim();
372
+ const prop = t.getAttribute("data-prop")?.trim();
373
+ lists[name] = new List(anchor, proto, onItem, key && prop ? {
374
+ key,
375
+ prop
376
+ } : void 0);
336
377
  }
337
378
  const refs = {};
338
379
  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, 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;
@@ -98,7 +99,7 @@ function unusedMarkup(html, code) {
98
99
  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
100
  for (const [list, s] of a.lists) {
100
101
  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}`);
102
+ 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
103
  continue;
103
104
  }
104
105
  const items = [
@@ -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-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, 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-CP_i4VPA.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,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 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
+ 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 };