@govtechsg/oobee 0.10.21 → 0.10.28

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 (39) hide show
  1. package/.github/workflows/docker-test.yml +1 -1
  2. package/DETAILS.md +40 -25
  3. package/Dockerfile +41 -47
  4. package/LICENSE-3RD-PARTY-REPORT.txt +448 -0
  5. package/LICENSE-3RD-PARTY.txt +19913 -0
  6. package/README.md +10 -2
  7. package/__mocks__/mock-report.html +1503 -1360
  8. package/package.json +8 -4
  9. package/scripts/decodeUnzipParse.js +29 -0
  10. package/scripts/install_oobee_dependencies.command +2 -2
  11. package/scripts/install_oobee_dependencies.ps1 +3 -3
  12. package/src/cli.ts +3 -2
  13. package/src/constants/cliFunctions.ts +16 -2
  14. package/src/constants/common.ts +29 -5
  15. package/src/constants/constants.ts +28 -26
  16. package/src/constants/questions.ts +4 -1
  17. package/src/crawlers/commonCrawlerFunc.ts +114 -152
  18. package/src/crawlers/crawlDomain.ts +25 -25
  19. package/src/crawlers/crawlIntelligentSitemap.ts +7 -1
  20. package/src/crawlers/crawlLocalFile.ts +1 -1
  21. package/src/crawlers/crawlSitemap.ts +1 -1
  22. package/src/crawlers/custom/flagUnlabelledClickableElements.ts +546 -472
  23. package/src/crawlers/customAxeFunctions.ts +1 -1
  24. package/src/index.ts +0 -2
  25. package/src/mergeAxeResults.ts +569 -219
  26. package/src/screenshotFunc/pdfScreenshotFunc.ts +3 -3
  27. package/src/static/ejs/partials/components/wcagCompliance.ejs +10 -29
  28. package/src/static/ejs/partials/footer.ejs +10 -13
  29. package/src/static/ejs/partials/scripts/categorySummary.ejs +2 -2
  30. package/src/static/ejs/partials/scripts/decodeUnzipParse.ejs +3 -0
  31. package/src/static/ejs/partials/scripts/reportSearch.ejs +1 -0
  32. package/src/static/ejs/partials/scripts/ruleOffcanvas.ejs +54 -52
  33. package/src/static/ejs/partials/styles/styles.ejs +4 -0
  34. package/src/static/ejs/partials/summaryMain.ejs +15 -42
  35. package/src/static/ejs/report.ejs +21 -12
  36. package/src/utils.ts +10 -2
  37. package/src/xPathToCss.ts +186 -0
  38. package/a11y-scan-results.zip +0 -0
  39. package/src/types/xpath-to-css.d.ts +0 -3
@@ -195,6 +195,7 @@
195
195
 
