A living almanac of seasons, soil, and the quiet knowledge that used to be common. Rust axum with minijinja and Vite.
agriculturealmanacaxumfolk-knowledgegardeningminijinjarustseasonalvite
1use minijinja::{path_loader, AutoEscape, Environment, Error, Output, State};
2use minijinja::value::Value;
3use serde_json::Value as JsonValue;
4use std::path::Path;
5
6/// Custom formatter that matches Jinja2's HTML escape (does NOT escape `/`).
7/// Without this, minijinja escapes `/` in URLs as `/` which is ugly even
8/// though browsers parse it the same.
9fn jinja2_html_formatter(out: &mut Output, state: &State, value: &Value) -> Result<(), Error> {
10 if value.is_safe() {
11 write!(out, "{value}").map_err(Error::from)?;
12 return Ok(());
13 }
14 let auto_escape = match state.auto_escape() {
15 AutoEscape::Html => true,
16 AutoEscape::None => false,
17 _ => return minijinja::escape_formatter(out, state, value),
18 };
19 if !auto_escape {
20 write!(out, "{value}").map_err(Error::from)?;
21 return Ok(());
22 }
23 if let Some(s) = value.as_str() {
24 write_jinja2_html(out, s).map_err(Error::from)?;
25 } else if value.is_undefined() || value.is_none() {
26 // emit nothing
27 } else {
28 let stringified = value.to_string();
29 write_jinja2_html(out, &stringified).map_err(Error::from)?;
30 }
31 Ok(())
32}
33
34fn write_jinja2_html(out: &mut Output, s: &str) -> std::fmt::Result {
35 let mut last = 0;
36 for (i, b) in s.bytes().enumerate() {
37 let escape = match b {
38 b'&' => "&",
39 b'<' => "<",
40 b'>' => ">",
41 b'"' => """,
42 b'\'' => "'",
43 _ => continue,
44 };
45 if last < i {
46 out.write_str(&s[last..i])?;
47 }
48 out.write_str(escape)?;
49 last = i + 1;
50 }
51 if last < s.len() {
52 out.write_str(&s[last..])?;
53 }
54 Ok(())
55}
56
57fn read_manifest(path: &Path) -> JsonValue {
58 let text = std::fs::read_to_string(path).unwrap_or_else(|_| "{}".to_string());
59 serde_json::from_str(&text).unwrap_or(JsonValue::Null)
60}
61
62fn lookup_asset(manifest: &JsonValue, entry: &str, kind: &str) -> String {
63 if let Some(chunk) = manifest.get(entry) {
64 if kind == "css" {
65 if let Some(css_arr) = chunk.get("css").and_then(|v| v.as_array()) {
66 if let Some(first) = css_arr.first().and_then(|v| v.as_str()) {
67 return format!("/static/{first}");
68 }
69 }
70 }
71 if let Some(file) = chunk.get("file").and_then(|v| v.as_str()) {
72 return format!("/static/{file}");
73 }
74 }
75 format!("/static/{entry}")
76}
77
78pub fn build_env(templates_dir: &Path, manifest_path: &Path) -> Environment<'static> {
79 let mut env = Environment::new();
80 env.set_loader(path_loader(templates_dir));
81 env.set_formatter(jinja2_html_formatter);
82
83 // Vite manifest:
84 // - debug builds re-read on every call so Vite watcher rebuilds show up
85 // immediately
86 // - release builds load once at startup and reuse the cached value
87 #[cfg(debug_assertions)]
88 {
89 let path = manifest_path.to_path_buf();
90 env.add_function(
91 "vite_asset",
92 move |entry: String, kind: Option<String>| -> Result<String, Error> {
93 let kind = kind.unwrap_or_else(|| "file".to_string());
94 let manifest = read_manifest(&path);
95 Ok(lookup_asset(&manifest, &entry, &kind))
96 },
97 );
98 }
99 #[cfg(not(debug_assertions))]
100 {
101 let manifest = read_manifest(manifest_path);
102 env.add_function(
103 "vite_asset",
104 move |entry: String, kind: Option<String>| -> Result<String, Error> {
105 let kind = kind.unwrap_or_else(|| "file".to_string());
106 Ok(lookup_asset(&manifest, &entry, &kind))
107 },
108 );
109 }
110
111 env
112}