use anyhow::{anyhow, Context as AnyhowContext};
use rhai::{Dynamic, Engine, Scope};
use std::collections::{BTreeMap, HashMap};
use std::io::{Cursor, Write};
use rhai::serde::to_dynamic;
use std::io;
use std::path::PathBuf;
use tera::{ArgFromValue, Context, Error, FunctionResult, Kwargs, State, Tera, TeraResult, Value};
mod definition;
mod precompiled;
use crate::renderers::RenderedParam;
pub use definition::*;
fn create_rhai_filter(
source: String,
) -> impl Fn(&Value, Kwargs, &State) -> TeraResult<Value> + Send + Sync + 'static {
move |val: &Value, args: Kwargs, _state: &State| -> TeraResult<Value> {
let eng = Engine::new();
let mut scope = Scope::new();
let val_json = serde_json::to_value(val).unwrap_or(serde_json::Value::Null);
let args_json: serde_json::Value = args.deserialize().unwrap_or(serde_json::Value::Null);
scope.push_dynamic(
"val",
to_dynamic(val_json).map_err(|e| Error::message(e.to_string()))?,
);
scope.push_dynamic(
"args",
to_dynamic(args_json).map_err(|e| Error::message(e.to_string()))?,
);
let res: Dynamic = eng
.eval_with_scope(&mut scope, &source)
.map_err(|e| Error::message(e.to_string()))?;
let json = serde_json::to_value(res).map_err(|e| Error::message(e.to_string()))?;
let out = match json {
serde_json::Value::String(s) => s,
other => other.to_string(),
};
Ok(Value::from(out))
}
}
fn get_shortcode_tera_fn(
temp: TemplateManager,
id: String,
template_prefix: String,
type_: TemplateType,
) -> impl Fn(Kwargs, &State) -> TeraResult<String> + Send + Sync + 'static {
move |args: Kwargs, _state: &State| -> TeraResult<String> {
let arg_map: BTreeMap<String, serde_json::Value> =
args.deserialize().map_err(|e| Error::message(e.to_string()))?;
let mut ctx = Context::new();
for (k, v) in &arg_map {
ctx.insert(k.clone(), v);
}
let mut buf = Cursor::new(Vec::new());
match temp.render(&id, &template_prefix, type_.clone(), &ctx, &mut buf) {
Ok(()) => Ok(String::from_utf8(buf.into_inner()).unwrap()),
Err(e) => {
let mut ebuf = Vec::new();
err_format(e, &mut ebuf).map_err(|er| Error::message(er.to_string()))?;
Ok(String::from_utf8(ebuf).unwrap())
}
}
}
}
fn err_format(e: anyhow::Error, mut f: impl Write) -> io::Result<()> {
write!(f, "Error {:?}", e)?;
e.chain()
.skip(1)
.try_for_each(|cause| write!(f, " caused by: {}", cause))?;
Ok(())
}
#[derive(Clone)]
pub struct TemplateManager {
path: PathBuf,
pub tera: Tera,
pub definitions: HashMap<String, TemplateDefinition>,
filter_path: PathBuf,
}
impl TemplateManager {
pub fn from_path(
template_path: PathBuf,
filter_path: PathBuf,
create_filters: bool,
) -> anyhow::Result<Self> {
TemplateManager::new(
load_template_definitions(template_path.clone())?,
template_path,
filter_path,
create_filters,
)
}
fn new(
definitions: HashMap<String, TemplateDefinition>,
dir: PathBuf,
filter_path: PathBuf,
create_filters: bool,
) -> anyhow::Result<Self> {
let defs = get_templates_from_definitions(&definitions, dir.clone());
let mut tera = Tera::new();
tera.register_function(
"render",
|_args: Kwargs, _state: &State| -> TeraResult<Value> { Ok(Value::from("")) },
);
tera.register_filter(
"json_encode",
|val: &Value, _args: Kwargs, _state: &State| -> TeraResult<Value> {
Ok(Value::from(serde_json::to_string(val).unwrap_or_default()))
},
);
tera.register_filter(
"embed",
|val: &Value, _args: Kwargs, _state: &State| -> TeraResult<Value> { Ok(val.clone()) },
);
tera.load_from_glob(&format!("{}/sources/**.html", dir.to_str().unwrap()))?;
let filters = get_filters_from_files(filter_path.clone())?;
filters.into_iter().for_each(|(name, source)| {
tera.register_filter(name, create_rhai_filter(source));
});
tera.add_raw_templates(defs)?;
let temp = TemplateManager {
path: dir,
tera,
definitions,
filter_path,
};
Ok(if create_filters {
temp.register_shortcode_fns()?
} else {
temp
})
}
#[allow(unused)]
fn combine(mut self, other: TemplateManager) -> anyhow::Result<TemplateManager> {
self.tera.register_from(&other.tera);
self.definitions.extend(other.definitions);
Ok(self)
}
fn register_shortcode_fns(mut self) -> anyhow::Result<Self> {
self.clone()
.definitions
.into_iter()
.try_for_each(|(tp_name, def)| {
let (_, id) = tp_name.split_once('_').unwrap();
let type_ = &def.type_;
for template_prefix in def.templates.keys() {
let f = get_shortcode_tera_fn(
self.clone(),
id.to_string(),
template_prefix.clone(),
type_.clone(),
);
let name = format!("shortcode_{template_prefix}_{id}");
self.tera.register_function(name, f);
}
Ok::<(), anyhow::Error>(())
})?;
Ok(self)
}
pub fn reload(&mut self) -> anyhow::Result<()> {
let defs = load_template_definitions(self.path.clone())?;
let tps = get_templates_from_definitions(&defs, self.path.clone());
self.tera.full_reload()?;
self.tera.add_raw_templates(tps)?;
let filters = get_filters_from_files(self.filter_path.clone())?;
filters.into_iter().for_each(|(name, source)| {
self.tera.register_filter(name, create_rhai_filter(source));
});
self.definitions = defs;
Ok(())
}
pub fn register_filter<Func, Arg, Res>(&mut self, name: &str, filter: Func)
where
Func: tera::Filter<Arg, Res> + for<'a> tera::Filter<<Arg as ArgFromValue<'a>>::Output, Res>,
Arg: for<'a> ArgFromValue<'a>,
Res: FunctionResult,
{
self.tera.register_filter(name.to_string(), filter)
}
pub fn get_template(
&self,
id: &str,
type_: TemplateType,
) -> anyhow::Result<TemplateDefinition> {
let tp = self
.definitions
.get(&format!("{type_}_{id}"))
.ok_or(anyhow!(
"Template definition with id '{}' and type '{}' doesn't exist.",
id,
type_
))?;
Ok(tp.clone())
}
pub fn render(
&self,
id: &str,
template_prefix: &str,
type_: TemplateType,
args: &Context,
buf: impl Write,
) -> anyhow::Result<()> {
let tp = self.get_template(id, type_)?;
let format_str = template_prefix;
let format = tp.get_format(format_str).context(format!(
"template with id '{id}' does not support format '{format_str}"
))?;
match format {
TemplateSource::Precompiled(tp, fm) => {
tp.render(fm, args, buf)?;
}
TemplateSource::Derive(from) => {
let format = tp.get_format(from).context(format!(
"template with id '{id}' does not support format '{format_str}"
))?;
if let TemplateSource::Precompiled(tp, fm) = format {
tp.render(fm, args, buf)?;
} else {
let type_ = &tp.type_;
let template_name = format!("{type_}_{id}.{format_str}");
self.tera.render_to(&template_name, args, buf)?;
}
}
_ => {
let type_ = &tp.type_;
let template_name = format!("{type_}_{id}.{format_str}");
self.tera.render_to(&template_name, args, buf)?;
}
}
Ok(())
}
pub fn validate_args_for_template(
&self,
id: &str,
args: &[RenderedParam],
) -> anyhow::Result<Vec<anyhow::Result<()>>> {
let tp = self
.get_template(id, TemplateType::Shortcode)
.context(format!("Invalid shortcode identifier '{}'", id))?;
tp.validate_args(args)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use std::path::PathBuf;
use tera::Context;
fn embedded_templates() -> TemplateManager {
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/config/templates");
let filters = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/config/filters");
TemplateManager::from_path(base, filters, false).expect("load embedded templates")
}
#[test]
fn loads_embedded_template_definitions() {
let tm = embedded_templates();
assert!(tm.get_template("header", TemplateType::Builtin).is_ok());
assert!(tm.get_template("message", TemplateType::Shortcode).is_ok());
}
#[test]
fn renders_message_shortcode() {
let tm = embedded_templates();
let mut ctx = Context::new();
ctx.insert("color", "info");
ctx.insert("title", "Heads up");
ctx.insert("body", "Body text here");
let mut buf = Cursor::new(Vec::new());
tm.render("message", "html", TemplateType::Shortcode, &ctx, &mut buf)
.expect("render message shortcode");
let out = String::from_utf8(buf.into_inner()).unwrap();
assert!(
out.contains(r#"class="message is-info""#),
"missing color class:\n{out}"
);
assert!(out.contains("Heads up"), "missing title:\n{out}");
assert!(out.contains("Body text here"), "missing body:\n{out}");
}
#[test]
fn message_shortcode_title_defaults_to_color() {
let tm = embedded_templates();
let mut ctx = Context::new();
ctx.insert("color", "warning");
ctx.insert("body", "No title here");
let mut buf = Cursor::new(Vec::new());
tm.render("message", "html", TemplateType::Shortcode, &ctx, &mut buf)
.expect("render message shortcode");
let out = String::from_utf8(buf.into_inner()).unwrap();
assert!(out.contains("Warning"), "expected capitalized color fallback:\n{out}");
}
}