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.
- package/dist/commands/broken-links.d.ts +2 -0
- package/dist/commands/broken-links.d.ts.map +1 -1
- package/dist/commands/broken-links.js +11 -2
- package/dist/commands/broken-links.js.map +1 -1
- 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 +49 -0
- package/vendored/lib/api-spec-offer.ts +50 -0
- package/vendored/lib/api-specs-bundle.ts +255 -0
- package/vendored/lib/api-specs-markdown-hint.ts +49 -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 +136 -22
- package/vendored/themes/jam/variables.css +8 -0
- 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
|
-
|
|
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
|
-
{
|
|
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
|
-
*
|
|
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,
|