196
196
  function resetIssueOccurrence(filteredItems) {
197
197
  for (let category in filteredItems) {
198
+ if (!["mustFix", "goodToFix", "needsReview", "passed"].includes(category)) continue; // skip other props like pagesScanned, etc
198
199
  const issueLabel = filteredItems[category].rules.length === 1 ? 'issue' : 'issues';
199
200
  const occurrenceLabel =
200
201
  filteredItems[category].totalItems === 1 ? 'occurrence' : 'occurrences';
@@ -12,31 +12,29 @@ category summary is clicked %>
12
12
  wcag111: 'https://www.w3.org/TR/WCAG22/#non-text-content',
13
13
  wcag122: 'https://www.w3.org/TR/WCAG22/#captions-prerecorded',
14
14
  wcag131: 'https://www.w3.org/TR/WCAG22/#info-and-relationships',
15
- wcag134: 'https://www.w3.org/TR/WCAG22/#orientation',
15
+ // wcag134: 'https://www.w3.org/TR/WCAG22/#orientation',
16
16
  wcag135: 'https://www.w3.org/TR/WCAG22/#identify-input-purpose',
17
17
  wcag141: 'https://www.w3.org/TR/WCAG22/#use-of-color',
18
18
  wcag142: 'https://www.w3.org/TR/WCAG22/#audio-control',
19
19
  wcag143: 'https://www.w3.org/TR/WCAG22/#contrast-minimum',
20
20
  wcag144: 'https://www.w3.org/TR/WCAG22/#resize-text',
21
- wcag146: 'https://www.w3.org/TR/WCAG21/#contrast-enhanced',
22
- wcag1410: 'https://www.w3.org/TR/WCAG22/#reflow',
21
+ wcag146: 'https://www.w3.org/TR/WCAG22/#contrast-enhanced', // AAA
22
+ // wcag1410: 'https://www.w3.org/TR/WCAG22/#reflow',
23
23
  wcag1412: 'https://www.w3.org/TR/WCAG22/#text-spacing',
24
24
  wcag211: 'https://www.w3.org/TR/WCAG22/#keyboard',
25
25
  wcag221: 'https://www.w3.org/TR/WCAG22/#timing-adjustable',
26
26
  wcag222: 'https://www.w3.org/TR/WCAG22/#pause-stop-hide',
27
- wcag224: 'https://www.w3.org/TR/WCAG21/#interruptions',
27
+ wcag224: 'https://www.w3.org/TR/WCAG22/#interruptions', // AAA
28
28
  wcag241: 'https://www.w3.org/TR/WCAG22/#bypass-blocks',
29
29
  wcag242: 'https://www.w3.org/TR/WCAG22/#page-titled',
30
- wcag243: 'https://www.w3.org/TR/WCAG22/#focus-order',
31
30
  wcag244: 'https://www.w3.org/TR/WCAG22/#link-purpose-in-context',
32
- wcag249: 'https://www.w3.org/TR/WCAG21/#link-purpose-link-only',
31
+ wcag249: 'https://www.w3.org/TR/WCAG22/#link-purpose-link-only', // AAA
33
32
  wcag258: 'https://www.w3.org/TR/WCAG22/#target-size-minimum',
34
33
  wcag311: 'https://www.w3.org/TR/WCAG22/#language-of-page',
35
34
  wcag312: 'https://www.w3.org/TR/WCAG22/#language-of-parts',
36
- wcag315: 'https://www.w3.org/TR/WCAG22/#reading-level',
35
+ wcag315: 'https://www.w3.org/TR/WCAG22/#reading-level', // AAA
36
+ wcag325: 'https://www.w3.org/TR/WCAG22/#change-on-request', // AAA
37
37
  wcag332: 'https://www.w3.org/TR/WCAG22/#labels-or-instructions',
38
- wcag325: 'https://www.w3.org/TR/WCAG21/#change-on-request',
39
- wcag411: 'https://www.w3.org/TR/WCAG22/#parsing',
40
38
  wcag412: 'https://www.w3.org/TR/WCAG22/#name-role-value',
41
39
  };
42
40
 
@@ -385,20 +383,22 @@ category summary is clicked %>
385
383
  var buttonAIId = `${ruleInCategory.rule}-${category}-button-AI-${index}`;
386
384
  var errorAIId = `${ruleInCategory.rule}-${category}-error-AI-${index}`;
387
385
 
386
+ const pageItemsCount = page.items.length || page.itemsCount || 0;
387
+ const normalMode = page.itemsCount === undefined;
388
388
  const accordion = createElementFromString(`
389
- <li>
389
+ <li class="${normalMode ? '' : 'no-chevron'}">
390
390
  <div class="accordion mt-2 ${category}">
391
391
  <div class="accordion-item">
392
392
  <div class="accordion-header" id="${accordionId}-title">
393
- <button
394
- aria-label="Page ${index + 1}: ${page.pageTitle}, ${page.items.length} ${page.items.length === 1 ? 'occurrence' : 'occurrences'}" class="accordion-button collapsed"
395
- type="button"
393
+ ${normalMode ? '<button' : '<div'}
394
+ aria-label="Page ${index + 1}: ${page.pageTitle}, ${pageItemsCount} ${pageItemsCount === 1 ? 'occurrence' : 'occurrences'}" class="accordion-button collapsed"
395
+ ${normalMode ? 'type="button"' : ''}
396
396
 
397
397
  >
398
- <span class="sr-only visually-hidden">${page.items.length} ${page.items.length === 1 ? 'occurrence' : 'occurrences'}</span>
398
+ <span class="sr-only visually-hidden">${pageItemsCount} ${pageItemsCount === 1 ? 'occurrence' : 'occurrences'}</span>
399
399
  <div class="me-3">${page.metadata ? page.metadata : page.pageTitle}</div>
400
- <div class="ms-auto counter">${page.items.length}</div>
401
- </button>
400
+ <div class="ms-auto counter">${pageItemsCount}</div>
401
+ ${normalMode ? '</button>' : '</div>'}
402
402
  </div>
403
403
  <div id="${accordionId}-content" class="accordion-collapse collapse" aria-labelledby="${accordionId}-title">
404
404
  <div class="accordion-body p-3">
@@ -414,7 +414,7 @@ category summary is clicked %>
414
414
  }
415
415
  <div class="page-accordion-content-title">
416
416
  <span>${getFormattedCategoryTitle(category)} elements</span>
417
- <span class="page-items-count">${page.items.length}</span>
417
+ <span class="page-items-count">${pageItemsCount}</span>
418
418
  </div>
419
419
  </div>
420
420
  </div>
@@ -423,45 +423,47 @@ category summary is clicked %>
423
423
  </li>
424
424
  `);
425
425
 
426
- accordion.querySelector('button').addEventListener('click', function(event) {
427
- var accordionBody = accordion.querySelector(".accordion-body");
428
-
429
- // So that It does not keep adding
430
- if (!accordionBody.querySelector(".unbulleted-list"))
431
- {
432
- buildExpandedRuleCategoryContentAccordian(accordionId,category,ruleInCategory,page,index);
433
-
434
- this.setAttribute('data-bs-target', '#' + accordionId+"-content");
426
+ if (normalMode) {
427
+ accordion.querySelector('button').addEventListener('click', function(event) {
428
+ var accordionBody = accordion.querySelector(".accordion-body");
429
+
430
+ // So that It does not keep adding
431
+ if (!accordionBody.querySelector(".unbulleted-list"))
432
+ {
433
+ buildExpandedRuleCategoryContentAccordian(accordionId,category,ruleInCategory,page,index);
434
+
435
+ this.setAttribute('data-bs-target', '#' + accordionId+"-content");
436
+
437
+ // Remove the event listener temporarily
438
+ this.removeEventListener('click', arguments.callee);
439
+
440
+ // Programmatically trigger a click on the button to open the accordion
441
+ this.click();
442
+ }
443
+ });
435
444
 
445
+ accordion.querySelector('button').addEventListener('click', function(event) {
446
+ // Set data attributes
447
+ this.setAttribute('data-bs-toggle', 'collapse');
448
+ this.setAttribute('data-bs-target', '#' + accordionId + "-content");
449
+ this.setAttribute('aria-expanded', 'false');
450
+ this.setAttribute('aria-controls', accordionId + "-content");
451
+
452
+ // Initialize the Collapse plugin on the button element
453
+ var collapse = new bootstrap.Collapse(this, {
454
+ toggle: false // Set to true if you want to toggle the collapsed state on initialization
455
+ });
456
+
436
457
  // Remove the event listener temporarily
437
458
  this.removeEventListener('click', arguments.callee);
438
-
459
+
439
460
  // Programmatically trigger a click on the button to open the accordion
440
- this.click();
441
- }
442
- });
443
-
444
- accordion.querySelector('button').addEventListener('click', function(event) {
445
- // Set data attributes
446
- this.setAttribute('data-bs-toggle', 'collapse');
447
- this.setAttribute('data-bs-target', '#' + accordionId + "-content");
448
- this.setAttribute('aria-expanded', 'false');
449
- this.setAttribute('aria-controls', accordionId + "-content");
450
-
451
- // Initialize the Collapse plugin on the button element
452
- var collapse = new bootstrap.Collapse(this, {
453
- toggle: false // Set to true if you want to toggle the collapsed state on initialization
454
- });
455
-
456
- // Remove the event listener temporarily
457
- this.removeEventListener('click', arguments.callee);
458
-
459
- // Programmatically trigger a click on the button to open the accordion
460
- setTimeout(() => { // Delaying to ensure the content is added before triggering the click
461
- this.click();
462
- }, 0);
463
-
464
- })
461
+ setTimeout(() => { // Delaying to ensure the content is added before triggering the click
462
+ this.click();
463
+ }, 0);
464
+
465
+ })
466
+ }
465
467
 
466
468
  if (isCustomFlow) {
467
469
  const customScreenshotElem = accordion.getElementsByClassName(`custom-flow-screenshot`)[0];
@@ -1196,6 +1196,10 @@
1196
1196
  content: none;
1197
1197
  }
1198
1198
 
1199
+ #expandedRuleCategoryContent .no-chevron .accordion-button::before {
1200
+ background-image: none !important;
1201
+ }
1202
+
1199
1203
  #expandedRuleCategoryContent .accordion-button::before {
