open-local-audit 0.77.0 → 0.79.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,8 @@
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";
5
+ import { applyTypeContext, contextBase, expandNodeId, isLocalBusinessType, noTypeContext } from "./local-business-types.js";
4
6
  function finding(rule, context) {
5
7
  return {
6
8
  id: rule.id,
@@ -30,25 +32,39 @@ function jsonLdTypes(node) {
30
32
  const types = Array.isArray(typeValue) ? typeValue : [typeValue];
31
33
  return types.filter((type) => typeof type === "string");
32
34
  }
33
- function flattenJsonLd(value) {
35
+ const nodeScopes = new WeakMap();
36
+ // Flattens top-level and @graph or @included node objects; with embedded set, also node objects nested
37
+ // as property values, such as an address or a mainEntity.
38
+ function flattenJsonLd(value, block, context = noTypeContext, embedded = false) {
34
39
  if (Array.isArray(value)) {
35
- return value.flatMap((item) => flattenJsonLd(item));
40
+ return value.flatMap((item) => flattenJsonLd(item, block, context, embedded));
36
41
  }
37
42
  if (!value || typeof value !== "object") {
38
43
  return [];
39
44
  }
40
45
  const node = value;
41
- const graphNodes = flattenJsonLd(node["@graph"]);
42
- return [node, ...graphNodes];
46
+ const localContext = node["@context"];
47
+ const nodeContext = applyTypeContext(context, localContext);
48
+ // A context with "@propagate": false, alone or in a context array, applies to this node only; nested
49
+ // nodes return to the previous context, which no longer gets the lenient no-context reading.
50
+ const localContexts = Array.isArray(localContext) ? localContext : [localContext];
51
+ const propagates = !localContexts.some((entry) => hasObjectField(entry) && entry["@propagate"] === false);
52
+ const childContext = propagates ? nodeContext : { ...context, declared: true };
53
+ nodeScopes.set(node, { context: nodeContext, nestedContext: childContext, block });
54
+ // @graph and @included both hold further node objects of the same document.
55
+ const nestedKeys = embedded
56
+ ? Object.keys(node).filter((key) => !key.startsWith("@") || ["@graph", "@included", "@list", "@set"].includes(key))
57
+ : ["@graph", "@included"];
58
+ return [node, ...nestedKeys.flatMap((key) => flattenJsonLd(node[key], block, childContext, embedded))];
43
59
  }
44
- function jsonLdNodes($) {
60
+ function jsonLdNodes($, embedded = false) {
45
61
  return $('script[type="application/ld+json"]')
46
62
  .toArray()
47
- .flatMap((element) => {
63
+ .flatMap((element, block) => {
48
64
  const raw = $(element).text();
49
65
  try {
50
66
  const parsed = JSON.parse(raw);
51
- return flattenJsonLd(parsed);
67
+ return flattenJsonLd(parsed, block, noTypeContext, embedded);
52
68
  }
53
69
  catch {
54
70
  return [];
@@ -72,7 +88,7 @@ function hasSuccessfulResource(statusCode) {
72
88
  return typeof statusCode === "number" && statusCode >= 200 && statusCode < 400;
73
89
  }
74
90
  function localBusinessNodes($) {
75
- return jsonLdNodes($).filter((node) => jsonLdTypes(node).some((type) => type.endsWith("LocalBusiness") || type === "LocalBusiness"));
91
+ return jsonLdNodes($).filter((node) => jsonLdTypes(node).some((type) => isLocalBusinessType(type, nodeScopes.get(node)?.context)));
76
92
  }
77
93
  function hasObjectField(value) {
78
94
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -80,12 +96,116 @@ function hasObjectField(value) {
80
96
  function hasStringField(value) {
81
97
  return typeof value === "string" && value.trim().length > 0;
82
98
  }
83
- function hasLocalBusinessContactFields($) {
99
+ // A day or time is a non-empty string, or a JSON-LD value or node object carrying one, such as
100
+ // {"@id": "https://schema.org/Monday"} or {"@value": "09:00"}.
101
+ function hasOpeningHoursValue(value) {
102
+ if (hasStringField(value)) {
103
+ return true;
104
+ }
105
+ if (!hasObjectField(value)) {
106
+ return false;
107
+ }
108
+ const fields = value;
109
+ return ["@id", "@value", "name"].some((key) => hasStringField(fields[key]));
110
+ }
111
+ function hasOpeningHoursData(specification) {
112
+ if (!hasObjectField(specification)) {
113
+ return false;
114
+ }
115
+ const fields = specification;
116
+ return ["dayOfWeek", "opens", "closes"].some((field) => {
117
+ const values = Array.isArray(fields[field]) ? fields[field] : [fields[field]];
118
+ return values.some(hasOpeningHoursValue);
119
+ });
120
+ }
121
+ // JSON-LD @id values are IRIs relative to the document base: the page URL, or its <base href>.
122
+ function documentBase($, snapshot) {
123
+ const pageUrl = snapshot.finalUrl || snapshot.url;
124
+ try {
125
+ return new URL($("base[href]").first().attr("href") ?? "", pageUrl).href;
126
+ }
127
+ catch {
128
+ return pageUrl;
129
+ }
130
+ }
131
+ // An @id as a lookup key: compact IRIs expand through the JSON-LD context and relative IRIs resolve
132
+ // against the context's @base or the document base, while blank node labels such as "_:b0" stay local
133
+ // to their script block.
134
+ function nodeKey(id, context, block, documentBaseIri) {
135
+ const expanded = expandNodeId(id, context);
136
+ if (expanded.startsWith("_:")) {
137
+ return `${block} ${expanded}`;
138
+ }
139
+ try {
140
+ return new URL(expanded, contextBase(documentBaseIri, context) ?? undefined).href;
141
+ }
142
+ catch {
143
+ return expanded;
144
+ }
145
+ }
146
+ function nodeIdKey(node, base) {
147
+ const scope = nodeScopes.get(node);
148
+ return scope && typeof node["@id"] === "string" ? nodeKey(node["@id"], scope.context, scope.block, base) : undefined;
149
+ }
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) {
155
+ const scope = nodeScopes.get(owner);
156
+ if (!scope || !hasObjectField(reference)) {
157
+ return undefined;
158
+ }
159
+ const fields = reference;
160
+ const idOnly = Object.keys(fields).length === 1;
161
+ const propertyContext = applyTypeContext(idOnly ? scope.context : scope.nestedContext, scope.context.terms.get(property)?.scopedContext);
162
+ const context = idOnly ? propertyContext : applyTypeContext(propertyContext, fields["@context"]);
163
+ return typeof fields["@id"] === "string" ? nodeKey(fields["@id"], context, scope.block, base) : undefined;
164
+ }
165
+ // Every JSON-LD node with each @id, embedded ones included, keyed as above; nodes sharing an @id
166
+ // describe the same thing.
167
+ function jsonLdNodesById($, base) {
168
+ const nodesById = new Map();
169
+ for (const node of jsonLdNodes($, true)) {
170
+ const key = nodeIdKey(node, base);
171
+ if (key !== undefined) {
172
+ nodesById.set(key, [...(nodesById.get(key) ?? []), node]);
173
+ }
174
+ }
175
+ return nodesById;
176
+ }
177
+ // A node together with every other node that shares its @id.
178
+ function nodeFragments(node, nodesById, base) {
179
+ const key = nodeIdKey(node, base);
180
+ return key === undefined ? [node] : (nodesById.get(key) ?? [node]);
181
+ }
182
+ // openingHours may be one string or a list; openingHoursSpecification is one object or a list of them,
183
+ // each with a day or time, or an {"@id": ...} reference to nodes elsewhere in the graph that have one.
184
+ function hasOpeningHoursField(node, nodesById, base) {
185
+ const specifications = Array.isArray(node.openingHoursSpecification)
186
+ ? node.openingHoursSpecification
187
+ : [node.openingHoursSpecification];
188
+ return (stringValues(node.openingHours).length > 0 ||
189
+ specifications.some((specification) => {
190
+ const reference = referenceKey(specification, node, "openingHoursSpecification", base);
191
+ const referenced = reference === undefined ? [] : (nodesById.get(reference) ?? []);
192
+ return hasOpeningHoursData(specification) || referenced.some(hasOpeningHoursData);
193
+ }));
194
+ }
195
+ // The business's fields may be split across nodes that share its @id.
196
+ function hasLocalBusinessContactFields($, snapshot) {
84
197
  const nodes = localBusinessNodes($);
85
198
  if (nodes.length === 0) {
86
199
  return true;
87
200
  }
88
- return nodes.some((node) => hasStringField(node.telephone) && hasObjectField(node.address) && hasStringField(node.openingHours));
201
+ const base = documentBase($, snapshot);
202
+ const nodesById = jsonLdNodesById($, base);
203
+ return nodes.some((node) => {
204
+ const fragments = nodeFragments(node, nodesById, base);
205
+ return (fragments.some((fragment) => hasStringField(fragment.telephone)) &&
206
+ fragments.some((fragment) => hasObjectField(fragment.address)) &&
207
+ fragments.some((fragment) => hasOpeningHoursField(fragment, nodesById, base)));
208
+ });
89
209
  }
90
210
  function stringValues(value) {
91
211
  const values = Array.isArray(value) ? value : [value];
@@ -622,18 +742,23 @@ function streetAddressVisible(streetAddress, regions) {
622
742
  return false;
623
743
  });
624
744
  }
625
- 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) {
626
747
  const nodes = localBusinessNodes($);
627
748
  if (nodes.length === 0) {
628
749
  return [];
629
750
  }
630
751
  const index = jsonLdIndex($);
752
+ const base = documentBase($, snapshot);
753
+ const nodesById = jsonLdNodesById($, base);
631
754
  const body = visibleBody($);
632
755
  const regions = visibleAddressRegions($, body);
633
756
  const mismatches = [];
634
757
  for (const node of nodes) {
635
- for (const telephone of stringValues(node.telephone)) {
636
- 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;
637
762
  const phones = visiblePhones($, body, country);
638
763
  if (phones.length === 0) {
639
764
  continue;
@@ -651,7 +776,7 @@ function localBusinessNapMismatches($) {
651
776
  }
652
777
  }
653
778
  if (regions.length > 0) {
654
- for (const streetAddress of streetAddresses(node, index)) {
779
+ for (const streetAddress of fragments.flatMap((fragment) => streetAddresses(fragment, index))) {
655
780
  if (!streetAddressVisible(streetAddress, regions)) {
656
781
  mismatches.push(`Schema streetAddress "${streetAddress.trim()}" not found in visible page text`);
657
782
  }
@@ -849,6 +974,202 @@ function tlsCertificateEvidence(tls) {
849
974
  : `Certificate expires ${date} (${tls.daysRemaining} day${tls.daysRemaining === 1 ? "" : "s"})`;
850
975
  return tls.issuer ? `${status}; issuer ${tls.issuer}` : status;
851
976
  }
977
+ const languageCode = /^[a-z]{2,3}(?:-[a-z0-9]{1,8})*$/i;
978
+ function htmlLangEvidence($) {
979
+ const attribute = $("html").attr("lang");
980
+ const lang = (attribute ?? "").trim();
981
+ if (!lang) {
982
+ return attribute === undefined ? "No lang attribute on the <html> element" : "The lang attribute on the <html> element is empty";
983
+ }
984
+ return languageCode.test(lang) ? undefined : `lang="${lang}" is not a valid language code`;
985
+ }
986
+ const unlabelledInputTypes = new Set(["hidden", "submit", "button", "reset", "image"]);
987
+ function textOf($, element) {
988
+ const copy = $(element).clone();
989
+ copy.find("input, select, textarea").remove();
990
+ return copy.text().replace(/\s+/g, " ").trim();
991
+ }
992
+ function hasAccessibleName($, element) {
993
+ const control = $(element);
994
+ const ids = (control.attr("aria-labelledby") ?? "").split(/\s+/).filter(Boolean);
995
+ if (ids.some((id) => $("[id]").toArray().some((target) => $(target).attr("id") === id && textOf($, target)))) {
996
+ return true;
997
+ }
998
+ if (["aria-label", "title", "placeholder"].some((name) => (control.attr(name) ?? "").trim())) {
999
+ return true;
1000
+ }
1001
+ const id = control.attr("id");
1002
+ const labels = [
1003
+ ...control.closest("label").toArray(),
1004
+ ...(id ? $("label[for]").toArray().filter((label) => $(label).attr("for") === id) : [])
1005
+ ];
1006
+ return labels.some((label) => textOf($, label));
1007
+ }
1008
+ function describeControl($, element) {
1009
+ const control = $(element);
1010
+ const tag = element.tagName?.toLowerCase() ?? "input";
1011
+ const type = control.attr("type");
1012
+ const name = control.attr("name");
1013
+ const id = control.attr("id");
1014
+ return `${tag}${type ? `[type=${type}]` : ""}${name ? `[name=${name}]` : id ? `#${id}` : ""}`;
1015
+ }
1016
+ function formFields($) {
1017
+ const fields = visibleBody($)
1018
+ .find("input, select, textarea")
1019
+ .toArray()
1020
+ // Fields under aria-hidden, such as the off-screen Mailchimp spam trap, are not exposed to assistive technology.
1021
+ .filter((element) => !unlabelledInputTypes.has(($(element).attr("type") ?? "").trim().toLowerCase()) && $(element).closest('[aria-hidden="true"]').length === 0);
1022
+ const unlabelled = fields.filter((element) => !hasAccessibleName($, element)).map((element) => describeControl($, element));
1023
+ return { total: fields.length, unlabelled };
1024
+ }
1025
+ // Lighthouse's English list of non-descriptive link text, plus "continue reading", "find out more", and common Turkish and German equivalents.
1026
+ const genericLinkText = new Set([
1027
+ "click here",
1028
+ "click this",
1029
+ "go",
1030
+ "here",
1031
+ "information",
1032
+ "learn more",
1033
+ "more",
1034
+ "more info",
1035
+ "more information",
1036
+ "right here",
1037
+ "read more",
1038
+ "see more",
1039
+ "start",
1040
+ "this",
1041
+ "continue reading",
1042
+ "find out more",
1043
+ "buraya tıklayın",
1044
+ "buraya tıkla",
1045
+ "tıklayın",
1046
+ "tıkla",
1047
+ "devamı",
1048
+ "devamını oku",
1049
+ "daha fazla",
1050
+ "detaylar",
1051
+ "ayrıntılar",
1052
+ "hier",
1053
+ "hier klicken",
1054
+ "mehr",
1055
+ "mehr erfahren",
1056
+ "mehr lesen",
1057
+ "weiterlesen"
1058
+ ]);
1059
+ function genericLinks($) {
1060
+ return visibleBody($)
1061
+ .find("a[href]")
1062
+ .toArray()
1063
+ .flatMap((element) => {
1064
+ const link = $(element);
1065
+ const href = (link.attr("href") ?? "").trim();
1066
+ if (!href || href.startsWith("#") || /^javascript:/i.test(href) || link.attr("aria-labelledby")) {
1067
+ return [];
1068
+ }
1069
+ const label = (link.attr("aria-label") ?? "").trim() ||
1070
+ [link.text(), ...link.find("img[alt]").toArray().map((image) => $(image).attr("alt") ?? "")].join(" ");
1071
+ const shown = label.replace(/\s+/g, " ").trim();
1072
+ const key = shown.toLowerCase().replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
1073
+ return genericLinkText.has(key) ? [`"${shown}" (${href})`] : [];
1074
+ });
1075
+ }
1076
+ function firstFive(items) {
1077
+ return [...items.slice(0, 5), ...(items.length > 5 ? [`and ${items.length - 5} more`] : [])].join("; ");
1078
+ }
1079
+ const trackers = [
1080
+ [
1081
+ "Google Analytics or Ads",
1082
+ /googletagmanager\.com\/gtag\/js|google-analytics\.com\/(?:analytics|ga|urchin)\.js|\bgtag\(\s*['"]config['"]|\bga\(\s*['"]create['"]/i
1083
+ ],
1084
+ ["Google Tag Manager", /googletagmanager\.com\/gtm\.js/i],
1085
+ ["Meta Pixel", /connect\.facebook\.net\/[\w-]+\/fbevents\.js|\bfbq\(\s*['"]init['"]/i],
1086
+ ["Hotjar", /static\.hotjar\.com|\b_hjSettings\b/i],
1087
+ ["Microsoft Clarity", /clarity\.ms\/tag/i],
1088
+ ["LinkedIn Insight Tag", /snap\.licdn\.com\/li\.lms-analytics|\b_linkedin_partner_id\b/i],
1089
+ ["TikTok Pixel", /analytics\.tiktok\.com/i],
1090
+ ["Microsoft Advertising", /bat\.bing\.com\/bat\.js/i]
1091
+ ];
1092
+ const trackingCookie = /^(?:_ga(?:_\w+)?|_gid|_gat(?:_\w+)?|_gcl_\w+|_fbp|_fbc|_hj\w+|_clck|_clsk|_uetsid|_uetvid|_ttp|li_fat_id)$/;
1093
+ // Consent platforms, IAB TCF, Google consent mode defaults, website-builder banners, and common cookie banner markup.
1094
+ // Matching is deliberately broad: a page that shows any sign of a consent step is not flagged.
1095
+ 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;
1096
+ // Consent tools hold tags back as type="text/plain" until visitors accept, so only executable scripts count.
1097
+ const executableScriptTypes = new Set(["", "text/javascript", "application/javascript", "module"]);
1098
+ function loadedTrackers($) {
1099
+ const scripts = $("script")
1100
+ .toArray()
1101
+ .filter((element) => executableScriptTypes.has(($(element).attr("type") ?? "").trim().toLowerCase()))
1102
+ .map((element) => `${$(element).attr("src") ?? ""} ${$(element).text()}`)
1103
+ .join("\n");
1104
+ return trackers.filter(([, pattern]) => pattern.test(scripts)).map(([name]) => name);
1105
+ }
1106
+ function serverTrackingCookies(snapshot) {
1107
+ return Array.from(new Set(cookieNames(snapshot.headers["set-cookie"]).filter((name) => trackingCookie.test(name))));
1108
+ }
1109
+ function hasConsentTool(snapshot) {
1110
+ return consentTool.test(snapshot.html);
1111
+ }
1112
+ function consentEvidence($, snapshot) {
1113
+ const names = loadedTrackers($);
1114
+ const cookies = serverTrackingCookies(snapshot);
1115
+ const scripts = names.length > 0 ? `${names.join(", ")} ${names.length === 1 ? "loads" : "load"} with no consent tool found` : "No consent tool found";
1116
+ return cookies.length > 0 ? `${scripts}; tracking cookies set by the server: ${cookies.join(", ")}` : scripts;
1117
+ }
1118
+ 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;
1119
+ const privacyLinkPath = /privacy|privacidad|gizlilik|kvkk|aydinlatma|datenschutz|confidentialit|gdpr|data-protection/i;
1120
+ const privacyPolicyHosts = ["iubenda.com", "termly.io", "privacypolicies.com", "freeprivacypolicy.com"];
1121
+ function bareHost(host) {
1122
+ return host.toLowerCase().replace(/^www\./, "");
1123
+ }
1124
+ function onHost(host, domain) {
1125
+ return host === domain || host.endsWith(`.${domain}`);
1126
+ }
1127
+ // The URL parser keeps percent sequences that do not decode, such as "/20%-off", so fall back to the raw path.
1128
+ function decodedPath(url) {
1129
+ try {
1130
+ return decodeURIComponent(url.pathname);
1131
+ }
1132
+ catch {
1133
+ return url.pathname;
1134
+ }
1135
+ }
1136
+ function hasPrivacyPolicyLink($, snapshot) {
1137
+ if ($("[rel~='privacy-policy'][href]").length > 0) {
1138
+ return true;
1139
+ }
1140
+ let pageHost;
1141
+ try {
1142
+ pageHost = bareHost(new URL(snapshot.finalUrl).hostname);
1143
+ }
1144
+ catch {
1145
+ pageHost = undefined;
1146
+ }
1147
+ return $("a[href]")
1148
+ .toArray()
1149
+ .some((element) => {
1150
+ let url;
1151
+ try {
1152
+ url = new URL(($(element).attr("href") ?? "").trim(), snapshot.finalUrl);
1153
+ }
1154
+ catch {
1155
+ return false;
1156
+ }
1157
+ const host = bareHost(url.hostname);
1158
+ const ownSite = pageHost !== undefined && (onHost(host, pageHost) || onHost(pageHost, host));
1159
+ if (!/^https?:$/.test(url.protocol) || !(ownSite || privacyPolicyHosts.some((domain) => onHost(host, domain)))) {
1160
+ return false;
1161
+ }
1162
+ return privacyLinkText.test($(element).text()) || privacyLinkPath.test(decodedPath(url));
1163
+ });
1164
+ }
1165
+ function privacyEvidence($, snapshot) {
1166
+ const names = loadedTrackers($);
1167
+ const context = [
1168
+ ...(detectPageForms($, snapshot.finalUrl).enquiryForm ? ["has an enquiry form"] : []),
1169
+ ...(names.length > 0 ? [`loads ${names.join(", ")}`] : [])
1170
+ ];
1171
+ return `No privacy policy link found${context.length > 0 ? `; the page ${context.join(" and ")}` : ""}`;
1172
+ }
852
1173
  function hasPlaceholderSocialLinks($) {
853
1174
  return $("a")
854
1175
  .toArray()
@@ -1027,7 +1348,7 @@ const rules = [
1027
1348
  severity: "medium",
1028
1349
  source: "JSON-LD",
1029
1350
  recommendation: "Add LocalBusiness schema when the page represents a local business location.",
1030
- check: ({ $ }) => hasJsonLdType($, (type) => type.endsWith("LocalBusiness") || type === "LocalBusiness"),
1351
+ check: ({ $ }) => localBusinessNodes($).length > 0,
1031
1352
  evidence: () => "No LocalBusiness JSON-LD type found"
1032
1353
  },
1033
1354
  {
@@ -1036,9 +1357,9 @@ const rules = [
1036
1357
  category: "search-basics",
1037
1358
  severity: "medium",
1038
1359
  source: "JSON-LD",
1039
- recommendation: "Add telephone, address, and openingHours fields to LocalBusiness schema.",
1040
- check: ({ $ }) => hasLocalBusinessContactFields($),
1041
- evidence: () => "LocalBusiness schema is missing telephone, address, or openingHours"
1360
+ recommendation: "Add telephone, address, and openingHours or openingHoursSpecification fields to LocalBusiness schema.",
1361
+ check: ({ $, snapshot }) => hasLocalBusinessContactFields($, snapshot),
1362
+ evidence: () => "LocalBusiness schema is missing telephone, address, or opening hours"
1042
1363
  },
1043
1364
  {
1044
1365
  id: "localbusiness-schema-nap-consistency",
@@ -1047,8 +1368,8 @@ const rules = [
1047
1368
  severity: "medium",
1048
1369
  source: "JSON-LD",
1049
1370
  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.",
1050
- check: ({ $ }) => localBusinessNapMismatches($).length === 0,
1051
- evidence: ({ $ }) => localBusinessNapMismatches($).join("; ")
1371
+ check: ({ $, snapshot }) => localBusinessNapMismatches($, snapshot).length === 0,
1372
+ evidence: ({ $, snapshot }) => localBusinessNapMismatches($, snapshot).join("; ")
1052
1373
  },
1053
1374
  {
1054
1375
  id: "organization-schema-present",
@@ -1112,6 +1433,26 @@ const rules = [
1112
1433
  hasLink($, (href) => href.toLowerCase().startsWith("mailto:")),
1113
1434
  evidence: ({ $, snapshot }) => `No enquiry form, contact page link, or email link${detectPageForms($, snapshot.finalUrl).newsletterForm ? "; only a newsletter signup form" : ""}`
1114
1435
  },
1436
+ {
1437
+ id: "privacy-policy-link-present",
1438
+ title: "Privacy policy link is missing",
1439
+ category: "trust-contact",
1440
+ severity: "medium",
1441
+ source: "Privacy links",
1442
+ 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.",
1443
+ check: ({ $, snapshot }) => hasPrivacyPolicyLink($, snapshot),
1444
+ evidence: ({ $, snapshot }) => privacyEvidence($, snapshot)
1445
+ },
1446
+ {
1447
+ id: "cookie-consent-present",
1448
+ title: "Tracking loads without a cookie consent tool",
1449
+ category: "trust-contact",
1450
+ severity: "medium",
1451
+ source: "Tracking and consent",
1452
+ 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.",
1453
+ check: ({ $, snapshot }) => (loadedTrackers($).length === 0 && serverTrackingCookies(snapshot).length === 0) || hasConsentTool(snapshot),
1454
+ evidence: ({ $, snapshot }) => consentEvidence($, snapshot)
1455
+ },
1115
1456
  {
1116
1457
  id: "placeholder-copy-absent",
1117
1458
  title: "Placeholder copy is still visible",
@@ -1246,6 +1587,42 @@ const rules = [
1246
1587
  .filter((element) => $(element).attr("alt") === undefined).length;
1247
1588
  return `${missing} of ${images} images missing alt attributes`;
1248
1589
  }
1590
+ },
1591
+ {
1592
+ id: "html-lang-present",
1593
+ title: "Page language is not declared",
1594
+ category: "mobile-usability",
1595
+ severity: "low",
1596
+ source: "HTML lang",
1597
+ 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.',
1598
+ check: ({ $ }) => htmlLangEvidence($) === undefined,
1599
+ evidence: ({ $ }) => htmlLangEvidence($) ?? ""
1600
+ },
1601
+ {
1602
+ id: "form-labels-present",
1603
+ title: "Some form fields have no label",
1604
+ category: "mobile-usability",
1605
+ severity: "low",
1606
+ source: "Form labels",
1607
+ 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.",
1608
+ check: ({ $ }) => formFields($).unlabelled.length === 0,
1609
+ evidence: ({ $ }) => {
1610
+ const { total, unlabelled } = formFields($);
1611
+ return `${unlabelled.length} of ${total} form field${total === 1 ? "" : "s"} ${unlabelled.length === 1 ? "has" : "have"} no label: ${firstFive(unlabelled)}`;
1612
+ }
1613
+ },
1614
+ {
1615
+ id: "link-text-descriptive",
1616
+ title: "Some links use generic text",
1617
+ category: "search-basics",
1618
+ severity: "low",
1619
+ source: "Link text",
1620
+ recommendation: 'Replace link text such as "Read more" or "Click here" with words that say where the link goes, such as "Dental implant prices".',
1621
+ check: ({ $ }) => genericLinks($).length === 0,
1622
+ evidence: ({ $ }) => {
1623
+ const links = genericLinks($);
1624
+ return `${links.length} link${links.length === 1 ? " uses" : "s use"} generic text: ${firstFive(links)}`;
1625
+ }
1249
1626
  }
1250
1627
  ];
1251
1628
  export function runRules(snapshot) {
@@ -1260,18 +1637,43 @@ export function runRules(snapshot) {
1260
1637
  export const ruleCount = rules.length;
1261
1638
  // Real sites can mention these phrases, so only short pages count as placeholders. Maintenance copy must be
1262
1639
  // about the whole site ("Website temporarily down", "Our site is offline") or open the page ("Under maintenance."), so a short page
1263
- // saying "Our booking system is temporarily unavailable" keeps its normal audit.
1640
+ // saying "Our booking system is temporarily unavailable" keeps its normal audit. Conditional copy such as
1641
+ // "If our site is offline, call us" describes a fallback, not the site's state.
1264
1642
  const placeholderTextLimit = 600;
1265
1643
  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;
1266
1644
  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;
1645
+ // "Should" is left out: as a condition it inverts the sentence ("Should our site go offline"), which the
1646
+ // notice pattern never matches, and otherwise it is advice ("You should be aware that our site is offline").
1647
+ const conditionalWords = /\b(?:if|when|whenever|unless|in case)\b/i;
1648
+ // The first maintenance notice whose sentence is not conditional; "If for any reason our site is offline,
1649
+ // call us" describes a fallback, not the site's state. Semicolons and line breaks between visible blocks end the clause, so
1650
+ // a "When to contact us" menu item does not hide the notice under it.
1651
+ function maintenanceNotice(text) {
1652
+ for (const match of text.matchAll(new RegExp(maintenanceCopy.source, "gi"))) {
1653
+ // A match opening with sentence punctuation starts a new sentence, so nothing before it applies.
1654
+ const sentence = /^[.!?]/.test(match[0]) ? "" : (text.slice(0, match.index).split(/[.!?;\n]/).pop() ?? "");
1655
+ // A condition right after the notice governs it too ("Our site is offline when scheduled maintenance
1656
+ // occurs"), but a later, separate clause does not ("Our site is offline, so if you need help call us").
1657
+ 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));
1658
+ if (!governedAfter && !conditionalWords.test(`${sentence} ${match[0]}`)) {
1659
+ return match;
1660
+ }
1661
+ }
1662
+ return undefined;
1663
+ }
1267
1664
  export function detectPlaceholderPage(snapshot) {
1268
1665
  const $ = load(snapshot.html);
1269
- const text = `${$("title").text()} ${collapsedText(visibleBody($))}`.replace(/\s+/g, " ").trim();
1666
+ const title = $("title").text().replace(/\s+/g, " ").trim();
1667
+ const lines = visibleLines(visibleBody($));
1668
+ const body = lines.join("\n");
1669
+ const text = `${title} ${lines.join(" ")}`.trim();
1270
1670
  if (text.length > placeholderTextLimit) {
1271
1671
  return undefined;
1272
1672
  }
1673
+ // The title and body are matched separately, so a notice opening the body is not read as continuing the
1674
+ // title ("Acme Dental" then "Site is temporarily offline.").
1273
1675
  const parked = text.match(parkedCopy);
1274
- const maintenance = parked ? undefined : text.match(maintenanceCopy);
1676
+ const maintenance = parked ? undefined : (maintenanceNotice(body) ?? maintenanceNotice(title));
1275
1677
  const match = parked ?? maintenance;
1276
1678
  if (!match) {
1277
1679
  return undefined;