From bb4fbaf3384bd63a9e97256c5f0f47f49d5db1ac Mon Sep 17 00:00:00 2001 From: Nick Chisiu <8492343+nickchisiu@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:11:26 +0300 Subject: [PATCH] feat(docs): add consent-aware analytics --- e2e/docs-ui-static.spec.ts | 8 + packages/core/__tests__/config-loader.test.ts | 20 ++ packages/core/src/config/schema.ts | 10 + packages/core/src/config/types.ts | 7 + packages/docs-site/cortex.config.yml | 5 + packages/docs-site/docs/configuration.md | 28 +- .../__tests__/analytics-consent.test.ts | 33 +++ packages/docs-ui/app/layout.tsx | 20 ++ packages/docs-ui/app/styles.css | 141 ++++++++++ .../components/docs/google-analytics.tsx | 248 ++++++++++++++++++ .../components/docs/site-config-provider.tsx | 7 + packages/docs-ui/lib/analytics-consent.ts | 114 ++++++++ packages/docs-ui/scripts/prepare-demo.mjs | 5 + 13 files changed, 636 insertions(+), 10 deletions(-) create mode 100644 packages/docs-ui/__tests__/analytics-consent.test.ts create mode 100644 packages/docs-ui/components/docs/google-analytics.tsx create mode 100644 packages/docs-ui/lib/analytics-consent.ts 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} + diff --git a/packages/docs-ui/app/styles.css b/packages/docs-ui/app/styles.css index 9f98637..7941737 100644 --- a/packages/docs-ui/app/styles.css +++ b/packages/docs-ui/app/styles.css @@ -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; + } +} diff --git a/packages/docs-ui/components/docs/google-analytics.tsx b/packages/docs-ui/components/docs/google-analytics.tsx new file mode 100644 index 0000000..6e42b27 --- /dev/null +++ b/packages/docs-ui/components/docs/google-analytics.tsx @@ -0,0 +1,248 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import type { AnalyticsConfig } from './site-config-provider'; +import { + analyticsAllowed, + CONSENT_CHANGED, + CONSENT_REOPEN, + consentRequired, + isAnalyticsHost, + openConsentSettings, + readConsent, + type ConsentChoice, + type ConsentState, + writeConsent, +} from '@/lib/analytics-consent'; + +const COOKIE_LIFETIME_SECONDS = 400 * 24 * 60 * 60; +const SCRIPT_ID = 'cortex-google-analytics'; + +type AnalyticsWindow = Window & { + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + __cortexAnalyticsInitialized?: boolean; +}; + +function useConsent(config: AnalyticsConfig): ConsentState { + const [state, setState] = useState({ + choice: null, + required: true, + enabled: false, + ready: false, + }); + + useEffect(() => { + if (!isAnalyticsHost(window.location.hostname, config.enabledHosts)) return; + let active = true; + + consentRequired().then((required) => { + if (active) { + setState({ choice: readConsent(), required, enabled: true, ready: true }); + } + }); + + const handleChange = (event: Event) => { + setState((previous) => ({ + ...previous, + choice: (event as CustomEvent).detail, + })); + }; + + window.addEventListener(CONSENT_CHANGED, handleChange); + return () => { + active = false; + window.removeEventListener(CONSENT_CHANGED, handleChange); + }; + }, [config.enabledHosts]); + + return state; +} + +function clearAnalyticsCookies(): void { + const host = window.location.hostname; + const registrableDomain = host.split('.').slice(-2).join('.'); + const domains = ['', `; domain=${host}`, `; domain=.${host}`, `; domain=.${registrableDomain}`]; + + for (const entry of document.cookie.split(';')) { + const name = entry.split('=')[0]?.trim(); + if (!name || (name !== '_ga' && name !== '_gid' && !name.startsWith('_ga_'))) continue; + + for (const domain of domains) { + document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT${domain}`; + } + } +} + +function initializeAnalytics(measurementId: string): void { + const analyticsWindow = window as AnalyticsWindow; + if (analyticsWindow.__cortexAnalyticsInitialized) return; + + const dataLayer = (analyticsWindow.dataLayer ??= []); + analyticsWindow.gtag ??= function () { + // gtag.js requires the Arguments object. It ignores a rest-parameter array. + // eslint-disable-next-line prefer-rest-params + dataLayer.push(arguments); + }; + analyticsWindow.__cortexAnalyticsInitialized = true; + + analyticsWindow.gtag('js', new Date()); + analyticsWindow.gtag('consent', 'default', { + analytics_storage: 'granted', + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + }); + analyticsWindow.gtag('config', measurementId, { + cookie_expires: COOKIE_LIFETIME_SECONDS, + allow_google_signals: false, + allow_ad_personalization_signals: false, + }); + + if (!document.getElementById(SCRIPT_ID)) { + const script = document.createElement('script'); + script.id = SCRIPT_ID; + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`; + document.head.appendChild(script); + } +} + +function AnalyticsLoader({ config }: { config: AnalyticsConfig }) { + const consent = useConsent(config); + const withdrawn = useRef(false); + const allowed = analyticsAllowed(consent); + + useEffect(() => { + if (!consent.enabled || !consent.ready) return; + + const analyticsWindow = window as AnalyticsWindow; + const killSwitch = `ga-disable-${config.googleAnalyticsId}`; + const switches = window as unknown as Record; + + if (consent.choice === 'denied') { + switches[killSwitch] = true; + withdrawn.current = true; + analyticsWindow.gtag?.('consent', 'update', { + analytics_storage: 'denied', + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + }); + clearAnalyticsCookies(); + return; + } + + if (!allowed) return; + + switches[killSwitch] = false; + const wasInitialized = analyticsWindow.__cortexAnalyticsInitialized; + initializeAnalytics(config.googleAnalyticsId); + + if (wasInitialized && withdrawn.current) { + withdrawn.current = false; + analyticsWindow.gtag?.('consent', 'update', { + analytics_storage: 'granted', + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + }); + analyticsWindow.gtag?.('event', 'page_view'); + } + }, [allowed, config.googleAnalyticsId, consent.choice, consent.enabled, consent.ready]); + + return null; +} + +function CookieBanner({ config }: { config: AnalyticsConfig }) { + const consent = useConsent(config); + const [reopened, setReopened] = useState(false); + + useEffect(() => { + const reopen = () => setReopened(true); + window.addEventListener(CONSENT_REOPEN, reopen); + return () => window.removeEventListener(CONSENT_REOPEN, reopen); + }, []); + + const asking = consent.ready && consent.required && consent.choice === null; + const open = consent.enabled && consent.ready && (asking || reopened); + + useEffect(() => { + if (!open || asking) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setReopened(false); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [asking, open]); + + if (!consent.enabled || !consent.ready) return null; + + if (!open) { + return ( + + ); + } + + const choose = (choice: ConsentChoice) => { + writeConsent(choice); + setReopened(false); + }; + const running = analyticsAllowed(consent); + + return ( +
+
+
+ {asking ? 'Cookies' : 'Cookie settings'} +

+ {asking + ? 'We use optional analytics cookies to understand how this site is used. Analytics stays off until you choose.' + : 'Analytics measures how this site is used. Turning it off removes the cookies it stored.'}{' '} + What we store. +

+
+
+ + + {!asking && ( + + )} +
+
+
+ ); +} + +export function GoogleAnalytics({ config }: { config?: AnalyticsConfig }) { + if (!config) return null; + return ( + <> + + + + ); +} diff --git a/packages/docs-ui/components/docs/site-config-provider.tsx b/packages/docs-ui/components/docs/site-config-provider.tsx index 18ce845..c6c3ebe 100644 --- a/packages/docs-ui/components/docs/site-config-provider.tsx +++ b/packages/docs-ui/components/docs/site-config-provider.tsx @@ -18,6 +18,12 @@ export interface HomeConfig { sections?: HomeSection[]; } +export interface AnalyticsConfig { + googleAnalyticsId: string; + enabledHosts: string[]; + privacyUrl: string; +} + export interface SiteConfig { title: string; project: string; @@ -34,6 +40,7 @@ export interface SiteConfig { hasSources?: boolean; hasDocs?: boolean; hasMcp?: boolean; + analytics?: AnalyticsConfig; } const SiteConfigContext = createContext({ diff --git a/packages/docs-ui/lib/analytics-consent.ts b/packages/docs-ui/lib/analytics-consent.ts new file mode 100644 index 0000000..a844619 --- /dev/null +++ b/packages/docs-ui/lib/analytics-consent.ts @@ -0,0 +1,114 @@ +export type ConsentChoice = 'granted' | 'denied'; + +export interface ConsentState { + choice: ConsentChoice | null; + required: boolean; + enabled: boolean; + ready: boolean; +} + +export const CONSENT_CHANGED = 'cortex:consent-changed'; +export const CONSENT_REOPEN = 'cortex:consent-reopen'; +export const CONSENT_STORAGE_KEY = 'cortex.cookie-consent.v1'; +const COUNTRY_STORAGE_KEY = 'cortex.country'; + +const CONSENT_REQUIRED = new Set([ + 'AT', + 'BE', + 'BG', + 'CY', + 'CZ', + 'DE', + 'DK', + 'EE', + 'ES', + 'FI', + 'FR', + 'GR', + 'HR', + 'HU', + 'IE', + 'IT', + 'LT', + 'LU', + 'LV', + 'MT', + 'NL', + 'PL', + 'PT', + 'RO', + 'SE', + 'SI', + 'SK', + 'IS', + 'LI', + 'NO', + 'GB', + 'CH', +]); + +export function isAnalyticsHost(hostname: string, enabledHosts: string[]): boolean { + return enabledHosts.length === 0 || enabledHosts.includes(hostname.toLowerCase()); +} + +export function analyticsAllowed(state: ConsentState): boolean { + if (!state.ready || !state.enabled) return false; + return state.choice === 'granted' || (state.choice === null && !state.required); +} + +export function readConsent(): ConsentChoice | null { + try { + const stored = window.localStorage.getItem(CONSENT_STORAGE_KEY); + return stored === 'granted' || stored === 'denied' ? stored : null; + } catch { + return null; + } +} + +export function writeConsent(choice: ConsentChoice): void { + try { + window.localStorage.setItem(CONSENT_STORAGE_KEY, choice); + } catch { + // The choice still applies to the current page when storage is unavailable. + } + + window.dispatchEvent(new CustomEvent(CONSENT_CHANGED, { detail: choice })); +} + +export function openConsentSettings(): void { + window.dispatchEvent(new Event(CONSENT_REOPEN)); +} + +async function visitorCountry(): Promise { + try { + const cached = window.sessionStorage.getItem(COUNTRY_STORAGE_KEY); + if (cached) return cached === '?' ? null : cached; + } catch { + // Continue without storage. Unknown locations require consent. + } + + let code: string | null = null; + + try { + const response = await fetch('/cdn-cgi/trace', { cache: 'no-store' }); + if (response.ok) { + const match = /^loc=([A-Z]{2})$/m.exec(await response.text()); + if (match && match[1] !== 'XX' && match[1] !== 'T1') code = match[1]; + } + } catch { + // Fail closed when the visitor location is unknown. + } + + try { + window.sessionStorage.setItem(COUNTRY_STORAGE_KEY, code ?? '?'); + } catch { + // The location is checked again during the next browser session. + } + + return code; +} + +export async function consentRequired(): Promise { + const code = await visitorCountry(); + return code === null || CONSENT_REQUIRED.has(code); +} diff --git a/packages/docs-ui/scripts/prepare-demo.mjs b/packages/docs-ui/scripts/prepare-demo.mjs index 8fbb2ec..421757c 100644 --- a/packages/docs-ui/scripts/prepare-demo.mjs +++ b/packages/docs-ui/scripts/prepare-demo.mjs @@ -148,6 +148,11 @@ API requests are rate-limited to **1000 requests per minute** per API key. When ].join('\n'), theme: 'system', primaryColor: '#ffffff', + analytics: { + google_analytics_id: 'G-KQW4ERPLHB', + enabled_hosts: ['demo.cortexdocs.dev'], + privacy_url: 'https://cortexdocs.dev/privacy#cookies-and-analytics', + }, home: { title: 'Petstore Docs', description: