@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.
- 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 +472 -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 +1231 -150
- 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,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
|
|
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";
|
|
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) => ({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
673
|
+
if (j > i)
|
|
674
|
+
captured.push(lines[j]);
|
|
675
|
+
j++;
|
|
210
676
|
if (started && depth <= 0)
|
|
211
677
|
break;
|
|
212
678
|
}
|
|
213
|
-
|
|
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 `<img src="…" />` 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,
|
|
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
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
1546
|
+
const info = token.info?.trim() ?? "";
|
|
1547
|
+
const lang = info.split(/\s+/)[0] || "";
|
|
536
1548
|
const code = token.content.replace(/\n$/, "");
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|