jamdesk 1.1.127 → 1.1.128
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/lib/deps.d.ts.map +1 -1
- package/dist/lib/deps.js +5 -0
- package/dist/lib/deps.js.map +1 -1
- package/package.json +2 -1
- package/vendored/app/api/markdown-export/[project]/[...slug]/route.ts +71 -1
- package/vendored/app/layout.tsx +8 -0
- package/vendored/components/AIActionsMenu.tsx +72 -3
- package/vendored/components/layout/EmbedLinkInterceptor.tsx +37 -0
- package/vendored/components/layout/LayoutWrapper.tsx +29 -1
- package/vendored/components/layout/PageColumns.tsx +10 -5
- package/vendored/components/navigation/Breadcrumb.tsx +6 -1
- package/vendored/components/navigation/SocialFooter.tsx +40 -17
- package/vendored/lib/api-spec-menu-gate.ts +36 -0
- package/vendored/lib/api-specs-bundle.ts +255 -0
- package/vendored/lib/api-specs-markdown-hint.ts +40 -0
- package/vendored/lib/api-specs-route.ts +45 -0
- package/vendored/lib/docs-types.ts +1 -1
- package/vendored/lib/heading-extractor.ts +34 -30
- package/vendored/lib/layout-helpers.tsx +19 -2
- package/vendored/lib/middleware-helpers.ts +29 -0
- package/vendored/lib/render-doc-page.tsx +93 -76
- package/vendored/lib/scanner-blocklist.ts +7 -0
- package/vendored/lib/static-artifacts.ts +39 -9
- package/vendored/lib/static-file-route.ts +35 -1
- package/vendored/scripts/github-slugger-regex.cjs +13 -0
- package/vendored/scripts/validate-links.cjs +45 -19
- package/vendored/themes/jam/variables.css +8 -0
- package/vendored/workspace-package-lock.json +125 -117
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ContextualOption, DocsConfig } from './docs-types';
|
|
2
|
+
import { siteHasOpenApiSpecs } from './api-specs-bundle';
|
|
3
|
+
|
|
4
|
+
export interface ApiSpecGateInput {
|
|
5
|
+
isIsr: boolean;
|
|
6
|
+
isApiPage: boolean;
|
|
7
|
+
/** This page's frontmatter declares an `openapi:` spec. */
|
|
8
|
+
pageHasOpenApi: boolean;
|
|
9
|
+
config: DocsConfig;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Insert `download-api-spec` iff: ISR mode (route + R2 only exist in prod) AND
|
|
14
|
+
* an API page AND the menu is non-empty (respect a disabled menu) AND the site
|
|
15
|
+
* has ≥1 spec (page-level OR anywhere in docs.json). Never mutates input or dups.
|
|
16
|
+
* The route is the source of truth (404 if assembly is empty); this is a hint.
|
|
17
|
+
*
|
|
18
|
+
* Placement: directly below "View as Markdown", so the download sits with the
|
|
19
|
+
* page-level actions near the top rather than trailing the MCP/editor
|
|
20
|
+
* integrations. Falls back to just after "Copy page", then the front, when
|
|
21
|
+
* "view" is absent (a customized `contextual.options` list).
|
|
22
|
+
*/
|
|
23
|
+
export function withApiSpecDownload(
|
|
24
|
+
options: ContextualOption[],
|
|
25
|
+
{ isIsr, isApiPage, pageHasOpenApi, config }: ApiSpecGateInput,
|
|
26
|
+
): ContextualOption[] {
|
|
27
|
+
if (!isIsr || !isApiPage || options.length === 0) return options;
|
|
28
|
+
if (options.includes('download-api-spec')) return options;
|
|
29
|
+
if (!(pageHasOpenApi || siteHasOpenApiSpecs(config))) return options;
|
|
30
|
+
const result = [...options];
|
|
31
|
+
const viewIdx = result.indexOf('view');
|
|
32
|
+
const copyIdx = result.indexOf('copy');
|
|
33
|
+
const insertAt = viewIdx !== -1 ? viewIdx + 1 : copyIdx !== -1 ? copyIdx + 1 : 0;
|
|
34
|
+
result.splice(insertAt, 0, 'download-api-spec');
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api-specs.zip assembly — REQUEST-TIME.
|
|
3
|
+
*
|
|
4
|
+
* Assembles the downloadable bundle on demand from specs already in R2 plus
|
|
5
|
+
* remote-URL specs fetched (SSRF-guarded) at request time. Verbatim bundling:
|
|
6
|
+
* local specs keep their R2 key path so co-located `$ref`s resolve.
|
|
7
|
+
*
|
|
8
|
+
* All I/O is injected so this is unit-testable without R2 or network.
|
|
9
|
+
*/
|
|
10
|
+
import path from 'path';
|
|
11
|
+
|
|
12
|
+
import { zipSync } from 'fflate';
|
|
13
|
+
|
|
14
|
+
import type { DocsConfig } from './docs-types';
|
|
15
|
+
import { isUrlSafe } from './url-safety';
|
|
16
|
+
import type { BuildWarning } from '../shared/status-reporter';
|
|
17
|
+
|
|
18
|
+
/** 8 MiB per remote spec; 10s per fetch — bounds a single short-lived request. */
|
|
19
|
+
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
21
|
+
/**
|
|
22
|
+
* Overall wall-clock budget (ms) for ALL remote fetches in one request, measured
|
|
23
|
+
* from assembler entry. Each fetch is individually bounded, but a docs.json with
|
|
24
|
+
* several slow remotes could sum past Vercel's 30s function cap and hard-kill the
|
|
25
|
+
* request — discarding the local specs already assembled. 25s leaves ~5s headroom
|
|
26
|
+
* under the cap for zipping + streaming the response.
|
|
27
|
+
*/
|
|
28
|
+
const DEFAULT_REMOTE_BUDGET_MS = 25_000;
|
|
29
|
+
|
|
30
|
+
export function isHttpUrl(ref: string): boolean {
|
|
31
|
+
return /^https?:\/\//i.test(ref);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pushRefs(out: string[], seen: Set<string>, val: unknown): void {
|
|
35
|
+
const add = (r: unknown) => {
|
|
36
|
+
if (typeof r === 'string' && r && !seen.has(r)) { seen.add(r); out.push(r); }
|
|
37
|
+
};
|
|
38
|
+
if (typeof val === 'string') add(val);
|
|
39
|
+
else if (Array.isArray(val)) val.forEach(add);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function walk(out: string[], seen: Set<string>, node: unknown): void {
|
|
43
|
+
if (!node || typeof node !== 'object') return;
|
|
44
|
+
if (Array.isArray(node)) { for (const n of node) walk(out, seen, n); return; }
|
|
45
|
+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
|
46
|
+
if (key === 'openapi') pushRefs(out, seen, value);
|
|
47
|
+
else if (value && typeof value === 'object') walk(out, seen, value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every `openapi` ref declared anywhere in docs.json (top-level + nested nav),
|
|
52
|
+
* de-duplicated, order-preserving. Locals AND http(s) URLs. */
|
|
53
|
+
export function collectConfigOpenApiRefs(config: DocsConfig): string[] {
|
|
54
|
+
const out: string[] = [];
|
|
55
|
+
const seen = new Set<string>();
|
|
56
|
+
pushRefs(out, seen, config.api?.openapi);
|
|
57
|
+
walk(out, seen, config.navigation);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function siteHasOpenApiSpecs(config: DocsConfig): boolean {
|
|
62
|
+
return collectConfigOpenApiRefs(config).length > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ZipEntry { name: string; content: Uint8Array; }
|
|
66
|
+
|
|
67
|
+
function uniqueName(name: string, used: Set<string>): string {
|
|
68
|
+
if (!used.has(name)) { used.add(name); return name; }
|
|
69
|
+
const ext = path.extname(name);
|
|
70
|
+
const base = name.slice(0, name.length - ext.length);
|
|
71
|
+
let n = 2;
|
|
72
|
+
let candidate = `${base}-${n}${ext}`;
|
|
73
|
+
while (used.has(candidate)) { n += 1; candidate = `${base}-${n}${ext}`; }
|
|
74
|
+
used.add(candidate);
|
|
75
|
+
return candidate;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build a zip from named entries. Entry names are used AS GIVEN (path preserved),
|
|
80
|
+
* so a local spec passed as `openapi/api.yaml` keeps its directory and its
|
|
81
|
+
* relative `$ref`s resolve. Colliding names get a `-2`/`-3` suffix. Null if empty.
|
|
82
|
+
* No `mtime` option: fflate's DOS date floor is 1980, so `mtime: 0` THROWS.
|
|
83
|
+
*/
|
|
84
|
+
export function buildSpecZip(entries: ZipEntry[]): Uint8Array | null {
|
|
85
|
+
if (entries.length === 0) return null;
|
|
86
|
+
const used = new Set<string>();
|
|
87
|
+
const map: Record<string, Uint8Array> = {};
|
|
88
|
+
for (const e of entries) {
|
|
89
|
+
const name = e.name.includes('/') ? e.name : (path.basename(e.name) || 'spec');
|
|
90
|
+
map[uniqueName(name, used)] = e.content;
|
|
91
|
+
}
|
|
92
|
+
return zipSync(map);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function remoteWarning(url: string, reason: string): BuildWarning {
|
|
96
|
+
return {
|
|
97
|
+
type: 'invalid_openapi_spec',
|
|
98
|
+
file: 'docs.json',
|
|
99
|
+
link: url,
|
|
100
|
+
message: `Remote OpenAPI spec "${url}" excluded from api-specs.zip (${reason}).`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function remoteSpecFilename(url: string): string {
|
|
105
|
+
try {
|
|
106
|
+
const base = path.basename(new URL(url).pathname);
|
|
107
|
+
if (base && base !== '/') return base;
|
|
108
|
+
} catch { /* fall through */ }
|
|
109
|
+
return 'remote-spec.json';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type RemoteOutcome = { entry: ZipEntry } | { warning: BuildWarning };
|
|
113
|
+
|
|
114
|
+
/** Max redirect hops to follow before giving up — bounds redirect chains. */
|
|
115
|
+
const MAX_REDIRECTS = 5;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Fetch one remote spec, SSRF-guarded + bounded. Never throws.
|
|
119
|
+
*
|
|
120
|
+
* Follows redirects MANUALLY (`redirect: 'manual'`) and re-validates `isUrlSafe`
|
|
121
|
+
* on EVERY hop. The default `fetchImpl` in prod is global `fetch` (undici), which
|
|
122
|
+
* would otherwise follow redirects transparently — letting a safe initial URL
|
|
123
|
+
* 302 to a private/metadata host and bypass the guard. Re-checking each target
|
|
124
|
+
* before fetching it closes that SSRF redirect bypass.
|
|
125
|
+
*/
|
|
126
|
+
export async function fetchRemoteSpec(
|
|
127
|
+
url: string,
|
|
128
|
+
fetchImpl: typeof fetch,
|
|
129
|
+
timeoutMs: number,
|
|
130
|
+
maxBytes: number,
|
|
131
|
+
): Promise<RemoteOutcome> {
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
134
|
+
try {
|
|
135
|
+
let current = url;
|
|
136
|
+
for (let hop = 0; hop < MAX_REDIRECTS; hop += 1) {
|
|
137
|
+
// Re-validate before EVERY fetch — covers the initial URL and each
|
|
138
|
+
// redirect target, so a 302 to a private/metadata host is blocked.
|
|
139
|
+
if (!isUrlSafe(current)) return { warning: remoteWarning(url, 'blocked by SSRF guard') };
|
|
140
|
+
const res = await fetchImpl(current, { signal: controller.signal, redirect: 'manual' });
|
|
141
|
+
if (res.status >= 300 && res.status < 400) {
|
|
142
|
+
const loc = res.headers.get('location');
|
|
143
|
+
if (!loc) return { warning: remoteWarning(url, 'redirect without location') };
|
|
144
|
+
current = new URL(loc, current).toString();
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
// Terminal response.
|
|
148
|
+
if (!res.ok) return { warning: remoteWarning(url, `HTTP ${res.status}`) };
|
|
149
|
+
const declared = res.headers.get('content-length');
|
|
150
|
+
if (declared && Number(declared) > maxBytes) {
|
|
151
|
+
return { warning: remoteWarning(url, `exceeds ${maxBytes} bytes`) };
|
|
152
|
+
}
|
|
153
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
154
|
+
if (buf.byteLength > maxBytes) return { warning: remoteWarning(url, `exceeds ${maxBytes} bytes`) };
|
|
155
|
+
return { entry: { name: remoteSpecFilename(url), content: buf } };
|
|
156
|
+
}
|
|
157
|
+
return { warning: remoteWarning(url, 'too many redirects') };
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return { warning: remoteWarning(url, err instanceof Error ? err.message : String(err)) };
|
|
160
|
+
} finally {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface AssembleResult {
|
|
166
|
+
zip: Uint8Array | null;
|
|
167
|
+
warnings: BuildWarning[];
|
|
168
|
+
localCount: number;
|
|
169
|
+
remoteCount: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface AssembleDeps {
|
|
173
|
+
projectSlug: string;
|
|
174
|
+
/** Raw text of `{slug}/{key}` from R2, or null. (lib/r2-content.ts fetchStaticFile) */
|
|
175
|
+
fetchStaticFile: (slug: string, key: string) => Promise<string | null>;
|
|
176
|
+
/** Parsed docs.json from R2, or null. (lib/r2-content.ts fetchDocsConfig) */
|
|
177
|
+
fetchDocsConfig: (slug: string) => Promise<DocsConfig | null>;
|
|
178
|
+
fetchImpl?: typeof fetch;
|
|
179
|
+
timeoutMs?: number;
|
|
180
|
+
maxBytes?: number;
|
|
181
|
+
/** Clock for the overall remote-fetch budget. Injectable for tests; defaults to Date.now. */
|
|
182
|
+
now?: () => number;
|
|
183
|
+
/** Overall wall-clock budget (ms) across all remote fetches. Default 25s, under Vercel's 30s cap. */
|
|
184
|
+
overallBudgetMs?: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Assemble api-specs.zip for one project from R2 + remote URLs. Never throws on
|
|
189
|
+
* a single spec — failures become non-blocking warnings. Returns `zip: null`
|
|
190
|
+
* when the project has zero specs (caller 404s).
|
|
191
|
+
*/
|
|
192
|
+
export async function assembleApiSpecsFromR2(deps: AssembleDeps): Promise<AssembleResult> {
|
|
193
|
+
const {
|
|
194
|
+
projectSlug, fetchStaticFile, fetchDocsConfig,
|
|
195
|
+
fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS, maxBytes = DEFAULT_MAX_BYTES,
|
|
196
|
+
now = Date.now, overallBudgetMs = DEFAULT_REMOTE_BUDGET_MS,
|
|
197
|
+
} = deps;
|
|
198
|
+
const remoteDeadline = now() + overallBudgetMs;
|
|
199
|
+
|
|
200
|
+
const warnings: BuildWarning[] = [];
|
|
201
|
+
const entries: ZipEntry[] = [];
|
|
202
|
+
const enc = new TextEncoder();
|
|
203
|
+
|
|
204
|
+
// Local specs: the build wrote them to R2 and recorded the keys in
|
|
205
|
+
// manifest.openapi. Read each verbatim and keep its KEY path in the zip.
|
|
206
|
+
const manifestRaw = await fetchStaticFile(projectSlug, 'manifest.json');
|
|
207
|
+
if (manifestRaw) {
|
|
208
|
+
let openapiKeys: string[] = [];
|
|
209
|
+
try {
|
|
210
|
+
const manifest = JSON.parse(manifestRaw) as { openapi?: Record<string, unknown> };
|
|
211
|
+
openapiKeys = Object.keys(manifest.openapi ?? {});
|
|
212
|
+
} catch { /* malformed manifest → treat as no locals */ }
|
|
213
|
+
for (const key of openapiKeys) {
|
|
214
|
+
const text = await fetchStaticFile(projectSlug, key);
|
|
215
|
+
if (text == null) {
|
|
216
|
+
warnings.push(remoteWarning(key, 'not found in R2 at request time'));
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
entries.push({ name: key, content: enc.encode(text) });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const localCount = entries.length;
|
|
223
|
+
|
|
224
|
+
// Remote specs: declared in docs.json, never uploaded. Fetch (guarded) now.
|
|
225
|
+
// fetchDocsConfig RE-THROWS on a non-NotFound R2 error (unlike fetchStaticFile,
|
|
226
|
+
// which returns null) — catch it so a transient config-read failure degrades to
|
|
227
|
+
// a warning and we still return the locals we already assembled, rather than
|
|
228
|
+
// discarding a complete local bundle as a 500.
|
|
229
|
+
let config: DocsConfig | null = null;
|
|
230
|
+
try {
|
|
231
|
+
config = await fetchDocsConfig(projectSlug);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
warnings.push(remoteWarning('docs.json', `config read failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
234
|
+
}
|
|
235
|
+
let remoteCount = 0;
|
|
236
|
+
if (config) {
|
|
237
|
+
const remoteUrls = collectConfigOpenApiRefs(config).filter(isHttpUrl);
|
|
238
|
+
for (const url of remoteUrls) {
|
|
239
|
+
// Bound total remote time to the overall budget: once spent, skip remaining
|
|
240
|
+
// remotes (warn) instead of risking a function timeout that would discard the
|
|
241
|
+
// locals we already have. Clamp each fetch's timeout to the time left so a
|
|
242
|
+
// single slow remote can't overrun the budget either.
|
|
243
|
+
const remaining = remoteDeadline - now();
|
|
244
|
+
if (remaining <= 0) {
|
|
245
|
+
warnings.push(remoteWarning(url, 'skipped — overall remote-fetch time budget exceeded'));
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const outcome = await fetchRemoteSpec(url, fetchImpl, Math.min(timeoutMs, remaining), maxBytes);
|
|
249
|
+
if ('entry' in outcome) { entries.push(outcome.entry); remoteCount += 1; }
|
|
250
|
+
else warnings.push(outcome.warning);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { zip: buildSpecZip(entries), warnings, localCount, remoteCount };
|
|
255
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-facing footer telling AI tools the site's OpenAPI specs are downloadable
|
|
3
|
+
* as a single api-specs.zip. Pure + server-safe: no I/O, no request access. The
|
|
4
|
+
* route resolves the gate inputs (frontmatter, config) and the absolute URL (via
|
|
5
|
+
* getBaseUrlFromConfig), then calls this.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ApiSpecsHintInput {
|
|
9
|
+
/** ISR mode only — the route + the R2 bundle only exist in prod. */
|
|
10
|
+
isIsr: boolean;
|
|
11
|
+
/** The contextual (AI-actions) menu is non-empty. Mirrors the menu gate's
|
|
12
|
+
* `options.length === 0` guard: a site with contextual.enabled:false (or
|
|
13
|
+
* options:[]) suppresses the menu item, so it must suppress the footer too. */
|
|
14
|
+
menuEnabled: boolean;
|
|
15
|
+
/** frontmatter `openapi:` present (a page-level spec ref). */
|
|
16
|
+
pageHasOpenApi: boolean;
|
|
17
|
+
/** frontmatter `api:` present (config-driven API page). */
|
|
18
|
+
pageHasApi: boolean;
|
|
19
|
+
/** siteHasOpenApiSpecs(config) — the site references ≥1 spec anywhere. */
|
|
20
|
+
siteHasSpecs: boolean;
|
|
21
|
+
/** Absolute api-specs.zip URL (getBaseUrlFromConfig is total → normally non-empty). */
|
|
22
|
+
zipUrl: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The footer Markdown to append, or null when it must not appear. The gate
|
|
27
|
+
* mirrors withApiSpecDownload (lib/api-spec-menu-gate.ts): ISR + the menu is
|
|
28
|
+
* enabled + the page is an API page + the site actually has ≥1 spec (a page-level
|
|
29
|
+
* `openapi:` short-circuits the site-wide check, exactly as the menu does). The
|
|
30
|
+
* zip route is the source of truth (it 404s if assembly is empty); this is a hint.
|
|
31
|
+
*/
|
|
32
|
+
export function apiSpecsMarkdownFooter(input: ApiSpecsHintInput): string | null {
|
|
33
|
+
const { isIsr, menuEnabled, pageHasOpenApi, pageHasApi, siteHasSpecs, zipUrl } = input;
|
|
34
|
+
if (!isIsr) return null;
|
|
35
|
+
if (!menuEnabled) return null; // contextual.enabled:false / options:[]
|
|
36
|
+
if (!(pageHasOpenApi || pageHasApi)) return null; // not an API page
|
|
37
|
+
if (!(pageHasOpenApi || siteHasSpecs)) return null; // no spec to bundle
|
|
38
|
+
if (!zipUrl) return null; // defensive
|
|
39
|
+
return `\n\n---\n\n📦 **OpenAPI specs:** Every OpenAPI specification referenced by this documentation is available as a single download — ${zipUrl}`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-time handler for /api-specs.zip (+ /docs variant). Assembles the
|
|
3
|
+
* bundle on demand from R2 + remote specs and streams it as a download.
|
|
4
|
+
*/
|
|
5
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
6
|
+
|
|
7
|
+
import { log } from '@/lib/logger';
|
|
8
|
+
import { isIsrMode } from '@/lib/page-isr-helpers';
|
|
9
|
+
import { fetchStaticFile, fetchDocsConfig } from '@/lib/r2-content';
|
|
10
|
+
import { assembleApiSpecsFromR2 } from '@/lib/api-specs-bundle';
|
|
11
|
+
|
|
12
|
+
export async function apiSpecsRouteHandler(request: NextRequest): Promise<NextResponse> {
|
|
13
|
+
// ISR-only: assembly needs R2. Local dev has neither R2 nor the menu item.
|
|
14
|
+
if (!isIsrMode()) return new NextResponse('Not found', { status: 404 });
|
|
15
|
+
|
|
16
|
+
const projectSlug = request.headers.get('x-project-slug');
|
|
17
|
+
if (!projectSlug) {
|
|
18
|
+
log('warn', 'api-specs.zip request missing project slug');
|
|
19
|
+
return new NextResponse('Project not found', { status: 404 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const result = await assembleApiSpecsFromR2({ projectSlug, fetchStaticFile, fetchDocsConfig });
|
|
24
|
+
if (result.warnings.length) {
|
|
25
|
+
log('warn', 'api-specs.zip assembled with warnings', {
|
|
26
|
+
projectSlug, warnings: result.warnings.map((w) => w.message),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (!result.zip) return new NextResponse('No API specs found', { status: 404 });
|
|
30
|
+
|
|
31
|
+
return new NextResponse(Buffer.from(result.zip), {
|
|
32
|
+
headers: {
|
|
33
|
+
'Content-Type': 'application/zip',
|
|
34
|
+
'Content-Disposition': 'attachment; filename="api-specs.zip"',
|
|
35
|
+
// Always fresh: assembly is cheap, downloads are rare. Avoids any
|
|
36
|
+
// post-rebuild staleness / R2-overwrite-consistency window.
|
|
37
|
+
'Cache-Control': 'no-store',
|
|
38
|
+
'X-Robots-Tag': 'noindex',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
log('error', 'Error assembling api-specs.zip', { projectSlug, error: String(error) });
|
|
43
|
+
return new NextResponse('Error assembling API specs', { status: 500 });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -712,7 +712,7 @@ export interface ErrorsConfig {
|
|
|
712
712
|
*/
|
|
713
713
|
export type ContextualOption =
|
|
714
714
|
| 'copy' | 'view' | 'chatgpt' | 'claude' | 'gemini' | 'perplexity'
|
|
715
|
-
| 'mcp' | 'cursor' | 'vscode'
|
|
715
|
+
| 'mcp' | 'cursor' | 'vscode' | 'download-api-spec'
|
|
716
716
|
| {
|
|
717
717
|
title: string;
|
|
718
718
|
description: string;
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* and Steps.tsx.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import GithubSlugger, { slug as githubSlug } from 'github-slugger';
|
|
10
|
+
|
|
9
11
|
export interface HeadingInfo {
|
|
10
12
|
id: string;
|
|
11
13
|
text: string;
|
|
@@ -17,26 +19,27 @@ export interface HeadingInfo {
|
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Generate a URL-friendly slug from heading text.
|
|
20
|
-
* Canonical implementation — also duplicated in validate-links.cjs (CJS, can't import).
|
|
21
|
-
*/
|
|
22
|
-
export function generateSlug(text: string): string {
|
|
23
|
-
return text
|
|
24
|
-
.toLowerCase()
|
|
25
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
26
|
-
.replace(/^-+|-+$/g, '');
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Suffix `base` with -2, -3, ... if it has been seen before. First occurrence
|
|
31
|
-
* keeps the bare slug for backwards compatibility with existing inbound links.
|
|
32
|
-
* Mutates `seen` in place — caller owns the lifetime (one Map per page).
|
|
33
22
|
*
|
|
34
|
-
*
|
|
23
|
+
* Delegates to github-slugger — the SAME library `rehype-slug` uses to assign the
|
|
24
|
+
* real heading `id` attributes during MDX compile (wired in render-doc-page.tsx).
|
|
25
|
+
* Using it here keeps the "On this page" TOC, <Update>/<Step> anchors, and the
|
|
26
|
+
* link validator byte-for-byte aligned with the actual DOM ids — including for
|
|
27
|
+
* non-ASCII headings, where the previous `[^a-z0-9]+ -> '-'` implementation
|
|
28
|
+
* diverged (accents/apostrophes/underscores became dashes) and produced dead
|
|
29
|
+
* anchors (e.g. heading "Abrir cualquier página" → real id `abrir-cualquier-página`,
|
|
30
|
+
* but the old slug was `abrir-cualquier-p-gina`).
|
|
31
|
+
*
|
|
32
|
+
* Stateless: repeated calls with the same text return the same slug. For
|
|
33
|
+
* per-document uniqueness across duplicate headings, use a `GithubSlugger`
|
|
34
|
+
* instance (as extractHeadings does) — it mirrors rehype-slug's `-1`/`-2`
|
|
35
|
+
* suffixing exactly.
|
|
36
|
+
*
|
|
37
|
+
* Mirrored in scripts/validate-links.cjs (CJS — can't import the ESM-only
|
|
38
|
+
* github-slugger), where the algorithm + regex are replicated and guarded by a
|
|
39
|
+
* drift test (`validate-links-slug.test.ts`). Keep both in sync.
|
|
35
40
|
*/
|
|
36
|
-
export function
|
|
37
|
-
|
|
38
|
-
seen.set(base, count + 1);
|
|
39
|
-
return count === 0 ? base : `${base}-${count + 1}`;
|
|
41
|
+
export function generateSlug(text: string): string {
|
|
42
|
+
return githubSlug(text);
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/;
|
|
@@ -57,6 +60,10 @@ const STEP_TITLE_REGEX = /<Step\s+[^>]*title=(?:"([^"]+)"|'([^']+)')/g;
|
|
|
57
60
|
* Includes markdown headings, <Update label="..."> component anchors,
|
|
58
61
|
* and <Step title="..."> entries inside <Steps> blocks (with per-block numbering).
|
|
59
62
|
* Skips content inside fenced code blocks.
|
|
63
|
+
*
|
|
64
|
+
* A single page-scoped `GithubSlugger` assigns ids so duplicate titles (across
|
|
65
|
+
* H2s, Update labels, and Steps) get `-1`, `-2`, ... suffixes in source order —
|
|
66
|
+
* the same algorithm rehype-slug applies to the real markdown-heading DOM ids.
|
|
60
67
|
*/
|
|
61
68
|
export function extractHeadings(content: string): HeadingInfo[] {
|
|
62
69
|
const headings: HeadingInfo[] = [];
|
|
@@ -67,9 +74,9 @@ export function extractHeadings(content: string): HeadingInfo[] {
|
|
|
67
74
|
// block can't stick the counter across subsequent blocks.
|
|
68
75
|
let inStepsBlock = false;
|
|
69
76
|
let stepCounter = 0;
|
|
70
|
-
// Page-scoped
|
|
71
|
-
//
|
|
72
|
-
const
|
|
77
|
+
// Page-scoped slugger so duplicate titles get -1, -2, ... suffixes in source
|
|
78
|
+
// order, matching rehype-slug's per-document GithubSlugger.
|
|
79
|
+
const slugger = new GithubSlugger();
|
|
73
80
|
|
|
74
81
|
for (let i = 0; i < lines.length; i++) {
|
|
75
82
|
const line = lines[i];
|
|
@@ -101,9 +108,8 @@ export function extractHeadings(content: string): HeadingInfo[] {
|
|
|
101
108
|
if (headingMatch) {
|
|
102
109
|
const level = headingMatch[1].length;
|
|
103
110
|
const text = headingMatch[2].trim();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const id = uniquifySlug(seenSlugs, base);
|
|
111
|
+
if (generateSlug(text)) {
|
|
112
|
+
const id = slugger.slug(text);
|
|
107
113
|
headings.push({ id, text, level, line: i + 1 });
|
|
108
114
|
}
|
|
109
115
|
}
|
|
@@ -111,9 +117,8 @@ export function extractHeadings(content: string): HeadingInfo[] {
|
|
|
111
117
|
const updateMatch = line.match(UPDATE_LABEL_REGEX);
|
|
112
118
|
if (updateMatch) {
|
|
113
119
|
const text = updateMatch[1];
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const id = uniquifySlug(seenSlugs, base);
|
|
120
|
+
if (generateSlug(text)) {
|
|
121
|
+
const id = slugger.slug(text);
|
|
117
122
|
headings.push({ id, text, level: 2, line: i + 1 });
|
|
118
123
|
}
|
|
119
124
|
}
|
|
@@ -121,10 +126,9 @@ export function extractHeadings(content: string): HeadingInfo[] {
|
|
|
121
126
|
if (inStepsBlock) {
|
|
122
127
|
for (const match of line.matchAll(STEP_TITLE_REGEX)) {
|
|
123
128
|
const text = match[1] ?? match[2];
|
|
124
|
-
|
|
125
|
-
if (!base) continue;
|
|
129
|
+
if (!generateSlug(text)) continue;
|
|
126
130
|
stepCounter += 1;
|
|
127
|
-
const id =
|
|
131
|
+
const id = slugger.slug(text);
|
|
128
132
|
headings.push({
|
|
129
133
|
id,
|
|
130
134
|
text,
|
|
@@ -365,6 +365,8 @@ interface DocsChromeProps {
|
|
|
365
365
|
customCss: string | null;
|
|
366
366
|
customJs: string | null;
|
|
367
367
|
children: React.ReactNode;
|
|
368
|
+
embed?: boolean;
|
|
369
|
+
embedTheme?: 'light' | 'dark' | 'auto';
|
|
368
370
|
}
|
|
369
371
|
|
|
370
372
|
/**
|
|
@@ -383,6 +385,8 @@ export async function DocsChrome({
|
|
|
383
385
|
customCss,
|
|
384
386
|
customJs,
|
|
385
387
|
children,
|
|
388
|
+
embed,
|
|
389
|
+
embedTheme,
|
|
386
390
|
}: DocsChromeProps): Promise<React.ReactElement> {
|
|
387
391
|
// Lowercase to match docs-config canonical case — `data-theme="nebula"` CSS
|
|
388
392
|
// selectors are case-sensitive, so `theme: "NEBULA"` from disk would silently
|
|
@@ -410,6 +414,11 @@ export async function DocsChrome({
|
|
|
410
414
|
const appearanceDefault = config.appearance?.default || 'system';
|
|
411
415
|
const appearanceStrict = config.appearance?.strict || false;
|
|
412
416
|
|
|
417
|
+
// In embed mode, an explicit ?theme=dark|light forces that theme so the
|
|
418
|
+
// panel can match the host app; theme=auto (or omitted) keeps system.
|
|
419
|
+
const embedForcedTheme =
|
|
420
|
+
embed && (embedTheme === 'light' || embedTheme === 'dark') ? embedTheme : undefined;
|
|
421
|
+
|
|
413
422
|
const linkPrefix = config.hostAtDocs ? '/docs' : '';
|
|
414
423
|
|
|
415
424
|
// Skip building the IIFE string in dev — both the script render below and
|
|
@@ -464,6 +473,11 @@ export async function DocsChrome({
|
|
|
464
473
|
}}
|
|
465
474
|
/>
|
|
466
475
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
476
|
+
{/* Embed render (widget modal): default every in-frame link to a new tab.
|
|
477
|
+
Clicking a doc link should open the full docs site in a new tab, not
|
|
478
|
+
navigate the modal iframe into a chrome'd (non-embed) page. Scoped to
|
|
479
|
+
embed — this <head> only renders when x-jd-layout=embed. */}
|
|
480
|
+
{embed && <base target="_blank" />}
|
|
467
481
|
{config.fonts && (
|
|
468
482
|
<>
|
|
469
483
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
@@ -646,11 +660,14 @@ export async function DocsChrome({
|
|
|
646
660
|
)}
|
|
647
661
|
<ThemeProvider
|
|
648
662
|
defaultTheme={appearanceDefault}
|
|
649
|
-
forcedTheme={
|
|
663
|
+
forcedTheme={
|
|
664
|
+
embedForcedTheme ??
|
|
665
|
+
(appearanceStrict ? (appearanceDefault === 'system' ? undefined : appearanceDefault as 'light' | 'dark') : undefined)
|
|
666
|
+
}
|
|
650
667
|
>
|
|
651
668
|
<LinkPrefixProvider prefix={linkPrefix}>
|
|
652
669
|
<ProjectSlugProvider slug={resolvedProjectSlug || ''}>
|
|
653
|
-
<LayoutWrapper config={config}>
|
|
670
|
+
<LayoutWrapper config={config} embed={embed}>
|
|
654
671
|
{children}
|
|
655
672
|
</LayoutWrapper>
|
|
656
673
|
</ProjectSlugProvider>
|
|
@@ -504,6 +504,31 @@ export function getMcpApiPath(projectSlug: string): string {
|
|
|
504
504
|
return `/api/mcp/${projectSlug}`;
|
|
505
505
|
}
|
|
506
506
|
|
|
507
|
+
export type EmbedTheme = 'light' | 'dark' | 'auto';
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* True when the request opts into the chrome-less embed render.
|
|
511
|
+
*
|
|
512
|
+
* Supports `?embed=1` (canonical) and `?layout=embed` (convenience alias).
|
|
513
|
+
*
|
|
514
|
+
* @param searchParams - URL search params from the incoming request
|
|
515
|
+
* @returns true if this request should render without site chrome
|
|
516
|
+
*/
|
|
517
|
+
export function isEmbedRequest(searchParams: URLSearchParams): boolean {
|
|
518
|
+
return searchParams.get('embed') === '1' || searchParams.get('layout') === 'embed';
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Forced theme for an embed render; defaults to 'auto' (prefers-color-scheme).
|
|
523
|
+
*
|
|
524
|
+
* @param searchParams - URL search params from the incoming request
|
|
525
|
+
* @returns 'dark' | 'light' when explicitly set, otherwise 'auto'
|
|
526
|
+
*/
|
|
527
|
+
export function getEmbedTheme(searchParams: URLSearchParams): EmbedTheme {
|
|
528
|
+
const t = searchParams.get('theme');
|
|
529
|
+
return t === 'dark' || t === 'light' ? t : 'auto';
|
|
530
|
+
}
|
|
531
|
+
|
|
507
532
|
/**
|
|
508
533
|
* Check if this is a chat request that needs routing.
|
|
509
534
|
*
|
|
@@ -629,6 +654,7 @@ const TRUSTED_PROXY_HEADERS = [
|
|
|
629
654
|
// attacker on a subdomain ALSO toggle the "owner can sign in"
|
|
630
655
|
// copy. Strip them all from the inbound request.
|
|
631
656
|
'x-jd-layout',
|
|
657
|
+
'x-jd-embed-theme', // forced-theme for embed render — never client-supplied
|
|
632
658
|
'x-jd-custom-domain',
|
|
633
659
|
'x-jd-project-name',
|
|
634
660
|
'x-jd-project-logo',
|
|
@@ -678,6 +704,9 @@ export interface BuildProjectHeadersOptions {
|
|
|
678
704
|
* Strips any client-supplied copies of those headers from the inbound
|
|
679
705
|
* request to prevent header smuggling.
|
|
680
706
|
*
|
|
707
|
+
* Strips (but does NOT set) x-jd-layout and x-jd-embed-theme — callers own
|
|
708
|
+
* those after this call; see TRUSTED_PROXY_HEADERS for the full strip list.
|
|
709
|
+
*
|
|
681
710
|
* @param projectSlug - Resolved project slug
|
|
682
711
|
* @param existingHeaders - Existing request headers to clone
|
|
683
712
|
* @param hostAtDocs - Whether docs are hosted at /docs
|