phrasebook 0.2.0

Fluent localization for Rust web frontends: message catalogs per language, arguments and plurals, a remembered language choice in the browser.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
use std::cell::RefCell;
use std::fmt;
use std::rc::Rc;

use fluent_bundle::types::{FluentNumber, FluentNumberOptions};

use crate::{Args, Catalog, Lang, Language, Phrasebook};

/// The handle components use: a [`Phrasebook`] open at one language.
///
/// Cloning is cheap (an `Rc` and a few words), and two handles compare
/// equal when they share a phrasebook, a language and a backstop, so a
/// component tree handed one re-renders only when the language changes.
///
/// # Lookup
///
/// A message is formatted in the first of these that defines it:
///
/// 1. the handle's language;
/// 2. the phrasebook's fallback language;
/// 3. the handle's backstop catalog, if it has one
///    ([`Phrases::with_backstop`]): in the handle's language if the catalog
///    has a source for it, else in the catalog's default language;
/// 4. otherwise the result is the id itself, so the hole is visible on the
///    page rather than blank.
///
/// Each miss (the handle's language lacking a message, or every bundle
/// lacking it) is logged once per id and language with `tracing`, and
/// format errors (a missing argument, say) once per id, so a page that
/// re-renders often does not drown the console.
///
/// # Examples
///
/// ```
/// use phrasebook::{Catalog, Lang, Phrasebook, Source, args};
///
/// static APP: Catalog = Catalog::new("app", &[
///     Source::new(Lang::EN_US, r#"
/// greeting = Hello, { $name }!
/// items = { $count ->
///     [one] One item
///    *[other] { $count } items
/// }
/// "#),
/// ]);
///
/// let book = Phrasebook::builder()
///     .language(Lang::EN_US, "English")
///     .catalog(&APP)
///     .build();
///
/// let phrases = book.phrases(Lang::EN_US);
///
/// assert_eq!(phrases.msg("greeting", &args!(name = "Ada")), "Hello, Ada!");
/// assert_eq!(phrases.msg("items", &args!(count = 1)), "One item");
/// // A count passed as its digits selects the same variant.
/// assert_eq!(phrases.msg("items", &args!(count = "1")), "One item");
/// assert_eq!(phrases.msg("items", &args!(count = 3)), "3 items");
/// ```
#[derive(Clone)]
pub struct Phrases {
    book: Phrasebook,
    lang: Lang,
    backstop: Option<Backstop>,
}

#[derive(Clone)]
struct Backstop {
    catalog: &'static Catalog,
    book: Phrasebook,
}

thread_local! {
    static EMPTY: Phrasebook = Phrasebook::builder().build();
    static CATALOGS: RefCell<Vec<(usize, Phrasebook)>> = const { RefCell::new(Vec::new()) };
}

/// The phrasebook of `catalog` alone, built once per thread: every language
/// it has a source for, its default language as the fallback.
fn catalog_book(catalog: &'static Catalog) -> Phrasebook {
    let address = catalog as *const Catalog as usize;

    CATALOGS.with(|books| {
        if let Some((_, book)) = books.borrow().iter().find(|(a, _)| *a == address) {
            return book.clone();
        }

        let mut builder = Phrasebook::builder();

        for lang in catalog.languages() {
            builder = builder.language(lang, "");
        }

        let book = builder.catalog(catalog).build();
        books.borrow_mut().push((address, book.clone()));
        book
    })
}

impl Phrases {
    pub(crate) fn new(book: Phrasebook, lang: Lang) -> Phrases {
        Phrases {
            book,
            lang,
            backstop: None,
        }
    }

    /// A handle with no catalogs, so every lookup renders the id; shared per
    /// thread, so every call returns an equal handle. For a component used
    /// where nothing provides a handle.
    ///
    /// ```
    /// use phrasebook::Phrases;
    ///
    /// let phrases = Phrases::empty();
    /// assert_eq!(&*phrases.text("hello"), "hello");
    /// assert!(phrases == Phrases::empty());
    /// ```
    pub fn empty() -> Phrases {
        let book = EMPTY.with(Phrasebook::clone);
        let lang = book.fallback();
        Phrases::new(book, lang)
    }

    /// `catalog` alone, in its default language (that of its first source),
    /// built once per thread per catalog: what a library renders when
    /// nothing provides a handle. Its other languages are reachable with
    /// [`Phrases::with_lang`]; their [`Language::name`]s are empty.
    ///
    /// ```
    /// use phrasebook::{Catalog, Lang, Phrases, Source};
    ///
    /// static LIB: Catalog = Catalog::new("lib", &[
    ///     Source::new(Lang::EN_US, "lib-hello = Hello"),
    /// ])
    /// .with_prefix("lib-");
    ///
    /// let phrases = Phrases::from_catalog(&LIB);
    /// assert_eq!(phrases.lang(), Lang::EN_US);
    /// assert_eq!(&*phrases.text("lib-hello"), "Hello");
    /// assert!(phrases == Phrases::from_catalog(&LIB));
    /// ```
    pub fn from_catalog(catalog: &'static Catalog) -> Phrases {
        let book = catalog_book(catalog);
        let lang = book.fallback();
        Phrases::new(book, lang)
    }

