@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,13 @@
1
+ /**
2
+ * K12-contact: the contact page is the page header, then the contact block, and nothing else (Site System v2 §13.2).
3
+ *
4
+ * The page's `data-bl-section` order is exactly `page-header`, `contact`. A visitor who came to get in touch finds the
5
+ * form and the phone number, not a reviews band, an FAQ or a map beneath it. Content in the main area outside those two
6
+ * sections counts too. When the page carries the section manifest (`data-bl-manifest`), its rendered order must agree.
7
+ */
8
+ import type { SiteSystemFinding } from "../../checks.js";
9
+ import { type ParsedPage } from "../enquiry/common.js";
10
+ /** The sections the contact page holds, in order. The contact block's section id is `contact`. */
11
+ export declare const K12_CONTACT_ORDER: readonly ["page-header", "contact"];
12
+ export declare function checkK12Contact(pages: readonly ParsedPage[]): SiteSystemFinding[];
13
+ //# sourceMappingURL=k12-contact.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"k12-contact.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/sections/k12-contact.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAA0D,KAAK,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAI/G,kGAAkG;AAClG,eAAO,MAAM,iBAAiB,qCAAsC,CAAC;AA8BrE,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,GAAG,iBAAiB,EAAE,CAgCjF"}
@@ -0,0 +1,70 @@
1
+ import { fail, notApplicable, pass, sectionId, topLevelSections } from "../enquiry/common.js";
2
+ import { attr, descendants, normaliseText, textOf } from "../enquiry/html.js";
3
+ import { isContactPage, mainOf } from "../library/common.js";
4
+ /** The sections the contact page holds, in order. The contact block's section id is `contact`. */
5
+ export const K12_CONTACT_ORDER = ["page-header", "contact"];
6
+ const IGNORED = new Set(["script", "style", "template", "noscript", "link", "meta"]);
7
+ const CONTENT = new Set(["img", "picture", "video", "iframe", "form", "table", "canvas"]);
8
+ /**
9
+ * Content in the main area that is not inside one of the page's sections. A wrapper that only holds sections is looked
10
+ * through; a wrapper with content of its own beside them is stray content.
11
+ */
12
+ function strayContent(main, sections) {
13
+ const stray = [];
14
+ const visit = (node) => {
15
+ for (const child of node.children) {
16
+ if (child.type !== "element" || IGNORED.has(child.tag) || sections.includes(child) || attr(child, "hidden") !== undefined)
17
+ continue;
18
+ if (attr(child, "data-bl-manifest") !== undefined)
19
+ continue;
20
+ if (descendants(child).some((inside) => sections.includes(inside))) {
21
+ visit(child);
22
+ continue;
23
+ }
24
+ const holdsContent = normaliseText(textOf(child)) !== "" || CONTENT.has(child.tag) || descendants(child).some((inside) => CONTENT.has(inside.tag));
25
+ if (holdsContent) {
26
+ const article = /^[aeiou]/.test(child.tag) ? "an" : "a";
27
+ stray.push(child.tag === "section" ? `${article} ${child.tag} outside the page's sections` : `${article} ${child.tag} with content outside the page's sections`);
28
+ }
29
+ }
30
+ };
31
+ visit(main);
32
+ return stray;
33
+ }
34
+ export function checkK12Contact(pages) {
35
+ const contact = pages.filter(isContactPage);
36
+ if (contact.length === 0)
37
+ return [notApplicable("K12-contact", "None of the sampled pages is the contact page.")];
38
+ const failures = [];
39
+ for (const page of contact) {
40
+ const sections = topLevelSections(page.root);
41
+ const order = sections.map(sectionId);
42
+ if (order.length === 0) {
43
+ failures.push(fail("K12-contact", page.path, `${page.path} has no page header and contact block sections; the contact page is ${K12_CONTACT_ORDER.join(" then ")} and nothing else.`));
44
+ continue;
45
+ }
46
+ if (order.join() !== K12_CONTACT_ORDER.join()) {
47
+ failures.push(fail("K12-contact", page.path, `${page.path} runs ${order.join(", ")}; the contact page is ${K12_CONTACT_ORDER.join(" then ")} and nothing else.`));
48
+ }
49
+ for (const stray of strayContent(mainOf(page.root), sections)) {
50
+ failures.push(fail("K12-contact", page.path, `${page.path} has ${stray}; the contact page is ${K12_CONTACT_ORDER.join(" then ")} and nothing else.`));
51
+ }
52
+ const manifest = descendants(page.root).find((element) => attr(element, "data-bl-manifest") !== undefined);
53
+ if (manifest) {
54
+ try {
55
+ const body = JSON.parse(manifest.children.map((child) => (child.type === "text" ? child.text : "")).join(""));
56
+ const rendered = (body.rendered ?? []).map((item) => item.id).filter((id) => typeof id === "string");
57
+ if (rendered.length > 0 && rendered.join() !== K12_CONTACT_ORDER.join()) {
58
+ failures.push(fail("K12-contact", page.path, `The section manifest of ${page.path} lists ${rendered.join(", ")}; the contact page is ${K12_CONTACT_ORDER.join(" then ")} and nothing else.`));
59
+ }
60
+ }
61
+ catch {
62
+ // a manifest that is not JSON is the sequencer's problem, not this rule's
63
+ }
64
+ }
65
+ }
66
+ if (failures.length > 0)
67
+ return failures;
68
+ return [pass("K12-contact", `Checked ${contact.length === 1 ? "the contact page" : `${contact.length} contact pages`}: ${K12_CONTACT_ORDER.join(" then ")} and nothing else.`)];
69
+ }
70
+ //# sourceMappingURL=k12-contact.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"k12-contact.js","sourceRoot":"","sources":["../../../../src/site-system/rules/sections/k12-contact.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,SAAS,EAAE,gBAAgB,EAAmB,MAAM,sBAAsB,CAAC;AAC/G,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,EAAoB,MAAM,oBAAoB,CAAC;AAChG,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAE7D,kGAAkG;AAClG,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,aAAa,EAAE,SAAS,CAAU,CAAC;AAErE,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,CAAC,QAAQ,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AACrF,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC;AAE1F;;;GAGG;AACH,SAAS,YAAY,CAAC,IAAiB,EAAE,QAAgC;IACvE,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,MAAM,KAAK,GAAG,CAAC,IAAiB,EAAE,EAAE;QAClC,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;YAClC,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,IAAI,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,KAAK,EAAE,QAAQ,CAAC,KAAK,SAAS;gBAAE,SAAS;YACpI,IAAI,IAAI,CAAC,KAAK,EAAE,kBAAkB,CAAC,KAAK,SAAS;gBAAE,SAAS;YAC5D,IAAI,WAAW,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC;gBACnE,KAAK,CAAC,KAAK,CAAC,CAAC;gBACb,SAAS;YACX,CAAC;YACD,MAAM,YAAY,GAAG,aAAa,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,IAAI,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,WAAW,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;YACnJ,IAAI,YAAY,EAAE,CAAC;gBACjB,MAAM,OAAO,GAAG,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC;gBACxD,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,IAAI,KAAK,CAAC,GAAG,8BAA8B,CAAC,CAAC,CAAC,GAAG,OAAO,IAAI,KAAK,CAAC,GAAG,2CAA2C,CAAC,CAAC;YACnK,CAAC;QACH,CAAC;IACH,CAAC,CAAC;IACF,KAAK,CAAC,IAAI,CAAC,CAAC;IACZ,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,KAA4B;IAC1D,MAAM,OAAO,GAAG,KAAK,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;IAC5C,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,aAAa,CAAC,aAAa,EAAE,gDAAgD,CAAC,CAAC,CAAC;IAClH,MAAM,QAAQ,GAAwB,EAAE,CAAC;IACzC,KAAK,MAAM,IAAI,IAAI,OAAO,EAAE,CAAC;QAC3B,MAAM,QAAQ,GAAG,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7C,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QACtC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,IAAI,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,IAAI,uEAAuE,iBAAiB,CAAC,IAAI,CAAC,QAAQ,CAAC,oBAAoB,CAAC,CAAC,CAAC;YACvL,SAAS;QACX,CAAC;QACD,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,iBAAiB,CAAC,IAAI,EAAE,EAAE,CAAC;YAC9C,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,IAAI,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,IAAI,SAAS,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,yBAAyB,iBAAiB,CAAC,IAAI,CAAC,QAAQ,CAAC,oBAAoB,CAAC,CAAC,CAAC;QACpK,CAAC;QACD,KAAK,MAAM,KAAK,IAAI,YAAY,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,EAAE,CAAC;YAC9D,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,IAAI,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,IAAI,QAAQ,KAAK,yBAAyB,iBAAiB,CAAC,IAAI,CAAC,QAAQ,CAAC,oBAAoB,CAAC,CAAC,CAAC;QACxJ,CAAC;QACD,MAAM,QAAQ,GAAG,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,kBAAkB,CAAC,KAAK,SAAS,CAAC,CAAC;QAC3G,IAAI,QAAQ,EAAE,CAAC;YACb,IAAI,CAAC;gBACH,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAqC,CAAC;gBAClJ,MAAM,QAAQ,GAAG,CAAC,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,EAAgB,EAAE,CAAC,OAAO,EAAE,KAAK,QAAQ,CAAC,CAAC;gBACnH,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,QAAQ,CAAC,IAAI,EAAE,KAAK,iBAAiB,CAAC,IAAI,EAAE,EAAE,CAAC;oBACxE,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,IAAI,CAAC,IAAI,EAAE,2BAA2B,IAAI,CAAC,IAAI,UAAU,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,yBAAyB,iBAAiB,CAAC,IAAI,CAAC,QAAQ,CAAC,oBAAoB,CAAC,CAAC,CAAC;gBAChM,CAAC;YACH,CAAC;YAAC,MAAM,CAAC;gBACP,0EAA0E;YAC5E,CAAC;QACH,CAAC;IACH,CAAC;IACD,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,QAAQ,CAAC;IACzC,OAAO,CAAC,IAAI,CAAC,aAAa,EAAE,WAAW,OAAO,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,gBAAgB,KAAK,iBAAiB,CAAC,IAAI,CAAC,QAAQ,CAAC,oBAAoB,CAAC,CAAC,CAAC;AAClL,CAAC","sourcesContent":["/**\n * K12-contact: the contact page is the page header, then the contact block, and nothing else (Site System v2 §13.2).\n *\n * The page's `data-bl-section` order is exactly `page-header`, `contact`. A visitor who came to get in touch finds the\n * form and the phone number, not a reviews band, an FAQ or a map beneath it. Content in the main area outside those two\n * sections counts too. When the page carries the section manifest (`data-bl-manifest`), its rendered order must agree.\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { fail, notApplicable, pass, sectionId, topLevelSections, type ParsedPage } from \"../enquiry/common.js\";\nimport { attr, descendants, normaliseText, textOf, type HtmlElement } from \"../enquiry/html.js\";\nimport { isContactPage, mainOf } from \"../library/common.js\";\n\n/** The sections the contact page holds, in order. The contact block's section id is `contact`. */\nexport const K12_CONTACT_ORDER = [\"page-header\", \"contact\"] as const;\n\nconst IGNORED = new Set([\"script\", \"style\", \"template\", \"noscript\", \"link\", \"meta\"]);\nconst CONTENT = new Set([\"img\", \"picture\", \"video\", \"iframe\", \"form\", \"table\", \"canvas\"]);\n\n/**\n * Content in the main area that is not inside one of the page's sections. A wrapper that only holds sections is looked\n * through; a wrapper with content of its own beside them is stray content.\n */\nfunction strayContent(main: HtmlElement, sections: readonly HtmlElement[]): string[] {\n const stray: string[] = [];\n const visit = (node: HtmlElement) => {\n for (const child of node.children) {\n if (child.type !== \"element\" || IGNORED.has(child.tag) || sections.includes(child) || attr(child, \"hidden\") !== undefined) continue;\n if (attr(child, \"data-bl-manifest\") !== undefined) continue;\n if (descendants(child).some((inside) => sections.includes(inside))) {\n visit(child);\n continue;\n }\n const holdsContent = normaliseText(textOf(child)) !== \"\" || CONTENT.has(child.tag) || descendants(child).some((inside) => CONTENT.has(inside.tag));\n if (holdsContent) {\n const article = /^[aeiou]/.test(child.tag) ? \"an\" : \"a\";\n stray.push(child.tag === \"section\" ? `${article} ${child.tag} outside the page's sections` : `${article} ${child.tag} with content outside the page's sections`);\n }\n }\n };\n visit(main);\n return stray;\n}\n\nexport function checkK12Contact(pages: readonly ParsedPage[]): SiteSystemFinding[] {\n const contact = pages.filter(isContactPage);\n if (contact.length === 0) return [notApplicable(\"K12-contact\", \"None of the sampled pages is the contact page.\")];\n const failures: SiteSystemFinding[] = [];\n for (const page of contact) {\n const sections = topLevelSections(page.root);\n const order = sections.map(sectionId);\n if (order.length === 0) {\n failures.push(fail(\"K12-contact\", page.path, `${page.path} has no page header and contact block sections; the contact page is ${K12_CONTACT_ORDER.join(\" then \")} and nothing else.`));\n continue;\n }\n if (order.join() !== K12_CONTACT_ORDER.join()) {\n failures.push(fail(\"K12-contact\", page.path, `${page.path} runs ${order.join(\", \")}; the contact page is ${K12_CONTACT_ORDER.join(\" then \")} and nothing else.`));\n }\n for (const stray of strayContent(mainOf(page.root), sections)) {\n failures.push(fail(\"K12-contact\", page.path, `${page.path} has ${stray}; the contact page is ${K12_CONTACT_ORDER.join(\" then \")} and nothing else.`));\n }\n const manifest = descendants(page.root).find((element) => attr(element, \"data-bl-manifest\") !== undefined);\n if (manifest) {\n try {\n const body = JSON.parse(manifest.children.map((child) => (child.type === \"text\" ? child.text : \"\")).join(\"\")) as { rendered?: { id?: string }[] };\n const rendered = (body.rendered ?? []).map((item) => item.id).filter((id): id is string => typeof id === \"string\");\n if (rendered.length > 0 && rendered.join() !== K12_CONTACT_ORDER.join()) {\n failures.push(fail(\"K12-contact\", page.path, `The section manifest of ${page.path} lists ${rendered.join(\", \")}; the contact page is ${K12_CONTACT_ORDER.join(\" then \")} and nothing else.`));\n }\n } catch {\n // a manifest that is not JSON is the sequencer's problem, not this rule's\n }\n }\n }\n if (failures.length > 0) return failures;\n return [pass(\"K12-contact\", `Checked ${contact.length === 1 ? \"the contact page\" : `${contact.length} contact pages`}: ${K12_CONTACT_ORDER.join(\" then \")} and nothing else.`)];\n}\n"]}
@@ -0,0 +1,11 @@
1
+ import type { SiteSystemFinding } from "../../checks.js";
2
+ import { type ParsedPage } from "../enquiry/common.js";
3
+ /** `data-bl` names that have an entry in `BrandlabSkin` (ui-kit/brandlab-skin.ts). */
4
+ export declare const K2_SKINNED_HOOKS: readonly ["header", "drawer", "utility", "callbar", "back-to-top", "quote-button", "phone-link", "text-link", "button", "container", "quote-form", "photo-upload", "dialog", "field", "thank-you", "rating-chip", "footer", "accordion", "price-table"];
5
+ /** `data-bl-section` ids of the sections the library renders and skins. */
6
+ export declare const K2_SKINNED_SECTIONS: readonly ["reviews", "cta", "faqs", "price-guide", "proof-strip", "page-header", "contact"];
7
+ export declare const K2_REFERENCE_CLASS: RegExp;
8
+ /** Hook names the project's own stylesheets select: `[data-bl="x"]` and `[data-bl-section="x"]`. */
9
+ export declare function hooksStyledBySite(projectRoot: string): Set<string>;
10
+ export declare function checkK2(pages: readonly ParsedPage[], projectRoot: string): SiteSystemFinding[];
11
+ //# sourceMappingURL=k2-skin-coverage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"k2-skin-coverage.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/sections/k2-skin-coverage.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAAqC,KAAK,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAI1F,sFAAsF;AACtF,eAAO,MAAM,gBAAgB,yPAGnB,CAAC;AAEX,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB,6FAA8F,CAAC;AAE/H,eAAO,MAAM,kBAAkB,QAA8B,CAAC;AAqB9D,oGAAoG;AACpG,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CA0BlE;AAED,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,EAAE,WAAW,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAqC9F"}
@@ -0,0 +1,126 @@
1
+ /**
2
+ * K2: skin coverage (Site System v2 §13.2, §8.4). Every shared component a page renders has a skin entry, so a site
3
+ * never ships an unstyled look-alike, and no reference-skin class (the test skins the library's own tests use) ships.
4
+ *
5
+ * What the HTML can show, defined exactly:
6
+ * - A component is a rendered element with `data-bl="<name>"` or `data-bl-section="<id>"`, for the names the library
7
+ * skins (SKINNED_HOOKS and SKINNED_SECTIONS below). Hooks without a skin entry (the skip link, the honeypot, a star
8
+ * row) and the site's own compositions (the hero) are not components in this sense.
9
+ * - The component is covered when, on at least one sampled page, its root element or one of its slot elements (an
10
+ * element with `data-slot` inside it, outside any component nested in it) carries a class, because a skin gives a
11
+ * component its look through `classes`; or when the project's own stylesheets under `src/` or `app/` select the
12
+ * component by its hook (`[data-bl="footer"]`, `[data-bl-section="faqs"]`), which is how a site styles slots in plain CSS.
13
+ * - A class token starting `ref-heavy-`, `ref-calm-` or `ref-pool-` on any element is a reference-skin class and fails.
14
+ */
15
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { fail, notApplicable, pass, plural } from "../enquiry/common.js";
18
+ import { attr, descendants } from "../enquiry/html.js";
19
+ import { PageProblems } from "../library/common.js";
20
+ /** `data-bl` names that have an entry in `BrandlabSkin` (ui-kit/brandlab-skin.ts). */
21
+ export const K2_SKINNED_HOOKS = [
22
+ "header", "drawer", "utility", "callbar", "back-to-top", "quote-button", "phone-link", "text-link", "button", "container",
23
+ "quote-form", "photo-upload", "dialog", "field", "thank-you", "rating-chip", "footer", "accordion", "price-table",
24
+ ];
25
+ /** `data-bl-section` ids of the sections the library renders and skins. */
26
+ export const K2_SKINNED_SECTIONS = ["reviews", "cta", "faqs", "price-guide", "proof-strip", "page-header", "contact"];
27
+ export const K2_REFERENCE_CLASS = /^ref-(?:heavy|calm|pool)-/;
28
+ function classesOf(element) {
29
+ return (attr(element, "class") ?? "").split(/\s+/).filter(Boolean);
30
+ }
31
+ /** The root and slot elements of one component, leaving out components nested inside it. */
32
+ function skinSurface(root) {
33
+ const surface = [root];
34
+ const visit = (node) => {
35
+ for (const child of node.children) {
36
+ if (child.type !== "element")
37
+ continue;
38
+ if (attr(child, "data-bl") !== undefined || attr(child, "data-bl-section") !== undefined)
39
+ continue;
40
+ if (attr(child, "data-slot") !== undefined)
41
+ surface.push(child);
42
+ visit(child);
43
+ }
44
+ };
45
+ visit(root);
46
+ return surface;
47
+ }
48
+ /** Hook names the project's own stylesheets select: `[data-bl="x"]` and `[data-bl-section="x"]`. */
49
+ export function hooksStyledBySite(projectRoot) {
50
+ const styled = new Set();
51
+ const selector = /\[\s*data-bl(?:-section)?\s*=\s*["']?([a-z0-9-]+)["']?\s*\]/gi;
52
+ const visit = (directory, depth) => {
53
+ if (depth > 6)
54
+ return;
55
+ let entries;
56
+ try {
57
+ entries = readdirSync(directory, { withFileTypes: true });
58
+ }
59
+ catch {
60
+ return;
61
+ }
62
+ for (const entry of entries) {
63
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
64
+ continue;
65
+ const path = join(directory, entry.name);
66
+ if (entry.isDirectory())
67
+ visit(path, depth + 1);
68
+ else if (entry.isFile() && /\.(?:css|scss)$/i.test(entry.name)) {
69
+ try {
70
+ for (const match of readFileSync(path, "utf8").matchAll(selector))
71
+ styled.add(match[1].toLowerCase());
72
+ }
73
+ catch {
74
+ // an unreadable stylesheet styles nothing we can see
75
+ }
76
+ }
77
+ }
78
+ };
79
+ for (const folder of ["src", "app", "sites"])
80
+ if (existsSync(join(projectRoot, folder)))
81
+ visit(join(projectRoot, folder), 0);
82
+ return styled;
83
+ }
84
+ export function checkK2(pages, projectRoot) {
85
+ const hooks = new Set(K2_SKINNED_HOOKS);
86
+ const sections = new Set(K2_SKINNED_SECTIONS);
87
+ /** Component key ("footer", "section:faqs") to whether any instance carries a skin class, and the first page. */
88
+ const components = new Map();
89
+ const references = new PageProblems();
90
+ for (const page of pages) {
91
+ for (const element of descendants(page.root)) {
92
+ const reference = classesOf(element).find((token) => K2_REFERENCE_CLASS.test(token));
93
+ if (reference)
94
+ references.add(page.path, `A reference-skin class (${reference}) ships; those belong to the library's own tests`);
95
+ const hook = attr(element, "data-bl");
96
+ const section = attr(element, "data-bl-section");
97
+ const key = hook !== undefined && hooks.has(hook) ? `hook:${hook}` : section !== undefined && sections.has(section) ? `section:${section}` : null;
98
+ if (!key)
99
+ continue;
100
+ const label = hook !== undefined ? `data-bl="${hook}"` : `data-bl-section="${section}"`;
101
+ const carries = skinSurface(element).some((candidate) => classesOf(candidate).length > 0);
102
+ const known = components.get(key);
103
+ if (known)
104
+ known.covered ||= carries;
105
+ else
106
+ components.set(key, { covered: carries, page: page.path, label });
107
+ }
108
+ }
109
+ const failures = references.findings("K2");
110
+ if (components.size === 0 && failures.length === 0)
111
+ return [notApplicable("K2", "None of the sampled pages renders a shared component.")];
112
+ const uncovered = [...components].filter(([, component]) => !component.covered);
113
+ if (uncovered.length > 0) {
114
+ const styled = hooksStyledBySite(projectRoot);
115
+ for (const [key, component] of uncovered) {
116
+ const name = key.slice(key.indexOf(":") + 1);
117
+ if (styled.has(name))
118
+ continue;
119
+ failures.push(fail("K2", component.page, `${component.label} is rendered (first on ${component.page}) with no skin class on its root or slots and no selector for it in the site's stylesheets, so it has no skin entry.`));
120
+ }
121
+ }
122
+ if (failures.length > 0)
123
+ return failures;
124
+ return [pass("K2", `Checked ${plural(components.size, "shared component")} on ${plural(pages.length, "page")}: each carries a skin class or is styled by the site's own CSS, and no reference-skin class ships.`)];
125
+ }
126
+ //# sourceMappingURL=k2-skin-coverage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"k2-skin-coverage.js","sourceRoot":"","sources":["../../../../src/site-system/rules/sections/k2-skin-coverage.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * K2: skin coverage (Site System v2 §13.2, §8.4). Every shared component a page renders has a skin entry, so a site\n * never ships an unstyled look-alike, and no reference-skin class (the test skins the library's own tests use) ships.\n *\n * What the HTML can show, defined exactly:\n * - A component is a rendered element with `data-bl=\"<name>\"` or `data-bl-section=\"<id>\"`, for the names the library\n * skins (SKINNED_HOOKS and SKINNED_SECTIONS below). Hooks without a skin entry (the skip link, the honeypot, a star\n * row) and the site's own compositions (the hero) are not components in this sense.\n * - The component is covered when, on at least one sampled page, its root element or one of its slot elements (an\n * element with `data-slot` inside it, outside any component nested in it) carries a class, because a skin gives a\n * component its look through `classes`; or when the project's own stylesheets under `src/` or `app/` select the\n * component by its hook (`[data-bl=\"footer\"]`, `[data-bl-section=\"faqs\"]`), which is how a site styles slots in plain CSS.\n * - A class token starting `ref-heavy-`, `ref-calm-` or `ref-pool-` on any element is a reference-skin class and fails.\n */\nimport { existsSync, readdirSync, readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { fail, notApplicable, pass, plural, type ParsedPage } from \"../enquiry/common.js\";\nimport { attr, descendants, type HtmlElement } from \"../enquiry/html.js\";\nimport { PageProblems } from \"../library/common.js\";\n\n/** `data-bl` names that have an entry in `BrandlabSkin` (ui-kit/brandlab-skin.ts). */\nexport const K2_SKINNED_HOOKS = [\n \"header\", \"drawer\", \"utility\", \"callbar\", \"back-to-top\", \"quote-button\", \"phone-link\", \"text-link\", \"button\", \"container\",\n \"quote-form\", \"photo-upload\", \"dialog\", \"field\", \"thank-you\", \"rating-chip\", \"footer\", \"accordion\", \"price-table\",\n] as const;\n\n/** `data-bl-section` ids of the sections the library renders and skins. */\nexport const K2_SKINNED_SECTIONS = [\"reviews\", \"cta\", \"faqs\", \"price-guide\", \"proof-strip\", \"page-header\", \"contact\"] as const;\n\nexport const K2_REFERENCE_CLASS = /^ref-(?:heavy|calm|pool)-/;\n\nfunction classesOf(element: HtmlElement): string[] {\n return (attr(element, \"class\") ?? \"\").split(/\\s+/).filter(Boolean);\n}\n\n/** The root and slot elements of one component, leaving out components nested inside it. */\nfunction skinSurface(root: HtmlElement): HtmlElement[] {\n const surface = [root];\n const visit = (node: HtmlElement) => {\n for (const child of node.children) {\n if (child.type !== \"element\") continue;\n if (attr(child, \"data-bl\") !== undefined || attr(child, \"data-bl-section\") !== undefined) continue;\n if (attr(child, \"data-slot\") !== undefined) surface.push(child);\n visit(child);\n }\n };\n visit(root);\n return surface;\n}\n\n/** Hook names the project's own stylesheets select: `[data-bl=\"x\"]` and `[data-bl-section=\"x\"]`. */\nexport function hooksStyledBySite(projectRoot: string): Set<string> {\n const styled = new Set<string>();\n const selector = /\\[\\s*data-bl(?:-section)?\\s*=\\s*[\"']?([a-z0-9-]+)[\"']?\\s*\\]/gi;\n const visit = (directory: string, depth: number) => {\n if (depth > 6) return;\n let entries;\n try {\n entries = readdirSync(directory, { withFileTypes: true });\n } catch {\n return;\n }\n for (const entry of entries) {\n if (entry.name.startsWith(\".\") || entry.name === \"node_modules\") continue;\n const path = join(directory, entry.name);\n if (entry.isDirectory()) visit(path, depth + 1);\n else if (entry.isFile() && /\\.(?:css|scss)$/i.test(entry.name)) {\n try {\n for (const match of readFileSync(path, \"utf8\").matchAll(selector)) styled.add(match[1]!.toLowerCase());\n } catch {\n // an unreadable stylesheet styles nothing we can see\n }\n }\n }\n };\n for (const folder of [\"src\", \"app\", \"sites\"]) if (existsSync(join(projectRoot, folder))) visit(join(projectRoot, folder), 0);\n return styled;\n}\n\nexport function checkK2(pages: readonly ParsedPage[], projectRoot: string): SiteSystemFinding[] {\n const hooks = new Set<string>(K2_SKINNED_HOOKS);\n const sections = new Set<string>(K2_SKINNED_SECTIONS);\n /** Component key (\"footer\", \"section:faqs\") to whether any instance carries a skin class, and the first page. */\n const components = new Map<string, { covered: boolean; page: string; label: string }>();\n const references = new PageProblems();\n\n for (const page of pages) {\n for (const element of descendants(page.root)) {\n const reference = classesOf(element).find((token) => K2_REFERENCE_CLASS.test(token));\n if (reference) references.add(page.path, `A reference-skin class (${reference}) ships; those belong to the library's own tests`);\n\n const hook = attr(element, \"data-bl\");\n const section = attr(element, \"data-bl-section\");\n const key = hook !== undefined && hooks.has(hook) ? `hook:${hook}` : section !== undefined && sections.has(section) ? `section:${section}` : null;\n if (!key) continue;\n const label = hook !== undefined ? `data-bl=\"${hook}\"` : `data-bl-section=\"${section}\"`;\n const carries = skinSurface(element).some((candidate) => classesOf(candidate).length > 0);\n const known = components.get(key);\n if (known) known.covered ||= carries;\n else components.set(key, { covered: carries, page: page.path, label });\n }\n }\n\n const failures = references.findings(\"K2\");\n if (components.size === 0 && failures.length === 0) return [notApplicable(\"K2\", \"None of the sampled pages renders a shared component.\")];\n const uncovered = [...components].filter(([, component]) => !component.covered);\n if (uncovered.length > 0) {\n const styled = hooksStyledBySite(projectRoot);\n for (const [key, component] of uncovered) {\n const name = key.slice(key.indexOf(\":\") + 1);\n if (styled.has(name)) continue;\n failures.push(fail(\"K2\", component.page, `${component.label} is rendered (first on ${component.page}) with no skin class on its root or slots and no selector for it in the site's stylesheets, so it has no skin entry.`));\n }\n }\n if (failures.length > 0) return failures;\n return [pass(\"K2\", `Checked ${plural(components.size, \"shared component\")} on ${plural(pages.length, \"page\")}: each carries a skin class or is styled by the site's own CSS, and no reference-skin class ships.`)];\n}\n"]}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * N9: footer content (Site System v2 §13.2, v1 §6).
3
+ *
4
+ * - Column 1 holds the logo, a short description, the licence numbers (when the business holds licences), social and
5
+ * profile links, and the contact details (a `tel:` link; an email when public).
6
+ * - Then Services, Areas (regions or popular areas) and Company, each under a real heading (an H2 to H6 with text and
7
+ * links beneath it), in that order. A site with no areas to list may leave that column out: it is reported as a
8
+ * warning, not a failure, because the library lets a site leave out a column it has nothing true for.
9
+ * - The bottom bar holds © and the trading name, the ABN and the legal links (privacy and terms).
10
+ * - Never a call-to-action button, "based in", opening hours or notes about hours, or a guides column.
11
+ *
12
+ * Library footers are read by their slots (`brand`, `columns`, `column`, `bottom`); any other footer is read by its
13
+ * headings, so a hand-written footer is held to the same content.
14
+ */
15
+ import type { SiteSystemFinding } from "../../checks.js";
16
+ import { type ParsedPage } from "../enquiry/common.js";
17
+ import { type HtmlElement } from "../enquiry/html.js";
18
+ type ColumnKey = "services" | "areas" | "company" | "guides" | "other";
19
+ interface Column {
20
+ readonly key: ColumnKey;
21
+ readonly heading: string;
22
+ readonly links: number;
23
+ readonly element: HtmlElement;
24
+ }
25
+ /** The footer of a page: the library footer, else the page-level `<footer>`. */
26
+ export declare function footerOf(root: HtmlElement): HtmlElement | null;
27
+ /** The footer's columns, in order. A column is a heading and the links under it. */
28
+ export declare function footerColumns(footer: HtmlElement, bottom: HtmlElement | undefined): Column[];
29
+ export interface FooterFacts {
30
+ /** Licence identifiers the business holds; an empty list when none are recorded or the facts could not be read. */
31
+ readonly licences: readonly string[];
32
+ }
33
+ /** Every problem and warning with one footer, as clauses with no subject. */
34
+ export declare function footerProblems(footer: HtmlElement, facts: FooterFacts): {
35
+ problems: string[];
36
+ warnings: string[];
37
+ };
38
+ export declare function checkN9(pages: readonly ParsedPage[], projectRoot: string): SiteSystemFinding[];
39
+ export {};
40
+ //# sourceMappingURL=n9-footer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"n9-footer.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/sections/n9-footer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAAqC,KAAK,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAC1F,OAAO,EAAuD,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAoB3G,KAAK,SAAS,GAAG,UAAU,GAAG,OAAO,GAAG,SAAS,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEvE,UAAU,MAAM;IACd,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;CAC/B;AAED,gFAAgF;AAChF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,WAAW,GAAG,WAAW,GAAG,IAAI,CAO9D;AAeD,oFAAoF;AACpF,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,GAAG,SAAS,GAAG,MAAM,EAAE,CAgB5F;AAkBD,MAAM,WAAW,WAAW;IAC1B,mHAAmH;IACnH,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;CACtC;AAMD,6EAA6E;AAC7E,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,WAAW,GAAG;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CA+DlH;AAED,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,EAAE,WAAW,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAsB9F"}
@@ -0,0 +1,186 @@
1
+ import { fail, notApplicable, pass, plural } from "../enquiry/common.js";
2
+ import { ancestors, attr, descendants, normaliseText, textOf } from "../enquiry/html.js";
3
+ import { PageProblems } from "../library/common.js";
4
+ import { loadShellProjectFacts } from "../shell/facts.js";
5
+ const SOCIAL_HOSTS = /(?:^|\.)(?:facebook|instagram|linkedin|youtube|tiktok|x|twitter|pinterest|houzz|hipages|yelp|threads)\.com$|(?:^|\.)g\.page$|(?:^|\.)goo\.gl$|(?:^|\.)youtu\.be$/i;
6
+ const PROFILE_PATH = /^(?:\/maps|\/search|\/local)/i;
7
+ const HEADING = /^h[2-6]$/;
8
+ const AREAS_HEADING = /\b(?:areas?|regions?|suburbs?|locations?|councils?|where we (?:work|service|cover)|we (?:service|cover|work in)|coverage)\b/i;
9
+ const SERVICES_HEADING = /\b(?:services?|what we do|our work|trades?|solutions)\b/i;
10
+ const COMPANY_HEADING = /\b(?:company|about|information|business|our firm|our company)\b/i;
11
+ const GUIDES_HEADING = /\b(?:guides?|cost guides?|resources|articles|advice)\b/i;
12
+ const CTA_LABEL = /^\s*(?:get|request|book|schedule|claim|start)\b.*\b(?:quote|estimate|inspection|booking|now|online|today)\b|^\s*(?:free|instant)\s+(?:quote|estimate|inspection)|^\s*enquire\b|^\s*call\s+now\b/i;
13
+ const HOURS = [
14
+ /\b(?:opening|business|office|trading|operating)\s+hours\b/i,
15
+ /\bhours\s*[:–-]/i,
16
+ /\bmon(?:day)?\s*(?:-|–|to|through|thru)\s*(?:fri(?:day)?|sat(?:urday)?|sun(?:day)?)\b/i,
17
+ /\b\d{1,2}(?::\d{2})?\s*(?:am|pm)\s*(?:-|–|to)\s*\d{1,2}(?::\d{2})?\s*(?:am|pm)\b/i,
18
+ /\b24\s*\/\s*7\b|\bopen\s+(?:24\s+hours|7\s+days|seven\s+days)\b/i,
19
+ ];
20
+ /** The footer of a page: the library footer, else the page-level `<footer>`. */
21
+ export function footerOf(root) {
22
+ const all = descendants(root);
23
+ return (all.find((element) => attr(element, "data-bl") === "footer") ??
24
+ all.find((element) => element.tag === "footer" && !ancestors(element).some((parent) => ["article", "section", "aside", "main", "blockquote", "dialog"].includes(parent.tag))) ??
25
+ null);
26
+ }
27
+ function classify(heading, declared) {
28
+ if (declared === "services" || declared === "areas" || declared === "company" || declared === "guides")
29
+ return declared;
30
+ if (GUIDES_HEADING.test(heading))
31
+ return "guides";
32
+ if (AREAS_HEADING.test(heading))
33
+ return "areas";
34
+ if (SERVICES_HEADING.test(heading))
35
+ return "services";
36
+ if (COMPANY_HEADING.test(heading))
37
+ return "company";
38
+ return "other";
39
+ }
40
+ function anchors(element) {
41
+ return descendants(element).filter((child) => child.tag === "a" && (attr(child, "href") ?? "") !== "");
42
+ }
43
+ /** The footer's columns, in order. A column is a heading and the links under it. */
44
+ export function footerColumns(footer, bottom) {
45
+ const slotted = descendants(footer).filter((element) => attr(element, "data-slot") === "column");
46
+ if (slotted.length > 0) {
47
+ return slotted.map((element) => {
48
+ const heading = descendants(element).find((child) => HEADING.test(child.tag));
49
+ const text = heading ? textOf(heading, true, (child) => child.tag === "button") : "";
50
+ return { key: classify(text, attr(element, "data-column")), heading: text, links: anchors(element).length, element };
51
+ });
52
+ }
53
+ const headings = descendants(footer).filter((element) => HEADING.test(element.tag) && !(bottom && ancestors(element).includes(bottom)));
54
+ return headings.map((heading) => {
55
+ let scope = heading.parent ?? footer;
56
+ while (scope !== footer && descendants(scope).filter((element) => HEADING.test(element.tag)).length > 1)
57
+ scope = scope.parent ?? footer;
58
+ const text = textOf(heading, true);
59
+ return { key: classify(text, undefined), heading: text, links: anchors(scope).length, element: scope };
60
+ });
61
+ }
62
+ function hostOf(href) {
63
+ try {
64
+ const url = new URL(href);
65
+ return { host: url.hostname.toLowerCase(), path: url.pathname };
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ function isSocialOrProfile(link) {
72
+ const target = hostOf(attr(link, "href") ?? "");
73
+ if (!target)
74
+ return false;
75
+ if (SOCIAL_HOSTS.test(target.host))
76
+ return true;
77
+ return /(?:^|\.)google\.[a-z.]+$/.test(target.host) && PROFILE_PATH.test(target.path);
78
+ }
79
+ function compact(value) {
80
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
81
+ }
82
+ /** Every problem and warning with one footer, as clauses with no subject. */
83
+ export function footerProblems(footer, facts) {
84
+ const problems = [];
85
+ const warnings = [];
86
+ const text = textOf(footer, true);
87
+ const bottom = descendants(footer).find((element) => attr(element, "data-slot") === "bottom");
88
+ const columns = footerColumns(footer, bottom);
89
+ const brand = descendants(footer).find((element) => attr(element, "data-slot") === "brand");
90
+ const inBottom = (element) => bottom !== undefined && ancestors(element).includes(bottom);
91
+ // Column 1.
92
+ const logo = descendants(footer).some((element) => (element.tag === "img" || element.tag === "svg") && ancestors(element).some((parent) => parent.tag === "a") && !inBottom(element));
93
+ if (!logo)
94
+ problems.push("has no logo linking home in column 1");
95
+ const describes = brand
96
+ ? descendants(brand).some((element) => attr(element, "data-slot") === "description" && textOf(element).split(/\s+/).length >= 6)
97
+ : descendants(footer).some((element) => element.tag === "p" && textOf(element).split(/\s+/).length >= 12 && !inBottom(element) && !columns.some((column) => ancestors(element).includes(column.element)));
98
+ if (!describes)
99
+ problems.push("has no short description of the business in column 1");
100
+ const footerCompact = compact(text);
101
+ const missingLicences = facts.licences.filter((licence) => !footerCompact.includes(compact(licence)));
102
+ if (missingLicences.length > 0)
103
+ problems.push(`does not show ${missingLicences.length === 1 ? "the licence number" : "the licence numbers"} ${missingLicences.join(", ")}`);
104
+ if (!anchors(footer).some(isSocialOrProfile))
105
+ warnings.push("has no social or Business Profile links in column 1");
106
+ if (!anchors(footer).some((link) => (attr(link, "href") ?? "").trim().toLowerCase().startsWith("tel:")))
107
+ problems.push("has no tel: contact link in column 1");
108
+ // Columns.
109
+ const present = columns.filter((column) => column.key !== "other");
110
+ for (const key of ["services", "company"]) {
111
+ const column = columns.find((candidate) => candidate.key === key);
112
+ if (!column)
113
+ problems.push(`has no ${key} column under a real heading`);
114
+ else if (column.links === 0)
115
+ problems.push(`has a ${key} column ("${column.heading}") with no links`);
116
+ }
117
+ const areas = columns.find((candidate) => candidate.key === "areas");
118
+ if (!areas)
119
+ warnings.push("has no areas column");
120
+ else if (areas.links === 0)
121
+ problems.push(`has an areas column ("${areas.heading}") with no links`);
122
+ const order = present.map((column) => column.key).filter((key) => key !== "guides");
123
+ const wanted = ["services", "areas", "company"].filter((key) => order.includes(key));
124
+ if (order.length === wanted.length && order.some((key, index) => key !== wanted[index]))
125
+ problems.push("runs its columns out of order (services, then areas, then company)");
126
+ for (const column of columns) {
127
+ if (column.heading === "")
128
+ problems.push("has a column with no real heading");
129
+ }
130
+ const guides = columns.find((column) => column.key === "guides");
131
+ if (guides)
132
+ problems.push(`has a guides column ("${guides.heading}")`);
133
+ // Bottom bar.
134
+ const legalText = bottom ? textOf(bottom, true) : text;
135
+ if (!/(?:©|\(c\)|&copy;)\s*(?:\d{4}\s*)?[A-Za-z]/.test(legalText))
136
+ problems.push("has no © line with the trading name in the bottom bar");
137
+ const abn = /\bABN\b[\s:.]*((?:\d[\s]*){11})(?!\d)/i.exec(legalText);
138
+ if (!abn)
139
+ problems.push("shows no ABN in the bottom bar");
140
+ const legalLinks = anchors(bottom ?? footer).map((link) => `${attr(link, "href") ?? ""} ${textOf(link)}`.toLowerCase());
141
+ if (!legalLinks.some((value) => value.includes("privacy")))
142
+ problems.push("has no privacy policy link in the bottom bar");
143
+ if (!legalLinks.some((value) => /terms|conditions/.test(value)))
144
+ problems.push("has no terms link in the bottom bar");
145
+ // Never.
146
+ const cta = descendants(footer).find((element) => attr(element, "data-bl") === "quote-button" ||
147
+ attr(element, "data-bl-cta") !== undefined ||
148
+ (element.tag === "button" && attr(element, "data-slot") !== "toggle" && attr(element, "type") !== "button") ||
149
+ ((element.tag === "a" || element.tag === "button") && attr(element, "data-bl") !== "phone-link" && CTA_LABEL.test(normaliseText(textOf(element)))));
150
+ if (cta)
151
+ problems.push(`has a call-to-action button ("${normaliseText(textOf(cta)) || "button"}")`);
152
+ if (/\bbased\s+in\b/i.test(text))
153
+ problems.push('says "based in"');
154
+ const hours = HOURS.map((pattern) => pattern.exec(text)).find((match) => match !== null);
155
+ if (hours)
156
+ problems.push(`shows opening hours ("${hours[0]}")`);
157
+ return { problems, warnings };
158
+ }
159
+ export function checkN9(pages, projectRoot) {
160
+ if (pages.length === 0)
161
+ return [notApplicable("N9", "No page could be read, so there is no footer to check.")];
162
+ const { facts, reason } = loadShellProjectFacts(projectRoot);
163
+ const footerFacts = { licences: (facts?.licences ?? []).map((licence) => licence.identifier).filter(Boolean) };
164
+ const problems = new PageProblems();
165
+ const notes = new PageProblems();
166
+ for (const page of pages) {
167
+ const footer = footerOf(page.root);
168
+ if (!footer) {
169
+ problems.add(page.path, "No footer");
170
+ continue;
171
+ }
172
+ const found = footerProblems(footer, footerFacts);
173
+ for (const problem of found.problems)
174
+ problems.add(page.path, `The footer ${problem}`);
175
+ for (const warning of found.warnings)
176
+ notes.add(page.path, `The footer ${warning}`);
177
+ }
178
+ const warnings = notes.findings("N9").map((finding) => ({ ...finding, status: "pass", severity: "warning" }));
179
+ if (facts === null) {
180
+ warnings.push({ checkId: "N9", status: "pass", severity: "warning", message: `The licence numbers in the footer were not checked: ${reason.charAt(0).toLowerCase()}${reason.slice(1)}` });
181
+ }
182
+ if (problems.size > 0)
183
+ return [...problems.findings("N9"), ...warnings];
184
+ return [pass("N9", `Checked the footer on ${plural(pages.length, "page")}: column 1, the services and company columns, the bottom bar and what a footer never holds are all in order.`), ...warnings];
185
+ }
186
+ //# sourceMappingURL=n9-footer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"n9-footer.js","sourceRoot":"","sources":["../../../../src/site-system/rules/sections/n9-footer.ts"],"names":[],"mappings":"AAeA,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,EAAmB,MAAM,sBAAsB,CAAC;AAC1F,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,EAAoB,MAAM,oBAAoB,CAAC;AAC3G,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACpD,OAAO,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAE1D,MAAM,YAAY,GAAG,mKAAmK,CAAC;AACzL,MAAM,YAAY,GAAG,+BAA+B,CAAC;AACrD,MAAM,OAAO,GAAG,UAAU,CAAC;AAC3B,MAAM,aAAa,GAAG,8HAA8H,CAAC;AACrJ,MAAM,gBAAgB,GAAG,0DAA0D,CAAC;AACpF,MAAM,eAAe,GAAG,kEAAkE,CAAC;AAC3F,MAAM,cAAc,GAAG,yDAAyD,CAAC;AACjF,MAAM,SAAS,GAAG,kMAAkM,CAAC;AACrN,MAAM,KAAK,GAAG;IACZ,4DAA4D;IAC5D,kBAAkB;IAClB,wFAAwF;IACxF,mFAAmF;IACnF,kEAAkE;CACnE,CAAC;AAWF,gFAAgF;AAChF,MAAM,UAAU,QAAQ,CAAC,IAAiB;IACxC,MAAM,GAAG,GAAG,WAAW,CAAC,IAAI,CAAC,CAAC;IAC9B,OAAO,CACL,GAAG,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,KAAK,QAAQ,CAAC;QAC5D,GAAG,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,KAAK,QAAQ,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,QAAQ,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;QAC7K,IAAI,CACL,CAAC;AACJ,CAAC;AAED,SAAS,QAAQ,CAAC,OAAe,EAAE,QAA4B;IAC7D,IAAI,QAAQ,KAAK,UAAU,IAAI,QAAQ,KAAK,OAAO,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,KAAK,QAAQ;QAAE,OAAO,QAAQ,CAAC;IACxH,IAAI,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,QAAQ,CAAC;IAClD,IAAI,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,OAAO,CAAC;IAChD,IAAI,gBAAgB,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,UAAU,CAAC;IACtD,IAAI,eAAe,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,SAAS,CAAC;IACpD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,SAAS,OAAO,CAAC,OAAoB;IACnC,OAAO,WAAW,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC,KAAK,EAAE,CAAC,CAAC;AACzG,CAAC;AAED,oFAAoF;AACpF,MAAM,UAAU,aAAa,CAAC,MAAmB,EAAE,MAA+B;IAChF,MAAM,OAAO,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,KAAK,QAAQ,CAAC,CAAC;IACjG,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACvB,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE;YAC7B,MAAM,OAAO,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;YAC9E,MAAM,IAAI,GAAG,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YACrF,OAAO,EAAE,GAAG,EAAE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,aAAa,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC;QACvH,CAAC,CAAC,CAAC;IACL,CAAC;IACD,MAAM,QAAQ,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,MAAM,IAAI,SAAS,CAAC,OAAO,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IACxI,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE;QAC9B,IAAI,KAAK,GAAgB,OAAO,CAAC,MAAM,IAAI,MAAM,CAAC;QAClD,OAAO,KAAK,KAAK,MAAM,IAAI,WAAW,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC;YAAE,KAAK,GAAG,KAAK,CAAC,MAAM,IAAI,MAAM,CAAC;QACxI,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QACnC,OAAO,EAAE,GAAG,EAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;IACzG,CAAC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,MAAM,CAAC,IAAY;IAC1B,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC;QAC1B,OAAO,EAAE,IAAI,EAAE,GAAG,CAAC,QAAQ,CAAC,WAAW,EAAE,EAAE,IAAI,EAAE,GAAG,CAAC,QAAQ,EAAE,CAAC;IAClE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,SAAS,iBAAiB,CAAC,IAAiB;IAC1C,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IAChD,IAAI,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IAC1B,IAAI,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAChD,OAAO,0BAA0B,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;AACxF,CAAC;AAOD,SAAS,OAAO,CAAC,KAAa;IAC5B,OAAO,KAAK,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;AACvD,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,cAAc,CAAC,MAAmB,EAAE,KAAkB;IACpE,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IAClC,MAAM,MAAM,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,KAAK,QAAQ,CAAC,CAAC;IAC9F,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9C,MAAM,KAAK,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,KAAK,OAAO,CAAC,CAAC;IAC5F,MAAM,QAAQ,GAAG,CAAC,OAAoB,EAAE,EAAE,CAAC,MAAM,KAAK,SAAS,IAAI,SAAS,CAAC,OAAO,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IAEvG,YAAY;IACZ,MAAM,IAAI,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,KAAK,KAAK,IAAI,OAAO,CAAC,GAAG,KAAK,KAAK,CAAC,IAAI,SAAS,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,KAAK,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;IACtL,IAAI,CAAC,IAAI;QAAE,QAAQ,CAAC,IAAI,CAAC,sCAAsC,CAAC,CAAC;IACjE,MAAM,SAAS,GAAG,KAAK;QACrB,CAAC,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,KAAK,aAAa,IAAI,MAAM,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC;QAChI,CAAC,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,KAAK,GAAG,IAAI,MAAM,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,IAAI,EAAE,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAC5M,IAAI,CAAC,SAAS;QAAE,QAAQ,CAAC,IAAI,CAAC,sDAAsD,CAAC,CAAC;IACtF,MAAM,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,MAAM,eAAe,GAAG,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,aAAa,CAAC,QAAQ,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IACtG,IAAI,eAAe,CAAC,MAAM,GAAG,CAAC;QAAE,QAAQ,CAAC,IAAI,CAAC,iBAAiB,eAAe,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,oBAAoB,CAAC,CAAC,CAAC,qBAAqB,IAAI,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC5K,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,iBAAiB,CAAC;QAAE,QAAQ,CAAC,IAAI,CAAC,qDAAqD,CAAC,CAAC;IACnH,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;QAAE,QAAQ,CAAC,IAAI,CAAC,sCAAsC,CAAC,CAAC;IAE/J,WAAW;IACX,MAAM,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,KAAK,OAAO,CAAC,CAAC;IACnE,KAAK,MAAM,GAAG,IAAI,CAAC,UAAU,EAAE,SAAS,CAAU,EAAE,CAAC;QACnD,MAAM,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,GAAG,KAAK,GAAG,CAAC,CAAC;QAClE,IAAI,CAAC,MAAM;YAAE,QAAQ,CAAC,IAAI,CAAC,UAAU,GAAG,8BAA8B,CAAC,CAAC;aACnE,IAAI,MAAM,CAAC,KAAK,KAAK,CAAC;YAAE,QAAQ,CAAC,IAAI,CAAC,SAAS,GAAG,aAAa,MAAM,CAAC,OAAO,kBAAkB,CAAC,CAAC;IACxG,CAAC;IACD,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,GAAG,KAAK,OAAO,CAAC,CAAC;IACrE,IAAI,CAAC,KAAK;QAAE,QAAQ,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;SAC5C,IAAI,KAAK,CAAC,KAAK,KAAK,CAAC;QAAE,QAAQ,CAAC,IAAI,CAAC,yBAAyB,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;IACpG,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAA2C,EAAE,CAAC,GAAG,KAAK,QAAQ,CAAC,CAAC;IAC7H,MAAM,MAAM,GAAI,CAAC,UAAU,EAAE,OAAO,EAAE,SAAS,CAAW,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC;IAChG,IAAI,KAAK,CAAC,MAAM,KAAK,MAAM,CAAC,MAAM,IAAI,KAAK,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE,CAAC,GAAG,KAAK,MAAM,CAAC,KAAK,CAAC,CAAC;QAAE,QAAQ,CAAC,IAAI,CAAC,oEAAoE,CAAC,CAAC;IAC7K,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,MAAM,CAAC,OAAO,KAAK,EAAE;YAAE,QAAQ,CAAC,IAAI,CAAC,mCAAmC,CAAC,CAAC;IAChF,CAAC;IACD,MAAM,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,KAAK,QAAQ,CAAC,CAAC;IACjE,IAAI,MAAM;QAAE,QAAQ,CAAC,IAAI,CAAC,yBAAyB,MAAM,CAAC,OAAO,IAAI,CAAC,CAAC;IAEvE,cAAc;IACd,MAAM,SAAS,GAAG,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACvD,IAAI,CAAC,4CAA4C,CAAC,IAAI,CAAC,SAAS,CAAC;QAAE,QAAQ,CAAC,IAAI,CAAC,uDAAuD,CAAC,CAAC;IAC1I,MAAM,GAAG,GAAG,wCAAwC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IACrE,IAAI,CAAC,GAAG;QAAE,QAAQ,CAAC,IAAI,CAAC,gCAAgC,CAAC,CAAC;IAC1D,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,IAAI,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,IAAI,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC;IACxH,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;QAAE,QAAQ,CAAC,IAAI,CAAC,8CAA8C,CAAC,CAAC;IAC1H,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,kBAAkB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAAE,QAAQ,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAC;IAEtH,SAAS;IACT,MAAM,GAAG,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC,IAAI,CAClC,CAAC,OAAO,EAAE,EAAE,CACV,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,KAAK,cAAc;QAC3C,IAAI,CAAC,OAAO,EAAE,aAAa,CAAC,KAAK,SAAS;QAC1C,CAAC,OAAO,CAAC,GAAG,KAAK,QAAQ,IAAI,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,KAAK,QAAQ,IAAI,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,KAAK,QAAQ,CAAC;QAC3G,CAAC,CAAC,OAAO,CAAC,GAAG,KAAK,GAAG,IAAI,OAAO,CAAC,GAAG,KAAK,QAAQ,CAAC,IAAI,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,KAAK,YAAY,IAAI,SAAS,CAAC,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CACrJ,CAAC;IACF,IAAI,GAAG;QAAE,QAAQ,CAAC,IAAI,CAAC,iCAAiC,aAAa,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,QAAQ,IAAI,CAAC,CAAC;IACpG,IAAI,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,QAAQ,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;IACnE,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,KAAK,IAAI,CAAC,CAAC;IACzF,IAAI,KAAK;QAAE,QAAQ,CAAC,IAAI,CAAC,yBAAyB,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IAChE,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC;AAChC,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,KAA4B,EAAE,WAAmB;IACvE,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,aAAa,CAAC,IAAI,EAAE,wDAAwD,CAAC,CAAC,CAAC;IAC/G,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,qBAAqB,CAAC,WAAW,CAAC,CAAC;IAC7D,MAAM,WAAW,GAAgB,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,QAAQ,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;IAC5H,MAAM,QAAQ,GAAG,IAAI,YAAY,EAAE,CAAC;IACpC,MAAM,KAAK,GAAG,IAAI,YAAY,EAAE,CAAC;IACjC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACnC,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC;YACrC,SAAS;QACX,CAAC;QACD,MAAM,KAAK,GAAG,cAAc,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;QAClD,KAAK,MAAM,OAAO,IAAI,KAAK,CAAC,QAAQ;YAAE,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,cAAc,OAAO,EAAE,CAAC,CAAC;QACvF,KAAK,MAAM,OAAO,IAAI,KAAK,CAAC,QAAQ;YAAE,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,cAAc,OAAO,EAAE,CAAC,CAAC;IACtF,CAAC;IACD,MAAM,QAAQ,GAAG,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,OAAO,EAAE,MAAM,EAAE,MAAe,EAAE,QAAQ,EAAE,SAAkB,EAAE,CAAC,CAAC,CAAC;IAChI,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;QACnB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,uDAAuD,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAC5L,CAAC;IACD,IAAI,QAAQ,CAAC,IAAI,GAAG,CAAC;QAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,GAAG,QAAQ,CAAC,CAAC;IACxE,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,yBAAyB,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,CAAC,8GAA8G,CAAC,EAAE,GAAG,QAAQ,CAAC,CAAC;AACxM,CAAC","sourcesContent":["/**\n * N9: footer content (Site System v2 §13.2, v1 §6).\n *\n * - Column 1 holds the logo, a short description, the licence numbers (when the business holds licences), social and\n * profile links, and the contact details (a `tel:` link; an email when public).\n * - Then Services, Areas (regions or popular areas) and Company, each under a real heading (an H2 to H6 with text and\n * links beneath it), in that order. A site with no areas to list may leave that column out: it is reported as a\n * warning, not a failure, because the library lets a site leave out a column it has nothing true for.\n * - The bottom bar holds © and the trading name, the ABN and the legal links (privacy and terms).\n * - Never a call-to-action button, \"based in\", opening hours or notes about hours, or a guides column.\n *\n * Library footers are read by their slots (`brand`, `columns`, `column`, `bottom`); any other footer is read by its\n * headings, so a hand-written footer is held to the same content.\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { fail, notApplicable, pass, plural, type ParsedPage } from \"../enquiry/common.js\";\nimport { ancestors, attr, descendants, normaliseText, textOf, type HtmlElement } from \"../enquiry/html.js\";\nimport { PageProblems } from \"../library/common.js\";\nimport { loadShellProjectFacts } from \"../shell/facts.js\";\n\nconst SOCIAL_HOSTS = /(?:^|\\.)(?:facebook|instagram|linkedin|youtube|tiktok|x|twitter|pinterest|houzz|hipages|yelp|threads)\\.com$|(?:^|\\.)g\\.page$|(?:^|\\.)goo\\.gl$|(?:^|\\.)youtu\\.be$/i;\nconst PROFILE_PATH = /^(?:\\/maps|\\/search|\\/local)/i;\nconst HEADING = /^h[2-6]$/;\nconst AREAS_HEADING = /\\b(?:areas?|regions?|suburbs?|locations?|councils?|where we (?:work|service|cover)|we (?:service|cover|work in)|coverage)\\b/i;\nconst SERVICES_HEADING = /\\b(?:services?|what we do|our work|trades?|solutions)\\b/i;\nconst COMPANY_HEADING = /\\b(?:company|about|information|business|our firm|our company)\\b/i;\nconst GUIDES_HEADING = /\\b(?:guides?|cost guides?|resources|articles|advice)\\b/i;\nconst CTA_LABEL = /^\\s*(?:get|request|book|schedule|claim|start)\\b.*\\b(?:quote|estimate|inspection|booking|now|online|today)\\b|^\\s*(?:free|instant)\\s+(?:quote|estimate|inspection)|^\\s*enquire\\b|^\\s*call\\s+now\\b/i;\nconst HOURS = [\n /\\b(?:opening|business|office|trading|operating)\\s+hours\\b/i,\n /\\bhours\\s*[:–-]/i,\n /\\bmon(?:day)?\\s*(?:-|–|to|through|thru)\\s*(?:fri(?:day)?|sat(?:urday)?|sun(?:day)?)\\b/i,\n /\\b\\d{1,2}(?::\\d{2})?\\s*(?:am|pm)\\s*(?:-|–|to)\\s*\\d{1,2}(?::\\d{2})?\\s*(?:am|pm)\\b/i,\n /\\b24\\s*\\/\\s*7\\b|\\bopen\\s+(?:24\\s+hours|7\\s+days|seven\\s+days)\\b/i,\n];\n\ntype ColumnKey = \"services\" | \"areas\" | \"company\" | \"guides\" | \"other\";\n\ninterface Column {\n readonly key: ColumnKey;\n readonly heading: string;\n readonly links: number;\n readonly element: HtmlElement;\n}\n\n/** The footer of a page: the library footer, else the page-level `<footer>`. */\nexport function footerOf(root: HtmlElement): HtmlElement | null {\n const all = descendants(root);\n return (\n all.find((element) => attr(element, \"data-bl\") === \"footer\") ??\n all.find((element) => element.tag === \"footer\" && !ancestors(element).some((parent) => [\"article\", \"section\", \"aside\", \"main\", \"blockquote\", \"dialog\"].includes(parent.tag))) ??\n null\n );\n}\n\nfunction classify(heading: string, declared: string | undefined): ColumnKey {\n if (declared === \"services\" || declared === \"areas\" || declared === \"company\" || declared === \"guides\") return declared;\n if (GUIDES_HEADING.test(heading)) return \"guides\";\n if (AREAS_HEADING.test(heading)) return \"areas\";\n if (SERVICES_HEADING.test(heading)) return \"services\";\n if (COMPANY_HEADING.test(heading)) return \"company\";\n return \"other\";\n}\n\nfunction anchors(element: HtmlElement): HtmlElement[] {\n return descendants(element).filter((child) => child.tag === \"a\" && (attr(child, \"href\") ?? \"\") !== \"\");\n}\n\n/** The footer's columns, in order. A column is a heading and the links under it. */\nexport function footerColumns(footer: HtmlElement, bottom: HtmlElement | undefined): Column[] {\n const slotted = descendants(footer).filter((element) => attr(element, \"data-slot\") === \"column\");\n if (slotted.length > 0) {\n return slotted.map((element) => {\n const heading = descendants(element).find((child) => HEADING.test(child.tag));\n const text = heading ? textOf(heading, true, (child) => child.tag === \"button\") : \"\";\n return { key: classify(text, attr(element, \"data-column\")), heading: text, links: anchors(element).length, element };\n });\n }\n const headings = descendants(footer).filter((element) => HEADING.test(element.tag) && !(bottom && ancestors(element).includes(bottom)));\n return headings.map((heading) => {\n let scope: HtmlElement = heading.parent ?? footer;\n while (scope !== footer && descendants(scope).filter((element) => HEADING.test(element.tag)).length > 1) scope = scope.parent ?? footer;\n const text = textOf(heading, true);\n return { key: classify(text, undefined), heading: text, links: anchors(scope).length, element: scope };\n });\n}\n\nfunction hostOf(href: string): { host: string; path: string } | null {\n try {\n const url = new URL(href);\n return { host: url.hostname.toLowerCase(), path: url.pathname };\n } catch {\n return null;\n }\n}\n\nfunction isSocialOrProfile(link: HtmlElement): boolean {\n const target = hostOf(attr(link, \"href\") ?? \"\");\n if (!target) return false;\n if (SOCIAL_HOSTS.test(target.host)) return true;\n return /(?:^|\\.)google\\.[a-z.]+$/.test(target.host) && PROFILE_PATH.test(target.path);\n}\n\nexport interface FooterFacts {\n /** Licence identifiers the business holds; an empty list when none are recorded or the facts could not be read. */\n readonly licences: readonly string[];\n}\n\nfunction compact(value: string): string {\n return value.toLowerCase().replace(/[^a-z0-9]/g, \"\");\n}\n\n/** Every problem and warning with one footer, as clauses with no subject. */\nexport function footerProblems(footer: HtmlElement, facts: FooterFacts): { problems: string[]; warnings: string[] } {\n const problems: string[] = [];\n const warnings: string[] = [];\n const text = textOf(footer, true);\n const bottom = descendants(footer).find((element) => attr(element, \"data-slot\") === \"bottom\");\n const columns = footerColumns(footer, bottom);\n const brand = descendants(footer).find((element) => attr(element, \"data-slot\") === \"brand\");\n const inBottom = (element: HtmlElement) => bottom !== undefined && ancestors(element).includes(bottom);\n\n // Column 1.\n const logo = descendants(footer).some((element) => (element.tag === \"img\" || element.tag === \"svg\") && ancestors(element).some((parent) => parent.tag === \"a\") && !inBottom(element));\n if (!logo) problems.push(\"has no logo linking home in column 1\");\n const describes = brand\n ? descendants(brand).some((element) => attr(element, \"data-slot\") === \"description\" && textOf(element).split(/\\s+/).length >= 6)\n : descendants(footer).some((element) => element.tag === \"p\" && textOf(element).split(/\\s+/).length >= 12 && !inBottom(element) && !columns.some((column) => ancestors(element).includes(column.element)));\n if (!describes) problems.push(\"has no short description of the business in column 1\");\n const footerCompact = compact(text);\n const missingLicences = facts.licences.filter((licence) => !footerCompact.includes(compact(licence)));\n if (missingLicences.length > 0) problems.push(`does not show ${missingLicences.length === 1 ? \"the licence number\" : \"the licence numbers\"} ${missingLicences.join(\", \")}`);\n if (!anchors(footer).some(isSocialOrProfile)) warnings.push(\"has no social or Business Profile links in column 1\");\n if (!anchors(footer).some((link) => (attr(link, \"href\") ?? \"\").trim().toLowerCase().startsWith(\"tel:\"))) problems.push(\"has no tel: contact link in column 1\");\n\n // Columns.\n const present = columns.filter((column) => column.key !== \"other\");\n for (const key of [\"services\", \"company\"] as const) {\n const column = columns.find((candidate) => candidate.key === key);\n if (!column) problems.push(`has no ${key} column under a real heading`);\n else if (column.links === 0) problems.push(`has a ${key} column (\"${column.heading}\") with no links`);\n }\n const areas = columns.find((candidate) => candidate.key === \"areas\");\n if (!areas) warnings.push(\"has no areas column\");\n else if (areas.links === 0) problems.push(`has an areas column (\"${areas.heading}\") with no links`);\n const order = present.map((column) => column.key).filter((key): key is \"services\" | \"areas\" | \"company\" => key !== \"guides\");\n const wanted = ([\"services\", \"areas\", \"company\"] as const).filter((key) => order.includes(key));\n if (order.length === wanted.length && order.some((key, index) => key !== wanted[index])) problems.push(\"runs its columns out of order (services, then areas, then company)\");\n for (const column of columns) {\n if (column.heading === \"\") problems.push(\"has a column with no real heading\");\n }\n const guides = columns.find((column) => column.key === \"guides\");\n if (guides) problems.push(`has a guides column (\"${guides.heading}\")`);\n\n // Bottom bar.\n const legalText = bottom ? textOf(bottom, true) : text;\n if (!/(?:©|\\(c\\)|&copy;)\\s*(?:\\d{4}\\s*)?[A-Za-z]/.test(legalText)) problems.push(\"has no © line with the trading name in the bottom bar\");\n const abn = /\\bABN\\b[\\s:.]*((?:\\d[\\s]*){11})(?!\\d)/i.exec(legalText);\n if (!abn) problems.push(\"shows no ABN in the bottom bar\");\n const legalLinks = anchors(bottom ?? footer).map((link) => `${attr(link, \"href\") ?? \"\"} ${textOf(link)}`.toLowerCase());\n if (!legalLinks.some((value) => value.includes(\"privacy\"))) problems.push(\"has no privacy policy link in the bottom bar\");\n if (!legalLinks.some((value) => /terms|conditions/.test(value))) problems.push(\"has no terms link in the bottom bar\");\n\n // Never.\n const cta = descendants(footer).find(\n (element) =>\n attr(element, \"data-bl\") === \"quote-button\" ||\n attr(element, \"data-bl-cta\") !== undefined ||\n (element.tag === \"button\" && attr(element, \"data-slot\") !== \"toggle\" && attr(element, \"type\") !== \"button\") ||\n ((element.tag === \"a\" || element.tag === \"button\") && attr(element, \"data-bl\") !== \"phone-link\" && CTA_LABEL.test(normaliseText(textOf(element)))),\n );\n if (cta) problems.push(`has a call-to-action button (\"${normaliseText(textOf(cta)) || \"button\"}\")`);\n if (/\\bbased\\s+in\\b/i.test(text)) problems.push('says \"based in\"');\n const hours = HOURS.map((pattern) => pattern.exec(text)).find((match) => match !== null);\n if (hours) problems.push(`shows opening hours (\"${hours[0]}\")`);\n return { problems, warnings };\n}\n\nexport function checkN9(pages: readonly ParsedPage[], projectRoot: string): SiteSystemFinding[] {\n if (pages.length === 0) return [notApplicable(\"N9\", \"No page could be read, so there is no footer to check.\")];\n const { facts, reason } = loadShellProjectFacts(projectRoot);\n const footerFacts: FooterFacts = { licences: (facts?.licences ?? []).map((licence) => licence.identifier).filter(Boolean) };\n const problems = new PageProblems();\n const notes = new PageProblems();\n for (const page of pages) {\n const footer = footerOf(page.root);\n if (!footer) {\n problems.add(page.path, \"No footer\");\n continue;\n }\n const found = footerProblems(footer, footerFacts);\n for (const problem of found.problems) problems.add(page.path, `The footer ${problem}`);\n for (const warning of found.warnings) notes.add(page.path, `The footer ${warning}`);\n }\n const warnings = notes.findings(\"N9\").map((finding) => ({ ...finding, status: \"pass\" as const, severity: \"warning\" as const }));\n if (facts === null) {\n warnings.push({ checkId: \"N9\", status: \"pass\", severity: \"warning\", message: `The licence numbers in the footer were not checked: ${reason.charAt(0).toLowerCase()}${reason.slice(1)}` });\n }\n if (problems.size > 0) return [...problems.findings(\"N9\"), ...warnings];\n return [pass(\"N9\", `Checked the footer on ${plural(pages.length, \"page\")}: column 1, the services and company columns, the bottom bar and what a footer never holds are all in order.`), ...warnings];\n}\n"]}
@@ -0,0 +1,14 @@
1
+ import type { SiteSystemFinding } from "../../checks.js";
2
+ import { type ParsedPage } from "../enquiry/common.js";
3
+ export declare const T6_MEDIA_PATH = "src/data/media.ts";
4
+ export declare const T6_PROVENANCES: readonly ["genuine", "stock", "illustrative-ai"];
5
+ export interface MediaManifest {
6
+ /** Media id to its `src`. */
7
+ readonly sources: ReadonlyMap<string, string>;
8
+ /** Problems with entries, one plain fragment each ("media.hero has no provenance"). */
9
+ readonly entryProblems: readonly string[];
10
+ }
11
+ /** "missing": no media.ts; "not-declared": no defineMedia call in it; null: unreadable; else the manifest. */
12
+ export declare function readMediaManifest(projectRoot: string): MediaManifest | "missing" | "not-declared" | null;
13
+ export declare function checkT6(pages: readonly ParsedPage[], projectRoot: string): SiteSystemFinding[];
14
+ //# sourceMappingURL=t6-media-entries.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"t6-media-entries.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/sections/t6-media-entries.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAAqC,KAAK,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAK1F,eAAO,MAAM,aAAa,sBAAsB,CAAC;AACjD,eAAO,MAAM,cAAc,kDAAmD,CAAC;AAE/E,MAAM,WAAW,aAAa;IAC5B,6BAA6B;IAC7B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9C,uFAAuF;IACvF,QAAQ,CAAC,aAAa,EAAE,SAAS,MAAM,EAAE,CAAC;CAC3C;AAED,8GAA8G;AAC9G,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,GAAG,cAAc,GAAG,IAAI,CA2BxG;AAOD,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,EAAE,WAAW,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAgC9F"}