@brandlab-digital/conformance 3.10.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 @@
1
+ {"version":3,"file":"e10-phrases.js","sourceRoot":"","sources":["../../../../src/site-system/rules/full/e10-phrases.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAoB,MAAM,aAAa,CAAC;AACtE,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAEpE,8EAA8E;AAC9E,MAAM,aAAa,GAAG,iEAAiE,CAAC;AACxF,qHAAqH;AACrH,MAAM,UAAU,GAAG,6MAA6M,CAAC;AAEjO,4FAA4F;AAC5F,MAAM,UAAU,aAAa,CAAC,OAAe,EAAE,IAA8B;IAC3E,OAAO,eAAe,CAAC,IAAI,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC,OAAO,CAAC,yBAAyB,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC,CAAC,CAAC;AACnJ,CAAC;AAED,kHAAkH;AAClH,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;IAC5E,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC,CAAC;AACtG,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,OAAoB;IAC3C,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC;IAChD,IAAI,CAAC,QAAQ,CAAC,KAAK;QAAE,OAAO,CAAC,IAAI,CAAC,KAAK,EAAE,kBAAkB,EAAE,mBAAmB,kBAAkB,wCAAwC,CAAC,CAAC,CAAC;IAC7I,IAAI,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,kBAAkB,EAAE,OAAO,CAAC,CAAC,CAAC;IACtI,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC;QACtB,OAAO,CAAC,IAAI,CAAC,KAAK,EAAE,kBAAkB,EAAE,GAAG,kBAAkB,4EAA4E,CAAC,CAAC,CAAC;IAC9I,CAAC;IACD,MAAM,QAAQ,GAAwB,EAAE,CAAC;IACzC,MAAM,MAAM,GAAG,IAAI,GAAG,EAA6C,CAAC;IACpE,KAAK,MAAM,KAAK,IAAI,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;QAC5C,MAAM,GAAG,GAAG,eAAe,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QAC1C,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC9B,IAAI,KAAK,IAAI,KAAK,CAAC,KAAK,KAAK,KAAK,CAAC,KAAK,EAAE,CAAC;YACzC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,kBAAkB,EAAE,eAAe,KAAK,CAAC,MAAM,uBAAuB,kBAAkB,KAAK,KAAK,CAAC,KAAK,QAAQ,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;QAC7J,CAAC;;YAAM,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAChC,CAAC;IACD,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;QACpB,KAAK,MAAM,KAAK,IAAI,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;YAC5C,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YAC5C,IAAI,CAAC,IAAI;gBAAE,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,eAAe,KAAK,CAAC,MAAM,iBAAiB,KAAK,CAAC,KAAK,oCAAoC,CAAC,CAAC,CAAC;iBAC3I,IAAI,CAAC,IAAI,CAAC,SAAS;gBAAE,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,eAAe,KAAK,CAAC,MAAM,iBAAiB,KAAK,CAAC,KAAK,2BAA2B,CAAC,CAAC,CAAC;QACxJ,CAAC;IACH,CAAC;IAED,MAAM,SAAS,GAAG,eAAe,CAAC,QAAQ,CAAC,OAAO,CAAC,SAAS,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC,CAAC;IACrG,IAAI,QAAQ,GAAG,CAAC,CAAC;IACjB,IAAI,SAAS,EAAE,CAAC;QACd,MAAM,YAAY,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,MAAM,KAAK,mBAAmB,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QAC3I,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;YAChC,IAAI,CAAC,IAAI,CAAC,KAAK;gBAAE,SAAS;YAC1B,MAAM,eAAe,GAAG,IAAI,CAAC,MAAM,KAAK,mBAAmB,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,QAAQ,CAAC;YAC5F,MAAM,gBAAgB,GAAG,IAAI,CAAC,MAAM,KAAK,QAAQ,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YACvH,IAAI,CAAC,eAAe,IAAI,CAAC,gBAAgB;gBAAE,SAAS;YACpD,QAAQ,IAAI,CAAC,CAAC;YACd,IAAI,aAAa,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,CAAC,IAAI,CAAC,KAAK,SAAS,IAAI,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,SAAS;gBAAE,SAAS;YAChH,QAAQ,CAAC,IAAI,CAAC,IAAI,CAChB,KAAK,EACL,IAAI,CAAC,IAAI,EACT,eAAe;gBACb,CAAC,CAAC,GAAG,IAAI,CAAC,IAAI,qCAAqC,IAAI,CAAC,EAAE,IAAI,IAAI,CAAC,IAAI,4DAA4D;gBACnI,CAAC,CAAC,GAAG,IAAI,CAAC,IAAI,iCAAiC,IAAI,CAAC,EAAE,IAAI,IAAI,CAAC,IAAI,4DAA4D,CAClI,CAAC,CAAC;QACL,CAAC;IACH,CAAC;SAAM,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,KAAK,mBAAmB,IAAI,IAAI,CAAC,MAAM,KAAK,QAAQ,CAAC,EAAE,CAAC;QACxG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,sCAAsC,OAAO,CAAC,gBAAgB,4DAA4D,CAAC,CAAC,CAAC;IACpK,CAAC;IACD,OAAO,OAAO,CACZ,KAAK,EACL,QAAQ,EACR,WAAW,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,gBAAgB,CAAC,yCAAyC,MAAM,CAAC,QAAQ,EAAE,gCAAgC,EAAE,kCAAkC,CAAC,iCAAiC,QAAQ,CAAC,OAAO,CAAC,SAAS,IAAI,CAC/O,CAAC;AACJ,CAAC","sourcesContent":["/**\n * E10, phrase ownership (Site System v2 §4.2, §4.4 step 3, §5.3). strategy.ts records the main trade and one owning\n * page per primary phrase; no phrase has two owners and every owner is an indexable page. The suburb page owns\n * \"{main trade} {suburb}\" and the region page \"{main trade} {region}\", so no service-in-suburb page and no\n * service-region hub carries the main trade as its own phrase (read from its H1 with the place taken out).\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { fail, outcome, plural, type FullContext } from \"./common.js\";\nimport { FULL_STRATEGY_FILE, normalisePhrase } from \"./strategy.js\";\n\n/** Words that join a phrase to its place in a heading (\"Plumber in Ryde\"). */\nconst PLACE_JOINERS = /\\b(?:in|near|around|across|servicing|serving|for|of|the|nsw)\\b/g;\n/** Words that qualify a trade without making it another service (\"Local plumber\", \"Licensed emergency plumbers\"). */\nconst QUALIFIERS = /\\b(?:local|residential|commercial|domestic|emergency|licensed|licenced|qualified|certified|professional|expert|experienced|trusted|reliable|affordable|best|top|24\\/7|24 hour|24-hour|same day|same-day)\\b/g;\n\n/** A heading's phrase without its place: \"Blocked drains in Ryde\" reads \"blocked drain\". */\nexport function headingPhrase(heading: string, mask: (text: string) => string): string {\n return normalisePhrase(mask(heading.toLowerCase()).replace(/\\{place\\}|\\{postcode\\}/g, \" \").replace(QUALIFIERS, \" \").replace(PLACE_JOINERS, \" \"));\n}\n\n/** The service slug in a stem, as a phrase: `/services/plumber/{place}` and `/plumber-{place}` read \"plumber\". */\nexport function stemPhrase(stem: string): string {\n const before = stem.slice(0, stem.indexOf(\"{place}\")).replace(/[/-]+$/, \"\");\n return normalisePhrase((before.split(\"/\").pop() ?? \"\").replace(/-/g, \" \").replace(QUALIFIERS, \" \"));\n}\n\nexport function checkE10(context: FullContext): SiteSystemFinding[] {\n const { strategy, estate, geography } = context;\n if (!strategy.found) return [fail(\"E10\", FULL_STRATEGY_FILE, `The site has no ${FULL_STRATEGY_FILE}, so phrase ownership is not recorded.`)];\n if (strategy.problems.phrases.length > 0) return strategy.problems.phrases.map((problem) => fail(\"E10\", FULL_STRATEGY_FILE, problem));\n if (!strategy.phrases) {\n return [fail(\"E10\", FULL_STRATEGY_FILE, `${FULL_STRATEGY_FILE} records no phrases (the main trade and the owner of each primary phrase).`)];\n }\n const failures: SiteSystemFinding[] = [];\n const owners = new Map<string, { phrase: string; route: string }>();\n for (const owner of strategy.phrases.owners) {\n const key = normalisePhrase(owner.phrase);\n const other = owners.get(key);\n if (other && other.route !== owner.route) {\n failures.push(fail(\"E10\", FULL_STRATEGY_FILE, `The phrase \"${owner.phrase}\" has two owners in ${FULL_STRATEGY_FILE}: ${other.route} and ${owner.route}.`));\n } else owners.set(key, owner);\n }\n if (estate.complete) {\n for (const owner of strategy.phrases.owners) {\n const page = estate.byPath.get(owner.route);\n if (!page) failures.push(fail(\"E10\", owner.route, `The phrase \"${owner.phrase}\" is owned by ${owner.route}, which the build has no page for.`));\n else if (!page.indexable) failures.push(fail(\"E10\", owner.route, `The phrase \"${owner.phrase}\" is owned by ${owner.route}, which is not indexable.`));\n }\n }\n\n const mainTrade = normalisePhrase(strategy.phrases.mainTrade.toLowerCase().replace(QUALIFIERS, \" \"));\n let compared = 0;\n if (geography) {\n const serviceStems = new Set(estate.pages.flatMap((page) => (page.family === \"service-in-suburb\" && page.place ? [page.place.stem] : [])));\n for (const page of estate.pages) {\n if (!page.place) continue;\n const serviceInSuburb = page.family === \"service-in-suburb\" && page.place.kind === \"suburb\";\n const serviceRegionHub = page.family === \"region\" && page.place.kind === \"region\" && serviceStems.has(page.place.stem);\n if (!serviceInSuburb && !serviceRegionHub) continue;\n compared += 1;\n if (headingPhrase(page.h1, geography.mask) !== mainTrade && stemPhrase(page.place.stem) !== mainTrade) continue;\n failures.push(fail(\n \"E10\",\n page.path,\n serviceInSuburb\n ? `${page.path} is a service-in-suburb page for \"${page.h1 || page.path}\", the main trade in a suburb, which the suburb page owns.`\n : `${page.path} is a service-region hub for \"${page.h1 || page.path}\", the main trade in a region, which the region page owns.`,\n ));\n }\n } else if (estate.pages.some((page) => page.family === \"service-in-suburb\" || page.family === \"region\")) {\n failures.push(fail(\"E10\", undefined, `The place names could not be read (${context.geographyProblem}), so service pages were not compared with the main trade.`));\n }\n return outcome(\n \"E10\",\n failures,\n `Each of ${plural(owners.size, \"primary phrase\")} has one indexable owner, and none of ${plural(compared, \"service-in-suburb page and hub\", \"service-in-suburb pages and hubs\")} competes for the main trade \"${strategy.phrases.mainTrade}\".`,\n );\n}\n"]}
@@ -0,0 +1,118 @@
1
+ import type { SiteSystemPage } from "../../samples.js";
2
+ import { type FullGeography, type PathPlace } from "./geography.js";
3
+ /** The section manifest as SectionSequence writes it (site-core `BrandlabSectionManifest`). */
4
+ export interface ManifestData {
5
+ readonly version: number;
6
+ readonly family: string;
7
+ readonly page?: string;
8
+ readonly cap: number;
9
+ readonly order: "default" | "recorded";
10
+ readonly planned: readonly {
11
+ readonly id: string;
12
+ readonly requirement: string;
13
+ }[];
14
+ readonly rendered: readonly {
15
+ readonly id: string;
16
+ readonly section: string;
17
+ readonly requirement: string;
18
+ readonly tone?: string;
19
+ }[];
20
+ readonly skipped: readonly {
21
+ readonly id: string;
22
+ readonly section: string;
23
+ readonly requirement: string;
24
+ readonly reason: string;
25
+ }[];
26
+ readonly counted: number;
27
+ readonly flags: readonly string[];
28
+ }
29
+ export type ManifestState = {
30
+ readonly kind: "none";
31
+ } | {
32
+ readonly kind: "invalid";
33
+ readonly reason: string;
34
+ } | {
35
+ readonly kind: "many";
36
+ readonly count: number;
37
+ } | {
38
+ readonly kind: "one";
39
+ readonly manifest: ManifestData;
40
+ };
41
+ export declare const LOCATION_FAMILIES: readonly ["suburb", "service-in-suburb", "region"];
42
+ export type LocationFamily = (typeof LOCATION_FAMILIES)[number];
43
+ export declare function isLocationFamily(family: string | undefined): family is LocationFamily;
44
+ /** The copy and parts of a location page the L4, L9 and L10 rules read. */
45
+ export interface LocationCopy {
46
+ /** Readable lines of the page content, with the section they sit in ("page" outside any section). */
47
+ readonly lines: readonly {
48
+ readonly section: string;
49
+ readonly text: string;
50
+ }[];
51
+ readonly faqs: readonly {
52
+ readonly question: string;
53
+ readonly answer: string;
54
+ }[];
55
+ /** The job cards of the nearby-jobs section; null when the page has none. */
56
+ readonly jobs: readonly string[] | null;
57
+ /** The price-guide section's text; null when the page has none. */
58
+ readonly prices: string | null;
59
+ readonly reviews: {
60
+ readonly written: number;
61
+ readonly googleLink: boolean;
62
+ readonly rating: boolean;
63
+ } | null;
64
+ readonly quoteForm: {
65
+ readonly suburbField: boolean;
66
+ readonly suburbValue: string;
67
+ } | null;
68
+ readonly telLink: boolean;
69
+ readonly callBar: boolean;
70
+ }
71
+ export interface EstatePage {
72
+ readonly path: string;
73
+ /** The app route Next built the page from (`/locations/[suburb]`), when known. */
74
+ readonly srcRoute: string | undefined;
75
+ readonly indexable: boolean;
76
+ readonly inSitemap: boolean;
77
+ /** The sitemap file that lists the page, without its number and extension (`service-locations`). */
78
+ readonly sitemapFile: string | undefined;
79
+ /** Internal paths the page links to, once each. */
80
+ readonly links: readonly string[];
81
+ /** The main content's link groups (lists), as internal paths, for the neighbour order. */
82
+ readonly linkGroups: readonly (readonly string[])[];
83
+ readonly manifest: ManifestState;
84
+ /** Top-level `data-bl-section` ids, in document order. */
85
+ readonly sections: readonly string[];
86
+ readonly h1: string;
87
+ readonly place: PathPlace | undefined;
88
+ family: string | undefined;
89
+ familySource: "manifest" | "sitemap" | "route" | "stem" | "path" | undefined;
90
+ copy: LocationCopy | undefined;
91
+ }
92
+ export interface Estate {
93
+ readonly pages: readonly EstatePage[];
94
+ readonly byPath: ReadonlyMap<string, EstatePage>;
95
+ /** True when the pages are the whole production build, not just a sample. */
96
+ readonly complete: boolean;
97
+ /** Paths listed in the build's sitemap files; null when the build holds no sitemap. */
98
+ readonly sitemap: ReadonlySet<string> | null;
99
+ /** Dynamic app routes in the build and how many pages each produced. */
100
+ readonly dynamicRoutes: ReadonlyMap<string, number>;
101
+ readonly buildDir: string;
102
+ /** Routes the prerender manifest lists with no HTML in the build; `location` when the route is a location page's. */
103
+ readonly missingHtml: readonly {
104
+ readonly path: string;
105
+ readonly location: boolean;
106
+ }[];
107
+ }
108
+ /** Reads the estate: the whole build when `buildDir` holds one, otherwise the sampled pages. */
109
+ export declare function readEstate(input: {
110
+ readonly pages: readonly SiteSystemPage[];
111
+ readonly buildDir: string;
112
+ readonly geography: FullGeography | null;
113
+ /** Families known from elsewhere, by path (the representative service route). */
114
+ readonly seeds?: Readonly<Record<string, string>>;
115
+ }): Estate;
116
+ /** An internal link's normalised path, or null for other sites, anchors on the same page, mail and phone links. */
117
+ export declare function internalPath(href: string, from: string, hosts: ReadonlySet<string>): string | null;
118
+ //# sourceMappingURL=estate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"estate.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/full/estate.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,447 @@
1
+ /**
2
+ * The estate the full tier reads: every page of the production build (the route catalogue as Next built it, from
3
+ * `.next/prerender-manifest.json` and each page's prerendered HTML), the sitemap files in the build, and per page its
4
+ * indexing, internal links, section manifest and the copy the location rules compare. Nothing is crawled: a site
5
+ * checked with only a base URL and no build has just its sampled pages, and the estate-wide checks say so.
6
+ *
7
+ * A page's family is the one its section manifest names. A page without a manifest takes its family from the
8
+ * sitemap file that lists it (`service-locations-*.xml`, `locations-*.xml`) or from the other pages of its stem (the
9
+ * path with its place replaced: `/services/decking/{place}`), so a location page missing its manifest is still
10
+ * found; a page built from the same dynamic route as a page with a manifest takes that page's family; the
11
+ * representative service route is a service page; home is `/`. Other pages without a manifest have no known family.
12
+ */
13
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
14
+ import { join, relative } from "node:path";
15
+ import { extractLinkGroups } from "../../../page-standard.js";
16
+ import { resolveStreamedHtml } from "../../streamed-html.js";
17
+ import { normalisePath, sectionId, topLevelSections } from "../enquiry/common.js";
18
+ import { attr, descendants, isHiddenFromPeople, normaliseText, parseHtml, readableLines, textOf } from "../enquiry/html.js";
19
+ import { placeOfPath } from "./geography.js";
20
+ export const LOCATION_FAMILIES = ["suburb", "service-in-suburb", "region"];
21
+ export function isLocationFamily(family) {
22
+ return LOCATION_FAMILIES.includes(family ?? "");
23
+ }
24
+ const INTERNAL_ROUTES = new Set(["/_not-found", "/_global-error", "/404", "/500"]);
25
+ /** Reads the estate: the whole build when `buildDir` holds one, otherwise the sampled pages. */
26
+ export function readEstate(input) {
27
+ const missing = [];
28
+ const manifest = readJson(join(input.buildDir, "prerender-manifest.json"));
29
+ const sources = new Map();
30
+ const dynamicRoutes = new Map();
31
+ let complete = false;
32
+ if (isRecord(manifest) && isRecord(manifest.routes)) {
33
+ complete = true;
34
+ const appDir = join(input.buildDir, "server", "app");
35
+ for (const route of isRecord(manifest.dynamicRoutes) ? Object.keys(manifest.dynamicRoutes) : [])
36
+ dynamicRoutes.set(route, 0);
37
+ for (const [rawPath, entry] of Object.entries(manifest.routes)) {
38
+ if (!isRecord(entry) || !rawPath.startsWith("/"))
39
+ continue;
40
+ const srcRoute = typeof entry.srcRoute === "string" ? entry.srcRoute : undefined;
41
+ if (srcRoute && dynamicRoutes.has(srcRoute))
42
+ dynamicRoutes.set(srcRoute, (dynamicRoutes.get(srcRoute) ?? 0) + 1);
43
+ if (entry.routeType !== undefined ? entry.routeType !== "page" : /\.[a-z0-9]+$/i.test(rawPath))
44
+ continue;
45
+ const path = normalisePath(rawPath);
46
+ if (INTERNAL_ROUTES.has(path) || INTERNAL_ROUTES.has(srcRoute ?? ""))
47
+ continue;
48
+ const file = join(appDir, `${path === "/" ? "/index" : path}.html`);
49
+ if (!existsSync(file)) {
50
+ missing.push({ path, srcRoute });
51
+ continue;
52
+ }
53
+ const meta = readJson(join(appDir, `${path === "/" ? "/index" : path}.meta`));
54
+ sources.set(path, {
55
+ html: () => readFileSync(file, "utf8"),
56
+ ...(srcRoute ? { srcRoute } : {}),
57
+ ...(isRecord(meta) && isRecord(meta.headers) ? { headers: meta.headers } : {}),
58
+ });
59
+ }
60
+ }
61
+ // Sampled pages fill in what the build does not have (a base-URL run without a build).
62
+ for (const page of input.pages) {
63
+ const path = normalisePath(page.path);
64
+ if (!sources.has(path))
65
+ sources.set(path, { html: page.html });
66
+ }
67
+ const sitemapFiles = complete ? readSitemaps(join(input.buildDir, "server", "app")) : [];
68
+ const sitemapOf = new Map();
69
+ for (const file of sitemapFiles)
70
+ for (const path of file.paths)
71
+ if (!sitemapOf.has(path))
72
+ sitemapOf.set(path, file.name);
73
+ // First pass: one summary per page, links kept as written until every canonical host is known.
74
+ const hosts = new Set(["localhost"]);
75
+ const read = [];
76
+ for (const [path, source] of sources) {
77
+ const html = resolveStreamedHtml(typeof source.html === "string" ? source.html : source.html());
78
+ for (const match of html.matchAll(/<link\b[^>]*\brel=["']canonical["'][^>]*>/gi)) {
79
+ const href = /\bhref=["']([^"']+)["']/i.exec(match[0])?.[1];
80
+ try {
81
+ if (href) {
82
+ // A self-link may use the www or the bare host, whichever the canonical does not.
83
+ const host = new URL(href).host;
84
+ hosts.add(host);
85
+ hosts.add(host.startsWith("www.") ? host.slice(4) : `www.${host}`);
86
+ }
87
+ }
88
+ catch {
89
+ // A relative canonical names no host.
90
+ }
91
+ }
92
+ const { summary, hrefs } = summarise({ path, html, ...(source.srcRoute ? { srcRoute: source.srcRoute } : {}), ...(source.headers ? { headers: source.headers } : {}) }, sitemapOf, input.geography);
93
+ read.push({ summary, hrefs, groups: extractLinkGroups(html), source });
94
+ }
95
+ const pages = [];
96
+ for (const { summary, hrefs, groups } of read) {
97
+ const links = new Set();
98
+ for (const href of hrefs) {
99
+ const path = internalPath(href, summary.path, hosts);
100
+ if (path && path !== summary.path)
101
+ links.add(path);
102
+ }
103
+ pages.push({
104
+ ...summary,
105
+ links: [...links],
106
+ linkGroups: groups.map((group) => group.flatMap((href) => internalPath(href, summary.path, hosts) ?? [])).filter((group) => group.length > 0),
107
+ });
108
+ }
109
+ const byPath = new Map(pages.map((page) => [page.path, page]));
110
+ inferFamilies(pages, input.seeds ?? {});
111
+ // Second pass: the copy of location pages found without a manifest.
112
+ for (const [index, page] of pages.entries()) {
113
+ if (!isLocationFamily(page.family) || page.copy)
114
+ continue;
115
+ const source = read[index].source;
116
+ page.copy = locationCopy(parseHtml(resolveStreamedHtml(typeof source.html === "string" ? source.html : source.html())));
117
+ }
118
+ const locationRoutes = new Set(pages.flatMap((page) => (isLocationFamily(page.family) && page.srcRoute && isGenerated(page.srcRoute) ? [page.srcRoute] : [])));
119
+ const locationStems = new Set(pages.flatMap((page) => (isLocationFamily(page.family) && page.place ? [page.place.stem] : [])));
120
+ const missingHtml = missing.map((item) => {
121
+ const stem = input.geography ? placeOfPath(item.path, input.geography)?.stem : undefined;
122
+ return { path: item.path, location: (item.srcRoute !== undefined && locationRoutes.has(item.srcRoute)) || (stem !== undefined && locationStems.has(stem)) };
123
+ });
124
+ return {
125
+ pages,
126
+ byPath,
127
+ complete,
128
+ sitemap: complete && sitemapFiles.length > 0 ? new Set(sitemapOf.keys()) : null,
129
+ dynamicRoutes,
130
+ buildDir: input.buildDir,
131
+ missingHtml,
132
+ };
133
+ }
134
+ function summarise(item, sitemapOf, geography) {
135
+ const root = parseHtml(item.html);
136
+ const elements = descendants(root);
137
+ const robots = elements
138
+ .filter((element) => element.tag === "meta" && /^(robots|googlebot)$/i.test(attr(element, "name") ?? ""))
139
+ .map((element) => attr(element, "content") ?? "")
140
+ .join(" ");
141
+ const header = item.headers?.["x-robots-tag"];
142
+ const indexable = !/noindex/i.test(`${robots} ${typeof header === "string" ? header : ""}`);
143
+ const hrefs = elements.filter((element) => element.tag === "a").map((element) => attr(element, "href") ?? "");
144
+ const h1Element = elements.find((element) => element.tag === "h1");
145
+ const place = geography ? placeOfPath(item.path, geography) : undefined;
146
+ const page = {
147
+ path: item.path,
148
+ srcRoute: item.srcRoute,
149
+ indexable,
150
+ inSitemap: sitemapOf.has(item.path),
151
+ sitemapFile: sitemapOf.get(item.path),
152
+ links: [],
153
+ linkGroups: [],
154
+ manifest: readManifest(elements),
155
+ sections: topLevelSections(root).map(sectionId),
156
+ h1: h1Element ? textOf(h1Element) : "",
157
+ place,
158
+ family: undefined,
159
+ familySource: undefined,
160
+ copy: undefined,
161
+ };
162
+ if (page.manifest.kind === "one") {
163
+ page.family = page.manifest.manifest.family;
164
+ page.familySource = "manifest";
165
+ if (isLocationFamily(page.family))
166
+ page.copy = locationCopy(root);
167
+ }
168
+ return { summary: page, hrefs };
169
+ }
170
+ function readManifest(elements) {
171
+ const scripts = elements.filter((element) => element.tag === "script" && attr(element, "data-bl-manifest") !== undefined);
172
+ if (scripts.length === 0)
173
+ return { kind: "none" };
174
+ if (scripts.length > 1)
175
+ return { kind: "many", count: scripts.length };
176
+ const text = scripts[0].children.map((child) => (child.type === "text" ? child.text : "")).join("");
177
+ let value;
178
+ try {
179
+ value = JSON.parse(text);
180
+ }
181
+ catch {
182
+ return { kind: "invalid", reason: "is not valid JSON" };
183
+ }
184
+ if (!isRecord(value) || typeof value.family !== "string" || !Array.isArray(value.planned) || !Array.isArray(value.rendered) || !Array.isArray(value.skipped)) {
185
+ return { kind: "invalid", reason: "lacks its family, planned, rendered or skipped lists" };
186
+ }
187
+ const entries = (list) => list.every((entry) => isRecord(entry) && typeof entry.id === "string" && typeof entry.requirement === "string");
188
+ if (!entries(value.planned) || !entries(value.rendered) || !entries(value.skipped)) {
189
+ return { kind: "invalid", reason: "has an entry without an id or requirement" };
190
+ }
191
+ return {
192
+ kind: "one",
193
+ manifest: {
194
+ version: typeof value.version === "number" ? value.version : 0,
195
+ family: value.family,
196
+ ...(typeof value.page === "string" ? { page: value.page } : {}),
197
+ cap: typeof value.cap === "number" ? value.cap : Number.POSITIVE_INFINITY,
198
+ order: value.order === "recorded" ? "recorded" : "default",
199
+ planned: value.planned,
200
+ rendered: value.rendered.map((entry) => ({
201
+ id: entry.id,
202
+ section: typeof entry.section === "string" ? entry.section : entry.id,
203
+ requirement: entry.requirement,
204
+ })),
205
+ skipped: value.skipped.map((entry) => ({
206
+ id: entry.id,
207
+ section: typeof entry.section === "string" ? entry.section : entry.id,
208
+ requirement: entry.requirement,
209
+ reason: typeof entry.reason === "string" ? entry.reason : "",
210
+ })),
211
+ counted: typeof value.counted === "number" ? value.counted : 0,
212
+ flags: Array.isArray(value.flags) ? value.flags.filter((flag) => typeof flag === "string") : [],
213
+ },
214
+ };
215
+ }
216
+ const SUBURB_STEM = /^\/(?:locations?|areas?|suburbs?|service-areas?|areas-we-(?:service|serve)|regions?|where-we-work)\/\{place\}$/;
217
+ /** A dynamic route that builds one family (not a catch-all, which serves many). */
218
+ function isGenerated(route) {
219
+ return route.includes("[") && !route.includes("[...") && !route.includes("[[...");
220
+ }
221
+ /**
222
+ * Families for pages without a manifest: the sitemap file, then the family of the other pages of their dynamic route,
223
+ * the seeds (the representative service route), the family of their stem, then `/` as home.
224
+ */
225
+ function inferFamilies(pages, seeds) {
226
+ for (const page of pages) {
227
+ if (page.family !== undefined || !page.sitemapFile)
228
+ continue;
229
+ const file = page.sitemapFile;
230
+ if (/service-?locations?/.test(file))
231
+ page.family = page.place?.kind === "region" ? "region" : "service-in-suburb";
232
+ else if (/locations?|areas?|suburbs?/.test(file))
233
+ page.family = page.place?.kind === "region" ? "region" : "suburb";
234
+ if (page.family !== undefined)
235
+ page.familySource = "sitemap";
236
+ }
237
+ // A dynamic route can build hubs and suburb pages alike, so pages match on the route and on the kind of place.
238
+ const routeKey = (page) => `${page.srcRoute}|${page.place?.kind ?? ""}`;
239
+ const counts = new Map();
240
+ for (const page of pages) {
241
+ if (page.familySource !== "manifest" || !page.srcRoute || !isGenerated(page.srcRoute))
242
+ continue;
243
+ const families = counts.get(routeKey(page)) ?? new Map();
244
+ families.set(page.family, (families.get(page.family) ?? 0) + 1);
245
+ counts.set(routeKey(page), families);
246
+ }
247
+ const routeFamilies = new Map([...counts].map(([key, families]) => [key, [...families].sort((left, right) => right[1] - left[1])[0][0]]));
248
+ for (const page of pages) {
249
+ if (page.family !== undefined)
250
+ continue;
251
+ const family = (page.srcRoute ? routeFamilies.get(routeKey(page)) : undefined) ?? seeds[page.path];
252
+ if (family) {
253
+ page.family = family;
254
+ page.familySource = "route";
255
+ }
256
+ }
257
+ // The other pages of a seeded page's dynamic route share its family.
258
+ for (const page of pages) {
259
+ if (page.family !== undefined || !page.srcRoute || !isGenerated(page.srcRoute))
260
+ continue;
261
+ const seeded = pages.find((other) => other.familySource === "route" && other.srcRoute === page.srcRoute);
262
+ if (seeded) {
263
+ page.family = seeded.family;
264
+ page.familySource = "route";
265
+ }
266
+ }
267
+ const stems = new Map();
268
+ for (const page of pages) {
269
+ if (!page.place || (page.family !== "suburb" && page.family !== "service-in-suburb"))
270
+ continue;
271
+ if (!stems.has(page.place.stem))
272
+ stems.set(page.place.stem, page.family);
273
+ }
274
+ // A stem that three or more suburbs share is a family of location pages even when nothing names it: suburb pages
275
+ // under an areas folder (`/locations/{place}`), service-in-suburb pages otherwise.
276
+ const unnamed = new Map();
277
+ for (const page of pages) {
278
+ if (page.family === undefined && page.place?.kind === "suburb")
279
+ unnamed.set(page.place.stem, (unnamed.get(page.place.stem) ?? 0) + 1);
280
+ }
281
+ for (const [stem, count] of unnamed) {
282
+ if (count >= 3 && !stems.has(stem))
283
+ stems.set(stem, SUBURB_STEM.test(stem) ? "suburb" : "service-in-suburb");
284
+ }
285
+ for (const page of pages) {
286
+ if (page.family !== undefined)
287
+ continue;
288
+ if (page.path === "/") {
289
+ page.family = "home";
290
+ page.familySource = "path";
291
+ continue;
292
+ }
293
+ const family = page.place ? stems.get(page.place.stem) : undefined;
294
+ if (!family || !page.place)
295
+ continue;
296
+ page.family = page.place.kind === "region" ? "region" : family;
297
+ page.familySource = "stem";
298
+ }
299
+ }
300
+ /** The page content a person reads, without the header, footer, navigation, call bar or drawer. */
301
+ function contentRoot(root) {
302
+ return descendants(root).find((element) => element.tag === "main") ?? descendants(root).find((element) => element.tag === "body") ?? root;
303
+ }
304
+ const CHROME = (element) => ["header", "footer", "nav"].includes(element.tag) || ["header", "footer", "callbar", "drawer", "utility"].includes(attr(element, "data-bl") ?? "");
305
+ function linesOutsideChrome(element) {
306
+ // readableLines has no skip, so chrome is cut from a shallow copy of the tree.
307
+ const prune = (node) => ({
308
+ ...node,
309
+ children: node.children.flatMap((child) => (child.type === "element" ? (CHROME(child) ? [] : [prune(child)]) : [child])),
310
+ });
311
+ return readableLines(prune(element)).map((line) => line.text);
312
+ }
313
+ function locationCopy(root) {
314
+ const elements = descendants(root);
315
+ const sections = topLevelSections(root);
316
+ const lines = sections.length > 0
317
+ ? sections.flatMap((section) => readableLines(section).map((line) => ({ section: sectionId(section), text: line.text })))
318
+ : linesOutsideChrome(contentRoot(root)).map((text) => ({ section: "page", text }));
319
+ const sectionOf = (id) => elements.find((element) => attr(element, "data-bl-section") === id);
320
+ // The FAQs section, or on a page without sections the disclosures that ask a question.
321
+ const faqSection = sectionOf("faqs");
322
+ const faqScope = faqSection ?? contentRoot(root);
323
+ const faqs = [];
324
+ for (const details of descendants(faqScope).filter((element) => element.tag === "details")) {
325
+ const summary = details.children.find((child) => child.type === "element" && child.tag === "summary");
326
+ if (!summary)
327
+ continue;
328
+ const question = textOf(summary);
329
+ const answer = normaliseText(textOf(details, true, (element) => element === summary));
330
+ if (question && (faqSection || question.endsWith("?")))
331
+ faqs.push({ question, answer });
332
+ }
333
+ if (faqs.length === 0) {
334
+ for (const term of descendants(faqScope).filter((element) => element.tag === "dt")) {
335
+ const siblings = term.parent?.children.filter((child) => child.type === "element") ?? [];
336
+ const definition = siblings[siblings.indexOf(term) + 1];
337
+ const question = textOf(term);
338
+ if (definition?.tag === "dd" && (faqSection || question.endsWith("?")))
339
+ faqs.push({ question, answer: textOf(definition, true) });
340
+ }
341
+ }
342
+ const jobsSection = sectionOf("nearby-jobs");
343
+ let jobs = null;
344
+ if (jobsSection) {
345
+ const cards = descendants(jobsSection).filter((element) => (element.tag === "li" || element.tag === "article") && !descendants(element).some((inner) => inner.tag === "li" || inner.tag === "article"));
346
+ const headings = new Set(descendants(jobsSection).filter((element) => /^h[1-6]$/.test(element.tag)).map((element) => textOf(element)));
347
+ jobs = cards.length > 0
348
+ ? cards.map((card) => textOf(card, true)).filter(Boolean)
349
+ : readableLines(jobsSection).map((line) => line.text).filter((text) => !headings.has(text));
350
+ }
351
+ const pricesSection = sectionOf("price-guide");
352
+ const reviewsSection = sectionOf("reviews");
353
+ const reviews = reviewsSection
354
+ ? {
355
+ written: descendants(reviewsSection).filter((element) => element.tag === "blockquote").length,
356
+ googleLink: descendants(reviewsSection).some((element) => element.tag === "a" && /(?:^|\.)google\.|g\.page|goo\.gl/i.test(attr(element, "href") ?? "")),
357
+ rating: descendants(reviewsSection).some((element) => attr(element, "data-bl") === "rating-chip") ||
358
+ /\b[1-5](?:\.\d)?\s*(?:out of 5|stars?|★)|\b[1-5]\.\d\b/i.test(textOf(reviewsSection, true)),
359
+ }
360
+ : null;
361
+ const forms = elements.filter((element) => element.tag === "form" && !isHiddenFromPeople(element));
362
+ const form = forms.find((element) => attr(element, "data-bl") === "quote-form") ?? forms[0];
363
+ let quoteForm = null;
364
+ if (form) {
365
+ const field = descendants(form).find((element) => element.tag === "input" &&
366
+ (attr(element, "autocomplete") === "address-level2" || /suburb|location/i.test(`${attr(element, "name") ?? ""} ${attr(element, "id") ?? ""}`)));
367
+ quoteForm = { suburbField: field !== undefined, suburbValue: normaliseText(field ? (attr(field, "value") ?? "") : "") };
368
+ }
369
+ return {
370
+ lines,
371
+ faqs,
372
+ jobs,
373
+ prices: pricesSection ? textOf(pricesSection, true) : null,
374
+ reviews,
375
+ quoteForm,
376
+ telLink: elements.some((element) => element.tag === "a" && /^tel:/i.test(attr(element, "href") ?? "")),
377
+ callBar: elements.some((element) => attr(element, "data-bl") === "callbar"),
378
+ };
379
+ }
380
+ /** An internal link's normalised path, or null for other sites, anchors on the same page, mail and phone links. */
381
+ export function internalPath(href, from, hosts) {
382
+ const value = href.trim();
383
+ if (!value || value.startsWith("#") || /^(?:mailto|tel|sms|javascript|data):/i.test(value))
384
+ return null;
385
+ let url;
386
+ try {
387
+ url = new URL(value, `http://localhost${from}`);
388
+ }
389
+ catch {
390
+ return null;
391
+ }
392
+ if (!/^https?:$/.test(url.protocol) || !hosts.has(url.host) && !hosts.has(url.hostname))
393
+ return null;
394
+ let path;
395
+ try {
396
+ path = decodeURIComponent(url.pathname);
397
+ }
398
+ catch {
399
+ path = url.pathname;
400
+ }
401
+ return normalisePath(path);
402
+ }
403
+ /** The URL sets in the build's prerendered sitemap files (`*.xml.body`); sitemap indexes are skipped. */
404
+ function readSitemaps(appDir) {
405
+ if (!existsSync(appDir))
406
+ return [];
407
+ const files = [];
408
+ const visit = (directory) => {
409
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
410
+ const full = join(directory, entry.name);
411
+ if (entry.isDirectory())
412
+ visit(full);
413
+ else if (entry.name.endsWith(".xml.body")) {
414
+ const xml = readFileSync(full, "utf8");
415
+ if (!/<urlset\b/i.test(xml))
416
+ continue;
417
+ const paths = [];
418
+ for (const match of xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)) {
419
+ try {
420
+ paths.push(normalisePath(new URL(match[1].replace(/&amp;/g, "&")).pathname));
421
+ }
422
+ catch {
423
+ // A loc that is not a URL lists no page.
424
+ }
425
+ }
426
+ const name = relative(appDir, full).replace(/\.xml\.body$/, "").split("/").pop().replace(/-\d+$/, "");
427
+ files.push({ name, paths });
428
+ }
429
+ }
430
+ };
431
+ visit(appDir);
432
+ return files;
433
+ }
434
+ function readJson(file) {
435
+ if (!existsSync(file))
436
+ return undefined;
437
+ try {
438
+ return JSON.parse(readFileSync(file, "utf8"));
439
+ }
440
+ catch {
441
+ return undefined;
442
+ }
443
+ }
444
+ function isRecord(value) {
445
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
446
+ }
447
+ //# sourceMappingURL=estate.js.map