mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 10:37:11 -06:00
* About: contributors compiled in from contributors.json (Contributors + Models tabs, grab bag + table, sortable, name toggle) Adds the craftrules standard (standards/contributors.md) to LightCraft: contributors/contributors.json is generated by craftrules/scripts/contributors.py and baked into the binary by crates/ui-egui/build.rs (merged next to the tr_format! generation) as static tables. The About dialog gets tabs About · Contributors · Models. Each contributor line: @username, opt-in display/real name, PRs, commits, lines +/−/Δ, binary assets +/−, first/last commit. Sortable by every column; alphabetical ignores case and the leading @. Tabs and the list view are control-channel widgets (button:aboutTab-*, button:creditsTable / button:creditsGrabBag). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Contributors docs: craftrules is two levels up (../../craftrules) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Contributors: self-submitted names in contributors/people.toml; AGENTS.md credit-yourself section build.rs reads contributors/people.toml next to the generated contributors.json and shows a name only for entries the generator verified as written by that person (craftrules standards/contributors.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Contributors: regenerate (verified people.toml entries) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Contributors: regenerate after rebase onto main Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Contributors: regenerate (verified people.toml entries) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
9.5 KiB
Rust
209 lines
9.5 KiB
Rust
//! Compile the translated format strings into the `tr_format!` macro.
|
|
//!
|
|
//! `locales/<code>-formats.json` maps an English format string to its translation for one language.
|
|
//! Every English message becomes one `tr_format!` arm that picks the active language and formats
|
|
//! that language's translation with `format!` — so Rust checks every catalog's placeholders against
|
|
//! the call site's arguments (a wrong or forgotten placeholder is a compile error, and `{:.1}`,
|
|
//! `{:+}`… keep their meaning). The English source is the fallback arm. Adding a language means
|
|
//! adding its file (and its `language_table!` entry); this script picks the file up by name.
|
|
//!
|
|
//! Every catalog must carry the same messages: a `tr_format!` call whose message is in no catalog
|
|
//! does not compile, so a message missing from one language would otherwise go unnoticed until it
|
|
//! shows English.
|
|
//!
|
|
//! It also bakes the contributor credits into the binary as static Rust tables (`$OUT_DIR/credits.rs`,
|
|
//! included by `src/credits.rs`; craftrules `standards/contributors.md`): commit stats from
|
|
//! `contributors/contributors.json` (generated by craftrules `scripts/contributors.py`) and the names
|
|
//! people chose for themselves from `contributors/people.toml`. Nothing is read at run time. A
|
|
//! missing or malformed file gives empty tables (or usernames only) and a warning, never a failed build.
|
|
use std::{
|
|
collections::{BTreeMap, BTreeSet},
|
|
env, fs,
|
|
path::PathBuf,
|
|
};
|
|
|
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
println!("cargo:rerun-if-changed=build.rs");
|
|
// The directory itself, so a catalog added or removed reruns this script.
|
|
println!("cargo:rerun-if-changed=locales");
|
|
let out = PathBuf::from(env::var("OUT_DIR")?);
|
|
fs::write(out.join("tr-formats.rs"), generate()?)?;
|
|
let (json, people) = credits::load();
|
|
fs::write(out.join("credits.rs"), credits::generate(&json, &people))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// `ja` → `Ja`, `zh-hans` → `ZhHans`: the `Locale` variant the `language_table!` entry declares.
|
|
/// The code must be a lowercase BCP-47-like tag (`^[a-z]+(-[a-z]+)*$`), so the variant is always a
|
|
/// valid Rust identifier.
|
|
fn variant_for(code: &str) -> Result<String, String> {
|
|
let valid = !code.is_empty() && code.split('-').all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_lowercase()));
|
|
if !valid {
|
|
return Err(format!("locales/{code}-formats.json: the language code must match ^[a-z]+(-[a-z]+)*$"));
|
|
}
|
|
Ok(code.split('-').map(capitalize).collect())
|
|
}
|
|
|
|
fn capitalize(part: &str) -> String {
|
|
let mut chars = part.chars();
|
|
match chars.next() {
|
|
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
fn generate() -> Result<String, Box<dyn std::error::Error>> {
|
|
let mut catalogs: Vec<(String, String, BTreeMap<String, String>)> = Vec::new();
|
|
for entry in fs::read_dir("locales")? {
|
|
let path = entry?.path();
|
|
let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default().to_string();
|
|
// `en` is the source language: it has no catalog, its formats are the source themselves.
|
|
let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue };
|
|
println!("cargo:rerun-if-changed=locales/{name}");
|
|
let variant = variant_for(code)?;
|
|
let messages: BTreeMap<String, String> =
|
|
serde_json::from_str(&fs::read_to_string(&path)?).map_err(|error| format!("locales/{name}: {error}"))?;
|
|
catalogs.push((code.to_string(), variant, messages));
|
|
}
|
|
catalogs.sort_by(|a, b| a.0.cmp(&b.0));
|
|
|
|
// Every catalog translates the same messages.
|
|
let all: BTreeSet<&String> = catalogs.iter().flat_map(|(_, _, messages)| messages.keys()).collect();
|
|
let mut problems = Vec::new();
|
|
for (code, _, messages) in &catalogs {
|
|
for key in &all {
|
|
if !messages.contains_key(*key) {
|
|
problems.push(format!("locales/{code}-formats.json is missing {key:?}"));
|
|
}
|
|
}
|
|
}
|
|
if !problems.is_empty() {
|
|
return Err(problems.join("\n").into());
|
|
}
|
|
|
|
let mut source = String::from(concat!(
|
|
"/// Translate a format string, checked by `format!` in every language.\n",
|
|
"/// `build.rs` writes one arm per message from `locales/*-formats.json`.\n",
|
|
"macro_rules! tr_format {\n",
|
|
));
|
|
for english in all {
|
|
let en = serde_json::to_string(english)?;
|
|
source.push_str(&format!(" ({en} $(, $($args:tt)*)?) => {{\n match $crate::i18n::language() {{\n"));
|
|
for (_, variant, messages) in &catalogs {
|
|
if let Some(translated) = messages.get(english) {
|
|
let translated = serde_json::to_string(translated)?;
|
|
source.push_str(&format!(" $crate::i18n::Locale::{variant} => format!({translated} $(, $($args)*)?),\n"));
|
|
}
|
|
}
|
|
source.push_str(&format!(" _ => format!({en} $(, $($args)*)?),\n }}\n }};\n"));
|
|
}
|
|
source.push_str(concat!(
|
|
"}\n",
|
|
"\n",
|
|
"// `macro_rules!` is textual: the module re-export is what lets call sites say\n",
|
|
"// `crate::i18n::tr_format!`.\n",
|
|
"pub(crate) use tr_format;\n",
|
|
));
|
|
Ok(source)
|
|
}
|
|
|
|
/// The About window's credits tables.
|
|
mod credits {
|
|
use std::fmt::Write as _;
|
|
|
|
use serde_json::Value;
|
|
|
|
/// `contributors/contributors.json` and `contributors/people.toml`; `Null`/empty (with a build
|
|
/// warning) when missing or malformed. Names come only from people.toml entries the JSON marks
|
|
/// verified (`people_entry`).
|
|
pub fn load() -> (Value, toml::Table) {
|
|
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors");
|
|
let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| {
|
|
serde_json::from_str::<Value>(t).map_err(|e| e.to_string())
|
|
})
|
|
.unwrap_or(Value::Null);
|
|
let people =
|
|
read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::<toml::Table>().map_err(|e| e.to_string()))
|
|
.unwrap_or_default();
|
|
(json, people)
|
|
}
|
|
|
|
fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
|
|
println!("cargo:rerun-if-changed={}", path.display());
|
|
let r = std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|t| parse(&t));
|
|
if let Err(e) = &r {
|
|
println!("cargo:warning={}: {e}; {fallback}", path.display());
|
|
}
|
|
r.ok()
|
|
}
|
|
|
|
/// A non-empty string field of someone's people.toml entry, as a Rust `Option<&str>` literal.
|
|
fn name(v: Option<&str>) -> String {
|
|
match v.map(str::trim).filter(|s| !s.is_empty()) {
|
|
Some(x) => format!("Some({x:?})"),
|
|
None => "None".into(),
|
|
}
|
|
}
|
|
|
|
fn s(v: &Value, k: &str) -> String {
|
|
format!("{:?}", v.get(k).and_then(Value::as_str).unwrap_or(""))
|
|
}
|
|
|
|
fn n(v: &Value, k: &str) -> u64 {
|
|
v.get(k).and_then(Value::as_u64).unwrap_or(0)
|
|
}
|
|
|
|
fn rows(json: &Value, k: &str) -> Vec<Value> {
|
|
json.get(k).and_then(Value::as_array).cloned().unwrap_or_default()
|
|
}
|
|
|
|
pub fn generate(json: &Value, people: &toml::Table) -> String {
|
|
let mut o = String::new();
|
|
let _ = writeln!(o, "pub static TOTAL_COMMITS: u64 = {};", n(json, "commits"));
|
|
let _ = writeln!(o, "pub static CONTRIBUTORS: &[Contributor] = &[");
|
|
for c in rows(json, "contributors") {
|
|
let login = c.get("login").and_then(Value::as_str).unwrap_or("");
|
|
if login.is_empty() {
|
|
continue;
|
|
}
|
|
// Names only from a verified, self-chosen people.toml entry.
|
|
let entry = people.get("people").and_then(|p| p.get(login)).filter(|_| c.get("people_entry").and_then(Value::as_bool) == Some(true));
|
|
let field = |k: &str| entry.and_then(|e| e.get(k)).and_then(toml::Value::as_str);
|
|
let synced = entry.and_then(|e| e.get("sync_github_name")).and_then(toml::Value::as_bool) == Some(true);
|
|
let display = field("display_name")
|
|
.filter(|s| !s.trim().is_empty())
|
|
.or_else(|| if synced { c.get("github_name").and_then(Value::as_str) } else { None });
|
|
let _ = writeln!(
|
|
o,
|
|
" Contributor {{ login: {login:?}, display_name: {}, real_name: {}, prs: {}, commits: {}, lines_added: {}, lines_deleted: {}, binary_added: {}, binary_deleted: {}, first_commit: {}, last_commit: {} }},",
|
|
name(display),
|
|
name(field("real_name")),
|
|
n(&c, "prs"),
|
|
n(&c, "commits"),
|
|
n(&c, "lines_added"),
|
|
n(&c, "lines_deleted"),
|
|
n(&c, "binary_added"),
|
|
n(&c, "binary_deleted"),
|
|
s(&c, "first_commit"),
|
|
s(&c, "last_commit"),
|
|
);
|
|
}
|
|
let _ = writeln!(o, "];");
|
|
let _ = writeln!(o, "pub static MODELS: &[Model] = &[");
|
|
for m in rows(json, "models") {
|
|
let _ = writeln!(
|
|
o,
|
|
" Model {{ company: {}, model: {}, version: {}, commits: {}, lines_added: {}, lines_deleted: {} }},",
|
|
s(&m, "company"),
|
|
s(&m, "model"),
|
|
s(&m, "version"),
|
|
n(&m, "commits"),
|
|
n(&m, "lines_added"),
|
|
n(&m, "lines_deleted"),
|
|
);
|
|
}
|
|
let _ = writeln!(o, "];");
|
|
o
|
|
}
|
|
}
|