454 lines
13 KiB
Python
454 lines
13 KiB
Python
"""
|
|
# Summary
|
|
|
|
Utilities for working with MkDocs in the doc-forge CLI.
|
|
|
|
---
|
|
|
|
Notes:
|
|
- A separate `mkdocs.{kind}.yml` configuration and build is emitted per
|
|
enabled kind (lib, api, wiki), each scoped to its own `docs_dir` and
|
|
written into its own `site_dir` (`site/lib`, `site/api`, `site/wiki`).
|
|
- Navigation blocks are re-rooted per kind: the wiki navigation drops its
|
|
leading `wiki/` scope and the resolved nav spec drops its `lib/` scope.
|
|
|
|
---
|
|
"""
|
|
|
|
from collections.abc import Iterable
|
|
from importlib import resources
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
import click
|
|
import yaml
|
|
|
|
from docforge.loaders import GriffeLoader, discover_module_paths
|
|
from docforge.nav import (
|
|
MkDocsNavEmitter,
|
|
build_wiki_nav,
|
|
load_nav_spec,
|
|
resolve_nav,
|
|
)
|
|
from docforge.renderers import MkDocsRenderer
|
|
|
|
|
|
def generate_sources(
|
|
module: str,
|
|
docs_dir: Path,
|
|
project_name: str | None = None,
|
|
module_is_source: bool | None = None,
|
|
readme_dir: Path | None = None,
|
|
) -> None:
|
|
"""
|
|
Generate MkDocs Markdown sources for a Python module.
|
|
|
|
This function introspects the specified module, builds the internal
|
|
documentation model, and renders Markdown documentation files for
|
|
use with MkDocs.
|
|
|
|
Args:
|
|
module (str):
|
|
Python module import path used as the entry point for
|
|
documentation generation.
|
|
|
|
docs_dir (Path):
|
|
Directory where the generated Markdown files will be written.
|
|
|
|
project_name (str | None):
|
|
Optional override for the project name used in documentation metadata.
|
|
|
|
module_is_source (bool | None):
|
|
If True, treat the specified module directory as the project root
|
|
rather than a nested module.
|
|
|
|
readme_dir (Path | None):
|
|
Directory where the generated README.md should be written. If not
|
|
provided, defaults to the parent of ``docs_dir``.
|
|
"""
|
|
loader = GriffeLoader()
|
|
discovered_paths = discover_module_paths(module)
|
|
project = loader.load_project(discovered_paths, project_name)
|
|
|
|
renderer = MkDocsRenderer()
|
|
renderer.generate_sources(
|
|
project,
|
|
docs_dir,
|
|
module_is_source,
|
|
)
|
|
|
|
renderer.generate_readme(
|
|
project,
|
|
docs_dir,
|
|
module_is_source,
|
|
readme_dir,
|
|
)
|
|
|
|
|
|
def build_lib_nav(
|
|
nav_file: Path,
|
|
docs_root: Path,
|
|
) -> tuple[list[dict[str, Any]], dict[str, str] | None]:
|
|
"""
|
|
Build the re-rooted navigation block for a lib site.
|
|
|
|
The navigation specification is resolved against the shared documentation
|
|
root and every resulting path is re-rooted relative to the ``lib``
|
|
subdirectory by stripping its leading ``lib/`` scope component.
|
|
|
|
Args:
|
|
nav_file (Path):
|
|
Path to the `docforge.nav.yml` navigation specification.
|
|
|
|
docs_root (Path):
|
|
Shared documentation root containing the ``lib`` sources.
|
|
|
|
Returns:
|
|
tuple[list[dict[str, Any]], dict[str, str] | None]:
|
|
The re-rooted navigation block and the optional theme icon
|
|
mapping from the specification.
|
|
|
|
Raises:
|
|
click.FileError:
|
|
If the navigation specification cannot be found.
|
|
"""
|
|
if not nav_file.exists():
|
|
raise click.FileError(str(nav_file), hint="Nav spec not found")
|
|
|
|
spec = load_nav_spec(nav_file)
|
|
resolved = resolve_nav(spec, docs_root)
|
|
block = MkDocsNavEmitter().emit(resolved)
|
|
return cast(list[dict[str, Any]], _strip_scope(block, "lib")), spec.icon
|
|
|
|
|
|
def build_wiki_nav_block(wiki_dir: Path) -> list[dict[str, Any]]:
|
|
"""
|
|
Build the re-rooted navigation block for a wiki site.
|
|
|
|
The wiki navigation derived from the wiki file structure is re-rooted
|
|
relative to the wiki directory itself by stripping the leading ``wiki/``
|
|
scope component.
|
|
|
|
Args:
|
|
wiki_dir (Path):
|
|
Path to the hand-written wiki directory, for example ``docs/wiki``.
|
|
|
|
Returns:
|
|
list[dict[str, Any]]:
|
|
Navigation entries relative to the wiki directory.
|
|
|
|
Raises:
|
|
click.FileError:
|
|
If the wiki directory does not exist.
|
|
"""
|
|
if not wiki_dir.exists():
|
|
raise click.FileError(str(wiki_dir), hint="Wiki dir not found")
|
|
|
|
return cast(list[dict[str, Any]], _strip_scope(build_wiki_nav(wiki_dir), "wiki"))
|
|
|
|
|
|
def load_spec_icon(nav_file: Path) -> dict[str, str] | None:
|
|
"""
|
|
Load the theme icon mapping from a navigation specification.
|
|
|
|
Args:
|
|
nav_file (Path):
|
|
Path to the navigation specification file.
|
|
|
|
Returns:
|
|
dict[str, str] | None:
|
|
The icon mapping, or ``None`` when the specification file is
|
|
absent or cannot be parsed.
|
|
"""
|
|
if not nav_file.exists():
|
|
return None
|
|
|
|
try:
|
|
return load_nav_spec(nav_file).icon
|
|
except (ValueError, yaml.YAMLError):
|
|
return None
|
|
|
|
|
|
def _strip_scope(value: object, scope: str) -> object:
|
|
"""
|
|
Re-root navigation paths by removing a leading scope component.
|
|
|
|
The navigation block is walked recursively and every relative path
|
|
string that starts with ``{scope}/`` has that prefix removed, so a lib
|
|
or wiki site scoped to its own ``docs_dir`` can reuse paths that were
|
|
originally written relative to the shared documentation root.
|
|
|
|
Args:
|
|
value (object):
|
|
Navigation block, group list, path string, or scalar value.
|
|
scope (str):
|
|
Leading path component to strip, for example ``lib`` or ``wiki``.
|
|
|
|
Returns:
|
|
object:
|
|
The navigation structure with re-rooted paths.
|
|
"""
|
|
if isinstance(value, str):
|
|
prefix = f"{scope}/"
|
|
return value[len(prefix) :] if value.startswith(prefix) else value
|
|
if isinstance(value, list):
|
|
return [_strip_scope(item, scope) for item in value]
|
|
if isinstance(value, dict):
|
|
return {key: _strip_scope(val, scope) for key, val in value.items()}
|
|
return value
|
|
|
|
|
|
def generate_site_config(
|
|
kind: str,
|
|
kind_root: Path,
|
|
nav_block: list[dict[str, Any]],
|
|
out: Path,
|
|
site_name: str,
|
|
docs_dir: str,
|
|
site_dir: str,
|
|
template: Path | None = None,
|
|
site_description: str | None = None,
|
|
site_author: str | None = None,
|
|
theme_icon: dict[str, str] | None = None,
|
|
) -> None:
|
|
"""
|
|
Generate a per-kind `mkdocs.{kind}.yml` configuration file.
|
|
|
|
The configuration is created by merging the shared ``mkdocs.common.yml``
|
|
template with the fragment contributed by the kind (``lib``, ``api``, or
|
|
``wiki``). Both ``docs_dir`` and ``site_dir`` are written relative to the
|
|
configuration file's directory: the kind's sources when expressed as a
|
|
sibling path (for example ``lib``) and the per-kind site output (for
|
|
example ``../site/lib``).
|
|
|
|
Args:
|
|
kind (str):
|
|
Documentation kind, one of ``lib``, ``api``, or ``wiki``.
|
|
|
|
kind_root (Path):
|
|
Directory scoped to the kind (for example ``docs/lib``) that
|
|
serves as the MkDocs ``docs_dir``.
|
|
|
|
nav_block (list[dict[str, Any]]):
|
|
Re-rooted navigation entries for the kind's site.
|
|
|
|
out (Path):
|
|
Destination path where the generated ``mkdocs.{kind}.yml`` file
|
|
is written.
|
|
|
|
site_name (str):
|
|
Display name for the generated documentation site.
|
|
|
|
docs_dir (str):
|
|
MkDocs ``docs_dir`` value, relative to the configuration
|
|
file's directory.
|
|
|
|
site_dir (str):
|
|
MkDocs ``site_dir`` value, relative to the configuration
|
|
file's directory.
|
|
|
|
template (Path | None):
|
|
Optional path to a fully custom MkDocs configuration template
|
|
that replaces the built-in templates entirely.
|
|
|
|
site_description (str | None):
|
|
Optional site description written into the configuration.
|
|
|
|
site_author (str | None):
|
|
Optional site author written into the configuration.
|
|
|
|
theme_icon (dict[str, str] | None):
|
|
Optional mapping of theme icon entries injected as
|
|
``theme.icon``.
|
|
"""
|
|
data = _load_template(template, [kind])
|
|
|
|
data["site_name"] = site_name
|
|
if site_description:
|
|
data["site_description"] = site_description
|
|
if site_author:
|
|
data["site_author"] = site_author
|
|
data["docs_dir"] = docs_dir
|
|
data["site_dir"] = site_dir
|
|
data["nav"] = nav_block
|
|
|
|
if theme_icon:
|
|
theme = data.setdefault("theme", {})
|
|
if not isinstance(theme, dict):
|
|
theme = {}
|
|
theme["icon"] = theme_icon
|
|
data["theme"] = theme
|
|
|
|
out.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
|
|
|
|
|
def _load_template(
|
|
template: Path | None,
|
|
modes: Iterable[str] | None,
|
|
) -> dict:
|
|
"""
|
|
Load the MkDocs configuration template.
|
|
|
|
When a custom template path is provided, it is used as-is. Otherwise the
|
|
shared ``mkdocs.common.yml`` template is deep-merged with the fragments
|
|
contributed by each enabled mode (``lib``, ``api``, or ``wiki``).
|
|
|
|
Args:
|
|
template (Path | None):
|
|
Optional fully custom template that replaces the built-ins.
|
|
|
|
modes (Iterable[str] | None):
|
|
Documentation modes whose template fragments should be merged.
|
|
|
|
Returns:
|
|
dict:
|
|
Merged MkDocs configuration mapping.
|
|
|
|
Raises:
|
|
click.FileError:
|
|
If a referenced template file cannot be found.
|
|
"""
|
|
if template is not None:
|
|
if not template.exists():
|
|
raise click.FileError(str(template), hint="Template not found")
|
|
return yaml.safe_load(template.read_text(encoding="utf-8"))
|
|
|
|
data: dict = {}
|
|
active_modes = list(modes) if modes else ["lib"]
|
|
parts = ["common", *active_modes]
|
|
|
|
seen: set[str] = set()
|
|
for part_name in parts:
|
|
if part_name in seen:
|
|
continue
|
|
seen.add(part_name)
|
|
|
|
text = (
|
|
resources.files("docforge.templates")
|
|
.joinpath(f"mkdocs.{part_name}.yml")
|
|
.read_text(encoding="utf-8")
|
|
)
|
|
part: dict = yaml.safe_load(text)
|
|
data = _deep_merge(data, part)
|
|
|
|
return data
|
|
|
|
|
|
def _item_name(item: object) -> str:
|
|
"""
|
|
Return the identifying name of a list entry.
|
|
|
|
String entries identify as themselves; mapping entries identify by their
|
|
first key. This is used to deduplicate plugin and extension lists.
|
|
|
|
Args:
|
|
item (object):
|
|
List entry, either a string or a single-key mapping.
|
|
|
|
Returns:
|
|
str:
|
|
The identifying name of the entry.
|
|
"""
|
|
if isinstance(item, dict):
|
|
return next(iter(item.keys()), "")
|
|
return str(item)
|
|
|
|
|
|
def _merge_list(base: list, added: list) -> list:
|
|
"""
|
|
Merge two lists, preserving order and dropping duplicates by name.
|
|
|
|
Args:
|
|
base (list):
|
|
Existing list entries.
|
|
added (list):
|
|
Entries to append when not already present.
|
|
|
|
Returns:
|
|
list:
|
|
Merged list with duplicates removed.
|
|
"""
|
|
result = list(base)
|
|
names = {_item_name(item) for item in result}
|
|
for item in added:
|
|
name = _item_name(item)
|
|
if name not in names:
|
|
result.append(item)
|
|
names.add(name)
|
|
return result
|
|
|
|
|
|
def _deep_merge(base: dict, part: dict) -> dict:
|
|
"""
|
|
Deep merge a template fragment into a base configuration.
|
|
|
|
Mappings are merged recursively, while lists are combined by
|
|
deduplicating entries by their identifying name. Non-container values in
|
|
the fragment override the base.
|
|
|
|
Args:
|
|
base (dict):
|
|
Configuration being built up.
|
|
part (dict):
|
|
Template fragment to merge into the base.
|
|
|
|
Returns:
|
|
dict:
|
|
The merged configuration.
|
|
"""
|
|
for key, value in part.items():
|
|
if isinstance(value, dict) and isinstance(base.get(key), dict):
|
|
base[key] = _deep_merge(base[key], value)
|
|
elif isinstance(value, list) and isinstance(base.get(key), list):
|
|
base[key] = _merge_list(base[key], value)
|
|
else:
|
|
base[key] = value
|
|
return base
|
|
|
|
|
|
def build_configs(yml_paths: Iterable[Path]) -> None:
|
|
"""
|
|
Build the MkDocs documentation site for every given configuration.
|
|
|
|
Each configuration file is loaded and built in turn, producing the
|
|
per-kind static sites (``site/lib``, ``site/api``, ``site/wiki``).
|
|
|
|
Args:
|
|
yml_paths (Iterable[Path]):
|
|
Configuration files to build, in order.
|
|
|
|
Raises:
|
|
click.ClickException:
|
|
If a configuration file does not exist.
|
|
"""
|
|
from mkdocs.commands.build import build as mkdocs_build
|
|
from mkdocs.config import load_config
|
|
|
|
for yml in yml_paths:
|
|
if not yml.exists():
|
|
raise click.ClickException(f"mkdocs.yml not found: {yml}")
|
|
mkdocs_build(load_config(str(yml)))
|
|
|
|
|
|
def serve(mkdocs_yml: Path) -> None:
|
|
"""
|
|
Start an MkDocs development server with live reload.
|
|
|
|
The server watches documentation files and automatically reloads
|
|
the site when changes are detected.
|
|
|
|
Args:
|
|
mkdocs_yml (Path):
|
|
Path to the `mkdocs.yml` configuration file.
|
|
|
|
Raises:
|
|
click.ClickException:
|
|
If the configuration file does not exist.
|
|
"""
|
|
if not mkdocs_yml.exists():
|
|
raise click.ClickException(f"mkdocs.yml not found: {mkdocs_yml}")
|
|
|
|
from mkdocs.commands.serve import serve as mkdocs_serve
|
|
|
|
mkdocs_serve(config_file=str(mkdocs_yml))
|