pursr 0.6.0 → 0.7.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.
@@ -1,260 +1,260 @@
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 { 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 pursr audit_`);
259
+ return lines.join('\n');
260
+ }