mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
feat(i18n): require translation updates for English copy changes (#15794)
This commit is contained in:
1 parent
f7769f27e7
commit
d89fd563ca
5 files changed
+259
No files matched your search
@@ -45,6 +45,8 @@ jobs:
|
||||
- uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece
|
||||
env:
|
||||
SKIP: ty,desktop-rust-fmt,desktop-rust-clippy
|
||||
# All-files checks need the push range for translation comparisons.
|
||||
PRE_COMMIT_FROM_REF: ${{ github.event_name == 'push' && github.ref_name == 'main' && github.event.before != '0000000000000000000000000000000000000000' && github.event.before || '' }}
|
||||
# Lets the zizmor hook run its online audits. Read-only: the job
|
||||
# grants `contents: read` and nothing else.
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -273,6 +273,13 @@ repos:
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: i18n-copy-changes
|
||||
name: translation updates for English copy changes
|
||||
language: system
|
||||
entry: python3 tools/i18n/check_copy_changes.py
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
files: ^web/src/i18n/messages/.*\.json$
|
||||
# `uv pip compile` cannot read `[build-system] requires`, so the pins are
|
||||
# duplicated in tools/requirements/build-constraints.in. Catch a mismatch
|
||||
# here rather than as an unsatisfiable resolution when a release builds wheels.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Reject English copy changes whose translations have not changed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
CATALOG_PATH: Path = Path("web/src/i18n/messages")
|
||||
|
||||
|
||||
def flatten(value: object, prefix: str = "") -> dict[str, str]:
|
||||
if isinstance(value, str):
|
||||
return {prefix: value}
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"Invalid message at {prefix}")
|
||||
messages: dict[str, str] = {}
|
||||
for key, child in value.items():
|
||||
if not isinstance(key, str):
|
||||
raise ValueError("Message keys must be strings")
|
||||
messages.update(flatten(child, f"{prefix}.{key}" if prefix else key))
|
||||
return messages
|
||||
|
||||
|
||||
def read_catalog(path: Path, base: str | None = None) -> dict[str, str]:
|
||||
source: str = (
|
||||
subprocess.check_output(["git", "show", f"{base}:{path.as_posix()}"], text=True)
|
||||
if base
|
||||
else path.read_text()
|
||||
)
|
||||
return flatten(json.loads(source))
|
||||
|
||||
|
||||
def default_base() -> str:
|
||||
"""Check working edits against HEAD, or the last commit in a clean checkout."""
|
||||
changes: str = subprocess.check_output(
|
||||
["git", "status", "--porcelain", "--", CATALOG_PATH.as_posix()], text=True
|
||||
)
|
||||
if changes:
|
||||
return "HEAD"
|
||||
parent: subprocess.CompletedProcess[str] = subprocess.run(
|
||||
["git", "rev-parse", "--verify", "HEAD^"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return "HEAD^" if parent.returncode == 0 else "HEAD"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser: argparse.ArgumentParser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base")
|
||||
args: argparse.Namespace = parser.parse_args()
|
||||
base: str = args.base or os.environ.get("PRE_COMMIT_FROM_REF") or default_base()
|
||||
previous: dict[str, str] = read_catalog(CATALOG_PATH / "en.json", base)
|
||||
current: dict[str, str] = read_catalog(CATALOG_PATH / "en.json")
|
||||
changed: list[str] = [
|
||||
key for key, text in current.items() if previous.get(key) != text
|
||||
]
|
||||
if not changed:
|
||||
return 0
|
||||
baseline_paths: set[str] = set(
|
||||
subprocess.check_output(
|
||||
[
|
||||
"git",
|
||||
"ls-tree",
|
||||
"-r",
|
||||
"--name-only",
|
||||
base,
|
||||
"--",
|
||||
CATALOG_PATH.as_posix(),
|
||||
],
|
||||
text=True,
|
||||
).splitlines()
|
||||
)
|
||||
failures: list[str] = []
|
||||
for path in sorted(CATALOG_PATH.glob("*.json")):
|
||||
if path.name == "en.json":
|
||||
continue
|
||||
old: dict[str, str] = (
|
||||
read_catalog(path, base) if path.as_posix() in baseline_paths else {}
|
||||
)
|
||||
new: dict[str, str] = read_catalog(path)
|
||||
failures.extend(
|
||||
f"{path.stem}: {key}"
|
||||
for key in changed
|
||||
if key not in new or new[key] == old.get(key)
|
||||
)
|
||||
if failures:
|
||||
print("English copy changed without corresponding translation updates:")
|
||||
print("\n".join(f" {failure}" for failure in failures))
|
||||
print(
|
||||
"Update each listed translation. This check cannot assess translation quality."
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Exercise the copy gate against real Git baselines and working catalogs."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
CHECKER: Path = Path(__file__).with_name("check_copy_changes.py").resolve()
|
||||
|
||||
|
||||
class CopyChangesTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp: tempfile.TemporaryDirectory[str] = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root: Path = Path(self.temp.name)
|
||||
self.catalogs: Path = self.root / "web/src/i18n/messages"
|
||||
self.catalogs.mkdir(parents=True)
|
||||
self.git("init", "-q")
|
||||
self.write("en", {"title": "Hello", "other": "Keep"})
|
||||
self.write("fr", {"title": "Bonjour", "other": "Garder"})
|
||||
self.write("de", {"title": "Hallo", "other": "Behalten"})
|
||||
self.git("add", ".")
|
||||
self.git(
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-qm",
|
||||
"baseline",
|
||||
)
|
||||
|
||||
def git(self, *args: str) -> str:
|
||||
return subprocess.check_output(["git", *args], cwd=self.root, text=True)
|
||||
|
||||
def write(self, locale: str, messages: dict[str, str]) -> None:
|
||||
(self.catalogs / f"{locale}.json").write_text(json.dumps({"nested": messages}))
|
||||
|
||||
def check(self, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(CHECKER), *args],
|
||||
cwd=self.root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def test_unchanged_copy_passes(self) -> None:
|
||||
self.assertEqual(self.check().returncode, 0)
|
||||
|
||||
def test_requires_the_same_key_in_every_locale(self) -> None:
|
||||
self.write("en", {"title": "Welcome", "other": "Keep"})
|
||||
self.write("fr", {"title": "Bienvenue", "other": "Garder"})
|
||||
self.write("de", {"title": "Hallo", "other": "Anders"})
|
||||
result: subprocess.CompletedProcess[str] = self.check()
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("de: nested.title", result.stdout)
|
||||
self.assertNotIn("fr: nested.title", result.stdout)
|
||||
|
||||
def test_updated_translations_pass(self) -> None:
|
||||
self.write("en", {"title": "Welcome"})
|
||||
self.write("fr", {"title": "Bienvenue"})
|
||||
self.write("de", {"title": "Willkommen"})
|
||||
self.assertEqual(self.check().returncode, 0)
|
||||
|
||||
def test_missing_new_key_fails(self) -> None:
|
||||
self.write("en", {"title": "Hello", "new": "New"})
|
||||
self.assertIn("fr: nested.new", self.check().stdout)
|
||||
self.assertEqual(self.check().returncode, 1)
|
||||
|
||||
def test_branch_base_checks_committed_copy(self) -> None:
|
||||
base: str = self.git("rev-parse", "HEAD").strip()
|
||||
self.write("en", {"title": "Welcome"})
|
||||
self.git("add", ".")
|
||||
self.git(
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-qm",
|
||||
"copy",
|
||||
)
|
||||
self.assertEqual(self.check().returncode, 1)
|
||||
self.assertEqual(self.check("--base", base).returncode, 1)
|
||||
|
||||
def test_new_locale_has_no_previous_translation(self) -> None:
|
||||
self.write("en", {"title": "Welcome"})
|
||||
self.write("fr", {"title": "Bienvenue"})
|
||||
self.write("de", {"title": "Willkommen"})
|
||||
self.write("es", {"title": "Bienvenido"})
|
||||
self.assertEqual(self.check().returncode, 0)
|
||||
|
||||
def test_push_range_checks_copy_before_an_unrelated_commit(self) -> None:
|
||||
base: str = self.git("rev-parse", "HEAD").strip()
|
||||
self.write("en", {"title": "Welcome"})
|
||||
self.git("add", ".")
|
||||
self.git(
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-qm",
|
||||
"copy",
|
||||
)
|
||||
(self.root / "unrelated.txt").write_text("unrelated")
|
||||
self.git("add", ".")
|
||||
self.git(
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-qm",
|
||||
"unrelated",
|
||||
)
|
||||
self.assertEqual(self.check().returncode, 0)
|
||||
with patch.dict(os.environ, {"PRE_COMMIT_FROM_REF": base}):
|
||||
self.assertEqual(self.check().returncode, 1)
|
||||
|
||||
def test_clean_checkout_with_committed_translations_passes(self) -> None:
|
||||
self.write("en", {"title": "Welcome"})
|
||||
self.write("fr", {"title": "Bienvenue"})
|
||||
self.write("de", {"title": "Willkommen"})
|
||||
self.git("add", ".")
|
||||
self.git(
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-qm",
|
||||
"translated copy",
|
||||
)
|
||||
self.assertEqual(self.check().returncode, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -63,6 +63,10 @@ Never import from `web/src/components/`. It is legacy and being deleted. The one
|
||||
- `web/src/i18n/messages/en.json` is the source of truth. When you add or change a key, add your
|
||||
best translation to every other locale file in that directory. Missing or extra keys fail
|
||||
`types:check`. ICU shape must match across locales (`src/i18n/__tests__/catalog.test.ts`).
|
||||
- The `i18n-copy-changes` pre-commit hook requires each changed English message to have a changed translation in every locale.
|
||||
It uses the pre-commit range base when supplied. Otherwise, it checks working edits against HEAD or clean checkouts against HEAD^.
|
||||
Main push checks use the push event’s previous commit. The hook does not assess translation quality.
|
||||
Run `python3 tools/i18n/check_copy_changes.py --base origin/main` from the repository root to check a branch.
|
||||
- Keys are stable identifiers: `<namespace>.<section>.<element>.<role>` in camelCase, for example
|
||||
`settings.appearance.colorMode.title`. Rewording the English never changes the key.
|
||||
- Use ICU for arguments and plurals. Never concatenate translated fragments.
|
||||
|
||||
Reference in new issue
Block a user