import fs from 'node:fs'; import path from 'node:path'; /** * CSV exports of the pages affected by each finding, so a developer can * pull the full list of URLs to reproduce and fix an issue — the report * shows a few examples and links to the complete CSV. * * Files are written under docs/reports///csv/. Filenames * are slugged from the rule id so they're stable and linkable. */ const csvField = (s) => { const v = String(s ?? ''); return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v; }; export function toCsv(headers, rows) { const lines = [headers.map(csvField).join(',')]; for (const row of rows) lines.push(row.map(csvField).join(',')); return lines.join('\n') + '\n'; } /** Filesystem- and URL-safe slug for a rule id (e.g. "sia-r12", "color-contrast"). */ export function ruleSlug(engine, ruleId) { return `${engine}__${ruleId}`.toLowerCase().replace(/[^a-z0-9._-]/g, '-'); } /** * Write the resource inventory CSV (every linked/embedded non-HTML * resource: PDFs, docs, iframes, media) with type, pages it appears on, * and when it was first seen (from the resource ledger). Returns the * relative path "resources.csv". */ /** Write the per-page Lighthouse CSV (scores + Core Web Vitals). */ export function writeLighthouseCsv(repDir, lighthouse) { if (!lighthouse?.pageDetail?.length) return null; const rows = lighthouse.pageDetail.map((p) => [ p.url, p.scores.performance, p.scores.accessibility, p.scores.bestPractices, p.scores.seo, p.scores.agentic, p.metrics.firstContentfulPaintMs, p.metrics.largestContentfulPaintMs, p.metrics.speedIndexMs, p.metrics.totalBlockingTimeMs, p.metrics.cumulativeLayoutShift, ]); fs.writeFileSync(path.join(repDir, 'lighthouse.csv'), toCsv(['url', 'performance', 'accessibility', 'best_practices', 'seo', 'agentic', 'fcp_ms', 'lcp_ms', 'speed_index_ms', 'tbt_ms', 'cls'], rows)); return 'lighthouse.csv'; } /** Write per-page readability CSV (words, Flesch reading ease, grade). */ export function writeReadabilityCsv(repDir, plRows) { if (!plRows?.length) return null; const rows = plRows.map((r) => [r.url, r.wordCount, r.fleschReadingEase, r.fleschKincaidGrade, r.scored]); fs.writeFileSync(path.join(repDir, 'readability.csv'), toCsv(['url', 'words', 'reading_ease', 'grade', 'scored'], rows)); return 'readability.csv'; } /** Write spelling CSV (misspelled word, pages affected, example URLs). */ export function writeSpellingCsv(repDir, spellRows) { if (!spellRows?.length) return null; const rows = spellRows.map((s) => [s.word, s.pages, (s.examplePages ?? []).join(' ')]); fs.writeFileSync(path.join(repDir, 'spelling.csv'), toCsv(['word', 'pages_affected', 'example_pages'], rows)); return 'spelling.csv'; } /** Write tech CSV (technology, category, confidence, pages, example URLs). */ export function writeTechCsv(repDir, tech) { if (!tech?.length) return null; const headers = ['technology', 'category', 'all_categories', 'confidence', 'version', 'pages_confirmed', 'website', 'example_pages']; const rows = tech.map((d) => [ d.name, d.category, (d.categories ?? []).join(' | '), d.confidence, d.version ?? '', d.pagesConfirmed ?? '', d.website ?? '', (d.examplePages ?? []).join(' '), ]); fs.writeFileSync(path.join(repDir, 'tech.csv'), toCsv(headers, rows)); return 'tech.csv'; } /** Write acronyms CSV (unexplained acronym, pages affected, example URLs). */ export function writeAcronymsCsv(repDir, acronymRows) { if (!acronymRows?.length) return null; const rows = acronymRows.map((a) => [a.acronym, a.pages, (a.examplePages ?? []).join(' ')]); fs.writeFileSync(path.join(repDir, 'acronyms.csv'), toCsv(['acronym', 'pages_affected', 'example_pages'], rows)); return 'acronyms.csv'; } export function writeResourceCsv(repDir, resources, ledger) { const rows = resources.list.map((r) => { const led = ledger.resources[r.url]; return [r.url, r.type, r.pages, led?.firstSeen ?? '', led?.lastSeen ?? '']; }); fs.writeFileSync(path.join(repDir, 'resources.csv'), toCsv(['url', 'type', 'pages', 'first_seen', 'last_seen'], rows)); return 'resources.csv'; } /** * Write a flat bugs.csv containing one row per bug report (one per failing * rule), with every field an accessibility engineer needs to triage, filter, * reproduce, and file in JIRA — matching what the HTML report shows: * * Identity: bug_id, pattern_id, combined_id (JIRA filter format) * Classification: engine, rule_id, wcag_category, wcag_sc, wcag_name, * wcag_level, wcag_version, severity * Frequency: pages_affected, instances, total_pages_scanned * Reproduction: example_url, xpath, html_snippet * Narrative: summary, description, steps_to_reproduce, suggested_fix, * remediation_tip, testing_environment * Impact: impact_summary, impact_groups (serialized) * History: first_seen, last_seen, weeks_seen * Deduplication: possible_duplicate_of, possible_duplicate_pattern * Links: affected_pages_csv, rule_url * * Returns the relative path "bugs.csv" (from index.html) or null. */ export function writeBugsCsv(repDir, bugs) { if (!bugs?.length) return null; const headers = [ 'bug_id', 'pattern_id', 'combined_id', 'engine', 'rule_id', 'rule_url', 'wcag_category', 'wcag_sc', 'wcag_name', 'wcag_level', 'wcag_version', 'severity', 'pages_affected', 'instances', 'total_pages_scanned', 'example_url', 'xpath', 'html_snippet', 'summary', 'description', 'steps_to_reproduce', 'suggested_fix', 'remediation_tip', 'testing_environment', 'impact_summary', 'impact_groups', 'first_seen', 'last_seen', 'weeks_seen', 'possible_duplicate_of', 'possible_duplicate_pattern', 'affected_pages_csv', ]; const rows = bugs.map((b) => { const groups = (b.impact?.groups ?? []) .map((g) => `${g.group} (${g.percent})`) .join('; '); return [ b.instance_id, b.pattern_id, `${b.instance_id} (pattern ${b.pattern_id})`, b.engine_key, b.rule_id, b.rule_url ?? '', b.wcag_category ?? '', b.wcag_sc ?? '', b.wcag_name ?? '', b.wcag_level ?? '', b.wcag_version ?? '', b.severity, b.frequency.pages_affected, b.frequency.instances, b.frequency.total_pages_scanned, b.url ?? '', b.xpath ?? '', b.html_snippet ?? '', b.summary ?? '', b.description ?? '', (b.steps_to_reproduce ?? []).join(' | '), b.suggested_fix ?? '', b.remediation_tip ?? '', b.testing_environment ?? '', b.impact?.summary ?? '', groups, b.first_seen ?? '', b.last_seen ?? '', b.weeks_seen ?? '', b.possible_duplicate_of ?? '', b.possible_duplicate_pattern ?? '', b.affected_pages_csv ?? '', ]; }); fs.writeFileSync(path.join(repDir, 'bugs.csv'), toCsv(headers, rows)); return 'bugs.csv'; } /** * Write a flat images.csv — one row per image found across scanned pages. * Returns the relative path "images.csv" or null if there's nothing to write. */ export function writeImagesCsv(repDir, summary) { const rows = summary.images?.imageRows; if (!rows?.length) return null; const headers = ['page_url', 'src', 'alt', 'alt_verdict', 'alt_reason', 'has_alt', 'is_decorative', 'is_missing_alt', 'width', 'height', 'natural_width', 'natural_height', 'loading', 'decoding', 'bytes']; const data = rows.map((img) => [ img.pageUrl, img.src, img.alt ?? '', img.altVerdict ?? '', img.altReason ?? '', img.hasAlt ? 'true' : 'false', img.isDecorative ? 'true' : 'false', img.isMissingAlt ? 'true' : 'false', img.width ?? '', img.height ?? '', img.naturalWidth ?? '', img.naturalHeight ?? '', img.loading ?? '', img.decoding ?? '', img.bytes ?? '', ]); fs.writeFileSync(path.join(repDir, 'images.csv'), toCsv(headers, data)); return 'images.csv'; } /** * Write a flat third-party.csv — one row per third-party vendor (registrable * domain) with its load cost and finding co-occurrence. Returns the relative * path "third-party.csv" or null if there's nothing to write. */ export function writeThirdPartyCsv(repDir, summary) { const vendors = summary.thirdParty?.vendors; if (!vendors?.length) return null; const headers = ['origin', 'is_script_vendor', 'pages', 'pages_with_scripts', 'median_bytes', 'median_requests', 'median_duration_ms', 'pages_with_finding', 'first_seen', 'last_seen', 'weeks_seen', 'example_pages']; const data = vendors.map((v) => [ v.origin, v.isScriptVendor ? 'true' : 'false', v.pages, v.pagesWithScripts ?? '', v.medianBytes, v.medianRequests, v.medianDurationMs, v.pagesWithFindings, v.firstSeen ?? '', v.lastSeen ?? '', v.weeksSeen ?? '', Array.isArray(v.examplePages) ? v.examplePages.join(' | ') : '', ]); fs.writeFileSync(path.join(repDir, 'third-party.csv'), toCsv(headers, data)); return 'third-party.csv'; } /** * Write a flat errors.csv for broken links and non-404 error pages. * Returns the relative path "errors.csv" or null if there's nothing to write. */ export function writeErrorsCsv(repDir, summary) { const broken = summary.linkCheck?.broken ?? []; const errors = (summary.errorPages ?? []).filter((e) => Number(e.status) !== 404); if (!broken.length && !errors.length) return null; const headers = ['type', 'url', 'status', 'linked_from']; const rows = [ ...broken.map((b) => [ 'broken_link', b.url, b.status || b.reason || '', Array.isArray(b.foundOn) ? b.foundOn.join(' | ') : (b.foundOn ?? ''), ]), ...errors.map((e) => [ 'page_error', e.url, e.status, '', ]), ]; fs.writeFileSync(path.join(repDir, 'errors.csv'), toCsv(headers, rows)); return 'errors.csv'; } /** * Write all CSVs for one domain/week into /csv/. Returns a map of * { axeAll, alfaAll, byRule: { ":": "" } } * so the report can link to each. Relative paths are from the report's * index.html (i.e. "csv/.csv"). */ export function writeCsvs(repDir, summary) { const csvDir = path.join(repDir, 'csv'); fs.mkdirSync(csvDir, { recursive: true }); const links = { axeAll: null, alfaAll: null, byRule: {} }; const writeFile = (name, content) => { fs.writeFileSync(path.join(csvDir, name), content); return `csv/${name}`; }; // "All pages with an axe violation" / "...Alfa failure". if (summary.pagesWithAxeList?.length) { links.axeAll = writeFile('axe-pages-with-violations.csv', toCsv(['url'], summary.pagesWithAxeList.map((u) => [u]))); } if (summary.pagesWithAlfaList?.length) { links.alfaAll = writeFile('alfa-pages-with-failures.csv', toCsv(['url'], summary.pagesWithAlfaList.map((u) => [u]))); } // Per-rule affected-page CSVs (axe, alfa, deprecated-html). const ruleSets = [ ['axe-core', summary.axe?.rules], ['alfa', summary.alfa?.rules], ['deprecated-html', summary.deprecatedHtml?.rules], ]; for (const [engine, rules] of ruleSets) { for (const [ruleId, rule] of Object.entries(rules ?? {})) { if (!rule.affectedPages?.length) continue; const name = `${ruleSlug(engine, ruleId)}.csv`; const rows = rule.affectedPages.map((p) => [p.url, p.instances]); links.byRule[`${engine}:${ruleId}`] = writeFile(name, toCsv(['url', 'instances'], rows)); } } return links; }