@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.
@@ -1 +1 @@
1
- {"version":3,"file":"estate.d.ts","sourceRoot":"","sources":["../src/estate.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"estate.d.ts","sourceRoot":"","sources":["../src/estate.ts"],"names":[],"mappings":"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"}
package/dist/estate.js CHANGED
@@ -10,16 +10,39 @@
10
10
  * defineBrandlabEstateTests({ site, routes, redirects })
11
11
  *
12
12
  * It asserts the estate and its routes: the sitemap, each route's status,
13
- * canonical and indexing, internal links, redirects, unknown slugs, the page
14
- * families rendered on request and robots.txt. Page and structured-data
13
+ * canonical, indexing and breadcrumb JSON-LD (every item but the last names its
14
+ * page, from 3.3.3), internal links, redirects, unknown slugs, the page families
15
+ * rendered on request and robots.txt. Page and structured-data
15
16
  * quality is reported, never enforced,
16
17
  * by defineBrandlabPageQualityTests: a backend release keeps production's pages
17
18
  * as they are, which the comparison harness proves. Counts are always derived
18
19
  * from the current catalogue, never a historic constant.
20
+ *
21
+ * From 3.4.0 (the Brandlab on-page and location standard) the estate journey
22
+ * also fails on: a sitemap that is not split by page family once the catalogue
23
+ * spans families, or whose lastmod is not the route's content date; an
24
+ * indexable page without a title, meta description or JSON-LD in its server HTML; FAQPage or
25
+ * HowTo markup, or a rating on the business itself; a homepage without a
26
+ * WebSite node (name and url); an unknown slug that does not answer 404 to GET
27
+ * and HEAD; a nearby list on a location page that is not ranked by distance
28
+ * between suburb centres; and a catalogue that adds indexable location pages
29
+ * beyond the recorded capacity (`locationCapacity`). The page-quality journey
30
+ * reports title, heading, description and HTML-size warnings, orphan pages,
31
+ * links from indexable pages to noindex location pages, centerpiece
32
+ * similarity (warn at 0.5, review list at 0.7) and the location review list
33
+ * (location-review.json); pages on that list stay indexed.
19
34
  */
20
35
  import { request as httpRequest } from 'node:http';
21
36
  import { expect, test } from '@playwright/test';
37
+ import { buildLocationReviewList, centerpieceSimilarity, checkLocationCapacity, descriptionLengthProblem, HTML_MAX_BYTES, headingTexts, loadBrandlabGeography, neighbourOrderProblems, parseSitemapEntries, SIMILARITY_REVIEW, SIMILARITY_WARN, sitemapFamilyProblems, sitemapLastmodProblems, structuredDataProblems, textBlocks, titleFormatProblems, titleHeadingProblems, } from './page-standard.js';
22
38
  import { extractSearchCertificationHtmlSnapshot } from './search-certification.js';
39
+ const LOCATION_KINDS = new Set(['location', 'service-location']);
40
+ /** A location route's place slug: the last segment of its path. */
41
+ function routePlace(route) {
42
+ if (!route || !LOCATION_KINDS.has(route.kind ?? ''))
43
+ return undefined;
44
+ return normalisePath(route.path).split('/').filter(Boolean).at(-1);
45
+ }
23
46
  const BUSINESS_TYPES = new Set([
24
47
  'LocalBusiness', 'HomeAndConstructionBusiness', 'GeneralContractor', 'Plumber', 'Electrician',
25
48
  'RoofingContractor', 'HousePainter', 'Locksmith', 'MovingCompany', 'HVACBusiness', 'ProfessionalService',
@@ -43,6 +66,8 @@ export function defineBrandlabEstateTests(options) {
43
66
  const timeout = options.timeout ?? 240_000;
44
67
  const families = options.families ?? [];
45
68
  const familyPaths = new Set(families.flatMap((family) => family.paths.map(normalisePath)));
69
+ const routeByPath = new Map(routes.map((route) => [normalisePath(route.path), route]));
70
+ const homePath = normalisePath(routes.find((route) => route.kind === 'home')?.path ?? '/');
46
71
  test.describe('Brandlab technical estate', () => {
47
72
  test('the sitemap lists exactly the indexable catalogue routes', async ({ request }) => {
48
73
  test.setTimeout(timeout);
@@ -53,8 +78,27 @@ export function defineBrandlabEstateTests(options) {
53
78
  expect(listed.length, 'the sitemap repeats a URL').toBe(new Set(listed).size);
54
79
  expect([...listed].sort()).toEqual([...new Set(expected)].sort());
55
80
  });
81
+ test('the sitemap is split by page family and dated by content, never by the build', async ({ request }, testInfo) => {
82
+ test.setTimeout(timeout);
83
+ const { index, files } = await readSitemapFiles(request, '/sitemap.xml', origin);
84
+ const problems = [
85
+ ...sitemapFamilyProblems({ index, files, routes, origin }),
86
+ ...sitemapLastmodProblems(files.flatMap((file) => file.entries), routes, origin),
87
+ ];
88
+ await testInfo.attach('sitemap-files.json', {
89
+ body: JSON.stringify(files.map((file) => ({ path: file.path, urls: file.entries.length })), null, 2),
90
+ contentType: 'application/json',
91
+ });
92
+ expect(problems).toEqual([]);
93
+ });
94
+ test('location pages stay within the recorded capacity', async ({}, testInfo) => {
95
+ const result = checkLocationCapacity(routes, options.locationCapacity);
96
+ testInfo.annotations.push({ type: result.limit === null ? 'warning' : 'location-capacity', description: result.detail });
97
+ expect(result.blocked, result.detail).toBe(false);
98
+ });
56
99
  test('every catalogue route serves its canonical identity and links only to live pages', async ({ request }, testInfo) => {
57
100
  test.setTimeout(timeout);
101
+ const centreOf = options.centreOf ?? (await loadBrandlabGeography()).centreOf;
58
102
  const failures = [];
59
103
  const internalLinks = new Map();
60
104
  const checked = [];
@@ -90,6 +134,36 @@ export function defineBrandlabEstateTests(options) {
90
134
  failures.push(`${route.path}: indexable route is noindex`);
91
135
  if (route.indexing !== 'index' && !/noindex/i.test(robots))
92
136
  failures.push(`${route.path}: ${route.indexing} route is indexable`);
137
+ for (const problem of breadcrumbListProblems(snapshot.jsonLd))
138
+ failures.push(`${route.path}: ${problem}`);
139
+ if (route.indexing === 'index') {
140
+ if (!snapshot.title)
141
+ failures.push(`${route.path}: no <title> in the server HTML`);
142
+ if (!snapshot.description)
143
+ failures.push(`${route.path}: no meta description in the server HTML`);
144
+ if (snapshot.jsonLd.length === 0)
145
+ failures.push(`${route.path}: no JSON-LD in the server HTML`);
146
+ }
147
+ if (snapshot.jsonLd.some((payload) => payload.parseError))
148
+ failures.push(`${route.path}: invalid JSON-LD`);
149
+ for (const problem of structuredDataProblems(snapshot.jsonLd, {
150
+ origin,
151
+ home: normalisePath(route.path) === homePath,
152
+ businessId: options.businessId,
153
+ business: options.business,
154
+ }))
155
+ failures.push(`${route.path}: ${problem}`);
156
+ const place = routePlace(route);
157
+ if (place && centreOf(place)) {
158
+ for (const problem of neighbourOrderProblems({
159
+ html,
160
+ origin: place,
161
+ pageUrl: expectedCanonical,
162
+ placeOf: (path) => routePlace(routeByPath.get(path)),
163
+ centreOf,
164
+ }))
165
+ failures.push(`${route.path}: ${problem}`);
166
+ }
93
167
  for (const href of extractLinks(html)) {
94
168
  const path = internalPath(href, expectedCanonical, origin);
95
169
  if (path && !internalLinks.has(path))
@@ -213,9 +287,14 @@ export function defineBrandlabEstateTests(options) {
213
287
  testInfo.annotations.push({ type: 'skipped', description: `${path}: a redirect rule answers it` });
214
288
  const failures = [];
215
289
  for (const path of paths) {
216
- const response = await request.get(path, { maxRedirects: 0 });
217
- if (response.status() !== 404)
218
- failures.push(`${path}: ${response.status()} (expected 404)`);
290
+ const [get, head] = await Promise.all([
291
+ request.get(path, { maxRedirects: 0 }),
292
+ request.head(path, { maxRedirects: 0 }),
293
+ ]);
294
+ if (get.status() !== 404)
295
+ failures.push(`${path}: GET ${get.status()} (expected 404)`);
296
+ if (head.status() !== 404)
297
+ failures.push(`${path}: HEAD ${head.status()} (expected 404)`);
219
298
  }
220
299
  expect(failures).toEqual([]);
221
300
  });
@@ -237,6 +316,43 @@ export function defineBrandlabEstateTests(options) {
237
316
  });
238
317
  });
239
318
  }
319
+ /**
320
+ * Why a page's BreadcrumbList JSON-LD would fail Google's breadcrumb rich result (3.3.3): every
321
+ * ListItem but the last needs its `item` (the page it names), as the shared builder requires;
322
+ * Google reports "Missing field item" otherwise. Lists inside an `@graph` count; an empty list
323
+ * has nothing to judge.
324
+ */
325
+ export function breadcrumbListProblems(jsonLd) {
326
+ const lists = [];
327
+ const collect = (value) => {
328
+ if (Array.isArray(value)) {
329
+ value.forEach(collect);
330
+ return;
331
+ }
332
+ if (!value || typeof value !== 'object')
333
+ return;
334
+ const node = value;
335
+ const type = node['@type'];
336
+ if (type === 'BreadcrumbList' || (Array.isArray(type) && type.includes('BreadcrumbList')))
337
+ lists.push(node);
338
+ if (Array.isArray(node['@graph']))
339
+ collect(node['@graph']);
340
+ };
341
+ collect(jsonLd);
342
+ const problems = [];
343
+ for (const list of lists) {
344
+ const items = Array.isArray(list.itemListElement) ? list.itemListElement : [];
345
+ items.slice(0, -1).forEach((entry, index) => {
346
+ const item = entry && typeof entry === 'object' ? entry.item : undefined;
347
+ const named = typeof item === 'string' ? item.trim() : item && typeof item === 'object' ? String(item['@id'] ?? item.url ?? '').trim() : '';
348
+ if (named)
349
+ return;
350
+ const name = entry && typeof entry === 'object' ? entry.name : undefined;
351
+ problems.push(`BreadcrumbList item ${index + 1}${typeof name === 'string' && name.trim() ? ` ("${name.trim()}")` : ''} has no URL (item); only the last breadcrumb may omit it, or Google rejects the list`);
352
+ });
353
+ }
354
+ return problems;
355
+ }
240
356
  /** One response header for a request that names its own Host (the Playwright request context always sends the server's). */
241
357
  export function responseHeader(server, host, path, name) {
242
358
  return new Promise((resolve, reject) => {
@@ -275,6 +391,29 @@ export async function readSitemap(request, path, origin) {
275
391
  }
276
392
  return nested;
277
393
  }
394
+ /**
395
+ * The sitemap's URL files with their entries: the sitemap itself, or every
396
+ * file a sitemap index lists (one level, same origin, no redirects).
397
+ */
398
+ export async function readSitemapFiles(request, path, origin) {
399
+ const read = async (target) => {
400
+ const response = await request.get(target, { maxRedirects: 0 });
401
+ if (response.status() !== 200)
402
+ throw new Error(`${target} answers ${response.status()}; a sitemap must answer 200 without redirecting.`);
403
+ return response.text();
404
+ };
405
+ const xml = await read(path);
406
+ if (!/<sitemapindex\b/i.test(xml))
407
+ return { index: false, files: [{ path, entries: parseSitemapEntries(xml) }] };
408
+ const files = [];
409
+ for (const location of extractSitemapLocations(xml)) {
410
+ const url = new URL(location);
411
+ if (url.origin !== new URL(origin).origin)
412
+ throw new Error(`The sitemap index lists ${location}, which is not on ${new URL(origin).origin}.`);
413
+ files.push({ path: url.pathname, entries: parseSitemapEntries(await read(`${url.pathname}${url.search}`)) });
414
+ }
415
+ return { index: true, files };
416
+ }
278
417
  export function extractSitemapLocations(xml) {
279
418
  return [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map((match) => new URL(decodeXml(match[1])).href);
280
419
  }
@@ -384,10 +523,17 @@ export function defineBrandlabPageQualityTests(options) {
384
523
  const businessId = options.businessId ?? `${origin}/#business`;
385
524
  const timeout = options.timeout ?? 240_000;
386
525
  const routes = options.routes.filter((route) => normalisePath(route.path) !== NOT_FOUND_ROUTE);
526
+ const routeByPath = new Map(routes.map((route) => [normalisePath(route.path), route]));
387
527
  test.describe('Brandlab page quality (advisory)', () => {
388
528
  test('reports page and structured-data quality without failing', async ({ request }, testInfo) => {
389
529
  test.setTimeout(timeout);
390
530
  const findings = [];
531
+ const geography = await loadBrandlabGeography();
532
+ const centreOf = options.centreOf ?? geography.centreOf;
533
+ const titles = new Map();
534
+ const descriptions = new Map();
535
+ const similarityPages = [];
536
+ const linksFrom = new Map();
391
537
  await eachWithConcurrency(routes, options.concurrency ?? 8, async (route) => {
392
538
  const response = await request.get(route.path, { maxRedirects: 0 });
393
539
  // The estate journey owns status codes; quality is read from the pages that load.
@@ -396,13 +542,35 @@ export function defineBrandlabPageQualityTests(options) {
396
542
  const html = await response.text();
397
543
  const snapshot = extractSearchCertificationHtmlSnapshot(html);
398
544
  const add = (kind, detail) => findings.push({ path: route.path, kind, detail });
399
- const headings = html.match(/<h1(?:\s|>)/gi)?.length ?? 0;
400
- if (headings !== 1)
401
- add('headings', `${headings} h1 headings`);
545
+ const h1s = headingTexts(html);
546
+ if (h1s.length !== 1)
547
+ add('headings', `${h1s.length} h1 headings`);
402
548
  if (!snapshot.title)
403
549
  add('title', 'no title');
404
550
  if (!snapshot.description)
405
551
  add('description', 'no meta description');
552
+ const indexable = route.indexing === 'index';
553
+ if (indexable && snapshot.title) {
554
+ const formatProblems = titleFormatProblems(snapshot.title);
555
+ if (formatProblems.length > 0)
556
+ add('title-format', `"${snapshot.title}": ${formatProblems.join('; ')}`);
557
+ titles.set(snapshot.title, [...(titles.get(snapshot.title) ?? []), route.path]);
558
+ const place = routePlace(route);
559
+ if (h1s.length === 1) {
560
+ const problems = titleHeadingProblems(snapshot.title, h1s[0], place ? geography.placeNames.get(place) : undefined);
561
+ if (problems.length > 0)
562
+ add('title-h1', problems.join('; '));
563
+ }
564
+ }
565
+ if (indexable && snapshot.description) {
566
+ const problem = descriptionLengthProblem(snapshot.description);
567
+ if (problem)
568
+ add('description-length', problem);
569
+ descriptions.set(snapshot.description, [...(descriptions.get(snapshot.description) ?? []), route.path]);
570
+ }
571
+ const bytes = Buffer.byteLength(html);
572
+ if (bytes > HTML_MAX_BYTES)
573
+ add('html-size', `${(bytes / 1_048_576).toFixed(2)} MB of HTML (investigate anything over 1 MB)`);
406
574
  const expectedCanonical = canonical(route.canonicalPath ?? route.path);
407
575
  const ogUrl = snapshot.openGraph['og:url'];
408
576
  if (ogUrl && new URL(ogUrl, origin).href !== expectedCanonical)
@@ -412,13 +580,80 @@ export function defineBrandlabPageQualityTests(options) {
412
580
  for (const failure of inspectStructuredData(route, snapshot.jsonLd, businessId)) {
413
581
  add('structured-data', failure.slice(route.path.length + 2));
414
582
  }
583
+ const place = routePlace(route);
584
+ if (indexable && place)
585
+ similarityPages.push({ path: route.path, place, blocks: textBlocks(html).map(geography.maskPlaces) });
586
+ const targets = new Set();
587
+ for (const href of extractLinks(html)) {
588
+ const path = internalPath(href, expectedCanonical, origin);
589
+ if (path && path !== normalisePath(route.path))
590
+ targets.add(path);
591
+ }
592
+ linksFrom.set(normalisePath(route.path), targets);
415
593
  });
594
+ // Internal links: every indexable page needs a link from another page, and indexable pages link no noindex location page.
595
+ const inbound = new Map();
596
+ for (const [from, targets] of linksFrom) {
597
+ const source = routeByPath.get(from);
598
+ for (const target of targets) {
599
+ inbound.set(target, (inbound.get(target) ?? 0) + 1);
600
+ const linked = routeByPath.get(target);
601
+ if (source?.indexing === 'index' && linked && linked.indexing !== 'index' && LOCATION_KINDS.has(linked.kind ?? '')) {
602
+ findings.push({ path: from, kind: 'noindex-link', detail: `links to ${target}, a noindex location page (list unindexed places as plain text)` });
603
+ }
604
+ }
605
+ }
606
+ for (const route of routes) {
607
+ const path = normalisePath(route.path);
608
+ if (route.indexing === 'index' && route.kind !== 'home' && path !== '/' && linksFrom.has(path) && !inbound.get(path)) {
609
+ findings.push({ path: route.path, kind: 'orphan', detail: 'no other catalogue page links to it' });
610
+ }
611
+ }
612
+ for (const [title, paths] of titles) {
613
+ if (paths.length > 1)
614
+ for (const path of paths)
615
+ findings.push({ path, kind: 'title-duplicate', detail: `"${title}" is also the title of ${paths.filter((other) => other !== path).slice(0, 3).join(', ')}${paths.length > 4 ? ' and others' : ''}` });
616
+ }
617
+ for (const [description, paths] of descriptions) {
618
+ if (paths.length > 1)
619
+ for (const path of paths)
620
+ findings.push({ path, kind: 'description-duplicate', detail: `the meta description is shared with ${paths.length - 1} other page(s) ("${description.slice(0, 60)}…")` });
621
+ }
622
+ // Location pages: centerpiece similarity against near siblings, by route kind.
623
+ const similarity = [...new Set(similarityPages.map((page) => routeByPath.get(normalisePath(page.path))?.kind))]
624
+ .flatMap((kind) => centerpieceSimilarity(similarityPages.filter((page) => routeByPath.get(normalisePath(page.path))?.kind === kind), centreOf));
625
+ for (const score of similarity) {
626
+ if (score.score < SIMILARITY_WARN)
627
+ continue;
628
+ findings.push({
629
+ path: score.path,
630
+ kind: 'similarity',
631
+ detail: `${score.score.toFixed(2)} of its page-specific phrases also appear on ${score.sibling ?? 'a near sibling'}${score.score >= SIMILARITY_REVIEW ? ' (at or above 0.7: on the review list; the page stays indexed)' : ' (at or above 0.5: add page-specific evidence)'}`,
632
+ });
633
+ }
634
+ const reviewList = buildLocationReviewList(routes);
635
+ const flagged = new Set(similarity.filter((score) => score.score >= SIMILARITY_REVIEW).map((score) => score.path));
636
+ const review = [
637
+ ...reviewList.map((item) => ({ ...item, reasons: [...item.reasons, ...(flagged.has(item.path) ? ['centerpiece similarity at or above 0.7'] : [])] })),
638
+ ...[...flagged].filter((path) => !reviewList.some((item) => item.path === path)).map((path) => ({ path, evidence: routeByPath.get(normalisePath(path))?.locationDecision?.evidence ?? [], reasons: ['centerpiece similarity at or above 0.7'] })),
639
+ ].sort((a, b) => a.path.localeCompare(b.path));
640
+ for (const item of review)
641
+ findings.push({ path: item.path, kind: 'location-review', detail: item.reasons.join('; ') });
416
642
  findings.sort((a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind));
417
643
  const byKind = Object.fromEntries([...new Set(findings.map((finding) => finding.kind))].sort().map((kind) => [kind, findings.filter((finding) => finding.kind === kind).length]));
418
644
  await testInfo.attach('page-quality.json', {
419
645
  body: JSON.stringify({ pages: routes.length, findings: findings.length, byKind, details: findings }, null, 2),
420
646
  contentType: 'application/json',
421
647
  });
648
+ await testInfo.attach('location-review.json', {
649
+ body: JSON.stringify({
650
+ reviewed: review.length,
651
+ note: 'Pages on the review list stay indexed. Each review ends in adding evidence, merging into the nearest owner or hiding with noindex, follow; merging and hiding are Romie\'s decision.',
652
+ similarity: similarity.sort((a, b) => b.score - a.score),
653
+ review,
654
+ }, null, 2),
655
+ contentType: 'application/json',
656
+ });
422
657
  testInfo.annotations.push({
423
658
  type: 'advisory',
424
659
  description: `${findings.length} page-quality finding(s) across ${routes.length} page(s)${findings.length ? `: ${Object.entries(byKind).map(([kind, count]) => `${kind} ${count}`).join(', ')}` : ''}; see page-quality.json.`,