@govtechsg/oobee 0.10.85 → 0.10.86

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 (38) hide show
  1. package/.github/workflows/image.yml +3 -2
  2. package/.github/workflows/publish.yml +10 -0
  3. package/DETAILS.md +29 -0
  4. package/dist/combine.js +1 -1
  5. package/dist/constants/common.js +15 -4
  6. package/dist/constants/constants.js +604 -1
  7. package/dist/crawlers/commonCrawlerFunc.js +3 -2
  8. package/dist/crawlers/crawlSitemap.js +98 -80
  9. package/dist/crawlers/custom/utils.js +137 -31
  10. package/dist/crawlers/guards/urlGuard.js +8 -15
  11. package/dist/crawlers/runCustom.js +18 -11
  12. package/dist/generateOobeeClientScanner.js +570 -0
  13. package/dist/mergeAxeResults.js +5 -4
  14. package/dist/npmIndex.js +10 -2
  15. package/dist/proxyService.js +18 -3
  16. package/dist/services/s3Uploader.js +21 -10
  17. package/dist/static/ejs/partials/scripts/header/aboutScanModal/ScanConfiguration.ejs +2 -2
  18. package/dist/static/ejs/partials/scripts/ruleModal/constants.ejs +1 -761
  19. package/dist/static/ejs/summary.ejs +10 -5
  20. package/oobee-client-scanner.js +34992 -0
  21. package/package.json +2 -2
  22. package/src/combine.ts +3 -1
  23. package/src/constants/common.ts +22 -10
  24. package/src/constants/constants.ts +602 -1
  25. package/src/crawlers/commonCrawlerFunc.ts +4 -3
  26. package/src/crawlers/crawlSitemap.ts +116 -98
  27. package/src/crawlers/custom/utils.ts +143 -38
  28. package/src/crawlers/guards/urlGuard.ts +24 -31
  29. package/src/crawlers/runCustom.ts +29 -11
  30. package/src/generateOobeeClientScanner.ts +591 -0
  31. package/src/mergeAxeResults.ts +5 -3
  32. package/src/npmIndex.ts +12 -2
  33. package/src/proxyService.ts +25 -4
  34. package/src/services/s3Uploader.ts +23 -11
  35. package/src/static/ejs/partials/scripts/header/aboutScanModal/ScanConfiguration.ejs +2 -2
  36. package/src/static/ejs/partials/scripts/ruleModal/constants.ejs +1 -761
  37. package/src/static/ejs/summary.ejs +10 -5
  38. package/testStaticJSScanner.html +534 -0