1200
1204
  content: '';
1201
1205
  width: 0.75rem;
@@ -2,11 +2,7 @@
2
2
  <main aria-label="Report main content" class="d-flex flex-column flex-grow-1">
3
3
  <div class="row m-0 py-4 px-3 d-flex" style="flex-wrap: nowrap">
4
4
  <div id="aboutScanDiv" class="pe-md-2 d-flex" style="flex: 1; flex-basis: 300px">
5
- <div
6
- id="scanabout-compliance-card"
7
- class="card h-100"
8
- style="width: -webkit-fill-available"
9
- >
5
+ <div id="scanabout-compliance-card" class="card h-100" style="width: -webkit-fill-available">
10
6
  <div class="card-body"><%- include("components/summaryScanAbout") %></div>
11
7
  </div>
12
8
  </div>
@@ -16,36 +12,21 @@
16
12
  <h2>Scan results</h2>
17
13
  <div class="d-flex justify-content-between align-items-center">
18
14
  <span class="fw-bold"> WCAG (A & AA) Passes </span>
19
- <span aria-label="Pass percentage" class="ms-2"><%= wcagPassPercentage %>% of automated checks</span>
15
+ <span aria-label="Pass percentage" class="ms-2">
16
+ <%= wcagPassPercentage %>% of automated checks
17
+ </span>
20
18
  </div>
