@brandlab-digital/conformance 3.11.0 → 3.12.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.
Files changed (71) hide show
  1. package/dist/current-release.js +6 -6
  2. package/dist/current-release.js.map +1 -1
  3. package/dist/ready-cli.d.ts.map +1 -1
  4. package/dist/ready-cli.js +1 -0
  5. package/dist/ready-cli.js.map +1 -1
  6. package/dist/site-system/checks.d.ts +1 -1
  7. package/dist/site-system/checks.js +1 -1
  8. package/dist/site-system/checks.js.map +1 -1
  9. package/dist/site-system/rule-modules.d.ts.map +1 -1
  10. package/dist/site-system/rule-modules.js +5 -0
  11. package/dist/site-system/rule-modules.js.map +1 -1
  12. package/dist/site-system/rules/full/common.d.ts +37 -0
  13. package/dist/site-system/rules/full/common.d.ts.map +1 -0
  14. package/dist/site-system/rules/full/common.js +51 -0
  15. package/dist/site-system/rules/full/common.js.map +1 -0
  16. package/dist/site-system/rules/full/e10-phrases.d.ts +14 -0
  17. package/dist/site-system/rules/full/e10-phrases.d.ts.map +1 -0
  18. package/dist/site-system/rules/full/e10-phrases.js +69 -0
  19. package/dist/site-system/rules/full/e10-phrases.js.map +1 -0
  20. package/dist/site-system/rules/full/estate.d.ts +118 -0
  21. package/dist/site-system/rules/full/estate.d.ts.map +1 -0
  22. package/dist/site-system/rules/full/estate.js +447 -0
  23. package/dist/site-system/rules/full/estate.js.map +1 -0
  24. package/dist/site-system/rules/full/geography.d.ts +60 -0
  25. package/dist/site-system/rules/full/geography.d.ts.map +1 -0
  26. package/dist/site-system/rules/full/geography.js +111 -0
  27. package/dist/site-system/rules/full/geography.js.map +1 -0
  28. package/dist/site-system/rules/full/index.d.ts +33 -0
  29. package/dist/site-system/rules/full/index.d.ts.map +1 -0
  30. package/dist/site-system/rules/full/index.js +126 -0
  31. package/dist/site-system/rules/full/index.js.map +1 -0
  32. package/dist/site-system/rules/full/k12-manifests.d.ts +19 -0
  33. package/dist/site-system/rules/full/k12-manifests.d.ts.map +1 -0
  34. package/dist/site-system/rules/full/k12-manifests.js +130 -0
  35. package/dist/site-system/rules/full/k12-manifests.js.map +1 -0
  36. package/dist/site-system/rules/full/l10-location-copy.d.ts +15 -0
  37. package/dist/site-system/rules/full/l10-location-copy.d.ts.map +1 -0
  38. package/dist/site-system/rules/full/l10-location-copy.js +59 -0
  39. package/dist/site-system/rules/full/l10-location-copy.js.map +1 -0
  40. package/dist/site-system/rules/full/l3-neighbours.d.ts +16 -0
  41. package/dist/site-system/rules/full/l3-neighbours.d.ts.map +1 -0
  42. package/dist/site-system/rules/full/l3-neighbours.js +78 -0
  43. package/dist/site-system/rules/full/l3-neighbours.js.map +1 -0
  44. package/dist/site-system/rules/full/l4-local-copy.d.ts +27 -0
  45. package/dist/site-system/rules/full/l4-local-copy.d.ts.map +1 -0
  46. package/dist/site-system/rules/full/l4-local-copy.js +150 -0
  47. package/dist/site-system/rules/full/l4-local-copy.js.map +1 -0
  48. package/dist/site-system/rules/full/l9-data-contract.d.ts +15 -0
  49. package/dist/site-system/rules/full/l9-data-contract.d.ts.map +1 -0
  50. package/dist/site-system/rules/full/l9-data-contract.js +65 -0
  51. package/dist/site-system/rules/full/l9-data-contract.js.map +1 -0
  52. package/dist/site-system/rules/full/links.d.ts +23 -0
  53. package/dist/site-system/rules/full/links.d.ts.map +1 -0
  54. package/dist/site-system/rules/full/links.js +131 -0
  55. package/dist/site-system/rules/full/links.js.map +1 -0
  56. package/dist/site-system/rules/full/loc-index.d.ts +16 -0
  57. package/dist/site-system/rules/full/loc-index.d.ts.map +1 -0
  58. package/dist/site-system/rules/full/loc-index.js +59 -0
  59. package/dist/site-system/rules/full/loc-index.js.map +1 -0
  60. package/dist/site-system/rules/full/s4-route-files.d.ts +4 -0
  61. package/dist/site-system/rules/full/s4-route-files.d.ts.map +1 -0
  62. package/dist/site-system/rules/full/s4-route-files.js +125 -0
  63. package/dist/site-system/rules/full/s4-route-files.js.map +1 -0
  64. package/dist/site-system/rules/full/strategy.d.ts +43 -0
  65. package/dist/site-system/rules/full/strategy.d.ts.map +1 -0
  66. package/dist/site-system/rules/full/strategy.js +147 -0
  67. package/dist/site-system/rules/full/strategy.js.map +1 -0
  68. package/dist/site-system/runners.d.ts +2 -0
  69. package/dist/site-system/runners.d.ts.map +1 -1
  70. package/dist/site-system/runners.js.map +1 -1
  71. package/package.json +2 -2
