mintlify-convert 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1075 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ MDX to Mintlify format converter.
4
+
5
+ Converts plain MDX/Markdown into Mintlify components (Steps, Callouts, Cards,
6
+ Accordions, Tabs, Banners, Frames) while leaving fenced code untouched, merging
7
+ existing frontmatter and producing output that converts to itself.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import posixpath
13
+ import re
14
+ import shutil
15
+ from pathlib import Path
16
+ from typing import Dict, Iterator, List, Optional, Tuple
17
+
18
+ # Inputs larger than this are rejected before any regex work (1 MiB).
19
+ MAX_INPUT_BYTES = 1024 * 1024
20
+
21
+ IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"})
22
+
23
+ # Placeholders for masked code. Private-use characters keep them out of prose.
24
+ _TOKEN_OPEN = ""
25
+ _TOKEN_CLOSE = ""
26
+ _TOKEN_RE = re.compile(_TOKEN_OPEN + r"([FC])(\d+)" + _TOKEN_CLOSE)
27
+ _FENCE_OPEN_RE = re.compile(r"^([ \t]*)(`{3,}|~{3,})(.*)$")
28
+ _INLINE_CODE_RE = re.compile(r"(?<!`)(`+)(?!`)([^\n]{1,500}?)(?<!`)\1(?!`)")
29
+
30
+ _SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:")
31
+ _HEADING_RE = re.compile(r"^#{1,6}(?:[ \t]|$)")
32
+ _H1_RE = re.compile(r"^#[ \t]+(.+?)[ \t]*$", re.MULTILINE)
33
+ _NON_PROSE_RE = re.compile(
34
+ r"^\s*(?:#{1,6}(?:\s|$)|[-*+]\s|\d+[.)]\s|>|<|\||import\s|export\s|"
35
+ r"-{3,}\s*$|\*{3,}\s*$|_{3,}\s*$|:::|!\[|\[\^|" + _TOKEN_OPEN + r"F)"
36
+ )
37
+ _STEP_PREFIX_RE = re.compile(
38
+ r"^(?:(?:First|Next|Then|Finally)\b|Step\s+\d+\b)[ \t]*[:,.\-–—][ \t]*",
39
+ re.IGNORECASE,
40
+ )
41
+ # Lines a prose block (banner or help card text) must not run into: a JSX/HTML tag, a
42
+ # heading or a masked code block.
43
+ _TAG_LINE = r"[ \t]*(?:</?[A-Za-z]|#{1,6}\s|" + _TOKEN_OPEN + "F)"
44
+ # Component tags that tab content must stop at (unfenced JSX in tab code is still allowed).
45
+ _COMPONENT_LINE_RE = re.compile(
46
+ r"^\s*</?(?:Steps?|Tabs?|Cards?|Accordion|Frame|Banner|Warning|Note|Tip|Info|Check)\b"
47
+ )
48
+ _BLOCK_START_RE = re.compile(r"#{1,6}(?:\s|$)|>|<|\||-{3,}\s*$|\*{3,}\s*$|_{3,}\s*$")
49
+ _NUMBERED_RE = re.compile(r"^[ \t]*(\d{1,9})\.[ \t]+(\S.*)$")
50
+
51
+
52
+ class _NumberedItem:
53
+ """Numbered list item matcher; an item that opens with a code fence is not a step.
54
+
55
+ "1. ```js" would turn into an indented fence opener inside the Step on output and change
56
+ how the page masks code on a second conversion.
57
+ """
58
+
59
+ @staticmethod
60
+ def match(line: str):
61
+ m = _NUMBERED_RE.match(line)
62
+ if m and _FENCE_OPEN_RE.match(m.group(2)):
63
+ return None
64
+ return m
65
+
66
+
67
+ _VOID_TAGS = frozenset(
68
+ {
69
+ "br",
70
+ "hr",
71
+ "img",
72
+ "input",
73
+ "meta",
74
+ "link",
75
+ "source",
76
+ "area",
77
+ "base",
78
+ "col",
79
+ "embed",
80
+ "wbr",
81
+ "track",
82
+ "param",
83
+ }
84
+ )
85
+ _LANGS = (
86
+ r"JavaScript|Python|TypeScript|Ruby|Go|Java|PHP|Curl|Node\.js|React|"
87
+ r"Vue|Angular|Bash|Shell|PowerShell"
88
+ )
89
+ _TAB_LABEL_RE = re.compile(rf"^({_LANGS}):\s*$", re.IGNORECASE)
90
+
91
+
92
+ # --- bounded link and image scanners ---------------------------------------
93
+ # These replace the equivalent regexes: a regex retries every "[" and rescans up to
94
+ # 2,500 characters each time, which is quadratic on adversarial input. Anchoring on
95
+ # "](" with str.find keeps every step bounded and fast.
96
+
97
+ _TEXT_MAX = 500
98
+ _TARGET_MAX = 2000
99
+ _IMG_ATTRS_MAX = 2000
100
+
101
+
102
+ def _scan_links(text: str, images: bool) -> Iterator[Tuple[int, int, str, str]]:
103
+ """Yield (start, end, text, target) for [text](target), or ![alt](target) if images.
104
+
105
+ Same rules as the former regexes: text has no "]" or newline (1-500 chars, 0-500 for
106
+ images), the target has no ")" or newline (1-2000 chars), and plain links are not
107
+ preceded by "!".
108
+ """
109
+ floor = 0
110
+ pos = 0
111
+ while True:
112
+ idx = text.find("](", pos)
113
+ if idx == -1:
114
+ return
115
+ pos = idx + 1
116
+ lo = max(floor, idx - _TEXT_MAX - 2)
117
+ window = text[lo:idx]
118
+ cut = max(window.rfind("]"), window.rfind("\n")) + 1
119
+ start = -1
120
+ while True:
121
+ cand = window.find("[", cut)
122
+ if cand == -1:
123
+ break
124
+ bang = (lo + cand - 1 >= floor) and text[lo + cand - 1 : lo + cand] == "!"
125
+ if bang == images:
126
+ start = lo + cand - (1 if images else 0)
127
+ text_start = lo + cand + 1
128
+ break
129
+ cut = cand + 1
130
+ if start == -1:
131
+ continue
132
+ label = text[text_start:idx]
133
+ if len(label) > _TEXT_MAX or (not images and not label):
134
+ continue
135
+ end = text.find(")", idx + 2, idx + 2 + _TARGET_MAX + 1)
136
+ if end == -1:
137
+ continue
138
+ target = text[idx + 2 : end]
139
+ if not target or len(target) > _TARGET_MAX or "\n" in target:
140
+ continue
141
+ yield start, end + 1, label, target
142
+ floor = pos = end + 1
143
+
144
+
145
+ def _scan_img_tags(text: str) -> Iterator[Tuple[int, int, str]]:
146
+ """Yield (start, end, attrs) for <img ...> tags on a single line."""
147
+ pos = 0
148
+ while True:
149
+ i = text.find("<img", pos)
150
+ if i == -1:
151
+ return
152
+ pos = i + 4
153
+ if text[pos : pos + 1] and (text[pos].isalnum() or text[pos] == "_"):
154
+ continue
155
+ end = text.find(">", pos, pos + _IMG_ATTRS_MAX + 2)
156
+ if end == -1:
157
+ continue
158
+ raw = text[pos:end]
159
+ if not raw or len(raw) > _IMG_ATTRS_MAX or "\n" in raw:
160
+ continue
161
+ yield i, end + 1, raw[:-1] if raw.endswith("/") and len(raw) > 1 else raw
162
+ pos = end + 1
163
+
164
+
165
+ def _sub_links(text: str, replace) -> str:
166
+ """Replace each [text](target) with replace(start, text, target) (None keeps it)."""
167
+ out: List[str] = []
168
+ last = 0
169
+ for start, end, label, target in _scan_links(text, images=False):
170
+ new = replace(start, label, target)
171
+ if new is None:
172
+ continue
173
+ out.append(text[last:start])
174
+ out.append(new)
175
+ last = end
176
+ if not out:
177
+ return text
178
+ out.append(text[last:])
179
+ return "".join(out)
180
+
181
+
182
+ def _strip_links(text: str) -> str:
183
+ """Reduce [text](url) to text."""
184
+ return _sub_links(text, lambda _start, label, _target: label)
185
+
186
+
187
+ # --- code masking -----------------------------------------------------------
188
+
189
+
190
+ class _Store(list):
191
+ """Masked code snippets; .tail holds an unclosed fence that runs to the end of the text."""
192
+
193
+ tail = ""
194
+
195
+
196
+ def _mask_code(text: str) -> Tuple[str, "_Store"]:
197
+ """Replace fenced blocks and inline code spans with single-line placeholders.
198
+
199
+ An unclosed fence swallows the rest of the text. It is not masked inline: it is returned
200
+ in store.tail so no component can wrap it (its closing tag would land inside the fence).
201
+ """
202
+ lines = text.split("\n")
203
+ store = _Store()
204
+ out: List[str] = []
205
+ i = 0
206
+ n = len(lines)
207
+ while i < n:
208
+ m = _FENCE_OPEN_RE.match(lines[i])
209
+ if m and not (m.group(2)[0] == "`" and "`" in m.group(3)):
210
+ char, size = m.group(2)[0], len(m.group(2))
211
+ close = re.compile(r"^[ \t]*" + re.escape(char) + "{%d,}[ \t]*$" % size)
212
+ j = i + 1
213
+ while j < n and not close.match(lines[j]):
214
+ j += 1
215
+ if j >= n:
216
+ store.tail = ("\n" if out else "") + "\n".join(lines[i:])
217
+ break
218
+ end = j
219
+ block = "\n".join(lines[i : end + 1])[len(m.group(1)) :]
220
+ out.append(f"{m.group(1)}{_TOKEN_OPEN}F{len(store)}{_TOKEN_CLOSE}")
221
+ store.append(block)
222
+ i = end + 1
223
+ continue
224
+ out.append(lines[i])
225
+ i += 1
226
+
227
+ def inline(match):
228
+ store.append(match.group(0))
229
+ return f"{_TOKEN_OPEN}C{len(store) - 1}{_TOKEN_CLOSE}"
230
+
231
+ return _INLINE_CODE_RE.sub(inline, "\n".join(out)), store
232
+
233
+
234
+ def _restore_all(text: str, store: "_Store") -> str:
235
+ """Restore a whole document: placeholders plus any unclosed trailing fence."""
236
+ return _restore_code(text, store) + store.tail
237
+
238
+
239
+ def _restore_code(text: str, store: List[str]) -> str:
240
+ if not store:
241
+ return text
242
+ return _TOKEN_RE.sub(
243
+ lambda m: store[int(m.group(2))] if int(m.group(2)) < len(store) else m.group(0), text
244
+ )
245
+
246
+
247
+ def _fence_safe(method):
248
+ """Run a text-transform method on content with code masked, then restore it.
249
+
250
+ The mask store is exposed as self._code_store while the method runs, so helpers can
251
+ read the original text behind a placeholder (for plain-text step titles).
252
+ """
253
+
254
+ def wrapper(self, content, *args, **kwargs):
255
+ masked, store = _mask_code(content)
256
+ previous = getattr(self, "_code_store", [])
257
+ self._code_store = store
258
+ try:
259
+ result = method(self, masked, *args, **kwargs)
260
+ finally:
261
+ self._code_store = previous
262
+ return _restore_all(result, store)
263
+
264
+ wrapper.__name__ = method.__name__
265
+ wrapper.__doc__ = method.__doc__
266
+ return wrapper
267
+
268
+
269
+ # --- frontmatter ------------------------------------------------------------
270
+
271
+ _FM_KEY_RE = re.compile(r"^([A-Za-z_][\w-]*)\s*:(.*)$")
272
+
273
+
274
+ def _split_frontmatter(text: str) -> Tuple[Optional[List[str]], str]:
275
+ """Return (frontmatter lines or None, body)."""
276
+ if not text.startswith("---\n"):
277
+ return None, text
278
+ lines = text.split("\n")
279
+ for idx in range(1, min(len(lines), 2000)):
280
+ if lines[idx].rstrip() in ("---", "..."):
281
+ inner = lines[1:idx]
282
+ if not any(_FM_KEY_RE.match(ln) for ln in inner):
283
+ return None, text
284
+ if not all(not ln.strip() or ln[0] in " \t#-" or _FM_KEY_RE.match(ln) for ln in inner):
285
+ return None, text
286
+ return inner, "\n".join(lines[idx + 1 :])
287
+ return None, text
288
+
289
+
290
+ def _fm_get(lines: List[str], key: str) -> Optional[str]:
291
+ """Parsed scalar value for a top-level key, or None when the key is absent."""
292
+ for ln in lines:
293
+ m = _FM_KEY_RE.match(ln)
294
+ if m and m.group(1) == key:
295
+ raw = m.group(2).strip()
296
+ if raw.startswith('"'):
297
+ try:
298
+ return json.loads(raw)
299
+ except ValueError:
300
+ return raw.strip('"')
301
+ if raw.startswith("'") and raw.endswith("'") and len(raw) >= 2:
302
+ return raw[1:-1].replace("''", "'")
303
+ return raw
304
+ return None
305
+
306
+
307
+ def _normalize(text: str) -> str:
308
+ # U+E000/U+E001 are the code-mask placeholder delimiters; drop them from input so user
309
+ # text can never be mistaken for (or forge) a placeholder.
310
+ text = text.lstrip("").replace("\r\n", "\n").replace("\r", "\n")
311
+ return text.replace(_TOKEN_OPEN, "").replace(_TOKEN_CLOSE, "")
312
+
313
+
314
+ def _attr(value: str) -> str:
315
+ return value.replace('"', "&quot;")
316
+
317
+
318
+ class MintlifyConverter:
319
+ def __init__(
320
+ self,
321
+ output_dir: Optional[Path] = None,
322
+ download_assets: bool = False,
323
+ input_root: Optional[Path] = None,
324
+ ):
325
+ self.output_dir = output_dir
326
+ self.download_assets = download_assets
327
+ self.input_root = input_root
328
+ self.assets_copied: Dict[Path, Path] = {}
329
+ self.validation_errors: List[str] = []
330
+ self.warnings: List[str] = []
331
+
332
+ # --- extraction ---------------------------------------------------------
333
+
334
+ def extract_title(self, content: str) -> str:
335
+ """Frontmatter title if present, else the first H1 outside code, else 'Untitled'."""
336
+ content = _normalize(content)
337
+ fm, body = _split_frontmatter(content)
338
+ if fm is not None:
339
+ title = _fm_get(fm, "title")
340
+ if title:
341
+ return title
342
+ masked, store = _mask_code(body)
343
+ match = _H1_RE.search(masked)
344
+ if match:
345
+ title = re.sub(r"\s+#+$", "", match.group(1)).strip()
346
+ if title:
347
+ return _restore_code(title, store)
348
+ return "Untitled"
349
+
350
+ def extract_description(self, content: str) -> str:
351
+ """First prose paragraph (skipping headings, lists, quotes, code and JSX)."""
352
+ content = _normalize(content)
353
+ fm, body = _split_frontmatter(content)
354
+ if fm is not None:
355
+ existing = _fm_get(fm, "description")
356
+ if existing:
357
+ return existing
358
+ masked, store = _mask_code(body)
359
+ for block in re.split(r"\n[ \t]*\n", masked.strip("\n")):
360
+ # Headings directly above prose do not hide it. The title heading is removed before
361
+ # a second conversion, so deciding on the prose alone keeps both passes identical.
362
+ block_lines = block.split("\n")
363
+ skip = 0
364
+ while skip < len(block_lines) and (
365
+ not block_lines[skip].strip() or _HEADING_RE.match(block_lines[skip])
366
+ ):
367
+ skip += 1
368
+ block_lines = block_lines[skip:]
369
+ block = "\n".join(block_lines)
370
+ first = block_lines[0] if block_lines else ""
371
+ if (
372
+ not block.strip()
373
+ or _NON_PROSE_RE.match(first)
374
+ or _TAB_LABEL_RE.match(first.strip())
375
+ ):
376
+ continue
377
+ prose = []
378
+ for line in block.split("\n"):
379
+ if _NON_PROSE_RE.match(line) or _TAB_LABEL_RE.match(line.strip()):
380
+ break
381
+ prose.append(line.strip())
382
+ desc = _strip_links(" ".join(prose))
383
+ desc = re.sub(r"\*\*([^*\n]{1,500})\*\*", r"\1", desc)
384
+ desc = re.sub(r"\s+", " ", desc).strip()
385
+ if not desc:
386
+ continue
387
+ if len(desc) > 200:
388
+ desc = desc[:200].rstrip() + "..."
389
+ return _restore_code(desc, store)
390
+ return ""
391
+
392
+ # --- links --------------------------------------------------------------
393
+
394
+ @_fence_safe
395
+ def convert_api_links(self, content: str) -> str:
396
+ """Convert API reference links to Mintlify format."""
397
+
398
+ def replace(_start, label, target):
399
+ m = re.fullmatch(r"/api/#operation/([^)\s]{1,500})", target)
400
+ return f"[{label}](/api-reference/{m.group(1)})" if m else None
401
+
402
+ return _sub_links(content, replace)
403
+
404
+ def _rewrite_link_target(self, target: str, current_file: Optional[Path]) -> str:
405
+ if (
406
+ not target
407
+ or target.startswith("#")
408
+ or target.startswith("//")
409
+ or _SCHEME_RE.match(target)
410
+ ):
411
+ return target
412
+ m = re.search(r"[#?]", target)
413
+ path, rest = (target[: m.start()], target[m.start() :]) if m else (target, "")
414
+ if path.endswith((".md", ".mdx")):
415
+ path = path.rsplit(".", 1)[0]
416
+ if current_file is not None and not path.startswith("/"):
417
+ base = ""
418
+ if self.input_root is not None:
419
+ try:
420
+ rel = Path(os.path.abspath(current_file)).parent.relative_to(
421
+ Path(os.path.abspath(self.input_root))
422
+ )
423
+ base = rel.as_posix() if rel.as_posix() != "." else ""
424
+ except ValueError:
425
+ pass
426
+ norm = posixpath.normpath(posixpath.join(base, path))
427
+ if norm == ".." or norm.startswith("../"):
428
+ # Resolves outside the docs root: a root link would be dead, so keep it as written.
429
+ return target
430
+ path = "/" if norm == "." else "/" + norm
431
+ return path + rest
432
+
433
+ @_fence_safe
434
+ def convert_relative_links(self, content: str, current_file: Optional[Path] = None) -> str:
435
+ """Convert relative links to Mintlify paths (images are never touched)."""
436
+
437
+ def replace_link(_start, text, raw):
438
+ if text.startswith("!["):
439
+ return None
440
+ parts = re.match(r'^(\S+)(\s+".*")?$', raw.strip())
441
+ if not parts or parts.group(1).startswith("<"):
442
+ return None
443
+ target = self._rewrite_link_target(parts.group(1), current_file)
444
+ return f'[{text}]({target}{parts.group(2) or ""})'
445
+
446
+ return _sub_links(content, replace_link)
447
+
448
+ # --- images -------------------------------------------------------------
449
+
450
+ def _copy_asset(self, src: str, source_file: Path) -> Optional[str]:
451
+ """Copy a local image into <output>/images and return its URL, or None."""
452
+ if not self.download_assets or self.output_dir is None:
453
+ return None
454
+ if (
455
+ src.startswith(("/", "\\", "//"))
456
+ or _SCHEME_RE.match(src)
457
+ or re.match(r"^[A-Za-z]:", src)
458
+ or "\x00" in src
459
+ ):
460
+ self.warnings.append(f"Skipped image with non-local path: {src}")
461
+ return None
462
+ if Path(src.split("#")[0].split("?")[0]).suffix.lower() not in IMAGE_EXTENSIONS:
463
+ self.warnings.append(f"Skipped image with unsupported extension: {src}")
464
+ return None
465
+ root = Path(
466
+ self.input_root if self.input_root is not None else source_file.parent
467
+ ).resolve()
468
+ candidate = Path(
469
+ os.path.normpath(source_file.parent.resolve() / src.split("#")[0].split("?")[0])
470
+ )
471
+ try:
472
+ resolved = candidate.resolve()
473
+ inside = resolved.is_relative_to(root)
474
+ except (OSError, RuntimeError):
475
+ return None
476
+ if not inside:
477
+ self.warnings.append(f"Skipped image outside the input directory: {src}")
478
+ return None
479
+ if resolved != candidate or candidate.is_symlink():
480
+ self.warnings.append(f"Skipped symlinked image: {src}")
481
+ return None
482
+ if not candidate.is_file():
483
+ self.warnings.append(f"Image not found: {src}")
484
+ return None
485
+
486
+ if candidate in self.assets_copied:
487
+ return f"/images/{self.assets_copied[candidate].name}"
488
+ assets_dir = Path(self.output_dir) / "images"
489
+ if assets_dir.is_symlink():
490
+ self.warnings.append("Skipped image copy: output images directory is a symlink")
491
+ return None
492
+ name = re.sub(r"[^\w.\-]", "_", candidate.name)
493
+ stem, ext = os.path.splitext(name)
494
+ dest = assets_dir / name
495
+ counter = 1
496
+ while dest in self.assets_copied.values():
497
+ dest = assets_dir / f"{stem}_{counter}{ext}"
498
+ counter += 1
499
+ if dest.is_symlink():
500
+ self.warnings.append(f"Skipped image copy over symlink: {dest.name}")
501
+ return None
502
+ assets_dir.mkdir(parents=True, exist_ok=True)
503
+ shutil.copyfile(candidate, dest)
504
+ self.assets_copied[candidate] = dest
505
+ return f"/images/{dest.name}"
506
+
507
+ @_fence_safe
508
+ def handle_images(self, content: str, source_file: Optional[Path] = None) -> str:
509
+ """Wrap local images in Frame (single pass); copy assets when enabled and safe."""
510
+
511
+ found = []
512
+ for start, end, alt, raw in _scan_links(content, images=True):
513
+ parts = re.match(r'^(\S+)(?:\s+"[^"]*")?$', raw.strip())
514
+ found.append((start, end, alt, parts.group(1) if parts else raw.strip()))
515
+ for start, end, attrs in _scan_img_tags(content):
516
+ s = re.search(r'\bsrc=(?:"([^"]*)"|\'([^\']*)\')', attrs)
517
+ if not s:
518
+ continue
519
+ if re.search(r"<Frame\b[^>]*>\s*$", content[max(0, start - 200) : start]):
520
+ continue
521
+ a = re.search(r'\balt=(?:"([^"]*)"|\'([^\']*)\')', attrs)
522
+ src = s.group(1) if s.group(1) is not None else s.group(2)
523
+ alt = (a.group(1) if a.group(1) is not None else a.group(2)) if a else ""
524
+ found.append((start, end, alt, src))
525
+ found.sort(key=lambda item: item[0])
526
+
527
+ out: List[str] = []
528
+ last = 0
529
+ for start, end, alt, src in found:
530
+ if start < last:
531
+ continue
532
+ if src.lower().startswith("file:"):
533
+ self.warnings.append(f"Skipped image with file: source: {src}")
534
+ continue
535
+ if src.startswith("//") or re.match(r"^(?:https?|data):", src, re.IGNORECASE):
536
+ continue
537
+ if content[start - 1 : start] == "[":
538
+ continue
539
+ if source_file is not None:
540
+ new_src = self._copy_asset(src, source_file)
541
+ if new_src:
542
+ src = new_src
543
+ out.append(content[last:start])
544
+ out.append(self._wrap_image_in_frame(alt, src))
545
+ last = end
546
+ if not out:
547
+ return content
548
+ out.append(content[last:])
549
+ return "".join(out)
550
+
551
+ def _wrap_image_in_frame(self, alt: str, src: str) -> str:
552
+ """Wrap image in a Frame component with non-empty alt text."""
553
+ if not alt or not alt.strip():
554
+ alt = "Documentation image"
555
+ return f'<Frame>\n <img src="{_attr(src)}" alt="{_attr(alt)}" />\n</Frame>'
556
+
557
+ # --- callouts and banners -----------------------------------------------
558
+
559
+ @_fence_safe
560
+ def convert_callouts(self, content: str) -> str:
561
+ """Convert blockquotes to Warning/Note/Tip/Info/Check components."""
562
+ result: List[str] = []
563
+ quote: List[str] = []
564
+ raw: List[str] = []
565
+
566
+ def flush():
567
+ if any(_FENCE_OPEN_RE.match(q) for q in quote):
568
+ # Unquoting would create a code fence and change how the page masks code.
569
+ result.extend(raw)
570
+ else:
571
+ text = "\n".join(quote)
572
+ tag = self._get_proper_callout_component(self._determine_callout_type(text))
573
+ result.extend([f"<{tag}>", text, f"</{tag}>"])
574
+ quote.clear()
575
+ raw.clear()
576
+
577
+ for line in content.split("\n"):
578
+ stripped = line.strip()
579
+ if stripped.startswith(">"):
580
+ quote.append(stripped[1:].strip())
581
+ raw.append(line)
582
+ continue
583
+ if quote:
584
+ flush()
585
+ result.append(line)
586
+ if quote:
587
+ flush()
588
+ return "\n".join(result)
589
+
590
+ def _determine_callout_type(self, text: str) -> str:
591
+ """Pick a callout type from its leading label, else from keywords."""
592
+ label = re.match(
593
+ r"^\W*(warning|caution|danger|critical|important|note|tip|hint|info|success)\b",
594
+ text,
595
+ re.IGNORECASE,
596
+ )
597
+ if label:
598
+ word = label.group(1).lower()
599
+ if word in ("warning", "caution", "danger", "critical", "important"):
600
+ return "warning"
601
+ return {
602
+ "note": "note",
603
+ "tip": "tip",
604
+ "hint": "tip",
605
+ "info": "info",
606
+ "success": "check",
607
+ }[word]
608
+
609
+ def has(*words):
610
+ return re.search(r"\b(?:" + "|".join(words) + r")\b", text, re.IGNORECASE) is not None
611
+
612
+ if has("warning", "caution", "critical", "danger", "important"):
613
+ return "warning"
614
+ if has("tip", "hint", "best practice", "pro tip"):
615
+ return "tip"
616
+ if has("note", "remember", "additional"):
617
+ return "note"
618
+ if has("success", "complete", "done", "achieved"):
619
+ return "check"
620
+ return "info"
621
+
622
+ def _get_proper_callout_component(self, callout_type: str) -> str:
623
+ component_map = {
624
+ "warning": "Warning",
625
+ "tip": "Tip",
626
+ "note": "Note",
627
+ "info": "Info",
628
+ "check": "Check",
629
+ }
630
+ return component_map.get(callout_type, "Info")
631
+
632
+ @_fence_safe
633
+ def convert_banner(self, content: str) -> str:
634
+ """Convert 'Deprecated:'/'Important:'/'Notice:'/'Alert:' paragraphs to Banner."""
635
+ pattern = (
636
+ r"(?:\A|(?<=\n\n))(?:(?:⚠️?|\U0001f6a8|\U0001f4e2|\U0001f4a1)[ \t]*)?"
637
+ r"(Deprecated|Important|Notice|Alert):[ \t]*([^\n]+(?:\n(?![ \t]*\n)(?!"
638
+ + _TAG_LINE
639
+ + r")[^\n]+)*)"
640
+ )
641
+ type_map = {
642
+ "deprecated": "warning",
643
+ "important": "warning",
644
+ "notice": "info",
645
+ "alert": "warning",
646
+ }
647
+
648
+ def replacer(match):
649
+ if any(_FENCE_OPEN_RE.match(ln) for ln in match.group(2).split("\n")):
650
+ return match.group(0)
651
+ kind = type_map.get(match.group(1).lower(), "info")
652
+ return f'<Banner type="{kind}">\n {match.group(2).strip()}\n</Banner>'
653
+
654
+ return re.sub(pattern, replacer, content)
655
+
656
+ # --- accordions ---------------------------------------------------------
657
+
658
+ @_fence_safe
659
+ def convert_accordions(self, content: str) -> str:
660
+ """Wrap FAQ/troubleshooting/advanced-style H3 sections in Accordion.
661
+
662
+ A section runs to the next heading of any level (or an existing Accordion).
663
+ """
664
+ lines = content.split("\n")
665
+ result: List[str] = []
666
+ i = 0
667
+ while i < len(lines):
668
+ h3 = re.match(r"^###[ \t]+(.+?)[ \t]*$", lines[i])
669
+ if not h3 or not self._should_be_accordion(h3.group(1)):
670
+ result.append(lines[i])
671
+ i += 1
672
+ continue
673
+ j = i + 1
674
+ while j < len(lines) and not re.match(r"^(?:#{1,6}[ \t]+|<Accordion\b)", lines[j]):
675
+ j += 1
676
+ section = lines[i + 1 : j]
677
+ while section and not section[0].strip():
678
+ section.pop(0)
679
+ trailing = 0
680
+ while section and not section[-1].strip():
681
+ section.pop()
682
+ trailing += 1
683
+ if not section:
684
+ result.append(lines[i])
685
+ i += 1
686
+ continue
687
+ result.append(f'<Accordion title="{_attr(h3.group(1))}">')
688
+ result.extend(section)
689
+ result.append("</Accordion>")
690
+ result.extend([""] * trailing)
691
+ i = j
692
+ return "\n".join(result)
693
+
694
+ _ACCORDION_RE = re.compile(
695
+ r"\b(?:troubleshooting|faqs?|frequently asked|advanced|common issues|known issues|"
696
+ r"error codes|walkthrough|step by step|break ?down)\b",
697
+ re.IGNORECASE,
698
+ )
699
+
700
+ def _should_be_accordion(self, title: str) -> bool:
701
+ """Only explicit FAQ/troubleshooting/advanced-style headings become accordions."""
702
+ return self._ACCORDION_RE.search(title) is not None
703
+
704
+ # --- tabs ---------------------------------------------------------------
705
+
706
+ @_fence_safe
707
+ def convert_tabs(self, content: str) -> str:
708
+ """Turn consecutive 'Language:' labelled blocks into one Tabs group.
709
+
710
+ A block is a label followed by lines up to the next blank line or label; blank lines
711
+ between blocks are allowed. A group needs two or more non-empty blocks, otherwise the
712
+ original lines are kept as they were.
713
+ """
714
+ lines = content.split("\n")
715
+ n = len(lines)
716
+ result: List[str] = []
717
+ i = 0
718
+ while i < n:
719
+ if not _TAB_LABEL_RE.match(lines[i].strip()):
720
+ result.append(lines[i])
721
+ i += 1
722
+ continue
723
+ start = i
724
+ tabs: List[Tuple[str, str]] = []
725
+ j = i
726
+ while True:
727
+ lang = _TAB_LABEL_RE.match(lines[j].strip()).group(1)
728
+ k = j + 1
729
+ buf: List[str] = []
730
+ while (
731
+ k < n
732
+ and lines[k].strip()
733
+ and not _TAB_LABEL_RE.match(lines[k].strip())
734
+ and not _COMPONENT_LINE_RE.match(lines[k])
735
+ ):
736
+ buf.append(lines[k])
737
+ k += 1
738
+ if buf:
739
+ tabs.append((lang, "\n".join(buf).strip()))
740
+ nxt = k
741
+ while nxt < n and not lines[nxt].strip():
742
+ nxt += 1
743
+ if nxt < n and _TAB_LABEL_RE.match(lines[nxt].strip()):
744
+ j = nxt
745
+ continue
746
+ end = k
747
+ break
748
+
749
+ if len(tabs) > 1:
750
+ result.append("<Tabs>")
751
+ for lang, code in tabs:
752
+ result.append(f' <Tab title="{self._get_proper_language_name(lang)}">')
753
+ if _TOKEN_OPEN + "F" in code or "```" in code:
754
+ # Already holds its own fenced code; an outer fence would be closed by it.
755
+ result.append(code)
756
+ else:
757
+ result.extend([f"```{lang.lower()}", code, "```"])
758
+ result.append(" </Tab>")
759
+ result.append("</Tabs>")
760
+ else:
761
+ result.extend(lines[start:end])
762
+ i = end
763
+ return "\n".join(result)
764
+
765
+ def _get_proper_language_name(self, lang: str) -> str:
766
+ language_map = {
767
+ "javascript": "JavaScript",
768
+ "python": "Python",
769
+ "typescript": "TypeScript",
770
+ "ruby": "Ruby",
771
+ "go": "Go",
772
+ "java": "Java",
773
+ "php": "PHP",
774
+ "curl": "cURL",
775
+ "node.js": "Node.js",
776
+ "react": "React",
777
+ "vue": "Vue.js",
778
+ "angular": "Angular",
779
+ "bash": "Bash",
780
+ "shell": "Shell",
781
+ "powershell": "PowerShell",
782
+ }
783
+ return language_map.get(lang.lower(), lang)
784
+
785
+ # --- lists and steps ----------------------------------------------------
786
+
787
+ @_fence_safe
788
+ def convert_lists_with_bold_headers(self, content: str) -> str:
789
+ """Normalise '* **Label** text' to '* **Label**: text' (no doubled colon)."""
790
+ pattern = r"^([ \t]*[*\-])[ \t]+\*\*([^*\n]{1,200})\*\*[ \t]+([^\n]+)$"
791
+
792
+ def replacer(match):
793
+ header, rest = match.group(2), match.group(3)
794
+ if header.endswith(":") or re.match(r"[-:\u2013\u2014]", rest):
795
+ return match.group(0)
796
+ return f"{match.group(1)} **{header}**: {rest}"
797
+
798
+ return re.sub(pattern, replacer, content, flags=re.MULTILINE)
799
+
800
+ @_fence_safe
801
+ def convert_numbered_steps(self, content: str) -> str:
802
+ """Convert each numbered list to one Steps component.
803
+
804
+ A list continues across blank lines, indented content and fenced code between
805
+ numbered items (they belong to the previous step). It ends at the first line that is
806
+ non-blank, not indented, not a fence and not numbered, once a blank line has been seen.
807
+ """
808
+ lines = content.split("\n")
809
+ n = len(lines)
810
+ result: List[str] = []
811
+ num_re = _NumberedItem()
812
+ fence = _TOKEN_OPEN + "F"
813
+
814
+ def flush(steps: List[str]):
815
+ result.append("<Steps>")
816
+ for idx, step in enumerate(steps, 1):
817
+ result.append(f' <Step title="{_attr(self._extract_step_title(step, idx))}">')
818
+ result.append(f" {step}")
819
+ result.append(" </Step>")
820
+ result.append("</Steps>")
821
+
822
+ i = 0
823
+ while i < n:
824
+ match = num_re.match(lines[i])
825
+ if not match:
826
+ result.append(lines[i])
827
+ i += 1
828
+ continue
829
+ steps: List[str] = []
830
+ while match:
831
+ body = [match.group(2)]
832
+ i += 1
833
+ seen_blank = False
834
+ while i < n and not num_re.match(lines[i]):
835
+ line = lines[i]
836
+ if not line.strip():
837
+ seen_blank = True
838
+ elif line[0] not in " \t" and not line.startswith(fence):
839
+ # Unindented text continues the step only directly after it, and
840
+ # never swallows a heading, quote, JSX block or table.
841
+ if seen_blank or _BLOCK_START_RE.match(line):
842
+ break
843
+ body.append(line)
844
+ i += 1
845
+ trailing = 0
846
+ while body and not body[-1].strip():
847
+ body.pop()
848
+ trailing += 1
849
+ match = num_re.match(lines[i]) if i < n else None
850
+ if not match:
851
+ i -= trailing
852
+ steps.append("\n".join(body))
853
+ flush(steps)
854
+ return "\n".join(result)
855
+
856
+ def _plain_inline(self, text: str) -> str:
857
+ """Text with inline code, links and emphasis markers reduced to plain words."""
858
+ store = getattr(self, "_code_store", [])
859
+
860
+ def code(m):
861
+ idx = int(m.group(2))
862
+ if m.group(1) != "C" or idx >= len(store):
863
+ return ""
864
+ return store[idx].strip("`").strip()
865
+
866
+ text = _TOKEN_RE.sub(code, text)
867
+ text = _strip_links(text)
868
+ text = re.sub(r"(\*\*|__)([^\n]{1,200}?)\1", r"\2", text)
869
+ text = re.sub(r"(?<![\w*])\*(?!\s)([^*\n]{1,200}?)(?<!\s)\*(?![\w*])", r"\1", text)
870
+ text = re.sub(r"(?<![\w_])_(?!\s)([^_\n]{1,200}?)(?<!\s)_(?![\w_])", r"\1", text)
871
+ return text.replace("**", "").replace("`", "")
872
+
873
+ def _extract_step_title(self, step_content: str, index: int = 1) -> str:
874
+ """Plain-text step title: first clause of the first line, at most ~80 characters."""
875
+ first = step_content.split("\n")[0].strip()
876
+ stripped = _STEP_PREFIX_RE.sub("", first, count=1)
877
+ if stripped and stripped.lower() not in ("step", "steps"):
878
+ first = stripped
879
+ title = re.sub(r"\s+", " ", self._plain_inline(first)).strip()
880
+ for m in re.finditer(r"[.!?;](?=\s|$)", title):
881
+ if m.start() >= 12:
882
+ title = title[: m.start()]
883
+ break
884
+ if len(title) > 80:
885
+ cut = title.rfind(" ", 0, 81)
886
+ if cut <= 0:
887
+ cut = title.find(" ")
888
+ title = title[:cut] if cut > 0 else title
889
+ title = title.rstrip(" ,;:-\u2013\u2014(").strip()
890
+ if not title or title.lower() in ("step", "steps"):
891
+ return f"Step {index}"
892
+ return title
893
+
894
+ # --- help cards ---------------------------------------------------------
895
+
896
+ @_fence_safe
897
+ def convert_help_sections(self, content: str) -> str:
898
+ """Convert 'Need help?'-style paragraphs to Card components."""
899
+ pattern = (
900
+ r"(?:\A|(?<=\n\n))(Need help\?|Questions\?|Support|Contact us|Get help|Need assistance|Stuck\?|Having trouble\?)"
901
+ r"[ \t]*\n+((?!" + _TAG_LINE + r")(?:[^\n]+\n(?!" + _TAG_LINE + r"))*[^\n]+)"
902
+ )
903
+
904
+ def replacer(match):
905
+ title, text = match.group(1), match.group(2).strip()
906
+ icon = self._get_help_section_icon(title, text)
907
+ return f'<Card title="{title}" icon="{icon}">\n {self._clean_help_section_text(text)}\n</Card>'
908
+
909
+ return re.sub(pattern, replacer, content)
910
+
911
+ def _get_help_section_icon(self, title: str, text: str) -> str:
912
+ title_lower = title.lower()
913
+ text_lower = text.lower()
914
+ if any(word in title_lower for word in ["support", "contact"]):
915
+ return "headphones"
916
+ elif any(word in title_lower for word in ["questions", "stuck", "trouble"]):
917
+ return "question"
918
+ elif any(word in text_lower for word in ["community", "discord", "forum"]):
919
+ return "users"
920
+ elif any(word in text_lower for word in ["documentation", "docs", "guide"]):
921
+ return "book"
922
+ return "help-circle"
923
+
924
+ def _clean_help_section_text(self, text: str) -> str:
925
+ return "\n".join(line.strip() for line in text.split("\n") if line.strip())
926
+
927
+ # --- validation ---------------------------------------------------------
928
+
929
+ def validate_mdx(self, content: str) -> List[str]:
930
+ """Report unclosed or mismatched tags, ignoring anything inside code."""
931
+ masked, _ = _mask_code(_normalize(content))
932
+ errors: List[str] = []
933
+ stack: List[str] = []
934
+ skip_until = 0
935
+ for match in re.finditer(r"</?\w+", masked):
936
+ if match.start() < skip_until:
937
+ continue
938
+ name = match.group(0).lstrip("</")
939
+ end = match.end()
940
+ if match.group(0).startswith("</"):
941
+ if masked[end : end + 1] != ">":
942
+ continue
943
+ skip_until = end + 1
944
+ if stack and stack[-1] == name:
945
+ stack.pop()
946
+ else:
947
+ errors.append(f"Unclosed or mismatched tag: </{name}>")
948
+ continue
949
+ nxt = masked[end : end + 1]
950
+ if nxt == ">":
951
+ tag_end = end
952
+ elif nxt.isspace():
953
+ tag_end = masked.find(">", end, end + 1002)
954
+ if tag_end == -1:
955
+ continue
956
+ else:
957
+ continue
958
+ skip_until = tag_end + 1
959
+ if name.lower() in _VOID_TAGS or masked[end:tag_end].rstrip().endswith("/"):
960
+ continue
961
+ stack.append(name)
962
+ if stack:
963
+ errors.append(f"Unclosed tags: {', '.join(stack)}")
964
+ return errors
965
+
966
+ # --- frontmatter and title ----------------------------------------------
967
+
968
+ def create_frontmatter(
969
+ self, title: str, description: str = "", icon: str = "", mode: str = ""
970
+ ) -> str:
971
+ """Create Mintlify frontmatter (values are JSON-escaped YAML strings)."""
972
+ fm = ["---", f"title: {json.dumps(title, ensure_ascii=False)}"]
973
+ if description:
974
+ fm.append(f"description: {json.dumps(description, ensure_ascii=False)}")
975
+ if icon:
976
+ fm.append(f"icon: {json.dumps(icon, ensure_ascii=False)}")
977
+ if mode:
978
+ fm.append(f"mode: {json.dumps(mode, ensure_ascii=False)}")
979
+ fm.append("---")
980
+ return "\n".join(fm)
981
+
982
+ def remove_original_title(self, content: str, expected_title: Optional[str] = None) -> str:
983
+ """Remove the title heading so it is not repeated under the frontmatter title.
984
+
985
+ Without expected_title (the title came from the heading) the first H1 is removed and,
986
+ if it led the content, so is any directly following H1 with the same text. With
987
+ expected_title (frontmatter already has it) only leading H1s with that text are removed.
988
+ Both rules leave nothing for a second pass to remove, so conversion is idempotent.
989
+ """
990
+ masked, store = _mask_code(content)
991
+
992
+ def h1_text(m):
993
+ return _restore_code(re.sub(r"\s+#+$", "", m.group(1)).strip(), store)
994
+
995
+ if expected_title is None:
996
+ match = _H1_RE.search(masked)
997
+ if not match:
998
+ return content
999
+ expected_title = h1_text(match)
1000
+ leading = not masked[: match.start()].strip()
1001
+ masked = masked[: match.start()] + masked[match.end() :].lstrip("\n")
1002
+ if not leading:
1003
+ return _restore_all(masked, store)
1004
+ # Walk forward by index: slicing the text on every removed heading is quadratic.
1005
+ pos = cut = 0
1006
+ while True:
1007
+ while masked.startswith("\n", pos):
1008
+ pos += 1
1009
+ match = _H1_RE.match(masked, pos)
1010
+ if not match or h1_text(match) != expected_title:
1011
+ break
1012
+ pos = match.end()
1013
+ while masked.startswith("\n", pos):
1014
+ pos += 1
1015
+ cut = pos
1016
+ if cut:
1017
+ masked = masked[cut:]
1018
+ return _restore_all(masked, store)
1019
+
1020
+ # --- main entry point ---------------------------------------------------
1021
+
1022
+ def convert(self, mdx_content: str, source_file: Optional[Path] = None, **kwargs) -> str:
1023
+ """Convert MDX text to Mintlify format. Raises ValueError for oversized input."""
1024
+ self.validation_errors = []
1025
+ self.warnings = []
1026
+ if (
1027
+ len(mdx_content) > MAX_INPUT_BYTES
1028
+ or len(mdx_content.encode("utf-8", "ignore")) > MAX_INPUT_BYTES
1029
+ ):
1030
+ raise ValueError(f"Input is too large ({MAX_INPUT_BYTES // (1024 * 1024)} MiB limit)")
1031
+ text = _normalize(mdx_content)
1032
+ if not text.strip():
1033
+ self.warnings.append("Input is empty; wrote a minimal page")
1034
+
1035
+ fm_lines, body = _split_frontmatter(text)
1036
+ existing = fm_lines or []
1037
+ fm_title = _fm_get(existing, "title")
1038
+ fm_description = _fm_get(existing, "description")
1039
+
1040
+ title = fm_title or self.extract_title(body)
1041
+ description = fm_description or self.extract_description(body)
1042
+ body = self.remove_original_title(body, fm_title if fm_title else None)
1043
+
1044
+ body = self.convert_banner(body)
1045
+ body = self.convert_api_links(body)
1046
+ body = self.convert_relative_links(body, source_file)
1047
+ body = self.handle_images(body, source_file)
1048
+ body = self.convert_lists_with_bold_headers(body)
1049
+ body = self.convert_tabs(body)
1050
+ body = self.convert_numbered_steps(body)
1051
+ body = self.convert_accordions(body)
1052
+ body = self.convert_callouts(body)
1053
+ body = self.convert_help_sections(body)
1054
+
1055
+ if fm_lines is None:
1056
+ header = self.create_frontmatter(
1057
+ title, description, icon=kwargs.get("icon") or "", mode=kwargs.get("mode") or ""
1058
+ )
1059
+ else:
1060
+ new = []
1061
+ if fm_title is None:
1062
+ new.append(f"title: {json.dumps(title, ensure_ascii=False)}")
1063
+ if fm_description is None and description:
1064
+ new.append(f"description: {json.dumps(description, ensure_ascii=False)}")
1065
+ tail = []
1066
+ for key in ("icon", "mode"):
1067
+ if kwargs.get(key) and _fm_get(existing, key) is None:
1068
+ tail.append(f"{key}: {json.dumps(kwargs[key], ensure_ascii=False)}")
1069
+ header = "\n".join(["---", *new, *existing, *tail, "---"])
1070
+
1071
+ result = header + "\n\n" + body.lstrip("\n")
1072
+
1073
+ if kwargs.get("validate"):
1074
+ self.validation_errors = self.validate_mdx(result)
1075
+ return result