@wildo-ai/saas-website 1.1.4 → 1.1.6

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 (265) hide show
  1. package/dist/esm/astro/blog-post-bridge.d.ts +23 -20
  2. package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
  3. package/dist/esm/astro/blog-post-bridge.js +13 -6
  4. package/dist/esm/astro/blog-post-bridge.js.map +1 -1
  5. package/dist/esm/astro/blog-post-head.renderer.d.ts.map +1 -1
  6. package/dist/esm/astro/bridge-runtime.d.ts +55 -26
  7. package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
  8. package/dist/esm/astro/bridge-runtime.js +21 -7
  9. package/dist/esm/astro/bridge-runtime.js.map +1 -1
  10. package/dist/esm/astro/collect-expected-label-keys.d.ts.map +1 -1
  11. package/dist/esm/astro/headers-renderer.d.ts.map +1 -1
  12. package/dist/esm/astro/i18n-routing.helper.d.ts.map +1 -1
  13. package/dist/esm/astro/label-pack-loader.d.ts +4 -4
  14. package/dist/esm/astro/label-pack-loader.d.ts.map +1 -1
  15. package/dist/esm/astro/label-pack-loader.js +14 -4
  16. package/dist/esm/astro/label-pack-loader.js.map +1 -1
  17. package/dist/esm/astro/llms-txt-server.d.ts.map +1 -1
  18. package/dist/esm/astro/pick-labels-for-page.d.ts.map +1 -1
  19. package/dist/esm/astro/robots-renderer.d.ts +9 -8
  20. package/dist/esm/astro/robots-renderer.d.ts.map +1 -1
  21. package/dist/esm/astro/robots-renderer.js +15 -15
  22. package/dist/esm/astro/robots-renderer.js.map +1 -1
  23. package/dist/esm/astro/sitemap-coverage.d.ts.map +1 -1
  24. package/dist/esm/astro/structured-data.renderer.d.ts.map +1 -1
  25. package/dist/esm/astro/website-page-head.renderer.d.ts +1 -1
  26. package/dist/esm/astro/website-page-head.renderer.d.ts.map +1 -1
  27. package/dist/esm/astro/website-page-head.renderer.js +2 -1
  28. package/dist/esm/astro/website-page-head.renderer.js.map +1 -1
  29. package/dist/esm/astro/website-page-runtime.helper.d.ts.map +1 -1
  30. package/dist/esm/astro/website-site-context.d.ts +5 -2
  31. package/dist/esm/astro/website-site-context.d.ts.map +1 -1
  32. package/dist/esm/astro/website-site-context.js.map +1 -1
  33. package/dist/esm/astro-island.d.ts.map +1 -1
  34. package/dist/esm/astro.d.ts.map +1 -1
  35. package/dist/esm/companion-exports.d.ts +8 -15
  36. package/dist/esm/companion-exports.d.ts.map +1 -1
  37. package/dist/esm/companion-exports.js +8 -15
  38. package/dist/esm/companion-exports.js.map +1 -1
  39. package/dist/esm/components/low-level/WebsiteInternalButton.d.ts.map +1 -1
  40. package/dist/esm/components/low-level/WebsiteInternalLink.d.ts.map +1 -1
  41. package/dist/esm/config/define-website-config.d.ts +3 -2
  42. package/dist/esm/config/define-website-config.d.ts.map +1 -1
  43. package/dist/esm/config/define-website-config.js +10 -10
  44. package/dist/esm/config/define-website-config.js.map +1 -1
  45. package/dist/esm/config/index.d.ts.map +1 -1
  46. package/dist/esm/config/load-website-config.d.ts +6 -0
  47. package/dist/esm/config/load-website-config.d.ts.map +1 -1
  48. package/dist/esm/config/load-website-config.js +20 -10
  49. package/dist/esm/config/load-website-config.js.map +1 -1
  50. package/dist/esm/config/load-website-pricing-catalog.d.ts +13 -0
  51. package/dist/esm/config/load-website-pricing-catalog.d.ts.map +1 -0
  52. package/dist/esm/config/load-website-pricing-catalog.js +34 -0
  53. package/dist/esm/config/load-website-pricing-catalog.js.map +1 -0
  54. package/dist/esm/config/load-website-site-context.d.ts +53 -0
  55. package/dist/esm/config/load-website-site-context.d.ts.map +1 -0
  56. package/dist/esm/config/load-website-site-context.js +98 -0
  57. package/dist/esm/config/load-website-site-context.js.map +1 -0
  58. package/dist/esm/config/wildo-website-config.schemas.d.ts +10 -5
  59. package/dist/esm/config/wildo-website-config.schemas.d.ts.map +1 -1
  60. package/dist/esm/config/wildo-website-config.schemas.js +10 -5
  61. package/dist/esm/config/wildo-website-config.schemas.js.map +1 -1
  62. package/dist/esm/config-loader.d.ts +2 -0
  63. package/dist/esm/config-loader.d.ts.map +1 -1
  64. package/dist/esm/config-loader.js +2 -0
  65. package/dist/esm/config-loader.js.map +1 -1
  66. package/dist/esm/core/anonymous-session/InboundContactForm.d.ts +26 -6
  67. package/dist/esm/core/anonymous-session/InboundContactForm.d.ts.map +1 -1
  68. package/dist/esm/core/anonymous-session/InboundContactForm.js +51 -33
  69. package/dist/esm/core/anonymous-session/InboundContactForm.js.map +1 -1
  70. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts +14 -3
  71. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts.map +1 -1
  72. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js +28 -13
  73. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js.map +1 -1
  74. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts +24 -1
  75. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts.map +1 -1
  76. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js +46 -3
  77. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -1
  78. package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts +36 -0
  79. package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts.map +1 -0
  80. package/dist/esm/core/anonymous-session/website-submission-challenge.js +69 -0
  81. package/dist/esm/core/anonymous-session/website-submission-challenge.js.map +1 -0
  82. package/dist/esm/core/consent/WebsiteConsentBanner.d.ts +6 -0
  83. package/dist/esm/core/consent/WebsiteConsentBanner.d.ts.map +1 -0
  84. package/dist/esm/core/consent/WebsiteConsentBanner.js +129 -0
  85. package/dist/esm/core/consent/WebsiteConsentBanner.js.map +1 -0
  86. package/dist/esm/core/consent/WebsiteConsentContext.d.ts +85 -0
  87. package/dist/esm/core/consent/WebsiteConsentContext.d.ts.map +1 -0
  88. package/dist/esm/core/consent/WebsiteConsentContext.js +89 -0
  89. package/dist/esm/core/consent/WebsiteConsentContext.js.map +1 -0
  90. package/dist/esm/core/consent/WebsiteConsentGate.d.ts +27 -0
  91. package/dist/esm/core/consent/WebsiteConsentGate.d.ts.map +1 -0
  92. package/dist/esm/core/consent/WebsiteConsentGate.js +9 -0
  93. package/dist/esm/core/consent/WebsiteConsentGate.js.map +1 -0
  94. package/dist/esm/core/consent/useWebsiteConsent.d.ts +10 -0
  95. package/dist/esm/core/consent/useWebsiteConsent.d.ts.map +1 -0
  96. package/dist/esm/core/consent/useWebsiteConsent.js +17 -0
  97. package/dist/esm/core/consent/useWebsiteConsent.js.map +1 -0
  98. package/dist/esm/core/consent/website-consent-storage.d.ts +22 -0
  99. package/dist/esm/core/consent/website-consent-storage.d.ts.map +1 -0
  100. package/dist/esm/core/consent/website-consent-storage.js +102 -0
  101. package/dist/esm/core/consent/website-consent-storage.js.map +1 -0
  102. package/dist/esm/core/contexts/WebsitePageContext.d.ts.map +1 -1
  103. package/dist/esm/core/contexts/WebsitePageContext.js.map +1 -1
  104. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +33 -6
  105. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts.map +1 -1
  106. package/dist/esm/core/contexts/WebsiteRuntimeContext.js.map +1 -1
  107. package/dist/esm/core/contexts/WebsiteSectionContext.d.ts.map +1 -1
  108. package/dist/esm/core/contexts/WebsiteSectionContext.js.map +1 -1
  109. package/dist/esm/core/contexts/useWebsitePage.d.ts.map +1 -1
  110. package/dist/esm/core/contexts/useWebsiteRuntime.d.ts +18 -0
  111. package/dist/esm/core/contexts/useWebsiteRuntime.d.ts.map +1 -1
  112. package/dist/esm/core/contexts/useWebsiteRuntime.js +28 -2
  113. package/dist/esm/core/contexts/useWebsiteRuntime.js.map +1 -1
  114. package/dist/esm/core/contexts/useWebsiteSection.d.ts.map +1 -1
  115. package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts +57 -0
  116. package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts.map +1 -0
  117. package/dist/esm/core/external-providers/WebsiteProviderComponent.js +56 -0
  118. package/dist/esm/core/external-providers/WebsiteProviderComponent.js.map +1 -0
  119. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts +1 -2
  120. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts.map +1 -1
  121. package/dist/esm/core/external-providers/frontend-provider-registry.website.js.map +1 -1
  122. package/dist/esm/core/external-providers/provider-component-registry.website.d.ts +63 -0
  123. package/dist/esm/core/external-providers/provider-component-registry.website.d.ts.map +1 -0
  124. package/dist/esm/core/external-providers/provider-component-registry.website.js +68 -0
  125. package/dist/esm/core/external-providers/provider-component-registry.website.js.map +1 -0
  126. package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts +36 -0
  127. package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts.map +1 -0
  128. package/dist/esm/core/external-providers/useWebsiteProviderComponent.js +59 -0
  129. package/dist/esm/core/external-providers/useWebsiteProviderComponent.js.map +1 -0
  130. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts +43 -10
  131. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
  132. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +92 -29
  133. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -1
  134. package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts +11 -0
  135. package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts.map +1 -0
  136. package/dist/esm/core/external-providers/website-provider-sdk-activations.js +14 -0
  137. package/dist/esm/core/external-providers/website-provider-sdk-activations.js.map +1 -0
  138. package/dist/esm/core/factories/define-website-page-manifest.d.ts.map +1 -1
  139. package/dist/esm/core/factories/define-website-section.d.ts +3 -3
  140. package/dist/esm/core/factories/define-website-section.d.ts.map +1 -1
  141. package/dist/esm/core/factories/define-website-section.js +3 -3
  142. package/dist/esm/core/factories/define-website-section.js.map +1 -1
  143. package/dist/esm/core/hooks/useWebsiteDesignTokens.d.ts.map +1 -1
  144. package/dist/esm/core/hooks/useWebsiteLabel.d.ts.map +1 -1
  145. package/dist/esm/core/layouts/WebsitePageLayout.d.ts +21 -1
  146. package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -1
  147. package/dist/esm/core/layouts/WebsitePageLayout.js +39 -8
  148. package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
  149. package/dist/esm/core/layouts/WebsiteSection.d.ts.map +1 -1
  150. package/dist/esm/core/routing/internal-routing.utils.d.ts.map +1 -1
  151. package/dist/esm/index.d.ts +36 -65
  152. package/dist/esm/index.d.ts.map +1 -1
  153. package/dist/esm/index.js +36 -65
  154. package/dist/esm/index.js.map +1 -1
  155. package/dist/esm/mdx/BlogPost.d.ts.map +1 -1
  156. package/dist/esm/mdx/blog-post-collection.config.d.ts.map +1 -1
  157. package/dist/esm/mdx/blog-post-frontmatter-source.d.ts.map +1 -1
  158. package/dist/esm/mdx/index.d.ts.map +1 -1
  159. package/dist/esm/mdx.d.ts.map +1 -1
  160. package/dist/esm/schemas/blog/blog-post-frontmatter.shared.schemas.d.ts.map +1 -1
  161. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts +35 -6
  162. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts.map +1 -1
  163. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js +39 -5
  164. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js.map +1 -1
  165. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts +61 -0
  166. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts.map +1 -0
  167. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.js +73 -0
  168. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.js.map +1 -0
  169. package/dist/esm/schemas/label-keys/website-label-key.schemas.d.ts.map +1 -1
  170. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts +32 -0
  171. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts.map +1 -0
  172. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js +36 -0
  173. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js.map +1 -0
  174. package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.d.ts.map +1 -1
  175. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts +6 -5
  176. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts.map +1 -1
  177. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js +8 -7
  178. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js.map +1 -1
  179. package/dist/esm/schemas/refs/page-ref.schemas.d.ts.map +1 -1
  180. package/dist/esm/schemas/refs/section-ref.schemas.d.ts.map +1 -1
  181. package/dist/esm/schemas/sections/website-section-category.shared.d.ts.map +1 -1
  182. package/dist/esm/schemas/sections/website-section-category.shared.js.map +1 -1
  183. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts +14 -7
  184. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts.map +1 -1
  185. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js +14 -7
  186. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js.map +1 -1
  187. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts +57 -0
  188. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts.map +1 -0
  189. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js +76 -0
  190. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js.map +1 -0
  191. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts +23 -0
  192. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts.map +1 -1
  193. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js +20 -2
  194. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js.map +1 -1
  195. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts +7 -5
  196. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts.map +1 -1
  197. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.js.map +1 -1
  198. package/dist/esm/schemas/validators/label-pack.validator.d.ts.map +1 -1
  199. package/dist/tsconfig.build.tsbuildinfo +1 -1
  200. package/package.json +6 -29
  201. package/src/__tests__/bundle-isolation.test.ts +19 -22
  202. package/src/astro/__tests__/blog-post-bridge.test.tsx +58 -1
  203. package/src/astro/__tests__/bridge-runtime.test.tsx +44 -1
  204. package/src/astro/__tests__/label-pack-loader.test.ts +29 -1
  205. package/src/astro/blog-post-bridge.tsx +36 -26
  206. package/src/astro/bridge-runtime.tsx +79 -33
  207. package/src/astro/label-pack-loader.ts +12 -4
  208. package/src/astro/robots-renderer.ts +15 -15
  209. package/src/astro/website-page-head.renderer.ts +2 -2
  210. package/src/astro/website-site-context.ts +5 -2
  211. package/src/companion-exports.ts +8 -15
  212. package/src/config/__tests__/website-build-origins.test.ts +52 -0
  213. package/src/config/define-website-config.ts +10 -10
  214. package/src/config/load-website-config.ts +27 -15
  215. package/src/config/load-website-pricing-catalog.ts +52 -0
  216. package/src/config/load-website-site-context.ts +145 -0
  217. package/src/config/wildo-website-config.schemas.ts +10 -5
  218. package/src/config-loader.ts +2 -0
  219. package/src/core/__tests__/WebsitePageLayout.test.tsx +1 -1
  220. package/src/core/__tests__/contexts.test.tsx +20 -1
  221. package/src/core/__tests__/website-consent.test.tsx +312 -0
  222. package/src/core/anonymous-session/InboundContactForm.tsx +100 -45
  223. package/src/core/anonymous-session/__tests__/InboundContactForm.labels.test.tsx +108 -0
  224. package/src/core/anonymous-session/__tests__/InboundContactForm.submission-challenge.test.tsx +139 -0
  225. package/src/core/anonymous-session/__tests__/inbound-contact-form.schema.test.ts +11 -4
  226. package/src/core/anonymous-session/inbound-contact-form.schema.ts +30 -13
  227. package/src/core/anonymous-session/website-anonymous-session-client.ts +52 -3
  228. package/src/core/anonymous-session/website-submission-challenge.ts +99 -0
  229. package/src/core/consent/WebsiteConsentBanner.tsx +223 -0
  230. package/src/core/consent/WebsiteConsentContext.tsx +164 -0
  231. package/src/core/consent/WebsiteConsentGate.tsx +34 -0
  232. package/src/core/consent/useWebsiteConsent.ts +18 -0
  233. package/src/core/consent/website-consent-storage.ts +116 -0
  234. package/src/core/contexts/WebsiteRuntimeContext.tsx +33 -6
  235. package/src/core/contexts/useWebsiteRuntime.ts +33 -2
  236. package/src/core/external-providers/WebsiteProviderComponent.tsx +153 -0
  237. package/src/core/external-providers/__tests__/frontend-provider-registry.website.test.ts +4 -0
  238. package/src/core/external-providers/__tests__/website-provider-component.test.tsx +238 -0
  239. package/src/core/external-providers/__tests__/website-provider-sdks.test.tsx +72 -0
  240. package/src/core/external-providers/frontend-provider-registry.website.ts +1 -2
  241. package/src/core/external-providers/provider-component-registry.website.ts +115 -0
  242. package/src/core/external-providers/useWebsiteProviderComponent.ts +76 -0
  243. package/src/core/external-providers/useWebsiteProviderScripts.ts +114 -40
  244. package/src/core/external-providers/website-provider-sdk-activations.ts +23 -0
  245. package/src/core/factories/define-website-section.ts +3 -3
  246. package/src/core/layouts/WebsitePageLayout.tsx +75 -7
  247. package/src/index.ts +38 -65
  248. package/src/schemas/__tests__/design-token-css-property.test.ts +72 -0
  249. package/src/schemas/__tests__/pricing-offer-structured-data.test.ts +122 -0
  250. package/src/schemas/__tests__/structured-data-reconciliation.test.ts +35 -0
  251. package/src/schemas/design-tokens/website-design-tokens.shared.schemas.ts +45 -5
  252. package/src/schemas/label-keys/__tests__/scaffolded-consent-label-pack.parity.test.ts +83 -0
  253. package/src/schemas/label-keys/website-consent-label-keys.schemas.ts +78 -0
  254. package/src/schemas/label-keys/website-provider-component-label-keys.schemas.ts +40 -0
  255. package/src/schemas/manifests/website-root-config.shared.schemas.ts +10 -9
  256. package/src/schemas/sections/website-section-definition.shared.schemas.ts +14 -7
  257. package/src/schemas/structured-data/pricing-offer-structured-data.shared.ts +144 -0
  258. package/src/schemas/structured-data/structured-data-reconciliation.shared.ts +46 -1
  259. package/src/schemas/structured-data/website-structured-data.shared.schemas.ts +7 -5
  260. package/dist/esm/core/external-providers/provider-scripts.website.d.ts +0 -68
  261. package/dist/esm/core/external-providers/provider-scripts.website.d.ts.map +0 -1
  262. package/dist/esm/core/external-providers/provider-scripts.website.js +0 -99
  263. package/dist/esm/core/external-providers/provider-scripts.website.js.map +0 -1
  264. package/src/core/external-providers/__tests__/provider-scripts.website.test.ts +0 -146
  265. package/src/core/external-providers/provider-scripts.website.ts +0 -126
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ ProductAvailability,
4
+ ProductPriceDisclosure,
5
+ PublicPricingAmountBasis,
6
+ PublicPricingRecurrenceUnit,
7
+ type PublicPricingPlan,
8
+ type PublicPricingPrice,
9
+ } from '@wildo-ai/saas-models/public-runtime';
10
+
11
+ import { buildWebsitePricingOfferStructuredData } from '../structured-data/pricing-offer-structured-data.shared';
12
+
13
+ /**
14
+ * Pins for the pricing page's `AggregateOffer` (#1835). A search engine shows `price` as the price of
15
+ * the offer, so each refusal pinned here is a case where publishing the catalogue's number would be a
16
+ * false statement.
17
+ */
18
+
19
+ const monthly = (amountMinor: string, overrides: Partial<PublicPricingPrice> = {}): PublicPricingPrice => ({
20
+ amountMinor,
21
+ decimals: 2,
22
+ currency: 'USD',
23
+ recurrence: { unit: PublicPricingRecurrenceUnit.MONTH, count: 1 },
24
+ amountBasis: PublicPricingAmountBasis.PER_UNIT,
25
+ isDefault: true,
26
+ ...overrides,
27
+ });
28
+
29
+ const plan = (key: string, overrides: Partial<PublicPricingPlan> = {}): PublicPricingPlan => ({
30
+ key,
31
+ sortOrder: 0,
32
+ targetScopes: ['organizations'],
33
+ isRecommended: false,
34
+ priceDisclosure: ProductPriceDisclosure.LISTED,
35
+ availability: ProductAvailability.AVAILABLE,
36
+ prices: [monthly('0')],
37
+ ...overrides,
38
+ });
39
+
40
+ const build = (plans: PublicPricingPlan[], planName?: (key: string) => string | undefined, perUnitName: string | null = 'member') =>
41
+ buildWebsitePricingOfferStructuredData({
42
+ catalog: { plans },
43
+ itemOfferedId: 'https://example.test/#software-application',
44
+ aggregateOfferIdPrefix: 'https://example.test/pricing#plans',
45
+ url: 'https://example.test/pricing',
46
+ ...(planName === undefined ? {} : { planName }),
47
+ ...(perUnitName === null ? {} : { perUnitName }),
48
+ });
49
+
50
+ type AggregateLike = { '@id': string; lowPrice: string; highPrice: string; offerCount: number; offers: Array<Record<string, unknown>> };
51
+
52
+ describe('buildWebsitePricingOfferStructuredData', () => {
53
+ it('publishes a per-unit price with what it is per — never as a whole price', () => {
54
+ const [aggregate] = build([plan('team', { prices: [monthly('400')] })], (key) => ({ team: 'Team' })[key]) as AggregateLike[];
55
+ expect(aggregate!.offers[0]).toEqual({
56
+ '@type': 'Offer',
57
+ name: 'Team',
58
+ price: '4',
59
+ priceCurrency: 'USD',
60
+ availability: 'https://schema.org/InStock',
61
+ url: 'https://example.test/pricing#plan-team',
62
+ priceSpecification: {
63
+ '@type': 'UnitPriceSpecification',
64
+ price: '4',
65
+ priceCurrency: 'USD',
66
+ billingDuration: 'P1M',
67
+ referenceQuantity: { '@type': 'QuantitativeValue', value: 1, unitText: 'member' },
68
+ },
69
+ });
70
+ });
71
+
72
+ it('refuses to publish a per-unit price when the page did not name the unit', () => {
73
+ expect(() => build([plan('team', { prices: [monthly('400')] })], undefined, null)).toThrow(/perUnitName/u);
74
+ });
75
+
76
+ it('never folds a flat price and a per-member price, or monthly and yearly prices, into one range', () => {
77
+ const aggregates = build([
78
+ plan('free', { prices: [monthly('0', { amountBasis: PublicPricingAmountBasis.FLAT })] }),
79
+ plan('team', { prices: [monthly('400')] }),
80
+ plan('company', { prices: [monthly('600')] }),
81
+ plan('annual', { prices: [monthly('4000', { amountBasis: PublicPricingAmountBasis.FLAT, recurrence: { unit: PublicPricingRecurrenceUnit.YEAR, count: 1 } })] }),
82
+ ]) as AggregateLike[];
83
+ expect(aggregates.map((aggregate) => [aggregate['@id'], aggregate.lowPrice, aggregate.highPrice, aggregate.offerCount])).toEqual([
84
+ ['https://example.test/pricing#plans-flat-1-month-usd', '0', '0', 1],
85
+ ['https://example.test/pricing#plans-per-unit-1-month-usd', '4', '6', 2],
86
+ ['https://example.test/pricing#plans-flat-1-year-usd', '40', '40', 1],
87
+ ]);
88
+ });
89
+
90
+ it("names an aggregate by what it aggregates, so a plan added to ANOTHER group never moves it", () => {
91
+ const perUnitId = (plans: PublicPricingPlan[]) =>
92
+ (build(plans) as AggregateLike[]).find((aggregate) => aggregate.offers.some((offer) => offer.url === 'https://example.test/pricing#plan-team'))!['@id'];
93
+ const team = plan('team', { prices: [monthly('400')] });
94
+ const free = plan('free', { prices: [monthly('0', { amountBasis: PublicPricingAmountBasis.FLAT })] });
95
+ expect(perUnitId([team])).toBe('https://example.test/pricing#plans-per-unit-1-month-usd');
96
+ expect(perUnitId([free, team])).toBe(perUnitId([team]));
97
+ });
98
+
99
+ it.each([
100
+ ['a starting price (only a floor)', plan('company', { priceDisclosure: ProductPriceDisclosure.STARTING_AT, prices: [monthly('600')] })],
101
+ ['an on-request plan (no price)', plan('enterprise', { priceDisclosure: ProductPriceDisclosure.ON_REQUEST, prices: [monthly('99900')] })],
102
+ ['an upcoming plan (cannot be bought)', plan('next', { availability: ProductAvailability.UPCOMING, prices: [monthly('900')] })],
103
+ ['a tiered price (the amount is indicative)', plan('volume', { prices: [monthly('100', { amountBasis: PublicPricingAmountBasis.VARIABLE })] })],
104
+ ])('never publishes %s as a price', (_label, excluded) => {
105
+ const aggregates = build([plan('team', { prices: [monthly('400')] }), excluded]) as AggregateLike[];
106
+ expect(aggregates.flatMap((aggregate) => aggregate.offers.map((offer) => offer.url))).toEqual(['https://example.test/pricing#plan-team']);
107
+ });
108
+
109
+ it('publishes nothing when nothing may be published — the true statement for an on-request-only catalogue', () => {
110
+ expect(build([plan('enterprise', { priceDisclosure: ProductPriceDisclosure.ON_REQUEST })])).toEqual([]);
111
+ });
112
+
113
+ it('never blends currencies into one aggregate', () => {
114
+ const aggregates = build([plan('us', { prices: [monthly('400')] }), plan('eu', { prices: [monthly('500', { currency: 'EUR' })] })]);
115
+ expect(aggregates.map((aggregate) => aggregate.priceCurrency)).toEqual(['USD', 'EUR']);
116
+ });
117
+
118
+ it('omits a one-time offer\'s billing duration rather than inventing one', () => {
119
+ const [aggregate] = build([plan('lifetime', { prices: [monthly('9900', { amountBasis: PublicPricingAmountBasis.FLAT, recurrence: { unit: PublicPricingRecurrenceUnit.ONE_TIME, count: 1 } })] })]) as AggregateLike[];
120
+ expect(aggregate!.offers[0]!.priceSpecification).toEqual({ '@type': 'UnitPriceSpecification', price: '99', priceCurrency: 'USD' });
121
+ });
122
+ });
@@ -541,3 +541,38 @@ describe('reconcileStructuredDataAgainstCatalog', () => {
541
541
  );
542
542
  });
