mkdocs cli
This commit is contained in:
@@ -7,6 +7,7 @@ import click
|
|||||||
|
|
||||||
from docforge.loader import GriffeLoader
|
from docforge.loader import GriffeLoader
|
||||||
from docforge.renderers.mkdocs import MkDocsRenderer
|
from docforge.renderers.mkdocs import MkDocsRenderer
|
||||||
|
from docforge.cli.mkdocs import mkdocs_cmd
|
||||||
|
|
||||||
|
|
||||||
@click.group()
|
@click.group()
|
||||||
@@ -15,6 +16,8 @@ def cli() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
cli.add_command(mkdocs_cmd)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
# tree
|
# tree
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
|
|||||||
84
docforge/cli/mkdocs.py
Normal file
84
docforge/cli/mkdocs.py
Normal 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
41
docforge/cli/mkdocs.pyi
Normal 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:
|
||||||
|
...
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from .spec import NavSpec
|
from .spec import NavSpec, load_nav_spec
|
||||||
from .resolver import ResolvedNav, resolve_nav
|
from .resolver import ResolvedNav, resolve_nav
|
||||||
from .mkdocs import MkDocsNavEmitter
|
from .mkdocs import MkDocsNavEmitter
|
||||||
|
|
||||||
@@ -7,4 +7,5 @@ __all__ = [
|
|||||||
"ResolvedNav",
|
"ResolvedNav",
|
||||||
"MkDocsNavEmitter",
|
"MkDocsNavEmitter",
|
||||||
"resolve_nav",
|
"resolve_nav",
|
||||||
|
"load_nav_spec",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from .spec import NavSpec
|
from .spec import NavSpec, load_nav_spec
|
||||||
from .resolver import ResolvedNav, resolve_nav
|
from .resolver import ResolvedNav, resolve_nav
|
||||||
from .mkdocs import MkDocsNavEmitter
|
from .mkdocs import MkDocsNavEmitter
|
||||||
|
|
||||||
@@ -7,4 +7,5 @@ __all__ = [
|
|||||||
"ResolvedNav",
|
"ResolvedNav",
|
||||||
"MkDocsNavEmitter",
|
"MkDocsNavEmitter",
|
||||||
"resolve_nav",
|
"resolve_nav",
|
||||||
|
"load_nav_spec",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
from __future__ import annotations
|
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
|
from docforge.nav.resolver import ResolvedNav
|
||||||
|
|
||||||
|
|
||||||
class MkDocsNavEmitter:
|
class MkDocsNavEmitter:
|
||||||
"""Emit MkDocs-compatible nav structure."""
|
"""Emit MkDocs-compatible nav structures."""
|
||||||
|
|
||||||
def emit(self, nav: ResolvedNav) -> List[Dict[str, Any]]:
|
def emit(self, nav: ResolvedNav) -> List[Dict[str, Any]]:
|
||||||
result: List[Dict[str, Any]] = []
|
result: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
# Home entry (semantic path)
|
||||||
if nav.home:
|
if nav.home:
|
||||||
result.append({"Home": nav.home})
|
result.append({"Home": nav.home})
|
||||||
|
|
||||||
|
# Group entries
|
||||||
for group, paths in nav.groups.items():
|
for group, paths in nav.groups.items():
|
||||||
entries: List[Dict[str, str]] = []
|
entries: List[str] = []
|
||||||
for path in paths:
|
for p in paths:
|
||||||
title = path.stem.replace("_", " ").title()
|
# Convert filesystem path back to docs-relative path
|
||||||
entries.append({title: path.as_posix()})
|
entries.append(self._to_relative(p))
|
||||||
result.append({group: entries})
|
result.append({group: entries})
|
||||||
|
|
||||||
return result
|
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]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Any
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from docforge.nav.resolver import ResolvedNav
|
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.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|||||||
@@ -53,3 +53,17 @@ class NavSpec:
|
|||||||
for items in self.groups.values():
|
for items in self.groups.values():
|
||||||
patterns.extend(items)
|
patterns.extend(items)
|
||||||
return patterns
|
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", {}),
|
||||||
|
)
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ class NavSpec:
|
|||||||
home: Optional[str]
|
home: Optional[str]
|
||||||
groups: Dict[str, List[str]]
|
groups: Dict[str, List[str]]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
home: Optional[str],
|
||||||
|
groups: Dict[str, List[str]],
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, path: Path) -> "NavSpec":
|
def load(cls, path: Path) -> "NavSpec":
|
||||||
"""
|
"""
|
||||||
@@ -30,3 +37,6 @@ class NavSpec:
|
|||||||
(including home and group entries).
|
(including home and group entries).
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def load_nav_spec(path: Path) -> NavSpec: ...
|
||||||
|
|||||||
33
docforge/templates/mkdocs.sample.yml
Normal file
33
docforge/templates/mkdocs.sample.yml
Normal 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
|
||||||
@@ -73,7 +73,7 @@ Versions = "https://git.aetoskia.com/aetos/doc-forge/tags"
|
|||||||
packages = { find = { include = ["docforge*"] } }
|
packages = { find = { include = ["docforge*"] } }
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
docforge = ["templates/**/*"]
|
docforge = ["templates/*.yml"]
|
||||||
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|||||||
140
tests/cli/test_mkdocs.py
Normal file
140
tests/cli/test_mkdocs.py
Normal 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
|
||||||
@@ -25,13 +25,13 @@ def test_emit_mkdocs_nav():
|
|||||||
{"Home": "openapi_first/index.md"},
|
{"Home": "openapi_first/index.md"},
|
||||||
{
|
{
|
||||||
"Core": [
|
"Core": [
|
||||||
{"App": "openapi_first/app.md"},
|
"openapi_first/app.md",
|
||||||
{"Client": "openapi_first/client.md"},
|
"openapi_first/client.md",
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"CLI": [
|
"CLI": [
|
||||||
{"Cli": "openapi_first/cli.md"},
|
"openapi_first/cli.md",
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user