open-local-audit 0.78.0 → 0.80.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/dist/rules.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { load } from "cheerio";
2
2
  import { findPhoneNumbersInText, getCountries, parsePhoneNumberFromString } from "libphonenumber-js/max";
3
3
  import { detectPageForms, hasContactOrBookingLink } from "./contact.js";
4
+ import { cookieNames } from "./dotnet-stack.js";
4
5
  import { applyTypeContext, contextBase, expandNodeId, isLocalBusinessType, noTypeContext } from "./local-business-types.js";
5
6
  function finding(rule, context) {
6
7
  return {
@@ -146,17 +147,19 @@ function nodeIdKey(node, base) {
146
147
  const scope = nodeScopes.get(node);
147
148
  return scope && typeof node["@id"] === "string" ? nodeKey(node["@id"], scope.context, scope.block, base) : undefined;
148
149
  }
149
- // The key of a node reference such as {"@id": "#hours"} found in one of the owner node's properties.
150
- // As in JSON-LD expansion, an object holding only @id keeps the owner's context even when that context
151
- // does not propagate; any other nested object returns to the previous context.
152
- function referenceKey(reference, owner, base) {
150
+ // The key of a node reference such as {"@id": "#hours"} found in the owner node's property. As in JSON-LD
151
+ // expansion, an object holding only @id keeps the owner's context even when that context does not
152
+ // propagate, while any other nested object returns to the previous context; the property term's own
153
+ // scoped @context then applies, and finally the reference's own @context.
154
+ function referenceKey(reference, owner, property, base) {
153
155
  const scope = nodeScopes.get(owner);
154
156
  if (!scope || !hasObjectField(reference)) {
155
157
  return undefined;
156
158
  }
157
159
  const fields = reference;
158
160
  const idOnly = Object.keys(fields).length === 1;
159
- const context = idOnly ? scope.context : applyTypeContext(scope.nestedContext, fields["@context"]);
161
+ const propertyContext = applyTypeContext(idOnly ? scope.context : scope.nestedContext, scope.context.terms.get(property)?.scopedContext);
162
+ const context = idOnly ? propertyContext : applyTypeContext(propertyContext, fields["@context"]);
160
163
  return typeof fields["@id"] === "string" ? nodeKey(fields["@id"], context, scope.block, base) : undefined;
161
164
  }
162
165
  // Every JSON-LD node with each @id, embedded ones included, keyed as above; nodes sharing an @id
@@ -184,7 +187,7 @@ function hasOpeningHoursField(node, nodesById, base) {
184
187
  : [node.openingHoursSpecification];
185
188
  return (stringValues(node.openingHours).length > 0 ||
186
189
  specifications.some((specification) => {
187
- const reference = referenceKey(specification, node, base);
190
+ const reference = referenceKey(specification, node, "openingHoursSpecification", base);
188
191
  const referenced = reference === undefined ? [] : (nodesById.get(reference) ?? []);
189
192
  return hasOpeningHoursData(specification) || referenced.some(hasOpeningHoursData);
190
193
  }));
@@ -739,18 +742,23 @@ function streetAddressVisible(streetAddress, regions) {
739
742
  return false;
740
743
  });
741
744
  }
742
- function localBusinessNapMismatches($) {
745
+ // As with the contact fields, the business's telephone and address may sit on any node sharing its @id.
746
+ function localBusinessNapMismatches($, snapshot) {
743
747
  const nodes = localBusinessNodes($);
744
748
  if (nodes.length === 0) {
745
749
  return [];
746
750
  }
747
751
  const index = jsonLdIndex($);
752
+ const base = documentBase($, snapshot);
753
+ const nodesById = jsonLdNodesById($, base);
748
754
  const body = visibleBody($);
749
755
  const regions = visibleAddressRegions($, body);
750
756
  const mismatches = [];
751
757
  for (const node of nodes) {
752
- for (const telephone of stringValues(node.telephone)) {
753
- const country = schemaCountry(node, index) ?? parsePhoneNumberFromString(telephone.trim())?.country;
758
+ const fragments = nodeFragments(node, nodesById, base);
759
+ const fragmentCountry = fragments.map((fragment) => schemaCountry(fragment, index)).find((code) => code !== undefined);
760
+ for (const telephone of fragments.flatMap((fragment) => stringValues(fragment.telephone))) {
761
+ const country = fragmentCountry ?? parsePhoneNumberFromString(telephone.trim())?.country;
754
762
  const phones = visiblePhones($, body, country);
755
763
  if (phones.length === 0) {
756
764
  continue;
@@ -768,7 +776,7 @@ function localBusinessNapMismatches($) {
768
776
  }
769
777
  }
770
778
  if (regions.length > 0) {
771
- for (const streetAddress of streetAddresses(node, index)) {
779
+ for (const streetAddress of fragments.flatMap((fragment) => streetAddresses(fragment, index))) {
772
780
  if (!streetAddressVisible(streetAddress, regions)) {
773
781
  mismatches.push(`Schema streetAddress "${streetAddress.trim()}" not found in visible page text`);
774
782
  }
@@ -957,6 +965,20 @@ function mixedContentUrls($, snapshot) {
957
965
  }
958
966
  // Let's Encrypt and most automated issuers renew at 30 days left, so fewer than 14 means automatic renewal is failing.
959
967
  const certificateWarningDays = 14;
968
+ // A month's notice leaves time to fix a failed renewal; healthy domains that renew in their final weeks can show here too.
969
+ const domainWarningDays = 30;
970
+ const lapsingDomainStatus = /^(?:redemption ?period|pending ?delete)$/i;
971
+ function domainRiskStatus(domain) {
972
+ return domain.status.find((status) => lapsingDomainStatus.test(status.trim())) ??
973
+ (domain.daysRemaining < domainWarningDays ? "expiring" : undefined);
974
+ }
975
+ function domainEvidence(domain) {
976
+ const date = domain.expiresAt.slice(0, 10);
977
+ const days = domain.daysRemaining;
978
+ const expiry = days < 0 ? `${domain.name} expired ${date}` : `${domain.name} expires ${date} (${days} day${days === 1 ? "" : "s"})`;
979
+ const risk = domainRiskStatus(domain);
980
+ return [expiry, ...(risk && risk !== "expiring" ? [`status: ${risk}`] : []), ...(domain.registrar ? [`registrar ${domain.registrar}`] : [])].join("; ");
981
+ }
960
982
  function tlsCertificateEvidence(tls) {
961
983
  const date = tls.validTo.slice(0, 10);
962
984
  const status = tls.daysRemaining < 0
@@ -966,6 +988,205 @@ function tlsCertificateEvidence(tls) {
966
988
  : `Certificate expires ${date} (${tls.daysRemaining} day${tls.daysRemaining === 1 ? "" : "s"})`;
967
989
  return tls.issuer ? `${status}; issuer ${tls.issuer}` : status;
968
990
  }
991
+ const languageCode = /^[a-z]{2,3}(?:-[a-z0-9]{1,8})*$/i;
992
+ function htmlLangEvidence($) {
993
+ const attribute = $("html").attr("lang");
994
+ const lang = (attribute ?? "").trim();
995
+ if (!lang) {
996
+ return attribute === undefined ? "No lang attribute on the <html> element" : "The lang attribute on the <html> element is empty";
997
+ }
998
+ return languageCode.test(lang) ? undefined : `lang="${lang}" is not a valid language code`;
999
+ }
1000
+ const unlabelledInputTypes = new Set(["hidden", "submit", "button", "reset", "image"]);
1001
+ function textOf($, element) {
1002
+ const copy = $(element).clone();
1003
+ copy.find("input, select, textarea").remove();
1004
+ return copy.text().replace(/\s+/g, " ").trim();
1005
+ }
1006
+ function hasAccessibleName($, element) {
1007
+ const control = $(element);
1008
+ const ids = (control.attr("aria-labelledby") ?? "").split(/\s+/).filter(Boolean);
1009
+ if (ids.some((id) => $("[id]").toArray().some((target) => $(target).attr("id") === id && textOf($, target)))) {
1010
+ return true;
1011
+ }
1012
+ if (["aria-label", "title", "placeholder"].some((name) => (control.attr(name) ?? "").trim())) {
1013
+ return true;
1014
+ }
1015
+ const id = control.attr("id");
1016
+ const labels = [
1017
+ ...control.closest("label").toArray(),
1018
+ ...(id ? $("label[for]").toArray().filter((label) => $(label).attr("for") === id) : [])
1019
+ ];
1020
+ return labels.some((label) => textOf($, label));
1021
+ }
1022
+ function describeControl($, element) {
1023
+ const control = $(element);
1024
+ const tag = element.tagName?.toLowerCase() ?? "input";
1025
+ const type = control.attr("type");
1026
+ const name = control.attr("name");
1027
+ const id = control.attr("id");
1028
+ return `${tag}${type ? `[type=${type}]` : ""}${name ? `[name=${name}]` : id ? `#${id}` : ""}`;
1029
+ }
1030
+ function formFields($) {
1031
+ const fields = visibleBody($)
1032
+ .find("input, select, textarea")
1033
+ .toArray()
1034
+ // Fields under aria-hidden, such as the off-screen Mailchimp spam trap, are not exposed to assistive technology.
1035
+ .filter((element) => !unlabelledInputTypes.has(($(element).attr("type") ?? "").trim().toLowerCase()) && $(element).closest('[aria-hidden="true"]').length === 0);
1036
+ const unlabelled = fields.filter((element) => !hasAccessibleName($, element)).map((element) => describeControl($, element));
1037
+ return { total: fields.length, unlabelled };
1038
+ }
1039
+ // Lighthouse's English list of non-descriptive link text, plus "continue reading", "find out more", and common Turkish and German equivalents.
1040
+ const genericLinkText = new Set([
1041
+ "click here",
1042
+ "click this",
1043
+ "go",
1044
+ "here",
1045
+ "information",
1046
+ "learn more",
1047
+ "more",
1048
+ "more info",
1049
+ "more information",
1050
+ "right here",
1051
+ "read more",
1052
+ "see more",
1053
+ "start",
1054
+ "this",
1055
+ "continue reading",
1056
+ "find out more",
1057
+ "buraya tıklayın",
1058
+ "buraya tıkla",
1059
+ "tıklayın",
1060
+ "tıkla",
1061
+ "devamı",
1062
+ "devamını oku",
1063
+ "daha fazla",
1064
+ "detaylar",
1065
+ "ayrıntılar",
1066
+ "hier",
1067
+ "hier klicken",
1068
+ "mehr",
1069
+ "mehr erfahren",
1070
+ "mehr lesen",
1071
+ "weiterlesen"
1072
+ ]);
1073
+ function genericLinks($) {
1074
+ return visibleBody($)
1075
+ .find("a[href]")
1076
+ .toArray()
1077
+ .flatMap((element) => {
1078
+ const link = $(element);
1079
+ const href = (link.attr("href") ?? "").trim();
1080
+ if (!href || href.startsWith("#") || /^javascript:/i.test(href) || link.attr("aria-labelledby")) {
1081
+ return [];
1082
+ }
1083
+ const label = (link.attr("aria-label") ?? "").trim() ||
1084
+ [link.text(), ...link.find("img[alt]").toArray().map((image) => $(image).attr("alt") ?? "")].join(" ");
1085
+ const shown = label.replace(/\s+/g, " ").trim();
1086
+ const key = shown.toLowerCase().replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
1087
+ return genericLinkText.has(key) ? [`"${shown}" (${href})`] : [];
1088
+ });
1089
+ }
1090
+ function firstFive(items) {
1091
+ return [...items.slice(0, 5), ...(items.length > 5 ? [`and ${items.length - 5} more`] : [])].join("; ");
1092
+ }
1093
+ const trackers = [
1094
+ [
1095
+ "Google Analytics or Ads",
1096
+ /googletagmanager\.com\/gtag\/js|google-analytics\.com\/(?:analytics|ga|urchin)\.js|\bgtag\(\s*['"]config['"]|\bga\(\s*['"]create['"]/i
1097
+ ],
1098
+ ["Google Tag Manager", /googletagmanager\.com\/gtm\.js/i],
1099
+ ["Meta Pixel", /connect\.facebook\.net\/[\w-]+\/fbevents\.js|\bfbq\(\s*['"]init['"]/i],
1100
+ ["Hotjar", /static\.hotjar\.com|\b_hjSettings\b/i],
1101
+ ["Microsoft Clarity", /clarity\.ms\/tag/i],
1102
+ ["LinkedIn Insight Tag", /snap\.licdn\.com\/li\.lms-analytics|\b_linkedin_partner_id\b/i],
1103
+ ["TikTok Pixel", /analytics\.tiktok\.com/i],
1104
+ ["Microsoft Advertising", /bat\.bing\.com\/bat\.js/i]
1105
+ ];
1106
+ const trackingCookie = /^(?:_ga(?:_\w+)?|_gid|_gat(?:_\w+)?|_gcl_\w+|_fbp|_fbc|_hj\w+|_clck|_clsk|_uetsid|_uetvid|_ttp|li_fat_id)$/;
1107
+ // Consent platforms, IAB TCF, Google consent mode defaults, website-builder banners, and common cookie banner markup.
1108
+ // Matching is deliberately broad: a page that shows any sign of a consent step is not flagged.
1109
+ const consentTool = /cookiebot|cookielaw\.org|onetrust|optanon|cookieyes|cookie-law-info|termly\.io|iubenda|complianz|cmplz|osano|usercentrics|cmp\.quantcast\.com|__tcfapi|didomi|cookiefirst|cookie-script\.com|civiccomputing|cookiecontrol|borlabs|klaro|real-cookie-banner|moove[_-]gdpr|cookie[_-]?(?:notice|consent|banner|bar|law)|consent[_-]?(?:banner|manager)|cookiehub|axeptio|tarteaucitron|trustarc|consentmanager|gdpr[_-]?cookie|cc-window|cc-banner|sqs-cookie-banner|\bfs-cc\b|fundingchoicesmessages\.google\.com|hs-banner|hs-eu-cookie|consentPolicy|customerPrivacy|\bgtag\(\s*['"]consent['"]\s*,\s*['"]default['"]/i;
1110
+ // Consent tools hold tags back as type="text/plain" until visitors accept, and data or template blocks never run.
1111
+ // Any other type counts, including the types that optimisers such as Rocket Loader ("<hash>-text/javascript"),
1112
+ // LiteSpeed ("litespeed/javascript"), WP Rocket ("rocketlazyloadscript"), and Partytown use for scripts they run later.
1113
+ const inertScriptType = /^text\/plain$|json|importmap|speculationrules|template|^text\/html$|handlebars|mustache/;
1114
+ function loadedTrackers($) {
1115
+ const scripts = $("script")
1116
+ .toArray()
1117
+ // Only the MIME type counts, so "text/plain; charset=utf-8" is inert too.
1118
+ .filter((element) => !inertScriptType.test(($(element).attr("type") ?? "").split(";")[0].trim().toLowerCase()))
1119
+ .map((element) => `${$(element).attr("src") ?? ""} ${$(element).text()}`)
1120
+ .join("\n");
1121
+ return trackers.filter(([, pattern]) => pattern.test(scripts)).map(([name]) => name);
1122
+ }
1123
+ function serverTrackingCookies(snapshot) {
1124
+ return Array.from(new Set(cookieNames(snapshot.headers["set-cookie"]).filter((name) => trackingCookie.test(name))));
1125
+ }
1126
+ function hasConsentTool(snapshot) {
1127
+ return consentTool.test(snapshot.html);
1128
+ }
1129
+ function consentEvidence($, snapshot) {
1130
+ const names = loadedTrackers($);
1131
+ const cookies = serverTrackingCookies(snapshot);
1132
+ const scripts = names.length > 0 ? `${names.join(", ")} ${names.length === 1 ? "loads" : "load"} with no consent tool found` : "No consent tool found";
1133
+ return cookies.length > 0 ? `${scripts}; tracking cookies set by the server: ${cookies.join(", ")}` : scripts;
1134
+ }
1135
+ const privacyLinkText = /privacy|privacidad|privacit|gizlilik|kvkk|kişisel veri|datenschutz|confidentialit|données personnelles|protección de datos|data protection|gdpr|privacybeleid|privacyverklaring/i;
1136
+ const privacyLinkPath = /privacy|privacidad|gizlilik|kvkk|aydinlatma|datenschutz|confidentialit|gdpr|data-protection/i;
1137
+ const privacyPolicyHosts = ["iubenda.com", "termly.io", "privacypolicies.com", "freeprivacypolicy.com"];
1138
+ function bareHost(host) {
1139
+ return host.toLowerCase().replace(/^www\./, "");
1140
+ }
1141
+ function onHost(host, domain) {
1142
+ return host === domain || host.endsWith(`.${domain}`);
1143
+ }
1144
+ // The URL parser keeps percent sequences that do not decode, such as "/20%-off", so fall back to the raw path.
1145
+ function decodedPath(url) {
1146
+ try {
1147
+ return decodeURIComponent(url.pathname);
1148
+ }
1149
+ catch {
1150
+ return url.pathname;
1151
+ }
1152
+ }
1153
+ function hasPrivacyPolicyLink($, snapshot) {
1154
+ if ($("[rel~='privacy-policy'][href]").length > 0) {
1155
+ return true;
1156
+ }
1157
+ let pageHost;
1158
+ try {
1159
+ pageHost = bareHost(new URL(snapshot.finalUrl).hostname);
1160
+ }
1161
+ catch {
1162
+ pageHost = undefined;
1163
+ }
1164
+ return $("a[href]")
1165
+ .toArray()
1166
+ .some((element) => {
1167
+ let url;
1168
+ try {
1169
+ url = new URL(($(element).attr("href") ?? "").trim(), snapshot.finalUrl);
1170
+ }
1171
+ catch {
1172
+ return false;
1173
+ }
1174
+ const host = bareHost(url.hostname);
1175
+ const ownSite = pageHost !== undefined && (onHost(host, pageHost) || onHost(pageHost, host));
1176
+ if (!/^https?:$/.test(url.protocol) || !(ownSite || privacyPolicyHosts.some((domain) => onHost(host, domain)))) {
1177
+ return false;
1178
+ }
1179
+ return privacyLinkText.test($(element).text()) || privacyLinkPath.test(decodedPath(url));
1180
+ });
1181
+ }
1182
+ function privacyEvidence($, snapshot) {
1183
+ const names = loadedTrackers($);
1184
+ const context = [
1185
+ ...(detectPageForms($, snapshot.finalUrl).enquiryForm ? ["has an enquiry form"] : []),
1186
+ ...(names.length > 0 ? [`loads ${names.join(", ")}`] : [])
1187
+ ];
1188
+ return `No privacy policy link found${context.length > 0 ? `; the page ${context.join(" and ")}` : ""}`;
1189
+ }
969
1190
  function hasPlaceholderSocialLinks($) {
970
1191
  return $("a")
971
1192
  .toArray()
@@ -1002,6 +1223,16 @@ const rules = [
1002
1223
  check: ({ snapshot }) => !snapshot.tls || (snapshot.tls.authorized && snapshot.tls.daysRemaining >= certificateWarningDays),
1003
1224
  evidence: ({ snapshot }) => (snapshot.tls ? tlsCertificateEvidence(snapshot.tls) : "")
1004
1225
  },
1226
+ {
1227
+ id: "domain-expiry",
1228
+ title: "Domain registration expires soon",
1229
+ category: "technical-health",
1230
+ severity: "medium",
1231
+ source: "Domain registration",
1232
+ recommendation: "Check with your domain registrar that the domain renews automatically and the payment details on file are current; if the domain lapses, the website and email stop working.",
1233
+ check: ({ snapshot }) => !snapshot.domain || domainRiskStatus(snapshot.domain) === undefined,
1234
+ evidence: ({ snapshot }) => (snapshot.domain ? domainEvidence(snapshot.domain) : "")
1235
+ },
1005
1236
  {
1006
1237
  id: "page-indexable",
1007
1238
  title: "Page tells search engines not to index it",
@@ -1164,8 +1395,8 @@ const rules = [
1164
1395
  severity: "medium",
1165
1396
  source: "JSON-LD",
1166
1397
  recommendation: "Make the LocalBusiness schema telephone and streetAddress match the phone number and address shown on the page, so search engines and customers see one consistent listing.",
1167
- check: ({ $ }) => localBusinessNapMismatches($).length === 0,
1168
- evidence: ({ $ }) => localBusinessNapMismatches($).join("; ")
1398
+ check: ({ $, snapshot }) => localBusinessNapMismatches($, snapshot).length === 0,
1399
+ evidence: ({ $, snapshot }) => localBusinessNapMismatches($, snapshot).join("; ")
1169
1400
  },
1170
1401
  {
1171
1402
  id: "organization-schema-present",
@@ -1229,6 +1460,26 @@ const rules = [
1229
1460
  hasLink($, (href) => href.toLowerCase().startsWith("mailto:")),
1230
1461
  evidence: ({ $, snapshot }) => `No enquiry form, contact page link, or email link${detectPageForms($, snapshot.finalUrl).newsletterForm ? "; only a newsletter signup form" : ""}`
1231
1462
  },
1463
+ {
1464
+ id: "privacy-policy-link-present",
1465
+ title: "Privacy policy link is missing",
1466
+ category: "trust-contact",
1467
+ severity: "medium",
1468
+ source: "Privacy links",
1469
+ recommendation: "Link to a privacy policy from every page, usually in the footer, explaining what personal data the site collects through forms, analytics, and cookies and how visitors can contact you about it.",
1470
+ check: ({ $, snapshot }) => hasPrivacyPolicyLink($, snapshot),
1471
+ evidence: ({ $, snapshot }) => privacyEvidence($, snapshot)
1472
+ },
1473
+ {
1474
+ id: "cookie-consent-present",
1475
+ title: "Tracking loads without a cookie consent tool",
1476
+ category: "trust-contact",
1477
+ severity: "medium",
1478
+ source: "Tracking and consent",
1479
+ recommendation: "Add a cookie consent banner that holds advertising and non-essential analytics tags until visitors accept, as UK and EU cookie rules (PECR and the ePrivacy Directive) generally expect, or remove trackers you do not use.",
1480
+ check: ({ $, snapshot }) => (loadedTrackers($).length === 0 && serverTrackingCookies(snapshot).length === 0) || hasConsentTool(snapshot),
1481
+ evidence: ({ $, snapshot }) => consentEvidence($, snapshot)
1482
+ },
1232
1483
  {
1233
1484
  id: "placeholder-copy-absent",
1234
1485
  title: "Placeholder copy is still visible",
@@ -1363,6 +1614,42 @@ const rules = [
1363
1614
  .filter((element) => $(element).attr("alt") === undefined).length;
1364
1615
  return `${missing} of ${images} images missing alt attributes`;
1365
1616
  }
1617
+ },
1618
+ {
1619
+ id: "html-lang-present",
1620
+ title: "Page language is not declared",
1621
+ category: "mobile-usability",
1622
+ severity: "low",
1623
+ source: "HTML lang",
1624
+ recommendation: 'Set the page language on the <html> element, such as lang="en-GB", so screen readers pronounce the text correctly and browsers offer the right translation.',
1625
+ check: ({ $ }) => htmlLangEvidence($) === undefined,
1626
+ evidence: ({ $ }) => htmlLangEvidence($) ?? ""
1627
+ },
1628
+ {
1629
+ id: "form-labels-present",
1630
+ title: "Some form fields have no label",
1631
+ category: "mobile-usability",
1632
+ severity: "low",
1633
+ source: "Form labels",
1634
+ recommendation: "Give every form field a visible label, or at least an aria-label, so screen reader users and people using voice control know what to enter.",
1635
+ check: ({ $ }) => formFields($).unlabelled.length === 0,
1636
+ evidence: ({ $ }) => {
1637
+ const { total, unlabelled } = formFields($);
1638
+ return `${unlabelled.length} of ${total} form field${total === 1 ? "" : "s"} ${unlabelled.length === 1 ? "has" : "have"} no label: ${firstFive(unlabelled)}`;
1639
+ }
1640
+ },
1641
+ {
1642
+ id: "link-text-descriptive",
1643
+ title: "Some links use generic text",
1644
+ category: "search-basics",
1645
+ severity: "low",
1646
+ source: "Link text",
1647
+ recommendation: 'Replace link text such as "Read more" or "Click here" with words that say where the link goes, such as "Dental implant prices".',
1648
+ check: ({ $ }) => genericLinks($).length === 0,
1649
+ evidence: ({ $ }) => {
1650
+ const links = genericLinks($);
1651
+ return `${links.length} link${links.length === 1 ? " uses" : "s use"} generic text: ${firstFive(links)}`;
1652
+ }
1366
1653
  }
1367
1654
  ];
1368
1655
  export function runRules(snapshot) {
@@ -1377,18 +1664,43 @@ export function runRules(snapshot) {
1377
1664
  export const ruleCount = rules.length;
1378
1665
  // Real sites can mention these phrases, so only short pages count as placeholders. Maintenance copy must be
1379
1666
  // about the whole site ("Website temporarily down", "Our site is offline") or open the page ("Under maintenance."), so a short page
1380
- // saying "Our booking system is temporarily unavailable" keeps its normal audit.
1667
+ // saying "Our booking system is temporarily unavailable" keeps its normal audit. Conditional copy such as
1668
+ // "If our site is offline, call us" describes a fallback, not the site's state.
1381
1669
  const placeholderTextLimit = 600;
1382
1670
  const maintenanceCopy = /(?:^|[.!?]\s+|\b(?:our|this|the) )(?:web)?site (?:is )?(?:temporarily |currently )?(?:down|unavailable|offline|under maintenance)\b|^(?:we(?:'|’)re |we are )?(?:currently |temporarily )?(?:down for|under(?:going)?) (?:scheduled )?maintenance\b/i;
1383
1671
  const parkedCopy = /\b(?:(?:this )?domain (?:name )?(?:may be|is) for sale|buy this domain|domain is parked|parked (?:free,? )?(?:courtesy of|by)|this domain has been registered)\b/i;
1672
+ // "Should" is left out: as a condition it inverts the sentence ("Should our site go offline"), which the
1673
+ // notice pattern never matches, and otherwise it is advice ("You should be aware that our site is offline").
1674
+ const conditionalWords = /\b(?:if|when|whenever|unless|in case)\b/i;
1675
+ // The first maintenance notice whose sentence is not conditional; "If for any reason our site is offline,
1676
+ // call us" describes a fallback, not the site's state. Semicolons and line breaks between visible blocks end the clause, so
1677
+ // a "When to contact us" menu item does not hide the notice under it.
1678
+ function maintenanceNotice(text) {
1679
+ for (const match of text.matchAll(new RegExp(maintenanceCopy.source, "gi"))) {
1680
+ // A match opening with sentence punctuation starts a new sentence, so nothing before it applies.
1681
+ const sentence = /^[.!?]/.test(match[0]) ? "" : (text.slice(0, match.index).split(/[.!?;\n]/).pop() ?? "");
1682
+ // A condition right after the notice governs it too ("Our site is offline when scheduled maintenance
1683
+ // occurs"), but a later, separate clause does not ("Our site is offline, so if you need help call us").
1684
+ const governedAfter = /^\s*,?\s*(?:(?:but|and|yet)\s+)?(?:(?:only|just|even|except|usually|sometimes)\s+)?(?:if|when|whenever|unless|in case)\b/i.test(text.slice((match.index ?? 0) + match[0].length));
1685
+ if (!governedAfter && !conditionalWords.test(`${sentence} ${match[0]}`)) {
1686
+ return match;
1687
+ }
1688
+ }
1689
+ return undefined;
1690
+ }
1384
1691
  export function detectPlaceholderPage(snapshot) {
1385
1692
  const $ = load(snapshot.html);
1386
- const text = `${$("title").text()} ${collapsedText(visibleBody($))}`.replace(/\s+/g, " ").trim();
1693
+ const title = $("title").text().replace(/\s+/g, " ").trim();
1694
+ const lines = visibleLines(visibleBody($));
1695
+ const body = lines.join("\n");
1696
+ const text = `${title} ${lines.join(" ")}`.trim();
1387
1697
  if (text.length > placeholderTextLimit) {
1388
1698
  return undefined;
1389
1699
  }
1700
+ // The title and body are matched separately, so a notice opening the body is not read as continuing the
1701
+ // title ("Acme Dental" then "Site is temporarily offline.").
1390
1702
  const parked = text.match(parkedCopy);
1391
- const maintenance = parked ? undefined : text.match(maintenanceCopy);
1703
+ const maintenance = parked ? undefined : (maintenanceNotice(body) ?? maintenanceNotice(title));
1392
1704
  const match = parked ?? maintenance;
1393
1705
  if (!match) {
1394
1706
  return undefined;