From e649cd09daedafbb09334eb478d2a5bdf40ce86f Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 22:20:55 +0100 Subject: [PATCH] build(i18n): embed the craft-i18n catalogs pinned in craft-i18n.rev Adds the craft-i18n crate (craft-libs f7cc8a7, storytold/craft-libs#247) and the build.rs recipe from craft-i18n's docs/integration.md: with CRAFT_I18N_DIR set, CRAFT_I18N embeds each locale's common.tsv and wordcraft.tsv (paths via {:?}, never canonicalized); unset, it is empty and the build is English only; CRAFT_I18N_REQUIRED=1 makes that an error. craft-i18n.rev pins storytold/craft-i18n be1f053 (LF via .gitattributes); the flatpak manifest checks out the same commit and sets CRAFT_I18N_DIR. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitattributes | 1 + Cargo.lock | 41 ++++++++++ craft-i18n.rev | 1 + crates/ui-egui/Cargo.toml | 4 + crates/ui-egui/build.rs | 82 ++++++++++++++++++- .../flatpak/ai.storyteller.wordcraft.yml | 8 ++ 6 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 craft-i18n.rev diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..e9ad0f2b1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +craft-i18n.rev text eol=lf diff --git a/Cargo.lock b/Cargo.lock index 91a380735..3165d15b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -869,6 +869,17 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=f7cc8a7df15e824b02cf73859567a6242d3a7de4#f7cc8a7df15e824b02cf73859567a6242d3a7de4" +dependencies = [ + "intl_pluralrules", + "log", + "sys-locale", + "unic-langid", +] + [[package]] name = "craft-pdf" version = "0.1.0" @@ -2080,6 +2091,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -4014,6 +4034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -4161,6 +4182,24 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5400,6 +5439,7 @@ name = "wordcraft-ui-egui" version = "0.10.0" dependencies = [ "cfb", + "craft-i18n", "craft-ui", "egui", "egui_kittest", @@ -5754,6 +5794,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/craft-i18n.rev b/craft-i18n.rev new file mode 100644 index 000000000..c63370989 --- /dev/null +++ b/craft-i18n.rev @@ -0,0 +1 @@ +be1f0535afbae9bfa1554b9afd545690eb2de2a5 diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 48ed41219..9b1f49dc8 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -10,6 +10,10 @@ workspace = true [dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } +# Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n +# catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in +# `craft-i18n.rev`). Its own rev: lazy per-locale parsing (storytold/craft-libs#247). +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "f7cc8a7df15e824b02cf73859567a6242d3a7de4" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index f40007fb2..215ead1a0 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,5 +1,6 @@ //! Bake the contributor credits into the binary as static Rust tables (craftrules -//! standards/contributors.md). Nothing is read at run time. +//! standards/contributors.md), and embed the craft-i18n interface catalogs. Nothing is read at +//! run time. //! //! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules //! scripts/contributors.py, which also marks whose people.toml entry it verified. @@ -7,6 +8,12 @@ //! JSON says the entry was verified (`people_entry`). //! //! A missing or malformed file gives empty tables (or no names) and a warning. +//! +//! - `CRAFT_I18N_DIR`, a craft-i18n checkout (storytold/craft-i18n, at the SHA in +//! `craft-i18n.rev`): `craft_i18n.rs` holds `CRAFT_I18N`, one (locale, shared catalog, WordCraft +//! catalog) entry per locale that has a `wordcraft.tsv`. Unset, or not a checkout (a warning), +//! it is empty and the interface is English only; with `CRAFT_I18N_REQUIRED=1` (releases) that +//! is an error instead. Recipe: craft-i18n's `docs/integration.md`. use std::fmt::Write as _; @@ -24,6 +31,79 @@ fn main() { if let Err(e) = std::fs::write(&out, generate(&json, &people)) { println!("cargo::warning=writing {}: {e}", out.display()); } + let out = out.with_file_name("craft_i18n.rs"); + if let Err(e) = std::fs::write(&out, craft_i18n()) { + println!("cargo::error=writing {}: {e}", out.display()); + } +} + +/// The catalog this app reads in each locale: `locales//wordcraft.tsv`. +const APP_CATALOG: &str = "wordcraft.tsv"; + +/// `CRAFT_I18N` from `CRAFT_I18N_DIR`: the `Embedded` list the craft-i18n crate reads. +fn craft_i18n() -> String { + println!("cargo::rerun-if-env-changed=CRAFT_I18N_DIR"); + println!("cargo::rerun-if-env-changed=CRAFT_I18N_REQUIRED"); + let required = std::env::var("CRAFT_I18N_REQUIRED").is_ok_and(|v| v.trim() == "1"); + let entries = match std::env::var_os("CRAFT_I18N_DIR").filter(|d| !d.is_empty()) { + // Not canonicalized: on Windows that gives `\\?\C:\…`, which `include_str!` can't read. + Some(dir) => match locales(&std::path::PathBuf::from(dir).join("locales")) { + Ok(entries) => entries, + Err(e) => { + let level = if required { "error" } else { "warning" }; + println!("cargo::{level}=CRAFT_I18N_DIR: {e}; the interface is English only"); + Vec::new() + } + }, + None => { + if required { + println!("cargo::error=CRAFT_I18N_REQUIRED=1 but CRAFT_I18N_DIR is unset: a release must embed the translations"); + } + Vec::new() + } + }; + let mut o = String::from("/// (locale, shared catalog, WordCraft's catalog), one entry per locale; `\"\"` for a missing file.\n"); + o.push_str("pub static CRAFT_I18N: &craft_i18n::Embedded = &[\n"); + for (code, shared, app) in entries { + // `{:?}` escapes the path (Windows backslashes) as a Rust string literal. + let shared = shared.map_or_else(|| "\"\"".to_string(), |p| format!("include_str!({p:?})")); + let _ = writeln!(o, " ({code:?}, {shared}, include_str!({app:?})),"); + } + o.push_str("];\n"); + o +} + +/// Every locale under `dir` with this app's catalog, by code: (code, its `common.tsv` if any, its +/// app catalog). An error when `dir` isn't a craft-i18n `locales/` directory. +#[allow(clippy::type_complexity)] +fn locales(dir: &std::path::Path) -> Result, std::path::PathBuf)>, String> { + // Build scripts run in the crate's directory and `include_str!` resolves against `OUT_DIR`, so + // a relative path would mean two different places. + if !dir.is_absolute() { + return Err(format!("{} is not an absolute path", dir.display())); + } + let list = std::fs::read_dir(dir).map_err(|e| format!("{} is not a craft-i18n checkout ({e})", dir.display()))?; + println!("cargo::rerun-if-changed={}", dir.display()); + let mut out = Vec::new(); + for entry in list.flatten() { + let path = entry.path(); + let Some(code) = path.file_name().and_then(|n| n.to_str()).map(str::to_string) else { continue }; + let app = path.join(APP_CATALOG); + if !app.is_file() { + continue; + } + println!("cargo::rerun-if-changed={}", app.display()); + let shared = Some(path.join("common.tsv")).filter(|p| p.is_file()); + if let Some(p) = &shared { + println!("cargo::rerun-if-changed={}", p.display()); + } + out.push((code, shared, app)); + } + if out.is_empty() { + return Err(format!("{} has no locale with a {APP_CATALOG}", dir.display())); + } + out.sort(); + Ok(out) } fn read(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result) -> Option { diff --git a/packaging/linux/flatpak/ai.storyteller.wordcraft.yml b/packaging/linux/flatpak/ai.storyteller.wordcraft.yml index 67ee2ae8e..adc6a11c3 100644 --- a/packaging/linux/flatpak/ai.storyteller.wordcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.wordcraft.yml @@ -43,6 +43,9 @@ modules: env: CARGO_HOME: /run/build/wordcraft/cargo CARGO_NET_OFFLINE: 'true' + # The interface translations (the craft-i18n source below), embedded by build.rs. + CRAFT_I18N_DIR: /run/build/wordcraft/craft-i18n + CRAFT_I18N_REQUIRED: '1' build-commands: - cargo --offline build --release --locked -p wordcraft -p wordcraft-cli - install -Dm755 target/release/wordcraft /app/bin/wordcraft @@ -64,4 +67,9 @@ modules: - corpus - build-flatpak - .flatpak-builder + # The interface translations at the commit in craft-i18n.rev (a test keeps the two equal). + - type: git + url: https://github.com/storytold/craft-i18n + commit: be1f0535afbae9bfa1554b9afd545690eb2de2a5 + dest: craft-i18n - cargo-sources.json