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.
@@ -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
+ }