    /// The same handle, consulting `catalog` when every bundle in the chain
    /// lacks a message (see [Lookup](Phrases#lookup)).
    ///
    /// A library component uses this on the handle its app provides, so its
    /// own messages render even when the app did not add its catalog, or
    /// not in this language. A message found only in the backstop is not
    /// logged as missing.
    ///
    /// ```
    /// use phrasebook::{Catalog, Lang, Phrasebook, Source};
    ///
    /// static APP: Catalog = Catalog::new("app", &[
    ///     Source::new(Lang::EN_US, "hello = Hello"),
    /// ]);
    ///
    /// static LIB: Catalog = Catalog::new("lib", &[
    ///     Source::new(Lang::EN_US, "lib-sign-in = Sign in"),
    /// ])
    /// .with_prefix("lib-");
    ///
    /// // The app did not add the library's catalog.
    /// let book = Phrasebook::builder()
    ///     .language(Lang::EN_US, "English")
    ///     .catalog(&APP)
    ///     .build();
    ///
    /// let phrases = book.phrases(Lang::EN_US).with_backstop(&LIB);
    /// assert_eq!(&*phrases.text("lib-sign-in"), "Sign in");
    /// assert_eq!(&*phrases.text("hello"), "Hello");
    /// ```
    pub fn with_backstop(&self, catalog: &'static Catalog) -> Phrases {
        Phrases {
            book: self.book.clone(),
            lang: self.lang,
            backstop: Some(Backstop {
                catalog,
                book: catalog_book(catalog),
            }),
        }
    }

    /// The handle's language.
    pub fn lang(&self) -> Lang {
        self.lang
    }

    /// The phrasebook's declared languages, for a language picker.
    pub fn languages(&self) -> &[Language] {
        self.book.languages()
    }

    /// The phrasebook this handle reads.
    pub fn phrasebook(&self) -> &Phrasebook {
        &self.book
    }

    /// The same bundles (and backstop) in another language, or in the
    /// fallback language if `lang` is not declared. An app's root calls
    /// this on a language change and provides the result.
    pub fn with_lang(&self, lang: Lang) -> Phrases {
        Phrases {
            book: self.book.clone(),
            lang: self.book.resolve(lang),
            backstop: self.backstop.clone(),
        }
    }

    /// A message without arguments, or its id if nothing defines it.
    ///
    /// Resolved once per language and id and handed out as an `Rc` clone
    /// after that.
    pub fn text(&self, id: &str) -> Rc<str> {
        match self.try_text(id) {
            Some(text) => text,
            None => {
                self.missing(id);
                Rc::from(id)
            }
        }
    }

    /// A message with arguments, or its id if nothing defines it.
    ///
    /// A variable the message selects a variant on (`{ $count -> ... }`, at
    /// any depth) that is passed as a string spelling a number is formatted
    /// as that number, so `[one]` and `[0]` match whether the caller passed
    /// `3` or `"3"`. Other variables are left as written.
    pub fn msg(&self, id: &str, args: &Args<'_>) -> String {
        match self.try_msg(id, args) {
            Some(text) => text,
            None => {
                self.missing(id);
                id.to_owned()
            }
        }
    }

    /// [`Phrases::text`] without the fallback to the id: `None` when nothing
    /// in the chain defines it.
    pub fn try_text(&self, id: &str) -> Option<Rc<str>> {
        if let Some(text) = self.book.inner.text(self.lang, id, false) {
            return Some(text);
        }

        let backstop = self.backstop.as_ref()?;
        let lang = backstop.book.resolve(self.lang);
        let text = backstop.book.inner.text(lang, id, true)?;
        self.backstopped(id, backstop);
        Some(text)
    }

    /// [`Phrases::msg`] without the fallback to the id: `None` when nothing
    /// in the chain defines it.
    pub fn try_msg(&self, id: &str, args: &Args<'_>) -> Option<String> {
        if let Some(text) = self.book.inner.format(self.lang, id, Some(args), false) {
            return Some(text);
        }

        let backstop = self.backstop.as_ref()?;
        let lang = backstop.book.resolve(self.lang);
        let text = backstop.book.inner.format(lang, id, Some(args), true)?;
        self.backstopped(id, backstop);
        Some(text)
    }

    /// A number shown outside a message, such as in a table cell, formatted
    /// as numbers in messages are (see [`Builder::locale_numbers`]): in the
    /// handle's language in the browser, as fluent does elsewhere. For
    /// other options, pass the number to a message using `NUMBER()`.
    ///
    /// ```
    /// use phrasebook::Phrases;
    ///
    /// // Outside the browser.
    /// assert_eq!(Phrases::empty().number(12345.5), "12345.5");
    /// ```
    ///
    /// [`Builder::locale_numbers`]: crate::Builder::locale_numbers
    pub fn number(&self, value: f64) -> String {
        let number = FluentNumber::new(value, FluentNumberOptions::default());

        #[cfg(all(feature = "browser", target_arch = "wasm32"))]
        if self.book.inner.locale_numbers
            && let Some(text) = crate::intl::number(self.lang, &number)
        {
            return text;
        }

        number.as_string().into_owned()
    }

