@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.
- package/dist/current-release.js +6 -6
- 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,131 @@
|
|
|
1
|
+
import { fail, listOf, needsBuild, notApplicable, outcome, plural } from "./common.js";
|
|
2
|
+
import { isLocationFamily } from "./estate.js";
|
|
3
|
+
import { placeName } from "./geography.js";
|
|
4
|
+
export const L6_MIN_INBOUND = 6;
|
|
5
|
+
export const L6_MIN_PAGE_TYPES = 3;
|
|
6
|
+
export const L7_MAX_DEPTH = 3;
|
|
7
|
+
/** For each page, the other pages that link to it. */
|
|
8
|
+
export function inboundLinks(estate) {
|
|
9
|
+
const inbound = new Map(estate.pages.map((page) => [page.path, new Set()]));
|
|
10
|
+
for (const page of estate.pages) {
|
|
11
|
+
for (const target of page.links)
|
|
12
|
+
inbound.get(target)?.add(page.path);
|
|
13
|
+
}
|
|
14
|
+
return inbound;
|
|
15
|
+
}
|
|
16
|
+
/** Clicks from home to every page it reaches. */
|
|
17
|
+
export function clickDepths(estate, home = "/") {
|
|
18
|
+
const depths = new Map();
|
|
19
|
+
if (!estate.byPath.has(home))
|
|
20
|
+
return depths;
|
|
21
|
+
depths.set(home, 0);
|
|
22
|
+
let frontier = [home];
|
|
23
|
+
while (frontier.length > 0) {
|
|
24
|
+
const next = [];
|
|
25
|
+
for (const path of frontier) {
|
|
26
|
+
const depth = depths.get(path);
|
|
27
|
+
for (const target of estate.byPath.get(path)?.links ?? []) {
|
|
28
|
+
if (!estate.byPath.has(target) || depths.has(target))
|
|
29
|
+
continue;
|
|
30
|
+
depths.set(target, depth + 1);
|
|
31
|
+
next.push(target);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
frontier = next;
|
|
35
|
+
}
|
|
36
|
+
return depths;
|
|
37
|
+
}
|
|
38
|
+
export function checkE5(context, inbound) {
|
|
39
|
+
const skip = needsBuild("E5", context.estate);
|
|
40
|
+
if (skip)
|
|
41
|
+
return [skip];
|
|
42
|
+
const failures = context.estate.missingHtml
|
|
43
|
+
.filter((item) => !item.location)
|
|
44
|
+
.map((item) => fail("E5", item.path, `${item.path} is a prerendered route with no HTML in the build, so nothing can link to it.`));
|
|
45
|
+
const indexable = context.estate.pages.filter((page) => page.indexable && page.path !== "/");
|
|
46
|
+
for (const page of indexable) {
|
|
47
|
+
if ((inbound.get(page.path)?.size ?? 0) === 0) {
|
|
48
|
+
failures.push(fail("E5", page.path, `${page.path} is indexable but no other page links to it.`));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return outcome("E5", failures, `Every one of ${plural(indexable.length, "indexable page")} other than home has a link from another page.`);
|
|
52
|
+
}
|
|
53
|
+
export function checkL6(context, inbound) {
|
|
54
|
+
const skip = needsBuild("L6", context.estate);
|
|
55
|
+
if (skip)
|
|
56
|
+
return [skip];
|
|
57
|
+
const failures = [];
|
|
58
|
+
const pages = context.estate.pages.filter((page) => page.indexable && (page.family === "suburb" || page.family === "service-in-suburb"));
|
|
59
|
+
if (pages.length === 0)
|
|
60
|
+
return [notApplicable("L6", "The build has no indexable suburb or service-in-suburb pages.")];
|
|
61
|
+
for (const page of pages) {
|
|
62
|
+
const sources = [...(inbound.get(page.path) ?? [])];
|
|
63
|
+
const types = [...new Set(sources.flatMap((source) => context.estate.byPath.get(source)?.family ?? []))].sort();
|
|
64
|
+
if (sources.length < L6_MIN_INBOUND || types.length < L6_MIN_PAGE_TYPES) {
|
|
65
|
+
failures.push(fail("L6", page.path, `${page.path} has ${plural(sources.length, "inbound link")} from ${plural(types.length, "page type")}${types.length > 0 ? ` (${listOf(types)})` : ""}; a location page needs ${L6_MIN_INBOUND} or more from ${L6_MIN_PAGE_TYPES} or more, such as its hub, suburb page, neighbours and service page.`));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return outcome("L6", failures, `All ${plural(pages.length, "suburb and service-in-suburb page")} have ${L6_MIN_INBOUND} or more inbound links from ${L6_MIN_PAGE_TYPES} or more page types.`);
|
|
69
|
+
}
|
|
70
|
+
export function checkL7(context) {
|
|
71
|
+
const skip = needsBuild("L7", context.estate);
|
|
72
|
+
if (skip)
|
|
73
|
+
return [skip];
|
|
74
|
+
const pages = context.estate.pages.filter((page) => page.indexable && isLocationFamily(page.family));
|
|
75
|
+
if (pages.length === 0)
|
|
76
|
+
return [notApplicable("L7", "The build has no indexable location pages.")];
|
|
77
|
+
if (!context.estate.byPath.has("/"))
|
|
78
|
+
return [fail("L7", "/", "The build has no home page, so click depth cannot be measured.")];
|
|
79
|
+
const depths = clickDepths(context.estate);
|
|
80
|
+
const failures = [];
|
|
81
|
+
for (const page of pages) {
|
|
82
|
+
const depth = depths.get(page.path);
|
|
83
|
+
if (depth === undefined)
|
|
84
|
+
failures.push(fail("L7", page.path, `${page.path} cannot be reached from home by following links.`));
|
|
85
|
+
else if (depth > L7_MAX_DEPTH)
|
|
86
|
+
failures.push(fail("L7", page.path, `${page.path} is ${depth} clicks from home; location pages are ${L7_MAX_DEPTH} or fewer.`));
|
|
87
|
+
}
|
|
88
|
+
return outcome("L7", failures, `All ${plural(pages.length, "location page")} are ${L7_MAX_DEPTH} clicks or fewer from home.`);
|
|
89
|
+
}
|
|
90
|
+
/** The hub path for a stem and region: `{stem}` with the region slug, or with `{region}-area`. */
|
|
91
|
+
function hubCandidates(stem, region) {
|
|
92
|
+
return [stem.replace("{place}", region), stem.replace("{place}", `${region}-area`)];
|
|
93
|
+
}
|
|
94
|
+
export function checkL8(context) {
|
|
95
|
+
const skip = needsBuild("L8", context.estate);
|
|
96
|
+
if (skip)
|
|
97
|
+
return [skip];
|
|
98
|
+
const geography = context.geography;
|
|
99
|
+
if (!geography)
|
|
100
|
+
return [fail("L8", undefined, `The regions of the suburbs could not be read (${context.geographyProblem}), so hubs could not be checked.`)];
|
|
101
|
+
const families = new Map();
|
|
102
|
+
for (const page of context.estate.pages) {
|
|
103
|
+
if (!page.indexable || (page.family !== "suburb" && page.family !== "service-in-suburb") || page.place?.kind !== "suburb")
|
|
104
|
+
continue;
|
|
105
|
+
const region = geography.suburbs.get(page.place.slug)?.region;
|
|
106
|
+
if (!region)
|
|
107
|
+
continue;
|
|
108
|
+
const regions = families.get(page.place.stem) ?? new Map();
|
|
109
|
+
regions.set(region, [...(regions.get(region) ?? []), page]);
|
|
110
|
+
families.set(page.place.stem, regions);
|
|
111
|
+
}
|
|
112
|
+
if (families.size === 0)
|
|
113
|
+
return [notApplicable("L8", "The build has no indexable suburb or service-in-suburb pages in registry suburbs.")];
|
|
114
|
+
const failures = [];
|
|
115
|
+
let hubs = 0;
|
|
116
|
+
for (const [stem, regions] of families) {
|
|
117
|
+
for (const [region, pages] of regions) {
|
|
118
|
+
const candidates = hubCandidates(stem, region);
|
|
119
|
+
const hub = candidates.map((path) => context.estate.byPath.get(path)).find((page) => page !== undefined);
|
|
120
|
+
if (hub?.indexable) {
|
|
121
|
+
hubs += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
failures.push(fail("L8", candidates[0], hub
|
|
125
|
+
? `The hub ${hub.path} for the ${stem} pages in ${placeName(region, geography)} is noindex; every family has an indexable hub in each region it serves.`
|
|
126
|
+
: `The ${stem} pages have ${plural(pages.length, "page")} in ${placeName(region, geography)} but no hub for that region (${candidates[0]}).`));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return outcome("L8", failures, `Every family of location pages has a hub in each region it serves (${plural(hubs, "hub")} across ${plural(families.size, "family", "families")}).`);
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=links.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"links.js","sourceRoot":"","sources":["../../../../src/site-system/rules/full/links.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The link graph of the built estate (Site System v2 §4.4 step 7), computed from the route catalogue as built and the\n * links in every page's server HTML, never by crawling:\n * - E5: every indexable page but home has at least one link from another page;\n * - L6: each indexable suburb and service-in-suburb page has 6 or more inbound links from 3 or more page types;\n * - L7: each indexable location page is 3 clicks or fewer from home;\n * - L8: each family of suburb pages has a hub in every region it serves (`{stem}` with the region, or `{region}-area`).\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { fail, listOf, needsBuild, notApplicable, outcome, plural, type FullContext } from \"./common.js\";\nimport { isLocationFamily, type Estate, type EstatePage } from \"./estate.js\";\nimport { placeName } from \"./geography.js\";\n\nexport const L6_MIN_INBOUND = 6;\nexport const L6_MIN_PAGE_TYPES = 3;\nexport const L7_MAX_DEPTH = 3;\n\n/** For each page, the other pages that link to it. */\nexport function inboundLinks(estate: Estate): Map<string, Set<string>> {\n const inbound = new Map<string, Set<string>>(estate.pages.map((page) => [page.path, new Set<string>()]));\n for (const page of estate.pages) {\n for (const target of page.links) inbound.get(target)?.add(page.path);\n }\n return inbound;\n}\n\n/** Clicks from home to every page it reaches. */\nexport function clickDepths(estate: Estate, home = \"/\"): Map<string, number> {\n const depths = new Map<string, number>();\n if (!estate.byPath.has(home)) return depths;\n depths.set(home, 0);\n let frontier = [home];\n while (frontier.length > 0) {\n const next: string[] = [];\n for (const path of frontier) {\n const depth = depths.get(path)!;\n for (const target of estate.byPath.get(path)?.links ?? []) {\n if (!estate.byPath.has(target) || depths.has(target)) continue;\n depths.set(target, depth + 1);\n next.push(target);\n }\n }\n frontier = next;\n }\n return depths;\n}\n\nexport function checkE5(context: FullContext, inbound: ReadonlyMap<string, ReadonlySet<string>>): SiteSystemFinding[] {\n const skip = needsBuild(\"E5\", context.estate);\n if (skip) return [skip];\n const failures: SiteSystemFinding[] = context.estate.missingHtml\n .filter((item) => !item.location)\n .map((item) => fail(\"E5\", item.path, `${item.path} is a prerendered route with no HTML in the build, so nothing can link to it.`));\n const indexable = context.estate.pages.filter((page) => page.indexable && page.path !== \"/\");\n for (const page of indexable) {\n if ((inbound.get(page.path)?.size ?? 0) === 0) {\n failures.push(fail(\"E5\", page.path, `${page.path} is indexable but no other page links to it.`));\n }\n }\n return outcome(\"E5\", failures, `Every one of ${plural(indexable.length, \"indexable page\")} other than home has a link from another page.`);\n}\n\nexport function checkL6(context: FullContext, inbound: ReadonlyMap<string, ReadonlySet<string>>): SiteSystemFinding[] {\n const skip = needsBuild(\"L6\", context.estate);\n if (skip) return [skip];\n const failures: SiteSystemFinding[] = [];\n const pages = context.estate.pages.filter((page) => page.indexable && (page.family === \"suburb\" || page.family === \"service-in-suburb\"));\n if (pages.length === 0) return [notApplicable(\"L6\", \"The build has no indexable suburb or service-in-suburb pages.\")];\n for (const page of pages) {\n const sources = [...(inbound.get(page.path) ?? [])];\n const types = [...new Set(sources.flatMap((source) => context.estate.byPath.get(source)?.family ?? []))].sort();\n if (sources.length < L6_MIN_INBOUND || types.length < L6_MIN_PAGE_TYPES) {\n failures.push(fail(\n \"L6\",\n page.path,\n `${page.path} has ${plural(sources.length, \"inbound link\")} from ${plural(types.length, \"page type\")}${types.length > 0 ? ` (${listOf(types)})` : \"\"}; a location page needs ${L6_MIN_INBOUND} or more from ${L6_MIN_PAGE_TYPES} or more, such as its hub, suburb page, neighbours and service page.`,\n ));\n }\n }\n return outcome(\"L6\", failures, `All ${plural(pages.length, \"suburb and service-in-suburb page\")} have ${L6_MIN_INBOUND} or more inbound links from ${L6_MIN_PAGE_TYPES} or more page types.`);\n}\n\nexport function checkL7(context: FullContext): SiteSystemFinding[] {\n const skip = needsBuild(\"L7\", context.estate);\n if (skip) return [skip];\n const pages = context.estate.pages.filter((page) => page.indexable && isLocationFamily(page.family));\n if (pages.length === 0) return [notApplicable(\"L7\", \"The build has no indexable location pages.\")];\n if (!context.estate.byPath.has(\"/\")) return [fail(\"L7\", \"/\", \"The build has no home page, so click depth cannot be measured.\")];\n const depths = clickDepths(context.estate);\n const failures: SiteSystemFinding[] = [];\n for (const page of pages) {\n const depth = depths.get(page.path);\n if (depth === undefined) failures.push(fail(\"L7\", page.path, `${page.path} cannot be reached from home by following links.`));\n else if (depth > L7_MAX_DEPTH) failures.push(fail(\"L7\", page.path, `${page.path} is ${depth} clicks from home; location pages are ${L7_MAX_DEPTH} or fewer.`));\n }\n return outcome(\"L7\", failures, `All ${plural(pages.length, \"location page\")} are ${L7_MAX_DEPTH} clicks or fewer from home.`);\n}\n\n/** The hub path for a stem and region: `{stem}` with the region slug, or with `{region}-area`. */\nfunction hubCandidates(stem: string, region: string): string[] {\n return [stem.replace(\"{place}\", region), stem.replace(\"{place}\", `${region}-area`)];\n}\n\nexport function checkL8(context: FullContext): SiteSystemFinding[] {\n const skip = needsBuild(\"L8\", context.estate);\n if (skip) return [skip];\n const geography = context.geography;\n if (!geography) return [fail(\"L8\", undefined, `The regions of the suburbs could not be read (${context.geographyProblem}), so hubs could not be checked.`)];\n const families = new Map<string, Map<string, EstatePage[]>>();\n for (const page of context.estate.pages) {\n if (!page.indexable || (page.family !== \"suburb\" && page.family !== \"service-in-suburb\") || page.place?.kind !== \"suburb\") continue;\n const region = geography.suburbs.get(page.place.slug)?.region;\n if (!region) continue;\n const regions = families.get(page.place.stem) ?? new Map<string, EstatePage[]>();\n regions.set(region, [...(regions.get(region) ?? []), page]);\n families.set(page.place.stem, regions);\n }\n if (families.size === 0) return [notApplicable(\"L8\", \"The build has no indexable suburb or service-in-suburb pages in registry suburbs.\")];\n const failures: SiteSystemFinding[] = [];\n let hubs = 0;\n for (const [stem, regions] of families) {\n for (const [region, pages] of regions) {\n const candidates = hubCandidates(stem, region);\n const hub = candidates.map((path) => context.estate.byPath.get(path)).find((page) => page !== undefined);\n if (hub?.indexable) {\n hubs += 1;\n continue;\n }\n failures.push(fail(\n \"L8\",\n candidates[0],\n hub\n ? `The hub ${hub.path} for the ${stem} pages in ${placeName(region, geography)} is noindex; every family has an indexable hub in each region it serves.`\n : `The ${stem} pages have ${plural(pages.length, \"page\")} in ${placeName(region, geography)} but no hub for that region (${candidates[0]}).`,\n ));\n }\n }\n return outcome(\"L8\", failures, `Every family of location pages has a hub in each region it serves (${plural(hubs, \"hub\")} across ${plural(families.size, \"family\", \"families\")}).`);\n}\n"]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The location indexing guard (Site System v2 §4.3 item 3, §4.4 step 8; D4, 20 Oct 2026): every location page stays
|
|
3
|
+
* indexed. A suburb, service-in-suburb or region page is indexable and listed in the sitemap, unless a wave in the
|
|
4
|
+
* recorded schedule (`waves` in strategy.ts) dates it in the future: until that day it may be noindex or out of the
|
|
5
|
+
* sitemap. A wave that is due has every page it lists built, indexable and in the sitemap. The schedule itself goes
|
|
6
|
+
* out in date order, 14 to 28 days apart, so it never holds a page open-ended. Nothing here suggests cutting, capping
|
|
7
|
+
* or noindexing a page.
|
|
8
|
+
*/
|
|
9
|
+
import type { SiteSystemFinding } from "../../checks.js";
|
|
10
|
+
import { type FullContext } from "./common.js";
|
|
11
|
+
/** The families the guard covers: the location families, council pages and the locations hub (v2 §4.1, §4.3 item 3). */
|
|
12
|
+
export declare const LOC_INDEX_FAMILIES: readonly ["suburb", "service-in-suburb", "region", "council", "locations-hub"];
|
|
13
|
+
/** Today's date in Sydney, YYYY-MM-DD. */
|
|
14
|
+
export declare function sydneyDay(date: Date): string;
|
|
15
|
+
export declare function checkLocationIndexing(context: FullContext): SiteSystemFinding[];
|
|
16
|
+
//# sourceMappingURL=loc-index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loc-index.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/full/loc-index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAAoD,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAEjG,wHAAwH;AACxH,eAAO,MAAM,kBAAkB,gFAAiF,CAAC;AAGjH,0CAA0C;AAC1C,wBAAgB,SAAS,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAE5C;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,WAAW,GAAG,iBAAiB,EAAE,CA4C/E"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { fail, needsBuild, notApplicable, outcome, plural } from "./common.js";
|
|
2
|
+
/** The families the guard covers: the location families, council pages and the locations hub (v2 §4.1, §4.3 item 3). */
|
|
3
|
+
export const LOC_INDEX_FAMILIES = ["suburb", "service-in-suburb", "region", "council", "locations-hub"];
|
|
4
|
+
import { FULL_STRATEGY_FILE, matchesWaveRoute } from "./strategy.js";
|
|
5
|
+
/** Today's date in Sydney, YYYY-MM-DD. */
|
|
6
|
+
export function sydneyDay(date) {
|
|
7
|
+
return new Intl.DateTimeFormat("en-CA", { timeZone: "Australia/Sydney", year: "numeric", month: "2-digit", day: "2-digit" }).format(date);
|
|
8
|
+
}
|
|
9
|
+
export function checkLocationIndexing(context) {
|
|
10
|
+
const skip = needsBuild("LOC-INDEX", context.estate);
|
|
11
|
+
if (skip)
|
|
12
|
+
return [skip];
|
|
13
|
+
const { estate, strategy, today } = context;
|
|
14
|
+
const failures = strategy.problems.waves.map((problem) => fail("LOC-INDEX", FULL_STRATEGY_FILE, problem));
|
|
15
|
+
const waves = strategy.waves;
|
|
16
|
+
const waveOf = (path) => waves.find((wave) => wave.routes.some((route) => matchesWaveRoute(path, route)));
|
|
17
|
+
const pages = estate.pages.filter((page) => LOC_INDEX_FAMILIES.includes(page.family ?? ""));
|
|
18
|
+
for (const missing of estate.missingHtml.filter((item) => item.location)) {
|
|
19
|
+
failures.push(fail("LOC-INDEX", missing.path, `${missing.path} is a prerendered location route with no HTML in the build, so it cannot be served or indexed.`));
|
|
20
|
+
}
|
|
21
|
+
if (pages.length === 0 && waves.length === 0)
|
|
22
|
+
return [...failures, notApplicable("LOC-INDEX", "The build has no location pages.")];
|
|
23
|
+
if (estate.sitemap === null && pages.length > 0) {
|
|
24
|
+
failures.push(fail("LOC-INDEX", undefined, "The build holds no sitemap file, so whether every location page is listed could not be checked."));
|
|
25
|
+
}
|
|
26
|
+
let live = 0;
|
|
27
|
+
let scheduled = 0;
|
|
28
|
+
for (const page of pages) {
|
|
29
|
+
const wave = waveOf(page.path);
|
|
30
|
+
if (wave && wave.releaseOn > today) {
|
|
31
|
+
scheduled += 1;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const dated = wave ? `its wave was due on ${wave.releaseOn}` : "no upcoming wave in the recorded schedule dates it";
|
|
35
|
+
const problems = [];
|
|
36
|
+
if (!page.indexable)
|
|
37
|
+
problems.push("is marked noindex");
|
|
38
|
+
if (estate.sitemap && !estate.sitemap.has(page.path))
|
|
39
|
+
problems.push("is not in the sitemap");
|
|
40
|
+
if (problems.length > 0) {
|
|
41
|
+
failures.push(fail("LOC-INDEX", page.path, `${page.path} is a location page that ${problems.join(" and ")}, and ${dated}; every location page is indexable and listed.`));
|
|
42
|
+
}
|
|
43
|
+
else
|
|
44
|
+
live += 1;
|
|
45
|
+
}
|
|
46
|
+
for (const wave of waves) {
|
|
47
|
+
if (wave.releaseOn > today)
|
|
48
|
+
continue;
|
|
49
|
+
for (const route of wave.routes) {
|
|
50
|
+
const matched = route.includes("*") ? estate.pages.some((page) => matchesWaveRoute(page.path, route)) : estate.byPath.has(route);
|
|
51
|
+
if (!matched) {
|
|
52
|
+
failures.push(fail("LOC-INDEX", route, `The wave of ${wave.releaseOn} released ${route}, but the build has no ${route.includes("*") ? "page matching it" : "such page"}.`));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const upcoming = scheduled > 0 ? `; ${plural(scheduled, "more page")} ${scheduled === 1 ? "is" : "are"} dated by upcoming waves` : "";
|
|
57
|
+
return outcome("LOC-INDEX", failures, `All ${plural(live, "released location page")} are indexable and in the sitemap${upcoming}.`);
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=loc-index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loc-index.js","sourceRoot":"","sources":["../../../../src/site-system/rules/full/loc-index.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,EAAoB,MAAM,aAAa,CAAC;AAEjG,wHAAwH;AACxH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,QAAQ,EAAE,mBAAmB,EAAE,QAAQ,EAAE,SAAS,EAAE,eAAe,CAAU,CAAC;AACjH,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAErE,0CAA0C;AAC1C,MAAM,UAAU,SAAS,CAAC,IAAU;IAClC,OAAO,IAAI,IAAI,CAAC,cAAc,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,kBAAkB,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,SAAS,EAAE,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;AAC5I,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,OAAoB;IACxD,MAAM,IAAI,GAAG,UAAU,CAAC,WAAW,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IACrD,IAAI,IAAI;QAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,OAAO,CAAC;IAC5C,MAAM,QAAQ,GAAwB,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,WAAW,EAAE,kBAAkB,EAAE,OAAO,CAAC,CAAC,CAAC;IAC/H,MAAM,KAAK,GAAG,QAAQ,CAAC,KAAK,CAAC;IAC7B,MAAM,MAAM,GAAG,CAAC,IAAY,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,gBAAgB,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC;IAClH,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAE,kBAAwC,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,CAAC;IACnH,KAAK,MAAM,OAAO,IAAI,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;QACzE,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,OAAO,CAAC,IAAI,EAAE,GAAG,OAAO,CAAC,IAAI,gGAAgG,CAAC,CAAC,CAAC;IAClK,CAAC;IACD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,GAAG,QAAQ,EAAE,aAAa,CAAC,WAAW,EAAE,kCAAkC,CAAC,CAAC,CAAC;IACnI,IAAI,MAAM,CAAC,OAAO,KAAK,IAAI,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAChD,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,SAAS,EAAE,iGAAiG,CAAC,CAAC,CAAC;IACjJ,CAAC;IAED,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,IAAI,SAAS,GAAG,CAAC,CAAC;IAClB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC/B,IAAI,IAAI,IAAI,IAAI,CAAC,SAAS,GAAG,KAAK,EAAE,CAAC;YACnC,SAAS,IAAI,CAAC,CAAC;YACf,SAAS;QACX,CAAC;QACD,MAAM,KAAK,GAAG,IAAI,CAAC,CAAC,CAAC,uBAAuB,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,oDAAoD,CAAC;QACpH,MAAM,QAAQ,GAAa,EAAE,CAAC;QAC9B,IAAI,CAAC,IAAI,CAAC,SAAS;YAAE,QAAQ,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC;QACxD,IAAI,MAAM,CAAC,OAAO,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,QAAQ,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC;QAC7F,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACxB,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,IAAI,4BAA4B,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,KAAK,gDAAgD,CAAC,CAAC,CAAC;QAC5K,CAAC;;YAAM,IAAI,IAAI,CAAC,CAAC;IACnB,CAAC;IAED,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,IAAI,CAAC,SAAS,GAAG,KAAK;YAAE,SAAS;QACrC,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YAChC,MAAM,OAAO,GAAG,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,gBAAgB,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACjI,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,KAAK,EAAE,eAAe,IAAI,CAAC,SAAS,aAAa,KAAK,0BAA0B,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,WAAW,GAAG,CAAC,CAAC,CAAC;YAC9K,CAAC;QACH,CAAC;IACH,CAAC;IACD,MAAM,QAAQ,GAAG,SAAS,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,SAAS,EAAE,WAAW,CAAC,IAAI,SAAS,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,0BAA0B,CAAC,CAAC,CAAC,EAAE,CAAC;IACtI,OAAO,OAAO,CAAC,WAAW,EAAE,QAAQ,EAAE,OAAO,MAAM,CAAC,IAAI,EAAE,wBAAwB,CAAC,oCAAoC,QAAQ,GAAG,CAAC,CAAC;AACtI,CAAC","sourcesContent":["/**\n * The location indexing guard (Site System v2 §4.3 item 3, §4.4 step 8; D4, 20 Oct 2026): every location page stays\n * indexed. A suburb, service-in-suburb or region page is indexable and listed in the sitemap, unless a wave in the\n * recorded schedule (`waves` in strategy.ts) dates it in the future: until that day it may be noindex or out of the\n * sitemap. A wave that is due has every page it lists built, indexable and in the sitemap. The schedule itself goes\n * out in date order, 14 to 28 days apart, so it never holds a page open-ended. Nothing here suggests cutting, capping\n * or noindexing a page.\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { fail, needsBuild, notApplicable, outcome, plural, type FullContext } from \"./common.js\";\n\n/** The families the guard covers: the location families, council pages and the locations hub (v2 §4.1, §4.3 item 3). */\nexport const LOC_INDEX_FAMILIES = [\"suburb\", \"service-in-suburb\", \"region\", \"council\", \"locations-hub\"] as const;\nimport { FULL_STRATEGY_FILE, matchesWaveRoute } from \"./strategy.js\";\n\n/** Today's date in Sydney, YYYY-MM-DD. */\nexport function sydneyDay(date: Date): string {\n return new Intl.DateTimeFormat(\"en-CA\", { timeZone: \"Australia/Sydney\", year: \"numeric\", month: \"2-digit\", day: \"2-digit\" }).format(date);\n}\n\nexport function checkLocationIndexing(context: FullContext): SiteSystemFinding[] {\n const skip = needsBuild(\"LOC-INDEX\", context.estate);\n if (skip) return [skip];\n const { estate, strategy, today } = context;\n const failures: SiteSystemFinding[] = strategy.problems.waves.map((problem) => fail(\"LOC-INDEX\", FULL_STRATEGY_FILE, problem));\n const waves = strategy.waves;\n const waveOf = (path: string) => waves.find((wave) => wave.routes.some((route) => matchesWaveRoute(path, route)));\n const pages = estate.pages.filter((page) => (LOC_INDEX_FAMILIES as readonly string[]).includes(page.family ?? \"\"));\n for (const missing of estate.missingHtml.filter((item) => item.location)) {\n failures.push(fail(\"LOC-INDEX\", missing.path, `${missing.path} is a prerendered location route with no HTML in the build, so it cannot be served or indexed.`));\n }\n if (pages.length === 0 && waves.length === 0) return [...failures, notApplicable(\"LOC-INDEX\", \"The build has no location pages.\")];\n if (estate.sitemap === null && pages.length > 0) {\n failures.push(fail(\"LOC-INDEX\", undefined, \"The build holds no sitemap file, so whether every location page is listed could not be checked.\"));\n }\n\n let live = 0;\n let scheduled = 0;\n for (const page of pages) {\n const wave = waveOf(page.path);\n if (wave && wave.releaseOn > today) {\n scheduled += 1;\n continue;\n }\n const dated = wave ? `its wave was due on ${wave.releaseOn}` : \"no upcoming wave in the recorded schedule dates it\";\n const problems: string[] = [];\n if (!page.indexable) problems.push(\"is marked noindex\");\n if (estate.sitemap && !estate.sitemap.has(page.path)) problems.push(\"is not in the sitemap\");\n if (problems.length > 0) {\n failures.push(fail(\"LOC-INDEX\", page.path, `${page.path} is a location page that ${problems.join(\" and \")}, and ${dated}; every location page is indexable and listed.`));\n } else live += 1;\n }\n\n for (const wave of waves) {\n if (wave.releaseOn > today) continue;\n for (const route of wave.routes) {\n const matched = route.includes(\"*\") ? estate.pages.some((page) => matchesWaveRoute(page.path, route)) : estate.byPath.has(route);\n if (!matched) {\n failures.push(fail(\"LOC-INDEX\", route, `The wave of ${wave.releaseOn} released ${route}, but the build has no ${route.includes(\"*\") ? \"page matching it\" : \"such page\"}.`));\n }\n }\n }\n const upcoming = scheduled > 0 ? `; ${plural(scheduled, \"more page\")} ${scheduled === 1 ? \"is\" : \"are\"} dated by upcoming waves` : \"\";\n return outcome(\"LOC-INDEX\", failures, `All ${plural(live, \"released location page\")} are indexable and in the sitemap${upcoming}.`);\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"s4-route-files.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/full/s4-route-files.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAAwC,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AA0CrF,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,GAAG,iBAAiB,EAAE,CA8EjE"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S4, pages from data (Site System v2 §4.1 URL rules): no hand-written per-service or per-suburb page files, and no
|
|
3
|
+
* dynamic route that builds zero pages. A source scan of the app directory finds:
|
|
4
|
+
* - a static page folder beside a dynamic one in the same folder (`services/blocked-drains/page.tsx` next to
|
|
5
|
+
* `services/[service]/`): a member of a generated family written by hand;
|
|
6
|
+
* - a static page folder named after a registry suburb or region (`locations/ryde/page.tsx`);
|
|
7
|
+
* - two or more static page folders in one folder whose built pages are service, suburb, region or
|
|
8
|
+
* service-in-suburb pages (by their manifests).
|
|
9
|
+
* The production build supplies the dynamic routes and how many pages each produced.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
12
|
+
import { join, relative } from "node:path";
|
|
13
|
+
import { fail, notApplicable, outcome, plural } from "./common.js";
|
|
14
|
+
const PAGE_FILE = /^page\.(?:tsx|ts|jsx|js|mdx)$/;
|
|
15
|
+
const GENERATED_FAMILIES = new Set(["service", "service-in-suburb", "suburb", "region"]);
|
|
16
|
+
function isDynamic(segment) {
|
|
17
|
+
return segment.startsWith("[");
|
|
18
|
+
}
|
|
19
|
+
/** Every folder under the app directory that adds a route segment or holds a page, groups and slots unwrapped. */
|
|
20
|
+
function scanApp(projectRoot, appDir) {
|
|
21
|
+
const folders = [];
|
|
22
|
+
const visit = (directory, segments) => {
|
|
23
|
+
const entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
|
|
24
|
+
const page = entries.find((entry) => entry.isFile() && PAGE_FILE.test(entry.name));
|
|
25
|
+
folders.push({
|
|
26
|
+
file: relative(projectRoot, directory) || ".",
|
|
27
|
+
segments,
|
|
28
|
+
dynamic: segments.some(isDynamic),
|
|
29
|
+
hasPage: page !== undefined,
|
|
30
|
+
pageFile: page ? relative(projectRoot, join(directory, page.name)) : undefined,
|
|
31
|
+
});
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (!entry.isDirectory() || entry.name.startsWith("_") || entry.name === "api" || entry.name === "node_modules")
|
|
34
|
+
continue;
|
|
35
|
+
const grouped = /^\(.*\)$/.test(entry.name) || entry.name.startsWith("@");
|
|
36
|
+
visit(join(directory, entry.name), grouped ? segments : [...segments, entry.name]);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
visit(appDir, []);
|
|
40
|
+
return folders;
|
|
41
|
+
}
|
|
42
|
+
export function checkS4(context) {
|
|
43
|
+
const appDir = ["src/app", "app"].map((path) => join(context.projectRoot, path)).find((path) => existsSync(path));
|
|
44
|
+
if (!appDir)
|
|
45
|
+
return [notApplicable("S4", "The project has no app directory to scan.")];
|
|
46
|
+
const folders = scanApp(context.projectRoot, appDir);
|
|
47
|
+
const failures = [];
|
|
48
|
+
const flagged = new Set();
|
|
49
|
+
const flag = (folder, message) => {
|
|
50
|
+
if (!folder.pageFile || flagged.has(folder.pageFile))
|
|
51
|
+
return;
|
|
52
|
+
flagged.add(folder.pageFile);
|
|
53
|
+
failures.push(fail("S4", folder.pageFile, message));
|
|
54
|
+
};
|
|
55
|
+
const parentKey = (folder) => folder.segments.slice(0, -1).join("/");
|
|
56
|
+
const pages = folders.filter((folder) => folder.hasPage && folder.segments.length > 0);
|
|
57
|
+
// Static pages beside a dynamic route in the same folder, when they build the same family (or, without families,
|
|
58
|
+
// below the root, where company pages sit beside a catch-all).
|
|
59
|
+
const routeFamily = (folder) => {
|
|
60
|
+
const route = `/${folder.segments.join("/")}`;
|
|
61
|
+
const counts = new Map();
|
|
62
|
+
for (const page of context.estate.pages)
|
|
63
|
+
if (page.srcRoute === route && page.family)
|
|
64
|
+
counts.set(page.family, (counts.get(page.family) ?? 0) + 1);
|
|
65
|
+
return [...counts].sort((left, right) => right[1] - left[1])[0]?.[0];
|
|
66
|
+
};
|
|
67
|
+
for (const folder of pages) {
|
|
68
|
+
if (folder.dynamic)
|
|
69
|
+
continue;
|
|
70
|
+
const parent = parentKey(folder);
|
|
71
|
+
const depth = folder.segments.length;
|
|
72
|
+
// A dynamic segment in the same place that builds pages, itself or below (`services/[service]/[suburb]`).
|
|
73
|
+
const generated = folders.find((other) => {
|
|
74
|
+
const segment = other.segments[depth - 1] ?? "";
|
|
75
|
+
return other.hasPage && other.segments.length >= depth && isDynamic(segment) && !segment.startsWith("[...") && !segment.startsWith("[[...") &&
|
|
76
|
+
other.segments.slice(0, depth - 1).join("/") === parent;
|
|
77
|
+
});
|
|
78
|
+
if (!generated)
|
|
79
|
+
continue;
|
|
80
|
+
const generatedFamily = generated.segments.length === depth ? routeFamily(generated) : undefined;
|
|
81
|
+
const ownFamily = context.estate.byPath.get(`/${folder.segments.join("/")}`)?.family;
|
|
82
|
+
const sameFamily = generatedFamily && ownFamily ? generatedFamily === ownFamily : ownFamily ? GENERATED_FAMILIES.has(ownFamily) : parent !== "";
|
|
83
|
+
if (sameFamily) {
|
|
84
|
+
flag(folder, `${folder.pageFile} is a hand-written page beside the generated route ${generated.file}; each member of a family comes from data through the dynamic route.`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Static pages named after a suburb or region.
|
|
88
|
+
const geography = context.geography;
|
|
89
|
+
if (geography) {
|
|
90
|
+
for (const folder of pages) {
|
|
91
|
+
const last = folder.segments.at(-1) ?? "";
|
|
92
|
+
if (folder.dynamic || (!geography.suburbs.has(last) && !geography.regions.has(last)))
|
|
93
|
+
continue;
|
|
94
|
+
flag(folder, `${folder.pageFile} is a hand-written page for ${last}; suburb and region pages come from data through a dynamic route.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Two or more static folders in one folder building service or location pages.
|
|
98
|
+
if (context.estate.complete) {
|
|
99
|
+
const byParent = new Map();
|
|
100
|
+
for (const folder of pages) {
|
|
101
|
+
if (folder.dynamic)
|
|
102
|
+
continue;
|
|
103
|
+
const family = context.estate.byPath.get(`/${folder.segments.join("/")}`)?.family;
|
|
104
|
+
if (!family || !GENERATED_FAMILIES.has(family))
|
|
105
|
+
continue;
|
|
106
|
+
byParent.set(parentKey(folder), [...(byParent.get(parentKey(folder)) ?? []), folder]);
|
|
107
|
+
}
|
|
108
|
+
for (const group of byParent.values()) {
|
|
109
|
+
if (group.length < 2)
|
|
110
|
+
continue;
|
|
111
|
+
for (const folder of group)
|
|
112
|
+
flag(folder, `${folder.pageFile} is one of ${group.length} hand-written ${context.estate.byPath.get(`/${folder.segments.join("/")}`)?.family} pages in one folder; these pages come from data through one dynamic route.`);
|
|
113
|
+
}
|
|
114
|
+
for (const [route, count] of context.estate.dynamicRoutes) {
|
|
115
|
+
if (count === 0) {
|
|
116
|
+
// Location routes are generated from data and never removed (D4); other routes may simply have nothing yet.
|
|
117
|
+
failures.push(fail("S4", route, /\[(?:\.\.\.)?(?:location|suburb|region|area|place|town|council)/i.test(route)
|
|
118
|
+
? `The dynamic route ${route} builds no pages; generate its pages from the location data.`
|
|
119
|
+
: `The dynamic route ${route} builds no pages; generate its pages from data, or take the route out if it has nothing to show.`));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return outcome("S4", failures, `${plural(pages.length, "page file")} in the app directory: no hand-written per-service or per-suburb page${context.estate.complete ? ", and every dynamic route builds pages" : ""}.`);
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=s4-route-files.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"s4-route-files.js","sourceRoot":"","sources":["../../../../src/site-system/rules/full/s4-route-files.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * S4, pages from data (Site System v2 §4.1 URL rules): no hand-written per-service or per-suburb page files, and no\n * dynamic route that builds zero pages. A source scan of the app directory finds:\n * - a static page folder beside a dynamic one in the same folder (`services/blocked-drains/page.tsx` next to\n * `services/[service]/`): a member of a generated family written by hand;\n * - a static page folder named after a registry suburb or region (`locations/ryde/page.tsx`);\n * - two or more static page folders in one folder whose built pages are service, suburb, region or\n * service-in-suburb pages (by their manifests).\n * The production build supplies the dynamic routes and how many pages each produced.\n */\nimport { existsSync, readdirSync } from \"node:fs\";\nimport { join, relative } from \"node:path\";\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { fail, notApplicable, outcome, plural, type FullContext } from \"./common.js\";\n\nconst PAGE_FILE = /^page\\.(?:tsx|ts|jsx|js|mdx)$/;\nconst GENERATED_FAMILIES = new Set([\"service\", \"service-in-suburb\", \"suburb\", \"region\"]);\n\ninterface RouteFolder {\n /** Relative to the project, for messages. */\n readonly file: string;\n /** The route segments, groups and slots removed. */\n readonly segments: readonly string[];\n readonly dynamic: boolean;\n readonly hasPage: boolean;\n readonly pageFile: string | undefined;\n}\n\nfunction isDynamic(segment: string): boolean {\n return segment.startsWith(\"[\");\n}\n\n/** Every folder under the app directory that adds a route segment or holds a page, groups and slots unwrapped. */\nfunction scanApp(projectRoot: string, appDir: string): RouteFolder[] {\n const folders: RouteFolder[] = [];\n const visit = (directory: string, segments: string[]) => {\n const entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));\n const page = entries.find((entry) => entry.isFile() && PAGE_FILE.test(entry.name));\n folders.push({\n file: relative(projectRoot, directory) || \".\",\n segments,\n dynamic: segments.some(isDynamic),\n hasPage: page !== undefined,\n pageFile: page ? relative(projectRoot, join(directory, page.name)) : undefined,\n });\n for (const entry of entries) {\n if (!entry.isDirectory() || entry.name.startsWith(\"_\") || entry.name === \"api\" || entry.name === \"node_modules\") continue;\n const grouped = /^\\(.*\\)$/.test(entry.name) || entry.name.startsWith(\"@\");\n visit(join(directory, entry.name), grouped ? segments : [...segments, entry.name]);\n }\n };\n visit(appDir, []);\n return folders;\n}\n\nexport function checkS4(context: FullContext): SiteSystemFinding[] {\n const appDir = [\"src/app\", \"app\"].map((path) => join(context.projectRoot, path)).find((path) => existsSync(path));\n if (!appDir) return [notApplicable(\"S4\", \"The project has no app directory to scan.\")];\n const folders = scanApp(context.projectRoot, appDir);\n const failures: SiteSystemFinding[] = [];\n const flagged = new Set<string>();\n const flag = (folder: RouteFolder, message: string) => {\n if (!folder.pageFile || flagged.has(folder.pageFile)) return;\n flagged.add(folder.pageFile);\n failures.push(fail(\"S4\", folder.pageFile, message));\n };\n const parentKey = (folder: RouteFolder) => folder.segments.slice(0, -1).join(\"/\");\n const pages = folders.filter((folder) => folder.hasPage && folder.segments.length > 0);\n\n // Static pages beside a dynamic route in the same folder, when they build the same family (or, without families,\n // below the root, where company pages sit beside a catch-all).\n const routeFamily = (folder: RouteFolder) => {\n const route = `/${folder.segments.join(\"/\")}`;\n const counts = new Map<string, number>();\n for (const page of context.estate.pages) if (page.srcRoute === route && page.family) counts.set(page.family, (counts.get(page.family) ?? 0) + 1);\n return [...counts].sort((left, right) => right[1] - left[1])[0]?.[0];\n };\n for (const folder of pages) {\n if (folder.dynamic) continue;\n const parent = parentKey(folder);\n const depth = folder.segments.length;\n // A dynamic segment in the same place that builds pages, itself or below (`services/[service]/[suburb]`).\n const generated = folders.find((other) => {\n const segment = other.segments[depth - 1] ?? \"\";\n return other.hasPage && other.segments.length >= depth && isDynamic(segment) && !segment.startsWith(\"[...\") && !segment.startsWith(\"[[...\") &&\n other.segments.slice(0, depth - 1).join(\"/\") === parent;\n });\n if (!generated) continue;\n const generatedFamily = generated.segments.length === depth ? routeFamily(generated) : undefined;\n const ownFamily = context.estate.byPath.get(`/${folder.segments.join(\"/\")}`)?.family;\n const sameFamily = generatedFamily && ownFamily ? generatedFamily === ownFamily : ownFamily ? GENERATED_FAMILIES.has(ownFamily) : parent !== \"\";\n if (sameFamily) {\n flag(folder, `${folder.pageFile} is a hand-written page beside the generated route ${generated.file}; each member of a family comes from data through the dynamic route.`);\n }\n }\n\n // Static pages named after a suburb or region.\n const geography = context.geography;\n if (geography) {\n for (const folder of pages) {\n const last = folder.segments.at(-1) ?? \"\";\n if (folder.dynamic || (!geography.suburbs.has(last) && !geography.regions.has(last))) continue;\n flag(folder, `${folder.pageFile} is a hand-written page for ${last}; suburb and region pages come from data through a dynamic route.`);\n }\n }\n\n // Two or more static folders in one folder building service or location pages.\n if (context.estate.complete) {\n const byParent = new Map<string, RouteFolder[]>();\n for (const folder of pages) {\n if (folder.dynamic) continue;\n const family = context.estate.byPath.get(`/${folder.segments.join(\"/\")}`)?.family;\n if (!family || !GENERATED_FAMILIES.has(family)) continue;\n byParent.set(parentKey(folder), [...(byParent.get(parentKey(folder)) ?? []), folder]);\n }\n for (const group of byParent.values()) {\n if (group.length < 2) continue;\n for (const folder of group) flag(folder, `${folder.pageFile} is one of ${group.length} hand-written ${context.estate.byPath.get(`/${folder.segments.join(\"/\")}`)?.family} pages in one folder; these pages come from data through one dynamic route.`);\n }\n for (const [route, count] of context.estate.dynamicRoutes) {\n if (count === 0) {\n // Location routes are generated from data and never removed (D4); other routes may simply have nothing yet.\n failures.push(fail(\"S4\", route, /\\[(?:\\.\\.\\.)?(?:location|suburb|region|area|place|town|council)/i.test(route)\n ? `The dynamic route ${route} builds no pages; generate its pages from the location data.`\n : `The dynamic route ${route} builds no pages; generate its pages from data, or take the route out if it has nothing to show.`));\n }\n }\n }\n return outcome(\n \"S4\",\n failures,\n `${plural(pages.length, \"page file\")} in the app directory: no hand-written per-service or per-suburb page${context.estate.complete ? \", and every dynamic route builds pages\" : \"\"}.`,\n );\n}\n"]}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export declare const FULL_STRATEGY_FILE = "src/data/strategy.ts";
|
|
2
|
+
/** As site-core's SITE_WAVE_MIN_GAP_DAYS and SITE_WAVE_MAX_GAP_DAYS. */
|
|
3
|
+
export declare const WAVE_MIN_GAP_DAYS = 14;
|
|
4
|
+
export declare const WAVE_MAX_GAP_DAYS = 28;
|
|
5
|
+
export interface FullStrategy {
|
|
6
|
+
readonly phrases: {
|
|
7
|
+
readonly mainTrade: string;
|
|
8
|
+
readonly owners: readonly {
|
|
9
|
+
readonly phrase: string;
|
|
10
|
+
readonly route: string;
|
|
11
|
+
}[];
|
|
12
|
+
} | null;
|
|
13
|
+
readonly waves: readonly {
|
|
14
|
+
readonly releaseOn: string;
|
|
15
|
+
readonly routes: readonly string[];
|
|
16
|
+
}[];
|
|
17
|
+
/** The trade `defineSectionOrder` names, if any. */
|
|
18
|
+
readonly trade: string | undefined;
|
|
19
|
+
/** Recorded alternative orders by family. */
|
|
20
|
+
readonly recordedOrders: ReadonlyMap<string, readonly string[]>;
|
|
21
|
+
/** What could not be read or breaks the schedule's rules; the checks that need it fail with these. */
|
|
22
|
+
readonly problems: {
|
|
23
|
+
readonly phrases: string[];
|
|
24
|
+
readonly waves: string[];
|
|
25
|
+
readonly orders: string[];
|
|
26
|
+
};
|
|
27
|
+
readonly found: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** As site-core's normaliseSitePhrase: lowercase words, "&" read as "and", punctuation dropped, a plural "s" dropped. */
|
|
30
|
+
export declare function normalisePhrase(phrase: string): string;
|
|
31
|
+
/** As site-core's matchesWaveRoute: an exact path, or a pattern with `*` (one segment) and `**` (any depth). */
|
|
32
|
+
export declare function matchesWaveRoute(path: string, route: string): boolean;
|
|
33
|
+
/** `today` (Sydney, YYYY-MM-DD) dates the schedule's next wave, which may not wait open-ended. */
|
|
34
|
+
export declare function readFullStrategy(projectRoot: string, today: string): FullStrategy;
|
|
35
|
+
/**
|
|
36
|
+
* The schedule's rules, as site-core's: valid dates, in order, 14 to 28 days apart, each route in one wave, and the next
|
|
37
|
+
* wave still to come at most 28 days after the last released one (or after today when none is).
|
|
38
|
+
*/
|
|
39
|
+
export declare function waveScheduleProblems(waves: readonly {
|
|
40
|
+
readonly releaseOn: string;
|
|
41
|
+
readonly routes: readonly string[];
|
|
42
|
+
}[], today: string): string[];
|
|
43
|
+
//# sourceMappingURL=strategy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"strategy.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/full/strategy.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,kBAAkB,yBAAyB,CAAC;AAEzD,wEAAwE;AACxE,eAAO,MAAM,iBAAiB,KAAK,CAAC;AACpC,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE;QAAE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,EAAE,SAAS;YAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;YAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;KAAE,GAAG,IAAI,CAAC;IACzI,QAAQ,CAAC,KAAK,EAAE,SAAS;QAAE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,EAAE,CAAC;IAC9F,oDAAoD;IACpD,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,6CAA6C;IAC7C,QAAQ,CAAC,cAAc,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;IAChE,sGAAsG;IACtG,QAAQ,CAAC,QAAQ,EAAE;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IACvG,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB;AAED,yHAAyH;AACzH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAStD;AAED,gHAAgH;AAChH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAOrE;AAKD,kGAAkG;AAClG,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,YAAY,CAoEjF;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,SAAS;IAAE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAiClJ"}
|