nav submodule
This commit is contained in:
10
docforge/nav/__init__.py
Normal file
10
docforge/nav/__init__.py
Normal 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
10
docforge/nav/__init__.pyi
Normal 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
24
docforge/nav/mkdocs.py
Normal 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
27
docforge/nav/mkdocs.pyi
Normal 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
71
docforge/nav/resolver.py
Normal 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
52
docforge/nav/resolver.pyi
Normal 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-root–relative 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
55
docforge/nav/spec.py
Normal 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
32
docforge/nav/spec.pyi
Normal 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
0
tests/nav/__init__.py
Normal file
37
tests/nav/test_mkdocs.py
Normal file
37
tests/nav/test_mkdocs.py
Normal 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
104
tests/nav/test_resolver.py
Normal 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
71
tests/nav/test_spec.py
Normal 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
|
||||||
Reference in New Issue
Block a user