@surea11y/core 1.6.0 → 1.8.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 (120) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +179 -90
  3. package/docs/ACT_RULE_MAPPING.md +10 -8
  4. package/docs/API_STABILITY.md +67 -6
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +162 -2
  8. package/docs/EARL.md +100 -0
  9. package/docs/ENGINE_OPTIONS.md +109 -5
  10. package/docs/I18N.md +62 -20
  11. package/docs/INTEGRATION.md +4 -2
  12. package/docs/JUNIT.md +73 -0
  13. package/docs/LIMITATIONS.md +4 -1
  14. package/docs/OUTPUT_SCHEMA.md +62 -11
  15. package/docs/POLICY.md +1 -1
  16. package/docs/REPORT.md +7 -2
  17. package/docs/RULE_AUTHORING.md +83 -17
  18. package/docs/RULE_CATALOG.md +212 -139
  19. package/docs/RULE_EXAMPLES.md +2189 -0
  20. package/docs/RULE_HELPERS.md +390 -0
  21. package/docs/RULE_TAXONOMY.md +27 -6
  22. package/docs/SARIF.md +23 -3
  23. package/docs/WCAG_CONFORMANCE.md +64 -3
  24. package/package.json +41 -12
  25. package/profiles/index.js +14 -0
  26. package/src/checks/automatic/area-alt-present.js +87 -31
  27. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  28. package/src/checks/automatic/aria-allowed-role.js +32 -23
  29. package/src/checks/automatic/aria-braille-equivalent.js +43 -17
  30. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  31. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  32. package/src/checks/automatic/aria-hidden-body.js +1 -1
  33. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  34. package/src/checks/automatic/aria-prohibited-attr.js +22 -4
  35. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  36. package/src/checks/automatic/aria-required-attr.js +88 -12
  37. package/src/checks/automatic/aria-required-children.js +33 -16
  38. package/src/checks/automatic/aria-required-parent.js +32 -6
  39. package/src/checks/automatic/aria-role-name-present.js +20 -3
  40. package/src/checks/automatic/aria-roles-valid.js +52 -21
  41. package/src/checks/automatic/aria-valid-attr-value.js +89 -24
  42. package/src/checks/automatic/aria-valid-attr.js +14 -9
  43. package/src/checks/automatic/autocomplete-valid.js +152 -26
  44. package/src/checks/automatic/avoid-inline-spacing.js +207 -15
  45. package/src/checks/automatic/button-name-present.js +2 -1
  46. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  47. package/src/checks/automatic/combobox-name-present.js +34 -51
  48. package/src/checks/automatic/contrast-computable.js +45 -4
  49. package/src/checks/automatic/contrast-enhanced.js +16 -4
  50. package/src/checks/automatic/contrast-minimum.js +57 -11
  51. package/src/checks/automatic/css-orientation-lock.js +171 -12
  52. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  53. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  54. package/src/checks/automatic/dialog-name-present.js +28 -9
  55. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  56. package/src/checks/automatic/duplicate-id.js +19 -10
  57. package/src/checks/automatic/form-control-single-label.js +9 -0
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +12 -4
  60. package/src/checks/automatic/iframe-title-unique.js +36 -81
  61. package/src/checks/automatic/input-image-alt-present.js +32 -20
  62. package/src/checks/automatic/label-in-name.js +78 -69
  63. package/src/checks/automatic/language-page-present.js +12 -6
  64. package/src/checks/automatic/link-in-text-block.js +512 -44
  65. package/src/checks/automatic/link-name-present.js +13 -5
  66. package/src/checks/automatic/list-children-valid.js +18 -1
  67. package/src/checks/automatic/listbox-name-present.js +19 -49
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  69. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  70. package/src/checks/automatic/page-title-present.js +16 -4
  71. package/src/checks/automatic/progressbar-name-present.js +11 -1
  72. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
  73. package/src/checks/automatic/searchbox-name-present.js +32 -49
  74. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  75. package/src/checks/automatic/slider-name-present.js +38 -52
  76. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  77. package/src/checks/automatic/target-size-minimum.js +84 -16
  78. package/src/checks/automatic/td-has-header.js +60 -23
  79. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  80. package/src/checks/automatic/textbox-name-present.js +32 -49
  81. package/src/checks/automatic/valid-lang.js +15 -10
  82. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  83. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  84. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  85. package/src/checks/manual/css-hidden-focus.js +215 -7
  86. package/src/checks/manual/form-control-label-quality-manual.js +243 -29
  87. package/src/checks/manual/heading-order-manual.js +9 -1
  88. package/src/checks/manual/heading-quality-manual.js +143 -9
  89. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  90. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  91. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  92. package/src/checks/manual/link-name-quality-manual.js +130 -4
  93. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  94. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  95. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  96. package/src/checks/manual/p-as-heading-manual.js +89 -44
  97. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  98. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  99. package/src/checks/manual/skip-link-manual.js +42 -14
  100. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  101. package/src/checks/manual/video-caption-manual.js +47 -24
  102. package/src/checks/manual-review.js +0 -4
  103. package/src/core.js +18061 -46194
  104. package/src/coverage/en301549-map.js +187 -0
  105. package/src/coverage/standards.js +279 -0
  106. package/src/coverage/wcag-facets.js +1119 -0
  107. package/src/coverage/wcag-version-map.js +101 -0
  108. package/src/earl.js +144 -0
  109. package/src/en301549.js +33 -0
  110. package/src/junit.js +321 -0
  111. package/src/profile-kit.js +163 -0
  112. package/src/report.js +343 -74
  113. package/src/sarif.js +56 -5
  114. package/src/wcag.js +105 -0
  115. package/surea11y.browser.js +11 -41039
  116. package/surea11y.i18n.de.js +2 -21
  117. package/surea11y.i18n.es.js +2 -21
  118. package/surea11y.i18n.fr.js +2 -21
  119. package/surea11y.i18n.ja.js +3 -0
  120. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -0,0 +1,187 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * EN 301 549 chapter 9 (Web) clause for each WCAG Success Criterion.
