@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,212 @@
|
|
|
1
|
+
import { implementationWordingMatches } from "../../../public-copy.js";
|
|
2
|
+
import { BASED_IN, KM_FROM_BASE } from "./location-wording.js";
|
|
3
|
+
import { closest } from "./html.js";
|
|
4
|
+
import { elementAt, excerpt, inAny } from "./page.js";
|
|
5
|
+
import { failFinding, passFinding, sampledPages, warningFinding } from "./types.js";
|
|
6
|
+
export const BANNED_PATTERNS = [
|
|
7
|
+
// Layer 1, everywhere.
|
|
8
|
+
{ id: "reassurance-filler", layer: 1, label: "a reassurance or helper line",
|
|
9
|
+
regex: /\bwe\s+(?:only|will\s+only)\s+use\s+your\s+(?:details|information|info|data)\s+to\s+(?:reply|respond|contact|get\s+back)|\ball\s+fields\s+(?:are\s+)?(?:needed|required)\b|\bphotos?\s+(?:are|is)\s+optional\b|\bwe(?:'ll|\s+will)\s+never\s+(?:share|sell|spam|pass\s+on)\b/i },
|
|
10
|
+
{ id: "site-explaining", layer: 1, label: "text that explains the website or the quoting process",
|
|
11
|
+
regex: /\b(?:browse|explore|scroll\s+through|have\s+a\s+look\s+through|take\s+a\s+look\s+through)\b[^.!?\n]{0,40}\b(?:below|on\s+(?:this|our)\s+(?:page|site|website))\b|\b(?:use|fill\s+(?:in|out)|complete|submit)\s+(?:the|our|this)\s+(?:(?:quick|short|simple|online|quote|contact|enquiry)\s+)*form\s+(?:below|above)\b|\bwelcome\s+to\s+(?:our|the)\s+(?:new\s+)?(?:website|site|web\s?page)\b|\b(?:our|the)\s+(?:simple\s+|easy\s+)?quot(?:e|ing)\s+process\b|^\W*(?:so,?\s+)?what\s+happens\s+next\W*$/i },
|
|
12
|
+
{ id: "price-source", layer: 1, label: "a price source or market-rate line",
|
|
13
|
+
regex: /\bmarket\s+rates?\b|\bgeneral\s+market\s+pric(?:e|es|ing)\b|\bmarket\s+pricing\b/i },
|
|
14
|
+
// Layer 2, headers, heroes and location pages.
|
|
15
|
+
{ id: "based-in", layer: 2, label: "a \"based in\" line",
|
|
16
|
+
regex: BASED_IN },
|
|
17
|
+
{ id: "km-from-base", layer: 2, label: "a distance from the base",
|
|
18
|
+
regex: KM_FROM_BASE },
|
|
19
|
+
{ id: "walk-ins", layer: 2, label: "a \"no walk-ins\" line",
|
|
20
|
+
regex: /\bno\s+(?:customer\s+)?walk[- ]?ins?\b/i },
|
|
21
|
+
// Layer 3, the trade claims scan. Each needs evidence in the claims register.
|
|
22
|
+
{ id: "licensed-claim", layer: 3, label: "a \"licensed\" claim without a licence number",
|
|
23
|
+
regex: /\b(?:fully|properly|100\s?%)\s+licen[cs]ed\b|\blicen[cs]ed\s+(?:and|&)\s+(?:insured|certified|qualified)\b|\bqualified,?\s+(?:and\s+)?licen[cs]ed\b|\b(?:we(?:'re|\s+are)|all\s+(?:our|of\s+our)\s+\w+\s+(?:are|is)|our\s+(?:team|tradies|\w+)\s+(?:is|are))\s+licen[cs]ed\b/i },
|
|
24
|
+
{ id: "24-7", layer: 3, label: "a 24/7 or round-the-clock promise",
|
|
25
|
+
regex: /\b24\s*\/\s*7\b|\b24\s*(?:hours?|hrs?)\s*(?:a|per)\s*day\b|\b24[- ]hours?\s+(?:emergency|service|call[- ]?outs?|response|availability|support)\b|\bopen\s+24\s*hours\b/i },
|
|
26
|
+
{ id: "response-time", layer: 3, label: "a minute or hour response promise",
|
|
27
|
+
regex: /\b(?:within|in)\s+(?:the\s+)?(?:\d{1,3}|an|one|fifteen|twenty|thirty|sixty)\s*(?:-\s*)?(?:minutes?|mins?|hours?|hrs?)\b(?=[^.]{0,40}\b(?:response|respond|reply|arrive|arrival|on[- ]site|at\s+your\s+door|call\s+back|there)\b)|\b(?:response|respond|reply|arrive|arrival|on[- ]site|at\s+your\s+door|call\s+back|there)\b[^.]{0,40}\b(?:within|in)\s+(?:the\s+)?(?:\d{1,3}|an|one|fifteen|twenty|thirty|sixty)\s*(?:-\s*)?(?:minutes?|mins?|hours?|hrs?)\b|\b\d{1,3}[- ]?(?:minute|min|hour|hr)\s+(?:response|arrival|call[- ]?out|guarantee)\b|\bwithin\s+the\s+hour\b/i },
|
|
28
|
+
{ id: "call-out-fee", layer: 3, label: "a free or $0 call-out claim",
|
|
29
|
+
regex: /\$\s?0\s*call[- ]?out|\b(?:free|no)\s+call[- ]?out(?:\s+(?:fee|charge))?s?\b/i },
|
|
30
|
+
{ id: "number-one", layer: 3, label: "a \"#1\" claim",
|
|
31
|
+
regex: /(?:^|[\s(])#\s?1\b|\bnumber\s+(?:one|1)\b|\bno\.?\s?1\s+(?:in|for|choice|rated)\b/i },
|
|
32
|
+
{ id: "best-claim", layer: 3, label: "a \"best\" claim",
|
|
33
|
+
regex: /\b(?:the\s+)?best\s+(?:\w+\s+){0,4}(?:in|of|around|across)\s+(?:sydney|nsw|new\s+south\s+wales|australia|the\s+(?:north\s+shore|northern\s+beaches|hills|inner\s+west|eastern\s+suburbs|shire))\b|\bvoted\s+(?:the\s+)?best\b/i },
|
|
34
|
+
{ id: "cheapest", layer: 3, label: "a cheapest or lowest-price claim",
|
|
35
|
+
regex: /\bcheapest\b|\blowest\s+prices?\b|\bunbeatable\s+(?:price|prices|rates?)\b|\bbest\s+prices?\b/i },
|
|
36
|
+
{ id: "leading", layer: 3, label: "a \"leading\" or \"largest\" claim",
|
|
37
|
+
regex: /\b[A-Z][A-Za-z]+(?:\s[A-Z][A-Za-z]+)?['’]s\s+(?:[Ll]eading|[Ll]argest|[Tt]op|[Pp]remier|[Ff]inest|[Bb]est|[Mm]ost\s+[Tt]rusted)\b|\bleading\s+(?:\w+\s+){0,3}(?:in|of)\s+(?:sydney|nsw|australia)\b/ },
|
|
38
|
+
{ id: "lifetime", layer: 3, label: "a lifetime guarantee claim",
|
|
39
|
+
regex: /\blifetime\s+(?:warranty|guarantee|guaranteed|workmanship|protection|cover)\b|\bguaranteed\s+for\s+life\b/i },
|
|
40
|
+
{ id: "satisfaction-guarantee", layer: 3, label: "a 100% satisfaction claim",
|
|
41
|
+
regex: /\b100\s?%\s+(?:satisfaction|guaranteed|guarantee)\b|\bsatisfaction\s+guaranteed\b/i },
|
|
42
|
+
{ id: "beat-any-quote", layer: 3, label: "a \"beat any quote\" promise",
|
|
43
|
+
regex: /\bbeat\s+any\s+(?:quote|price)\b|\bprice[- ]match(?:ing)?\s+guarantee\b/i },
|
|
44
|
+
{ id: "master-plumber", layer: 3, label: "a \"Master Plumber\" claim without membership evidence",
|
|
45
|
+
regex: /\bmaster\s+plumbers?\b/i },
|
|
46
|
+
{ id: "dulux-accredited", layer: 3, label: "a \"Dulux accredited\" claim without the certificate",
|
|
47
|
+
regex: /\bdulux\s+accredited\b/i },
|
|
48
|
+
{ id: "colorbond-accredited", layer: 3, label: "an accredited Colorbond contractor claim without the certificate",
|
|
49
|
+
regex: /\baccredited\s+colorbond\b/i },
|
|
50
|
+
{ id: "as-3740", layer: 3, label: "an AS 3740 compliance claim without paperwork",
|
|
51
|
+
regex: /\bAS\s?\/?\s?(?:NZS\s?)?3740\b[^.]{0,40}\bcompl|\bcompl\w*\s+(?:with\s+)?AS\s?\/?\s?(?:NZS\s?)?3740\b/i },
|
|
52
|
+
{ id: "award-winning", layer: 3, label: "an \"award-winning\" claim without evidence of a win",
|
|
53
|
+
regex: /\baward[- ]winning\b/i },
|
|
54
|
+
{ id: "since-year", layer: 3, label: "a \"since {year}\" claim not matched to the ABN or licence record",
|
|
55
|
+
regex: /\bsince\s+(?:19|20)\d{2}\b/i },
|
|
56
|
+
{ id: "years-experience", layer: 3, label: "a years-in-business claim not matched to the ABN or licence record",
|
|
57
|
+
regex: /\b\d{1,3}\s?\+\s*years?\b|\b(?:over|more\s+than|almost|nearly)\s+\d{1,3}\s+years?\b|\b\d{1,3}\s+years?(?:['’]s?)?\s+(?:of\s+)?(?:combined\s+)?(?:experience|in\s+(?:business|the\s+industry|the\s+trade)|trading)\b/i },
|
|
58
|
+
{ id: "council-approved", layer: 3, label: "a \"council-approved\" claim",
|
|
59
|
+
regex: /\bcouncil[- ]approved\b/i },
|
|
60
|
+
{ id: "interior-designer", layer: 3, label: "an \"interior designer\" claim without evidence",
|
|
61
|
+
regex: /\b(?:our|we(?:'re|\s+are)|qualified|experienced|professional|expert|team\s+of)\s+(?:\w+\s+){0,2}interior\s+designers?\b/i },
|
|
62
|
+
{ id: "landscape-architect", layer: 3, label: "a \"landscape architect\" claim without registration",
|
|
63
|
+
regex: /\b(?:our|we(?:'re|\s+are)|qualified|experienced|registered|team\s+of)\s+(?:\w+\s+){0,2}landscape\s+architects?\b/i },
|
|
64
|
+
{ id: "plus-gst", layer: 3, label: "a price shown \"+ GST\" to homeowners",
|
|
65
|
+
regex: /\+\s*GST\b|\bplus\s+GST\b|\b(?:ex|excl\.?|excluding)\s+GST\b/i },
|
|
66
|
+
{ id: "environmental-claim", layer: 3, label: "an environmental claim without specific evidence",
|
|
67
|
+
regex: /\beco[- ]?friendly\b|\benvironmentally[- ]friendly\b|\bsustainab(?:le|ility)\b|\bgo(?:ing)?\s+green\b|\bgreen\s+(?:energy|building|solutions?|choices?|alternatives?|products?|credentials|practices)\b|\bcarbon[- ](?:neutral|positive|friendly)\b|\bnet[- ]zero\b|\bzero[- ]emissions?\b|\b(?:save|saving|reduce|cut|lower)\s+(?:up\s+to\s+|by\s+)?\d{1,3}\s?%\s*(?:on\s+|off\s+)?(?:your\s+)?(?:energy|power|electricity|heating|cooling|gas|water)\b/i },
|
|
68
|
+
];
|
|
69
|
+
/** American spellings are advised on, not failed (spec 6.3, layer 1 "warning"). */
|
|
70
|
+
const AMERICAN_WORDS = [
|
|
71
|
+
"color", "colors", "colored", "neighbor", "neighbors", "neighborhood", "neighborhoods", "favorite", "favorites",
|
|
72
|
+
"center", "centers", "organize", "organizes", "organized", "organization", "realize", "realizes", "realized",
|
|
73
|
+
"recognize", "recognizes", "recognized", "specialize", "specializes", "specialized", "customize", "customizes",
|
|
74
|
+
"customized", "minimize", "minimizes", "minimized", "prioritize", "prioritizes", "prioritized", "summarize",
|
|
75
|
+
"summarizes", "summarized", "sanitize", "sanitizes", "sanitized", "authorize", "authorizes", "authorized",
|
|
76
|
+
"aluminum", "curb", "curbs", "gray", "catalog", "catalogs", "tire", "tires", "defense",
|
|
77
|
+
];
|
|
78
|
+
const AMERICAN_SPELLING = new RegExp(`\\b(?:${AMERICAN_WORDS.join("|")})\\b`, "gi");
|
|
79
|
+
const COMPILED = BANNED_PATTERNS.map((pattern) => ({
|
|
80
|
+
pattern,
|
|
81
|
+
global: new RegExp(pattern.regex.source, pattern.regex.flags.includes("g") ? pattern.regex.flags : `${pattern.regex.flags}g`),
|
|
82
|
+
}));
|
|
83
|
+
function pageSources(page) {
|
|
84
|
+
return [
|
|
85
|
+
...page.units.map((unit) => ({ text: unit.text, unit, where: "text" })),
|
|
86
|
+
...page.attributeCopy.map((unit) => ({ text: unit.text, unit, where: "attribute" })),
|
|
87
|
+
...page.metadata.map((text) => ({ text, unit: null, where: "metadata" })),
|
|
88
|
+
...page.schemaCopy.map((text) => ({ text, unit: null, where: "schema" })),
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
function isReviewVoice(element) {
|
|
92
|
+
return closest(element, (candidate) => candidate.attrs["data-bl-section"] === "reviews" || candidate.attrs["data-bl-source"] === "feed" || candidate.tag === "blockquote" || candidate.attrs.itemprop === "reviewBody") !== null;
|
|
93
|
+
}
|
|
94
|
+
function isMenu(element) {
|
|
95
|
+
return closest(element, (candidate) => candidate.tag === "nav" || candidate.attrs["data-slot"] === "panel") !== null;
|
|
96
|
+
}
|
|
97
|
+
function layerTwoRegions(page) {
|
|
98
|
+
if (page.family === "contact")
|
|
99
|
+
return null;
|
|
100
|
+
const exclude = [...page.footers];
|
|
101
|
+
if (page.family === "location")
|
|
102
|
+
return { include: [page.body], exclude };
|
|
103
|
+
return { include: [...page.headers, ...(page.hero ? [page.hero] : [])], exclude };
|
|
104
|
+
}
|
|
105
|
+
function recordedMatch(recorded, pattern, matched) {
|
|
106
|
+
const phrase = matched.toLowerCase();
|
|
107
|
+
return recorded.some((entry) => {
|
|
108
|
+
const claim = entry.trim().toLowerCase();
|
|
109
|
+
return claim === pattern.id || (claim.length > 2 && (phrase.includes(claim) || claim.includes(phrase)));
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const LICENCE_NUMBER = /\blic(?:en[cs]e)?\.?\s*(?:no\.?|number|#)?\s*:?\s*[A-Z]{0,3}\s?\d{4,}[A-Z]?\b/i;
|
|
113
|
+
function showsLicenceNumber(page, facts) {
|
|
114
|
+
const text = page.units.map((unit) => unit.text).join(" ");
|
|
115
|
+
if (LICENCE_NUMBER.test(text))
|
|
116
|
+
return true;
|
|
117
|
+
const compact = text.toLowerCase().replace(/[\s\-.]/g, "");
|
|
118
|
+
return (facts?.licences ?? []).some((licence) => compact.includes(licence.identifier.toLowerCase().replace(/[\s\-.]/g, "")));
|
|
119
|
+
}
|
|
120
|
+
/** A customer's own words, or a block rendered from the feed, are not the business's copy. */
|
|
121
|
+
function isExempt(source, index) {
|
|
122
|
+
return source.unit !== null && isReviewVoice(elementAt(source.unit, index));
|
|
123
|
+
}
|
|
124
|
+
/** E8 over the sampled pages. */
|
|
125
|
+
export function checkBannedWording(pages, facts, recordedClaims = []) {
|
|
126
|
+
const findings = [];
|
|
127
|
+
let claimWarnings = 0;
|
|
128
|
+
for (const page of pages) {
|
|
129
|
+
const hits = new Map();
|
|
130
|
+
const sources = pageSources(page);
|
|
131
|
+
const licenceShown = showsLicenceNumber(page, facts);
|
|
132
|
+
const layerTwo = layerTwoRegions(page);
|
|
133
|
+
for (const { pattern, global } of COMPILED) {
|
|
134
|
+
for (const source of sources) {
|
|
135
|
+
if (pattern.layer === 2 && (source.where === "metadata" || source.where === "schema"))
|
|
136
|
+
continue;
|
|
137
|
+
let hit;
|
|
138
|
+
for (const match of source.text.matchAll(global)) {
|
|
139
|
+
if (isExempt(source, match.index))
|
|
140
|
+
continue;
|
|
141
|
+
if (pattern.layer === 2) {
|
|
142
|
+
const element = source.unit ? elementAt(source.unit, match.index) : null;
|
|
143
|
+
if (!element || !layerTwo || inAny(element, layerTwo.exclude) || !inAny(element, layerTwo.include))
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (pattern.layer === 3) {
|
|
147
|
+
if (recordedMatch(recordedClaims, pattern, match[0]))
|
|
148
|
+
continue;
|
|
149
|
+
if (pattern.id === "licensed-claim" && licenceShown)
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
hit = { layer: pattern.layer, label: pattern.label, quote: excerpt(source.text, match.index, match[0].length) };
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
if (hit) {
|
|
156
|
+
hits.set(pattern.id, hit);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Layer 1: price teasers in menus and template wording.
|
|
162
|
+
for (const unit of page.units) {
|
|
163
|
+
for (const match of unit.text.matchAll(/\bfrom\s+\$\s?\d/gi)) {
|
|
164
|
+
const element = elementAt(unit, match.index);
|
|
165
|
+
if (!isMenu(element) || isReviewVoice(element))
|
|
166
|
+
continue;
|
|
167
|
+
hits.set("menu-price-teaser", { layer: 1, label: "a \"from $X\" price teaser in a menu", quote: excerpt(unit.text, match.index, match[0].length) });
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
if (hits.has("menu-price-teaser"))
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
// Every match counts: an exempt one (a customer's own words) must not hide template wording later in the source.
|
|
174
|
+
for (const source of sources) {
|
|
175
|
+
for (const found of implementationWordingMatches(source.text, { singleWord: true })) {
|
|
176
|
+
if (isExempt(source, found.index))
|
|
177
|
+
continue;
|
|
178
|
+
hits.set("template-wording", { layer: 1, label: "template or placeholder wording", quote: excerpt(source.text, found.index, 10) });
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
if (hits.has("template-wording"))
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
for (const hit of hits.values()) {
|
|
185
|
+
if (hit.layer === 3)
|
|
186
|
+
claimWarnings += 1;
|
|
187
|
+
findings.push(hit.layer === 3
|
|
188
|
+
? warningFinding("E8", `Warning: the page ${page.path} shows ${hit.label} ("${hit.quote}"); it needs evidence in the claims register or removing.`, page.path)
|
|
189
|
+
: failFinding("E8", page.path, `The page ${page.path} shows ${hit.label} ("${hit.quote}"), which the banned list does not allow.`));
|
|
190
|
+
}
|
|
191
|
+
const american = new Set();
|
|
192
|
+
for (const source of sources) {
|
|
193
|
+
for (const match of source.text.matchAll(AMERICAN_SPELLING)) {
|
|
194
|
+
if (!isExempt(source, match.index))
|
|
195
|
+
american.add(match[0].toLowerCase());
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (american.size > 0) {
|
|
199
|
+
findings.push(warningFinding("E8", `Warning: the page ${page.path} uses American spelling (${[...american].slice(0, 4).join(", ")}); client copy is Australian English.`, page.path));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const failed = findings.some((finding) => finding.status === "fail");
|
|
203
|
+
if (failed)
|
|
204
|
+
return findings;
|
|
205
|
+
const clear = claimWarnings === 0 ? "from any of the three layers" : "from layers 1 and 2 (the claims below need evidence)";
|
|
206
|
+
return [passFinding("E8", `No banned wording ${clear} on ${sampledPages(pages.length)}.`), ...findings];
|
|
207
|
+
}
|
|
208
|
+
/** Used by tests to confirm which patterns exist for each layer. */
|
|
209
|
+
export function bannedPatternIds(layer) {
|
|
210
|
+
return BANNED_PATTERNS.filter((pattern) => pattern.layer === layer).map((pattern) => pattern.id);
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=banned-wording.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"banned-wording.js","sourceRoot":"","sources":["../../../../src/site-system/rules/shell/banned-wording.ts"],"names":[],"mappings":"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","sourcesContent":["import { implementationWordingMatches } from \"../../../public-copy.js\";\nimport { BASED_IN, KM_FROM_BASE } from \"./location-wording.js\";\nimport { closest, type HtmlElement } from \"./html.js\";\nimport { elementAt, excerpt, inAny, type ShellPage, type TextUnit } from \"./page.js\";\nimport { failFinding, passFinding, sampledPages, warningFinding, type ShellFinding, type ShellProjectFacts } from \"./types.js\";\n\n/**\n * E8: the banned list, all three layers (spec 6.3). Layer 1 holds everywhere (helper and reassurance filler, text that\n * explains the site or the quoting process, price sources and menu price teasers, template wording). Layer 2 holds on\n * headers, heroes and location pages and is allowed in the footer, on the contact page and in schema. Layer 3 is the\n * trade claims scan: each pattern needs evidence in the claims register. Until a register reader exists, layer 3 is\n * reported as a warning (status \"pass\", severity \"warning\") naming the phrase and page; claims in `recordedClaims`\n * are not reported. A customer's own words (review text and feed blocks) are exempt from every layer.\n * Template wording reuses the existing public-copy patterns.\n */\n\nexport interface BannedPattern {\n id: string;\n layer: 1 | 2 | 3;\n /** What the match is, completing \"shows …\". */\n label: string;\n regex: RegExp;\n}\n\nexport const BANNED_PATTERNS: readonly BannedPattern[] = [\n // Layer 1, everywhere.\n { id: \"reassurance-filler\", layer: 1, label: \"a reassurance or helper line\",\n regex: /\\bwe\\s+(?:only|will\\s+only)\\s+use\\s+your\\s+(?:details|information|info|data)\\s+to\\s+(?:reply|respond|contact|get\\s+back)|\\ball\\s+fields\\s+(?:are\\s+)?(?:needed|required)\\b|\\bphotos?\\s+(?:are|is)\\s+optional\\b|\\bwe(?:'ll|\\s+will)\\s+never\\s+(?:share|sell|spam|pass\\s+on)\\b/i },\n { id: \"site-explaining\", layer: 1, label: \"text that explains the website or the quoting process\",\n regex: /\\b(?:browse|explore|scroll\\s+through|have\\s+a\\s+look\\s+through|take\\s+a\\s+look\\s+through)\\b[^.!?\\n]{0,40}\\b(?:below|on\\s+(?:this|our)\\s+(?:page|site|website))\\b|\\b(?:use|fill\\s+(?:in|out)|complete|submit)\\s+(?:the|our|this)\\s+(?:(?:quick|short|simple|online|quote|contact|enquiry)\\s+)*form\\s+(?:below|above)\\b|\\bwelcome\\s+to\\s+(?:our|the)\\s+(?:new\\s+)?(?:website|site|web\\s?page)\\b|\\b(?:our|the)\\s+(?:simple\\s+|easy\\s+)?quot(?:e|ing)\\s+process\\b|^\\W*(?:so,?\\s+)?what\\s+happens\\s+next\\W*$/i },\n { id: \"price-source\", layer: 1, label: \"a price source or market-rate line\",\n regex: /\\bmarket\\s+rates?\\b|\\bgeneral\\s+market\\s+pric(?:e|es|ing)\\b|\\bmarket\\s+pricing\\b/i },\n\n // Layer 2, headers, heroes and location pages.\n { id: \"based-in\", layer: 2, label: \"a \\\"based in\\\" line\",\n regex: BASED_IN },\n { id: \"km-from-base\", layer: 2, label: \"a distance from the base\",\n regex: KM_FROM_BASE },\n { id: \"walk-ins\", layer: 2, label: \"a \\\"no walk-ins\\\" line\",\n regex: /\\bno\\s+(?:customer\\s+)?walk[- ]?ins?\\b/i },\n\n // Layer 3, the trade claims scan. Each needs evidence in the claims register.\n { id: \"licensed-claim\", layer: 3, label: \"a \\\"licensed\\\" claim without a licence number\",\n regex: /\\b(?:fully|properly|100\\s?%)\\s+licen[cs]ed\\b|\\blicen[cs]ed\\s+(?:and|&)\\s+(?:insured|certified|qualified)\\b|\\bqualified,?\\s+(?:and\\s+)?licen[cs]ed\\b|\\b(?:we(?:'re|\\s+are)|all\\s+(?:our|of\\s+our)\\s+\\w+\\s+(?:are|is)|our\\s+(?:team|tradies|\\w+)\\s+(?:is|are))\\s+licen[cs]ed\\b/i },\n { id: \"24-7\", layer: 3, label: \"a 24/7 or round-the-clock promise\",\n regex: /\\b24\\s*\\/\\s*7\\b|\\b24\\s*(?:hours?|hrs?)\\s*(?:a|per)\\s*day\\b|\\b24[- ]hours?\\s+(?:emergency|service|call[- ]?outs?|response|availability|support)\\b|\\bopen\\s+24\\s*hours\\b/i },\n { id: \"response-time\", layer: 3, label: \"a minute or hour response promise\",\n regex: /\\b(?:within|in)\\s+(?:the\\s+)?(?:\\d{1,3}|an|one|fifteen|twenty|thirty|sixty)\\s*(?:-\\s*)?(?:minutes?|mins?|hours?|hrs?)\\b(?=[^.]{0,40}\\b(?:response|respond|reply|arrive|arrival|on[- ]site|at\\s+your\\s+door|call\\s+back|there)\\b)|\\b(?:response|respond|reply|arrive|arrival|on[- ]site|at\\s+your\\s+door|call\\s+back|there)\\b[^.]{0,40}\\b(?:within|in)\\s+(?:the\\s+)?(?:\\d{1,3}|an|one|fifteen|twenty|thirty|sixty)\\s*(?:-\\s*)?(?:minutes?|mins?|hours?|hrs?)\\b|\\b\\d{1,3}[- ]?(?:minute|min|hour|hr)\\s+(?:response|arrival|call[- ]?out|guarantee)\\b|\\bwithin\\s+the\\s+hour\\b/i },\n { id: \"call-out-fee\", layer: 3, label: \"a free or $0 call-out claim\",\n regex: /\\$\\s?0\\s*call[- ]?out|\\b(?:free|no)\\s+call[- ]?out(?:\\s+(?:fee|charge))?s?\\b/i },\n { id: \"number-one\", layer: 3, label: \"a \\\"#1\\\" claim\",\n regex: /(?:^|[\\s(])#\\s?1\\b|\\bnumber\\s+(?:one|1)\\b|\\bno\\.?\\s?1\\s+(?:in|for|choice|rated)\\b/i },\n { id: \"best-claim\", layer: 3, label: \"a \\\"best\\\" claim\",\n regex: /\\b(?:the\\s+)?best\\s+(?:\\w+\\s+){0,4}(?:in|of|around|across)\\s+(?:sydney|nsw|new\\s+south\\s+wales|australia|the\\s+(?:north\\s+shore|northern\\s+beaches|hills|inner\\s+west|eastern\\s+suburbs|shire))\\b|\\bvoted\\s+(?:the\\s+)?best\\b/i },\n { id: \"cheapest\", layer: 3, label: \"a cheapest or lowest-price claim\",\n regex: /\\bcheapest\\b|\\blowest\\s+prices?\\b|\\bunbeatable\\s+(?:price|prices|rates?)\\b|\\bbest\\s+prices?\\b/i },\n { id: \"leading\", layer: 3, label: \"a \\\"leading\\\" or \\\"largest\\\" claim\",\n regex: /\\b[A-Z][A-Za-z]+(?:\\s[A-Z][A-Za-z]+)?['’]s\\s+(?:[Ll]eading|[Ll]argest|[Tt]op|[Pp]remier|[Ff]inest|[Bb]est|[Mm]ost\\s+[Tt]rusted)\\b|\\bleading\\s+(?:\\w+\\s+){0,3}(?:in|of)\\s+(?:sydney|nsw|australia)\\b/ },\n { id: \"lifetime\", layer: 3, label: \"a lifetime guarantee claim\",\n regex: /\\blifetime\\s+(?:warranty|guarantee|guaranteed|workmanship|protection|cover)\\b|\\bguaranteed\\s+for\\s+life\\b/i },\n { id: \"satisfaction-guarantee\", layer: 3, label: \"a 100% satisfaction claim\",\n regex: /\\b100\\s?%\\s+(?:satisfaction|guaranteed|guarantee)\\b|\\bsatisfaction\\s+guaranteed\\b/i },\n { id: \"beat-any-quote\", layer: 3, label: \"a \\\"beat any quote\\\" promise\",\n regex: /\\bbeat\\s+any\\s+(?:quote|price)\\b|\\bprice[- ]match(?:ing)?\\s+guarantee\\b/i },\n { id: \"master-plumber\", layer: 3, label: \"a \\\"Master Plumber\\\" claim without membership evidence\",\n regex: /\\bmaster\\s+plumbers?\\b/i },\n { id: \"dulux-accredited\", layer: 3, label: \"a \\\"Dulux accredited\\\" claim without the certificate\",\n regex: /\\bdulux\\s+accredited\\b/i },\n { id: \"colorbond-accredited\", layer: 3, label: \"an accredited Colorbond contractor claim without the certificate\",\n regex: /\\baccredited\\s+colorbond\\b/i },\n { id: \"as-3740\", layer: 3, label: \"an AS 3740 compliance claim without paperwork\",\n regex: /\\bAS\\s?\\/?\\s?(?:NZS\\s?)?3740\\b[^.]{0,40}\\bcompl|\\bcompl\\w*\\s+(?:with\\s+)?AS\\s?\\/?\\s?(?:NZS\\s?)?3740\\b/i },\n { id: \"award-winning\", layer: 3, label: \"an \\\"award-winning\\\" claim without evidence of a win\",\n regex: /\\baward[- ]winning\\b/i },\n { id: \"since-year\", layer: 3, label: \"a \\\"since {year}\\\" claim not matched to the ABN or licence record\",\n regex: /\\bsince\\s+(?:19|20)\\d{2}\\b/i },\n { id: \"years-experience\", layer: 3, label: \"a years-in-business claim not matched to the ABN or licence record\",\n regex: /\\b\\d{1,3}\\s?\\+\\s*years?\\b|\\b(?:over|more\\s+than|almost|nearly)\\s+\\d{1,3}\\s+years?\\b|\\b\\d{1,3}\\s+years?(?:['’]s?)?\\s+(?:of\\s+)?(?:combined\\s+)?(?:experience|in\\s+(?:business|the\\s+industry|the\\s+trade)|trading)\\b/i },\n { id: \"council-approved\", layer: 3, label: \"a \\\"council-approved\\\" claim\",\n regex: /\\bcouncil[- ]approved\\b/i },\n { id: \"interior-designer\", layer: 3, label: \"an \\\"interior designer\\\" claim without evidence\",\n regex: /\\b(?:our|we(?:'re|\\s+are)|qualified|experienced|professional|expert|team\\s+of)\\s+(?:\\w+\\s+){0,2}interior\\s+designers?\\b/i },\n { id: \"landscape-architect\", layer: 3, label: \"a \\\"landscape architect\\\" claim without registration\",\n regex: /\\b(?:our|we(?:'re|\\s+are)|qualified|experienced|registered|team\\s+of)\\s+(?:\\w+\\s+){0,2}landscape\\s+architects?\\b/i },\n { id: \"plus-gst\", layer: 3, label: \"a price shown \\\"+ GST\\\" to homeowners\",\n regex: /\\+\\s*GST\\b|\\bplus\\s+GST\\b|\\b(?:ex|excl\\.?|excluding)\\s+GST\\b/i },\n { id: \"environmental-claim\", layer: 3, label: \"an environmental claim without specific evidence\",\n regex: /\\beco[- ]?friendly\\b|\\benvironmentally[- ]friendly\\b|\\bsustainab(?:le|ility)\\b|\\bgo(?:ing)?\\s+green\\b|\\bgreen\\s+(?:energy|building|solutions?|choices?|alternatives?|products?|credentials|practices)\\b|\\bcarbon[- ](?:neutral|positive|friendly)\\b|\\bnet[- ]zero\\b|\\bzero[- ]emissions?\\b|\\b(?:save|saving|reduce|cut|lower)\\s+(?:up\\s+to\\s+|by\\s+)?\\d{1,3}\\s?%\\s*(?:on\\s+|off\\s+)?(?:your\\s+)?(?:energy|power|electricity|heating|cooling|gas|water)\\b/i },\n];\n\n/** American spellings are advised on, not failed (spec 6.3, layer 1 \"warning\"). */\nconst AMERICAN_WORDS = [\n \"color\", \"colors\", \"colored\", \"neighbor\", \"neighbors\", \"neighborhood\", \"neighborhoods\", \"favorite\", \"favorites\",\n \"center\", \"centers\", \"organize\", \"organizes\", \"organized\", \"organization\", \"realize\", \"realizes\", \"realized\",\n \"recognize\", \"recognizes\", \"recognized\", \"specialize\", \"specializes\", \"specialized\", \"customize\", \"customizes\",\n \"customized\", \"minimize\", \"minimizes\", \"minimized\", \"prioritize\", \"prioritizes\", \"prioritized\", \"summarize\",\n \"summarizes\", \"summarized\", \"sanitize\", \"sanitizes\", \"sanitized\", \"authorize\", \"authorizes\", \"authorized\",\n \"aluminum\", \"curb\", \"curbs\", \"gray\", \"catalog\", \"catalogs\", \"tire\", \"tires\", \"defense\",\n];\nconst AMERICAN_SPELLING = new RegExp(`\\\\b(?:${AMERICAN_WORDS.join(\"|\")})\\\\b`, \"gi\");\n\nconst COMPILED = BANNED_PATTERNS.map((pattern) => ({\n pattern,\n global: new RegExp(pattern.regex.source, pattern.regex.flags.includes(\"g\") ? pattern.regex.flags : `${pattern.regex.flags}g`),\n}));\n\ninterface Source {\n text: string;\n unit: TextUnit | null;\n /** Where the text renders: page text, an attribute, metadata or schema. */\n where: \"text\" | \"attribute\" | \"metadata\" | \"schema\";\n}\n\nfunction pageSources(page: ShellPage): Source[] {\n return [\n ...page.units.map((unit): Source => ({ text: unit.text, unit, where: \"text\" })),\n ...page.attributeCopy.map((unit): Source => ({ text: unit.text, unit, where: \"attribute\" })),\n ...page.metadata.map((text): Source => ({ text, unit: null, where: \"metadata\" })),\n ...page.schemaCopy.map((text): Source => ({ text, unit: null, where: \"schema\" })),\n ];\n}\n\nfunction isReviewVoice(element: HtmlElement): boolean {\n return closest(element, (candidate) =>\n candidate.attrs[\"data-bl-section\"] === \"reviews\" || candidate.attrs[\"data-bl-source\"] === \"feed\" || candidate.tag === \"blockquote\" || candidate.attrs.itemprop === \"reviewBody\") !== null;\n}\n\nfunction isMenu(element: HtmlElement): boolean {\n return closest(element, (candidate) => candidate.tag === \"nav\" || candidate.attrs[\"data-slot\"] === \"panel\") !== null;\n}\n\nfunction layerTwoRegions(page: ShellPage): { include: HtmlElement[]; exclude: HtmlElement[] } | null {\n if (page.family === \"contact\") return null;\n const exclude = [...page.footers];\n if (page.family === \"location\") return { include: [page.body], exclude };\n return { include: [...page.headers, ...(page.hero ? [page.hero] : [])], exclude };\n}\n\nfunction recordedMatch(recorded: readonly string[], pattern: BannedPattern, matched: string): boolean {\n const phrase = matched.toLowerCase();\n return recorded.some((entry) => {\n const claim = entry.trim().toLowerCase();\n return claim === pattern.id || (claim.length > 2 && (phrase.includes(claim) || claim.includes(phrase)));\n });\n}\n\nconst LICENCE_NUMBER = /\\blic(?:en[cs]e)?\\.?\\s*(?:no\\.?|number|#)?\\s*:?\\s*[A-Z]{0,3}\\s?\\d{4,}[A-Z]?\\b/i;\n\nfunction showsLicenceNumber(page: ShellPage, facts: ShellProjectFacts | null): boolean {\n const text = page.units.map((unit) => unit.text).join(\" \");\n if (LICENCE_NUMBER.test(text)) return true;\n const compact = text.toLowerCase().replace(/[\\s\\-.]/g, \"\");\n return (facts?.licences ?? []).some((licence) => compact.includes(licence.identifier.toLowerCase().replace(/[\\s\\-.]/g, \"\")));\n}\n\ninterface Hit {\n layer: 1 | 2 | 3;\n label: string;\n quote: string;\n}\n\n/** A customer's own words, or a block rendered from the feed, are not the business's copy. */\nfunction isExempt(source: Source, index: number): boolean {\n return source.unit !== null && isReviewVoice(elementAt(source.unit, index));\n}\n\n/** E8 over the sampled pages. */\nexport function checkBannedWording(\n pages: readonly ShellPage[],\n facts: ShellProjectFacts | null,\n recordedClaims: readonly string[] = [],\n): ShellFinding[] {\n const findings: ShellFinding[] = [];\n let claimWarnings = 0;\n for (const page of pages) {\n const hits = new Map<string, Hit>();\n const sources = pageSources(page);\n const licenceShown = showsLicenceNumber(page, facts);\n const layerTwo = layerTwoRegions(page);\n\n for (const { pattern, global } of COMPILED) {\n for (const source of sources) {\n if (pattern.layer === 2 && (source.where === \"metadata\" || source.where === \"schema\")) continue;\n let hit: Hit | undefined;\n for (const match of source.text.matchAll(global)) {\n if (isExempt(source, match.index)) continue;\n if (pattern.layer === 2) {\n const element = source.unit ? elementAt(source.unit, match.index) : null;\n if (!element || !layerTwo || inAny(element, layerTwo.exclude) || !inAny(element, layerTwo.include)) continue;\n }\n if (pattern.layer === 3) {\n if (recordedMatch(recordedClaims, pattern, match[0])) continue;\n if (pattern.id === \"licensed-claim\" && licenceShown) continue;\n }\n hit = { layer: pattern.layer, label: pattern.label, quote: excerpt(source.text, match.index, match[0].length) };\n break;\n }\n if (hit) {\n hits.set(pattern.id, hit);\n break;\n }\n }\n }\n\n // Layer 1: price teasers in menus and template wording.\n for (const unit of page.units) {\n for (const match of unit.text.matchAll(/\\bfrom\\s+\\$\\s?\\d/gi)) {\n const element = elementAt(unit, match.index);\n if (!isMenu(element) || isReviewVoice(element)) continue;\n hits.set(\"menu-price-teaser\", { layer: 1, label: \"a \\\"from $X\\\" price teaser in a menu\", quote: excerpt(unit.text, match.index, match[0].length) });\n break;\n }\n if (hits.has(\"menu-price-teaser\")) break;\n }\n // Every match counts: an exempt one (a customer's own words) must not hide template wording later in the source.\n for (const source of sources) {\n for (const found of implementationWordingMatches(source.text, { singleWord: true })) {\n if (isExempt(source, found.index)) continue;\n hits.set(\"template-wording\", { layer: 1, label: \"template or placeholder wording\", quote: excerpt(source.text, found.index, 10) });\n break;\n }\n if (hits.has(\"template-wording\")) break;\n }\n\n for (const hit of hits.values()) {\n if (hit.layer === 3) claimWarnings += 1;\n findings.push(hit.layer === 3\n ? warningFinding(\"E8\", `Warning: the page ${page.path} shows ${hit.label} (\"${hit.quote}\"); it needs evidence in the claims register or removing.`, page.path)\n : failFinding(\"E8\", page.path, `The page ${page.path} shows ${hit.label} (\"${hit.quote}\"), which the banned list does not allow.`));\n }\n\n const american = new Set<string>();\n for (const source of sources) {\n for (const match of source.text.matchAll(AMERICAN_SPELLING)) {\n if (!isExempt(source, match.index)) american.add(match[0].toLowerCase());\n }\n }\n if (american.size > 0) {\n findings.push(warningFinding(\"E8\", `Warning: the page ${page.path} uses American spelling (${[...american].slice(0, 4).join(\", \")}); client copy is Australian English.`, page.path));\n }\n }\n const failed = findings.some((finding) => finding.status === \"fail\");\n if (failed) return findings;\n const clear = claimWarnings === 0 ? \"from any of the three layers\" : \"from layers 1 and 2 (the claims below need evidence)\";\n return [passFinding(\"E8\", `No banned wording ${clear} on ${sampledPages(pages.length)}.`), ...findings];\n}\n\n/** Used by tests to confirm which patterns exist for each layer. */\nexport function bannedPatternIds(layer: 1 | 2 | 3): string[] {\n return BANNED_PATTERNS.filter((pattern) => pattern.layer === layer).map((pattern) => pattern.id);\n}\n"]}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ShellPage } from "./page.js";
|
|
2
|
+
import { type ShellAssetReader, type ShellFinding } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* B1: lab budgets read from the built page HTML (spec 5.8): JavaScript, third-party requests, the hero image and the
|
|
5
|
+
* HTML itself, with no chat widget, pixel, heatmap or tag manager unless the project records the decision. Script and
|
|
6
|
+
* image sizes come from the project's build output, then the base URL; a size that can't be read is left out of the
|
|
7
|
+
* total and said so, never guessed.
|
|
8
|
+
*/
|
|
9
|
+
/** First-party JavaScript per page, gzip: a warning from here. Starting value, calibrated in the R1 canaries. */
|
|
10
|
+
export declare const B1_FIRST_PARTY_JS_GZIP_WARN_BYTES: number;
|
|
11
|
+
/** First-party JavaScript per page, gzip: the spec's starting budget; over it blocks. */
|
|
12
|
+
export declare const B1_FIRST_PARTY_JS_GZIP_FAIL_BYTES: number;
|
|
13
|
+
export declare const B1_HTML_MAX_BYTES: number;
|
|
14
|
+
export declare const B1_HERO_IMAGE_MAX_BYTES: number;
|
|
15
|
+
/** The viewport width the hero image is judged at. */
|
|
16
|
+
export declare const B1_HERO_VIEWPORT_PX = 1440;
|
|
17
|
+
type ThirdPartyKind = "chat" | "pixel" | "heatmap" | "tag-manager";
|
|
18
|
+
interface Verdict {
|
|
19
|
+
kind: ThirdPartyKind | "other";
|
|
20
|
+
label: string;
|
|
21
|
+
}
|
|
22
|
+
/** What a third-party URL is, or null when the shared setup allows it. */
|
|
23
|
+
export declare function judgeThirdParty(url: URL): Verdict | null;
|
|
24
|
+
/**
|
|
25
|
+
* The origin standing in for the site when there is no base URL (build-output mode): relative asset URLs resolve to it,
|
|
26
|
+
* so they count as first-party and are read from the project's build output alone.
|
|
27
|
+
*/
|
|
28
|
+
export declare const BUILD_OUTPUT_ORIGIN = "https://build-output.invalid";
|
|
29
|
+
/**
|
|
30
|
+
* The default reader: the project's build output for its own paths (`.next/static`, `public`, `out`), then the base
|
|
31
|
+
* URL when there is one. Never reads other hosts.
|
|
32
|
+
*/
|
|
33
|
+
export declare const readBuiltAsset: ShellAssetReader;
|
|
34
|
+
export interface BudgetOptions {
|
|
35
|
+
baseUrl: string;
|
|
36
|
+
projectRoot: string;
|
|
37
|
+
recordedThirdParties?: readonly string[];
|
|
38
|
+
readAsset?: ShellAssetReader;
|
|
39
|
+
}
|
|
40
|
+
/** B1 over the sampled pages. */
|
|
41
|
+
export declare function checkLabBudgets(pages: readonly ShellPage[], options: BudgetOptions): Promise<ShellFinding[]>;
|
|
42
|
+
export {};
|
|
43
|
+
//# sourceMappingURL=budgets.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"budgets.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/shell/budgets.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EACqC,KAAK,gBAAgB,EAAE,KAAK,YAAY,EACnF,MAAM,YAAY,CAAC;AAEpB;;;;;GAKG;AAEH,iHAAiH;AACjH,eAAO,MAAM,iCAAiC,QAAa,CAAC;AAC5D,yFAAyF;AACzF,eAAO,MAAM,iCAAiC,QAAa,CAAC;AAC5D,eAAO,MAAM,iBAAiB,QAAa,CAAC;AAC5C,eAAO,MAAM,uBAAuB,QAAa,CAAC;AAClD,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,OAAO,CAAC;AAExC,KAAK,cAAc,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,aAAa,CAAC;AA6CnE,UAAU,OAAO;IACf,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC;IAC/B,KAAK,EAAE,MAAM,CAAC;CACf;AAED,0EAA0E;AAC1E,wBAAgB,eAAe,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,GAAG,IAAI,CAaxD;AAeD;;;GAGG;AACH,eAAO,MAAM,mBAAmB,iCAAiC,CAAC;AAUlE;;;GAGG;AACH,eAAO,MAAM,cAAc,EAAE,gBA+B5B,CAAC;AA4EF,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,oBAAoB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC,SAAS,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AAED,iCAAiC;AACjC,wBAAsB,eAAe,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAqFlH"}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve, sep } from "node:path";
|
|
3
|
+
import { gzipSync } from "node:zlib";
|
|
4
|
+
import { descendants, findAll, isWithin } from "./html.js";
|
|
5
|
+
import { failFinding, passFinding, warningFinding, } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* B1: lab budgets read from the built page HTML (spec 5.8): JavaScript, third-party requests, the hero image and the
|
|
8
|
+
* HTML itself, with no chat widget, pixel, heatmap or tag manager unless the project records the decision. Script and
|
|
9
|
+
* image sizes come from the project's build output, then the base URL; a size that can't be read is left out of the
|
|
10
|
+
* total and said so, never guessed.
|
|
11
|
+
*/
|
|
12
|
+
/** First-party JavaScript per page, gzip: a warning from here. Starting value, calibrated in the R1 canaries. */
|
|
13
|
+
export const B1_FIRST_PARTY_JS_GZIP_WARN_BYTES = 180 * 1024;
|
|
14
|
+
/** First-party JavaScript per page, gzip: the spec's starting budget; over it blocks. */
|
|
15
|
+
export const B1_FIRST_PARTY_JS_GZIP_FAIL_BYTES = 250 * 1024;
|
|
16
|
+
export const B1_HTML_MAX_BYTES = 500 * 1024;
|
|
17
|
+
export const B1_HERO_IMAGE_MAX_BYTES = 200 * 1024;
|
|
18
|
+
/** The viewport width the hero image is judged at. */
|
|
19
|
+
export const B1_HERO_VIEWPORT_PX = 1440;
|
|
20
|
+
const CATEGORIES = [
|
|
21
|
+
{ kind: "chat", label: "a chat widget", hosts: [
|
|
22
|
+
"widget.intercom.io", "js.intercomcdn.com", "intercom.io", "tawk.to", "crisp.chat", "livechatinc.com", "tidio.co",
|
|
23
|
+
"driftt.com", "drift.com", "usemessages.com", "zdassets.com", "freshchat.com", "olark.com", "smartsuppchat.com",
|
|
24
|
+
"chatra.io", "jivosite.com", "manychat.com", "chatwoot.com",
|
|
25
|
+
] },
|
|
26
|
+
{ kind: "pixel", label: "an advertising pixel", hosts: [
|
|
27
|
+
"connect.facebook.net", "snap.licdn.com", "px.ads.linkedin.com", "static.ads-twitter.com", "analytics.twitter.com",
|
|
28
|
+
"analytics.tiktok.com", "s.pinimg.com", "bat.bing.com", "sc-static.net", "tr.snapchat.com", "adroll.com",
|
|
29
|
+
"criteo.net", "outbrain.com", "taboola.com",
|
|
30
|
+
] },
|
|
31
|
+
{ kind: "heatmap", label: "a heatmap or session recorder", hosts: [
|
|
32
|
+
"hotjar.com", "hotjar.io", "clarity.ms", "mouseflow.com", "crazyegg.com", "fullstory.com", "logrocket.com",
|
|
33
|
+
"lr-ingest.io", "luckyorange.com", "luckyorange.net", "smartlook.com", "inspectlet.com", "heapanalytics.com",
|
|
34
|
+
] },
|
|
35
|
+
{ kind: "tag-manager", label: "a tag manager", hosts: [
|
|
36
|
+
"tags.tiqcdn.com", "tiqcdn.com", "cdn.segment.com", "assets.adobedtm.com", "cdn.tealium.com",
|
|
37
|
+
] },
|
|
38
|
+
];
|
|
39
|
+
/** Hosts the shared analytics, Ads, maps and form-protection setup uses (see the Content-Security-Policy in site-next). */
|
|
40
|
+
const ALLOWED_HOSTS = [
|
|
41
|
+
"google-analytics.com", "analytics.google.com", "googleadservices.com", "doubleclick.net", "googlesyndication.com",
|
|
42
|
+
"maps.googleapis.com", "maps.gstatic.com", "gstatic.com", "challenges.cloudflare.com",
|
|
43
|
+
];
|
|
44
|
+
const INLINE_SIGNATURES = [
|
|
45
|
+
{ kind: "pixel", label: "a Meta pixel", regex: /\bfbq\s*\(/ },
|
|
46
|
+
{ kind: "tag-manager", label: "a tag manager", regex: /gtm\.start|googletagmanager\.com\/gtm\.js/ },
|
|
47
|
+
{ kind: "heatmap", label: "a heatmap or session recorder", regex: /\bhj\s*\(\s*['"]|static\.hotjar\.com|clarity\.ms|\bclarity\s*\(/ },
|
|
48
|
+
{ kind: "chat", label: "a chat widget", regex: /\bTawk_API\b|\$crisp\b|window\.Intercom|\bIntercom\s*\(|drift\.load|LiveChatWidget|tidioChatApi|\bzE\s*\(|\bfcWidget\b/ },
|
|
49
|
+
];
|
|
50
|
+
function hostMatches(host, domain) {
|
|
51
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
52
|
+
}
|
|
53
|
+
/** What a third-party URL is, or null when the shared setup allows it. */
|
|
54
|
+
export function judgeThirdParty(url) {
|
|
55
|
+
const host = url.hostname.toLowerCase();
|
|
56
|
+
if (hostMatches(host, "googletagmanager.com")) {
|
|
57
|
+
return /^\/gtag\/js/.test(url.pathname) ? null : { kind: "tag-manager", label: "a tag manager" };
|
|
58
|
+
}
|
|
59
|
+
if (host === "www.google.com" || host === "google.com") {
|
|
60
|
+
return /^\/(?:maps|recaptcha)\//.test(url.pathname) ? null : { kind: "other", label: "a third-party request" };
|
|
61
|
+
}
|
|
62
|
+
for (const category of CATEGORIES) {
|
|
63
|
+
if (category.hosts.some((domain) => hostMatches(host, domain)))
|
|
64
|
+
return { kind: category.kind, label: category.label };
|
|
65
|
+
}
|
|
66
|
+
if (ALLOWED_HOSTS.some((domain) => hostMatches(host, domain)))
|
|
67
|
+
return null;
|
|
68
|
+
return { kind: "other", label: "a third-party request" };
|
|
69
|
+
}
|
|
70
|
+
function isRecorded(recorded, url, verdict) {
|
|
71
|
+
const host = url.hostname.toLowerCase();
|
|
72
|
+
return recorded.some((entry) => {
|
|
73
|
+
const value = entry.trim().toLowerCase();
|
|
74
|
+
return value !== "" && (value === verdict.kind || hostMatches(host, value));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function sameSite(host, baseHost) {
|
|
78
|
+
const strip = (value) => value.replace(/^www\./, "");
|
|
79
|
+
return strip(host.toLowerCase()) === strip(baseHost.toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The origin standing in for the site when there is no base URL (build-output mode): relative asset URLs resolve to it,
|
|
83
|
+
* so they count as first-party and are read from the project's build output alone.
|
|
84
|
+
*/
|
|
85
|
+
export const BUILD_OUTPUT_ORIGIN = "https://build-output.invalid";
|
|
86
|
+
function parseBase(baseUrl) {
|
|
87
|
+
try {
|
|
88
|
+
return new URL(baseUrl);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The default reader: the project's build output for its own paths (`.next/static`, `public`, `out`), then the base
|
|
96
|
+
* URL when there is one. Never reads other hosts.
|
|
97
|
+
*/
|
|
98
|
+
export const readBuiltAsset = async (url, { projectRoot, baseUrl }) => {
|
|
99
|
+
const given = parseBase(baseUrl);
|
|
100
|
+
const base = given ?? new URL(BUILD_OUTPUT_ORIGIN);
|
|
101
|
+
if (!sameSite(url.hostname, base.hostname))
|
|
102
|
+
return undefined;
|
|
103
|
+
let pathname = url.pathname;
|
|
104
|
+
try {
|
|
105
|
+
pathname = decodeURIComponent(pathname);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Keep the encoded path.
|
|
109
|
+
}
|
|
110
|
+
const root = resolve(projectRoot);
|
|
111
|
+
const candidates = pathname.startsWith("/_next/static/")
|
|
112
|
+
? [join(root, ".next", pathname.slice("/_next/".length))]
|
|
113
|
+
: pathname.startsWith("/_next/") ? [] : [join(root, "public", pathname), join(root, "out", pathname)];
|
|
114
|
+
for (const candidate of candidates) {
|
|
115
|
+
const resolved = resolve(candidate);
|
|
116
|
+
if (resolved.startsWith(root + sep) && existsSync(resolved)) {
|
|
117
|
+
try {
|
|
118
|
+
return new Uint8Array(readFileSync(resolved));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Fall through to the network.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!given || !/^https?:$/.test(url.protocol))
|
|
126
|
+
return undefined;
|
|
127
|
+
try {
|
|
128
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
|
129
|
+
return response.ok ? new Uint8Array(await response.arrayBuffer()) : undefined;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
function resolveUrl(value, page) {
|
|
136
|
+
if (!value || /^\s*(?:data|blob|about|javascript|mailto|tel):/i.test(value))
|
|
137
|
+
return null;
|
|
138
|
+
try {
|
|
139
|
+
return new URL(value.trim(), page);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** The scripts, frames and stylesheets a page requests. */
|
|
146
|
+
function pageResources(page, pageUrl) {
|
|
147
|
+
const resources = [];
|
|
148
|
+
for (const element of descendants(page.doc.root)) {
|
|
149
|
+
const { tag, attrs } = element;
|
|
150
|
+
if (tag === "script" && attrs.src) {
|
|
151
|
+
if (attrs.nomodule !== undefined)
|
|
152
|
+
continue;
|
|
153
|
+
const url = resolveUrl(attrs.src, pageUrl);
|
|
154
|
+
if (url)
|
|
155
|
+
resources.push({ url, kind: "script" });
|
|
156
|
+
}
|
|
157
|
+
else if (tag === "link") {
|
|
158
|
+
const rel = (attrs.rel ?? "").toLowerCase().split(/\s+/);
|
|
159
|
+
const wantsScript = rel.includes("modulepreload") || (rel.includes("preload") && attrs.as === "script");
|
|
160
|
+
const wantsOther = rel.includes("stylesheet") || (rel.includes("preload") && attrs.as !== "script" && attrs.as !== undefined && attrs.as !== "image");
|
|
161
|
+
if (wantsScript || wantsOther) {
|
|
162
|
+
const url = resolveUrl(attrs.href, pageUrl);
|
|
163
|
+
if (url)
|
|
164
|
+
resources.push({ url, kind: wantsScript ? "script" : "other" });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else if (tag === "iframe" || tag === "embed" || tag === "object") {
|
|
168
|
+
const url = resolveUrl(attrs.src ?? attrs.data, pageUrl);
|
|
169
|
+
if (url)
|
|
170
|
+
resources.push({ url, kind: "other" });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return resources;
|
|
174
|
+
}
|
|
175
|
+
function inlineScripts(page) {
|
|
176
|
+
return findAll(page.doc.root, (element) => element.tag === "script" && !element.attrs.src && !/(?:json|importmap|speculationrules)/i.test(element.attrs.type ?? ""))
|
|
177
|
+
.map((script) => script.children.map((child) => (child.kind === "text" ? child.text : "")).join(""))
|
|
178
|
+
// The framework's streamed page data holds the page's words, not code that runs a widget.
|
|
179
|
+
.filter((text) => !text.includes("__next_f"));
|
|
180
|
+
}
|
|
181
|
+
function srcsetCandidates(srcset) {
|
|
182
|
+
return srcset.split(",").map((part) => part.trim().split(/\s+/)).filter((parts) => parts[0])
|
|
183
|
+
.map(([url, descriptor]) => ({ url: url, width: descriptor?.endsWith("w") ? Number.parseInt(descriptor, 10) : descriptor?.endsWith("x") ? Number.parseFloat(descriptor) * 1000 : 0 }));
|
|
184
|
+
}
|
|
185
|
+
/** The image the hero shows on a wide screen: the smallest candidate at least 1440 px wide, else the widest. */
|
|
186
|
+
function heroImage(page) {
|
|
187
|
+
const hero = page.hero;
|
|
188
|
+
if (!hero)
|
|
189
|
+
return null;
|
|
190
|
+
const images = findAll(hero, (element) => element.tag === "img" && !page.headers.some((header) => isWithin(element, header)) && element.attrs["data-bl"] !== "logo");
|
|
191
|
+
if (images.length === 0)
|
|
192
|
+
return null;
|
|
193
|
+
const width = (image) => Number.parseInt(image.attrs.width ?? "0", 10) || 0;
|
|
194
|
+
const element = images.find((image) => image.attrs.fetchpriority === "high") ?? images.reduce((best, image) => (width(image) > width(best) ? image : best), images[0]);
|
|
195
|
+
const candidates = srcsetCandidates(element.attrs.srcset ?? "");
|
|
196
|
+
const wide = candidates.filter((candidate) => candidate.width >= B1_HERO_VIEWPORT_PX).sort((left, right) => left.width - right.width)[0]
|
|
197
|
+
?? candidates.sort((left, right) => right.width - left.width)[0];
|
|
198
|
+
return { element, source: wide?.url ?? element.attrs.src };
|
|
199
|
+
}
|
|
200
|
+
function gzipBytes(bytes) {
|
|
201
|
+
return gzipSync(Buffer.from(bytes)).length;
|
|
202
|
+
}
|
|
203
|
+
function kilobytes(bytes) {
|
|
204
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
205
|
+
}
|
|
206
|
+
/** B1 over the sampled pages. */
|
|
207
|
+
export async function checkLabBudgets(pages, options) {
|
|
208
|
+
const read = options.readAsset ?? readBuiltAsset;
|
|
209
|
+
const recorded = options.recordedThirdParties ?? [];
|
|
210
|
+
const cache = new Map();
|
|
211
|
+
const readCached = (url) => {
|
|
212
|
+
const key = url.href;
|
|
213
|
+
if (!cache.has(key))
|
|
214
|
+
cache.set(key, read(url, { projectRoot: options.projectRoot, baseUrl: options.baseUrl }).catch(() => undefined));
|
|
215
|
+
return cache.get(key);
|
|
216
|
+
};
|
|
217
|
+
const base = parseBase(options.baseUrl) ?? new URL(BUILD_OUTPUT_ORIGIN);
|
|
218
|
+
const failures = [];
|
|
219
|
+
const warnings = [];
|
|
220
|
+
const hostsSeen = new Set();
|
|
221
|
+
let largestJs = 0;
|
|
222
|
+
let jsPages = 0;
|
|
223
|
+
for (const page of pages) {
|
|
224
|
+
const pageUrl = new URL(page.path, base);
|
|
225
|
+
const htmlBytes = Buffer.byteLength(page.html);
|
|
226
|
+
if (htmlBytes > B1_HTML_MAX_BYTES) {
|
|
227
|
+
failures.push(failFinding("B1", page.path, `The page ${page.path} is ${kilobytes(htmlBytes)} of HTML, over the ${kilobytes(B1_HTML_MAX_BYTES)} budget.`));
|
|
228
|
+
}
|
|
229
|
+
const resources = pageResources(page, pageUrl);
|
|
230
|
+
const flagged = new Set();
|
|
231
|
+
for (const resource of resources) {
|
|
232
|
+
if (sameSite(resource.url.hostname, base.hostname))
|
|
233
|
+
continue;
|
|
234
|
+
hostsSeen.add(resource.url.hostname);
|
|
235
|
+
const verdict = judgeThirdParty(resource.url);
|
|
236
|
+
if (!verdict || isRecorded(recorded, resource.url, verdict))
|
|
237
|
+
continue;
|
|
238
|
+
const key = `${resource.url.hostname}\u0000${verdict.kind}`;
|
|
239
|
+
if (flagged.has(key))
|
|
240
|
+
continue;
|
|
241
|
+
flagged.add(key);
|
|
242
|
+
failures.push(failFinding("B1", page.path, verdict.kind === "other"
|
|
243
|
+
? `The page ${page.path} requests ${resource.url.hostname}, a third party outside analytics, maps and the reviews feed, with no recorded decision.`
|
|
244
|
+
: `The page ${page.path} loads ${verdict.label} from ${resource.url.hostname} with no recorded decision.`));
|
|
245
|
+
}
|
|
246
|
+
for (const script of inlineScripts(page)) {
|
|
247
|
+
for (const signature of INLINE_SIGNATURES) {
|
|
248
|
+
const key = `inline\u0000${signature.kind}`;
|
|
249
|
+
if (flagged.has(key) || !signature.regex.test(script))
|
|
250
|
+
continue;
|
|
251
|
+
if (recorded.some((entry) => entry.trim().toLowerCase() === signature.kind))
|
|
252
|
+
continue;
|
|
253
|
+
flagged.add(key);
|
|
254
|
+
failures.push(failFinding("B1", page.path, `The page ${page.path} runs ${signature.label} from an inline script with no recorded decision.`));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const scripts = [...new Map(resources.filter((resource) => resource.kind === "script" && sameSite(resource.url.hostname, base.hostname)).map((resource) => [resource.url.href, resource.url])).values()];
|
|
258
|
+
if (scripts.length > 0) {
|
|
259
|
+
const sizes = await Promise.all(scripts.map(readCached));
|
|
260
|
+
const measured = sizes.filter((bytes) => bytes !== undefined);
|
|
261
|
+
const total = measured.reduce((sum, bytes) => sum + gzipBytes(bytes), 0);
|
|
262
|
+
if (measured.length > 0) {
|
|
263
|
+
jsPages += 1;
|
|
264
|
+
largestJs = Math.max(largestJs, total);
|
|
265
|
+
const unread = scripts.length - measured.length;
|
|
266
|
+
const note = unread > 0 ? ` (${unread} of ${scripts.length} script file${scripts.length === 1 ? "" : "s"} could not be read)` : "";
|
|
267
|
+
if (total > B1_FIRST_PARTY_JS_GZIP_FAIL_BYTES) {
|
|
268
|
+
failures.push(failFinding("B1", page.path, `The page ${page.path} loads ${kilobytes(total)} of first-party JavaScript (gzip)${note}, over the ${kilobytes(B1_FIRST_PARTY_JS_GZIP_FAIL_BYTES)} budget.`));
|
|
269
|
+
}
|
|
270
|
+
else if (total > B1_FIRST_PARTY_JS_GZIP_WARN_BYTES) {
|
|
271
|
+
warnings.push(warningFinding("B1", `Warning: the page ${page.path} loads ${kilobytes(total)} of first-party JavaScript (gzip)${note}, over the ${kilobytes(B1_FIRST_PARTY_JS_GZIP_WARN_BYTES)} warning threshold.`, page.path));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const hero = heroImage(page);
|
|
276
|
+
if (hero) {
|
|
277
|
+
const { element, source } = hero;
|
|
278
|
+
if (element.attrs.loading === "lazy" || element.attrs.fetchpriority === "low" || /\blazy(?:load|loaded)?\b/.test(element.attrs.class ?? "")) {
|
|
279
|
+
failures.push(failFinding("B1", page.path, `The hero image on ${page.path} is set to load lazily; the first-screen image must load straight away.`));
|
|
280
|
+
}
|
|
281
|
+
const url = resolveUrl(source, pageUrl);
|
|
282
|
+
if (url && sameSite(url.hostname, base.hostname)) {
|
|
283
|
+
const bytes = await readCached(url);
|
|
284
|
+
if (bytes && bytes.length > B1_HERO_IMAGE_MAX_BYTES) {
|
|
285
|
+
failures.push(failFinding("B1", page.path, `The hero image on ${page.path} is ${kilobytes(bytes.length)}, over the ${kilobytes(B1_HERO_IMAGE_MAX_BYTES)} budget at ${B1_HERO_VIEWPORT_PX} px.`));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (failures.length > 0)
|
|
291
|
+
return [...failures, ...warnings];
|
|
292
|
+
const jsNote = jsPages > 0 ? ` The heaviest page loads ${kilobytes(largestJs)} of first-party JavaScript (gzip).` : " Script sizes could not be read from the build output or the base URL, so the JavaScript budget was not measured.";
|
|
293
|
+
const hostNote = hostsSeen.size > 0 ? ` Third-party hosts seen: ${[...hostsSeen].sort().join(", ")}.` : " No third-party requests.";
|
|
294
|
+
const within = pages.length === 1 ? "The sampled page is" : `The ${pages.length} sampled pages are`;
|
|
295
|
+
return [passFinding("B1", `${within} within the HTML, hero image and third-party budgets.${jsNote}${hostNote}`), ...warnings];
|
|
296
|
+
}
|
|
297
|
+
//# sourceMappingURL=budgets.js.map
|