@dogsbay/format-mdx 0.2.0-beta.10 → 0.2.0-beta.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter.d.ts +159 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +95 -0
- package/dist/adapter.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +269 -19
- package/dist/cli.js.map +1 -1
- package/dist/extract.d.ts +32 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +152 -0
- package/dist/extract.js.map +1 -0
- package/dist/fumadocs.d.ts +48 -0
- package/dist/fumadocs.d.ts.map +1 -0
- package/dist/fumadocs.js +470 -0
- package/dist/fumadocs.js.map +1 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/js-scan.d.ts +35 -0
- package/dist/js-scan.d.ts.map +1 -0
- package/dist/js-scan.js +124 -0
- package/dist/js-scan.js.map +1 -0
- package/dist/meta-nav.d.ts +29 -0
- package/dist/meta-nav.d.ts.map +1 -0
- package/dist/meta-nav.js +194 -0
- package/dist/meta-nav.js.map +1 -0
- package/dist/mintlify-nav.d.ts +31 -0
- package/dist/mintlify-nav.d.ts.map +1 -0
- package/dist/mintlify-nav.js +418 -0
- package/dist/mintlify-nav.js.map +1 -0
- package/dist/mintlify-openapi.d.ts +107 -0
- package/dist/mintlify-openapi.d.ts.map +1 -0
- package/dist/mintlify-openapi.js +357 -0
- package/dist/mintlify-openapi.js.map +1 -0
- package/dist/mintlify.d.ts +3 -0
- package/dist/mintlify.d.ts.map +1 -0
- package/dist/mintlify.js +196 -0
- package/dist/mintlify.js.map +1 -0
- package/dist/nav-extract.d.ts +43 -0
- package/dist/nav-extract.d.ts.map +1 -0
- package/dist/nav-extract.js +135 -0
- package/dist/nav-extract.js.map +1 -0
- package/dist/parse.d.ts +100 -4
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +1228 -149
- package/dist/parse.js.map +1 -1
- package/dist/ts-type-block.d.ts +43 -0
- package/dist/ts-type-block.d.ts.map +1 -0
- package/dist/ts-type-block.js +164 -0
- package/dist/ts-type-block.js.map +1 -0
- 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
|
|
12
|
-
* string attributes — no complex JSX expressions. This means a
|
|
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) => ({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
671
|
+
if (j > i)
|
|
672
|
+
captured.push(lines[j]);
|
|
673
|
+
j++;
|
|
210
674
|
if (started && depth <= 0)
|
|
211
675
|
break;
|
|
212
676
|
}
|
|
213
|
-
|
|
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 `<img src="…" />` 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,
|
|
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
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
1544
|
+
const info = token.info?.trim() ?? "";
|
|
1545
|
+
const lang = info.split(/\s+/)[0] || "";
|
|
536
1546
|
const code = token.content.replace(/\n$/, "");
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|