jamdesk 1.1.35 → 1.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/integration/init.integration.test.js +41 -0
- package/dist/__tests__/integration/init.integration.test.js.map +1 -1
- package/dist/__tests__/unit/docs-config.test.js +17 -0
- package/dist/__tests__/unit/docs-config.test.js.map +1 -1
- package/dist/__tests__/unit/init.test.js +2 -1
- package/dist/__tests__/unit/init.test.js.map +1 -1
- package/dist/lib/docs-config.d.ts +4 -1
- package/dist/lib/docs-config.d.ts.map +1 -1
- package/dist/lib/docs-config.js +27 -23
- package/dist/lib/docs-config.js.map +1 -1
- package/package.json +1 -1
- package/templates/api-reference/openapi-example.mdx +55 -0
- package/templates/api-reference/request-response-examples.mdx +210 -0
- package/templates/docs.json +27 -0
- package/templates/openapi/example-api.yaml +185 -0
- package/vendored/app/[[...slug]]/page.tsx +26 -8
- package/vendored/app/api/chat/[project]/route.ts +53 -3
- package/vendored/app/api/docs-search/[project]/search/route.ts +83 -3
- package/vendored/app/layout.tsx +26 -3
- package/vendored/components/HtmlLangSync.tsx +38 -0
- package/vendored/components/mdx/OpenApiEndpoint.tsx +2 -1
- package/vendored/components/navigation/LanguageSelector.tsx +18 -21
- package/vendored/components/navigation/TableOfContents.tsx +18 -3
- package/vendored/components/search/SearchModal.tsx +7 -14
- package/vendored/hooks/useChat.ts +22 -4
- package/vendored/lib/chat-prompt.ts +1 -1
- package/vendored/lib/chat-tools.ts +3 -0
- package/vendored/lib/embedding-chunker.ts +18 -2
- package/vendored/lib/language-codes.json +27 -0
- package/vendored/lib/language-utils.ts +98 -6
- package/vendored/lib/link-rewriter.ts +67 -0
- package/vendored/lib/locale-helpers.ts +62 -0
- package/vendored/lib/middleware-helpers.ts +57 -2
- package/vendored/lib/openapi/code-examples.ts +5 -6
- package/vendored/lib/openapi/derive-auth.ts +46 -0
- package/vendored/lib/openapi/index.ts +7 -0
- package/vendored/lib/openapi/parser.ts +7 -2
- package/vendored/lib/openapi/resolve-server-url.ts +14 -0
- package/vendored/lib/openapi/types.ts +2 -0
- package/vendored/lib/page-isr-helpers.ts +20 -0
- package/vendored/lib/path-safety.ts +96 -0
- package/vendored/lib/search-client.ts +67 -10
- package/vendored/lib/seo.ts +80 -13
- package/vendored/lib/static-artifacts.ts +25 -1
- package/vendored/lib/vector-store.ts +70 -17
- package/vendored/scripts/build-search-index.cjs +59 -0
- package/vendored/scripts/validate-links.cjs +21 -66
- package/vendored/themes/base.css +5 -0
- package/vendored/workspace-package-lock.json +16 -16
|
@@ -164,28 +164,25 @@ export function LanguageSelector({
|
|
|
164
164
|
${compact ? 'px-2 py-1.5' : 'px-3 py-2 w-full'}
|
|
165
165
|
`}
|
|
166
166
|
>
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
aria-hidden="true"
|
|
180
|
-
/>
|
|
181
|
-
</>
|
|
182
|
-
)}
|
|
183
|
-
{compact && (
|
|
184
|
-
<i
|
|
185
|
-
className={`fa-solid fa-chevron-down h-3 w-3 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
|
186
|
-
aria-hidden="true"
|
|
187
|
-
/>
|
|
167
|
+
<i
|
|
168
|
+
className="fa-solid fa-globe text-[12px] text-[var(--color-text-tertiary)]"
|
|
169
|
+
aria-hidden="true"
|
|
170
|
+
/>
|
|
171
|
+
{compact ? (
|
|
172
|
+
<span className="text-xs font-semibold tracking-wide">
|
|
173
|
+
{currentLanguage?.code.split('-')[0].toUpperCase()}
|
|
174
|
+
</span>
|
|
175
|
+
) : (
|
|
176
|
+
<span className="flex-1 text-left text-sm font-medium truncate">
|
|
177
|
+
{currentLanguage?.displayName}
|
|
178
|
+
</span>
|
|
188
179
|
)}
|
|
180
|
+
<i
|
|
181
|
+
className={`fa-solid fa-chevron-${position} text-[10px] text-[var(--color-text-tertiary)] transition-transform ${
|
|
182
|
+
isOpen ? 'rotate-180' : ''
|
|
183
|
+
}`}
|
|
184
|
+
aria-hidden="true"
|
|
185
|
+
/>
|
|
189
186
|
</button>
|
|
190
187
|
|
|
191
188
|
{/* Dropdown menu */}
|
|
@@ -360,13 +360,20 @@ export function TableOfContents({ content, className = '' }: TableOfContentsProp
|
|
|
360
360
|
: window.innerHeight;
|
|
361
361
|
const viewBottom = viewTop + viewHeight;
|
|
362
362
|
|
|
363
|
-
//
|
|
363
|
+
// `offsetTop` is relative to `offsetParent`, so for elements inside a
|
|
364
|
+
// positioned ancestor (e.g. <Step> inside <Steps className="relative">)
|
|
365
|
+
// it bears no relation to the scroll container. Use bounding-rect math
|
|
366
|
+
// so positions sit in the container's content coordinate frame
|
|
367
|
+
// regardless of the offsetParent chain.
|
|
368
|
+
const containerTop = contentContainer
|
|
369
|
+
? contentContainer.getBoundingClientRect().top
|
|
370
|
+
: 0;
|
|
364
371
|
const positions: { id: string; top: number }[] = [];
|
|
365
372
|
for (const h of headings) {
|
|
366
373
|
const el = document.getElementById(h.id);
|
|
367
374
|
if (!el) continue;
|
|
368
375
|
const top = contentContainer
|
|
369
|
-
? el.
|
|
376
|
+
? el.getBoundingClientRect().top - containerTop + contentContainer.scrollTop
|
|
370
377
|
: el.getBoundingClientRect().top + window.scrollY;
|
|
371
378
|
positions.push({ id: h.id, top });
|
|
372
379
|
}
|
|
@@ -398,7 +405,15 @@ export function TableOfContents({ content, className = '' }: TableOfContentsProp
|
|
|
398
405
|
}
|
|
399
406
|
}
|
|
400
407
|
|
|
401
|
-
|
|
408
|
+
const next = active.length > 0 ? active : [headings[0].id];
|
|
409
|
+
// Skip state update when the active set hasn't changed — otherwise
|
|
410
|
+
// every scroll tick allocates a new array and cascades re-renders
|
|
411
|
+
// through updateThumb (which reads layout per active anchor).
|
|
412
|
+
setActiveAnchors(prev =>
|
|
413
|
+
prev.length === next.length && prev.every((id, i) => id === next[i])
|
|
414
|
+
? prev
|
|
415
|
+
: next,
|
|
416
|
+
);
|
|
402
417
|
};
|
|
403
418
|
|
|
404
419
|
computeActive();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState, useRef, type ReactElement } from 'react';
|
|
4
|
-
import { useRouter } from 'next/navigation';
|
|
4
|
+
import { useRouter, usePathname } from 'next/navigation';
|
|
5
5
|
import { getRecentSearches, addRecentSearch, clearRecentSearches } from '@/lib/recent-searches';
|
|
6
6
|
import { useFocusTrap } from '@/hooks/useFocusTrap';
|
|
7
7
|
import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';
|
|
@@ -9,16 +9,7 @@ import { getSuggestions } from '@/lib/search-suggestions';
|
|
|
9
9
|
import { trackSearch } from '@/lib/analytics-client';
|
|
10
10
|
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
11
11
|
import { useProjectSlug } from '@/lib/project-slug-context';
|
|
12
|
-
|
|
13
|
-
interface SearchResult {
|
|
14
|
-
id: string;
|
|
15
|
-
title: string;
|
|
16
|
-
description?: string;
|
|
17
|
-
content: string;
|
|
18
|
-
slug: string;
|
|
19
|
-
section?: string;
|
|
20
|
-
type?: 'api' | 'component' | 'guide' | 'help' | 'quickstart';
|
|
21
|
-
}
|
|
12
|
+
import type { SearchResult } from '@/lib/search-client';
|
|
22
13
|
|
|
23
14
|
interface PopularPage {
|
|
24
15
|
title: string;
|
|
@@ -102,6 +93,7 @@ function NoResultsState({ query, onSuggestionClick }: { query: string; onSuggest
|
|
|
102
93
|
export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: SearchModalProps) {
|
|
103
94
|
const linkPrefix = useLinkPrefix();
|
|
104
95
|
const projectSlug = useProjectSlug();
|
|
96
|
+
const pathname = usePathname() ?? '';
|
|
105
97
|
const [query, setQuery] = useState('');
|
|
106
98
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
107
99
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
@@ -210,8 +202,9 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
210
202
|
|
|
211
203
|
const timer = setTimeout(async () => {
|
|
212
204
|
try {
|
|
213
|
-
const { search } = await import('@/lib/search-client');
|
|
214
|
-
const
|
|
205
|
+
const { search, resolveActiveLocale } = await import('@/lib/search-client');
|
|
206
|
+
const activeLocale = resolveActiveLocale(pathname);
|
|
207
|
+
const searchResults = await search(query, 15, activeLocale);
|
|
215
208
|
if (!cancelled) {
|
|
216
209
|
setResults(searchResults);
|
|
217
210
|
setSelectedIndex(0);
|
|
@@ -235,7 +228,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
235
228
|
cancelled = true;
|
|
236
229
|
clearTimeout(timer);
|
|
237
230
|
};
|
|
238
|
-
}, [query]);
|
|
231
|
+
}, [query, pathname]);
|
|
239
232
|
|
|
240
233
|
// Scroll selected result into view
|
|
241
234
|
useEffect(() => {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
4
4
|
import { createTextStreamPacer, type TextStreamPacer } from './useTextStreamPacer';
|
|
5
5
|
import { useMediaQuery } from './useMediaQuery';
|
|
6
|
+
import { extractLanguageFromPath } from '@/lib/language-utils';
|
|
6
7
|
|
|
7
8
|
export interface Citation {
|
|
8
9
|
title: string;
|
|
@@ -120,13 +121,13 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
120
121
|
const markRevealComplete = useCallback(
|
|
121
122
|
(
|
|
122
123
|
assistantId: string,
|
|
123
|
-
pending: { citations?: Citation[]; clarificationOptions?: string[] },
|
|
124
|
+
pending: { citations?: Citation[]; clarificationOptions?: string[]; replacedContent?: string },
|
|
124
125
|
): void => {
|
|
125
126
|
setMessages((prev) => {
|
|
126
127
|
const idx = prev.findIndex((m) => m.id === assistantId);
|
|
127
128
|
if (idx === -1) return prev;
|
|
128
129
|
const msg = prev[idx];
|
|
129
|
-
if (!msg.content) {
|
|
130
|
+
if (!msg.content && !pending.replacedContent) {
|
|
130
131
|
// Empty bubble — drop it entirely.
|
|
131
132
|
return prev.filter((m) => m.id !== assistantId);
|
|
132
133
|
}
|
|
@@ -134,6 +135,7 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
134
135
|
next[idx] = {
|
|
135
136
|
...msg,
|
|
136
137
|
isStreaming: false,
|
|
138
|
+
...(pending.replacedContent !== undefined ? { content: pending.replacedContent } : {}),
|
|
137
139
|
...(pending.citations ? { citations: pending.citations } : {}),
|
|
138
140
|
...(pending.clarificationOptions
|
|
139
141
|
? { clarificationOptions: pending.clarificationOptions }
|
|
@@ -154,7 +156,7 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
154
156
|
// Citations + clarifications arrive instantly from the server but we
|
|
155
157
|
// want them to appear only after the prose is finished revealing —
|
|
156
158
|
// otherwise the badges/buttons render above text that hasn't been typed.
|
|
157
|
-
const pending: { citations?: Citation[]; clarificationOptions?: string[] } = {};
|
|
159
|
+
const pending: { citations?: Citation[]; clarificationOptions?: string[]; replacedContent?: string } = {};
|
|
158
160
|
|
|
159
161
|
const pacer = createTextStreamPacer({
|
|
160
162
|
reducedMotion,
|
|
@@ -210,6 +212,15 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
210
212
|
pacer.enqueue(event.content || '');
|
|
211
213
|
break;
|
|
212
214
|
|
|
215
|
+
case 'text_replace':
|
|
216
|
+
// Server has finalized the markdown (e.g. rewrote slug links to
|
|
217
|
+
// absolute URLs). Buffer it here and apply at reveal-end so it
|
|
218
|
+
// doesn't snap mid-typing-animation. Brief artifact: between the
|
|
219
|
+
// last text chunk and reveal-end, the user sees the pre-rewrite
|
|
220
|
+
// markdown rendered with non-canonical URLs.
|
|
221
|
+
pending.replacedContent = event.content || '';
|
|
222
|
+
break;
|
|
223
|
+
|
|
213
224
|
case 'citations':
|
|
214
225
|
pending.citations = event.sources;
|
|
215
226
|
break;
|
|
@@ -304,11 +315,18 @@ export function useChat(endpoint = '/_chat'): {
|
|
|
304
315
|
? '/docs/_chat'
|
|
305
316
|
: endpoint;
|
|
306
317
|
|
|
318
|
+
// extractLanguageFromPath returns undefined for root-level paths (default
|
|
319
|
+
// language). We omit `locale` in that case rather than guessing — the server
|
|
320
|
+
// only filters when a locale is explicitly sent AND the per-project flag is
|
|
321
|
+
// on, so omitting is the correct "use whatever you have, no filter" signal
|
|
322
|
+
// that's safe across mid-rollout client deployments.
|
|
323
|
+
const locale = extractLanguageFromPath(window.location.pathname);
|
|
324
|
+
|
|
307
325
|
try {
|
|
308
326
|
const response = await fetch(chatUrl, {
|
|
309
327
|
method: 'POST',
|
|
310
328
|
headers: { 'Content-Type': 'application/json' },
|
|
311
|
-
body: JSON.stringify({ message: trimmed, history }),
|
|
329
|
+
body: JSON.stringify(locale ? { message: trimmed, history, locale } : { message: trimmed, history }),
|
|
312
330
|
signal: controller.signal,
|
|
313
331
|
});
|
|
314
332
|
|
|
@@ -99,7 +99,7 @@ Rules:
|
|
|
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
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
|
|
102
|
+
(a) RELEVANT PAGE EXISTS → answer by referring to it with a markdown link. The link's URL must be either the pageSlug (e.g. "See [OpenAPI Example](api-reference/openapi-example) for a live endpoint page.") OR the full URL from the URL: line of the cited chunk. The system rewrites both forms to the canonical absolute URL automatically. Never invent a URL or use a relative path like ./ or ../. Do NOT prefix with "I don't have information".
|
|
103
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
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.
|
|
105
105
|
- Never name a page ("the Changelog", "the API reference", "the Settings page", etc.) unless that page appears in the Documentation context below.
|
|
@@ -64,6 +64,9 @@ export const ANSWER_TOOL: AnswerToolSchema = {
|
|
|
64
64
|
type: 'string',
|
|
65
65
|
description:
|
|
66
66
|
'The answer in markdown. Use code blocks with language hints when showing code. ' +
|
|
67
|
+
'When linking to a documentation page, prefer the pageSlug (from the [pageSlug: ...] label) as the URL: ' +
|
|
68
|
+
'[Anchor Text](api-reference/openapi-example). The full URL from the URL: line also works. ' +
|
|
69
|
+
'The system rewrites either form to the canonical absolute URL after generation. ' +
|
|
67
70
|
'Do not embed citation text like "[Page Title]" — citations are listed in cited_page_slugs.',
|
|
68
71
|
},
|
|
69
72
|
cited_page_slugs: {
|
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* first, then at sentence boundaries for oversized sections.
|
|
7
7
|
*/
|
|
8
8
|
import { extractSections } from './static-artifacts.js';
|
|
9
|
+
import {
|
|
10
|
+
deriveChunkLocale,
|
|
11
|
+
normalizeLanguageList,
|
|
12
|
+
type LanguageEntry,
|
|
13
|
+
} from './locale-helpers.js';
|
|
14
|
+
|
|
15
|
+
export { normalizeLanguageList };
|
|
9
16
|
|
|
10
17
|
export interface EmbeddingChunk {
|
|
11
18
|
/** Unique ID: `${pageSlug}#${sectionIndex}` */
|
|
@@ -25,6 +32,9 @@ export interface EmbeddingChunk {
|
|
|
25
32
|
prefix: string;
|
|
26
33
|
/** Page title from frontmatter, or slug-derived fallback */
|
|
27
34
|
pageTitle: string;
|
|
35
|
+
/** Locale derived from i18n config + leading slug segment. `null` for
|
|
36
|
+
* single-language projects (no `i18n.languages` configured). */
|
|
37
|
+
locale: string | null;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
/**
|
|
@@ -200,16 +210,21 @@ function sanitizeHeadingText(raw: string): string {
|
|
|
200
210
|
* Chunk a documentation page into embedding-sized pieces.
|
|
201
211
|
*
|
|
202
212
|
* @param page - Page with file path, raw MDX content, and frontmatter
|
|
203
|
-
* @param
|
|
213
|
+
* @param options - maxChars cap (default 2000, ~500 tokens) and i18n languages
|
|
214
|
+
* (used to derive each chunk's locale tag)
|
|
204
215
|
* @returns Array of embedding chunks with unique IDs
|
|
205
216
|
*/
|
|
206
217
|
export function chunkPageForEmbedding(
|
|
207
218
|
page: { path: string; content: string; frontmatter: Record<string, unknown> },
|
|
208
|
-
maxChars =
|
|
219
|
+
options: { maxChars?: number; languages?: LanguageEntry[] } = {},
|
|
209
220
|
): EmbeddingChunk[] {
|
|
221
|
+
const maxChars = options.maxChars ?? 2000;
|
|
222
|
+
const languages = options.languages ?? [];
|
|
223
|
+
|
|
210
224
|
const slug = page.path.replace(/\.mdx?$/, '').replace(/\\/g, '/');
|
|
211
225
|
const rawTitle = (page.frontmatter.title as string) || titleFromSlug(slug);
|
|
212
226
|
const pageTitle = sanitizeHeadingText(rawTitle) || titleFromSlug(slug);
|
|
227
|
+
const locale = deriveChunkLocale(page.path, languages);
|
|
213
228
|
|
|
214
229
|
// Normalize Windows line endings before extracting sections
|
|
215
230
|
const normalizedContent = preprocessUpdateBlocks(page.content.replace(/\r\n/g, '\n'));
|
|
@@ -238,6 +253,7 @@ export function chunkPageForEmbedding(
|
|
|
238
253
|
content: piece,
|
|
239
254
|
prefix,
|
|
240
255
|
pageTitle,
|
|
256
|
+
locale,
|
|
241
257
|
});
|
|
242
258
|
chunkIndex++;
|
|
243
259
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[
|
|
2
|
+
"en",
|
|
3
|
+
"cn", "zh", "zh-Hans", "zh-Hant",
|
|
4
|
+
"es",
|
|
5
|
+
"fr", "fr-CA", "fr-ca",
|
|
6
|
+
"ja", "jp", "ja-jp",
|
|
7
|
+
"pt", "pt-BR",
|
|
8
|
+
"de",
|
|
9
|
+
"ko",
|
|
10
|
+
"it",
|
|
11
|
+
"ru",
|
|
12
|
+
"ro",
|
|
13
|
+
"cs",
|
|
14
|
+
"id",
|
|
15
|
+
"ar",
|
|
16
|
+
"tr",
|
|
17
|
+
"hi",
|
|
18
|
+
"sv",
|
|
19
|
+
"no",
|
|
20
|
+
"lv",
|
|
21
|
+
"nl",
|
|
22
|
+
"uk",
|
|
23
|
+
"vi",
|
|
24
|
+
"pl",
|
|
25
|
+
"uz",
|
|
26
|
+
"he"
|
|
27
|
+
]
|
|
@@ -4,7 +4,16 @@
|
|
|
4
4
|
* Provides display names, flag emojis, and helper functions for multi-language support.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { LanguageCode } from './docs-types';
|
|
7
|
+
import type { LanguageCode, LanguageConfig } from './docs-types';
|
|
8
|
+
import LANGUAGE_CODES_JSON from './language-codes.json';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Canonical language code list, shared with the CJS build script via
|
|
12
|
+
* `lib/language-codes.json`. Updates to LANGUAGE_DISPLAY_NAMES below MUST
|
|
13
|
+
* also update language-codes.json. The sync test in
|
|
14
|
+
* __tests__/lib/language-codes-sync.test.ts catches drift.
|
|
15
|
+
*/
|
|
16
|
+
export const LANGUAGE_CODES = LANGUAGE_CODES_JSON as readonly LanguageCode[];
|
|
8
17
|
|
|
9
18
|
/**
|
|
10
19
|
* Display names for each supported language code
|
|
@@ -275,21 +284,81 @@ export function isValidLanguageCode(code: string): code is LanguageCode {
|
|
|
275
284
|
return code in LANGUAGE_DISPLAY_NAMES;
|
|
276
285
|
}
|
|
277
286
|
|
|
287
|
+
/** Lowercase → canonical lookup, built once. Lets `extractLanguageFromPath`
|
|
288
|
+
* match URL segments regardless of case (`/pt-br/...` returns `'pt-BR'`).
|
|
289
|
+
* Vercel and some CDN configs serve mixed-case folder paths under
|
|
290
|
+
* lowercased URLs, so a strict case-sensitive match silently breaks
|
|
291
|
+
* every locale-aware feature on those URL forms.
|
|
292
|
+
* First insertion wins — iteration order MUST match the CJS map in
|
|
293
|
+
* `scripts/build-search-index.cjs`, so we iterate the shared JSON list
|
|
294
|
+
* (single source of truth) instead of `LANGUAGE_DISPLAY_NAMES` keys. */
|
|
295
|
+
const LANGUAGE_CODE_BY_LOWER: Map<string, LanguageCode> = (() => {
|
|
296
|
+
const m = new Map<string, LanguageCode>();
|
|
297
|
+
for (const k of LANGUAGE_CODES) {
|
|
298
|
+
const lower = k.toLowerCase();
|
|
299
|
+
if (!m.has(lower)) m.set(lower, k);
|
|
300
|
+
}
|
|
301
|
+
return m;
|
|
302
|
+
})();
|
|
303
|
+
|
|
278
304
|
/**
|
|
279
305
|
* Extract language code from a pathname
|
|
280
306
|
*
|
|
281
307
|
* @param pathname - The URL pathname (e.g., '/es/introduction' or '/docs/es/introduction')
|
|
282
|
-
* @returns The language code if found, undefined otherwise
|
|
308
|
+
* @returns The canonical-cased language code if found, undefined otherwise
|
|
283
309
|
*/
|
|
284
310
|
export function extractLanguageFromPath(pathname: string): LanguageCode | undefined {
|
|
285
311
|
// Remove /docs prefix if present (for backward compatibility)
|
|
286
312
|
const parts = pathname.replace(/^\/docs\/?/, '').replace(/^\//, '').split('/').filter(Boolean);
|
|
287
313
|
|
|
288
|
-
if (parts.length
|
|
289
|
-
|
|
290
|
-
|
|
314
|
+
if (parts.length === 0) return undefined;
|
|
315
|
+
return LANGUAGE_CODE_BY_LOWER.get(parts[0].toLowerCase());
|
|
316
|
+
}
|
|
291
317
|
|
|
292
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Resolve the active locale from a pathname, gated by the project's declared
|
|
320
|
+
* language whitelist.
|
|
321
|
+
*
|
|
322
|
+
* Returns:
|
|
323
|
+
* - `''` when the path has no language prefix, OR the prefix is not in the
|
|
324
|
+
* project's `navigation.languages[].language` whitelist (e.g. `dodo/de/foo`
|
|
325
|
+
* where dodo only declares `en`).
|
|
326
|
+
* - The canonical language code when the prefix matches the whitelist.
|
|
327
|
+
*
|
|
328
|
+
* Use this — not `extractLanguageFromPath` directly — for any feature that
|
|
329
|
+
* filters by language (search, language selector). The whitelist prevents
|
|
330
|
+
* false-positives on directories named after language codes but not actually
|
|
331
|
+
* translations.
|
|
332
|
+
*/
|
|
333
|
+
export function resolveLocaleFromPath(
|
|
334
|
+
pathname: string,
|
|
335
|
+
projectLanguages: readonly string[],
|
|
336
|
+
): string {
|
|
337
|
+
return resolveLocaleWithLoweredSet(pathname, buildLoweredLocaleSet(projectLanguages));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Build the lowercase whitelist Set used by `resolveLocaleWithLoweredSet`.
|
|
342
|
+
* Hoist this out of hot paths (build-time per-page loops, runtime per-keystroke)
|
|
343
|
+
* to avoid re-allocating on every call.
|
|
344
|
+
*/
|
|
345
|
+
export function buildLoweredLocaleSet(
|
|
346
|
+
projectLanguages: readonly string[],
|
|
347
|
+
): ReadonlySet<string> {
|
|
348
|
+
return new Set(projectLanguages.map((l) => l.toLowerCase()));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Hot-path variant of `resolveLocaleFromPath`: caller supplies a pre-built
|
|
353
|
+
* lowered Set. Same semantics; avoids per-call Set construction.
|
|
354
|
+
*/
|
|
355
|
+
export function resolveLocaleWithLoweredSet(
|
|
356
|
+
pathname: string,
|
|
357
|
+
loweredLanguageSet: ReadonlySet<string>,
|
|
358
|
+
): string {
|
|
359
|
+
const candidate = extractLanguageFromPath(pathname);
|
|
360
|
+
if (!candidate) return '';
|
|
361
|
+
return loweredLanguageSet.has(candidate.toLowerCase()) ? candidate : '';
|
|
293
362
|
}
|
|
294
363
|
|
|
295
364
|
/**
|
|
@@ -336,3 +405,26 @@ export function clearLanguagePreference(): void {
|
|
|
336
405
|
// localStorage not available
|
|
337
406
|
}
|
|
338
407
|
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Resolve the active locale by priority:
|
|
411
|
+
* 1. The path-derived language from the middleware header
|
|
412
|
+
* 2. The project's default language from docs.json (`default: true`,
|
|
413
|
+
* else first entry)
|
|
414
|
+
* 3. "en"
|
|
415
|
+
*
|
|
416
|
+
* Pure function — no I/O, no headers, no config-fetching. Designed to be
|
|
417
|
+
* called once from `app/layout.tsx` after both inputs are resolved.
|
|
418
|
+
*/
|
|
419
|
+
export function resolveLanguageWithFallback(
|
|
420
|
+
headerLang: LanguageCode | null,
|
|
421
|
+
configLanguages: LanguageConfig[] | undefined,
|
|
422
|
+
): LanguageCode {
|
|
423
|
+
if (headerLang) return headerLang;
|
|
424
|
+
if (configLanguages && configLanguages.length > 0) {
|
|
425
|
+
const explicitDefault = configLanguages.find((l) => l.default)?.language;
|
|
426
|
+
if (explicitDefault) return explicitDefault;
|
|
427
|
+
return configLanguages[0].language;
|
|
428
|
+
}
|
|
429
|
+
return 'en';
|
|
430
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown link rewriter for chat responses.
|
|
3
|
+
*
|
|
4
|
+
* Rewrites two forms to absolute URLs from a `pageSlug → absoluteUrl` map:
|
|
5
|
+
*
|
|
6
|
+
* 1. Bare-slug: `[Anchor](api-reference/foo)` → `[Anchor](https://x/docs/api-reference/foo)`
|
|
7
|
+
* 2. Absolute URL: `[Anchor](https://anything/.../api-reference/foo)` → canonical absolute URL
|
|
8
|
+
*
|
|
9
|
+
* Both forms are needed because Haiku at temp 0.3 produces a mix:
|
|
10
|
+
* - Sometimes copies the `URL:` line from chunk context verbatim (form 2, correct host)
|
|
11
|
+
* - Sometimes emits the pageSlug (form 1)
|
|
12
|
+
* - Sometimes hallucinates a host (form 2, wrong host) — the slug suffix still resolves
|
|
13
|
+
* - Sometimes lowercases the leading segment of mixed-case locale folders
|
|
14
|
+
* (`pt-BR/foo` → `pt-br/foo`) — hence the lowercase lookup map.
|
|
15
|
+
*
|
|
16
|
+
* Streaming note: this is NOT streaming-safe. Apply ONLY to a complete markdown
|
|
17
|
+
* string (the final tool input). The chat route streams the raw markdown live
|
|
18
|
+
* for typing-effect UX, then sends a `text_replace` SSE event with the
|
|
19
|
+
* rewritten string at end-of-stream.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
23
|
+
|
|
24
|
+
export function rewriteSlugLinks(
|
|
25
|
+
text: string,
|
|
26
|
+
slugToUrl: Record<string, string>,
|
|
27
|
+
): string {
|
|
28
|
+
const lookup: Record<string, string> = {};
|
|
29
|
+
for (const [key, value] of Object.entries(slugToUrl)) lookup[key.toLowerCase()] = value;
|
|
30
|
+
|
|
31
|
+
return text.replace(MARKDOWN_LINK_RE, (full, anchor: string, url: string, offset: number) => {
|
|
32
|
+
// Skip image syntax (). A leading "!" turns the same bracket
|
|
33
|
+
// pattern into an image — rewriting it would point the <img src> at a docs
|
|
34
|
+
// page if the URL happens to match a known slug.
|
|
35
|
+
if (offset > 0 && text[offset - 1] === '!') return full;
|
|
36
|
+
const resolved = resolveSlugFromUrl(url, lookup);
|
|
37
|
+
return resolved ? `[${anchor}](${resolved})` : full;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveSlugFromUrl(
|
|
42
|
+
url: string,
|
|
43
|
+
lookup: Record<string, string>,
|
|
44
|
+
): string | null {
|
|
45
|
+
let path = url;
|
|
46
|
+
let hash = '';
|
|
47
|
+
|
|
48
|
+
if (/^https?:\/\//i.test(url)) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = new URL(url);
|
|
51
|
+
path = parsed.pathname;
|
|
52
|
+
hash = parsed.hash;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
const hashIdx = path.indexOf('#');
|
|
58
|
+
if (hashIdx !== -1) {
|
|
59
|
+
hash = path.slice(hashIdx);
|
|
60
|
+
path = path.slice(0, hashIdx);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
path = path.replace(/^\.?\/+/, '').toLowerCase();
|
|
65
|
+
const hit = lookup[path] ?? lookup[path.replace(/^docs\//, '')];
|
|
66
|
+
return hit ? hit + hash : null;
|
|
67
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale helpers for AI chat embedding + retrieval.
|
|
3
|
+
*
|
|
4
|
+
* The chunker uses these at build time to tag each EmbeddingChunk with the
|
|
5
|
+
* locale derived from the file path (using the project's i18n config). The
|
|
6
|
+
* chat API uses the same shape at query time to filter Upstash Vector
|
|
7
|
+
* results to a single locale, preventing cross-language pollution.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface LanguageEntry {
|
|
11
|
+
code: string;
|
|
12
|
+
isDefault: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RawLanguageEntry {
|
|
16
|
+
language: string;
|
|
17
|
+
default?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalize the raw `navigation.languages` array into a stable, lowercased shape.
|
|
22
|
+
*
|
|
23
|
+
* - If no entry has `default: true`, the first entry is treated as default
|
|
24
|
+
* (matches resolveLanguageWithFallback() in language-utils.ts).
|
|
25
|
+
* - Codes are lowercased so `pt-BR` from docs.json matches `pt-br/...` paths.
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeLanguageList(
|
|
28
|
+
raw: RawLanguageEntry[] | undefined,
|
|
29
|
+
): LanguageEntry[] {
|
|
30
|
+
if (!raw || raw.length === 0) return [];
|
|
31
|
+
const hasExplicitDefault = raw.some((l) => l.default === true);
|
|
32
|
+
return raw.map((l, i) => ({
|
|
33
|
+
code: l.language.toLowerCase(),
|
|
34
|
+
isDefault: hasExplicitDefault ? l.default === true : i === 0,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Derive the locale for a chunk from its file path.
|
|
40
|
+
*
|
|
41
|
+
* Returns:
|
|
42
|
+
* - `null` when no i18n config is present (single-language project) — the
|
|
43
|
+
* filter must NOT be applied to chunks with no locale metadata, and a
|
|
44
|
+
* blanket "en" default would silently mislabel Spanish-only docs sites.
|
|
45
|
+
* - The matching language code when the leading path segment is in the
|
|
46
|
+
* configured allowlist.
|
|
47
|
+
* - The configured default language code otherwise.
|
|
48
|
+
*
|
|
49
|
+
* Critically, ONLY exact membership in the configured language list counts.
|
|
50
|
+
* Two-letter folders like `ai/`, `cn/`, `ui/`, `snippets/` are NOT treated as
|
|
51
|
+
* locales unless they are explicitly listed in `navigation.languages`.
|
|
52
|
+
*/
|
|
53
|
+
export function deriveChunkLocale(
|
|
54
|
+
filePath: string,
|
|
55
|
+
languages: LanguageEntry[],
|
|
56
|
+
): string | null {
|
|
57
|
+
if (languages.length === 0) return null;
|
|
58
|
+
const defaultCode = languages.find((l) => l.isDefault)?.code ?? languages[0].code;
|
|
59
|
+
const firstSegment = filePath.replace(/\\/g, '/').split('/')[0].toLowerCase();
|
|
60
|
+
const match = languages.find((l) => l.code === firstSegment);
|
|
61
|
+
return match ? match.code : defaultCode;
|
|
62
|
+
}
|