154 lines
4.8 KiB
Python
154 lines
4.8 KiB
Python
#!/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()
|