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.
@@ -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
- * Mirrored in scripts/validate-links.cjskeep both in sync.
23
+ * Delegates to github-sluggerthe 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 uniquifySlug(seen: Map<string, number>, base: string): string {
37
- const count = seen.get(base) ?? 0;
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 slug counter so duplicate titles (across H2s, Update labels,
71
- // and Steps) get -2, -3, ... suffixes in source order.
72
- const seenSlugs = new Map<string, number>();
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
- const base = generateSlug(text);
105
- if (base) {
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
- const base = generateSlug(text);
115
- if (base) {
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
- const base = generateSlug(text);
125
- if (!base) continue;
129
+ if (!generateSlug(text)) continue;
126
130
  stepCounter += 1;
127
- const id = uniquifySlug(seenSlugs, base);
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={appearanceStrict ? (appearanceDefault === 'system' ? undefined : appearanceDefault as 'light' | 'dark') : undefined}
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