#!/usr/bin/env python3 """Gate on the two message rules that cannot be caught at runtime. 1. **Every locale carries every key.** Paraglide falls back to the base locale for a missing message, which means an untranslated string ships silently as German inside an English interface. A missing key is a build failure here instead. 2. **No em or en dashes in UI copy.** They are hard to type, inconsistent across the app when hand-written, and in German they collide with the Gedankenstrich convention. Commas, colons or a second sentence do the job. Prose in docs/ and comments is unaffected: this only reads the message files. Run by `make lint`, so both rules hold for every route the migration touches rather than only where someone remembered. """ import json import sys from pathlib import Path MESSAGES = Path(__file__).resolve().parents[1] / "messages" BASE_LOCALE = "de" DASHES = {"—": "em dash", "–": "en dash"} def load(path: Path) -> dict[str, object]: data = json.loads(path.read_text(encoding="utf-8")) return {key: value for key, value in data.items() if not key.startswith("$")} def strings_in(value: object) -> list[str]: """Every translatable string inside a message. A message is either a plain string or a list of variants, each with a `match` object mapping a selector to a string (see docs/i18n.md, pluralization). Declarations and selectors are machinery, not copy, so only the match values are checked for dashes. """ if isinstance(value, str): return [value] if isinstance(value, list): return [ text for variant in value if isinstance(variant, dict) for text in variant.get("match", {}).values() if isinstance(text, str) ] return [] def main() -> int: files = sorted(MESSAGES.glob("*.json")) if not files: print(f"check-messages: no message files in {MESSAGES}", file=sys.stderr) return 1 catalogs = {path.stem: load(path) for path in files} if BASE_LOCALE not in catalogs: print(f"check-messages: missing base locale {BASE_LOCALE}.json", file=sys.stderr) return 1 problems: list[str] = [] base_keys = set(catalogs[BASE_LOCALE]) for locale, catalog in sorted(catalogs.items()): if locale == BASE_LOCALE: continue for key in sorted(base_keys - set(catalog)): problems.append( f"{locale}.json: missing message '{key}' " f"(present in {BASE_LOCALE}.json): a missing translation " f"would silently ship as {BASE_LOCALE}" ) for key in sorted(set(catalog) - base_keys): problems.append( f"{locale}.json: message '{key}' has no counterpart in " f"{BASE_LOCALE}.json: the source language defines the set" ) for locale, catalog in sorted(catalogs.items()): for key, value in sorted(catalog.items()): for char, name in DASHES.items(): if any(char in text for text in strings_in(value)): problems.append( f"{locale}.json: message '{key}' contains an {name} " f"({char}): use a comma, a colon, or two sentences" ) if problems: print("check-messages: FAILED", file=sys.stderr) for problem in problems: print(f" {problem}", file=sys.stderr) return 1 total = len(base_keys) locales = ", ".join(sorted(catalogs)) print(f"check-messages: {total} messages complete in {locales}, no dashes") return 0 if __name__ == "__main__": sys.exit(main())