@@ -0,0 +1,570 @@
1
+ /**
2
+ * generateOobeeClientScanner.ts
3
+ *
4
+ * Standalone script that generates oobee-client-scanner.js — a self-contained
5
+ * browser bundle that runs axe-core + oobee custom checks, returns results in
6
+ * the same JSON format as npmIndex's processAndSubmitResults, and reports
7
+ * telemetry to Sentry using the official Sentry JavaScript browser SDK.
8
+ *
9
+ * Usage (after `npm run build`):
10
+ * node dist/generateOobeeClientScanner.js [output-path]
11
+ *
12
+ * Default output: ./oobee-client-scanner.js (relative to cwd)
13
+ *
14
+ * Environment variables read at generation time:
15
+ * OOBEE_SENTRY_DSN — Sentry DSN to embed in the bundle (falls back to the
16
+ * default DSN in constants.ts if not set)
17
+ *
18
+ * Then in your HTML:
19
+ * <script src="oobee-client-scanner.js"></script>
20
+ * <script>
21
+ * window.oobee.scan({
22
+ * userInfo: { email: 'you@example.com', name: 'Your Name' },
23
+ * // scanMode: [string] choices: "default" | "disable-oobee" | "enable-wcag-aaa" | "disable-oobee,enable-wcag-aaa"
24
+ * disableOobee: false, // true → skip oobee custom checks
25
+ * enableWcagAaa: true, // true → also run WCAG AAA rules
26
+ * elementsToScan: [], // [] = full page; or CSS selectors / DOM nodes
27
+ * }).then(results => console.log(results));
28
+ *
29
+ * // Scroll to an element by CSS selector (item.xpath from scan results):
30
+ * window.oobee.scrollToElement(item.xpath);
31
+ * </script>
32
+ */
33
+ import { writeFileSync } from 'fs';
34
+ import path from 'path';
35
+ import { createRequire } from 'module';
36
+ import { fileURLToPath } from 'url';
37
+ import axe from 'axe-core';
38
+ import { a11yRuleShortDescriptionMap, a11yRuleLongDescriptionMap, a11yRuleStepByStepGuide, sentryConfig, wcagCriteriaLabels, formatWcagId, } from './constants/constants.js';
39
+ import { getOobeeFunctionsScript } from './npmIndex.js';
40
+ import { getVersion } from './utils.js';
41
+ const _require = createRequire(import.meta.url);
42
+ const _filename = fileURLToPath(import.meta.url);
43
+ const _dirname = path.dirname(_filename);
44
+ // ---------------------------------------------------------------------------
45
+ // Sentry config — DSN is read from process.env at generation time
46
+ // ---------------------------------------------------------------------------
47
+ const SENTRY_DSN = sentryConfig.dsn; // already resolves OOBEE_SENTRY_DSN || default
48
+ const APP_VERSION = getVersion();
49
+ const SENTRY_NODE_VERSION = (() => {
50
+ try {
51
+ return _require('@sentry/node/package.json').version;
52
+ }
53
+ catch {
54
+ return '9.47.1'; // safe fallback matching currently installed version
55
+ }
56
+ })();
57
+ // ---------------------------------------------------------------------------
58
+ // WCAG conformance helpers — formatWcagId and wcagCriteriaLabels are exported
59
+ // from constants.ts; embedded here so the browser bundle has the same logic.
60
+ // ---------------------------------------------------------------------------
61
+ const wcagConformanceScript = `
62
+ // Format a numeric WCAG criterion tag (mirrors formatWcagId in constants.ts).
63
+ // e.g. wcag143 → "WCAG 1.4.3", wcag1412 → "WCAG 1.4.12"
64
+ var _oobeeFormatWcagId = ${formatWcagId.toString()};
65
+
66
+ // Criteria → level map (mirrors wcagCriteriaLabels in constants.ts).
67
+ var _oobeeWcagCriteriaLabels = ${JSON.stringify(wcagCriteriaLabels, null, 2)};
68
+
69
+ /**
70
+ * Given an axe-core conformance array (e.g. ["wcag2a","wcag111","wcag143"]),
71
+ * returns the formatted criteria labels and the resolved level string —
72
+ * mirrors the logic used in ruleOffcanvas.ejs / AllIssues.ejs.
73
+ *
74
+ * Returns: { criteria: string[], level: string|null }
75
+ * criteria — e.g. ["WCAG 1.1.1", "WCAG 1.4.3"]
76
+ * level — e.g. "A", "AA", "AAA", or null if none found
77
+ */
78
+ function _oobeeFormatConformance(conformance) {
79
+ var wcagTags = (conformance || []).filter(function(c) { return c.startsWith('wcag'); });
80
+ var criteria = [];
81
+ var level = null;
82
+ wcagTags.forEach(function(tag) {
83
+ var formatted = _oobeeFormatWcagId(tag);
84
+ if (_oobeeWcagCriteriaLabels[formatted]) {
85
+ criteria.push(formatted);
86
+ if (!level) level = _oobeeWcagCriteriaLabels[formatted];
87
+ }
88
+ });
89
+ return { criteria: criteria, level: level };
90
+ }
91
+ `;
92
+ // ---------------------------------------------------------------------------
93
+ // filterAxeResults — browser-compatible (mirrors commonCrawlerFunc.ts)
94
+ // ---------------------------------------------------------------------------
95
+ const filterAxeResultsScript = `
96
+ function _oobeeTruncateHtml(html, maxBytes, suffix) {
97
+ maxBytes = maxBytes !== undefined ? maxBytes : 1024;
98
+ suffix = suffix !== undefined ? suffix : '\\u2026'; // '…'
99
+ var encoder = new TextEncoder();
100
+ if (encoder.encode(html).length <= maxBytes) return html;
101
+ var left = 0, right = html.length, result = '';
102
+ while (left <= right) {
103
+ var mid = Math.floor((left + right) / 2);
104
+ var truncated = html.slice(0, mid) + suffix;
105
+ var bytes = encoder.encode(truncated).length;
106
+ if (bytes <= maxBytes) { result = truncated; left = mid + 1; }
107
+ else { right = mid - 1; }
108
+ }
109
+ return result;
110
+ }
111
+
112
+ function _oobeeFilterAxeResults(axeResults, pageTitle) {
113
+ var violations = axeResults.violations || [];
114
+ var passes = axeResults.passes || [];
115
+ var incomplete = axeResults.incomplete || [];
116
+ var url = axeResults.url || (typeof window !== 'undefined' ? window.location.href : '');
117
+
118
+ var totalItems = 0;
119
+ var mustFix = { totalItems: 0, rules: {} };
120
+ var goodToFix = { totalItems: 0, rules: {} };
121
+ var needsReview = { totalItems: 0, rules: {} };
122
+ var passed = { totalItems: 0, rules: {} };
123
+
124
+ var wcagLevelRegex = /^wcag\\d+a+$/;
125
+
126
+ function processItem(item, displayNeedsReview) {
127
+ var rule = item.id;
128
+ var description = item.help;
129
+ var helpUrl = item.helpUrl;
130
+ var tags = item.tags || [];
131
+ var nodes = item.nodes || [];
132
+
133
+ if (rule === 'frame-tested') return;
134
+
135
+ var conformance = tags.filter(function(t) {
136
+ return t.startsWith('wcag') || t === 'best-practice';
137
+ });
138
+
139
+ if (conformance[0] !== 'best-practice' && !wcagLevelRegex.test(conformance[0])) {
140
+ conformance.sort(function(a, b) {
141
+ if (wcagLevelRegex.test(a) && !wcagLevelRegex.test(b)) return -1;
142
+ if (!wcagLevelRegex.test(a) && wcagLevelRegex.test(b)) return 1;
143
+ return 0;
144
+ });
145
+ }
146
+
147
+ var hasWcagA = conformance.some(function(t) { return /^wcag\\d*a$/.test(t); });
148
+ var hasWcagAA = conformance.some(function(t) { return /^wcag\\d*aa$/.test(t); });
149
+
150
+ var category = displayNeedsReview ? needsReview
151
+ : (hasWcagA || hasWcagAA) ? mustFix
152
+ : goodToFix;
153
+
154
+ nodes.forEach(function(node) {
155
+ var html = node.html || '';
156
+ var failureSummary = node.failureSummary || '';
157
+ var target = node.target || [];
158
+ var axeImpact = node.impact;
159
+
160
+ if (!(rule in category.rules)) {
161
+ category.rules[rule] = {
162
+ rule: rule, description: description, axeImpact: axeImpact,
163
+ helpUrl: helpUrl, conformance: conformance, totalItems: 0, items: [],
164
+ };
165
+ }
166
+
167
+ var message = displayNeedsReview
168
+ ? failureSummary.slice(failureSummary.indexOf('\\n') + 1).trim()
169
+ : failureSummary;
170
+
171
+ var finalHtml = html;
172
+ if (html.includes('<\\/script>')) {
173
+ finalHtml = html.replaceAll('<\\/script>', '&lt;/script>');
174
+ }
175
+ finalHtml = _oobeeTruncateHtml(finalHtml);
176
+
177
+ var xpath = (target.length === 1 && typeof target[0] === 'string') ? target[0] : undefined;
178
+
179
+ category.rules[rule].items.push({
180
+ html: finalHtml, message: message, xpath: xpath,
181
+ displayNeedsReview: displayNeedsReview || undefined,
182
+ });
183
+ category.rules[rule].totalItems += 1;
184
+ category.totalItems += 1;
185
+ totalItems += 1;
186
+ });
187
+ }
188
+
189
+ violations.forEach(function(item) { processItem(item, false); });
190
+ incomplete.forEach(function(item) { processItem(item, true); });
191
+
192
+ passes.forEach(function(item) {
193
+ var rule = item.id;
194
+ var description = item.help;
195
+ var axeImpact = item.impact;
196
+ var helpUrl = item.helpUrl;
197
+ var tags = item.tags || [];
198
+ var nodes = item.nodes || [];
199
+
200
+ if (rule === 'frame-tested') return;
201
+
202
+ var conformance = tags.filter(function(t) {
203
+ return t.startsWith('wcag') || t === 'best-practice';
204
+ });
205
+
206
+ nodes.forEach(function(node) {
207
+ if (!(rule in passed.rules)) {
208
+ passed.rules[rule] = {
209
+ rule: rule, description: description, axeImpact: axeImpact,
210
+ helpUrl: helpUrl, conformance: conformance, totalItems: 0, items: [],
211
+ };
212
+ }
213
+ var passedXpath = (node.target && node.target.length === 1 && typeof node.target[0] === 'string')
214
+ ? node.target[0] : undefined;
215
+ passed.rules[rule].items.push({
216
+ html: _oobeeTruncateHtml(node.html || ''), screenshotPath: '',
217
+ message: '', xpath: passedXpath,
218
+ });
219
+ passed.totalItems += 1;
220
+ passed.rules[rule].totalItems += 1;
221
+ totalItems += 1;
222
+ });
223
+ });
224
+
225
+ return {
226
+ url: url, pageTitle: pageTitle, totalItems: totalItems,
227
+ mustFix: mustFix, goodToFix: goodToFix, needsReview: needsReview, passed: passed,
228
+ };
229
+ }
230
+ `;
231
+ // ---------------------------------------------------------------------------
232
+ // Sentry telemetry — uses the official Sentry JavaScript browser SDK loaded
233
+ // from CDN (same major version as the installed @sentry/node build).
234
+ // DSN and app version are baked in at generation time.
235
+ // ---------------------------------------------------------------------------
236
+ const sentryTelemetryScript = (dsn, appVersion, sentryVersion) => `
237
+ var _oobeeSentryDsn = ${JSON.stringify(dsn)};
238
+ var _oobeeAppVersion = ${JSON.stringify(appVersion)};
239
+ var _oobeeSentryVersion = ${JSON.stringify(sentryVersion)};
240
+ var _oobeeSentryInitialized = false;
241
+ var _oobeeSentryLoadPromise = null;
242
+
243
+ /**
244
+ * Lazily load the Sentry JavaScript browser SDK from CDN and return the
245
+ * global Sentry object. Subsequent calls reuse the same promise.
246
+ */
247
+ function _oobeeLoadSentry() {
248
+ if (_oobeeSentryLoadPromise) return _oobeeSentryLoadPromise;
249
+
250
+ _oobeeSentryLoadPromise = new Promise(function(resolve, reject) {
251
+ // Already present (e.g. host page loaded Sentry itself)
252
+ if (window.Sentry && typeof window.Sentry.init === 'function') {
253
+ resolve(window.Sentry);
254
+ return;
255
+ }
256
+ var script = document.createElement('script');
257
+ script.src = 'https://browser.sentry-cdn.com/' + _oobeeSentryVersion + '/bundle.min.js';
258
+ script.crossOrigin = 'anonymous';
259
+ script.onload = function() {
260
+ if (window.Sentry && typeof window.Sentry.init === 'function') {
261
+ resolve(window.Sentry);
262
+ } else {
263
+ reject(new Error('[oobee] Sentry SDK loaded but window.Sentry not found'));
264
+ }
265
+ };
266
+ script.onerror = function() {
267
+ reject(new Error('[oobee] Failed to load Sentry browser SDK from CDN'));
268
+ };
269
+ document.head.appendChild(script);
270
+ });
271
+
272
+ return _oobeeSentryLoadPromise;
273
+ }
274
+
275
+ /**
276
+ * Build WCAG occurrence map and per-criterion level map from scan results.
277
+ * Mirrors the logic in npmIndex.ts processAndSubmitResults.
278
+ */
279
+ function _oobeeBuildWcagData(results) {
280
+ var wcagOccurrencesMap = {}; // { wcag111: 3, wcag412: 1, ... }
281
+ var criterionLevel = {}; // { wcag111: 'a', wcag143: 'aa', ... }
282
+ var criterionRegex = /^wcag[0-9]{3,4}$/;
283
+
284
+ ['mustFix', 'goodToFix', 'needsReview'].forEach(function(cat) {
285
+ var catData = results[cat];
286
+ if (!catData || !catData.rules) return;
287
+
288
+ Object.values(catData.rules).forEach(function(rule) {
289
+ if (!rule.conformance) return;
290
+
291
+ // Derive level from conformance level-tags (wcag2a / wcag2aa / wcag2aaa)
292
+ var level = '';
293
+ var criteria = [];
294
+ rule.conformance.forEach(function(c) {
295
+ if (/^wcag\\d+aaa$/.test(c)) { if (!level) level = 'aaa'; }
296
+ else if (/^wcag\\d+aa$/.test(c)) { if (!level) level = 'aa'; }
297
+ else if (/^wcag\\d+a$/.test(c)) { if (!level) level = 'a'; }
298
+ else if (criterionRegex.test(c)) { criteria.push(c); }
299
+ });
300
+
301
+ criteria.forEach(function(c) {
302
+ // Keep the most severe level seen for a criterion
303
+ var existing = criterionLevel[c];
304
+ if (!existing || level === 'aaa' ||
305
+ (level === 'aa' && existing === 'a') ||
306
+ (level === 'a' && !existing)) {
307
+ criterionLevel[c] = level;
308
+ }
309
+ });
310
+
311
+ // Only count violations for the occurrence map (mustFix + goodToFix)
312
+ if (cat === 'mustFix' || cat === 'goodToFix') {
313
+ criteria.forEach(function(c) {
314
+ wcagOccurrencesMap[c] = (wcagOccurrencesMap[c] || 0) + rule.totalItems;
315
+ });
316
+ }
317
+ });
318
+ });
319
+
320
+ return { wcagOccurrencesMap: wcagOccurrencesMap, criterionLevel: criterionLevel };
321
+ }
322
+
323
+ /**
324
+ * Send an "Accessibility Scan Page" event to Sentry using the official
325
+ * Sentry JavaScript browser SDK API:
326
+ * Sentry.init / Sentry.setUser / Sentry.captureEvent / Sentry.flush
327
+ *
328
+ * @param {object} results - Full oobee scan result from window.oobee.scan()
329
+ * @param {object} userInfo - { email, name } provided by the implementer
330
+ */
331
+ async function _oobeeSendSentryTelemetry(results, userInfo) {
332
+ if (!_oobeeSentryDsn) return;
333
+
334
+ try {
335
+ var Sentry = await _oobeeLoadSentry();
336
+
337
+ // Initialise once per page load
338
+ if (!_oobeeSentryInitialized) {
339
+ Sentry.init({
340
+ dsn: _oobeeSentryDsn,
341
+ tracesSampleRate: 1.0,
342
+ });
343
+ _oobeeSentryInitialized = true;
344
+ }
345
+
346
+ // ── User context ────────────────────────────────────────────────────
347
+ Sentry.setUser({
348
+ email: (userInfo && userInfo.email) || undefined,
349
+ username: (userInfo && userInfo.name) || undefined,
350
+ });
351
+
352
+ // ── WCAG breakdown tags ─────────────────────────────────────────────
353
+ var wcagData = _oobeeBuildWcagData(results);
354
+ var wcagOccurrencesMap = wcagData.wcagOccurrencesMap;
355
+ var criterionLevel = wcagData.criterionLevel;
356
+
357
+ var tags = {};
358
+ var wcagCriteriaBreakdown = {};
359
+
360
+ // Format: wcag111a_Occurrences (mirrors sentryTelemetry.ts formatWcagTag)
361
+ Object.keys(wcagOccurrencesMap).forEach(function(wcagId) {
362
+ var level = criterionLevel[wcagId] || '';
363
+ var formattedTag = wcagId + level + '_Occurrences';
364
+ tags[formattedTag] = String(wcagOccurrencesMap[wcagId]);
365
+ wcagCriteriaBreakdown[formattedTag] = { count: wcagOccurrencesMap[wcagId] };
366
+ });
367
+
368
+ // ── Category counts & occurrences ───────────────────────────────────
369
+ var mustFixRules = results.mustFix ? Object.keys(results.mustFix.rules) : [];
370
+ var goodToFixRules = results.goodToFix ? Object.keys(results.goodToFix.rules) : [];
371
+ var needsReviewRules = results.needsReview ? Object.keys(results.needsReview.rules) : [];
372
+
373
+ tags['version'] = _oobeeAppVersion;
374
+ tags['WCAG-MustFix-Count'] = String(mustFixRules.length);
375
+ tags['WCAG-GoodToFix-Count'] = String(goodToFixRules.length);
376
+ tags['WCAG-NeedsReview-Count'] = String(needsReviewRules.length);
377
+ tags['WCAG-MustFix-Occurrences'] = String(results.mustFix ? results.mustFix.totalItems : 0);
378
+ tags['WCAG-GoodToFix-Occurrences'] = String(results.goodToFix ? results.goodToFix.totalItems : 0);
379
+ tags['WCAG-NeedsReview-Occurrences'] = String(results.needsReview ? results.needsReview.totalItems : 0);
380
+ tags['Pages-Scanned-Count'] = '1';
381
+
382
+ // ── Capture event ───────────────────────────────────────────────────
383
+ Sentry.captureEvent({
384
+ message: 'Accessibility Scan Page',
385
+ level: 'info',
386
+ tags: Object.assign({}, tags, {
387
+ event_type: 'accessibility_scan',
388
+ scanType: 'browser',
389
+ browser: 'browser',
390
+ entryUrl: window.location.href,
391
+ }),
392
+ extra: {
393
+ wcagBreakdown: wcagCriteriaBreakdown,
394
+ reportCounts: {
395
+ mustFix: { issues: mustFixRules.length, occurrences: results.mustFix ? results.mustFix.totalItems : 0 },
396
+ goodToFix: { issues: goodToFixRules.length, occurrences: results.goodToFix ? results.goodToFix.totalItems : 0 },
397
+ needsReview: { issues: needsReviewRules.length, occurrences: results.needsReview ? results.needsReview.totalItems : 0 },
398
+ },
399
+ },
400
+ });
401
+
402
+ await Sentry.flush(2000);
403
+
404
+ } catch (err) {
405
+ // Telemetry failures must never break the caller
406
+ console.error('[oobee-client-scanner] Sentry telemetry error:', err);
407
+ }
408
+ }
409
+ `;
410
+ // ---------------------------------------------------------------------------
411
+ // scan API — enriches results and fires Sentry telemetry
412
+ // ---------------------------------------------------------------------------
413
+ const scanApiScript = (shortDescMap, longDescMap, stepByStepMap) => `
414
+ var _oobeeShortDescMap = ${JSON.stringify(shortDescMap)};
415
+ var _oobeeLongDescMap = ${JSON.stringify(longDescMap)};
416
+ var _oobeeStepByStepGuide = ${JSON.stringify(stepByStepMap)};
417
+
418
+ /**
419
+ * window.oobee.scan(options?) — scan the current page for accessibility issues.
420
+ *
421
+ * @param {object} [options]
422
+ * @param {boolean} [options.disableOobee=false] Disable oobee custom checks.
423
+ * @param {boolean} [options.enableWcagAaa=false] Include WCAG 2 AAA rules.
424
+ * @param {Array} [options.elementsToScan=[]] CSS selectors / DOM nodes to
425
+ * scope the scan; [] = full page.
426
+ * @param {object} [options.userInfo] Implementer-supplied identity.
427
+ * @param {string} [options.userInfo.email] User e-mail for Sentry telemetry.
428
+ * @param {string} [options.userInfo.name] User name for Sentry telemetry.
429
+ *
430
+ * @returns {Promise<object>} Oobee scan result (same shape as npmIndex JSON output).
431
+ */
432
+ window.oobee = {
433
+ scan: async function(options) {
434
+ var opts = options || {};
435
+ var disableOobee = opts.disableOobee !== undefined ? !!opts.disableOobee : false;
436
+ var enableWcagAaa = opts.enableWcagAaa !== undefined ? !!opts.enableWcagAaa : false;
437
+ var elementsToScan = opts.elementsToScan || [];
438
+ var userInfo = opts.userInfo || {};
439
+
440
+ // Update window globals read by runA11yScan
441
+ window.disableOobee = disableOobee;
442
+ window.enableWcagAaa = enableWcagAaa;
443
+
444
+ // Run axe-core + oobee custom checks
445
+ var scanResult = await window.runA11yScan(elementsToScan, '');
446
+
447
+ // Convert raw axe results into oobee category structure
448
+ var filtered = _oobeeFilterAxeResults(scanResult.axeScanResults, scanResult.pageTitle);
449
+
450
+ // Enrich rules with oobee knowledge-base descriptions
451
+ ['mustFix', 'goodToFix', 'needsReview'].forEach(function(category) {
452
+ var cat = filtered[category];
453
+ if (!cat || !cat.rules) return;
454
+ Object.keys(cat.rules).forEach(function(ruleId) {
455
+ var rule = cat.rules[ruleId];
456
+ rule.shortDescription = _oobeeShortDescMap[ruleId];
457
+ rule.longDescription = _oobeeLongDescMap[ruleId];
458
+ rule.stepByStepGuide = _oobeeStepByStepGuide[ruleId];
459
+ });
460
+ });
461
+
462
+ // Fire-and-forget Sentry telemetry (errors are caught internally)
463
+ _oobeeSendSentryTelemetry(filtered, userInfo);
464
+
465
+ return filtered;
466
+ },
467
+
468
+ /**
469
+ * Format a raw conformance tag array into criteria labels + level.
470
+ * Mirrors ruleOffcanvas.ejs / AllIssues.ejs logic (single source of truth
471
+ * via formatWcagId + wcagCriteriaLabels exported from constants.ts).
472
+ *
473
+ * @param {string[]} conformance e.g. ["wcag2a","wcag111","wcag143"]
474
+ * @returns {{ criteria: string[], level: string|null }}
475
+ * e.g. { criteria: ["WCAG 1.1.1","WCAG 1.4.3"], level: "A" }
476
+ */
477
+ formatConformance: _oobeeFormatConformance,
478
+
479
+ /**
480
+ * Scroll the element matching the given CSS selector into view and briefly
481
+ * highlight it with an outline flash. The selector comes from item.xpath
482
+ * in the scan results (axe-core stores CSS selectors there).
483
+ *
484
+ * @param {string} selector CSS selector, e.g. "button:nth-child(2)"
485
+ */
486
+ scrollToElement: function(selector) {
487
+ if (!selector) return;
488
+ var el;
489
+ try { el = document.querySelector(selector); } catch (e) { return; }
490
+ if (!el) return;
491
+
492
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
493
+
494
+ // Brief outline flash so the element is easy to spot
495
+ var prev = el.style.outline;
496
+ el.style.outline = '3px solid #fd7e14';
497
+ setTimeout(function() { el.style.outline = prev; }, 1800);
498
+ },
499
+ };
500
+
501
+ console.log(
502
+ '[oobee-client-scanner] Ready. Call window.oobee.scan() to scan this page.'
503
+ );
504
+ `;
505
+ // ---------------------------------------------------------------------------
506
+ // Assemble the full client bundle
507
+ // ---------------------------------------------------------------------------
508
+ function generateClientBundle() {
509
+ const axeSource = axe.source;
510
+ const oobeeFunctions = getOobeeFunctionsScript(false, false);
511
+ return `/**
512
+ * oobee-client-scanner.js — auto-generated by generateOobeeClientScanner.ts
513
+ * DO NOT EDIT MANUALLY. Re-generate with: node dist/generateOobeeClientScanner.js
514
+ *
515
+ * Embedded at generation time:
516
+ * App version : ${APP_VERSION}
517
+ * Sentry DSN : (from OOBEE_SENTRY_DSN env var or constants.ts default)
518
+ * Sentry SDK : @sentry/browser ${SENTRY_NODE_VERSION} (loaded from CDN at runtime)
519
+ *
520
+ * Usage:
521
+ * <script src="oobee-client-scanner.js"></script>
522
+ * <script>
523
+ * window.oobee.scan({
524
+ * userInfo: { email: 'you@example.com', name: 'Your Name' },
525
+ * // scanMode: [string] choices: "default" | "disable-oobee" | "enable-wcag-aaa" | "disable-oobee,enable-wcag-aaa"
526
+ * // "default" — axe-core + oobee custom checks, WCAG A/AA only
527
+ * // "disable-oobee" — axe-core only, no oobee custom checks
528
+ * // "enable-wcag-aaa" — axe-core + oobee + WCAG AAA rules
529
+ * // "disable-oobee,enable-wcag-aaa" — axe-core + WCAG AAA, no oobee checks
530
+ * disableOobee: false, // true → same as "disable-oobee"
531
+ * enableWcagAaa: true, // true → same as "enable-wcag-aaa"
532
+ * elementsToScan: [], // [] = full page; or pass CSS selectors / DOM nodes
533
+ * }).then(results => console.log(JSON.stringify(results, null, 2)));
534
+ * </script>
535
+ */
536
+ (function () {
537
+ 'use strict';
538
+
539
+ // ── axe-core ──────────────────────────────────────────────────────────────
540
+ ${axeSource}
541
+
542
+ // ── Oobee helper functions + getAxeConfiguration + runA11yScan ───────────
543
+ ${oobeeFunctions}
544
+
545
+ // ── filterAxeResults (browser-compatible) ─────────────────────────────────
546
+ ${filterAxeResultsScript}
547
+
548
+ // ── WCAG conformance helpers (formatWcagId + wcagCriteriaLabels from constants.ts) ──
549
+ ${wcagConformanceScript}
550
+
551
+ // ── Sentry browser telemetry (Sentry JS SDK, loaded from CDN) ────────────
552
+ ${sentryTelemetryScript(SENTRY_DSN, APP_VERSION, SENTRY_NODE_VERSION)}
553
+
554
+ // ── Description maps + window.oobee API ───────────────────────────────────
555
+ ${scanApiScript(a11yRuleShortDescriptionMap, a11yRuleLongDescriptionMap, a11yRuleStepByStepGuide)}
556
+ })();
557
+ `;
558
+ }
559
+ // ---------------------------------------------------------------------------
560
+ // Write output file
561
+ // ---------------------------------------------------------------------------
562
+ const outputArg = process.argv[2];
563
+ const outputPath = outputArg
564
+ ? path.resolve(outputArg)
565
+ : path.resolve(process.cwd(), 'oobee-client-scanner.js');
566
+ writeFileSync(outputPath, generateClientBundle(), 'utf-8');
567
+ console.log(`Generated: ${outputPath}`);
568
+ console.log(` App version : ${APP_VERSION}`);
569
+ console.log(` Sentry DSN : ${SENTRY_DSN.slice(0, 40)}…`);
570
+ console.log(` Sentry SDK : @sentry/browser ${SENTRY_NODE_VERSION} (CDN)`);
@@ -5,7 +5,7 @@ import printMessage from 'print-message';
5
5
  import path from 'path';
