@brandlab-digital/conformance 3.3.3 → 3.4.1

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/estate.js CHANGED
@@ -17,10 +17,32 @@
17
17
  * by defineBrandlabPageQualityTests: a backend release keeps production's pages
18
18
  * as they are, which the comparison harness proves. Counts are always derived
19
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.
20
34
  */
21
35
  import { request as httpRequest } from 'node:http';
22
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';
23
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
+ }
24
46
  const BUSINESS_TYPES = new Set([
25
47
  'LocalBusiness', 'HomeAndConstructionBusiness', 'GeneralContractor', 'Plumber', 'Electrician',
26
48
  'RoofingContractor', 'HousePainter', 'Locksmith', 'MovingCompany', 'HVACBusiness', 'ProfessionalService',
@@ -44,6 +66,8 @@ export function defineBrandlabEstateTests(options) {
44
66
  const timeout = options.timeout ?? 240_000;
45
67
  const families = options.families ?? [];
46
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 ?? '/');
47
71
  test.describe('Brandlab technical estate', () => {
48
72
  test('the sitemap lists exactly the indexable catalogue routes', async ({ request }) => {
49
73
  test.setTimeout(timeout);
@@ -54,8 +78,27 @@ export function defineBrandlabEstateTests(options) {
54
78
  expect(listed.length, 'the sitemap repeats a URL').toBe(new Set(listed).size);
55
79
  expect([...listed].sort()).toEqual([...new Set(expected)].sort());
56
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
+ });
57
99
  test('every catalogue route serves its canonical identity and links only to live pages', async ({ request }, testInfo) => {
58
100
  test.setTimeout(timeout);
101
+ const centreOf = options.centreOf ?? (await loadBrandlabGeography()).centreOf;
59
102
  const failures = [];
60
103
  const internalLinks = new Map();
61
104
  const checked = [];
@@ -93,6 +136,34 @@ export function defineBrandlabEstateTests(options) {
93
136
  failures.push(`${route.path}: ${route.indexing} route is indexable`);
94
137
  for (const problem of breadcrumbListProblems(snapshot.jsonLd))
95
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
+ }
96
167
  for (const href of extractLinks(html)) {
97
168
  const path = internalPath(href, expectedCanonical, origin);
98
169
  if (path && !internalLinks.has(path))
@@ -216,9 +287,14 @@ export function defineBrandlabEstateTests(options) {
216
287
  testInfo.annotations.push({ type: 'skipped', description: `${path}: a redirect rule answers it` });
217
288
  const failures = [];
218
289
  for (const path of paths) {
219
- const response = await request.get(path, { maxRedirects: 0 });
220
- if (response.status() !== 404)
221
- 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)`);
222
298
  }
223
299
  expect(failures).toEqual([]);
224
300
  });
@@ -315,6 +391,29 @@ export async function readSitemap(request, path, origin) {
315
391
  }
316
392
  return nested;
317
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
+ }
318
417
  export function extractSitemapLocations(xml) {
319
418
  return [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map((match) => new URL(decodeXml(match[1])).href);
320
419
  }
@@ -424,10 +523,17 @@ export function defineBrandlabPageQualityTests(options) {
424
523
  const businessId = options.businessId ?? `${origin}/#business`;
425
524
  const timeout = options.timeout ?? 240_000;
426
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]));
427
527
  test.describe('Brandlab page quality (advisory)', () => {
428
528
  test('reports page and structured-data quality without failing', async ({ request }, testInfo) => {
429
529
  test.setTimeout(timeout);
430
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();
431
537
  await eachWithConcurrency(routes, options.concurrency ?? 8, async (route) => {
432
538
  const response = await request.get(route.path, { maxRedirects: 0 });
433
539
  // The estate journey owns status codes; quality is read from the pages that load.
@@ -436,13 +542,35 @@ export function defineBrandlabPageQualityTests(options) {
436
542
  const html = await response.text();
437
543
  const snapshot = extractSearchCertificationHtmlSnapshot(html);
438
544
  const add = (kind, detail) => findings.push({ path: route.path, kind, detail });
439
- const headings = html.match(/<h1(?:\s|>)/gi)?.length ?? 0;
440
- if (headings !== 1)
441
- add('headings', `${headings} h1 headings`);
545
+ const h1s = headingTexts(html);
546
+ if (h1s.length !== 1)
547
+ add('headings', `${h1s.length} h1 headings`);
442
548
  if (!snapshot.title)
443
549
  add('title', 'no title');
444
550
  if (!snapshot.description)
445
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)`);
446
574
  const expectedCanonical = canonical(route.canonicalPath ?? route.path);
447
575
  const ogUrl = snapshot.openGraph['og:url'];
448
576
  if (ogUrl && new URL(ogUrl, origin).href !== expectedCanonical)
@@ -452,13 +580,80 @@ export function defineBrandlabPageQualityTests(options) {
452
580
  for (const failure of inspectStructuredData(route, snapshot.jsonLd, businessId)) {
453
581
  add('structured-data', failure.slice(route.path.length + 2));
454
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);
455
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('; ') });
456
642
  findings.sort((a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind));
457
643
  const byKind = Object.fromEntries([...new Set(findings.map((finding) => finding.kind))].sort().map((kind) => [kind, findings.filter((finding) => finding.kind === kind).length]));
458
644
  await testInfo.attach('page-quality.json', {
459
645
  body: JSON.stringify({ pages: routes.length, findings: findings.length, byKind, details: findings }, null, 2),
460
646
  contentType: 'application/json',
461
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
+ });
462
657
  testInfo.annotations.push({
463
658
  type: 'advisory',
464
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.`,