mkdocs cli

This commit is contained in:
2026-01-20 21:40:18 +05:30
parent 726e7ca6d2
commit a8ba02c57b
13 changed files with 356 additions and 12 deletions

View File

@@ -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
# ---------------------------------------------------------------------

84
docforge/cli/mkdocs.py Normal file
View File

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

41
docforge/cli/mkdocs.pyi Normal file
View File

@@ -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:
...

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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]

View File

@@ -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.
"""
...

View File

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

View File

@@ -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: ...

View File

@@ -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

View File

@@ -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]

140
tests/cli/test_mkdocs.py Normal file
View File

@@ -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

View File

@@ -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",
]
},
]