i18n: complete Simplified Chinese catalog, Chinese font fallback and a coverage xtask (#930)

Adds a complete Simplified Chinese (zh) catalog covering every English message, a font fallback for Chinese text, and `cargo xtask i18n --check`, which checks that the complete catalogs (ja, zh) carry every English message and that every command label is translatable.

Co-authored-by: Noly1990 <20500931+Noly1990@users.noreply.github.com>
This commit is contained in:
NolyandNoly1990 authored and GitHub committed 2026-10-13 23:00:39 -04:00
1 parent 587cf865b8
commit 45f92277cb
17 files changed
+2485 -52

No files matched your search

+232
View File
@@ -0,0 +1,232 @@
//! `cargo xtask i18n`: the message catalogs' coverage report, and a way to repair it.
//!
//! The catalogs under `crates/ui-egui/src/locales` are plain JSON maps from a stable message id
//! to one translation. The English catalog is also the lookup table for `Locale::label()`, which
//! translates source strings coming from the engine (command labels, toolbar tabs and panels) by
//! matching their English text. A label the English catalog does not carry therefore stays English
//! in *every* locale, silently — nothing fails, the row just looks untranslated.
//!
//! That is the failure this command exists to make visible. `--check` turns the report into a
//! non-zero exit for CI; `--add-missing` writes the uncovered command labels into the English
//! catalog under `cmd.<command id>` so a translator only has to fill in the other catalogs.
//!
//! German and Korean predate the coverage guards and are still partial, so their gaps are
//! reported but do not fail `--check`; the catalogs delivered complete (`COMPLETE`) may not
//! regress.
use std::collections::BTreeMap;
use std::path::Path;
use serde_json::{Map, Value};
/// Where the catalogs live, and the file the English one must lead.
const DIR: &str = "crates/ui-egui/src/locales";
/// Catalogs delivered complete: a missing message here fails `--check`. English is the source
/// catalog and is checked implicitly; keep this in step with `COMPLETE` in `ui-egui::i18n`.
const COMPLETE: &[&str] = &["ja", "zh"];
/// What `run` found.
#[derive(Default)]
struct Report {
/// Locale id → the English message ids it has not translated.
missing: BTreeMap<String, Vec<String>>,
/// Command label → the command ids that use it, for labels English does not carry.
untranslated: BTreeMap<String, Vec<String>>,
}
impl Report {
/// Gaps that must be repaired (complete catalogs only; partial catalogs are reported).
fn problems(&self) -> usize {
self.missing.iter().filter(|(id, _)| COMPLETE.contains(&id.as_str())).map(|(_, m)| m.len()).sum::<usize>() + self.untranslated.len()
}
/// Gaps in catalogs that are still allowed to be partial.
fn reported_only(&self) -> usize {
self.missing.iter().filter(|(id, _)| !COMPLETE.contains(&id.as_str())).map(|(_, m)| m.len()).sum()
}
}
pub fn run(root: &Path, args: &[&str]) -> Result<(), String> {
let english = read(root, "en")?;
let mut catalogs: Vec<(String, Map<String, Value>)> = Vec::new();
for id in locales(root)? {
let messages = read(root, &id)?;
catalogs.push((id, messages));
}
let mut report = Report::default();
for (id, messages) in &catalogs {
if id == "en" {
continue;
}
let missing: Vec<String> = english.keys().filter(|k| !messages.contains_key(*k)).cloned().collect();
if !missing.is_empty() {
report.missing.insert(id.clone(), missing);
}
}
let known: Vec<&str> = english.values().filter_map(Value::as_str).collect();
for (label, ids) in labels_needing_translation(&known) {
report.untranslated.insert(label, ids);
}
if args.contains(&"--add-missing") {
let added = add_missing(root, &mut report)?;
println!("i18n: wrote {added} new English message(s) into {DIR}/en.json");
return Ok(());
}
print_report(&report);
if args.contains(&"--check") {
if report.problems() > 0 {
return Err(format!(
"{} message(s) missing from a complete catalog and {} command label(s) with no English entry \
(see above); `cargo xtask i18n --add-missing` writes the labels into en.json",
report.missing.iter().filter(|(id, _)| COMPLETE.contains(&id.as_str())).map(|(_, m)| m.len()).sum::<usize>(),
report.untranslated.len()
));
}
if report.reported_only() > 0 {
println!(
"i18n: every complete catalog ({}) carries every English message and every command label is translatable; \
{} message(s) are still missing from the partial catalogs (de, ko)",
COMPLETE.join(", "),
report.reported_only()
);
} else {
println!("i18n: every catalog carries every English message; every command label is translatable");
}
}
Ok(())
}
/// Locale ids other than English: every catalog file that sits beside `en.json`, so a newly
/// added locale is picked up without editing this command.
fn locales(root: &Path) -> Result<Vec<String>, String> {
let dir = root.join(DIR);
let entries = std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let mut ids: Vec<String> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json"))
.filter_map(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned()))
.filter(|id| id != "en")
.collect();
ids.sort();
Ok(ids)
}
fn read(root: &Path, locale: &str) -> Result<Map<String, Value>, String> {
let path = root.join(DIR).join(format!("{locale}.json"));
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
match serde_json::from_str(&text) {
Ok(Value::Object(map)) => Ok(map),
Ok(_) => Err(format!("{}: a catalog must be a JSON object", path.display())),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
/// Command labels the English catalog cannot translate, mapped to the commands that use them.
///
/// The registry is read through `solidcraft-cli commands` so the report comes from the real
/// command table, never from a copy of it.
fn labels_needing_translation(known: &[&str]) -> BTreeMap<String, Vec<String>> {
let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
let Ok(commands) = registry() else {
return out;
};
for command in commands {
let (Some(id), Some(label)) = (command["id"].as_str(), command["label"].as_str()) else {
continue;
};
if known.contains(&label) {
continue;
}
let ids = out.entry(label.to_string()).or_default();
if !ids.iter().any(|x| x == id) {
ids.push(id.to_string());
}
}
out
}
fn registry() -> Result<Vec<Value>, String> {
let out = crate::cargo()
.args(["run", "-q", "--release", "-p", "solidcraft-cli", "--", "commands"])
.output()
.map_err(|e| format!("solidcraft-cli: {e}"))?;
if !out.status.success() {
return Err(format!("solidcraft-cli commands failed:\n{}", String::from_utf8_lossy(&out.stderr)));
}
serde_json::from_slice(&out.stdout).map_err(|e| format!("commands JSON: {e}"))
}
/// Write one `cmd.<command id>` entry per still-untranslated label into the English catalog and
/// drop the newly covered labels from the report.
fn add_missing(root: &Path, report: &mut Report) -> Result<usize, String> {
if report.untranslated.is_empty() {
return Ok(0);
}
let path = root.join(DIR).join("en.json");
let mut english = read(root, "en")?;
// One key per label, so two commands sharing a label cannot collide in `label()`'s
// English-text → id table. The lowest command id wins, keeping the entry stable.
let mut chosen: BTreeMap<String, String> = BTreeMap::new();
for (label, ids) in &report.untranslated {
if let Some(id) = ids.iter().min() {
chosen.insert(label.clone(), format!("cmd.{id}"));
}
}
for (label, key) in &chosen {
if english.contains_key(key) {
continue;
}
english.insert(key.clone(), Value::String(label.clone()));
}
english.sort_keys();
std::fs::write(&path, serde_json::to_string_pretty(&Value::Object(english)).map_err(|e| e.to_string())? + "\n")
.map_err(|e| format!("{}: {e}", path.display()))?;
let added = chosen.len();
report.untranslated.clear();
Ok(added)
}
fn print_report(report: &Report) {
for (locale, missing) in &report.missing {
println!("i18n: {locale} is missing {} English message(s):", missing.len());
for id in missing {
println!(" {id}");
}
}
if !report.untranslated.is_empty() {
println!("i18n: {} command label(s) have no English catalog entry, so they stay English in every locale:", report.untranslated.len());
for (label, ids) in &report.untranslated {
println!(" {label:<42} {}", ids.join(", "));
}
println!("i18n: run `cargo xtask i18n --add-missing` to add them to en.json, then translate them.");
}
if report.problems() == 0 {
println!("i18n: catalogs complete; every command label is translatable");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_counts_both_kinds_of_gap() {
let report = Report {
missing: BTreeMap::from([("zh".into(), vec!["a".into(), "b".into()])]),
untranslated: BTreeMap::from([("Flange".into(), vec!["sheet.flange".into()])]),
};
assert_eq!(report.problems(), 3);
}
#[test]
fn an_empty_report_has_no_problems() {
assert_eq!(Report::default().problems(), 0);
}
}
+8
View File
@@ -6,6 +6,7 @@
mod assets;
mod book;
mod features;
mod i18n;
mod layers;
mod licences;
mod test;
@@ -20,6 +21,12 @@ commands:
layers enforce the crate dependency layering (plan/architecture.md §3)
assets check that every non-code asset file is attributed in ATTRIBUTION.md
features list SolidCraft's commands by area in docs/features.md (from the registry)
i18n [--check | --add-missing]
report how complete the message catalogs in crates/ui-egui/src/locales are:
which messages a locale has not translated and which command labels the English
catalog cannot translate (so they stay English everywhere). --check fails when
there is a gap, for CI; --add-missing writes the uncovered command labels into
en.json under cmd.<command id> so only the translations are left to do
oracle [--cli BIN]
replay the reference tests (tests/oracle/*/recipe.json), compare with each
case's expected.json and write docs/oracle.md (--cli: a solidcraft-cli
@@ -49,6 +56,7 @@ fn main() -> ExitCode {
Some("layers") => cmd_layers(),
Some("assets") => assets::run(&root()),
Some("features") => features::run(&root()),
Some("i18n") => i18n::run(&root(), &rest),
Some("oracle") => cmd_oracle(rest.iter().position(|a| *a == "--cli").and_then(|i| rest.get(i + 1)).copied()),
Some("action-matrix") => {
cmd_action_matrix(rest.contains(&"--bless"), rest.iter().position(|a| *a == "--results").and_then(|i| rest.get(i + 1)).copied())