@brandlab-digital/conformance 3.13.0 → 3.15.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.
Files changed (152) hide show
  1. package/README.md +68 -2
  2. package/dist/analytics-e2e.d.ts +10 -16
  3. package/dist/analytics-e2e.d.ts.map +1 -1
  4. package/dist/analytics-e2e.js +61 -3
  5. package/dist/analytics-e2e.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +4 -1
  8. package/dist/cli.js.map +1 -1
  9. package/dist/current-release.js +5 -5
  10. package/dist/current-release.js.map +1 -1
  11. package/dist/engineering.d.ts.map +1 -1
  12. package/dist/engineering.js +18 -2
  13. package/dist/engineering.js.map +1 -1
  14. package/dist/estate-snapshot.d.ts +54 -0
  15. package/dist/estate-snapshot.d.ts.map +1 -0
  16. package/dist/estate-snapshot.js +164 -0
  17. package/dist/estate-snapshot.js.map +1 -0
  18. package/dist/estate.d.ts +38 -0
  19. package/dist/estate.d.ts.map +1 -1
  20. package/dist/estate.js +71 -28
  21. package/dist/estate.js.map +1 -1
  22. package/dist/media-snapshot.d.ts +53 -0
  23. package/dist/media-snapshot.d.ts.map +1 -0
  24. package/dist/media-snapshot.js +66 -0
  25. package/dist/media-snapshot.js.map +1 -0
  26. package/dist/project.d.ts +20 -0
  27. package/dist/project.d.ts.map +1 -1
  28. package/dist/project.js +92 -2
  29. package/dist/project.js.map +1 -1
  30. package/dist/ready-cli.d.ts.map +1 -1
  31. package/dist/ready-cli.js +8 -3
  32. package/dist/ready-cli.js.map +1 -1
  33. package/dist/rule-registry.d.ts +5 -0
  34. package/dist/rule-registry.d.ts.map +1 -1
  35. package/dist/rule-registry.js +1 -0
  36. package/dist/rule-registry.js.map +1 -1
  37. package/dist/site-system/checks.d.ts +28 -3
  38. package/dist/site-system/checks.d.ts.map +1 -1
  39. package/dist/site-system/checks.js +5 -2
  40. package/dist/site-system/checks.js.map +1 -1
  41. package/dist/site-system/config.d.ts +13 -0
  42. package/dist/site-system/config.d.ts.map +1 -1
  43. package/dist/site-system/config.js +30 -1
  44. package/dist/site-system/config.js.map +1 -1
  45. package/dist/site-system/index.d.ts +1 -1
  46. package/dist/site-system/index.d.ts.map +1 -1
  47. package/dist/site-system/index.js +1 -1
  48. package/dist/site-system/index.js.map +1 -1
  49. package/dist/site-system/journeys/conversion.d.ts.map +1 -1
  50. package/dist/site-system/journeys/conversion.js +15 -2
  51. package/dist/site-system/journeys/conversion.js.map +1 -1
  52. package/dist/site-system/journeys/menu.d.ts.map +1 -1
  53. package/dist/site-system/journeys/menu.js +24 -1
  54. package/dist/site-system/journeys/menu.js.map +1 -1
  55. package/dist/site-system/journeys/page-helpers.d.ts.map +1 -1
  56. package/dist/site-system/journeys/page-helpers.js +1 -1
  57. package/dist/site-system/journeys/page-helpers.js.map +1 -1
  58. package/dist/site-system/journeys/plan.d.ts +13 -3
  59. package/dist/site-system/journeys/plan.d.ts.map +1 -1
  60. package/dist/site-system/journeys/plan.js +12 -2
  61. package/dist/site-system/journeys/plan.js.map +1 -1
  62. package/dist/site-system/journeys/shell.d.ts +1 -1
  63. package/dist/site-system/journeys/shell.js +2 -2
  64. package/dist/site-system/journeys/shell.js.map +1 -1
  65. package/dist/site-system/report.d.ts.map +1 -1
  66. package/dist/site-system/report.js +42 -8
  67. package/dist/site-system/report.js.map +1 -1
  68. package/dist/site-system/rule-modules.d.ts.map +1 -1
  69. package/dist/site-system/rule-modules.js +5 -0
  70. package/dist/site-system/rule-modules.js.map +1 -1
  71. package/dist/site-system/rules/delivery/h13-inbox.d.ts +23 -0
  72. package/dist/site-system/rules/delivery/h13-inbox.d.ts.map +1 -0
  73. package/dist/site-system/rules/delivery/h13-inbox.js +208 -0
  74. package/dist/site-system/rules/delivery/h13-inbox.js.map +1 -0
  75. package/dist/site-system/rules/delivery/index.d.ts +12 -0
  76. package/dist/site-system/rules/delivery/index.d.ts.map +1 -0
  77. package/dist/site-system/rules/delivery/index.js +7 -0
  78. package/dist/site-system/rules/delivery/index.js.map +1 -0
  79. package/dist/site-system/rules/form/c7-thank-you.d.ts +7 -1
  80. package/dist/site-system/rules/form/c7-thank-you.d.ts.map +1 -1
  81. package/dist/site-system/rules/form/c7-thank-you.js +26 -3
  82. package/dist/site-system/rules/form/c7-thank-you.js.map +1 -1
  83. package/dist/site-system/rules/form/index.d.ts.map +1 -1
  84. package/dist/site-system/rules/form/index.js +11 -1
  85. package/dist/site-system/rules/form/index.js.map +1 -1
  86. package/dist/site-system/rules/full/common.d.ts +2 -0
  87. package/dist/site-system/rules/full/common.d.ts.map +1 -1
  88. package/dist/site-system/rules/full/common.js.map +1 -1
  89. package/dist/site-system/rules/full/e1-pattern.d.ts +4 -0
  90. package/dist/site-system/rules/full/e1-pattern.d.ts.map +1 -0
  91. package/dist/site-system/rules/full/e1-pattern.js +47 -0
  92. package/dist/site-system/rules/full/e1-pattern.js.map +1 -0
  93. package/dist/site-system/rules/full/estate.d.ts +26 -3
  94. package/dist/site-system/rules/full/estate.d.ts.map +1 -1
  95. package/dist/site-system/rules/full/estate.js +58 -9
  96. package/dist/site-system/rules/full/estate.js.map +1 -1
  97. package/dist/site-system/rules/full/geography.d.ts +15 -2
  98. package/dist/site-system/rules/full/geography.d.ts.map +1 -1
  99. package/dist/site-system/rules/full/geography.js +15 -2
  100. package/dist/site-system/rules/full/geography.js.map +1 -1
  101. package/dist/site-system/rules/full/index.d.ts +4 -3
  102. package/dist/site-system/rules/full/index.d.ts.map +1 -1
  103. package/dist/site-system/rules/full/index.js +20 -5
  104. package/dist/site-system/rules/full/index.js.map +1 -1
  105. package/dist/site-system/rules/full/k12-slots.d.ts +4 -0
  106. package/dist/site-system/rules/full/k12-slots.d.ts.map +1 -0
  107. package/dist/site-system/rules/full/k12-slots.js +40 -0
  108. package/dist/site-system/rules/full/k12-slots.js.map +1 -0
  109. package/dist/site-system/rules/full/l10-location-copy.d.ts +1 -1
  110. package/dist/site-system/rules/full/l10-location-copy.d.ts.map +1 -1
  111. package/dist/site-system/rules/full/l10-location-copy.js +5 -2
  112. package/dist/site-system/rules/full/l10-location-copy.js.map +1 -1
  113. package/dist/site-system/rules/full/l3-neighbours.d.ts +3 -2
  114. package/dist/site-system/rules/full/l3-neighbours.d.ts.map +1 -1
  115. package/dist/site-system/rules/full/l3-neighbours.js +4 -1
  116. package/dist/site-system/rules/full/l3-neighbours.js.map +1 -1
  117. package/dist/site-system/rules/full/l4-local-copy.d.ts +2 -1
  118. package/dist/site-system/rules/full/l4-local-copy.d.ts.map +1 -1
  119. package/dist/site-system/rules/full/l4-local-copy.js +8 -3
  120. package/dist/site-system/rules/full/l4-local-copy.js.map +1 -1
  121. package/dist/site-system/rules/full/l9-data-contract.js +2 -2
  122. package/dist/site-system/rules/full/l9-data-contract.js.map +1 -1
  123. package/dist/site-system/rules/full/links.d.ts +3 -1
  124. package/dist/site-system/rules/full/links.d.ts.map +1 -1
  125. package/dist/site-system/rules/full/links.js +15 -5
  126. package/dist/site-system/rules/full/links.js.map +1 -1
  127. package/dist/site-system/rules/full/loc-index.d.ts +5 -0
  128. package/dist/site-system/rules/full/loc-index.d.ts.map +1 -1
  129. package/dist/site-system/rules/full/loc-index.js +43 -2
  130. package/dist/site-system/rules/full/loc-index.js.map +1 -1
  131. package/dist/site-system/rules/full/route-kinds.d.ts +15 -0
  132. package/dist/site-system/rules/full/route-kinds.d.ts.map +1 -0
  133. package/dist/site-system/rules/full/route-kinds.js +52 -0
  134. package/dist/site-system/rules/full/route-kinds.js.map +1 -0
  135. package/dist/site-system/rules/full/strategy.d.ts +6 -0
  136. package/dist/site-system/rules/full/strategy.d.ts.map +1 -1
  137. package/dist/site-system/rules/full/strategy.js +98 -10
  138. package/dist/site-system/rules/full/strategy.js.map +1 -1
  139. package/dist/site-system/rules/sections/k2-skin-coverage.d.ts +2 -2
  140. package/dist/site-system/rules/sections/k2-skin-coverage.d.ts.map +1 -1
  141. package/dist/site-system/rules/sections/k2-skin-coverage.js +13 -2
  142. package/dist/site-system/rules/sections/k2-skin-coverage.js.map +1 -1
  143. package/dist/site-system/rules/sections/t6-media-entries.d.ts.map +1 -1
  144. package/dist/site-system/rules/sections/t6-media-entries.js +69 -5
  145. package/dist/site-system/rules/sections/t6-media-entries.js.map +1 -1
  146. package/dist/site-system/runners.js +1 -1
  147. package/dist/site-system/runners.js.map +1 -1
  148. package/dist/site-system-e2e.d.ts +1 -1
  149. package/dist/site-system-e2e.d.ts.map +1 -1
  150. package/dist/site-system-e2e.js +17 -4
  151. package/dist/site-system-e2e.js.map +1 -1
  152. package/package.json +10 -2
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The estate plan snapshot (3.15.0): a site whose waves or estate plan are computed (from its location catalogue,
3
+ * for example) records them as plain JSON, so the ready gate's LOC-INDEX can read them without running the site's
4
+ * code. The snapshot is deterministic (sorted routes and keys, no timestamp) and carries a hash of the source files it
5
+ * was written from (`src/data` by default), so the gate fails a snapshot older than those files.
6
+ *
7
+ * The site writes it from its own tests, where its module aliases resolve:
8
+ *
9
+ * // tests/data/estatePlanSnapshot.test.ts
10
+ * import { writeEstatePlanSnapshot } from '@brandlab-digital/conformance/estate-snapshot'
11
+ * import { siteStrategy } from '@/data/strategy'
12
+ *
13
+ * it('records the estate plan for the ready gate', () => {
14
+ * const { changed } = writeEstatePlanSnapshot(siteStrategy, { projectRoot: process.cwd() })
15
+ * if (process.env.CI) expect(changed).toBe(false)
16
+ * })
17
+ *
18
+ * Node only: it reads and writes files.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
22
+ import { dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
23
+ /** Where the snapshot lives, relative to the project root. */
24
+ export const ESTATE_PLAN_SNAPSHOT_FILE = "src/data/estate-plan.generated.json";
25
+ /** The folders hashed by default: the snapshot is stale once any file in them changes. */
26
+ export const ESTATE_PLAN_SNAPSHOT_SOURCES = Object.freeze(["src/data"]);
27
+ export const ESTATE_PLAN_SNAPSHOT_KIND = "brandlab-estate-plan-snapshot";
28
+ export const ESTATE_PLAN_SNAPSHOT_VERSION = 1;
29
+ /** The source files hashed: code and data, never tests or generated files. */
30
+ const SOURCE_FILE = /\.(?:ts|tsx|mts|cts|js|mjs|cjs|json|mdx?|csv)$/;
31
+ const SKIPPED_FILE = /(?:\.generated\.json|\.(?:test|spec)\.[a-z]+)$/;
32
+ function sortedUnique(values) {
33
+ return [...new Set(values)].sort();
34
+ }
35
+ function sortedRecord(record) {
36
+ return Object.fromEntries(Object.entries(record).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)));
37
+ }
38
+ /**
39
+ * The waves and estate plan in their canonical form: waves in date order (stable), each wave's routes sorted, the
40
+ * plan's lists sorted and its keys in a fixed order. Two plans that mean the same thing serialise the same.
41
+ */
42
+ export function canonicalEstatePlan(strategy) {
43
+ const result = {};
44
+ if (strategy.waves !== undefined) {
45
+ result.waves = [...strategy.waves]
46
+ .map((wave, index) => ({ wave, index }))
47
+ .sort((left, right) => (left.wave.releaseOn < right.wave.releaseOn ? -1 : left.wave.releaseOn > right.wave.releaseOn ? 1 : left.index - right.index))
48
+ .map(({ wave }) => ({
49
+ releaseOn: wave.releaseOn,
50
+ ...(wave.kind !== undefined ? { kind: wave.kind } : {}),
51
+ routes: sortedUnique(wave.routes),
52
+ }));
53
+ }
54
+ const plan = strategy.estate;
55
+ if (plan !== undefined) {
56
+ const paths = plan.paths;
57
+ const slugs = plan.slugs;
58
+ result.estate = {
59
+ indexedNow: sortedUnique(plan.indexedNow),
60
+ ...(paths !== undefined
61
+ ? {
62
+ paths: {
63
+ ...(paths.region !== undefined ? { region: paths.region } : {}),
64
+ ...(paths.suburb !== undefined ? { suburb: paths.suburb } : {}),
65
+ ...(paths.serviceInSuburb !== undefined ? { serviceInSuburb: sortedUnique(paths.serviceInSuburb) } : {}),
66
+ },
67
+ }
68
+ : {}),
69
+ ...(slugs !== undefined
70
+ ? {
71
+ slugs: {
72
+ ...(slugs.regions !== undefined ? { regions: sortedRecord(slugs.regions) } : {}),
73
+ ...(slugs.suburbs !== undefined ? { suburbs: sortedRecord(slugs.suburbs) } : {}),
74
+ },
75
+ }
76
+ : {}),
77
+ };
78
+ }
79
+ return result;
80
+ }
81
+ function assertSourcePath(path) {
82
+ if (typeof path !== "string" || path === "" || isAbsolute(path)) {
83
+ throw new Error(`The snapshot source "${String(path)}" must be a folder or file inside the project.`);
84
+ }
85
+ const clean = normalize(path).split(sep).join("/").replace(/\/$/, "");
86
+ if (clean === "." || clean === ".." || clean.startsWith("../")) {
87
+ throw new Error(`The snapshot source "${path}" must be a folder or file inside the project.`);
88
+ }
89
+ return clean;
90
+ }
91
+ function collectFiles(root, path, files) {
92
+ const absolute = join(root, path);
93
+ if (!existsSync(absolute))
94
+ return;
95
+ if (statSync(absolute).isFile()) {
96
+ if (!SKIPPED_FILE.test(path))
97
+ files.push(path);
98
+ return;
99
+ }
100
+ for (const name of readdirSync(absolute)) {
101
+ if (name === "node_modules" || name.startsWith("."))
102
+ continue;
103
+ const child = `${path}/${name}`;
104
+ // statSync follows symbolic links, so a linked file or folder is hashed by what it points to.
105
+ const stats = statSync(join(root, child), { throwIfNoEntry: false });
106
+ if (stats?.isDirectory())
107
+ collectFiles(root, child, files);
108
+ else if (stats?.isFile() && SOURCE_FILE.test(name) && !SKIPPED_FILE.test(name))
109
+ files.push(child);
110
+ }
111
+ }
112
+ /**
113
+ * The hash of the files the plan is computed from: every code and data file under `sources` (tests and generated
114
+ * files left out), by path and content in path order. The ready gate recomputes it to tell a stale snapshot.
115
+ */
116
+ export function estatePlanSourceHash(projectRoot, sources = ESTATE_PLAN_SNAPSHOT_SOURCES) {
117
+ const files = [];
118
+ for (const source of sortedUnique(sources.map(assertSourcePath)))
119
+ collectFiles(projectRoot, source, files);
120
+ const hash = createHash("sha256");
121
+ for (const file of sortedUnique(files)) {
122
+ hash.update(`${file}\n`);
123
+ hash.update(readFileSync(join(projectRoot, file)));
124
+ hash.update("\n");
125
+ }
126
+ return hash.digest("hex");
127
+ }
128
+ /** The snapshot for a strategy record: its waves and estate plan in canonical form, with the sources' hash. */
129
+ export function estatePlanSnapshot(strategy, options) {
130
+ const paths = sortedUnique((options.sources ?? ESTATE_PLAN_SNAPSHOT_SOURCES).map(assertSourcePath));
131
+ return {
132
+ kind: ESTATE_PLAN_SNAPSHOT_KIND,
133
+ version: ESTATE_PLAN_SNAPSHOT_VERSION,
134
+ sources: { paths, sha256: estatePlanSourceHash(options.projectRoot, paths) },
135
+ ...canonicalEstatePlan(strategy),
136
+ };
137
+ }
138
+ /** The snapshot as the file holds it: two-space JSON and a final newline. */
139
+ export function serialiseEstatePlanSnapshot(snapshot) {
140
+ return `${JSON.stringify(snapshot, null, 2)}\n`;
141
+ }
142
+ /**
143
+ * Writes `src/data/estate-plan.generated.json` when its content differs; `changed` says whether it did. Run it from the
144
+ * site's tests or build so the snapshot follows the strategy record, and commit the file.
145
+ */
146
+ export function writeEstatePlanSnapshot(strategy, options) {
147
+ const snapshot = estatePlanSnapshot(strategy, options);
148
+ return { ...writeSnapshotFile(options.projectRoot, ESTATE_PLAN_SNAPSHOT_FILE, serialiseEstatePlanSnapshot(snapshot)), snapshot };
149
+ }
150
+ /** Writes a snapshot file when its text differs; `changed` says whether it did. Shared with the media snapshot. */
151
+ export function writeSnapshotFile(projectRoot, file, text) {
152
+ const path = join(projectRoot, file);
153
+ const current = existsSync(path) ? readFileSync(path, "utf8") : null;
154
+ if (current === text)
155
+ return { path: relative(projectRoot, path), changed: false };
156
+ mkdirSync(dirname(path), { recursive: true });
157
+ writeFileSync(path, text);
158
+ return { path: relative(projectRoot, path), changed: true };
159
+ }
160
+ /** The folder list a snapshot records, checked and sorted. */
161
+ export function snapshotSourcePaths(sources = ESTATE_PLAN_SNAPSHOT_SOURCES) {
162
+ return sortedUnique(sources.map(assertSourcePath));
163
+ }
164
+ //# sourceMappingURL=estate-snapshot.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"estate-snapshot.js","sourceRoot":"","sources":["../src/estate-snapshot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACpG,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAGhF,8DAA8D;AAC9D,MAAM,CAAC,MAAM,yBAAyB,GAAG,qCAAqC,CAAC;AAC/E,0FAA0F;AAC1F,MAAM,CAAC,MAAM,4BAA4B,GAAsB,MAAM,CAAC,MAAM,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;AAC3F,MAAM,CAAC,MAAM,yBAAyB,GAAG,+BAA+B,CAAC;AACzE,MAAM,CAAC,MAAM,4BAA4B,GAAG,CAAC,CAAC;AAE9C,8EAA8E;AAC9E,MAAM,WAAW,GAAG,gDAAgD,CAAC;AACrE,MAAM,YAAY,GAAG,gDAAgD,CAAC;AAiBtE,SAAS,YAAY,CAAC,MAAyB;IAC7C,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;AACrC,CAAC;AAED,SAAS,YAAY,CAAC,MAAwC;IAC5D,OAAO,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAC1H,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CAAC,QAAgD;IAClF,MAAM,MAAM,GAA2D,EAAE,CAAC;IAC1E,IAAI,QAAQ,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;QACjC,MAAM,CAAC,KAAK,GAAG,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC;aAC/B,GAAG,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;aACvC,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,GAAG,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC;aACpJ,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;YAClB,SAAS,EAAE,IAAI,CAAC,SAAS;YACzB,GAAG,CAAC,IAAI,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACvD,MAAM,EAAE,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC;SAClC,CAAC,CAAC,CAAC;IACR,CAAC;IACD,MAAM,IAAI,GAAG,QAAQ,CAAC,MAAM,CAAC;IAC7B,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACvB,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;QACzB,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;QACzB,MAAM,CAAC,MAAM,GAAG;YACd,UAAU,EAAE,YAAY,CAAC,IAAI,CAAC,UAAU,CAAC;YACzC,GAAG,CAAC,KAAK,KAAK,SAAS;gBACrB,CAAC,CAAC;oBACE,KAAK,EAAE;wBACL,GAAG,CAAC,KAAK,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;wBAC/D,GAAG,CAAC,KAAK,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;wBAC/D,GAAG,CAAC,KAAK,CAAC,eAAe,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,eAAe,EAAE,YAAY,CAAC,KAAK,CAAC,eAAe,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;qBACzG;iBACF;gBACH,CAAC,CAAC,EAAE,CAAC;YACP,GAAG,CAAC,KAAK,KAAK,SAAS;gBACrB,CAAC,CAAC;oBACE,KAAK,EAAE;wBACL,GAAG,CAAC,KAAK,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,YAAY,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;wBAChF,GAAG,CAAC,KAAK,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,YAAY,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;qBACjF;iBACF;gBACH,CAAC,CAAC,EAAE,CAAC;SACR,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,gBAAgB,CAAC,IAAY;IACpC,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,EAAE,IAAI,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QAChE,MAAM,IAAI,KAAK,CAAC,wBAAwB,MAAM,CAAC,IAAI,CAAC,gDAAgD,CAAC,CAAC;IACxG,CAAC;IACD,MAAM,KAAK,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACtE,IAAI,KAAK,KAAK,GAAG,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,EAAE,CAAC;QAC/D,MAAM,IAAI,KAAK,CAAC,wBAAwB,IAAI,gDAAgD,CAAC,CAAC;IAChG,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,YAAY,CAAC,IAAY,EAAE,IAAY,EAAE,KAAe;IAC/D,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAClC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC;QAAE,OAAO;IAClC,IAAI,QAAQ,CAAC,QAAQ,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC;QAChC,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC/C,OAAO;IACT,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,WAAW,CAAC,QAAQ,CAAC,EAAE,CAAC;QACzC,IAAI,IAAI,KAAK,cAAc,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QAC9D,MAAM,KAAK,GAAG,GAAG,IAAI,IAAI,IAAI,EAAE,CAAC;QAChC,8FAA8F;QAC9F,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE,CAAC,CAAC;QACrE,IAAI,KAAK,EAAE,WAAW,EAAE;YAAE,YAAY,CAAC,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;aACtD,IAAI,KAAK,EAAE,MAAM,EAAE,IAAI,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACpG,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAAC,WAAmB,EAAE,UAA6B,4BAA4B;IACjH,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,MAAM,MAAM,IAAI,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;QAAE,YAAY,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;IAC3G,MAAM,IAAI,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC;IAClC,KAAK,MAAM,IAAI,IAAI,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;QACvC,IAAI,CAAC,MAAM,CAAC,GAAG,IAAI,IAAI,CAAC,CAAC;QACzB,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC;QACnD,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACpB,CAAC;IACD,OAAO,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC5B,CAAC;AAED,+GAA+G;AAC/G,MAAM,UAAU,kBAAkB,CAAC,QAAgD,EAAE,OAAkC;IACrH,MAAM,KAAK,GAAG,YAAY,CAAC,CAAC,OAAO,CAAC,OAAO,IAAI,4BAA4B,CAAC,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC,CAAC;IACpG,OAAO;QACL,IAAI,EAAE,yBAAyB;QAC/B,OAAO,EAAE,4BAA4B;QACrC,OAAO,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,oBAAoB,CAAC,OAAO,CAAC,WAAW,EAAE,KAAK,CAAC,EAAE;QAC5E,GAAG,mBAAmB,CAAC,QAAQ,CAAC;KACjC,CAAC;AACJ,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,2BAA2B,CAAC,QAA4B;IACtE,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC;AAClD,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,uBAAuB,CACrC,QAAgD,EAChD,OAAkC;IAElC,MAAM,QAAQ,GAAG,kBAAkB,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;IACvD,OAAO,EAAE,GAAG,iBAAiB,CAAC,OAAO,CAAC,WAAW,EAAE,yBAAyB,EAAE,2BAA2B,CAAC,QAAQ,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC;AACnI,CAAC;AAED,mHAAmH;AACnH,MAAM,UAAU,iBAAiB,CAAC,WAAmB,EAAE,IAAY,EAAE,IAAY;IAC/E,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC;IACrC,MAAM,OAAO,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACrE,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,CAAC,WAAW,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;IACnF,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC9C,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAC1B,OAAO,EAAE,IAAI,EAAE,QAAQ,CAAC,WAAW,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;AAC9D,CAAC;AAED,8DAA8D;AAC9D,MAAM,UAAU,mBAAmB,CAAC,UAA6B,4BAA4B;IAC3F,OAAO,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC,CAAC;AACrD,CAAC","sourcesContent":["/**\n * The estate plan snapshot (3.15.0): a site whose waves or estate plan are computed (from its location catalogue,\n * for example) records them as plain JSON, so the ready gate's LOC-INDEX can read them without running the site's\n * code. The snapshot is deterministic (sorted routes and keys, no timestamp) and carries a hash of the source files it\n * was written from (`src/data` by default), so the gate fails a snapshot older than those files.\n *\n * The site writes it from its own tests, where its module aliases resolve:\n *\n * // tests/data/estatePlanSnapshot.test.ts\n * import { writeEstatePlanSnapshot } from '@brandlab-digital/conformance/estate-snapshot'\n * import { siteStrategy } from '@/data/strategy'\n *\n * it('records the estate plan for the ready gate', () => {\n * const { changed } = writeEstatePlanSnapshot(siteStrategy, { projectRoot: process.cwd() })\n * if (process.env.CI) expect(changed).toBe(false)\n * })\n *\n * Node only: it reads and writes files.\n */\nimport { createHash } from \"node:crypto\";\nimport { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from \"node:fs\";\nimport { dirname, isAbsolute, join, normalize, relative, sep } from \"node:path\";\nimport type { SiteEstatePlan, SiteReleaseWave, SiteStrategy } from \"@brandlab-digital/site-core/strategy\";\n\n/** Where the snapshot lives, relative to the project root. */\nexport const ESTATE_PLAN_SNAPSHOT_FILE = \"src/data/estate-plan.generated.json\";\n/** The folders hashed by default: the snapshot is stale once any file in them changes. */\nexport const ESTATE_PLAN_SNAPSHOT_SOURCES: readonly string[] = Object.freeze([\"src/data\"]);\nexport const ESTATE_PLAN_SNAPSHOT_KIND = \"brandlab-estate-plan-snapshot\";\nexport const ESTATE_PLAN_SNAPSHOT_VERSION = 1;\n\n/** The source files hashed: code and data, never tests or generated files. */\nconst SOURCE_FILE = /\\.(?:ts|tsx|mts|cts|js|mjs|cjs|json|mdx?|csv)$/;\nconst SKIPPED_FILE = /(?:\\.generated\\.json|\\.(?:test|spec)\\.[a-z]+)$/;\n\nexport interface EstatePlanSnapshot {\n readonly kind: typeof ESTATE_PLAN_SNAPSHOT_KIND;\n readonly version: typeof ESTATE_PLAN_SNAPSHOT_VERSION;\n /** The folders hashed and the hash of their files when the snapshot was written. */\n readonly sources: { readonly paths: readonly string[]; readonly sha256: string };\n readonly waves?: readonly SiteReleaseWave[];\n readonly estate?: SiteEstatePlan;\n}\n\nexport interface EstatePlanSnapshotOptions {\n readonly projectRoot: string;\n /** Folders (or files) relative to the project root the plan is computed from. Defaults to `src/data`. */\n readonly sources?: readonly string[];\n}\n\nfunction sortedUnique(values: readonly string[]): string[] {\n return [...new Set(values)].sort();\n}\n\nfunction sortedRecord(record: Readonly<Record<string, string>>): Record<string, string> {\n return Object.fromEntries(Object.entries(record).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)));\n}\n\n/**\n * The waves and estate plan in their canonical form: waves in date order (stable), each wave's routes sorted, the\n * plan's lists sorted and its keys in a fixed order. Two plans that mean the same thing serialise the same.\n */\nexport function canonicalEstatePlan(strategy: Pick<SiteStrategy, \"waves\" | \"estate\">): Pick<EstatePlanSnapshot, \"waves\" | \"estate\"> {\n const result: { waves?: SiteReleaseWave[]; estate?: SiteEstatePlan } = {};\n if (strategy.waves !== undefined) {\n result.waves = [...strategy.waves]\n .map((wave, index) => ({ wave, index }))\n .sort((left, right) => (left.wave.releaseOn < right.wave.releaseOn ? -1 : left.wave.releaseOn > right.wave.releaseOn ? 1 : left.index - right.index))\n .map(({ wave }) => ({\n releaseOn: wave.releaseOn,\n ...(wave.kind !== undefined ? { kind: wave.kind } : {}),\n routes: sortedUnique(wave.routes),\n }));\n }\n const plan = strategy.estate;\n if (plan !== undefined) {\n const paths = plan.paths;\n const slugs = plan.slugs;\n result.estate = {\n indexedNow: sortedUnique(plan.indexedNow),\n ...(paths !== undefined\n ? {\n paths: {\n ...(paths.region !== undefined ? { region: paths.region } : {}),\n ...(paths.suburb !== undefined ? { suburb: paths.suburb } : {}),\n ...(paths.serviceInSuburb !== undefined ? { serviceInSuburb: sortedUnique(paths.serviceInSuburb) } : {}),\n },\n }\n : {}),\n ...(slugs !== undefined\n ? {\n slugs: {\n ...(slugs.regions !== undefined ? { regions: sortedRecord(slugs.regions) } : {}),\n ...(slugs.suburbs !== undefined ? { suburbs: sortedRecord(slugs.suburbs) } : {}),\n },\n }\n : {}),\n };\n }\n return result;\n}\n\nfunction assertSourcePath(path: string): string {\n if (typeof path !== \"string\" || path === \"\" || isAbsolute(path)) {\n throw new Error(`The snapshot source \"${String(path)}\" must be a folder or file inside the project.`);\n }\n const clean = normalize(path).split(sep).join(\"/\").replace(/\\/$/, \"\");\n if (clean === \".\" || clean === \"..\" || clean.startsWith(\"../\")) {\n throw new Error(`The snapshot source \"${path}\" must be a folder or file inside the project.`);\n }\n return clean;\n}\n\nfunction collectFiles(root: string, path: string, files: string[]): void {\n const absolute = join(root, path);\n if (!existsSync(absolute)) return;\n if (statSync(absolute).isFile()) {\n if (!SKIPPED_FILE.test(path)) files.push(path);\n return;\n }\n for (const name of readdirSync(absolute)) {\n if (name === \"node_modules\" || name.startsWith(\".\")) continue;\n const child = `${path}/${name}`;\n // statSync follows symbolic links, so a linked file or folder is hashed by what it points to.\n const stats = statSync(join(root, child), { throwIfNoEntry: false });\n if (stats?.isDirectory()) collectFiles(root, child, files);\n else if (stats?.isFile() && SOURCE_FILE.test(name) && !SKIPPED_FILE.test(name)) files.push(child);\n }\n}\n\n/**\n * The hash of the files the plan is computed from: every code and data file under `sources` (tests and generated\n * files left out), by path and content in path order. The ready gate recomputes it to tell a stale snapshot.\n */\nexport function estatePlanSourceHash(projectRoot: string, sources: readonly string[] = ESTATE_PLAN_SNAPSHOT_SOURCES): string {\n const files: string[] = [];\n for (const source of sortedUnique(sources.map(assertSourcePath))) collectFiles(projectRoot, source, files);\n const hash = createHash(\"sha256\");\n for (const file of sortedUnique(files)) {\n hash.update(`${file}\\n`);\n hash.update(readFileSync(join(projectRoot, file)));\n hash.update(\"\\n\");\n }\n return hash.digest(\"hex\");\n}\n\n/** The snapshot for a strategy record: its waves and estate plan in canonical form, with the sources' hash. */\nexport function estatePlanSnapshot(strategy: Pick<SiteStrategy, \"waves\" | \"estate\">, options: EstatePlanSnapshotOptions): EstatePlanSnapshot {\n const paths = sortedUnique((options.sources ?? ESTATE_PLAN_SNAPSHOT_SOURCES).map(assertSourcePath));\n return {\n kind: ESTATE_PLAN_SNAPSHOT_KIND,\n version: ESTATE_PLAN_SNAPSHOT_VERSION,\n sources: { paths, sha256: estatePlanSourceHash(options.projectRoot, paths) },\n ...canonicalEstatePlan(strategy),\n };\n}\n\n/** The snapshot as the file holds it: two-space JSON and a final newline. */\nexport function serialiseEstatePlanSnapshot(snapshot: EstatePlanSnapshot): string {\n return `${JSON.stringify(snapshot, null, 2)}\\n`;\n}\n\n/**\n * Writes `src/data/estate-plan.generated.json` when its content differs; `changed` says whether it did. Run it from the\n * site's tests or build so the snapshot follows the strategy record, and commit the file.\n */\nexport function writeEstatePlanSnapshot(\n strategy: Pick<SiteStrategy, \"waves\" | \"estate\">,\n options: EstatePlanSnapshotOptions\n): { readonly path: string; readonly changed: boolean; readonly snapshot: EstatePlanSnapshot } {\n const snapshot = estatePlanSnapshot(strategy, options);\n return { ...writeSnapshotFile(options.projectRoot, ESTATE_PLAN_SNAPSHOT_FILE, serialiseEstatePlanSnapshot(snapshot)), snapshot };\n}\n\n/** Writes a snapshot file when its text differs; `changed` says whether it did. Shared with the media snapshot. */\nexport function writeSnapshotFile(projectRoot: string, file: string, text: string): { readonly path: string; readonly changed: boolean } {\n const path = join(projectRoot, file);\n const current = existsSync(path) ? readFileSync(path, \"utf8\") : null;\n if (current === text) return { path: relative(projectRoot, path), changed: false };\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, text);\n return { path: relative(projectRoot, path), changed: true };\n}\n\n/** The folder list a snapshot records, checked and sorted. */\nexport function snapshotSourcePaths(sources: readonly string[] = ESTATE_PLAN_SNAPSHOT_SOURCES): string[] {\n return sortedUnique(sources.map(assertSourcePath));\n}\n"]}
package/dist/estate.d.ts CHANGED
@@ -9,6 +9,8 @@ export interface BrandlabEstateRoute {
9
9
  /** The route kind (a catalogue route has it): sitemap families and location checks read it. */
10
10
  kind?: string;
11
11
  id?: string;
12
+ /** The catalogue parent: a location page other location pages sit under is a region page. */
13
+ parentId?: string;
12
14
  /** The content date the sitemap's lastmod must carry. */
13
15
  lastSignificantChange?: string;
14
16
  locationDecision?: {
@@ -79,6 +81,11 @@ export interface BrandlabEstateTestOptions {
79
81
  };
80
82
  /** A place's centre for neighbour order (default: the registry's suburb centres). */
81
83
  centreOf?: SuburbCentreLookup;
84
+ /**
85
+ * The site's estate plan (site-core `siteStrategy.estate`, 3.14.0): its public slugs map a location page to the
86
+ * registry suburb it names (`ryde-suburb` → `ryde`) and mark region pages (`ryde`), which are not ranked by distance.
87
+ */
88
+ estate?: BrandlabEstateSlugPlan | null;
82
89
  /** The most inline markup one service-area map may carry (default 60 000 bytes, 3.6.0). */
83
90
  areaMapMaxBytes?: number;
84
91
  /**
@@ -98,6 +105,8 @@ export interface BrandlabPageQualityTestOptions {
98
105
  businessId?: string | false;
99
106
  /** A place's centre for similarity siblings (default: the registry's suburb centres). */
100
107
  centreOf?: SuburbCentreLookup;
108
+ /** The site's estate plan, as for the estate journey: public slugs resolve to the places they name. */
109
+ estate?: BrandlabEstateSlugPlan | null;
101
110
  concurrency?: number;
102
111
  timeout?: number;
103
112
  }
@@ -106,6 +115,25 @@ export interface BrandlabPageQualityFinding {
106
115
  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';
107
116
  detail: string;
108
117
  }
118
+ /** The public-slug part of site-core's SiteEstatePlan (mirrored: conformance compiles without site-core). */
119
+ export interface BrandlabEstateSlugPlan {
120
+ slugs?: {
121
+ /** Registry region slug → public slug, for example `{ 'ryde-area': 'ryde' }`. */
122
+ regions?: Readonly<Record<string, string>>;
123
+ /** Registry suburb slug → public slug, for example `{ ryde: 'ryde-suburb' }`. */
124
+ suburbs?: Readonly<Record<string, string>>;
125
+ };
126
+ }
127
+ /**
128
+ * A location route's place: the registry suburb slug its last path segment names (through the estate plan's public
129
+ * slugs, as site-core's `estatePublicSlug`), and whether it is a region page. A region page has no centre, so its
130
+ * nearby lists are not ranked by distance: it is a region when other location pages sit under it, when its slug is a
131
+ * region's public slug, or when its slug is a suburb's registry slug that the plan moved elsewhere (AA: `ryde`).
132
+ */
133
+ export declare function estatePlaceResolver(routes: readonly BrandlabEstateRoute[], estate?: BrandlabEstateSlugPlan | null): (route: BrandlabEstateRoute | undefined) => {
134
+ slug: string;
135
+ region: boolean;
136
+ } | undefined;
109
137
  export declare function defineBrandlabEstateTests(options: BrandlabEstateTestOptions): void;
110
138
  /**
111
139
  * Why a page's BreadcrumbList JSON-LD would fail Google's breadcrumb rich result (3.3.3): every
@@ -184,6 +212,16 @@ export declare function pageQualityRunFromEnv(env?: NodeJS.ProcessEnv): {
184
212
  reason: string;
185
213
  directory?: string;
186
214
  };
215
+ /**
216
+ * The internal pages a page's server HTML links, once each, without the page itself. Only anchors count: an area named
217
+ * as plain text (a `Coverage` entry with no `href`, 3.15.0) is no link.
218
+ */
219
+ export declare function pageLinkTargets(html: string, path: string, origin: string, base?: string): Set<string>;
220
+ /**
221
+ * Indexable pages that link a noindex location page (D4: list a place whose page waits for its wave as plain text,
222
+ * as site-core's `coverageEntries` does). `linksFrom` maps each page to the pages it links.
223
+ */
224
+ export declare function noindexLinkFindings(linksFrom: ReadonlyMap<string, ReadonlySet<string>>, routes: readonly Pick<BrandlabEstateRoute, 'path' | 'indexing' | 'kind'>[]): BrandlabPageQualityFinding[];
187
225
  /**
188
226
  * Absolute (`https://host`) and protocol-relative (`//host`) destinations leave
189
227
  * the site; browsers also read `/\host` and `\\host` as protocol-relative.
@@ -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
@@ -51,11 +51,29 @@ import { extractSearchCertificationHtmlSnapshot } from './search-certification.j
51
51
  import { changedFilesFromEnv, estateScopeFromEnv, sampleBySeed, sampleEstateRoutes } from './estate-sample.js';
52
52
  export { changedFilesFromEnv, changedFileStem, defaultRoutesForFile, estateScopeFromEnv, sampleBySeed, sampleEstateRoutes, } from './estate-sample.js';
53
53
  const LOCATION_KINDS = new Set(['location', 'service-location']);
54
- /** A location route's place slug: the last segment of its path. */
55
- function routePlace(route) {
56
- if (!route || !LOCATION_KINDS.has(route.kind ?? ''))
57
- return undefined;
58
- return normalisePath(route.path).split('/').filter(Boolean).at(-1);
54
+ /**
55
+ * A location route's place: the registry suburb slug its last path segment names (through the estate plan's public
56
+ * slugs, as site-core's `estatePublicSlug`), and whether it is a region page. A region page has no centre, so its
57
+ * nearby lists are not ranked by distance: it is a region when other location pages sit under it, when its slug is a
58
+ * region's public slug, or when its slug is a suburb's registry slug that the plan moved elsewhere (AA: `ryde`).
59
+ */
60
+ export function estatePlaceResolver(routes, estate) {
61
+ const parents = new Set(routes.filter((route) => LOCATION_KINDS.has(route.kind ?? '')).map((route) => route.parentId));
62
+ const regionSlugs = new Set(Object.values(estate?.slugs?.regions ?? {}));
63
+ const suburbRenames = estate?.slugs?.suburbs ?? {};
64
+ const suburbByPublic = new Map(Object.entries(suburbRenames).map(([registry, publicSlug]) => [publicSlug, registry]));
65
+ return (route) => {
66
+ if (!route || !LOCATION_KINDS.has(route.kind ?? ''))
67
+ return undefined;
68
+ const publicSlug = normalisePath(route.path).split('/').filter(Boolean).at(-1);
69
+ if (!publicSlug)
70
+ return undefined;
71
+ const suburb = suburbByPublic.get(publicSlug);
72
+ const region = (route.id !== undefined && parents.has(route.id)) ||
73
+ regionSlugs.has(publicSlug) ||
74
+ (suburb === undefined && Object.hasOwn(suburbRenames, publicSlug));
75
+ return { slug: suburb ?? publicSlug, region };
76
+ };
59
77
  }
60
78
  const BUSINESS_TYPES = new Set([
61
79
  'LocalBusiness', 'HomeAndConstructionBusiness', 'GeneralContractor', 'Plumber', 'Electrician',
@@ -81,6 +99,7 @@ export function defineBrandlabEstateTests(options) {
81
99
  const families = options.families ?? [];
82
100
  const familyPaths = new Set(families.flatMap((family) => family.paths.map(normalisePath)));
83
101
  const routeByPath = new Map(routes.map((route) => [normalisePath(route.path), route]));
102
+ const placeOfRoute = estatePlaceResolver(routes, options.estate);
84
103
  const homePath = normalisePath(routes.find((route) => route.kind === 'home')?.path ?? '/');
85
104
  // A draft pull request crawls a sample (BRANDLAB_ESTATE_SCOPE=sample); ready for review crawls every route.
86
105
  const scope = estateScopeFromEnv();
@@ -174,13 +193,16 @@ export function defineBrandlabEstateTests(options) {
174
193
  business: options.business,
175
194
  }))
176
195
  failures.push(`${route.path}: ${problem}`);
177
- const place = routePlace(route);
178
- if (place && centreOf(place)) {
196
+ const place = placeOfRoute(route);
197
+ if (place && !place.region && centreOf(place.slug)) {
179
198
  for (const problem of neighbourOrderProblems({
180
199
  html,
181
- origin: place,
200
+ origin: place.slug,
182
201
  pageUrl: expectedCanonical,
183
- placeOf: (path) => routePlace(routeByPath.get(path)),
202
+ placeOf: (path) => {
203
+ const linked = placeOfRoute(routeByPath.get(path));
204
+ return linked && !linked.region ? linked.slug : undefined;
205
+ },
184
206
  centreOf,
185
207
  }))
186
208
  failures.push(`${route.path}: ${problem}`);
@@ -575,6 +597,7 @@ export function defineBrandlabPageQualityTests(options) {
575
597
  const timeout = options.timeout ?? 240_000;
576
598
  const routes = options.routes.filter((route) => normalisePath(route.path) !== NOT_FOUND_ROUTE);
577
599
  const routeByPath = new Map(routes.map((route) => [normalisePath(route.path), route]));
600
+ const placeOfRoute = estatePlaceResolver(routes, options.estate);
578
601
  const run = pageQualityRunFromEnv();
579
602
  test.describe('Brandlab page quality (advisory)', () => {
580
603
  test('reports page and structured-data quality without failing', async ({ request }, testInfo) => {
@@ -608,9 +631,9 @@ export function defineBrandlabPageQualityTests(options) {
608
631
  if (formatProblems.length > 0)
609
632
  add('title-format', `"${snapshot.title}": ${formatProblems.join('; ')}`);
610
633
  titles.set(snapshot.title, [...(titles.get(snapshot.title) ?? []), route.path]);
611
- const place = routePlace(route);
634
+ const place = placeOfRoute(route);
612
635
  if (h1s.length === 1) {
613
- const problems = titleHeadingProblems(snapshot.title, h1s[0], place ? geography.placeNames.get(place) : undefined);
636
+ const problems = titleHeadingProblems(snapshot.title, h1s[0], place && !place.region ? geography.placeNames.get(place.slug) : undefined);
614
637
  if (problems.length > 0)
615
638
  add('title-h1', problems.join('; '));
616
639
  }
@@ -633,29 +656,17 @@ export function defineBrandlabPageQualityTests(options) {
633
656
  for (const failure of inspectStructuredData(route, snapshot.jsonLd, businessId)) {
634
657
  add('structured-data', failure.slice(route.path.length + 2));
635
658
  }
636
- const place = routePlace(route);
659
+ const place = placeOfRoute(route)?.slug;
637
660
  if (indexable && place)
638
661
  similarityPages.push({ path: route.path, place, blocks: textBlocks(html).map(geography.maskPlaces) });
639
- const targets = new Set();
640
- for (const href of extractLinks(html)) {
641
- const path = internalPath(href, expectedCanonical, origin);
642
- if (path && path !== normalisePath(route.path))
643
- targets.add(path);
644
- }
645
- linksFrom.set(normalisePath(route.path), targets);
662
+ linksFrom.set(normalisePath(route.path), pageLinkTargets(html, route.path, origin, expectedCanonical));
646
663
  });
647
664
  // Internal links: every indexable page needs a link from another page, and indexable pages link no noindex location page.
648
665
  const inbound = new Map();
649
- for (const [from, targets] of linksFrom) {
650
- const source = routeByPath.get(from);
651
- for (const target of targets) {
666
+ for (const targets of linksFrom.values())
667
+ for (const target of targets)
652
668
  inbound.set(target, (inbound.get(target) ?? 0) + 1);
653
- const linked = routeByPath.get(target);
654
- if (source?.indexing === 'index' && linked && linked.indexing !== 'index' && LOCATION_KINDS.has(linked.kind ?? '')) {
655
- findings.push({ path: from, kind: 'noindex-link', detail: `links to ${target}, a noindex location page (list unindexed places as plain text)` });
656
- }
657
- }
658
- }
669
+ findings.push(...noindexLinkFindings(linksFrom, routes));
659
670
  for (const route of routes) {
660
671
  const path = normalisePath(route.path);
661
672
  if (route.indexing === 'index' && route.kind !== 'home' && path !== '/' && linksFrom.has(path) && !inbound.get(path)) {
@@ -766,6 +777,38 @@ function inspectStructuredData(route, payloads, businessId) {
766
777
  failures.push(`${route.path}: JSON-LD does not reference ${businessId}`);
767
778
  return failures;
768
779
  }
780
+ /**
781
+ * The internal pages a page's server HTML links, once each, without the page itself. Only anchors count: an area named
782
+ * as plain text (a `Coverage` entry with no `href`, 3.15.0) is no link.
783
+ */
784
+ export function pageLinkTargets(html, path, origin, base = new URL(path, `${origin}/`).href) {
785
+ const targets = new Set();
786
+ for (const href of extractLinks(html)) {
787
+ const target = internalPath(href, base, origin);
788
+ if (target && target !== normalisePath(path))
789
+ targets.add(target);
790
+ }
791
+ return targets;
792
+ }
793
+ /**
794
+ * Indexable pages that link a noindex location page (D4: list a place whose page waits for its wave as plain text,
795
+ * as site-core's `coverageEntries` does). `linksFrom` maps each page to the pages it links.
796
+ */
797
+ export function noindexLinkFindings(linksFrom, routes) {
798
+ const routeByPath = new Map(routes.map((route) => [normalisePath(route.path), route]));
799
+ const findings = [];
800
+ for (const [from, targets] of linksFrom) {
801
+ if (routeByPath.get(from)?.indexing !== 'index')
802
+ continue;
803
+ for (const target of targets) {
804
+ const linked = routeByPath.get(target);
805
+ if (linked && linked.indexing !== 'index' && LOCATION_KINDS.has(linked.kind ?? '')) {
806
+ findings.push({ path: from, kind: 'noindex-link', detail: `links to ${target}, a noindex location page (list unindexed places as plain text)` });
807
+ }
808
+ }
809
+ }
810
+ return findings;
811
+ }
769
812
  function extractLinks(html) {
770
813
  return [...html.matchAll(/<a\b[^>]*\bhref=["']([^"']+)["']/gi)].map((match) => decodeXml(match[1]));
771
814
  }