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
@@ -17,6 +17,7 @@ import { redis } from './redis';
17
17
  import { getForwardedHosts, isJamdeskDomain } from './domain-helpers';
18
18
  import { getRedirects, matchRedirect, mergeQueryStrings, isInvalidDestination } from './redirect-matcher';
19
19
  import { ASSET_PREFIX } from './docs-types';
20
+ import { extractLanguageFromPath } from './language-utils';
20
21
  import type { NextRequest } from 'next/server';
21
22
 
22
23
  /**
@@ -536,25 +537,79 @@ export function getHostAtDocsRedirect(
536
537
  return null;
537
538
  }
538
539
 
540
+ /**
541
+ * Headers we own end-to-end: written by middleware, trusted by the layout.
542
+ * Strip any inbound copy from the cloned request before we set our own,
543
+ * otherwise a client can send `x-jd-language: ar` (or `x-project-slug: evil`)
544
+ * and the layout will trust it. Same shape of bug as inbound `x-forwarded-*`.
545
+ */
546
+ const TRUSTED_PROXY_HEADERS = [
547
+ 'x-project-slug',
548
+ 'x-host-at-docs',
549
+ 'x-jd-language',
550
+ ] as const;
551
+
539
552
  /**
540
553
  * Build response headers with project context.
541
554
  *
555
+ * Sets:
556
+ * - x-project-slug — resolved project for downstream handlers
557
+ * - x-host-at-docs — whether docs are mounted at /docs
558
+ * - x-jd-language — locale code if the path starts with one (e.g. /fr/...)
559
+ *
560
+ * Strips any client-supplied copies of those headers from the inbound
561
+ * request to prevent header smuggling.
562
+ *
542
563
  * @param projectSlug - Resolved project slug
543
- * @param hostAtDocs - Whether docs are hosted at /docs
544
564
  * @param existingHeaders - Existing request headers to clone
565
+ * @param hostAtDocs - Whether docs are hosted at /docs
566
+ * @param pathname - The request pathname (request.nextUrl.pathname or, for
567
+ * the unlock direct-hit branch, the `from` query param). Used to derive
568
+ * the active locale so the root layout can emit <html lang>.
545
569
  * @returns New headers with project context added
546
570
  */
547
571
  export function buildProjectHeaders(
548
572
  projectSlug: string,
549
573
  existingHeaders: Headers,
550
- hostAtDocs = true
574
+ hostAtDocs: boolean,
575
+ pathname: string,
551
576
  ): Headers {
552
577
  const newHeaders = new Headers(existingHeaders);
578
+ for (const h of TRUSTED_PROXY_HEADERS) {
579
+ newHeaders.delete(h);
580
+ }
581
+
553
582
  newHeaders.set('x-project-slug', projectSlug);
554
583
  newHeaders.set('x-host-at-docs', hostAtDocs ? 'true' : 'false');
584
+
585
+ const language = extractLanguageFromPath(pathname);
586
+ if (language) {
587
+ newHeaders.set('x-jd-language', language);
588
+ }
589
+
555
590
  return newHeaders;
556
591
  }
557
592
 
593
+ /**
594
+ * Recover a pathname from the `from` query param on /jd/unlock.
595
+ *
596
+ * The unlock URL itself carries no locale ("/jd/unlock"), but the user's
597
+ * original path is encoded in `?from=` (e.g. `?from=%2Ffr%2Ffoo`). The
598
+ * proxy uses this to derive the active locale so the unlock page renders
599
+ * with the user's language, not the project default.
600
+ *
601
+ * Returns "" when input is null, empty, or unparseable. The caller treats
602
+ * this as "no language signal available" and falls back to project default.
603
+ */
604
+ export function pathnameFromUnlockFrom(from: string | null): string {
605
+ if (!from) return '';
606
+ try {
607
+ return new URL(from, 'http://x').pathname;
608
+ } catch {
609
+ return '';
610
+ }
611
+ }
612
+
558
613
  /**
559
614
  * Asset extensions that need to be routed to the assets API.
560
615
  */
@@ -11,11 +11,10 @@ import type {
11
11
  CodeExample,
12
12
  JsonSchema,
13
13
  } from './types';
