jamdesk 1.0.22 → 1.1.0

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 (33) hide show
  1. package/dist/__tests__/unit/package-config.test.js +1 -1
  2. package/dist/__tests__/unit/package-config.test.js.map +1 -1
  3. package/dist/lib/deps.js +4 -4
  4. package/dist/lib/deps.js.map +1 -1
  5. package/package.json +3 -4
  6. package/vendored/app/[[...slug]]/page.tsx +31 -16
  7. package/vendored/app/api/og/route.tsx +13 -23
  8. package/vendored/app/api/raw-content/[...slug]/route.ts +28 -0
  9. package/vendored/app/layout.tsx +56 -16
  10. package/vendored/components/AIActionsMenu.tsx +677 -0
  11. package/vendored/components/mdx/ApiEndpoint.tsx +2 -1
  12. package/vendored/components/mdx/ImagePriorityProvider.tsx +53 -0
  13. package/vendored/components/mdx/MDXComponents.tsx +15 -0
  14. package/vendored/components/mdx/ZoomableImage.tsx +35 -27
  15. package/vendored/lib/analytics-script.ts +0 -8
  16. package/vendored/lib/contextual-defaults.ts +16 -0
  17. package/vendored/lib/docs-types.ts +12 -3
  18. package/vendored/lib/enhance-navigation.ts +1 -1
  19. package/vendored/lib/isr-build-executor.ts +41 -0
  20. package/vendored/lib/preprocess-mdx.ts +5 -3
  21. package/vendored/lib/r2-content.ts +2 -0
  22. package/vendored/lib/redirect-matcher.ts +31 -0
  23. package/vendored/lib/revalidation-helpers.ts +10 -0
  24. package/vendored/lib/revalidation-trigger.ts +7 -3
  25. package/vendored/lib/seo.ts +10 -20
  26. package/vendored/lib/static-artifacts.ts +43 -0
  27. package/vendored/lib/static-file-route.ts +11 -1
  28. package/vendored/schema/docs-schema.json +36 -3
  29. package/vendored/scripts/copy-files.cjs +47 -4
  30. package/vendored/components/snippets/generated/CodeLink.tsx +0 -25
  31. package/vendored/components/snippets/generated/HeaderAPI.tsx +0 -44
  32. package/vendored/components/snippets/generated/PlansAvailable.tsx +0 -53
  33. package/vendored/components/snippets/generated/SnippetIntro.tsx +0 -43
