YOLO-Master-WebUI-Demo / docs /build_docs.py
isLinXu
Track images with Xet; remove caches
2793310
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
"""
Automates building and post-processing of MkDocs documentation, especially for multilingual projects.
This script streamlines generating localized documentation and updating HTML links for correct formatting.
Key Features:
- Automated building of MkDocs documentation: Compiles main documentation and localized versions from separate
MkDocs configuration files.
- Post-processing of generated HTML files: Updates HTML files to remove '.md' from internal links, ensuring
correct navigation in web-based documentation.
Usage:
- Run from the root directory of your MkDocs project.
- Ensure MkDocs is installed and configuration files (main and localized) are present.
- The script builds documentation using MkDocs, then scans HTML files in 'site' to update links.
- Ideal for projects with Markdown documentation served as a static website.
Note:
- Requires Python and MkDocs to be installed and configured.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
import time
from pathlib import Path
import yaml
from bs4 import BeautifulSoup
from minijinja import Environment, load_from_path
try:
from plugin import postprocess_site # mkdocs-ultralytics-plugin
except ImportError:
postprocess_site = None
from build_reference import build_reference_docs, build_reference_for
from ultralytics.utils import LINUX, LOGGER, MACOS
from ultralytics.utils.tqdm import TQDM
os.environ["JUPYTER_PLATFORM_DIRS"] = "1" # fix DeprecationWarning: Jupyter is migrating to use standard platformdirs
DOCS = Path(__file__).parent.resolve()
SITE = DOCS.parent / "site"
LINK_PATTERN = re.compile(r"(https?://[^\s()<>]*[^\s()<>.,:;!?\'\"])")
TITLE_PATTERN = re.compile(r"<title>(.*?)</title>", flags=re.IGNORECASE | re.DOTALL)
MD_LINK_PATTERN = re.compile(r'(["\']?)([^"\'>\s]+?)\.md(["\']?)')
DOC_KIND_LABELS = {"Class", "Function", "Method", "Property"}
DOC_KIND_COLORS = {
"Class": "#039dfc", # blue
"Method": "#ef5eff", # magenta
"Function": "#fc9803", # orange
"Property": "#02e835", # green
}
def prepare_docs_markdown(clone_repos: bool = True):
"""Build docs using mkdocs."""
LOGGER.info("Removing existing build artifacts")
shutil.rmtree(SITE, ignore_errors=True)
shutil.rmtree(DOCS / "repos", ignore_errors=True)
if clone_repos:
# Get hub-sdk repo
repo = "https://github.com/ultralytics/hub-sdk"
local_dir = DOCS / "repos" / Path(repo).name
subprocess.run(
["git", "clone", "-q", "--depth=1", "--single-branch", "-b", "main", repo, str(local_dir)], check=True
)
shutil.rmtree(DOCS / "en/hub/sdk", ignore_errors=True) # delete if exists
shutil.copytree(local_dir / "docs", DOCS / "en/hub/sdk") # for docs
LOGGER.info(f"Cloned/Updated {repo} in {local_dir}")
# Get docs repo
repo = "https://github.com/ultralytics/docs"
local_dir = DOCS / "repos" / Path(repo).name
subprocess.run(
["git", "clone", "-q", "--depth=1", "--single-branch", "-b", "main", repo, str(local_dir)], check=True
)
shutil.rmtree(DOCS / "en/compare", ignore_errors=True) # delete if exists
shutil.copytree(local_dir / "docs/en/compare", DOCS / "en/compare") # for docs
LOGGER.info(f"Cloned/Updated {repo} in {local_dir}")
# Add frontmatter
for file in TQDM((DOCS / "en").rglob("*.md"), desc="Adding frontmatter"):
update_markdown_files(file)
def update_markdown_files(md_filepath: Path):
"""Create or update a Markdown file, ensuring frontmatter is present."""
if md_filepath.exists():
content = md_filepath.read_text().strip()
# Replace apostrophes
content = content.replace("‘", "'").replace("’", "'")
# Add frontmatter if missing
if not content.strip().startswith("---\n"):
header = "---\ncomments: true\ndescription: TODO ADD DESCRIPTION\nkeywords: TODO ADD KEYWORDS\n---\n\n"
content = header + content
# Ensure MkDocs admonitions "=== " lines are preceded and followed by empty newlines
lines = content.split("\n")
new_lines = []
for i, line in enumerate(lines):
stripped_line = line.strip()
if stripped_line.startswith("=== "):
if i > 0 and new_lines[-1] != "":
new_lines.append("")
new_lines.append(line)
if i < len(lines) - 1 and lines[i + 1].strip() != "":
new_lines.append("")
else:
new_lines.append(line)
content = "\n".join(new_lines)
# Add EOF newline if missing
if not content.endswith("\n"):
content += "\n"
# Save page
md_filepath.write_text(content)
return
def update_docs_html():
"""Update titles, edit links, and convert plaintext links in HTML documentation in one pass."""
from concurrent.futures import ProcessPoolExecutor
html_files = list(SITE.rglob("*.html"))
if not html_files:
LOGGER.info("Updated HTML files: 0")
return
desc = f"Updating HTML at {SITE}"
max_workers = os.cpu_count() or 1
with ProcessPoolExecutor(max_workers=max_workers) as executor:
pbar = TQDM(executor.map(_process_html_file, html_files), total=len(html_files), desc=desc)
updated = 0
for res in pbar:
updated += bool(res)
pbar.set_description(f"{desc} ({updated}/{len(html_files)} updated)")
def _process_html_file(html_file: Path) -> bool:
"""Process a single HTML file; returns True if modified."""
try:
content = html_file.read_text(encoding="utf-8")
except Exception as e:
LOGGER.warning(f"Could not read {html_file}: {e}")
return False
changed = False
try:
rel_path = html_file.relative_to(SITE).as_posix()
except ValueError:
rel_path = html_file.name
# For pages sourced from external repos (hub-sdk, compare), drop edit/copy buttons to avoid wrong links
if rel_path.startswith(("hub/sdk/", "compare/")):
before = content
content = re.sub(
r'<a[^>]*class="[^"]*md-content__button[^"]*"[^>]*>.*?</a>',
"",
content,
flags=re.IGNORECASE | re.DOTALL,
)
if content != before:
changed = True
if rel_path == "404.html":
new_content = re.sub(r"<title>.*?</title>", "<title>Ultralytics Docs - Not Found</title>", content)
if new_content != content:
content, changed = new_content, True
new_content = update_docs_soup(content, html_file=html_file)
if new_content != content:
content, changed = new_content, True
new_content = _rewrite_md_links(content)
if new_content != content:
content, changed = new_content, True
if changed:
try:
html_file.write_text(content, encoding="utf-8")
return True
except Exception as e:
LOGGER.warning(f"Could not write {html_file}: {e}")
return False
def update_docs_soup(content: str, html_file: Path | None = None, max_title_length: int = 70) -> str:
"""Convert plaintext links to HTML hyperlinks, truncate long meta titles, and remove code line hrefs."""
title_match = TITLE_PATTERN.search(content)
needs_title_trim = bool(
title_match and len(title_match.group(1)) > max_title_length and "-" in title_match.group(1)
)
needs_link_conversion = ("<p" in content or "<li" in content) and bool(LINK_PATTERN.search(content))
needs_codelineno_cleanup = "__codelineno-" in content
rel_path = ""
if html_file:
try:
rel_path = html_file.relative_to(SITE).as_posix()
except Exception:
rel_path = html_file.as_posix()
needs_kind_highlight = "reference" in rel_path or "reference" in content
if not (needs_title_trim or needs_link_conversion or needs_codelineno_cleanup or needs_kind_highlight):
return content
try:
soup = BeautifulSoup(content, "lxml")
except Exception:
soup = BeautifulSoup(content, "html.parser")
modified = False
# Truncate long meta title if needed
title_tag = soup.find("title") if needs_title_trim else None
if title_tag and len(title_tag.text) > max_title_length and "-" in title_tag.text:
title_tag.string = title_tag.text.rsplit("-", 1)[0].strip()
modified = True
# Find the main content area
main_content = soup.find("main") or soup.find("div", class_="md-content")
if not main_content:
return str(soup) if modified else content
# Convert plaintext links to HTML hyperlinks
if needs_link_conversion:
for paragraph in main_content.select("p, li"):
for text_node in paragraph.find_all(string=True, recursive=False):
if text_node.parent.name not in {"a", "code"}:
new_text = LINK_PATTERN.sub(r'<a href="\1">\1</a>', str(text_node))
if "<a href=" in new_text:
text_node.replace_with(BeautifulSoup(new_text, "html.parser"))
modified = True
# Remove href attributes from code line numbers in code blocks
if needs_codelineno_cleanup:
for a in soup.select('a[href^="#__codelineno-"], a[id^="__codelineno-"]'):
if a.string: # If the a tag has text (the line number)
# Check if parent is a span with class="normal"
if a.parent and a.parent.name == "span" and "normal" in a.parent.get("class", []):
del a.parent["class"]
a.replace_with(a.string) # Replace with just the text
else: # If it has no text
a.replace_with(soup.new_tag("span")) # Replace with an empty span
modified = True
def highlight_labels(nodes):
"""Inject doc-kind badges into headings and nav entries."""
nonlocal modified
for node in nodes:
if not node.contents:
continue
first = node.contents[0]
if hasattr(first, "get") and "doc-kind" in (first.get("class") or []):
continue
text = first if isinstance(first, str) else getattr(first, "string", "")
if not text:
continue
stripped = str(text).strip()
if not stripped:
continue
kind = stripped.split()[0].rstrip(":")
if kind not in DOC_KIND_LABELS:
continue
span = soup.new_tag("span", attrs={"class": f"doc-kind doc-kind-{kind.lower()}"})
span.string = kind.lower()
first.replace_with(span)
tail = str(text)[len(kind) :]
tail_stripped = tail.lstrip()
if tail_stripped.startswith(kind):
tail = tail_stripped[len(kind) :]
if not tail and len(node.contents) > 0:
tail = " "
if tail:
span.insert_after(tail)
modified = True
highlight_labels(soup.select("main h1, main h2, main h3, main h4, main h5"))
highlight_labels(soup.select("nav.md-nav--secondary .md-ellipsis, nav.md-nav__list .md-ellipsis"))
if "reference" in rel_path:
for ellipsis in soup.select("nav.md-nav--secondary .md-ellipsis"):
kind = ellipsis.find(class_=lambda c: c and "doc-kind" in c.split())
text = str(kind.next_sibling).strip() if kind and kind.next_sibling else ellipsis.get_text(strip=True)
if "." not in text:
continue
ellipsis.clear()
short = text.rsplit(".", 1)[-1]
if kind:
ellipsis.append(kind)
ellipsis.append(f" {short}")
else:
ellipsis.append(short)
modified = True
if needs_kind_highlight and not modified and soup.select(".doc-kind"):
# Ensure style injection when pre-existing badges are present
modified = True
if modified:
head = soup.find("head")
if head and not soup.select("style[data-doc-kind]"):
style = soup.new_tag("style", attrs={"data-doc-kind": "true"})
style.string = (
".doc-kind{display:inline-flex;align-items:center;gap:0.25em;padding:0.21em 0.59em;border-radius:999px;"
"font-weight:700;font-size:0.81em;letter-spacing:0.06em;text-transform:uppercase;"
"line-height:1;color:var(--doc-kind-color,#f8fafc);"
"background:var(--doc-kind-bg,rgba(255,255,255,0.12));}"
f".doc-kind-class{{--doc-kind-color:{DOC_KIND_COLORS['Class']};--doc-kind-bg:rgba(3,157,252,0.22);}}"
f".doc-kind-function{{--doc-kind-color:{DOC_KIND_COLORS['Function']};--doc-kind-bg:rgba(252,152,3,0.22);}}"
f".doc-kind-method{{--doc-kind-color:{DOC_KIND_COLORS['Method']};--doc-kind-bg:rgba(239,94,255,0.22);}}"
f".doc-kind-property{{--doc-kind-color:{DOC_KIND_COLORS['Property']};--doc-kind-bg:rgba(2,232,53,0.22);}}"
)
head.append(style)
return str(soup) if modified else content
def _rewrite_md_links(content: str) -> str:
"""Replace .md references with trailing slashes in HTML content, skipping GitHub links."""
if ".md" not in content:
return content
lines = []
for line in content.split("\n"):
if "github.com" not in line:
line = line.replace("index.md", "")
line = MD_LINK_PATTERN.sub(r"\1\2/\3", line)
lines.append(line)
return "\n".join(lines)
# Precompiled regex patterns for minification
HTML_COMMENT = re.compile(r"<!--[\s\S]*?-->")
HTML_PRESERVE = re.compile(r"<(pre|code|textarea|script)[^>]*>[\s\S]*?</\1>", re.IGNORECASE)
HTML_TAG_SPACE = re.compile(r">\s+<")
HTML_MULTI_SPACE = re.compile(r"\s{2,}")
HTML_EMPTY_LINE = re.compile(r"^\s*$\n", re.MULTILINE)
CSS_COMMENT = re.compile(r"/\*[\s\S]*?\*/")
def remove_comments_and_empty_lines(content: str, file_type: str) -> str:
"""Remove comments and empty lines from a string of code, preserving newlines and URLs.
Args:
content (str): Code content to process.
file_type (str): Type of file ('html', 'css', or 'js').
Returns:
(str): Cleaned content with comments and empty lines removed.
Notes:
Typical reductions for Ultralytics Docs are:
- Total HTML reduction: 2.83% (1301.56 KB saved)
- Total CSS reduction: 1.75% (2.61 KB saved)
- Total JS reduction: 13.51% (99.31 KB saved)
"""
if file_type == "html":
content = HTML_COMMENT.sub("", content) # Remove HTML comments
# Preserve whitespace in <pre>, <code>, <textarea> tags
preserved = []
def preserve(match):
"""Mark HTML blocks that should not be minified."""
preserved.append(match.group(0))
return f"___PRESERVE_{len(preserved) - 1}___"
content = HTML_PRESERVE.sub(preserve, content)
content = HTML_TAG_SPACE.sub("><", content) # Remove whitespace between tags
content = HTML_MULTI_SPACE.sub(" ", content) # Collapse multiple spaces
content = HTML_EMPTY_LINE.sub("", content) # Remove empty lines
# Restore preserved content
for i, text in enumerate(preserved):
content = content.replace(f"___PRESERVE_{i}___", text)
elif file_type == "css":
content = CSS_COMMENT.sub("", content) # Remove CSS comments
# Remove whitespace around specific characters
content = re.sub(r"\s*([{}:;,])\s*", r"\1", content)
# Remove empty lines
content = re.sub(r"^\s*\n", "", content, flags=re.MULTILINE)
# Collapse multiple spaces to single space
content = re.sub(r"\s{2,}", " ", content)
# Remove all newlines
content = re.sub(r"\n", "", content)
elif file_type == "js":
# Handle JS single-line comments (preserving http:// and https://)
lines = content.split("\n")
processed_lines = []
for line in lines:
# Only remove comments if they're not part of a URL
if "//" in line and "http://" not in line and "https://" not in line:
processed_lines.append(line.partition("//")[0])
else:
processed_lines.append(line)
content = "\n".join(processed_lines)
# Remove JS multi-line comments and clean whitespace
content = re.sub(r"/\*[\s\S]*?\*/", "", content)
# Remove empty lines
content = re.sub(r"^\s*\n", "", content, flags=re.MULTILINE)
# Collapse multiple spaces to single space
content = re.sub(r"\s{2,}", " ", content)
# Safe space removal around punctuation and operators (never include colons - breaks JS)
content = re.sub(r"\s*([;{}])\s*", r"\1", content)
content = re.sub(r"(\w)\s*\(|\)\s*{|\s*([+\-*/=])\s*", lambda m: m.group(0).replace(" ", ""), content)
return content
def minify_files(html: bool = True, css: bool = True, js: bool = True):
"""Minify HTML, CSS, and JS files and print total reduction stats."""
minify, compress, jsmin = None, None, None
try:
if html:
from minify_html import minify
if css:
from csscompressor import compress
if js:
import jsmin
except ImportError as e:
LOGGER.info(f"Missing required package: {e}")
return
stats = {}
for ext, minifier in {
"html": (lambda x: minify(x, keep_closing_tags=True, minify_css=True, minify_js=True)) if html else None,
"css": compress if css else None,
"js": jsmin.jsmin if js else None,
}.items():
orig = minified = 0
files = list(SITE.rglob(f"*.{ext}"))
if not files:
continue
pbar = TQDM(files, desc=f"Minifying {ext.upper()} - reduced 0.00% (0.00 KB saved)")
for f in pbar:
content = f.read_text(encoding="utf-8")
out = minifier(content) if minifier else remove_comments_and_empty_lines(content, ext)
orig += len(content)
minified += len(out)
f.write_text(out, encoding="utf-8")
saved = orig - minified
pct = (saved / orig) * 100 if orig else 0.0
pbar.set_description(f"Minifying {ext.upper()} - reduced {pct:.2f}% ({saved / 1024:.2f} KB saved)")
stats[ext] = {"original": orig, "minified": minified}
def render_jinja_macros() -> None:
"""Render MiniJinja macros in markdown files before building with MkDocs."""
mkdocs_yml = DOCS.parent / "mkdocs.yml"
default_yaml = DOCS.parent / "ultralytics" / "cfg" / "default.yaml"
class SafeFallbackLoader(yaml.SafeLoader):
"""SafeLoader that gracefully skips unknown tags (required for mkdocs.yml)."""
def _ignore_unknown(loader, tag_suffix, node):
"""Gracefully handle YAML tags that aren't registered."""
if isinstance(node, yaml.ScalarNode):
return loader.construct_scalar(node)
if isinstance(node, yaml.SequenceNode):
return loader.construct_sequence(node)
if isinstance(node, yaml.MappingNode):
return loader.construct_mapping(node)
return None
SafeFallbackLoader.add_multi_constructor("", _ignore_unknown)
def load_yaml(path: Path, *, safe_loader: yaml.Loader = yaml.SafeLoader) -> dict:
"""Load YAML safely, returning an empty dict on errors."""
if not path.exists():
return {}
try:
with open(path, encoding="utf-8") as f:
return yaml.load(f, Loader=safe_loader) or {}
except Exception as e:
LOGGER.warning(f"Could not load {path}: {e}")
return {}
mkdocs_cfg = load_yaml(mkdocs_yml, safe_loader=SafeFallbackLoader)
extra_vars = mkdocs_cfg.get("extra", {}) or {}
site_name = mkdocs_cfg.get("site_name", "Ultralytics Docs")
extra_vars.update(load_yaml(default_yaml))
env = Environment(
loader=load_from_path([DOCS / "en", DOCS]),
auto_escape_callback=lambda _: False,
trim_blocks=True,
lstrip_blocks=True,
keep_trailing_newline=True,
)
def indent_filter(value: str, width: int = 4, first: bool = False, blank: bool = False) -> str:
"""Mimic Jinja's indent filter to preserve macros compatibility."""
prefix = " " * int(width)
result = []
for i, line in enumerate(str(value).splitlines(keepends=True)):
if not line.strip() and not blank:
result.append(line)
continue
if i == 0 and not first:
result.append(line)
else:
result.append(prefix + line)
return "".join(result)
env.add_filter("indent", indent_filter)
reserved_keys = {"name"}
base_context = {**extra_vars, "page": {"meta": {}}, "config": {"site_name": site_name}}
files_processed = 0
files_with_macros = 0
macros_total = 0
pbar = TQDM((DOCS / "en").rglob("*.md"), desc="MiniJinja: 0 macros, 0 pages")
for md_file in pbar:
if "macros" in md_file.parts or "reference" in md_file.parts:
continue
files_processed += 1
try:
content = md_file.read_text(encoding="utf-8")
except Exception as e:
LOGGER.warning(f"Could not read {md_file}: {e}")
continue
if "{{" not in content and "{%" not in content:
continue
parts = content.split("---\n")
frontmatter = ""
frontmatter_data = {}
markdown_content = content
if content.startswith("---\n") and len(parts) >= 3:
frontmatter = f"---\n{parts[1]}---\n"
markdown_content = "---\n".join(parts[2:])
try:
frontmatter_data = yaml.safe_load(parts[1]) or {}
except Exception as e:
LOGGER.warning(f"Could not parse frontmatter in {md_file}: {e}")
macro_hits = markdown_content.count("{{") + markdown_content.count("{%")
if not macro_hits:
continue
context = {k: v for k, v in base_context.items() if k not in reserved_keys}
context.update({k: v for k, v in frontmatter_data.items() if k not in reserved_keys})
context["page"] = context.get("page", {})
context["page"]["meta"] = frontmatter_data
try:
rendered = env.render_str(markdown_content, name=str(md_file.relative_to(DOCS)), **context)
except Exception as e:
LOGGER.warning(f"Error rendering macros in {md_file}: {e}")
continue
md_file.write_text(frontmatter + rendered, encoding="utf-8")
files_with_macros += 1
macros_total += macro_hits
pbar.set_description(f"MiniJinja: {macros_total} macros, {files_with_macros} pages")
def backup_docs_sources() -> tuple[Path, list[tuple[Path, Path]]]:
"""Create a temporary backup of docs sources so we can fully restore after building."""
backup_root = Path(tempfile.mkdtemp(prefix="docs_backup_", dir=str(DOCS.parent)))
sources = [DOCS / "en", DOCS / "macros"]
copied: list[tuple[Path, Path]] = []
for src in sources:
if not src.exists():
continue
dst = backup_root / src.name
shutil.copytree(src, dst)
copied.append((src, dst))
return backup_root, copied
def restore_docs_sources(backup_root: Path, backups: list[tuple[Path, Path]]):
"""Restore docs sources from the temporary backup."""
for src, dst in backups:
shutil.rmtree(src, ignore_errors=True)
if dst.exists():
shutil.copytree(dst, src)
shutil.rmtree(backup_root, ignore_errors=True)
def main():
"""Build docs, update titles and edit links, minify HTML, and print local server command."""
start_time = time.perf_counter()
backup_root: Path | None = None
docs_backups: list[tuple[Path, Path]] = []
restored = False
def restore_all():
"""Restore docs sources from backup once build steps complete."""
nonlocal restored
if backup_root:
LOGGER.info("Restoring docs directory from backup")
restore_docs_sources(backup_root, docs_backups)
restored = True
try:
backup_root, docs_backups = backup_docs_sources()
prepare_docs_markdown()
build_reference_docs(update_nav=False)
# Render reference docs for any extra packages present (e.g., hub-sdk)
extra_refs = [
{
"package": DOCS / "repos" / "hub-sdk" / "hub_sdk",
"reference_dir": DOCS / "en" / "hub" / "sdk" / "reference",
"repo": "ultralytics/hub-sdk",
},
]
for ref in extra_refs:
if ref["package"].exists():
build_reference_for(ref["package"], ref["reference_dir"], ref["repo"], update_nav=False)
render_jinja_macros()
# Remove cloned repos before serving/building to keep the tree lean during mkdocs processing
shutil.rmtree(DOCS / "repos", ignore_errors=True)
# Build the main documentation
LOGGER.info(f"Building docs from {DOCS}")
subprocess.run(["zensical", "build", "-f", str(DOCS.parent / "mkdocs.yml")], check=True)
LOGGER.info(f"Site built at {SITE}")
# Remove search index JSON files to disable search
Path(SITE / "search.json").unlink(missing_ok=True)
# Update docs HTML pages
update_docs_html()
# Post-process site for meta tags, authors, social cards, and mkdocstrings polish
if postprocess_site:
postprocess_site(
site_dir=SITE,
docs_dir=DOCS / "en",
site_url="https://docs.ultralytics.com",
default_image="https://raw.githubusercontent.com/ultralytics/assets/main/yolov8/banner-yolov8.png",
default_author="glenn.jocher@ultralytics.com",
add_desc=False,
add_image=True,
add_authors=True,
add_json_ld=True,
add_share_buttons=True,
add_css=False,
verbose=True,
)
else:
LOGGER.warning("postprocess_site not available; skipping mkdocstrings postprocessing")
# Minify files
minify_files(html=False, css=False, js=False)
# Print results and auto-serve on macOS
size = sum(f.stat().st_size for f in SITE.rglob("*") if f.is_file()) >> 20
duration = time.perf_counter() - start_time
LOGGER.info(f"Docs built correctly ✅ ({size:.1f}MB, {duration:.1f}s)")
# Restore sources before optionally serving
restore_all()
if (MACOS or LINUX) and not os.getenv("GITHUB_ACTIONS"):
import webbrowser
url = "http://localhost:8000"
LOGGER.info(f"Opening browser at {url}")
webbrowser.open(url)
try:
subprocess.run(["python", "-m", "http.server", "--directory", str(SITE), "8000"], check=True)
except KeyboardInterrupt:
LOGGER.info(f"\n✅ Server stopped. Restart at {url}")
except Exception as e:
if "Address already in use" in str(e):
LOGGER.info("Port 8000 in use; skipping auto-serve. Serve manually if needed.")
else:
LOGGER.info(f"\n❌ Server failed: {e}")
else:
LOGGER.info('Serve site at http://localhost:8000 with "python -m http.server --directory site"')
finally:
if not restored:
restore_all()
shutil.rmtree(DOCS.parent / "hub_sdk", ignore_errors=True)
shutil.rmtree(DOCS / "repos", ignore_errors=True)
if __name__ == "__main__":
main()