@brandlab-digital/conformance 3.7.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.
Files changed (290) hide show
  1. package/README.md +26 -0
  2. package/dist/current-release.js +5 -5
  3. package/dist/current-release.js.map +1 -1
  4. package/dist/engineering.js +4 -2
  5. package/dist/engineering.js.map +1 -1
  6. package/dist/public-copy.d.ts +7 -0
  7. package/dist/public-copy.d.ts.map +1 -1
  8. package/dist/public-copy.js +8 -3
  9. package/dist/public-copy.js.map +1 -1
  10. package/dist/ready-cli.d.ts +38 -0
  11. package/dist/ready-cli.d.ts.map +1 -0
  12. package/dist/ready-cli.js +327 -0
  13. package/dist/ready-cli.js.map +1 -0
  14. package/dist/site-system/checks.d.ts +389 -0
  15. package/dist/site-system/checks.d.ts.map +1 -0
  16. package/dist/site-system/checks.js +96 -0
  17. package/dist/site-system/checks.js.map +1 -0
  18. package/dist/site-system/config.d.ts +40 -0
  19. package/dist/site-system/config.d.ts.map +1 -0
  20. package/dist/site-system/config.js +214 -0
  21. package/dist/site-system/config.js.map +1 -0
  22. package/dist/site-system/index.d.ts +14 -0
  23. package/dist/site-system/index.d.ts.map +1 -0
  24. package/dist/site-system/index.js +14 -0
  25. package/dist/site-system/index.js.map +1 -0
  26. package/dist/site-system/journeys/access.d.ts +7 -0
  27. package/dist/site-system/journeys/access.d.ts.map +1 -0
  28. package/dist/site-system/journeys/access.js +263 -0
  29. package/dist/site-system/journeys/access.js.map +1 -0
  30. package/dist/site-system/journeys/browser.d.ts +79 -0
  31. package/dist/site-system/journeys/browser.d.ts.map +1 -0
  32. package/dist/site-system/journeys/browser.js +218 -0
  33. package/dist/site-system/journeys/browser.js.map +1 -0
  34. package/dist/site-system/journeys/conversion.d.ts +3 -0
  35. package/dist/site-system/journeys/conversion.d.ts.map +1 -0
  36. package/dist/site-system/journeys/conversion.js +152 -0
  37. package/dist/site-system/journeys/conversion.js.map +1 -0
  38. package/dist/site-system/journeys/menu.d.ts +11 -0
  39. package/dist/site-system/journeys/menu.d.ts.map +1 -0
  40. package/dist/site-system/journeys/menu.js +395 -0
  41. package/dist/site-system/journeys/menu.js.map +1 -0
  42. package/dist/site-system/journeys/page-helpers.d.ts +146 -0
  43. package/dist/site-system/journeys/page-helpers.d.ts.map +1 -0
  44. package/dist/site-system/journeys/page-helpers.js +426 -0
  45. package/dist/site-system/journeys/page-helpers.js.map +1 -0
  46. package/dist/site-system/journeys/plan.d.ts +82 -0
  47. package/dist/site-system/journeys/plan.d.ts.map +1 -0
  48. package/dist/site-system/journeys/plan.js +112 -0
  49. package/dist/site-system/journeys/plan.js.map +1 -0
  50. package/dist/site-system/journeys/shell.d.ts +24 -0
  51. package/dist/site-system/journeys/shell.d.ts.map +1 -0
  52. package/dist/site-system/journeys/shell.js +269 -0
  53. package/dist/site-system/journeys/shell.js.map +1 -0
  54. package/dist/site-system/playwright-report.d.ts +22 -0
  55. package/dist/site-system/playwright-report.d.ts.map +1 -0
  56. package/dist/site-system/playwright-report.js +131 -0
  57. package/dist/site-system/playwright-report.js.map +1 -0
  58. package/dist/site-system/records.d.ts +30 -0
  59. package/dist/site-system/records.d.ts.map +1 -0
  60. package/dist/site-system/records.js +98 -0
  61. package/dist/site-system/records.js.map +1 -0
  62. package/dist/site-system/report.d.ts +116 -0
  63. package/dist/site-system/report.d.ts.map +1 -0
  64. package/dist/site-system/report.js +366 -0
  65. package/dist/site-system/report.js.map +1 -0
  66. package/dist/site-system/rule-modules.d.ts +8 -0
  67. package/dist/site-system/rule-modules.d.ts.map +1 -0
  68. package/dist/site-system/rule-modules.js +33 -0
  69. package/dist/site-system/rule-modules.js.map +1 -0
  70. package/dist/site-system/rules/design/d4-distinctness.d.ts +36 -0
  71. package/dist/site-system/rules/design/d4-distinctness.d.ts.map +1 -0
  72. package/dist/site-system/rules/design/d4-distinctness.js +207 -0
  73. package/dist/site-system/rules/design/d4-distinctness.js.map +1 -0
  74. package/dist/site-system/rules/design/index.d.ts +17 -0
  75. package/dist/site-system/rules/design/index.d.ts.map +1 -0
  76. package/dist/site-system/rules/design/index.js +7 -0
  77. package/dist/site-system/rules/design/index.js.map +1 -0
  78. package/dist/site-system/rules/enquiry/c2-quote-form.d.ts +31 -0
  79. package/dist/site-system/rules/enquiry/c2-quote-form.d.ts.map +1 -0
  80. package/dist/site-system/rules/enquiry/c2-quote-form.js +211 -0
  81. package/dist/site-system/rules/enquiry/c2-quote-form.js.map +1 -0
  82. package/dist/site-system/rules/enquiry/common.d.ts +37 -0
  83. package/dist/site-system/rules/enquiry/common.d.ts.map +1 -0
  84. package/dist/site-system/rules/enquiry/common.js +59 -0
  85. package/dist/site-system/rules/enquiry/common.js.map +1 -0
  86. package/dist/site-system/rules/enquiry/e1-recorded-titles.d.ts +8 -0
  87. package/dist/site-system/rules/enquiry/e1-recorded-titles.d.ts.map +1 -0
  88. package/dist/site-system/rules/enquiry/e1-recorded-titles.js +59 -0
  89. package/dist/site-system/rules/enquiry/e1-recorded-titles.js.map +1 -0
  90. package/dist/site-system/rules/enquiry/e9-cost-guides.d.ts +22 -0
  91. package/dist/site-system/rules/enquiry/e9-cost-guides.d.ts.map +1 -0
  92. package/dist/site-system/rules/enquiry/e9-cost-guides.js +55 -0
  93. package/dist/site-system/rules/enquiry/e9-cost-guides.js.map +1 -0
  94. package/dist/site-system/rules/enquiry/html.d.ts +53 -0
  95. package/dist/site-system/rules/enquiry/html.d.ts.map +1 -0
  96. package/dist/site-system/rules/enquiry/html.js +294 -0
  97. package/dist/site-system/rules/enquiry/html.js.map +1 -0
  98. package/dist/site-system/rules/enquiry/index.d.ts +17 -0
  99. package/dist/site-system/rules/enquiry/index.d.ts.map +1 -0
  100. package/dist/site-system/rules/enquiry/index.js +26 -0
  101. package/dist/site-system/rules/enquiry/index.js.map +1 -0
  102. package/dist/site-system/rules/enquiry/k8-sections.d.ts +17 -0
  103. package/dist/site-system/rules/enquiry/k8-sections.d.ts.map +1 -0
  104. package/dist/site-system/rules/enquiry/k8-sections.js +76 -0
  105. package/dist/site-system/rules/enquiry/k8-sections.js.map +1 -0
  106. package/dist/site-system/rules/enquiry/literal.d.ts +29 -0
  107. package/dist/site-system/rules/enquiry/literal.d.ts.map +1 -0
  108. package/dist/site-system/rules/enquiry/literal.js +189 -0
  109. package/dist/site-system/rules/enquiry/literal.js.map +1 -0
  110. package/dist/site-system/rules/enquiry/t11-licence-classes.d.ts +73 -0
  111. package/dist/site-system/rules/enquiry/t11-licence-classes.d.ts.map +1 -0
  112. package/dist/site-system/rules/enquiry/t11-licence-classes.js +275 -0
  113. package/dist/site-system/rules/enquiry/t11-licence-classes.js.map +1 -0
  114. package/dist/site-system/rules/enquiry/t5-reviews.d.ts +23 -0
  115. package/dist/site-system/rules/enquiry/t5-reviews.d.ts.map +1 -0
  116. package/dist/site-system/rules/enquiry/t5-reviews.js +91 -0
  117. package/dist/site-system/rules/enquiry/t5-reviews.js.map +1 -0
  118. package/dist/site-system/rules/form/c11-contact-route.d.ts +5 -0
  119. package/dist/site-system/rules/form/c11-contact-route.d.ts.map +1 -0
  120. package/dist/site-system/rules/form/c11-contact-route.js +132 -0
  121. package/dist/site-system/rules/form/c11-contact-route.js.map +1 -0
  122. package/dist/site-system/rules/form/c7-thank-you.d.ts +20 -0
  123. package/dist/site-system/rules/form/c7-thank-you.d.ts.map +1 -0
  124. package/dist/site-system/rules/form/c7-thank-you.js +67 -0
  125. package/dist/site-system/rules/form/c7-thank-you.js.map +1 -0
  126. package/dist/site-system/rules/form/c8-forgiving-form.d.ts +19 -0
  127. package/dist/site-system/rules/form/c8-forgiving-form.d.ts.map +1 -0
  128. package/dist/site-system/rules/form/c8-forgiving-form.js +128 -0
  129. package/dist/site-system/rules/form/c8-forgiving-form.js.map +1 -0
  130. package/dist/site-system/rules/form/index.d.ts +14 -0
  131. package/dist/site-system/rules/form/index.d.ts.map +1 -0
  132. package/dist/site-system/rules/form/index.js +13 -0
  133. package/dist/site-system/rules/form/index.js.map +1 -0
  134. package/dist/site-system/rules/library/common.d.ts +36 -0
  135. package/dist/site-system/rules/library/common.d.ts.map +1 -0
  136. package/dist/site-system/rules/library/common.js +70 -0
  137. package/dist/site-system/rules/library/common.js.map +1 -0
  138. package/dist/site-system/rules/library/index.d.ts +16 -0
  139. package/dist/site-system/rules/library/index.d.ts.map +1 -0
  140. package/dist/site-system/rules/library/index.js +19 -0
  141. package/dist/site-system/rules/library/index.js.map +1 -0
  142. package/dist/site-system/rules/library/k1-shared-components.d.ts +29 -0
  143. package/dist/site-system/rules/library/k1-shared-components.d.ts.map +1 -0
  144. package/dist/site-system/rules/library/k1-shared-components.js +97 -0
  145. package/dist/site-system/rules/library/k1-shared-components.js.map +1 -0
  146. package/dist/site-system/rules/navigation/extract.d.ts +50 -0
  147. package/dist/site-system/rules/navigation/extract.d.ts.map +1 -0
  148. package/dist/site-system/rules/navigation/extract.js +220 -0
  149. package/dist/site-system/rules/navigation/extract.js.map +1 -0
  150. package/dist/site-system/rules/navigation/html.d.ts +23 -0
  151. package/dist/site-system/rules/navigation/html.d.ts.map +1 -0
  152. package/dist/site-system/rules/navigation/html.js +219 -0
  153. package/dist/site-system/rules/navigation/html.js.map +1 -0
  154. package/dist/site-system/rules/navigation/index.d.ts +37 -0
  155. package/dist/site-system/rules/navigation/index.d.ts.map +1 -0
  156. package/dist/site-system/rules/navigation/index.js +163 -0
  157. package/dist/site-system/rules/navigation/index.js.map +1 -0
  158. package/dist/site-system/rules/navigation/record.d.ts +8 -0
  159. package/dist/site-system/rules/navigation/record.d.ts.map +1 -0
  160. package/dist/site-system/rules/navigation/record.js +39 -0
  161. package/dist/site-system/rules/navigation/record.js.map +1 -0
  162. package/dist/site-system/rules/sections/exif.d.ts +22 -0
  163. package/dist/site-system/rules/sections/exif.d.ts.map +1 -0
  164. package/dist/site-system/rules/sections/exif.js +218 -0
  165. package/dist/site-system/rules/sections/exif.js.map +1 -0
  166. package/dist/site-system/rules/sections/images.d.ts +31 -0
  167. package/dist/site-system/rules/sections/images.d.ts.map +1 -0
  168. package/dist/site-system/rules/sections/images.js +96 -0
  169. package/dist/site-system/rules/sections/images.js.map +1 -0
  170. package/dist/site-system/rules/sections/index.d.ts +20 -0
  171. package/dist/site-system/rules/sections/index.d.ts.map +1 -0
  172. package/dist/site-system/rules/sections/index.js +34 -0
  173. package/dist/site-system/rules/sections/index.js.map +1 -0
  174. package/dist/site-system/rules/sections/k11-density.d.ts +23 -0
  175. package/dist/site-system/rules/sections/k11-density.d.ts.map +1 -0
  176. package/dist/site-system/rules/sections/k11-density.js +88 -0
  177. package/dist/site-system/rules/sections/k11-density.js.map +1 -0
  178. package/dist/site-system/rules/sections/k12-contact.d.ts +13 -0
  179. package/dist/site-system/rules/sections/k12-contact.d.ts.map +1 -0
  180. package/dist/site-system/rules/sections/k12-contact.js +70 -0
  181. package/dist/site-system/rules/sections/k12-contact.js.map +1 -0
  182. package/dist/site-system/rules/sections/k2-skin-coverage.d.ts +11 -0
  183. package/dist/site-system/rules/sections/k2-skin-coverage.d.ts.map +1 -0
  184. package/dist/site-system/rules/sections/k2-skin-coverage.js +126 -0
  185. package/dist/site-system/rules/sections/k2-skin-coverage.js.map +1 -0
  186. package/dist/site-system/rules/sections/n9-footer.d.ts +40 -0
  187. package/dist/site-system/rules/sections/n9-footer.d.ts.map +1 -0
  188. package/dist/site-system/rules/sections/n9-footer.js +186 -0
  189. package/dist/site-system/rules/sections/n9-footer.js.map +1 -0
  190. package/dist/site-system/rules/sections/t6-media-entries.d.ts +14 -0
  191. package/dist/site-system/rules/sections/t6-media-entries.d.ts.map +1 -0
  192. package/dist/site-system/rules/sections/t6-media-entries.js +96 -0
  193. package/dist/site-system/rules/sections/t6-media-entries.js.map +1 -0
  194. package/dist/site-system/rules/sections/t7-main-images.d.ts +13 -0
  195. package/dist/site-system/rules/sections/t7-main-images.d.ts.map +1 -0
  196. package/dist/site-system/rules/sections/t7-main-images.js +41 -0
  197. package/dist/site-system/rules/sections/t7-main-images.js.map +1 -0
  198. package/dist/site-system/rules/sections/t8-prices.d.ts +13 -0
  199. package/dist/site-system/rules/sections/t8-prices.d.ts.map +1 -0
  200. package/dist/site-system/rules/sections/t8-prices.js +92 -0
  201. package/dist/site-system/rules/sections/t8-prices.js.map +1 -0
  202. package/dist/site-system/rules/sections/t9-location-metadata.d.ts +3 -0
  203. package/dist/site-system/rules/sections/t9-location-metadata.d.ts.map +1 -0
  204. package/dist/site-system/rules/sections/t9-location-metadata.js +54 -0
  205. package/dist/site-system/rules/sections/t9-location-metadata.js.map +1 -0
  206. package/dist/site-system/rules/shell/banned-wording.d.ts +24 -0
  207. package/dist/site-system/rules/shell/banned-wording.d.ts.map +1 -0
  208. package/dist/site-system/rules/shell/banned-wording.js +212 -0
  209. package/dist/site-system/rules/shell/banned-wording.js.map +1 -0
  210. package/dist/site-system/rules/shell/budgets.d.ts +43 -0
  211. package/dist/site-system/rules/shell/budgets.d.ts.map +1 -0
  212. package/dist/site-system/rules/shell/budgets.js +297 -0
  213. package/dist/site-system/rules/shell/budgets.js.map +1 -0
  214. package/dist/site-system/rules/shell/cta-labels.d.ts +17 -0
  215. package/dist/site-system/rules/shell/cta-labels.d.ts.map +1 -0
  216. package/dist/site-system/rules/shell/cta-labels.js +86 -0
  217. package/dist/site-system/rules/shell/cta-labels.js.map +1 -0
  218. package/dist/site-system/rules/shell/facts.d.ts +33 -0
  219. package/dist/site-system/rules/shell/facts.d.ts.map +1 -0
  220. package/dist/site-system/rules/shell/facts.js +217 -0
  221. package/dist/site-system/rules/shell/facts.js.map +1 -0
  222. package/dist/site-system/rules/shell/headings.d.ts +5 -0
  223. package/dist/site-system/rules/shell/headings.d.ts.map +1 -0
  224. package/dist/site-system/rules/shell/headings.js +23 -0
  225. package/dist/site-system/rules/shell/headings.js.map +1 -0
  226. package/dist/site-system/rules/shell/hero-location.d.ts +4 -0
  227. package/dist/site-system/rules/shell/hero-location.d.ts.map +1 -0
  228. package/dist/site-system/rules/shell/hero-location.js +45 -0
  229. package/dist/site-system/rules/shell/hero-location.js.map +1 -0
  230. package/dist/site-system/rules/shell/html.d.ts +53 -0
  231. package/dist/site-system/rules/shell/html.d.ts.map +1 -0
  232. package/dist/site-system/rules/shell/html.js +341 -0
  233. package/dist/site-system/rules/shell/html.js.map +1 -0
  234. package/dist/site-system/rules/shell/index.d.ts +18 -0
  235. package/dist/site-system/rules/shell/index.d.ts.map +1 -0
  236. package/dist/site-system/rules/shell/index.js +65 -0
  237. package/dist/site-system/rules/shell/index.js.map +1 -0
  238. package/dist/site-system/rules/shell/licence.d.ts +4 -0
  239. package/dist/site-system/rules/shell/licence.d.ts.map +1 -0
  240. package/dist/site-system/rules/shell/licence.js +60 -0
  241. package/dist/site-system/rules/shell/licence.js.map +1 -0
  242. package/dist/site-system/rules/shell/location-wording.d.ts +13 -0
  243. package/dist/site-system/rules/shell/location-wording.d.ts.map +1 -0
  244. package/dist/site-system/rules/shell/location-wording.js +15 -0
  245. package/dist/site-system/rules/shell/location-wording.js.map +1 -0
  246. package/dist/site-system/rules/shell/navigation.d.ts +5 -0
  247. package/dist/site-system/rules/shell/navigation.d.ts.map +1 -0
  248. package/dist/site-system/rules/shell/navigation.js +272 -0
  249. package/dist/site-system/rules/shell/navigation.js.map +1 -0
  250. package/dist/site-system/rules/shell/overlays.d.ts +4 -0
  251. package/dist/site-system/rules/shell/overlays.d.ts.map +1 -0
  252. package/dist/site-system/rules/shell/overlays.js +93 -0
  253. package/dist/site-system/rules/shell/overlays.js.map +1 -0
  254. package/dist/site-system/rules/shell/page.d.ts +51 -0
  255. package/dist/site-system/rules/shell/page.d.ts.map +1 -0
  256. package/dist/site-system/rules/shell/page.js +256 -0
  257. package/dist/site-system/rules/shell/page.js.map +1 -0
  258. package/dist/site-system/rules/shell/phone.d.ts +13 -0
  259. package/dist/site-system/rules/shell/phone.d.ts.map +1 -0
  260. package/dist/site-system/rules/shell/phone.js +99 -0
  261. package/dist/site-system/rules/shell/phone.js.map +1 -0
  262. package/dist/site-system/rules/shell/proof-numbers.d.ts +7 -0
  263. package/dist/site-system/rules/shell/proof-numbers.d.ts.map +1 -0
  264. package/dist/site-system/rules/shell/proof-numbers.js +81 -0
  265. package/dist/site-system/rules/shell/proof-numbers.js.map +1 -0
  266. package/dist/site-system/rules/shell/types.d.ts +80 -0
  267. package/dist/site-system/rules/shell/types.d.ts.map +1 -0
  268. package/dist/site-system/rules/shell/types.js +17 -0
  269. package/dist/site-system/rules/shell/types.js.map +1 -0
  270. package/dist/site-system/runners.d.ts +37 -0
  271. package/dist/site-system/runners.d.ts.map +1 -0
  272. package/dist/site-system/runners.js +48 -0
  273. package/dist/site-system/runners.js.map +1 -0
  274. package/dist/site-system/samples.d.ts +43 -0
  275. package/dist/site-system/samples.d.ts.map +1 -0
  276. package/dist/site-system/samples.js +99 -0
  277. package/dist/site-system/samples.js.map +1 -0
  278. package/dist/site-system/steps.d.ts +66 -0
  279. package/dist/site-system/steps.d.ts.map +1 -0
  280. package/dist/site-system/steps.js +45 -0
  281. package/dist/site-system/steps.js.map +1 -0
  282. package/dist/site-system/streamed-html.d.ts +9 -0
  283. package/dist/site-system/streamed-html.d.ts.map +1 -0
  284. package/dist/site-system/streamed-html.js +43 -0
  285. package/dist/site-system/streamed-html.js.map +1 -0
  286. package/dist/site-system-e2e.d.ts +6 -0
  287. package/dist/site-system-e2e.d.ts.map +1 -0
  288. package/dist/site-system-e2e.js +54 -0
  289. package/dist/site-system-e2e.js.map +1 -0
  290. package/package.json +22 -5
