From a8ba02c57b6ceb6c76f3a64c4f42802c012eaff0 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Tue, 20 Jan 2026 21:40:18 +0530 Subject: [PATCH] mkdocs cli --- docforge/cli/main.py | 3 + docforge/cli/mkdocs.py | 84 ++++++++++++++++ docforge/cli/mkdocs.pyi | 41 ++++++++ docforge/nav/__init__.py | 3 +- docforge/nav/__init__.pyi | 3 +- docforge/nav/mkdocs.py | 22 +++-- docforge/nav/mkdocs.pyi | 7 ++ docforge/nav/spec.py | 14 +++ docforge/nav/spec.pyi | 10 ++ docforge/templates/mkdocs.sample.yml | 33 +++++++ pyproject.toml | 2 +- tests/cli/test_mkdocs.py | 140 +++++++++++++++++++++++++++ tests/nav/test_mkdocs.py | 6 +- 13 files changed, 356 insertions(+), 12 deletions(-) create mode 100644 docforge/cli/mkdocs.py create mode 100644 docforge/cli/mkdocs.pyi create mode 100644 docforge/templates/mkdocs.sample.yml create mode 100644 tests/cli/test_mkdocs.py diff --git a/docforge/cli/main.py b/docforge/cli/main.py index 14bb62d..90ef261 100644 --- a/docforge/cli/main.py +++ b/docforge/cli/main.py @@ -7,6 +7,7 @@ import click from docforge.loader import GriffeLoader from docforge.renderers.mkdocs import MkDocsRenderer +from docforge.cli.mkdocs import mkdocs_cmd @click.group() @@ -15,6 +16,8 @@ def cli() -> None: pass +cli.add_command(mkdocs_cmd) + # --------------------------------------------------------------------- # tree # --------------------------------------------------------------------- diff --git a/docforge/cli/mkdocs.py b/docforge/cli/mkdocs.py new file mode 100644 index 0000000..2c1a36b --- /dev/null +++ b/docforge/cli/mkdocs.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from pathlib import Path +from importlib import resources + +import click +import yaml + +from docforge.nav import load_nav_spec +from docforge.nav import resolve_nav +from docforge.nav import MkDocsNavEmitter + + +def _load_template(template: Path | None) -> dict: + if template is not None: + if not template.exists(): + raise click.FileError(str(template), hint="Template not found") + return yaml.safe_load(template.read_text(encoding="utf-8")) + + # Load built-in default + text = ( + resources.files("docforge.templates") + .joinpath("mkdocs.sample.yml") + .read_text(encoding="utf-8") + ) + return yaml.safe_load(text) + + +@click.command("mkdocs") +@click.option( + "--docs-dir", + type=click.Path(path_type=Path), + default=Path("docs"), +) +@click.option( + "--nav", + "nav_file", + type=click.Path(path_type=Path), + default=Path("docforge.nav.yml"), +) +@click.option( + "--template", + type=click.Path(path_type=Path), + default=None, + help="Override the built-in mkdocs template", +) +@click.option( + "--out", + type=click.Path(path_type=Path), + default=Path("mkdocs.yml"), +) +def mkdocs_cmd( + docs_dir: Path, + nav_file: Path, + template: Path | None, + out: Path, +) -> None: + """Generate mkdocs.yml from nav spec and template.""" + + if not nav_file.exists(): + raise click.FileError(str(nav_file), hint="Nav spec not found") + + # Load nav spec + spec = load_nav_spec(nav_file) + + # Resolve nav + resolved = resolve_nav(spec, docs_dir) + + # Emit mkdocs nav + nav_block = MkDocsNavEmitter().emit(resolved) + + # Load template (user or built-in) + data = _load_template(template) + + # Inject nav + data["nav"] = nav_block + + # Write output + out.write_text( + yaml.safe_dump(data, sort_keys=False), + encoding="utf-8", + ) + + click.echo(f"mkdocs.yml written to {out}") diff --git a/docforge/cli/mkdocs.pyi b/docforge/cli/mkdocs.pyi new file mode 100644 index 0000000..1880292 --- /dev/null +++ b/docforge/cli/mkdocs.pyi @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Optional + +import click + + +def _load_template(template: Optional[Path]) -> Dict[str, Any]: + ... + + +@click.command("mkdocs") +@click.option( + "--docs-dir", + type=click.Path(path_type=Path), + default=Path("docs"), +) +@click.option( + "--nav", + "nav_file", + type=click.Path(path_type=Path), + default=Path("docforge.nav.yml"), +) +@click.option( + "--template", + type=click.Path(path_type=Path), + default=None, +) +@click.option( + "--out", + type=click.Path(path_type=Path), + default=Path("mkdocs.yml"), +) +def mkdocs_cmd( + docs_dir: Path, + nav_file: Path, + template: Optional[Path], + out: Path, +) -> None: + ... diff --git a/docforge/nav/__init__.py b/docforge/nav/__init__.py index 1c16dd3..2db95eb 100644 --- a/docforge/nav/__init__.py +++ b/docforge/nav/__init__.py @@ -1,4 +1,4 @@ -from .spec import NavSpec +from .spec import NavSpec, load_nav_spec from .resolver import ResolvedNav, resolve_nav from .mkdocs import MkDocsNavEmitter @@ -7,4 +7,5 @@ __all__ = [ "ResolvedNav", "MkDocsNavEmitter", "resolve_nav", + "load_nav_spec", ] diff --git a/docforge/nav/__init__.pyi b/docforge/nav/__init__.pyi index 1c16dd3..2db95eb 100644 --- a/docforge/nav/__init__.pyi +++ b/docforge/nav/__init__.pyi @@ -1,4 +1,4 @@ -from .spec import NavSpec +from .spec import NavSpec, load_nav_spec from .resolver import ResolvedNav, resolve_nav from .mkdocs import MkDocsNavEmitter @@ -7,4 +7,5 @@ __all__ = [ "ResolvedNav", "MkDocsNavEmitter", "resolve_nav", + "load_nav_spec", ] diff --git a/docforge/nav/mkdocs.py b/docforge/nav/mkdocs.py index 9867729..db19d5a 100644 --- a/docforge/nav/mkdocs.py +++ b/docforge/nav/mkdocs.py @@ -1,24 +1,34 @@ from __future__ import annotations -from typing import Any, Dict, List +from pathlib import Path +from typing import List, Dict, Any from docforge.nav.resolver import ResolvedNav class MkDocsNavEmitter: - """Emit MkDocs-compatible nav structure.""" + """Emit MkDocs-compatible nav structures.""" def emit(self, nav: ResolvedNav) -> List[Dict[str, Any]]: result: List[Dict[str, Any]] = [] + # Home entry (semantic path) if nav.home: result.append({"Home": nav.home}) + # Group entries for group, paths in nav.groups.items(): - entries: List[Dict[str, str]] = [] - for path in paths: - title = path.stem.replace("_", " ").title() - entries.append({title: path.as_posix()}) + entries: List[str] = [] + for p in paths: + # Convert filesystem path back to docs-relative path + entries.append(self._to_relative(p)) result.append({group: entries}) return result + + def _to_relative(self, path: Path) -> str: + """ + Convert a filesystem path to a docs-relative path. + """ + # Normalize to POSIX-style for MkDocs + return path.as_posix().split("/docs/", 1)[-1] diff --git a/docforge/nav/mkdocs.pyi b/docforge/nav/mkdocs.pyi index 5a25581..a9e3b73 100644 --- a/docforge/nav/mkdocs.pyi +++ b/docforge/nav/mkdocs.pyi @@ -1,4 +1,5 @@ from typing import Dict, List, Any +from pathlib import Path from docforge.nav.resolver import ResolvedNav @@ -25,3 +26,9 @@ class MkDocsNavEmitter: ] """ ... + + def _to_relative(self, path: Path) -> str: + """ + Convert a filesystem path to a docs-relative path. + """ + ... diff --git a/docforge/nav/spec.py b/docforge/nav/spec.py index c824233..6d8505e 100644 --- a/docforge/nav/spec.py +++ b/docforge/nav/spec.py @@ -53,3 +53,17 @@ class NavSpec: for items in self.groups.values(): patterns.extend(items) return patterns + + +def load_nav_spec(path: Path) -> NavSpec: + if not path.exists(): + raise FileNotFoundError(path) + + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("Nav spec must be a YAML mapping") + + return NavSpec( + home=data.get("home"), + groups=data.get("groups", {}), + ) diff --git a/docforge/nav/spec.pyi b/docforge/nav/spec.pyi index f618d17..fd3bf4d 100644 --- a/docforge/nav/spec.pyi +++ b/docforge/nav/spec.pyi @@ -13,6 +13,13 @@ class NavSpec: home: Optional[str] groups: Dict[str, List[str]] + def __init__( + self, + home: Optional[str], + groups: Dict[str, List[str]], + ) -> None: + ... + @classmethod def load(cls, path: Path) -> "NavSpec": """ @@ -30,3 +37,6 @@ class NavSpec: (including home and group entries). """ ... + + +def load_nav_spec(path: Path) -> NavSpec: ... diff --git a/docforge/templates/mkdocs.sample.yml b/docforge/templates/mkdocs.sample.yml new file mode 100644 index 0000000..1cc2eb9 --- /dev/null +++ b/docforge/templates/mkdocs.sample.yml @@ -0,0 +1,33 @@ +theme: + name: material + palette: + - scheme: slate + primary: deep purple + accent: cyan + font: + text: Inter + code: JetBrains Mono + features: + - navigation.tabs + - navigation.expand + - navigation.top + - navigation.instant + - content.code.copy + - content.code.annotate + +plugins: + - search + - mkdocstrings: + handlers: + python: + paths: ["."] + options: + docstring_style: google + show_source: false + show_signature_annotations: true + separate_signature: true + merge_init_into_class: true + inherited_members: true + annotations_path: brief + show_root_heading: true + group_by_category: true diff --git a/pyproject.toml b/pyproject.toml index 2ffb7f6..e0061be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ Versions = "https://git.aetoskia.com/aetos/doc-forge/tags" packages = { find = { include = ["docforge*"] } } [tool.setuptools.package-data] -docforge = ["templates/**/*"] +docforge = ["templates/*.yml"] [tool.ruff] diff --git a/tests/cli/test_mkdocs.py b/tests/cli/test_mkdocs.py new file mode 100644 index 0000000..262bd2e --- /dev/null +++ b/tests/cli/test_mkdocs.py @@ -0,0 +1,140 @@ +from pathlib import Path + +import yaml +from click.testing import CliRunner + +from docforge.cli.main import cli + + +def _write_nav_spec(path: Path) -> None: + path.write_text( + """ +home: openapi_first/index.md +groups: + Core: + - openapi_first/app.md + - openapi_first/client.md +""".strip(), + encoding="utf-8", + ) + + +def _write_docs(docs: Path) -> None: + files = [ + "openapi_first/index.md", + "openapi_first/app.md", + "openapi_first/client.md", + ] + for rel in files: + p = docs / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(f"# {rel}", encoding="utf-8") + + +def test_mkdocs_uses_builtin_template(tmp_path: Path) -> None: + docs = tmp_path / "docs" + docs.mkdir() + + nav_file = tmp_path / "docforge.nav.yml" + out_file = tmp_path / "mkdocs.yml" + + _write_docs(docs) + _write_nav_spec(nav_file) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "mkdocs", + "--docs-dir", + str(docs), + "--nav", + str(nav_file), + "--out", + str(out_file), + ], + ) + + assert result.exit_code == 0 + assert out_file.exists() + + data = yaml.safe_load(out_file.read_text(encoding="utf-8")) + + # Nav should be injected + assert "nav" in data + assert data["nav"] == [ + {"Home": "openapi_first/index.md"}, + { + "Core": [ + "openapi_first/app.md", + "openapi_first/client.md", + ] + }, + ] + + # Template content should still exist + assert "theme" in data + assert "plugins" in data + + +def test_mkdocs_overrides_template(tmp_path: Path) -> None: + docs = tmp_path / "docs" + docs.mkdir() + + nav_file = tmp_path / "docforge.nav.yml" + template = tmp_path / "custom.yml" + out_file = tmp_path / "mkdocs.yml" + + _write_docs(docs) + _write_nav_spec(nav_file) + + template.write_text( + """ +site_name: Custom Site +theme: + name: readthedocs +""".strip(), + encoding="utf-8", + ) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "mkdocs", + "--docs-dir", + str(docs), + "--nav", + str(nav_file), + "--template", + str(template), + "--out", + str(out_file), + ], + ) + + assert result.exit_code == 0 + + data = yaml.safe_load(out_file.read_text(encoding="utf-8")) + + assert data["site_name"] == "Custom Site" + assert data["theme"]["name"] == "readthedocs" + assert "nav" in data + + +def test_mkdocs_missing_nav_fails(tmp_path: Path) -> None: + docs = tmp_path / "docs" + docs.mkdir() + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "mkdocs", + "--docs-dir", + str(docs), + ], + ) + + assert result.exit_code != 0 + assert "Nav spec not found" in result.output diff --git a/tests/nav/test_mkdocs.py b/tests/nav/test_mkdocs.py index 20d054a..2022208 100644 --- a/tests/nav/test_mkdocs.py +++ b/tests/nav/test_mkdocs.py @@ -25,13 +25,13 @@ def test_emit_mkdocs_nav(): {"Home": "openapi_first/index.md"}, { "Core": [ - {"App": "openapi_first/app.md"}, - {"Client": "openapi_first/client.md"}, + "openapi_first/app.md", + "openapi_first/client.md", ] }, { "CLI": [ - {"Cli": "openapi_first/cli.md"}, + "openapi_first/cli.md", ] }, ]