@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.
- package/dist/esm/astro/blog-post-bridge.d.ts +14 -20
- package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
- package/dist/esm/astro/blog-post-bridge.js +4 -2
- package/dist/esm/astro/blog-post-bridge.js.map +1 -1
- package/dist/esm/astro/bridge-runtime.d.ts +30 -20
- package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
- package/dist/esm/astro/bridge-runtime.js +13 -3
- package/dist/esm/astro/bridge-runtime.js.map +1 -1
- package/dist/esm/astro/label-pack-loader.d.ts.map +1 -1
- package/dist/esm/astro/label-pack-loader.js +5 -0
- package/dist/esm/astro/label-pack-loader.js.map +1 -1
- package/dist/esm/config/load-website-config.d.ts.map +1 -1
- package/dist/esm/config/load-website-config.js +2 -1
- package/dist/esm/config/load-website-config.js.map +1 -1
- package/dist/esm/config/wildo-website-config.schemas.d.ts +10 -5
- package/dist/esm/config/wildo-website-config.schemas.d.ts.map +1 -1
- package/dist/esm/config/wildo-website-config.schemas.js +10 -5
- package/dist/esm/config/wildo-website-config.schemas.js.map +1 -1
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts +3 -3
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js +13 -9
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js.map +1 -1
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts +11 -0
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.js +14 -1
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -1
- package/dist/esm/core/consent/WebsiteConsentBanner.d.ts +6 -0
- package/dist/esm/core/consent/WebsiteConsentBanner.d.ts.map +1 -0
- package/dist/esm/core/consent/WebsiteConsentBanner.js +128 -0
- package/dist/esm/core/consent/WebsiteConsentBanner.js.map +1 -0
- package/dist/esm/core/consent/WebsiteConsentContext.d.ts +85 -0
- package/dist/esm/core/consent/WebsiteConsentContext.d.ts.map +1 -0
- package/dist/esm/core/consent/WebsiteConsentContext.js +89 -0
- package/dist/esm/core/consent/WebsiteConsentContext.js.map +1 -0
- package/dist/esm/core/consent/WebsiteConsentGate.d.ts +27 -0
- package/dist/esm/core/consent/WebsiteConsentGate.d.ts.map +1 -0
- package/dist/esm/core/consent/WebsiteConsentGate.js +9 -0
- package/dist/esm/core/consent/WebsiteConsentGate.js.map +1 -0
- package/dist/esm/core/consent/useWebsiteConsent.d.ts +10 -0
- package/dist/esm/core/consent/useWebsiteConsent.d.ts.map +1 -0
- package/dist/esm/core/consent/useWebsiteConsent.js +17 -0
- package/dist/esm/core/consent/useWebsiteConsent.js.map +1 -0
- package/dist/esm/core/consent/website-consent-storage.d.ts +22 -0
- package/dist/esm/core/consent/website-consent-storage.d.ts.map +1 -0
- package/dist/esm/core/consent/website-consent-storage.js +102 -0
- package/dist/esm/core/consent/website-consent-storage.js.map +1 -0
- package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +8 -1
- package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts.map +1 -1
- package/dist/esm/core/contexts/WebsiteRuntimeContext.js.map +1 -1
- package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts +1 -2
- package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts.map +1 -1
- package/dist/esm/core/external-providers/frontend-provider-registry.website.js.map +1 -1
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts +33 -10
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +64 -16
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -1
- package/dist/esm/core/layouts/WebsitePageLayout.d.ts +21 -1
- package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -1
- package/dist/esm/core/layouts/WebsitePageLayout.js +32 -4
- package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
- package/dist/esm/index.d.ts +6 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts +61 -0
- package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts.map +1 -0
- package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.js +73 -0
- package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +6 -29
- package/src/astro/__tests__/blog-post-bridge.test.tsx +41 -0
- package/src/astro/__tests__/bridge-runtime.test.tsx +25 -0
- package/src/astro/__tests__/label-pack-loader.test.ts +26 -1
- package/src/astro/blog-post-bridge.tsx +18 -22
- package/src/astro/bridge-runtime.tsx +45 -23
- package/src/astro/label-pack-loader.ts +4 -0
- package/src/config/load-website-config.ts +2 -1
- package/src/config/wildo-website-config.schemas.ts +10 -5
- package/src/core/__tests__/WebsitePageLayout.test.tsx +1 -1
- package/src/core/__tests__/website-consent.test.tsx +311 -0
- package/src/core/anonymous-session/__tests__/inbound-contact-form.schema.test.ts +11 -4
- package/src/core/anonymous-session/inbound-contact-form.schema.ts +14 -9
- package/src/core/anonymous-session/website-anonymous-session-client.ts +15 -1
- package/src/core/consent/WebsiteConsentBanner.tsx +222 -0
- package/src/core/consent/WebsiteConsentContext.tsx +164 -0
- package/src/core/consent/WebsiteConsentGate.tsx +34 -0
- package/src/core/consent/useWebsiteConsent.ts +18 -0
- package/src/core/consent/website-consent-storage.ts +116 -0
- package/src/core/contexts/WebsiteRuntimeContext.tsx +8 -1
- package/src/core/external-providers/frontend-provider-registry.website.ts +1 -2
- package/src/core/external-providers/useWebsiteProviderScripts.ts +72 -17
- package/src/core/layouts/WebsitePageLayout.tsx +66 -3
- package/src/index.ts +7 -0
- package/src/schemas/label-keys/__tests__/scaffolded-consent-label-pack.parity.test.ts +71 -0
- package/src/schemas/label-keys/website-consent-label-keys.schemas.ts +78 -0
- package/dist/esm/core/external-providers/provider-scripts.website.d.ts +0 -68
- package/dist/esm/core/external-providers/provider-scripts.website.d.ts.map +0 -1
- package/dist/esm/core/external-providers/provider-scripts.website.js +0 -99
- package/dist/esm/core/external-providers/provider-scripts.website.js.map +0 -1
- package/src/core/external-providers/__tests__/provider-scripts.website.test.ts +0 -146
- 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
|
+
"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.
|
|
69
|
-
"@wildo-ai/platform-config-lib": "1.1.
|
|
70
|
-
"@wildo-ai/presets-components-models": "1.1.
|
|
71
|
-
"@wildo-ai/saas-models": "1.1.
|
|
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
|
-
"
|
|
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(
|
|
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
|
-
*
|
|
319
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
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 '
|
|
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,
|