""" # 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))