@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.
Files changed (71) hide show
  1. package/dist/current-release.js +5 -5
  2. package/dist/current-release.js.map +1 -1
  3. package/dist/ready-cli.d.ts.map +1 -1
  4. package/dist/ready-cli.js +1 -0
  5. package/dist/ready-cli.js.map +1 -1
  6. package/dist/site-system/checks.d.ts +1 -1
  7. package/dist/site-system/checks.js +1 -1
  8. package/dist/site-system/checks.js.map +1 -1
  9. package/dist/site-system/rule-modules.d.ts.map +1 -1
  10. package/dist/site-system/rule-modules.js +5 -0
  11. package/dist/site-system/rule-modules.js.map +1 -1
  12. package/dist/site-system/rules/full/common.d.ts +37 -0
  13. package/dist/site-system/rules/full/common.d.ts.map +1 -0
  14. package/dist/site-system/rules/full/common.js +51 -0
  15. package/dist/site-system/rules/full/common.js.map +1 -0
  16. package/dist/site-system/rules/full/e10-phrases.d.ts +14 -0
  17. package/dist/site-system/rules/full/e10-phrases.d.ts.map +1 -0
  18. package/dist/site-system/rules/full/e10-phrases.js +69 -0
  19. package/dist/site-system/rules/full/e10-phrases.js.map +1 -0
  20. package/dist/site-system/rules/full/estate.d.ts +118 -0
  21. package/dist/site-system/rules/full/estate.d.ts.map +1 -0
  22. package/dist/site-system/rules/full/estate.js +447 -0
  23. package/dist/site-system/rules/full/estate.js.map +1 -0
  24. package/dist/site-system/rules/full/geography.d.ts +60 -0
  25. package/dist/site-system/rules/full/geography.d.ts.map +1 -0
  26. package/dist/site-system/rules/full/geography.js +111 -0
  27. package/dist/site-system/rules/full/geography.js.map +1 -0
  28. package/dist/site-system/rules/full/index.d.ts +33 -0
  29. package/dist/site-system/rules/full/index.d.ts.map +1 -0
  30. package/dist/site-system/rules/full/index.js +126 -0
  31. package/dist/site-system/rules/full/index.js.map +1 -0
  32. package/dist/site-system/rules/full/k12-manifests.d.ts +19 -0
  33. package/dist/site-system/rules/full/k12-manifests.d.ts.map +1 -0
  34. package/dist/site-system/rules/full/k12-manifests.js +130 -0
  35. package/dist/site-system/rules/full/k12-manifests.js.map +1 -0
  36. package/dist/site-system/rules/full/l10-location-copy.d.ts +15 -0
  37. package/dist/site-system/rules/full/l10-location-copy.d.ts.map +1 -0
  38. package/dist/site-system/rules/full/l10-location-copy.js +59 -0
  39. package/dist/site-system/rules/full/l10-location-copy.js.map +1 -0
  40. package/dist/site-system/rules/full/l3-neighbours.d.ts +16 -0
  41. package/dist/site-system/rules/full/l3-neighbours.d.ts.map +1 -0
  42. package/dist/site-system/rules/full/l3-neighbours.js +78 -0
  43. package/dist/site-system/rules/full/l3-neighbours.js.map +1 -0
  44. package/dist/site-system/rules/full/l4-local-copy.d.ts +27 -0
  45. package/dist/site-system/rules/full/l4-local-copy.d.ts.map +1 -0
  46. package/dist/site-system/rules/full/l4-local-copy.js +150 -0
  47. package/dist/site-system/rules/full/l4-local-copy.js.map +1 -0
  48. package/dist/site-system/rules/full/l9-data-contract.d.ts +15 -0
  49. package/dist/site-system/rules/full/l9-data-contract.d.ts.map +1 -0
  50. package/dist/site-system/rules/full/l9-data-contract.js +65 -0
  51. package/dist/site-system/rules/full/l9-data-contract.js.map +1 -0
  52. package/dist/site-system/rules/full/links.d.ts +23 -0
  53. package/dist/site-system/rules/full/links.d.ts.map +1 -0
  54. package/dist/site-system/rules/full/links.js +131 -0
  55. package/dist/site-system/rules/full/links.js.map +1 -0
  56. package/dist/site-system/rules/full/loc-index.d.ts +16 -0
  57. package/dist/site-system/rules/full/loc-index.d.ts.map +1 -0
  58. package/dist/site-system/rules/full/loc-index.js +59 -0
  59. package/dist/site-system/rules/full/loc-index.js.map +1 -0
  60. package/dist/site-system/rules/full/s4-route-files.d.ts +4 -0
  61. package/dist/site-system/rules/full/s4-route-files.d.ts.map +1 -0
  62. package/dist/site-system/rules/full/s4-route-files.js +125 -0
  63. package/dist/site-system/rules/full/s4-route-files.js.map +1 -0
  64. package/dist/site-system/rules/full/strategy.d.ts +43 -0
  65. package/dist/site-system/rules/full/strategy.d.ts.map +1 -0
  66. package/dist/site-system/rules/full/strategy.js +147 -0
  67. package/dist/site-system/rules/full/strategy.js.map +1 -0
  68. package/dist/site-system/runners.d.ts +2 -0
  69. package/dist/site-system/runners.d.ts.map +1 -1
  70. package/dist/site-system/runners.js.map +1 -1
  71. package/package.json +2 -2
