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.
- package/README.md +161 -0
- package/bin/cli.js +2 -0
- package/dist/chunk-PE4WYXR5.mjs +50 -0
- package/dist/cli/index.d.mts +2 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +2183 -0
- package/dist/cli/index.mjs +2124 -0
- package/dist/index.d.mts +64 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +34 -0
- package/dist/index.mjs +6 -0
- package/package.json +60 -0
|
@@ -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();
|