@dogsbay/format-mdx 0.2.0-beta.14 → 0.2.0-beta.141

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Mintlify `docs.json` / legacy `mint.json` → NavItem[].
3
+ *
4
+ * Mintlify's navigation is polymorphic and recursive. A "navigation node" is an
5
+ * object that carries one (schema says one; we tolerate several) of these
6
+ * container arrays, each of which nests another navigation node:
7
+ *
8
+ * languages · versions · tabs · dropdowns · anchors · groups · pages
9
+ *
10
+ * plus leaf `pages` entries that are bare path strings, `{ group, pages }`
11
+ * sub-groups, or `{ page/anchor/..., href }` external links. `versions` and
12
+ * `languages` wrap any other structure, so a fully-loaded site can be
13
+ * `versions → languages → tabs → groups → pages`.
14
+ *
15
+ * The legacy `mint.json` is flatter: `navigation` is an ARRAY of
16
+ * `{ group, pages }`, plus top-level `anchors: [{ name, url }]`. Both are
17
+ * handled here.
18
+ *
19
+ * Page labels are NOT in the config — a page is a bare slug, and its
20
+ * human-readable title lives in the MDX file's frontmatter. We resolve it the
21
+ * same way the Fumadocs nav builder does (frontmatter `title`, else a humanized
22
+ * last path segment).
23
+ */
24
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
25
+ import { basename, dirname, join, sep } from "node:path";
26
+ import YAML from "yaml";
27
+ import { openApiPageKey, openApiRefSlug } from "./mintlify-openapi.js";
28
+ /**
29
+ * True when `dir` looks like a Mintlify docs source: a `docs.json`/`mint.json`
30
+ * that actually reads as a Mintlify config, plus md/mdx pages. The config sniff
31
+ * (a `navigation` key or a `mintlify` `$schema`) keeps an unrelated `docs.json`
32
+ * — e.g. TypeDoc's output — from being claimed by the mdx importer.
33
+ */
34
+ export function detectMintlifyDir(dir) {
35
+ if (!hasMdFiles(dir))
36
+ return false;
37
+ const config = readConfig(dir);
38
+ if (!config)
39
+ return false;
40
+ return (config.navigation != null ||
41
+ Array.isArray(config.anchors) ||
42
+ (typeof config.$schema === "string" && config.$schema.includes("mintlify")));
43
+ }
44
+ /**
45
+ * Build NavItem[] from a Mintlify config, or null when no config is present or
46
+ * it declares no navigation.
47
+ */
48
+ export function buildMintlifyNav(source, opts = {}) {
49
+ const config = readConfig(source);
50
+ if (!config)
51
+ return null;
52
+ const ctx = { source, prefix: opts.hrefPrefix ?? "", depth: 0, budget: { spent: 0, warned: false } };
53
+ const items = [];
54
+ // `navigation` is an object (docs.json) or an array (legacy mint.json).
55
+ if (config.navigation != null) {
56
+ items.push(...navNodeToItems(config.navigation, ctx));
57
+ }
58
+ // Legacy top-level anchors sit alongside `navigation`, not inside it.
59
+ for (const a of objs(config.anchors))
60
+ items.push(anchorItem(a, ctx));
61
+ return items.length > 0 ? items : null;
62
+ }
63
+ /** Depth cap for the navigation walk — far above any real config (max seen: 6). */
64
+ const MAX_NAV_DEPTH = 64;
65
+ /**
66
+ * Total nav nodes one build may visit.
67
+ *
68
+ * Depth alone does not bound WORK: a cyclic `$ref` with two children per level
69
+ * is O(2^depth) — measured 4x per two levels, each node doing a readFileSync —
70
+ * so a depth cap of 64 still hangs the import forever. The bug this replaced
71
+ * (an unguarded RangeError) at least TERMINATED. Mintlify's own docs are ~1000
72
+ * items, so this is ~50x the largest real config.
73
+ */
74
+ const MAX_NAV_NODES = 50_000;
75
+ function readConfig(source) {
76
+ for (const name of ["docs.json", "mint.json"]) {
77
+ const p = join(source, name);
78
+ if (existsSync(p)) {
79
+ try {
80
+ return JSON.parse(readFileSync(p, "utf-8"));
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+ /**
90
+ * A navigation node: an array (legacy nav / a bare pages list) or an object
91
+ * carrying container arrays. Containers are checked in outer-to-inner order and
92
+ * concatenated — the schema says one type per level, but concatenating is safe
93
+ * and tolerant of hand-written configs that mix them.
94
+ */
95
+ function navNodeToItems(node, ctx) {
96
+ if (Array.isArray(node))
97
+ return node.flatMap((e) => entryToItems(e, ctx));
98
+ if (!node || typeof node !== "object")
99
+ return [];
100
+ // Container elements must be objects — a stray null/scalar in an array must
101
+ // be skipped, not throw (readConfig's contract is "bad config → null nav",
102
+ // and buildMintlifyNav is called uncaught). `pages` is exempt: entryToItems
103
+ // already tolerates strings and null.
104
+ if (++ctx.budget.spent > MAX_NAV_NODES) {
105
+ if (!ctx.budget.warned) {
106
+ console.warn(`warning: navigation exceeded ${MAX_NAV_NODES} nodes — the rest was skipped ` +
107
+ "(a branching $ref cycle looks like this)");
108
+ ctx.budget.warned = true;
109
+ }
110
+ return [];
111
+ }
112
+ if (ctx.depth >= MAX_NAV_DEPTH) {
113
+ console.warn(`warning: navigation nesting exceeded ${MAX_NAV_DEPTH} levels — the rest of ` +
114
+ "this branch was skipped (a $ref cycle looks like this)");
115
+ return [];
116
+ }
117
+ const inner = { ...ctx, depth: ctx.depth + 1 };
118
+ const items = [];
119
+ // `$ref` is resolved at every container level, not just languages: the same
120
+ // split-file pattern is used for versions and tabs in large configs.
121
+ for (const l of objs(node.languages))
122
+ items.push(variantItem(resolveRef(l, inner), "language", inner));
123
+ for (const v of objs(node.versions))
124
+ items.push(variantItem(resolveRef(v, inner), "version", inner));
125
+ for (const p of objs(node.products))
126
+ items.push(containerItem(resolveRef(p, inner), "product", inner));
127
+ for (const t of objs(node.tabs))
128
+ items.push(containerItem(resolveRef(t, inner), "tab", inner));
129
+ for (const a of objs(node.anchors))
130
+ items.push(containerItem(resolveRef(a, inner), "anchor", inner));
131
+ for (const d of objs(node.dropdowns))
132
+ items.push(containerItem(resolveRef(d, inner), "dropdown", inner));
133
+ for (const g of objs(node.groups))
134
+ items.push(...groupItems(resolveRef(g, inner), inner));
135
+ // `global` holds divisions shown on EVERY page (global anchors/tabs — a
136
+ // changelog or blog link that persists across sections). Documented in
137
+ // Mintlify's navigation reference; unhandled it silently dropped those
138
+ // links. Recursed like any other node, since it just wraps divisions.
139
+ if (node.global && typeof node.global === "object") {
140
+ items.push(...navNodeToItems(resolveRef(node.global, inner), inner));
141
+ }
142
+ // `menu` holds `{ item, href|groups }` entries — the dropdown under a tab.
143
+ // Missing it cost Mintlify's own docs their entire "Guides" section: 30 real
144
+ // pages imported from disk but appeared in no navigation, which is also why
145
+ // they looked like "pages outside the nav" when auditing coverage.
146
+ for (const m of objs(node.menu))
147
+ items.push(menuItem(resolveRef(m, inner), inner));
148
+ if (Array.isArray(node.pages))
149
+ items.push(...node.pages.flatMap((p) => entryToItems(p, inner)));
150
+ return items.filter(Boolean);
151
+ }
152
+ /** Array elements that are non-null objects (skips stray null/scalar entries). */
153
+ function objs(v) {
154
+ return Array.isArray(v) ? v.filter((e) => e != null && typeof e === "object") : [];
155
+ }
156
+ /**
157
+ * Resolve a `{ "$ref": "./fr.json" }` node by loading that file.
158
+ *
159
+ * Mintlify splits large multi-language configs across files: the `en` entry is
160
+ * inlined in `docs.json` and the rest are `$ref`s to siblings. Not following
161
+ * them cost Mintlify's own docs their ENTIRE French, Spanish and Chinese
162
+ * navigation — three nav entries labelled `language` with no children, while
163
+ * the fr/es/zh pages imported fine and were simply unreachable.
164
+ *
165
+ * Resolved relative to the docs root, like every other Mintlify path. A ref
166
+ * that cannot be read returns the node untouched, so it degrades to today's
167
+ * behaviour rather than throwing.
168
+ *
169
+ * Two guards, both learned from `mintlify-openapi.ts` closing the same holes:
170
+ *
171
+ * - CONFINEMENT. `docs.json` ships inside the untrusted repo being converted,
172
+ * so "the operator chose it" covers which DIRECTORY to convert, not the
173
+ * contents of a file in it. `{"$ref":"../../secret.json"}` put an
174
+ * out-of-corpus file's contents into the exported navigation as labels and
175
+ * hrefs.
176
+ * - CYCLES. A self-referential ref recursed until the stack was exhausted,
177
+ * producing a nav ~1800 levels deep whose depth varied by machine; it only
178
+ * failed to crash because the RangeError happened to land inside the
179
+ * try/catch below.
180
+ */
181
+ function resolveRef(node, ctx) {
182
+ if (!node || typeof node !== "object" || typeof node.$ref !== "string")
183
+ return node;
184
+ const target = join(ctx.source, node.$ref.replace(/^\.\//, "").replace(/^\//, ""));
185
+ if (!withinSource(target, ctx.source)) {
186
+ console.warn(`warning: navigation $ref "${node.$ref}" points outside the docs directory — ignored`);
187
+ return node;
188
+ }
189
+ if (!existsSync(target))
190
+ return node;
191
+ try {
192
+ const loaded = JSON.parse(readFileSync(target, "utf-8"));
193
+ // A ref can point at an ARRAY (redirects.json is a bare list) — spreading
194
+ // that into an object would turn it into {0:…,1:…}.
195
+ if (Array.isArray(loaded))
196
+ return loaded;
197
+ // Keep any sibling keys the ref node carried (e.g. an explicit `language`).
198
+ const { $ref: _ref, ...rest } = node;
199
+ return { ...loaded, ...rest };
200
+ }
201
+ catch {
202
+ return node;
203
+ }
204
+ }
205
+ /** Is `target` inside the docs root once symlinks are resolved? */
206
+ function withinSource(target, source) {
207
+ const real = (p) => {
208
+ try {
209
+ return realpathSync(p);
210
+ }
211
+ catch {
212
+ const parent = dirname(p);
213
+ return parent === p ? p : join(real(parent), basename(p));
214
+ }
215
+ };
216
+ const root = real(source);
217
+ const abs = real(target);
218
+ return abs === root || abs.startsWith(root + sep);
219
+ }
220
+ /** A single entry inside a `pages` array. */
221
+ function entryToItems(entry, ctx) {
222
+ if (typeof entry === "string") {
223
+ // A `METHOD /path` string references an OpenAPI operation generated from the
224
+ // container's `openapi` spec (ollama's "Endpoints" group) — it is not a
225
+ // file. Point at the slug the page generator uses (openApiRefSlug) so the
226
+ // link resolves; treating it as a file slug produced a broken `/POST /x`.
227
+ const oaKey = openApiPageKey(entry);
228
+ if (oaKey) {
229
+ const path = oaKey.slice(oaKey.indexOf(" ") + 1);
230
+ const slug = openApiRefSlug(path);
231
+ const last = slug.split("/").pop() || slug;
232
+ return [{ label: humanize(last), href: `${ctx.prefix}/${slug}` }];
233
+ }
234
+ return [pageItem(entry, ctx)];
235
+ }
236
+ if (!entry || typeof entry !== "object")
237
+ return [];
238
+ // Sub-group: `{ group, pages }`.
239
+ if (typeof entry.group === "string")
240
+ return groupItems(entry, ctx);
241
+ // External or explicit page link: `{ page?, href }`.
242
+ if (typeof entry.href === "string") {
243
+ const label = str(entry.page ?? entry.group ?? entry.title) ?? entry.href;
244
+ return [{ label, href: resolveHref(entry.href, ctx) }];
245
+ }
246
+ // A nested navigation node (e.g. a page entry that itself carries groups).
247
+ const nested = navNodeToItems(entry, ctx);
248
+ return nested;
249
+ }
250
+ /** `{ group, pages, root?, tag? }` → a group NavItem (empty label = splice up). */
251
+ function groupItems(g, ctx) {
252
+ const children = navNodeToItems(g, ctx);
253
+ const label = str(g.group);
254
+ // A group with an empty/absent name (legacy root group) has no header — its
255
+ // pages belong at the parent level.
256
+ if (!label)
257
+ return children;
258
+ const item = { label, children };
259
+ // `root` names a landing page for the group.
260
+ if (typeof g.root === "string")
261
+ item.href = pageHref(g.root, ctx);
262
+ return [item];
263
+ }
264
+ /** tab / dropdown / anchor: labelled by its own key; may be external or nest. */
265
+ function containerItem(node, kind, ctx) {
266
+ const label = str(node[kind]) ?? str(node.name) ?? kind;
267
+ const children = navNodeToItems(node, ctx);
268
+ if (children.length > 0)
269
+ return { label, children };
270
+ // Leaf: an external href, or a bare path (legacy anchor `url`).
271
+ const target = str(node.href) ?? str(node.url);
272
+ return target ? { label, href: resolveHref(target, ctx) } : { label };
273
+ }
274
+ /** version / language: labelled by its key, tag appended; wraps nested nav. */
275
+ function variantItem(node, kind, ctx) {
276
+ const base = str(node[kind]) ?? kind;
277
+ const tag = str(node.tag);
278
+ const label = tag ? `${base} (${tag})` : base;
279
+ const children = navNodeToItems(node, ctx);
280
+ const item = { label };
281
+ if (children.length > 0)
282
+ item.children = children;
283
+ else if (str(node.href))
284
+ item.href = resolveHref(node.href, ctx);
285
+ return item;
286
+ }
287
+ /**
288
+ * Mintlify `redirects` as a source → destination map, `$ref` resolved.
289
+ *
290
+ * A docs site accumulates these as pages move, and its OWN pages then link to
291
+ * the old paths — Mintlify serves the redirect, so the link works and nobody
292
+ * notices it is stale. On import the redirect does not exist, so those links
293
+ * 404: measured on Mintlify's own docs, 16 internal links point at a redirect
294
+ * source, including `/installation` on the front page (→ `/cli/install`).
295
+ *
296
+ * `redirects` is itself commonly a `$ref` to a sibling file (121 entries in
297
+ * `redirects.json` here), so it is resolved the same way as navigation.
298
+ */
299
+ export function mintlifyRedirects(source) {
300
+ const config = readConfig(source);
301
+ if (!config)
302
+ return {};
303
+ const ctx = { source, prefix: "", depth: 0, budget: { spent: 0, warned: false } };
304
+ const raw = config.redirects;
305
+ const list = Array.isArray(raw)
306
+ ? raw
307
+ : (() => {
308
+ const resolved = resolveRef(raw, ctx);
309
+ return Array.isArray(resolved)
310
+ ? resolved
311
+ : Array.isArray(resolved?.redirects)
312
+ ? resolved.redirects
313
+ : [];
314
+ })();
315
+ const map = {};
316
+ for (const entry of list) {
317
+ const from = str(entry?.source);
318
+ const to = str(entry?.destination);
319
+ if (from && to)
320
+ map[from.replace(/\/$/, "")] = to;
321
+ }
322
+ return map;
323
+ }
324
+ /** A `menu` entry: `{ item, href? , groups?/pages? }`. */
325
+ function menuItem(node, ctx) {
326
+ const label = str(node.item) ?? str(node.group) ?? "Menu";
327
+ const children = navNodeToItems(node, ctx);
328
+ const item = { label };
329
+ if (children.length > 0)
330
+ item.children = children;
331
+ else if (str(node.href))
332
+ item.href = resolveHref(node.href, ctx);
333
+ return item;
334
+ }
335
+ /** Legacy top-level anchor: `{ name, url, icon? }`. */
336
+ function anchorItem(a, ctx) {
337
+ const label = str(a.name) ?? str(a.anchor) ?? "Anchor";
338
+ const target = str(a.url) ?? str(a.href);
339
+ return target ? { label, href: resolveHref(target, ctx) } : { label };
340
+ }
341
+ /** A bare page slug → NavItem, label resolved from frontmatter title. */
342
+ function pageItem(slug, ctx) {
343
+ return { label: pageLabel(slug, ctx), href: pageHref(slug, ctx) };
344
+ }
345
+ // ── href / label helpers ────────────────────────────────
346
+ function resolveHref(target, ctx) {
347
+ if (isExternal(target) || target.startsWith("#") || target.startsWith("mailto:"))
348
+ return target;
349
+ return pageHref(target, ctx);
350
+ }
351
+ function pageHref(slug, ctx) {
352
+ const clean = slug.replace(/^\//, "").replace(/\.mdx?$/, "");
353
+ // An `index` page is routed to its folder root by Astro (root `index.mdx` → `/`,
354
+ // `foo/index.mdx` → `/foo`), so the nav href must match — linking to `/index`
355
+ // 404s while the page lives at `/`. Mirrors the Fumadocs nav builder.
356
+ if (clean === "index")
357
+ return `${ctx.prefix}/`;
358
+ return `${ctx.prefix}/${clean.replace(/\/index$/, "")}`;
359
+ }
360
+ function isExternal(s) {
361
+ return /^https?:\/\//.test(s);
362
+ }
363
+ function pageLabel(slug, ctx) {
364
+ const clean = slug.replace(/^\//, "").replace(/\.mdx?$/, "");
365
+ const file = resolvePageFile(ctx.source, clean);
366
+ if (file) {
367
+ const title = frontmatterTitle(file);
368
+ if (title)
369
+ return title;
370
+ }
371
+ const last = clean.split("/").pop() || clean;
372
+ return humanize(last);
373
+ }
374
+ function resolvePageFile(source, slug) {
375
+ for (const ext of [".mdx", ".md"]) {
376
+ const p = join(source, slug + ext);
377
+ if (existsSync(p))
378
+ return p;
379
+ // A folder index: `slug/index.mdx`.
380
+ const idx = join(source, slug, "index" + ext);
381
+ if (existsSync(idx))
382
+ return idx;
383
+ }
384
+ return null;
385
+ }
386
+ function frontmatterTitle(file) {
387
+ try {
388
+ const src = readFileSync(file, "utf-8");
389
+ const m = /^---\n([\s\S]*?)\n---/.exec(src);
390
+ if (m) {
391
+ const fm = YAML.parse(m[1]);
392
+ if (fm && typeof fm.title === "string")
393
+ return fm.title;
394
+ }
395
+ }
396
+ catch {
397
+ // fall through
398
+ }
399
+ return undefined;
400
+ }
401
+ function humanize(name) {
402
+ return name.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
403
+ }
404
+ function str(v) {
405
+ if (typeof v !== "string")
406
+ return undefined;
407
+ const t = v.trim();
408
+ return t.length > 0 ? t : undefined;
409
+ }
410
+ function hasMdFiles(dir) {
411
+ try {
412
+ return readdirSync(dir).some((e) => (e.endsWith(".md") || e.endsWith(".mdx")) && e !== "llms.txt");
413
+ }
414
+ catch {
415
+ return false;
416
+ }
417
+ }
@@ -0,0 +1,108 @@
1
+ import type { TreeNode, ExportPage } from "@dogsbay/types";
2
+ /**
3
+ * The OpenAPI spec reference declared in a Mintlify config, or null. Reads
4
+ * `docs.json` / `mint.json` `api.openapi` (string, or first of an array) and the
5
+ * legacy top-level `openapi`. Only an `http(s)://` value is a URL; everything
6
+ * else — including a ROOT-relative path like `/api-reference/openapi.json`,
7
+ * which Mintlify resolves against the docs root, not the filesystem root — is
8
+ * resolved against `source`.
9
+ */
10
+ export declare function mintlifyOpenApiSpecRef(source: string): string | null;
11
+ /**
12
+ * Where a spec reference came from — which decides how far we trust it.
13
+ *
14
+ * `config` is `docs.json` / `mint.json`: the operator pointed us at this site,
15
+ * so its config is a deliberate choice. `page` is a `.mdx` file's own
16
+ * frontmatter — that is CONTENT, authored by whoever wrote the repo we are
17
+ * converting, and converting an arbitrary public repo is the whole point of
18
+ * the migration story. Content does not get to name a network endpoint or a
19
+ * file outside the corpus.
20
+ */
21
+ export type SpecRefOrigin = "config" | "page";
22
+ /**
23
+ * Fetch the raw spec document, unparsed.
24
+ *
25
+ * An OpenAPI spec is a CANONICAL SOURCE ARTIFACT, not prose: round-tripping it
26
+ * through the TreeNode IR is lossy by construction ($ref/component schemas
27
+ * inline, security schemes and callbacks vanish). Exporters that can consume a
28
+ * spec directly should pass THIS through rather than rebuild it from endpoint
29
+ * nodes. Returns null when the spec cannot be read.
30
+ */
31
+ export declare function readOpenApiSpecSource(specRef: string): Promise<{
32
+ text: string;
33
+ filename: string;
34
+ } | null>;
35
+ export interface EndpointMapResult {
36
+ /** `"<method> <path>"` (lowercase method) → the `endpoint` TreeNode(s). */
37
+ map: Map<string, TreeNode[]>;
38
+ /** The same keys → the page title format-openapi gives the operation. */
39
+ titles?: Map<string, string>;
40
+ /** Spec load/parse failure message, or null. Lets the caller distinguish a
41
+ * failed fetch (blame the spec source) from a genuine no-match (blame the
42
+ * page). Without this, every page reads "no matching operation" on a 404. */
43
+ loadError: string | null;
44
+ }
45
+ /**
46
+ * Parse a spec into an endpoint map. A fetch/parse error must degrade to blank
47
+ * pages (never crash the import), but is reported via `loadError` rather than
48
+ * silently swallowed.
49
+ */
50
+ export declare function buildMintlifyEndpointMap(specRef: string): Promise<EndpointMapResult>;
51
+ /**
52
+ * Normalise a page's `openapi` frontmatter value to a map key, or null when the
53
+ * page is not an OpenAPI page. Accepts `"post /partners/ban"` (method + path);
54
+ * a leading named-spec token (`"api POST /x"`) is tolerated by taking the last
55
+ * two whitespace-separated tokens.
56
+ */
57
+ export declare function openApiPageKey(value: unknown): string | null;
58
+ /**
59
+ * The spec a PAGE names in its own `openapi:` frontmatter, or null.
60
+ *
61
+ * Mintlify lets a page point at a specific spec inline:
62
+ *
63
+ * openapi: "/openapi.json GET /project/update-status/{statusId}"
64
+ *
65
+ * `openApiPageKey` already tolerates that leading token (it takes the last two
66
+ * tokens); this reads the token itself, which nothing did. A site whose
67
+ * `docs.json` has no `api.openapi` key therefore resolved NO endpoints even
68
+ * though every page stated exactly which spec to use — measured on Mintlify's
69
+ * own documentation: 100 pages imported without their endpoint content and 20
70
+ * of them came out completely blank, next to an `openapi.json` sitting in the
71
+ * repo root.
72
+ *
73
+ * Resolved the same way as a config-declared ref: a leading `/` is the DOCS
74
+ * root, not the filesystem root.
75
+ */
76
+ export declare function openApiPageSpecRef(source: string, value: unknown): string | null;
77
+ /**
78
+ * The vault/site slug for a nav-referenced OpenAPI operation (a `pages` string
79
+ * of the form `"POST /api/generate"` under a tab/group that declares an
80
+ * `openapi` spec). The NAV and the GENERATED PAGE both call this, so their
81
+ * href and slug always agree. Path-based (`/api/generate` → `api/generate`) for
82
+ * clean URLs; same-path-different-method operations would collide — rare, and
83
+ * documented (ollama and typical REST doc sets use unique paths per page).
84
+ */
85
+ export declare function openApiRefSlug(path: string): string;
86
+ /**
87
+ * Every distinct OpenAPI spec a Mintlify config references — the top-level
88
+ * `api.openapi` AND any `openapi` declared on a nav container (tab/group).
89
+ *
90
+ * Both are real source artifacts. Capturing only the top-level one meant a site
91
+ * like ollama (whose spec is declared on an "API Reference" tab) fell back to
92
+ * lossy reconstruction despite having a perfectly good spec on disk.
93
+ */
94
+ export declare function collectMintlifyOpenApiSpecRefs(source: string,
95
+ /** Spec refs named by individual pages' `openapi:` frontmatter. */
96
+ pageSpecRefs?: readonly string[]): string[];
97
+ /**
98
+ * Generate an ExportPage for every OpenAPI operation referenced ONLY by the nav
99
+ * (a `METHOD /path` `pages` string under a container that declares `openapi`) —
100
+ * these pages exist in no `.mdx` file (unlike the frontmatter case), so without
101
+ * this the nav links are dead and the API section is empty. ollama's docs.json
102
+ * is exactly this shape (an "Endpoints" group of 12 such strings under a tab
103
+ * with `openapi: /openapi.yaml`).
104
+ */
105
+ export declare function collectMintlifyOpenApiNavPages(source: string): Promise<{
106
+ pages: ExportPage[];
107
+ loadError: string | null;
108
+ }>;