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
@@ -1,10 +1,37 @@
1
1
  {
2
2
  "name": "{{PROJECT_NAME}}",
3
+ "theme": "jam",
4
+ "api": {
5
+ "openapi": [
6
+ "/openapi/example-api.yaml"
7
+ ],
8
+ "examples": {
9
+ "languages": [
10
+ "curl",
11
+ "python",
12
+ "javascript",
13
+ "go",
14
+ "ruby",
15
+ "csharp",
16
+ "java",
17
+ "rust",
18
+ "php"
19
+ ]
20
+ }
21
+ },
3
22
  "navigation": {
4
23
  "groups": [
5
24
  {
6
25
  "group": "Getting Started",
7
26
  "pages": ["introduction", "quickstart"]
27
+ },
28
+ {
29
+ "group": "API Pages",
30
+ "icon": "plug",
31
+ "pages": [
32
+ { "page": "api-reference/openapi-example", "title": "OpenAPI Example" },
33
+ "api-reference/request-response-examples"
34
+ ]
8
35
  }
9
36
  ]
10
37
  }
@@ -0,0 +1,185 @@
1
+ openapi: 3.0.3
2
+ info:
3
+ title: Acme Support API
4
+ version: "1.0.0"
5
+ description: |
6
+ The Acme Support API lets you create and track customer support tickets.
7
+ Use it to post new issues from your product and keep users updated on status.
8
+ servers:
9
+ - url: https://jamdesk-docs.jamdesk.app/api/playground/demo
10
+ security: []
11
+ paths:
12
+ /tickets:
13
+ get:
14
+ summary: List support tickets
15
+ description: Retrieve all open support tickets.
16
+ operationId: listTickets
17
+ responses:
18
+ "200":
19
+ description: Ticket list
20
+ content:
21
+ application/json:
22
+ schema:
23
+ type: object
24
+ properties:
25
+ tickets:
26
+ type: array
27
+ items:
28
+ $ref: "#/components/schemas/TicketSummary"
29
+ total:
30
+ type: integer
31
+ example:
32
+ tickets:
33
+ - id: "tkt_9S8L2"
34
+ customer_id: "cus_2X9W8"
35
+ subject: "Export stuck on step 3"
36
+ priority: "high"
37
+ status: "open"
38
+ created_at: "2026-02-04T16:12:00Z"
39
+ total: 1
40
+ post:
41
+ summary: Create a support ticket
42
+ description: Create a new ticket for a customer issue or request.
43
+ operationId: createTicket
44
+ requestBody:
45
+ required: true
46
+ content:
47
+ application/json:
48
+ schema:
49
+ $ref: "#/components/schemas/CreateTicketRequest"
50
+ example:
51
+ customer_id: "cus_2X9W8"
52
+ subject: "Export stuck on step 3"
53
+ priority: "high"
54
+ tags: ["export", "bug"]
55
+ message: "The export job fails with error 504 after 2 minutes."
56
+ responses:
57
+ "201":
58
+ description: Ticket created
59
+ content:
60
+ application/json:
61
+ schema:
62
+ $ref: "#/components/schemas/Ticket"
63
+ example:
64
+ id: "tkt_9S8L2"
65
+ customer_id: "cus_2X9W8"
66
+ subject: "Export stuck on step 3"
67
+ priority: "high"
68
+ status: "open"
69
+ created_at: "2026-02-04T16:12:00Z"
70
+ "400":
71
+ description: Invalid request
72
+ content:
73
+ application/json:
74
+ schema:
75
+ $ref: "#/components/schemas/Error"
76
+ example:
77
+ code: "invalid_request"
78
+ message: "subject is required"
79
+ /tickets/{ticket_id}:
80
+ get:
81
+ summary: Get a support ticket
82
+ description: Retrieve a specific support ticket by its ID.
83
+ operationId: getTicket
84
+ parameters:
85
+ - name: ticket_id
86
+ in: path
87
+ required: true
88
+ description: Unique ticket identifier
89
+ schema:
90
+ type: string
91
+ example: "tkt_9S8L2"
92
+ responses:
93
+ "200":
94
+ description: Ticket details
95
+ content:
96
+ application/json:
97
+ schema:
98
+ $ref: "#/components/schemas/Ticket"
99
+ example:
100
+ id: "tkt_9S8L2"
101
+ customer_id: "cus_2X9W8"
102
+ subject: "Export stuck on step 3"
103
+ priority: "high"
104
+ status: "open"
105
+ tags: ["export", "bug"]
106
+ message: "The export job fails with error 504 after 2 minutes."
107
+ created_at: "2026-02-04T16:12:00Z"
108
+ updated_at: "2026-02-04T16:12:00Z"
109
+ components:
110
+ schemas:
111
+ CreateTicketRequest:
112
+ type: object
113
+ required:
114
+ - customer_id
115
+ - subject
116
+ - message
117
+ properties:
118
+ customer_id:
119
+ type: string
120
+ description: Customer identifier in Acme.
121
+ subject:
122
+ type: string
123
+ description: Short summary of the issue.
124
+ priority:
125
+ type: string
126
+ enum: [low, normal, high, urgent]
127
+ tags:
128
+ type: array
129
+ items:
130
+ type: string
131
+ message:
132
+ type: string
133
+ description: Detailed problem description.
134
+ TicketSummary:
135
+ type: object
136
+ description: Abbreviated ticket for list responses (excludes message and tags).
137
+ properties:
138
+ id:
139
+ type: string
140
+ customer_id:
141
+ type: string
142
+ subject:
143
+ type: string
144
+ priority:
145
+ type: string
146
+ status:
147
+ type: string
148
+ enum: [open, pending, resolved]
149
+ created_at:
150
+ type: string
151
+ format: date-time
152
+ Ticket:
153
+ type: object
154
+ description: Full ticket detail including message and tags.
155
+ properties:
156
+ id:
157
+ type: string
158
+ customer_id:
159
+ type: string
160
+ subject:
161
+ type: string
162
+ priority:
163
+ type: string
164
+ status:
165
+ type: string
166
+ enum: [open, pending, resolved]
167
+ tags:
168
+ type: array
169
+ items:
170
+ type: string
171
+ message:
172
+ type: string
173
+ created_at:
174
+ type: string
175
+ format: date-time
176
+ updated_at:
177
+ type: string
178
+ format: date-time
179
+ Error:
180
+ type: object
181
+ properties:
182
+ code:
183
+ type: string
184
+ message:
185
+ type: string
@@ -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}
@@ -30,6 +30,7 @@ import { rewriteQueryForSearch } from '@/lib/query-rewriter';
30
30
  import { fetchDocsConfig } from '@/lib/r2-content';
