nav submodule

This commit is contained in:
2026-01-20 21:22:28 +05:30
parent 869b1730c4
commit 726e7ca6d2
12 changed files with 493 additions and 0 deletions

10
docforge/nav/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
from .spec import NavSpec
from .resolver import ResolvedNav, resolve_nav
from .mkdocs import MkDocsNavEmitter
__all__ = [
"NavSpec",
"ResolvedNav",
"MkDocsNavEmitter",
"resolve_nav",
]

10
docforge/nav/__init__.pyi Normal file
View File

@@ -0,0 +1,10 @@
from .spec import NavSpec
from .resolver import ResolvedNav, resolve_nav
from .mkdocs import MkDocsNavEmitter
__all__ = [
"NavSpec",
"ResolvedNav",
"MkDocsNavEmitter",
"resolve_nav",
]

24
docforge/nav/mkdocs.py Normal file
View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from typing import Any, Dict, List
from docforge.nav.resolver import ResolvedNav
class MkDocsNavEmitter:
"""Emit MkDocs-compatible nav structure."""
def emit(self, nav: ResolvedNav) -> List[Dict[str, Any]]:
result: List[Dict[str, Any]] = []
if nav.home:
result.append({"Home": nav.home})
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()})
result.append({group: entries})
return result

27
docforge/nav/mkdocs.pyi Normal file
View File

@@ -0,0 +1,27 @@
from typing import Dict, List, Any
from docforge.nav.resolver import ResolvedNav
class MkDocsNavEmitter:
"""
Converts a ResolvedNav into MkDocs-compatible `nav` data.
"""
def emit(self, nav: ResolvedNav) -> List[Dict[str, Any]]:
"""
Emit a structure suitable for insertion into mkdocs.yml.
Example return value:
[
{"Home": "openapi_first/index.md"},
{
"Core": [
{"OpenAPI-First App": "openapi_first/app.md"},
...
]
}
]
"""
...

71
docforge/nav/resolver.py Normal file
View File

@@ -0,0 +1,71 @@
from __future__ import annotations
from pathlib import Path
from typing import Dict, Iterable, List
import glob
from docforge.nav.spec import NavSpec
class ResolvedNav:
def __init__(
self,
home: str | None,
groups: Dict[str, List[Path]],
docs_root: Path | None = None,
) -> None:
self.home = home
self.groups = groups
self._docs_root = docs_root
def all_files(self) -> Iterable[Path]:
if self.home:
if self._docs_root is None:
raise RuntimeError("docs_root is required to resolve home path")
yield self._docs_root / self.home
for paths in self.groups.values():
for p in paths:
yield p
def resolve_nav(
spec: NavSpec,
docs_root: Path,
) -> ResolvedNav:
if not docs_root.exists():
raise FileNotFoundError(docs_root)
def resolve_pattern(pattern: str) -> List[Path]:
full = docs_root / pattern
matches = sorted(
Path(p) for p in glob.glob(str(full), recursive=True)
)
if not matches:
raise FileNotFoundError(pattern)
return matches
# Resolve home
home: str | None = None
if spec.home:
home_path = docs_root / spec.home
if not home_path.exists():
raise FileNotFoundError(spec.home)
home = spec.home
# Resolve groups
resolved_groups: Dict[str, List[Path]] = {}
for group, patterns in spec.groups.items():
files: List[Path] = []
for pattern in patterns:
files.extend(resolve_pattern(pattern))
resolved_groups[group] = files
return ResolvedNav(
home=home,
groups=resolved_groups,
docs_root=docs_root,
)

52
docforge/nav/resolver.pyi Normal file
View File

@@ -0,0 +1,52 @@
from pathlib import Path
from typing import Dict, List, Iterable, Optional
from docforge.nav.spec import NavSpec
class ResolvedNav:
"""
Fully-resolved navigation tree.
- `home` is a semantic, docs-rootrelative path (string)
- `groups` contain resolved filesystem Paths
- Order is preserved
"""
home: Optional[str]
groups: Dict[str, List[Path]]
def __init__(
self,
home: str | None,
groups: Dict[str, List[Path]],
docs_root: Path | None = None,
) -> None:
self._docs_root = None
...
def all_files(self) -> Iterable[Path]:
"""
Return all resolved documentation files in nav order.
Includes the home file (resolved against docs_root)
followed by all group files.
"""
...
def resolve_nav(
spec: NavSpec,
docs_root: Path,
) -> ResolvedNav:
"""
Resolve a NavSpec against a docs directory.
Expands wildcards, validates existence, and
resolves filesystem paths relative to docs_root.
Raises:
FileNotFoundError: if any referenced file is missing
ValueError: if resolution fails
"""
...

55
docforge/nav/spec.py Normal file
View File