543
543
  });
544
+
545
+ describe('optional-field recommendations — the catalog entry\'s recommendOptionals, in its own channel', () => {
546
+ const reconcile = (recommendOptionals: boolean, payload: Record<string, unknown>) =>
547
+ reconcileStructuredDataAgainstCatalog({
548
+ catalog: [
549
+ createCatalogEntry({
550
+ '@type': 'SoftwareApplication',
551
+ requiredFields: ['name'],
552
+ optionalFields: ['offers', 'aggregateRating', 'screenshot'],
553
+ recommendOptionals,
554
+ }),
555
+ ],
556
+ pages: [{ ref: 'pricing', routePath: '/pricing', sectionRefs: ['pricing-plans'] }],
557
+ sections: [{ sectionRef: 'pricing-plans', getStructuredData: () => ({ '@type': 'SoftwareApplication', name: 'App', ...payload }) }],
558
+ });
559
+
560
+ it('recommends the optional fields a payload leaves out, when the entry opts in — and reports no finding', () => {
561
+ const result = reconcile(true, { offers: { '@type': 'Offer' } });
562
+ expect(result.findings).toEqual([]);
563
+ expect(result.recommendations).toHaveLength(1);
564
+ expect(result.recommendations[0]).toMatchObject({
565
+ pageRef: 'pricing',
566
+ sectionRef: 'pricing-plans',
567
+ entityType: 'SoftwareApplication',
568
+ missingOptionalFields: ['aggregateRating', 'screenshot'],
569
+ });
570
+ // A recommendation is not counted as a finding on the page either.
571
+ expect(result.pages[0]?.findingCount).toBe(0);
572
+ });
573
+
574
+ it('stays quiet when the entry does not opt in (the default), and when nothing is missing', () => {
575
+ expect(reconcile(false, {}).recommendations).toEqual([]);
576
+ expect(reconcile(true, { offers: {}, aggregateRating: {}, screenshot: 'x.png' }).recommendations).toEqual([]);
577
+ });
578
+ });
@@ -62,8 +62,10 @@ const cssSafeValue = (fieldName: string): z.ZodString =>
62
62
  * @wildo_source:part:start saas.website.design-tokens.colors facet:layer:shared facet:family:website
