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 +7 -15
- package/package.json +3 -3
- package/vendored/app/[[...slug]]/page.tsx +9 -3
- package/vendored/app/api/search-ev/route.ts +69 -0
- package/vendored/app/layout.tsx +70 -12
- package/vendored/components/search/SearchModal.tsx +7 -5
- package/vendored/lib/analytics-client.ts +17 -7
- package/vendored/lib/docs-types.ts +2 -2
- package/vendored/lib/extract-highlights.ts +2 -0
- package/vendored/lib/middleware-helpers.ts +1 -0
- package/vendored/lib/search-client.ts +81 -36
- package/vendored/lib/seo.ts +12 -0
- package/vendored/lib/static-file-route.ts +5 -2
- package/vendored/lib/validate-config.ts +1 -0
- package/vendored/schema/docs-schema.json +15 -3
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
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
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
|
-
-
|
|
239
|
-
-
|
|
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.
|
|
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": "
|
|
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:
|
|
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
|
+
}
|
package/vendored/app/layout.tsx
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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={
|
|
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,
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 = '
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
|
package/vendored/lib/seo.ts
CHANGED
|
@@ -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':
|
|
69
|
+
'Cache-Control': cacheControl,
|
|
67
70
|
},
|
|
68
71
|
});
|
|
69
72
|
} catch (error) {
|
|
@@ -1141,15 +1141,27 @@
|
|
|
1141
1141
|
},
|
|
1142
1142
|
"plausible": {
|
|
1143
1143
|
"type": "object",
|
|
1144
|
-
"description": "Plausible Analytics
|
|
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": {
|