@@ -63,7 +63,7 @@ export function ApiEndpoint({ method, path, baseUrl = 'https://api.jamdesk.com/a
63
63
  };
64
64
 
65
65
  return (
66
- <div data-api-endpoint="true" onClick={handleCopy} className="flex items-center gap-3 px-3 py-1.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-primary)] mb-4 not-prose cursor-pointer">
66
+ <div data-api-endpoint="true" className="flex items-center gap-3 px-3 py-1.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-primary)] mb-4 not-prose">
67
67
  {/* Method badge */}
68
68
  <span className={`px-2.5 py-1 text-xs font-bold rounded-md ${colors.bg} ${colors.text}`}>
69
69
  {method}
@@ -78,6 +78,7 @@ export function ApiEndpoint({ method, path, baseUrl = 'https://api.jamdesk.com/a
78
78
  onClick={handleCopy}
79
79
  className="p-1.5 text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] rounded-md transition-colors"
80
80
  title="Copy endpoint URL"
81
+ aria-label="Copy endpoint URL"
81
82
  >
82
83
  {copied ? (
83
84
  <i className="fa-solid fa-check h-4 w-4 text-emerald-500" aria-hidden="true" />
@@ -0,0 +1,53 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useRef, useMemo, type ReactNode } from 'react';
4
+
5
+ interface ImagePriorityContextValue {
6
+ /** Call once per image. Returns true for the first image only. */
7
+ claimPriority: () => boolean;
8
+ }
9
+
10
+ const ImagePriorityContext = createContext<ImagePriorityContextValue | null>(null);
11
+
12
+ /**
13
+ * Wrap MDX content to auto-prioritize the first image for LCP.
14
+ * The first image to call `claimPriority()` gets `loading="eager"` + `fetchPriority="high"`.
15
+ * All subsequent images get the default `loading="lazy"`.
16
+ */
17
+ export function ImagePriorityProvider({ children }: { children: ReactNode }) {
18
+ const claimed = useRef(false);
19
+
20
+ const value = useMemo<ImagePriorityContextValue>(() => ({
21
+ claimPriority: () => {
22
+ if (claimed.current) return false;
23
+ claimed.current = true;
24
+ return true;
25
+ },
26
+ }), []);
27
+
28
+ return (
29
+ <ImagePriorityContext.Provider value={value}>
30
+ {children}
31
+ </ImagePriorityContext.Provider>
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Hook for images to check if they should be the priority (LCP) image.
37
+ * Returns `{ isFirst: true }` for the first caller, `{ isFirst: false }` for all others.
38
+ * Safe to use outside provider — returns `{ isFirst: false }`.
39
+ *
40
+ * Uses a local ref to cache the result — without this, React strict mode's
41
+ * double-render would claim priority on the first pass, then the second pass
42
+ * would see `claimed.current === true` and no image would get priority.
43
+ */
44
+ export function useImagePriority(): { isFirst: boolean } {
45
+ const ctx = useContext(ImagePriorityContext);
46
+ const result = useRef<boolean | null>(null);
47
+
48
+ if (result.current === null) {
49
+ result.current = ctx ? ctx.claimPriority() : false;
50
+ }
51
+
52
+ return { isFirst: result.current };
53
+ }
@@ -139,6 +139,7 @@ function HttpCodeBlock({ method, url }: { method: string; url: string }) {
139
139
  <button
140
140
  className="http-copy-btn p-1.5 text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] rounded-md transition-colors flex-shrink-0"
141
141
  title="Copy"
142
+ aria-label="Copy"
142
143
  type="button"
143
144
  >
144
145
  <i className="fa-regular fa-copy text-sm" aria-hidden="true" />
@@ -217,6 +218,18 @@ export const MDXComponents = {
217
218
  ViewWrapper,
218
219
  // Media embeds
219
220
  YouTube,
221
+ // Sized images from preprocess-mdx (![alt](url =WIDTHx) syntax).
222
+ // These are output as <SizedImage> JSX so they go through component mapping
223
+ // (raw <img> JSX in MDX bypasses the components provider).
224
+ SizedImage: ({ src, alt, width, height, className, ...rest }: any) => (
225
+ <ZoomableImage
226
+ src={src}
227
+ alt={alt || ''}
228
+ width={width}
229
+ height={height}
230
+ className={`mx-auto block ${className || ''}`.trim()}
231
+ />
232
+ ),
220
233
  // Localized component aliases (for translated docs)
221
234
  Varning: Warning, // Swedish
222
235
  Avertissement: Warning, // French
@@ -228,6 +241,7 @@ export const MDXComponents = {
228
241
  style,
229
242
  loading,
230
243
  decoding,
244
+ fetchPriority,
231
245
  className,
232
246
  class: htmlClass,
233
247
  'data-no-zoom': dataNoZoom,
@@ -266,6 +280,7 @@ export const MDXComponents = {
266
280
  style={style}
267
281
  loading={loading}
268
282
  decoding={decoding}
283
+ fetchPriority={fetchPriority}
269
284
  noZoom={disableZoom}
270
285
  />
271
286
  );
@@ -1,6 +1,8 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect } from 'react';
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { useImagePriority } from './ImagePriorityProvider';
5
+ import { useBodyScrollLock } from '../../hooks/useBodyScrollLock';
4
6
 
5
7
  interface ZoomableImageProps {
6
8
  src: string;
@@ -11,6 +13,7 @@ interface ZoomableImageProps {
11
13
  style?: React.CSSProperties;
12
14
  loading?: 'lazy' | 'eager';
13
15
  decoding?: 'sync' | 'async' | 'auto';
16
+ fetchPriority?: 'high' | 'low' | 'auto';
14
17
  noZoom?: boolean;
15
18
  }
16
19
 
@@ -23,34 +26,36 @@ export function ZoomableImage({
23
26
  style,
24
27
  loading,
25
28
  decoding,
29
+ fetchPriority,
26
30
  noZoom = false,
27
31
  }: ZoomableImageProps) {
32
+ const { isFirst } = useImagePriority();
28
33
  const [isZoomed, setIsZoomed] = useState(false);
29
- const [isMobile, setIsMobile] = useState(false);
30
34
 
31
- // Detect mobile devices
32
- useEffect(() => {
33
- const checkMobile = () => {
34
- setIsMobile(window.innerWidth < 768);
35
- };
36
-
37
- checkMobile();
38
- window.addEventListener('resize', checkMobile);
39
- return () => window.removeEventListener('resize', checkMobile);
40
- }, []);
35
+ // Auto-prioritize first image for LCP (unless explicitly overridden)
36
+ const effectiveLoading = loading || (isFirst ? 'eager' : 'lazy');
37
+ const effectivePriority = fetchPriority || (isFirst ? 'high' : undefined);
41
38
 
42
- // Disable zoom if noZoom prop is set OR if on mobile
43
- const zoomDisabled = noZoom || isMobile;
39
+ useBodyScrollLock(isZoomed);
44
40
 
45
- const handleClick = () => {
46
- if (!zoomDisabled) {
41
+ const handleClick = useCallback(() => {
42
+ // Check mobile at click time — avoids per-image resize listeners
43
+ if (!noZoom && window.innerWidth >= 768) {
47
44
  setIsZoomed(true);
48
45
  }
49
- };
46
+ }, [noZoom]);
50
47
 
51
- const handleClose = () => {
52
- setIsZoomed(false);
53
- };
48
+ const handleClose = useCallback(() => setIsZoomed(false), []);
49
+
50
+ // Escape key closes zoom modal
51
+ useEffect(() => {
52
+ if (!isZoomed) return;
53
+ const handleKeyDown = (e: KeyboardEvent) => {
54
+ if (e.key === 'Escape') handleClose();
55
+ };
56
+ document.addEventListener('keydown', handleKeyDown);
57
+ return () => document.removeEventListener('keydown', handleKeyDown);
58
+ }, [isZoomed, handleClose]);
54
59
 
55
60
  return (
56
61
  <>
@@ -59,17 +64,21 @@ export function ZoomableImage({
59
64
  alt={alt}
60
65
  width={width}
61
66
  height={height}
62
- className={`${className} ${zoomDisabled ? '' : 'cursor-zoom-in'}`}
67
+ className={`${className} ${noZoom ? '' : 'md:cursor-zoom-in'}`}
63
68
  style={style}
64
- loading={loading || 'lazy'}
69
+ loading={effectiveLoading}
65
70
  decoding={decoding || 'async'}
71
+ fetchPriority={effectivePriority}
66
72
  onClick={handleClick}
67
73
  />
68
-
69
- {/* Zoom Modal - only renders on desktop when zoom is enabled */}
70
- {isZoomed && !isMobile && (
71
- <div
74
+
75
+ {/* Zoom Modal */}
76
+ {isZoomed && (
77
+ <div
72
78
  className="image-zoom-modal"
79
+ role="dialog"
80
+ aria-modal="true"
81
+ aria-label={`Zoomed image: ${alt || 'Image'}`}
73
82
  onClick={handleClose}
74
83
  >
75
84
  <div className="image-zoom-backdrop">
@@ -80,4 +89,3 @@ export function ZoomableImage({
80
89
  </>
81
90
  );
82
91
  }
83
-
@@ -134,14 +134,6 @@ export function getAnalyticsScript(rawSlug: string | null): string {
134
134
  window.addEventListener('popstate', function() {
135
135
  setTimeout(trackIfNewPath, 0);
136
136
  });
137
-
138
- // Fallback: Poll for URL changes (catches Next.js soft navigation)
139
- setInterval(function() {
140
- if (location.pathname !== lastTrackedPath) {
141
- lastTrackedPath = location.pathname;
142
- trackPageView();
143
- }
144
- }, 1000);
145
137
  })();
146
138
  `;
147
139
  }
@@ -0,0 +1,16 @@
1
+ import type { DocsConfig, ContextualOption } from './docs-types';
2
+
3
+ const DEFAULT_OPTIONS: ContextualOption[] = [
4
+ 'copy', 'view', 'chatgpt', 'claude', 'perplexity', 'mcp', 'cursor', 'vscode',
5
+ ];
6
+
7
+ /**
8
+ * Resolve contextual menu options from config.
9
+ * Returns empty array if disabled or explicitly empty.
10
+ */
11
+ export function getContextualOptions(config: DocsConfig): ContextualOption[] {
12
+ if (config.contextual?.enabled === false) return [];
13
+ const options = config.contextual?.options;
14
+ if (options !== undefined) return options;
15
+ return DEFAULT_OPTIONS;
16
+ }
@@ -568,6 +568,13 @@ export interface StylingConfig {
568
568
  * Note: May interfere with code examples containing quote characters.
569
569
  */
570
570
  typography?: boolean;
571
+ /**
572
+ * Custom JavaScript file path(s) to include on documentation pages.
573
+ * Files are resolved relative to the project's content directory.
574
+ *
575
+ * Example: "scripts/custom.js" or ["scripts/analytics.js", "scripts/widget.js"]
576
+ */
577
+ js?: string | string[];
571
578
  }
572
579
 
573
580
  // =============================================================================
@@ -588,6 +595,7 @@ export interface IntegrationsConfig {
588
595
  heap?: { appId: string };
589
596
  hightouch?: { writeKey: string; apiHost?: string };
590
597
  hotjar?: { hjid: string; hjsv: string };
598
+ crisp?: { websiteId: string };
591
599
  intercom?: { appId: string };
592
600
  koala?: { publicApiKey: string };
593
601
  logrocket?: { appId: string };
@@ -669,8 +677,8 @@ export interface ErrorsConfig {
669
677
  /**
670
678
  * Contextual option - built-in or custom
671
679
  */
672
- export type ContextualOption =
673
- | 'copy' | 'view' | 'chatgpt' | 'claude' | 'perplexity'
680
+ export type ContextualOption =
681
+ | 'copy' | 'view' | 'chatgpt' | 'claude' | 'gemini' | 'perplexity'
674
682
  | 'mcp' | 'cursor' | 'vscode'
675
683
  | {
676
684
  title: string;
@@ -683,7 +691,8 @@ export type ContextualOption =
683
691
  * Contextual menu configuration
684
692
  */
685
693
  export interface ContextualConfig {
686
- options: ContextualOption[];
694
+ enabled?: boolean;
695
+ options?: ContextualOption[];
687
696
  }
688
697
 
689
698
  /**
@@ -121,7 +121,7 @@ function enhancePages(
121
121
  }
122
122
 
123
123
  /** Navigation keys whose children are sub-nodes (recurse with enhanceNavNode). */
124
- const RECURSE_KEYS = [
124
+ export const RECURSE_KEYS = [
125
125
  'groups', 'tabs', 'anchors', 'dropdowns',
126
126
  'products', 'versions', 'languages', 'menu',
127
127
  ] as const;
@@ -8,7 +8,9 @@
8
8
  */
9
9
 
10
10
  import fs from 'fs';
11
+ import path from 'path';
11
12
  import glob from 'fast-glob';
13
+ import type { DocsConfig } from './docs-types.js';
12
14
 
13
15
  /**
14
16
  * Check if ISR mode should be used.
@@ -200,3 +202,42 @@ export const ISR_PHASES = {
200
202
  } as const;
201
203
 
202
204
  export type IsrPhase = keyof typeof ISR_PHASES;
205
+
206
+ /**
207
+ * Collect custom JavaScript content from project directory.
208
+ * Priority: styling.js config > auto-detect .js files in project root.
209
+ * Returns concatenated JS content or null if none found.
210
+ */
211
+ export function collectCustomJs(
212
+ projectDir: string,
213
+ config: DocsConfig
214
+ ): string | null {
215
+ let jsPaths: string[] = [];
216
+
217
+ if (config.styling?.js) {
218
+ const configured = Array.isArray(config.styling.js)
219
+ ? config.styling.js
220
+ : [config.styling.js];
221
+ jsPaths = configured.map(p => p.replace(/^\//, ''));
222
+ } else {
223
+ try {
224
+ jsPaths = (fs.readdirSync(projectDir) as string[])
225
+ .filter(f => f.endsWith('.js'))
226
+ .sort();
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+
232
+ if (jsPaths.length === 0) return null;
233
+
234
+ const contents: string[] = [];
235
+ for (const jsPath of jsPaths) {
236
+ const fullPath = path.join(projectDir, jsPath);
237
+ if (fs.existsSync(fullPath)) {
238
+ contents.push(fs.readFileSync(fullPath, 'utf-8'));
239
+ }
240
+ }
241
+
242
+ return contents.length > 0 ? contents.join('\n') : null;
243
+ }
@@ -768,13 +768,15 @@ function transformImageDimensionsInText(text: string): string {
768
768
 
769
769
  return text.replace(pattern, (_match, alt, url, width, height) => {
770
770
  const escapedAlt = (alt as string).replace(/"/g, '&quot;');
771
- // Center images with explicit dimensions using mx-auto and block display
772
- const attrs = [`src="${url}"`, `alt="${escapedAlt}"`, `className="mx-auto block"`];
771
+ // Use <SizedImage> (registered in MDXComponents) instead of raw <img>.
772
+ // Raw <img> JSX in MDX bypasses the component provider, so these images
773
+ // wouldn't get ZoomableImage, ImagePriorityProvider, or loading attributes.
774
+ const attrs = [`src="${url}"`, `alt="${escapedAlt}"`];
773
775
 
774
776
  if (width) attrs.push(`width="${width}"`);
775
777
  if (height) attrs.push(`height="${height}"`);
776
778
 
777
- return `<img ${attrs.join(' ')} />`;
779
+ return `<SizedImage ${attrs.join(' ')} />`;
778
780
  });
779
781
  }
780
782
 
@@ -62,4 +62,6 @@ export interface AssetResult {
62
62
 
63
63
  export async function fetchAsset(_projectSlug: string, _assetPath: string): Promise<AssetResult | null> { return null; }
64
64
  export async function fetchStaticFile(_projectSlug: string, _filename: string): Promise<string | null> { return null; }
65
+ export async function fetchCustomCss(_projectSlug: string): Promise<string | null> { return null; }
66
+ export async function fetchCustomJs(_projectSlug: string): Promise<string | null> { return null; }
65
67
  export function setR2Client(_client: unknown): void {}
@@ -60,6 +60,37 @@ function compileRedirectPatterns(rules: RedirectRule[]): void {
60
60
  }
61
61
  }
62
62
 
63
+ /**
64
+ * Clear cached redirects for a project (or all projects).
65
+ * Called during revalidation to pick up updated _redirects.json from R2.
66
+ */
67
+ export async function clearRedirectCache(projectName?: string): Promise<void> {
68
+ if (!redis) return;
69
+ try {
70
+ if (projectName) {
71
+ await redis.del(`${CACHE_PREFIX}${projectName}`);
72
+ } else {
73
+ // Scan for all redirect cache keys and delete them
74
+ const keys: string[] = [];
75
+ let cursor = '0';
76
+ do {
77
+ const result: [string, string[]] = await redis.scan(cursor, { match: `${CACHE_PREFIX}*`, count: 100 });
78
+ cursor = result[0];
79
+ keys.push(...result[1]);
80
+ } while (cursor !== '0');
81
+ if (keys.length > 0) {
82
+ const pipeline = redis.pipeline();
83
+ for (const key of keys) {
84
+ pipeline.del(key);
85
+ }
86
+ await pipeline.exec();
87
+ }
88
+ }
89
+ } catch {
90
+ // Ignore cache clear errors
91
+ }
92
+ }
93
+
63
94
  /**
64
95
  * Fetch redirects for a project from R2 with Redis caching
65
96
  * Returns null if no redirects file or error (fail open)
@@ -11,6 +11,8 @@ import { clearSnippetCache } from './snippet-compiler-isr';
11
11
  import { clearOpenApiCache } from './openapi-isr';
12
12
  import { clearProjectCache as clearMcpProjectCache, clearCache as clearMcpCache } from './mcp-search';
13
13
  import { clearNavigationCache } from './navigation-resolver';
14
+ import { STATIC_REVALIDATION_PATHS } from './static-file-route';
15
+ import { clearRedirectCache } from './redirect-matcher';
14
16
 
15
17
  /**
16
18
  * Validate the revalidation secret using constant-time comparison.
@@ -123,6 +125,7 @@ export async function executeRevalidation(
123
125
  clearSnippetCache(request.project);
124
126
  clearOpenApiCache(request.project);
125
127
  clearMcpProjectCache(request.project);
128
+ await clearRedirectCache(request.project);
126
129
  clearNavigationCache(); // Navigation cache keys include project name, clear all for simplicity
127
130
  revalidated.push(`cache:${request.project}`);
128
131
  }
@@ -160,8 +163,15 @@ export async function executeRevalidation(
160
163
  clearOpenApiCache();
161
164
  clearMcpCache();
162
165
  clearNavigationCache();
166
+ await clearRedirectCache();
163
167
  if (revalidatePath) {
164
168
  revalidatePath('/', 'layout');
169
+ // Also purge CDN cache for static file routes (sitemap, robots, llms.txt, etc.)
170
+ // These are route handlers with s-maxage caching that revalidatePath('/', 'layout') doesn't cover
171
+ for (const p of STATIC_REVALIDATION_PATHS) {
172
+ revalidatePath(p);
173
+ revalidated.push(`path:${p}`);
174
+ }
165
175
  }
166
176
  revalidated.push('all');
167
177
  }
@@ -100,6 +100,7 @@ async function revalidateProxy(secret: string, projectSlug: string): Promise<voi
100
100
  export interface Manifest {
101
101
  files: Record<string, { hash: string }>;
102
102
  openapi?: Record<string, { hash: string }>;
103
+ configHash?: string;
103
104
  }
104
105
 
105
106
  /** Compare manifests and return paths that changed (without .mdx extension). */
@@ -129,9 +130,12 @@ export function getChangedPaths(
129
130
  }
130
131
  }
131
132
 
132
- // When only OpenAPI specs changed (no MDX changes), revalidate all pages
133
- // because we can't know which pages reference the changed spec
134
- if (changed.length === 0 && hasOpenapiChanges(oldManifest, newManifest)) {
133
+ // When config or OpenAPI specs changed (no MDX changes), revalidate all pages.
134
+ // Config changes (e.g., _hasCustomJs flag) affect every page's layout.
135
+ // OpenAPI changes can affect any endpoint page.
136
+ if (changed.length === 0 &&
137
+ (oldManifest.configHash !== newManifest.configHash ||
138
+ hasOpenapiChanges(oldManifest, newManifest))) {
135
139
  return Object.keys(newManifest.files).map(p => p.replace('.mdx', ''));
136
140
  }
137
141
 
@@ -23,6 +23,13 @@ export function buildSiteTitle(configName: string): string {
23
23
  : `${configName} Documentation`;
24
24
  }
25
25
 
26
+ /** Resolve a Logo or Favicon config to a path string, preferring light variant. */
27
+ function resolveImagePath(image?: Logo | Favicon): string {
28
+ if (!image) return '';
29
+ if (typeof image === 'string') return image;
30
+ return (image as LogoConfig).light || (image as LogoConfig).dark || '';
31
+ }
32
+
26
33
  /**
27
34
  * Build the OG image URL for a page using the proxy's /api/og endpoint.
28
35
  * Returns undefined if og:image is explicitly set in metatags.
@@ -36,7 +43,6 @@ function buildOgImageUrl(
36
43
  section?: string;
37
44
  logo?: Logo;
38
45
  favicon?: Favicon;
39
- theme?: 'light' | 'dark';
40
46
  }
41
47
  ): string {
42
48
  const params = new URLSearchParams();
@@ -49,10 +55,6 @@ function buildOgImageUrl(
49
55
  if (options?.section) {
50
56
  params.set('section', options.section);
51
57
  }
52
- if (options?.theme) {
53
- params.set('theme', options.theme);
54
- }
55
-
56
58
  // For hostAtDocs sites (baseUrl ends with /docs), use the parent domain with
57
59
  // /_jd/og path instead of cross-domain /api/og. LinkedIn's crawler fails to
58
60
  // fetch OG images from different domains. The /_jd/og path is proxied through
@@ -60,24 +62,13 @@ function buildOgImageUrl(
60
62
  const isHostAtDocs = baseUrl.endsWith('/docs');
61
63
  const rootUrl = baseUrl.replace(/\/docs$/, '');
62
64
 
63
- // Handle logo - prefer dark variant for OG images (dark background)
65
+ // Handle logo - prefer light variant for OG images (cream background)
64
66
  // Satori (@vercel/og) can't render webp, so fall back to favicon if logo is webp
65
67
  if (options?.logo || options?.favicon) {
66
- let logoPath = '';
67
- if (options?.logo) {
68
- if (typeof options.logo === 'string') {
69
- logoPath = options.logo;
70
- } else {
71
- logoPath = (options.logo as LogoConfig).dark || (options.logo as LogoConfig).light;
72
- }
73
- }
68
+ let logoPath = resolveImagePath(options?.logo);
74
69
  // Fall back to favicon when logo is webp (Satori can't decode webp)
75
70
  if ((!logoPath || logoPath.endsWith('.webp')) && options?.favicon) {
76
- if (typeof options.favicon === 'string') {
77
- logoPath = options.favicon;
78
- } else {
79
- logoPath = (options.favicon as FaviconConfig).dark || (options.favicon as FaviconConfig).light;
80
- }
71
+ logoPath = resolveImagePath(options.favicon);
81
72
  }
82
73
  // Make logo URL absolute (use root URL since assets are at domain root)
83
74
  if (logoPath && !logoPath.startsWith('http')) {
@@ -242,7 +233,6 @@ export function buildSeoMetadata(
242
233
  section: frontmatter.section,
243
234
  logo: config.logo,
244
235
  favicon: config.favicon,
245
- theme: 'dark', // Dark theme looks better for OG images
246
236
  }
247
237
  );
248
238
 
@@ -5,6 +5,9 @@
5
5
  * for ISR projects. These are uploaded to R2 alongside the MDX content.
6
6
  */
7
7
 
8
+ import type { NavigationConfig } from './docs-types.js';
9
+ import { RECURSE_KEYS } from './enhance-navigation.js';
10
+
8
11
  /**
9
12
  * Page metadata for artifact generation.
10
13
  */
@@ -206,6 +209,46 @@ export function extractPageMetadata(pages: RawPageInfo[]): PageMetadata[] {
206
209
  });
207
210
  }
208
211
 
212
+ /**
213
+ * Recursively extract all page paths from a docs.json navigation tree.
214
+ * Used to filter sitemap/llms.txt to only include pages referenced in navigation.
215
+ */
216
+ export function extractNavigationPaths(
217
+ navigation: NavigationConfig | undefined,
218
+ ): Set<string> {
219
+ const paths = new Set<string>();
220
+ if (!navigation) return paths;
221
+
222
+ function walkNode(node: Record<string, unknown>): void {
223
+ if (Array.isArray(node.pages)) {
224
+ for (const item of node.pages) {
225
+ if (typeof item === 'string') {
226
+ paths.add(item);
227
+ } else if (typeof item === 'object' && item !== null) {
228
+ const obj = item as Record<string, unknown>;
229
+ if (typeof obj.page === 'string') {
230
+ paths.add(obj.page);
231
+ }
232
+ if ('group' in obj) {
233
+ walkNode(obj);
234
+ }
235
+ }
236
+ }
237
+ }
238
+
239
+ for (const key of RECURSE_KEYS) {
240
+ if (Array.isArray(node[key])) {
241
+ for (const child of node[key] as Record<string, unknown>[]) {
242
+ walkNode(child);
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ walkNode(navigation as Record<string, unknown>);
249
+ return paths;
250
+ }
251
+
209
252
  /**
210
253
  * Options for generating all artifacts.
211
254
  */
@@ -12,6 +12,16 @@ import { log } from '@/lib/logger';
12
12
  import { isIsrMode } from '@/lib/page-isr-helpers';
13
13
  import { fetchStaticFile } from '@/lib/r2-content';
14
14
 
15
+ /** Filenames served via createStaticFileHandler (5 at root + 5 at /docs). */
16
+ export const STATIC_FILE_NAMES = [
17
+ 'sitemap.xml', 'robots.txt', 'llms.txt', 'feed.xml', 'search-data.json',
18
+ ] as const;
19
+
20
+ /** All CDN paths for static file routes — used by revalidation to purge CDN cache. */
21
+ export const STATIC_REVALIDATION_PATHS = STATIC_FILE_NAMES.flatMap(
22
+ f => [`/${f}`, `/docs/${f}`]
23
+ );
24
+
15
25
  const CONTENT_TYPES: Record<string, string> = {
16
26
  '.xml': 'application/xml',
17
27
  '.json': 'application/json',
@@ -41,7 +51,7 @@ export function createStaticFileHandler(
41
51
  cacheControlOverride?: string
42
52
  ): (request: NextRequest) => Promise<NextResponse> {
43
53
  const contentType = contentTypeOverride || getContentType(filename);
44
- const cacheControl = cacheControlOverride || 'public, max-age=3600, s-maxage=86400';
54
+ const cacheControl = cacheControlOverride || 'public, max-age=3600, s-maxage=3600';
45
55
 
46
56
  return async function GET(request: NextRequest): Promise<NextResponse> {
47
57
  if (!isIsrMode()) {