@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,144 @@
1
+ import {
2
+ ProductAvailability,
3
+ ProductPriceDisclosure,
4
+ PublicPricingAmountBasis,
5
+ PublicPricingRecurrenceUnit,
6
+ publicPricingAmountDecimal,
7
+ type PublicPricingCatalog,
8
+ type PublicPricingPlan,
9
+ type PublicPricingPrice,
10
+ } from '@wildo-ai/saas-models/public-runtime';
11
+
12
+ import type { WebsiteStructuredDataPayload } from './website-structured-data.shared.schemas';
13
+
14
+ /**
15
+ * @wildo_source:part:start saas.website.pricing-offer-structured-data facet:layer:core facet:family:website facet:audience:app-developer
16
+ *
17
+ * `buildWebsitePricingOfferStructuredData` — the `AggregateOffer` a pricing page publishes, DERIVED from
18
+ * the application's public pricing catalogue (#1835) instead of typed by hand beside it.
19
+ *
20
+ * ## What it publishes, and what it refuses to
21
+ *
22
+ * A search engine shows an offer's `price` as the price of the offer, so an offer is published only
23
+ * when that is TRUE:
24
+ *
25
+ * - the plan is on sale (`availability: AVAILABLE`) — an upcoming plan cannot be bought;
26
+ * - its price is `LISTED` — a starting price is a floor, and an on-request plan has no price;
27
+ * - its published price states the whole offer for its unit: a flat or per-unit amount, never an
28
+ * indicative amount of a tiered, graduated or package price.
29
+ *
30
+ * Each published offer uses the plan's DEFAULT price (the one the catalogue marks) and carries a
31
+ * `UnitPriceSpecification` saying what the number is FOR: its recurrence as `billingDuration` (ISO 8601
32
+ * `P1M` / `P1Y`) and, for a per-unit price, a `referenceQuantity` of one of the page's unit (`member`)
33
+ * — so "$4 per member per month" is never read as "$4".
34
+ *
35
+ * One `AggregateOffer` per (currency, amount basis, recurrence): a range is only meaningful over prices
36
+ * that measure the same thing, so a flat price and a per-member price, or a monthly and a yearly one, are
37
+ * never folded into one `lowPrice` / `highPrice`. EVERY group's `@id` is `aggregateOfferIdPrefix` suffixed with
38
+ * what distinguishes it (`-per-unit-1-month-usd`), so an aggregate's identity depends only on what it
39
+ * aggregates: adding, removing or reordering a plan in ANOTHER group never moves it. Returns `[]` when
40
+ * nothing may be published — a page with only on-request plans publishes no price at all, which is the
41
+ * true statement.
42
+ */
43
+ export interface BuildWebsitePricingOfferStructuredDataInput {
44
+ readonly catalog: PublicPricingCatalog;
45
+ /** The `@id` of the entity the offers price — typically the page's `SoftwareApplication`. */
46
+ readonly itemOfferedId: string;
47
+ /**
48
+ * The prefix of every `AggregateOffer` `@id` (`https://…/pricing#plans`); each group appends what it
49
+ * aggregates, e.g. `#plans-flat-1-month-usd`.
50
+ */
51
+ readonly aggregateOfferIdPrefix: string;
52
+ /** The URL of the page the offers are made on; each offer's URL is this plus `#plan-<key>`. */
53
+ readonly url: string;
54
+ /**
55
+ * The plan's name in the page's locale. Names are text a person reads, so they come from the page's
56
+ * label pack; a plan the callback does not name is published without one rather than with its key.
57
+ */
58
+ readonly planName?: (planKey: string) => string | undefined;
59
+ /**
60
+ * What ONE unit of a per-unit price is, in the page's locale (`member`, `seat`) — published as the
61
+ * offer's `referenceQuantity.unitText`. Required when any published offer is per-unit: omitting it
62
+ * would state a per-member price as a whole price, so the builder refuses instead.
63
+ */
64
+ readonly perUnitName?: string;
65
+ }
66
+
67
+ export function buildWebsitePricingOfferStructuredData(input: BuildWebsitePricingOfferStructuredDataInput): readonly WebsiteStructuredDataPayload[] {
68
+ const published = input.catalog.plans.flatMap((plan) => {
69
+ const price = publishablePrice(plan);
70
+ return price === undefined ? [] : [{ plan, price }];
71
+ });
72
+ if (published.some(({ price }) => price.amountBasis === PublicPricingAmountBasis.PER_UNIT) && !input.perUnitName) {
73
+ throw new Error(
74
+ '[buildWebsitePricingOfferStructuredData] a published plan is priced per unit, so the page must name the unit '
75
+ + '(`perUnitName`, e.g. "member"); without it the offer would state a per-unit price as the whole price.',
76
+ );
77
+ }
78
+ const groupKeyOf = (price: PublicPricingPrice): string =>
79
+ `${price.currency}|${price.amountBasis}|${price.recurrence.unit}|${price.recurrence.count}`;
80
+ const groupKeys = [...new Set(published.map(({ price }) => groupKeyOf(price)))];
81
+ return groupKeys.map((groupKey) => {
82
+ const offers = published.filter(({ price }) => groupKeyOf(price) === groupKey);
83
+ const sample = offers[0]!.price;
84
+ const amounts = offers.map(({ price }) => publicPricingAmountDecimal(price));
85
+ const numeric = amounts.map(Number);
86
+ return {
87
+ '@type': 'AggregateOffer',
88
+ '@id': `${input.aggregateOfferIdPrefix}-${aggregateSuffix(sample)}`,
89
+ itemOffered: { '@id': input.itemOfferedId },
90
+ priceCurrency: sample.currency,
91
+ lowPrice: amounts[numeric.indexOf(Math.min(...numeric))],
92
+ highPrice: amounts[numeric.indexOf(Math.max(...numeric))],
93
+ offerCount: offers.length,
94
+ url: input.url,
95
+ offers: offers.map(({ plan, price }) => {
96
+ const name = input.planName?.(plan.key);
97
+ const amount = publicPricingAmountDecimal(price);
98
+ return {
99
+ '@type': 'Offer',
100
+ ...(name === undefined ? {} : { name }),
101
+ price: amount,
102
+ priceCurrency: price.currency,
103
+ availability: 'https://schema.org/InStock',
104
+ url: `${input.url}#plan-${plan.key}`,
105
+ priceSpecification: {
106
+ '@type': 'UnitPriceSpecification',
107
+ price: amount,
108
+ priceCurrency: price.currency,
109
+ ...(price.recurrence.unit === PublicPricingRecurrenceUnit.ONE_TIME ? {} : { billingDuration: isoDurationOf(price) }),
110
+ ...(price.amountBasis === PublicPricingAmountBasis.PER_UNIT
111
+ ? { referenceQuantity: { '@type': 'QuantitativeValue', value: 1, unitText: input.perUnitName } }
112
+ : {}),
113
+ },
114
+ };
115
+ }),
116
+ };
117
+ });
118
+ }
119
+
120
+ /** What distinguishes an aggregate's `@id`: its basis, its recurrence and its currency — exactly its group key. */
121
+ function aggregateSuffix(price: PublicPricingPrice): string {
122
+ return `${price.amountBasis}-${price.recurrence.count}-${price.recurrence.unit}-${price.currency}`.toLowerCase().replace(/_/gu, '-');
123
+ }
124
+
125
+ /** The plan's default price, when publishing it as the offer's price would be true. */
126
+ function publishablePrice(plan: PublicPricingPlan): PublicPricingPrice | undefined {
127
+ if (plan.availability !== ProductAvailability.AVAILABLE) return undefined;
128
+ if (plan.priceDisclosure !== ProductPriceDisclosure.LISTED) return undefined;
129
+ const price = plan.prices.find((candidate) => candidate.isDefault) ?? plan.prices[0];
130
+ if (price === undefined || price.amountBasis === PublicPricingAmountBasis.VARIABLE) return undefined;
131
+ return price;
132
+ }
133
+
134
+ /** Total over the recurring units: a member added to the vocabulary is a compile error here until it is mapped. */
135
+ const ISO_DURATION_DESIGNATOR: Record<Exclude<PublicPricingRecurrenceUnit, PublicPricingRecurrenceUnit.ONE_TIME>, string> = {
136
+ [PublicPricingRecurrenceUnit.MONTH]: 'M',
137
+ [PublicPricingRecurrenceUnit.YEAR]: 'Y',
138
+ };
139
+
140
+ function isoDurationOf(price: PublicPricingPrice): string {
141
+ const unit = price.recurrence.unit as Exclude<PublicPricingRecurrenceUnit, PublicPricingRecurrenceUnit.ONE_TIME>;
142
+ return `P${price.recurrence.count}${ISO_DURATION_DESIGNATOR[unit]}`;
143
+ }
144
+ /** @wildo_source:part:end saas.website.pricing-offer-structured-data */
@@ -103,6 +103,28 @@ export type StructuredDataReconciliationFinding =
103
103
  | StructuredDataDeprecatedFieldFinding
