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.
- package/dist/__tests__/unit/package-config.test.js +1 -1
- package/dist/__tests__/unit/package-config.test.js.map +1 -1
- package/dist/lib/deps.js +4 -4
- package/dist/lib/deps.js.map +1 -1
- package/package.json +3 -4
- package/vendored/app/[[...slug]]/page.tsx +31 -16
- package/vendored/app/api/og/route.tsx +13 -23
- package/vendored/app/api/raw-content/[...slug]/route.ts +28 -0
- package/vendored/app/layout.tsx +56 -16
- package/vendored/components/AIActionsMenu.tsx +677 -0
- package/vendored/components/mdx/ApiEndpoint.tsx +2 -1
- package/vendored/components/mdx/ImagePriorityProvider.tsx +53 -0
- package/vendored/components/mdx/MDXComponents.tsx +15 -0
- package/vendored/components/mdx/ZoomableImage.tsx +35 -27
- package/vendored/lib/analytics-script.ts +0 -8
- package/vendored/lib/contextual-defaults.ts +16 -0
- package/vendored/lib/docs-types.ts +12 -3
- package/vendored/lib/enhance-navigation.ts +1 -1
- package/vendored/lib/isr-build-executor.ts +41 -0
- package/vendored/lib/preprocess-mdx.ts +5 -3
- package/vendored/lib/r2-content.ts +2 -0
- package/vendored/lib/redirect-matcher.ts +31 -0
- package/vendored/lib/revalidation-helpers.ts +10 -0
- package/vendored/lib/revalidation-trigger.ts +7 -3
- package/vendored/lib/seo.ts +10 -20
- package/vendored/lib/static-artifacts.ts +43 -0
- package/vendored/lib/static-file-route.ts +11 -1
- package/vendored/schema/docs-schema.json +36 -3
- package/vendored/scripts/copy-files.cjs +47 -4
- package/vendored/components/snippets/generated/CodeLink.tsx +0 -25
- package/vendored/components/snippets/generated/HeaderAPI.tsx +0 -44
- package/vendored/components/snippets/generated/PlansAvailable.tsx +0 -53
- 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"
|
|
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 ( 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
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
43
|
-
const zoomDisabled = noZoom || isMobile;
|
|
39
|
+
useBodyScrollLock(isZoomed);
|
|
44
40
|
|
|
45
|
-
const handleClick = () => {
|
|
46
|
-
|
|
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
|
-
|
|
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} ${
|
|
67
|
+
className={`${className} ${noZoom ? '' : 'md:cursor-zoom-in'}`}
|
|
63
68
|
style={style}
|
|
64
|
-
loading={
|
|
69
|
+
loading={effectiveLoading}
|
|
65
70
|
decoding={decoding || 'async'}
|
|
71
|
+
fetchPriority={effectivePriority}
|
|
66
72
|
onClick={handleClick}
|
|
67
73
|
/>
|
|
68
|
-
|
|
69
|
-
{/* Zoom Modal
|
|
70
|
-
{isZoomed &&
|
|
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
|
-
|
|
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, '"');
|
|
771
|
-
//
|
|
772
|
-
|
|
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 `<
|
|
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
|
|
133
|
-
//
|
|
134
|
-
|
|
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
|
|
package/vendored/lib/seo.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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=
|
|
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()) {
|