mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-11 13:02:25 +00:00
i18n: prefer pt over pt-br, no fallback across scripts, lookup without fallback
Final review fixes: a European Portuguese locale wins over pt-br when both are embedded; sr-latn no longer falls back to Cyrillic sr; get() looks up one context with no plain or English fallback (key-cap names). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F9fM3mv1PUyKpk5rdwUQrW
This commit is contained in:
1 parent
c9bcc60832
commit
841ebed58c
2 files changed
+59
-5
No files matched your search
@@ -35,10 +35,12 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l
|
||||
## Behaviour
|
||||
|
||||
- **Fallback:** `pt-br` app rows → `pt-br` shared rows → `pt` → the English source text. App rows
|
||||
override shared ones in their own locale. A missing translation never errors.
|
||||
override shared ones in their own locale. Only a region falls back (`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`, falling back to plain),
|
||||
`tid` (an `@id` command id, falling back to the English label), `trn` (a count, `@plural` rows,
|
||||
`{n}` filled), and `fmt` for `{name}` placeholders, filled in one pass.
|
||||
`{n}` filled), `get` (one context, no fallback: 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) 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
|
||||
|
||||
+55
-3
@@ -71,7 +71,13 @@ impl I18n {
|
||||
}
|
||||
for i in 2..locales.len() {
|
||||
let mut chain = vec![i];
|
||||
let base = locales.get(i).and_then(|l| l.code.split('-').next()).unwrap_or_default();
|
||||
// Only a region falls back to its language (`pt-br` → `pt`); a script doesn't, since
|
||||
// `sr-latn` → `sr` would show Cyrillic.
|
||||
let code = locales.get(i).map_or("", |l| l.code.as_str());
|
||||
let base = match code.split_once('-') {
|
||||
Some((language, region)) if is_region(region) => language,
|
||||
_ => "",
|
||||
};
|
||||
if let Some(b) = locales.iter().skip(2).position(|l| l.code == base)
|
||||
&& b + 2 != i
|
||||
{
|
||||
@@ -110,6 +116,13 @@ impl I18n {
|
||||
self.find(lang, context, source).unwrap_or_else(|| self.t(lang, source))
|
||||
}
|
||||
|
||||
/// The translation of (`context`, `source`) along `lang`'s chain, with no fallback to the plain
|
||||
/// row or the English: for strings that must not borrow another meaning's wording (a key cap's
|
||||
/// name under context `key`), or to ask whether a translation exists.
|
||||
pub fn get(&self, lang: Lang, context: &str, source: &str) -> Option<&str> {
|
||||
self.find(lang, context, source)
|
||||
}
|
||||
|
||||
/// A command's label by its `id` (`@id` rows); falls back to [`I18n::t`] of the English `label`.
|
||||
pub fn tid<'a>(&'a self, lang: Lang, id: &str, label: &'a str) -> &'a str {
|
||||
if lang == Lang::PSEUDO {
|
||||
@@ -233,7 +246,8 @@ pub fn fmt(template: &str, args: &[(&str, &str)]) -> String {
|
||||
}
|
||||
|
||||
/// Codes to try for a locale tag, most specific first: `zh_TW.UTF-8` → `zh-tw`, `zh-hant`, `zh`.
|
||||
/// Chinese without a script picks one by region; Portuguese regions without a catalog use `pt-br`.
|
||||
/// Chinese without a script picks one by region; Portuguese falls back to `pt-br` last, when
|
||||
/// there is no `pt`.
|
||||
fn candidates(tag: &str) -> Vec<String> {
|
||||
let base = tag.trim().split(['.', '@']).next().unwrap_or("").replace('_', "-").to_ascii_lowercase();
|
||||
let parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).collect();
|
||||
@@ -246,7 +260,7 @@ fn candidates(tag: &str) -> Vec<String> {
|
||||
out.insert(out.len().saturating_sub(1), script.to_owned());
|
||||
}
|
||||
if primary == "pt" && !out.iter().any(|c| c == "pt-br") {
|
||||
out.insert(out.len().saturating_sub(1), "pt-br".to_owned());
|
||||
out.push("pt-br".to_owned());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -256,6 +270,11 @@ fn marked(text: &str) -> String {
|
||||
format!("⟦{}⟧", text.replace('\n', "⟧\n⟦"))
|
||||
}
|
||||
|
||||
/// A BCP 47 region subtag: two letters or three digits.
|
||||
fn is_region(subtag: &str) -> bool {
|
||||
(subtag.len() == 2 && subtag.bytes().all(|b| b.is_ascii_alphabetic())) || (subtag.len() == 3 && subtag.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// A locale with no catalogs (English, the pseudo locale).
|
||||
fn bare(code: &str) -> Locale {
|
||||
Locale { code: code.to_owned(), rows: HashMap::new(), chain: Vec::new(), plural: None }
|
||||
@@ -484,4 +503,37 @@ mod tests {
|
||||
assert!(!i.languages().contains(&Lang::PSEUDO));
|
||||
assert_eq!(i.from_code("pseudo"), Some(Lang::PSEUDO));
|
||||
}
|
||||
|
||||
static F: &Embedded = &[
|
||||
("pt", "", ""),
|
||||
("pt-br", "", ""),
|
||||
("sr", "\tSave\tСачувај\n", ""),
|
||||
("sr-latn", "", ""),
|
||||
("fr", "\tDelete\tSupprimer\nkey\tEsc\tÉchap\n", ""),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn european_portuguese_prefers_pt() {
|
||||
let i = I18n::new(F).0;
|
||||
for tag in ["pt", "pt-PT", "pt_PT.UTF-8"] {
|
||||
assert_eq!(i.match_tag(tag).map(|l| i.code(l)), Some("pt"), "{tag}");
|
||||
}
|
||||
assert_eq!(i.match_tag("pt_BR").map(|l| i.code(l)), Some("pt-br"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_locale_does_not_fall_back_across_scripts() {
|
||||
let i = I18n::new(F).0;
|
||||
assert_eq!(i.t(lang(&i, "sr-latn"), "Save"), "Save");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_has_no_plain_fallback() {
|
||||
let i = I18n::new(F).0;
|
||||
let fr = lang(&i, "fr");
|
||||
assert_eq!(i.get(fr, "key", "Esc"), Some("Échap"));
|
||||
assert_eq!(i.get(fr, "key", "Delete"), None);
|
||||
assert_eq!(i.get(fr, "", "Delete"), Some("Supprimer"));
|
||||
assert_eq!(i.get(Lang::EN, "", "Delete"), None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user