next-a11y 0.1.0

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.
@@ -0,0 +1,2124 @@
1
+ import {
2
+ DEFAULT_CONFIG,
3
+ DEFAULT_RULES,
4
+ PROVIDER_DEFAULTS,
5
+ PROVIDER_ENV
6
+ } from "../chunk-PE4WYXR5.mjs";
7
+
8
+ // src/cli/index.ts
9
+ import { Command } from "commander";
10
+
11
+ // src/config/resolve.ts
12
+ import * as fs from "fs";
13
+ import * as path from "path";
14
+ async function loadConfigFile(cwd) {
15
+ const configNames = ["a11y.config.ts", "a11y.config.js", "a11y.config.mjs"];
16
+ for (const name of configNames) {
17
+ const configPath = path.join(cwd, name);
18
+ if (fs.existsSync(configPath)) {
19
+ try {
20
+ if (name.endsWith(".js") || name.endsWith(".mjs")) {
21
+ const mod2 = await import(configPath);
22
+ return mod2.default ?? mod2;
23
+ }
24
+ const { createJiti } = await import("jiti").catch(() => ({ createJiti: null }));
25
+ if (createJiti) {
26
+ const jiti = createJiti(configPath);
27
+ const mod2 = await jiti.import(configPath);
28
+ return mod2.default ?? mod2;
29
+ }
30
+ const mod = await import(configPath);
31
+ return mod.default ?? mod;
32
+ } catch {
33
+ }
34
+ }
35
+ }
36
+ return {};
37
+ }
38
+ function resolveConfig(fileConfig, cliFlags = {}) {
39
+ const merged = deepMerge(DEFAULT_CONFIG, fileConfig);
40
+ const provider = cliFlags.provider ?? merged.provider;
41
+ const model = cliFlags.model ?? merged.model ?? (provider ? PROVIDER_DEFAULTS[provider] : "gpt-4.1-nano");
42
+ return {
43
+ provider,
44
+ model,
45
+ locale: merged.locale ?? "en",
46
+ cache: merged.cache ?? ".a11y-cache",
47
+ scanner: {
48
+ include: merged.scanner?.include ?? DEFAULT_CONFIG.scanner.include,
49
+ exclude: merged.scanner?.exclude ?? DEFAULT_CONFIG.scanner.exclude
50
+ },
51
+ rules: { ...DEFAULT_RULES, ...merged.rules },
52
+ fix: cliFlags.fix ?? false,
53
+ interactive: cliFlags.interactive ?? false,
54
+ noAi: cliFlags.noAi ?? false,
55
+ minScore: cliFlags.minScore
56
+ };
57
+ }
58
+ function deepMerge(target, source) {
59
+ const result = { ...target };
60
+ for (const key of Object.keys(source)) {
61
+ const val = source[key];
62
+ if (val === void 0) continue;
63
+ if (typeof val === "object" && val !== null && !Array.isArray(val) && typeof result[key] === "object" && result[key] !== null) {
64
+ result[key] = { ...result[key], ...val };
65
+ } else {
66
+ result[key] = val;
67
+ }
68
+ }
69
+ return result;
70
+ }
71
+
72
+ // src/scan/scan.ts
73
+ import * as path4 from "path";
74
+ import { Project } from "ts-morph";
75
+
76
+ // src/scan/glob.ts
77
+ import * as fs2 from "fs";
78
+ import * as path2 from "path";
79
+ async function discoverFiles(basePath, include, exclude) {
80
+ const absBase = path2.resolve(basePath);
81
+ const allFiles = [];
82
+ if (typeof fs2.glob === "function") {
83
+ for (const pattern of include) {
84
+ try {
85
+ const matches = await new Promise((resolve3, reject) => {
86
+ fs2.glob(
87
+ pattern,
88
+ { cwd: absBase },
89
+ (err, files) => {
90
+ if (err) reject(err);
91
+ else resolve3(files);
92
+ }
93
+ );
94
+ });
95
+ for (const f of matches) {
96
+ allFiles.push(path2.join(absBase, f));
97
+ }
98
+ } catch {
99
+ return fallbackGlob(absBase, include, exclude);
100
+ }
101
+ }
102
+ } else {
103
+ return fallbackGlob(absBase, include, exclude);
104
+ }
105
+ return filterExcluded(allFiles, exclude);
106
+ }
107
+ async function fallbackGlob(basePath, include, exclude) {
108
+ const allFiles = await walkDir(basePath);
109
+ const matched = allFiles.filter((f) => {
110
+ const rel = path2.relative(basePath, f);
111
+ return include.some((pattern) => matchGlob(rel, pattern));
112
+ });
113
+ return filterExcluded(matched, exclude);
114
+ }
115
+ function filterExcluded(files, exclude) {
116
+ if (exclude.length === 0) return files;
117
+ return files.filter((f) => {
118
+ const rel = path2.relative(path2.dirname(f), f);
119
+ return !exclude.some(
120
+ (pattern) => matchGlob(path2.basename(f), pattern) || matchGlob(rel, pattern) || matchGlob(f, pattern)
121
+ );
122
+ });
123
+ }
124
+ async function walkDir(dir) {
125
+ const results = [];
126
+ try {
127
+ const entries = await fs2.promises.readdir(dir, { withFileTypes: true });
128
+ for (const entry of entries) {
129
+ const fullPath = path2.join(dir, entry.name);
130
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
131
+ if (entry.isDirectory()) {
132
+ results.push(...await walkDir(fullPath));
133
+ } else if (entry.isFile()) {
134
+ results.push(fullPath);
135
+ }
136
+ }
137
+ } catch {
138
+ }
139
+ return results;
140
+ }
141
+ function matchGlob(filePath, pattern) {
142
+ const normalized = filePath.replace(/\\/g, "/");
143
+ const regex = globToRegex(pattern);
144
+ return regex.test(normalized);
145
+ }
146
+ function globToRegex(pattern) {
147
+ let regex = pattern.replace(/\\/g, "/");
148
+ regex = regex.replace(/[.+^${}()|[\]\\]/g, "\\$&");
149
+ regex = regex.replace(/\*\*/g, "___GLOBSTAR___");
150
+ regex = regex.replace(/\*/g, "[^/]*");
151
+ regex = regex.replace(/___GLOBSTAR___/g, ".*");
152
+ regex = regex.replace(/\?/g, "[^/]");
153
+ regex = regex.replace(/\\{([^}]+)\\}/g, (_, contents) => {
154
+ const alternatives = contents.split(",").map((s) => s.trim());
155
+ return `(${alternatives.join("|")})`;
156
+ });
157
+ return new RegExp(`^${regex}$`);
158
+ }
159
+
160
+ // src/rules/img-alt/img-alt.rule.ts
161
+ import { SyntaxKind } from "ts-morph";
162
+
163
+ // src/rules/img-alt/img-alt.classify.ts
164
+ var MEANINGLESS_PATTERNS = [
165
+ /^image$/i,
166
+ /^photo$/i,
167
+ /^picture$/i,
168
+ /^img$/i,
169
+ /^banner$/i,
170
+ /^hero$/i,
171
+ /^thumbnail$/i,
172
+ /^untitled$/i,
173
+ /^placeholder$/i,
174
+ /^screenshot$/i,
175
+ /^IMG_\d+/i,
176
+ /^DSC_?\d+/i,
177
+ /^DCIM/i,
178
+ /^\d+$/,
179
+ /\.(jpg|jpeg|png|gif|webp|svg|avif)$/i
180
+ ];
181
+ function classifyAlt(altValue, isExpression) {
182
+ if (altValue === void 0 || altValue === null) {
183
+ return "missing";
184
+ }
185
+ if (altValue === "") {
186
+ return "decorative";
187
+ }
188
+ if (isExpression) {
189
+ return "dynamic";
190
+ }
191
+ const trimmed = altValue.trim();
192
+ for (const pattern of MEANINGLESS_PATTERNS) {
193
+ if (pattern.test(trimmed)) {
194
+ return "meaningless";
195
+ }
196
+ }
197
+ const words = trimmed.split(/\s+/).filter(Boolean);
198
+ if (words.length < 3 && words.length > 0) {
199
+ if (words.length >= 2) return "valid";
200
+ if (MEANINGLESS_PATTERNS.some((p) => p.test(words[0]))) {
201
+ return "meaningless";
202
+ }
203
+ }
204
+ return "valid";
205
+ }
206
+
207
+ // src/rules/img-alt/img-alt.rule.ts
208
+ var imgAltRule = {
209
+ id: "img-alt",
210
+ type: "ai",
211
+ scan(file) {
212
+ const violations = [];
213
+ const filePath = file.getFilePath();
214
+ const elements = [
215
+ ...file.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
216
+ ...file.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)
217
+ ];
218
+ for (const el of elements) {
219
+ const tagName = el.getTagNameNode().getText();
220
+ if (tagName !== "img" && tagName !== "Image") continue;
221
+ if (tagName === "Image" && !isNextImage(file)) continue;
222
+ const altAttr = el.getAttribute("alt");
223
+ let altValue;
224
+ let isExpression = false;
225
+ if (!altAttr) {
226
+ altValue = void 0;
227
+ } else if (altAttr.getKind() === SyntaxKind.JsxAttribute) {
228
+ const jsxAttr = altAttr.asKind(SyntaxKind.JsxAttribute);
229
+ const init = jsxAttr?.getInitializer();
230
+ if (!init) {
231
+ altValue = "";
232
+ } else if (init.getKind() === SyntaxKind.StringLiteral) {
233
+ altValue = init.asKind(SyntaxKind.StringLiteral)?.getLiteralValue() ?? "";
234
+ } else if (init.getKind() === SyntaxKind.JsxExpression) {
235
+ const expr = init.asKind(SyntaxKind.JsxExpression);
236
+ const exprText = expr?.getExpression()?.getText() ?? "";
237
+ if (exprText === '""' || exprText === "''") {
238
+ altValue = "";
239
+ } else {
240
+ altValue = exprText;
241
+ isExpression = true;
242
+ }
243
+ }
244
+ }
245
+ const classification = classifyAlt(altValue, isExpression);
246
+ if (classification === "missing" || classification === "meaningless") {
247
+ violations.push({
248
+ rule: "img-alt",
249
+ filePath,
250
+ line: el.getStartLineNumber(),
251
+ column: el.getStart() - el.getStartLinePos(),
252
+ element: el.getText().slice(0, 80),
253
+ message: classification === "missing" ? "Image is missing alt text" : `Image has meaningless alt text: "${altValue}"`,
254
+ fix: {
255
+ type: "insert-attr",
256
+ attribute: "alt",
257
+ value: async () => {
258
+ return `[AI-generated alt text placeholder]`;
259
+ }
260
+ }
261
+ });
262
+ }
263
+ }
264
+ return violations;
265
+ }
266
+ };
267
+ function isNextImage(file) {
268
+ const imports = file.getImportDeclarations();
269
+ return imports.some(
270
+ (imp) => imp.getModuleSpecifierValue() === "next/image" && (imp.getDefaultImport()?.getText() === "Image" || imp.getNamedImports().some((n) => n.getName() === "Image"))
271
+ );
272
+ }
273
+
274
+ // src/rules/button-label/button-label.rule.ts
275
+ import { SyntaxKind as SyntaxKind2 } from "ts-morph";
276
+ var buttonLabelRule = {
277
+ id: "button-label",
278
+ type: "ai",
279
+ scan(file) {
280
+ const violations = [];
281
+ const filePath = file.getFilePath();
282
+ const elements = [
283
+ ...file.getDescendantsOfKind(SyntaxKind2.JsxOpeningElement),
284
+ ...file.getDescendantsOfKind(SyntaxKind2.JsxSelfClosingElement)
285
+ ];
286
+ for (const el of elements) {
287
+ const tagName = el.getTagNameNode().getText();
288
+ if (tagName !== "button") continue;
289
+ if (el.getAttribute("aria-label") || el.getAttribute("aria-labelledby")) {
290
+ continue;
291
+ }
292
+ if (el.getKind() === SyntaxKind2.JsxOpeningElement) {
293
+ const parent = el.getParent();
294
+ if (parent) {
295
+ const hasTextContent = parent.getDescendantsOfKind(SyntaxKind2.JsxText).some((t) => t.getText().trim().length > 0);
296
+ if (hasTextContent) continue;
297
+ const nestedElements = parent.getDescendantsOfKind(
298
+ SyntaxKind2.JsxOpeningElement
299
+ );
300
+ const hasAccessibleChild = nestedElements.some((nested) => {
301
+ const nestedTag = nested.getTagNameNode().getText();
302
+ return nestedTag !== "svg" && !nestedTag.endsWith("Icon") && nested.getParent()?.getDescendantsOfKind(SyntaxKind2.JsxText).some((t) => t.getText().trim().length > 0);
303
+ });
304
+ if (hasAccessibleChild) continue;
305
+ }
306
+ }
307
+ if (el.getKind() === SyntaxKind2.JsxSelfClosingElement) {
308
+ }
309
+ const iconName = getIconName(el);
310
+ violations.push({
311
+ rule: "button-label",
312
+ filePath,
313
+ line: el.getStartLineNumber(),
314
+ column: el.getStart() - el.getStartLinePos(),
315
+ element: el.getText().slice(0, 80),
316
+ message: "Button has no accessible name",
317
+ fix: {
318
+ type: "insert-attr",
319
+ attribute: "aria-label",
320
+ value: async () => {
321
+ if (iconName) {
322
+ return iconNameToLabel(iconName);
323
+ }
324
+ return "Button";
325
+ }
326
+ }
327
+ });
328
+ }
329
+ return violations;
330
+ }
331
+ };
332
+ function getIconName(el) {
333
+ const parent = el.getParent();
334
+ if (!parent) return void 0;
335
+ const selfClosingChildren = parent.getDescendantsOfKind(
336
+ SyntaxKind2.JsxSelfClosingElement
337
+ );
338
+ for (const child of selfClosingChildren) {
339
+ const tag = child.getTagNameNode().getText();
340
+ if (tag.endsWith("Icon") || tag === "svg") {
341
+ return tag;
342
+ }
343
+ }
344
+ const openingChildren = parent.getDescendantsOfKind(
345
+ SyntaxKind2.JsxOpeningElement
346
+ );
347
+ for (const child of openingChildren) {
348
+ const tag = child.getTagNameNode().getText();
349
+ if (tag.endsWith("Icon") || tag === "svg") {
350
+ return tag;
351
+ }
352
+ }
353
+ return void 0;
354
+ }
355
+ function iconNameToLabel(iconName) {
356
+ const nameMap = {
357
+ TrashIcon: "Delete",
358
+ DeleteIcon: "Delete",
359
+ XIcon: "Close",
360
+ CloseIcon: "Close",
361
+ MenuIcon: "Menu",
362
+ HamburgerIcon: "Menu",
363
+ SearchIcon: "Search",
364
+ EditIcon: "Edit",
365
+ PencilIcon: "Edit",
366
+ SettingsIcon: "Settings",
367
+ GearIcon: "Settings",
368
+ ChevronLeftIcon: "Go back",
369
+ ChevronRightIcon: "Go forward",
370
+ ArrowLeftIcon: "Go back",
371
+ ArrowRightIcon: "Go forward",
372
+ PlusIcon: "Add",
373
+ MinusIcon: "Remove",
374
+ HeartIcon: "Favorite",
375
+ StarIcon: "Star",
376
+ ShareIcon: "Share",
377
+ DownloadIcon: "Download",
378
+ UploadIcon: "Upload",
379
+ CopyIcon: "Copy",
380
+ RefreshIcon: "Refresh",
381
+ FilterIcon: "Filter",
382
+ SortIcon: "Sort",
383
+ ExpandIcon: "Expand",
384
+ CollapseIcon: "Collapse",
385
+ PlayIcon: "Play",
386
+ PauseIcon: "Pause",
387
+ StopIcon: "Stop",
388
+ MuteIcon: "Mute",
389
+ VolumeIcon: "Volume",
390
+ BellIcon: "Notifications",
391
+ UserIcon: "User profile",
392
+ LogoutIcon: "Log out",
393
+ LoginIcon: "Log in",
394
+ EyeIcon: "Show",
395
+ EyeOffIcon: "Hide",
396
+ LockIcon: "Lock",
397
+ UnlockIcon: "Unlock",
398
+ InfoIcon: "Information",
399
+ HelpIcon: "Help",
400
+ WarningIcon: "Warning",
401
+ CheckIcon: "Confirm",
402
+ SaveIcon: "Save",
403
+ PrintIcon: "Print",
404
+ HomeIcon: "Home",
405
+ CalendarIcon: "Calendar",
406
+ ClockIcon: "Clock",
407
+ MapIcon: "Map",
408
+ PhoneIcon: "Phone",
409
+ MailIcon: "Email",
410
+ SendIcon: "Send",
411
+ AttachIcon: "Attach",
412
+ LinkIcon: "Link",
413
+ ExternalLinkIcon: "Open in new tab",
414
+ MoreIcon: "More options",
415
+ DotsVerticalIcon: "More options",
416
+ DotsHorizontalIcon: "More options",
417
+ GridIcon: "Grid view",
418
+ ListIcon: "List view",
419
+ SunIcon: "Light mode",
420
+ MoonIcon: "Dark mode"
421
+ };
422
+ if (nameMap[iconName]) return nameMap[iconName];
423
+ const name = iconName.replace(/Icon$/, "").replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase();
424
+ return name.charAt(0).toUpperCase() + name.slice(1);
425
+ }
426
+
427
+ // src/rules/link-label/link-label.rule.ts
428
+ import { SyntaxKind as SyntaxKind3 } from "ts-morph";
429
+ var linkLabelRule = {
430
+ id: "link-label",
431
+ type: "ai",
432
+ scan(file) {
433
+ const violations = [];
434
+ const filePath = file.getFilePath();
435
+ const elements = [
436
+ ...file.getDescendantsOfKind(SyntaxKind3.JsxOpeningElement),
437
+ ...file.getDescendantsOfKind(SyntaxKind3.JsxSelfClosingElement)
438
+ ];
439
+ for (const el of elements) {
440
+ const tagName = el.getTagNameNode().getText();
441
+ if (tagName !== "a" && tagName !== "Link") continue;
442
+ if (tagName === "Link" && !isNextLink(file)) continue;
443
+ if (el.getAttribute("aria-label") || el.getAttribute("aria-labelledby")) {
444
+ continue;
445
+ }
446
+ if (el.getKind() === SyntaxKind3.JsxOpeningElement) {
447
+ const parent = el.getParent();
448
+ if (parent) {
449
+ const hasTextContent = parent.getDescendantsOfKind(SyntaxKind3.JsxText).some((t) => t.getText().trim().length > 0);
450
+ if (hasTextContent) continue;
451
+ const images = [
452
+ ...parent.getDescendantsOfKind(SyntaxKind3.JsxSelfClosingElement),
453
+ ...parent.getDescendantsOfKind(SyntaxKind3.JsxOpeningElement)
454
+ ];
455
+ const hasAltImage = images.some((img) => {
456
+ const imgTag = img.getTagNameNode().getText();
457
+ if (imgTag !== "img" && imgTag !== "Image") return false;
458
+ const alt = img.getAttribute("alt");
459
+ if (!alt) return false;
460
+ const jsxAttr = alt.asKind(SyntaxKind3.JsxAttribute);
461
+ const init = jsxAttr?.getInitializer();
462
+ if (!init) return false;
463
+ if (init.getKind() === SyntaxKind3.StringLiteral) {
464
+ return (init.asKind(SyntaxKind3.StringLiteral)?.getLiteralValue() ?? "").length > 0;
465
+ }
466
+ return true;
467
+ });
468
+ if (hasAltImage) continue;
469
+ }
470
+ }
471
+ const iconName = getIconName2(el);
472
+ violations.push({
473
+ rule: "link-label",
474
+ filePath,
475
+ line: el.getStartLineNumber(),
476
+ column: el.getStart() - el.getStartLinePos(),
477
+ element: el.getText().slice(0, 80),
478
+ message: "Link has no accessible name",
479
+ fix: {
480
+ type: "insert-attr",
481
+ attribute: "aria-label",
482
+ value: async () => {
483
+ if (iconName) {
484
+ return iconNameToLabel2(iconName);
485
+ }
486
+ return "Link";
487
+ }
488
+ }
489
+ });
490
+ }
491
+ return violations;
492
+ }
493
+ };
494
+ function isNextLink(file) {
495
+ const imports = file.getImportDeclarations();
496
+ return imports.some(
497
+ (imp) => imp.getModuleSpecifierValue() === "next/link" && (imp.getDefaultImport()?.getText() === "Link" || imp.getNamedImports().some((n) => n.getName() === "Link"))
498
+ );
499
+ }
500
+ function getIconName2(el) {
501
+ const parent = el.getParent();
502
+ if (!parent) return void 0;
503
+ const selfClosingChildren = parent.getDescendantsOfKind(
504
+ SyntaxKind3.JsxSelfClosingElement
505
+ );
506
+ for (const child of selfClosingChildren) {
507
+ const tag = child.getTagNameNode().getText();
508
+ if (tag.endsWith("Icon") || tag === "svg") {
509
+ return tag;
510
+ }
511
+ }
512
+ return void 0;
513
+ }
514
+ function iconNameToLabel2(iconName) {
515
+ const name = iconName.replace(/Icon$/, "").replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase();
516
+ return name.charAt(0).toUpperCase() + name.slice(1);
517
+ }
518
+
519
+ // src/rules/input-label/input-label.rule.ts
520
+ import { SyntaxKind as SyntaxKind4 } from "ts-morph";
521
+ var INPUT_TAGS = ["input", "select", "textarea"];
522
+ var inputLabelRule = {
523
+ id: "input-label",
524
+ type: "ai",
525
+ scan(file) {
526
+ const violations = [];
527
+ const filePath = file.getFilePath();
528
+ const elements = [
529
+ ...file.getDescendantsOfKind(SyntaxKind4.JsxOpeningElement),
530
+ ...file.getDescendantsOfKind(SyntaxKind4.JsxSelfClosingElement)
531
+ ];
532
+ for (const el of elements) {
533
+ const tagName = el.getTagNameNode().getText();
534
+ if (!INPUT_TAGS.includes(tagName)) continue;
535
+ const typeAttr = el.getAttribute("type");
536
+ if (typeAttr) {
537
+ const jsxAttr = typeAttr.asKind(SyntaxKind4.JsxAttribute);
538
+ const init = jsxAttr?.getInitializer();
539
+ if (init?.getKind() === SyntaxKind4.StringLiteral) {
540
+ const typeValue = init.asKind(SyntaxKind4.StringLiteral)?.getLiteralValue();
541
+ if (typeValue === "hidden") continue;
542
+ }
543
+ }
544
+ if (el.getAttribute("aria-label") || el.getAttribute("aria-labelledby")) {
545
+ continue;
546
+ }
547
+ const idAttr = el.getAttribute("id");
548
+ if (idAttr) {
549
+ const jsxAttr = idAttr.asKind(SyntaxKind4.JsxAttribute);
550
+ const init = jsxAttr?.getInitializer();
551
+ if (init?.getKind() === SyntaxKind4.StringLiteral) {
552
+ const idValue = init.asKind(SyntaxKind4.StringLiteral)?.getLiteralValue();
553
+ if (idValue && hasLabelFor(file, idValue)) continue;
554
+ }
555
+ }
556
+ if (isWrappedInLabel(el)) continue;
557
+ const placeholder = getStringAttribute(el, "placeholder");
558
+ const name = getStringAttribute(el, "name");
559
+ violations.push({
560
+ rule: "input-label",
561
+ filePath,
562
+ line: el.getStartLineNumber(),
563
+ column: el.getStart() - el.getStartLinePos(),
564
+ element: el.getText().slice(0, 80),
565
+ message: `<${tagName}> is missing an associated label`,
566
+ fix: {
567
+ type: "insert-attr",
568
+ attribute: "aria-label",
569
+ value: async () => {
570
+ if (placeholder) return placeholder;
571
+ if (name) {
572
+ return name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]/g, " ").replace(/^\w/, (c) => c.toUpperCase());
573
+ }
574
+ return tagName === "select" ? "Select option" : tagName === "textarea" ? "Text input" : "Input";
575
+ }
576
+ }
577
+ });
578
+ }
579
+ return violations;
580
+ }
581
+ };
582
+ function hasLabelFor(file, id) {
583
+ const allElements = [
584
+ ...file.getDescendantsOfKind(SyntaxKind4.JsxOpeningElement),
585
+ ...file.getDescendantsOfKind(SyntaxKind4.JsxSelfClosingElement)
586
+ ];
587
+ return allElements.some((el) => {
588
+ if (el.getTagNameNode().getText() !== "label") return false;
589
+ const htmlFor = el.getAttribute("htmlFor");
590
+ if (!htmlFor) return false;
591
+ const jsxAttr = htmlFor.asKind(SyntaxKind4.JsxAttribute);
592
+ const init = jsxAttr?.getInitializer();
593
+ if (init?.getKind() === SyntaxKind4.StringLiteral) {
594
+ return init.asKind(SyntaxKind4.StringLiteral)?.getLiteralValue() === id;
595
+ }
596
+ return false;
597
+ });
598
+ }
599
+ function isWrappedInLabel(el) {
600
+ let current = el.getParent();
601
+ while (current) {
602
+ if (current.getKind() === SyntaxKind4.JsxElement) {
603
+ const opening = current.getFirstChildByKind(SyntaxKind4.JsxOpeningElement);
604
+ if (opening?.getTagNameNode().getText() === "label") return true;
605
+ }
606
+ current = current.getParent();
607
+ }
608
+ return false;
609
+ }
610
+ function getStringAttribute(el, name) {
611
+ const attr = el.getAttribute(name);
612
+ if (!attr) return void 0;
613
+ const jsxAttr = attr.asKind(SyntaxKind4.JsxAttribute);
614
+ const init = jsxAttr?.getInitializer();
615
+ if (init?.getKind() === SyntaxKind4.StringLiteral) {
616
+ return init.asKind(SyntaxKind4.StringLiteral)?.getLiteralValue();
617
+ }
618
+ return void 0;
619
+ }
620
+
621
+ // src/rules/no-positive-tabindex/no-positive-tabindex.rule.ts
622
+ import { SyntaxKind as SyntaxKind5 } from "ts-morph";
623
+ var noPositiveTabindexRule = {
624
+ id: "no-positive-tabindex",
625
+ type: "deterministic",
626
+ scan(file) {
627
+ const violations = [];
628
+ const filePath = file.getFilePath();
629
+ const jsxAttributes = file.getDescendantsOfKind(SyntaxKind5.JsxAttribute);
630
+ for (const attr of jsxAttributes) {
631
+ const name = attr.getNameNode().getText();
632
+ if (name !== "tabIndex") continue;
633
+ const initializer = attr.getInitializer();
634
+ if (!initializer) continue;
635
+ if (initializer.isKind(SyntaxKind5.JsxExpression)) {
636
+ const expression = initializer.getExpression();
637
+ if (!expression) continue;
638
+ if (expression.isKind(SyntaxKind5.NumericLiteral)) {
639
+ const value = expression.getLiteralValue();
640
+ if (value > 0) {
641
+ const jsxElement = attr.getFirstAncestorByKind(SyntaxKind5.JsxOpeningElement) ?? attr.getFirstAncestorByKind(SyntaxKind5.JsxSelfClosingElement);
642
+ const tagName = jsxElement ? jsxElement.getTagNameNode().getText() : "unknown";
643
+ violations.push({
644
+ rule: "no-positive-tabindex",
645
+ filePath,
646
+ line: attr.getStartLineNumber(),
647
+ column: attr.getStart() - attr.getStartLinePos() + 1,
648
+ element: tagName,
649
+ message: `Avoid using positive tabIndex value (${value}). Use tabIndex={0} or tabIndex={-1} instead.`,
650
+ fix: {
651
+ type: "replace-attr",
652
+ attribute: "tabIndex",
653
+ value: "0"
654
+ }
655
+ });
656
+ }
657
+ }
658
+ }
659
+ }
660
+ return violations;
661
+ }
662
+ };
663
+
664
+ // src/rules/button-type/button-type.rule.ts
665
+ import { SyntaxKind as SyntaxKind6 } from "ts-morph";
666
+ var buttonTypeRule = {
667
+ id: "button-type",
668
+ type: "deterministic",
669
+ scan(file) {
670
+ const violations = [];
671
+ const filePath = file.getFilePath();
672
+ const openingElements = file.getDescendantsOfKind(
673
+ SyntaxKind6.JsxOpeningElement
674
+ );
675
+ for (const element of openingElements) {
676
+ const tagName = element.getTagNameNode().getText();
677
+ if (tagName !== "button") continue;
678
+ const typeAttr = element.getAttributes().find(
679
+ (attr) => attr.isKind(SyntaxKind6.JsxAttribute) && attr.getNameNode().getText() === "type"
680
+ );
681
+ if (typeAttr) continue;
682
+ const { line, column } = file.getLineAndColumnAtPos(element.getStart());
683
+ violations.push({
684
+ rule: "button-type",
685
+ filePath,
686
+ line,
687
+ column,
688
+ element: "<button>",
689
+ message: 'Native <button> elements should have an explicit "type" attribute to avoid unexpected form submissions.',
690
+ fix: {
691
+ type: "insert-attr",
692
+ attribute: "type",
693
+ value: "button"
694
+ }
695
+ });
696
+ }
697
+ const selfClosingElements = file.getDescendantsOfKind(
698
+ SyntaxKind6.JsxSelfClosingElement
699
+ );
700
+ for (const element of selfClosingElements) {
701
+ const tagName = element.getTagNameNode().getText();
702
+ if (tagName !== "button" && tagName[0] === tagName[0].toUpperCase()) {
703
+ const hasTypeAttr = element.getAttributes().some(
704
+ (attr) => attr.isKind(SyntaxKind6.JsxAttribute) && attr.getNameNode().getText() === "type"
705
+ );
706
+ if (!hasTypeAttr && /button/i.test(tagName)) {
707
+ const { line: line2, column: column2 } = file.getLineAndColumnAtPos(
708
+ element.getStart()
709
+ );
710
+ violations.push({
711
+ rule: "button-type",
712
+ filePath,
713
+ line: line2,
714
+ column: column2,
715
+ element: `<${tagName}>`,
716
+ message: `Custom component <${tagName}> may render a <button> without an explicit "type" attribute. Consider passing type="button".`
717
+ });
718
+ }
719
+ continue;
720
+ }
721
+ if (tagName !== "button") continue;
722
+ const typeAttr = element.getAttributes().find(
723
+ (attr) => attr.isKind(SyntaxKind6.JsxAttribute) && attr.getNameNode().getText() === "type"
724
+ );
725
+ if (typeAttr) continue;
726
+ const { line, column } = file.getLineAndColumnAtPos(element.getStart());
727
+ violations.push({
728
+ rule: "button-type",
729
+ filePath,
730
+ line,
731
+ column,
732
+ element: "<button>",
733
+ message: 'Native <button> elements should have an explicit "type" attribute to avoid unexpected form submissions.',
734
+ fix: {
735
+ type: "insert-attr",
736
+ attribute: "type",
737
+ value: "button"
738
+ }
739
+ });
740
+ }
741
+ for (const element of openingElements) {
742
+ const tagName = element.getTagNameNode().getText();
743
+ if (tagName[0] !== tagName[0].toUpperCase()) continue;
744
+ if (!/button/i.test(tagName)) continue;
745
+ const hasTypeAttr = element.getAttributes().some(
746
+ (attr) => attr.isKind(SyntaxKind6.JsxAttribute) && attr.getNameNode().getText() === "type"
747
+ );
748
+ if (!hasTypeAttr) {
749
+ const { line, column } = file.getLineAndColumnAtPos(
750
+ element.getStart()
751
+ );
752
+ violations.push({
753
+ rule: "button-type",
754
+ filePath,
755
+ line,
756
+ column,
757
+ element: `<${tagName}>`,
758
+ message: `Custom component <${tagName}> may render a <button> without an explicit "type" attribute. Consider passing type="button".`
759
+ });
760
+ }
761
+ }
762
+ return violations;
763
+ }
764
+ };
765
+
766
+ // src/rules/link-noopener/link-noopener.rule.ts
767
+ import { SyntaxKind as SyntaxKind7 } from "ts-morph";
768
+ function getAttributeValue(element, name) {
769
+ const attr = element.getAttribute(name);
770
+ if (!attr || attr.getKind() !== SyntaxKind7.JsxAttribute) return void 0;
771
+ const init = attr.getInitializer();
772
+ if (!init) return void 0;
773
+ if (init.getKind() === SyntaxKind7.StringLiteral) {
774
+ return init.getLiteralValue();
775
+ }
776
+ return void 0;
777
+ }
778
+ function isNextLinkImported(file) {
779
+ const importDecls = file.getImportDeclarations();
780
+ return importDecls.some(
781
+ (decl) => decl.getModuleSpecifierValue() === "next/link"
782
+ );
783
+ }
784
+ function checkElement(element, file) {
785
+ const tagName = element.getTagNameNode().getText();
786
+ if (tagName !== "a" && tagName !== "Link") return null;
787
+ if (tagName === "Link" && !isNextLinkImported(file)) return null;
788
+ const targetValue = getAttributeValue(element, "target");
789
+ if (targetValue !== "_blank") return null;
790
+ const relValue = getAttributeValue(element, "rel");
791
+ const hasNoopener = relValue?.includes("noopener") ?? false;
792
+ const hasNoreferrer = relValue?.includes("noreferrer") ?? false;
793
+ if (hasNoopener && hasNoreferrer) return null;
794
+ const fixType = relValue === void 0 ? "insert-attr" : "replace-attr";
795
+ const fix = {
796
+ type: fixType,
797
+ attribute: "rel",
798
+ value: "noopener noreferrer"
799
+ };
800
+ const line = element.getStartLineNumber();
801
+ const column = element.getStartLinePos() + 1;
802
+ return {
803
+ rule: "link-noopener",
804
+ filePath: file.getFilePath(),
805
+ line,
806
+ column,
807
+ element: element.getText(),
808
+ message: `<${tagName} target="_blank"> is missing rel="noopener noreferrer". This is a security risk.`,
809
+ fix
810
+ };
811
+ }
812
+ var linkNoopenerRule = {
813
+ id: "link-noopener",
814
+ type: "deterministic",
815
+ scan(file) {
816
+ const violations = [];
817
+ const openingElements = file.getDescendantsOfKind(
818
+ SyntaxKind7.JsxOpeningElement
819
+ );
820
+ for (const el of openingElements) {
821
+ const violation = checkElement(el, file);
822
+ if (violation) violations.push(violation);
823
+ }
824
+ const selfClosingElements = file.getDescendantsOfKind(
825
+ SyntaxKind7.JsxSelfClosingElement
826
+ );
827
+ for (const el of selfClosingElements) {
828
+ const violation = checkElement(el, file);
829
+ if (violation) violations.push(violation);
830
+ }
831
+ return violations;
832
+ }
833
+ };
834
+
835
+ // src/rules/emoji-alt/emoji-alt.rule.ts
836
+ import { SyntaxKind as SyntaxKind8 } from "ts-morph";
837
+
838
+ // src/rules/emoji-alt/emoji-names.ts
839
+ var EMOJI_NAMES = {
840
+ // Smileys & people
841
+ "\u{1F600}": "grinning face",
842
+ "\u{1F601}": "beaming face with smiling eyes",
843
+ "\u{1F602}": "face with tears of joy",
844
+ "\u{1F603}": "grinning face with big eyes",
845
+ "\u{1F604}": "grinning face with smiling eyes",
846
+ "\u{1F605}": "grinning face with sweat",
847
+ "\u{1F606}": "grinning squinting face",
848
+ "\u{1F609}": "winking face",
849
+ "\u{1F60A}": "smiling face with smiling eyes",
850
+ "\u{1F60D}": "smiling face with heart-eyes",
851
+ "\u{1F60E}": "smiling face with sunglasses",
852
+ "\u{1F60F}": "smirking face",
853
+ "\u{1F612}": "unamused face",
854
+ "\u{1F614}": "pensive face",
855
+ "\u{1F618}": "face blowing a kiss",
856
+ "\u{1F621}": "pouting face",
857
+ "\u{1F622}": "crying face",
858
+ "\u{1F62D}": "loudly crying face",
859
+ "\u{1F62E}": "face with open mouth",
860
+ "\u{1F631}": "face screaming in fear",
861
+ "\u{1F633}": "flushed face",
862
+ "\u{1F634}": "sleeping face",
863
+ "\u{1F637}": "face with medical mask",
864
+ "\u{1F914}": "thinking face",
865
+ "\u{1F923}": "rolling on the floor laughing",
866
+ "\u{1F929}": "star-struck",
867
+ "\u{1F970}": "smiling face with hearts",
868
+ "\u{1F973}": "partying face",
869
+ // Hands & gestures
870
+ "\u{1F44D}": "thumbs up",
871
+ "\u{1F44E}": "thumbs down",
872
+ "\u{1F44F}": "clapping hands",
873
+ "\u{1F44B}": "waving hand",
874
+ "\u{1F4AA}": "flexed biceps",
875
+ "\u{1F64F}": "folded hands",
876
+ "\u270C\uFE0F": "victory hand",
877
+ "\u{1F91D}": "handshake",
878
+ // Hearts & love
879
+ "\u2764\uFE0F": "red heart",
880
+ "\u{1F494}": "broken heart",
881
+ "\u{1F495}": "two hearts",
882
+ "\u{1F496}": "sparkling heart",
883
+ "\u{1F499}": "blue heart",
884
+ "\u{1F49A}": "green heart",
885
+ "\u{1F49B}": "yellow heart",
886
+ "\u{1F49C}": "purple heart",
887
+ "\u{1F5A4}": "black heart",
888
+ "\u{1F90D}": "white heart",
889
+ "\u{1F9E1}": "orange heart",
890
+ // Nature & animals
891
+ "\u{1F525}": "fire",
892
+ "\u2B50": "star",
893
+ "\u{1F31F}": "glowing star",
894
+ "\u2600\uFE0F": "sun",
895
+ "\u{1F308}": "rainbow",
896
+ "\u26A1": "high voltage",
897
+ "\u{1F4A7}": "droplet",
898
+ "\u2744\uFE0F": "snowflake",
899
+ "\u{1F33A}": "hibiscus",
900
+ "\u{1F339}": "rose",
901
+ "\u{1F335}": "cactus",
902
+ "\u{1F343}": "leaf fluttering in wind",
903
+ "\u{1F436}": "dog face",
904
+ "\u{1F431}": "cat face",
905
+ "\u{1F98B}": "butterfly",
906
+ // Objects & symbols
907
+ "\u{1F680}": "rocket",
908
+ "\u2705": "check mark",
909
+ "\u274C": "cross mark",
910
+ "\u26A0\uFE0F": "warning",
911
+ "\u{1F6A8}": "police car light",
912
+ "\u{1F4A1}": "light bulb",
913
+ "\u{1F389}": "party popper",
914
+ "\u{1F381}": "wrapped gift",
915
+ "\u{1F3AF}": "bullseye",
916
+ "\u{1F3C6}": "trophy",
917
+ "\u{1F4E2}": "loudspeaker",
918
+ "\u{1F514}": "bell",
919
+ "\u{1F4CC}": "pushpin",
920
+ "\u{1F4DD}": "memo",
921
+ "\u{1F4DA}": "books",
922
+ "\u{1F4BB}": "laptop",
923
+ "\u{1F4F1}": "mobile phone",
924
+ "\u{1F510}": "locked with key",
925
+ "\u{1F512}": "locked",
926
+ "\u{1F513}": "unlocked",
927
+ "\u{1F504}": "counterclockwise arrows",
928
+ "\u{1F4B0}": "money bag",
929
+ "\u{1F3E0}": "house",
930
+ "\u{1F4E7}": "e-mail",
931
+ // Food & drink
932
+ "\u2615": "hot beverage",
933
+ "\u{1F355}": "pizza",
934
+ "\u{1F382}": "birthday cake",
935
+ "\u{1F37A}": "beer mug",
936
+ "\u{1F377}": "wine glass",
937
+ // Miscellaneous symbols
938
+ "\u2728": "sparkles",
939
+ "\u{1F4AF}": "hundred points",
940
+ "\u{1F44C}": "OK hand",
941
+ "\u270D\uFE0F": "writing hand",
942
+ "\u{1F4A5}": "collision",
943
+ "\u{1F4AB}": "dizzy",
944
+ "\u{1F440}": "eyes",
945
+ "\u{1F4AC}": "speech balloon",
946
+ "\u23F0": "alarm clock",
947
+ "\u{1F30D}": "globe showing Europe-Africa",
948
+ "\u{1F30E}": "globe showing Americas",
949
+ "\u{1F30F}": "globe showing Asia-Australia"
950
+ };
951
+ function getEmojiName(emoji) {
952
+ return EMOJI_NAMES[emoji] ?? "emoji";
953
+ }
954
+
955
+ // src/rules/emoji-alt/emoji-alt.rule.ts
956
+ var EMOJI_REGEX = /(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(?:\u200D(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*/gu;
957
+ function isWrappedInAccessibleSpan(node) {
958
+ const parent = node.getParent();
959
+ if (!parent) return false;
960
+ if (parent.getKind() !== SyntaxKind8.JsxElement) return false;
961
+ const jsxElement = parent;
962
+ const openingElement = jsxElement.getChildrenOfKind(SyntaxKind8.JsxOpeningElement)[0];
963
+ if (!openingElement) return false;
964
+ const tagName = openingElement.getTagNameNode().getText();
965
+ if (tagName !== "span") return false;
966
+ const attributes = openingElement.getAttributes();
967
+ let hasRoleImg = false;
968
+ let hasAriaLabel = false;
969
+ for (const attr of attributes) {
970
+ if (attr.getKind() !== SyntaxKind8.JsxAttribute) continue;
971
+ const jsxAttr = attr;
972
+ const name = jsxAttr.getNameNode().getText();
973
+ if (name === "role") {
974
+ const init = jsxAttr.getInitializer();
975
+ if (init && init.getKind() === SyntaxKind8.StringLiteral) {
976
+ const value = init.getLiteralValue();
977
+ if (value === "img") hasRoleImg = true;
978
+ }
979
+ }
980
+ if (name === "aria-label") {
981
+ const init = jsxAttr.getInitializer();
982
+ if (init) hasAriaLabel = true;
983
+ }
984
+ }
985
+ return hasRoleImg && hasAriaLabel;
986
+ }
987
+ var emojiAltRule = {
988
+ id: "emoji-alt",
989
+ type: "deterministic",
990
+ scan(file) {
991
+ const violations = [];
992
+ const filePath = file.getFilePath();
993
+ const jsxTextNodes = file.getDescendantsOfKind(SyntaxKind8.JsxText);
994
+ for (const textNode of jsxTextNodes) {
995
+ const text = textNode.getText();
996
+ EMOJI_REGEX.lastIndex = 0;
997
+ let match;
998
+ while ((match = EMOJI_REGEX.exec(text)) !== null) {
999
+ const emoji = match[0];
1000
+ if (isWrappedInAccessibleSpan(textNode)) continue;
1001
+ const nodeStart = textNode.getStart();
1002
+ const sourceFile = textNode.getSourceFile();
1003
+ const emojiPos = nodeStart + match.index;
1004
+ const lineAndCol = sourceFile.getLineAndColumnAtPos(emojiPos);
1005
+ const emojiName = getEmojiName(emoji);
1006
+ violations.push({
1007
+ rule: "emoji-alt",
1008
+ filePath,
1009
+ line: lineAndCol.line,
1010
+ column: lineAndCol.column,
1011
+ element: emoji,
1012
+ message: `Emoji "${emoji}" is missing accessible labeling. Wrap it in <span role="img" aria-label="${emojiName}">${emoji}</span> so screen readers can announce its meaning.`,
1013
+ fix: {
1014
+ type: "wrap-element",
1015
+ value: emojiName
1016
+ }
1017
+ });
1018
+ }
1019
+ }
1020
+ return violations;
1021
+ }
1022
+ };
1023
+
1024
+ // src/rules/html-lang/html-lang.rule.ts
1025
+ import { SyntaxKind as SyntaxKind9 } from "ts-morph";
1026
+ var htmlLangRule = {
1027
+ id: "html-lang",
1028
+ type: "deterministic",
1029
+ scan(file) {
1030
+ const filePath = file.getFilePath();
1031
+ if (!isRootLayoutOrDocument(filePath)) {
1032
+ return [];
1033
+ }
1034
+ const violations = [];
1035
+ const openingElements = file.getDescendantsOfKind(
1036
+ SyntaxKind9.JsxOpeningElement
1037
+ );
1038
+ for (const el of openingElements) {
1039
+ const tagName = el.getTagNameNode().getText();
1040
+ if (tagName === "html") {
1041
+ const langAttr = el.getAttributes().find(
1042
+ (attr) => attr.getKind() === SyntaxKind9.JsxAttribute && attr.getChildAtIndex(0).getText() === "lang"
1043
+ );
1044
+ if (!langAttr) {
1045
+ violations.push({
1046
+ rule: "html-lang",
1047
+ filePath,
1048
+ line: el.getStartLineNumber(),
1049
+ column: el.getStartLinePos() + 1,
1050
+ element: "<html>",
1051
+ message: "The <html> element must have a `lang` attribute for accessibility (WCAG 3.1.1).",
1052
+ fix: {
1053
+ type: "insert-attr",
1054
+ attribute: "lang",
1055
+ value: "en"
1056
+ }
1057
+ });
1058
+ }
1059
+ }
1060
+ }
1061
+ const selfClosingElements = file.getDescendantsOfKind(
1062
+ SyntaxKind9.JsxSelfClosingElement
1063
+ );
1064
+ for (const el of selfClosingElements) {
1065
+ const tagName = el.getTagNameNode().getText();
1066
+ if (tagName === "html") {
1067
+ const langAttr = el.getAttributes().find(
1068
+ (attr) => attr.getKind() === SyntaxKind9.JsxAttribute && attr.getChildAtIndex(0).getText() === "lang"
1069
+ );
1070
+ if (!langAttr) {
1071
+ violations.push({
1072
+ rule: "html-lang",
1073
+ filePath,
1074
+ line: el.getStartLineNumber(),
1075
+ column: el.getStartLinePos() + 1,
1076
+ element: "<html />",
1077
+ message: "The <html> element must have a `lang` attribute for accessibility (WCAG 3.1.1).",
1078
+ fix: {
1079
+ type: "insert-attr",
1080
+ attribute: "lang",
1081
+ value: "en"
1082
+ }
1083
+ });
1084
+ }
1085
+ }
1086
+ }
1087
+ return violations;
1088
+ }
1089
+ };
1090
+ function isRootLayoutOrDocument(filePath) {
1091
+ const normalized = filePath.replace(/\\/g, "/");
1092
+ return normalized.includes("layout") || normalized.includes("_document");
1093
+ }
1094
+
1095
+ // src/rules/heading-order/heading-order.rule.ts
1096
+ import { SyntaxKind as SyntaxKind10 } from "ts-morph";
1097
+ var HEADING_TAGS = /* @__PURE__ */ new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
1098
+ function headingLevel(tag) {
1099
+ return Number(tag[1]);
1100
+ }
1101
+ var headingOrderRule = {
1102
+ id: "heading-order",
1103
+ type: "detect",
1104
+ scan(file) {
1105
+ const violations = [];
1106
+ const filePath = file.getFilePath();
1107
+ const headings = [];
1108
+ const openingElements = file.getDescendantsOfKind(
1109
+ SyntaxKind10.JsxOpeningElement
1110
+ );
1111
+ for (const element of openingElements) {
1112
+ const tagName = element.getTagNameNode().getText();
1113
+ if (!HEADING_TAGS.has(tagName)) continue;
1114
+ const { line, column } = file.getLineAndColumnAtPos(element.getStart());
1115
+ headings.push({ tag: tagName, line, column });
1116
+ }
1117
+ const selfClosingElements = file.getDescendantsOfKind(
1118
+ SyntaxKind10.JsxSelfClosingElement
1119
+ );
1120
+ for (const element of selfClosingElements) {
1121
+ const tagName = element.getTagNameNode().getText();
1122
+ if (!HEADING_TAGS.has(tagName)) continue;
1123
+ const { line, column } = file.getLineAndColumnAtPos(element.getStart());
1124
+ headings.push({ tag: tagName, line, column });
1125
+ }
1126
+ headings.sort((a, b) => a.line - b.line || a.column - b.column);
1127
+ for (let i = 1; i < headings.length; i++) {
1128
+ const prev = headings[i - 1];
1129
+ const curr = headings[i];
1130
+ const prevLevel = headingLevel(prev.tag);
1131
+ const currLevel = headingLevel(curr.tag);
1132
+ if (currLevel > prevLevel + 1) {
1133
+ const expectedTag = `h${prevLevel + 1}`;
1134
+ violations.push({
1135
+ rule: "heading-order",
1136
+ filePath,
1137
+ line: curr.line,
1138
+ column: curr.column,
1139
+ element: `<${curr.tag}>`,
1140
+ message: `Heading level skipped: expected ${expectedTag} but found ${curr.tag}`
1141
+ });
1142
+ }
1143
+ }
1144
+ return violations;
1145
+ }
1146
+ };
1147
+
1148
+ // src/rules/no-div-interactive/no-div-interactive.rule.ts
1149
+ import { SyntaxKind as SyntaxKind11 } from "ts-morph";
1150
+ var INTERACTIVE_TAGS = ["div", "span"];
1151
+ function hasAttribute(element, name) {
1152
+ return element.getAttributes().some(
1153
+ (attr) => attr.isKind(SyntaxKind11.JsxAttribute) && attr.getNameNode().getText() === name
1154
+ );
1155
+ }
1156
+ var noDivInteractiveRule = {
1157
+ id: "no-div-interactive",
1158
+ type: "detect",
1159
+ scan(file) {
1160
+ const violations = [];
1161
+ const filePath = file.getFilePath();
1162
+ const openingElements = file.getDescendantsOfKind(
1163
+ SyntaxKind11.JsxOpeningElement
1164
+ );
1165
+ for (const element of openingElements) {
1166
+ const tagName = element.getTagNameNode().getText();
1167
+ if (!INTERACTIVE_TAGS.includes(tagName)) continue;
1168
+ if (!hasAttribute(element, "onClick")) continue;
1169
+ if (hasAttribute(element, "role") && hasAttribute(element, "tabIndex")) continue;
1170
+ const { line, column } = file.getLineAndColumnAtPos(element.getStart());
1171
+ violations.push({
1172
+ rule: "no-div-interactive",
1173
+ filePath,
1174
+ line,
1175
+ column,
1176
+ element: `<${tagName}>`,
1177
+ message: "Interactive <div> should be a <button> or have role and tabIndex"
1178
+ });
1179
+ }
1180
+ const selfClosingElements = file.getDescendantsOfKind(
1181
+ SyntaxKind11.JsxSelfClosingElement
1182
+ );
1183
+ for (const element of selfClosingElements) {
1184
+ const tagName = element.getTagNameNode().getText();
1185
+ if (!INTERACTIVE_TAGS.includes(tagName)) continue;
1186
+ if (!hasAttribute(element, "onClick")) continue;
1187
+ if (hasAttribute(element, "role") && hasAttribute(element, "tabIndex")) continue;
1188
+ const { line, column } = file.getLineAndColumnAtPos(element.getStart());
1189
+ violations.push({
1190
+ rule: "no-div-interactive",
1191
+ filePath,
1192
+ line,
1193
+ column,
1194
+ element: `<${tagName}>`,
1195
+ message: "Interactive <div> should be a <button> or have role and tabIndex"
1196
+ });
1197
+ }
1198
+ return violations;
1199
+ }
1200
+ };
1201
+
1202
+ // src/rules/next-metadata-title/next-metadata-title.rule.ts
1203
+ import { SyntaxKind as SyntaxKind12 } from "ts-morph";
1204
+ var PAGE_FILE_PATTERN = /\bpage\.(tsx|jsx|ts|js)$/;
1205
+ var nextMetadataTitleRule = {
1206
+ id: "next-metadata-title",
1207
+ type: "deterministic",
1208
+ scan(file) {
1209
+ const filePath = file.getFilePath();
1210
+ if (!PAGE_FILE_PATTERN.test(filePath)) {
1211
+ return [];
1212
+ }
1213
+ const variableStatements = file.getDescendantsOfKind(
1214
+ SyntaxKind12.VariableStatement
1215
+ );
1216
+ for (const stmt of variableStatements) {
1217
+ const hasExport = stmt.getModifiers().some(
1218
+ (mod) => mod.getKind() === SyntaxKind12.ExportKeyword
1219
+ );
1220
+ if (!hasExport) continue;
1221
+ const declarations = stmt.getDeclarationList().getDeclarations();
1222
+ for (const decl of declarations) {
1223
+ if (decl.getName() !== "metadata") continue;
1224
+ const initializer = decl.getInitializer();
1225
+ if (!initializer) continue;
1226
+ if (initializer.isKind(SyntaxKind12.ObjectLiteralExpression)) {
1227
+ const hasTitleProp = initializer.getProperties().some(
1228
+ (prop) => prop.isKind(SyntaxKind12.PropertyAssignment) && prop.getNameNode().getText() === "title"
1229
+ );
1230
+ if (hasTitleProp) {
1231
+ return [];
1232
+ }
1233
+ }
1234
+ }
1235
+ }
1236
+ const functionDeclarations = file.getDescendantsOfKind(
1237
+ SyntaxKind12.FunctionDeclaration
1238
+ );
1239
+ for (const func of functionDeclarations) {
1240
+ if (func.getName() !== "generateMetadata") continue;
1241
+ const hasExport = func.getModifiers().some(
1242
+ (mod) => mod.getKind() === SyntaxKind12.ExportKeyword
1243
+ );
1244
+ if (hasExport) {
1245
+ return [];
1246
+ }
1247
+ }
1248
+ return [
1249
+ {
1250
+ rule: "next-metadata-title",
1251
+ filePath,
1252
+ line: 1,
1253
+ column: 1,
1254
+ element: "page",
1255
+ message: "Page is missing metadata.title \u2014 Next.js route announcer will be silent"
1256
+ }
1257
+ ];
1258
+ }
1259
+ };
1260
+
1261
+ // src/rules/next-image-sizes/next-image-sizes.rule.ts
1262
+ import { SyntaxKind as SyntaxKind13 } from "ts-morph";
1263
+ function isNextImageImported(file) {
1264
+ const importDecls = file.getImportDeclarations();
1265
+ return importDecls.some(
1266
+ (decl) => decl.getModuleSpecifierValue() === "next/image"
1267
+ );
1268
+ }
1269
+ function hasBooleanAttribute(element, name) {
1270
+ const attrs = element.getAttributes();
1271
+ for (const attr of attrs) {
1272
+ if (!attr.isKind(SyntaxKind13.JsxAttribute)) continue;
1273
+ if (attr.getNameNode().getText() !== name) continue;
1274
+ const init = attr.getInitializer();
1275
+ if (!init) return true;
1276
+ if (init.isKind(SyntaxKind13.JsxExpression)) {
1277
+ const expr = init.getExpression();
1278
+ if (expr && expr.getText() === "true") return true;
1279
+ }
1280
+ return false;
1281
+ }
1282
+ return false;
1283
+ }
1284
+ function hasAttribute2(element, name) {
1285
+ return element.getAttributes().some(
1286
+ (attr) => attr.isKind(SyntaxKind13.JsxAttribute) && attr.getNameNode().getText() === name
1287
+ );
1288
+ }
1289
+ function checkElement2(element, file) {
1290
+ const tagName = element.getTagNameNode().getText();
1291
+ if (tagName !== "Image") return null;
1292
+ if (!hasBooleanAttribute(element, "fill")) return null;
1293
+ if (hasAttribute2(element, "sizes")) return null;
1294
+ const { line, column } = file.getLineAndColumnAtPos(element.getStart());
1295
+ return {
1296
+ rule: "next-image-sizes",
1297
+ filePath: file.getFilePath(),
1298
+ line,
1299
+ column,
1300
+ element: "<Image>",
1301
+ message: "<Image fill> without sizes prop loads full-width image on all viewports"
1302
+ };
1303
+ }
1304
+ var nextImageSizesRule = {
1305
+ id: "next-image-sizes",
1306
+ type: "deterministic",
1307
+ scan(file) {
1308
+ if (!isNextImageImported(file)) return [];
1309
+ const violations = [];
1310
+ const openingElements = file.getDescendantsOfKind(
1311
+ SyntaxKind13.JsxOpeningElement
1312
+ );
1313
+ for (const el of openingElements) {
1314
+ const violation = checkElement2(el, file);
1315
+ if (violation) violations.push(violation);
1316
+ }
1317
+ const selfClosingElements = file.getDescendantsOfKind(
1318
+ SyntaxKind13.JsxSelfClosingElement
1319
+ );
1320
+ for (const el of selfClosingElements) {
1321
+ const violation = checkElement2(el, file);
1322
+ if (violation) violations.push(violation);
1323
+ }
1324
+ return violations;
1325
+ }
1326
+ };
1327
+
1328
+ // src/rules/next-skip-nav/next-skip-nav.rule.ts
1329
+ import { SyntaxKind as SyntaxKind14 } from "ts-morph";
1330
+ var LAYOUT_FILE_PATTERN = /\blayout\.(tsx|jsx)$/;
1331
+ function getAttributeStringValue(element, name) {
1332
+ const attr = element.getAttribute(name);
1333
+ if (!attr || !attr.isKind(SyntaxKind14.JsxAttribute)) return void 0;
1334
+ const init = attr.getInitializer();
1335
+ if (!init) return void 0;
1336
+ if (init.isKind(SyntaxKind14.StringLiteral)) {
1337
+ return init.getLiteralValue();
1338
+ }
1339
+ return void 0;
1340
+ }
1341
+ function getJsxChildrenText(element) {
1342
+ const parent = element.getParentIfKind(SyntaxKind14.JsxElement);
1343
+ if (!parent) return "";
1344
+ return parent.getJsxChildren().map((child) => child.getText()).join(" ");
1345
+ }
1346
+ var nextSkipNavRule = {
1347
+ id: "next-skip-nav",
1348
+ type: "deterministic",
1349
+ scan(file) {
1350
+ const filePath = file.getFilePath();
1351
+ if (!LAYOUT_FILE_PATTERN.test(filePath)) {
1352
+ return [];
1353
+ }
1354
+ const openingElements = file.getDescendantsOfKind(
1355
+ SyntaxKind14.JsxOpeningElement
1356
+ );
1357
+ for (const el of openingElements) {
1358
+ const tagName = el.getTagNameNode().getText();
1359
+ if (tagName !== "a") continue;
1360
+ const href = getAttributeStringValue(el, "href");
1361
+ if (href === "#main-content") return [];
1362
+ const childrenText = getJsxChildrenText(el);
1363
+ if (/skip/i.test(childrenText)) return [];
1364
+ }
1365
+ const selfClosingElements = file.getDescendantsOfKind(
1366
+ SyntaxKind14.JsxSelfClosingElement
1367
+ );
1368
+ for (const el of selfClosingElements) {
1369
+ const tagName = el.getTagNameNode().getText();
1370
+ if (tagName !== "a") continue;
1371
+ const href = getAttributeStringValue(el, "href");
1372
+ if (href === "#main-content") return [];
1373
+ }
1374
+ return [
1375
+ {
1376
+ rule: "next-skip-nav",
1377
+ filePath,
1378
+ line: 1,
1379
+ column: 1,
1380
+ element: "layout",
1381
+ message: "Root layout is missing a skip navigation link"
1382
+ }
1383
+ ];
1384
+ }
1385
+ };
1386
+
1387
+ // src/rules/next-link-no-nested-a/next-link-no-nested-a.rule.ts
1388
+ import { SyntaxKind as SyntaxKind15 } from "ts-morph";
1389
+ function isNextLinkImported2(file) {
1390
+ const importDecls = file.getImportDeclarations();
1391
+ return importDecls.some(
1392
+ (decl) => decl.getModuleSpecifierValue() === "next/link"
1393
+ );
1394
+ }
1395
+ function hasNestedAnchor(linkElement) {
1396
+ const parent = linkElement.getParentIfKind(SyntaxKind15.JsxElement);
1397
+ if (!parent) return false;
1398
+ const children = parent.getJsxChildren();
1399
+ for (const child of children) {
1400
+ if (child.isKind(SyntaxKind15.JsxElement)) {
1401
+ const opening = child.getOpeningElement();
1402
+ if (opening.getTagNameNode().getText() === "a") return true;
1403
+ }
1404
+ if (child.isKind(SyntaxKind15.JsxSelfClosingElement)) {
1405
+ if (child.getTagNameNode().getText() === "a") return true;
1406
+ }
1407
+ }
1408
+ return false;
1409
+ }
1410
+ var nextLinkNoNestedARule = {
1411
+ id: "next-link-no-nested-a",
1412
+ type: "deterministic",
1413
+ scan(file) {
1414
+ if (!isNextLinkImported2(file)) return [];
1415
+ const violations = [];
1416
+ const openingElements = file.getDescendantsOfKind(
1417
+ SyntaxKind15.JsxOpeningElement
1418
+ );
1419
+ for (const el of openingElements) {
1420
+ const tagName = el.getTagNameNode().getText();
1421
+ if (tagName !== "Link") continue;
1422
+ if (!hasNestedAnchor(el)) continue;
1423
+ const { line, column } = file.getLineAndColumnAtPos(el.getStart());
1424
+ violations.push({
1425
+ rule: "next-link-no-nested-a",
1426
+ filePath: file.getFilePath(),
1427
+ line,
1428
+ column,
1429
+ element: "<Link>",
1430
+ message: "<Link> from next/link should not contain a nested <a> element. Since Next.js 13, <Link> renders an <a> automatically.",
1431
+ fix: {
1432
+ type: "remove-element",
1433
+ value: "nested-a"
1434
+ }
1435
+ });
1436
+ }
1437
+ return violations;
1438
+ }
1439
+ };
1440
+
1441
+ // src/rules/index.ts
1442
+ var ALL_RULES = [
1443
+ imgAltRule,
1444
+ buttonLabelRule,
1445
+ linkLabelRule,
1446
+ inputLabelRule,
1447
+ noPositiveTabindexRule,
1448
+ buttonTypeRule,
1449
+ linkNoopenerRule,
1450
+ emojiAltRule,
1451
+ htmlLangRule,
1452
+ headingOrderRule,
1453
+ noDivInteractiveRule,
1454
+ nextMetadataTitleRule,
1455
+ nextImageSizesRule,
1456
+ nextSkipNavRule,
1457
+ nextLinkNoNestedARule
1458
+ ];
1459
+ var RULE_MAP = new Map(
1460
+ ALL_RULES.map((r) => [r.id, r])
1461
+ );
1462
+ function getRulesForConfig(ruleSettings, noAi) {
1463
+ return ALL_RULES.filter((rule) => {
1464
+ const setting = ruleSettings[rule.id];
1465
+ if (setting === "off") return false;
1466
+ if (noAi && rule.type === "ai") return false;
1467
+ return true;
1468
+ });
1469
+ }
1470
+
1471
+ // src/scan/score.ts
1472
+ import * as fs3 from "fs";
1473
+ import * as path3 from "path";
1474
+ var WEIGHT_TABLE = {
1475
+ "img-alt": 2,
1476
+ "button-label": 2,
1477
+ "link-label": 2,
1478
+ "input-label": 3,
1479
+ "html-lang": 5,
1480
+ "next-metadata-title": 3,
1481
+ "next-skip-nav": 3,
1482
+ "next-link-no-nested-a": 2,
1483
+ "no-positive-tabindex": 1,
1484
+ "button-type": 1,
1485
+ "next-image-sizes": 1,
1486
+ "heading-order": 1,
1487
+ "no-div-interactive": 1,
1488
+ "emoji-alt": 0.5,
1489
+ "link-noopener": 0.5
1490
+ };
1491
+ function computeScore(violations) {
1492
+ let score = 100;
1493
+ for (const v of violations) {
1494
+ const weight = WEIGHT_TABLE[v.rule] ?? 1;
1495
+ score -= weight;
1496
+ }
1497
+ return Math.max(0, Math.min(100, Math.round(score)));
1498
+ }
1499
+ function getScoreBadge(score) {
1500
+ if (score >= 90) return { label: "Good", color: "green" };
1501
+ if (score >= 70) return { label: "Needs work", color: "yellow" };
1502
+ return { label: "Poor", color: "red" };
1503
+ }
1504
+ function loadPreviousScore(cacheDir) {
1505
+ const scorePath = path3.join(cacheDir, "score.json");
1506
+ try {
1507
+ if (fs3.existsSync(scorePath)) {
1508
+ const data = JSON.parse(fs3.readFileSync(scorePath, "utf-8"));
1509
+ return data.score;
1510
+ }
1511
+ } catch {
1512
+ }
1513
+ return void 0;
1514
+ }
1515
+ function savePreviousScore(cacheDir, score) {
1516
+ const scorePath = path3.join(cacheDir, "score.json");
1517
+ fs3.mkdirSync(cacheDir, { recursive: true });
1518
+ fs3.writeFileSync(scorePath, JSON.stringify({ score, timestamp: (/* @__PURE__ */ new Date()).toISOString() }));
1519
+ }
1520
+
1521
+ // src/apply/apply.ts
1522
+ import { SyntaxKind as SyntaxKind16 } from "ts-morph";
1523
+ async function applyFix(file, violation) {
1524
+ if (!violation.fix) return false;
1525
+ const fix = violation.fix;
1526
+ const value = typeof fix.value === "function" ? await fix.value() : fix.value;
1527
+ switch (fix.type) {
1528
+ case "insert-attr":
1529
+ return insertAttribute(file, violation.line, fix.attribute, value);
1530
+ case "replace-attr":
1531
+ return replaceAttribute(file, violation.line, fix.attribute, value);
1532
+ case "wrap-element":
1533
+ return wrapElement(file, violation.line, value);
1534
+ case "insert-element":
1535
+ return insertElement(file, violation.line, value);
1536
+ case "remove-element":
1537
+ return removeElement(file, violation.line);
1538
+ default:
1539
+ return false;
1540
+ }
1541
+ }
1542
+ function insertAttribute(file, line, attribute, value) {
1543
+ const elements = [
1544
+ ...file.getDescendantsOfKind(SyntaxKind16.JsxOpeningElement),
1545
+ ...file.getDescendantsOfKind(SyntaxKind16.JsxSelfClosingElement)
1546
+ ];
1547
+ for (const el of elements) {
1548
+ if (el.getStartLineNumber() === line) {
1549
+ const existing = el.getAttribute(attribute);
1550
+ if (!existing) {
1551
+ el.addAttribute({ name: attribute, initializer: `"${value}"` });
1552
+ return true;
1553
+ }
1554
+ }
1555
+ }
1556
+ return false;
1557
+ }
1558
+ function replaceAttribute(file, line, attribute, value) {
1559
+ const elements = [
1560
+ ...file.getDescendantsOfKind(SyntaxKind16.JsxOpeningElement),
1561
+ ...file.getDescendantsOfKind(SyntaxKind16.JsxSelfClosingElement)
1562
+ ];
1563
+ for (const el of elements) {
1564
+ if (el.getStartLineNumber() === line) {
1565
+ const attr = el.getAttribute(attribute);
1566
+ if (attr && attr.getKind() === SyntaxKind16.JsxAttribute) {
1567
+ const jsxAttr = attr.asKind(SyntaxKind16.JsxAttribute);
1568
+ if (jsxAttr) {
1569
+ const initializer = jsxAttr.getInitializer();
1570
+ if (initializer) {
1571
+ if (value.startsWith("{")) {
1572
+ initializer.replaceWithText(value);
1573
+ } else {
1574
+ initializer.replaceWithText(`"${value}"`);
1575
+ }
1576
+ return true;
1577
+ } else {
1578
+ jsxAttr.setInitializer(`"${value}"`);
1579
+ return true;
1580
+ }
1581
+ }
1582
+ }
1583
+ }
1584
+ }
1585
+ return false;
1586
+ }
1587
+ function wrapElement(file, line, wrapperText) {
1588
+ const fullText = file.getFullText();
1589
+ const lines = fullText.split("\n");
1590
+ if (line > 0 && line <= lines.length) {
1591
+ return true;
1592
+ }
1593
+ return false;
1594
+ }
1595
+ function insertElement(file, line, elementText) {
1596
+ const fullText = file.getFullText();
1597
+ const lines = fullText.split("\n");
1598
+ if (line > 0 && line <= lines.length) {
1599
+ const indent = lines[line - 1].match(/^(\s*)/)?.[1] ?? "";
1600
+ lines.splice(line - 1, 0, `${indent}${elementText}`);
1601
+ file.replaceWithText(lines.join("\n"));
1602
+ return true;
1603
+ }
1604
+ return false;
1605
+ }
1606
+ function removeElement(file, line) {
1607
+ const fullText = file.getFullText();
1608
+ const lines = fullText.split("\n");
1609
+ if (line > 0 && line <= lines.length) {
1610
+ lines.splice(line - 1, 1);
1611
+ file.replaceWithText(lines.join("\n"));
1612
+ return true;
1613
+ }
1614
+ return false;
1615
+ }
1616
+
1617
+ // src/scan/scan.ts
1618
+ async function scan(targetPath, config) {
1619
+ const absPath = path4.resolve(targetPath);
1620
+ const files = await discoverFiles(
1621
+ absPath,
1622
+ config.scanner.include,
1623
+ config.scanner.exclude
1624
+ );
1625
+ if (files.length === 0) {
1626
+ return {
1627
+ violations: [],
1628
+ filesScanned: 0,
1629
+ elementsScanned: 0,
1630
+ score: 100,
1631
+ fixedCount: 0
1632
+ };
1633
+ }
1634
+ const project = new Project({
1635
+ skipAddingFilesFromTsConfig: true,
1636
+ compilerOptions: {
1637
+ jsx: 4,
1638
+ // JsxEmit.ReactJSX
1639
+ allowJs: true,
1640
+ noEmit: true
1641
+ }
1642
+ });
1643
+ for (const filePath of files) {
1644
+ try {
1645
+ project.addSourceFileAtPath(filePath);
1646
+ } catch {
1647
+ }
1648
+ }
1649
+ const rules = getRulesForConfig(config.rules, config.noAi);
1650
+ const allViolations = [];
1651
+ let elementsScanned = 0;
1652
+ for (const sourceFile of project.getSourceFiles()) {
1653
+ for (const rule of rules) {
1654
+ try {
1655
+ const violations = rule.scan(sourceFile);
1656
+ allViolations.push(...violations);
1657
+ } catch {
1658
+ }
1659
+ }
1660
+ elementsScanned += sourceFile.getDescendants().length;
1661
+ }
1662
+ let fixedCount = 0;
1663
+ if (config.fix) {
1664
+ for (const violation of allViolations) {
1665
+ if (!violation.fix) continue;
1666
+ const rule = rules.find((r) => r.id === violation.rule);
1667
+ if (config.noAi && rule?.type === "ai") continue;
1668
+ try {
1669
+ const sourceFile = project.getSourceFile(violation.filePath);
1670
+ if (!sourceFile) continue;
1671
+ const applied = await applyFix(sourceFile, violation);
1672
+ if (applied) fixedCount++;
1673
+ } catch {
1674
+ }
1675
+ }
1676
+ if (fixedCount > 0) {
1677
+ await project.save();
1678
+ }
1679
+ }
1680
+ const remainingViolations = config.fix ? allViolations.filter((v) => !v.fix || config.noAi && rules.find((r) => r.id === v.rule)?.type === "ai") : allViolations;
1681
+ const score = computeScore(remainingViolations);
1682
+ const previousScore = loadPreviousScore(config.cache);
1683
+ savePreviousScore(config.cache, score);
1684
+ return {
1685
+ violations: allViolations,
1686
+ filesScanned: files.length,
1687
+ elementsScanned,
1688
+ score,
1689
+ previousScore,
1690
+ fixedCount
1691
+ };
1692
+ }
1693
+
1694
+ // src/cli/format.ts
1695
+ import pc from "picocolors";
1696
+ var RULE_ICONS = {
1697
+ "img-alt": "img",
1698
+ "button-label": "btn",
1699
+ "link-label": "lnk",
1700
+ "input-label": "inp",
1701
+ "html-lang": "lng",
1702
+ "emoji-alt": "emj",
1703
+ "no-positive-tabindex": "tab",
1704
+ "button-type": "btn",
1705
+ "link-noopener": "rel",
1706
+ "next-metadata-title": "ttl",
1707
+ "next-image-sizes": "img",
1708
+ "next-link-no-nested-a": "lnk",
1709
+ "next-skip-nav": "nav",
1710
+ "heading-order": "hdg",
1711
+ "no-div-interactive": "div"
1712
+ };
1713
+ function formatReport(result, fix) {
1714
+ const lines = [];
1715
+ lines.push("");
1716
+ lines.push(pc.bold(` next-a11y v0.1.0`));
1717
+ lines.push(
1718
+ ` Scanned ${result.filesScanned} files`
1719
+ );
1720
+ lines.push("");
1721
+ const badge = getScoreBadge(result.score);
1722
+ const colorFn = badge.color === "green" ? pc.green : badge.color === "yellow" ? pc.yellow : pc.red;
1723
+ lines.push(
1724
+ ` ${pc.bold("Accessibility Score:")} ${colorFn(pc.bold(`${result.score} / 100`))} ${colorFn(badge.label)}`
1725
+ );
1726
+ lines.push("");
1727
+ if (result.violations.length === 0) {
1728
+ lines.push(pc.green(" No accessibility issues found!"));
1729
+ lines.push("");
1730
+ return lines.join("\n");
1731
+ }
1732
+ const aiViolations = result.violations.filter(
1733
+ (v) => ["img-alt", "button-label", "link-label", "input-label"].includes(v.rule)
1734
+ );
1735
+ const deterministicViolations = result.violations.filter(
1736
+ (v) => [
1737
+ "html-lang",
1738
+ "emoji-alt",
1739
+ "no-positive-tabindex",
1740
+ "button-type",
1741
+ "link-noopener"
1742
+ ].includes(v.rule)
1743
+ );
1744
+ const nextViolations = result.violations.filter(
1745
+ (v) => [
1746
+ "next-metadata-title",
1747
+ "next-image-sizes",
1748
+ "next-link-no-nested-a",
1749
+ "next-skip-nav"
1750
+ ].includes(v.rule)
1751
+ );
1752
+ const detectOnlyViolations = result.violations.filter(
1753
+ (v) => ["heading-order", "no-div-interactive"].includes(v.rule)
1754
+ );
1755
+ if (aiViolations.length > 0) {
1756
+ lines.push(pc.bold(" AI fixes available:"));
1757
+ formatViolationGroup(lines, aiViolations);
1758
+ lines.push("");
1759
+ }
1760
+ if (deterministicViolations.length > 0) {
1761
+ lines.push(pc.bold(" Auto fixes available:"));
1762
+ formatViolationGroup(lines, deterministicViolations);
1763
+ lines.push("");
1764
+ }
1765
+ if (nextViolations.length > 0) {
1766
+ lines.push(pc.bold(" Next.js-specific:"));
1767
+ formatViolationGroup(lines, nextViolations);
1768
+ lines.push("");
1769
+ }
1770
+ if (detectOnlyViolations.length > 0) {
1771
+ lines.push(pc.bold(" Warnings (manual review needed):"));
1772
+ formatViolationGroup(lines, detectOnlyViolations);
1773
+ lines.push("");
1774
+ }
1775
+ const fixable = result.violations.filter((v) => v.fix).length;
1776
+ const warnings = result.violations.filter((v) => !v.fix).length;
1777
+ lines.push(pc.dim(" " + "-".repeat(40)));
1778
+ if (fix && result.fixedCount > 0) {
1779
+ lines.push(` ${pc.green(`${result.fixedCount} fixed`)} \xB7 ${warnings} warnings`);
1780
+ } else {
1781
+ lines.push(
1782
+ ` ${fixable} fixable \xB7 ${warnings} warnings${fixable > 0 ? ` \xB7 Run ${pc.bold("--fix")} to apply` : ""}`
1783
+ );
1784
+ }
1785
+ if (result.previousScore !== void 0) {
1786
+ const delta = result.score - result.previousScore;
1787
+ if (delta !== 0) {
1788
+ const deltaStr = delta > 0 ? pc.green(`+${delta}`) : pc.red(`${delta}`);
1789
+ lines.push(
1790
+ ` Score: ${result.previousScore} -> ${result.score} (${deltaStr} pts)`
1791
+ );
1792
+ }
1793
+ }
1794
+ lines.push("");
1795
+ return lines.join("\n");
1796
+ }
1797
+ function formatViolationGroup(lines, violations) {
1798
+ const byRule = /* @__PURE__ */ new Map();
1799
+ for (const v of violations) {
1800
+ const existing = byRule.get(v.rule) ?? [];
1801
+ existing.push(v);
1802
+ byRule.set(v.rule, existing);
1803
+ }
1804
+ for (const [rule, ruleViolations] of byRule) {
1805
+ const count = ruleViolations.length;
1806
+ const weight = WEIGHT_TABLE[rule] ?? 1;
1807
+ const totalPts = count * weight;
1808
+ const icon = RULE_ICONS[rule] ?? "???";
1809
+ lines.push(
1810
+ ` [${icon}] ${pc.bold(String(count).padStart(2))} ${formatRuleDescription(rule)}${totalPts > 0 ? pc.dim(` -${totalPts} pts`) : ""}`
1811
+ );
1812
+ }
1813
+ }
1814
+ function formatRuleDescription(rule) {
1815
+ const descriptions = {
1816
+ "img-alt": "images missing alt text",
1817
+ "button-label": "buttons without accessible name",
1818
+ "link-label": "links without accessible name",
1819
+ "input-label": "inputs without label",
1820
+ "html-lang": "missing lang on <html>",
1821
+ "emoji-alt": 'emoji without role="img"',
1822
+ "no-positive-tabindex": "positive tabIndex values",
1823
+ "button-type": "buttons without type",
1824
+ "link-noopener": 'links target="_blank" without rel',
1825
+ "next-metadata-title": "routes missing page title",
1826
+ "next-image-sizes": "next/image without sizes",
1827
+ "next-link-no-nested-a": "next/link wrapping nested <a>",
1828
+ "next-skip-nav": "missing skip navigation link",
1829
+ "heading-order": "heading hierarchy violations",
1830
+ "no-div-interactive": "div used as interactive element"
1831
+ };
1832
+ return descriptions[rule] ?? rule;
1833
+ }
1834
+
1835
+ // src/cli/scan-command.ts
1836
+ function registerScanCommand(program2) {
1837
+ program2.command("scan").description("Scan files for accessibility issues").argument("<path>", "Path to scan").option("--fix", "Auto-fix issues").option("-i, --interactive", "Review each fix interactively").option("--no-ai", "Skip AI-powered fixes").option("--provider <provider>", "Override AI provider").option("--model <model>", "Override AI model").option("--min-score <score>", "Minimum score threshold (exit code 1 if below)", parseInt).action(async (targetPath, options) => {
1838
+ const fileConfig = await loadConfigFile(process.cwd());
1839
+ const config = resolveConfig(fileConfig, {
1840
+ fix: options.fix,
1841
+ interactive: options.interactive,
1842
+ noAi: !options.ai,
1843
+ // commander inverts --no-ai to options.ai = false
1844
+ provider: options.provider,
1845
+ model: options.model,
1846
+ minScore: options.minScore
1847
+ });
1848
+ const result = await scan(targetPath, config);
1849
+ console.log(formatReport(result, config.fix));
1850
+ if (config.minScore !== void 0 && result.score < config.minScore) {
1851
+ console.error(
1852
+ ` Score ${result.score} is below minimum threshold ${config.minScore}`
1853
+ );
1854
+ process.exit(1);
1855
+ }
1856
+ });
1857
+ }
1858
+
1859
+ // src/cli/init-command.ts
1860
+ import * as fs4 from "fs";
1861
+ import * as path5 from "path";
1862
+ import * as readline from "readline";
1863
+ import { execSync } from "child_process";
1864
+ import pc2 from "picocolors";
1865
+ function registerInitCommand(program2) {
1866
+ program2.command("init").description("Initialize next-a11y configuration").action(async () => {
1867
+ console.log(pc2.bold("\n next-a11y v0.1.0 \u2014 Setup\n"));
1868
+ const options = await promptInitOptions();
1869
+ const cwd = process.cwd();
1870
+ const hasAppDir = fs4.existsSync(path5.join(cwd, "app"));
1871
+ const hasSrcDir = fs4.existsSync(path5.join(cwd, "src"));
1872
+ const include = [];
1873
+ if (hasSrcDir) include.push("src/**/*.{tsx,jsx}");
1874
+ if (hasAppDir) include.push("app/**/*.{tsx,jsx}");
1875
+ if (include.length === 0) include.push("**/*.{tsx,jsx}");
1876
+ const configContent = generateConfig(options.provider, include);
1877
+ const configPath = path5.join(cwd, "a11y.config.ts");
1878
+ fs4.writeFileSync(configPath, configContent);
1879
+ console.log(pc2.green(" Created a11y.config.ts"));
1880
+ if (options.provider !== "none" && options.installDep) {
1881
+ const pkgMap = {
1882
+ openai: "@ai-sdk/openai",
1883
+ anthropic: "@ai-sdk/anthropic",
1884
+ google: "@ai-sdk/google",
1885
+ ollama: "ollama-ai-provider"
1886
+ };
1887
+ const pkg = pkgMap[options.provider];
1888
+ if (pkg) {
1889
+ const pm = detectPackageManager(cwd);
1890
+ const installCmd = pm === "yarn" ? `yarn add ${pkg}` : pm === "pnpm" ? `pnpm add ${pkg}` : pm === "bun" ? `bun add ${pkg}` : `npm install ${pkg}`;
1891
+ try {
1892
+ console.log(pc2.dim(` Running: ${installCmd}`));
1893
+ execSync(installCmd, { cwd, stdio: "pipe" });
1894
+ console.log(pc2.green(` Installed ${pkg}`));
1895
+ } catch {
1896
+ console.log(pc2.yellow(` Failed to install ${pkg}. Run manually: ${installCmd}`));
1897
+ }
1898
+ }
1899
+ }
1900
+ if (options.addGitignore) {
1901
+ const gitignorePath = path5.join(cwd, ".gitignore");
1902
+ let content = "";
1903
+ if (fs4.existsSync(gitignorePath)) {
1904
+ content = fs4.readFileSync(gitignorePath, "utf-8");
1905
+ }
1906
+ if (!content.includes(".a11y-cache")) {
1907
+ const newline = content.endsWith("\n") ? "" : "\n";
1908
+ fs4.appendFileSync(gitignorePath, `${newline}.a11y-cache
1909
+ `);
1910
+ console.log(pc2.green(" Updated .gitignore"));
1911
+ }
1912
+ }
1913
+ console.log("");
1914
+ console.log(pc2.bold(" Next steps:"));
1915
+ if (options.provider !== "none" && options.provider !== "ollama") {
1916
+ const envVar = PROVIDER_ENV[options.provider];
1917
+ if (envVar) {
1918
+ console.log(` 1. Set ${pc2.bold(envVar)} in your .env`);
1919
+ console.log(` 2. Run: ${pc2.bold("npx next-a11y scan ./src")}`);
1920
+ }
1921
+ } else if (options.provider === "ollama") {
1922
+ console.log(` 1. Ensure Ollama is running locally`);
1923
+ console.log(` 2. Run: ${pc2.bold("npx next-a11y scan ./src")}`);
1924
+ } else {
1925
+ console.log(` 1. Run: ${pc2.bold("npx next-a11y scan ./src --no-ai")}`);
1926
+ }
1927
+ console.log("");
1928
+ });
1929
+ }
1930
+ async function promptInitOptions() {
1931
+ const provider = await promptSelect(
1932
+ "Which AI provider do you want to use?",
1933
+ [
1934
+ { value: "openai", label: "OpenAI (gpt-4.1-nano)" },
1935
+ { value: "google", label: "Google (gemini-2.0-flash-lite) \u2014 free tier" },
1936
+ { value: "anthropic", label: "Anthropic (claude-haiku-4-5)" },
1937
+ { value: "ollama", label: "Ollama (local, offline)" },
1938
+ { value: "none", label: "None \u2014 deterministic fixes only" }
1939
+ ]
1940
+ );
1941
+ let installDep = false;
1942
+ if (provider !== "none") {
1943
+ installDep = await promptYesNo(`Install AI SDK package now?`);
1944
+ }
1945
+ const addGitignore = await promptYesNo(`Add .a11y-cache to .gitignore?`);
1946
+ return { provider, installDep, addGitignore };
1947
+ }
1948
+ function promptSelect(question, options) {
1949
+ return new Promise((resolve3) => {
1950
+ const rl = readline.createInterface({
1951
+ input: process.stdin,
1952
+ output: process.stdout
1953
+ });
1954
+ console.log(` ${pc2.bold(question)}`);
1955
+ options.forEach((opt, i) => {
1956
+ console.log(` ${pc2.dim(`${i + 1}.`)} ${opt.label}`);
1957
+ });
1958
+ rl.question(` Choice [1-${options.length}]: `, (answer) => {
1959
+ rl.close();
1960
+ const idx = parseInt(answer.trim()) - 1;
1961
+ if (idx >= 0 && idx < options.length) {
1962
+ resolve3(options[idx].value);
1963
+ } else {
1964
+ resolve3(options[0].value);
1965
+ }
1966
+ });
1967
+ });
1968
+ }
1969
+ function promptYesNo(question) {
1970
+ return new Promise((resolve3) => {
1971
+ const rl = readline.createInterface({
1972
+ input: process.stdin,
1973
+ output: process.stdout
1974
+ });
1975
+ rl.question(` ${question} ${pc2.dim("[Y/n]")} `, (answer) => {
1976
+ rl.close();
1977
+ const normalized = answer.trim().toLowerCase();
1978
+ resolve3(normalized !== "n" && normalized !== "no");
1979
+ });
1980
+ });
1981
+ }
1982
+ function detectPackageManager(cwd) {
1983
+ if (fs4.existsSync(path5.join(cwd, "bun.lock"))) return "bun";
1984
+ if (fs4.existsSync(path5.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
1985
+ if (fs4.existsSync(path5.join(cwd, "yarn.lock"))) return "yarn";
1986
+ return "npm";
1987
+ }
1988
+ function generateConfig(provider, include) {
1989
+ const providerLine = provider === "none" ? " // provider: 'openai', // Uncomment and set when ready" : ` provider: "${provider}",`;
1990
+ const modelMap = {
1991
+ openai: "gpt-4.1-nano",
1992
+ anthropic: "claude-haiku-4-5-20251001",
1993
+ google: "gemini-2.0-flash-lite",
1994
+ ollama: "llava"
1995
+ };
1996
+ const modelLine = provider !== "none" ? `
1997
+ model: "${modelMap[provider]}",` : "";
1998
+ return `import { defineConfig } from "next-a11y";
1999
+
2000
+ export default defineConfig({
2001
+ ${providerLine}${modelLine}
2002
+ locale: "en",
2003
+ cache: ".a11y-cache",
2004
+ scanner: {
2005
+ include: [${include.map((p) => `"${p}"`).join(", ")}],
2006
+ exclude: ["**/*.test.*", "**/*.stories.*"],
2007
+ },
2008
+ rules: {
2009
+ "img-alt": "fix",
2010
+ "button-label": "fix",
2011
+ "link-label": "fix",
2012
+ "input-label": "fix",
2013
+ "html-lang": "fix",
2014
+ "emoji-alt": "fix",
2015
+ "no-positive-tabindex": "fix",
2016
+ "button-type": "fix",
2017
+ "link-noopener": "fix",
2018
+ "next-metadata-title": "warn",
2019
+ "next-image-sizes": "warn",
2020
+ "next-link-no-nested-a": "fix",
2021
+ "next-skip-nav": "warn",
2022
+ "heading-order": "warn",
2023
+ "no-div-interactive": "warn",
2024
+ },
2025
+ });
2026
+ `;
2027
+ }
2028
+
2029
+ // src/cli/cache-command.ts
2030
+ import pc3 from "picocolors";
2031
+
2032
+ // src/cache/fs-cache.ts
2033
+ import * as fs5 from "fs";
2034
+ import * as path6 from "path";
2035
+ import * as crypto from "crypto";
2036
+ var FsCache = class {
2037
+ constructor(cacheDir) {
2038
+ this.cacheDir = cacheDir;
2039
+ this.cachePath = path6.join(cacheDir, "cache.json");
2040
+ this.data = this.load();
2041
+ }
2042
+ load() {
2043
+ try {
2044
+ if (fs5.existsSync(this.cachePath)) {
2045
+ return JSON.parse(fs5.readFileSync(this.cachePath, "utf-8"));
2046
+ }
2047
+ } catch {
2048
+ }
2049
+ return {};
2050
+ }
2051
+ save() {
2052
+ fs5.mkdirSync(this.cacheDir, { recursive: true });
2053
+ fs5.writeFileSync(this.cachePath, JSON.stringify(this.data, null, 2));
2054
+ }
2055
+ static hashContent(content) {
2056
+ return crypto.createHash("sha256").update(content).digest("hex").slice(0, 16);
2057
+ }
2058
+ get(key) {
2059
+ return this.data[key];
2060
+ }
2061
+ set(key, entry) {
2062
+ this.data[key] = entry;
2063
+ this.save();
2064
+ }
2065
+ has(key) {
2066
+ return key in this.data;
2067
+ }
2068
+ clear() {
2069
+ this.data = {};
2070
+ if (fs5.existsSync(this.cachePath)) {
2071
+ fs5.unlinkSync(this.cachePath);
2072
+ }
2073
+ }
2074
+ stats() {
2075
+ const entries = Object.keys(this.data).length;
2076
+ let sizeBytes = 0;
2077
+ try {
2078
+ if (fs5.existsSync(this.cachePath)) {
2079
+ sizeBytes = fs5.statSync(this.cachePath).size;
2080
+ }
2081
+ } catch {
2082
+ }
2083
+ return { entries, sizeBytes };
2084
+ }
2085
+ };
2086
+
2087
+ // src/cli/cache-command.ts
2088
+ function registerCacheCommand(program2) {
2089
+ const cache = program2.command("cache").description("Manage the AI result cache");
2090
+ cache.command("stats").description("Show cache statistics").action(async () => {
2091
+ const fileConfig = await loadConfigFile(process.cwd());
2092
+ const config = resolveConfig(fileConfig);
2093
+ const fsCache = new FsCache(config.cache);
2094
+ const stats = fsCache.stats();
2095
+ console.log(pc3.bold("\n Cache Statistics\n"));
2096
+ console.log(` Entries: ${stats.entries}`);
2097
+ console.log(
2098
+ ` Size: ${formatBytes(stats.sizeBytes)}`
2099
+ );
2100
+ console.log(` Path: ${config.cache}/cache.json`);
2101
+ console.log("");
2102
+ });
2103
+ cache.command("clear").description("Clear the cache").action(async () => {
2104
+ const fileConfig = await loadConfigFile(process.cwd());
2105
+ const config = resolveConfig(fileConfig);
2106
+ const fsCache = new FsCache(config.cache);
2107
+ fsCache.clear();
2108
+ console.log(pc3.green("\n Cache cleared.\n"));
2109
+ });
2110
+ }
2111
+ function formatBytes(bytes) {
2112
+ if (bytes === 0) return "0 B";
2113
+ const units = ["B", "KB", "MB", "GB"];
2114
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
2115
+ return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
2116
+ }
2117
+
2118
+ // src/cli/index.ts
2119
+ var program = new Command();
2120
+ program.name("next-a11y").description("AI-powered accessibility codemod for Next.js").version("0.1.0");
2121
+ registerScanCommand(program);
2122
+ registerInitCommand(program);
2123
+ registerCacheCommand(program);
2124
+ program.parse();