63
63
  *
64
64
  * `WebsiteColorTokens` — minimal palette the marketing-site exposes as
65
- * CSS custom properties (`--website-color-<token>`) on the
66
- * `<WebsitePageLayout>` root.
65
+ * CSS custom properties (`--website-colors-<token>`, the key verbatim, so
66
+ * `surfaceForeground` → `--website-colors-surfaceForeground`) on `:root`.
67
+ * Reference one from code through `websiteDesignTokenCssVar('colors', …)`
68
+ * rather than spelling the name.
67
69
  *
68
70
  * **Why a dedicated marketing palette** (vs. reusing
69
71
  * `presets-components-models/design-tokens-config`):
@@ -92,8 +94,9 @@ const cssSafeValue = (fieldName: string): z.ZodString =>
92
94
  * `cssSafeValue` JSDoc above for the full threat model.
93
95
  *
94
96
  * **Adding a token**: append the field here using `cssSafeValue(...)`,
95
- * document its semantic intent in the JSDoc, and update the matching
96
- * CSS-emit helper in Phase 2 (`emitWebsiteColorTokensCss`).
97
+ * document its semantic intent in the JSDoc. `renderWebsiteDesignTokensCss`
98
+ * (from `@wildo-ai/saas-website/astro`) emits every key generically, so no
99
+ * emitter needs editing.
97
100
  */
