1use super::page::{page_name, PageSettings};
19use super::router::{Params, RouteTable};
20use super::{store, Kind, Theme, ThemeError};
21use laterite_core::Db;
22use minijinja::value::{Kwargs, Serde, Value};
23use minijinja::{AutoEscape, Environment, ErrorKind, State, UndefinedBehavior};
24use serde::Serialize;
25use std::collections::HashMap;
26use std::fmt;
27use std::sync::{Arc, RwLock};
28
29const FUEL: u64 = 5_000_000;
32
33pub fn template_name(kind: Kind, path: &str) -> String {
35 format!("{}/{path}", folder(kind))
36}
37
38fn folder(kind: Kind) -> &'static str {
39 match kind {
40 Kind::Layout => "layouts",
41 Kind::Page => "pages",
42 Kind::Partial => "partials",
43 Kind::Content => "content",
44 Kind::Asset => "assets",
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct RenderError {
51 pub template: Option<String>,
53 pub line: Option<usize>,
55 pub kind: String,
57 pub message: String,
58}
59
60impl fmt::Display for RenderError {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 match (&self.template, self.line) {
63 (Some(t), Some(l)) => write!(f, "{t}:{l}: {}", self.message),
64 (Some(t), None) => write!(f, "{t}: {}", self.message),
65 _ => f.write_str(&self.message),
66 }
67 }
68}
69
70impl std::error::Error for RenderError {}
71
72impl From<minijinja::Error> for RenderError {
73 fn from(e: minijinja::Error) -> Self {
74 let message = match e.detail() {
75 Some(detail) => format!("{}: {detail}", e.kind()),
76 None => e.kind().to_string(),
77 };
78 RenderError {
79 template: e.name().map(str::to_string),
80 line: e.line(),
81 kind: e.kind().to_string(),
82 message,
83 }
84 }
85}
86
87pub struct Engine {
89 env: Environment<'static>,
90 theme: Theme,
91 stamp: String,
92 names: Vec<String>,
93 routes: Arc<RouteTable>,
94 pages: HashMap<String, PageSettings>,
95 line_maps: HashMap<String, Vec<usize>>,
97 page_paths: HashMap<String, String>,
99 layout_settings: HashMap<String, serde_json::Value>,
101 page_bags: HashMap<String, serde_json::Value>,
103}
104
105fn compose_page(source: &str, layout: Option<&str>) -> (String, Vec<usize>) {
115 let lines = source.lines().count().max(1);
116 let identity: Vec<usize> = (1..=lines).collect();
117 let Some(layout) = layout else {
118 return (source.to_string(), identity);
119 };
120 if source.contains("{% extends") || source.contains("{%- extends") {
121 return (source.to_string(), identity);
122 }
123 let tag = regex::Regex::new(
124 r"\{%-?\s*(block\s+[A-Za-z_][A-Za-z0-9_]*|endblock(?:\s+[A-Za-z_][A-Za-z0-9_]*)?)\s*-?%\}",
125 )
126 .expect("a fixed expression");
127 let mut spans: Vec<std::ops::Range<usize>> = Vec::new();
128 let mut depth = 0usize;
129 let mut open = None;
130 for m in tag.find_iter(source) {
131 if m.as_str().contains("endblock") {
132 depth = depth.saturating_sub(1);
133 if depth == 0 {
134 if let Some(start) = open.take() {
135 spans.push(start..m.end());
136 }
137 }
138 } else {
139 if depth == 0 {
140 open = Some(m.start());
141 }
142 depth += 1;
143 }
144 }
145 let mut body = String::with_capacity(source.len());
146 let mut hoisted: Vec<(usize, &str)> = Vec::new();
147 let mut at = 0;
148 for span in &spans {
149 body.push_str(&source[at..span.start]);
150 let text = &source[span.clone()];
151 body.extend(std::iter::repeat_n('\n', text.matches('\n').count()));
152 hoisted.push((source[..span.start].matches('\n').count() + 1, text));
153 at = span.end;
154 }
155 body.push_str(&source[at..]);
156 let mut compiled = format!(
157 "{{% extends {:?} %}}{{% block page %}}{body}{{% endblock %}}",
158 template_name(Kind::Layout, layout)
159 );
160 let mut map: Vec<usize> = (1..=body.lines().count().max(1)).collect();
161 for (origin, text) in hoisted {
162 compiled.push('\n');
163 compiled.push_str(text);
164 map.extend(text.lines().enumerate().map(|(i, _)| origin + i));
165 }
166 (compiled, map)
167}
168
169fn theme_value(theme: &Theme) -> serde_json::Value {
172 let mut map = match &theme.settings {
173 serde_json::Value::Object(values) => values.clone(),
174 _ => serde_json::Map::new(),
175 };
176 map.insert("code".into(), theme.code.clone().into());
177 map.insert("name".into(), theme.config.name.clone().into());
178 map.insert("author".into(), theme.config.author.clone().into());
179 map.insert("homepage".into(), theme.config.homepage.clone().into());
180 map.insert(
181 "description".into(),
182 theme.config.description.clone().into(),
183 );
184 serde_json::Value::Object(map)
185}
186
187fn stamp_of(chain: &[Theme]) -> String {
190 chain
191 .iter()
192 .map(|t| format!("{}@{}", t.code, t.updated_at))
193 .collect::<Vec<_>>()
194 .join(";")
195}
196
197impl Engine {
198 pub async fn build(db: &Db, code: &str) -> Result<Engine, ThemeError> {
200 Engine::build_with(db, code, |_| {}).await
201 }
202
203 pub async fn build_with(
207 db: &Db,
208 code: &str,
209 configure: impl FnOnce(&mut Environment<'static>),
210 ) -> Result<Engine, ThemeError> {
211 let chain = store::chain(db, code).await?;
212 let stamp = stamp_of(&chain);
213 let mut sources: HashMap<String, String> = HashMap::new();
214 let mut content: HashMap<String, String> = HashMap::new();
215 let mut page_rows: HashMap<String, (serde_json::Value, String)> = HashMap::new();
216 let mut layout_settings: HashMap<String, serde_json::Value> = HashMap::new();
217 for theme in chain.iter().rev() {
219 for t in store::list_templates(db, theme.id, None).await? {
220 match t.kind {
221 Kind::Content => {
222 content.insert(t.path, t.markup);
223 }
224 Kind::Page => {
225 page_rows.insert(t.path, (t.settings, t.markup));
226 }
227 Kind::Layout => {
228 layout_settings.insert(t.path.clone(), t.settings);
229 sources.insert(template_name(t.kind, &t.path), t.markup);
230 }
231 _ => {
232 sources.insert(template_name(t.kind, &t.path), t.markup);
233 }
234 }
235 }
236 }
237 let mut routes = RouteTable::default();
240 let mut pages: HashMap<String, PageSettings> = HashMap::new();
241 let mut line_maps: HashMap<String, Vec<usize>> = HashMap::new();
242 let mut page_paths: HashMap<String, String> = HashMap::new();
243 let mut page_bags: HashMap<String, serde_json::Value> = HashMap::new();
244 for (path, (settings, markup)) in page_rows {
245 let name = page_name(&path).to_string();
246 page_paths.insert(name.clone(), path.clone());
247 page_bags.insert(name.clone(), settings.clone());
248 let parsed = PageSettings::from_value(&settings).unwrap_or_default();
249 if let Ok(pattern) = parsed.pattern() {
250 routes.add(name.clone(), pattern);
251 }
252 let layout = parsed.layout_path();
253 let (compiled, map) = compose_page(&markup, layout.as_deref());
254 let template = template_name(Kind::Page, &path);
255 sources.insert(template.clone(), compiled);
256 line_maps.insert(template, map);
257 pages.insert(name, parsed);
258 }
259 let routes = Arc::new(routes);
260 let theme = chain
261 .into_iter()
262 .next()
263 .expect("a chain starts with its theme");
264 let mut env = base_environment();
265 env.add_global("theme", Value::from(Serde(theme_value(&theme))));
266 let content = Arc::new(content);
267 env.add_function(
268 "content",
269 move |name: String, kwargs: Kwargs| -> Result<Value, minijinja::Error> {
270 let Some(body) = content.get(&name) else {
271 return Err(minijinja::Error::new(
272 ErrorKind::TemplateNotFound,
273 format!("no content block {name:?}"),
274 ));
275 };
276 let mut text = body.clone();
278 for key in kwargs.args() {
279 let value: Value = kwargs.get(key)?;
280 text = text.replace(
281 &format!("{{{key}}}"),
282 &minijinja::HtmlEscape(&value.to_string()).to_string(),
283 );
284 }
285 kwargs.assert_all_used()?;
286 Ok(Value::from_safe_string(render_content(&name, &text)))
287 },
288 );
289 env.add_function("partial", partial);
290 env.add_function("component", component);
291 let code = theme.code.clone();
292 env.add_function("asset", move |path: String| -> Value {
293 Value::from_safe_string(format!(
294 "/themes/{code}/assets/{}",
295 path.trim_start_matches('/')
296 ))
297 });
298 env.add_function("media", media);
299 env.add_filter("variant", |url: String, preset: String| -> Value {
300 Value::from_safe_string(variant(url, preset))
301 });
302 let table = Arc::clone(&routes);
303 env.add_function(
304 "page",
305 move |name: String, kwargs: Kwargs| -> Result<Value, minijinja::Error> {
306 let mut params = Params::new();
307 for key in kwargs.args() {
308 params.insert(key.to_string(), kwargs.get::<String>(key)?);
309 }
310 kwargs.assert_all_used()?;
311 table
312 .url(&name, ¶ms)
313 .map(Value::from_safe_string)
314 .ok_or_else(|| {
315 minijinja::Error::new(
316 ErrorKind::InvalidOperation,
317 format!("no page {name:?}"),
318 )
319 })
320 },
321 );
322 configure(&mut env);
323 let mut names: Vec<String> = sources.keys().cloned().collect();
324 names.sort();
325 for (name, source) in sources {
326 env.add_template_owned(name, source)
327 .map_err(|e| ThemeError::Template(RenderError::from(e)))?;
328 }
329 Ok(Engine {
330 env,
331 theme,
332 stamp,
333 names,
334 routes,
335 pages,
336 line_maps,
337 page_paths,
338 layout_settings,
339 page_bags,
340 })
341 }
342
343 pub fn theme(&self) -> &Theme {
345 &self.theme
346 }
347
348 pub fn stamp(&self) -> &str {
350 &self.stamp
351 }
352
353 pub fn env(&self) -> &Environment<'static> {
355 &self.env
356 }
357
358 pub fn names(&self) -> &[String] {
360 &self.names
361 }
362
363 pub fn resolve(&self, path: &str) -> Option<(&str, Params)> {
365 self.routes.resolve(path)
366 }
367
368 pub fn page(&self, name: &str) -> Option<&PageSettings> {
370 self.pages.get(name)
371 }
372
373 pub fn page_path(&self, name: &str) -> Option<&str> {
375 self.page_paths.get(name).map(String::as_str)
376 }
377
378 pub fn layout_settings(&self, path: &str) -> Option<&serde_json::Value> {
380 self.layout_settings.get(path)
381 }
382
383 pub fn page_bag(&self, name: &str) -> Option<&serde_json::Value> {
385 self.page_bags.get(name)
386 }
387
388 pub fn url_for(&self, name: &str, params: &Params) -> Option<String> {
390 self.routes.url(name, params)
391 }
392
393 pub fn routes(&self) -> &RouteTable {
395 &self.routes
396 }
397
398 pub fn has(&self, kind: Kind, path: &str) -> bool {
400 self.names.binary_search(&template_name(kind, path)).is_ok()
401 }
402
403 pub fn render(
405 &self,
406 kind: Kind,
407 path: &str,
408 ctx: impl Serialize,
409 ) -> Result<String, RenderError> {
410 let name = template_name(kind, path);
411 let template = self.env.get_template(&name)?;
412 template
413 .render(Value::from(Serde(ctx)))
414 .map_err(|e| self.author_line(RenderError::from(e)))
415 }
416
417 pub fn author_line(&self, mut err: RenderError) -> RenderError {
419 if let (Some(template), Some(line)) = (&err.template, err.line) {
420 if let Some(map) = self.line_maps.get(template) {
421 if let Some(original) = map.get(line.saturating_sub(1)) {
422 err.line = Some(*original);
423 }
424 }
425 }
426 err
427 }
428}
429
430fn base_environment() -> Environment<'static> {
433 let mut env = Environment::new();
434 env.set_auto_escape_callback(|name| {
435 if name.starts_with("assets/") {
436 AutoEscape::None
437 } else {
438 AutoEscape::Html
439 }
440 });
441 env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
442 env.set_fuel(Some(FUEL));
443 env
444}
445
446fn media(value: Value, kwargs: Kwargs) -> Result<Value, minijinja::Error> {
450 let preset: Option<String> = kwargs.get("preset")?;
451 kwargs.assert_all_used()?;
452 let url = media_url(&value);
453 Ok(match (url, preset) {
454 (Some(url), Some(p)) => Value::from_safe_string(variant(url, p)),
455 (Some(url), None) => Value::from_safe_string(url),
456 (None, _) => Value::from(""),
457 })
458}
459
460fn media_url(value: &Value) -> Option<String> {
461 if value.is_undefined() || value.is_none() {
462 return None;
463 }
464 if let Some(s) = value.as_str() {
465 return (!s.is_empty()).then(|| s.to_string());
466 }
467 value
468 .get_attr("url")
469 .ok()
470 .filter(|u| !u.is_undefined())
471 .and_then(|u| u.as_str().map(str::to_string))
472 .filter(|u| !u.is_empty())
473}
474
475pub fn variant(url: String, preset: String) -> String {
479 const MARK: &str = "/storage/media/";
480 let Some(at) = url.find(MARK) else {
481 return url;
482 };
483 let (origin, rest) = url.split_at(at + MARK.len());
484 let mut parts = rest.splitn(3, '/');
485 let (Some(hash), Some(second)) = (parts.next(), parts.next()) else {
486 return url;
487 };
488 let name = match parts.next() {
489 Some(name) => name,
491 None => second,
492 };
493 format!("{origin}{hash}/{preset}/{name}")
494}
495
496fn partial(state: &State, name: String, kwargs: Kwargs) -> Result<Value, minijinja::Error> {
499 let template = state
500 .env()
501 .get_template(&template_name(Kind::Partial, &name))?;
502 let mut pairs: Vec<(String, Value)> = ["this", "head"]
503 .into_iter()
504 .filter_map(|key| state.lookup(key).map(|v| (key.to_string(), v)))
505 .collect();
506 for key in kwargs.args() {
507 pairs.push((key.to_string(), kwargs.get::<Value>(key)?));
508 }
509 kwargs.assert_all_used()?;
510 Ok(Value::from_safe_string(
511 template.render(Value::from_pairs(pairs))?,
512 ))
513}
514
515fn component(state: &State, alias: String) -> Result<Value, minijinja::Error> {
519 let code = state
520 .lookup(super::component::ALIASES_KEY)
521 .and_then(|m| m.get_attr(&alias).ok())
522 .filter(|v| !v.is_undefined())
523 .map(|v| v.to_string())
524 .ok_or_else(|| {
525 minijinja::Error::new(
526 ErrorKind::InvalidOperation,
527 format!("no component is attached as {alias:?}"),
528 )
529 })?;
530 let own = template_name(Kind::Partial, &format!("{alias}/default.htm"));
531 let template = match state.env().get_template(&own) {
532 Ok(t) => t,
533 Err(_) => state
534 .env()
535 .get_template(&super::component::template_name(&code))
536 .map_err(|_| {
537 minijinja::Error::new(
538 ErrorKind::TemplateNotFound,
539 format!("no markup for the component {code:?} as {alias:?}: add partials/{alias}/default.htm"),
540 )
541 })?,
542 };
543 let data = state.lookup(&alias).unwrap_or_default();
544 let mut pairs: Vec<(String, Value)> = ["this", "head"]
545 .into_iter()
546 .filter_map(|key| state.lookup(key).map(|v| (key.to_string(), v)))
547 .collect();
548 pairs.push((alias.clone(), data.clone()));
549 pairs.push(("component".to_string(), data));
550 Ok(Value::from_safe_string(
551 template.render(Value::from_pairs(pairs))?,
552 ))
553}
554
555pub fn render_content(path: &str, body: &str) -> String {
558 let ext = path.rsplit('.').next().unwrap_or_default();
559 match ext {
560 "md" | "markdown" => {
561 let mut options = pulldown_cmark::Options::empty();
562 options.insert(pulldown_cmark::Options::ENABLE_TABLES);
563 options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
564 options.insert(pulldown_cmark::Options::ENABLE_FOOTNOTES);
565 let parser = pulldown_cmark::Parser::new_ext(body, options);
566 let mut html = String::with_capacity(body.len() * 2);
567 pulldown_cmark::html::push_html(&mut html, parser);
568 html
569 }
570 "htm" | "html" => body.to_string(),
571 _ => minijinja::HtmlEscape(body).to_string(),
572 }
573}
574
575pub fn check(source: &str) -> Result<(), RenderError> {
578 let env = base_environment();
579 env.template_from_str(source)?;
580 Ok(())
581}
582
583#[derive(Default)]
585pub struct EngineCache {
586 engines: RwLock<HashMap<String, Arc<Engine>>>,
587}
588
589impl EngineCache {
590 pub fn new() -> Self {
591 Self::default()
592 }
593
594 pub async fn get(&self, db: &Db, code: &str) -> Result<Arc<Engine>, ThemeError> {
596 self.get_with(db, code, |_| {}).await
597 }
598
599 pub async fn get_with(
602 &self,
603 db: &Db,
604 code: &str,
605 configure: impl FnOnce(&mut Environment<'static>),
606 ) -> Result<Arc<Engine>, ThemeError> {
607 let chain = store::chain(db, code).await?;
608 let stamp = stamp_of(&chain);
609 if let Some(engine) = self.engines.read().expect("engine cache").get(code) {
610 if engine.stamp == stamp {
611 return Ok(Arc::clone(engine));
612 }
613 }
614 let engine = Arc::new(Engine::build_with(db, code, configure).await?);
615 self.engines
616 .write()
617 .expect("engine cache")
618 .insert(code.to_string(), Arc::clone(&engine));
619 Ok(engine)
620 }
621
622 pub fn invalidate(&self, code: &str) {
624 self.engines.write().expect("engine cache").remove(code);
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631
632 #[test]
633 fn a_page_is_composed_into_its_layout_with_its_blocks_lifted() {
634 let (compiled, map) = compose_page(
635 "a\n{% block title %}T\nU{% endblock %}\nb {{ x }}",
636 Some("default.htm"),
637 );
638 assert_eq!(
639 compiled,
640 "{% extends \"layouts/default.htm\" %}{% block page %}a\n\n\nb {{ x }}{% endblock %}\n{% block title %}T\nU{% endblock %}"
641 );
642 assert_eq!(map, vec![1, 2, 3, 4, 2, 3]);
644
645 let (plain, map) = compose_page("x\ny", None);
646 assert_eq!(plain, "x\ny");
647 assert_eq!(map, vec![1, 2]);
648 let (own, _) = compose_page("{% extends \"layouts/a.htm\" %}", Some("default.htm"));
649 assert!(own.starts_with("{% extends \"layouts/a.htm\" %}"));
650 let (unmatched, _) = compose_page("{% block t %}x", Some("default.htm"));
652 assert!(unmatched.contains("{% block page %}{% block t %}x{% endblock %}"));
653 }
654}