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.
Files changed (47) hide show
  1. package/dist/__tests__/unit/openapi.test.js +12 -0
  2. package/dist/__tests__/unit/openapi.test.js.map +1 -1
  3. package/dist/commands/openapi-check.d.ts.map +1 -1
  4. package/dist/commands/openapi-check.js +7 -13
  5. package/dist/commands/openapi-check.js.map +1 -1
  6. package/dist/index.js +2 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/deps.js +1 -1
  9. package/dist/lib/openapi/errors.d.ts.map +1 -1
  10. package/dist/lib/openapi/errors.js +17 -0
  11. package/dist/lib/openapi/errors.js.map +1 -1
  12. package/dist/lib/openapi/validator.d.ts +2 -2
  13. package/dist/lib/openapi/validator.d.ts.map +1 -1
  14. package/dist/lib/openapi/validator.js +52 -15
  15. package/dist/lib/openapi/validator.js.map +1 -1
  16. package/package.json +2 -2
  17. package/vendored/app/[[...slug]]/page.tsx +105 -28
  18. package/vendored/app/api/ev/route.ts +4 -1
  19. package/vendored/app/api/indexnow/[key]/route.ts +34 -0
  20. package/vendored/app/api/search-ev/route.ts +69 -0
  21. package/vendored/app/layout.tsx +70 -12
  22. package/vendored/components/chat/ChatInput.tsx +1 -1
  23. package/vendored/components/chat/ChatPanel.tsx +60 -11
  24. package/vendored/components/mdx/Update.tsx +7 -7
  25. package/vendored/components/navigation/Sidebar.tsx +2 -10
  26. package/vendored/components/navigation/TableOfContents.tsx +48 -35
  27. package/vendored/components/search/SearchModal.tsx +9 -5
  28. package/vendored/components/ui/CodePanelModal.tsx +5 -14
  29. package/vendored/hooks/useBodyScrollLock.ts +37 -0
  30. package/vendored/lib/analytics-client.ts +17 -7
  31. package/vendored/lib/docs-types.ts +3 -2
  32. package/vendored/lib/email-templates/components/base-layout.tsx +15 -15
  33. package/vendored/lib/extract-highlights.ts +2 -0
  34. package/vendored/lib/heading-extractor.ts +2 -2
  35. package/vendored/lib/indexnow.ts +77 -0
  36. package/vendored/lib/json-ld.ts +171 -0
  37. package/vendored/lib/middleware-helpers.ts +2 -0
  38. package/vendored/lib/openapi/errors.ts +21 -1
  39. package/vendored/lib/openapi/validator.ts +70 -23
  40. package/vendored/lib/route-helpers.ts +7 -2
  41. package/vendored/lib/search-client.ts +81 -36
  42. package/vendored/lib/seo.ts +77 -0
  43. package/vendored/lib/static-artifacts.ts +204 -5
  44. package/vendored/lib/static-file-route.ts +10 -5
  45. package/vendored/lib/validate-config.ts +1 -0
  46. package/vendored/schema/docs-schema.json +130 -8
  47. 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
+ }
@@ -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 */}
@@ -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-sm text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none py-2 min-h-[36px]"
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 messagesEndRef = useRef<HTMLDivElement>(null);
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
- // Auto-scroll to bottom when new messages arrive — debounced to avoid
44
- // scroll thrashing during SSE streaming (messages changes per animation frame)
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
- const id = requestAnimationFrame(() => {
47
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
48
- });
49
- return () => cancelAnimationFrame(id);
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
- const slug = label
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
- useEffect(() => {
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 immediately, then scan DOM for dynamic components
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 headingRegex = /^(#{2,3})\s+(.+)$/gm;
202
- let match;
203
- while ((match = headingRegex.exec(content)) !== null) {
204
- const level = match[1].length;
205
- const text = match[2].trim();
206
- const id = text
207
- .toLowerCase()
208
- .replace(/[^a-z0-9]+/g, '-')
209
- .replace(/^-+|-+$/g, '');
210
- items.push({ id, text, level });
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
- const updateRegex = /<Update\s+label=["']([^"']+)["']/g;
214
- while ((match = updateRegex.exec(content)) !== null) {
215
- const text = match[1];
216
- const id = text
217
- .toLowerCase()
218
- .replace(/[^a-z0-9]+/g, '-')
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 domHeadings = document.querySelectorAll('main h2[id], main h3[id]');
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
- domHeadings.forEach((heading) => {
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
- const text = element.getAttribute('data-update-label') || '';
243
- if (!text || !id || seenDomIds.has(id)) return;
244
- newItems.push({ id, text, level: 2 });
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, isInitialized }, response] = await Promise.all([
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
- if (!isInitialized()) {
178
- const data = await response.json();
179
- await initializeSearch(data);
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
- // Body scroll lock + focus management
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]);