jamdesk 1.1.127 → 1.1.129

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/commands/broken-links.d.ts +2 -0
  2. package/dist/commands/broken-links.d.ts.map +1 -1
  3. package/dist/commands/broken-links.js +11 -2
  4. package/dist/commands/broken-links.js.map +1 -1
  5. package/dist/lib/deps.d.ts.map +1 -1
  6. package/dist/lib/deps.js +5 -0
  7. package/dist/lib/deps.js.map +1 -1
  8. package/package.json +2 -1
  9. package/vendored/app/api/markdown-export/[project]/[...slug]/route.ts +71 -1
  10. package/vendored/app/layout.tsx +8 -0
  11. package/vendored/components/AIActionsMenu.tsx +72 -3
  12. package/vendored/components/layout/EmbedLinkInterceptor.tsx +37 -0
  13. package/vendored/components/layout/LayoutWrapper.tsx +29 -1
  14. package/vendored/components/layout/PageColumns.tsx +10 -5
  15. package/vendored/components/navigation/Breadcrumb.tsx +6 -1
  16. package/vendored/components/navigation/SocialFooter.tsx +40 -17
  17. package/vendored/lib/api-spec-menu-gate.ts +49 -0
  18. package/vendored/lib/api-spec-offer.ts +50 -0
  19. package/vendored/lib/api-specs-bundle.ts +255 -0
  20. package/vendored/lib/api-specs-markdown-hint.ts +49 -0
  21. package/vendored/lib/api-specs-route.ts +45 -0
  22. package/vendored/lib/docs-types.ts +1 -1
  23. package/vendored/lib/heading-extractor.ts +34 -30
  24. package/vendored/lib/layout-helpers.tsx +19 -2
  25. package/vendored/lib/middleware-helpers.ts +29 -0
  26. package/vendored/lib/render-doc-page.tsx +93 -76
  27. package/vendored/lib/scanner-blocklist.ts +7 -0
  28. package/vendored/lib/static-artifacts.ts +39 -9
  29. package/vendored/lib/static-file-route.ts +35 -1
  30. package/vendored/scripts/github-slugger-regex.cjs +13 -0
  31. package/vendored/scripts/validate-links.cjs +136 -22
  32. package/vendored/themes/jam/variables.css +8 -0
  33. package/vendored/workspace-package-lock.json +128 -120
@@ -22,6 +22,9 @@ interface SocialFooterProps {
22
22
  config: DocsConfig;
23
23
  hidden?: boolean;
24
24
  projectSlug?: string;
25
+ /** Embed render (widget modal): keep ONLY the "Powered by Jamdesk"
26
+ * attribution — drop the link columns + social icons. */
27
+ embed?: boolean;
25
28
  }
26
29
 
27
30
  /**
@@ -137,7 +140,42 @@ function SocialIcons({ socials }: { socials: Partial<Record<SocialPlatform, stri
137
140
  );
138
141
  }
139
142
 
140
- export function SocialFooter({ config, hidden, projectSlug }: SocialFooterProps) {
143
+ /** "Powered by Jamdesk" attribution link, shared by the full footer and the
144
+ * embed footer. Renders nothing when branding is disabled at build time. */
145
+ function BrandingLink({ projectSlug }: { projectSlug?: string }) {
146
+ if (!showBranding) return null;
147
+ return (
148
+ <a
149
+ href={getBrandingUrl(projectSlug)}
150
+ target="_blank"
151
+ rel="noopener noreferrer"
152
+ className="group flex items-baseline gap-1 text-sm text-[var(--color-text-muted)]/60 hover:text-[var(--color-text-muted)] transition-colors whitespace-nowrap"
153
+ >
154
+ Powered by
155
+ <span
156
+ role="img"
157
+ aria-label="Jamdesk"
158
+ className="inline-block translate-y-[2px] text-[var(--color-text-muted)]/85 group-hover:text-[var(--color-text-primary)]"
159
+ style={WORDMARK_STYLE}
160
+ />
161
+ </a>
162
+ );
163
+ }
164
+
165
+ export function SocialFooter({ config, hidden, projectSlug, embed }: SocialFooterProps) {
166
+ // Embed render (widget modal): keep ONLY the "Powered by Jamdesk" attribution.
167
+ // The link columns + social icons read as out of place inside an embedded
168
+ // changelog; the attribution stays even when the normal footer is hidden,
169
+ // since it's the embed widget's branding.
170
+ if (embed) {
171
+ if (!showBranding) return null;
172
+ return (
173
+ <footer className="mt-10 pt-6 border-t border-[var(--color-border)] flex justify-center">
174
+ <BrandingLink projectSlug={projectSlug} />
175
+ </footer>
176
+ );
177
+ }
178
+
141
179
  if (hidden) return null;
142
180
 
143
181
  const { socials, links } = config.footer || {};
@@ -156,22 +194,7 @@ export function SocialFooter({ config, hidden, projectSlug }: SocialFooterProps)
156
194
  {hasLinks && <LinkColumns columns={links} />}
157
195
  <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
158
196
  {hasSocials && <SocialIcons socials={socials} />}
159
- {showBranding && (
160
- <a
161
- href={getBrandingUrl(projectSlug)}
162
- target="_blank"
163
- rel="noopener noreferrer"
164
- className="group flex items-baseline gap-1 text-sm text-[var(--color-text-muted)]/60 hover:text-[var(--color-text-muted)] transition-colors whitespace-nowrap"
165
- >
166
- Powered by
167
- <span
168
- role="img"
169
- aria-label="Jamdesk"
170
- className="inline-block translate-y-[2px] text-[var(--color-text-muted)]/85 group-hover:text-[var(--color-text-primary)]"
171
- style={WORDMARK_STYLE}
172
- />
173
- </a>
174
- )}
197
+ <BrandingLink projectSlug={projectSlug} />
175
198
  </div>
176
199
  </footer>
177
200
  );
