@wildo-ai/saas-website 1.1.3 → 1.1.5

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 (101) hide show
  1. package/dist/esm/astro/blog-post-bridge.d.ts +14 -20
  2. package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
  3. package/dist/esm/astro/blog-post-bridge.js +4 -2
  4. package/dist/esm/astro/blog-post-bridge.js.map +1 -1
  5. package/dist/esm/astro/bridge-runtime.d.ts +30 -20
  6. package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
  7. package/dist/esm/astro/bridge-runtime.js +13 -3
  8. package/dist/esm/astro/bridge-runtime.js.map +1 -1
  9. package/dist/esm/astro/label-pack-loader.d.ts.map +1 -1
  10. package/dist/esm/astro/label-pack-loader.js +5 -0
  11. package/dist/esm/astro/label-pack-loader.js.map +1 -1
  12. package/dist/esm/config/load-website-config.d.ts.map +1 -1
  13. package/dist/esm/config/load-website-config.js +2 -1
  14. package/dist/esm/config/load-website-config.js.map +1 -1
  15. package/dist/esm/config/wildo-website-config.schemas.d.ts +10 -5
  16. package/dist/esm/config/wildo-website-config.schemas.d.ts.map +1 -1
  17. package/dist/esm/config/wildo-website-config.schemas.js +10 -5
  18. package/dist/esm/config/wildo-website-config.schemas.js.map +1 -1
  19. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts +3 -3
  20. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts.map +1 -1
  21. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js +13 -9
  22. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js.map +1 -1
  23. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts +11 -0
  24. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts.map +1 -1
  25. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js +14 -1
  26. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -1
  27. package/dist/esm/core/consent/WebsiteConsentBanner.d.ts +6 -0
  28. package/dist/esm/core/consent/WebsiteConsentBanner.d.ts.map +1 -0
  29. package/dist/esm/core/consent/WebsiteConsentBanner.js +128 -0
  30. package/dist/esm/core/consent/WebsiteConsentBanner.js.map +1 -0
  31. package/dist/esm/core/consent/WebsiteConsentContext.d.ts +85 -0
  32. package/dist/esm/core/consent/WebsiteConsentContext.d.ts.map +1 -0
  33. package/dist/esm/core/consent/WebsiteConsentContext.js +89 -0
  34. package/dist/esm/core/consent/WebsiteConsentContext.js.map +1 -0
  35. package/dist/esm/core/consent/WebsiteConsentGate.d.ts +27 -0
  36. package/dist/esm/core/consent/WebsiteConsentGate.d.ts.map +1 -0
  37. package/dist/esm/core/consent/WebsiteConsentGate.js +9 -0
  38. package/dist/esm/core/consent/WebsiteConsentGate.js.map +1 -0
  39. package/dist/esm/core/consent/useWebsiteConsent.d.ts +10 -0
  40. package/dist/esm/core/consent/useWebsiteConsent.d.ts.map +1 -0
  41. package/dist/esm/core/consent/useWebsiteConsent.js +17 -0
  42. package/dist/esm/core/consent/useWebsiteConsent.js.map +1 -0
  43. package/dist/esm/core/consent/website-consent-storage.d.ts +22 -0
  44. package/dist/esm/core/consent/website-consent-storage.d.ts.map +1 -0
  45. package/dist/esm/core/consent/website-consent-storage.js +102 -0
  46. package/dist/esm/core/consent/website-consent-storage.js.map +1 -0
  47. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +8 -1
  48. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts.map +1 -1
  49. package/dist/esm/core/contexts/WebsiteRuntimeContext.js.map +1 -1
  50. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts +1 -2
  51. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts.map +1 -1
  52. package/dist/esm/core/external-providers/frontend-provider-registry.website.js.map +1 -1
  53. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts +33 -10
  54. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
  55. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +64 -16
  56. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -1
  57. package/dist/esm/core/layouts/WebsitePageLayout.d.ts +21 -1
  58. package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -1
  59. package/dist/esm/core/layouts/WebsitePageLayout.js +32 -4
  60. package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
  61. package/dist/esm/index.d.ts +6 -0
  62. package/dist/esm/index.d.ts.map +1 -1
  63. package/dist/esm/index.js +6 -0
  64. package/dist/esm/index.js.map +1 -1
  65. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts +61 -0
  66. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts.map +1 -0
  67. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.js +73 -0
  68. package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.js.map +1 -0
  69. package/dist/tsconfig.build.tsbuildinfo +1 -1
  70. package/package.json +6 -29
  71. package/src/astro/__tests__/blog-post-bridge.test.tsx +41 -0
  72. package/src/astro/__tests__/bridge-runtime.test.tsx +25 -0
  73. package/src/astro/__tests__/label-pack-loader.test.ts +26 -1
  74. package/src/astro/blog-post-bridge.tsx +18 -22
  75. package/src/astro/bridge-runtime.tsx +45 -23
  76. package/src/astro/label-pack-loader.ts +4 -0
  77. package/src/config/load-website-config.ts +2 -1
  78. package/src/config/wildo-website-config.schemas.ts +10 -5
  79. package/src/core/__tests__/WebsitePageLayout.test.tsx +1 -1
  80. package/src/core/__tests__/website-consent.test.tsx +311 -0
  81. package/src/core/anonymous-session/__tests__/inbound-contact-form.schema.test.ts +11 -4
  82. package/src/core/anonymous-session/inbound-contact-form.schema.ts +14 -9
  83. package/src/core/anonymous-session/website-anonymous-session-client.ts +15 -1
  84. package/src/core/consent/WebsiteConsentBanner.tsx +222 -0
  85. package/src/core/consent/WebsiteConsentContext.tsx +164 -0
  86. package/src/core/consent/WebsiteConsentGate.tsx +34 -0
  87. package/src/core/consent/useWebsiteConsent.ts +18 -0
  88. package/src/core/consent/website-consent-storage.ts +116 -0
  89. package/src/core/contexts/WebsiteRuntimeContext.tsx +8 -1
  90. package/src/core/external-providers/frontend-provider-registry.website.ts +1 -2
  91. package/src/core/external-providers/useWebsiteProviderScripts.ts +72 -17
  92. package/src/core/layouts/WebsitePageLayout.tsx +66 -3
  93. package/src/index.ts +7 -0
  94. package/src/schemas/label-keys/__tests__/scaffolded-consent-label-pack.parity.test.ts +71 -0
  95. package/src/schemas/label-keys/website-consent-label-keys.schemas.ts +78 -0
  96. package/dist/esm/core/external-providers/provider-scripts.website.d.ts +0 -68
  97. package/dist/esm/core/external-providers/provider-scripts.website.d.ts.map +0 -1
  98. package/dist/esm/core/external-providers/provider-scripts.website.js +0 -99
  99. package/dist/esm/core/external-providers/provider-scripts.website.js.map +0 -1
  100. package/src/core/external-providers/__tests__/provider-scripts.website.test.ts +0 -146
  101. package/src/core/external-providers/provider-scripts.website.ts +0 -126
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wildo-ai/saas-website",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "SaaS marketing/landing website engine — schemas, helpers, layout/primitive placeholders, Astro/MDX adapters, and SEO/AEO/GEO builder library. Consumers author all business sections themselves via the defineWebsiteSection factory.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Wildo AI",
@@ -65,10 +65,10 @@
65
65
  "test:watch": "vitest"
