From 726e7ca6d2ce7109459dbf95b3d5876429ca0446 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Tue, 20 Jan 2026 21:22:28 +0530 Subject: [PATCH] nav submodule --- docforge/nav/__init__.py | 10 ++++ docforge/nav/__init__.pyi | 10 ++++ docforge/nav/mkdocs.py | 24 +++++++++ docforge/nav/mkdocs.pyi | 27 ++++++++++ docforge/nav/resolver.py | 71 +++++++++++++++++++++++++ docforge/nav/resolver.pyi | 52 +++++++++++++++++++ docforge/nav/spec.py | 55 ++++++++++++++++++++ docforge/nav/spec.pyi | 32 ++++++++++++ tests/nav/__init__.py | 0 tests/nav/test_mkdocs.py | 37 +++++++++++++ tests/nav/test_resolver.py | 104 +++++++++++++++++++++++++++++++++++++ tests/nav/test_spec.py | 71 +++++++++++++++++++++++++ 12 files changed, 493 insertions(+) create mode 100644 docforge/nav/__init__.py create mode 100644 docforge/nav/__init__.pyi create mode 100644 docforge/nav/mkdocs.py create mode 100644 docforge/nav/mkdocs.pyi create mode 100644 docforge/nav/resolver.py create mode 100644 docforge/nav/resolver.pyi create mode 100644 docforge/nav/spec.py create mode 100644 docforge/nav/spec.pyi create mode 100644 tests/nav/__init__.py create mode 100644 tests/nav/test_mkdocs.py create mode 100644 tests/nav/test_resolver.py create mode 100644 tests/nav/test_spec.py diff --git a/docforge/nav/__init__.py b/docforge/nav/__init__.py new file mode 100644 index 0000000..1c16dd3 --- /dev/null +++ b/docforge/nav/__init__.py @@ -0,0 +1,10 @@ +from .spec import NavSpec +from .resolver import ResolvedNav, resolve_nav +from .mkdocs import MkDocsNavEmitter + +__all__ = [ + "NavSpec", + "ResolvedNav", + "MkDocsNavEmitter", + "resolve_nav", +] diff --git a/docforge/nav/__init__.pyi b/docforge/nav/__init__.pyi new file mode 100644 index 0000000..1c16dd3 --- /dev/null +++ b/docforge/nav/__init__.pyi @@ -0,0 +1,10 @@ +from .spec import NavSpec +from .resolver import ResolvedNav, resolve_nav +from .mkdocs import MkDocsNavEmitter + +__all__ = [ + "NavSpec", + "ResolvedNav", + "MkDocsNavEmitter", + "resolve_nav", +] diff --git a/docforge/nav/mkdocs.py b/docforge/nav/mkdocs.py new file mode 100644 index 0000000..9867729 --- /dev/null +++ b/docforge/nav/mkdocs.py @@ -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 diff --git a/docforge/nav/mkdocs.pyi b/docforge/nav/mkdocs.pyi new file mode 100644 index 0000000..5a25581 --- /dev/null +++ b/docforge/nav/mkdocs.pyi @@ -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"}, + ... + ] + } + ] + """ + ... diff --git a/docforge/nav/resolver.py b/docforge/nav/resolver.py new file mode 100644 index 0000000..1bc291f --- /dev/null +++ b/docforge/nav/resolver.py @@ -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, + ) diff --git a/docforge/nav/resolver.pyi b/docforge/nav/resolver.pyi new file mode 100644 index 0000000..e0c4001 --- /dev/null +++ b/docforge/nav/resolver.pyi @@ -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 + """ + ... diff --git a/docforge/nav/spec.py b/docforge/nav/spec.py new file mode 100644 index 0000000..c824233 --- /dev/null +++ b/docforge/nav/spec.py @@ -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 diff --git a/docforge/nav/spec.pyi b/docforge/nav/spec.pyi new file mode 100644 index 0000000..f618d17 --- /dev/null +++ b/docforge/nav/spec.pyi @@ -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). + """ + ... diff --git a/tests/nav/__init__.py b/tests/nav/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/nav/test_mkdocs.py b/tests/nav/test_mkdocs.py new file mode 100644 index 0000000..20d054a --- /dev/null +++ b/tests/nav/test_mkdocs.py @@ -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"}, + ] + }, + ] diff --git a/tests/nav/test_resolver.py b/tests/nav/test_resolver.py new file mode 100644 index 0000000..260d95a --- /dev/null +++ b/tests/nav/test_resolver.py @@ -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", + ] diff --git a/tests/nav/test_spec.py b/tests/nav/test_spec.py new file mode 100644 index 0000000..47565d0 --- /dev/null +++ b/tests/nav/test_spec.py @@ -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