@dogsbay/format-mdx 0.2.0-beta.12 → 0.2.0-beta.120

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