jamdesk 1.1.90 → 1.1.92
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 +37 -10
- 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 +87 -15
- 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 +22 -7
- package/vendored/lib/shiki-highlighter.ts +1 -1
- package/vendored/lib/snippet-loader-isr.ts +1 -1
- package/vendored/lib/static-artifacts.ts +37 -6
- 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,8 +11,10 @@ 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';
|
|
17
|
+
import { useProjectSlug } from '@/lib/project-slug-context';
|
|
16
18
|
import { getUiStrings } from '@/lib/ui-strings';
|
|
17
19
|
|
|
18
20
|
interface LanguageSelectorProps {
|
|
@@ -34,6 +36,7 @@ export function LanguageSelector({
|
|
|
34
36
|
}: LanguageSelectorProps) {
|
|
35
37
|
const router = useRouter();
|
|
36
38
|
const linkPrefix = useLinkPrefix();
|
|
39
|
+
const projectSlug = useProjectSlug();
|
|
37
40
|
const pathname = usePathname() ?? '';
|
|
38
41
|
const ui = getUiStrings(extractLanguageFromPath(pathname || '/'));
|
|
39
42
|
const [isOpen, setIsOpen] = useState(false);
|
|
@@ -51,15 +54,38 @@ export function LanguageSelector({
|
|
|
51
54
|
const isNavigating = pendingPathname !== null;
|
|
52
55
|
const showSpinner = spinnerPathname !== null;
|
|
53
56
|
|
|
57
|
+
// Dedupe entries that collapse onto the same BCP 47 tag (e.g. customer
|
|
58
|
+
// declared both `cn` and `zh-Hans`, which would render two identical
|
|
59
|
+
// "简体中文" rows). Prefer the entry whose code already IS the BCP 47 form;
|
|
60
|
+
// fall back to the first occurrence. The server-side warning in
|
|
61
|
+
// buildHreflangAlternates is the authoritative signal — this is the UX
|
|
62
|
+
// safety net so users don't see duplicate rows in the meantime.
|
|
63
|
+
const displayedLanguages = (() => {
|
|
64
|
+
const byTag = new Map<string, ResolvedLanguage>();
|
|
65
|
+
for (const lang of languages) {
|
|
66
|
+
const tag = toHreflang(lang.code);
|
|
67
|
+
const existing = byTag.get(tag);
|
|
68
|
+
if (!existing) {
|
|
69
|
+
byTag.set(tag, lang);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (existing.code !== tag && lang.code === tag) byTag.set(tag, lang);
|
|
73
|
+
}
|
|
74
|
+
return [...byTag.values()];
|
|
75
|
+
})();
|
|
76
|
+
|
|
54
77
|
// Find current language
|
|
55
|
-
const currentLanguage =
|
|
78
|
+
const currentLanguage = displayedLanguages.find((l) => l.isActive) || displayedLanguages[0];
|
|
56
79
|
|
|
57
80
|
// Find actual default language from config
|
|
58
|
-
const actualDefault =
|
|
81
|
+
const actualDefault = displayedLanguages.find((l) => l.isDefault)?.code || defaultLanguage;
|
|
59
82
|
|
|
60
|
-
// Check localStorage preference on mount and redirect if needed
|
|
83
|
+
// Check localStorage preference on mount and redirect if needed.
|
|
84
|
+
// Intentionally mount-only: re-running on pathname/language changes would
|
|
85
|
+
// fight the user's in-session navigation choices (they may have explicitly
|
|
86
|
+
// switched languages this session, which we don't want to revert).
|
|
61
87
|
useEffect(() => {
|
|
62
|
-
const savedPref = getLanguagePreference();
|
|
88
|
+
const savedPref = getLanguagePreference(projectSlug);
|
|
63
89
|
if (savedPref && currentLanguage && savedPref !== currentLanguage.code) {
|
|
64
90
|
// Only redirect if on default language URL (no explicit language in path)
|
|
65
91
|
const hasLangInPath = languages.some(
|
|
@@ -68,18 +94,20 @@ export function LanguageSelector({
|
|
|
68
94
|
if (!hasLangInPath) {
|
|
69
95
|
const basePath = transformLanguagePath(
|
|
70
96
|
pathname || '/docs',
|
|
71
|
-
actualDefault,
|
|
72
97
|
savedPref,
|
|
73
98
|
actualDefault
|
|
74
99
|
);
|
|
75
100
|
router.replace(`${linkPrefix}${basePath}`);
|
|
76
101
|
}
|
|
77
102
|
}
|
|
78
|
-
|
|
103
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only by design; see comment above the effect
|
|
104
|
+
}, []);
|
|
79
105
|
|
|
80
106
|
// Handle click outside to close
|
|
81
107
|
useOnClickOutside(containerRef, () => setIsOpen(false), isOpen);
|
|
82
108
|
|
|
109
|
+
// Defined before handleKeyDown so handleKeyDown can include it in its
|
|
110
|
+
// dependency array without TDZ issues.
|
|
83
111
|
const handleSelectLanguage = useCallback(
|
|
84
112
|
(lang: ResolvedLanguage) => {
|
|
85
113
|
// Block double-fire while a previous switch is still navigating —
|
|
@@ -91,11 +119,10 @@ export function LanguageSelector({
|
|
|
91
119
|
return;
|
|
92
120
|
}
|
|
93
121
|
|
|
94
|
-
saveLanguagePreference(lang.code);
|
|
122
|
+
saveLanguagePreference(lang.code, projectSlug);
|
|
95
123
|
|
|
96
124
|
const basePath = transformLanguagePath(
|
|
97
125
|
pathname || '/docs',
|
|
98
|
-
currentLanguage?.code || actualDefault,
|
|
99
126
|
lang.code,
|
|
100
127
|
actualDefault
|
|
101
128
|
);
|
|
@@ -105,7 +132,7 @@ export function LanguageSelector({
|
|
|
105
132
|
onNavigate(url);
|
|
106
133
|
router.push(url);
|
|
107
134
|
},
|
|
108
|
-
[isNavigating, currentLanguage?.code, actualDefault, pathname, linkPrefix, router, onNavigate]
|
|
135
|
+
[isNavigating, currentLanguage?.code, actualDefault, pathname, linkPrefix, router, onNavigate, projectSlug]
|
|
109
136
|
);
|
|
110
137
|
|
|
111
138
|
// Handle keyboard navigation
|
|
@@ -228,7 +255,7 @@ export function LanguageSelector({
|
|
|
228
255
|
`}
|
|
229
256
|
style={{ boxShadow: 'var(--shadow-lg)' }}
|
|
230
257
|
>
|
|
231
|
-
{
|
|
258
|
+
{displayedLanguages.map((lang, index) => (
|
|
232
259
|
<li key={lang.code}>
|
|
233
260
|
<button
|
|
234
261
|
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 {
|
|
@@ -368,17 +419,28 @@ export function resolveLocaleWithLoweredSet(
|
|
|
368
419
|
}
|
|
369
420
|
|
|
370
421
|
/**
|
|
371
|
-
* localStorage key for language
|
|
422
|
+
* localStorage key prefix for project-scoped language preferences. The actual
|
|
423
|
+
* key is `${LANGUAGE_STORAGE_KEY_PREFIX}${slug}`. Scoping by project prevents
|
|
424
|
+
* a `cn` preference saved on Project A from forcing a `/cn/...` redirect on
|
|
425
|
+
* Project B that only declares `zh-Hans` (would 404).
|
|
372
426
|
*/
|
|
373
|
-
export const
|
|
427
|
+
export const LANGUAGE_STORAGE_KEY_PREFIX = 'jamdesk-language-preference:';
|
|
428
|
+
|
|
429
|
+
/** Legacy unscoped key — read-once for one-time migration to per-project storage. */
|
|
430
|
+
const LEGACY_LANGUAGE_STORAGE_KEY = 'jamdesk-language-preference';
|
|
431
|
+
|
|
432
|
+
function storageKey(projectSlug: string): string {
|
|
433
|
+
return `${LANGUAGE_STORAGE_KEY_PREFIX}${projectSlug}`;
|
|
434
|
+
}
|
|
374
435
|
|
|
375
436
|
/**
|
|
376
|
-
* Save language preference to localStorage
|
|
377
|
-
* Handles private browsing mode gracefully
|
|
437
|
+
* Save language preference to localStorage, scoped to project slug.
|
|
438
|
+
* Handles private browsing mode gracefully.
|
|
378
439
|
*/
|
|
379
|
-
export function saveLanguagePreference(code: LanguageCode): void {
|
|
440
|
+
export function saveLanguagePreference(code: LanguageCode, projectSlug: string): void {
|
|
441
|
+
if (!projectSlug) return;
|
|
380
442
|
try {
|
|
381
|
-
localStorage.setItem(
|
|
443
|
+
localStorage.setItem(storageKey(projectSlug), code);
|
|
382
444
|
} catch {
|
|
383
445
|
// localStorage not available (private browsing, etc.)
|
|
384
446
|
// Fail silently
|
|
@@ -386,15 +448,24 @@ export function saveLanguagePreference(code: LanguageCode): void {
|
|
|
386
448
|
}
|
|
387
449
|
|
|
388
450
|
/**
|
|
389
|
-
* Get saved language preference from localStorage
|
|
390
|
-
* Returns undefined if not set or localStorage unavailable
|
|
451
|
+
* Get saved language preference for a project from localStorage.
|
|
452
|
+
* Returns undefined if not set or localStorage unavailable.
|
|
391
453
|
*/
|
|
392
|
-
export function getLanguagePreference(): LanguageCode | undefined {
|
|
454
|
+
export function getLanguagePreference(projectSlug: string): LanguageCode | undefined {
|
|
455
|
+
if (!projectSlug) return undefined;
|
|
393
456
|
try {
|
|
394
|
-
const saved = localStorage.getItem(
|
|
457
|
+
const saved = localStorage.getItem(storageKey(projectSlug));
|
|
395
458
|
if (saved && isValidLanguageCode(saved)) {
|
|
396
459
|
return saved as LanguageCode;
|
|
397
460
|
}
|
|
461
|
+
// One-time read of legacy unscoped key — promotes it to the per-project
|
|
462
|
+
// slot the first time a returning user visits and clears the legacy entry.
|
|
463
|
+
const legacy = localStorage.getItem(LEGACY_LANGUAGE_STORAGE_KEY);
|
|
464
|
+
if (legacy && isValidLanguageCode(legacy)) {
|
|
465
|
+
localStorage.setItem(storageKey(projectSlug), legacy);
|
|
466
|
+
localStorage.removeItem(LEGACY_LANGUAGE_STORAGE_KEY);
|
|
467
|
+
return legacy as LanguageCode;
|
|
468
|
+
}
|
|
398
469
|
} catch {
|
|
399
470
|
// localStorage not available
|
|
400
471
|
}
|
|
@@ -402,11 +473,12 @@ export function getLanguagePreference(): LanguageCode | undefined {
|
|
|
402
473
|
}
|
|
403
474
|
|
|
404
475
|
/**
|
|
405
|
-
* Clear saved language preference
|
|
476
|
+
* Clear saved language preference for a project.
|
|
406
477
|
*/
|
|
407
|
-
export function clearLanguagePreference(): void {
|
|
478
|
+
export function clearLanguagePreference(projectSlug: string): void {
|
|
479
|
+
if (!projectSlug) return;
|
|
408
480
|
try {
|
|
409
|
-
localStorage.removeItem(
|
|
481
|
+
localStorage.removeItem(storageKey(projectSlug));
|
|
410
482
|
} catch {
|
|
411
483
|
// localStorage not available
|
|
412
484
|
}
|
|
@@ -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
|
);
|