@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,207 @@
1
+ /**
2
+ * D4: same-trade distinctness on the look levers (Site System v2 §7.7).
3
+ *
4
+ * `src/data/strategy.ts` records the site's own look and every same-trade competitor's. The rule compares the site
5
+ * with each competitor lever by lever and fails when fewer than four of the seven differ from any one of them. The
6
+ * seven are the display font, the action-colour family, the hero variant, the header variant, the card style (radius,
7
+ * border and shadow tokens), the photo treatment (crop ratio, scrim and frame) and the home page's section order and
8
+ * signature sections. A shared display font counts as the same lever, an action hue within 20 degrees counts as the
9
+ * same, and a card style, photo treatment or home page layout is the same only when every part of it matches. The
10
+ * levers are the recorded values: this reads the record, it does not measure the rendered look.
11
+ *
12
+ * D4 blocks every new build and redesign from 3.9.0 and is advisory before that (`siteSystemCheckBlocks`).
13
+ */
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { LiteralReader, isFullyResolved, isLiteralObject } from "../enquiry/literal.js";
17
+ /** Where the site records its strategy. */
18
+ export const D4_STRATEGY_FILE = "src/data/strategy.ts";
19
+ /** The recorded levers (spec §7.7), as site-core's `SITE_LOOK_LEVERS` lists them (a test keeps the two lists equal). */
20
+ export const D4_LEVERS = [
21
+ "displayFont",
22
+ "actionHue",
23
+ "heroVariant",
24
+ "headerVariant",
25
+ "cardStyle",
26
+ "photoTreatment",
27
+ "homeSections",
28
+ ];
29
+ /** An action hue this close to a competitor's, in degrees, counts as the same. */
30
+ export const D4_SAME_HUE_DEGREES = 20;
31
+ /** At least this many levers must differ from every competitor. */
32
+ export const D4_MIN_DIFFERING = 4;
33
+ /** The parts of the levers that record several values. All of them must match for the lever to be the same. */
34
+ const CARD_STYLE_PARTS = ["radius", "border", "shadow"];
35
+ const PHOTO_TREATMENT_PARTS = ["cropRatio", "scrim", "frame"];
36
+ /** The first family of a font stack, without quotes, lowercased: `"Plus Jakarta Sans", sans-serif` is `plus jakarta sans`. */
37
+ function fontFamily(value) {
38
+ return (value.split(",")[0] ?? "").replace(/["']/g, "").trim().toLowerCase().replace(/\s+/g, " ");
39
+ }
40
+ function words(value) {
41
+ return value.trim().toLowerCase().replace(/\s+/g, " ");
42
+ }
43
+ /** Smallest angle between two hues, in degrees, on the colour wheel. */
44
+ export function hueDistance(left, right) {
45
+ const gap = Math.abs(left - right) % 360;
46
+ return Math.min(gap, 360 - gap);
47
+ }
48
+ function textParts(value, parts) {
49
+ if (!isLiteralObject(value))
50
+ return null;
51
+ const found = parts.map((part) => value[part]);
52
+ return found.every((item) => typeof item === "string" && item.trim() !== "") ? found.map(words) : null;
53
+ }
54
+ function textList(value) {
55
+ return Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim() !== "") ? value.map(words) : null;
56
+ }
57
+ function homeSections(value) {
58
+ if (!isLiteralObject(value))
59
+ return null;
60
+ const order = textList(value["sectionOrder"]);
61
+ const signatures = textList(value["signatures"]);
62
+ return order && order.length > 0 && signatures ? { order, signatures: [...new Set(signatures)].sort() } : null;
63
+ }
64
+ /** The lever's value if it is recorded in the shape the lever needs, otherwise undefined. */
65
+ function recordedLever(lever, value) {
66
+ switch (lever) {
67
+ case "actionHue":
68
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
69
+ case "cardStyle":
70
+ return textParts(value, CARD_STYLE_PARTS) ? value : undefined;
71
+ case "photoTreatment":
72
+ return textParts(value, PHOTO_TREATMENT_PARTS) ? value : undefined;
73
+ case "homeSections":
74
+ return homeSections(value) ? value : undefined;
75
+ default:
76
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
77
+ }
78
+ }
79
+ /** Whether one lever is the same in both looks. A lever recorded in the wrong shape is never the same. */
80
+ export function sameLever(lever, left, right) {
81
+ switch (lever) {
82
+ case "actionHue":
83
+ return typeof left === "number" && typeof right === "number" && hueDistance(left, right) <= D4_SAME_HUE_DEGREES;
84
+ case "displayFont":
85
+ return typeof left === "string" && typeof right === "string" && fontFamily(left) === fontFamily(right);
86
+ case "cardStyle":
87
+ case "photoTreatment": {
88
+ const parts = lever === "cardStyle" ? CARD_STYLE_PARTS : PHOTO_TREATMENT_PARTS;
89
+ const a = textParts(left, parts);
90
+ const b = textParts(right, parts);
91
+ return a !== null && b !== null && a.every((part, index) => part === b[index]);
92
+ }
93
+ case "homeSections": {
94
+ const a = homeSections(left);
95
+ const b = homeSections(right);
96
+ return (a !== null &&
97
+ b !== null &&
98
+ a.order.length === b.order.length &&
99
+ a.order.every((id, index) => id === b.order[index]) &&
100
+ a.signatures.length === b.signatures.length &&
101
+ a.signatures.every((name, index) => name === b.signatures[index]));
102
+ }
103
+ default:
104
+ return typeof left === "string" && typeof right === "string" && words(left) === words(right);
105
+ }
106
+ }
107
+ export function compareLooks(own, rival) {
108
+ const result = { differing: [], same: [], unrecorded: [] };
109
+ for (const lever of D4_LEVERS) {
110
+ const left = recordedLever(lever, own[lever]);
111
+ const right = recordedLever(lever, rival[lever]);
112
+ if (left === undefined || right === undefined)
113
+ result.unrecorded.push(lever);
114
+ else if (sameLever(lever, left, right))
115
+ result.same.push(lever);
116
+ else
117
+ result.differing.push(lever);
118
+ }
119
+ return result;
120
+ }
121
+ function readLook(value) {
122
+ if (!isLiteralObject(value))
123
+ return null;
124
+ const look = {};
125
+ for (const lever of D4_LEVERS) {
126
+ const recorded = recordedLever(lever, value[lever]);
127
+ if (recorded !== undefined)
128
+ look[lever] = recorded;
129
+ }
130
+ return look;
131
+ }
132
+ /** Reads the strategy record without running the site. Returns a string when it cannot be read as plain data. */
133
+ export function readStrategy(projectRoot) {
134
+ const file = join(projectRoot, D4_STRATEGY_FILE);
135
+ if (!existsSync(file))
136
+ return null;
137
+ const reader = new LiteralReader(readFileSync(file, "utf8"), D4_STRATEGY_FILE);
138
+ const argument = reader.callArguments("defineSiteStrategy")[0] ?? reader.constant("strategy");
139
+ if (!argument)
140
+ return `${D4_STRATEGY_FILE} does not define the strategy with defineSiteStrategy({ … }).`;
141
+ const value = reader.evaluate(argument);
142
+ if (!isLiteralObject(value) || !isFullyResolved(value)) {
143
+ return `${D4_STRATEGY_FILE} is not plain data, so the look levers cannot be read; write the strategy as a literal.`;
144
+ }
145
+ const look = readLook(value["look"]);
146
+ // defineSiteStrategy accepts a record without these, but D4 cannot pass a new build or redesign without them.
147
+ if (!look)
148
+ return `${D4_STRATEGY_FILE} has no "look" for the site; a new build or redesign must record it.`;
149
+ const rawCompetitors = value["competitors"];
150
+ if (!Array.isArray(rawCompetitors))
151
+ return `${D4_STRATEGY_FILE} has no "competitors" list; a new build or redesign must record it.`;
152
+ const competitors = [];
153
+ for (const [index, item] of rawCompetitors.entries()) {
154
+ const competitorLook = isLiteralObject(item) ? readLook(item["look"]) : null;
155
+ if (!isLiteralObject(item) || !competitorLook || typeof item["domain"] !== "string") {
156
+ return `${D4_STRATEGY_FILE} competitor ${index + 1} needs a domain and a look.`;
157
+ }
158
+ competitors.push({ name: typeof item["name"] === "string" ? item["name"] : item["domain"], domain: item["domain"], look: competitorLook });
159
+ }
160
+ return { look, competitors };
161
+ }
162
+ function list(levers) {
163
+ return levers.join(", ");
164
+ }
165
+ export function checkD4(projectRoot) {
166
+ const strategy = readStrategy(projectRoot);
167
+ if (strategy === null) {
168
+ return [{ checkId: "D4", status: "fail", target: D4_STRATEGY_FILE, message: `The site has no ${D4_STRATEGY_FILE}, so its look cannot be compared with its competitors'.` }];
169
+ }
170
+ if (typeof strategy === "string")
171
+ return [{ checkId: "D4", status: "fail", target: D4_STRATEGY_FILE, message: strategy }];
172
+ if (strategy.competitors.length === 0) {
173
+ return [{ checkId: "D4", status: "not-applicable", message: "The strategy lists no competitors, so there is no look to compare against." }];
174
+ }
175
+ const ownMissing = D4_LEVERS.filter((lever) => strategy.look[lever] === undefined);
176
+ if (ownMissing.length > 0) {
177
+ return [{ checkId: "D4", status: "fail", target: D4_STRATEGY_FILE, message: `The site's own look does not record ${list(ownMissing)}, so it cannot be compared.` }];
178
+ }
179
+ const failures = [];
180
+ let fewest = { domain: "", count: Number.POSITIVE_INFINITY };
181
+ for (const competitor of strategy.competitors) {
182
+ const comparison = compareLooks(strategy.look, competitor.look);
183
+ if (comparison.unrecorded.length > 0) {
184
+ failures.push({ checkId: "D4", status: "fail", target: competitor.domain, message: `The look recorded for ${competitor.domain} is missing ${list(comparison.unrecorded)}, so it cannot be compared.` });
185
+ continue;
186
+ }
187
+ if (comparison.differing.length < fewest.count)
188
+ fewest = { domain: competitor.domain, count: comparison.differing.length };
189
+ if (comparison.differing.length < D4_MIN_DIFFERING) {
190
+ failures.push({
191
+ checkId: "D4",
192
+ status: "fail",
193
+ target: competitor.domain,
194
+ message: `The look is too close to ${competitor.domain}: ${comparison.differing.length} of ${D4_LEVERS.length} levers differ and at least ${D4_MIN_DIFFERING} must (same: ${list(comparison.same)}).`,
195
+ });
196
+ }
197
+ }
198
+ if (failures.length > 0)
199
+ return failures;
200
+ const count = strategy.competitors.length;
201
+ return [{
202
+ checkId: "D4",
203
+ status: "pass",
204
+ message: `The look differs from ${count === 1 ? "the listed competitor" : `all ${count} listed competitors`} on at least ${D4_MIN_DIFFERING} of ${D4_LEVERS.length} levers (closest: ${fewest.domain} with ${fewest.count}).`,
205
+ }];
206
+ }
207
+ //# sourceMappingURL=d4-distinctness.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"d4-distinctness.js","sourceRoot":"","sources":["../../../../src/site-system/rules/design/d4-distinctness.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * D4: same-trade distinctness on the look levers (Site System v2 §7.7).\n *\n * `src/data/strategy.ts` records the site's own look and every same-trade competitor's. The rule compares the site\n * with each competitor lever by lever and fails when fewer than four of the seven differ from any one of them. The\n * seven are the display font, the action-colour family, the hero variant, the header variant, the card style (radius,\n * border and shadow tokens), the photo treatment (crop ratio, scrim and frame) and the home page's section order and\n * signature sections. A shared display font counts as the same lever, an action hue within 20 degrees counts as the\n * same, and a card style, photo treatment or home page layout is the same only when every part of it matches. The\n * levers are the recorded values: this reads the record, it does not measure the rendered look.\n *\n * D4 blocks every new build and redesign from 3.9.0 and is advisory before that (`siteSystemCheckBlocks`).\n */\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { LiteralReader, isFullyResolved, isLiteralObject, type Literal } from \"../enquiry/literal.js\";\n\n/** Where the site records its strategy. */\nexport const D4_STRATEGY_FILE = \"src/data/strategy.ts\";\n\n/** The recorded levers (spec §7.7), as site-core's `SITE_LOOK_LEVERS` lists them (a test keeps the two lists equal). */\nexport const D4_LEVERS = [\n \"displayFont\",\n \"actionHue\",\n \"heroVariant\",\n \"headerVariant\",\n \"cardStyle\",\n \"photoTreatment\",\n \"homeSections\",\n] as const;\nexport type D4Lever = (typeof D4_LEVERS)[number];\n\n/** An action hue this close to a competitor's, in degrees, counts as the same. */\nexport const D4_SAME_HUE_DEGREES = 20;\n/** At least this many levers must differ from every competitor. */\nexport const D4_MIN_DIFFERING = 4;\n\n/** The parts of the levers that record several values. All of them must match for the lever to be the same. */\nconst CARD_STYLE_PARTS = [\"radius\", \"border\", \"shadow\"] as const;\nconst PHOTO_TREATMENT_PARTS = [\"cropRatio\", \"scrim\", \"frame\"] as const;\n\nexport type RecordedLook = Partial<Record<D4Lever, Literal>>;\n\nexport interface RecordedCompetitor {\n name: string;\n domain: string;\n look: RecordedLook;\n}\n\n/** The first family of a font stack, without quotes, lowercased: `\"Plus Jakarta Sans\", sans-serif` is `plus jakarta sans`. */\nfunction fontFamily(value: string): string {\n return (value.split(\",\")[0] ?? \"\").replace(/[\"']/g, \"\").trim().toLowerCase().replace(/\\s+/g, \" \");\n}\n\nfunction words(value: string): string {\n return value.trim().toLowerCase().replace(/\\s+/g, \" \");\n}\n\n/** Smallest angle between two hues, in degrees, on the colour wheel. */\nexport function hueDistance(left: number, right: number): number {\n const gap = Math.abs(left - right) % 360;\n return Math.min(gap, 360 - gap);\n}\n\nfunction textParts(value: Literal | undefined, parts: readonly string[]): string[] | null {\n if (!isLiteralObject(value)) return null;\n const found = parts.map((part) => value[part]);\n return found.every((item): item is string => typeof item === \"string\" && item.trim() !== \"\") ? found.map(words) : null;\n}\n\nfunction textList(value: Literal | undefined): string[] | null {\n return Array.isArray(value) && value.every((item): item is string => typeof item === \"string\" && item.trim() !== \"\") ? value.map(words) : null;\n}\n\nfunction homeSections(value: Literal | undefined): { order: string[]; signatures: string[] } | null {\n if (!isLiteralObject(value)) return null;\n const order = textList(value[\"sectionOrder\"]);\n const signatures = textList(value[\"signatures\"]);\n return order && order.length > 0 && signatures ? { order, signatures: [...new Set(signatures)].sort() } : null;\n}\n\n/** The lever's value if it is recorded in the shape the lever needs, otherwise undefined. */\nfunction recordedLever(lever: D4Lever, value: Literal | undefined): Literal | undefined {\n switch (lever) {\n case \"actionHue\":\n return typeof value === \"number\" && Number.isFinite(value) ? value : undefined;\n case \"cardStyle\":\n return textParts(value, CARD_STYLE_PARTS) ? value : undefined;\n case \"photoTreatment\":\n return textParts(value, PHOTO_TREATMENT_PARTS) ? value : undefined;\n case \"homeSections\":\n return homeSections(value) ? value : undefined;\n default:\n return typeof value === \"string\" && value.trim() !== \"\" ? value : undefined;\n }\n}\n\n/** Whether one lever is the same in both looks. A lever recorded in the wrong shape is never the same. */\nexport function sameLever(lever: D4Lever, left: Literal, right: Literal): boolean {\n switch (lever) {\n case \"actionHue\":\n return typeof left === \"number\" && typeof right === \"number\" && hueDistance(left, right) <= D4_SAME_HUE_DEGREES;\n case \"displayFont\":\n return typeof left === \"string\" && typeof right === \"string\" && fontFamily(left) === fontFamily(right);\n case \"cardStyle\":\n case \"photoTreatment\": {\n const parts = lever === \"cardStyle\" ? CARD_STYLE_PARTS : PHOTO_TREATMENT_PARTS;\n const a = textParts(left, parts);\n const b = textParts(right, parts);\n return a !== null && b !== null && a.every((part, index) => part === b[index]);\n }\n case \"homeSections\": {\n const a = homeSections(left);\n const b = homeSections(right);\n return (\n a !== null &&\n b !== null &&\n a.order.length === b.order.length &&\n a.order.every((id, index) => id === b.order[index]) &&\n a.signatures.length === b.signatures.length &&\n a.signatures.every((name, index) => name === b.signatures[index])\n );\n }\n default:\n return typeof left === \"string\" && typeof right === \"string\" && words(left) === words(right);\n }\n}\n\nexport interface LookComparison {\n differing: D4Lever[];\n same: D4Lever[];\n /** Levers that are not recorded on one side; they can be neither the same nor different. */\n unrecorded: D4Lever[];\n}\n\nexport function compareLooks(own: RecordedLook, rival: RecordedLook): LookComparison {\n const result: LookComparison = { differing: [], same: [], unrecorded: [] };\n for (const lever of D4_LEVERS) {\n const left = recordedLever(lever, own[lever]);\n const right = recordedLever(lever, rival[lever]);\n if (left === undefined || right === undefined) result.unrecorded.push(lever);\n else if (sameLever(lever, left, right)) result.same.push(lever);\n else result.differing.push(lever);\n }\n return result;\n}\n\nexport interface RecordedStrategy {\n look: RecordedLook;\n competitors: RecordedCompetitor[];\n}\n\nfunction readLook(value: Literal | undefined): RecordedLook | null {\n if (!isLiteralObject(value)) return null;\n const look: RecordedLook = {};\n for (const lever of D4_LEVERS) {\n const recorded = recordedLever(lever, value[lever]);\n if (recorded !== undefined) look[lever] = recorded;\n }\n return look;\n}\n\n/** Reads the strategy record without running the site. Returns a string when it cannot be read as plain data. */\nexport function readStrategy(projectRoot: string): RecordedStrategy | string | null {\n const file = join(projectRoot, D4_STRATEGY_FILE);\n if (!existsSync(file)) return null;\n const reader = new LiteralReader(readFileSync(file, \"utf8\"), D4_STRATEGY_FILE);\n const argument = reader.callArguments(\"defineSiteStrategy\")[0] ?? reader.constant(\"strategy\");\n if (!argument) return `${D4_STRATEGY_FILE} does not define the strategy with defineSiteStrategy({ … }).`;\n const value = reader.evaluate(argument);\n if (!isLiteralObject(value) || !isFullyResolved(value)) {\n return `${D4_STRATEGY_FILE} is not plain data, so the look levers cannot be read; write the strategy as a literal.`;\n }\n const look = readLook(value[\"look\"]);\n // defineSiteStrategy accepts a record without these, but D4 cannot pass a new build or redesign without them.\n if (!look) return `${D4_STRATEGY_FILE} has no \"look\" for the site; a new build or redesign must record it.`;\n const rawCompetitors = value[\"competitors\"];\n if (!Array.isArray(rawCompetitors)) return `${D4_STRATEGY_FILE} has no \"competitors\" list; a new build or redesign must record it.`;\n const competitors: RecordedCompetitor[] = [];\n for (const [index, item] of rawCompetitors.entries()) {\n const competitorLook = isLiteralObject(item) ? readLook(item[\"look\"]) : null;\n if (!isLiteralObject(item) || !competitorLook || typeof item[\"domain\"] !== \"string\") {\n return `${D4_STRATEGY_FILE} competitor ${index + 1} needs a domain and a look.`;\n }\n competitors.push({ name: typeof item[\"name\"] === \"string\" ? item[\"name\"] : item[\"domain\"], domain: item[\"domain\"], look: competitorLook });\n }\n return { look, competitors };\n}\n\nfunction list(levers: readonly string[]): string {\n return levers.join(\", \");\n}\n\nexport function checkD4(projectRoot: string): SiteSystemFinding[] {\n const strategy = readStrategy(projectRoot);\n if (strategy === null) {\n return [{ checkId: \"D4\", status: \"fail\", target: D4_STRATEGY_FILE, message: `The site has no ${D4_STRATEGY_FILE}, so its look cannot be compared with its competitors'.` }];\n }\n if (typeof strategy === \"string\") return [{ checkId: \"D4\", status: \"fail\", target: D4_STRATEGY_FILE, message: strategy }];\n if (strategy.competitors.length === 0) {\n return [{ checkId: \"D4\", status: \"not-applicable\", message: \"The strategy lists no competitors, so there is no look to compare against.\" }];\n }\n\n const ownMissing = D4_LEVERS.filter((lever) => strategy.look[lever] === undefined);\n if (ownMissing.length > 0) {\n return [{ checkId: \"D4\", status: \"fail\", target: D4_STRATEGY_FILE, message: `The site's own look does not record ${list(ownMissing)}, so it cannot be compared.` }];\n }\n\n const failures: SiteSystemFinding[] = [];\n let fewest = { domain: \"\", count: Number.POSITIVE_INFINITY };\n for (const competitor of strategy.competitors) {\n const comparison = compareLooks(strategy.look, competitor.look);\n if (comparison.unrecorded.length > 0) {\n failures.push({ checkId: \"D4\", status: \"fail\", target: competitor.domain, message: `The look recorded for ${competitor.domain} is missing ${list(comparison.unrecorded)}, so it cannot be compared.` });\n continue;\n }\n if (comparison.differing.length < fewest.count) fewest = { domain: competitor.domain, count: comparison.differing.length };\n if (comparison.differing.length < D4_MIN_DIFFERING) {\n failures.push({\n checkId: \"D4\",\n status: \"fail\",\n target: competitor.domain,\n message: `The look is too close to ${competitor.domain}: ${comparison.differing.length} of ${D4_LEVERS.length} levers differ and at least ${D4_MIN_DIFFERING} must (same: ${list(comparison.same)}).`,\n });\n }\n }\n if (failures.length > 0) return failures;\n const count = strategy.competitors.length;\n return [{\n checkId: \"D4\",\n status: \"pass\",\n message: `The look differs from ${count === 1 ? \"the listed competitor\" : `all ${count} listed competitors`} on at least ${D4_MIN_DIFFERING} of ${D4_LEVERS.length} levers (closest: ${fewest.domain} with ${fewest.count}).`,\n }];\n}\n"]}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The design rules (release 3.8.0): D4, same-trade distinctness. It reads the site's recorded strategy, so it needs
3
+ * the project root and ignores the sampled pages. The runner has the signature the ready gate registers.
4
+ */
5
+ import type { SiteSystemFinding } from "../../checks.js";
6
+ export { D4_LEVERS, D4_MIN_DIFFERING, D4_SAME_HUE_DEGREES, D4_STRATEGY_FILE, checkD4, compareLooks, hueDistance, readStrategy, sameLever, } from "./d4-distinctness.js";
7
+ export interface DesignRuleInput {
8
+ readonly baseUrl: string;
9
+ readonly pages: readonly {
10
+ readonly path: string;
11
+ readonly html: string;
12
+ }[];
13
+ readonly projectRoot: string;
14
+ }
15
+ /** Runs D4 against the strategy record at `projectRoot`. */
16
+ export declare function runDesignRules(input: DesignRuleInput): Promise<readonly SiteSystemFinding[]>;
17
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/design/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAGzD,OAAO,EACL,SAAS,EACT,gBAAgB,EAChB,mBAAmB,EACnB,gBAAgB,EAChB,OAAO,EACP,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,SAAS,GACV,MAAM,sBAAsB,CAAC;AAE9B,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,SAAS;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC5E,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,4DAA4D;AAC5D,wBAAsB,cAAc,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,SAAS,iBAAiB,EAAE,CAAC,CAElG"}
@@ -0,0 +1,7 @@
1
+ import { checkD4 } from "./d4-distinctness.js";
2
+ export { D4_LEVERS, D4_MIN_DIFFERING, D4_SAME_HUE_DEGREES, D4_STRATEGY_FILE, checkD4, compareLooks, hueDistance, readStrategy, sameLever, } from "./d4-distinctness.js";
3
+ /** Runs D4 against the strategy record at `projectRoot`. */
4
+ export async function runDesignRules(input) {
5
+ return checkD4(input.projectRoot);
6
+ }
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/site-system/rules/design/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAE/C,OAAO,EACL,SAAS,EACT,gBAAgB,EAChB,mBAAmB,EACnB,gBAAgB,EAChB,OAAO,EACP,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,SAAS,GACV,MAAM,sBAAsB,CAAC;AAQ9B,4DAA4D;AAC5D,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,KAAsB;IACzD,OAAO,OAAO,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;AACpC,CAAC","sourcesContent":["/**\n * The design rules (release 3.8.0): D4, same-trade distinctness. It reads the site's recorded strategy, so it needs\n * the project root and ignores the sampled pages. The runner has the signature the ready gate registers.\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { checkD4 } from \"./d4-distinctness.js\";\n\nexport {\n D4_LEVERS,\n D4_MIN_DIFFERING,\n D4_SAME_HUE_DEGREES,\n D4_STRATEGY_FILE,\n checkD4,\n compareLooks,\n hueDistance,\n readStrategy,\n sameLever,\n} from \"./d4-distinctness.js\";\n\nexport interface DesignRuleInput {\n readonly baseUrl: string;\n readonly pages: readonly { readonly path: string; readonly html: string }[];\n readonly projectRoot: string;\n}\n\n/** Runs D4 against the strategy record at `projectRoot`. */\nexport async function runDesignRules(input: DesignRuleInput): Promise<readonly SiteSystemFinding[]> {\n return checkD4(input.projectRoot);\n}\n"]}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * C2: the quote form's layout and labels (Site System v2 §3.1 F8, §9.3).
3
+ *
4
+ * One five-field form: name, suburb, phone, email, "How can we help?", then photos. Phone and email are both
5
+ * required. There is no "(required)" text, no job-type select, no helper or reassurance line, and exactly one
6
+ * consent line (the line that links the privacy policy). Every enquiry already carries its page path, so there is nothing to choose about the job.
7
+ */
8
+ import type { SiteSystemFinding } from "../../checks.js";
9
+ import { type ParsedPage } from "./common.js";
10
+ import { type HtmlElement } from "./html.js";
11
+ /** The fields of the form, in the order they appear. */
12
+ export declare const C2_FIELD_ORDER: readonly ["name", "suburb", "phone", "email", "message", "photos"];
13
+ export type C2Field = (typeof C2_FIELD_ORDER)[number];
14
+ /** The message field's label. The trade-specific prompt goes in the placeholder, never the label. */
15
+ export declare const C2_MESSAGE_LABEL = "How can we help?";
16
+ /** Helper and reassurance lines the spec names. They are written here as examples; the patterns below match them. */
17
+ export declare const C2_HELPER_LINE_EXAMPLES: readonly ["We only use your details to reply", "All fields needed; photos are optional"];
18
+ /** Wording that explains the form or reassures the visitor. None of it belongs inside the quote form. */
19
+ export declare const C2_HELPER_LINE_PATTERNS: readonly RegExp[];
20
+ /** Marks that tell the visitor which fields are required or optional. Required fields use the `required` attribute. */
21
+ export declare const C2_REQUIRED_MARKER_PATTERNS: readonly RegExp[];
22
+ /**
23
+ * The consent line is the one line that links the site's own privacy policy. A notice that links somewhere else (a
24
+ * spam-protection widget's privacy link, for example) is not a second consent line.
25
+ */
26
+ export declare const C2_CONSENT_LINK: RegExp;
27
+ export declare function isQuoteForm(form: HtmlElement): boolean;
28
+ /** Every problem with one quote form, as short fragments that complete "The quote form on /x …". */
29
+ export declare function quoteFormProblems(form: HtmlElement, baseUrl?: string): string[];
30
+ export declare function checkC2(pages: readonly ParsedPage[], baseUrl?: string): SiteSystemFinding[];
31
+ //# sourceMappingURL=c2-quote-form.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"c2-quote-form.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/enquiry/c2-quote-form.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAAyC,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACrF,OAAO,EAAwF,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAEnI,wDAAwD;AACxD,eAAO,MAAM,cAAc,oEAAqE,CAAC;AACjG,MAAM,MAAM,OAAO,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC;AAEtD,qGAAqG;AACrG,eAAO,MAAM,gBAAgB,qBAAqB,CAAC;AAEnD,qHAAqH;AACrH,eAAO,MAAM,uBAAuB,0FAG1B,CAAC;AAEX,yGAAyG;AACzG,eAAO,MAAM,uBAAuB,EAAE,SAAS,MAAM,EAYpD,CAAC;AAEF,uHAAuH;AACvH,eAAO,MAAM,2BAA2B,EAAE,SAAS,MAAM,EAMxD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,eAAe,QAAa,CAAC;AAqD1C,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAGtD;AAWD,oGAAoG;AACpG,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,SAAK,GAAG,MAAM,EAAE,CAgE3E;AA2BD,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,EAAE,OAAO,SAAK,GAAG,iBAAiB,EAAE,CAiBvF"}
@@ -0,0 +1,211 @@
1
+ import { fail, notApplicable, plural, resultOf } from "./common.js";
2
+ import { accessibleLabel, attr, descendants, isHiddenFromPeople, normaliseText, readableLines } from "./html.js";
3
+ /** The fields of the form, in the order they appear. */
4
+ export const C2_FIELD_ORDER = ["name", "suburb", "phone", "email", "message", "photos"];
5
+ /** The message field's label. The trade-specific prompt goes in the placeholder, never the label. */
6
+ export const C2_MESSAGE_LABEL = "How can we help?";
7
+ /** Helper and reassurance lines the spec names. They are written here as examples; the patterns below match them. */
8
+ export const C2_HELPER_LINE_EXAMPLES = [
9
+ "We only use your details to reply",
10
+ "All fields needed; photos are optional",
11
+ ];
12
+ /** Wording that explains the form or reassures the visitor. None of it belongs inside the quote form. */
13
+ export const C2_HELPER_LINE_PATTERNS = [
14
+ /\bwe\s+(?:only|will\s+only)\s+use\s+(?:your|the)\s+(?:details|information|info)\b/i,
15
+ /\ball\s+(?:the\s+)?fields\s+(?:are\s+)?(?:needed|required|mandatory)\b/i,
16
+ /\bphotos?\s+(?:are|is)\s+optional\b/i,
17
+ /\bwe(?:'|’)ll\s+never\s+(?:share|sell|spam)\b/i,
18
+ /\bwe\s+(?:will\s+)?never\s+(?:share|sell|spam)\b/i,
19
+ /\bno\s+(?:spam|obligation|pressure|hidden\s+fees)\b/i,
20
+ /\byour\s+(?:details|information|privacy)\s+(?:is|are)\s+(?:safe|secure|private|protected)\b/i,
21
+ /\btakes?\s+(?:less\s+than|under|only|just)\s+(?:a|an|\d+)\s+(?:minute|second|min)/i,
22
+ /\bfill\s+(?:in|out)\s+(?:the|this)\s+(?:form|short\s+form)\b/i,
23
+ /\bwe(?:'|’)ll\s+(?:get\s+back\s+to\s+you|reply|respond|be\s+in\s+touch)\b/i,
24
+ /\b(?:free|quick|easy)[,\s]+(?:no[- ]obligation|no[- ]hassle)\b/i,
25
+ ];
26
+ /** Marks that tell the visitor which fields are required or optional. Required fields use the `required` attribute. */
27
+ export const C2_REQUIRED_MARKER_PATTERNS = [
28
+ /\(\s*required\s*\)/i,
29
+ /\(\s*optional\s*\)/i,
30
+ /\brequired\s+fields?\b/i,
31
+ /\*\s*required\b/i,
32
+ /\brequired\s*\*/i,
33
+ ];
34
+ /**
35
+ * The consent line is the one line that links the site's own privacy policy. A notice that links somewhere else (a
36
+ * spam-protection widget's privacy link, for example) is not a second consent line.
37
+ */
38
+ export const C2_CONSENT_LINK = /privacy/i;
39
+ const IGNORED_INPUT_TYPES = new Set(["hidden", "submit", "button", "reset", "image", "checkbox"]);
40
+ function token(value) {
41
+ return (value ?? "").toLowerCase();
42
+ }
43
+ /** What a control is, judged from its visible label when its name and attributes do not say. */
44
+ function classifyByLabel(label) {
45
+ const text = label.toLowerCase().replace(/[^a-z\s]/g, " ").replace(/\s+/g, " ").trim();
46
+ if (/\b(?:suburb|locality|postcode|post code|location|area|town|city)\b/.test(text))
47
+ return "suburb";
48
+ if (/\b(?:phone|mobile|telephone)\b/.test(text))
49
+ return "phone";
50
+ if (/\be ?mail\b/.test(text))
51
+ return "email";
52
+ if (/^(?:your |full |first |last )?name\b/.test(text))
53
+ return "name";
54
+ return null;
55
+ }
56
+ function classify(control) {
57
+ const type = token(attr(control, "type"));
58
+ const autocomplete = token(attr(control, "autocomplete"));
59
+ const identity = `${token(attr(control, "name"))} ${token(attr(control, "id"))}`;
60
+ if (control.tag === "select")
61
+ return "select";
62
+ if (control.tag === "textarea")
63
+ return "message";
64
+ if (type === "file")
65
+ return "photos";
66
+ if (type === "radio")
67
+ return "choice";
68
+ if (type === "tel" || /\btel\b/.test(autocomplete) || /phone|mobile|\btel\b/.test(identity))
69
+ return "phone";
70
+ if (type === "email" || /\bemail\b/.test(autocomplete) || /e-?mail/.test(identity))
71
+ return "email";
72
+ if (/address-level2|postal-code/.test(autocomplete) || /suburb|locality|postcode|location|area|town|city/.test(identity))
73
+ return "suburb";
74
+ if (/^(?:name|given-name|family-name)$/.test(autocomplete) || /(?:^|[\s_-])(?:full|first|last|your)?[_-]?name\b/.test(identity))
75
+ return "name";
76
+ return "other";
77
+ }
78
+ /** The controls a visitor sees and fills in, with what each is. A control the attributes do not identify is judged by its label. */
79
+ function visibleControls(form) {
80
+ return descendants(form)
81
+ .filter((element) => ["input", "textarea", "select"].includes(element.tag))
82
+ .filter((element) => !IGNORED_INPUT_TYPES.has(token(attr(element, "type"))))
83
+ // The honeypot ("website" in the shared contact runtime) is out of the tab order and hidden from people.
84
+ .filter((element) => attr(element, "name") !== "website" && attr(element, "tabindex") !== "-1" && !isHiddenFromPeople(element))
85
+ .map((element) => {
86
+ const kind = classify(element);
87
+ if (kind !== "other")
88
+ return { element, kind };
89
+ const label = accessibleLabel(element, form) || attr(element, "placeholder") || "";
90
+ return { element, kind: classifyByLabel(label) ?? "other" };
91
+ });
92
+ }
93
+ export function isQuoteForm(form) {
94
+ if (attr(form, "data-bl") === "quote-form")
95
+ return true;
96
+ return visibleControls(form).some((control) => control.kind === "phone");
97
+ }
98
+ function isRequired(control) {
99
+ return attr(control, "required") !== undefined || attr(control, "aria-required") === "true";
100
+ }
101
+ function sameLabel(actual, expected) {
102
+ const simplify = (value) => normaliseText(value).replace(/[?.!:’']/g, "").toLowerCase();
103
+ return simplify(actual) === simplify(expected);
104
+ }
105
+ /** Every problem with one quote form, as short fragments that complete "The quote form on /x …". */
106
+ export function quoteFormProblems(form, baseUrl = "") {
107
+ const problems = [];
108
+ const controls = visibleControls(form);
109
+ for (const field of C2_FIELD_ORDER) {
110
+ if (!controls.some((control) => control.kind === field))
111
+ problems.push(`has no ${field} field`);
112
+ }
113
+ const seen = C2_FIELD_ORDER.map((field) => controls.findIndex((control) => control.kind === field));
114
+ const present = seen.filter((position) => position !== -1);
115
+ if (present.some((position, index) => index > 0 && position < present[index - 1])) {
116
+ problems.push(`lists its fields out of order; they run ${C2_FIELD_ORDER.join(", ")}`);
117
+ }
118
+ for (const control of controls) {
119
+ if (control.kind === "select") {
120
+ problems.push(`has a select (${attr(control.element, "name") ?? attr(control.element, "id") ?? "unnamed"}); the form has no job-type dropdown`);
121
+ }
122
+ else if (control.kind === "choice") {
123
+ problems.push(`asks the visitor to choose between options (${attr(control.element, "name") ?? "unnamed"}); the form has no job-type choice`);
124
+ }
125
+ else if (control.kind === "other") {
126
+ problems.push(`has a field beyond the five (${attr(control.element, "name") ?? attr(control.element, "id") ?? attr(control.element, "type") ?? "unnamed"})`);
127
+ }
128
+ }
129
+ for (const field of ["phone", "email"]) {
130
+ const control = controls.find((candidate) => candidate.kind === field);
131
+ if (control && !isRequired(control.element))
132
+ problems.push(`does not require the ${field} field`);
133
+ }
134
+ const message = controls.find((control) => control.kind === "message");
135
+ if (message) {
136
+ const label = accessibleLabel(message.element, form);
137
+ if (!sameLabel(label, C2_MESSAGE_LABEL)) {
138
+ problems.push(`labels the message field "${label || "(no label)"}" instead of "${C2_MESSAGE_LABEL}"`);
139
+ }
140
+ }
141
+ const readable = readableLines(form);
142
+ const lines = readable.map((line) => line.text);
143
+ const placeholders = descendants(form).map((element) => attr(element, "placeholder")).filter((value) => Boolean(value));
144
+ const wording = [...lines, ...placeholders];
145
+ for (const pattern of C2_REQUIRED_MARKER_PATTERNS) {
146
+ const line = wording.find((candidate) => pattern.test(candidate));
147
+ if (line) {
148
+ problems.push(`marks fields as required or optional in its wording ("${shorten(line)}")`);
149
+ break;
150
+ }
151
+ }
152
+ const helper = lines.find((line) => C2_HELPER_LINE_PATTERNS.some((pattern) => pattern.test(line)));
153
+ if (helper)
154
+ problems.push(`carries a helper or reassurance line ("${shorten(helper)}")`);
155
+ const siteHost = hostOf(baseUrl);
156
+ const consentLines = readable.filter((line) => line.hrefs.some((href) => linksPrivacyPolicy(href, siteHost)));
157
+ if (consentLines.length === 0)
158
+ problems.push("has no consent line linking the privacy policy");
159
+ else if (consentLines.length > 1)
160
+ problems.push(`has ${consentLines.length} lines linking the privacy policy instead of one consent line`);
161
+ const hasSubmit = descendants(form).some((element) => (element.tag === "button" && ["submit", ""].includes(token(attr(element, "type")))) ||
162
+ (element.tag === "input" && token(attr(element, "type")) === "submit"));
163
+ if (!hasSubmit)
164
+ problems.push("has no submit button");
165
+ return problems;
166
+ }
167
+ function hostOf(baseUrl) {
168
+ try {
169
+ return new URL(baseUrl).hostname.replace(/^www\./, "");
170
+ }
171
+ catch {
172
+ return "";
173
+ }
174
+ }
175
+ /** Whether a link goes to the site's own privacy policy: a relative link, or one on the site's host, with "privacy" in the path. */
176
+ function linksPrivacyPolicy(href, siteHost) {
177
+ let url;
178
+ try {
179
+ url = new URL(href, "https://site.invalid");
180
+ }
181
+ catch {
182
+ return false;
183
+ }
184
+ const relative = !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith("//");
185
+ const sameSite = relative || (siteHost !== "" && url.hostname.replace(/^www\./, "") === siteHost);
186
+ return sameSite && C2_CONSENT_LINK.test(url.pathname);
187
+ }
188
+ function shorten(line) {
189
+ return line.length > 70 ? `${line.slice(0, 67)}...` : line;
190
+ }
191
+ export function checkC2(pages, baseUrl = "") {
192
+ const failures = [];
193
+ let forms = 0;
194
+ let pagesWithForms = 0;
195
+ for (const page of pages) {
196
+ const pageForms = descendants(page.root).filter((element) => element.tag === "form" && isQuoteForm(element));
197
+ if (pageForms.length > 0)
198
+ pagesWithForms += 1;
199
+ pageForms.forEach((form, index) => {
200
+ forms += 1;
201
+ const which = pageForms.length > 1 ? `quote form ${index + 1}` : "quote form";
202
+ for (const problem of quoteFormProblems(form, baseUrl)) {
203
+ failures.push(fail("C2", page.path, `The ${which} on ${page.path} ${problem}.`));
204
+ }
205
+ });
206
+ }
207
+ if (forms === 0)
208
+ return [notApplicable("C2", "None of the sampled pages has a quote form.")];
209
+ return resultOf("C2", failures, `Checked ${plural(forms, "quote form")} on ${plural(pagesWithForms, "page")}: all follow the form layout.`);
210
+ }
211
+ //# sourceMappingURL=c2-quote-form.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"c2-quote-form.js","sourceRoot":"","sources":["../../../../src/site-system/rules/enquiry/c2-quote-form.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * C2: the quote form's layout and labels (Site System v2 §3.1 F8, §9.3).\n *\n * One five-field form: name, suburb, phone, email, \"How can we help?\", then photos. Phone and email are both\n * required. There is no \"(required)\" text, no job-type select, no helper or reassurance line, and exactly one\n * consent line (the line that links the privacy policy). Every enquiry already carries its page path, so there is nothing to choose about the job.\n */\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { fail, notApplicable, plural, resultOf, type ParsedPage } from \"./common.js\";\nimport { accessibleLabel, attr, descendants, isHiddenFromPeople, normaliseText, readableLines, type HtmlElement } from \"./html.js\";\n\n/** The fields of the form, in the order they appear. */\nexport const C2_FIELD_ORDER = [\"name\", \"suburb\", \"phone\", \"email\", \"message\", \"photos\"] as const;\nexport type C2Field = (typeof C2_FIELD_ORDER)[number];\n\n/** The message field's label. The trade-specific prompt goes in the placeholder, never the label. */\nexport const C2_MESSAGE_LABEL = \"How can we help?\";\n\n/** Helper and reassurance lines the spec names. They are written here as examples; the patterns below match them. */\nexport const C2_HELPER_LINE_EXAMPLES = [\n \"We only use your details to reply\",\n \"All fields needed; photos are optional\",\n] as const;\n\n/** Wording that explains the form or reassures the visitor. None of it belongs inside the quote form. */\nexport const C2_HELPER_LINE_PATTERNS: readonly RegExp[] = [\n /\\bwe\\s+(?:only|will\\s+only)\\s+use\\s+(?:your|the)\\s+(?:details|information|info)\\b/i,\n /\\ball\\s+(?:the\\s+)?fields\\s+(?:are\\s+)?(?:needed|required|mandatory)\\b/i,\n /\\bphotos?\\s+(?:are|is)\\s+optional\\b/i,\n /\\bwe(?:'|’)ll\\s+never\\s+(?:share|sell|spam)\\b/i,\n /\\bwe\\s+(?:will\\s+)?never\\s+(?:share|sell|spam)\\b/i,\n /\\bno\\s+(?:spam|obligation|pressure|hidden\\s+fees)\\b/i,\n /\\byour\\s+(?:details|information|privacy)\\s+(?:is|are)\\s+(?:safe|secure|private|protected)\\b/i,\n /\\btakes?\\s+(?:less\\s+than|under|only|just)\\s+(?:a|an|\\d+)\\s+(?:minute|second|min)/i,\n /\\bfill\\s+(?:in|out)\\s+(?:the|this)\\s+(?:form|short\\s+form)\\b/i,\n /\\bwe(?:'|’)ll\\s+(?:get\\s+back\\s+to\\s+you|reply|respond|be\\s+in\\s+touch)\\b/i,\n /\\b(?:free|quick|easy)[,\\s]+(?:no[- ]obligation|no[- ]hassle)\\b/i,\n];\n\n/** Marks that tell the visitor which fields are required or optional. Required fields use the `required` attribute. */\nexport const C2_REQUIRED_MARKER_PATTERNS: readonly RegExp[] = [\n /\\(\\s*required\\s*\\)/i,\n /\\(\\s*optional\\s*\\)/i,\n /\\brequired\\s+fields?\\b/i,\n /\\*\\s*required\\b/i,\n /\\brequired\\s*\\*/i,\n];\n\n/**\n * The consent line is the one line that links the site's own privacy policy. A notice that links somewhere else (a\n * spam-protection widget's privacy link, for example) is not a second consent line.\n */\nexport const C2_CONSENT_LINK = /privacy/i;\n\nconst IGNORED_INPUT_TYPES = new Set([\"hidden\", \"submit\", \"button\", \"reset\", \"image\", \"checkbox\"]);\n\ninterface Control {\n element: HtmlElement;\n kind: C2Field | \"select\" | \"choice\" | \"other\";\n}\n\nfunction token(value: string | undefined): string {\n return (value ?? \"\").toLowerCase();\n}\n\n/** What a control is, judged from its visible label when its name and attributes do not say. */\nfunction classifyByLabel(label: string): C2Field | null {\n const text = label.toLowerCase().replace(/[^a-z\\s]/g, \" \").replace(/\\s+/g, \" \").trim();\n if (/\\b(?:suburb|locality|postcode|post code|location|area|town|city)\\b/.test(text)) return \"suburb\";\n if (/\\b(?:phone|mobile|telephone)\\b/.test(text)) return \"phone\";\n if (/\\be ?mail\\b/.test(text)) return \"email\";\n if (/^(?:your |full |first |last )?name\\b/.test(text)) return \"name\";\n return null;\n}\n\nfunction classify(control: HtmlElement): Control[\"kind\"] {\n const type = token(attr(control, \"type\"));\n const autocomplete = token(attr(control, \"autocomplete\"));\n const identity = `${token(attr(control, \"name\"))} ${token(attr(control, \"id\"))}`;\n if (control.tag === \"select\") return \"select\";\n if (control.tag === \"textarea\") return \"message\";\n if (type === \"file\") return \"photos\";\n if (type === \"radio\") return \"choice\";\n if (type === \"tel\" || /\\btel\\b/.test(autocomplete) || /phone|mobile|\\btel\\b/.test(identity)) return \"phone\";\n if (type === \"email\" || /\\bemail\\b/.test(autocomplete) || /e-?mail/.test(identity)) return \"email\";\n if (/address-level2|postal-code/.test(autocomplete) || /suburb|locality|postcode|location|area|town|city/.test(identity)) return \"suburb\";\n if (/^(?:name|given-name|family-name)$/.test(autocomplete) || /(?:^|[\\s_-])(?:full|first|last|your)?[_-]?name\\b/.test(identity)) return \"name\";\n return \"other\";\n}\n\n/** The controls a visitor sees and fills in, with what each is. A control the attributes do not identify is judged by its label. */\nfunction visibleControls(form: HtmlElement): Control[] {\n return descendants(form)\n .filter((element) => [\"input\", \"textarea\", \"select\"].includes(element.tag))\n .filter((element) => !IGNORED_INPUT_TYPES.has(token(attr(element, \"type\"))))\n // The honeypot (\"website\" in the shared contact runtime) is out of the tab order and hidden from people.\n .filter((element) => attr(element, \"name\") !== \"website\" && attr(element, \"tabindex\") !== \"-1\" && !isHiddenFromPeople(element))\n .map((element): Control => {\n const kind = classify(element);\n if (kind !== \"other\") return { element, kind };\n const label = accessibleLabel(element, form) || attr(element, \"placeholder\") || \"\";\n return { element, kind: classifyByLabel(label) ?? \"other\" };\n });\n}\n\nexport function isQuoteForm(form: HtmlElement): boolean {\n if (attr(form, \"data-bl\") === \"quote-form\") return true;\n return visibleControls(form).some((control) => control.kind === \"phone\");\n}\n\nfunction isRequired(control: HtmlElement): boolean {\n return attr(control, \"required\") !== undefined || attr(control, \"aria-required\") === \"true\";\n}\n\nfunction sameLabel(actual: string, expected: string): boolean {\n const simplify = (value: string) => normaliseText(value).replace(/[?.!:’']/g, \"\").toLowerCase();\n return simplify(actual) === simplify(expected);\n}\n\n/** Every problem with one quote form, as short fragments that complete \"The quote form on /x …\". */\nexport function quoteFormProblems(form: HtmlElement, baseUrl = \"\"): string[] {\n const problems: string[] = [];\n const controls = visibleControls(form);\n\n for (const field of C2_FIELD_ORDER) {\n if (!controls.some((control) => control.kind === field)) problems.push(`has no ${field} field`);\n }\n const seen = C2_FIELD_ORDER.map((field) => controls.findIndex((control) => control.kind === field));\n const present = seen.filter((position) => position !== -1);\n if (present.some((position, index) => index > 0 && position < present[index - 1]!)) {\n problems.push(`lists its fields out of order; they run ${C2_FIELD_ORDER.join(\", \")}`);\n }\n\n for (const control of controls) {\n if (control.kind === \"select\") {\n problems.push(`has a select (${attr(control.element, \"name\") ?? attr(control.element, \"id\") ?? \"unnamed\"}); the form has no job-type dropdown`);\n } else if (control.kind === \"choice\") {\n problems.push(`asks the visitor to choose between options (${attr(control.element, \"name\") ?? \"unnamed\"}); the form has no job-type choice`);\n } else if (control.kind === \"other\") {\n problems.push(`has a field beyond the five (${attr(control.element, \"name\") ?? attr(control.element, \"id\") ?? attr(control.element, \"type\") ?? \"unnamed\"})`);\n }\n }\n\n for (const field of [\"phone\", \"email\"] as const) {\n const control = controls.find((candidate) => candidate.kind === field);\n if (control && !isRequired(control.element)) problems.push(`does not require the ${field} field`);\n }\n\n const message = controls.find((control) => control.kind === \"message\");\n if (message) {\n const label = accessibleLabel(message.element, form);\n if (!sameLabel(label, C2_MESSAGE_LABEL)) {\n problems.push(`labels the message field \"${label || \"(no label)\"}\" instead of \"${C2_MESSAGE_LABEL}\"`);\n }\n }\n\n const readable = readableLines(form);\n const lines = readable.map((line) => line.text);\n const placeholders = descendants(form).map((element) => attr(element, \"placeholder\")).filter((value): value is string => Boolean(value));\n const wording = [...lines, ...placeholders];\n\n for (const pattern of C2_REQUIRED_MARKER_PATTERNS) {\n const line = wording.find((candidate) => pattern.test(candidate));\n if (line) {\n problems.push(`marks fields as required or optional in its wording (\"${shorten(line)}\")`);\n break;\n }\n }\n const helper = lines.find((line) => C2_HELPER_LINE_PATTERNS.some((pattern) => pattern.test(line)));\n if (helper) problems.push(`carries a helper or reassurance line (\"${shorten(helper)}\")`);\n\n const siteHost = hostOf(baseUrl);\n const consentLines = readable.filter((line) => line.hrefs.some((href) => linksPrivacyPolicy(href, siteHost)));\n if (consentLines.length === 0) problems.push(\"has no consent line linking the privacy policy\");\n else if (consentLines.length > 1) problems.push(`has ${consentLines.length} lines linking the privacy policy instead of one consent line`);\n\n const hasSubmit = descendants(form).some(\n (element) =>\n (element.tag === \"button\" && [\"submit\", \"\"].includes(token(attr(element, \"type\")))) ||\n (element.tag === \"input\" && token(attr(element, \"type\")) === \"submit\"),\n );\n if (!hasSubmit) problems.push(\"has no submit button\");\n\n return problems;\n}\n\nfunction hostOf(baseUrl: string): string {\n try {\n return new URL(baseUrl).hostname.replace(/^www\\./, \"\");\n } catch {\n return \"\";\n }\n}\n\n/** Whether a link goes to the site's own privacy policy: a relative link, or one on the site's host, with \"privacy\" in the path. */\nfunction linksPrivacyPolicy(href: string, siteHost: string): boolean {\n let url: URL;\n try {\n url = new URL(href, \"https://site.invalid\");\n } catch {\n return false;\n }\n const relative = !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith(\"//\");\n const sameSite = relative || (siteHost !== \"\" && url.hostname.replace(/^www\\./, \"\") === siteHost);\n return sameSite && C2_CONSENT_LINK.test(url.pathname);\n}\n\nfunction shorten(line: string): string {\n return line.length > 70 ? `${line.slice(0, 67)}...` : line;\n}\n\nexport function checkC2(pages: readonly ParsedPage[], baseUrl = \"\"): SiteSystemFinding[] {\n const failures: SiteSystemFinding[] = [];\n let forms = 0;\n let pagesWithForms = 0;\n for (const page of pages) {\n const pageForms = descendants(page.root).filter((element) => element.tag === \"form\" && isQuoteForm(element));\n if (pageForms.length > 0) pagesWithForms += 1;\n pageForms.forEach((form, index) => {\n forms += 1;\n const which = pageForms.length > 1 ? `quote form ${index + 1}` : \"quote form\";\n for (const problem of quoteFormProblems(form, baseUrl)) {\n failures.push(fail(\"C2\", page.path, `The ${which} on ${page.path} ${problem}.`));\n }\n });\n }\n if (forms === 0) return [notApplicable(\"C2\", \"None of the sampled pages has a quote form.\")];\n return resultOf(\"C2\", failures, `Checked ${plural(forms, \"quote form\")} on ${plural(pagesWithForms, \"page\")}: all follow the form layout.`);\n}\n"]}
@@ -0,0 +1,37 @@
1
+ import type { SiteSystemCheckId, SiteSystemFinding } from "../../checks.js";
2
+ import { type HtmlElement } from "./html.js";
3
+ /** What every rendered rule receives: the sampled pages' server HTML and where the project lives. */
4
+ export interface EnquiryRuleInput {
5
+ readonly baseUrl: string;
6
+ readonly pages: readonly {
7
+ readonly path: string;
8
+ readonly html: string;
9
+ }[];
10
+ readonly projectRoot: string;
11
+ }
12
+ /** Options for tests; a real run uses the defaults. */
13
+ export interface EnquiryRuleOptions {
14
+ /** The date licences are judged on. Defaults to now. */
15
+ readonly asOf?: Date;
16
+ }
17
+ export interface ParsedPage {
18
+ /** The page path, normalised: a leading slash, no trailing slash (except home), no query or fragment. */
19
+ readonly path: string;
20
+ readonly html: string;
21
+ readonly root: HtmlElement;
22
+ }
23
+ /** A path as the route catalogue writes it: leading slash, no query or fragment, no trailing slash except for home. */
24
+ export declare function normalisePath(path: string): string;
25
+ export declare function parsePages(pages: EnquiryRuleInput["pages"]): ParsedPage[];
26
+ export declare function fail(checkId: SiteSystemCheckId, target: string | undefined, message: string): SiteSystemFinding;
27
+ export declare function pass(checkId: SiteSystemCheckId, message: string, target?: string): SiteSystemFinding;
28
+ export declare function notApplicable(checkId: SiteSystemCheckId, message: string): SiteSystemFinding;
29
+ /** The failures when there are any; otherwise one pass finding. */
30
+ export declare function resultOf(checkId: SiteSystemCheckId, failures: readonly SiteSystemFinding[], passMessage: string): SiteSystemFinding[];
31
+ export declare function plural(count: number, singular: string, pluralForm?: string): string;
32
+ /** The outermost `data-bl-section` elements of a page, in document order. A section nested in one is part of it. */
33
+ export declare function topLevelSections(root: HtmlElement): HtmlElement[];
34
+ export declare function sectionId(section: HtmlElement): string;
35
+ /** Dollar figures in a piece of text, as numbers. A range such as "$450 to 900" counts both ends. */
36
+ export declare function dollarFigures(text: string): number[];
37
+ //# sourceMappingURL=common.d.ts.map