98
101
  const _WebsiteColorTokensSchema = z.object({
99
102
  background: cssSafeValue('background color'),
@@ -118,7 +121,7 @@ export type WebsiteColorTokens = z.infer<typeof _WebsiteColorTokensSchema>;
118
121
  * @wildo_source:part:start saas.website.design-tokens.radius facet:layer:shared facet:family:website
119
122
  *
120
123
  * `WebsiteRadiusTokens` — border-radius scale exposed as
121
- * `--website-radius-<token>` CSS variables.
124
+ * `--website-radii-<token>` CSS variables.
122
125
  *
123
126
  * Marketing radii are intentionally a separate token set from the
124
127
  * SaaS app's radii: the website typically wants larger pill / hero
@@ -164,3 +167,40 @@ export const WebsiteDesignTokensSchema: typeof _WebsiteDesignTokensSchema = _Web
164
167
 
165
168
  export type WebsiteDesignTokens = z.infer<typeof _WebsiteDesignTokensSchema>;
166
169
  /** @wildo_source:part:end saas.website.design-tokens.bundle */
170
+
171
+ /**
172
+ * The CSS custom-property NAME one design token is emitted under —
173
+ * `--website-<group>-<token>`, with the token key verbatim (no case
174
+ * conversion), so `('colors', 'surfaceForeground')` is
175
+ * `--website-colors-surfaceForeground`.
176
+ *
177
+ * This is the one place the naming rule lives. `renderWebsiteDesignTokensCss`
178
+ * emits through it, and every engine component that reads a token reads
179
+ * through `websiteDesignTokenCssVar`. It exists because the rule used to be
180
+ * spelled by hand at each reader, and one reader spelled it
181
+ * `surface-foreground`: the consent banner's text colour silently fell back to
182
+ * `#000` on every site, whatever palette the application authored.
183
+ */
184
+ export function websiteDesignTokenCssProperty(group: string, token: string): string {
185
+ return `--website-${group}-${token}`;
186
+ }
187
+
188
+ /**
189
+ * A `var(...)` reference to one design token, typed against the token bundle
190
+ * so a misspelt group or key does not compile.
191
+ *
192
+ * @example
193
+ * ```ts
194
+ * import { websiteDesignTokenCssVar } from '@wildo-ai/saas-website';
195
+ * const style = { color: websiteDesignTokenCssVar('colors', 'surfaceForeground', '#000') };
196
+ * // → 'var(--website-colors-surfaceForeground, #000)'
197
+ * ```
198
+ */
199
+ export function websiteDesignTokenCssVar<G extends keyof WebsiteDesignTokens>(
200
+ group: G,
201
+ token: keyof WebsiteDesignTokens[G] & string,
202
+ fallback?: string,
203
+ ): string {
204
+ const property = websiteDesignTokenCssProperty(group, token);
205
+ return fallback === undefined ? `var(${property})` : `var(${property}, ${fallback})`;
206
+ }
@@ -0,0 +1,83 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ import { WEBSITE_CONSENT_LABEL_KEYS } from '../website-consent-label-keys.schemas';
8
+ import { WEBSITE_PROVIDER_COMPONENT_LABEL_KEYS } from '../website-provider-component-label-keys.schemas';
9
+
10
+ /**
11
+ * The `add-website` scaffold must author EVERY framework-owned consent key.
12
+ *
13
+ * ## The failure this exists to stop, measured 2026-09-20
14
+ *
15
+ * `WEBSITE_CONSENT_LABEL_KEYS` is what a locale pack must carry, and a pack missing one fails the
16
+ * site's build rather than showing a visitor a raw key — that refusal is deliberate, and its own
17
+ * declaration says why: *"a site that adds analytics tomorrow must not ship it with an unlabelled —
18
+ * or absent — way to refuse."*
19
+ *
20
+ * The key set then GREW by four (the always-active row, and one explanation per purpose). The
21
+ * dogfood site's packs were updated by hand and the SCAFFOLD was not, so every newly composed
22
+ * website would have shipped four keys short — a scaffold that cannot build, produced by a change
23
+ * two packages away, with nothing between the two but somebody remembering.
24
+ *
25
+ * ## Why a parity test rather than a shared constant
26
+ *
27
+ * The template is Handlebars, so no TypeScript can import it and no type can relate the two. That
28
+ * leaves the same choice this repository has made before for exactly this shape
29
+ * (`scaffolded-resource-operations.parity.test.ts`): a COPY of a fact, with a test holding the copy
30
+ * equal to its source. What makes it worth having is the direction of the failure — a missing key is
31
+ * invisible until somebody composes a site, and the person who composes it is not the person who
32
+ * added the key.
33
+ *
34
+ * It asserts EQUALITY, not containment, in both directions: a key the template authors and the
35
+ * framework no longer asks for is a label nobody renders, which is the defect `#54` started from.
36
+ */
37
+ const TEMPLATE_RELATIVE_PATH = 'templates/saas/scenarios/add-website/files/website/src/i18n/en.json.hbs';
38
+
39
+ function findRepositoryRoot(): string {
40
+ let directory = dirname(fileURLToPath(import.meta.url));
41
+ while (!existsSync(join(directory, TEMPLATE_RELATIVE_PATH))) {
42
+ const parent = dirname(directory);
43
+ if (parent === directory) throw new Error(`Could not locate ${TEMPLATE_RELATIVE_PATH} above ${fileURLToPath(import.meta.url)}.`);
44
+ directory = parent;
45
+ }
46
+ return directory;
47
+ }
48
+
49
+ describe('the add-website scaffold’s locale pack', () => {
50
+ it('authors exactly the consent keys the framework requires of every site', () => {
51
+ const template = readFileSync(join(findRepositoryRoot(), TEMPLATE_RELATIVE_PATH), 'utf8');
52
+ const authored = [...template.matchAll(/"(website\.chrome\.consent\.[^"]+)"\s*:/gu)].map((match) => match[1]!);
53
+
54
+ /*
55
+ * Guards the guard: a regex that matched nothing would report perfect agreement with an empty
56
+ * template, which is the shape `--self-test` exists for everywhere else in this repository.
57
+ */
58
+ expect(authored.length, 'the template no longer authors consent keys this test can read').toBeGreaterThan(0);
59
+
60
+ const required = WEBSITE_CONSENT_LABEL_KEYS.map((key) => key as unknown as string);
61
+ expect([...authored].sort()).toEqual([...required].sort());
62
+ });
63
+
64
+ it('gives every authored consent key a non-empty value', () => {
65
+ // A key present with an empty string satisfies a pack validator and shows a visitor nothing —
66
+ // the same outcome as the missing key, arrived at through the check rather than around it.
67
+ const template = readFileSync(join(findRepositoryRoot(), TEMPLATE_RELATIVE_PATH), 'utf8');
68
+ const entries = [...template.matchAll(/"(website\.chrome\.consent\.[^"]+)"\s*:\s*"([^"]*)"/gu)];
69
+ expect(entries.length).toBe(WEBSITE_CONSENT_LABEL_KEYS.length);
70
+ for (const [, key, value] of entries) expect(value!.trim(), `${key!} is authored empty`).not.toBe('');
71
+ });
72
+
73
+ it('authors exactly the provider-component slot keys the framework requires of every site (#1703)', () => {
74
+ // Same parity, same reason: the slot's consent-required and unavailable wording is framework-owned
75
+ // and required of every pack, so a composed site missing it would not build.
76
+ const template = readFileSync(join(findRepositoryRoot(), TEMPLATE_RELATIVE_PATH), 'utf8');
77
+ const entries = [...template.matchAll(/"(website\.chrome\.provider-component\.[^"]+)"\s*:\s*"([^"]*)"/gu)];
78
+ expect(entries.map(([, key]) => key!).sort()).toEqual(
79
+ WEBSITE_PROVIDER_COMPONENT_LABEL_KEYS.map((key) => key as unknown as string).sort(),
80
+ );
81
+ for (const [, key, value] of entries) expect(value!.trim(), `${key!} is authored empty`).not.toBe('');
82
+ });
83
+ });
@@ -0,0 +1,78 @@
1
+ import { ConsentPurpose, CONSENT_PURPOSES } from '@wildo-ai/saas-models/public-runtime';
2
+
3
+ import { WebsiteLabelKeySchema, type WebsiteLabelKey } from './website-label-key.schemas';
4
+
5
+ /**
6
+ * The label keys the framework's visitor consent surface reads (#534).
7
+ *
8
+ * FRAMEWORK-OWNED, like the skip link's key: the consent banner is rendered by `WebsitePageLayout`, not by
9
+ * the site's chrome, so the site does not list these in `chromeLabelKeys`.
10
+ * `composeEffectiveChromeLabelKeys` unions them into the label-pack validator's expected set, so a locale
11
+ * pack missing one fails the build rather than showing a visitor a raw key.
12
+ *
13
+ * They are required of every site, including one that runs no consent-based provider today: the words are
14
+ * cheap, and a site that adds analytics tomorrow must not ship it with an unlabelled — or absent — way to
15
+ * refuse.
16
+ *
17
+ * Purpose names are keyed by the `ConsentPurpose` VALUE and derived from the vocabulary, so a purpose added
18
+ * to it is a missing label in every site's pack at the next build, not a purpose the banner cannot name.
19
+ */
20
+ export enum WebsiteConsentLabelSlot {
21
+ /** Heading of the consent panel. */
22
+ TITLE = 'title',
23
+ /** What the panel is for: optional purposes are off until agreed, each chosen separately, changeable later. */
24
+ DESCRIPTION = 'description',
25
+ /** Agree to every purpose listed. */
26
+ ACCEPT_ALL = 'accept-all',
27
+ /** Agree to none of them — recorded as a choice, so the panel does not return. */
28
+ DECLINE_ALL = 'decline-all',
29
+ /** Save exactly the purposes ticked. */
30
+ SAVE = 'save',
31
+ /** Re-open the panel to change a choice already made. */
32
+ MANAGE = 'manage',
33
+ /**
34
+ * Name of the row covering what runs whatever the visitor chooses.
35
+ *
36
+ * NOT a `ConsentPurpose`, and it must never become one: that vocabulary is what a visitor DECIDES,
37
+ * and this is the thing they are not being asked about. It is shown because a panel listing only
38
+ * the optional purposes cannot answer the question a visitor opened it to answer — what is
39
+ * happening on this site — which is why every specialized consent platform states it.
40
+ */
41
+ ESSENTIAL_TITLE = 'essential-title',
42
+ /** What the always-active row covers, and why its control is checked and cannot be changed. */
43
+ ESSENTIAL_DESCRIPTION = 'essential-description',
44
+ }
45
+
46
+ const CONSENT_LABEL_KEY_PREFIX = 'website.chrome.consent';
47
+
48
+ /** The fully-composed key for one panel slot. */
49
+ export function websiteConsentLabelKey(slot: WebsiteConsentLabelSlot): WebsiteLabelKey {
50
+ return WebsiteLabelKeySchema.parse(`${CONSENT_LABEL_KEY_PREFIX}.${slot}`);
51
+ }
52
+
53
+ /** The fully-composed key naming one consent purpose. */
54
+ export function websiteConsentPurposeLabelKey(purpose: ConsentPurpose): WebsiteLabelKey {
55
+ return WebsiteLabelKeySchema.parse(`${CONSENT_LABEL_KEY_PREFIX}.purpose-${purpose}`);
56
+ }
57
+
58
+ /**
59
+ * The key EXPLAINING one consent purpose — what agreeing to it means for this visitor.
60
+ *
61
+ * The name above answers *what is it called*; this answers *what will happen to me*, and a choice
62
+ * made without the second is not an informed one. "Analytics" beside a tick box is a category, not
63
+ * something a visitor can weigh.
64
+ *
65
+ * Derived from the vocabulary exactly as the name is, so a purpose added to `ConsentPurpose` is a
66
+ * missing label in every site's pack at the next build rather than a row that renders a name with no
67
+ * explanation beneath it.
68
+ */
69
+ export function websiteConsentPurposeDescriptionLabelKey(purpose: ConsentPurpose): WebsiteLabelKey {
70
+ return WebsiteLabelKeySchema.parse(`${CONSENT_LABEL_KEY_PREFIX}.purpose-${purpose}-description`);
71
+ }
72
+
73
+ /** Every framework-owned consent key — what a locale pack must carry. Total over both vocabularies. */
74
+ export const WEBSITE_CONSENT_LABEL_KEYS: readonly WebsiteLabelKey[] = Object.freeze([
75
+ ...Object.values(WebsiteConsentLabelSlot).map(websiteConsentLabelKey),
76
+ ...CONSENT_PURPOSES.map(websiteConsentPurposeLabelKey),
77
+ ...CONSENT_PURPOSES.map(websiteConsentPurposeDescriptionLabelKey),
78
+ ]);
@@ -0,0 +1,40 @@
1
+ import { WebsiteLabelKeySchema, type WebsiteLabelKey } from './website-label-key.schemas';
2
+
3
+ /**
4
+ * The label keys the framework's provider-component slot reads (#1703).
5
+ *
6
+ * FRAMEWORK-OWNED, like the consent panel's keys: `WebsiteProviderComponent` renders these states,
7
+ * not the site's chrome, so the site does not list them in `chromeLabelKeys`.
8
+ * `composeEffectiveChromeLabelKeys` unions them into the label-pack validator's expected set, so a
9
+ * locale pack missing one fails the build rather than showing a visitor a raw key.
10
+ *
11
+ * Required of every site, including one that places no provider component today, for the reason the
12
+ * consent keys are: the words are cheap, and a site that adds an address lookup tomorrow must not
13
+ * ship it with an unlabelled refusal. The consent-required state also names each missing purpose and
14
+ * offers the panel, and it reuses the consent keys for both (`purpose-<purpose>`, `manage`) rather
15
+ * than a second wording of the same thing.
16
+ */
17
+ export enum WebsiteProviderComponentLabelSlot {
18
+ /**
19
+ * Shown where a provider component would be when the visitor has not agreed to a purpose it rests
20
+ * on. Says the feature is off until they agree; the purposes and the way to agree follow it.
21
+ */
22
+ CONSENT_REQUIRED = 'consent-required',
23
+ /**
24
+ * Shown when the component could not be loaded (a network failure, a blocked script). Says the
25
+ * feature is unavailable right now, without blaming the visitor.
26
+ */
27
+ UNAVAILABLE = 'unavailable',
28
+ }
29
+
30
+ const PROVIDER_COMPONENT_LABEL_KEY_PREFIX = 'website.chrome.provider-component';
31
+
32
+ /** The fully-composed key for one slot state. */
33
+ export function websiteProviderComponentLabelKey(slot: WebsiteProviderComponentLabelSlot): WebsiteLabelKey {
34
+ return WebsiteLabelKeySchema.parse(`${PROVIDER_COMPONENT_LABEL_KEY_PREFIX}.${slot}`);
35
+ }
36
+
37
+ /** Every framework-owned provider-component key — what a locale pack must carry. Total over the vocabulary. */
38
+ export const WEBSITE_PROVIDER_COMPONENT_LABEL_KEYS: readonly WebsiteLabelKey[] = Object.freeze(
39
+ Object.values(WebsiteProviderComponentLabelSlot).map(websiteProviderComponentLabelKey),
40
+ );
@@ -94,9 +94,10 @@ export type WebsiteSitemapConfig = z.infer<typeof _WebsiteSitemapConfigSchema>;
94
94
  * `'noindex'` is the deliberate exception (staging environments,
95
95
  * beta surfaces, sites still under construction). The opt-OUT
96
96
  * shape mirrors `WebsiteSitemapConfig.enabled` for consistency.
97
- * - `'noindex'` emits a `User-agent: *\nDisallow: /` block — the
98
- * standard "tell every well-behaved crawler to stay away"
99
- * primitive.
97
+ * - `'noindex'` is expressed by a `<meta name="robots" content="noindex">`
98
+ * on every page, NOT by `Disallow: /` in `robots.txt` (#494): a crawler
99
+ * barred from fetching a page never reads its noindex, so the URL can
100
+ * still be indexed bare. `robots.txt` stays allow-everything for it.
100
101
  *
101
102
  * **Why `disallow[]` is a path-prefix list (not a regex)**:
102
103
  * - The `robots.txt` spec only supports literal path prefixes
@@ -420,10 +421,10 @@ const _WebsiteRootConfigSchema = z
420
421
  * the chrome) or above it (creating a structural surface
421
422
  * the framework needs anyway).
422
423
  *
423
- * The key MUST also be present in `chromeLabelKeys` (the
424
- * label-pack validator silences "orphaned" warnings off
425
- * `chromeLabelKeys`) — the framework adds it automatically via
426
- * the `superRefine` pass below so consumers cannot forget.
424
+ * It is validated as a chrome key without being listed in
425
+ * `chromeLabelKeys`: the label-pack loader adds it to the effective
426
+ * chrome key set (`composeEffectiveChromeLabelKeys` in
427
+ * `astro/label-pack-loader.ts`), so consumers cannot forget it.
427
428
  */
428
429
  skipToMainContentLabelKey: WebsiteLabelKeySchema,
429
430
  })
