sphinx-mintlify-output 0.1.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.
Files changed (35) hide show
  1. sphinx_mintlify_output/__init__.py +31 -0
  2. sphinx_mintlify_output/assets.py +109 -0
  3. sphinx_mintlify_output/autodoc.py +350 -0
  4. sphinx_mintlify_output/builder.py +231 -0
  5. sphinx_mintlify_output/components.py +166 -0
  6. sphinx_mintlify_output/escaping.py +150 -0
  7. sphinx_mintlify_output/frontmatter.py +98 -0
  8. sphinx_mintlify_output/labels.py +21 -0
  9. sphinx_mintlify_output/navigation.py +160 -0
  10. sphinx_mintlify_output/nodes/__init__.py +166 -0
  11. sphinx_mintlify_output/nodes/admonitions.py +103 -0
  12. sphinx_mintlify_output/nodes/autodoc.py +558 -0
  13. sphinx_mintlify_output/nodes/base.py +196 -0
  14. sphinx_mintlify_output/nodes/block.py +91 -0
  15. sphinx_mintlify_output/nodes/definitions.py +55 -0
  16. sphinx_mintlify_output/nodes/footnotes.py +71 -0
  17. sphinx_mintlify_output/nodes/images.py +116 -0
  18. sphinx_mintlify_output/nodes/inline.py +75 -0
  19. sphinx_mintlify_output/nodes/links.py +41 -0
  20. sphinx_mintlify_output/nodes/lists.py +61 -0
  21. sphinx_mintlify_output/nodes/math.py +19 -0
  22. sphinx_mintlify_output/nodes/raw.py +44 -0
  23. sphinx_mintlify_output/nodes/sphinx_design.py +128 -0
  24. sphinx_mintlify_output/nodes/sphinx_inline_tabs.py +93 -0
  25. sphinx_mintlify_output/nodes/tables.py +60 -0
  26. sphinx_mintlify_output/nodes/toctree.py +96 -0
  27. sphinx_mintlify_output/py.typed +0 -0
  28. sphinx_mintlify_output/state.py +45 -0
  29. sphinx_mintlify_output/tables.py +124 -0
  30. sphinx_mintlify_output/toctree.py +80 -0
  31. sphinx_mintlify_output/urls.py +55 -0
  32. sphinx_mintlify_output-0.1.0.dist-info/METADATA +325 -0
  33. sphinx_mintlify_output-0.1.0.dist-info/RECORD +35 -0
  34. sphinx_mintlify_output-0.1.0.dist-info/WHEEL +4 -0
  35. sphinx_mintlify_output-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,31 @@
