@wildo-ai/saas-website 1.1.2 → 1.1.3

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 (76) hide show
  1. package/dist/esm/astro/blog-post-bridge.d.ts +21 -0
  2. package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
  3. package/dist/esm/astro/blog-post-bridge.js +5 -2
  4. package/dist/esm/astro/blog-post-bridge.js.map +1 -1
  5. package/dist/esm/astro/bridge-runtime.d.ts +37 -0
  6. package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
  7. package/dist/esm/astro/bridge-runtime.js +6 -2
  8. package/dist/esm/astro/bridge-runtime.js.map +1 -1
  9. package/dist/esm/astro/robots-renderer.d.ts.map +1 -1
  10. package/dist/esm/astro/robots-renderer.js +14 -1
  11. package/dist/esm/astro/robots-renderer.js.map +1 -1
  12. package/dist/esm/astro/website-page-head.renderer.d.ts.map +1 -1
  13. package/dist/esm/astro/website-page-head.renderer.js +32 -12
  14. package/dist/esm/astro/website-page-head.renderer.js.map +1 -1
  15. package/dist/esm/config/wildo-website-config.schemas.d.ts +12 -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 +12 -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 +5 -5
  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 +7 -6
  22. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js.map +1 -1
  23. package/dist/esm/core/contexts/WebsitePageContext.d.ts +4 -3
  24. package/dist/esm/core/contexts/WebsitePageContext.d.ts.map +1 -1
  25. package/dist/esm/core/contexts/WebsitePageContext.js.map +1 -1
  26. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +36 -7
  27. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts.map +1 -1
  28. package/dist/esm/core/contexts/WebsiteRuntimeContext.js.map +1 -1
  29. package/dist/esm/core/contexts/WebsiteSectionContext.d.ts +3 -2
  30. package/dist/esm/core/contexts/WebsiteSectionContext.d.ts.map +1 -1
  31. package/dist/esm/core/contexts/WebsiteSectionContext.js.map +1 -1
  32. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts +37 -13
  33. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts.map +1 -1
  34. package/dist/esm/core/external-providers/frontend-provider-registry.website.js +28 -19
  35. package/dist/esm/core/external-providers/frontend-provider-registry.website.js.map +1 -1
  36. package/dist/esm/core/external-providers/provider-scripts.website.d.ts +68 -0
  37. package/dist/esm/core/external-providers/provider-scripts.website.d.ts.map +1 -0
  38. package/dist/esm/core/external-providers/provider-scripts.website.js +99 -0
  39. package/dist/esm/core/external-providers/provider-scripts.website.js.map +1 -0
  40. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts +34 -0
  41. package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -0
  42. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +78 -0
  43. package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -0
  44. package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -1
  45. package/dist/esm/core/layouts/WebsitePageLayout.js +7 -0
  46. package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
  47. package/dist/esm/schemas/sections/website-section-category.shared.d.ts +19 -20
  48. package/dist/esm/schemas/sections/website-section-category.shared.d.ts.map +1 -1
  49. package/dist/esm/schemas/sections/website-section-category.shared.js +19 -20
  50. package/dist/esm/schemas/sections/website-section-category.shared.js.map +1 -1
  51. package/dist/tsconfig.build.tsbuildinfo +1 -1
  52. package/package.json +5 -4
  53. package/src/__tests__/bundle-isolation.test.ts +21 -0
  54. package/src/astro/__tests__/blog-post-bridge.test.tsx +2 -2
  55. package/src/astro/__tests__/bridge-runtime.test.tsx +57 -2
  56. package/src/astro/__tests__/robots-renderer.test.ts +16 -1
  57. package/src/astro/__tests__/website-page-head.renderer.test.ts +43 -0
  58. package/src/astro/__tests__/website-site-context.test.ts +1 -1
  59. package/src/astro/blog-post-bridge.tsx +26 -1
  60. package/src/astro/bridge-runtime.tsx +44 -1
  61. package/src/astro/robots-renderer.ts +14 -1
  62. package/src/astro/website-page-head.renderer.ts +32 -12
  63. package/src/config/__tests__/define-website-config.test.ts +9 -3
  64. package/src/config/wildo-website-config.schemas.ts +12 -5
  65. package/src/core/__tests__/WebsitePageLayout.test.tsx +47 -4
  66. package/src/core/anonymous-session/inbound-contact-form.schema.ts +7 -6
  67. package/src/core/contexts/WebsitePageContext.tsx +4 -3
  68. package/src/core/contexts/WebsiteRuntimeContext.tsx +36 -7
  69. package/src/core/contexts/WebsiteSectionContext.tsx +3 -2
  70. package/src/core/external-providers/__tests__/frontend-provider-registry.website.test.ts +79 -0
  71. package/src/core/external-providers/__tests__/provider-scripts.website.test.ts +146 -0
  72. package/src/core/external-providers/frontend-provider-registry.website.ts +53 -54
  73. package/src/core/external-providers/provider-scripts.website.ts +126 -0
  74. package/src/core/external-providers/useWebsiteProviderScripts.ts +92 -0
  75. package/src/core/layouts/WebsitePageLayout.tsx +11 -0
  76. package/src/schemas/sections/website-section-category.shared.ts +19 -20
