gettext_macros/
lib.rs

1//! A set of macros to make i18n easier.
2
3extern crate proc_macro;
4use proc_macro::TokenStream;
5use proc_macro2::{
6    token_stream::IntoIter as TokenIter, Literal, TokenTree,
7};
8use quote::quote;
9use std::{
10    env,
11    fs::{create_dir_all, read, File, OpenOptions},
12    io::{BufRead, Read, Seek, SeekFrom, Write},
13    path::Path,
14    process::{Command, Stdio},
15};
16use syn::Token;
17
18fn is(t: &TokenTree, ch: char) -> bool {
19    match t {
20        TokenTree::Punct(p) => p.as_char() == ch,
21        _ => false,
22    }
23}
24
25fn named_arg(mut input: TokenIter, name: &'static str) -> Option<proc_macro2::TokenStream> {
26    input.next().and_then(|t| match t {
27        TokenTree::Ident(ref i) if i.to_string() == name => {
28            input.next(); // skip "="
29            Some(
30                input
31                    .take_while(|tok| match tok {
32                        TokenTree::Punct(_) => false,
33                        _ => true,
34                    })
35                    .collect(),
36            )
37        }
38        _ => None,
39    })
40}
41
42fn root_crate_path() -> std::path::PathBuf {
43    let path = env::var("CARGO_MANIFEST_DIR")
44        .expect("CARGO_MANIFEST_DIR is not set. Please use cargo to compile your crate.");
45    let path = Path::new(&path);
46    if path
47        .parent()
48        .expect("No parent dir")
49        .join("Cargo.toml")
50        .exists()
51    {
52        path.parent().expect("No parent dir").to_path_buf()
53    } else {
54        path.to_path_buf()
55    }
56}
57
58struct Config {
59    domain: String,
60    make_po: bool,
61    make_mo: bool,
62    langs: Vec<String>,
63}
64
65impl Config {
66    fn path() -> std::path::PathBuf {
67        Path::new(&env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| {
68            root_crate_path()
69                .join("target")
70                .join("debug")
71                .to_str()
72                .expect("Couldn't compute mo output dir")
73                .into()
74        }))
75        .join("gettext_macros")
76        .join(env::var("CARGO_PKG_NAME").expect("Please build with cargo"))
77    }
78
79    fn read() -> Config {
80        let config = read(Config::path())
81            .expect("Coudln't read domain, make sure to call init_i18n! before");
82        let mut lines = config.lines();
83        let domain = lines
84            .next()
85            .expect("Invalid config file. Make sure to call init_i18n! before this macro")
86            .expect("IO error while reading config");
87        let make_po: bool = lines
88            .next()
89            .expect("Invalid config file. Make sure to call init_i18n! before this macro")
90            .expect("IO error while reading config")
91            .parse()
92            .expect("Couldn't parse make_po");
93        let make_mo: bool = lines
94            .next()
95            .expect("Invalid config file. Make sure to call init_i18n! before this macro")
96            .expect("IO error while reading config")
97            .parse()
98            .expect("Couldn't parse make_mo");
99        Config {
100            domain,
101            make_po,
102            make_mo,
103            langs: lines
104                .map(|l| l.expect("IO error while reading config"))
105                .collect(),
106        }
107    }
108
109    fn write(&self) {
110        // emit file to include
111        create_dir_all(Config::path().parent().unwrap()).expect("Couldn't create output dir");
112        let mut out = File::create(Config::path()).expect("Metadata file couldn't be open");
113        writeln!(out, "{}", self.domain).expect("Couldn't write domain");
114        writeln!(out, "{}", self.make_po).expect("Couldn't write po settings");
115        writeln!(out, "{}", self.make_mo).expect("Couldn't write mo settings");
116        for l in self.langs.clone() {
117            writeln!(out, "{}", l).expect("Couldn't write lang");
118        }
119    }
120}
121
122trait Message {
123    fn writable(&self) -> bool;
124    fn content(&self) -> String;
125    fn context(&self) -> Option<String>;
126    fn plural(&self) -> Option<String>;
127
128    fn write(&self) {
129        if !self.writable() {
130            return;
131        }
132
133        let config = Config::read();
134
135        let mut pot = OpenOptions::new()
136            .read(true)
137            .write(true)
138            .create(true)
139            .open(format!("po/{0}/{0}.pot", config.domain))
140            .expect("Couldn't open .pot file");
141
142        let mut contents = String::new();
143        pot.read_to_string(&mut contents)
144            .expect("IO error while reading .pot file");
145        pot.seek(SeekFrom::End(0))
146            .expect("IO error while seeking .pot file to end");
147
148        let already_exists = self.content().is_empty()
149            || contents.contains(&format!(
150                r#"{}msgid "{}""#,
151                self.context()
152                    .clone()
153                    .map(|c| format!(
154r#"msgctxt "{}"
155"#,
156                    c))
157                    .unwrap_or_default(),
158                self.content()
159            ));
160        if already_exists {
161            return;
162        }
163
164        let prefix = if let Some(c) = self.context() {
165            format!(
166r#"msgctxt "{}"
167"#, c)
168        } else {
169            String::new()
170        };
171
172        if let Some(ref pl) = self.plural() {
173            pot.write_all(
174                &format!(
175                    r#"
176{}msgid "{}"
177msgid_plural "{}"
178msgstr[0] ""
179"#,
180                    prefix, self.content(), pl,
181                )
182                .into_bytes(),
183            )
184            .expect("Couldn't write message to .pot (plural)");
185        } else {
186            pot.write_all(
187                &format!(
188                    r#"
189{}msgid "{}"
190msgstr ""
191"#,
192                    prefix, self.content(),
193                )
194                .into_bytes(),
195            )
196            .expect("Couldn't write message to .pot");
197        }
198    }
199}
200
201struct I18nCall {
202    catalog: syn::Expr,
203    context: Option<syn::LitStr>,
204    msg: syn::Expr,
205    plural: Option<syn::Expr>,
206    format_args: Option<syn::punctuated::Punctuated<syn::Expr, syn::Token![,]>>,
207}
208
209mod kw {
210    syn::custom_keyword!(context);
211}
212
213impl syn::parse::Parse for I18nCall {
214    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
215        let catalog = input.parse()?;
216        input.parse::<Token![,]>()?;
217        let context = if input.parse::<kw::context>().is_ok() {
218            input.parse::<Token![=]>()?;
219            let ctx = input.parse().ok();
220            input.parse::<Token![,]>()?;
221            ctx
222        } else {
223            None
224        };
225        let msg = input.parse()?;
226        let plural = if input.parse::<Token![,]>().is_ok() {
227            input.parse().ok()
228        } else {
229            None
230        };
231        let format_args = if input.parse::<Token![;]>().is_ok() {
232            syn::punctuated::Punctuated::parse_terminated(input).ok()
233        } else {
234            None
235        };
236
237        Ok(I18nCall {
238            catalog,
239            context,
240            msg,
241            plural,
242            format_args,
243        })
244    }
245}
246
247fn extract_str_lit(expr: &syn::Expr) -> Option<String> {
248    match *expr {
249        syn::Expr::Lit(syn::ExprLit { lit : syn::Lit::Str(ref s), attrs: _ }) => Some(s.value()),
250        _ => None,
251    }
252}
253
254impl Message for I18nCall {
255    fn writable(&self) -> bool {
256        extract_str_lit(&self.msg).is_some()
257    }
258
259    fn content(&self) -> String {
260        extract_str_lit(&self.msg).unwrap_or_default().replace("\"", "\\\"").replace('\n', "\\n")
261    }
262
263    fn context(&self) -> Option<String> {
264        self.context.as_ref().map(|c| c.value())
265    }
266
267    fn plural(&self) -> Option<String> {
268        self.plural.as_ref().and_then(extract_str_lit)
269    }
270}
271
272struct TCall {
273    context: Option<syn::LitStr>,
274    msg: syn::LitStr,
275    plural: Option<syn::LitStr>,
276}
277
278impl syn::parse::Parse for TCall {
279    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
280        let context = if input.parse::<kw::context>().is_ok() {
281            input.parse::<Token![=]>()?;
282            let ctx = input.parse().ok();
283            input.parse::<Token![,]>()?;
284            ctx
285        } else {
286            None
287        };
288
289        let msg = input.parse()?;
290        let plural = if input.parse::<Token![,]>().is_ok() {
291            input.parse().ok()
292        } else {
293            None
294        };
295
296        Ok(TCall {
297            context,
298            msg,
299            plural,
300        })
301    }
302}
303
304impl Message for TCall {
305    fn writable(&self) -> bool {
306        true
307    }
308
309    fn content(&self) -> String {
310        self.msg.value().replace("\"", "\\\"").replace('\n', "\\n")
311    }
312
313    fn context(&self) -> Option<String> {
314        self.context.as_ref().map(|c| c.value())
315    }
316
317    fn plural(&self) -> Option<String> {
318        self.plural.as_ref().map(|p| p.value())
319    }
320}
321
322/// Marks a string as translatable
323///
324/// It only adds the given string to the `.pot` file, without translating it at runtime.
325///
326/// To translate it for real, you will have to use `i18n`. The advantage of this macro, is
327/// that you mark a string as translatable without requiring a catalog to be available in scope.
328///
329/// # Return value
330///
331/// In case of a singular message, the message itself is returned.
332///
333/// For messages with a plural form, it is a tuple containing the singular form, and the plural one.
334///
335/// # Example
336///
337/// ```rust,ignore
338/// #use gettext_macros::*;
339/// // Let's say we can't have access to a Catalog at this point of the program
340/// let msg = t!("Hello, world!");
341/// let plural = t!("Singular", "Plural")
342///
343/// // Now, let's get a catalog, and translate these messages
344/// let cat = get_catalog();
345/// i18n!(cat, msg);
346/// i18n!(cat, plural.0, plural.1; 57);
347/// ```
348///
349/// # Syntax
350///
351/// This macro accepts the following syntaxes:
352///
353/// ```rust,ignore
354/// t!($singular)
355/// t!($singular, $plural)
356/// t!(context = $ctx, $singular)
357/// t!(context = $ctx, $singular, $plural)
358/// ```
359///
360/// Where `$singular`, `$plural` and `$ctx` all are `str` literals (and not variables, expressions or literal of any other type).
361#[proc_macro]
362pub fn t(input: TokenStream) -> TokenStream {
363    let message = syn::parse_macro_input!(input as TCall);
364    message.write();
365    let msg = message.content();
366    if let Some(pl) = message.plural.clone() {
367        quote!(
368            (#msg, #pl)
369        ).into()
370    } else {
371        quote!(#msg).into()
372    }
373}
374
375/// Marks a string as translatable and translate it at runtime.
376///
377/// It add the string to the `.pot` file and translate them at runtime, using a given `gettext::Catalog`.
378///
379/// # Return value
380///
381/// This macro returns the translated string.
382///
383/// # Panics
384///
385/// This macro will panic if it the format string (of the translation) does not match the
386/// format arguments that were given. For instance, if you have a string `Hello!`, that
387/// is translated in Esperanto as `Saluton {name}!`, and that you call this function without
388/// any format argument (as expected in the original English string), it will panic.
389///
390/// # Examples
391///
392/// Basic usage:
393///
394/// ```rust,ignore
395/// // cat is the gettext::Catalog containing translations for the current locale.
396/// let cat = get_catalog();
397/// i18n!(cat, "Hello, world!");
398/// ```
399///
400/// Formatting a translated string:
401///
402/// ```rust,ignore
403/// let name = "Peter";
404/// i18n!(cat, "Hi {0}!"; name);
405///
406/// // Also works with multiple format arguments
407/// i18n!(cat, "You are our {}th visitor! You won ${}!"; 99_999, 2);
408/// ```
409///
410/// With a context, that will be shown to translators:
411///
412/// ```rust,ignore
413/// let name = "Sophia";
414/// i18n!(cat, context = "The variable is the name of the person being greeted", "Hello, {0}!"; name);
415/// ```
416///
417/// Translating string that changes depending on a number:
418///
419/// ```rust,ignore
420/// let flowers_count = 18;
421/// i18n!(cat, "What a nice flower!", "What a nice garden!"; flowers_count);
422/// ```
423///
424/// With all available options:
425///
426/// ```rust,ignore
427/// let updates = 69;
428/// i18n!(
429///     cat,
430///     context = "The notification when updates are available.",
431///     "There is {} app update available."
432///     "There are {} app updates available.";
433///     updates
434/// );
435/// ```
436///
437/// # Syntax
438///
439/// This macro expects:
440///
441/// - first, the expression to get the translation catalog to use
442/// - then, optionally, the `context` named argument, that is a string that will be shown
443///   to translators. It should be a `str` literal, because it needs to be known at compile time.
444/// - the message to translate. It can either be a string literal, or an expression, but if you use the later
445///   make sure that the string is correctly added to the `.pot` file with `t`.
446/// - if this message has a plural version, it should come after. Here too, both string literals or other expressions
447///   are allowed
448///
449/// All these arguments should be separated by commas.
450///
451/// If you want to pass format arguments to this macro, to have them inserted into the translated strings,
452/// you should add them at the end, after a colon, and seperate them with commas too.
453#[proc_macro]
454pub fn i18n(input: TokenStream) -> TokenStream {
455    let message = syn::parse_macro_input!(input as I18nCall);
456    message.write();
457
458    let gettext_call = message.catalog.clone();
459    let content = message.msg;
460    let gettext_call = if let Some(pl) = message.plural {
461        let count = message
462            .format_args
463            .clone()
464            .and_then(|args| args.first().cloned());
465        if let Some(c) = message.context {
466            quote!(
467                #gettext_call.npgettext(#c, #content, #pl, #count as u64)
468            )
469        } else {
470            quote!(
471                #gettext_call.ngettext(#content, #pl, #count as u64)
472            )
473        }
474    } else {
475        if let Some(c) = message.context {
476            quote!(
477                #gettext_call.pgettext(#c, #content)
478            )
479        } else {
480            quote!(
481                #gettext_call.gettext(#content)
482            )
483        }
484    };
485
486    let fargs: syn::punctuated::Punctuated<proc_macro2::TokenStream, Token![,]> = message.format_args.unwrap_or_default().into_iter().map(|x| {
487        quote!(::std::boxed::Box::new(#x))
488    }).collect();
489    let res = quote!({
490        use gettext_utils::try_format;
491        try_format(#gettext_call, &[#fargs]).expect("Error while formatting message")
492    });
493    res.into()
494}
495
496/// This macro configures internationalization for the current crate
497///
498/// This macro expands to nothing, it just write your configuration to files
499/// for other macros calls, and creates the `.pot` file if needed.
500///
501/// This macro should be called before (not in the program flow, but in the Rust parser flow) all other
502/// internationalization macros.
503///
504/// # Examples
505///
506/// Basic usage:
507///
508/// ```rust,ignore
509/// init_i18n!("my_app", de, en, eo, fr, ja, pl, ru);
510/// ```
511/// With `.po` and `.mo` generation turned off, and without comments about string location in the `.pot`:
512///
513/// ```rust,ignore
514/// init_i18n!("my_app", po = false, mo = false, de, en, eo, fr, ja, pl, ru);
515/// ```
516///
517/// # Syntax
518///
519/// This macro expects:
520///
521/// - a string literal, that is the translation domain of your crate.
522/// - optionally, the `po` named argument, that is a boolean literal to turn off `.po` generation from `.pot` in `compile_i18n`
523/// - optionally, the `mo` named argument, that is a boolean literal too, to turn of `.po` compilation into `.mo` files in `compile_i18n`.
524///   Note that if you turn this feature off, `include_i18n` won't work unless you manually generate the `.mo` files in
525///   `target/TARGET/gettext_macros/LOCALE/DOMAIN.mo`.
526/// - optionally, the `location` named argument, a boolean too, to avoid writing the location of the string in the source code to translation files.
527///   Having this location available can be usefull if your translators know a bit of Rust and needs context about what they are translating, but it
528///   also makes bigger diffs, because your `.pot` and `.po` files may be regenerated if a line number changes.
529/// - then, the list of languages you want your app to be translated in, separated by commas. The languages are not string literals, but identifiers.
530///
531/// All the three boolean options are turned on by default. Also note that you may ommit one (or more) of them, but they should always be in this order.
532#[proc_macro]
533pub fn init_i18n(input: TokenStream) -> TokenStream {
534    let input = proc_macro2::TokenStream::from(input);
535    let mut input = input.into_iter();
536    let domain = match input.next() {
537        Some(TokenTree::Literal(lit)) => lit.to_string().replace("\"", ""),
538        Some(_) => panic!("Domain should be a str"),
539        None => panic!("Expected a translation domain (for instance \"myapp\")"),
540    };
541
542    let (po, mo) = if let Some(n) = input.next() {
543        if is(&n, ',') {
544            let po = named_arg(input.clone(), "po");
545            if let Some(po) = po.clone() {
546                for _ in 0..(po.into_iter().count() + 3) {
547                    input.next();
548                }
549            }
550
551            let mo = named_arg(input.clone(), "mo");
552            if let Some(mo) = mo.clone() {
553                for _ in 0..(mo.into_iter().count() + 3) {
554                    input.next();
555                }
556            }
557
558            (po, mo)
559        } else {
560            (None, None)
561        }
562    } else {
563        (None, None)
564    };
565
566    let mut langs = vec![];
567    match input.next() {
568        Some(TokenTree::Ident(i)) => {
569            langs.push(i.to_string());
570            loop {
571                let next = input.next();
572                if next.is_none() || !is(&next.expect("Unreachable: next should be Some"), ',') {
573                    break;
574                }
575                match input.next() {
576                    Some(TokenTree::Ident(i)) => {
577                        langs.push(i.to_string());
578                    }
579                    _ => panic!("Expected a language identifier"),
580                }
581            }
582        }
583        None => {}
584        _ => panic!("Expected a language identifier"),
585    }
586
587    let conf = Config {
588        domain: domain.clone(),
589        make_po: po.map(|x| x.to_string() == "true").unwrap_or(true),
590        make_mo: mo.map(|x| x.to_string() == "true").unwrap_or(true),
591        langs,
592    };
593    conf.write();
594
595    // write base .pot
596    create_dir_all(format!("po/{}", domain)).expect("Couldn't create po dir");
597    let mut pot = OpenOptions::new()
598        .write(true)
599        .create(true)
600        .truncate(true)
601        .open(format!("po/{0}/{0}.pot", domain))
602        .expect("Couldn't open .pot file");
603    pot.write_all(
604        &format!(
605            r#"msgid ""
606msgstr ""
607"Project-Id-Version: {}\n"
608"Report-Msgid-Bugs-To: \n"
609"POT-Creation-Date: 2018-06-15 16:33-0700\n"
610"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
611"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
612"Language-Team: LANGUAGE <LL@li.org>\n"
613"Language: \n"
614"MIME-Version: 1.0\n"
615"Content-Type: text/plain; charset=UTF-8\n"
616"Content-Transfer-Encoding: 8bit\n"
617"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
618"#,
619            domain
620        )
621        .into_bytes(),
622    )
623    .expect("Couldn't init .pot file");
624
625    quote!().into()
626}
627
628/// Gives you the translation domain for the current crate.
629///
630/// # Return value
631///
632/// A `'static str` containing the GetText domain of this crate.
633///
634/// # Example
635///
636/// ```rust,ignore
637/// println!("The GetText domain is: {}", i18n_domain!());
638/// ```
639#[proc_macro]
640pub fn i18n_domain(_: TokenStream) -> TokenStream {
641    let domain = Config::read().domain;
642    let tok = TokenTree::Literal(Literal::string(&domain));
643    quote!(#tok).into()
644}
645
646/// Compiles your internationalization files.
647///
648/// This macro expands to nothing, it just writes `.po` and `.mo` files.
649///
650/// You can configure its behavior with the `po` and `mo` options of `init_i18n`.
651///
652/// This macro should be called after (not in the program flow, but in the Rust parser flow) all other internationlaziton macros,
653/// expected `include_i18n`.
654///
655/// # Example
656///
657/// ```rust,ignore
658/// compile_i18n!();
659/// ```
660#[proc_macro]
661pub fn compile_i18n(_: TokenStream) -> TokenStream {
662    let conf = Config::read();
663    let domain = &conf.domain;
664
665    let pot_path = root_crate_path()
666        .join("po")
667        .join(domain.clone())
668        .join(format!("{}.pot", domain));
669
670    for lang in conf.langs {
671        let po_path = root_crate_path()
672            .join("po")
673            .join(domain.clone())
674            .join(format!("{}.po", lang.clone()));
675        if conf.make_po {
676            if po_path.exists() && po_path.is_file() {
677                // Update it
678                Command::new("msgmerge")
679                    .arg("-U")
680                    .arg(po_path.to_str().expect("msgmerge: PO path error"))
681                    .arg(pot_path.to_str().expect("msgmerge: POT path error"))
682                    .stdout(Stdio::null())
683                    .status()
684                    .map(|s| {
685                        if !s.success() {
686                            panic!("Couldn't update PO file")
687                        }
688                    })
689                    .expect("Couldn't update PO file. Make sure msgmerge is installed.");
690            } else {
691                println!("Creating {}", lang.clone());
692                // Create it from the template
693                Command::new("msginit")
694                    .arg(format!(
695                        "--input={}",
696                        pot_path.to_str().expect("msginit: POT path error")
697                    ))
698                    .arg(format!(
699                        "--output-file={}",
700                        po_path.to_str().expect("msginit: PO path error")
701                    ))
702                    .arg("-l")
703                    .arg(lang.clone())
704                    .arg("--no-translator")
705                    .stdout(Stdio::null())
706                    .status()
707                    .map(|s| {
708                        if !s.success() {
709                            panic!("Couldn't init PO file (gettext returned an error)")
710                        }
711                    })
712                    .expect("Couldn't init PO file. Make sure msginit is installed.");
713            }
714        }
715
716        if conf.make_mo {
717            if !po_path.exists() {
718                panic!(
719                    "{} doesn't exist. Make sure you didn't disabled po generation.",
720                    po_path.display()
721                );
722            }
723
724            // Generate .mo
725            let mo_dir = Path::new(&env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| {
726                root_crate_path()
727                    .join("target")
728                    .join("debug")
729                    .to_str()
730                    .expect("Couldn't compute mo output dir")
731                    .into()
732            }))
733            .join("gettext_macros")
734            .join(lang);
735            create_dir_all(mo_dir.clone()).expect("Couldn't create MO directory");
736            let mo_path = mo_dir.join(format!("{}.mo", domain));
737
738            Command::new("msgfmt")
739                .arg(format!(
740                    "--output-file={}",
741                    mo_path.to_str().expect("msgfmt: MO path error")
742                ))
743                .arg(po_path)
744                .stdout(Stdio::null())
745                .status()
746                .map(|s| {
747                    if !s.success() {
748                        panic!("Couldn't compile translations (gettext returned an error)")
749                    }
750                })
751                .expect("Couldn't compile translations. Make sure msgfmt is installed");
752        }
753    }
754    quote!().into()
755}
756
757/// Use this macro to staticaly import translations into your final binary.
758///
759/// This macro won't work if ou set `mo = false` in `init_i18n`, unless you manually generate the `.mo` files in
760/// `target/TARGET/gettext_macros/LOCALE/DOMAIN.mo`.
761///
762/// # Example
763///
764/// ```rust,ignore
765/// let catalogs = include_i18n!();
766/// catalog.into_iter()
767///     .find(|(lang, _)| lang == "eo")
768///     .map(|(_, catalog| println!("{}", i18n!(catalog, "Hello world!")));
769/// ```
770#[proc_macro]
771pub fn include_i18n(_: TokenStream) -> TokenStream {
772    let conf = Config::read();
773    let locales = conf.langs.clone().into_iter().map(|l| {
774        let lang = TokenTree::Literal(Literal::string(&l));
775        let path = Config::path().parent().unwrap().join(l).join(format!("{}.mo", conf.domain));
776
777        if !path.exists() {
778            panic!("{} doesn't exist. Make sure to call compile_i18n! before include_i18n!, and check that you didn't disabled mo compilation.", path.display());
779        }
780
781        let path = TokenTree::Literal(Literal::string(path.to_str().expect("Couldn't write MO file path")));
782        quote!{
783            (#lang, ::gettext::Catalog::parse(
784                &include_bytes!(
785                    #path
786                )[..]
787            ).expect("Error while loading catalog")),
788        }
789	}).collect::<proc_macro2::TokenStream>();
790
791    quote!({
792        vec![
793            #locales
794        ]
795    }).into()
796}