@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/CHANGELOG.md +74 -0
- package/README.md +203 -17
- package/README.zh-TW.md +203 -17
- package/dist/{a11y-CeZcA10z.mjs → a11y-wWL9rie7.mjs} +175 -29
- package/dist/cli.mjs +1143 -22
- package/dist/index.d.mts +67 -8
- package/dist/index.mjs +48 -7
- package/dist/{lint-DS-IOjkf.mjs → lint-CP_i4VPA.mjs} +4 -3
- package/dist/oxlint-plugin.d.mts +12 -1
- package/dist/oxlint-plugin.mjs +59 -19
- package/dist/vite-plugin.d.mts +29 -6
- package/dist/vite-plugin.mjs +98 -19
- package/llms.txt +20 -2
- package/package.json +4 -3
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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]
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 = [
|
package/dist/oxlint-plugin.d.mts
CHANGED
|
@@ -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.
|
|
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;
|
package/dist/oxlint-plugin.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { n as checkA11y } from "./a11y-
|
|
2
|
-
import { a as dataFlow, i as contractsFor, l as templateCode, r as componentClasses, u as unusedMarkup } from "./lint-
|
|
1
|
+
import { n as checkA11y, 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
|
|
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,
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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.
|
|
52
|
-
"unused-markup": templateRule("Markup declared in the HTML (data-ref, data-if, data-each, {{ }}, data-on-*) must be used by the component class"
|
|
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"
|
|
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"
|
|
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 };
|
package/dist/vite-plugin.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export interface TsDomPluginOptions {
|
|
2
|
-
/** Directories to scan for .html files and generate .html.ts into. Default: ["src"] */
|
|
2
|
+
/** Directories to scan for .html files and generate .html.ts into, relative to Vite's root. Default: ["src"] */
|
|
3
3
|
dirs?: string[];
|
|
4
4
|
/** Module name the generated files import the runtime from. Default: "@nativele/ts-dom" */
|
|
5
5
|
runtime?: string;
|
|
@@ -14,6 +14,24 @@ export interface TsDomPluginOptions {
|
|
|
14
14
|
ignore?: string[];
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
+
interface Logger {
|
|
18
|
+
error(m: string): void;
|
|
19
|
+
warn(m: string): void;
|
|
20
|
+
}
|
|
21
|
+
interface HotServer {
|
|
22
|
+
config: {
|
|
23
|
+
logger: Logger;
|
|
24
|
+
};
|
|
25
|
+
moduleGraph: {
|
|
26
|
+
getModulesByFile(f: string): Set<unknown> | undefined;
|
|
27
|
+
invalidateModule(m: unknown): void;
|
|
28
|
+
};
|
|
29
|
+
ws: {
|
|
30
|
+
send(payload: {
|
|
31
|
+
type: "full-reload";
|
|
32
|
+
}): void;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
17
35
|
/**
|
|
18
36
|
* Optional Vite plugin: generates `.html.ts` when the build starts and regenerates it while the dev server runs.
|
|
19
37
|
* ts-dom does not depend on it: `ts-dom gen` (or `--watch`) together with tsc is enough.
|
|
@@ -21,6 +39,9 @@ export interface TsDomPluginOptions {
|
|
|
21
39
|
export declare function tsDom(opts?: TsDomPluginOptions): {
|
|
22
40
|
name: string;
|
|
23
41
|
enforce: "pre";
|
|
42
|
+
configResolved(config: {
|
|
43
|
+
root: string;
|
|
44
|
+
}): void;
|
|
24
45
|
buildStart(this: {
|
|
25
46
|
error(m: string): never;
|
|
26
47
|
warn(m: string): void;
|
|
@@ -31,10 +52,12 @@ export declare function tsDom(opts?: TsDomPluginOptions): {
|
|
|
31
52
|
on(ev: string, fn: (f: string) => void): void;
|
|
32
53
|
};
|
|
33
54
|
config: {
|
|
34
|
-
logger:
|
|
35
|
-
error(m: string): void;
|
|
36
|
-
warn(m: string): void;
|
|
37
|
-
};
|
|
55
|
+
logger: Logger;
|
|
38
56
|
};
|
|
39
57
|
}): void;
|
|
40
|
-
|
|
58
|
+
handleHotUpdate(ctx: {
|
|
59
|
+
file: string;
|
|
60
|
+
server: HotServer;
|
|
61
|
+
}): never[] | undefined;
|
|
62
|
+
};
|
|
63
|
+
export {};
|
package/dist/vite-plugin.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { c as
|
|
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
|
|
42
|
-
if (!file.endsWith(".html") || file.includes("node_modules") || !existsSync(file)) return;
|
|
43
|
-
const html = readFileSync(file, "utf8");
|
|
44
|
-
if (isFullPage(html)) return;
|
|
45
|
-
const dir = dirs.find((d) => file.startsWith(resolve(d) + sep));
|
|
46
|
-
if (!dir || !isSelected(resolve(dir), file, generate)) return;
|
|
47
|
-
try {
|
|
48
|
-
generateFile(file, generate, dir);
|
|
49
|
-
if (a11y) for (const diag of checkA11y(html, a11y.ignore)) server.config.logger.warn(formatDiagnostics(file, [diag]));
|
|
50
|
-
} catch (e) {
|
|
51
|
-
if (e instanceof CodegenError) server.config.logger.error(e.message);
|
|
52
|
-
else throw e;
|
|
53
|
-
}
|
|
54
|
-
};
|
|
104
|
+
const logger = server.config.logger;
|
|
55
105
|
server.watcher.add(dirs);
|
|
56
|
-
server.watcher.on("
|
|
57
|
-
|
|
106
|
+
server.watcher.on("add", (f) => {
|
|
107
|
+
const file = resolve(f);
|
|
108
|
+
const dir = dirOf(file);
|
|
109
|
+
if (file.endsWith(".html")) regenerate(file, logger);
|
|
110
|
+
else if (dir && isSourceTs(file) && read(file)?.includes("define(")) regenerateDir(dir, logger);
|
|
111
|
+
});
|
|
112
|
+
server.watcher.on("unlink", (f) => {
|
|
113
|
+
const file = resolve(f);
|
|
114
|
+
const dir = dirOf(file);
|
|
115
|
+
if (file.endsWith(".html")) removeGenerated(file);
|
|
116
|
+
else if (dir && isDefineFile(file)) regenerateDir(dir, logger);
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
handleHotUpdate(ctx) {
|
|
120
|
+
const file = resolve(ctx.file);
|
|
121
|
+
const logger = ctx.server.config.logger;
|
|
122
|
+
if (file.endsWith(".html.ts")) {
|
|
123
|
+
const now = read(file);
|
|
124
|
+
if (now !== null && shown.get(file) === now) return [];
|
|
125
|
+
if (now !== null) shown.set(file, now);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (file.endsWith(".html")) {
|
|
129
|
+
if (!dirOf(file)) return;
|
|
130
|
+
if (regenerate(file, logger)) reload(ctx.server, `${file}.ts`);
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
const dir = dirOf(file);
|
|
134
|
+
if (!dir || !isSourceTs(file)) return;
|
|
135
|
+
if (read(file)?.includes("define(") || isDefineFile(file)) regenerateDir(dir, logger);
|
|
58
136
|
}
|
|
59
137
|
};
|
|
60
138
|
}
|
|
139
|
+
const isSourceTs = (file) => file.endsWith(".ts") && !file.endsWith(".d.ts") && !file.endsWith(".html.ts") && !file.endsWith(`${sep}ts-dom-tags.ts`);
|
|
61
140
|
export { tsDom };
|