@@ -51,13 +51,22 @@ import type { LabelPack } from '../../schemas/validators/label-pack.validator';
51
51
  * contract the SaaS app now uses, instead of inventing a website-only
52
52
  * metadata seam.
53
53
  *
54
- * **Why a separate file from the hook + provider**: split for React
55
- * Fast Refresh — when a `.tsx` exports both a React component and a
56
- * React Context object, edits to either one invalidate the entire
57
- * module's HMR boundary. Keeping the bare context here, the hook in
58
- * `useWebsiteRuntime.ts`, and the provider component in
59
- * `WebsiteRuntimeContextProvider.tsx` makes section authors edit
60
- * components without retriggering the runtime context's identity.
54
+ * **Why the hook lives in a separate file**: React Fast Refresh only
55
+ * preserves state for a module whose every export is a component, so
56
+ * a module mixing components with plain functions loses its HMR
57
+ * boundary. This file holds the two things that must be identical
58
+ * objects at runtime — the context and the provider component that
59
+ * writes it — while `useWebsiteRuntime` sits alone in
60
+ * `useWebsiteRuntime.ts`. A section author editing a component that
61
+ * merely READS the runtime therefore never remounts the provider.
62
+ *
63
+ * Until 2026-09-09 this paragraph described a different arrangement:
64
+ * that only the bare context was kept here and the provider lived in
65
+ * a file of its own. No such file has ever existed — the provider is
66
+ * exported below — and splitting it out would be the wrong shape
67
+ * anyway, since `useContext` matches on object identity.
68
+ * prose-referent-waiver: the retired file name is quoted only to retire it.
69
+ * The claim named `WebsiteRuntimeContextProvider.tsx`.
61
70
  */
