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) <noreply@anthropic.com>
This commit is contained in:
Martin Ball
2026-10-23 22:20:55 +01:00
co-authored by Claude Opus 5.5
parent 806a945d85
commit e649cd09da
6 changed files with 136 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
craft-i18n.rev text eol=lf
Generated
+41
View File
@@ -869,6 +869,17 @@ dependencies = [
"libc", "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]] [[package]]
name = "craft-pdf" name = "craft-pdf"
version = "0.1.0" version = "0.1.0"
@@ -2080,6 +2091,15 @@ dependencies = [
"hybrid-array", "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]] [[package]]
name = "is-docker" name = "is-docker"
version = "0.2.0" version = "0.2.0"
@@ -4014,6 +4034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"serde_core",
"zerovec", "zerovec",
] ]
@@ -4161,6 +4182,24 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" 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]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.18" version = "0.3.18"
@@ -5400,6 +5439,7 @@ name = "wordcraft-ui-egui"
version = "0.10.0" version = "0.10.0"
dependencies = [ dependencies = [
"cfb", "cfb",
"craft-i18n",
"craft-ui", "craft-ui",
"egui", "egui",
"egui_kittest", "egui_kittest",
@@ -5754,6 +5794,7 @@ version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [ dependencies = [
"serde",
"yoke", "yoke",
"zerofrom", "zerofrom",
"zerovec-derive", "zerovec-derive",
+1
View File
@@ -0,0 +1 @@
be1f0535afbae9bfa1554b9afd545690eb2de2a5
+4
View File
@@ -10,6 +10,10 @@ workspace = true
[dependencies] [dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } 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-geom = { workspace = true }
wordcraft-doc = { workspace = true } wordcraft-doc = { workspace = true }
unicode-bidi = { workspace = true } unicode-bidi = { workspace = true }
+81 -1
View File
@@ -1,5 +1,6 @@
//! Bake the contributor credits into the binary as static Rust tables (craftrules //! 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 //! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
//! scripts/contributors.py, which also marks whose people.toml entry it verified. //! scripts/contributors.py, which also marks whose people.toml entry it verified.
@@ -7,6 +8,12 @@
//! JSON says the entry was verified (`people_entry`). //! JSON says the entry was verified (`people_entry`).
//! //!
//! A missing or malformed file gives empty tables (or no names) and a warning. //! 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 _; use std::fmt::Write as _;
@@ -24,6 +31,79 @@ fn main() {
if let Err(e) = std::fs::write(&out, generate(&json, &people)) { if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
println!("cargo::warning=writing {}: {e}", out.display()); 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/<locale>/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<Vec<(String, Option<std::path::PathBuf>, 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<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> { fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
@@ -43,6 +43,9 @@ modules:
env: env:
CARGO_HOME: /run/build/wordcraft/cargo CARGO_HOME: /run/build/wordcraft/cargo
CARGO_NET_OFFLINE: 'true' 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: build-commands:
- cargo --offline build --release --locked -p wordcraft -p wordcraft-cli - cargo --offline build --release --locked -p wordcraft -p wordcraft-cli
- install -Dm755 target/release/wordcraft /app/bin/wordcraft - install -Dm755 target/release/wordcraft /app/bin/wordcraft
@@ -64,4 +67,9 @@ modules:
- corpus - corpus
- build-flatpak - build-flatpak
- .flatpak-builder - .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 - cargo-sources.json