""" MkDocs renderer implementation. This module defines the ``MkDocsRenderer`` class, which generates Markdown documentation sources compatible with MkDocs Material and the mkdocstrings plugin. The renderer ensures a consistent documentation structure by: - Creating a root ``index.md`` if one does not exist - Generating package index pages automatically - Linking child modules within parent package pages - Optionally generating ``README.md`` from the root package docstring """ from pathlib import Path from docforge.models import Project, Module class MkDocsRenderer: """ Renderer that produces Markdown documentation for MkDocs. Generated pages use mkdocstrings directives to reference Python modules, allowing MkDocs to render API documentation dynamically. """ name = "mkdocs" # ------------------------- # Public API # ------------------------- def generate_sources( self, project: Project, out_dir: Path, module_is_source: bool | None = None, ) -> None: """ Generate Markdown documentation files for a project. This method renders a documentation structure from the provided project model and writes the resulting Markdown files to the specified output directory. Args: project: Project model containing modules to document. out_dir: Directory where generated Markdown files will be written. module_is_source: If True, treat the specified module as the documentation root rather than nesting it inside a folder. """ out_dir.mkdir(parents=True, exist_ok=True) self._ensure_root_index(project, out_dir) modules = list(project.get_all_modules()) paths = {m.path for m in modules} # Detect packages (modules with children) packages = { p for p in paths if any(other.startswith(p + ".") for other in paths) } for module in modules: self._write_module( module, packages, out_dir, module_is_source, ) def generate_readme( self, project: Project, docs_dir: Path, module_is_source: bool | None = None, ) -> None: """ Generate a ``README.md`` file from the root module docstring. Behavior: - If ``module_is_source`` is True, ``README.md`` is written to the project root directory. - If False, README generation is currently not implemented. Args: project: Project model containing documentation metadata. docs_dir: Directory containing generated documentation sources. module_is_source: Whether the module is treated as the project source root. """ if not module_is_source: # Future: support README generation per module return readme_path = docs_dir.parent / "README.md" root_module = None for module in project.get_all_modules(): if module.path == project.name: root_module = module break if root_module is None: return doc = "" if root_module.docstring: doc = getattr( root_module.docstring, "value", str(root_module.docstring), ) content = ( f"# {project.name}\n\n" f"{doc.strip()}\n" ) if not readme_path.exists() or readme_path.read_text(encoding="utf-8") != content: readme_path.write_text( content, encoding="utf-8", ) # ------------------------- # Internal helpers # ------------------------- def _find_root_module(self, project: Project) -> Module | None: """ Locate the root module corresponding to the project name. Args: project: Project model to inspect. Returns: The root ``Module`` if found, otherwise ``None``. """ for module in project.get_all_modules(): if module.path == project.name: return module return None def _write_module( self, module: Module, packages: set[str], out_dir: Path, module_is_source: bool | None = None, ) -> None: """ Write documentation for a single module. Package modules are rendered as ``index.md`` files inside their corresponding directories, while leaf modules are written as standalone Markdown pages. Args: module: Module to render. packages: Set of module paths identified as packages. out_dir: Base directory for generated documentation files. module_is_source: Whether the module acts as the documentation root directory. """ parts = module.path.split(".") if module_is_source: module_name, parts = parts[0], parts[1:] else: module_name, parts = parts[0], parts if module.path in packages: dir_path = out_dir.joinpath(*parts) dir_path.mkdir(parents=True, exist_ok=True) md_path = dir_path / "index.md" link_target = f"{parts[-1]}/" if parts else None else: dir_path = out_dir.joinpath(*parts[:-1]) dir_path.mkdir(parents=True, exist_ok=True) md_path = dir_path / f"{parts[-1]}.md" link_target = f"{parts[-1]}.md" if parts else None title = parts[-1].replace("_", " ").title() if parts else module_name content = self._render_markdown(title, module.path) if not md_path.exists() or md_path.read_text(encoding="utf-8") != content: md_path.write_text(content, encoding="utf-8") if not module_is_source: self._ensure_parent_index(parts, out_dir, link_target, title) def _render_markdown(self, title: str, module_path: str) -> str: """ Generate Markdown content for a module documentation page. Args: title: Page title displayed in the documentation. module_path: Dotted import path of the module. Returns: Markdown source containing a mkdocstrings directive. """ return ( f"# {title}\n\n" f"::: {module_path}\n" ) def _ensure_root_index( self, project: Project, out_dir: Path, ) -> None: """ Ensure that the root ``index.md`` page exists. Args: project: Project model used for the page title. out_dir: Documentation output directory. """ root_index = out_dir / "index.md" if not root_index.exists(): root_index.write_text( f"# {project.name}\n\n" "## Modules\n\n", encoding="utf-8", ) def _ensure_parent_index( self, parts: list[str], out_dir: Path, link_target: str, title: str, ) -> None: """ Ensure that parent package index files exist and contain links to child modules. Args: parts: Module path components. out_dir: Documentation output directory. link_target: Link target used in the parent index. title: Display title for the link. """ if len(parts) == 1: parent_index = out_dir / "index.md" else: parent_dir = out_dir.joinpath(*parts[:-1]) parent_dir.mkdir(parents=True, exist_ok=True) parent_index = parent_dir / "index.md" if not parent_index.exists(): parent_title = parts[-2].replace("_", " ").title() parent_index.write_text( f"# {parent_title}\n\n", encoding="utf-8", ) content = parent_index.read_text(encoding="utf-8") link = f"- [{title}]({link_target})\n" if link not in content: parent_index.write_text(content + link, encoding="utf-8")