jamdesk 1.1.29 → 1.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/deps.js +3 -3
- package/package.json +3 -3
- package/vendored/app/[[...slug]]/page.tsx +42 -101
- package/vendored/app/api/assets/[...path]/route.ts +2 -2
- package/vendored/app/api/chat/[project]/route.ts +76 -35
- package/vendored/app/api/isr-health/route.ts +2 -3
- package/vendored/app/layout.tsx +2 -0
- package/vendored/components/JdReadySentinel.tsx +25 -0
- package/vendored/components/navigation/Breadcrumb.tsx +2 -2
- package/vendored/components/navigation/Header.tsx +23 -17
- package/vendored/components/navigation/LanguageSelector.tsx +7 -4
- package/vendored/components/navigation/Sidebar.tsx +28 -37
- package/vendored/components/navigation/TabsNav.tsx +1 -1
- package/vendored/hooks/useChat.ts +113 -60
- package/vendored/hooks/useDelayedNavigationSpinner.ts +94 -0
- package/vendored/hooks/useTextStreamPacer.ts +152 -0
- package/vendored/lib/chat-prompt.ts +5 -3
- package/vendored/lib/docs-types.ts +4 -0
- package/vendored/lib/find-first-nav-page.ts +40 -0
- package/vendored/lib/hedge-strip.ts +29 -0
- package/vendored/lib/middleware-helpers.ts +2 -1
- package/vendored/lib/openapi/lang-spec-path.ts +16 -0
- package/vendored/lib/page-isr-helpers.ts +4 -1
- package/vendored/lib/public-paths-resolver.ts +3 -42
- package/vendored/lib/ui-strings.ts +54 -0
- package/vendored/schema/docs-schema.json +15 -0
- package/vendored/themes/jam/variables.css +7 -7
- package/vendored/themes/nebula/variables.css +7 -7
- package/vendored/themes/pulsar/variables.css +7 -7
- package/vendored/workspace-package-lock.json +18 -18
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_DELAY_MS = 500;
|
|
6
|
+
|
|
7
|
+
export interface UseDelayedNavigationSpinnerResult {
|
|
8
|
+
/** Path of the link that was just clicked — set immediately, used for optimistic active highlight. */
|
|
9
|
+
pendingPathname: string | null;
|
|
10
|
+
/** Path of the link that has been "pending" longer than the delay threshold — drives the spinner UI. */
|
|
11
|
+
spinnerPathname: string | null;
|
|
12
|
+
/** Call from the link's onClick handler with the link's href. */
|
|
13
|
+
onNavigate: (url: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Two-stage navigation feedback:
|
|
18
|
+
* 1. Click → `pendingPathname` set immediately (optimistic active highlight).
|
|
19
|
+
* 2. After `delayMs` (default 500ms) → `spinnerPathname` set, signaling
|
|
20
|
+
* "this navigation is taking a while, show a spinner."
|
|
21
|
+
*
|
|
22
|
+
* Both clear when the live `pathname` matches the pending one (navigation
|
|
23
|
+
* resolved) or when the user clicks a different link (the new click cancels
|
|
24
|
+
* the old timer and starts fresh).
|
|
25
|
+
*
|
|
26
|
+
* Same-page hash clicks (`/current#anchor`) are ignored — they don't cause
|
|
27
|
+
* a real navigation, so the timer would never get cleared by `pathname` change.
|
|
28
|
+
*
|
|
29
|
+
* Double-clicks on the same pending link are no-ops — the in-flight timer keeps
|
|
30
|
+
* running so the spinner appears 500ms after the FIRST click, not the second.
|
|
31
|
+
*
|
|
32
|
+
* `onNavigate` has stable identity across pathname changes (live values are read
|
|
33
|
+
* from refs, not closure-captured) so it doesn't bust `React.memo` on the
|
|
34
|
+
* hundreds of `<NavPage>` instances rendered in large docs sites.
|
|
35
|
+
*/
|
|
36
|
+
export function useDelayedNavigationSpinner(
|
|
37
|
+
pathname: string | null,
|
|
38
|
+
delayMs: number = DEFAULT_DELAY_MS,
|
|
39
|
+
): UseDelayedNavigationSpinnerResult {
|
|
40
|
+
const [pendingPathname, setPendingPathname] = useState<string | null>(null);
|
|
41
|
+
const [spinnerPathname, setSpinnerPathname] = useState<string | null>(null);
|
|
42
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
43
|
+
const pathnameRef = useRef(pathname);
|
|
44
|
+
const pendingRef = useRef<string | null>(null);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
pathnameRef.current = pathname;
|
|
48
|
+
}, [pathname]);
|
|
49
|
+
|
|
50
|
+
const clearTimer = useCallback(() => {
|
|
51
|
+
if (timerRef.current !== null) {
|
|
52
|
+
clearTimeout(timerRef.current);
|
|
53
|
+
timerRef.current = null;
|
|
54
|
+
}
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const onNavigate = useCallback((url: string) => {
|
|
58
|
+
const path = url.split('#')[0];
|
|
59
|
+
|
|
60
|
+
// Same-page hash click — no real navigation, just clear any leftover state.
|
|
61
|
+
if (path === pathnameRef.current) {
|
|
62
|
+
clearTimer();
|
|
63
|
+
setPendingPathname(null);
|
|
64
|
+
setSpinnerPathname(null);
|
|
65
|
+
pendingRef.current = null;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Double-click on the same pending link — let the existing timer run.
|
|
70
|
+
if (path === pendingRef.current) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
clearTimer();
|
|
75
|
+
setSpinnerPathname(null);
|
|
76
|
+
setPendingPathname(path);
|
|
77
|
+
pendingRef.current = path;
|
|
78
|
+
timerRef.current = setTimeout(() => {
|
|
79
|
+
setSpinnerPathname(path);
|
|
80
|
+
timerRef.current = null;
|
|
81
|
+
}, delayMs);
|
|
82
|
+
}, [clearTimer, delayMs]);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
clearTimer();
|
|
86
|
+
setPendingPathname(null);
|
|
87
|
+
setSpinnerPathname(null);
|
|
88
|
+
pendingRef.current = null;
|
|
89
|
+
}, [pathname, clearTimer]);
|
|
90
|
+
|
|
91
|
+
useEffect(() => () => clearTimer(), [clearTimer]);
|
|
92
|
+
|
|
93
|
+
return { pendingPathname, spinnerPathname, onNavigate };
|
|
94
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate-limited text reveal pacer for streaming chat responses.
|
|
3
|
+
*
|
|
4
|
+
* Backend SSE deltas often arrive clumped (Haiku generates short answers
|
|
5
|
+
* fast; CDN/middleware sometimes buffers tiny frames together). Without
|
|
6
|
+
* pacing, the React message state updates in one big jump and the user
|
|
7
|
+
* perceives "the response just appeared". This pacer holds incoming text
|
|
8
|
+
* in a buffer and drains it at a bounded rate via requestAnimationFrame.
|
|
9
|
+
*
|
|
10
|
+
* The `now` option exists for deterministic tests: vitest fake timers
|
|
11
|
+
* do NOT fake `performance.now` unless `toFake: ['performance']` is
|
|
12
|
+
* passed, and we want tests that don't reach into that global.
|
|
13
|
+
*/
|
|
14
|
+
export interface TextStreamPacerOptions {
|
|
15
|
+
/** Called each frame with newly-revealed text (never the full accumulated string). */
|
|
16
|
+
onReveal: (chunk: string) => void;
|
|
17
|
+
/** Called once after `finish()` has emitted the last buffered characters,
|
|
18
|
+
* or synchronously after `finish()` under reduced-motion. */
|
|
19
|
+
onDrain?: () => void;
|
|
20
|
+
/** Baseline reveal rate. Default 80 chars/sec (~comfortable "writing" speed). */
|
|
21
|
+
baseCharsPerSecond?: number;
|
|
22
|
+
/** Max reveal rate while catching up. Default 400 chars/sec (~2x fast-talker read speed). */
|
|
23
|
+
maxCharsPerSecond?: number;
|
|
24
|
+
/** Buffer length at which we switch from base to max rate. Default 200. */
|
|
25
|
+
catchUpThreshold?: number;
|
|
26
|
+
/** Buffer length at which finish() dumps the whole buffer at once. Default 800.
|
|
27
|
+
* Only applies post-finish — ensures long responses don't take >2s of reveal
|
|
28
|
+
* time after the stream completes. Arriving bursts mid-stream pace normally. */
|
|
29
|
+
instantFlushThreshold?: number;
|
|
30
|
+
/** If true, pacer flushes immediately on every `enqueue` and `finish` fires
|
|
31
|
+
* `onDrain` synchronously. Callers own the `prefers-reduced-motion` check —
|
|
32
|
+
* typically via `useMediaQuery('(prefers-reduced-motion: reduce)')`. Default false. */
|
|
33
|
+
reducedMotion?: boolean;
|
|
34
|
+
/** Clock function. Default `performance.now`. Injectable for tests. */
|
|
35
|
+
now?: () => number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TextStreamPacer {
|
|
39
|
+
/** Add text to the buffer. RAF loop resumes on first enqueue after idle. */
|
|
40
|
+
enqueue(text: string): void;
|
|
41
|
+
/** Signal stream complete — drain remaining buffer at max rate, then fire onDrain. */
|
|
42
|
+
finish(): void;
|
|
43
|
+
/** Stop pacing immediately. Buffered text is discarded; onDrain does not fire. */
|
|
44
|
+
abort(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createTextStreamPacer(options: TextStreamPacerOptions): TextStreamPacer {
|
|
48
|
+
const {
|
|
49
|
+
onReveal,
|
|
50
|
+
onDrain,
|
|
51
|
+
baseCharsPerSecond = 80,
|
|
52
|
+
maxCharsPerSecond = 400,
|
|
53
|
+
catchUpThreshold = 200,
|
|
54
|
+
instantFlushThreshold = 800,
|
|
55
|
+
reducedMotion = false,
|
|
56
|
+
now = () => performance.now(),
|
|
57
|
+
} = options;
|
|
58
|
+
|
|
59
|
+
let buffer = '';
|
|
60
|
+
let finished = false;
|
|
61
|
+
let aborted = false;
|
|
62
|
+
let rafId: number | null = null;
|
|
63
|
+
let lastFrameMs: number | null = null;
|
|
64
|
+
let fractionalChars = 0;
|
|
65
|
+
|
|
66
|
+
const flushBuffer = () => {
|
|
67
|
+
if (buffer.length === 0) return;
|
|
68
|
+
const chunk = buffer;
|
|
69
|
+
buffer = '';
|
|
70
|
+
onReveal(chunk);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const tick = () => {
|
|
74
|
+
rafId = null;
|
|
75
|
+
if (aborted) return;
|
|
76
|
+
|
|
77
|
+
const t = now();
|
|
78
|
+
const elapsedMs = lastFrameMs === null ? 16 : t - lastFrameMs;
|
|
79
|
+
lastFrameMs = t;
|
|
80
|
+
|
|
81
|
+
// Instant flush only applies post-completion — it bounds drain time after
|
|
82
|
+
// finish(), but must not short-circuit pacing while the stream is still
|
|
83
|
+
// arriving (otherwise a fast Haiku burst would appear in one flash).
|
|
84
|
+
if (finished && buffer.length >= instantFlushThreshold) {
|
|
85
|
+
flushBuffer();
|
|
86
|
+
fractionalChars = 0;
|
|
87
|
+
} else {
|
|
88
|
+
// Rate is driven purely by buffer length — don't boost on `finished`,
|
|
89
|
+
// or short responses whose buffer never hits catchUpThreshold would
|
|
90
|
+
// drain in one frame and defeat the typing-pace effect.
|
|
91
|
+
const rate = buffer.length > catchUpThreshold
|
|
92
|
+
? maxCharsPerSecond
|
|
93
|
+
: baseCharsPerSecond;
|
|
94
|
+
|
|
95
|
+
fractionalChars += (rate * elapsedMs) / 1000;
|
|
96
|
+
const reveal = Math.min(Math.floor(fractionalChars), buffer.length);
|
|
97
|
+
if (reveal > 0) {
|
|
98
|
+
fractionalChars -= reveal;
|
|
99
|
+
const chunk = buffer.slice(0, reveal);
|
|
100
|
+
buffer = buffer.slice(reveal);
|
|
101
|
+
onReveal(chunk);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (buffer.length > 0) {
|
|
106
|
+
schedule();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Drained.
|
|
111
|
+
lastFrameMs = null;
|
|
112
|
+
fractionalChars = 0;
|
|
113
|
+
if (finished) {
|
|
114
|
+
finished = false; // one-shot
|
|
115
|
+
onDrain?.();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const schedule = () => {
|
|
120
|
+
if (rafId !== null || aborted) return;
|
|
121
|
+
rafId = requestAnimationFrame(tick);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
enqueue(text) {
|
|
126
|
+
if (aborted || !text) return;
|
|
127
|
+
if (reducedMotion) {
|
|
128
|
+
onReveal(text);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
buffer += text;
|
|
132
|
+
schedule();
|
|
133
|
+
},
|
|
134
|
+
finish() {
|
|
135
|
+
if (aborted) return;
|
|
136
|
+
if (reducedMotion) {
|
|
137
|
+
onDrain?.();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
finished = true;
|
|
141
|
+
schedule();
|
|
142
|
+
},
|
|
143
|
+
abort() {
|
|
144
|
+
aborted = true;
|
|
145
|
+
buffer = '';
|
|
146
|
+
if (rafId !== null) {
|
|
147
|
+
cancelAnimationFrame(rafId);
|
|
148
|
+
rafId = null;
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -98,11 +98,13 @@ Rules:
|
|
|
98
98
|
- Be concise and direct.
|
|
99
99
|
- Use markdown formatting, including code blocks with language hints when showing code.
|
|
100
100
|
- Never make up information not in the context.
|
|
101
|
-
-
|
|
102
|
-
(a)
|
|
103
|
-
(b)
|
|
101
|
+
- Pick one of exactly two response shapes, based on whether any page in the Documentation context below is relevant to the question:
|
|
102
|
+
(a) RELEVANT PAGE EXISTS → answer by referring to it with a markdown link (e.g., "See [Changelog](.../changelog) for recent releases."). Do NOT prefix with "I don't have information".
|
|
103
|
+
(b) NO RELEVANT PAGE → respond with this exact sentence and stop: "I don't have information about that in the documentation." Do not add "However", "but", "you could try", or any follow-up suggestion after it. The reply ends at the period.
|
|
104
|
+
WRONG: "I don't have information about that in the documentation. However, if you're asking about X, you could..." ← This is forbidden. If the first sentence is true, stop there.
|
|
104
105
|
- Never name a page ("the Changelog", "the API reference", "the Settings page", etc.) unless that page appears in the Documentation context below.
|
|
105
106
|
- Refer to this site as "${projectName}" or just "the documentation". Never expose internal project slugs, repo names, or hostnames (e.g. do not say "jamdesk-docs", "acme-docs", or "<slug>.jamdesk.app") even if they appear in source URLs.
|
|
107
|
+
- RESOLVE IMPLICIT SUBJECTS: when the user's question has no explicit subject (pronouns like "it", "this", "they", or bare noun phrases like "the product", "pricing", "the API"), assume they are asking about ${projectName} unless the conversation clearly establishes a different subject. For example, "how much does it cost" means "how much does ${projectName} cost"; "is it open source" means "is ${projectName} open source". Resolve the reference and answer from the documentation context — do not hedge just because the pronoun wasn't spelled out.
|
|
106
108
|
- When the context includes API endpoints (e.g. "POST /analytics/post") or technical operations, proactively include a short code example showing the request (HTTP method, endpoint, JSON body) even if the user didn't explicitly ask. If the context has no code-relevant information, skip the example.
|
|
107
109
|
- Use the pageSlug values (shown in the context as [pageSlug: ...]) when populating cited_page_slugs — not the page title.
|
|
108
110
|
|
|
@@ -394,6 +394,8 @@ export interface NavigationConfig {
|
|
|
394
394
|
*/
|
|
395
395
|
export interface NavbarLink {
|
|
396
396
|
label: string;
|
|
397
|
+
/** Optional per-language overrides (e.g. `{ "fr": "Blog", "es": "Blog" }`). Falls back to `label`. */
|
|
398
|
+
labels?: Record<string, string>;
|
|
397
399
|
icon?: IconConfig;
|
|
398
400
|
href: string;
|
|
399
401
|
}
|
|
@@ -404,6 +406,8 @@ export interface NavbarLink {
|
|
|
404
406
|
export interface NavbarPrimary {
|
|
405
407
|
type: 'button' | 'github';
|
|
406
408
|
label?: string;
|
|
409
|
+
/** Optional per-language overrides. Falls back to `label` or the built-in default. */
|
|
410
|
+
labels?: Record<string, string>;
|
|
407
411
|
href: string;
|
|
408
412
|
}
|
|
409
413
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk the navigation tree and return the slug of the first leaf page.
|
|
3
|
+
* Used to resolve empty-root and bare-language URLs (`/`, `/fr`, `/es`) to
|
|
4
|
+
* a concrete page in place, without emitting a redirect. Returns null if no
|
|
5
|
+
* leaf page is found.
|
|
6
|
+
*/
|
|
7
|
+
export function findFirstNavPage(nav: unknown): string | null {
|
|
8
|
+
if (!nav || typeof nav !== 'object') return null;
|
|
9
|
+
const node = nav as Record<string, unknown>;
|
|
10
|
+
const pages = node.pages;
|
|
11
|
+
|
|
12
|
+
if (Array.isArray(pages)) {
|
|
13
|
+
for (const p of pages) {
|
|
14
|
+
if (typeof p === 'string' && p.length > 0) return '/' + p;
|
|
15
|
+
if (p && typeof p === 'object') {
|
|
16
|
+
const pageVal = (p as Record<string, unknown>).page;
|
|
17
|
+
if (typeof pageVal === 'string' && pageVal.length > 0) {
|
|
18
|
+
return '/' + pageVal;
|
|
19
|
+
}
|
|
20
|
+
const nested = findFirstNavPage(p);
|
|
21
|
+
if (nested) return nested;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// A group is an atomic container — exhaust its pages rather than
|
|
25
|
+
// falling through to the sibling-key loop below.
|
|
26
|
+
if ('group' in node) return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const key of ['groups', 'tabs', 'anchors', 'versions', 'languages']) {
|
|
30
|
+
const arr = node[key];
|
|
31
|
+
if (Array.isArray(arr)) {
|
|
32
|
+
for (const item of arr) {
|
|
33
|
+
const nested = findFirstNavPage(item);
|
|
34
|
+
if (nested) return nested;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects and truncates Claude's canned no-answer sentence
|
|
3
|
+
* ("I don't have information about that in the documentation.") when followed
|
|
4
|
+
* by a self-contradicting "However..." — a prompt-adherence failure mode of
|
|
5
|
+
* Haiku 4.5. Tolerates curly-vs-straight apostrophes and surrounding markdown
|
|
6
|
+
* emphasis.
|
|
7
|
+
*
|
|
8
|
+
* ASSUMPTION: the sentence is reserved for the no-answer reply. A docs page
|
|
9
|
+
* that legitimately contains the phrase would have content silently truncated.
|
|
10
|
+
*/
|
|
11
|
+
export const HEDGE_SENTENCE = "I don't have information about that in the documentation.";
|
|
12
|
+
|
|
13
|
+
const HEDGE_REGEX = /I\s+don['\u2018\u2019]t\s+have\s+information\s+about\s+that\s+in\s+the\s+documentation\./i;
|
|
14
|
+
|
|
15
|
+
export function stripHedge(markdown: string): string {
|
|
16
|
+
if (!markdown) return markdown;
|
|
17
|
+
const match = HEDGE_REGEX.exec(markdown);
|
|
18
|
+
if (!match) return markdown;
|
|
19
|
+
return markdown.slice(0, match.index + match[0].length);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when the reply is (or contains) the canned no-answer sentence.
|
|
24
|
+
* Used to suppress misleading citation fallbacks on hedge replies.
|
|
25
|
+
*/
|
|
26
|
+
export function isHedgeReply(markdown: string): boolean {
|
|
27
|
+
const stripped = stripHedge(markdown);
|
|
28
|
+
return stripped.length !== markdown.length || stripped.trim() === HEDGE_SENTENCE;
|
|
29
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { log } from './logger';
|
|
9
|
+
import { isIsrMode } from './page-isr-helpers';
|
|
9
10
|
import {
|
|
10
11
|
resolveProjectFromHostname,
|
|
11
12
|
resolveCustomDomain,
|
|
@@ -83,7 +84,7 @@ export async function handleProjectResolution(
|
|
|
83
84
|
const hostname = request.headers.get('host') || '';
|
|
84
85
|
|
|
85
86
|
// Skip if not in ISR mode
|
|
86
|
-
if (
|
|
87
|
+
if (!isIsrMode()) {
|
|
87
88
|
return { projectSlug: null, hostAtDocs: true, skip: true };
|
|
88
89
|
}
|
|
89
90
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function langSpecPath(specPath: string, lang: string): string {
|
|
2
|
+
const lastSlash = specPath.lastIndexOf('/');
|
|
3
|
+
const fileName = specPath.slice(lastSlash + 1);
|
|
4
|
+
const dot = fileName.lastIndexOf('.');
|
|
5
|
+
if (dot === -1) return `${specPath}.${lang}`;
|
|
6
|
+
const base = fileName.slice(0, dot);
|
|
7
|
+
const ext = fileName.slice(dot);
|
|
8
|
+
const dir = specPath.slice(0, lastSlash + 1);
|
|
9
|
+
return `${dir}${base}.${lang}${ext}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function candidateSpecPaths(specPath: string, lang: string | undefined): string[] {
|
|
13
|
+
if (!lang || lang === 'en') return [specPath];
|
|
14
|
+
if (/^https?:\/\//i.test(specPath)) return [specPath];
|
|
15
|
+
return [langSpecPath(specPath, lang), specPath];
|
|
16
|
+
}
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
* Check if ISR mode is enabled.
|
|
10
10
|
*/
|
|
11
11
|
export function isIsrMode(): boolean {
|
|
12
|
-
|
|
12
|
+
// Trim guards against trailing whitespace/newline picked up when the env
|
|
13
|
+
// var is set via the Vercel UI or piped-stdin CLI — a single stray \n
|
|
14
|
+
// silently disables ISR mode and returns 404 for every project.
|
|
15
|
+
return process.env.ISR_MODE?.trim() === 'true';
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
/**
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { DocsConfig } from './docs-types.js';
|
|
2
2
|
import { matchPublicPath } from './glob-match.js';
|
|
3
|
+
import { findFirstNavPage } from './find-first-nav-page.js';
|
|
4
|
+
|
|
5
|
+
export { findFirstNavPage };
|
|
3
6
|
|
|
4
7
|
interface PageWithFrontmatter {
|
|
5
8
|
slug: string;
|
|
@@ -11,48 +14,6 @@ interface Input {
|
|
|
11
14
|
pagesWithFrontmatter: PageWithFrontmatter[];
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
/**
|
|
15
|
-
* Walk the navigation tree and return the slug of the first leaf page.
|
|
16
|
-
* Matches the resolution used by `app/[[...slug]]/page.tsx:findFirstPage`
|
|
17
|
-
* so specific-pages mode can keep the root path public when the first
|
|
18
|
-
* nav page is public. Returns null if no leaf page is found.
|
|
19
|
-
*/
|
|
20
|
-
export function findFirstNavPage(nav: unknown): string | null {
|
|
21
|
-
if (!nav || typeof nav !== 'object') return null;
|
|
22
|
-
const node = nav as Record<string, unknown>;
|
|
23
|
-
const pages = node.pages;
|
|
24
|
-
|
|
25
|
-
if (Array.isArray(pages)) {
|
|
26
|
-
for (const p of pages) {
|
|
27
|
-
if (typeof p === 'string' && p.length > 0) return '/' + p;
|
|
28
|
-
if (p && typeof p === 'object') {
|
|
29
|
-
const pageVal = (p as Record<string, unknown>).page;
|
|
30
|
-
if (typeof pageVal === 'string' && pageVal.length > 0) {
|
|
31
|
-
return '/' + pageVal;
|
|
32
|
-
}
|
|
33
|
-
const nested = findFirstNavPage(p);
|
|
34
|
-
if (nested) return nested;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
// A group is an atomic container — exhaust its pages rather than
|
|
38
|
-
// falling through to the sibling-key loop below (which is for
|
|
39
|
-
// top-level nav shapes like tabs/anchors/languages, not group contents).
|
|
40
|
-
if ('group' in node) return null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
for (const key of ['groups', 'tabs', 'anchors', 'versions', 'languages']) {
|
|
44
|
-
const arr = node[key];
|
|
45
|
-
if (Array.isArray(arr)) {
|
|
46
|
-
for (const item of arr) {
|
|
47
|
-
const nested = findFirstNavPage(item);
|
|
48
|
-
if (nested) return nested;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
17
|
function collectPublicGroupPages(
|
|
57
18
|
nav: unknown,
|
|
58
19
|
inPublicGroup: boolean = false,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { LanguageCode } from './docs-types';
|
|
2
|
+
|
|
3
|
+
export interface UiStrings {
|
|
4
|
+
search: string;
|
|
5
|
+
askAi: string;
|
|
6
|
+
more: string;
|
|
7
|
+
toggleMenu: string;
|
|
8
|
+
selectLanguage: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const EN: UiStrings = {
|
|
12
|
+
search: 'Search',
|
|
13
|
+
askAi: 'Ask AI',
|
|
14
|
+
more: 'More',
|
|
15
|
+
toggleMenu: 'Toggle menu',
|
|
16
|
+
selectLanguage: 'Select language',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const ZH: Partial<UiStrings> = {
|
|
20
|
+
search: '搜索',
|
|
21
|
+
askAi: '询问 AI',
|
|
22
|
+
more: '更多',
|
|
23
|
+
toggleMenu: '切换菜单',
|
|
24
|
+
selectLanguage: '选择语言',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const OVERRIDES: Partial<Record<LanguageCode, Partial<UiStrings>>> = {
|
|
28
|
+
fr: {
|
|
29
|
+
search: 'Rechercher',
|
|
30
|
+
askAi: "Demander à l'IA",
|
|
31
|
+
more: 'Plus',
|
|
32
|
+
toggleMenu: 'Basculer le menu',
|
|
33
|
+
selectLanguage: 'Choisir la langue',
|
|
34
|
+
},
|
|
35
|
+
es: {
|
|
36
|
+
search: 'Buscar',
|
|
37
|
+
askAi: 'Preguntar a la IA',
|
|
38
|
+
more: 'Más',
|
|
39
|
+
toggleMenu: 'Alternar menú',
|
|
40
|
+
selectLanguage: 'Elegir idioma',
|
|
41
|
+
},
|
|
42
|
+
zh: ZH,
|
|
43
|
+
cn: ZH,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Pre-merge so repeated calls return a stable object reference.
|
|
47
|
+
const MERGED: Partial<Record<LanguageCode, UiStrings>> = {};
|
|
48
|
+
for (const [code, overrides] of Object.entries(OVERRIDES) as [LanguageCode, Partial<UiStrings>][]) {
|
|
49
|
+
MERGED[code] = { ...EN, ...overrides };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getUiStrings(lang: LanguageCode | undefined): UiStrings {
|
|
53
|
+
return (lang && MERGED[lang]) || EN;
|
|
54
|
+
}
|
|
@@ -303,6 +303,11 @@
|
|
|
303
303
|
"label": {
|
|
304
304
|
"type": "string"
|
|
305
305
|
},
|
|
306
|
+
"labels": {
|
|
307
|
+
"type": "object",
|
|
308
|
+
"additionalProperties": { "type": "string" },
|
|
309
|
+
"description": "Per-language label overrides keyed by language code (e.g. fr, es). Falls back to label."
|
|
310
|
+
},
|
|
306
311
|
"icon": {
|
|
307
312
|
"$ref": "#/definitions/iconSchema",
|
|
308
313
|
"description": "Icon to display alongside this navigation item"
|
|
@@ -331,6 +336,11 @@
|
|
|
331
336
|
"label": {
|
|
332
337
|
"type": "string"
|
|
333
338
|
},
|
|
339
|
+
"labels": {
|
|
340
|
+
"type": "object",
|
|
341
|
+
"additionalProperties": { "type": "string" },
|
|
342
|
+
"description": "Per-language label overrides keyed by language code (e.g. fr, es). Falls back to label."
|
|
343
|
+
},
|
|
334
344
|
"href": {
|
|
335
345
|
"$ref": "#/definitions/hrefSchema"
|
|
336
346
|
}
|
|
@@ -349,6 +359,11 @@
|
|
|
349
359
|
"type": "string",
|
|
350
360
|
"const": "github"
|
|
351
361
|
},
|
|
362
|
+
"labels": {
|
|
363
|
+
"type": "object",
|
|
364
|
+
"additionalProperties": { "type": "string" },
|
|
365
|
+
"description": "Per-language label overrides keyed by language code (e.g. fr, es). Falls back to 'GitHub'."
|
|
366
|
+
},
|
|
352
367
|
"href": {
|
|
353
368
|
"$ref": "#/definitions/hrefSchema"
|
|
354
369
|
}
|
|
@@ -782,19 +782,19 @@ body[data-theme="jam"] .prose video {
|
|
|
782
782
|
/* Phase 2: Touch Targets for Sidebar */
|
|
783
783
|
@media (max-width: 1023px) {
|
|
784
784
|
:root {
|
|
785
|
-
--sidebar-item-spacing:
|
|
785
|
+
--sidebar-item-spacing: 3px;
|
|
786
786
|
}
|
|
787
787
|
|
|
788
788
|
.sidebar-scroll ul li a {
|
|
789
|
-
padding-top:
|
|
790
|
-
padding-bottom:
|
|
791
|
-
min-height:
|
|
789
|
+
padding-top: 7px !important;
|
|
790
|
+
padding-bottom: 7px !important;
|
|
791
|
+
min-height: 36px;
|
|
792
792
|
}
|
|
793
793
|
|
|
794
794
|
.sidebar-scroll .nav-group-l1 {
|
|
795
|
-
padding-top:
|
|
796
|
-
padding-bottom:
|
|
797
|
-
min-height:
|
|
795
|
+
padding-top: 7px !important;
|
|
796
|
+
padding-bottom: 7px !important;
|
|
797
|
+
min-height: 36px;
|
|
798
798
|
}
|
|
799
799
|
}
|
|
800
800
|
|
|
@@ -141,19 +141,19 @@
|
|
|
141
141
|
/* Phase 2: Touch Targets for Sidebar */
|
|
142
142
|
@media (max-width: 1023px) {
|
|
143
143
|
:root {
|
|
144
|
-
--sidebar-item-spacing:
|
|
144
|
+
--sidebar-item-spacing: 3px;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
.sidebar-scroll ul li a {
|
|
148
|
-
padding-top:
|
|
149
|
-
padding-bottom:
|
|
150
|
-
min-height:
|
|
148
|
+
padding-top: 7px !important;
|
|
149
|
+
padding-bottom: 7px !important;
|
|
150
|
+
min-height: 36px;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
.sidebar-scroll .nav-group-l1 {
|
|
154
|
-
padding-top:
|
|
155
|
-
padding-bottom:
|
|
156
|
-
min-height:
|
|
154
|
+
padding-top: 7px !important;
|
|
155
|
+
padding-bottom: 7px !important;
|
|
156
|
+
min-height: 36px;
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
@@ -896,19 +896,19 @@ body[data-theme="pulsar"] header [data-theme-toggle] {
|
|
|
896
896
|
/* Phase 3: Touch Targets for Sidebar */
|
|
897
897
|
@media (max-width: 1023px) {
|
|
898
898
|
:root {
|
|
899
|
-
--sidebar-item-spacing:
|
|
899
|
+
--sidebar-item-spacing: 3px;
|
|
900
900
|
}
|
|
901
901
|
|
|
902
902
|
.sidebar-scroll ul li a {
|
|
903
|
-
padding-top:
|
|
904
|
-
padding-bottom:
|
|
905
|
-
min-height:
|
|
903
|
+
padding-top: 7px !important;
|
|
904
|
+
padding-bottom: 7px !important;
|
|
905
|
+
min-height: 36px;
|
|
906
906
|
}
|
|
907
907
|
|
|
908
908
|
.sidebar-scroll .nav-group-l1 {
|
|
909
|
-
padding-top:
|
|
910
|
-
padding-bottom:
|
|
911
|
-
min-height:
|
|
909
|
+
padding-top: 7px !important;
|
|
910
|
+
padding-bottom: 7px !important;
|
|
911
|
+
min-height: 36px;
|
|
912
912
|
}
|
|
913
913
|
}
|
|
914
914
|
|