@@ -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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extract.js","sourceRoot":"","sources":["../../../../src/site-system/rules/navigation/extract.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Reads the main navigation out of rendered HTML: the `<nav aria-label=\"Main\">` inside the `<header>`, its top-level\n * items in order, and for each dropdown the links of its panel. It reads structure, not class names, so it works on\n * 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\n * of the nav); its label link is the first link outside the panel; a dropdown is an entry with a disclosure button\n * or a panel (the element the button controls, a nested list, or an element marked as a panel).\n */\nimport { descendants, elementChildren, hasAncestor, isInside, parseHtml, textOf, type HtmlElement } from \"./html.js\";\n\nexport interface NavigationLinkLike {\n label: string;\n href: string;\n}\n\nexport interface NavigationLinkItemLike {\n kind: \"link\";\n id: string;\n label: string;\n href: string;\n}\n\nexport interface NavigationMenuItemLike {\n kind: \"menu\";\n id: string;\n label: string;\n href: string;\n hubLabel: string;\n panel: \"list\";\n links: NavigationLinkLike[];\n}\n\nexport type NavigationItemLike = NavigationLinkItemLike | NavigationMenuItemLike;\n\nexport interface ExtractedNavigation {\n readonly root: HtmlElement;\n readonly nav: HtmlElement;\n /** The header that holds the nav, or the whole document when the nav stands on its own. */\n readonly header: HtmlElement;\n readonly items: NavigationItemLike[];\n}\n\nexport function slugOf(text: string): string {\n return text.toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/(^-|-$)/g, \"\");\n}\n\nfunction isNavElement(element: HtmlElement): boolean {\n return element.tag === \"nav\" || element.attrs.role === \"navigation\";\n}\n\n/** The main navigation: the first `<nav aria-label=\"Main\">` inside the `<header>`, outside any dialog or drawer. */\nexport function findMainNav(root: HtmlElement): HtmlElement | undefined {\n const all = descendants(root);\n const headers = all.filter((element) => element.tag === \"header\" || element.attrs.role === \"banner\");\n const inDialog = (element: HtmlElement) => hasAncestor(element, (ancestor) => ancestor.tag === \"dialog\" || ancestor.attrs[\"aria-modal\"] === \"true\");\n return all.find(\n (element) =>\n isNavElement(element) &&\n (element.attrs[\"aria-label\"] ?? \"\").trim() === \"Main\" &&\n headers.some((header) => isInside(element, header)) &&\n !inDialog(element),\n );\n}\n\nfunction sameSite(left: string, right: string): boolean {\n return left.replace(/^www\\./i, \"\").toLowerCase() === right.replace(/^www\\./i, \"\").toLowerCase();\n}\n\n/** 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. */\nexport function sitePath(href: string | undefined, baseUrl: string): string {\n const raw = (href ?? \"\").trim();\n if (raw === \"\" || raw.startsWith(\"#\") || /^javascript:/i.test(raw)) return \"\";\n if (/^(tel|mailto|sms|whatsapp):/i.test(raw)) return raw;\n try {\n const base = new URL(baseUrl || \"https://site.invalid\");\n const url = new URL(raw, base);\n return sameSite(url.hostname, base.hostname) ? url.pathname : url.href;\n } catch {\n return raw;\n }\n}\n\n/** A `<summary>` always opens something; a button does when it says so. */\nfunction isDisclosure(element: HtmlElement): boolean {\n if (element.tag === \"summary\") return true;\n const control = element.tag === \"button\" || element.attrs.role === \"button\";\n return control && (\"aria-expanded\" in element.attrs || \"aria-haspopup\" in element.attrs || \"aria-controls\" in element.attrs);\n}\n\n/** A button that opens the phone drawer or toggles the whole menu is not a dropdown of this menu. */\nconst MENU_BUTTON_TEXT = /^(open |close |toggle |show |hide )?(the )?(main |site |mobile )?(menu|navigation|nav)( menu)?$/i;\n\nconst PANEL_TAGS = new Set([\"div\", \"ul\", \"ol\", \"nav\", \"section\", \"aside\"]);\nconst LABELISH_TAGS = new Set([\"a\", \"button\", \"span\", \"summary\"]);\n\nfunction previousElement(element: HtmlElement): HtmlElement | undefined {\n const siblings = element.parent ? elementChildren(element.parent) : [];\n return siblings[siblings.indexOf(element) - 1];\n}\n\n/**\n * The panels inside a nav: what a disclosure button controls, anything marked as a panel, and a block that follows\n * a labelled link, button or span inside an item and holds links (`<a>Services</a><div>...links...</div>`).\n */\nfunction findPanels(nav: HtmlElement): Set<HtmlElement> {\n const all = descendants(nav);\n const panels = new Set<HtmlElement>();\n for (const toggle of all.filter(isDisclosure)) {\n const id = toggle.attrs[\"aria-controls\"];\n const target = id ? all.find((element) => element.attrs.id === id) : undefined;\n if (target) panels.add(target);\n }\n for (const element of all) {\n if (element.attrs[\"data-slot\"] === \"panel\" || element.attrs.role === \"menu\") panels.add(element);\n }\n for (const element of all) {\n if (!PANEL_TAGS.has(element.tag) || !element.parent || element.parent === nav) continue;\n const before = previousElement(element);\n if (!before || !LABELISH_TAGS.has(before.tag)) continue;\n const beforeText = cleanLabel(textOf(before));\n if (beforeText === \"\" || MENU_BUTTON_TEXT.test(beforeText)) continue;\n if (descendants(element).some((entry) => entry.tag === \"a\" && \"href\" in entry.attrs)) panels.add(element);\n }\n return panels;\n}\n\n/** Entries that are actions, not menu items: the phone, the email, the quote button. */\nfunction isAction(entry: HtmlElement, label: string, href: string): boolean {\n if (/^(tel|mailto|sms|whatsapp):/i.test(href)) return true;\n const marked = [entry, ...descendants(entry)].some(\n (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,\n );\n if (marked) return true;\n return /\\bquote\\b/i.test(label) || /^(call|phone|ring)\\b/i.test(label) || /^\\+?[\\d\\s()-]{8,}$/.test(label) || /^send photos?$/i.test(label);\n}\n\n/** Drops the chevron glyphs a bespoke header may put in the label text. */\nfunction cleanLabel(text: string): string {\n return text.replace(/[\\u25bc\\u25be\\u25bf\\u2304\\u02c5\\u2193\\u25b8\\u203a]+$/u, \"\").replace(/\\s+/g, \" \").trim();\n}\n\n/** A panel's closing link to the whole group: \"All services\", \"View all decking\". */\nconst HUB_LINK_TEXT = /^(view |see |browse )?all\\b/i;\n\n/**\n * The hub of a dropdown whose label has no page, from its panel: the first link that is the parent path of the\n * others, else the first link worded as the whole group (\"All gardening services\").\n */\nfunction guessHub(links: readonly NavigationLinkLike[]): NavigationLinkLike | undefined {\n return (\n links.find((link) => link.href.startsWith(\"/\") && links.some((other) => other !== link && other.href.startsWith(`${link.href.replace(/\\/+$/, \"\")}/`))) ??\n links.find((link) => link.href.startsWith(\"/\") && HUB_LINK_TEXT.test(link.label))\n );\n}\n\ninterface NavScan {\n readonly nav: HtmlElement;\n readonly panels: Set<HtmlElement>;\n readonly inPanel: (element: HtmlElement) => boolean;\n}\n\n/**\n * The top-level entries, in order. Each entry has exactly one label: a link with text, or a button with text that\n * opens a dropdown, outside any panel. An entry is the widest element that holds that one label and no other.\n */\nfunction topLevelEntries(scan: NavScan): { entry: HtmlElement; label: HtmlElement }[] {\n const { nav, inPanel } = scan;\n const labels = descendants(nav).filter((element) => {\n if (inPanel(element)) return false;\n const text = cleanLabel(textOf(element));\n if (text === \"\") return false;\n if (element.tag === \"a\") return \"href\" in element.attrs;\n // A chevron drawn as text (v, +) is not a label; a button needs a word, and the menu button is not a dropdown.\n return isDisclosure(element) && /[a-z]{2,}/i.test(text) && !MENU_BUTTON_TEXT.test(text);\n });\n const within = (container: HtmlElement) => labels.filter((label) => label === container || isInside(label, container)).length;\n return labels.map((label) => {\n let entry = label;\n while (entry.parent && entry.parent !== nav && within(entry.parent) === 1) entry = entry.parent;\n return { entry, label };\n });\n}\n\nfunction readEntry(scan: NavScan, entry: HtmlElement, label: HtmlElement, baseUrl: string): NavigationItemLike | undefined {\n if (\"hidden\" in entry.attrs || entry.attrs[\"aria-hidden\"] === \"true\") return undefined;\n const { nav, panels, inPanel } = scan;\n const inside = [entry, ...descendants(entry)];\n const toggles = inside.filter((element) => isDisclosure(element) && !inPanel(element));\n const controlled = toggles\n .map((toggle) => toggle.attrs[\"aria-controls\"])\n .map((id) => (id ? descendants(nav).find((element) => element.attrs.id === id) : undefined))\n .find((target): target is HtmlElement => target !== undefined && panels.has(target));\n const panel = controlled ?? inside.find((element) => panels.has(element) && !hasAncestor(element, (ancestor) => panels.has(ancestor)));\n const panelAnchors = panel ? [panel, ...descendants(panel)].filter((element) => element.tag === \"a\" && \"href\" in element.attrs) : [];\n const labelIsAnchor = label.tag === \"a\";\n const labelHref = labelIsAnchor ? sitePath(label.attrs.href, baseUrl) : \"\";\n const text = cleanLabel(textOf(label));\n if (text === \"\" || isAction(entry, text, labelHref)) return undefined;\n const opens = !labelIsAnchor || \"aria-haspopup\" in label.attrs || \"aria-expanded\" in label.attrs || toggles.some((toggle) => toggle !== label);\n if (panelAnchors.length === 0 && !opens) return { kind: \"link\", id: slugOf(text), label: text, href: labelHref };\n\n const panelLinks = panelAnchors\n .map((anchor) => ({ label: cleanLabel(textOf(anchor)), href: sitePath(anchor.attrs.href, baseUrl) }))\n .filter((link) => link.href !== \"\");\n const hub = labelHref !== \"\" ? labelHref : guessHub(panelLinks)?.href ?? \"\";\n const hubKey = hub.replace(/\\/+$/, \"\").toLowerCase();\n const hubLink = hub === \"\" ? undefined : panelLinks.find((link) => link.href.replace(/\\/+$/, \"\").toLowerCase() === hubKey);\n return {\n kind: \"menu\",\n id: slugOf(text),\n label: text,\n href: hub,\n hubLabel: hubLink?.label ?? \"\",\n panel: \"list\",\n links: panelLinks.filter((link) => link !== hubLink && link.href.replace(/\\/+$/, \"\").toLowerCase() !== hubKey),\n };\n}\n\n/** The main navigation of a document: its element and its top-level items in order. Undefined when the page has none. */\nexport function extractMainNavigation(html: string, baseUrl: string): ExtractedNavigation | undefined {\n const root = parseHtml(html);\n const nav = findMainNav(root);\n if (!nav) return undefined;\n const panels = findPanels(nav);\n const scan: NavScan = { nav, panels, inPanel: (element) => panels.has(element) || hasAncestor(element, (ancestor) => panels.has(ancestor)) };\n const items = topLevelEntries(scan)\n .map(({ entry, label }) => readEntry(scan, entry, label, baseUrl))\n .filter((item): item is NavigationItemLike => item !== undefined);\n let header: HtmlElement = root;\n for (let node = nav.parent; node; node = node.parent) {\n if (node.tag === \"header\" || node.attrs.role === \"banner\") header = node;\n }\n return { root, nav, header, items };\n}\n\n/** Whether the header holds a phone link and a quote button, outside any drawer or dialog. */\nexport function headerActions(header: HtmlElement): { phone: boolean; quote: boolean } {\n const live = descendants(header).filter((element) => !hasAncestor(element, (ancestor) => ancestor.tag === \"dialog\" || ancestor.attrs[\"aria-modal\"] === \"true\"));\n const phone = live.some((element) => element.tag === \"a\" && /^tel:/i.test((element.attrs.href ?? \"\").trim()));\n const quote = live.some((element) => {\n if (element.attrs[\"data-bl\"] === \"quote-button\" || \"data-bl-cta\" in element.attrs || \"data-lead-cta\" in element.attrs) return true;\n if (element.tag !== \"a\" && element.tag !== \"button\") return false;\n const text = textOf(element);\n return /\\bquote\\b/i.test(text) || /^send photos?$/i.test(text);\n });\n return { phone, quote };\n}\n\n/** Every link on a page outside the given element, as same-site paths: the evidence of which pages exist. */\nexport function linkedPaths(root: HtmlElement, baseUrl: string, except?: HtmlElement): string[] {\n const paths = new Set<string>();\n for (const element of descendants(root)) {\n if (element.tag !== \"a\" || !(\"href\" in element.attrs)) continue;\n if (except && (element === except || isInside(element, except))) continue;\n const path = sitePath(element.attrs.href, baseUrl);\n if (path.startsWith(\"/\")) paths.add(path);\n }\n return [...paths];\n}\n"]}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A small tolerant HTML reader for the rendered rules: it builds an element tree from server HTML so a rule can ask
3
+ * for "the nav in the header" and read links and text in order. It is not a validating parser; it understands
4
+ * comments, void elements, raw-text elements, quoted and bare attributes, and the implied end of a list item.
5
+ */
6
+ export interface HtmlElement {
7
+ readonly tag: string;
8
+ readonly attrs: Readonly<Record<string, string>>;
9
+ readonly children: HtmlChild[];
10
+ readonly parent: HtmlElement | null;
11
+ }
12
+ export type HtmlChild = HtmlElement | string;
13
+ export declare function decodeEntities(value: string): string;
14
+ /** Builds the element tree for a document or fragment. The returned root is a `#document` element. */
15
+ export declare function parseHtml(html: string): HtmlElement;
16
+ export declare function elementChildren(element: HtmlElement): HtmlElement[];
17
+ /** Every descendant element in document order. */
18
+ export declare function descendants(element: HtmlElement): HtmlElement[];
19
+ export declare function isInside(element: HtmlElement, ancestor: HtmlElement): boolean;
20
+ export declare function hasAncestor(element: HtmlElement, test: (ancestor: HtmlElement) => boolean): boolean;
21
+ /** The visible text of an element: no SVG, no screen-reader-only text, no hidden or aria-hidden parts. */
22
+ export declare function textOf(element: HtmlElement): string;
23
+ //# sourceMappingURL=html.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"html.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/navigation/html.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACjD,QAAQ,CAAC,QAAQ,EAAE,SAAS,EAAE,CAAC;IAC/B,QAAQ,CAAC,MAAM,EAAE,WAAW,GAAG,IAAI,CAAC;CACrC;AAED,MAAM,MAAM,SAAS,GAAG,WAAW,GAAG,MAAM,CAAC;AAwB7C,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQpD;AA8DD,sGAAsG;AACtG,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAgEnD;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,WAAW,GAAG,WAAW,EAAE,CAEnE;AAED,kDAAkD;AAClD,wBAAgB,WAAW,CAAC,OAAO,EAAE,WAAW,GAAG,WAAW,EAAE,CAW/D;AAED,wBAAgB,QAAQ,CAAC,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,WAAW,GAAG,OAAO,CAK7E;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,QAAQ,EAAE,WAAW,KAAK,OAAO,GAAG,OAAO,CAKnG;AAID,0GAA0G;AAC1G,wBAAgB,MAAM,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAoBnD"}