jamdesk 1.1.25 → 1.1.27
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/validate.integration.test.js +40 -1
- package/dist/__tests__/integration/validate.integration.test.js.map +1 -1
- package/dist/__tests__/unit/templates-consistency.test.d.ts +2 -0
- package/dist/__tests__/unit/templates-consistency.test.d.ts.map +1 -0
- package/dist/__tests__/unit/templates-consistency.test.js +19 -0
- package/dist/__tests__/unit/templates-consistency.test.js.map +1 -0
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +9 -6
- package/dist/commands/validate.js.map +1 -1
- package/dist/lib/navigation-validator.d.ts +39 -17
- package/dist/lib/navigation-validator.d.ts.map +1 -1
- package/dist/lib/navigation-validator.js +65 -36
- package/dist/lib/navigation-validator.js.map +1 -1
- package/dist/lib/openapi/types.d.ts +1 -0
- package/dist/lib/openapi/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/vendored/app/[[...slug]]/page.tsx +66 -29
- package/vendored/app/api/docs-search/[project]/search/route.ts +168 -0
- package/vendored/components/openapi/OpenApiError.tsx +35 -0
- package/vendored/lib/docs-search-auth.ts +95 -0
- package/vendored/lib/docs-types.ts +14 -4
- package/vendored/lib/extract-highlights.ts +2 -2
- package/vendored/lib/mcp-search.ts +39 -0
- package/vendored/lib/middleware-helpers.ts +21 -1
- package/vendored/lib/openapi/parser.ts +7 -3
- package/vendored/lib/openapi/types.ts +1 -0
- package/vendored/lib/validate-config.ts +118 -13
- package/vendored/schema/docs-schema.json +0 -3
- package/vendored/shared/navigation-validator.ts +103 -53
- package/vendored/shared/status-reporter.ts +1 -1
- package/vendored/workspace-package-lock.json +6 -6
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docs Search API — Semantic Search Endpoint
|
|
3
|
+
*
|
|
4
|
+
* REST API for external integrations (Intercom, Zendesk, custom chatbots)
|
|
5
|
+
* to search project documentation via vector similarity.
|
|
6
|
+
*
|
|
7
|
+
* Security:
|
|
8
|
+
* - Opaque API key auth (SHA-256 hash lookup in Upstash Redis; revocation
|
|
9
|
+
* is a Redis DEL, so there is no separate blocklist)
|
|
10
|
+
* - Per-key rate limiting (60 req/min)
|
|
11
|
+
* - CORS enabled (cross-origin access by design)
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* POST https://acme.jamdesk.app/_api/search
|
|
15
|
+
* Authorization: Bearer jd_live_<32 hex>
|
|
16
|
+
* {"query": "How do I authenticate?", "limit": 5}
|
|
17
|
+
*/
|
|
18
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
19
|
+
import { querySimilarChunks } from '@/lib/vector-store';
|
|
20
|
+
import { verifyApiKey } from '@/lib/docs-search-auth';
|
|
21
|
+
import { getBaseUrl, trackServerAnalytics } from '@/lib/route-helpers';
|
|
22
|
+
import { redis } from '@/lib/redis';
|
|
23
|
+
|
|
24
|
+
export const runtime = 'nodejs';
|
|
25
|
+
export const maxDuration = 30;
|
|
26
|
+
|
|
27
|
+
const CORS_HEADERS = {
|
|
28
|
+
'Access-Control-Allow-Origin': '*',
|
|
29
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
30
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const MAX_LIMIT = 20;
|
|
34
|
+
const DEFAULT_LIMIT = 5;
|
|
35
|
+
const MAX_QUERY_LENGTH = 500;
|
|
36
|
+
const RATE_LIMIT_PER_MIN = 60;
|
|
37
|
+
|
|
38
|
+
export async function OPTIONS(_request: NextRequest) {
|
|
39
|
+
return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function POST(
|
|
43
|
+
request: NextRequest,
|
|
44
|
+
context: { params: Promise<{ project: string }> },
|
|
45
|
+
): Promise<NextResponse> {
|
|
46
|
+
const { project } = await context.params;
|
|
47
|
+
|
|
48
|
+
// --- Auth: verify opaque key against Upstash ---
|
|
49
|
+
// A revoked key is a Redis DEL, so "not found" and "revoked" collapse
|
|
50
|
+
// into the same invalid_key reason — no separate blocklist check.
|
|
51
|
+
// RFC 7235: scheme is case-insensitive. Accept `Bearer`, `bearer`, etc.,
|
|
52
|
+
// and tolerate stray whitespace rather than 401-ing strict clients.
|
|
53
|
+
const token = (request.headers.get('Authorization') || '')
|
|
54
|
+
.replace(/^Bearer\s+/i, '')
|
|
55
|
+
.trim();
|
|
56
|
+
const verify = await verifyApiKey(token, project);
|
|
57
|
+
|
|
58
|
+
if (!verify.ok) {
|
|
59
|
+
const status =
|
|
60
|
+
verify.reason === 'wrong_project' ? 403 :
|
|
61
|
+
verify.reason === 'lookup_failed' ||
|
|
62
|
+
verify.reason === 'redis_unavailable' ? 503 :
|
|
63
|
+
401;
|
|
64
|
+
return NextResponse.json(
|
|
65
|
+
{ error: verify.reason },
|
|
66
|
+
{ status, headers: CORS_HEADERS },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Rate limiting: per key ID ---
|
|
71
|
+
// Always-call expire (not just on count === 1) — otherwise a transient
|
|
72
|
+
// failure between INCR and EXPIRE leaves the bucket immortal, slowly
|
|
73
|
+
// leaking Upstash keys. EXPIRE is idempotent.
|
|
74
|
+
if (redis) {
|
|
75
|
+
try {
|
|
76
|
+
const rlKey = `docs_search_rl:${verify.id}:${Math.floor(Date.now() / 60000)}`;
|
|
77
|
+
const count = await redis.incr(rlKey);
|
|
78
|
+
await redis.expire(rlKey, 120);
|
|
79
|
+
|
|
80
|
+
if (count > RATE_LIMIT_PER_MIN) {
|
|
81
|
+
return NextResponse.json(
|
|
82
|
+
{ error: 'Rate limit exceeded' },
|
|
83
|
+
{ status: 429, headers: { ...CORS_HEADERS, 'Retry-After': '60' } },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// Redis down — allow through
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- Parse & validate request body ---
|
|
92
|
+
let body: { query?: string; limit?: number };
|
|
93
|
+
try {
|
|
94
|
+
body = await request.json();
|
|
95
|
+
} catch {
|
|
96
|
+
return NextResponse.json(
|
|
97
|
+
{ error: 'Invalid JSON body' },
|
|
98
|
+
{ status: 400, headers: CORS_HEADERS },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { query, limit: rawLimit } = body;
|
|
103
|
+
|
|
104
|
+
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
|
105
|
+
return NextResponse.json(
|
|
106
|
+
{ error: 'Missing or empty "query" field' },
|
|
107
|
+
{ status: 400, headers: CORS_HEADERS },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (query.length > MAX_QUERY_LENGTH) {
|
|
112
|
+
return NextResponse.json(
|
|
113
|
+
{ error: `Query exceeds ${MAX_QUERY_LENGTH} characters` },
|
|
114
|
+
{ status: 400, headers: CORS_HEADERS },
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Use Number.isFinite so limit=0 clamps to 1 instead of falling to default
|
|
119
|
+
const parsedLimit = Number(rawLimit);
|
|
120
|
+
const effectiveLimit = Number.isFinite(parsedLimit)
|
|
121
|
+
? parsedLimit
|
|
122
|
+
: DEFAULT_LIMIT;
|
|
123
|
+
const limit = Math.min(
|
|
124
|
+
Math.max(1, Math.floor(effectiveLimit)),
|
|
125
|
+
MAX_LIMIT,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// --- Semantic vector search ---
|
|
129
|
+
const startMs = Date.now();
|
|
130
|
+
let chunks;
|
|
131
|
+
try {
|
|
132
|
+
chunks = await querySimilarChunks(project, query.trim(), limit);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error('Vector search failed:', err);
|
|
135
|
+
return NextResponse.json(
|
|
136
|
+
{ error: 'Search temporarily unavailable' },
|
|
137
|
+
{ status: 502, headers: CORS_HEADERS },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const durationMs = Date.now() - startMs;
|
|
141
|
+
|
|
142
|
+
const resolvedHost = request.headers.get('x-jamdesk-forwarded-host')
|
|
143
|
+
|| request.headers.get('x-original-host') || '';
|
|
144
|
+
const baseUrl = getBaseUrl(project, resolvedHost);
|
|
145
|
+
|
|
146
|
+
const results = chunks.map(chunk => ({
|
|
147
|
+
title: chunk.pageTitle,
|
|
148
|
+
section: chunk.sectionHeading || undefined,
|
|
149
|
+
slug: chunk.pageSlug,
|
|
150
|
+
content: chunk.content.slice(0, 500),
|
|
151
|
+
url: `${baseUrl}/${chunk.pageSlug}`,
|
|
152
|
+
score: Math.round(chunk.score * 1000) / 1000,
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
// --- Analytics (fire-and-forget) ---
|
|
156
|
+
trackServerAnalytics({
|
|
157
|
+
projectSlug: project,
|
|
158
|
+
type: 'docs_search',
|
|
159
|
+
query: query.trim(),
|
|
160
|
+
resultsCount: results.length,
|
|
161
|
+
source: `key:${verify.id}`,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return NextResponse.json(
|
|
165
|
+
{ results, query: query.trim(), total: results.length, durationMs },
|
|
166
|
+
{ status: 200, headers: CORS_HEADERS },
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenApiError — visible warning when OpenAPI spec resolution fails.
|
|
3
|
+
* Renders in all environments (dev, ISR, CLI) so users see the problem
|
|
4
|
+
* instead of a silently blank page.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
interface OpenApiErrorProps {
|
|
8
|
+
message: string;
|
|
9
|
+
slug: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function OpenApiError({ message, slug }: OpenApiErrorProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
role="alert"
|
|
16
|
+
style={{
|
|
17
|
+
margin: '1.5rem 0',
|
|
18
|
+
padding: '1rem 1.25rem',
|
|
19
|
+
borderRadius: '8px',
|
|
20
|
+
border: '1px solid #f59e0b',
|
|
21
|
+
backgroundColor: 'rgba(245, 158, 11, 0.08)',
|
|
22
|
+
fontSize: '0.875rem',
|
|
23
|
+
lineHeight: 1.5,
|
|
24
|
+
color: 'var(--color-text-primary, #1e293b)',
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
27
|
+
<div style={{ fontWeight: 600, marginBottom: '0.375rem' }}>
|
|
28
|
+
OpenAPI Error
|
|
29
|
+
</div>
|
|
30
|
+
<div style={{ color: 'var(--color-text-muted, #64748b)' }}>
|
|
31
|
+
Failed to load the OpenAPI specification for <code>{slug}</code>: {message}
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docs Search API key verification.
|
|
3
|
+
*
|
|
4
|
+
* Tokens are opaque: `jd_live_<32 hex chars>` (40 chars total, 128 bits
|
|
5
|
+
* of entropy from crypto.randomBytes). The `_live_` segment reserves
|
|
6
|
+
* namespace for a future `jd_test_` mode. The server hashes the token
|
|
7
|
+
* with SHA-256 and looks up `apikey:<hash>` in Upstash Redis — the
|
|
8
|
+
* dashboard function dual-writes this record on generate and deletes
|
|
9
|
+
* it on revoke. No JWT, no signing secret, no expiration.
|
|
10
|
+
*/
|
|
11
|
+
import {createHash} from 'crypto';
|
|
12
|
+
import {redis} from './redis';
|
|
13
|
+
import {parseRedisConfig} from './domain-helpers';
|
|
14
|
+
|
|
15
|
+
const KEY_FORMAT = /^jd_live_[0-9a-f]{32}$/;
|
|
16
|
+
|
|
17
|
+
export type VerifyResult =
|
|
18
|
+
| {ok: true; id: string}
|
|
19
|
+
| {ok: false; reason: VerifyFailure};
|
|
20
|
+
|
|
21
|
+
export type VerifyFailure =
|
|
22
|
+
| 'invalid_key_format'
|
|
23
|
+
| 'invalid_key'
|
|
24
|
+
| 'wrong_project'
|
|
25
|
+
| 'lookup_failed'
|
|
26
|
+
| 'redis_unavailable';
|
|
27
|
+
|
|
28
|
+
interface StoredKey {
|
|
29
|
+
projectSlug: string;
|
|
30
|
+
id: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hashApiKey(rawKey: string): string {
|
|
34
|
+
return createHash('sha256').update(rawKey).digest('hex');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseStoredKey(raw: unknown): StoredKey | null {
|
|
38
|
+
let parsed: Record<string, unknown> | null;
|
|
39
|
+
try {
|
|
40
|
+
parsed = parseRedisConfig(raw);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
parsed &&
|
|
46
|
+
typeof parsed.projectSlug === 'string' &&
|
|
47
|
+
typeof parsed.id === 'string'
|
|
48
|
+
) {
|
|
49
|
+
return parsed as unknown as StoredKey;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Verify a bearer token against the expected project slug.
|
|
56
|
+
*
|
|
57
|
+
* @param rawKey The bearer token exactly as received (no trimming — the
|
|
58
|
+
* caller is responsible for stripping the `Bearer ` prefix).
|
|
59
|
+
* @param projectSlug The slug from the URL path (`[project]` param).
|
|
60
|
+
* @returns `{ok: true, id}` on success, `{ok: false, reason}` otherwise.
|
|
61
|
+
* `id` is the short identifier suitable for audit logging;
|
|
62
|
+
* do NOT log the raw token or the hash.
|
|
63
|
+
*/
|
|
64
|
+
export async function verifyApiKey(
|
|
65
|
+
rawKey: string,
|
|
66
|
+
projectSlug: string,
|
|
67
|
+
): Promise<VerifyResult> {
|
|
68
|
+
if (typeof rawKey !== 'string' || !KEY_FORMAT.test(rawKey)) {
|
|
69
|
+
return {ok: false, reason: 'invalid_key_format'};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!redis) {
|
|
73
|
+
return {ok: false, reason: 'redis_unavailable'};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const hash = hashApiKey(rawKey);
|
|
77
|
+
|
|
78
|
+
let raw: unknown;
|
|
79
|
+
try {
|
|
80
|
+
raw = await redis.get(`apikey:${hash}`);
|
|
81
|
+
} catch {
|
|
82
|
+
return {ok: false, reason: 'lookup_failed'};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const stored = parseStoredKey(raw);
|
|
86
|
+
if (!stored) {
|
|
87
|
+
return {ok: false, reason: 'invalid_key'};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (stored.projectSlug !== projectSlug) {
|
|
91
|
+
return {ok: false, reason: 'wrong_project'};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {ok: true, id: stored.id};
|
|
95
|
+
}
|
|
@@ -214,9 +214,13 @@ export interface TabConfig {
|
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
/**
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
217
|
+
* Legacy anchor configuration (top-level navigation sections).
|
|
218
|
+
*
|
|
219
|
+
* Retained so NavigationConfig / DropdownConfig / ProductConfig /
|
|
220
|
+
* VersionConfig / LanguageConfig can continue to type customer configs
|
|
221
|
+
* that still carry the legacy shape. New configs should use TabConfig
|
|
222
|
+
* for internal sections and top-level ExternalAnchorConfig for external
|
|
223
|
+
* links; `validateConfig` rejects `navigation.anchors` at build time.
|
|
220
224
|
*/
|
|
221
225
|
export interface AnchorConfig {
|
|
222
226
|
anchor: string;
|
|
@@ -792,9 +796,15 @@ export interface DocsConfig {
|
|
|
792
796
|
// Required fields
|
|
793
797
|
theme: ThemeName;
|
|
794
798
|
name: string;
|
|
795
|
-
colors: ColorsConfig;
|
|
796
799
|
navigation: NavigationConfig;
|
|
797
800
|
|
|
801
|
+
// Boundary escape hatch — docs.json is user-authored and may carry fields
|
|
802
|
+
// not represented in this interface. Unknown keys read as `unknown`.
|
|
803
|
+
[key: string]: unknown;
|
|
804
|
+
|
|
805
|
+
// Optional — themes provide defaults at render time
|
|
806
|
+
colors?: ColorsConfig;
|
|
807
|
+
|
|
798
808
|
// Optional fields
|
|
799
809
|
description?: string;
|
|
800
810
|
favicon?: Favicon;
|
|
@@ -12,7 +12,7 @@ import type { DocsConfig } from './docs-types.js';
|
|
|
12
12
|
export interface ExtractedHighlights {
|
|
13
13
|
siteName: string;
|
|
14
14
|
theme: 'jam' | 'nebula' | 'pulsar';
|
|
15
|
-
primaryColor
|
|
15
|
+
primaryColor?: string;
|
|
16
16
|
seoIndexable: boolean;
|
|
17
17
|
analyticsIntegrations: string[];
|
|
18
18
|
apiPlaygroundType: 'interactive' | 'simple' | 'hidden' | null;
|
|
@@ -118,7 +118,7 @@ export function extractConfigHighlights(config: DocsConfig, pageCount: number =
|
|
|
118
118
|
return {
|
|
119
119
|
siteName: config.name,
|
|
120
120
|
theme: config.theme,
|
|
121
|
-
primaryColor: config.colors
|
|
121
|
+
primaryColor: config.colors?.primary,
|
|
122
122
|
seoIndexable,
|
|
123
123
|
analyticsIntegrations,
|
|
124
124
|
apiPlaygroundType,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { create, insertMultiple, search as oramaSearch, type Orama } from '@orama/orama';
|
|
8
8
|
import { restore } from '@orama/plugin-data-persistence';
|
|
9
9
|
import { getFileBufferFromR2 } from './r2';
|
|
10
|
+
import { querySimilarChunks } from './vector-store';
|
|
10
11
|
|
|
11
12
|
// Types matching builder/build-service/lib/search-client.ts
|
|
12
13
|
export interface SearchDocument {
|
|
@@ -28,6 +29,35 @@ export interface SearchResult {
|
|
|
28
29
|
score: number;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Vector search adapter for MCP. Wraps querySimilarChunks()
|
|
34
|
+
* and maps to SearchResult[] format. Returns null if vector
|
|
35
|
+
* store is not configured (Orama fallback kicks in).
|
|
36
|
+
*/
|
|
37
|
+
export async function searchProjectWithVector(
|
|
38
|
+
project: string,
|
|
39
|
+
query: string,
|
|
40
|
+
limit: number,
|
|
41
|
+
): Promise<SearchResult[] | null> {
|
|
42
|
+
if (!process.env.UPSTASH_VECTOR_REST_URL ||
|
|
43
|
+
!process.env.UPSTASH_VECTOR_REST_TOKEN) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const chunks = await querySimilarChunks(project, query, limit);
|
|
49
|
+
return chunks.map(chunk => ({
|
|
50
|
+
title: chunk.pageTitle,
|
|
51
|
+
url: chunk.pageSlug,
|
|
52
|
+
section: chunk.sectionHeading || undefined,
|
|
53
|
+
type: 'guide',
|
|
54
|
+
score: chunk.score,
|
|
55
|
+
}));
|
|
56
|
+
} catch {
|
|
57
|
+
return null; // Fall back to Orama on error
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
31
61
|
// Orama schema type
|
|
32
62
|
type SearchSchema = {
|
|
33
63
|
id: 'string';
|
|
@@ -74,6 +104,15 @@ export async function searchProject(
|
|
|
74
104
|
return [];
|
|
75
105
|
}
|
|
76
106
|
|
|
107
|
+
// Try vector search first for unfiltered queries
|
|
108
|
+
if (type === 'all') {
|
|
109
|
+
const vectorResults = await searchProjectWithVector(project, query, limit);
|
|
110
|
+
if (vectorResults) {
|
|
111
|
+
return vectorResults.map(r => ({ ...r, url: `${docsPath}/${r.url}` }));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Fall back to Orama text search
|
|
77
116
|
const db = await getOrCreateIndex(project, docsPath);
|
|
78
117
|
|
|
79
118
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
|
|
8
8
|
import { log } from './logger';
|
|
9
9
|
import {
|
|
10
|
-
resolveProject,
|
|
11
10
|
resolveProjectFromHostname,
|
|
12
11
|
resolveCustomDomain,
|
|
13
12
|
getProjectConfig,
|
|
@@ -315,6 +314,7 @@ export const INTERNAL_API_ROUTES = [
|
|
|
315
314
|
'/api/indexnow', // IndexNow key verification (app/api/indexnow/[key])
|
|
316
315
|
'/api/isr-health', // Health check endpoint (app/api/isr-health)
|
|
317
316
|
'/api/chat', // Chat endpoint (app/api/chat/[project])
|
|
317
|
+
'/api/docs-search', // Docs Search API (app/api/docs-search/[project]/search)
|
|
318
318
|
'/api/mcp', // MCP endpoint (app/api/mcp/[project])
|
|
319
319
|
'/api/og', // OG image generation (app/api/og)
|
|
320
320
|
'/api/playground', // API playground (token, proxy, demo) — must skip hostAtDocs redirect
|
|
@@ -447,6 +447,26 @@ export function getChatApiPath(projectSlug: string): string {
|
|
|
447
447
|
return `/api/chat/${projectSlug}`;
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Check if this is a docs search request that needs routing.
|
|
452
|
+
*
|
|
453
|
+
* @param pathname - Request pathname
|
|
454
|
+
* @returns true if this is a docs search request
|
|
455
|
+
*/
|
|
456
|
+
export function isDocsSearchRequest(pathname: string): boolean {
|
|
457
|
+
return pathname === '/_api/search' || pathname === '/docs/_api/search';
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Get the docs search API path for a project.
|
|
462
|
+
*
|
|
463
|
+
* @param projectSlug - Project identifier
|
|
464
|
+
* @returns Docs search API route path
|
|
465
|
+
*/
|
|
466
|
+
export function getDocsSearchApiPath(projectSlug: string): string {
|
|
467
|
+
return `/api/docs-search/${projectSlug}/search`;
|
|
468
|
+
}
|
|
469
|
+
|
|
450
470
|
const PLAYGROUND_PREFIX = '/_jd/playground/';
|
|
451
471
|
|
|
452
472
|
/**
|
|
@@ -27,7 +27,7 @@ const VALID_METHODS: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'H
|
|
|
27
27
|
* Full format: "/path/to/spec.yaml METHOD /endpoint/path"
|
|
28
28
|
* Short format (with defaultSpecPath): "METHOD /endpoint/path"
|
|
29
29
|
*/
|
|
30
|
-
export function parseOpenApiFrontmatter(value: string, defaultSpecPath?: string): ParsedOpenApiFrontmatter {
|
|
30
|
+
export function parseOpenApiFrontmatter(value: string, defaultSpecPath?: string | string[]): ParsedOpenApiFrontmatter {
|
|
31
31
|
const trimmed = value.trim();
|
|
32
32
|
const parts = trimmed.split(/\s+/);
|
|
33
33
|
|
|
@@ -38,7 +38,7 @@ export function parseOpenApiFrontmatter(value: string, defaultSpecPath?: string)
|
|
|
38
38
|
|
|
39
39
|
if (isMethodFirst) {
|
|
40
40
|
// Short format: "METHOD /endpoint/path"
|
|
41
|
-
if (!defaultSpecPath) {
|
|
41
|
+
if (!defaultSpecPath || (Array.isArray(defaultSpecPath) && defaultSpecPath.length === 0)) {
|
|
42
42
|
throw createFrontmatterError(
|
|
43
43
|
value,
|
|
44
44
|
'Short format (e.g., "GET /users") requires api.openapi to be configured in docs.json.\n' +
|
|
@@ -46,6 +46,8 @@ export function parseOpenApiFrontmatter(value: string, defaultSpecPath?: string)
|
|
|
46
46
|
);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
const resolvedDefault = Array.isArray(defaultSpecPath) ? defaultSpecPath[0] : defaultSpecPath;
|
|
50
|
+
|
|
49
51
|
const [method, ...pathParts] = parts;
|
|
50
52
|
const endpointPath = pathParts.join(' ');
|
|
51
53
|
|
|
@@ -58,9 +60,10 @@ export function parseOpenApiFrontmatter(value: string, defaultSpecPath?: string)
|
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
return {
|
|
61
|
-
specPath:
|
|
63
|
+
specPath: resolvedDefault.startsWith('/') ? resolvedDefault : `/${resolvedDefault}`,
|
|
62
64
|
method: method.toUpperCase() as HttpMethod,
|
|
63
65
|
path: endpointPath,
|
|
66
|
+
isShortFormat: true,
|
|
64
67
|
};
|
|
65
68
|
}
|
|
66
69
|
}
|
|
@@ -106,6 +109,7 @@ export function parseOpenApiFrontmatter(value: string, defaultSpecPath?: string)
|
|
|
106
109
|
specPath,
|
|
107
110
|
method: upperMethod,
|
|
108
111
|
path: endpointPath,
|
|
112
|
+
isShortFormat: false,
|
|
109
113
|
};
|
|
110
114
|
}
|
|
111
115
|
|