104
104
  | StructuredDataCrossEntityReferenceDriftFinding;
105
105
 
106
+ /**
107
+ * An OPTIONAL field a payload leaves out, reported because its catalog entry opted in with
108
+ * `recommendOptionals: true` — the entry's statement that for this type the optional fields are
109
+ * where the richer search and answer-engine results come from.
110
+ *
111
+ * Deliberately NOT a finding. A finding says the payload is wrong (a missing required field, an
112
+ * unknown type, a deprecated field) and `--strict` escalates every finding to a failure. A payload
113
+ * without its optional fields is valid; reporting it through the same channel would fail strict CI for
114
+ * following the vocabulary. So recommendations travel beside the findings, are shown as information,
115
+ * and never change an audit's outcome.
116
+ */
117
+ export interface StructuredDataOptionalFieldRecommendation {
118
+ pageRef: string;
119
+ routePath: string;
120
+ sectionRef: string;
121
+ entityType: string;
122
+ entityId?: string;
123
+ /** The entry's optional fields this payload does not carry, in catalog order. */
124
+ missingOptionalFields: readonly string[];
125
+ message: string;
126
+ }
127
+
106
128
  export interface StructuredDataReconciliationPageResult {
107
129
  pageRef: string;
108
130
  routePath: string;
@@ -112,6 +134,8 @@ export interface StructuredDataReconciliationPageResult {
112
134
 
113
135
  export interface StructuredDataReconciliationResult {
114
136
  findings: StructuredDataReconciliationFinding[];
137
+ /** Optional-field recommendations from entries that opted in — information, never a finding. */
138
+ recommendations: StructuredDataOptionalFieldRecommendation[];
115
139
  pages: StructuredDataReconciliationPageResult[];
116
140
  }
117
141
 
@@ -133,6 +157,7 @@ export function reconcileStructuredDataAgainstCatalog(
133
157
  options: ReconcileStructuredDataOptions,
134
158
  ): StructuredDataReconciliationResult {
135
159
  const findings: StructuredDataReconciliationFinding[] = [];
160
+ const recommendations: StructuredDataOptionalFieldRecommendation[] = [];
136
161
  const pages: StructuredDataReconciliationPageResult[] = [];
137
162
  const sectionsByRef = new Map(
138
163
  options.sections.map((section) => [section.sectionRef, section] as const),
@@ -301,7 +326,7 @@ export function reconcileStructuredDataAgainstCatalog(
301
326
 
302
327
  const routeFindingStart = findings.length;
303
328
  for (const payloadContext of payloadContexts) {
304
- validatePayloadAgainstCatalog(payloadContext, catalogByType, declaredEntityIds, findings);
329
+ validatePayloadAgainstCatalog(payloadContext, catalogByType, declaredEntityIds, findings, recommendations);
305
330
  }
306
331
 
307
332
  pages.push({
@@ -332,6 +357,7 @@ export function reconcileStructuredDataAgainstCatalog(
332
357
 
333
358
  return {
334
359
  findings,
360
+ recommendations,
335
361
  pages,
336
362
  };
337
363
  }
@@ -411,6 +437,7 @@ function validatePayloadAgainstCatalog(
411
437
  catalogByType: ReadonlyMap<string, StructuredDataCatalogEntry>,
412
438
  declaredEntityIds: ReadonlySet<string>,
413
439
  findings: StructuredDataReconciliationFinding[],
440
+ recommendations: StructuredDataOptionalFieldRecommendation[],
414
441
  ): void {
415
442
  const { payload, pageRef, routePath, sectionRef } = payloadContext;
416
443
  const entityType = payload['@type'];
@@ -450,6 +477,24 @@ function validatePayloadAgainstCatalog(
450
477
  }
451
478
  }
452
479
 
480
+ if (catalogEntry.recommendOptionals === true) {
481
+ const missingOptionalFields = (catalogEntry.optionalFields ?? []).filter((field) => !hasPresentFieldValue(payload, field));
482
+ if (missingOptionalFields.length > 0) {
483
+ recommendations.push({
484
+ pageRef,
485
+ routePath,
486
+ sectionRef,
487
+ entityType,
488
+ entityId,
489
+ missingOptionalFields,
490
+ message:
491
+ `Structured-data payload @type="${entityType}" emitted by section "${sectionRef}" on page "${pageRef}" ` +
492
+ `could carry ${missingOptionalFields.map((field) => `"${field}"`).join(', ')} — optional, and the catalog ` +
493
+ 'recommends them for richer search and answer-engine results.',
494
+ });
495
+ }
496
+ }
497
+
453
498
  for (const deprecatedField of catalogEntry.deprecatedFields ?? []) {
454
499
  if (!hasPresentFieldValue(payload, deprecatedField.field)) {
455
500
  continue;
@@ -16,11 +16,13 @@
16
16
  *
17
17
  * - `@type` REQUIRED — the Schema.org entity kind (`'Article'`,
18
18
  * `'BlogPosting'`, `'FAQPage'`, …). Typed as a free-form `string`
19
- * here (not a closed enum) so app-side catalog extensions
20
- * (e.g. `'SoftwareApplication'`, custom Schema.org-extension
21
- * `@type`s) are emittable without a saas-website release. The
22
- * companion's catalog reconciliation surface validates the
23
- * `@type` value against the merged engine + app catalog.
19
+ * here (not a closed enum) because this package ships to the browser
20
+ * and must not carry the catalog: the renderer emits whatever type a
21
+ * section returns. What VALIDATES the type is the Schema.org catalog,
22
+ * through `wildo website audit` and the companion's reconciliation —
23
+ * and that catalog is a closed enum an application can override but
24
+ * not extend, so a type outside it reports `catalog-miss` until the
25
+ * framework adds it.
24
26
  * - `@id?` OPTIONAL — a stable URI identifying the entity. Used for
25
27
  * cross-referencing on the same page (e.g. `Article.publisher`
26
28
  * referencing the page-level `Organization` via `@id`). The
@@ -1,68 +0,0 @@
1
- /**
2
- * Loading what a website provider actually declared, instead of naming it.
3
- *
4
- * `FRONTEND_SDK` was a label on this surface. The layout resolved the analytics,
5
- * error-monitoring and captcha provider refs and stamped them into `data-*`
6
- * attributes on the page root; no consumer loaded anything. A provider could be
7
- * declared, enabled, hydrated — and do nothing at all.
8
- *
9
- * This is the consumer for the DATA half of a provider's executable surface: the
10
- * `scripts` it declares (#332). It serves the case a dynamic `import()` cannot,
11
- * and it is the dominant case on a marketing site: a vendor that ships a
12
- * global-installing `<script>` tag rather than an npm module (reCAPTCHA, tag
13
- * managers, chat widgets, and the CDN distributions of most analytics SDKs).
14
- *
15
- * The npm half — `sdk.activate`, a dynamic import inside the provider package —
16
- * needs the CODE channel, which needs this package to depend on
17
- * `@wildo-ai/external-connectors-public`. That dependency is not added here; see
18
- * the note in the S4a ledger. Nothing below anticipates it: a provider that
19
- * declares both gets its scripts from here and its SDK from there.
20
- *
21
- * ## Client-side injection, and the alternative that was rejected
22
- *
23
- * This is an Astro static site, so the "obvious" home for a third-party tag is
24
- * the built HTML `<head>`. That was rejected for now: the set of live providers
25
- * is per SERVICE and per ENVIRONMENT, resolved from the materialized artifact,
26
- * while the built HTML is one artifact shared across deploys of the same build.
27
- * Baking the tags in would make the build environment-specific — the same class
28
- * of coupling the DATA channel exists to avoid. Injecting from the client keeps
29
- * one build correct everywhere, at the cost of the tag arriving after hydration.
30
- * Revisit if a provider ever needs to run before first paint.
31
- */
32
- import type { ProviderFrontendScriptDeclaration } from '@wildo-ai/saas-models/public-runtime';
33
- import type { FrontendWebsiteProviderRegistry } from './frontend-provider-registry.website';
34
- /**
35
- * Marks a `<script>` this module owns, so a re-render, a client-side route
36
- * change or a second layout mount finds it instead of appending a duplicate.
37
- * A vendor tag loaded twice initialises twice — two analytics clients, two sets
38
- * of events — which is the failure mode this attribute exists to prevent.
39
- */
40
- export declare const PROVIDER_SCRIPT_MARKER_ATTRIBUTE = "data-wildo-provider-script";
41
- export interface DeclaredProviderScript {
42
- readonly providerRef: string;
43
- readonly declaration: ProviderFrontendScriptDeclaration;
44
- }
45
- /**
46
- * Every script declared by the providers live on this surface, in registry
47
- * order, with the declaring ref kept beside each one so a failure can name it.
48
- */
49
- export declare function collectDeclaredProviderScripts(registry: FrontendWebsiteProviderRegistry | undefined): ReadonlyArray<DeclaredProviderScript>;
50
- export interface InjectProviderScriptsResult {
51
- /** Refs whose script this call added. */
52
- readonly injected: ReadonlyArray<string>;
53
- /** Refs whose script was already present, so nothing was added. */
54
- readonly alreadyPresent: ReadonlyArray<string>;
55
- }
56
- /**
57
- * Adds each declared script to the document, once.
58
- *
59
- * Idempotent by `src`: a declaration whose element is already in the document is
60
- * skipped. Callers may therefore run this on every mount without tracking state.
61
- *
62
- * `async` defaults to TRUE when the declaration says nothing. A third-party tag
63
- * that blocks parsing is the single most common way an analytics provider costs
64
- * a marketing site its paint metrics, and a provider author who has not thought
65
- * about it should get the safe answer rather than the blocking one.
66
- */
67
- export declare function injectProviderScripts(scripts: ReadonlyArray<DeclaredProviderScript>, targetDocument: Document): InjectProviderScriptsResult;
68
- //# sourceMappingURL=provider-scripts.website.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"provider-scripts.website.d.ts","sourceRoot":"","sources":["../../../../src/core/external-providers/provider-scripts.website.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,iCAAiC,EAAE,MAAM,sCAAsC,CAAC;AAC9F,OAAO,KAAK,EAAE,+BAA+B,EAAE,MAAM,sCAAsC,CAAC;AAE5F;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,+BAA+B,CAAC;AAE7E,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,WAAW,EAAE,iCAAiC,CAAC;CACzD;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,QAAQ,EAAE,+BAA+B,GAAG,SAAS,GACpD,aAAa,CAAC,sBAAsB,CAAC,CAUvC;AAED,MAAM,WAAW,2BAA2B;IAC1C,yCAAyC;IACzC,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACzC,mEAAmE;IACnE,QAAQ,CAAC,cAAc,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAChD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,aAAa,CAAC,sBAAsB,CAAC,EAC9C,cAAc,EAAE,QAAQ,GACvB,2BAA2B,CAsC7B"}
@@ -1,99 +0,0 @@
1
- /**
2
- * Loading what a website provider actually declared, instead of naming it.
3
- *
4
- * `FRONTEND_SDK` was a label on this surface. The layout resolved the analytics,
5
- * error-monitoring and captcha provider refs and stamped them into `data-*`
6
- * attributes on the page root; no consumer loaded anything. A provider could be
7
- * declared, enabled, hydrated — and do nothing at all.
8
- *
9
- * This is the consumer for the DATA half of a provider's executable surface: the
10
- * `scripts` it declares (#332). It serves the case a dynamic `import()` cannot,
11
- * and it is the dominant case on a marketing site: a vendor that ships a
12
- * global-installing `<script>` tag rather than an npm module (reCAPTCHA, tag
13
- * managers, chat widgets, and the CDN distributions of most analytics SDKs).
14
- *
15
- * The npm half — `sdk.activate`, a dynamic import inside the provider package —
16
- * needs the CODE channel, which needs this package to depend on
17
- * `@wildo-ai/external-connectors-public`. That dependency is not added here; see
18
- * the note in the S4a ledger. Nothing below anticipates it: a provider that
19
- * declares both gets its scripts from here and its SDK from there.
20
- *
21
- * ## Client-side injection, and the alternative that was rejected
22
- *
23
- * This is an Astro static site, so the "obvious" home for a third-party tag is
24
- * the built HTML `<head>`. That was rejected for now: the set of live providers
25
- * is per SERVICE and per ENVIRONMENT, resolved from the materialized artifact,
26
- * while the built HTML is one artifact shared across deploys of the same build.
27
- * Baking the tags in would make the build environment-specific — the same class
28
- * of coupling the DATA channel exists to avoid. Injecting from the client keeps
29
- * one build correct everywhere, at the cost of the tag arriving after hydration.
30
- * Revisit if a provider ever needs to run before first paint.
31
- */
32
- /**
33
- * Marks a `<script>` this module owns, so a re-render, a client-side route
34
- * change or a second layout mount finds it instead of appending a duplicate.
35
- * A vendor tag loaded twice initialises twice — two analytics clients, two sets
36
- * of events — which is the failure mode this attribute exists to prevent.
37
- */
38
- export const PROVIDER_SCRIPT_MARKER_ATTRIBUTE = 'data-wildo-provider-script';
39
- /**
40
- * Every script declared by the providers live on this surface, in registry
41
- * order, with the declaring ref kept beside each one so a failure can name it.
42
- */
43
- export function collectDeclaredProviderScripts(registry) {
44
- if (registry === undefined)
45
- return [];
46
- const collected = [];
47
- for (const provider of registry.modules.values()) {
48
- for (const declaration of provider.scripts ?? []) {
49
- collected.push({ providerRef: provider.metadata.ref, declaration });
50
- }
51
- }
52
- return collected;
53
- }
54
- /**
55
- * Adds each declared script to the document, once.
56
- *
57
- * Idempotent by `src`: a declaration whose element is already in the document is
58
- * skipped. Callers may therefore run this on every mount without tracking state.
59
- *
60
- * `async` defaults to TRUE when the declaration says nothing. A third-party tag
61
- * that blocks parsing is the single most common way an analytics provider costs
62
- * a marketing site its paint metrics, and a provider author who has not thought
63
- * about it should get the safe answer rather than the blocking one.
64
- */
65
- export function injectProviderScripts(scripts, targetDocument) {
66
- const injected = [];
67
- const alreadyPresent = [];
68
- for (const { providerRef, declaration } of scripts) {
69
- // Matched by reading the attribute rather than by a selector carrying the
70
- // URL: a `src` can contain characters a CSS selector would have to escape,
71
- // and an escaping bug here would show up as a duplicated vendor tag — two
72
- // analytics clients, two sets of events — rather than as an error.
73
- const existing = [
74
- ...targetDocument.querySelectorAll(`script[${PROVIDER_SCRIPT_MARKER_ATTRIBUTE}]`),
75
- ].some((element) => element.getAttribute('src') === declaration.src);
76
- if (existing) {
77
- alreadyPresent.push(providerRef);
78
- continue;
79
- }
80
- const element = targetDocument.createElement('script');
81
- element.src = declaration.src;
82
- element.setAttribute(PROVIDER_SCRIPT_MARKER_ATTRIBUTE, providerRef);
83
- if (declaration.integrity !== undefined) {
84
- element.integrity = declaration.integrity;
85
- }
86
- if (declaration.crossOrigin !== undefined) {
87
- element.crossOrigin = declaration.crossOrigin;
88
- }
89
- // Both flags are set explicitly rather than left to the element's defaults:
90
- // a dynamically created script is `async` by default, so `defer: true` alone
91
- // would be silently ignored without the paired `async = false`.
92
- element.async = declaration.async ?? declaration.defer !== true;
93
- element.defer = declaration.defer ?? false;
94
- targetDocument.head.appendChild(element);
95
- injected.push(providerRef);
96
- }
97
- return { injected, alreadyPresent };
98
- }
99
- //# sourceMappingURL=provider-scripts.website.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"provider-scripts.website.js","sourceRoot":"","sources":["../../../../src/core/external-providers/provider-scripts.website.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAKH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,gCAAgC,GAAG,4BAA4B,CAAC;AAO7E;;;GAGG;AACH,MAAM,UAAU,8BAA8B,CAC5C,QAAqD;IAErD,IAAI,QAAQ,KAAK,SAAS;QAAE,OAAO,EAAE,CAAC;IAEtC,MAAM,SAAS,GAA6B,EAAE,CAAC;IAC/C,KAAK,MAAM,QAAQ,IAAI,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC;QACjD,KAAK,MAAM,WAAW,IAAI,QAAQ,CAAC,OAAO,IAAI,EAAE,EAAE,CAAC;YACjD,SAAS,CAAC,IAAI,CAAC,EAAE,WAAW,EAAE,QAAQ,CAAC,QAAQ,CAAC,GAAG,EAAE,WAAW,EAAE,CAAC,CAAC;QACtE,CAAC;IACH,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AASD;;;;;;;;;;GAUG;AACH,MAAM,UAAU,qBAAqB,CACnC,OAA8C,EAC9C,cAAwB;IAExB,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,MAAM,cAAc,GAAa,EAAE,CAAC;IAEpC,KAAK,MAAM,EAAE,WAAW,EAAE,WAAW,EAAE,IAAI,OAAO,EAAE,CAAC;QACnD,0EAA0E;QAC1E,2EAA2E;QAC3E,0EAA0E;QAC1E,mEAAmE;QACnE,MAAM,QAAQ,GAAG;YACf,GAAG,cAAc,CAAC,gBAAgB,CAAC,UAAU,gCAAgC,GAAG,CAAC;SAClF,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC,KAAK,WAAW,CAAC,GAAG,CAAC,CAAC;QACrE,IAAI,QAAQ,EAAE,CAAC;YACb,cAAc,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;YACjC,SAAS;QACX,CAAC;QAED,MAAM,OAAO,GAAG,cAAc,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;QACvD,OAAO,CAAC,GAAG,GAAG,WAAW,CAAC,GAAG,CAAC;QAC9B,OAAO,CAAC,YAAY,CAAC,gCAAgC,EAAE,WAAW,CAAC,CAAC;QAEpE,IAAI,WAAW,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;YACxC,OAAO,CAAC,SAAS,GAAG,WAAW,CAAC,SAAS,CAAC;QAC5C,CAAC;QACD,IAAI,WAAW,CAAC,WAAW,KAAK,SAAS,EAAE,CAAC;YAC1C,OAAO,CAAC,WAAW,GAAG,WAAW,CAAC,WAAW,CAAC;QAChD,CAAC;QACD,4EAA4E;QAC5E,6EAA6E;QAC7E,gEAAgE;QAChE,OAAO,CAAC,KAAK,GAAG,WAAW,CAAC,KAAK,IAAI,WAAW,CAAC,KAAK,KAAK,IAAI,CAAC;QAChE,OAAO,CAAC,KAAK,GAAG,WAAW,CAAC,KAAK,IAAI,KAAK,CAAC;QAE3C,cAAc,CAAC,IAAI,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;QACzC,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;IAC7B,CAAC;IAED,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,CAAC;AACtC,CAAC","sourcesContent":["/**\n * Loading what a website provider actually declared, instead of naming it.\n *\n * `FRONTEND_SDK` was a label on this surface. The layout resolved the analytics,\n * error-monitoring and captcha provider refs and stamped them into `data-*`\n * attributes on the page root; no consumer loaded anything. A provider could be\n * declared, enabled, hydrated — and do nothing at all.\n *\n * This is the consumer for the DATA half of a provider's executable surface: the\n * `scripts` it declares (#332). It serves the case a dynamic `import()` cannot,\n * and it is the dominant case on a marketing site: a vendor that ships a\n * global-installing `<script>` tag rather than an npm module (reCAPTCHA, tag\n * managers, chat widgets, and the CDN distributions of most analytics SDKs).\n *\n * The npm half — `sdk.activate`, a dynamic import inside the provider package —\n * needs the CODE channel, which needs this package to depend on\n * `@wildo-ai/external-connectors-public`. That dependency is not added here; see\n * the note in the S4a ledger. Nothing below anticipates it: a provider that\n * declares both gets its scripts from here and its SDK from there.\n *\n * ## Client-side injection, and the alternative that was rejected\n *\n * This is an Astro static site, so the \"obvious\" home for a third-party tag is\n * the built HTML `<head>`. That was rejected for now: the set of live providers\n * is per SERVICE and per ENVIRONMENT, resolved from the materialized artifact,\n * while the built HTML is one artifact shared across deploys of the same build.\n * Baking the tags in would make the build environment-specific — the same class\n * of coupling the DATA channel exists to avoid. Injecting from the client keeps\n * one build correct everywhere, at the cost of the tag arriving after hydration.\n * Revisit if a provider ever needs to run before first paint.\n */\n\nimport type { ProviderFrontendScriptDeclaration } from '@wildo-ai/saas-models/public-runtime';\nimport type { FrontendWebsiteProviderRegistry } from './frontend-provider-registry.website';\n\n/**\n * Marks a `<script>` this module owns, so a re-render, a client-side route\n * change or a second layout mount finds it instead of appending a duplicate.\n * A vendor tag loaded twice initialises twice — two analytics clients, two sets\n * of events — which is the failure mode this attribute exists to prevent.\n */\nexport const PROVIDER_SCRIPT_MARKER_ATTRIBUTE = 'data-wildo-provider-script';\n\nexport interface DeclaredProviderScript {\n readonly providerRef: string;\n readonly declaration: ProviderFrontendScriptDeclaration;\n}\n\n/**\n * Every script declared by the providers live on this surface, in registry\n * order, with the declaring ref kept beside each one so a failure can name it.\n */\nexport function collectDeclaredProviderScripts(\n registry: FrontendWebsiteProviderRegistry | undefined,\n): ReadonlyArray<DeclaredProviderScript> {\n if (registry === undefined) return [];\n\n const collected: DeclaredProviderScript[] = [];\n for (const provider of registry.modules.values()) {\n for (const declaration of provider.scripts ?? []) {\n collected.push({ providerRef: provider.metadata.ref, declaration });\n }\n }\n return collected;\n}\n\nexport interface InjectProviderScriptsResult {\n /** Refs whose script this call added. */\n readonly injected: ReadonlyArray<string>;\n /** Refs whose script was already present, so nothing was added. */\n readonly alreadyPresent: ReadonlyArray<string>;\n}\n\n/**\n * Adds each declared script to the document, once.\n *\n * Idempotent by `src`: a declaration whose element is already in the document is\n * skipped. Callers may therefore run this on every mount without tracking state.\n *\n * `async` defaults to TRUE when the declaration says nothing. A third-party tag\n * that blocks parsing is the single most common way an analytics provider costs\n * a marketing site its paint metrics, and a provider author who has not thought\n * about it should get the safe answer rather than the blocking one.\n */\nexport function injectProviderScripts(\n scripts: ReadonlyArray<DeclaredProviderScript>,\n targetDocument: Document,\n): InjectProviderScriptsResult {\n const injected: string[] = [];\n const alreadyPresent: string[] = [];\n\n for (const { providerRef, declaration } of scripts) {\n // Matched by reading the attribute rather than by a selector carrying the\n // URL: a `src` can contain characters a CSS selector would have to escape,\n // and an escaping bug here would show up as a duplicated vendor tag — two\n // analytics clients, two sets of events — rather than as an error.\n const existing = [\n ...targetDocument.querySelectorAll(`script[${PROVIDER_SCRIPT_MARKER_ATTRIBUTE}]`),\n ].some((element) => element.getAttribute('src') === declaration.src);\n if (existing) {\n alreadyPresent.push(providerRef);\n continue;\n }\n\n const element = targetDocument.createElement('script');\n element.src = declaration.src;\n element.setAttribute(PROVIDER_SCRIPT_MARKER_ATTRIBUTE, providerRef);\n\n if (declaration.integrity !== undefined) {\n element.integrity = declaration.integrity;\n }\n if (declaration.crossOrigin !== undefined) {\n element.crossOrigin = declaration.crossOrigin;\n }\n // Both flags are set explicitly rather than left to the element's defaults:\n // a dynamically created script is `async` by default, so `defer: true` alone\n // would be silently ignored without the paired `async = false`.\n element.async = declaration.async ?? declaration.defer !== true;\n element.defer = declaration.defer ?? false;\n\n targetDocument.head.appendChild(element);\n injected.push(providerRef);\n }\n\n return { injected, alreadyPresent };\n}\n"]}
@@ -1,146 +0,0 @@
1
- import { beforeEach, describe, expect, it } from 'vitest';
2
- import {
3
- ENGINE_PROVIDER_ORIGIN,
4
- ProviderFrontendScriptCrossOrigin,
5
- ProviderTier,
6
- defineProviderMetadata,
7
- type FrontendProvidersBlock,
8
- } from '@wildo-ai/saas-models/public-runtime';
9
- import { createFrontendWebsiteProviderRegistry } from '../frontend-provider-registry.website';
10
- import {
11
- PROVIDER_SCRIPT_MARKER_ATTRIBUTE,
12
- collectDeclaredProviderScripts,
13
- injectProviderScripts,
14
- } from '../provider-scripts.website';
15
-
16
- function registryWith(scriptsByRef: Record<string, unknown[]>): ReturnType<typeof createFrontendWebsiteProviderRegistry> {
17
- const providers = Object.fromEntries(
18
- Object.entries(scriptsByRef).map(([ref, scripts]) => [
19
- ref,
20
- {
21
- metadata: defineProviderMetadata({
22
- ref,
23
- packageName: `@wildo-ai/provider-${ref}`,
24
- tier: ProviderTier.CATALOGUE,
25
- origin: ENGINE_PROVIDER_ORIGIN,
26
- }),
27
- providerCapabilities: [],
28
- protocols: ['FRONTEND_SDK'],
29
- scripts,
30
- },
31
- ]),
32
- );
33
- return createFrontendWebsiteProviderRegistry({ entries: { providers } as unknown as FrontendProvidersBlock });
34
- }
35
-
36
- function injectedScripts(): HTMLScriptElement[] {
37
- return [...document.querySelectorAll<HTMLScriptElement>(`script[${PROVIDER_SCRIPT_MARKER_ATTRIBUTE}]`)];
38
- }
39
-
40
- describe('website provider scripts', () => {
41
- beforeEach(() => {
42
- document.head.innerHTML = '';
43
- });
44
-
45
- it('collects every declared script with the ref that declared it', () => {
46
- const collected = collectDeclaredProviderScripts(
47
- registryWith({
48
- analytics: [{ src: 'https://cdn.analytics.test/a.js' }],
49
- captcha: [{ src: 'https://captcha.test/api.js' }, { src: 'https://captcha.test/extra.js' }],
50
- }),
51
- );
52
-
53
- expect(collected.map((entry) => `${entry.providerRef}:${entry.declaration.src}`)).toEqual([
54
- 'analytics:https://cdn.analytics.test/a.js',
55
- 'captcha:https://captcha.test/api.js',
56
- 'captcha:https://captcha.test/extra.js',
57
- ]);
58
- });
59
-
60
- it('collects nothing when no registry is hydrated at all', () => {
61
- expect(collectDeclaredProviderScripts(undefined)).toEqual([]);
62
- });
63
-
64
- it('injects a declared script into the document head, carrying its SRI and crossorigin', () => {
65
- const result = injectProviderScripts(
66
- collectDeclaredProviderScripts(
67
- registryWith({
68
- analytics: [
69
- {
70
- src: 'https://cdn.analytics.test/a.js',
71
- integrity: 'sha384-abc',
72
- crossOrigin: ProviderFrontendScriptCrossOrigin.ANONYMOUS,
73
- },
74
- ],
75
- }),
76
- ),
77
- document,
78
- );
79
-
80
- const [element] = injectedScripts();
81
- expect(result.injected).toEqual(['analytics']);
82
- expect(element.src).toBe('https://cdn.analytics.test/a.js');
83
- expect(element.integrity).toBe('sha384-abc');
84
- expect(element.crossOrigin).toBe('anonymous');
85
- expect(element.getAttribute(PROVIDER_SCRIPT_MARKER_ATTRIBUTE)).toBe('analytics');
86
- });
87
-
88
- it('defaults to async, because a blocking third-party tag is how a marketing site loses its paint', () => {
89
- injectProviderScripts(
90
- collectDeclaredProviderScripts(registryWith({ analytics: [{ src: 'https://cdn.analytics.test/a.js' }] })),
91
- document,
92
- );
93
-
94
- expect(injectedScripts()[0].async).toBe(true);
95
- expect(injectedScripts()[0].defer).toBe(false);
96
- });
97
-
98
- it('honours defer by turning async OFF, which a dynamically created script does not do on its own', () => {
99
- // A script element created by script is async by default, so `defer: true`
100
- // alone would be silently ignored — the exact shape of a declaration that
101
- // looks respected and is not.
102
- injectProviderScripts(
103
- collectDeclaredProviderScripts(
104
- registryWith({ analytics: [{ src: 'https://cdn.analytics.test/a.js', defer: true }] }),
105
- ),
106
- document,
107
- );
108
-
109
- expect(injectedScripts()[0].async).toBe(false);
110
- expect(injectedScripts()[0].defer).toBe(true);
111
- });
112
-
113
- it('is idempotent: a second call adds nothing, so a remount cannot start a vendor twice', () => {
114
- const scripts = collectDeclaredProviderScripts(
115
- registryWith({ analytics: [{ src: 'https://cdn.analytics.test/a.js' }] }),
116
- );
117
-
118
- injectProviderScripts(scripts, document);
119
- const second = injectProviderScripts(scripts, document);
120
-
121
- expect(injectedScripts()).toHaveLength(1);
122
- expect(second.injected).toEqual([]);
123
- expect(second.alreadyPresent).toEqual(['analytics']);
124
- });
125
-
126
- it('matches an existing script by its src even when the URL carries query characters', () => {
127
- const scripts = collectDeclaredProviderScripts(
128
- registryWith({ captcha: [{ src: 'https://captcha.test/api.js?render=abc&hl=fr' }] }),
129
- );
130
-
131
- injectProviderScripts(scripts, document);
132
- injectProviderScripts(scripts, document);
133
-
134
- expect(injectedScripts()).toHaveLength(1);
135
- });
136
-
137
- it('adds nothing for a provider that declares no scripts', () => {
138
- const result = injectProviderScripts(
139
- collectDeclaredProviderScripts(registryWith({ analytics: [] })),
140
- document,
141
- );
142
-
143
- expect(result.injected).toEqual([]);
144
- expect(injectedScripts()).toHaveLength(0);
145
- });
146
- });