jamdesk 1.0.20 → 1.0.22

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/README.md CHANGED
@@ -15,10 +15,10 @@ Jamdesk is a docs-as-code platform. Connect a GitHub repo, write in MDX, and you
15
15
  - **Dev server** — Turbopack-powered with hot reload on every save
16
16
  - **50+ MDX components** — accordions, tabs, code groups, callouts, [and more](https://www.jamdesk.com/docs/components/overview)
17
17
  - **Three themes** — Jam, Nebula, Pulsar. Configured in `docs.json`
18
- - **OpenAPI** — auto-generate API reference pages from your specs
19
- - **Full-text search** works locally and in production, with AI search on hosted sites
20
- - **Validation** broken links, MDX syntax errors, config issues. Catch them before deploy
21
- - **Mintlify migration** one command to convert your existing docs
18
+ - Auto-generate API reference pages from OpenAPI specs
19
+ - Full-text search works locally and in production (AI search on hosted sites)
20
+ - Catches broken links, MDX syntax errors, and config issues before you deploy
21
+ - Migrate from Mintlify in one command
22
22
 
23
23
  ## Quick Start
24
24
 
@@ -120,13 +120,7 @@ jamdesk dev --port 3001 # Custom port
120
120
 
121
121
  The dev server auto-validates on startup, auto-recovers from corrupted Turbopack cache, and auto-increments the port if yours is taken. Full search, all themes, and all components work locally.
122
122
 
123
- Set a default port in `~/.jamdeskrc`:
124
-
125
- ```json
126
- {
127
- "defaultPort": 3001
128
- }
129
- ```
123
+ Set a default port in [`~/.jamdeskrc`](#cli-defaults).
130
124
 
131
125
  ## Authentication
132
126
 
@@ -235,8 +229,8 @@ Detects your `mint.json`, converts config to `docs.json`, lets you pick a theme,
235
229
 
236
230
  - **Config** — `mint.json` → `docs.json` (navbar, navigation, footer, SEO, appearance)
237
231
  - **Components** — deprecated components like `<CardGroup>` → `<Columns>`
238
- - **React hooks** — inline components with `useState`/`useEffect` get extracted to `/snippets` as `.tsx` files with `'use client'`
239
- - **Video embeds** iframe normalization
232
+ - Inline components with `useState`/`useEffect` get extracted to `/snippets` as `'use client'` `.tsx` files
233
+ - iframe video embeds are normalized
240
234
 
241
235
  | Option | Description |
242
236
  |--------|-------------|
@@ -396,8 +390,6 @@ See the [docs.json reference](https://www.jamdesk.com/docs/config/docs-json-refe
396
390
  - [Homepage](https://www.jamdesk.com)
397
391
  - [Pricing](https://www.jamdesk.com/pricing)
398
392
 
399
- **Example:** [jamdesk.com/docs](https://www.jamdesk.com/docs)
400
-
401
393
  ## Support
402
394
 
403
395
  - [Report Issues](https://github.com/jamdesk/jamdesk-cli/issues)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
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",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "repository": {
41
41
  "type": "git",
42
- "url": "https://github.com/jamdesk/jamdesk.git",
42
+ "url": "git+https://github.com/jamdesk/jamdesk.git",
43
43
  "directory": "builder/cli"
44
44
  },
45
45
  "license": "Apache-2.0",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "type": "module",
52
52
  "bin": {
53
- "jamdesk": "./bin/jamdesk.js"
53
+ "jamdesk": "bin/jamdesk.js"
54
54
  },
55
55
  "main": "./dist/index.js",
56
56
  "types": "./dist/index.d.ts",
@@ -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": {