Register
Login
Resources
Docs Blog Datasets Glossary Case Studies Tutorials & Webinars
Product
Data Engine LLMs Platform Enterprise
Pricing Explore
Connect to our Discord channel

build_docs.py 15 KB

You have to be logged in to leave a comment. Sign In
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
  1. # Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
  2. """
  3. Automates building and post-processing of MkDocs documentation, especially for multilingual projects.
  4. This script streamlines generating localized documentation and updating HTML links for correct formatting.
  5. Key Features:
  6. - Automated building of MkDocs documentation: Compiles main documentation and localized versions from separate
  7. MkDocs configuration files.
  8. - Post-processing of generated HTML files: Updates HTML files to remove '.md' from internal links, ensuring
  9. correct navigation in web-based documentation.
  10. Usage:
  11. - Run from the root directory of your MkDocs project.
  12. - Ensure MkDocs is installed and configuration files (main and localized) are present.
  13. - The script builds documentation using MkDocs, then scans HTML files in 'site' to update links.
  14. - Ideal for projects with Markdown documentation served as a static website.
  15. Note:
  16. - Requires Python and MkDocs to be installed and configured.
  17. """
  18. import os
  19. import re
  20. import shutil
  21. import subprocess
  22. from pathlib import Path
  23. from bs4 import BeautifulSoup
  24. from tqdm import tqdm
  25. os.environ["JUPYTER_PLATFORM_DIRS"] = "1" # fix DeprecationWarning: Jupyter is migrating to use standard platformdirs
  26. DOCS = Path(__file__).parent.resolve()
  27. SITE = DOCS.parent / "site"
  28. LINK_PATTERN = re.compile(r"(https?://[^\s()<>]*[^\s()<>.,:;!?\'\"])")
  29. def prepare_docs_markdown(clone_repos: bool = True):
  30. """Build docs using mkdocs."""
  31. print("Removing existing build artifacts")
  32. shutil.rmtree(SITE, ignore_errors=True)
  33. shutil.rmtree(DOCS / "repos", ignore_errors=True)
  34. if clone_repos:
  35. # Get hub-sdk repo
  36. repo = "https://github.com/ultralytics/hub-sdk"
  37. local_dir = DOCS / "repos" / Path(repo).name
  38. os.system(f"git clone {repo} {local_dir} --depth 1 --single-branch --branch main")
  39. shutil.rmtree(DOCS / "en/hub/sdk", ignore_errors=True) # delete if exists
  40. shutil.copytree(local_dir / "docs", DOCS / "en/hub/sdk") # for docs
  41. shutil.rmtree(DOCS.parent / "hub_sdk", ignore_errors=True) # delete if exists
  42. shutil.copytree(local_dir / "hub_sdk", DOCS.parent / "hub_sdk") # for mkdocstrings
  43. print(f"Cloned/Updated {repo} in {local_dir}")
  44. # Get docs repo
  45. repo = "https://github.com/ultralytics/docs"
  46. local_dir = DOCS / "repos" / Path(repo).name
  47. os.system(f"git clone {repo} {local_dir} --depth 1 --single-branch --branch main")
  48. shutil.rmtree(DOCS / "en/compare", ignore_errors=True) # delete if exists
  49. shutil.copytree(local_dir / "docs/en/compare", DOCS / "en/compare") # for docs
  50. print(f"Cloned/Updated {repo} in {local_dir}")
  51. # Add frontmatter
  52. for file in tqdm((DOCS / "en").rglob("*.md"), desc="Adding frontmatter"):
  53. update_markdown_files(file)
  54. def update_page_title(file_path: Path, new_title: str):
  55. """Update the title of an HTML file."""
  56. with open(file_path, encoding="utf-8") as file:
  57. content = file.read()
  58. # Replace the existing title with the new title
  59. updated_content = re.sub(r"<title>.*?</title>", f"<title>{new_title}</title>", content)
  60. # Write the updated content back to the file
  61. with open(file_path, "w", encoding="utf-8") as file:
  62. file.write(updated_content)
  63. def update_html_head(script: str = ""):
  64. """Update the HTML head section of each file."""
  65. html_files = Path(SITE).rglob("*.html")
  66. for html_file in tqdm(html_files, desc="Processing HTML files"):
  67. with html_file.open("r", encoding="utf-8") as file:
  68. html_content = file.read()
  69. if script in html_content: # script already in HTML file
  70. return
  71. head_end_index = html_content.lower().rfind("</head>")
  72. if head_end_index != -1:
  73. # Add the specified JavaScript to the HTML file just before the end of the head tag.
  74. new_html_content = html_content[:head_end_index] + script + html_content[head_end_index:]
  75. with html_file.open("w", encoding="utf-8") as file:
  76. file.write(new_html_content)
  77. def update_subdir_edit_links(subdir: str = "", docs_url: str = ""):
  78. """Update the HTML head section of each file."""
  79. if str(subdir[0]) == "/":
  80. subdir = str(subdir[0])[1:]
  81. html_files = (SITE / subdir).rglob("*.html")
  82. for html_file in tqdm(html_files, desc="Processing subdir files", mininterval=1.0):
  83. with html_file.open("r", encoding="utf-8") as file:
  84. soup = BeautifulSoup(file, "html.parser")
  85. # Find the anchor tag and update its href attribute
  86. a_tag = soup.find("a", {"class": "md-content__button md-icon"})
  87. if a_tag and a_tag["title"] == "Edit this page":
  88. a_tag["href"] = f"{docs_url}{a_tag['href'].rpartition(subdir)[-1]}"
  89. # Write the updated HTML back to the file
  90. with open(html_file, "w", encoding="utf-8") as file:
  91. file.write(str(soup))
  92. def update_markdown_files(md_filepath: Path):
  93. """Create or update a Markdown file, ensuring frontmatter is present."""
  94. if md_filepath.exists():
  95. content = md_filepath.read_text().strip()
  96. # Replace apostrophes
  97. content = content.replace("‘", "'").replace("’", "'")
  98. # Add frontmatter if missing
  99. if not content.strip().startswith("---\n") and "macros" not in md_filepath.parts: # skip macros directory
  100. header = "---\ncomments: true\ndescription: TODO ADD DESCRIPTION\nkeywords: TODO ADD KEYWORDS\n---\n\n"
  101. content = header + content
  102. # Ensure MkDocs admonitions "=== " lines are preceded and followed by empty newlines
  103. lines = content.split("\n")
  104. new_lines = []
  105. for i, line in enumerate(lines):
  106. stripped_line = line.strip()
  107. if stripped_line.startswith("=== "):
  108. if i > 0 and new_lines[-1] != "":
  109. new_lines.append("")
  110. new_lines.append(line)
  111. if i < len(lines) - 1 and lines[i + 1].strip() != "":
  112. new_lines.append("")
  113. else:
  114. new_lines.append(line)
  115. content = "\n".join(new_lines)
  116. # Add EOF newline if missing
  117. if not content.endswith("\n"):
  118. content += "\n"
  119. # Save page
  120. md_filepath.write_text(content)
  121. return
  122. def update_docs_html():
  123. """Update titles, edit links, head sections, and convert plaintext links in HTML documentation."""
  124. # Update 404 titles
  125. update_page_title(SITE / "404.html", new_title="Ultralytics Docs - Not Found")
  126. # Update edit button links
  127. for subdir, docs_url in (
  128. ("hub/sdk/", "https://github.com/ultralytics/hub-sdk/tree/main/docs/"), # do not use leading slash
  129. ("compare/", "https://github.com/ultralytics/docs/tree/main/docs/en/compare/"),
  130. ):
  131. update_subdir_edit_links(subdir=subdir, docs_url=docs_url)
  132. # Convert plaintext links to HTML hyperlinks
  133. files_modified = 0
  134. for html_file in tqdm(SITE.rglob("*.html"), desc="Updating bs4 soup", mininterval=1.0):
  135. with open(html_file, encoding="utf-8") as file:
  136. content = file.read()
  137. updated_content = update_docs_soup(content, html_file=html_file)
  138. if updated_content != content:
  139. with open(html_file, "w", encoding="utf-8") as file:
  140. file.write(updated_content)
  141. files_modified += 1
  142. print(f"Modified bs4 soup in {files_modified} files.")
  143. # Update HTML file head section
  144. script = ""
  145. if any(script):
  146. update_html_head(script)
  147. # Delete the /macros directory from the built site
  148. macros_dir = SITE / "macros"
  149. if macros_dir.exists():
  150. print(f"Removing /macros directory from site: {macros_dir}")
  151. shutil.rmtree(macros_dir)
  152. def update_docs_soup(content: str, html_file: Path = None, max_title_length: int = 70) -> str:
  153. """Convert plaintext links to HTML hyperlinks, truncate long meta titles, and remove code line hrefs."""
  154. soup = BeautifulSoup(content, "html.parser")
  155. modified = False
  156. # Truncate long meta title if needed
  157. title_tag = soup.find("title")
  158. if title_tag and len(title_tag.text) > max_title_length and "-" in title_tag.text:
  159. title_tag.string = title_tag.text.rsplit("-", 1)[0].strip()
  160. modified = True
  161. # Find the main content area
  162. main_content = soup.find("main") or soup.find("div", class_="md-content")
  163. if not main_content:
  164. return str(soup) if modified else content
  165. # Convert plaintext links to HTML hyperlinks
  166. for paragraph in main_content.select("p, li"):
  167. for text_node in paragraph.find_all(string=True, recursive=False):
  168. if text_node.parent.name not in {"a", "code"}:
  169. new_text = LINK_PATTERN.sub(r'<a href="\1">\1</a>', str(text_node))
  170. if "<a href=" in new_text:
  171. text_node.replace_with(BeautifulSoup(new_text, "html.parser"))
  172. modified = True
  173. # Remove href attributes from code line numbers in code blocks
  174. for a in soup.select('a[href^="#__codelineno-"], a[id^="__codelineno-"]'):
  175. if a.string: # If the a tag has text (the line number)
  176. # Check if parent is a span with class="normal"
  177. if a.parent and a.parent.name == "span" and "normal" in a.parent.get("class", []):
  178. del a.parent["class"]
  179. a.replace_with(a.string) # Replace with just the text
  180. else: # If it has no text
  181. a.replace_with(soup.new_tag("span")) # Replace with an empty span
  182. modified = True
  183. return str(soup) if modified else content
  184. def remove_macros():
  185. """Remove the /macros directory and related entries in sitemap.xml from the built site."""
  186. shutil.rmtree(SITE / "macros", ignore_errors=True)
  187. (SITE / "sitemap.xml.gz").unlink(missing_ok=True)
  188. # Process sitemap.xml
  189. sitemap = SITE / "sitemap.xml"
  190. lines = sitemap.read_text(encoding="utf-8").splitlines(keepends=True)
  191. # Find indices of '/macros/' lines
  192. macros_indices = [i for i, line in enumerate(lines) if "/macros/" in line]
  193. # Create a set of indices to remove (including lines before and after)
  194. indices_to_remove = set()
  195. for i in macros_indices:
  196. indices_to_remove.update(range(i - 1, i + 3)) # i-1, i, i+1, i+2, i+3
  197. # Create new list of lines, excluding the ones to remove
  198. new_lines = [line for i, line in enumerate(lines) if i not in indices_to_remove]
  199. # Write the cleaned content back to the file
  200. sitemap.write_text("".join(new_lines), encoding="utf-8")
  201. print(f"Removed {len(macros_indices)} URLs containing '/macros/' from {sitemap}")
  202. def remove_comments_and_empty_lines(content: str, file_type: str) -> str:
  203. """
  204. Remove comments and empty lines from a string of code, preserving newlines and URLs.
  205. Args:
  206. content (str): Code content to process.
  207. file_type (str): Type of file ('html', 'css', or 'js').
  208. Returns:
  209. (str): Cleaned content with comments and empty lines removed.
  210. Notes:
  211. Typical reductions for Ultralytics Docs are:
  212. - Total HTML reduction: 2.83% (1301.56 KB saved)
  213. - Total CSS reduction: 1.75% (2.61 KB saved)
  214. - Total JS reduction: 13.51% (99.31 KB saved)
  215. """
  216. if file_type == "html":
  217. # Remove HTML comments
  218. content = re.sub(r"<!--[\s\S]*?-->", "", content)
  219. # Only remove empty lines for HTML, preserve indentation
  220. content = re.sub(r"^\s*$\n", "", content, flags=re.MULTILINE)
  221. elif file_type == "css":
  222. # Remove CSS comments
  223. content = re.sub(r"/\*[\s\S]*?\*/", "", content)
  224. # Remove whitespace around specific characters
  225. content = re.sub(r"\s*([{}:;,])\s*", r"\1", content)
  226. # Remove empty lines
  227. content = re.sub(r"^\s*\n", "", content, flags=re.MULTILINE)
  228. # Collapse multiple spaces to single space
  229. content = re.sub(r"\s{2,}", " ", content)
  230. # Remove all newlines
  231. content = re.sub(r"\n", "", content)
  232. elif file_type == "js":
  233. # Handle JS single-line comments (preserving http:// and https://)
  234. lines = content.split("\n")
  235. processed_lines = []
  236. for line in lines:
  237. # Only remove comments if they're not part of a URL
  238. if "//" in line and "http://" not in line and "https://" not in line:
  239. processed_lines.append(line.partition("//")[0])
  240. else:
  241. processed_lines.append(line)
  242. content = "\n".join(processed_lines)
  243. # Remove JS multi-line comments and clean whitespace
  244. content = re.sub(r"/\*[\s\S]*?\*/", "", content)
  245. # Remove empty lines
  246. content = re.sub(r"^\s*\n", "", content, flags=re.MULTILINE)
  247. # Collapse multiple spaces to single space
  248. content = re.sub(r"\s{2,}", " ", content)
  249. # Safe space removal around punctuation and operators (NEVER include colons - breaks JS)
  250. content = re.sub(r"\s*([,;{}])\s*", r"\1", content)
  251. content = re.sub(r"(\w)\s*\(|\)\s*{|\s*([+\-*/=])\s*", lambda m: m.group(0).replace(" ", ""), content)
  252. return content
  253. def minify_files(html: bool = True, css: bool = True, js: bool = True):
  254. """Minify HTML, CSS, and JS files and print total reduction stats."""
  255. minify, compress, jsmin = None, None, None
  256. try:
  257. if html:
  258. from minify_html import minify
  259. if css:
  260. from csscompressor import compress
  261. if js:
  262. import jsmin
  263. except ImportError as e:
  264. print(f"Missing required package: {str(e)}")
  265. return
  266. stats = {}
  267. for ext, minifier in {
  268. "html": (lambda x: minify(x, keep_closing_tags=True, minify_css=True, minify_js=True)) if html else None,
  269. "css": compress if css else None,
  270. "js": jsmin.jsmin if js else None,
  271. }.items():
  272. stats[ext] = {"original": 0, "minified": 0}
  273. directory = "" # "stylesheets" if ext == css else "javascript" if ext == "js" else ""
  274. for f in tqdm((SITE / directory).rglob(f"*.{ext}"), desc=f"Minifying {ext.upper()}", mininterval=1.0):
  275. content = f.read_text(encoding="utf-8")
  276. minified = minifier(content) if minifier else remove_comments_and_empty_lines(content, ext)
  277. stats[ext]["original"] += len(content)
  278. stats[ext]["minified"] += len(minified)
  279. f.write_text(minified, encoding="utf-8")
  280. for ext, data in stats.items():
  281. if data["original"]:
  282. r = data["original"] - data["minified"] # reduction
  283. print(f"Total {ext.upper()} reduction: {(r / data['original']) * 100:.2f}% ({r / 1024:.2f} KB saved)")
  284. def main():
  285. """Build docs, update titles and edit links, minify HTML, and print local server command."""
  286. prepare_docs_markdown()
  287. # Build the main documentation
  288. print(f"Building docs from {DOCS}")
  289. subprocess.run(f"mkdocs build -f {DOCS.parent}/mkdocs.yml --strict", check=True, shell=True)
  290. remove_macros()
  291. print(f"Site built at {SITE}")
  292. # Update docs HTML pages
  293. update_docs_html()
  294. # Minify files
  295. minify_files(html=False, css=False, js=False)
  296. # Cleanup
  297. shutil.rmtree(DOCS.parent / "hub_sdk", ignore_errors=True)
  298. shutil.rmtree(DOCS / "repos", ignore_errors=True)
  299. # Print results
  300. size = sum(f.stat().st_size for f in SITE.rglob("*") if f.is_file()) >> 20
  301. print(
  302. f"Docs built correctly ✅ ({size:.1f} MB)\n"
  303. f'Serve site at http://localhost:8000 with "python -m http.server --directory site"'
  304. )
  305. if __name__ == "__main__":
  306. main()
Tip!

Press p or to see the previous file or, n or to see the next file

Comments

Loading...