@dogsbay/format-mdx 0.2.0-beta.11 → 0.2.0-beta.111

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 (52) hide show
  1. package/dist/adapter.d.ts +159 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +95 -0
  4. package/dist/adapter.js.map +1 -0
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +269 -19
  7. package/dist/cli.js.map +1 -1
  8. package/dist/extract.d.ts +32 -0
  9. package/dist/extract.d.ts.map +1 -0
  10. package/dist/extract.js +152 -0
  11. package/dist/extract.js.map +1 -0
  12. package/dist/fumadocs.d.ts +48 -0
  13. package/dist/fumadocs.d.ts.map +1 -0
  14. package/dist/fumadocs.js +470 -0
  15. package/dist/fumadocs.js.map +1 -0
  16. package/dist/index.d.ts +11 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +11 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/js-scan.d.ts +35 -0
  21. package/dist/js-scan.d.ts.map +1 -0
  22. package/dist/js-scan.js +124 -0
  23. package/dist/js-scan.js.map +1 -0
  24. package/dist/meta-nav.d.ts +29 -0
  25. package/dist/meta-nav.d.ts.map +1 -0
  26. package/dist/meta-nav.js +194 -0
  27. package/dist/meta-nav.js.map +1 -0
  28. package/dist/mintlify-nav.d.ts +31 -0
  29. package/dist/mintlify-nav.d.ts.map +1 -0
  30. package/dist/mintlify-nav.js +418 -0
  31. package/dist/mintlify-nav.js.map +1 -0
  32. package/dist/mintlify-openapi.d.ts +107 -0
  33. package/dist/mintlify-openapi.d.ts.map +1 -0
  34. package/dist/mintlify-openapi.js +357 -0
  35. package/dist/mintlify-openapi.js.map +1 -0
  36. package/dist/mintlify.d.ts +3 -0
  37. package/dist/mintlify.d.ts.map +1 -0
  38. package/dist/mintlify.js +196 -0
  39. package/dist/mintlify.js.map +1 -0
  40. package/dist/nav-extract.d.ts +43 -0
  41. package/dist/nav-extract.d.ts.map +1 -0
  42. package/dist/nav-extract.js +135 -0
  43. package/dist/nav-extract.js.map +1 -0
  44. package/dist/parse.d.ts +100 -4
  45. package/dist/parse.d.ts.map +1 -1
  46. package/dist/parse.js +1228 -149
  47. package/dist/parse.js.map +1 -1
  48. package/dist/ts-type-block.d.ts +43 -0
  49. package/dist/ts-type-block.d.ts.map +1 -0
  50. package/dist/ts-type-block.js +164 -0
  51. package/dist/ts-type-block.js.map +1 -0
  52. package/package.json +10 -8
package/dist/parse.js CHANGED
@@ -8,12 +8,21 @@
8
8
  * 4. Parse markdown sections with markdown-it for inline formatting.
9
9
  * 5. Map components to TreeNode types.
10
10
  *
11
- * The MDX served by Mintlify/Next.js uses well-formed, XML-like JSX tags with
12
- * string attributes — no complex JSX expressions. This means a simple recursive
13
- * tag parser works without a full JSX/AST parser.
11
+ * The MDX served by Mintlify/Next.js/Fumadocs uses well-formed, XML-like JSX
12
+ * tags with string attributes — no complex JSX expressions. This means a
13
+ * simple recursive tag parser works without a full JSX/AST parser.
14
+ *
15
+ * Extension points (plans/format-fumadocs.md): the component registry is
16
+ * open — `parseMdx(source, { adapter, components })` merges
17
+ * builtins < adapter < per-source config. Unknown block components are
18
+ * NEVER dropped: they become `mdx-raw` nodes carrying the verbatim source
19
+ * span plus a fidelity-ledger entry.
14
20
  */
21
+ import { existsSync, readFileSync } from "node:fs";
22
+ import { dirname, isAbsolute, resolve } from "node:path";
15
23
  import MarkdownIt from "markdown-it";
16
24
  const md = new MarkdownIt({ html: true, linkify: true });
