@brandlab-digital/conformance 3.3.2 → 3.4.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/current-release.js +5 -5
- package/dist/current-release.js.map +1 -1
- package/dist/engineering.d.ts +7 -0
- package/dist/engineering.d.ts.map +1 -1
- package/dist/engineering.js +35 -1
- package/dist/engineering.js.map +1 -1
- package/dist/estate.d.ts +45 -1
- package/dist/estate.d.ts.map +1 -1
- package/dist/estate.js +243 -8
- package/dist/estate.js.map +1 -1
- package/dist/families-e2e.d.ts +21 -0
- package/dist/families-e2e.d.ts.map +1 -1
- package/dist/families-e2e.js +69 -1
- package/dist/families-e2e.js.map +1 -1
- package/dist/page-standard.d.ts +198 -0
- package/dist/page-standard.d.ts.map +1 -0
- package/dist/page-standard.js +536 -0
- package/dist/page-standard.js.map +1 -0
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +14 -0
- package/dist/project.js.map +1 -1
- package/dist/rule-registry.d.ts +15 -0
- package/dist/rule-registry.d.ts.map +1 -1
- package/dist/rule-registry.js +3 -0
- package/dist/rule-registry.js.map +1 -1
- package/dist/static-rules.d.ts +2 -0
- package/dist/static-rules.d.ts.map +1 -1
- package/dist/static-rules.js +176 -0
- package/dist/static-rules.js.map +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Brandlab on-page and location standard (3.4.0) as pure checks over the
|
|
3
|
+
* server HTML of a production build. The estate journey enforces the objective
|
|
4
|
+
* ones; the page-quality journey reports the judgement ones without failing.
|
|
5
|
+
*
|
|
6
|
+
* Every function reads only the HTML a crawler receives before any script runs.
|
|
7
|
+
*/
|
|
8
|
+
// Conformance compiles without the other packages (the operations evaluator builds it from source alone), so
|
|
9
|
+
// the few site-core rules it needs are mirrored here; page-standard.test.ts keeps them identical to site-core.
|
|
10
|
+
/** Great-circle distance in kilometres, as site-core's distanceBetweenCentresKm. */
|
|
11
|
+
export function distanceBetweenCentresKm(from, to) {
|
|
12
|
+
const radians = (degrees) => (degrees * Math.PI) / 180;
|
|
13
|
+
const latitudeDelta = radians(to.latitude - from.latitude);
|
|
14
|
+
const longitudeDelta = radians(to.longitude - from.longitude);
|
|
15
|
+
const haversine = Math.sin(latitudeDelta / 2) ** 2 +
|
|
16
|
+
Math.cos(radians(from.latitude)) * Math.cos(radians(to.latitude)) * Math.sin(longitudeDelta / 2) ** 2;
|
|
17
|
+
return 2 * 6371.0088 * Math.asin(Math.min(1, Math.sqrt(haversine)));
|
|
18
|
+
}
|
|
19
|
+
/** The first place listed after a farther one, as site-core's findNeighbourOrderProblem. */
|
|
20
|
+
export function findNeighbourOrderProblem(origin, slugs, centreOf, toleranceKm = 0.05) {
|
|
21
|
+
const from = centreOf(origin);
|
|
22
|
+
if (!from)
|
|
23
|
+
return null;
|
|
24
|
+
let previous;
|
|
25
|
+
for (const [index, slug] of slugs.entries()) {
|
|
26
|
+
const to = centreOf(slug);
|
|
27
|
+
if (!to)
|
|
28
|
+
continue;
|
|
29
|
+
const current = { slug, distanceKm: distanceBetweenCentresKm(from, to) };
|
|
30
|
+
if (previous && current.distanceKm + toleranceKm < previous.distanceKm) {
|
|
31
|
+
return {
|
|
32
|
+
index,
|
|
33
|
+
slug,
|
|
34
|
+
previous: previous.slug,
|
|
35
|
+
detail: `${slug} (${current.distanceKm.toFixed(1)} km) is listed after ${previous.slug} (${previous.distanceKm.toFixed(1)} km); nearby places are ranked nearest first`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
previous = current;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const SITEMAP_FAMILY_BY_KIND = Object.freeze({
|
|
43
|
+
service: 'services',
|
|
44
|
+
location: 'locations',
|
|
45
|
+
'service-location': 'service-locations',
|
|
46
|
+
'blog-post': 'blog',
|
|
47
|
+
project: 'projects',
|
|
48
|
+
});
|
|
49
|
+
/** The split sitemap family of a route kind, as site-core's sitemapFamilyForKind. */
|
|
50
|
+
export function sitemapFamilyForKind(kind) {
|
|
51
|
+
return Object.hasOwn(SITEMAP_FAMILY_BY_KIND, kind) ? SITEMAP_FAMILY_BY_KIND[kind] : 'core';
|
|
52
|
+
}
|
|
53
|
+
const LOCATION_ROUTE_KINDS = new Set(['location', 'service-location']);
|
|
54
|
+
function isIndexableLocationRoute(route) {
|
|
55
|
+
return LOCATION_ROUTE_KINDS.has(route.kind ?? '') && route.indexing === 'index';
|
|
56
|
+
}
|
|
57
|
+
/** As site-core's checkLocationCapacity: blocked only beyond both the limit and the pages at record (or the approval). */
|
|
58
|
+
export function checkLocationCapacity(routes, capacity) {
|
|
59
|
+
const indexable = routes.filter(isIndexableLocationRoute).length;
|
|
60
|
+
if (!capacity) {
|
|
61
|
+
return { indexable, limit: null, allowed: null, overCapacity: false, blocked: false, detail: `${indexable} indexable location page(s); no location capacity is recorded yet.` };
|
|
62
|
+
}
|
|
63
|
+
const approval = capacity.approval;
|
|
64
|
+
const allowed = approval ? approval.maxIndexable ?? Number.POSITIVE_INFINITY : Math.max(capacity.limit, capacity.indexableAtRecord);
|
|
65
|
+
const overCapacity = indexable > capacity.limit;
|
|
66
|
+
const blocked = indexable > allowed;
|
|
67
|
+
const approved = approval ? ` under ${approval.approvedBy}'s approval of ${approval.approvedAt} (review by ${approval.reviewBy})` : '';
|
|
68
|
+
const detail = blocked
|
|
69
|
+
? `${indexable} indexable location page(s) exceed what the site may carry (${allowed}: capacity ${capacity.limit} recorded ${capacity.recordedAt} with ${capacity.indexableAtRecord} at record${approved}). Add pages only within capacity, or record Romie's approval.`
|
|
70
|
+
: overCapacity
|
|
71
|
+
? `${indexable} indexable location page(s) against a capacity of ${capacity.limit} (recorded ${capacity.recordedAt})${approved}; no pages may be added.`
|
|
72
|
+
: `${indexable} indexable location page(s) within a capacity of ${capacity.limit} (recorded ${capacity.recordedAt}).`;
|
|
73
|
+
return { indexable, limit: capacity.limit, allowed: Number.isFinite(allowed) ? allowed : null, overCapacity, blocked, detail };
|
|
74
|
+
}
|
|
75
|
+
/** As site-core's buildLocationReviewList: indexable location pages without evidence or with an open review flag. */
|
|
76
|
+
export function buildLocationReviewList(routes) {
|
|
77
|
+
const items = [];
|
|
78
|
+
for (const route of routes) {
|
|
79
|
+
if (!isIndexableLocationRoute(route))
|
|
80
|
+
continue;
|
|
81
|
+
const evidence = route.locationDecision?.evidence ?? [];
|
|
82
|
+
const reasons = [];
|
|
83
|
+
if (evidence.length === 0) {
|
|
84
|
+
reasons.push('no qualifying evidence recorded (job, base suburb, facts and demand, existing clicks or enquiries, or an approved exception)');
|
|
85
|
+
}
|
|
86
|
+
const review = route.locationDecision?.review?.trim();
|
|
87
|
+
if (review)
|
|
88
|
+
reasons.push(review);
|
|
89
|
+
if (reasons.length > 0)
|
|
90
|
+
items.push({ ...(route.id ? { id: route.id } : {}), path: route.path, evidence, reasons });
|
|
91
|
+
}
|
|
92
|
+
return items.sort((a, b) => a.path.localeCompare(b.path));
|
|
93
|
+
}
|
|
94
|
+
// A variable specifier: the module is the site's own site-core (3.4.0 or later), resolved when a journey runs.
|
|
95
|
+
const GEOGRAPHY_MODULE = '@brandlab-digital/site-core/geography';
|
|
96
|
+
let geography;
|
|
97
|
+
/** The suburb centres and place names of the site's installed site-core geography. */
|
|
98
|
+
export function loadBrandlabGeography() {
|
|
99
|
+
geography ??= import(GEOGRAPHY_MODULE).then((module) => {
|
|
100
|
+
if (typeof module.getSuburbCentre !== 'function') {
|
|
101
|
+
throw new Error('@brandlab-digital/site-core/geography has no getSuburbCentre: install site-core 3.4.0 or later with the conformance package.');
|
|
102
|
+
}
|
|
103
|
+
const registries = [module.sydneyGeography, module.centralCoastGeography];
|
|
104
|
+
const placeNames = new Map();
|
|
105
|
+
const names = [];
|
|
106
|
+
for (const registry of registries) {
|
|
107
|
+
for (const record of [...registry.suburbs, ...registry.nonSuburbLocalities, ...registry.regions, ...registry.subRegions]) {
|
|
108
|
+
if (!placeNames.has(record.slug))
|
|
109
|
+
placeNames.set(record.slug, record.name);
|
|
110
|
+
}
|
|
111
|
+
for (const record of [...registry.suburbs, ...registry.nonSuburbLocalities, ...registry.councils, ...registry.subRegions])
|
|
112
|
+
names.push(record.name);
|
|
113
|
+
for (const region of registry.regions)
|
|
114
|
+
names.push(region.name, region.seoName, ...(region.alternateNames ?? []));
|
|
115
|
+
}
|
|
116
|
+
return { centreOf: module.getSuburbCentre, placeNames, maskPlaces: createPlaceMask(names) };
|
|
117
|
+
});
|
|
118
|
+
return geography;
|
|
119
|
+
}
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Structured data
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
/** Types that describe the business itself; a rating on them is a self-serving rating. */
|
|
124
|
+
export const BUSINESS_SCHEMA_TYPES = new Set([
|
|
125
|
+
'LocalBusiness', 'HomeAndConstructionBusiness', 'GeneralContractor', 'Plumber', 'Electrician',
|
|
126
|
+
'RoofingContractor', 'HousePainter', 'Locksmith', 'MovingCompany', 'HVACBusiness', 'ProfessionalService',
|
|
127
|
+
'Organization', 'Corporation', 'EmergencyService',
|
|
128
|
+
]);
|
|
129
|
+
const RETIRED_RICH_RESULT_TYPES = new Set(['FAQPage', 'HowTo']);
|
|
130
|
+
function schemaTypes(node) {
|
|
131
|
+
return [].concat(node['@type'] ?? []).map((type) => String(type).replace(/^(?:https?:\/\/)?schema\.org\//i, ''));
|
|
132
|
+
}
|
|
133
|
+
function collectNodes(payloads) {
|
|
134
|
+
const nodes = [];
|
|
135
|
+
const visit = (value) => {
|
|
136
|
+
if (!value || typeof value !== 'object')
|
|
137
|
+
return;
|
|
138
|
+
if (Array.isArray(value)) {
|
|
139
|
+
value.forEach(visit);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const node = value;
|
|
143
|
+
nodes.push(node);
|
|
144
|
+
Object.values(node).forEach(visit);
|
|
145
|
+
};
|
|
146
|
+
payloads.forEach(visit);
|
|
147
|
+
return nodes;
|
|
148
|
+
}
|
|
149
|
+
function digits(value) {
|
|
150
|
+
return String(value ?? '').replace(/\D/g, '').replace(/^61/, '0');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The structured-data rules a crawler can check on one page: no FAQPage or
|
|
154
|
+
* HowTo, no rating on the business itself, a WebSite node with name and url on
|
|
155
|
+
* the homepage, and the business name and telephone matching the facts record.
|
|
156
|
+
*/
|
|
157
|
+
export function structuredDataProblems(payloads, options) {
|
|
158
|
+
const problems = [];
|
|
159
|
+
const nodes = collectNodes(payloads);
|
|
160
|
+
const businessId = options.businessId === undefined ? `${options.origin}/#business` : options.businessId;
|
|
161
|
+
for (const node of nodes) {
|
|
162
|
+
const types = schemaTypes(node);
|
|
163
|
+
const retired = types.find((type) => RETIRED_RICH_RESULT_TYPES.has(type));
|
|
164
|
+
if (retired)
|
|
165
|
+
problems.push(`JSON-LD declares ${retired}; FAQ and HowTo markup is no longer used`);
|
|
166
|
+
const isBusiness = types.some((type) => BUSINESS_SCHEMA_TYPES.has(type)) || (businessId !== false && node['@id'] === businessId && types.length > 0);
|
|
167
|
+
if (!isBusiness)
|
|
168
|
+
continue;
|
|
169
|
+
if (node.aggregateRating !== undefined || node.review !== undefined || node.reviews !== undefined) {
|
|
170
|
+
problems.push(`JSON-LD rates the business itself (${String(node['@id'] ?? types.join('/'))} has ${node.aggregateRating !== undefined ? 'aggregateRating' : 'review'}); stars are not allowed for the business's own rating`);
|
|
171
|
+
}
|
|
172
|
+
if (options.business && (businessId === false || node['@id'] === businessId)) {
|
|
173
|
+
const { name, telephone } = options.business;
|
|
174
|
+
if (name && typeof node.name === 'string' && node.name.trim() !== name.trim()) {
|
|
175
|
+
problems.push(`JSON-LD business name "${node.name}" does not match the business facts ("${name}")`);
|
|
176
|
+
}
|
|
177
|
+
if (telephone && node.telephone !== undefined && digits(node.telephone) !== digits(telephone)) {
|
|
178
|
+
problems.push(`JSON-LD business telephone ${String(node.telephone)} does not match the business facts (${telephone})`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (options.home) {
|
|
183
|
+
const website = nodes.find((node) => schemaTypes(node).includes('WebSite'));
|
|
184
|
+
const expected = new URL('/', options.origin).href;
|
|
185
|
+
if (!website) {
|
|
186
|
+
problems.push('the homepage JSON-LD has no WebSite node (name and url) for the site name');
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
if (typeof website.name !== 'string' || !website.name.trim())
|
|
190
|
+
problems.push('the homepage WebSite node has no name');
|
|
191
|
+
const url = typeof website.url === 'string' ? safeHref(website.url, options.origin) : null;
|
|
192
|
+
if (!url || url.replace(/\/$/, '') !== expected.replace(/\/$/, '')) {
|
|
193
|
+
problems.push(`the homepage WebSite node's url is ${String(website.url ?? 'missing')} (expected ${expected})`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return [...new Set(problems)];
|
|
198
|
+
}
|
|
199
|
+
function safeHref(value, base) {
|
|
200
|
+
try {
|
|
201
|
+
return new URL(value, base).href;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export function parseSitemapEntries(xml) {
|
|
208
|
+
return [...xml.matchAll(/<url>([\s\S]*?)<\/url>/gi)].flatMap((match) => {
|
|
209
|
+
const loc = /<loc>\s*([^<\s]+)\s*<\/loc>/i.exec(match[1])?.[1];
|
|
210
|
+
if (!loc)
|
|
211
|
+
return [];
|
|
212
|
+
const lastmod = /<lastmod>\s*([^<\s]+)\s*<\/lastmod>/i.exec(match[1])?.[1];
|
|
213
|
+
return [{ url: new URL(decodeXml(loc)).href, ...(lastmod ? { lastmod: decodeXml(lastmod) } : {}) }];
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Whether the sitemap is split by page family (services, locations,
|
|
218
|
+
* service-in-suburb, blog, projects and the rest) once the catalogue's
|
|
219
|
+
* indexable pages span more than one family, each file holding one family.
|
|
220
|
+
*/
|
|
221
|
+
export function sitemapFamilyProblems(input) {
|
|
222
|
+
const familyOf = new Map();
|
|
223
|
+
for (const route of input.routes) {
|
|
224
|
+
if (route.indexing !== 'index')
|
|
225
|
+
continue;
|
|
226
|
+
familyOf.set(new URL(route.canonicalPath ?? route.path, `${input.origin}/`).href, sitemapFamilyForKind(route.kind ?? ''));
|
|
227
|
+
}
|
|
228
|
+
const families = new Set(familyOf.values());
|
|
229
|
+
if (families.size <= 1)
|
|
230
|
+
return [];
|
|
231
|
+
if (!input.index) {
|
|
232
|
+
return [`the sitemap is one file for ${families.size} page families (${[...families].sort().join(', ')}); split it by family (brandlab.config sitemap.mode 'split') so Search Console reports each family's coverage`];
|
|
233
|
+
}
|
|
234
|
+
const problems = [];
|
|
235
|
+
for (const file of input.files) {
|
|
236
|
+
const inFile = new Set(file.entries.map((entry) => familyOf.get(entry.url)).filter((family) => Boolean(family)));
|
|
237
|
+
if (inFile.size > 1)
|
|
238
|
+
problems.push(`${file.path} mixes page families (${[...inFile].sort().join(', ')}); each sitemap file holds one family`);
|
|
239
|
+
}
|
|
240
|
+
return problems;
|
|
241
|
+
}
|
|
242
|
+
/** Hours before the run in which a shared timestamp looks like the build, not a content change. */
|
|
243
|
+
const BUILD_WINDOW_HOURS = 48;
|
|
244
|
+
/**
|
|
245
|
+
* `lastmod` must date the content, never the build: it equals the route's
|
|
246
|
+
* `lastSignificantChange` (and is absent without one), is never in the future,
|
|
247
|
+
* and pages do not share one fresh timestamp.
|
|
248
|
+
*/
|
|
249
|
+
export function sitemapLastmodProblems(entries, routes, origin, now = Date.now()) {
|
|
250
|
+
const routeByUrl = new Map(routes.map((route) => [new URL(route.canonicalPath ?? route.path, `${origin}/`).href, route]));
|
|
251
|
+
const problems = [];
|
|
252
|
+
const recentTimestamps = new Map();
|
|
253
|
+
for (const entry of entries) {
|
|
254
|
+
if (!entry.lastmod)
|
|
255
|
+
continue;
|
|
256
|
+
const time = Date.parse(entry.lastmod);
|
|
257
|
+
if (Number.isNaN(time)) {
|
|
258
|
+
problems.push(`${entry.url}: lastmod "${entry.lastmod}" is not a date`);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (time > now + 24 * 3_600_000)
|
|
262
|
+
problems.push(`${entry.url}: lastmod ${entry.lastmod} is in the future`);
|
|
263
|
+
const route = routeByUrl.get(entry.url);
|
|
264
|
+
if (route && !route.lastSignificantChange) {
|
|
265
|
+
problems.push(`${entry.url}: lastmod ${entry.lastmod} without a content date (the route has no lastSignificantChange)`);
|
|
266
|
+
}
|
|
267
|
+
else if (route?.lastSignificantChange && Date.parse(route.lastSignificantChange) !== time) {
|
|
268
|
+
problems.push(`${entry.url}: lastmod ${entry.lastmod} is not the route's content date ${route.lastSignificantChange}`);
|
|
269
|
+
}
|
|
270
|
+
if (/T\d{2}:\d{2}/.test(entry.lastmod) && now - time >= 0 && now - time < BUILD_WINDOW_HOURS * 3_600_000) {
|
|
271
|
+
recentTimestamps.set(entry.lastmod, (recentTimestamps.get(entry.lastmod) ?? 0) + 1);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const [timestamp, count] of recentTimestamps) {
|
|
275
|
+
if (count > 1)
|
|
276
|
+
problems.push(`${count} URLs share the lastmod ${timestamp}, a timestamp from the last ${BUILD_WINDOW_HOURS} hours: lastmod must be the content date, not the build time`);
|
|
277
|
+
}
|
|
278
|
+
return problems;
|
|
279
|
+
}
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// HTML structure: main content, link groups and text blocks
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
const VOID_ELEMENTS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
|
|
284
|
+
const LIST_ELEMENTS = new Set(['ul', 'ol']);
|
|
285
|
+
const CONTAINER_ELEMENTS = new Set(['div', 'section', 'aside', 'article', 'p', 'dl', 'table', 'tbody', 'nav', 'main', 'figure']);
|
|
286
|
+
const CHROME_ELEMENTS = new Set(['header', 'footer', 'nav']);
|
|
287
|
+
function stripInert(html) {
|
|
288
|
+
return html.replace(/<(script|style|template|noscript|svg)\b[\s\S]*?<\/\1>/gi, ' ').replace(/<!--[\s\S]*?-->/g, ' ');
|
|
289
|
+
}
|
|
290
|
+
/** The page's main content: `<main>` when present, else the body. */
|
|
291
|
+
export function mainContent(html) {
|
|
292
|
+
const cleaned = stripInert(html);
|
|
293
|
+
const main = /<main\b[^>]*>([\s\S]*)<\/main>/i.exec(cleaned)?.[1];
|
|
294
|
+
if (main !== undefined)
|
|
295
|
+
return main;
|
|
296
|
+
return /<body\b[^>]*>([\s\S]*?)(?:<\/body>|$)/i.exec(cleaned)?.[1] ?? cleaned;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The links of the main content grouped by the list (or, outside a list, the
|
|
300
|
+
* nearest container) that holds them, in document order; links inside a
|
|
301
|
+
* header, footer or navigation block are left out.
|
|
302
|
+
*/
|
|
303
|
+
export function extractLinkGroups(html) {
|
|
304
|
+
const content = mainContent(html);
|
|
305
|
+
const stack = [];
|
|
306
|
+
const groups = new Map();
|
|
307
|
+
let nextId = 1;
|
|
308
|
+
for (const match of content.matchAll(/<(\/?)([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*)>/g)) {
|
|
309
|
+
const closing = match[1] === '/';
|
|
310
|
+
const tag = match[2].toLowerCase();
|
|
311
|
+
const attributes = match[3] ?? '';
|
|
312
|
+
if (closing) {
|
|
313
|
+
const index = stack.map((element) => element.tag).lastIndexOf(tag);
|
|
314
|
+
if (index >= 0)
|
|
315
|
+
stack.length = index;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (tag === 'a') {
|
|
319
|
+
const href = /\bhref=(?:"([^"]*)"|'([^']*)')/i.exec(attributes);
|
|
320
|
+
if (href && !stack.some((element) => CHROME_ELEMENTS.has(element.tag))) {
|
|
321
|
+
const holder = [...stack].reverse().find((element) => LIST_ELEMENTS.has(element.tag)) ??
|
|
322
|
+
[...stack].reverse().find((element) => CONTAINER_ELEMENTS.has(element.tag));
|
|
323
|
+
const key = holder?.id ?? 0;
|
|
324
|
+
if (!groups.has(key))
|
|
325
|
+
groups.set(key, []);
|
|
326
|
+
groups.get(key).push(decodeXml(href[1] ?? href[2] ?? ''));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (VOID_ELEMENTS.has(tag) || attributes.trimEnd().endsWith('/'))
|
|
330
|
+
continue;
|
|
331
|
+
stack.push({ tag, id: nextId++ });
|
|
332
|
+
}
|
|
333
|
+
return [...groups.values()];
|
|
334
|
+
}
|
|
335
|
+
/** The most links a list may hold and still be a nearby list rather than a directory. */
|
|
336
|
+
const NEARBY_LIST_MAX = 12;
|
|
337
|
+
/**
|
|
338
|
+
* Nearby lists on a location page must be ranked by distance: any list of 3 to
|
|
339
|
+
* 12 links to other places with a centre is checked; a directory longer than
|
|
340
|
+
* that is not a nearby list.
|
|
341
|
+
*/
|
|
342
|
+
export function neighbourOrderProblems(input) {
|
|
343
|
+
const problems = [];
|
|
344
|
+
const base = new URL(input.pageUrl);
|
|
345
|
+
for (const group of extractLinkGroups(input.html)) {
|
|
346
|
+
const places = [];
|
|
347
|
+
for (const href of group) {
|
|
348
|
+
let url;
|
|
349
|
+
try {
|
|
350
|
+
url = new URL(href, base);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (url.origin !== base.origin)
|
|
356
|
+
continue;
|
|
357
|
+
const place = input.placeOf(normalisePath(url.pathname));
|
|
358
|
+
if (place && !places.includes(place))
|
|
359
|
+
places.push(place);
|
|
360
|
+
}
|
|
361
|
+
const located = places.filter((place) => place !== input.origin && input.centreOf(place));
|
|
362
|
+
if (located.length < 3 || places.length > NEARBY_LIST_MAX)
|
|
363
|
+
continue;
|
|
364
|
+
const problem = findNeighbourOrderProblem(input.origin, located, input.centreOf);
|
|
365
|
+
if (problem)
|
|
366
|
+
problems.push(`nearby list [${located.join(', ')}] is not ranked by distance: ${problem.detail}`);
|
|
367
|
+
}
|
|
368
|
+
return problems;
|
|
369
|
+
}
|
|
370
|
+
const BLOCK_TAGS = /<\/?(?:p|li|h[1-6]|td|th|dt|dd|blockquote|figcaption|caption|div|section|article|aside|br|tr|summary|details|label|button)\b[^>]*>/gi;
|
|
371
|
+
/** The main content's text blocks (header, footer and navigation removed), lower-cased with whitespace collapsed. */
|
|
372
|
+
export function textBlocks(html) {
|
|
373
|
+
let content = mainContent(html);
|
|
374
|
+
for (const tag of CHROME_ELEMENTS)
|
|
375
|
+
content = content.replace(new RegExp(`<${tag}\\b[\\s\\S]*?<\\/${tag}>`, 'gi'), ' ');
|
|
376
|
+
return decodeXml(content.replace(BLOCK_TAGS, '\n').replace(/<[^>]+>/g, ' '))
|
|
377
|
+
.replace(/ | /g, ' ')
|
|
378
|
+
.split('\n')
|
|
379
|
+
.map((block) => block.toLowerCase().replace(/\s+/g, ' ').trim())
|
|
380
|
+
.filter((block) => block.split(' ').length >= 3);
|
|
381
|
+
}
|
|
382
|
+
/** Masks place names and postcodes so name-swapped copy compares as the same text. */
|
|
383
|
+
export function createPlaceMask(names) {
|
|
384
|
+
const unique = [...new Set(names.map((name) => name.trim().toLowerCase()).filter((name) => name.length >= 3))]
|
|
385
|
+
.sort((a, b) => b.length - a.length)
|
|
386
|
+
.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+'));
|
|
387
|
+
const pattern = unique.length ? new RegExp(`\\b(?:${unique.join('|')})\\b`, 'g') : null;
|
|
388
|
+
return (text) => (pattern ? text.replace(pattern, '{place}') : text).replace(/\b2\d{3}\b/g, '{postcode}');
|
|
389
|
+
}
|
|
390
|
+
const SHINGLE = 5;
|
|
391
|
+
function shingles(words) {
|
|
392
|
+
const result = new Set();
|
|
393
|
+
for (let index = 0; index + SHINGLE <= words.length; index += 1)
|
|
394
|
+
result.add(words.slice(index, index + SHINGLE).join(' '));
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
function words(text) {
|
|
398
|
+
return text.replace(/[^a-z0-9{}' -]+/g, ' ').split(/\s+/).filter(Boolean);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Centerpiece similarity: shared blocks (on half or more of the location pages)
|
|
402
|
+
* are removed, place names are masked, and each page's five-word phrases are
|
|
403
|
+
* compared with its three nearest siblings that have a centre. Pages without a
|
|
404
|
+
* centre are not scored.
|
|
405
|
+
*/
|
|
406
|
+
export function centerpieceSimilarity(pages, centreOf, siblings = 3) {
|
|
407
|
+
const located = pages.filter((page) => centreOf(page.place));
|
|
408
|
+
if (located.length < 2)
|
|
409
|
+
return [];
|
|
410
|
+
const frequency = new Map();
|
|
411
|
+
for (const page of located)
|
|
412
|
+
for (const block of new Set(page.blocks))
|
|
413
|
+
frequency.set(block, (frequency.get(block) ?? 0) + 1);
|
|
414
|
+
const threshold = Math.max(2, Math.ceil(located.length / 2));
|
|
415
|
+
const prepared = located.map((page) => {
|
|
416
|
+
const unique = page.blocks.filter((block) => (frequency.get(block) ?? 0) < threshold);
|
|
417
|
+
const tokens = unique.flatMap(words);
|
|
418
|
+
return { page, tokens, shingles: shingles(tokens), centre: centreOf(page.place) };
|
|
419
|
+
});
|
|
420
|
+
return prepared.map((current) => {
|
|
421
|
+
const nearest = prepared
|
|
422
|
+
.filter((other) => other !== current && other.page.place !== current.page.place)
|
|
423
|
+
.map((other) => ({ other, distance: Math.hypot(other.centre.latitude - current.centre.latitude, (other.centre.longitude - current.centre.longitude) * Math.cos((current.centre.latitude * Math.PI) / 180)) }))
|
|
424
|
+
.sort((a, b) => a.distance - b.distance)
|
|
425
|
+
.slice(0, siblings);
|
|
426
|
+
if (current.shingles.size === 0) {
|
|
427
|
+
return { path: current.page.path, score: nearest.length ? 1 : 0, sibling: nearest[0]?.other.page.path ?? null, uniqueWords: current.tokens.length };
|
|
428
|
+
}
|
|
429
|
+
let best = { score: 0, sibling: null };
|
|
430
|
+
for (const { other } of nearest) {
|
|
431
|
+
let shared = 0;
|
|
432
|
+
for (const phrase of current.shingles)
|
|
433
|
+
if (other.shingles.has(phrase))
|
|
434
|
+
shared += 1;
|
|
435
|
+
const score = shared / current.shingles.size;
|
|
436
|
+
if (score > best.score)
|
|
437
|
+
best = { score, sibling: other.page.path };
|
|
438
|
+
}
|
|
439
|
+
return { path: current.page.path, score: Math.round(best.score * 1000) / 1000, sibling: best.sibling, uniqueWords: current.tokens.length };
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
/** Similarity at or above this is reported as a warning. */
|
|
443
|
+
export const SIMILARITY_WARN = 0.5;
|
|
444
|
+
/** Similarity at or above this puts the page on the review list; it stays indexed. */
|
|
445
|
+
export const SIMILARITY_REVIEW = 0.7;
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// Titles, headings, descriptions and page weight
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
export const TITLE_MAX_LENGTH = 60;
|
|
450
|
+
export const DESCRIPTION_MIN_LENGTH = 70;
|
|
451
|
+
export const DESCRIPTION_MAX_LENGTH = 170;
|
|
452
|
+
export const HTML_MAX_BYTES = 1_048_576;
|
|
453
|
+
/** Title format warnings: over 60 characters, more than one pipe, brackets, emoji, capitals or "near me". */
|
|
454
|
+
export function titleFormatProblems(title) {
|
|
455
|
+
const problems = [];
|
|
456
|
+
const length = [...title].length;
|
|
457
|
+
if (length > TITLE_MAX_LENGTH)
|
|
458
|
+
problems.push(`${length} characters (keep to about ${TITLE_MAX_LENGTH})`);
|
|
459
|
+
const pipes = title.split('|').length - 1;
|
|
460
|
+
if (pipes > 1)
|
|
461
|
+
problems.push(`${pipes} pipes (use one hyphen or colon as the separator)`);
|
|
462
|
+
if (/[[\](){}]/.test(title))
|
|
463
|
+
problems.push('brackets');
|
|
464
|
+
if (/\p{Extended_Pictographic}/u.test(title))
|
|
465
|
+
problems.push('emoji');
|
|
466
|
+
const letters = title.replace(/[^A-Za-z]/g, '');
|
|
467
|
+
if (letters.length >= 6 && letters === letters.toUpperCase())
|
|
468
|
+
problems.push('all capitals');
|
|
469
|
+
else if (/\b[A-Z]{5,}\b/.test(title))
|
|
470
|
+
problems.push(`capitalised word ${/\b[A-Z]{5,}\b/.exec(title)[0]}`);
|
|
471
|
+
if (/\bnear (?:me|you)\b/i.test(title))
|
|
472
|
+
problems.push('"near me" wording');
|
|
473
|
+
return problems;
|
|
474
|
+
}
|
|
475
|
+
const STOP_WORDS = new Set(['a', 'an', 'and', 'the', 'in', 'for', 'of', 'to', 'on', 'at', 'by', 'with', 'our', 'your', 'from', 'we', 'you', 'is', 'are', 'best', 'local', 'sydney-wide']);
|
|
476
|
+
function contentWords(text) {
|
|
477
|
+
return text
|
|
478
|
+
.toLowerCase()
|
|
479
|
+
.replace(/&/g, ' and ')
|
|
480
|
+
.replace(/[^a-z0-9' ]+/g, ' ')
|
|
481
|
+
.split(/\s+/)
|
|
482
|
+
.map((word) => word.replace(/'s$|'$/, '').replace(/(?<=[a-z]{3})s$/, ''))
|
|
483
|
+
.filter((word) => word && !STOP_WORDS.has(word));
|
|
484
|
+
}
|
|
485
|
+
/** The title's primary phrase: the part before its first separator. */
|
|
486
|
+
export function primaryTitlePhrase(title) {
|
|
487
|
+
return title.split(/\s+[|–—-]\s+|:\s+/)[0].trim();
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Whether the title and the H1 share the page's primary phrase: half or more of
|
|
491
|
+
* the title's primary words appear in the H1, and the page's place (when it has
|
|
492
|
+
* one) appears in both.
|
|
493
|
+
*/
|
|
494
|
+
export function titleHeadingProblems(title, h1, place) {
|
|
495
|
+
const primary = contentWords(primaryTitlePhrase(title));
|
|
496
|
+
const heading = new Set(contentWords(h1));
|
|
497
|
+
const problems = [];
|
|
498
|
+
if (primary.length > 0) {
|
|
499
|
+
const shared = primary.filter((word) => heading.has(word)).length;
|
|
500
|
+
if (shared / primary.length < 0.5)
|
|
501
|
+
problems.push(`the H1 "${h1}" does not say what the title's primary phrase "${primaryTitlePhrase(title)}" says`);
|
|
502
|
+
}
|
|
503
|
+
if (place) {
|
|
504
|
+
const pattern = new RegExp(`\\b${place.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+')}\\b`);
|
|
505
|
+
if (!pattern.test(title.toLowerCase()))
|
|
506
|
+
problems.push(`the title does not name ${place}`);
|
|
507
|
+
if (!pattern.test(h1.toLowerCase()))
|
|
508
|
+
problems.push(`the H1 does not name ${place}`);
|
|
509
|
+
}
|
|
510
|
+
return problems;
|
|
511
|
+
}
|
|
512
|
+
export function descriptionLengthProblem(description) {
|
|
513
|
+
const length = [...description].length;
|
|
514
|
+
if (length < DESCRIPTION_MIN_LENGTH)
|
|
515
|
+
return `${length} characters (aim for ${DESCRIPTION_MIN_LENGTH}–${DESCRIPTION_MAX_LENGTH})`;
|
|
516
|
+
if (length > DESCRIPTION_MAX_LENGTH)
|
|
517
|
+
return `${length} characters (aim for ${DESCRIPTION_MIN_LENGTH}–${DESCRIPTION_MAX_LENGTH})`;
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
/** The text of every H1 in the server HTML. */
|
|
521
|
+
export function headingTexts(html) {
|
|
522
|
+
return [...stripInert(html).matchAll(/<h1\b[^>]*>([\s\S]*?)<\/h1>/gi)].map((match) => decodeXml(match[1].replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim());
|
|
523
|
+
}
|
|
524
|
+
export function normalisePath(path) {
|
|
525
|
+
const pathname = path.split(/[?#]/)[0] || '/';
|
|
526
|
+
return pathname.length > 1 ? pathname.replace(/\/+$/, '') : '/';
|
|
527
|
+
}
|
|
528
|
+
export function decodeXml(value) {
|
|
529
|
+
return value
|
|
530
|
+
.replace(/&/g, '&')
|
|
531
|
+
.replace(/</g, '<')
|
|
532
|
+
.replace(/>/g, '>')
|
|
533
|
+
.replace(/"/g, '"')
|
|
534
|
+
.replace(/'|'|'/g, "'");
|
|
535
|
+
}
|
|
536
|
+
//# sourceMappingURL=page-standard.js.map
|