@@ -0,0 +1,15 @@
1
+ /**
2
+ * L9, the local data contract on every service-in-suburb page (Site System v2 §4.4 step 4):
3
+ * - D3: typical prices with a dollar figure, or a link to the cost guide while the family has no renderable price;
4
+ * - D4: the reviews band with two or more reviews, or its fallback (Google's rating and the profile link beside what
5
+ * it has). A profile with nothing to show, recorded in the manifest as a data reason, is a warning: a thin profile is
6
+ * visible, never a silent failure and never a reason to hold pages;
7
+ * - D11: the quote path on the page itself: the quote form with the suburb prefilled, a phone link and the call bar;
8
+ * - D2: the nearby-job card or none: a nearby-jobs section that renders shows jobs that name their suburb.
9
+ */
10
+ import type { SiteSystemFinding } from "../../checks.js";
11
+ import { type FullContext } from "./common.js";
12
+ import type { EstatePage } from "./estate.js";
13
+ export declare function contractFindings(page: EstatePage, context: FullContext): SiteSystemFinding[];
14
+ export declare function checkL9(context: FullContext): SiteSystemFinding[];
15
+ //# sourceMappingURL=l9-data-contract.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"l9-data-contract.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/full/l9-data-contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,OAAO,EAAyD,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AACtG,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAS9C,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,WAAW,GAAG,iBAAiB,EAAE,CAuC5F;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,GAAG,iBAAiB,EAAE,CAQjE"}
@@ -0,0 +1,65 @@
1
+ import { dollarFigures } from "../enquiry/common.js";
2
+ import { fail, listOf, notApplicable, outcome, plural, warning } from "./common.js";
3
+ import { isDataBackedReason } from "./k12-manifests.js";
4
+ import { placeName } from "./geography.js";
5
+ /** A cost-guide link in the page content (its link lists and sections), never the header, footer or navigation. */
6
+ function linksToCostGuide(page, context) {
7
+ return page.linkGroups.flat().some((target) => context.estate.byPath.get(target)?.family === "cost-guide" || /(?:^|[/-])cost(?:s|-guide)?(?:[/-]|$)/.test(target));
8
+ }
9
+ export function contractFindings(page, context) {
10
+ const copy = page.copy;
11
+ const findings = [];
12
+ const place = page.place && context.geography ? placeName(page.place.slug, context.geography) : undefined;
13
+ if (!(copy.prices !== null && dollarFigures(copy.prices).length > 0) && !linksToCostGuide(page, context)) {
14
+ findings.push(fail("L9", page.path, `${page.path} shows no typical price and no link to the cost guide (D3).`));
15
+ }
16
+ const reviews = copy.reviews;
17
+ if (!reviews) {
18
+ const skip = page.manifest.kind === "one" ? page.manifest.manifest.skipped.find((entry) => entry.id === "reviews") : undefined;
19
+ if (skip && isDataBackedReason(skip.reason)) {
20
+ findings.push(warning("L9", page.path, `${page.path} has no Google reviews or rating to show yet (${skip.reason}); the band and its fallback appear once the profile has them (D4).`));
21
+ }
22
+ else {
23
+ findings.push(fail("L9", page.path, `${page.path} shows neither reviews nor the fallback with Google's rating, count and link (D4).`));
24
+ }
25
+ }
26
+ else if (reviews.written < 2 && !(reviews.googleLink && reviews.rating)) {
27
+ findings.push(fail("L9", page.path, `${page.path} shows ${plural(reviews.written, "review")} without Google's rating and profile link beside them (D4 fallback).`));
28
+ }
29
+ const missing = [];
30
+ if (!copy.quoteForm)
31
+ missing.push("a quote form");
32
+ else if (!copy.quoteForm.suburbField)
33
+ missing.push("a suburb field in the quote form");
34
+ else if (!copy.quoteForm.suburbValue)
35
+ missing.push(`the suburb prefilled${place ? ` as ${place}` : ""}`);
36
+ else if (place && copy.quoteForm.suburbValue.toLowerCase() !== place.toLowerCase())
37
+ missing.push(`the suburb prefilled as ${place} (it says "${copy.quoteForm.suburbValue}")`);
38
+ if (!copy.telLink)
39
+ missing.push("a phone link");
40
+ if (!copy.callBar)
41
+ missing.push("the call bar");
42
+ if (missing.length > 0)
43
+ findings.push(fail("L9", page.path, `${page.path} is missing part of its quote path: ${listOf(missing)} (D11).`));
44
+ if (copy.jobs !== null && context.geography) {
45
+ const geography = context.geography;
46
+ if (copy.jobs.length === 0) {
47
+ findings.push(fail("L9", page.path, `${page.path} renders its nearby-jobs section with no job in it; with no job close enough the section is left out (D2).`));
48
+ }
49
+ else if (copy.jobs.some((card) => geography.placesIn(card).length === 0)) {
50
+ findings.push(fail("L9", page.path, `${page.path} shows a nearby job that does not name its suburb (D2).`));
51
+ }
52
+ }
53
+ return findings;
54
+ }
55
+ export function checkL9(context) {
56
+ const pages = context.estate.pages.filter((page) => page.family === "service-in-suburb" && page.copy);
57
+ if (pages.length === 0)
58
+ return [notApplicable("L9", "No service-in-suburb page was read.")];
59
+ const findings = pages.flatMap((page) => contractFindings(page, context));
60
+ if (!context.geography) {
61
+ findings.push(fail("L9", undefined, `The place names could not be read (${context.geographyProblem}), so the suburb prefill and the nearby-job cards could not be checked.`));
62
+ }
63
+ return outcome("L9", findings, `All ${plural(pages.length, "service-in-suburb page")} carry typical prices or the cost-guide link, reviews or their fallback, and the full quote path.`);
64
+ }
65
+ //# sourceMappingURL=l9-data-contract.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"l9-data-contract.js","sourceRoot":"","sources":["../../../../src/site-system/rules/full/l9-data-contract.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * L9, the local data contract on every service-in-suburb page (Site System v2 §4.4 step 4):\n * - D3: typical prices with a dollar figure, or a link to the cost guide while the family has no renderable price;\n * - D4: the reviews band with two or more reviews, or its fallback (Google's rating and the profile link beside what\n * it has). A profile with nothing to show, recorded in the manifest as a data reason, is a warning: a thin profile is\n * visible, never a silent failure and never a reason to hold pages;\n * - D11: the quote path on the page itself: the quote form with the suburb prefilled, a phone link and the call bar;\n * - D2: the nearby-job card or none: a nearby-jobs section that renders shows jobs that name their suburb.\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { dollarFigures } from \"../enquiry/common.js\";\nimport { fail, listOf, notApplicable, outcome, plural, warning, type FullContext } from \"./common.js\";\nimport type { EstatePage } from \"./estate.js\";\nimport { isDataBackedReason } from \"./k12-manifests.js\";\nimport { placeName } from \"./geography.js\";\n\n/** A cost-guide link in the page content (its link lists and sections), never the header, footer or navigation. */\nfunction linksToCostGuide(page: EstatePage, context: FullContext): boolean {\n return page.linkGroups.flat().some((target) => context.estate.byPath.get(target)?.family === \"cost-guide\" || /(?:^|[/-])cost(?:s|-guide)?(?:[/-]|$)/.test(target));\n}\n\nexport function contractFindings(page: EstatePage, context: FullContext): SiteSystemFinding[] {\n const copy = page.copy!;\n const findings: SiteSystemFinding[] = [];\n const place = page.place && context.geography ? placeName(page.place.slug, context.geography) : undefined;\n\n if (!(copy.prices !== null && dollarFigures(copy.prices).length > 0) && !linksToCostGuide(page, context)) {\n findings.push(fail(\"L9\", page.path, `${page.path} shows no typical price and no link to the cost guide (D3).`));\n }\n\n const reviews = copy.reviews;\n if (!reviews) {\n const skip = page.manifest.kind === \"one\" ? page.manifest.manifest.skipped.find((entry) => entry.id === \"reviews\") : undefined;\n if (skip && isDataBackedReason(skip.reason)) {\n findings.push(warning(\"L9\", page.path, `${page.path} has no Google reviews or rating to show yet (${skip.reason}); the band and its fallback appear once the profile has them (D4).`));\n } else {\n findings.push(fail(\"L9\", page.path, `${page.path} shows neither reviews nor the fallback with Google's rating, count and link (D4).`));\n }\n } else if (reviews.written < 2 && !(reviews.googleLink && reviews.rating)) {\n findings.push(fail(\"L9\", page.path, `${page.path} shows ${plural(reviews.written, \"review\")} without Google's rating and profile link beside them (D4 fallback).`));\n }\n\n const missing: string[] = [];\n if (!copy.quoteForm) missing.push(\"a quote form\");\n else if (!copy.quoteForm.suburbField) missing.push(\"a suburb field in the quote form\");\n else if (!copy.quoteForm.suburbValue) missing.push(`the suburb prefilled${place ? ` as ${place}` : \"\"}`);\n else if (place && copy.quoteForm.suburbValue.toLowerCase() !== place.toLowerCase()) missing.push(`the suburb prefilled as ${place} (it says \"${copy.quoteForm.suburbValue}\")`);\n if (!copy.telLink) missing.push(\"a phone link\");\n if (!copy.callBar) missing.push(\"the call bar\");\n if (missing.length > 0) findings.push(fail(\"L9\", page.path, `${page.path} is missing part of its quote path: ${listOf(missing)} (D11).`));\n\n if (copy.jobs !== null && context.geography) {\n const geography = context.geography;\n if (copy.jobs.length === 0) {\n findings.push(fail(\"L9\", page.path, `${page.path} renders its nearby-jobs section with no job in it; with no job close enough the section is left out (D2).`));\n } else if (copy.jobs.some((card) => geography.placesIn(card).length === 0)) {\n findings.push(fail(\"L9\", page.path, `${page.path} shows a nearby job that does not name its suburb (D2).`));\n }\n }\n return findings;\n}\n\nexport function checkL9(context: FullContext): SiteSystemFinding[] {\n const pages = context.estate.pages.filter((page) => page.family === \"service-in-suburb\" && page.copy);\n if (pages.length === 0) return [notApplicable(\"L9\", \"No service-in-suburb page was read.\")];\n const findings = pages.flatMap((page) => contractFindings(page, context));\n if (!context.geography) {\n findings.push(fail(\"L9\", undefined, `The place names could not be read (${context.geographyProblem}), so the suburb prefill and the nearby-job cards could not be checked.`));\n }\n return outcome(\"L9\", findings, `All ${plural(pages.length, \"service-in-suburb page\")} carry typical prices or the cost-guide link, reviews or their fallback, and the full quote path.`);\n}\n"]}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The link graph of the built estate (Site System v2 §4.4 step 7), computed from the route catalogue as built and the
3
+ * links in every page's server HTML, never by crawling:
4
+ * - E5: every indexable page but home has at least one link from another page;
5
+ * - L6: each indexable suburb and service-in-suburb page has 6 or more inbound links from 3 or more page types;
6
+ * - L7: each indexable location page is 3 clicks or fewer from home;
7
+ * - L8: each family of suburb pages has a hub in every region it serves (`{stem}` with the region, or `{region}-area`).
8
+ */
9
+ import type { SiteSystemFinding } from "../../checks.js";
10
+ import { type FullContext } from "./common.js";
11
+ import { type Estate } from "./estate.js";
12
+ export declare const L6_MIN_INBOUND = 6;
13
+ export declare const L6_MIN_PAGE_TYPES = 3;
14
+ export declare const L7_MAX_DEPTH = 3;
15
+ /** For each page, the other pages that link to it. */
16
+ export declare function inboundLinks(estate: Estate): Map<string, Set<string>>;
17
+ /** Clicks from home to every page it reaches. */
18
+ export declare function clickDepths(estate: Estate, home?: string): Map<string, number>;
19
+ export declare function checkE5(context: FullContext, inbound: ReadonlyMap<string, ReadonlySet<string>>): SiteSystemFinding[];
20
+ export declare function checkL6(context: FullContext, inbound: ReadonlyMap<string, ReadonlySet<string>>): SiteSystemFinding[];
21
+ export declare function checkL7(context: FullContext): SiteSystemFinding[];
22
+ export declare function checkL8(context: FullContext): SiteSystemFinding[];
23
+ //# sourceMappingURL=links.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"links.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/full/links.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAA4D,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AACzG,OAAO,EAAoB,KAAK,MAAM,EAAmB,MAAM,aAAa,CAAC;AAG7E,eAAO,MAAM,cAAc,IAAI,CAAC;AAChC,eAAO,MAAM,iBAAiB,IAAI,CAAC;AACnC,eAAO,MAAM,YAAY,IAAI,CAAC;AAE9B,sDAAsD;AACtD,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAMrE;AAED,iDAAiD;AACjD,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,SAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAkB3E;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC,GAAG,iBAAiB,EAAE,CAapH;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC,GAAG,iBAAiB,EAAE,CAkBpH;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,GAAG,iBAAiB,EAAE,CAcjE;AAOD,wBAAgB,OAAO,CAAC,OAAO,EAAE,WAAW,GAAG,iBAAiB,EAAE,CAmCjE"}
@@ -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":"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","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,4 @@
1
+ import type { SiteSystemFinding } from "../../checks.js";
2
+ import { type FullContext } from "./common.js";
3
+ export declare function checkS4(context: FullContext): SiteSystemFinding[];
4
+ //# sourceMappingURL=s4-route-files.d.ts.map
@@ -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