304 lines
9.1 KiB
Rust
304 lines
9.1 KiB
Rust
//! # Rocket I18N
|
|
//!
|
|
//! A crate to help you internationalize your Rocket applications.
|
|
//!
|
|
//! ## Features
|
|
//!
|
|
//! - Build helpers (with the `build` feature enabled), to update and compile PO files.
|
|
//! - Select the correct locale for each request
|
|
//! - Provides a macro to internationalize any string.
|
|
//!
|
|
//! ## Usage
|
|
//!
|
|
//! First add it to your `Cargo.toml` (you have to use the git version, because we can't publish the latest version on [https://crates.io](crates.io) as it depends on the `master` branch of Rocket):
|
|
//!
|
|
//! ```toml
|
|
//! [dependencies.rocket_i18n]
|
|
//! git = "https://github.com/BaptisteGelez/rocket_i18n"
|
|
//! rev = "<LATEST COMMIT>"
|
|
//! ```
|
|
//!
|
|
//! Then, in your `main.rs`:
|
|
//!
|
|
//! ```rust,ignore
|
|
//! extern crate rocket;
|
|
//! #[macro_use]
|
|
//! extern crate rocket_i18n;
|
|
//!
|
|
//! fn main() {
|
|
//! rocket::ignite()
|
|
//! // Make Rocket manage your translations.
|
|
//! .manage(rocket_i18n::i18n(vec![ "en", "fr", "de", "ja" ]));
|
|
//! // Register routes, etc
|
|
//! }
|
|
//! ```
|
|
//!
|
|
//! Then in all your requests you'll be able to use the `i18n` macro to translate anything.
|
|
//! It takes a `gettext::Catalog` and a string to translate as argument.
|
|
//!
|
|
//! ```rust,ignore
|
|
//! # #[macro_use] extern crate rocket_i18n;
|
|
//!
|
|
//! use rocket_i18n::I18n;
|
|
//!
|
|
//! #[get("/")]
|
|
//! fn route(i18n: I18n) -> &str {
|
|
//! i18n!(i18n.catalog, "Hello, world!")
|
|
//! }
|
|
//! ```
|
|
//!
|
|
//! For strings that may have a plural form, just add the plural and the number of element to the
|
|
//! arguments
|
|
//!
|
|
//! ```rust,ignore
|
|
//! i18n!(i18n.catalog, "One new message", "{0} new messages", 42);
|
|
//! ```
|
|
//!
|
|
//! Any extra argument, after a `;`, will be used for formatting.
|
|
//!
|
|
//! ```rust,ignore
|
|
//! let user_name = "Alex";
|
|
//! i18n!(i18n.catalog, "Hello {0}!"; user_name);
|
|
//! ```
|
|
//!
|
|
//! When using it with plural, `{0}` will be the number of elements, and other arguments will start
|
|
//! at `{1}`.
|
|
//!
|
|
//! Because of its design, rocket_i18n is only compatible with askama. You can use
|
|
//! the `t` macro in your templates, as long as they have a field called `catalog` to
|
|
//! store your catalog.
|
|
//!
|
|
//! ### Editing the POT
|
|
//!
|
|
//! For those strings to be translatable you should also add them to the `po/YOUR_DOMAIN.pot` file. To add a simple message, just do:
|
|
//!
|
|
//! ```po
|
|
//! msgid "Hello, world" # The string you used with your filter
|
|
//! msgstr "" # Always empty
|
|
//! ```
|
|
//!
|
|
//! For plural forms, the syntax is a bit different:
|
|
//!
|
|
//! ```po
|
|
//! msgid "You have one new notification" # The singular form
|
|
//! msgid_plural "You have {{ count }} new notifications" # The plural one
|
|
//! msgstr[0] ""
|
|
//! msgstr[1] ""
|
|
//! ```
|
|
//!
|
|
|
|
extern crate gettext;
|
|
extern crate rocket;
|
|
|
|
pub use gettext::*;
|
|
use rocket::{
|
|
http::Status,
|
|
request::{self, FromRequest},
|
|
Outcome, Request, State,
|
|
};
|
|
use std::fs;
|
|
|
|
const ACCEPT_LANG: &'static str = "Accept-Language";
|
|
|
|
/// A request guard to get the right translation catalog for the current request
|
|
pub struct I18n<'a> {
|
|
pub catalog: Catalog<'a>,
|
|
}
|
|
|
|
type Translations<'a> = Vec<(&'static str, Catalog<'a>)>;
|
|
|
|
pub fn i18n(lang: Vec<&'static str>) -> Translations {
|
|
lang.iter().fold(Vec::new(), |mut trans, l| {
|
|
let mo_file =
|
|
fs::File::open(format!("translations/{}.mo", l)).expect("Couldn't open catalog");
|
|
let cat = Catalog::parse(mo_file).expect("Error while loading catalog");
|
|
trans.push((l, cat));
|
|
trans
|
|
})
|
|
}
|
|
|
|
impl<'a, 'r> FromRequest<'a, 'r> for I18n<'r> {
|
|
type Error = ();
|
|
|
|
fn from_request(req: &'a Request<'r>) -> request::Outcome<I18n<'r>, ()> {
|
|
let langs = &*req
|
|
.guard::<State<Translations>>()
|
|
.expect("Couldn't retrieve translations because they are not managed by Rocket.");
|
|
|
|
let lang = req
|
|
.headers()
|
|
.get_one(ACCEPT_LANG)
|
|
.unwrap_or("en")
|
|
.split(",")
|
|
.filter_map(|lang| lang
|
|
// Get the locale, not the country code
|
|
.split(|c| c == '-' || c == ';')
|
|
.nth(0))
|
|
// Get the first requested locale we support
|
|
.find(|lang| langs.iter().any(|l| l.0 == &lang.to_string()))
|
|
.unwrap_or("en");
|
|
|
|
match langs.iter().find(|l| l.0 == lang) {
|
|
Some(catalog) => Outcome::Success(I18n {
|
|
catalog: catalog.1.clone(),
|
|
}),
|
|
None => Outcome::Failure((Status::InternalServerError, ())),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "build")]
|
|
pub fn update_po(domain: &str) {
|
|
let pot_path = Path::new("po").join(format!("{}.pot", domain));
|
|
|
|
for lang in get_locales() {
|
|
let po_path = Path::new("po").join(format!("{}.po", lang.clone()));
|
|
if po_path.exists() && po_path.is_file() {
|
|
println!("Updating {}", lang.clone());
|
|
// Update it
|
|
Command::new("msgmerge")
|
|
.arg("-U")
|
|
.arg(po_path.to_str().unwrap())
|
|
.arg(pot_path.to_str().unwrap())
|
|
.spawn()
|
|
.expect("Couldn't update PO file");
|
|
} else {
|
|
println!("Creating {}", lang.clone());
|
|
// Create it from the template
|
|
Command::new("msginit")
|
|
.arg(format!("--input={}", pot_path.to_str().unwrap()))
|
|
.arg(format!("--output-file={}", po_path.to_str().unwrap()))
|
|
.arg("-l")
|
|
.arg(lang)
|
|
.arg("--no-translator")
|
|
.spawn()
|
|
.expect("Couldn't init PO file");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Transforms all the .po files in the `po` directory of your project
|
|
#[cfg(feature = "build")]
|
|
fn compile_po() {
|
|
for lang in get_locales() {
|
|
let po_path = Path::new("po").join(format!("{}.po", lang.clone()));
|
|
let mo_dir = Path::new("translations")
|
|
.join(lang.clone())
|
|
.join("LC_MESSAGES");
|
|
fs::create_dir_all(mo_dir.clone()).expect("Couldn't create MO directory");
|
|
let mo_path = mo_dir.join(format!("{}.mo", domain));
|
|
|
|
Command::new("msgfmt")
|
|
.arg(format!("--output-file={}", mo_path.to_str().unwrap()))
|
|
.arg(po_path)
|
|
.spawn()
|
|
.expect("Couldn't compile translations");
|
|
}
|
|
}
|
|
|
|
/// See the crate documentation for information
|
|
/// about how to use this macro.
|
|
#[macro_export]
|
|
macro_rules! i18n {
|
|
($cat:expr, $msg:expr) => {
|
|
$cat.gettext($msg)
|
|
};
|
|
($cat:expr, $msg:expr, $plur:expr, $count:expr) => {
|
|
$crate::try_format(cat.ngettext($msg, $plur, $count), &[ Box::new($count) ])
|
|
.expect("GetText formatting error")
|
|
};
|
|
|
|
($cat:expr, $msg:expr ; $( $args:expr ),*) => {
|
|
$crate::try_format($cat.gettext($msg), &[ $( Box::new($args) ),* ])
|
|
.expect("GetText formatting error")
|
|
};
|
|
($cat:expr, $msg:expr, $plur:expr, $count:expr ; $( $args:expr ),*) => {
|
|
$crate::try_format($cat.ngettext($msg, $plur, $count), &[ Box::new($count), $( Box::new($args) ),* ])
|
|
.expect("GetText formatting error")
|
|
};
|
|
}
|
|
|
|
/// Works the same way as `i18n`, but without needing to give a `Catalog`
|
|
/// as first argument.
|
|
///
|
|
/// For use in askama templates.
|
|
#[macro_export]
|
|
macro_rules! t {
|
|
($( $args:tt )+) => {
|
|
i18n!(self.catalog, $( $args )+)
|
|
};
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
#[doc(hidden)]
|
|
pub enum FormatError {
|
|
UnmatchedCurlyBracket,
|
|
InvalidPositionalArgument,
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
pub fn try_format(str_pattern: &str, argv: &[Box<std::fmt::Display>]) -> Result<String, FormatError> {
|
|
use std::fmt::Write;
|
|
|
|
//first we parse the pattern
|
|
let mut pattern = vec![];
|
|
let mut vars = vec![];
|
|
let mut finish_or_fail = false;
|
|
for (i, part) in str_pattern.split('}').enumerate() {
|
|
if finish_or_fail {
|
|
return Err(FormatError::UnmatchedCurlyBracket);
|
|
}
|
|
if part.contains('{') {
|
|
let mut part = part.split('{');
|
|
let text = part.next().unwrap();
|
|
let arg = part.next().ok_or(FormatError::UnmatchedCurlyBracket)?;
|
|
if part.next() != None {
|
|
return Err(FormatError::UnmatchedCurlyBracket);
|
|
}
|
|
pattern.push(text);
|
|
vars.push(
|
|
argv.get::<usize>(if arg.len() > 0 {
|
|
arg.parse()
|
|
.map_err(|_| FormatError::InvalidPositionalArgument)?
|
|
} else {
|
|
i
|
|
}).ok_or(FormatError::InvalidPositionalArgument)?,
|
|
);
|
|
} else {
|
|
finish_or_fail = true;
|
|
pattern.push(part);
|
|
}
|
|
}
|
|
|
|
//then we generate the result String
|
|
let mut res = String::with_capacity(str_pattern.len());
|
|
let mut pattern = pattern.iter();
|
|
let mut vars = vars.iter();
|
|
while let Some(text) = pattern.next() {
|
|
res.write_str(text).unwrap();
|
|
if let Some(var) = vars.next() {
|
|
res.write_str(&format!("{}", var)).unwrap();
|
|
}
|
|
}
|
|
Ok(res)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
struct FakeCatalog;
|
|
|
|
#[cfg(test)]
|
|
impl FakeCatalog {
|
|
pub fn gettext<'a>(&self, x: &'a str) -> &'a str {
|
|
x
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[test]
|
|
fn test_macros() {
|
|
let catalog = FakeCatalog;
|
|
assert_eq!(
|
|
String::from("Hello, John"),
|
|
i18n!(catalog, "Hello, {0}"; "John")
|
|
);
|
|
}
|