@brandlab-digital/conformance 3.7.0 → 3.9.0
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.
- package/README.md +26 -0
- package/dist/current-release.js +5 -5
- package/dist/current-release.js.map +1 -1
- package/dist/engineering.js +4 -2
- package/dist/engineering.js.map +1 -1
- package/dist/public-copy.d.ts +7 -0
- package/dist/public-copy.d.ts.map +1 -1
- package/dist/public-copy.js +8 -3
- package/dist/public-copy.js.map +1 -1
- package/dist/ready-cli.d.ts +38 -0
- package/dist/ready-cli.d.ts.map +1 -0
- package/dist/ready-cli.js +327 -0
- package/dist/ready-cli.js.map +1 -0
- package/dist/site-system/checks.d.ts +389 -0
- package/dist/site-system/checks.d.ts.map +1 -0
- package/dist/site-system/checks.js +96 -0
- package/dist/site-system/checks.js.map +1 -0
- package/dist/site-system/config.d.ts +40 -0
- package/dist/site-system/config.d.ts.map +1 -0
- package/dist/site-system/config.js +214 -0
- package/dist/site-system/config.js.map +1 -0
- package/dist/site-system/index.d.ts +14 -0
- package/dist/site-system/index.d.ts.map +1 -0
- package/dist/site-system/index.js +14 -0
- package/dist/site-system/index.js.map +1 -0
- package/dist/site-system/journeys/access.d.ts +7 -0
- package/dist/site-system/journeys/access.d.ts.map +1 -0
- package/dist/site-system/journeys/access.js +263 -0
- package/dist/site-system/journeys/access.js.map +1 -0
- package/dist/site-system/journeys/browser.d.ts +79 -0
- package/dist/site-system/journeys/browser.d.ts.map +1 -0
- package/dist/site-system/journeys/browser.js +218 -0
- package/dist/site-system/journeys/browser.js.map +1 -0
- package/dist/site-system/journeys/conversion.d.ts +3 -0
- package/dist/site-system/journeys/conversion.d.ts.map +1 -0
- package/dist/site-system/journeys/conversion.js +152 -0
- package/dist/site-system/journeys/conversion.js.map +1 -0
- package/dist/site-system/journeys/menu.d.ts +11 -0
- package/dist/site-system/journeys/menu.d.ts.map +1 -0
- package/dist/site-system/journeys/menu.js +395 -0
- package/dist/site-system/journeys/menu.js.map +1 -0
- package/dist/site-system/journeys/page-helpers.d.ts +146 -0
- package/dist/site-system/journeys/page-helpers.d.ts.map +1 -0
- package/dist/site-system/journeys/page-helpers.js +426 -0
- package/dist/site-system/journeys/page-helpers.js.map +1 -0
- package/dist/site-system/journeys/plan.d.ts +82 -0
- package/dist/site-system/journeys/plan.d.ts.map +1 -0
- package/dist/site-system/journeys/plan.js +112 -0
- package/dist/site-system/journeys/plan.js.map +1 -0
- package/dist/site-system/journeys/shell.d.ts +24 -0
- package/dist/site-system/journeys/shell.d.ts.map +1 -0
- package/dist/site-system/journeys/shell.js +269 -0
- package/dist/site-system/journeys/shell.js.map +1 -0
- package/dist/site-system/playwright-report.d.ts +22 -0
- package/dist/site-system/playwright-report.d.ts.map +1 -0
- package/dist/site-system/playwright-report.js +131 -0
- package/dist/site-system/playwright-report.js.map +1 -0
- package/dist/site-system/records.d.ts +30 -0
- package/dist/site-system/records.d.ts.map +1 -0
- package/dist/site-system/records.js +98 -0
- package/dist/site-system/records.js.map +1 -0
- package/dist/site-system/report.d.ts +116 -0
- package/dist/site-system/report.d.ts.map +1 -0
- package/dist/site-system/report.js +366 -0
- package/dist/site-system/report.js.map +1 -0
- package/dist/site-system/rule-modules.d.ts +8 -0
- package/dist/site-system/rule-modules.d.ts.map +1 -0
- package/dist/site-system/rule-modules.js +33 -0
- package/dist/site-system/rule-modules.js.map +1 -0
- package/dist/site-system/rules/design/d4-distinctness.d.ts +36 -0
- package/dist/site-system/rules/design/d4-distinctness.d.ts.map +1 -0
- package/dist/site-system/rules/design/d4-distinctness.js +207 -0
- package/dist/site-system/rules/design/d4-distinctness.js.map +1 -0
- package/dist/site-system/rules/design/index.d.ts +17 -0
- package/dist/site-system/rules/design/index.d.ts.map +1 -0
- package/dist/site-system/rules/design/index.js +7 -0
- package/dist/site-system/rules/design/index.js.map +1 -0
- package/dist/site-system/rules/enquiry/c2-quote-form.d.ts +31 -0
- package/dist/site-system/rules/enquiry/c2-quote-form.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/c2-quote-form.js +211 -0
- package/dist/site-system/rules/enquiry/c2-quote-form.js.map +1 -0
- package/dist/site-system/rules/enquiry/common.d.ts +37 -0
- package/dist/site-system/rules/enquiry/common.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/common.js +59 -0
- package/dist/site-system/rules/enquiry/common.js.map +1 -0
- package/dist/site-system/rules/enquiry/e1-recorded-titles.d.ts +8 -0
- package/dist/site-system/rules/enquiry/e1-recorded-titles.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/e1-recorded-titles.js +59 -0
- package/dist/site-system/rules/enquiry/e1-recorded-titles.js.map +1 -0
- package/dist/site-system/rules/enquiry/e9-cost-guides.d.ts +22 -0
- package/dist/site-system/rules/enquiry/e9-cost-guides.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/e9-cost-guides.js +55 -0
- package/dist/site-system/rules/enquiry/e9-cost-guides.js.map +1 -0
- package/dist/site-system/rules/enquiry/html.d.ts +53 -0
- package/dist/site-system/rules/enquiry/html.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/html.js +294 -0
- package/dist/site-system/rules/enquiry/html.js.map +1 -0
- package/dist/site-system/rules/enquiry/index.d.ts +17 -0
- package/dist/site-system/rules/enquiry/index.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/index.js +26 -0
- package/dist/site-system/rules/enquiry/index.js.map +1 -0
- package/dist/site-system/rules/enquiry/k8-sections.d.ts +17 -0
- package/dist/site-system/rules/enquiry/k8-sections.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/k8-sections.js +76 -0
- package/dist/site-system/rules/enquiry/k8-sections.js.map +1 -0
- package/dist/site-system/rules/enquiry/literal.d.ts +29 -0
- package/dist/site-system/rules/enquiry/literal.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/literal.js +189 -0
- package/dist/site-system/rules/enquiry/literal.js.map +1 -0
- package/dist/site-system/rules/enquiry/t11-licence-classes.d.ts +73 -0
- package/dist/site-system/rules/enquiry/t11-licence-classes.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/t11-licence-classes.js +275 -0
- package/dist/site-system/rules/enquiry/t11-licence-classes.js.map +1 -0
- package/dist/site-system/rules/enquiry/t5-reviews.d.ts +23 -0
- package/dist/site-system/rules/enquiry/t5-reviews.d.ts.map +1 -0
- package/dist/site-system/rules/enquiry/t5-reviews.js +91 -0
- package/dist/site-system/rules/enquiry/t5-reviews.js.map +1 -0
- package/dist/site-system/rules/form/c11-contact-route.d.ts +5 -0
- package/dist/site-system/rules/form/c11-contact-route.d.ts.map +1 -0
- package/dist/site-system/rules/form/c11-contact-route.js +132 -0
- package/dist/site-system/rules/form/c11-contact-route.js.map +1 -0
- package/dist/site-system/rules/form/c7-thank-you.d.ts +20 -0
- package/dist/site-system/rules/form/c7-thank-you.d.ts.map +1 -0
- package/dist/site-system/rules/form/c7-thank-you.js +67 -0
- package/dist/site-system/rules/form/c7-thank-you.js.map +1 -0
- package/dist/site-system/rules/form/c8-forgiving-form.d.ts +19 -0
- package/dist/site-system/rules/form/c8-forgiving-form.d.ts.map +1 -0
- package/dist/site-system/rules/form/c8-forgiving-form.js +128 -0
- package/dist/site-system/rules/form/c8-forgiving-form.js.map +1 -0
- package/dist/site-system/rules/form/index.d.ts +14 -0
- package/dist/site-system/rules/form/index.d.ts.map +1 -0
- package/dist/site-system/rules/form/index.js +13 -0
- package/dist/site-system/rules/form/index.js.map +1 -0
- package/dist/site-system/rules/library/common.d.ts +36 -0
- package/dist/site-system/rules/library/common.d.ts.map +1 -0
- package/dist/site-system/rules/library/common.js +70 -0
- package/dist/site-system/rules/library/common.js.map +1 -0
- package/dist/site-system/rules/library/index.d.ts +16 -0
- package/dist/site-system/rules/library/index.d.ts.map +1 -0
- package/dist/site-system/rules/library/index.js +19 -0
- package/dist/site-system/rules/library/index.js.map +1 -0
- package/dist/site-system/rules/library/k1-shared-components.d.ts +29 -0
- package/dist/site-system/rules/library/k1-shared-components.d.ts.map +1 -0
- package/dist/site-system/rules/library/k1-shared-components.js +97 -0
- package/dist/site-system/rules/library/k1-shared-components.js.map +1 -0
- package/dist/site-system/rules/navigation/extract.d.ts +50 -0
- package/dist/site-system/rules/navigation/extract.d.ts.map +1 -0
- package/dist/site-system/rules/navigation/extract.js +220 -0
- package/dist/site-system/rules/navigation/extract.js.map +1 -0
- package/dist/site-system/rules/navigation/html.d.ts +23 -0
- package/dist/site-system/rules/navigation/html.d.ts.map +1 -0
- package/dist/site-system/rules/navigation/html.js +219 -0
- package/dist/site-system/rules/navigation/html.js.map +1 -0
- package/dist/site-system/rules/navigation/index.d.ts +37 -0
- package/dist/site-system/rules/navigation/index.d.ts.map +1 -0
- package/dist/site-system/rules/navigation/index.js +163 -0
- package/dist/site-system/rules/navigation/index.js.map +1 -0
- package/dist/site-system/rules/navigation/record.d.ts +8 -0
- package/dist/site-system/rules/navigation/record.d.ts.map +1 -0
- package/dist/site-system/rules/navigation/record.js +39 -0
- package/dist/site-system/rules/navigation/record.js.map +1 -0
- package/dist/site-system/rules/sections/exif.d.ts +22 -0
- package/dist/site-system/rules/sections/exif.d.ts.map +1 -0
- package/dist/site-system/rules/sections/exif.js +218 -0
- package/dist/site-system/rules/sections/exif.js.map +1 -0
- package/dist/site-system/rules/sections/images.d.ts +31 -0
- package/dist/site-system/rules/sections/images.d.ts.map +1 -0
- package/dist/site-system/rules/sections/images.js +96 -0
- package/dist/site-system/rules/sections/images.js.map +1 -0
- package/dist/site-system/rules/sections/index.d.ts +20 -0
- package/dist/site-system/rules/sections/index.d.ts.map +1 -0
- package/dist/site-system/rules/sections/index.js +34 -0
- package/dist/site-system/rules/sections/index.js.map +1 -0
- package/dist/site-system/rules/sections/k11-density.d.ts +23 -0
- package/dist/site-system/rules/sections/k11-density.d.ts.map +1 -0
- package/dist/site-system/rules/sections/k11-density.js +88 -0
- package/dist/site-system/rules/sections/k11-density.js.map +1 -0
- package/dist/site-system/rules/sections/k12-contact.d.ts +13 -0
- package/dist/site-system/rules/sections/k12-contact.d.ts.map +1 -0
- package/dist/site-system/rules/sections/k12-contact.js +70 -0
- package/dist/site-system/rules/sections/k12-contact.js.map +1 -0
- package/dist/site-system/rules/sections/k2-skin-coverage.d.ts +11 -0
- package/dist/site-system/rules/sections/k2-skin-coverage.d.ts.map +1 -0
- package/dist/site-system/rules/sections/k2-skin-coverage.js +126 -0
- package/dist/site-system/rules/sections/k2-skin-coverage.js.map +1 -0
- package/dist/site-system/rules/sections/n9-footer.d.ts +40 -0
- package/dist/site-system/rules/sections/n9-footer.d.ts.map +1 -0
- package/dist/site-system/rules/sections/n9-footer.js +186 -0
- package/dist/site-system/rules/sections/n9-footer.js.map +1 -0
- package/dist/site-system/rules/sections/t6-media-entries.d.ts +14 -0
- package/dist/site-system/rules/sections/t6-media-entries.d.ts.map +1 -0
- package/dist/site-system/rules/sections/t6-media-entries.js +96 -0
- package/dist/site-system/rules/sections/t6-media-entries.js.map +1 -0
- package/dist/site-system/rules/sections/t7-main-images.d.ts +13 -0
- package/dist/site-system/rules/sections/t7-main-images.d.ts.map +1 -0
- package/dist/site-system/rules/sections/t7-main-images.js +41 -0
- package/dist/site-system/rules/sections/t7-main-images.js.map +1 -0
- package/dist/site-system/rules/sections/t8-prices.d.ts +13 -0
- package/dist/site-system/rules/sections/t8-prices.d.ts.map +1 -0
- package/dist/site-system/rules/sections/t8-prices.js +92 -0
- package/dist/site-system/rules/sections/t8-prices.js.map +1 -0
- package/dist/site-system/rules/sections/t9-location-metadata.d.ts +3 -0
- package/dist/site-system/rules/sections/t9-location-metadata.d.ts.map +1 -0
- package/dist/site-system/rules/sections/t9-location-metadata.js +54 -0
- package/dist/site-system/rules/sections/t9-location-metadata.js.map +1 -0
- package/dist/site-system/rules/shell/banned-wording.d.ts +24 -0
- package/dist/site-system/rules/shell/banned-wording.d.ts.map +1 -0
- package/dist/site-system/rules/shell/banned-wording.js +212 -0
- package/dist/site-system/rules/shell/banned-wording.js.map +1 -0
- package/dist/site-system/rules/shell/budgets.d.ts +43 -0
- package/dist/site-system/rules/shell/budgets.d.ts.map +1 -0
- package/dist/site-system/rules/shell/budgets.js +297 -0
- package/dist/site-system/rules/shell/budgets.js.map +1 -0
- package/dist/site-system/rules/shell/cta-labels.d.ts +17 -0
- package/dist/site-system/rules/shell/cta-labels.d.ts.map +1 -0
- package/dist/site-system/rules/shell/cta-labels.js +86 -0
- package/dist/site-system/rules/shell/cta-labels.js.map +1 -0
- package/dist/site-system/rules/shell/facts.d.ts +33 -0
- package/dist/site-system/rules/shell/facts.d.ts.map +1 -0
- package/dist/site-system/rules/shell/facts.js +217 -0
- package/dist/site-system/rules/shell/facts.js.map +1 -0
- package/dist/site-system/rules/shell/headings.d.ts +5 -0
- package/dist/site-system/rules/shell/headings.d.ts.map +1 -0
- package/dist/site-system/rules/shell/headings.js +23 -0
- package/dist/site-system/rules/shell/headings.js.map +1 -0
- package/dist/site-system/rules/shell/hero-location.d.ts +4 -0
- package/dist/site-system/rules/shell/hero-location.d.ts.map +1 -0
- package/dist/site-system/rules/shell/hero-location.js +45 -0
- package/dist/site-system/rules/shell/hero-location.js.map +1 -0
- package/dist/site-system/rules/shell/html.d.ts +53 -0
- package/dist/site-system/rules/shell/html.d.ts.map +1 -0
- package/dist/site-system/rules/shell/html.js +341 -0
- package/dist/site-system/rules/shell/html.js.map +1 -0
- package/dist/site-system/rules/shell/index.d.ts +18 -0
- package/dist/site-system/rules/shell/index.d.ts.map +1 -0
- package/dist/site-system/rules/shell/index.js +65 -0
- package/dist/site-system/rules/shell/index.js.map +1 -0
- package/dist/site-system/rules/shell/licence.d.ts +4 -0
- package/dist/site-system/rules/shell/licence.d.ts.map +1 -0
- package/dist/site-system/rules/shell/licence.js +60 -0
- package/dist/site-system/rules/shell/licence.js.map +1 -0
- package/dist/site-system/rules/shell/location-wording.d.ts +13 -0
- package/dist/site-system/rules/shell/location-wording.d.ts.map +1 -0
- package/dist/site-system/rules/shell/location-wording.js +15 -0
- package/dist/site-system/rules/shell/location-wording.js.map +1 -0
- package/dist/site-system/rules/shell/navigation.d.ts +5 -0
- package/dist/site-system/rules/shell/navigation.d.ts.map +1 -0
- package/dist/site-system/rules/shell/navigation.js +272 -0
- package/dist/site-system/rules/shell/navigation.js.map +1 -0
- package/dist/site-system/rules/shell/overlays.d.ts +4 -0
- package/dist/site-system/rules/shell/overlays.d.ts.map +1 -0
- package/dist/site-system/rules/shell/overlays.js +93 -0
- package/dist/site-system/rules/shell/overlays.js.map +1 -0
- package/dist/site-system/rules/shell/page.d.ts +51 -0
- package/dist/site-system/rules/shell/page.d.ts.map +1 -0
- package/dist/site-system/rules/shell/page.js +256 -0
- package/dist/site-system/rules/shell/page.js.map +1 -0
- package/dist/site-system/rules/shell/phone.d.ts +13 -0
- package/dist/site-system/rules/shell/phone.d.ts.map +1 -0
- package/dist/site-system/rules/shell/phone.js +99 -0
- package/dist/site-system/rules/shell/phone.js.map +1 -0
- package/dist/site-system/rules/shell/proof-numbers.d.ts +7 -0
- package/dist/site-system/rules/shell/proof-numbers.d.ts.map +1 -0
- package/dist/site-system/rules/shell/proof-numbers.js +81 -0
- package/dist/site-system/rules/shell/proof-numbers.js.map +1 -0
- package/dist/site-system/rules/shell/types.d.ts +80 -0
- package/dist/site-system/rules/shell/types.d.ts.map +1 -0
- package/dist/site-system/rules/shell/types.js +17 -0
- package/dist/site-system/rules/shell/types.js.map +1 -0
- package/dist/site-system/runners.d.ts +37 -0
- package/dist/site-system/runners.d.ts.map +1 -0
- package/dist/site-system/runners.js +48 -0
- package/dist/site-system/runners.js.map +1 -0
- package/dist/site-system/samples.d.ts +43 -0
- package/dist/site-system/samples.d.ts.map +1 -0
- package/dist/site-system/samples.js +99 -0
- package/dist/site-system/samples.js.map +1 -0
- package/dist/site-system/steps.d.ts +66 -0
- package/dist/site-system/steps.d.ts.map +1 -0
- package/dist/site-system/steps.js +45 -0
- package/dist/site-system/steps.js.map +1 -0
- package/dist/site-system/streamed-html.d.ts +9 -0
- package/dist/site-system/streamed-html.d.ts.map +1 -0
- package/dist/site-system/streamed-html.js +43 -0
- package/dist/site-system/streamed-html.js.map +1 -0
- package/dist/site-system-e2e.d.ts +6 -0
- package/dist/site-system-e2e.d.ts.map +1 -0
- package/dist/site-system-e2e.js +54 -0
- package/dist/site-system-e2e.js.map +1 -0
- package/package.json +22 -5
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/enquiry/common.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAC5E,OAAO,EAAgC,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAE3E,qGAAqG;AACrG,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,SAAS;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC5E,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,uDAAuD;AACvD,MAAM,WAAW,kBAAkB;IACjC,wDAAwD;IACxD,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,UAAU;IACzB,yGAAyG;IACzG,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;CAC5B;AAED,uHAAuH;AACvH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMlD;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,gBAAgB,CAAC,OAAO,CAAC,GAAG,UAAU,EAAE,CAEzE;AAED,wBAAgB,IAAI,CAAC,OAAO,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,iBAAiB,CAE/G;AAED,wBAAgB,IAAI,CAAC,OAAO,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,iBAAiB,CAEpG;AAED,wBAAgB,aAAa,CAAC,OAAO,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM,GAAG,iBAAiB,CAE5F;AAED,mEAAmE;AACnE,wBAAgB,QAAQ,CACtB,OAAO,EAAE,iBAAiB,EAC1B,QAAQ,EAAE,SAAS,iBAAiB,EAAE,EACtC,WAAW,EAAE,MAAM,GAClB,iBAAiB,EAAE,CAErB;AAED,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,SAAiB,GAAG,MAAM,CAE3F;AAED,oHAAoH;AACpH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,WAAW,GAAG,WAAW,EAAE,CAQjE;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAEtD;AAED,qGAAqG;AACrG,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAYpD"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { attr, descendants, parseHtml } from "./html.js";
|
|
2
|
+
/** A path as the route catalogue writes it: leading slash, no query or fragment, no trailing slash except for home. */
|
|
3
|
+
export function normalisePath(path) {
|
|
4
|
+
let value = path.trim();
|
|
5
|
+
const cut = value.search(/[?#]/);
|
|
6
|
+
if (cut !== -1)
|
|
7
|
+
value = value.slice(0, cut);
|
|
8
|
+
value = `/${value}`.replace(/\/{2,}/g, "/");
|
|
9
|
+
return value.length > 1 ? value.replace(/\/$/, "") : value;
|
|
10
|
+
}
|
|
11
|
+
export function parsePages(pages) {
|
|
12
|
+
return pages.map((page) => ({ path: normalisePath(page.path), html: page.html, root: parseHtml(page.html) }));
|
|
13
|
+
}
|
|
14
|
+
export function fail(checkId, target, message) {
|
|
15
|
+
return target === undefined ? { checkId, status: "fail", message } : { checkId, status: "fail", target, message };
|
|
16
|
+
}
|
|
17
|
+
export function pass(checkId, message, target) {
|
|
18
|
+
return target === undefined ? { checkId, status: "pass", message } : { checkId, status: "pass", target, message };
|
|
19
|
+
}
|
|
20
|
+
export function notApplicable(checkId, message) {
|
|
21
|
+
return { checkId, status: "not-applicable", message };
|
|
22
|
+
}
|
|
23
|
+
/** The failures when there are any; otherwise one pass finding. */
|
|
24
|
+
export function resultOf(checkId, failures, passMessage) {
|
|
25
|
+
return failures.length > 0 ? [...failures] : [pass(checkId, passMessage)];
|
|
26
|
+
}
|
|
27
|
+
export function plural(count, singular, pluralForm = `${singular}s`) {
|
|
28
|
+
return `${count} ${count === 1 ? singular : pluralForm}`;
|
|
29
|
+
}
|
|
30
|
+
/** The outermost `data-bl-section` elements of a page, in document order. A section nested in one is part of it. */
|
|
31
|
+
export function topLevelSections(root) {
|
|
32
|
+
const sections = descendants(root).filter((element) => attr(element, "data-bl-section") !== undefined);
|
|
33
|
+
return sections.filter((section) => {
|
|
34
|
+
for (let parent = section.parent; parent && parent.tag !== "#root"; parent = parent.parent) {
|
|
35
|
+
if (attr(parent, "data-bl-section") !== undefined)
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function sectionId(section) {
|
|
42
|
+
return attr(section, "data-bl-section") ?? "";
|
|
43
|
+
}
|
|
44
|
+
/** Dollar figures in a piece of text, as numbers. A range such as "$450 to 900" counts both ends. */
|
|
45
|
+
export function dollarFigures(text) {
|
|
46
|
+
const figures = [];
|
|
47
|
+
const figure = /\$\s?(\d{1,3}(?:,\d{3})+|\d+)(?:\.\d{1,2})?(?:\s*(?:-|–|—|to)\s*\$?\s?(\d{1,3}(?:,\d{3})+|\d+)(?:\.\d{1,2})?(?![\d,]*(?:%|[a-zA-Z])))?/g;
|
|
48
|
+
for (const match of text.matchAll(figure)) {
|
|
49
|
+
const first = Number(match[1].replace(/,/g, ""));
|
|
50
|
+
figures.push(first);
|
|
51
|
+
if (match[2] !== undefined) {
|
|
52
|
+
const second = Number(match[2].replace(/,/g, ""));
|
|
53
|
+
if (second >= first)
|
|
54
|
+
figures.push(second);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return figures;
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=common.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"common.js","sourceRoot":"","sources":["../../../../src/site-system/rules/enquiry/common.ts"],"names":[],"mappings":"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","sourcesContent":["import type { SiteSystemCheckId, SiteSystemFinding } from \"../../checks.js\";\nimport { attr, descendants, parseHtml, type HtmlElement } from \"./html.js\";\n\n/** What every rendered rule receives: the sampled pages' server HTML and where the project lives. */\nexport interface EnquiryRuleInput {\n readonly baseUrl: string;\n readonly pages: readonly { readonly path: string; readonly html: string }[];\n readonly projectRoot: string;\n}\n\n/** Options for tests; a real run uses the defaults. */\nexport interface EnquiryRuleOptions {\n /** The date licences are judged on. Defaults to now. */\n readonly asOf?: Date;\n}\n\nexport interface ParsedPage {\n /** The page path, normalised: a leading slash, no trailing slash (except home), no query or fragment. */\n readonly path: string;\n readonly html: string;\n readonly root: HtmlElement;\n}\n\n/** A path as the route catalogue writes it: leading slash, no query or fragment, no trailing slash except for home. */\nexport function normalisePath(path: string): string {\n let value = path.trim();\n const cut = value.search(/[?#]/);\n if (cut !== -1) value = value.slice(0, cut);\n value = `/${value}`.replace(/\\/{2,}/g, \"/\");\n return value.length > 1 ? value.replace(/\\/$/, \"\") : value;\n}\n\nexport function parsePages(pages: EnquiryRuleInput[\"pages\"]): ParsedPage[] {\n return pages.map((page) => ({ path: normalisePath(page.path), html: page.html, root: parseHtml(page.html) }));\n}\n\nexport function fail(checkId: SiteSystemCheckId, target: string | undefined, message: string): SiteSystemFinding {\n return target === undefined ? { checkId, status: \"fail\", message } : { checkId, status: \"fail\", target, message };\n}\n\nexport function pass(checkId: SiteSystemCheckId, message: string, target?: string): SiteSystemFinding {\n return target === undefined ? { checkId, status: \"pass\", message } : { checkId, status: \"pass\", target, message };\n}\n\nexport function notApplicable(checkId: SiteSystemCheckId, message: string): SiteSystemFinding {\n return { checkId, status: \"not-applicable\", message };\n}\n\n/** The failures when there are any; otherwise one pass finding. */\nexport function resultOf(\n checkId: SiteSystemCheckId,\n failures: readonly SiteSystemFinding[],\n passMessage: string,\n): SiteSystemFinding[] {\n return failures.length > 0 ? [...failures] : [pass(checkId, passMessage)];\n}\n\nexport function plural(count: number, singular: string, pluralForm = `${singular}s`): string {\n return `${count} ${count === 1 ? singular : pluralForm}`;\n}\n\n/** The outermost `data-bl-section` elements of a page, in document order. A section nested in one is part of it. */\nexport function topLevelSections(root: HtmlElement): HtmlElement[] {\n const sections = descendants(root).filter((element) => attr(element, \"data-bl-section\") !== undefined);\n return sections.filter((section) => {\n for (let parent = section.parent; parent && parent.tag !== \"#root\"; parent = parent.parent) {\n if (attr(parent, \"data-bl-section\") !== undefined) return false;\n }\n return true;\n });\n}\n\nexport function sectionId(section: HtmlElement): string {\n return attr(section, \"data-bl-section\") ?? \"\";\n}\n\n/** Dollar figures in a piece of text, as numbers. A range such as \"$450 to 900\" counts both ends. */\nexport function dollarFigures(text: string): number[] {\n const figures: number[] = [];\n const figure = /\\$\\s?(\\d{1,3}(?:,\\d{3})+|\\d+)(?:\\.\\d{1,2})?(?:\\s*(?:-|–|—|to)\\s*\\$?\\s?(\\d{1,3}(?:,\\d{3})+|\\d+)(?:\\.\\d{1,2})?(?![\\d,]*(?:%|[a-zA-Z])))?/g;\n for (const match of text.matchAll(figure)) {\n const first = Number(match[1]!.replace(/,/g, \"\"));\n figures.push(first);\n if (match[2] !== undefined) {\n const second = Number(match[2].replace(/,/g, \"\"));\n if (second >= first) figures.push(second);\n }\n }\n return figures;\n}\n"]}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SiteSystemFinding } from "../../checks.js";
|
|
2
|
+
import { type ParsedPage } from "./common.js";
|
|
3
|
+
export declare const E1_RECORD_PATH = "docs/recorded-titles.json";
|
|
4
|
+
export type RecordedTitles = ReadonlyMap<string, string>;
|
|
5
|
+
/** Reads the record. Returns null when it does not exist and a string when it cannot be read. */
|
|
6
|
+
export declare function readRecordedTitles(projectRoot: string): RecordedTitles | string | null;
|
|
7
|
+
export declare function checkE1(pages: readonly ParsedPage[], projectRoot: string): SiteSystemFinding[];
|
|
8
|
+
//# sourceMappingURL=e1-recorded-titles.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"e1-recorded-titles.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/enquiry/e1-recorded-titles.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAAwD,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAGpG,eAAO,MAAM,cAAc,8BAA8B,CAAC;AAE1D,MAAM,MAAM,cAAc,GAAG,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAEzD,iGAAiG;AACjG,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,cAAc,GAAG,MAAM,GAAG,IAAI,CAkBtF;AAED,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,EAAE,WAAW,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAsB9F"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (recorded titles): a page whose title the site has recorded keeps that title word for word.
|
|
3
|
+
*
|
|
4
|
+
* The record is `docs/recorded-titles.json`, an object of page path to title, taken from the live site before a
|
|
5
|
+
* rebuild or an adoption pass. A page that is not in the record is not judged here (the title pattern part of E1 is
|
|
6
|
+
* separate). Without the record there is nothing to compare, so the rule is not applicable.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { fail, normalisePath, notApplicable, plural, resultOf } from "./common.js";
|
|
11
|
+
import { documentTitle, normaliseText } from "./html.js";
|
|
12
|
+
export const E1_RECORD_PATH = "docs/recorded-titles.json";
|
|
13
|
+
/** Reads the record. Returns null when it does not exist and a string when it cannot be read. */
|
|
14
|
+
export function readRecordedTitles(projectRoot) {
|
|
15
|
+
const file = join(projectRoot, E1_RECORD_PATH);
|
|
16
|
+
if (!existsSync(file))
|
|
17
|
+
return null;
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return `${E1_RECORD_PATH} is not valid JSON.`;
|
|
24
|
+
}
|
|
25
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
26
|
+
return `${E1_RECORD_PATH} must be an object of page path to title.`;
|
|
27
|
+
}
|
|
28
|
+
const titles = new Map();
|
|
29
|
+
for (const [path, title] of Object.entries(parsed)) {
|
|
30
|
+
if (typeof title !== "string" || !title.trim())
|
|
31
|
+
return `${E1_RECORD_PATH} has no title for ${path}.`;
|
|
32
|
+
titles.set(normalisePath(path), normaliseText(title));
|
|
33
|
+
}
|
|
34
|
+
return titles;
|
|
35
|
+
}
|
|
36
|
+
export function checkE1(pages, projectRoot) {
|
|
37
|
+
const recorded = readRecordedTitles(projectRoot);
|
|
38
|
+
if (recorded === null)
|
|
39
|
+
return [notApplicable("E1", `The site has no ${E1_RECORD_PATH}, so there are no recorded titles to keep.`)];
|
|
40
|
+
if (typeof recorded === "string")
|
|
41
|
+
return [fail("E1", E1_RECORD_PATH, recorded)];
|
|
42
|
+
const failures = [];
|
|
43
|
+
let compared = 0;
|
|
44
|
+
for (const page of pages) {
|
|
45
|
+
const expected = recorded.get(page.path);
|
|
46
|
+
if (expected === undefined)
|
|
47
|
+
continue;
|
|
48
|
+
compared += 1;
|
|
49
|
+
const actual = documentTitle(page.root);
|
|
50
|
+
if (actual !== expected) {
|
|
51
|
+
failures.push(fail("E1", page.path, `The title of ${page.path} is ${actual === null ? "missing" : `"${actual}"`}; the recorded title is "${expected}" and stays as it is.`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (compared === 0) {
|
|
55
|
+
return [notApplicable("E1", `None of the ${plural(recorded.size, "recorded title")} is for a sampled page.`)];
|
|
56
|
+
}
|
|
57
|
+
return resultOf("E1", failures, `Checked ${plural(compared, "recorded title")} on sampled pages: all are unchanged.`);
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=e1-recorded-titles.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"e1-recorded-titles.js","sourceRoot":"","sources":["../../../../src/site-system/rules/enquiry/e1-recorded-titles.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * E1 (recorded titles): a page whose title the site has recorded keeps that title word for word.\n *\n * The record is `docs/recorded-titles.json`, an object of page path to title, taken from the live site before a\n * rebuild or an adoption pass. A page that is not in the record is not judged here (the title pattern part of E1 is\n * separate). Without the record there is nothing to compare, so the rule is not applicable.\n */\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { fail, normalisePath, notApplicable, plural, resultOf, type ParsedPage } from \"./common.js\";\nimport { documentTitle, normaliseText } from \"./html.js\";\n\nexport const E1_RECORD_PATH = \"docs/recorded-titles.json\";\n\nexport type RecordedTitles = ReadonlyMap<string, string>;\n\n/** Reads the record. Returns null when it does not exist and a string when it cannot be read. */\nexport function readRecordedTitles(projectRoot: string): RecordedTitles | string | null {\n const file = join(projectRoot, E1_RECORD_PATH);\n if (!existsSync(file)) return null;\n let parsed: unknown;\n try {\n parsed = JSON.parse(readFileSync(file, \"utf8\"));\n } catch {\n return `${E1_RECORD_PATH} is not valid JSON.`;\n }\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n return `${E1_RECORD_PATH} must be an object of page path to title.`;\n }\n const titles = new Map<string, string>();\n for (const [path, title] of Object.entries(parsed)) {\n if (typeof title !== \"string\" || !title.trim()) return `${E1_RECORD_PATH} has no title for ${path}.`;\n titles.set(normalisePath(path), normaliseText(title));\n }\n return titles;\n}\n\nexport function checkE1(pages: readonly ParsedPage[], projectRoot: string): SiteSystemFinding[] {\n const recorded = readRecordedTitles(projectRoot);\n if (recorded === null) return [notApplicable(\"E1\", `The site has no ${E1_RECORD_PATH}, so there are no recorded titles to keep.`)];\n if (typeof recorded === \"string\") return [fail(\"E1\", E1_RECORD_PATH, recorded)];\n\n const failures: SiteSystemFinding[] = [];\n let compared = 0;\n for (const page of pages) {\n const expected = recorded.get(page.path);\n if (expected === undefined) continue;\n compared += 1;\n const actual = documentTitle(page.root);\n if (actual !== expected) {\n failures.push(\n fail(\"E1\", page.path, `The title of ${page.path} is ${actual === null ? \"missing\" : `\"${actual}\"`}; the recorded title is \"${expected}\" and stays as it is.`),\n );\n }\n }\n if (compared === 0) {\n return [notApplicable(\"E1\", `None of the ${plural(recorded.size, \"recorded title\")} is for a sampled page.`)];\n }\n return resultOf(\"E1\", failures, `Checked ${plural(compared, \"recorded title\")} on sampled pages: all are unchanged.`);\n}\n"]}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E9: every cost guide carries at least 8 dollar figures in a table cell or price element (Site System v2 §9.2, §13.2).
|
|
3
|
+
*
|
|
4
|
+
* A cost guide is a guide page (`/blog/…`, `/guides/…`) with "cost" in its path, or one that carries a `price-guide`
|
|
5
|
+
* section, and that has a price table or a `price-guide` section to count figures in. A guides hub such as
|
|
6
|
+
* `/blog/cost-guides` has neither, and a service page's own price summary is not a guide, so neither is held to the 8.
|
|
7
|
+
* Only rendered figures count: a band hidden because it is unconfirmed is not in the HTML, so it cannot count.
|
|
8
|
+
*/
|
|
9
|
+
import type { SiteSystemFinding } from "../../checks.js";
|
|
10
|
+
import { type ParsedPage } from "./common.js";
|
|
11
|
+
import { type HtmlElement } from "./html.js";
|
|
12
|
+
export declare const E9_MIN_FIGURES = 8;
|
|
13
|
+
/** The first path segment of a guide page. */
|
|
14
|
+
export declare const E9_GUIDE_ROOTS: readonly ["blog", "guides", "guide"];
|
|
15
|
+
/** `data-slot` values that mark a price shown outside a table. */
|
|
16
|
+
export declare const E9_PRICE_SLOTS: readonly ["price", "price-low", "price-high", "price-range", "range", "amount"];
|
|
17
|
+
export declare function isGuidePath(path: string): boolean;
|
|
18
|
+
export declare function isCostGuide(page: ParsedPage): boolean;
|
|
19
|
+
/** The dollar figures a page shows in table cells and price elements. A price inside a cell is counted once. */
|
|
20
|
+
export declare function priceFigures(root: HtmlElement): number[];
|
|
21
|
+
export declare function checkE9(pages: readonly ParsedPage[]): SiteSystemFinding[];
|
|
22
|
+
//# sourceMappingURL=e9-cost-guides.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"e9-cost-guides.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/enquiry/e9-cost-guides.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAAwD,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACpG,OAAO,EAA6B,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAExE,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC,8CAA8C;AAC9C,eAAO,MAAM,cAAc,sCAAuC,CAAC;AAEnE,kEAAkE;AAClE,eAAO,MAAM,cAAc,iFAAkF,CAAC;AAE9G,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAGjD;AAOD,wBAAgB,WAAW,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAKrD;AASD,gHAAgH;AAChH,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,EAAE,CAQxD;AAED,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,GAAG,iBAAiB,EAAE,CAUzE"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { dollarFigures, fail, notApplicable, plural, resultOf } from "./common.js";
|
|
2
|
+
import { attr, descendants, textOf } from "./html.js";
|
|
3
|
+
export const E9_MIN_FIGURES = 8;
|
|
4
|
+
/** The first path segment of a guide page. */
|
|
5
|
+
export const E9_GUIDE_ROOTS = ["blog", "guides", "guide"];
|
|
6
|
+
/** `data-slot` values that mark a price shown outside a table. */
|
|
7
|
+
export const E9_PRICE_SLOTS = ["price", "price-low", "price-high", "price-range", "range", "amount"];
|
|
8
|
+
export function isGuidePath(path) {
|
|
9
|
+
const [first, ...rest] = path.split("/").filter(Boolean);
|
|
10
|
+
return first !== undefined && rest.length > 0 && E9_GUIDE_ROOTS.includes(first);
|
|
11
|
+
}
|
|
12
|
+
function hasCostInPath(path) {
|
|
13
|
+
const slug = path.split("/").filter(Boolean).slice(1).join("-");
|
|
14
|
+
return slug.split("-").some((word) => word === "cost" || word === "costs");
|
|
15
|
+
}
|
|
16
|
+
export function isCostGuide(page) {
|
|
17
|
+
if (!isGuidePath(page.path))
|
|
18
|
+
return false;
|
|
19
|
+
const hasPriceGuideSection = descendants(page.root).some((element) => attr(element, "data-bl-section") === "price-guide");
|
|
20
|
+
const hasPriceTable = hasPriceGuideSection || descendants(page.root).some((element) => element.tag === "table");
|
|
21
|
+
return hasPriceTable && (hasCostInPath(page.path) || hasPriceGuideSection);
|
|
22
|
+
}
|
|
23
|
+
function isPriceElement(element) {
|
|
24
|
+
if (element.tag === "td" || element.tag === "th")
|
|
25
|
+
return true;
|
|
26
|
+
const slot = attr(element, "data-slot");
|
|
27
|
+
if (slot !== undefined && E9_PRICE_SLOTS.includes(slot))
|
|
28
|
+
return true;
|
|
29
|
+
return attr(element, "data-bl") === "price" || attr(element, "itemprop") === "price";
|
|
30
|
+
}
|
|
31
|
+
/** The dollar figures a page shows in table cells and price elements. A price inside a cell is counted once. */
|
|
32
|
+
export function priceFigures(root) {
|
|
33
|
+
const elements = descendants(root).filter(isPriceElement);
|
|
34
|
+
const priced = new Set(elements);
|
|
35
|
+
const outermost = elements.filter((element) => {
|
|
36
|
+
for (let parent = element.parent; parent; parent = parent.parent)
|
|
37
|
+
if (priced.has(parent))
|
|
38
|
+
return false;
|
|
39
|
+
return true;
|
|
40
|
+
});
|
|
41
|
+
return outermost.flatMap((element) => dollarFigures(textOf(element)));
|
|
42
|
+
}
|
|
43
|
+
export function checkE9(pages) {
|
|
44
|
+
const guides = pages.filter(isCostGuide);
|
|
45
|
+
if (guides.length === 0)
|
|
46
|
+
return [notApplicable("E9", "None of the sampled pages is a cost guide.")];
|
|
47
|
+
const failures = guides.flatMap((page) => {
|
|
48
|
+
const count = priceFigures(page.root).length;
|
|
49
|
+
return count >= E9_MIN_FIGURES
|
|
50
|
+
? []
|
|
51
|
+
: [fail("E9", page.path, `The cost guide at ${page.path} shows ${plural(count, "dollar figure")} in its price table; it needs at least ${E9_MIN_FIGURES}.`)];
|
|
52
|
+
});
|
|
53
|
+
return resultOf("E9", failures, `Checked ${plural(guides.length, "cost guide")}: each shows at least ${E9_MIN_FIGURES} dollar figures.`);
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=e9-cost-guides.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"e9-cost-guides.js","sourceRoot":"","sources":["../../../../src/site-system/rules/enquiry/e9-cost-guides.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * E9: every cost guide carries at least 8 dollar figures in a table cell or price element (Site System v2 §9.2, §13.2).\n *\n * A cost guide is a guide page (`/blog/…`, `/guides/…`) with \"cost\" in its path, or one that carries a `price-guide`\n * section, and that has a price table or a `price-guide` section to count figures in. A guides hub such as\n * `/blog/cost-guides` has neither, and a service page's own price summary is not a guide, so neither is held to the 8.\n * Only rendered figures count: a band hidden because it is unconfirmed is not in the HTML, so it cannot count.\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { dollarFigures, fail, notApplicable, plural, resultOf, type ParsedPage } from \"./common.js\";\nimport { attr, descendants, textOf, type HtmlElement } from \"./html.js\";\n\nexport const E9_MIN_FIGURES = 8;\n\n/** The first path segment of a guide page. */\nexport const E9_GUIDE_ROOTS = [\"blog\", \"guides\", \"guide\"] as const;\n\n/** `data-slot` values that mark a price shown outside a table. */\nexport const E9_PRICE_SLOTS = [\"price\", \"price-low\", \"price-high\", \"price-range\", \"range\", \"amount\"] as const;\n\nexport function isGuidePath(path: string): boolean {\n const [first, ...rest] = path.split(\"/\").filter(Boolean);\n return first !== undefined && rest.length > 0 && (E9_GUIDE_ROOTS as readonly string[]).includes(first);\n}\n\nfunction hasCostInPath(path: string): boolean {\n const slug = path.split(\"/\").filter(Boolean).slice(1).join(\"-\");\n return slug.split(\"-\").some((word) => word === \"cost\" || word === \"costs\");\n}\n\nexport function isCostGuide(page: ParsedPage): boolean {\n if (!isGuidePath(page.path)) return false;\n const hasPriceGuideSection = descendants(page.root).some((element) => attr(element, \"data-bl-section\") === \"price-guide\");\n const hasPriceTable = hasPriceGuideSection || descendants(page.root).some((element) => element.tag === \"table\");\n return hasPriceTable && (hasCostInPath(page.path) || hasPriceGuideSection);\n}\n\nfunction isPriceElement(element: HtmlElement): boolean {\n if (element.tag === \"td\" || element.tag === \"th\") return true;\n const slot = attr(element, \"data-slot\");\n if (slot !== undefined && (E9_PRICE_SLOTS as readonly string[]).includes(slot)) return true;\n return attr(element, \"data-bl\") === \"price\" || attr(element, \"itemprop\") === \"price\";\n}\n\n/** The dollar figures a page shows in table cells and price elements. A price inside a cell is counted once. */\nexport function priceFigures(root: HtmlElement): number[] {\n const elements = descendants(root).filter(isPriceElement);\n const priced = new Set(elements);\n const outermost = elements.filter((element) => {\n for (let parent = element.parent; parent; parent = parent.parent) if (priced.has(parent)) return false;\n return true;\n });\n return outermost.flatMap((element) => dollarFigures(textOf(element)));\n}\n\nexport function checkE9(pages: readonly ParsedPage[]): SiteSystemFinding[] {\n const guides = pages.filter(isCostGuide);\n if (guides.length === 0) return [notApplicable(\"E9\", \"None of the sampled pages is a cost guide.\")];\n const failures = guides.flatMap((page): SiteSystemFinding[] => {\n const count = priceFigures(page.root).length;\n return count >= E9_MIN_FIGURES\n ? []\n : [fail(\"E9\", page.path, `The cost guide at ${page.path} shows ${plural(count, \"dollar figure\")} in its price table; it needs at least ${E9_MIN_FIGURES}.`)];\n });\n return resultOf(\"E9\", failures, `Checked ${plural(guides.length, \"cost guide\")}: each shows at least ${E9_MIN_FIGURES} dollar figures.`);\n}\n"]}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small tolerant HTML reader for the rendered rules. It reads the server HTML a crawler receives, builds the
|
|
3
|
+
* element tree and answers the few questions the rules ask: attributes, descendants, text and the lines a person
|
|
4
|
+
* reads. It is not a browser: it does not run scripts, apply CSS or repair every invalid document.
|
|
5
|
+
*/
|
|
6
|
+
export interface HtmlText {
|
|
7
|
+
type: "text";
|
|
8
|
+
text: string;
|
|
9
|
+
parent: HtmlElement | null;
|
|
10
|
+
}
|
|
11
|
+
export interface HtmlElement {
|
|
12
|
+
type: "element";
|
|
13
|
+
tag: string;
|
|
14
|
+
attrs: Record<string, string>;
|
|
15
|
+
children: HtmlNode[];
|
|
16
|
+
parent: HtmlElement | null;
|
|
17
|
+
}
|
|
18
|
+
export type HtmlNode = HtmlElement | HtmlText;
|
|
19
|
+
export declare function decodeEntities(value: string): string;
|
|
20
|
+
/** Collapses runs of whitespace, non-breaking spaces included, and trims. */
|
|
21
|
+
export declare function normaliseText(value: string): string;
|
|
22
|
+
/** Parses server HTML into a tree under a synthetic `#root` element. Never throws. */
|
|
23
|
+
export declare function parseHtml(html: string): HtmlElement;
|
|
24
|
+
/** Every element below `node`, in document order. */
|
|
25
|
+
export declare function descendants(node: HtmlElement): HtmlElement[];
|
|
26
|
+
export declare function attr(element: HtmlElement, name: string): string | undefined;
|
|
27
|
+
export declare function ancestors(element: HtmlElement): HtmlElement[];
|
|
28
|
+
export declare function closest(element: HtmlElement, test: (candidate: HtmlElement) => boolean): HtmlElement | null;
|
|
29
|
+
/** Whether the element, or an ancestor, is hidden from people: `hidden` or `aria-hidden="true"`. */
|
|
30
|
+
export declare function isHiddenFromPeople(element: HtmlElement): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* The text a person reads inside an element, whitespace collapsed. Blocks are separated by a space. Inline parts join
|
|
33
|
+
* with no space ("$" then "450" reads "$450") unless `spaced` is set, which puts a space at every element boundary
|
|
34
|
+
* so that "4.9" in one span and "132 reviews" in the next do not run together.
|
|
35
|
+
*/
|
|
36
|
+
export declare function textOf(element: HtmlElement, spaced?: boolean, skip?: (element: HtmlElement) => boolean): string;
|
|
37
|
+
export interface ReadableLine {
|
|
38
|
+
text: string;
|
|
39
|
+
/** The `href` of every link in the line. */
|
|
40
|
+
hrefs: string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The lines of text a person reads in an element: inline parts join, every block starts a new line. Subtrees that are
|
|
44
|
+
* hidden (`hidden`, `aria-hidden="true"`) are left out, and each line keeps the links it contains.
|
|
45
|
+
*/
|
|
46
|
+
export declare function readableLines(element: HtmlElement): ReadableLine[];
|
|
47
|
+
/** The text of each line a person reads in an element. */
|
|
48
|
+
export declare function linesOf(element: HtmlElement): string[];
|
|
49
|
+
/** The page's `<title>` text, or null when it has none. */
|
|
50
|
+
export declare function documentTitle(root: HtmlElement): string | null;
|
|
51
|
+
/** The visible name of a form control: its label, `aria-label` or `aria-labelledby` text. Empty when it has none. */
|
|
52
|
+
export declare function accessibleLabel(control: HtmlElement, scope: HtmlElement): string;
|
|
53
|
+
//# sourceMappingURL=html.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"html.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/enquiry/html.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,WAAW,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,SAAS,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,QAAQ,EAAE,QAAQ,EAAE,CAAC;IACrB,MAAM,EAAE,WAAW,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,MAAM,QAAQ,GAAG,WAAW,GAAG,QAAQ,CAAC;AAuC9C,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQpD;AAED,6EAA6E;AAC7E,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnD;AAkBD,sFAAsF;AACtF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAwFnD;AAED,qDAAqD;AACrD,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,GAAG,WAAW,EAAE,CAW5D;AAED,wBAAgB,IAAI,CAAC,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE3E;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,WAAW,GAAG,WAAW,EAAE,CAI7D;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,SAAS,EAAE,WAAW,KAAK,OAAO,GAAG,WAAW,GAAG,IAAI,CAK3G;AAED,oGAAoG;AACpG,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAEhE;AAED;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,UAAQ,EAAE,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,GAAG,MAAM,CAE7G;AAgBD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,4CAA4C;IAC5C,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAOD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,YAAY,EAAE,CA+BlE;AAED,0DAA0D;AAC1D,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,EAAE,CAEtD;AAED,2DAA2D;AAC3D,wBAAgB,aAAa,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CAI9D;AAED,qHAAqH;AACrH,wBAAgB,eAAe,CAAC,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,WAAW,GAAG,MAAM,CAqBhF"}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small tolerant HTML reader for the rendered rules. It reads the server HTML a crawler receives, builds the
|
|
3
|
+
* element tree and answers the few questions the rules ask: attributes, descendants, text and the lines a person
|
|
4
|
+
* reads. It is not a browser: it does not run scripts, apply CSS or repair every invalid document.
|
|
5
|
+
*/
|
|
6
|
+
const VOID_TAGS = new Set([
|
|
7
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr",
|
|
8
|
+
]);
|
|
9
|
+
/** Elements whose content is not markup: kept as text, never part of what a person reads. */
|
|
10
|
+
const RAW_TEXT_TAGS = new Set(["script", "style"]);
|
|
11
|
+
/** Elements whose content is text with entities and no markup. */
|
|
12
|
+
const ESCAPABLE_RAW_TEXT_TAGS = new Set(["textarea", "title"]);
|
|
13
|
+
/** Opening the key closes an open element of one of the listed tags when it is the innermost one. */
|
|
14
|
+
const IMPLIED_END = {
|
|
15
|
+
li: ["li"],
|
|
16
|
+
dt: ["dt", "dd"],
|
|
17
|
+
dd: ["dt", "dd"],
|
|
18
|
+
tr: ["tr", "td", "th"],
|
|
19
|
+
td: ["td", "th"],
|
|
20
|
+
th: ["td", "th"],
|
|
21
|
+
option: ["option"],
|
|
22
|
+
optgroup: ["option", "optgroup"],
|
|
23
|
+
p: ["p"],
|
|
24
|
+
};
|
|
25
|
+
/** Elements that read as part of the line they sit in. Every other element starts a new line. */
|
|
26
|
+
const INLINE_TAGS = new Set([
|
|
27
|
+
"a", "abbr", "b", "bdi", "bdo", "cite", "code", "data", "dfn", "em", "i", "kbd", "mark", "q", "s", "samp", "small",
|
|
28
|
+
"span", "strong", "sub", "sup", "time", "u", "var", "wbr",
|
|
29
|
+
]);
|
|
30
|
+
/** Elements whose text a person never reads as page copy. */
|
|
31
|
+
const NON_READABLE_TAGS = new Set(["script", "style", "template", "noscript", "select", "option", "head", "svg"]);
|
|
32
|
+
const NAMED_ENTITIES = {
|
|
33
|
+
amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'", nbsp: " ", dollar: "$", pound: "£", ndash: "–",
|
|
34
|
+
mdash: "—", hellip: "…", rsquo: "’", lsquo: "‘", ldquo: "“", rdquo: "”", times: "×",
|
|
35
|
+
copy: "©", bull: "•", middot: "·", check: "✓", star: "☆",
|
|
36
|
+
};
|
|
37
|
+
export function decodeEntities(value) {
|
|
38
|
+
return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (match, body) => {
|
|
39
|
+
if (body[0] === "#") {
|
|
40
|
+
const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
|
|
41
|
+
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
|
42
|
+
}
|
|
43
|
+
return NAMED_ENTITIES[body.toLowerCase()] ?? match;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Collapses runs of whitespace, non-breaking spaces included, and trims. */
|
|
47
|
+
export function normaliseText(value) {
|
|
48
|
+
return value.replace(/[\s ]+/g, " ").trim();
|
|
49
|
+
}
|
|
50
|
+
const ATTRIBUTE = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
51
|
+
function parseAttributes(source) {
|
|
52
|
+
const attrs = {};
|
|
53
|
+
for (const match of source.matchAll(ATTRIBUTE)) {
|
|
54
|
+
const name = match[1].toLowerCase();
|
|
55
|
+
if (Object.hasOwn(attrs, name))
|
|
56
|
+
continue;
|
|
57
|
+
attrs[name] = decodeEntities(match[2] ?? match[3] ?? match[4] ?? "");
|
|
58
|
+
}
|
|
59
|
+
return attrs;
|
|
60
|
+
}
|
|
61
|
+
function createElement(tag, attrs, parent) {
|
|
62
|
+
return { type: "element", tag, attrs, children: [], parent };
|
|
63
|
+
}
|
|
64
|
+
/** Parses server HTML into a tree under a synthetic `#root` element. Never throws. */
|
|
65
|
+
export function parseHtml(html) {
|
|
66
|
+
const root = createElement("#root", {}, null);
|
|
67
|
+
const stack = [root];
|
|
68
|
+
const top = () => stack[stack.length - 1];
|
|
69
|
+
const addText = (text) => {
|
|
70
|
+
if (text)
|
|
71
|
+
top().children.push({ type: "text", text, parent: top() });
|
|
72
|
+
};
|
|
73
|
+
let index = 0;
|
|
74
|
+
while (index < html.length) {
|
|
75
|
+
const open = html.indexOf("<", index);
|
|
76
|
+
if (open === -1) {
|
|
77
|
+
addText(decodeEntities(html.slice(index)));
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
if (open > index)
|
|
81
|
+
addText(decodeEntities(html.slice(index, open)));
|
|
82
|
+
if (html.startsWith("<!--", open)) {
|
|
83
|
+
const close = html.indexOf("-->", open + 4);
|
|
84
|
+
index = close === -1 ? html.length : close + 3;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (html[open + 1] === "!" || html[open + 1] === "?") {
|
|
88
|
+
const close = html.indexOf(">", open + 2);
|
|
89
|
+
index = close === -1 ? html.length : close + 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (html[open + 1] === "/") {
|
|
93
|
+
const close = html.indexOf(">", open + 2);
|
|
94
|
+
const tag = html.slice(open + 2, close === -1 ? html.length : close).trim().toLowerCase();
|
|
95
|
+
index = close === -1 ? html.length : close + 1;
|
|
96
|
+
for (let depth = stack.length - 1; depth > 0; depth -= 1) {
|
|
97
|
+
if (stack[depth].tag === tag) {
|
|
98
|
+
stack.length = depth;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const tagMatch = /^<([a-zA-Z][a-zA-Z0-9:-]*)/.exec(html.slice(open, open + 64));
|
|
105
|
+
if (!tagMatch) {
|
|
106
|
+
addText("<");
|
|
107
|
+
index = open + 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// The tag ends at the first ">" outside a quoted attribute value.
|
|
111
|
+
let cursor = open + tagMatch[0].length;
|
|
112
|
+
let quote = "";
|
|
113
|
+
while (cursor < html.length) {
|
|
114
|
+
const character = html[cursor];
|
|
115
|
+
if (quote) {
|
|
116
|
+
if (character === quote)
|
|
117
|
+
quote = "";
|
|
118
|
+
}
|
|
119
|
+
else if (character === "\"" || character === "'") {
|
|
120
|
+
quote = character;
|
|
121
|
+
}
|
|
122
|
+
else if (character === ">") {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
cursor += 1;
|
|
126
|
+
}
|
|
127
|
+
const tag = tagMatch[1].toLowerCase();
|
|
128
|
+
const rawAttributes = html.slice(open + tagMatch[0].length, cursor);
|
|
129
|
+
const selfClosing = rawAttributes.trimEnd().endsWith("/");
|
|
130
|
+
index = Math.min(cursor + 1, html.length);
|
|
131
|
+
const implied = IMPLIED_END[tag];
|
|
132
|
+
if (implied && stack.length > 1 && implied.includes(top().tag))
|
|
133
|
+
stack.pop();
|
|
134
|
+
const element = createElement(tag, parseAttributes(selfClosing ? rawAttributes.trimEnd().slice(0, -1) : rawAttributes), top());
|
|
135
|
+
top().children.push(element);
|
|
136
|
+
if (VOID_TAGS.has(tag) || selfClosing)
|
|
137
|
+
continue;
|
|
138
|
+
if (RAW_TEXT_TAGS.has(tag) || ESCAPABLE_RAW_TEXT_TAGS.has(tag)) {
|
|
139
|
+
const closing = new RegExp(`</${tag}\\s*>`, "gi");
|
|
140
|
+
closing.lastIndex = index;
|
|
141
|
+
const close = closing.exec(html);
|
|
142
|
+
const end = close ? close.index : html.length;
|
|
143
|
+
const content = html.slice(index, end);
|
|
144
|
+
element.children.push({
|
|
145
|
+
type: "text",
|
|
146
|
+
text: ESCAPABLE_RAW_TEXT_TAGS.has(tag) ? decodeEntities(content) : content,
|
|
147
|
+
parent: element,
|
|
148
|
+
});
|
|
149
|
+
index = close ? close.index + close[0].length : html.length;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
stack.push(element);
|
|
153
|
+
}
|
|
154
|
+
return root;
|
|
155
|
+
}
|
|
156
|
+
/** Every element below `node`, in document order. */
|
|
157
|
+
export function descendants(node) {
|
|
158
|
+
const found = [];
|
|
159
|
+
const visit = (current) => {
|
|
160
|
+
for (const child of current.children) {
|
|
161
|
+
if (child.type !== "element")
|
|
162
|
+
continue;
|
|
163
|
+
found.push(child);
|
|
164
|
+
visit(child);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
visit(node);
|
|
168
|
+
return found;
|
|
169
|
+
}
|
|
170
|
+
export function attr(element, name) {
|
|
171
|
+
return Object.hasOwn(element.attrs, name) ? element.attrs[name] : undefined;
|
|
172
|
+
}
|
|
173
|
+
export function ancestors(element) {
|
|
174
|
+
const found = [];
|
|
175
|
+
for (let current = element.parent; current && current.tag !== "#root"; current = current.parent)
|
|
176
|
+
found.push(current);
|
|
177
|
+
return found;
|
|
178
|
+
}
|
|
179
|
+
export function closest(element, test) {
|
|
180
|
+
for (let current = element; current && current.tag !== "#root"; current = current.parent) {
|
|
181
|
+
if (test(current))
|
|
182
|
+
return current;
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
/** Whether the element, or an ancestor, is hidden from people: `hidden` or `aria-hidden="true"`. */
|
|
187
|
+
export function isHiddenFromPeople(element) {
|
|
188
|
+
return closest(element, (candidate) => attr(candidate, "hidden") !== undefined || attr(candidate, "aria-hidden") === "true") !== null;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The text a person reads inside an element, whitespace collapsed. Blocks are separated by a space. Inline parts join
|
|
192
|
+
* with no space ("$" then "450" reads "$450") unless `spaced` is set, which puts a space at every element boundary
|
|
193
|
+
* so that "4.9" in one span and "132 reviews" in the next do not run together.
|
|
194
|
+
*/
|
|
195
|
+
export function textOf(element, spaced = false, skip) {
|
|
196
|
+
return normaliseText(collectText(element, spaced, skip));
|
|
197
|
+
}
|
|
198
|
+
function collectText(element, spaced, skip) {
|
|
199
|
+
let text = "";
|
|
200
|
+
for (const child of element.children) {
|
|
201
|
+
if (child.type === "text") {
|
|
202
|
+
text += child.text;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (NON_READABLE_TAGS.has(child.tag) || skip?.(child))
|
|
206
|
+
continue;
|
|
207
|
+
const inline = !spaced && INLINE_TAGS.has(child.tag);
|
|
208
|
+
text += (inline ? "" : " ") + collectText(child, spaced, skip) + (inline ? "" : " ");
|
|
209
|
+
}
|
|
210
|
+
return text;
|
|
211
|
+
}
|
|
212
|
+
/** Hidden from people by `hidden` or `aria-hidden="true"` on the element itself. */
|
|
213
|
+
function hiddenItself(element) {
|
|
214
|
+
return attr(element, "hidden") !== undefined || attr(element, "aria-hidden") === "true";
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The lines of text a person reads in an element: inline parts join, every block starts a new line. Subtrees that are
|
|
218
|
+
* hidden (`hidden`, `aria-hidden="true"`) are left out, and each line keeps the links it contains.
|
|
219
|
+
*/
|
|
220
|
+
export function readableLines(element) {
|
|
221
|
+
const lines = [];
|
|
222
|
+
let current = "";
|
|
223
|
+
let hrefs = [];
|
|
224
|
+
const flush = () => {
|
|
225
|
+
const text = normaliseText(current);
|
|
226
|
+
if (text)
|
|
227
|
+
lines.push({ text, hrefs });
|
|
228
|
+
current = "";
|
|
229
|
+
hrefs = [];
|
|
230
|
+
};
|
|
231
|
+
const visit = (node) => {
|
|
232
|
+
for (const child of node.children) {
|
|
233
|
+
if (child.type === "text") {
|
|
234
|
+
current += child.text;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (NON_READABLE_TAGS.has(child.tag) || hiddenItself(child))
|
|
238
|
+
continue;
|
|
239
|
+
if (child.tag === "br") {
|
|
240
|
+
flush();
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (child.tag === "a")
|
|
244
|
+
hrefs.push(attr(child, "href") ?? "");
|
|
245
|
+
const inline = INLINE_TAGS.has(child.tag);
|
|
246
|
+
if (!inline)
|
|
247
|
+
flush();
|
|
248
|
+
visit(child);
|
|
249
|
+
if (!inline)
|
|
250
|
+
flush();
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
visit(element);
|
|
254
|
+
flush();
|
|
255
|
+
return lines;
|
|
256
|
+
}
|
|
257
|
+
/** The text of each line a person reads in an element. */
|
|
258
|
+
export function linesOf(element) {
|
|
259
|
+
return readableLines(element).map((line) => line.text);
|
|
260
|
+
}
|
|
261
|
+
/** The page's `<title>` text, or null when it has none. */
|
|
262
|
+
export function documentTitle(root) {
|
|
263
|
+
const title = descendants(root).find((element) => element.tag === "title");
|
|
264
|
+
if (!title)
|
|
265
|
+
return null;
|
|
266
|
+
return normaliseText(title.children.map((child) => (child.type === "text" ? child.text : "")).join(""));
|
|
267
|
+
}
|
|
268
|
+
/** The visible name of a form control: its label, `aria-label` or `aria-labelledby` text. Empty when it has none. */
|
|
269
|
+
export function accessibleLabel(control, scope) {
|
|
270
|
+
const ariaLabel = attr(control, "aria-label");
|
|
271
|
+
if (ariaLabel)
|
|
272
|
+
return normaliseText(ariaLabel);
|
|
273
|
+
const labelledBy = attr(control, "aria-labelledby");
|
|
274
|
+
const elements = descendants(scope);
|
|
275
|
+
if (labelledBy) {
|
|
276
|
+
const text = labelledBy
|
|
277
|
+
.split(/\s+/)
|
|
278
|
+
.map((id) => elements.find((element) => attr(element, "id") === id))
|
|
279
|
+
.filter((element) => element !== undefined)
|
|
280
|
+
.map((element) => textOf(element))
|
|
281
|
+
.join(" ");
|
|
282
|
+
if (text)
|
|
283
|
+
return normaliseText(text);
|
|
284
|
+
}
|
|
285
|
+
const id = attr(control, "id");
|
|
286
|
+
if (id) {
|
|
287
|
+
const label = elements.find((element) => element.tag === "label" && attr(element, "for") === id);
|
|
288
|
+
if (label)
|
|
289
|
+
return textOf(label);
|
|
290
|
+
}
|
|
291
|
+
const wrapping = closest(control, (candidate) => candidate.tag === "label");
|
|
292
|
+
return wrapping ? textOf(wrapping) : "";
|
|
293
|
+
}
|
|
294
|
+
//# sourceMappingURL=html.js.map
|