feat(core): add structured documentation localization contract
Module Package Release / publish-packages (push) Successful in 13s

This commit is contained in:
2026-08-22 20:25:12 +02:00
parent c6f6faf64f
commit 562d278f60
9 changed files with 240 additions and 7 deletions
+154
View File
@@ -289,6 +289,30 @@ DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
CapabilityStability = Literal["experimental", "stable", "deprecated"]
DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION = "1"
DOCUMENTATION_LOCALIZABLE_METADATA_KEYS = frozenset(
{
"admin_explanation",
"consequence_classes",
"consequences",
"constraints",
"current_configuration",
"fields",
"limitations",
"operational_consequences",
"outcome",
"prerequisites",
"privacy_notes",
"purpose",
"result",
"steps",
"user_explanation",
"verification",
"when_used",
}
)
@dataclass(frozen=True, slots=True)
class DocumentationLink:
label: str
@@ -324,12 +348,142 @@ class DocumentationTopic:
configuration_keys: tuple[str, ...] = ()
i18n_key: str | None = None
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
structured_translation_version: str | None = None
structured_translations: Mapping[str, Mapping[str, Any]] = field(
default_factory=dict
)
source_module_id: str | None = None
version_min: str | None = None
version_max_exclusive: str | None = None
metadata: Mapping[str, Any] = field(default_factory=dict)
def localizable_documentation_metadata_keys(
topic: DocumentationTopic,
) -> tuple[str, ...]:
"""Return structured metadata keys whose values are public prose."""
return tuple(
sorted(DOCUMENTATION_LOCALIZABLE_METADATA_KEYS.intersection(topic.metadata))
)
def localized_documentation_metadata(
topic: DocumentationTopic,
locale: str,
) -> dict[str, Any]:
"""Overlay one validated structured translation onto source metadata."""
localized = dict(topic.metadata)
translation = topic.structured_translations.get(locale)
if translation:
localized.update(translation)
return localized
def documentation_structured_translation_issues(
topic: DocumentationTopic,
) -> tuple[str, ...]:
"""Validate the opt-in, versioned structured-documentation translation."""
version = topic.structured_translation_version
translations = topic.structured_translations
if version is None:
if translations:
return (
"structured_translations require structured_translation_version",
)
return ()
if version != DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION:
return (
"unsupported structured_translation_version "
f"{version!r}; expected {DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION!r}",
)
localizable_keys = set(localizable_documentation_metadata_keys(topic))
issues: list[str] = []
for locale, translation in translations.items():
if not locale.strip():
issues.append("structured translation locale must not be empty")
continue
translated_keys = set(translation)
for key in sorted(translated_keys - localizable_keys):
issues.append(
f"structured translation {locale!r} contains non-localizable or missing metadata key {key!r}"
)
for key in sorted(localizable_keys - translated_keys):
issues.append(
f"structured translation {locale!r} is missing metadata key {key!r}"
)
for key in sorted(localizable_keys & translated_keys):
issues.extend(
_structured_translation_shape_issues(
topic.metadata[key],
translation[key],
path=f"{locale}.{key}",
)
)
return tuple(issues)
def _structured_translation_shape_issues(
source: object,
translated: object,
*,
path: str,
) -> tuple[str, ...]:
if isinstance(source, str):
if not isinstance(translated, str) or not translated.strip():
return (f"structured translation {path} must be a non-empty string",)
return ()
if isinstance(source, Mapping):
if not isinstance(translated, Mapping):
return (f"structured translation {path} must preserve object shape",)
issues: list[str] = []
source_keys = {str(key) for key in source}
translated_keys = {str(key) for key in translated}
if source_keys != translated_keys:
issues.append(
f"structured translation {path} must preserve object keys"
)
return tuple(issues)
for key, value in source.items():
issues.extend(
_structured_translation_shape_issues(
value,
translated[key],
path=f"{path}.{key}",
)
)
return tuple(issues)
if isinstance(source, Sequence) and not isinstance(
source, (str, bytes, bytearray)
):
if not isinstance(translated, Sequence) or isinstance(
translated, (str, bytes, bytearray)
):
return (f"structured translation {path} must preserve list shape",)
if len(source) != len(translated):
return (f"structured translation {path} must preserve list length",)
issues: list[str] = []
for index, (source_item, translated_item) in enumerate(
zip(source, translated, strict=True)
):
issues.extend(
_structured_translation_shape_issues(
source_item,
translated_item,
path=f"{path}[{index}]",
)
)
return tuple(issues)
if translated != source:
return (
f"structured translation {path} must preserve non-text value {source!r}",
)
return ()
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
"""Return fail-closed authoring issues for a user-facing workflow topic.
+5
View File
@@ -26,6 +26,7 @@ from govoplan_core.core.modules import (
TenantSummaryBatchProvider,
TenantSummaryProvider,
user_workflow_scope_condition_issues,
documentation_structured_translation_issues,
)
from govoplan_core.core.module_entitlements import (
TenantModuleEntitlementResolver,
@@ -962,6 +963,10 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
)
for issue in documentation_structured_translation_issues(topic):
raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
)
_validate_documentation_extensions(manifest)
_validate_architecture_declarations(manifest)
_validate_workflow_definition_contributions(manifest)