Skip to main content

ocre/
i18n.rs

1//! Translations: `locales/*.yml`, `%{name}` interpolation, plurals, locale per request.
2//!
3//! Works like Rails' `I18n.t`: YAML files in `locales/` compiled into the
4//! Worker, `%{name}` interpolation, CLDR plurals, fallback to the default
5//! locale and locale selection per request.
6//!
7//! ```yaml
8//! # locales/fr.yml (a YAML subset: nested keys and strings)
9//! fr:
10//!   posts:
11//!     created: "Article créé."
12//!     greeting: "Bonjour %{name} !"
13//!     count:
14//!       one: "%{count} article"     # CLDR categories: zero, one, two, few, many, other
15//!       other: "%{count} articles"
16//! ```
17//!
18//! An app declares its locales once in `src/lib.rs` (`ocre g locale en fr`
19//! writes it) and adds [`layer`] at the end of
20//! `routes()`; handlers then take the [`I18n`] extractor:
21//!
22//! ```ignore
23//! // Not compiled here: `locales!` includes `locales/en.yml` and `locales/fr.yml` from the app's root.
24//! static LOCALES: ocre::i18n::Locales = ocre::locales!("en", "fr");
25//! ```
26//!
27//! ```
28//! use std::sync::LazyLock;
29//! use axum::{Router, routing::get};
30//! use ocre::{Ctx, i18n::{Catalog, I18n, Locales}};
31//!
32//! // `ocre::locales!("en", "fr")` expands to this, with the files' contents.
33//! static LOCALES: Locales = LazyLock::new(|| {
34//!     Catalog::load(&[
35//!         ("en", "en:\n  posts:\n    count:\n      one: \"%{count} post\"\n      other: \"%{count} posts\"\n"),
36//!         ("fr", "fr:\n  posts:\n    count:\n      one: \"%{count} article\"\n      other: \"%{count} articles\"\n"),
37//!     ])
38//! });
39//!
40//! async fn home(i18n: I18n) -> String {
41//!     i18n.t("posts.count").count(3).to_string() // "3 articles" for French visitors
42//! }
43//!
44//! fn routes() -> Router<Ctx> {
45//!     Router::new()
46//!         .route("/", get(home))
47//!         // ocre:routes
48//!         .layer(ocre::i18n::layer(&LOCALES))
49//! }
50//! # let _ = routes;
51//!
52//! // Outside requests (mailers, jobs):
53//! assert_eq!(LOCALES.locale("fr").t("posts.count").count(3).to_string(), "3 articles");
54//! ```
55//!
56//! In templates, translations are [`Display`](std::fmt::Display) values that
57//! askama writes (escaped) straight into the page:
58//! `<p>{{ i18n.t("posts.greeting").arg("name", user.name) }}</p>`. Never mark
59//! them `|safe`; for a translation containing markup, end with
60//! [`.html()`](Translation::html), which escapes the values only.
61//!
62//! Beyond `t`: [`I18n::l`] formats dates and times, [`I18n::number`] and
63//! [`I18n::currency`] numbers, [`I18n::model_name`] and [`I18n::attribute`]
64//! name models and fields, [`I18n::error_message`] translates validation
65//! errors, and [`I18n::time_ago_in_words`] durations. Their texts come from
66//! the app's files first, then from built-in translations (compiled in, no
67//! parsing) for English, French, German, Spanish, Italian, Portuguese and
68//! Dutch, under the rails-i18n keys (`date.formats.short`,
69//! `errors.messages.blank`...).
70//!
71//! The files are parsed once per Worker instance, on the first translation,
72//! by a small parser for this YAML subset; lookups are a `BTreeMap` search.
73//! No D1, KV or other billed resource is used. [`I18n`] picks the locale from
74//! a `{locale}` path segment, then the `locale` cookie, then
75//! `Accept-Language`, then the default locale. A key missing from the
76//! request's locale falls back to the default locale in release builds
77//! (`ocre deploy`); debug builds (`ocre dev`) show
78//! `translation missing: fr.posts.created` instead, so gaps are visible.
79//! `ocre i18n missing` lists them (see [`check`]).
80
81mod builtin;
82mod format;
83mod model;
84mod plural;
85mod yaml;
86
87use std::{
88    borrow::Cow,
89    collections::{BTreeMap, BTreeSet},
90    fmt,
91    sync::LazyLock,
92};
93
94use axum::{
95    Extension,
96    extract::{FromRequestParts, RawPathParams},
97    http::{HeaderMap, header, request::Parts},
98};
99
100use crate::{Error, Result};
101use plural::{Category, Rule};
102
103/// Name of the cookie that remembers a visitor's chosen locale.
104///
105/// [`I18n`] reads it after the path segment; [`I18n::cookie`] builds its
106/// `Set-Cookie` value.
107pub const LOCALE_COOKIE: &str = "locale";
108
109/// Name of the path parameter [`I18n`] reads the locale from.
110///
111/// Nest localized routes under it: `.nest("/{locale}", pages())`. An unknown
112/// code in the path is a 404.
113pub const LOCALE_PARAM: &str = "locale";
114
115/// The app's translations: a [`Catalog`] parsed on first use, declared once as a `static`.
116///
117/// Build it with [`locales!`](crate::locales), which compiles the files into
118/// the binary; the first access in a Worker instance parses them, once, and
119/// later accesses reuse the parsed tables.
120///
121/// # Examples
122///
123/// ```ignore
124/// // Not compiled here: `locales!` includes `locales/*.yml` from the app's root.
125/// static LOCALES: ocre::i18n::Locales = ocre::locales!("en", "fr");
126/// ```
127///
128/// Without files, e.g. in tests:
129///
130/// ```
131/// use std::sync::LazyLock;
132/// static LOCALES: ocre::i18n::Locales =
133///     LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n  title: Posts\n")]));
134/// assert_eq!(LOCALES.locale("en").t("title").to_string(), "Posts");
135/// ```
136pub type Locales = LazyLock<Catalog>;
137
138/// Declares the app's locales as a [`Locales`] static, compiling `locales/<code>.yml` into the binary.
139///
140/// `ocre::locales!("en", "fr")` includes `locales/en.yml` and
141/// `locales/fr.yml` (paths from the app root, `CARGO_MANIFEST_DIR`) with
142/// `include_str!` and hands them to [`Catalog::load`](crate::i18n::Catalog::load)
143/// on first use. The first code is the default locale, used for fallbacks. A
144/// missing file is a compile error; a file that does not parse is logged and
145/// listed in [`Catalog::errors`](crate::i18n::Catalog::errors) (`ocre dev` and
146/// `ocre deploy` refuse it earlier).
147///
148/// # Examples
149///
150/// ```ignore
151/// // Not compiled here: needs `locales/en.yml` and `locales/fr.yml` next to the app's Cargo.toml.
152/// static LOCALES: ocre::i18n::Locales = ocre::locales!("en", "fr");
153/// ```
154#[macro_export]
155macro_rules! locales {
156    ($($code:literal),+ $(,)?) => {
157        ::std::sync::LazyLock::new(|| {
158            $crate::i18n::Catalog::load(&[$((
159                $code,
160                ::std::include_str!(::std::concat!(::std::env!("CARGO_MANIFEST_DIR"), "/locales/", $code, ".yml")),
161            )),+])
162        })
163    };
164}
165
166/// Makes the catalog available to the [`I18n`] extractor, as an axum layer.
167///
168/// Forces `locales` (parsing the files if no translation did yet) and returns
169/// an [`Extension`] holding it. Add it last in `routes()`, after the
170/// `// ocre:routes` marker, so it covers every route; without it, [`I18n`]
171/// fails with a 500 whose log line names this fix.
172///
173/// # Examples
174///
175/// ```
176/// use std::sync::LazyLock;
177/// use axum::{Router, routing::get};
178/// use ocre::{Ctx, i18n::{Catalog, I18n, Locales}};
179///
180/// static LOCALES: Locales = LazyLock::new(|| Catalog::load(&[("en", "en:\n  title: Posts\n")]));
181///
182/// async fn home(i18n: I18n) -> String {
183///     i18n.t("title").to_string()
184/// }
185///
186/// let _app: Router<Ctx> = Router::new()
187///     .route("/", get(home))
188///     // ocre:routes
189///     .layer(ocre::i18n::layer(&LOCALES));
190/// ```
191pub fn layer(locales: &'static Locales) -> Extension<&'static Catalog> {
192    Extension(LazyLock::force(locales))
193}
194
195/// A translated string (text or plural forms).
196#[derive(Debug, Clone, PartialEq)]
197enum Value<'t> {
198    Text(Cow<'t, str>),
199    /// Indexed like [`Category::ALL`].
200    Plural([Option<Cow<'t, str>>; 6]),
201}
202
203/// One parsed locale file.
204#[derive(Debug)]
205struct Table {
206    code: &'static str,
207    rule: Rule,
208    values: BTreeMap<String, Value<'static>>,
209}
210
211impl Table {
212    /// Tables live in a `static` [`Locales`], so their strings are `'static`.
213    fn get(&'static self, key: &str, count: Option<i64>) -> Option<&'static str> {
214        let text = match (self.values.get(key)?, count) {
215            (Value::Text(text), _) => text,
216            (Value::Plural(forms), Some(0)) if forms[Category::Zero as usize].is_some() => {
217                forms[Category::Zero as usize].as_ref()?
218            }
219            (Value::Plural(forms), Some(n)) => {
220                forms[self.rule.category(n) as usize].as_ref().or(forms[Category::Other as usize].as_ref())?
221            }
222            (Value::Plural(forms), None) => forms[Category::Other as usize].as_ref()?,
223        };
224        Some(text)
225    }
226}
227
228/// All locales of an app, parsed: one table of dotted keys per locale code, the first being the default.
229///
230/// Built by [`locales!`](crate::locales) inside a [`Locales`] static. Files
231/// that fail to parse are empty (so every key falls back to the default
232/// locale) and listed in [`errors`](Self::errors), which are also logged to
233/// the Worker logs. [`locale`](Self::locale) gives translations outside
234/// requests; in handlers, the [`I18n`] extractor picks the locale.
235///
236/// # Examples
237///
238/// ```
239/// use std::sync::LazyLock;
240/// use ocre::i18n::{Catalog, Locales};
241///
242/// static LOCALES: Locales = LazyLock::new(|| {
243///     Catalog::load(&[("en", "en:\n  hello: \"Hello %{name}\"\n"), ("fr", "fr:\n  hello: \"Bonjour %{name}\"\n")])
244/// });
245///
246/// assert_eq!(LOCALES.default_locale(), "en");
247/// assert_eq!(LOCALES.codes().collect::<Vec<_>>(), ["en", "fr"]);
248/// assert_eq!(LOCALES.locale("fr").t("hello").arg("name", "Ada").to_string(), "Bonjour Ada");
249/// ```
250#[derive(Debug)]
251pub struct Catalog {
252    tables: Vec<Table>,
253    errors: Vec<String>,
254}
255
256impl Catalog {
257    /// Parses `(code, file contents)` pairs into a catalog; the first pair is the default locale.
258    ///
259    /// Each file must start with its code as the root key (`en:`); plural
260    /// rules come from the code (`fr-CH` uses French rules). A file that does
261    /// not parse becomes an empty table and one entry in
262    /// [`errors`](Self::errors); each error is logged. An empty `sources`
263    /// gives an empty `en` catalog with an error telling to declare locales.
264    /// Never fails: missing translations show up at lookup time instead.
265    ///
266    /// # Examples
267    ///
268    /// ```
269    /// let catalog = ocre::i18n::Catalog::load(&[("en", "en:\n  title: Posts\n")]);
270    /// assert!(catalog.errors().is_empty());
271    /// ```
272    pub fn load(sources: &[(&'static str, &'static str)]) -> Self {
273        let mut tables = Vec::with_capacity(sources.len().max(1));
274        let mut errors = Vec::new();
275        for (code, text) in sources {
276            let values = match yaml::parse_locale(code, text) {
277                Ok(entries) => group(entries),
278                Err(err) => {
279                    errors.push(format!("locales/{code}.yml line {}: {}", err.line, err.message));
280                    BTreeMap::new()
281                }
282            };
283            tables.push(Table { code, rule: Rule::for_locale(code), values });
284        }
285        if tables.is_empty() {
286            errors.push("no locales: declare them with ocre::locales!(\"en\")".to_owned());
287            tables.push(Table { code: "en", rule: Rule::One, values: BTreeMap::new() });
288        }
289        for error in &errors {
290            crate::error::log_internal(error);
291        }
292        Self { tables, errors }
293    }
294
295    /// Parse errors from [`load`](Self::load), one line each (`locales/fr.yml line 3: ...`).
296    ///
297    /// Empty when every file parsed. `ocre dev` and `ocre deploy` check the
298    /// same files before building, so a deployed app normally has none.
299    ///
300    /// # Examples
301    ///
302    /// ```
303    /// let catalog = ocre::i18n::Catalog::load(&[("en", "en:\n  title: [Posts]\n")]);
304    /// assert_eq!(catalog.errors().len(), 1);
305    /// assert!(catalog.errors()[0].starts_with("locales/en.yml line 2: "));
306    /// ```
307    pub fn errors(&self) -> &[String] {
308        &self.errors
309    }
310
311    /// Returns the default locale code: the first code given to [`locales!`](crate::locales).
312    ///
313    /// Keys missing from another locale fall back to it in release builds,
314    /// and requests without a usable locale get it.
315    ///
316    /// # Examples
317    ///
318    /// ```
319    /// let catalog = ocre::i18n::Catalog::load(&[("fr", "fr:\n"), ("en", "en:\n")]);
320    /// assert_eq!(catalog.default_locale(), "fr");
321    /// ```
322    pub fn default_locale(&self) -> &'static str {
323        self.tables[0].code
324    }
325
326    /// Returns every locale code, default first, e.g. for a language switcher.
327    ///
328    /// # Examples
329    ///
330    /// ```
331    /// let catalog = ocre::i18n::Catalog::load(&[("en", "en:\n"), ("pt-BR", "pt-BR:\n")]);
332    /// assert_eq!(catalog.codes().collect::<Vec<_>>(), ["en", "pt-BR"]);
333    /// ```
334    pub fn codes(&self) -> impl Iterator<Item = &'static str> + '_ {
335        self.tables.iter().map(|table| table.code)
336    }
337
338    /// Returns translations in locale `code`, for code outside requests such as mailers and jobs.
339    ///
340    /// `code` matches case-insensitively and exactly (`fr-CH` does not match
341    /// `fr` here, unlike `Accept-Language`); an unknown code gives the
342    /// default locale. Requires a `'static` catalog, i.e. a [`Locales`]
343    /// static.
344    ///
345    /// # Examples
346    ///
347    /// ```
348    /// use std::sync::LazyLock;
349    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
350    ///     ocre::i18n::Catalog::load(&[("en", "en:\n  subject: Welcome\n"), ("fr", "fr:\n  subject: Bienvenue\n")])
351    /// });
352    ///
353    /// let user_locale = "FR";
354    /// assert_eq!(LOCALES.locale(user_locale).t("subject").to_string(), "Bienvenue");
355    /// assert_eq!(LOCALES.locale("de").locale(), "en");
356    /// ```
357    pub fn locale(&'static self, code: &str) -> I18n {
358        I18n { catalog: self, index: self.find(code).unwrap_or(0), scope: "" }
359    }
360
361    fn find(&self, code: &str) -> Option<usize> {
362        self.tables.iter().position(|table| table.code.eq_ignore_ascii_case(code))
363    }
364
365    /// Best locale for an `Accept-Language` header: by quality, exact code
366    /// first, then the same language (`fr-CH` matches `fr`, `pt` matches `pt-BR`).
367    fn negotiate(&self, accept_language: &str) -> Option<usize> {
368        let mut ranges: Vec<(&str, f32)> = accept_language
369            .split(',')
370            .filter_map(|part| {
371                let mut pieces = part.split(';');
372                let tag = pieces.next().unwrap_or_default().trim();
373                let quality = pieces
374                    .find_map(|param| param.trim().strip_prefix("q="))
375                    .map_or(1.0, |q| q.trim().parse::<f32>().unwrap_or(0.0));
376                (!tag.is_empty() && tag != "*" && quality > 0.0).then_some((tag, quality))
377            })
378            .collect();
379        ranges.sort_by(|a, b| b.1.total_cmp(&a.1));
380        ranges.iter().find_map(|(tag, _)| {
381            self.find(tag).or_else(|| {
382                let language = primary(tag);
383                self.tables.iter().position(|table| primary(table.code).eq_ignore_ascii_case(language))
384            })
385        })
386    }
387}
388
389/// `fr` of `fr-CH`.
390fn primary(tag: &str) -> &str {
391    tag.split(['-', '_']).next().unwrap_or(tag)
392}
393
394/// Turns `posts.count.one`/`posts.count.other` into one plural value. A
395/// mapping is plural when every child is a CLDR category name (`zero`,
396/// `one`, `two`, `few`, `many`, `other`).
397fn group<'t>(entries: Vec<(String, Cow<'t, str>)>) -> BTreeMap<String, Value<'t>> {
398    let flat: BTreeMap<String, Cow<'t, str>> = entries.into_iter().collect();
399    let candidates: BTreeSet<&str> = flat
400        .keys()
401        .filter_map(|key| key.rsplit_once('.'))
402        .filter(|(_, last)| Category::from_name(last).is_some())
403        .map(|(parent, _)| parent)
404        .collect();
405    let plural: BTreeSet<String> = candidates
406        .into_iter()
407        .filter(|parent| {
408            let prefix = format!("{parent}.");
409            flat.range(prefix.clone()..)
410                .take_while(|(key, _)| key.starts_with(&prefix))
411                .all(|(key, _)| Category::from_name(&key[prefix.len()..]).is_some())
412        })
413        .map(str::to_owned)
414        .collect();
415    let mut values = BTreeMap::new();
416    for (key, text) in flat {
417        match key.rsplit_once('.').filter(|(parent, _)| plural.contains(*parent)) {
418            Some((parent, last)) => {
419                let category = Category::from_name(last).expect("plural children are categories");
420                let entry = values.entry(parent.to_owned()).or_insert_with(|| Value::Plural(Default::default()));
421                if let Value::Plural(forms) = entry {
422                    forms[category as usize] = Some(text);
423                }
424            }
425            None => {
426                values.insert(key, Value::Text(text));
427            }
428        }
429    }
430    values
431}
432
433/// A problem in the locale files, found by [`check`].
434///
435/// Displays as one line pointing at the file, as `ocre i18n missing` prints
436/// it: `locales/fr.yml: missing posts.title`.
437///
438/// # Examples
439///
440/// ```
441/// use ocre::i18n::Problem;
442///
443/// let missing = Problem::Missing { locale: "fr".into(), key: "posts.title".into() };
444/// assert_eq!(missing.to_string(), "locales/fr.yml: missing posts.title");
445/// let invalid = Problem::Invalid { locale: "fr".into(), line: 3, message: "expected `key: value`".into() };
446/// assert_eq!(invalid.to_string(), "locales/fr.yml:3: expected `key: value`");
447/// ```
448#[derive(Debug, Clone, PartialEq)]
449pub enum Problem {
450    /// The file is not valid locale YAML; nothing in it is used.
451    Invalid {
452        /// Locale code of the file, e.g. `fr` for `locales/fr.yml`.
453        locale: String,
454        /// 1-based line of the first error.
455        line: usize,
456        /// What is wrong, and how to fix it.
457        message: String,
458    },
459    /// A key of the default locale (or a plural form this language needs)
460    /// is missing from `locale`.
461    Missing {
462        /// Locale code of the file lacking the key.
463        locale: String,
464        /// Full dotted key, e.g. `posts.title` or `posts.count.few`.
465        key: String,
466    },
467}
468
469impl fmt::Display for Problem {
470    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
471        match self {
472            Self::Invalid { locale, line, message } => write!(f, "locales/{locale}.yml:{line}: {message}"),
473            Self::Missing { locale, key } => write!(f, "locales/{locale}.yml: missing {key}"),
474        }
475    }
476}
477
478/// Checks locale files for syntax errors and keys missing from non-default locales.
479///
480/// Reads `(code, file contents)` pairs the way [`Catalog::load`] does: first
481/// the syntax of each file ([`Problem::Invalid`]), then every key of the
482/// default (first) locale in every other one ([`Problem::Missing`]), with the
483/// plural forms each language needs (Russian needs `one`, `few`, `many` and
484/// `other`; Japanese only `other`). A plural key present as plain text counts
485/// as present. When the default file is invalid, only syntax problems are
486/// reported. Nothing is logged; `ocre i18n missing` prints the result and
487/// fails when it is not empty.
488///
489/// # Examples
490///
491/// ```
492/// use ocre::i18n::{Problem, check};
493///
494/// let problems = check(&[
495///     ("en", "en:\n  title: Posts\n  count:\n    one: \"%{count} post\"\n    other: \"%{count} posts\"\n"),
496///     ("fr", "fr:\n  count:\n    one: \"%{count} article\"\n    other: \"%{count} articles\"\n"),
497/// ]);
498/// assert_eq!(problems, [Problem::Missing { locale: "fr".into(), key: "title".into() }]);
499///
500/// // Russian needs more plural forms than English.
501/// let problems = check(&[
502///     ("en", "en:\n  count:\n    one: \"%{count} post\"\n    other: \"%{count} posts\"\n"),
503///     ("ru", "ru:\n  count:\n    one: \"%{count} пост\"\n    other: \"%{count} поста\"\n"),
504/// ]);
505/// let lines: Vec<String> = problems.iter().map(ToString::to_string).collect();
506/// assert_eq!(lines, ["locales/ru.yml: missing count.few", "locales/ru.yml: missing count.many"]);
507/// ```
508pub fn check(sources: &[(&str, &str)]) -> Vec<Problem> {
509    let mut problems = Vec::new();
510    let mut parsed = Vec::with_capacity(sources.len());
511    for (code, text) in sources {
512        match yaml::parse_locale(code, text) {
513            Ok(entries) => parsed.push(Some(entries)),
514            Err(err) => {
515                problems.push(Problem::Invalid { locale: (*code).to_owned(), line: err.line, message: err.message });
516                parsed.push(None);
517            }
518        }
519    }
520    let Some(Some(default)) = parsed.first() else {
521        return problems;
522    };
523    let default = group(default.clone());
524    for ((code, _), entries) in sources.iter().zip(&parsed).skip(1) {
525        let Some(entries) = entries else { continue };
526        let present: BTreeSet<&str> = entries.iter().map(|(key, _)| key.as_str()).collect();
527        for (key, value) in &default {
528            let required: Vec<String> = match value {
529                Value::Text(_) => vec![key.clone()],
530                Value::Plural(_) if present.contains(key.as_str()) => vec![],
531                Value::Plural(_) => Rule::for_locale(code)
532                    .categories()
533                    .iter()
534                    .map(|category| format!("{key}.{}", category.name()))
535                    .collect(),
536            };
537            for key in required.into_iter().filter(|key| !present.contains(key.as_str())) {
538                problems.push(Problem::Missing { locale: (*code).to_owned(), key });
539            }
540        }
541    }
542    problems
543}
544
545/// Axum extractor giving translations in the request's locale, like Rails' `I18n.t` with a per-request locale.
546///
547/// The locale comes from the `{locale}` path segment (routes nested with
548/// `.nest("/{locale}", ..)`, see [`LOCALE_PARAM`]), else the `locale` cookie
549/// ([`LOCALE_COOKIE`]), else `Accept-Language` (by quality; `fr-CH` matches
550/// `fr`, `pt` matches `pt-BR`), else the default locale. It is `Copy`: pass
551/// it by value to template structs, where `{{ i18n.t("posts.title") }}`
552/// writes the text. Selecting the locale reads headers only; no billed
553/// resource is used.
554///
555/// Rejections render as HTML pages in full-stack apps and as JSON in
556/// API-only apps:
557/// - [`Error::NotFound`] (404) when the `{locale}`
558///   path segment is not one of the app's locales;
559/// - [`Error::Internal`] (500) when [`layer`] is
560///   missing; the log line names the fix.
561///
562/// # Examples
563///
564/// ```
565/// use axum::{Router, routing::get};
566/// use ocre::{Ctx, i18n::I18n};
567///
568/// async fn title(i18n: I18n) -> String {
569///     i18n.t("posts.title").to_string()
570/// }
571///
572/// // `/en/posts`, `/fr/posts`; `/de/posts` is a 404 unless the app has German.
573/// fn routes() -> Router<Ctx> {
574///     Router::new().nest("/{locale}", Router::new().route("/posts", get(title)))
575/// }
576/// # let _ = routes;
577/// ```
578#[derive(Clone, Copy)]
579pub struct I18n {
580    catalog: &'static Catalog,
581    index: usize,
582    /// Prefix of keys starting with `.`, set by [`scope`](Self::scope).
583    scope: &'static str,
584}
585
586impl fmt::Debug for I18n {
587    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
588        f.debug_struct("I18n").field("locale", &self.locale()).finish()
589    }
590}
591
592impl I18n {
593    /// Returns the locale code, e.g. for `<html lang="{{ i18n.locale() }}">`.
594    ///
595    /// # Examples
596    ///
597    /// ```
598    /// # use std::sync::LazyLock;
599    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n")]));
600    /// assert_eq!(LOCALES.locale("en").locale(), "en");
601    /// ```
602    pub fn locale(&self) -> &'static str {
603        self.catalog.tables[self.index].code
604    }
605
606    /// Returns every locale code of the app, default first, e.g. for a language switcher.
607    ///
608    /// # Examples
609    ///
610    /// ```
611    /// # use std::sync::LazyLock;
612    /// static LOCALES: ocre::i18n::Locales =
613    ///     LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n"), ("fr", "fr:\n")]));
614    /// assert_eq!(LOCALES.locale("fr").codes().collect::<Vec<_>>(), ["en", "fr"]);
615    /// ```
616    pub fn codes(&self) -> impl Iterator<Item = &'static str> + 'static {
617        self.catalog.codes()
618    }
619
620    /// Starts the translation of a dotted key, like Rails' `t("posts.created")`.
621    ///
622    /// The returned [`Translation`] is a [`Display`](fmt::Display) value:
623    /// askama writes it (escaped) into the page without an extra `String`,
624    /// `.to_string()` gives one (for flashes, emails). Add `%{name}` values
625    /// with [`Translation::arg`] and the plural count with
626    /// [`Translation::count`]. The lookup happens when it is displayed.
627    ///
628    /// # Examples
629    ///
630    /// ```
631    /// # use std::sync::LazyLock;
632    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
633    ///     ocre::i18n::Catalog::load(&[("en", "en:\n  posts:\n    created: Post was successfully created.\n")])
634    /// });
635    /// let i18n = LOCALES.locale("en");
636    /// assert_eq!(i18n.t("posts.created").to_string(), "Post was successfully created.");
637    /// ```
638    pub fn t<'a>(&self, key: &'a str) -> Translation<'a> {
639        Translation {
640            i18n: *self,
641            key: self.resolve(key),
642            alternatives: Vec::new(),
643            default: None,
644            count: None,
645            args: Vec::new(),
646        }
647    }
648
649    /// Returns the same translations with `scope` as the prefix of keys starting with `.` (Rails' lazy lookup).
650    ///
651    /// `i18n.scope("posts.index").t(".title")` looks up `posts.index.title`;
652    /// keys without a leading dot stay absolute. Rails derives the scope from
653    /// the view's path; in Ocre, the handler sets it before passing `i18n` to
654    /// its template. A scope replaces the previous one.
655    ///
656    /// # Examples
657    ///
658    /// ```
659    /// # use std::sync::LazyLock;
660    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
661    ///     ocre::i18n::Catalog::load(&[("en", "en:\n  app:\n    name: Blog\n  posts:\n    index:\n      title: Posts\n")])
662    /// });
663    /// let i18n = LOCALES.locale("en").scope("posts.index");
664    /// assert_eq!(i18n.t(".title").to_string(), "Posts");
665    /// assert_eq!(i18n.t("app.name").to_string(), "Blog");
666    /// ```
667    pub fn scope(&self, scope: &'static str) -> Self {
668        Self { scope, ..*self }
669    }
670
671    /// Returns the same translations in locale `code` (Rails' `locale:` option), keeping the scope.
672    ///
673    /// `code` matches like [`Catalog::locale`]: exactly, case-insensitively,
674    /// and an unknown code gives the default locale.
675    ///
676    /// # Examples
677    ///
678    /// ```
679    /// # use std::sync::LazyLock;
680    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
681    ///     ocre::i18n::Catalog::load(&[("en", "en:\n  hello: Hello\n"), ("fr", "fr:\n  hello: Bonjour\n")])
682    /// });
683    /// let i18n = LOCALES.locale("en");
684    /// assert_eq!(i18n.in_locale("fr").t("hello").to_string(), "Bonjour");
685    /// assert_eq!(i18n.in_locale("de").locale(), "en");
686    /// ```
687    pub fn in_locale(&self, code: &str) -> Self {
688        Self { index: self.catalog.find(code).unwrap_or(0), ..*self }
689    }
690
691    /// Whether `key` has a translation: in the locale, the default locale or the built-in translations.
692    ///
693    /// Keys starting with `.` are relative to the [`scope`](Self::scope).
694    ///
695    /// # Examples
696    ///
697    /// ```
698    /// # use std::sync::LazyLock;
699    /// static LOCALES: ocre::i18n::Locales =
700    ///     LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n  posts:\n    title: Posts\n")]));
701    /// let i18n = LOCALES.locale("en");
702    /// assert!(i18n.exists("posts.title"));
703    /// assert!(i18n.exists("errors.messages.blank"), "built in");
704    /// assert!(!i18n.exists("posts"), "a namespace is not a translation");
705    /// ```
706    pub fn exists(&self, key: &str) -> bool {
707        self.lookup(&[&self.resolve(key)], None, false).is_some()
708    }
709
710    /// Returns every translation under `prefix`, by key relative to it (Rails' namespace lookup).
711    ///
712    /// Nested keys keep their dots (`form.submit`); plural keys give their
713    /// `other` form. Only the app's files are read. Release builds add the
714    /// default locale's keys missing from the locale, as [`t`](Self::t) falls
715    /// back to them. Keys starting with `.` are relative to the
716    /// [`scope`](Self::scope). Handy to hand a group of texts to JavaScript:
717    /// `Json(i18n.namespace("editor"))`.
718    ///
719    /// # Examples
720    ///
721    /// ```
722    /// # use std::sync::LazyLock;
723    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
724    ///     ocre::i18n::Catalog::load(&[("en", "en:\n  editor:\n    bold: Bold\n    link:\n      title: Link\n  other: x\n")])
725    /// });
726    /// let texts = LOCALES.locale("en").namespace("editor");
727    /// assert_eq!(texts.into_iter().collect::<Vec<_>>(), [("bold", "Bold"), ("link.title", "Link")]);
728    /// ```
729    pub fn namespace(&self, prefix: &str) -> BTreeMap<&'static str, &'static str> {
730        self.namespace_in(prefix, cfg!(debug_assertions))
731    }
732
733    fn namespace_in(&self, prefix: &str, strict: bool) -> BTreeMap<&'static str, &'static str> {
734        let start = format!("{}.", self.resolve(prefix));
735        let mut texts = BTreeMap::new();
736        let tables = if strict || self.index == 0 { &[self.index][..] } else { &[0, self.index][..] };
737        for &index in tables {
738            let table: &'static Table = &self.catalog.tables[index];
739            for (key, _) in table.values.range(start.clone()..).take_while(|(key, _)| key.starts_with(&start)) {
740                if let Some(text) = table.get(key, None) {
741                    texts.insert(&key[start.len()..], text);
742                }
743            }
744        }
745        texts
746    }
747
748    /// Prefixes `path` with the locale, for routes nested under `/{locale}` (Rails' `default_url_options`).
749    ///
750    /// `i18n.path("/posts")` is `/fr/posts` for French visitors, so links
751    /// keep the locale; `"/"` gives `/fr`. Use it in templates:
752    /// `<a href="{{ i18n.path("/posts") }}">`.
753    ///
754    /// # Examples
755    ///
756    /// ```
757    /// # use std::sync::LazyLock;
758    /// static LOCALES: ocre::i18n::Locales =
759    ///     LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n"), ("fr", "fr:\n")]));
760    /// let i18n = LOCALES.locale("fr");
761    /// assert_eq!(i18n.path("/posts/3"), "/fr/posts/3");
762    /// assert_eq!(i18n.path("/"), "/fr");
763    /// ```
764    pub fn path(&self, path: &str) -> String {
765        match path.trim_start_matches('/') {
766            "" => format!("/{}", self.locale()),
767            rest => format!("/{}/{rest}", self.locale()),
768        }
769    }
770
771    /// The absolute URL of `path` in every locale, the default first:
772    /// `(code, base_url + "/<code>" + path)`, for a sitemap's alternates
773    /// ([`Sitemap::add_localized`](crate::seo::Sitemap::add_localized)).
774    ///
775    /// # Examples
776    ///
777    /// ```
778    /// # use std::sync::LazyLock;
779    /// static LOCALES: ocre::i18n::Locales =
780    ///     LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n"), ("zh-Hans", "zh-Hans:\n")]));
781    /// let urls = LOCALES.locale("zh-Hans").alternates("https://ex.com/", "/pricing");
782    /// assert_eq!(urls, [("en", "https://ex.com/en/pricing".to_owned()), ("zh-Hans", "https://ex.com/zh-Hans/pricing".to_owned())]);
783    /// ```
784    pub fn alternates(&self, base_url: &str, path: &str) -> Vec<(&'static str, String)> {
785        let base = base_url.trim_end_matches('/');
786        self.codes().map(|code| (code, format!("{base}{}", self.in_locale(code).path(path)))).collect()
787    }
788
789    /// The `<link>` elements a localized page's `<head>` needs: `canonical`
790    /// (this locale's URL), one `alternate` per locale with its `hreflang`,
791    /// and `x-default` (the default locale's URL). `base_url` is the app's
792    /// public origin (`APP_URL`, [`ocre::mail::url`](crate::mail::url)),
793    /// `path` the page without its locale prefix. Values are escaped; in
794    /// askama: `{{ i18n.alternate_links(base_url, "/pricing")|safe }}`.
795    ///
796    /// # Examples
797    ///
798    /// ```
799    /// # use std::sync::LazyLock;
800    /// static LOCALES: ocre::i18n::Locales =
801    ///     LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n"), ("fr", "fr:\n")]));
802    /// assert_eq!(
803    ///     LOCALES.locale("fr").alternate_links("https://ex.com", "/"),
804    ///     "<link rel=\"canonical\" href=\"https://ex.com/fr\">\n\
805    ///      <link rel=\"alternate\" hreflang=\"en\" href=\"https://ex.com/en\">\n\
806    ///      <link rel=\"alternate\" hreflang=\"fr\" href=\"https://ex.com/fr\">\n\
807    ///      <link rel=\"alternate\" hreflang=\"x-default\" href=\"https://ex.com/en\">"
808    /// );
809    /// ```
810    pub fn alternate_links(&self, base_url: &str, path: &str) -> String {
811        let base = base_url.trim_end_matches('/');
812        let attribute = |text: &str| text.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
813        let mut links =
814            vec![format!("<link rel=\"canonical\" href=\"{}\">", attribute(&format!("{base}{}", self.path(path))))];
815        let alternates = self.alternates(base, path);
816        for (code, url) in &alternates {
817            links.push(format!(
818                "<link rel=\"alternate\" hreflang=\"{}\" href=\"{}\">",
819                attribute(code),
820                attribute(url)
821            ));
822        }
823        if let Some((_, default)) = alternates.first() {
824            links.push(format!("<link rel=\"alternate\" hreflang=\"x-default\" href=\"{}\">", attribute(default)));
825        }
826        links.join("\n")
827    }
828
829    /// `.title` with the scope `posts.index` is `posts.index.title`.
830    fn resolve<'a>(&self, key: &'a str) -> Cow<'a, str> {
831        match key.strip_prefix('.') {
832            Some(rest) if !self.scope.is_empty() => Cow::Owned(format!("{}.{rest}", self.scope)),
833            Some(rest) => Cow::Borrowed(rest),
834            None => Cow::Borrowed(key),
835        }
836    }
837
838    /// The first of `keys` found in: the locale's file, the built-in
839    /// translations of its language, then (unless `strict`) the default
840    /// locale's file and its built-in translations, then built-in English.
841    fn lookup(&self, keys: &[&str], count: Option<i64>, strict: bool) -> Option<&'static str> {
842        let catalog: &'static Catalog = self.catalog;
843        let own = &catalog.tables[self.index];
844        let default = &catalog.tables[0];
845        let app = |table: &'static Table| keys.iter().find_map(|key| table.get(key, count));
846        let built_in = |code: &str| keys.iter().find_map(|key| builtin::get(code, key, count));
847        app(own)
848            .or_else(|| built_in(own.code))
849            .or_else(|| if strict { None } else { app(default).or_else(|| built_in(default.code)) })
850            .or_else(|| built_in("en"))
851    }
852
853    /// Returns a `Set-Cookie` value remembering this locale for a year.
854    ///
855    /// The cookie is `locale=<code>; Path=/; Max-Age=31536000; SameSite=Lax`;
856    /// later requests without a `{locale}` path segment use it. Send it
857    /// when the visitor picks a language.
858    ///
859    /// # Examples
860    ///
861    /// ```
862    /// use std::sync::LazyLock;
863    /// use axum::{extract::Path, http::header, response::{IntoResponse, Redirect}};
864    ///
865    /// static LOCALES: ocre::i18n::Locales =
866    ///     LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n"), ("fr", "fr:\n")]));
867    ///
868    /// // POST /locale/{code}
869    /// async fn switch(Path(code): Path<String>) -> impl IntoResponse {
870    ///     let cookie = LOCALES.locale(&code).cookie(); // unknown codes give the default
871    ///     ([(header::SET_COOKIE, cookie)], Redirect::to("/"))
872    /// }
873    /// # let _ = switch;
874    ///
875    /// assert_eq!(LOCALES.locale("fr").cookie(), "locale=fr; Path=/; Max-Age=31536000; SameSite=Lax");
876    /// ```
877    pub fn cookie(&self) -> String {
878        format!("{LOCALE_COOKIE}={}; Path=/; Max-Age=31536000; SameSite=Lax", self.locale())
879    }
880
881    /// The extractor's logic: the catalog put there by [`layer`], then [`select`](Self::select).
882    fn from_parts(catalog: Option<&'static Catalog>, path: Option<&str>, headers: &HeaderMap) -> Result<Self> {
883        let catalog = catalog.ok_or_else(|| {
884            Error::internal(
885                "the I18n extractor needs the catalog. Fix: add `.layer(ocre::i18n::layer(&LOCALES))` at the end of \
886                 routes() in src/lib.rs (`ocre g locale` does it)",
887            )
888        })?;
889        Self::select(catalog, path, headers)
890    }
891
892    /// The locale of a request: path segment, cookie, `Accept-Language`, default.
893    fn select(catalog: &'static Catalog, path: Option<&str>, headers: &HeaderMap) -> Result<Self> {
894        if let Some(code) = path {
895            return catalog.find(code).map(|index| Self { catalog, index, scope: "" }).ok_or(Error::NotFound);
896        }
897        let cookie = headers
898            .get_all(header::COOKIE)
899            .iter()
900            .filter_map(|value| value.to_str().ok())
901            .flat_map(|value| value.split(';'))
902            .filter_map(|pair| pair.trim().strip_prefix(LOCALE_COOKIE)?.strip_prefix('='))
903            .find_map(|code| catalog.find(code));
904        let index = cookie
905            .or_else(|| {
906                let accept = headers.get(header::ACCEPT_LANGUAGE)?.to_str().ok()?;
907                catalog.negotiate(accept)
908            })
909            .unwrap_or(0);
910        Ok(Self { catalog, index, scope: "" })
911    }
912}
913
914/// Failures render as HTML pages in full-stack apps and as JSON in API-only apps.
915impl<S: Send + Sync> FromRequestParts<S> for I18n {
916    type Rejection = crate::session::Rejection;
917
918    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
919        let params = RawPathParams::from_request_parts(parts, state).await.ok();
920        let path = params.as_ref().and_then(|params| params.iter().find(|(name, _)| *name == LOCALE_PARAM));
921        Self::from_parts(
922            parts.extensions.get::<&'static Catalog>().copied(),
923            path.map(|(_, code)| code),
924            &parts.headers,
925        )
926        .map_err(crate::session::reject)
927    }
928}
929
930/// A translation being built by [`I18n::t`], displayed (looked up and interpolated) when written.
931///
932/// Add `%{name}` values with [`arg`](Self::arg) and the plural count with
933/// [`count`](Self::count). Displaying it looks the key up in the locale,
934/// then (release builds only) in the default locale, and replaces each
935/// `%{name}` with its value; a placeholder without a value stays as written.
936/// A key missing everywhere, and in debug builds (`ocre dev`, tests) a key
937/// missing from the locale, displays as `translation missing: fr.posts.title`.
938/// The text is not escaped here: askama escapes it when it writes it.
939///
940/// # Examples
941///
942/// ```
943/// # use std::sync::LazyLock;
944/// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
945///     ocre::i18n::Catalog::load(&[(
946///         "en",
947///         "en:\n  inbox:\n    zero: \"No messages, %{name}\"\n    one: \"One message\"\n    other: \"%{count} messages\"\n",
948///     )])
949/// });
950/// let i18n = LOCALES.locale("en");
951/// assert_eq!(i18n.t("inbox").count(0).arg("name", "Ada").to_string(), "No messages, Ada");
952/// assert_eq!(i18n.t("inbox").count(1).to_string(), "One message");
953/// assert_eq!(i18n.t("inbox").count(12).to_string(), "12 messages");
954/// assert_eq!(i18n.t("nope").to_string(), "translation missing: en.nope");
955/// ```
956#[derive(Debug, Clone)]
957pub struct Translation<'a> {
958    i18n: I18n,
959    key: Cow<'a, str>,
960    alternatives: Vec<Cow<'a, str>>,
961    default: Option<Cow<'a, str>>,
962    count: Option<i64>,
963    args: Vec<(&'a str, String)>,
964}
965
966impl<'a> Translation<'a> {
967    /// Sets the value for the `%{name}` placeholder.
968    ///
969    /// `value` is formatted with [`Display`](fmt::Display) right away.
970    /// Calling it twice with the same name keeps the first value.
971    ///
972    /// # Examples
973    ///
974    /// ```
975    /// # use std::sync::LazyLock;
976    /// static LOCALES: ocre::i18n::Locales =
977    ///     LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n  hi: \"Hi %{name}\"\n")]));
978    /// assert_eq!(LOCALES.locale("en").t("hi").arg("name", "Ada").to_string(), "Hi Ada");
979    /// ```
980    pub fn arg(mut self, name: &'a str, value: impl fmt::Display) -> Self {
981        self.args.push((name, value.to_string()));
982        self
983    }
984
985    /// Sets the plural count: picks the form for `n` and fills `%{count}`.
986    ///
987    /// The form follows the locale's CLDR rule (French: 0 and 1 are `one`;
988    /// Russian: `one`, `few`, `many`); a `zero` form, when present, wins
989    /// for 0. A missing form falls back to `other`. An explicit
990    /// [`arg`](Self::arg)`("count", ..)` overrides the `%{count}` text.
991    ///
992    /// # Examples
993    ///
994    /// ```
995    /// # use std::sync::LazyLock;
996    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
997    ///     ocre::i18n::Catalog::load(&[("fr", "fr:\n  posts:\n    one: \"%{count} article\"\n    other: \"%{count} articles\"\n")])
998    /// });
999    /// assert_eq!(LOCALES.locale("fr").t("posts").count(0).to_string(), "0 article");
1000    /// assert_eq!(LOCALES.locale("fr").t("posts").count(2_usize).to_string(), "2 articles");
1001    /// ```
1002    pub fn count(mut self, n: impl Count) -> Self {
1003        self.count = Some(n.to_count());
1004        self
1005    }
1006
1007    /// Tries another key when this one has no translation (Rails' `default: :"other.key"`).
1008    ///
1009    /// Alternatives are tried in the order given, each like the first key
1010    /// (locale, then default locale in release builds); a leading `.` is
1011    /// relative to the [`scope`](I18n::scope).
1012    ///
1013    /// # Examples
1014    ///
1015    /// ```
1016    /// # use std::sync::LazyLock;
1017    /// static LOCALES: ocre::i18n::Locales =
1018    ///     LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n  actions:\n    save: Save\n")]));
1019    /// let i18n = LOCALES.locale("en");
1020    /// assert_eq!(i18n.t("posts.form.save").or_key("actions.save").to_string(), "Save");
1021    /// ```
1022    pub fn or_key(mut self, key: &'a str) -> Self {
1023        self.alternatives.push(self.i18n.resolve(key));
1024        self
1025    }
1026
1027    /// Uses `text` when neither the key nor its alternatives have a translation (Rails' `default: "text"`).
1028    ///
1029    /// It replaces `translation missing: ...` in every build (debug builds
1030    /// included, where a key missing from the locale shows `text` rather
1031    /// than the default locale's translation). `%{name}` placeholders in
1032    /// `text` are filled like in a translation.
1033    ///
1034    /// # Examples
1035    ///
1036    /// ```
1037    /// # use std::sync::LazyLock;
1038    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n")]));
1039    /// let i18n = LOCALES.locale("en");
1040    /// assert_eq!(i18n.t("posts.empty").or("No posts yet, %{name}").arg("name", "Ada").to_string(), "No posts yet, Ada");
1041    /// ```
1042    pub fn or(mut self, text: impl Into<Cow<'a, str>>) -> Self {
1043        self.default = Some(text.into());
1044        self
1045    }
1046
1047    /// Marks the translation as HTML: its text is written as is, the values of [`arg`](Self::arg) escaped.
1048    ///
1049    /// Rails' `_html` keys: for translations containing markup, such as
1050    /// `terms: "I accept the <a href=\"/terms\">terms</a>, %{name}"`. askama
1051    /// writes the result without escaping it again (feature `html`); the
1052    /// `%{name}` values are escaped, so user input stays text. Use it only
1053    /// for keys whose text you wrote, never for text from users.
1054    ///
1055    /// # Examples
1056    ///
1057    /// ```
1058    /// # use std::sync::LazyLock;
1059    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
1060    ///     ocre::i18n::Catalog::load(&[("en", "en:\n  welcome: \"Hello <b>%{name}</b>\"\n")])
1061    /// });
1062    /// let i18n = LOCALES.locale("en");
1063    /// assert_eq!(i18n.t("welcome").arg("name", "<script>").html().to_string(), "Hello <b>&lt;script&gt;</b>");
1064    /// ```
1065    pub fn html(self) -> HtmlTranslation<'a> {
1066        HtmlTranslation(self)
1067    }
1068
1069    fn write(&self, f: &mut fmt::Formatter<'_>, strict: bool) -> fmt::Result {
1070        self.write_with(f, strict, false)
1071    }
1072
1073    fn write_with(&self, f: &mut fmt::Formatter<'_>, strict: bool, escape: bool) -> fmt::Result {
1074        let mut keys = Vec::with_capacity(1 + self.alternatives.len());
1075        keys.push(self.key.as_ref());
1076        keys.extend(self.alternatives.iter().map(AsRef::as_ref));
1077        let text = match (self.i18n.lookup(&keys, self.count, strict), &self.default) {
1078            (Some(text), _) => text,
1079            (None, Some(default)) => default,
1080            (None, None) => return write!(f, "translation missing: {}.{}", self.i18n.locale(), self.key),
1081        };
1082        interpolate(f, text, &self.args, self.count, escape)
1083    }
1084}
1085
1086/// Writes `text` with each `%{name}` replaced by its value in `args` (or
1087/// `count` for `%{count}`), HTML-escaped when `escape`; a placeholder without
1088/// a value stays as written.
1089fn interpolate(
1090    out: &mut impl fmt::Write,
1091    text: &str,
1092    args: &[(&str, String)],
1093    count: Option<i64>,
1094    escape: bool,
1095) -> fmt::Result {
1096    let mut rest = text;
1097    while let Some(start) = rest.find("%{") {
1098        let Some(len) = rest[start..].find('}') else { break };
1099        out.write_str(&rest[..start])?;
1100        let name = &rest[start + 2..start + len];
1101        match args.iter().find(|(arg, _)| *arg == name) {
1102            Some((_, value)) if escape => write_escaped(out, value)?,
1103            Some((_, value)) => out.write_str(value)?,
1104            None => match count {
1105                Some(count) if name == "count" => write!(out, "{count}")?,
1106                _ => out.write_str(&rest[start..=start + len])?,
1107            },
1108        }
1109        rest = &rest[start + len + 1..];
1110    }
1111    out.write_str(rest)
1112}
1113
1114fn write_escaped(out: &mut impl fmt::Write, text: &str) -> fmt::Result {
1115    let mut done = 0;
1116    for (at, c) in text.char_indices() {
1117        let entity = match c {
1118            '&' => "&amp;",
1119            '<' => "&lt;",
1120            '>' => "&gt;",
1121            '"' => "&quot;",
1122            '\'' => "&#39;",
1123            _ => continue,
1124        };
1125        out.write_str(&text[done..at])?;
1126        out.write_str(entity)?;
1127        done = at + 1;
1128    }
1129    out.write_str(&text[done..])
1130}
1131
1132/// A [`Translation`] written as HTML, from [`Translation::html`]: the text as is, every value escaped.
1133///
1134/// askama writes it without escaping (it implements
1135/// `askama::filters::HtmlSafe` with the `html` feature), so
1136/// `{{ i18n.t("terms").arg("name", user.name).html() }}` keeps the
1137/// translation's markup while `user.name` stays text.
1138///
1139/// # Examples
1140///
1141/// ```
1142/// # use std::sync::LazyLock;
1143/// static LOCALES: ocre::i18n::Locales =
1144///     LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n  note: \"<em>%{text}</em>\"\n")]));
1145/// let html = LOCALES.locale("en").t("note").arg("text", "a & b").html();
1146/// assert_eq!(html.to_string(), "<em>a &amp; b</em>");
1147/// ```
1148#[derive(Debug, Clone)]
1149pub struct HtmlTranslation<'a>(Translation<'a>);
1150
1151impl fmt::Display for HtmlTranslation<'_> {
1152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1153        self.0.write_with(f, cfg!(debug_assertions), true)
1154    }
1155}
1156
1157#[cfg(feature = "html")]
1158impl askama::filters::HtmlSafe for HtmlTranslation<'_> {}
1159
1160/// English `datetime.distance_in_words.<key>` for `count`, as [`helpers`](crate::helpers) writes it.
1161pub(crate) fn english_distance(key: &str, count: i64) -> String {
1162    let text = builtin::get("en", &format!("datetime.distance_in_words.{key}"), Some(count)).unwrap_or(key);
1163    let mut out = String::new();
1164    interpolate(&mut out, text, &[], Some(count), false).expect("writing to a String");
1165    out
1166}
1167
1168/// A number [`Translation::count`] accepts: every integer type, and references to them.
1169///
1170/// References are accepted because askama passes template fields by
1171/// reference. Values outside the `i64` range count as `i64::MAX`.
1172///
1173/// # Examples
1174///
1175/// ```
1176/// use ocre::i18n::Count;
1177/// assert_eq!(3_usize.to_count(), 3);
1178/// assert_eq!((&-2_i32).to_count(), -2);
1179/// assert_eq!(u64::MAX.to_count(), i64::MAX);
1180/// ```
1181pub trait Count {
1182    /// Returns the number as `i64`, or `i64::MAX` when it does not fit.
1183    ///
1184    /// # Examples
1185    ///
1186    /// ```
1187    /// use ocre::i18n::Count;
1188    /// assert_eq!(7_u8.to_count(), 7);
1189    /// ```
1190    fn to_count(&self) -> i64;
1191}
1192
1193macro_rules! count_via_try_from {
1194    ($($ty:ty),*) => {$(
1195        impl Count for $ty {
1196            fn to_count(&self) -> i64 {
1197                i64::try_from(*self).unwrap_or(i64::MAX)
1198            }
1199        }
1200    )*};
1201}
1202
1203count_via_try_from!(i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize);
1204
1205impl<T: Count + ?Sized> Count for &T {
1206    fn to_count(&self) -> i64 {
1207        (**self).to_count()
1208    }
1209}
1210
1211/// Debug builds (`ocre dev`) show missing translations instead of falling back.
1212impl fmt::Display for Translation<'_> {
1213    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1214        self.write(f, cfg!(debug_assertions))
1215    }
1216}
1217
1218#[cfg(test)]
1219#[path = "../tests/i18n.rs"]
1220mod tests;