@dogsbay/format-mdx 0.2.0-beta.15 → 0.2.0-beta.150
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/adapter.d.ts +158 -0
- package/dist/adapter.js +94 -0
- package/dist/cli.d.ts +0 -1
- package/dist/cli.js +269 -20
- package/dist/extract.d.ts +31 -0
- package/dist/extract.js +151 -0
- package/dist/fumadocs.d.ts +47 -0
- package/dist/fumadocs.js +471 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.js +11 -2
- package/dist/js-scan.d.ts +34 -0
- package/dist/js-scan.js +123 -0
- package/dist/meta-nav.d.ts +28 -0
- package/dist/meta-nav.js +193 -0
- package/dist/mintlify-nav.d.ts +30 -0
- package/dist/mintlify-nav.js +417 -0
- package/dist/mintlify-openapi.d.ts +108 -0
- package/dist/mintlify-openapi.js +362 -0
- package/dist/mintlify.d.ts +2 -0
- package/dist/mintlify.js +195 -0
- package/dist/nav-extract.d.ts +42 -0
- package/dist/nav-extract.js +134 -0
- package/dist/parse.d.ts +100 -5
- package/dist/parse.js +1231 -151
- package/dist/ts-type-block.d.ts +42 -0
- package/dist/ts-type-block.js +163 -0
- package/package.json +10 -8
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/parse.d.ts.map +0 -1
- package/dist/parse.js.map +0 -1
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mintlify OpenAPI page generation.
|
|
3
|
+
*
|
|
4
|
+
* A Mintlify API-reference page is often just frontmatter — no body:
|
|
5
|
+
*
|
|
6
|
+
* ---
|
|
7
|
+
* openapi: post /partners/ban
|
|
8
|
+
* sidebarTitle: Ban partner
|
|
9
|
+
* ---
|
|
10
|
+
*
|
|
11
|
+
* The page content (endpoint card, params, request/response) is generated by
|
|
12
|
+
* Mintlify from the OpenAPI spec declared in `docs.json` (`api.openapi`, a URL
|
|
13
|
+
* or a local file). Without resolving that directive, every such page imports
|
|
14
|
+
* blank. This module reads the spec once and hands back the `endpoint` TreeNode
|
|
15
|
+
* for a given `METHOD path`, reusing `@dogsbay/format-openapi` (which already
|
|
16
|
+
* maps operations to endpoint nodes the Astro serializer renders as full API
|
|
17
|
+
* pages).
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
20
|
+
import { join, resolve as resolvePath, dirname, basename, sep } from "node:path";
|
|
21
|
+
import { parseOpenApiSpec } from "@dogsbay/format-openapi";
|
|
22
|
+
const HTTP_METHODS = new Set([
|
|
23
|
+
"get",
|
|
24
|
+
"post",
|
|
25
|
+
"put",
|
|
26
|
+
"patch",
|
|
27
|
+
"delete",
|
|
28
|
+
"head",
|
|
29
|
+
"options",
|
|
30
|
+
"trace",
|
|
31
|
+
]);
|
|
32
|
+
/**
|
|
33
|
+
* The OpenAPI spec reference declared in a Mintlify config, or null. Reads
|
|
34
|
+
* `docs.json` / `mint.json` `api.openapi` (string, or first of an array) and the
|
|
35
|
+
* legacy top-level `openapi`. Only an `http(s)://` value is a URL; everything
|
|
36
|
+
* else — including a ROOT-relative path like `/api-reference/openapi.json`,
|
|
37
|
+
* which Mintlify resolves against the docs root, not the filesystem root — is
|
|
38
|
+
* resolved against `source`.
|
|
39
|
+
*/
|
|
40
|
+
export function mintlifyOpenApiSpecRef(source) {
|
|
41
|
+
const config = readConfig(source);
|
|
42
|
+
if (!config)
|
|
43
|
+
return null;
|
|
44
|
+
const ref = firstString(config.api?.openapi) ?? firstString(config.openapi) ?? null;
|
|
45
|
+
return ref ? resolveSpecRef(source, ref) : null;
|
|
46
|
+
}
|
|
47
|
+
/** Is `abs` inside `root` once symlinks are resolved? Separator-aware. */
|
|
48
|
+
function withinRoot(abs, root) {
|
|
49
|
+
const real = (p) => {
|
|
50
|
+
try {
|
|
51
|
+
return realpathSync(p);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
const parent = dirname(p);
|
|
55
|
+
return parent === p ? p : join(real(parent), basename(p));
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const realRoot = real(resolvePath(root));
|
|
59
|
+
const realAbs = real(resolvePath(abs));
|
|
60
|
+
return realAbs === realRoot || realAbs.startsWith(realRoot + sep);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A spec ref → a URL, a source-relative filesystem path, or null when it is
|
|
64
|
+
* not allowed.
|
|
65
|
+
*
|
|
66
|
+
* Two refusals, both silent-by-default before:
|
|
67
|
+
*
|
|
68
|
+
* - **Escaping the corpus.** `join(source, ref.replace(/^\//, ""))` strips a
|
|
69
|
+
* leading slash but leaves `../` intact, so `openapi: "/../../secrets.json
|
|
70
|
+
* GET /x"` read a file outside the docs root and put its contents into the
|
|
71
|
+
* imported page. The check now compares realpaths and requires a separator,
|
|
72
|
+
* matching the include resolver in `cli/src/commands/convert.ts`.
|
|
73
|
+
* - **A page naming a URL.** Only a config-declared spec may be fetched. A
|
|
74
|
+
* page-declared `https://…` made the converter issue a request from
|
|
75
|
+
* wherever it runs — a CI runner or a hosted service sits inside a network
|
|
76
|
+
* the repo's author cannot otherwise reach.
|
|
77
|
+
*/
|
|
78
|
+
function resolveSpecRef(source, ref, origin = "config") {
|
|
79
|
+
if (/^https?:\/\//.test(ref)) {
|
|
80
|
+
if (origin === "page") {
|
|
81
|
+
console.warn(` warning: ignoring OpenAPI spec URL declared in page frontmatter ` +
|
|
82
|
+
`(${ref}) — only docs.json/mint.json may name a remote spec.`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return ref;
|
|
86
|
+
}
|
|
87
|
+
const abs = join(source, ref.replace(/^\//, ""));
|
|
88
|
+
if (!withinRoot(abs, source)) {
|
|
89
|
+
console.warn(` warning: ignoring OpenAPI spec path that escapes the docs root (${ref}).`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return abs;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Fetch the raw spec document, unparsed.
|
|
96
|
+
*
|
|
97
|
+
* An OpenAPI spec is a CANONICAL SOURCE ARTIFACT, not prose: round-tripping it
|
|
98
|
+
* through the TreeNode IR is lossy by construction ($ref/component schemas
|
|
99
|
+
* inline, security schemes and callbacks vanish). Exporters that can consume a
|
|
100
|
+
* spec directly should pass THIS through rather than rebuild it from endpoint
|
|
101
|
+
* nodes. Returns null when the spec cannot be read.
|
|
102
|
+
*/
|
|
103
|
+
export async function readOpenApiSpecSource(specRef) {
|
|
104
|
+
try {
|
|
105
|
+
let text;
|
|
106
|
+
if (/^https?:\/\//.test(specRef)) {
|
|
107
|
+
const res = await fetch(specRef, { signal: AbortSignal.timeout(30_000) });
|
|
108
|
+
if (!res.ok)
|
|
109
|
+
return null;
|
|
110
|
+
text = await res.text();
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
text = readFileSync(specRef, "utf-8");
|
|
114
|
+
}
|
|
115
|
+
const base = specRef.split(/[?#]/)[0].split("/").pop() || "openapi";
|
|
116
|
+
const filename = /\.(json|ya?ml)$/i.test(base)
|
|
117
|
+
? base
|
|
118
|
+
: `${base}.${text.trimStart().startsWith("{") ? "json" : "yaml"}`;
|
|
119
|
+
return { text, filename };
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Parse a spec into an endpoint map. A fetch/parse error must degrade to blank
|
|
127
|
+
* pages (never crash the import), but is reported via `loadError` rather than
|
|
128
|
+
* silently swallowed.
|
|
129
|
+
*/
|
|
130
|
+
export async function buildMintlifyEndpointMap(specRef) {
|
|
131
|
+
const map = new Map();
|
|
132
|
+
const titles = new Map();
|
|
133
|
+
try {
|
|
134
|
+
const { pages } = await parseOpenApiSpec(specRef);
|
|
135
|
+
for (const page of pages) {
|
|
136
|
+
const ep = page.tree.find((n) => n.type === "endpoint");
|
|
137
|
+
if (!ep)
|
|
138
|
+
continue;
|
|
139
|
+
const method = String(ep.props?.method ?? "").toLowerCase();
|
|
140
|
+
const path = String(ep.props?.path ?? "");
|
|
141
|
+
if (method && path) {
|
|
142
|
+
map.set(`${method} ${path}`, [ep]);
|
|
143
|
+
titles.set(`${method} ${path}`, page.title);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { map, titles, loadError: null };
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
return { map, titles, loadError: err instanceof Error ? err.message : String(err) };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Normalise a page's `openapi` frontmatter value to a map key, or null when the
|
|
154
|
+
* page is not an OpenAPI page. Accepts `"post /partners/ban"` (method + path);
|
|
155
|
+
* a leading named-spec token (`"api POST /x"`) is tolerated by taking the last
|
|
156
|
+
* two whitespace-separated tokens.
|
|
157
|
+
*/
|
|
158
|
+
export function openApiPageKey(value) {
|
|
159
|
+
if (typeof value !== "string")
|
|
160
|
+
return null;
|
|
161
|
+
const parts = value.trim().split(/\s+/);
|
|
162
|
+
if (parts.length < 2)
|
|
163
|
+
return null;
|
|
164
|
+
const path = parts[parts.length - 1];
|
|
165
|
+
const method = parts[parts.length - 2].toLowerCase();
|
|
166
|
+
if (!HTTP_METHODS.has(method))
|
|
167
|
+
return null;
|
|
168
|
+
if (!path.startsWith("/"))
|
|
169
|
+
return null;
|
|
170
|
+
return `${method} ${path}`;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The spec a PAGE names in its own `openapi:` frontmatter, or null.
|
|
174
|
+
*
|
|
175
|
+
* Mintlify lets a page point at a specific spec inline:
|
|
176
|
+
*
|
|
177
|
+
* openapi: "/openapi.json GET /project/update-status/{statusId}"
|
|
178
|
+
*
|
|
179
|
+
* `openApiPageKey` already tolerates that leading token (it takes the last two
|
|
180
|
+
* tokens); this reads the token itself, which nothing did. A site whose
|
|
181
|
+
* `docs.json` has no `api.openapi` key therefore resolved NO endpoints even
|
|
182
|
+
* though every page stated exactly which spec to use — measured on Mintlify's
|
|
183
|
+
* own documentation: 100 pages imported without their endpoint content and 20
|
|
184
|
+
* of them came out completely blank, next to an `openapi.json` sitting in the
|
|
185
|
+
* repo root.
|
|
186
|
+
*
|
|
187
|
+
* Resolved the same way as a config-declared ref: a leading `/` is the DOCS
|
|
188
|
+
* root, not the filesystem root.
|
|
189
|
+
*/
|
|
190
|
+
export function openApiPageSpecRef(source, value) {
|
|
191
|
+
if (typeof value !== "string")
|
|
192
|
+
return null;
|
|
193
|
+
const parts = value.trim().split(/\s+/);
|
|
194
|
+
// Three tokens minimum: <spec> <METHOD> <path>. Two is method+path with no
|
|
195
|
+
// spec, which is the config-declared case.
|
|
196
|
+
if (parts.length < 3)
|
|
197
|
+
return null;
|
|
198
|
+
const ref = parts[0];
|
|
199
|
+
// Only treat it as a spec when it looks like one — a URL, a path, or a
|
|
200
|
+
// json/yaml filename. Anything else is a NAMED spec (Mintlify also allows
|
|
201
|
+
// `openapi: "api POST /x"` referring to a named entry), which this does not
|
|
202
|
+
// resolve and which the existing key parser already tolerates.
|
|
203
|
+
const looksLikeSpec = /^https?:\/\//.test(ref) || ref.startsWith("/") || /\.(json|ya?ml)$/i.test(ref);
|
|
204
|
+
return looksLikeSpec ? resolveSpecRef(source, ref, "page") : null;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The vault/site slug for a nav-referenced OpenAPI operation (a `pages` string
|
|
208
|
+
* of the form `"POST /api/generate"` under a tab/group that declares an
|
|
209
|
+
* `openapi` spec). The NAV and the GENERATED PAGE both call this, so their
|
|
210
|
+
* href and slug always agree. Path-based (`/api/generate` → `api/generate`) for
|
|
211
|
+
* clean URLs; same-path-different-method operations would collide — rare, and
|
|
212
|
+
* documented (ollama and typical REST doc sets use unique paths per page).
|
|
213
|
+
*/
|
|
214
|
+
export function openApiRefSlug(path) {
|
|
215
|
+
return path.replace(/^\//, "").replace(/\/$/, "");
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Every distinct OpenAPI spec a Mintlify config references — the top-level
|
|
219
|
+
* `api.openapi` AND any `openapi` declared on a nav container (tab/group).
|
|
220
|
+
*
|
|
221
|
+
* Both are real source artifacts. Capturing only the top-level one meant a site
|
|
222
|
+
* like ollama (whose spec is declared on an "API Reference" tab) fell back to
|
|
223
|
+
* lossy reconstruction despite having a perfectly good spec on disk.
|
|
224
|
+
*/
|
|
225
|
+
export function collectMintlifyOpenApiSpecRefs(source,
|
|
226
|
+
/** Spec refs named by individual pages' `openapi:` frontmatter. */
|
|
227
|
+
pageSpecRefs = []) {
|
|
228
|
+
const config = readConfig(source);
|
|
229
|
+
const refs = new Set(pageSpecRefs);
|
|
230
|
+
if (!config)
|
|
231
|
+
return [...refs];
|
|
232
|
+
const top = firstString(config.api?.openapi) ?? firstString(config.openapi);
|
|
233
|
+
const topResolved = top ? resolveSpecRef(source, top) : null;
|
|
234
|
+
if (topResolved)
|
|
235
|
+
refs.add(topResolved);
|
|
236
|
+
if (config.navigation) {
|
|
237
|
+
const found = new Map();
|
|
238
|
+
walkForOpenApiRefs(config.navigation, undefined, source, found);
|
|
239
|
+
for (const ref of found.keys())
|
|
240
|
+
refs.add(ref);
|
|
241
|
+
}
|
|
242
|
+
return [...refs];
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Generate an ExportPage for every OpenAPI operation referenced ONLY by the nav
|
|
246
|
+
* (a `METHOD /path` `pages` string under a container that declares `openapi`) —
|
|
247
|
+
* these pages exist in no `.mdx` file (unlike the frontmatter case), so without
|
|
248
|
+
* this the nav links are dead and the API section is empty. ollama's docs.json
|
|
249
|
+
* is exactly this shape (an "Endpoints" group of 12 such strings under a tab
|
|
250
|
+
* with `openapi: /openapi.yaml`).
|
|
251
|
+
*/
|
|
252
|
+
export async function collectMintlifyOpenApiNavPages(source) {
|
|
253
|
+
const config = readConfig(source);
|
|
254
|
+
if (!config?.navigation)
|
|
255
|
+
return { pages: [], loadError: null };
|
|
256
|
+
const globalSpec = firstString(config.api?.openapi) ?? firstString(config.openapi);
|
|
257
|
+
// refs: specRef → set of "method path" keys referenced under it.
|
|
258
|
+
const refs = new Map();
|
|
259
|
+
walkForOpenApiRefs(config.navigation, globalSpec, source, refs);
|
|
260
|
+
if (refs.size === 0)
|
|
261
|
+
return { pages: [], loadError: null };
|
|
262
|
+
const pages = [];
|
|
263
|
+
const seen = new Set();
|
|
264
|
+
let loadError = null;
|
|
265
|
+
for (const [specRef, keys] of refs) {
|
|
266
|
+
const { map, titles, loadError: err } = await buildMintlifyEndpointMap(specRef);
|
|
267
|
+
if (err)
|
|
268
|
+
loadError = err;
|
|
269
|
+
for (const key of keys) {
|
|
270
|
+
const endpoint = map.get(key);
|
|
271
|
+
if (!endpoint)
|
|
272
|
+
continue;
|
|
273
|
+
const path = key.slice(key.indexOf(" ") + 1);
|
|
274
|
+
const slug = openApiRefSlug(path);
|
|
275
|
+
if (seen.has(slug))
|
|
276
|
+
continue;
|
|
277
|
+
seen.add(slug);
|
|
278
|
+
const ep = endpoint[0];
|
|
279
|
+
// format-openapi's title: the summary, unless it is shared or was a
|
|
280
|
+
// sentence fragment joined to its description (props.summary then holds
|
|
281
|
+
// the whole sentence).
|
|
282
|
+
const title = titles?.get(key) ||
|
|
283
|
+
`${String(ep.props?.method ?? "").toUpperCase()} ${path}`;
|
|
284
|
+
pages.push({ slug, title, tree: endpoint, headings: [] });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { pages, loadError };
|
|
288
|
+
}
|
|
289
|
+
/** Walk a navigation node, tracking the inherited `openapi` spec, collecting
|
|
290
|
+
* `METHOD /path` page strings keyed by their governing (resolved) spec ref. */
|
|
291
|
+
function walkForOpenApiRefs(node, inheritedSpec, source, out, inPages = false) {
|
|
292
|
+
if (Array.isArray(node)) {
|
|
293
|
+
for (const e of node)
|
|
294
|
+
walkForOpenApiRefs(e, inheritedSpec, source, out, inPages);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (!node || typeof node !== "object") {
|
|
298
|
+
// A bare `METHOD /path` string — ONLY meaningful inside a `pages` array.
|
|
299
|
+
// Scanning every string made prose like `"See POST /users"` in a tab
|
|
300
|
+
// description generate a phantom API page.
|
|
301
|
+
if (!inPages)
|
|
302
|
+
return;
|
|
303
|
+
const key = typeof node === "string" ? openApiPageKey(node) : null;
|
|
304
|
+
if (key && inheritedSpec) {
|
|
305
|
+
const resolved = resolveSpecRef(source, inheritedSpec);
|
|
306
|
+
if (resolved) {
|
|
307
|
+
if (!out.has(resolved))
|
|
308
|
+
out.set(resolved, new Set());
|
|
309
|
+
out.get(resolved).add(key);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const declared = firstString(node.openapi);
|
|
315
|
+
const spec = declared ?? inheritedSpec;
|
|
316
|
+
// A container that DECLARES a spec registers it even when its children are
|
|
317
|
+
// ordinary .mdx pages — otherwise a nav-declared spec was only captured when
|
|
318
|
+
// a `METHOD /path` string happened to sit under it.
|
|
319
|
+
if (declared) {
|
|
320
|
+
const resolved = resolveSpecRef(source, declared);
|
|
321
|
+
if (resolved && !out.has(resolved))
|
|
322
|
+
out.set(resolved, new Set());
|
|
323
|
+
}
|
|
324
|
+
for (const [key, v] of Object.entries(node)) {
|
|
325
|
+
if (v && typeof v === "object") {
|
|
326
|
+
walkForOpenApiRefs(v, spec, source, out, key === "pages" || inPages);
|
|
327
|
+
}
|
|
328
|
+
else if (typeof v === "string" && key === "pages") {
|
|
329
|
+
walkForOpenApiRefs(v, spec, source, out, true);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function readConfig(source) {
|
|
334
|
+
for (const name of ["docs.json", "mint.json"]) {
|
|
335
|
+
const p = join(source, name);
|
|
336
|
+
if (existsSync(p)) {
|
|
337
|
+
try {
|
|
338
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
function firstString(v) {
|
|
348
|
+
if (typeof v === "string" && v.length > 0)
|
|
349
|
+
return v;
|
|
350
|
+
if (Array.isArray(v)) {
|
|
351
|
+
for (const e of v) {
|
|
352
|
+
const s = firstString(typeof e === "object" && e ? e.openapi ?? e.source : e);
|
|
353
|
+
if (s)
|
|
354
|
+
return s;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (v && typeof v === "object") {
|
|
358
|
+
// `api.openapi` may be `{ source: "..." }` in some configs.
|
|
359
|
+
return firstString(v.source ?? v.openapi);
|
|
360
|
+
}
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
package/dist/mintlify.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<Update label="2.1.218" description="July 22, 2026">` wraps every release
|
|
3
|
+
* in a Mintlify changelog, with the entries as ordinary markdown bullets
|
|
4
|
+
* inside.
|
|
5
|
+
*
|
|
6
|
+
* Unmapped it is PRESERVED as raw MDX, which means its inner markdown is
|
|
7
|
+
* never parsed — a 5,000-line changelog renders as one run-on paragraph of
|
|
8
|
+
* literal `*` characters (observed: 4,000+ bullets, zero list items). Mapping
|
|
9
|
+
* it with `children: "blocks"` is what makes the bullets parse.
|
|
10
|
+
*
|
|
11
|
+
* `details` rather than a callout: a changelog is hundreds of releases, and
|
|
12
|
+
* collapsing each one is the difference between a usable page and a wall.
|
|
13
|
+
* Both attributes ride into the title, because a version without its date
|
|
14
|
+
* loses the thing a reader scans for.
|
|
15
|
+
*/
|
|
16
|
+
const update = {
|
|
17
|
+
type: "details",
|
|
18
|
+
container: true,
|
|
19
|
+
children: "blocks",
|
|
20
|
+
propsFromAttrs: (attrs) => {
|
|
21
|
+
const label = attrs.label?.trim();
|
|
22
|
+
const description = attrs.description?.trim();
|
|
23
|
+
const title = [label, description].filter(Boolean).join(" — ");
|
|
24
|
+
// `<Update>` also carries `tags={[…]}` and `rss={{ title, description }}`.
|
|
25
|
+
// Reading only label/description dropped them with no ledger entry, and on
|
|
26
|
+
// the raw path they had survived byte-exact — 71 tags on one changelog page
|
|
27
|
+
// alone. Carried through as props so the information is not lost; an
|
|
28
|
+
// exporter that has nowhere to put them declares that in its capabilities.
|
|
29
|
+
const extra = {};
|
|
30
|
+
for (const key of ["tags", "rss"]) {
|
|
31
|
+
const value = attrs[key]?.trim();
|
|
32
|
+
if (value)
|
|
33
|
+
extra[key] = value;
|
|
34
|
+
}
|
|
35
|
+
return { variant: "note", ...(title ? { title } : {}), ...extra };
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Self-closing marketing/experiment wrappers. Both carry NO documentation
|
|
40
|
+
* content — `<ContactSalesCard surface="bedrock" />` is a CTA, and
|
|
41
|
+
* `<Experiment flag="…" treatment={<ContactSalesCard …/>} />` is an A/B
|
|
42
|
+
* wrapper whose treatment is that same CTA.
|
|
43
|
+
*
|
|
44
|
+
* They were already being dropped by the built-in strip list; declaring them
|
|
45
|
+
* here makes the drop an ADAPTER DECISION recorded in the ledger, rather than
|
|
46
|
+
* a side effect of a component nobody had considered.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* `container: true` matches the BUILTIN strip entries for the same tags
|
|
50
|
+
* (`parse.ts` AgentInstructions/Experiment/ContactSalesCard). With
|
|
51
|
+
* `container: false` a paired `<Experiment>…</Experiment>` had its body kept
|
|
52
|
+
* and a stray `</Experiment>` html node leaked into the tree — invisible while
|
|
53
|
+
* the adapter was opt-in, and the default for every Mintlify site once adapter
|
|
54
|
+
* auto-selection landed.
|
|
55
|
+
*/
|
|
56
|
+
const stripped = { type: "", container: true, strip: true };
|
|
57
|
+
/**
|
|
58
|
+
* `<ParamField body="url" type="string" required>` / `<ResponseField …>` — the
|
|
59
|
+
* parameter vocabulary of every hand-written Mintlify API page.
|
|
60
|
+
*
|
|
61
|
+
* Unmapped these were PRESERVED as `mdx-raw`, which is honest but not useful:
|
|
62
|
+
* an exporter that cannot render `<ParamField>` strips the tag and keeps only
|
|
63
|
+
* the description, so the reader is left with a page of unattributed sentences
|
|
64
|
+
* — the parameter NAME, TYPE, REQUIRED flag and DEFAULT all vanish. On the dub
|
|
65
|
+
* corpus that is 331 fields across the API reference.
|
|
66
|
+
*
|
|
67
|
+
* The mapping is evidence-backed, not guessed: the attribute set is fixed and
|
|
68
|
+
* documented, and the name arrives in whichever of `body`/`query`/`path`/
|
|
69
|
+
* `header` names the location — so the location is real information too, and
|
|
70
|
+
* rides into the term rather than being discarded.
|
|
71
|
+
*
|
|
72
|
+
* `deflist` because that IS the shape: a term (the parameter) and its
|
|
73
|
+
* definition (the prose). Every exporter already handles it, and the ones that
|
|
74
|
+
* lack a definition-list syntax degrade it visibly (bold term + indented body)
|
|
75
|
+
* rather than dropping it.
|
|
76
|
+
*/
|
|
77
|
+
const PARAM_LOCATIONS = ["body", "query", "path", "header"];
|
|
78
|
+
/**
|
|
79
|
+
* `<Expandable title="properties">` wraps the NESTED fields of an object
|
|
80
|
+
* parameter. Unmapped it was preserved as raw MDX, so every exporter that
|
|
81
|
+
* cannot render it stripped the tag and orphaned the children — on dub that is
|
|
82
|
+
* 198 of 458 fields (43%), and 568 of 1753 (32%) on mintlify-docs, each losing
|
|
83
|
+
* its name and type exactly like the top-level fields did before ParamField
|
|
84
|
+
* was mapped.
|
|
85
|
+
*
|
|
86
|
+
* It has no content of its own beyond the title, so it UNWRAPS: its children
|
|
87
|
+
* are already `deflist`s and belong in the flow. The nesting is conveyed by the
|
|
88
|
+
* dotted field names Mintlify authors use (`user.name`), so flattening reads
|
|
89
|
+
* correctly rather than losing a distinction.
|
|
90
|
+
*/
|
|
91
|
+
const expandable = {
|
|
92
|
+
type: "",
|
|
93
|
+
container: true,
|
|
94
|
+
toNode: (_attrs, innerSource, ctx) => ctx.parseBlocks(innerSource),
|
|
95
|
+
};
|
|
96
|
+
const paramField = {
|
|
97
|
+
type: "deflist",
|
|
98
|
+
container: true,
|
|
99
|
+
toNode: (attrs, innerSource, ctx) => {
|
|
100
|
+
const location = PARAM_LOCATIONS.find((k) => attrs[k] !== undefined);
|
|
101
|
+
const name = (location ? attrs[location] : attrs.name) ?? "";
|
|
102
|
+
if (!name)
|
|
103
|
+
return "preserve";
|
|
104
|
+
const bits = [];
|
|
105
|
+
if (attrs.type)
|
|
106
|
+
bits.push(attrs.type);
|
|
107
|
+
if (location)
|
|
108
|
+
bits.push(location);
|
|
109
|
+
// A bare attribute arrives as the string "true".
|
|
110
|
+
if (attrs.required === "true" || attrs.required === "")
|
|
111
|
+
bits.push("required");
|
|
112
|
+
if (attrs.deprecated === "true" || attrs.deprecated === "")
|
|
113
|
+
bits.push("deprecated");
|
|
114
|
+
if (attrs.default)
|
|
115
|
+
bits.push(`default: ${attrs.default}`);
|
|
116
|
+
const body = ctx.parseBlocks(innerSource);
|
|
117
|
+
const dt = {
|
|
118
|
+
type: "dt",
|
|
119
|
+
inline: [
|
|
120
|
+
{ type: "code", text: name },
|
|
121
|
+
...(bits.length > 0 ? [{ type: "text", text: ` — ${bits.join(", ")}` }] : []),
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
type: "deflist",
|
|
126
|
+
children: [dt, ...(body.length > 0 ? [{ type: "dd", children: body }] : [])],
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* `<HeroCard filename title description href />` — a linked image card that
|
|
132
|
+
* Mintlify's own docs define IN THE PAGE:
|
|
133
|
+
*
|
|
134
|
+
* export const HeroCard = ({ filename, title, description, href }) => {
|
|
135
|
+
* return (<a href={href}><img src={…}/>…</a>)
|
|
136
|
+
* }
|
|
137
|
+
*
|
|
138
|
+
* The definition is a multi-statement arrow, so the snippet substitution that
|
|
139
|
+
* handles `props => (<JSX/>)` cannot expand it, and every USAGE fell through to
|
|
140
|
+
* `mdx-raw` — 32 of them across the corpus, each rendering as a code block of
|
|
141
|
+
* its own JSX on the site's front page.
|
|
142
|
+
*
|
|
143
|
+
* Mapped rather than expanded: a Dogsbay `card` carries the title, description
|
|
144
|
+
* and href faithfully. What is lost is the hero IMAGE, whose src is built by
|
|
145
|
+
* template-interpolating `filename` into a GitHub raw URL — that is JavaScript,
|
|
146
|
+
* and reconstructing it would hard-code one site's asset convention into a
|
|
147
|
+
* general adapter. The card is the content; the image was decoration.
|
|
148
|
+
*/
|
|
149
|
+
/**
|
|
150
|
+
* IT DECLINES ANYTHING THAT IS NOT THAT SHAPE. `HeroCard` is not part of
|
|
151
|
+
* Mintlify's documented vocabulary the way `Update`/`ParamField`/`Expandable`
|
|
152
|
+
* are — it is a component one site defines in its own pages, and the name is
|
|
153
|
+
* generic enough that another site will define its own with different props
|
|
154
|
+
* (`image`, `cta`, `variant`). Coercing those into a card whose title and href
|
|
155
|
+
* are empty would be worse than leaving them raw, so an unrecognised shape
|
|
156
|
+
* returns "preserve" — byte-exact `mdx-raw` plus a ledger entry — and a site
|
|
157
|
+
* that wants its own mapping uses `--components`, which exists for exactly this.
|
|
158
|
+
*/
|
|
159
|
+
const heroCard = {
|
|
160
|
+
type: "card",
|
|
161
|
+
container: false,
|
|
162
|
+
toNode: (attrs) => {
|
|
163
|
+
const title = attrs.title?.trim();
|
|
164
|
+
const href = attrs.href?.trim();
|
|
165
|
+
// The documented shape carries at least a title and a destination.
|
|
166
|
+
if (!title || !href)
|
|
167
|
+
return "preserve";
|
|
168
|
+
const description = attrs.description?.trim();
|
|
169
|
+
return {
|
|
170
|
+
type: "card",
|
|
171
|
+
props: { title, href, ...(description ? { description } : {}) },
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
export const mintlifyAdapter = {
|
|
176
|
+
name: "mintlify",
|
|
177
|
+
components: {
|
|
178
|
+
HeroCard: heroCard,
|
|
179
|
+
Update: update,
|
|
180
|
+
ParamField: paramField,
|
|
181
|
+
Expandable: expandable,
|
|
182
|
+
ResponseField: paramField,
|
|
183
|
+
ContactSalesCard: stripped,
|
|
184
|
+
Experiment: stripped,
|
|
185
|
+
},
|
|
186
|
+
// NOT self-closing, despite an earlier note here saying so. The segmenter
|
|
187
|
+
// decides pairing from THIS list, not from a mapping's `container` flag, so
|
|
188
|
+
// declaring them self-closing meant `<Experiment name="x">…</Experiment>`
|
|
189
|
+
// was never paired: the body stayed in the page and the stray closing tag
|
|
190
|
+
// was emitted as raw HTML, while the ledger recorded it as "stripped".
|
|
191
|
+
// Flipping `container: true` on the mapping did not fix it, because nothing
|
|
192
|
+
// reads `container` at segmentation time. The builtin (no-adapter) path
|
|
193
|
+
// handled these correctly, so the declaration was a regression against it.
|
|
194
|
+
selfClosingTags: [],
|
|
195
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static sidebar extraction from a hardcoded Fumadocs page tree.
|
|
3
|
+
*
|
|
4
|
+
* Passing a hand-built `PageTree.Root` to `DocsLayout tree={…}` is a
|
|
5
|
+
* documented first-class Fumadocs pattern; sites that use it (better-auth's
|
|
6
|
+
* sidebar-content.tsx) keep the REAL sidebar in a TSX data literal and let
|
|
7
|
+
* meta.json go stale. Page trees must be serializable by design, so the
|
|
8
|
+
* literal is statically readable: this module captures the exported array
|
|
9
|
+
* and maps it to NavItem[] — no execution, ever (icons and other JSX
|
|
10
|
+
* values ride through the balanced scanner and are dropped).
|
|
11
|
+
*
|
|
12
|
+
* The mapping is shape-tolerant rather than better-auth-specific: an entry
|
|
13
|
+
* contributes a nav item when it has a string `title`/`name`/`label`;
|
|
14
|
+
* `href`/`url` become the link; the first array-valued key among
|
|
15
|
+
* `list`/`subpages`/`children`/`items`/`pages` becomes the children.
|
|
16
|
+
*
|
|
17
|
+
* Href prefix: sites hardcode their mount point (`/docs/introduction`).
|
|
18
|
+
* When every internal href shares the same first path segment it is
|
|
19
|
+
* stripped and replaced with the importer's own `hrefPrefix` (auto-detected
|
|
20
|
+
* — pass `stripPrefix` to override).
|
|
21
|
+
*/
|
|
22
|
+
import type { NavItem } from "@dogsbay/types";
|
|
23
|
+
export interface NavExtractOptions {
|
|
24
|
+
/** Name of the exported array (default "contents"). */
|
|
25
|
+
exportName?: string;
|
|
26
|
+
/** Prefix for emitted hrefs (e.g. "/docs" of the TARGET site). */
|
|
27
|
+
hrefPrefix?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Leading path segment to strip from the source's hardcoded hrefs
|
|
30
|
+
* (e.g. "/docs"). Auto-detected when omitted: stripped when every
|
|
31
|
+
* internal href shares the same first segment.
|
|
32
|
+
*/
|
|
33
|
+
stripPrefix?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Extract NavItem[] from TSX source containing an exported data-literal
|
|
37
|
+
* page tree. Returns null when the export is missing or doesn't scan —
|
|
38
|
+
* callers fall back to the meta.json walk (never guess).
|
|
39
|
+
*/
|
|
40
|
+
export declare function extractNavFromTsx(source: string, opts?: NavExtractOptions): NavItem[] | null;
|
|
41
|
+
/** File-reading convenience for the CLI layer. */
|
|
42
|
+
export declare function extractNavFromTsxFile(filePath: string, opts?: NavExtractOptions): NavItem[] | null;
|