Skip to main content

lumen_ir/
translate.rs

1//! Resolving the strings an element shows into one language.
2//!
3//! `translatable="key"` names one catalogue message, and the element's every
4//! visible string comes off it: the text from the message value, and each
5//! further string from the Fluent attribute of the same name. So a search box
6//! written as
7//!
8//! ```text
9//! <input placeholder="Search catalogue" translatable="search"/>
10//! ```
11//!
12//! reads its placeholder from `search.placeholder`, and an author names one
13//! key per widget rather than one per string.
14//!
15//! This lives here, below both consumers, because the desktop spawner and the
16//! web emitter resolve the same element and must reach the same strings: an
17//! app run in a locale and a page built for it read alike. The rule has to
18//! know which attributes the markup authored, which the catalogue cannot see,
19//! so it belongs on this side of the seam. `lumen-ir` itself stays free of a
20//! translation dependency: the caller brings the lookup.
21//!
22//! The rule's input is [`AuthoredStrings`], which is what the markup wrote.
23//! A spawned element keeps that component, so the same rule resolves it
24//! again when the app changes locale while it runs, reaching the strings the
25//! catalogue holds now rather than the ones on screen.
26
27use lumen_core::components::AuthoredStrings;
28
29use crate::layout_ir::Attributes;
30
31/// What an element shows once its catalogue key has been resolved.
32///
33/// Each field is `None` exactly when the element has no such string at all;
34/// a string the markup authored is never dropped, only replaced.
35pub struct TranslatedStrings {
36    /// The element's text: [`Attributes::text`] after translation.
37    pub text: Option<String>,
38    /// A text entry's prompt: [`Attributes::placeholder`] after translation.
39    pub placeholder: Option<String>,
40    /// An image's alternative text: [`Attributes::alt`] after translation.
41    pub alt: Option<String>,
42    /// The popup body of the wrapping `<tooltip>`, which carries its own key.
43    pub tooltip: Option<String>,
44}
45
46/// The catalogue key the `name` string of a `translatable="key"` element
47/// resolves through.
48///
49/// `lumen_i18n::I18n`'s lookup is what splits this back apart, and defines
50/// the dotted spelling both sides agree on.
51pub fn attribute_key(key: &str, name: &str) -> String {
52    format!("{key}.{name}")
53}
54
55/// What the markup wrote, lifted off an element's attributes.
56///
57/// This is the whole input the resolver needs, which is why the spawner
58/// keeps it on the entity: rebuilding an element's text in another locale
59/// asks the same question of the same strings.
60impl From<&Attributes> for AuthoredStrings {
61    fn from(attrs: &Attributes) -> Self {
62        Self {
63            key: attrs.translatable.clone(),
64            text: attrs.text.clone(),
65            placeholder: attrs.placeholder.clone(),
66            alt: attrs.alt.clone(),
67            tooltip: attrs.tooltip.as_ref().map(|spec| spec.text.clone()),
68            tooltip_key: attrs
69                .tooltip
70                .as_ref()
71                .and_then(|spec| spec.translatable.clone()),
72        }
73    }
74}
75
76/// Every string `authored` shows, resolved through `lookup`.
77///
78/// `lookup` answers a catalogue key with the message it holds, or `None` when
79/// it holds none - `lumen_i18n::SharedI18n::try_t` and
80/// `lumen_core::i18n::AppI18n::try_translate` are the two that do.
81///
82/// Only a string the markup authored is translated. A catalogue entry for
83/// `key.placeholder` on an element with no `placeholder` does nothing, which
84/// is the same rule `lumenc i18n extract` applies from the other side, so
85/// what the extractor writes is exactly what resolves.
86pub fn translate(
87    authored: &AuthoredStrings,
88    lookup: &dyn Fn(&str) -> Option<String>,
89) -> TranslatedStrings {
90    let mut out = TranslatedStrings {
91        text: authored.text.clone(),
92        placeholder: authored.placeholder.clone(),
93        alt: authored.alt.clone(),
94        tooltip: authored.tooltip.clone(),
95    };
96    if let Some(key) = &authored.key {
97        if authored.placeholder.is_some() {
98            out.placeholder = lookup(&attribute_key(key, "placeholder")).or(out.placeholder);
99        }
100        if authored.alt.is_some() {
101            out.alt = lookup(&attribute_key(key, "alt")).or(out.alt);
102        }
103        // The catalogue's string wins, then the authored text, then the key
104        // itself, so an element whose translations are missing still shows
105        // its source string and one with no text at all shows the key that
106        // failed to resolve. The key stops standing in once the element
107        // names another translated string: an `<input translatable="search"
108        // placeholder="Search"/>` has no text, and writing `search` into the
109        // field as its value is a wrong value rather than a diagnostic.
110        out.text = lookup(key).or(out.text).or_else(|| {
111            (authored.placeholder.is_none() && authored.alt.is_none()).then(|| key.clone())
112        });
113    }
114    // A `<tooltip>` is its own element with its own text, so it takes a plain
115    // key of its own rather than an attribute on the trigger's. That also
116    // survives the desugars: a `<checkbox>` moves its key onto the caption
117    // child it synthesizes, leaving the trigger nothing to hang one on.
118    if let Some(key) = &authored.tooltip_key {
119        out.tooltip = lookup(key).or(out.tooltip);
120    }
121    out
122}
123
124#[cfg(test)]
125mod tests {
126    use std::collections::BTreeMap;
127
128    use super::*;
129    use crate::layout_ir::TooltipSpec;
130
131    fn catalogue(entries: &[(&str, &str)]) -> BTreeMap<String, String> {
132        entries
133            .iter()
134            .map(|(k, v)| (k.to_string(), v.to_string()))
135            .collect()
136    }
137
138    fn resolve(attrs: &Attributes, entries: &[(&str, &str)]) -> TranslatedStrings {
139        let map = catalogue(entries);
140        translate(&AuthoredStrings::from(attrs), &|key| map.get(key).cloned())
141    }
142
143    #[test]
144    fn an_untranslatable_element_keeps_every_authored_string() {
145        let attrs = Attributes {
146            text: Some("Search".into()),
147            placeholder: Some("Search catalogue".into()),
148            alt: Some("A logo".into()),
149            ..Attributes::default()
150        };
151        let out = resolve(&attrs, &[("search", "Suche")]);
152        assert_eq!(out.text.as_deref(), Some("Search"));
153        assert_eq!(out.placeholder.as_deref(), Some("Search catalogue"));
154        assert_eq!(out.alt.as_deref(), Some("A logo"));
155    }
156
157    #[test]
158    fn one_key_reaches_the_text_the_placeholder_and_the_alt() {
159        let attrs = Attributes {
160            text: Some("Search".into()),
161            placeholder: Some("Search catalogue".into()),
162            alt: Some("A logo".into()),
163            translatable: Some("search".into()),
164            ..Attributes::default()
165        };
166        let out = resolve(
167            &attrs,
168            &[
169                ("search", "Suche"),
170                ("search.placeholder", "Katalog durchsuchen"),
171                ("search.alt", "Ein Logo"),
172            ],
173        );
174        assert_eq!(out.text.as_deref(), Some("Suche"));
175        assert_eq!(out.placeholder.as_deref(), Some("Katalog durchsuchen"));
176        assert_eq!(out.alt.as_deref(), Some("Ein Logo"));
177    }
178
179    #[test]
180    fn a_string_the_markup_does_not_write_is_not_translated_into_existence() {
181        let attrs = Attributes {
182            text: Some("Search".into()),
183            translatable: Some("search".into()),
184            ..Attributes::default()
185        };
186        let out = resolve(&attrs, &[("search.placeholder", "Katalog durchsuchen")]);
187        assert_eq!(out.placeholder, None);
188        assert_eq!(out.alt, None);
189    }
190
191    #[test]
192    fn a_missing_message_leaves_the_authored_string() {
193        let attrs = Attributes {
194            text: Some("Search".into()),
195            placeholder: Some("Search catalogue".into()),
196            translatable: Some("search".into()),
197            ..Attributes::default()
198        };
199        let out = resolve(&attrs, &[]);
200        assert_eq!(out.text.as_deref(), Some("Search"));
201        assert_eq!(out.placeholder.as_deref(), Some("Search catalogue"));
202    }
203
204    #[test]
205    fn a_key_with_no_text_anywhere_shows_itself() {
206        let attrs = Attributes {
207            translatable: Some("app-title".into()),
208            ..Attributes::default()
209        };
210        assert_eq!(resolve(&attrs, &[]).text.as_deref(), Some("app-title"));
211    }
212
213    #[test]
214    fn a_key_naming_another_string_does_not_stand_in_for_the_text() {
215        let attrs = Attributes {
216            placeholder: Some("Search catalogue".into()),
217            translatable: Some("search".into()),
218            ..Attributes::default()
219        };
220        let out = resolve(&attrs, &[("search.placeholder", "Katalog durchsuchen")]);
221        assert_eq!(out.text, None);
222        assert_eq!(out.placeholder.as_deref(), Some("Katalog durchsuchen"));
223    }
224
225    #[test]
226    fn a_tooltip_carries_its_own_key() {
227        let attrs = Attributes {
228            text: Some("Save".into()),
229            translatable: Some("save".into()),
230            tooltip: Some(TooltipSpec {
231                text: "Save the file".into(),
232                translatable: Some("save-tip".into()),
233                delay_ms: None,
234                offset: None,
235            }),
236            ..Attributes::default()
237        };
238        let out = resolve(
239            &attrs,
240            &[("save", "Speichern"), ("save-tip", "Datei speichern")],
241        );
242        assert_eq!(out.text.as_deref(), Some("Speichern"));
243        assert_eq!(out.tooltip.as_deref(), Some("Datei speichern"));
244    }
245
246    #[test]
247    fn an_unmarked_tooltip_keeps_its_authored_body() {
248        let attrs = Attributes {
249            tooltip: Some(TooltipSpec {
250                text: "Save the file".into(),
251                translatable: None,
252                delay_ms: None,
253                offset: None,
254            }),
255            ..Attributes::default()
256        };
257        let out = resolve(&attrs, &[("save-tip", "Datei speichern")]);
258        assert_eq!(out.tooltip.as_deref(), Some("Save the file"));
259    }
260
261    #[test]
262    fn the_component_carries_every_string_the_markup_wrote() {
263        let attrs = Attributes {
264            text: Some("Save".into()),
265            placeholder: Some("Search catalogue".into()),
266            alt: Some("A logo".into()),
267            translatable: Some("save".into()),
268            tooltip: Some(TooltipSpec {
269                text: "Save the file".into(),
270                translatable: Some("save-tip".into()),
271                delay_ms: None,
272                offset: None,
273            }),
274            ..Attributes::default()
275        };
276        let authored = AuthoredStrings::from(&attrs);
277        assert_eq!(authored.key.as_deref(), Some("save"));
278        assert_eq!(authored.text.as_deref(), Some("Save"));
279        assert_eq!(authored.placeholder.as_deref(), Some("Search catalogue"));
280        assert_eq!(authored.alt.as_deref(), Some("A logo"));
281        assert_eq!(authored.tooltip.as_deref(), Some("Save the file"));
282        assert_eq!(authored.tooltip_key.as_deref(), Some("save-tip"));
283    }
284
285    /// The component is the resolver's whole input, so resolving it twice
286    /// against two catalogues reaches both languages from the same element.
287    /// That is what a locale change does to a live tree.
288    #[test]
289    fn one_authored_element_resolves_in_every_locale() {
290        let attrs = Attributes {
291            text: Some("Good morning".into()),
292            placeholder: Some("Search".into()),
293            translatable: Some("greet".into()),
294            ..Attributes::default()
295        };
296        let authored = AuthoredStrings::from(&attrs);
297        let german = catalogue(&[
298            ("greet", "Guten Morgen"),
299            ("greet.placeholder", "Katalog durchsuchen"),
300        ]);
301        let out = translate(&authored, &|key| german.get(key).cloned());
302        assert_eq!(out.text.as_deref(), Some("Guten Morgen"));
303        assert_eq!(out.placeholder.as_deref(), Some("Katalog durchsuchen"));
304        // Back to a catalogue with nothing in it, and the authored strings
305        // stand again rather than the German ones sticking.
306        let out = translate(&authored, &|_| None);
307        assert_eq!(out.text.as_deref(), Some("Good morning"));
308        assert_eq!(out.placeholder.as_deref(), Some("Search"));
309    }
310}