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,246 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Navigation, docs.json and mint.json generation for Mintlify Convert.
4
+ """
5
+
6
+ import json
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional, Tuple
10
+
11
+ DOCS_JSON_SCHEMA_URL = "https://mintlify.com/docs.json"
12
+ DEFAULT_PRIMARY_COLOR = "#0D9373"
13
+ FIRST_GROUP = "Getting Started"
14
+
15
+ _HEX_COLOR = re.compile(r"^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})$")
16
+ _SOCIAL_SCHEMES = ("http://", "https://")
17
+ _PAGE_SUFFIXES = (".mdx", ".md")
18
+
19
+
20
+ def _clean_path(raw) -> Optional[str]:
21
+ """Normalise a page path to a forward-slash, extension-less relative path."""
22
+ if not isinstance(raw, str):
23
+ return None
24
+ path = raw.strip().replace("\\", "/")
25
+ for suffix in _PAGE_SUFFIXES:
26
+ if path.lower().endswith(suffix):
27
+ path = path[: -len(suffix)]
28
+ break
29
+ parts = [part for part in path.split("/") if part not in ("", ".")]
30
+ if not parts or ".." in parts:
31
+ return None
32
+ return "/".join(parts)
33
+
34
+
35
+ def _group_title(name) -> str:
36
+ if not isinstance(name, str) or not name.strip():
37
+ return "Documentation"
38
+ return name.replace("-", " ").replace("_", " ").strip().title()
39
+
40
+
41
+ def _page_title(meta: Dict, path: str) -> str:
42
+ title = meta.get("title")
43
+ if isinstance(title, str) and title.strip():
44
+ return title.strip()
45
+ return path.rsplit("/", 1)[-1].replace("-", " ").replace("_", " ").title()
46
+
47
+
48
+ def _group_sort_key(title: str):
49
+ # "Getting Started" first, "Documentation" next (legacy behaviour), then A-Z.
50
+ return (title != FIRST_GROUP, title != "Documentation", title.casefold(), title)
51
+
52
+
53
+ def _build_groups(file_metadata: Optional[Dict], sort_pages: bool = False) -> List[Dict]:
54
+ """Return [{'group': title, 'pages': [path, ...]}] in navigation order."""
55
+ groups: Dict[str, List[Tuple[str, str]]] = {}
56
+ seen = set()
57
+
58
+ for key, meta in sorted((file_metadata or {}).items(), key=lambda kv: str(kv[0])):
59
+ if not isinstance(meta, dict):
60
+ continue
61
+ path = _clean_path(meta.get("path", key))
62
+ if path is None or path in seen:
63
+ continue
64
+ seen.add(path)
65
+ title = _group_title(meta.get("group"))
66
+ groups.setdefault(title, []).append((_page_title(meta, path), path))
67
+
68
+ result = []
69
+ for title in sorted(groups, key=_group_sort_key):
70
+ pages = groups[title]
71
+ if sort_pages:
72
+ pages = sorted(pages, key=lambda tp: (tp[0].casefold(), tp[1]))
73
+ result.append({"group": title, "pages": [path for _, path in pages]})
74
+ return result
75
+
76
+
77
+ def _valid_url(value, schemes=_SOCIAL_SCHEMES) -> Optional[str]:
78
+ if isinstance(value, str) and value.strip().lower().startswith(schemes):
79
+ return value.strip()
80
+ return None
81
+
82
+
83
+ def _check_color(value, label: str) -> Optional[str]:
84
+ if value is None or value == "":
85
+ return None
86
+ if not isinstance(value, str) or not _HEX_COLOR.match(value):
87
+ raise ValueError(f"{label} must be a hex color like #0D9373, got {value!r}")
88
+ return value
89
+
90
+
91
+ def _write_json(config: Dict, output_path) -> None:
92
+ path = Path(output_path)
93
+ if path.is_symlink():
94
+ raise ValueError(f"Refusing to write through symlink: {path}")
95
+ with open(path, "w", encoding="utf-8") as f:
96
+ json.dump(config, f, indent=2)
97
+ f.write("\n")
98
+
99
+
100
+ def generate_docs_json(
101
+ file_metadata: Optional[Dict],
102
+ output_file,
103
+ project_name: str = "Documentation",
104
+ primary_color: Optional[str] = None,
105
+ light_color: Optional[str] = None,
106
+ dark_color: Optional[str] = None,
107
+ support_url: Optional[str] = None,
108
+ dashboard_url: Optional[str] = None,
109
+ twitter: Optional[str] = None,
110
+ github: Optional[str] = None,
111
+ linkedin: Optional[str] = None,
112
+ sort_pages: bool = False,
113
+ **_ignored,
114
+ ):
115
+ """Write a minimal docs.json (theme "mint") valid against the Mintlify schema.
116
+
117
+ Keys whose values are unset are omitted; nothing is filled with placeholders.
118
+ Raises ValueError for malformed colors.
119
+ """
120
+ colors = {"primary": _check_color(primary_color, "primary_color") or DEFAULT_PRIMARY_COLOR}
121
+ for key, value in (("light", light_color), ("dark", dark_color)):
122
+ checked = _check_color(value, f"{key}_color")
123
+ if checked:
124
+ colors[key] = checked
125
+
126
+ groups = _build_groups(file_metadata, sort_pages)
127
+ config: Dict = {
128
+ "$schema": DOCS_JSON_SCHEMA_URL,
129
+ "theme": "mint",
130
+ "name": (project_name or "").strip() or "Documentation",
131
+ "colors": colors,
132
+ # The schema rejects an empty "groups" array but accepts an empty "pages" one.
133
+ "navigation": {"groups": groups} if groups else {"pages": []},
134
+ }
135
+
136
+ links = []
137
+ support = _valid_url(support_url, _SOCIAL_SCHEMES + ("mailto:",))
138
+ if support:
139
+ links.append({"label": "Support", "href": support})
140
+ navbar: Dict = {}
141
+ if links:
142
+ navbar["links"] = links
143
+ dashboard = _valid_url(dashboard_url)
144
+ if dashboard:
145
+ navbar["primary"] = {"type": "button", "label": "Dashboard", "href": dashboard}
146
+ if navbar:
147
+ config["navbar"] = navbar
148
+
149
+ socials = {}
150
+ for name, value in (("x", twitter), ("github", github), ("linkedin", linkedin)):
151
+ url = _valid_url(value)
152
+ if url:
153
+ socials[name] = url
154
+ if socials:
155
+ config["footer"] = {"socials": socials}
156
+
157
+ _write_json(config, output_file)
158
+ print(f"\u2713 Generated docs.json at {output_file}")
159
+
160
+
161
+ def generate_enhanced_mint_json(file_metadata: Dict, output_path: Path, **kwargs):
162
+ """Generate a legacy mint.json with grouped navigation (no placeholder values)."""
163
+ primary = _check_color(kwargs.get("primary_color"), "primary_color") or DEFAULT_PRIMARY_COLOR
164
+ light = _check_color(kwargs.get("light_color"), "light_color")
165
+ dark = _check_color(kwargs.get("dark_color"), "dark_color")
166
+
167
+ colors = {"primary": primary}
168
+ if light:
169
+ colors["light"] = light
170
+ if dark:
171
+ colors["dark"] = dark
172
+ if light:
173
+ colors["anchors"] = {"from": primary, "to": light}
174
+
175
+ mint_config: Dict = {
176
+ "name": (kwargs.get("project_name") or "").strip() or "Documentation",
177
+ "colors": colors,
178
+ "navigation": _build_groups(file_metadata, bool(kwargs.get("sort_pages"))),
179
+ }
180
+
181
+ support = _valid_url(kwargs.get("support_url"), _SOCIAL_SCHEMES + ("mailto:",))
182
+ if support:
183
+ mint_config["topbarLinks"] = [{"name": "Support", "url": support}]
184
+ dashboard = _valid_url(kwargs.get("dashboard_url"))
185
+ if dashboard:
186
+ mint_config["topbarCtaButton"] = {"name": "Dashboard", "url": dashboard}
187
+
188
+ socials = {}
189
+ for name in ("twitter", "github", "linkedin"):
190
+ url = _valid_url(kwargs.get(name))
191
+ if url:
192
+ socials[name] = url
193
+ if socials:
194
+ mint_config["footerSocials"] = socials
195
+
196
+ _write_json(mint_config, output_path)
197
+ print(f"\u2713 Generated enhanced mint.json at {output_path}")
198
+
199
+
200
+ def create_basic_mint_json(output_path: Path, project_name: str = "Documentation"):
201
+ """Create a basic mint.json file for simple setups."""
202
+ basic_config = {
203
+ "name": project_name,
204
+ "logo": {"dark": "/logo/dark.svg", "light": "/logo/light.svg"},
205
+ "favicon": "/favicon.svg",
206
+ "colors": {"primary": "#0D9373", "light": "#07C983", "dark": "#0D9373"},
207
+ "navigation": [{"group": "Getting Started", "pages": ["getting-started"]}],
208
+ }
209
+
210
+ with open(output_path, "w", encoding="utf-8") as f:
211
+ json.dump(basic_config, f, indent=2)
212
+
213
+ print(f"✓ Generated basic mint.json at {output_path}")
214
+
215
+
216
+ def validate_mint_json(mint_json_path: Path) -> bool:
217
+ """Validate mint.json structure."""
218
+ try:
219
+ with open(mint_json_path, "r", encoding="utf-8") as f:
220
+ config = json.load(f)
221
+
222
+ required_fields = ["name", "navigation"]
223
+ for field in required_fields:
224
+ if field not in config:
225
+ print(f"Error: Missing required field '{field}' in mint.json")
226
+ return False
227
+
228
+ # Validate navigation structure
229
+ if not isinstance(config["navigation"], list):
230
+ print("Error: 'navigation' must be a list")
231
+ return False
232
+
233
+ for item in config["navigation"]:
234
+ if not isinstance(item, dict) or "group" not in item or "pages" not in item:
235
+ print("Error: Each navigation item must have 'group' and 'pages' fields")
236
+ return False
237
+
238
+ print("✓ mint.json validation passed")
239
+ return True
240
+
241
+ except json.JSONDecodeError as e:
242
+ print(f"Error: Invalid JSON in mint.json: {e}")
243
+ return False
244
+ except Exception as e:
245
+ print(f"Error validating mint.json: {e}")
246
+ return False
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Utility functions for Mintlify Convert.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Dict, Tuple, Optional
10
+
11
+ from .converter import MAX_INPUT_BYTES, MintlifyConverter
12
+
13
+
14
+ def check_paths(input_path: Path, output_path: Optional[Path]) -> Optional[str]:
15
+ """Return an error message if the output would overwrite or nest with the input."""
16
+ if output_path is None:
17
+ return None
18
+ try:
19
+ src = Path(input_path).resolve()
20
+ dst = Path(output_path).resolve()
21
+ except (OSError, RuntimeError) as e:
22
+ return f"Cannot resolve paths: {e}"
23
+ if src == dst:
24
+ return f"Output path must differ from the input path: {output_path}"
25
+ if Path(input_path).is_dir():
26
+ if dst.is_relative_to(src):
27
+ return f"Output directory must not be inside the input directory: {output_path}"
28
+ if src.is_relative_to(dst):
29
+ return f"Input directory must not be inside the output directory: {input_path}"
30
+ return None
31
+
32
+
33
+ def process_file(
34
+ input_path: Path, output_path: Path = None, converter: MintlifyConverter = None, **kwargs
35
+ ) -> Tuple[bool, Optional[str]]:
36
+ """Process a single MDX file.
37
+
38
+ Args:
39
+ input_path: Path to input MDX file
40
+ output_path: Path to output file (optional)
41
+ converter: MintlifyConverter instance (optional)
42
+ **kwargs: Additional conversion options
43
+
44
+ Returns:
45
+ Tuple of (success, title)
46
+ """
47
+ try:
48
+ input_path = Path(input_path)
49
+ if input_path.is_symlink():
50
+ print(f"Skipping symlink: {input_path}", file=sys.stderr)
51
+ return False, None
52
+ problem = check_paths(input_path, output_path)
53
+ if problem is None and output_path is not None and Path(output_path).is_symlink():
54
+ problem = f"Refusing to write through symlink: {output_path}"
55
+ if problem:
56
+ print(f"Error: {problem}", file=sys.stderr)
57
+ return False, None
58
+ if input_path.stat().st_size > MAX_INPUT_BYTES:
59
+ print(
60
+ f"Error: {input_path} is too large "
61
+ f"(limit is {MAX_INPUT_BYTES // (1024 * 1024)} MiB)",
62
+ file=sys.stderr,
63
+ )
64
+ return False, None
65
+
66
+ with open(input_path, "r", encoding="utf-8") as f:
67
+ content = f.read()
68
+
69
+ if converter is None:
70
+ converter = MintlifyConverter(
71
+ Path(output_path).parent if output_path else None,
72
+ download_assets=bool(kwargs.get("download_assets")),
73
+ input_root=input_path.parent,
74
+ )
75
+
76
+ converted = converter.convert(content, source_file=input_path, **kwargs)
77
+
78
+ for warning in converter.warnings:
79
+ print(f"\u26a0 {input_path}: {warning}", file=sys.stderr)
80
+
81
+ if output_path:
82
+ output_path = Path(output_path)
83
+ output_path.parent.mkdir(parents=True, exist_ok=True)
84
+ with open(output_path, "w", encoding="utf-8") as f:
85
+ f.write(converted)
86
+ print(f"\u2713 Converted: {input_path} -> {output_path}")
87
+
88
+ if kwargs.get("validate") and converter.validation_errors:
89
+ print(f"\u26a0 Validation warnings for {input_path}:")
90
+ for error in converter.validation_errors:
91
+ print(f" - {error}")
92
+ else:
93
+ print(converted)
94
+
95
+ return True, converter.extract_title(content)
96
+ except Exception as e:
97
+ print(f"\u2717 Error processing {input_path}: {str(e)}", file=sys.stderr)
98
+ if kwargs.get("verbose"):
99
+ import traceback
100
+
101
+ traceback.print_exc()
102
+ return False, None
103
+
104
+
105
+ def process_directory(input_dir: Path, output_dir: Path, **kwargs) -> Dict:
106
+ """Process all MDX files in a directory.
107
+
108
+ Args:
109
+ input_dir: Input directory containing MDX files
110
+ output_dir: Output directory for converted files
111
+ **kwargs: Additional conversion options
112
+
113
+ Returns:
114
+ Dictionary of file metadata for navigation generation
115
+
116
+ Raises:
117
+ ValueError: if the output directory equals or nests with the input directory
118
+ """
119
+ input_dir, output_dir = Path(input_dir), Path(output_dir)
120
+ problem = check_paths(input_dir, output_dir)
121
+ if problem:
122
+ raise ValueError(problem)
123
+
124
+ skipped: list = []
125
+ mdx_files = find_mdx_files(input_dir, skipped)
126
+ for path in skipped:
127
+ print(f"Warning: skipping symlink: {path}", file=sys.stderr)
128
+
129
+ if not mdx_files:
130
+ print(f"No MDX/MD files found in {input_dir}")
131
+ return {}
132
+
133
+ print(f"Found {len(mdx_files)} files to convert...")
134
+
135
+ converter = MintlifyConverter(
136
+ output_dir, download_assets=bool(kwargs.get("download_assets")), input_root=input_dir
137
+ )
138
+ success_count = 0
139
+ file_metadata = {}
140
+
141
+ for mdx_file in mdx_files:
142
+ relative_path = mdx_file.relative_to(input_dir)
143
+ output_path = output_dir / relative_path
144
+
145
+ success, title = process_file(mdx_file, output_path, converter, **kwargs)
146
+ if success:
147
+ success_count += 1
148
+ key = relative_path.with_suffix("").as_posix()
149
+ if key in file_metadata:
150
+ print(
151
+ f"Warning: {relative_path} collides with another page at '{key}'",
152
+ file=sys.stderr,
153
+ )
154
+ file_metadata[key] = {
155
+ "title": title,
156
+ "path": key,
157
+ "group": (
158
+ relative_path.parent.as_posix()
159
+ if relative_path.parent != Path(".")
160
+ else "Documentation"
161
+ ),
162
+ }
163
+
164
+ print(f"\n\u2713 Successfully converted {success_count}/{len(mdx_files)} files")
165
+
166
+ if converter.assets_copied:
167
+ print(f"\u2713 Copied {len(converter.assets_copied)} assets to /images")
168
+
169
+ return file_metadata
170
+
171
+
172
+ def find_mdx_files(directory: Path, skipped: Optional[list] = None) -> list[Path]:
173
+ """Find all MDX and MD files in a directory, sorted, without following symlinks.
174
+
175
+ Args:
176
+ directory: Directory to search
177
+ skipped: Optional list that receives the symlinked files and directories passed over
178
+
179
+ Returns:
180
+ List of MDX/MD file paths
181
+ """
182
+ found = []
183
+ for root, dirs, files in os.walk(directory, followlinks=False):
184
+ dirs.sort()
185
+ if skipped is not None:
186
+ skipped.extend(Path(root) / d for d in dirs if (Path(root) / d).is_symlink())
187
+ for name in sorted(files):
188
+ path = Path(root) / name
189
+ if path.suffix.lower() not in (".md", ".mdx"):
190
+ continue
191
+ if path.is_symlink():
192
+ if skipped is not None:
193
+ skipped.append(path)
194
+ continue
195
+ found.append(path)
196
+ return found
197
+
198
+
199
+ def ensure_output_directory(output_path: Path) -> None:
200
+ """Ensure output directory exists.
201
+
202
+ Args:
203
+ output_path: Path to output directory
204
+ """
205
+ output_path.mkdir(parents=True, exist_ok=True)
206
+
207
+
208
+ def get_relative_path(file_path: Path, base_path: Path) -> str:
209
+ """Get relative path string from file path to base path.
210
+
211
+ Args:
212
+ file_path: File path
213
+ base_path: Base directory path
214
+
215
+ Returns:
216
+ Relative path string
217
+ """
218
+ try:
219
+ return str(file_path.relative_to(base_path))
220
+ except ValueError:
221
+ # If file_path is not relative to base_path, return the filename
222
+ return file_path.name
223
+
224
+
225
+ def validate_input_path(input_path: Path) -> bool:
226
+ """Validate that input path exists and is readable.
227
+
228
+ Args:
229
+ input_path: Path to validate
230
+
231
+ Returns:
232
+ True if valid, False otherwise
233
+ """
234
+ if not input_path.exists():
235
+ print(f"Error: {input_path} does not exist", file=sys.stderr)
236
+ return False
237
+
238
+ if input_path.is_file() and not input_path.suffix.lower() in [".md", ".mdx"]:
239
+ print(f"Error: {input_path} is not an MDX or MD file", file=sys.stderr)
240
+ return False
241
+
242
+ return True
243
+
244
+
245
+ def print_conversion_summary(
246
+ converted_count: int, total_count: int, assets_copied: int = 0, errors: list = None
247
+ ) -> None:
248
+ """Print a summary of the conversion process.
249
+
250
+ Args:
251
+ converted_count: Number of files successfully converted
252
+ total_count: Total number of files processed
253
+ assets_copied: Number of assets copied
254
+ errors: List of error messages
255
+ """
256
+ print(f"\n{'='*50}")
257
+ print("CONVERSION SUMMARY")
258
+ print(f"{'='*50}")
259
+ print(f"Files converted: {converted_count}/{total_count}")
260
+
261
+ if assets_copied > 0:
262
+ print(f"Assets copied: {assets_copied}")
263
+
264
+ if errors:
265
+ print(f"Errors: {len(errors)}")
266
+ for error in errors:
267
+ print(f" - {error}")
268
+
269
+ if converted_count == total_count:
270
+ print("✓ All files converted successfully!")
271
+ else:
272
+ print(f"⚠ {total_count - converted_count} files failed to convert")
273
+
274
+ print(f"{'='*50}")