feat: add --api OpenAPI build mode with docs/api scheme and lib-prefixed nav
This commit is contained in:
@@ -5,6 +5,7 @@ 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
|
||||
|
||||
@@ -74,6 +75,9 @@ def generate_config(
|
||||
template: Path | None,
|
||||
out: Path,
|
||||
site_name: str,
|
||||
modes: Iterable[str] | None = None,
|
||||
site_description: str | None = None,
|
||||
site_author: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Generate an `mkdocs.yml` configuration file.
|
||||
@@ -82,16 +86,21 @@ def generate_config(
|
||||
with a navigation structure derived from the docforge navigation
|
||||
specification.
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
docs_dir (Path):
|
||||
Directory containing generated documentation Markdown files.
|
||||
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 custom MkDocs configuration template. If not
|
||||
provided, a built-in template will be used.
|
||||
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.
|
||||
@@ -99,6 +108,17 @@ def generate_config(
|
||||
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`` or ``api``),
|
||||
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.
|
||||
|
||||
Raises:
|
||||
click.FileError:
|
||||
If the navigation specification or template file cannot be found.
|
||||
@@ -110,26 +130,145 @@ def generate_config(
|
||||
resolved = resolve_nav(spec, docs_dir)
|
||||
nav_block = MkDocsNavEmitter().emit(resolved)
|
||||
|
||||
# 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)
|
||||
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.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.
|
||||
|
||||
Reference in New Issue
Block a user