bfsg-scanner 0.1.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.
Files changed (104) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +147 -0
  4. package/dist/cli/index.d.ts +2 -0
  5. package/dist/cli/index.js +4 -0
  6. package/dist/cli/index.js.map +1 -0
  7. package/dist/cli/run.d.ts +29 -0
  8. package/dist/cli/run.js +202 -0
  9. package/dist/cli/run.js.map +1 -0
  10. package/dist/config/index.d.ts +2 -0
  11. package/dist/config/index.js +3 -0
  12. package/dist/config/index.js.map +1 -0
  13. package/dist/config/load.d.ts +12 -0
  14. package/dist/config/load.js +47 -0
  15. package/dist/config/load.js.map +1 -0
  16. package/dist/config/schema.d.ts +51 -0
  17. package/dist/config/schema.js +16 -0
  18. package/dist/config/schema.js.map +1 -0
  19. package/dist/discovery/crawl.d.ts +21 -0
  20. package/dist/discovery/crawl.js +79 -0
  21. package/dist/discovery/crawl.js.map +1 -0
  22. package/dist/discovery/discover-site.d.ts +26 -0
  23. package/dist/discovery/discover-site.js +50 -0
  24. package/dist/discovery/discover-site.js.map +1 -0
  25. package/dist/discovery/discover.d.ts +25 -0
  26. package/dist/discovery/discover.js +81 -0
  27. package/dist/discovery/discover.js.map +1 -0
  28. package/dist/discovery/index.d.ts +11 -0
  29. package/dist/discovery/index.js +7 -0
  30. package/dist/discovery/index.js.map +1 -0
  31. package/dist/discovery/links.d.ts +11 -0
  32. package/dist/discovery/links.js +69 -0
  33. package/dist/discovery/links.js.map +1 -0
  34. package/dist/discovery/robots.d.ts +18 -0
  35. package/dist/discovery/robots.js +91 -0
  36. package/dist/discovery/robots.js.map +1 -0
  37. package/dist/discovery/sitemap.d.ts +18 -0
  38. package/dist/discovery/sitemap.js +36 -0
  39. package/dist/discovery/sitemap.js.map +1 -0
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js +2 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/report/build.d.ts +14 -0
  44. package/dist/report/build.js +100 -0
  45. package/dist/report/build.js.map +1 -0
  46. package/dist/report/clauses.d.ts +32 -0
  47. package/dist/report/clauses.js +57 -0
  48. package/dist/report/clauses.js.map +1 -0
  49. package/dist/report/i18n.d.ts +54 -0
  50. package/dist/report/i18n.js +95 -0
  51. package/dist/report/i18n.js.map +1 -0
  52. package/dist/report/index.d.ts +12 -0
  53. package/dist/report/index.js +8 -0
  54. package/dist/report/index.js.map +1 -0
  55. package/dist/report/pdf.d.ts +12 -0
  56. package/dist/report/pdf.js +25 -0
  57. package/dist/report/pdf.js.map +1 -0
  58. package/dist/report/render.d.ts +8 -0
  59. package/dist/report/render.js +204 -0
  60. package/dist/report/render.js.map +1 -0
  61. package/dist/report/types.d.ts +78 -0
  62. package/dist/report/types.js +2 -0
  63. package/dist/report/types.js.map +1 -0
  64. package/dist/report/verdict.d.ts +20 -0
  65. package/dist/report/verdict.js +28 -0
  66. package/dist/report/verdict.js.map +1 -0
  67. package/dist/report/write.d.ts +21 -0
  68. package/dist/report/write.js +35 -0
  69. package/dist/report/write.js.map +1 -0
  70. package/dist/scan/index.d.ts +2 -0
  71. package/dist/scan/index.js +2 -0
  72. package/dist/scan/index.js.map +1 -0
  73. package/dist/scan/scan.d.ts +2 -0
  74. package/dist/scan/scan.js +82 -0
  75. package/dist/scan/scan.js.map +1 -0
  76. package/dist/scan/types.d.ts +56 -0
  77. package/dist/scan/types.js +2 -0
  78. package/dist/scan/types.js.map +1 -0
  79. package/dist/shared/index.d.ts +4 -0
  80. package/dist/shared/index.js +5 -0
  81. package/dist/shared/index.js.map +1 -0
  82. package/dist/shared/package-info.d.ts +10 -0
  83. package/dist/shared/package-info.js +11 -0
  84. package/dist/shared/package-info.js.map +1 -0
  85. package/dist/shared/pool.d.ts +7 -0
  86. package/dist/shared/pool.js +25 -0
  87. package/dist/shared/pool.js.map +1 -0
  88. package/dist/shared/rate-limiter.d.ts +11 -0
  89. package/dist/shared/rate-limiter.js +26 -0
  90. package/dist/shared/rate-limiter.js.map +1 -0
  91. package/dist/shared/user-agent.d.ts +11 -0
  92. package/dist/shared/user-agent.js +12 -0
  93. package/dist/shared/user-agent.js.map +1 -0
  94. package/docs/SCANNING-ETHICS.md +52 -0
  95. package/docs/adr/0001-page-discovery-strategy.md +74 -0
  96. package/docs/adr/0002-json-report-output.md +54 -0
  97. package/docs/adr/0003-failon-threshold-and-exit-codes.md +72 -0
  98. package/docs/adr/0004-clause-mapping-from-axe-tags.md +81 -0
  99. package/docs/adr/0005-html-report-rendering.md +71 -0
  100. package/docs/adr/0006-pdf-report-rendering.md +58 -0
  101. package/docs/adr/0007-cli-argument-surface.md +73 -0
  102. package/docs/adr/0008-report-json-schema.md +59 -0
  103. package/package.json +73 -0
  104. package/schema/report.v1.json +156 -0