66
66
  },
67
67
  "dependencies": {
68
- "@wildo-ai/external-connectors-public": "1.1.3",
69
- "@wildo-ai/platform-config-lib": "1.1.3",
70
- "@wildo-ai/presets-components-models": "1.1.3",
71
- "@wildo-ai/saas-models": "1.1.3",
68
+ "@wildo-ai/external-connectors-public": "1.1.5",
69
+ "@wildo-ai/platform-config-lib": "1.1.5",
70
+ "@wildo-ai/presets-components-models": "1.1.5",
71
+ "@wildo-ai/saas-models": "1.1.5",
72
72
  "yaml": "^2.3.4"
73
73
  },
74
74
  "devDependencies": {
@@ -96,19 +96,11 @@
96
96
  },
97
97
  "peerDependencies": {
98
98
  "@astrojs/react": "^3.0.0",
99
- "@astrojs/sitemap": "^3.7.2",
100
99
  "@mdx-js/react": "^3.0.0",
101
100
  "astro": "^4.0.0",
102
- "axios": "^1.6.8",
103
- "clsx": "^2.1.0",
104
- "jsonwebtoken": "^9.0.2",
105
101
  "react": "^19.0.0",
106
102
  "react-dom": "^19.0.0",
107
- "react-intl": "^8.0.0",
108
- "reflect-metadata": "^0.2.2",
109
- "socket.io-client": "^4.7.5",
110
- "tailwind-merge": "^3.3.1",
111
- "tailwind-variants": "^3.1.0"
103
+ "zod": "^4.3.6"
112
104
  },
