jamdesk 1.0.19 → 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/dist/__tests__/unit/openapi.test.js +12 -0
- package/dist/__tests__/unit/openapi.test.js.map +1 -1
- package/dist/commands/openapi-check.d.ts.map +1 -1
- package/dist/commands/openapi-check.js +7 -13
- package/dist/commands/openapi-check.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/deps.js +1 -1
- package/dist/lib/openapi/errors.d.ts.map +1 -1
- package/dist/lib/openapi/errors.js +17 -0
- package/dist/lib/openapi/errors.js.map +1 -1
- package/dist/lib/openapi/validator.d.ts +2 -2
- package/dist/lib/openapi/validator.d.ts.map +1 -1
- package/dist/lib/openapi/validator.js +52 -15
- package/dist/lib/openapi/validator.js.map +1 -1
- package/package.json +2 -2
- package/vendored/app/[[...slug]]/page.tsx +105 -28
- package/vendored/app/api/ev/route.ts +4 -1
- package/vendored/app/api/indexnow/[key]/route.ts +34 -0
- package/vendored/app/api/search-ev/route.ts +69 -0
- package/vendored/app/layout.tsx +70 -12
- package/vendored/components/chat/ChatInput.tsx +1 -1
- package/vendored/components/chat/ChatPanel.tsx +60 -11
- package/vendored/components/mdx/Update.tsx +7 -7
- package/vendored/components/navigation/Sidebar.tsx +2 -10
- package/vendored/components/navigation/TableOfContents.tsx +48 -35
- package/vendored/components/search/SearchModal.tsx +9 -5
- package/vendored/components/ui/CodePanelModal.tsx +5 -14
- package/vendored/hooks/useBodyScrollLock.ts +37 -0
- package/vendored/lib/analytics-client.ts +17 -7
- package/vendored/lib/docs-types.ts +3 -2
- package/vendored/lib/email-templates/components/base-layout.tsx +15 -15
- package/vendored/lib/extract-highlights.ts +2 -0
- package/vendored/lib/heading-extractor.ts +2 -2
- package/vendored/lib/indexnow.ts +77 -0
- package/vendored/lib/json-ld.ts +171 -0
- package/vendored/lib/middleware-helpers.ts +2 -0
- package/vendored/lib/openapi/errors.ts +21 -1
- package/vendored/lib/openapi/validator.ts +70 -23
- package/vendored/lib/route-helpers.ts +7 -2
- package/vendored/lib/search-client.ts +81 -36
- package/vendored/lib/seo.ts +77 -0
- package/vendored/lib/static-artifacts.ts +204 -5
- package/vendored/lib/static-file-route.ts +10 -5
- package/vendored/lib/validate-config.ts +1 -0
- package/vendored/schema/docs-schema.json +130 -8
- package/vendored/scripts/validate-links.cjs +1 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IndexNow Key Verification Endpoint
|
|
3
|
+
*
|
|
4
|
+
* IndexNow fetches this URL to verify key ownership.
|
|
5
|
+
* Returns the key as plain text if it matches the project's generated key.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
9
|
+
import { headers } from 'next/headers';
|
|
10
|
+
import { isIsrMode, getProjectFromRequest } from '@/lib/page-isr-helpers';
|
|
11
|
+
import { generateIndexNowKey } from '@/lib/indexnow';
|
|
12
|
+
|
|
13
|
+
function notFound(): NextResponse {
|
|
14
|
+
return new NextResponse('Not found', { status: 404 });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function GET(
|
|
18
|
+
_request: NextRequest,
|
|
19
|
+
{ params }: { params: Promise<{ key: string }> }
|
|
20
|
+
): Promise<NextResponse> {
|
|
21
|
+
if (!isIsrMode()) return notFound();
|
|
22
|
+
|
|
23
|
+
const headersList = await headers();
|
|
24
|
+
const projectSlug = getProjectFromRequest(headersList);
|
|
25
|
+
if (!projectSlug) return notFound();
|
|
26
|
+
|
|
27
|
+
const { key } = await params;
|
|
28
|
+
const expectedKey = generateIndexNowKey(projectSlug);
|
|
29
|
+
if (key !== expectedKey) return notFound();
|
|
30
|
+
|
|
31
|
+
return new NextResponse(expectedKey, {
|
|
32
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -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 */}
|
|
@@ -71,7 +71,7 @@ export const ChatInput = memo(function ChatInput({ onSend, onAbort, isLoading, d
|
|
|
71
71
|
placeholder="Ask a question…"
|
|
72
72
|
disabled={disabled}
|
|
73
73
|
rows={1}
|
|
74
|
-
className="flex-1 resize-none bg-transparent text-
|
|
74
|
+
className="flex-1 resize-none bg-transparent text-base text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none py-2 min-h-[36px]"
|
|
75
75
|
aria-label="Chat message"
|
|
76
76
|
maxLength={MAX_LENGTH + 100} // Allow slight overshoot for UX, enforce on send
|
|
77
77
|
/>
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useRef, useEffect, useCallback } from 'react';
|
|
3
|
+
import { useRef, useEffect, useLayoutEffect, useCallback } from 'react';
|
|
4
4
|
import { useChat } from '@/hooks/useChat';
|
|
5
|
+
import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';
|
|
5
6
|
import { ChatMessage } from './ChatMessage';
|
|
6
7
|
import { ChatInput } from './ChatInput';
|
|
7
8
|
import { ChatEmptyState } from './ChatEmptyState';
|
|
8
9
|
|
|
9
10
|
const SOMETHING_ELSE_PATTERNS = ['something else', 'none of the above', 'none of these'];
|
|
10
11
|
|
|
12
|
+
/** If visualViewport shrinks below this fraction of innerHeight, assume the virtual keyboard is open. */
|
|
13
|
+
const KEYBOARD_VIEWPORT_THRESHOLD = 0.85;
|
|
14
|
+
|
|
11
15
|
/**
|
|
12
16
|
* Rewrite generic "something else" / "none of the above" options to a more
|
|
13
17
|
* descriptive message so Claude gets useful follow-up context.
|
|
@@ -37,16 +41,61 @@ interface ChatPanelProps {
|
|
|
37
41
|
*/
|
|
38
42
|
export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mode = 'overlay' }: ChatPanelProps) {
|
|
39
43
|
const { messages, sendMessage, isLoading, abort, retry, clearChat, error, markClarificationSelected } = useChat(chatEndpoint);
|
|
40
|
-
const
|
|
44
|
+
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
|
41
45
|
const inputRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
const overlayPanelRef = useRef<HTMLDivElement>(null);
|
|
47
|
+
|
|
48
|
+
const isInline = mode === 'inline';
|
|
42
49
|
|
|
43
|
-
//
|
|
44
|
-
|
|
50
|
+
// Lock body scroll when mobile overlay is open
|
|
51
|
+
useBodyScrollLock(!isInline && isOpen);
|
|
52
|
+
|
|
53
|
+
// On iOS, the virtual keyboard doesn't resize the layout viewport, so the
|
|
54
|
+
// panel's max-h-[80dvh] can extend behind the keyboard. When the user taps the
|
|
55
|
+
// textarea, iOS scrolls the page to reveal it — pushing the fixed panel off-screen.
|
|
56
|
+
// Fix: use the visualViewport API to shrink the panel above the keyboard, and
|
|
57
|
+
// snap window scroll back to 0 to prevent the page from shifting.
|
|
45
58
|
useEffect(() => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
if (isInline || !isOpen) return;
|
|
60
|
+
const vv = window.visualViewport;
|
|
61
|
+
const panel = overlayPanelRef.current;
|
|
62
|
+
if (!vv || !panel) return;
|
|
63
|
+
|
|
64
|
+
const update = () => {
|
|
65
|
+
const keyboardLikelyOpen = vv.height < window.innerHeight * KEYBOARD_VIEWPORT_THRESHOLD;
|
|
66
|
+
if (keyboardLikelyOpen) {
|
|
67
|
+
// 16px matches the top-4 offset on the panel's Tailwind class
|
|
68
|
+
panel.style.maxHeight = `${vv.height - 16}px`;
|
|
69
|
+
} else {
|
|
70
|
+
panel.style.maxHeight = '';
|
|
71
|
+
}
|
|
72
|
+
// iOS scrolls the page even with overflow:hidden to reveal focused inputs.
|
|
73
|
+
// Snap back instantly (override html { scroll-behavior: smooth } from base.css).
|
|
74
|
+
if (window.scrollY !== 0) {
|
|
75
|
+
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
vv.addEventListener('resize', update);
|
|
80
|
+
vv.addEventListener('scroll', update);
|
|
81
|
+
window.addEventListener('scroll', update);
|
|
82
|
+
|
|
83
|
+
return () => {
|
|
84
|
+
vv.removeEventListener('resize', update);
|
|
85
|
+
vv.removeEventListener('scroll', update);
|
|
86
|
+
window.removeEventListener('scroll', update);
|
|
87
|
+
panel.style.maxHeight = '';
|
|
88
|
+
};
|
|
89
|
+
}, [isOpen, isInline]);
|
|
90
|
+
|
|
91
|
+
// Auto-scroll to bottom when new messages arrive. Uses useLayoutEffect so
|
|
92
|
+
// scrollHeight is accurate (DOM is updated) and there's no visible 1-frame lag.
|
|
93
|
+
// Scrolls the container directly instead of scrollIntoView to avoid scrolling
|
|
94
|
+
// ancestor elements (which pushes the panel off-screen on mobile).
|
|
95
|
+
useLayoutEffect(() => {
|
|
96
|
+
const container = messagesContainerRef.current;
|
|
97
|
+
if (!container) return;
|
|
98
|
+
container.scrollTop = container.scrollHeight;
|
|
50
99
|
}, [messages]);
|
|
51
100
|
|
|
52
101
|
// Escape key handling is in useChatPanel (capture phase, with search-modal awareness)
|
|
@@ -69,7 +118,6 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
|
|
|
69
118
|
}, [markClarificationSelected, sendMessage]);
|
|
70
119
|
|
|
71
120
|
const hasMessages = messages.length > 0;
|
|
72
|
-
const isInline = mode === 'inline';
|
|
73
121
|
|
|
74
122
|
// Shared panel content (header, messages, error, input)
|
|
75
123
|
const panelContent = (
|
|
@@ -103,6 +151,7 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
|
|
|
103
151
|
|
|
104
152
|
{/* Messages area */}
|
|
105
153
|
<div
|
|
154
|
+
ref={messagesContainerRef}
|
|
106
155
|
className="flex-1 overflow-y-auto min-h-0"
|
|
107
156
|
style={{ overscrollBehavior: 'contain' }}
|
|
108
157
|
aria-live="polite"
|
|
@@ -117,7 +166,6 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
|
|
|
117
166
|
onSelectOption={handleSelectOption}
|
|
118
167
|
/>
|
|
119
168
|
))}
|
|
120
|
-
<div ref={messagesEndRef} />
|
|
121
169
|
</div>
|
|
122
170
|
) : (
|
|
123
171
|
<ChatEmptyState
|
|
@@ -180,6 +228,7 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
|
|
|
180
228
|
|
|
181
229
|
{/* Panel */}
|
|
182
230
|
<div
|
|
231
|
+
ref={overlayPanelRef}
|
|
183
232
|
role="dialog"
|
|
184
233
|
aria-label="AI Chat"
|
|
185
234
|
aria-hidden={!isOpen}
|
|
@@ -187,7 +236,7 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
|
|
|
187
236
|
data-open={isOpen}
|
|
188
237
|
data-chat-panel
|
|
189
238
|
className={`
|
|
190
|
-
fixed z-[55] flex flex-col
|
|
239
|
+
fixed z-[55] flex flex-col overflow-hidden
|
|
191
240
|
transition-all duration-200
|
|
192
241
|
inset-x-2 top-4 max-h-[80dvh] rounded-2xl shadow-xl border border-[var(--color-border)]
|
|
193
242
|
lg:inset-x-auto lg:top-0 lg:max-h-none lg:rounded-none lg:shadow-none lg:border-0
|
|
@@ -1,35 +1,35 @@
|
|
|
1
|
+
import { generateSlug } from '@/lib/heading-extractor';
|
|
2
|
+
|
|
1
3
|
interface UpdateProps {
|
|
2
4
|
label?: string;
|
|
3
5
|
description?: string;
|
|
4
6
|
tags?: string[];
|
|
7
|
+
date?: string; // ISO date for RSS pubDate (e.g., "2025-03-15")
|
|
5
8
|
children?: React.ReactNode;
|
|
6
9
|
}
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* Generates a URL-friendly slug from a label string.
|
|
10
13
|
* Used to create anchor IDs for Update components.
|
|
14
|
+
* Uses shared generateSlug to stay in sync with TOC and link validation.
|
|
11
15
|
*/
|
|
12
16
|
export function generateUpdateId(label?: string): string | undefined {
|
|
13
17
|
if (!label) return undefined;
|
|
14
|
-
|
|
15
|
-
.toLowerCase()
|
|
16
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
17
|
-
.replace(/^-+|-+$/g, '');
|
|
18
|
-
// Return undefined if the result is empty (e.g., label was only special characters)
|
|
19
|
-
return slug || undefined;
|
|
18
|
+
return generateSlug(label) || undefined;
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
/**
|
|
23
22
|
* Update component - for changelog/whatsnew entries.
|
|
24
23
|
* Creates timeline-style entries with automatic anchor links and TOC integration.
|
|
25
24
|
*/
|
|
26
|
-
export function Update({ label, description, tags, children }: UpdateProps) {
|
|
25
|
+
export function Update({ label, description, tags, date, children }: UpdateProps) {
|
|
27
26
|
const id = generateUpdateId(label);
|
|
28
27
|
|
|
29
28
|
return (
|
|
30
29
|
<div
|
|
31
30
|
id={id}
|
|
32
31
|
data-update-label={label}
|
|
32
|
+
data-update-date={date}
|
|
33
33
|
className="my-8 flex flex-col sm:flex-row gap-1 not-prose scroll-mt-20"
|
|
34
34
|
>
|
|
35
35
|
{/* Date badge - stacked on mobile, left side on desktop */}
|
|
@@ -4,6 +4,7 @@ import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import Image from 'next/image';
|
|
6
6
|
import { usePathname, useRouter } from 'next/navigation';
|
|
7
|
+
import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';
|
|
7
8
|
// Icons use Font Awesome CSS classes for lightweight rendering
|
|
8
9
|
import type {
|
|
9
10
|
DocsConfig,
|
|
@@ -291,16 +292,7 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
|
|
|
291
292
|
}, [activePathname]);
|
|
292
293
|
|
|
293
294
|
// Prevent body scroll when sidebar is open on mobile
|
|
294
|
-
|
|
295
|
-
if (isOpen) {
|
|
296
|
-
document.body.style.overflow = 'hidden';
|
|
297
|
-
} else {
|
|
298
|
-
document.body.style.overflow = '';
|
|
299
|
-
}
|
|
300
|
-
return () => {
|
|
301
|
-
document.body.style.overflow = '';
|
|
302
|
-
};
|
|
303
|
-
}, [isOpen]);
|
|
295
|
+
useBodyScrollLock(isOpen);
|
|
304
296
|
|
|
305
297
|
// Toggle group expansion; if expanding, navigate to first page
|
|
306
298
|
const handleGroupClick = useCallback((group: ResolvedGroup) => {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
4
4
|
import { getIconClass } from '@/lib/icon-utils';
|
|
5
|
+
import { generateSlug } from '@/lib/heading-extractor';
|
|
5
6
|
|
|
6
7
|
interface TocItem {
|
|
7
8
|
id: string;
|
|
@@ -195,54 +196,66 @@ export function TableOfContents({ content, className = '' }: TableOfContentsProp
|
|
|
195
196
|
return () => observer.disconnect();
|
|
196
197
|
}, [updateThumb]);
|
|
197
198
|
|
|
198
|
-
// Parse headings from content
|
|
199
|
+
// Parse headings from content in source order, then scan DOM for dynamic components
|
|
199
200
|
useEffect(() => {
|
|
201
|
+
// Line-by-line pass to preserve source order and skip fenced code blocks
|
|
200
202
|
const items: TocItem[] = [];
|
|
201
|
-
const
|
|
202
|
-
let
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
203
|
+
const lines = content.split('\n');
|
|
204
|
+
let inCodeBlock = false;
|
|
205
|
+
let fencePattern = '';
|
|
206
|
+
|
|
207
|
+
for (const line of lines) {
|
|
208
|
+
const fenceMatch = line.match(/^(`{3,}|~{3,})/);
|
|
209
|
+
if (fenceMatch) {
|
|
210
|
+
if (!inCodeBlock) {
|
|
211
|
+
inCodeBlock = true;
|
|
212
|
+
fencePattern = fenceMatch[1];
|
|
213
|
+
continue;
|
|
214
|
+
} else if (line.startsWith(fencePattern)) {
|
|
215
|
+
inCodeBlock = false;
|
|
216
|
+
fencePattern = '';
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (inCodeBlock) continue;
|
|
221
|
+
|
|
222
|
+
const headingMatch = line.match(/^(#{2,3})\s+(.+)$/);
|
|
223
|
+
if (headingMatch) {
|
|
224
|
+
const level = headingMatch[1].length;
|
|
225
|
+
const text = headingMatch[2].trim();
|
|
226
|
+
const id = generateSlug(text);
|
|
227
|
+
if (id) items.push({ id, text, level });
|
|
228
|
+
}
|
|
212
229
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
.
|
|
218
|
-
|
|
219
|
-
.replace(/^-+|-+$/g, '');
|
|
220
|
-
items.push({ id, text, level: 2 });
|
|
230
|
+
const updateMatch = line.match(/<Update\s+label=["']([^"']+)["']/);
|
|
231
|
+
if (updateMatch) {
|
|
232
|
+
const text = updateMatch[1];
|
|
233
|
+
const id = generateSlug(text);
|
|
234
|
+
if (id) items.push({ id, text, level: 2 });
|
|
235
|
+
}
|
|
221
236
|
}
|
|
222
237
|
|
|
223
238
|
setHeadings(items);
|
|
224
239
|
|
|
240
|
+
// DOM scan: single query to get all TOC elements in document order
|
|
225
241
|
const scanDomHeadings = () => {
|
|
226
|
-
const
|
|
242
|
+
const tocElements = document.querySelectorAll('main h2[id], main h3[id], main [data-update-label]');
|
|
227
243
|
const newItems: TocItem[] = [];
|
|
228
244
|
const seenDomIds = new Set<string>();
|
|
229
245
|
|
|
230
|
-
|
|
231
|
-
const id = heading.id;
|
|
232
|
-
const text = heading.textContent?.trim() || '';
|
|
233
|
-
if (!text || !id || seenDomIds.has(id)) return;
|
|
234
|
-
seenDomIds.add(id);
|
|
235
|
-
const level = heading.tagName === 'H2' ? 2 : 3;
|
|
236
|
-
newItems.push({ id, text, level });
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
const updateElements = document.querySelectorAll('main [data-update-label]');
|
|
240
|
-
updateElements.forEach((element) => {
|
|
246
|
+
tocElements.forEach((element) => {
|
|
241
247
|
const id = element.id;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
248
|
+
if (!id || seenDomIds.has(id)) return;
|
|
249
|
+
|
|
250
|
+
const isUpdate = element.hasAttribute('data-update-label');
|
|
251
|
+
const text = isUpdate
|
|
252
|
+
? (element.getAttribute('data-update-label') || '')
|
|
253
|
+
: (element.textContent?.trim() || '');
|
|
254
|
+
if (!text) return;
|
|
255
|
+
|
|
245
256
|
seenDomIds.add(id);
|
|
257
|
+
const level = isUpdate ? 2 : (element.tagName === 'H2' ? 2 : 3);
|
|
258
|
+
newItems.push({ id, text, level });
|
|
246
259
|
});
|
|
247
260
|
|
|
248
261
|
if (newItems.length > 0) {
|
|
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef, type ReactElement } from 'react';
|
|
|
4
4
|
import { useRouter } from 'next/navigation';
|
|
5
5
|
import { getRecentSearches, addRecentSearch, clearRecentSearches } from '@/lib/recent-searches';
|
|
6
6
|
import { useFocusTrap } from '@/hooks/useFocusTrap';
|
|
7
|
+
import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';
|
|
7
8
|
import { getSuggestions } from '@/lib/search-suggestions';
|
|
8
9
|
import { trackSearch } from '@/lib/analytics-client';
|
|
9
10
|
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
@@ -123,6 +124,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
123
124
|
|
|
124
125
|
// Focus trap for accessibility
|
|
125
126
|
useFocusTrap(modalRef, isOpen);
|
|
127
|
+
useBodyScrollLock(isOpen);
|
|
126
128
|
|
|
127
129
|
// Close on Escape
|
|
128
130
|
useEffect(() => {
|
|
@@ -165,7 +167,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
165
167
|
|
|
166
168
|
const init = async () => {
|
|
167
169
|
try {
|
|
168
|
-
const [{ initializeSearch,
|
|
170
|
+
const [{ initializeSearch, getLastData }, response] = await Promise.all([
|
|
169
171
|
import('@/lib/search-client'),
|
|
170
172
|
fetch(`${linkPrefix}/search-data.json`),
|
|
171
173
|
]);
|
|
@@ -174,10 +176,12 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
174
176
|
throw new Error(`Failed to fetch search data: ${response.status}`);
|
|
175
177
|
}
|
|
176
178
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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);
|
|
181
185
|
} catch (error) {
|
|
182
186
|
console.error('Failed to initialize search:', error);
|
|
183
187
|
setInitError('Search is temporarily unavailable');
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
5
|
import { codePanelColors, extractTextContent, getStatusColor, type CodePanelTab } from './CodePanel';
|
|
6
|
+
import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';
|
|
6
7
|
|
|
7
8
|
interface CodePanelModalProps {
|
|
8
9
|
isOpen: boolean;
|
|
@@ -55,31 +56,21 @@ export function CodePanelModal({ isOpen, onClose, tabs, title, initialTabIndex =
|
|
|
55
56
|
}
|
|
56
57
|
}, [isOpen, initialTabIndex]);
|
|
57
58
|
|
|
58
|
-
//
|
|
59
|
+
// Lock body scroll when modal is open
|
|
60
|
+
useBodyScrollLock(isOpen);
|
|
61
|
+
|
|
62
|
+
// Focus management
|
|
59
63
|
useEffect(() => {
|
|
60
64
|
if (!isOpen) return;
|
|
61
65
|
|
|
62
|
-
// Save current focus
|
|
63
66
|
previousFocusRef.current = document.activeElement as HTMLElement;
|
|
64
67
|
|
|
65
|
-
// Lock body scroll
|
|
66
|
-
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
|
67
|
-
document.body.style.overflow = 'hidden';
|
|
68
|
-
if (scrollbarWidth > 0) {
|
|
69
|
-
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Focus close button
|
|
73
68
|
setTimeout(() => {
|
|
74
69
|
const closeBtn = modalRef.current?.querySelector<HTMLButtonElement>('[aria-label="Close modal"]');
|
|
75
70
|
closeBtn?.focus();
|
|
76
71
|
}, 0);
|
|
77
72
|
|
|
78
73
|
return () => {
|
|
79
|
-
// Restore scroll
|
|
80
|
-
document.body.style.overflow = '';
|
|
81
|
-
document.body.style.paddingRight = '';
|
|
82
|
-
// Restore focus
|
|
83
74
|
previousFocusRef.current?.focus();
|
|
84
75
|
};
|
|
85
76
|
}, [isOpen]);
|