#!/usr/bin/env python3 """Comprehensive i18n validation for ObsiGate. Validates: 1. JSON validity for both locale files 2. Key alignment between EN and FR (no missing/mismatched keys) 3. Empty or truncated values 4. Non-string values (nested objects by accident) 5. Untranslated keys (FR == EN, excluding system keys) 6. HTML i18n references all have corresponding EN keys 7. Auto-generated key health (truncation, nonsense values) Usage: python scripts/validate-i18n.py [--fix] """ import json import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent.parent FRONTEND = ROOT / "frontend" EN_PATH = FRONTEND / "locales" / "en.json" FR_PATH = FRONTEND / "locales" / "fr.json" HTML_PATH = FRONTEND / "index.html" EXIT_CODE = 0 def fail(msg: str): global EXIT_CODE print(f" FAIL: {msg}") EXIT_CODE = 1 def ok(msg: str): print(f" OK: {msg}") def main(): # 1. Load JSON print("=== 1. JSON Validity ===") try: en = json.loads(EN_PATH.read_text(encoding="utf-8")) print(f" EN: {len(en)} keys, valid JSON") except Exception as e: fail(f"EN invalid: {e}") sys.exit(1) try: fr = json.loads(FR_PATH.read_text(encoding="utf-8")) print(f" FR: {len(fr)} keys, valid JSON") except Exception as e: fail(f"FR invalid: {e}") sys.exit(1) # 2. Key alignment print("\n=== 2. Key Alignment ===") en_only = set(en.keys()) - set(fr.keys()) fr_only = set(fr.keys()) - set(en.keys()) if en_only: for k in sorted(en_only): fail(f"EN-only key: {k}") if fr_only: for k in sorted(fr_only): fail(f"FR-only key: {k}") if not en_only and not fr_only: ok(f"Keys aligned: {len(en)} = {len(fr)}") # 3. Empty / non-string values print("\n=== 3. Value Health ===") empty_en = [k for k, v in en.items() if isinstance(v, str) and not v.strip()] empty_fr = [k for k, v in fr.items() if isinstance(v, str) and not v.strip()] non_str_en = [(k, type(v).__name__) for k, v in en.items() if not isinstance(v, str)] non_str_fr = [(k, type(v).__name__) for k, v in fr.items() if not isinstance(v, str)] if empty_en: for k in empty_en: fail(f"Empty EN: {k}") if empty_fr: for k in empty_fr: fail(f"Empty FR: {k}") if non_str_en: for k, t in non_str_en: fail(f"Non-string EN[{k}]: {t}") if non_str_fr: for k, t in non_str_fr: fail(f"Non-string FR[{k}]: {t}") if not empty_en and not empty_fr and not non_str_en and not non_str_fr: ok("All values non-empty strings") # 4. Untranslated keys print("\n=== 4. Untranslated Keys (FR == EN) ===") untranslated = [] system_keys = {"This key is auto-generated"} for k in en: if k in fr and isinstance(en[k], str) and isinstance(fr[k], str): if en[k] == fr[k] and en[k].strip() and en[k] not in system_keys: untranslated.append(k) if untranslated: for k in sorted(untranslated): fail(f"Untranslated: {k} = \"{en[k][:80]}\"") else: ok("No untranslated keys (excluding system keys)") # 5. Auto-key health print("\n=== 5. Auto-Key Health ===") auto_en = {k: v for k, v in en.items() if k.startswith("auto.")} truncated = [] for k, v in auto_en.items(): if not isinstance(v, str): continue if not v.endswith((".", ")", "]", "}", '"', "!", "?", "▾", "…")) and len(v) < 80: # Possible truncation — but check if it's a short label if len(v) < 30 and v[0].isupper(): truncated.append((k, v)) if truncated: for k, v in truncated: fail(f"Possibly truncated auto-key: {k} = \"{v}\"") else: ok(f"{len(auto_en)} auto-keys, none suspicious") # 6. HTML i18n references print("\n=== 6. HTML i18n References ===") html = HTML_PATH.read_text(encoding="utf-8") refs = set() refs |= set(re.findall(r'data-i18n="([^"]+)"', html)) refs |= set(re.findall(r'data-i18n-title="([^"]+)"', html)) refs |= set(re.findall(r'data-i18n-placeholder="([^"]+)"', html)) refs |= set(re.findall(r"t\('([^']+)'\)", html)) # Filter false positives (JavaScript keywords, short strings) false_positives = {"input", "true", "false", "null", "undefined", "length"} missing = sorted(r for r in refs if r not in en and r not in false_positives) if missing: for k in missing: fail(f"HTML ref missing from EN: {k}") else: ok(f"{len(refs)} HTML refs, all in EN") # 7. Summary print(f"\n=== Summary ===") if EXIT_CODE == 0: print("ALL CHECKS PASSED — i18n production-ready") else: print(f"{EXIT_CODE} issue(s) found") sys.exit(EXIT_CODE) if __name__ == "__main__": main()