#!/usr/bin/env python3 from __future__ import annotations import json import re import sys from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] I18N_ROOT = ROOT / "shared" / "i18n" PLACEHOLDER = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_]*)\s*}}") def flatten(value: Any, prefix: str = "") -> dict[str, str]: if isinstance(value, str): return {prefix: value} if not isinstance(value, dict): raise ValueError(f"{prefix or ''} must contain only objects and strings") result: dict[str, str] = {} for key, child in value.items(): if not isinstance(key, str) or not key: raise ValueError(f"{prefix or ''} contains an invalid key") child_prefix = f"{prefix}.{key}" if prefix else key result.update(flatten(child, child_prefix)) return result def load(path: Path) -> dict[str, Any]: with path.open(encoding="utf-8") as handle: value = json.load(handle) if not isinstance(value, dict): raise ValueError(f"{path} must contain a JSON object") return value def main() -> int: manifest = load(I18N_ROOT / "manifest.json") locales = manifest.get("supported_locales") namespaces = manifest.get("namespaces") if not isinstance(locales, list) or not locales: raise ValueError("manifest supported_locales must be a non-empty array") if not isinstance(namespaces, list) or not namespaces: raise ValueError("manifest namespaces must be a non-empty array") default_locale = str(manifest.get("default_locale") or locales[0]) errors: list[str] = [] for namespace in namespaces: catalogs: dict[str, dict[str, str]] = {} for locale in locales: path = I18N_ROOT / "locales" / str(locale) / f"{namespace}.json" if not path.exists(): errors.append(f"missing catalog: {path.relative_to(ROOT)}") continue try: catalogs[str(locale)] = flatten(load(path)) except (ValueError, json.JSONDecodeError) as exc: errors.append(f"invalid catalog {path.relative_to(ROOT)}: {exc}") reference = catalogs.get(default_locale) if reference is None: continue for locale, catalog in catalogs.items(): missing = sorted(set(reference) - set(catalog)) extra = sorted(set(catalog) - set(reference)) for key in missing: errors.append(f"{locale}/{namespace}: missing key {key}") for key in extra: errors.append(f"{locale}/{namespace}: unexpected key {key}") for key in sorted(set(reference) & set(catalog)): expected = set(PLACEHOLDER.findall(reference[key])) actual = set(PLACEHOLDER.findall(catalog[key])) if expected != actual: errors.append( f"{locale}/{namespace}:{key}: placeholders {sorted(actual)} " f"do not match {default_locale} {sorted(expected)}" ) if errors: print("i18n catalog validation failed:", file=sys.stderr) for error in errors: print(f"- {error}", file=sys.stderr) return 1 print(f"i18n catalogs valid: {len(locales)} locales, {len(namespaces)} namespaces") return 0 if __name__ == "__main__": raise SystemExit(main())