Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions e2e/docs-ui-static.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
20 changes: 20 additions & 0 deletions packages/core/__tests__/config-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,11 @@ describe('ConfigLoader', () => {
title: 'Acme API',
logo: './logo.png',
custom_head_html: '<meta name="theme-color" content="#ffffff">',
analytics: {
google_analytics_id: 'G-KQW4ERPLHB',
enabled_hosts: ['docs.example.com'],
privacy_url: 'https://example.com/privacy',
},
theme: 'light',
sources: [
{
Expand All @@ -515,8 +520,23 @@ describe('ConfigLoader', () => {
expect(config.title).toBe('Acme API');
expect(config.logo).toBe('./logo.png');
expect(config.custom_head_html).toBe('<meta name="theme-color" content="#ffffff">');
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 () => {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -275,6 +284,7 @@ export const cortexConfigSchema = z
generators: generatorConfigSchema.optional(),
docs: z.array(docsSectionSchema).optional(),
mcp: mcpConfigSchema,
analytics: analyticsConfigSchema,
publish: publishConfigSchema,
})
.strict();
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -189,5 +195,6 @@ export interface CortexConfig {
languages: LanguageConfig[];
docs?: DocsSection[];
mcp?: McpConfig;
analytics?: AnalyticsConfig;
publish?: PublishConfig;
}
5 changes: 5 additions & 0 deletions packages/docs-site/cortex.config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 18 additions & 10 deletions packages/docs-site/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -159,21 +160,28 @@ Files in the project `assets` directory are available under `/assets/*`.
custom_head_html: |-
<meta name="theme-color" content="#ffffff">
<link rel="stylesheet" href="/assets/custom.css">

<!-- Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>
```

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.
Expand Down
33 changes: 33 additions & 0 deletions packages/docs-ui/__tests__/analytics-consent.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
20 changes: 20 additions & 0 deletions packages/docs-ui/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -125,6 +126,23 @@ function readSiteConfig(): LoadedSiteConfig {
const sources = raw?.sources as Array<unknown> | undefined;
const docs = raw?.docs as Array<unknown> | undefined;
const mcp = raw?.mcp as Record<string, unknown> | undefined;
const analyticsValue = raw?.analytics as Record<string, unknown> | 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()
Expand All @@ -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,
Expand Down Expand Up @@ -200,6 +219,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<SiteConfigProvider config={siteConfig}>
<ThemeProvider defaultTheme={defaultTheme} disableTransitionOnChange>
<SearchProvider>{children}</SearchProvider>
<GoogleAnalytics config={siteConfig.analytics} />
</ThemeProvider>
</SiteConfigProvider>
</body>
Expand Down
141 changes: 141 additions & 0 deletions packages/docs-ui/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,144 @@ html.dark {
color: #ffdcd7;
background-color: #67060c;
}

.cortex-cookie-settings-button {
position: fixed;
z-index: 100;
right: 14px;
bottom: 12px;
padding: 6px 9px;
border: 1px solid var(--color-border);
border-radius: 7px;
color: var(--color-muted-foreground);
background: color-mix(in oklab, var(--color-background) 92%, transparent);
font-family: var(--font-mono);
font-size: 10px;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
backdrop-filter: blur(12px);
}

.cortex-cookie-banner {
position: fixed;
z-index: 110;
right: 0;
bottom: 0;
left: 0;
padding: 0 24px 24px;
pointer-events: none;
}

.cortex-cookie-banner-panel {
display: flex;
max-width: 1040px;
min-height: 112px;
align-items: center;
justify-content: space-between;
gap: 36px;
margin: 0 auto;
padding: 22px 24px;
border: 1px solid var(--color-border);
border-radius: 16px;
color: var(--color-foreground);
background: color-mix(in oklab, var(--color-background) 96%, transparent);
box-shadow: 0 24px 80px rgb(0 0 0 / 0.28);
backdrop-filter: blur(14px);
pointer-events: auto;
}

.cortex-cookie-banner-copy {
max-width: 620px;
}

.cortex-cookie-banner-copy > span {
color: var(--color-muted-foreground);
font-family: var(--font-mono);
font-size: 9px;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
}

.cortex-cookie-banner-copy p {
margin: 10px 0 0;
color: var(--color-muted-foreground);
font-size: 13px;
line-height: 1.65;
}

.cortex-cookie-banner-copy a {
color: var(--color-foreground);
text-decoration: underline;
text-underline-offset: 3px;
}

.cortex-cookie-banner-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 9px;
}

.cortex-cookie-banner-actions > button {
min-width: 96px;
height: 40px;
padding: 0 18px;
border: 1px solid var(--color-border);
border-radius: 9px;
color: var(--color-foreground);
background: var(--color-muted);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
}

.cortex-cookie-banner-actions > button.primary {
border-color: var(--color-primary);
color: var(--color-primary-foreground);
background: var(--color-primary);
}

.cortex-cookie-banner-actions > button.active:not(.primary) {
border-color: var(--color-ring);
background: var(--color-accent);
}

.cortex-cookie-banner-actions > button:focus-visible {
outline: 2px solid var(--color-ring);
outline-offset: 3px;
}

.cortex-cookie-banner-actions > button.close {
min-width: 36px;
width: 36px;
padding: 0;
border-color: transparent;
color: var(--color-muted-foreground);
background: transparent;
font-family: var(--font-sans);
font-size: 19px;
}

@media (max-width: 760px) {
.cortex-cookie-banner {
padding: 0 12px 12px;
}

.cortex-cookie-banner-panel {
display: grid;
gap: 18px;
padding: 20px;
}

.cortex-cookie-banner-actions {
width: 100%;
}

.cortex-cookie-banner-actions > button:not(.close) {
flex: 1;
}

.cortex-cookie-banner-actions > button.close {
display: none;
}
}
Loading