feat: add wiki build kind with file-structure-derived navigation
- add build_wiki_nav deriving MkDocs nav from docs/wiki file structure (index.md -> Home, numeric prefixes stripped and title-cased, nested dirs become groups, natural ordering) - add --wiki / --wiki-dir to build; wiki-only builds need no --module - merge wiki nav before generated lib/api nav; wiki Home replaces the nav spec Home entry - add mkdocs.wiki.yml template fragment and nav/cli tests - dogfood doc-forge's own docs/wiki and regenerate site output
This commit is contained in:
@@ -29,6 +29,9 @@ def cli() -> None:
|
||||
@click.option("--mcp", is_flag=True, help="Build MCP resources")
|
||||
@click.option("--mkdocs", is_flag=True, help="Build MkDocs site")
|
||||
@click.option("--api", is_flag=True, help="Build API docs from an OpenAPI spec")
|
||||
@click.option(
|
||||
"--wiki", is_flag=True, help="Include a hand-written wiki in the MkDocs site"
|
||||
)
|
||||
@click.option(
|
||||
"--module-is-source",
|
||||
is_flag=True,
|
||||
@@ -48,6 +51,12 @@ def cli() -> None:
|
||||
default=Path("docs"),
|
||||
help="MkDocs documentation root",
|
||||
)
|
||||
@click.option(
|
||||
"--wiki-dir",
|
||||
type=click.Path(path_type=Path),
|
||||
default=Path("docs/wiki"),
|
||||
help="Hand-written wiki directory included in the MkDocs site",
|
||||
)
|
||||
@click.option(
|
||||
"--nav",
|
||||
"nav_file",
|
||||
@@ -74,12 +83,14 @@ def build(
|
||||
mcp: bool,
|
||||
mkdocs: bool,
|
||||
api: bool,
|
||||
wiki: bool,
|
||||
module_is_source: bool,
|
||||
module: str | None,
|
||||
openapi_spec: Path | None,
|
||||
project_name: str | None,
|
||||
site_name: str | None,
|
||||
docs_dir: Path,
|
||||
wiki_dir: Path,
|
||||
nav_file: Path,
|
||||
template: Path | None,
|
||||
mkdocs_yml: Path,
|
||||
@@ -96,6 +107,7 @@ def build(
|
||||
|
||||
- MkDocs static documentation sites for library reference docs
|
||||
- Swagger-enabled API docs generated from an OpenAPI spec
|
||||
- Hand-written wiki pages included in the MkDocs site
|
||||
- MCP structured documentation resources
|
||||
|
||||
Args:
|
||||
@@ -108,6 +120,9 @@ def build(
|
||||
api (bool):
|
||||
Enable API documentation generation from an OpenAPI spec.
|
||||
|
||||
wiki (bool):
|
||||
Include a hand-written wiki directory in the MkDocs site.
|
||||
|
||||
module_is_source (bool):
|
||||
Treat the specified module directory as the project root.
|
||||
|
||||
@@ -126,6 +141,9 @@ def build(
|
||||
docs_dir (Path):
|
||||
Shared documentation root used as the MkDocs ``docs_dir``.
|
||||
|
||||
wiki_dir (Path):
|
||||
Directory containing hand-written wiki markdown files.
|
||||
|
||||
nav_file (Path):
|
||||
Path to the navigation specification file.
|
||||
|
||||
@@ -142,13 +160,13 @@ def build(
|
||||
click.UsageError:
|
||||
If required options are missing or conflicting.
|
||||
"""
|
||||
if not mcp and not mkdocs and not api:
|
||||
raise click.UsageError("Must specify either --mcp, --mkdocs, or --api")
|
||||
if not mcp and not mkdocs and not api and not wiki:
|
||||
raise click.UsageError("Must specify either --mcp, --mkdocs, --wiki, or --api")
|
||||
|
||||
if api:
|
||||
if not openapi_spec:
|
||||
raise click.UsageError("--openapi-spec is required for API build")
|
||||
if site_name and not mkdocs:
|
||||
if site_name and not mkdocs and not wiki:
|
||||
raise click.UsageError(
|
||||
"--site-name cannot be overridden for API build; "
|
||||
"the OpenAPI spec provides the site name"
|
||||
@@ -181,16 +199,18 @@ def build(
|
||||
click.echo(f"Generating API sources in {api_dir}...")
|
||||
api_utils.generate_api_sources(spec, api_dir)
|
||||
|
||||
if mkdocs or api:
|
||||
if mkdocs or api or wiki:
|
||||
modes: list[str] = []
|
||||
if mkdocs:
|
||||
modes.append("lib")
|
||||
if api:
|
||||
modes.append("api")
|
||||
if wiki:
|
||||
modes.append("wiki")
|
||||
|
||||
site_description: str | None = None
|
||||
site_author: str | None = None
|
||||
effective_site_name = site_name or module
|
||||
effective_site_name = site_name or module or Path.cwd().name
|
||||
|
||||
if api:
|
||||
metadata = api_utils.derive_metadata(spec)
|
||||
@@ -208,6 +228,7 @@ def build(
|
||||
modes=modes,
|
||||
site_description=site_description,
|
||||
site_author=site_author,
|
||||
wiki_dir=wiki_dir if wiki else None,
|
||||
)
|
||||
|
||||
click.echo("Running MkDocs build...")
|
||||
|
||||
@@ -9,12 +9,14 @@ def build(
|
||||
mcp: bool,
|
||||
mkdocs: bool,
|
||||
api: bool,
|
||||
wiki: bool,
|
||||
module_is_source: bool,
|
||||
module: str | None,
|
||||
openapi_spec: Path | None,
|
||||
project_name: str | None,
|
||||
site_name: str | None,
|
||||
docs_dir: Path,
|
||||
wiki_dir: Path,
|
||||
nav_file: Path,
|
||||
template: Path | None,
|
||||
mkdocs_yml: Path,
|
||||
|
||||
@@ -13,7 +13,12 @@ import click
|
||||
import yaml
|
||||
|
||||
from docforge.loaders import GriffeLoader, discover_module_paths
|
||||
from docforge.nav import MkDocsNavEmitter, load_nav_spec, resolve_nav
|
||||
from docforge.nav import (
|
||||
MkDocsNavEmitter,
|
||||
build_wiki_nav,
|
||||
load_nav_spec,
|
||||
resolve_nav,
|
||||
)
|
||||
from docforge.renderers import MkDocsRenderer
|
||||
|
||||
|
||||
@@ -78,17 +83,20 @@ def generate_config(
|
||||
modes: Iterable[str] | None = None,
|
||||
site_description: str | None = None,
|
||||
site_author: str | None = None,
|
||||
wiki_dir: Path | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Generate an `mkdocs.yml` configuration file.
|
||||
|
||||
The configuration is created by combining a template configuration
|
||||
with a navigation structure derived from the docforge navigation
|
||||
specification.
|
||||
specification (and, when a wiki directory is provided, from the wiki
|
||||
file structure).
|
||||
|
||||
The ``docs_dir`` is always written relative to the MkDocs root and is
|
||||
expected to be the shared documentation parent (for example ``docs``),
|
||||
with generated sources nested under ``lib/`` or ``api/`` subdirectories.
|
||||
with generated sources nested under ``lib/`` or ``api/`` subdirectories
|
||||
and hand-written wiki content under a ``wiki/`` subdirectory.
|
||||
|
||||
Args:
|
||||
docs_dir (Path):
|
||||
@@ -110,8 +118,9 @@ def generate_config(
|
||||
|
||||
modes (Optional[Iterable[str]]):
|
||||
Documentation modes to enable. Each mode contributes its own
|
||||
built-in template fragment (for example ``lib`` or ``api``),
|
||||
merged on top of the shared ``mkdocs.common.yml`` template.
|
||||
built-in template fragment (for example ``lib``, ``api``, or
|
||||
``wiki``), merged on top of the shared ``mkdocs.common.yml``
|
||||
template.
|
||||
|
||||
site_description (Optional[str]):
|
||||
Optional site description written into the configuration.
|
||||
@@ -119,16 +128,34 @@ def generate_config(
|
||||
site_author (Optional[str]):
|
||||
Optional site author written into the configuration.
|
||||
|
||||
wiki_dir (Optional[Path]):
|
||||
Optional path to a hand-written wiki directory (for example
|
||||
``docs/wiki``). When provided, the site navigation is derived
|
||||
from the wiki file structure and placed before the navigation
|
||||
groups defined in ``nav_file``.
|
||||
|
||||
Raises:
|
||||
click.FileError:
|
||||
If the navigation specification or template file cannot be found.
|
||||
If the navigation specification, template, or wiki directory
|
||||
cannot be found.
|
||||
"""
|
||||
if not nav_file.exists():
|
||||
if not nav_file.exists() and wiki_dir is None:
|
||||
raise click.FileError(str(nav_file), hint="Nav spec not found")
|
||||
|
||||
spec = load_nav_spec(nav_file)
|
||||
resolved = resolve_nav(spec, docs_dir)
|
||||
nav_block = MkDocsNavEmitter().emit(resolved)
|
||||
nav_block: list[dict] = []
|
||||
if nav_file.exists():
|
||||
spec = load_nav_spec(nav_file)
|
||||
resolved = resolve_nav(spec, docs_dir)
|
||||
nav_block = MkDocsNavEmitter().emit(resolved)
|
||||
else:
|
||||
spec = None
|
||||
|
||||
if wiki_dir is not None:
|
||||
if not wiki_dir.exists():
|
||||
raise click.FileError(str(wiki_dir), hint="Wiki dir not found")
|
||||
wiki_nav = build_wiki_nav(wiki_dir)
|
||||
if wiki_nav:
|
||||
nav_block = wiki_nav + [entry for entry in nav_block if "Home" not in entry]
|
||||
|
||||
data = _load_template(template, modes)
|
||||
|
||||
@@ -140,7 +167,7 @@ def generate_config(
|
||||
data["docs_dir"] = Path(os.path.relpath(docs_dir, out.parent)).as_posix()
|
||||
data["nav"] = nav_block
|
||||
|
||||
if spec.icon:
|
||||
if spec is not None and spec.icon:
|
||||
theme = data.setdefault("theme", {})
|
||||
if not isinstance(theme, dict):
|
||||
theme = {}
|
||||
|
||||
@@ -17,6 +17,7 @@ def generate_config(
|
||||
modes: Iterable[str] | None = None,
|
||||
site_description: str | None = None,
|
||||
site_author: str | None = None,
|
||||
wiki_dir: Path | None = None,
|
||||
) -> None: ...
|
||||
def build(mkdocs_yml: Path) -> None: ...
|
||||
def serve(mkdocs_yml: Path) -> None: ...
|
||||
|
||||
@@ -26,11 +26,13 @@ independent of module hierarchy.
|
||||
from .spec import NavSpec, load_nav_spec
|
||||
from .resolver import ResolvedNav, resolve_nav
|
||||
from .mkdocs import MkDocsNavEmitter
|
||||
from .wiki import build_wiki_nav
|
||||
|
||||
__all__ = [
|
||||
"NavSpec",
|
||||
"ResolvedNav",
|
||||
"MkDocsNavEmitter",
|
||||
"build_wiki_nav",
|
||||
"resolve_nav",
|
||||
"load_nav_spec",
|
||||
]
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from .mkdocs import MkDocsNavEmitter
|
||||
from .resolver import ResolvedNav, resolve_nav
|
||||
from .spec import NavSpec, load_nav_spec
|
||||
from .wiki import build_wiki_nav
|
||||
|
||||
__all__ = [
|
||||
"NavSpec",
|
||||
"ResolvedNav",
|
||||
"MkDocsNavEmitter",
|
||||
"build_wiki_nav",
|
||||
"resolve_nav",
|
||||
"load_nav_spec",
|
||||
]
|
||||
|
||||
161
docforge/nav/wiki.py
Normal file
161
docforge/nav/wiki.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
# Summary
|
||||
|
||||
Wiki navigation derivation.
|
||||
|
||||
This module provides ``build_wiki_nav``, which derives an MkDocs-ready
|
||||
navigation block from the file structure of a hand-written wiki directory
|
||||
(typically ``docs/wiki``). wiki content is authored by hand and is never
|
||||
modified by doc-forge; only the navigation layout is inferred.
|
||||
|
||||
# Notes
|
||||
|
||||
- ``index.md`` at the wiki root becomes the ``Home`` entry.
|
||||
- Page labels are derived from filenames: numeric order prefixes such as
|
||||
``01_`` or ``02-`` are stripped, separators are replaced with spaces, and
|
||||
names are title-cased (``01_overview.md`` becomes ``Overview``).
|
||||
- Subdirectories become nested navigation groups. A nested ``index.md`` is
|
||||
rendered as the section root placed first inside the group.
|
||||
- Only ``.md`` files are considered; hidden entries are ignored.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_wiki_nav(wiki_dir: Path) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Derive an MkDocs navigation block from a wiki directory.
|
||||
|
||||
Returned paths are relative to the parent of ``wiki_dir`` and carry the
|
||||
wiki directory name as their leading component (for example
|
||||
``wiki/01_overview.md`` when the wiki lives at ``docs/wiki``). This makes
|
||||
the result directly usable in an MkDocs ``nav`` block with
|
||||
|
||||
- ``index.md`` at the wiki root becomes the ``Home`` entry.
|
||||
- Page labels are derived from filenames: numeric order prefixes such as
|
||||
``01_`` or ``02-`` are stripped, separators are replaced with spaces, and
|
||||
names are title-cased (``01_overview.md`` becomes ``Overview``).
|
||||
- Subdirectories become nested navigation groups. A nested ``index.md`` is
|
||||
rendered as the section root placed first inside the group.
|
||||
- Only ``.md`` files are considered; hidden entries are ignored.
|
||||
|
||||
Args:
|
||||
wiki_dir (Path):
|
||||
Path to the hand-written wiki directory, for example ``docs/wiki``.
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]:
|
||||
Navigation entries compatible with the MkDocs ``nav`` configuration.
|
||||
The list is empty if the wiki contains no Markdown files.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError:
|
||||
If the wiki directory does not exist.
|
||||
"""
|
||||
if not wiki_dir.exists():
|
||||
raise FileNotFoundError(wiki_dir)
|
||||
|
||||
root = wiki_dir.parent
|
||||
|
||||
def rel(path: Path) -> str:
|
||||
try:
|
||||
return path.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
return path.as_posix()
|
||||
|
||||
nav: list[dict[str, Any]] = []
|
||||
|
||||
if (wiki_dir / "index.md").exists():
|
||||
nav.append({"Home": rel(wiki_dir / "index.md")})
|
||||
|
||||
nav.extend(_render_entries(wiki_dir, rel))
|
||||
|
||||
return nav
|
||||
|
||||
|
||||
def _render_entries(base_dir: Path, rel) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Render navigation entries for the children of a wiki directory.
|
||||
|
||||
Markdown pages become labeled entries, subdirectories containing
|
||||
Markdown become nested groups, and a nested ``index.md`` is emitted
|
||||
first as the section root.
|
||||
|
||||
Args:
|
||||
base_dir (Path):
|
||||
Directory whose children are rendered.
|
||||
|
||||
rel:
|
||||
Callable converting a wiki file path into a docs-relative path.
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]:
|
||||
Navigation entries for ``base_dir`` in natural sort order.
|
||||
"""
|
||||
children = sorted(
|
||||
(child for child in base_dir.iterdir() if not child.name.startswith(".")),
|
||||
key=lambda child: _natural_key(child.name),
|
||||
)
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
|
||||
for child in children:
|
||||
if child.is_dir():
|
||||
if not any(child.rglob("*.md")):
|
||||
continue
|
||||
|
||||
group: list[dict[str, Any]] = []
|
||||
nested_index = child / "index.md"
|
||||
if nested_index.exists():
|
||||
group.append({_prettify(child.name): rel(nested_index)})
|
||||
group.extend(_render_entries(child, rel))
|
||||
entries.append({_prettify(child.name): group})
|
||||
elif child.suffix == ".md":
|
||||
if child.name == "index.md":
|
||||
continue
|
||||
entries.append({_prettify(child.stem): rel(child)})
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def _prettify(name: str) -> str:
|
||||
"""
|
||||
Convert a filename or directory name into a navigation label.
|
||||
|
||||
Numeric order prefixes (``01_``, ``02-``, ``03.``) are stripped,
|
||||
separators are replaced with spaces, and the result is title-cased.
|
||||
|
||||
Args:
|
||||
name (str):
|
||||
Filename stem or directory name to prettify.
|
||||
|
||||
Returns:
|
||||
str:
|
||||
Title-cased navigation label.
|
||||
"""
|
||||
name = re.sub(r"^\d+[_\-. ]?", "", name)
|
||||
name = re.sub(r"[_-]+", " ", name).strip()
|
||||
return name.title()
|
||||
|
||||
|
||||
def _natural_key(name: str) -> list[object]:
|
||||
"""
|
||||
Build a natural sort key from a filename.
|
||||
|
||||
The key splits ``name`` into alternating non-digit and digit parts so
|
||||
that numeric prefixes sort numerically (``02`` before ``10``).
|
||||
|
||||
Args:
|
||||
name (str):
|
||||
Filename or directory name to key.
|
||||
|
||||
Returns:
|
||||
List[object]:
|
||||
Mixed list of lowercased strings and integers used for sorting.
|
||||
"""
|
||||
return [
|
||||
int(part) if part.isdigit() else part.lower()
|
||||
for part in re.split(r"(\d+)", name)
|
||||
]
|
||||
15
docforge/nav/wiki.pyi
Normal file
15
docforge/nav/wiki.pyi
Normal file
@@ -0,0 +1,15 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
def build_wiki_nav(wiki_dir: Path) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Derive an MkDocs navigation block from a wiki directory.
|
||||
|
||||
Returns:
|
||||
Wiki navigation entries compatible with the MkDocs
|
||||
`nav` configuration.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: if the wiki directory does not exist
|
||||
"""
|
||||
...
|
||||
2
docforge/templates/mkdocs.wiki.yml
Normal file
2
docforge/templates/mkdocs.wiki.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
plugins:
|
||||
- search
|
||||
3
docs/lib/docforge/cli/api_utils.md
Normal file
3
docs/lib/docforge/cli/api_utils.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Api Utils
|
||||
|
||||
::: docforge.cli.api_utils
|
||||
3
docs/lib/docforge/cli/commands.md
Normal file
3
docs/lib/docforge/cli/commands.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Commands
|
||||
|
||||
::: docforge.cli.commands
|
||||
8
docs/lib/docforge/cli/index.md
Normal file
8
docs/lib/docforge/cli/index.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Cli
|
||||
|
||||
::: docforge.cli
|
||||
- [Api Utils](api_utils.md)
|
||||
- [Commands](commands.md)
|
||||
- [Main](main.md)
|
||||
- [Mcp Utils](mcp_utils.md)
|
||||
- [Mkdocs Utils](mkdocs_utils.md)
|
||||
3
docs/lib/docforge/cli/main.md
Normal file
3
docs/lib/docforge/cli/main.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Main
|
||||
|
||||
::: docforge.cli.main
|
||||
3
docs/lib/docforge/cli/mcp_utils.md
Normal file
3
docs/lib/docforge/cli/mcp_utils.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Mcp Utils
|
||||
|
||||
::: docforge.cli.mcp_utils
|
||||
3
docs/lib/docforge/cli/mkdocs_utils.md
Normal file
3
docs/lib/docforge/cli/mkdocs_utils.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Mkdocs Utils
|
||||
|
||||
::: docforge.cli.mkdocs_utils
|
||||
9
docs/lib/docforge/index.md
Normal file
9
docs/lib/docforge/index.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Docforge
|
||||
|
||||
::: docforge
|
||||
- [Cli](cli/)
|
||||
- [Loaders](loaders/)
|
||||
- [Models](models/)
|
||||
- [Nav](nav/)
|
||||
- [Renderers](renderers/)
|
||||
- [Servers](servers/)
|
||||
3
docs/lib/docforge/loaders/griffe_loader.md
Normal file
3
docs/lib/docforge/loaders/griffe_loader.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Griffe Loader
|
||||
|
||||
::: docforge.loaders.griffe_loader
|
||||
4
docs/lib/docforge/loaders/index.md
Normal file
4
docs/lib/docforge/loaders/index.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Loaders
|
||||
|
||||
::: docforge.loaders
|
||||
- [Griffe Loader](griffe_loader.md)
|
||||
6
docs/lib/docforge/models/index.md
Normal file
6
docs/lib/docforge/models/index.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Models
|
||||
|
||||
::: docforge.models
|
||||
- [Module](module.md)
|
||||
- [Object](object.md)
|
||||
- [Project](project.md)
|
||||
3
docs/lib/docforge/models/module.md
Normal file
3
docs/lib/docforge/models/module.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Module
|
||||
|
||||
::: docforge.models.module
|
||||
3
docs/lib/docforge/models/object.md
Normal file
3
docs/lib/docforge/models/object.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Object
|
||||
|
||||
::: docforge.models.object
|
||||
3
docs/lib/docforge/models/project.md
Normal file
3
docs/lib/docforge/models/project.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Project
|
||||
|
||||
::: docforge.models.project
|
||||
7
docs/lib/docforge/nav/index.md
Normal file
7
docs/lib/docforge/nav/index.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Nav
|
||||
|
||||
::: docforge.nav
|
||||
- [Mkdocs](mkdocs.md)
|
||||
- [Resolver](resolver.md)
|
||||
- [Spec](spec.md)
|
||||
- [Wiki](wiki.md)
|
||||
3
docs/lib/docforge/nav/mkdocs.md
Normal file
3
docs/lib/docforge/nav/mkdocs.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Mkdocs
|
||||
|
||||
::: docforge.nav.mkdocs
|
||||
3
docs/lib/docforge/nav/resolver.md
Normal file
3
docs/lib/docforge/nav/resolver.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Resolver
|
||||
|
||||
::: docforge.nav.resolver
|
||||
3
docs/lib/docforge/nav/spec.md
Normal file
3
docs/lib/docforge/nav/spec.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Spec
|
||||
|
||||
::: docforge.nav.spec
|
||||
3
docs/lib/docforge/nav/wiki.md
Normal file
3
docs/lib/docforge/nav/wiki.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Wiki
|
||||
|
||||
::: docforge.nav.wiki
|
||||
3
docs/lib/docforge/renderers/base.md
Normal file
3
docs/lib/docforge/renderers/base.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Base
|
||||
|
||||
::: docforge.renderers.base
|
||||
6
docs/lib/docforge/renderers/index.md
Normal file
6
docs/lib/docforge/renderers/index.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Renderers
|
||||
|
||||
::: docforge.renderers
|
||||
- [Base](base.md)
|
||||
- [Mcp Renderer](mcp_renderer.md)
|
||||
- [Mkdocs Renderer](mkdocs_renderer.md)
|
||||
3
docs/lib/docforge/renderers/mcp_renderer.md
Normal file
3
docs/lib/docforge/renderers/mcp_renderer.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Mcp Renderer
|
||||
|
||||
::: docforge.renderers.mcp_renderer
|
||||
3
docs/lib/docforge/renderers/mkdocs_renderer.md
Normal file
3
docs/lib/docforge/renderers/mkdocs_renderer.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Mkdocs Renderer
|
||||
|
||||
::: docforge.renderers.mkdocs_renderer
|
||||
4
docs/lib/docforge/servers/index.md
Normal file
4
docs/lib/docforge/servers/index.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Servers
|
||||
|
||||
::: docforge.servers
|
||||
- [Mcp Server](mcp_server.md)
|
||||
3
docs/lib/docforge/servers/mcp_server.md
Normal file
3
docs/lib/docforge/servers/mcp_server.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Mcp Server
|
||||
|
||||
::: docforge.servers.mcp_server
|
||||
@@ -1,3 +1,4 @@
|
||||
# docforge
|
||||
|
||||
::: docforge
|
||||
- [Docforge](docforge/)
|
||||
|
||||
25
docs/wiki/01_overview.md
Normal file
25
docs/wiki/01_overview.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Overview
|
||||
|
||||
`docforge` turns GSDFC-compliant Python docstrings into maintainable reference
|
||||
documentation. It never edits source docstrings; it reads them, renders them,
|
||||
and assembles a single MkDocs site from all available material.
|
||||
|
||||
## What it generates
|
||||
|
||||
| Kind | Source | Output |
|
||||
|-----------|----------------------------|---------------------------------|
|
||||
| `lib` | GSDFC docstrings | `docs/lib/**` rendered markdown |
|
||||
| `api` | OpenAPI JSON spec | `docs/api/**` rendered markdown |
|
||||
| `wiki` | Hand-written markdown | `docs/wiki/**` (unchanged) |
|
||||
| `mcp` | Griffe + renderers | `docs/mcp/**` structured files |
|
||||
|
||||
## Combined build
|
||||
|
||||
One `mkdocs.yml` and one MkDocs build serve all kinds:
|
||||
|
||||
1. Wiki navigation is derived from the `docs/wiki/` file structure.
|
||||
2. Generated library/API navigation is appended after it.
|
||||
3. The wiki `index.md` becomes the site `Home`.
|
||||
|
||||
Hand-written wiki content is never overwritten or regenerated — only its
|
||||
navigation is derived automatically.
|
||||
34
docs/wiki/02_architecture.md
Normal file
34
docs/wiki/02_architecture.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Architecture
|
||||
|
||||
`docforge` is split into four horizontal layers. Everything flows top to
|
||||
bottom through the CLI.
|
||||
|
||||
## Loaders → Models
|
||||
|
||||
The `docforge/loaders` package wraps `griffe` to extract modules, functions,
|
||||
classes, and Google-style docstring sections. Loaded data is normalized into
|
||||
the object model under `docforge/models` (`Module`, `Object`, `Project`,
|
||||
`Field`).
|
||||
|
||||
## Navigation
|
||||
|
||||
`docforge/nav` parses `docforge.nav.yml` specs (`NavSpec`, `Resolver`, and the
|
||||
MkDocs nav emitter) and, since the wiki kind, derives wiki navigation from the
|
||||
file structure via `build_wiki_nav`.
|
||||
|
||||
## Renderers
|
||||
|
||||
`docforge/renderers` turn model data into artifacts:
|
||||
|
||||
- `MkDocsRenderer` → `docs/lib/**` reference markdown
|
||||
- `MCPRenderer` → `docs/mcp/**` structured documentation
|
||||
|
||||
## CLI
|
||||
|
||||
`docforge/cli` wires it all together:
|
||||
|
||||
- `commands.py` — the `build` command and its `--mkdocs` / `--api` / `--wiki`
|
||||
/ `--mcp` modes
|
||||
- `mkdocs_utils.py` — config generation (`mkdocs.yml`) including merged
|
||||
wiki + lib + api navigation
|
||||
- `api_utils.py` — OpenAPI loading and API docs generation
|
||||
29
docs/wiki/03_conventions.md
Normal file
29
docs/wiki/03_conventions.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Conventions
|
||||
|
||||
## GSDFC docstrings
|
||||
|
||||
All documented source uses the Google-Styled Doc-Forge Convention (GSDFC):
|
||||
`Args:`, `Returns:`, `Raises:`, and `Attributes:` sections with properly typed
|
||||
signatures.
|
||||
|
||||
## `.pyi` stubs
|
||||
|
||||
Every module ships a matching `.pyi` stub kept in sync with the `.py`
|
||||
implementation. When signatures change, update both files.
|
||||
|
||||
## Wiki pages
|
||||
|
||||
- Hand-written markdown lives in `docs/wiki/**` and is never generated.
|
||||
- File names use a numeric prefix: `01_overview.md`, `02_components.md`.
|
||||
- Nested directories become nested navigation groups:
|
||||
`05_development/01_environment.md` → group *Development*.
|
||||
- `index.md` is the site `Home` at the root, and a section root inside a
|
||||
directory.
|
||||
- Navigation labels are derived by stripping the numeric prefix and applying
|
||||
title case on the remaining words.
|
||||
|
||||
## Templates
|
||||
|
||||
MkDocs config fragments live in `docforge/templates`. The `mkdocs.wiki.yml`
|
||||
fragment carries only the `search` plugin, since wiki pages contain no
|
||||
mkdocstrings directives.
|
||||
33
docs/wiki/04_iterative_workflow.md
Normal file
33
docs/wiki/04_iterative_workflow.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Iterative Workflow
|
||||
|
||||
A docs build runs through the CLI in a single pass.
|
||||
|
||||
## Build commands
|
||||
|
||||
```bash
|
||||
# Library reference only
|
||||
doc-forge build --mkdocs --module docforge
|
||||
|
||||
# Wik + library combined (single MkDocs build)
|
||||
doc-forge build --wiki --mkdocs --module docforge
|
||||
|
||||
# Wiki only — no module required
|
||||
doc-forge build --wiki --site-name docforge
|
||||
```
|
||||
|
||||
## What a combined build does
|
||||
|
||||
1. Validates the requested modes (`--mkdocs`, `--api`, `--wiki`, `--mcp`).
|
||||
2. Generates library sources under `docs/lib/**` with `MkDocsRenderer`.
|
||||
3. Generates API sources under `docs/api/**` when `--api` is given.
|
||||
4. Derives wiki navigation from `docs/wiki/**`.
|
||||
5. Writes `mkdocs.yml` with merged navigation — wiki first, generated groups
|
||||
appended, and the wiki `Home` replacing any spec `Home` entry.
|
||||
6. Runs `mkdocs build` once and emits the site.
|
||||
|
||||
## Explore the site
|
||||
|
||||
```bash
|
||||
doc-forge build --wiki --mkdocs --module docforge
|
||||
doc-forge serve --mkdocs-yml mkdocs.yml
|
||||
```
|
||||
20
docs/wiki/05_development/01_environment.md
Normal file
20
docs/wiki/05_development/01_environment.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Environment Setup
|
||||
|
||||
## Create the environment
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
Requires Python 3.11+.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Development extras include:
|
||||
|
||||
- `pytest`, `pytest-cov` — test suite and coverage
|
||||
- `ruff` — lint and format checking
|
||||
- `black` — auto-formatting
|
||||
- `mypy` — strict typing checks
|
||||
22
docs/wiki/05_development/02_quality_gates.md
Normal file
22
docs/wiki/05_development/02_quality_gates.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Quality Gates
|
||||
|
||||
Run all checks before pushing:
|
||||
|
||||
```bash
|
||||
.venv\Scripts\python.exe -m pytest
|
||||
.venv\Scripts\python.exe -m ruff check docforge tests
|
||||
.venv\Scripts\python.exe -m black --check docforge tests
|
||||
.venv\Scripts\python.exe -m mypy docforge
|
||||
```
|
||||
|
||||
## Test layout
|
||||
|
||||
| Path | Covers |
|
||||
|--------------------------|------------------------------------|
|
||||
| `tests/nav/` | Nav spec, resolver, wiki nav |
|
||||
| `tests/cli/` | Build command flows and modes |
|
||||
| `tests/renderers/` | MkDocs / MCP rendering |
|
||||
|
||||
CLI tests use the `cli_runner` fixture with `mock_mkdocs_build` and
|
||||
`mock_mkdocs_load_config` so they exercise the full flow without invoking a
|
||||
real MkDocs build.
|
||||
39
docs/wiki/index.md
Normal file
39
docs/wiki/index.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 🔨 docforge — Docstring-Driven Documentation Forge
|
||||
|
||||
`docforge` is an internal documentation tool that generates reference
|
||||
documentation from Google-Styled Doc-Forge Convention (GSDFC) docstrings and
|
||||
assembles it into a single MkDocs site, alongside hand-written wiki pages and
|
||||
OpenAPI-based API docs.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Key Features
|
||||
|
||||
- 🧪 GSDFC docstring extraction via Griffe
|
||||
- 🧭 Auto-derived navigation for hand-written wiki pages
|
||||
- 🗂 Combined MkDocs build: wiki first, then generated library/API reference
|
||||
- 🔌 Material MkDocs theming out of the box
|
||||
- 📦 MCP structured documentation resources
|
||||
|
||||
---
|
||||
|
||||
## 📁 Documentation Structure
|
||||
|
||||
| Section | Description |
|
||||
|----------------------------------------------| -------------------------------------------------- |
|
||||
| [Overview](01_overview.md) | What docforge is and how it fits the pipeline |
|
||||
| [Architecture](02_architecture.md) | Loaders, models, nav, renderers, CLI |
|
||||
| [Conventions](03_conventions.md) | GSDFC, `.pyi` stubs, and template conventions |
|
||||
| [Iterative Workflow](04_iterative_workflow.md) | How a docs build runs end to end |
|
||||
| [Development](05_development/01_environment.md) | Environment setup and quality gates |
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Resources
|
||||
|
||||
- **Source Code:** `C:\Users\vishe\WorkSpace\code\aetos\doc-forge`
|
||||
- **Wiki Kind:** hand-written content lives in `docs/wiki/`
|
||||
|
||||
---
|
||||
|
||||
© Aetoskia Internal
|
||||
@@ -69,7 +69,14 @@ plugins:
|
||||
site_name: docforge
|
||||
docs_dir: docs
|
||||
nav:
|
||||
- Home: lib/index.md
|
||||
- Home: wiki/index.md
|
||||
- Overview: wiki/01_overview.md
|
||||
- Architecture: wiki/02_architecture.md
|
||||
- Conventions: wiki/03_conventions.md
|
||||
- Iterative Workflow: wiki/04_iterative_workflow.md
|
||||
- Development:
|
||||
- Environment: wiki/05_development/01_environment.md
|
||||
- Quality Gates: wiki/05_development/02_quality_gates.md
|
||||
- Loaders:
|
||||
- lib/loaders/index.md
|
||||
- lib/loaders/griffe_loader.md
|
||||
|
||||
137
tests/cli/test_build_wiki.py
Normal file
137
tests/cli/test_build_wiki.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from pathlib import Path
|
||||
|
||||
from docforge.cli.main import cli
|
||||
|
||||
|
||||
def test_wiki_only_build_requires_no_module(
|
||||
cli_runner,
|
||||
mock_mkdocs_build,
|
||||
mock_mkdocs_load_config,
|
||||
):
|
||||
with cli_runner.isolated_filesystem():
|
||||
cwd = Path.cwd()
|
||||
|
||||
wiki = cwd / "docs" / "wiki"
|
||||
wiki.mkdir(parents=True)
|
||||
(wiki / "index.md").write_text("# Home", encoding="utf-8")
|
||||
(wiki / "01_overview.md").write_text("# Overview", encoding="utf-8")
|
||||
|
||||
result = cli_runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"build",
|
||||
"--wiki",
|
||||
"--site-name",
|
||||
"Wiki Site",
|
||||
"--mkdocs-yml",
|
||||
"mkdocs.yml",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert mock_mkdocs_build() is True
|
||||
assert (cwd / "mkdocs.yml").exists()
|
||||
|
||||
content = (cwd / "mkdocs.yml").read_text(encoding="utf-8")
|
||||
assert "docs_dir: docs" in content
|
||||
assert "Home: wiki/index.md" in content
|
||||
assert "Overview: wiki/01_overview.md" in content
|
||||
|
||||
|
||||
def test_wiki_combined_with_mkdocs_keeps_lib_nav(
|
||||
cli_runner,
|
||||
mock_mkdocs_build,
|
||||
mock_mkdocs_load_config,
|
||||
):
|
||||
with cli_runner.isolated_filesystem():
|
||||
cwd = Path.cwd()
|
||||
|
||||
pkg = cwd / "testpkg"
|
||||
pkg.mkdir()
|
||||
(pkg / "__init__.py").write_text("")
|
||||
(pkg / "mod.py").write_text("def f(): ...\n")
|
||||
|
||||
nav_file = cwd / "docforge.nav.yml"
|
||||
nav_file.write_text(
|
||||
"home: lib/testpkg/index.md\ngroups: {}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
wiki = cwd / "docs" / "wiki"
|
||||
wiki.mkdir(parents=True)
|
||||
(wiki / "index.md").write_text("# Home", encoding="utf-8")
|
||||
|
||||
result = cli_runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"build",
|
||||
"--mkdocs",
|
||||
"--wiki",
|
||||
"--module",
|
||||
"testpkg",
|
||||
"--site-name",
|
||||
"Combined Site",
|
||||
"--mkdocs-yml",
|
||||
"mkdocs.yml",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert mock_mkdocs_build() is True
|
||||
assert (cwd / "docs" / "lib" / "testpkg" / "mod.md").exists()
|
||||
|
||||
content = (cwd / "mkdocs.yml").read_text(encoding="utf-8")
|
||||
assert "docs_dir: docs" in content
|
||||
assert "Home: wiki/index.md" in content
|
||||
# Wiki home replaces the lib home entry
|
||||
assert "Home: lib/testpkg/index.md" not in content
|
||||
|
||||
|
||||
def test_wiki_without_module_flag_ok(
|
||||
cli_runner,
|
||||
mock_mkdocs_build,
|
||||
mock_mkdocs_load_config,
|
||||
):
|
||||
with cli_runner.isolated_filesystem():
|
||||
cwd = Path.cwd()
|
||||
|
||||
wiki = cwd / "docs" / "wiki"
|
||||
wiki.mkdir(parents=True)
|
||||
(wiki / "index.md").write_text("# Home", encoding="utf-8")
|
||||
|
||||
result = cli_runner.invoke(
|
||||
cli,
|
||||
["build", "--wiki", "--site-name", "S"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert mock_mkdocs_build() is True
|
||||
|
||||
|
||||
def test_missing_wiki_dir_errors(cli_runner, mock_mkdocs_build):
|
||||
with cli_runner.isolated_filesystem():
|
||||
cwd = Path.cwd()
|
||||
(cwd / "docs").mkdir()
|
||||
|
||||
result = cli_runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"build",
|
||||
"--wiki",
|
||||
"--site-name",
|
||||
"S",
|
||||
"--wiki-dir",
|
||||
"docs/wiki",
|
||||
"--mkdocs-yml",
|
||||
"mkdocs.yml",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Wiki dir not found" in result.output
|
||||
|
||||
|
||||
def test_no_flag_raises_usage_error(cli_runner):
|
||||
result = cli_runner.invoke(cli, ["build"])
|
||||
assert result.exit_code != 0
|
||||
assert "--mcp, --mkdocs, --wiki, or --api" in result.output
|
||||
106
tests/nav/test_wiki.py
Normal file
106
tests/nav/test_wiki.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from docforge.nav import build_wiki_nav
|
||||
|
||||
|
||||
def _write_wiki(root: Path, files: list[str]) -> None:
|
||||
for rel in files:
|
||||
full = root / rel
|
||||
full.parent.mkdir(parents=True, exist_ok=True)
|
||||
full.write_text(f"# {rel}", encoding="utf-8")
|
||||
|
||||
|
||||
def test_missing_wiki_dir_raises(tmp_path: Path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
build_wiki_nav(tmp_path / "nope")
|
||||
|
||||
|
||||
def test_empty_wiki_dir_returns_empty(tmp_path: Path):
|
||||
wiki = tmp_path / "wiki"
|
||||
wiki.mkdir()
|
||||
|
||||
assert build_wiki_nav(wiki) == []
|
||||
|
||||
|
||||
def test_index_becomes_home(tmp_path: Path):
|
||||
wiki = tmp_path / "docs" / "wiki"
|
||||
_write_wiki(wiki, ["index.md", "01_overview.md"])
|
||||
|
||||
nav = build_wiki_nav(wiki)
|
||||
|
||||
assert nav[0] == {"Home": "wiki/index.md"}
|
||||
assert {"Overview": "wiki/01_overview.md"} in nav
|
||||
|
||||
|
||||
def test_number_prefix_stripped_and_prettified(tmp_path: Path):
|
||||
wiki = tmp_path / "docs" / "wiki"
|
||||
_write_wiki(wiki, ["01_basic_crud.md", "02_custom_repo.md", "10_transactions.md"])
|
||||
|
||||
nav = build_wiki_nav(wiki)
|
||||
|
||||
labels = [list(entry)[0] for entry in nav]
|
||||
assert labels == ["Basic Crud", "Custom Repo", "Transactions"]
|
||||
|
||||
|
||||
def test_directory_becomes_nested_group(tmp_path: Path):
|
||||
wiki = tmp_path / "docs" / "wiki"
|
||||
_write_wiki(
|
||||
wiki,
|
||||
[
|
||||
"index.md",
|
||||
"03_use_cases/01_basic_crud.md",
|
||||
"03_use_cases/02_custom_repo.md",
|
||||
],
|
||||
)
|
||||
|
||||
nav = build_wiki_nav(wiki)
|
||||
|
||||
assert {"Home": "wiki/index.md"} in nav
|
||||
assert {
|
||||
"Use Cases": [
|
||||
{"Basic Crud": "wiki/03_use_cases/01_basic_crud.md"},
|
||||
{"Custom Repo": "wiki/03_use_cases/02_custom_repo.md"},
|
||||
]
|
||||
} in nav
|
||||
|
||||
|
||||
def test_nested_index_is_section_root(tmp_path: Path):
|
||||
wiki = tmp_path / "docs" / "wiki"
|
||||
_write_wiki(
|
||||
wiki,
|
||||
[
|
||||
"guide/index.md",
|
||||
"guide/01_setup.md",
|
||||
],
|
||||
)
|
||||
|
||||
nav = build_wiki_nav(wiki)
|
||||
|
||||
assert {
|
||||
"Guide": [
|
||||
{"Guide": "wiki/guide/index.md"},
|
||||
{"Setup": "wiki/guide/01_setup.md"},
|
||||
]
|
||||
} in nav
|
||||
|
||||
|
||||
def test_hidden_entries_ignored(tmp_path: Path):
|
||||
wiki = tmp_path / "docs" / "wiki"
|
||||
_write_wiki(wiki, ["index.md", ".draft.md"])
|
||||
(wiki / ".hidden").mkdir()
|
||||
|
||||
nav = build_wiki_nav(wiki)
|
||||
|
||||
assert nav == [{"Home": "wiki/index.md"}]
|
||||
|
||||
|
||||
def test_natural_order_for_zero_padded_prefixes(tmp_path: Path):
|
||||
wiki = tmp_path / "docs" / "wiki"
|
||||
_write_wiki(wiki, ["02_b.md", "10_j.md", "01_a.md"])
|
||||
|
||||
nav = build_wiki_nav(wiki)
|
||||
|
||||
labels = [list(entry)[0] for entry in nav if list(entry)[0] in {"A", "B", "J"}]
|
||||
assert labels == ["A", "B", "J"]
|
||||
Reference in New Issue
Block a user