@@ -0,0 +1,55 @@
from __future__ import annotations
from pathlib import Path
from typing import Dict, List, Optional
import yaml
class NavSpec:
"""Parsed representation of docforge.nav.yml."""
def __init__(
self,
home: Optional[str],
groups: Dict[str, List[str]],
) -> None:
self.home = home
self.groups = groups
@classmethod
def load(cls, 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 mapping")
home = data.get("home")
groups = data.get("groups", {})
if home is not None and not isinstance(home, str):
raise ValueError("home must be a string")
if not isinstance(groups, dict):
raise ValueError("groups must be a mapping")
for key, value in groups.items():
if not isinstance(key, str):
raise ValueError("group names must be strings")
if not isinstance(value, list) or not all(
isinstance(v, str) for v in value
):
raise ValueError(f"group '{key}' must be a list of strings")
return cls(home=home, groups=groups)
def all_patterns(self) -> List[str]:
patterns: List[str] = []
if self.home:
patterns.append(self.home)
for items in self.groups.values():
patterns.extend(items)
return patterns

32
docforge/nav/spec.pyi Normal file
View File

@@ -0,0 +1,32 @@
from pathlib import Path
from typing import Dict, List, Optional
class NavSpec:
"""
Parsed representation of `docforge.nav.yml`.
This object represents *semantic intent* and is independent
of filesystem structure or MkDocs specifics.
"""
home: Optional[str]
groups: Dict[str, List[str]]
@classmethod
def load(cls, path: Path) -> "NavSpec":
"""
Load and validate a nav specification from YAML.
Raises:
FileNotFoundError: if the file does not exist
ValueError: if the schema is invalid
"""
...
def all_patterns(self) -> List[str]:
"""
Return all path patterns referenced by the spec
(including home and group entries).
"""
...

0
tests/nav/__init__.py Normal file
View File

37
tests/nav/test_mkdocs.py Normal file
View File

@@ -0,0 +1,37 @@
from pathlib import Path
from docforge.nav import ResolvedNav
from docforge.nav import MkDocsNavEmitter
def test_emit_mkdocs_nav():
nav = ResolvedNav(
home="openapi_first/index.md",
groups={
"Core": [
Path("openapi_first/app.md"),
Path("openapi_first/client.md"),
],
"CLI": [
Path("openapi_first/cli.md"),
],
},
)
emitter = MkDocsNavEmitter()
mkdocs_nav = emitter.emit(nav)
assert mkdocs_nav == [
{"Home": "openapi_first/index.md"},
{
"Core": [
{"App": "openapi_first/app.md"},
{"Client": "openapi_first/client.md"},
]
},
{
"CLI": [
{"Cli": "openapi_first/cli.md"},
]
},
]

104
tests/nav/test_resolver.py Normal file
View File

@@ -0,0 +1,104 @@
from pathlib import Path
import pytest
from docforge.nav import NavSpec
from docforge.nav import resolve_nav
def _write_docs(root: Path, paths: list[str]) -> None:
for p in paths:
full = root / p
full.parent.mkdir(parents=True, exist_ok=True)
full.write_text(f"# {p}", encoding="utf-8")
def test_resolve_simple_nav(tmp_path: Path):
docs = tmp_path / "docs"
_write_docs(
docs,
[
"openapi_first/index.md",
"openapi_first/app.md",
"openapi_first/client.md",
],
)
spec = NavSpec(
home="openapi_first/index.md",
groups={
"Core": [
"openapi_first/app.md",
"openapi_first/client.md",
]
},
)
resolved = resolve_nav(spec, docs)
assert resolved.home == "openapi_first/index.md"
assert list(resolved.groups["Core"]) == [
docs / "openapi_first/app.md",
docs / "openapi_first/client.md",
]
def test_wildcard_expansion_preserves_order(tmp_path: Path):
docs = tmp_path / "docs"
_write_docs(
docs,
[
"pkg/a.md",
"pkg/b.md",
"pkg/c.md",
],
)
spec = NavSpec(
home=None,
groups={"All": ["pkg/*.md"]},
)
resolved = resolve_nav(spec, docs)
paths = [p.name for p in resolved.groups["All"]]
assert paths == ["a.md", "b.md", "c.md"]
def test_missing_doc_file_raises(tmp_path: Path):
docs = tmp_path / "docs"
docs.mkdir()
spec = NavSpec(
home="missing.md",
groups={},
)
with pytest.raises(FileNotFoundError):
resolve_nav(spec, docs)
def test_all_files_returns_flat_sequence(tmp_path: Path):
docs = tmp_path / "docs"
_write_docs(
docs,
[
"a.md",
"b.md",
"c.md",
],
)
spec = NavSpec(
home="a.md",
groups={"G": ["b.md", "c.md"]},
)
resolved = resolve_nav(spec, docs)
files = list(resolved.all_files())
assert files == [
docs / "a.md",
docs / "b.md",
docs / "c.md",
]

71
tests/nav/test_spec.py Normal file
View File

@@ -0,0 +1,71 @@
from pathlib import Path
import pytest
from docforge.nav import NavSpec
def test_load_valid_nav_spec(tmp_path: Path):
nav_yml = tmp_path / "docforge.nav.yml"
nav_yml.write_text(
"""
home: openapi_first/index.md
groups:
Core:
- openapi_first/app.md
- openapi_first/client.md
CLI:
- openapi_first/cli.md
""",
encoding="utf-8",
)
spec = NavSpec.load(nav_yml)
assert spec.home == "openapi_first/index.md"
assert "Core" in spec.groups
assert spec.groups["Core"] == [
"openapi_first/app.md",
"openapi_first/client.md",
]
def test_missing_file_raises(tmp_path: Path):
with pytest.raises(FileNotFoundError):
NavSpec.load(tmp_path / "missing.yml")
def test_invalid_schema_raises(tmp_path: Path):
nav_yml = tmp_path / "docforge.nav.yml"
nav_yml.write_text(
"""
groups:
- not_a_mapping
""",
encoding="utf-8",
)
with pytest.raises(ValueError):
NavSpec.load(nav_yml)
def test_all_patterns_includes_home_and_groups(tmp_path: Path):
nav_yml = tmp_path / "docforge.nav.yml"
nav_yml.write_text(
"""
home: a.md
groups:
X:
- b.md
- c/*.md
""",
encoding="utf-8",
)
spec = NavSpec.load(nav_yml)
patterns = spec.all_patterns()
assert "a.md" in patterns
assert "b.md" in patterns
assert "c/*.md" in patterns