@@ -0,0 +1,100 @@
1
+ import { SCHEMA_VERSION } from '../index.js';
2
+ import { toolInfo } from '../shared/index.js';
3
+ import { clausesFor, compareDotted } from './clauses.js';
4
+ import { countAtOrAbove } from './verdict.js';
5
+ /**
6
+ * Assembles a versioned, self-describing report from a scan result. Pure
7
+ * apart from the default clock and the one-time package.json read, both of
8
+ * which can be overridden via options.
9
+ */
10
+ export function buildReport(scanResult, target, options = {}) {
11
+ const { generatedAt = new Date(), tool = toolInfo() } = options;
12
+ const pages = scanResult.pages.map(enrichPage);
13
+ const summary = summarise(pages);
14
+ return {
15
+ schemaVersion: SCHEMA_VERSION,
16
+ generatedAt: generatedAt.toISOString(),
17
+ tool,
18
+ target,
19
+ summary,
20
+ verdict: judge(summary, target.failOn),
21
+ pages,
22
+ };
23
+ }
24
+ /** Attaches the breached WCAG / EN 301 549 clauses to every finding on a scanned page. */
25
+ function enrichPage(page) {
26
+ if (page.status === 'error') {
27
+ return page;
28
+ }
29
+ return {
30
+ status: 'ok',
31
+ url: page.url,
32
+ violations: page.violations.map(withClauses),
33
+ incomplete: page.incomplete.map(withClauses),
34
+ };
35
+ }
36
+ function withClauses(finding) {
37
+ return { ...finding, clauses: clausesFor(finding.tags) };
38
+ }
39
+ function judge(summary, failOn) {
40
+ const violationsAtOrAboveThreshold = countAtOrAbove(summary.violationsByImpact, failOn);
41
+ return {
42
+ violationsAtOrAboveThreshold,
43
+ unrankedViolations: summary.violationsByImpact.unknown,
44
+ passed: violationsAtOrAboveThreshold === 0,
45
+ };
46
+ }
47
+ function summarise(pages) {
48
+ const byImpact = {
49
+ critical: 0,
50
+ serious: 0,
51
+ moderate: 0,
52
+ minor: 0,
53
+ unknown: 0,
54
+ };
55
+ const breachedSuccessCriteria = new Set();
56
+ const breachedEn301549Clauses = new Set();
57
+ let pagesFailed = 0;
58
+ let pagesWithViolations = 0;
59
+ let totalViolations = 0;
60
+ for (const page of pages) {
61
+ if (page.status === 'error') {
62
+ pagesFailed += 1;
63
+ continue;
64
+ }
65
+ if (page.violations.length > 0) {
66
+ pagesWithViolations += 1;
67
+ }
68
+ totalViolations += page.violations.length;
69
+ for (const finding of page.violations) {
70
+ byImpact[impactBucket(finding.impact)] += 1;
71
+ for (const sc of finding.clauses.wcagSc) {
72
+ breachedSuccessCriteria.add(sc);
73
+ }
74
+ for (const clause of finding.clauses.en301549) {
75
+ breachedEn301549Clauses.add(clause);
76
+ }
77
+ }
78
+ }
79
+ return {
80
+ pagesScanned: pages.length,
81
+ pagesFailed,
82
+ pagesWithViolations,
83
+ totalViolations,
84
+ violationsByImpact: byImpact,
85
+ breachedSuccessCriteria: [...breachedSuccessCriteria].sort(compareDotted),
86
+ breachedEn301549Clauses: [...breachedEn301549Clauses].sort(compareDotted),
87
+ };
88
+ }
89
+ function impactBucket(impact) {
90
+ switch (impact) {
91
+ case 'critical':
92
+ case 'serious':
93
+ case 'moderate':
94
+ case 'minor':
95
+ return impact;
96
+ default:
97
+ return 'unknown';
98
+ }
99
+ }
100
+ //# sourceMappingURL=build.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build.js","sourceRoot":"","sources":["../../src/report/build.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7C,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAWzD,OAAO,EAAE,cAAc,EAAwB,MAAM,cAAc,CAAC;AASpE;;;;GAIG;AACH,MAAM,UAAU,WAAW,CACzB,UAAsB,EACtB,MAAoB,EACpB,UAA8B,EAAE;IAEhC,MAAM,EAAE,WAAW,GAAG,IAAI,IAAI,EAAE,EAAE,IAAI,GAAG,QAAQ,EAAE,EAAE,GAAG,OAAO,CAAC;IAChE,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;IAC/C,MAAM,OAAO,GAAG,SAAS,CAAC,KAAK,CAAC,CAAC;IAEjC,OAAO;QACL,aAAa,EAAE,cAAc;QAC7B,WAAW,EAAE,WAAW,CAAC,WAAW,EAAE;QACtC,IAAI;QACJ,MAAM;QACN,OAAO;QACP,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC;QACtC,KAAK;KACN,CAAC;AACJ,CAAC;AAED,0FAA0F;AAC1F,SAAS,UAAU,CAAC,IAAiC;IACnD,IAAI,IAAI,CAAC,MAAM,KAAK,OAAO,EAAE,CAAC;QAC5B,OAAO,IAAI,CAAC;IACd,CAAC;IACD,OAAO;QACL,MAAM,EAAE,IAAI;QACZ,GAAG,EAAE,IAAI,CAAC,GAAG;QACb,UAAU,EAAE,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,WAAW,CAAC;QAC5C,UAAU,EAAE,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,WAAW,CAAC;KAC7C,CAAC;AACJ,CAAC;AAED,SAAS,WAAW,CAAC,OAAoB;IACvC,OAAO,EAAE,GAAG,OAAO,EAAE,OAAO,EAAE,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;AAC3D,CAAC;AAED,SAAS,KAAK,CAAC,OAAsB,EAAE,MAAuB;IAC5D,MAAM,4BAA4B,GAAG,cAAc,CAAC,OAAO,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC;IACxF,OAAO;QACL,4BAA4B;QAC5B,kBAAkB,EAAE,OAAO,CAAC,kBAAkB,CAAC,OAAO;QACtD,MAAM,EAAE,4BAA4B,KAAK,CAAC;KAC3C,CAAC;AACJ,CAAC;AAED,SAAS,SAAS,CAAC,KAA4B;IAC7C,MAAM,QAAQ,GAA6C;QACzD,QAAQ,EAAE,CAAC;QACX,OAAO,EAAE,CAAC;QACV,QAAQ,EAAE,CAAC;QACX,KAAK,EAAE,CAAC;QACR,OAAO,EAAE,CAAC;KACX,CAAC;IACF,MAAM,uBAAuB,GAAG,IAAI,GAAG,EAAU,CAAC;IAClD,MAAM,uBAAuB,GAAG,IAAI,GAAG,EAAU,CAAC;IAElD,IAAI,WAAW,GAAG,CAAC,CAAC;IACpB,IAAI,mBAAmB,GAAG,CAAC,CAAC;IAC5B,IAAI,eAAe,GAAG,CAAC,CAAC;IAExB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,IAAI,CAAC,MAAM,KAAK,OAAO,EAAE,CAAC;YAC5B,WAAW,IAAI,CAAC,CAAC;YACjB,SAAS;QACX,CAAC;QACD,IAAI,IAAI,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC/B,mBAAmB,IAAI,CAAC,CAAC;QAC3B,CAAC;QACD,eAAe,IAAI,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC;QAC1C,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,UAAU,EAAE,CAAC;YACtC,QAAQ,CAAC,YAAY,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC;YAC5C,KAAK,MAAM,EAAE,IAAI,OAAO,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;gBACxC,uBAAuB,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YAClC,CAAC;YACD,KAAK,MAAM,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC;gBAC9C,uBAAuB,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;YACtC,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO;QACL,YAAY,EAAE,KAAK,CAAC,MAAM;QAC1B,WAAW;QACX,mBAAmB;QACnB,eAAe;QACf,kBAAkB,EAAE,QAAQ;QAC5B,uBAAuB,EAAE,CAAC,GAAG,uBAAuB,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC;QACzE,uBAAuB,EAAE,CAAC,GAAG,uBAAuB,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC;KAC1E,CAAC;AACJ,CAAC;AAED,SAAS,YAAY,CAAC,MAAmB;IACvC,QAAQ,MAAM,EAAE,CAAC;QACf,KAAK,UAAU,CAAC;QAChB,KAAK,SAAS,CAAC;QACf,KAAK,UAAU,CAAC;QAChB,KAAK,OAAO;YACV,OAAO,MAAM,CAAC;QAChB;YACE,OAAO,SAAS,CAAC;IACrB,CAAC;AACH,CAAC"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The standards clauses an axe-core finding breaches, read from the rule's
3
+ * own tags.
4
+ *
5
+ * axe-core already carries the mapping: every rule's `tags` array names the
6
+ * WCAG success criteria it checks (`wcag143` -> SC 1.4.3) and the matching
7
+ * EN 301 549 clauses (`EN-9.1.4.3`). The scanner reads that mapping rather
8
+ * than maintaining a hand-authored table (see ADR 0004). The German BFSG
9
+ * adds no per-rule detail of its own: SS 4 BFSG presumes conformity for
10
+ * content that meets the relevant harmonised standard, which for the web
11
+ * is EN 301 549 (Chapter 9 references WCAG 2.1 level A/AA). So the EN
12
+ * 301 549 clauses below are the corresponding citations. Not legal advice.
13
+ */
14
+ export interface Clauses {
15
+ /** WCAG success criteria, dotted (`1.4.3`), numerically sorted, deduped. */
16
+ readonly wcagSc: readonly string[];
17
+ /** EN 301 549 clauses, dotted (`9.1.4.3`), numerically sorted, deduped. */
18
+ readonly en301549: readonly string[];
19
+ }
20
+ /**
21
+ * Derives the WCAG and EN 301 549 clauses a finding breaches from its axe
22
+ * rule tags. Pure. Unrecognised tags — `cat.*`, `ACT`, `TTv5`, `section508*`,
23
+ * `RGAA*`, `best-practice`, `experimental`, the bare `EN-301-549` marker —
24
+ * are ignored.
25
+ */
26
+ export declare function clausesFor(tags: readonly string[]): Clauses;
27
+ /**
28
+ * Orders dotted-number strings segment by segment, numerically, so `1.4.10`
29
+ * sorts after `1.4.3` rather than before it as a plain string compare would.
30
+ * Exported for the report layer's clause rollup, which sorts the same shape.
31
+ */
32
+ export declare function compareDotted(a: string, b: string): number;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * A WCAG SC tag: `wcag` followed by the digits of the criterion number with
3
+ * the dots removed — `wcag143` is SC 1.4.3, `wcag1410` is SC 1.4.10. The
4
+ * first two digits are always the principle and guideline; whatever follows
5
+ * is the criterion. Level tags (`wcag2a`, `wcag2aa`, `wcag21aa`) carry
6
+ * letters after `wcag` and so never match.
7
+ */
8
+ const WCAG_SC_TAG = /^wcag(\d)(\d)(\d+)$/;
9
+ /**
10
+ * An EN 301 549 clause tag: `EN-` followed by a dotted clause number
11
+ * (`EN-9.1.4.3`). The bare `EN-301-549` marker uses hyphens, not dots, so
12
+ * it is skipped — it says only that the rule maps to the standard somehow,
13
+ * not to which clause.
14
+ */
15
+ const EN_301_549_TAG = /^EN-(\d+(?:\.\d+)+)$/;
16
+ /**
17
+ * Derives the WCAG and EN 301 549 clauses a finding breaches from its axe
18
+ * rule tags. Pure. Unrecognised tags — `cat.*`, `ACT`, `TTv5`, `section508*`,
19
+ * `RGAA*`, `best-practice`, `experimental`, the bare `EN-301-549` marker —
20
+ * are ignored.
21
+ */
22
+ export function clausesFor(tags) {
23
+ const wcagSc = new Set();
24
+ const en301549 = new Set();
25
+ for (const tag of tags) {
26
+ const wcag = WCAG_SC_TAG.exec(tag);
27
+ if (wcag !== null) {
28
+ wcagSc.add(`${wcag[1]}.${wcag[2]}.${wcag[3]}`);
29
+ continue;
30
+ }
31
+ const enClause = EN_301_549_TAG.exec(tag)?.[1];
32
+ if (enClause !== undefined) {
33
+ en301549.add(enClause);
34
+ }
35
+ }
36
+ return {
37
+ wcagSc: [...wcagSc].sort(compareDotted),
38
+ en301549: [...en301549].sort(compareDotted),
39
+ };
40
+ }
41
+ /**
42
+ * Orders dotted-number strings segment by segment, numerically, so `1.4.10`
43
+ * sorts after `1.4.3` rather than before it as a plain string compare would.
44
+ * Exported for the report layer's clause rollup, which sorts the same shape.
45
+ */
46
+ export function compareDotted(a, b) {
47
+ const as = a.split('.');
48
+ const bs = b.split('.');
49
+ for (let i = 0; i < Math.max(as.length, bs.length); i += 1) {
50
+ const diff = Number(as[i] ?? 0) - Number(bs[i] ?? 0);
51
+ if (diff !== 0) {
52
+ return diff;
53
+ }
54
+ }
55
+ return 0;
56
+ }
57
+ //# sourceMappingURL=clauses.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clauses.js","sourceRoot":"","sources":["../../src/report/clauses.ts"],"names":[],"mappings":"AAoBA;;;;;;GAMG;AACH,MAAM,WAAW,GAAG,qBAAqB,CAAC;AAE1C;;;;;GAKG;AACH,MAAM,cAAc,GAAG,sBAAsB,CAAC;AAE9C;;;;;GAKG;AACH,MAAM,UAAU,UAAU,CAAC,IAAuB;IAChD,MAAM,MAAM,GAAG,IAAI,GAAG,EAAU,CAAC;IACjC,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAU,CAAC;IAEnC,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,MAAM,IAAI,GAAG,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACnC,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;YAClB,MAAM,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YAC/C,SAAS;QACX,CAAC;QACD,MAAM,QAAQ,GAAG,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QAC/C,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;YAC3B,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACzB,CAAC;IACH,CAAC;IAED,OAAO;QACL,MAAM,EAAE,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC;QACvC,QAAQ,EAAE,CAAC,GAAG,QAAQ,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC;KAC5C,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,CAAS,EAAE,CAAS;IAChD,MAAM,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACxB,MAAM,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACxB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3D,MAAM,IAAI,GAAG,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;QACrD,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC;YACf,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IACD,OAAO,CAAC,CAAC;AACX,CAAC"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Localised strings for the human-facing HTML report. Two locales, a flat
3
+ * table, no i18n library: the set is small, there is no runtime locale
4
+ * negotiation, and every label is phrased to avoid grammatical agreement
5
+ * (`Affected elements: 3`, not `3 elements`) so no plural rules are needed.
6
+ * See ADR 0005.
7
+ *
8
+ * The JSON report stays language-neutral; a locale is chosen only when
9
+ * rendering HTML.
10
+ */
11
+ export type Locale = 'de' | 'en';
12
+ export interface Messages {
13
+ /** `<title>` and the page's `<h1>`. */
14
+ readonly reportTitle: string;
15
+ readonly generatedAt: string;
16
+ readonly tool: string;
17
+ readonly target: string;
18
+ readonly wcagTags: string;
19
+ readonly threshold: string;
20
+ readonly verdictPassed: string;
21
+ readonly verdictFailed: string;
22
+ /** Precedes the count of violations at or above the threshold. */
23
+ readonly verdictFailedDetail: string;
24
+ readonly unrankedNote: string;
25
+ readonly summaryHeading: string;
26
+ readonly pagesScanned: string;
27
+ readonly pagesFailed: string;
28
+ readonly pagesWithViolations: string;
29
+ readonly totalViolations: string;
30
+ readonly byImpact: string;
31
+ readonly impactCritical: string;
32
+ readonly impactSerious: string;
33
+ readonly impactModerate: string;
34
+ readonly impactMinor: string;
35
+ readonly impactUnknown: string;
36
+ readonly clausesHeading: string;
37
+ readonly breachedSuccessCriteria: string;
38
+ readonly breachedEn301549: string;
39
+ readonly noClausesBreached: string;
40
+ readonly clausesLegalNote: string;
41
+ readonly findingsHeading: string;
42
+ readonly pageScanFailed: string;
43
+ readonly noViolations: string;
44
+ readonly rule: string;
45
+ readonly impact: string;
46
+ readonly successCriteria: string;
47
+ readonly en301549Clauses: string;
48
+ readonly help: string;
49
+ readonly affectedElements: string;
50
+ readonly incompleteHeading: string;
51
+ readonly incompleteNote: string;
52
+ readonly none: string;
53
+ }
54
+ export declare function messagesFor(locale: Locale): Messages;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Localised strings for the human-facing HTML report. Two locales, a flat
3
+ * table, no i18n library: the set is small, there is no runtime locale
4
+ * negotiation, and every label is phrased to avoid grammatical agreement
5
+ * (`Affected elements: 3`, not `3 elements`) so no plural rules are needed.
6
+ * See ADR 0005.
7
+ *
8
+ * The JSON report stays language-neutral; a locale is chosen only when
9
+ * rendering HTML.
10
+ */
11
+ const EN = {
12
+ reportTitle: 'Accessibility scan report',
13
+ generatedAt: 'Generated at',
14
+ tool: 'Tool',
15
+ target: 'Target',
16
+ wcagTags: 'Rule sets',
17
+ threshold: 'Failure threshold',
18
+ verdictPassed: 'Passed — no violations at or above the failure threshold.',
19
+ verdictFailed: 'Failed',
20
+ verdictFailedDetail: 'violation(s) at or above the failure threshold',
21
+ unrankedNote: 'violation(s) carry no impact rating from axe and were excluded from the threshold. Review them below.',
22
+ summaryHeading: 'Summary',
23
+ pagesScanned: 'Pages scanned',
24
+ pagesFailed: 'Pages that failed to load',
25
+ pagesWithViolations: 'Pages with violations',
26
+ totalViolations: 'Violated rules (total)',
27
+ byImpact: 'By impact',
28
+ impactCritical: 'Critical',
29
+ impactSerious: 'Serious',
30
+ impactModerate: 'Moderate',
31
+ impactMinor: 'Minor',
32
+ impactUnknown: 'Unrated',
33
+ clausesHeading: 'Breached clauses',
34
+ breachedSuccessCriteria: 'WCAG 2.1 success criteria',
35
+ breachedEn301549: 'EN 301 549 clauses',
36
+ noClausesBreached: 'No WCAG or EN 301 549 clauses were breached by a confirmed violation.',
37
+ clausesLegalNote: '§ 4 BFSG presumes conformity for content that meets the relevant harmonised standard — for the web, EN 301 549, whose Chapter 9 references WCAG 2.1 level A/AA. The clauses above are the corresponding citations. This is not legal advice.',
38
+ findingsHeading: 'Findings by page',
39
+ pageScanFailed: 'This page could not be scanned',
40
+ noViolations: 'No violations.',
41
+ rule: 'Rule',
42
+ impact: 'Impact',
43
+ successCriteria: 'WCAG SC',
44
+ en301549Clauses: 'EN 301 549',
45
+ help: 'How to fix',
46
+ affectedElements: 'Affected elements',
47
+ incompleteHeading: 'Needs review',
48
+ incompleteNote: 'axe could not decide these automatically. They are not counted as violations.',
49
+ none: 'none',
50
+ };
51
+ const DE = {
52
+ reportTitle: 'Bericht zur Barrierefreiheitsprüfung',
53
+ generatedAt: 'Erstellt am',
54
+ tool: 'Werkzeug',
55
+ target: 'Ziel',
56
+ wcagTags: 'Regelsätze',
57
+ threshold: 'Schwellenwert für Fehlschlag',
58
+ verdictPassed: 'Bestanden — keine Verstöße auf oder über dem Schwellenwert.',
59
+ verdictFailed: 'Nicht bestanden',
60
+ verdictFailedDetail: 'Verstoß/Verstöße auf oder über dem Schwellenwert',
61
+ unrankedNote: 'Verstoß/Verstöße ohne Schweregrad von axe wurden vom Schwellenwert ausgenommen. Bitte unten prüfen.',
62
+ summaryHeading: 'Zusammenfassung',
63
+ pagesScanned: 'Geprüfte Seiten',
64
+ pagesFailed: 'Nicht ladbare Seiten',
65
+ pagesWithViolations: 'Seiten mit Verstößen',
66
+ totalViolations: 'Verletzte Regeln (gesamt)',
67
+ byImpact: 'Nach Schweregrad',
68
+ impactCritical: 'Kritisch',
69
+ impactSerious: 'Schwerwiegend',
70
+ impactModerate: 'Mittel',
71
+ impactMinor: 'Gering',
72
+ impactUnknown: 'Ohne Bewertung',
73
+ clausesHeading: 'Verletzte Anforderungen',
74
+ breachedSuccessCriteria: 'WCAG-2.1-Erfolgskriterien',
75
+ breachedEn301549: 'EN-301-549-Anforderungen',
76
+ noClausesBreached: 'Kein WCAG- oder EN-301-549-Kriterium wurde durch einen bestätigten Verstoß verletzt.',
77
+ clausesLegalNote: '§ 4 BFSG begründet eine Konformitätsvermutung für Angebote, die die einschlägige harmonisierte Norm erfüllen — für das Web ist das EN 301 549, deren Kapitel 9 auf WCAG 2.1 (Stufen A/AA) verweist. Die oben genannten Anforderungen sind die entsprechenden Fundstellen. Dies ist keine Rechtsberatung.',
78
+ findingsHeading: 'Ergebnisse nach Seite',
79
+ pageScanFailed: 'Diese Seite konnte nicht geprüft werden',
80
+ noViolations: 'Keine Verstöße.',
81
+ rule: 'Regel',
82
+ impact: 'Schweregrad',
83
+ successCriteria: 'WCAG SC',
84
+ en301549Clauses: 'EN 301 549',
85
+ help: 'Behebung',
86
+ affectedElements: 'Betroffene Elemente',
87
+ incompleteHeading: 'Manuelle Prüfung nötig',
88
+ incompleteNote: 'axe konnte dies nicht automatisch entscheiden. Es wird nicht als Verstoß gezählt.',
89
+ none: 'keine',
90
+ };
91
+ const MESSAGES = { de: DE, en: EN };
92
+ export function messagesFor(locale) {
93
+ return MESSAGES[locale];
94
+ }
95
+ //# sourceMappingURL=i18n.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"i18n.js","sourceRoot":"","sources":["../../src/report/i18n.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAmDH,MAAM,EAAE,GAAa;IACnB,WAAW,EAAE,2BAA2B;IACxC,WAAW,EAAE,cAAc;IAC3B,IAAI,EAAE,MAAM;IACZ,MAAM,EAAE,QAAQ;IAChB,QAAQ,EAAE,WAAW;IACrB,SAAS,EAAE,mBAAmB;IAE9B,aAAa,EAAE,2DAA2D;IAC1E,aAAa,EAAE,QAAQ;IACvB,mBAAmB,EAAE,gDAAgD;IACrE,YAAY,EACV,uGAAuG;IAEzG,cAAc,EAAE,SAAS;IACzB,YAAY,EAAE,eAAe;IAC7B,WAAW,EAAE,2BAA2B;IACxC,mBAAmB,EAAE,uBAAuB;IAC5C,eAAe,EAAE,wBAAwB;IACzC,QAAQ,EAAE,WAAW;IACrB,cAAc,EAAE,UAAU;IAC1B,aAAa,EAAE,SAAS;IACxB,cAAc,EAAE,UAAU;IAC1B,WAAW,EAAE,OAAO;IACpB,aAAa,EAAE,SAAS;IAExB,cAAc,EAAE,kBAAkB;IAClC,uBAAuB,EAAE,2BAA2B;IACpD,gBAAgB,EAAE,oBAAoB;IACtC,iBAAiB,EAAE,uEAAuE;IAC1F,gBAAgB,EACd,8OAA8O;IAEhP,eAAe,EAAE,kBAAkB;IACnC,cAAc,EAAE,gCAAgC;IAChD,YAAY,EAAE,gBAAgB;IAC9B,IAAI,EAAE,MAAM;IACZ,MAAM,EAAE,QAAQ;IAChB,eAAe,EAAE,SAAS;IAC1B,eAAe,EAAE,YAAY;IAC7B,IAAI,EAAE,YAAY;IAClB,gBAAgB,EAAE,mBAAmB;IACrC,iBAAiB,EAAE,cAAc;IACjC,cAAc,EAAE,+EAA+E;IAC/F,IAAI,EAAE,MAAM;CACb,CAAC;AAEF,MAAM,EAAE,GAAa;IACnB,WAAW,EAAE,sCAAsC;IACnD,WAAW,EAAE,aAAa;IAC1B,IAAI,EAAE,UAAU;IAChB,MAAM,EAAE,MAAM;IACd,QAAQ,EAAE,YAAY;IACtB,SAAS,EAAE,8BAA8B;IAEzC,aAAa,EAAE,6DAA6D;IAC5E,aAAa,EAAE,iBAAiB;IAChC,mBAAmB,EAAE,kDAAkD;IACvE,YAAY,EACV,qGAAqG;IAEvG,cAAc,EAAE,iBAAiB;IACjC,YAAY,EAAE,iBAAiB;IAC/B,WAAW,EAAE,sBAAsB;IACnC,mBAAmB,EAAE,sBAAsB;IAC3C,eAAe,EAAE,2BAA2B;IAC5C,QAAQ,EAAE,kBAAkB;IAC5B,cAAc,EAAE,UAAU;IAC1B,aAAa,EAAE,eAAe;IAC9B,cAAc,EAAE,QAAQ;IACxB,WAAW,EAAE,QAAQ;IACrB,aAAa,EAAE,gBAAgB;IAE/B,cAAc,EAAE,yBAAyB;IACzC,uBAAuB,EAAE,2BAA2B;IACpD,gBAAgB,EAAE,0BAA0B;IAC5C,iBAAiB,EACf,sFAAsF;IACxF,gBAAgB,EACd,0SAA0S;IAE5S,eAAe,EAAE,uBAAuB;IACxC,cAAc,EAAE,yCAAyC;IACzD,YAAY,EAAE,iBAAiB;IAC/B,IAAI,EAAE,OAAO;IACb,MAAM,EAAE,aAAa;IACrB,eAAe,EAAE,SAAS;IAC1B,eAAe,EAAE,YAAY;IAC7B,IAAI,EAAE,UAAU;IAChB,gBAAgB,EAAE,qBAAqB;IACvC,iBAAiB,EAAE,wBAAwB;IAC3C,cAAc,EACZ,mFAAmF;IACrF,IAAI,EAAE,OAAO;CACd,CAAC;AAEF,MAAM,QAAQ,GAAuC,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC;AAExE,MAAM,UAAU,WAAW,CAAC,MAAc;IACxC,OAAO,QAAQ,CAAC,MAAM,CAAC,CAAC;AAC1B,CAAC"}
@@ -0,0 +1,12 @@
1
+ export type { BuildReportOptions } from './build.js';
2
+ export { buildReport } from './build.js';
3
+ export type { Clauses } from './clauses.js';
4
+ export { clausesFor } from './clauses.js';
5
+ export type { Locale, Messages } from './i18n.js';
6
+ export { messagesFor } from './i18n.js';
7
+ export { renderPdfReport } from './pdf.js';
8
+ export { renderHtmlReport } from './render.js';
9
+ export type { Report, ReportFinding, ReportImpactCounts, ReportPage, ReportPageSuccess, ReportSummary, ReportTarget, ReportToolInfo, ReportVerdict, } from './types.js';
10
+ export type { ImpactThreshold } from './verdict.js';
11
+ export { countAtOrAbove, IMPACT_RANK, meetsThreshold } from './verdict.js';
12
+ export { HTML_REPORT_FILENAME, PDF_REPORT_FILENAME, REPORT_FILENAME, writeHtmlReport, writePdfReport, writeReport, } from './write.js';
@@ -0,0 +1,8 @@
1
+ export { buildReport } from './build.js';
2
+ export { clausesFor } from './clauses.js';
3
+ export { messagesFor } from './i18n.js';
4
+ export { renderPdfReport } from './pdf.js';
5
+ export { renderHtmlReport } from './render.js';
6
+ export { countAtOrAbove, IMPACT_RANK, meetsThreshold } from './verdict.js';
7
+ export { HTML_REPORT_FILENAME, PDF_REPORT_FILENAME, REPORT_FILENAME, writeHtmlReport, writePdfReport, writeReport, } from './write.js';
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/report/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAEzC,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE1C,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAa/C,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC3E,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,cAAc,EACd,WAAW,GACZ,MAAM,YAAY,CAAC"}
@@ -0,0 +1,12 @@
1
+ import type { Browser } from 'playwright';
2
+ /**
3
+ * Renders the self-contained HTML report (see `render.ts`) to PDF bytes by
4
+ * printing it in headless Chromium.
5
+ *
6
+ * Takes an already-launched browser and never closes it: the caller owns
7
+ * the lifecycle, exactly as `scan` does. Chromium is the only engine
8
+ * Playwright can print with; that is acceptable because it is already the
9
+ * scanner's engine (ADR 0006). The HTML carries its own inline CSS and no
10
+ * external assets, so `setContent` needs no network and no static server.
11
+ */
12
+ export declare function renderPdfReport(html: string, browser: Browser): Promise<Buffer>;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Renders the self-contained HTML report (see `render.ts`) to PDF bytes by
3
+ * printing it in headless Chromium.
4
+ *
5
+ * Takes an already-launched browser and never closes it: the caller owns
6
+ * the lifecycle, exactly as `scan` does. Chromium is the only engine
7
+ * Playwright can print with; that is acceptable because it is already the
8
+ * scanner's engine (ADR 0006). The HTML carries its own inline CSS and no
9
+ * external assets, so `setContent` needs no network and no static server.
10
+ */
11
+ export async function renderPdfReport(html, browser) {
12
+ const page = await browser.newPage();
13
+ try {
14
+ await page.setContent(html, { waitUntil: 'load' });
15
+ return await page.pdf({
16
+ format: 'A4',
17
+ printBackground: true,
18
+ margin: { top: '16mm', bottom: '16mm', left: '14mm', right: '14mm' },
19
+ });
20
+ }
21
+ finally {
22
+ await page.close();
23
+ }
24
+ }
25
+ //# sourceMappingURL=pdf.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pdf.js","sourceRoot":"","sources":["../../src/report/pdf.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,IAAY,EAAE,OAAgB;IAClE,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC;IACrC,IAAI,CAAC;QACH,MAAM,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC,CAAC;QACnD,OAAO,MAAM,IAAI,CAAC,GAAG,CAAC;YACpB,MAAM,EAAE,IAAI;YACZ,eAAe,EAAE,IAAI;YACrB,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE;SACrE,CAAC,CAAC;IACL,CAAC;YAAS,CAAC;QACT,MAAM,IAAI,CAAC,KAAK,EAAE,CAAC;IACrB,CAAC;AACH,CAAC"}
@@ -0,0 +1,8 @@
1
+ import { type Locale } from './i18n.js';
2
+ import type { Report } from './types.js';
3
+ /**
4
+ * Renders a `Report` as a single self-contained HTML document: inline CSS,
5
+ * no JavaScript, no external assets. It is meant to be archived by CI,
6
+ * opened offline, and printed to PDF (a later change). See ADR 0005.
7
+ */
8
+ export declare function renderHtmlReport(report: Report, locale: Locale): string;
@@ -0,0 +1,204 @@
1
+ import { messagesFor } from './i18n.js';
2
+ /**
3
+ * Renders a `Report` as a single self-contained HTML document: inline CSS,
4
+ * no JavaScript, no external assets. It is meant to be archived by CI,
5
+ * opened offline, and printed to PDF (a later change). See ADR 0005.
6
+ */
7
+ export function renderHtmlReport(report, locale) {
8
+ const m = messagesFor(locale);
9
+ const body = [
10
+ header(report, m),
11
+ verdictBanner(report, m),
12
+ summarySection(report, m),
13
+ clausesSection(report, m),
14
+ findingsSection(report, m),
15
+ ].join('\n');
16
+ return [
17
+ '<!doctype html>',
18
+ `<html lang="${locale}">`,
19
+ '<head>',
20
+ '<meta charset="utf-8">',
21
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
22
+ `<title>${esc(m.reportTitle)} — ${esc(report.target.baseUrl)}</title>`,
23
+ `<style>${STYLE}</style>`,
24
+ '</head>',
25
+ '<body>',
26
+ body,
27
+ '</body>',
28
+ '</html>',
29
+ '',
30
+ ].join('\n');
31
+ }
32
+ function header(report, m) {
33
+ const { target, tool, generatedAt } = report;
34
+ return section(`
35
+ <h1>${esc(m.reportTitle)}</h1>
36
+ <dl class="meta">
37
+ ${row(m.target, link(target.baseUrl))}
38
+ ${row(m.generatedAt, esc(generatedAt))}
39
+ ${row(m.tool, esc(`${tool.name} ${tool.version}`))}
40
+ ${row(m.wcagTags, esc(target.wcagTags.join(', ')))}
41
+ ${row(m.threshold, esc(target.failOn))}
42
+ </dl>`);
43
+ }
44
+ function verdictBanner(report, m) {
45
+ const { verdict } = report;
46
+ const status = verdict.passed ? 'pass' : 'fail';
47
+ const headline = verdict.passed
48
+ ? esc(m.verdictPassed)
49
+ : `<strong>${esc(m.verdictFailed)}</strong> — ${verdict.violationsAtOrAboveThreshold} ${esc(m.verdictFailedDetail)}`;
50
+ const unranked = verdict.unrankedViolations > 0
51
+ ? `<p class="unranked">${verdict.unrankedViolations} ${esc(m.unrankedNote)}</p>`
52
+ : '';
53
+ return `<div class="banner ${status}"><p>${headline}</p>${unranked}</div>`;
54
+ }
55
+ function summarySection(report, m) {
56
+ const s = report.summary;
57
+ const impacts = [
58
+ ['critical', m.impactCritical],
59
+ ['serious', m.impactSerious],
60
+ ['moderate', m.impactModerate],
61
+ ['minor', m.impactMinor],
62
+ ['unknown', m.impactUnknown],
63
+ ];
64
+ return section(`
65
+ <h2>${esc(m.summaryHeading)}</h2>
66
+ <dl class="meta">
67
+ ${row(m.pagesScanned, String(s.pagesScanned))}
68
+ ${row(m.pagesFailed, String(s.pagesFailed))}
69
+ ${row(m.pagesWithViolations, String(s.pagesWithViolations))}
70
+ ${row(m.totalViolations, String(s.totalViolations))}
71
+ </dl>
72
+ <h3>${esc(m.byImpact)}</h3>
73
+ <ul class="impacts">
74
+ ${impacts
75
+ .map(([key, label]) => `<li><span>${esc(label)}</span><b>${s.violationsByImpact[key]}</b></li>`)
76
+ .join('\n ')}
77
+ </ul>`);
78
+ }
79
+ function clausesSection(report, m) {
80
+ const { breachedSuccessCriteria, breachedEn301549Clauses } = report.summary;
81
+ if (breachedSuccessCriteria.length === 0 && breachedEn301549Clauses.length === 0) {
82
+ return section(`
83
+ <h2>${esc(m.clausesHeading)}</h2>
84
+ <p>${esc(m.noClausesBreached)}</p>`);
85
+ }
86
+ return section(`
87
+ <h2>${esc(m.clausesHeading)}</h2>
88
+ <p class="clause-group"><span>${esc(m.breachedSuccessCriteria)}</span>${chips(breachedSuccessCriteria)}</p>
89
+ <p class="clause-group"><span>${esc(m.breachedEn301549)}</span>${chips(breachedEn301549Clauses)}</p>
90
+ <p class="legal">${esc(m.clausesLegalNote)}</p>`);
91
+ }
92
+ function findingsSection(report, m) {
93
+ const pages = report.pages.map((page) => pageBlock(page, m)).join('\n');
94
+ return section(`
95
+ <h2>${esc(m.findingsHeading)}</h2>
96
+ ${pages}`);
97
+ }
98
+ function pageBlock(page, m) {
99
+ if (page.status === 'error') {
100
+ return `<article class="page failed">
101
+ <h3>${link(page.url)}</h3>
102
+ <p class="scan-failed">${esc(m.pageScanFailed)}: <code>${esc(page.error)}</code></p>
103
+ </article>`;
104
+ }
105
+ const parts = [`<h3>${link(page.url)}</h3>`];
106
+ if (page.violations.length === 0 && page.incomplete.length === 0) {
107
+ parts.push(`<p class="ok">${esc(m.noViolations)}</p>`);
108
+ }
109
+ parts.push(...page.violations.map((f) => findingBlock(f, m)));
110
+ if (page.incomplete.length > 0) {
111
+ parts.push(`<h4>${esc(m.incompleteHeading)}</h4>`);
112
+ parts.push(`<p class="note">${esc(m.incompleteNote)}</p>`);
113
+ parts.push(...page.incomplete.map((f) => findingBlock(f, m)));
114
+ }
115
+ return `<article class="page">${parts.join('\n')}</article>`;
116
+ }
117
+ function findingBlock(f, m) {
118
+ return `<div class="finding impact-${esc(impactClass(f.impact))}">
119
+ <p class="finding-head">
120
+ <code>${esc(f.ruleId)}</code>
121
+ <span class="badge">${esc(m.impact)}: ${esc(f.impact ?? m.impactUnknown)}</span>
122
+ </p>
123
+ <p class="finding-desc">${esc(f.help)}</p>
124
+ <dl class="meta">
125
+ ${row(m.successCriteria, f.clauses.wcagSc.length > 0 ? esc(f.clauses.wcagSc.join(', ')) : esc(m.none))}
126
+ ${row(m.en301549Clauses, f.clauses.en301549.length > 0 ? esc(f.clauses.en301549.join(', ')) : esc(m.none))}
127
+ ${row(m.help, `<a href="${esc(f.helpUrl)}">${esc(f.helpUrl)}</a>`)}
128
+ ${row(m.affectedElements, String(f.nodes.length))}
129
+ </dl>
130
+ ${f.nodes
131
+ .map((n) => `<pre class="node"><code>${esc(n.html)}</code></pre>${n.failureSummary ? `<p class="node-summary">${esc(n.failureSummary)}</p>` : ''}`)
132
+ .join('\n')}
133
+ </div>`;
134
+ }
135
+ function impactClass(impact) {
136
+ return impact ?? 'unknown';
137
+ }
138
+ function section(inner) {
139
+ return `<section>${inner}\n</section>`;
140
+ }
141
+ function row(label, valueHtml) {
142
+ return `<div><dt>${esc(label)}</dt><dd>${valueHtml}</dd></div>`;
143
+ }
144
+ function chips(values) {
145
+ return values.map((v) => `<code class="chip">${esc(v)}</code>`).join(' ');
146
+ }
147
+ function link(url) {
148
+ return `<a href="${esc(url)}">${esc(url)}</a>`;
149
+ }
150
+ /** HTML-escapes text for use in element content and double-quoted attributes. */
151
+ function esc(value) {
152
+ return value
153
+ .replace(/&/g, '&amp;')
154
+ .replace(/</g, '&lt;')
155
+ .replace(/>/g, '&gt;')
156
+ .replace(/"/g, '&quot;')
157
+ .replace(/'/g, '&#39;');
158
+ }
159
+ const STYLE = `
160
+ :root { color-scheme: light dark; }
161
+ * { box-sizing: border-box; }
162
+ body { margin: 0 auto; max-width: 60rem; padding: 2rem 1.25rem;
163
+ font: 16px/1.55 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
164
+ h1 { font-size: 1.6rem; margin: 0 0 1rem; }
165
+ h2 { font-size: 1.25rem; margin: 2rem 0 0.75rem; border-bottom: 1px solid; padding-bottom: 0.25rem; }
166
+ h3 { font-size: 1.05rem; margin: 1.5rem 0 0.5rem; word-break: break-all; }
167
+ h4 { font-size: 0.95rem; margin: 1rem 0 0.35rem; }
168
+ a { color: inherit; }
169
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
170
+ dl.meta { display: grid; grid-template-columns: max-content 1fr; gap: 0.15rem 1rem; margin: 0.5rem 0; }
171
+ dl.meta > div { display: contents; }
172
+ dl.meta dt { font-weight: 600; }
173
+ dl.meta dd { margin: 0; word-break: break-word; }
174
+ /* Tints, not solid fills, so the banner reads on a light or a dark ground;
175
+ the saturated border and the bold headline carry the pass/fail signal. */
176
+ .banner { margin: 1rem 0; padding: 0.75rem 1rem; border-radius: 6px; border: 1px solid; }
177
+ .banner.pass { background: rgba(30, 126, 52, 0.14); border-color: #1e7e34; }
178
+ .banner.fail { background: rgba(176, 42, 55, 0.16); border-color: #b02a37; }
179
+ .banner p { margin: 0.15rem 0; }
180
+ .banner .unranked { font-size: 0.9rem; }
181
+ ul.impacts { list-style: none; padding: 0; margin: 0.35rem 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }
182
+ ul.impacts li { border: 1px solid; border-radius: 4px; padding: 0.2rem 0.55rem; }
183
+ ul.impacts li span { margin-right: 0.4rem; }
184
+ .clause-group span { font-weight: 600; margin-right: 0.5rem; }
185
+ code.chip { border: 1px solid; border-radius: 4px; padding: 0.1rem 0.4rem; display: inline-block; margin: 0.1rem 0; }
186
+ .legal { font-size: 0.9rem; opacity: 0.85; }
187
+ article.page { margin: 0.5rem 0 1.5rem; }
188
+ .finding { border-left: 4px solid #b02a37; padding: 0.5rem 0.9rem; margin: 0.75rem 0; background: rgba(127,127,127,0.06); }
189
+ .finding.impact-moderate, .finding.impact-minor { border-left-color: #b8860b; }
190
+ .finding.impact-unknown { border-left-color: #6c757d; }
191
+ .finding-head { margin: 0 0 0.35rem; display: flex; gap: 0.6rem; align-items: baseline; flex-wrap: wrap; }
192
+ .badge { font-size: 0.8rem; border: 1px solid; border-radius: 4px; padding: 0.05rem 0.4rem; }
193
+ .finding-desc { margin: 0.25rem 0; }
194
+ pre.node { overflow-x: auto; padding: 0.5rem 0.7rem; background: rgba(127,127,127,0.12); border-radius: 4px; }
195
+ .node-summary { white-space: pre-wrap; font-size: 0.9rem; margin: 0.15rem 0 0.6rem; }
196
+ .scan-failed code { color: #b02a37; }
197
+ @media print {
198
+ body { max-width: none; }
199
+ .finding { break-inside: avoid; }
200
+ /* No horizontal scroll on paper: wrap the evidence markup instead of clipping it. */
201
+ pre.node { white-space: pre-wrap; overflow-wrap: anywhere; }
202
+ }
203
+ `;
204
+ //# sourceMappingURL=render.js.map