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,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}")
|