1use super::{store, Kind, Theme, ThemeError};
19use laterite_core::Db;
20use minijinja::value::{Kwargs, Serde, Value};
21use minijinja::{AutoEscape, Environment, ErrorKind, State, UndefinedBehavior};
22use serde::Serialize;
23use std::collections::HashMap;
24use std::fmt;
25use std::sync::{Arc, RwLock};
26
27const FUEL: u64 = 5_000_000;
30
31pub fn template_name(kind: Kind, path: &str) -> String {
33 format!("{}/{path}", folder(kind))
34}
35
36fn folder(kind: Kind) -> &'static str {
37 match kind {
38 Kind::Layout => "layouts",
39 Kind::Page => "pages",
40 Kind::Partial => "partials",
41 Kind::Content => "content",
42 Kind::Asset => "assets",
43 }
44}
45
46#[derive(Debug, Clone)]
48pub struct RenderError {
49 pub template: Option<String>,
51 pub line: Option<usize>,
53 pub kind: String,
55 pub message: String,
56}
57
58impl fmt::Display for RenderError {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 match (&self.template, self.line) {
61 (Some(t), Some(l)) => write!(f, "{t}:{l}: {}", self.message),
62 (Some(t), None) => write!(f, "{t}: {}", self.message),
63 _ => f.write_str(&self.message),
64 }
65 }
66}
67
68impl std::error::Error for RenderError {}
69
70impl From<minijinja::Error> for RenderError {
71 fn from(e: minijinja::Error) -> Self {
72 let message = match e.detail() {
73 Some(detail) => format!("{}: {detail}", e.kind()),
74 None => e.kind().to_string(),
75 };
76 RenderError {
77 template: e.name().map(str::to_string),
78 line: e.line(),
79 kind: e.kind().to_string(),
80 message,
81 }
82 }
83}
84
85pub struct Engine {
87 env: Environment<'static>,
88 theme: Theme,
89 stamp: String,
90 names: Vec<String>,
91}
92
93fn theme_value(theme: &Theme) -> serde_json::Value {
96 let mut map = match &theme.settings {
97 serde_json::Value::Object(values) => values.clone(),
98 _ => serde_json::Map::new(),
99 };
100 map.insert("code".into(), theme.code.clone().into());
101 map.insert("name".into(), theme.config.name.clone().into());
102 map.insert("author".into(), theme.config.author.clone().into());
103 map.insert("homepage".into(), theme.config.homepage.clone().into());
104 map.insert(
105 "description".into(),
106 theme.config.description.clone().into(),
107 );
108 serde_json::Value::Object(map)
109}
110
111fn stamp_of(chain: &[Theme]) -> String {
114 chain
115 .iter()
116 .map(|t| format!("{}@{}", t.code, t.updated_at))
117 .collect::<Vec<_>>()
118 .join(";")
119}
120
121impl Engine {
122 pub async fn build(db: &Db, code: &str) -> Result<Engine, ThemeError> {
124 Engine::build_with(db, code, |_| {}).await
125 }
126
127 pub async fn build_with(
131 db: &Db,
132 code: &str,
133 configure: impl FnOnce(&mut Environment<'static>),
134 ) -> Result<Engine, ThemeError> {
135 let chain = store::chain(db, code).await?;
136 let stamp = stamp_of(&chain);
137 let mut sources: HashMap<String, String> = HashMap::new();
138 let mut content: HashMap<String, String> = HashMap::new();
139 for theme in chain.iter().rev() {
141 for t in store::list_templates(db, theme.id, None).await? {
142 if t.kind == Kind::Content {
143 content.insert(t.path, t.markup);
144 } else {
145 sources.insert(template_name(t.kind, &t.path), t.markup);
146 }
147 }
148 }
149 let theme = chain
150 .into_iter()
151 .next()
152 .expect("a chain starts with its theme");
153 let mut env = base_environment();
154 env.add_global("theme", Value::from(Serde(theme_value(&theme))));
155 let content = Arc::new(content);
156 env.add_function(
157 "content",
158 move |name: String| -> Result<Value, minijinja::Error> {
159 match content.get(&name) {
160 Some(body) => Ok(Value::from_safe_string(render_content(&name, body))),
161 None => Err(minijinja::Error::new(
162 ErrorKind::TemplateNotFound,
163 format!("no content block {name:?}"),
164 )),
165 }
166 },
167 );
168 env.add_function("partial", partial);
169 configure(&mut env);
170 let mut names: Vec<String> = sources.keys().cloned().collect();
171 names.sort();
172 for (name, source) in sources {
173 env.add_template_owned(name, source)
174 .map_err(|e| ThemeError::Template(RenderError::from(e)))?;
175 }
176 Ok(Engine {
177 env,
178 theme,
179 stamp,
180 names,
181 })
182 }
183
184 pub fn theme(&self) -> &Theme {
186 &self.theme
187 }
188
189 pub fn stamp(&self) -> &str {
191 &self.stamp
192 }
193
194 pub fn env(&self) -> &Environment<'static> {
196 &self.env
197 }
198
199 pub fn names(&self) -> &[String] {
201 &self.names
202 }
203
204 pub fn has(&self, kind: Kind, path: &str) -> bool {
206 self.names.binary_search(&template_name(kind, path)).is_ok()
207 }
208
209 pub fn render(
211 &self,
212 kind: Kind,
213 path: &str,
214 ctx: impl Serialize,
215 ) -> Result<String, RenderError> {
216 let template = self.env.get_template(&template_name(kind, path))?;
217 Ok(template.render(Value::from(Serde(ctx)))?)
218 }
219}
220
221fn base_environment() -> Environment<'static> {
224 let mut env = Environment::new();
225 env.set_auto_escape_callback(|name| {
226 if name.starts_with("assets/") {
227 AutoEscape::None
228 } else {
229 AutoEscape::Html
230 }
231 });
232 env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
233 env.set_fuel(Some(FUEL));
234 env
235}
236
237fn partial(state: &State, name: String, kwargs: Kwargs) -> Result<Value, minijinja::Error> {
240 let template = state
241 .env()
242 .get_template(&template_name(Kind::Partial, &name))?;
243 let ctx = Value::from_pairs(
244 kwargs
245 .args()
246 .map(|k| (k.to_string(), kwargs.get::<Value>(k).unwrap_or_default())),
247 );
248 kwargs.assert_all_used()?;
249 Ok(Value::from_safe_string(template.render(ctx)?))
250}
251
252pub fn render_content(path: &str, body: &str) -> String {
255 let ext = path.rsplit('.').next().unwrap_or_default();
256 match ext {
257 "md" | "markdown" => {
258 let mut options = pulldown_cmark::Options::empty();
259 options.insert(pulldown_cmark::Options::ENABLE_TABLES);
260 options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
261 options.insert(pulldown_cmark::Options::ENABLE_FOOTNOTES);
262 let parser = pulldown_cmark::Parser::new_ext(body, options);
263 let mut html = String::with_capacity(body.len() * 2);
264 pulldown_cmark::html::push_html(&mut html, parser);
265 html
266 }
267 "htm" | "html" => body.to_string(),
268 _ => minijinja::HtmlEscape(body).to_string(),
269 }
270}
271
272pub fn check(source: &str) -> Result<(), RenderError> {
275 let env = base_environment();
276 env.template_from_str(source)?;
277 Ok(())
278}
279
280#[derive(Default)]
282pub struct EngineCache {
283 engines: RwLock<HashMap<String, Arc<Engine>>>,
284}
285
286impl EngineCache {
287 pub fn new() -> Self {
288 Self::default()
289 }
290
291 pub async fn get(&self, db: &Db, code: &str) -> Result<Arc<Engine>, ThemeError> {
293 self.get_with(db, code, |_| {}).await
294 }
295
296 pub async fn get_with(
299 &self,
300 db: &Db,
301 code: &str,
302 configure: impl FnOnce(&mut Environment<'static>),
303 ) -> Result<Arc<Engine>, ThemeError> {
304 let chain = store::chain(db, code).await?;
305 let stamp = stamp_of(&chain);
306 if let Some(engine) = self.engines.read().expect("engine cache").get(code) {
307 if engine.stamp == stamp {
308 return Ok(Arc::clone(engine));
309 }
310 }
311 let engine = Arc::new(Engine::build_with(db, code, configure).await?);
312 self.engines
313 .write()
314 .expect("engine cache")
315 .insert(code.to_string(), Arc::clone(&engine));
316 Ok(engine)
317 }
318
319 pub fn invalidate(&self, code: &str) {
321 self.engines.write().expect("engine cache").remove(code);
322 }
323}