21
19
  <div class="wcag-compliance-passes-bar mb-5 d-flex">
22
- <svg
23
- width="500"
24
- role="none"
25
- height="6"
26
- fill="none"
27
- xmlns="http://www.w3.org/2000/svg"
28
- style="display: flex; width: 100%; position: absolute"
29
- >
30
- <rect
31
- width="100%"
32
- height="10"
33
- rx="3"
34
- fill="#E7ECEE"
35
- style="justify-content: left"
36
- ></rect>
37
- <rect
38
- width="<%= wcagPassPercentage %>%"
39
- height="6"
40
- rx="3"
41
- fill="#9021a6"
42
- style=""
43
- ></rect>
20
+ <svg width="500" role="none" height="6" fill="none" xmlns="http://www.w3.org/2000/svg"
21
+ style="display: flex; width: 100%; position: absolute">
22
+ <rect width="100%" height="10" rx="3" fill="#E7ECEE" style="justify-content: left"></rect>
23
+ <rect width="<%= wcagPassPercentage %>%" height="6" rx="3" fill="#9021a6" style=""></rect>
44
24
  </svg>
45
25
  </div>
46
26
  <ul class="unbulleted-list">
47
- <% Object.keys(items).forEach((category) => { %> <%-
48
- include("components/summaryScanResults", { category: category }) %> <% }) %>
27
+ <% Object.keys(items).forEach((category)=> { %> <%- include("components/summaryScanResults", { category:
28
+ category }) %>
29
+ <% }) %>
49
30
  </ul>
50
31
  </div>
51
32
  </div>
@@ -55,21 +36,13 @@
55
36
  <h2 class="mb-2">Summary of issues:</h2>
56
37
  <p>
57
38
  Only a subset of
