astro-a11y 0.0.0-stage → 0.2.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,13 @@
1
- # Temporary Holding Version
1
+ # astro-a11y
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ CLI that scans a static build folder or a live URL with Playwright + axe-core, maps findings to RGAA 4.1.2 criteria and renders terminal, JSON, HTML or Markdown reports.
4
+
5
+ ```bash
6
+ npx playwright install chromium
7
+ npx astro-a11y check ./dist --mode balanced
8
+ npx astro-a11y check https://example.com --mode paranoid --allowed-domains example.com
9
+ ```
10
+
11
+ Modes, options and security guards: see the [main documentation](https://github.com/Stan69000/astro-a11y#readme) ([français](https://github.com/Stan69000/astro-a11y/blob/main/README.fr.md)).
12
+
13
+ Backed by [Le Singe du Numérique](https://lesingedunumerique.fr). MIT licensed.
package/dist/bin.js ADDED
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import path from 'node:path';
6
+ import {
7
+ SUPPORTED_LANGS,
8
+ isSupportedLang,
9
+ applyBaseline,
10
+ createBaseline,
11
+ installChromium,
12
+ parseBaseline,
13
+ scanTarget,
14
+ SEVERITY_ORDER,
15
+ SCAN_MODES,
16
+ isSeverity,
17
+ isScanMode,
18
+ resolveFailThreshold,
19
+ hasIssuesAtOrAbove
20
+ } from '@astro-a11y/core';
21
+ import { renderByFormat } from '@astro-a11y/reporters';
22
+ import { writeOutput } from './output-path.js';
23
+
24
+ // Read from the published manifest so `--version` always matches the release.
25
+ const { version } = createRequire(import.meta.url)('../package.json');
26
+
27
+ function exitWithError(message) {
28
+ process.stderr.write(`Error: ${message}\n`);
29
+ process.exit(1);
30
+ }
31
+
32
+ const program = new Command();
33
+
34
+ program
35
+ .name('astro-a11y')
36
+ .description('Accessibility guardrails for Astro projects.')
37
+ .version(version);
38
+
39
+ program
40
+ .command('check')
41
+ .description('Scan a local directory or remote URL for accessibility issues')
42
+ .argument('<target>', 'Local directory path or remote URL')
43
+ .option('--mode <mode>', 'Scan mode: strict|balanced|learning|paranoid', 'balanced')
44
+ .option('-f, --format <format>', 'Output format: terminal|json|html|markdown|github', 'terminal')
45
+ .option('--output-format <format>', 'Alias for --format')
46
+ .option('-o, --output <path>', 'Output file path')
47
+ .option('--allow-unsafe-targets', 'Allow scanning localhost/private remote targets')
48
+ .option(
49
+ '--allowed-domains <domains>',
50
+ 'Comma-separated domain allowlist for remote targets',
51
+ (val) =>
52
+ val
53
+ .split(',')
54
+ .map((s) => s.trim())
55
+ .filter(Boolean)
56
+ )
57
+ .option('--max-redirects <count>', 'Maximum number of followed redirects for remote scans', '3')
58
+ .option('--block-sensitive-ports', 'Block remote targets using sensitive ports')
59
+ .option(
60
+ '--fail-on <severity>',
61
+ 'Exit with code 1 when issues at or above this severity are found: critical|major|minor|info'
62
+ )
63
+ .option('--timeout <ms>', 'Page load timeout in milliseconds', '15000')
64
+ .option('--safe-report', 'Strip raw HTML snippets from rendered reports')
65
+ .option('--max-field-length <chars>', 'Maximum report field length before truncation', '5000')
66
+ .option('--report-url <url>', 'Link to the full report, shown by the github format')
67
+ .option('--progress', 'Show progress indicator for multi-page scans')
68
+ .option(
69
+ '--include-tags <tags>',
70
+ 'Comma-separated axe tag names to include (e.g. wcag2a,wcag2aa)',
71
+ (val) =>
72
+ val
73
+ .split(',')
74
+ .map((s) => s.trim())
75
+ .filter(Boolean)
76
+ )
77
+ .option('--exclude-rules <rules>', 'Comma-separated axe rule IDs to exclude', (val) =>
78
+ val
79
+ .split(',')
80
+ .map((s) => s.trim())
81
+ .filter(Boolean)
82
+ )
83
+ .option(
84
+ '--baseline <file>',
85
+ 'Known issues file: only issues missing from it count as new and can fail the run'
86
+ )
87
+ .option('--write-baseline <file>', 'Write the issues found by this scan to a baseline file')
88
+ .option(
89
+ '--sitemap [url]',
90
+ 'For a URL target: scan the pages listed in the site sitemaps (robots.txt, /sitemap-index.xml, /sitemap.xml) or in the given sitemap'
91
+ )
92
+ .option('--max-pages <count>', 'Maximum number of pages scanned with --sitemap', '50')
93
+ .option('--lang <lang>', 'Language of issue texts and reports: en|fr', 'en')
94
+ .option('--no-keyboard', 'Skip the keyboard checks (focus visibility, keyboard traps, skip link)')
95
+ .action(async (target, options) => {
96
+ const mode = options.mode ?? 'balanced';
97
+ if (!isScanMode(mode)) {
98
+ exitWithError(`--mode must be one of ${SCAN_MODES.join('|')}, got: ${mode}`);
99
+ }
100
+ if (options.failOn !== undefined && !isSeverity(options.failOn)) {
101
+ exitWithError(`--fail-on must be one of ${SEVERITY_ORDER.join('|')}, got: ${options.failOn}`);
102
+ }
103
+ const paranoidMode = mode === 'paranoid';
104
+ const failThreshold = resolveFailThreshold({ mode, failOn: options.failOn });
105
+ const timeout = paranoidMode ? 8000 : parseInt(options.timeout, 10);
106
+ const maxRedirects = paranoidMode ? 0 : parseInt(options.maxRedirects, 10);
107
+ const maxFieldLength = parseInt(options.maxFieldLength, 10);
108
+ if (isNaN(timeout) || timeout <= 0) {
109
+ process.stderr.write(`Error: --timeout must be a positive number, got: ${options.timeout}\n`);
110
+ process.exit(1);
111
+ }
112
+ if (isNaN(maxRedirects) || maxRedirects < 0) {
113
+ process.stderr.write(
114
+ `Error: --max-redirects must be a non-negative number, got: ${options.maxRedirects}\n`
115
+ );
116
+ process.exit(1);
117
+ }
118
+ if (isNaN(maxFieldLength) || maxFieldLength <= 0) {
119
+ process.stderr.write(
120
+ `Error: --max-field-length must be a positive number, got: ${options.maxFieldLength}\n`
121
+ );
122
+ process.exit(1);
123
+ }
124
+ if (paranoidMode && options.allowUnsafeTargets) {
125
+ process.stderr.write('Error: --allow-unsafe-targets cannot be used with --mode paranoid\n');
126
+ process.exit(1);
127
+ }
128
+ const onProgress = options.progress
129
+ ? ({ current, total, route }) => {
130
+ process.stdout.write(`\rScanning ${current}/${total}: ${route}`);
131
+ if (current === total) process.stdout.write('\n');
132
+ }
133
+ : undefined;
134
+ if (!isSupportedLang(options.lang)) {
135
+ exitWithError(`--lang must be one of ${SUPPORTED_LANGS.join('|')}, got: ${options.lang}`);
136
+ }
137
+ const maxPages = parseInt(options.maxPages, 10);
138
+ if (isNaN(maxPages) || maxPages <= 0) {
139
+ exitWithError(`--max-pages must be a positive number, got: ${options.maxPages}`);
140
+ }
141
+ if (options.sitemap && !/^https?:\/\//i.test(target)) {
142
+ exitWithError(
143
+ '--sitemap only applies to URL targets; a build directory is crawled from its HTML files.'
144
+ );
145
+ }
146
+ let baseline;
147
+ if (options.baseline) {
148
+ try {
149
+ baseline = parseBaseline(await readFile(path.resolve(options.baseline), 'utf8'));
150
+ } catch (err) {
151
+ exitWithError(`could not read baseline ${options.baseline}: ${err.message}`);
152
+ }
153
+ }
154
+ const scanResult = await scanTarget(target, {
155
+ mode,
156
+ allowUnsafeTargets: paranoidMode ? false : options.allowUnsafeTargets,
157
+ allowedDomains: options.allowedDomains,
158
+ maxRedirects,
159
+ blockSensitivePorts: paranoidMode ? true : options.blockSensitivePorts,
160
+ enforceSandbox: paranoidMode,
161
+ timeout,
162
+ onProgress,
163
+ includeTags: options.includeTags,
164
+ excludeRules: options.excludeRules,
165
+ keyboard: options.keyboard,
166
+ lang: options.lang,
167
+ sitemap: Boolean(options.sitemap),
168
+ sitemapUrl: typeof options.sitemap === 'string' ? options.sitemap : undefined,
169
+ maxPages
170
+ });
171
+ const result = baseline ? applyBaseline(scanResult, baseline) : scanResult;
172
+ if (options.writeBaseline) {
173
+ await writeOutput(
174
+ options.writeBaseline,
175
+ JSON.stringify(createBaseline(scanResult), null, 2) + '\n'
176
+ );
177
+ }
178
+ const format = options.outputFormat ?? options.format;
179
+ const rendered = renderByFormat(result, format, {
180
+ safeReport: paranoidMode ? true : options.safeReport,
181
+ maxFieldLength,
182
+ reportUrl: options.reportUrl
183
+ });
184
+
185
+ if (options.output) {
186
+ await writeOutput(options.output, rendered);
187
+ } else {
188
+ process.stdout.write(rendered + '\n');
189
+ }
190
+
191
+ for (const pageError of result.pageErrors ?? []) {
192
+ process.stderr.write(`Warning: could not scan ${pageError.page}: ${pageError.message}\n`);
193
+ }
194
+ // A page that could not be scanned must not let a gated run pass silently.
195
+ if (failThreshold !== null && result.pageErrors?.length > 0) {
196
+ process.exit(1);
197
+ }
198
+ if (hasIssuesAtOrAbove(result.summary, failThreshold)) {
199
+ process.exit(1);
200
+ }
201
+ });
202
+
203
+ program
204
+ .command('report')
205
+ .description('Generate a report from a previous scan JSON output')
206
+ .option('-i, --input <path>', 'Input JSON file path (required)')
207
+ .option('-f, --format <format>', 'Output format: terminal|json|html|markdown|github', 'html')
208
+ .option('--output-format <format>', 'Alias for --format')
209
+ .option('-o, --output <path>', 'Output file path')
210
+ .option('--safe-report', 'Strip raw HTML snippets from rendered reports')
211
+ .option('--max-field-length <chars>', 'Maximum report field length before truncation', '5000')
212
+ .option('--report-url <url>', 'Link to the full report, shown by the github format')
213
+ .action(async (options) => {
214
+ if (!options.input) {
215
+ process.stderr.write('Error: --input is required\n');
216
+ process.exit(1);
217
+ }
218
+ let result;
219
+ try {
220
+ result = JSON.parse(await readFile(path.resolve(options.input), 'utf8'));
221
+ } catch (err) {
222
+ process.stderr.write(`Error: could not parse input file: ${err.message}\n`);
223
+ process.exit(1);
224
+ }
225
+ const format = options.outputFormat ?? options.format;
226
+ const maxFieldLength = parseInt(options.maxFieldLength, 10);
227
+ if (isNaN(maxFieldLength) || maxFieldLength <= 0) {
228
+ process.stderr.write(
229
+ `Error: --max-field-length must be a positive number, got: ${options.maxFieldLength}\n`
230
+ );
231
+ process.exit(1);
232
+ }
233
+ const rendered = renderByFormat(result, format, {
234
+ safeReport: options.safeReport,
235
+ maxFieldLength,
236
+ reportUrl: options.reportUrl
237
+ });
238
+
239
+ if (options.output) {
240
+ await writeOutput(options.output, rendered);
241
+ } else {
242
+ process.stdout.write(rendered + '\n');
243
+ }
244
+ });
245
+
246
+ program
247
+ .command('install-browser')
248
+ .description(
249
+ 'Download the Chromium build used for scans (matches the bundled Playwright version)'
250
+ )
251
+ .option('--with-deps', 'Also install system dependencies (Linux, requires root)')
252
+ .action(async (options) => {
253
+ const code = await installChromium({ withDeps: options.withDeps === true });
254
+ process.exit(code);
255
+ });
256
+
257
+ program.parse();
@@ -0,0 +1,17 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export function resolveOutputPath(filePath, cwd = process.cwd()) {
5
+ const resolvedCwd = path.resolve(cwd);
6
+ const fullPath = path.resolve(resolvedCwd, filePath);
7
+ if (!fullPath.startsWith(resolvedCwd + path.sep) && fullPath !== resolvedCwd) {
8
+ throw new Error("Output path must be within current working directory.");
9
+ }
10
+ return fullPath;
11
+ }
12
+
13
+ export async function writeOutput(filePath, contents, cwd = process.cwd()) {
14
+ const fullPath = resolveOutputPath(filePath, cwd);
15
+ await mkdir(path.dirname(fullPath), { recursive: true });
16
+ await writeFile(fullPath, contents, "utf8");
17
+ }
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "astro-a11y",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Accessibility guardrails for Astro sites and static builds: axe-core scans with RGAA 4.1.2 mapping.",
5
+ "keywords": [
6
+ "accessibility",
7
+ "a11y",
8
+ "rgaa",
9
+ "wcag",
10
+ "astro",
11
+ "cli"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Stan Bouchet",
15
+ "homepage": "https://stan69000.github.io/astro-a11y/",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Stan69000/astro-a11y.git",
19
+ "directory": "packages/cli"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/Stan69000/astro-a11y/issues"
23
+ },
24
+ "type": "module",
25
+ "main": "./dist/bin.js",
26
+ "bin": {
27
+ "astro-a11y": "./dist/bin.js"
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "engines": {
33
+ "node": ">=20"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "commander": "^14.0.3",
40
+ "@astro-a11y/core": "0.2.0",
41
+ "@astro-a11y/reporters": "0.2.0"
42
+ },
43
+ "scripts": {
44
+ "build": "node ../../scripts/copy-package.js src dist"
45
+ }
6
46
  }