Files
craft-libs/crates/i18n/README.md
T
aiongg 44dbe32226 i18n: glibc names in Collator; fa and hi collation; Persian letters and digits in folding
Collator::new takes sv_SE.UTF-8 and sr_RS@latin. Folding maps every
script's decimal digits to ASCII and, ignoring accents, Persian keheh,
Farsi yeh, alef maksura, teh marbuta and heh with hamza to the Arabic
letters. The collation data grows 4.8 KB (465 bytes gzipped).
2026-10-21 05:45:47 +00:00

5.5 KiB
Raw Blame History

craft-i18n

Translations for the Craft apps: lookup with fallback, plurals, language names and the system locale, over the catalogs of craft-i18n (the data repository, same name).

The catalogs are a fully optional build input. An app's build.rs embeds them from CRAFT_I18N_DIR as a static CRAFT_I18N: &[(locale, shared catalog, app catalog)], empty when the variable is unset; the recipe is in craft-i18n's docs/integration.md. This crate never reads files.

Wiring an app

use std::cell::Cell;
use std::sync::LazyLock;

include!(concat!(env!("OUT_DIR"), "/craft_i18n.rs")); // pub static CRAFT_I18N: &craft_i18n::Embedded

pub static I18N: LazyLock<craft_i18n::I18n> = LazyLock::new(|| {
    let offered: Vec<&str> = LANGUAGES.iter().map(|l| l.code).collect(); // the app's own list
    let (i18n, problems) = craft_i18n::I18n::new(CRAFT_I18N, &offered);
    problems.iter().for_each(|p| log::warn!("translation catalog: {p}; that string shows in English"));
    i18n
});

thread_local! { static CURRENT: Cell<craft_i18n::Lang> = const { Cell::new(craft_i18n::Lang::EN) }; }

macro_rules! tl { ($s:expr) => { $crate::I18N.t($crate::CURRENT.get(), $s) }; }

The app keeps its tl! macro, its language setting ("auto" → I18N.system_lang(), otherwise I18N.from_code) and its picker (I18N.languages(), I18N.name(lang)).

Behaviour

  • Offered locales: I18n::new takes the codes the app offers (its own language list), so an app never offers a language its interface isn't ready for (Arabic without right-to-left layout, say). languages(), from_code, match_tag and system_lang only return offered locales; an embedded locale that isn't offered still serves as a fallback (pt for an offered pt-br).
  • Fallback: locale by locale along the chain, pt-br → pt → the English source text. In each locale, app rows override shared ones, and tc/tid try their context or @id row before the plain row, so pt-br's plain row beats pt's menu row. Only a region falls back (pt-br → pt, zh-hant-hk → zh-hant); sr-latn never falls back to Cyrillic sr. A missing translation never errors.
  • Lookups: t (plain), tc (with a context such as menu or @msg, else plain), tid (an @id command id, else the English label), trn (a count, @plural rows, {n} filled), get (one context along the locale chain, so pt-br can return a pt row, but never the plain row or English: a key cap's name under key, or "is there a translation?"), and fmt for {name} placeholders, filled in one pass.
  • Bad rows (malformed, an unknown @ context, a wrong plural form count, a row defined twice, of which the first is kept) and bad locales (en, pseudo, a code embedded twice in any case) are skipped and returned by I18n::new for the app to log; craft-i18n's CI rejects them before they ship.
  • Plurals: forms are listed in CLDR order (zero, one, two, few, many, other) for the categories whole numbers 0 to 1000 reach, from intl_pluralrules; a count in any other category takes the last form. CLDR's pt is Brazilian, so pt uses pt-PT's rules (0 is plural) and pt-br pt's. intl_pluralrules is pinned to the version craft-i18n's checker uses, and plural_form_counts holds the same counts.
  • Names: a language's name comes from its shared catalog's @id language.name row, else its code; English is "English".
  • Right to left: rtl is true for ar, ckb, dv, fa, he, ps, sd, ug, ur and yi; putting text into display order is the app's job.
  • System locale: system_lang matches the system's list once and caches it: on desktop Unix LANGUAGE, LC_ALL, LC_MESSAGES and LANG, read raw; elsewhere sys-locale's (macOS and Windows preferred languages, the browser's on the web). fr_CA.UTF-8 → fr, zh-TW → zh-hant, pt-PT → pt-br when that is the only Portuguese, C/POSIX → English. A script is never crossed: sr_RS@latin and sr-Latn-RS pick sr-latn, never sr, while sr-Cyrl-RS picks sr.
  • Pseudo locale (pseudo, debug builds only, never in languages()): every lookup, get included, comes back as ⟦…⟧, line by line, so text drawn without the marks never went through translation. looked_up() lists the source texts looked up so far, for an audit against a complete catalog.
  • Search (Folder): Folder::new(locale).fold(text) gives a key for case- and accent-insensitive matching (keep_accents() makes accents count) and a map back to the original text, so Folded::find returns the byte range to highlight. Compatibility forms fold to their plain letters (fi, full-width A), ß to ss, ς to σ, digits of every script to ASCII (Layer ١٢ finds layer 12); ignoring accents, Persian and Arabic letter forms match (ک and ك, ی and ي, ة and ه, أ and ا); invisible format characters (soft hyphens, joiners, bidi isolates) are dropped. Turkish and Azerbaijani fold I to ı when accents count; ignoring accents, I, i, İ and ı all match.
  • Sorting (Collator, feature collate): ICU4X collation for the locales craft-i18n ships, Persian and Hindi, with digits compared by value (Layer 2 before Layer 10) and -u-co-pinyin / -u-co-stroke for Chinese. The data (754 KB) is regenerated by data/generate-collation.sh; the feature adds about 1 MB to a release binary or wasm module (320 KB gzipped).