@brandlab-digital/conformance 3.8.0 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/current-release.js +5 -5
- package/dist/current-release.js.map +1 -1
- package/dist/engineering.js +4 -2
- package/dist/engineering.js.map +1 -1
- package/dist/site-system/checks.d.ts +1 -1
- package/dist/site-system/checks.js +1 -1
- package/dist/site-system/checks.js.map +1 -1
- package/dist/site-system/rule-modules.d.ts.map +1 -1
- package/dist/site-system/rule-modules.js +22 -1
- package/dist/site-system/rule-modules.js.map +1 -1
- package/dist/site-system/rules/form/c11-contact-route.d.ts +5 -0
- package/dist/site-system/rules/form/c11-contact-route.d.ts.map +1 -0
- package/dist/site-system/rules/form/c11-contact-route.js +132 -0
- package/dist/site-system/rules/form/c11-contact-route.js.map +1 -0
- package/dist/site-system/rules/form/c7-thank-you.d.ts +20 -0
- package/dist/site-system/rules/form/c7-thank-you.d.ts.map +1 -0
- package/dist/site-system/rules/form/c7-thank-you.js +67 -0
- package/dist/site-system/rules/form/c7-thank-you.js.map +1 -0
- package/dist/site-system/rules/form/c8-forgiving-form.d.ts +19 -0
- package/dist/site-system/rules/form/c8-forgiving-form.d.ts.map +1 -0
- package/dist/site-system/rules/form/c8-forgiving-form.js +128 -0
- package/dist/site-system/rules/form/c8-forgiving-form.js.map +1 -0
- package/dist/site-system/rules/form/index.d.ts +14 -0
- package/dist/site-system/rules/form/index.d.ts.map +1 -0
- package/dist/site-system/rules/form/index.js +13 -0
- package/dist/site-system/rules/form/index.js.map +1 -0
- package/dist/site-system/rules/library/common.d.ts +36 -0
- package/dist/site-system/rules/library/common.d.ts.map +1 -0
- package/dist/site-system/rules/library/common.js +70 -0
- package/dist/site-system/rules/library/common.js.map +1 -0
- package/dist/site-system/rules/library/index.d.ts +16 -0
- package/dist/site-system/rules/library/index.d.ts.map +1 -0
- package/dist/site-system/rules/library/index.js +19 -0
- package/dist/site-system/rules/library/index.js.map +1 -0
- package/dist/site-system/rules/library/k1-shared-components.d.ts +29 -0
- package/dist/site-system/rules/library/k1-shared-components.d.ts.map +1 -0
- package/dist/site-system/rules/library/k1-shared-components.js +97 -0
- package/dist/site-system/rules/library/k1-shared-components.js.map +1 -0
- package/dist/site-system/rules/navigation/extract.d.ts +50 -0
- package/dist/site-system/rules/navigation/extract.d.ts.map +1 -0
- package/dist/site-system/rules/navigation/extract.js +220 -0
- package/dist/site-system/rules/navigation/extract.js.map +1 -0
- package/dist/site-system/rules/navigation/html.d.ts +23 -0
- package/dist/site-system/rules/navigation/html.d.ts.map +1 -0
- package/dist/site-system/rules/navigation/html.js +219 -0
- package/dist/site-system/rules/navigation/html.js.map +1 -0
- package/dist/site-system/rules/navigation/index.d.ts +37 -0
- package/dist/site-system/rules/navigation/index.d.ts.map +1 -0
- package/dist/site-system/rules/navigation/index.js +163 -0
- package/dist/site-system/rules/navigation/index.js.map +1 -0
- package/dist/site-system/rules/navigation/record.d.ts +8 -0
- package/dist/site-system/rules/navigation/record.d.ts.map +1 -0
- package/dist/site-system/rules/navigation/record.js +39 -0
- package/dist/site-system/rules/navigation/record.js.map +1 -0
- package/dist/site-system/rules/sections/exif.d.ts +22 -0
- package/dist/site-system/rules/sections/exif.d.ts.map +1 -0
- package/dist/site-system/rules/sections/exif.js +218 -0
- package/dist/site-system/rules/sections/exif.js.map +1 -0
- package/dist/site-system/rules/sections/images.d.ts +31 -0
- package/dist/site-system/rules/sections/images.d.ts.map +1 -0
- package/dist/site-system/rules/sections/images.js +96 -0
- package/dist/site-system/rules/sections/images.js.map +1 -0
- package/dist/site-system/rules/sections/index.d.ts +20 -0
- package/dist/site-system/rules/sections/index.d.ts.map +1 -0
- package/dist/site-system/rules/sections/index.js +34 -0
- package/dist/site-system/rules/sections/index.js.map +1 -0
- package/dist/site-system/rules/sections/k11-density.d.ts +23 -0
- package/dist/site-system/rules/sections/k11-density.d.ts.map +1 -0
- package/dist/site-system/rules/sections/k11-density.js +88 -0
- package/dist/site-system/rules/sections/k11-density.js.map +1 -0
- package/dist/site-system/rules/sections/k12-contact.d.ts +13 -0
- package/dist/site-system/rules/sections/k12-contact.d.ts.map +1 -0
- package/dist/site-system/rules/sections/k12-contact.js +70 -0
- package/dist/site-system/rules/sections/k12-contact.js.map +1 -0
- package/dist/site-system/rules/sections/k2-skin-coverage.d.ts +11 -0
- package/dist/site-system/rules/sections/k2-skin-coverage.d.ts.map +1 -0
- package/dist/site-system/rules/sections/k2-skin-coverage.js +126 -0
- package/dist/site-system/rules/sections/k2-skin-coverage.js.map +1 -0
- package/dist/site-system/rules/sections/n9-footer.d.ts +40 -0
- package/dist/site-system/rules/sections/n9-footer.d.ts.map +1 -0
- package/dist/site-system/rules/sections/n9-footer.js +186 -0
- package/dist/site-system/rules/sections/n9-footer.js.map +1 -0
- package/dist/site-system/rules/sections/t6-media-entries.d.ts +14 -0
- package/dist/site-system/rules/sections/t6-media-entries.d.ts.map +1 -0
- package/dist/site-system/rules/sections/t6-media-entries.js +96 -0
- package/dist/site-system/rules/sections/t6-media-entries.js.map +1 -0
- package/dist/site-system/rules/sections/t7-main-images.d.ts +13 -0
- package/dist/site-system/rules/sections/t7-main-images.d.ts.map +1 -0
- package/dist/site-system/rules/sections/t7-main-images.js +41 -0
- package/dist/site-system/rules/sections/t7-main-images.js.map +1 -0
- package/dist/site-system/rules/sections/t8-prices.d.ts +13 -0
- package/dist/site-system/rules/sections/t8-prices.d.ts.map +1 -0
- package/dist/site-system/rules/sections/t8-prices.js +92 -0
- package/dist/site-system/rules/sections/t8-prices.js.map +1 -0
- package/dist/site-system/rules/sections/t9-location-metadata.d.ts +3 -0
- package/dist/site-system/rules/sections/t9-location-metadata.d.ts.map +1 -0
- package/dist/site-system/rules/sections/t9-location-metadata.js +54 -0
- package/dist/site-system/rules/sections/t9-location-metadata.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { fail } from "../enquiry/common.js";
|
|
2
|
+
import { attr, descendants } from "../enquiry/html.js";
|
|
3
|
+
/** How many page paths a message names before it says "and N other pages". */
|
|
4
|
+
const NAMED_PAGES = 3;
|
|
5
|
+
/** "/a", "/a and /b", "/a, /b and /c", or "/a, /b, /c and 4 other pages". */
|
|
6
|
+
export function pagesPhrase(paths) {
|
|
7
|
+
const unique = [...new Set(paths)];
|
|
8
|
+
if (unique.length <= NAMED_PAGES) {
|
|
9
|
+
return unique.length <= 1 ? (unique[0] ?? "the site") : `${unique.slice(0, -1).join(", ")} and ${unique[unique.length - 1]}`;
|
|
10
|
+
}
|
|
11
|
+
const rest = unique.length - NAMED_PAGES;
|
|
12
|
+
return `${unique.slice(0, NAMED_PAGES).join(", ")} and ${rest} other ${rest === 1 ? "page" : "pages"}`;
|
|
13
|
+
}
|
|
14
|
+
/** Collects `problem` (a clause with no subject) against the pages it was found on. */
|
|
15
|
+
export class PageProblems {
|
|
16
|
+
found = new Map();
|
|
17
|
+
add(path, problem) {
|
|
18
|
+
const paths = this.found.get(problem);
|
|
19
|
+
if (paths) {
|
|
20
|
+
if (!paths.includes(path))
|
|
21
|
+
paths.push(path);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
this.found.set(problem, [path]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
get size() {
|
|
28
|
+
return this.found.size;
|
|
29
|
+
}
|
|
30
|
+
/** One failing finding per distinct problem: "{problem} on {pages}." The target is the first page. */
|
|
31
|
+
findings(checkId) {
|
|
32
|
+
return [...this.found].map(([problem, paths]) => fail(checkId, paths[0], `${problem} on ${pagesPhrase(paths)}.`));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** The path's last segment, lower case: "/aa-tree/contact" and "/contact" both give "contact"; home gives "". */
|
|
36
|
+
export function lastSegment(path) {
|
|
37
|
+
const parts = path.split("/").filter(Boolean);
|
|
38
|
+
return (parts[parts.length - 1] ?? "").toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
export function isContactPath(path) {
|
|
41
|
+
return ["contact", "contact-us", "get-in-touch"].includes(lastSegment(path));
|
|
42
|
+
}
|
|
43
|
+
/** The contact page: a contact path, or any page that holds the library's contact section. */
|
|
44
|
+
export function isContactPage(page) {
|
|
45
|
+
return isContactPath(page.path) || descendants(page.root).some((element) => attr(element, "data-bl-section") === "contact");
|
|
46
|
+
}
|
|
47
|
+
export function isThankYouPath(path) {
|
|
48
|
+
return /^(?:thank-you|thanks|thankyou|enquiry-sent|message-sent)$/.test(lastSegment(path));
|
|
49
|
+
}
|
|
50
|
+
/** The first element below `node` for which `test` holds. */
|
|
51
|
+
export function findElement(node, test) {
|
|
52
|
+
return descendants(node).find(test);
|
|
53
|
+
}
|
|
54
|
+
export function hookOf(element) {
|
|
55
|
+
return attr(element, "data-bl");
|
|
56
|
+
}
|
|
57
|
+
/** Elements carrying `data-bl="name"`. */
|
|
58
|
+
export function byHook(root, name) {
|
|
59
|
+
return descendants(root).filter((element) => attr(element, "data-bl") === name);
|
|
60
|
+
}
|
|
61
|
+
/** The `<main>` element, or the whole document when the page has none. */
|
|
62
|
+
export function mainOf(root) {
|
|
63
|
+
return descendants(root).find((element) => element.tag === "main") ?? root;
|
|
64
|
+
}
|
|
65
|
+
/** The first `n` items of a list, then "and N more": for messages that name files or images. */
|
|
66
|
+
export function namedList(items, shown = 5) {
|
|
67
|
+
const head = items.slice(0, shown).join(", ");
|
|
68
|
+
return items.length > shown ? `${head} and ${items.length - shown} more` : head;
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=common.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"common.js","sourceRoot":"","sources":["../../../../src/site-system/rules/library/common.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Helpers the 3.9.0 to 3.11.0 rule modules (library, form, sections) share: the same problem found on many pages is\n * one finding that names a few of them, and a page is found by the last segment of its path so a site mounted below a\n * prefix (the component catalogue) reads like a site mounted at the root.\n */\nimport type { SiteSystemCheckId, SiteSystemFinding } from \"../../checks.js\";\nimport { fail } from \"../enquiry/common.js\";\nimport { attr, descendants, type HtmlElement } from \"../enquiry/html.js\";\n\n/** How many page paths a message names before it says \"and N other pages\". */\nconst NAMED_PAGES = 3;\n\n/** \"/a\", \"/a and /b\", \"/a, /b and /c\", or \"/a, /b, /c and 4 other pages\". */\nexport function pagesPhrase(paths: readonly string[]): string {\n const unique = [...new Set(paths)];\n if (unique.length <= NAMED_PAGES) {\n return unique.length <= 1 ? (unique[0] ?? \"the site\") : `${unique.slice(0, -1).join(\", \")} and ${unique[unique.length - 1]}`;\n }\n const rest = unique.length - NAMED_PAGES;\n return `${unique.slice(0, NAMED_PAGES).join(\", \")} and ${rest} other ${rest === 1 ? \"page\" : \"pages\"}`;\n}\n\n/** Collects `problem` (a clause with no subject) against the pages it was found on. */\nexport class PageProblems {\n private readonly found = new Map<string, string[]>();\n\n add(path: string, problem: string): void {\n const paths = this.found.get(problem);\n if (paths) {\n if (!paths.includes(path)) paths.push(path);\n } else {\n this.found.set(problem, [path]);\n }\n }\n\n get size(): number {\n return this.found.size;\n }\n\n /** One failing finding per distinct problem: \"{problem} on {pages}.\" The target is the first page. */\n findings(checkId: SiteSystemCheckId): SiteSystemFinding[] {\n return [...this.found].map(([problem, paths]) => fail(checkId, paths[0], `${problem} on ${pagesPhrase(paths)}.`));\n }\n}\n\n/** The path's last segment, lower case: \"/aa-tree/contact\" and \"/contact\" both give \"contact\"; home gives \"\". */\nexport function lastSegment(path: string): string {\n const parts = path.split(\"/\").filter(Boolean);\n return (parts[parts.length - 1] ?? \"\").toLowerCase();\n}\n\nexport function isContactPath(path: string): boolean {\n return [\"contact\", \"contact-us\", \"get-in-touch\"].includes(lastSegment(path));\n}\n\n/** The contact page: a contact path, or any page that holds the library's contact section. */\nexport function isContactPage(page: { readonly path: string; readonly root: HtmlElement }): boolean {\n return isContactPath(page.path) || descendants(page.root).some((element) => attr(element, \"data-bl-section\") === \"contact\");\n}\n\nexport function isThankYouPath(path: string): boolean {\n return /^(?:thank-you|thanks|thankyou|enquiry-sent|message-sent)$/.test(lastSegment(path));\n}\n\n/** The first element below `node` for which `test` holds. */\nexport function findElement(node: HtmlElement, test: (element: HtmlElement) => boolean): HtmlElement | undefined {\n return descendants(node).find(test);\n}\n\nexport function hookOf(element: HtmlElement): string | undefined {\n return attr(element, \"data-bl\");\n}\n\n/** Elements carrying `data-bl=\"name\"`. */\nexport function byHook(root: HtmlElement, name: string): HtmlElement[] {\n return descendants(root).filter((element) => attr(element, \"data-bl\") === name);\n}\n\n/** The `<main>` element, or the whole document when the page has none. */\nexport function mainOf(root: HtmlElement): HtmlElement {\n return descendants(root).find((element) => element.tag === \"main\") ?? root;\n}\n\n/** The first `n` items of a list, then \"and N more\": for messages that name files or images. */\nexport function namedList(items: readonly string[], shown = 5): string {\n const head = items.slice(0, shown).join(\", \");\n return items.length > shown ? `${head} and ${items.length - shown} more` : head;\n}\n"]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The library rule module (release 3.9.0): K1, shared components in use. It reads the declared tier from
|
|
3
|
+
* brandlab.config.ts so the enquiry-tier parts (quote form, reviews band, CTA band, footer) are required only where
|
|
4
|
+
* the tier covers them; a config it cannot read leaves the shell parts, which every tier requires.
|
|
5
|
+
*/
|
|
6
|
+
import type { SiteSystemFinding, SiteSystemTier } from "../../checks.js";
|
|
7
|
+
import { type EnquiryRuleInput } from "../enquiry/common.js";
|
|
8
|
+
export { K1_SHELL_PARTS, checkK1 } from "./k1-shared-components.js";
|
|
9
|
+
export { PageProblems, isContactPath, isThankYouPath, lastSegment, pagesPhrase } from "./common.js";
|
|
10
|
+
export interface LibraryRuleOptions {
|
|
11
|
+
/** Replaces the tier read from brandlab.config.ts (tests). */
|
|
12
|
+
readonly tier?: SiteSystemTier;
|
|
13
|
+
}
|
|
14
|
+
/** Runs K1 over the sampled pages. */
|
|
15
|
+
export declare function runLibraryRules(input: EnquiryRuleInput, options?: LibraryRuleOptions): Promise<readonly SiteSystemFinding[]>;
|
|
16
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/library/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEzE,OAAO,EAAc,KAAK,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAGzE,OAAO,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAEpG,MAAM,WAAW,kBAAkB;IACjC,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,CAAC,EAAE,cAAc,CAAC;CAChC;AAUD,sCAAsC;AACtC,wBAAsB,eAAe,CAAC,KAAK,EAAE,gBAAgB,EAAE,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,SAAS,iBAAiB,EAAE,CAAC,CAGtI"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readSiteSystemProjectConfig } from "../../config.js";
|
|
2
|
+
import { parsePages } from "../enquiry/common.js";
|
|
3
|
+
import { checkK1 } from "./k1-shared-components.js";
|
|
4
|
+
export { K1_SHELL_PARTS, checkK1 } from "./k1-shared-components.js";
|
|
5
|
+
export { PageProblems, isContactPath, isThankYouPath, lastSegment, pagesPhrase } from "./common.js";
|
|
6
|
+
function declaredTier(projectRoot) {
|
|
7
|
+
try {
|
|
8
|
+
return readSiteSystemProjectConfig(projectRoot).siteSystem?.tier ?? "shell";
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return "shell";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Runs K1 over the sampled pages. */
|
|
15
|
+
export async function runLibraryRules(input, options = {}) {
|
|
16
|
+
const tier = options.tier ?? declaredTier(input.projectRoot);
|
|
17
|
+
return checkK1(parsePages(input.pages), tier);
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/site-system/rules/library/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,2BAA2B,EAAE,MAAM,iBAAiB,CAAC;AAC9D,OAAO,EAAE,UAAU,EAAyB,MAAM,sBAAsB,CAAC;AACzE,OAAO,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AAEpD,OAAO,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAOpG,SAAS,YAAY,CAAC,WAAmB;IACvC,IAAI,CAAC;QACH,OAAO,2BAA2B,CAAC,WAAW,CAAC,CAAC,UAAU,EAAE,IAAI,IAAI,OAAO,CAAC;IAC9E,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,OAAO,CAAC;IACjB,CAAC;AACH,CAAC;AAED,sCAAsC;AACtC,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,KAAuB,EAAE,UAA8B,EAAE;IAC7F,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,YAAY,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;IAC7D,OAAO,OAAO,CAAC,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,IAAI,CAAC,CAAC;AAChD,CAAC","sourcesContent":["/**\n * The library rule module (release 3.9.0): K1, shared components in use. It reads the declared tier from\n * brandlab.config.ts so the enquiry-tier parts (quote form, reviews band, CTA band, footer) are required only where\n * the tier covers them; a config it cannot read leaves the shell parts, which every tier requires.\n */\nimport type { SiteSystemFinding, SiteSystemTier } from \"../../checks.js\";\nimport { readSiteSystemProjectConfig } from \"../../config.js\";\nimport { parsePages, type EnquiryRuleInput } from \"../enquiry/common.js\";\nimport { checkK1 } from \"./k1-shared-components.js\";\n\nexport { K1_SHELL_PARTS, checkK1 } from \"./k1-shared-components.js\";\nexport { PageProblems, isContactPath, isThankYouPath, lastSegment, pagesPhrase } from \"./common.js\";\n\nexport interface LibraryRuleOptions {\n /** Replaces the tier read from brandlab.config.ts (tests). */\n readonly tier?: SiteSystemTier;\n}\n\nfunction declaredTier(projectRoot: string): SiteSystemTier {\n try {\n return readSiteSystemProjectConfig(projectRoot).siteSystem?.tier ?? \"shell\";\n } catch {\n return \"shell\";\n }\n}\n\n/** Runs K1 over the sampled pages. */\nexport async function runLibraryRules(input: EnquiryRuleInput, options: LibraryRuleOptions = {}): Promise<readonly SiteSystemFinding[]> {\n const tier = options.tier ?? declaredTier(input.projectRoot);\n return checkK1(parsePages(input.pages), tier);\n}\n"]}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* K1: shared components in use where the declared tier covers them (Site System v2 §13.2).
|
|
3
|
+
*
|
|
4
|
+
* The library components mark their root with `data-bl="<name>"` in the server HTML, so use is read from the rendered
|
|
5
|
+
* pages. The shell parts (header, drawer, call bar, back-to-top) are all in the server HTML: the call bar and
|
|
6
|
+
* back-to-top are client islands, but they render hidden and `inert` on the server (`data-state="hidden"`), so a page
|
|
7
|
+
* that lacks them has not mounted the library's shell. From the enquiry tier the quote form, reviews band, CTA band
|
|
8
|
+
* and footer must also be the library's wherever the page has such a part. The lint rule catches a literal `tel:`
|
|
9
|
+
* href in source; this rule is its rendered counterpart: no `tel:` link outside an element with
|
|
10
|
+
* `data-bl="phone-link"`, so a hand-written call link cannot hide behind a wrapper the lint cannot see through.
|
|
11
|
+
*/
|
|
12
|
+
import type { SiteSystemFinding, SiteSystemTier } from "../../checks.js";
|
|
13
|
+
import { type ParsedPage } from "../enquiry/common.js";
|
|
14
|
+
/** The shell parts every page carries, by the tier that requires them. */
|
|
15
|
+
export declare const K1_SHELL_PARTS: readonly [{
|
|
16
|
+
readonly hook: "header";
|
|
17
|
+
readonly name: "header";
|
|
18
|
+
}, {
|
|
19
|
+
readonly hook: "drawer";
|
|
20
|
+
readonly name: "mobile drawer";
|
|
21
|
+
}, {
|
|
22
|
+
readonly hook: "callbar";
|
|
23
|
+
readonly name: "call bar";
|
|
24
|
+
}, {
|
|
25
|
+
readonly hook: "back-to-top";
|
|
26
|
+
readonly name: "back-to-top control";
|
|
27
|
+
}];
|
|
28
|
+
export declare function checkK1(pages: readonly ParsedPage[], tier?: SiteSystemTier): SiteSystemFinding[];
|
|
29
|
+
//# sourceMappingURL=k1-shared-components.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"k1-shared-components.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/library/k1-shared-components.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACzE,OAAO,EAA+B,KAAK,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAKpF,0EAA0E;AAC1E,eAAO,MAAM,cAAc;;;;;;;;;;;;EAKjB,CAAC;AA8CX,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,EAAE,IAAI,GAAE,cAAwB,GAAG,iBAAiB,EAAE,CA8CzG"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { notApplicable, pass, plural } from "../enquiry/common.js";
|
|
2
|
+
import { isQuoteForm } from "../enquiry/c2-quote-form.js";
|
|
3
|
+
import { ancestors, attr, closest, descendants, textOf } from "../enquiry/html.js";
|
|
4
|
+
import { PageProblems, byHook, mainOf } from "./common.js";
|
|
5
|
+
/** The shell parts every page carries, by the tier that requires them. */
|
|
6
|
+
export const K1_SHELL_PARTS = [
|
|
7
|
+
{ hook: "header", name: "header" },
|
|
8
|
+
{ hook: "drawer", name: "mobile drawer" },
|
|
9
|
+
{ hook: "callbar", name: "call bar" },
|
|
10
|
+
{ hook: "back-to-top", name: "back-to-top control" },
|
|
11
|
+
];
|
|
12
|
+
const REVIEWS_HEADING = /\b(?:reviews?|testimonials?)\b|what (?:our )?(?:customers|clients|people) (?:say|think)/i;
|
|
13
|
+
const QUOTE_LINK = /\b(?:quote|estimate|inspection|book)\b/i;
|
|
14
|
+
const CTA_BAND_WORDS = 60;
|
|
15
|
+
/** Page-level footers: a `<footer>` that is not the footer of an article, section, aside, quote or dialog. */
|
|
16
|
+
function pageFooters(root) {
|
|
17
|
+
return descendants(root).filter((element) => element.tag === "footer" &&
|
|
18
|
+
!ancestors(element).some((parent) => ["article", "section", "aside", "main", "blockquote", "dialog"].includes(parent.tag)));
|
|
19
|
+
}
|
|
20
|
+
function isUnbound(element) {
|
|
21
|
+
return attr(element, "data-bl-section") === undefined;
|
|
22
|
+
}
|
|
23
|
+
/** A section in the main content, written by hand, whose own heading reads like a reviews section. */
|
|
24
|
+
function handBuiltReviews(main) {
|
|
25
|
+
return descendants(main).find((element) => {
|
|
26
|
+
if (element.tag !== "section" || !isUnbound(element))
|
|
27
|
+
return false;
|
|
28
|
+
if (ancestors(element).some((parent) => attr(parent, "data-bl-section") !== undefined))
|
|
29
|
+
return false;
|
|
30
|
+
const inside = descendants(element);
|
|
31
|
+
const headings = inside.filter((child) => /^h[23]$/.test(child.tag));
|
|
32
|
+
// A wrapper around many sections is not the reviews section; its own heading is the one that counts.
|
|
33
|
+
return inside.every((child) => child.tag !== "section") && headings.some((child) => REVIEWS_HEADING.test(textOf(child)));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** The last section of the main content, when it is a hand-written band with a call and a quote link. */
|
|
37
|
+
function handBuiltCtaBand(main) {
|
|
38
|
+
const sections = descendants(main).filter((element) => element.tag === "section" && !closest(element.parent ?? element, (parent) => parent.tag === "section"));
|
|
39
|
+
const last = sections[sections.length - 1];
|
|
40
|
+
if (!last || !isUnbound(last))
|
|
41
|
+
return undefined;
|
|
42
|
+
const links = descendants(last).filter((element) => element.tag === "a");
|
|
43
|
+
const hasCall = links.some((link) => (attr(link, "href") ?? "").toLowerCase().startsWith("tel:"));
|
|
44
|
+
const hasQuote = links.some((link) => QUOTE_LINK.test(textOf(link)));
|
|
45
|
+
const heading = descendants(last).some((element) => element.tag === "h2");
|
|
46
|
+
const words = textOf(last).split(/\s+/).filter(Boolean).length;
|
|
47
|
+
return hasCall && hasQuote && heading && words <= CTA_BAND_WORDS ? last : undefined;
|
|
48
|
+
}
|
|
49
|
+
export function checkK1(pages, tier = "shell") {
|
|
50
|
+
if (pages.length === 0)
|
|
51
|
+
return [notApplicable("K1", "No page could be read, so there is nothing to check for shared components.")];
|
|
52
|
+
const problems = new PageProblems();
|
|
53
|
+
const coversEnquiry = tier === "enquiry" || tier === "full";
|
|
54
|
+
for (const page of pages) {
|
|
55
|
+
for (const part of K1_SHELL_PARTS) {
|
|
56
|
+
if (byHook(page.root, part.hook).length === 0) {
|
|
57
|
+
problems.add(page.path, `No ${part.name} from the shared library (no data-bl="${part.hook}")`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const hand = descendants(page.root).some((element) => {
|
|
61
|
+
if (element.tag !== "a" || !(attr(element, "href") ?? "").trim().toLowerCase().startsWith("tel:"))
|
|
62
|
+
return false;
|
|
63
|
+
return closest(element, (candidate) => attr(candidate, "data-bl") === "phone-link") === null;
|
|
64
|
+
});
|
|
65
|
+
if (hand)
|
|
66
|
+
problems.add(page.path, `A tel: link written by hand, outside the shared phone link (data-bl="phone-link")`);
|
|
67
|
+
if (!coversEnquiry)
|
|
68
|
+
continue;
|
|
69
|
+
for (const form of descendants(page.root).filter((element) => element.tag === "form")) {
|
|
70
|
+
if (attr(form, "data-bl") !== "quote-form" && isQuoteForm(form)) {
|
|
71
|
+
problems.add(page.path, `A quote form that is not the shared one (no data-bl="quote-form")`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const footers = pageFooters(page.root);
|
|
75
|
+
if (footers.length === 0 && byHook(page.root, "footer").length === 0) {
|
|
76
|
+
problems.add(page.path, `No footer from the shared library (no data-bl="footer")`);
|
|
77
|
+
}
|
|
78
|
+
else if (footers.some((footer) => attr(footer, "data-bl") !== "footer")) {
|
|
79
|
+
problems.add(page.path, `A footer written by hand instead of the shared one (no data-bl="footer")`);
|
|
80
|
+
}
|
|
81
|
+
const main = mainOf(page.root);
|
|
82
|
+
if (handBuiltReviews(main))
|
|
83
|
+
problems.add(page.path, `A reviews section written by hand (no data-bl-section="reviews")`);
|
|
84
|
+
if (handBuiltCtaBand(main))
|
|
85
|
+
problems.add(page.path, `A call-to-action band written by hand (no data-bl-section="cta")`);
|
|
86
|
+
}
|
|
87
|
+
if (problems.size > 0)
|
|
88
|
+
return problems.findings("K1");
|
|
89
|
+
const names = K1_SHELL_PARTS.map((part) => part.name);
|
|
90
|
+
const parts = `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
91
|
+
return [
|
|
92
|
+
pass("K1", coversEnquiry
|
|
93
|
+
? `Checked ${plural(pages.length, "page")}: the ${parts}, quote form, reviews band, CTA band and footer are the shared components, and no tel: link is written by hand.`
|
|
94
|
+
: `Checked ${plural(pages.length, "page")}: the ${parts} are the shared components, and no tel: link is written by hand.`),
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=k1-shared-components.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"k1-shared-components.js","sourceRoot":"","sources":["../../../../src/site-system/rules/library/k1-shared-components.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * K1: shared components in use where the declared tier covers them (Site System v2 §13.2).\n *\n * The library components mark their root with `data-bl=\"<name>\"` in the server HTML, so use is read from the rendered\n * pages. The shell parts (header, drawer, call bar, back-to-top) are all in the server HTML: the call bar and\n * back-to-top are client islands, but they render hidden and `inert` on the server (`data-state=\"hidden\"`), so a page\n * that lacks them has not mounted the library's shell. From the enquiry tier the quote form, reviews band, CTA band\n * and footer must also be the library's wherever the page has such a part. The lint rule catches a literal `tel:`\n * href in source; this rule is its rendered counterpart: no `tel:` link outside an element with\n * `data-bl=\"phone-link\"`, so a hand-written call link cannot hide behind a wrapper the lint cannot see through.\n */\nimport type { SiteSystemFinding, SiteSystemTier } from \"../../checks.js\";\nimport { notApplicable, pass, plural, type ParsedPage } from \"../enquiry/common.js\";\nimport { isQuoteForm } from \"../enquiry/c2-quote-form.js\";\nimport { ancestors, attr, closest, descendants, textOf, type HtmlElement } from \"../enquiry/html.js\";\nimport { PageProblems, byHook, mainOf } from \"./common.js\";\n\n/** The shell parts every page carries, by the tier that requires them. */\nexport const K1_SHELL_PARTS = [\n { hook: \"header\", name: \"header\" },\n { hook: \"drawer\", name: \"mobile drawer\" },\n { hook: \"callbar\", name: \"call bar\" },\n { hook: \"back-to-top\", name: \"back-to-top control\" },\n] as const;\n\nconst REVIEWS_HEADING = /\\b(?:reviews?|testimonials?)\\b|what (?:our )?(?:customers|clients|people) (?:say|think)/i;\nconst QUOTE_LINK = /\\b(?:quote|estimate|inspection|book)\\b/i;\nconst CTA_BAND_WORDS = 60;\n\n/** Page-level footers: a `<footer>` that is not the footer of an article, section, aside, quote or dialog. */\nfunction pageFooters(root: HtmlElement): HtmlElement[] {\n return descendants(root).filter(\n (element) =>\n element.tag === \"footer\" &&\n !ancestors(element).some((parent) => [\"article\", \"section\", \"aside\", \"main\", \"blockquote\", \"dialog\"].includes(parent.tag)),\n );\n}\n\nfunction isUnbound(element: HtmlElement): boolean {\n return attr(element, \"data-bl-section\") === undefined;\n}\n\n/** A section in the main content, written by hand, whose own heading reads like a reviews section. */\nfunction handBuiltReviews(main: HtmlElement): HtmlElement | undefined {\n return descendants(main).find((element) => {\n if (element.tag !== \"section\" || !isUnbound(element)) return false;\n if (ancestors(element).some((parent) => attr(parent, \"data-bl-section\") !== undefined)) return false;\n const inside = descendants(element);\n const headings = inside.filter((child) => /^h[23]$/.test(child.tag));\n // A wrapper around many sections is not the reviews section; its own heading is the one that counts.\n return inside.every((child) => child.tag !== \"section\") && headings.some((child) => REVIEWS_HEADING.test(textOf(child)));\n });\n}\n\n/** The last section of the main content, when it is a hand-written band with a call and a quote link. */\nfunction handBuiltCtaBand(main: HtmlElement): HtmlElement | undefined {\n const sections = descendants(main).filter(\n (element) => element.tag === \"section\" && !closest(element.parent ?? element, (parent) => parent.tag === \"section\"),\n );\n const last = sections[sections.length - 1];\n if (!last || !isUnbound(last)) return undefined;\n const links = descendants(last).filter((element) => element.tag === \"a\");\n const hasCall = links.some((link) => (attr(link, \"href\") ?? \"\").toLowerCase().startsWith(\"tel:\"));\n const hasQuote = links.some((link) => QUOTE_LINK.test(textOf(link)));\n const heading = descendants(last).some((element) => element.tag === \"h2\");\n const words = textOf(last).split(/\\s+/).filter(Boolean).length;\n return hasCall && hasQuote && heading && words <= CTA_BAND_WORDS ? last : undefined;\n}\n\nexport function checkK1(pages: readonly ParsedPage[], tier: SiteSystemTier = \"shell\"): SiteSystemFinding[] {\n if (pages.length === 0) return [notApplicable(\"K1\", \"No page could be read, so there is nothing to check for shared components.\")];\n const problems = new PageProblems();\n const coversEnquiry = tier === \"enquiry\" || tier === \"full\";\n\n for (const page of pages) {\n for (const part of K1_SHELL_PARTS) {\n if (byHook(page.root, part.hook).length === 0) {\n problems.add(page.path, `No ${part.name} from the shared library (no data-bl=\"${part.hook}\")`);\n }\n }\n\n const hand = descendants(page.root).some((element) => {\n if (element.tag !== \"a\" || !(attr(element, \"href\") ?? \"\").trim().toLowerCase().startsWith(\"tel:\")) return false;\n return closest(element, (candidate) => attr(candidate, \"data-bl\") === \"phone-link\") === null;\n });\n if (hand) problems.add(page.path, `A tel: link written by hand, outside the shared phone link (data-bl=\"phone-link\")`);\n\n if (!coversEnquiry) continue;\n for (const form of descendants(page.root).filter((element) => element.tag === \"form\")) {\n if (attr(form, \"data-bl\") !== \"quote-form\" && isQuoteForm(form)) {\n problems.add(page.path, `A quote form that is not the shared one (no data-bl=\"quote-form\")`);\n }\n }\n const footers = pageFooters(page.root);\n if (footers.length === 0 && byHook(page.root, \"footer\").length === 0) {\n problems.add(page.path, `No footer from the shared library (no data-bl=\"footer\")`);\n } else if (footers.some((footer) => attr(footer, \"data-bl\") !== \"footer\")) {\n problems.add(page.path, `A footer written by hand instead of the shared one (no data-bl=\"footer\")`);\n }\n const main = mainOf(page.root);\n if (handBuiltReviews(main)) problems.add(page.path, `A reviews section written by hand (no data-bl-section=\"reviews\")`);\n if (handBuiltCtaBand(main)) problems.add(page.path, `A call-to-action band written by hand (no data-bl-section=\"cta\")`);\n }\n\n if (problems.size > 0) return problems.findings(\"K1\");\n const names = K1_SHELL_PARTS.map((part) => part.name);\n const parts = `${names.slice(0, -1).join(\", \")} and ${names[names.length - 1]}`;\n return [\n pass(\n \"K1\",\n coversEnquiry\n ? `Checked ${plural(pages.length, \"page\")}: the ${parts}, quote form, reviews band, CTA band and footer are the shared components, and no tel: link is written by hand.`\n : `Checked ${plural(pages.length, \"page\")}: the ${parts} are the shared components, and no tel: link is written by hand.`,\n ),\n ];\n}\n"]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the main navigation out of rendered HTML: the `<nav aria-label="Main">` inside the `<header>`, its top-level
|
|
3
|
+
* items in order, and for each dropdown the links of its panel. It reads structure, not class names, so it works on
|
|
4
|
+
* the ui-kit header and on a header a site built itself: a top-level item is a list entry (or, with no list, a child
|
|
5
|
+
* of the nav); its label link is the first link outside the panel; a dropdown is an entry with a disclosure button
|
|
6
|
+
* or a panel (the element the button controls, a nested list, or an element marked as a panel).
|
|
7
|
+
*/
|
|
8
|
+
import { type HtmlElement } from "./html.js";
|
|
9
|
+
export interface NavigationLinkLike {
|
|
10
|
+
label: string;
|
|
11
|
+
href: string;
|
|
12
|
+
}
|
|
13
|
+
export interface NavigationLinkItemLike {
|
|
14
|
+
kind: "link";
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
href: string;
|
|
18
|
+
}
|
|
19
|
+
export interface NavigationMenuItemLike {
|
|
20
|
+
kind: "menu";
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
href: string;
|
|
24
|
+
hubLabel: string;
|
|
25
|
+
panel: "list";
|
|
26
|
+
links: NavigationLinkLike[];
|
|
27
|
+
}
|
|
28
|
+
export type NavigationItemLike = NavigationLinkItemLike | NavigationMenuItemLike;
|
|
29
|
+
export interface ExtractedNavigation {
|
|
30
|
+
readonly root: HtmlElement;
|
|
31
|
+
readonly nav: HtmlElement;
|
|
32
|
+
/** The header that holds the nav, or the whole document when the nav stands on its own. */
|
|
33
|
+
readonly header: HtmlElement;
|
|
34
|
+
readonly items: NavigationItemLike[];
|
|
35
|
+
}
|
|
36
|
+
export declare function slugOf(text: string): string;
|
|
37
|
+
/** The main navigation: the first `<nav aria-label="Main">` inside the `<header>`, outside any dialog or drawer. */
|
|
38
|
+
export declare function findMainNav(root: HtmlElement): HtmlElement | undefined;
|
|
39
|
+
/** A link as the menu sees it: a same-site path without query or hash, an absolute URL for other sites, or "" for no page. */
|
|
40
|
+
export declare function sitePath(href: string | undefined, baseUrl: string): string;
|
|
41
|
+
/** The main navigation of a document: its element and its top-level items in order. Undefined when the page has none. */
|
|
42
|
+
export declare function extractMainNavigation(html: string, baseUrl: string): ExtractedNavigation | undefined;
|
|
43
|
+
/** Whether the header holds a phone link and a quote button, outside any drawer or dialog. */
|
|
44
|
+
export declare function headerActions(header: HtmlElement): {
|
|
45
|
+
phone: boolean;
|
|
46
|
+
quote: boolean;
|
|
47
|
+
};
|
|
48
|
+
/** Every link on a page outside the given element, as same-site paths: the evidence of which pages exist. */
|
|
49
|
+
export declare function linkedPaths(root: HtmlElement, baseUrl: string, except?: HtmlElement): string[];
|
|
50
|
+
//# sourceMappingURL=extract.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/navigation/extract.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAA0E,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAErH,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,kBAAkB,EAAE,CAAC;CAC7B;AAED,MAAM,MAAM,kBAAkB,GAAG,sBAAsB,GAAG,sBAAsB,CAAC;AAEjF,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,QAAQ,CAAC,GAAG,EAAE,WAAW,CAAC;IAC1B,2FAA2F;IAC3F,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,kBAAkB,EAAE,CAAC;CACtC;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE3C;AAMD,oHAAoH;AACpH,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,GAAG,WAAW,GAAG,SAAS,CAWtE;AAMD,8HAA8H;AAC9H,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAW1E;AA0ID,yHAAyH;AACzH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS,CAcpG;AAED,8FAA8F;AAC9F,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAUrF;AAED,6GAA6G;AAC7G,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,MAAM,EAAE,CAS9F"}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the main navigation out of rendered HTML: the `<nav aria-label="Main">` inside the `<header>`, its top-level
|
|
3
|
+
* items in order, and for each dropdown the links of its panel. It reads structure, not class names, so it works on
|
|
4
|
+
* the ui-kit header and on a header a site built itself: a top-level item is a list entry (or, with no list, a child
|
|
5
|
+
* of the nav); its label link is the first link outside the panel; a dropdown is an entry with a disclosure button
|
|
6
|
+
* or a panel (the element the button controls, a nested list, or an element marked as a panel).
|
|
7
|
+
*/
|
|
8
|
+
import { descendants, elementChildren, hasAncestor, isInside, parseHtml, textOf } from "./html.js";
|
|
9
|
+
export function slugOf(text) {
|
|
10
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
11
|
+
}
|
|
12
|
+
function isNavElement(element) {
|
|
13
|
+
return element.tag === "nav" || element.attrs.role === "navigation";
|
|
14
|
+
}
|
|
15
|
+
/** The main navigation: the first `<nav aria-label="Main">` inside the `<header>`, outside any dialog or drawer. */
|
|
16
|
+
export function findMainNav(root) {
|
|
17
|
+
const all = descendants(root);
|
|
18
|
+
const headers = all.filter((element) => element.tag === "header" || element.attrs.role === "banner");
|
|
19
|
+
const inDialog = (element) => hasAncestor(element, (ancestor) => ancestor.tag === "dialog" || ancestor.attrs["aria-modal"] === "true");
|
|
20
|
+
return all.find((element) => isNavElement(element) &&
|
|
21
|
+
(element.attrs["aria-label"] ?? "").trim() === "Main" &&
|
|
22
|
+
headers.some((header) => isInside(element, header)) &&
|
|
23
|
+
!inDialog(element));
|
|
24
|
+
}
|
|
25
|
+
function sameSite(left, right) {
|
|
26
|
+
return left.replace(/^www\./i, "").toLowerCase() === right.replace(/^www\./i, "").toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
/** A link as the menu sees it: a same-site path without query or hash, an absolute URL for other sites, or "" for no page. */
|
|
29
|
+
export function sitePath(href, baseUrl) {
|
|
30
|
+
const raw = (href ?? "").trim();
|
|
31
|
+
if (raw === "" || raw.startsWith("#") || /^javascript:/i.test(raw))
|
|
32
|
+
return "";
|
|
33
|
+
if (/^(tel|mailto|sms|whatsapp):/i.test(raw))
|
|
34
|
+
return raw;
|
|
35
|
+
try {
|
|
36
|
+
const base = new URL(baseUrl || "https://site.invalid");
|
|
37
|
+
const url = new URL(raw, base);
|
|
38
|
+
return sameSite(url.hostname, base.hostname) ? url.pathname : url.href;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return raw;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** A `<summary>` always opens something; a button does when it says so. */
|
|
45
|
+
function isDisclosure(element) {
|
|
46
|
+
if (element.tag === "summary")
|
|
47
|
+
return true;
|
|
48
|
+
const control = element.tag === "button" || element.attrs.role === "button";
|
|
49
|
+
return control && ("aria-expanded" in element.attrs || "aria-haspopup" in element.attrs || "aria-controls" in element.attrs);
|
|
50
|
+
}
|
|
51
|
+
/** A button that opens the phone drawer or toggles the whole menu is not a dropdown of this menu. */
|
|
52
|
+
const MENU_BUTTON_TEXT = /^(open |close |toggle |show |hide )?(the )?(main |site |mobile )?(menu|navigation|nav)( menu)?$/i;
|
|
53
|
+
const PANEL_TAGS = new Set(["div", "ul", "ol", "nav", "section", "aside"]);
|
|
54
|
+
const LABELISH_TAGS = new Set(["a", "button", "span", "summary"]);
|
|
55
|
+
function previousElement(element) {
|
|
56
|
+
const siblings = element.parent ? elementChildren(element.parent) : [];
|
|
57
|
+
return siblings[siblings.indexOf(element) - 1];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The panels inside a nav: what a disclosure button controls, anything marked as a panel, and a block that follows
|
|
61
|
+
* a labelled link, button or span inside an item and holds links (`<a>Services</a><div>...links...</div>`).
|
|
62
|
+
*/
|
|
63
|
+
function findPanels(nav) {
|
|
64
|
+
const all = descendants(nav);
|
|
65
|
+
const panels = new Set();
|
|
66
|
+
for (const toggle of all.filter(isDisclosure)) {
|
|
67
|
+
const id = toggle.attrs["aria-controls"];
|
|
68
|
+
const target = id ? all.find((element) => element.attrs.id === id) : undefined;
|
|
69
|
+
if (target)
|
|
70
|
+
panels.add(target);
|
|
71
|
+
}
|
|
72
|
+
for (const element of all) {
|
|
73
|
+
if (element.attrs["data-slot"] === "panel" || element.attrs.role === "menu")
|
|
74
|
+
panels.add(element);
|
|
75
|
+
}
|
|
76
|
+
for (const element of all) {
|
|
77
|
+
if (!PANEL_TAGS.has(element.tag) || !element.parent || element.parent === nav)
|
|
78
|
+
continue;
|
|
79
|
+
const before = previousElement(element);
|
|
80
|
+
if (!before || !LABELISH_TAGS.has(before.tag))
|
|
81
|
+
continue;
|
|
82
|
+
const beforeText = cleanLabel(textOf(before));
|
|
83
|
+
if (beforeText === "" || MENU_BUTTON_TEXT.test(beforeText))
|
|
84
|
+
continue;
|
|
85
|
+
if (descendants(element).some((entry) => entry.tag === "a" && "href" in entry.attrs))
|
|
86
|
+
panels.add(element);
|
|
87
|
+
}
|
|
88
|
+
return panels;
|
|
89
|
+
}
|
|
90
|
+
/** Entries that are actions, not menu items: the phone, the email, the quote button. */
|
|
91
|
+
function isAction(entry, label, href) {
|
|
92
|
+
if (/^(tel|mailto|sms|whatsapp):/i.test(href))
|
|
93
|
+
return true;
|
|
94
|
+
const marked = [entry, ...descendants(entry)].some((element) => element.attrs["data-bl"] === "quote-button" || "data-bl-cta" in element.attrs || "data-lead-cta" in element.attrs || "data-phone-location" in element.attrs);
|
|
95
|
+
if (marked)
|
|
96
|
+
return true;
|
|
97
|
+
return /\bquote\b/i.test(label) || /^(call|phone|ring)\b/i.test(label) || /^\+?[\d\s()-]{8,}$/.test(label) || /^send photos?$/i.test(label);
|
|
98
|
+
}
|
|
99
|
+
/** Drops the chevron glyphs a bespoke header may put in the label text. */
|
|
100
|
+
function cleanLabel(text) {
|
|
101
|
+
return text.replace(/[\u25bc\u25be\u25bf\u2304\u02c5\u2193\u25b8\u203a]+$/u, "").replace(/\s+/g, " ").trim();
|
|
102
|
+
}
|
|
103
|
+
/** A panel's closing link to the whole group: "All services", "View all decking". */
|
|
104
|
+
const HUB_LINK_TEXT = /^(view |see |browse )?all\b/i;
|
|
105
|
+
/**
|
|
106
|
+
* The hub of a dropdown whose label has no page, from its panel: the first link that is the parent path of the
|
|
107
|
+
* others, else the first link worded as the whole group ("All gardening services").
|
|
108
|
+
*/
|
|
109
|
+
function guessHub(links) {
|
|
110
|
+
return (links.find((link) => link.href.startsWith("/") && links.some((other) => other !== link && other.href.startsWith(`${link.href.replace(/\/+$/, "")}/`))) ??
|
|
111
|
+
links.find((link) => link.href.startsWith("/") && HUB_LINK_TEXT.test(link.label)));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The top-level entries, in order. Each entry has exactly one label: a link with text, or a button with text that
|
|
115
|
+
* opens a dropdown, outside any panel. An entry is the widest element that holds that one label and no other.
|
|
116
|
+
*/
|
|
117
|
+
function topLevelEntries(scan) {
|
|
118
|
+
const { nav, inPanel } = scan;
|
|
119
|
+
const labels = descendants(nav).filter((element) => {
|
|
120
|
+
if (inPanel(element))
|
|
121
|
+
return false;
|
|
122
|
+
const text = cleanLabel(textOf(element));
|
|
123
|
+
if (text === "")
|
|
124
|
+
return false;
|
|
125
|
+
if (element.tag === "a")
|
|
126
|
+
return "href" in element.attrs;
|
|
127
|
+
// A chevron drawn as text (v, +) is not a label; a button needs a word, and the menu button is not a dropdown.
|
|
128
|
+
return isDisclosure(element) && /[a-z]{2,}/i.test(text) && !MENU_BUTTON_TEXT.test(text);
|
|
129
|
+
});
|
|
130
|
+
const within = (container) => labels.filter((label) => label === container || isInside(label, container)).length;
|
|
131
|
+
return labels.map((label) => {
|
|
132
|
+
let entry = label;
|
|
133
|
+
while (entry.parent && entry.parent !== nav && within(entry.parent) === 1)
|
|
134
|
+
entry = entry.parent;
|
|
135
|
+
return { entry, label };
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function readEntry(scan, entry, label, baseUrl) {
|
|
139
|
+
if ("hidden" in entry.attrs || entry.attrs["aria-hidden"] === "true")
|
|
140
|
+
return undefined;
|
|
141
|
+
const { nav, panels, inPanel } = scan;
|
|
142
|
+
const inside = [entry, ...descendants(entry)];
|
|
143
|
+
const toggles = inside.filter((element) => isDisclosure(element) && !inPanel(element));
|
|
144
|
+
const controlled = toggles
|
|
145
|
+
.map((toggle) => toggle.attrs["aria-controls"])
|
|
146
|
+
.map((id) => (id ? descendants(nav).find((element) => element.attrs.id === id) : undefined))
|
|
147
|
+
.find((target) => target !== undefined && panels.has(target));
|
|
148
|
+
const panel = controlled ?? inside.find((element) => panels.has(element) && !hasAncestor(element, (ancestor) => panels.has(ancestor)));
|
|
149
|
+
const panelAnchors = panel ? [panel, ...descendants(panel)].filter((element) => element.tag === "a" && "href" in element.attrs) : [];
|
|
150
|
+
const labelIsAnchor = label.tag === "a";
|
|
151
|
+
const labelHref = labelIsAnchor ? sitePath(label.attrs.href, baseUrl) : "";
|
|
152
|
+
const text = cleanLabel(textOf(label));
|
|
153
|
+
if (text === "" || isAction(entry, text, labelHref))
|
|
154
|
+
return undefined;
|
|
155
|
+
const opens = !labelIsAnchor || "aria-haspopup" in label.attrs || "aria-expanded" in label.attrs || toggles.some((toggle) => toggle !== label);
|
|
156
|
+
if (panelAnchors.length === 0 && !opens)
|
|
157
|
+
return { kind: "link", id: slugOf(text), label: text, href: labelHref };
|
|
158
|
+
const panelLinks = panelAnchors
|
|
159
|
+
.map((anchor) => ({ label: cleanLabel(textOf(anchor)), href: sitePath(anchor.attrs.href, baseUrl) }))
|
|
160
|
+
.filter((link) => link.href !== "");
|
|
161
|
+
const hub = labelHref !== "" ? labelHref : guessHub(panelLinks)?.href ?? "";
|
|
162
|
+
const hubKey = hub.replace(/\/+$/, "").toLowerCase();
|
|
163
|
+
const hubLink = hub === "" ? undefined : panelLinks.find((link) => link.href.replace(/\/+$/, "").toLowerCase() === hubKey);
|
|
164
|
+
return {
|
|
165
|
+
kind: "menu",
|
|
166
|
+
id: slugOf(text),
|
|
167
|
+
label: text,
|
|
168
|
+
href: hub,
|
|
169
|
+
hubLabel: hubLink?.label ?? "",
|
|
170
|
+
panel: "list",
|
|
171
|
+
links: panelLinks.filter((link) => link !== hubLink && link.href.replace(/\/+$/, "").toLowerCase() !== hubKey),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** The main navigation of a document: its element and its top-level items in order. Undefined when the page has none. */
|
|
175
|
+
export function extractMainNavigation(html, baseUrl) {
|
|
176
|
+
const root = parseHtml(html);
|
|
177
|
+
const nav = findMainNav(root);
|
|
178
|
+
if (!nav)
|
|
179
|
+
return undefined;
|
|
180
|
+
const panels = findPanels(nav);
|
|
181
|
+
const scan = { nav, panels, inPanel: (element) => panels.has(element) || hasAncestor(element, (ancestor) => panels.has(ancestor)) };
|
|
182
|
+
const items = topLevelEntries(scan)
|
|
183
|
+
.map(({ entry, label }) => readEntry(scan, entry, label, baseUrl))
|
|
184
|
+
.filter((item) => item !== undefined);
|
|
185
|
+
let header = root;
|
|
186
|
+
for (let node = nav.parent; node; node = node.parent) {
|
|
187
|
+
if (node.tag === "header" || node.attrs.role === "banner")
|
|
188
|
+
header = node;
|
|
189
|
+
}
|
|
190
|
+
return { root, nav, header, items };
|
|
191
|
+
}
|
|
192
|
+
/** Whether the header holds a phone link and a quote button, outside any drawer or dialog. */
|
|
193
|
+
export function headerActions(header) {
|
|
194
|
+
const live = descendants(header).filter((element) => !hasAncestor(element, (ancestor) => ancestor.tag === "dialog" || ancestor.attrs["aria-modal"] === "true"));
|
|
195
|
+
const phone = live.some((element) => element.tag === "a" && /^tel:/i.test((element.attrs.href ?? "").trim()));
|
|
196
|
+
const quote = live.some((element) => {
|
|
197
|
+
if (element.attrs["data-bl"] === "quote-button" || "data-bl-cta" in element.attrs || "data-lead-cta" in element.attrs)
|
|
198
|
+
return true;
|
|
199
|
+
if (element.tag !== "a" && element.tag !== "button")
|
|
200
|
+
return false;
|
|
201
|
+
const text = textOf(element);
|
|
202
|
+
return /\bquote\b/i.test(text) || /^send photos?$/i.test(text);
|
|
203
|
+
});
|
|
204
|
+
return { phone, quote };
|
|
205
|
+
}
|
|
206
|
+
/** Every link on a page outside the given element, as same-site paths: the evidence of which pages exist. */
|
|
207
|
+
export function linkedPaths(root, baseUrl, except) {
|
|
208
|
+
const paths = new Set();
|
|
209
|
+
for (const element of descendants(root)) {
|
|
210
|
+
if (element.tag !== "a" || !("href" in element.attrs))
|
|
211
|
+
continue;
|
|
212
|
+
if (except && (element === except || isInside(element, except)))
|
|
213
|
+
continue;
|
|
214
|
+
const path = sitePath(element.attrs.href, baseUrl);
|
|
215
|
+
if (path.startsWith("/"))
|
|
216
|
+
paths.add(path);
|
|
217
|
+
}
|
|
218
|
+
return [...paths];
|
|
219
|
+
}
|
|
220
|
+
//# sourceMappingURL=extract.js.map
|