@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,275 @@
1
+ /**
2
+ * T11: no page for work outside the licence classes the business holds, and the licence conditions carried
3
+ * (Site System v2 §8.1, §13.2).
4
+ *
5
+ * Two things are read from the project:
6
+ * - the licences in the evidence data (`src/data/businessEvidence.ts`, then `src/data/business.ts`), read as plain
7
+ * data without running the site: `BrandlabLicence` classes, conditions and expiry;
8
+ * - `docs/service-classes.json`, the service-to-class map: for each service the site publishes a page for, the
9
+ * licence classes that cover it and the value above which the work needs a licence.
10
+ *
11
+ * A service whose work needs a licence is covered when a current licence holds one of its classes. An expired
12
+ * licence covers nothing. A class condition that caps the job value (the Home Building Compensation limit of
13
+ * $20,000, or any "up to $N") caps the price bands the service page may show. The rule is not applicable only when
14
+ * it genuinely does not apply: the evidence is readable and records no licence (and there is no map), or no service
15
+ * on the map needs a licence. When it cannot tell (licences on record but no map, or licence data it cannot read)
16
+ * it returns no finding, so the check shows as not run rather than guess.
17
+ *
18
+ * `readLicences` is also the licence reader for T3, so the two checks always agree on what the business holds.
19
+ */
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { licenceRenderState } from "@brandlab-digital/site-core/business";
23
+ import { fail, normalisePath, notApplicable, plural, resultOf } from "./common.js";
24
+ import { priceFigures } from "./e9-cost-guides.js";
25
+ import { attr, descendants } from "./html.js";
26
+ import { LiteralReader, isFullyResolved, isLiteralObject } from "./literal.js";
27
+ export const T11_SERVICE_CLASS_MAP = "docs/service-classes.json";
28
+ export const T11_LICENCE_FILES = ["src/data/businessEvidence.ts", "src/data/business.ts"];
29
+ /** The Home Building Compensation threshold: a condition that names HBC without an amount caps the job at this. */
30
+ export const T11_HBC_CAP = 20_000;
31
+ function readStringArray(value) {
32
+ if (value === undefined)
33
+ return [];
34
+ return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : null;
35
+ }
36
+ function toHeldLicence(value) {
37
+ if (!isLiteralObject(value) || typeof value["name"] !== "string" || typeof value["identifier"] !== "string")
38
+ return null;
39
+ const rawClasses = value["classes"];
40
+ let classes;
41
+ if (rawClasses === undefined) {
42
+ // A licence recorded before 3.8.0 names its work in `name`.
43
+ classes = [{ name: value["name"], conditions: [] }];
44
+ }
45
+ else if (Array.isArray(rawClasses)) {
46
+ classes = [];
47
+ for (const item of rawClasses) {
48
+ if (!isLiteralObject(item) || typeof item["name"] !== "string")
49
+ return null;
50
+ const conditions = readStringArray(item["conditions"]);
51
+ if (conditions === null)
52
+ return null;
53
+ classes.push({ name: item["name"], conditions });
54
+ }
55
+ }
56
+ else {
57
+ return null;
58
+ }
59
+ const optional = (key) => (typeof value[key] === "string" && value[key] !== "" ? value[key] : undefined);
60
+ const jurisdiction = optional("jurisdiction");
61
+ const licensee = optional("licensee") ?? optional("holder");
62
+ const expiresOn = optional("expiresOn");
63
+ const checkedOn = optional("checkedOn");
64
+ return {
65
+ name: value["name"],
66
+ identifier: value["identifier"],
67
+ ...(jurisdiction ? { jurisdiction } : {}),
68
+ ...(licensee ? { licensee } : {}),
69
+ classes,
70
+ ...(expiresOn ? { expiresOn } : {}),
71
+ ...(checkedOn ? { checkedOn } : {}),
72
+ };
73
+ }
74
+ /** Reads the licences from the evidence data files without running them. */
75
+ export function readLicences(projectRoot) {
76
+ let unreadable = null;
77
+ for (const file of T11_LICENCE_FILES) {
78
+ const path = join(projectRoot, file);
79
+ if (!existsSync(path))
80
+ continue;
81
+ const reader = new LiteralReader(readFileSync(path, "utf8"), file);
82
+ for (const expression of reader.propertyValues("licences")) {
83
+ let value = reader.evaluate(expression);
84
+ if (isLiteralObject(value) && typeof value["status"] === "string") {
85
+ if (value["status"] === "unknown" || value["status"] === "not-applicable")
86
+ return { status: "none", file };
87
+ value = (value["value"] ?? null);
88
+ }
89
+ if (!Array.isArray(value))
90
+ continue;
91
+ if (!isFullyResolved(value)) {
92
+ unreadable = `${file} lists licences that are not plain data.`;
93
+ continue;
94
+ }
95
+ const licences = value.map(toHeldLicence);
96
+ if (licences.some((licence) => licence === null)) {
97
+ unreadable = `${file} lists a licence without a name and number.`;
98
+ continue;
99
+ }
100
+ return licences.length === 0 ? { status: "none", file } : { status: "found", licences: licences, file };
101
+ }
102
+ }
103
+ return { status: "unreadable", reason: unreadable ?? "No licence data was found in the evidence files." };
104
+ }
105
+ /** Reads the service-to-class map. Returns null when it does not exist and a string when it cannot be read. */
106
+ export function readServiceClassMap(projectRoot) {
107
+ const file = join(projectRoot, T11_SERVICE_CLASS_MAP);
108
+ if (!existsSync(file))
109
+ return null;
110
+ let parsed;
111
+ try {
112
+ parsed = JSON.parse(readFileSync(file, "utf8"));
113
+ }
114
+ catch {
115
+ return `${T11_SERVICE_CLASS_MAP} is not valid JSON.`;
116
+ }
117
+ const services = parsed?.services;
118
+ if (!Array.isArray(services))
119
+ return `${T11_SERVICE_CLASS_MAP} must be an object with a "services" list.`;
120
+ const entries = [];
121
+ for (const [index, service] of services.entries()) {
122
+ const entry = service;
123
+ const where = `${T11_SERVICE_CLASS_MAP} services[${index}]`;
124
+ if (!entry || typeof entry !== "object")
125
+ return `${where} must be an object.`;
126
+ if (typeof entry["path"] !== "string" || !entry["path"].startsWith("/"))
127
+ return `${where} needs a "path" that starts with "/".`;
128
+ if (typeof entry["name"] !== "string" || !entry["name"].trim())
129
+ return `${where} needs a "name".`;
130
+ const classes = entry["classes"];
131
+ if (!Array.isArray(classes) || !classes.every((item) => typeof item === "string" && item.trim())) {
132
+ return `${where} needs "classes": a list of licence class names, empty when the work needs no licence.`;
133
+ }
134
+ for (const key of ["licenceAbove", "jobValueMax"]) {
135
+ const value = entry[key];
136
+ if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || value < 0)) {
137
+ return `${where} has a "${key}" that is not a dollar amount.`;
138
+ }
139
+ }
140
+ entries.push({
141
+ path: normalisePath(entry["path"]),
142
+ name: entry["name"],
143
+ classes: classes,
144
+ licenceAbove: entry["licenceAbove"] ?? 0,
145
+ ...(typeof entry["jobValueMax"] === "number" ? { jobValueMax: entry["jobValueMax"] } : {}),
146
+ });
147
+ }
148
+ return entries;
149
+ }
150
+ /** Whether a licence has expired on `asOf` (today in Sydney is after its expiry date), by site-core's licenceRenderState. */
151
+ export function isExpired(licence, asOf) {
152
+ return licenceRenderState(licence.expiresOn === undefined ? {} : { expiresOn: licence.expiresOn }, asOf).state === "expired";
153
+ }
154
+ const CAP_PHRASE = /(?:up\s+to|under|below|less\s+than|not\s+(?:exceeding|more\s+than|over)|no\s+more\s+than|(?:work|jobs?)\s+(?:valued\s+)?(?:over|above|exceeding)|limit(?:ed)?(?:\s+(?:to|of))?|cap(?:ped)?(?:\s+(?:at|of))?|maximum(?:\s+(?:of|value))?|max\.?)[^$\d]{0,60}\$\s?(\d{1,3}(?:,\d{3})+|\d+)/gi;
155
+ /**
156
+ * The job value a class's conditions cap the work at, or null when they set no cap. A condition that says "up to",
157
+ * "limited to", "not exceeding" and the like before a dollar amount sets that cap. One that names Home Building
158
+ * Compensation (HBC) without an amount sets the HBC threshold.
159
+ */
160
+ export function licenceConditionCap(conditions) {
161
+ const caps = [];
162
+ for (const condition of conditions) {
163
+ const amounts = [...condition.matchAll(CAP_PHRASE)].map((match) => Number(match[1].replace(/,/g, "")));
164
+ if (amounts.length > 0)
165
+ caps.push(...amounts);
166
+ else if (/\bhbc\b|home\s+building\s+compensation/i.test(condition))
167
+ caps.push(T11_HBC_CAP);
168
+ }
169
+ return caps.length > 0 ? Math.min(...caps) : null;
170
+ }
171
+ export function serviceNeedsLicence(service) {
172
+ if (service.classes.length === 0)
173
+ return false;
174
+ return !(service.jobValueMax !== undefined && service.jobValueMax <= service.licenceAbove);
175
+ }
176
+ function sameClass(left, right) {
177
+ return left.trim().toLowerCase() === right.trim().toLowerCase();
178
+ }
179
+ /** Judges each service that needs a licence against the licences held on `asOf`. Pure. */
180
+ export function evaluateServiceClassGate(services, licences, asOf) {
181
+ return services.filter(serviceNeedsLicence).map((service) => {
182
+ const covering = licences.flatMap((licence) => licence.classes
183
+ .filter((heldClass) => service.classes.some((required) => sameClass(required, heldClass.name)))
184
+ .map((heldClass) => ({ licence, heldClass })));
185
+ const current = covering.filter(({ licence }) => !isExpired(licence, asOf));
186
+ if (current.length === 0) {
187
+ const lapsed = covering[0];
188
+ const problem = lapsed
189
+ ? `its licence ${lapsed.licence.identifier} (${lapsed.heldClass.name}) expired on ${lapsed.licence.expiresOn}`
190
+ : `no licence on record holds ${service.classes.length === 1 ? "the class" : "any of the classes"} ${service.classes.map((name) => `"${name}"`).join(" or ")}`;
191
+ return { service, cap: null, problem };
192
+ }
193
+ const caps = current.map(({ heldClass }) => licenceConditionCap(heldClass.conditions));
194
+ // One covering class with no cap means the service is not capped.
195
+ const cap = caps.some((value) => value === null) ? null : Math.max(...caps);
196
+ const first = current[0];
197
+ return { service, coveredBy: { licence: first.licence, className: first.heldClass.name }, cap };
198
+ });
199
+ }
200
+ /** `price`, `lowPrice` and `highPrice` values in a page's JSON-LD (the Offer data), as numbers. */
201
+ export function structuredDataPrices(root) {
202
+ const prices = [];
203
+ const visit = (value) => {
204
+ if (Array.isArray(value)) {
205
+ value.forEach(visit);
206
+ }
207
+ else if (value && typeof value === "object") {
208
+ for (const [key, child] of Object.entries(value)) {
209
+ if (["price", "lowPrice", "highPrice"].includes(key)) {
210
+ const amount = Number(String(child).replace(/[$,\s]/g, ""));
211
+ if (Number.isFinite(amount))
212
+ prices.push(amount);
213
+ }
214
+ else {
215
+ visit(child);
216
+ }
217
+ }
218
+ }
219
+ };
220
+ for (const script of descendants(root).filter((element) => element.tag === "script" && attr(element, "type") === "application/ld+json")) {
221
+ try {
222
+ visit(JSON.parse(script.children.map((child) => (child.type === "text" ? child.text : "")).join("")));
223
+ }
224
+ catch {
225
+ // A block that is not valid JSON has no prices to read.
226
+ }
227
+ }
228
+ return prices;
229
+ }
230
+ function dollars(amount) {
231
+ return `$${amount.toLocaleString("en-AU")}`;
232
+ }
233
+ export function checkT11(pages, projectRoot, asOf) {
234
+ const map = readServiceClassMap(projectRoot);
235
+ if (typeof map === "string")
236
+ return [fail("T11", T11_SERVICE_CLASS_MAP, map)];
237
+ const read = readLicences(projectRoot);
238
+ if (map === null) {
239
+ if (read.status === "none") {
240
+ return [notApplicable("T11", `${read.file} records no licence and the site has no ${T11_SERVICE_CLASS_MAP}, so no page advertises licensed work.`)];
241
+ }
242
+ // Licences on record (or licence data that cannot be read) without the map: the check cannot run.
243
+ return [];
244
+ }
245
+ const gated = map.filter(serviceNeedsLicence);
246
+ if (gated.length === 0)
247
+ return [notApplicable("T11", "None of the services on the service-to-class map needs a licence.")];
248
+ // Licence data that exists but cannot be read is not a pass: no finding, so the check shows as not run.
249
+ if (read.status === "unreadable")
250
+ return [];
251
+ const licences = read.status === "found" ? read.licences : [];
252
+ const failures = [];
253
+ for (const result of evaluateServiceClassGate(map, licences, asOf)) {
254
+ const { service } = result;
255
+ if (result.problem) {
256
+ failures.push(fail("T11", service.path, `${service.path} has a page for ${service.name}, but ${result.problem}; the page is held until the class is held.`));
257
+ continue;
258
+ }
259
+ if (result.cap === null)
260
+ continue;
261
+ const via = `the "${result.coveredBy.className}" licence ${result.coveredBy.licence.identifier}`;
262
+ if (service.jobValueMax !== undefined && service.jobValueMax > result.cap) {
263
+ failures.push(fail("T11", service.path, `${service.path} advertises ${service.name} jobs up to ${dollars(service.jobValueMax)}, above the ${dollars(result.cap)} limit on ${via}.`));
264
+ }
265
+ const page = pages.find((candidate) => candidate.path === service.path);
266
+ if (page) {
267
+ const shown = [...priceFigures(page.root), ...structuredDataPrices(page.root)].filter((amount) => amount > result.cap);
268
+ if (shown.length > 0) {
269
+ failures.push(fail("T11", service.path, `${service.path} shows a price of ${dollars(Math.max(...shown))}, above the ${dollars(result.cap)} limit on ${via}.`));
270
+ }
271
+ }
272
+ }
273
+ return resultOf("T11", failures, `Checked ${plural(gated.length, "service")} that need a licence: each is covered by a current class, and no price is above a licence limit.`);
274
+ }
275
+ //# sourceMappingURL=t11-licence-classes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"t11-licence-classes.js","sourceRoot":"","sources":["../../../../src/site-system/rules/enquiry/t11-licence-classes.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * T11: no page for work outside the licence classes the business holds, and the licence conditions carried\n * (Site System v2 §8.1, §13.2).\n *\n * Two things are read from the project:\n * - the licences in the evidence data (`src/data/businessEvidence.ts`, then `src/data/business.ts`), read as plain\n * data without running the site: `BrandlabLicence` classes, conditions and expiry;\n * - `docs/service-classes.json`, the service-to-class map: for each service the site publishes a page for, the\n * licence classes that cover it and the value above which the work needs a licence.\n *\n * A service whose work needs a licence is covered when a current licence holds one of its classes. An expired\n * licence covers nothing. A class condition that caps the job value (the Home Building Compensation limit of\n * $20,000, or any \"up to $N\") caps the price bands the service page may show. The rule is not applicable only when\n * it genuinely does not apply: the evidence is readable and records no licence (and there is no map), or no service\n * on the map needs a licence. When it cannot tell (licences on record but no map, or licence data it cannot read)\n * it returns no finding, so the check shows as not run rather than guess.\n *\n * `readLicences` is also the licence reader for T3, so the two checks always agree on what the business holds.\n */\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { licenceRenderState } from \"@brandlab-digital/site-core/business\";\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { fail, normalisePath, notApplicable, plural, resultOf, type ParsedPage } from \"./common.js\";\nimport { priceFigures } from \"./e9-cost-guides.js\";\nimport { attr, descendants, type HtmlElement } from \"./html.js\";\nimport { LiteralReader, isFullyResolved, isLiteralObject, type Literal } from \"./literal.js\";\n\nexport const T11_SERVICE_CLASS_MAP = \"docs/service-classes.json\";\nexport const T11_LICENCE_FILES = [\"src/data/businessEvidence.ts\", \"src/data/business.ts\"] as const;\n\n/** The Home Building Compensation threshold: a condition that names HBC without an amount caps the job at this. */\nexport const T11_HBC_CAP = 20_000;\n\nexport interface HeldLicenceClass {\n name: string;\n conditions: readonly string[];\n}\n\nexport interface HeldLicence {\n name: string;\n identifier: string;\n jurisdiction?: string;\n /** The licensee (`licensee`, or a legacy `holder`), shown beside the number when it differs from the trading name. */\n licensee?: string;\n classes: readonly HeldLicenceClass[];\n expiresOn?: string;\n checkedOn?: string;\n}\n\nexport interface ServiceClassEntry {\n path: string;\n name: string;\n /** Licence classes that cover the service. Any one is enough. Empty when the work needs no licence. */\n classes: readonly string[];\n /** The job value above which the work needs a licence. 0 (the default) means any value. */\n licenceAbove: number;\n /** The largest job the page advertises, when the site records it. */\n jobValueMax?: number;\n}\n\nexport type LicenceRead =\n | { status: \"found\"; licences: HeldLicence[]; file: string }\n | { status: \"none\"; file: string }\n | { status: \"unreadable\"; reason: string };\n\nfunction readStringArray(value: Literal | undefined): string[] | null {\n if (value === undefined) return [];\n return Array.isArray(value) && value.every((item): item is string => typeof item === \"string\") ? value : null;\n}\n\nfunction toHeldLicence(value: Literal): HeldLicence | null {\n if (!isLiteralObject(value) || typeof value[\"name\"] !== \"string\" || typeof value[\"identifier\"] !== \"string\") return null;\n const rawClasses = value[\"classes\"];\n let classes: HeldLicenceClass[];\n if (rawClasses === undefined) {\n // A licence recorded before 3.8.0 names its work in `name`.\n classes = [{ name: value[\"name\"], conditions: [] }];\n } else if (Array.isArray(rawClasses)) {\n classes = [];\n for (const item of rawClasses) {\n if (!isLiteralObject(item) || typeof item[\"name\"] !== \"string\") return null;\n const conditions = readStringArray(item[\"conditions\"]);\n if (conditions === null) return null;\n classes.push({ name: item[\"name\"], conditions });\n }\n } else {\n return null;\n }\n const optional = (key: string): string | undefined => (typeof value[key] === \"string\" && value[key] !== \"\" ? (value[key] as string) : undefined);\n const jurisdiction = optional(\"jurisdiction\");\n const licensee = optional(\"licensee\") ?? optional(\"holder\");\n const expiresOn = optional(\"expiresOn\");\n const checkedOn = optional(\"checkedOn\");\n return {\n name: value[\"name\"],\n identifier: value[\"identifier\"],\n ...(jurisdiction ? { jurisdiction } : {}),\n ...(licensee ? { licensee } : {}),\n classes,\n ...(expiresOn ? { expiresOn } : {}),\n ...(checkedOn ? { checkedOn } : {}),\n };\n}\n\n/** Reads the licences from the evidence data files without running them. */\nexport function readLicences(projectRoot: string): LicenceRead {\n let unreadable: string | null = null;\n for (const file of T11_LICENCE_FILES) {\n const path = join(projectRoot, file);\n if (!existsSync(path)) continue;\n const reader = new LiteralReader(readFileSync(path, \"utf8\"), file);\n for (const expression of reader.propertyValues(\"licences\")) {\n let value = reader.evaluate(expression);\n if (isLiteralObject(value) && typeof value[\"status\"] === \"string\") {\n if (value[\"status\"] === \"unknown\" || value[\"status\"] === \"not-applicable\") return { status: \"none\", file };\n value = (value[\"value\"] ?? null) as Literal;\n }\n if (!Array.isArray(value)) continue;\n if (!isFullyResolved(value)) {\n unreadable = `${file} lists licences that are not plain data.`;\n continue;\n }\n const licences = value.map(toHeldLicence);\n if (licences.some((licence) => licence === null)) {\n unreadable = `${file} lists a licence without a name and number.`;\n continue;\n }\n return licences.length === 0 ? { status: \"none\", file } : { status: \"found\", licences: licences as HeldLicence[], file };\n }\n }\n return { status: \"unreadable\", reason: unreadable ?? \"No licence data was found in the evidence files.\" };\n}\n\n/** Reads the service-to-class map. Returns null when it does not exist and a string when it cannot be read. */\nexport function readServiceClassMap(projectRoot: string): ServiceClassEntry[] | string | null {\n const file = join(projectRoot, T11_SERVICE_CLASS_MAP);\n if (!existsSync(file)) return null;\n let parsed: unknown;\n try {\n parsed = JSON.parse(readFileSync(file, \"utf8\"));\n } catch {\n return `${T11_SERVICE_CLASS_MAP} is not valid JSON.`;\n }\n const services = (parsed as { services?: unknown } | null)?.services;\n if (!Array.isArray(services)) return `${T11_SERVICE_CLASS_MAP} must be an object with a \"services\" list.`;\n const entries: ServiceClassEntry[] = [];\n for (const [index, service] of services.entries()) {\n const entry = service as Record<string, unknown> | null;\n const where = `${T11_SERVICE_CLASS_MAP} services[${index}]`;\n if (!entry || typeof entry !== \"object\") return `${where} must be an object.`;\n if (typeof entry[\"path\"] !== \"string\" || !entry[\"path\"].startsWith(\"/\")) return `${where} needs a \"path\" that starts with \"/\".`;\n if (typeof entry[\"name\"] !== \"string\" || !entry[\"name\"].trim()) return `${where} needs a \"name\".`;\n const classes = entry[\"classes\"];\n if (!Array.isArray(classes) || !classes.every((item) => typeof item === \"string\" && item.trim())) {\n return `${where} needs \"classes\": a list of licence class names, empty when the work needs no licence.`;\n }\n for (const key of [\"licenceAbove\", \"jobValueMax\"] as const) {\n const value = entry[key];\n if (value !== undefined && (typeof value !== \"number\" || !Number.isFinite(value) || value < 0)) {\n return `${where} has a \"${key}\" that is not a dollar amount.`;\n }\n }\n entries.push({\n path: normalisePath(entry[\"path\"]),\n name: entry[\"name\"],\n classes: classes as string[],\n licenceAbove: (entry[\"licenceAbove\"] as number | undefined) ?? 0,\n ...(typeof entry[\"jobValueMax\"] === \"number\" ? { jobValueMax: entry[\"jobValueMax\"] } : {}),\n });\n }\n return entries;\n}\n\n/** Whether a licence has expired on `asOf` (today in Sydney is after its expiry date), by site-core's licenceRenderState. */\nexport function isExpired(licence: Pick<HeldLicence, \"expiresOn\">, asOf: Date): boolean {\n return licenceRenderState(licence.expiresOn === undefined ? {} : { expiresOn: licence.expiresOn }, asOf).state === \"expired\";\n}\n\nconst CAP_PHRASE =\n /(?:up\\s+to|under|below|less\\s+than|not\\s+(?:exceeding|more\\s+than|over)|no\\s+more\\s+than|(?:work|jobs?)\\s+(?:valued\\s+)?(?:over|above|exceeding)|limit(?:ed)?(?:\\s+(?:to|of))?|cap(?:ped)?(?:\\s+(?:at|of))?|maximum(?:\\s+(?:of|value))?|max\\.?)[^$\\d]{0,60}\\$\\s?(\\d{1,3}(?:,\\d{3})+|\\d+)/gi;\n\n/**\n * The job value a class's conditions cap the work at, or null when they set no cap. A condition that says \"up to\",\n * \"limited to\", \"not exceeding\" and the like before a dollar amount sets that cap. One that names Home Building\n * Compensation (HBC) without an amount sets the HBC threshold.\n */\nexport function licenceConditionCap(conditions: readonly string[]): number | null {\n const caps: number[] = [];\n for (const condition of conditions) {\n const amounts = [...condition.matchAll(CAP_PHRASE)].map((match) => Number(match[1]!.replace(/,/g, \"\")));\n if (amounts.length > 0) caps.push(...amounts);\n else if (/\\bhbc\\b|home\\s+building\\s+compensation/i.test(condition)) caps.push(T11_HBC_CAP);\n }\n return caps.length > 0 ? Math.min(...caps) : null;\n}\n\nexport function serviceNeedsLicence(service: ServiceClassEntry): boolean {\n if (service.classes.length === 0) return false;\n return !(service.jobValueMax !== undefined && service.jobValueMax <= service.licenceAbove);\n}\n\nfunction sameClass(left: string, right: string): boolean {\n return left.trim().toLowerCase() === right.trim().toLowerCase();\n}\n\nexport interface ServiceGateResult {\n service: ServiceClassEntry;\n /** Set when the service is covered by a current licence class. */\n coveredBy?: { licence: HeldLicence; className: string };\n /** The dollar cap the covering class's conditions set, when they set one. */\n cap: number | null;\n /** Why the service is not covered, in plain words. */\n problem?: string;\n}\n\n/** Judges each service that needs a licence against the licences held on `asOf`. Pure. */\nexport function evaluateServiceClassGate(\n services: readonly ServiceClassEntry[],\n licences: readonly HeldLicence[],\n asOf: Date,\n): ServiceGateResult[] {\n return services.filter(serviceNeedsLicence).map((service): ServiceGateResult => {\n const covering = licences.flatMap((licence) =>\n licence.classes\n .filter((heldClass) => service.classes.some((required) => sameClass(required, heldClass.name)))\n .map((heldClass) => ({ licence, heldClass })),\n );\n const current = covering.filter(({ licence }) => !isExpired(licence, asOf));\n if (current.length === 0) {\n const lapsed = covering[0];\n const problem = lapsed\n ? `its licence ${lapsed.licence.identifier} (${lapsed.heldClass.name}) expired on ${lapsed.licence.expiresOn}`\n : `no licence on record holds ${service.classes.length === 1 ? \"the class\" : \"any of the classes\"} ${service.classes.map((name) => `\"${name}\"`).join(\" or \")}`;\n return { service, cap: null, problem };\n }\n const caps = current.map(({ heldClass }) => licenceConditionCap(heldClass.conditions));\n // One covering class with no cap means the service is not capped.\n const cap = caps.some((value) => value === null) ? null : Math.max(...(caps as number[]));\n const first = current[0]!;\n return { service, coveredBy: { licence: first.licence, className: first.heldClass.name }, cap };\n });\n}\n\n/** `price`, `lowPrice` and `highPrice` values in a page's JSON-LD (the Offer data), as numbers. */\nexport function structuredDataPrices(root: HtmlElement): number[] {\n const prices: number[] = [];\n const visit = (value: unknown) => {\n if (Array.isArray(value)) {\n value.forEach(visit);\n } else if (value && typeof value === \"object\") {\n for (const [key, child] of Object.entries(value)) {\n if ([\"price\", \"lowPrice\", \"highPrice\"].includes(key)) {\n const amount = Number(String(child).replace(/[$,\\s]/g, \"\"));\n if (Number.isFinite(amount)) prices.push(amount);\n } else {\n visit(child);\n }\n }\n }\n };\n for (const script of descendants(root).filter((element) => element.tag === \"script\" && attr(element, \"type\") === \"application/ld+json\")) {\n try {\n visit(JSON.parse(script.children.map((child) => (child.type === \"text\" ? child.text : \"\")).join(\"\")));\n } catch {\n // A block that is not valid JSON has no prices to read.\n }\n }\n return prices;\n}\n\nfunction dollars(amount: number): string {\n return `$${amount.toLocaleString(\"en-AU\")}`;\n}\n\nexport function checkT11(pages: readonly ParsedPage[], projectRoot: string, asOf: Date): SiteSystemFinding[] {\n const map = readServiceClassMap(projectRoot);\n if (typeof map === \"string\") return [fail(\"T11\", T11_SERVICE_CLASS_MAP, map)];\n const read = readLicences(projectRoot);\n if (map === null) {\n if (read.status === \"none\") {\n return [notApplicable(\"T11\", `${read.file} records no licence and the site has no ${T11_SERVICE_CLASS_MAP}, so no page advertises licensed work.`)];\n }\n // Licences on record (or licence data that cannot be read) without the map: the check cannot run.\n return [];\n }\n\n const gated = map.filter(serviceNeedsLicence);\n if (gated.length === 0) return [notApplicable(\"T11\", \"None of the services on the service-to-class map needs a licence.\")];\n // Licence data that exists but cannot be read is not a pass: no finding, so the check shows as not run.\n if (read.status === \"unreadable\") return [];\n const licences = read.status === \"found\" ? read.licences : [];\n\n const failures: SiteSystemFinding[] = [];\n for (const result of evaluateServiceClassGate(map, licences, asOf)) {\n const { service } = result;\n if (result.problem) {\n failures.push(fail(\"T11\", service.path, `${service.path} has a page for ${service.name}, but ${result.problem}; the page is held until the class is held.`));\n continue;\n }\n if (result.cap === null) continue;\n const via = `the \"${result.coveredBy!.className}\" licence ${result.coveredBy!.licence.identifier}`;\n if (service.jobValueMax !== undefined && service.jobValueMax > result.cap) {\n failures.push(fail(\"T11\", service.path, `${service.path} advertises ${service.name} jobs up to ${dollars(service.jobValueMax)}, above the ${dollars(result.cap)} limit on ${via}.`));\n }\n const page = pages.find((candidate) => candidate.path === service.path);\n if (page) {\n const shown = [...priceFigures(page.root), ...structuredDataPrices(page.root)].filter((amount) => amount > result.cap!);\n if (shown.length > 0) {\n failures.push(fail(\"T11\", service.path, `${service.path} shows a price of ${dollars(Math.max(...shown))}, above the ${dollars(result.cap)} limit on ${via}.`));\n }\n }\n }\n return resultOf(\n \"T11\",\n failures,\n `Checked ${plural(gated.length, \"service\")} that need a licence: each is covered by a current class, and no price is above a licence limit.`,\n );\n}\n"]}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * T5: reviews come from the Google feed, with Google's rating, count and link beside any list (Site System v2 §13.2).
3
+ *
4
+ * A reviews section (`data-bl-section="reviews"`) shows the feed's summary rating, its review count and a link to
5
+ * Google beside any reviews it lists. There is no reviews section on the contact page, and no empty one. The rule has
6
+ * no exclusion clause: it judges what the feed rendered, and the library does not hide or tag reviews (D6).
7
+ */
8
+ import type { SiteSystemFinding } from "../../checks.js";
9
+ import { type ParsedPage } from "./common.js";
10
+ import { type HtmlElement } from "./html.js";
11
+ export declare const T5_SECTION_ID = "reviews";
12
+ export declare function isContactPath(path: string): boolean;
13
+ export declare function isGoogleLink(href: string): boolean;
14
+ export interface ReviewsSectionReport {
15
+ entries: number;
16
+ hasRating: boolean;
17
+ hasCount: boolean;
18
+ hasGoogleLink: boolean;
19
+ source: string | undefined;
20
+ }
21
+ export declare function inspectReviewsSection(section: HtmlElement): ReviewsSectionReport;
22
+ export declare function checkT5(pages: readonly ParsedPage[]): SiteSystemFinding[];
23
+ //# sourceMappingURL=t5-reviews.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"t5-reviews.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/enquiry/t5-reviews.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAAoD,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAChG,OAAO,EAA6B,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAExE,eAAO,MAAM,aAAa,YAAY,CAAC;AAMvC,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEnD;AAID,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAMlD;AAkCD,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,aAAa,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;CAC5B;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,WAAW,GAAG,oBAAoB,CAWhF;AAED,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,GAAG,iBAAiB,EAAE,CA+BzE"}
@@ -0,0 +1,91 @@
1
+ import { fail, notApplicable, plural, resultOf, sectionId } from "./common.js";
2
+ import { attr, descendants, textOf } from "./html.js";
3
+ export const T5_SECTION_ID = "reviews";
4
+ /** Pages that never carry reviews: the contact page, and any thank-you page (`/thank-you`, `/contact/thank-you`). */
5
+ const CONTACT_PATH = /^\/(?:contact|contact-us|get-in-touch)(?:\/|$)/;
6
+ const THANK_YOU_PATH = /(?:^|\/)(?:thank-you|thankyou|thanks)(?:\/|$)/;
7
+ export function isContactPath(path) {
8
+ return CONTACT_PATH.test(path) || THANK_YOU_PATH.test(path);
9
+ }
10
+ const GOOGLE_HOST = /^(?:[a-z0-9-]+\.)*(?:google\.(?:com(?:\.au)?|co\.[a-z]{2})|g\.page|g\.co|goo\.gl|share\.google)$/i;
11
+ export function isGoogleLink(href) {
12
+ try {
13
+ return GOOGLE_HOST.test(new URL(href, "https://page.invalid").hostname);
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ const RATING = /(?:\b[1-5](?:\.\d)?\s*(?:\/\s*5\b|out\s+of\s+5\b|stars?\b|★)|\brated\s+[1-5](?:\.\d)?\b|\b[1-5]\.\d\b|★\s*[1-5](?:\.\d)?\b)/i;
20
+ const COUNT = /\b\d[\d,]*\s*(?:google\s+)?reviews?\b|\bbased\s+on\s+\d[\d,]*\b|\b\d[\d,]*\s+ratings?\b/i;
21
+ function isReviewBody(element) {
22
+ return (attr(element, "data-slot") ?? "").split(/\s+/).includes("review") || element.tag === "blockquote" || element.tag === "article";
23
+ }
24
+ /** The reviews listed in a section: review slots, quotes and articles. */
25
+ function listedReviews(section) {
26
+ return descendants(section).filter(isReviewBody);
27
+ }
28
+ function insideReviewBody(element, section) {
29
+ for (let parent = element; parent && parent !== section; parent = parent.parent) {
30
+ if (isReviewBody(parent))
31
+ return true;
32
+ }
33
+ return false;
34
+ }
35
+ /**
36
+ * The section's text plus the accessible names in it, where a star rating often keeps its number, leaving out the
37
+ * listed reviews themselves: a review that says "5 stars" or "after 20 reviews" is not Google's rating or count.
38
+ */
39
+ function summaryText(section) {
40
+ const names = descendants(section)
41
+ .filter((element) => !insideReviewBody(element, section))
42
+ .map((element) => attr(element, "aria-label") ?? attr(element, "title") ?? "")
43
+ .filter(Boolean);
44
+ return `${textOf(section, true, isReviewBody)} ${names.join(" ")}`;
45
+ }
46
+ export function inspectReviewsSection(section) {
47
+ const text = summaryText(section);
48
+ const outside = descendants(section).filter((element) => !insideReviewBody(element, section));
49
+ const countSlot = outside.find((element) => attr(element, "data-slot") === "count");
50
+ return {
51
+ entries: listedReviews(section).length,
52
+ hasRating: RATING.test(text),
53
+ hasCount: COUNT.test(text) || (countSlot !== undefined && /\d/.test(textOf(countSlot, true))),
54
+ hasGoogleLink: outside.some((element) => element.tag === "a" && isGoogleLink(attr(element, "href") ?? "")),
55
+ source: attr(section, "data-bl-source"),
56
+ };
57
+ }
58
+ export function checkT5(pages) {
59
+ const failures = [];
60
+ let sections = 0;
61
+ for (const page of pages) {
62
+ // Every reviews section counts, including one nested inside another section.
63
+ for (const section of descendants(page.root).filter((candidate) => sectionId(candidate) === T5_SECTION_ID)) {
64
+ sections += 1;
65
+ if (isContactPath(page.path)) {
66
+ failures.push(fail("T5", page.path, `${page.path} is a contact or thank-you page with a reviews section; reviews do not belong on it.`));
67
+ continue;
68
+ }
69
+ const report = inspectReviewsSection(section);
70
+ if (report.source !== undefined && report.source !== "feed") {
71
+ failures.push(fail("T5", page.path, `The reviews section on ${page.path} is from "${report.source}"; reviews come only from the Google feed.`));
72
+ }
73
+ if (report.entries === 0 && !report.hasRating && !report.hasCount && !report.hasGoogleLink) {
74
+ failures.push(fail("T5", page.path, `The reviews section on ${page.path} is empty; a page with no reviews to show has no reviews section.`));
75
+ continue;
76
+ }
77
+ const missing = [
78
+ report.hasRating ? null : "Google's rating",
79
+ report.hasCount ? null : "the review count",
80
+ report.hasGoogleLink ? null : "the link to Google",
81
+ ].filter((part) => part !== null);
82
+ if (missing.length > 0) {
83
+ failures.push(fail("T5", page.path, `The reviews section on ${page.path} is missing ${missing.join(", ")}; the feed's summary sits beside any list of reviews.`));
84
+ }
85
+ }
86
+ }
87
+ if (sections === 0)
88
+ return [notApplicable("T5", "None of the sampled pages has a reviews section.")];
89
+ return resultOf("T5", failures, `Checked ${plural(sections, "reviews section")}: each shows the feed's rating, count and Google link, and none is on the contact page or empty.`);
90
+ }
91
+ //# sourceMappingURL=t5-reviews.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"t5-reviews.js","sourceRoot":"","sources":["../../../../src/site-system/rules/enquiry/t5-reviews.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAmB,MAAM,aAAa,CAAC;AAChG,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAoB,MAAM,WAAW,CAAC;AAExE,MAAM,CAAC,MAAM,aAAa,GAAG,SAAS,CAAC;AAEvC,qHAAqH;AACrH,MAAM,YAAY,GAAG,gDAAgD,CAAC;AACtE,MAAM,cAAc,GAAG,+CAA+C,CAAC;AAEvE,MAAM,UAAU,aAAa,CAAC,IAAY;IACxC,OAAO,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC9D,CAAC;AAED,MAAM,WAAW,GAAG,mGAAmG,CAAC;AAExH,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,IAAI,CAAC;QACH,OAAO,WAAW,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,IAAI,EAAE,sBAAsB,CAAC,CAAC,QAAQ,CAAC,CAAC;IAC1E,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,MAAM,MAAM,GACV,8HAA8H,CAAC;AACjI,MAAM,KAAK,GAAG,0FAA0F,CAAC;AAEzG,SAAS,YAAY,CAAC,OAAoB;IACxC,OAAO,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,OAAO,CAAC,GAAG,KAAK,YAAY,IAAI,OAAO,CAAC,GAAG,KAAK,SAAS,CAAC;AACzI,CAAC;AAED,0EAA0E;AAC1E,SAAS,aAAa,CAAC,OAAoB;IACzC,OAAO,WAAW,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;AACnD,CAAC;AAED,SAAS,gBAAgB,CAAC,OAAoB,EAAE,OAAoB;IAClE,KAAK,IAAI,MAAM,GAAuB,OAAO,EAAE,MAAM,IAAI,MAAM,KAAK,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;QACpG,IAAI,YAAY,CAAC,MAAM,CAAC;YAAE,OAAO,IAAI,CAAC;IACxC,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;GAGG;AACH,SAAS,WAAW,CAAC,OAAoB;IACvC,MAAM,KAAK,GAAG,WAAW,CAAC,OAAO,CAAC;SAC/B,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,gBAAgB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;SACxD,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,YAAY,CAAC,IAAI,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC;SAC7E,MAAM,CAAC,OAAO,CAAC,CAAC;IACnB,OAAO,GAAG,MAAM,CAAC,OAAO,EAAE,IAAI,EAAE,YAAY,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;AACrE,CAAC;AAUD,MAAM,UAAU,qBAAqB,CAAC,OAAoB;IACxD,MAAM,IAAI,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC;IAClC,MAAM,OAAO,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,gBAAgB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;IAC9F,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,KAAK,OAAO,CAAC,CAAC;IACpF,OAAO;QACL,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC,MAAM;QACtC,SAAS,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC;QAC5B,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,KAAK,SAAS,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC;QAC7F,aAAa,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,KAAK,GAAG,IAAI,YAAY,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;QAC1G,MAAM,EAAE,IAAI,CAAC,OAAO,EAAE,gBAAgB,CAAC;KACxC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,KAA4B;IAClD,MAAM,QAAQ,GAAwB,EAAE,CAAC;IACzC,IAAI,QAAQ,GAAG,CAAC,CAAC;IACjB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,6EAA6E;QAC7E,KAAK,MAAM,OAAO,IAAI,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,SAAS,CAAC,KAAK,aAAa,CAAC,EAAE,CAAC;YAC3G,QAAQ,IAAI,CAAC,CAAC;YACd,IAAI,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC7B,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,IAAI,sFAAsF,CAAC,CAAC,CAAC;gBACzI,SAAS;YACX,CAAC;YACD,MAAM,MAAM,GAAG,qBAAqB,CAAC,OAAO,CAAC,CAAC;YAC9C,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS,IAAI,MAAM,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;gBAC5D,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,0BAA0B,IAAI,CAAC,IAAI,aAAa,MAAM,CAAC,MAAM,4CAA4C,CAAC,CAAC,CAAC;YAClJ,CAAC;YACD,IAAI,MAAM,CAAC,OAAO,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,SAAS,IAAI,CAAC,MAAM,CAAC,QAAQ,IAAI,CAAC,MAAM,CAAC,aAAa,EAAE,CAAC;gBAC3F,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,0BAA0B,IAAI,CAAC,IAAI,mEAAmE,CAAC,CAAC,CAAC;gBAC7I,SAAS;YACX,CAAC;YACD,MAAM,OAAO,GAAG;gBACd,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,iBAAiB;gBAC3C,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,kBAAkB;gBAC3C,MAAM,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,oBAAoB;aACnD,CAAC,MAAM,CAAC,CAAC,IAAI,EAAkB,EAAE,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC;YAClD,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACvB,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,0BAA0B,IAAI,CAAC,IAAI,eAAe,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,uDAAuD,CAAC,CAAC,CAAC;YACpK,CAAC;QACH,CAAC;IACH,CAAC;IACD,IAAI,QAAQ,KAAK,CAAC;QAAE,OAAO,CAAC,aAAa,CAAC,IAAI,EAAE,kDAAkD,CAAC,CAAC,CAAC;IACrG,OAAO,QAAQ,CAAC,IAAI,EAAE,QAAQ,EAAE,WAAW,MAAM,CAAC,QAAQ,EAAE,iBAAiB,CAAC,kGAAkG,CAAC,CAAC;AACpL,CAAC","sourcesContent":["/**\n * T5: reviews come from the Google feed, with Google's rating, count and link beside any list (Site System v2 §13.2).\n *\n * A reviews section (`data-bl-section=\"reviews\"`) shows the feed's summary rating, its review count and a link to\n * Google beside any reviews it lists. There is no reviews section on the contact page, and no empty one. The rule has\n * no exclusion clause: it judges what the feed rendered, and the library does not hide or tag reviews (D6).\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { fail, notApplicable, plural, resultOf, sectionId, type ParsedPage } from \"./common.js\";\nimport { attr, descendants, textOf, type HtmlElement } from \"./html.js\";\n\nexport const T5_SECTION_ID = \"reviews\";\n\n/** Pages that never carry reviews: the contact page, and any thank-you page (`/thank-you`, `/contact/thank-you`). */\nconst CONTACT_PATH = /^\\/(?:contact|contact-us|get-in-touch)(?:\\/|$)/;\nconst THANK_YOU_PATH = /(?:^|\\/)(?:thank-you|thankyou|thanks)(?:\\/|$)/;\n\nexport function isContactPath(path: string): boolean {\n return CONTACT_PATH.test(path) || THANK_YOU_PATH.test(path);\n}\n\nconst GOOGLE_HOST = /^(?:[a-z0-9-]+\\.)*(?:google\\.(?:com(?:\\.au)?|co\\.[a-z]{2})|g\\.page|g\\.co|goo\\.gl|share\\.google)$/i;\n\nexport function isGoogleLink(href: string): boolean {\n try {\n return GOOGLE_HOST.test(new URL(href, \"https://page.invalid\").hostname);\n } catch {\n return false;\n }\n}\n\nconst RATING =\n /(?:\\b[1-5](?:\\.\\d)?\\s*(?:\\/\\s*5\\b|out\\s+of\\s+5\\b|stars?\\b|★)|\\brated\\s+[1-5](?:\\.\\d)?\\b|\\b[1-5]\\.\\d\\b|★\\s*[1-5](?:\\.\\d)?\\b)/i;\nconst COUNT = /\\b\\d[\\d,]*\\s*(?:google\\s+)?reviews?\\b|\\bbased\\s+on\\s+\\d[\\d,]*\\b|\\b\\d[\\d,]*\\s+ratings?\\b/i;\n\nfunction isReviewBody(element: HtmlElement): boolean {\n return (attr(element, \"data-slot\") ?? \"\").split(/\\s+/).includes(\"review\") || element.tag === \"blockquote\" || element.tag === \"article\";\n}\n\n/** The reviews listed in a section: review slots, quotes and articles. */\nfunction listedReviews(section: HtmlElement): HtmlElement[] {\n return descendants(section).filter(isReviewBody);\n}\n\nfunction insideReviewBody(element: HtmlElement, section: HtmlElement): boolean {\n for (let parent: HtmlElement | null = element; parent && parent !== section; parent = parent.parent) {\n if (isReviewBody(parent)) return true;\n }\n return false;\n}\n\n/**\n * The section's text plus the accessible names in it, where a star rating often keeps its number, leaving out the\n * listed reviews themselves: a review that says \"5 stars\" or \"after 20 reviews\" is not Google's rating or count.\n */\nfunction summaryText(section: HtmlElement): string {\n const names = descendants(section)\n .filter((element) => !insideReviewBody(element, section))\n .map((element) => attr(element, \"aria-label\") ?? attr(element, \"title\") ?? \"\")\n .filter(Boolean);\n return `${textOf(section, true, isReviewBody)} ${names.join(\" \")}`;\n}\n\nexport interface ReviewsSectionReport {\n entries: number;\n hasRating: boolean;\n hasCount: boolean;\n hasGoogleLink: boolean;\n source: string | undefined;\n}\n\nexport function inspectReviewsSection(section: HtmlElement): ReviewsSectionReport {\n const text = summaryText(section);\n const outside = descendants(section).filter((element) => !insideReviewBody(element, section));\n const countSlot = outside.find((element) => attr(element, \"data-slot\") === \"count\");\n return {\n entries: listedReviews(section).length,\n hasRating: RATING.test(text),\n hasCount: COUNT.test(text) || (countSlot !== undefined && /\\d/.test(textOf(countSlot, true))),\n hasGoogleLink: outside.some((element) => element.tag === \"a\" && isGoogleLink(attr(element, \"href\") ?? \"\")),\n source: attr(section, \"data-bl-source\"),\n };\n}\n\nexport function checkT5(pages: readonly ParsedPage[]): SiteSystemFinding[] {\n const failures: SiteSystemFinding[] = [];\n let sections = 0;\n for (const page of pages) {\n // Every reviews section counts, including one nested inside another section.\n for (const section of descendants(page.root).filter((candidate) => sectionId(candidate) === T5_SECTION_ID)) {\n sections += 1;\n if (isContactPath(page.path)) {\n failures.push(fail(\"T5\", page.path, `${page.path} is a contact or thank-you page with a reviews section; reviews do not belong on it.`));\n continue;\n }\n const report = inspectReviewsSection(section);\n if (report.source !== undefined && report.source !== \"feed\") {\n failures.push(fail(\"T5\", page.path, `The reviews section on ${page.path} is from \"${report.source}\"; reviews come only from the Google feed.`));\n }\n if (report.entries === 0 && !report.hasRating && !report.hasCount && !report.hasGoogleLink) {\n failures.push(fail(\"T5\", page.path, `The reviews section on ${page.path} is empty; a page with no reviews to show has no reviews section.`));\n continue;\n }\n const missing = [\n report.hasRating ? null : \"Google's rating\",\n report.hasCount ? null : \"the review count\",\n report.hasGoogleLink ? null : \"the link to Google\",\n ].filter((part): part is string => part !== null);\n if (missing.length > 0) {\n failures.push(fail(\"T5\", page.path, `The reviews section on ${page.path} is missing ${missing.join(\", \")}; the feed's summary sits beside any list of reviews.`));\n }\n }\n }\n if (sections === 0) return [notApplicable(\"T5\", \"None of the sampled pages has a reviews section.\")];\n return resultOf(\"T5\", failures, `Checked ${plural(sections, \"reviews section\")}: each shows the feed's rating, count and Google link, and none is on the contact page or empty.`);\n}\n"]}
@@ -0,0 +1,5 @@
1
+ import type { SiteSystemFinding } from "../../checks.js";
2
+ import { type ParsedPage } from "../enquiry/common.js";
3
+ export declare const C11_ROUTE_CANDIDATES: readonly ["src/app/api/contact/route", "app/api/contact/route"];
4
+ export declare function checkC11(projectRoot: string, pages?: readonly ParsedPage[]): SiteSystemFinding[];
5
+ //# sourceMappingURL=c11-contact-route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"c11-contact-route.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/form/c11-contact-route.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAA6B,KAAK,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAKlF,eAAO,MAAM,oBAAoB,iEAGvB,CAAC;AAiFX,wBAAgB,QAAQ,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,GAAE,SAAS,UAAU,EAAO,GAAG,iBAAiB,EAAE,CAsBpG"}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * C11 (static half): the contact route keeps the shared handler's protections (Site System v2 §13.2).
3
+ *
4
+ * `POST /api/contact` must come from the shared handler (`createBrandlabContactRouteHandler`, or
5
+ * `createBrandlabContactRuntime` during the transition, the same evidence as brandlab-verify's
6
+ * contact-provider-route rule). The shared handler strips the `bl_test` marker before delivery and reporting, so a test
7
+ * enquiry is never delivered as a real one, and it rate-limits abuse per IP and per email address. A route that
8
+ * writes its own handler has neither, and a route that passes `rateLimit: false` (or turns off both the per-IP and
9
+ * per-address limits) has switched the second one off.
10
+ *
11
+ * The route file is read from the syntax tree, never run; one hop of local imports is followed because the handler is
12
+ * often created in a `src/config/contact.ts` the route re-exports.
13
+ */
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { dirname, join, resolve } from "node:path";
16
+ import ts from "typescript";
17
+ import { fail, notApplicable, pass } from "../enquiry/common.js";
18
+ import { isQuoteForm } from "../enquiry/c2-quote-form.js";
19
+ import { descendants } from "../enquiry/html.js";
20
+ import { LiteralReader, isLiteralObject } from "../enquiry/literal.js";
21
+ export const C11_ROUTE_CANDIDATES = [
22
+ "src/app/api/contact/route",
23
+ "app/api/contact/route",
24
+ ];
25
+ const EXTENSIONS = [".ts", ".tsx", ".mts", ".js", ".mjs"];
26
+ const SHARED_HANDLERS = new Set(["createBrandlabContactRouteHandler", "createBrandlabContactRuntime"]);
27
+ const SHARED_MODULE = /^@brandlab-digital\/site-next\/contact(?:\/|$)/;
28
+ function fileWithExtension(base) {
29
+ for (const extension of EXTENSIONS)
30
+ if (existsSync(base + extension))
31
+ return base + extension;
32
+ for (const extension of EXTENSIONS)
33
+ if (existsSync(join(base, `index${extension}`)))
34
+ return join(base, `index${extension}`);
35
+ return existsSync(base) && /\.[a-z]+$/.test(base) ? base : undefined;
36
+ }
37
+ function readModule(file) {
38
+ try {
39
+ const text = readFileSync(file, "utf8");
40
+ return { file, text, source: ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) };
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ /** The names a module imports from the shared contact entry points, by local name. */
47
+ function sharedImports(module) {
48
+ const names = new Map();
49
+ for (const statement of module.source.statements) {
50
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
51
+ continue;
52
+ if (!SHARED_MODULE.test(statement.moduleSpecifier.text))
53
+ continue;
54
+ const bindings = statement.importClause?.namedBindings;
55
+ if (bindings && ts.isNamedImports(bindings)) {
56
+ for (const element of bindings.elements)
57
+ names.set(element.name.text, (element.propertyName ?? element.name).text);
58
+ }
59
+ }
60
+ return names;
61
+ }
62
+ function usesSharedHandler(module) {
63
+ const imported = sharedImports(module);
64
+ let used = false;
65
+ const visit = (node) => {
66
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
67
+ const original = imported.get(node.expression.text);
68
+ if (original !== undefined && SHARED_HANDLERS.has(original))
69
+ used = true;
70
+ }
71
+ if (!used)
72
+ ts.forEachChild(node, visit);
73
+ };
74
+ visit(module.source);
75
+ return used;
76
+ }
77
+ function localImports(module, projectRoot) {
78
+ const found = [];
79
+ for (const statement of module.source.statements) {
80
+ if (!(ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) || !statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier))
81
+ continue;
82
+ const specifier = statement.moduleSpecifier.text;
83
+ let base;
84
+ if (specifier.startsWith("."))
85
+ base = resolve(dirname(module.file), specifier);
86
+ else if (specifier.startsWith("@/"))
87
+ base = join(projectRoot, "src", specifier.slice(2));
88
+ else
89
+ continue;
90
+ const file = fileWithExtension(base);
91
+ const imported = file ? readModule(file) : undefined;
92
+ if (imported)
93
+ found.push(imported);
94
+ }
95
+ return found;
96
+ }
97
+ /** What a `rateLimit` option turns off: "all" for `false` (or both limits off), null when the limits stay on. */
98
+ function rateLimitOff(module) {
99
+ const reader = new LiteralReader(module.text, module.file);
100
+ for (const expression of reader.propertyValues("rateLimit")) {
101
+ const value = reader.evaluate(expression);
102
+ if (value === false)
103
+ return "rateLimit: false";
104
+ if (isLiteralObject(value) && value.perIp === false && value.perAddress === false)
105
+ return "both the per-IP and per-address limits set to false";
106
+ }
107
+ return null;
108
+ }
109
+ export function checkC11(projectRoot, pages = []) {
110
+ const routeBase = C11_ROUTE_CANDIDATES.map((base) => join(projectRoot, base)).map(fileWithExtension).find((file) => file !== undefined);
111
+ if (!routeBase) {
112
+ const hasForm = pages.some((page) => descendants(page.root).some((element) => element.tag === "form" && isQuoteForm(element)));
113
+ return hasForm
114
+ ? [fail("C11", C11_ROUTE_CANDIDATES[0] + ".ts", "The site has a quote form but no /api/contact route, so enquiries have no shared handler, test-marker handling or rate limits.")]
115
+ : [notApplicable("C11", "None of the sampled pages has a quote form and the project has no /api/contact route.")];
116
+ }
117
+ const route = readModule(routeBase);
118
+ if (!route)
119
+ return [];
120
+ const shown = routeBase.slice(projectRoot.length).replace(/^\/+/, "");
121
+ const modules = [route, ...localImports(route, projectRoot)];
122
+ const off = modules.map((module) => ({ module, off: rateLimitOff(module) })).find((entry) => entry.off !== null);
123
+ if (off) {
124
+ const where = off.module.file === routeBase ? shown : off.module.file.slice(projectRoot.length).replace(/^\/+/, "");
125
+ return [fail("C11", where, `The contact route turns the rate limits off (${off.off} in ${where}); /api/contact must stay rate-limited per IP and per email address.`)];
126
+ }
127
+ if (!modules.some(usesSharedHandler)) {
128
+ return [fail("C11", shown, `${shown} does not create its handler with the shared contact handler, so the bl_test marker is not stripped before delivery and /api/contact is not rate-limited.`)];
129
+ }
130
+ return [pass("C11", `${shown} uses the shared contact handler: the bl_test marker is stripped before delivery and reporting, and /api/contact keeps its rate limits.`, shown)];
131
+ }
132
+ //# sourceMappingURL=c11-contact-route.js.map