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('&', "&").replace('"', """).replace('<', "<"))
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}