58
- <a
59
- href="https://www.w3.org/WAI/WCAG21/quickref/?currentsidebar=%23col_customize&versions=2.1&levels=aaa"
60
- target="_blank"
61
- >WCAG 2.1</a
62
- >
39
+ <a href="https://www.w3.org/WAI/WCAG22/quickref/?versions=2.2" target=" _blank">WCAG 2.2</a>
63
40
  (Conformance Level A & AA) Success Criteria can be automatically checked so
64
- <a
65
- aria-label="Manual testing guide"
66
- href="http://go.gov.sg/oobee-manual-testing"
67
- target="_blank"
68
- >manual testing</a
69
- >
41
+ <a aria-label="Manual testing guide" href="http://go.gov.sg/oobee-manual-testing" target="_blank">manual
42
+ testing</a>
70
43
  is still required. For more details, please refer to the HTML report.
71
44
  </p>
72
45
  </div>
73
46
  <%- include("components/summaryTable") %>
74
47
  </main>
75
- </div>
48
+ </div>
@@ -17,6 +17,7 @@
17
17
  href="data:image/svg+xml,%3Csvg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M23.5 6C11.7707 6 10 11.1369 10 19.23V28.77C10 36.8631 11.7707 42 23.5 42C35.2293 42 37 36.8631 37 28.77V19.23C37 11.1369 35.2293 6 23.5 6ZM25.4903 14.5985V35.0562H21.5097V12.9438H27.8925L25.4903 14.5985Z' fill='%239021A6'/%3E%3C/svg%3E
18
18
  "
19
19
  />
20
+ <%- include('partials/scripts/decodeUnzipParse') %>
20
21
  <%- include('partials/styles/bootstrap') %> <%- include('partials/styles/highlightjs') %> <%-
21
22
  include('partials/styles/styles') %>
22
23
  </head>
@@ -100,7 +101,7 @@
100
101
  const scategoryList = document.getElementById('categorySelector');
101
102
 
