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