diff --git a/e2e/docs-ui-static.spec.ts b/e2e/docs-ui-static.spec.ts
index f5ae9f2..fac8b6d 100644
--- a/e2e/docs-ui-static.spec.ts
+++ b/e2e/docs-ui-static.spec.ts
@@ -22,6 +22,14 @@ test.describe('Cloudflare Static Assets export', () => {
await expect(page.getByText('List all pets').first()).toBeVisible();
});
+ test('embeds analytics settings without tracking the local preview', async ({ page }) => {
+ await page.goto('/');
+
+ expect(await page.content()).toContain('G-KQW4ERPLHB');
+ await expect(page.locator('#cortex-google-analytics')).toHaveCount(0);
+ await expect(page.locator('.cortex-cookie-settings-button')).toHaveCount(0);
+ });
+
test('supports client navigation between generated documentation pages', async ({ page }) => {
await page.goto('/docs/quickstart');
await expect(page).toHaveTitle('Petstore Docs');
diff --git a/packages/core/__tests__/config-loader.test.ts b/packages/core/__tests__/config-loader.test.ts
index e356ec6..46802c8 100644
--- a/packages/core/__tests__/config-loader.test.ts
+++ b/packages/core/__tests__/config-loader.test.ts
@@ -501,6 +501,11 @@ describe('ConfigLoader', () => {
title: 'Acme API',
logo: './logo.png',
custom_head_html: '',
+ analytics: {
+ google_analytics_id: 'G-KQW4ERPLHB',
+ enabled_hosts: ['docs.example.com'],
+ privacy_url: 'https://example.com/privacy',
+ },
theme: 'light',
sources: [
{
@@ -515,8 +520,23 @@ describe('ConfigLoader', () => {
expect(config.title).toBe('Acme API');
expect(config.logo).toBe('./logo.png');
expect(config.custom_head_html).toBe('');
+ expect(config.analytics).toEqual({
+ google_analytics_id: 'G-KQW4ERPLHB',
+ enabled_hosts: ['docs.example.com'],
+ privacy_url: 'https://example.com/privacy',
+ });
expect(config.theme).toBe('light');
});
+
+ it('rejects an invalid Google Analytics measurement ID', () => {
+ expect(() =>
+ loader.validate({
+ project: 'acme',
+ analytics: { google_analytics_id: 'UA-123456' },
+ sources: [],
+ }),
+ ).toThrow();
+ });
});
it('resolves project paths relative to the config file', async () => {
diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts
index 17232bc..21261ca 100644
--- a/packages/core/src/config/schema.ts
+++ b/packages/core/src/config/schema.ts
@@ -238,6 +238,15 @@ const mcpConfigSchema = z
.strict()
.optional();
+const analyticsConfigSchema = z
+ .object({
+ google_analytics_id: z.string().regex(/^G-[A-Z0-9]+$/),
+ enabled_hosts: z.array(z.string().min(1)).optional(),
+ privacy_url: z.string().url().optional(),
+ })
+ .strict()
+ .optional();
+
const publishConfigSchema = z
.object({
registries: z
@@ -275,6 +284,7 @@ export const cortexConfigSchema = z
generators: generatorConfigSchema.optional(),
docs: z.array(docsSectionSchema).optional(),
mcp: mcpConfigSchema,
+ analytics: analyticsConfigSchema,
publish: publishConfigSchema,
})
.strict();
diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts
index 356e93d..965ea4c 100644
--- a/packages/core/src/config/types.ts
+++ b/packages/core/src/config/types.ts
@@ -170,6 +170,12 @@ export interface McpConfig {
github_repository?: string;
}
+export interface AnalyticsConfig {
+ google_analytics_id: string;
+ enabled_hosts?: string[];
+ privacy_url?: string;
+}
+
export interface CortexConfig {
project: string;
title?: string;
@@ -189,5 +195,6 @@ export interface CortexConfig {
languages: LanguageConfig[];
docs?: DocsSection[];
mcp?: McpConfig;
+ analytics?: AnalyticsConfig;
publish?: PublishConfig;
}
diff --git a/packages/docs-site/cortex.config.yml b/packages/docs-site/cortex.config.yml
index c2d2458..8dfa7ce 100644
--- a/packages/docs-site/cortex.config.yml
+++ b/packages/docs-site/cortex.config.yml
@@ -9,6 +9,11 @@ showLogoDocsLabel: true
favicon: ./assets/favicon.svg
theme: system
primaryColor: '#ffffff'
+analytics:
+ google_analytics_id: G-KQW4ERPLHB
+ enabled_hosts:
+ - docs.cortexdocs.dev
+ privacy_url: https://cortexdocs.dev/privacy#cookies-and-analytics
home:
title: Cortex Docs
description: Generate typed SDKs, documentation, and MCP servers from OpenAPI, AsyncAPI, GraphQL, Protocol Buffer, and OpenRPC sources.
diff --git a/packages/docs-site/docs/configuration.md b/packages/docs-site/docs/configuration.md
index cecd3d2..73d142f 100644
--- a/packages/docs-site/docs/configuration.md
+++ b/packages/docs-site/docs/configuration.md
@@ -132,6 +132,7 @@ mcp:
| `home` | object | No | Landing-page content and navigation cards |
| `docs` | array | No | Markdown navigation sections |
| `mcp` | object | No | Generated MCP package settings |
+| `analytics` | object | No | Consent-aware Google Analytics settings |
| `publish` | object | No | Package registry and GitHub publication settings |
See [Custom Generators](/docs/custom-generators) for export commands, template data, and override rules.
@@ -159,21 +160,28 @@ Files in the project `assets` directory are available under `/assets/*`.
custom_head_html: |-
-
-
-
-
```
-Replace `G-XXXXXXXXXX` with your Google Analytics measurement ID.
+For Google Analytics, use the `analytics` configuration. This configuration adds the consent controls.
CAUTION: Add only HTML that you trust. Scripts in this field can execute in every visitor's browser.
+## Google Analytics
+
+Use `analytics` to add consent-aware Google Analytics 4 tracking. Cortex disables advertising signals for this integration.
+
+```yaml
+analytics:
+ google_analytics_id: G-XXXXXXXXXX
+ enabled_hosts:
+ - docs.example.com
+ privacy_url: https://example.com/privacy#cookies-and-analytics
+```
+
+`google_analytics_id` is the Google Analytics measurement ID. `enabled_hosts` prevents tracking on local and preview sites.
+
+`privacy_url` opens from the cookie banner. Cortex asks for consent where required and stores the choice in local browser storage.
+
## Sources
The `sources` array is the primary way to define your API specs. Each source represents a single spec file and its language targets.
diff --git a/packages/docs-ui/__tests__/analytics-consent.test.ts b/packages/docs-ui/__tests__/analytics-consent.test.ts
new file mode 100644
index 0000000..d37d457
--- /dev/null
+++ b/packages/docs-ui/__tests__/analytics-consent.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest';
+import { analyticsAllowed, isAnalyticsHost } from '../lib/analytics-consent';
+
+describe('analytics consent', () => {
+ it('restricts analytics to configured production hosts', () => {
+ const hosts = ['docs.cortexdocs.dev', 'demo.cortexdocs.dev'];
+ expect(isAnalyticsHost('docs.cortexdocs.dev', hosts)).toBe(true);
+ expect(isAnalyticsHost('DOCS.CORTEXDOCS.DEV', hosts)).toBe(true);
+ expect(isAnalyticsHost('localhost', hosts)).toBe(false);
+ expect(isAnalyticsHost('preview.example.com', [])).toBe(true);
+ });
+
+ it('requires an explicit choice in consent regions', () => {
+ expect(analyticsAllowed({ choice: null, required: true, enabled: true, ready: true })).toBe(
+ false,
+ );
+ expect(
+ analyticsAllowed({ choice: 'granted', required: true, enabled: true, ready: true }),
+ ).toBe(true);
+ expect(
+ analyticsAllowed({ choice: 'denied', required: false, enabled: true, ready: true }),
+ ).toBe(false);
+ });
+
+ it('starts analytics without a choice outside consent regions', () => {
+ expect(analyticsAllowed({ choice: null, required: false, enabled: true, ready: true })).toBe(
+ true,
+ );
+ expect(analyticsAllowed({ choice: null, required: false, enabled: false, ready: true })).toBe(
+ false,
+ );
+ });
+});
diff --git a/packages/docs-ui/app/layout.tsx b/packages/docs-ui/app/layout.tsx
index 336302b..4e333b0 100644
--- a/packages/docs-ui/app/layout.tsx
+++ b/packages/docs-ui/app/layout.tsx
@@ -11,6 +11,7 @@ import {
type SiteConfig,
} from '@/components/docs/site-config-provider';
import { SearchProvider } from '@/components/docs/search-provider';
+import { GoogleAnalytics } from '@/components/docs/google-analytics';
import { sanitizeSvg } from '@/lib/sanitize-svg';
interface LoadedSiteConfig extends SiteConfig {
@@ -125,6 +126,23 @@ function readSiteConfig(): LoadedSiteConfig {
const sources = raw?.sources as Array | undefined;
const docs = raw?.docs as Array | undefined;
const mcp = raw?.mcp as Record | undefined;
+ const analyticsValue = raw?.analytics as Record | undefined;
+ const googleAnalyticsId = analyticsValue?.google_analytics_id;
+ const enabledHostsValue = analyticsValue?.enabled_hosts;
+ const privacyUrlValue = analyticsValue?.privacy_url;
+ const analytics =
+ typeof googleAnalyticsId === 'string'
+ ? {
+ googleAnalyticsId,
+ enabledHosts: Array.isArray(enabledHostsValue)
+ ? enabledHostsValue.filter((host): host is string => typeof host === 'string')
+ : [],
+ privacyUrl:
+ typeof privacyUrlValue === 'string'
+ ? privacyUrlValue
+ : 'https://cortexdocs.dev/privacy#cookies-and-analytics',
+ }
+ : undefined;
const customHeadHtmlValue = raw?.custom_head_html;
const customHeadHtml =
typeof customHeadHtmlValue === 'string' && customHeadHtmlValue.trim()
@@ -146,6 +164,7 @@ function readSiteConfig(): LoadedSiteConfig {
hasSources: Array.isArray(sources) && sources.length > 0,
hasDocs: Array.isArray(docs) && docs.length > 0,
hasMcp: !!mcp || (Array.isArray(sources) && sources.length > 0),
+ analytics,
home: home
? {
title: home.title as string | undefined,
@@ -200,6 +219,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}
+