""" # Summary Utilities for working with MkDocs in the doc-forge CLI. """ import os from collections.abc import Iterable from importlib import resources from pathlib import Path 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 (Optional[str]): Optional override for the project name used in documentation metadata. module_is_source (Optional[bool]): If True, treat the specified module directory as the project root rather than a nested module. readme_dir (Optional[Path]): 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 generate_config( docs_dir: Path, nav_file: Path, template: Path | None, out: Path, site_name: str, 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 (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 and hand-written wiki content under a ``wiki/`` subdirectory. Args: docs_dir (Path): Shared documentation root used as the MkDocs ``docs_dir``. nav_file (Path): Path to the `docforge.nav.yml` navigation specification. template (Optional[Path]): Optional path to a fully custom MkDocs configuration template. If not provided, built-in templates are merged; the provided template replaces the built-in templates entirely. out (Path): Destination path where the generated `mkdocs.yml` file will be written. site_name (str): Display name for the generated documentation site. modes (Optional[Iterable[str]]): Documentation modes to enable. Each mode contributes its own 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. 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, template, or wiki directory cannot be found. """ if not nav_file.exists() and wiki_dir is None: raise click.FileError(str(nav_file), hint="Nav spec not found") 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) data["site_name"] = site_name if site_description: data["site_description"] = site_description if site_author: data["site_author"] = site_author data["docs_dir"] = Path(os.path.relpath(docs_dir, out.parent)).as_posix() data["nav"] = nav_block if spec is not None and spec.icon: theme = data.setdefault("theme", {}) if not isinstance(theme, dict): theme = {} theme["icon"] = spec.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`` or ``api``). Args: template (Optional[Path]): Optional fully custom template that replaces the built-ins. modes (Optional[Iterable[str]]): 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: 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: Existing list entries. added: 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: Configuration being built up. part: 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(mkdocs_yml: Path) -> None: """ Build the MkDocs documentation site. This function loads the MkDocs configuration and runs the MkDocs build command to generate the final static documentation site. 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.build import build as mkdocs_build from mkdocs.config import load_config mkdocs_build(load_config(str(mkdocs_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))