@wildo-ai/saas-website 1.1.5 → 1.1.7

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 (225) hide show
  1. package/dist/esm/astro/blog-post-bridge.d.ts +9 -0
  2. package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
  3. package/dist/esm/astro/blog-post-bridge.js +10 -5
  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 +25 -6
  7. package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
  8. package/dist/esm/astro/bridge-runtime.js +10 -6
  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 +9 -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 +18 -9
  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.map +1 -1
  59. package/dist/esm/config-loader.d.ts +2 -0
  60. package/dist/esm/config-loader.d.ts.map +1 -1
  61. package/dist/esm/config-loader.js +2 -0
  62. package/dist/esm/config-loader.js.map +1 -1
  63. package/dist/esm/core/anonymous-session/InboundContactForm.d.ts +26 -6
  64. package/dist/esm/core/anonymous-session/InboundContactForm.d.ts.map +1 -1
  65. package/dist/esm/core/anonymous-session/InboundContactForm.js +51 -33
  66. package/dist/esm/core/anonymous-session/InboundContactForm.js.map +1 -1
  67. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts +11 -0
  68. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts.map +1 -1
  69. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js +15 -4
  70. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js.map +1 -1
  71. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts +14 -2
  72. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts.map +1 -1
  73. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js +34 -4
  74. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -1
  75. package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts +36 -0
  76. package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts.map +1 -0
  77. package/dist/esm/core/anonymous-session/website-submission-challenge.js +69 -0
  78. package/dist/esm/core/anonymous-session/website-submission-challenge.js.map +1 -0
  79. package/dist/esm/core/consent/WebsiteConsentBanner.d.ts.map +1 -1
  80. package/dist/esm/core/consent/WebsiteConsentBanner.js +11 -10
  81. package/dist/esm/core/consent/WebsiteConsentBanner.js.map +1 -1
  82. package/dist/esm/core/consent/WebsiteConsentContext.d.ts.map +1 -1
  83. package/dist/esm/core/consent/WebsiteConsentGate.d.ts.map +1 -1
  84. package/dist/esm/core/consent/useWebsiteConsent.d.ts.map +1 -1
  85. package/dist/esm/core/consent/website-consent-storage.d.ts.map +1 -1
  86. package/dist/esm/core/contexts/WebsitePageContext.d.ts.map +1 -1
  87. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +25 -5
  88. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts.map +1 -1
  89. package/dist/esm/core/contexts/WebsiteRuntimeContext.js.map +1 -1
  90. package/dist/esm/core/contexts/WebsiteSectionContext.d.ts.map +1 -1
  91. package/dist/esm/core/contexts/useWebsitePage.d.ts.map +1 -1
  92. package/dist/esm/core/contexts/useWebsiteRuntime.d.ts +18 -0
  93. package/dist/esm/core/contexts/useWebsiteRuntime.d.ts.map +1 -1
  94. package/dist/esm/core/contexts/useWebsiteRuntime.js +28 -2
  95. package/dist/esm/core/contexts/useWebsiteRuntime.js.map +1 -1
  96. package/dist/esm/core/contexts/useWebsiteSection.d.ts.map +1 -1
  97. package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts +57 -0
  98. package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts.map +1 -0
  99. package/dist/esm/core/external-providers/WebsiteProviderComponent.js +56 -0
  100. package/dist/esm/core/external-providers/WebsiteProviderComponent.js.map +1 -0
  101. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts.map +1 -1
  102. package/dist/esm/core/external-providers/provider-component-registry.website.d.ts +63 -0
  103. package/dist/esm/core/external-providers/provider-component-registry.website.d.ts.map +1 -0
  104. package/dist/esm/core/external-providers/provider-component-registry.website.js +68 -0
  105. package/dist/esm/core/external-providers/provider-component-registry.website.js.map +1 -0
  106. package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts +36 -0
  107. package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts.map +1 -0
  108. package/dist/esm/core/external-providers/useWebsiteProviderComponent.js +59 -0
  109. package/dist/esm/core/external-providers/useWebsiteProviderComponent.js.map +1 -0
  110. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts +15 -5
  111. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
  112. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +39 -24
  113. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -1
  114. package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts +11 -0
  115. package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts.map +1 -0
  116. package/dist/esm/core/external-providers/website-provider-sdk-activations.js +14 -0
  117. package/dist/esm/core/external-providers/website-provider-sdk-activations.js.map +1 -0
  118. package/dist/esm/core/factories/define-website-page-manifest.d.ts.map +1 -1
  119. package/dist/esm/core/factories/define-website-section.d.ts +3 -3
  120. package/dist/esm/core/factories/define-website-section.d.ts.map +1 -1
  121. package/dist/esm/core/factories/define-website-section.js +3 -3
  122. package/dist/esm/core/factories/define-website-section.js.map +1 -1
  123. package/dist/esm/core/hooks/useWebsiteDesignTokens.d.ts.map +1 -1
  124. package/dist/esm/core/hooks/useWebsiteLabel.d.ts.map +1 -1
  125. package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -1
  126. package/dist/esm/core/layouts/WebsitePageLayout.js +10 -7
  127. package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
  128. package/dist/esm/core/layouts/WebsiteSection.d.ts.map +1 -1
  129. package/dist/esm/core/routing/internal-routing.utils.d.ts.map +1 -1
  130. package/dist/esm/index.d.ts +30 -65
  131. package/dist/esm/index.d.ts.map +1 -1
  132. package/dist/esm/index.js +30 -65
  133. package/dist/esm/index.js.map +1 -1
  134. package/dist/esm/mdx/BlogPost.d.ts.map +1 -1
  135. package/dist/esm/mdx/blog-post-collection.config.d.ts.map +1 -1
  136. package/dist/esm/mdx/blog-post-frontmatter-source.d.ts.map +1 -1
  137. package/dist/esm/mdx/index.d.ts.map +1 -1
  138. package/dist/esm/mdx.d.ts.map +1 -1
  139. package/dist/esm/schemas/blog/blog-post-frontmatter.shared.schemas.d.ts.map +1 -1
  140. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts +35 -6
  141. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts.map +1 -1
  142. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js +39 -5
  143. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js.map +1 -1
  144. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts.map +1 -1
  145. package/dist/esm/schemas/label-keys/website-label-key.schemas.d.ts.map +1 -1
  146. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts +32 -0
  147. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts.map +1 -0
  148. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js +36 -0
  149. package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js.map +1 -0
  150. package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.d.ts.map +1 -1
  151. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts +9 -8
  152. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts.map +1 -1
  153. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js +8 -7
  154. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js.map +1 -1
  155. package/dist/esm/schemas/refs/page-ref.schemas.d.ts.map +1 -1
  156. package/dist/esm/schemas/refs/section-ref.schemas.d.ts.map +1 -1
  157. package/dist/esm/schemas/sections/website-section-category.shared.d.ts.map +1 -1
  158. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts +14 -7
  159. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts.map +1 -1
  160. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js +14 -7
  161. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js.map +1 -1
  162. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts +57 -0
  163. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts.map +1 -0
  164. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js +76 -0
  165. package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js.map +1 -0
  166. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts +23 -0
  167. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts.map +1 -1
  168. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js +20 -2
  169. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js.map +1 -1
  170. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts +7 -5
  171. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts.map +1 -1
  172. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.js.map +1 -1
  173. package/dist/esm/schemas/validators/label-pack.validator.d.ts.map +1 -1
  174. package/dist/tsconfig.build.tsbuildinfo +1 -1
  175. package/package.json +5 -5
  176. package/src/__tests__/bundle-isolation.test.ts +19 -22
  177. package/src/astro/__tests__/blog-post-bridge.test.tsx +18 -2
  178. package/src/astro/__tests__/bridge-runtime.test.tsx +21 -3
  179. package/src/astro/__tests__/label-pack-loader.test.ts +7 -4
  180. package/src/astro/blog-post-bridge.tsx +19 -5
  181. package/src/astro/bridge-runtime.tsx +35 -11
  182. package/src/astro/label-pack-loader.ts +8 -4
  183. package/src/astro/robots-renderer.ts +15 -15
  184. package/src/astro/website-page-head.renderer.ts +2 -2
  185. package/src/astro/website-site-context.ts +5 -2
  186. package/src/companion-exports.ts +8 -15
  187. package/src/config/__tests__/website-build-origins.test.ts +52 -0
  188. package/src/config/define-website-config.ts +10 -10
  189. package/src/config/load-website-config.ts +25 -14
  190. package/src/config/load-website-pricing-catalog.ts +52 -0
  191. package/src/config/load-website-site-context.ts +145 -0
  192. package/src/config-loader.ts +2 -0
  193. package/src/core/__tests__/contexts.test.tsx +20 -1
  194. package/src/core/__tests__/website-consent.test.tsx +2 -1
  195. package/src/core/anonymous-session/InboundContactForm.tsx +100 -45
  196. package/src/core/anonymous-session/__tests__/InboundContactForm.labels.test.tsx +108 -0
  197. package/src/core/anonymous-session/__tests__/InboundContactForm.submission-challenge.test.tsx +139 -0
  198. package/src/core/anonymous-session/inbound-contact-form.schema.ts +16 -4
  199. package/src/core/anonymous-session/website-anonymous-session-client.ts +39 -4
  200. package/src/core/anonymous-session/website-submission-challenge.ts +99 -0
  201. package/src/core/consent/WebsiteConsentBanner.tsx +11 -10
  202. package/src/core/contexts/WebsiteRuntimeContext.tsx +25 -5
  203. package/src/core/contexts/useWebsiteRuntime.ts +33 -2
  204. package/src/core/external-providers/WebsiteProviderComponent.tsx +153 -0
  205. package/src/core/external-providers/__tests__/frontend-provider-registry.website.test.ts +4 -0
  206. package/src/core/external-providers/__tests__/website-provider-component.test.tsx +238 -0
  207. package/src/core/external-providers/__tests__/website-provider-sdks.test.tsx +72 -0
  208. package/src/core/external-providers/provider-component-registry.website.ts +115 -0
  209. package/src/core/external-providers/useWebsiteProviderComponent.ts +76 -0
  210. package/src/core/external-providers/useWebsiteProviderScripts.ts +56 -37
  211. package/src/core/external-providers/website-provider-sdk-activations.ts +23 -0
  212. package/src/core/factories/define-website-section.ts +3 -3
  213. package/src/core/layouts/WebsitePageLayout.tsx +11 -6
  214. package/src/index.ts +31 -65
  215. package/src/schemas/__tests__/design-token-css-property.test.ts +72 -0
  216. package/src/schemas/__tests__/pricing-offer-structured-data.test.ts +122 -0
  217. package/src/schemas/__tests__/structured-data-reconciliation.test.ts +35 -0
  218. package/src/schemas/design-tokens/website-design-tokens.shared.schemas.ts +45 -5
  219. package/src/schemas/label-keys/__tests__/scaffolded-consent-label-pack.parity.test.ts +12 -0
  220. package/src/schemas/label-keys/website-provider-component-label-keys.schemas.ts +40 -0
  221. package/src/schemas/manifests/website-root-config.shared.schemas.ts +10 -9
  222. package/src/schemas/sections/website-section-definition.shared.schemas.ts +14 -7
  223. package/src/schemas/structured-data/pricing-offer-structured-data.shared.ts +144 -0
  224. package/src/schemas/structured-data/structured-data-reconciliation.shared.ts +46 -1
  225. package/src/schemas/structured-data/website-structured-data.shared.schemas.ts +7 -5
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Reading provider-contributed components on the website (#1703) — the website's counterpart of the
3
+ * application's `useProviderComponentChoices` / `useProviderComponent`, over the same registry and
4
+ * the same status vocabulary (`ProviderComponentStatus`).
5
+ *
6
+ * Two differences from the application, both about WHO answers:
7
+ *
8
+ * - the consent grant is the VISITOR's decision from `WebsiteConsentContext`, not a signed-in
9
+ * person's stored preference;
10
+ * - nothing starts before that decision has been read from storage (`isResolved`). The server render
11
+ * and the first client render read no storage, and asking the registry then would report a
12
+ * consent-required state to a visitor who already agreed, flashing the refusal on every page.
13
+ *
14
+ * Neither hook picks a provider: when several providers offer a component for one capability,
15
+ * choosing is the page's job.
16
+ */
17
+
18
+ import { useEffect, useState } from 'react';
19
+ import {
20
+ idleProviderComponentLoadState,
21
+ startProviderComponentLoad,
22
+ type ProviderComponentLoadState,
23
+ } from '@wildo-ai/external-connectors-public/website';
24
+ import type { ProviderCapability } from '@wildo-ai/saas-models/public-runtime';
25
+
26
+ import { useWebsiteConsent } from '../consent/useWebsiteConsent';
27
+ import { useWebsiteRuntime } from '../contexts/useWebsiteRuntime';
28
+ import type { WebsiteProviderComponentType } from './provider-component-registry.website';
29
+
30
+ /** What `useWebsiteProviderComponent` returns: the shared load state, holding a React component. */
31
+ export type WebsiteProviderComponentState = ProviderComponentLoadState<WebsiteProviderComponentType>;
32
+
33
+ /**
34
+ * The providers that are BOTH live for this website service and carry a component for this
35
+ * capability, in the order the service declared them. Synchronous, so a page can render its choice
36
+ * on the first frame.
37
+ */
38
+ export function useWebsiteProviderComponentChoices(capability: ProviderCapability): ReadonlyArray<string> {
39
+ const { providerComponentRegistry } = useWebsiteRuntime();
40
+ return providerComponentRegistry?.listProviders(capability) ?? [];
41
+ }
42
+
43
+ /**
44
+ * Loads one provider's component for a capability, once the visitor's consent decision is known.
45
+ *
46
+ * Pass `undefined` for the ref while nothing is chosen. The load runs in an effect, so it never runs
47
+ * during the Astro server render; `CONSENT_REQUIRED` means nothing of the provider was downloaded,
48
+ * and granting the missing purposes through the consent panel loads it without a re-mount.
49
+ */
50
+ export function useWebsiteProviderComponent(
51
+ capability: ProviderCapability,
52
+ providerRef: string | undefined,
53
+ ): WebsiteProviderComponentState {
54
+ const { providerComponentRegistry } = useWebsiteRuntime();
55
+ const consent = useWebsiteConsent();
56
+ const grantedKey = [...consent.grantedPurposes].sort().join(',');
57
+ const [state, setState] = useState<WebsiteProviderComponentState>(() => idleProviderComponentLoadState<WebsiteProviderComponentType>());
58
+
59
+ useEffect(() => {
60
+ if (!consent.isResolved) {
61
+ setState(idleProviderComponentLoadState<WebsiteProviderComponentType>());
62
+ return undefined;
63
+ }
64
+ return startProviderComponentLoad({
65
+ registry: providerComponentRegistry,
66
+ capability,
67
+ providerRef,
68
+ grantedConsentPurposes: consent.grantedPurposes,
69
+ onState: setState,
70
+ });
71
+ // `grantedPurposes` is read through `grantedKey`: keyed by the granted SET, not the array's identity.
72
+ // eslint-disable-next-line react-hooks/exhaustive-deps
73
+ }, [capability, providerRef, providerComponentRegistry, consent.isResolved, grantedKey]);
74
+
75
+ return state;
76
+ }
@@ -31,16 +31,18 @@
31
31
  * Revisit if a provider ever needs to run before first paint.
32
32
  */
33
33
 
34
- import { useEffect } from 'react';
34
+ import { useEffect, useMemo, useRef, useState } from 'react';
35
35
  import {
36
36
  activateFrontendProviderSdks,
37
- collectDeclaredProviderScripts,
37
+ collectProviderScriptsWithoutComponents,
38
38
  consentPurposesRequiredByProvider,
39
+ createFrontendProviderSdkActivationLane,
39
40
  injectProviderScripts,
40
- type ActivatedFrontendProviderSdks,
41
41
  } from '@wildo-ai/external-connectors-public/website';
42
42
  import { AppFrontendType, type ConsentPurpose } from '@wildo-ai/saas-models/public-runtime';
43
43
  import type { FrontendWebsiteProviderRegistry } from './frontend-provider-registry.website';
44
+ import type { WebsiteProviderComponentRegistry } from './provider-component-registry.website';
45
+ import type { WebsiteProviderSdkActivation } from './website-provider-sdk-activations';
44
46
 
45
47
  /**
46
48
  * A stable key for a granted-purpose set, so an effect re-runs when the SET changes and not when a new
@@ -50,20 +52,28 @@ function grantedKey(grantedConsentPurposes: readonly ConsentPurpose[]): string {
50
52
  return [...grantedConsentPurposes].sort().join(',');
51
53
  }
52
54
 
55
+ /**
56
+ * Injects the `<script>` tags of the live providers that rest on NO consent purpose and render no component.
57
+ *
58
+ * No consent input, deliberately (#1704): an executed `<script>` cannot be stopped, so a provider that rests on a
59
+ * purpose must ship as a stoppable SDK (started on the grant and stopped on withdrawal by
60
+ * {@link useWebsiteProviderSdks}); a consent-based module declaring `scripts` is refused at projection and skipped by
61
+ * the collector. No grant could change what this injects.
62
+ */
53
63
  export function useWebsiteProviderScripts(
54
64
  registry: FrontendWebsiteProviderRegistry | undefined,
55
65
  /**
56
- * The purposes the visitor granted (#534). A provider resting on an ungranted purpose injects nothing;
57
- * granting it later injects its script then. A withdrawal cannot remove an executed script, so it takes
58
- * effect on the next page load.
66
+ * The website's provider-component registry (#1703). A provider with a registered component loads its
67
+ * scripts WITH that component, when a page first shows it — so an address lookup's vendor loader is
68
+ * not downloaded on every page of the site, only on the one with the form. This hook injects the rest.
59
69
  */
60
- grantedConsentPurposes: readonly ConsentPurpose[],
70
+ componentRegistry?: WebsiteProviderComponentRegistry,
61
71
  ): void {
62
- const key = grantedKey(grantedConsentPurposes);
63
72
  useEffect(() => {
64
73
  if (typeof document === 'undefined') return;
65
74
 
66
- const scripts = collectDeclaredProviderScripts(registry?.modules.values(), grantedConsentPurposes);
75
+ const withComponents = [...(componentRegistry?.providersWithComponents() ?? [])].map((providerRef) => ({ providerRef }));
76
+ const scripts = collectProviderScriptsWithoutComponents(registry?.modules.values() ?? [], withComponents);
67
77
  if (scripts.length === 0) return;
68
78
 
69
79
  injectProviderScripts(scripts, document);
@@ -71,8 +81,7 @@ export function useWebsiteProviderScripts(
71
81
  // work immediately; removing the element undoes neither, so a cleanup that
72
82
  // removed it would only make the next mount inject a SECOND copy of an
73
83
  // already-running script. Idempotence by `src` is the real guard.
74
- // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by the granted SET, see grantedKey.
75
- }, [registry, key]);
84
+ }, [registry, componentRegistry]);
76
85
  }
