jamdesk 1.1.35 → 1.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/integration/init.integration.test.js +41 -0
- package/dist/__tests__/integration/init.integration.test.js.map +1 -1
- package/dist/__tests__/unit/docs-config.test.js +17 -0
- package/dist/__tests__/unit/docs-config.test.js.map +1 -1
- package/dist/__tests__/unit/init.test.js +2 -1
- package/dist/__tests__/unit/init.test.js.map +1 -1
- package/dist/lib/docs-config.d.ts +4 -1
- package/dist/lib/docs-config.d.ts.map +1 -1
- package/dist/lib/docs-config.js +27 -23
- package/dist/lib/docs-config.js.map +1 -1
- package/package.json +1 -1
- package/templates/api-reference/openapi-example.mdx +55 -0
- package/templates/api-reference/request-response-examples.mdx +210 -0
- package/templates/docs.json +27 -0
- package/templates/openapi/example-api.yaml +185 -0
- package/vendored/app/[[...slug]]/page.tsx +26 -8
- package/vendored/app/api/chat/[project]/route.ts +53 -3
- package/vendored/app/api/docs-search/[project]/search/route.ts +83 -3
- package/vendored/app/layout.tsx +26 -3
- package/vendored/components/HtmlLangSync.tsx +38 -0
- package/vendored/components/mdx/OpenApiEndpoint.tsx +2 -1
- package/vendored/components/navigation/LanguageSelector.tsx +18 -21
- package/vendored/components/navigation/TableOfContents.tsx +18 -3
- package/vendored/components/search/SearchModal.tsx +7 -14
- package/vendored/hooks/useChat.ts +22 -4
- package/vendored/lib/chat-prompt.ts +1 -1
- package/vendored/lib/chat-tools.ts +3 -0
- package/vendored/lib/embedding-chunker.ts +18 -2
- package/vendored/lib/language-codes.json +27 -0
- package/vendored/lib/language-utils.ts +98 -6
- package/vendored/lib/link-rewriter.ts +67 -0
- package/vendored/lib/locale-helpers.ts +62 -0
- package/vendored/lib/middleware-helpers.ts +57 -2
- package/vendored/lib/openapi/code-examples.ts +5 -6
- package/vendored/lib/openapi/derive-auth.ts +46 -0
- package/vendored/lib/openapi/index.ts +7 -0
- package/vendored/lib/openapi/parser.ts +7 -2
- package/vendored/lib/openapi/resolve-server-url.ts +14 -0
- package/vendored/lib/openapi/types.ts +2 -0
- package/vendored/lib/page-isr-helpers.ts +20 -0
- package/vendored/lib/path-safety.ts +96 -0
- package/vendored/lib/search-client.ts +67 -10
- package/vendored/lib/seo.ts +80 -13
- package/vendored/lib/static-artifacts.ts +25 -1
- package/vendored/lib/vector-store.ts +70 -17
- package/vendored/scripts/build-search-index.cjs +59 -0
- package/vendored/scripts/validate-links.cjs +21 -66
- package/vendored/themes/base.css +5 -0
- package/vendored/workspace-package-lock.json +16 -16
package/templates/docs.json
CHANGED
|
@@ -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
|
|
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={
|
|
657
|
-
authHeaderName={
|
|
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={
|
|
678
|
-
authHeaderName={
|
|
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
|
-
|
|
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
|
}
|
package/vendored/app/layout.tsx
CHANGED
|
@@ -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 {
|
|
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=
|
|
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=
|
|
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]
|
|
920
|
+
const baseUrl = resolveServerUrl(servers[0]);
|
|
920
921
|
|
|
921
922
|
// Playground state
|
|
922
923
|
const showPlayground = playgroundDisplay !== 'none';
|