@brandlab-digital/conformance 3.11.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/current-release.js +5 -5
- package/dist/current-release.js.map +1 -1
- package/dist/ready-cli.d.ts.map +1 -1
- package/dist/ready-cli.js +1 -0
- package/dist/ready-cli.js.map +1 -1
- package/dist/site-system/checks.d.ts +1 -1
- package/dist/site-system/checks.js +1 -1
- package/dist/site-system/checks.js.map +1 -1
- package/dist/site-system/rule-modules.d.ts.map +1 -1
- package/dist/site-system/rule-modules.js +5 -0
- package/dist/site-system/rule-modules.js.map +1 -1
- package/dist/site-system/rules/full/common.d.ts +37 -0
- package/dist/site-system/rules/full/common.d.ts.map +1 -0
- package/dist/site-system/rules/full/common.js +51 -0
- package/dist/site-system/rules/full/common.js.map +1 -0
- package/dist/site-system/rules/full/e10-phrases.d.ts +14 -0
- package/dist/site-system/rules/full/e10-phrases.d.ts.map +1 -0
- package/dist/site-system/rules/full/e10-phrases.js +69 -0
- package/dist/site-system/rules/full/e10-phrases.js.map +1 -0
- package/dist/site-system/rules/full/estate.d.ts +118 -0
- package/dist/site-system/rules/full/estate.d.ts.map +1 -0
- package/dist/site-system/rules/full/estate.js +447 -0
- package/dist/site-system/rules/full/estate.js.map +1 -0
- package/dist/site-system/rules/full/geography.d.ts +60 -0
- package/dist/site-system/rules/full/geography.d.ts.map +1 -0
- package/dist/site-system/rules/full/geography.js +111 -0
- package/dist/site-system/rules/full/geography.js.map +1 -0
- package/dist/site-system/rules/full/index.d.ts +33 -0
- package/dist/site-system/rules/full/index.d.ts.map +1 -0
- package/dist/site-system/rules/full/index.js +126 -0
- package/dist/site-system/rules/full/index.js.map +1 -0
- package/dist/site-system/rules/full/k12-manifests.d.ts +19 -0
- package/dist/site-system/rules/full/k12-manifests.d.ts.map +1 -0
- package/dist/site-system/rules/full/k12-manifests.js +130 -0
- package/dist/site-system/rules/full/k12-manifests.js.map +1 -0
- package/dist/site-system/rules/full/l10-location-copy.d.ts +15 -0
- package/dist/site-system/rules/full/l10-location-copy.d.ts.map +1 -0
- package/dist/site-system/rules/full/l10-location-copy.js +59 -0
- package/dist/site-system/rules/full/l10-location-copy.js.map +1 -0
- package/dist/site-system/rules/full/l3-neighbours.d.ts +16 -0
- package/dist/site-system/rules/full/l3-neighbours.d.ts.map +1 -0
- package/dist/site-system/rules/full/l3-neighbours.js +78 -0
- package/dist/site-system/rules/full/l3-neighbours.js.map +1 -0
- package/dist/site-system/rules/full/l4-local-copy.d.ts +27 -0
- package/dist/site-system/rules/full/l4-local-copy.d.ts.map +1 -0
- package/dist/site-system/rules/full/l4-local-copy.js +150 -0
- package/dist/site-system/rules/full/l4-local-copy.js.map +1 -0
- package/dist/site-system/rules/full/l9-data-contract.d.ts +15 -0
- package/dist/site-system/rules/full/l9-data-contract.d.ts.map +1 -0
- package/dist/site-system/rules/full/l9-data-contract.js +65 -0
- package/dist/site-system/rules/full/l9-data-contract.js.map +1 -0
- package/dist/site-system/rules/full/links.d.ts +23 -0
- package/dist/site-system/rules/full/links.d.ts.map +1 -0
- package/dist/site-system/rules/full/links.js +131 -0
- package/dist/site-system/rules/full/links.js.map +1 -0
- package/dist/site-system/rules/full/loc-index.d.ts +16 -0
- package/dist/site-system/rules/full/loc-index.d.ts.map +1 -0
- package/dist/site-system/rules/full/loc-index.js +59 -0
- package/dist/site-system/rules/full/loc-index.js.map +1 -0
- package/dist/site-system/rules/full/s4-route-files.d.ts +4 -0
- package/dist/site-system/rules/full/s4-route-files.d.ts.map +1 -0
- package/dist/site-system/rules/full/s4-route-files.js +125 -0
- package/dist/site-system/rules/full/s4-route-files.js.map +1 -0
- package/dist/site-system/rules/full/strategy.d.ts +43 -0
- package/dist/site-system/rules/full/strategy.d.ts.map +1 -0
- package/dist/site-system/rules/full/strategy.js +147 -0
- package/dist/site-system/rules/full/strategy.js.map +1 -0
- package/dist/site-system/runners.d.ts +2 -0
- package/dist/site-system/runners.d.ts.map +1 -1
- package/dist/site-system/runners.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { fail, listOf, notApplicable, outcome, plural, warning } from "./common.js";
|
|
2
|
+
/** The families that carry a manifest (site-core BRANDLAB_PAGE_FAMILIES without the contact page). */
|
|
3
|
+
export const K12_FAMILIES = [
|
|
4
|
+
"home",
|
|
5
|
+
"service",
|
|
6
|
+
"service-in-suburb",
|
|
7
|
+
"region",
|
|
8
|
+
"suburb",
|
|
9
|
+
"cost-guide",
|
|
10
|
+
"services-hub",
|
|
11
|
+
"locations-hub",
|
|
12
|
+
"projects-hub",
|
|
13
|
+
"project",
|
|
14
|
+
"gallery",
|
|
15
|
+
"reviews",
|
|
16
|
+
"about",
|
|
17
|
+
"faqs",
|
|
18
|
+
"guides-hub",
|
|
19
|
+
"guide",
|
|
20
|
+
"council",
|
|
21
|
+
"emergency",
|
|
22
|
+
];
|
|
23
|
+
/** As site-core's isDataBackedSkipReason: the section's own data is absent, or its content is in the hero checklist. */
|
|
24
|
+
export function isDataBackedReason(reason) {
|
|
25
|
+
return reason === "merge-into-hero-checklist" || /^no-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(reason);
|
|
26
|
+
}
|
|
27
|
+
function sameList(left, right) {
|
|
28
|
+
return left.length === right.length && left.every((item, index) => item === right[index]);
|
|
29
|
+
}
|
|
30
|
+
export function manifestProblems(page, manifest, context) {
|
|
31
|
+
const problems = [];
|
|
32
|
+
const where = page.path;
|
|
33
|
+
const planned = manifest.planned.map((entry) => entry.id);
|
|
34
|
+
if (!K12_FAMILIES.includes(manifest.family)) {
|
|
35
|
+
return [`The manifest on ${where} names "${manifest.family}", which is not a page family that carries one.`];
|
|
36
|
+
}
|
|
37
|
+
if (manifest.order === "recorded") {
|
|
38
|
+
const recorded = context.strategy.recordedOrders.get(manifest.family);
|
|
39
|
+
if (!recorded)
|
|
40
|
+
problems.push(`The manifest on ${where} says its order is recorded, but strategy.ts records no order for ${manifest.family} pages.`);
|
|
41
|
+
else if (!sameList(planned, recorded))
|
|
42
|
+
problems.push(`The sections planned on ${where} (${planned.join(", ")}) are not the order recorded for ${manifest.family} pages (${recorded.join(", ")}).`);
|
|
43
|
+
}
|
|
44
|
+
const canonical = typeof context.defaultOrder === "string" ? undefined : context.defaultOrder(manifest.family, context.strategy.trade);
|
|
45
|
+
if (canonical) {
|
|
46
|
+
const ids = canonical.entries.map((entry) => entry.id);
|
|
47
|
+
if (manifest.order !== "recorded" && !sameList(planned, ids)) {
|
|
48
|
+
problems.push(`The sections planned on ${where} (${planned.join(", ")}) are not the canonical order for ${manifest.family} pages (${ids.join(", ")}).`);
|
|
49
|
+
}
|
|
50
|
+
// Each planned section keeps the requirement the family gives it, whatever the order.
|
|
51
|
+
const requirementOf = new Map(canonical.entries.map((entry) => [entry.id, entry.requirement]));
|
|
52
|
+
for (const entry of manifest.planned) {
|
|
53
|
+
const expected = requirementOf.get(entry.id);
|
|
54
|
+
if (expected !== undefined && expected !== entry.requirement) {
|
|
55
|
+
problems.push(`The manifest on ${where} marks "${entry.id}" as ${entry.requirement}; on ${manifest.family} pages it is ${expected}.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (manifest.cap !== canonical.cap)
|
|
59
|
+
problems.push(`The manifest on ${where} gives a cap of ${manifest.cap}; ${manifest.family} pages have a cap of ${canonical.cap}.`);
|
|
60
|
+
}
|
|
61
|
+
if (manifest.flags.includes("cap-exceeded") !== manifest.counted > manifest.cap) {
|
|
62
|
+
problems.push(`The manifest on ${where} counts ${manifest.counted} sections against a cap of ${manifest.cap} but ${manifest.flags.includes("cap-exceeded") ? "flags" : "does not flag"} cap-exceeded.`);
|
|
63
|
+
}
|
|
64
|
+
// What renders follows the plan, and the page shows exactly what the manifest lists.
|
|
65
|
+
let position = -1;
|
|
66
|
+
for (const entry of manifest.rendered) {
|
|
67
|
+
const index = planned.indexOf(entry.id, position + 1);
|
|
68
|
+
if (index === -1) {
|
|
69
|
+
problems.push(`The manifest on ${where} lists "${entry.id}" as rendered out of its planned place.`);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
position = index;
|
|
73
|
+
}
|
|
74
|
+
const listed = manifest.rendered.map((entry) => entry.section);
|
|
75
|
+
if (!sameList(page.sections, listed)) {
|
|
76
|
+
problems.push(`${where} renders the sections ${page.sections.join(", ") || "(none)"}, but its manifest lists ${listed.join(", ") || "(none)"}.`);
|
|
77
|
+
}
|
|
78
|
+
const renderedIds = new Set(manifest.rendered.map((entry) => entry.id));
|
|
79
|
+
for (const entry of manifest.planned) {
|
|
80
|
+
if (entry.requirement !== "R" && entry.requirement !== "C")
|
|
81
|
+
continue;
|
|
82
|
+
if (renderedIds.has(entry.id))
|
|
83
|
+
continue;
|
|
84
|
+
const reasons = manifest.skipped.filter((skip) => skip.id === entry.id).map((skip) => skip.reason);
|
|
85
|
+
if (reasons.some(isDataBackedReason))
|
|
86
|
+
continue;
|
|
87
|
+
const reason = reasons[0] ?? "no reason recorded";
|
|
88
|
+
problems.push(entry.requirement === "R"
|
|
89
|
+
? `${where} skips its required "${entry.id}" section without a reason drawn from its data (${reason}).`
|
|
90
|
+
: `${where} leaves out its conditional "${entry.id}" section without saying its data is absent (${reason}).`);
|
|
91
|
+
}
|
|
92
|
+
return problems;
|
|
93
|
+
}
|
|
94
|
+
export function checkK12(context) {
|
|
95
|
+
const failures = [];
|
|
96
|
+
let checked = 0;
|
|
97
|
+
if (typeof context.defaultOrder === "string") {
|
|
98
|
+
failures.push(fail("K12", undefined, `The canonical section orders could not be loaded (${context.defaultOrder}), so default orders were not compared.`));
|
|
99
|
+
}
|
|
100
|
+
for (const problem of context.strategy.problems.orders)
|
|
101
|
+
failures.push(fail("K12", "src/data/strategy.ts", problem));
|
|
102
|
+
for (const page of context.estate.pages) {
|
|
103
|
+
if (!K12_FAMILIES.includes(page.family ?? ""))
|
|
104
|
+
continue;
|
|
105
|
+
checked += 1;
|
|
106
|
+
const state = page.manifest;
|
|
107
|
+
if (state.kind === "none") {
|
|
108
|
+
failures.push(fail("K12", page.path, `${page.path} is a ${page.family} page with no section manifest; its sections go through SectionSequence.`));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (state.kind === "many") {
|
|
112
|
+
failures.push(fail("K12", page.path, `${page.path} carries ${state.count} section manifests; a page has one.`));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (state.kind === "invalid") {
|
|
116
|
+
failures.push(fail("K12", page.path, `The section manifest on ${page.path} ${state.reason}.`));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
for (const problem of manifestProblems(page, state.manifest, context))
|
|
120
|
+
failures.push(fail("K12", page.path, problem));
|
|
121
|
+
if (state.manifest.flags.includes("cap-exceeded")) {
|
|
122
|
+
failures.push(warning("K12", page.path, `${page.path} renders ${state.manifest.counted} sections against a cap of ${state.manifest.cap}, because its required and conditional sections all have data.`));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (checked === 0)
|
|
126
|
+
return [...failures, notApplicable("K12", "No page of a family that carries a section manifest was found.")];
|
|
127
|
+
const families = [...new Set(context.estate.pages.flatMap((page) => (K12_FAMILIES.includes(page.family ?? "") ? [page.family] : [])))];
|
|
128
|
+
return outcome("K12", failures, `${plural(checked, "page")} across ${listOf(families)} carry a section manifest with every required and conditional section accounted for, in the canonical or recorded order.`);
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=k12-manifests.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"k12-manifests.js","sourceRoot":"","sources":["../../../../src/site-system/rules/full/k12-manifests.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * K12 on every family (Site System v2 §3.3, §13.2): each page of a family carries one section manifest\n * (`<script type=\"application/json\" data-bl-manifest>`, written by SectionSequence). Every required section renders\n * or is skipped with a reason drawn from its data (`no-…`), never the sequencer's `not-provided`, `cap` or\n * `duplicate`; every conditional section that does not render says its data is absent; the planned order is the\n * family's canonical one or the alternative recorded in strategy.ts; and the page renders exactly the sections the\n * manifest lists, in that order. A page over its cap (`cap-exceeded`) is a warning, not a failure. The contact page\n * has its own check (K12-contact).\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { fail, listOf, notApplicable, outcome, plural, warning, type FullContext } from \"./common.js\";\nimport type { EstatePage, ManifestData } from \"./estate.js\";\n\n/** The families that carry a manifest (site-core BRANDLAB_PAGE_FAMILIES without the contact page). */\nexport const K12_FAMILIES = [\n \"home\",\n \"service\",\n \"service-in-suburb\",\n \"region\",\n \"suburb\",\n \"cost-guide\",\n \"services-hub\",\n \"locations-hub\",\n \"projects-hub\",\n \"project\",\n \"gallery\",\n \"reviews\",\n \"about\",\n \"faqs\",\n \"guides-hub\",\n \"guide\",\n \"council\",\n \"emergency\",\n] as const;\n\n/** As site-core's isDataBackedSkipReason: the section's own data is absent, or its content is in the hero checklist. */\nexport function isDataBackedReason(reason: string): boolean {\n return reason === \"merge-into-hero-checklist\" || /^no-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(reason);\n}\n\nfunction sameList(left: readonly string[], right: readonly string[]): boolean {\n return left.length === right.length && left.every((item, index) => item === right[index]);\n}\n\nexport function manifestProblems(page: EstatePage, manifest: ManifestData, context: FullContext): string[] {\n const problems: string[] = [];\n const where = page.path;\n const planned = manifest.planned.map((entry) => entry.id);\n\n if (!(K12_FAMILIES as readonly string[]).includes(manifest.family)) {\n return [`The manifest on ${where} names \"${manifest.family}\", which is not a page family that carries one.`];\n }\n if (manifest.order === \"recorded\") {\n const recorded = context.strategy.recordedOrders.get(manifest.family);\n if (!recorded) problems.push(`The manifest on ${where} says its order is recorded, but strategy.ts records no order for ${manifest.family} pages.`);\n else if (!sameList(planned, recorded)) problems.push(`The sections planned on ${where} (${planned.join(\", \")}) are not the order recorded for ${manifest.family} pages (${recorded.join(\", \")}).`);\n }\n const canonical = typeof context.defaultOrder === \"string\" ? undefined : context.defaultOrder(manifest.family, context.strategy.trade);\n if (canonical) {\n const ids = canonical.entries.map((entry) => entry.id);\n if (manifest.order !== \"recorded\" && !sameList(planned, ids)) {\n problems.push(`The sections planned on ${where} (${planned.join(\", \")}) are not the canonical order for ${manifest.family} pages (${ids.join(\", \")}).`);\n }\n // Each planned section keeps the requirement the family gives it, whatever the order.\n const requirementOf = new Map(canonical.entries.map((entry) => [entry.id, entry.requirement]));\n for (const entry of manifest.planned) {\n const expected = requirementOf.get(entry.id);\n if (expected !== undefined && expected !== entry.requirement) {\n problems.push(`The manifest on ${where} marks \"${entry.id}\" as ${entry.requirement}; on ${manifest.family} pages it is ${expected}.`);\n }\n }\n if (manifest.cap !== canonical.cap) problems.push(`The manifest on ${where} gives a cap of ${manifest.cap}; ${manifest.family} pages have a cap of ${canonical.cap}.`);\n }\n if (manifest.flags.includes(\"cap-exceeded\") !== manifest.counted > manifest.cap) {\n problems.push(`The manifest on ${where} counts ${manifest.counted} sections against a cap of ${manifest.cap} but ${manifest.flags.includes(\"cap-exceeded\") ? \"flags\" : \"does not flag\"} cap-exceeded.`);\n }\n\n // What renders follows the plan, and the page shows exactly what the manifest lists.\n let position = -1;\n for (const entry of manifest.rendered) {\n const index = planned.indexOf(entry.id, position + 1);\n if (index === -1) {\n problems.push(`The manifest on ${where} lists \"${entry.id}\" as rendered out of its planned place.`);\n break;\n }\n position = index;\n }\n const listed = manifest.rendered.map((entry) => entry.section);\n if (!sameList(page.sections, listed)) {\n problems.push(`${where} renders the sections ${page.sections.join(\", \") || \"(none)\"}, but its manifest lists ${listed.join(\", \") || \"(none)\"}.`);\n }\n\n const renderedIds = new Set(manifest.rendered.map((entry) => entry.id));\n for (const entry of manifest.planned) {\n if (entry.requirement !== \"R\" && entry.requirement !== \"C\") continue;\n if (renderedIds.has(entry.id)) continue;\n const reasons = manifest.skipped.filter((skip) => skip.id === entry.id).map((skip) => skip.reason);\n if (reasons.some(isDataBackedReason)) continue;\n const reason = reasons[0] ?? \"no reason recorded\";\n problems.push(\n entry.requirement === \"R\"\n ? `${where} skips its required \"${entry.id}\" section without a reason drawn from its data (${reason}).`\n : `${where} leaves out its conditional \"${entry.id}\" section without saying its data is absent (${reason}).`,\n );\n }\n return problems;\n}\n\nexport function checkK12(context: FullContext): SiteSystemFinding[] {\n const failures: SiteSystemFinding[] = [];\n let checked = 0;\n if (typeof context.defaultOrder === \"string\") {\n failures.push(fail(\"K12\", undefined, `The canonical section orders could not be loaded (${context.defaultOrder}), so default orders were not compared.`));\n }\n for (const problem of context.strategy.problems.orders) failures.push(fail(\"K12\", \"src/data/strategy.ts\", problem));\n for (const page of context.estate.pages) {\n if (!(K12_FAMILIES as readonly string[]).includes(page.family ?? \"\")) continue;\n checked += 1;\n const state = page.manifest;\n if (state.kind === \"none\") {\n failures.push(fail(\"K12\", page.path, `${page.path} is a ${page.family} page with no section manifest; its sections go through SectionSequence.`));\n continue;\n }\n if (state.kind === \"many\") {\n failures.push(fail(\"K12\", page.path, `${page.path} carries ${state.count} section manifests; a page has one.`));\n continue;\n }\n if (state.kind === \"invalid\") {\n failures.push(fail(\"K12\", page.path, `The section manifest on ${page.path} ${state.reason}.`));\n continue;\n }\n for (const problem of manifestProblems(page, state.manifest, context)) failures.push(fail(\"K12\", page.path, problem));\n if (state.manifest.flags.includes(\"cap-exceeded\")) {\n failures.push(warning(\"K12\", page.path, `${page.path} renders ${state.manifest.counted} sections against a cap of ${state.manifest.cap}, because its required and conditional sections all have data.`));\n }\n }\n if (checked === 0) return [...failures, notApplicable(\"K12\", \"No page of a family that carries a section manifest was found.\")];\n const families = [...new Set(context.estate.pages.flatMap((page) => ((K12_FAMILIES as readonly string[]).includes(page.family ?? \"\") ? [page.family!] : [])))];\n return outcome(\"K12\", failures, `${plural(checked, \"page\")} across ${listOf(families)} carry a section manifest with every required and conditional section accounted for, in the canonical or recorded order.`);\n}\n"]}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L10 for location copy (Site System v2 §4.4 step 4, banned on location pages): no kilometre figures, no "km from"
|
|
3
|
+
* and no "based in" anywhere in a suburb, service-in-suburb or region page's content (verbatim reviews aside), and no
|
|
4
|
+
* year stamp posing as local detail: a year in a line that names a place, outside a "Month Year" job or review date,
|
|
5
|
+
* a price table and the proof strip. The patterns are the shell's (L10-hero), so the two checks never disagree.
|
|
6
|
+
*/
|
|
7
|
+
import type { SiteSystemFinding } from "../../checks.js";
|
|
8
|
+
import { type FullContext } from "./common.js";
|
|
9
|
+
/**
|
|
10
|
+
* The first year stamp in a line, or null. A number after a month is a date, and a number straight after a place
|
|
11
|
+
* name ("Abbotsford 2046", "Ryde NSW 2112") is a postcode; `isPlace` says whether some words are a place name.
|
|
12
|
+
*/
|
|
13
|
+
export declare function yearStamp(line: string, isPlace?: (words: string) => boolean): string | null;
|
|
14
|
+
export declare function checkL10(context: FullContext): SiteSystemFinding[];
|
|
15
|
+
//# sourceMappingURL=l10-location-copy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"l10-location-copy.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/full/l10-location-copy.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,OAAO,EAAwC,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AASrF;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAqB,GAAG,MAAM,GAAG,IAAI,CASxG;AAED,wBAAgB,QAAQ,CAAC,OAAO,EAAE,WAAW,GAAG,iBAAiB,EAAE,CAwBlE"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { BASED_IN, KM_FIGURE, KM_FROM } from "../shell/location-wording.js";
|
|
2
|
+
import { fail, notApplicable, outcome, plural } from "./common.js";
|
|
3
|
+
import { isLocationFamily } from "./estate.js";
|
|
4
|
+
const 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)?)";
|
|
5
|
+
/** A year from 1800 to 2099, or a decade ("1950s"), not part of a price, a longer number or a postcode. */
|
|
6
|
+
const YEAR = /(?<![$\d.,/:-])\b(1[89]\d\d|20\d\d)(?:'?s)?\b(?![.,]?\d)/gi;
|
|
7
|
+
const BEFORE_YEAR_ALLOWED = new RegExp(`(?:${MONTH}\\.?|nsw|postcode|©|\\(c\\))\\s*$`, "i");
|
|
8
|
+
const NO_YEAR_SECTIONS = new Set(["reviews", "nearby-jobs", "price-guide", "proof-strip"]);
|
|
9
|
+
/**
|
|
10
|
+
* The first year stamp in a line, or null. A number after a month is a date, and a number straight after a place
|
|
11
|
+
* name ("Abbotsford 2046", "Ryde NSW 2112") is a postcode; `isPlace` says whether some words are a place name.
|
|
12
|
+
*/
|
|
13
|
+
export function yearStamp(line, isPlace = () => false) {
|
|
14
|
+
for (const match of line.matchAll(YEAR)) {
|
|
15
|
+
const before = line.slice(0, match.index);
|
|
16
|
+
if (BEFORE_YEAR_ALLOWED.test(before))
|
|
17
|
+
continue;
|
|
18
|
+
const words = before.replace(/[,\s]+$/, "").split(/\s+/);
|
|
19
|
+
if ([1, 2, 3, 4].some((count) => words.length >= count && isPlace(words.slice(-count).join(" "))))
|
|
20
|
+
continue;
|
|
21
|
+
return match[0];
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
export function checkL10(context) {
|
|
26
|
+
const geography = context.geography;
|
|
27
|
+
const failures = [];
|
|
28
|
+
let checked = 0;
|
|
29
|
+
for (const page of context.estate.pages) {
|
|
30
|
+
if (!isLocationFamily(page.family) || !page.copy)
|
|
31
|
+
continue;
|
|
32
|
+
checked += 1;
|
|
33
|
+
// One example of each kind of problem.
|
|
34
|
+
const found = new Map();
|
|
35
|
+
const isPlace = (words) => geography?.names.has(words.toLowerCase()) === true;
|
|
36
|
+
for (const line of page.copy.lines) {
|
|
37
|
+
if (line.section === "reviews")
|
|
38
|
+
continue;
|
|
39
|
+
const km = KM_FIGURE.exec(line.text) ?? KM_FROM.exec(line.text);
|
|
40
|
+
if (km && !found.has("km"))
|
|
41
|
+
found.set("km", `a kilometre figure ("${km[0]}")`);
|
|
42
|
+
if (BASED_IN.test(line.text))
|
|
43
|
+
found.set("based", `"based in"`);
|
|
44
|
+
if (NO_YEAR_SECTIONS.has(line.section) || !geography || found.has("year"))
|
|
45
|
+
continue;
|
|
46
|
+
const year = yearStamp(line.text, isPlace);
|
|
47
|
+
if (year && geography.placesIn(line.text).length > 0)
|
|
48
|
+
found.set("year", `a year stamp as local detail ("${year}" in "${line.text.slice(0, 80)}")`);
|
|
49
|
+
}
|
|
50
|
+
if (found.size > 0)
|
|
51
|
+
failures.push(fail("L10", page.path, `The location copy on ${page.path} has ${[...found.values()].join(" and ")}, which location pages do not carry.`));
|
|
52
|
+
}
|
|
53
|
+
if (checked === 0)
|
|
54
|
+
return [notApplicable("L10", "No location page was read, so there is no location copy to check.")];
|
|
55
|
+
if (!geography)
|
|
56
|
+
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.`));
|
|
57
|
+
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")}.`);
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=l10-location-copy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"l10-location-copy.js","sourceRoot":"","sources":["../../../../src/site-system/rules/full/l10-location-copy.ts"],"names":[],"mappings":"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","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"]}
|