@govtechsg/oobee 0.10.85 → 0.10.87

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 (62) hide show
  1. package/.github/workflows/publish.yml +10 -0
  2. package/DETAILS.md +29 -0
  3. package/dist/cli.js +18 -5
  4. package/dist/combine.js +3 -1
  5. package/dist/constants/cliFunctions.js +2 -2
  6. package/dist/constants/common.js +70 -17
  7. package/dist/constants/constants.js +604 -1
  8. package/dist/crawlers/commonCrawlerFunc.js +3 -2
  9. package/dist/crawlers/crawlDomain.js +38 -13
  10. package/dist/crawlers/crawlIntelligentSitemap.js +62 -30
  11. package/dist/crawlers/crawlSitemap.js +141 -84
  12. package/dist/crawlers/custom/utils.js +218 -71
  13. package/dist/crawlers/guards/urlGuard.js +8 -15
  14. package/dist/crawlers/runCustom.js +18 -11
  15. package/dist/generateHtmlReport.js +18 -11
  16. package/dist/generateOobeeClientScanner.js +570 -0
  17. package/dist/mergeAxeResults/itemReferences.js +60 -25
  18. package/dist/mergeAxeResults/sentryTelemetry.js +4 -1
  19. package/dist/mergeAxeResults.js +23 -13
  20. package/dist/npmIndex.js +10 -2
  21. package/dist/proxyService.js +18 -3
  22. package/dist/services/s3Uploader.js +21 -10
  23. package/dist/static/ejs/partials/scripts/decodeUnzipParse.ejs +6 -3
  24. package/dist/static/ejs/partials/scripts/header/aboutScanModal/ScanConfiguration.ejs +2 -2
  25. package/dist/static/ejs/partials/scripts/ruleModal/constants.ejs +1 -761
  26. package/dist/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +38 -2
  27. package/dist/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +1 -1
  28. package/dist/static/ejs/partials/scripts/ruleModal/ruleOffcanvas.ejs +4 -4
  29. package/dist/static/ejs/summary.ejs +19 -8
  30. package/dist/utils.js +4 -3
  31. package/fix-summary-html-oom-pr.md +62 -0
  32. package/oobee-client-scanner.js +34992 -0
  33. package/package.json +5 -5
  34. package/src/cli.ts +19 -5
  35. package/src/combine.ts +5 -1
  36. package/src/constants/cliFunctions.ts +2 -2
  37. package/src/constants/common.ts +87 -22
  38. package/src/constants/constants.ts +602 -1
  39. package/src/crawlers/commonCrawlerFunc.ts +4 -3
  40. package/src/crawlers/crawlDomain.ts +39 -13
  41. package/src/crawlers/crawlIntelligentSitemap.ts +63 -30
  42. package/src/crawlers/crawlSitemap.ts +165 -100
  43. package/src/crawlers/custom/utils.ts +241 -80
  44. package/src/crawlers/guards/urlGuard.ts +24 -31
  45. package/src/crawlers/runCustom.ts +29 -11
  46. package/src/generateHtmlReport.ts +21 -11
  47. package/src/generateOobeeClientScanner.ts +591 -0
  48. package/src/mergeAxeResults/itemReferences.ts +70 -26
  49. package/src/mergeAxeResults/sentryTelemetry.ts +4 -1
  50. package/src/mergeAxeResults.ts +26 -14
  51. package/src/npmIndex.ts +12 -2
  52. package/src/proxyService.ts +25 -4
  53. package/src/services/s3Uploader.ts +23 -11
  54. package/src/static/ejs/partials/scripts/decodeUnzipParse.ejs +6 -3
  55. package/src/static/ejs/partials/scripts/header/aboutScanModal/ScanConfiguration.ejs +2 -2
  56. package/src/static/ejs/partials/scripts/ruleModal/constants.ejs +1 -761
  57. package/src/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +38 -2
  58. package/src/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +1 -1
  59. package/src/static/ejs/partials/scripts/ruleModal/ruleOffcanvas.ejs +4 -4
  60. package/src/static/ejs/summary.ejs +19 -8
  61. package/src/utils.ts +4 -3
  62. 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)`);
@@ -25,31 +25,66 @@ export const buildHtmlGroups = (rule, items, pageUrl) => {
25
25
  }
26
26
  });
27
27
  };
28
+ /*
29
+ // Commenting this out for now as we are not including htmlGroups in the embedded report payload to keep it lean.
30
+ // We can revisit this if we want to include htmlGroups in the future and need a reference builder for it.
31
+ const toHtmlGroupReference = (item: any) => {
32
+ if (typeof item === 'string') {
33
+ return item;
34
+ }
35
+
36
+ return `${item?.html || 'No HTML element'}\x00${item?.xpath || ''}`;
37
+ };
38
+
39
+ const cloneCategoryWithReferenceItems = (category: ScanCategory): ScanCategory =>
40
+ ({
41
+ ...category,
42
+ rules: category.rules.map(
43
+ rule =>
44
+ ({
45
+ ...rule,
46
+ pagesAffected: rule.pagesAffected.map(
47
+ page => {
48
+ const { items, ...pageWithoutItems } = page;
49
+
50
+ return {
51
+ ...pageWithoutItems,
52
+ itemsCount: page.itemsCount ?? (Array.isArray(items) ? items.length : 0),
53
+ items: Array.isArray(items) ? items.map(toHtmlGroupReference) : items,
54
+ } as any;
55
+ },
56
+ ),
57
+ }) as any,
58
+ ),
59
+ }) as ScanCategory;
60
+ */
61
+ const cloneCategoryLight = (category, includeHtmlGroups) => ({
62
+ ...category,
63
+ rules: category.rules.map(rule => ({
64
+ rule: rule.rule,
65
+ description: rule.description,
66
+ helpUrl: rule.helpUrl,
67
+ conformance: rule.conformance,
68
+ totalItems: rule.totalItems,
69
+ axeImpact: rule.axeImpact,
70
+ ...(includeHtmlGroups && rule.htmlGroups ? { htmlGroups: rule.htmlGroups } : {}),
71
+ pagesAffected: rule.pagesAffected.map(page => ({
72
+ url: page.url,
73
+ pageTitle: page.pageTitle,
74
+ itemsCount: page.itemsCount ?? (Array.isArray(page.items) ? page.items.length : 0),
75
+ })),
76
+ })),
77
+ });
28
78
  /**
29
- * Converts items in pagesAffected to references (html\x00xpath composite keys) for embedding in HTML report.
30
- * Additionally, it deep-clones allIssues, replaces page.items objects with composite reference keys.
31
- * Those refs are specifically for htmlGroups lookup (html + xpath).
79
+ * Builds the embedded HTML-report payload from the full scan items.
80
+ * Includes htmlGroups for non-passed categories (Group by HTML Element),
81
+ * excludes them from passed to keep payload within browser memory limits.
32
82
  */
33
- export const convertItemsToReferences = (allIssues) => {
34
- const cloned = JSON.parse(JSON.stringify(allIssues));
35
- ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
36
- if (!cloned.items[category]?.rules)
37
- return;
38
- cloned.items[category].rules.forEach((rule) => {
39
- if (!rule.pagesAffected || !rule.htmlGroups)
40
- return;
41
- rule.pagesAffected.forEach((page) => {
42
- if (!page.items)
43
- return;
44
- page.items = page.items.map((item) => {
45
- if (typeof item === 'string')
46
- return item; // Already a reference
47
- // Use composite key matching buildHtmlGroups
48
- const htmlKey = `${item.html || 'No HTML element'}\x00${item.xpath || ''}`;
49
- return htmlKey;
50
- });
51
- });
52
- });
53
- });
54
- return cloned;
83
+ export const convertItemsToReferences = (source) => {
84
+ return {
85
+ mustFix: cloneCategoryLight(source.items.mustFix, true),
86
+ goodToFix: cloneCategoryLight(source.items.goodToFix, true),
87
+ needsReview: cloneCategoryLight(source.items.needsReview, true),
88
+ passed: cloneCategoryLight(source.items.passed, false),
89
+ };
55
90
  };
@@ -110,7 +110,10 @@ const sendWcagBreakdownToSentry = async (appVersion, wcagBreakdown, ruleIdJson,
110
110
  event_type: 'accessibility_scan',
111
111
  scanType: scanInfo.scanType,
112
112
  browser: scanInfo.browser,
113
- entryUrl: scanInfo.entryUrl,
113
+ entryUrl: process.env.OOBEE_SCAN_METADATA ?? scanInfo.entryUrl,
114
+ ...(process.env.OOBEE_SCAN_PRODUCT && {
115
+ scanProduct: process.env.OOBEE_SCAN_PRODUCT,
116
+ }),
114
117
  },
115
118
  user: {
116
119
  ...(scanInfo.email && scanInfo.name