@@ -0,0 +1 @@
1
+ {"version":3,"file":"l10-location-copy.js","sourceRoot":"","sources":["../../../../src/site-system/rules/full/l10-location-copy.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAC5E,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,EAAoB,MAAM,aAAa,CAAC;AACrF,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAE/C,MAAM,KAAK,GAAG,uJAAuJ,CAAC;AACtK,2GAA2G;AAC3G,MAAM,IAAI,GAAG,4DAA4D,CAAC;AAC1E,MAAM,mBAAmB,GAAG,IAAI,MAAM,CAAC,MAAM,KAAK,mCAAmC,EAAE,GAAG,CAAC,CAAC;AAC5F,MAAM,gBAAgB,GAAG,IAAI,GAAG,CAAC,CAAC,SAAS,EAAE,aAAa,EAAE,aAAa,EAAE,aAAa,CAAC,CAAC,CAAC;AAE3F;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAC,IAAY,EAAE,UAAsC,GAAG,EAAE,CAAC,KAAK;IACvF,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QACxC,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QAC1C,IAAI,mBAAmB,CAAC,IAAI,CAAC,MAAM,CAAC;YAAE,SAAS;QAC/C,MAAM,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACzD,IAAI,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,IAAI,KAAK,IAAI,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;YAAE,SAAS;QAC5G,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,OAAoB;IAC3C,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;IACpC,MAAM,QAAQ,GAAwB,EAAE,CAAC;IACzC,IAAI,OAAO,GAAG,CAAC,CAAC;IAChB,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QACxC,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI;YAAE,SAAS;QAC3D,OAAO,IAAI,CAAC,CAAC;QACb,uCAAuC;QACvC,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAC;QACxC,MAAM,OAAO,GAAG,CAAC,KAAa,EAAE,EAAE,CAAC,SAAS,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,KAAK,IAAI,CAAC;QACtF,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;YACnC,IAAI,IAAI,CAAC,OAAO,KAAK,SAAS;gBAAE,SAAS;YACzC,MAAM,EAAE,GAAG,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAChE,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC;gBAAE,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,wBAAwB,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;YAC/E,IAAI,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;gBAAE,KAAK,CAAC,GAAG,CAAC,OAAO,EAAE,YAAY,CAAC,CAAC;YAC/D,IAAI,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,SAAS,IAAI,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC;gBAAE,SAAS;YACpF,MAAM,IAAI,GAAG,SAAS,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;YAC3C,IAAI,IAAI,IAAI,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC;gBAAE,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,kCAAkC,IAAI,SAAS,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC;QACrJ,CAAC;QACD,IAAI,KAAK,CAAC,IAAI,GAAG,CAAC;YAAE,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,EAAE,wBAAwB,IAAI,CAAC,IAAI,QAAQ,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,sCAAsC,CAAC,CAAC,CAAC;IAC9K,CAAC;IACD,IAAI,OAAO,KAAK,CAAC;QAAE,OAAO,CAAC,aAAa,CAAC,KAAK,EAAE,mEAAmE,CAAC,CAAC,CAAC;IACtH,IAAI,CAAC,SAAS;QAAE,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,sCAAsC,OAAO,CAAC,gBAAgB,gEAAgE,CAAC,CAAC,CAAC;IACtL,OAAO,OAAO,CAAC,KAAK,EAAE,QAAQ,EAAE,6FAA6F,MAAM,CAAC,OAAO,EAAE,eAAe,CAAC,GAAG,CAAC,CAAC;AACpK,CAAC","sourcesContent":["/**\n * L10 for location copy (Site System v2 §4.4 step 4, banned on location pages): no kilometre figures, no \"km from\"\n * and no \"based in\" anywhere in a suburb, service-in-suburb or region page's content (verbatim reviews aside), and no\n * year stamp posing as local detail: a year in a line that names a place, outside a \"Month Year\" job or review date,\n * a price table and the proof strip. The patterns are the shell's (L10-hero), so the two checks never disagree.\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { BASED_IN, KM_FIGURE, KM_FROM } from \"../shell/location-wording.js\";\nimport { fail, notApplicable, outcome, plural, type FullContext } from \"./common.js\";\nimport { isLocationFamily } from \"./estate.js\";\n\nconst MONTH = \"(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\";\n/** A year from 1800 to 2099, or a decade (\"1950s\"), not part of a price, a longer number or a postcode. */\nconst YEAR = /(?<![$\\d.,/:-])\\b(1[89]\\d\\d|20\\d\\d)(?:'?s)?\\b(?![.,]?\\d)/gi;\nconst BEFORE_YEAR_ALLOWED = new RegExp(`(?:${MONTH}\\\\.?|nsw|postcode|©|\\\\(c\\\\))\\\\s*$`, \"i\");\nconst NO_YEAR_SECTIONS = new Set([\"reviews\", \"nearby-jobs\", \"price-guide\", \"proof-strip\"]);\n\n/**\n * The first year stamp in a line, or null. A number after a month is a date, and a number straight after a place\n * name (\"Abbotsford 2046\", \"Ryde NSW 2112\") is a postcode; `isPlace` says whether some words are a place name.\n */\nexport function yearStamp(line: string, isPlace: (words: string) => boolean = () => false): string | null {\n for (const match of line.matchAll(YEAR)) {\n const before = line.slice(0, match.index);\n if (BEFORE_YEAR_ALLOWED.test(before)) continue;\n const words = before.replace(/[,\\s]+$/, \"\").split(/\\s+/);\n if ([1, 2, 3, 4].some((count) => words.length >= count && isPlace(words.slice(-count).join(\" \")))) continue;\n return match[0];\n }\n return null;\n}\n\nexport function checkL10(context: FullContext): SiteSystemFinding[] {\n const geography = context.geography;\n const failures: SiteSystemFinding[] = [];\n let checked = 0;\n for (const page of context.estate.pages) {\n if (!isLocationFamily(page.family) || !page.copy) continue;\n checked += 1;\n // One example of each kind of problem.\n const found = new Map<string, string>();\n const isPlace = (words: string) => geography?.names.has(words.toLowerCase()) === true;\n for (const line of page.copy.lines) {\n if (line.section === \"reviews\") continue;\n const km = KM_FIGURE.exec(line.text) ?? KM_FROM.exec(line.text);\n if (km && !found.has(\"km\")) found.set(\"km\", `a kilometre figure (\"${km[0]}\")`);\n if (BASED_IN.test(line.text)) found.set(\"based\", `\"based in\"`);\n if (NO_YEAR_SECTIONS.has(line.section) || !geography || found.has(\"year\")) continue;\n const year = yearStamp(line.text, isPlace);\n if (year && geography.placesIn(line.text).length > 0) found.set(\"year\", `a year stamp as local detail (\"${year}\" in \"${line.text.slice(0, 80)}\")`);\n }\n if (found.size > 0) failures.push(fail(\"L10\", page.path, `The location copy on ${page.path} has ${[...found.values()].join(\" and \")}, which location pages do not carry.`));\n }\n if (checked === 0) return [notApplicable(\"L10\", \"No location page was read, so there is no location copy to check.\")];\n if (!geography) failures.push(fail(\"L10\", undefined, `The place names could not be read (${context.geographyProblem}), so year stamps posing as local detail could not be checked.`));\n return outcome(\"L10\", failures, `No kilometre figures, \"km from\", \"based in\" or year stamps as local detail in the copy of ${plural(checked, \"location page\")}.`);\n}\n"]}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * L3, neighbours by real distance (Site System v2 §4.4 D8). A location page's neighbour list (a list of 3 to 12 links
3
+ * to other pages of its own family) names the nearest suburbs that have a page of that family, nearest first, by the
4
+ * distance between suburb centres: never alphabetical rings or seeded shuffles. Only indexable pages count as
5
+ * candidates, so a page held for a later wave is never expected in a list. A line of copy that names three or more
6
+ * nearby suburbs ("Nearby areas include …") is held to the same order, so an alphabetical ring in text fails too.
7
+ */
8
+ import type { SiteSystemFinding } from "../../checks.js";
9
+ import { type FullContext } from "./common.js";
10
+ /** A list longer than this is a directory, not a neighbour list. */
11
+ export declare const L3_MAX_LIST = 12;
12
+ export declare const L3_MIN_LIST = 3;
13
+ /** A nearer page left out by less than this is a tie. */
14
+ export declare const L3_TOLERANCE_KM = 0.5;
15
+ export declare function checkL3(context: FullContext): SiteSystemFinding[];
16
+ //# sourceMappingURL=l3-neighbours.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"l3-neighbours.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/full/l3-neighbours.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,OAAO,EAAwC,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAIrF,oEAAoE;AACpE,eAAO,MAAM,WAAW,KAAK,CAAC;AAC9B,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,yDAAyD;AACzD,eAAO,MAAM,eAAe,MAAM,CAAC;AAGnC,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,GAAG,iBAAiB,EAAE,CA2DjE"}
@@ -0,0 +1,78 @@
1
+ import { distanceBetweenCentresKm, findNeighbourOrderProblem } from "../../../page-standard.js";
2
+ import { fail, notApplicable, outcome, plural } from "./common.js";
3
+ import { isLocationFamily } from "./estate.js";
4
+ import { placeName } from "./geography.js";
5
+ /** A list longer than this is a directory, not a neighbour list. */
6
+ export const L3_MAX_LIST = 12;
7
+ export const L3_MIN_LIST = 3;
8
+ /** A nearer page left out by less than this is a tie. */
9
+ export const L3_TOLERANCE_KM = 0.5;
10
+ const NEARBY_WORDS = /\b(?:nearby|neighbouring|neighboring|surrounding|nearest|close by)\b/i;
11
+ export function checkL3(context) {
12
+ const { estate, geography } = context;
13
+ if (!geography)
14
+ return [fail("L3", undefined, `The suburb centres could not be read (${context.geographyProblem}), so neighbour lists could not be checked.`)];
15
+ const byStem = new Map();
16
+ for (const page of estate.pages) {
17
+ if (!isLocationFamily(page.family) || page.place?.kind !== "suburb")
18
+ continue;
19
+ byStem.set(page.place.stem, [...(byStem.get(page.place.stem) ?? []), page]);
20
+ }
21
+ const failures = [];
22
+ let lists = 0;
23
+ for (const page of estate.pages) {
24
+ const place = page.place;
25
+ if (!isLocationFamily(page.family) || place?.kind !== "suburb")
26
+ continue;
27
+ const origin = geography.centreOf(place.slug);
28
+ if (!origin)
29
+ continue;
30
+ const family = byStem.get(place.stem) ?? [];
31
+ for (const line of page.copy?.lines ?? []) {
32
+ if (line.section === "reviews" || line.section === "nearby-jobs" || !NEARBY_WORDS.test(line.text))
33
+ continue;
34
+ const named = geography.placesIn(line.text).filter((slug) => slug !== place.slug && geography.suburbs.has(slug) && geography.centreOf(slug));
35
+ if (named.length < L3_MIN_LIST || named.length > L3_MAX_LIST)
36
+ continue;
37
+ lists += 1;
38
+ const order = findNeighbourOrderProblem(place.slug, named, geography.centreOf);
39
+ if (order)
40
+ failures.push(fail("L3", page.path, `The nearby suburbs named on ${page.path} are not nearest first: ${order.detail}.`));
41
+ }
42
+ for (const group of page.linkGroups) {
43
+ const listed = [];
44
+ for (const target of group) {
45
+ const other = estate.byPath.get(target);
46
+ if (!other || other === page || other.place?.stem !== place.stem || other.place.kind !== "suburb")
47
+ continue;
48
+ if (!listed.includes(other.place.slug))
49
+ listed.push(other.place.slug);
50
+ }
51
+ if (listed.length < L3_MIN_LIST || listed.length > L3_MAX_LIST)
52
+ continue;
53
+ lists += 1;
54
+ const order = findNeighbourOrderProblem(place.slug, listed, geography.centreOf);
55
+ if (order) {
56
+ failures.push(fail("L3", page.path, `The neighbour list on ${page.path} is not nearest first: ${order.detail}.`));
57
+ continue;
58
+ }
59
+ const distance = (slug) => {
60
+ const centre = geography.centreOf(slug);
61
+ return centre ? distanceBetweenCentresKm(origin, centre) : Number.POSITIVE_INFINITY;
62
+ };
63
+ const farthest = Math.max(...listed.map(distance).filter(Number.isFinite));
64
+ const nearer = family
65
+ .filter((other) => other !== page && other.indexable && other.place && !listed.includes(other.place.slug) && other.place.slug !== place.slug)
66
+ .map((other) => ({ slug: other.place.slug, km: distance(other.place.slug) }))
67
+ .filter((other) => other.km + L3_TOLERANCE_KM < farthest)
68
+ .sort((left, right) => left.km - right.km)[0];
69
+ if (nearer) {
70
+ failures.push(fail("L3", page.path, `The neighbour list on ${page.path} leaves out ${placeName(nearer.slug, geography)} (${nearer.km.toFixed(1)} km), which has a page and is nearer than suburbs it lists (up to ${farthest.toFixed(1)} km).`));
71
+ }
72
+ }
73
+ }
74
+ if (lists === 0)
75
+ return [notApplicable("L3", "No location page lists 3 to 12 neighbours of its own family.")];
76
+ return outcome("L3", failures, `${plural(lists, "neighbour list")} on location pages name the nearest suburbs with a page, nearest first.`);
77
+ }
78
+ //# sourceMappingURL=l3-neighbours.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"l3-neighbours.js","sourceRoot":"","sources":["../../../../src/site-system/rules/full/l3-neighbours.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * L3, neighbours by real distance (Site System v2 §4.4 D8). A location page's neighbour list (a list of 3 to 12 links\n * to other pages of its own family) names the nearest suburbs that have a page of that family, nearest first, by the\n * distance between suburb centres: never alphabetical rings or seeded shuffles. Only indexable pages count as\n * candidates, so a page held for a later wave is never expected in a list. A line of copy that names three or more\n * nearby suburbs (\"Nearby areas include …\") is held to the same order, so an alphabetical ring in text fails too.\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { distanceBetweenCentresKm, findNeighbourOrderProblem } from \"../../../page-standard.js\";\nimport { fail, notApplicable, outcome, plural, type FullContext } from \"./common.js\";\nimport { isLocationFamily, type EstatePage } from \"./estate.js\";\nimport { placeName } from \"./geography.js\";\n\n/** A list longer than this is a directory, not a neighbour list. */\nexport const L3_MAX_LIST = 12;\nexport const L3_MIN_LIST = 3;\n/** A nearer page left out by less than this is a tie. */\nexport const L3_TOLERANCE_KM = 0.5;\nconst NEARBY_WORDS = /\\b(?:nearby|neighbouring|neighboring|surrounding|nearest|close by)\\b/i;\n\nexport function checkL3(context: FullContext): SiteSystemFinding[] {\n const { estate, geography } = context;\n if (!geography) return [fail(\"L3\", undefined, `The suburb centres could not be read (${context.geographyProblem}), so neighbour lists could not be checked.`)];\n const byStem = new Map<string, EstatePage[]>();\n for (const page of estate.pages) {\n if (!isLocationFamily(page.family) || page.place?.kind !== \"suburb\") continue;\n byStem.set(page.place.stem, [...(byStem.get(page.place.stem) ?? []), page]);\n }\n const failures: SiteSystemFinding[] = [];\n let lists = 0;\n for (const page of estate.pages) {\n const place = page.place;\n if (!isLocationFamily(page.family) || place?.kind !== \"suburb\") continue;\n const origin = geography.centreOf(place.slug);\n if (!origin) continue;\n const family = byStem.get(place.stem) ?? [];\n for (const line of page.copy?.lines ?? []) {\n if (line.section === \"reviews\" || line.section === \"nearby-jobs\" || !NEARBY_WORDS.test(line.text)) continue;\n const named = geography.placesIn(line.text).filter((slug) => slug !== place.slug && geography.suburbs.has(slug) && geography.centreOf(slug));\n if (named.length < L3_MIN_LIST || named.length > L3_MAX_LIST) continue;\n lists += 1;\n const order = findNeighbourOrderProblem(place.slug, named, geography.centreOf);\n if (order) failures.push(fail(\"L3\", page.path, `The nearby suburbs named on ${page.path} are not nearest first: ${order.detail}.`));\n }\n for (const group of page.linkGroups) {\n const listed: string[] = [];\n for (const target of group) {\n const other = estate.byPath.get(target);\n if (!other || other === page || other.place?.stem !== place.stem || other.place.kind !== \"suburb\") continue;\n if (!listed.includes(other.place.slug)) listed.push(other.place.slug);\n }\n if (listed.length < L3_MIN_LIST || listed.length > L3_MAX_LIST) continue;\n lists += 1;\n const order = findNeighbourOrderProblem(place.slug, listed, geography.centreOf);\n if (order) {\n failures.push(fail(\"L3\", page.path, `The neighbour list on ${page.path} is not nearest first: ${order.detail}.`));\n continue;\n }\n const distance = (slug: string) => {\n const centre = geography.centreOf(slug);\n return centre ? distanceBetweenCentresKm(origin, centre) : Number.POSITIVE_INFINITY;\n };\n const farthest = Math.max(...listed.map(distance).filter(Number.isFinite));\n const nearer = family\n .filter((other) => other !== page && other.indexable && other.place && !listed.includes(other.place.slug) && other.place.slug !== place.slug)\n .map((other) => ({ slug: other.place!.slug, km: distance(other.place!.slug) }))\n .filter((other) => other.km + L3_TOLERANCE_KM < farthest)\n .sort((left, right) => left.km - right.km)[0];\n if (nearer) {\n failures.push(fail(\n \"L3\",\n page.path,\n `The neighbour list on ${page.path} leaves out ${placeName(nearer.slug, geography)} (${nearer.km.toFixed(1)} km), which has a page and is nearer than suburbs it lists (up to ${farthest.toFixed(1)} km).`,\n ));\n }\n }\n }\n if (lists === 0) return [notApplicable(\"L3\", \"No location page lists 3 to 12 neighbours of its own family.\")];\n return outcome(\"L3\", failures, `${plural(lists, \"neighbour list\")} on location pages name the nearest suburbs with a page, nearest first.`);\n}\n"]}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * L4, local copy that is really local (Site System v2 §4.3 item 5, §4.4 step 4), compared across the sibling pages of
3
+ * each family (pages sharing a stem such as `/services/decking/{place}`):
4
+ * - no copy rotated by page address: one section carrying two or more templates of a local sentence (the page's own
5
+ * suburb swapped in) that never appear together, each on two or more pages and a tenth of the family, that name
6
+ * the same other places, alternate inside one region and are worded differently. Variants keyed to an area, or one
7
+ * slot filled from data ("about 5 km" against "just down the road"), are data, not rotation (L10 catches distance
8
+ * wording);
9
+ * - no name-swapped FAQs: the same question and answer, naming the page's own place, on pages in two or more councils
10
+ * (or regions) with only the place names changed; an answer drawn from council data stays the same within its
11
+ * council, which is data, not a name swap;
12
+ * - no other suburb's job without the "nearby" label in the nearby-jobs section.
13
+ * A local line is one that names the page's own place; lines of 12 words or more are compared with the page's own
14
+ * place and every number masked (FAQs with every place name and postcode masked). Reviews and job cards are left out
15
+ * of the template comparison.
16
+ */
17
+ import type { SiteSystemFinding } from "../../checks.js";
18
+ import { type FullContext } from "./common.js";
19
+ export declare const L4_MIN_TEMPLATE_WORDS = 12;
20
+ /** Two templates sharing this much of their wording at the start and end are one sentence with a data slot. */
21
+ export declare const L4_SLOT_SHARE = 0.6;
22
+ /** Each rotated template is on at least this share of its family's pages (hash rotation spreads them evenly). */
23
+ export declare const L4_ROTATION_SHARE = 0.1;
24
+ /** A family needs this many pages before rotation can be told from coincidence. */
25
+ export declare const L4_MIN_ROTATION_PAGES = 4;
26
+ export declare function checkL4(context: FullContext): SiteSystemFinding[];
27
+ //# sourceMappingURL=l4-local-copy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"l4-local-copy.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/full/l4-local-copy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAAwC,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAIrF,eAAO,MAAM,qBAAqB,KAAK,CAAC;AACxC,+GAA+G;AAC/G,eAAO,MAAM,aAAa,MAAM,CAAC;AACjC,iHAAiH;AACjH,eAAO,MAAM,iBAAiB,MAAM,CAAC;AACrC,mFAAmF;AACnF,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAgIvC,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,GAAG,iBAAiB,EAAE,CAiBjE"}
@@ -0,0 +1,150 @@
1
+ import { fail, notApplicable, outcome, plural } from "./common.js";
2
+ import { isLocationFamily } from "./estate.js";
3
+ import { placeName } from "./geography.js";
4
+ export const L4_MIN_TEMPLATE_WORDS = 12;
5
+ /** Two templates sharing this much of their wording at the start and end are one sentence with a data slot. */
6
+ export const L4_SLOT_SHARE = 0.6;
7
+ /** Each rotated template is on at least this share of its family's pages (hash rotation spreads them evenly). */
8
+ export const L4_ROTATION_SHARE = 0.1;
9
+ /** A family needs this many pages before rotation can be told from coincidence. */
10
+ export const L4_MIN_ROTATION_PAGES = 4;
11
+ const NOT_TEMPLATED = new Set(["faqs", "nearby-jobs", "reviews"]);
12
+ function namesPlace(text, slug, geography) {
13
+ return geography.placesIn(text).includes(slug);
14
+ }
15
+ /** A line with only the page's own place and the numbers masked, so lines keyed to another area stay apart. */
16
+ function ownTemplate(text, page, geography) {
17
+ const name = placeName(page.place.slug, geography).toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+");
18
+ return text
19
+ .toLowerCase()
20
+ .replace(new RegExp(`\\b(?:the\\s+)?${name}\\b`, "g"), "{place}")
21
+ .replace(/\d+(?:[.,]\d+)*/g, "#")
22
+ .replace(/\s+/g, " ")
23
+ .trim();
24
+ }
25
+ /** The share of the shorter template's words that both templates open and close with. */
26
+ function sharedFrame(left, right) {
27
+ const a = left.split(" ");
28
+ const b = right.split(" ");
29
+ const shortest = Math.min(a.length, b.length);
30
+ let start = 0;
31
+ while (start < shortest && a[start] === b[start])
32
+ start += 1;
33
+ let end = 0;
34
+ while (end < shortest - start && a[a.length - 1 - end] === b[b.length - 1 - end])
35
+ end += 1;
36
+ return shortest === 0 ? 1 : (start + end) / shortest;
37
+ }
38
+ function regionOf(page, geography) {
39
+ const place = page.place;
40
+ return place.kind === "region" ? place.slug : (geography.suburbs.get(place.slug)?.region ?? place.slug);
41
+ }
42
+ function siblings(pages) {
43
+ const groups = new Map();
44
+ for (const page of pages) {
45
+ if (!isLocationFamily(page.family) || !page.place || !page.copy)
46
+ continue;
47
+ // Suburb pages compare with suburb pages, hubs with hubs.
48
+ const key = `${page.place.stem}|${page.place.kind}`;
49
+ groups.set(key, [...(groups.get(key) ?? []), page]);
50
+ }
51
+ return groups;
52
+ }
53
+ function rotationFindings(stem, pages, geography) {
54
+ if (pages.length < L4_MIN_ROTATION_PAGES)
55
+ return [];
56
+ // section -> template -> pages carrying it
57
+ const bySection = new Map();
58
+ for (const page of pages) {
59
+ for (const line of page.copy.lines) {
60
+ if (NOT_TEMPLATED.has(line.section) || line.text.split(/\s+/).length < L4_MIN_TEMPLATE_WORDS)
61
+ continue;
62
+ if (!namesPlace(line.text, page.place.slug, geography))
63
+ continue;
64
+ const templates = bySection.get(line.section) ?? new Map();
65
+ const key = ownTemplate(line.text, page, geography);
66
+ templates.set(key, (templates.get(key) ?? new Set()).add(page.path));
67
+ bySection.set(line.section, templates);
68
+ }
69
+ }
70
+ const regionByPath = new Map(pages.map((page) => [page.path, regionOf(page, geography)]));
71
+ const findings = [];
72
+ for (const [section, templates] of bySection) {
73
+ const shared = [...templates]
74
+ .filter(([, carriers]) => carriers.size >= Math.max(2, pages.length * L4_ROTATION_SHARE))
75
+ .map(([key, carriers]) => ({
76
+ key,
77
+ carriers,
78
+ regions: new Set([...carriers].map((path) => regionByPath.get(path))),
79
+ others: geography.placesIn(key).sort().join(" "),
80
+ }));
81
+ let rotated;
82
+ for (let left = 0; left < shared.length && !rotated; left += 1) {
83
+ for (let right = left + 1; right < shared.length && !rotated; right += 1) {
84
+ const a = shared[left];
85
+ const b = shared[right];
86
+ if (a.others !== b.others || sharedFrame(a.key, b.key) >= L4_SLOT_SHARE || [...a.carriers].some((path) => b.carriers.has(path)))
87
+ continue;
88
+ if ([...a.regions].some((region) => b.regions.has(region)))
89
+ rotated = [a.carriers, b.carriers];
90
+ }
91
+ }
92
+ if (!rotated)
93
+ continue;
94
+ const [first, second] = rotated;
95
+ const example = [...first][0];
96
+ const other = [...second].find((path) => regionByPath.get(path) === regionByPath.get(example)) ?? [...second][0];
97
+ findings.push(fail("L4", example, `The ${stem} pages rotate local copy by address in their ${section === "page" ? "content" : `"${section}" section`}: different templates of the same sentence with the suburb swapped in, for example on ${example} and ${other}.`));
98
+ }
99
+ return findings;
100
+ }
101
+ function faqFindings(pages, geography) {
102
+ const carriers = new Map();
103
+ for (const page of pages) {
104
+ const area = page.place.kind === "suburb" ? (geography.suburbs.get(page.place.slug)?.council ?? regionOf(page, geography)) : page.place.slug;
105
+ for (const faq of page.copy.faqs) {
106
+ const text = `${faq.question} ${faq.answer}`;
107
+ if (!namesPlace(text, page.place.slug, geography))
108
+ continue;
109
+ const key = geography.mask(text.toLowerCase()).replace(/\s+/g, " ").trim();
110
+ const entry = carriers.get(key) ?? { question: faq.question, paths: [], areas: new Set() };
111
+ if (!entry.paths.includes(page.path))
112
+ entry.paths.push(page.path);
113
+ entry.areas.add(area);
114
+ carriers.set(key, entry);
115
+ }
116
+ }
117
+ return [...carriers.values()]
118
+ .filter((entry) => entry.paths.length >= 2 && entry.areas.size >= 2)
119
+ .map((entry) => fail("L4", entry.paths[0], `The FAQ "${entry.question}" on ${entry.paths[0]} is name-swapped: the same question and answer appear on ${plural(entry.paths.length - 1, "other page")} (such as ${entry.paths[1]}) with the place name changed.`));
120
+ }
121
+ function jobFindings(page, geography) {
122
+ const findings = [];
123
+ for (const card of page.copy.jobs ?? []) {
124
+ if (/\bnearby\b/i.test(card))
125
+ continue;
126
+ const other = geography.placesIn(card).find((slug) => slug !== page.place.slug && geography.suburbs.has(slug));
127
+ if (other) {
128
+ findings.push(fail("L4", page.path, `${page.path} shows a job in ${placeName(other, geography)} without the "nearby" label.`));
129
+ }
130
+ }
131
+ return findings;
132
+ }
133
+ export function checkL4(context) {
134
+ const geography = context.geography;
135
+ if (!geography)
136
+ return [fail("L4", undefined, `The place names could not be read (${context.geographyProblem}), so local copy could not be compared.`)];
137
+ const groups = siblings(context.estate.pages);
138
+ if (groups.size === 0)
139
+ return [notApplicable("L4", "No location page with a known place was read, so there is no local copy to compare.")];
140
+ const failures = [];
141
+ let pages = 0;
142
+ for (const [key, family] of groups) {
143
+ pages += family.length;
144
+ failures.push(...rotationFindings(key.split("|")[0], family, geography), ...faqFindings(family, geography));
145
+ for (const page of family)
146
+ failures.push(...jobFindings(page, geography));
147
+ }
148
+ return outcome("L4", failures, `Local copy on ${plural(pages, "location page")} in ${plural(new Set([...groups.keys()].map((key) => key.split("|")[0])).size, "family", "families")} is not rotated by address, no FAQ is name-swapped and every other suburb's job is labelled nearby.`);
149
+ }
150
+ //# sourceMappingURL=l4-local-copy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"l4-local-copy.js","sourceRoot":"","sources":["../../../../src/site-system/rules/full/l4-local-copy.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * L4, local copy that is really local (Site System v2 §4.3 item 5, §4.4 step 4), compared across the sibling pages of\n * each family (pages sharing a stem such as `/services/decking/{place}`):\n * - no copy rotated by page address: one section carrying two or more templates of a local sentence (the page's own\n * suburb swapped in) that never appear together, each on two or more pages and a tenth of the family, that name\n * the same other places, alternate inside one region and are worded differently. Variants keyed to an area, or one\n * slot filled from data (\"about 5 km\" against \"just down the road\"), are data, not rotation (L10 catches distance\n * wording);\n * - no name-swapped FAQs: the same question and answer, naming the page's own place, on pages in two or more councils\n * (or regions) with only the place names changed; an answer drawn from council data stays the same within its\n * council, which is data, not a name swap;\n * - no other suburb's job without the \"nearby\" label in the nearby-jobs section.\n * A local line is one that names the page's own place; lines of 12 words or more are compared with the page's own\n * place and every number masked (FAQs with every place name and postcode masked). Reviews and job cards are left out\n * of the template comparison.\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { fail, notApplicable, outcome, plural, type FullContext } from \"./common.js\";\nimport { isLocationFamily, type EstatePage } from \"./estate.js\";\nimport { placeName, type FullGeography } from \"./geography.js\";\n\nexport const L4_MIN_TEMPLATE_WORDS = 12;\n/** Two templates sharing this much of their wording at the start and end are one sentence with a data slot. */\nexport const L4_SLOT_SHARE = 0.6;\n/** Each rotated template is on at least this share of its family's pages (hash rotation spreads them evenly). */\nexport const L4_ROTATION_SHARE = 0.1;\n/** A family needs this many pages before rotation can be told from coincidence. */\nexport const L4_MIN_ROTATION_PAGES = 4;\nconst NOT_TEMPLATED = new Set([\"faqs\", \"nearby-jobs\", \"reviews\"]);\n\nfunction namesPlace(text: string, slug: string, geography: FullGeography): boolean {\n return geography.placesIn(text).includes(slug);\n}\n\n/** A line with only the page's own place and the numbers masked, so lines keyed to another area stay apart. */\nfunction ownTemplate(text: string, page: EstatePage, geography: FullGeography): string {\n const name = placeName(page.place!.slug, geography).toLowerCase().replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\").replace(/\\s+/g, \"\\\\s+\");\n return text\n .toLowerCase()\n .replace(new RegExp(`\\\\b(?:the\\\\s+)?${name}\\\\b`, \"g\"), \"{place}\")\n .replace(/\\d+(?:[.,]\\d+)*/g, \"#\")\n .replace(/\\s+/g, \" \")\n .trim();\n}\n\n/** The share of the shorter template's words that both templates open and close with. */\nfunction sharedFrame(left: string, right: string): number {\n const a = left.split(\" \");\n const b = right.split(\" \");\n const shortest = Math.min(a.length, b.length);\n let start = 0;\n while (start < shortest && a[start] === b[start]) start += 1;\n let end = 0;\n while (end < shortest - start && a[a.length - 1 - end] === b[b.length - 1 - end]) end += 1;\n return shortest === 0 ? 1 : (start + end) / shortest;\n}\n\nfunction regionOf(page: EstatePage, geography: FullGeography): string {\n const place = page.place!;\n return place.kind === \"region\" ? place.slug : (geography.suburbs.get(place.slug)?.region ?? place.slug);\n}\n\nfunction siblings(pages: readonly EstatePage[]): Map<string, EstatePage[]> {\n const groups = new Map<string, EstatePage[]>();\n for (const page of pages) {\n if (!isLocationFamily(page.family) || !page.place || !page.copy) continue;\n // Suburb pages compare with suburb pages, hubs with hubs.\n const key = `${page.place.stem}|${page.place.kind}`;\n groups.set(key, [...(groups.get(key) ?? []), page]);\n }\n return groups;\n}\n\nfunction rotationFindings(stem: string, pages: readonly EstatePage[], geography: FullGeography): SiteSystemFinding[] {\n if (pages.length < L4_MIN_ROTATION_PAGES) return [];\n // section -> template -> pages carrying it\n const bySection = new Map<string, Map<string, Set<string>>>();\n for (const page of pages) {\n for (const line of page.copy!.lines) {\n if (NOT_TEMPLATED.has(line.section) || line.text.split(/\\s+/).length < L4_MIN_TEMPLATE_WORDS) continue;\n if (!namesPlace(line.text, page.place!.slug, geography)) continue;\n const templates = bySection.get(line.section) ?? new Map<string, Set<string>>();\n const key = ownTemplate(line.text, page, geography);\n templates.set(key, (templates.get(key) ?? new Set()).add(page.path));\n bySection.set(line.section, templates);\n }\n }\n const regionByPath = new Map(pages.map((page) => [page.path, regionOf(page, geography)]));\n const findings: SiteSystemFinding[] = [];\n for (const [section, templates] of bySection) {\n const shared = [...templates]\n .filter(([, carriers]) => carriers.size >= Math.max(2, pages.length * L4_ROTATION_SHARE))\n .map(([key, carriers]) => ({\n key,\n carriers,\n regions: new Set([...carriers].map((path) => regionByPath.get(path))),\n others: geography.placesIn(key).sort().join(\" \"),\n }));\n let rotated: [Set<string>, Set<string>] | undefined;\n for (let left = 0; left < shared.length && !rotated; left += 1) {\n for (let right = left + 1; right < shared.length && !rotated; right += 1) {\n const a = shared[left]!;\n const b = shared[right]!;\n if (a.others !== b.others || sharedFrame(a.key, b.key) >= L4_SLOT_SHARE || [...a.carriers].some((path) => b.carriers.has(path))) continue;\n if ([...a.regions].some((region) => b.regions.has(region))) rotated = [a.carriers, b.carriers];\n }\n }\n if (!rotated) continue;\n const [first, second] = rotated;\n const example = [...first][0]!;\n const other = [...second].find((path) => regionByPath.get(path) === regionByPath.get(example)) ?? [...second][0];\n findings.push(fail(\n \"L4\",\n example,\n `The ${stem} pages rotate local copy by address in their ${section === \"page\" ? \"content\" : `\"${section}\" section`}: different templates of the same sentence with the suburb swapped in, for example on ${example} and ${other}.`,\n ));\n }\n return findings;\n}\n\nfunction faqFindings(pages: readonly EstatePage[], geography: FullGeography): SiteSystemFinding[] {\n const carriers = new Map<string, { question: string; paths: string[]; areas: Set<string> }>();\n for (const page of pages) {\n const area = page.place!.kind === \"suburb\" ? (geography.suburbs.get(page.place!.slug)?.council ?? regionOf(page, geography)) : page.place!.slug;\n for (const faq of page.copy!.faqs) {\n const text = `${faq.question} ${faq.answer}`;\n if (!namesPlace(text, page.place!.slug, geography)) continue;\n const key = geography.mask(text.toLowerCase()).replace(/\\s+/g, \" \").trim();\n const entry = carriers.get(key) ?? { question: faq.question, paths: [], areas: new Set<string>() };\n if (!entry.paths.includes(page.path)) entry.paths.push(page.path);\n entry.areas.add(area);\n carriers.set(key, entry);\n }\n }\n return [...carriers.values()]\n .filter((entry) => entry.paths.length >= 2 && entry.areas.size >= 2)\n .map((entry) => fail(\n \"L4\",\n entry.paths[0]!,\n `The FAQ \"${entry.question}\" on ${entry.paths[0]} is name-swapped: the same question and answer appear on ${plural(entry.paths.length - 1, \"other page\")} (such as ${entry.paths[1]}) with the place name changed.`,\n ));\n}\n\nfunction jobFindings(page: EstatePage, geography: FullGeography): SiteSystemFinding[] {\n const findings: SiteSystemFinding[] = [];\n for (const card of page.copy!.jobs ?? []) {\n if (/\\bnearby\\b/i.test(card)) continue;\n const other = geography.placesIn(card).find((slug) => slug !== page.place!.slug && geography.suburbs.has(slug));\n if (other) {\n findings.push(fail(\"L4\", page.path, `${page.path} shows a job in ${placeName(other, geography)} without the \"nearby\" label.`));\n }\n }\n return findings;\n}\n\nexport function checkL4(context: FullContext): SiteSystemFinding[] {\n const geography = context.geography;\n if (!geography) return [fail(\"L4\", undefined, `The place names could not be read (${context.geographyProblem}), so local copy could not be compared.`)];\n const groups = siblings(context.estate.pages);\n if (groups.size === 0) return [notApplicable(\"L4\", \"No location page with a known place was read, so there is no local copy to compare.\")];\n const failures: SiteSystemFinding[] = [];\n let pages = 0;\n for (const [key, family] of groups) {\n pages += family.length;\n failures.push(...rotationFindings(key.split(\"|\")[0]!, family, geography), ...faqFindings(family, geography));\n for (const page of family) failures.push(...jobFindings(page, geography));\n }\n return outcome(\n \"L4\",\n failures,\n `Local copy on ${plural(pages, \"location page\")} in ${plural(new Set([...groups.keys()].map((key) => key.split(\"|\")[0])).size, \"family\", \"families\")} is not rotated by address, no FAQ is name-swapped and every other suburb's job is labelled nearby.`,\n );\n}\n"]}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * L9, the local data contract on every service-in-suburb page (Site System v2 §4.4 step 4):
3
+ * - D3: typical prices with a dollar figure, or a link to the cost guide while the family has no renderable price;
4
+ * - D4: the reviews band with two or more reviews, or its fallback (Google's rating and the profile link beside what
5
+ * it has). A profile with nothing to show, recorded in the manifest as a data reason, is a warning: a thin profile is
6
+ * visible, never a silent failure and never a reason to hold pages;
7
+ * - D11: the quote path on the page itself: the quote form with the suburb prefilled, a phone link and the call bar;
8
+ * - D2: the nearby-job card or none: a nearby-jobs section that renders shows jobs that name their suburb.
9
+ */
10
+ import type { SiteSystemFinding } from "../../checks.js";
11
+ import { type FullContext } from "./common.js";
12
+ import type { EstatePage } from "./estate.js";
13
+ export declare function contractFindings(page: EstatePage, context: FullContext): SiteSystemFinding[];
14
+ export declare function checkL9(context: FullContext): SiteSystemFinding[];
15
+ //# sourceMappingURL=l9-data-contract.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"l9-data-contract.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/full/l9-data-contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,OAAO,EAAyD,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AACtG,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAS9C,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,WAAW,GAAG,iBAAiB,EAAE,CAuC5F;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,GAAG,iBAAiB,EAAE,CAQjE"}
@@ -0,0 +1,65 @@
1
+ import { dollarFigures } from "../enquiry/common.js";
2
+ import { fail, listOf, notApplicable, outcome, plural, warning } from "./common.js";
3
+ import { isDataBackedReason } from "./k12-manifests.js";
4
+ import { placeName } from "./geography.js";
5
+ /** A cost-guide link in the page content (its link lists and sections), never the header, footer or navigation. */
6
+ function linksToCostGuide(page, context) {
7
+ return page.linkGroups.flat().some((target) => context.estate.byPath.get(target)?.family === "cost-guide" || /(?:^|[/-])cost(?:s|-guide)?(?:[/-]|$)/.test(target));
8
+ }
9
+ export function contractFindings(page, context) {
10
+ const copy = page.copy;
11
+ const findings = [];
12
+ const place = page.place && context.geography ? placeName(page.place.slug, context.geography) : undefined;
13
+ if (!(copy.prices !== null && dollarFigures(copy.prices).length > 0) && !linksToCostGuide(page, context)) {
14
+ findings.push(fail("L9", page.path, `${page.path} shows no typical price and no link to the cost guide (D3).`));
15
+ }
16
+ const reviews = copy.reviews;
17
+ if (!reviews) {
18
+ const skip = page.manifest.kind === "one" ? page.manifest.manifest.skipped.find((entry) => entry.id === "reviews") : undefined;
19
+ if (skip && isDataBackedReason(skip.reason)) {
20
+ findings.push(warning("L9", page.path, `${page.path} has no Google reviews or rating to show yet (${skip.reason}); the band and its fallback appear once the profile has them (D4).`));
21
+ }
22
+ else {
23
+ findings.push(fail("L9", page.path, `${page.path} shows neither reviews nor the fallback with Google's rating, count and link (D4).`));
24
+ }
25
+ }
26
+ else if (reviews.written < 2 && !(reviews.googleLink && reviews.rating)) {
27
+ findings.push(fail("L9", page.path, `${page.path} shows ${plural(reviews.written, "review")} without Google's rating and profile link beside them (D4 fallback).`));
28
+ }
29
+ const missing = [];
30
+ if (!copy.quoteForm)
31
+ missing.push("a quote form");
32
+ else if (!copy.quoteForm.suburbField)
33
+ missing.push("a suburb field in the quote form");
34
+ else if (!copy.quoteForm.suburbValue)
35
+ missing.push(`the suburb prefilled${place ? ` as ${place}` : ""}`);
36
+ else if (place && copy.quoteForm.suburbValue.toLowerCase() !== place.toLowerCase())
37
+ missing.push(`the suburb prefilled as ${place} (it says "${copy.quoteForm.suburbValue}")`);
38
+ if (!copy.telLink)
39
+ missing.push("a phone link");
40
+ if (!copy.callBar)
41
+ missing.push("the call bar");
42
+ if (missing.length > 0)
43
+ findings.push(fail("L9", page.path, `${page.path} is missing part of its quote path: ${listOf(missing)} (D11).`));
44
+ if (copy.jobs !== null && context.geography) {
45
+ const geography = context.geography;
46
+ if (copy.jobs.length === 0) {
47
+ findings.push(fail("L9", page.path, `${page.path} renders its nearby-jobs section with no job in it; with no job close enough the section is left out (D2).`));
48
+ }
49
+ else if (copy.jobs.some((card) => geography.placesIn(card).length === 0)) {
50
+ findings.push(fail("L9", page.path, `${page.path} shows a nearby job that does not name its suburb (D2).`));
51
+ }
52
+ }
53
+ return findings;
54
+ }
55
+ export function checkL9(context) {
56
+ const pages = context.estate.pages.filter((page) => page.family === "service-in-suburb" && page.copy);
57
+ if (pages.length === 0)
58
+ return [notApplicable("L9", "No service-in-suburb page was read.")];
59
+ const findings = pages.flatMap((page) => contractFindings(page, context));
60
+ if (!context.geography) {
61
+ findings.push(fail("L9", undefined, `The place names could not be read (${context.geographyProblem}), so the suburb prefill and the nearby-job cards could not be checked.`));
62
+ }
63
+ return outcome("L9", findings, `All ${plural(pages.length, "service-in-suburb page")} carry typical prices or the cost-guide link, reviews or their fallback, and the full quote path.`);
64
+ }
65
+ //# sourceMappingURL=l9-data-contract.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"l9-data-contract.js","sourceRoot":"","sources":["../../../../src/site-system/rules/full/l9-data-contract.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * L9, the local data contract on every service-in-suburb page (Site System v2 §4.4 step 4):\n * - D3: typical prices with a dollar figure, or a link to the cost guide while the family has no renderable price;\n * - D4: the reviews band with two or more reviews, or its fallback (Google's rating and the profile link beside what\n * it has). A profile with nothing to show, recorded in the manifest as a data reason, is a warning: a thin profile is\n * visible, never a silent failure and never a reason to hold pages;\n * - D11: the quote path on the page itself: the quote form with the suburb prefilled, a phone link and the call bar;\n * - D2: the nearby-job card or none: a nearby-jobs section that renders shows jobs that name their suburb.\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { dollarFigures } from \"../enquiry/common.js\";\nimport { fail, listOf, notApplicable, outcome, plural, warning, type FullContext } from \"./common.js\";\nimport type { EstatePage } from \"./estate.js\";\nimport { isDataBackedReason } from \"./k12-manifests.js\";\nimport { placeName } from \"./geography.js\";\n\n/** A cost-guide link in the page content (its link lists and sections), never the header, footer or navigation. */\nfunction linksToCostGuide(page: EstatePage, context: FullContext): boolean {\n return page.linkGroups.flat().some((target) => context.estate.byPath.get(target)?.family === \"cost-guide\" || /(?:^|[/-])cost(?:s|-guide)?(?:[/-]|$)/.test(target));\n}\n\nexport function contractFindings(page: EstatePage, context: FullContext): SiteSystemFinding[] {\n const copy = page.copy!;\n const findings: SiteSystemFinding[] = [];\n const place = page.place && context.geography ? placeName(page.place.slug, context.geography) : undefined;\n\n if (!(copy.prices !== null && dollarFigures(copy.prices).length > 0) && !linksToCostGuide(page, context)) {\n findings.push(fail(\"L9\", page.path, `${page.path} shows no typical price and no link to the cost guide (D3).`));\n }\n\n const reviews = copy.reviews;\n if (!reviews) {\n const skip = page.manifest.kind === \"one\" ? page.manifest.manifest.skipped.find((entry) => entry.id === \"reviews\") : undefined;\n if (skip && isDataBackedReason(skip.reason)) {\n findings.push(warning(\"L9\", page.path, `${page.path} has no Google reviews or rating to show yet (${skip.reason}); the band and its fallback appear once the profile has them (D4).`));\n } else {\n findings.push(fail(\"L9\", page.path, `${page.path} shows neither reviews nor the fallback with Google's rating, count and link (D4).`));\n }\n } else if (reviews.written < 2 && !(reviews.googleLink && reviews.rating)) {\n findings.push(fail(\"L9\", page.path, `${page.path} shows ${plural(reviews.written, \"review\")} without Google's rating and profile link beside them (D4 fallback).`));\n }\n\n const missing: string[] = [];\n if (!copy.quoteForm) missing.push(\"a quote form\");\n else if (!copy.quoteForm.suburbField) missing.push(\"a suburb field in the quote form\");\n else if (!copy.quoteForm.suburbValue) missing.push(`the suburb prefilled${place ? ` as ${place}` : \"\"}`);\n else if (place && copy.quoteForm.suburbValue.toLowerCase() !== place.toLowerCase()) missing.push(`the suburb prefilled as ${place} (it says \"${copy.quoteForm.suburbValue}\")`);\n if (!copy.telLink) missing.push(\"a phone link\");\n if (!copy.callBar) missing.push(\"the call bar\");\n if (missing.length > 0) findings.push(fail(\"L9\", page.path, `${page.path} is missing part of its quote path: ${listOf(missing)} (D11).`));\n\n if (copy.jobs !== null && context.geography) {\n const geography = context.geography;\n if (copy.jobs.length === 0) {\n findings.push(fail(\"L9\", page.path, `${page.path} renders its nearby-jobs section with no job in it; with no job close enough the section is left out (D2).`));\n } else if (copy.jobs.some((card) => geography.placesIn(card).length === 0)) {\n findings.push(fail(\"L9\", page.path, `${page.path} shows a nearby job that does not name its suburb (D2).`));\n }\n }\n return findings;\n}\n\nexport function checkL9(context: FullContext): SiteSystemFinding[] {\n const pages = context.estate.pages.filter((page) => page.family === \"service-in-suburb\" && page.copy);\n if (pages.length === 0) return [notApplicable(\"L9\", \"No service-in-suburb page was read.\")];\n const findings = pages.flatMap((page) => contractFindings(page, context));\n if (!context.geography) {\n findings.push(fail(\"L9\", undefined, `The place names could not be read (${context.geographyProblem}), so the suburb prefill and the nearby-job cards could not be checked.`));\n }\n return outcome(\"L9\", findings, `All ${plural(pages.length, \"service-in-suburb page\")} carry typical prices or the cost-guide link, reviews or their fallback, and the full quote path.`);\n}\n"]}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The link graph of the built estate (Site System v2 §4.4 step 7), computed from the route catalogue as built and the
3
+ * links in every page's server HTML, never by crawling:
4
+ * - E5: every indexable page but home has at least one link from another page;
5
+ * - L6: each indexable suburb and service-in-suburb page has 6 or more inbound links from 3 or more page types;
6
+ * - L7: each indexable location page is 3 clicks or fewer from home;
7
+ * - L8: each family of suburb pages has a hub in every region it serves (`{stem}` with the region, or `{region}-area`).
8
+ */
9
+ import type { SiteSystemFinding } from "../../checks.js";
10
+ import { type FullContext } from "./common.js";
11
+ import { type Estate } from "./estate.js";
12
+ export declare const L6_MIN_INBOUND = 6;
13
+ export declare const L6_MIN_PAGE_TYPES = 3;
14
+ export declare const L7_MAX_DEPTH = 3;
15
+ /** For each page, the other pages that link to it. */
16
+ export declare function inboundLinks(estate: Estate): Map<string, Set<string>>;
17
+ /** Clicks from home to every page it reaches. */
18
+ export declare function clickDepths(estate: Estate, home?: string): Map<string, number>;
19
+ export declare function checkE5(context: FullContext, inbound: ReadonlyMap<string, ReadonlySet<string>>): SiteSystemFinding[];
20
+ export declare function checkL6(context: FullContext, inbound: ReadonlyMap<string, ReadonlySet<string>>): SiteSystemFinding[];
21
+ export declare function checkL7(context: FullContext): SiteSystemFinding[];
22
+ export declare function checkL8(context: FullContext): SiteSystemFinding[];
23
+ //# sourceMappingURL=links.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"links.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/full/links.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAA4D,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AACzG,OAAO,EAAoB,KAAK,MAAM,EAAmB,MAAM,aAAa,CAAC;AAG7E,eAAO,MAAM,cAAc,IAAI,CAAC;AAChC,eAAO,MAAM,iBAAiB,IAAI,CAAC;AACnC,eAAO,MAAM,YAAY,IAAI,CAAC;AAE9B,sDAAsD;AACtD,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAMrE;AAED,iDAAiD;AACjD,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,SAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAkB3E;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC,GAAG,iBAAiB,EAAE,CAapH;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC,GAAG,iBAAiB,EAAE,CAkBpH;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,GAAG,iBAAiB,EAAE,CAcjE;AAOD,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,GAAG,iBAAiB,EAAE,CAmCjE"}