@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
|
@@ -21,6 +21,9 @@ const CODEGEN_RULES = {
|
|
|
21
21
|
"empty-each": "data-each must not be empty",
|
|
22
22
|
"nested-each": "data-each templates cannot be nested",
|
|
23
23
|
"duplicate-each": "data-each names must be unique",
|
|
24
|
+
"each-binding-incomplete": "data-key and data-prop go together on a data-each template",
|
|
25
|
+
"invalid-each-binding": "data-key and data-prop must each be a single identifier",
|
|
26
|
+
"each-binding-root": "with data-key and data-prop the item must be one custom element",
|
|
24
27
|
"each-root-count": "a data-each template needs exactly one root element",
|
|
25
28
|
"document-tag": "<html>, <head> and <body> cannot be in a template (the browser drops them from fragments)",
|
|
26
29
|
"forbidden-tag": "<script> cannot be in a template (it would run code)",
|
|
@@ -28,6 +31,17 @@ const CODEGEN_RULES = {
|
|
|
28
31
|
"unsafe-url": "href / src / action / formaction cannot start with javascript:",
|
|
29
32
|
"duplicate-define": "a custom element tag can be defined only once per scanned directory"
|
|
30
33
|
};
|
|
34
|
+
const RESERVED_ELEMENT_NAMES = /* @__PURE__ */ new Set([
|
|
35
|
+
"annotation-xml",
|
|
36
|
+
"color-profile",
|
|
37
|
+
"font-face",
|
|
38
|
+
"font-face-src",
|
|
39
|
+
"font-face-uri",
|
|
40
|
+
"font-face-format",
|
|
41
|
+
"font-face-name",
|
|
42
|
+
"missing-glyph"
|
|
43
|
+
]);
|
|
44
|
+
const isCustomElementName = (tag) => /^[a-z][a-z0-9._]*-[a-z0-9._-]*$/.test(tag) && !RESERVED_ELEMENT_NAMES.has(tag);
|
|
31
45
|
const URL_ATTRS = /^(href|src|action|formaction|xlink:href)$/i;
|
|
32
46
|
const HTML_NS = "http://www.w3.org/1999/xhtml";
|
|
33
47
|
const newScope = () => ({
|
|
@@ -52,6 +66,7 @@ function analyze(html) {
|
|
|
52
66
|
scope: newScope(),
|
|
53
67
|
lists: /* @__PURE__ */ new Map(),
|
|
54
68
|
slots: /* @__PURE__ */ new Set(),
|
|
69
|
+
boundLists: /* @__PURE__ */ new Map(),
|
|
55
70
|
events: /* @__PURE__ */ new Set(),
|
|
56
71
|
eventSites: [],
|
|
57
72
|
customTags: /* @__PURE__ */ new Set(),
|
|
@@ -169,6 +184,21 @@ function analyze(html) {
|
|
|
169
184
|
else {
|
|
170
185
|
const items = n.content.childNodes.filter((c) => isEl$1(c) || c.nodeName === "#text" && c.value.trim());
|
|
171
186
|
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);
|
|
187
|
+
const key = attr$1(n, "data-key");
|
|
188
|
+
const prop = attr$1(n, "data-prop");
|
|
189
|
+
if (key === void 0 !== (prop === void 0)) report("each-binding-incomplete", "error", `data-each="${each}" has ${key === void 0 ? "data-prop" : "data-key"} but not ${key === void 0 ? "data-key" : "data-prop"}. Fix: write both, e.g. <template data-each="${each}" data-key="id" data-prop="item">, or neither to fill the items yourself with replace().`, n);
|
|
190
|
+
else if (key !== void 0 && prop !== void 0) {
|
|
191
|
+
if (!IDENT.test(key.trim()) || !IDENT.test(prop.trim())) report("invalid-each-binding", "error", `data-key="${key}" and data-prop="${prop}" must each be a single identifier: the item field that identifies it, and the property of the item element that receives the item. Fix: write data-key="id" data-prop="item".`, n);
|
|
192
|
+
else if (items.length === 1 && isEl$1(items[0])) {
|
|
193
|
+
const root = items[0];
|
|
194
|
+
if (root.namespaceURI === HTML_NS && isCustomElementName(root.tagName)) a.boundLists.set(each, {
|
|
195
|
+
tag: root.tagName,
|
|
196
|
+
prop: prop.trim(),
|
|
197
|
+
key: key.trim()
|
|
198
|
+
});
|
|
199
|
+
else report("each-binding-root", "error", `data-each="${each}" has data-key and data-prop, so each item is one component that receives the item as a property, but its root is <${root.tagName}>. Fix: make the item a custom element such as <${each}-row></${each}-row> and put the item's markup in that component's own .html.`, n);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
172
202
|
const s = newScope();
|
|
173
203
|
a.lists.set(each, s);
|
|
174
204
|
mark(`each:${each}`, n);
|
|
@@ -197,6 +227,14 @@ function rootElementType(html) {
|
|
|
197
227
|
const el = elements[0];
|
|
198
228
|
return el.namespaceURI === HTML_NS ? `El<${JSON.stringify(el.tagName)}>` : "SVGElement";
|
|
199
229
|
}
|
|
230
|
+
/** The whitespace mode an existing generated module was written with; "preserve" when there is none. */
|
|
231
|
+
function recordedWhitespace(out) {
|
|
232
|
+
try {
|
|
233
|
+
return readFileSync(out, "utf8").split("\n", 3).includes(COLLAPSE_MARK) ? "collapse" : "preserve";
|
|
234
|
+
} catch {
|
|
235
|
+
return "preserve";
|
|
236
|
+
}
|
|
237
|
+
}
|
|
200
238
|
function globToRegExp(glob) {
|
|
201
239
|
let re = "";
|
|
202
240
|
for (let i = 0; i < glob.length; i++) {
|
|
@@ -252,10 +290,16 @@ function toNodes(parent, collapse = false) {
|
|
|
252
290
|
* tsc (or any bundler) can compile it directly: no plugin and no allowArbitraryExtensions needed.
|
|
253
291
|
* Consumers import it as `import tpl from "./x.html.js"` (TypeScript maps it to x.html.ts).
|
|
254
292
|
*/
|
|
255
|
-
function generateModule(html, options = {}, imports = []) {
|
|
293
|
+
function generateModule(html, options = {}, imports = [], defs = []) {
|
|
256
294
|
const runtime = options.runtime ?? "@nativele/ts-dom";
|
|
257
295
|
const a = analyze(html);
|
|
258
|
-
const lists = [...a.lists].map(([n, s]) =>
|
|
296
|
+
const lists = [...a.lists].map(([n, s]) => {
|
|
297
|
+
const bound = a.boundLists.get(n);
|
|
298
|
+
const def = bound && defs.find((d) => d.tag === bound.tag);
|
|
299
|
+
const source = !def ? "external" : def.exported ? "local" : "unexported";
|
|
300
|
+
const item = bound ? `; item: { tag: ${q(bound.tag)}; prop: ${q(bound.prop)}; key: ${q(bound.key)}; source: ${q(source)} }` : "";
|
|
301
|
+
return `${q(n)}: { refs: ${obj(s.refs)}; text: ${union(s.text)}; ifs: ${union(s.ifs)}${item} }`;
|
|
302
|
+
}).join("; ");
|
|
259
303
|
const type = `{
|
|
260
304
|
refs: ${obj(a.scope.refs)};
|
|
261
305
|
lists: { ${lists} };
|
|
@@ -266,8 +310,9 @@ function generateModule(html, options = {}, imports = []) {
|
|
|
266
310
|
events: ${union(a.events)};
|
|
267
311
|
}`;
|
|
268
312
|
const needsEl = type.includes("El<");
|
|
269
|
-
|
|
270
|
-
|
|
313
|
+
const side = imports.map((i) => `import ${q(i)};\n`).join("");
|
|
314
|
+
return `// Generated by ts-dom. Do not edit.${options.whitespace === "collapse" ? `\n${COLLAPSE_MARK}` : ""}
|
|
315
|
+
${side}import { template } from ${q(runtime)};
|
|
271
316
|
${needsEl ? `import type { El } from ${q(runtime)};\n` : ""}export default template<${type}>(${JSON.stringify(toNodes(parseFragment(html), options.whitespace === "collapse"))});
|
|
272
317
|
`;
|
|
273
318
|
}
|
|
@@ -284,21 +329,31 @@ function formatDiagnostics(file, ds) {
|
|
|
284
329
|
return ds.map((d) => `${file}:${d.line}:${d.col} ${d.severity} [${d.rule}] ${d.message}`).join("\n");
|
|
285
330
|
}
|
|
286
331
|
var CodegenError = class extends Error {};
|
|
287
|
-
/**
|
|
288
|
-
|
|
289
|
-
* `defs` is either a directory to scan or a precomputed scan result: custom elements used by the template
|
|
290
|
-
* are imported automatically from the modules that define them.
|
|
291
|
-
*/
|
|
292
|
-
function generateFile(htmlPath, options = {}, defs) {
|
|
332
|
+
/** What `generateFile` would write for a template, without writing it; throws CodegenError when the template has error diagnostics. */
|
|
333
|
+
function renderFile(htmlPath, options = {}, defs) {
|
|
293
334
|
const html = readFileSync(htmlPath, "utf8");
|
|
335
|
+
if (options.whitespace === void 0) options = {
|
|
336
|
+
...options,
|
|
337
|
+
whitespace: recordedWhitespace(modulePathFor(htmlPath))
|
|
338
|
+
};
|
|
294
339
|
const analysis = analyze(html);
|
|
295
340
|
const errors = analysis.diagnostics.filter((d) => d.severity === "error");
|
|
296
341
|
if (errors.length) throw new CodegenError(formatDiagnostics(htmlPath, errors));
|
|
297
342
|
const known = typeof defs === "string" ? scanDefines(defs) : defs ?? [];
|
|
298
343
|
const here = dirname(htmlPath);
|
|
299
344
|
const imports = [...new Set(known.filter((d) => analysis.customTags.has(d.tag)).map((d) => relImport(here, d.file)))].sort();
|
|
300
|
-
|
|
301
|
-
|
|
345
|
+
return {
|
|
346
|
+
out: modulePathFor(htmlPath),
|
|
347
|
+
next: generateModule(html, options, imports, known)
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Generate a single `x.html.ts`; throws CodegenError (and writes nothing) when there are error diagnostics.
|
|
352
|
+
* `defs` is either a directory to scan or a precomputed scan result: custom elements used by the template
|
|
353
|
+
* are imported automatically from the modules that define them.
|
|
354
|
+
*/
|
|
355
|
+
function generateFile(htmlPath, options = {}, defs) {
|
|
356
|
+
const { out, next } = renderFile(htmlPath, options, defs);
|
|
302
357
|
let prev = "";
|
|
303
358
|
try {
|
|
304
359
|
prev = readFileSync(out, "utf8");
|
|
@@ -309,7 +364,7 @@ function generateFile(htmlPath, options = {}, defs) {
|
|
|
309
364
|
function listHtml(dir) {
|
|
310
365
|
const out = [];
|
|
311
366
|
for (const name of readdirSync(dir)) {
|
|
312
|
-
if (name === "node_modules") continue;
|
|
367
|
+
if (name === "node_modules" || name === "dist") continue;
|
|
313
368
|
const full = join(dir, name);
|
|
314
369
|
if (statSync(full).isDirectory()) out.push(...listHtml(full));
|
|
315
370
|
else if (name.endsWith(".html") && !isFullPage(readFileSync(full, "utf8"))) out.push(full);
|
|
@@ -318,6 +373,7 @@ function listHtml(dir) {
|
|
|
318
373
|
}
|
|
319
374
|
const TAGS_FILE = "ts-dom-tags.ts";
|
|
320
375
|
const TAGS_HEADER = "// Generated by ts-dom. Do not edit.";
|
|
376
|
+
const COLLAPSE_MARK = "// whitespace: collapse";
|
|
321
377
|
function listTs(dir) {
|
|
322
378
|
const out = [];
|
|
323
379
|
for (const name of readdirSync(dir)) {
|
|
@@ -395,6 +451,20 @@ function scanTags(dir) {
|
|
|
395
451
|
* so that `refs.x` for `<tag data-ref="x">` in a template is fully typed (props, methods and emitted events).
|
|
396
452
|
*/
|
|
397
453
|
function generateTags(dir) {
|
|
454
|
+
const { out, next } = renderTags(dir);
|
|
455
|
+
if (next === null) {
|
|
456
|
+
if (existsSync(out) && readFileSync(out, "utf8").startsWith(TAGS_HEADER)) unlinkSync(out);
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
let prev = "";
|
|
460
|
+
try {
|
|
461
|
+
prev = readFileSync(out, "utf8");
|
|
462
|
+
} catch {}
|
|
463
|
+
if (prev !== next) writeFileSync(out, next);
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
466
|
+
/** What `generateTags` would write, without writing it; `next` is null when the directory defines no exported component. */
|
|
467
|
+
function renderTags(dir) {
|
|
398
468
|
const defs = scanTags(dir);
|
|
399
469
|
const out = join(dir, TAGS_FILE);
|
|
400
470
|
const seen = /* @__PURE__ */ new Map();
|
|
@@ -403,19 +473,86 @@ function generateTags(dir) {
|
|
|
403
473
|
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.`);
|
|
404
474
|
seen.set(d.tag, d);
|
|
405
475
|
}
|
|
406
|
-
if (!defs.length) {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
476
|
+
if (!defs.length) return {
|
|
477
|
+
out,
|
|
478
|
+
next: null
|
|
479
|
+
};
|
|
410
480
|
const alias = (d) => `Tag${[...seen.keys()].indexOf(d.tag)}`;
|
|
411
481
|
const imports = [...seen.values()].map((d) => `import type { ${d.isDefault ? "default" : d.className} as ${alias(d)} } from ${JSON.stringify(d.from)};`);
|
|
412
482
|
const entries = [...seen.values()].map((d) => ` ${JSON.stringify(d.tag)}: ${alias(d)};`);
|
|
413
|
-
|
|
414
|
-
|
|
483
|
+
return {
|
|
484
|
+
out,
|
|
485
|
+
next: `${TAGS_HEADER}\n${imports.join("\n")}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n${entries.join("\n")}\n }\n}\n\nexport {};\n`
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Everything `generateAll` would write for a directory, in memory: generated path to content, or null for a file that should not exist.
|
|
490
|
+
* Templates with error diagnostics are left out and their messages returned in `errors`.
|
|
491
|
+
*/
|
|
492
|
+
function planGenerated(dir, options = {}) {
|
|
493
|
+
const files = /* @__PURE__ */ new Map();
|
|
494
|
+
const errors = [];
|
|
495
|
+
const defs = scanDefines(dir);
|
|
496
|
+
for (const f of listHtml(dir)) {
|
|
497
|
+
if (!isSelected(dir, f, options)) continue;
|
|
498
|
+
try {
|
|
499
|
+
const { out, next } = renderFile(f, options, defs);
|
|
500
|
+
files.set(out, next);
|
|
501
|
+
} catch (e) {
|
|
502
|
+
if (e instanceof CodegenError) errors.push(e.message);
|
|
503
|
+
else throw e;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
415
506
|
try {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
507
|
+
const tags = renderTags(dir);
|
|
508
|
+
files.set(tags.out, tags.next);
|
|
509
|
+
} catch (e) {
|
|
510
|
+
if (e instanceof CodegenError) errors.push(e.message);
|
|
511
|
+
else throw e;
|
|
512
|
+
}
|
|
513
|
+
for (const f of listGenerated(dir)) if (!files.has(f) && !existsSync(f.slice(0, -3))) files.set(f, null);
|
|
514
|
+
return {
|
|
515
|
+
files,
|
|
516
|
+
errors
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
/** Generated files under `dir` that differ from what `ts-dom gen` would write now. */
|
|
520
|
+
function staleGenerated(dir, options = {}) {
|
|
521
|
+
return checkGenerated(dir, options).stale;
|
|
522
|
+
}
|
|
523
|
+
/** `staleGenerated`, plus the errors that would make `ts-dom gen` fail (template diagnostics, a tag defined twice). */
|
|
524
|
+
function checkGenerated(dir, options = {}) {
|
|
525
|
+
const plan = planGenerated(dir, options);
|
|
526
|
+
const out = [];
|
|
527
|
+
for (const [file, next] of plan.files) {
|
|
528
|
+
const prev = existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
529
|
+
if (next === null) {
|
|
530
|
+
if (prev !== null && prev.startsWith(TAGS_HEADER)) out.push({
|
|
531
|
+
file,
|
|
532
|
+
reason: file.endsWith(".html.ts") ? "orphan" : "outdated"
|
|
533
|
+
});
|
|
534
|
+
} else if (prev === null) out.push({
|
|
535
|
+
file,
|
|
536
|
+
reason: "missing"
|
|
537
|
+
});
|
|
538
|
+
else if (prev !== next) out.push({
|
|
539
|
+
file,
|
|
540
|
+
reason: "outdated"
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
stale: out.sort((a, b) => a.file.localeCompare(b.file)),
|
|
545
|
+
errors: plan.errors
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
function listGenerated(dir) {
|
|
549
|
+
const out = [];
|
|
550
|
+
for (const name of readdirSync(dir)) {
|
|
551
|
+
if (name === "node_modules") continue;
|
|
552
|
+
const full = join(dir, name);
|
|
553
|
+
if (statSync(full).isDirectory()) out.push(...listGenerated(full));
|
|
554
|
+
else if (name.endsWith(".html.ts") && readFileSync(full, "utf8").startsWith(TAGS_HEADER)) out.push(full);
|
|
555
|
+
}
|
|
419
556
|
return out;
|
|
420
557
|
}
|
|
421
558
|
/** Generate every `x.html.ts` (and the tag map) under a directory; collects all errors and throws them together. */
|
|
@@ -439,6 +576,7 @@ function generateAll(dir, options = {}) {
|
|
|
439
576
|
if (e instanceof CodegenError) errors.push(e.message);
|
|
440
577
|
else throw e;
|
|
441
578
|
}
|
|
579
|
+
for (const f of listGenerated(dir)) if (!existsSync(f.slice(0, -3))) unlinkSync(f);
|
|
442
580
|
if (errors.length) throw new CodegenError(errors.join("\n"));
|
|
443
581
|
return written;
|
|
444
582
|
}
|
|
@@ -498,10 +636,10 @@ const A11Y_RULES = {
|
|
|
498
636
|
"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.",
|
|
499
637
|
"heading-order": "Heading levels are skipped. Fix: do not jump levels (for example h1 straight to h3); use the next level down.",
|
|
500
638
|
"table-header": "<table> has no <th> header cells. Fix: add <th> cells, or set role=\"presentation\" if the table is only used for layout.",
|
|
501
|
-
"list-structure": "Invalid list structure. Fix: <ul>/<ol> may only contain <li
|
|
639
|
+
"list-structure": "Invalid list structure. Fix: <ul>/<ol> may only contain <li> (or a custom element with role=\"listitem\"), and <li> must be inside <ul>, <ol> or <menu>.",
|
|
502
640
|
"landmark-label": "There are several landmarks of the same kind. Fix: give each <nav> / <aside> a distinct aria-label.",
|
|
503
641
|
"redundant-role": "The role repeats the element's native role. Fix: remove the role attribute.",
|
|
504
|
-
"click-semantics": "data-on-click is on an element that is not
|
|
642
|
+
"click-semantics": "data-on-click is on an element that is not natively 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."
|
|
505
643
|
};
|
|
506
644
|
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(" "));
|
|
507
645
|
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}`));
|
|
@@ -629,10 +767,10 @@ function checkA11y(html, ignore = []) {
|
|
|
629
767
|
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.`);
|
|
630
768
|
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.`);
|
|
631
769
|
const tabindex = attr(el, "tabindex");
|
|
632
|
-
if (role && INTERACTIVE_ROLES.has(role) && !focusable(el) && tabindex === void 0 && !managed) report("role-tabindex", el, `<${tag} role="${role}"> is not
|
|
770
|
+
if (role && INTERACTIVE_ROLES.has(role) && !focusable(el) && tabindex === void 0 && !managed) report("role-tabindex", el, `<${tag} role="${role}"> is not natively focusable. Fix: add tabindex="0" and handle keyboard events, or use a native element such as <button>.`);
|
|
633
771
|
if (tabindex !== void 0 && Number(tabindex) > 0) report("tabindex-positive", el, `tabindex="${tabindex}" disturbs the natural focus order. Fix: use tabindex="0" or "-1".`);
|
|
634
772
|
if (attr(el, "aria-hidden") === "true" && (focusable(el) || tabindex !== void 0 && Number(tabindex) >= 0)) report("hidden-focusable", el);
|
|
635
|
-
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
|
|
773
|
+
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 natively 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.`);
|
|
636
774
|
if (/^h[1-6]$/.test(tag)) {
|
|
637
775
|
const level = Number(tag[1]);
|
|
638
776
|
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.`);
|
|
@@ -643,8 +781,16 @@ function checkA11y(html, ignore = []) {
|
|
|
643
781
|
if (!isEl(c) || c.tagName === "slot" || c.tagName === "script") continue;
|
|
644
782
|
if (c.tagName === "template") {
|
|
645
783
|
if (!has(c, "data-each")) continue;
|
|
646
|
-
for (const item of c.content.childNodes) if (isEl(item)
|
|
647
|
-
} else
|
|
784
|
+
for (const item of c.content.childNodes) if (isEl(item)) listItemProblem(item, tag, "The root element of a data-each item");
|
|
785
|
+
} else listItemProblem(c, tag, `A child of <${tag}>`);
|
|
786
|
+
}
|
|
787
|
+
function listItemProblem(item, listTag, what) {
|
|
788
|
+
if (item.tagName === "li") return;
|
|
789
|
+
if (item.tagName.includes("-")) {
|
|
790
|
+
if (attr(item, "role") !== "listitem") report("list-structure", item, `${what} inside <${listTag}> is the component <${item.tagName}>, which has no list item role. Fix: add role="listitem" to <${item.tagName}>, or make its root an <li>.`);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
report("list-structure", item, `${what} inside <${listTag}> must be <li> or a custom element with role="listitem", found <${item.tagName}>. Fix: use <li> as the item root.`);
|
|
648
794
|
}
|
|
649
795
|
if (tag === "li" && parent && ![
|
|
650
796
|
"ul",
|
|
@@ -668,4 +814,4 @@ function checkA11y(html, ignore = []) {
|
|
|
668
814
|
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.`);
|
|
669
815
|
return out.sort((a, b) => a.line - b.line || a.col - b.col);
|
|
670
816
|
}
|
|
671
|
-
export { analyze as a,
|
|
817
|
+
export { watchAll as _, analyze as a, generateAll as c, isSelected as d, listHtml as f, staleGenerated as g, scanDefines as h, CodegenError as i, generateFile as l, renderTags as m, checkA11y as n, checkGenerated as o, listTs as p, CODEGEN_RULES as r, formatDiagnostics as s, A11Y_RULES as t, isFullPage as u };
|