jamdesk 1.0.20 → 1.0.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
4
4
  "description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
5
5
  "keywords": [
6
6
  "jamdesk",
@@ -47,7 +47,7 @@ import fs from 'fs';
47
47
  import path from 'path';
48
48
  import matter from 'gray-matter';
49
49
  import { getDocsConfig, getContentDir } from '@/lib/docs';
50
- import { buildSeoMetadata, generateAutoDescription } from '@/lib/seo';
50
+ import { buildSeoMetadata, generateAutoDescription, buildSiteTitle } from '@/lib/seo';
51
51
  import { buildJsonLd } from '@/lib/json-ld';
52
52
  import {
53
53
  getContentLoader,
@@ -339,7 +339,7 @@ export async function generateMetadata({ params }: PageProps) {
339
339
  if (normalizedSlug.length === 0) {
340
340
  const config = await loader.getConfig();
341
341
  return {
342
- title: `${config.name} - Redirecting...`,
342
+ title: { absolute: `${config.name} - Redirecting...` },
343
343
  robots: { index: false }, // Don't index redirect page
344
344
  };
345
345
  }
@@ -372,8 +372,14 @@ export async function generateMetadata({ params }: PageProps) {
372
372
 
373
373
  const seoMetadata = buildSeoMetadata(config, data, pagePath, baseUrl, languages);
374
374
 
375
+ // If page title matches config.name, use absolute to prevent "X — X" double-wrap.
376
+ // If no title, use buildSiteTitle (which avoids "X Documentation Documentation").
377
+ const titleValue = data.title
378
+ ? (data.title === config.name ? { absolute: data.title } : data.title)
379
+ : { absolute: buildSiteTitle(config.name) };
380
+
375
381
  return {
376
- title: data.title || `${config.name} Documentation`,
382
+ title: titleValue,
377
383
  description: data.description || '',
378
384
  ...seoMetadata,
379
385
  ...(data.rss ? {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Search Analytics Event Proxy
3
+ * Proxies to Firebase Cloud Functions via first-party domain to avoid ad blockers
4
+ * and CORS issues. Mirrors /api/ev but targets the search analytics function.
5
+ */
6
+
7
+ import { NextRequest, NextResponse } from 'next/server';
8
+
9
+ const ANALYTICS_ENDPOINT = 'https://us-central1-jamdesk-prod.cloudfunctions.net/trackSearchAnalytics';
10
+ const TIMEOUT_MS = 5000;
11
+
12
+ export const runtime = 'edge';
13
+
14
+ export async function POST(request: NextRequest) {
15
+ try {
16
+ const body = await request.json();
17
+
18
+ // Forward geo headers from Vercel + User-Agent for bot detection
19
+ const headers: HeadersInit = {
20
+ 'Content-Type': 'application/json',
21
+ 'X-Analytics-Secret': process.env.ANALYTICS_SECRET || '',
22
+ };
23
+ const userAgent = request.headers.get('user-agent');
24
+ if (userAgent) {
25
+ headers['User-Agent'] = userAgent;
26
+ }
27
+ const forwardHeaders = ['x-vercel-ip-country', 'x-vercel-ip-city', 'x-forwarded-for'];
28
+ for (const h of forwardHeaders) {
29
+ const val = request.headers.get(h);
30
+ if (val) headers[h] = val;
31
+ }
32
+
33
+ // Timeout protection - don't let slow Firebase responses hang the request
34
+ const controller = new AbortController();
35
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
36
+
37
+ const response = await fetch(ANALYTICS_ENDPOINT, {
38
+ method: 'POST',
39
+ headers,
40
+ body: JSON.stringify(body),
41
+ signal: controller.signal,
42
+ });
43
+ clearTimeout(timeout);
44
+
45
+ // Handle non-JSON responses (e.g., Firebase error pages)
46
+ const text = await response.text();
47
+ try {
48
+ const data = JSON.parse(text);
49
+ return NextResponse.json(data, { status: response.status });
50
+ } catch {
51
+ console.error('[Search Analytics Proxy] Non-JSON response:', text.slice(0, 200));
52
+ return NextResponse.json({ success: true, proxied: false });
53
+ }
54
+ } catch (error) {
55
+ console.error('[Search Analytics Proxy] Error:', error);
56
+ return NextResponse.json({ success: true, proxied: false });
57
+ }
58
+ }
59
+
60
+ export async function OPTIONS() {
61
+ return new NextResponse(null, {
62
+ status: 204,
63
+ headers: {
64
+ 'Access-Control-Allow-Origin': '*',
65
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
66
+ 'Access-Control-Allow-Headers': 'Content-Type',
67
+ },
68
+ });
69
+ }
@@ -17,6 +17,7 @@ import type { DocsConfig, Favicon, FontConfig } from '@/lib/docs-types';
17
17
  import { ASSET_PREFIX, transformConfigImagePath } from '@/lib/docs-types';
18
18
  import { LinkPrefixProvider } from '@/lib/link-prefix-context';
19
19
  import { getAnalyticsScript } from '@/lib/analytics-script';
20
+ import { buildSiteTitle } from '@/lib/seo';
20
21
 
21
22
  // Pre-load fonts - Next.js will tree-shake unused ones
22
23
  const inter = Inter({
@@ -47,6 +48,14 @@ function getFaviconPath(favicon: Favicon | undefined, assetVersion?: string): st
47
48
  return transformConfigImagePath(favicon.light, assetVersion) || DEFAULT_FAVICON;
48
49
  }
49
50
 
51
+ const FALLBACK_METADATA: Metadata = {
52
+ title: {
53
+ template: '%s — Documentation',
54
+ default: 'Documentation',
55
+ },
56
+ description: 'Documentation',
57
+ };
58
+
50
59
  export async function generateMetadata(): Promise<Metadata> {
51
60
  // Get config - from R2 in ISR mode, from filesystem in static mode
52
61
  let config: DocsConfig;
@@ -58,17 +67,10 @@ export async function generateMetadata(): Promise<Metadata> {
58
67
  try {
59
68
  config = await getIsrDocsConfig(projectSlug);
60
69
  } catch {
61
- // Project not found - use minimal fallback
62
- return {
63
- title: 'Documentation',
64
- description: 'Documentation',
65
- };
70
+ return FALLBACK_METADATA;
66
71
  }
67
72
  } else {
68
- return {
69
- title: 'Documentation',
70
- description: 'Documentation',
71
- };
73
+ return FALLBACK_METADATA;
72
74
  }
73
75
  } else {
74
76
  config = getDocsConfig();
@@ -76,7 +78,10 @@ export async function generateMetadata(): Promise<Metadata> {
76
78
 
77
79
  const faviconPath = getFaviconPath(config.favicon, config.assetVersion);
78
80
  return {
79
- title: `${config.name} Documentation`,
81
+ title: {
82
+ template: `%s — ${config.name}`,
83
+ default: buildSiteTitle(config.name),
84
+ },
80
85
  description: config.description || `Documentation for ${config.name}`,
81
86
  icons: {
82
87
  icon: faviconPath,
@@ -217,6 +222,43 @@ async function ConditionalGA({ gaId }: { gaId: string }) {
217
222
  }
218
223
  }
219
224
 
225
+ // Render Plausible Analytics — supports standard (data-domain) and paid proxy (scriptUrl) modes
226
+ function PlausibleScript({
227
+ domain,
228
+ server,
229
+ scriptUrl,
230
+ }: {
231
+ domain?: string;
232
+ server?: string;
233
+ scriptUrl?: string;
234
+ }): React.ReactElement {
235
+ // Paid proxy script mode (pa-XXXXX.js) — Plausible's CDN handles routing internally,
236
+ // no endpoint or data-domain needed. scriptUrl takes precedence over domain/server.
237
+ if (scriptUrl) {
238
+ return (
239
+ <>
240
+ <script async src={scriptUrl} />
241
+ <script dangerouslySetInnerHTML={{
242
+ __html: 'window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init()',
243
+ }} />
244
+ </>
245
+ );
246
+ }
247
+
248
+ // Standard mode — data-domain with optional self-hosted server
249
+ const baseServer = server || 'https://plausible.io';
250
+ const plausibleServer = baseServer.replace(/\/+$/, '');
251
+ const scriptProps: Record<string, unknown> = {
252
+ defer: true,
253
+ 'data-domain': domain,
254
+ src: `${plausibleServer}/js/script.js`,
255
+ };
256
+ if (server) {
257
+ scriptProps['data-api'] = `${plausibleServer}/api/event`;
258
+ }
259
+ return <script {...scriptProps} />;
260
+ }
261
+
220
262
  export default async function RootLayout({
221
263
  children,
222
264
  }: {
@@ -324,8 +366,16 @@ export default async function RootLayout({
324
366
  {config.integrations?.posthog && (
325
367
  <link rel="dns-prefetch" href={config.integrations.posthog.apiHost || "https://app.posthog.com"} />
326
368
  )}
327
- {config.integrations?.plausible && (
328
- <link rel="dns-prefetch" href={config.integrations.plausible.server || "https://plausible.io"} />
369
+ {(config.integrations?.plausible?.domain || config.integrations?.plausible?.scriptUrl) && (
370
+ <link rel="dns-prefetch" href={(() => {
371
+ try {
372
+ return config.integrations!.plausible!.scriptUrl
373
+ ? new URL(config.integrations!.plausible!.scriptUrl).origin
374
+ : config.integrations!.plausible!.server || "https://plausible.io";
375
+ } catch {
376
+ return config.integrations!.plausible!.server || "https://plausible.io";
377
+ }
378
+ })()} />
329
379
  )}
330
380
  {config.integrations?.intercom && (
331
381
  <link rel="dns-prefetch" href="https://widget.intercom.io" />
@@ -427,6 +477,14 @@ export default async function RootLayout({
427
477
  {analyticsScript && (
428
478
  <script dangerouslySetInnerHTML={{ __html: analyticsScript }} />
429
479
  )}
480
+ {/* Plausible Analytics */}
481
+ {(config.integrations?.plausible?.domain || config.integrations?.plausible?.scriptUrl) && (
482
+ <PlausibleScript
483
+ domain={config.integrations.plausible.domain}
484
+ server={config.integrations.plausible.server}
485
+ scriptUrl={config.integrations.plausible.scriptUrl}
486
+ />
487
+ )}
430
488
  </head>
431
489
  <body className={fontClassName} data-theme={themeName || 'jam'}>
432
490
  {/* Google Tag Manager */}
@@ -167,7 +167,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
167
167
 
168
168
  const init = async () => {
169
169
  try {
170
- const [{ initializeSearch, isInitialized }, response] = await Promise.all([
170
+ const [{ initializeSearch, getLastData }, response] = await Promise.all([
171
171
  import('@/lib/search-client'),
172
172
  fetch(`${linkPrefix}/search-data.json`),
173
173
  ]);
@@ -176,10 +176,12 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
176
176
  throw new Error(`Failed to fetch search data: ${response.status}`);
177
177
  }
178
178
 
179
- if (!isInitialized()) {
180
- const data = await response.json();
181
- await initializeSearch(data);
182
- }
179
+ // If the ETag matches we already have the current data in memory —
180
+ // pass it back to initializeSearch so the fingerprint check short-circuits.
181
+ const etag = response.headers.get('etag') ?? '';
182
+ const lastData = getLastData(etag);
183
+ const data = lastData ?? await response.json();
184
+ await initializeSearch(data, etag);
183
185
  } catch (error) {
184
186
  console.error('Failed to initialize search:', error);
185
187
  setInitError('Search is temporarily unavailable');
@@ -2,17 +2,28 @@
2
2
  // Tracks search queries and clicks for analytics dashboard
3
3
 
4
4
  // Firebase Function URL for search analytics
5
- const ANALYTICS_URL = 'https://us-central1-jamdesk-prod.cloudfunctions.net/trackSearchAnalytics';
5
+ const ANALYTICS_URL = '/api/search-ev';
6
+
7
+ // Reuse the same session ID as the pageview tracking script (localStorage with 30-min inactivity timeout).
8
+ // Every call refreshes the timestamp, so active users keep the same session.
9
+ const SESSION_TIMEOUT_MS = 1800000; // 30 minutes
6
10
 
7
- // Session ID persists for the browser session
8
11
  function getSessionId(): string {
9
12
  if (typeof window === 'undefined') return 'ssr';
10
13
 
11
- let sessionId = sessionStorage.getItem('jd_session_id');
12
- if (!sessionId) {
13
- sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
14
- sessionStorage.setItem('jd_session_id', sessionId);
14
+ const now = Date.now();
15
+ let sessionId = localStorage.getItem('jamdesk_session_id');
16
+ const sessionTs = localStorage.getItem('jamdesk_session_ts');
17
+
18
+ if (!sessionId || !sessionTs || (now - parseInt(sessionTs)) >= SESSION_TIMEOUT_MS) {
19
+ // Match the tracking script's format: 16 random bytes as hex + timestamp in base36
20
+ const bytes = new Uint8Array(16);
21
+ crypto.getRandomValues(bytes);
22
+ sessionId = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') +
23
+ now.toString(36);
24
+ localStorage.setItem('jamdesk_session_id', sessionId);
15
25
  }
26
+ localStorage.setItem('jamdesk_session_ts', now.toString());
16
27
  return sessionId;
17
28
  }
18
29
 
@@ -67,7 +78,6 @@ export function trackSearch(event: SearchEvent): void {
67
78
  headers: { 'Content-Type': 'application/json' },
68
79
  body: payload,
69
80
  keepalive: true,
70
- mode: 'cors',
71
81
  }).catch(() => {
72
82
  // Silent fail - analytics should never break the app
73
83
  });
@@ -595,7 +595,7 @@ export interface IntegrationsConfig {
595
595
  osano?: { scriptSource: string };
596
596
  pirsch?: { id: string };
597
597
  posthog?: { apiKey: string; apiHost?: string };
598
- plausible?: { domain: string; server?: string };
598
+ plausible?: { domain?: string; server?: string; scriptUrl?: string };
599
599
  segment?: { key: string };
600
600
  telemetry?: { enabled: boolean };
601
601
  cookies?: { key?: string; value?: string };
@@ -622,7 +622,7 @@ export interface SearchConfig {
622
622
  * AI Chat configuration
623
623
  */
624
624
  export interface ChatConfig {
625
- /** Enable AI chat assistant (default: false) */
625
+ /** Enable AI chat assistant (default: true) */
626
626
  enabled?: boolean;
627
627
  /** Starter questions shown in empty state (max 4). Auto-generated by Haiku during builds when omitted. Set to [] to disable. */
628
628
  starterQuestions?: string[];
@@ -11,6 +11,7 @@ export interface ExtractedHighlights {
11
11
  seoIndexable: boolean;
12
12
  analyticsIntegrations: string[];
13
13
  apiPlaygroundType: 'interactive' | 'simple' | 'hidden' | null;
14
+ chatEnabled: boolean;
14
15
  languageCount: number;
15
16
  redirectCount: number;
16
17
  pageCount: number;
@@ -115,6 +116,7 @@ export function extractConfigHighlights(config: DocsConfig, pageCount: number =
115
116
  seoIndexable,
116
117
  analyticsIntegrations,
117
118
  apiPlaygroundType,
119
+ chatEnabled: config.chat?.enabled !== false,
118
120
  languageCount: countLanguages(config),
119
121
  redirectCount: config.redirects?.length ?? 0,
120
122
  pageCount,
@@ -293,6 +293,7 @@ export const INTERNAL_API_ROUTES = [
293
293
  '/api/og', // OG image generation (app/api/og)
294
294
  '/api/r2', // R2 content serving (app/api/r2/[project]/[...path])
295
295
  '/api/revalidate', // Cache revalidation (app/api/revalidate)
296
+ '/api/search-ev', // Search analytics proxy (app/api/search-ev)
296
297
  ];
297
298
 
298
299
  /**
@@ -11,8 +11,7 @@ export interface SearchResult {
11
11
  type?: 'api' | 'component' | 'guide' | 'help' | 'quickstart';
12
12
  }
13
13
 
14
- // Orama database instance
15
- let db: Orama<{
14
+ type OramaDb = Orama<{
16
15
  id: 'string';
17
16
  title: 'string';
18
17
  description: 'string';
@@ -20,47 +19,91 @@ let db: Orama<{
20
19
  slug: 'string';
21
20
  section: 'string';
22
21
  type: 'string';
23
- }> | null = null;
22
+ }>;
24
23
 
24
+ // Orama database instance
25
+ let db: OramaDb | null = null;
26
+ // Fingerprint of the data that is currently indexed (committed) or null if not yet built
27
+ let committedFingerprint: string | null = null;
28
+ // Fingerprint of the data currently being built (in-flight), to deduplicate concurrent calls
29
+ let buildingFingerprint: string | null = null;
25
30
  let initPromise: Promise<void> | null = null;
31
+ // ETag of the last fetched search-data.json and the parsed data it produced.
32
+ // Lets the modal skip response.json() when the CDN returns the same ETag.
33
+ let lastEtag = '';
34
+ let lastParsedData: SearchResult[] | null = null;
35
+
36
+ /**
37
+ * Cheap fingerprint: count + first/last IDs + a sample of content lengths.
38
+ * Detects new/removed pages AND content edits (which change content length).
39
+ */
40
+ function fingerprint(data: SearchResult[]): string {
41
+ if (data.length === 0) return '0';
42
+ const first = data[0].id;
43
+ const last = data[data.length - 1].id;
44
+ const step = Math.max(1, Math.floor(data.length / 8));
45
+ let contentSig = '';
46
+ // Sample up to 8 evenly-spaced items' content lengths to detect edits
47
+ for (let i = 0; i < data.length; i += step) {
48
+ contentSig += data[i].content.length + ',';
49
+ }
50
+ return `${data.length}:${first}:${last}:${contentSig}`;
51
+ }
52
+
53
+ async function buildIndex(data: SearchResult[], etag: string): Promise<void> {
54
+ db = await create({
55
+ schema: {
56
+ id: 'string',
57
+ title: 'string',
58
+ description: 'string',
59
+ content: 'string',
60
+ slug: 'string',
61
+ section: 'string',
62
+ type: 'string',
63
+ },
64
+ });
26
65
 
27
- export async function initializeSearch(data: SearchResult[]): Promise<void> {
28
- // Only initialize once
29
- if (db) return;
30
-
31
- // Return existing promise if already initializing
32
- if (initPromise) return initPromise;
33
-
34
- initPromise = (async () => {
35
- db = await create({
36
- schema: {
37
- id: 'string',
38
- title: 'string',
39
- description: 'string',
40
- content: 'string',
41
- slug: 'string',
42
- section: 'string',
43
- type: 'string',
44
- },
45
- });
46
-
47
- // Normalize data - ensure all fields are strings
48
- const normalizedData = data.map(item => ({
49
- id: item.id,
50
- title: item.title,
51
- description: item.description || '',
52
- content: item.content,
53
- slug: item.slug,
54
- section: item.section || '',
55
- type: item.type || 'guide',
56
- }));
57
-
58
- await insertMultiple(db, normalizedData);
59
- })();
66
+ const normalizedData = data.map(item => ({
67
+ id: item.id,
68
+ title: item.title,
69
+ description: item.description || '',
70
+ content: item.content,
71
+ slug: item.slug,
72
+ section: item.section || '',
73
+ type: item.type || 'guide',
74
+ }));
60
75
 
76
+ await insertMultiple(db, normalizedData);
77
+ committedFingerprint = buildingFingerprint;
78
+ lastParsedData = data;
79
+ lastEtag = etag;
80
+ }
81
+
82
+ export async function initializeSearch(data: SearchResult[], etag = ''): Promise<void> {
83
+ const fp = fingerprint(data);
84
+
85
+ // Skip rebuild if the committed index already matches
86
+ if (fp === committedFingerprint) return;
87
+
88
+ // If a build with this exact data is already in flight, wait on it
89
+ if (initPromise && fp === buildingFingerprint) return initPromise;
90
+
91
+ // New data available (or first init) — rebuild the index
92
+ buildingFingerprint = fp;
93
+ initPromise = buildIndex(data, etag);
61
94
  return initPromise;
62
95
  }
63
96
 
97
+ /**
98
+ * Returns the previously parsed search data if the given ETag matches the
99
+ * last successful fetch, so the modal can skip response.json() on unchanged data.
100
+ * Returns null when the ETag is empty, unknown, or has changed.
101
+ */
102
+ export function getLastData(etag: string | null): SearchResult[] | null {
103
+ if (!etag || etag !== lastEtag || !lastParsedData) return null;
104
+ return lastParsedData;
105
+ }
106
+
64
107
  export async function search(query: string, limit = 10): Promise<SearchResult[]> {
65
108
  if (!db) {
66
109
  console.warn('Search database not initialized');
@@ -86,6 +129,8 @@ export async function search(query: string, limit = 10): Promise<SearchResult[]>
86
129
  return results.hits.map(hit => hit.document as unknown as SearchResult);
87
130
  }
88
131
 
132
+ /** @internal Used by tests only */
89
133
  export function isInitialized(): boolean {
90
134
  return db !== null;
91
135
  }
136
+
@@ -11,6 +11,18 @@ import type { Metadata } from 'next';
11
11
  import type { DocsConfig, Logo, LogoConfig, Favicon, FaviconConfig, LanguageConfig, LanguageCode } from './docs-types';
12
12
  import { transformLanguagePath, extractLanguageFromPath, isValidLanguageCode } from './language-utils';
13
13
 
14
+ const HAS_DOCS_SUFFIX = /\b(?:Documentation|Docs)\s*$/i;
15
+
16
+ /**
17
+ * Build a display title for the site, appending "Documentation" only when
18
+ * config.name doesn't already end with a docs-related word.
19
+ */
20
+ export function buildSiteTitle(configName: string): string {
21
+ return HAS_DOCS_SUFFIX.test(configName)
22
+ ? configName
23
+ : `${configName} Documentation`;
24
+ }
25
+
14
26
  /**
15
27
  * Build the OG image URL for a page using the proxy's /api/og endpoint.
16
28
  * Returns undefined if og:image is explicitly set in metatags.
@@ -32,13 +32,16 @@ function getContentType(filename: string): string {
32
32
  * @param filename - The R2 filename (e.g., 'sitemap.xml', 'search-data.json')
33
33
  * @param label - Human-readable label for log messages (e.g., 'Sitemap', 'Search data')
34
34
  * @param contentTypeOverride - Override the inferred content type (e.g., 'application/rss+xml')
35
+ * @param cacheControlOverride - Override the default Cache-Control header
35
36
  */
36
37
  export function createStaticFileHandler(
37
38
  filename: string,
38
39
  label: string,
39
- contentTypeOverride?: string
40
+ contentTypeOverride?: string,
41
+ cacheControlOverride?: string
40
42
  ): (request: NextRequest) => Promise<NextResponse> {
41
43
  const contentType = contentTypeOverride || getContentType(filename);
44
+ const cacheControl = cacheControlOverride || 'public, max-age=3600, s-maxage=86400';
42
45
 
43
46
  return async function GET(request: NextRequest): Promise<NextResponse> {
44
47
  if (!isIsrMode()) {
@@ -63,7 +66,7 @@ export function createStaticFileHandler(
63
66
  return new NextResponse(content, {
64
67
  headers: {
65
68
  'Content-Type': contentType,
66
- 'Cache-Control': 'public, max-age=3600, s-maxage=86400',
69
+ 'Cache-Control': cacheControl,
67
70
  },
68
71
  });
69
72
  } catch (error) {
@@ -31,6 +31,7 @@ export interface DocsConfig {
31
31
  export interface IntegrationsConfig {
32
32
  ga4?: { measurementId: string };
33
33
  gtm?: { tagId: string };
34
+ plausible?: { domain?: string; server?: string; scriptUrl?: string };
34
35
  [key: string]: unknown;
35
36
  }
36
37
 
@@ -1141,15 +1141,27 @@
1141
1141
  },
1142
1142
  "plausible": {
1143
1143
  "type": "object",
1144
- "description": "Plausible Analytics integration. Include empty object {} to indicate placeholder.",
1144
+ "description": "Plausible Analytics. Use domain for standard setup (free/paid). Use scriptUrl for paid proxy scripts that bypass ad blockers (pa-XXXXX.js URLs from Plausible dashboard). Only set one.",
1145
1145
  "properties": {
1146
1146
  "domain": {
1147
- "type": "string"
1147
+ "type": "string",
1148
+ "description": "Standard setup: your site domain registered in Plausible (e.g., docs.example.com). Used as the data-domain attribute."
1148
1149
  },
1149
1150
  "server": {
1150
- "type": "string"
1151
+ "type": "string",
1152
+ "format": "uri",
1153
+ "description": "Self-hosted only: URL of your Plausible server. Only used with domain, not scriptUrl."
1154
+ },
1155
+ "scriptUrl": {
1156
+ "type": "string",
1157
+ "format": "uri",
1158
+ "description": "Proxy setup: full URL to your Plausible paid proxy script (e.g., https://plausible.io/js/pa-XXXXX.js). Site identity is embedded in the script — no domain needed."
1151
1159
  }
1152
1160
  },
1161
+ "anyOf": [
1162
+ { "required": ["domain"] },
1163
+ { "required": ["scriptUrl"] }
1164
+ ],
1153
1165
  "additionalProperties": false
1154
1166
  },
1155
1167
  "segment": {