25
+ // ── Built-in component tag → TreeNode type mapping ───────
17
26
  const COMPONENT_MAP = {
18
27
  // Callouts — map to native Obsidian callout syntax
19
28
  Info: { type: "callout", container: true, propsFromAttrs: () => ({ variant: "info" }) },
@@ -21,17 +30,30 @@ const COMPONENT_MAP = {
21
30
  Warning: { type: "callout", container: true, propsFromAttrs: () => ({ variant: "warning" }) },
22
31
  Note: { type: "callout", container: true, propsFromAttrs: () => ({ variant: "note" }) },
23
32
  Danger: { type: "callout", container: true, propsFromAttrs: () => ({ variant: "danger" }) },
33
+ // Mintlify's success callout. Unmapped it was preserved as raw MDX, so the
34
+ // exporter stripped the tag and the "this worked" framing was lost; it
35
+ // surfaced only once snippet inlining exposed the 19 uses inside snippets.
36
+ Check: { type: "callout", container: true, propsFromAttrs: () => ({ variant: "success" }) },
24
37
  Callout: {
25
38
  type: "callout", container: true,
26
- propsFromAttrs: (attrs) => ({ variant: attrs.type || "note", title: attrs.title }),
39
+ propsFromAttrs: (attrs) => ({
40
+ variant: mapCalloutType(attrs.type || "note"),
41
+ title: attrs.title,
42
+ }),
27
43
  },
28
44
  // Tabs
29
45
  Tabs: { type: "tabs", container: true },
30
46
  Tab: {
31
47
  type: "tab", container: true,
32
- propsFromAttrs: (attrs) => ({ title: attrs.title || attrs.label || "" }),
48
+ propsFromAttrs: (attrs) => ({ title: attrs.title || attrs.label || attrs.value || "" }),
33
49
  },
34
50
  CodeGroup: { type: "tabs", container: true },
51
+ // Mintlify renders these in a side panel next to an API description; there
52
+ // is no equivalent anywhere else, and the fenced code inside already carries
53
+ // its own title (```json Response). Unwrapping keeps the code and loses only
54
+ // the panel placement — preserving them as raw MDX lost the code entirely.
55
+ RequestExample: { type: "", container: true, toNode: (_a, inner, ctx) => ctx.parseBlocks(inner) },
56
+ ResponseExample: { type: "", container: true, toNode: (_a, inner, ctx) => ctx.parseBlocks(inner) },
35
57
  // Steps
36
58
  Steps: { type: "steps", container: true },
37
59
  Step: {
@@ -56,6 +78,9 @@ const COMPONENT_MAP = {
56
78
  title: attrs.title || "",
57
79
  icon: attrs.icon,
58
80
  href: attrs.href,
81
+ // Fumadocs cards carry their body in a description attr —
82
+ // dropping it loses real content.
83
+ description: attrs.description,
59
84
  }),
60
85
  },
61
86
  // Frame (image wrapper with optional caption)
@@ -69,22 +94,278 @@ const COMPONENT_MAP = {
69
94
  ContactSalesCard: { type: "", container: true, strip: true },
70
95
  InstallConfigurator: { type: "", container: true, strip: true },
71
96
  };
97
+ /** Fumadocs/Mintlify callout `type` attr → Dogsbay callout variant. */
98
+ function mapCalloutType(type) {
99
+ const map = {
100
+ note: "note",
101
+ info: "info",
102
+ tip: "tip",
103
+ warn: "warning",
104
+ warning: "warning",
105
+ error: "danger",
106
+ danger: "danger",
107
+ caution: "warning",
108
+ success: "success",
109
+ };
110
+ return map[type.toLowerCase()] ?? "note";
111
+ }
72
112
  // Tags we recognize as self-closing (no children)
73
- const SELF_CLOSING_TAGS = new Set(["Frame", "ContactSalesCard"]);
113
+ // `Frame` is NOT here: it is a CONTAINER. Measured on the real corpora, dub has
114
+ // 818 `<Frame>` opens, 818 `</Frame>` closes and zero self-closing forms;
115
+ // mintlify-docs has 537/526. Declaring it self-closing meant every Frame
116
+ // opened and immediately closed, so its `<img>` fell out as a SIBLING node —
117
+ // producing 818 empty `figure` nodes, 818 "figure had no renderable content"
118
+ // ledger drops that were false, and the loss of the caption↔image pairing.
119
+ // That single wrong entry was the largest line in the export ledger.
120
+ const SELF_CLOSING_TAGS = new Set(["ContactSalesCard"]);
121
+ function buildCtx(opts) {
122
+ const map = { ...COMPONENT_MAP };
123
+ const selfClosing = new Set(SELF_CLOSING_TAGS);
124
+ if (opts?.adapter?.components)
125
+ Object.assign(map, opts.adapter.components);
126
+ for (const tag of opts?.adapter?.selfClosingTags ?? [])
127
+ selfClosing.add(tag);
128
+ if (opts?.components)
129
+ Object.assign(map, opts.components);
130
+ for (const tag of opts?.selfClosingTags ?? [])
131
+ selfClosing.add(tag);
132
+ return {
133
+ map,
134
+ selfClosing,
135
+ ledger: [],
136
+ moduleData: {},
137
+ imports: {},
138
+ rootDir: opts?.rootDir,
139
+ inlining: opts?.inlining ?? new Set(),
140
+ file: opts?.file,
141
+ };
142
+ }
74
143
  // ── Main parser ──────────────────────────────────────────
75
- export function mdxToTree(source) {
144
+ /**
145
+ * Parse MDX into TreeNode[] + fidelity ledger.
146
+ */
147
+ export function parseMdx(source, opts) {
148
+ const ctx = buildCtx(opts);
76
149
  // Strip LLM preamble (blockquote at top referencing llms.txt)
77
150
  let cleaned = stripPreamble(source);
78
- // Strip JSX component definitions (export const Foo = () => { ... };)
79
- // These are React components embedded in MDX that can't be converted
80
- cleaned = stripJsxExports(cleaned);
81
- // Parse into a flat list of segments: markdown blocks and component blocks
82
- const segments = segmentMdx(cleaned);
83
- // Convert segments to TreeNode[]
84
- const tree = segmentsToTree(segments);
151
+ // Adapter raw-text preprocessing (variable substitution, includes, …)
152
+ if (opts?.adapter?.preprocess) {
153
+ cleaned = opts.adapter.preprocess(cleaned, { filePath: opts.file });
154
+ }
155
+ // Adapter inline transforms (e.g. <Link …>…</Link> → markdown link)
156
+ cleaned = applyInlineTransforms(cleaned, opts?.adapter);
157
+ // Drop MDX comments. `{/* … */}` is an expression whose only content is a JS
158
+ // block comment, so MDX renders NOTHING for it — but we turned each one into
159
+ // a paragraph of literal source. Mintlify's own docs carry vale directives
160
+ // (`{/* vale Mintlify.Semicolons = NO */}`) on 42 pages, and every one of
161
+ // them showed up as visible prose on the rendered site. vale is the standard
162
+ // docs linter, so this is not a Mintlify quirk.
163
+ cleaned = stripMdxComments(cleaned);
164
+ // Strip MDX import statements and export blocks — fence-aware.
165
+ // Export literals are captured for handlers (same-file data pattern).
166
+ const stripped = stripMdxStatements(cleaned);
167
+ cleaned = stripped.source;
168
+ ctx.moduleData = stripped.moduleData;
169
+ ctx.imports = stripped.imports;
170
+ // An `export const X = (props) => (<JSX/>)` is a COMPONENT DEFINITION, and
171
+ // real docs put real documentation inside one. Stripping it is correct — it
172
+ // is JavaScript with props interpolated, so it cannot be rendered statically
173
+ // — but stripping it SILENTLY is not. dub's
174
+ // `snippets/webhook-response-body-parameters.mdx` is one such export and
175
+ // nothing else: 888 source chars became a zero-byte page with an empty
176
+ // ledger, and the four parameters it defines went missing from all 13
177
+ // webhook and postback pages that include it.
178
+ for (const [name, literal] of Object.entries(ctx.moduleData)) {
179
+ if (!/=>/.test(literal) || !/<[A-Za-z]/.test(literal))
180
+ continue;
181
+ ctx.ledger.push({
182
+ component: `export const ${name} (JSX component definition, ${literal.length} chars of content not rendered)`,
183
+ action: "stripped",
184
+ line: 0,
185
+ });
186
+ }
187
+ // Collapse block-leading multi-line lowercase-HTML opening tags to one line.
188
+ cleaned = collapseMultilineHtmlOpenTags(cleaned);
189
+ cleaned = joinBlankLinesInHtmlBlocks(cleaned);
190
+ const tree = parseBlocks(cleaned, ctx);
85
191
  // Post-process: rewrite links, fix JSX attributes, handle dark mode images
86
- postProcess(tree);
87
- return tree;
192
+ postProcess(tree, opts?.linkRedirects ?? {});
193
+ const grouped = groupTabbedCode(tree);
194
+ return { tree: grouped, ledger: ctx.ledger, moduleData: ctx.moduleData };
195
+ }
196
+ /**
197
+ * Back-compat convenience: parse and return only the tree.
198
+ * Prefer `parseMdx` — it also returns the fidelity ledger.
199
+ */
200
+ export function mdxToTree(source, opts) {
201
+ return parseMdx(source, opts).tree;
202
+ }
203
+ /**
204
+ * Parse an MDX fragment (component children) — no preamble/statement
205
+ * stripping, shared registry + ledger via ctx.
206
+ */
207
+ function parseBlocks(source, ctx) {
208
+ return segmentsToTree(segmentMdx(source, ctx), ctx);
209
+ }
210
+ /**
211
+ * Remove `{/* … *​/}` MDX comments, fence-aware.
212
+ *
213
+ * Only an expression whose ENTIRE body is a block comment is removed: `{foo /*
214
+ * note *​/}` is a real expression and must survive to the mdx-raw path. When
215
+ * the comment occupied a whole line, the line goes with it, so no blank
216
+ * paragraph is left behind.
217
+ */
218
+ export function stripMdxComments(source) {
219
+ // Line-based fence tracking rather than a regex split. The regex anchored the
220
+ // opener at column 0, so an INDENTED fence — a code block inside a list item
221
+ // or a <Step>, which is everywhere in Mintlify docs — was not protected, and
222
+ // its comments were stripped. It also let a ``` opener pair with a ~~~ closer
223
+ // and desynced from there, the same over-greedy-mask bug the Docusaurus
224
+ // sanitizer notes were written about. An unterminated fence now protects the
225
+ // remainder of the file rather than exposing it.
226
+ const out = [];
227
+ // Prose is buffered and stripped as a BLOCK, never line by line — a comment
228
+ // may span lines, and per-line stripping would miss it.
229
+ let prose = [];
230
+ const flush = () => {
231
+ if (prose.length === 0)
232
+ return;
233
+ out.push(stripCommentsOutsideCode(prose.join("\n")));
234
+ prose = [];
235
+ };
236
+ let fence = null;
237
+ for (const line of source.split("\n")) {
238
+ const m = /^\s*(`{3,}|~{3,})/.exec(line);
239
+ if (fence) {
240
+ // Only a run of the SAME character, at least as long, closes it.
241
+ if (m && m[1][0] === fence.char && m[1].length >= fence.len)
242
+ fence = null;
243
+ out.push(line);
244
+ continue;
245
+ }
246
+ // A fence INSIDE an open comment is commented-out sample code, not a code
247
+ // block. Treating it as one flushed the prose buffer mid-comment, so the
248
+ // comment could never be matched and `{/*`, the sample and `*/}` all
249
+ // rendered as visible page content — commenting out an example is a
250
+ // common docs idiom, so this fired on real pages.
251
+ if (m && !hasOpenComment(prose)) {
252
+ flush();
253
+ fence = { char: m[1][0], len: m[1].length };
254
+ out.push(line);
255
+ continue;
256
+ }
257
+ prose.push(line);
258
+ }
259
+ flush();
260
+ return out.join("\n");
261
+ }
262
+ /**
263
+ * Inline code spans, replaced with a NUL-delimited index.
264
+ *
265
+ * A span may cross lines (CommonMark allows it) but never a BLANK line — that
266
+ * bound is what stops one unbalanced backtick from pairing with the opener of
267
+ * a real span two paragraphs later and mis-masking everything between, which
268
+ * is the parity bug the split-based version had. Restricting spans to a single
269
+ * line also fixed it, but silently dropped multi-line spans from protection.
270
+ */
271
+ const CODE_SPAN = /`+(?:[^`\n]|\n(?![ \t]*\n))*`+/g;
272
+ function maskCodeSpans(text) {
273
+ const spans = [];
274
+ const masked = text.replace(CODE_SPAN, (m) => {
275
+ // A multi-line span that swallows a comment CLOSER it did not also open is
276
+ // almost certainly not a code span at all — it is an unbalanced backtick
277
+ // inside a comment pairing with one after it. Masking the `*/}` hid the
278
+ // close, so the comment read as open forever and the next fenced block was
279
+ // deleted. Leave it unmasked; a real documented span (`{/* x */}`) opens
280
+ // and closes inside itself and is unaffected.
281
+ if (m.includes("\n") && m.includes("*/}") && !m.includes("{/*"))
282
+ return m;
283
+ spans.push(m);
284
+ return `\u0000${spans.length - 1}\u0000`;
285
+ });
286
+ return { masked, spans };
287
+ }
288
+ /**
289
+ * Does the buffered prose end with an unclosed `{/*`?
290
+ *
291
+ * Masked first: scanning raw text meant a page that merely DOCUMENTS the
292
+ * syntax (`` `{/*` `` — 49 files in mintlify-docs contain `{/*`) looked like
293
+ * it had a comment open forever, which disabled fence protection for the rest
294
+ * of the file and deleted content from real code blocks.
295
+ */
296
+ function hasOpenComment(prose) {
297
+ const masked = maskCodeSpans(prose.join("\n")).masked.split("\n");
298
+ let openLine = -1;
299
+ for (let i = masked.length - 1; i >= 0; i--) {
300
+ const line = masked[i];
301
+ if (line.includes("*/}") && line.lastIndexOf("*/}") > line.lastIndexOf("{/*"))
302
+ return false;
303
+ if (line.includes("{/*")) {
304
+ openLine = i;
305
+ break;
306
+ }
307
+ }
308
+ if (openLine === -1)
309
+ return false;
310
+ // Bounded by a blank line, exactly like the stripper's own regexes: a
311
+ // comment that has not closed by then is never going to match, so treating
312
+ // the remainder of the FILE as commented-out un-protected every later fence
313
+ // and deleted content from real code blocks.
314
+ return !masked.slice(openLine + 1).some((l) => l.trim() === "");
315
+ }
316
+ /**
317
+ * Remove `{/* … *\/}` comments from a prose span, leaving INLINE CODE alone.
318
+ *
319
+ * Two ways this ate real content before:
320
+ *
321
+ * - A code span documenting the syntax lost its contents:
322
+ * "Always use `{/* … *\/}`" became "Always use ``". Mintlify's own docs do
323
+ * exactly this on ~8 pages, in four languages.
324
+ * - An UNTERMINATED `{/*` matched forward to the next `*\/` anywhere in the
325
+ * file, swallowing every paragraph in between. A single typo silently
326
+ * deleted the rest of a page.
327
+ *
328
+ * So: split on inline code spans and skip them, and require the comment to
329
+ * close before the next blank line — an unterminated one is left as-is rather
330
+ * than eating the document.
331
+ */
332
+ function stripCommentsOutsideCode(part) {
333
+ // MASK code spans, don't split on them. Splitting broke twice:
334
+ //
335
+ // - the "comment owns its line" rule is anchored with `^`, which matched at
336
+ // the start of every SEGMENT — a position that is mid-line whenever a code
337
+ // span precedes it. `Use \`code\` {/* vale off */}` then consumed the
338
+ // trailing newline and mashed the next line onto it.
339
+ // - `[^\`]*` crossed newlines, so one unbalanced backtick earlier in the
340
+ // block flipped the odd/even parity and a documented `{/* … */}` code span
341
+ // was emptied — the exact bug this function exists to prevent.
342
+ //
343
+ // Masking keeps line structure intact and makes parity a per-line question.
344
+ const { masked, spans } = maskCodeSpans(part);
345
+ const stripped = masked
346
+ // Comment alone on its line takes the line with it.
347
+ .replace(/^[ \t]*\{\s*\/\*(?:(?!\*\/)[^\n])*(?:\n(?!\s*\n)(?:(?!\*\/)[^\n])*)*\*\/\s*\}[ \t]*\n?/gm, "")
348
+ // Otherwise just the expression, leaving surrounding text intact.
349
+ .replace(/\{\s*\/\*(?:(?!\*\/)[^\n])*(?:\n(?!\s*\n)(?:(?!\*\/)[^\n])*)*\*\/\s*\}/g, "");
350
+ return stripped.replace(/\u0000(\d+)\u0000/g, (m, i) => spans[Number(i)] ?? m);
351
+ }
352
+ function applyInlineTransforms(source, adapter) {
353
+ if (!adapter?.inlineTransforms)
354
+ return source;
355
+ // Never rewrite inside fenced code blocks: split on fences, transform
356
+ // only the prose spans.
357
+ const parts = source.split(/(^(?:`{3,}|~{3,})[^\n]*\n[\s\S]*?\n(?:`{3,}|~{3,})[ \t]*$)/m);
358
+ return parts
359
+ .map((part, idx) => {
360
+ if (idx % 2 === 1)
361
+ return part; // fence span — untouched
362
+ let result = part;
363
+ for (const [regex, replacement] of adapter.inlineTransforms) {
364
+ result = result.replace(regex, replacement);
365
+ }
366
+ return result;
367
+ })
368
+ .join("");
88
369
  }
89
370
  // ── Post-processing ──────────────────────────────────────
90
371
  /**
@@ -93,28 +374,134 @@ export function mdxToTree(source) {
93
374
  * - Convert JSX className to HTML class
94
375
  * - Handle dark mode images (className="hidden dark:block" → hide by default)
95
376
  */
96
- function postProcess(nodes) {
377
+ /**
378
+ * Group CONSECUTIVE code blocks carrying `tab="…"` into one tab group.
379
+ *
380
+ * fumadocs' `remarkCodeTab` gives a bare fence a tab label and groups the run
381
+ * that follows; there is no wrapping element. Without this the labels were
382
+ * read but each fence still rendered as its own standalone code block, so a
383
+ * before/after comparison lost the thing that made it a comparison.
384
+ *
385
+ * Runs on every source, not just fumadocs: `tab=` has no other meaning in a
386
+ * fence info string, so a source that uses it means this.
387
+ */
388
+ function groupTabbedCode(nodes) {
389
+ const out = [];
390
+ let run = [];
391
+ const flush = () => {
392
+ if (run.length === 0)
393
+ return;
394
+ // A single tabbed fence is not a group — keep it a plain code block.
395
+ if (run.length === 1) {
396
+ out.push(run[0]);
397
+ }
398
+ else {
399
+ out.push({
400
+ type: "tabs",
401
+ children: run.map((code) => ({
402
+ type: "tab",
403
+ props: { title: String(code.props?.tab ?? "") },
404
+ children: [code],
405
+ })),
406
+ });
407
+ }
408
+ run = [];
409
+ };
97
410
  for (const node of nodes) {
411
+ if (node.children)
412
+ node.children = groupTabbedCode(node.children);
413
+ if (node.type === "code" && node.props?.tab) {
414
+ run.push(node);
415
+ continue;
416
+ }
417
+ flush();
418
+ out.push(node);
419
+ }
420
+ flush();
421
+ return out;
422
+ }
423
+ /**
424
+ * Rewrite an internal link so it resolves in the IMPORTED site.
425
+ *
426
+ * Two corrections, both measured on Mintlify's own docs, where they broke two
427
+ * of the four cards on the front page:
428
+ *
429
+ * 1. `/dir/index` → `/dir`. A `dir/index.mdx` page is routed at `/dir` (every
430
+ * importer collapses it, and so does the nav), but links written to the
431
+ * file path kept the `/index` and 404'd. 107 links in that corpus.
432
+ * 2. A path that the source's own `redirects` map moves — a docs site
433
+ * accumulates these as pages are renamed, and its own pages go on linking
434
+ * to the old path because the live site serves the redirect. After import
435
+ * nothing serves it. 16 links, including `/installation` → `/cli/install`.
436
+ *
437
+ * Anchors and query strings are preserved, and redirect chains are followed
438
+ * with a seen-set so a cycle in someone's config cannot hang the import.
439
+ */
440
+ export function rewriteInternalHref(href, redirects = {}) {
441
+ if (!href.startsWith("/"))
442
+ return href;
443
+ const [pathAndQuery, ...hashParts] = href.split("#");
444
+ const hash = hashParts.length ? `#${hashParts.join("#")}` : "";
445
+ const [rawPath, ...queryParts] = pathAndQuery.split("?");
446
+ const query = queryParts.length ? `?${queryParts.join("?")}` : "";
447
+ let path = rawPath.replace(/\/index$/, "") || "/";
448
+ const seen = new Set([path]);
449
+ for (let hop = 0; hop < 8; hop++) {
450
+ // `Object.hasOwn`, not a bare index: the map comes from someone else's
451
+ // config, and a plain-object lookup of `/toString` or `/constructor`
452
+ // returns an inherited FUNCTION. `target.replace` then threw a TypeError
453
+ // that aborted the whole import rather than producing a bad link.
454
+ const key = Object.hasOwn(redirects, path)
455
+ ? path
456
+ : path.replace(/\/$/, "");
457
+ const target = Object.hasOwn(redirects, key) ? redirects[key] : undefined;
458
+ if (typeof target !== "string" || seen.has(target))
459
+ break;
460
+ seen.add(target);
461
+ path = target.replace(/\/index$/, "") || "/";
462
+ }
463
+ return `${path}${query}${hash}`;
464
+ }
465
+ function postProcess(nodes, redirects = {}) {
466
+ for (const node of nodes) {
467
+ // Never touch preserved raw spans — byte-exactness is the contract.
468
+ if (node.type === "mdx-raw")
469
+ continue;
98
470
  // Fix HTML strings
99
471
  if (node.html) {
100
- node.html = fixHtml(node.html);
472
+ node.html = fixHtml(node.html, redirects);
101
473
  }
102
474
  // Fix href props (cards, links, etc.)
103
475
  if (node.props?.href && typeof node.props.href === "string") {
104
- node.props.href = node.props.href.replace(/^\/en\//, "/docs/");
476
+ node.props.href = rewriteInternalHref(node.props.href.replace(/^\/en\//, "/docs/"), redirects);
105
477
  }
106
478
  // Handle figure nodes (from <Frame>) — process children HTML
107
479
  if (node.type === "figure" && node.children) {
108
480
  // Filter out dark-only images, keep light/default ones
109
481
  filterDarkModeImages(node);
110
482
  }
483
+ // Inline links live on `inline`, not just `props`/`html` — a body link is
484
+ // the common case and was untouched by the href fix above.
485
+ for (const inl of node.inline ?? [])
486
+ rewriteInlineHrefs(inl, redirects);
111
487
  if (node.children)
112
- postProcess(node.children);
488
+ postProcess(node.children, redirects);
113
489
  }
114
490
  }
115
- function fixHtml(html) {
491
+ /** Apply the href rewrite to an inline node and its children. */
492
+ function rewriteInlineHrefs(node, redirects) {
493
+ if (node.type === "link" && typeof node.href === "string") {
494
+ node.href = rewriteInternalHref(node.href.replace(/^\/en\//, "/docs/"), redirects);
495
+ }
496
+ for (const child of node.children ?? []) {
497
+ rewriteInlineHrefs(child, redirects);
498
+ }
499
+ }
500
+ function fixHtml(html, redirects = {}) {
116
501
  // Rewrite internal links: /en/foo#bar → /docs/foo#bar
117
502
  html = html.replace(/href="\/en\//g, 'href="/docs/');
503
+ // …then the /index collapse + source redirects, on every internal href.
504
+ html = html.replace(/href="(\/[^"]*)"/g, (_m, h) => `href="${rewriteInternalHref(h, redirects)}"`);
118
505
  // Convert JSX className to HTML class
119
506
  html = html.replace(/className="/g, 'class="');
120
507
  return html;
@@ -177,59 +564,339 @@ function stripPreamble(source) {
177
564
  return content.trim();
178
565
  }
179
566
  /**
180
- * Strip `export const Foo = () => { ... };` blocks from MDX source.
567
+ * Strip MDX module-level statements — fence-aware so `import` lines inside
568
+ * code samples survive untouched:
569
+ * - `import … from "…"` lines (incl. multi-line import blocks) — Fumadocs
570
+ * and Docusaurus corpora carry these at the top of the body;
571
+ * - `export const Foo = …;` blocks — depth-tracked over BOTH braces and
572
+ * brackets (data arrays like `export const fields = [ {…}, ];` would
573
+ * otherwise leave a stray `];` behind).
181
574
  *
182
- * These are React component definitions embedded in MDX that can't be
183
- * converted to static markup. They appear as bare JavaScript in the
184
- * markdown and would otherwise render as visible source code.
185
- *
186
- * Uses brace-depth tracking to find the end of each export block.
575
+ * Export blocks are not just stripped — their right-hand sides are
576
+ * CAPTURED into `moduleData` (name → raw literal text). Docsets commonly
577
+ * keep component data as same-file exports (`<DatabaseTable
578
+ * fields={twoFactorTableFields} />`); handlers resolve those identifiers
579
+ * statically via `ctx.moduleData`. No execution, ever.
187
580
  */
188
- function stripJsxExports(source) {
581
+ function stripMdxStatements(source) {
189
582
  const lines = source.split("\n");
190
583
  const result = [];
584
+ const moduleData = {};
585
+ const imports = {};
191
586
  let i = 0;
587
+ let inFence = false;
588
+ let fenceChar = "";
192
589
  while (i < lines.length) {
193
590
  const line = lines[i];
194
- // Detect export const ... = () => { or export const ... = ({ ... }) => {
195
- if (line.match(/^export\s+const\s+\w+\s*=/)) {
196
- // Skip this entire block by tracking brace depth
591
+ const trimmed = line.trim();
592
+ // Fence tracking — statements inside fences are code, not MDX.
593
+ if (!inFence) {
594
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
595
+ if (fenceMatch) {
596
+ inFence = true;
597
+ fenceChar = fenceMatch[1][0];
598
+ result.push(line);
599
+ i++;
600
+ continue;
601
+ }
602
+ }
603
+ else {
604
+ if (trimmed.match(new RegExp(`^${fenceChar === "`" ? "`" : "~"}{3,}\\s*$`))) {
605
+ inFence = false;
606
+ }
607
+ result.push(line);
608
+ i++;
609
+ continue;
610
+ }
611
+ // import statements (possibly spanning lines until the `from "…"` /
612
+ // quoted specifier terminator)
613
+ if (trimmed.match(/^import\s/)) {
614
+ const start = i;
615
+ while (i < lines.length && !lines[i].match(/["'][^"']*["'];?\s*$/)) {
616
+ i++;
617
+ }
618
+ const statement = lines.slice(start, i + 1).join(" ");
619
+ i++; // skip the terminating line
620
+ // Remember `import X from "/snippets/foo.mdx"`. Mintlify's reuse
621
+ // mechanism is an MDX import of another .mdx file used as a component;
622
+ // throwing the specifier away is what turned <LinkArguments /> into an
623
+ // unresolvable unknown component.
624
+ // BOTH forms. dub uses `import Foo from …` for plain snippets and
625
+ // `import { Foo } from …` for parameterised ones, and matching only the
626
+ // default form left the named ones unresolved on 13 pages.
627
+ const spec = /\sfrom\s*["']([^"']+\.mdx?)["']/.exec(statement)?.[1];
628
+ if (spec) {
629
+ const clause = statement.slice("import".length, statement.search(/\sfrom\s/));
630
+ const def = /^\s*([A-Za-z_$][\w$]*)\s*(?:,|$)/.exec(clause);
631
+ if (def)
632
+ imports[def[1]] = spec;
633
+ const named = /\{([^}]*)\}/.exec(clause);
634
+ for (const part of named?.[1].split(",") ?? []) {
635
+ const name = part.trim().split(/\s+as\s+/).pop()?.trim();
636
+ if (name && /^[A-Za-z_$][\w$]*$/.test(name))
637
+ imports[name] = spec;
638
+ }
639
+ }
640
+ continue;
641
+ }
642
+ // export const Foo = <literal or component>;
643
+ const exportMatch = line.match(/^export\s+const\s+([\w$]+)\s*=\s*(.*)$/);
644
+ if (exportMatch) {
645
+ const name = exportMatch[1];
646
+ const captured = [exportMatch[2]];
197
647
  let depth = 0;
198
648
  let started = false;
199
- while (i < lines.length) {
200
- for (const ch of lines[i]) {
201
- if (ch === "{") {
649
+ let quote = null;
650
+ let j = i;
651
+ while (j < lines.length) {
652
+ const scan = j === i ? exportMatch[2] : lines[j];
653
+ for (let k = 0; k < scan.length; k++) {
654
+ const ch = scan[k];
655
+ if (quote) {
656
+ if (ch === quote && scan[k - 1] !== "\\")
657
+ quote = null;
658
+ continue;
659
+ }
660
+ if (ch === '"' || ch === "'" || ch === "`") {
661
+ quote = ch;
662
+ continue;
663
+ }
664
+ if (ch === "{" || ch === "[" || ch === "(") {
202
665
  depth++;
203
666
  started = true;
204
667
  }
205
- if (ch === "}")
668
+ if (ch === "}" || ch === "]" || ch === ")")
206
669
  depth--;
207
670
  }
208
- i++;
209
- // End when we've seen at least one { and depth returns to 0
671
+ if (j > i)
672
+ captured.push(lines[j]);
673
+ j++;
210
674
  if (started && depth <= 0)
211
675
  break;
212
676
  }
213
- // Also skip trailing semicolons or blank lines
677
+ i = j;
214
678
  while (i < lines.length && (lines[i].trim() === ";" || lines[i].trim() === "")) {
215
679
  i++;
216
680
  }
681
+ const literal = captured.join("\n").trim().replace(/;$/, "").trim();
682
+ if (literal)
683
+ moduleData[name] = literal;
217
684
  continue;
218
685
  }
219
686
  result.push(line);
220
687
  i++;
221
688
  }
222
- return result.join("\n");
689
+ return { source: result.join("\n"), moduleData, imports };
690
+ }
691
+ /**
692
+ * Collapse a block-leading multi-line HTML opening tag onto one line so
693
+ * markdown-it parses it as a single inline-HTML tag. MDX/JSX allows attributes
694
+ * on separate lines (e.g. a styled `<a\n href=…\n className=…\n>text</a>`); the
695
+ * JSX plugin only claims CAPITALISED component tags, so a lowercase multi-line
696
+ * tag falls through to markdown-it, which mangles it — the lone `>` line becomes
697
+ * a blockquote and the attributes become literal text. Single-line lowercase
698
+ * tags already parse correctly, so collapsing the head is a safe, targeted fix.
699
+ * Fence-aware; only triggers when a line STARTS with a lowercase tag opener that
700
+ * does not close on that line (rare in prose).
701
+ */
702
+ function collapseMultilineHtmlOpenTags(source) {
703
+ const lines = source.split("\n");
704
+ const out = [];
705
+ let inFence = false;
706
+ let fenceChar = "";
707
+ for (let i = 0; i < lines.length; i++) {
708
+ const line = lines[i];
709
+ const trimmed = line.trim();
710
+ if (!inFence) {
711
+ const fm = trimmed.match(/^(`{3,}|~{3,})/);
712
+ if (fm) {
713
+ inFence = true;
714
+ fenceChar = fm[1][0];
715
+ out.push(line);
716
+ continue;
717
+ }
718
+ }
719
+ else {
720
+ if (new RegExp(`^${fenceChar === "`" ? "`" : "~"}{3,}\\s*$`).test(trimmed))
721
+ inFence = false;
722
+ out.push(line);
723
+ continue;
724
+ }
725
+ // A line that starts with a lowercase HTML tag opener but does not close it.
726
+ if (/^<[a-z][a-z0-9-]*(\s|$)/.test(trimmed) && firstUnquotedGt(line) === -1) {
727
+ const buf = [line];
728
+ let j = i + 1;
729
+ let closed = false;
730
+ for (; j < lines.length && j - i < 40; j++) {
731
+ buf.push(lines[j]);
732
+ if (firstUnquotedGt(lines[j]) !== -1) {
733
+ closed = true;
734
+ break;
735
+ }
736
+ }
737
+ if (closed) {
738
+ out.push(normalizeJsxAttrs(collapseTagHead(buf.join("\n"))));
739
+ i = j;
740
+ continue;
741
+ }
742
+ }
743
+ out.push(line);
744
+ }
745
+ return out.join("\n");
746
+ }
747
+ /** Index of the first `>` outside single/double quotes on this line, or -1. */
748
+ function firstUnquotedGt(s) {
749
+ let q = "";
750
+ for (let i = 0; i < s.length; i++) {
751
+ const c = s[i];
752
+ if (q) {
753
+ if (c === q)
754
+ q = "";
755
+ continue;
756
+ }
757
+ if (c === '"' || c === "'")
758
+ q = c;
759
+ else if (c === ">")
760
+ return i;
761
+ }
762
+ return -1;
763
+ }
764
+ /** Collapse the opening tag (up to and incl. the first unquoted `>`, tracking
765
+ * quotes across newlines) onto one line; leave content after the tag intact. */
766
+ /**
767
+ * Rewrite JSX-only attributes on a LOWERCASE HTML tag into HTML markdown-it
768
+ * will accept.
769
+ *
770
+ * markdown-it's HTML rules require quoted or bare attribute values, so a JSX
771
+ * expression attribute — `style={{ borderRadius: "12px" }}` — makes the whole
772
+ * tag fail to parse as HTML and it is emitted as ESCAPED TEXT. On ollama that
773
+ * showed a reader a literal `&lt;img src="…" /&gt;` and lost the image: 25
774
+ * occurrences across 17 pages, 5 of them the page's only images.
775
+ *
776
+ * `style={{ … }}` converts (camelCase → kebab-case, the real CSS); any other
777
+ * expression attribute is dropped, because its value is JavaScript and there
778
+ * is nothing faithful to put in its place.
779
+ */
780
+ export function normalizeJsxAttrs(tag, onDrop) {
781
+ return tag.replace(/\s([A-Za-z_:][\w:.-]*)=\{(\{[\s\S]*?\}|[^{}]*)\}/g, (_m, name, expr) => {
782
+ if (name === "style" && expr.startsWith("{")) {
783
+ const css = [...expr.matchAll(/([A-Za-z_][\w]*)\s*:\s*"([^"]*)"|([A-Za-z_][\w]*)\s*:\s*'([^']*)'/g)]
784
+ .map(([, k1, v1, k2, v2]) => {
785
+ const key = (k1 ?? k2).replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
786
+ return `${key}:${v1 ?? v2}`;
787
+ })
788
+ .join(";");
789
+ if (css)
790
+ return ` style="${css}"`;
791
+ }
792
+ onDrop?.(name);
793
+ return "";
794
+ });
795
+ }
796
+ const VOID_HTML = new Set([
797
+ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr",
798
+ ]);
799
+ /**
800
+ * Remove blank lines inside a balanced block-level raw-HTML region.
801
+ *
802
+ * markdown-it ends an html_block at the first BLANK LINE, so a container whose
803
+ * children are separated by blank lines is split: everything after the first
804
+ * child is emitted as separate blocks, and the trailing bare `</div>` closers
805
+ * arrive as their own node. On ollama's integration pages that dropped items
806
+ * 2–6 of a six-item capability grid — ~380 words of real prose — before the
807
+ * exporter could even see them, so no ledger could record it.
808
+ *
809
+ * Only PURE-MARKUP regions are joined (every non-blank line starts with a tag).
810
+ * A region containing markdown keeps its blank lines, because there the blank
811
+ * line is meaningful and markdown-it is right to split.
812
+ */
813
+ function joinBlankLinesInHtmlBlocks(source) {
814
+ const lines = source.split("\n");
815
+ const out = [];
816
+ let inFence = false;
817
+ let fenceChar = "";
818
+ for (let i = 0; i < lines.length; i++) {
819
+ const trimmed = lines[i].trim();
820
+ if (!inFence) {
821
+ const fm = trimmed.match(/^(`{3,}|~{3,})/);
822
+ if (fm) {
823
+ inFence = true;
824
+ fenceChar = fm[1][0];
825
+ out.push(lines[i]);
826
+ continue;
827
+ }
828
+ }
829
+ else {
830
+ if (new RegExp(`^${fenceChar === "`" ? "`" : "~"}{3,}\\s*$`).test(trimmed))
831
+ inFence = false;
832
+ out.push(lines[i]);
833
+ continue;
834
+ }
835
+ if (!/^<[a-z][a-z0-9-]*(\s|>)/.test(trimmed)) {
836
+ out.push(lines[i]);
837
+ continue;
838
+ }
839
+ // Scan to the matching close, tracking depth.
840
+ let depth = 0;
841
+ let end = -1;
842
+ let pure = true;
843
+ for (let j = i; j < lines.length; j++) {
844
+ const t = lines[j].trim();
845
+ if (t !== "" && !/^<\/?[A-Za-z]/.test(t))
846
+ pure = false;
847
+ for (const m of t.matchAll(/<(\/?)([a-z][a-z0-9-]*)([^>]*)>/g)) {
848
+ const [, slash, name, rest] = m;
849
+ if (VOID_HTML.has(name) || rest.trimEnd().endsWith("/"))
850
+ continue;
851
+ depth += slash ? -1 : 1;
852
+ }
853
+ if (depth <= 0) {
854
+ end = j;
855
+ break;
856
+ }
857
+ }
858
+ if (end === -1 || end === i || !pure) {
859
+ out.push(lines[i]);
860
+ continue;
861
+ }
862
+ for (let j = i; j <= end; j++) {
863
+ if (lines[j].trim() !== "")
864
+ out.push(lines[j]);
865
+ }
866
+ i = end;
867
+ }
868
+ return out.join("\n");
869
+ }
870
+ function collapseTagHead(text) {
871
+ let q = "";
872
+ for (let i = 0; i < text.length; i++) {
873
+ const c = text[i];
874
+ if (q) {
875
+ if (c === q)
876
+ q = "";
877
+ continue;
878
+ }
879
+ if (c === '"' || c === "'")
880
+ q = c;
881
+ else if (c === ">") {
882
+ return text.slice(0, i + 1).replace(/\s*\n\s*/g, " ") + text.slice(i + 1);
883
+ }
884
+ }
885
+ return text;
223
886
  }
224
887
  /**
225
888
  * Split MDX source into alternating markdown and component segments.
226
- * Handles nested components, fenced code blocks, and self-closing tags.
889
+ * Handles nested components, fenced code blocks, multi-line tag heads,
890
+ * and self-closing tags. Unknown capitalized tags are captured too —
891
+ * they become `mdx-raw` nodes downstream (never dropped).
227
892
  */
228
- function segmentMdx(source) {
893
+ function segmentMdx(source, ctx) {
229
894
  const segments = [];
230
895
  const lines = source.split("\n");
231
896
  let i = 0;
232
897
  let markdownBuffer = [];
898
+ let inFence = false;
899
+ let fenceChar = "";
233
900
  function flushMarkdown() {
234
901
  const content = markdownBuffer.join("\n").trim();
235
902
  if (content) {
@@ -240,73 +907,94 @@ function segmentMdx(source) {
240
907
  while (i < lines.length) {
241
908
  const line = lines[i];
242
909
  const trimmed = line.trim();
243
- // Check for single-line component: <Tag>content</Tag> on one line
244
- const singleLineMatch = trimmed.match(/^<([A-Z]\w*)(\s[^>]*)?>(.+)<\/\1>$/);
245
- if (singleLineMatch && COMPONENT_MAP[singleLineMatch[1]]) {
246
- flushMarkdown();
247
- const tag = singleLineMatch[1];
248
- const attrStr = singleLineMatch[2] || "";
249
- const children = singleLineMatch[3];
250
- segments.push({
251
- kind: "component",
252
- tag,
253
- attrs: parseAttrs(attrStr),
254
- children,
255
- selfClosing: false,
256
- });
910
+ // Fence tracking — component tags inside code samples are literal text.
911
+ if (!inFence) {
912
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
913
+ if (fenceMatch) {
914
+ inFence = true;
915
+ fenceChar = fenceMatch[1][0];
916
+ markdownBuffer.push(line);
917
+ i++;
918
+ continue;
919
+ }
920
+ }
921
+ else {
922
+ if (trimmed.match(new RegExp(`^${fenceChar === "`" ? "`" : "~"}{3,}\\s*$`))) {
923
+ inFence = false;
924
+ }
925
+ markdownBuffer.push(line);
257
926
  i++;
258
927
  continue;
259
928
  }
260
- // Check for component opening tag at the start of a line
261
- const openMatch = trimmed.match(/^<([A-Z]\w*)(\s[^>]*)?\s*\/?>$/);
262
- if (openMatch) {
263
- const tag = openMatch[1];
264
- const attrStr = openMatch[2] || "";
265
- const isSelfClose = trimmed.endsWith("/>");
266
- if (COMPONENT_MAP[tag]) {
267
- flushMarkdown();
268
- if (isSelfClose) {
929
+ // Component tag at the start of a line? (name may end the line when
930
+ // attributes continue on the next line, e.g. <TypeTable\n type={{…}})
931
+ const tagMatch = trimmed.match(/^<([A-Z][A-Za-z0-9]*)(?=[\s/>]|$)/);
932
+ if (tagMatch) {
933
+ const tag = tagMatch[1];
934
+ // Assemble the tag head — attributes may span multiple lines
935
+ // (e.g. <TypeTable type={{ … }} />). Join lines until the scanner
936
+ // finds the terminating `>` outside quotes/braces.
937
+ const head = scanTagHead(lines, i, tag);
938
+ if (head) {
939
+ const { attrStr, selfClosing, headEndLine, afterHead } = head;
940
+ const isSelfClosing = selfClosing || ctx.selfClosing.has(tag);
941
+ if (isSelfClosing) {
942
+ flushMarkdown();
269
943
  segments.push({
270
944
  kind: "component",
271
945
  tag,
272
946
  attrs: parseAttrs(attrStr),
273
947
  children: "",
948
+ raw: rawSpan(lines, i, headEndLine),
274
949
  selfClosing: true,
950
+ line: i + 1,
275
951
  });
952
+ if (afterHead.trim())
953
+ markdownBuffer.push(afterHead);
954
+ i = headEndLine + 1;
955
+ continue;
956
+ }
957
+ // Content and close on the head's own trailing text?
958
+ const inlineClose = afterHead.match(new RegExp(`^([\\s\\S]*)</${tag}\\s*>\\s*$`));
959
+ if (inlineClose) {
960
+ flushMarkdown();
961
+ segments.push({
962
+ kind: "component",
963
+ tag,
964
+ attrs: parseAttrs(attrStr),
965
+ children: inlineClose[1],
966
+ raw: rawSpan(lines, i, headEndLine),
967
+ selfClosing: false,
968
+ line: i + 1,
969
+ });
970
+ i = headEndLine + 1;
971
+ continue;
972
+ }
973
+ // Collect children until the matching closing tag.
974
+ const collected = collectUntilClose(lines, headEndLine + 1, tag);
975
+ if (!collected.found && !ctx.map[tag]) {
976
+ // Unterminated UNKNOWN tag — likely prose starting with "<Word>"
977
+ // or malformed MDX. Don't swallow the document into one raw
978
+ // blob; let the head line(s) flow through as markdown.
979
+ markdownBuffer.push(line);
276
980
  i++;
277
981
  continue;
278
982
  }
279
- // Collect children until closing tag
280
- const { content, endLine } = collectUntilClose(lines, i + 1, tag);
281
- segments.push({
282
- kind: "component",
283
- tag,
284
- attrs: parseAttrs(attrStr),
285
- children: content,
286
- selfClosing: false,
287
- });
288
- i = endLine + 1;
289
- continue;
290
- }
291
- }
292
- // Also check for opening tags that aren't on their own line (with content after)
293
- const inlineOpenMatch = trimmed.match(/^<([A-Z]\w*)(\s[^>]*)?>$/);
294
- if (inlineOpenMatch && !openMatch) {
295
- const tag = inlineOpenMatch[1];
296
- const attrStr = inlineOpenMatch[2] || "";
297
- if (COMPONENT_MAP[tag]) {
298
983
  flushMarkdown();
299
- const { content, endLine } = collectUntilClose(lines, i + 1, tag);
984
+ const childPrefix = afterHead.trim() ? `${afterHead}\n` : "";
300
985
  segments.push({
301
986
  kind: "component",
302
987
  tag,
303
988
  attrs: parseAttrs(attrStr),
304
- children: content,
989
+ children: childPrefix + dedent(collected.content),
990
+ raw: rawSpan(lines, i, collected.endLine),
305
991
  selfClosing: false,
992
+ line: i + 1,
306
993
  });
307
- i = endLine + 1;
994
+ i = collected.endLine + 1;
308
995
  continue;
309
996
  }
997
+ // No terminated tag head — fall through: plain markdown line.
310
998
  }
311
999
  markdownBuffer.push(line);
312
1000
  i++;
@@ -314,6 +1002,64 @@ function segmentMdx(source) {
314
1002
  flushMarkdown();
315
1003
  return segments;
316
1004
  }
1005
+ function rawSpan(lines, from, to) {
1006
+ return lines.slice(from, Math.min(to + 1, lines.length)).join("\n");
1007
+ }
1008
+ /**
1009
+ * Scan a tag head starting at lines[startLine] for tag `tag`. The head may
1010
+ * span several lines (multi-line attribute objects). Returns null when no
1011
+ * terminating `>` is found within a sane window — the line is then treated
1012
+ * as plain markdown.
1013
+ *
1014
+ * The window exists to stop a stray `<` from swallowing the rest of the
1015
+ * document; it is NOT a statement about how big a real attribute may be.
1016
+ * At 60 lines it was cutting real content in half: better-auth 1.5.6 writes
1017
+ * `<DatabaseTable fields={[…]}/>` with one object per field and one property
1018
+ * per line, so a 12-column table needs ~70 lines. The scanner gave up, the
1019
+ * component fell back to plain text, and the whole table shipped as literal
1020
+ * JSX in the reader — silently, because a head that never closes looks
1021
+ * exactly like prose that happens to start with `<`.
1022
+ *
1023
+ * The bound stays generous rather than absent: a runaway scan is still
1024
+ * bounded, but no plausible hand-written attribute reaches it.
1025
+ */
1026
+ function scanTagHead(lines, startLine, tag) {
1027
+ const MAX_HEAD_LINES = 600;
1028
+ let text = "";
1029
+ for (let j = startLine; j < Math.min(lines.length, startLine + MAX_HEAD_LINES); j++) {
1030
+ text = text ? `${text}\n${lines[j]}` : lines[j].trimStart();
1031
+ // Scan from just past "<Tag" for the terminating ">" at depth 0.
1032
+ const offset = 1 + tag.length;
1033
+ let depth = 0;
1034
+ let quote = null;
1035
+ for (let k = offset; k < text.length; k++) {
1036
+ const ch = text[k];
1037
+ if (quote) {
1038
+ if (ch === quote && text[k - 1] !== "\\")
1039
+ quote = null;
1040
+ continue;
1041
+ }
1042
+ if (ch === '"' || ch === "'" || ch === "`") {
1043
+ quote = ch;
1044
+ continue;
1045
+ }
1046
+ if (ch === "{")
1047
+ depth++;
1048
+ else if (ch === "}")
1049
+ depth--;
1050
+ else if (ch === ">" && depth <= 0) {
1051
+ const selfClosing = text[k - 1] === "/";
1052
+ const attrEnd = selfClosing ? k - 1 : k;
1053
+ const attrStr = text.slice(offset, attrEnd);
1054
+ // The scan buffer ends at line j, so everything past ">" is the
1055
+ // remainder of the final head line.
1056
+ const afterHead = text.slice(k + 1);
1057
+ return { attrStr, selfClosing, headEndLine: j, afterHead };
1058
+ }
1059
+ }
1060
+ }
1061
+ return null;
1062
+ }
317
1063
  /**
318
1064
  * Collect lines until the matching closing tag, handling nesting.
319
1065
  * Respects fenced code blocks (content inside is literal).
@@ -348,7 +1094,7 @@ function collectUntilClose(lines, startLine, tag) {
348
1094
  if (closeMatch) {
349
1095
  depth--;
350
1096
  if (depth === 0) {
351
- return { content: dedent(collected.join("\n")), endLine: i };
1097
+ return { content: collected.join("\n"), endLine: i, found: true };
352
1098
  }
353
1099
  }
354
1100
  const openMatch = trimmed.match(new RegExp(`^<${tag}(\\s[^>]*)?>$`));
@@ -359,7 +1105,7 @@ function collectUntilClose(lines, startLine, tag) {
359
1105
  i++;
360
1106
  }
361
1107
  // If we never found the closing tag, return everything
362
- return { content: dedent(collected.join("\n")), endLine: lines.length - 1 };
1108
+ return { content: collected.join("\n"), endLine: lines.length - 1, found: false };
363
1109
  }
364
1110
  /**
365
1111
  * Remove common leading whitespace from all lines.
@@ -383,99 +1129,362 @@ function dedent(text) {
383
1129
  return lines.map((line) => line.slice(minIndent)).join("\n");
384
1130
  }
385
1131
  // ── Attribute parsing ────────────────────────────────────
386
- function parseAttrs(attrStr) {
1132
+ /**
1133
+ * Parse a JSX attribute string. Supports `key="v"`, `key='v'`,
1134
+ * `key={expr}` (balanced braces — `{{obj}}` keeps the inner `{obj}`),
1135
+ * and bare boolean attributes (`requireSession` → "true").
1136
+ */
1137
+ export function parseAttrs(attrStr) {
387
1138
  const attrs = {};
388
- // Match: key="value" or key={value} or key='value'
389
- const re = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{([^}]*)\})/g;
390
- let match;
391
- while ((match = re.exec(attrStr))) {
392
- attrs[match[1]] = match[2] ?? match[3] ?? match[4] ?? "";
1139
+ const s = attrStr;
1140
+ let i = 0;
1141
+ while (i < s.length) {
1142
+ while (i < s.length && /\s/.test(s[i]))
1143
+ i++;
1144
+ const keyMatch = /^[A-Za-z_][\w-]*/.exec(s.slice(i));
1145
+ if (!keyMatch) {
1146
+ i++;
1147
+ continue;
1148
+ }
1149
+ const key = keyMatch[0];
1150
+ i += key.length;
1151
+ while (i < s.length && /\s/.test(s[i]))
1152
+ i++;
1153
+ if (s[i] !== "=") {
1154
+ attrs[key] = "true"; // bare attribute
1155
+ continue;
1156
+ }
1157
+ i++;
1158
+ while (i < s.length && /\s/.test(s[i]))
1159
+ i++;
1160
+ const ch = s[i];
1161
+ if (ch === '"' || ch === "'") {
1162
+ const end = s.indexOf(ch, i + 1);
1163
+ attrs[key] = end === -1 ? s.slice(i + 1) : s.slice(i + 1, end);
1164
+ i = end === -1 ? s.length : end + 1;
1165
+ }
1166
+ else if (ch === "{") {
1167
+ let depth = 0;
1168
+ let quote = null;
1169
+ let k = i;
1170
+ for (; k < s.length; k++) {
1171
+ const c = s[k];
1172
+ if (quote) {
1173
+ if (c === quote && s[k - 1] !== "\\")
1174
+ quote = null;
1175
+ continue;
1176
+ }
1177
+ if (c === '"' || c === "'" || c === "`") {
1178
+ quote = c;
1179
+ continue;
1180
+ }
1181
+ if (c === "{")
1182
+ depth++;
1183
+ else if (c === "}") {
1184
+ depth--;
1185
+ if (depth === 0)
1186
+ break;
1187
+ }
1188
+ }
1189
+ attrs[key] = s.slice(i + 1, k).trim();
1190
+ i = k + 1;
1191
+ }
1192
+ else {
1193
+ const m = /^\S+/.exec(s.slice(i));
1194
+ attrs[key] = m?.[0] ?? "";
1195
+ i += m?.[0].length ?? 1;
1196
+ }
393
1197
  }
394
1198
  return attrs;
395
1199
  }
396
1200
  // ── Segments → TreeNode[] ────────────────────────────────
397
- function segmentsToTree(segments) {
1201
+ function mdxRawNode(seg) {
1202
+ return { type: "mdx-raw", props: { source: seg.raw, component: seg.tag } };
1203
+ }
1204
+ /**
1205
+ * Resolve `<Snippet />` to the parsed content of the `.mdx` it was imported
1206
+ * from, or null when it cannot be resolved (missing file, cycle, no rootDir).
1207
+ *
1208
+ * A specifier starting with `/` is DOCS-ROOT-relative, which is Mintlify's
1209
+ * rule — treating it as an absolute filesystem path finds nothing. Everything
1210
+ * else resolves against the importing file.
1211
+ */
1212
+ function inlineSnippet(seg, ctx) {
1213
+ const spec = ctx.imports[seg.tag];
1214
+ // The importing file's path may be RELATIVE to the docs root (that is what
1215
+ // the CLI stamps for ledger entries), so resolve it before taking dirname.
1216
+ const fileDir = ctx.file
1217
+ ? isAbsolute(ctx.file)
1218
+ ? dirname(ctx.file)
1219
+ : ctx.rootDir
1220
+ ? resolve(ctx.rootDir, dirname(ctx.file))
1221
+ : undefined
1222
+ : undefined;
1223
+ const base = spec.startsWith("/") ? ctx.rootDir : fileDir;
1224
+ if (!base)
1225
+ return null;
1226
+ const path = resolve(base, spec.replace(/^\//, ""));
1227
+ if (!existsSync(path))
1228
+ return null;
1229
+ if (ctx.inlining.has(path)) {
1230
+ // A snippet that includes itself would recurse forever. Record and stop.
1231
+ ctx.ledger.push({
1232
+ component: `${seg.tag} (import cycle at ${spec})`,
1233
+ action: "stripped",
1234
+ line: seg.line,
1235
+ file: ctx.file,
1236
+ });
1237
+ return null;
1238
+ }
1239
+ if (ctx.inlining.size >= 10)
1240
+ return null;
1241
+ let source;
1242
+ try {
1243
+ source = readFileSync(path, "utf-8");
1244
+ }
1245
+ catch {
1246
+ return null;
1247
+ }
1248
+ // Strip the snippet's own frontmatter — it describes the snippet file, not
1249
+ // the page including it.
1250
+ source = source.replace(/^---\n[\s\S]*?\n---\n/, "");
1251
+ // A snippet whose whole body is `export const X = (props) => (<JSX/>)` is a
1252
+ // parameterised component. Its JSX body is real documentation, and the only
1253
+ // thing standing between it and the reader is `{prop}` interpolation — which
1254
+ // is a STATIC substitution from the attributes at the use site, not code to
1255
+ // execute. dub's webhook parameter block is this shape and appears on 13
1256
+ // pages.
1257
+ const arrow = /^\s*export\s+const\s+[\w$]+\s*=\s*\(([^)]*)\)\s*=>\s*\(([\s\S]*)\)\s*;?\s*$/.exec(source);
1258
+ if (arrow) {
1259
+ const body = substituteProps(arrow[2], seg.attrs, seg.children);
1260
+ if (!body.trim())
1261
+ return null;
1262
+ source = dedentBlock(body);
1263
+ }
1264
+ ctx.inlining.add(path);
1265
+ try {
1266
+ const sub = parseMdx(source, {
1267
+ components: ctx.map,
1268
+ selfClosingTags: [...ctx.selfClosing],
1269
+ file: path,
1270
+ rootDir: ctx.rootDir,
1271
+ inlining: ctx.inlining,
1272
+ });
1273
+ ctx.ledger.push(...sub.ledger);
1274
+ return sub.tree;
1275
+ }
1276
+ finally {
1277
+ ctx.inlining.delete(path);
1278
+ }
1279
+ }
1280
+ /**
1281
+ * Replace `{name}` in a snippet component's JSX body with the value supplied
1282
+ * at the use site. Purely textual: an expression that is not a bare prop name
1283
+ * is left alone, and `{children}` takes the element's own inner source.
1284
+ */
1285
+ function substituteProps(body, attrs, children) {
1286
+ return body.replace(/\{([A-Za-z_$][\w$]*)\}/g, (whole, name) => {
1287
+ if (name === "children")
1288
+ return children;
1289
+ return name in attrs ? attrs[name] : whole;
1290
+ });
1291
+ }
1292
+ /** Remove the common leading indentation of a block. */
1293
+ function dedentBlock(text) {
1294
+ const lines = text.split("\n").filter((l) => l.trim() !== "");
1295
+ const indent = Math.min(...lines.map((l) => l.match(/^[ \t]*/)[0].length), Infinity);
1296
+ return indent > 0 && Number.isFinite(indent)
1297
+ ? text
1298
+ .split("\n")
1299
+ .map((l) => l.slice(indent))
1300
+ .join("\n")
1301
+ : text;
1302
+ }
1303
+ function segmentsToTree(segments, ctx) {
398
1304
  const nodes = [];
399
1305
  for (const seg of segments) {
400
1306
  if (seg.kind === "markdown") {
401
- nodes.push(...markdownToNodes(seg.content));
1307
+ nodes.push(...markdownToNodes(seg.content, ctx));
1308
+ continue;
402
1309
  }
403
- else {
404
- const mapping = COMPONENT_MAP[seg.tag];
405
- if (!mapping || mapping.strip)
1310
+ const mapping = ctx.map[seg.tag];
1311
+ // An unmapped component that was IMPORTED from another .mdx file is a
1312
+ // snippet, not an unknown component. Mintlify's reuse mechanism is exactly
1313
+ // this, and on dub it accounted for ~170 of the 198 "preserved" nodes: the
1314
+ // content was sitting in `snippets/*.mdx` in the same repo the whole time,
1315
+ // while the exporter stripped `<LinkArguments />` down to nothing.
1316
+ if (!mapping && ctx.imports[seg.tag]) {
1317
+ const inlined = inlineSnippet(seg, ctx);
1318
+ if (inlined) {
1319
+ nodes.push(...inlined);
406
1320
  continue;
407
- const node = componentToNode(seg, mapping);
408
- if (node)
409
- nodes.push(node);
1321
+ }
1322
+ }
1323
+ // Unknown component — NEVER drop. Preserve byte-exact + ledger.
1324
+ if (!mapping) {
1325
+ ctx.ledger.push({
1326
+ component: seg.tag,
1327
+ action: "preserved",
1328
+ line: seg.line,
1329
+ file: ctx.file,
1330
+ });
1331
+ nodes.push(mdxRawNode(seg));
1332
+ continue;
1333
+ }
1334
+ if (mapping.strip && !mapping.toNode) {
1335
+ ctx.ledger.push({
1336
+ component: seg.tag,
1337
+ action: "stripped",
1338
+ line: seg.line,
1339
+ file: ctx.file,
1340
+ });
1341
+ continue;
410
1342
  }
1343
+ const node = componentToNode(seg, mapping, ctx);
1344
+ if (Array.isArray(node))
1345
+ nodes.push(...node);
1346
+ else if (node)
1347
+ nodes.push(node);
411
1348
  }
412
1349
  return nodes;
413
1350
  }
414
- function componentToNode(seg, mapping) {
1351
+ function componentToNode(seg, mapping, ctx) {
1352
+ // Function form — full control, with "preserve" fallback.
1353
+ if (mapping.toNode) {
1354
+ const hctx = {
1355
+ parseBlocks: (s) => parseBlocks(s, ctx),
1356
+ ledger: (entry) => ctx.ledger.push({ ...entry, file: ctx.file }),
1357
+ moduleData: ctx.moduleData,
1358
+ };
1359
+ const result = mapping.toNode(seg.attrs, seg.children, hctx);
1360
+ if (result === "preserve") {
1361
+ ctx.ledger.push({
1362
+ component: seg.tag,
1363
+ action: "preserved",
1364
+ line: seg.line,
1365
+ file: ctx.file,
1366
+ });
1367
+ return mdxRawNode(seg);
1368
+ }
1369
+ if (result === null) {
1370
+ ctx.ledger.push({
1371
+ component: seg.tag,
1372
+ action: "stripped",
1373
+ line: seg.line,
1374
+ file: ctx.file,
1375
+ });
1376
+ return null;
1377
+ }
1378
+ return result;
1379
+ }
415
1380
  const props = mapping.propsFromAttrs
416
1381
  ? mapping.propsFromAttrs(seg.attrs)
417
1382
  : {};
418
1383
  // Special handling for tabs container
419
1384
  if (mapping.type === "tabs") {
420
- return tabsToNode(seg);
1385
+ return tabsToNode(seg, ctx);
421
1386
  }
422
1387
  // Special handling for steps container
423
1388
  if (mapping.type === "steps") {
424
- return stepsToNode(seg);
425
- }
426
- // Special handling for accordion-group
427
- if (mapping.type === "accordion-group") {
428
- // Parse children as segments, extract accordion items
429
- const childSegments = segmentMdx(seg.children);
430
- const children = segmentsToTree(childSegments);
431
- return { type: "accordion-group", children };
432
- }
433
- // Special handling for cards container
434
- if (mapping.type === "cards") {
435
- const childSegments = segmentMdx(seg.children);
436
- const children = segmentsToTree(childSegments);
437
- return { type: "cards", children };
438
- }
439
- // For callouts, details, card — parse children as content
440
- const children = seg.selfClosing ? [] : mdxToTree(seg.children);
1389
+ return stepsToNode(seg, ctx);
1390
+ }
1391
+ // Special handling for accordion-group / cards — children are components
1392
+ if (mapping.type === "accordion-group" || mapping.type === "cards") {
1393
+ const children = parseBlocks(seg.children, ctx);
1394
+ return { type: mapping.type, children };
1395
+ }
1396
+ // Children policy (default: parse as nested blocks)
1397
+ const policy = mapping.children ?? "blocks";
1398
+ let children;
1399
+ if (seg.selfClosing || policy === "ignore") {
1400
+ children = [];
1401
+ }
1402
+ else if (policy === "raw") {
1403
+ children = seg.children.trim()
1404
+ ? [{ type: "mdx-raw", props: { source: seg.children, component: seg.tag } }]
1405
+ : [];
1406
+ }
1407
+ else if (policy === "code") {
1408
+ children = codePolicyChildren(seg.children);
1409
+ }
1410
+ else {
1411
+ children = parseBlocks(seg.children, ctx);
1412
+ }
441
1413
  const node = { type: mapping.type };
442
- if (Object.keys(props).length > 0)
443
- node.props = props;
1414
+ const cleanProps = Object.fromEntries(Object.entries(props).filter(([, v]) => v !== undefined));
1415
+ if (Object.keys(cleanProps).length > 0)
1416
+ node.props = cleanProps;
444
1417
  if (children.length > 0)
445
1418
  node.children = children;
446
- // For callout types, if there's a title prop, set it
447
- if (mapping.type === "callout" && props.title) {
448
- node.props = { ...props };
449
- }
450
1419
  return node;
451
1420
  }
1421
+ /**
1422
+ * `children: "code"` policy — keep inner content as a single code node.
1423
+ * A lone inner fence is unwrapped (keeps its language); anything else
1424
+ * becomes a language-less code node verbatim.
1425
+ */
1426
+ function codePolicyChildren(inner) {
1427
+ const trimmed = inner.trim();
1428
+ if (!trimmed)
1429
+ return [];
1430
+ const fenceMatch = trimmed.match(/^```([^\n`]*)\n([\s\S]*?)\n?```$/);
1431
+ if (fenceMatch) {
1432
+ const lang = fenceMatch[1].trim().split(/\s+/)[0] || null;
1433
+ return [{ type: "code", props: { lang, code: fenceMatch[2] } }];
1434
+ }
1435
+ return [{ type: "code", props: { lang: null, code: trimmed } }];
1436
+ }
452
1437
  // ── Tabs handling ────────────────────────────────────────
453
- function tabsToNode(seg) {
1438
+ function tabsToNode(seg, ctx) {
454
1439
  // Parse children to find <Tab> segments
455
- const childSegments = segmentMdx(seg.children);
1440
+ const childSegments = segmentMdx(seg.children, ctx);
456
1441
  const tabs = [];
457
1442
  for (const child of childSegments) {
458
1443
  if (child.kind === "component" && child.tag === "Tab") {
459
- const title = child.attrs.title || child.attrs.label || "";
460
- const content = mdxToTree(child.children);
1444
+ const title = child.attrs.title || child.attrs.label || child.attrs.value || "";
1445
+ const content = parseBlocks(child.children, ctx);
461
1446
  tabs.push({
462
1447
  type: "tab",
463
1448
  props: { title },
464
1449
  children: content,
465
1450
  });
1451
+ continue;
1452
+ }
1453
+ // A <CodeGroup> is mapped to `tabs` but its children are FENCED CODE
1454
+ // BLOCKS, not <Tab>. Discarding non-Tab children emptied 124 of 147 tab
1455
+ // groups on the dub corpus — every code sample in them vanished before the
1456
+ // exporter ever saw it. Each code block becomes a tab, labelled by its
1457
+ // fence title or language.
1458
+ if (child.kind === "markdown") {
1459
+ for (const node of parseBlocks(child.content, ctx)) {
1460
+ if (node.type !== "code") {
1461
+ // Never silently: a paragraph inside a <CodeGroup> is rare but real,
1462
+ // and dropping it with no record is what this package forbids.
1463
+ ctx.ledger.push({
1464
+ component: `${seg.tag} (non-code ${node.type} child)`,
1465
+ action: "stripped",
1466
+ line: 0,
1467
+ });
1468
+ continue;
1469
+ }
1470
+ const label = node.props?.tab ||
1471
+ node.props?.title ||
1472
+ node.props?.lang ||
1473
+ `Tab ${tabs.length + 1}`;
1474
+ tabs.push({ type: "tab", props: { title: label }, children: [node] });
1475
+ }
466
1476
  }
467
- // Ignore non-Tab children inside Tabs
468
1477
  }
469
1478
  return { type: "tabs", children: tabs };
470
1479
  }
471
1480
  // ── Steps handling ───────────────────────────────────────
472
- function stepsToNode(seg) {
473
- const childSegments = segmentMdx(seg.children);
1481
+ function stepsToNode(seg, ctx) {
1482
+ const childSegments = segmentMdx(seg.children, ctx);
474
1483
  const steps = [];
475
1484
  for (const child of childSegments) {
476
1485
  if (child.kind === "component" && child.tag === "Step") {
477
1486
  const title = child.attrs.title || "";
478
- const content = mdxToTree(child.children);
1487
+ const content = parseBlocks(child.children, ctx);
479
1488
  // Prepend title as bold text if present
480
1489
  const children = [];
481
1490
  if (title) {
@@ -497,11 +1506,11 @@ function stepsToNode(seg) {
497
1506
  *
498
1507
  * Uses markdown-it for tokenization, then walks tokens to build TreeNode[].
499
1508
  */
500
- function markdownToNodes(content) {
1509
+ function markdownToNodes(content, ctx) {
501
1510
  const tokens = md.parse(content, {});
502
- return tokensToTree(tokens);
1511
+ return tokensToTree(tokens, ctx);
503
1512
  }
504
- function tokensToTree(tokens) {
1513
+ function tokensToTree(tokens, ctx) {
505
1514
  const nodes = [];
506
1515
  let i = 0;
507
1516
  while (i < tokens.length) {
@@ -524,7 +1533,7 @@ function tokensToTree(tokens) {
524
1533
  i++;
525
1534
  }
526
1535
  i++; // skip close token
527
- const node = openTokenToNode(token, children);
1536
+ const node = openTokenToNode(token, children, ctx);
528
1537
  if (node)
529
1538
  nodes.push(node);
530
1539
  continue;
@@ -532,12 +1541,54 @@ function tokensToTree(tokens) {
532
1541
  // Handle flat (self-closing) tokens
533
1542
  switch (token.type) {
534
1543
  case "fence": {
535
- const lang = token.info?.split(/\s+/)[0] || "";
1544
+ const info = token.info?.trim() ?? "";
1545
+ const lang = info.split(/\s+/)[0] || "";
536
1546
  const code = token.content.replace(/\n$/, "");
537
- nodes.push({
538
- type: "code",
539
- props: { lang: lang || null, code },
540
- });
1547
+ const props = { lang: lang || null, code };
1548
+ // Info-string metadata: title="auth.ts" (Fumadocs/Docusaurus/
1549
+ // Expressive Code convention) — dropping it loses real content.
1550
+ // fumadocs' `remarkCodeTab`: `tab="Before"` is the TAB LABEL, and it
1551
+ // is independent of `title=` (which is the filename) — a real fence
1552
+ // carries both. 354 fences across the fumadocs corpus use it, and
1553
+ // without reading it every tab was labelled with its language.
1554
+ const tabMatch = info.match(/\btab=(?:["']([^"']*)["']|(\S+))/);
1555
+ if (tabMatch)
1556
+ props.tab = tabMatch[1] ?? tabMatch[2];
1557
+ const titleMatch = info.match(/title=["']([^"']*)["']/);
1558
+ if (titleMatch) {
1559
+ props.title = titleMatch[1];
1560
+ }
1561
+ else {
1562
+ // Mintlify's convention is a BARE second token — ```bash npm — and
1563
+ // it is what labels the tab inside a <CodeGroup>. Without it every
1564
+ // tab of a four-manager install block was labelled "bash", which is
1565
+ // both useless to a reader and a hard build error in Docusaurus
1566
+ // (Tabs requires unique values). Only a plain filename/word token
1567
+ // qualifies: highlight ranges ({1,3}) and flags (showLineNumbers,
1568
+ // key=value) are meta, not titles.
1569
+ // SUBTRACT the known meta, then take what is left — do not require
1570
+ // the whole remainder to look like a title. Requiring that rejected
1571
+ // every real title that travels with meta, which on Mintlify's own
1572
+ // export (`theme={null}` on all 1338 fences) was ALL of them, plus
1573
+ // `.env`, `iOS (SwiftUI)`, `示例`, and `cURL {5}`. It also promoted
1574
+ // bare flags (`wrap`, `twoslash`) to titles, which then rendered as
1575
+ // a wrong label in the code-block title bar.
1576
+ let rest = info.slice(lang.length);
1577
+ rest = rest
1578
+ // key=value FIRST — otherwise the range strip below eats the
1579
+ // `{null}` out of `theme={null}` and leaves a bare `theme=`.
1580
+ .replace(/[A-Za-z_][\w-]*=(?:"[^"]*"|'[^']*'|\{[^}]*\}|\S+)/g, " ")
1581
+ .replace(/\{[^}]*\}/g, " ") // highlight ranges: {1,3-5}
1582
+ .replace(
1583
+ // Known bare flags. An unknown bare word is treated as a title:
1584
+ // a wrong title is visible and fixable, a dropped one is not.
1585
+ /(?:^|\s)(?:showLineNumbers|noLineNumbers|no-line-numbers|lineNumbers|twoslash|ts2js|noCopy|nocopy|copy|wrap|nowrap|expandable|collapsible|live|editable|frame)(?=\s|$)/g, " ")
1586
+ .replace(/\s+/g, " ")
1587
+ .trim();
1588
+ if (rest)
1589
+ props.title = rest;
1590
+ }
1591
+ nodes.push({ type: "code", props });
541
1592
  i++;
542
1593
  break;
543
1594
  }
@@ -555,7 +1606,26 @@ function tokensToTree(tokens) {
555
1606
  }
556
1607
  case "html_block": {
557
1608
  const trimmed = token.content.trim();
558
- if (!trimmed.match(/^<[A-Z]/)) {
1609
+ const jsxMatch = trimmed.match(/^<([A-Z][A-Za-z0-9]*)/);
1610
+ if (jsxMatch) {
1611
+ // JSX that slipped past segmentation (multi-line heads the
1612
+ // segmenter's window missed, odd formatting). Never drop —
1613
+ // preserve byte-exact + ledger.
1614
+ ctx.ledger.push({
1615
+ component: jsxMatch[1],
1616
+ action: "preserved",
1617
+ line: (token.map?.[0] ?? 0) + 1,
1618
+ file: ctx.file,
1619
+ });
1620
+ nodes.push({
1621
+ type: "mdx-raw",
1622
+ props: {
1623
+ source: token.content.replace(/\n$/, ""),
1624
+ component: jsxMatch[1],
1625
+ },
1626
+ });
1627
+ }
1628
+ else {
559
1629
  nodes.push({ type: "html", html: token.content });
560
1630
  }
561
1631
  i++;
@@ -571,8 +1641,8 @@ function tokensToTree(tokens) {
571
1641
  /**
572
1642
  * Map an opening token (nesting=1) + its collected children to a TreeNode.
573
1643
  */
574
- function openTokenToNode(token, childTokens) {
575
- const children = tokensToTree(childTokens);
1644
+ function openTokenToNode(token, childTokens, ctx) {
1645
+ const children = tokensToTree(childTokens, ctx);
576
1646
  switch (token.type) {
577
1647
  case "heading_open": {
578
1648
  const level = parseInt(token.tag.slice(1), 10);
@@ -590,8 +1660,17 @@ function openTokenToNode(token, childTokens) {
590
1660
  return { type: "paragraph", children };
591
1661
  case "blockquote_open":
592
1662
  return { type: "blockquote", children };
593
- case "ordered_list_open":
594
- return { type: "ordered-list", children };
1663
+ case "ordered_list_open": {
1664
+ // Preserve the list's start number. markdown-it sets a `start` attr only
1665
+ // when the first item is not 1 — which happens when interspersed block
1666
+ // content (an image div, a callout) splits a `1. 2. 3.` list into several
1667
+ // lists starting at 2, 3, … Dropping it renumbered every fragment from 1.
1668
+ const startAttr = token.attrGet?.("start");
1669
+ const start = startAttr != null ? parseInt(startAttr, 10) : undefined;
1670
+ return start != null && !Number.isNaN(start)
1671
+ ? { type: "ordered-list", props: { start }, children }
1672
+ : { type: "ordered-list", children };
1673
+ }
595
1674
  case "bullet_list_open":
596
1675
  return { type: "unordered-list", children };
597
1676
  case "list_item_open":