feat: export manifest documentation for public sites

This commit is contained in:
2026-08-07 14:53:14 +02:00
parent 30ea95854b
commit b5d3983844
5 changed files with 635 additions and 2 deletions
+37 -2
View File
@@ -168,8 +168,8 @@ manifest = ModuleManifest(
},
links=(
DocumentationLink(
label="Public GovOPlaN module documentation",
href="https://govplan.add-ideas.de/",
label="Public GovOPlaN documentation",
href="https://govoplan.add-ideas.de/docs",
kind="public",
),
DocumentationLink(
@@ -185,6 +185,41 @@ manifest = ModuleManifest(
),
metadata={"kind": "system"},
),
DocumentationTopic(
id="docs.public-manifest-export",
title="Public documentation from module manifests",
summary="Publish the static documentation baseline from every module without maintaining a second content source.",
body=(
"The public exporter reads DocumentationTopic contributions from all installed packages or sibling module checkouts, "
"projects German and English content, and records documentation coverage. Runtime providers remain in the authenticated "
"Docs surface because their output can depend on the current actor, policy, configuration, and live service state. "
"Publication CI should run the export check and reject a stale source digest."
),
layer="always",
documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "operator", "module_admin", "publisher"),
order=12,
translations={
"de": {
"title": "Öffentliche Dokumentation aus Modulmanifesten",
"summary": "Die statische Dokumentationsbasis aller Module veröffentlichen, ohne eine zweite Inhaltsquelle zu pflegen.",
"body": (
"Der öffentliche Export liest die DocumentationTopic-Beiträge aus allen installierten Paketen oder benachbarten Modulquellen, "
"projiziert deutsche und englische Inhalte und weist Dokumentationslücken aus. Laufzeit-Provider verbleiben in der authentifizierten "
"Dokumentationsoberfläche, da ihre Ausgabe von Rolle, Richtlinie, Konfiguration und Dienstzustand abhängen kann. "
"Die Veröffentlichungs-CI soll den Quelldigest prüfen und veraltete Exporte ablehnen."
),
}
},
links=(
DocumentationLink(
label="Public documentation",
href="https://govoplan.add-ideas.de/docs",
kind="public",
),
),
metadata={"kind": "system"},
),
DocumentationTopic(
id="docs.reference.institutional-governance-architecture",
title="Institutional governance architecture",
+424
View File
@@ -0,0 +1,424 @@
from __future__ import annotations
import argparse
import hashlib
import importlib
import json
import os
import sys
import tomllib
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from govoplan_core.core.discovery import discover_module_manifests
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
ModuleManifest,
)
SCHEMA_VERSION = "1"
DEFAULT_LOCALE = "de"
SUPPORTED_LOCALES = ("de", "en")
TOPIC_KINDS = (
"workflow",
"operator-workflow",
"guide",
"runbook",
"reference",
"pattern",
"system",
)
@dataclass(frozen=True, slots=True)
class ManifestSource:
manifest: ModuleManifest
repository: str | None = None
def collect_manifest_sources(
*,
workspace_root: Path | None = None,
) -> tuple[ManifestSource, ...]:
"""Collect manifests without requiring every optional module to be installed."""
by_id = {
manifest.id: ManifestSource(manifest=manifest)
for manifest in discover_module_manifests(ignore_load_errors=True)
}
if workspace_root is not None:
for source in _workspace_manifest_sources(workspace_root):
by_id[source.manifest.id] = source
return tuple(by_id[module_id] for module_id in sorted(by_id))
def build_public_catalog(
sources: Sequence[ManifestSource],
*,
generated_at: datetime | None = None,
) -> dict[str, Any]:
modules = [_module_payload(source) for source in sources]
digest_input = {
"schema_version": SCHEMA_VERSION,
"default_locale": DEFAULT_LOCALE,
"supported_locales": list(SUPPORTED_LOCALES),
"modules": modules,
}
source_digest = _sha256(digest_input)
timestamp = generated_at or _generated_at()
return {
**digest_input,
"generated_at": timestamp.astimezone(UTC).isoformat().replace("+00:00", "Z"),
"source_digest": source_digest,
"summary": _coverage_summary(modules),
}
def documentation_coverage_markdown(catalog: Mapping[str, Any]) -> str:
summary = _mapping(catalog.get("summary"))
modules = [item for item in catalog.get("modules", ()) if isinstance(item, Mapping)]
gaps = [item for item in summary.get("gaps", ()) if isinstance(item, Mapping)]
lines = [
"# Public documentation coverage",
"",
"This report is generated from the static `DocumentationTopic` entries, including their structured workflow and field metadata, in every module manifest.",
"Configured-instance topics from runtime providers remain in the authenticated Docs module because they may depend on permissions, policy, and live state.",
"",
f"- Modules: {summary.get('module_count', 0)}",
f"- Static topics: {summary.get('topic_count', 0)}",
f"- Topics with complete German title, summary, and body: {summary.get('german_complete_topic_count', 0)}",
f"- Modules with runtime documentation providers: {summary.get('runtime_provider_module_count', 0)}",
f"- Source digest: `{catalog.get('source_digest', '')}`",
"",
"## Expansion priorities",
"",
"1. Add German title, summary, and body translations to every public topic; German is the reference target.",
"2. Give every user-facing module at least one scope-conditioned workflow topic and one field/consequence reference.",
"3. Give every configurable module an administrator topic covering permissions, policy provenance, retention, and operational consequences.",
"4. Keep live provider-state and instance-specific limitations in `documentation_providers`; do not publish them as generic facts.",
"5. Add a versioned localization contract for structured metadata such as steps, fields, limitations, and verification; these values currently retain their manifest source language.",
"",
"## Module gaps",
"",
"| Module | Topics | Missing German | Missing coverage |",
"| --- | ---: | ---: | --- |",
]
gaps_by_id = {str(item.get("module_id")): item for item in gaps}
for module in modules:
module_id = str(module.get("id", ""))
coverage = _mapping(module.get("coverage"))
gap = gaps_by_id.get(module_id, {})
missing = ", ".join(str(item) for item in gap.get("missing", ())) or "-"
lines.append(
f"| `{module_id}` | {coverage.get('topic_count', 0)} | "
f"{coverage.get('missing_german_topic_count', 0)} | {missing} |"
)
lines.extend(("", "Generated file. Edit module manifests, then regenerate this report.", ""))
return "\n".join(lines)
def write_public_catalog(
output: Path,
catalog: Mapping[str, Any],
*,
coverage_output: Path | None = None,
) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(catalog, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
if coverage_output is not None:
coverage_output.parent.mkdir(parents=True, exist_ok=True)
coverage_output.write_text(
documentation_coverage_markdown(catalog),
encoding="utf-8",
)
def catalog_matches_sources(output: Path, catalog: Mapping[str, Any]) -> bool:
if not output.is_file():
return False
try:
current = json.loads(output.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return False
return current.get("source_digest") == catalog.get("source_digest")
def _workspace_manifest_sources(workspace_root: Path) -> tuple[ManifestSource, ...]:
declarations: list[tuple[Path, Mapping[str, str]]] = []
for repository in sorted(workspace_root.glob("govoplan-*")):
pyproject = repository / "pyproject.toml"
if not pyproject.is_file():
continue
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
raw = data.get("project", {}).get("entry-points", {}).get("govoplan.modules", {})
if not isinstance(raw, Mapping) or not raw:
continue
declarations.append(
(repository, {str(key): str(value) for key, value in raw.items()})
)
# Add every module source before imports so optional contracts resolve
# independently of package installation order.
for repository, _entry_points in reversed(declarations):
source_root = str(repository / "src")
if source_root not in sys.path:
sys.path.insert(0, source_root)
sources: list[ManifestSource] = []
for repository, entry_points in declarations:
for target in entry_points.values():
module_name, separator, attribute = target.partition(":")
if not separator or not module_name or not attribute:
raise ValueError(f"Invalid GovOPlaN module entry point: {target!r}")
loaded = getattr(importlib.import_module(module_name), attribute)
manifest = loaded() if callable(loaded) else loaded
if not isinstance(manifest, ModuleManifest):
raise TypeError(f"Entry point {target!r} did not return ModuleManifest")
sources.append(
ManifestSource(
manifest=manifest,
repository=f"https://git.add-ideas.de/GovOPlaN/{repository.name}",
)
)
return tuple(sources)
def _module_payload(source: ManifestSource) -> dict[str, Any]:
manifest = source.manifest
topics = [
_topic_payload(manifest.id, topic)
for topic in sorted(manifest.documentation, key=lambda item: (item.order, item.id))
]
return {
"id": manifest.id,
"name": manifest.name,
"version": manifest.version,
"repository": source.repository or _default_repository(manifest.id),
"dependencies": list(manifest.dependencies),
"optional_dependencies": list(manifest.optional_dependencies),
"runtime_documentation_provider_count": len(manifest.documentation_providers),
"topics": topics,
"coverage": _module_coverage(manifest, topics),
}
def _topic_payload(module_id: str, topic: DocumentationTopic) -> dict[str, Any]:
return {
"id": topic.id,
"source_module_id": topic.source_module_id or module_id,
"kind": _topic_kind(topic),
"layer": topic.layer,
"documentation_types": list(topic.documentation_types),
"audience": list(topic.audience),
"order": topic.order,
"localizations": {
locale: _localized_topic(topic, locale) for locale in SUPPORTED_LOCALES
},
"links": [_link_payload(link) for link in topic.links],
"related_modules": list(topic.related_modules),
"unlocks": list(topic.unlocks),
"version_min": topic.version_min,
"version_max_exclusive": topic.version_max_exclusive,
"is_conditioned": bool(topic.conditions or topic.configuration_keys),
"conditions": [_condition_payload(item) for item in topic.conditions],
"configuration_keys": list(topic.configuration_keys),
"content": _public_value(
{
key: value
for key, value in topic.metadata.items()
if key != "kind"
}
),
}
def _localized_topic(topic: DocumentationTopic, locale: str) -> dict[str, Any]:
translation = topic.translations.get(locale, {})
translated_fields = [
field
for field in ("title", "summary", "body")
if str(translation.get(field, "")).strip()
]
return {
"title": str(translation.get("title") or topic.title),
"summary": str(translation.get("summary") or topic.summary),
"body": str(translation.get("body") or topic.body),
"source_locale": locale if translated_fields else "en",
"translated_fields": translated_fields,
"complete": len(translated_fields) == 3,
}
def _module_coverage(
manifest: ModuleManifest,
topics: Sequence[Mapping[str, Any]],
) -> dict[str, Any]:
kinds = {str(topic.get("kind")) for topic in topics}
has_user = any("user" in topic.get("documentation_types", ()) for topic in topics)
has_admin = any("admin" in topic.get("documentation_types", ()) for topic in topics)
missing_german = [
str(topic.get("id"))
for topic in topics
if not _mapping(_mapping(topic.get("localizations")).get("de")).get("complete")
]
missing: list[str] = []
if not has_user:
missing.append("user documentation")
if not has_admin:
missing.append("administrator documentation")
if manifest.frontend is not None and "workflow" not in kinds:
missing.append("user workflow")
if "reference" not in kinds:
missing.append("field/consequence reference")
if missing_german:
missing.append("complete German localization")
return {
"topic_count": len(topics),
"user_topic_count": sum(
"user" in topic.get("documentation_types", ()) for topic in topics
),
"admin_topic_count": sum(
"admin" in topic.get("documentation_types", ()) for topic in topics
),
"missing_german_topic_count": len(missing_german),
"missing_german_topic_ids": missing_german,
"kinds": sorted(kinds),
"missing": missing,
}
def _coverage_summary(modules: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
topic_count = sum(len(module.get("topics", ())) for module in modules)
missing_german = sum(
int(_mapping(module.get("coverage")).get("missing_german_topic_count", 0))
for module in modules
)
gaps = [
{
"module_id": str(module.get("id")),
"missing": list(_mapping(module.get("coverage")).get("missing", ())),
}
for module in modules
if _mapping(module.get("coverage")).get("missing")
]
return {
"module_count": len(modules),
"topic_count": topic_count,
"german_complete_topic_count": topic_count - missing_german,
"runtime_provider_module_count": sum(
int(module.get("runtime_documentation_provider_count", 0)) > 0
for module in modules
),
"gap_module_count": len(gaps),
"gaps": gaps,
}
def _topic_kind(topic: DocumentationTopic) -> str:
raw = topic.metadata.get("kind")
if isinstance(raw, str):
normalized = raw.strip().lower().replace("_", "-")
if normalized in TOPIC_KINDS:
return normalized
return "system"
def _link_payload(link: DocumentationLink) -> dict[str, str]:
return {"label": link.label, "href": link.href, "kind": link.kind}
def _condition_payload(condition: DocumentationCondition) -> dict[str, list[str]]:
return {
"required_modules": list(condition.required_modules),
"any_modules": list(condition.any_modules),
"missing_modules": list(condition.missing_modules),
"required_capabilities": list(condition.required_capabilities),
"required_scopes": list(condition.required_scopes),
"any_scopes": list(condition.any_scopes),
"configuration_keys": list(condition.configuration_keys),
}
def _public_value(value: object) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, Mapping):
return {str(key): _public_value(item) for key, item in value.items()}
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return [_public_value(item) for item in value]
raise TypeError(
"Static documentation metadata must contain only JSON-compatible values; "
f"received {type(value).__name__}."
)
def _default_repository(module_id: str) -> str:
slug = {"campaigns": "campaign"}.get(module_id, module_id.replace("_", "-"))
return f"https://git.add-ideas.de/GovOPlaN/govoplan-{slug}"
def _sha256(value: object) -> str:
encoded = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _generated_at() -> datetime:
raw_epoch = os.environ.get("SOURCE_DATE_EPOCH")
if raw_epoch:
return datetime.fromtimestamp(int(raw_epoch), tz=UTC)
return datetime.now(UTC)
def _mapping(value: object) -> Mapping[str, Any]:
return value if isinstance(value, Mapping) else {}
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Export all static GovOPlaN module documentation for a public site."
)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--coverage-output", type=Path)
parser.add_argument("--workspace-root", type=Path)
parser.add_argument(
"--check",
action="store_true",
help="Fail when the existing output does not match current manifest content.",
)
return parser
def main(argv: Iterable[str] | None = None) -> int:
args = _parser().parse_args(list(argv) if argv is not None else None)
catalog = build_public_catalog(
collect_manifest_sources(workspace_root=args.workspace_root)
)
if args.check:
if catalog_matches_sources(args.output, catalog):
return 0
print(f"Public documentation catalog is stale: {args.output}", file=sys.stderr)
return 1
write_public_catalog(
args.output,
catalog,
coverage_output=args.coverage_output,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())