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
use crate::translator::errors::*;
use fluent_bundle::{FluentArgs, FluentBundle, FluentResource};
use std::rc::Rc;
use unic_langid::{LanguageIdentifier, LanguageIdentifierError};

/// The file extension used by the Fluent translator, which expects FTL files.
pub const FLUENT_TRANSLATOR_FILE_EXT: &str = "ftl";

/// Manages translations on the client-side for a single locale using Mozilla's [Fluent](https://projectfluent.org/) syntax. This
/// should generally be placed into an `Rc<T>` and referred to by every template in an app. You do NOT want to be cloning potentially
/// thousands of translations!
///
/// Fluent supports compound messages, with many variants, which can specified here using the form `[id].[variant]` in a translation ID,
/// as a `.` is not valid in an ID anyway, and so can be used as a delimiter. More than one dot will result in an error.
pub struct FluentTranslator {
    /// Stores the internal Fluent data for translating. This bundle directly owns its attached resources (translations).
    bundle: Rc<FluentBundle<FluentResource>>,
    /// The locale for which translations are being managed by this instance.
    locale: String,
}
impl FluentTranslator {
    /// Creates a new translator for a given locale, passing in translations in FTL syntax form.
    pub fn new(locale: String, ftl_string: String) -> Result<Self, TranslatorError> {
        let resource = FluentResource::try_new(ftl_string)
            // If this errors, we get it still and a vector of errors (wtf.)
            .map_err(|(_, errs)| TranslatorError::TranslationsStrSerFailed {
                locale: locale.clone(),
                source: errs
                    .iter()
                    .map(|e| e.to_string())
                    .collect::<String>()
                    .into(),
            })?;
        let lang_id: LanguageIdentifier =
            locale.parse().map_err(|err: LanguageIdentifierError| {
                TranslatorError::InvalidLocale {
                    locale: locale.clone(),
                    source: Box::new(err),
                }
            })?;
        let mut bundle = FluentBundle::new(vec![lang_id]);
        bundle.add_resource(resource).map_err(|errs| {
            TranslatorError::TranslationsStrSerFailed {
                locale: locale.clone(),
                source: errs
                    .iter()
                    .map(|e| e.to_string())
                    .collect::<String>()
                    .into(),
            }
        })?;

        Ok(Self {
            bundle: Rc::new(bundle),
            locale,
        })
    }
    /// Gets the path to the given URL in whatever locale the instance is configured for.
    pub fn url<S: Into<String> + std::fmt::Display>(&self, url: S) -> String {
        format!("/{}{}", self.locale, url)
    }
    /// Gets the locale for which this instancce is configured.
    pub fn get_locale(&self) -> String {
        self.locale.clone()
    }
    /// Translates the given ID. This additionally takes any arguments that should be interpolated. If your i18n system also has variants,
    /// they should be specified somehow in the ID.
    /// # Panics
    /// This will `panic!` if any errors occur while trying to prepare the given ID. Therefore, this method should only be used for
    /// hardcoded IDs that can be confirmed as valid. If you need to parse arbitrary IDs, use `.translate_checked()` instead.
    pub fn translate<I: Into<String> + std::fmt::Display>(
        &self,
        id: I,
        args: Option<FluentArgs>,
    ) -> String {
        let translation_res = self.translate_checked(&id.to_string(), args);
        match translation_res {
            Ok(translation) => translation,
            Err(_) => panic!("translation id '{}' not found for locale '{}' (if you're not hardcoding the id, use `.translate_checked()` instead)", id, self.locale)
        }
    }
    /// Translates the given ID, returning graceful errors. This additionally takes any arguments that should be interpolated. If your
    /// i18n system also has variants, they should be specified somehow in the ID.
    pub fn translate_checked<I: Into<String> + std::fmt::Display>(
        &self,
        id: I,
        args: Option<FluentArgs>,
    ) -> Result<String, TranslatorError> {
        let id_str = id.to_string();
        // Deal with the possibility of a specified variant
        let id_vec: Vec<&str> = id_str.split('.').collect();
        let id_str = id_vec[0].to_string();
        let variant = id_vec.get(1);

        // This is the message in the Fluent system, an unformatted translation (still needs variables etc.)
        // This may also be compound, which means it has multiple variants
        let msg = self.bundle.get_message(&id_str);
        let msg = match msg {
            Some(msg) => msg,
            None => {
                return Err(TranslatorError::TranslationIdNotFound {
                    id: id_str,
                    locale: self.locale.clone(),
                })
            }
        };
        // This module accumulates errors in a provided buffer, we'll handle them later
        let mut errors = Vec::new();
        let value = msg.value();
        let mut translation = None; // If it's compound, the requested variant may not exist
        if let Some(value) = value {
            // Non-compound, just one variant
            translation = Some(
                self.bundle
                    .format_pattern(value, args.as_ref(), &mut errors),
            );
        } else {
            // Compound, many variants, one should be specified
            if let Some(variant) = variant {
                for attr in msg.attributes() {
                    // Once we find the requested variant, we don't need to continue (they should all be unique)
                    if &attr.id() == variant {
                        translation = Some(self.bundle.format_pattern(
                            attr.value(),
                            args.as_ref(),
                            &mut errors,
                        ));
                        break;
                    }
                }
            } else {
                return Err(TranslatorError::TranslationFailed {
                    id: id_str,
                    locale: self.locale.clone(),
                    source: "no variant provided for compound message".into(),
                });
            }
        }
        // Check for any errors
        // TODO apparently these aren't all fatal, but how do we know?
        if !errors.is_empty() {
            return Err(TranslatorError::TranslationFailed {
                id: id_str,
                locale: self.locale.clone(),
                source: errors
                    .iter()
                    .map(|e| e.to_string())
                    .collect::<String>()
                    .into(),
            });
        }
        // Make sure we've actually got a translation
        match translation {
            Some(translation) => Ok(translation.to_string()),
            None => Err(TranslatorError::NoTranslationDerived {
                id: id_str,
                locale: self.locale.clone(),
            }),
        }
    }
    /// Gets the Fluent bundle for more advanced translation requirements.
    pub fn get_bundle(&self) -> Rc<FluentBundle<FluentResource>> {
        Rc::clone(&self.bundle)
    }
}