1
+ """Sphinx builder that emits Mintlify-compatible MDX output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from sphinx_mintlify_output.builder import MintlifyBuilder
8
+
9
+ if TYPE_CHECKING:
10
+ from sphinx.application import Sphinx
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = ["MintlifyBuilder", "__version__", "setup"]
15
+
16
+
17
+ def setup(app: Sphinx) -> dict[str, Any]:
18
+ app.add_builder(MintlifyBuilder)
19
+ app.add_config_value("mintlify_docs_json", {}, "env")
20
+ app.add_config_value("mintlify_static_path", [], "env")
21
+ app.add_config_value("mintlify_image_dir", "images", "env")
22
+ app.add_config_value("mintlify_frontmatter", {}, "env")
23
+ app.add_config_value("mintlify_component_map", {}, "env")
24
+ app.add_config_value("mintlify_emit_anchors", True, "env")
25
+ app.add_config_value("mintlify_externalize_assets", True, "env")
26
+ app.add_config_value("mintlify_base_path", "", "env")
27
+ return {
28
+ "version": __version__,
29
+ "parallel_read_safe": True,
30
+ "parallel_write_safe": True,
31
+ }
@@ -0,0 +1,109 @@
1
+ """Externalize and write image assets referenced from raw HTML blocks.
2
+
3
+ Sphinx output can embed images two ways the translator has to handle:
4
+
5
+ * Inline ``<svg>...</svg>`` markup (e.g. from sphinxcontrib-svgbob).
6
+ * Base64 ``data:image/<mime>;base64,...`` URIs.
7
+
8
+ Both forms inflate the page and can break MDX parsing. The functions
9
+ here write the payload to ``<outdir>/<image_dir>/raw-<sha>.<ext>`` and
10
+ rewrite the HTML to reference the file instead. Asset writes are
11
+ idempotent: identical payloads share a single file.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import binascii
18
+ import hashlib
19
+ import os
20
+ import posixpath
21
+ import re
22
+
23
+ from sphinx_mintlify_output.urls import url_for
24
+
25
+ SVG_BLOCK_RE = re.compile(r"<svg\b[^>]*>.*?</svg>", re.DOTALL | re.IGNORECASE)
26
+ DATA_URI_RE = re.compile(
27
+ r"data:image/(?P<mime>png|jpeg|jpg|gif|svg\+xml|webp);base64,"
28
+ r"(?P<data>[A-Za-z0-9+/=\s]+?)(?=[\"')\s])",
29
+ re.IGNORECASE,
30
+ )
31
+ MIME_EXT: dict[str, str] = {
32
+ "png": "png",
33
+ "jpeg": "jpg",
34
+ "jpg": "jpg",
35
+ "gif": "gif",
36
+ "svg+xml": "svg",
37
+ "webp": "webp",
38
+ }
39
+
40
+
41
+ def externalize_inline_assets(
42
+ html: str,
43
+ *,
44
+ outdir: str,
45
+ image_dir: str,
46
+ from_doc: str,
47
+ ) -> str:
48
+ """Replace inline SVG and base64 data: URIs with file references.
49
+
50
+ Standalone ``<svg>…</svg>`` blocks are written as ``.svg`` files and
51
+ replaced with ``<img>`` tags. ``data:image/<mime>;base64,…`` payloads
52
+ inside larger HTML (typically inside ``<img src="…">``) are written
53
+ using the inferred extension and only the URI is replaced. Other HTML
54
+ passes through untouched. URLs are produced via
55
+ :func:`~sphinx_mintlify_output.urls.url_for` so they respect the
56
+ relative/absolute mode for the current build.
57
+ """
58
+
59
+ def replace_svg_block(match: re.Match[str]) -> str:
60
+ svg = match.group(0)
61
+ rel = write_asset_file(
62
+ svg.encode("utf-8"),
63
+ ext="svg",
64
+ outdir=outdir,
65
+ image_dir=image_dir,
66
+ from_doc=from_doc,
67
+ )
68
+ return f'<img src="{rel}" />'
69
+
70
+ def replace_data_uri(match: re.Match[str]) -> str:
71
+ mime = match.group("mime").lower()
72
+ raw = re.sub(r"\s+", "", match.group("data"))
73
+ ext = MIME_EXT.get(mime, "bin")
74
+ try:
75
+ payload = base64.b64decode(raw, validate=True)
76
+ except (binascii.Error, ValueError):
77
+ return match.group(0)
78
+ rel = write_asset_file(
79
+ payload,
80
+ ext=ext,
81
+ outdir=outdir,
82
+ image_dir=image_dir,
83
+ from_doc=from_doc,
84
+ )
85
+ return rel
86
+
87
+ html = SVG_BLOCK_RE.sub(replace_svg_block, html)
88
+ html = DATA_URI_RE.sub(replace_data_uri, html)
89
+ return html
90
+
91
+
92
+ def write_asset_file(
93
+ content: bytes,
94
+ *,
95
+ ext: str,
96
+ outdir: str,
97
+ image_dir: str,
98
+ from_doc: str,
99
+ ) -> str:
100
+ """Write ``content`` to ``<outdir>/<image_dir>/raw-<hash>.<ext>``."""
101
+ digest = hashlib.sha256(content).hexdigest()[:16]
102
+ filename = f"raw-{digest}.{ext}"
103
+ target_dir = os.path.join(outdir, image_dir)
104
+ os.makedirs(target_dir, exist_ok=True)
105
+ target = os.path.join(target_dir, filename)
106
+ if not os.path.exists(target):
107
+ with open(target, "wb") as fp:
108
+ fp.write(content)
109
+ return url_for(from_doc, posixpath.join(image_dir, filename))
@@ -0,0 +1,350 @@
1
+ """Helpers for rendering Sphinx ``desc`` (autodoc) nodes as MDX.
2
+
3
+ Covers:
4
+
5
+ * Signature reconstruction (:func:`build_clean_signature`,
6
+ :func:`decorate_signature`).
7
+ * Heading and label formatting (:func:`format_desc_label`).
8
+ * Parameter parsing for ``Parameters`` field lists
9
+ (:func:`parse_param_item`, :func:`parse_param_head`,
10
+ :func:`extract_param_info`).
11
+ * Type-aware cross-referencing of strings like ``list[int] | None``
12
+ (:func:`link_types_in_string`, :func:`lookup_python_object`).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Any
19
+
20
+ from docutils import nodes
21
+
22
+ from sphinx_mintlify_output.state import ParamInfo
23
+ from sphinx_mintlify_output.urls import url_for
24
+
25
+
26
+ def desc_short_name(signature: nodes.Element) -> str:
27
+ """Return the leaf name of a desc_signature (last ``desc_name`` found)."""
28
+ from sphinx import addnodes
29
+
30
+ short = ""
31
+ for descendant in signature.findall(condition=addnodes.desc_name):
32
+ short = descendant.astext().strip()
33
+ return short
34
+
35
+
36
+ def decorate_signature(sig: str, desctype: str, return_type: str = "") -> str:
37
+ """Massage a captured desc signature into a Python-like declaration."""
38
+ sig = sig.strip()
39
+ if desctype in {"function", "method", "staticmethod", "classmethod"}:
40
+ if sig.startswith("async "):
41
+ sig = "async def " + sig[len("async ") :]
42
+ elif sig.startswith("classmethod "):
43
+ sig = "@classmethod def " + sig[len("classmethod ") :]
44
+ elif sig.startswith("staticmethod "):
45
+ sig = "@staticmethod def " + sig[len("staticmethod ") :]
46
+ elif sig.startswith("abstractmethod "):
47
+ sig = "@abstractmethod def " + sig[len("abstractmethod ") :]
48
+ else:
49
+ sig = "def " + sig
50
+ if return_type and " -> " not in sig:
51
+ sig = f"{sig} -> {return_type}"
52
+ return sig
53
+
54
+
55
+ def _collect_signature_metadata(
56
+ signature: nodes.Element,
57
+ short_name: str,
58
+ ) -> tuple[str, str, bool, str]:
59
+ """Pull (full_name, annotation, has_paramlist, inline_return) from a sig."""
60
+ from sphinx import addnodes
61
+
62
+ full_name = short_name
63
+ annotation = ""
64
+ has_paramlist = False
65
+ inline_return = ""
66
+ for child in signature.children:
67
+ if isinstance(child, addnodes.desc_addname):
68
+ full_name = child.astext().strip() + short_name
69
+ elif isinstance(child, addnodes.desc_annotation):
70
+ text = child.astext()
71
+ if text.startswith(": "):
72
+ continue
73
+ annotation = text.strip()
74
+ elif isinstance(child, addnodes.desc_parameterlist):
75
+ has_paramlist = True
76
+ elif isinstance(child, addnodes.desc_returns):
77
+ text = child.astext().strip()
78
+ if text.startswith("->"):
79
+ text = text[2:].strip()
80
+ elif text.startswith("→"):
81
+ text = text[1:].strip()
82
+ inline_return = text
83
+ return full_name, annotation, has_paramlist, inline_return
84
+
85
+
86
+ def _collect_param_names(signature: nodes.Element) -> list[str]:
87
+ """Extract parameter names from the first parameterlist of ``signature``."""
88
+ from sphinx import addnodes
89
+
90
+ param_names: list[str] = []
91
+ for plist in signature.findall(condition=addnodes.desc_parameterlist):
92
+ for param in plist.findall(condition=addnodes.desc_parameter):
93
+ text = param.astext().strip()
94
+ if text in {"/", "*"}:
95
+ param_names.append(text)
96
+ continue
97
+ match = SIGNATURE_PARAM_RE.match(text)
98
+ if match:
99
+ param_names.append(match.group("name"))
100
+ else:
101
+ param_names.append(text.split(":", 1)[0].split("=", 1)[0])
102
+ break
103
+ return param_names
104
+
105
+
106
+ def _build_class_signature(
107
+ desctype: str, full_name: str, param_names: list[str]
108
+ ) -> str:
109
+ return f"{desctype} {full_name}({', '.join(param_names)})"
110
+
111
+
112
+ def _build_callable_signature(
113
+ desctype: str,
114
+ full_name: str,
115
+ annotation: str,
116
+ param_names: list[str],
117
+ return_type: str,
118
+ ) -> str:
119
+ prefix = ""
120
+ async_kw = ""
121
+ if annotation == "async":
122
+ async_kw = "async "
123
+ elif annotation == "classmethod":
124
+ prefix = "@classmethod\n"
125
+ elif annotation == "staticmethod":
126
+ prefix = "@staticmethod\n"
127
+ elif annotation == "abstractmethod":
128
+ prefix = "@abstractmethod\n"
129
+ if desctype == "classmethod" and "@classmethod" not in prefix:
130
+ prefix = "@classmethod\n"
131
+ elif desctype == "staticmethod" and "@staticmethod" not in prefix:
132
+ prefix = "@staticmethod\n"
133
+ body = f"{async_kw}def {full_name}({', '.join(param_names)})"
134
+ if return_type:
135
+ body = f"{body} -> {return_type}"
136
+ return prefix + body
137
+
138
+
139
+ def _build_property_signature(full_name: str, return_type: str) -> str:
140
+ body = f"@property\ndef {full_name}()"
141
+ if return_type:
142
+ body = f"{body} -> {return_type}"
143
+ return body
144
+
145
+
146
+ def build_clean_signature(
147
+ signature: nodes.Element,
148
+ desctype: str,
149
+ short_name: str,
150
+ return_type: str = "",
151
+ ) -> str:
152
+ """Reconstruct a parameter-name-only signature with decorators."""
153
+ full_name, annotation, has_paramlist, inline_return = _collect_signature_metadata(
154
+ signature, short_name
155
+ )
156
+ if inline_return and not return_type:
157
+ return_type = inline_return
158
+ param_names = _collect_param_names(signature) if has_paramlist else []
159
+
160
+ if desctype in {"class", "exception"}:
161
+ return _build_class_signature(desctype, full_name, param_names)
162
+ if desctype in {"function", "method", "staticmethod", "classmethod"}:
163
+ return _build_callable_signature(
164
+ desctype, full_name, annotation, param_names, return_type
165
+ )
166
+ if desctype == "property":
167
+ return _build_property_signature(full_name, return_type)
168
+ return full_name
169
+
170
+
171
+ def format_desc_label(desctype: str, short_name: str) -> str:
172
+ """Return the heading label for a desc node, with type prefix when useful."""
173
+ if not short_name:
174
+ return desctype or "object"
175
+ if desctype == "class":
176
+ return f"`class {short_name}`"
177
+ if desctype == "exception":
178
+ return f"`exception {short_name}`"
179
+ if desctype in {"function", "method", "staticmethod", "classmethod"}:
180
+ return f"`{short_name}()`"
181
+ return f"`{short_name}`"
182
+
183
+
184
+ # Matches a single token from a Sphinx ``desc_parameterlist`` —
185
+ # ``name``, ``name: type``, ``name = default``, ``name: type = default``,
186
+ # ``*args`` / ``**kwargs``.
187
+ SIGNATURE_PARAM_RE = re.compile(
188
+ r"^(?P<name>\*{0,2}\w+)"
189
+ r"\s*(?::\s*(?P<type>[^=]+?))?"
190
+ r"\s*(?:=\s*(?P<default>.+))?$",
191
+ re.DOTALL,
192
+ )
193
+
194
+
195
+ def extract_param_info(desc_node: nodes.Element) -> dict[str, ParamInfo]:
196
+ """Pull per-parameter type/default/required info from a desc node."""
197
+ from sphinx import addnodes
198
+
199
+ info: dict[str, ParamInfo] = {}
200
+ for plist in desc_node.findall(condition=addnodes.desc_parameterlist):
201
+ for param in plist.findall(condition=addnodes.desc_parameter):
202
+ text = param.astext().strip()
203
+ if text in {"/", "*"}:
204
+ continue
205
+ match = SIGNATURE_PARAM_RE.match(text)
206
+ if match is None:
207
+ continue
208
+ name = (match.group("name") or "").strip()
209
+ if not name:
210
+ continue
211
+ type_str = (match.group("type") or "").strip()
212
+ default = (match.group("default") or "").strip()
213
+ is_varargs = name.startswith("*")
214
+ info[name] = {
215
+ "type": type_str,
216
+ "default": default,
217
+ "required": not default and not is_varargs,
218
+ }
219
+ break
220
+ return info
221
+
222
+
223
+ EN_DASH = chr(0x2013)
224
+ EM_DASH = chr(0x2014)
225
+ PARAM_DASH_SEPARATORS = (f" {EN_DASH} ", f" {EM_DASH} ", " -- ")
226
+ PARAM_DASH_CLASS = "[-" + EN_DASH + EM_DASH + "]+"
227
+ # Matches one rendered docstring param line — ``name(type) -- description``
228
+ # or any of the dash variants. Used by :func:`parse_param_item` to peel
229
+ # ``:param X: text`` items rendered by Sphinx into the body field list.
230
+ DOCSTRING_PARAM_RE = re.compile(
231
+ "^\\s*(?P<name>\\S+?)\\s*(?:\\((?P<type>[^)]*)\\))?\\s*"
232
+ + PARAM_DASH_CLASS
233
+ + "\\s*(?P<desc>.*)$",
234
+ re.DOTALL,
235
+ )
236
+
237
+
238
+ TYPE_TOKEN_RE = re.compile(r"([A-Za-z_][\w.]*)")
239
+
240
+
241
+ def lookup_python_object(env: Any, name: str) -> tuple[str, str] | None:
242
+ """Find a Python-domain class/exception/function by short or full name.
243
+
244
+ Returns ``(docname, anchor)`` or ``None`` when nothing matches.
245
+ """
246
+ try:
247
+ domain = env.get_domain("py")
248
+ except Exception:
249
+ return None
250
+ objects = getattr(domain, "objects", None)
251
+ if not objects:
252
+ return None
253
+ entry = objects.get(name)
254
+ if entry is not None:
255
+ objtype = getattr(entry, "objtype", "")
256
+ if objtype in {"class", "exception", "function", "method"}:
257
+ return getattr(entry, "docname", ""), getattr(entry, "node_id", "")
258
+ suffix = "." + name
259
+ for fullname, found in objects.items():
260
+ if fullname == name or fullname.endswith(suffix):
261
+ objtype = getattr(found, "objtype", "")
262
+ if objtype in {"class", "exception", "function", "method"}:
263
+ return getattr(found, "docname", ""), getattr(found, "node_id", "")
264
+ return None
265
+
266
+
267
+ def link_types_in_string(type_str: str, from_doc: str, env: Any) -> str:
268
+ """Wrap recognised class names inside a type expression with markdown links."""
269
+ if not type_str:
270
+ return ""
271
+
272
+ def replace(match: re.Match[str]) -> str:
273
+ token = match.group(1)
274
+ if token in BUILTIN_TYPE_NAMES:
275
+ return token
276
+ ref = lookup_python_object(env, token)
277
+ if ref is None:
278
+ return token
279
+ docname, anchor = ref
280
+ href = url_for(from_doc, docname)
281
+ if anchor:
282
+ href = href + "#" + anchor
283
+ return f"[`{token}`]({href})"
284
+
285
+ return TYPE_TOKEN_RE.sub(replace, type_str)
286
+
287
+
288
+ BUILTIN_TYPE_NAMES: frozenset[str] = frozenset(
289
+ {
290
+ "Any",
291
+ "Awaitable",
292
+ "Callable",
293
+ "ClassVar",
294
+ "Dict",
295
+ "Final",
296
+ "Generator",
297
+ "Iterable",
298
+ "Iterator",
299
+ "List",
300
+ "Literal",
301
+ "Mapping",
302
+ "None",
303
+ "Optional",
304
+ "Path",
305
+ "PurePosixPath",
306
+ "Sequence",
307
+ "Set",
308
+ "Tuple",
309
+ "Type",
310
+ "TypeVar",
311
+ "Union",
312
+ "UUID",
313
+ "bool",
314
+ "bytes",
315
+ "datetime",
316
+ "dict",
317
+ "float",
318
+ "frozenset",
319
+ "int",
320
+ "list",
321
+ "object",
322
+ "set",
323
+ "str",
324
+ "timedelta",
325
+ "tuple",
326
+ "type",
327
+ }
328
+ )
329
+
330
+
331
+ def parse_param_item(item: nodes.Element) -> tuple[str, str, str]:
332
+ """Extract (name, type, description) from a Sphinx autodoc param item."""
333
+ text = item.astext().strip()
334
+ match = DOCSTRING_PARAM_RE.match(text)
335
+ if match:
336
+ return (
337
+ match.group("name"),
338
+ (match.group("type") or "").strip(),
339
+ (match.group("desc") or "").strip(),
340
+ )
341
+ parts = text.split(None, 1)
342
+ if len(parts) == 2:
343
+ return parts[0], "", parts[1]
344
+ return text, "", ""
345
+
346
+
347
+ def parse_param_head(item: nodes.Element) -> tuple[str, str]:
348
+ """Extract only (name, type) from a Sphinx autodoc param item."""
349
+ name, type_str, _ = parse_param_item(item)
350
+ return name, type_str