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('&', "&").replace('"', """).replace('<', "<");
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><script></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 '&' => "&",
1119 '<' => "<",
1120 '>' => ">",
1121 '"' => """,
1122 '\'' => "'",
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 & 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;