A crate to help you internationalize your Rocket applications.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

262 lines
8.3 KiB

  1. //! # Rocket I18N
  2. //!
  3. //! A crate to help you internationalize your Rocket applications.
  4. //!
  5. //! ## Features
  6. //!
  7. //! - Create `.po` files for locales listed in `po/LINGUAS`, from a POT file
  8. //! - Update `.po` files from the POT file if needed
  9. //! - Compile `.po` files into `.mo` ones
  10. //! - Select the correct locale for each request
  11. //! - Integrates with Tera templates
  12. //!
  13. //! ## Usage
  14. //!
  15. //! First add it to your `Cargo.toml`:
  16. //!
  17. //! ```toml
  18. //! [dependencies]
  19. //! rocket_i18n = "0.1"
  20. //! ```
  21. //!
  22. //! Then, in your `main.rs`:
  23. //!
  24. //! ```rust
  25. //! extern crate rocket_i18n;
  26. //!
  27. //! // ...
  28. //!
  29. //! fn main() {
  30. //! rocket::ignite()
  31. //! // Register the fairing. The parameter is the domain you want to use (the name of your app most of the time)
  32. //! .attach(rocket_i18n::I18n::new("my_app"))
  33. //! // Eventually register the Tera filters (only works with the master branch of Rocket)
  34. //! .attach(rocket_contrib::Template::custom(|engines| {
  35. //! rocket_i18n::tera(&mut engines.tera);
  36. //! }))
  37. //! // Register routes, etc
  38. //! }
  39. //! ```
  40. //!
  41. //! ## For the developers
  42. //!
  43. //! ### Using Tera filters
  44. //!
  45. //! If you called `rocket_i18n::tera`, you'll be able to use two Tera filters to translate your interface.
  46. //!
  47. //! The first one, `_`, corresponds to the `gettext` function of gettext. It takes a string as input and translate it. Any argument given to the filter can
  48. //! be used in the translated string using the Tera syntax.
  49. //!
  50. //! ```jinja
  51. //! <p>{{ "Hello, world" | _ }}</p>
  52. //! <p>{{ "Your name is {{ name }}" | _(name=user.name) }}
  53. //! ```
  54. //!
  55. //! The second one, `_n`, is equivalent to `ngettext`. It takes the plural form as input, and two required arguments in addition to those you may want to use for interpolation:
  56. //!
  57. //! - `singular`, the singular form of this string
  58. //! - `count`, the number of items, to determine how the string should be pluralized
  59. //!
  60. //! ```jinja
  61. //! <p>{{ "{{ count }} new messages" | _n(singular="One new message", count=messages.unread_count) }}</p>
  62. //! ```
  63. //!
  64. //! ### In Rust code
  65. //!
  66. //! You can also use all the gettext functions in your Rust code.
  67. //!
  68. //! ```rust
  69. //! use rocket_i18n;
  70. //!
  71. //! #[get("/")]
  72. //! fn index() -> String {
  73. //! gettext("Hello, world!")
  74. //! }
  75. //!
  76. //! #[get("/<name>")]
  77. //! fn hello(name: String) -> String {
  78. //! format!(gettext("Hello, {}!"), name)
  79. //! }
  80. //! ```
  81. //!
  82. //! ### Editing the POT
  83. //!
  84. //! 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:
  85. //!
  86. //! ```po
  87. //! msgid "Hello, world" # The string you used with your filter
  88. //! msgstr "" # Always empty
  89. //! ```
  90. //!
  91. //! For plural forms, the syntax is a bit different:
  92. //!
  93. //! ```po
  94. //! msgid "You have one new notification" # The singular form
  95. //! msgid_plural "You have {{ count }} new notifications" # The plural one
  96. //! msgstr[0] ""
  97. //! msgstr[1] ""
  98. //! ```
  99. //!
  100. extern crate gettextrs;
  101. extern crate rocket;
  102. extern crate serde_json;
  103. extern crate tera;
  104. use gettextrs::*;
  105. use rocket::{Data, Request, Rocket, fairing::{Fairing, Info, Kind}};
  106. use std::{
  107. collections::HashMap,
  108. env,
  109. fs,
  110. io::{BufRead, BufReader},
  111. path::{Path, PathBuf},
  112. process::Command
  113. };
  114. use tera::{Tera, Error as TeraError};
  115. const ACCEPT_LANG: &'static str = "Accept-Language";
  116. /// This is the main struct of this crate. You can register it on your Rocket instance as a
  117. /// fairing.
  118. ///
  119. /// ```rust
  120. /// rocket::ignite()
  121. /// .attach(I18n::new("app"))
  122. /// ```
  123. ///
  124. /// The parameter you give to [`I18n::new`] is the gettext domain to use. It doesn't really matter what you choose,
  125. /// but it is usually the name of your app.
  126. ///
  127. /// Once this fairing is registered, it will update your .po files from the POT, compile them into .mo files, and select
  128. /// the requested locale for each request using the `Accept-Language` HTTP header.
  129. pub struct I18n {
  130. domain: &'static str
  131. }
  132. impl I18n {
  133. /// Creates a new I18n fairing for the given domain
  134. pub fn new(domain: &'static str) -> I18n {
  135. I18n {
  136. domain: domain
  137. }
  138. }
  139. }
  140. impl Fairing for I18n {
  141. fn info(&self) -> Info {
  142. Info {
  143. name: "Gettext I18n",
  144. kind: Kind::Attach | Kind::Request
  145. }
  146. }
  147. fn on_attach(&self, rocket: Rocket) -> Result<Rocket, Rocket> {
  148. update_po(self.domain);
  149. compile_po(self.domain);
  150. bindtextdomain(self.domain, fs::canonicalize(&PathBuf::from("./translations/")).unwrap().to_str().unwrap());
  151. textdomain(self.domain);
  152. Ok(rocket)
  153. }
  154. fn on_request(&self, request: &mut Request, _: &Data) {
  155. let lang = request
  156. .headers()
  157. .get_one(ACCEPT_LANG)
  158. .unwrap_or("en")
  159. .split(",")
  160. .nth(0)
  161. .unwrap_or("en");
  162. // We can't use setlocale(LocaleCategory::LcAll, lang), because it only accepts system-wide installed
  163. // locales (and most of the time there are only a few of them).
  164. // But, when we set the LANGUAGE environment variable, and an empty string as a second parameter to
  165. // setlocale, gettext will be smart enough to find a matching locale in the locally installed ones.
  166. env::set_var("LANGUAGE", lang);
  167. setlocale(LocaleCategory::LcAll, "");
  168. }
  169. }
  170. fn update_po(domain: &str) {
  171. let pot_path = Path::new("po").join(format!("{}.pot", domain));
  172. for lang in get_locales() {
  173. let po_path = Path::new("po").join(format!("{}.po", lang.clone()));
  174. if po_path.exists() && po_path.is_file() {
  175. println!("Updating {}", lang.clone());
  176. // Update it
  177. Command::new("msgmerge")
  178. .arg("-U")
  179. .arg(po_path.to_str().unwrap())
  180. .arg(pot_path.to_str().unwrap())
  181. .spawn()
  182. .expect("Couldn't update PO file");
  183. } else {
  184. println!("Creating {}", lang.clone());
  185. // Create it from the template
  186. Command::new("msginit")
  187. .arg(format!("--input={}", pot_path.to_str().unwrap()))
  188. .arg(format!("--output-file={}", po_path.to_str().unwrap()))
  189. .arg("-l")
  190. .arg(lang)
  191. .arg("--no-translator")
  192. .spawn()
  193. .expect("Couldn't init PO file");
  194. }
  195. }
  196. }
  197. fn compile_po(domain: &str) {
  198. for lang in get_locales() {
  199. let po_path = Path::new("po").join(format!("{}.po", lang.clone()));
  200. let mo_dir = Path::new("translations")
  201. .join(lang.clone())
  202. .join("LC_MESSAGES");
  203. fs::create_dir_all(mo_dir.clone()).expect("Couldn't create MO directory");
  204. let mo_path = mo_dir.join(format!("{}.mo", domain));
  205. Command::new("msgfmt")
  206. .arg(format!("--output-file={}", mo_path.to_str().unwrap()))
  207. .arg(po_path)
  208. .spawn()
  209. .expect("Couldn't compile translations");
  210. }
  211. }
  212. fn get_locales() -> Vec<String> {
  213. let linguas_file = fs::File::open(Path::new("po").join("LINGUAS")).expect("Couldn't find po/LINGUAS file");
  214. let linguas = BufReader::new(&linguas_file);
  215. linguas.lines().map(Result::unwrap).collect()
  216. }
  217. fn tera_gettext(msg: serde_json::Value, ctx: HashMap<String, serde_json::Value>) -> Result<serde_json::Value, TeraError> {
  218. let trans = gettext(msg.as_str().unwrap());
  219. Ok(serde_json::Value::String(Tera::one_off(trans.as_ref(), &ctx, false).unwrap_or(String::from(""))))
  220. }
  221. fn tera_ngettext(msg: serde_json::Value, ctx: HashMap<String, serde_json::Value>) -> Result<serde_json::Value, TeraError> {
  222. let trans = ngettext(
  223. ctx.get("singular").unwrap().as_str().unwrap(),
  224. msg.as_str().unwrap(),
  225. ctx.get("count").unwrap().as_u64().unwrap() as u32
  226. );
  227. Ok(serde_json::Value::String(Tera::one_off(trans.as_ref(), &ctx, false).unwrap_or(String::from(""))))
  228. }
  229. /// Register translation filters on your Tera instance
  230. ///
  231. /// ```rust
  232. /// rocket::ignite()
  233. /// .attach(rocket_contrib::Template::custom(|engines| {
  234. /// rocket_i18n::tera(&mut engines.tera);
  235. /// }))
  236. /// ```
  237. ///
  238. /// The two registered filters are `_` and `_n`. For example use, see the crate documentation,
  239. /// or the project's README.
  240. pub fn tera(t: &mut Tera) {
  241. t.register_filter("_", tera_gettext);
  242. t.register_filter("_n", tera_ngettext);
  243. }