diff --git a/askama/Cargo.toml b/askama/Cargo.toml
index f70902b34..8af117cec 100644
--- a/askama/Cargo.toml
+++ b/askama/Cargo.toml
@@ -33,6 +33,7 @@ with-mendes = ["askama_derive/with-mendes"]
with-rocket = ["askama_derive/with-rocket"]
with-tide = ["askama_derive/with-tide"]
with-warp = ["askama_derive/with-warp"]
+i18n = ["askama_derive/i18n", "fluent-templates"]
# deprecated
mime = []
@@ -48,6 +49,7 @@ percent-encoding = { version = "2.1.0", optional = true }
serde = { version = "1.0", optional = true, features = ["derive"] }
serde_json = { version = "1.0", optional = true }
serde_yaml = { version = "0.9", optional = true }
+fluent-templates = { version = "0.8.0", optional = true }
[package.metadata.docs.rs]
features = ["config", "humansize", "num-traits", "serde-json", "serde-yaml"]
diff --git a/askama/src/i18n.rs b/askama/src/i18n.rs
new file mode 100644
index 000000000..1869b2383
--- /dev/null
+++ b/askama/src/i18n.rs
@@ -0,0 +1,74 @@
+//! Module for compile time checked localization
+//!
+//! # Example:
+//!
+//! [Fluent Translation List](https://projectfluent.org/) resource file `i18n/es-MX/basic.ftl`:
+//!
+//! ```ftl
+//! greeting = ¡Hola, { $name }!
+//! ```
+//!
+//! Askama HTML template `templates/example.html`:
+//!
+//! ```html
+//!
{{ localize("greeting", name: name) }}
+//! ```
+//!
+//! Rust usage:
+//! ```ignore
+//! use askama::i18n::{langid, Locale};
+//! use askama::Template;
+//!
+//! askama::i18n::load!(LOCALES);
+//!
+//! #[derive(Template)]
+//! #[template(path = "example.html")]
+//! struct ExampleTemplate<'a> {
+//! #[locale]
+//! loc: Locale<'a>,
+//! name: &'a str,
+//! }
+//!
+//! let template = ExampleTemplate {
+//! loc: Locale::new(langid!("es-MX"), &LOCALES),
+//! name: "Hilda",
+//! };
+//!
+//! // "¡Hola, Hilda!
"
+//! template.render().unwrap();
+//! ```
+
+use std::collections::HashMap;
+use std::iter::FromIterator;
+
+// Re-export conventiently as `askama::i18n::load!()`.
+// Proc-macro crates can only export macros from their root namespace.
+/// Load locales at compile time. See example above for usage.
+pub use askama_derive::i18n_load as load;
+
+pub use fluent_templates::{self, fluent_bundle::FluentValue, fs::langid, LanguageIdentifier};
+use fluent_templates::{Loader, StaticLoader};
+
+pub struct Locale<'a> {
+ loader: &'a StaticLoader,
+ language: LanguageIdentifier,
+}
+
+impl Locale<'_> {
+ pub fn new(language: LanguageIdentifier, loader: &'static StaticLoader) -> Self {
+ Self { loader, language }
+ }
+
+ pub fn translate<'a>(
+ &self,
+ msg_id: &str,
+ args: impl IntoIterator- )>,
+ ) -> Option {
+ let args = HashMap::<&str, FluentValue<'_>>::from_iter(args);
+ let args = match args.is_empty() {
+ true => None,
+ false => Some(&args),
+ };
+ self.loader.lookup_complete(&self.language, msg_id, args)
+ }
+}
diff --git a/askama/src/lib.rs b/askama/src/lib.rs
index a98989e85..cb80b5d94 100644
--- a/askama/src/lib.rs
+++ b/askama/src/lib.rs
@@ -66,6 +66,8 @@
mod error;
pub mod filters;
pub mod helpers;
+#[cfg(feature = "i18n")]
+pub mod i18n;
use std::fmt;
diff --git a/askama_derive/Cargo.toml b/askama_derive/Cargo.toml
index 5b33e6d7a..4f5c39ab2 100644
--- a/askama_derive/Cargo.toml
+++ b/askama_derive/Cargo.toml
@@ -29,6 +29,7 @@ with-mendes = []
with-rocket = []
with-tide = []
with-warp = []
+i18n = ["fluent-syntax", "fluent-templates", "serde", "basic-toml"]
[dependencies]
mime = "0.3"
@@ -39,3 +40,5 @@ quote = "1"
serde = { version = "1.0", optional = true, features = ["derive"] }
syn = "2"
basic-toml = { version = "0.1.1", optional = true }
+fluent-syntax = { version = "0.11.0", optional = true, default-features = false }
+fluent-templates = { version = "0.8.0", optional = true, default-features = false }
diff --git a/askama_derive/src/generator.rs b/askama_derive/src/generator.rs
index f990330a7..2f4595209 100644
--- a/askama_derive/src/generator.rs
+++ b/askama_derive/src/generator.rs
@@ -1376,9 +1376,40 @@ impl<'a> Generator<'a> {
Expr::RustMacro(ref path, args) => self.visit_rust_macro(buf, path, args),
Expr::Try(ref expr) => self.visit_try(buf, expr.as_ref())?,
Expr::Tuple(ref exprs) => self.visit_tuple(buf, exprs)?,
+ Expr::Localize(ref msg_id, ref args) => self.visit_localize(buf, msg_id, args)?,
})
}
+ fn visit_localize(
+ &mut self,
+ buf: &mut Buffer,
+ msg_id: &Expr<'_>,
+ args: &[(&str, Expr<'_>)],
+ ) -> Result {
+ let localizer =
+ self.input.localizer.as_deref().ok_or(
+ "You need to annotate a field with #[locale] to use the localize() function.",
+ )?;
+
+ buf.write(&format!(
+ "self.{}.translate(",
+ normalize_identifier(localizer)
+ ));
+ self.visit_expr(buf, msg_id)?;
+ buf.writeln(", [")?;
+ buf.indent();
+ for (k, v) in args {
+ buf.write(&format!("({:?}, ::askama::i18n::FluentValue::from(", k));
+ self.visit_expr(buf, v)?;
+ buf.writeln(")),")?;
+ }
+ buf.dedent()?;
+ // Safe to unwrap, as `msg_id` is checked at compile time.
+ buf.write("]).unwrap()");
+
+ Ok(DisplayWrap::Unwrapped)
+ }
+
fn visit_try(
&mut self,
buf: &mut Buffer,
diff --git a/askama_derive/src/i18n.rs b/askama_derive/src/i18n.rs
new file mode 100644
index 000000000..d9d6eaa3f
--- /dev/null
+++ b/askama_derive/src/i18n.rs
@@ -0,0 +1,378 @@
+use std::collections::{HashMap, HashSet};
+use std::fmt::Display;
+use std::fs::{DirEntry, OpenOptions};
+use std::io::Read;
+use std::path::{Path, PathBuf};
+use std::str::FromStr;
+
+use basic_toml::from_str;
+use fluent_syntax::ast::{
+ Expression, InlineExpression, PatternElement, Resource, Variant, VariantKey,
+};
+use fluent_syntax::parser::parse_runtime;
+use fluent_templates::lazy_static::lazy_static;
+use fluent_templates::loader::build_fallbacks;
+use fluent_templates::LanguageIdentifier;
+use proc_macro::TokenStream;
+use proc_macro2::{Ident, TokenStream as TokenStream2};
+use quote::quote_spanned;
+use serde::Deserialize;
+use syn::parse::{Parse, ParseStream};
+use syn::spanned::Spanned;
+use syn::{parse2, Visibility};
+
+use crate::CompileError;
+
+type FileResource = (PathBuf, Resource);
+
+macro_rules! mk_static {
+ ($(let $ident:ident: $ty:ty = $expr:expr;)*) => {
+ $(
+ let $ident = {
+ let value: Option<$ty> = Some($expr);
+ unsafe {
+ static mut VALUE: Option<$ty> = None;
+ VALUE = value;
+ match &VALUE {
+ Some(value) => value,
+ None => unreachable!(),
+ }
+ }
+ };
+ )*
+ };
+}
+
+struct Variable {
+ vis: Visibility,
+ name: Ident,
+}
+
+impl Parse for Variable {
+ fn parse(input: ParseStream<'_>) -> syn::Result {
+ let vis = input.parse().unwrap_or(Visibility::Inherited);
+ let name = input.parse()?;
+ Ok(Variable { vis, name })
+ }
+}
+
+struct Configuration {
+ pub(crate) fallback: LanguageIdentifier,
+ pub(crate) use_isolating: bool,
+ pub(crate) core_locales: Option,
+ pub(crate) locales: Vec<(LanguageIdentifier, Vec)>,
+ pub(crate) fallbacks: &'static HashMap>,
+ pub(crate) assets_dir: PathBuf,
+}
+
+#[derive(Default, Deserialize)]
+struct I18nConfig {
+ #[serde(default)]
+ pub(crate) fallback_language: Option,
+ #[serde(default)]
+ pub(crate) fluent: Option,
+}
+
+#[derive(Default, Deserialize)]
+struct I18nFluent {
+ #[serde(default)]
+ pub(crate) assets_dir: Option,
+ #[serde(default)]
+ pub(crate) core_locales: Option,
+ #[serde(default)]
+ pub(crate) use_isolating: Option,
+}
+
+fn format_err(path: &Path, err: impl Display) -> String {
+ format!("error processing {:?}: {}", path, err)
+}
+
+fn read_resource(path: PathBuf) -> Result {
+ let mut buf = String::new();
+ OpenOptions::new()
+ .read(true)
+ .open(&path)
+ .map_err(|err| format_err(&path, err))?
+ .read_to_string(&mut buf)
+ .map_err(|err| format_err(&path, err))?;
+
+ let resource = match parse_runtime(buf) {
+ Ok(resource) => resource,
+ Err((_, err_vec)) => return Err(format_err(&path, err_vec.first().unwrap())),
+ };
+ Ok((path, resource))
+}
+
+fn read_lang_dir(
+ entry: Result,
+) -> Result