@surea11y/core 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +103 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +8 -7
  4. package/docs/API_STABILITY.md +18 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +97 -3
  8. package/docs/EARL.md +2 -2
  9. package/docs/ENGINE_OPTIONS.md +81 -3
  10. package/docs/I18N.md +62 -20
  11. package/docs/JUNIT.md +73 -0
  12. package/docs/LIMITATIONS.md +1 -0
  13. package/docs/OUTPUT_SCHEMA.md +19 -6
  14. package/docs/REPORT.md +7 -2
  15. package/docs/RULE_AUTHORING.md +73 -6
  16. package/docs/RULE_CATALOG.md +139 -116
  17. package/docs/RULE_EXAMPLES.md +2189 -0
  18. package/docs/RULE_HELPERS.md +62 -5
  19. package/docs/RULE_TAXONOMY.md +2 -2
  20. package/docs/SARIF.md +2 -1
  21. package/docs/WCAG_CONFORMANCE.md +56 -3
  22. package/package.json +34 -11
  23. package/profiles/index.js +14 -0
  24. package/src/checks/automatic/area-alt-present.js +87 -31
  25. package/src/checks/automatic/aria-braille-equivalent.js +25 -7
  26. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  27. package/src/checks/automatic/aria-prohibited-attr.js +17 -4
  28. package/src/checks/automatic/aria-required-attr.js +29 -0
  29. package/src/checks/automatic/aria-role-name-present.js +19 -2
  30. package/src/checks/automatic/aria-valid-attr-value.js +28 -16
  31. package/src/checks/automatic/autocomplete-valid.js +26 -11
  32. package/src/checks/automatic/avoid-inline-spacing.js +105 -40
  33. package/src/checks/automatic/button-name-present.js +2 -1
  34. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  35. package/src/checks/automatic/combobox-name-present.js +34 -51
  36. package/src/checks/automatic/contrast-computable.js +35 -4
  37. package/src/checks/automatic/contrast-enhanced.js +4 -4
  38. package/src/checks/automatic/contrast-minimum.js +45 -11
  39. package/src/checks/automatic/css-orientation-lock.js +152 -30
  40. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  41. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  42. package/src/checks/automatic/dialog-name-present.js +28 -9
  43. package/src/checks/automatic/duplicate-id.js +6 -2
  44. package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
  45. package/src/checks/automatic/iframe-focusable-content.js +7 -4
  46. package/src/checks/automatic/iframe-title-unique.js +36 -81
  47. package/src/checks/automatic/input-image-alt-present.js +32 -20
  48. package/src/checks/automatic/label-in-name.js +40 -13
  49. package/src/checks/automatic/language-page-present.js +12 -6
  50. package/src/checks/automatic/link-in-text-block.js +272 -60
  51. package/src/checks/automatic/link-name-present.js +13 -5
  52. package/src/checks/automatic/list-children-valid.js +18 -1
  53. package/src/checks/automatic/listbox-name-present.js +19 -49
  54. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  55. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  56. package/src/checks/automatic/page-title-present.js +16 -4
  57. package/src/checks/automatic/progressbar-name-present.js +11 -1
  58. package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
  59. package/src/checks/automatic/searchbox-name-present.js +32 -49
  60. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  61. package/src/checks/automatic/slider-name-present.js +38 -52
  62. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  63. package/src/checks/automatic/target-size-minimum.js +0 -11
  64. package/src/checks/automatic/td-has-header.js +41 -5
  65. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  66. package/src/checks/automatic/textbox-name-present.js +32 -49
  67. package/src/checks/automatic/valid-lang.js +15 -10
  68. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  69. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  70. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  71. package/src/checks/manual/css-hidden-focus.js +215 -7
  72. package/src/checks/manual/form-control-label-quality-manual.js +109 -5
  73. package/src/checks/manual/heading-order-manual.js +9 -1
  74. package/src/checks/manual/heading-quality-manual.js +143 -9
  75. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  76. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  77. package/src/checks/manual/link-name-quality-manual.js +130 -4
  78. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  79. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  80. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  81. package/src/checks/manual/p-as-heading-manual.js +89 -44
  82. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  83. package/src/checks/manual/skip-link-manual.js +42 -14
  84. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  85. package/src/checks/manual/video-caption-manual.js +47 -24
  86. package/src/checks/manual-review.js +0 -4
  87. package/src/core.js +14285 -2219
  88. package/src/coverage/en301549-map.js +187 -0
  89. package/src/coverage/standards.js +279 -0
  90. package/src/coverage/wcag-facets.js +1119 -0
  91. package/src/coverage/wcag-version-map.js +101 -0
  92. package/src/en301549.js +33 -0
  93. package/src/junit.js +321 -0
  94. package/src/profile-kit.js +163 -0
  95. package/src/report.js +343 -74
  96. package/src/sarif.js +34 -3
  97. package/src/wcag.js +105 -0
  98. package/surea11y.browser.js +5 -4
  99. package/surea11y.i18n.de.js +1 -1
  100. package/surea11y.i18n.es.js +1 -1
  101. package/surea11y.i18n.fr.js +1 -1
  102. package/surea11y.i18n.ja.js +3 -0
  103. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
package/src/report.js CHANGED
@@ -11,6 +11,8 @@
11
11
  * scorecard + searchable/filterable/paginated table, dark-mode CSS.
12
12
  */
13
13
 
14
+ const { NORMATIVE_STANDARDS, standardOfEntry } = require('./coverage/standards.js');
15
+
14
16
  // Uses the dataviz skill's validated status palette, mapped 1:1 onto this
