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