jamdesk 1.1.37 → 1.1.39

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 (47) hide show
  1. package/dist/__tests__/integration/init.integration.test.js +44 -0
  2. package/dist/__tests__/integration/init.integration.test.js.map +1 -1
  3. package/dist/__tests__/unit/init.test.js +2 -1
  4. package/dist/__tests__/unit/init.test.js.map +1 -1
  5. package/package.json +1 -1
  6. package/templates/api-reference/openapi-example.mdx +55 -0
  7. package/templates/api-reference/request-response-examples.mdx +210 -0
  8. package/templates/components/callouts.mdx +56 -0
  9. package/templates/components/cards.mdx +80 -0
  10. package/templates/components/steps.mdx +39 -0
  11. package/templates/components/tabs-and-accordions.mdx +65 -0
  12. package/templates/docs.json +48 -0
  13. package/templates/introduction.mdx +40 -10
  14. package/templates/openapi/example-api.yaml +185 -0
  15. package/templates/quickstart.mdx +98 -9
  16. package/templates/writing/code-blocks.mdx +80 -0
  17. package/templates/writing/components.mdx +78 -0
  18. package/templates/writing/pages.mdx +59 -0
  19. package/vendored/app/[[...slug]]/page.tsx +26 -8
  20. package/vendored/app/api/chat/[project]/route.ts +53 -3
  21. package/vendored/app/api/docs-search/[project]/search/route.ts +48 -3
  22. package/vendored/app/layout.tsx +4 -4
  23. package/vendored/components/mdx/OpenApiEndpoint.tsx +2 -1
  24. package/vendored/components/navigation/Sidebar.tsx +9 -4
  25. package/vendored/components/search/SearchModal.tsx +13 -20
  26. package/vendored/hooks/useChat.ts +22 -4
  27. package/vendored/lib/chat-prompt.ts +1 -1
  28. package/vendored/lib/chat-tools.ts +3 -0
  29. package/vendored/lib/embedding-chunker.ts +18 -2
  30. package/vendored/lib/language-codes.json +27 -0
  31. package/vendored/lib/language-utils.ts +80 -5
  32. package/vendored/lib/link-rewriter.ts +67 -0
  33. package/vendored/lib/locale-helpers.ts +62 -0
  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/path-safety.ts +96 -0
  41. package/vendored/lib/search-client.ts +117 -12
  42. package/vendored/lib/static-artifacts.ts +25 -1
  43. package/vendored/lib/static-file-route.ts +13 -0
  44. package/vendored/lib/vector-store.ts +70 -17
  45. package/vendored/scripts/build-search-index.cjs +91 -24
  46. package/vendored/themes/base.css +5 -0
  47. package/vendored/workspace-package-lock.json +6 -6
@@ -48,7 +48,6 @@ import { buildEndpointFromMdx } from '@/lib/build-endpoint-from-mdx';
48
48
  import { mdxSecurityOptions } from '@/lib/mdx-security-options';
49
49
  import fs from 'fs';
50
50
  import path from 'path';
51
- import matter from 'gray-matter';
52
51
  import { getContentDir } from '@/lib/docs';
53
52
  import type { DocsConfig } from '@/lib/docs-types';
54
53
  import { buildSeoMetadata, generateAutoDescription, buildSiteTitle } from '@/lib/seo';
@@ -61,7 +60,6 @@ import {
61
60
  normalizeSlugForContent,
62
61
  parseFrontmatter,
63
62
  projectExists,
64
- type ContentLoader,
65
63
  } from '@/lib/content-loader';
66
64
  import { getBaseUrl, pathToSlug } from '@/lib/page-isr-helpers';
