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.
Files changed (49) hide show
  1. package/dist/__tests__/integration/init.integration.test.js +41 -0
  2. package/dist/__tests__/integration/init.integration.test.js.map +1 -1
  3. package/dist/__tests__/unit/docs-config.test.js +17 -0
  4. package/dist/__tests__/unit/docs-config.test.js.map +1 -1
  5. package/dist/__tests__/unit/init.test.js +2 -1
  6. package/dist/__tests__/unit/init.test.js.map +1 -1
  7. package/dist/lib/docs-config.d.ts +4 -1
  8. package/dist/lib/docs-config.d.ts.map +1 -1
  9. package/dist/lib/docs-config.js +27 -23
  10. package/dist/lib/docs-config.js.map +1 -1
  11. package/package.json +1 -1
  12. package/templates/api-reference/openapi-example.mdx +55 -0
  13. package/templates/api-reference/request-response-examples.mdx +210 -0
  14. package/templates/docs.json +27 -0
  15. package/templates/openapi/example-api.yaml +185 -0
  16. package/vendored/app/[[...slug]]/page.tsx +26 -8
  17. package/vendored/app/api/chat/[project]/route.ts +53 -3
  18. package/vendored/app/api/docs-search/[project]/search/route.ts +83 -3
  19. package/vendored/app/layout.tsx +26 -3
  20. package/vendored/components/HtmlLangSync.tsx +38 -0
  21. package/vendored/components/mdx/OpenApiEndpoint.tsx +2 -1
  22. package/vendored/components/navigation/LanguageSelector.tsx +18 -21
  23. package/vendored/components/navigation/TableOfContents.tsx +18 -3
  24. package/vendored/components/search/SearchModal.tsx +7 -14
  25. package/vendored/hooks/useChat.ts +22 -4
  26. package/vendored/lib/chat-prompt.ts +1 -1
  27. package/vendored/lib/chat-tools.ts +3 -0
  28. package/vendored/lib/embedding-chunker.ts +18 -2
  29. package/vendored/lib/language-codes.json +27 -0
  30. package/vendored/lib/language-utils.ts +98 -6
  31. package/vendored/lib/link-rewriter.ts +67 -0
  32. package/vendored/lib/locale-helpers.ts +62 -0
  33. package/vendored/lib/middleware-helpers.ts +57 -2
  34. package/vendored/lib/openapi/code-examples.ts +5 -6
  35. package/vendored/lib/openapi/derive-auth.ts +46 -0
  36. package/vendored/lib/openapi/index.ts +7 -0
  37. package/vendored/lib/openapi/parser.ts +7 -2
  38. package/vendored/lib/openapi/resolve-server-url.ts +14 -0
  39. package/vendored/lib/openapi/types.ts +2 -0
  40. package/vendored/lib/page-isr-helpers.ts +20 -0
  41. package/vendored/lib/path-safety.ts +96 -0
  42. package/vendored/lib/search-client.ts +67 -10
  43. package/vendored/lib/seo.ts +80 -13
  44. package/vendored/lib/static-artifacts.ts +25 -1
  45. package/vendored/lib/vector-store.ts +70 -17
  46. package/vendored/scripts/build-search-index.cjs +59 -0
  47. package/vendored/scripts/validate-links.cjs +21 -66
  48. package/vendored/themes/base.css +5 -0
  49. 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
- <span className="text-base" role="img" aria-label={currentLanguage?.displayName}>
168
- {currentLanguage?.flag}
169
- </span>
170
- {!compact && (
171
- <>
172
- <span className="flex-1 text-left text-sm font-medium truncate">
173
- {currentLanguage?.displayName}
174
- </span>
175
- <i
176
- className={`fa-solid fa-chevron-${position === 'up' ? 'up' : 'down'} h-3 w-3 transition-transform ${
177
- isOpen ? (position === 'up' ? 'rotate-180' : 'rotate-180') : ''
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
- // Build position list: each heading's scroll offset
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.offsetTop - contentContainer.offsetTop
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
- setActiveAnchors(active.length > 0 ? active : [headings[0].id]);
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 searchResults = await search(query, 15);
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., "See [Changelog](.../changelog) for recent releases."). Do NOT prefix with "I don't have information".
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 maxChars - Maximum characters per chunk (default 2000, ~500 tokens)
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 = 2000,
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 > 0 && isValidLanguageCode(parts[0])) {
289
- return parts[0] as LanguageCode;
290
- }
314
+ if (parts.length === 0) return undefined;
315
+ return LANGUAGE_CODE_BY_LOWER.get(parts[0].toLowerCase());
316
+ }
291
317
 
292
- return undefined;
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 (![alt](url)). 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
+ }