14
+ import type { ApiAuthMethod } from '../docs-types';
15
+ import { resolveServerUrl } from './resolve-server-url';
14
16
 
15
- /**
16
- * Supported auth methods from docs.json
17
- */
18
- export type AuthMethod = 'bearer' | 'basic' | 'key' | 'cobo';
17
+ export type AuthMethod = ApiAuthMethod;
19
18
 
20
19
  /**
21
20
  * Configuration for code example generation
@@ -194,7 +193,7 @@ export function buildGeneratorContext(
194
193
  config: CodeExampleConfig
195
194
  ): GeneratorContext {
196
195
  const { method, path, parameters, requestBody } = endpoint;
197
- const baseUrl = endpoint.servers[0]?.url || DEFAULT_BASE_URL;
196
+ const baseUrl = resolveServerUrl(endpoint.servers[0]) || DEFAULT_BASE_URL;
198
197
  const formattedPath = formatPathWithParams(path, parameters);
199
198
  const queryString = formatQueryParams(parameters);
200
199
  const fullUrl = `${baseUrl}${formattedPath}${queryString}`;
@@ -257,7 +256,7 @@ export function generatePythonExample(
257
256
 
258
257
  // Python handles query params separately (params=params in request call),
259
258
  // so we build the URL without query string
260
- const baseUrl = endpoint.servers[0]?.url || DEFAULT_BASE_URL;
259
+ const baseUrl = resolveServerUrl(endpoint.servers[0]) || DEFAULT_BASE_URL;
261
260
  const formattedPath = formatPathWithParams(endpoint.path, parameters);
262
261
  const pythonUrl = `${baseUrl}${formattedPath}`;
263
262
 
@@ -0,0 +1,46 @@
1
+ import type { AuthMethod } from './code-examples';
2
+ import type { SecurityRequirement } from './types';
3
+
4
+ export interface DerivedAuth {
5
+ method?: AuthMethod;
6
+ headerName?: string;
7
+ }
8
+
9
+ /**
10
+ * Derive playground auth config from a parsed OpenAPI security list.
11
+ *
12
+ * Returns `{}` when no scheme is renderable in the playground (empty list,
13
+ * apiKey-in-query/cookie, etc). docs.json `api.mdx.auth` should still take
14
+ * precedence over this — callers in page.tsx merge with the docs.json value first.
15
+ *
16
+ * Falls through past schemes the playground can't render, so a spec offering
17
+ * "either apiKey-in-query or bearer" still works.
18
+ */
19
+ export function deriveAuthFromSecurity(security: SecurityRequirement[]): DerivedAuth {
20
+ for (const req of security) {
21
+ const mapped = mapSingle(req);
22
+ if (mapped.method) return mapped;
23
+ }
24
+ return {};
25
+ }
26
+
27
+ function mapSingle(req: SecurityRequirement): DerivedAuth {
28
+ if (req.type === 'http') {
29
+ if (req.scheme === 'bearer') return { method: 'bearer' };
30
+ if (req.scheme === 'basic') return { method: 'basic' };
31
+ return {};
32
+ }
33
+
34
+ if (req.type === 'apiKey') {
35
+ if (req.in === 'header' && req.parameterName) {
36
+ return { method: 'key', headerName: req.parameterName };
37
+ }
38
+ return {};
39
+ }
40
+
41
+ if (req.type === 'oauth2' || req.type === 'openIdConnect') {
42
+ return { method: 'bearer' };
43
+ }
44
+
45
+ return {};
46
+ }
@@ -90,3 +90,10 @@ export {
90
90
  generateNavigationGroups,
91
91
  getSpecStats,
92
92
  } from './generator';
93
+
94
+ // Auth derivation
95
+ export type { DerivedAuth } from './derive-auth';
96
+ export { deriveAuthFromSecurity } from './derive-auth';
97
+
98
+ // Server URL resolution
99
+ export { resolveServerUrl } from './resolve-server-url';
@@ -5,7 +5,7 @@
5
5
  * Extracts parameters, request bodies, responses, and security info.
6
6
  */
7
7
 