67
65
  import {
@@ -70,6 +68,7 @@ import {
70
68
  parseEndpoint,
71
69
  generateCodeExamples,
72
70
  formatOpenApiWarning,
71
+ deriveAuthFromSecurity,
73
72
  type OpenApiEndpointData,
74
73
  type CodeExample,
75
74
  type AuthMethod,
@@ -134,6 +133,22 @@ function resolveBaseUrl(
134
133
  return process.env.SITE_URL || DEFAULT_SITE_URL;
135
134
  }
136
135
 
136
+ /**
137
+ * docs.json override is treated as a UNIT — if method is set, both method and name
138
+ * come from docs.json, avoiding a stale customer-set `name` pairing with a
139
+ * spec-derived `method`. Falls back to deriving auth from the OpenAPI security schemes.
140
+ */
141
+ function resolveAuth(
142
+ endpoint: OpenApiEndpointData | null | undefined,
143
+ config: DocsConfig,
144
+ ): { method?: AuthMethod; headerName?: string } {
145
+ const override = config.api?.mdx?.auth;
146
+ if (override?.method) {
147
+ return { method: override.method, headerName: override.name };
148
+ }
149
+ return endpoint ? deriveAuthFromSecurity(endpoint.security) : {};
150
+ }
151
+
137
152
  /**
138
153
  * Frontmatter data from MDX files.
139
154
  */
@@ -548,9 +563,9 @@ export default async function DocPage({ params }: PageProps) {
548
563
 
549
564
  // Generate code examples
550
565
  if (openApiEndpointData) {
551
- const authMethod = config.api?.mdx?.auth?.method as AuthMethod | undefined;
566
+ const { method: authMethod, headerName: authHeaderName } = resolveAuth(openApiEndpointData, config);
552
567
  const languages = config.api?.examples?.languages;
553
- openApiCodeExamples = generateCodeExamples(openApiEndpointData, { authMethod, languages });
568
+ openApiCodeExamples = generateCodeExamples(openApiEndpointData, { authMethod, authHeaderName, languages });
554
569
  }
555
570
  } catch (err) {
556
571
  const typed = classifyOpenApiLoadError(err, lastFailure?.specPath ?? null);
@@ -616,6 +631,9 @@ export default async function DocPage({ params }: PageProps) {
616
631
  mdxEndpointData = buildEndpointFromMdx(mdxApiMethod, mdxApiPath, paramFields, fallbackServerUrl);
617
632
  }
618
633
 
634
+ const resolvedMdxAuth = resolveAuth(mdxEndpointData, config);
635
+ const resolvedOpenApiAuth = resolveAuth(openApiEndpointData, config);
636
+
619
637
  // For API pages, wrap the entire content area with ApiPageWrapper
620
638
  // so code panels can be positioned as siblings at the page level
621
639
  if (isApiPage) {
@@ -653,8 +671,8 @@ export default async function DocPage({ params }: PageProps) {
653
671
  endpoint={mdxEndpointData}
654
672
  playgroundOnly
655
673
  playgroundDisplay={playgroundDisplay}
656
- authMethod={config.api?.mdx?.auth?.method as AuthMethod | undefined}
657
- authHeaderName={config.api?.mdx?.auth?.name}
674
+ authMethod={resolvedMdxAuth.method}
675
+ authHeaderName={resolvedMdxAuth.headerName}
658
676
  serverUrl={fallbackServerUrl}
659
677
  proxyEnabled={proxyEnabled}
660
678
  languages={config.api?.examples?.languages}
@@ -674,8 +692,8 @@ export default async function DocPage({ params }: PageProps) {
674
692
  endpoint={openApiEndpointData}
675
693
  codeExamples={openApiCodeExamples || undefined}
676
694
  playgroundDisplay={playgroundDisplay}
677
- authMethod={config.api?.mdx?.auth?.method as AuthMethod | undefined}
678
- authHeaderName={config.api?.mdx?.auth?.name}
695
+ authMethod={resolvedOpenApiAuth.method}
696
+ authHeaderName={resolvedOpenApiAuth.headerName}
679
697
  serverUrl={fallbackServerUrl}
680
698
  proxyEnabled={proxyEnabled}
681
699
  languages={config.api?.examples?.languages}
@@ -28,8 +28,10 @@ import { getDocsPath, getBaseUrl, trackChatAnalytics } from '@/lib/route-helpers
28
28
  import { getAnthropicClient } from '@/lib/anthropic-client';
29
29
  import { rewriteQueryForSearch } from '@/lib/query-rewriter';
30
30
  import { fetchDocsConfig } from '@/lib/r2-content';
31
+ import { BCP47_LANGUAGE_RE } from '@/lib/language-utils';
31
32
  import { redis } from '@/lib/redis';
32
33
  import { CHAT_TOOLS } from '@/lib/chat-tools';
34
+ import { rewriteSlugLinks } from '@/lib/link-rewriter';
33
35
  import { log } from '@/lib/logger';
34
36
 
35
37
  export const runtime = 'nodejs';
@@ -75,7 +77,7 @@ export async function POST(
75
77
  }
76
78
  }
77
79
 
78
- let body: { message: unknown; history?: unknown[] };
80
+ let body: { message: unknown; history?: unknown[]; locale?: unknown };
79
81
  try {
80
82
  body = await request.json();
81
83
  } catch {
@@ -88,6 +90,14 @@ export async function POST(
88
90
  return Response.json({ error: 'Invalid message' }, { status: 400 });
89
91
  }
90
92
 
93
+ let clientLocale: string | undefined;
94
+ if (body.locale !== undefined) {
95
+ if (typeof body.locale !== 'string' || !BCP47_LANGUAGE_RE.test(body.locale)) {
96
+ return Response.json({ error: 'Invalid locale' }, { status: 400 });
97
+ }
98
+ clientLocale = body.locale;
99
+ }
100
+
91
101
  // Sanitize history: only allow valid roles, string content, capped length
92
102
  const history = (Array.isArray(rawHistory) ? rawHistory : [])
93
103
  .filter((h): h is { role: 'user' | 'assistant'; content: string } => {
@@ -122,6 +132,29 @@ export async function POST(
122
132
  fetchDocsConfig(project).catch(() => null),
123
133
  ]);
124
134
 
135
+ // Per-project rollout gate. Filter applies ONLY when the client sent a
136
+ // locale AND the project is opted in. Both default to off so this is a
137
+ // safe deploy. Set the flag with: redis-cli SET chatLocaleFilter:<slug> true
138
+ //
139
+ // Skip the Redis read entirely when no client locale was sent — the filter
140
+ // can never apply, so the round-trip would just block vector queries for
141
+ // nothing. This matters because the vector query path below depends on
142
+ // `effectiveLocale` and would otherwise wait on this read serially.
143
+ const localeFilterEnabled = clientLocale && redis
144
+ ? await redis.get(`chatLocaleFilter:${project}`)
145
+ .then((v) => v === 'true')
146
+ .catch((err) => {
147
+ // Fail-open is intentional (don't 500 chat on a Redis blip), but
148
+ // SREs need a signal — without this log, intermittent Redis
149
+ // outages silently regress the locale-pollution fix.
150
+ log('warn', 'chat: locale flag read failed, defaulting filter off', {
151
+ project, error: String(err),
152
+ });
153
+ return false;
154
+ })
155
+ : false;
156
+ const effectiveLocale = (localeFilterEnabled && clientLocale) ? clientLocale : undefined;
157
+
125
158
  // Fully parallel retrieval:
126
159
  // - Original vector query runs immediately.
127
160
  // - Rewriter + rewritten-query vector search are chained into ONE promise,
@@ -130,7 +163,8 @@ export async function POST(
130
163
  // max(original, rewriter) + rewritten_query_time.
131
164
  // Any failure in the rewrite path resolves to an empty chunk list — the
132
165
  // original query still returns results so chat doesn't fail.
133
- const originalQueryPromise = querySimilarChunks(project, searchQuery, 15);
166
+
167
+ const originalQueryPromise = querySimilarChunks(project, searchQuery, 15, { locale: effectiveLocale });
134
168
 
135
169
  // Skipping the rewriter on short follow-ups avoids 500-2000ms of serial
136
170
  // Anthropic latency — `searchQuery` above is already enriched with prior-turn
@@ -145,7 +179,7 @@ export async function POST(
145
179
  // (which is enriched with prior-turn context for short follow-ups).
146
180
  // The rewriter only sees `message`, so a no-op rewrite equals `message`.
147
181
  if (!rewritten || rewritten === message) return [];
148
- return querySimilarChunks(project, rewritten, 15).catch(() => []);
182
+ return querySimilarChunks(project, rewritten, 15, { locale: effectiveLocale }).catch(() => []);
149
183
  });
150
184
 
151
185
  let originalChunks: Awaited<ReturnType<typeof querySimilarChunks>>;
@@ -196,6 +230,12 @@ export async function POST(
196
230
  const baseUrl = getBaseUrl(project, originalHost);
197
231
  const siteName = resolveSiteName(config);
198
232
 
233
+ // Map of pageSlug → canonical absolute URL, consumed by rewriteSlugLinks
234
+ // after the stream completes to canonicalize Haiku-generated link targets.
235
+ const slugToUrl: Record<string, string> = Object.fromEntries(
236
+ chunks.map((c) => [c.pageSlug, `${baseUrl}${docsPath}/${c.pageSlug}`]),
237
+ );
238
+
199
239
  const systemPrompt = buildSystemPrompt(siteName, chunks, baseUrl, docsPath);
200
240
 
201
241
  const stream = anthropic.messages.stream({
@@ -344,6 +384,16 @@ export async function POST(
344
384
  : explicitSources.length > 0
345
385
  ? explicitSources
346
386
  : topChunksAsCitations(2, seen);
387
+
388
+ // Two-pass URL rewrite: stream raw markdown live (for typing-effect
389
+ // UX), then emit a `text_replace` event with canonical absolute URLs
390
+ // once we have the full final markdown. Brief artifact: between the
391
+ // last text chunk and this event the user sees rendered slug-form
392
+ // URLs. Skip the event when nothing changed.
393
+ const rewrittenMarkdown = rewriteSlugLinks(markdownText, slugToUrl);
394
+ if (rewrittenMarkdown !== markdownText) {
395
+ controller.enqueue(sendEvent({ type: 'text_replace', content: rewrittenMarkdown }));
396
+ }
347
397
  } else if (toolUse.name === 'ask_clarification') {
348
398
  const input = toolUse.input as ClarificationInput;
349
399
  const options = Array.isArray(input.options) ? input.options : [];
@@ -20,6 +20,10 @@ import { querySimilarChunks } from '@/lib/vector-store';
20
20
  import { verifyApiKey } from '@/lib/docs-search-auth';
21
21
  import { getBaseUrl, trackServerAnalytics } from '@/lib/route-helpers';
22
22
  import { redis } from '@/lib/redis';
23
+ import { fetchDocsConfig } from '@/lib/r2-content';
24
+ import { isMultiLanguageConfig } from '@/lib/navigation-utils';
25
+ import { BCP47_LANGUAGE_RE } from '@/lib/language-utils';
26
+ import { log } from '@/lib/logger';
23
27
 
24
28
  export const runtime = 'nodejs';
25
29
  export const maxDuration = 30;
@@ -34,6 +38,7 @@ const MAX_LIMIT = 20;
34
38
  const DEFAULT_LIMIT = 5;
35
39
  const MAX_QUERY_LENGTH = 500;
36
40
  const RATE_LIMIT_PER_MIN = 60;
41
+ const DEFAULT_LANGUAGE = 'en';
37
42
 
38
43
  export async function OPTIONS(_request: NextRequest) {
39
44
  return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
@@ -89,7 +94,7 @@ export async function POST(
89
94
  }
90
95
 
91
96
  // --- Parse & validate request body ---
92
- let body: { query?: string; limit?: number };
97
+ let body: { query?: string; limit?: number; language?: unknown };
93
98
  try {
94
99
  body = await request.json();
95
100
  } catch {
@@ -101,6 +106,19 @@ export async function POST(
101
106
 
102
107
  const { query, limit: rawLimit } = body;
103
108
 
109
+ // `null` is treated as omitted: real-world clients emit `language: null`
110
+ // from `?? null` fallbacks, and rejecting them would be gratuitous.
111
+ let language: string = DEFAULT_LANGUAGE;
112
+ if (body.language != null) {
113
+ if (typeof body.language !== 'string' || !BCP47_LANGUAGE_RE.test(body.language)) {
114
+ return NextResponse.json(
115
+ { error: 'Invalid language code' },
116
+ { status: 400, headers: CORS_HEADERS },
117
+ );
118
+ }
119
+ language = body.language;
120
+ }
121
+
104
122
  if (!query || typeof query !== 'string' || query.trim().length === 0) {
105
123
  return NextResponse.json(
106
124
  { error: 'Missing or empty "query" field' },
@@ -125,11 +143,38 @@ export async function POST(
125
143
  MAX_LIMIT,
126
144
  );
127
145
 
146
+ // --- Multi-language gate ---
147
+ // Apply the locale filter only when the project is configured for
148
+ // multiple languages AND the per-project kill switch is not set.
149
+ // Single-language projects' chunks have no locale metadata, so the
150
+ // strict filter would return zero. Both reads tolerate failure (R2
151
+ // outage or Redis blip) by falling back to "filter disabled" — same
152
+ // pattern the chat endpoint uses.
153
+ const [config, killSwitch] = await Promise.all([
154
+ fetchDocsConfig(project).catch(() => null),
155
+ redis
156
+ ? redis.get(`searchLocaleFilter:${project}`)
157
+ .then((v) => v === 'disabled')
158
+ .catch(() => false)
159
+ : Promise.resolve(false),
160
+ ]);
161
+
162
+ if (killSwitch) {
163
+ log('warn', 'docs-search: locale filter kill switch is enabled', {
164
+ project,
165
+ });
166
+ }
167
+
168
+ const applyLocaleFilter = !killSwitch && !!config && isMultiLanguageConfig(config);
169
+ const effectiveLocale = applyLocaleFilter ? language : undefined;
170
+
128
171
  // --- Semantic vector search ---
129
172
  const startMs = Date.now();
130
173
  let chunks;
131
174
  try {
132
- chunks = await querySimilarChunks(project, query.trim(), limit);
175
+ chunks = await querySimilarChunks(project, query.trim(), limit, {
176
+ locale: effectiveLocale,
177
+ });
133
178
  } catch (err) {
134
179
  console.error('Vector search failed:', err);
135
180
  return NextResponse.json(
@@ -162,7 +207,7 @@ export async function POST(
162
207
  });
163
208
 
164
209
  return NextResponse.json(
165
- { results, query: query.trim(), total: results.length, durationMs },
210
+ { results, query: query.trim(), language, total: results.length, durationMs },
166
211
  { status: 200, headers: CORS_HEADERS },
167
212
  );
168
213
  }
@@ -1,6 +1,7 @@
1
1
  import type { Metadata } from 'next';
2
2
  import { Inter, JetBrains_Mono } from 'next/font/google';
3
3
  import { headers } from 'next/headers';
4
+ import Script from 'next/script';
4
5
  import './globals.css';
5
6
  import { ThemeProvider } from '@/components/theme/ThemeProvider';
6
7
  import { LayoutWrapper } from '@/components/layout/LayoutWrapper';
@@ -411,10 +412,9 @@ export default async function RootLayout({
411
412
  {/* Preload FA font files to avoid waterfall: CSS → font discovery → download */}
412
413
  <link rel="preload" href="/_jd/fonts/fontawesome/webfonts/fa-light-300.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
413
414
  <link rel="preload" href="/_jd/fonts/fontawesome/webfonts/fa-brands-400.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
414
- <script
415
- // eslint-disable-next-line react/no-danger -- static trusted content, no user input
416
- dangerouslySetInnerHTML={{ __html: `var l=document.createElement('link');l.rel='stylesheet';l.href='${FA_CSS_HREF}';document.head.appendChild(l);window.__FA_CSS_LOADED__=true;` }}
417
- />
415
+ <Script id="fa-css-async-loader" strategy="beforeInteractive">
416
+ {`var l=document.createElement('link');l.rel='stylesheet';l.href='${FA_CSS_HREF}';document.head.appendChild(l);window.__FA_CSS_LOADED__=true;`}
417
+ </Script>
418
418
  <noscript>
419
419
  <link rel="stylesheet" href={FA_CSS_HREF} />
420
420
  </noscript>
@@ -11,6 +11,7 @@ import { CodePanel, CodePanelTab, getStatusColor } from '../ui/CodePanel';
11
11
  import { useShikiHighlightMultiple } from '@/hooks/useShikiHighlight';
12
12
  import { preloadHighlighter } from '@/lib/shiki-client';
13
13
  import ReactMarkdown from 'react-markdown';
14
+ import { resolveServerUrl } from '@/lib/openapi/resolve-server-url';
14
15
  // Icons use Font Awesome CSS classes for lightweight rendering
15
16
  import type {
16
17
  OpenApiEndpointData,
@@ -916,7 +917,7 @@ export function OpenApiEndpoint({
916
917
  const headerParams = parameters.filter(p => p.in === 'header');
917
918
  const cookieParams = parameters.filter(p => p.in === 'cookie');
918
919
 
919
- const baseUrl = servers[0]?.url;
920
+ const baseUrl = resolveServerUrl(servers[0]);
920
921
 
921
922
  // Playground state
922
923
  const showPlayground = playgroundDisplay !== 'none';
@@ -278,20 +278,25 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
278
278
  // Prevent body scroll when sidebar is open on mobile
279
279
  useBodyScrollLock(isOpen);
280
280
 
281
- // Toggle group expansion; if expanding, navigate to first page
281
+ // Toggle group expansion; if expanding, navigate to first page.
282
+ // The router.push must run AFTER the setState commits — calling it inside the
283
+ // updater triggers "Cannot update a component (Router) while rendering Sidebar".
282
284
  const handleGroupClick = useCallback((group: ResolvedGroup) => {
285
+ const willExpand = !expandedGroups.has(group.name);
283
286
  setExpandedGroups(prev => {
284
287
  const newSet = new Set(prev);
285
288
  if (prev.has(group.name)) {
286
289
  newSet.delete(group.name);
287
290
  } else {
288
- const firstPagePath = findFirstPageInGroups([group]);
289
- if (firstPagePath) router.push(`${linkPrefix}/${firstPagePath}`);
290
291
  newSet.add(group.name);
291
292
  }
292
293
  return newSet;
293
294
  });
294
- }, [router, linkPrefix]);
295
+ if (willExpand) {
296
+ const firstPagePath = findFirstPageInGroups([group]);
297
+ if (firstPagePath) router.push(`${linkPrefix}/${firstPagePath}`);
298
+ }
299
+ }, [router, linkPrefix, expandedGroups]);
295
300
 
296
301
  // Render a navigation group (supports nesting)
297
302
  function renderGroup(group: ResolvedGroup, level: number = 0) {
@@ -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;
@@ -83,7 +74,7 @@ function NoResultsState({ query, onSuggestionClick }: { query: string; onSuggest
83
74
  <button
84
75
  key={suggestion}
85
76
  onClick={() => onSuggestionClick(suggestion)}
86
- className="px-2.5 py-1 text-xs bg-[var(--color-bg-secondary)] hover:bg-[var(--color-bg-tertiary)] text-[var(--color-text-primary)] rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
77
+ className="px-2.5 py-1 text-xs cursor-pointer bg-[var(--color-bg-secondary)] hover:bg-[var(--color-bg-tertiary)] text-[var(--color-text-primary)] rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
87
78
  >
88
79
  {suggestion}
89
80
  </button>
@@ -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(() => {
@@ -417,7 +410,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
417
410
  />
418
411
  <button
419
412
  onClick={onClose}
420
- className="p-1.5 hover:bg-[var(--color-bg-tertiary)] rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
413
+ className="p-1.5 cursor-pointer hover:bg-[var(--color-bg-tertiary)] rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
421
414
  aria-label="Close search"
422
415
  >
423
416
  <i className="fa-solid fa-xmark h-4 w-4 text-[var(--color-text-muted)]" aria-hidden="true" />
@@ -455,7 +448,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
455
448
  onClick={() => handleResultClick(result, index)}
456
449
  role="option"
457
450
  aria-selected={index === selectedIndex}
458
- className={`w-full px-4 py-2.5 flex items-start gap-3 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-inset ${
451
+ className={`w-full px-4 py-2.5 flex items-start gap-3 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-inset ${
459
452
  index === selectedIndex
460
453
  ? 'bg-[var(--color-bg-secondary)]'
461
454
  : 'hover:bg-[var(--color-bg-secondary)]'
@@ -497,7 +490,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
497
490
  </span>
498
491
  <button
499
492
  onClick={handleClearRecentSearches}
500
- className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
493
+ className="text-xs cursor-pointer text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
501
494
  >
502
495
  Clear
503
496
  </button>
@@ -506,7 +499,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
506
499
  <button
507
500
  key={term}
508
501
  onClick={() => handleRecentSearchClick(term)}
509
- className="w-full px-4 py-2 flex items-center gap-3 hover:bg-[var(--color-bg-secondary)] transition-colors group focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
502
+ className="w-full px-4 py-2 flex items-center gap-3 cursor-pointer hover:bg-[var(--color-bg-secondary)] transition-colors group focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
510
503
  >
511
504
  {/* Keyboard shortcut badge */}
512
505
  <kbd className="w-4 h-4 flex items-center justify-center bg-[var(--color-bg-tertiary)] border border-[var(--color-border)] rounded text-[9px] font-medium text-[var(--color-text-muted)] group-hover:border-[var(--color-accent)] transition-colors">
@@ -539,7 +532,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
539
532
  router.push(url);
540
533
  onClose();
541
534
  }}
542
- className={`w-full px-4 py-2.5 flex items-center gap-3 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-inset ${
535
+ className={`w-full px-4 py-2.5 flex items-center gap-3 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-inset ${
543
536
  index === popularIndex
544
537
  ? 'bg-[var(--color-bg-secondary)]'
545
538
  : 'hover:bg-[var(--color-bg-secondary)]'
@@ -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
+ ]