# craft-i18n Translations for the Craft apps: lookup with fallback, plurals, language names and the system locale, over the catalogs of [craft-i18n](https://github.com/benandez/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`](https://github.com/benandez/craft-i18n/blob/main/docs/integration.md). This crate never reads files. ## Wiring an app ```rust 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 = 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); // parses nothing yet problems.iter().for_each(|p| log::warn!("translation catalog: {p}")); i18n }); thread_local! { static CURRENT: Cell = 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` and `trn_decimal` (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. - **Isolation:** when a message or a value inserted into it has a right-to-left letter (bidi class R or AL), or a value has a bidi control (LRE, RLE, PDF, LRO, RLO, LRI, RLI, FSI, PDI, LRM, RLM, ALM), `fmt` wraps each inserted value in a first-strong isolate (FSI…PDI), and `trn`, `file_size` and `measure` wrap `{n}` in a left-to-right one (LRI…PDI), so a Latin file name in an Arabic sentence, a Hebrew name in an English one, or an ASCII file name with a right-to-left override can't reorder the words around it. Embeddings, overrides and isolates a value opens are closed inside its isolate, and a stray PDF or PDI in it is dropped. Wrap numbers, paths and codes in `ltr` before passing them; a value that already is one isolate keeps it. Other text comes out without marks, as if `fmt` didn't isolate at all. egui draws the marks as nothing. For text that leaves the interface (file names, logs, terminals, text parsed later), use `fmt_plain` or `strip_isolates`. - **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; craft-i18n's CI rejects them before they ship. `I18n::new` parses nothing: it returns the bad locales, and each locale's rows are parsed (and its bad rows logged with `log::warn!`) on the first lookup that reaches it, including as a fallback. `I18n::check_all()` parses every locale and returns all problems (`/: line N: …`), for a test. - **Plurals:** forms are listed in CLDR order (zero, one, two, few, many, other) for the categories whole numbers 0 to 1000 reach, from ICU4X's CLDR rules; `plural_form_counts` holds the counts craft-i18n's checker expects. `trn_decimal` picks the form from the digits shown (ICU4X plural operands), so English `1.0` is plural and French `1,5` singular. A count in a category integers never reach takes the `few` form if there is one (Russian and Ukrainian decimals, `1,5 файла`), else the last. Rules are per language, so `sr-latn` and `zh-hant` take `sr`'s and `zh`'s. CLDR's `pt` is Brazilian, so `pt` uses `pt-PT`'s rules (0 is plural) and `pt-br` `pt`'s. A language CLDR has no plural rules for shows English forms. - **Names:** a language's name comes from the app catalog's `@id language.name` row, else the shared catalog's, else its code; English is "English". - **Scripts and direction:** for any language tag, `scripts` lists the ISO 15924 scripts its interface text is written in (`ja` → Hani, Hira, Kana; `sr-latn` → Latn; `pa-PK` → Arab), `direction` is right to left when the main script is (Arabic, Hebrew, Syriac, Thaana), and `han` gives the regional forms of its Han characters for picking a CJK face: `zh-CN`/`zh-SG`/`zh-Hans` → SC, `zh-TW`/`zh-US`/`zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is `direction` of the language's code; putting text into display order is the app's job. - **Reading numbers:** `Format::parse(text)` reads what the format writes (its digits, decimal and grouping separators, the system's included) and what people type or paste: any Unicode decimal digits (ASCII, Arabic-Indic, Persian, Devanagari, Bengali, Thai, full-width, …) from one set; `٫` as a decimal mark everywhere; grouping by the locale's other mark of `.` and `,`, spaces (NBSP, narrow NBSP), `'`, `’` or `٬`, in threes or the Indian 2-2-3; a leading `-`, `−` or `+`; bidi marks ignored. A lone `.` in a comma locale is decimal when it can't be grouping (`1.5`, `0.250`, `1234.5`), while `1.234` stays 1234 in German; the same holds for `,` in a point locale. Percent signs and units (`50 %`, `12 px`) are not read: strip them first. Other input that doesn't fit is an error, never a guess. `parse_number(text, locale)` is the same for `Format::from_tag(locale)`. - **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`. A region that implies a script counts as that script: `pa-PK` picks `pa-arab`, not Gurmukhi `pa`. - **Sizes and units:** `file_size(lang, bytes, style)` writes `1.5 MB` or `512 bytes`; `FileSize` picks steps of 1024 named KB (`BINARY`), 1024 named KiB (`IEC`) or 1000 named KB (`DECIMAL`), the decimals per unit, no decimals from a given value up, the smallest and largest unit (below the smallest, sizes round up), and `B` or words for bytes. `measure(lang, value, decimals, "{n} px")` writes any other measure. Unit names are rows in context `unit`, keyed by the English template (`{n} MB` → `{n} Mo`, `{n}%` → `{n} %`) and read with `get`, so a plain `B` row (bold) is never used; bytes in words are the `@plural` row `{n} byte|{n} bytes`. Numbers are written by `I18n::format`. - **Numbers:** `Format` writes numbers for a `Region` on ICU4X's decimal formatter: `integer` (`1.234.567`, Indian `12,34,567`), `number(value, decimals)` and `percent(ratio, decimals)` (`50 %`, Turkish `%50`). It follows the region, not the interface language, with the locale's digits (`١٬٢٣٤٫٥` in Egypt, `۱٬۲۳۴` in Iran) or, with `Digits::Latin`, `0`–`9`. With Latin digits, a decimal or grouping separator the system sets (Windows' and macOS's own settings) replaces the locale's. `I18n::format` is the format `trn`, `file_size` and `measure` use: the system region's until `I18n::set_format` (the user's region setting or digit choice), and again after `I18n::reset_format`. A count in `trn` is therefore grouped and in the region's digits (`1.234 Dateien`, `١٬٢٣٤ ملفًا`); for names written into documents or files (`Copy 1234`), use `trn_plain`, which writes `0`–`9` without grouping or isolate marks. - **CLDR data:** decimal symbols, digits and plural rules (`data/numbers.postcard`, about 10 KB) and percent patterns (`data/locales.rs`) cover every CLDR locale; `data/generate.sh` rebuilds both from CLDR. The ICU4X crates are pinned to one minor version (`~2.3`), the one the data is generated for. The ICU4X code and data add about 90 KB to a stripped release build and 80 KB to wasm. - **Formatting region:** `Region` holds how numbers, times and measurements are written, apart from the interface language: the number and time locales (BCP 47), metric or US measurement, A4 or Letter, and the decimal and grouping separators (or no grouping) and clock when the system sets them; otherwise the number locale's CLDR data decides. `Region::system()` reads it once: Windows' regional format with the user's changes (`HKCU\Control Panel\International`), Apple's current locale (`en_PL`, `en_GB@rg=uszzzz`), `LC_ALL` or else `LC_NUMERIC`, `LC_TIME`, `LC_MEASUREMENT` and `LC_PAPER` each on its own over `LANG` on Unix (`C` there is English on A4, metric, with a `.` decimal and no grouping; `LANG=C` gives way to the preferred language), the preferred language on the web and Android. `Region::from_setting` takes the app's saved choice (`auto` or a tag like `pl-PL`). Paper and measurement follow CLDR's territory lists; tags may carry `-u-ms-`, `-u-hc-`, `-u-rg-` and `-u-nu-` (Apple's `@numbers=`), and a chosen digit system stays in the number locale. Ported from WordCraft's `Region`. - **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).