Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions plugin/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class MetaPlugin(BasePlugin):
("add_json_ld", config_options.Type(bool, default=False)),
("add_css", config_options.Type(bool, default=True)),
("add_copy_llm", config_options.Type(bool, default=True)),
("add_llms_txt", config_options.Type(bool, default=True)),
)

def __init__(self):
Expand Down Expand Up @@ -84,3 +85,18 @@ def on_post_page(self, output: str, page, config) -> str:
if self.config["verbose"]:
print(f"ERROR - mkdocs-ultralytics-plugin: Failed to process {page.file.src_path}: {e}")
return output # Return original output on error

def on_post_build(self, config):
"""Generate llms.txt after build completes."""
if not self.config.get("enabled", True) or not self.config.get("add_llms_txt", True):
return
from plugin.postprocess import generate_llms_txt

generate_llms_txt(
site_dir=Path(config["site_dir"]),
docs_dir=Path(config["docs_dir"]),
site_url=config.get("site_url", ""),
site_name=config.get("site_name"),
site_description=config.get("site_description"),
nav=config.get("nav"),
)
103 changes: 103 additions & 0 deletions plugin/postprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,105 @@ def process_html_file(
return False


def generate_llms_txt(
site_dir: Path,
docs_dir: Path,
site_url: str,
site_name: str | None = None,
site_description: str | None = None,
nav: list | None = None,
) -> None:
"""Generate llms.txt file for LLM consumption."""
import yaml

# Fallback to reading mkdocs.yml if config values not provided (standalone postprocess mode)
if site_name is None or nav is None:

class _Loader(yaml.SafeLoader):
pass

_Loader.add_multi_constructor("", lambda loader, suffix, node: None)

mkdocs_yml = site_dir.parent / "mkdocs.yml"
if mkdocs_yml.exists():
config = yaml.load(mkdocs_yml.read_text(), Loader=_Loader) or {}
site_name = site_name or config.get("site_name", "Documentation")
site_description = site_description or config.get("site_description", "")
nav = nav or config.get("nav")
site_name = site_name or "Documentation"
site_description = site_description or ""

lines = [f"# {site_name}", f"> {site_description}"]
site_url = site_url.rstrip("/")

def get_description(md_path: Path) -> str:
"""Extract description from markdown frontmatter."""
try:
content = md_path.read_text()
if content.startswith("---"):
end = content.find("\n---\n", 3)
if end != -1:
fm = yaml.safe_load(content[4:end]) or {}
return fm.get("description", "")
except Exception:
pass
return ""

def md_to_url(md_path: str) -> str:
"""Convert markdown path to HTML URL."""
url = md_path.replace(".md", "/").replace("/index/", "/")
return f"{site_url}/{url}" if url != "index/" else f"{site_url}/"

if nav:

def process_items(items, indent=0):
"""Recursively process nav items with indentation (Vercel-style)."""
prefix = " " * indent + "- "
for item in items:
if isinstance(item, str):
md = docs_dir / item
if md.exists():
url = md_to_url(item)
desc = get_description(md)
# Use parent dir name for index.md, else filename
title = md.parent.name if md.stem == "index" else md.stem
title = title.replace("-", " ").replace("_", " ").title()
desc_part = f": {desc}" if desc else ""
lines.append(f"{prefix}[{title}]({url}){desc_part}")
elif isinstance(item, dict):
for k, v in item.items():
if isinstance(v, str):
md = docs_dir / v
if md.exists():
url = md_to_url(v)
desc = get_description(md)
desc_part = f": {desc}" if desc else ""
lines.append(f"{prefix}[{k}]({url}){desc_part}")
elif isinstance(v, list):
# Nested section - plain text header, then recurse
lines.append(f"{prefix}{k}")
process_items(v, indent + 1)

# Top-level nav items become ## sections
for item in nav:
if isinstance(item, dict):
for section_name, section_items in item.items():
lines.extend(["", f"## {section_name}"])
if isinstance(section_items, list):
process_items(section_items, indent=0)
else:
for md in sorted(docs_dir.rglob("*.md")):
desc = get_description(md)
rel = md.relative_to(docs_dir).as_posix()
url = md_to_url(rel)
title = md.stem.replace("-", " ").replace("_", " ").title()
desc_part = f": {desc}" if desc else ""
lines.append(f"- [{title}]({url}){desc_part}")

(site_dir / "llms.txt").write_text("\n".join(lines))
print("Generated llms.txt")


def postprocess_site(
site_dir: str | Path = "site",
docs_dir: str | Path = "docs",
Expand All @@ -148,6 +247,7 @@ def postprocess_site(
add_json_ld: bool = False,
add_css: bool = True,
add_copy_llm: bool = True,
add_llms_txt: bool = True,
verbose: bool = True,
use_processes: bool = True,
workers: int | None = None,
Expand Down Expand Up @@ -250,6 +350,9 @@ def submit_fn(ex, f):

print(f"βœ… Postprocessing complete: {processed}/{len(html_files)} files processed")

if add_llms_txt:
generate_llms_txt(site_dir, docs_dir, site_url)


if __name__ == "__main__":
postprocess_site()