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
|
@@ -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
|
|
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]
|
|
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]
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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(
|
|
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
|
+
}
|
package/vendored/lib/seo.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
//
|
|
128
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
//
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
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
|
}
|