6
6
  import ejs from 'ejs';
7
7
  import { fileURLToPath } from 'url';
8
- import constants, { BrowserTypes, ScannerTypes, WCAGclauses, a11yRuleShortDescriptionMap, disabilityBadgesMap, a11yRuleLongDescriptionMap, } from './constants/constants.js';
8
+ import constants, { BrowserTypes, ScannerTypes, WCAGclauses, a11yRuleShortDescriptionMap, disabilityBadgesMap, a11yRuleLongDescriptionMap, a11yRuleStepByStepGuide, } from './constants/constants.js';
9
9
  import { getBrowserToRun, getPlaywrightLaunchOptions } from './constants/common.js';
10
10
  import { createScreenshotsFolder, getStoragePath, getVersion, getWcagPassPercentage, getProgressPercentage, retryFunction, zipResults, getIssuesPercentage, register, } from './utils.js';
11
11
  import { consoleLogger } from './logs.js';
@@ -302,9 +302,9 @@ const wcagOccurrencesMap = new Map();
302
302
  const pushResults = async (pageResults, allIssues, isCustomFlow) => {
303
303
  const { url, pageTitle, filePath } = pageResults;
304
304
  const totalIssuesInPage = new Set();
305
- Object.keys(pageResults.mustFix.rules).forEach(k => totalIssuesInPage.add(k));
306
- Object.keys(pageResults.goodToFix.rules).forEach(k => totalIssuesInPage.add(k));
307
- Object.keys(pageResults.needsReview.rules).forEach(k => totalIssuesInPage.add(k));
305
+ Object.keys(pageResults.mustFix?.rules ?? {}).forEach(k => totalIssuesInPage.add(k));
306
+ Object.keys(pageResults.goodToFix?.rules ?? {}).forEach(k => totalIssuesInPage.add(k));
307
+ Object.keys(pageResults.needsReview?.rules ?? {}).forEach(k => totalIssuesInPage.add(k));
308
308
  allIssues.topFiveMostIssues.push({
309
309
  url,
310
310
  pageTitle,
@@ -592,6 +592,7 @@ generateJsonFiles = false) => {
592
592
  a11yRuleShortDescriptionMap,
593
593
  disabilityBadgesMap,
594
594
  a11yRuleLongDescriptionMap,
595
+ a11yRuleStepByStepGuide,
595
596
  wcagCriteriaLabels: constants.wcagCriteriaLabels,
596
597
  scanPagesDetail: {
597
598
  pagesAffected: [],
package/dist/npmIndex.js CHANGED
@@ -4,7 +4,7 @@ import axe from 'axe-core';
4
4
  import { JSDOM } from 'jsdom';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { EnqueueStrategy } from 'crawlee';
7
- import constants, { BrowserTypes, RuleFlags, ScannerTypes } from './constants/constants.js';
7
+ import constants, { BrowserTypes, RuleFlags, ScannerTypes, a11yRuleShortDescriptionMap, a11yRuleLongDescriptionMap, a11yRuleStepByStepGuide } from './constants/constants.js';
8
8
  import { deleteClonedProfiles, getBrowserToRun, getPlaywrightLaunchOptions, submitForm, } from './constants/common.js';
9
9
  import { createCrawleeSubFolders, filterAxeResults } from './crawlers/commonCrawlerFunc.js';
10
10
  import { createAndUpdateResultsFolders, getVersion } from './utils.js';
@@ -489,6 +489,10 @@ const processAndSubmitResults = async (scanData, name, email, metadata) => {
489
489
  if (constants.a11yRuleShortDescriptionMap[ruleId]) {
490
490
  mergedResults[category].rules[ruleId].description = constants.a11yRuleShortDescriptionMap[ruleId];
491
491
  }
492
+ // Add short description, long description and step-by-step guide
493
+ mergedResults[category].rules[ruleId].shortDescription = a11yRuleShortDescriptionMap[ruleId];
494
+ mergedResults[category].rules[ruleId].longDescription = a11yRuleLongDescriptionMap[ruleId];
495
+ mergedResults[category].rules[ruleId].stepByStepGuide = a11yRuleStepByStepGuide[ruleId];
492
496
  // Add url to items
493
497
  mergedResults[category].rules[ruleId].items.forEach((item) => {
494
498
  item.url = result.url;
@@ -554,6 +558,10 @@ const processAndSubmitResults = async (scanData, name, email, metadata) => {
554
558
  if (constants.a11yRuleShortDescriptionMap[rule.rule]) {
555
559
  rule.description = constants.a11yRuleShortDescriptionMap[rule.rule];
556
560
  }
561
+ // Add short description, long description and step-by-step guide
562
+ rule.shortDescription = a11yRuleShortDescriptionMap[rule.rule];
563
+ rule.longDescription = a11yRuleLongDescriptionMap[rule.rule];
564
+ rule.stepByStepGuide = a11yRuleStepByStepGuide[rule.rule];
557
565
  if (rule.items) {
558
566
  rule.items.forEach((item) => {
559
567
  // Ensure item URL matches the result URL
@@ -637,4 +645,4 @@ export const scanPage = async (pages, config) => {
637
645
  }
638
646
  return processAndSubmitResults(scanData, name, email, metadata);
639
647
  };
640
- export { RuleFlags };
648
+ export { RuleFlags, a11yRuleLongDescriptionMap, a11yRuleStepByStepGuide, getOobeeFunctionsScript };