chore(tools) add script to audit translations

This commit is contained in:
Edouard Vanbelle
2026-06-05 14:01:00 +02:00
parent 540c947e61
commit 6f9e12624b
2 changed files with 295 additions and 18 deletions
Regular → Executable
+48 -18
View File
@@ -3,9 +3,18 @@
check-icons.py — Audit FA icon usage against the inline SVG registry.
Usage:
python3 tools/check-icons.py [--dry-run]
python3 tools/check-icons.py [--dry-run] [--check-only]
What it does:
Modes:
(default) Scan, diff, and **patch** icons.js — clones Font-Awesome
to tmp/ if missing so SVG paths can be resolved.
--dry-run Same as default but print the proposed insertions
instead of writing icons.js.
--check-only CI-friendly: just scan + diff and exit with status 1
if any used FA icon is absent from OxiIcons. No
Font-Awesome clone, no file writes, no SVG parsing.
What the full mode does:
1. Scans every file under static/ for fas fa-<name> occurrences.
2. Reads the OxiIcons registry from static/js/core/icons.js.
3. For each icon name that is missing from the registry, looks up
@@ -29,25 +38,30 @@ STATIC_DIR = REPO_ROOT / "static"
ICONS_JS = STATIC_DIR / "js" / "core" / "icons.js"
FA_SVG_DIR = REPO_ROOT / "tmp" / "Font-Awesome" / "svgs" / "solid"
DRY_RUN = "--dry-run" in sys.argv
DRY_RUN = "--dry-run" in sys.argv
CHECK_ONLY = "--check-only" in sys.argv
# ── 0. Ensure Font-Awesome source is available ────────────────────────────────
TMP_DIR = REPO_ROOT / "tmp"
if not TMP_DIR.exists():
print(f"Creating {TMP_DIR.relative_to(REPO_ROOT)}/")
TMP_DIR.mkdir(parents=True, exist_ok=True)
# Skipped in --check-only mode — that path stops after the diff (step 3)
# so it never needs to resolve SVG sources. This keeps CI runs offline,
# fast, and free of clone side effects in the checkout dir.
if not CHECK_ONLY:
TMP_DIR = REPO_ROOT / "tmp"
if not TMP_DIR.exists():
print(f"Creating {TMP_DIR.relative_to(REPO_ROOT)}/")
TMP_DIR.mkdir(parents=True, exist_ok=True)
FA_REPO = TMP_DIR / "Font-Awesome"
if not FA_REPO.exists():
print(f"Font-Awesome not found at {FA_REPO.relative_to(REPO_ROOT)} — cloning …")
result = subprocess.run(
["git", "clone", "https://github.com/FortAwesome/Font-Awesome.git", str(FA_REPO)],
check=False,
)
if result.returncode != 0:
print("✗ git clone failed — cannot continue without Font-Awesome source.")
sys.exit(1)
print("✓ Font-Awesome cloned successfully.\n")
FA_REPO = TMP_DIR / "Font-Awesome"
if not FA_REPO.exists():
print(f"Font-Awesome not found at {FA_REPO.relative_to(REPO_ROOT)} — cloning …")
result = subprocess.run(
["git", "clone", "https://github.com/FortAwesome/Font-Awesome.git", str(FA_REPO)],
check=False,
)
if result.returncode != 0:
print("✗ git clone failed — cannot continue without Font-Awesome source.")
sys.exit(1)
print("✓ Font-Awesome cloned successfully.\n")
# ── 1. Scan static/ for all fas fa-<name> occurrences ───────────────────────
FA_RE = re.compile(r'\bfas fa-([\w-]+)')
@@ -104,6 +118,22 @@ if not missing:
print(f"\n{len(missing)} missing icon(s):")
# ── 3b. CI gate ───────────────────────────────────────────────────────────────
# In --check-only mode we report the missing names and stop here. The
# default mode continues into the SVG-resolve + patch path below.
if CHECK_ONLY:
for name, files in sorted(missing.items()):
print(f" • {name:30s} used in: {', '.join(files)}")
print(
f"\n✗ {len(missing)} icon(s) referenced in static/ are absent from "
f"OxiIcons in {ICONS_JS.relative_to(REPO_ROOT)}."
)
print(
" Run `python3 tools/check-icons.py` locally (without "
"--check-only) to auto-add them from Font-Awesome."
)
sys.exit(1)
# ── 4. Resolve each missing icon from FA SVG files ────────────────────────────
VIEWBOX_RE = re.compile(r'viewBox="0 0 (\d+) (\d+)"')
PATH_D_RE = re.compile(r'<path[^>]+\bd="([^"]+)"')
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""
check-missing-translations.py — Audit locale files against en.json.
Treats static/locales/en.json as the canonical key set. For every other
JSON file in static/locales/, reports keys that are missing (present in
en.json, absent here) and optionally keys that are extra (present here,
absent in en.json — usually drift from a removed feature).
Exit code:
0 — every non-English locale has every English key
1 — at least one locale is missing one or more keys
Usage:
python3 tools/check-missing-translations.py [options]
Options:
--check-only CI-friendly: print per-locale counts only
(no per-key list). Exit code is unchanged —
the script always returns 1 on any miss,
this flag just keeps the CI log terse.
Mirrors `tools/check-icons.py --check-only`.
--no-extras Suppress the "extra keys" section.
--values Show the English source value next to each
missing key (truncated to 80 chars).
--locale CODE [CODE…] Audit only the listed locale(s) (e.g. fr de).
Default: every non-English file in the dir.
Examples:
# Verbose audit of every locale, including extras
python3 tools/check-missing-translations.py
# CI mode — terse output, exit 1 on any miss
python3 tools/check-missing-translations.py --check-only
# Just French and Spanish, with English values to help translators
python3 tools/check-missing-translations.py --locale fr es --values
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parent.parent
LOCALES_DIR = REPO_ROOT / "static" / "locales"
SOURCE_LOCALE = "en"
# Truncation length for the English source value shown by --values.
VALUE_PREVIEW_LEN = 80
def flatten(obj: dict[str, Any], prefix: str = "") -> dict[str, Any]:
"""Walk a nested JSON object and produce a flat {"dotted.key": value}
dict. Non-dict leaves (strings, numbers, booleans, arrays) are kept
as-is; only nested dicts are expanded into the key path."""
out: dict[str, Any] = {}
for key, value in obj.items():
full = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict):
out.update(flatten(value, full))
else:
out[full] = value
return out
def truncate(text: str, max_len: int) -> str:
"""Visual truncation for terminal output. Newlines normalised to
spaces so a multi-line email body still fits on one row."""
one_line = text.replace("\n", " ").replace("\r", " ")
if len(one_line) <= max_len:
return one_line
return one_line[: max_len - 1] + "…"
def load_locale(path: Path) -> dict[str, Any]:
"""Parse one locale file. Returns the flattened key set."""
try:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
print(f"✗ {path.name}: could not parse ({exc})", file=sys.stderr)
sys.exit(1)
if not isinstance(data, dict):
print(f"✗ {path.name}: root must be an object", file=sys.stderr)
sys.exit(1)
return flatten(data)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Audit static/locales/*.json against en.json. Reports keys "
"missing from each non-English locale and (optionally) keys "
"that exist in non-English but not in English."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--check-only",
action="store_true",
help=(
"CI mode: print per-locale counts only, not the full key "
"list. Exit code is unchanged (1 on any miss). Mirrors "
"`tools/check-icons.py --check-only`."
),
)
parser.add_argument(
"--no-extras",
action="store_true",
help="Suppress the 'extra keys' section (default: report them).",
)
parser.add_argument(
"--values",
action="store_true",
help=(
"Print the English source value next to each missing key. "
"Helpful for translators; ignored under --check-only."
),
)
parser.add_argument(
"--locale",
nargs="+",
metavar="CODE",
help=(
"Audit only the given locale code(s) (e.g. 'fr', 'zh-TW'). "
"Default: every *.json in static/locales/ except en.json."
),
)
return parser.parse_args()
def main() -> int:
args = parse_args()
if not LOCALES_DIR.is_dir():
print(f"✗ Locales directory not found: {LOCALES_DIR}", file=sys.stderr)
return 1
source_path = LOCALES_DIR / f"{SOURCE_LOCALE}.json"
if not source_path.exists():
print(f"✗ Source locale not found: {source_path}", file=sys.stderr)
return 1
en = load_locale(source_path)
en_keys = set(en.keys())
# Decide which locales to audit. --locale narrows the set; otherwise
# we audit everything except en.json.
if args.locale:
locale_paths = []
for code in args.locale:
p = LOCALES_DIR / f"{code}.json"
if not p.exists():
print(f"✗ Locale file not found: {p}", file=sys.stderr)
return 1
if code == SOURCE_LOCALE:
print(
f"⚠ Skipping --locale {code}: that's the source locale.",
file=sys.stderr,
)
continue
locale_paths.append(p)
else:
locale_paths = sorted(
p
for p in LOCALES_DIR.glob("*.json")
if p.stem != SOURCE_LOCALE
)
if not locale_paths:
print("Nothing to check.")
return 0
print(f"Source: {source_path.relative_to(REPO_ROOT)} ({len(en_keys)} keys)\n")
total_missing = 0
total_extra = 0
locale_with_missing: list[str] = []
for path in locale_paths:
loc = load_locale(path)
loc_keys = set(loc.keys())
missing = sorted(en_keys - loc_keys)
extra = sorted(loc_keys - en_keys)
total_missing += len(missing)
total_extra += len(extra)
if missing:
locale_with_missing.append(path.stem)
mark = "✓" if not missing else "✗"
suffix = ""
if missing or extra:
parts = []
if missing:
parts.append(f"missing={len(missing)}")
if extra:
parts.append(f"extra={len(extra)}")
suffix = " " + " ".join(parts)
print(f" {mark} {path.name:14} total={len(loc_keys)}{suffix}")
if args.check_only:
continue
# Per-key listing (suppressed under --check-only).
if missing:
print(f" missing ({len(missing)}):")
for key in missing:
if args.values:
val = en.get(key, "")
if not isinstance(val, str):
val = json.dumps(val, ensure_ascii=False)
preview = truncate(val, VALUE_PREVIEW_LEN)
print(f" - {key} :: {preview}")
else:
print(f" - {key}")
if extra and not args.no_extras:
print(f" extra ({len(extra)}):")
for key in extra:
print(f" + {key}")
# ── Trailer ────────────────────────────────────────────────────────
print()
if total_missing == 0:
print(f"✓ Every locale is at parity with {SOURCE_LOCALE}.json.")
if total_extra and not args.no_extras:
print(
f" (Note: {total_extra} extra key(s) across locales — "
f"they don't fail the check but may indicate drift.)"
)
return 0
print(
f"✗ {total_missing} missing translation(s) across "
f"{len(locale_with_missing)} locale(s): "
f"{', '.join(locale_with_missing)}"
)
if total_extra and not args.no_extras:
print(f" Plus {total_extra} extra key(s); see per-locale output above.")
return 1
if __name__ == "__main__":
sys.exit(main())