8
- import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
8
+ import type { OpenAPI, OpenAPIV3 } from 'openapi-types';
9
9
  import type {
10
10
  HttpMethod,
11
11
  ParsedOpenApiFrontmatter,
@@ -219,10 +219,14 @@ function parseSecuritySchemes(api: OpenAPI.Document): Map<string, SecurityRequir
219
219
  scheme: 'scheme' in s ? s.scheme : undefined,
220
220
  in: 'in' in s ? s.in as SecurityRequirement['in'] : undefined,
221
221
  bearerFormat: 'bearerFormat' in s ? s.bearerFormat : undefined,
222
+ parameterName:
223
+ s.type === 'apiKey' && 'name' in s
224
+ ? (s as OpenAPIV3.ApiKeySecurityScheme).name
225
+ : undefined,
222
226
  });
223
227
  }
224
228
  }
225
-
229
+
226
230
  // Swagger 2.0
227
231
  if ('securityDefinitions' in api) {
228
232
  const defs = (api as any).securityDefinitions;
@@ -233,6 +237,7 @@ function parseSecuritySchemes(api: OpenAPI.Document): Map<string, SecurityRequir
233
237
  type: s.type,
234
238
  scheme: s.scheme,
235
239
  in: s.in,
240
+ parameterName: s.type === 'apiKey' ? s.name : undefined,
236
241
  });
237
242
  }
238
243
  }
@@ -0,0 +1,14 @@
1
+ import type { ServerInfo } from './types';
2
+
3
+ /**
4
+ * Substitute `{varname}` placeholders in an OpenAPI server URL with the
5
+ * defaults declared in `server.variables`. Unknown placeholders are left
6
+ * intact so they remain visible to the user rather than producing a broken
7
+ * URL fragment.
8
+ */
9
+ export function resolveServerUrl(server: ServerInfo | undefined): string | undefined {
10
+ if (!server) return undefined;
11
+ return server.url.replace(/\{(\w+)\}/g, (_, key: string) => {
12
+ return server.variables?.[key]?.default ?? `{${key}}`;
13
+ });
14
+ }
@@ -120,6 +120,8 @@ export interface SecurityRequirement {
120
120
  scheme?: string; // For http type: 'bearer', 'basic'
121
121
  in?: 'query' | 'header' | 'cookie'; // For apiKey type
122
122
  bearerFormat?: string;
123
+ /** For apiKey schemes: the header / query / cookie parameter name (e.g. 'X-Api-Key'). */
124
+ parameterName?: string;
123
125
  }
124
126
 
125
127
  /**
@@ -5,6 +5,9 @@
5
5
  * These are extracted to be testable without importing Next.js components.
6
6
  */
7
7
 
8
+ import { isValidLanguageCode } from './language-utils';
9
+ import type { LanguageCode } from './docs-types';
10
+
8
11
  /**
9
12
  * Check if ISR mode is enabled.
10
13
  */
@@ -155,3 +158,20 @@ export function getBaseUrl(headers: Headers, projectSlug: string, hostAtDocs = f
155
158
  // Fallback to subdomain URL (subdomains always serve at root, never /docs)
156
159
  return `https://${projectSlug}.jamdesk.app`;
157
160
  }
161
+
162
+ /**
163
+ * Read the active locale from request headers.
164
+ *
165
+ * The middleware (proxy.ts → buildProjectHeaders) sets `x-jd-language` based
166
+ * on the URL path (e.g. `/fr/intro` → "fr"). Returns null when the header is
167
+ * absent (path had no language prefix, or middleware did not run — e.g. local
168
+ * CLI dev) or when the value is not a recognised LanguageCode.
169
+ *
170
+ * Callers should fall back to the project's default language and then "en".
171
+ */
172
+ export function getLanguageFromRequest(headers: Headers): LanguageCode | null {
173
+ const value = headers.get('x-jd-language');
174
+ if (!value) return null;
175
+ if (!isValidLanguageCode(value)) return null;
176
+ return value;
177
+ }
@@ -97,6 +97,102 @@ export function validateDocsPath(
97
97
  return { valid: true, resolvedPath: resolved };
98
98
  }
99
99
 