102
103
  Object.keys(scanItems).forEach(category => {
103
- if (category !== 'passed') {
104
+ if (["mustFix", "goodToFix", "needsReview"].includes(category)) { // skip other keys like pagesScanned, etc
104
105
  const categoryData = scanItems[category];
105
106
  const listItem = document.createElement('div');
106
107
  listItem.className = 'col-md-4 px-2';
@@ -169,12 +170,10 @@
169
170
  spanInfo.id = `${category}ItemsInformation`;
170
171
  spanInfo.className = 'category-information';
171
172
 
172
- if (category !== 'passed' && categoryData.totalItems !== 0) {
173
+ if (categoryData.totalItems !== 0) {
173
174
  spanInfo.textContent = `${categoryData.rules.length} ${categoryData.rules.length === 1 ? 'issue' : 'issues'} / ${categoryData.totalItems} ${categoryData.totalItems === 1 ? 'occurrence' : 'occurrences'}`;
174
- } else if (category !== 'passed' && categoryData.totalItems === 0) {
175
+ } else if (categoryData.totalItems === 0) {
175
176
  spanInfo.textContent = `0 issues`;
176
- } else {
177
- spanInfo.textContent = `${categoryData.totalItems} ${categoryData.totalItems === 1 ? 'occurrence' : 'occurrences'}`;
178
177
  }
179
178
 
180
179
  button.appendChild(divFlex);
@@ -187,7 +186,8 @@
187
186
  const categoryList = document.getElementById('issueTypeListbox');
188
187
 
189
188
  Object.keys(scanItems).forEach(category => {
190
- if (category !== 'passed') {
189
+ if (["mustFix", "goodToFix", "needsReview"].includes(category)) { // skip other keys like pagesScanned, etc
190
+
191
191
  const categoryData = scanItems[category];
192
192
  const rulesLength = categoryData.rules ? categoryData.rules.length : 0;
193
193
 
@@ -206,12 +206,10 @@
206
206
  spanInfo.id = `${category}ItemsInformation`;
207
207
  spanInfo.className = 'category-information';
208
208
 
209
- if (category !== 'passed' && categoryData.totalItems !== 0) {
209
+ if (categoryData.totalItems !== 0) {
210
210
  spanInfo.textContent = `(${categoryData.rules.length} ${categoryData.rules.length === 1 ? 'issue' : 'issues'})`;
211
- } else if (category !== 'passed' && categoryData.totalItems === 0) {
211
+ } else if (categoryData.totalItems === 0) {
212
212
  spanInfo.textContent = `(0 issues)`;
213
- } else {
214
- spanInfo.textContent = `(${categoryData.totalItems} ${categoryData.totalItems === 1 ? 'occurrence' : 'occurrences'})`;
215
213
  }
216
214
 
217
215
  listItem.appendChild(spanTitle);
@@ -408,9 +406,20 @@
408
406
  };
409
407
 
410
408
  document.addEventListener('DOMContentLoaded', () => {
411
- initTooltips();
412
- scanDataHTML();
409
+ scanDataPromise.then(() => {
410
+ console.log("scanData loaded.");
411
+ scanItemsPromise.then(() => {
412
+ console.log("scanItems loaded.");
413
+ initTooltips();
414
+ scanDataHTML();
415
+ }).error(e => {
416
+ console.error("Failed to load scanItems: ", e);
417
+ });
418
+ }).error(e => {
419
+ console.error("Failed to load scanData: ", e);
420
+ });
413
421
  });
422
+
414
423
  </script>
415
424
  <!-- Checks if js runs -->
416
425
  <script>
package/src/utils.ts CHANGED
@@ -191,8 +191,15 @@ export const cleanUp = async pathToDelete => {
191
191
  // });
192
192
 
193
193
  export const getWcagPassPercentage = (wcagViolations: string[]): string => {
194
- const totalChecks = Object.keys(constants.wcagLinks).length;
195
- const passedChecks = totalChecks - wcagViolations.length;
194
+
195
+ // These AAA rules should not be counted as WCAG Pass Percentage only contains A and AA
196
+ const wcagAAA = ['WCAG 1.4.6', 'WCAG 2.2.4', 'WCAG 2.4.9', 'WCAG 3.1.5', 'WCAG 3.2.5'];
197
+
198
+ const filteredWcagLinks = Object.keys(constants.wcagLinks).filter(key => !wcagAAA.includes(key));
199
+ const filteredWcagViolations = wcagViolations.filter(violation => !wcagAAA.includes(violation));
200
+ const totalChecks = filteredWcagLinks.length;
201
+
202
+ const passedChecks = totalChecks - filteredWcagViolations.length;
196
203
  const passPercentage = (passedChecks / totalChecks) * 100;
197
204
 
198
205
  return passPercentage.toFixed(2); // toFixed returns a string, which is correct here
@@ -241,6 +248,7 @@ export const setHeadlessMode = (browser: string, isHeadless: boolean): void => {
241
248
  } else {
242
249
  process.env.CRAWLEE_HEADLESS = '0';
243
250
  }
251
+
244
252
  };
245
253
 
246
254
  export const setThresholdLimits = setWarnLevel => {
@@ -0,0 +1,186 @@
1
+ /**
2
+ * XPath to CSS
3
+ *
4
+ * Utility function for converting XPath expressions to CSS selectors
5
+ *
6
+ * Originally written in Python by [santiycr](https://github.com/santiycr) for
7
+ * [cssify](https://github.com/santiycr/cssify) and ported to JavaScript by
8
+ * [Dither](https://github.com/Dither). Converted to ES2015 and packaged as an npm module by
9
+ * [svenheden](https://github.com/svenheden)
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ import { consoleLogger } from "./logs.js";
15
+
16
+ const isValidXPath = expr => (
17
+ typeof expr != 'undefined' &&
18
+ expr.replace(/[\s-_=]/g,'') !== '' &&
19
+ expr.length === expr.replace(/[-_\w:.]+\(\)\s*=|=\s*[-_\w:.]+\(\)|\sor\s|\sand\s|\[(?:[^\/\]]+[\/\[]\/?.+)+\]|starts-with\(|\[.*last\(\)\s*[-\+<>=].+\]|number\(\)|not\(|count\(|text\(|first\(|normalize-space|[^\/]following-sibling|concat\(|descendant::|parent::|self::|child::|/gi,'').length
20
+ );
21
+
22
+ const getValidationRegex = () => {
23
+ let regex =
24
+ "(?P<node>"+
25
+ "("+
26
+ "^id\\([\"\\']?(?P<idvalue>%(value)s)[\"\\']?\\)"+// special case! `id(idValue)`
27
+ "|"+
28
+ "(?P<nav>//?(?:following-sibling::)?)(?P<tag>%(tag)s)" + // `//div`
29
+ "(\\[("+
30
+ "(?P<matched>(?P<mattr>@?%(attribute)s=[\"\\'](?P<mvalue>%(value)s))[\"\\']"+ // `[@id="well"]` supported and `[text()="yes"]` is not
31
+ "|"+
32
+ "(?P<contained>contains\\((?P<cattr>@?%(attribute)s,\\s*[\"\\'](?P<cvalue>%(value)s)[\"\\']\\))"+// `[contains(@id, "bleh")]` supported and `[contains(text(), "some")]` is not
33
+ ")\\])?"+
34
+ "(\\[\\s*(?P<nth>\\d+|last\\(\\s*\\))\\s*\\])?"+
35
+ ")"+
36
+ ")";
37
+
38
+ const subRegexes = {
39
+ "tag": "([a-zA-Z][a-zA-Z0-9:-]*|\\*)",
40
+ "attribute": "[.a-zA-Z_:][-\\w:.]*(\\(\\))?)",
41
+ "value": "\\s*[\\w/:][-/\\w\\s,:;.]*"
42
+ };
43
+
44
+ Object.keys(subRegexes).forEach(key => {
45
+ regex = regex.replace(new RegExp('%\\(' + key + '\\)s', 'gi'), subRegexes[key]);
46
+ });
47
+
48
+ regex = regex.replace(/\?P<node>|\?P<idvalue>|\?P<nav>|\?P<tag>|\?P<matched>|\?P<mattr>|\?P<mvalue>|\?P<contained>|\?P<cattr>|\?P<cvalue>|\?P<nth>/gi, '');
49
+
50
+ return new RegExp(regex, 'gi');
51
+ };
52
+
53
+ const preParseXpath = expr => (
54
+ expr.replace(/contains\s*\(\s*concat\(["']\s+["']\s*,\s*@class\s*,\s*["']\s+["']\)\s*,\s*["']\s+([a-zA-Z0-9-_]+)\s+["']\)/gi, '@class="$1"')
55
+ );
56
+
57
+ function escapeCssIdSelectors(cssSelector) {
58
+ return cssSelector.replace(/#([^ >]+)/g, (match, id) => {
59
+ // Escape special characters in the id part
60
+ return '#' + id.replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, '\\$&');
61
+ });
62
+ }
63
+
64
+ export const xPathToCss = expr => {
65
+ if (!expr) {
66
+ throw new Error('Missing XPath expression');
67
+ }
68
+
69
+ expr = preParseXpath(expr);
70
+
71
+ if (!isValidXPath(expr)) {
72
+ consoleLogger.error(`Invalid or unsupported XPath: ${expr}`);
73
+ // do not throw error so that this function proceeds to convert xpath that it does not support
74
+ // for example, //*[@id="google_ads_iframe_/4654/dweb/imu1/homepage/landingpage/na_0"]/html/body/div[1]/a
75
+ // becomes #google_ads_iframe_/4654/dweb/imu1/homepage/landingpage/na_0 > html > body > div:first-of-type > div > a
76
+ // which is invalid because the slashes in the id selector are not escaped
77
+ // throw new Error('Invalid or unsupported XPath: ' + expr);
78
+ }
79
+
80
+ const xPathArr = expr.split('|');
81
+ const prog = getValidationRegex();
82
+ const cssSelectors = [];
83
+ let xindex = 0;
84
+
85
+ while (xPathArr[xindex]) {
86
+ const css = [];
87
+ let position = 0;
88
+ let nodes;
89
+
90
+ while (nodes = prog.exec(xPathArr[xindex])) {
91
+ let attr;
92
+
93
+ if (!nodes && position === 0) {
94
+ throw new Error('Invalid or unsupported XPath: ' + expr);
95
+ }
96
+
97
+ const match = {
98
+ node: nodes[5],
99
+ idvalue: nodes[12] || nodes[3],
100
+ nav: nodes[4],
101
+ tag: nodes[5],
102
+ matched: nodes[7],
103
+ mattr: nodes[10] || nodes[14],
104
+ mvalue: nodes[12] || nodes[16],
105
+ contained: nodes[13],
106
+ cattr: nodes[14],
107
+ cvalue: nodes[16],
108
+ nth: nodes[18]
109
+ };
110
+
111
+ let nav = '';
112
+
113
+ if (position != 0 && match['nav']) {
114
+ if (~match['nav'].indexOf('following-sibling::')) {
115
+ nav = ' + ';
116
+ } else {
117
+ nav = (match['nav'] == '//') ? ' ' : ' > ';
118
+ }
119
+ }
120
+
121
+ const tag = (match['tag'] === '*') ? '' : (match['tag'] || '');
122
+
123
+ if (match['contained']) {
124
+ if (match['cattr'].indexOf('@') === 0) {
125
+ attr = '[' + match['cattr'].replace(/^@/, '') + '*="' + match['cvalue'] + '"]';
126
+ } else {
127
+ throw new Error('Invalid or unsupported XPath attribute: ' + match['cattr']);
128
+ }
129
+ } else if (match['matched']) {
130
+ switch (match['mattr']) {
131
+ case '@id':
132
+ attr = '#' + match['mvalue'].replace(/^\s+|\s+$/,'').replace(/\s/g, '#');
133
+ break;
134
+ case '@class':
135
+ attr = '.' + match['mvalue'].replace(/^\s+|\s+$/,'').replace(/\s/g, '.');
136
+ break;
137
+ case 'text()':
138
+ case '.':
139
+ throw new Error('Invalid or unsupported XPath attribute: ' + match['mattr']);
140
+ default:
141
+ if (match['mattr'].indexOf('@') !== 0) {
142
+ throw new Error('Invalid or unsupported XPath attribute: ' + match['mattr']);
143
+ }
144
+ if (match['mvalue'].indexOf(' ') !== -1) {
145
+ match['mvalue'] = '\"' + match['mvalue'].replace(/^\s+|\s+$/,'') + '\"';
146
+ }
147
+ attr = '[' + match['mattr'].replace('@', '') + '="' + match['mvalue'] + '"]';
148
+ break;
149
+ }
150
+ } else if (match['idvalue']) {
151
+ attr = '#' + match['idvalue'].replace(/\s/, '#');
152
+ } else {
153
+ attr = '';
154
+ }
155
+
156
+ let nth = '';
157
+
158
+ if (match['nth']) {
159
+ if (match['nth'].indexOf('last') === -1) {
160
+ if (isNaN(parseInt(match['nth'], 10))) {
161
+ throw new Error('Invalid or unsupported XPath attribute: ' + match['nth']);
162
+ }
163
+ nth = parseInt(match['nth'], 10) !== 1 ? ':nth-of-type(' + match['nth'] + ')' : ':first-of-type';
164
+ } else {
165
+ nth = ':last-of-type';
166
+ }
167
+ }
168
+
169
+ css.push(nav + tag + attr + nth);
170
+ position++;
171
+ }
172
+
173
+ const result = css.join('');
174
+
175
+ if (result === '') {
176
+ throw new Error('Invalid or unsupported XPath');
177
+ }
178
+
179
+ cssSelectors.push(result);
180
+ xindex++;
181
+ }
182
+
183
+ // return cssSelectors.join(', ');
184
+ const originalResult = cssSelectors.join(', ');
185
+ return escapeCssIdSelectors(originalResult);
186
+ };
Binary file
@@ -1,3 +0,0 @@
1
- declare module 'xpath-to-css' {
2
- export default function xPathToCss(xPath: string): string;
3
- }