Compare commits
7
Commits
v0.1.17
...
77eb7339e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77eb7339e6 | ||
|
|
281f35310b | ||
|
|
e05d5b4a54 | ||
|
|
0b4e7b6391 | ||
|
|
36c0afadd4 | ||
|
|
b5d3983844 | ||
|
|
30ea95854b |
@@ -70,6 +70,24 @@ cd /mnt/DATA/git/govoplan
|
||||
Feature content remains in the owning module. Docs indexes and renders the
|
||||
contributions without importing feature implementations.
|
||||
|
||||
## Public documentation export
|
||||
|
||||
The public website is generated from the same static `DocumentationTopic`
|
||||
contributions that power the in-product documentation. Export every installed
|
||||
module, or every module checkout in a workspace, with:
|
||||
|
||||
```bash
|
||||
govoplan-docs-export-public \
|
||||
--workspace-root /mnt/DATA/git \
|
||||
--output public/docs/v1/catalog.json \
|
||||
--coverage-output docs/DOCUMENTATION_COVERAGE.md
|
||||
```
|
||||
|
||||
Use `--check` in publication CI to reject a stale checked-in catalog. Dynamic
|
||||
`documentation_providers` remain instance-only because their output can depend
|
||||
on permissions, policy, configuration, and live provider state; the export
|
||||
records which modules have such additional documentation.
|
||||
|
||||
Pressing `F1` resolves the focused field or action first, then its containing
|
||||
dialog or section, current page, and owning module. The shell sends the focused
|
||||
context together with `fallback_context` and `module`; Docs selects the first
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Docs Interface Pattern Migration
|
||||
|
||||
The Docs route composes the platform interface language without owning a
|
||||
parallel shell. Core owns its navigation/content pane geometry and page frame;
|
||||
Docs owns audience filtering, version selection, topic navigation, configured
|
||||
documentation projection, and evidence presentation.
|
||||
|
||||
## Surface contract
|
||||
|
||||
- `WorkspaceLayout` owns the outline/content regions, pane width, scrolling,
|
||||
accessible labels, and narrow-layout navigation behavior.
|
||||
- `PageLayout` owns the content inset, sticky heading, audience-aware page
|
||||
identity, reload action placement, error and loading regions, and responsive
|
||||
action flow.
|
||||
- `ExplorerTree`, `SegmentedControl`, `DataGrid`, `DescriptionList`, dialogs,
|
||||
alerts, and status badges retain their Core interaction and accessibility
|
||||
contracts.
|
||||
- Docs-specific CSS remains limited to the documentation tree, reading column,
|
||||
topic typography, page outline, and reference content. It no longer defines
|
||||
the outer workspace grid or page skeleton.
|
||||
|
||||
The module keeps user/admin audience boundaries, installed-version selection,
|
||||
topic URLs, configured-state projection, and evidence visibility unchanged.
|
||||
The platform layout checker rejects restoring a raw Docs page or workspace
|
||||
frame.
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -17,7 +17,7 @@
|
||||
"README.md"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.17",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
+6
-3
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-docs"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.17",
|
||||
"govoplan-access>=0.1.17",
|
||||
"govoplan-core>=0.1.18",
|
||||
"govoplan-access>=0.1.18",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
@@ -22,3 +22,6 @@ govoplan_docs = ["py.typed"]
|
||||
|
||||
[project.entry-points."govoplan.modules"]
|
||||
docs = "govoplan_docs.backend.manifest:get_manifest"
|
||||
|
||||
[project.scripts]
|
||||
govoplan-docs-export-public = "govoplan_docs.public_export:main"
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.17"
|
||||
__version__ = "0.1.18"
|
||||
|
||||
@@ -47,6 +47,11 @@ ARCHITECTURE = ModuleArchitectureDeclaration(
|
||||
reference="docs/DOCUMENTATION_LAYER_CONCEPT.md",
|
||||
summary="Defines the manifest-driven documentation boundary.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="documentation",
|
||||
reference="docs/INTERFACE_PATTERN_MIGRATION.md",
|
||||
summary="Records the Core-owned Docs workspace and page-layout boundary.",
|
||||
),
|
||||
),
|
||||
known_limits=(
|
||||
"Architecture declarations are in staged adoption, so undeclared modules remain visible as pending.",
|
||||
@@ -84,7 +89,7 @@ def _route_factory(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="docs",
|
||||
name="Docs",
|
||||
version="0.1.17",
|
||||
version="0.1.18",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
@@ -168,8 +173,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 +190,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",
|
||||
@@ -208,7 +248,7 @@ manifest = ModuleManifest(
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Institutional governance target architecture",
|
||||
href="govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md",
|
||||
href="govoplan/docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md",
|
||||
kind="repository",
|
||||
),
|
||||
DocumentationLink(
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.modules import DocumentationTopic, ModuleManifest
|
||||
from govoplan_docs.public_export import (
|
||||
ManifestSource,
|
||||
build_public_catalog,
|
||||
catalog_matches_sources,
|
||||
documentation_coverage_markdown,
|
||||
write_public_catalog,
|
||||
)
|
||||
|
||||
|
||||
class PublicDocumentationExportTests(unittest.TestCase):
|
||||
def test_catalog_projects_localized_manifest_topics_and_gaps(self) -> None:
|
||||
manifest = ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.2.3",
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="example.workflow",
|
||||
title="Use example",
|
||||
summary="Perform the example workflow.",
|
||||
body="Open and finish it.",
|
||||
documentation_types=("user", "admin"),
|
||||
metadata={"kind": "workflow"},
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Beispiel verwenden",
|
||||
"summary": "Den Beispielablauf durchführen.",
|
||||
"body": "Öffnen und abschließen.",
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
catalog = build_public_catalog(
|
||||
(ManifestSource(manifest, "https://example.invalid/repository"),)
|
||||
)
|
||||
|
||||
topic = catalog["modules"][0]["topics"][0]
|
||||
self.assertEqual("Beispiel verwenden", topic["localizations"]["de"]["title"])
|
||||
self.assertTrue(topic["localizations"]["de"]["complete"])
|
||||
self.assertEqual("workflow", topic["kind"])
|
||||
self.assertEqual({}, topic["content"])
|
||||
self.assertIn(
|
||||
"field/consequence reference",
|
||||
catalog["modules"][0]["coverage"]["missing"],
|
||||
)
|
||||
|
||||
def test_digest_check_ignores_generation_timestamp(self) -> None:
|
||||
source = ManifestSource(
|
||||
ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="example.reference",
|
||||
title="Reference",
|
||||
summary="Reference summary",
|
||||
metadata={"kind": "reference"},
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
first = build_public_catalog((source,))
|
||||
second = build_public_catalog((source,))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "catalog.json"
|
||||
write_public_catalog(output, first)
|
||||
self.assertTrue(catalog_matches_sources(output, second))
|
||||
parsed = json.loads(output.read_text(encoding="utf-8"))
|
||||
parsed["source_digest"] = "stale"
|
||||
output.write_text(json.dumps(parsed), encoding="utf-8")
|
||||
self.assertFalse(catalog_matches_sources(output, second))
|
||||
|
||||
def test_coverage_report_identifies_generated_source(self) -> None:
|
||||
catalog = build_public_catalog(
|
||||
(
|
||||
ManifestSource(
|
||||
ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation=(),
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
report = documentation_coverage_markdown(catalog)
|
||||
self.assertIn("Public documentation coverage", report)
|
||||
self.assertIn("`example`", report)
|
||||
self.assertIn("Edit module manifests", report)
|
||||
|
||||
def test_catalog_preserves_structured_static_documentation(self) -> None:
|
||||
manifest = ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="example.workflow",
|
||||
title="Run example",
|
||||
summary="Run it safely.",
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"prerequisites": ["Approved input"],
|
||||
"steps": ["Review", "Execute"],
|
||||
"fields": [
|
||||
{
|
||||
"label": "Mode",
|
||||
"user_description": "Selected behavior.",
|
||||
}
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
topic = build_public_catalog((ManifestSource(manifest),))["modules"][0]["topics"][0]
|
||||
|
||||
self.assertEqual(["Review", "Execute"], topic["content"]["steps"])
|
||||
self.assertEqual("Mode", topic["content"]["fields"][0]["label"])
|
||||
|
||||
def test_catalog_preserves_specialized_documentation_kinds(self) -> None:
|
||||
manifest = ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="example.runbook",
|
||||
title="Recover example",
|
||||
summary="Restore the example safely.",
|
||||
metadata={"kind": "operator_workflow"},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
topic = build_public_catalog((ManifestSource(manifest),))["modules"][0]["topics"][0]
|
||||
|
||||
self.assertEqual("operator-workflow", topic["kind"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.17",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DescriptionList } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { ChevronDown, ChevronRight, Eye, RefreshCw } from "lucide-react";
|
||||
@@ -7,10 +8,11 @@ import {
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
ExplorerTree,
|
||||
LoadingFrame,
|
||||
PageTitle,
|
||||
PageActionBar,
|
||||
PageLayout,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
WorkspaceLayout,
|
||||
adminErrorMessage,
|
||||
useGuardedNavigate,
|
||||
usePlatformLanguage,
|
||||
@@ -132,7 +134,14 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
}, [treeNodes, selectedPage?.id]);
|
||||
|
||||
return (
|
||||
<div className="workspace module-workspace docs-workspace">
|
||||
<WorkspaceLayout
|
||||
className="module-workspace docs-workspace"
|
||||
primarySize="wide"
|
||||
primaryLabel="i18n:govoplan-docs.documentation_outline.6f836b99"
|
||||
contentLabel="i18n:govoplan-docs.help_center.f3f3a34b"
|
||||
documentationType={documentationType}
|
||||
contentClassName="docs-workspace-content"
|
||||
primary={(
|
||||
<aside className="section-sidebar docs-outline" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
||||
<div className="docs-sidebar-header">
|
||||
<div className="section-title">i18n:govoplan-docs.help_center.f3f3a34b</div>
|
||||
@@ -180,21 +189,20 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
<section className="workspace-content docs-workspace-content">
|
||||
<div className="content-pad workspace-data-page docs-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>i18n:govoplan-docs.help_center.f3f3a34b</PageTitle>
|
||||
<p>{adminDocs ? "i18n:govoplan-docs.technical_documentation_for_the_modules_and_conf.267e739e" : "i18n:govoplan-docs.guidance_for_the_functions_available_in_this_ins.723b7cad"}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void load()} disabled={loading}><RefreshCw size={16} /> i18n:govoplan-docs.reload.cce71553</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-docs.loading_documentation_context.1c091645">
|
||||
)}
|
||||
>
|
||||
<PageLayout
|
||||
archetype="workspace"
|
||||
title="i18n:govoplan-docs.help_center.f3f3a34b"
|
||||
description={adminDocs ? "i18n:govoplan-docs.technical_documentation_for_the_modules_and_conf.267e739e" : "i18n:govoplan-docs.guidance_for_the_functions_available_in_this_ins.723b7cad"}
|
||||
actions={<PageActionBar variant="workspace" refreshable reloadAction={{ onReload: () => void load(), loading }} />}
|
||||
loading={loading}
|
||||
loadingLabel="i18n:govoplan-docs.loading_documentation_context.1c091645"
|
||||
error={error}
|
||||
mode="workspace"
|
||||
documentationType={documentationType}
|
||||
className="docs-page"
|
||||
>
|
||||
<div className="docs-content">
|
||||
<main className="docs-page-main">
|
||||
<SelectedPageContent
|
||||
@@ -214,10 +222,8 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
</main>
|
||||
<PageOutline items={outlineItems} />
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
|
||||
function selectDocumentationType(type: DocumentationType) {
|
||||
@@ -459,7 +465,7 @@ function ConstraintDetails({ id, constraints }: { id: string; constraints: Recor
|
||||
return (
|
||||
<div className="docs-detail-block" id={id}>
|
||||
<h4>i18n:govoplan-docs.requirements.09a428f9</h4>
|
||||
<dl className="detail-list compact">
|
||||
<DescriptionList variant="inline" density="compact">
|
||||
{constraints.map((constraint, index) => {
|
||||
const label = metadataString(constraint, "label");
|
||||
const description = metadataString(constraint, "description");
|
||||
@@ -474,7 +480,7 @@ function ConstraintDetails({ id, constraints }: { id: string; constraints: Recor
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</DescriptionList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -487,11 +493,11 @@ function ReferenceDetails({ topic, showTechnical, documentationType, topicById }
|
||||
return (
|
||||
<div className="docs-topic-details">
|
||||
{(route || screen || section) &&
|
||||
<dl className="detail-list compact">
|
||||
<DescriptionList variant="inline" density="compact">
|
||||
{screen && <div><dt>i18n:govoplan-docs.screen.c4878ec4</dt><dd>{screen}</dd></div>}
|
||||
{section && <div><dt>i18n:govoplan-docs.section.5e498158</dt><dd>{section}</dd></div>}
|
||||
{showTechnical && route && <div><dt>i18n:govoplan-docs.route.4999528e</dt><dd>{route}</dd></div>}
|
||||
</dl>
|
||||
</DescriptionList>
|
||||
}
|
||||
{!!fields.length && <ReferenceFieldTable id={`${topicAnchorId(topic)}-fields`} fields={fields} showTechnical={showTechnical} />}
|
||||
<RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} />
|
||||
@@ -604,14 +610,14 @@ function UnavailableDocumentationReason({ topic }: { topic: DocsDocumentationTop
|
||||
<div className="docs-unavailable-reason">
|
||||
<p className="muted">{topic.reason}</p>
|
||||
{!!rows.length &&
|
||||
<dl className="detail-list compact">
|
||||
<DescriptionList variant="inline" density="compact">
|
||||
{rows.map(([label, values]) =>
|
||||
<div key={String(label)}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{(values as string[]).join(", ")}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</DescriptionList>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
@@ -1001,7 +1007,7 @@ function RouteTable({ routes, emptyText }: { routes: DocsRoute[]; emptyText: str
|
||||
function PermissionList({ permissions }: { permissions: Array<{ scope: string; label: string; category: string }> }) {
|
||||
if (!permissions.length) return <p className="muted">i18n:govoplan-docs.no_granted_platform_permissions_found.36010898</p>;
|
||||
return (
|
||||
<dl className="detail-list">
|
||||
<DescriptionList variant="inline">
|
||||
{permissions.slice(0, 24).map((permission) =>
|
||||
<div key={permission.scope}>
|
||||
<dt>{permission.category}</dt>
|
||||
@@ -1009,7 +1015,7 @@ function PermissionList({ permissions }: { permissions: Array<{ scope: string; l
|
||||
</div>
|
||||
)}
|
||||
{permissions.length > 24 && <div><dt>i18n:govoplan-docs.more.4bab2d8f</dt><dd>{permissions.length - 24} i18n:govoplan-docs.additional_permissions.8042cb01</dd></div>}
|
||||
</dl>
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1047,7 +1053,7 @@ function EvidenceList({
|
||||
return (
|
||||
<>
|
||||
{sourceError && <DismissibleAlert tone="danger" resetKey={sourceError}>{sourceError}</DismissibleAlert>}
|
||||
<dl className="detail-list">
|
||||
<DescriptionList variant="inline">
|
||||
{modules.map((item) =>
|
||||
<div key={`${item.source_module_id}-${item.module_id}`}>
|
||||
<dt><StatusBadge status={item.status === "installed" ? "success" : "inactive"} label={item.status} /></dt>
|
||||
@@ -1076,7 +1082,7 @@ function EvidenceList({
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</DescriptionList>
|
||||
<Dialog
|
||||
open={selected !== null}
|
||||
title={selected?.label ?? "i18n:govoplan-docs.source_details.6dc79c75"}
|
||||
@@ -1114,14 +1120,14 @@ function SourceInspectionGroup({ title, values }: { title: string; values: Array
|
||||
return (
|
||||
<section>
|
||||
<h3>{title}</h3>
|
||||
<dl className="detail-list">
|
||||
<DescriptionList variant="inline">
|
||||
{values.map(([key, value]) =>
|
||||
<div key={key}>
|
||||
<dt>{humanizeSourceKey(key)}</dt>
|
||||
<dd>{formatSourceValue(value)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</DescriptionList>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user