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