repos

blog.bythewood.me-rust

mirror archived upstream

Single-binary self-hosted Markdown blog on Rust axum: no database, live search, Typst PDF export, and strong SEO.

axumblogdockermarkdownminijinjarustself-hostedtypstvite

6.4 KB · 203 lines · Rust Raw History
  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'&' => "&amp;",
 42            b'<' => "&lt;",
 43            b'>' => "&gt;",
 44            b'"' => "&#34;",
 45            b'\'' => "&#39;",
 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}