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.
- mintlify_convert/__init__.py +20 -0
- mintlify_convert/cli.py +170 -0
- mintlify_convert/converter.py +1075 -0
- mintlify_convert/navigation.py +246 -0
- mintlify_convert/utils.py +274 -0
- mintlify_convert-2.0.0.dist-info/METADATA +299 -0
- mintlify_convert-2.0.0.dist-info/RECORD +11 -0
- mintlify_convert-2.0.0.dist-info/WHEEL +5 -0
- mintlify_convert-2.0.0.dist-info/entry_points.txt +2 -0
- mintlify_convert-2.0.0.dist-info/licenses/LICENSE +21 -0
- mintlify_convert-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -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  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('"', """)
|
|
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(" 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
|