@wildo-ai/saas-website 1.1.2 → 1.1.4
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 +21 -0
- package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
- package/dist/esm/astro/blog-post-bridge.js +5 -2
- package/dist/esm/astro/blog-post-bridge.js.map +1 -1
- package/dist/esm/astro/blog-post-head.renderer.d.ts.map +1 -1
- package/dist/esm/astro/bridge-runtime.d.ts +37 -0
- package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
- package/dist/esm/astro/bridge-runtime.js +6 -2
- package/dist/esm/astro/bridge-runtime.js.map +1 -1
- package/dist/esm/astro/collect-expected-label-keys.d.ts.map +1 -1
- package/dist/esm/astro/headers-renderer.d.ts.map +1 -1
- package/dist/esm/astro/i18n-routing.helper.d.ts.map +1 -1
- package/dist/esm/astro/label-pack-loader.d.ts.map +1 -1
- package/dist/esm/astro/llms-txt-server.d.ts.map +1 -1
- package/dist/esm/astro/pick-labels-for-page.d.ts.map +1 -1
- package/dist/esm/astro/robots-renderer.d.ts.map +1 -1
- package/dist/esm/astro/robots-renderer.js +14 -1
- package/dist/esm/astro/robots-renderer.js.map +1 -1
- package/dist/esm/astro/sitemap-coverage.d.ts.map +1 -1
- package/dist/esm/astro/structured-data.renderer.d.ts.map +1 -1
- package/dist/esm/astro/website-page-head.renderer.d.ts.map +1 -1
- package/dist/esm/astro/website-page-head.renderer.js +32 -12
- package/dist/esm/astro/website-page-head.renderer.js.map +1 -1
- package/dist/esm/astro/website-page-runtime.helper.d.ts.map +1 -1
- package/dist/esm/astro/website-site-context.d.ts.map +1 -1
- package/dist/esm/astro-island.d.ts.map +1 -1
- package/dist/esm/astro.d.ts.map +1 -1
- package/dist/esm/companion-exports.d.ts.map +1 -1
- package/dist/esm/components/low-level/WebsiteInternalButton.d.ts.map +1 -1
- package/dist/esm/components/low-level/WebsiteInternalLink.d.ts.map +1 -1
- package/dist/esm/config/define-website-config.d.ts.map +1 -1
- package/dist/esm/config/index.d.ts.map +1 -1
- package/dist/esm/config/load-website-config.d.ts.map +1 -1
- package/dist/esm/config/wildo-website-config.schemas.d.ts +12 -5
- package/dist/esm/config/wildo-website-config.schemas.d.ts.map +1 -1
- package/dist/esm/config/wildo-website-config.schemas.js +12 -5
- package/dist/esm/config/wildo-website-config.schemas.js.map +1 -1
- package/dist/esm/config-loader.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/InboundContactForm.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts +5 -5
- 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 +7 -6
- 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.map +1 -1
- package/dist/esm/core/contexts/WebsitePageContext.d.ts +4 -3
- package/dist/esm/core/contexts/WebsitePageContext.d.ts.map +1 -1
- package/dist/esm/core/contexts/WebsitePageContext.js.map +1 -1
- package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +36 -7
- 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/contexts/WebsiteSectionContext.d.ts +3 -2
- package/dist/esm/core/contexts/WebsiteSectionContext.d.ts.map +1 -1
- package/dist/esm/core/contexts/WebsiteSectionContext.js.map +1 -1
- package/dist/esm/core/contexts/useWebsitePage.d.ts.map +1 -1
- package/dist/esm/core/contexts/useWebsiteRuntime.d.ts.map +1 -1
- package/dist/esm/core/contexts/useWebsiteSection.d.ts.map +1 -1
- package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts +37 -13
- 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 +28 -19
- package/dist/esm/core/external-providers/frontend-provider-registry.website.js.map +1 -1
- package/dist/esm/core/external-providers/provider-scripts.website.d.ts +68 -0
- package/dist/esm/core/external-providers/provider-scripts.website.d.ts.map +1 -0
- package/dist/esm/core/external-providers/provider-scripts.website.js +99 -0
- package/dist/esm/core/external-providers/provider-scripts.website.js.map +1 -0
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts +34 -0
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -0
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +78 -0
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -0
- package/dist/esm/core/factories/define-website-page-manifest.d.ts.map +1 -1
- package/dist/esm/core/factories/define-website-section.d.ts.map +1 -1
- package/dist/esm/core/hooks/useWebsiteDesignTokens.d.ts.map +1 -1
- package/dist/esm/core/hooks/useWebsiteLabel.d.ts.map +1 -1
- package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -1
- package/dist/esm/core/layouts/WebsitePageLayout.js +7 -0
- package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
- package/dist/esm/core/layouts/WebsiteSection.d.ts.map +1 -1
- package/dist/esm/core/routing/internal-routing.utils.d.ts.map +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/mdx/BlogPost.d.ts.map +1 -1
- package/dist/esm/mdx/blog-post-collection.config.d.ts.map +1 -1
- package/dist/esm/mdx/blog-post-frontmatter-source.d.ts.map +1 -1
- package/dist/esm/mdx/index.d.ts.map +1 -1
- package/dist/esm/mdx.d.ts.map +1 -1
- package/dist/esm/schemas/blog/blog-post-frontmatter.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/label-keys/website-label-key.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/refs/page-ref.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/refs/section-ref.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/sections/website-section-category.shared.d.ts +19 -20
- package/dist/esm/schemas/sections/website-section-category.shared.d.ts.map +1 -1
- package/dist/esm/schemas/sections/website-section-category.shared.js +19 -20
- package/dist/esm/schemas/sections/website-section-category.shared.js.map +1 -1
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/validators/label-pack.validator.d.ts.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +5 -4
- package/src/__tests__/bundle-isolation.test.ts +21 -0
- package/src/astro/__tests__/blog-post-bridge.test.tsx +2 -2
- package/src/astro/__tests__/bridge-runtime.test.tsx +57 -2
- package/src/astro/__tests__/robots-renderer.test.ts +16 -1
- package/src/astro/__tests__/website-page-head.renderer.test.ts +43 -0
- package/src/astro/__tests__/website-site-context.test.ts +1 -1
- package/src/astro/blog-post-bridge.tsx +26 -1
- package/src/astro/bridge-runtime.tsx +44 -1
- package/src/astro/robots-renderer.ts +14 -1
- package/src/astro/website-page-head.renderer.ts +32 -12
- package/src/config/__tests__/define-website-config.test.ts +9 -3
- package/src/config/wildo-website-config.schemas.ts +12 -5
- package/src/core/__tests__/WebsitePageLayout.test.tsx +47 -4
- package/src/core/anonymous-session/inbound-contact-form.schema.ts +7 -6
- package/src/core/contexts/WebsitePageContext.tsx +4 -3
- package/src/core/contexts/WebsiteRuntimeContext.tsx +36 -7
- package/src/core/contexts/WebsiteSectionContext.tsx +3 -2
- package/src/core/external-providers/__tests__/frontend-provider-registry.website.test.ts +79 -0
- package/src/core/external-providers/__tests__/provider-scripts.website.test.ts +146 -0
- package/src/core/external-providers/frontend-provider-registry.website.ts +53 -54
- package/src/core/external-providers/provider-scripts.website.ts +126 -0
- package/src/core/external-providers/useWebsiteProviderScripts.ts +92 -0
- package/src/core/layouts/WebsitePageLayout.tsx +11 -0
- package/src/schemas/sections/website-section-category.shared.ts +19 -20
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.4",
|
|
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/
|
|
69
|
-
"@wildo-ai/
|
|
70
|
-
"@wildo-ai/
|
|
68
|
+
"@wildo-ai/external-connectors-public": "1.1.4",
|
|
69
|
+
"@wildo-ai/platform-config-lib": "1.1.4",
|
|
70
|
+
"@wildo-ai/presets-components-models": "1.1.4",
|
|
71
|
+
"@wildo-ai/saas-models": "1.1.4",
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* `
|
|
204
|
-
*
|
|
205
|
-
* `
|
|
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
|
-
*
|
|
209
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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` —
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* interaction must use the separate exact-release receipt contract
|
|
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).
|
|
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
|
|
21
|
-
* as `WebsiteRuntimeContext` —
|
|
22
|
-
*
|
|
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
|
/**
|