100
+ /**
101
+ * Validates a git ref (branch name) for use in `git clone --branch <ref>`.
102
+ *
103
+ * Combines two rule sets:
104
+ * 1. git's `check-ref-format` constraints (forbids `..`, `@{`, trailing `.lock`,
105
+ * leading/trailing `/`, control chars, and a handful of ASCII punctuation).
106
+ * 2. A strict shell-safety allowlist — even though `cloneRepo` now uses
107
+ * `execFileSync` with an argv array, this validator is defense-in-depth so
108
+ * a future refactor that reintroduces a shell cannot be silently exploited.
109
+ *
110
+ * Allowed characters: A–Z, a–z, 0–9, and `._-/`.
111
+ *
112
+ * @param ref - Git ref received from a client (webhook payload / API request)
113
+ * @returns `{ valid: true }` or `{ valid: false, error }`
114
+ */
115
+ export function validateGitRef(ref: unknown): SlugValidationResult {
116
+ if (typeof ref !== 'string') {
117
+ return { valid: false, error: 'Invalid git ref: must be a string' };
118
+ }
119
+
120
+ // Deliberately do NOT trim. The caller passes `ref` verbatim into argv, so
121
+ // the validator must judge the exact string the caller will use. If a
122
+ // consumer wants to allow trimming, they must trim before calling.
123
+ if (ref.length === 0) {
124
+ return { valid: false, error: 'Invalid git ref: cannot be empty' };
125
+ }
126
+ if (ref.length > 255) {
127
+ return { valid: false, error: 'Invalid git ref: exceeds 255 characters' };
128
+ }
129
+
130
+ // Allowlist: alphanumerics plus `._-/` only.
131
+ // Everything outside this set — including every shell metacharacter
132
+ // (`;`, backtick, `$`, `|`, `&`, `<`, `>`, `(`, `)`, `{`, `}`, `[`, `]`,
133
+ // single/double quotes, `\`, `#`, `!`, `*`, `?`, `~`, `^`, `:`, spaces,
134
+ // tabs, newlines, null bytes, and all other control chars) — is rejected.
135
+ if (!/^[A-Za-z0-9._\-\/]+$/.test(ref)) {
136
+ return { valid: false, error: 'Invalid git ref: contains disallowed characters' };
137
+ }
138
+
139
+ // The following enforce git check-ref-format rules that the allowlist
140
+ // cannot express positionally — characters like `.`, `/`, and `-` are
141
+ // permitted in general but disallowed in specific positions or sequences.
142
+ if (ref.includes('..')) {
143
+ return { valid: false, error: 'Invalid git ref: cannot contain ".." sequences' };
144
+ }
145
+ if (ref.startsWith('/') || ref.endsWith('/')) {
146
+ return { valid: false, error: 'Invalid git ref: cannot start or end with "/"' };
147
+ }
148
+ if (ref.startsWith('.') || ref.endsWith('.')) {
149
+ return { valid: false, error: 'Invalid git ref: cannot start or end with "."' };
150
+ }
151
+ if (ref.endsWith('.lock')) {
152
+ return { valid: false, error: 'Invalid git ref: cannot end with ".lock"' };
153
+ }
154
+ // Block leading `-` so git does not parse the ref as a CLI flag
155
+ // (e.g. `-oProxyCommand=...`, `--upload-pack=...`).
156
+ if (ref.startsWith('-')) {
157
+ return { valid: false, error: 'Invalid git ref: cannot start with "-"' };
158
+ }
159
+
160
+ return { valid: true };
161
+ }
162
+
163
+ /**
164
+ * Validates a GitHub `owner/repo` full-name string.
165
+ *
166
+ * Allowlist per segment: `[A-Za-z0-9][A-Za-z0-9_.-]*` with 1–100 chars each.
167
+ * Exactly one `/` separating owner and repo. Leading `-` is rejected so the
168
+ * value cannot be mistaken for a CLI flag even if it ever escapes argv to a
169
+ * shell (defense-in-depth).
170
+ *
171
+ * @param repoFullName - Full repo name from a webhook/API payload
172
+ * @returns `{ valid: true }` or `{ valid: false, error }`
173
+ */
174
+ export function validateRepoFullName(repoFullName: unknown): SlugValidationResult {
175
+ if (typeof repoFullName !== 'string') {
176
+ return { valid: false, error: 'Invalid repoFullName: must be a string' };
177
+ }
178
+
179
+ // No trim: validate the exact string the caller will pass into argv.
180
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(repoFullName)) {
181
+ return {
182
+ valid: false,
183
+ error: 'Invalid repoFullName: must match owner/repo with alphanumerics, "_", ".", or "-"',
184
+ };
185
+ }
186
+
187
+ // Defense in depth against path traversal: `..` segments could resolve
188
+ // outside the intended repos dir if this value ever flows into a path-join.
189
+ if (repoFullName.includes('..')) {
190
+ return { valid: false, error: 'Invalid repoFullName: cannot contain ".." sequences' };
191
+ }
192
+
193
+ return { valid: true };
194
+ }
195
+
100
196
  /**
101
197
  * Validates a project slug for use in file paths.
102
198
  * Defense-in-depth: slugs come from Firestore but should still be validated
@@ -1,5 +1,6 @@
1
1
  // Client-side search with Orama (BM25 ranking)
2
2
  import { create, insertMultiple, search as oramaSearch, type Orama } from '@orama/orama';
3
+ import { resolveLocaleWithLoweredSet } from './language-utils';
3
4
 
4
5
  export interface SearchResult {
5
6
  id: string;
@@ -9,6 +10,8 @@ export interface SearchResult {
9
10
  slug: string;
10
11
  section?: string;
11
12
  type?: 'api' | 'component' | 'guide' | 'help' | 'quickstart';
13
+ /** Language of the document. Empty string for default-language pages. */
14
+ locale: string;
12
15
  }