7
+ *
8
+ * PURPOSE
9
+ * -------
10
+ * Chapter 9 of EN 301 549 restates the WCAG Level A and AA Success Criteria as
11
+ * clauses numbered `9.` + the criterion's own number: WCAG 1.4.3 is clause
12
+ * 9.1.4.3. Which criteria it restates depends on the version of the standard,
13
+ * so the table is keyed by version and lists every clause explicitly rather
14
+ * than deriving `9.` + sc for any criterion:
15
+ *
16
+ * - V3.2.1 (2021-03) restates WCAG 2.1 A and AA, including 9.4.1.1 Parsing.
17
+ * - V4.1.1 (2026-09) restates WCAG 2.2 A and AA: it adds 2.4.11, 2.5.7, 2.5.8,
18
+ * 3.2.6, 3.3.7 and 3.3.8, and leaves 9.4.1.1 void.
19
+ *
20
+ * Clauses the standard marks "Void" (the AAA criteria, and 4.1.1 in V4.1.1)
21
+ * have no row. Titles are the standard's own, so they differ between versions
22
+ * where the standard's wording does: V4.1.1 says "Subtitles" where V3.2.1 says
23
+ * "Captions", and V3.2.1 capitalises "Focus Order".
24
+ *
25
+ * This states a correspondence between two published documents and nothing
26
+ * more. Which version a given law requires, and from when, is not an engine
27
+ * question.
28
+ *
29
+ * SOURCE: ETSI EN 301 549 V3.2.1 (2021-03) and V4.1.1 (2026-09), clause 9.
30
+ * Chapters 10 (non-web documents) and 11 (software) are out of scope: the
31
+ * engine tests web content.
32
+ */
33
+
34
+ const EN301549_VERSIONS = [
35
+ { version: 'V3.2.1', published: '2021-03', wcagVersion: '2.1' },
36
+ { version: 'V4.1.1', published: '2026-09', wcagVersion: '2.2' }
37
+ ];
38
+
39
+ const EN301549_CLAUSES = {
40
+ 'V3.2.1': {
41
+ '1.1.1': { clause: '9.1.1.1', title: 'Non-text content' },
42
+ '1.2.1': { clause: '9.1.2.1', title: 'Audio-only and video-only (pre-recorded)' },
43
+ '1.2.2': { clause: '9.1.2.2', title: 'Captions (pre-recorded)' },
44
+ '1.2.3': { clause: '9.1.2.3', title: 'Audio description or media alternative (pre-recorded)' },
45
+ '1.2.4': { clause: '9.1.2.4', title: 'Captions (live)' },
46
+ '1.2.5': { clause: '9.1.2.5', title: 'Audio description (pre-recorded)' },
47
+ '1.3.1': { clause: '9.1.3.1', title: 'Info and relationships' },
48
+ '1.3.2': { clause: '9.1.3.2', title: 'Meaningful sequence' },
49
+ '1.3.3': { clause: '9.1.3.3', title: 'Sensory characteristics' },
50
+ '1.3.4': { clause: '9.1.3.4', title: 'Orientation' },
51
+ '1.3.5': { clause: '9.1.3.5', title: 'Identify input purpose' },
52
+ '1.4.1': { clause: '9.1.4.1', title: 'Use of colour' },
53
+ '1.4.2': { clause: '9.1.4.2', title: 'Audio control' },
54
+ '1.4.3': { clause: '9.1.4.3', title: 'Contrast (minimum)' },
55
+ '1.4.4': { clause: '9.1.4.4', title: 'Resize text' },
56
+ '1.4.5': { clause: '9.1.4.5', title: 'Images of text' },
57
+ '1.4.10': { clause: '9.1.4.10', title: 'Reflow' },
58
+ '1.4.11': { clause: '9.1.4.11', title: 'Non-text contrast' },
59
+ '1.4.12': { clause: '9.1.4.12', title: 'Text spacing' },
60
+ '1.4.13': { clause: '9.1.4.13', title: 'Content on hover or focus' },
61
+ '2.1.1': { clause: '9.2.1.1', title: 'Keyboard' },
62
+ '2.1.2': { clause: '9.2.1.2', title: 'No keyboard trap' },
63
+ '2.1.4': { clause: '9.2.1.4', title: 'Character key shortcuts' },
64
+ '2.2.1': { clause: '9.2.2.1', title: 'Timing adjustable' },
65
+ '2.2.2': { clause: '9.2.2.2', title: 'Pause, stop, hide' },
66
+ '2.3.1': { clause: '9.2.3.1', title: 'Three flashes or below threshold' },
67
+ '2.4.1': { clause: '9.2.4.1', title: 'Bypass blocks' },
68
+ '2.4.2': { clause: '9.2.4.2', title: 'Page titled' },
69
+ '2.4.3': { clause: '9.2.4.3', title: 'Focus Order' },
70
+ '2.4.4': { clause: '9.2.4.4', title: 'Link purpose (in context)' },
71
+ '2.4.5': { clause: '9.2.4.5', title: 'Multiple ways' },
72
+ '2.4.6': { clause: '9.2.4.6', title: 'Headings and labels' },
73
+ '2.4.7': { clause: '9.2.4.7', title: 'Focus visible' },
74
+ '2.5.1': { clause: '9.2.5.1', title: 'Pointer gestures' },
75
+ '2.5.2': { clause: '9.2.5.2', title: 'Pointer cancellation' },
76
+ '2.5.3': { clause: '9.2.5.3', title: 'Label in name' },
77
+ '2.5.4': { clause: '9.2.5.4', title: 'Motion actuation' },
78
+ '3.1.1': { clause: '9.3.1.1', title: 'Language of page' },
79
+ '3.1.2': { clause: '9.3.1.2', title: 'Language of parts' },
80
+ '3.2.1': { clause: '9.3.2.1', title: 'On focus' },
81
+ '3.2.2': { clause: '9.3.2.2', title: 'On input' },
82
+ '3.2.3': { clause: '9.3.2.3', title: 'Consistent navigation' },
83
+ '3.2.4': { clause: '9.3.2.4', title: 'Consistent identification' },
84
+ '3.3.1': { clause: '9.3.3.1', title: 'Error identification' },
85
+ '3.3.2': { clause: '9.3.3.2', title: 'Labels or instructions' },
86
+ '3.3.3': { clause: '9.3.3.3', title: 'Error suggestion' },
87
+ '3.3.4': { clause: '9.3.3.4', title: 'Error prevention (legal, financial, data)' },
88
+ '4.1.1': { clause: '9.4.1.1', title: 'Parsing' },
89
+ '4.1.2': { clause: '9.4.1.2', title: 'Name, role, value' },
90
+ '4.1.3': { clause: '9.4.1.3', title: 'Status messages' }
91
+ },
92
+ 'V4.1.1': {
93
+ '1.1.1': { clause: '9.1.1.1', title: 'Non-text content' },
94
+ '1.2.1': { clause: '9.1.2.1', title: 'Audio-only and video-only (pre-recorded)' },
95
+ '1.2.2': { clause: '9.1.2.2', title: 'Subtitles (pre-recorded)' },
96
+ '1.2.3': { clause: '9.1.2.3', title: 'Audio description or media alternative (pre-recorded)' },
97
+ '1.2.4': { clause: '9.1.2.4', title: 'Subtitles (live)' },
98
+ '1.2.5': { clause: '9.1.2.5', title: 'Audio description (pre-recorded)' },
99
+ '1.3.1': { clause: '9.1.3.1', title: 'Info and relationships' },
100
+ '1.3.2': { clause: '9.1.3.2', title: 'Meaningful sequence' },
101
+ '1.3.3': { clause: '9.1.3.3', title: 'Sensory characteristics' },
102
+ '1.3.4': { clause: '9.1.3.4', title: 'Orientation' },
103
+ '1.3.5': { clause: '9.1.3.5', title: 'Identify input purpose' },
104
+ '1.4.1': { clause: '9.1.4.1', title: 'Use of colour' },
105
+ '1.4.2': { clause: '9.1.4.2', title: 'Audio control' },
106
+ '1.4.3': { clause: '9.1.4.3', title: 'Contrast (minimum)' },
107
+ '1.4.4': { clause: '9.1.4.4', title: 'Resize text' },
108
+ '1.4.5': { clause: '9.1.4.5', title: 'Images of text' },
109
+ '1.4.10': { clause: '9.1.4.10', title: 'Reflow' },
110
+ '1.4.11': { clause: '9.1.4.11', title: 'Non-text contrast' },
111
+ '1.4.12': { clause: '9.1.4.12', title: 'Text spacing' },
112
+ '1.4.13': { clause: '9.1.4.13', title: 'Content on hover or focus' },
113
+ '2.1.1': { clause: '9.2.1.1', title: 'Keyboard' },
114
+ '2.1.2': { clause: '9.2.1.2', title: 'No keyboard trap' },
115
+ '2.1.4': { clause: '9.2.1.4', title: 'Character key shortcuts' },
116
+ '2.2.1': { clause: '9.2.2.1', title: 'Timing adjustable' },
117
+ '2.2.2': { clause: '9.2.2.2', title: 'Pause, stop, hide' },
118
+ '2.3.1': { clause: '9.2.3.1', title: 'Three flashes or below threshold' },
119
+ '2.4.1': { clause: '9.2.4.1', title: 'Bypass blocks' },
120
+ '2.4.2': { clause: '9.2.4.2', title: 'Page titled' },
121
+ '2.4.3': { clause: '9.2.4.3', title: 'Focus order' },
122
+ '2.4.4': { clause: '9.2.4.4', title: 'Link purpose (in context)' },
123
+ '2.4.5': { clause: '9.2.4.5', title: 'Multiple ways' },
124
+ '2.4.6': { clause: '9.2.4.6', title: 'Headings and labels' },
125
+ '2.4.7': { clause: '9.2.4.7', title: 'Focus visible' },
126
+ '2.4.11': { clause: '9.2.4.11', title: 'Focus not obscured (minimum)' },
127
+ '2.5.1': { clause: '9.2.5.1', title: 'Pointer gestures' },
128
+ '2.5.2': { clause: '9.2.5.2', title: 'Pointer cancellation' },
129
+ '2.5.3': { clause: '9.2.5.3', title: 'Label in name' },
130
+ '2.5.4': { clause: '9.2.5.4', title: 'Motion actuation' },
131
+ '2.5.7': { clause: '9.2.5.7', title: 'Dragging movements' },
132
+ '2.5.8': { clause: '9.2.5.8', title: 'Target size (minimum)' },
133
+ '3.1.1': { clause: '9.3.1.1', title: 'Language of page' },
134
+ '3.1.2': { clause: '9.3.1.2', title: 'Language of parts' },
135
+ '3.2.1': { clause: '9.3.2.1', title: 'On focus' },
136
+ '3.2.2': { clause: '9.3.2.2', title: 'On input' },
137
+ '3.2.3': { clause: '9.3.2.3', title: 'Consistent navigation' },
138
+ '3.2.4': { clause: '9.3.2.4', title: 'Consistent identification' },
139
+ '3.2.6': { clause: '9.3.2.6', title: 'Consistent help' },
140
+ '3.3.1': { clause: '9.3.3.1', title: 'Error identification' },
141
+ '3.3.2': { clause: '9.3.3.2', title: 'Labels or instructions' },
142
+ '3.3.3': { clause: '9.3.3.3', title: 'Error suggestion' },
143
+ '3.3.4': { clause: '9.3.3.4', title: 'Error prevention (legal, financial, data)' },
144
+ '3.3.7': { clause: '9.3.3.7', title: 'Redundant entry' },
145
+ '3.3.8': { clause: '9.3.3.8', title: 'Accessible authentication (minimum)' },
146
+ '4.1.2': { clause: '9.4.1.2', title: 'Name, role, value' },
147
+ '4.1.3': { clause: '9.4.1.3', title: 'Status messages' }
148
+ }
149
+ };
150
+
151
+ // Every EN 301 549 clause that restates this Success Criterion, one entry per
152
+ // version that includes it, oldest version first. An AAA criterion, or one no
153
+ // listed version restates, gets an empty list.
154
+ function en301549ClausesForSc(sc) {
155
+ const s = String(sc || '').trim();
156
+ const out = [];
157
+ for (const { version } of EN301549_VERSIONS) {
158
+ const row = EN301549_CLAUSES[version][s];
159
+ if (row) out.push({ version, clause: row.clause, title: row.title });
160
+ }
161
+ return out;
162
+ }
163
+
164
+ // The EN 301 549 entries for a list of WCAG criteria, shaped as
165
+ // `normativeMappings` entries, in criterion order and oldest version first.
166
+ function en301549MappingsForScs(scs) {
167
+ const out = [];
168
+ for (const sc of Array.isArray(scs) ? scs : []) {
169
+ for (const c of en301549ClausesForSc(sc)) {
170
+ out.push({
171
+ standard: 'EN 301 549',
172
+ version: c.version,
173
+ requirement: c.clause,
174
+ title: c.title,
175
+ wcagSc: [String(sc).trim()]
176
+ });
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+
182
+ module.exports = {
183
+ EN301549_VERSIONS,
184
+ EN301549_CLAUSES,
185
+ en301549ClausesForSc,
186
+ en301549MappingsForScs
187
+ };
@@ -0,0 +1,279 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * The standards besides WCAG that a result's `meta.normativeMappings` can name.
7
+ *
8
+ * PURPOSE
9
+ * -------
10
+ * Every rule is written against WCAG. Other standards restate or reorganise
11
+ * those criteria under their own numbers, and a caller auditing against one of
12
+ * them wants its numbers on each result. This registry is the one place that
13
+ * knows which standards exist; the build, the runner's `engineOptions.mappings`
14
+ * and the reporters all read it. A standard that renumbers WCAG (EN 301 549)
15
+ * is a table of its own plus one entry here. A standard with verdicts of its
16
+ * own is a profile under profiles/, which brings its entry, tables,
17
+ * rules and tests with it; the registry appends the entries of
18
+ * profiles/index.js after its own.
19
+ *
20
+ * ENTRY SHAPE
21
+ * -----------
22
+ * - key: the name a caller writes in `engineOptions.mappings` ('en301549'),
23
+ * also the SARIF tag prefix and the JUnit property name. Lowercase, no ':'.
24
+ * - standard: the `standard` its entries carry ('EN 301 549'), also the label
25
+ * the HTML report shows.
26
+ * - versions: the versions it has a table for, oldest first. An entry's
27
+ * `version` is always one of these.
28
+ * - profiles: optional named conformance targets it brings, each the WCAG
29
+ * version-origin tags it runs and the version of the standard it targets.
30
+ * A profile switches its own version's mappings on. With `mappedRules`, it
31
+ * also runs every rule this standard maps for that version, which matters
32
+ * when the standard checks things WCAG leaves to best practice (heading
33
+ * hierarchy, say): those rules carry no WCAG tag to select them by.
34
+ * A profile may also have `exclude: { rules, criteria }`: rules it does not
35
+ * run, and WCAG criteria it waives (their WCAG rollups go, and so does a rule
36
+ * whose every criterion is waived). See profileExclusions below.
37
+ * - mappingsFor({ id, wcagSc, checksIds }): the entries for a rule or a
38
+ * composite, given its id and the WCAG criteria it maps to; a composite also
39
+ * passes `checksIds`, its rules. Each entry is
40
+ * { standard, version, requirement, title, wcagSc, ...extra }, where
41
+ * `wcagSc` lists the WCAG criteria the requirement corresponds to, so a
42
+ * per-criterion view can tell which entries belong under which criterion.
43
+ * It is empty for a requirement WCAG does not make; a per-criterion view
44
+ * then shows the entry under every criterion of the rule that names it. A
45
+ * standard may add fields of its own (a `criterion`, say).
46
+ * - ruleTag: optional; a tag that marks rules checking this standard's own
47
+ * requirements, ones WCAG does not make (a doctype or presentational
48
+ * attributes, say). A rule carrying it is opt-in: it runs only when a
49
+ * selection asks for it by that tag or by id, typically through one of this
50
+ * standard's profiles, so a scan that targets WCAG never reports a failure
51
+ * WCAG does not define. Must not be a WCAG tag.
52
+ * - ruleMapped: optional; true when the standard's entries come from each
53
+ * rule rather than from the WCAG criterion. A rollup then names only
54
+ * the entries of the rules that produced its outcome; a standard that
55
+ * restates the criterion itself (EN 301 549) names the same entry
56
+ * whichever rule decided.
57
+ * - restatedPrefixes: optional, with ruleMapped; the prefixes of requirement
58
+ * ids that restate a WCAG criterion one for one (['A.'] for a standard whose
59
+ * part A renumbers WCAG). A rollup names those entries whatever rule decided
60
+ * its outcome, as it names a standard's that is not rule-mapped (EN 301
61
+ * 549), and the others only for the rules that decided it.
62
+ * - composites(): optional; rollups of the standard's own, shaped like the
63
+ * entries of src/catalogs/composites.wcag.js and carrying the standard's
64
+ * ruleTag in meta.tags so only a run that asks for the standard produces
65
+ * them (one per requirement, say).
66
+ * - validate(rules): optional; given every rule ([{ ruleId, wcagSc }]),
67
+ * returns a list of problems with the standard's own tables. The build
68
+ * fails on any.
69
+ * - report: optional; how the HTML report shows the standard's own rollups.
70
+ * `noteKey` is a dictionary key for the note above their table, and
71
+ * `titleLang` the language their titles are written in when that is not
72
+ * the scan's (titles written in French whatever the locale, say).
73
+ *
74
+ * Mappings state a correspondence between published documents, nothing more:
75
+ * which standard or version applies to whom is not an engine question.
76
+ */
77
+
78
+ const { EN301549_VERSIONS, en301549MappingsForScs } = require('./en301549-map');
79
+ const { wcagTags } = require('../wcag');
80
+ const PROFILES = require('../../profiles');
81
+
82
+ // The WCAG A and AA tags of the WCAG version an EN 301 549 version is built on.
83
+ const en301549Tags = (version) =>
84
+ wcagTags(EN301549_VERSIONS.find((v) => v.version === version).wcagVersion);
85
+
86
+ const NORMATIVE_STANDARDS = [
87
+ {
88
+ key: 'en301549',
89
+ standard: 'EN 301 549',
90
+ versions: EN301549_VERSIONS.map((v) => v.version),
91
+ // Chapter 9 restates WCAG A and AA: V4.1.1 is built on 2.2, V3.2.1 on 2.1
92
+ // (which keeps 4.1.1 Parsing).
93
+ profiles: {
94
+ 'en301549-v4.1.1': { version: 'V4.1.1', tags: en301549Tags('V4.1.1') },
95
+ 'en301549-v3.2.1': { version: 'V3.2.1', tags: en301549Tags('V3.2.1') }
96
+ },
97
+ mappingsFor: ({ wcagSc }) => en301549MappingsForScs(wcagSc)
98
+ },
99
+ // The standards that bring their own verdicts, each from its profile
100
+ // (profiles/).
101
+ ...PROFILES.map((p) => p.standard)
102
+ ];
103
+
104
+ // The entries every registered standard gives a rule or composite, in
105
+ // registry order.
106
+ function standardMappingsFor({ id, wcagSc, checksIds }) {
107
+ const out = [];
108
+ for (const s of NORMATIVE_STANDARDS) out.push(...s.mappingsFor({ id, wcagSc, checksIds }));
109
+ return out;
110
+ }
111
+
112
+ // For each profile with `mappedRules`, the ids of the rules its standard maps
113
+ // for the profile's version, given every rule ([{ ruleId, wcagSc }]).
114
+ function profileRuleIds(rules) {
115
+ const out = {};
116
+ for (const s of NORMATIVE_STANDARDS) {
117
+ for (const [name, p] of Object.entries(s.profiles || {})) {
118
+ if (!p.mappedRules) continue;
119
+ out[name] = rules
120
+ .filter((r) =>
121
+ s.mappingsFor({ id: r.ruleId, wcagSc: r.wcagSc }).some((m) => m.version === p.version)
122
+ )
123
+ .map((r) => r.ruleId)
124
+ .sort();
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+
130
+ // Every registered standard's own rollups, in registry order.
131
+ function standardComposites() {
132
+ return NORMATIVE_STANDARDS.flatMap((s) => (typeof s.composites === 'function' ? s.composites() : []));
133
+ }
134
+
135
+ // Every registered standard's problems with its own tables, given every rule.
136
+ function validateStandards(rules) {
137
+ return NORMATIVE_STANDARDS.flatMap((s) =>
138
+ typeof s.validate === 'function' ? s.validate(rules).map((p) => `${s.key}: ${p}`) : []
139
+ );
140
+ }
141
+
142
+ // A rule's `normativeMappings` with every registered standard's entries for it
143
+ // appended. An entry the rule already declares is not repeated. Only WCAG
144
+ // criteria are followed: an Understanding-document entry, or one for another
145
+ // standard, shares a `requirement` with a criterion without being one.
146
+ function withStandardMappings(normativeMappings, id) {
147
+ const list = Array.isArray(normativeMappings) ? normativeMappings : [];
148
+ const wcagSc = list
149
+ .filter((m) => m && m.requirement && (m.standard == null || m.standard === 'WCAG') && !m.type)
150
+ .map((m) => String(m.requirement).trim());
151
+ const key = (m) => `${m.standard}|${m.version}|${m.requirement}`;
152
+ const seen = new Set(list.filter(Boolean).map(key));
153
+ const added = standardMappingsFor({ id, wcagSc }).filter((m) => {
154
+ if (seen.has(key(m))) return false;
155
+ seen.add(key(m));
156
+ return true;
157
+ });
158
+ return list.concat(added);
159
+ }
160
+
161
+ // The registry as plain data, for what cannot call functions: the generated
162
+ // core (inlined as JSON) and the reporters.
163
+ function standardsData() {
164
+ return NORMATIVE_STANDARDS.map((s) => ({
165
+ key: s.key,
166
+ standard: s.standard,
167
+ versions: s.versions.slice(),
168
+ profiles: Object.fromEntries(
169
+ Object.entries(s.profiles || {}).map(([name, p]) => [
170
+ name,
171
+ {
172
+ version: p.version,
173
+ tags: p.tags.slice(),
174
+ ...(p.mappedRules ? { mappedRules: true } : {}),
175
+ ...(p.exclude ? { exclude: normalizeExclude(p.exclude) } : {})
176
+ }
177
+ ])
178
+ ),
179
+ ...(s.ruleTag ? { ruleTag: s.ruleTag } : {}),
180
+ ...(s.ruleMapped ? { ruleMapped: true } : {}),
181
+ ...(s.ruleMapped && Array.isArray(s.restatedPrefixes) && s.restatedPrefixes.length
182
+ ? { restatedPrefixes: s.restatedPrefixes.map(String) }
183
+ : {})
184
+ }));
185
+ }
186
+
187
+ // A profile's `exclude`, as lists: { rules: [...], criteria: [...] }.
188
+ function normalizeExclude(exclude) {
189
+ const list = (v) => (Array.isArray(v) ? v.map((x) => String(x).trim()).filter(Boolean) : []);
190
+ return { rules: list(exclude && exclude.rules), criteria: list(exclude && exclude.criteria) };
191
+ }
192
+
193
+ // What each profile with `exclude` leaves out, given every rule
194
+ // ([{ ruleId, wcagSc }]) and every WCAG rollup ([{ id, wcagSc }]):
195
+ // { [profile]: { rules, criteria, ruleIds, rollupIds } }. `rules` and
196
+ // `criteria` are what it declared; `ruleIds` the rules it does not run (the
197
+ // ones it names, and those whose every WCAG criterion it waives) and
198
+ // `rollupIds` the WCAG rollups of the criteria it waives. A rule that also
199
+ // checks a criterion it keeps still runs. Throws, naming every problem, on an
200
+ // unknown rule or criterion, or on a rule the profile excludes and also maps.
201
+ function profileExclusions(rules, rollups) {
202
+ const known = new Set(rules.map((r) => r.ruleId));
203
+ const knownSc = new Set(rules.flatMap((r) => r.wcagSc || []).concat(rollups.flatMap((r) => r.wcagSc || [])));
204
+ const mapped = profileRuleIds(rules);
205
+ const problems = [];
206
+ const out = {};
207
+ for (const s of NORMATIVE_STANDARDS) {
208
+ for (const [name, p] of Object.entries(s.profiles || {})) {
209
+ if (!p.exclude) continue;
210
+ const { rules: ids, criteria } = normalizeExclude(p.exclude);
211
+ for (const id of ids) {
212
+ if (!known.has(id)) problems.push(`${name}: exclude.rules names ${id}, which is no rule`);
213
+ else if ((mapped[name] || []).includes(id)) {
214
+ problems.push(`${name}: excludes ${id}, which its standard maps for the same version`);
215
+ }
216
+ }
217
+ for (const sc of criteria) {
218
+ if (!knownSc.has(sc)) problems.push(`${name}: exclude.criteria names ${sc}, which no rule or rollup checks`);
219
+ }
220
+ const waived = (list) => list.length > 0 && list.every((sc) => criteria.includes(sc));
221
+ const ruleIds = [...new Set(ids.concat(rules.filter((r) => waived(r.wcagSc || [])).map((r) => r.ruleId)))].sort();
222
+ const rollupIds = rollups.filter((r) => waived(r.wcagSc || [])).map((r) => r.id).sort();
223
+ out[name] = { rules: ids, criteria, ruleIds, rollupIds };
224
+ }
225
+ }
226
+ if (problems.length) throw new Error(`profile exclusions:\n ${problems.join('\n ')}`);
227
+ return out;
228
+ }
229
+
230
+ // A profile depends on core only, never on another profile: the rules a
231
+ // standard maps, and the bases of its rules' variants, are core's or its own,
232
+ // not another standard's opt-in rules. A rule two standards need belongs in
233
+ // core. Given every rule ([{ ruleId, wcagSc, tags, variantOf }]), the problems.
234
+ function validateProfileIndependence(rules) {
235
+ const byId = new Map(rules.map((r) => [r.ruleId, r]));
236
+ const ownerTag = (r) =>
237
+ NORMATIVE_STANDARDS.map((s) => s.ruleTag).find((t) => t && (r.tags || []).includes(t)) || null;
238
+ const problems = [];
239
+ for (const s of NORMATIVE_STANDARDS) {
240
+ for (const r of rules) {
241
+ const tag = ownerTag(r);
242
+ if (!tag || tag === s.ruleTag) continue;
243
+ if (s.mappingsFor({ id: r.ruleId, wcagSc: r.wcagSc || [] }).length) {
244
+ problems.push(
245
+ `${s.key} maps ${r.ruleId}, a rule of the standard tagged ${tag}: map a core rule or one of its own`
246
+ );
247
+ }
248
+ }
249
+ for (const r of rules) {
250
+ if (!s.ruleTag || !r.variantOf || ownerTag(r) !== s.ruleTag) continue;
251
+ const base = byId.get(r.variantOf);
252
+ const tag = base && ownerTag(base);
253
+ if (tag && tag !== s.ruleTag) {
254
+ problems.push(`${s.key}'s ${r.ruleId} is a variant of ${base.ruleId}, a rule of the standard tagged ${tag}`);
255
+ }
256
+ }
257
+ }
258
+ return problems;
259
+ }
260
+
261
+ // The registered standard an entry belongs to, or null (WCAG itself, or a
262
+ // standard the engine does not know, such as one a custom rule declares).
263
+ function standardOfEntry(m) {
264
+ if (!m || typeof m !== 'object' || !m.requirement) return null;
265
+ return NORMATIVE_STANDARDS.find((s) => s.standard === m.standard) || null;
266
+ }
267
+
268
+ module.exports = {
269
+ NORMATIVE_STANDARDS,
270
+ profileExclusions,
271
+ validateProfileIndependence,
272
+ standardMappingsFor,
273
+ withStandardMappings,
274
+ validateStandards,
275
+ standardComposites,
276
+ profileRuleIds,
277
+ standardsData,
278
+ standardOfEntry
279
+ };