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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user