bettera11y 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/dist/index.cjs ADDED
@@ -0,0 +1,853 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ BETTERA11Y_API_VERSION: () => BETTERA11Y_API_VERSION,
24
+ MockAdapter: () => MockAdapter,
25
+ coreRules: () => coreRules,
26
+ createDiagnosticFingerprint: () => createDiagnosticFingerprint,
27
+ createEngine: () => createEngine,
28
+ createJsonReporter: () => createJsonReporter,
29
+ createMachineReporter: () => createMachineReporter,
30
+ createPrettyReporter: () => createPrettyReporter,
31
+ createRangeLocation: () => createRangeLocation,
32
+ defaultRules: () => defaultRules,
33
+ getSha256: () => getSha256,
34
+ positionFromOffset: () => positionFromOffset,
35
+ recommendedPreset: () => recommendedPreset,
36
+ selectorToSourceLocation: () => selectorToSourceLocation,
37
+ strictPreset: () => strictPreset,
38
+ translateSeverity: () => translateSeverity,
39
+ wcagAaBaselinePreset: () => wcagAaBaselinePreset
40
+ });
41
+ module.exports = __toCommonJS(index_exports);
42
+
43
+ // src/dom.ts
44
+ var import_jsdom = require("jsdom");
45
+
46
+ // src/utils/hash.ts
47
+ var import_node_crypto = require("crypto");
48
+ function getSha256(value) {
49
+ return (0, import_node_crypto.createHash)("sha256").update(value).digest("hex");
50
+ }
51
+
52
+ // src/utils/fingerprint.ts
53
+ function createDiagnosticFingerprint(diagnostic) {
54
+ return getSha256(
55
+ JSON.stringify({
56
+ ruleId: diagnostic.ruleId,
57
+ message: diagnostic.message,
58
+ severity: diagnostic.severity,
59
+ selector: diagnostic.location?.selector,
60
+ start: diagnostic.location?.start,
61
+ end: diagnostic.location?.end
62
+ })
63
+ ).slice(0, 16);
64
+ }
65
+
66
+ // src/utils/positions.ts
67
+ function positionFromOffset(source, offset) {
68
+ const safeOffset = Math.max(0, Math.min(offset, source.length));
69
+ let line = 1;
70
+ let column = 1;
71
+ for (let i = 0; i < safeOffset; i += 1) {
72
+ if (source[i] === "\n") {
73
+ line += 1;
74
+ column = 1;
75
+ } else {
76
+ column += 1;
77
+ }
78
+ }
79
+ return { line, column, offset: safeOffset };
80
+ }
81
+ function createRangeLocation(source, startOffset, endOffset, selector, sourcePath) {
82
+ return {
83
+ sourcePath,
84
+ selector,
85
+ start: positionFromOffset(source, startOffset),
86
+ end: positionFromOffset(source, endOffset)
87
+ };
88
+ }
89
+ function selectorToSourceLocation(source, selector, sourcePath) {
90
+ if (!selector) {
91
+ return { sourcePath };
92
+ }
93
+ const tag = selector.split(/[.#:\s>]/).find(Boolean);
94
+ if (!tag) {
95
+ return { sourcePath, selector };
96
+ }
97
+ const openTag = `<${tag}`;
98
+ const startOffset = source.indexOf(openTag);
99
+ if (startOffset === -1) {
100
+ return { sourcePath, selector };
101
+ }
102
+ const endOffset = startOffset + openTag.length;
103
+ return createRangeLocation(
104
+ source,
105
+ startOffset,
106
+ endOffset,
107
+ selector,
108
+ sourcePath
109
+ );
110
+ }
111
+ function translateSeverity(severity, target) {
112
+ if (target === "eslint") {
113
+ if (severity === "error") return 2;
114
+ if (severity === "warn") return 1;
115
+ return 0;
116
+ }
117
+ if (target === "vite") {
118
+ if (severity === "error") return "error";
119
+ if (severity === "warn") return "warning";
120
+ return "info";
121
+ }
122
+ return severity.toUpperCase();
123
+ }
124
+
125
+ // src/dom.ts
126
+ function createDocumentFromHtml(html) {
127
+ return new import_jsdom.JSDOM(html).window.document;
128
+ }
129
+ function normalizeInputToHtml(input) {
130
+ if (input.kind === "html" || input.kind === "dom-snapshot") {
131
+ return input.html;
132
+ }
133
+ if (input.kind === "virtual-file") {
134
+ return input.content;
135
+ }
136
+ return input.fallbackHtml ?? null;
137
+ }
138
+ function createElementSelector(element) {
139
+ const id = element.getAttribute("id");
140
+ if (id) {
141
+ return `#${id}`;
142
+ }
143
+ const parts = [];
144
+ let current = element;
145
+ while (current && current.tagName.toLowerCase() !== "html") {
146
+ const tag = current.tagName.toLowerCase();
147
+ const parent = current.parentElement;
148
+ if (!parent) {
149
+ parts.unshift(tag);
150
+ break;
151
+ }
152
+ const siblings = Array.from(parent.children).filter(
153
+ (child) => child.tagName === current?.tagName
154
+ );
155
+ const index = siblings.indexOf(current) + 1;
156
+ parts.unshift(`${tag}:nth-of-type(${index})`);
157
+ current = parent;
158
+ }
159
+ return parts.join(" > ");
160
+ }
161
+ function locateElementInSource(source, element, selector, sourcePath) {
162
+ const tag = element.tagName.toLowerCase();
163
+ const openTag = `<${tag}`;
164
+ const offset = source.indexOf(openTag);
165
+ if (offset === -1) {
166
+ return { selector, sourcePath };
167
+ }
168
+ return createRangeLocation(
169
+ source,
170
+ offset,
171
+ offset + openTag.length,
172
+ selector,
173
+ sourcePath
174
+ );
175
+ }
176
+
177
+ // src/contracts/session.ts
178
+ var BETTERA11Y_API_VERSION = "1";
179
+
180
+ // src/engine.ts
181
+ function createEngine(initialRules = [], config = {}) {
182
+ const ruleMap = /* @__PURE__ */ new Map();
183
+ const cache = /* @__PURE__ */ new Map();
184
+ initialRules.forEach((rule) => {
185
+ ruleMap.set(rule.meta.id, rule);
186
+ });
187
+ const listRules = () => Array.from(ruleMap.values()).sort(
188
+ (a, b) => a.meta.id.localeCompare(b.meta.id)
189
+ );
190
+ const run = async (input, signal) => {
191
+ if (signal?.aborted) {
192
+ throw new Error("Audit cancelled before start.");
193
+ }
194
+ const start = performance.now();
195
+ const html = normalizeInputToHtml(input);
196
+ if (!html) {
197
+ return {
198
+ diagnostics: [],
199
+ metadata: {
200
+ inputId: input.id,
201
+ sourcePath: input.source.path,
202
+ cacheHit: false,
203
+ durationMs: Number((performance.now() - start).toFixed(3)),
204
+ ruleTimingsMs: {}
205
+ }
206
+ };
207
+ }
208
+ const rules = listRules().filter(
209
+ (rule) => config.enabledRules?.[rule.meta.id] !== false
210
+ );
211
+ const cacheKey = getSha256(
212
+ `${config.apiVersion ?? BETTERA11Y_API_VERSION}:${input.source.contentHash ?? getSha256(html)}:${rules.map(
213
+ (rule) => `${rule.meta.id}:${config.severityOverrides?.[rule.meta.id] ?? "default"}`
214
+ ).join("|")}`
215
+ );
216
+ const cached = cache.get(cacheKey);
217
+ if (cached) {
218
+ return {
219
+ ...cached.result,
220
+ metadata: {
221
+ ...cached.result.metadata,
222
+ cacheHit: true,
223
+ durationMs: Number((performance.now() - start).toFixed(3))
224
+ }
225
+ };
226
+ }
227
+ const document = createDocumentFromHtml(html);
228
+ const ruleTimingsMs = {};
229
+ const diagnostics = [];
230
+ for (const rule of rules) {
231
+ if (signal?.aborted) {
232
+ throw new Error("Audit cancelled.");
233
+ }
234
+ const ruleStart = performance.now();
235
+ const emitted = await rule.check({
236
+ document,
237
+ input,
238
+ createSelector: createElementSelector,
239
+ locate(element) {
240
+ const selector = createElementSelector(element);
241
+ return locateElementInSource(
242
+ html,
243
+ element,
244
+ selector,
245
+ input.source.path
246
+ );
247
+ },
248
+ signal
249
+ });
250
+ ruleTimingsMs[rule.meta.id] = Number(
251
+ (performance.now() - ruleStart).toFixed(3)
252
+ );
253
+ for (const item of emitted) {
254
+ const severity = config.severityOverrides?.[rule.meta.id] ?? item.severity ?? rule.meta.defaultSeverity;
255
+ const normalized = {
256
+ ...item,
257
+ severity,
258
+ category: item.category ?? rule.meta.category,
259
+ location: item.location ?? selectorToSourceLocation(html, void 0, input.source.path),
260
+ metadata: {
261
+ docsUrl: rule.meta.docsUrl,
262
+ tags: rule.meta.tags,
263
+ ...item.metadata
264
+ }
265
+ };
266
+ diagnostics.push({
267
+ ...normalized,
268
+ id: createDiagnosticFingerprint(normalized)
269
+ });
270
+ }
271
+ }
272
+ const result = {
273
+ diagnostics,
274
+ metadata: {
275
+ inputId: input.id,
276
+ sourcePath: input.source.path,
277
+ cacheHit: false,
278
+ durationMs: Number((performance.now() - start).toFixed(3)),
279
+ ruleTimingsMs
280
+ }
281
+ };
282
+ cache.set(cacheKey, { result, createdAt: Date.now() });
283
+ return result;
284
+ };
285
+ const runIncremental = async (request, signal) => {
286
+ const output = [];
287
+ for (const change of request.changes) {
288
+ output.push(await run(change, signal));
289
+ }
290
+ return output;
291
+ };
292
+ return {
293
+ registerRule(rule) {
294
+ ruleMap.set(rule.meta.id, rule);
295
+ },
296
+ unregisterRule(ruleId) {
297
+ ruleMap.delete(ruleId);
298
+ },
299
+ listRules,
300
+ run,
301
+ runIncremental,
302
+ createAuditSession(telemetry) {
303
+ let started = false;
304
+ return {
305
+ async start() {
306
+ started = true;
307
+ telemetry?.emit({ type: "session-start" });
308
+ },
309
+ async stop() {
310
+ started = false;
311
+ telemetry?.emit({ type: "session-stop" });
312
+ },
313
+ async run(input, signal) {
314
+ if (!started) {
315
+ throw new Error("Audit session must be started before run.");
316
+ }
317
+ const result = await run(input, signal);
318
+ telemetry?.emit({
319
+ type: "audit-run",
320
+ durationMs: result.metadata.durationMs,
321
+ cacheHit: result.metadata.cacheHit
322
+ });
323
+ return result;
324
+ },
325
+ async runIncremental(request, signal) {
326
+ if (!started) {
327
+ throw new Error(
328
+ "Audit session must be started before runIncremental."
329
+ );
330
+ }
331
+ return runIncremental(request, signal);
332
+ }
333
+ };
334
+ }
335
+ };
336
+ }
337
+
338
+ // src/reporters.ts
339
+ function createJsonReporter() {
340
+ return {
341
+ format(result) {
342
+ return JSON.stringify(result, null, 2);
343
+ }
344
+ };
345
+ }
346
+ function createMachineReporter() {
347
+ return {
348
+ format(result) {
349
+ return JSON.stringify({
350
+ diagnostics: result.diagnostics,
351
+ metadata: result.metadata
352
+ });
353
+ }
354
+ };
355
+ }
356
+ function createPrettyReporter() {
357
+ return {
358
+ format(result) {
359
+ if (result.diagnostics.length === 0) {
360
+ return "No accessibility warnings detected.";
361
+ }
362
+ const lines = result.diagnostics.map((diagnostic, index) => {
363
+ const at = diagnostic.location?.selector ? ` at ${diagnostic.location.selector}` : "";
364
+ const remediation = diagnostic.remediation ? `
365
+ remediation: ${diagnostic.remediation}` : "";
366
+ return `${index + 1}. [${diagnostic.severity}] ${diagnostic.ruleId}: ${diagnostic.message}${at}${remediation}`;
367
+ });
368
+ return lines.join("\n");
369
+ }
370
+ };
371
+ }
372
+
373
+ // src/adapters.ts
374
+ var MockAdapter = class {
375
+ emit;
376
+ options;
377
+ receivedInputs = [];
378
+ diagnostics = [];
379
+ started = false;
380
+ constructor(options) {
381
+ this.options = options;
382
+ this.emit = options.emit;
383
+ }
384
+ start() {
385
+ this.started = true;
386
+ }
387
+ stop() {
388
+ this.started = false;
389
+ }
390
+ async onInput(input) {
391
+ this.receivedInputs.push(input);
392
+ const result = await this.emit(input);
393
+ await this.onDiagnostics(result);
394
+ }
395
+ async onDiagnostics(result) {
396
+ this.diagnostics.push(result);
397
+ await this.options.diagnosticsSink?.onResult(result);
398
+ }
399
+ async pump() {
400
+ if (!this.options.inputProvider) {
401
+ return 0;
402
+ }
403
+ let processed = 0;
404
+ while (this.started) {
405
+ const input = await this.options.inputProvider.nextInput();
406
+ if (!input) {
407
+ break;
408
+ }
409
+ await this.onInput(input);
410
+ processed += 1;
411
+ }
412
+ return processed;
413
+ }
414
+ };
415
+
416
+ // src/rules/core/helpers.ts
417
+ function hasAccessibleName(element) {
418
+ const ariaLabel = element.getAttribute("aria-label");
419
+ if (ariaLabel && ariaLabel.trim()) return true;
420
+ const ariaLabelledBy = element.getAttribute("aria-labelledby");
421
+ if (ariaLabelledBy && ariaLabelledBy.trim()) return true;
422
+ const title = element.getAttribute("title");
423
+ if (title && title.trim()) return true;
424
+ return Boolean(element.textContent?.trim());
425
+ }
426
+
427
+ // src/rules/core/forms-media-rules.ts
428
+ var imageAltRule = {
429
+ meta: {
430
+ id: "image-alt",
431
+ description: "Detect images without alternative text.",
432
+ category: "media",
433
+ defaultSeverity: "warn",
434
+ tags: ["wcag-1.1.1"]
435
+ },
436
+ check({ document, locate }) {
437
+ if (!document) return [];
438
+ return Array.from(document.querySelectorAll("img")).flatMap((img) => {
439
+ if (img.hasAttribute("alt")) return [];
440
+ return [
441
+ {
442
+ ruleId: "image-alt",
443
+ message: "Image is missing an alt attribute.",
444
+ severity: "warn",
445
+ category: "media",
446
+ remediation: "Provide meaningful alt text or empty alt for decorative images.",
447
+ location: locate(img)
448
+ }
449
+ ];
450
+ });
451
+ }
452
+ };
453
+ var formControlLabelRule = {
454
+ meta: {
455
+ id: "form-control-label",
456
+ description: "Detect form controls without labels.",
457
+ category: "forms",
458
+ defaultSeverity: "error",
459
+ tags: ["forms", "wcag-3.3.2"]
460
+ },
461
+ check({ document, locate }) {
462
+ if (!document) return [];
463
+ const controls = Array.from(
464
+ document.querySelectorAll("input,select,textarea")
465
+ ).filter((el) => el.getAttribute("type")?.toLowerCase() !== "hidden");
466
+ return controls.flatMap((control) => {
467
+ const id = control.getAttribute("id");
468
+ const explicitLabel = id ? document.querySelector(`label[for="${id}"]`) : null;
469
+ const hasLabel = Boolean(explicitLabel) || Boolean(control.closest("label")) || Boolean(control.getAttribute("aria-label")) || Boolean(control.getAttribute("aria-labelledby"));
470
+ if (hasLabel) return [];
471
+ return [
472
+ {
473
+ ruleId: "form-control-label",
474
+ message: "Form control does not have an associated accessible label.",
475
+ severity: "error",
476
+ category: "forms",
477
+ remediation: "Associate a <label> or provide aria-label/aria-labelledby.",
478
+ location: locate(control)
479
+ }
480
+ ];
481
+ });
482
+ }
483
+ };
484
+ var buttonAccessibleNameRule = {
485
+ meta: {
486
+ id: "button-accessible-name",
487
+ description: "Ensure buttons have accessible names.",
488
+ category: "aria",
489
+ defaultSeverity: "error",
490
+ tags: ["controls", "wcag-4.1.2"]
491
+ },
492
+ check({ document, locate }) {
493
+ if (!document) return [];
494
+ const buttons = Array.from(document.querySelectorAll("button"));
495
+ return buttons.flatMap(
496
+ (button) => hasAccessibleName(button) ? [] : [
497
+ {
498
+ ruleId: "button-accessible-name",
499
+ message: "Button is missing an accessible name.",
500
+ severity: "error",
501
+ category: "aria",
502
+ remediation: "Provide text content, aria-label, or aria-labelledby.",
503
+ location: locate(button)
504
+ }
505
+ ]
506
+ );
507
+ }
508
+ };
509
+
510
+ // src/rules/core/structure-rules.ts
511
+ var duplicateIdRule = {
512
+ meta: {
513
+ id: "duplicate-id",
514
+ description: "Detect duplicate element ids.",
515
+ category: "structure",
516
+ defaultSeverity: "error",
517
+ tags: ["wcag-4.1.1"]
518
+ },
519
+ check({ document, createSelector, locate }) {
520
+ if (!document) return [];
521
+ const idMap = /* @__PURE__ */ new Map();
522
+ document.querySelectorAll("[id]").forEach((element) => {
523
+ const id = element.getAttribute("id");
524
+ if (!id) return;
525
+ const list = idMap.get(id) ?? [];
526
+ list.push(element);
527
+ idMap.set(id, list);
528
+ });
529
+ return Array.from(idMap.entries()).flatMap(
530
+ ([id, elements]) => elements.length < 2 ? [] : elements.map((element) => {
531
+ const selector = createSelector(element);
532
+ return {
533
+ ruleId: "duplicate-id",
534
+ message: `Duplicate id "${id}" found.`,
535
+ severity: "error",
536
+ category: "structure",
537
+ remediation: "Ensure each id value is unique within a page.",
538
+ location: locate(element),
539
+ metadata: { tags: ["id", "structure"] }
540
+ };
541
+ })
542
+ );
543
+ }
544
+ };
545
+ var duplicateH1Rule = {
546
+ meta: {
547
+ id: "duplicate-h1",
548
+ description: "Detect multiple h1 elements in one document.",
549
+ category: "structure",
550
+ defaultSeverity: "warn",
551
+ tags: ["headings"]
552
+ },
553
+ check({ document, locate }) {
554
+ if (!document) return [];
555
+ const h1s = Array.from(document.querySelectorAll("h1"));
556
+ if (h1s.length <= 1) return [];
557
+ return h1s.map((h1) => ({
558
+ ruleId: "duplicate-h1",
559
+ message: "Multiple h1 elements found in the same document.",
560
+ severity: "warn",
561
+ category: "structure",
562
+ remediation: "Use a single primary h1 and nest sections under lower heading levels.",
563
+ location: locate(h1)
564
+ }));
565
+ }
566
+ };
567
+ var headingOrderRule = {
568
+ meta: {
569
+ id: "heading-order",
570
+ description: "Detect heading level jumps.",
571
+ category: "semantics",
572
+ defaultSeverity: "warn",
573
+ tags: ["headings", "wcag-1.3.1"]
574
+ },
575
+ check({ document, locate }) {
576
+ if (!document) return [];
577
+ const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"));
578
+ const diagnostics = [];
579
+ let previous = 0;
580
+ for (const heading of headings) {
581
+ const level = Number(heading.tagName.toLowerCase().slice(1));
582
+ if (previous > 0 && level > previous + 1) {
583
+ diagnostics.push({
584
+ ruleId: "heading-order",
585
+ message: `Heading level jumps from h${previous} to h${level}.`,
586
+ severity: "warn",
587
+ category: "semantics",
588
+ remediation: "Use sequential heading levels to preserve document outline.",
589
+ location: locate(heading)
590
+ });
591
+ }
592
+ previous = level;
593
+ }
594
+ return diagnostics;
595
+ }
596
+ };
597
+ var htmlLangRule = {
598
+ meta: {
599
+ id: "html-lang",
600
+ description: "Ensure html element defines a language.",
601
+ category: "semantics",
602
+ defaultSeverity: "error",
603
+ tags: ["wcag-3.1.1"]
604
+ },
605
+ check({ document, locate }) {
606
+ if (!document) return [];
607
+ const html = document.documentElement;
608
+ if (!html || html.getAttribute("lang")) return [];
609
+ return [
610
+ {
611
+ ruleId: "html-lang",
612
+ message: "The <html> element is missing a lang attribute.",
613
+ severity: "error",
614
+ category: "semantics",
615
+ remediation: 'Add a valid language code, for example <html lang="en">.',
616
+ location: locate(html)
617
+ }
618
+ ];
619
+ }
620
+ };
621
+
622
+ // src/rules/core/aria-keyboard-rules.ts
623
+ var VALID_ARIA_ATTRIBUTES = /* @__PURE__ */ new Set([
624
+ "aria-label",
625
+ "aria-labelledby",
626
+ "aria-describedby",
627
+ "aria-hidden",
628
+ "aria-expanded",
629
+ "aria-controls",
630
+ "aria-checked",
631
+ "aria-pressed",
632
+ "aria-current",
633
+ "aria-live",
634
+ "aria-invalid",
635
+ "aria-required",
636
+ "aria-selected",
637
+ "aria-role-description"
638
+ ]);
639
+ var BOOLEAN_ARIA_ATTRIBUTES = /* @__PURE__ */ new Set([
640
+ "aria-hidden",
641
+ "aria-expanded",
642
+ "aria-checked",
643
+ "aria-pressed",
644
+ "aria-invalid",
645
+ "aria-required",
646
+ "aria-selected"
647
+ ]);
648
+ var invalidAriaRule = {
649
+ meta: {
650
+ id: "invalid-aria",
651
+ description: "Detect invalid aria attributes and values.",
652
+ category: "aria",
653
+ defaultSeverity: "error",
654
+ tags: ["aria", "wcag-4.1.2"]
655
+ },
656
+ check({ document, locate }) {
657
+ if (!document) return [];
658
+ const diagnostics = [];
659
+ for (const element of Array.from(document.querySelectorAll("*"))) {
660
+ for (const attr of element.getAttributeNames()) {
661
+ if (!attr.startsWith("aria-")) continue;
662
+ if (!VALID_ARIA_ATTRIBUTES.has(attr)) {
663
+ diagnostics.push({
664
+ ruleId: "invalid-aria",
665
+ message: `Invalid ARIA attribute "${attr}".`,
666
+ severity: "error",
667
+ category: "aria",
668
+ remediation: "Use only valid WAI-ARIA attributes.",
669
+ location: locate(element)
670
+ });
671
+ continue;
672
+ }
673
+ if (BOOLEAN_ARIA_ATTRIBUTES.has(attr)) {
674
+ const value = element.getAttribute(attr);
675
+ if (value !== "true" && value !== "false") {
676
+ diagnostics.push({
677
+ ruleId: "invalid-aria",
678
+ message: `ARIA boolean attribute "${attr}" must be "true" or "false".`,
679
+ severity: "error",
680
+ category: "aria",
681
+ remediation: `Set ${attr} to "true" or "false".`,
682
+ location: locate(element)
683
+ });
684
+ }
685
+ }
686
+ }
687
+ }
688
+ return diagnostics;
689
+ }
690
+ };
691
+ var interactiveRoleNameRule = {
692
+ meta: {
693
+ id: "interactive-role-name",
694
+ description: "Detect custom interactive roles without accessible names.",
695
+ category: "aria",
696
+ defaultSeverity: "warn",
697
+ tags: ["aria", "roles"]
698
+ },
699
+ check({ document, locate }) {
700
+ if (!document) return [];
701
+ const interactiveRoles = /* @__PURE__ */ new Set([
702
+ "button",
703
+ "switch",
704
+ "checkbox",
705
+ "menuitem"
706
+ ]);
707
+ return Array.from(document.querySelectorAll("[role]")).flatMap(
708
+ (element) => {
709
+ const role = element.getAttribute("role");
710
+ if (!role || !interactiveRoles.has(role) || hasAccessibleName(element)) {
711
+ return [];
712
+ }
713
+ return [
714
+ {
715
+ ruleId: "interactive-role-name",
716
+ message: `Element with role="${role}" is missing an accessible name.`,
717
+ severity: "warn",
718
+ category: "aria",
719
+ remediation: "Provide aria-label, aria-labelledby, or meaningful text content.",
720
+ location: locate(element)
721
+ }
722
+ ];
723
+ }
724
+ );
725
+ }
726
+ };
727
+ var positiveTabindexRule = {
728
+ meta: {
729
+ id: "positive-tabindex",
730
+ description: "Detect positive tabindex usage.",
731
+ category: "keyboard",
732
+ defaultSeverity: "warn",
733
+ tags: ["keyboard", "focus-order"]
734
+ },
735
+ check({ document, locate }) {
736
+ if (!document) return [];
737
+ return Array.from(document.querySelectorAll("[tabindex]")).flatMap(
738
+ (element) => {
739
+ const value = Number(element.getAttribute("tabindex"));
740
+ if (Number.isNaN(value) || value <= 0) return [];
741
+ return [
742
+ {
743
+ ruleId: "positive-tabindex",
744
+ message: "Positive tabindex can create confusing keyboard focus order.",
745
+ severity: "warn",
746
+ category: "keyboard",
747
+ remediation: 'Use tabindex="0" or rely on natural DOM order when possible.',
748
+ location: locate(element)
749
+ }
750
+ ];
751
+ }
752
+ );
753
+ }
754
+ };
755
+
756
+ // src/rules/core/landmark-rules.ts
757
+ var mainLandmarkRule = {
758
+ meta: {
759
+ id: "main-landmark",
760
+ description: "Require exactly one main landmark.",
761
+ category: "landmarks",
762
+ defaultSeverity: "warn",
763
+ tags: ["landmarks", "wcag-1.3.1"]
764
+ },
765
+ check({ document, locate }) {
766
+ if (!document) return [];
767
+ const mains = Array.from(document.querySelectorAll("main, [role='main']"));
768
+ if (mains.length === 1) return [];
769
+ if (mains.length === 0) {
770
+ return [
771
+ {
772
+ ruleId: "main-landmark",
773
+ message: "Document is missing a main landmark.",
774
+ severity: "warn",
775
+ category: "landmarks",
776
+ remediation: 'Add a <main> element (or role="main") around core page content.'
777
+ }
778
+ ];
779
+ }
780
+ return mains.map((main) => ({
781
+ ruleId: "main-landmark",
782
+ message: "Document has multiple main landmarks.",
783
+ severity: "warn",
784
+ category: "landmarks",
785
+ remediation: "Keep only one top-level main landmark per page.",
786
+ location: locate(main)
787
+ }));
788
+ }
789
+ };
790
+
791
+ // src/rules/default-rules.ts
792
+ var coreRules = [
793
+ duplicateIdRule,
794
+ duplicateH1Rule,
795
+ headingOrderRule,
796
+ htmlLangRule,
797
+ imageAltRule,
798
+ formControlLabelRule,
799
+ buttonAccessibleNameRule,
800
+ invalidAriaRule,
801
+ interactiveRoleNameRule,
802
+ positiveTabindexRule,
803
+ mainLandmarkRule
804
+ ];
805
+ var defaultRules = coreRules;
806
+
807
+ // src/presets/index.ts
808
+ function byId(ids) {
809
+ const idSet = new Set(ids);
810
+ return coreRules.filter((rule) => idSet.has(rule.meta.id));
811
+ }
812
+ var recommendedPreset = byId([
813
+ "duplicate-id",
814
+ "heading-order",
815
+ "image-alt",
816
+ "form-control-label",
817
+ "button-accessible-name",
818
+ "invalid-aria",
819
+ "main-landmark"
820
+ ]);
821
+ var strictPreset = [...coreRules];
822
+ var wcagAaBaselinePreset = byId([
823
+ "duplicate-id",
824
+ "heading-order",
825
+ "html-lang",
826
+ "image-alt",
827
+ "form-control-label",
828
+ "button-accessible-name",
829
+ "invalid-aria",
830
+ "main-landmark",
831
+ "positive-tabindex"
832
+ ]);
833
+ // Annotate the CommonJS export names for ESM import in node:
834
+ 0 && (module.exports = {
835
+ BETTERA11Y_API_VERSION,
836
+ MockAdapter,
837
+ coreRules,
838
+ createDiagnosticFingerprint,
839
+ createEngine,
840
+ createJsonReporter,
841
+ createMachineReporter,
842
+ createPrettyReporter,
843
+ createRangeLocation,
844
+ defaultRules,
845
+ getSha256,
846
+ positionFromOffset,
847
+ recommendedPreset,
848
+ selectorToSourceLocation,
849
+ strictPreset,
850
+ translateSeverity,
851
+ wcagAaBaselinePreset
852
+ });
853
+ //# sourceMappingURL=index.cjs.map