@wildo-ai/saas-website 1.1.1 → 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 (77) 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
  77. package/dist/esm/.builder.pid +0 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wildo-ai/saas-website",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
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,9 +65,10 @@
65
65
  "test:watch": "vitest"
66
66
  },
67
67
  "dependencies": {
68
- "@wildo-ai/platform-config-lib": "1.1.1",
69
- "@wildo-ai/presets-components-models": "1.1.1",
70
- "@wildo-ai/saas-models": "1.1.1",
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",
71
72
  "yaml": "^2.3.4"
72
73
  },
73
74
  "devDependencies": {
@@ -235,6 +235,27 @@ const ALLOWED_BARE_SPECIFIERS: ReadonlySet<string> = new Set([
235
235
  'react',
236
236
  'zod',
237
237
  '@wildo-ai/saas-models/public-runtime',
238
+ /**
239
+ * The browser-bound trust zone of the provider registry split (#332). The
240
+ * website hydrates its providers from BOTH channels through this subpath:
241
+ * `composeHydratedFrontendWebsiteProviders` joins the served entries with the
242
+ * bundle's generated modules, and `activateFrontendProviderSdks` loads the
243
+ * vendor SDK of any provider that declares one.
244
+ *
245
+ * Admitted rather than mirrored locally, and the acceptance criteria are met
246
+ * by CONSTRUCTION rather than by inspection: this package's own
247
+ * `bundle-isolation.test.ts` walks each of its three facet subpaths and holds
248
+ * them to a one-entry allow-list — no saas-models root barrel, no
249
+ * presets-components-models root barrel, no backend lib, no
250
+ * `@wildo-ai/external-connectors-private` (the secrets-bound sibling), and no
251
+ * `reflect-metadata`. That test is the reason this entry is safe, and it is
252
+ * the thing to check if that ever stops being true.
253
+ *
254
+ * The `/website` FACET specifically: the package's facet boundary is
255
+ * type-enforced so this bundle never reaches the SaaS-app or techdoc provider
256
+ * modules.
257
+ */
258
+ '@wildo-ai/external-connectors-public/website',
238
259
  /**
239
260
  * Narrow public-runtime seam exposing
240
261
  * `AvailableLanguage` / `TextDirection` (and any future locale-tier
@@ -4,7 +4,7 @@ import { useMDXComponents } from '@mdx-js/react';
4
4
  import React, { type ComponentType } from 'react';
5
5
  import { describe, expect, it } from 'vitest';
6
6
 
7
- import { BUILTIN_PROVIDER_CAPABILITY } from '@wildo-ai/saas-models/public-runtime';
7
+ import { BUILTIN_PROVIDER_CAPABILITY, defineProviderMetadata, ProviderOriginKind, ProviderTier } from '@wildo-ai/saas-models/public-runtime';
8
8
  import type { FrontendProvidersBlock } from '@wildo-ai/saas-models/public-runtime';
9
9
  import { AvailableLanguage } from '@wildo-ai/saas-models/public-runtime';
10
10
 
@@ -59,7 +59,7 @@ const SKIP_LABEL_PACK = {
59
59
  const FRONTEND_PROVIDERS: FrontendProvidersBlock = {
60
60
  providers: {
61
61
  sentry: {
62
- metadata: { ref: 'sentry' },
62
+ metadata: defineProviderMetadata({ ref: 'sentry', packageName: '@test/providers', tier: ProviderTier.EXTENSION, origin: { kind: ProviderOriginKind.APPLICATION } }),
63
63
  providerCapabilities: [BUILTIN_PROVIDER_CAPABILITY.FRONTEND_ERROR_MONITORING],
64
64
  protocols: ['FRONTEND_SDK'],
65
65
  publicConfig: {
@@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react';
3
3
  import React from 'react';
4
4
  import { describe, expect, it } from 'vitest';
5
5
 
6
- import { BUILTIN_PROVIDER_CAPABILITY } from '@wildo-ai/saas-models/public-runtime';
6
+ import { BUILTIN_PROVIDER_CAPABILITY, defineProviderMetadata, ProviderOriginKind, ProviderTier } from '@wildo-ai/saas-models/public-runtime';
7
7
  import type { FrontendProvidersBlock } from '@wildo-ai/saas-models/public-runtime';
8
8
  import { AvailableLanguage } from '@wildo-ai/saas-models/public-runtime';
9
9
 
@@ -75,7 +75,7 @@ const SKIP_LABEL_PACK = { 'website.chrome.layout.skip-to-main-content': 'Skip to
75
75
  const FRONTEND_PROVIDERS: FrontendProvidersBlock = {
76
76
  providers: {
77
77
  posthog: {
78
- metadata: { ref: 'posthog' },
78
+ metadata: defineProviderMetadata({ ref: 'posthog', packageName: '@test/providers', tier: ProviderTier.EXTENSION, origin: { kind: ProviderOriginKind.APPLICATION } }),
79
79
  providerCapabilities: [BUILTIN_PROVIDER_CAPABILITY.FRONTEND_PRODUCT_ANALYTICS],
80
80
  protocols: ['FRONTEND_SDK'],
81
81
  publicConfig: {
@@ -86,6 +86,11 @@ const FRONTEND_PROVIDERS: FrontendProvidersBlock = {
86
86
  },
87
87
  };
88
88
 
89
+ function RuntimeProbe(): React.ReactNode {
90
+ const runtime = useWebsiteRuntime();
91
+ return <span data-testid="app-origin">{runtime.appOrigin ?? ''}</span>;
92
+ }
93
+
89
94
  describe('createWebsitePageBridgeIsland', () => {
90
95
  it('returns a React component (closes over deps; never crosses prop boundary)', () => {
91
96
  const Island = createWebsitePageBridgeIsland({
@@ -120,6 +125,56 @@ describe('createWebsitePageBridgeIsland', () => {
120
125
  expect(screen.getByTestId('provider-ref').textContent).toBe('posthog');
121
126
  });
122
127
 
128
+ /**
129
+ * `appOrigin` reaches the browser, so a CTA no longer needs a second copy of the product host (#493).
130
+ *
131
+ * A `wildo.website.config.ts` is Node-loaded and invisible to a client island, so before this a site
132
+ * resolved the app origin AGAIN from a build-time environment variable — one host authored twice
133
+ * with no mechanical link, and a silent fallback to localhost when the variable was unset. It now
134
+ * travels exactly as `apiOrigin` does: page → island prop → runtime context.
135
+ */
136
+ it('forwards appOrigin from the island prop onto the runtime context', () => {
137
+ const Island = createWebsitePageBridgeIsland({
138
+ PageComponent: RuntimeProbe,
139
+ pageRef: PAGE_REF,
140
+ navigation: buildNavigation(),
141
+ });
142
+ render(
143
+ <Island
144
+ locale={AvailableLanguage.EN}
145
+ defaultLocale={AvailableLanguage.EN}
146
+ labelPack={{ ...SKIP_LABEL_PACK }}
147
+ designTokens={DESIGN_TOKENS}
148
+ skipToMainContentLabelKey={SKIP_KEY}
149
+ frontendProviders={FRONTEND_PROVIDERS}
150
+ appOrigin="https://app.example.com"
151
+ />,
152
+ );
153
+ expect(screen.getByTestId('app-origin').textContent).toBe('https://app.example.com');
154
+ });
155
+
156
+ it('leaves appOrigin undefined when the page does not forward it, so a consumer can fail loudly', () => {
157
+ // Optional on purpose: a page with no CTA needs none. The CONSUMER decides what absence means, and
158
+ // the dogfood's `useAppOrigin()` throws — which beats the old silent fallback to localhost, since
159
+ // that fallback shipped a production site whose every CTA pointed at a machine that was not there.
160
+ const Island = createWebsitePageBridgeIsland({
161
+ PageComponent: RuntimeProbe,
162
+ pageRef: PAGE_REF,
163
+ navigation: buildNavigation(),
164
+ });
165
+ render(
166
+ <Island
167
+ locale={AvailableLanguage.EN}
168
+ defaultLocale={AvailableLanguage.EN}
169
+ labelPack={{ ...SKIP_LABEL_PACK }}
170
+ designTokens={DESIGN_TOKENS}
171
+ skipToMainContentLabelKey={SKIP_KEY}
172
+ frontendProviders={FRONTEND_PROVIDERS}
173
+ />,
174
+ );
175
+ expect(screen.getByTestId('app-origin').textContent).toBe('');
176
+ });
177
+
123
178
  it('emits the layout-root data attribute carrying the page ref', () => {
124
179
  const Island = createWebsitePageBridgeIsland({
125
180
  PageComponent: PageProbe,
@@ -116,8 +116,23 @@ describe('renderRobotsTxt (Phase 8 close-out — saas-website-seo-sitemap.md)',
116
116
  });
117
117
 
118
118
  describe('policy = noindex (staging / private surfaces)', () => {
119
- it('emits Disallow: / for the catch-all User-agent', () => {
119
+ /**
120
+ * The catch-all must NOT be barred (#494).
121
+ *
122
+ * `Disallow` governs crawling and `noindex` governs indexing, and the second only reaches a crawler
123
+ * that is allowed to fetch the page. Emitting `Disallow: /` here made the site's own `noindex` meta
124
+ * unreadable, so the site was closed to well-behaved crawlers and still listable as a bare URL —
125
+ * the weaker of the two outcomes. Closure lives in the document; this file stays out of the way.
126
+ */
127
+ it('does NOT bar the catch-all, so the noindex meta can be read', () => {
120
128
  const txt = renderRobotsTxt(buildSiteContext({ robots: { policy: 'noindex' } }));
129
+ expect(txt).not.toContain('User-agent: *\nDisallow: /');
130
+ });
131
+
132
+ it('STILL closes the catch-all when allowedCrawlers is set, which is a different feature', () => {
133
+ // Deny-by-default crawler allow-listing IS about crawling, so `Disallow: /` is correct there —
134
+ // and it must survive the noindex change rather than being removed with it.
135
+ const txt = renderRobotsTxt(buildSiteContext({ robots: { policy: 'noindex', allowedCrawlers: ['Googlebot'] } }));
121
136
  expect(txt).toContain('User-agent: *\nDisallow: /');
122
137
  });
123
138
 
@@ -71,6 +71,7 @@ interface BuildSiteContextOverrides {
71
71
  defaultOgImageUrl?: string;
72
72
  siteName?: string;
73
73
  publicOrigin?: string;
74
+ robotsPolicy?: 'index' | 'noindex';
74
75
  }
75
76
 
76
77
  const buildSiteContext = (
@@ -88,6 +89,7 @@ const buildSiteContext = (
88
89
  ...(overrides.defaultOgImageUrl !== undefined
89
90
  ? { defaultOgImageUrl: overrides.defaultOgImageUrl }
90
91
  : {}),
92
+ ...(overrides.robotsPolicy !== undefined ? { robots: { policy: overrides.robotsPolicy } } : {}),
91
93
  },
92
94
  skipToMainContentLabelKey: WebsiteLabelKeySchema.parse(
93
95
  'website.chrome.layout.skip-to-main-content',
@@ -313,6 +315,47 @@ describe('renderWebsitePageHeadHtml', () => {
313
315
  expect(defaultHtml).not.toContain('noai, noimageai');
314
316
  });
315
317
 
318
+ /**
319
+ * #494 — the directive that actually keeps a site out of search.
320
+ *
321
+ * `robots.txt` `Disallow` governs CRAWLING and this meta governs INDEXING; only the second de-indexes,
322
+ * and it is readable only by a crawler that was allowed to fetch the page. Before this, a site declared
323
+ * `noindex` emitted the `Disallow` and NOT this tag — closed to well-behaved crawlers and still listable
324
+ * as a bare URL. The sibling assertion in `robots-renderer.test.ts` pins the other half.
325
+ */
326
+ it('emits noindex when the site policy is noindex, and nothing when it is index', () => {
327
+ const manifest = buildManifest('landing', '/');
328
+ const closedHtml = renderWebsitePageHeadHtml({
329
+ manifest,
330
+ siteContext: buildSiteContext([AvailableLanguage.EN], AvailableLanguage.EN, manifest, { robotsPolicy: 'noindex' }),
331
+ locale: AvailableLanguage.EN,
332
+ labelPack: { 'website.landing.meta.title': 'T', 'website.landing.meta.description': 'D' },
333
+ });
334
+ expect(closedHtml).toContain('<meta name="robots" content="noindex" />');
335
+
336
+ const openHtml = renderWebsitePageHeadHtml({
337
+ manifest,
338
+ siteContext: buildSiteContext([AvailableLanguage.EN], AvailableLanguage.EN, manifest, { robotsPolicy: 'index' }),
339
+ locale: AvailableLanguage.EN,
340
+ labelPack: { 'website.landing.meta.title': 'T', 'website.landing.meta.description': 'D' },
341
+ });
342
+ expect(openHtml).not.toContain('name="robots"');
343
+ });
344
+
345
+ it('merges noindex and the AI opt-out into ONE robots meta rather than emitting two', () => {
346
+ // Two `<meta name="robots">` tags are legal and read inconsistently; one comma-separated value is
347
+ // the canonical shape, and merging is what stops the two features colliding as they are combined.
348
+ const manifest = buildManifest('pricing', '/pricing', undefined, true);
349
+ const html = renderWebsitePageHeadHtml({
350
+ manifest,
351
+ siteContext: buildSiteContext([AvailableLanguage.EN], AvailableLanguage.EN, manifest, { robotsPolicy: 'noindex' }),
352
+ locale: AvailableLanguage.EN,
353
+ labelPack: { 'website.pricing.meta.title': 'T', 'website.pricing.meta.description': 'D' },
354
+ });
355
+ expect(html).toContain('<meta name="robots" content="noindex, noai, noimageai" />');
356
+ expect(html.match(/name="robots"/gu)).toHaveLength(1);
357
+ });
358
+
316
359
  it('per-page ogImageUrl wins over seo.defaultOgImageUrl (precedence rule, Phase 6 M-A)', () => {
317
360
  const manifest = buildManifest('landing', '/', 'https://cdn.example.com/page.png');
318
361
  const siteContext = buildSiteContext(
@@ -99,7 +99,7 @@ describe('composeWebsiteSiteContext', () => {
99
99
  const frontendProviders = FrontendProvidersBlockSchema.parse({
100
100
  providers: {
101
101
  posthog: {
102
- metadata: { ref: 'posthog' },
102
+ metadata: { ref: 'posthog', packageName: '@test/providers', tier: 'EXTENSION', vendor: 'posthog', origin: { kind: 'APPLICATION' } },
103
103
  providerCapabilities: ['FRONTEND_PRODUCT_ANALYTICS'],
104
104
  protocols: ['FRONTEND_SDK'],
105
105
  publicConfig: {
@@ -8,6 +8,7 @@ import {
8
8
  type WebsiteRuntimeContextValue,
9
9
  } from '../core/contexts/WebsiteRuntimeContext';
10
10
  import { createFrontendWebsiteProviderRegistry } from '../core/external-providers/frontend-provider-registry.website';
11
+ import type { GeneratedWebsiteProviderModules } from '../core/external-providers/frontend-provider-registry.website';
11
12
  import { BlogPost, BLOG_POST_PAGE_REF } from '../mdx/BlogPost';
12
13
  import type { WebsiteMdxComponentsMap } from '../mdx/website-mdx-provider';
13
14
  import type { WebsiteDesignTokens } from '../schemas/design-tokens/website-design-tokens.shared.schemas';
@@ -313,6 +314,26 @@ export interface WebsiteBlogPostBridgeIslandProps {
313
314
  skipToMainContentLabelKey: WebsiteLabelKey;
314
315
  /** Optional website-facing public providers materialized at build time. */
315
316
  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;
316
337
  }
317
338
 
318
339
  const EMPTY_MDX_COMPONENTS: WebsiteMdxComponentsMap = Object.freeze({});
@@ -343,6 +364,7 @@ export function createWebsiteBlogPostBridgeIsland(
343
364
  designTokens,
344
365
  skipToMainContentLabelKey,
345
366
  frontendProviders,
367
+ frontendProviderModules,
346
368
  }: WebsiteBlogPostBridgeIslandProps): ReactNode {
347
369
  const MdxBody = slugToMdxBody[slug];
348
370
  if (MdxBody === undefined) {
@@ -374,7 +396,10 @@ export function createWebsiteBlogPostBridgeIsland(
374
396
  designTokens,
375
397
  navigation,
376
398
  skipToMainContentLabelKey,
377
- frontendProviderRegistry: createFrontendWebsiteProviderRegistry(frontendProviders),
399
+ frontendProviderRegistry: createFrontendWebsiteProviderRegistry({
400
+ entries: frontendProviders,
401
+ modules: frontendProviderModules,
402
+ }),
378
403
  };
379
404
  return (
380
405
  <WebsiteRuntimeContextProvider value={runtimeValue}>
@@ -8,6 +8,7 @@ import {
8
8
  type WebsiteRuntimeContextValue,
9
9
  } from '../core/contexts/WebsiteRuntimeContext';
10
10
  import { createFrontendWebsiteProviderRegistry } from '../core/external-providers/frontend-provider-registry.website';
11
+ import type { GeneratedWebsiteProviderModules } from '../core/external-providers/frontend-provider-registry.website';
11
12
  import { WebsitePageLayout } from '../core/layouts/WebsitePageLayout';
12
13
  import type { WebsiteDesignTokens } from '../schemas/design-tokens/website-design-tokens.shared.schemas';
13
14
  import type { WebsiteLabelKey } from '../schemas/label-keys/website-label-key.schemas';
@@ -211,6 +212,26 @@ export interface WebsitePageBridgeIslandProps {
211
212
  skipToMainContentLabelKey: WebsiteLabelKey;
212
213
  /** Optional website-facing public providers materialized at build time. */
213
214
  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;
214
235
  /**
215
236
  * Optional backend API origin (no trailing slash) forwarded into
216
237
  * `WebsiteRuntimeContext.apiOrigin` for anonymous-capture sections
@@ -222,6 +243,22 @@ export interface WebsitePageBridgeIslandProps {
222
243
  * capture section.
223
244
  */
224
245
  apiOrigin?: string;
246
+ /**
247
+ * The product origin this site's self-serve CTAs link to ("Start free",
248
+ * "Get started"), forwarded into `WebsiteRuntimeContext.appOrigin` (#493).
249
+ *
250
+ * An island prop for the same reason `apiOrigin` above is one: the `.astro`
251
+ * page resolves it from `WildoMarketingWebsiteConfig.appOrigin`, which is
252
+ * Node-loaded and invisible to a client island. Before this, a site
253
+ * resolved the product origin a SECOND time from a public build-time
254
+ * environment variable — one host authored twice with no mechanical link,
255
+ * and a silent fallback to localhost when the variable was unset.
256
+ *
257
+ * Optional because a page with no CTA needs none; a page that renders one
258
+ * without passing this should fail LOUDLY rather than fall back, which is
259
+ * the consumer's job and the whole point of removing the fallback.
260
+ */
261
+ appOrigin?: string;
225
262
  }
226
263
 
227
264
  /**
@@ -243,7 +280,9 @@ export function createWebsitePageBridgeIsland(
243
280
  designTokens,
244
281
  skipToMainContentLabelKey,
245
282
  frontendProviders,
283
+ frontendProviderModules,
246
284
  apiOrigin,
285
+ appOrigin,
247
286
  }: WebsitePageBridgeIslandProps): ReactNode {
248
287
  const runtimeValue: WebsiteRuntimeContextValue = {
249
288
  locale,
@@ -252,8 +291,12 @@ export function createWebsitePageBridgeIsland(
252
291
  designTokens,
253
292
  navigation,
254
293
  skipToMainContentLabelKey,
255
- frontendProviderRegistry: createFrontendWebsiteProviderRegistry(frontendProviders),
294
+ frontendProviderRegistry: createFrontendWebsiteProviderRegistry({
295
+ entries: frontendProviders,
296
+ modules: frontendProviderModules,
297
+ }),
256
298
  apiOrigin,
299
+ appOrigin,
257
300
  };
258
301
  return (
259
302
  <WebsiteRuntimeContextProvider value={runtimeValue}>
@@ -129,7 +129,20 @@ export function renderRobotsTxt(siteContext: WebsiteSiteContext): string {
129
129
  * `Disallow: /` so the named bots are the ONLY ones permitted.
130
130
  */
131
131
  const catchAllLines: string[] = ['User-agent: *'];
132
- if (policy === 'noindex' || allowedCrawlers.length > 0) {
132
+ /*
133
+ * `policy === 'noindex'` deliberately does NOT emit `Disallow: /` (#494).
134
+ *
135
+ * `Disallow` governs CRAWLING; the `<meta name="robots" content="noindex">` emitted by the page-head
136
+ * renderer governs INDEXING, and it is the one that keeps a site out of search. Barring the crawler
137
+ * here would mean it never fetches the page and never reads that directive — so the pair defeated
138
+ * itself, and the site stayed indexable as a bare URL. Closure is expressed in the document; this
139
+ * file's job for that policy is to stay out of the way.
140
+ *
141
+ * `allowedCrawlers` is a DIFFERENT feature and keeps its `Disallow: /`: there the catch-all closes
142
+ * the rest of the crawler universe so the named bots are the only ones permitted, which is about
143
+ * crawling and is exactly what `Disallow` is for.
144
+ */
145
+ if (allowedCrawlers.length > 0) {
133
146
  catchAllLines.push('Disallow: /');
134
147
  } else if (disallow.length === 0) {
135
148
  /**
@@ -198,23 +198,43 @@ export function renderWebsitePageHeadHtml(opts: RenderWebsitePageHeadHtmlOptions
198
198
  const ogLocaleAttr = escapeHtmlAttribute(currentLocaleStr);
199
199
 
200
200
  /**
201
- * AI-training opt-out (D-AG3) — `<meta name="robots" content="noai, noimageai">`
202
- * is emitted ONLY when the page manifest opts in via
203
- * `seo.noaiOptOut: true`. Default is OPT-IN to AI training (the
204
- * marketing-site default). This emission pairs with the sibling
205
- * `_headers` entry produced by `renderWebsitePageNoaiHeaders()`
201
+ * SITE-WIDE `noindex` (#494) — the directive that actually keeps a site out of search.
202
+ *
203
+ * `robots.txt` `Disallow` governs CRAWLING, not INDEXING: a disallowed URL can still be listed, as a
204
+ * bare URL with no snippet, if anything links to it. Only this directive de-indexes. Until 2026-09-08
205
+ * a site declared `noindex` emitted the `Disallow` and NOT this, so it was closed to well-behaved
206
+ * crawlers and still indexable — the weaker half of the pair.
207
+ *
208
+ * The two are also mutually defeating, which is why `renderRobotsTxt` no longer emits `Disallow: /`
209
+ * for this policy: a crawler barred from fetching the page never reads the directive telling it not
210
+ * to index. To stay out of search you must let the crawler IN and tell it no.
211
+ *
212
+ * `noindex` only — not `noindex, nofollow`. `nofollow` additionally suppresses link discovery, which
213
+ * is a broader claim than "keep this out of the index" and is not what the policy declares.
214
+ */
215
+ const robotsDirectives: string[] = [];
216
+ if (rootConfig.seo.robots?.policy === 'noindex') {
217
+ robotsDirectives.push('noindex');
218
+ }
219
+ /**
220
+ * AI-training opt-out (D-AG3) — emitted ONLY when the page manifest opts in via
221
+ * `seo.noaiOptOut: true`. Default is OPT-IN to AI training (the marketing-site default). This
222
+ * emission pairs with the sibling `_headers` entry produced by `renderWebsitePageNoaiHeaders()`
206
223
  * for crawlers that don't honor in-document meta.
207
224
  *
208
- * Format: `noai` (block AI training on text) and `noimageai`
209
- * (block AI training on images) are the two robots directives the
210
- * major AI crawlers (GPTBot, ClaudeBot, Google-Extended, etc.)
211
- * recognise. Emitting them as a single comma-separated `content`
212
- * value is the canonical robots-meta shape.
225
+ * `noai` (block AI training on text) and `noimageai` (block AI training on images) are the two
226
+ * directives the major AI crawlers (GPTBot, ClaudeBot, Google-Extended, etc.) recognise.
213
227
  */
214
- const noaiMetaLines: string[] = [];
215
228
  if (manifest.seo?.noaiOptOut === true) {
216
- noaiMetaLines.push(`<meta name="robots" content="noai, noimageai" />`);
229
+ robotsDirectives.push('noai', 'noimageai');
217
230
  }
231
+ /*
232
+ * ONE `<meta name="robots">`, carrying every directive comma-separated — the canonical shape. Two
233
+ * separate robots meta tags are legal and are read inconsistently; merging avoids the question.
234
+ */
235
+ const noaiMetaLines: string[] = robotsDirectives.length > 0
236
+ ? [`<meta name="robots" content="${robotsDirectives.join(', ')}" />`]
237
+ : [];
218
238
 
219
239
  return [
220
240
  `<title>${titleText}</title>`,
@@ -204,12 +204,18 @@ describe('defineWebsiteConfig — @unimplemented warnings', () => {
204
204
  /**
205
205
  * The `@unimplemented` warner has ZERO branches today, NOT
206
206
  * because every authored field is wired to a runtime consumer
207
- * — `appOrigin` still has no runtime consumer (the
208
- * marketing → app handoff link surface that will read it does
209
- * not exist yet, see the schema file header) — but because the
207
+ * (`appOrigin` still has no runtime consumer), but because the
210
208
  * only OPTIONAL field that exists today (`csp?`) is already wired,
211
209
  * and firing on every Astro build for a required field would drown
212
210
  * the dev console.
211
+ *
212
+ * `appOrigin` is unwired for a narrower reason than an absent
213
+ * surface. The marketing → app handoff EXISTS: the CTA renders as
214
+ * an external button into the app. It just does not read this
215
+ * field, because a `wildo.website.config.ts` is Node-loaded and
216
+ * invisible to client islands, so the button resolves the origin
217
+ * from a public build-time environment variable instead. See the
218
+ * schema file header.
213
219
  * After the seam-collapse (`saas-website-seam-collapse.md` —
214
220
  * SHIPPED), `publicOrigin`, `defaultLocale`, and `locales` are
215
221
  * each consumed by at least one engine helper via
@@ -16,11 +16,18 @@
16
16
  * `<link rel="canonical">`, `og:url`, hreflang `href` URLs and
17
17
  * the structured-data `mainEntityOfPage`. The previous
18
18
  * `WebsiteRootConfig.seo.siteOrigin` was deleted.
19
- * - `appOrigin` — has NO runtime consumer today. The
20
- * marketing → app handoff link surface that will read it does
21
- * not exist yet. Authored eagerly so the link-surface PR is a
22
- * single framework-owned move; until then the field is exercised
23
- * only by the schema parser.
19
+ * - `appOrigin` — the marketing → app handoff target, read at runtime
20
+ * since #493. A `wildo.website.config.ts` is Node-loaded and so
21
+ * invisible to a client island, which is why this value reaches the
22
+ * browser the way `apiOrigin` does: the `.astro` page forwards it to
23
+ * its bridge island, which puts it on `WebsiteRuntimeContext.appOrigin`
24
+ * for the CTA to read.
25
+ *
26
+ * It previously had NO runtime consumer, and a site resolved the app
27
+ * origin a SECOND time from a public build-time environment variable —
28
+ * one host authored twice with no mechanical link, and a silent
29
+ * fallback to localhost when the variable was unset. That is how a
30
+ * marketing site comes to point at the wrong environment.
24
31
  * - `csp?` — read by the website-local `sync-nginx-csp` build step,
25
32
  * which merges the app-static posture with `infra.csp?.<serviceKey>`,
26
33
  * emits the generated artifact consumed by Astro pages, and rewrites
@@ -2,9 +2,11 @@ import React from 'react';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import { render } from '@testing-library/react';
4
4
  import '@testing-library/jest-dom';
5
- import { BUILTIN_PROVIDER_CAPABILITY } from '@wildo-ai/saas-models/public-runtime';
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';
9
+ import { createFrontendWebsiteProviderRegistry } from '../external-providers/frontend-provider-registry.website';
8
10
  import {
9
11
  WebsiteRuntimeContextProvider,
10
12
  type WebsiteRuntimeContextValue,
@@ -52,21 +54,21 @@ const RUNTIME_WITH_FRONTEND_PROVIDERS: WebsiteRuntimeContextValue = {
52
54
  switch (providerCapability) {
53
55
  case BUILTIN_PROVIDER_CAPABILITY.FRONTEND_PRODUCT_ANALYTICS:
54
56
  return {
55
- metadata: { ref: 'posthog' },
57
+ metadata: defineProviderMetadata({ ref: 'posthog', packageName: '@test/providers', tier: ProviderTier.EXTENSION, origin: { kind: ProviderOriginKind.APPLICATION } }),
56
58
  providerCapabilities: [BUILTIN_PROVIDER_CAPABILITY.FRONTEND_PRODUCT_ANALYTICS],
57
59
  protocols: ['FRONTEND_SDK'],
58
60
  publicConfig: { integrationKind: 'product-analytics' },
59
61
  };
60
62
  case BUILTIN_PROVIDER_CAPABILITY.FRONTEND_ERROR_MONITORING:
61
63
  return {
62
- metadata: { ref: 'sentry' },
64
+ metadata: defineProviderMetadata({ ref: 'sentry', packageName: '@test/providers', tier: ProviderTier.EXTENSION, origin: { kind: ProviderOriginKind.APPLICATION } }),
63
65
  providerCapabilities: [BUILTIN_PROVIDER_CAPABILITY.FRONTEND_ERROR_MONITORING],
64
66
  protocols: ['FRONTEND_SDK'],
65
67
  publicConfig: { integrationKind: 'browser-error-monitoring' },
66
68
  };
67
69
  case BUILTIN_PROVIDER_CAPABILITY.FRONTEND_CAPTCHA:
68
70
  return {
69
- metadata: { ref: 'recaptcha' },
71
+ metadata: defineProviderMetadata({ ref: 'recaptcha', packageName: '@test/providers', tier: ProviderTier.EXTENSION, origin: { kind: ProviderOriginKind.APPLICATION } }),
70
72
  providerCapabilities: [BUILTIN_PROVIDER_CAPABILITY.FRONTEND_CAPTCHA],
71
73
  protocols: ['FRONTEND_SDK'],
72
74
  publicConfig: { integrationKind: 'captcha' },
@@ -181,4 +183,45 @@ describe('WebsitePageLayout', () => {
181
183
  ),
182
184
  ).toThrowError(/WebsitePageRef/);
183
185
  });
186
+
187
+ it('loads the scripts a live provider declared, instead of only naming the provider', () => {
188
+ // The defect this closes: the layout resolved provider refs and stamped
189
+ // them into the `data-*` attributes asserted above, and nothing anywhere
190
+ // loaded anything. The attributes stay — they are a legitimate
191
+ // introspection surface — but they are no longer all that happens.
192
+ document.head.innerHTML = '';
193
+ const runtime: WebsiteRuntimeContextValue = {
194
+ ...RUNTIME,
195
+ frontendProviderRegistry: createFrontendWebsiteProviderRegistry({
196
+ entries: {
197
+ providers: {
198
+ recaptcha: {
199
+ metadata: defineProviderMetadata({
200
+ ref: 'recaptcha',
201
+ packageName: '@test/providers',
202
+ tier: ProviderTier.EXTENSION,
203
+ origin: { kind: ProviderOriginKind.APPLICATION },
204
+ }),
205
+ providerCapabilities: [BUILTIN_PROVIDER_CAPABILITY.FRONTEND_CAPTCHA],
206
+ protocols: ['FRONTEND_SDK'],
207
+ scripts: [{ src: 'https://captcha.test/api.js' }],
208
+ },
209
+ },
210
+ } as never,
211
+ }),
212
+ };
213
+
214
+ render(
215
+ <WebsiteRuntimeContextProvider value={runtime}>
216
+ <WebsitePageLayout pageRef="home">
217
+ <span>content</span>
218
+ </WebsitePageLayout>
219
+ </WebsiteRuntimeContextProvider>,
220
+ );
221
+
222
+ const injected = document.querySelectorAll(`script[${PROVIDER_SCRIPT_MARKER_ATTRIBUTE}]`);
223
+ expect(injected).toHaveLength(1);
224
+ expect(injected[0].getAttribute('src')).toBe('https://captcha.test/api.js');
225
+ expect(injected[0].getAttribute(PROVIDER_SCRIPT_MARKER_ATTRIBUTE)).toBe('recaptcha');
226
+ });
184
227
  });
@@ -10,11 +10,11 @@
10
10
  * server-set fields: `_id`, `anonymousUserId`, `userId`, `emailUserMatch`, timestamps).
11
11
  *
12
12
  * Mirrors the non-excluded, non-backend-only CREATE inputs of `InboundContact`
13
- * (`email` required; `firstName` / `lastName` / `phone` optional), plus TWO distinct
14
- * privacy signal: an OPTIONAL marketing opt-in (mapped to the
15
- * `privacy.dataProcessingConsent` tier). A governed notice, contract or consent
16
- * interaction must use the separate exact-release receipt contract; this DTO cannot
17
- * manufacture legal evidence from a checkbox and client timestamp.
13
+ * (`email` required; `firstName` / `lastName` / `phone` optional), plus ONE privacy
14
+ * signal: an OPTIONAL marketing opt-in (mapped to the `privacy.dataProcessingConsent`
15
+ * tier). There is no second, required agreement field: a governed notice, contract or
16
+ * consent interaction must use the separate exact-release receipt contract, because this
17
+ * DTO cannot manufacture legal evidence from a checkbox and a client timestamp.
18
18
  * `relatedUserId` is intentionally absent — it is `.excludeFromCreate()` on the
19
19
  * resource (sales/admin-only, never client-authored).
20
20
  */
@@ -35,7 +35,8 @@ export const InboundContactFormSchema = z.object({
35
35
  phone: z.string().trim().max(64).optional(),
36
36
  /**
37
37
  * OPTIONAL marketing opt-in — maps into the resource's `privacy.dataProcessingConsent`
38
- * tier ('marketing' vs the BASIC default). Independent of the required agreement above.
38
+ * tier ('marketing' vs the BASIC default). It is the only privacy field on this form,
39
+ * and it gates nothing: an unchecked box still submits, at the BASIC tier.
39
40
  */
40
41
  marketingConsent: z.boolean().default(false),
41
42
  });
@@ -17,9 +17,10 @@ import type { WebsitePageRef } from '../../schemas/refs/page-ref.schemas';
17
17
  * MDX-driven blog index aggregating post excerpts, etc.) without
18
18
  * lying about which page each subtree belongs to.
19
19
  *
20
- * **Why a separate file from the hook**: same Fast-Refresh rationale
21
- * as `WebsiteRuntimeContext` — split provider/context/hook to keep
22
- * HMR boundaries small.
20
+ * **Why the hook lives in a separate file**: same Fast-Refresh
21
+ * rationale as `WebsiteRuntimeContext` — the context and its provider
22
+ * component stay together here (`useContext` matches on object
23
+ * identity), and only the hook is split out, into `useWebsitePage.ts`.
23
24
  */
24
25
  export interface WebsitePageContextValue {
25
26
  /**