Single-binary self-hosted Markdown blog on Rust axum: no database, live search, Typst PDF export, and strong SEO.
axumblogdockermarkdownminijinjarustself-hostedtypstvite
1use minijinja::value::{Kwargs, Value};
2use minijinja::{path_loader, AutoEscape, Environment, Error, ErrorKind, Output, State};
3use serde::Serialize;
4use serde_json::Value as JsonValue;
5use std::path::Path;
6
7/// Custom formatter that matches Jinja2's HTML escape (does NOT escape `/`).
8fn jinja2_html_formatter(
9 out: &mut Output,
10 _state: &State,
11 value: &Value,
12) -> Result<(), Error> {
13 if value.is_safe() {
14 write!(out, "{value}").map_err(Error::from)?;
15 return Ok(());
16 }
17 let auto_escape = match _state.auto_escape() {
18 AutoEscape::Html => true,
19 AutoEscape::None => false,
20 _ => return minijinja::escape_formatter(out, _state, value),
21 };
22 if !auto_escape {
23 write!(out, "{value}").map_err(Error::from)?;
24 return Ok(());
25 }
26 if let Some(s) = value.as_str() {
27 write_jinja2_html(out, s).map_err(Error::from)?;
28 } else if value.is_undefined() || value.is_none() {
29 // emit nothing
30 } else {
31 let stringified = value.to_string();
32 write_jinja2_html(out, &stringified).map_err(Error::from)?;
33 }
34 Ok(())
35}
36
37fn write_jinja2_html(out: &mut Output, s: &str) -> std::fmt::Result {
38 let mut last = 0;
39 for (i, b) in s.bytes().enumerate() {
40 let escape = match b {
41 b'&' => "&",
42 b'<' => "<",
43 b'>' => ">",
44 b'"' => """,
45 b'\'' => "'",
46 _ => continue,
47 };
48 if last < i {
49 out.write_str(&s[last..i])?;
50 }
51 out.write_str(escape)?;
52 last = i + 1;
53 }
54 if last < s.len() {
55 out.write_str(&s[last..])?;
56 }
57 Ok(())
58}
59
60#[derive(Debug, Clone, Serialize)]
61pub struct RequestCtx {
62 pub url: String,
63 pub url_root: String,
64 pub base_url: String,
65}
66
67fn read_manifest(path: &Path) -> JsonValue {
68 let text = std::fs::read_to_string(path).unwrap_or_else(|_| "{}".to_string());
69 serde_json::from_str(&text).unwrap_or(JsonValue::Null)
70}
71
72fn lookup_asset(manifest: &JsonValue, entry: &str, kind: &str) -> String {
73 if let Some(chunk) = manifest.get(entry) {
74 if kind == "css" {
75 if let Some(css_arr) = chunk.get("css").and_then(|v| v.as_array()) {
76 if let Some(first) = css_arr.first().and_then(|v| v.as_str()) {
77 return format!("/static/{first}");
78 }
79 }
80 }
81 if let Some(file) = chunk.get("file").and_then(|v| v.as_str()) {
82 return format!("/static/{file}");
83 }
84 }
85 format!("/static/{entry}")
86}
87
88pub fn build_env(templates_dir: &Path, manifest_path: &Path) -> Environment<'static> {
89 let mut env = Environment::new();
90 env.set_loader(path_loader(templates_dir));
91 env.set_formatter(jinja2_html_formatter);
92
93 // Vite manifest:
94 // - debug builds re-read on every call so Vite watcher rebuilds show up immediately
95 // - release builds load once at startup and reuse the cached value
96 #[cfg(debug_assertions)]
97 {
98 let path = manifest_path.to_path_buf();
99 env.add_function(
100 "vite_asset",
101 move |entry: String, kind: Option<String>| -> Result<String, Error> {
102 let kind = kind.unwrap_or_else(|| "file".to_string());
103 let manifest = read_manifest(&path);
104 Ok(lookup_asset(&manifest, &entry, &kind))
105 },
106 );
107 }
108 #[cfg(not(debug_assertions))]
109 {
110 let manifest = read_manifest(manifest_path);
111 env.add_function(
112 "vite_asset",
113 move |entry: String, kind: Option<String>| -> Result<String, Error> {
114 let kind = kind.unwrap_or_else(|| "file".to_string());
115 Ok(lookup_asset(&manifest, &entry, &kind))
116 },
117 );
118 }
119
120 env.add_function("url_for", url_for);
121
122 env
123}
124
125fn url_for(state: &State, endpoint: String, kwargs: Kwargs) -> Result<String, Error> {
126 let take_str = |k: &str| -> Result<Option<String>, Error> {
127 let v: Option<Value> = kwargs.get(k).ok();
128 match v {
129 None => Ok(None),
130 Some(val) => {
131 if val.is_undefined() || val.is_none() {
132 Ok(None)
133 } else {
134 Ok(Some(val.to_string()))
135 }
136 }
137 }
138 };
139
140 let external: bool = kwargs.get("_external").unwrap_or(false);
141
142 let path = match endpoint.as_str() {
143 "index" => "/".to_string(),
144 "blog_index" => "/blog/".to_string(),
145 "blog_post" => {
146 let slug = take_str("slug")?.unwrap_or_default();
147 format!("/posts/{}/", urlencoding::encode(&slug))
148 }
149 "blog_post_pdf" => {
150 let slug = take_str("slug")?.unwrap_or_default();
151 format!("/posts/{}/pdf/", urlencoding::encode(&slug))
152 }
153 "blog_post_md" => {
154 let slug = take_str("slug")?.unwrap_or_default();
155 format!("/posts/{}/md/", urlencoding::encode(&slug))
156 }
157 "blog_tag" => {
158 let tag = take_str("tag")?.unwrap_or_default();
159 format!("/blog/tag/{}/", urlencoding::encode(&tag))
160 }
161 "blog_year" => {
162 let year = take_str("year")?.unwrap_or_default();
163 format!("/blog/year/{}/", urlencoding::encode(&year))
164 }
165 "content_images" => {
166 let filename = take_str("filename")?.unwrap_or_default();
167 format!("/content/images/{filename}")
168 }
169 "og_image" => {
170 let slug = take_str("slug")?.unwrap_or_default();
171 format!("/og/{slug}.svg")
172 }
173 "search" => "/search/".to_string(),
174 "static" => {
175 let filename = take_str("filename")?.unwrap_or_default();
176 format!("/static/{filename}")
177 }
178 other => {
179 return Err(Error::new(
180 ErrorKind::InvalidOperation,
181 format!("unknown route in url_for: {other}"),
182 ));
183 }
184 };
185
186 // Allow assert_all_used to pass with our optional kwargs.
187 kwargs.assert_all_used()?;
188
189 if external {
190 let url_root: Option<String> = state
191 .lookup("request")
192 .and_then(|req| req.get_attr("url_root").ok())
193 .and_then(|v| if v.is_undefined() { None } else { Some(v.to_string()) });
194 if let Some(mut root) = url_root {
195 if root.ends_with('/') {
196 root.pop();
197 }
198 return Ok(format!("{root}{path}"));
199 }
200 }
201 Ok(path)
202}