jamdesk 1.1.90 → 1.1.91
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/package.json +1 -1
- package/vendored/app/api/r2/[project]/[...path]/route.ts +14 -9
- package/vendored/app/layout.tsx +2 -2
- package/vendored/components/HtmlLangSync.tsx +3 -2
- package/vendored/components/mdx/Accordion.tsx +1 -1
- package/vendored/components/mdx/Card.tsx +1 -1
- package/vendored/components/mdx/CodeGroup.tsx +18 -23
- package/vendored/components/mdx/Color.tsx +0 -1
- package/vendored/components/mdx/Icon.tsx +1 -1
- package/vendored/components/mdx/MDXComponents.tsx +92 -66
- package/vendored/components/mdx/OpenApiEndpoint.tsx +0 -1
- package/vendored/components/mdx/ParamField.tsx +0 -1
- package/vendored/components/mdx/RequestExample.tsx +0 -1
- package/vendored/components/mdx/ResponseExample.tsx +0 -1
- package/vendored/components/mdx/Steps.tsx +12 -3
- package/vendored/components/mdx/Table.tsx +8 -2
- package/vendored/components/mdx/Tabs.tsx +1 -1
- package/vendored/components/mdx/Tree.tsx +6 -4
- package/vendored/components/navigation/Header.tsx +7 -5
- package/vendored/components/navigation/LanguageSelector.tsx +32 -7
- package/vendored/components/navigation/TableOfContents.tsx +1 -1
- package/vendored/components/navigation/TabsNav.tsx +17 -5
- package/vendored/components/search/SearchModal.tsx +41 -36
- package/vendored/components/ui/CodePanel.tsx +2 -2
- package/vendored/hooks/useChat.ts +1 -1
- package/vendored/hooks/useShikiHighlight.ts +7 -1
- package/vendored/lib/code-utils.ts +6 -2
- package/vendored/lib/health-checks.ts +2 -2
- package/vendored/lib/language-utils.ts +53 -2
- package/vendored/lib/layout-helpers.tsx +2 -1
- package/vendored/lib/mdx-inline-components.ts +1 -1
- package/vendored/lib/navigation-resolver.ts +0 -69
- package/vendored/lib/normalize-config.ts +1 -1
- package/vendored/lib/openapi/generator.ts +3 -3
- package/vendored/lib/openapi/parser.ts +14 -6
- package/vendored/lib/openapi/validator.ts +2 -2
- package/vendored/lib/openapi-isr.ts +4 -1
- package/vendored/lib/public-paths-resolver.ts +7 -6
- package/vendored/lib/redis.ts +2 -2
- package/vendored/lib/rehype-code-meta.ts +2 -2
- package/vendored/lib/render-doc-page.tsx +2 -2
- package/vendored/lib/seo.ts +21 -6
- package/vendored/lib/shiki-highlighter.ts +1 -1
- package/vendored/lib/snippet-loader-isr.ts +1 -1
- package/vendored/lib/validate-config.ts +1 -0
- package/vendored/workspace-package-lock.json +10 -10
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useMemo, useRef } from 'react';
|
|
3
|
+
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
4
4
|
import { useOnClickOutside } from '@/hooks/useOnClickOutside';
|
|
5
5
|
import Link from 'next/link';
|
|
6
6
|
import { usePathname } from 'next/navigation';
|
|
@@ -95,8 +95,10 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
95
95
|
return resolveNavigation(config, pathname);
|
|
96
96
|
}, [config, pathname, hasTabs, layout, hasMultipleLanguages]);
|
|
97
97
|
|
|
98
|
-
// Simple check if current path belongs to a tab (without full navigation resolution)
|
|
99
|
-
|
|
98
|
+
// Simple check if current path belongs to a tab (without full navigation resolution).
|
|
99
|
+
// Pure over its arguments — wrapped in useCallback so it's stable for the
|
|
100
|
+
// tab list useMemo below.
|
|
101
|
+
const pathBelongsToTab = useCallback((tab: typeof navigationTabs[0], currentPath: string): boolean => {
|
|
100
102
|
const path = currentPath.replace(/^\/docs\/?/, '');
|
|
101
103
|
|
|
102
104
|
// Check tab's groups
|
|
@@ -124,7 +126,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
return false;
|
|
127
|
-
};
|
|
129
|
+
}, []);
|
|
128
130
|
|
|
129
131
|
// Build tabs data for header
|
|
130
132
|
const headerTabs = useMemo(() => {
|
|
@@ -175,7 +177,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
175
177
|
isExternal: false,
|
|
176
178
|
};
|
|
177
179
|
});
|
|
178
|
-
}, [navigationTabs, resolved, hasTabs, pathname, linkPrefix]);
|
|
180
|
+
}, [navigationTabs, resolved, hasTabs, pathname, linkPrefix, pathBelongsToTab]);
|
|
179
181
|
|
|
180
182
|
// Detect dark mode for logo switching
|
|
181
183
|
useEffect(() => {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
saveLanguagePreference,
|
|
12
12
|
getLanguagePreference,
|
|
13
13
|
extractLanguageFromPath,
|
|
14
|
+
toHreflang,
|
|
14
15
|
} from '@/lib/language-utils';
|
|
15
16
|
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
16
17
|
import { getUiStrings } from '@/lib/ui-strings';
|
|
@@ -51,13 +52,36 @@ export function LanguageSelector({
|
|
|
51
52
|
const isNavigating = pendingPathname !== null;
|
|
52
53
|
const showSpinner = spinnerPathname !== null;
|
|
53
54
|
|
|
55
|
+
// Dedupe entries that collapse onto the same BCP 47 tag (e.g. customer
|
|
56
|
+
// declared both `cn` and `zh-Hans`, which would render two identical
|
|
57
|
+
// "简体中文" rows). Prefer the entry whose code already IS the BCP 47 form;
|
|
58
|
+
// fall back to the first occurrence. The server-side warning in
|
|
59
|
+
// buildHreflangAlternates is the authoritative signal — this is the UX
|
|
60
|
+
// safety net so users don't see duplicate rows in the meantime.
|
|
61
|
+
const displayedLanguages = (() => {
|
|
62
|
+
const byTag = new Map<string, ResolvedLanguage>();
|
|
63
|
+
for (const lang of languages) {
|
|
64
|
+
const tag = toHreflang(lang.code);
|
|
65
|
+
const existing = byTag.get(tag);
|
|
66
|
+
if (!existing) {
|
|
67
|
+
byTag.set(tag, lang);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (existing.code !== tag && lang.code === tag) byTag.set(tag, lang);
|
|
71
|
+
}
|
|
72
|
+
return [...byTag.values()];
|
|
73
|
+
})();
|
|
74
|
+
|
|
54
75
|
// Find current language
|
|
55
|
-
const currentLanguage =
|
|
76
|
+
const currentLanguage = displayedLanguages.find((l) => l.isActive) || displayedLanguages[0];
|
|
56
77
|
|
|
57
78
|
// Find actual default language from config
|
|
58
|
-
const actualDefault =
|
|
79
|
+
const actualDefault = displayedLanguages.find((l) => l.isDefault)?.code || defaultLanguage;
|
|
59
80
|
|
|
60
|
-
// Check localStorage preference on mount and redirect if needed
|
|
81
|
+
// Check localStorage preference on mount and redirect if needed.
|
|
82
|
+
// Intentionally mount-only: re-running on pathname/language changes would
|
|
83
|
+
// fight the user's in-session navigation choices (they may have explicitly
|
|
84
|
+
// switched languages this session, which we don't want to revert).
|
|
61
85
|
useEffect(() => {
|
|
62
86
|
const savedPref = getLanguagePreference();
|
|
63
87
|
if (savedPref && currentLanguage && savedPref !== currentLanguage.code) {
|
|
@@ -68,18 +92,20 @@ export function LanguageSelector({
|
|
|
68
92
|
if (!hasLangInPath) {
|
|
69
93
|
const basePath = transformLanguagePath(
|
|
70
94
|
pathname || '/docs',
|
|
71
|
-
actualDefault,
|
|
72
95
|
savedPref,
|
|
73
96
|
actualDefault
|
|
74
97
|
);
|
|
75
98
|
router.replace(`${linkPrefix}${basePath}`);
|
|
76
99
|
}
|
|
77
100
|
}
|
|
78
|
-
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only by design; see comment above the effect
|
|
102
|
+
}, []);
|
|
79
103
|
|
|
80
104
|
// Handle click outside to close
|
|
81
105
|
useOnClickOutside(containerRef, () => setIsOpen(false), isOpen);
|
|
82
106
|
|
|
107
|
+
// Defined before handleKeyDown so handleKeyDown can include it in its
|
|
108
|
+
// dependency array without TDZ issues.
|
|
83
109
|
const handleSelectLanguage = useCallback(
|
|
84
110
|
(lang: ResolvedLanguage) => {
|
|
85
111
|
// Block double-fire while a previous switch is still navigating —
|
|
@@ -95,7 +121,6 @@ export function LanguageSelector({
|
|
|
95
121
|
|
|
96
122
|
const basePath = transformLanguagePath(
|
|
97
123
|
pathname || '/docs',
|
|
98
|
-
currentLanguage?.code || actualDefault,
|
|
99
124
|
lang.code,
|
|
100
125
|
actualDefault
|
|
101
126
|
);
|
|
@@ -228,7 +253,7 @@ export function LanguageSelector({
|
|
|
228
253
|
`}
|
|
229
254
|
style={{ boxShadow: 'var(--shadow-lg)' }}
|
|
230
255
|
>
|
|
231
|
-
{
|
|
256
|
+
{displayedLanguages.map((lang, index) => (
|
|
232
257
|
<li key={lang.code}>
|
|
233
258
|
<button
|
|
234
259
|
role="option"
|
|
@@ -494,7 +494,7 @@ export function TableOfContents({ content, className = '' }: TableOfContentsProp
|
|
|
494
494
|
return () => {
|
|
495
495
|
scrollTarget.removeEventListener('scroll', computeActive);
|
|
496
496
|
};
|
|
497
|
-
|
|
497
|
+
|
|
498
498
|
}, [headings, isHidden]);
|
|
499
499
|
|
|
500
500
|
if (headings.length === 0 || isHidden) {
|
|
@@ -4,7 +4,12 @@ import { useMemo } from 'react';
|
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { usePathname } from 'next/navigation';
|
|
6
6
|
// Icons use Font Awesome CSS classes for lightweight rendering
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
DocsConfig,
|
|
9
|
+
GroupConfig,
|
|
10
|
+
NavigationPage,
|
|
11
|
+
TabsPosition,
|
|
12
|
+
} from '@/lib/docs-types';
|
|
8
13
|
import { resolveNavigation } from '@/lib/navigation-resolver';
|
|
9
14
|
import { getIconClass } from '@/lib/icon-utils';
|
|
10
15
|
import { getTheme } from '@/themes';
|
|
@@ -87,14 +92,21 @@ export function TabsNav({ config, className = '' }: TabsNavProps) {
|
|
|
87
92
|
const currentPath = pathname.replace(/^\/docs\/?/, '').replace(/^\//, '');
|
|
88
93
|
let isActive = false;
|
|
89
94
|
|
|
90
|
-
const checkPages = (pages:
|
|
95
|
+
const checkPages = (pages: (NavigationPage | GroupConfig)[]): boolean => {
|
|
91
96
|
for (const page of pages) {
|
|
92
|
-
const pagePath =
|
|
93
|
-
|
|
97
|
+
const pagePath =
|
|
98
|
+
typeof page === 'string'
|
|
99
|
+
? page
|
|
100
|
+
: 'page' in page
|
|
101
|
+
? page.page
|
|
102
|
+
: 'group' in page
|
|
103
|
+
? page.group
|
|
104
|
+
: undefined;
|
|
105
|
+
if (pagePath && (pagePath === currentPath || currentPath.startsWith(pagePath + '/'))) {
|
|
94
106
|
return true;
|
|
95
107
|
}
|
|
96
108
|
// Check nested groups
|
|
97
|
-
if (page.pages) {
|
|
109
|
+
if (typeof page !== 'string' && 'pages' in page && page.pages) {
|
|
98
110
|
if (checkPages(page.pages)) return true;
|
|
99
111
|
}
|
|
100
112
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState, useRef, type ReactElement } from 'react';
|
|
3
|
+
import { useEffect, useState, useRef, useCallback, type ReactElement } from 'react';
|
|
4
4
|
import { usePathname, useRouter } from 'next/navigation';
|
|
5
5
|
import { getRecentSearches, addRecentSearch, clearRecentSearches } from '@/lib/recent-searches';
|
|
6
6
|
import { useFocusTrap } from '@/hooks/useFocusTrap';
|
|
@@ -237,6 +237,45 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
237
237
|
el?.scrollIntoView({ block: 'nearest' });
|
|
238
238
|
}, [selectedIndex, results.length]);
|
|
239
239
|
|
|
240
|
+
// Click handler for a result row. Defined before the keyboard-navigation
|
|
241
|
+
// effect so it can be a stable dep without TDZ issues.
|
|
242
|
+
const handleResultClick = useCallback(
|
|
243
|
+
(result: SearchResult, index: number) => {
|
|
244
|
+
if (query.trim()) {
|
|
245
|
+
// Track search_query event (the search itself)
|
|
246
|
+
trackSearch(projectSlug, {
|
|
247
|
+
type: 'search_query',
|
|
248
|
+
query: query.trim(),
|
|
249
|
+
resultsCount: results.length,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Track search_click event (the result they clicked)
|
|
253
|
+
trackSearch(projectSlug, {
|
|
254
|
+
type: 'search_click',
|
|
255
|
+
query: query.trim(),
|
|
256
|
+
resultsCount: results.length,
|
|
257
|
+
clickedResult: {
|
|
258
|
+
slug: result.slug,
|
|
259
|
+
position: index + 1, // 1-indexed for analytics
|
|
260
|
+
title: result.title,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
hasTrackedRef.current = true; // Mark as tracked to avoid duplicate on modal close
|
|
264
|
+
|
|
265
|
+
// Save to recent searches
|
|
266
|
+
addRecentSearch(projectSlug, query.trim());
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const url = `${linkPrefix}/${result.slug}${result.section ? `#${result.section.toLowerCase().replace(/\s+/g, '-')}` : ''}`;
|
|
270
|
+
onNavigate?.(url);
|
|
271
|
+
router.push(url);
|
|
272
|
+
onClose();
|
|
273
|
+
setQuery('');
|
|
274
|
+
setResults([]);
|
|
275
|
+
},
|
|
276
|
+
[query, projectSlug, results, linkPrefix, onNavigate, router, onClose]
|
|
277
|
+
);
|
|
278
|
+
|
|
240
279
|
// Keyboard navigation
|
|
241
280
|
useEffect(() => {
|
|
242
281
|
if (!isOpen) return;
|
|
@@ -297,41 +336,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
297
336
|
|
|
298
337
|
document.addEventListener('keydown', handleKeyDown);
|
|
299
338
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
300
|
-
}, [isOpen, query, recentSearches, results, selectedIndex, effectivePopularPages, popularIndex, router, onClose, onNavigate, projectSlug]);
|
|
301
|
-
|
|
302
|
-
const handleResultClick = (result: SearchResult, index: number) => {
|
|
303
|
-
if (query.trim()) {
|
|
304
|
-
// Track search_query event (the search itself)
|
|
305
|
-
trackSearch(projectSlug, {
|
|
306
|
-
type: 'search_query',
|
|
307
|
-
query: query.trim(),
|
|
308
|
-
resultsCount: results.length,
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
// Track search_click event (the result they clicked)
|
|
312
|
-
trackSearch(projectSlug, {
|
|
313
|
-
type: 'search_click',
|
|
314
|
-
query: query.trim(),
|
|
315
|
-
resultsCount: results.length,
|
|
316
|
-
clickedResult: {
|
|
317
|
-
slug: result.slug,
|
|
318
|
-
position: index + 1, // 1-indexed for analytics
|
|
319
|
-
title: result.title,
|
|
320
|
-
},
|
|
321
|
-
});
|
|
322
|
-
hasTrackedRef.current = true; // Mark as tracked to avoid duplicate on modal close
|
|
323
|
-
|
|
324
|
-
// Save to recent searches
|
|
325
|
-
addRecentSearch(projectSlug, query.trim());
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const url = `${linkPrefix}/${result.slug}${result.section ? `#${result.section.toLowerCase().replace(/\s+/g, '-')}` : ''}`;
|
|
329
|
-
onNavigate?.(url);
|
|
330
|
-
router.push(url);
|
|
331
|
-
onClose();
|
|
332
|
-
setQuery('');
|
|
333
|
-
setResults([]);
|
|
334
|
-
};
|
|
339
|
+
}, [isOpen, query, recentSearches, results, selectedIndex, effectivePopularPages, popularIndex, router, onClose, onNavigate, projectSlug, handleResultClick, isSearching, linkPrefix]);
|
|
335
340
|
|
|
336
341
|
const handleClearRecentSearches = () => {
|
|
337
342
|
clearRecentSearches(projectSlug);
|
|
@@ -83,12 +83,12 @@ export function extractTextContent(node: ReactNode): string {
|
|
|
83
83
|
if (!node) return '';
|
|
84
84
|
|
|
85
85
|
if (isValidElement(node)) {
|
|
86
|
-
const props = node.props as
|
|
86
|
+
const props = node.props as { children?: ReactNode };
|
|
87
87
|
// If it's a pre element, get the code element's children
|
|
88
88
|
if (node.type === 'pre') {
|
|
89
89
|
const codeElement = props?.children;
|
|
90
90
|
if (isValidElement(codeElement)) {
|
|
91
|
-
const codeProps = codeElement.props as
|
|
91
|
+
const codeProps = codeElement.props as { children?: ReactNode };
|
|
92
92
|
return extractTextContent(codeProps?.children);
|
|
93
93
|
}
|
|
94
94
|
}
|
|
@@ -105,7 +105,7 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
105
105
|
useEffect(() => {
|
|
106
106
|
const lastUser = messagesRef.current.filter(m => m.role === 'user').at(-1);
|
|
107
107
|
if (lastUser) lastUserMessageRef.current = lastUser.content;
|
|
108
|
-
|
|
108
|
+
|
|
109
109
|
}, []);
|
|
110
110
|
|
|
111
111
|
/** Unlock the input: the request is done (or errored out). The assistant
|
|
@@ -57,6 +57,11 @@ export function useShikiHighlightMultiple(
|
|
|
57
57
|
);
|
|
58
58
|
const [isLoading, setIsLoading] = useState(true);
|
|
59
59
|
|
|
60
|
+
// Serialize items for the effect dep array. Using a stable string key
|
|
61
|
+
// avoids re-running the highlighter on every parent render that produces
|
|
62
|
+
// a new (but value-equal) `items` array.
|
|
63
|
+
const itemsKey = JSON.stringify(items);
|
|
64
|
+
|
|
60
65
|
useEffect(() => {
|
|
61
66
|
let cancelled = false;
|
|
62
67
|
|
|
@@ -85,7 +90,8 @@ export function useShikiHighlightMultiple(
|
|
|
85
90
|
return () => {
|
|
86
91
|
cancelled = true;
|
|
87
92
|
};
|
|
88
|
-
|
|
93
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- itemsKey is the stable serialization of items; using items directly would re-fire on every parent render
|
|
94
|
+
}, [itemsKey]);
|
|
89
95
|
|
|
90
96
|
return { results, isLoading };
|
|
91
97
|
}
|
|
@@ -34,9 +34,13 @@ export function formatLanguage(lang: string): string {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
* Helper for accessing React element props
|
|
38
|
-
*
|
|
37
|
+
* Helper for accessing React element props.
|
|
38
|
+
* Many MDX call sites read .className/.children with string ops without
|
|
39
|
+
* narrowing first; widening the value type to `unknown` would force a
|
|
40
|
+
* type-narrow at every site. Keep `any` here only.
|
|
39
41
|
*/
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- MDX consumer call sites do unchecked string ops on prop values; narrowing here would require touching dozens of components
|
|
40
43
|
export function getElementProps(element: ReactElement): Record<string, any> {
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- see function-level comment
|
|
41
45
|
return (element.props || {}) as Record<string, any>;
|
|
42
46
|
}
|
|
@@ -45,7 +45,7 @@ export async function checkMemoryHealth(): Promise<MemoryHealth> {
|
|
|
45
45
|
percentUsed: stats.peakPercentage !== null ? Math.round(stats.peakPercentage) : null,
|
|
46
46
|
available,
|
|
47
47
|
};
|
|
48
|
-
} catch
|
|
48
|
+
} catch {
|
|
49
49
|
return {
|
|
50
50
|
status: 'critical',
|
|
51
51
|
heapUsedMB: 0,
|
|
@@ -119,7 +119,7 @@ export async function checkDiskHealth(path: string): Promise<DiskHealth> {
|
|
|
119
119
|
percentUsed,
|
|
120
120
|
available: isUnlimited ? `${available} (tmpfs)` : available,
|
|
121
121
|
};
|
|
122
|
-
} catch
|
|
122
|
+
} catch {
|
|
123
123
|
return {
|
|
124
124
|
status: 'critical',
|
|
125
125
|
path,
|
|
@@ -199,6 +199,59 @@ export const LANGUAGE_FLAGS: Record<LanguageCode, string> = {
|
|
|
199
199
|
he: '🇮🇱',
|
|
200
200
|
};
|
|
201
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Maps internal LanguageCode values to valid IETF BCP 47 tags for use in
|
|
204
|
+
* `<link rel="alternate" hreflang>` and `<html lang>` attributes.
|
|
205
|
+
*
|
|
206
|
+
* Why: Customer projects use directory-aligned codes like `cn/`, `jp/` that
|
|
207
|
+
* are first-class LanguageCode values for internal routing, but `cn` and `jp`
|
|
208
|
+
* are not valid BCP 47 language tags. Google silently drops alternates with
|
|
209
|
+
* invalid tags, so the Chinese (or `jp`-named Japanese) page gets no hreflang
|
|
210
|
+
* benefit. Remap at emit time to keep file layouts stable.
|
|
211
|
+
*
|
|
212
|
+
* One-way map only — never add the canonical form (e.g. `'zh-Hans': 'cn'`)
|
|
213
|
+
* as a key; downstream callers assume the output is BCP 47-valid.
|
|
214
|
+
*/
|
|
215
|
+
const HREFLANG_REMAP: Partial<Record<LanguageCode, string>> = {
|
|
216
|
+
cn: 'zh-Hans', // not a BCP 47 tag; Chinese Simplified is zh-Hans
|
|
217
|
+
jp: 'ja', // not a BCP 47 tag; Japanese is ja
|
|
218
|
+
'ja-jp': 'ja-JP', // lowercase region subtag is not valid BCP 47
|
|
219
|
+
'fr-ca': 'fr-CA', // lowercase region subtag is not valid BCP 47
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Convert an internal LanguageCode to a valid BCP 47 tag for SEO emission.
|
|
224
|
+
* Codes not in HREFLANG_REMAP pass through unchanged.
|
|
225
|
+
*/
|
|
226
|
+
export function toHreflang(code: LanguageCode): string {
|
|
227
|
+
return HREFLANG_REMAP[code] ?? code;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Detect customer-config errors where two distinct LanguageCode entries
|
|
232
|
+
* collapse to the same BCP 47 tag after toHreflang — e.g. declaring both
|
|
233
|
+
* `cn` and `zh-Hans`, or both `ja` and `jp`. When this happens, the
|
|
234
|
+
* second entry silently overwrites the first in the hreflang alternates
|
|
235
|
+
* map, dropping a translation from search-engine discovery.
|
|
236
|
+
*
|
|
237
|
+
* Returns one entry per colliding tag, listing the internal codes that
|
|
238
|
+
* collapsed onto it. Empty array means no collision.
|
|
239
|
+
*/
|
|
240
|
+
export function findHreflangAliasCollisions(
|
|
241
|
+
codes: LanguageCode[]
|
|
242
|
+
): Array<{ tag: string; codes: LanguageCode[] }> {
|
|
243
|
+
const byTag = new Map<string, LanguageCode[]>();
|
|
244
|
+
for (const code of codes) {
|
|
245
|
+
const tag = toHreflang(code);
|
|
246
|
+
const existing = byTag.get(tag);
|
|
247
|
+
if (existing) existing.push(code);
|
|
248
|
+
else byTag.set(tag, [code]);
|
|
249
|
+
}
|
|
250
|
+
return [...byTag.entries()]
|
|
251
|
+
.filter(([, group]) => group.length > 1)
|
|
252
|
+
.map(([tag, group]) => ({ tag, codes: group }));
|
|
253
|
+
}
|
|
254
|
+
|
|
202
255
|
/**
|
|
203
256
|
* RTL (Right-to-Left) languages
|
|
204
257
|
*/
|
|
@@ -246,14 +299,12 @@ export function getLanguageDisplayInfo(code: LanguageCode): LanguageDisplayInfo
|
|
|
246
299
|
* - transformPath('/docs/introduction', 'en', 'es') → '/es/introduction' (strips /docs prefix)
|
|
247
300
|
*
|
|
248
301
|
* @param currentPath - Current pathname
|
|
249
|
-
* @param fromLang - Current language code (or 'en' for default)
|
|
250
302
|
* @param toLang - Target language code
|
|
251
303
|
* @param defaultLang - The default language (typically 'en')
|
|
252
304
|
* @returns Transformed path for the target language (relative to app root)
|
|
253
305
|
*/
|
|
254
306
|
export function transformLanguagePath(
|
|
255
307
|
currentPath: string,
|
|
256
|
-
fromLang: LanguageCode,
|
|
257
308
|
toLang: LanguageCode,
|
|
258
309
|
defaultLang: LanguageCode = 'en'
|
|
259
310
|
): string {
|
|
@@ -26,6 +26,7 @@ import { LinkPrefixProvider } from '@/lib/link-prefix-context';
|
|
|
26
26
|
import { ProjectSlugProvider } from '@/lib/project-slug-context';
|
|
27
27
|
import { getAnalyticsScript } from '@/lib/analytics-script';
|
|
28
28
|
import { fetchCustomCss, fetchCustomJs } from '@/lib/r2-content';
|
|
29
|
+
import { toHreflang } from '@/lib/language-utils';
|
|
29
30
|
|
|
30
31
|
const scrollLockBootstrap = `
|
|
31
32
|
(function() {
|
|
@@ -314,7 +315,7 @@ export async function DocsChrome({
|
|
|
314
315
|
preload('/_jd/fonts/fontawesome/webfonts/fa-brands-400.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' });
|
|
315
316
|
|
|
316
317
|
return (
|
|
317
|
-
<html lang={lang} dir={dir} suppressHydrationWarning data-scroll-behavior="smooth" data-scroll-locked="true">
|
|
318
|
+
<html lang={toHreflang(lang)} dir={dir} suppressHydrationWarning data-scroll-behavior="smooth" data-scroll-locked="true">
|
|
318
319
|
<head>
|
|
319
320
|
{/*
|
|
320
321
|
SSR scroll lock — prevents Chrome's same-tab cross-origin "preserve
|
|
@@ -121,7 +121,7 @@ export function compileInlineComponents(
|
|
|
121
121
|
// IMPORTANT: React hooks are intentionally NOT provided here. Inline components
|
|
122
122
|
// are server-rendered during Next.js static export, and hooks only work client-side.
|
|
123
123
|
// Users who need hooks should use snippet files with 'use client' directive instead.
|
|
124
|
-
|
|
124
|
+
|
|
125
125
|
const createComponent = new Function(
|
|
126
126
|
'React',
|
|
127
127
|
'_jsx',
|
|
@@ -9,11 +9,9 @@
|
|
|
9
9
|
|
|
10
10
|
import type {
|
|
11
11
|
DocsConfig,
|
|
12
|
-
NavigationConfig,
|
|
13
12
|
NavigationPage,
|
|
14
13
|
NavigationPageObject,
|
|
15
14
|
GroupConfig,
|
|
16
|
-
AnchorConfig,
|
|
17
15
|
TabConfig,
|
|
18
16
|
LanguageCode,
|
|
19
17
|
LanguageConfig,
|
|
@@ -228,50 +226,6 @@ function resolveGroup(group: GroupConfig): ResolvedGroup {
|
|
|
228
226
|
};
|
|
229
227
|
}
|
|
230
228
|
|
|
231
|
-
/**
|
|
232
|
-
* Resolve an anchor configuration
|
|
233
|
-
*/
|
|
234
|
-
function resolveAnchor(anchor: AnchorConfig): ResolvedAnchor {
|
|
235
|
-
const groups: ResolvedGroup[] = [];
|
|
236
|
-
|
|
237
|
-
// If anchor has groups, resolve them
|
|
238
|
-
if (anchor.groups) {
|
|
239
|
-
for (const group of anchor.groups) {
|
|
240
|
-
groups.push(resolveGroup(group));
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// If anchor has pages directly, create a default group
|
|
245
|
-
if (anchor.pages && anchor.pages.length > 0) {
|
|
246
|
-
const { resolvedPages, nestedGroups, items } = resolvePages(anchor.pages);
|
|
247
|
-
if (resolvedPages.length > 0 || nestedGroups.length > 0) {
|
|
248
|
-
groups.push({
|
|
249
|
-
name: '', // No group name for direct pages
|
|
250
|
-
pages: resolvedPages,
|
|
251
|
-
nested: nestedGroups.length > 0 ? nestedGroups : undefined,
|
|
252
|
-
items: items.length > 0 ? items : undefined,
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// If anchor has tabs, we need to resolve those too
|
|
258
|
-
// For now, we flatten tabs into groups
|
|
259
|
-
if (anchor.tabs) {
|
|
260
|
-
for (const tab of anchor.tabs) {
|
|
261
|
-
const tabGroups = resolveTabGroups(tab);
|
|
262
|
-
groups.push(...tabGroups);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
return {
|
|
267
|
-
name: anchor.anchor,
|
|
268
|
-
icon: getIconName(anchor.icon),
|
|
269
|
-
href: anchor.href,
|
|
270
|
-
isExternal: !!anchor.href && !anchor.groups && !anchor.pages && !anchor.tabs,
|
|
271
|
-
groups,
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
|
|
275
229
|
/**
|
|
276
230
|
* Resolve groups from a tab configuration
|
|
277
231
|
*/
|
|
@@ -311,29 +265,6 @@ function resolveTab(tab: TabConfig): ResolvedTab {
|
|
|
311
265
|
};
|
|
312
266
|
}
|
|
313
267
|
|
|
314
|
-
/**
|
|
315
|
-
* Find active anchor based on pathname
|
|
316
|
-
*/
|
|
317
|
-
function findActiveAnchor(anchors: ResolvedAnchor[], pathname: string): string | undefined {
|
|
318
|
-
// Remove /docs prefix and leading slash to match page paths (stored without leading slash)
|
|
319
|
-
const path = pathname.replace(/^\/docs\/?/, '').replace(/^\//, '');
|
|
320
|
-
|
|
321
|
-
// Find anchor whose groups contain a page matching the path
|
|
322
|
-
for (const anchor of anchors) {
|
|
323
|
-
if (anchor.isExternal) continue;
|
|
324
|
-
|
|
325
|
-
for (const group of anchor.groups) {
|
|
326
|
-
if (groupContainsPath(group, path)) {
|
|
327
|
-
return anchor.name;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Default to first non-external anchor
|
|
333
|
-
const firstAnchor = anchors.find(a => !a.isExternal);
|
|
334
|
-
return firstAnchor?.name;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
268
|
/**
|
|
338
269
|
* Find active tab based on pathname
|
|
339
270
|
*/
|
|
@@ -84,7 +84,7 @@ export function normalizeConfig(config: DocsConfigInput): NormalizeResult {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
// 4. Warn about navbar.style
|
|
87
|
-
if ((rest.navbar as
|
|
87
|
+
if ((rest.navbar as { style?: unknown } | undefined)?.style) {
|
|
88
88
|
warnings.push(
|
|
89
89
|
'navbar.style is ignored. All navbars use the default style.'
|
|
90
90
|
);
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* Supports generating pages from navigation openapi field.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
9
|
-
import type { GeneratedPage,
|
|
8
|
+
import type { OpenAPIV3 } from 'openapi-types';
|
|
9
|
+
import type { GeneratedPage, OpenApiEndpointData } from './types';
|
|
10
10
|
import { parseEndpoint, getAllEndpoints, getSpecInfo } from './parser';
|
|
11
11
|
import { getCachedSpec } from './cache';
|
|
12
12
|
|
|
@@ -94,7 +94,7 @@ export async function generatePagesFromSpec(
|
|
|
94
94
|
|
|
95
95
|
for (const { method, path, operation, tags } of endpoints) {
|
|
96
96
|
// Skip hidden endpoints (x-hidden extension)
|
|
97
|
-
if ((operation as
|
|
97
|
+
if ((operation as { 'x-hidden'?: unknown })['x-hidden']) {
|
|
98
98
|
continue;
|
|
99
99
|
}
|
|
100
100
|
|
|
@@ -193,8 +193,9 @@ function parseServers(api: OpenAPI.Document): ServerInfo[] {
|
|
|
193
193
|
|
|
194
194
|
// Swagger 2.0
|
|
195
195
|
if ('host' in api && typeof api.host === 'string') {
|
|
196
|
-
const
|
|
197
|
-
const
|
|
196
|
+
const swagger2 = api as { schemes?: string[]; basePath?: string };
|
|
197
|
+
const scheme = swagger2.schemes?.[0] || 'https';
|
|
198
|
+
const basePath = swagger2.basePath || '';
|
|
198
199
|
return [{
|
|
199
200
|
url: `${scheme}://${api.host}${basePath}`,
|
|
200
201
|
}];
|
|
@@ -229,9 +230,16 @@ function parseSecuritySchemes(api: OpenAPI.Document): Map<string, SecurityRequir
|
|
|
229
230
|
|
|
230
231
|
// Swagger 2.0
|
|
231
232
|
if ('securityDefinitions' in api) {
|
|
232
|
-
|
|
233
|
+
type Swagger2SecurityScheme = {
|
|
234
|
+
type: SecurityRequirement['type'];
|
|
235
|
+
scheme?: string;
|
|
236
|
+
in?: SecurityRequirement['in'];
|
|
237
|
+
name?: string;
|
|
238
|
+
};
|
|
239
|
+
const defs = (api as { securityDefinitions?: Record<string, Swagger2SecurityScheme> })
|
|
240
|
+
.securityDefinitions;
|
|
233
241
|
for (const [name, scheme] of Object.entries(defs || {})) {
|
|
234
|
-
const s = scheme as
|
|
242
|
+
const s = scheme as Swagger2SecurityScheme;
|
|
235
243
|
schemes.set(name, {
|
|
236
244
|
name,
|
|
237
245
|
type: s.type,
|
|
@@ -319,7 +327,7 @@ function parseRequestBody(
|
|
|
319
327
|
content[mediaType] = {
|
|
320
328
|
schema: toJsonSchema(mediaObj.schema),
|
|
321
329
|
example: mediaObj.example,
|
|
322
|
-
examples: mediaObj.examples as
|
|
330
|
+
examples: mediaObj.examples as Record<string, { value: unknown; summary?: string }> | undefined,
|
|
323
331
|
};
|
|
324
332
|
}
|
|
325
333
|
|
|
@@ -346,7 +354,7 @@ function parseResponses(
|
|
|
346
354
|
content[mediaType] = {
|
|
347
355
|
schema: toJsonSchema(mediaObj.schema),
|
|
348
356
|
example: mediaObj.example,
|
|
349
|
-
examples: mediaObj.examples as
|
|
357
|
+
examples: mediaObj.examples as Record<string, { value: unknown; summary?: string }> | undefined,
|
|
350
358
|
};
|
|
351
359
|
}
|
|
352
360
|
|
|
@@ -41,12 +41,12 @@ function checkDuplicateOperationIds(
|
|
|
41
41
|
specPath: string
|
|
42
42
|
): OpenApiValidationError | null {
|
|
43
43
|
const seen = new Map<string, string>(); // operationId → "METHOD /path"
|
|
44
|
-
const paths = (api as
|
|
44
|
+
const paths = (api as { paths?: Record<string, unknown> }).paths || {};
|
|
45
45
|
const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
46
46
|
|
|
47
47
|
for (const [pathStr, pathItem] of Object.entries(paths)) {
|
|
48
48
|
for (const method of methods) {
|
|
49
|
-
const operation = (pathItem as Record<string,
|
|
49
|
+
const operation = (pathItem as Record<string, { operationId?: string } | undefined> | undefined)?.[method];
|
|
50
50
|
if (!operation?.operationId) continue;
|
|
51
51
|
|
|
52
52
|
const id = operation.operationId;
|
|
@@ -69,7 +69,10 @@ export async function fetchOpenApiSpecFromR2(
|
|
|
69
69
|
const isYaml = /\.ya?ml$/i.test(specPath);
|
|
70
70
|
const raw = isYaml ? yaml.load(content) : JSON.parse(content);
|
|
71
71
|
|
|
72
|
-
// Dereference all $ref pointers (matching static mode's SwaggerParser.validate behavior)
|
|
72
|
+
// Dereference all $ref pointers (matching static mode's SwaggerParser.validate behavior).
|
|
73
|
+
// SwaggerParser.dereference accepts a loose Document type that does not line up with
|
|
74
|
+
// our internal OpenApiSpec; intermediate `any` cast is required at this library boundary.
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SwaggerParser type interop (see CLAUDE.md memory)
|
|
73
76
|
const spec = await SwaggerParser.dereference(raw as any) as unknown as OpenApiSpec;
|
|
74
77
|
|
|
75
78
|
// Cache it
|