77
86
 
78
87
  /**
@@ -91,16 +100,20 @@ export function useWebsiteProviderSdks(
91
100
  * is withdrawn — which is the only way a withdrawal stops a running analytics client.
92
101
  */
93
102
  grantedConsentPurposes: readonly ConsentPurpose[],
94
- ): void {
95
- useProviderSdkGroup(registry, false, []);
96
- useProviderSdkGroup(registry, true, grantedConsentPurposes);
103
+ ): ReadonlyArray<WebsiteProviderSdkActivation> {
104
+ const consentFree = useProviderSdkGroup(registry, false, []);
105
+ const consentBased = useProviderSdkGroup(registry, true, grantedConsentPurposes);
106
+ // The providers RUNNING on this page, both groups, published by the page layout (#1735): a form reads the captcha
107
+ // vendor's handle from here. Emptied the moment a group stops, so nothing holds a handle past a withdrawal.
108
+ return useMemo(() => [...consentFree, ...consentBased], [consentFree, consentBased]);
97
109
  }
98
110
 
99
111
  function useProviderSdkGroup(
100
112
  registry: FrontendWebsiteProviderRegistry | undefined,
101
113
  consentBased: boolean,
102
114
  grantedConsentPurposes: readonly ConsentPurpose[],
103
- ): void {
115
+ ): ReadonlyArray<WebsiteProviderSdkActivation> {
116
+ const [running, setRunning] = useState<ReadonlyArray<WebsiteProviderSdkActivation>>([]);
104
117
  // Keyed by the grants this group's providers actually rest on, so changing an unrelated purpose does
105
118
  // not stop and restart a running analytics client.
106
119
  const relevant = registry === undefined
@@ -110,6 +123,8 @@ function useProviderSdkGroup(
110
123
  .flatMap((module) => consentPurposesRequiredByProvider(module))
111
124
  .filter((purpose) => grantedConsentPurposes.includes(purpose));
112
125
  const key = grantedKey(relevant);
126
+ // One lane per group for the page's lifetime: a start waits for the previous stop.
127
+ const lane = useRef(createFrontendProviderSdkActivationLane());
113
128
  useEffect(() => {
114
129
  if (registry === undefined) return;
115
130
  const modules = [...registry.modules.values()].filter(
@@ -117,31 +132,35 @@ function useProviderSdkGroup(
117
132
  );
118
133
  if (modules.length === 0) return;
119
134
 
120
- let unmounted = false;
121
- let activated: ActivatedFrontendProviderSdks | undefined;
122
-
123
- void activateFrontendProviderSdks({
124
- surface: AppFrontendType.STATIC_WEBSITE,
125
- modules,
126
- grantedConsentPurposes,
127
- onDiagnostic: (diagnostic) => {
128
- // The website runtime has no logger service, and a provider that cannot
129
- // start is a developer-facing fact that must not be silent.
130
- // eslint-disable-next-line no-console
131
- console.warn(`[wildo-provider-sdk] ${diagnostic.kind}: ${diagnostic.message}`);
135
+ // Through the group's lane, so a start never overlaps the previous stop of the same page-global vendor (#1704): a
136
+ // visitor who grants, withdraws and grants again while the bundle downloads must not have the first run's stop
137
+ // switch off the second run's client. The stop also covers an unmount mid-download.
138
+ const stop = lane.current.start(
139
+ () => activateFrontendProviderSdks({
140
+ surface: AppFrontendType.STATIC_WEBSITE,
141
+ modules,
142
+ grantedConsentPurposes,
143
+ onDiagnostic: (diagnostic) => {
144
+ // The website runtime has no logger service, and a provider that cannot
145
+ // start is a developer-facing fact that must not be silent.
146
+ // eslint-disable-next-line no-console
147
+ console.warn(`[wildo-provider-sdk] ${diagnostic.kind}: ${diagnostic.message}`);
148
+ },
149
+ }),
150
+ (result) => {
151
+ setRunning(modules.flatMap((module) => {
152
+ const activation = result.activations.get(module.metadata.ref);
153
+ return activation === undefined
154
+ ? []
155
+ : [{ providerRef: module.metadata.ref, providerCapabilities: [...module.providerCapabilities], activation }];
156
+ }));
132
157
  },
133
- }).then((result) => {
134
- activated = result;
135
- // Activation is asynchronous and unmount is not: if the surface went away
136
- // while a vendor bundle was still downloading, stop what we just started
137
- // rather than leaving a client nobody can reach.
138
- if (unmounted) void result.deactivateAll();
139
- });
140
-
158
+ );
141
159
  return () => {
142
- unmounted = true;
143
- void activated?.deactivateAll();
160
+ setRunning([]);
161
+ stop();
144
162
  };
145
163
  // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by the granted SET, see grantedKey.
146
164
  }, [registry, consentBased, key]);
165
+ return running;
147
166
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The provider SDKs RUNNING on this website page (#1735), published by the page layout.
3
+ *
4
+ * A section reaches a running vendor through here rather than through a global — the contact form asks the consented
5
+ * captcha vendor for a token this way. The list is emptied the moment a provider is stopped (a consent withdrawal, a
6
+ * navigation), so a consumer that reads it at the moment it needs a handle never holds one past a withdrawal.
7
+ */
8
+ import { createContext, useContext } from 'react';
9
+ import type { ProviderFrontendSdkActivation } from '@wildo-ai/external-connectors-public/website';
10
+
11
+ /** One running provider: its ref, the capabilities it serves, and its activation (whose `handle` is the vendor's client). */
12
+ export interface WebsiteProviderSdkActivation {
13
+ readonly providerRef: string;
14
+ readonly providerCapabilities: ReadonlyArray<string>;
15
+ readonly activation: ProviderFrontendSdkActivation;
16
+ }
17
+
18
+ export const WebsiteProviderSdkActivationsContext = createContext<ReadonlyArray<WebsiteProviderSdkActivation>>([]);
19
+
20
+ /** The provider SDKs running on this page, in declaration order. Empty outside a page layout and before any start. */
21
+ export function useWebsiteProviderSdkActivations(): ReadonlyArray<WebsiteProviderSdkActivation> {
22
+ return useContext(WebsiteProviderSdkActivationsContext);
23
+ }
@@ -71,9 +71,9 @@ export interface TypedWebsiteSectionDefinition<TProps>
71
71
  * **Why a factory instead of constructing the literal directly**:
72
72
  * - Consumers don't have to remember to parse refs / keys
73
73
  * themselves before assembling the literal.
74
- * - The schema parse provides a single audited validation chokepoint
75
- * so `wildo website audit` (Phase 7) can re-validate definitions
76
- * without re-entering the React graph.
74
+ * - The schema parse provides a single audited validation chokepoint,
75
+ * and the parsed `expectedLabelKeys` are what `wildo website audit`
76
+ * checks every locale pack against, without re-entering the React graph.
77
77
  * - The factory is the documented authoring API the
78
78
  * `website_factory_authoring` skill targets — keeping the
79
79
  * authoring surface narrow lets the framework evolve the
@@ -7,8 +7,10 @@ import {
7
7
  useWebsiteProviderScripts,
8
8
  useWebsiteProviderSdks,
9
9
  } from '../external-providers/useWebsiteProviderScripts';
10
+ import { WebsiteProviderSdkActivationsContext } from '../external-providers/website-provider-sdk-activations';
10
11
  import type { WebsitePageContextValue } from '../contexts/WebsitePageContext';
11
12
  import { useWebsiteRuntime } from '../contexts/useWebsiteRuntime';
13
+ import { websiteDesignTokenCssVar } from '../../schemas/design-tokens/website-design-tokens.shared.schemas';
12
14
  import { useWebsiteLabelByKey } from '../hooks/useWebsiteLabel';
13
15
  import { WebsiteConsentProvider, WebsiteConsentStatus, type WebsiteConsentRecorder } from '../consent/WebsiteConsentContext';
14
16
  import { useWebsiteConsent } from '../consent/useWebsiteConsent';
@@ -100,10 +102,10 @@ const SKIP_LINK_FOCUS_CSS = `
100
102
  overflow: visible !important;
101
103
  clip: auto !important;
102
104
  white-space: normal !important;
103
- background: var(--website-colors-background, #fff) !important;
104
- color: var(--website-colors-foreground, #000) !important;
105
- border: 2px solid var(--website-colors-primary, #000) !important;
106
- border-radius: var(--website-radii-md, 0.375rem) !important;
105
+ background: ${websiteDesignTokenCssVar('colors', 'background', '#fff')} !important;
106
+ color: ${websiteDesignTokenCssVar('colors', 'foreground', '#000')} !important;
107
+ border: 2px solid ${websiteDesignTokenCssVar('colors', 'primary', '#000')} !important;
108
+ border-radius: ${websiteDesignTokenCssVar('radii', 'md', '0.375rem')} !important;
107
109
  z-index: 9999 !important;
108
110
  text-decoration: none !important;
109
111
  }
@@ -281,8 +283,9 @@ function WebsitePageLayoutContent({
281
283
  //
282
284
  // Consent-based providers start only once their purpose is granted (#534). Before the stored decision is
283
285
  // read, nothing is granted — the static HTML and the first render start nothing optional.
284
- useWebsiteProviderScripts(runtime.frontendProviderRegistry, consent.grantedPurposes);
285
- useWebsiteProviderSdks(runtime.frontendProviderRegistry, consent.grantedPurposes);
286
+ useWebsiteProviderScripts(runtime.frontendProviderRegistry, runtime.providerComponentRegistry);
287
+ // The running SDKs are published to the page (#1735): the contact form asks the consented captcha vendor for a token.
288
+ const runningProviderSdks = useWebsiteProviderSdks(runtime.frontendProviderRegistry, consent.grantedPurposes);
286
289
 
287
290
  const providerRootAttributes = useMemo<Record<string, string>>(() => {
288
291
  const attrs: Record<string, string> = {};
@@ -330,6 +333,7 @@ function WebsitePageLayoutContent({
330
333
 
331
334
  return (
332
335
  <WebsitePageContextProvider value={pageContextValue}>
336
+ <WebsiteProviderSdkActivationsContext.Provider value={runningProviderSdks}>
333
337
  <div
334
338
  data-website-page-ref={safePageRef as unknown as string}
335
339
  className={className}
@@ -357,6 +361,7 @@ function WebsitePageLayoutContent({
357
361
  {resolvedFooter !== null ? <footer>{resolvedFooter}</footer> : null}
358
362
  {renderConsentSurface(consent, runtime.consentBannerComponent)}
359
363
  </div>
364
+ </WebsiteProviderSdkActivationsContext.Provider>
360
365
  </WebsitePageContextProvider>
361
366
  );
362
367
  }
package/src/index.ts CHANGED
@@ -11,75 +11,33 @@
11
11
  * accompanying SKILLs in `framework-knowledge`.
12
12
  *
13
13
  * @wildo-boundary
14
- * - This barrel MAY re-export from any subdirectory of `src/`.
15
- * - Subdirectories MUST observe their own boundary contracts (see
16
- * each subdirectory's own `index.ts` `@wildo-boundary` block):
14
+ * This barrel re-exports the React-side surface: `schemas/`, `core/`,
15
+ * `components/`. The other entries are separate subpaths with their own
16
+ * audiences — `/astro` (Node, Astro frontmatter), `/astro-island` (the
17
+ * bridge factory), `/config` and `/config-loader` (Node), `/mdx`,
18
+ * `/companion` (React-free). Per directory:
17
19
  * - `schemas/` : NO React, NO Astro, NO `~wildo-frontend-lib/**`
18
- * - `seo/` : NO React, NO Astro
19
- * - `core/` : MAY use React; NO `~wildo-frontend-lib/**` (Q17=B)
20
- * - `astro/` : MAY use Astro; NO React component runtime imports
20
+ * - `core/`, `components/`: MAY use React; NO `~wildo-frontend-lib/**` (Q17=B)
21
+ * - `astro/` : MAY use Astro and Node; NO React component runtime imports
22
+ * - `config/` : Node-side loaders; never reachable from a React-island entry
21
23
  * - `mdx/` : MAY use `@mdx-js/*`
22
- * - `integration/` : MAY use React; MAY import the
23
- * `@wildo-ai/saas-models/public-runtime` SUBPATH,
24
- * the `@wildo-ai/presets-components-models/public-runtime`
25
- * SUBPATH, `@wildo-ai/zod-decorators`,
26
- * plus the SSR / Astro-middleware tier
27
- * (`axios`, `jsonwebtoken`, `socket.io-client`,
28
- * `reflect-metadata`) — D12 revised
29
- * 2026-04-19 (second same-day loosening);
30
- * the ROOT barrels of `@wildo-ai/saas-models`
31
- * and `@wildo-ai/presets-components-models`
32
- * are FORBIDDEN (they boot the decorator
33
- * framework / pull the heavy registry);
34
- * the saas-app DI / backend stack
35
- * (`inversify`, `mongoose`, `express`,
36
- * `pino`, `helmet`, `argon2`, `bcrypt`,
37
- * the `@wildo-ai/saas-frontend-lib` /
38
- * `@wildo-ai/saas-backend-lib` /
39
- * `@wildo-ai/external-connectors-models`
40
- * root + subpath surfaces) is FORBIDDEN;
41
- * MUST NOT import `~wildo-frontend-lib/**`
42
- * (Q17=B / D16)
43
24
  *
44
- * D12 (client-bundle import discipline, all `core/`/`schemas/`/`integration/`
45
- * files): the entire saas-website source tree ships into a browser bundle
46
- * (Astro SSG + React islands). Every shared model atom MUST come from a
47
- * side-effect-free `/public-runtime` subpath:
48
- * - `@wildo-ai/saas-models/public-runtime` is the primary door
49
- * (saas-models-public-runtime.md K-6 — consumers see one path).
50
- * - `@wildo-ai/presets-components-models/public-runtime` is the
51
- * internal seam for symbols not (yet) re-exported by saas-models.
52
- * - `@wildo-ai/zod-decorators` is allowed to import (first revision
53
- * 2026-04-19). The package itself is side-effect-free; only an
54
- * explicit `initZodDecorators()` call mutates `z.ZodType.prototype`,
55
- * which neither saas-website nor the saas-models /public-runtime
56
- * barrel does.
57
- * - `axios`, `jsonwebtoken`, `socket.io-client`, `reflect-metadata` are
58
- * allowed for SSR / Astro-middleware code paths (second same-day
59
- * revision 2026-04-19). The website engine needs them to provide
60
- * middleware utilities (HTTP proxying to the SaaS backend, JWT
61
- * verification of cross-frontend handoff codes, real-time push,
62
- * decorator-metadata reads). They are SSR-tier — discipline
63
- * (and a future strict/loose entry split for React islands vs
64
- * middleware) keeps them out of the React-island bundle.
25
+ * D12 (client-bundle import discipline): everything reachable from a
26
+ * React-island entry ships to the browser, so every shared model atom
27
+ * comes from a side-effect-free `/public-runtime` subpath —
28
+ * `@wildo-ai/saas-models/public-runtime` first,
29
+ * `@wildo-ai/presets-components-models/public-runtime` for symbols it does
30
+ * not re-export yet — and `@wildo-ai/zod-decorators` is allowed because
31
+ * only an explicit `initZodDecorators()` mutates Zod's prototype.
65
32
  *
66
- * The ROOT barrel of `@wildo-ai/saas-models` is forbidden everywhere in
67
- * this package — it calls `initZodDecorators()` at module load and pulls
68
- * `@wildo-ai/external-connectors-models` + the full schema graph into
69
- * the bundle. Same forbid applies to the root barrel of
70
- * `@wildo-ai/presets-components-models`, the saas-app DI runtime
71
- * (`inversify`, `@wildo-ai/saas-frontend-lib`, `@wildo-ai/saas-backend-lib`),
72
- * and pure-backend libs (`mongoose`, `express`, `pino`, `helmet`,
73
- * `argon2`, `bcrypt`). Enforced by `src/__tests__/bundle-isolation.test.ts`.
74
- *
75
- * These boundaries are enforced by review + the bundle-isolation test
76
- * above until Phase 8 promotes them to a programmatic ESLint check
77
- * (cf. `saas-website-slice.md`
78
- * §"DEFERRED to Phase 8 — boundary lint mechanism design").
79
- *
80
- * Phase 1+2 status: schemas + runtime contexts/hooks + layout
81
- * primitives + L1 placeholders + factories are all exported. Phase 5
82
- * adds the Astro adapter; Phase 5.7 the `integration/` layer.
33
+ * The ROOT barrels of `@wildo-ai/saas-models` and
34
+ * `@wildo-ai/presets-components-models` are forbidden everywhere in this
35
+ * package (they boot the decorator framework and pull the full schema
36
+ * graph), as are the saas-app DI runtime (`inversify`,
37
+ * `@wildo-ai/saas-frontend-lib`, `@wildo-ai/saas-backend-lib`) and
38
+ * pure-backend libs (`mongoose`, `express`, `pino`, `helmet`, `argon2`,
39
+ * `bcrypt`). Enforced by `src/__tests__/bundle-isolation.test.ts`, which
40
+ * walks every entry's import graph against an allow-list.
83
41
  *
84
42
  * D18 (L1 boundary, Phase 2): the L1 primitives (`WebsiteHeading`,
85
43
  * `WebsiteText`, `WebsiteButton`, `WebsiteLink`, `WebsiteImage`) are
@@ -96,10 +54,12 @@ export * from './schemas/refs/page-ref.schemas';
96
54
  export * from './schemas/refs/section-ref.schemas';
97
55
  export * from './schemas/label-keys/website-label-key.schemas';
98
56
  export * from './schemas/label-keys/website-consent-label-keys.schemas';
57
+ export * from './schemas/label-keys/website-provider-component-label-keys.schemas';
99
58
  export * from './schemas/sections/website-section-category.shared';
100
59
  export * from './schemas/sections/website-section-definition.shared.schemas';
101
60
  export * from './schemas/structured-data/website-structured-data.shared.schemas';
102
61
  export * from './schemas/structured-data/structured-data-reconciliation.shared';
62
+ export * from './schemas/structured-data/pricing-offer-structured-data.shared';
103
63
  export * from './schemas/design-tokens/website-design-tokens.shared.schemas';
104
64
  export * from './schemas/manifests/website-page-manifest.shared.schemas';
105
65
  export * from './schemas/manifests/website-root-config.shared.schemas';
@@ -125,9 +85,15 @@ export * from './core/consent/useWebsiteConsent';
125
85
  export * from './core/consent/WebsiteConsentGate';
126
86
  export * from './core/consent/WebsiteConsentBanner';
127
87
 
88
+ export * from './core/external-providers/provider-component-registry.website';
89
+ export * from './core/external-providers/useWebsiteProviderComponent';
90
+ export * from './core/external-providers/WebsiteProviderComponent';
91
+ export * from './core/external-providers/website-provider-sdk-activations';
92
+
128
93
  export * from './core/anonymous-session/website-anonymous-session-client';
129
94
  export * from './core/anonymous-session/inbound-contact-form.schema';
130
95
  export * from './core/anonymous-session/InboundContactForm';
96
+ export * from './core/anonymous-session/website-submission-challenge';
131
97
 
132
98
  export * from './components/low-level/WebsiteHeading';
133
99
  export * from './components/low-level/WebsiteText';
@@ -0,0 +1,72 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { renderWebsiteDesignTokensCss } from '../../astro/website-page-head.renderer';
7
+ import {
8
+ WebsiteDesignTokensSchema,
9
+ websiteDesignTokenCssProperty,
10
+ websiteDesignTokenCssVar,
11
+ type WebsiteDesignTokens,
12
+ } from '../design-tokens/website-design-tokens.shared.schemas';
13
+
14
+ const TOKENS: WebsiteDesignTokens = WebsiteDesignTokensSchema.parse({
15
+ colors: {
16
+ background: '#fff',
17
+ foreground: '#111',
18
+ primary: '#222',
19
+ primaryForeground: '#fff',
20
+ accent: '#333',
21
+ accentForeground: '#fff',
22
+ surface: '#eee',
23
+ surfaceForeground: '#444',
24
+ border: '#ccc',
25
+ muted: '#ddd',
26
+ mutedForeground: '#555',
27
+ },
28
+ radii: { none: '0', sm: '0.25rem', md: '0.5rem', lg: '0.75rem', xl: '1rem', full: '9999px' },
29
+ });
30
+
31
+ const SOURCE_ROOT = join(__dirname, '..', '..');
32
+
33
+ function sourceFiles(dir: string): string[] {
34
+ return readdirSync(dir).flatMap((entry) => {
35
+ const full = join(dir, entry);
36
+ if (statSync(full).isDirectory()) return entry === '__tests__' ? [] : sourceFiles(full);
37
+ return /\.(ts|tsx)$/.test(entry) ? [full] : [];
38
+ });
39
+ }
40
+
41
+ describe('design-token CSS property naming', () => {
42
+ it('the renderer emits every token under the name the readers reference', () => {
43
+ const css = renderWebsiteDesignTokensCss(TOKENS);
44
+ for (const [group, values] of Object.entries(TOKENS)) {
45
+ for (const token of Object.keys(values)) {
46
+ expect(css).toContain(`${websiteDesignTokenCssProperty(group, token)}:`);
47
+ }
48
+ }
49
+ // The key is verbatim: the reader the consent banner used to be spelled `surface-foreground`.
50
+ expect(css).toContain('--website-colors-surfaceForeground: #444;');
51
+ expect(websiteDesignTokenCssVar('colors', 'surfaceForeground', '#000')).toBe('var(--website-colors-surfaceForeground, #000)');
52
+ expect(websiteDesignTokenCssVar('radii', 'md')).toBe('var(--website-radii-md)');
53
+ });
54
+
55
+ it('no engine source spells a token custom property by hand', () => {
56
+ // Positive control: the walk reaches the component that used to carry the defect.
57
+ const files = sourceFiles(SOURCE_ROOT);
58
+ expect(files.some((file) => file.endsWith('WebsiteConsentBanner.tsx'))).toBe(true);
59
+
60
+ const offenders = files
61
+ .filter((file) => !file.endsWith('website-design-tokens.shared.schemas.ts'))
62
+ .flatMap((file) =>
63
+ readFileSync(file, 'utf8')
64
+ .split('\n')
65
+ .map((line, index) => ({ line, index }))
66
+ // A `var(--website-…)` in code, not in a comment explaining the naming rule.
67
+ .filter(({ line }) => /var\(--website-/.test(line) && !/^\s*(\*|\/\/)/.test(line))
68
+ .map(({ index }) => `${relative(SOURCE_ROOT, file)}:${index + 1}`),
69
+ );
70
+ expect(offenders).toEqual([]);
71
+ });
72
+ });