@nativele/ts-dom 0.0.0-stage → 0.2.1
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 +63 -0
- package/LICENSE +21 -0
- package/README.md +881 -2
- package/README.zh-TW.md +887 -0
- package/dist/a11y-DZvViTVA.mjs +649 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +112 -0
- package/dist/holes-DTBtTZF_.mjs +15 -0
- package/dist/index.d.mts +242 -0
- package/dist/index.mjs +728 -0
- package/dist/lint-B7SIRo0c.mjs +356 -0
- package/dist/oxlint-plugin.d.mts +99 -0
- package/dist/oxlint-plugin.mjs +59 -0
- package/dist/vite-plugin.d.mts +40 -0
- package/dist/vite-plugin.mjs +61 -0
- package/llms.txt +85 -0
- package/package.json +92 -4
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
import { i as RESERVED_NAMES, n as IDENT, r as holeName, t as HOLE_ANY } from "./holes-DTBtTZF_.mjs";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
4
|
+
import { parseFragment } from "parse5";
|
|
5
|
+
// src/lib/codegen.ts
|
|
6
|
+
/** Every diagnostic that `ts-dom gen` can report while analyzing a template. */
|
|
7
|
+
const CODEGEN_RULES = {
|
|
8
|
+
"invalid-hole": "a {{ hole }} must be a single identifier (no paths or expressions)",
|
|
9
|
+
"attr-hole": "{{ }} is only supported in text, not in attributes",
|
|
10
|
+
"invalid-event-name": "the event name in data-on-<event> must be lowercase",
|
|
11
|
+
"invalid-handler": "data-on-* must be a plain method name (no calls or expressions)",
|
|
12
|
+
"reserved-handler": "the handler name would override a DOM or ts-dom member",
|
|
13
|
+
"empty-ref": "data-ref must not be empty",
|
|
14
|
+
"duplicate-ref": "data-ref names must be unique within a scope",
|
|
15
|
+
"empty-if": "data-if must not be empty",
|
|
16
|
+
"if-on-item-root": "data-if cannot be on the root element of a data-each item",
|
|
17
|
+
"slot-in-list": "<slot> cannot be inside a data-each template",
|
|
18
|
+
"style-not-top-level": "<style> must be a top-level element of the template",
|
|
19
|
+
"duplicate-slot": "slot names should be unique (warning)",
|
|
20
|
+
"each-not-template": "data-each only works on <template>",
|
|
21
|
+
"empty-each": "data-each must not be empty",
|
|
22
|
+
"nested-each": "data-each templates cannot be nested",
|
|
23
|
+
"duplicate-each": "data-each names must be unique",
|
|
24
|
+
"each-root-count": "a data-each template needs exactly one root element",
|
|
25
|
+
"forbidden-tag": "<script> cannot be in a template (it would run code)",
|
|
26
|
+
"forbidden-attribute": "on* event attributes and srcdoc cannot be in a template (they run code or are blocked by Trusted Types)",
|
|
27
|
+
"unsafe-url": "href / src / action / formaction cannot start with javascript:",
|
|
28
|
+
"duplicate-define": "a custom element tag can be defined only once per scanned directory"
|
|
29
|
+
};
|
|
30
|
+
const URL_ATTRS = /^(href|src|action|formaction|xlink:href)$/i;
|
|
31
|
+
const HTML_NS = "http://www.w3.org/1999/xhtml";
|
|
32
|
+
const newScope = () => ({
|
|
33
|
+
refs: /* @__PURE__ */ new Map(),
|
|
34
|
+
text: /* @__PURE__ */ new Set(),
|
|
35
|
+
ifs: /* @__PURE__ */ new Set()
|
|
36
|
+
});
|
|
37
|
+
const attr$1 = (el, name) => el.attrs.find((a) => a.name === name)?.value;
|
|
38
|
+
const isEl$1 = (n) => "tagName" in n;
|
|
39
|
+
const isRaw = (n) => n.tagName === "style" || n.tagName === "script";
|
|
40
|
+
const locOf = (n) => {
|
|
41
|
+
const loc = n.sourceCodeLocation;
|
|
42
|
+
return {
|
|
43
|
+
line: loc?.startLine ?? 1,
|
|
44
|
+
col: loc?.startCol ?? 1
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
/** Scan an HTML template for data-ref / data-each / data-if / <slot name> / {{ name }} / data-on-* and collect diagnostics. */
|
|
48
|
+
function analyze(html) {
|
|
49
|
+
const root = parseFragment(html, { sourceCodeLocationInfo: true });
|
|
50
|
+
const a = {
|
|
51
|
+
scope: newScope(),
|
|
52
|
+
lists: /* @__PURE__ */ new Map(),
|
|
53
|
+
slots: /* @__PURE__ */ new Set(),
|
|
54
|
+
events: /* @__PURE__ */ new Set(),
|
|
55
|
+
eventSites: [],
|
|
56
|
+
customTags: /* @__PURE__ */ new Set(),
|
|
57
|
+
customUses: [],
|
|
58
|
+
locs: /* @__PURE__ */ new Map(),
|
|
59
|
+
diagnostics: []
|
|
60
|
+
};
|
|
61
|
+
const report = (rule, severity, message, n) => {
|
|
62
|
+
a.diagnostics.push({
|
|
63
|
+
rule,
|
|
64
|
+
severity,
|
|
65
|
+
message,
|
|
66
|
+
...locOf(n)
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
const mark = (key, n) => {
|
|
70
|
+
if (!a.locs.has(key)) a.locs.set(key, locOf(n));
|
|
71
|
+
};
|
|
72
|
+
function holesIn(value, scope, listName, at) {
|
|
73
|
+
for (const m of value.matchAll(HOLE_ANY)) {
|
|
74
|
+
const name = holeName(m[1] ?? "");
|
|
75
|
+
if (name) {
|
|
76
|
+
scope.text.add(name);
|
|
77
|
+
mark(listName ? `text:${listName}/${name}` : `text:${name}`, at);
|
|
78
|
+
} else {
|
|
79
|
+
const inner = (m[1] ?? "").trim();
|
|
80
|
+
report("invalid-hole", "error", `{{ ${inner} }} is not a valid hole: a hole must be a single identifier, paths and expressions are not supported. Fix: compute the value in TypeScript (state, derive or this.text) and use a named hole such as {{ name }}.`, at);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function walk(parent, scope, listName, itemRoot = false, top = false) {
|
|
85
|
+
const children = "content" in parent && parent.nodeName === "template" ? parent.content.childNodes : parent.childNodes;
|
|
86
|
+
for (const n of children) {
|
|
87
|
+
if (n.nodeName === "#text") {
|
|
88
|
+
const p = n.parentNode;
|
|
89
|
+
if (!(p && isEl$1(p) && isRaw(p))) holesIn(n.value, scope, listName, n);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (!isEl$1(n)) continue;
|
|
93
|
+
const tag = n.tagName;
|
|
94
|
+
const isCustom = tag.includes("-") && n.namespaceURI === HTML_NS;
|
|
95
|
+
if (isCustom) a.customTags.add(tag);
|
|
96
|
+
for (const at of n.attrs) if (at.value.includes("{{")) report("attr-hole", "error", `Attribute ${at.name} contains {{ }}, which is only supported in text. Fix: add data-ref to the element and set the attribute from TypeScript (e.g. this.refs.x.setAttribute(...)).`, n);
|
|
97
|
+
if (tag === "script" && n.namespaceURI === HTML_NS) report("forbidden-tag", "error", "<script> cannot be in a template. Fix: put the code in TypeScript and attach it with a data-on-* handler or a component.", n);
|
|
98
|
+
for (const at of n.attrs) if (/^on/i.test(at.name) || at.name === "srcdoc") report("forbidden-attribute", "error", `${at.name} cannot be in a template: it runs code or is blocked when Trusted Types is enforced. Fix: ${at.name === "srcdoc" ? "load the document with a component that sets it from TypeScript" : `use data-on-${at.name.slice(2).toLowerCase()}="method" and a public method`}.`, n);
|
|
99
|
+
else if (URL_ATTRS.test(at.name) && /^\s*javascript:/i.test(at.value)) report("unsafe-url", "error", `${at.name}="${at.value}" starts with javascript:, which runs code. Fix: use a data-on-click handler, or a real URL.`, n);
|
|
100
|
+
const handlers = [];
|
|
101
|
+
for (const at of n.attrs) {
|
|
102
|
+
if (!at.name.startsWith("data-on-")) continue;
|
|
103
|
+
const type = at.name.slice(8);
|
|
104
|
+
const method = at.value.trim();
|
|
105
|
+
if (!/^[a-z][\w-]*$/.test(type)) report("invalid-event-name", "error", `${at.name} has an invalid event name. Fix: use a lowercase event name such as data-on-click or data-on-field-input (HTML attributes are case-insensitive).`, n);
|
|
106
|
+
else if (!IDENT.test(method)) report("invalid-handler", "error", `${at.name}="${at.value}" must be a plain method name; expressions, calls and paths are not supported. Fix: write data-on-${type}="methodName" and read extra data in the method from hit.el.dataset (data-* attributes).`, n);
|
|
107
|
+
else if (RESERVED_NAMES.has(method)) report("reserved-handler", "error", `${at.name}="${method}" would override a built-in DOM method or a ts-dom member of the same name on the element. Fix: rename the method (for example remove -> removeItem, click -> onClick).`, n);
|
|
108
|
+
else {
|
|
109
|
+
a.events.add(method);
|
|
110
|
+
mark(`event:${method}`, n);
|
|
111
|
+
a.eventSites.push({
|
|
112
|
+
event: type,
|
|
113
|
+
method,
|
|
114
|
+
tag,
|
|
115
|
+
attrs: n.attrs.map((x) => x.name)
|
|
116
|
+
});
|
|
117
|
+
handlers.push(type);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (isCustom) a.customUses.push({
|
|
121
|
+
tag,
|
|
122
|
+
attrs: n.attrs.map((x) => x.name),
|
|
123
|
+
events: handlers,
|
|
124
|
+
...locOf(n)
|
|
125
|
+
});
|
|
126
|
+
const ref = attr$1(n, "data-ref");
|
|
127
|
+
if (ref !== void 0) {
|
|
128
|
+
if (!ref.trim()) report("empty-ref", "error", "data-ref is empty. Fix: give it a name, e.g. data-ref=\"title\".", n);
|
|
129
|
+
else if (scope.refs.has(ref)) report("duplicate-ref", "error", `data-ref="${ref}" is used more than once in the same scope, so this.refs.${ref} would be ambiguous. Fix: use a different name (names inside different data-each templates may repeat).`, n);
|
|
130
|
+
else {
|
|
131
|
+
scope.refs.set(ref, n.namespaceURI === HTML_NS ? `El<${JSON.stringify(tag)}>` : "SVGElement");
|
|
132
|
+
mark(listName ? `ref:${listName}/${ref}` : `ref:${ref}`, n);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const cond = attr$1(n, "data-if");
|
|
136
|
+
if (cond !== void 0) {
|
|
137
|
+
if (!cond.trim()) report("empty-if", "error", "data-if is empty. Fix: give it a name, e.g. data-if=\"open\".", n);
|
|
138
|
+
else if (itemRoot) report("if-on-item-root", "error", `data-if="${cond}" is on the root element of data-each="${listName}". Removing the root would break the list ordering. Fix: wrap the content in one more element and put data-if on the inner element.`, n);
|
|
139
|
+
else {
|
|
140
|
+
scope.ifs.add(cond);
|
|
141
|
+
mark(listName ? `if:${listName}/${cond}` : `if:${cond}`, n);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (tag === "slot" && listName) report("slot-in-list", "error", `<slot> is inside data-each="${listName}". The list repeats the slot but the user provides its content only once. Fix: move the slot outside the list, or fill each item from TypeScript in the update callback.`, n);
|
|
145
|
+
if (tag === "style" && n.namespaceURI === HTML_NS && !top) report("style-not-top-level", "error", "<style> must be a top-level element of the template (not nested in another element, <template data-each> or a data-if element). Fix: move it to the first level of the HTML file.", n);
|
|
146
|
+
if (tag === "slot") {
|
|
147
|
+
const name = attr$1(n, "name") ?? "";
|
|
148
|
+
if (a.slots.has(name)) report("duplicate-slot", "warning", `slot "${name}" appears more than once; only one of them can receive the content. Fix: give each slot a distinct name.`, n);
|
|
149
|
+
a.slots.add(name);
|
|
150
|
+
}
|
|
151
|
+
const each = attr$1(n, "data-each");
|
|
152
|
+
if (each !== void 0 && tag !== "template") report("each-not-template", "error", `data-each is on <${tag}> but it only works on <template>. Fix: write <template data-each="${each || "items"}"><li>...</li></template>.`, n);
|
|
153
|
+
if (tag === "template" && each !== void 0) {
|
|
154
|
+
if (!each.trim()) report("empty-each", "error", "data-each is empty. Fix: give it a name, e.g. <template data-each=\"items\">.", n);
|
|
155
|
+
else if (listName) report("nested-each", "error", `data-each="${each}" is nested inside data-each="${listName}". Lists cannot be nested; an inner list belongs to its own template. Fix: move the inner list and its item markup into a separate .html file (a child component with its own data-each) and use that component's tag inside the outer item.`, n);
|
|
156
|
+
else if (a.lists.has(each)) report("duplicate-each", "error", `data-each="${each}" is used more than once, so this.lists.${each} would be ambiguous. Fix: use a different name.`, n);
|
|
157
|
+
else {
|
|
158
|
+
const items = n.content.childNodes.filter((c) => isEl$1(c) || c.nodeName === "#text" && c.value.trim());
|
|
159
|
+
if (items.length !== 1 || !isEl$1(items[0])) report("each-root-count", "error", `data-each="${each}" must contain exactly one root element (found ${items.length} root nodes). Fix: wrap the item content in a single element such as <li>.`, n);
|
|
160
|
+
const s = newScope();
|
|
161
|
+
a.lists.set(each, s);
|
|
162
|
+
mark(`each:${each}`, n);
|
|
163
|
+
walk(n, s, each, true, false);
|
|
164
|
+
}
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
walk(n, scope, listName, false, false);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
walk(root, a.scope, null, false, true);
|
|
171
|
+
return a;
|
|
172
|
+
}
|
|
173
|
+
const q = (s) => JSON.stringify(s);
|
|
174
|
+
const union = (xs) => {
|
|
175
|
+
const arr = [...new Set(xs)];
|
|
176
|
+
return arr.length ? arr.map(q).join(" | ") : "never";
|
|
177
|
+
};
|
|
178
|
+
const obj = (m) => `{ ${[...m].map(([n, t]) => `${q(n)}: ${t}`).join("; ")} }`;
|
|
179
|
+
function globToRegExp(glob) {
|
|
180
|
+
let re = "";
|
|
181
|
+
for (let i = 0; i < glob.length; i++) {
|
|
182
|
+
const c = glob[i];
|
|
183
|
+
if (c === "*" && glob[i + 1] === "*") {
|
|
184
|
+
const slash = glob[i + 2] === "/";
|
|
185
|
+
re += slash ? "(?:.*/)?" : ".*";
|
|
186
|
+
i += slash ? 2 : 1;
|
|
187
|
+
} else if (c === "*") re += "[^/]*";
|
|
188
|
+
else if (c === "?") re += "[^/]";
|
|
189
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
190
|
+
}
|
|
191
|
+
return new RegExp(`^${re}$`);
|
|
192
|
+
}
|
|
193
|
+
/** Whether `file` (under `dir`) is one of the templates `options.include` / `options.exclude` select. */
|
|
194
|
+
function isSelected(dir, file, options = {}) {
|
|
195
|
+
const rel = relative(dir, file).split(sep).join("/");
|
|
196
|
+
const hit = (globs) => globs.some((g) => globToRegExp(g).test(rel));
|
|
197
|
+
return (!options.include?.length || hit(options.include)) && !(options.exclude?.length && hit(options.exclude));
|
|
198
|
+
}
|
|
199
|
+
const KEEPS_WHITESPACE = /* @__PURE__ */ new Set([
|
|
200
|
+
"pre",
|
|
201
|
+
"textarea",
|
|
202
|
+
"style",
|
|
203
|
+
"script"
|
|
204
|
+
]);
|
|
205
|
+
function toNodes(parent, collapse = false) {
|
|
206
|
+
const children = "content" in parent && parent.nodeName === "template" ? parent.content.childNodes : parent.childNodes;
|
|
207
|
+
const out = [];
|
|
208
|
+
for (const n of children) if (n.nodeName === "#text") {
|
|
209
|
+
const value = n.value;
|
|
210
|
+
if (!collapse) out.push(value);
|
|
211
|
+
else if (!(/^\s*$/.test(value) && value.includes("\n"))) out.push(value.replace(/\s+/g, " "));
|
|
212
|
+
} else if (n.nodeName === "#comment") out.push(["#comment", n.data]);
|
|
213
|
+
else if (isEl$1(n)) {
|
|
214
|
+
const attrs = Object.fromEntries(n.attrs.map((a) => [a.name, a.value]));
|
|
215
|
+
const inner = toNodes(n, collapse && !KEEPS_WHITESPACE.has(n.tagName));
|
|
216
|
+
out.push(n.namespaceURI === HTML_NS ? [
|
|
217
|
+
n.tagName,
|
|
218
|
+
attrs,
|
|
219
|
+
inner
|
|
220
|
+
] : [
|
|
221
|
+
n.tagName,
|
|
222
|
+
attrs,
|
|
223
|
+
inner,
|
|
224
|
+
n.namespaceURI
|
|
225
|
+
]);
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Generate `x.html.ts`: a real TypeScript module that contains both the template markup (as DOM-building data, never parsed from a string at runtime) and its types.
|
|
231
|
+
* tsc (or any bundler) can compile it directly: no plugin and no allowArbitraryExtensions needed.
|
|
232
|
+
* Consumers import it as `import tpl from "./x.html.js"` (TypeScript maps it to x.html.ts).
|
|
233
|
+
*/
|
|
234
|
+
function generateModule(html, options = {}, imports = []) {
|
|
235
|
+
const runtime = options.runtime ?? "@nativele/ts-dom";
|
|
236
|
+
const a = analyze(html);
|
|
237
|
+
const lists = [...a.lists].map(([n, s]) => `${q(n)}: { refs: ${obj(s.refs)}; text: ${union(s.text)}; ifs: ${union(s.ifs)} }`).join("; ");
|
|
238
|
+
const type = `{
|
|
239
|
+
refs: ${obj(a.scope.refs)};
|
|
240
|
+
lists: { ${lists} };
|
|
241
|
+
ifs: ${union(a.scope.ifs)};
|
|
242
|
+
slots: ${union(a.slots)};
|
|
243
|
+
text: ${union(a.scope.text)};
|
|
244
|
+
events: ${union(a.events)};
|
|
245
|
+
}`;
|
|
246
|
+
const needsEl = type.includes("El<");
|
|
247
|
+
return `// Generated by ts-dom. Do not edit.
|
|
248
|
+
${imports.map((i) => `import ${q(i)};\n`).join("")}import { template } from ${q(runtime)};
|
|
249
|
+
${needsEl ? `import type { El } from ${q(runtime)};\n` : ""}export default template<${type}>(${JSON.stringify(toNodes(parseFragment(html), options.whitespace === "collapse"))});
|
|
250
|
+
`;
|
|
251
|
+
}
|
|
252
|
+
/** x.html → x.html.ts */
|
|
253
|
+
function modulePathFor(htmlPath) {
|
|
254
|
+
return `${htmlPath}.ts`;
|
|
255
|
+
}
|
|
256
|
+
/** A full page (starting with a doctype or <html>) is a page entry, not a template. */
|
|
257
|
+
function isFullPage(html) {
|
|
258
|
+
return /^\s*(<!--[\s\S]*?-->\s*)*<(!doctype|html)\b/i.test(html);
|
|
259
|
+
}
|
|
260
|
+
/** `file:line:col severity [rule] message` — one line per diagnostic. */
|
|
261
|
+
function formatDiagnostics(file, ds) {
|
|
262
|
+
return ds.map((d) => `${file}:${d.line}:${d.col} ${d.severity} [${d.rule}] ${d.message}`).join("\n");
|
|
263
|
+
}
|
|
264
|
+
var CodegenError = class extends Error {};
|
|
265
|
+
/**
|
|
266
|
+
* Generate a single `x.html.ts`; throws CodegenError (and writes nothing) when there are error diagnostics.
|
|
267
|
+
* `defs` is either a directory to scan or a precomputed scan result: custom elements used by the template
|
|
268
|
+
* are imported automatically from the modules that define them.
|
|
269
|
+
*/
|
|
270
|
+
function generateFile(htmlPath, options = {}, defs) {
|
|
271
|
+
const html = readFileSync(htmlPath, "utf8");
|
|
272
|
+
const analysis = analyze(html);
|
|
273
|
+
const errors = analysis.diagnostics.filter((d) => d.severity === "error");
|
|
274
|
+
if (errors.length) throw new CodegenError(formatDiagnostics(htmlPath, errors));
|
|
275
|
+
const known = typeof defs === "string" ? scanDefines(defs) : defs ?? [];
|
|
276
|
+
const here = dirname(htmlPath);
|
|
277
|
+
const imports = [...new Set(known.filter((d) => analysis.customTags.has(d.tag)).map((d) => relImport(here, d.file)))].sort();
|
|
278
|
+
const out = modulePathFor(htmlPath);
|
|
279
|
+
const next = generateModule(html, options, imports);
|
|
280
|
+
let prev = "";
|
|
281
|
+
try {
|
|
282
|
+
prev = readFileSync(out, "utf8");
|
|
283
|
+
} catch {}
|
|
284
|
+
if (prev !== next) writeFileSync(out, next);
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
function listHtml(dir) {
|
|
288
|
+
const out = [];
|
|
289
|
+
for (const name of readdirSync(dir)) {
|
|
290
|
+
if (name === "node_modules") continue;
|
|
291
|
+
const full = join(dir, name);
|
|
292
|
+
if (statSync(full).isDirectory()) out.push(...listHtml(full));
|
|
293
|
+
else if (name.endsWith(".html") && !isFullPage(readFileSync(full, "utf8"))) out.push(full);
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
const TAGS_FILE = "ts-dom-tags.ts";
|
|
298
|
+
const TAGS_HEADER = "// Generated by ts-dom. Do not edit.";
|
|
299
|
+
function listTs(dir) {
|
|
300
|
+
const out = [];
|
|
301
|
+
for (const name of readdirSync(dir)) {
|
|
302
|
+
if (name === "node_modules" || name === "dist") continue;
|
|
303
|
+
const full = join(dir, name);
|
|
304
|
+
if (statSync(full).isDirectory()) out.push(...listTs(full));
|
|
305
|
+
else if (name.endsWith(".ts") && !name.endsWith(".d.ts") && !name.endsWith(".html.ts") && name !== TAGS_FILE) out.push(full);
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
/** Ranges [start, end) of the string literals, template literals and comments of a piece of TypeScript source. */
|
|
310
|
+
function nonCodeRanges(code) {
|
|
311
|
+
const out = [];
|
|
312
|
+
for (let i = 0; i < code.length; i++) {
|
|
313
|
+
const c = code[i];
|
|
314
|
+
if (c === "\"" || c === "'" || c === "`") {
|
|
315
|
+
const start = i;
|
|
316
|
+
for (i++; i < code.length && code[i] !== c; i++) if (code[i] === "\\") i++;
|
|
317
|
+
out.push([start, i + 1]);
|
|
318
|
+
} else if (c === "/" && code[i + 1] === "/") {
|
|
319
|
+
const end = code.indexOf("\n", i);
|
|
320
|
+
out.push([i, end < 0 ? code.length : end]);
|
|
321
|
+
i = end < 0 ? code.length : end;
|
|
322
|
+
} else if (c === "/" && code[i + 1] === "*") {
|
|
323
|
+
const end = code.indexOf("*/", i + 2);
|
|
324
|
+
out.push([i, end < 0 ? code.length : end + 2]);
|
|
325
|
+
i = end < 0 ? code.length : end + 1;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Scan a directory for every `define("my-tag", MyClass)` call.
|
|
332
|
+
* Calls that only appear inside a string or a comment (for example sample code in a test or in documentation) are ignored.
|
|
333
|
+
*/
|
|
334
|
+
function scanDefines(dir) {
|
|
335
|
+
const defs = [];
|
|
336
|
+
const re = /\bdefine\(\s*(["'])([a-z][a-z0-9]*(?:-[a-z0-9]+)+)\1\s*,\s*([A-Za-z_$][\w$]*)\s*\)/g;
|
|
337
|
+
for (const file of listTs(dir)) {
|
|
338
|
+
const code = readFileSync(file, "utf8");
|
|
339
|
+
const skip = nonCodeRanges(code);
|
|
340
|
+
const inCode = (pos) => !skip.some(([a, b]) => pos >= a && pos < b);
|
|
341
|
+
const has = (re) => [...code.matchAll(re)].some((m) => inCode(m.index));
|
|
342
|
+
for (const m of code.matchAll(re)) {
|
|
343
|
+
if (!inCode(m.index)) continue;
|
|
344
|
+
const [, , tag, className] = m;
|
|
345
|
+
const named = has(new RegExp(`export\\s+class\\s+${className}\\b`, "g")) || has(new RegExp(`export\\s*\\{[^}]*\\b${className}\\b[^}]*\\}`, "g"));
|
|
346
|
+
const isDefault = has(new RegExp(`export\\s+default\\s+class\\s+${className}\\b`, "g"));
|
|
347
|
+
defs.push({
|
|
348
|
+
tag,
|
|
349
|
+
className,
|
|
350
|
+
file,
|
|
351
|
+
exported: named || isDefault,
|
|
352
|
+
isDefault
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return defs.sort((a, b) => a.tag.localeCompare(b.tag));
|
|
357
|
+
}
|
|
358
|
+
const relImport = (fromDir, file) => {
|
|
359
|
+
const r = relative(fromDir, file).split(sep).join("/").replace(/\.ts$/, ".js");
|
|
360
|
+
return r.startsWith(".") ? r : `./${r}`;
|
|
361
|
+
};
|
|
362
|
+
/** Scan `define("my-tag", MyClass)` calls and return the components a type can be generated for (the class must be exported). */
|
|
363
|
+
function scanTags(dir) {
|
|
364
|
+
return scanDefines(dir).filter((d) => d.exported).map((d) => ({
|
|
365
|
+
tag: d.tag,
|
|
366
|
+
className: d.className,
|
|
367
|
+
from: relImport(dir, d.file),
|
|
368
|
+
isDefault: d.isDefault
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Generate `ts-dom-tags.ts`: register every `define("tag", Class)` component in HTMLElementTagNameMap
|
|
373
|
+
* so that `refs.x` for `<tag data-ref="x">` in a template is fully typed (props, methods and emitted events).
|
|
374
|
+
*/
|
|
375
|
+
function generateTags(dir) {
|
|
376
|
+
const defs = scanTags(dir);
|
|
377
|
+
const out = join(dir, TAGS_FILE);
|
|
378
|
+
const seen = /* @__PURE__ */ new Map();
|
|
379
|
+
for (const d of defs) {
|
|
380
|
+
const prev = seen.get(d.tag);
|
|
381
|
+
if (prev && (prev.from !== d.from || prev.className !== d.className)) throw new CodegenError(`${dir}: [duplicate-define] the tag <${d.tag}> is defined more than once (${prev.from} and ${d.from}). Fix: each custom element name can be defined only once; rename one of them.`);
|
|
382
|
+
seen.set(d.tag, d);
|
|
383
|
+
}
|
|
384
|
+
if (!defs.length) {
|
|
385
|
+
if (existsSync(out) && readFileSync(out, "utf8").startsWith(TAGS_HEADER)) unlinkSync(out);
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
const alias = (d) => `Tag${[...seen.keys()].indexOf(d.tag)}`;
|
|
389
|
+
const imports = [...seen.values()].map((d) => `import type { ${d.isDefault ? "default" : d.className} as ${alias(d)} } from ${JSON.stringify(d.from)};`);
|
|
390
|
+
const entries = [...seen.values()].map((d) => ` ${JSON.stringify(d.tag)}: ${alias(d)};`);
|
|
391
|
+
const next = `${TAGS_HEADER}\n${imports.join("\n")}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n${entries.join("\n")}\n }\n}\n\nexport {};\n`;
|
|
392
|
+
let prev = "";
|
|
393
|
+
try {
|
|
394
|
+
prev = readFileSync(out, "utf8");
|
|
395
|
+
} catch {}
|
|
396
|
+
if (prev !== next) writeFileSync(out, next);
|
|
397
|
+
return out;
|
|
398
|
+
}
|
|
399
|
+
/** Generate every `x.html.ts` (and the tag map) under a directory; collects all errors and throws them together. */
|
|
400
|
+
function generateAll(dir, options = {}) {
|
|
401
|
+
const written = [];
|
|
402
|
+
const errors = [];
|
|
403
|
+
const defs = scanDefines(dir);
|
|
404
|
+
for (const f of listHtml(dir)) {
|
|
405
|
+
if (!isSelected(dir, f, options)) continue;
|
|
406
|
+
try {
|
|
407
|
+
written.push(generateFile(f, options, defs));
|
|
408
|
+
} catch (e) {
|
|
409
|
+
if (e instanceof CodegenError) errors.push(e.message);
|
|
410
|
+
else throw e;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
const tags = generateTags(dir);
|
|
415
|
+
if (tags) written.push(tags);
|
|
416
|
+
} catch (e) {
|
|
417
|
+
if (e instanceof CodegenError) errors.push(e.message);
|
|
418
|
+
else throw e;
|
|
419
|
+
}
|
|
420
|
+
if (errors.length) throw new CodegenError(errors.join("\n"));
|
|
421
|
+
return written;
|
|
422
|
+
}
|
|
423
|
+
/** Watch .html (and define() calls in .ts) under the directories and regenerate; returns a stop function. Errors go to onError and never stop the watcher. */
|
|
424
|
+
function watchAll(dirs, opts = {}) {
|
|
425
|
+
const timers = /* @__PURE__ */ new Map();
|
|
426
|
+
const watchers = dirs.map((dir) => watch(dir, { recursive: true }, (_event, name) => {
|
|
427
|
+
if (!name || name.includes("node_modules")) return;
|
|
428
|
+
if (name.endsWith(".ts") && !name.endsWith(".html.ts") && !name.endsWith(".d.ts") && !name.endsWith(TAGS_FILE)) {
|
|
429
|
+
const key = `${dir}:tags`;
|
|
430
|
+
clearTimeout(timers.get(key));
|
|
431
|
+
timers.set(key, setTimeout(() => {
|
|
432
|
+
try {
|
|
433
|
+
for (const f of generateAll(dir, opts)) opts.onGenerate?.(f);
|
|
434
|
+
} catch (e) {
|
|
435
|
+
if (e instanceof CodegenError) opts.onError?.(e.message);
|
|
436
|
+
else throw e;
|
|
437
|
+
}
|
|
438
|
+
}, 100));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (!name.endsWith(".html")) return;
|
|
442
|
+
const file = join(dir, name);
|
|
443
|
+
clearTimeout(timers.get(file));
|
|
444
|
+
timers.set(file, setTimeout(() => {
|
|
445
|
+
try {
|
|
446
|
+
if (!existsSync(file) || !isSelected(dir, file, opts) || isFullPage(readFileSync(file, "utf8"))) return;
|
|
447
|
+
opts.onGenerate?.(generateFile(file, opts, dir));
|
|
448
|
+
} catch (e) {
|
|
449
|
+
if (e instanceof CodegenError) opts.onError?.(e.message);
|
|
450
|
+
else throw e;
|
|
451
|
+
}
|
|
452
|
+
}, 50));
|
|
453
|
+
}));
|
|
454
|
+
return () => {
|
|
455
|
+
for (const w of watchers) w.close();
|
|
456
|
+
for (const t of timers.values()) clearTimeout(t);
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
// src/lib/a11y.ts
|
|
460
|
+
/** Every check, by rule id (usable in `ignore`). The text is the default message: what is wrong and how to fix it. */
|
|
461
|
+
const A11Y_RULES = {
|
|
462
|
+
"img-alt": "<img> has no alt attribute, so screen readers cannot describe it. Fix: add alt=\"description\", or alt=\"\" if the image is purely decorative.",
|
|
463
|
+
"control-name": "This control has no accessible name. Fix: add text, a {{ hole }}, a <slot>, aria-label or aria-labelledby.",
|
|
464
|
+
"form-label": "This form control has no label. Fix: wrap it in a <label>, add <label for=\"id\">, or add aria-label.",
|
|
465
|
+
"iframe-title": "<iframe> has no title. Fix: add a title attribute (or aria-label) that describes its content.",
|
|
466
|
+
"dialog-name": "<dialog> has no accessible name. Fix: add aria-label or aria-labelledby.",
|
|
467
|
+
"heading-empty": "This heading is empty. Fix: add text or a {{ hole }}, or remove the heading element.",
|
|
468
|
+
"aria-role": "This role is not a valid ARIA role. Fix: use a role from the WAI-ARIA specification (check the spelling).",
|
|
469
|
+
"aria-attr": "This is not a valid ARIA attribute. Fix: check the spelling against the WAI-ARIA attribute list.",
|
|
470
|
+
"role-tabindex": "An element with an interactive role must be focusable. Fix: add tabindex=\"0\" (and handle keyboard events), or use a native element such as <button>.",
|
|
471
|
+
"tabindex-positive": "A positive tabindex disturbs the natural focus order. Fix: use tabindex=\"0\" or \"-1\".",
|
|
472
|
+
"hidden-focusable": "A focusable element has aria-hidden=\"true\", so keyboard users can reach something screen readers cannot see. Fix: remove aria-hidden, or make the element non-focusable.",
|
|
473
|
+
"label-for": "<label for> points to an id that does not exist in this template. Fix: add the matching id to the control, or wrap the control in the label instead.",
|
|
474
|
+
"id-in-list": "An id inside a data-each template is repeated for every item. Fix: use data-ref or a class instead of an id.",
|
|
475
|
+
"duplicate-id": "This id is used more than once in the template. Fix: make every id unique.",
|
|
476
|
+
"aria-idref": "This ARIA attribute refers to an id that does not exist in this template. Fix: add the id, or ignore this rule if it refers to an element outside the component.",
|
|
477
|
+
"heading-order": "Heading levels are skipped. Fix: do not jump levels (for example h1 straight to h3); use the next level down.",
|
|
478
|
+
"table-header": "<table> has no <th> header cells. Fix: add <th> cells, or set role=\"presentation\" if the table is only used for layout.",
|
|
479
|
+
"list-structure": "Invalid list structure. Fix: <ul>/<ol> may only contain <li>, and <li> must be inside <ul>, <ol> or <menu>.",
|
|
480
|
+
"landmark-label": "There are several landmarks of the same kind. Fix: give each <nav> / <aside> a distinct aria-label.",
|
|
481
|
+
"redundant-role": "The role repeats the element's native role. Fix: remove the role attribute.",
|
|
482
|
+
"click-semantics": "data-on-click is on an element that is not ts-domy operable, so keyboard and assistive technology users cannot use it. Fix: use <button> or <a href>, or add an interactive role plus tabindex and keyboard handling."
|
|
483
|
+
};
|
|
484
|
+
const ROLES = new Set(`alert alertdialog application article banner blockquote button caption cell checkbox code columnheader combobox complementary contentinfo definition deletion dialog directory document emphasis feed figure form generic grid gridcell group heading img insertion link list listbox listitem log main mark marquee math menu menubar menuitem menuitemcheckbox menuitemradio meter navigation none note option paragraph presentation progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider spinbutton status strong subscript superscript switch tab table tablist tabpanel term textbox time timer toolbar tooltip tree treegrid treeitem`.split(" "));
|
|
485
|
+
const ARIA_ATTRS = new Set(`activedescendant atomic autocomplete braillelabel brailleroledescription busy checked colcount colindex colindextext colspan controls current describedby description details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount rowindex rowindextext rowspan selected setsize sort valuemax valuemin valuenow valuetext`.split(" ").map((n) => `aria-${n}`));
|
|
486
|
+
const INTERACTIVE_ROLES = new Set("button link checkbox switch tab menuitem menuitemcheckbox menuitemradio option radio textbox combobox slider spinbutton searchbox".split(" "));
|
|
487
|
+
const IDREF_ATTRS = [
|
|
488
|
+
"aria-labelledby",
|
|
489
|
+
"aria-describedby",
|
|
490
|
+
"aria-controls",
|
|
491
|
+
"aria-owns",
|
|
492
|
+
"aria-flowto",
|
|
493
|
+
"aria-details",
|
|
494
|
+
"aria-errormessage",
|
|
495
|
+
"aria-activedescendant"
|
|
496
|
+
];
|
|
497
|
+
const IMPLICIT_ROLE = {
|
|
498
|
+
button: "button",
|
|
499
|
+
nav: "navigation",
|
|
500
|
+
ul: "list",
|
|
501
|
+
ol: "list",
|
|
502
|
+
li: "listitem",
|
|
503
|
+
img: "img",
|
|
504
|
+
main: "main",
|
|
505
|
+
aside: "complementary",
|
|
506
|
+
form: "form",
|
|
507
|
+
table: "table",
|
|
508
|
+
article: "article",
|
|
509
|
+
hr: "separator",
|
|
510
|
+
dialog: "dialog",
|
|
511
|
+
h1: "heading",
|
|
512
|
+
h2: "heading",
|
|
513
|
+
h3: "heading",
|
|
514
|
+
h4: "heading",
|
|
515
|
+
h5: "heading",
|
|
516
|
+
h6: "heading"
|
|
517
|
+
};
|
|
518
|
+
const NO_LABEL_INPUT = /* @__PURE__ */ new Set([
|
|
519
|
+
"hidden",
|
|
520
|
+
"submit",
|
|
521
|
+
"reset",
|
|
522
|
+
"button",
|
|
523
|
+
"image"
|
|
524
|
+
]);
|
|
525
|
+
const isEl = (n) => "tagName" in n;
|
|
526
|
+
const attr = (el, name) => el.attrs.find((a) => a.name === name)?.value;
|
|
527
|
+
const has = (el, name) => el.attrs.some((a) => a.name === name);
|
|
528
|
+
const nonEmpty = (v) => v !== void 0 && v.trim() !== "";
|
|
529
|
+
const kids = (el) => el.tagName === "template" ? el.content.childNodes : el.childNodes;
|
|
530
|
+
function focusable(el) {
|
|
531
|
+
const t = el.tagName;
|
|
532
|
+
if (t === "button" || t === "select" || t === "textarea" || t === "summary") return true;
|
|
533
|
+
if (t === "a") return has(el, "href");
|
|
534
|
+
if (t === "input") return attr(el, "type")?.toLowerCase() !== "hidden";
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
/** Whether an element may contain readable content: text, holes, slots, TypeScript-managed elements, named descendants. */
|
|
538
|
+
function hasContent(el) {
|
|
539
|
+
for (const c of kids(el)) if (c.nodeName === "#text") {
|
|
540
|
+
if (c.value.trim()) return true;
|
|
541
|
+
} else if (isEl(c)) {
|
|
542
|
+
if (c.tagName === "style" || c.tagName === "script") continue;
|
|
543
|
+
if (c.tagName === "slot" || has(c, "data-ref")) return true;
|
|
544
|
+
if (nonEmpty(attr(c, "aria-label")) || nonEmpty(attr(c, "title"))) return true;
|
|
545
|
+
if (c.tagName === "img" && nonEmpty(attr(c, "alt"))) return true;
|
|
546
|
+
if (hasContent(c)) return true;
|
|
547
|
+
}
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
const named = (el) => nonEmpty(attr(el, "aria-label")) || nonEmpty(attr(el, "aria-labelledby")) || nonEmpty(attr(el, "title"));
|
|
551
|
+
/** Check a template for ARIA and basic accessibility problems. Returns warnings and never throws. */
|
|
552
|
+
function checkA11y(html, ignore = []) {
|
|
553
|
+
const root = parseFragment(html, { sourceCodeLocationInfo: true });
|
|
554
|
+
const out = [];
|
|
555
|
+
const skip = new Set(ignore);
|
|
556
|
+
const ids = /* @__PURE__ */ new Map();
|
|
557
|
+
const labelFor = [];
|
|
558
|
+
const all = [];
|
|
559
|
+
(function collect(parent, domParent, inList, inLabel) {
|
|
560
|
+
for (const n of parent.childNodes) {
|
|
561
|
+
if (!isEl(n)) continue;
|
|
562
|
+
const list = inList || n.tagName === "template" && has(n, "data-each");
|
|
563
|
+
const label = inLabel || n.tagName === "label";
|
|
564
|
+
all.push({
|
|
565
|
+
el: n,
|
|
566
|
+
parent: domParent,
|
|
567
|
+
inList,
|
|
568
|
+
inLabel
|
|
569
|
+
});
|
|
570
|
+
const id = attr(n, "id");
|
|
571
|
+
if (nonEmpty(id)) ids.set(id, (ids.get(id) ?? 0) + 1);
|
|
572
|
+
if (n.tagName === "label" && nonEmpty(attr(n, "for"))) labelFor.push({
|
|
573
|
+
id: attr(n, "for"),
|
|
574
|
+
el: n
|
|
575
|
+
});
|
|
576
|
+
if (n.tagName === "template") collect(n.content, domParent, list, label);
|
|
577
|
+
else collect(n, n, list, label);
|
|
578
|
+
}
|
|
579
|
+
})(root, null, false, false);
|
|
580
|
+
const report = (rule, el, detail) => {
|
|
581
|
+
if (skip.has(rule)) return;
|
|
582
|
+
const loc = el.sourceCodeLocation;
|
|
583
|
+
out.push({
|
|
584
|
+
rule,
|
|
585
|
+
severity: "warning",
|
|
586
|
+
message: detail ?? A11Y_RULES[rule],
|
|
587
|
+
line: loc?.startLine ?? 1,
|
|
588
|
+
col: loc?.startCol ?? 1
|
|
589
|
+
});
|
|
590
|
+
};
|
|
591
|
+
let lastHeading = 0;
|
|
592
|
+
const landmarks = all.filter((x) => (x.el.tagName === "nav" || x.el.tagName === "aside") && attr(x.el, "role") !== "presentation");
|
|
593
|
+
const descendants = (el) => kids(el).flatMap((c) => isEl(c) ? [c, ...descendants(c)] : []);
|
|
594
|
+
for (const { el, parent, inList, inLabel } of all) {
|
|
595
|
+
const tag = el.tagName;
|
|
596
|
+
const managed = has(el, "data-ref");
|
|
597
|
+
const role = attr(el, "role")?.trim().split(/\s+/)[0];
|
|
598
|
+
if (tag === "img" && !has(el, "alt") && !managed && role !== "presentation" && role !== "none") report("img-alt", el);
|
|
599
|
+
if ((tag === "button" || tag === "summary" || tag === "a" && has(el, "href")) && !managed && !named(el) && !hasContent(el)) report("control-name", el, `<${tag}> has no accessible name. Fix: add text, a {{ hole }}, a <slot>, aria-label or aria-labelledby.`);
|
|
600
|
+
if ((tag === "select" || tag === "textarea" || tag === "input" && !NO_LABEL_INPUT.has((attr(el, "type") ?? "text").toLowerCase())) && !managed && !named(el) && !inLabel) {
|
|
601
|
+
const id = attr(el, "id");
|
|
602
|
+
if (!(nonEmpty(id) && labelFor.some((l) => l.id === id))) report("form-label", el, `<${tag}> has no label. Fix: wrap it in a <label>, add <label for="id"> with a matching id, or add aria-label.`);
|
|
603
|
+
}
|
|
604
|
+
if (tag === "iframe" && !managed && !named(el)) report("iframe-title", el);
|
|
605
|
+
if (tag === "dialog" && !managed && !nonEmpty(attr(el, "aria-label")) && !nonEmpty(attr(el, "aria-labelledby"))) report("dialog-name", el);
|
|
606
|
+
if (/^h[1-6]$/.test(tag) && !managed && !nonEmpty(attr(el, "aria-label")) && !hasContent(el)) report("heading-empty", el, `<${tag}> is empty. Fix: add text or a {{ hole }}, or remove the heading element.`);
|
|
607
|
+
if (role !== void 0 && !ROLES.has(role) && !role.startsWith("doc-") && !role.startsWith("graphics-")) report("aria-role", el, `role="${role}" is not a valid ARIA role. Fix: check the spelling against the WAI-ARIA roles.`);
|
|
608
|
+
for (const a of el.attrs) if (a.name.startsWith("aria-") && !ARIA_ATTRS.has(a.name)) report("aria-attr", el, `${a.name} is not a valid ARIA attribute. Fix: check the spelling against the WAI-ARIA attributes.`);
|
|
609
|
+
const tabindex = attr(el, "tabindex");
|
|
610
|
+
if (role && INTERACTIVE_ROLES.has(role) && !focusable(el) && tabindex === void 0 && !managed) report("role-tabindex", el, `<${tag} role="${role}"> is not ts-domy focusable. Fix: add tabindex="0" and handle keyboard events, or use a native element such as <button>.`);
|
|
611
|
+
if (tabindex !== void 0 && Number(tabindex) > 0) report("tabindex-positive", el, `tabindex="${tabindex}" disturbs the natural focus order. Fix: use tabindex="0" or "-1".`);
|
|
612
|
+
if (attr(el, "aria-hidden") === "true" && (focusable(el) || tabindex !== void 0 && Number(tabindex) >= 0)) report("hidden-focusable", el);
|
|
613
|
+
if (has(el, "data-on-click") && !focusable(el) && tag !== "label" && !(role && INTERACTIVE_ROLES.has(role) && tabindex !== void 0)) report("click-semantics", el, `data-on-click is on <${tag}>, which is not ts-domy operable, so keyboard and assistive technology users cannot use it. Fix: use <button> or <a href>, or add an interactive role plus tabindex and keyboard handling.`);
|
|
614
|
+
if (/^h[1-6]$/.test(tag)) {
|
|
615
|
+
const level = Number(tag[1]);
|
|
616
|
+
if (lastHeading && level > lastHeading + 1) report("heading-order", el, `<${tag}> skips a heading level (the previous heading is h${lastHeading}). Fix: use h${lastHeading + 1} here, or restructure the headings.`);
|
|
617
|
+
lastHeading = level;
|
|
618
|
+
}
|
|
619
|
+
if (tag === "table" && !managed && role !== "presentation" && role !== "none" && !descendants(el).some((d) => d.tagName === "th")) report("table-header", el);
|
|
620
|
+
if (tag === "ul" || tag === "ol" || tag === "menu") for (const c of kids(el)) {
|
|
621
|
+
if (!isEl(c) || c.tagName === "slot" || c.tagName === "script") continue;
|
|
622
|
+
if (c.tagName === "template") {
|
|
623
|
+
if (!has(c, "data-each")) continue;
|
|
624
|
+
for (const item of c.content.childNodes) if (isEl(item) && item.tagName !== "li") report("list-structure", item, `The root element of a data-each item inside <${tag}> must be <li>, found <${item.tagName}>. Fix: use <li> as the item root.`);
|
|
625
|
+
} else if (c.tagName !== "li") report("list-structure", c, `<${tag}> may only contain <li> children, found <${c.tagName}>. Fix: wrap the content in <li>.`);
|
|
626
|
+
}
|
|
627
|
+
if (tag === "li" && parent && ![
|
|
628
|
+
"ul",
|
|
629
|
+
"ol",
|
|
630
|
+
"menu",
|
|
631
|
+
"slot"
|
|
632
|
+
].includes(parent.tagName) && !["list", "menu"].includes(attr(parent, "role") ?? "")) report("list-structure", el, `<li> must be inside <ul>, <ol> or <menu>, found inside <${parent.tagName}>. Fix: change the parent element, or use a different element instead of <li>.`);
|
|
633
|
+
if ((tag === "nav" || tag === "aside") && landmarks.length > 1 && landmarks.filter((l) => l.el.tagName === tag).length > 1 && !named(el) && !managed) report("landmark-label", el, `There are several <${tag}> landmarks in this template. Fix: give each one a distinct aria-label.`);
|
|
634
|
+
if (role && (IMPLICIT_ROLE[tag] === role || tag === "a" && has(el, "href") && role === "link")) report("redundant-role", el, `<${tag}> already has the role "${role}". Fix: remove the redundant role attribute.`);
|
|
635
|
+
if (!managed) for (const name of IDREF_ATTRS) {
|
|
636
|
+
const v = attr(el, name);
|
|
637
|
+
if (!nonEmpty(v)) continue;
|
|
638
|
+
for (const ref of v.trim().split(/\s+/)) if (!ids.has(ref)) report("aria-idref", el, `${name}="${ref}" refers to an id that does not exist in this template. Fix: add the id here, or add "aria-idref" to the ignore list if it points to an element outside the component.`);
|
|
639
|
+
}
|
|
640
|
+
const id = attr(el, "id");
|
|
641
|
+
if (nonEmpty(id)) {
|
|
642
|
+
if (inList) report("id-in-list", el, `id="${id}" is inside a data-each template and would repeat for every item. Fix: use data-ref or a class instead.`);
|
|
643
|
+
if ((ids.get(id) ?? 0) > 1) report("duplicate-id", el, `id="${id}" is used more than once in this template. Fix: make every id unique.`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
for (const { id, el } of labelFor) if (!ids.has(id)) report("label-for", el, `<label for="${id}"> points to an id that does not exist in this template. Fix: add id="${id}" to the control, or wrap the control in the label.`);
|
|
647
|
+
return out.sort((a, b) => a.line - b.line || a.col - b.col);
|
|
648
|
+
}
|
|
649
|
+
export { analyze as a, generateFile as c, listHtml as d, scanDefines as f, CodegenError as i, isFullPage as l, checkA11y as n, formatDiagnostics as o, watchAll as p, CODEGEN_RULES as r, generateAll as s, A11Y_RULES as t, isSelected as u };
|
package/dist/cli.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|