@@ -484,8 +485,8 @@ export const WebsiteRootConfigSchema: typeof _WebsiteRootConfigSchema = _Website
484
485
  * and `navigation.footerComponent` as `unknown` (their schema field
485
486
  * type), losing the `ComponentType<unknown>` narrowing carried by the
486
487
  * hand-written `WebsitePageManifest` and `WebsiteNavigationConfig`
487
- * interfaces. Factories such as `defineWebsiteRootConfig` and the
488
- * layout primitives consume `WebsiteRootConfig` and must see those
488
+ * interfaces. The layout primitives and the Astro helpers consume
489
+ * `WebsiteRootConfig` and must see those
489
490
  * fields pre-narrowed; otherwise every consumer would need a local
490
491
  * `as ComponentType` cast at the JSX call site, defeating the type-safe
491
492
  * component contract.
@@ -14,7 +14,8 @@ import { WebsiteSectionCategorySchema } from './website-section-category.shared'
14
14
  * `core/define-website-section.tsx` module in Phase 2 because it carries
15
15
  * React types in its inputs). The shape is declared in `schemas/`
16
16
  * because it is the **observable contract** the page-manifest
17
- * registration, label-pack validator, and SEO dispatcher depend on.
17
+ * registration, the label-pack validator, `renderPageStructuredData` and the
18
+ * structured-data audit depend on.
18
19
  *