    /// A message split where the translation places the argument `name`,
    /// for markup in its place, such as a user's name in `<strong>`: the
    /// text before it and, if the message uses `name`, the text after it.
    /// The other arguments are formatted as in [`Phrases::msg`], and a
    /// value `args` gives for `name` is ignored. If the message uses `name`
    /// more than once, the split is at the first use and later uses are
    /// left out.
    ///
    /// ```
    /// use phrasebook::{Args, Catalog, Lang, Phrasebook, Source, args};
    ///
    /// const SV_SE: Lang = Lang::new("sv-SE");
    ///
    /// static APP: Catalog = Catalog::new("app", &[
    ///     Source::new(Lang::EN_US, "removed = Removed { $user } from { $team }\nempty = Nobody here\n"),
    ///     Source::new(SV_SE, "removed = { $user } togs bort från { $team }\n"),
    /// ]);
    ///
    /// let book = Phrasebook::builder()
    ///     .language(Lang::EN_US, "English")
    ///     .language(SV_SE, "Svenska")
    ///     .catalog(&APP)
    ///     .build();
    ///
    /// let around = book.phrases(SV_SE).msg_around("removed", &args!(team = "Röd"), "user");
    /// assert_eq!(around.before, "");
    /// assert_eq!(around.after.as_deref(), Some(" togs bort från Röd"));
    ///
    /// // A message without the argument.
    /// let around = book.phrases(Lang::EN_US).msg_around("empty", &Args::new(), "user");
    /// assert_eq!(around.before, "Nobody here");
    /// assert_eq!(around.after, None);
    /// ```
    pub fn msg_around(&self, id: &str, args: &Args<'_>, name: &str) -> Around {
        // A private use character, which no message contains.
        const MARK: &str = "\u{E000}";
        // Bidi isolation around an argument, with `use_isolating`.
        const ISOLATES: [char; 2] = ['\u{2068}', '\u{2069}'];
        const ISOLATED: &str = "\u{2068}\u{E000}\u{2069}";

        let mut marked = Args::new();

        for (key, value) in args.inner.iter() {
            if key != name {
                marked.inner.set(key, value.clone());
            }
        }

        marked.inner.set(name, MARK);
        let text = self.msg(id, &marked);

        match text.split_once(MARK) {
            Some((before, after)) => Around {
                before: before
                    .strip_suffix(ISOLATES[0])
                    .unwrap_or(before)
                    .to_owned(),
                after: Some(
                    after
                        .strip_prefix(ISOLATES[1])
                        .unwrap_or(after)
                        .replace(ISOLATED, "")
                        .replace(MARK, ""),
                ),
            },
            None => Around {
                before: text,
                after: None,
            },
        }
    }

    fn missing(&self, id: &str) {
        if self.book.inner.first_miss(self.lang, id) {
            tracing::warn!(lang = %self.lang, id, "phrasebook: message missing");
        }
    }

    fn backstopped(&self, id: &str, backstop: &Backstop) {
        if self.book.inner.first_miss(self.lang, id) {
            tracing::debug!(
                lang = %self.lang,
                id,
                catalog = backstop.catalog.name(),
                "phrasebook: message from the backstop catalog"
            );
        }
    }
}

/// A message split where the translation places an argument, from
/// [`Phrases::msg_around`].
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Around {
    /// The text before the argument, or the whole message if it does not
    /// use the argument.
    pub before: String,
    /// The text after the argument; `None` if the message does not use it.
    pub after: Option<String>,
}

#[cfg(feature = "browser")]
#[cfg_attr(phrasebook_docsrs, doc(cfg(feature = "browser")))]
impl Phrases {
    /// Remember the handle's language in localStorage under
    /// [`browser::DEFAULT_KEY`](crate::browser::DEFAULT_KEY), for
    /// [`browser::initial`](crate::browser::initial) to pick up on the next
    /// visit. Outside wasm it does nothing.
    pub fn store(&self) {
        self.store_as(crate::browser::DEFAULT_KEY);
    }

    /// [`Phrases::store`] under another key.
    pub fn store_as(&self, key: &str) {
        crate::browser::store(self.lang, key);
    }
}

/// Equal when they share a phrasebook (by pointer), a language and a
/// backstop catalog (by pointer).
impl PartialEq for Phrases {
    fn eq(&self, other: &Self) -> bool {
        self.lang == other.lang
            && self.book.ptr_eq(&other.book)
            && match (&self.backstop, &other.backstop) {
                (None, None) => true,
                (Some(a), Some(b)) => core::ptr::eq(a.catalog, b.catalog),
                _ => false,
            }
    }
}

impl fmt::Debug for Phrases {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Phrases")
            .field("lang", &self.lang)
            .field("phrasebook", &self.book)
            .field(
                "backstop",
                &self.backstop.as_ref().map(|b| b.catalog.name()),
            )
            .finish()
    }
}