feat(core): add structured documentation localization contract
Module Package Release / publish-packages (push) Successful in 13s
Module Package Release / publish-packages (push) Successful in 13s
This commit is contained in:
@@ -18,6 +18,27 @@ The platform inventory recognizes both inline locale objects and generated
|
|||||||
catalogs declared as `const de` / `const en`. Its strict mode requires both
|
catalogs declared as `const de` / `const en`. Its strict mode requires both
|
||||||
locales and reports `de` explicitly as the reference locale.
|
locales and reports `de` explicitly as the reference locale.
|
||||||
|
|
||||||
|
## Structured Documentation Localization
|
||||||
|
|
||||||
|
`DocumentationTopic.translations` continues to own localized title, summary,
|
||||||
|
and body prose. Topics whose metadata contains rendered prose opt into the
|
||||||
|
separate `structured_translation_version="1"` contract and provide a complete
|
||||||
|
same-shape value for each translated metadata key in
|
||||||
|
`structured_translations`. Version 1 covers workflow prerequisites, steps,
|
||||||
|
outcome, result and verification; reference fields; limitations, constraints,
|
||||||
|
consequences and consequence classes; and the other rendered explanation
|
||||||
|
fields declared by Core.
|
||||||
|
|
||||||
|
The registry rejects an unversioned translation, an unsupported contract
|
||||||
|
version, missing structured keys, changed object keys or list lengths, empty
|
||||||
|
translated strings, and changed non-text values. Stable field IDs, routes,
|
||||||
|
permission scopes, and other technical leaves therefore remain structurally
|
||||||
|
bound to the source metadata. The Docs module overlays only a validated locale
|
||||||
|
at response time and reports the selected structured locale separately from the
|
||||||
|
title/body locale. Missing structured translations fall back to source content
|
||||||
|
and remain visible in public coverage until the owning module adopts the
|
||||||
|
contract.
|
||||||
|
|
||||||
## Help Resolution
|
## Help Resolution
|
||||||
|
|
||||||
Every focusable field and action receives a stable derived F1 identity from the
|
Every focusable field and action receives a stable derived F1 identity from the
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.36"
|
version = "0.1.37"
|
||||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -289,6 +289,30 @@ DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
|
|||||||
CapabilityStability = Literal["experimental", "stable", "deprecated"]
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class DocumentationLink:
|
class DocumentationLink:
|
||||||
label: str
|
label: str
|
||||||
@@ -324,12 +348,142 @@ class DocumentationTopic:
|
|||||||
configuration_keys: tuple[str, ...] = ()
|
configuration_keys: tuple[str, ...] = ()
|
||||||
i18n_key: str | None = None
|
i18n_key: str | None = None
|
||||||
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
|
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
|
source_module_id: str | None = None
|
||||||
version_min: str | None = None
|
version_min: str | None = None
|
||||||
version_max_exclusive: str | None = None
|
version_max_exclusive: str | None = None
|
||||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
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, ...]:
|
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
|
||||||
"""Return fail-closed authoring issues for a user-facing workflow topic.
|
"""Return fail-closed authoring issues for a user-facing workflow topic.
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from govoplan_core.core.modules import (
|
|||||||
TenantSummaryBatchProvider,
|
TenantSummaryBatchProvider,
|
||||||
TenantSummaryProvider,
|
TenantSummaryProvider,
|
||||||
user_workflow_scope_condition_issues,
|
user_workflow_scope_condition_issues,
|
||||||
|
documentation_structured_translation_issues,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.module_entitlements import (
|
from govoplan_core.core.module_entitlements import (
|
||||||
TenantModuleEntitlementResolver,
|
TenantModuleEntitlementResolver,
|
||||||
@@ -962,6 +963,10 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
|||||||
raise RegistryError(
|
raise RegistryError(
|
||||||
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
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_documentation_extensions(manifest)
|
||||||
_validate_architecture_declarations(manifest)
|
_validate_architecture_declarations(manifest)
|
||||||
_validate_workflow_definition_contributions(manifest)
|
_validate_workflow_definition_contributions(manifest)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from govoplan_core.core.modules import (
|
|||||||
DocumentationSourceDefinition,
|
DocumentationSourceDefinition,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
|
localized_documentation_metadata,
|
||||||
user_workflow_scope_condition_issues,
|
user_workflow_scope_condition_issues,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||||
@@ -83,6 +84,58 @@ class DocumentationTopicContractTests(unittest.TestCase):
|
|||||||
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
|
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
|
||||||
registry_for(scoped, admin_workflow, user_reference).validate()
|
registry_for(scoped, admin_workflow, user_reference).validate()
|
||||||
|
|
||||||
|
def test_versioned_structured_translation_preserves_metadata_shape(self) -> None:
|
||||||
|
topic = DocumentationTopic(
|
||||||
|
id="example.workflow.localized",
|
||||||
|
title="Run task",
|
||||||
|
summary="Run the task.",
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"steps": ["Review", "Execute"],
|
||||||
|
"verification": "Confirm the result.",
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"steps": ["Prüfen", "Ausführen"],
|
||||||
|
"verification": "Das Ergebnis bestätigen.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
registry_for(topic).validate()
|
||||||
|
self.assertEqual(
|
||||||
|
["Prüfen", "Ausführen"],
|
||||||
|
localized_documentation_metadata(topic, "de")["steps"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"workflow", localized_documentation_metadata(topic, "de")["kind"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_structured_translation_requires_version_and_complete_shape(self) -> None:
|
||||||
|
missing_version = DocumentationTopic(
|
||||||
|
id="example.localized.missing-version",
|
||||||
|
title="Localized",
|
||||||
|
summary="Invalid contract.",
|
||||||
|
metadata={"limitations": ["One", "Two"]},
|
||||||
|
structured_translations={"de": {"limitations": ["Eins", "Zwei"]}},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
RegistryError, "require structured_translation_version"
|
||||||
|
):
|
||||||
|
registry_for(missing_version).validate()
|
||||||
|
|
||||||
|
incomplete_shape = DocumentationTopic(
|
||||||
|
id="example.localized.incomplete",
|
||||||
|
title="Localized",
|
||||||
|
summary="Invalid shape.",
|
||||||
|
metadata={"limitations": ["One", "Two"]},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={"de": {"limitations": ["Eins"]}},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RegistryError, "preserve list length"):
|
||||||
|
registry_for(incomplete_shape).validate()
|
||||||
|
|
||||||
def test_documentation_configuration_and_source_extensions_are_validated(self) -> None:
|
def test_documentation_configuration_and_source_extensions_are_validated(self) -> None:
|
||||||
resolver = lambda _context, keys: { # noqa: E731
|
resolver = lambda _context, keys: { # noqa: E731
|
||||||
key: DocumentationConfigurationDecision(key=key, state="enabled")
|
key: DocumentationConfigurationDecision(key=key, state="enabled")
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.36",
|
"version": "0.1.37",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.36",
|
"version": "0.1.37",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.36",
|
"version": "0.1.37",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.36",
|
"version": "0.1.37",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.20",
|
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.20",
|
||||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.19",
|
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.19",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.36",
|
"version": "0.1.37",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.36",
|
"version": "0.1.37",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user