19
20
  * **Shape rationale** (each field carries semantic weight — see
20
21
  * `design-philosophy.mdc`):
@@ -24,7 +25,8 @@ import { WebsiteSectionCategorySchema } from './website-section-category.shared'
24
25
  * (a section can be reused with the same `sectionRef` across pages
25
26
  * when its label-key root is intentionally shared).
26
27
  * - `category` — see `WebsiteSectionCategory` JSDoc; tags the section
27
- * for SEO-dispatch, skill-mapping, and analytics labelling.
28
+ * for skill-mapping and analytics labelling. It does NOT select the
29
+ * section's structured data.
28
30
  * - `expectedLabelKeys` — the **fully-composed** label keys the section
29
31
  * commits to consuming. The plan originally proposed leaf-relative
30
32
  * keys (`'headline'`) resolved at runtime against the
@@ -37,9 +39,13 @@ import { WebsiteSectionCategorySchema } from './website-section-category.shared'
37
39
  * graph — `wildo website audit` can list a page's required keys
38
40
  * without booting React.
39
41
  * 3. The leaf-relative ergonomics (`useWebsiteLabel('headline')`)
40
- * survive in Phase 2 — the hook composes the leaf with the
41
- * page+section context and validates the result against
42
- * `expectedLabelKeys`.
42
+ * survive — the hook composes the leaf with the page+section
43
+ * context and looks the result up in the active pack. It does
44
+ * NOT check the key against `expectedLabelKeys`: a leaf the
45
+ * section never declared renders its key (with a console warning)
46
+ * and is caught by nothing until a reviewer reads the page. The
47
+ * build and `wildo website audit` check the pack against the
48
+ * DECLARED keys, so declare every slot the component reads.
43
49
  * - `Component` — the React component that renders the section. Typed
44
50
  * as `ComponentType<unknown>` here because schema-layer cannot lock
45
51
  * the component's prop shape (props vary per section). The factory
@@ -49,8 +55,9 @@ import { WebsiteSectionCategorySchema } from './website-section-category.shared'
49
55
  * - `getStructuredData?` — optional pure function the SEO/AEO/GEO
50
56
  * builder library invokes at static-build time to materialize JSON-LD
51
57
  * for the section. The return type is intentionally `unknown` here;
52
- * the SEO library narrows by `category` (e.g. `PRICING` →
53
- * `Product | AggregateOffer`).
58
+ * nothing narrows it by `category` — the section returns whatever
59
+ * vocabulary it emits, and `wildo website audit` reconciles that
60
+ * against the application's Schema.org catalog.
54
61
  *
55
62
  * **Why not `z.custom<ComponentType>(…)`**: a `z.custom` predicate
56
63
  * cannot meaningfully validate "is a React component" beyond