pursr 0.6.0 → 0.7.1

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.
@@ -1,260 +1,278 @@
1
- // pursor — axe-core accessibility audit.
2
- //
3
- // Injects axe-core into the page, runs a WCAG audit, returns violations.
4
- // Optionally highlights violated elements with a red overlay and
5
- // generates a full audit report.
6
- //
7
- // Used internally via runAudit() and also exposed as a sweep-op
8
- // so it works in batch plans.
9
- //
10
- // Dependencies: axe-core (npm i axe-core)
11
-
12
- import { launch, newPage } from "./runway.js";
13
- import { resolveViewport } from "./viewport.js";
14
- import { gotoOrThrow, settle } from "./overlays.js";
15
- import { writeFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
16
- import { join, dirname } from "node:path";
17
- import { asNum, nowIso } from "./util.js";
18
-
19
- // ─── Injected audit runner ──────────────────────────────────────────────
20
-
21
- const AUDIT_RUNNER = (axeSource) => `
22
- ${axeSource}
23
- (function() {
24
- return new Promise((resolve, reject) => {
25
- const config = typeof window.__PURR_AUDIT_CONFIG__ !== 'undefined' ? window.__PURR_AUDIT_CONFIG__ : {};
26
- var tags = config.tags || ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'];
27
- axe.run(
28
- { runOnly: { type: 'tag', values: tags } },
29
- function(err, results) {
30
- if (err) { reject(err.message); return; }
31
- // Also collect element references for highlighting
32
- resolve(JSON.parse(JSON.stringify(results)));
33
- }
34
- );
35
- });
36
- })()
37
- `;
38
-
39
- // ─── Highlight overlay script ──────────────────────────────────────────
40
-
41
- const HIGHLIGHT_VIOLATIONS = (violationsJson) => `
42
- (function() {
43
- var violations = ${violationsJson};
44
- if (!violations || !violations.length) return;
45
- var style = document.createElement('style');
46
- style.id = '__purr_audit_highlight__';
47
- style.textContent = \`
48
- [data-purr-audit-violation] {
49
- outline: 3px solid rgba(255, 0, 0, 0.85) !important;
50
- background: rgba(255, 0, 0, 0.12) !important;
51
- position: relative !important;
52
- }
53
- [data-purr-audit-violation]::after {
54
- content: attr(data-purr-audit-violation);
55
- position: absolute;
56
- top: -20px;
57
- left: 0;
58
- background: #d00;
59
- color: #fff;
60
- font: 10px/14px monospace;
61
- padding: 1px 5px;
62
- border-radius: 3px;
63
- z-index: 999999;
64
- white-space: nowrap;
65
- }
66
- \`;
67
- document.documentElement.appendChild(style);
68
- for (var v of violations) {
69
- for (var n of v.nodes || []) {
70
- for (var t of n.target || []) {
71
- try {
72
- var sel = Array.isArray(t) ? t.join(' ') : t;
73
- var els = document.querySelectorAll(sel);
74
- for (var e of els) {
75
- e.setAttribute('data-purr-audit-violation', v.id + ': ' + v.impact);
76
- }
77
- } catch(e) {}
78
- }
79
- }
80
- }
81
- })()
82
- `;
83
-
84
- // ─── Load axe-core ──────────────────────────────────────────────────────
85
-
86
- let _axeSource = null;
87
- async function getAxeSource() {
88
- if (_axeSource) return _axeSource;
89
- // Try node_modules/axe-core
90
- const paths = [
91
- join(process.cwd(), "node_modules", "axe-core", "axe.min.js"),
92
- join(process.cwd(), "node_modules", "axe-core", "axe.js"),
93
- new URL("..", import.meta.url).pathname && join(dirname(new URL(import.meta.url).pathname), "node_modules", "axe-core", "axe.min.js"),
94
- join(dirname(process.execPath), "node_modules", "axe-core", "axe.min.js"),
95
- ];
96
- for (const p of paths) {
97
- if (p && existsSync(p)) {
98
- _axeSource = readFileSync(p, "utf8");
99
- return _axeSource;
100
- }
101
- }
102
- throw new Error("axe-core not found. Install: npm i axe-core");
103
- }
104
-
105
- // ─── Group helper ───────────────────────────────────────────────────────
106
-
107
- /** Count violations by WCAG impact level */
108
- function summarizeViolations(violations) {
109
- const byImpact = { critical: 0, serious: 0, moderate: 0, minor: 0 };
110
- const byTag = {};
111
- for (const v of violations) {
112
- byImpact[v.impact] = (byImpact[v.impact] || 0) + v.nodes.length;
113
- for (const t of v.tags || []) {
114
- if (!byTag[t]) byTag[t] = 0;
115
- byTag[t] += v.nodes.length;
116
- }
117
- }
118
- return { total: violations.length, totalNodes: violations.reduce((s, v) => s + v.nodes.length, 0), byImpact, byTag };
119
- }
120
-
121
- // ─── Public API ─────────────────────────────────────────────────────────
122
-
123
- /**
124
- * Run axe-core audit on a URL.
125
- *
126
- * @param {object} opts
127
- * @param {string} opts.url - Target URL
128
- * @param {string[]} [opts.tags] - WCAG tags (default: wcag2a, wcag2aa, wcag21a, wcag21aa, best-practice)
129
- * @param {string} [opts.outDir] - Output directory
130
- * @param {boolean} [opts.screenshot=true] - Capture highlighted screenshot
131
- * @param {object} [opts.flags] - Extra capture flags
132
- * @returns {Promise<object>} Audit result
133
- */
134
- export async function runAudit({ url, tags, outDir, screenshot = true, flags = {} }) {
135
- const dir = outDir || join(process.cwd(), `audit-${Date.now()}`);
136
- mkdirSync(dir, { recursive: true });
137
-
138
- const viewport = resolveViewport(flags);
139
- const browser = await launch();
140
- const axeSource = await getAxeSource();
141
-
142
- try {
143
- const page = await newPage(browser, viewport);
144
- const r = await gotoOrThrow(page, url);
145
- await settle(page);
146
- // Give dynamic content time to fully render
147
- await page.waitForTimeout(800);
148
-
149
- // Inject axe config
150
- await page.evaluate((t) => {
151
- window.__PURR_AUDIT_CONFIG__ = { tags: t || ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'] };
152
- }, tags || undefined);
153
-
154
- // Inject and run axe
155
- const rawResults = await page.evaluate(AUDIT_RUNNER, axeSource);
156
-
157
- const result = {
158
- url,
159
- title: r.title,
160
- ts: nowIso(),
161
- viewport: { width: viewport.width, height: viewport.height, dpr: viewport.dpr },
162
- violationSummary: summarizeViolations(rawResults.violations || []),
163
- passes: (rawResults.passes || []).length,
164
- incomplete: (rawResults.incomplete || []).length,
165
- inapplicable: (rawResults.inapplicable || []).length,
166
- violations: rawResults.violations || [],
167
- };
168
-
169
- // Write audit report
170
- const auditPath = join(dir, "audit.json");
171
- writeFileSync(auditPath, JSON.stringify(result, null, 2));
172
-
173
- // Write summary Markdown
174
- const mdPath = join(dir, "audit-summary.md");
175
- writeFileSync(mdPath, renderAuditMarkdown(result));
176
-
177
- // Highlighted screenshot
178
- if (screenshot && result.violations.length > 0) {
179
- const violationsJson = JSON.stringify(result.violations.map(v => ({
180
- id: v.id,
181
- impact: v.impact,
182
- nodes: (v.nodes || []).map(n => ({ target: n.target })),
183
- })));
184
- await page.evaluate(HIGHLIGHT_VIOLATIONS, violationsJson);
185
- await page.waitForTimeout(200);
186
- const shotPath = join(dir, "audit-highlighted.png");
187
- await page.screenshot({ path: shotPath, fullPage: true });
188
- result.highlightedScreenshot = shotPath;
189
- } else if (screenshot) {
190
- const shotPath = join(dir, "audit-clean.png");
191
- await page.screenshot({ path: shotPath, fullPage: true });
192
- result.cleanScreenshot = shotPath;
193
- }
194
-
195
- return result;
196
- } finally {
197
- try { await browser.close(); } catch {}
198
- }
199
- }
200
-
201
- // ─── Markdown report ────────────────────────────────────────────────────
202
-
203
- function renderAuditMarkdown(result) {
204
- const s = result.violationSummary || {};
205
- const lines = [
206
- `# Accessibility Audit: ${result.url}`,
207
- ``,
208
- `**Date:** ${result.ts}`,
209
- `**Viewport:** ${result.viewport.width}x${result.viewport.height} @${result.viewport.dpr}x`,
210
- ``,
211
- `## Summary`,
212
- ``,
213
- `| Severity | Count |`,
214
- `|----------|-------|`,
215
- `| 🔴 Critical | ${s.byImpact?.critical || 0} nodes`,
216
- `| 🟠 Serious | ${s.byImpact?.serious || 0} nodes`,
217
- `| 🟡 Moderate | ${s.byImpact?.moderate || 0} nodes`,
218
- `| ⚪ Minor | ${s.byImpact?.minor || 0} nodes`,
219
- `| **Total violations** | **${s.total} rules, ${s.totalNodes} nodes** |`,
220
- ``,
221
- `| Check | Count |`,
222
- `|-------|-------|`,
223
- `| Passes | ${result.passes || 0} |`,
224
- `| Incomplete | ${result.incomplete || 0} |`,
225
- `| Inapplicable | ${result.inapplicable || 0} |`,
226
- ``,
227
- ];
228
-
229
- if (result.violations.length) {
230
- lines.push(`## Violations`);
231
- lines.push(``);
232
- for (const v of result.violations) {
233
- lines.push(`### ${v.id} ${v.impact}`);
234
- lines.push(``);
235
- lines.push(`**Help:** ${v.helpUrl || v.help || 'N/A'}`);
236
- lines.push(``);
237
- lines.push(`**Tags:** ${(v.tags || []).join(', ')}`);
238
- lines.push(``);
239
- lines.push(`**Affected nodes:** ${(v.nodes || []).length}`);
240
- lines.push(``);
241
- for (const n of (v.nodes || []).slice(0, 10)) { // top 10 per violation
242
- const target = (n.target || []).join(', ');
243
- const snippet = (n.html || '').slice(0, 200);
244
- const failureSummary = n.failureSummary || '';
245
- lines.push(`- \`${target}\``);
246
- if (snippet) lines.push(` - \`${snippet.replace(/`/g, '')}\``);
247
- if (failureSummary) lines.push(` - ${failureSummary.split('\\n')[0]}`);
248
- }
249
- if ((v.nodes || []).length > 10) lines.push(` - … and ${v.nodes.length - 10} more nodes`);
250
- lines.push(``);
251
- }
252
- }
253
-
254
- if (result.highlightedScreenshot) lines.push(`![Highlighted screenshot](${result.highlightedScreenshot})`);
255
- if (result.cleanScreenshot) lines.push(`![Clean screenshot](${result.cleanScreenshot})`);
256
-
257
- lines.push(``);
258
- lines.push(`_Generated by pursor audit_`);
259
- return lines.join('\n');
260
- }
1
+ // pursr — axe-core accessibility audit.
2
+ //
3
+ // Injects axe-core into the page, runs a WCAG audit, returns violations.
4
+ // Optionally highlights violated elements with a red overlay and
5
+ // generates a full audit report.
6
+ //
7
+ // Used internally via runAudit() and also exposed as a sweep-op
8
+ // so it works in batch plans.
9
+ //
10
+ // Dependencies: axe-core (npm i axe-core)
11
+
12
+ import { launch, newPage } from "./runway.js";
13
+ import { createRequire } from "node:module";
14
+ import { resolveViewport } from "./viewport.js";
15
+ import { gotoOrThrow, settle } from "./overlays.js";
16
+ import { writeFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
17
+ import { join, dirname } from "node:path";
18
+ import { asNum, nowIso } from "./util.js";
19
+
20
+ // ─── Injected audit runner ──────────────────────────────────────────────
21
+
22
+ const AUDIT_RUNNER = (axeSource) => `
23
+ ${axeSource}
24
+ (function() {
25
+ return new Promise((resolve, reject) => {
26
+ const config = typeof window.__PURR_AUDIT_CONFIG__ !== 'undefined' ? window.__PURR_AUDIT_CONFIG__ : {};
27
+ var tags = config.tags || ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'];
28
+ axe.run(
29
+ { runOnly: { type: 'tag', values: tags } },
30
+ function(err, results) {
31
+ if (err) { reject(err.message); return; }
32
+ // Also collect element references for highlighting
33
+ resolve(JSON.parse(JSON.stringify(results)));
34
+ }
35
+ );
36
+ });
37
+ })()
38
+ `;
39
+
40
+ // ─── Highlight overlay script ──────────────────────────────────────────
41
+
42
+ const HIGHLIGHT_VIOLATIONS = (violationsJson) => `
43
+ (function() {
44
+ var violations = ${violationsJson};
45
+ if (!violations || !violations.length) return;
46
+ var style = document.createElement('style');
47
+ style.id = '__purr_audit_highlight__';
48
+ style.textContent = \`
49
+ [data-purr-audit-violation] {
50
+ outline: 3px solid rgba(255, 0, 0, 0.85) !important;
51
+ background: rgba(255, 0, 0, 0.12) !important;
52
+ position: relative !important;
53
+ }
54
+ [data-purr-audit-violation]::after {
55
+ content: attr(data-purr-audit-violation);
56
+ position: absolute;
57
+ top: -20px;
58
+ left: 0;
59
+ background: #d00;
60
+ color: #fff;
61
+ font: 10px/14px monospace;
62
+ padding: 1px 5px;
63
+ border-radius: 3px;
64
+ z-index: 999999;
65
+ white-space: nowrap;
66
+ }
67
+ \`;
68
+ document.documentElement.appendChild(style);
69
+ for (var v of violations) {
70
+ for (var n of v.nodes || []) {
71
+ for (var t of n.target || []) {
72
+ try {
73
+ var sel = Array.isArray(t) ? t.join(' ') : t;
74
+ var els = document.querySelectorAll(sel);
75
+ for (var e of els) {
76
+ e.setAttribute('data-purr-audit-violation', v.id + ': ' + v.impact);
77
+ }
78
+ } catch(e) {}
79
+ }
80
+ }
81
+ }
82
+ })()
83
+ `;
84
+
85
+ // ─── Load axe-core ──────────────────────────────────────────────────────
86
+
87
+ let _axeSource = null;
88
+ async function getAxeSource() {
89
+ if (_axeSource) return _axeSource;
90
+ // Try node_modules/axe-core
91
+ // Primary: use createRequire to resolve axe-core from this module (works for the
92
+ // pursr package itself, the linked repo, or any project that has axe-core installed).
93
+ try {
94
+ const req = createRequire(import.meta.url);
95
+ const resolved = req.resolve("axe-core");
96
+ const dir = resolved.replace(/[\\\/][^\\\/]+$/, "");
97
+ for (const fname of ["axe.min.js", "axe.js"]) {
98
+ const p = dir + "/" + fname;
99
+ if (existsSync(p)) { _axeSource = readFileSync(p, "utf8"); return _axeSource; }
100
+ }
101
+ } catch { /* fall through to path-based lookup */ }
102
+ const paths = [
103
+ join(process.cwd(), "node_modules", "axe-core", "axe.min.js"),
104
+ join(process.cwd(), "node_modules", "axe-core", "axe.js"),
105
+ new URL("..", import.meta.url).pathname && join(dirname(new URL(import.meta.url).pathname), "node_modules", "axe-core", "axe.min.js"),
106
+ join(dirname(process.execPath), "node_modules", "axe-core", "axe.min.js"),
107
+ // Global npm install (Windows: %APPDATA%\npm\node_modules, Unix: /usr/lib/node_modules)
108
+ join(process.env.APPDATA || "", "npm", "node_modules", "axe-core", "axe.min.js"),
109
+ join(process.env.HOME || "", ".npm-global", "lib", "node_modules", "axe-core", "axe.min.js"),
110
+ join("/usr", "lib", "node_modules", "axe-core", "axe.min.js"),
111
+ join("/usr", "local", "lib", "node_modules", "axe-core", "axe.min.js"),
112
+ // The pursr package's own node_modules (when running from local repo or via node bin)
113
+ join(dirname(new URL(import.meta.url).pathname), "..", "node_modules", "axe-core", "axe.min.js"),
114
+ ];
115
+ for (const p of paths) {
116
+ if (p && existsSync(p)) {
117
+ _axeSource = readFileSync(p, "utf8");
118
+ return _axeSource;
119
+ }
120
+ }
121
+ throw new Error("axe-core not found. Install: npm i axe-core");}
122
+
123
+ // ─── Group helper ───────────────────────────────────────────────────────
124
+
125
+ /** Count violations by WCAG impact level */
126
+ function summarizeViolations(violations) {
127
+ const byImpact = { critical: 0, serious: 0, moderate: 0, minor: 0 };
128
+ const byTag = {};
129
+ for (const v of violations) {
130
+ byImpact[v.impact] = (byImpact[v.impact] || 0) + v.nodes.length;
131
+ for (const t of v.tags || []) {
132
+ if (!byTag[t]) byTag[t] = 0;
133
+ byTag[t] += v.nodes.length;
134
+ }
135
+ }
136
+ return { total: violations.length, totalNodes: violations.reduce((s, v) => s + v.nodes.length, 0), byImpact, byTag };
137
+ }
138
+
139
+ // ─── Public API ─────────────────────────────────────────────────────────
140
+
141
+ /**
142
+ * Run axe-core audit on a URL.
143
+ *
144
+ * @param {object} opts
145
+ * @param {string} opts.url - Target URL
146
+ * @param {string[]} [opts.tags] - WCAG tags (default: wcag2a, wcag2aa, wcag21a, wcag21aa, best-practice)
147
+ * @param {string} [opts.outDir] - Output directory
148
+ * @param {boolean} [opts.screenshot=true] - Capture highlighted screenshot
149
+ * @param {object} [opts.flags] - Extra capture flags
150
+ * @returns {Promise<object>} Audit result
151
+ */
152
+ export async function runAudit({ url, tags, outDir, screenshot = true, flags = {} }) {
153
+ const dir = outDir || join(process.cwd(), `audit-${Date.now()}`);
154
+ mkdirSync(dir, { recursive: true });
155
+
156
+ const viewport = resolveViewport(flags);
157
+ const browser = await launch();
158
+ const axeSource = await getAxeSource();
159
+
160
+ try {
161
+ const page = await newPage(browser, viewport);
162
+ const r = await gotoOrThrow(page, url);
163
+ await settle(page);
164
+ // Give dynamic content time to fully render
165
+ await page.waitForTimeout(800);
166
+
167
+ // Inject axe config
168
+ await page.evaluate((t) => {
169
+ window.__PURR_AUDIT_CONFIG__ = { tags: t || ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'] };
170
+ }, tags || undefined);
171
+
172
+ // Inject and run axe
173
+ const rawResults = await page.evaluate(AUDIT_RUNNER, axeSource);
174
+
175
+ const result = {
176
+ url,
177
+ title: r.title,
178
+ ts: nowIso(),
179
+ viewport: { width: viewport.width, height: viewport.height, dpr: viewport.dpr },
180
+ violationSummary: summarizeViolations(rawResults.violations || []),
181
+ passes: (rawResults.passes || []).length,
182
+ incomplete: (rawResults.incomplete || []).length,
183
+ inapplicable: (rawResults.inapplicable || []).length,
184
+ violations: rawResults.violations || [],
185
+ };
186
+
187
+ // Write audit report
188
+ const auditPath = join(dir, "audit.json");
189
+ writeFileSync(auditPath, JSON.stringify(result, null, 2));
190
+
191
+ // Write summary Markdown
192
+ const mdPath = join(dir, "audit-summary.md");
193
+ writeFileSync(mdPath, renderAuditMarkdown(result));
194
+
195
+ // Highlighted screenshot
196
+ if (screenshot && result.violations.length > 0) {
197
+ const violationsJson = JSON.stringify(result.violations.map(v => ({
198
+ id: v.id,
199
+ impact: v.impact,
200
+ nodes: (v.nodes || []).map(n => ({ target: n.target })),
201
+ })));
202
+ await page.evaluate(HIGHLIGHT_VIOLATIONS, violationsJson);
203
+ await page.waitForTimeout(200);
204
+ const shotPath = join(dir, "audit-highlighted.png");
205
+ await page.screenshot({ path: shotPath, fullPage: true });
206
+ result.highlightedScreenshot = shotPath;
207
+ } else if (screenshot) {
208
+ const shotPath = join(dir, "audit-clean.png");
209
+ await page.screenshot({ path: shotPath, fullPage: true });
210
+ result.cleanScreenshot = shotPath;
211
+ }
212
+
213
+ return result;
214
+ } finally {
215
+ try { await browser.close(); } catch {}
216
+ }
217
+ }
218
+
219
+ // ─── Markdown report ────────────────────────────────────────────────────
220
+
221
+ function renderAuditMarkdown(result) {
222
+ const s = result.violationSummary || {};
223
+ const lines = [
224
+ `# Accessibility Audit: ${result.url}`,
225
+ ``,
226
+ `**Date:** ${result.ts}`,
227
+ `**Viewport:** ${result.viewport.width}x${result.viewport.height} @${result.viewport.dpr}x`,
228
+ ``,
229
+ `## Summary`,
230
+ ``,
231
+ `| Severity | Count |`,
232
+ `|----------|-------|`,
233
+ `| 🔴 Critical | ${s.byImpact?.critical || 0} nodes`,
234
+ `| 🟠 Serious | ${s.byImpact?.serious || 0} nodes`,
235
+ `| 🟡 Moderate | ${s.byImpact?.moderate || 0} nodes`,
236
+ `| ⚪ Minor | ${s.byImpact?.minor || 0} nodes`,
237
+ `| **Total violations** | **${s.total} rules, ${s.totalNodes} nodes** |`,
238
+ ``,
239
+ `| Check | Count |`,
240
+ `|-------|-------|`,
241
+ `| Passes | ${result.passes || 0} |`,
242
+ `| Incomplete | ${result.incomplete || 0} |`,
243
+ `| Inapplicable | ${result.inapplicable || 0} |`,
244
+ ``,
245
+ ];
246
+
247
+ if (result.violations.length) {
248
+ lines.push(`## Violations`);
249
+ lines.push(``);
250
+ for (const v of result.violations) {
251
+ lines.push(`### ${v.id} — ${v.impact}`);
252
+ lines.push(``);
253
+ lines.push(`**Help:** ${v.helpUrl || v.help || 'N/A'}`);
254
+ lines.push(``);
255
+ lines.push(`**Tags:** ${(v.tags || []).join(', ')}`);
256
+ lines.push(``);
257
+ lines.push(`**Affected nodes:** ${(v.nodes || []).length}`);
258
+ lines.push(``);
259
+ for (const n of (v.nodes || []).slice(0, 10)) { // top 10 per violation
260
+ const target = (n.target || []).join(', ');
261
+ const snippet = (n.html || '').slice(0, 200);
262
+ const failureSummary = n.failureSummary || '';
263
+ lines.push(`- \`${target}\``);
264
+ if (snippet) lines.push(` - \`${snippet.replace(/`/g, '')}\``);
265
+ if (failureSummary) lines.push(` - ${failureSummary.split('\\n')[0]}`);
266
+ }
267
+ if ((v.nodes || []).length > 10) lines.push(` - … and ${v.nodes.length - 10} more nodes`);
268
+ lines.push(``);
269
+ }
270
+ }
271
+
272
+ if (result.highlightedScreenshot) lines.push(`![Highlighted screenshot](${result.highlightedScreenshot})`);
273
+ if (result.cleanScreenshot) lines.push(`![Clean screenshot](${result.cleanScreenshot})`);
274
+
275
+ lines.push(``);
276
+ lines.push(`_Generated by pursr audit_`);
277
+ return lines.join('\n');
278
+ }