Skip to main content

renox_core/
view.rs

1use std::path::{Component as PathPart, Path, PathBuf};
2use std::sync::Arc;
3
4use axum::body::Body;
5use axum::extract::{Request, State};
6use axum::http::header::{ACCEPT, CONTENT_LENGTH, CONTENT_TYPE};
7use axum::http::{HeaderValue, StatusCode};
8use axum::middleware::Next;
9use axum::response::{IntoResponse, Redirect, Response};
10use minijinja::value::{Rest, merge_maps};
11use minijinja::{Environment, ErrorKind, Value, context};
12use minijinja_autoreload::AutoReloader;
13use serde::Serialize;
14
15use crate::auth::CurrentUser;
16use crate::error::{ErrorPage, reason};
17use crate::storage::Storage;
18use crate::validation::ValidationError;
19use crate::{AppState, Config, Error, Htmx, RouteTable, Session, assets};
20
21/// Templates that ship with Renox. An app overrides one by creating a file
22/// with the same name in its views directory.
23pub(crate) const BUILTIN: &[(&str, &str)] = &[
24    ("renox/error.html", include_str!("../views/error.html")),
25    (
26        "renox/pagination.html",
27        include_str!("../views/pagination.html"),
28    ),
29    (
30        "renox/auth/layout.html",
31        include_str!("../views/auth/layout.html"),
32    ),
33    (
34        "renox/auth/login.html",
35        include_str!("../views/auth/login.html"),
36    ),
37    (
38        "renox/auth/register.html",
39        include_str!("../views/auth/register.html"),
40    ),
41    (
42        "renox/auth/forgot-password.html",
43        include_str!("../views/auth/forgot-password.html"),
44    ),
45    (
46        "renox/auth/reset-password.html",
47        include_str!("../views/auth/reset-password.html"),
48    ),
49    (
50        "renox/auth/verify-email.html",
51        include_str!("../views/auth/verify-email.html"),
52    ),
53    (
54        "renox/mail/layout.html",
55        include_str!("../views/mail/layout.html"),
56    ),
57    (
58        "renox/mail/button.html",
59        include_str!("../views/mail/button.html"),
60    ),
61    ("renox/live.html", include_str!("../views/live.html")),
62    ("renox/ui.html", include_str!("../views/ui.html")),
63    (
64        "renox/import_report.html",
65        include_str!("../views/import_report.html"),
66    ),
67    ("renox/grid.html", include_str!("../views/grid.html")),
68    (
69        "renox/grid_print.html",
70        include_str!("../views/grid_print.html"),
71    ),
72    ("renox/debug.html", include_str!("../views/debug.html")),
73    (
74        "renox/notifications.html",
75        include_str!("../views/notifications.html"),
76    ),
77    (
78        "renox/queue/dashboard.html",
79        include_str!("../views/queue/dashboard.html"),
80    ),
81    (
82        "renox/mail/components.html",
83        include_str!("../views/mail/components.html"),
84    ),
85    (
86        "renox/auth/account.html",
87        include_str!("../views/auth/account.html"),
88    ),
89    (
90        "renox/auth/account_sections.html",
91        include_str!("../views/auth/account_sections.html"),
92    ),
93    (
94        "renox/auth/confirm-password.html",
95        include_str!("../views/auth/confirm-password.html"),
96    ),
97    (
98        "renox/mail/auth/reset-password.html",
99        include_str!("../views/mail/auth/reset-password.html"),
100    ),
101    (
102        "renox/mail/auth/reset-password.txt",
103        include_str!("../views/mail/auth/reset-password.txt"),
104    ),
105    (
106        "renox/mail/auth/verify-email.html",
107        include_str!("../views/mail/auth/verify-email.html"),
108    ),
109    (
110        "renox/mail/auth/verify-email.txt",
111        include_str!("../views/mail/auth/verify-email.txt"),
112    ),
113];
114
115/// The template engine (MiniJinja), reading from `VIEWS_PATH`.
116///
117/// Templates are reloaded when they change while `APP_DEBUG` is on.
118#[derive(Clone)]
119pub struct Views {
120    reloader: Arc<AutoReloader>,
121    /// `APP_DEBUG`: also warn about values hidden by imported macros.
122    debug: bool,
123    /// `VIEWS_PATH`, where the app's templates live.
124    dir: PathBuf,
125    /// Templates compiled into the binary, used instead of `dir` when given.
126    embedded: Option<&'static [(&'static str, &'static str)]>,
127    /// The plugins' components, next to the built-in ones.
128    plugin_components: Arc<Vec<crate::components::Contract>>,
129    /// The page structs registered with `App::view_data`, checked by `view:check`.
130    typed: Arc<Vec<TypedView>>,
131    /// The keys of `App::share` / `Registry::share` values, which every view gets.
132    shared: Arc<Vec<String>>,
133}
134
135/// A page struct registered with `App::view_data`: its template and fields.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub(crate) struct TypedView {
138    /// The struct's name, without its module path.
139    pub name: String,
140    /// `ViewData::TEMPLATE`.
141    pub template: &'static str,
142    /// `ViewData::FIELDS`.
143    pub fields: &'static [&'static str],
144}
145
146impl TypedView {
147    pub(crate) fn of<T: ViewData>() -> Self {
148        Self {
149            name: short_type_name(std::any::type_name::<T>()),
150            template: T::TEMPLATE,
151            fields: T::FIELDS,
152        }
153    }
154}
155
156/// `app::pages::Suppliers<'a>` → `Suppliers<'a>`: the last path segment, generics kept.
157fn short_type_name(full: &str) -> String {
158    let base = full.split('<').next().unwrap_or(full);
159    let start = base.rfind("::").map_or(0, |i| i + 2);
160    full[start..].to_owned()
161}
162
163/// The data of one page: a struct whose fields are its template's variables, so the
164/// compiler checks the handler's side and `view:check` the template's (the variables it
165/// uses that the struct lacks). Derive it with `#[derive(ViewData)]` and
166/// `#[view(template = "…")]`, render it with [`View::typed`], and register it with
167/// `App::view_data::<T>()` (`Registry::view_data` in a module) so `view:check` knows it.
168///
169/// ```
170/// # use renox::prelude::*;
171/// #[derive(serde::Serialize, ViewData)]
172/// #[view(template = "suppliers/index.html")]
173/// struct SupplierList {
174///     suppliers: Vec<String>,
175///     #[serde(rename = "total")]
176///     count: usize,
177/// }
178///
179/// assert_eq!(SupplierList::TEMPLATE, "suppliers/index.html");
180/// assert_eq!(SupplierList::FIELDS, ["suppliers", "total"]);
181///
182/// async fn index() -> View {
183///     View::typed(SupplierList { suppliers: vec!["Velo".into()], count: 1 })
184/// }
185/// let app = App::new().view_data::<SupplierList>();
186/// ```
187///
188/// The check reads what MiniJinja loads (after the `rx-*` components are compiled) and
189/// follows neither `extends` nor `include` nor imported macros: a variable used only in
190/// the layout or a partial isn't checked. Besides the struct's fields, a template may
191/// use the request globals (`auth`, `request`, `t`, `old`…), the environment's
192/// functions and globals, the `App::share` keys and the names it defines itself.
193pub trait ViewData: Serialize {
194    /// The template the page renders, relative to `VIEWS_PATH`.
195    const TEMPLATE: &'static str;
196    /// The names the template sees: the fields as serialized (`#[serde(rename)]` applied,
197    /// skipped fields left out).
198    const FIELDS: &'static [&'static str];
199}
200
201/// One template that does not compile, found by [`Views::check`].
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub(crate) struct ViewProblem {
204    /// The template's name, e.g. `products/index.html`.
205    pub template: String,
206    /// The line the error is on, when the compiler knows it.
207    pub line: Option<usize>,
208    /// What is wrong.
209    pub message: String,
210}
211
212impl std::fmt::Display for ViewProblem {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        match self.line {
215            Some(line) => write!(f, "{}:{line}: {}", self.template, self.message),
216            None => write!(f, "{}: {}", self.template, self.message),
217        }
218    }
219}
220
221/// Every `*.html` and `*.txt` file under `dir`, as names relative to it with
222/// `/` separators. Dotfiles and dot directories are skipped.
223fn walk(dir: &Path, prefix: &str, out: &mut Vec<String>) {
224    let Ok(entries) = std::fs::read_dir(dir) else {
225        return;
226    };
227    for entry in entries.flatten() {
228        let file_name = entry.file_name().to_string_lossy().into_owned();
229        if file_name.starts_with('.') {
230            continue;
231        }
232        let name = if prefix.is_empty() {
233            file_name
234        } else {
235            format!("{prefix}/{file_name}")
236        };
237        let path = entry.path();
238        if path.is_dir() {
239            walk(&path, &name, out);
240        } else if name.ends_with(".html") || name.ends_with(".txt") {
241            out.push(name);
242        }
243    }
244}
245
246/// What an `App::share` function knows about the request being rendered.
247#[non_exhaustive]
248#[derive(Clone)]
249pub struct ViewContext {
250    /// The application state.
251    pub state: AppState,
252    /// The logged-in user, if any.
253    pub user: Option<Arc<crate::auth::User>>,
254    /// The request's language, e.g. `en`.
255    pub locale: String,
256    /// The request's path, e.g. `/products`.
257    pub path: String,
258    /// The request's session (absent only where no session middleware ran),
259    /// e.g. a guest's cart kept there:
260    ///
261    /// ```
262    /// # use renox::prelude::*;
263    /// # let _ =
264    /// App::new().share("cart_count", |ctx: renox::view::ViewContext| async move {
265    ///     let cart: Vec<i64> = ctx.session.and_then(|s| s.get("cart")).unwrap_or_default();
266    ///     Ok(cart.len())
267    /// })
268    /// # ;
269    /// ```
270    pub session: Option<crate::Session>,
271}
272
273pub(crate) type ShareFn = Arc<
274    dyn Fn(
275            ViewContext,
276        )
277            -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::Result<Value>> + Send>>
278        + Send
279        + Sync,
280>;
281
282pub(crate) fn share_fn<F, Fut, T>(compute: F) -> ShareFn
283where
284    F: Fn(ViewContext) -> Fut + Send + Sync + 'static,
285    Fut: std::future::Future<Output = crate::Result<T>> + Send + 'static,
286    T: Serialize,
287{
288    let compute = Arc::new(compute);
289    Arc::new(move |ctx| {
290        let compute = compute.clone();
291        Box::pin(async move { Ok(Value::from_serialize(compute(ctx).await?)) })
292    })
293}
294
295/// The names `{% from … import … %}` and `{% import … as … %}` bind in
296/// `source` that are also keys of one of `values` (the handler's context, the
297/// shared values): inside the template those names are the macros.
298fn hidden_by_imports(source: &str, values: &[&Value]) -> Vec<String> {
299    static FROM: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
300        regex::Regex::new(
301            r#"(?s)\{%[-+]?\s*from\s+(?:"[^"]*"|'[^']*'|\S+)\s+import\s+(.*?)\s*[-+]?%\}"#,
302        )
303        .expect("valid regex")
304    });
305    static IMPORT: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
306        regex::Regex::new(r#"\{%[-+]?\s*import\s+(?:"[^"]*"|'[^']*'|\S+)\s+as\s+(\w+)"#)
307            .expect("valid regex")
308    });
309    let mut names: Vec<String> = Vec::new();
310    for found in FROM.captures_iter(source) {
311        let list = found[1]
312            .trim_end_matches("without context")
313            .trim_end_matches("with context");
314        for item in list.split(',') {
315            let name = item.rsplit(" as ").next().unwrap_or(item).trim();
316            if !name.is_empty() {
317                names.push(name.to_owned());
318            }
319        }
320    }
321    names.extend(
322        IMPORT
323            .captures_iter(source)
324            .map(|found| found[1].to_owned()),
325    );
326    names.retain(|name| {
327        values
328            .iter()
329            .any(|value| value.get_attr(name).is_ok_and(|v| !v.is_undefined()))
330    });
331    names.sort();
332    names.dedup();
333    names
334}
335
336/// Adds functions, filters or globals to the template environment; see
337/// `App::templates`.
338pub(crate) type TemplateHook = Arc<dyn Fn(&mut Environment<'static>) + Send + Sync>;
339
340thread_local! {
341    /// The components known while template hooks run, for [`add_template`].
342    static HOOK_COMPONENTS: std::cell::RefCell<Option<Arc<Vec<crate::components::Contract>>>> =
343        const { std::cell::RefCell::new(None) };
344}
345
346/// Makes the registered components visible to [`add_template`] until dropped.
347struct HookScope(Option<Arc<Vec<crate::components::Contract>>>);
348
349impl HookScope {
350    fn enter(contracts: Arc<Vec<crate::components::Contract>>) -> Self {
351        Self(HOOK_COMPONENTS.with(|c| c.borrow_mut().replace(contracts)))
352    }
353}
354
355impl Drop for HookScope {
356    fn drop(&mut self) {
357        let previous = self.0.take();
358        HOOK_COMPONENTS.with(|c| *c.borrow_mut() = previous);
359    }
360}
361
362pub use crate::components::{Component, Prop};
363
364/// Adds the template `name` to `env` after compiling its component tags
365/// (`<rx-…>`, `rx-if`, `rx-for`), as the app's own views are. For modules and
366/// plugins, inside [`Registry::templates`](crate::Registry::templates), where
367/// the components other modules registered are known too. A mistake in the
368/// source is an error naming the template and line.
369///
370/// ```
371/// use renox::view::add_template;
372///
373/// let mut env = minijinja::Environment::new();
374/// add_template(&mut env, "hi.html", "<p>{{ name }}</p>").unwrap();
375/// ```
376pub fn add_template(
377    env: &mut Environment<'static>,
378    name: impl Into<String>,
379    source: impl Into<String>,
380) -> Result<(), minijinja::Error> {
381    let name = name.into();
382    let source = source.into();
383    let contracts = HOOK_COMPONENTS
384        .with(|c| c.borrow().clone())
385        .unwrap_or_else(|| Arc::new(crate::components::BUILTIN.to_vec()));
386    let catalog = crate::components::Catalog {
387        contracts: &contracts,
388        lookup: &|_| None,
389    };
390    let compiled = crate::components::compile(&name, &source, &catalog).map_err(|e| {
391        minijinja::Error::new(
392            ErrorKind::SyntaxError,
393            format!("{name}:{}: {}", e.line, e.message),
394        )
395    })?;
396    env.add_template_owned(name, compiled)
397}
398
399impl Views {
400    /// `embedded`: templates compiled into the binary, used instead of
401    /// `VIEWS_PATH` when given.
402    #[allow(clippy::too_many_arguments)]
403    pub(crate) fn new(
404        config: &Config,
405        routes: Arc<RouteTable>,
406        storage: Storage,
407        embedded: Option<&'static [(&'static str, &'static str)]>,
408        hooks: Arc<Vec<TemplateHook>>,
409        components: Arc<Vec<crate::components::Contract>>,
410        zone: crate::timezone::Zone,
411        versions: Arc<crate::embedded::AssetVersions>,
412    ) -> Self {
413        let plugin_components = components.clone();
414        let dir = config.views_path.clone();
415        let watch = config.debug && embedded.is_none() && dir.is_dir();
416        let debug = config.debug;
417        let currency = config.currency.clone();
418        let reloader = AutoReloader::new(move |notifier| {
419            let mut env = Environment::new();
420            env.set_formatter(format_value);
421            // While developing, printing a misspelled variable is an error
422            // instead of an empty string (`{% if x %}` on a missing one is fine).
423            if debug {
424                env.set_undefined_behavior(minijinja::UndefinedBehavior::SemiStrict);
425            }
426            let loader_dir = dir.clone();
427            let contracts: Arc<Vec<crate::components::Contract>> = Arc::new(
428                crate::components::BUILTIN
429                    .iter()
430                    .chain(components.iter())
431                    .copied()
432                    .collect(),
433            );
434            let loader_contracts = contracts.clone();
435            env.set_loader(move |name| {
436                let Some(src) = load(&loader_dir, embedded, name)? else {
437                    return Ok(None);
438                };
439                let lookup = |n: &str| load(&loader_dir, embedded, n).ok().flatten();
440                let catalog = crate::components::Catalog {
441                    contracts: &loader_contracts,
442                    lookup: &lookup,
443                };
444                crate::components::compile(name, &src, &catalog)
445                    .map(Some)
446                    .map_err(|e| {
447                        minijinja::Error::new(
448                            ErrorKind::SyntaxError,
449                            format!("{name}:{}: {}", e.line, e.message),
450                        )
451                    })
452            });
453
454            let routes = routes.clone();
455            env.add_function(
456                "route",
457                move |name: String, params: Rest<Value>| -> Result<Value, minijinja::Error> {
458                    // `route('products.index', page=2)`: named arguments are
459                    // the query string (`?page=2`).
460                    let (query, params): (Vec<&Value>, Vec<&Value>) =
461                        params.iter().partition(|p| p.is_kwargs());
462                    let params: Vec<&dyn std::fmt::Display> = params
463                        .iter()
464                        .map(|p| *p as &dyn std::fmt::Display)
465                        .collect();
466                    // Percent-encoded, so safe to use in HTML without escaping `/`.
467                    let mut url = routes.url(&name, &params).map_err(|err| {
468                        minijinja::Error::new(ErrorKind::InvalidOperation, err.to_string())
469                    })?;
470                    if let Some(kwargs) = query.first() {
471                        let mut pairs = form_urlencoded::Serializer::new(String::new());
472                        for key in kwargs.try_iter()? {
473                            let value = kwargs.get_item(&key)?;
474                            if value.is_none() || value.is_undefined() {
475                                continue;
476                            }
477                            pairs.append_pair(&key.to_string(), &value.to_string());
478                        }
479                        let pairs = pairs.finish();
480                        if !pairs.is_empty() {
481                            url.push(if url.contains('?') { '&' } else { '?' });
482                            url.push_str(&pairs.replace('&', "&amp;"));
483                        }
484                    }
485                    Ok(Value::from_safe_string(url))
486                },
487            );
488            // The current URL's query string with `page` set to `page`, for
489            // pagination links that keep filters such as `?q=coffee`.
490            env.add_function("page_url", |state: &minijinja::State, page: u32| -> Value {
491                let query = state
492                    .lookup("request")
493                    .and_then(|request| request.get_attr("query").ok())
494                    .and_then(|query| query.as_str().map(str::to_owned))
495                    .unwrap_or_default();
496                let mut url = form_urlencoded::Serializer::new(String::new());
497                for (key, value) in form_urlencoded::parse(query.as_bytes()) {
498                    if key != "page" {
499                        url.append_pair(&key, &value);
500                    }
501                }
502                url.append_pair("page", &page.to_string());
503                Value::from(format!("?{}", url.finish()))
504            });
505            // The current URL's query string with some keys set (or removed
506            // with `none`), and `page` dropped: `query_with(period="7d")`
507            // for filters that keep the others.
508            env.add_function(
509                "query_with",
510                |state: &minijinja::State,
511                 kwargs: minijinja::value::Kwargs|
512                 -> Result<Value, minijinja::Error> {
513                    let query = state
514                        .lookup("request")
515                        .and_then(|request| request.get_attr("query").ok())
516                        .and_then(|query| query.as_str().map(str::to_owned))
517                        .unwrap_or_default();
518                    let keys: Vec<String> = kwargs.args().map(str::to_owned).collect();
519                    let mut url = form_urlencoded::Serializer::new(String::new());
520                    for (key, value) in form_urlencoded::parse(query.as_bytes()) {
521                        if key != "page" && !keys.iter().any(|k| *k == key) {
522                            url.append_pair(&key, &value);
523                        }
524                    }
525                    for key in &keys {
526                        let value: Value = kwargs.get(key)?;
527                        if !value.is_none() && !value.is_undefined() {
528                            url.append_pair(key, &value.to_string());
529                        }
530                    }
531                    kwargs.assert_all_used()?;
532                    Ok(Value::from(format!("?{}", url.finish())))
533                },
534            );
535            // The current URL's query string as hidden inputs, without `page`
536            // and the keys named: `query_fields("period", "from", "to")`, so
537            // a GET form keeps the page's other filters.
538            env.add_function(
539                "query_fields",
540                |state: &minijinja::State, except: minijinja::value::Rest<String>| -> Value {
541                    let query = state
542                        .lookup("request")
543                        .and_then(|request| request.get_attr("query").ok())
544                        .and_then(|query| query.as_str().map(str::to_owned))
545                        .unwrap_or_default();
546                    let mut html = String::new();
547                    for (key, value) in form_urlencoded::parse(query.as_bytes()) {
548                        if key != "page" && !except.iter().any(|k| *k == key) {
549                            html.push_str(&format!(
550                                r#"<input type="hidden" name="{}" value="{}">"#,
551                                crate::toast::escape(&key),
552                                crate::toast::escape(&value)
553                            ));
554                        }
555                    }
556                    Value::from_safe_string(html)
557                },
558            );
559            env.add_function("method_field", |method: String| {
560                let method: String = method.chars().filter(char::is_ascii_alphabetic).collect();
561                Value::from_safe_string(format!(
562                    "<input type=\"hidden\" name=\"{}\" value=\"{}\">",
563                    crate::method::METHOD_FIELD,
564                    method.to_ascii_uppercase()
565                ))
566            });
567            // `/app.css?v=1a2b3c4d`: a new URL whenever the file changes.
568            let versions = versions.clone();
569            env.add_function("asset", move |path: String| {
570                let mut url = String::from("/");
571                crate::routing::encode(&mut url, path.trim_start_matches('/'), true);
572                if let Some(version) = versions.version(&path) {
573                    url.push_str("?v=");
574                    url.push_str(&version);
575                }
576                Value::from_safe_string(url)
577            });
578            let storage = storage.clone();
579            env.add_function("storage_url", move |key: String| {
580                Value::from_safe_string(storage.url(&key))
581            });
582            // The request's values for components: `old`, `error`, `t`, `can`,
583            // `csrf_field`, `auth`, `request`, `flash`… work inside imported
584            // macros too, not only in the rendered template.
585            for name in REQUEST_GLOBALS {
586                env.add_global(*name, Value::from_object(RequestGlobal(name)));
587            }
588            crate::view_stack::register(&mut env);
589            env.add_function("renox_ui", |kwargs: minijinja::value::Kwargs| {
590                let styles: Option<bool> = kwargs.get("styles")?;
591                kwargs.assert_all_used()?;
592                Ok::<_, minijinja::Error>(Value::from_safe_string(crate::assets::ui_tags(
593                    styles.unwrap_or(true),
594                )))
595            });
596            env.add_function("renox_calendar", || {
597                Value::from_safe_string(crate::assets::calendar_tags())
598            });
599            // Until the live component script ships (#435): nothing.
600            env.add_function("renox_live", || {
601                Value::from_safe_string(crate::assets::live_tags())
602            });
603            // The kit's `icon(…)` macro: a Lucide icon as inline SVG.
604            env.add_function(
605                "renox_icon",
606                |name: String, kwargs: minijinja::value::Kwargs| {
607                    let size: Option<u32> = kwargs.get("size")?;
608                    let label: Option<String> = kwargs.get("label")?;
609                    let class: Option<String> = kwargs.get("class")?;
610                    kwargs.assert_all_used()?;
611                    Ok::<_, minijinja::Error>(Value::from_safe_string(crate::icons::svg(
612                        &name,
613                        size.unwrap_or(20),
614                        label.as_deref(),
615                        class.as_deref(),
616                    )))
617                },
618            );
619            env.add_function("renox_grid", || {
620                Value::from_safe_string(crate::assets::grid_tags())
621            });
622            env.add_function("sparkline", crate::view_filters::sparkline);
623            env.add_filter("number", crate::view_filters::number);
624            env.add_filter("date", crate::view_filters::date(zone));
625            env.add_filter("since", crate::view_filters::since(zone));
626            env.add_filter("money", crate::view_filters::money(currency.clone()));
627            env.add_filter("words", crate::view_filters::words);
628            env.add_filter("markdown", crate::view_filters::markdown);
629            env.add_function("chart", crate::chart::chart(currency.clone()));
630            env.add_function("class_names", crate::view_filters::class_names);
631            env.add_function("rx_attr", crate::view_filters::rx_attr);
632            env.add_function("rx_attr_value", crate::view_filters::attr_value);
633            // The app's own functions and filters (`App::templates`).
634            let _scope = HookScope::enter(contracts.clone());
635            for hook in hooks.iter() {
636                hook(&mut env);
637            }
638
639            if watch {
640                notifier.watch_path(&dir, true);
641            }
642            Ok(env)
643        });
644        Self {
645            reloader: Arc::new(reloader),
646            debug,
647            dir: config.views_path.clone(),
648            embedded,
649            plugin_components,
650            typed: Arc::new(Vec::new()),
651            shared: Arc::new(Vec::new()),
652        }
653    }
654
655    /// The name of every template: the app's files (or the embedded ones),
656    /// the built-in ones and those added to the environment, sorted.
657    pub(crate) fn names(&self) -> Vec<String> {
658        let mut names = Vec::new();
659        match self.embedded {
660            Some(files) => names.extend(files.iter().map(|(name, _)| (*name).to_owned())),
661            None => walk(&self.dir, "", &mut names),
662        }
663        names.extend(BUILTIN.iter().map(|(name, _)| (*name).to_owned()));
664        if let Ok(env) = self.reloader.acquire_env() {
665            names.extend(env.templates().map(|(name, _)| name.to_owned()));
666        }
667        names.sort();
668        names.dedup();
669        names
670    }
671
672    /// The editor data file (VS Code's `html.customData`): the built-in and plugin components
673    /// and the app's own (`components/*.html` files that declare `<rx-props>`).
674    pub(crate) fn custom_data(&self) -> serde_json::Value {
675        let contracts: Vec<crate::components::Contract> = crate::components::BUILTIN
676            .iter()
677            .chain(self.plugin_components.iter())
678            .copied()
679            .collect();
680        let sources: Vec<(String, String)> = self
681            .names()
682            .into_iter()
683            .filter(|n| n.starts_with("components/") && n.ends_with(".html"))
684            .filter_map(|n| {
685                let src = load(&self.dir, self.embedded, &n).ok().flatten()?;
686                src.contains("<rx-props").then_some((n, src))
687            })
688            .collect();
689        crate::components::custom_data_for(&contracts, &sources)
690    }
691
692    /// Compiles every template and returns those that fail, in name order.
693    pub(crate) fn check(&self, routes: &RouteTable) -> Vec<ViewProblem> {
694        let names = self.names();
695        let env = match self.reloader.acquire_env() {
696            Ok(env) => env,
697            Err(err) => {
698                return vec![ViewProblem {
699                    template: String::new(),
700                    line: None,
701                    message: err.to_string(),
702                }];
703            }
704        };
705        let mut problems = Vec::new();
706        let known: std::collections::HashSet<&str> = names.iter().map(String::as_str).collect();
707        for name in &names {
708            match env.get_template(name) {
709                Err(err) => problems.push(problem(name, &err)),
710                // The framework's own templates show example names in their docs.
711                Ok(template) if !name.starts_with("renox/") => {
712                    check_route_names(name, template.source(), routes, &mut problems);
713                    check_includes(name, template.source(), &known, &mut problems);
714                }
715                Ok(_) => {}
716            }
717        }
718        for typed in self.typed.iter() {
719            self.check_typed(&env, typed, &mut problems);
720        }
721        problems
722    }
723
724    /// Records the page structs (`App::view_data`) and the shared keys, for [`Views::check`].
725    pub(crate) fn with_typed(mut self, typed: Vec<TypedView>, shared: Vec<String>) -> Self {
726        self.typed = Arc::new(typed);
727        self.shared = Arc::new(shared);
728        self
729    }
730
731    /// Reports the variables `typed`'s template uses that its struct doesn't have. The
732    /// template is the one MiniJinja loads, after the `rx-*` components are compiled.
733    fn check_typed(
734        &self,
735        env: &minijinja::Environment<'_>,
736        typed: &TypedView,
737        problems: &mut Vec<ViewProblem>,
738    ) {
739        let template = match env.get_template(typed.template) {
740            Ok(template) => template,
741            // A template that doesn't compile is already reported.
742            Err(err) if err.kind() != ErrorKind::TemplateNotFound => return,
743            Err(_) => {
744                problems.push(ViewProblem {
745                    template: typed.template.to_owned(),
746                    line: None,
747                    message: format!("not found, but {} renders it", typed.name),
748                });
749                return;
750            }
751        };
752        let globals: std::collections::HashSet<&str> =
753            env.globals().map(|(name, _)| name).collect();
754        let mut unknown: Vec<String> = template
755            .undeclared_variables(false)
756            .into_iter()
757            .filter(|var| {
758                !typed.fields.contains(&var.as_str())
759                    && !REQUEST_GLOBALS.contains(&var.as_str())
760                    && !globals.contains(var.as_str())
761                    && !self.shared.iter().any(|key| key == var)
762                    // Names the component compiler makes up (`__rx_slot_1`).
763                    && !var.starts_with("__rx")
764            })
765            .collect();
766        unknown.sort();
767        for var in unknown {
768            problems.push(ViewProblem {
769                template: typed.template.to_owned(),
770                line: first_use(template.source(), &var),
771                message: format!("\"{var}\" isn't a field of {}", typed.name),
772            });
773        }
774    }
775
776    /// Whether a template of this name exists (the app's or a built-in).
777    pub fn exists(&self, name: &str) -> bool {
778        self.reloader
779            .acquire_env()
780            .is_ok_and(|env| env.get_template(name).is_ok())
781    }
782
783    /// Renders a template with the given context and no request globals.
784    pub fn render(&self, name: &str, ctx: impl Serialize) -> anyhow::Result<String> {
785        let env = self.reloader.acquire_env()?;
786        Ok(env.get_template(name)?.render(ctx)?)
787    }
788
789    fn render_view(
790        &self,
791        view: &View,
792        shared: Value,
793        globals: Value,
794        htmx: &Htmx,
795    ) -> anyhow::Result<String> {
796        let env = self.reloader.acquire_env()?;
797        let template = env.get_template(&view.name)?;
798        // Components (macros imported from other templates) don't see this
799        // context; they reach the same values through `RequestGlobal`s.
800        let _current = CurrentGlobals::set(globals.clone());
801        // The last map wins: shared values, then the handler's, then Renox's.
802        // A value named like a macro the template imports is hidden by the
803        // macro, which fails far from the cause (#312): say so while debugging.
804        let hidden = if self.debug {
805            hidden_by_imports(template.source(), &[&shared, &view.ctx])
806        } else {
807            Vec::new()
808        };
809        for name in &hidden {
810            tracing::warn!(
811                template = %view.name,
812                name = %name,
813                "the view gets a value named like a macro the template imports; the macro hides it (rename one)"
814            );
815        }
816        let ctx = merge_maps([shared, view.ctx.clone(), globals]);
817        let stacks = crate::view_stack::Scope::begin();
818        let rendered = match &view.fragment {
819            Some(block) if htmx.wants_fragment() => (|| {
820                let mut captured = template.render_captured_to(ctx, std::io::sink())?;
821                let mut out = captured.with_state_mut(|state| state.render_block(block))?;
822                for extra in &view.also {
823                    out.push_str(&captured.with_state_mut(|state| state.render_block(extra))?);
824                }
825                Ok::<_, minijinja::Error>(out)
826            })(),
827            _ => template.render(ctx),
828        };
829        let html = rendered.map_err(|err| {
830            let err = anyhow::Error::from(err);
831            match hidden.as_slice() {
832                [] => err,
833                names => err.context(format!(
834                    "{} imports a macro named like a value the view gets ({}): \
835                     inside the template the name is the macro; rename the value or import the macro `as` another name",
836                    view.name,
837                    names.join(", ")
838                )),
839            }
840        })?;
841        Ok(stacks.finish(html))
842    }
843
844    /// The error page: the app's `errors/{status}.html`, else its
845    /// `errors/default.html`, else Renox's. With `globals` (a request's), an
846    /// app's page can extend its layout; a page that fails falls back to
847    /// Renox's, so an error in the layout doesn't hide the first error.
848    fn render_error(
849        &self,
850        page: &ErrorPage,
851        debug: bool,
852        request: &str,
853        globals: Option<Value>,
854    ) -> anyhow::Result<String> {
855        let env = self.reloader.acquire_env()?;
856        let ctx = context! {
857            status => page.status.as_u16(),
858            reason => reason(page.status),
859            detail => page.shown_detail(debug),
860            // Only while developing: what was asked, and where a template failed.
861            debug => debug,
862            request_line => debug.then_some(request),
863            template => page.template.as_deref().filter(|_| debug),
864        };
865        for name in [
866            format!("errors/{}.html", page.status.as_u16()),
867            "errors/default.html".to_owned(),
868        ] {
869            let template = match env.get_template(&name) {
870                Ok(template) => template,
871                Err(err) if err.kind() == ErrorKind::TemplateNotFound => continue,
872                // One that doesn't parse fails like one that doesn't render.
873                Err(err) => {
874                    tracing::error!(error = ?err, template = %name, "the error page failed; showing Renox's");
875                    break;
876                }
877            };
878            let rendered = match &globals {
879                Some(globals) => {
880                    let _current = CurrentGlobals::set(globals.clone());
881                    let stacks = crate::view_stack::Scope::begin();
882                    template
883                        .render(merge_maps([globals.clone(), ctx.clone()]))
884                        .map(|html| stacks.finish(html))
885                }
886                None => template.render(ctx.clone()),
887            };
888            match rendered {
889                Ok(html) => return Ok(html),
890                Err(err) => {
891                    tracing::error!(error = ?err, template = %name, "the error page failed; showing Renox's");
892                    break;
893                }
894            }
895        }
896        Ok(env.get_template("renox/error.html")?.render(ctx)?)
897    }
898}
899
900/// The template a response was rendered from.
901#[derive(Debug, Clone)]
902pub(crate) struct RenderedView(pub String);
903
904/// The globals Renox gives each rendered page (see `globals`), which
905/// components reach through the environment.
906pub(crate) const REQUEST_GLOBALS: &[&str] = &[
907    "app",
908    "auth",
909    "t",
910    "can",
911    "request",
912    "route_is",
913    "csrf_token",
914    "csrf_field",
915    "flash",
916    "errors",
917    "errors_in",
918    "error",
919    "old",
920    "has_old",
921    "renox_head",
922    "seo",
923    "csp_nonce",
924    "toasts",
925    "once",
926];
927
928thread_local! {
929    /// The globals of the page being rendered on this thread.
930    static CURRENT: std::cell::RefCell<Option<Value>> = const { std::cell::RefCell::new(None) };
931}
932
933/// Makes `globals` the current page's while alive (rendering is synchronous).
934struct CurrentGlobals(Option<Value>);
935
936impl CurrentGlobals {
937    fn set(globals: Value) -> Self {
938        Self(CURRENT.with(|c| c.borrow_mut().replace(globals)))
939    }
940}
941
942impl Drop for CurrentGlobals {
943    fn drop(&mut self) {
944        let previous = self.0.take();
945        CURRENT.with(|c| *c.borrow_mut() = previous);
946    }
947}
948
949/// A request global as seen from the environment: forwards to the value of
950/// the page being rendered, so an imported macro gets the same `old()`,
951/// `auth`, `t()`… as the template that called it.
952#[derive(Debug)]
953struct RequestGlobal(&'static str);
954
955impl RequestGlobal {
956    fn current(&self) -> Option<Value> {
957        CURRENT
958            .with(|c| c.borrow().clone())
959            .and_then(|globals| globals.get_attr(self.0).ok())
960            .filter(|v| !v.is_undefined())
961    }
962}
963
964impl minijinja::value::Object for RequestGlobal {
965    fn repr(self: &Arc<Self>) -> minijinja::value::ObjectRepr {
966        minijinja::value::ObjectRepr::Map
967    }
968
969    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
970        self.current()?
971            .get_item(key)
972            .ok()
973            .filter(|v| !v.is_undefined())
974    }
975
976    fn enumerate(self: &Arc<Self>) -> minijinja::value::Enumerator {
977        match self
978            .current()
979            .and_then(|v| v.try_iter().ok().map(|keys| keys.collect::<Vec<_>>()))
980        {
981            Some(keys)
982                if self
983                    .current()
984                    .is_some_and(|v| v.kind() == minijinja::value::ValueKind::Map) =>
985            {
986                minijinja::value::Enumerator::Values(keys)
987            }
988            _ => minijinja::value::Enumerator::Empty,
989        }
990    }
991
992    fn is_true(self: &Arc<Self>) -> bool {
993        self.current().is_some_and(|v| v.is_true())
994    }
995
996    fn call(
997        self: &Arc<Self>,
998        state: &minijinja::State<'_, '_>,
999        args: &[Value],
1000    ) -> Result<Value, minijinja::Error> {
1001        match self.current() {
1002            Some(value) => value.call(state, args),
1003            None => Err(minijinja::Error::new(
1004                ErrorKind::InvalidOperation,
1005                format!("`{}` is only available while rendering a page", self.0),
1006            )),
1007        }
1008    }
1009
1010    fn render(self: &Arc<Self>, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1011        match self.current() {
1012            Some(value) => std::fmt::Display::fmt(&value, f),
1013            None => Ok(()),
1014        }
1015    }
1016}
1017
1018/// Like MiniJinja's default formatter, but HTML escaping leaves `/` alone:
1019/// escaping `& < > " '` is enough for text and quoted attributes, and URLs
1020/// (links in pages and mail) stay readable.
1021fn format_value(
1022    out: &mut minijinja::Output,
1023    state: &minijinja::State,
1024    value: &Value,
1025) -> Result<(), minijinja::Error> {
1026    use std::fmt::Write;
1027    if let (minijinja::AutoEscape::Html, false, Some(text)) =
1028        (state.auto_escape(), value.is_safe(), value.as_str())
1029    {
1030        for c in text.chars() {
1031            let written = match c {
1032                '&' => out.write_str("&amp;"),
1033                '<' => out.write_str("&lt;"),
1034                '>' => out.write_str("&gt;"),
1035                '"' => out.write_str("&quot;"),
1036                '\'' => out.write_str("&#39;"),
1037                c => out.write_char(c),
1038            };
1039            written.map_err(|_| {
1040                minijinja::Error::new(ErrorKind::WriteFailure, "could not write output")
1041            })?;
1042        }
1043        return Ok(());
1044    }
1045    minijinja::escape_formatter(out, state, value)
1046}
1047
1048fn load(
1049    dir: &Path,
1050    embedded: Option<&'static [(&'static str, &'static str)]>,
1051    name: &str,
1052) -> Result<Option<String>, minijinja::Error> {
1053    if let Some(files) = embedded {
1054        if let Some((_, source)) = files.iter().find(|(file, _)| *file == name) {
1055            return Ok(Some((*source).to_owned()));
1056        }
1057    } else if let Some(path) = safe_join(dir, name) {
1058        match std::fs::read_to_string(&path) {
1059            Ok(source) => return Ok(Some(source)),
1060            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1061            Err(err) => {
1062                return Err(minijinja::Error::new(
1063                    ErrorKind::InvalidOperation,
1064                    format!("could not read template {}", path.display()),
1065                )
1066                .with_source(err));
1067            }
1068        }
1069    }
1070    Ok(BUILTIN
1071        .iter()
1072        .find(|(builtin, _)| *builtin == name)
1073        .map(|(_, source)| (*source).to_owned()))
1074}
1075
1076/// Reports every literal `route('name')` in the compiled `source` that no route has.
1077fn check_route_names(
1078    template: &str,
1079    source: &str,
1080    routes: &RouteTable,
1081    problems: &mut Vec<ViewProblem>,
1082) {
1083    static ROUTE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
1084        regex::Regex::new(r#"\broute\(\s*["']([^"']+)["']"#).expect("valid regex")
1085    });
1086    for caps in ROUTE.captures_iter(source) {
1087        let name = &caps[1];
1088        if routes.path(name).is_some() {
1089            continue;
1090        }
1091        let hint = crate::components::suggest::did_you_mean(name, routes.names())
1092            .map(|x| format!("; did you mean \"{x}\"?"))
1093            .unwrap_or_default();
1094        let offset = caps.get(0).map_or(0, |m| m.start());
1095        problems.push(ViewProblem {
1096            template: template.to_owned(),
1097            line: Some(source[..offset].matches('\n').count() + 1),
1098            message: format!("unknown route \"{name}\"{hint}"),
1099        });
1100    }
1101}
1102
1103/// Reports `{% include "name" %}` (what `<rx-include template="name"/>` compiles to) of a template
1104/// that does not exist. An include with `ignore missing`, or whose name is worked out at run
1105/// time, is not checked.
1106fn check_includes(
1107    template: &str,
1108    source: &str,
1109    known: &std::collections::HashSet<&str>,
1110    problems: &mut Vec<ViewProblem>,
1111) {
1112    static INCLUDE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
1113        regex::Regex::new(r#"\{%-?\s*include\s+["']([^"']+)["']\s*-?%\}"#).expect("valid regex")
1114    });
1115    for caps in INCLUDE.captures_iter(source) {
1116        let name = &caps[1];
1117        if known.contains(name) {
1118            continue;
1119        }
1120        let hint = crate::components::suggest::did_you_mean(name, known.iter().copied())
1121            .map(|x| format!("; did you mean \"{x}\"?"))
1122            .unwrap_or_default();
1123        let offset = caps.get(0).map_or(0, |m| m.start());
1124        problems.push(ViewProblem {
1125            template: template.to_owned(),
1126            line: Some(source[..offset].matches('\n').count() + 1),
1127            message: format!(
1128                "<rx-include> (or include) names a template that does not exist: \"{name}\"{hint}"
1129            ),
1130        });
1131    }
1132}
1133
1134/// A [`ViewProblem`] from a compile error. The component compiler's details
1135/// already start with `name:line: `, which is taken out of the message.
1136fn problem(name: &str, err: &minijinja::Error) -> ViewProblem {
1137    let detail = err.detail().unwrap_or("could not be compiled");
1138    if let Some(rest) = detail.strip_prefix(name).and_then(|r| r.strip_prefix(':'))
1139        && let Some((line, message)) = rest.split_once(": ")
1140        && let Ok(line) = line.parse::<usize>()
1141    {
1142        return ViewProblem {
1143            template: name.to_owned(),
1144            line: Some(line),
1145            message: message.to_owned(),
1146        };
1147    }
1148    ViewProblem {
1149        template: name.to_owned(),
1150        line: err.line(),
1151        message: detail.to_owned(),
1152    }
1153}
1154
1155fn safe_join(dir: &Path, name: &str) -> Option<PathBuf> {
1156    let mut path = dir.to_path_buf();
1157    for component in Path::new(name).components() {
1158        match component {
1159            PathPart::Normal(part) => path.push(part),
1160            _ => return None,
1161        }
1162    }
1163    Some(path)
1164}
1165
1166/// A template response, rendered by Renox with the request's globals:
1167/// `app` (with the request's `app.locale`), `request`, `auth` (`auth.check`,
1168/// `auth.user`), `t()`, `can()`, `flash`, `errors`, `error()`, `old()`,
1169/// `csrf_token`, `csrf_field()` and `renox_head()`.
1170///
1171/// ```
1172/// # use renox::prelude::*;
1173/// # let list: Vec<String> = Vec::new();
1174/// # let _ = move || {
1175/// async fn index() -> View {
1176/// #   let list: Vec<String> = Vec::new();
1177///     view("products/index.html", context! { products => list }).fragment("list")
1178/// }
1179/// # };
1180/// ```
1181#[derive(Clone)]
1182pub struct View {
1183    name: String,
1184    ctx: Value,
1185    fragment: Option<String>,
1186    /// More blocks sent with the fragment, for out-of-band swaps.
1187    also: Vec<String>,
1188    status: StatusCode,
1189}
1190
1191/// The line of the first `{{ … }}` or `{% … %}` tag that names `var`, if any.
1192fn first_use(source: &str, var: &str) -> Option<usize> {
1193    let word = regex::Regex::new(&format!(r"\b{}\b", regex::escape(var))).ok()?;
1194    let mut rest = source;
1195    let mut offset = 0;
1196    while let Some(open) = rest.find('{') {
1197        let after = &rest[open..];
1198        let close = match after.get(..2) {
1199            Some("{{") => "}}",
1200            Some("{%") => "%}",
1201            _ => {
1202                offset += open + 1;
1203                rest = &rest[open + 1..];
1204                continue;
1205            }
1206        };
1207        let end = after.find(close).map_or(after.len(), |i| i + 2);
1208        if word.is_match(&after[..end]) {
1209            return Some(source[..offset + open].matches('\n').count() + 1);
1210        }
1211        offset += open + end;
1212        rest = &rest[open + end..];
1213    }
1214    None
1215}
1216
1217/// Renders `name` from the views directory with `ctx` (anything serializable,
1218/// usually `context! { ... }`).
1219pub fn view(name: impl Into<String>, ctx: impl Serialize) -> View {
1220    View {
1221        name: name.into(),
1222        ctx: Value::from_serialize(ctx),
1223        fragment: None,
1224        also: Vec::new(),
1225        status: StatusCode::OK,
1226    }
1227}
1228
1229impl View {
1230    /// Renders a page struct in its own template ([`ViewData::TEMPLATE`]): the typed
1231    /// form of [`view`]. Register the struct with `App::view_data::<T>()` so
1232    /// `view:check` reports the template's variables the struct lacks.
1233    ///
1234    /// ```
1235    /// # use renox::prelude::*;
1236    /// #[derive(serde::Serialize, ViewData)]
1237    /// #[view(template = "orders/show.html")]
1238    /// struct OrderPage {
1239    ///     number: String,
1240    /// }
1241    ///
1242    /// async fn show() -> View {
1243    ///     View::typed(OrderPage { number: "A-1".into() }).fragment("summary")
1244    /// }
1245    /// ```
1246    pub fn typed<T: ViewData>(page: T) -> View {
1247        view(T::TEMPLATE, page)
1248    }
1249
1250    /// For HTMX requests (except `hx-boost`), render only this `{% block %}`.
1251    pub fn fragment(mut self, block: impl Into<String>) -> Self {
1252        self.fragment = Some(block.into());
1253        self
1254    }
1255
1256    /// For the same HTMX requests, also render `block` after the fragment,
1257    /// to update other parts of the page in one response: give the block's
1258    /// root element an `id` and `hx-swap-oob="true"` and htmx swaps it into
1259    /// the element with that id.
1260    ///
1261    /// ```html
1262    /// {% block row %}<tr id="order-{{ order.id }}">…</tr>{% endblock %}
1263    /// {% block count %}<span id="order-count" hx-swap-oob="true">{{ count }}</span>{% endblock %}
1264    /// ```
1265    ///
1266    /// `view("orders/index.html", ctx).fragment("row").also("count")`
1267    pub fn also(mut self, block: impl Into<String>) -> Self {
1268        self.also.push(block.into());
1269        self
1270    }
1271
1272    /// The response status (200 by default).
1273    pub fn status(mut self, status: StatusCode) -> Self {
1274        self.status = status;
1275        self
1276    }
1277}
1278
1279impl IntoResponse for View {
1280    fn into_response(self) -> Response {
1281        let mut res = self.status.into_response();
1282        res.extensions_mut().insert(self);
1283        res
1284    }
1285}
1286
1287/// Renders `View` and error responses once the handler has returned, and
1288/// turns validation errors into a redirect back for regular form posts.
1289pub(crate) async fn middleware(
1290    State(state): State<AppState>,
1291    req: Request,
1292    next: Next,
1293) -> Response {
1294    let session = req.extensions().get::<Session>().cloned();
1295    let current_user = req.extensions().get::<CurrentUser>().cloned();
1296    let htmx = Htmx::from_headers(req.headers());
1297    let method = req.method().clone();
1298    let path = req.uri().path().to_owned();
1299    let query = req.uri().query().unwrap_or_default().to_owned();
1300    let route = crate::routing::CurrentRoute::of(req.method(), req.extensions(), &state)
1301        .name()
1302        .map(str::to_owned);
1303    let nonce = req
1304        .extensions()
1305        .get::<crate::security::CspNonce>()
1306        .map(|n| n.0.clone())
1307        .unwrap_or_default();
1308    let (wants_json, referer) = {
1309        let header = |name| req.headers().get(name).and_then(|v| v.to_str().ok());
1310        let wants_json = header(ACCEPT).is_some_and(|v| v.contains("application/json"))
1311            || header(CONTENT_TYPE).is_some_and(|v| v.starts_with("application/json"));
1312        // Back to the form's page, but never to another site.
1313        (wants_json, crate::htmx::same_site_referer(req.headers()))
1314    };
1315
1316    let request_line = if query.is_empty() {
1317        format!("{method} {path}")
1318    } else {
1319        format!("{method} {path}?{query}")
1320    };
1321    let mut res = next.run(req).await;
1322    // A layer may have switched the language (`RequestLocale::switch`).
1323    let locale = crate::i18n::current_locale(&state);
1324
1325    if let Some(crate::toast::PendingToasts(toasts)) =
1326        res.extensions_mut().remove::<crate::toast::PendingToasts>()
1327    {
1328        // An htmx swap shows them now; a page, an htmx redirect or refresh
1329        // on the next page, from the session (the reload would lose them).
1330        if htmx.request
1331            && !res.headers().contains_key("hx-redirect")
1332            && !res.headers().contains_key("hx-refresh")
1333        {
1334            crate::htmx::add_trigger(
1335                &mut res,
1336                crate::toast::EVENT,
1337                serde_json::json!({ "toasts": toasts }),
1338            );
1339        } else if let Some(session) = &session {
1340            let mut waiting: Vec<crate::Toast> =
1341                session.get(crate::toast::SESSION_KEY).unwrap_or_default();
1342            waiting.extend(toasts);
1343            if let Err(err) = session.put(crate::toast::SESSION_KEY, &waiting) {
1344                return err.into_response();
1345            }
1346        }
1347    }
1348
1349    if let Some(failed) = res.extensions_mut().remove::<ValidationError>() {
1350        if htmx.request || wants_json {
1351            return res;
1352        }
1353        if let Some(session) = &session {
1354            let bag = failed.bag().map(str::to_owned);
1355            // A `ValidationError` made after `Valid` (a model hook, the
1356            // handler) carries no input: refill with what `Valid` read.
1357            let input = if failed.input.is_empty() {
1358                crate::context::get::<crate::validation::extract::SubmittedInput>()
1359                    .map(|submitted| submitted.0)
1360                    .unwrap_or_default()
1361            } else {
1362                failed.input
1363            };
1364            let flashed = match &bag {
1365                Some(bag) => session.flash_errors_in(bag, &failed.errors),
1366                None => session.flash_errors(&failed.errors),
1367            }
1368            .and_then(|()| session.flash_input(&input));
1369            if let Err(err) = flashed {
1370                return err.into_response();
1371            }
1372        }
1373        return Redirect::to(referer.as_deref().unwrap_or("/")).into_response();
1374    }
1375
1376    // Analytics events: with an htmx swap, in its HX-Trigger; with a page,
1377    // in its head (below); otherwise they wait for the next page.
1378    if let Some(session) = &session
1379        && crate::analytics::has_pending(session)
1380        && htmx.request
1381        && crate::analytics::deliverable_by_htmx(&res)
1382    {
1383        crate::analytics::add_trigger(&mut res, crate::analytics::take(session));
1384    }
1385
1386    if let Some(view) = res.extensions_mut().remove::<View>() {
1387        let mut shared = std::collections::BTreeMap::new();
1388        for (key, compute) in state.shares.iter() {
1389            let ctx = ViewContext {
1390                state: state.clone(),
1391                user: current_user.as_ref().and_then(|c| c.user.clone()),
1392                locale: locale.clone(),
1393                path: path.clone(),
1394                session: session.clone(),
1395            };
1396            match compute(ctx).await {
1397                Ok(value) => {
1398                    shared.insert(key.clone(), value);
1399                }
1400                Err(err) => {
1401                    let err = match err {
1402                        Error::Internal(err) => err,
1403                        other => anyhow::anyhow!("{other:?}"),
1404                    };
1405                    // Shown like any error: the error page, with the detail
1406                    // while debugging.
1407                    let mut failed =
1408                        Error::Internal(err.context(format!("sharing `{key}` with views")))
1409                            .into_response();
1410                    return match failed.extensions_mut().remove::<ErrorPage>() {
1411                        Some(page) => error_response(
1412                            &state,
1413                            page,
1414                            failed,
1415                            wants_json,
1416                            &htmx,
1417                            &request_line,
1418                            None,
1419                        ),
1420                        None => failed,
1421                    };
1422                }
1423            }
1424        }
1425        let events = match &session {
1426            Some(session) if !htmx.request => crate::analytics::take(session),
1427            _ => Vec::new(),
1428        };
1429        let globals = globals(
1430            &state,
1431            session.as_ref(),
1432            current_user.clone(),
1433            &htmx,
1434            &Requested {
1435                path: &path,
1436                query: &query,
1437                route: route.as_deref(),
1438                nonce: &nonce,
1439                events: &events,
1440            },
1441            &locale,
1442        );
1443        return match state
1444            .views
1445            .render_view(&view, Value::from_serialize(&shared), globals, &htmx)
1446        {
1447            Ok(html) => {
1448                let mut page = with_html(res, html);
1449                // For `TestResponse::assert_view`.
1450                page.extensions_mut()
1451                    .insert(RenderedView(view.name.clone()));
1452                page
1453            }
1454            Err(err) => {
1455                let mut failed = Error::Internal(err.context(format!("rendering {}", view.name)))
1456                    .into_response();
1457                match failed.extensions_mut().remove::<ErrorPage>() {
1458                    Some(page) => {
1459                        error_response(&state, page, failed, wants_json, &htmx, &request_line, None)
1460                    }
1461                    None => failed,
1462                }
1463            }
1464        };
1465    }
1466
1467    if let Some(page) = res.extensions_mut().remove::<ErrorPage>() {
1468        // The app's error pages may extend its layout: give them what pages
1469        // get, the values `App::share`s included (a layout's cart count).
1470        let globals = if !wants_json || htmx.request {
1471            let mut shared = std::collections::BTreeMap::new();
1472            for (key, compute) in state.shares.iter() {
1473                let ctx = ViewContext {
1474                    state: state.clone(),
1475                    user: current_user.as_ref().and_then(|c| c.user.clone()),
1476                    locale: locale.clone(),
1477                    path: path.clone(),
1478                    session: session.clone(),
1479                };
1480                // A share that fails here only leaves its value out: the page
1481                // is about another error, which it mustn't hide.
1482                match compute(ctx).await {
1483                    Ok(value) => {
1484                        shared.insert(key.clone(), value);
1485                    }
1486                    Err(err) => {
1487                        tracing::warn!(error = ?err, key = %key, "a shared view value failed on an error page")
1488                    }
1489                }
1490            }
1491            let page_globals = globals(
1492                &state,
1493                session.as_ref(),
1494                current_user,
1495                &htmx,
1496                &Requested {
1497                    path: &path,
1498                    query: &query,
1499                    route: route.as_deref(),
1500                    nonce: &nonce,
1501                    events: &[],
1502                },
1503                &locale,
1504            );
1505            Some(merge_maps([page_globals, Value::from_serialize(&shared)]))
1506        } else {
1507            None
1508        };
1509        return error_response(&state, page, res, wants_json, &htmx, &request_line, globals);
1510    }
1511    res
1512}
1513
1514/// The error page (or JSON for API clients) for `page`.
1515fn error_response(
1516    state: &AppState,
1517    page: ErrorPage,
1518    res: Response,
1519    wants_json: bool,
1520    htmx: &Htmx,
1521    request_line: &str,
1522    globals: Option<Value>,
1523) -> Response {
1524    let debug = state.config.debug;
1525    if wants_json && !htmx.request {
1526        // Keep what the error response carried (`Retry-After` on a 429, …).
1527        let mut json = page.json(debug);
1528        for (name, value) in res.headers() {
1529            if name != CONTENT_TYPE && name != CONTENT_LENGTH {
1530                json.headers_mut().insert(name.clone(), value.clone());
1531            }
1532        }
1533        return json;
1534    }
1535    let html = state
1536        .views
1537        .render_error(&page, debug, request_line, globals)
1538        .unwrap_or_else(|err| {
1539            tracing::error!(error = ?err, "could not render the error page");
1540            crate::error::error_page(page.status, page.shown_detail(debug))
1541        });
1542    with_html(res, html)
1543}
1544
1545fn with_html(mut res: Response, html: String) -> Response {
1546    let headers = res.headers_mut();
1547    headers.insert(
1548        CONTENT_TYPE,
1549        HeaderValue::from_static("text/html; charset=utf-8"),
1550    );
1551    headers.remove(CONTENT_LENGTH);
1552    *res.body_mut() = Body::from(html);
1553    res
1554}
1555
1556/// Flashed values; a key that wasn't flashed reads as `""`, so
1557/// `{{ flash.status }}` needs no `if`, even with strict templates.
1558#[derive(Debug)]
1559struct Flashed(serde_json::Map<String, serde_json::Value>);
1560
1561impl minijinja::value::Object for Flashed {
1562    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
1563        let key = key.as_str()?;
1564        Some(
1565            self.0
1566                .get(key)
1567                .map(Value::from_serialize)
1568                .unwrap_or_else(|| Value::from("")),
1569        )
1570    }
1571
1572    fn enumerate(self: &Arc<Self>) -> minijinja::value::Enumerator {
1573        minijinja::value::Enumerator::Values(
1574            self.0.keys().map(|k| Value::from(k.as_str())).collect(),
1575        )
1576    }
1577
1578    fn repr(self: &Arc<Self>) -> minijinja::value::ObjectRepr {
1579        minijinja::value::ObjectRepr::Map
1580    }
1581}
1582
1583/// What templates see of the request, besides the session and user.
1584struct Requested<'a> {
1585    path: &'a str,
1586    query: &'a str,
1587    /// The matched route's name, if it has one.
1588    route: Option<&'a str>,
1589    nonce: &'a str,
1590    /// Analytics events for this page's head.
1591    events: &'a [crate::analytics::Event],
1592}
1593
1594/// The templates' `t(key, name=…, count=…)` in `locale`.
1595pub(crate) fn translate_function(state: &AppState, locale: &str) -> Value {
1596    let translator = state.translator.clone();
1597    let (locale, fallback) = (locale.to_owned(), state.config.fallback_locale.clone());
1598    Value::from_function(
1599        move |key: String, kwargs: minijinja::value::Kwargs| -> Result<Value, minijinja::Error> {
1600            let mut params = Vec::new();
1601            let mut count = None;
1602            for name in kwargs.args() {
1603                let value: Value = kwargs.get(name)?;
1604                if name == "count" {
1605                    match i64::try_from(value.clone()) {
1606                        Ok(whole) => count = Some(whole),
1607                        // Not a whole number (a fraction, or text such as
1608                        // "1,204"): `:count` shows it as given, and a plural
1609                        // form is still picked by its value when it has one.
1610                        Err(_) => {
1611                            let shown = value.to_string();
1612                            let number = f64::try_from(value)
1613                                .ok()
1614                                .or_else(|| shown.trim().parse::<f64>().ok());
1615                            count = number.map(plural_count);
1616                            params.push((name.to_owned(), shown));
1617                        }
1618                    }
1619                } else {
1620                    params.push((name.to_owned(), value.to_string()));
1621                }
1622            }
1623            kwargs.assert_all_used()?;
1624            let params: Vec<(&str, String)> = params
1625                .iter()
1626                .map(|(k, v)| (k.as_str(), v.clone()))
1627                .collect();
1628            let text = translator.get(&locale, &fallback, &key);
1629            Ok(Value::from(crate::i18n::format(&text, &params, count)))
1630        },
1631    )
1632}
1633
1634/// The count a number stands for when picking a plural form: one only for
1635/// exactly 1 (so 1.5 or 0.5 kilos are plural), else the nearest whole one
1636/// (for ranges such as `[2,5]`).
1637fn plural_count(number: f64) -> i64 {
1638    if number == 1.0 {
1639        1
1640    } else {
1641        match number.round() as i64 {
1642            1 => 2,
1643            other => other,
1644        }
1645    }
1646}
1647
1648fn globals(
1649    state: &AppState,
1650    session: Option<&Session>,
1651    current_user: Option<CurrentUser>,
1652    htmx: &Htmx,
1653    requested: &Requested,
1654    locale: &str,
1655) -> Value {
1656    let config = &state.config;
1657    let token = session.map(Session::token).unwrap_or_default();
1658
1659    let strict = state.security.mode == crate::CspMode::Strict;
1660    let head = Value::from_safe_string(format!(
1661        "{}\n{}",
1662        assets::head_tags(&token, state.live.is_some(), strict),
1663        crate::seo::head_tags(&state.config, requested.nonce, requested.events)
1664    ));
1665    let seo = {
1666        let (config, path, locale) = (
1667            state.config.clone(),
1668            requested.path.to_owned(),
1669            locale.to_owned(),
1670        );
1671        Value::from_function(
1672            move |kwargs: minijinja::value::Kwargs| -> Result<Value, minijinja::Error> {
1673                crate::seo::tags(&config, &path, &locale, &kwargs).map(Value::from_safe_string)
1674            },
1675        )
1676    };
1677    let nonce = requested.nonce.to_owned();
1678    let field = Value::from_safe_string(format!(
1679        "<input type=\"hidden\" name=\"{}\" value=\"{token}\">",
1680        crate::csrf::CSRF_FIELD
1681    ));
1682    let old_input = session.cloned();
1683    let has_old = session.is_some_and(Session::has_old_input);
1684    let toast_session = session.cloned();
1685    let toast_texts = crate::toast::RegionTexts {
1686        dismiss: state
1687            .translator
1688            .get(locale, &state.config.fallback_locale, "ui.dismiss"),
1689        failed: state
1690            .translator
1691            .get(locale, &state.config.fallback_locale, "ui.request_failed"),
1692    };
1693    let seen = std::sync::Mutex::new(std::collections::HashSet::<String>::new());
1694    let user = current_user.as_ref().and_then(|c| c.user.clone());
1695    let roles = current_user
1696        .as_ref()
1697        .map(|c| c.grants.roles())
1698        .unwrap_or_default();
1699    let gate_user = current_user.and_then(|c| Some((c.user?, c.gates, c.grants)));
1700    // Errors flashed in a named bag (`Validate::ERROR_BAG`) are shown only
1701    // by `error(field, bag=…)` and `errors_in(bag)`.
1702    let error_bag = session.and_then(Session::error_bag);
1703    let flashed_errors = match (session, &error_bag) {
1704        (Some(session), Some(bag)) => session.errors_in(bag),
1705        (Some(session), None) => session.errors(),
1706        (None, _) => Default::default(),
1707    };
1708    let errors = if error_bag.is_none() {
1709        flashed_errors.clone()
1710    } else {
1711        Default::default()
1712    };
1713    let first_errors: std::collections::BTreeMap<String, String> = flashed_errors
1714        .iter()
1715        .filter_map(|(field, messages)| {
1716            messages
1717                .get(0)
1718                .and_then(|m| m.as_str())
1719                .map(|m| (field.clone(), m.to_owned()))
1720        })
1721        .collect();
1722
1723    context! {
1724        app => context! {
1725            name => config.name,
1726            env => format!("{:?}", config.env).to_lowercase(),
1727            debug => config.debug,
1728            url => config.url,
1729            locale => locale,
1730            currency => config.currency,
1731        },
1732        auth => context! {
1733            check => user.is_some(),
1734            user => user.as_deref(),
1735            roles => roles,
1736        },
1737        t => translate_function(state, locale),
1738        // `can('admin')` asks a gate; `can('update', product)` reads the
1739        // abilities `auth::Can` attached to the model in the handler.
1740        can => Value::from_function(move |ability: String, target: Option<Value>| {
1741            match target {
1742                Some(target) => target
1743                    .get_attr("_can")
1744                    .and_then(|can| can.get_attr(&ability))
1745                    .is_ok_and(|allowed| allowed.is_true()),
1746                None => gate_user
1747                    .as_ref()
1748                    .is_some_and(|(user, gates, grants)| gates.check(user, grants, &ability)),
1749            }
1750        }),
1751        request => context! {
1752            path => requested.path,
1753            query => requested.query,
1754            route => requested.route,
1755            htmx => htmx.request,
1756            boosted => htmx.boosted,
1757        },
1758        // `route_is('admin.*')`, `route_is('products.index', 'products.show')`.
1759        route_is => {
1760            let route = requested.route.map(str::to_owned);
1761            Value::from_function(move |patterns: minijinja::value::Rest<String>| {
1762                route.as_deref().is_some_and(|name| {
1763                    patterns
1764                        .iter()
1765                        .any(|pattern| crate::routing::route_name_matches(name, pattern))
1766                })
1767            })
1768        },
1769        csrf_token => token,
1770        flash => Value::from_object(Flashed(session.map(Session::flashed).unwrap_or_default())),
1771        errors => errors,
1772        // `errors_in('login')`: every error of a named bag.
1773        errors_in => {
1774            let bag = error_bag.clone();
1775            Value::from_function(move |name: String| {
1776                if bag.as_deref() == Some(name.as_str()) {
1777                    Value::from_serialize(&flashed_errors)
1778                } else {
1779                    Value::from_serialize(serde_json::Map::new())
1780                }
1781            })
1782        },
1783        // `error('photos')` also shows the first error of an item (`photos.1`);
1784        // `error('email', bag='login')` reads a named bag.
1785        error => Value::from_function(move |field: String, kwargs: minijinja::value::Kwargs| {
1786            let bag: Option<String> = kwargs.get("bag")?;
1787            kwargs.assert_all_used()?;
1788            if bag != error_bag {
1789                return Ok::<_, minijinja::Error>(String::new());
1790            }
1791            // `items[0][name]`'s errors are keyed `items.0.name`.
1792            let field = crate::validation::nested::normalize(&field);
1793            Ok(first_errors
1794                .get(&field)
1795                .or_else(|| {
1796                    let prefix = format!("{field}.");
1797                    first_errors
1798                        .iter()
1799                        .find(|(key, _)| key.starts_with(&prefix))
1800                        .map(|(_, message)| message)
1801                })
1802                .cloned()
1803                .unwrap_or_default())
1804        }),
1805        renox_head => Value::from_function(move || head.clone()),
1806        seo => seo,
1807        csp_nonce => Value::from_function(move || nonce.clone()),
1808        csrf_field => Value::from_function(move || field.clone()),
1809        // `{{ toasts() }}`: the toast region, with the toasts waiting for
1810        // this page (taken from the session: shown once).
1811        // `toasts(position="bottom-end")` moves them (see toast::POSITIONS).
1812        toasts => Value::from_function(move |kwargs: minijinja::value::Kwargs| {
1813            let position: Option<String> = kwargs.get("position")?;
1814            kwargs.assert_all_used()?;
1815            let waiting: Vec<crate::Toast> = toast_session
1816                .as_ref()
1817                .and_then(|s| s.pull(crate::toast::SESSION_KEY))
1818                .unwrap_or_default();
1819            Ok::<_, minijinja::Error>(Value::from_safe_string(crate::toast::region(
1820                &waiting,
1821                &toast_texts,
1822                position.as_deref().unwrap_or("top"),
1823            )))
1824        }),
1825        // `{% if once('date-picker') %}…{% endif %}`: true the first time a key
1826        // is asked for on a page, e.g. for a component's script.
1827        once => Value::from_function(move |key: String| {
1828            seen.lock().unwrap_or_else(|e| e.into_inner()).insert(key)
1829        }),
1830        // `has_old()`: the previous request was a failed submit, so a field
1831        // missing from `old()` was sent empty (an unticked checkbox).
1832        has_old => Value::from_function(move || has_old),
1833        old => Value::from_function(move |field: String, default: Option<Value>| {
1834            old_input
1835                .as_ref()
1836                .and_then(|s| s.old(&field))
1837                .map(Value::from_serialize)
1838                .or(default)
1839                .unwrap_or_else(|| Value::from(""))
1840        }),
1841    }
1842}
1843
1844#[cfg(test)]
1845mod tests {
1846    use super::*;
1847
1848    fn views_in(dir: &Path) -> Views {
1849        let config = Config {
1850            views_path: dir.to_path_buf(),
1851            ..Config::default()
1852        };
1853        Views::new(
1854            &config,
1855            Arc::new(RouteTable::default()),
1856            Storage::from_config(&config).unwrap(),
1857            None,
1858            Arc::new(Vec::new()),
1859            Arc::new(Vec::new()),
1860            config.timezone,
1861            Arc::new(crate::embedded::AssetVersions::new(dir, None)),
1862        )
1863    }
1864
1865    #[test]
1866    fn check_finds_every_broken_template_with_its_line() {
1867        let dir = tempfile::tempdir().unwrap();
1868        let write = |name: &str, body: &str| {
1869            let path = dir.path().join(name);
1870            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1871            std::fs::write(path, body).unwrap();
1872        };
1873        write("good.html", "<h1>{{ 1 + 1 }}</h1>");
1874        write("sub/typo.html", "<p>x</p>\n<rx-tabel>y</rx-tabel>\n");
1875        write("bad.html", "a\nb\n{% if x %}\nc\n");
1876        write(".hidden.html", "{% if %}");
1877        write("notes.md", "{% if %}");
1878        let views = views_in(dir.path());
1879
1880        let names = views.names();
1881        assert!(names.contains(&"good.html".to_owned()));
1882        assert!(names.contains(&"sub/typo.html".to_owned()));
1883        assert!(names.contains(&"renox/error.html".to_owned()));
1884        assert!(
1885            !names
1886                .iter()
1887                .any(|n| n.contains("hidden") || n.ends_with(".md"))
1888        );
1889        assert!(names.windows(2).all(|w| w[0] < w[1]));
1890
1891        let problems = views.check(&RouteTable::default());
1892        assert_eq!(problems.len(), 2, "{problems:?}");
1893        assert_eq!(problems[0].template, "bad.html");
1894        assert!(problems[0].line.is_some_and(|l| l >= 3), "{problems:?}");
1895        assert_eq!(problems[1].template, "sub/typo.html");
1896        assert_eq!(problems[1].line, Some(2));
1897        assert!(problems[1].message.contains("rx-tabel"), "{problems:?}");
1898        assert!(
1899            problems[1]
1900                .to_string()
1901                .starts_with("sub/typo.html:2: unknown component"),
1902        );
1903    }
1904
1905    #[test]
1906    fn check_reports_unknown_route_names_with_a_suggestion() {
1907        let dir = tempfile::tempdir().unwrap();
1908        std::fs::write(
1909            dir.path().join("p.html"),
1910            "<a href=\"{{ route('products.index') }}\">ok</a>\n\
1911             <a href=\"{{ route('products.indx') }}\">typo</a>\n\
1912             <a href=\"{{ route('shop.home') }}\">domain</a>\n\
1913             <a href=\"{{ route('nothing.like.it') }}\">far</a>\n",
1914        )
1915        .unwrap();
1916        let mut routes = RouteTable::default();
1917        routes
1918            .insert("products.index".into(), "/products".into())
1919            .unwrap();
1920        // A route defined inside `Routes::domain` is in the table as well.
1921        routes.insert("shop.home".into(), "/".into()).unwrap();
1922        routes.set_domain("shop.home", "shop.example.com");
1923        let problems = views_in(dir.path()).check(&routes);
1924        assert_eq!(problems.len(), 2, "{problems:?}");
1925        assert_eq!(problems[0].line, Some(2));
1926        assert_eq!(
1927            problems[0].to_string(),
1928            "p.html:2: unknown route \"products.indx\"; did you mean \"products.index\"?"
1929        );
1930        assert_eq!(problems[1].line, Some(4));
1931        assert!(!problems[1].message.contains("did you mean"));
1932    }
1933
1934    fn env() -> Environment<'static> {
1935        let mut env = Environment::new();
1936        for name in REQUEST_GLOBALS {
1937            env.add_global(*name, Value::from_object(RequestGlobal(name)));
1938        }
1939        env
1940    }
1941
1942    /// A template rendered while a page renders (a component a Rust
1943    /// template function renders, say) reads the page's request values
1944    /// through the `RequestGlobal`s; outside a page they are empty.
1945    #[test]
1946    fn request_globals_read_the_page_being_rendered() {
1947        let env = env();
1948        let page = "{% if auth %}[{{ auth.name }}]{% endif %}\
1949                    {% for key in flash %}{{ key }}={{ flash[key] }};{% endfor %}\
1950                    {{ t('hi') }}|{{ request }}|{% for e in errors %}{{ e }}{% endfor %}.";
1951
1952        let outside = env
1953            .render_str(
1954                "{% if auth %}in{% endif %}[{{ request }}]{% for k in flash %}{{ k }}{% endfor %}",
1955                (),
1956            )
1957            .unwrap();
1958        assert_eq!(outside, "[]");
1959        let err = env.render_str("{{ t('hi') }}", ()).unwrap_err();
1960        assert!(
1961            err.to_string()
1962                .contains("`t` is only available while rendering a page"),
1963            "{err}"
1964        );
1965
1966        let globals = minijinja::context! {
1967            auth => minijinja::context! { name => "Ann" },
1968            flash => minijinja::context! { status => "Saved" },
1969            t => Value::from_function(|key: String| format!("({key})")),
1970            request => "GET /",
1971            // A list: not a map, so it lists no keys.
1972            errors => vec!["a"],
1973        };
1974        let current = CurrentGlobals::set(globals);
1975        assert_eq!(
1976            env.render_str(page, ()).unwrap(),
1977            "[Ann]status=Saved;(hi)|GET /|."
1978        );
1979        drop(current);
1980        assert_eq!(env.render_str("{{ request }}", ()).unwrap(), "");
1981    }
1982}