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:
2026-09-11 23:38:23 +05:30
parent bacf17b930
commit 8c6c46caf2
44 changed files with 798 additions and 17 deletions

View File

@@ -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...")

View File

@@ -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,

View File

@@ -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 = {}

View File

@@ -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: ...

View File

@@ -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",
]

View File

@@ -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
View 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
View 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
"""
...

View File

@@ -0,0 +1,2 @@
plugins:
- search