Skip to main content

rust_web_server/template/
mod.rs

1//! Tera HTML template engine integration.
2//!
3//! Requires `features = ["tera"]`.
4//!
5//! # Quick start
6//!
7//! 1. Put templates in `templates/` (e.g. `templates/index.html`).
8//! 2. Call [`init`] (or [`init_from_env`]) once at startup.
9//! 3. Call [`render`] from any handler.
10//!
11//! ```rust,no_run
12//! use rust_web_server::template::{self, Context};
13//!
14//! // At startup:
15//! template::init("templates").unwrap();
16//!
17//! // In a handler:
18//! let mut ctx = Context::new();
19//! ctx.insert("title", "Home");
20//! ctx.insert("items", &["Rust", "rws", "Tera"]);
21//! let response = template::render("index.html", &ctx).unwrap();
22//! ```
23//!
24//! Templates use [Jinja2 syntax](https://keats.github.io/tera/docs/#templates):
25//! `{{ variable }}`, `{% if cond %}`, `{% for item in list %}`, `{% extends "base.html" %}`.
26//!
27//! # Hot reload
28//!
29//! Edit a template file and it's re-read from disk on the next [`reload`] —
30//! no server restart, unlike a code change. [`reload`] is wired into the same
31//! `SIGHUP` hook (`crate::config_reload::reload`) used for CORS, rate limits,
32//! and TLS certs, so `kill -HUP $(pidof rws)` — or your own trigger calling
33//! `config_reload::reload()` — picks up edited templates too:
34//!
35//! ```rust,no_run
36//! use rust_web_server::template;
37//!
38//! template::init("templates").unwrap();
39//! // ... edit templates/index.html on disk ...
40//! template::reload().unwrap(); // re-globs the directory, replacing all templates
41//! ```
42//!
43//! # View helpers (18.35.0)
44//!
45//! Every engine has these functions:
46//!
47//! - `url_for(name="posts.show", id=7)` — the path of a named route
48//!   ([`router::url_for`](crate::router::url_for)); arguments the pattern
49//!   doesn't have become the query string. Its output is percent-encoded,
50//!   so `| safe` is fine (Tera otherwise writes `/` as `/`, which
51//!   browsers read back as `/`).
52//! - `vite(entry="src/main.ts")` — the `<script>`/`<link>` tags of a Vite
53//!   entry from the installed [`vite`](crate::vite) helper (`| safe`).
54//! - `vite_asset(path="src/logo.svg")` — a built asset's URL (`| safe`).
55//!
56//! [`page_context`] starts a [`Context`] for a request with its flash
57//! messages (`flash`, `notice`, `alert`), the CSRF token (`csrf_token`,
58//! and `csrf_field`, a hidden `_csrf` input: `{{ csrf_field | safe }}`)
59//! and the request path (`path`):
60//!
61//! ```rust
62//! use rust_web_server::template::{page_context, TeraEngine};
63//!
64//! let engine = TeraEngine::from_raw(&[("form.html", r#"<form method="post" action="{{ url_for(name='doc_form_save') | safe }}">{{ csrf_field | safe }}</form>"#)]).unwrap();
65//! rust_web_server::router::name_route("doc_form_save", "/save");
66//! # let request = rust_web_server::request::Request { method: "GET".into(), request_uri: "/form".into(), http_version: "HTTP/1.1".into(), headers: vec![], body: Vec::new().into() };
67//! let html = engine.render("form.html", &page_context(&request)).unwrap();
68//! assert!(html.starts_with(r#"<form method="post" action="/save">"#));
69//! ```
70//!
71//! Only engines created from a glob ([`init`], [`init_from_env`],
72//! [`TeraEngine::from_dir`], [`TeraEngine::from_glob`]) can reload — an
73//! engine built from [`TeraEngine::from_raw`] has no directory to re-read,
74//! so [`TeraEngine::reload`] returns `Err` for it.
75
76#[cfg(test)]
77mod tests;
78
79use std::sync::{OnceLock, RwLock};
80
81pub use tera::Context;
82pub use tera::Value;
83
84use crate::core::New;
85use crate::mime_type::MimeType;
86use crate::range::Range;
87use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
88
89static ENGINE: OnceLock<RwLock<TeraEngine>> = OnceLock::new();
90
91/// A Tera template engine instance.
92#[derive(Debug)]
93pub struct TeraEngine {
94    inner: tera::Tera,
95    /// The glob this engine was built from, remembered so [`reload`][Self::reload]
96    /// can re-read it. `None` for [`from_raw`][Self::from_raw] engines.
97    glob: Option<String>,
98}
99
100impl TeraEngine {
101    /// Load all templates matching `glob_pattern`, e.g. `"templates/**/*"`.
102    ///
103    /// The pattern is passed directly to [`tera::Tera::new`].
104    pub fn from_glob(pattern: &str) -> Result<Self, String> {
105        let mut tera = tera::Tera::new(pattern)
106            .map_err(|e| format!("template engine init failed: {}", e))?;
107        register_helpers(&mut tera);
108        Ok(TeraEngine { inner: tera, glob: Some(pattern.to_string()) })
109    }
110
111    /// Load all templates from `dir`. Equivalent to `from_glob("dir/**/*")`.
112    pub fn from_dir(dir: &str) -> Result<Self, String> {
113        let pattern = format!("{}/**/*", dir.trim_end_matches('/'));
114        Self::from_glob(&pattern)
115    }
116
117    /// Build an engine from in-memory template strings — useful for testing.
118    ///
119    /// `templates` is a slice of `(name, content)` pairs. Has no glob, so
120    /// [`reload`][Self::reload] always returns `Err` for an engine built this way.
121    pub fn from_raw(templates: &[(&str, &str)]) -> Result<Self, String> {
122        let mut tera = tera::Tera::default();
123        register_helpers(&mut tera);
124        for (name, content) in templates {
125            tera.add_raw_template(name, content)
126                .map_err(|e| format!("failed to add template '{}': {}", name, e))?;
127        }
128        Ok(TeraEngine { inner: tera, glob: None })
129    }
130
131    /// Render `template_name` with `ctx` and return the output as a `String`.
132    pub fn render(&self, template_name: &str, ctx: &Context) -> Result<String, String> {
133        self.inner
134            .render(template_name, ctx)
135            .map_err(|e| format!("render '{}' failed: {}", template_name, e))
136    }
137
138    /// Whether the engine has a template named `name` (18.35.0).
139    pub fn has_template(&self, name: &str) -> bool {
140        self.inner.get_template_names().any(|n| n == name)
141    }
142
143    /// Render `template_name` and wrap the output in a `200 OK` HTML [`Response`].
144    pub fn response(&self, template_name: &str, ctx: &Context) -> Result<Response, String> {
145        let html = self.render(template_name, ctx)?;
146        Ok(html_response(html))
147    }
148
149    /// Re-read every template from the glob this engine was created with,
150    /// replacing all currently-loaded templates.
151    ///
152    /// Picks up edited content in existing files *and* newly added or removed
153    /// files matching the original glob — it's a full re-glob, not a diff.
154    ///
155    /// Builds the replacement set of templates in full before swapping it in,
156    /// rather than mutating the live template set in place (which is what
157    /// [`tera::Tera::full_reload`] does, and why this doesn't just call it) —
158    /// so a syntax error in one edited file fails the whole reload atomically
159    /// and this engine keeps serving its last-known-good templates, instead
160    /// of serving a half-reloaded, partially-broken set. Returns `Err` if:
161    /// - this engine was built with [`TeraEngine::from_raw`] (no glob to
162    ///   re-read), or
163    /// - any template now fails to parse (e.g. a syntax error mid-edit) or
164    ///   an `{% extends %}`/`{% import %}` chain no longer resolves.
165    pub fn reload(&mut self) -> Result<(), String> {
166        let pattern = self.glob.as_ref().ok_or_else(|| {
167            "cannot reload: this engine was built with TeraEngine::from_raw, \
168             which has no template directory to re-read"
169                .to_string()
170        })?;
171        let mut fresh = tera::Tera::new(pattern)
172            .map_err(|e| format!("template reload failed, previous templates are still active: {}", e))?;
173        register_helpers(&mut fresh);
174        self.inner = fresh;
175        Ok(())
176    }
177}
178
179// ── Global singleton ──────────────────────────────────────────────────────────
180
181/// Initialize the global template engine from a directory on disk.
182///
183/// Call once at startup before any handlers run. Subsequent calls return an `Err`.
184pub fn init(dir: &str) -> Result<(), String> {
185    let engine = TeraEngine::from_dir(dir)?;
186    ENGINE
187        .set(RwLock::new(engine))
188        .map_err(|_| "template engine already initialized".to_string())?;
189    // SIGHUP / `POST /admin/config/reload` re-glob the templates.
190    crate::config_reload::on_reload(reload_if_initialized);
191    Ok(())
192}
193
194/// Initialize the global engine from `RWS_CONFIG_TEMPLATE_DIR` (default: `"templates"`).
195pub fn init_from_env() -> Result<(), String> {
196    let dir = std::env::var(crate::entry_point::Config::RWS_CONFIG_TEMPLATE_DIR)
197        .unwrap_or_else(|_| {
198            crate::entry_point::Config::RWS_CONFIG_TEMPLATE_DIR_DEFAULT_VALUE.to_string()
199        });
200    init(&dir)
201}
202
203/// Acquire a read lock on the global template engine. Held only for the
204/// duration of one render — never store the guard across `.await` points or
205/// requests.
206///
207/// # Panics
208///
209/// Panics if [`init`] has not been called yet.
210pub fn global() -> std::sync::RwLockReadGuard<'static, TeraEngine> {
211    ENGINE
212        .get()
213        .expect("template engine not initialized — call template::init() at startup")
214        .read()
215        .unwrap_or_else(|e| e.into_inner())
216}
217
218/// A read lock on the global engine, or `None` before [`init`] (18.35.0).
219pub fn try_global() -> Option<std::sync::RwLockReadGuard<'static, TeraEngine>> {
220    ENGINE.get().map(|lock| lock.read().unwrap_or_else(|e| e.into_inner()))
221}
222
223/// Render `template_name` with `ctx` using the global engine and return a `200 OK` HTML response.
224///
225/// With `RWS_ENV=dev` the templates are re-read from disk first (18.35.0),
226/// so an edit shows on the next page load.
227pub fn render(template_name: &str, ctx: &Context) -> Result<Response, String> {
228    if reloads_on_render() {
229        reload_if_initialized();
230    }
231    global().response(template_name, ctx)
232}
233
234/// `RWS_ENV=dev`, read once.
235fn reloads_on_render() -> bool {
236    static RELOAD: OnceLock<bool> = OnceLock::new();
237    *RELOAD.get_or_init(|| crate::entry_point::RwsEnv::current().reloads_templates())
238}
239
240/// Reload the global template engine's templates from disk.
241///
242/// See the [module-level hot reload section](self#hot-reload). Wired into
243/// [`crate::config_reload::reload`] automatically, so `SIGHUP` (or your own
244/// trigger calling `config_reload::reload()`) reloads templates too — you
245/// don't need to call this directly unless you want reload on a different
246/// trigger.
247///
248/// # Errors
249///
250/// Returns `Err` if [`init`] hasn't been called yet, or if [`TeraEngine::reload`]
251/// fails (in which case the previously-loaded templates are still active and
252/// still being served — see [`TeraEngine::reload`]'s docs on why a bad edit
253/// can't corrupt the live template set).
254pub fn reload() -> Result<(), String> {
255    let lock = ENGINE
256        .get()
257        .ok_or_else(|| "template engine not initialized — call template::init() at startup".to_string())?;
258    let mut guard = lock.write().unwrap_or_else(|e| e.into_inner());
259    guard.reload()
260}
261
262/// Reload the global template engine if [`init`] has been called; silently
263/// does nothing otherwise.
264///
265/// Used by [`crate::config_reload::reload`] to safely fold template hot
266/// reload into the same `SIGHUP` hook regardless of whether the calling
267/// binary uses templates at all — most `tera`-feature-enabled binaries that
268/// never called `template::init` shouldn't see a reload error every time
269/// something else triggers a config reload.
270pub(crate) fn reload_if_initialized() {
271    if let Some(lock) = ENGINE.get() {
272        let mut guard = lock.write().unwrap_or_else(|e| e.into_inner());
273        if let Err(e) = guard.reload() {
274            eprintln!("[template] hot reload failed: {}", e);
275        }
276    }
277}
278
279// ── view helpers ──────────────────────────────────────────────────────────────
280
281/// A template argument as text: strings as they are, other values as JSON.
282fn arg_text(value: &Value) -> String {
283    match value {
284        Value::String(s) => s.clone(),
285        other => other.to_string(),
286    }
287}
288
289fn register_helpers(tera: &mut tera::Tera) {
290    tera.register_function("url_for", |args: &std::collections::HashMap<String, Value>| {
291        let name = args.get("name").and_then(Value::as_str).ok_or_else(|| tera::Error::msg("url_for: needs name=\"<route name>\""))?;
292        // Sorted, so the query string doesn't depend on hash order.
293        let mut params: Vec<(String, String)> = args.iter().filter(|(k, _)| k.as_str() != "name").map(|(k, v)| (k.clone(), arg_text(v))).collect();
294        params.sort();
295        let borrowed: Vec<(&str, &str)> = params.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
296        crate::router::url_for(name, &borrowed)
297            .map(Value::String)
298            .ok_or_else(|| tera::Error::msg(format!("url_for: no route named {name:?}, or a parameter of it is missing")))
299    });
300    tera.register_function("vite", |args: &std::collections::HashMap<String, Value>| {
301        let entry = args.get("entry").and_then(Value::as_str).ok_or_else(|| tera::Error::msg("vite: needs entry=\"<source path>\""))?;
302        crate::vite::tags(entry)
303            .map(Value::String)
304            .ok_or_else(|| tera::Error::msg(format!("vite: no entry {entry:?} (or no Vite helper installed: rust_web_server::vite::install)")))
305    });
306    tera.register_function("vite_asset", |args: &std::collections::HashMap<String, Value>| {
307        let path = args.get("path").and_then(Value::as_str).ok_or_else(|| tera::Error::msg("vite_asset: needs path=\"<source path>\""))?;
308        crate::vite::asset(path).map(Value::String).ok_or_else(|| tera::Error::msg(format!("vite_asset: no asset {path:?}")))
309    });
310}
311
312/// A [`Context`] for rendering a page in answer to `request`: `flash`
313/// (a list of `{kind, text}`), `notice` and `alert` (the first of each,
314/// when there is one), `csrf_token` and `csrf_field` (`csrf` feature; from
315/// [`CsrfLayer`](crate::csrf::CsrfLayer), else its `_csrf` cookie; empty
316/// without either) and `path`.
317pub fn page_context(request: &crate::request::Request) -> Context {
318    let mut ctx = Context::new();
319    let flash = crate::flash::Flash::read(request);
320    let messages: Vec<Value> = flash
321        .messages()
322        .iter()
323        .map(|m| serde_json::json!({ "kind": m.kind, "text": m.text }))
324        .collect();
325    ctx.insert("flash", &messages);
326    if let Some(notice) = flash.notice() {
327        ctx.insert("notice", notice);
328    }
329    if let Some(alert) = flash.alert() {
330        ctx.insert("alert", alert);
331    }
332    let token = csrf_token(request);
333    let field = if token.is_empty() {
334        String::new()
335    } else {
336        format!(r#"<input type="hidden" name="_csrf" value="{}">"#, token.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;"))
337    };
338    ctx.insert("csrf_token", &token);
339    ctx.insert("csrf_field", &field);
340    ctx.insert("path", request.request_uri.split('?').next().unwrap_or("/"));
341    ctx
342}
343
344#[cfg(feature = "csrf")]
345fn csrf_token(request: &crate::request::Request) -> String {
346    if let Some(token) = crate::csrf::CsrfToken::from_request(request) {
347        return token.value().to_string();
348    }
349    request
350        .header("cookie")
351        .and_then(|h| crate::cookie::CookieJar::parse(&h.value).get("_csrf").map(|c| c.value.clone()))
352        .unwrap_or_default()
353}
354
355#[cfg(not(feature = "csrf"))]
356fn csrf_token(_request: &crate::request::Request) -> String {
357    String::new()
358}
359
360// ── helpers ───────────────────────────────────────────────────────────────────
361
362fn html_response(html: String) -> Response {
363    let mut r = Response::new();
364    r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
365    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
366    r.content_range_list = vec![Range::get_content_range(
367        html.into_bytes(),
368        MimeType::TEXT_HTML.to_string(),
369    )];
370    r
371}