13
16
 
14
17
  type OramaDb = Orama<{
@@ -19,6 +22,7 @@ type OramaDb = Orama<{
19
22
  slug: 'string';
20
23
  section: 'string';
21
24
  type: 'string';
25
+ locale: 'enum';
22
26
  }>;
23
27
 
24
28
  // Orama database instance
@@ -33,6 +37,18 @@ let initPromise: Promise<void> | null = null;
33
37
  let lastEtag = '';
34
38
  let lastParsedData: SearchResult[] | null = null;
35
39
 
40
+ /**
41
+ * Non-empty locale values present in the currently-committed index.
42
+ * Empty Set means either (a) the index has zero translated docs (single-
43
+ * language project) or (b) the index is a legacy build that predates the
44
+ * locale field. In either case the language filter is a no-op.
45
+ *
46
+ * `projectLocalesLowered` mirrors the same set lowercased — kept around so
47
+ * `resolveActiveLocale` doesn't re-lowercase per keystroke.
48
+ */
49
+ let projectLocales: Set<string> = new Set();
50
+ let projectLocalesLowered: ReadonlySet<string> = new Set();
51
+
36
52
  /**
37
53
  * Cheap fingerprint: count + first/last IDs + a sample of content lengths.
38
54
  * Detects new/removed pages AND content edits (which change content length).
@@ -60,20 +76,34 @@ async function buildIndex(data: SearchResult[], etag: string): Promise<void> {
60
76
  slug: 'string',
61
77
  section: 'string',
62
78
  type: 'string',
79
+ locale: 'enum',
63
80
  },
64
81
  });
65
82
 
66
- const normalizedData = data.map(item => ({
67
- id: item.id,
68
- title: item.title,
69
- description: item.description || '',
70
- content: item.content,
71
- slug: item.slug,
72
- section: item.section || '',
73
- type: item.type || 'guide',
74
- }));
83
+ // Detect a legacy (pre-feature) index by inspecting the RAW input — once the
84
+ // ?? '' fallback runs in the map below, we can't tell "doc had locale=''" from
85
+ // "doc was missing the locale field entirely".
86
+ const seenLocales = new Set<string>();
87
+ const normalizedData = data.map(item => {
88
+ const raw = item.locale;
89
+ if (typeof raw === 'string' && raw.length > 0) seenLocales.add(raw);
90
+ return {
91
+ id: item.id,
92
+ title: item.title,
93
+ description: item.description || '',
94
+ content: item.content,
95
+ slug: item.slug,
96
+ section: item.section || '',
97
+ type: item.type || 'guide',
98
+ locale: raw ?? '',
99
+ };
100
+ });
75
101
 
76
102
  await insertMultiple(db, normalizedData);
103
+
104
+ projectLocales = seenLocales;
105
+ projectLocalesLowered = new Set(Array.from(seenLocales, (l) => l.toLowerCase()));
106
+
77
107
  committedFingerprint = buildingFingerprint;
78
108
  lastParsedData = data;
79
109
  lastEtag = etag;
@@ -104,7 +134,11 @@ export function getLastData(etag: string | null): SearchResult[] | null {
104
134
  return lastParsedData;
105
135
  }
106
136
 
107
- export async function search(query: string, limit = 10): Promise<SearchResult[]> {
137
+ export async function search(
138
+ query: string,
139
+ limit = 10,
140
+ language?: string,
141
+ ): Promise<SearchResult[]> {
108
142
  if (!db) {
109
143
  console.warn('Search database not initialized');
110
144
  return [];
@@ -114,6 +148,12 @@ export async function search(query: string, limit = 10): Promise<SearchResult[]>
114
148
  return [];
115
149
  }
116
150
 
151
+ // Legacy-index safety: if the committed index has zero translated docs
152
+ // (single-language project OR pre-feature search-data.json), skip the where
153
+ // clause so un-rebuilt R2 files behave identically to before this feature.
154
+ const useFilter = projectLocales.size > 0;
155
+ const targetLocale = language ?? '';
156
+
117
157
  const results = await oramaSearch(db, {
118
158
  term: query,
119
159
  limit,
@@ -124,6 +164,7 @@ export async function search(query: string, limit = 10): Promise<SearchResult[]>
124
164
  description: 1,
125
165
  content: 0.5,
126
166
  },
167
+ ...(useFilter ? { where: { locale: { eq: targetLocale } } } : {}),
127
168
  });
128
169
 
129
170
  return results.hits.map(hit => hit.document as unknown as SearchResult);
@@ -134,3 +175,19 @@ export function isInitialized(): boolean {
134
175
  return db !== null;
135
176
  }
136
177
 
178
+ /**
179
+ * Resolve the locale that the search filter should target for a given pathname,
180
+ * gated by the locales actually present in the currently-committed index.
181
+ *
182
+ * Returns:
183
+ * - `''` when there is no committed index yet, OR no translations exist, OR
184
+ * the pathname's prefix is not a translated locale.
185
+ * - The canonical language code when the prefix matches an indexed locale.
186
+ *
187
+ * Caller (SearchModal) should pass the result to `search()`. Empty string and
188
+ * undefined are equivalent — both target the default-language doc set.
189
+ */
190
+ export function resolveActiveLocale(pathname: string): string {
191
+ if (projectLocalesLowered.size === 0) return '';
192
+ return resolveLocaleWithLoweredSet(pathname, projectLocalesLowered);
193
+ }
@@ -99,9 +99,63 @@ export interface PageFrontmatter {
99
99
  };
100
100
  }
