chore(release): 0.0.7

Squash-merge lib-standardisation: adopt repo standards across packaging,
tooling, docs, and CI.

- standardize pyproject.toml (canonical packaging, lint config, extras, GSDFC pydoclint)
- add py.typed marker, modernize type hints and Google-style docstrings
- nest generated docs under docs/lib and docs/mcp; add hand-written wiki
- adopt standard Type A PyPI CI pipeline (tag==version guard + quality gate)
- promote generated CHANGELOG.md; bump version to 0.0.7
This commit is contained in:
2026-10-17 02:16:44 +05:30
parent 8253c25928
commit 06ffd2febd
149 changed files with 7132 additions and 4594 deletions

View File

@@ -2,15 +2,35 @@
# 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 pathlib import Path
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
from docforge.nav import load_nav_spec, resolve_nav, MkDocsNavEmitter
def generate_sources(
@@ -18,6 +38,7 @@ def generate_sources(
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.
@@ -34,12 +55,16 @@ def generate_sources(
docs_dir (Path):
Directory where the generated Markdown files will be written.
project_name (Optional[str]):
project_name (str | None):
Optional override for the project name used in documentation metadata.
module_is_source (Optional[bool]):
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)
@@ -56,92 +81,353 @@ def generate_sources(
project,
docs_dir,
module_is_source,
readme_dir,
)
def generate_config(
docs_dir: Path,
def build_lib_nav(
nav_file: Path,
template: Path | None,
out: Path,
site_name: str,
) -> None:
docs_root: Path,
) -> tuple[list[dict[str, Any]], dict[str, str] | None]:
"""
Generate an `mkdocs.yml` configuration file.
Build the re-rooted navigation block for a lib site.
The configuration is created by combining a template configuration
with a navigation structure derived from the docforge navigation
specification.
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:
docs_dir (Path):
Directory containing generated documentation Markdown files.
nav_file (Path):
Path to the `docforge.nav.yml` navigation specification.
template (Optional[Path]):
Optional path to a custom MkDocs configuration template. If not
provided, a built-in template will be used.
docs_root (Path):
Shared documentation root containing the ``lib`` sources.
out (Path):
Destination path where the generated `mkdocs.yml` file will be written.
site_name (str):
Display name for the generated documentation site.
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 or template file cannot be found.
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_dir)
nav_block = MkDocsNavEmitter().emit(resolved)
resolved = resolve_nav(spec, docs_root)
block = MkDocsNavEmitter().emit(resolved)
return cast(list[dict[str, Any]], _strip_scope(block, "lib")), spec.icon
# Load template
if template is not None:
if not template.exists():
raise click.FileError(str(template), hint="Template not found")
data = yaml.safe_load(template.read_text(encoding="utf-8"))
else:
text = (
resources.files("docforge.templates")
.joinpath("mkdocs.sample.yml")
.read_text(encoding="utf-8")
)
data = yaml.safe_load(text)
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 build(mkdocs_yml: Path) -> None:
def _load_template(
template: Path | None,
modes: Iterable[str] | None,
) -> dict:
"""
Build the MkDocs documentation site.
Load the MkDocs configuration template.
This function loads the MkDocs configuration and runs the MkDocs
build command to generate the final static documentation site.
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:
mkdocs_yml (Path):
Path to the `mkdocs.yml` configuration file.
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 the configuration file does not exist.
If a configuration file does not exist.
"""
if not mkdocs_yml.exists():
raise click.ClickException(f"mkdocs.yml not found: {mkdocs_yml}")
from mkdocs.config import load_config
from mkdocs.commands.build import build as mkdocs_build
from mkdocs.config import load_config
mkdocs_build(load_config(str(mkdocs_yml)))
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:
@@ -163,4 +449,5 @@ def serve(mkdocs_yml: Path) -> None:
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))