@@ -0,0 +1,49 @@
1
+ import type { ContextualOption, DocsConfig } from './docs-types';
2
+ import { siteHasOpenApiSpecs } from './api-specs-bundle';
3
+ import { shouldOfferApiSpecDownload } from './api-spec-offer';
4
+
5
+ export interface ApiSpecGateInput {
6
+ isIsr: boolean;
7
+ isApiPage: boolean;
8
+ /** This page's frontmatter declares an `openapi:` spec. */
9
+ pageHasOpenApi: boolean;
10
+ config: DocsConfig;
11
+ }
12
+
13
+ /**
14
+ * Insert `download-api-spec` into the AI-actions menu iff the page offers the
15
+ * api-specs download (shared gate: shouldOfferApiSpecDownload). The offer
16
+ * decision is shared with the markdown footer so the two cannot drift; this
17
+ * function owns only the menu-specific concerns: idempotency and placement.
18
+ *
19
+ * Placement: directly below "View as Markdown"; falls back to just after "Copy
20
+ * page", then the front, when "view" is absent (a customized options list).
21
+ *
22
+ * `siteHasOpenApiSpecs` walks the whole docs.json navigation, so it is passed as
23
+ * a thunk — the shared predicate evaluates it only after the cheap gates pass
24
+ * and only when the page has no page-level `openapi:`, preserving the original
25
+ * no-walk-on-guide-pages behavior.
26
+ */
27
+ export function withApiSpecDownload(
28
+ options: ContextualOption[],
29
+ { isIsr, isApiPage, pageHasOpenApi, config }: ApiSpecGateInput,
30
+ ): ContextualOption[] {
31
+ if (options.includes('download-api-spec')) return options;
32
+ if (
33
+ !shouldOfferApiSpecDownload({
34
+ isIsr,
35
+ menuEnabled: options.length > 0,
36
+ isApiPage,
37
+ pageHasOpenApi,
38
+ siteHasSpecs: () => siteHasOpenApiSpecs(config),
39
+ })
40
+ ) {
41
+ return options;
42
+ }
43
+ const result = [...options];
44
+ const viewIdx = result.indexOf('view');
45
+ const copyIdx = result.indexOf('copy');
46
+ const insertAt = viewIdx !== -1 ? viewIdx + 1 : copyIdx !== -1 ? copyIdx + 1 : 0;
47
+ result.splice(insertAt, 0, 'download-api-spec');
48
+ return result;
49
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Single source of truth for "should this page offer the api-specs download?".
3
+ * Consumed by BOTH the visible menu-button gate (withApiSpecDownload,
4
+ * lib/api-spec-menu-gate.ts) and the agent-facing markdown footer gate
5
+ * (apiSpecsMarkdownFooter, lib/api-specs-markdown-hint.ts), so the two gates
6
+ * cannot drift on the offer condition.
7
+ *
8
+ * Pure: no I/O, no config access. Callers resolve the inputs (ISR, frontmatter,
9
+ * menu, spec existence) and pass primitives.
10
+ *
11
+ * Rule: ISR + the AI-actions menu is enabled + the page is an API page (`api:`
12
+ * or `openapi:` frontmatter) + there is a spec to bundle — a page-level
13
+ * `openapi:` short-circuits the site-wide spec check, exactly as the menu has
14
+ * always done.
15
+ *
16
+ * `siteHasSpecs` may be a thunk. The menu gate's site-spec check
17
+ * (siteHasOpenApiSpecs) walks the whole docs.json navigation; the original gate
18
+ * deliberately avoided that walk on the common non-API-page render path.
19
+ * Passing `() => …` preserves the laziness — the thunk runs only after the cheap
20
+ * gates pass and only when the page has no page-level `openapi:`.
21
+ */
22
+ export interface ApiSpecOfferInput {
23
+ /** ISR runtime. Both gates are inert in local `jamdesk dev` (isIsr=false). */
24
+ isIsr: boolean;
25
+ /**
26
+ * The AI-actions menu is present with ≥1 option — i.e. NOT disabled via
27
+ * `contextual.enabled:false` / `options:[]`. This is a derived fact, not a
28
+ * config flag: callers compute it from the rendered options list
29
+ * (`options.length > 0` / `getContextualOptions(config).length > 0`), which is
30
+ * where menu-enablement actually lives (`lib/contextual-defaults.ts`).
31
+ */
32
+ menuEnabled: boolean;
33
+ /** Page frontmatter declares `api:` or `openapi:`. */
34
+ isApiPage: boolean;
35
+ /** Page frontmatter declares its own `openapi:` — short-circuits the site-wide check. */
36
+ pageHasOpenApi: boolean;
37
+ /**
38
+ * Whether the site references ≥1 OpenAPI spec anywhere. May be a thunk so a
39
+ * caller can defer an expensive docs.json navigation walk until the cheap
40
+ * gates pass; see the menu-gate call site.
41
+ */
42
+ siteHasSpecs: boolean | (() => boolean);
43
+ }
44
+
45
+ export function shouldOfferApiSpecDownload(input: ApiSpecOfferInput): boolean {
46
+ const { isIsr, menuEnabled, isApiPage, pageHasOpenApi, siteHasSpecs } = input;
47
+ if (!(isIsr && menuEnabled && isApiPage)) return false;
48
+ if (pageHasOpenApi) return true;
49
+ return typeof siteHasSpecs === 'function' ? siteHasSpecs() : siteHasSpecs;
50
+ }
@@ -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,49 @@
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
+ import { shouldOfferApiSpecDownload } from './api-spec-offer';
9
+
10
+ export interface ApiSpecsHintInput {
11
+ /** ISR mode only — the route + the R2 bundle only exist in prod. */
12
+ isIsr: boolean;
13
+ /** The contextual (AI-actions) menu is non-empty. Mirrors the menu gate's
14
+ * `options.length === 0` guard: a site with contextual.enabled:false (or
15
+ * options:[]) suppresses the menu item, so it must suppress the footer too. */
16
+ menuEnabled: boolean;
17
+ /** frontmatter `openapi:` present (a page-level spec ref). */
18
+ pageHasOpenApi: boolean;
19
+ /** frontmatter `api:` present (config-driven API page). */
20
+ pageHasApi: boolean;
21
+ /** siteHasOpenApiSpecs(config) — the site references ≥1 spec anywhere. */
22
+ siteHasSpecs: boolean;
23
+ /** Absolute api-specs.zip URL (getBaseUrlFromConfig is total → normally non-empty). */
24
+ zipUrl: string;
25
+ }
26
+
27
+ /**
28
+ * The footer Markdown to append, or null when it must not appear. The gate
29
+ * mirrors withApiSpecDownload (lib/api-spec-menu-gate.ts): ISR + the menu is
30
+ * enabled + the page is an API page + the site actually has ≥1 spec (a page-level
31
+ * `openapi:` short-circuits the site-wide check, exactly as the menu does). The
32
+ * zip route is the source of truth (it 404s if assembly is empty); this is a hint.
33
+ */
34
+ export function apiSpecsMarkdownFooter(input: ApiSpecsHintInput): string | null {
35
+ const { pageHasOpenApi, pageHasApi, zipUrl } = input;
36
+ if (
37
+ !shouldOfferApiSpecDownload({
38
+ isIsr: input.isIsr,
39
+ menuEnabled: input.menuEnabled,
40
+ isApiPage: pageHasOpenApi || pageHasApi,
41
+ pageHasOpenApi,
42
+ siteHasSpecs: input.siteHasSpecs,
43
+ })
44
+ ) {
45
+ return null;
46
+ }
47
+ if (!zipUrl) return null; // defensive: getBaseUrlFromConfig is total, so this is contract insurance
48
+ return `\n\n---\n\n📦 **OpenAPI specs:** Every OpenAPI specification referenced by this documentation is available as a single download — ${zipUrl}`;
49
+ }
@@ -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,