113
105
  "peerDependenciesMeta": {
114
106
  "astro": {
@@ -117,23 +109,8 @@
117
109
  "@astrojs/react": {
118
110
  "optional": true
119
111
  },
120
- "@astrojs/sitemap": {
121
- "optional": true
122
- },
123
112
  "@mdx-js/react": {
124
113
  "optional": true
125
- },
126
- "axios": {
127
- "optional": true
128
- },
129
- "jsonwebtoken": {
130
- "optional": true
131
- },
132
- "reflect-metadata": {
133
- "optional": true
134
- },
135
- "socket.io-client": {
136
- "optional": true
137
114
  }
138
115
  },
139
116
  "repository": {
@@ -1,3 +1,4 @@
1
+ import type { GeneratedWebsiteProviderModules } from '../../core/external-providers/frontend-provider-registry.website';
1
2
  import '@testing-library/jest-dom/vitest';
2
3
  import { render, screen } from '@testing-library/react';
3
4
  import { useMDXComponents } from '@mdx-js/react';
@@ -94,6 +95,46 @@ function ProviderAwareMdxBody(): React.ReactElement {
94
95
  }
95
96
 
96
97
  describe('createWebsiteBlogPostBridgeIsland', () => {
98
+ it('keeps executable provider code in the bridge closure while props survive JSON serialization', () => {
99
+ const activate = async () => ({ handle: 'test' });
100
+ let observed: unknown;
101
+ function ProviderProbe() {
102
+ const registry = useWebsiteRuntime().frontendProviderRegistry;
103
+ observed = registry?.modules.values().next().value?.sdk?.activate;
104
+ return <span>provider closure</span>;
105
+ }
106
+ const entry = { ...FRONTEND_PROVIDERS.providers.sentry, sdk: { packageName: '@test/sdk' } };
107
+ const modules = { sentry: { ...entry, sdk: { descriptor: entry.sdk, activate } } } as GeneratedWebsiteProviderModules;
108
+ const Island = createWebsiteBlogPostBridgeIsland({
109
+ slugToMdxBody: { demo: ProviderProbe },
110
+ navigation: buildNavigation(),
111
+ frontendProviderModules: modules,
112
+ });
113
+ const props = JSON.parse(JSON.stringify({
114
+ locale: AvailableLanguage.EN, defaultLocale: AvailableLanguage.EN,
115
+ labelPack: SKIP_LABEL_PACK, designTokens: DESIGN_TOKENS, skipToMainContentLabelKey: SKIP_KEY,
116
+ frontendProviders: { providers: { sentry: entry } }, slug: 'demo', rawFrontmatter: VALID_FRONTMATTER,
117
+ }));
118
+ render(<Island {...props} />);
119
+ expect(observed).toBe(activate);
120
+ });
121
+
122
+ it('carries the configured product origin into blog navigation and MDX content', () => {
123
+ function OriginProbe() {
124
+ return <span data-testid="blog-app-origin">{useWebsiteRuntime().appOrigin}</span>;
125
+ }
126
+ const Island = createWebsiteBlogPostBridgeIsland({
127
+ slugToMdxBody: { demo: OriginProbe }, navigation: buildNavigation(),
128
+ });
129
+ render(<Island
130
+ slug="demo" rawFrontmatter={VALID_FRONTMATTER}
131
+ locale={AvailableLanguage.EN} defaultLocale={AvailableLanguage.EN}
132
+ labelPack={SKIP_LABEL_PACK} designTokens={DESIGN_TOKENS} skipToMainContentLabelKey={SKIP_KEY}
133
+ appOrigin="https://product.example"
134
+ />);
135
+ expect(screen.getByTestId('blog-app-origin').textContent).toBe('https://product.example');
136
+ });
137
+
97
138
  it('returns a React component (closes over deps; never crosses prop boundary)', () => {
98
139
  const Island = createWebsiteBlogPostBridgeIsland({
99
140
  slugToMdxBody: { 'why-context-beats-content': MdxBodyA },
@@ -1,3 +1,4 @@
1
+ import type { GeneratedWebsiteProviderModules } from '../../core/external-providers/frontend-provider-registry.website';
1
2
  import '@testing-library/jest-dom/vitest';
2
3
  import { render, screen } from '@testing-library/react';
3
4
  import React from 'react';
@@ -92,6 +93,30 @@ function RuntimeProbe(): React.ReactNode {
92
93
  }
93
94
 
94
95
  describe('createWebsitePageBridgeIsland', () => {
96
+ it('keeps executable provider code in the bridge closure while props survive JSON serialization', () => {
97
+ const activate = async () => ({ handle: 'test' });
98
+ let observed: unknown;
99
+ function ProviderProbe() {
100
+ const registry = useWebsiteRuntime().frontendProviderRegistry;
101
+ observed = registry?.modules.values().next().value?.sdk?.activate;
102
+ return <span>provider closure</span>;
103
+ }
104
+ const entry = { ...FRONTEND_PROVIDERS.providers.posthog, sdk: { packageName: '@test/sdk' } };
105
+ const modules = { posthog: { ...entry, sdk: { descriptor: entry.sdk, activate } } } as GeneratedWebsiteProviderModules;
106
+ const Island = createWebsitePageBridgeIsland({
107
+ PageComponent: ProviderProbe, pageRef: PAGE_REF,
108
+ navigation: buildNavigation(),
109
+ frontendProviderModules: modules,
110
+ });
111
+ const props = JSON.parse(JSON.stringify({
112
+ locale: AvailableLanguage.EN, defaultLocale: AvailableLanguage.EN,
113
+ labelPack: SKIP_LABEL_PACK, designTokens: DESIGN_TOKENS, skipToMainContentLabelKey: SKIP_KEY,
114
+ frontendProviders: { providers: { posthog: entry } },
115
+ }));
116
+ render(<Island {...props} />);
117
+ expect(observed).toBe(activate);
118
+ });
119
+
95
120
  it('returns a React component (closes over deps; never crosses prop boundary)', () => {
96
121
  const Island = createWebsitePageBridgeIsland({
97
122
  PageComponent: PageProbe,
@@ -1,3 +1,4 @@
1
+ import { WEBSITE_CONSENT_LABEL_KEYS } from '../../schemas/label-keys/website-consent-label-keys.schemas';
1
2
  import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
3
  import { tmpdir } from 'node:os';
3
4
  import { resolve } from 'node:path';
@@ -60,6 +61,8 @@ const buildManifest = () =>
60
61
  });
61
62
 
62
63
  const SKIP_KEY_STR = 'website.chrome.layout.skip-to-main-content';
64
+ // Framework-owned consent-panel keys (#534): required in every pack, like the skip link.
65
+ const CONSENT_LABELS: Record<string, string> = Object.fromEntries(WEBSITE_CONSENT_LABEL_KEYS.map((key) => [key as unknown as string, 'X']));
63
66
 
64
67
  const buildRootConfig = (): WebsiteRootConfig =>
65
68
  WebsiteRootConfigSchema.parse({
@@ -207,7 +210,7 @@ describe('label-pack-loader', () => {
207
210
  writeFileSync(resolve(tmpDir, 'fr.json'), JSON.stringify({}));
208
211
  expect(() =>
209
212
  assertLabelPacksValid(tmpDir, buildSiteContext(), buildExpectedKeys()),
210
- ).toThrow(/Locale "fr".*missing 4 required key\(s\)/s);
213
+ ).toThrow(new RegExp(`Locale "fr".*missing ${4 + WEBSITE_CONSENT_LABEL_KEYS.length} required key\\(s\\)`, 's'));
211
214
  });
212
215
 
213
216
  it('annotates each missing key with its declaring page / section origin', () => {
@@ -261,6 +264,7 @@ describe('label-pack-loader', () => {
261
264
  'website.landing.meta.title': 'X',
262
265
  'website.landing.meta.description': 'X',
263
266
  [SKIP_KEY_STR]: 'Skip to main content',
267
+ ...CONSENT_LABELS,
264
268
  });
265
269
  writeFileSync(resolve(tmpDir, 'en.json'), fullPack);
266
270
  writeFileSync(resolve(tmpDir, 'fr.json'), fullPack);
@@ -277,6 +281,7 @@ describe('label-pack-loader', () => {
277
281
  'website.landing.meta.description': 'X',
278
282
  'website.landing.hero.unused-orphan': 'X',
279
283
  [SKIP_KEY_STR]: 'Skip to main content',
284
+ ...CONSENT_LABELS,
280
285
  });
281
286
  writeFileSync(resolve(tmpDir, 'en.json'), packWithOrphan);
282
287
  writeFileSync(resolve(tmpDir, 'fr.json'), packWithOrphan);
@@ -289,6 +294,26 @@ describe('label-pack-loader', () => {
289
294
  warnSpy.mockRestore();
290
295
  });
291
296
 
297
+ it('requires every framework-owned consent-panel key in every locale pack (#534)', () => {
298
+ const withoutConsent = JSON.stringify({
299
+ 'website.landing.hero.headline': 'X',
300
+ 'website.landing.meta.title': 'X',
301
+ 'website.landing.meta.description': 'X',
302
+ [SKIP_KEY_STR]: 'Skip to main content',
303
+ });
304
+ writeFileSync(resolve(tmpDir, 'en.json'), withoutConsent);
305
+ writeFileSync(resolve(tmpDir, 'fr.json'), JSON.stringify({ ...JSON.parse(withoutConsent), ...CONSENT_LABELS }));
306
+ let captured = '';
307
+ try {
308
+ assertLabelPacksValid(tmpDir, buildSiteContext(), buildExpectedKeys());
309
+ } catch (err) {
310
+ captured = (err as Error).message;
311
+ }
312
+ expect(captured).toMatch(/Locale "en":/);
313
+ expect(captured).not.toMatch(/Locale "fr":/);
314
+ for (const key of WEBSITE_CONSENT_LABEL_KEYS) expect(captured).toContain(key as unknown as string);
315
+ });
316
+
292
317
  it('treats WebsiteRootConfig.skipToMainContentLabelKey as required (Phase 6 third audit fix M-α)', () => {
293
318
  const partialPack = JSON.stringify({
294
319
  'website.landing.hero.headline': 'X',
@@ -222,6 +222,12 @@ import type { LabelPack } from '../schemas/validators/label-pack.validator';
222
222
  * island. NEVER cross the hydration boundary as serialized props.
223
223
  */
224
224
  export interface CreateWebsiteBlogPostBridgeIslandDeps {
225
+ /**
226
+ * Browser CODE imported by the consumer bridge, closed over at module load.
227
+ * Never pass executable modules through Astro island props: those are serialized.
228
+ * Config sync emits a stable map, including an empty map when no providers are declared.
229
+ */
230
+ frontendProviderModules?: GeneratedWebsiteProviderModules;
225
231
  /**
226
232
  * Per-slug map of compiled MDX bodies (each entry is a React
227
233
  * component — the `.default` export of the MDX module). Typically
@@ -256,6 +262,11 @@ export interface CreateWebsiteBlogPostBridgeIslandDeps {
256
262
  * serialization boundary.
257
263
  */
258
264
  navigation: WebsiteNavigationConfig;
265
+ /**
266
+ * Optional replacement for the framework's visitor consent panel (#534). Closed over for the same
267
+ * reason as `navigation`. See `WebsiteRuntimeContextValue.consentBannerComponent`.
268
+ */
269
+ consentBannerComponent?: ComponentType;
259
270
  /**
260
271
  * MDX-tag → React-component map forwarded into `<BlogPost>` and
261
272
  * scoped via `<WebsiteMdxProvider>`. Typically a small object
@@ -314,26 +325,9 @@ export interface WebsiteBlogPostBridgeIslandProps {
314
325
  skipToMainContentLabelKey: WebsiteLabelKey;
315
326
  /** Optional website-facing public providers materialized at build time. */
316
327
  frontendProviders?: FrontendProvidersBlock;
317
- /**
318
- * The CODE channel for those providers (#332): the
319
- * `generatedFrontendProviderModules` export of this website service's own
320
- * `src/generated/frontend-providers.generated.ts`, written by
321
- * `wildo config sync`.
322
- *
323
- * `frontendProviders` above says WHICH providers this service turned on, in
324
- * this environment; this says what code the bundle actually contains for
325
- * them. Both are needed, because a JSON payload cannot carry an SDK loader
326
- * and a bundle cannot know which environment it is running in.
327
- *
328
- * NOT a JSON-serializable island prop like its siblings, and it does not need
329
- * to be: the bridge module is bundled with the page, so the generated module
330
- * map is an ordinary import in the same graph rather than something that has
331
- * to cross the hydration boundary.
332
- *
333
- * Omitted is the ordinary case for a website declaring no package-shipped
334
- * provider: no such file is generated, so there is nothing to import.
335
- */
336
- frontendProviderModules?: GeneratedWebsiteProviderModules;
328
+ /** Product origin for navigation and call-to-action components inside the blog island.
329
+ * Supplied by the Astro page from websiteConfig, just as on ordinary page bridges. */
330
+ appOrigin?: string;
337
331
  }
338
332
 
339
333
  const EMPTY_MDX_COMPONENTS: WebsiteMdxComponentsMap = Object.freeze({});
@@ -364,7 +358,7 @@ export function createWebsiteBlogPostBridgeIsland(
364
358
  designTokens,
365
359
  skipToMainContentLabelKey,
366
360
  frontendProviders,
367
- frontendProviderModules,
361
+ appOrigin,
368
362
  }: WebsiteBlogPostBridgeIslandProps): ReactNode {
369
363
  const MdxBody = slugToMdxBody[slug];
370
364
  if (MdxBody === undefined) {
@@ -396,9 +390,11 @@ export function createWebsiteBlogPostBridgeIsland(
396
390
  designTokens,
397
391
  navigation,
398
392
  skipToMainContentLabelKey,
393
+ appOrigin,
394
+ ...(deps.consentBannerComponent !== undefined ? { consentBannerComponent: deps.consentBannerComponent } : {}),
399
395
  frontendProviderRegistry: createFrontendWebsiteProviderRegistry({
400
396
  entries: frontendProviders,
401
- modules: frontendProviderModules,
397
+ modules: deps.frontendProviderModules,
402
398
  }),
403
399
  };
404
400
  return (
@@ -1,3 +1,4 @@
1
+ import { useMemo } from 'react';
1
2
  import type { ComponentType, FunctionComponent, ReactNode } from 'react';
2
3
 
3
4
  import type { FrontendProvidersBlock } from '@wildo-ai/saas-models/public-runtime';
@@ -10,6 +11,7 @@ import {
10
11
  import { createFrontendWebsiteProviderRegistry } from '../core/external-providers/frontend-provider-registry.website';
11
12
  import type { GeneratedWebsiteProviderModules } from '../core/external-providers/frontend-provider-registry.website';
12
13
  import { WebsitePageLayout } from '../core/layouts/WebsitePageLayout';
14
+ import type { WebsiteConsentRecorder } from '../core/consent/WebsiteConsentContext';
13
15
  import type { WebsiteDesignTokens } from '../schemas/design-tokens/website-design-tokens.shared.schemas';
14
16
  import type { WebsiteLabelKey } from '../schemas/label-keys/website-label-key.schemas';
15
17
  import type { WebsiteNavigationConfig } from '../schemas/manifests/website-root-config.shared.schemas';
@@ -144,6 +146,12 @@ import type { LabelPack } from '../schemas/validators/label-pack.validator';
144
146
  * island. NEVER cross the hydration boundary as serialized props.
145
147
  */
146
148
  export interface CreateWebsitePageBridgeIslandDeps {
149
+ /**
150
+ * Browser CODE imported by the consumer bridge, closed over at module load.
151
+ * Never pass executable modules through Astro island props: those are serialized.
152
+ * Config sync emits a stable map, including an empty map when no providers are declared.
153
+ */
154
+ frontendProviderModules?: GeneratedWebsiteProviderModules;
147
155
  /**
148
156
  * The page's React component. Typically the same value that was
149
157
  * passed to `defineWebsitePageManifest({ pageComponent })` — the
@@ -160,6 +168,24 @@ export interface CreateWebsitePageBridgeIslandDeps {
160
168
  * runtime-safe.
161
169
  */
162
170
  PageComponent: ComponentType<any>;
171
+ /**
172
+ * Builds the recorder that persists a VISITOR's consent decision server-side, from the island's
173
+ * runtime `apiOrigin` (#1258).
174
+ *
175
+ * A DEPS entry rather than an island prop, for the reason this whole interface exists: it is a
176
+ * function, and island props are serialized. A factory rather than an instance, because the
177
+ * `apiOrigin` it needs is resolved per environment by the `.astro` page and only reaches the island
178
+ * as a prop.
179
+ *
180
+ * The engine cannot build this itself — a client needs the application's own
181
+ * `frontendServiceName`, which the engine has no way to know. The application already constructs
182
+ * one for its capture forms; passing THAT factory here is what makes a visitor's consent decision,
183
+ * their captured lead and their draft note share a single `uuidRef` and transpose together.
184
+ *
185
+ * Optional. Omitted, a decision lives in the visitor's browser and nowhere else — the behaviour
186
+ * before this existed, and correct for a site with no anonymous-session wiring.
187
+ */
188
+ consentRecorderFactory?: (apiOrigin: string) => WebsiteConsentRecorder;
163
189
  /**
164
190
  * The active page's ref. Carried as a primitive (string-branded)
165
191
  * value so the inner `WebsitePageLayout` can register the
@@ -173,6 +199,11 @@ export interface CreateWebsitePageBridgeIslandDeps {
173
199
  * serialization boundary.
174
200
  */
175
201
  navigation: WebsiteNavigationConfig;
202
+ /**
203
+ * Optional replacement for the framework's visitor consent panel (#534). Closed over for the same
204
+ * reason as `navigation`. See `WebsiteRuntimeContextValue.consentBannerComponent`.
205
+ */
206
+ consentBannerComponent?: ComponentType;
176
207
  }
177
208
 
178
209
  /**
@@ -212,26 +243,6 @@ export interface WebsitePageBridgeIslandProps {
212
243
  skipToMainContentLabelKey: WebsiteLabelKey;
213
244
  /** Optional website-facing public providers materialized at build time. */
214
245
  frontendProviders?: FrontendProvidersBlock;
215
- /**
216
- * The CODE channel for those providers (#332): the
217
- * `generatedFrontendProviderModules` export of this website service's own
218
- * `src/generated/frontend-providers.generated.ts`, written by
219
- * `wildo config sync`.
220
- *
221
- * `frontendProviders` above says WHICH providers this service turned on, in
222
- * this environment; this says what code the bundle actually contains for
223
- * them. Both are needed, because a JSON payload cannot carry an SDK loader
224
- * and a bundle cannot know which environment it is running in.
225
- *
226
- * NOT a JSON-serializable island prop like its siblings, and it does not need
227
- * to be: the bridge module is bundled with the page, so the generated module
228
- * map is an ordinary import in the same graph rather than something that has
229
- * to cross the hydration boundary.
230
- *
231
- * Omitted is the ordinary case for a website declaring no package-shipped
232
- * provider: no such file is generated, so there is nothing to import.
233
- */
234
- frontendProviderModules?: GeneratedWebsiteProviderModules;
235
246
  /**
236
247
  * Optional backend API origin (no trailing slash) forwarded into
237
248
  * `WebsiteRuntimeContext.apiOrigin` for anonymous-capture sections
@@ -280,10 +291,20 @@ export function createWebsitePageBridgeIsland(
280
291
  designTokens,
281
292
  skipToMainContentLabelKey,
282
293
  frontendProviders,
283
- frontendProviderModules,
284
294
  apiOrigin,
285
295
  appOrigin,
286
296
  }: WebsitePageBridgeIslandProps): ReactNode {
297
+ /*
298
+ * Both must be present: the factory is build-time wiring and `apiOrigin` is per-environment, and
299
+ * a page that declares no capture section legitimately has neither. Memoised on `apiOrigin` so a
300
+ * re-render cannot hand the provider a new identity and re-post a decision nobody retook.
301
+ */
302
+ const consentRecorder = useMemo(
303
+ () => (deps.consentRecorderFactory !== undefined && apiOrigin !== undefined
304
+ ? deps.consentRecorderFactory(apiOrigin)
305
+ : undefined),
306
+ [apiOrigin],
307
+ );
287
308
  const runtimeValue: WebsiteRuntimeContextValue = {
288
309
  locale,
289
310
  defaultLocale,
@@ -293,14 +314,15 @@ export function createWebsitePageBridgeIsland(
293
314
  skipToMainContentLabelKey,
294
315
  frontendProviderRegistry: createFrontendWebsiteProviderRegistry({
295
316
  entries: frontendProviders,
296
- modules: frontendProviderModules,
317
+ modules: deps.frontendProviderModules,
297
318
  }),
319
+ ...(deps.consentBannerComponent !== undefined ? { consentBannerComponent: deps.consentBannerComponent } : {}),
298
320
  apiOrigin,
299
321
  appOrigin,
300
322
  };
301
323
  return (
302
324
  <WebsiteRuntimeContextProvider value={runtimeValue}>
303
- <WebsitePageLayout pageRef={pageRef}>
325
+ <WebsitePageLayout pageRef={pageRef} {...(consentRecorder !== undefined ? { consentRecorder } : {})}>
304
326
  <PageComponent />
305
327
  </WebsitePageLayout>
306
328
  </WebsiteRuntimeContextProvider>
@@ -4,6 +4,7 @@ import { resolve } from 'node:path';
4
4
  import type { AvailableLanguage } from '@wildo-ai/saas-models/public-runtime';
5
5
 
6
6
  import type { WebsiteLabelKey } from '../schemas/label-keys/website-label-key.schemas';
7
+ import { WEBSITE_CONSENT_LABEL_KEYS } from '../schemas/label-keys/website-consent-label-keys.schemas';
7
8
  import type { WebsitePageManifest } from '../schemas/manifests/website-page-manifest.shared.schemas';
8
9
  import type { WebsiteRootConfig } from '../schemas/manifests/website-root-config.shared.schemas';
9
10
  import {
@@ -207,6 +208,9 @@ function composeEffectiveChromeLabelKeys(
207
208
  ): readonly WebsiteLabelKey[] {
208
209
  const set = new Set<WebsiteLabelKey>(rootConfig.chromeLabelKeys);
209
210
  set.add(rootConfig.skipToMainContentLabelKey);
211
+ // The visitor consent panel is framework-owned in the same way (#534): required in every pack, so a site
212
+ // that turns on analytics can never ship it with an unlabelled way to refuse.
213
+ for (const key of WEBSITE_CONSENT_LABEL_KEYS) set.add(key);
210
214
  return Array.from(set);
211
215
  }
212
216
 
@@ -37,6 +37,7 @@ import { AppFrontendType, loadConfig } from '@wildo-ai/platform-config-lib';
37
37
  import type { WildoSaasConfig } from '@wildo-ai/platform-config-lib';
38
38
  import type { WildoMarketingWebsiteConfig } from './wildo-website-config.schemas';
39
39
 
40
+ import { describeThrownValue } from '@wildo-ai/saas-models/public-runtime';
40
41
  /**
41
42
  * Conventional file name for the per-service marketing-website
42
43
  * configuration (Step 7.5 refactor — `saas-website-slice.md` /
@@ -213,7 +214,7 @@ export async function loadWebsiteConfig(
213
214
  try {
214
215
  loaded = await loadConfig<WildoMarketingWebsiteConfig>(websiteConfigPath);
215
216
  } catch (e) {
216
- const cause = e instanceof Error ? e.message : String(e);
217
+ const cause = describeThrownValue(e);
217
218
  throw new Error(
218
219
  `[wildo-website-config] Failed to load per-service marketing-website config at `
219
220
  + `${websiteConfigPath}. The file MUST do `
@@ -195,11 +195,16 @@ export const WildoMarketingWebsiteConfigSchema = z
195
195
  * Origin of the SaaS application the marketing site links into
196
196
  * (signup / login / pricing → checkout).
197
197
  *
198
- * **Today**: NO runtime consumer. The cross-origin handoff
199
- * surface that will read it does not exist yet. Authored
200
- * eagerly so the link-surface PR is a single framework-owned move
201
- * — until then the field is exercised only by the schema
202
- * parser.
198
+ * Read at runtime since #493: each `.astro` page forwards it to its
199
+ * bridge island, and the CTA reads it off `WebsiteRuntimeContext`.
200
+ *
201
+ * Per environment: a deployment's app lives at a different host in each
202
+ * environment, and a static site bakes the value in at build time, so the
203
+ * generated deploy workflow passes the environment's app origin as
204
+ * `PUBLIC_APP_ORIGIN` — derived from the infra config's `serviceHosts` and
205
+ * `publicDomain`, not authored a second time. An application's
206
+ * `wildo.website.config.ts` reads that first and keeps its authored value
207
+ * as the default for a build that passes none.
203
208
  *
204
209
  * Distinct from the general `WildoSaasConfig.services.<name>`
205
210
  * URL surface because the marketing site is a separate Astro
@@ -5,7 +5,7 @@ import '@testing-library/jest-dom';
5
5
  import { BUILTIN_PROVIDER_CAPABILITY, defineProviderMetadata, ProviderOriginKind, ProviderTier } from '@wildo-ai/saas-models/public-runtime';
6
6
 
7
7
  import { WebsitePageLayout } from '../layouts/WebsitePageLayout';
8
- import { PROVIDER_SCRIPT_MARKER_ATTRIBUTE } from '../external-providers/provider-scripts.website';
8
+ import { PROVIDER_SCRIPT_MARKER_ATTRIBUTE } from '@wildo-ai/external-connectors-public/website';
9
9
  import { createFrontendWebsiteProviderRegistry } from '../external-providers/frontend-provider-registry.website';
10
10
  import {
11
11
  WebsiteRuntimeContextProvider,