62
71
  export interface WebsiteRuntimeContextValue {
63
72
  /**
@@ -77,6 +86,26 @@ export interface WebsiteRuntimeContextValue {
77
86
  * paths.
78
87
  */
79
88
  defaultLocale: AvailableLanguage;
89
+ /**
90
+ * Origin of the application this marketing site sends visitors INTO — the
91
+ * marketing → product handoff target, authored once as
92
+ * `WildoMarketingWebsiteConfig.appOrigin` (#493).
93
+ *
94
+ * Carried here for exactly the reason `defaultLocale` above is: a
95
+ * `wildo.website.config.ts` is Node-loaded and therefore invisible to a
96
+ * client island, so before this a site resolved the app origin a SECOND
97
+ * time from a public build-time environment variable it could read in the
98
+ * browser — one host authored twice, in two files, with no mechanical link
99
+ * between the copies. That is how a marketing site comes to point at the
100
+ * wrong environment: a defect nobody notices until a customer follows the
101
+ * link.
102
+ *
103
+ * Threading it through the runtime keeps the handoff a framework-internal
104
+ * concern, the same way threading `defaultLocale` kept locale-prefixing one:
105
+ * section authors never hand-assemble the product URL, and there is one
106
+ * place to change it.
107
+ */
108
+ appOrigin?: string;
80
109
  /**
81
110
  * Flat `Record<string, string>` label pack for the active locale.
82
111
  * Lookup keys MUST be fully-composed `website.<page>.<section>.<slot>`
@@ -19,8 +19,9 @@ import type { WebsiteSectionRef } from '../../schemas/refs/section-ref.schemas';
19
19
  * both saves consumers from having to thread the manifest just to
20
20
  * map a ref back to its category.
21
21
  *
22
- * **Why a separate file from the hook**: same Fast-Refresh rationale
23
- * as the runtime + page contexts.
22
+ * **Why the hook lives in a separate file**: same Fast-Refresh
23
+ * rationale as the runtime + page contexts — context and provider
24
+ * together here, the hook alone in `useWebsiteSection.ts`.
24
25
  */
25
26
  export interface WebsiteSectionContextValue {
26
27
  /**
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The website's half of the two-channel design (#332).
3
+ *
4
+ * The registry used to build itself by casting each served entry to a module
5
+ * type that was an alias of that entry — true, and useless: it could never hold
6
+ * anything a JSON payload cannot carry, so a provider's SDK loader had nowhere
7
+ * to arrive.
8
+ */
9
+ import { describe, expect, it, vi } from 'vitest';
10
+ import {
11
+ ENGINE_PROVIDER_ORIGIN,
12
+ ProviderTier,
13
+ defineProviderMetadata,
14
+ type FrontendProvidersBlock,
15
+ } from '@wildo-ai/saas-models/public-runtime';
16
+ import type { FrontendWebsiteProviderRef } from '@wildo-ai/external-connectors-public/website';
17
+ import { createFrontendWebsiteProviderRegistry } from '../frontend-provider-registry.website';
18
+
19
+ // FRONTEND_CAPTCHA binds no engine slot, which is what a CATALOGUE provider is
20
+ // allowed to claim (#328). Using an engine-slot capability here would be
21
+ // refused by the tier rule the composer still runs — as it should be.
22
+ const metadata = defineProviderMetadata({
23
+ ref: 'acme',
24
+ packageName: '@wildo-ai/provider-acme',
25
+ tier: ProviderTier.CATALOGUE,
26
+ origin: ENGINE_PROVIDER_ORIGIN,
27
+ });
28
+
29
+ function entries(): FrontendProvidersBlock {
30
+ return {
31
+ providers: {
32
+ acme: {
33
+ metadata,
34
+ providerCapabilities: ['FRONTEND_CAPTCHA'],
35
+ protocols: ['FRONTEND_SDK'],
36
+ publicConfig: { token: 'public-token' },
37
+ },
38
+ },
39
+ } as unknown as FrontendProvidersBlock;
40
+ }
41
+
42
+ describe('createFrontendWebsiteProviderRegistry', () => {
43
+ it('joins the served entries with the bundle\'s generated modules', () => {
44
+ const activate = vi.fn();
45
+
46
+ const registry = createFrontendWebsiteProviderRegistry({
47
+ entries: entries(),
48
+ modules: {
49
+ acme: {
50
+ metadata,
51
+ providerCapabilities: ['FRONTEND_CAPTCHA'],
52
+ protocols: ['FRONTEND_SDK'],
53
+ sdk: { descriptor: { packageName: '@acme/analytics-web' }, activate },
54
+ },
55
+ },
56
+ });
57
+
58
+ const provider = registry.getProviderByRef('acme' as FrontendWebsiteProviderRef);
59
+ expect(provider?.sdk?.descriptor.packageName).toBe('@acme/analytics-web');
60
+ expect(provider?.publicConfig).toEqual({ token: 'public-token' });
61
+ });
62
+
63
+ it('a provider the bundle has no code for is still listed, and says which command fixes it', () => {
64
+ const diagnostics: string[] = [];
65
+
66
+ const registry = createFrontendWebsiteProviderRegistry({
67
+ entries: entries(),
68
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic.message),
69
+ });
70
+
71
+ expect(registry.modules.size).toBe(1);
72
+ expect(registry.getProviderByRef('acme' as FrontendWebsiteProviderRef)?.sdk).toBeUndefined();
73
+ expect(diagnostics.join(' ')).toContain('wildo config sync');
74
+ });
75
+
76
+ it('hydrates to an empty registry when the website declares nothing', () => {
77
+ expect(createFrontendWebsiteProviderRegistry().modules.size).toBe(0);
78
+ });
79
+ });
@@ -0,0 +1,146 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import {
3
+ ENGINE_PROVIDER_ORIGIN,
4
+ ProviderFrontendScriptCrossOrigin,
5
+ ProviderTier,
6
+ defineProviderMetadata,
7
+ type FrontendProvidersBlock,
8
+ } from '@wildo-ai/saas-models/public-runtime';
9
+ import { createFrontendWebsiteProviderRegistry } from '../frontend-provider-registry.website';
10
+ import {
11
+ PROVIDER_SCRIPT_MARKER_ATTRIBUTE,
12
+ collectDeclaredProviderScripts,
13
+ injectProviderScripts,
14
+ } from '../provider-scripts.website';
15
+
16
+ function registryWith(scriptsByRef: Record<string, unknown[]>): ReturnType<typeof createFrontendWebsiteProviderRegistry> {
17
+ const providers = Object.fromEntries(
18
+ Object.entries(scriptsByRef).map(([ref, scripts]) => [
19
+ ref,
20
+ {
21
+ metadata: defineProviderMetadata({
22
+ ref,
23
+ packageName: `@wildo-ai/provider-${ref}`,
24
+ tier: ProviderTier.CATALOGUE,
25
+ origin: ENGINE_PROVIDER_ORIGIN,
26
+ }),
27
+ providerCapabilities: [],
28
+ protocols: ['FRONTEND_SDK'],
29
+ scripts,
30
+ },
31
+ ]),
32
+ );
33
+ return createFrontendWebsiteProviderRegistry({ entries: { providers } as unknown as FrontendProvidersBlock });
34
+ }
35
+
36
+ function injectedScripts(): HTMLScriptElement[] {
37
+ return [...document.querySelectorAll<HTMLScriptElement>(`script[${PROVIDER_SCRIPT_MARKER_ATTRIBUTE}]`)];
38
+ }
39
+
40
+ describe('website provider scripts', () => {
41
+ beforeEach(() => {
42
+ document.head.innerHTML = '';
43
+ });
44
+
45
+ it('collects every declared script with the ref that declared it', () => {
46
+ const collected = collectDeclaredProviderScripts(
47
+ registryWith({
48
+ analytics: [{ src: 'https://cdn.analytics.test/a.js' }],
49
+ captcha: [{ src: 'https://captcha.test/api.js' }, { src: 'https://captcha.test/extra.js' }],
50
+ }),
51
+ );
52
+
53
+ expect(collected.map((entry) => `${entry.providerRef}:${entry.declaration.src}`)).toEqual([
54
+ 'analytics:https://cdn.analytics.test/a.js',
55
+ 'captcha:https://captcha.test/api.js',
56
+ 'captcha:https://captcha.test/extra.js',
57
+ ]);
58
+ });
59
+
60
+ it('collects nothing when no registry is hydrated at all', () => {
61
+ expect(collectDeclaredProviderScripts(undefined)).toEqual([]);
62
+ });
63
+
64
+ it('injects a declared script into the document head, carrying its SRI and crossorigin', () => {
65
+ const result = injectProviderScripts(
66
+ collectDeclaredProviderScripts(
67
+ registryWith({
68
+ analytics: [
69
+ {
70
+ src: 'https://cdn.analytics.test/a.js',
71
+ integrity: 'sha384-abc',
72
+ crossOrigin: ProviderFrontendScriptCrossOrigin.ANONYMOUS,
73
+ },
74
+ ],
75
+ }),
76
+ ),
77
+ document,
78
+ );
79
+
80
+ const [element] = injectedScripts();
81
+ expect(result.injected).toEqual(['analytics']);
82
+ expect(element.src).toBe('https://cdn.analytics.test/a.js');
83
+ expect(element.integrity).toBe('sha384-abc');
84
+ expect(element.crossOrigin).toBe('anonymous');
85
+ expect(element.getAttribute(PROVIDER_SCRIPT_MARKER_ATTRIBUTE)).toBe('analytics');
86
+ });
87
+
88
+ it('defaults to async, because a blocking third-party tag is how a marketing site loses its paint', () => {
89
+ injectProviderScripts(
90
+ collectDeclaredProviderScripts(registryWith({ analytics: [{ src: 'https://cdn.analytics.test/a.js' }] })),
91
+ document,
92
+ );
93
+
94
+ expect(injectedScripts()[0].async).toBe(true);
95
+ expect(injectedScripts()[0].defer).toBe(false);
96
+ });
97
+
98
+ it('honours defer by turning async OFF, which a dynamically created script does not do on its own', () => {
99
+ // A script element created by script is async by default, so `defer: true`
100
+ // alone would be silently ignored — the exact shape of a declaration that
101
+ // looks respected and is not.
102
+ injectProviderScripts(
103
+ collectDeclaredProviderScripts(
104
+ registryWith({ analytics: [{ src: 'https://cdn.analytics.test/a.js', defer: true }] }),
105
+ ),
106
+ document,
107
+ );
108
+
109
+ expect(injectedScripts()[0].async).toBe(false);
110
+ expect(injectedScripts()[0].defer).toBe(true);
111
+ });
112
+
113
+ it('is idempotent: a second call adds nothing, so a remount cannot start a vendor twice', () => {
114
+ const scripts = collectDeclaredProviderScripts(
115
+ registryWith({ analytics: [{ src: 'https://cdn.analytics.test/a.js' }] }),
116
+ );
117
+
118
+ injectProviderScripts(scripts, document);
119
+ const second = injectProviderScripts(scripts, document);
120
+
121
+ expect(injectedScripts()).toHaveLength(1);
122
+ expect(second.injected).toEqual([]);
123
+ expect(second.alreadyPresent).toEqual(['analytics']);
124
+ });
125
+
126
+ it('matches an existing script by its src even when the URL carries query characters', () => {
127
+ const scripts = collectDeclaredProviderScripts(
128
+ registryWith({ captcha: [{ src: 'https://captcha.test/api.js?render=abc&hl=fr' }] }),
129
+ );
130
+
131
+ injectProviderScripts(scripts, document);
132
+ injectProviderScripts(scripts, document);
133
+
134
+ expect(injectedScripts()).toHaveLength(1);
135
+ });
136
+
137
+ it('adds nothing for a provider that declares no scripts', () => {
138
+ const result = injectProviderScripts(
139
+ collectDeclaredProviderScripts(registryWith({ analytics: [] })),
140
+ document,
141
+ );
142
+
143
+ expect(result.injected).toEqual([]);
144
+ expect(injectedScripts()).toHaveLength(0);
145
+ });
146
+ });
@@ -1,62 +1,61 @@
1
- import type {
2
- FrontendProviderEntry,
3
- FrontendProvidersBlock,
4
- ProviderCapability,
5
- } from '@wildo-ai/saas-models/public-runtime';
6
-
7
- export type FrontendWebsiteProviderRef = string & {
8
- readonly __brand: 'FrontendWebsiteProviderRef';
9
- };
10
-
11
- export type FrontendWebsiteProviderModule = FrontendProviderEntry;
12
-
13
- export interface FrontendWebsiteProviderRegistry {
14
- readonly modules: ReadonlyMap<
15
- FrontendWebsiteProviderRef,
16
- FrontendWebsiteProviderModule
17
- >;
18
-
19
- getProviderByRef(
20
- ref: FrontendWebsiteProviderRef,
21
- ): FrontendWebsiteProviderModule | undefined;
22
-
23
- getProvidersByProviderCapability(
24
- providerCapability: ProviderCapability,
25
- ): ReadonlyArray<FrontendWebsiteProviderModule>;
1
+ /**
2
+ * The website's provider registry — now composed from BOTH channels.
3
+ *
4
+ * It used to build itself by casting each served `FrontendProviderEntry` to a
5
+ * module type that was an alias of that entry, which made the cast true and
6
+ * useless: the registry could never hold anything a JSON payload could not
7
+ * carry, so a provider's SDK loader had nowhere to arrive.
8
+ *
9
+ * - **DATA** — the materialized frontend-services block for THIS website
10
+ * service. Authoritative for which providers are on, in this environment.
11
+ * - **CODE** — `generatedFrontendProviderModules` from the website's own
12
+ * `src/generated/frontend-providers.generated.ts`, written by
13
+ * `wildo config sync` and handed in by the Astro bridge.
14
+ *
15
+ * The types are now the engine's own website-facet types rather than local
16
+ * aliases, so a provider module authored against
17
+ * `@wildo-ai/external-connectors-public/website` is the same type this registry
18
+ * holds. Re-exported under the same names so every consumer's import is
19
+ * unchanged.
20
+ */
26
21
 
27
- findProviderByProviderCapability(
28
- providerCapability: ProviderCapability,
29
- ): FrontendWebsiteProviderModule | undefined;
22
+ import {
23
+ composeHydratedFrontendWebsiteProviders,
24
+ type GeneratedWebsiteProviderModules,
25
+ } from '@wildo-ai/external-connectors-public/website';
26
+ import type { FrontendProvidersBlock } from '@wildo-ai/saas-models/public-runtime';
27
+ import type { FrontendProviderHydrationDiagnostic } from '@wildo-ai/external-connectors-public/website';
28
+
29
+ export type {
30
+ FrontendWebsiteProviderModule,
31
+ FrontendWebsiteProviderRef,
32
+ FrontendWebsiteProviderRegistry,
33
+ GeneratedWebsiteProviderModules,
34
+ } from '@wildo-ai/external-connectors-public/website';
35
+
36
+ import type { FrontendWebsiteProviderRegistry } from '@wildo-ai/external-connectors-public/website';
37
+
38
+ export interface CreateFrontendWebsiteProviderRegistryInput {
39
+ /** The DATA channel: the materialized block for this website service. */
40
+ readonly entries?: FrontendProvidersBlock;
41
+ /**
42
+ * The CODE channel. Absent is the ordinary case for a website that declares
43
+ * no package-shipped provider — `wildo config sync` writes no generated file,
44
+ * so there is nothing to import and nothing to pass.
45
+ */
46
+ readonly modules?: GeneratedWebsiteProviderModules;
47
+ readonly onDiagnostic?: (diagnostic: FrontendProviderHydrationDiagnostic) => void;
30
48
  }
31
49
 
32
50
  /**
33
- * Website-side public-provider registry hydrated from the materialized
34
- * frontend-provider bootstrap block.
51
+ * Website-side public-provider registry, hydrated from both channels.
35
52
  */
36
53
  export function createFrontendWebsiteProviderRegistry(
37
- frontendProviders?: FrontendProvidersBlock,
54
+ input?: CreateFrontendWebsiteProviderRegistryInput,
38
55
  ): FrontendWebsiteProviderRegistry {
39
- const modules = new Map<FrontendWebsiteProviderRef, FrontendWebsiteProviderModule>(
40
- Object.entries(frontendProviders?.providers ?? {}).map(([ref, provider]) => [
41
- ref as FrontendWebsiteProviderRef,
42
- provider as FrontendWebsiteProviderModule,
43
- ]),
44
- );
45
-
46
- return {
47
- modules,
48
- getProviderByRef(ref: FrontendWebsiteProviderRef) {
49
- return modules.get(ref);
50
- },
51
- getProvidersByProviderCapability(providerCapability: ProviderCapability) {
52
- return [...modules.values()].filter((provider) =>
53
- provider.providerCapabilities.includes(providerCapability),
54
- );
55
- },
56
- findProviderByProviderCapability(providerCapability: ProviderCapability) {
57
- return [...modules.values()].find((provider) =>
58
- provider.providerCapabilities.includes(providerCapability),
59
- );
60
- },
61
- };
56
+ return composeHydratedFrontendWebsiteProviders({
57
+ entries: input?.entries,
58
+ modules: input?.modules,
59
+ onDiagnostic: input?.onDiagnostic,
60
+ });
62
61
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Loading what a website provider actually declared, instead of naming it.
3
+ *
4
+ * `FRONTEND_SDK` was a label on this surface. The layout resolved the analytics,
5
+ * error-monitoring and captcha provider refs and stamped them into `data-*`
6
+ * attributes on the page root; no consumer loaded anything. A provider could be
7
+ * declared, enabled, hydrated — and do nothing at all.
8
+ *
9
+ * This is the consumer for the DATA half of a provider's executable surface: the
10
+ * `scripts` it declares (#332). It serves the case a dynamic `import()` cannot,
11
+ * and it is the dominant case on a marketing site: a vendor that ships a
12
+ * global-installing `<script>` tag rather than an npm module (reCAPTCHA, tag
13
+ * managers, chat widgets, and the CDN distributions of most analytics SDKs).
14
+ *
15
+ * The npm half — `sdk.activate`, a dynamic import inside the provider package —
16
+ * needs the CODE channel, which needs this package to depend on
17
+ * `@wildo-ai/external-connectors-public`. That dependency is not added here; see
18
+ * the note in the S4a ledger. Nothing below anticipates it: a provider that
19
+ * declares both gets its scripts from here and its SDK from there.
20
+ *
21
+ * ## Client-side injection, and the alternative that was rejected
22
+ *
23
+ * This is an Astro static site, so the "obvious" home for a third-party tag is
24
+ * the built HTML `<head>`. That was rejected for now: the set of live providers
25
+ * is per SERVICE and per ENVIRONMENT, resolved from the materialized artifact,
26
+ * while the built HTML is one artifact shared across deploys of the same build.
27
+ * Baking the tags in would make the build environment-specific — the same class
28
+ * of coupling the DATA channel exists to avoid. Injecting from the client keeps
29
+ * one build correct everywhere, at the cost of the tag arriving after hydration.
30
+ * Revisit if a provider ever needs to run before first paint.
31
+ */
32
+
33
+ import type { ProviderFrontendScriptDeclaration } from '@wildo-ai/saas-models/public-runtime';
34
+ import type { FrontendWebsiteProviderRegistry } from './frontend-provider-registry.website';
35
+
36
+ /**
37
+ * Marks a `<script>` this module owns, so a re-render, a client-side route
38
+ * change or a second layout mount finds it instead of appending a duplicate.
39
+ * A vendor tag loaded twice initialises twice — two analytics clients, two sets
40
+ * of events — which is the failure mode this attribute exists to prevent.
41
+ */
42
+ export const PROVIDER_SCRIPT_MARKER_ATTRIBUTE = 'data-wildo-provider-script';
43
+
44
+ export interface DeclaredProviderScript {
45
+ readonly providerRef: string;
46
+ readonly declaration: ProviderFrontendScriptDeclaration;
47
+ }
48
+
49
+ /**
50
+ * Every script declared by the providers live on this surface, in registry
51
+ * order, with the declaring ref kept beside each one so a failure can name it.
52
+ */
53
+ export function collectDeclaredProviderScripts(
54
+ registry: FrontendWebsiteProviderRegistry | undefined,
55
+ ): ReadonlyArray<DeclaredProviderScript> {
56
+ if (registry === undefined) return [];
57
+
58
+ const collected: DeclaredProviderScript[] = [];
59
+ for (const provider of registry.modules.values()) {
60
+ for (const declaration of provider.scripts ?? []) {
61
+ collected.push({ providerRef: provider.metadata.ref, declaration });
62
+ }
63
+ }
64
+ return collected;
65
+ }
66
+
67
+ export interface InjectProviderScriptsResult {
68
+ /** Refs whose script this call added. */
69
+ readonly injected: ReadonlyArray<string>;
70
+ /** Refs whose script was already present, so nothing was added. */
71
+ readonly alreadyPresent: ReadonlyArray<string>;
72
+ }
73
+
74
+ /**
75
+ * Adds each declared script to the document, once.
76
+ *
77
+ * Idempotent by `src`: a declaration whose element is already in the document is
78
+ * skipped. Callers may therefore run this on every mount without tracking state.
79
+ *
80
+ * `async` defaults to TRUE when the declaration says nothing. A third-party tag
81
+ * that blocks parsing is the single most common way an analytics provider costs
82
+ * a marketing site its paint metrics, and a provider author who has not thought
83
+ * about it should get the safe answer rather than the blocking one.
84
+ */
85
+ export function injectProviderScripts(
86
+ scripts: ReadonlyArray<DeclaredProviderScript>,
87
+ targetDocument: Document,
88
+ ): InjectProviderScriptsResult {
89
+ const injected: string[] = [];
90
+ const alreadyPresent: string[] = [];
91
+
92
+ for (const { providerRef, declaration } of scripts) {
93
+ // Matched by reading the attribute rather than by a selector carrying the
94
+ // URL: a `src` can contain characters a CSS selector would have to escape,
95
+ // and an escaping bug here would show up as a duplicated vendor tag — two
96
+ // analytics clients, two sets of events — rather than as an error.
97
+ const existing = [
98
+ ...targetDocument.querySelectorAll(`script[${PROVIDER_SCRIPT_MARKER_ATTRIBUTE}]`),
99
+ ].some((element) => element.getAttribute('src') === declaration.src);
100
+ if (existing) {
101
+ alreadyPresent.push(providerRef);
102
+ continue;
103
+ }
104
+
105
+ const element = targetDocument.createElement('script');
106
+ element.src = declaration.src;
107
+ element.setAttribute(PROVIDER_SCRIPT_MARKER_ATTRIBUTE, providerRef);
108
+
109
+ if (declaration.integrity !== undefined) {
110
+ element.integrity = declaration.integrity;
111
+ }
112
+ if (declaration.crossOrigin !== undefined) {
113
+ element.crossOrigin = declaration.crossOrigin;
114
+ }
115
+ // Both flags are set explicitly rather than left to the element's defaults:
116
+ // a dynamically created script is `async` by default, so `defer: true` alone
117
+ // would be silently ignored without the paired `async = false`.
118
+ element.async = declaration.async ?? declaration.defer !== true;
119
+ element.defer = declaration.defer ?? false;
120
+
121
+ targetDocument.head.appendChild(element);
122
+ injected.push(providerRef);
123
+ }
124
+
125
+ return { injected, alreadyPresent };
126
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The website's executable-surface hooks: load what the live providers declared.
3
+ *
4
+ * Two mechanisms, one per kind of vendor. `useWebsiteProviderScripts` adds the
5
+ * `<script>` tags a provider declared — the case a dynamic `import()` cannot
6
+ * serve, and the dominant one on a marketing site. `useWebsiteProviderSdks`
7
+ * activates the npm SDK of any provider that ships one, which reaches this
8
+ * bundle through the CODE channel.
9
+ *
10
+ * Both are effect-only, and that is not incidental: this is an Astro site, so
11
+ * page modules execute in Node at build time. Injecting into a document that
12
+ * does not exist would throw, and starting a vendor SDK there would run a live
13
+ * analytics client inside a build.
14
+ *
15
+ * SDK activation comes from `@wildo-ai/external-connectors-public/website`, which
16
+ * this package now depends on. The script injector beside it does NOT yet, and
17
+ * that is a timing artifact rather than a decision: it belongs in the same
18
+ * package so the technical doc can use one copy — a second copy of a DOM
19
+ * mutation eventually disagrees about idempotence, which is how a vendor tag
20
+ * gets loaded twice. Moving it needs that package's `dist` to carry it, and no
21
+ * watcher is running in this tree; consolidating it is a one-step follow-up, not
22
+ * a design question.
23
+ */
24
+
25
+ import { useEffect } from 'react';
26
+ import {
27
+ activateFrontendProviderSdks,
28
+ type ActivatedFrontendProviderSdks,
29
+ } from '@wildo-ai/external-connectors-public/website';
30
+ import {
31
+ collectDeclaredProviderScripts,
32
+ injectProviderScripts,
33
+ } from './provider-scripts.website';
34
+ import { AppFrontendType } from '@wildo-ai/saas-models/public-runtime';
35
+ import type { FrontendWebsiteProviderRegistry } from './frontend-provider-registry.website';
36
+
37
+ export function useWebsiteProviderScripts(
38
+ registry: FrontendWebsiteProviderRegistry | undefined,
39
+ ): void {
40
+ useEffect(() => {
41
+ if (typeof document === 'undefined') return;
42
+
43
+ const scripts = collectDeclaredProviderScripts(registry);
44
+ if (scripts.length === 0) return;
45
+
46
+ injectProviderScripts(scripts, document);
47
+ // Deliberately no teardown. A vendor tag installs a global and often starts
48
+ // work immediately; removing the element undoes neither, so a cleanup that
49
+ // removed it would only make the next mount inject a SECOND copy of an
50
+ // already-running script. Idempotence by `src` is the real guard.
51
+ }, [registry]);
52
+ }
53
+
54
+ /**
55
+ * Loads and starts the npm SDK of every live provider that declares one.
56
+ *
57
+ * Unlike a script tag, an SDK activation IS undone on unmount where the vendor
58
+ * offers a teardown — an analytics client that cannot be stopped cannot honour
59
+ * a consent withdrawal, and accumulates one instance per view transition.
60
+ */
61
+ export function useWebsiteProviderSdks(
62
+ registry: FrontendWebsiteProviderRegistry | undefined,
63
+ ): void {
64
+ useEffect(() => {
65
+ if (registry === undefined) return;
66
+
67
+ let unmounted = false;
68
+ let activated: ActivatedFrontendProviderSdks | undefined;
69
+
70
+ void activateFrontendProviderSdks({
71
+ surface: AppFrontendType.STATIC_WEBSITE,
72
+ modules: registry.modules.values(),
73
+ onDiagnostic: (diagnostic) => {
74
+ // The website runtime has no logger service, and a provider that cannot
75
+ // start is a developer-facing fact that must not be silent.
76
+ // eslint-disable-next-line no-console
77
+ console.warn(`[wildo-provider-sdk] ${diagnostic.kind}: ${diagnostic.message}`);
78
+ },
79
+ }).then((result) => {
80
+ activated = result;
81
+ // Activation is asynchronous and unmount is not: if the surface went away
82
+ // while a vendor bundle was still downloading, stop what we just started
83
+ // rather than leaving a client nobody can reach.
84
+ if (unmounted) void result.deactivateAll();
85
+ });
86
+
87
+ return () => {
88
+ unmounted = true;
89
+ void activated?.deactivateAll();
90
+ };
91
+ }, [registry]);
92
+ }
@@ -2,6 +2,10 @@ import React, { useMemo, type ReactNode } from 'react';
2
2
  import { BUILTIN_PROVIDER_CAPABILITY } from '@wildo-ai/saas-models/public-runtime';
3
3
 
4
4
  import { WebsitePageContextProvider } from '../contexts/WebsitePageContext';
5
+ import {
6
+ useWebsiteProviderScripts,
7
+ useWebsiteProviderSdks,
8
+ } from '../external-providers/useWebsiteProviderScripts';
5
9
  import type { WebsitePageContextValue } from '../contexts/WebsitePageContext';
6
10
  import { useWebsiteRuntime } from '../contexts/useWebsiteRuntime';
7
11
  import { useWebsiteLabelByKey } from '../hooks/useWebsiteLabel';
@@ -232,6 +236,13 @@ export function WebsitePageLayout({
232
236
  return <FooterComponent />;
233
237
  }, [footer, runtime.navigation.footerComponent]);
234
238
 
239
+ // Load whatever the live providers declared. Until this landed, the surface
240
+ // resolved provider refs and stamped them into the attributes below and
241
+ // stopped there — a provider could be declared, enabled and hydrated without
242
+ // anything of it ever running.
243
+ useWebsiteProviderScripts(runtime.frontendProviderRegistry);
244
+ useWebsiteProviderSdks(runtime.frontendProviderRegistry);
245
+
235
246
  const providerRootAttributes = useMemo<Record<string, string>>(() => {
236
247
  const attrs: Record<string, string> = {};
237
248
  const productAnalyticsProviderRef = runtime.frontendProviderRegistry