diff --git a/askama/Cargo.toml b/askama/Cargo.toml
index a3d841eda..4f3a0b6ef 100644
--- a/askama/Cargo.toml
+++ b/askama/Cargo.toml
@@ -19,6 +19,7 @@ maintenance = { status = "actively-developed" }
default = ["config", "humansize", "num-traits", "urlencode"]
config = ["askama_derive/config"]
humansize = ["askama_derive/humansize", "dep_humansize"]
+i18n = ["askama_derive/i18n", "fluent-templates", "parking_lot"]
markdown = ["askama_derive/markdown", "comrak"]
num-traits = ["askama_derive/num-traits", "dep_num_traits"]
serde-json = ["askama_derive/serde-json", "askama_escape/json", "serde", "serde_json"]
@@ -43,6 +44,8 @@ askama_escape = { version = "0.10.3", path = "../askama_escape" }
comrak = { version = "0.15", optional = true, default-features = false }
dep_humansize = { package = "humansize", version = "2", optional = true }
dep_num_traits = { package = "num-traits", version = "0.2.6", optional = true }
+fluent-templates = { version = "0.8.0", optional = true }
+parking_lot = { version = "0.12.1", optional = true }
percent-encoding = { version = "2.1.0", optional = true }
serde = { version = "1.0", optional = true, features = ["derive"] }
serde_json = { version = "1.0", optional = true }
diff --git a/askama/src/i18n.rs b/askama/src/i18n.rs
new file mode 100644
index 000000000..1a77aabe3
--- /dev/null
+++ b/askama/src/i18n.rs
@@ -0,0 +1,129 @@
+//! 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};
+use parking_lot::const_mutex;
+
+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)
+ }
+}
+
+/// Similar to OnceCell, but it has an additional take() function, which can only be used once,
+/// and only if the instance was never dereferenced.
+///
+/// The struct is only meant to be used by the [`i18n_load!()`] macro.
+/// Concurrent access will deliberately panic.
+///
+/// Rationale: StaticLoader cannot be cloned.
+#[doc(hidden)]
+pub struct Unlazy(parking_lot::Mutex>);
+
+enum UnlazyEnum {
+ Generator(Option T>),
+ Value(Box),
+}
+
+impl Unlazy {
+ pub const fn new(f: fn() -> T) -> Self {
+ Self(const_mutex(UnlazyEnum::Generator(Some(f))))
+ }
+
+ pub fn take(&self) -> T {
+ let f = match &mut *self.0.try_lock().unwrap() {
+ UnlazyEnum::Generator(f) => f.take(),
+ _ => None,
+ };
+ f.unwrap()()
+ }
+}
+
+impl std::ops::Deref for Unlazy
+where
+ Self: 'static,
+{
+ type Target = T;
+
+ fn deref(&self) -> &Self::Target {
+ let data = &mut *self.0.try_lock().unwrap();
+ let value: &T = match data {
+ UnlazyEnum::Generator(f) => {
+ *data = UnlazyEnum::Value(Box::new(f.take().unwrap()()));
+ match data {
+ UnlazyEnum::Value(value) => value,
+ _ => unreachable!(),
+ }
+ }
+ UnlazyEnum::Value(value) => value,
+ };
+
+ // SAFETY: This transmutation is safe because once a value is assigned,
+ // it won't be unassigned again, and Self has static lifetime.
+ unsafe { std::mem::transmute(value) }
+ }
+}
diff --git a/askama/src/lib.rs b/askama/src/lib.rs
index 91cf62ebb..8d934d75a 100644
--- a/askama/src/lib.rs
+++ b/askama/src/lib.rs
@@ -59,13 +59,15 @@
//! in the configuration file. The default syntax , "default", is the one
//! provided by Askama.
-#![forbid(unsafe_code)]
+#![cfg_attr(not(feature = "i18n"), forbid(unsafe_code))]
#![deny(elided_lifetimes_in_paths)]
#![deny(unreachable_pub)]
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 1d176b710..c4c5805e2 100644
--- a/askama_derive/Cargo.toml
+++ b/askama_derive/Cargo.toml
@@ -15,11 +15,12 @@ proc-macro = true
[features]
config = ["serde", "toml"]
humansize = []
+i18n = ["fluent-syntax", "fluent-templates", "serde", "toml"]
markdown = []
-urlencode = []
+num-traits = []
serde-json = []
serde-yaml = []
-num-traits = []
+urlencode = []
with-actix-web = []
with-axum = []
with-gotham = []
@@ -30,6 +31,8 @@ with-tide = []
with-warp = []
[dependencies]
+fluent-syntax = { version = "0.11.0", optional = true, default-features = false }
+fluent-templates = { version = "0.8.0", optional = true, default-features = false }
mime = "0.3"
mime_guess = "2"
nom = "7"
diff --git a/askama_derive/src/generator.rs b/askama_derive/src/generator.rs
index 08c1de168..ac7ed92d8 100644
--- a/askama_derive/src/generator.rs
+++ b/askama_derive/src/generator.rs
@@ -1291,9 +1291,42 @@ impl<'a> Generator<'a> {
Expr::RustMacro(name, args) => self.visit_rust_macro(buf, name, args),
Expr::Try(ref expr) => self.visit_try(buf, expr.as_ref())?,
Expr::Tuple(ref exprs) => self.visit_tuple(buf, exprs)?,
+ #[cfg(feature = "i18n")]
+ Expr::Localize(ref msg_id, ref args) => self.visit_localize(buf, msg_id, args)?,
})
}
+ #[cfg(feature = "i18n")]
+ 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..2f80a1131
--- /dev/null
+++ b/askama_derive/src/i18n.rs
@@ -0,0 +1,387 @@
+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 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 toml::from_str;
+
+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