jamdesk 1.1.29 → 1.1.30

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/lib/deps.js CHANGED
@@ -73,8 +73,8 @@ export const REQUIRED_DEPS = {
73
73
  'unified': '^11.0.0',
74
74
  'unist-util-visit': '^5.0.0',
75
75
  // CSS
76
- 'tailwindcss': '^4.2.2',
77
- '@tailwindcss/postcss': '^4.2.2',
76
+ 'tailwindcss': '^4.2.4',
77
+ '@tailwindcss/postcss': '^4.2.4',
78
78
  '@tailwindcss/typography': '^0.5.10',
79
79
  'postcss': '^8.5.10',
80
80
  'autoprefixer': '^10.4.24',
@@ -83,7 +83,7 @@ export const REQUIRED_DEPS = {
83
83
  'json5': '^2.2.3',
84
84
  'glob': '^13.0.6',
85
85
  // TypeScript (needed for Next.js to avoid auto-install breaking symlink)
86
- 'typescript': '^6.0.2',
86
+ 'typescript': '^6.0.3',
87
87
  '@types/node': '^25.5.2',
88
88
  '@types/react': '^19.2.14',
89
89
  '@types/react-dom': '^19.0.0',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.29",
3
+ "version": "1.1.30",
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",
@@ -114,7 +114,7 @@
114
114
  "json5": "^2.2.3",
115
115
  "nspell": "^2.1.5",
116
116
  "open": "^11.0.0",
117
- "ora": "^9.3.0",
117
+ "ora": "^9.4.0",
118
118
  "tar": "^7.5.9"
119
119
  },
120
120
  "devDependencies": {
@@ -122,7 +122,7 @@
122
122
  "@types/fs-extra": "^11.0.0",
123
123
  "@types/node": "^25.5.0",
124
124
  "typescript": "^6.0.2",
125
- "vitest": "^4.1.3"
125
+ "vitest": "^4.1.5"
126
126
  },
127
127
  "engines": {
128
128
  "node": ">=20.0.0"
@@ -74,6 +74,9 @@ import {
74
74
  type CodeExample,
75
75
  type AuthMethod,
76
76
  } from '@/lib/openapi';
77
+ import { extractLanguageFromPath, isValidLanguageCode } from '@/lib/language-utils';
78
+ import { findFirstNavPage } from '@/lib/find-first-nav-page';
79
+ import { candidateSpecPaths } from '@/lib/openapi/lang-spec-path';
77
80
  import { ApiEndpoint } from '@/components/mdx/ApiEndpoint';
78
81
  import { ImagePriorityProvider } from '@/components/mdx/ImagePriorityProvider';
79
82
  import { AIActionsMenu } from '@/components/AIActionsMenu';
@@ -182,100 +185,19 @@ function getAllDocPaths(): string[] {
182
185
  return paths;
183
186
  }
184
187
 
185
- /**
186
- * Find the first page in navigation (used to resolve empty root slug in place).
187
- */
188
- function findFirstPage(config: DocsConfig): string {
189
- const navigation = config.navigation;
190
-
191
- // Helper to extract page path from a page entry
192
- const getPagePath = (page: unknown): string | null => {
193
- if (typeof page === 'string') return page;
194
- if (typeof page === 'object' && page && 'page' in page) {
195
- return (page as { page: string }).page;
196
- }
197
- return null;
198
- };
199
-
200
- // Helper to extract first page from a group
201
- const extractFirstFromGroup = (group: { pages?: unknown[] }): string | null => {
202
- const itemPages = group.pages || [];
203
- for (const page of itemPages) {
204
- if (typeof page === 'object' && page && 'group' in page) {
205
- const result = extractFirstFromGroup(page as { pages?: unknown[] });
206
- if (result) return result;
207
- continue;
208
- }
209
- const pagePath = getPagePath(page);
210
- if (pagePath) return pagePath;
211
- }
212
- return null;
213
- };
214
-
215
- // Check languages first (multi-language navigation structure)
216
- if (navigation.languages && navigation.languages.length > 0) {
217
- const firstLang = navigation.languages[0];
218
- if (firstLang.tabs && firstLang.tabs.length > 0) {
219
- for (const tab of firstLang.tabs) {
220
- if (tab.groups) {
221
- for (const group of tab.groups) {
222
- const result = extractFirstFromGroup(group);
223
- if (result) return result;
224
- }
225
- }
226
- }
227
- }
228
- }
229
-
230
- // Check tabs first
231
- if (navigation.tabs && navigation.tabs.length > 0) {
232
- for (const tab of navigation.tabs) {
233
- if (tab.groups) {
234
- for (const group of tab.groups) {
235
- const result = extractFirstFromGroup(group);
236
- if (result) return result;
237
- }
238
- }
239
- }
240
- }
241
-
242
- // Check top-level groups
243
- if (navigation.groups && navigation.groups.length > 0) {
244
- for (const group of navigation.groups) {
245
- const result = extractFirstFromGroup(group);
246
- if (result) return result;
247
- }
248
- }
249
-
250
- // Check anchors
251
- if (navigation.anchors && navigation.anchors.length > 0) {
252
- for (const anchor of navigation.anchors) {
253
- if (anchor.groups) {
254
- for (const group of anchor.groups) {
255
- const result = extractFirstFromGroup(group);
256
- if (result) return result;
257
- }
258
- }
259
- }
260
- }
188
+ function findFirstPage(config: DocsConfig, lang?: string): string {
189
+ const nav = config.navigation;
190
+ const langBlock = lang ? nav.languages?.find((l) => l.language === lang) : undefined;
191
+ const result = (langBlock && findFirstNavPage(langBlock)) || findFirstNavPage(nav);
192
+ return result ? result.replace(/^\//, '') : 'introduction';
193
+ }
261
194
 
262
- // Check pages array (flat format with groups inside pages)
263
- if (navigation.pages && Array.isArray(navigation.pages)) {
264
- for (const item of navigation.pages) {
265
- // Could be a string page
266
- const directPath = getPagePath(item);
267
- if (directPath) return directPath;
268
-
269
- // Could be a group object { group: "...", pages: [...] }
270
- if (item && typeof item === 'object' && 'group' in item) {
271
- const result = extractFirstFromGroup(item as { pages?: unknown[] });
272
- if (result) return result;
273
- }
274
- }
195
+ function resolveSlug(normalizedSlug: string[], config: DocsConfig): string[] {
196
+ if (normalizedSlug.length === 0) return pathToSlug(findFirstPage(config));
197
+ if (normalizedSlug.length === 1 && isValidLanguageCode(normalizedSlug[0])) {
198
+ return pathToSlug(findFirstPage(config, normalizedSlug[0]));
275
199
  }
276
-
277
- // Fallback
278
- return 'introduction';
200
+ return normalizedSlug;
279
201
  }
280
202
 
281
203
  export async function generateStaticParams() {
@@ -342,17 +264,12 @@ export async function generateMetadata({ params }: PageProps) {
342
264
  // Normalize slug: strip /docs prefix when hostAtDocs=true.
343
265
  // Empty root → resolve to first page (see DocPage for the full rationale).
344
266
  const normalizedSlug = normalizeSlugForContent(resolvedParams.slug || [], hostAtDocs);
267
+ const config = await loader.getConfig();
268
+ const slug = resolveSlug(normalizedSlug, config);
345
269
  const isRoot = normalizedSlug.length === 0;
346
- const slug = isRoot
347
- ? pathToSlug(findFirstPage(await loader.getConfig()))
348
- : normalizedSlug;
349
270
  const pagePath = slug.join('/');
350
271
 
351
- // Fetch content and config in parallel
352
- const [fileContents, config] = await Promise.all([
353
- loader.getContent(pagePath).catch(() => null),
354
- loader.getConfig(),
355
- ]);
272
+ const fileContents = await loader.getContent(pagePath).catch(() => null);
356
273
 
357
274
  if (!fileContents) {
358
275
  return {
@@ -428,14 +345,11 @@ export default async function DocPage({ params }: PageProps) {
428
345
  // redirect() emits cache-control: private, blocking CDN caching. Canonical
429
346
  // + noindex in generateMetadata prevent duplicate indexing.
430
347
  const normalizedSlug = normalizeSlugForContent(resolvedParams.slug || [], hostAtDocs);
431
- const slug = normalizedSlug.length === 0
432
- ? pathToSlug(findFirstPage(await loader.getConfig()))
433
- : normalizedSlug;
348
+ const config = await loader.getConfig();
349
+ const slug = resolveSlug(normalizedSlug, config);
434
350
  const pagePath = slug.join('/');
435
- const [fileContents, config] = await Promise.all([
436
- loader.getContent(pagePath).catch(() => null),
437
- loader.getConfig(),
438
- ]);
351
+ const currentLang = extractLanguageFromPath(`/${pagePath}`);
352
+ const fileContents = await loader.getContent(pagePath).catch(() => null);
439
353
 
440
354
  // Check if content exists (getContent returns null via catch if not found)
441
355
  if (!fileContents) {
@@ -567,9 +481,13 @@ export default async function DocPage({ params }: PageProps) {
567
481
  allSpecPaths.length > 0 ? allSpecPaths : undefined
568
482
  );
569
483
 
570
- const specsToTry = parsed.isShortFormat && allSpecPaths.length > 1
484
+ const baseSpecs = parsed.isShortFormat && allSpecPaths.length > 1
571
485
  ? allSpecPaths
572
486
  : [parsed.specPath];
487
+ // For each base spec, expand into [<base>.<lang>.ext, <base>.ext] when
488
+ // the page is under a localized URL. Resolver tries each in order; the
489
+ // first that loads wins. Missing lang variant falls back to English.
490
+ const specsToTry = baseSpecs.flatMap(p => candidateSpecPaths(p, currentLang));
573
491
 
574
492
  // Hoist mode-dependent values before the loop
575
493
  const useIsr = isIsrMode() && !!projectSlug;
@@ -580,7 +498,8 @@ export default async function DocPage({ params }: PageProps) {
580
498
 
581
499
  let lastError: unknown = null;
582
500
 
583
- for (const specPath of specsToTry) {
501
+ for (let i = 0; i < specsToTry.length; i++) {
502
+ const specPath = specsToTry[i];
584
503
  try {
585
504
  if (resolveSpec && projectSlug) {
586
505
  const spec = await resolveSpec(projectSlug, specPath);
@@ -598,6 +517,14 @@ export default async function DocPage({ params }: PageProps) {
598
517
  break;
599
518
  } catch (err) {
600
519
  lastError = err;
520
+ const isLast = i === specsToTry.length - 1;
521
+ if (!isLast) {
522
+ // Lang variant (or intermediate candidate) failed — log so we
523
+ // notice transient R2 errors that silently fall through to English.
524
+ console.warn(
525
+ `[openapi] spec candidate "${specPath}" failed; trying next: ${(err as Error).message}`
526
+ );
527
+ }
601
528
  }
602
529
  }
603
530
 
@@ -8,6 +8,7 @@
8
8
  import { NextRequest, NextResponse } from 'next/server';
9
9
  import { fetchAsset } from '@/lib/r2-content';
10
10
  import { log } from '@/lib/logger';
11
+ import { isIsrMode } from '@/lib/page-isr-helpers';
11
12
 
12
13
  // Cache durations by content type (in seconds)
13
14
  const CACHE_DURATIONS: Record<string, number> = {
@@ -27,8 +28,7 @@ export async function GET(
27
28
  request: NextRequest,
28
29
  { params }: { params: Promise<{ path: string[] }> }
29
30
  ) {
30
- // Check if ISR mode is enabled
31
- if (process.env.ISR_MODE !== 'true') {
31
+ if (!isIsrMode()) {
32
32
  return new NextResponse('Asset serving only available in ISR mode', {
33
33
  status: 404,
34
34
  });
@@ -23,6 +23,7 @@
23
23
  import { NextRequest } from 'next/server';
24
24
  import { querySimilarChunks } from '@/lib/vector-store';
25
25
  import { buildSystemPrompt, isCannedIdentityReply, resolveSiteName } from '@/lib/chat-prompt';
26
+ import { stripHedge, isHedgeReply } from '@/lib/hedge-strip';
26
27
  import { getDocsPath, getBaseUrl, trackChatAnalytics } from '@/lib/route-helpers';
27
28
  import { getAnthropicClient } from '@/lib/anthropic-client';
28
29
  import { rewriteQueryForSearch } from '@/lib/query-rewriter';
@@ -38,6 +39,9 @@ const CHAT_MODEL = 'claude-haiku-4-5-20251001';
38
39
  const RATE_LIMIT = 10;
39
40
  const RATE_WINDOW_SECONDS = 60;
40
41
  const MAX_HISTORY = 10;
42
+ /** Messages shorter than this with prior history are treated as follow-ups.
43
+ * Used both for searchQuery enrichment and to skip the rewriter. */
44
+ const SHORT_FOLLOWUP_LEN = 60;
41
45
  const VALID_ROLES = new Set<string>(['user', 'assistant']);
42
46
 
43
47
  export async function POST(
@@ -93,11 +97,12 @@ export async function POST(
93
97
  })
94
98
  .map((h) => ({ role: h.role, content: h.content.slice(0, 4000) }));
95
99
 
96
- // Enrich short messages with conversation context for better vector search.
97
- // When user picks a clarification option (e.g., "Post Analytics"), the message
98
- // alone is too short for good retrieval. Combine with the preceding question.
100
+ const isShortFollowUp = message.length < SHORT_FOLLOWUP_LEN && history.length > 0;
101
+
102
+ // When a user picks a clarification option (e.g. "Post Analytics"), the
103
+ // message alone is too short for good retrieval — combine with the prior turn.
99
104
  let searchQuery = message;
100
- if (message.length < 60 && history.length > 0) {
105
+ if (isShortFollowUp) {
101
106
  const prevUserMsg = [...history].reverse().find(
102
107
  h => h.role === 'user' && h.content !== message,
103
108
  );
@@ -127,16 +132,21 @@ export async function POST(
127
132
  // original query still returns results so chat doesn't fail.
128
133
  const originalQueryPromise = querySimilarChunks(project, searchQuery, 15);
129
134
 
135
+ // Skipping the rewriter on short follow-ups avoids 500-2000ms of serial
136
+ // Anthropic latency — `searchQuery` above is already enriched with prior-turn
137
+ // context using the same threshold, so the rewrite has no retrieval benefit.
130
138
  const rewrittenPathPromise: Promise<Awaited<ReturnType<typeof querySimilarChunks>>> =
131
- rewriteQueryForSearch(message, history)
132
- .catch(() => null)
133
- .then(rewritten => {
134
- // Compare against `message` (the rewriter's input), not `searchQuery`
135
- // (which is enriched with prior-turn context for short follow-ups).
136
- // The rewriter only sees `message`, so a no-op rewrite equals `message`.
137
- if (!rewritten || rewritten === message) return [];
138
- return querySimilarChunks(project, rewritten, 15).catch(() => []);
139
- });
139
+ isShortFollowUp
140
+ ? Promise.resolve([])
141
+ : rewriteQueryForSearch(message, history)
142
+ .catch(() => null)
143
+ .then(rewritten => {
144
+ // Compare against `message` (the rewriter's input), not `searchQuery`
145
+ // (which is enriched with prior-turn context for short follow-ups).
146
+ // The rewriter only sees `message`, so a no-op rewrite equals `message`.
147
+ if (!rewritten || rewritten === message) return [];
148
+ return querySimilarChunks(project, rewritten, 15).catch(() => []);
149
+ });
140
150
 
141
151
  let originalChunks: Awaited<ReturnType<typeof querySimilarChunks>>;
142
152
  let rewrittenChunks: Awaited<ReturnType<typeof querySimilarChunks>>;
@@ -228,23 +238,53 @@ export async function POST(
228
238
  let emittedMarkdownLength = 0;
229
239
  let emittedQuestionLength = 0;
230
240
 
231
- stream.on('inputJson', (_partialJson, jsonSnapshot) => {
232
- if (!jsonSnapshot || typeof jsonSnapshot !== 'object') return;
241
+ // Incrementally extract a still-open string field from partial tool_use JSON.
242
+ // The SDK's jsonSnapshot only exposes a field once its string literal closes,
243
+ // so a ~1000-char answer lands in one snapshot update and the client sees it
244
+ // in one flash. Instead, accumulate the raw partial_json and close the string
245
+ // + object ourselves to read the in-progress value.
246
+ let accumulatedJson = '';
247
+ function extractPartialString(field: 'markdown' | 'question'): string | null {
248
+ // A trailing odd number of backslashes means we're mid-escape. Appending
249
+ // `"}` would turn `\` into `\"` — a valid escaped quote — producing a
250
+ // bogus literal `"` in the extracted string that gets streamed as text.
251
+ const trailing = accumulatedJson.match(/\\+$/)?.[0].length ?? 0;
252
+ if (trailing % 2 !== 0) return null;
253
+ try {
254
+ const parsed = JSON.parse(accumulatedJson + '"}') as Record<string, unknown>;
255
+ const value = parsed[field];
256
+ return typeof value === 'string' ? value : null;
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+
262
+ stream.on('inputJson', (partialJson, jsonSnapshot) => {
263
+ accumulatedJson += typeof partialJson === 'string' ? partialJson : '';
233
264
 
234
265
  // Gate on toolName — emitting before content_block_start resolves
235
266
  // would leak clarification JSON into the answer text channel.
236
267
  if (toolName === 'answer') {
237
- const snap = jsonSnapshot as AnswerInput;
238
- if (typeof snap.markdown === 'string' && snap.markdown.length > emittedMarkdownLength) {
239
- const newContent = snap.markdown.slice(emittedMarkdownLength);
240
- emittedMarkdownLength = snap.markdown.length;
241
- controller.enqueue(sendEvent({ type: 'text', content: newContent }));
268
+ const snap = jsonSnapshot as AnswerInput | null | undefined;
269
+ const md = typeof snap?.markdown === 'string'
270
+ ? snap.markdown
271
+ : extractPartialString('markdown');
272
+ if (md !== null) {
273
+ const trimmed = stripHedge(md);
274
+ if (trimmed.length > emittedMarkdownLength) {
275
+ const newContent = trimmed.slice(emittedMarkdownLength);
276
+ emittedMarkdownLength = trimmed.length;
277
+ controller.enqueue(sendEvent({ type: 'text', content: newContent }));
278
+ }
242
279
  }
243
280
  } else if (toolName === 'ask_clarification') {
244
- const snap = jsonSnapshot as ClarificationInput;
245
- if (typeof snap.question === 'string' && snap.question.length > emittedQuestionLength) {
246
- const newContent = snap.question.slice(emittedQuestionLength);
247
- emittedQuestionLength = snap.question.length;
281
+ const snap = jsonSnapshot as ClarificationInput | null | undefined;
282
+ const q = typeof snap?.question === 'string'
283
+ ? snap.question
284
+ : extractPartialString('question');
285
+ if (q !== null && q.length > emittedQuestionLength) {
286
+ const newContent = q.slice(emittedQuestionLength);
287
+ emittedQuestionLength = q.length;
248
288
  controller.enqueue(sendEvent({ type: 'text', content: newContent }));
249
289
  }
250
290
  }
@@ -275,15 +315,17 @@ export async function POST(
275
315
  if (toolUse.name === 'answer') {
276
316
  const input = toolUse.input as AnswerInput;
277
317
  const slugs = Array.isArray(input.cited_page_slugs) ? input.cited_page_slugs : [];
278
- const markdownText = typeof input.markdown === 'string' ? input.markdown : '';
279
- // Suppress citations on the canned identity reply — otherwise the
280
- // top-2 fallback would attach unrelated docs under "I'm the
281
- // documentation assistant for ".
282
- const isIdentityReply = isCannedIdentityReply(markdownText, siteName);
318
+ const rawMarkdown = typeof input.markdown === 'string' ? input.markdown : '';
319
+ const markdownText = stripHedge(rawMarkdown);
320
+ // Suppress citations on identity + hedge replies the top-2 fallback
321
+ // would otherwise attach unrelated docs under "I'm the documentation
322
+ // assistant…" or under "I don't have information…".
323
+ const suppressCitations =
324
+ isCannedIdentityReply(markdownText, siteName) || isHedgeReply(rawMarkdown);
283
325
 
284
326
  const seen = new Set<string>();
285
327
  const explicitSources: Citation[] = [];
286
- if (!isIdentityReply) {
328
+ if (!suppressCitations) {
287
329
  for (const slug of slugs) {
288
330
  const chunk = chunks.find(c => c.pageSlug === slug);
289
331
  if (chunk && !seen.has(chunk.pageSlug)) {
@@ -296,10 +338,8 @@ export async function POST(
296
338
  }
297
339
  }
298
340
  }
299
- // Identity replies set hadExplicitCitations=true to mark intentional
300
- // no-cite; non-identity answers fall back to top-2 when Claude omits slugs.
301
- hadExplicitCitations = isIdentityReply || explicitSources.length > 0;
302
- sources = isIdentityReply
341
+ hadExplicitCitations = suppressCitations || explicitSources.length > 0;
342
+ sources = suppressCitations
303
343
  ? []
304
344
  : explicitSources.length > 0
305
345
  ? explicitSources
@@ -354,8 +394,9 @@ export async function POST(
354
394
  return new Response(readable, {
355
395
  headers: {
356
396
  'Content-Type': 'text/event-stream',
357
- 'Cache-Control': 'no-cache',
397
+ 'Cache-Control': 'no-cache, no-transform',
358
398
  'Connection': 'keep-alive',
399
+ 'X-Accel-Buffering': 'no',
359
400
  },
360
401
  });
361
402
  }
@@ -9,6 +9,7 @@ import { NextResponse } from 'next/server';
9
9
  import { getConfigCacheSize } from '@/lib/r2-content';
10
10
  import { getSnippetCacheSize } from '@/lib/snippet-compiler-isr';
11
11
  import { getOpenApiCacheSize } from '@/lib/openapi-isr';
12
+ import { isIsrMode } from '@/lib/page-isr-helpers';
12
13
 
13
14
  interface HealthCheck {
14
15
  status: 'ok' | 'degraded' | 'unhealthy';
@@ -31,8 +32,6 @@ interface HealthCheck {
31
32
  const startTime = Date.now();
32
33
 
33
34
  export async function GET() {
34
- const isIsrMode = process.env.ISR_MODE === 'true';
35
-
36
35
  // Memory usage
37
36
  const memUsage = process.memoryUsage();
38
37
  const memory = {
@@ -54,7 +53,7 @@ export async function GET() {
54
53
  memory,
55
54
  uptime: Math.round((Date.now() - startTime) / 1000),
56
55
  timestamp: new Date().toISOString(),
57
- isrMode: isIsrMode,
56
+ isrMode: isIsrMode(),
58
57
  };
59
58
 
60
59
  return NextResponse.json(health, {
@@ -7,6 +7,7 @@ import { LayoutWrapper } from '@/components/layout/LayoutWrapper';
7
7
  import { CodeBlockCopyButton } from '@/components/CodeBlockCopyButton';
8
8
  import { HeaderLinkCopy } from '@/components/HeaderLinkCopy';
9
9
  import { FontAwesomeLoader } from '@/components/FontAwesomeLoader';
10
+ import { JdReadySentinel } from '@/components/JdReadySentinel';
10
11
  import { FA_CSS_HREF } from '@/lib/font-awesome';
11
12
  import { getDocsConfig } from '@/lib/docs';
12
13
  import { getDocsConfig as getIsrDocsConfig } from '@/lib/docs-isr';
@@ -580,6 +581,7 @@ export default async function RootLayout({
580
581
  {config.integrations?.ga4?.measurementId && (
581
582
  <ConditionalGA gaId={config.integrations.ga4.measurementId} />
582
583
  )}
584
+ <JdReadySentinel />
583
585
  </body>
584
586
  </html>
585
587
  );
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ /**
6
+ * Sets document.body.dataset.jdReady = 'true' after window.load so the
7
+ * pdf-service (and any future browser automation) can wait on a reliable
8
+ * signal that the page is fully rendered and resources have settled.
9
+ */
10
+ export function JdReadySentinel(): null {
11
+ useEffect(() => {
12
+ const mark = () => {
13
+ document.body.dataset.jdReady = 'true';
14
+ };
15
+ if (document.readyState === 'complete') {
16
+ mark();
17
+ } else {
18
+ window.addEventListener('load', mark, { once: true });
19
+ }
20
+ // removeEventListener with an unregistered listener is a no-op, so the
21
+ // cleanup is safe whether or not the branch above registered it.
22
+ return () => window.removeEventListener('load', mark);
23
+ }, []);
24
+ return null;
25
+ }
@@ -267,7 +267,7 @@ export function Breadcrumb({ slug, config }: BreadcrumbProps) {
267
267
  {/* Home link */}
268
268
  <Link
269
269
  href={homeLink}
270
- prefetch={false}
270
+ prefetch={true}
271
271
  className="text-[var(--color-marker)] hover:text-[var(--color-text-primary)] transition-colors flex items-center"
272
272
  aria-label="Home"
273
273
  >
@@ -284,7 +284,7 @@ export function Breadcrumb({ slug, config }: BreadcrumbProps) {
284
284
  ) : item.path ? (
285
285
  <Link
286
286
  href={`${linkPrefix}/${item.path}`}
287
- prefetch={false}
287
+ prefetch={true}
288
288
  className="text-[var(--color-marker)] hover:text-[var(--color-text-primary)] transition-colors"
289
289
  >
290
290
  {item.label}