a11y-pilot 1.0.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/LICENSE +21 -0
- package/README.md +281 -0
- package/bin/a11y-pilot.js +5 -0
- package/package.json +67 -0
- package/src/cli.js +324 -0
- package/src/copilot-bridge.js +332 -0
- package/src/parsers/html-parser.js +124 -0
- package/src/parsers/jsx-parser.js +134 -0
- package/src/reporter.js +281 -0
- package/src/rules/anchor-content.js +46 -0
- package/src/rules/button-content.js +37 -0
- package/src/rules/form-label.js +47 -0
- package/src/rules/heading-order.js +51 -0
- package/src/rules/img-alt.js +40 -0
- package/src/rules/index.js +43 -0
- package/src/rules/no-autofocus.js +32 -0
- package/src/rules/no-div-button.js +54 -0
- package/src/rules/semantic-nav.js +75 -0
- package/src/scanner.js +106 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: semantic-nav
|
|
3
|
+
* Navigation links should be wrapped in a <nav> landmark element.
|
|
4
|
+
* WCAG 1.3.1 — Info and Relationships (Level A)
|
|
5
|
+
* WCAG 2.4.1 — Bypass Blocks (Level A)
|
|
6
|
+
*
|
|
7
|
+
* This rule checks for groups of links inside non-semantic containers.
|
|
8
|
+
*/
|
|
9
|
+
export default {
|
|
10
|
+
id: 'semantic-nav',
|
|
11
|
+
description: 'Groups of navigation links should be wrapped in a <nav> element',
|
|
12
|
+
severity: 'warning',
|
|
13
|
+
wcag: '1.3.1, 2.4.1',
|
|
14
|
+
impact: 'Screen reader users cannot identify and skip navigation blocks without <nav> landmarks',
|
|
15
|
+
url: 'https://www.w3.org/WAI/WCAG21/Understanding/bypass-blocks.html',
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* This rule doesn't check individual elements.
|
|
19
|
+
* It uses checkFile to analyze link patterns.
|
|
20
|
+
*/
|
|
21
|
+
check() {
|
|
22
|
+
return null;
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check for navigation-like patterns without <nav>
|
|
27
|
+
* Looks for divs/uls containing multiple anchor links
|
|
28
|
+
* @param {object[]} elements - All parsed elements
|
|
29
|
+
* @param {string} code - Original source code
|
|
30
|
+
* @returns {object[]} Array of issues
|
|
31
|
+
*/
|
|
32
|
+
checkNavPatterns(elements, code) {
|
|
33
|
+
const issues = [];
|
|
34
|
+
const lines = code.split('\n');
|
|
35
|
+
|
|
36
|
+
// Simple heuristic: look for lines with multiple <a> or <Link> in sequence
|
|
37
|
+
// inside a <div> or <ul> but not inside a <nav>
|
|
38
|
+
let inNav = false;
|
|
39
|
+
let consecutiveLinks = 0;
|
|
40
|
+
let linkStartLine = 0;
|
|
41
|
+
|
|
42
|
+
// Check if code contains <nav> at all
|
|
43
|
+
const hasNav = elements.some(el => el.name === 'nav');
|
|
44
|
+
|
|
45
|
+
// If there's already a <nav>, skip this check (simplified heuristic)
|
|
46
|
+
if (hasNav) return issues;
|
|
47
|
+
|
|
48
|
+
// Count anchor elements — if there are 3+ links and no <nav>, flag it
|
|
49
|
+
const anchorElements = elements.filter(el => el.name === 'a' && 'href' in el.attributes);
|
|
50
|
+
|
|
51
|
+
if (anchorElements.length >= 3) {
|
|
52
|
+
// Check if anchors are close together (within 10 lines of each other)
|
|
53
|
+
for (let i = 0; i < anchorElements.length - 2; i++) {
|
|
54
|
+
const a1 = anchorElements[i];
|
|
55
|
+
const a2 = anchorElements[i + 1];
|
|
56
|
+
const a3 = anchorElements[i + 2];
|
|
57
|
+
|
|
58
|
+
if (a3.line - a1.line <= 15) {
|
|
59
|
+
issues.push({
|
|
60
|
+
ruleId: this.id,
|
|
61
|
+
severity: this.severity,
|
|
62
|
+
message: `Found ${anchorElements.length} navigation links without a <nav> landmark wrapper`,
|
|
63
|
+
line: a1.line,
|
|
64
|
+
sourceLine: a1.sourceLine,
|
|
65
|
+
fix: 'Wrap these navigation links in a <nav> element with an aria-label',
|
|
66
|
+
copilotPrompt: `Look around line ${a1.line}. There are multiple navigation links (<a> tags) grouped together but not wrapped in a <nav> element. Wrap the group of navigation links in a <nav aria-label="Main navigation"> element (or another appropriate label). This helps screen reader users identify and skip past navigation blocks. Keep the existing structure inside, just add the <nav> wrapper.`,
|
|
67
|
+
});
|
|
68
|
+
break; // Only report once per file
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return issues;
|
|
74
|
+
},
|
|
75
|
+
};
|
package/src/scanner.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Recursively walk a directory and return file paths matching given extensions.
|
|
10
|
+
* Respects common ignore patterns (node_modules, .git, dist, build, etc.)
|
|
11
|
+
*/
|
|
12
|
+
const IGNORE_DIRS = new Set([
|
|
13
|
+
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt',
|
|
14
|
+
'coverage', '.cache', '.turbo', 'out', '.output', 'vendor',
|
|
15
|
+
'__pycache__', '.svelte-kit', 'storybook-static'
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const SUPPORTED_EXTENSIONS = new Set([
|
|
19
|
+
'.html', '.htm', '.jsx', '.tsx', '.vue', '.astro', '.svelte'
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Walk directory tree and collect scannable files
|
|
24
|
+
* @param {string} dir - Directory to walk
|
|
25
|
+
* @param {string[]} [extensions] - File extensions to include
|
|
26
|
+
* @returns {string[]} Array of absolute file paths
|
|
27
|
+
*/
|
|
28
|
+
export function walkDir(dir, extensions = null) {
|
|
29
|
+
const allowedExts = extensions
|
|
30
|
+
? new Set(extensions.map(e => e.startsWith('.') ? e : `.${e}`))
|
|
31
|
+
: SUPPORTED_EXTENSIONS;
|
|
32
|
+
|
|
33
|
+
const results = [];
|
|
34
|
+
|
|
35
|
+
function walk(currentDir) {
|
|
36
|
+
let entries;
|
|
37
|
+
try {
|
|
38
|
+
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
39
|
+
} catch {
|
|
40
|
+
return; // skip unreadable dirs
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
45
|
+
|
|
46
|
+
if (entry.isDirectory()) {
|
|
47
|
+
if (!IGNORE_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
|
|
48
|
+
walk(fullPath);
|
|
49
|
+
}
|
|
50
|
+
} else if (entry.isFile()) {
|
|
51
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
52
|
+
if (allowedExts.has(ext)) {
|
|
53
|
+
results.push(fullPath);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// If dir is actually a file, check it directly
|
|
60
|
+
const stat = fs.statSync(dir);
|
|
61
|
+
if (stat.isFile()) {
|
|
62
|
+
const ext = path.extname(dir).toLowerCase();
|
|
63
|
+
if (allowedExts.has(ext)) {
|
|
64
|
+
return [path.resolve(dir)];
|
|
65
|
+
}
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
walk(dir);
|
|
70
|
+
return results.sort();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read file content safely
|
|
75
|
+
* @param {string} filePath
|
|
76
|
+
* @returns {string|null}
|
|
77
|
+
*/
|
|
78
|
+
export function readFileSafe(filePath) {
|
|
79
|
+
try {
|
|
80
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Determine file type from extension
|
|
88
|
+
* @param {string} filePath
|
|
89
|
+
* @returns {'jsx'|'html'|'vue'|'unknown'}
|
|
90
|
+
*/
|
|
91
|
+
export function getFileType(filePath) {
|
|
92
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
93
|
+
if (['.jsx', '.tsx'].includes(ext)) return 'jsx';
|
|
94
|
+
if (['.html', '.htm'].includes(ext)) return 'html';
|
|
95
|
+
if (['.vue', '.svelte', '.astro'].includes(ext)) return 'html'; // template-based
|
|
96
|
+
return 'unknown';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get relative path from cwd
|
|
101
|
+
* @param {string} absolutePath
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export function relativePath(absolutePath) {
|
|
105
|
+
return path.relative(process.cwd(), absolutePath);
|
|
106
|
+
}
|