101
101
 
102
+ /**
103
+ * Walk a navigation node (LanguageConfig, GroupConfig, etc.) and collect every
104
+ * page path declared anywhere underneath it. Pages can appear under `pages`,
105
+ * `groups`, `tabs`, `anchors`, `dropdowns`, `products`, `versions`, or `menu`,
106
+ * and groups nest. Permissive `unknown` typing handles every container shape
107
+ * with one walker — keep the key list in sync with new container types in
108
+ * docs-types.ts (a future container would silently miss pages otherwise).
109
+ */
110
+ function collectPagePathsUnder(node: unknown, out: Set<string>): void {
111
+ if (!node || typeof node !== 'object') return;
112
+ const obj = node as Record<string, unknown>;
113
+
114
+ const pages = obj.pages;
115
+ if (Array.isArray(pages)) {
116
+ for (const entry of pages) {
117
+ if (typeof entry === 'string') {
118
+ out.add(entry);
119
+ } else if (entry && typeof entry === 'object') {
120
+ const page = (entry as { page?: unknown }).page;
121
+ if (typeof page === 'string') out.add(page);
122
+ else collectPagePathsUnder(entry, out); // nested group inside `pages`
123
+ }
124
+ }
125
+ }
126
+
127
+ for (const key of ['groups', 'tabs', 'anchors', 'dropdowns', 'products', 'versions', 'menu']) {
128
+ const children = obj[key];
129
+ if (Array.isArray(children)) {
130
+ for (const child of children) collectPagePathsUnder(child, out);
131
+ }
132
+ }
133
+ }
134
+
135
+ // Same `LanguageConfig` reference is reused across every page render in a
136
+ // config's cache window (see r2-content.ts configCache + IsrContentLoader).
137
+ // Memoising avoids re-walking the nav tree on every generateMetadata call —
138
+ // a real cost for sites with many languages × many pages.
139
+ const langPagePathsCache = new WeakMap<LanguageConfig, Set<string>>();
140
+
141
+ function getLanguagePagePaths(langConfig: LanguageConfig): Set<string> {
142
+ let paths = langPagePathsCache.get(langConfig);
143
+ if (!paths) {
144
+ paths = new Set<string>();
145
+ collectPagePathsUnder(langConfig, paths);
146
+ langPagePathsCache.set(langConfig, paths);
147
+ }
148
+ return paths;
149
+ }
150
+
102
151
  /**
103
152
  * Build hreflang alternate links for multi-language pages.
104
- * Generates links for each language version of the page, plus x-default.
153
+ *
154
+ * Only emits hreflang for languages whose navigation actually declares the
155
+ * current page — Ahrefs flagged 25+ pages where we advertised translations
156
+ * (`/docs/es/ai/overview`, `/docs/fr/ai/overview`) that returned 404 because
157
+ * partially-translated sections (e.g. AI section in jamdesk-docs is en-only)
158
+ * still got hreflang for every declared language.
105
159
  *
106
160
  * @param baseUrl - The base URL (e.g., "https://docs.example.com")
107
161
  * @param pagePath - The current page path (e.g., "es/getting-started")
@@ -124,22 +178,35 @@ function buildHreflangAlternates(
124
178
  // Detect current language from path
125
179
  const currentLang = extractLanguageFromPath(pagePath) || defaultLang;
126
180
 
127
- // Build alternates for each language
128
- const alternates: Record<string, string> = {};
129
-
181
+ // Keep only languages whose navigation declares this page — capturing the
182
+ // language-relative path so the build loop and x-default branch don't have
183
+ // to recompute it via transformLanguagePath.
184
+ const supported: Array<{ lang: LanguageCode; cleanPath: string }> = [];
130
185
  for (const langConfig of languages) {
131
- const lang = langConfig.language;
132
- // Transform current path to this language's version
133
- // transformLanguagePath returns paths relative to app root (e.g., '/es/intro' or '/intro')
134
- const langPath = transformLanguagePath(pagePath, currentLang, lang, defaultLang);
135
- const cleanPath = langPath.replace(/^\//, ''); // Remove leading slash for URL building
186
+ const langPath = transformLanguagePath(pagePath, currentLang, langConfig.language, defaultLang);
187
+ const cleanPath = langPath.replace(/^\//, '');
188
+ if (getLanguagePagePaths(langConfig).has(cleanPath)) {
189
+ supported.push({ lang: langConfig.language, cleanPath });
190
+ }
191
+ }
192
+
193
+ // One or zero language versions → no useful hreflang. `<html lang>` and page
194
+ // content are sufficient signals.
195
+ if (supported.length <= 1) {
196
+ return undefined;
197
+ }
198
+
199
+ const alternates: Record<string, string> = {};
200
+ for (const { lang, cleanPath } of supported) {
136
201
  alternates[lang] = cleanPath ? `${baseUrl}/${cleanPath}` : baseUrl;
137
202
  }
138
203
 
139
- // Add x-default pointing to the default language version
140
- const defaultPath = transformLanguagePath(pagePath, currentLang, defaultLang, defaultLang);
141
- const cleanDefaultPath = defaultPath.replace(/^\//, '');
142
- alternates['x-default'] = cleanDefaultPath ? `${baseUrl}/${cleanDefaultPath}` : baseUrl;
204
+ // x-default points to the default language only when it has the page; absence
205
+ // is fine per Google, whereas pointing at a 404 is what Ahrefs flagged.
206
+ const defaultEntry = supported.find((s) => s.lang === defaultLang);
207
+ if (defaultEntry) {
208
+ alternates['x-default'] = defaultEntry.cleanPath ? `${baseUrl}/${defaultEntry.cleanPath}` : baseUrl;
209
+ }
143
210
 
144
211
  return alternates;
145
212
  }