dtp-caal 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/dist/analyzer.js +119 -0
- package/dist/index.js +66 -0
- package/dist/remediator.js +140 -0
- package/dist/reporter.js +48 -0
- package/dist/scanner.js +54 -0
- package/dist/src/analyzer.js +119 -0
- package/dist/src/index.js +59 -0
- package/dist/src/reporter.js +48 -0
- package/dist/src/scanner.js +54 -0
- package/package.json +37 -0
package/dist/analyzer.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.analyzeElement = analyzeElement;
|
|
7
|
+
exports.analyzeElements = analyzeElements;
|
|
8
|
+
const groq_sdk_1 = __importDefault(require("groq-sdk"));
|
|
9
|
+
const dotenv_1 = __importDefault(require("dotenv"));
|
|
10
|
+
dotenv_1.default.config();
|
|
11
|
+
const apiKey = process.env.GROQ_API_KEY || '';
|
|
12
|
+
const groq = new groq_sdk_1.default({ apiKey });
|
|
13
|
+
const delay = (ms) => new Promise(res => setTimeout(res, ms));
|
|
14
|
+
async function analyzeElement(element) {
|
|
15
|
+
const prompt = `
|
|
16
|
+
You are an expert accessibility engineer. Your task is to analyze an HTML/JSX element within its parent context to determine if it meets WCAG accessibility standards.
|
|
17
|
+
You must output ONLY valid JSON without any markdown code blocks or conversational text.
|
|
18
|
+
|
|
19
|
+
Context:
|
|
20
|
+
Parent Code:
|
|
21
|
+
\`\`\`
|
|
22
|
+
${element.parentHtml}
|
|
23
|
+
\`\`\`
|
|
24
|
+
|
|
25
|
+
Target Element Code:
|
|
26
|
+
\`\`\`
|
|
27
|
+
${element.elementHtml}
|
|
28
|
+
\`\`\`
|
|
29
|
+
|
|
30
|
+
Analyze the target element. Does it have sufficient context for screen readers? Does it use semantic HTML properly? Is it missing ARIA attributes where necessary?
|
|
31
|
+
|
|
32
|
+
Be extremely accurate in your code fix. Ensure the replacement code contains all necessary structural semantics, aria-labels, alt text, and valid roles based on the parent context.
|
|
33
|
+
CRITICAL INSTRUCTIONS FOR FIX:
|
|
34
|
+
1. Your suggested fix MUST COMPLETELY resolve the accessibility issue. If the fixed element were analyzed again, it MUST pass all WCAG checks.
|
|
35
|
+
2. Provide the COMPLETE Target Element in your fix, including its opening tag, all original children, and its closing tag. Do NOT provide partial snippets.
|
|
36
|
+
3. The replacement code MUST match the exact framework syntax of the input. If the input uses React JSX syntax (like \`className\`, camelCase attributes, or \`style={{}}\`), the output MUST be valid JSX. If standard HTML, output standard HTML.
|
|
37
|
+
4. Maintain all existing non-accessibility attributes (e.g., \`id\`, \`class\`, \`onClick\`, \`href\`, etc.) exactly as they appear in the original Target Element.
|
|
38
|
+
|
|
39
|
+
Return a JSON object with this exact structure:
|
|
40
|
+
{
|
|
41
|
+
"isAccessible": boolean,
|
|
42
|
+
"issueTitle": string (or null if isAccessible is true. A concise title of the WCAG violation),
|
|
43
|
+
"explanation": string (or null. Explain the issue concisely to a developer),
|
|
44
|
+
"suggestedFixCode": string (or null. Provide the highly accurate, complete replacement code for the Target Element that fixes the issue, matching the input's syntax),
|
|
45
|
+
"fixReasoning": string (or null. Briefly explain exactly what the suggested fix code does and how it solves the accessibility issue)
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
let retries = 3;
|
|
49
|
+
let delayMs = 2000;
|
|
50
|
+
while (retries > 0) {
|
|
51
|
+
try {
|
|
52
|
+
const completion = await groq.chat.completions.create({
|
|
53
|
+
messages: [
|
|
54
|
+
{
|
|
55
|
+
role: 'system',
|
|
56
|
+
content: 'You are an AI that only outputs valid JSON. Do not output anything else.'
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
role: 'user',
|
|
60
|
+
content: prompt
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
model: 'llama-3.3-70b-versatile',
|
|
64
|
+
response_format: { type: 'json_object' }
|
|
65
|
+
});
|
|
66
|
+
const text = completion.choices[0]?.message?.content;
|
|
67
|
+
if (text) {
|
|
68
|
+
const parsed = JSON.parse(text);
|
|
69
|
+
return {
|
|
70
|
+
...element,
|
|
71
|
+
...parsed
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
throw new Error("No text in response");
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error.status === 503 || error.status === 429 || error.message?.includes('503') || error.message?.includes('429')) {
|
|
78
|
+
console.warn(`[WARN] Rate limited. Retries left: ${retries - 1}. Retrying in ${delayMs}ms...`);
|
|
79
|
+
retries--;
|
|
80
|
+
if (retries === 0) {
|
|
81
|
+
return {
|
|
82
|
+
...element,
|
|
83
|
+
isAccessible: false,
|
|
84
|
+
error: "Failed to analyze due to API rate limits."
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
await delay(delayMs);
|
|
88
|
+
delayMs *= 2;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
return {
|
|
92
|
+
...element,
|
|
93
|
+
isAccessible: false,
|
|
94
|
+
error: error.message || "Unknown API error"
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
...element,
|
|
101
|
+
isAccessible: false,
|
|
102
|
+
error: "Exhausted retries"
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async function analyzeElements(elements) {
|
|
106
|
+
const results = [];
|
|
107
|
+
console.log(`Analyzing ${elements.length} elements using Groq API...`);
|
|
108
|
+
for (let i = 0; i < elements.length; i++) {
|
|
109
|
+
// Log progress
|
|
110
|
+
console.log(`Analyzing element ${i + 1}/${elements.length}: <${elements[i].tagName}>...`);
|
|
111
|
+
const result = await analyzeElement(elements[i]);
|
|
112
|
+
results.push(result);
|
|
113
|
+
// Wait briefly between requests to avoid rate limits
|
|
114
|
+
if (i < elements.length - 1) {
|
|
115
|
+
await delay(1000);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return results;
|
|
119
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const scanner_1 = require("./scanner");
|
|
10
|
+
const analyzer_1 = require("./analyzer");
|
|
11
|
+
const reporter_1 = require("./reporter");
|
|
12
|
+
const remediator_1 = require("./remediator");
|
|
13
|
+
const program = new commander_1.Command();
|
|
14
|
+
program
|
|
15
|
+
.name('a11y-audit')
|
|
16
|
+
.description('Context-Aware Accessibility Linter CLI')
|
|
17
|
+
.version('1.0.0')
|
|
18
|
+
.requiredOption('-u, --url <url>', 'URL to scan (e.g., http://localhost:3000)', 'http://localhost:3000')
|
|
19
|
+
.option('-o, --output <path>', 'Output file path', './caal-report.md')
|
|
20
|
+
.option('-f, --format <format>', 'Output format (json or md)', 'md')
|
|
21
|
+
.option('--auto-fix', 'Automatically attempt to fix source files (beta)')
|
|
22
|
+
.option('--src-dir <path>', 'Directory containing source files for auto-fix', './')
|
|
23
|
+
.action(async (options) => {
|
|
24
|
+
try {
|
|
25
|
+
console.log(chalk_1.default.blue(`Starting accessibility audit for: ${options.url}`));
|
|
26
|
+
// Step 1: Scan page and extract elements
|
|
27
|
+
const scannedElements = await (0, scanner_1.scanPage)(options.url);
|
|
28
|
+
if (scannedElements.length === 0) {
|
|
29
|
+
console.log(chalk_1.default.yellow('No relevant elements found to analyze.'));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// Step 2: Analyze with LLM
|
|
33
|
+
if (!process.env.GROQ_API_KEY) {
|
|
34
|
+
console.error(chalk_1.default.red('Error: GROQ_API_KEY environment variable is not set.'));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
const results = await (0, analyzer_1.analyzeElements)(scannedElements);
|
|
38
|
+
// Step 3: Report
|
|
39
|
+
const format = options.format.toLowerCase();
|
|
40
|
+
if (format === 'json' || options.output.endsWith('.json')) {
|
|
41
|
+
(0, reporter_1.generateJsonReport)(results, options.output);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
(0, reporter_1.generateMarkdownReport)(results, options.output);
|
|
45
|
+
}
|
|
46
|
+
// Step 4: Auto-Fix if enabled
|
|
47
|
+
if (options.autoFix) {
|
|
48
|
+
await (0, remediator_1.autoFix)(results, options.srcDir);
|
|
49
|
+
}
|
|
50
|
+
// Step 5: Exit with error code if issues found (useful for CI/CD)
|
|
51
|
+
const failedElements = results.filter(r => !r.isAccessible);
|
|
52
|
+
if (failedElements.length > 0) {
|
|
53
|
+
console.log(chalk_1.default.red(`\nFound ${failedElements.length} accessibility issues!`));
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
console.log(chalk_1.default.green('\nAll checks passed! 🎉'));
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
console.error(chalk_1.default.red('Audit failed:'), error);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.autoFix = autoFix;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const glob_1 = require("glob");
|
|
9
|
+
const groq_sdk_1 = __importDefault(require("groq-sdk"));
|
|
10
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
11
|
+
const dotenv_1 = __importDefault(require("dotenv"));
|
|
12
|
+
dotenv_1.default.config();
|
|
13
|
+
const apiKey = process.env.GROQ_API_KEY || '';
|
|
14
|
+
const groq = new groq_sdk_1.default({ apiKey });
|
|
15
|
+
const delay = (ms) => new Promise(res => setTimeout(res, ms));
|
|
16
|
+
function extractSearchableStrings(html) {
|
|
17
|
+
const strings = [];
|
|
18
|
+
// Extract text content if it's a simple tag (very naive approach)
|
|
19
|
+
const textMatch = html.match(/>([^<]+)</);
|
|
20
|
+
if (textMatch && textMatch[1].trim()) {
|
|
21
|
+
strings.push(textMatch[1].trim());
|
|
22
|
+
}
|
|
23
|
+
// Extract some common attributes
|
|
24
|
+
const classMatch = html.match(/class(?:Name)?=["']([^"']+)["']/);
|
|
25
|
+
if (classMatch)
|
|
26
|
+
strings.push(classMatch[1].split(' ')[0]); // just the first class for simplicity
|
|
27
|
+
const idMatch = html.match(/id=["']([^"']+)["']/);
|
|
28
|
+
if (idMatch)
|
|
29
|
+
strings.push(idMatch[1]);
|
|
30
|
+
const altMatch = html.match(/alt=["']([^"']*)["']/);
|
|
31
|
+
if (altMatch)
|
|
32
|
+
strings.push(altMatch[1]);
|
|
33
|
+
return strings.filter(s => s.length > 2); // only use somewhat meaningful strings
|
|
34
|
+
}
|
|
35
|
+
async function findProbableSourceFile(srcDir, elementHtml) {
|
|
36
|
+
const searchTerms = extractSearchableStrings(elementHtml);
|
|
37
|
+
if (searchTerms.length === 0)
|
|
38
|
+
return null;
|
|
39
|
+
const files = await (0, glob_1.glob)('**/*.{ts,tsx,js,jsx,html,vue,svelte}', {
|
|
40
|
+
cwd: srcDir,
|
|
41
|
+
ignore: ['node_modules/**', 'dist/**', 'build/**', '.git/**'],
|
|
42
|
+
absolute: true
|
|
43
|
+
});
|
|
44
|
+
let bestFile = null;
|
|
45
|
+
let maxScore = 0;
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
const content = fs_1.default.readFileSync(file, 'utf-8');
|
|
48
|
+
let score = 0;
|
|
49
|
+
for (const term of searchTerms) {
|
|
50
|
+
if (content.includes(term)) {
|
|
51
|
+
score++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (score > maxScore) {
|
|
55
|
+
maxScore = score;
|
|
56
|
+
bestFile = file;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// We only consider it a match if we found at least something relevant
|
|
60
|
+
return maxScore > 0 ? bestFile : null;
|
|
61
|
+
}
|
|
62
|
+
async function autoFix(results, srcDir) {
|
|
63
|
+
const failedResults = results.filter(r => !r.isAccessible && r.suggestedFixCode);
|
|
64
|
+
if (failedResults.length === 0) {
|
|
65
|
+
console.log(chalk_1.default.green('No automated fixes to apply.'));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
console.log(chalk_1.default.cyan(`\nAttempting Automated Remediation on ${failedResults.length} issues in ${srcDir}...`));
|
|
69
|
+
for (let i = 0; i < failedResults.length; i++) {
|
|
70
|
+
const result = failedResults[i];
|
|
71
|
+
console.log(chalk_1.default.yellow(`\n[Fix ${i + 1}/${failedResults.length}] Issue: ${result.issueTitle}`));
|
|
72
|
+
const sourceFile = await findProbableSourceFile(srcDir, result.elementHtml);
|
|
73
|
+
if (!sourceFile) {
|
|
74
|
+
console.log(chalk_1.default.red(` -> Could not locate a probable source file for this element.`));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
console.log(chalk_1.default.green(` -> Probable source file found: ${sourceFile}`));
|
|
78
|
+
const originalContent = fs_1.default.readFileSync(sourceFile, 'utf-8');
|
|
79
|
+
const prompt = `
|
|
80
|
+
You are an expert developer. Your task is to apply an accessibility fix to a source code file.
|
|
81
|
+
I will give you the complete original source file, and the details of an accessibility issue found in an element that was rendered from this file.
|
|
82
|
+
|
|
83
|
+
Original Source File:
|
|
84
|
+
\`\`\`
|
|
85
|
+
${originalContent}
|
|
86
|
+
\`\`\`
|
|
87
|
+
|
|
88
|
+
Rendered Element (HTML) with issue:
|
|
89
|
+
\`\`\`
|
|
90
|
+
${result.elementHtml}
|
|
91
|
+
\`\`\`
|
|
92
|
+
|
|
93
|
+
Suggested fix for the rendered element:
|
|
94
|
+
\`\`\`
|
|
95
|
+
${result.suggestedFixCode}
|
|
96
|
+
\`\`\`
|
|
97
|
+
|
|
98
|
+
Your Task:
|
|
99
|
+
Find where the "Rendered Element" comes from in the "Original Source File".
|
|
100
|
+
Apply the "Suggested fix" logic to the exact component or JSX/HTML code in the file.
|
|
101
|
+
Make sure you write valid syntax for the framework (e.g., in React use className instead of class, camelCase for attributes).
|
|
102
|
+
Return the ENTIRE updated source file content. DO NOT truncate or omit any unchanged parts. The output must be the complete, ready-to-save file.
|
|
103
|
+
DO NOT wrap the response in markdown code blocks. OUTPUT ONLY THE RAW CODE.
|
|
104
|
+
`;
|
|
105
|
+
try {
|
|
106
|
+
console.log(chalk_1.default.gray(` -> Asking LLM to apply fix to source file...`));
|
|
107
|
+
const completion = await groq.chat.completions.create({
|
|
108
|
+
messages: [
|
|
109
|
+
{
|
|
110
|
+
role: 'system',
|
|
111
|
+
content: 'You are an AI code editor. Output ONLY the raw updated source code. Do not include markdown formatting or explanations.'
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
role: 'user',
|
|
115
|
+
content: prompt
|
|
116
|
+
}
|
|
117
|
+
],
|
|
118
|
+
model: 'llama-3.3-70b-versatile',
|
|
119
|
+
});
|
|
120
|
+
let newContent = completion.choices[0]?.message?.content || '';
|
|
121
|
+
// Just in case it wraps in markdown despite instructions
|
|
122
|
+
if (newContent.startsWith('\`\`\`')) {
|
|
123
|
+
newContent = newContent.replace(/^\`\`\`[a-z]*\n/, '').replace(/\n\`\`\`$/, '');
|
|
124
|
+
}
|
|
125
|
+
if (newContent && newContent !== originalContent) {
|
|
126
|
+
fs_1.default.writeFileSync(sourceFile, newContent);
|
|
127
|
+
console.log(chalk_1.default.green(` -> Successfully patched ${sourceFile}`));
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
console.log(chalk_1.default.red(` -> LLM failed to modify the file or returned identical code.`));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
console.log(chalk_1.default.red(` -> Error patching file: ${error.message}`));
|
|
135
|
+
}
|
|
136
|
+
// Wait to avoid rate limits
|
|
137
|
+
await delay(1500);
|
|
138
|
+
}
|
|
139
|
+
console.log(chalk_1.default.cyan(`\nAutomated Remediation completed! Note: Changes are left uncommitted on your file system for you to review manually.`));
|
|
140
|
+
}
|
package/dist/reporter.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.generateJsonReport = generateJsonReport;
|
|
7
|
+
exports.generateMarkdownReport = generateMarkdownReport;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
function generateJsonReport(results, outputPath) {
|
|
11
|
+
const outputDir = path_1.default.dirname(outputPath);
|
|
12
|
+
if (!fs_1.default.existsSync(outputDir)) {
|
|
13
|
+
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
fs_1.default.writeFileSync(outputPath, JSON.stringify(results, null, 2));
|
|
16
|
+
console.log(`JSON report generated at ${outputPath}`);
|
|
17
|
+
}
|
|
18
|
+
function generateMarkdownReport(results, outputPath) {
|
|
19
|
+
const outputDir = path_1.default.dirname(outputPath);
|
|
20
|
+
if (!fs_1.default.existsSync(outputDir)) {
|
|
21
|
+
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
const failedElements = results.filter(r => !r.isAccessible);
|
|
24
|
+
let md = `# Accessibility Audit Report\n\n`;
|
|
25
|
+
md += `**Total Elements Scanned:** ${results.length}\n`;
|
|
26
|
+
md += `**Accessibility Issues Found:** ${failedElements.length}\n\n`;
|
|
27
|
+
if (failedElements.length === 0) {
|
|
28
|
+
md += `🎉 Congratulations! No accessibility issues were found.\n`;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
md += `## Issues Detected\n\n`;
|
|
32
|
+
failedElements.forEach((el, i) => {
|
|
33
|
+
md += `### ${i + 1}. <${el.tagName}>: ${el.issueTitle || 'Issue'}\n\n`;
|
|
34
|
+
if (el.error) {
|
|
35
|
+
md += `**Error during analysis:** ${el.error}\n\n`;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
md += `**Explanation:** ${el.explanation}\n\n`;
|
|
39
|
+
md += `**Original Code:**\n\`\`\`html\n${el.elementHtml}\n\`\`\`\n\n`;
|
|
40
|
+
md += `**Suggested Fix:**\n\`\`\`html\n${el.suggestedFixCode}\n\`\`\`\n\n`;
|
|
41
|
+
md += `**Why this works:** ${el.fixReasoning}\n\n`;
|
|
42
|
+
}
|
|
43
|
+
md += `---\n\n`;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
fs_1.default.writeFileSync(outputPath, md);
|
|
47
|
+
console.log(`Markdown report generated at ${outputPath}`);
|
|
48
|
+
}
|
package/dist/scanner.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scanPage = scanPage;
|
|
4
|
+
const playwright_1 = require("playwright");
|
|
5
|
+
async function scanPage(url) {
|
|
6
|
+
console.log(`\nNavigating to ${url}...`);
|
|
7
|
+
// Launch headless chromium
|
|
8
|
+
const browser = await playwright_1.chromium.launch({ headless: true });
|
|
9
|
+
const page = await browser.newPage();
|
|
10
|
+
try {
|
|
11
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
12
|
+
console.log('Page loaded. Extracting elements...');
|
|
13
|
+
// Wait a tiny bit extra for framework rendering if needed
|
|
14
|
+
await page.waitForTimeout(2000);
|
|
15
|
+
// Evaluate extraction logic in the page context
|
|
16
|
+
const extractedElements = await page.evaluate(() => {
|
|
17
|
+
function extractContext(element) {
|
|
18
|
+
const parent = element.parentElement;
|
|
19
|
+
let parentHtml = '';
|
|
20
|
+
if (parent) {
|
|
21
|
+
const clone = parent.cloneNode(true);
|
|
22
|
+
clone.querySelectorAll('script, style').forEach(el => el.remove());
|
|
23
|
+
parentHtml = clone.outerHTML;
|
|
24
|
+
if (parentHtml.length > 15000) {
|
|
25
|
+
parentHtml = parentHtml.substring(0, 15000) + '\\n... [TRUNCATED]';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
elementHtml: element.outerHTML,
|
|
30
|
+
parentHtml
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const elements = Array.from(document.querySelectorAll('button, img, input, a, [role="button"], [role="link"], [role="img"]'));
|
|
34
|
+
return elements.map((el, index) => {
|
|
35
|
+
const context = extractContext(el);
|
|
36
|
+
return {
|
|
37
|
+
id: index.toString(),
|
|
38
|
+
tagName: el.tagName.toLowerCase(),
|
|
39
|
+
elementHtml: context.elementHtml,
|
|
40
|
+
parentHtml: context.parentHtml
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
console.log(`Found ${extractedElements.length} elements to analyze.`);
|
|
45
|
+
return extractedElements;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
console.error(`Error scanning page: ${error}`);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await browser.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.analyzeElement = analyzeElement;
|
|
7
|
+
exports.analyzeElements = analyzeElements;
|
|
8
|
+
const groq_sdk_1 = __importDefault(require("groq-sdk"));
|
|
9
|
+
const dotenv_1 = __importDefault(require("dotenv"));
|
|
10
|
+
dotenv_1.default.config();
|
|
11
|
+
const apiKey = process.env.GROQ_API_KEY || '';
|
|
12
|
+
const groq = new groq_sdk_1.default({ apiKey });
|
|
13
|
+
const delay = (ms) => new Promise(res => setTimeout(res, ms));
|
|
14
|
+
async function analyzeElement(element) {
|
|
15
|
+
const prompt = `
|
|
16
|
+
You are an expert accessibility engineer. Your task is to analyze an HTML/JSX element within its parent context to determine if it meets WCAG accessibility standards.
|
|
17
|
+
You must output ONLY valid JSON without any markdown code blocks or conversational text.
|
|
18
|
+
|
|
19
|
+
Context:
|
|
20
|
+
Parent Code:
|
|
21
|
+
\`\`\`
|
|
22
|
+
${element.parentHtml}
|
|
23
|
+
\`\`\`
|
|
24
|
+
|
|
25
|
+
Target Element Code:
|
|
26
|
+
\`\`\`
|
|
27
|
+
${element.elementHtml}
|
|
28
|
+
\`\`\`
|
|
29
|
+
|
|
30
|
+
Analyze the target element. Does it have sufficient context for screen readers? Does it use semantic HTML properly? Is it missing ARIA attributes where necessary?
|
|
31
|
+
|
|
32
|
+
Be extremely accurate in your code fix. Ensure the replacement code contains all necessary structural semantics, aria-labels, alt text, and valid roles based on the parent context.
|
|
33
|
+
CRITICAL INSTRUCTIONS FOR FIX:
|
|
34
|
+
1. Your suggested fix MUST COMPLETELY resolve the accessibility issue. If the fixed element were analyzed again, it MUST pass all WCAG checks.
|
|
35
|
+
2. Provide the COMPLETE Target Element in your fix, including its opening tag, all original children, and its closing tag. Do NOT provide partial snippets.
|
|
36
|
+
3. The replacement code MUST match the exact framework syntax of the input. If the input uses React JSX syntax (like \`className\`, camelCase attributes, or \`style={{}}\`), the output MUST be valid JSX. If standard HTML, output standard HTML.
|
|
37
|
+
4. Maintain all existing non-accessibility attributes (e.g., \`id\`, \`class\`, \`onClick\`, \`href\`, etc.) exactly as they appear in the original Target Element.
|
|
38
|
+
|
|
39
|
+
Return a JSON object with this exact structure:
|
|
40
|
+
{
|
|
41
|
+
"isAccessible": boolean,
|
|
42
|
+
"issueTitle": string (or null if isAccessible is true. A concise title of the WCAG violation),
|
|
43
|
+
"explanation": string (or null. Explain the issue concisely to a developer),
|
|
44
|
+
"suggestedFixCode": string (or null. Provide the highly accurate, complete replacement code for the Target Element that fixes the issue, matching the input's syntax),
|
|
45
|
+
"fixReasoning": string (or null. Briefly explain exactly what the suggested fix code does and how it solves the accessibility issue)
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
let retries = 3;
|
|
49
|
+
let delayMs = 2000;
|
|
50
|
+
while (retries > 0) {
|
|
51
|
+
try {
|
|
52
|
+
const completion = await groq.chat.completions.create({
|
|
53
|
+
messages: [
|
|
54
|
+
{
|
|
55
|
+
role: 'system',
|
|
56
|
+
content: 'You are an AI that only outputs valid JSON. Do not output anything else.'
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
role: 'user',
|
|
60
|
+
content: prompt
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
model: 'llama-3.3-70b-versatile',
|
|
64
|
+
response_format: { type: 'json_object' }
|
|
65
|
+
});
|
|
66
|
+
const text = completion.choices[0]?.message?.content;
|
|
67
|
+
if (text) {
|
|
68
|
+
const parsed = JSON.parse(text);
|
|
69
|
+
return {
|
|
70
|
+
...element,
|
|
71
|
+
...parsed
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
throw new Error("No text in response");
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error.status === 503 || error.status === 429 || error.message?.includes('503') || error.message?.includes('429')) {
|
|
78
|
+
console.warn(`[WARN] Rate limited. Retries left: ${retries - 1}. Retrying in ${delayMs}ms...`);
|
|
79
|
+
retries--;
|
|
80
|
+
if (retries === 0) {
|
|
81
|
+
return {
|
|
82
|
+
...element,
|
|
83
|
+
isAccessible: false,
|
|
84
|
+
error: "Failed to analyze due to API rate limits."
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
await delay(delayMs);
|
|
88
|
+
delayMs *= 2;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
return {
|
|
92
|
+
...element,
|
|
93
|
+
isAccessible: false,
|
|
94
|
+
error: error.message || "Unknown API error"
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
...element,
|
|
101
|
+
isAccessible: false,
|
|
102
|
+
error: "Exhausted retries"
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async function analyzeElements(elements) {
|
|
106
|
+
const results = [];
|
|
107
|
+
console.log(`Analyzing ${elements.length} elements using Groq API...`);
|
|
108
|
+
for (let i = 0; i < elements.length; i++) {
|
|
109
|
+
// Log progress
|
|
110
|
+
console.log(`Analyzing element ${i + 1}/${elements.length}: <${elements[i].tagName}>...`);
|
|
111
|
+
const result = await analyzeElement(elements[i]);
|
|
112
|
+
results.push(result);
|
|
113
|
+
// Wait briefly between requests to avoid rate limits
|
|
114
|
+
if (i < elements.length - 1) {
|
|
115
|
+
await delay(1000);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return results;
|
|
119
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const scanner_1 = require("./scanner");
|
|
10
|
+
const analyzer_1 = require("./analyzer");
|
|
11
|
+
const reporter_1 = require("./reporter");
|
|
12
|
+
const program = new commander_1.Command();
|
|
13
|
+
program
|
|
14
|
+
.name('a11y-audit')
|
|
15
|
+
.description('Context-Aware Accessibility Linter CLI')
|
|
16
|
+
.version('1.0.0')
|
|
17
|
+
.requiredOption('-u, --url <url>', 'URL to scan (e.g., http://localhost:3000)', 'http://localhost:3000')
|
|
18
|
+
.option('-o, --output <path>', 'Output file path', './caal-report.md')
|
|
19
|
+
.option('-f, --format <format>', 'Output format (json or md)', 'md')
|
|
20
|
+
.action(async (options) => {
|
|
21
|
+
try {
|
|
22
|
+
console.log(chalk_1.default.blue(`Starting accessibility audit for: ${options.url}`));
|
|
23
|
+
// Step 1: Scan page and extract elements
|
|
24
|
+
const scannedElements = await (0, scanner_1.scanPage)(options.url);
|
|
25
|
+
if (scannedElements.length === 0) {
|
|
26
|
+
console.log(chalk_1.default.yellow('No relevant elements found to analyze.'));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// Step 2: Analyze with LLM
|
|
30
|
+
if (!process.env.GROQ_API_KEY) {
|
|
31
|
+
console.error(chalk_1.default.red('Error: GROQ_API_KEY environment variable is not set.'));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const results = await (0, analyzer_1.analyzeElements)(scannedElements);
|
|
35
|
+
// Step 3: Report
|
|
36
|
+
const format = options.format.toLowerCase();
|
|
37
|
+
if (format === 'json' || options.output.endsWith('.json')) {
|
|
38
|
+
(0, reporter_1.generateJsonReport)(results, options.output);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
(0, reporter_1.generateMarkdownReport)(results, options.output);
|
|
42
|
+
}
|
|
43
|
+
// Step 4: Exit with error code if issues found (useful for CI/CD)
|
|
44
|
+
const failedElements = results.filter(r => !r.isAccessible);
|
|
45
|
+
if (failedElements.length > 0) {
|
|
46
|
+
console.log(chalk_1.default.red(`\nFound ${failedElements.length} accessibility issues!`));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
console.log(chalk_1.default.green('\nAll checks passed! 🎉'));
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
console.error(chalk_1.default.red('Audit failed:'), error);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.generateJsonReport = generateJsonReport;
|
|
7
|
+
exports.generateMarkdownReport = generateMarkdownReport;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
function generateJsonReport(results, outputPath) {
|
|
11
|
+
const outputDir = path_1.default.dirname(outputPath);
|
|
12
|
+
if (!fs_1.default.existsSync(outputDir)) {
|
|
13
|
+
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
fs_1.default.writeFileSync(outputPath, JSON.stringify(results, null, 2));
|
|
16
|
+
console.log(`JSON report generated at ${outputPath}`);
|
|
17
|
+
}
|
|
18
|
+
function generateMarkdownReport(results, outputPath) {
|
|
19
|
+
const outputDir = path_1.default.dirname(outputPath);
|
|
20
|
+
if (!fs_1.default.existsSync(outputDir)) {
|
|
21
|
+
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
const failedElements = results.filter(r => !r.isAccessible);
|
|
24
|
+
let md = `# Accessibility Audit Report\n\n`;
|
|
25
|
+
md += `**Total Elements Scanned:** ${results.length}\n`;
|
|
26
|
+
md += `**Accessibility Issues Found:** ${failedElements.length}\n\n`;
|
|
27
|
+
if (failedElements.length === 0) {
|
|
28
|
+
md += `🎉 Congratulations! No accessibility issues were found.\n`;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
md += `## Issues Detected\n\n`;
|
|
32
|
+
failedElements.forEach((el, i) => {
|
|
33
|
+
md += `### ${i + 1}. <${el.tagName}>: ${el.issueTitle || 'Issue'}\n\n`;
|
|
34
|
+
if (el.error) {
|
|
35
|
+
md += `**Error during analysis:** ${el.error}\n\n`;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
md += `**Explanation:** ${el.explanation}\n\n`;
|
|
39
|
+
md += `**Original Code:**\n\`\`\`html\n${el.elementHtml}\n\`\`\`\n\n`;
|
|
40
|
+
md += `**Suggested Fix:**\n\`\`\`html\n${el.suggestedFixCode}\n\`\`\`\n\n`;
|
|
41
|
+
md += `**Why this works:** ${el.fixReasoning}\n\n`;
|
|
42
|
+
}
|
|
43
|
+
md += `---\n\n`;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
fs_1.default.writeFileSync(outputPath, md);
|
|
47
|
+
console.log(`Markdown report generated at ${outputPath}`);
|
|
48
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scanPage = scanPage;
|
|
4
|
+
const playwright_1 = require("playwright");
|
|
5
|
+
async function scanPage(url) {
|
|
6
|
+
console.log(`\nNavigating to ${url}...`);
|
|
7
|
+
// Launch headless chromium
|
|
8
|
+
const browser = await playwright_1.chromium.launch({ headless: true });
|
|
9
|
+
const page = await browser.newPage();
|
|
10
|
+
try {
|
|
11
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
12
|
+
console.log('Page loaded. Extracting elements...');
|
|
13
|
+
// Wait a tiny bit extra for framework rendering if needed
|
|
14
|
+
await page.waitForTimeout(2000);
|
|
15
|
+
// Evaluate extraction logic in the page context
|
|
16
|
+
const extractedElements = await page.evaluate(() => {
|
|
17
|
+
function extractContext(element) {
|
|
18
|
+
const parent = element.parentElement;
|
|
19
|
+
let parentHtml = '';
|
|
20
|
+
if (parent) {
|
|
21
|
+
const clone = parent.cloneNode(true);
|
|
22
|
+
clone.querySelectorAll('script, style').forEach(el => el.remove());
|
|
23
|
+
parentHtml = clone.outerHTML;
|
|
24
|
+
if (parentHtml.length > 15000) {
|
|
25
|
+
parentHtml = parentHtml.substring(0, 15000) + '\\n... [TRUNCATED]';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
elementHtml: element.outerHTML,
|
|
30
|
+
parentHtml
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const elements = Array.from(document.querySelectorAll('button, img, input, a, [role="button"], [role="link"], [role="img"]'));
|
|
34
|
+
return elements.map((el, index) => {
|
|
35
|
+
const context = extractContext(el);
|
|
36
|
+
return {
|
|
37
|
+
id: index.toString(),
|
|
38
|
+
tagName: el.tagName.toLowerCase(),
|
|
39
|
+
elementHtml: context.elementHtml,
|
|
40
|
+
parentHtml: context.parentHtml
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
console.log(`Found ${extractedElements.length} elements to analyze.`);
|
|
45
|
+
return extractedElements;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
console.error(`Error scanning page: ${error}`);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await browser.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dtp-caal",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"dtp-caal": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"start": "ts-node src/index.ts",
|
|
12
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["accessibility", "linter", "cli", "ai"],
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"package.json",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"author": "",
|
|
21
|
+
"license": "ISC",
|
|
22
|
+
"type": "commonjs",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"chalk": "^4.1.2",
|
|
25
|
+
"commander": "^15.0.0",
|
|
26
|
+
"dotenv": "^17.4.2",
|
|
27
|
+
"glob": "^13.0.6",
|
|
28
|
+
"groq-sdk": "^1.3.0",
|
|
29
|
+
"playwright": "^1.61.1"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/glob": "^8.1.0",
|
|
33
|
+
"@types/node": "^26.0.1",
|
|
34
|
+
"ts-node": "^10.9.2",
|
|
35
|
+
"typescript": "^6.0.3"
|
|
36
|
+
}
|
|
37
|
+
}
|