15
17
  // engine's own 4 outcomes.
16
18
  const STATUS = {
@@ -23,14 +25,78 @@ const STATUS = {
23
25
  // fail first: a QA tester scanning a report wants violations up front,
24
26
  // not passes.
25
27
  const OUTCOME_ORDER = ['fail', 'cantTell', 'pass', 'notApplicable'];
26
- const OUTCOME_INFO = {
27
- fail: { label: 'Fail', status: 'serious', defaultOn: true },
28
- cantTell: { label: 'Needs review', status: 'warning', defaultOn: true },
29
- pass: { label: 'Pass', status: 'good', defaultOn: false },
30
- notApplicable: { label: 'Not applicable', status: 'neutral', defaultOn: false }
28
+ const OUTCOME_STATUS = {
29
+ fail: { status: 'serious', defaultOn: true },
30
+ cantTell: { status: 'warning', defaultOn: true },
31
+ pass: { status: 'good', defaultOn: false },
32
+ notApplicable: { status: 'neutral', defaultOn: false }
31
33
  };
32
- for (const code of OUTCOME_ORDER) {
33
- Object.assign(OUTCOME_INFO[code], STATUS[OUTCOME_INFO[code].status]);
34
+
35
+ // Every word on the page comes from the engine's dictionaries (report_*
36
+ // keys), so a report reads in the language its scan resolved to and a new
37
+ // locale needs no change here. Loaded on first use: rendering a saved result
38
+ // should not cost a scan's worth of setup until a report is asked for.
39
+ let translator = null;
40
+ function getTranslator() {
41
+ if (!translator) translator = require('./core.js').__internal;
42
+ return translator;
43
+ }
44
+
45
+ // The page speaks the locale the scan resolved to when the engine carries
46
+ // it. A dictionary the caller supplied at scan time is not in the result, so
47
+ // such a report falls back to English labels and marks the findings with
48
+ // their own language instead.
49
+ function createUi(engine) {
50
+ const core = getTranslator();
51
+ const locale = engine && engine.locale;
52
+ const contentLocale =
53
+ locale && typeof locale.resolved === 'string' && locale.resolved ? locale.resolved : 'en';
54
+ const uiLocale = core.resolveLocale(contentLocale).resolved;
55
+
56
+ const tr = (key, params) => core.translate(key, '', params || null, uiLocale);
57
+
58
+ let numberFormat;
59
+ try {
60
+ numberFormat = new Intl.NumberFormat(uiLocale);
61
+ } catch {
62
+ numberFormat = new Intl.NumberFormat('en-US');
63
+ }
64
+ const num = (n) => numberFormat.format(Number(n) || 0);
65
+
66
+ // Escapes the translated sentence, then puts the numbers back in bold.
67
+ // Dictionaries hold plain text; markup never goes through them.
68
+ function trStrong(key, params) {
69
+ const marked = {};
70
+ const values = [];
71
+ for (const name of Object.keys(params)) {
72
+ marked[name] = `\uE000${values.length}\uE000`;
73
+ values.push(params[name]);
74
+ }
75
+ return esc(tr(key, marked)).replace(
76
+ /\uE000(\d+)\uE000/g,
77
+ (m, i) => `<strong>${num(values[Number(i)])}</strong>`
78
+ );
79
+ }
80
+
81
+ const outcomeInfo = {};
82
+ for (const code of OUTCOME_ORDER) {
83
+ outcomeInfo[code] = {
84
+ label: tr(`report_outcome_${code}`),
85
+ defaultOn: OUTCOME_STATUS[code].defaultOn,
86
+ ...STATUS[OUTCOME_STATUS[code].status]
87
+ };
88
+ }
89
+
90
+ const severity = (s) => {
91
+ const label = typeof s === 'string' ? tr(`report_severity_${s}`) : '';
92
+ return label || String(s || '');
93
+ };
94
+
95
+ // The English text a message key renders to, for telling a translated
96
+ // string from one that fell back to English.
97
+ const english = (key, params) => (key ? core.translate(key, '', params || null, 'en') : null);
98
+
99
+ return { tr, trStrong, num, outcomeInfo, severity, uiLocale, contentLocale, english };
34
100
  }
35
101
 
36
102
  function esc(s) {
@@ -84,24 +150,80 @@ function getCardOutcome(ruleResult) {
84
150
  return hasCantTell ? 'cantTell' : ruleResult.outcome;
85
151
  }
86
152
 
153
+ // Same test as src/sarif.js: other standards (EN 301 549, say) and WCAG's
154
+ // non-normative documents (`type: 'Understanding'`) share `requirement`
155
+ // with a Success Criterion, and chipping them as "WCAG" mislabels or repeats it.
156
+ function isWcagCriterion(m) {
157
+ return !!(m && m.requirement && (m.standard == null || m.standard === 'WCAG') && !m.type);
158
+ }
159
+
160
+ // What the scan was tested against, and any opt-in rules it added. A result
161
+ // from an engine older than the WCAG-version target carries none of these
162
+ // fields and gets no chips.
163
+ function renderTargetChips(engine, ui) {
164
+ if (!engine) return '';
165
+ const chips = [];
166
+ if (engine.wcagVersion)
167
+ chips.push(
168
+ `<div><b>WCAG ${esc(engine.wcagVersion)}</b>${esc(ui.tr('report_meta_target'))}</div>`
169
+ );
170
+ if (engine.profile)
171
+ chips.push(`<div><b>${esc(engine.profile)}</b>${esc(ui.tr('report_meta_profile'))}</div>`);
172
+ // Rules beyond the targeted standard ran (engineOptions.optInRules).
173
+ if (Array.isArray(engine.optInRules) && engine.optInRules.length)
174
+ chips.push(
175
+ `<div><b>${esc(engine.optInRules.join(', '))}</b>${esc(ui.tr('report_meta_optInRules'))}</div>`
176
+ );
177
+ return chips.join('\n ');
178
+ }
179
+
87
180
  // Locale fallback is per-string and silent in the text itself, so a report
88
181
  // generated in a locale the engine does not carry reads as a normal English
89
182
  // one. A result from an older engine has no engine.locale and gets no chip.
90
- function renderLocaleChip(engine) {
183
+ function renderLocaleChip(engine, ui) {
91
184
  const locale = engine && engine.locale;
92
185
  if (!locale || typeof locale.resolved !== 'string' || !locale.resolved) return '';
93
186
 
94
187
  const fellBack = locale.requested !== locale.resolved;
95
- const label = fellBack ? `locale (requested ${esc(locale.requested)})` : 'locale';
188
+ const label = esc(
189
+ fellBack
190
+ ? ui.tr('report_meta_localeRequested', { requested: locale.requested })
191
+ : ui.tr('report_meta_locale')
192
+ );
96
193
 
97
194
  return `<div><b>${esc(locale.resolved)}</b>${label}</div>`;
98
195
  }
99
196
 
197
+ // Rule titles, summaries and hints arrive in the scan's locale. When the
198
+ // page's own labels are in a different language (a caller-supplied
199
+ // dictionary the engine does not carry), those parts are marked with their
200
+ // language so a screen reader switches voice for them (WCAG 3.1.2).
201
+ const LANG_TAG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/;
202
+ function contentLang(ui) {
203
+ const lang = ui.contentLocale;
204
+ if (!LANG_TAG.test(lang) || lang.toLowerCase() === ui.uiLocale.toLowerCase()) return '';
205
+ return lang;
206
+ }
207
+
208
+ // The language one string is in: the scan's, unless the dictionary had no
209
+ // entry for its key and it came out as the English text, which the English
210
+ // page around it already covers. '' when no attribute is needed.
211
+ function textLang(ui, text, key, params) {
212
+ const lang = contentLang(ui);
213
+ if (!lang || !text) return '';
214
+ return key && ui.english(key, params) === text ? '' : lang;
215
+ }
216
+
217
+ function langAttr(lang) {
218
+ return lang ? ` lang="${esc(lang)}"` : '';
219
+ }
220
+
100
221
  // One plain-language headline + one horizontal stacked bar + a legend with
101
222
  // icon+label+count (status color is never the only signal) -- the first
102
223
  // thing a reader sees; exhaustive detail lives in the collapsed
103
224
  // "full technical data" section further down.
104
- function renderHeroBar(byOutcome) {
225
+ function renderHeroBar(byOutcome, ui) {
226
+ const OUTCOME_INFO = ui.outcomeInfo;
105
227
  const total = OUTCOME_ORDER.reduce((sum, c) => sum + byOutcome[c], 0) || 1;
106
228
  const applicable = byOutcome.pass + byOutcome.fail + byOutcome.cantTell;
107
229
 
@@ -110,7 +232,7 @@ function renderHeroBar(byOutcome) {
110
232
  const n = byOutcome[c];
111
233
  const pct = (n / total) * 100;
112
234
  const info = OUTCOME_INFO[c];
113
- return `<div class="hero-bar-seg" style="width:${pct}%; background:${info.color}" title="${esc(info.label)}: ${n.toLocaleString()} (${pct.toFixed(1)}%)"></div>`;
235
+ return `<div class="hero-bar-seg" style="width:${pct}%; background:${info.color}" title="${esc(info.label)}: ${ui.num(n)} (${pct.toFixed(1)}%)"></div>`;
114
236
  })
115
237
  .join('');
116
238
 
@@ -120,19 +242,26 @@ function renderHeroBar(byOutcome) {
120
242
  return `<div class="hero-legend-item">
121
243
  <span class="hero-legend-swatch" style="background:${info.color}">${info.icon}</span>
122
244
  <span class="hero-legend-label">${esc(info.label)}</span>
123
- <span class="hero-legend-count">${n.toLocaleString()} (${fmtPct(n, total)})</span>
245
+ <span class="hero-legend-count">${ui.num(n)} (${fmtPct(n, total)})</span>
124
246
  </div>`;
125
247
  }).join('\n');
126
248
 
127
249
  let headline;
128
250
  if (byOutcome.fail > 0) {
129
- headline = `<strong>${byOutcome.pass.toLocaleString()}</strong> of <strong>${applicable.toLocaleString()}</strong> applicable checks passed. <strong>${byOutcome.fail.toLocaleString()}</strong> failure${byOutcome.fail === 1 ? '' : 's'} need${byOutcome.fail === 1 ? 's' : ''} attention below.`;
251
+ headline = ui.trStrong(
252
+ byOutcome.fail === 1 ? 'report_hero_failures_one' : 'report_hero_failures_other',
253
+ { passed: byOutcome.pass, applicable, failed: byOutcome.fail }
254
+ );
130
255
  } else if (byOutcome.cantTell > 0) {
131
- headline = `<strong>${byOutcome.pass.toLocaleString()}</strong> of <strong>${applicable.toLocaleString()}</strong> applicable checks passed, with <strong>${byOutcome.cantTell.toLocaleString()}</strong> needing manual review.`;
256
+ headline = ui.trStrong('report_hero_review', {
257
+ passed: byOutcome.pass,
258
+ applicable,
259
+ review: byOutcome.cantTell
260
+ });
132
261
  } else if (applicable > 0) {
133
- headline = `All <strong>${applicable.toLocaleString()}</strong> applicable checks passed.`;
262
+ headline = ui.trStrong('report_hero_allPassed', { applicable });
134
263
  } else {
135
- headline = 'No applicable checks ran for this scan.';
264
+ headline = esc(ui.tr('report_hero_none'));
136
265
  }
137
266
 
138
267
  return `<div class="hero">
@@ -142,13 +271,14 @@ function renderHeroBar(byOutcome) {
142
271
  </div>`;
143
272
  }
144
273
 
145
- function renderScorecard(byOutcome) {
274
+ function renderScorecard(byOutcome, ui) {
275
+ const OUTCOME_INFO = ui.outcomeInfo;
146
276
  const total = OUTCOME_ORDER.reduce((sum, c) => sum + byOutcome[c], 0);
147
277
  const tiles = OUTCOME_ORDER.map((c) => {
148
278
  const n = byOutcome[c];
149
279
  const info = OUTCOME_INFO[c];
150
280
  return `<div class="tile" style="border-color:${info.color}; background:${info.bg}">
151
- <div class="tile-num" style="color:${info.color}">${n.toLocaleString()}</div>
281
+ <div class="tile-num" style="color:${info.color}">${ui.num(n)}</div>
152
282
  <div class="tile-pct">${fmtPct(n, total)}</div>
153
283
  <div class="tile-label">${esc(info.label)}</div>
154
284
  </div>`;
@@ -160,9 +290,10 @@ function renderScorecard(byOutcome) {
160
290
  // entry per Success Criterion, docs/WCAG_CONFORMANCE.md), not an invented
161
291
  // grouping. Grouped by conformance level (A / AA / AAA) since that's the
162
292
  // axis a compliance-minded reader actually cares about.
163
- function renderWcagRollup(rulesResults) {
293
+ function renderWcagRollup(rulesResults, ui) {
294
+ const OUTCOME_INFO = ui.outcomeInfo;
164
295
  if (!Array.isArray(rulesResults) || !rulesResults.length) {
165
- return '<p class="note">No WCAG composite rollups available for this scan (composite rules were excluded via runOnly/engineOptions).</p>';
296
+ return `<p class="note">${esc(ui.tr('report_rollup_none'))}</p>`;
166
297
  }
167
298
 
168
299
  const byLevel = { A: [], AA: [], AAA: [], other: [] };
@@ -189,11 +320,34 @@ function renderWcagRollup(rulesResults) {
189
320
  const metrics = (rule.data && rule.data.details && rule.data.details.metrics) || {};
190
321
  const checksIds = (rule.data && rule.data.details && rule.data.details.checksIds) || [];
191
322
  const chip = `<span class="chip" style="background:${info.bg};color:${info.color}">${esc(info.label)}</span>`;
192
- const scLabel = mapping ? `WCAG ${esc(mapping.requirement)}` : 'WCAG (unmapped)';
193
- const metricsLabel = `${metrics.passCount || 0} pass / ${metrics.failCount || 0} fail / ${metrics.cantTellCount || 0} needs review / ${metrics.notApplicableCount || 0} n/a`;
323
+ const scLabel = mapping
324
+ ? `WCAG ${esc(mapping.requirement)}`
325
+ : esc(ui.tr('report_rollup_unmapped'));
326
+ // One line per registered standard the row carries, in registry
327
+ // order. A requirement numbered the same in two versions (as EN 301
328
+ // 549 clauses are) is listed once.
329
+ const ruleMappings = (rule.meta && rule.meta.normativeMappings) || [];
330
+ const enLabel = NORMATIVE_STANDARDS.map((standard) => {
331
+ const requirements = Array.from(
332
+ new Set(
333
+ ruleMappings
334
+ .filter((m) => standardOfEntry(m) === standard)
335
+ .map((m) => m.requirement)
336
+ )
337
+ );
338
+ return requirements.length
339
+ ? `<br><span class="note">${esc(standard.standard)} ${requirements.map(esc).join(', ')}</span>`
340
+ : '';
341
+ }).join('');
342
+ const metricsLabel = ui.tr('report_rollup_breakdown', {
343
+ pass: ui.num(metrics.passCount),
344
+ fail: ui.num(metrics.failCount),
345
+ review: ui.num(metrics.cantTellCount),
346
+ na: ui.num(metrics.notApplicableCount)
347
+ });
194
348
  return `<tr>
195
- <td class="sc-cell">${scLabel}</td>
196
- <td>${esc(rule.title || rule.ruleId)}</td>
349
+ <td class="sc-cell">${scLabel}${enLabel}</td>
350
+ <td${langAttr(textLang(ui, rule.title, rule.i18n && rule.i18n.titleKey))}>${esc(rule.title || rule.ruleId)}</td>
197
351
  <td>${chip}</td>
198
352
  <td class="note">${esc(metricsLabel)}</td>
199
353
  <td class="note">${esc(checksIds.join(', '))}</td>
@@ -201,9 +355,13 @@ function renderWcagRollup(rulesResults) {
201
355
  })
202
356
  .join('\n');
203
357
 
204
- return `<h3 class="wcag-level-heading">Level ${level === 'other' ? '(unmapped)' : level}</h3>
358
+ const levelHeading =
359
+ level === 'other'
360
+ ? ui.tr('report_rollup_levelUnmapped')
361
+ : ui.tr('report_rollup_level', { level });
362
+ return `<h3 class="wcag-level-heading">${esc(levelHeading)}</h3>
205
363
  <table class="wcag-table">
206
- <thead><tr><th>SC</th><th>Requirement</th><th>Outcome</th><th>Breakdown</th><th>Contributing rules</th></tr></thead>
364
+ <thead><tr><th>${esc(ui.tr('report_rollup_col_sc'))}</th><th>${esc(ui.tr('report_rollup_col_requirement'))}</th><th>${esc(ui.tr('report_col_outcome'))}</th><th>${esc(ui.tr('report_rollup_col_breakdown'))}</th><th>${esc(ui.tr('report_rollup_col_contributing'))}</th></tr></thead>
207
365
  <tbody>${rows}</tbody>
208
366
  </table>`;
209
367
  })
@@ -212,8 +370,56 @@ function renderWcagRollup(rulesResults) {
212
370
  return sections;
213
371
  }
214
372
 
373
+ // A registered standard's own rollups (one per requirement, say), present only
374
+ // when the scan produced them. `standard.report` supplies the note above the
375
+ // table and the language of the titles when it is not the scan's.
376
+ function renderStandardRollup(standard, results, ui) {
377
+ const OUTCOME_INFO = ui.outcomeInfo;
378
+ const report = standard.report || {};
379
+ const titleLang = report.titleLang ? ` lang="${esc(report.titleLang)}"` : '';
380
+ const byCriterion = (r) =>
381
+ String((r.data && r.data.details && r.data.details.criterion) || r.ruleId || '');
382
+ const rows = results
383
+ .slice()
384
+ .sort((a, b) => byCriterion(a).localeCompare(byCriterion(b), undefined, { numeric: true }))
385
+ .map((rule) => {
386
+ const info = OUTCOME_INFO[rule.outcome] || OUTCOME_INFO.notApplicable;
387
+ const details = (rule.data && rule.data.details) || {};
388
+ const metrics = details.metrics || {};
389
+ const checksIds = details.checksIds || [];
390
+ const tests = Array.from(
391
+ new Set(
392
+ ((rule.meta && rule.meta.normativeMappings) || [])
393
+ .filter((m) => standardOfEntry(m) === standard)
394
+ .map((m) => m.requirement)
395
+ )
396
+ );
397
+ const chip = `<span class="chip" style="background:${info.bg};color:${info.color}">${esc(info.label)}</span>`;
398
+ const metricsLabel = ui.tr('report_rollup_breakdown', {
399
+ pass: ui.num(metrics.passCount),
400
+ fail: ui.num(metrics.failCount),
401
+ review: ui.num(metrics.cantTellCount),
402
+ na: ui.num(metrics.notApplicableCount)
403
+ });
404
+ return `<tr>
405
+ <td class="sc-cell">${esc(standard.standard)} ${esc(byCriterion(rule))}${tests.length ? `<br><span class="note">${tests.map(esc).join(', ')}</span>` : ''}</td>
406
+ <td${titleLang}>${esc(rule.title || rule.ruleId)}</td>
407
+ <td>${chip}</td>
408
+ <td class="note">${esc(metricsLabel)}</td>
409
+ <td class="note">${esc(checksIds.join(', '))}</td>
410
+ </tr>`;
411
+ })
412
+ .join('\n');
413
+ const note = report.noteKey ? `<p class="note">${esc(ui.tr(report.noteKey))}</p>\n ` : '';
414
+ return `${note}<table class="wcag-table">
415
+ <thead><tr><th>${esc(ui.tr('report_standardRollup_col_criterion'))}</th><th>${esc(ui.tr('report_rollup_col_requirement'))}</th><th>${esc(ui.tr('report_col_outcome'))}</th><th>${esc(ui.tr('report_rollup_col_breakdown'))}</th><th>${esc(ui.tr('report_rollup_col_contributing'))}</th></tr></thead>
416
+ <tbody>${rows}</tbody>
417
+ </table>`;
418
+ }
419
+
215
420
  // Collapse internal whitespace/newlines and cap length for card display --
216
- // the findings table below shows the untruncated value.
421
+ // the findings table below shows the untruncated selector and summary. The
422
+ // hint is left whole: it is the fix advice, and the table does not repeat it.
217
423
  const CARD_SNIPPET_MAX = 220;
218
424
  function truncateForCard(s) {
219
425
  const collapsed = String(s || '')
@@ -228,7 +434,8 @@ function truncateForCard(s) {
228
434
  // the same underlying issue is one thing worth a person's attention, not N.
229
435
  const MAX_CARDS = 24;
230
436
 
231
- function renderCards(checksResults) {
437
+ function renderCards(checksResults, ui) {
438
+ const OUTCOME_INFO = ui.outcomeInfo;
232
439
  const withIssues = checksResults.filter(
233
440
  (r) =>
234
441
  Array.isArray(r.occurrences) &&
@@ -238,7 +445,7 @@ function renderCards(checksResults) {
238
445
  })
239
446
  );
240
447
  if (!withIssues.length) {
241
- return '<p class="note">No fail/cantTell rules with occurrences on this scan.</p>';
448
+ return `<p class="note">${esc(ui.tr('report_cards_none'))}</p>`;
242
449
  }
243
450
 
244
451
  const sorted = withIssues.slice().sort((a, b) => {
@@ -261,7 +468,9 @@ function renderCards(checksResults) {
261
468
  const representative =
262
469
  r.occurrences.find((occ) => getOccurrenceOutcome(r, occ) === cardOutcome) ||
263
470
  r.occurrences[0];
471
+ const occI18n = representative.i18n || {};
264
472
  const wcagChips = ((r.meta && r.meta.normativeMappings) || [])
473
+ .filter(isWcagCriterion)
265
474
  .map(
266
475
  (m) =>
267
476
  `<span class="chip" style="background:${STATUS.neutral.bg};color:${STATUS.neutral.color}">WCAG ${esc(m.requirement)}</span>`
@@ -270,19 +479,19 @@ function renderCards(checksResults) {
270
479
  const hasMixedOutcomes = occurrenceCounts.fail > 0 && occurrenceCounts.cantTell > 0;
271
480
  const countLabel =
272
481
  r.occurrences.length > 1
273
- ? ` <span class="card-count">× ${r.occurrences.length.toLocaleString()}${hasMixedOutcomes ? ` (${occurrenceCounts.fail.toLocaleString()} fail / ${occurrenceCounts.cantTell.toLocaleString()} needs review)` : ''}</span>`
482
+ ? ` <span class="card-count">× ${ui.num(r.occurrences.length)}${hasMixedOutcomes ? ` ${esc(ui.tr('report_card_mixed', { fail: ui.num(occurrenceCounts.fail), review: ui.num(occurrenceCounts.cantTell) }))}` : ''}</span>`
274
483
  : '';
275
484
 
276
485
  return `<div class="card">
277
486
  <div class="card-head">
278
487
  <span class="hero-legend-swatch" style="background:${info.color}">${info.icon}</span>
279
- <span class="card-title"><strong>${esc(r.ruleId)}</strong> (${esc(info.label)}, ${esc(r.severity)})${countLabel}</span>
488
+ <span class="card-title"><strong>${esc(r.ruleId)}</strong> ${esc(ui.tr('report_card_status', { outcome: info.label, severity: ui.severity(r.severity) }))}${countLabel}</span>
280
489
  </div>
281
490
  <div class="card-body">
282
491
  <div class="card-meta">${wcagChips}</div>
283
- <div class="card-selector"><span class="card-selector-label">Selector:</span> <code>${esc(representative.selector || '(none)')}</code></div>
284
- <div class="card-snippet">${esc(representative.summary)}${representative.hint ? ` — ${esc(representative.hint)}` : ''}</div>
285
- ${r.occurrences.length > 1 ? `<p class="card-note">Selector/summary above are from one representative occurrence; ${r.occurrences.length.toLocaleString()} total on this rule.</p>` : ''}
492
+ <div class="card-selector"><span class="card-selector-label">${esc(ui.tr('report_card_selector'))}</span> <code>${esc(representative.selector ? truncateForCard(representative.selector) : ui.tr('report_card_noSelector'))}</code></div>
493
+ <div class="card-snippet">${snippetPart(ui, truncateForCard(representative.summary), representative.summary, occI18n.summaryKey, occI18n.params)}${representative.hint ? ` — ${snippetPart(ui, representative.hint, representative.hint, occI18n.hintKey, occI18n.params)}` : ''}</div>
494
+ ${r.occurrences.length > 1 ? `<p class="card-note">${esc(ui.tr('report_card_representative', { count: ui.num(r.occurrences.length) }))}</p>` : ''}
286
495
  </div>
287
496
  </div>`;
288
497
  })
@@ -290,16 +499,23 @@ function renderCards(checksResults) {
290
499
 
291
500
  const overflow =
292
501
  sorted.length > MAX_CARDS
293
- ? `<p class="note">Showing the ${MAX_CARDS} highest-priority rules of ${sorted.length.toLocaleString()} with issues. See the full technical data below for the rest.</p>`
502
+ ? `<p class="note">${esc(ui.tr('report_cards_overflow', { shown: ui.num(MAX_CARDS), total: ui.num(sorted.length) }))}</p>`
294
503
  : '';
295
504
 
296
505
  return `<div class="cards">${cards}</div>${overflow}`;
297
506
  }
298
507
 
508
+ // A summary or hint for a card, wrapped in its language when that is not the
509
+ // page's. `full` is the untruncated text the key rendered to.
510
+ function snippetPart(ui, shown, full, key, params) {
511
+ const lang = textLang(ui, full, key, params);
512
+ return lang ? `<span lang="${esc(lang)}">${esc(shown)}</span>` : esc(shown);
513
+ }
514
+
299
515
  // Flatten every occurrence across every rule into one row per occurrence --
300
516
  // the searchable/filterable/paginated detail view. Rules with zero
301
517
  // occurrences (pass/notApplicable) have nothing to show at this granularity.
302
- function flattenOccurrences(checksResults) {
518
+ function flattenOccurrences(checksResults, ui) {
303
519
  const rows = [];
304
520
  for (const r of checksResults) {
305
521
  if (!Array.isArray(r.occurrences)) continue;
@@ -309,9 +525,16 @@ function flattenOccurrences(checksResults) {
309
525
  ruleId: r.ruleId,
310
526
  outcome: occurrenceOutcome || r.outcome,
311
527
  severity: r.severity,
528
+ severityLabel: ui.severity(r.severity),
312
529
  selector: occ.selector || '',
313
530
  html: occ.html || '',
314
531
  summary: occ.summary || '',
532
+ summaryLang: textLang(
533
+ ui,
534
+ occ.summary,
535
+ occ.i18n && occ.i18n.summaryKey,
536
+ occ.i18n && occ.i18n.params
537
+ ),
315
538
  hint: occ.hint || ''
316
539
  });
317
540
  }
@@ -321,19 +544,45 @@ function flattenOccurrences(checksResults) {
321
544
 
322
545
  function renderHtmlReport(result, options = {}) {
323
546
  const checksResults = Array.isArray(result && result.checksResults) ? result.checksResults : [];
324
- const rulesResults = Array.isArray(result && result.rulesResults) ? result.rulesResults : [];
547
+ const allRollups = Array.isArray(result && result.rulesResults) ? result.rulesResults : [];
548
+ // A rollup a registered standard defines for itself carries that standard's
549
+ // name in meta.standard and gets a section of its own after the WCAG one.
550
+ const ownStandard = (r) =>
551
+ (r && r.meta && NORMATIVE_STANDARDS.find((s) => s.standard === r.meta.standard)) || null;
552
+ const rulesResults = allRollups.filter((r) => !ownStandard(r));
553
+ const standardRollups = NORMATIVE_STANDARDS.map((standard) => ({
554
+ standard,
555
+ results: allRollups.filter((r) => ownStandard(r) === standard)
556
+ })).filter((s) => s.results.length);
325
557
  const byOutcome = countByOutcome(checksResults);
326
- const generatedAtLabel = new Date().toLocaleString('en-US', {
327
- dateStyle: 'medium',
328
- timeStyle: 'medium'
329
- });
330
- const title = (options && options.title) || 'surea11y scan report';
558
+ const engine = result && result.engine;
559
+ const ui = createUi(engine);
560
+ const OUTCOME_INFO = ui.outcomeInfo;
561
+ let generatedAtLabel;
562
+ try {
563
+ generatedAtLabel = new Date().toLocaleString(ui.uiLocale, {
564
+ dateStyle: 'medium',
565
+ timeStyle: 'medium'
566
+ });
567
+ } catch {
568
+ generatedAtLabel = new Date().toLocaleString('en-US', {
569
+ dateStyle: 'medium',
570
+ timeStyle: 'medium'
571
+ });
572
+ }
573
+ const title = (options && options.title) || ui.tr('report_title_default');
331
574
 
332
575
  const defaultOnList = OUTCOME_ORDER.filter((c) => OUTCOME_INFO[c].defaultOn);
333
- const rows = flattenOccurrences(checksResults);
576
+ const rows = flattenOccurrences(checksResults, ui);
577
+ const pageLang = LANG_TAG.test(ui.uiLocale) ? ui.uiLocale : 'en';
578
+ // Strings the in-page script needs; it cannot call the translator.
579
+ const clientText = {
580
+ none: ui.tr('report_pager_none'),
581
+ info: ui.tr('report_pager_info')
582
+ };
334
583
 
335
584
  return `<!doctype html>
336
- <html lang="en">
585
+ <html lang="${esc(pageLang)}">
337
586
  <head>
338
587
  <meta charset="utf-8">
339
588
  <title>${esc(title)} — ${esc(generatedAtLabel)}</title>
@@ -416,53 +665,61 @@ function renderHtmlReport(result, options = {}) {
416
665
 
417
666
  <header>
418
667
  <h1>${esc(title)}</h1>
419
- <div class="sub">${esc(result && result.url ? result.url : '(no url)')} — generated ${esc(generatedAtLabel)}</div>
668
+ <div class="sub">${esc(result && result.url ? result.url : ui.tr('report_noUrl'))} — ${esc(ui.tr('report_generated', { date: generatedAtLabel }))}</div>
420
669
  </header>
421
670
 
422
671
  <div class="meta-bar">
423
- <div><b>${checksResults.length}</b>rules run</div>
424
- <div><b>${rows.length}</b>total occurrences</div>
425
- <div><b>${esc((result && result.engine && result.engine.tag) || '?')}</b>engine</div>
426
- <div><b>${esc((result && result.engine && result.engine.schemaVersion) || '?')}</b>schema version</div>
427
- ${renderLocaleChip(result && result.engine)}
672
+ <div><b>${ui.num(checksResults.length)}</b>${esc(ui.tr('report_meta_rulesRun'))}</div>
673
+ <div><b>${ui.num(rows.length)}</b>${esc(ui.tr('report_meta_totalOccurrences'))}</div>
674
+ <div><b>${esc((engine && engine.tag) || '?')}</b>${esc(ui.tr('report_meta_engine'))}</div>
675
+ <div><b>${esc((engine && engine.schemaVersion) || '?')}</b>${esc(ui.tr('report_meta_schemaVersion'))}</div>
676
+ ${renderTargetChips(engine, ui)}
677
+ ${renderLocaleChip(engine, ui)}
428
678
  </div>
429
679
 
430
680
  <main>
431
- ${renderHeroBar(byOutcome)}
432
-
433
- <h2>Worth reviewing</h2>
434
- ${renderCards(checksResults)}
435
-
436
- <h2>WCAG rollup</h2>
437
- ${renderWcagRollup(rulesResults)}
438
-
681
+ ${renderHeroBar(byOutcome, ui)}
682
+
683
+ <h2>${esc(ui.tr('report_heading_worthReviewing'))}</h2>
684
+ ${renderCards(checksResults, ui)}
685
+
686
+ <h2>${esc(ui.tr('report_heading_wcagRollup'))}</h2>
687
+ ${renderWcagRollup(rulesResults, ui)}
688
+ ${standardRollups
689
+ .map(
690
+ ({ standard, results }) => `
691
+ <h2>${esc(ui.tr('report_heading_standardRollup', { standard: standard.standard }))}</h2>
692
+ ${renderStandardRollup(standard, results, ui)}
693
+ `
694
+ )
695
+ .join('')}
439
696
  <details class="tech-details">
440
- <summary>Full technical data — scorecard, searchable occurrence browser</summary>
697
+ <summary>${esc(ui.tr('report_techDetails'))}</summary>
441
698
  <div class="tech-body">
442
- <h2>Scorecard</h2>
443
- ${renderScorecard(byOutcome)}
699
+ <h2>${esc(ui.tr('report_heading_scorecard'))}</h2>
700
+ ${renderScorecard(byOutcome, ui)}
444
701
 
445
- <h2>Occurrences</h2>
702
+ <h2>${esc(ui.tr('report_heading_occurrences'))}</h2>
446
703
  <div class="filters" id="filters"></div>
447
- <input type="search" id="search" placeholder="Search rule id / selector / html…" style="margin-bottom:10px; padding:6px 10px; border:1px solid #ccc; border-radius:5px; font-size:13px; width:100%; max-width:480px;">
704
+ <input type="search" id="search" aria-label="${esc(ui.tr('report_search_placeholder'))}" placeholder="${esc(ui.tr('report_search_placeholder'))}" style="margin-bottom:10px; padding:6px 10px; border:1px solid #ccc; border-radius:5px; font-size:13px; width:100%; max-width:480px;">
448
705
  <div style="overflow-x:auto;">
449
706
  <table class="findings-table" id="findings-table">
450
707
  <thead>
451
708
  <tr>
452
- <th>Rule</th>
453
- <th>Outcome</th>
454
- <th>Severity</th>
455
- <th>Selector</th>
456
- <th>Summary</th>
709
+ <th>${esc(ui.tr('report_col_rule'))}</th>
710
+ <th>${esc(ui.tr('report_col_outcome'))}</th>
711
+ <th>${esc(ui.tr('report_col_severity'))}</th>
712
+ <th>${esc(ui.tr('report_col_selector'))}</th>
713
+ <th>${esc(ui.tr('report_col_summary'))}</th>
457
714
  </tr>
458
715
  </thead>
459
716
  <tbody id="findings-body"></tbody>
460
717
  </table>
461
718
  </div>
462
719
  <div class="pager">
463
- <button id="prev-page">&larr; Prev</button>
720
+ <button id="prev-page">&larr; ${esc(ui.tr('report_pager_prev'))}</button>
464
721
  <span id="page-info"></span>
465
- <button id="next-page">Next &rarr;</button>
722
+ <button id="next-page">${esc(ui.tr('report_pager_next'))} &rarr;</button>
466
723
  </div>
467
724
  </div>
468
725
  </details>
@@ -477,6 +734,13 @@ function renderHtmlReport(result, options = {}) {
477
734
  var rows = JSON.parse(document.getElementById('report-data').textContent);
478
735
 
479
736
  var PAGE_SIZE = 100;
737
+ var TEXT = ${jsonForScript(clientText)};
738
+ var numberFormat = new Intl.NumberFormat(${jsonForScript(ui.uiLocale)});
739
+ function fill(template, values) {
740
+ return template.replace(/\\{\\{(\\w+)\\}\\}/g, function (m, k) {
741
+ return Object.prototype.hasOwnProperty.call(values, k) ? String(values[k]) : m;
742
+ });
743
+ }
480
744
  var page = 0;
481
745
  var active = {};
482
746
  DEFAULT_ON.forEach(function (c) { active[c] = true; });
@@ -531,15 +795,20 @@ function renderHtmlReport(result, options = {}) {
531
795
  return '<tr>' +
532
796
  '<td class="snippet">' + esc(r.ruleId) + '</td>' +
533
797
  '<td>' + chip + '</td>' +
534
- '<td>' + esc(r.severity) + '</td>' +
798
+ '<td>' + esc(r.severityLabel) + '</td>' +
535
799
  '<td class="snippet" title="' + esc(r.html) + '">' + esc(r.selector) + '</td>' +
536
- '<td class="snippet">' + esc(r.summary) + '</td>' +
800
+ '<td class="snippet"' + (r.summaryLang ? ' lang="' + esc(r.summaryLang) + '"' : '') + '>' + esc(r.summary) + '</td>' +
537
801
  '</tr>';
538
802
  }).join('');
539
803
 
540
804
  pageInfoEl.textContent = filtered.length === 0
541
- ? 'No matching occurrences'
542
- : ('Page ' + (page + 1) + ' / ' + totalPages + ' (' + filtered.length.toLocaleString() + ' matching, ' + rows.length.toLocaleString() + ' total)');
805
+ ? TEXT.none
806
+ : fill(TEXT.info, {
807
+ page: numberFormat.format(page + 1),
808
+ pages: numberFormat.format(totalPages),
809
+ matching: numberFormat.format(filtered.length),
810
+ total: numberFormat.format(rows.length)
811
+ });
543
812
  prevBtn.disabled = page <= 0;
544
813
  nextBtn.disabled = page >= totalPages - 1;
545
814
  }