Compare commits
10
Commits
v0.1.15
...
77eb7339e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77eb7339e6 | ||
|
|
281f35310b | ||
|
|
e05d5b4a54 | ||
|
|
0b4e7b6391 | ||
|
|
36c0afadd4 | ||
|
|
b5d3983844 | ||
|
|
30ea95854b | ||
|
|
f14692a15e | ||
|
|
8cbab781bf | ||
|
|
2d18ecc9b6 |
@@ -70,6 +70,31 @@ cd /mnt/DATA/git/govoplan
|
|||||||
Feature content remains in the owning module. Docs indexes and renders the
|
Feature content remains in the owning module. Docs indexes and renders the
|
||||||
contributions without importing feature implementations.
|
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
|
||||||
|
visible exact, page-level, or module-level topic after applying audience and
|
||||||
|
permission filtering. Modules announce exact route and control identities in
|
||||||
|
static topic `metadata.help_contexts`.
|
||||||
|
|
||||||
Capabilities can provide generic documentation without exposing their runtime
|
Capabilities can provide generic documentation without exposing their runtime
|
||||||
provider implementation:
|
provider implementation:
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
"name": "@govoplan/docs-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.15",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
+6
-3
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-docs"
|
name = "govoplan-docs"
|
||||||
version = "0.1.15"
|
version = "0.1.18"
|
||||||
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
|
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.15",
|
"govoplan-core>=0.1.18",
|
||||||
"govoplan-access>=0.1.15",
|
"govoplan-access>=0.1.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
@@ -22,3 +22,6 @@ govoplan_docs = ["py.typed"]
|
|||||||
|
|
||||||
[project.entry-points."govoplan.modules"]
|
[project.entry-points."govoplan.modules"]
|
||||||
docs = "govoplan_docs.backend.manifest:get_manifest"
|
docs = "govoplan_docs.backend.manifest:get_manifest"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
govoplan-docs-export-public = "govoplan_docs.public_export:main"
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
__version__ = "0.1.15"
|
__version__ = "0.1.18"
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ ARCHITECTURE = ModuleArchitectureDeclaration(
|
|||||||
reference="docs/DOCUMENTATION_LAYER_CONCEPT.md",
|
reference="docs/DOCUMENTATION_LAYER_CONCEPT.md",
|
||||||
summary="Defines the manifest-driven documentation boundary.",
|
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=(
|
known_limits=(
|
||||||
"Architecture declarations are in staged adoption, so undeclared modules remain visible as pending.",
|
"Architecture declarations are in staged adoption, so undeclared modules remain visible as pending.",
|
||||||
@@ -84,7 +89,7 @@ def _route_factory(context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="docs",
|
id="docs",
|
||||||
name="Docs",
|
name="Docs",
|
||||||
version="0.1.15",
|
version="0.1.18",
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
@@ -168,8 +173,8 @@ manifest = ModuleManifest(
|
|||||||
},
|
},
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Public GovOPlaN module documentation",
|
label="Public GovOPlaN documentation",
|
||||||
href="https://govplan.add-ideas.de/",
|
href="https://govoplan.add-ideas.de/docs",
|
||||||
kind="public",
|
kind="public",
|
||||||
),
|
),
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
@@ -185,6 +190,41 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
metadata={"kind": "system"},
|
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(
|
DocumentationTopic(
|
||||||
id="docs.reference.institutional-governance-architecture",
|
id="docs.reference.institutional-governance-architecture",
|
||||||
title="Institutional governance architecture",
|
title="Institutional governance architecture",
|
||||||
@@ -208,7 +248,7 @@ manifest = ModuleManifest(
|
|||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Institutional governance target architecture",
|
label="Institutional governance target architecture",
|
||||||
href="govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md",
|
href="govoplan/docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md",
|
||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
@@ -263,6 +303,69 @@ manifest = ModuleManifest(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="docs.pattern.contextual-help",
|
||||||
|
title="Context-sensitive help",
|
||||||
|
summary="Press F1 on a page, field, action, or dialog to open help for the current interface context.",
|
||||||
|
body=(
|
||||||
|
"GovOPlaN first resolves help for the focused field or action, then its dialog or section, "
|
||||||
|
"the current page, and the owning module. Exact documentation is shown when available; "
|
||||||
|
"otherwise the page or module documentation is used. The titlebar help button opens the "
|
||||||
|
"current page context. Documentation remains filtered by the current account's audience, "
|
||||||
|
"permissions, and configured modules."
|
||||||
|
),
|
||||||
|
layer="always",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "tenant_admin", "operator", "module_admin"),
|
||||||
|
order=21,
|
||||||
|
i18n_key="docs.topic.pattern.contextual_help",
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Contextual help contract",
|
||||||
|
href="govoplan-core/docs/CONTEXTUAL_HELP_CONTRACT.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "pattern",
|
||||||
|
"pattern_id": "contextual-f1-help",
|
||||||
|
"help_contexts": [
|
||||||
|
"core.contextual-help",
|
||||||
|
"core.titlebar.language",
|
||||||
|
],
|
||||||
|
"component_refs": [
|
||||||
|
"govoplan-core/webui/src/layout/HelpMenu.tsx",
|
||||||
|
"govoplan-core/webui/src/utils/helpContext.ts",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="docs.reference.temporal-data-context",
|
||||||
|
title="Temporal data context",
|
||||||
|
summary="Choose whether pages show currently valid records, records valid at a selected time, or all valid-time states.",
|
||||||
|
body=(
|
||||||
|
"The titlebar calendar controls valid time across participating modules. Current is the neutral "
|
||||||
|
"default. At time selects records valid at the chosen instant, while All includes historical and "
|
||||||
|
"future valid-time states. Recorded time remains separate: it describes when the platform learned "
|
||||||
|
"or stored a fact. Permissions are evaluated now, so temporal selection never restores historical access."
|
||||||
|
),
|
||||||
|
layer="always",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "tenant_admin", "operator", "module_admin"),
|
||||||
|
order=22,
|
||||||
|
i18n_key="docs.topic.reference.temporal_data_context",
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Temporal data read contract",
|
||||||
|
href="govoplan-core/docs/TEMPORAL_DATA_CONTEXT.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": ["core.temporal-data-context"],
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="docs.reference.organization-identity-idm-access-boundary",
|
id="docs.reference.organization-identity-idm-access-boundary",
|
||||||
title="Organization, identity, IDM, and access boundary",
|
title="Organization, identity, IDM, and access boundary",
|
||||||
@@ -278,7 +381,7 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("tenant_admin", "access_admin", "operator", "user"),
|
audience=("tenant_admin", "access_admin", "operator", "user"),
|
||||||
related_modules=("organizations", "identity", "idm", "access"),
|
related_modules=("organizations", "identity", "idm", "access"),
|
||||||
order=21,
|
order=23,
|
||||||
conditions=(
|
conditions=(
|
||||||
DocumentationCondition(
|
DocumentationCondition(
|
||||||
required_modules=("organizations", "identity", "idm", "access"),
|
required_modules=("organizations", "identity", "idm", "access"),
|
||||||
|
|||||||
@@ -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",
|
"name": "@govoplan/docs-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.15",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link, useLocation } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
import { ChevronDown, ChevronRight, Eye, RefreshCw } from "lucide-react";
|
import { ChevronDown, ChevronRight, Eye, RefreshCw } from "lucide-react";
|
||||||
@@ -7,10 +8,11 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
ExplorerTree,
|
ExplorerTree,
|
||||||
LoadingFrame,
|
PageActionBar,
|
||||||
PageTitle,
|
PageLayout,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
WorkspaceLayout,
|
||||||
adminErrorMessage,
|
adminErrorMessage,
|
||||||
useGuardedNavigate,
|
useGuardedNavigate,
|
||||||
usePlatformLanguage,
|
usePlatformLanguage,
|
||||||
@@ -132,92 +134,96 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
|||||||
}, [treeNodes, selectedPage?.id]);
|
}, [treeNodes, selectedPage?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workspace module-workspace docs-workspace">
|
<WorkspaceLayout
|
||||||
<aside className="section-sidebar docs-outline" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
className="module-workspace docs-workspace"
|
||||||
<div className="docs-sidebar-header">
|
primarySize="wide"
|
||||||
<div className="section-title">i18n:govoplan-docs.help_center.f3f3a34b</div>
|
primaryLabel="i18n:govoplan-docs.documentation_outline.6f836b99"
|
||||||
<AudienceToggle
|
contentLabel="i18n:govoplan-docs.help_center.f3f3a34b"
|
||||||
selected={documentationType}
|
documentationType={documentationType}
|
||||||
onSelect={selectDocumentationType}
|
contentClassName="docs-workspace-content"
|
||||||
canViewAdmin={context?.actor.available_documentation_types.includes("admin") ?? documentationType === "admin"}
|
primary={(
|
||||||
/>
|
<aside className="section-sidebar docs-outline" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
||||||
<label className="docs-version-selector" title={context?.versions.fallback_policy}>
|
<div className="docs-sidebar-header">
|
||||||
<span>Version</span>
|
<div className="section-title">i18n:govoplan-docs.help_center.f3f3a34b</div>
|
||||||
<select
|
<AudienceToggle
|
||||||
value={selectedVersion ?? ""}
|
selected={documentationType}
|
||||||
onChange={(event) => selectVersion(event.target.value || null)}
|
onSelect={selectDocumentationType}
|
||||||
>
|
canViewAdmin={context?.actor.available_documentation_types.includes("admin") ?? documentationType === "admin"}
|
||||||
<option value="">Installed versions</option>
|
/>
|
||||||
{selectedVersion && !context?.versions.supported_versions.includes(selectedVersion) &&
|
<label className="docs-version-selector" title={context?.versions.fallback_policy}>
|
||||||
<option value={selectedVersion}>{selectedVersion} (unsupported)</option>
|
<span>Version</span>
|
||||||
}
|
<select
|
||||||
{(context?.versions.supported_versions ?? []).map((version) => (
|
value={selectedVersion ?? ""}
|
||||||
<option key={version} value={version}>{version}</option>
|
onChange={(event) => selectVersion(event.target.value || null)}
|
||||||
))}
|
>
|
||||||
</select>
|
<option value="">Installed versions</option>
|
||||||
</label>
|
{selectedVersion && !context?.versions.supported_versions.includes(selectedVersion) &&
|
||||||
</div>
|
<option value={selectedVersion}>{selectedVersion} (unsupported)</option>
|
||||||
<nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
}
|
||||||
<ExplorerTree
|
{(context?.versions.supported_versions ?? []).map((version) => (
|
||||||
nodes={treeNodes}
|
<option key={version} value={version}>{version}</option>
|
||||||
getNodeId={(node) => node.id}
|
))}
|
||||||
getNodeLabel={(node) => node.title}
|
</select>
|
||||||
getNodeChildren={(node) => node.children}
|
</label>
|
||||||
activeId={activeNodeIdForPage(treeNodes, selectedPage?.id ?? "")}
|
|
||||||
expandedIds={expandedNodes}
|
|
||||||
depth={0}
|
|
||||||
childrenBaseClassName="docs-tree-children"
|
|
||||||
nodeContainerClassName="docs-tree-node"
|
|
||||||
nodeWrapBaseClassName="docs-tree-row"
|
|
||||||
toggleBaseClassName="docs-tree-toggle"
|
|
||||||
nodeButtonBaseClassName="docs-tree-page"
|
|
||||||
getNodeWrapStyle={(_node, context) => ({ paddingLeft: `${context.depth * 14}px` })}
|
|
||||||
getNodeButtonClassName={(node) => selectedPage?.id === node.page.id ? "is-active" : ""}
|
|
||||||
renderToggleIcon={(_node, context) => context.hasChildren ? context.expanded ? <ChevronDown size={15} /> : <ChevronRight size={15} /> : <span className="docs-tree-toggle-placeholder" />}
|
|
||||||
renderNodeContent={(node) => node.title}
|
|
||||||
onToggle={(node) => toggleNode(node.id)}
|
|
||||||
onOpen={(node) => selectPage(node.page)}
|
|
||||||
/>
|
|
||||||
</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>
|
</div>
|
||||||
|
<nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
<ExplorerTree
|
||||||
|
nodes={treeNodes}
|
||||||
<LoadingFrame loading={loading} label="i18n:govoplan-docs.loading_documentation_context.1c091645">
|
getNodeId={(node) => node.id}
|
||||||
<div className="docs-content">
|
getNodeLabel={(node) => node.title}
|
||||||
<main className="docs-page-main">
|
getNodeChildren={(node) => node.children}
|
||||||
<SelectedPageContent
|
activeId={activeNodeIdForPage(treeNodes, selectedPage?.id ?? "")}
|
||||||
page={selectedPage}
|
expandedIds={expandedNodes}
|
||||||
adminDocs={adminDocs}
|
depth={0}
|
||||||
documentationType={documentationType}
|
childrenBaseClassName="docs-tree-children"
|
||||||
topicById={topicById}
|
nodeContainerClassName="docs-tree-node"
|
||||||
moduleRows={moduleRows}
|
nodeWrapBaseClassName="docs-tree-row"
|
||||||
visibleRoutes={visibleRoutes}
|
toggleBaseClassName="docs-tree-toggle"
|
||||||
availableRoutes={availableRoutes}
|
nodeButtonBaseClassName="docs-tree-page"
|
||||||
grantedPermissions={grantedPermissions}
|
getNodeWrapStyle={(_node, context) => ({ paddingLeft: `${context.depth * 14}px` })}
|
||||||
evidenceModules={context?.layers.evidence.optional_modules ?? []}
|
getNodeButtonClassName={(node) => selectedPage?.id === node.page.id ? "is-active" : ""}
|
||||||
evidenceSources={context?.layers.evidence.sources ?? []}
|
renderToggleIcon={(_node, context) => context.hasChildren ? context.expanded ? <ChevronDown size={15} /> : <ChevronRight size={15} /> : <span className="docs-tree-toggle-placeholder" />}
|
||||||
settings={settings}
|
renderNodeContent={(node) => node.title}
|
||||||
locale={locale}
|
onToggle={(node) => toggleNode(node.id)}
|
||||||
/>
|
onOpen={(node) => selectPage(node.page)}
|
||||||
</main>
|
/>
|
||||||
<PageOutline items={outlineItems} />
|
</nav>
|
||||||
</div>
|
</aside>
|
||||||
</LoadingFrame>
|
)}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
page={selectedPage}
|
||||||
|
adminDocs={adminDocs}
|
||||||
|
documentationType={documentationType}
|
||||||
|
topicById={topicById}
|
||||||
|
moduleRows={moduleRows}
|
||||||
|
visibleRoutes={visibleRoutes}
|
||||||
|
availableRoutes={availableRoutes}
|
||||||
|
grantedPermissions={grantedPermissions}
|
||||||
|
evidenceModules={context?.layers.evidence.optional_modules ?? []}
|
||||||
|
evidenceSources={context?.layers.evidence.sources ?? []}
|
||||||
|
settings={settings}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
<PageOutline items={outlineItems} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</PageLayout>
|
||||||
</div>
|
</WorkspaceLayout>
|
||||||
);
|
);
|
||||||
|
|
||||||
function selectDocumentationType(type: DocumentationType) {
|
function selectDocumentationType(type: DocumentationType) {
|
||||||
@@ -459,7 +465,7 @@ function ConstraintDetails({ id, constraints }: { id: string; constraints: Recor
|
|||||||
return (
|
return (
|
||||||
<div className="docs-detail-block" id={id}>
|
<div className="docs-detail-block" id={id}>
|
||||||
<h4>i18n:govoplan-docs.requirements.09a428f9</h4>
|
<h4>i18n:govoplan-docs.requirements.09a428f9</h4>
|
||||||
<dl className="detail-list compact">
|
<DescriptionList variant="inline" density="compact">
|
||||||
{constraints.map((constraint, index) => {
|
{constraints.map((constraint, index) => {
|
||||||
const label = metadataString(constraint, "label");
|
const label = metadataString(constraint, "label");
|
||||||
const description = metadataString(constraint, "description");
|
const description = metadataString(constraint, "description");
|
||||||
@@ -474,7 +480,7 @@ function ConstraintDetails({ id, constraints }: { id: string; constraints: Recor
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</dl>
|
</DescriptionList>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -487,11 +493,11 @@ function ReferenceDetails({ topic, showTechnical, documentationType, topicById }
|
|||||||
return (
|
return (
|
||||||
<div className="docs-topic-details">
|
<div className="docs-topic-details">
|
||||||
{(route || screen || section) &&
|
{(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>}
|
{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>}
|
{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>}
|
{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} />}
|
{!!fields.length && <ReferenceFieldTable id={`${topicAnchorId(topic)}-fields`} fields={fields} showTechnical={showTechnical} />}
|
||||||
<RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} />
|
<RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} />
|
||||||
@@ -604,14 +610,14 @@ function UnavailableDocumentationReason({ topic }: { topic: DocsDocumentationTop
|
|||||||
<div className="docs-unavailable-reason">
|
<div className="docs-unavailable-reason">
|
||||||
<p className="muted">{topic.reason}</p>
|
<p className="muted">{topic.reason}</p>
|
||||||
{!!rows.length &&
|
{!!rows.length &&
|
||||||
<dl className="detail-list compact">
|
<DescriptionList variant="inline" density="compact">
|
||||||
{rows.map(([label, values]) =>
|
{rows.map(([label, values]) =>
|
||||||
<div key={String(label)}>
|
<div key={String(label)}>
|
||||||
<dt>{label}</dt>
|
<dt>{label}</dt>
|
||||||
<dd>{(values as string[]).join(", ")}</dd>
|
<dd>{(values as string[]).join(", ")}</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</DescriptionList>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -857,9 +863,12 @@ function selectedPageFromSearch(search: string, pages: DocsPageNode[]): DocsPage
|
|||||||
if (requested) return pages.find((page) => page.id === requested) ?? pages[0];
|
if (requested) return pages.find((page) => page.id === requested) ?? pages[0];
|
||||||
const helpContext = params.get("context") || "";
|
const helpContext = params.get("context") || "";
|
||||||
if (helpContext) {
|
if (helpContext) {
|
||||||
const exact = pages.find((page) => page.kind === "topic" && metadataList(page.topic.metadata, "help_contexts").includes(helpContext));
|
const fallbackContext = params.get("fallback_context") || "";
|
||||||
if (exact) return exact;
|
for (const contextId of [helpContext, fallbackContext].filter(Boolean)) {
|
||||||
const moduleId = helpContextModuleId(helpContext);
|
const exact = pages.find((page) => page.kind === "topic" && metadataList(page.topic.metadata, "help_contexts").includes(contextId));
|
||||||
|
if (exact) return exact;
|
||||||
|
}
|
||||||
|
const moduleId = params.get("module") || helpContextModuleId(fallbackContext || helpContext);
|
||||||
const moduleTopic = pages.find((page) => page.kind === "topic" && page.topic.source_module_id === moduleId);
|
const moduleTopic = pages.find((page) => page.kind === "topic" && page.topic.source_module_id === moduleId);
|
||||||
if (moduleTopic) return moduleTopic;
|
if (moduleTopic) return moduleTopic;
|
||||||
}
|
}
|
||||||
@@ -998,7 +1007,7 @@ function RouteTable({ routes, emptyText }: { routes: DocsRoute[]; emptyText: str
|
|||||||
function PermissionList({ permissions }: { permissions: Array<{ scope: string; label: string; category: string }> }) {
|
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>;
|
if (!permissions.length) return <p className="muted">i18n:govoplan-docs.no_granted_platform_permissions_found.36010898</p>;
|
||||||
return (
|
return (
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
{permissions.slice(0, 24).map((permission) =>
|
{permissions.slice(0, 24).map((permission) =>
|
||||||
<div key={permission.scope}>
|
<div key={permission.scope}>
|
||||||
<dt>{permission.category}</dt>
|
<dt>{permission.category}</dt>
|
||||||
@@ -1006,7 +1015,7 @@ function PermissionList({ permissions }: { permissions: Array<{ scope: string; l
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{permissions.length > 24 && <div><dt>i18n:govoplan-docs.more.4bab2d8f</dt><dd>{permissions.length - 24} i18n:govoplan-docs.additional_permissions.8042cb01</dd></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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,7 +1053,7 @@ function EvidenceList({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{sourceError && <DismissibleAlert tone="danger" resetKey={sourceError}>{sourceError}</DismissibleAlert>}
|
{sourceError && <DismissibleAlert tone="danger" resetKey={sourceError}>{sourceError}</DismissibleAlert>}
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
{modules.map((item) =>
|
{modules.map((item) =>
|
||||||
<div key={`${item.source_module_id}-${item.module_id}`}>
|
<div key={`${item.source_module_id}-${item.module_id}`}>
|
||||||
<dt><StatusBadge status={item.status === "installed" ? "success" : "inactive"} label={item.status} /></dt>
|
<dt><StatusBadge status={item.status === "installed" ? "success" : "inactive"} label={item.status} /></dt>
|
||||||
@@ -1073,7 +1082,7 @@ function EvidenceList({
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</DescriptionList>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={selected !== null}
|
open={selected !== null}
|
||||||
title={selected?.label ?? "i18n:govoplan-docs.source_details.6dc79c75"}
|
title={selected?.label ?? "i18n:govoplan-docs.source_details.6dc79c75"}
|
||||||
@@ -1111,14 +1120,14 @@ function SourceInspectionGroup({ title, values }: { title: string; values: Array
|
|||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
{values.map(([key, value]) =>
|
{values.map(([key, value]) =>
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<dt>{humanizeSourceKey(key)}</dt>
|
<dt>{humanizeSourceKey(key)}</dt>
|
||||||
<dd>{formatSourceValue(value)}</dd>
|
<dd>{formatSourceValue(value)}</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</DescriptionList>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user