31
31
  import { redis } from '@/lib/redis';
32
32
  import { CHAT_TOOLS } from '@/lib/chat-tools';
33
+ import { rewriteSlugLinks } from '@/lib/link-rewriter';
33
34
  import { log } from '@/lib/logger';
34
35
 
35
36
  export const runtime = 'nodejs';
@@ -43,6 +44,7 @@ const MAX_HISTORY = 10;
43
44
  * Used both for searchQuery enrichment and to skip the rewriter. */
44
45
  const SHORT_FOLLOWUP_LEN = 60;
45
46
  const VALID_ROLES = new Set<string>(['user', 'assistant']);
47
+ const VALID_LOCALE_RE = /^[a-zA-Z]{2,3}(?:[-_][a-zA-Z]{2,4})?$/;
46
48
 
47
49
  export async function POST(
48
50
  request: NextRequest,
@@ -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' || !VALID_LOCALE_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,9 @@ 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 { log } from '@/lib/logger';
23
26
 
24
27
  export const runtime = 'nodejs';
25
28
  export const maxDuration = 30;
@@ -34,6 +37,11 @@ const MAX_LIMIT = 20;
34
37
  const DEFAULT_LIMIT = 5;
35
38
  const MAX_QUERY_LENGTH = 500;
36
39
  const RATE_LIMIT_PER_MIN = 60;
40
+ /** BCP-47-ish: 2-3 letter primary tag, optional 2-4 letter region/script.
41
+ * Matches the chat endpoint's VALID_LOCALE_RE — keep both contracts in sync.
42
+ * Limitation: 3-segment tags like `zh-Hant-HK` are rejected. Documented. */
43
+ const VALID_LANGUAGE_RE = /^[a-zA-Z]{2,3}(?:[-_][a-zA-Z]{2,4})?$/;
44
+ const DEFAULT_LANGUAGE = 'en';
37
45
 
38
46
  export async function OPTIONS(_request: NextRequest) {
39
47
  return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
@@ -89,7 +97,7 @@ export async function POST(
89
97
  }
90
98
 
91
99
  // --- Parse & validate request body ---
92
- let body: { query?: string; limit?: number };
100
+ let body: { query?: string; limit?: number; language?: unknown };
93
101
  try {
94
102
  body = await request.json();
95
103
  } catch {
@@ -101,6 +109,23 @@ export async function POST(
101
109
 
102
110
  const { query, limit: rawLimit } = body;
103
111
 
112
+ // Validate language. `null` and `undefined` both default to 'en'
113
+ // (real-world clients send `language: null` from `?? null` fallbacks —
114
+ // rejecting them as 400 would be gratuitous). A non-string value or a
115
+ // string that doesn't match the BCP-47 pattern is a 400.
116
+ let language: string = DEFAULT_LANGUAGE;
117
+ let languageWasExplicit = false;
118
+ if (body.language != null) {
119
+ if (typeof body.language !== 'string' || !VALID_LANGUAGE_RE.test(body.language)) {
120
+ return NextResponse.json(
121
+ { error: 'Invalid language code' },
122
+ { status: 400, headers: CORS_HEADERS },
123
+ );
124
+ }
125
+ language = body.language;
126
+ languageWasExplicit = true;
127
+ }
128
+
104
129
  if (!query || typeof query !== 'string' || query.trim().length === 0) {
105
130
  return NextResponse.json(
106
131
  { error: 'Missing or empty "query" field' },
@@ -125,11 +150,66 @@ export async function POST(
125
150
  MAX_LIMIT,
126
151
  );
127
152
 
153
+ // --- Multi-language gate ---
154
+ // Apply the locale filter only when the project is configured for
155
+ // multiple languages AND the per-project kill switch is not set.
156
+ // Single-language projects' chunks have no locale metadata, so the
157
+ // strict filter would return zero. Run the config fetch and kill-switch
158
+ // read in parallel — fetchDocsConfig has a 5-min in-memory cache, and
159
+ // Redis is the bottleneck only on a cold cache. .catch() on the kill
160
+ // switch keeps a Redis blip from breaking searches.
161
+ const killSwitchPromise: Promise<boolean> = redis
162
+ ? redis.get(`searchLocaleFilter:${project}`)
163
+ .then((v) => v === 'disabled')
164
+ .catch(() => false)
165
+ : Promise.resolve(false);
166
+
167
+ let config: Awaited<ReturnType<typeof fetchDocsConfig>>;
168
+ let killSwitch: boolean;
169
+ try {
170
+ [config, killSwitch] = await Promise.all([
171
+ fetchDocsConfig(project),
172
+ killSwitchPromise,
173
+ ]);
174
+ } catch (err) {
175
+ // R2 outage — config unavailable. Asymmetric fail-mode:
176
+ // - Caller sent an explicit language: 503 (don't silently leak
177
+ // cross-language results to a caller who asked for filtering).
178
+ // - Caller defaulted to 'en': fall back to today's no-filter behavior
179
+ // so docs sites keep working through R2 incidents.
180
+ log('error', 'docs-search: fetchDocsConfig failed', {
181
+ project,
182
+ error: String(err),
183
+ languageWasExplicit,
184
+ });
185
+ if (languageWasExplicit) {
186
+ return NextResponse.json(
187
+ { error: 'Search temporarily unavailable' },
188
+ { status: 503, headers: CORS_HEADERS },
189
+ );
190
+ }
191
+ config = null;
192
+ killSwitch = await killSwitchPromise.catch(() => false);
193
+ }
194
+
195
+ if (killSwitch) {
196
+ // Operators have flipped the kill switch for this project — log so we
197
+ // can spot escapes. Not an error, but worth surfacing.
198
+ log('warn', 'docs-search: locale filter kill switch is enabled', {
199
+ project,
200
+ });
201
+ }
202
+
203
+ const applyLocaleFilter = !killSwitch && !!config && isMultiLanguageConfig(config);
204
+ const effectiveLocale = applyLocaleFilter ? language : undefined;
205
+
128
206
  // --- Semantic vector search ---
129
207
  const startMs = Date.now();
130
208
  let chunks;
131
209
  try {
132
- chunks = await querySimilarChunks(project, query.trim(), limit);
210
+ chunks = await querySimilarChunks(project, query.trim(), limit, {
211
+ locale: effectiveLocale,
212
+ });
133
213
  } catch (err) {
134
214
  console.error('Vector search failed:', err);
135
215
  return NextResponse.json(
@@ -162,7 +242,7 @@ export async function POST(
162
242
  });
163
243
 
164
244
  return NextResponse.json(
165
- { results, query: query.trim(), total: results.length, durationMs },
245
+ { results, query: query.trim(), language, total: results.length, durationMs },
166
246
  { status: 200, headers: CORS_HEADERS },
167
247
  );
168
248
  }
@@ -8,10 +8,17 @@ import { CodeBlockCopyButton } from '@/components/CodeBlockCopyButton';
8
8
  import { HeaderLinkCopy } from '@/components/HeaderLinkCopy';
9
9
  import { FontAwesomeLoader } from '@/components/FontAwesomeLoader';
10
10
  import { JdReadySentinel } from '@/components/JdReadySentinel';
11
+ import { HtmlLangSync } from '@/components/HtmlLangSync';
11
12
  import { FA_CSS_HREF } from '@/lib/font-awesome';
12
13
  import { getDocsConfig } from '@/lib/docs';
13
14
  import { getDocsConfig as getIsrDocsConfig } from '@/lib/docs-isr';
14
- import { isIsrMode, getProjectFromRequest, getHostAtDocs } from '@/lib/page-isr-helpers';
15
+ import {
16
+ isIsrMode,
17
+ getProjectFromRequest,
18
+ getHostAtDocs,
19
+ getLanguageFromRequest,
20
+ } from '@/lib/page-isr-helpers';
21
+ import { isRTLLanguage, resolveLanguageWithFallback } from '@/lib/language-utils';
15
22
  import { getTheme, type ThemeName } from '@/themes';
16
23
  import { generateFontImports, generateFontVariables, isPreloadedFont, getPrimaryFontFamily } from '@/lib/fonts';
17
24
  import fs from 'fs';
@@ -269,8 +276,17 @@ export default async function RootLayout({
269
276
  // fetch. The unlock page owns its own visuals.
270
277
  const headersList = await headers();
271
278
  if (headersList.get('x-jd-unlock-mode') === '1') {
279
+ // Unlock mode short-circuits before we fetch project config, so we
280
+ // can't consult `config.navigation.languages` here. The middleware
281
+ // already extracts language from the original `from` path
282
+ // (proxy.ts), so the header is our only signal — fall back to "en".
283
+ const unlockLang = resolveLanguageWithFallback(
284
+ getLanguageFromRequest(headersList),
285
+ undefined,
286
+ );
287
+ const unlockDir = isRTLLanguage(unlockLang) ? 'rtl' : undefined;
272
288
  return (
273
- <html lang="en">
289
+ <html lang={unlockLang} dir={unlockDir}>
274
290
  <head>
275
291
  <meta name="viewport" content="width=device-width, initial-scale=1" />
276
292
  <meta name="robots" content="noindex, nofollow" />
@@ -371,8 +387,14 @@ export default async function RootLayout({
371
387
  ? getAnalyticsScript(resolvedProjectSlug)
372
388
  : null;
373
389
 
390
+ // Project default — what `lang` resolves to when no path locale is present.
391
+ // HtmlLangSync uses it on soft-nav back to a non-localized path (e.g. `/`).
392
+ const projectDefaultLang = resolveLanguageWithFallback(null, config.navigation?.languages);
393
+ const lang = getLanguageFromRequest(headersList) ?? projectDefaultLang;
394
+ const dir = isRTLLanguage(lang) ? 'rtl' : undefined;
395
+
374
396
  return (
375
- <html lang="en" suppressHydrationWarning data-scroll-behavior="smooth">
397
+ <html lang={lang} dir={dir} suppressHydrationWarning data-scroll-behavior="smooth">
376
398
  <head>
377
399
  {/* Add viewport meta for mobile */}
378
400
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -559,6 +581,7 @@ export default async function RootLayout({
559
581
  <CodeBlockCopyButton />
560
582
  <HeaderLinkCopy />
561
583
  <FontAwesomeLoader />
584
+ <HtmlLangSync defaultLanguage={projectDefaultLang} />
562
585
  </ThemeProvider>
563
586
  {/* Crisp Chat */}
564
587
  {config.integrations?.crisp?.websiteId &&
@@ -0,0 +1,38 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+ import { extractLanguageFromPath, isRTLLanguage } from '@/lib/language-utils';
6
+ import type { LanguageCode } from '@/lib/docs-types';
7
+
8
+ interface HtmlLangSyncProps {
9
+ /** Project's resolved default language — used when the path has no locale prefix. */
10
+ defaultLanguage: LanguageCode;
11
+ }
12
+
13
+ /**
14
+ * Keeps `<html lang>` and `<html dir>` in sync with the current pathname
15
+ * across client-side navigations.
16
+ *
17
+ * The root layout only renders server-side on the initial request. Soft
18
+ * navigations (`router.push`, `<Link>`) leave the html attributes stale at
19
+ * the value the server emitted, so switching locale via the dropdown left
20
+ * `lang` pointing at the previous language until a hard refresh.
21
+ */
22
+ export function HtmlLangSync({ defaultLanguage }: HtmlLangSyncProps) {
23
+ const pathname = usePathname();
24
+
25
+ useEffect(() => {
26
+ const lang = extractLanguageFromPath(pathname || '/') ?? defaultLanguage;
27
+ const html = document.documentElement;
28
+ if (html.lang !== lang) html.lang = lang;
29
+
30
+ if (isRTLLanguage(lang)) {
31
+ if (html.dir !== 'rtl') html.dir = 'rtl';
32
+ } else if (html.dir) {
33
+ html.removeAttribute('dir');
34
+ }
35
+ }, [pathname, defaultLanguage]);
36
+
37
+ return null;
38
+ }
@@ -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';