use super::{store, Kind, Theme, ThemeError};
use laterite_core::Db;
use minijinja::value::{Kwargs, Serde, Value};
use minijinja::{AutoEscape, Environment, ErrorKind, State, UndefinedBehavior};
use serde::Serialize;
use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, RwLock};
const FUEL: u64 = 5_000_000;
pub fn template_name(kind: Kind, path: &str) -> String {
format!("{}/{path}", folder(kind))
}
fn folder(kind: Kind) -> &'static str {
match kind {
Kind::Layout => "layouts",
Kind::Page => "pages",
Kind::Partial => "partials",
Kind::Content => "content",
Kind::Asset => "assets",
}
}
#[derive(Debug, Clone)]
pub struct RenderError {
pub template: Option<String>,
pub line: Option<usize>,
pub kind: String,
pub message: String,
}
impl fmt::Display for RenderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match (&self.template, self.line) {
(Some(t), Some(l)) => write!(f, "{t}:{l}: {}", self.message),
(Some(t), None) => write!(f, "{t}: {}", self.message),
_ => f.write_str(&self.message),
}
}
}
impl std::error::Error for RenderError {}
impl From<minijinja::Error> for RenderError {
fn from(e: minijinja::Error) -> Self {
let message = match e.detail() {
Some(detail) => format!("{}: {detail}", e.kind()),
None => e.kind().to_string(),
};
RenderError {
template: e.name().map(str::to_string),
line: e.line(),
kind: e.kind().to_string(),
message,
}
}
}
pub struct Engine {
env: Environment<'static>,
theme: Theme,
stamp: String,
names: Vec<String>,
}
fn theme_value(theme: &Theme) -> serde_json::Value {
let mut map = match &theme.settings {
serde_json::Value::Object(values) => values.clone(),
_ => serde_json::Map::new(),
};
map.insert("code".into(), theme.code.clone().into());
map.insert("name".into(), theme.config.name.clone().into());
map.insert("author".into(), theme.config.author.clone().into());
map.insert("homepage".into(), theme.config.homepage.clone().into());
map.insert(
"description".into(),
theme.config.description.clone().into(),
);
serde_json::Value::Object(map)
}
fn stamp_of(chain: &[Theme]) -> String {
chain
.iter()
.map(|t| format!("{}@{}", t.code, t.updated_at))
.collect::<Vec<_>>()
.join(";")
}
impl Engine {
pub async fn build(db: &Db, code: &str) -> Result<Engine, ThemeError> {
Engine::build_with(db, code, |_| {}).await
}
pub async fn build_with(
db: &Db,
code: &str,
configure: impl FnOnce(&mut Environment<'static>),
) -> Result<Engine, ThemeError> {
let chain = store::chain(db, code).await?;
let stamp = stamp_of(&chain);
let mut sources: HashMap<String, String> = HashMap::new();
let mut content: HashMap<String, String> = HashMap::new();
for theme in chain.iter().rev() {
for t in store::list_templates(db, theme.id, None).await? {
if t.kind == Kind::Content {
content.insert(t.path, t.markup);
} else {
sources.insert(template_name(t.kind, &t.path), t.markup);
}
}
}
let theme = chain
.into_iter()
.next()
.expect("a chain starts with its theme");
let mut env = base_environment();
env.add_global("theme", Value::from(Serde(theme_value(&theme))));
let content = Arc::new(content);
env.add_function(
"content",
move |name: String| -> Result<Value, minijinja::Error> {
match content.get(&name) {
Some(body) => Ok(Value::from_safe_string(render_content(&name, body))),
None => Err(minijinja::Error::new(
ErrorKind::TemplateNotFound,
format!("no content block {name:?}"),
)),
}
},
);
env.add_function("partial", partial);
configure(&mut env);
let mut names: Vec<String> = sources.keys().cloned().collect();
names.sort();
for (name, source) in sources {
env.add_template_owned(name, source)
.map_err(|e| ThemeError::Template(RenderError::from(e)))?;
}
Ok(Engine {
env,
theme,
stamp,
names,
})
}
pub fn theme(&self) -> &Theme {
&self.theme
}
pub fn stamp(&self) -> &str {
&self.stamp
}
pub fn env(&self) -> &Environment<'static> {
&self.env
}
pub fn names(&self) -> &[String] {
&self.names
}
pub fn has(&self, kind: Kind, path: &str) -> bool {
self.names.binary_search(&template_name(kind, path)).is_ok()
}
pub fn render(
&self,
kind: Kind,
path: &str,
ctx: impl Serialize,
) -> Result<String, RenderError> {
let template = self.env.get_template(&template_name(kind, path))?;
Ok(template.render(Value::from(Serde(ctx)))?)
}
}
fn base_environment() -> Environment<'static> {
let mut env = Environment::new();
env.set_auto_escape_callback(|name| {
if name.starts_with("assets/") {
AutoEscape::None
} else {
AutoEscape::Html
}
});
env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
env.set_fuel(Some(FUEL));
env
}
fn partial(state: &State, name: String, kwargs: Kwargs) -> Result<Value, minijinja::Error> {
let template = state
.env()
.get_template(&template_name(Kind::Partial, &name))?;
let ctx = Value::from_pairs(
kwargs
.args()
.map(|k| (k.to_string(), kwargs.get::<Value>(k).unwrap_or_default())),
);
kwargs.assert_all_used()?;
Ok(Value::from_safe_string(template.render(ctx)?))
}
pub fn render_content(path: &str, body: &str) -> String {
let ext = path.rsplit('.').next().unwrap_or_default();
match ext {
"md" | "markdown" => {
let mut options = pulldown_cmark::Options::empty();
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
options.insert(pulldown_cmark::Options::ENABLE_FOOTNOTES);
let parser = pulldown_cmark::Parser::new_ext(body, options);
let mut html = String::with_capacity(body.len() * 2);
pulldown_cmark::html::push_html(&mut html, parser);
html
}
"htm" | "html" => body.to_string(),
_ => minijinja::HtmlEscape(body).to_string(),
}
}
pub fn check(source: &str) -> Result<(), RenderError> {
let env = base_environment();
env.template_from_str(source)?;
Ok(())
}
#[derive(Default)]
pub struct EngineCache {
engines: RwLock<HashMap<String, Arc<Engine>>>,
}
impl EngineCache {
pub fn new() -> Self {
Self::default()
}
pub async fn get(&self, db: &Db, code: &str) -> Result<Arc<Engine>, ThemeError> {
self.get_with(db, code, |_| {}).await
}
pub async fn get_with(
&self,
db: &Db,
code: &str,
configure: impl FnOnce(&mut Environment<'static>),
) -> Result<Arc<Engine>, ThemeError> {
let chain = store::chain(db, code).await?;
let stamp = stamp_of(&chain);
if let Some(engine) = self.engines.read().expect("engine cache").get(code) {
if engine.stamp == stamp {
return Ok(Arc::clone(engine));
}
}
let engine = Arc::new(Engine::build_with(db, code, configure).await?);
self.engines
.write()
.expect("engine cache")
.insert(code.to_string(), Arc::clone(&engine));
Ok(engine)
}
pub fn invalidate(&self, code: &str) {
self.engines.write().expect("engine cache").remove(code);
}
}