@brandlab-digital/conformance 3.3.3 → 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/estate.d.ts +38 -1
- package/dist/estate.d.ts.map +1 -1
- package/dist/estate.js +201 -6
- 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 +10 -0
- package/dist/rule-registry.d.ts.map +1 -1
- package/dist/rule-registry.js +2 -0
- package/dist/rule-registry.js.map +1 -1
- package/package.json +2 -1
package/dist/current-release.js
CHANGED
|
@@ -23,19 +23,19 @@ export const BRANDLAB_CURRENT_RUNTIME_RELEASE = Object.freeze({
|
|
|
23
23
|
"kind": "brandlab-runtime-release-contract",
|
|
24
24
|
"packages": {
|
|
25
25
|
"@brandlab-digital/conformance": {
|
|
26
|
-
"version": "3.
|
|
26
|
+
"version": "3.4.0"
|
|
27
27
|
},
|
|
28
28
|
"@brandlab-digital/eslint-plugin": {
|
|
29
|
-
"version": "3.
|
|
29
|
+
"version": "3.4.0"
|
|
30
30
|
},
|
|
31
31
|
"@brandlab-digital/site-core": {
|
|
32
|
-
"version": "3.
|
|
32
|
+
"version": "3.4.0"
|
|
33
33
|
},
|
|
34
34
|
"@brandlab-digital/site-next": {
|
|
35
|
-
"version": "3.
|
|
35
|
+
"version": "3.4.0"
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
|
-
"releaseVersion": "3.
|
|
38
|
+
"releaseVersion": "3.4.0",
|
|
39
39
|
"runtime": {
|
|
40
40
|
"bundler": "turbopack",
|
|
41
41
|
"cacheComponents": false,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"current-release.js","sourceRoot":"","sources":["../src/current-release.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,wEAAwE;AACxE,OAAO,EAEL,sCAAsC,GACvC,MAAM,uBAAuB,CAAC;AAE/B,MAAM,CAAC,MAAM,gCAAgC,GAAG,MAAM,CAAC,MAAM,CAC7D;IACE,cAAc,EAAE;QACd,uCAAuC;QACvC,8BAA8B;QAC9B,yBAAyB;QACzB,6BAA6B;QAC7B,0BAA0B;QAC1B,4BAA4B;QAC5B,kCAAkC;QAClC,wCAAwC;QACxC,yBAAyB;KAC1B;IACD,WAAW,EAAE;QACX,WAAW,EAAE,cAAc;QAC3B,cAAc,EAAE,iBAAiB;QACjC,eAAe,EAAE,mBAAmB;QACpC,SAAS,EAAE,YAAY;QACvB,KAAK,EAAE,QAAQ;KAChB;IACD,MAAM,EAAE,mCAAmC;IAC3C,UAAU,EAAE;QACV,+BAA+B,EAAE;YAC/B,SAAS,EAAE,OAAO;SACnB;QACD,iCAAiC,EAAE;YACjC,SAAS,EAAE,OAAO;SACnB;QACD,6BAA6B,EAAE;YAC7B,SAAS,EAAE,OAAO;SACnB;QACD,6BAA6B,EAAE;YAC7B,SAAS,EAAE,OAAO;SACnB;KACF;IACD,gBAAgB,EAAE,OAAO;IACzB,SAAS,EAAE;QACT,SAAS,EAAE,WAAW;QACtB,iBAAiB,EAAE,KAAK;QACxB,MAAM,EAAE,QAAQ;QAChB,mBAAmB,EAAE,OAAO;QAC5B,MAAM,EAAE,MAAM;QACd,gBAAgB,EAAE,aAAa;QAC/B,OAAO,EAAE,QAAQ;QACjB,UAAU,EAAE,QAAQ;QACpB,QAAQ,EAAE,KAAK;QACf,OAAO,EAAE,QAAQ;QACjB,YAAY,EAAE,OAAO;KACtB;IACD,eAAe,EAAE,sCAAsC;CACxD,CAC6C,CAAC;AAE/C,sCAAsC,CAAC,gCAAgC,CAAC,CAAC","sourcesContent":["// Generated by standards/scripts/generate-conformance-current-release.mjs.\n// Each exact package release verifies its own current runtime contract.\nimport {\n type BrandlabRuntimeReleaseContract,\n validateBrandlabRuntimeReleaseContract,\n} from \"./release-manifest.js\";\n\nexport const BRANDLAB_CURRENT_RUNTIME_RELEASE = Object.freeze(\n{\n \"capabilities\": [\n \"typed-project-business-and-route-data\",\n \"essential-search-foundations\",\n \"shared-contact-delivery\",\n \"privacy-safe-lead-analytics\",\n \"security-patched-runtime\",\n \"live-google-review-summary\",\n \"high-signal-project-verification\",\n \"packed-positive-and-negative-consumers\",\n \"changesets-oidc-release\"\n ],\n \"contracts\": {\n \"analytics\": \"analytics-v4\",\n \"architecture\": \"architecture-v7\",\n \"configuration\": \"project-config-v7\",\n \"contact\": \"contact-v4\",\n \"seo\": \"seo-v4\"\n },\n \"kind\": \"brandlab-runtime-release-contract\",\n \"packages\": {\n \"@brandlab-digital/conformance\": {\n \"version\": \"3.
|
|
1
|
+
{"version":3,"file":"current-release.js","sourceRoot":"","sources":["../src/current-release.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,wEAAwE;AACxE,OAAO,EAEL,sCAAsC,GACvC,MAAM,uBAAuB,CAAC;AAE/B,MAAM,CAAC,MAAM,gCAAgC,GAAG,MAAM,CAAC,MAAM,CAC7D;IACE,cAAc,EAAE;QACd,uCAAuC;QACvC,8BAA8B;QAC9B,yBAAyB;QACzB,6BAA6B;QAC7B,0BAA0B;QAC1B,4BAA4B;QAC5B,kCAAkC;QAClC,wCAAwC;QACxC,yBAAyB;KAC1B;IACD,WAAW,EAAE;QACX,WAAW,EAAE,cAAc;QAC3B,cAAc,EAAE,iBAAiB;QACjC,eAAe,EAAE,mBAAmB;QACpC,SAAS,EAAE,YAAY;QACvB,KAAK,EAAE,QAAQ;KAChB;IACD,MAAM,EAAE,mCAAmC;IAC3C,UAAU,EAAE;QACV,+BAA+B,EAAE;YAC/B,SAAS,EAAE,OAAO;SACnB;QACD,iCAAiC,EAAE;YACjC,SAAS,EAAE,OAAO;SACnB;QACD,6BAA6B,EAAE;YAC7B,SAAS,EAAE,OAAO;SACnB;QACD,6BAA6B,EAAE;YAC7B,SAAS,EAAE,OAAO;SACnB;KACF;IACD,gBAAgB,EAAE,OAAO;IACzB,SAAS,EAAE;QACT,SAAS,EAAE,WAAW;QACtB,iBAAiB,EAAE,KAAK;QACxB,MAAM,EAAE,QAAQ;QAChB,mBAAmB,EAAE,OAAO;QAC5B,MAAM,EAAE,MAAM;QACd,gBAAgB,EAAE,aAAa;QAC/B,OAAO,EAAE,QAAQ;QACjB,UAAU,EAAE,QAAQ;QACpB,QAAQ,EAAE,KAAK;QACf,OAAO,EAAE,QAAQ;QACjB,YAAY,EAAE,OAAO;KACtB;IACD,eAAe,EAAE,sCAAsC;CACxD,CAC6C,CAAC;AAE/C,sCAAsC,CAAC,gCAAgC,CAAC,CAAC","sourcesContent":["// Generated by standards/scripts/generate-conformance-current-release.mjs.\n// Each exact package release verifies its own current runtime contract.\nimport {\n type BrandlabRuntimeReleaseContract,\n validateBrandlabRuntimeReleaseContract,\n} from \"./release-manifest.js\";\n\nexport const BRANDLAB_CURRENT_RUNTIME_RELEASE = Object.freeze(\n{\n \"capabilities\": [\n \"typed-project-business-and-route-data\",\n \"essential-search-foundations\",\n \"shared-contact-delivery\",\n \"privacy-safe-lead-analytics\",\n \"security-patched-runtime\",\n \"live-google-review-summary\",\n \"high-signal-project-verification\",\n \"packed-positive-and-negative-consumers\",\n \"changesets-oidc-release\"\n ],\n \"contracts\": {\n \"analytics\": \"analytics-v4\",\n \"architecture\": \"architecture-v7\",\n \"configuration\": \"project-config-v7\",\n \"contact\": \"contact-v4\",\n \"seo\": \"seo-v4\"\n },\n \"kind\": \"brandlab-runtime-release-contract\",\n \"packages\": {\n \"@brandlab-digital/conformance\": {\n \"version\": \"3.4.0\"\n },\n \"@brandlab-digital/eslint-plugin\": {\n \"version\": \"3.4.0\"\n },\n \"@brandlab-digital/site-core\": {\n \"version\": \"3.4.0\"\n },\n \"@brandlab-digital/site-next\": {\n \"version\": \"3.4.0\"\n }\n },\n \"releaseVersion\": \"3.4.0\",\n \"runtime\": {\n \"bundler\": \"turbopack\",\n \"cacheComponents\": false,\n \"next\": \"16.3.8\",\n \"nextVersionPolicy\": \"exact\",\n \"node\": \"24.x\",\n \"packageManager\": \"npm@11.14.1\",\n \"react\": \"19.2.8\",\n \"reactDom\": \"19.2.8\",\n \"router\": \"app\",\n \"sharp\": \"0.35.5\",\n \"typescript\": \"6.0.3\"\n },\n \"schemaVersion\": \"brandlab.runtime-release-contract.v1\"\n},\n) as unknown as BrandlabRuntimeReleaseContract;\n\nvalidateBrandlabRuntimeReleaseContract(BRANDLAB_CURRENT_RUNTIME_RELEASE);\n"]}
|
package/dist/estate.d.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import { type APIRequestContext } from '@playwright/test';
|
|
2
|
+
import { type LocationCapacityRecord, type SitemapFile, type SuburbCentreLookup } from './page-standard.js';
|
|
2
3
|
export interface BrandlabEstateRoute {
|
|
3
4
|
path: string;
|
|
4
5
|
canonicalPath?: string;
|
|
5
6
|
indexing: 'index' | 'noindex-follow' | 'noindex-nofollow';
|
|
7
|
+
/** The route kind (a catalogue route has it): sitemap families and location checks read it. */
|
|
8
|
+
kind?: string;
|
|
9
|
+
id?: string;
|
|
10
|
+
/** The content date the sitemap's lastmod must carry. */
|
|
11
|
+
lastSignificantChange?: string;
|
|
12
|
+
locationDecision?: {
|
|
13
|
+
indexing?: string;
|
|
14
|
+
evidence?: readonly string[];
|
|
15
|
+
review?: string;
|
|
16
|
+
};
|
|
6
17
|
}
|
|
7
18
|
export interface BrandlabEstateRedirect {
|
|
8
19
|
source: string;
|
|
@@ -50,6 +61,22 @@ export interface BrandlabEstateTestOptions {
|
|
|
50
61
|
* on the server: the text an unknown top-level path's 404 page shows.
|
|
51
62
|
*/
|
|
52
63
|
families?: readonly BrandlabEstateFamily[];
|
|
64
|
+
/**
|
|
65
|
+
* The site's recorded location capacity (site-core/location-policy). The
|
|
66
|
+
* journey fails only when the catalogue holds more indexable location pages
|
|
67
|
+
* than the capacity and the pages at record allow; without one it notes that
|
|
68
|
+
* none is recorded.
|
|
69
|
+
*/
|
|
70
|
+
locationCapacity?: LocationCapacityRecord | null;
|
|
71
|
+
/** The business node's @id (default `<origin>/#business`); `false` when the site has none. */
|
|
72
|
+
businessId?: string | false;
|
|
73
|
+
/** The business facts record: the business node's name and telephone must match it. */
|
|
74
|
+
business?: {
|
|
75
|
+
name?: string;
|
|
76
|
+
telephone?: string;
|
|
77
|
+
};
|
|
78
|
+
/** A place's centre for neighbour order (default: the registry's suburb centres). */
|
|
79
|
+
centreOf?: SuburbCentreLookup;
|
|
53
80
|
concurrency?: number;
|
|
54
81
|
timeout?: number;
|
|
55
82
|
}
|
|
@@ -60,12 +87,14 @@ export interface BrandlabPageQualityTestOptions {
|
|
|
60
87
|
routes: readonly BrandlabEstateRoute[];
|
|
61
88
|
/** The @id every indexable page's graph references; defaults to `<origin>/#business`, `false` skips it. */
|
|
62
89
|
businessId?: string | false;
|
|
90
|
+
/** A place's centre for similarity siblings (default: the registry's suburb centres). */
|
|
91
|
+
centreOf?: SuburbCentreLookup;
|
|
63
92
|
concurrency?: number;
|
|
64
93
|
timeout?: number;
|
|
65
94
|
}
|
|
66
95
|
export interface BrandlabPageQualityFinding {
|
|
67
96
|
path: string;
|
|
68
|
-
kind: 'headings' | 'title' | 'description' | 'og-url' | 'implementation-wording' | 'structured-data';
|
|
97
|
+
kind: 'headings' | 'title' | 'title-format' | 'title-duplicate' | 'title-h1' | 'description' | 'description-length' | 'description-duplicate' | 'html-size' | 'similarity' | 'location-review' | 'orphan' | 'noindex-link' | 'og-url' | 'implementation-wording' | 'structured-data';
|
|
69
98
|
detail: string;
|
|
70
99
|
}
|
|
71
100
|
export declare function defineBrandlabEstateTests(options: BrandlabEstateTestOptions): void;
|
|
@@ -84,6 +113,14 @@ export declare function responseHeader(server: URL, host: string, path: string,
|
|
|
84
113
|
* on that origin (it is then fetched from the build under test).
|
|
85
114
|
*/
|
|
86
115
|
export declare function readSitemap(request: APIRequestContext, path: string, origin?: string): Promise<string[]>;
|
|
116
|
+
/**
|
|
117
|
+
* The sitemap's URL files with their entries: the sitemap itself, or every
|
|
118
|
+
* file a sitemap index lists (one level, same origin, no redirects).
|
|
119
|
+
*/
|
|
120
|
+
export declare function readSitemapFiles(request: APIRequestContext, path: string, origin: string): Promise<{
|
|
121
|
+
index: boolean;
|
|
122
|
+
files: SitemapFile[];
|
|
123
|
+
}>;
|
|
87
124
|
export declare function extractSitemapLocations(xml: string): string[];
|
|
88
125
|
export declare function defaultNotFoundPaths(routes: readonly BrandlabEstateRoute[]): string[];
|
|
89
126
|
/** The unknown-slug probes to request, apart from those a redirect rule answers. */
|
package/dist/estate.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"estate.d.ts","sourceRoot":"","sources":["../src/estate.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"estate.d.ts","sourceRoot":"","sources":["../src/estate.ts"],"names":[],"mappings":"AAmCA,OAAO,EAAgB,KAAK,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAkBL,KAAK,sBAAsB,EAE3B,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACxB,MAAM,oBAAoB,CAAC;AAG5B,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,OAAO,GAAG,gBAAgB,GAAG,kBAAkB,CAAC;IAC1D,+FAA+F;IAC/F,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,yDAAyD;IACzD,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,gBAAgB,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACzF;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;IACpC,gFAAgF;IAChF,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACnC,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,yFAAyF;IACzF,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,2DAA2D;IAC3D,QAAQ,EAAE,mBAAmB,CAAC,UAAU,CAAC,CAAC;CAC3C;AAED,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE;QAAE,YAAY,EAAE,MAAM,GAAG,GAAG,CAAA;KAAE,CAAC;IACrC,wFAAwF;IACxF,MAAM,EAAE,SAAS,mBAAmB,EAAE,CAAC;IACvC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,SAAS,sBAAsB,EAAE,CAAC;IAC9C,6FAA6F;IAC7F,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,oGAAoG;IACpG,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,SAAS,oBAAoB,EAAE,CAAC;IAC3C;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,sBAAsB,GAAG,IAAI,CAAC;IACjD,8FAA8F;IAC9F,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IAC5B,uFAAuF;IACvF,QAAQ,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACjD,qFAAqF;IACrF,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,8BAA8B;IAC7C,IAAI,EAAE;QAAE,YAAY,EAAE,MAAM,GAAG,GAAG,CAAA;KAAE,CAAC;IACrC,MAAM,EAAE,SAAS,mBAAmB,EAAE,CAAC;IACvC,2GAA2G;IAC3G,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IAC5B,yFAAyF;IACzF,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,0BAA0B;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EACA,UAAU,GACV,OAAO,GACP,cAAc,GACd,iBAAiB,GACjB,UAAU,GACV,aAAa,GACb,oBAAoB,GACpB,uBAAuB,GACvB,WAAW,GACX,YAAY,GACZ,iBAAiB,GACjB,QAAQ,GACR,cAAc,GACd,QAAQ,GACR,wBAAwB,GACxB,iBAAiB,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;CAChB;AAoBD,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,yBAAyB,QA2P3E;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,OAAO,EAAE,GAAG,MAAM,EAAE,CA0B3E;AAED,4HAA4H;AAC5H,wBAAgB,cAAc,CAAC,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAUjH;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,iBAAiB,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAkB9G;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,iBAAiB,EAC1B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,WAAW,EAAE,CAAA;CAAE,CAAC,CAenD;AAED,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAG7D;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,mBAAmB,EAAE,GAAG,MAAM,EAAE,CAQrF;AAED,oFAAoF;AACpF,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,GAAG;IAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,UAAU,EAAE,MAAM,EAAE,CAAA;CAAE,CAK5I;AAED,6GAA6G;AAC7G,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,OAAO,CAAC,EAAE,SAAS,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,SAAS,CAEhI;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,GAAG,OAAO,CAAC,GAAG,MAAM,EAAE,CAQrG;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQ7C;AAED,iHAAiH;AACjH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,sBAAsB,EAAE,QAAQ,GAAG,aAAa,CAAC,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GAAG,SAAS,CAajK;AAaD;;;;;;;;;GASG;AACH,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,8BAA8B,QAgIrF;AA+CD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAElE"}
|
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
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
440
|
-
if (
|
|
441
|
-
add('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.`,
|