use std::path::{Component as PathPart, Path, PathBuf};
use std::sync::Arc;
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::header::{ACCEPT, CONTENT_LENGTH, CONTENT_TYPE};
use axum::http::{HeaderValue, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Redirect, Response};
use minijinja::value::{Rest, merge_maps};
use minijinja::{Environment, ErrorKind, Value, context};
use minijinja_autoreload::AutoReloader;
use serde::Serialize;
use crate::auth::CurrentUser;
use crate::error::{ErrorPage, reason};
use crate::storage::Storage;
use crate::validation::ValidationError;
use crate::{AppState, Config, Error, Htmx, RouteTable, Session, assets};
pub(crate) const BUILTIN: &[(&str, &str)] = &[
("renox/error.html", include_str!("../views/error.html")),
(
"renox/pagination.html",
include_str!("../views/pagination.html"),
),
(
"renox/auth/layout.html",
include_str!("../views/auth/layout.html"),
),
(
"renox/auth/login.html",
include_str!("../views/auth/login.html"),
),
(
"renox/auth/register.html",
include_str!("../views/auth/register.html"),
),
(
"renox/auth/forgot-password.html",
include_str!("../views/auth/forgot-password.html"),
),
(
"renox/auth/reset-password.html",
include_str!("../views/auth/reset-password.html"),
),
(
"renox/auth/verify-email.html",
include_str!("../views/auth/verify-email.html"),
),
(
"renox/mail/layout.html",
include_str!("../views/mail/layout.html"),
),
(
"renox/mail/button.html",
include_str!("../views/mail/button.html"),
),
("renox/live.html", include_str!("../views/live.html")),
("renox/ui.html", include_str!("../views/ui.html")),
(
"renox/import_report.html",
include_str!("../views/import_report.html"),
),
("renox/grid.html", include_str!("../views/grid.html")),
(
"renox/grid_print.html",
include_str!("../views/grid_print.html"),
),
("renox/debug.html", include_str!("../views/debug.html")),
(
"renox/notifications.html",
include_str!("../views/notifications.html"),
),
(
"renox/queue/dashboard.html",
include_str!("../views/queue/dashboard.html"),
),
(
"renox/mail/components.html",
include_str!("../views/mail/components.html"),
),
(
"renox/auth/account.html",
include_str!("../views/auth/account.html"),
),
(
"renox/auth/account_sections.html",
include_str!("../views/auth/account_sections.html"),
),
(
"renox/auth/confirm-password.html",
include_str!("../views/auth/confirm-password.html"),
),
(
"renox/mail/auth/reset-password.html",
include_str!("../views/mail/auth/reset-password.html"),
),
(
"renox/mail/auth/reset-password.txt",
include_str!("../views/mail/auth/reset-password.txt"),
),
(
"renox/mail/auth/verify-email.html",
include_str!("../views/mail/auth/verify-email.html"),
),
(
"renox/mail/auth/verify-email.txt",
include_str!("../views/mail/auth/verify-email.txt"),
),
];
#[derive(Clone)]
pub struct Views {
reloader: Arc<AutoReloader>,
debug: bool,
dir: PathBuf,
embedded: Option<&'static [(&'static str, &'static str)]>,
plugin_components: Arc<Vec<crate::components::Contract>>,
typed: Arc<Vec<TypedView>>,
shared: Arc<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TypedView {
pub name: String,
pub template: &'static str,
pub fields: &'static [&'static str],
}
impl TypedView {
pub(crate) fn of<T: ViewData>() -> Self {
Self {
name: short_type_name(std::any::type_name::<T>()),
template: T::TEMPLATE,
fields: T::FIELDS,
}
}
}
fn short_type_name(full: &str) -> String {
let base = full.split('<').next().unwrap_or(full);
let start = base.rfind("::").map_or(0, |i| i + 2);
full[start..].to_owned()
}
pub trait ViewData: Serialize {
const TEMPLATE: &'static str;
const FIELDS: &'static [&'static str];
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ViewProblem {
pub template: String,
pub line: Option<usize>,
pub message: String,
}
impl std::fmt::Display for ViewProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.line {
Some(line) => write!(f, "{}:{line}: {}", self.template, self.message),
None => write!(f, "{}: {}", self.template, self.message),
}
}
}
fn walk(dir: &Path, prefix: &str, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let file_name = entry.file_name().to_string_lossy().into_owned();
if file_name.starts_with('.') {
continue;
}
let name = if prefix.is_empty() {
file_name
} else {
format!("{prefix}/{file_name}")
};
let path = entry.path();
if path.is_dir() {
walk(&path, &name, out);
} else if name.ends_with(".html") || name.ends_with(".txt") {
out.push(name);
}
}
}
#[non_exhaustive]
#[derive(Clone)]
pub struct ViewContext {
pub state: AppState,
pub user: Option<Arc<crate::auth::User>>,
pub locale: String,
pub path: String,
pub session: Option<crate::Session>,
}
pub(crate) type ShareFn = Arc<
dyn Fn(
ViewContext,
)
-> std::pin::Pin<Box<dyn std::future::Future<Output = crate::Result<Value>> + Send>>
+ Send
+ Sync,
>;
pub(crate) fn share_fn<F, Fut, T>(compute: F) -> ShareFn
where
F: Fn(ViewContext) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = crate::Result<T>> + Send + 'static,
T: Serialize,
{
let compute = Arc::new(compute);
Arc::new(move |ctx| {
let compute = compute.clone();
Box::pin(async move { Ok(Value::from_serialize(compute(ctx).await?)) })
})
}
fn hidden_by_imports(source: &str, values: &[&Value]) -> Vec<String> {
static FROM: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(
r#"(?s)\{%[-+]?\s*from\s+(?:"[^"]*"|'[^']*'|\S+)\s+import\s+(.*?)\s*[-+]?%\}"#,
)
.expect("valid regex")
});
static IMPORT: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r#"\{%[-+]?\s*import\s+(?:"[^"]*"|'[^']*'|\S+)\s+as\s+(\w+)"#)
.expect("valid regex")
});
let mut names: Vec<String> = Vec::new();
for found in FROM.captures_iter(source) {
let list = found[1]
.trim_end_matches("without context")
.trim_end_matches("with context");
for item in list.split(',') {
let name = item.rsplit(" as ").next().unwrap_or(item).trim();
if !name.is_empty() {
names.push(name.to_owned());
}
}
}
names.extend(
IMPORT
.captures_iter(source)
.map(|found| found[1].to_owned()),
);
names.retain(|name| {
values
.iter()
.any(|value| value.get_attr(name).is_ok_and(|v| !v.is_undefined()))
});
names.sort();
names.dedup();
names
}
pub(crate) type TemplateHook = Arc<dyn Fn(&mut Environment<'static>) + Send + Sync>;
thread_local! {
static HOOK_COMPONENTS: std::cell::RefCell<Option<Arc<Vec<crate::components::Contract>>>> =
const { std::cell::RefCell::new(None) };
}
struct HookScope(Option<Arc<Vec<crate::components::Contract>>>);
impl HookScope {
fn enter(contracts: Arc<Vec<crate::components::Contract>>) -> Self {
Self(HOOK_COMPONENTS.with(|c| c.borrow_mut().replace(contracts)))
}
}
impl Drop for HookScope {
fn drop(&mut self) {
let previous = self.0.take();
HOOK_COMPONENTS.with(|c| *c.borrow_mut() = previous);
}
}
pub use crate::components::{Component, Prop};
pub fn add_template(
env: &mut Environment<'static>,
name: impl Into<String>,
source: impl Into<String>,
) -> Result<(), minijinja::Error> {
let name = name.into();
let source = source.into();
let contracts = HOOK_COMPONENTS
.with(|c| c.borrow().clone())
.unwrap_or_else(|| Arc::new(crate::components::BUILTIN.to_vec()));
let catalog = crate::components::Catalog {
contracts: &contracts,
lookup: &|_| None,
};
let compiled = crate::components::compile(&name, &source, &catalog).map_err(|e| {
minijinja::Error::new(
ErrorKind::SyntaxError,
format!("{name}:{}: {}", e.line, e.message),
)
})?;
env.add_template_owned(name, compiled)
}
impl Views {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
config: &Config,
routes: Arc<RouteTable>,
storage: Storage,
embedded: Option<&'static [(&'static str, &'static str)]>,
hooks: Arc<Vec<TemplateHook>>,
components: Arc<Vec<crate::components::Contract>>,
zone: crate::timezone::Zone,
versions: Arc<crate::embedded::AssetVersions>,
) -> Self {
let plugin_components = components.clone();
let dir = config.views_path.clone();
let watch = config.debug && embedded.is_none() && dir.is_dir();
let debug = config.debug;
let currency = config.currency.clone();
let reloader = AutoReloader::new(move |notifier| {
let mut env = Environment::new();
env.set_formatter(format_value);
if debug {
env.set_undefined_behavior(minijinja::UndefinedBehavior::SemiStrict);
}
let loader_dir = dir.clone();
let contracts: Arc<Vec<crate::components::Contract>> = Arc::new(
crate::components::BUILTIN
.iter()
.chain(components.iter())
.copied()
.collect(),
);
let loader_contracts = contracts.clone();
env.set_loader(move |name| {
let Some(src) = load(&loader_dir, embedded, name)? else {
return Ok(None);
};
let lookup = |n: &str| load(&loader_dir, embedded, n).ok().flatten();
let catalog = crate::components::Catalog {
contracts: &loader_contracts,
lookup: &lookup,
};
crate::components::compile(name, &src, &catalog)
.map(Some)
.map_err(|e| {
minijinja::Error::new(
ErrorKind::SyntaxError,
format!("{name}:{}: {}", e.line, e.message),
)
})
});
let routes = routes.clone();
env.add_function(
"route",
move |name: String, params: Rest<Value>| -> Result<Value, minijinja::Error> {
let (query, params): (Vec<&Value>, Vec<&Value>) =
params.iter().partition(|p| p.is_kwargs());
let params: Vec<&dyn std::fmt::Display> = params
.iter()
.map(|p| *p as &dyn std::fmt::Display)
.collect();
let mut url = routes.url(&name, ¶ms).map_err(|err| {
minijinja::Error::new(ErrorKind::InvalidOperation, err.to_string())
})?;
if let Some(kwargs) = query.first() {
let mut pairs = form_urlencoded::Serializer::new(String::new());
for key in kwargs.try_iter()? {
let value = kwargs.get_item(&key)?;
if value.is_none() || value.is_undefined() {
continue;
}
pairs.append_pair(&key.to_string(), &value.to_string());
}
let pairs = pairs.finish();
if !pairs.is_empty() {
url.push(if url.contains('?') { '&' } else { '?' });
url.push_str(&pairs.replace('&', "&"));
}
}
Ok(Value::from_safe_string(url))
},
);
env.add_function("page_url", |state: &minijinja::State, page: u32| -> Value {
let query = state
.lookup("request")
.and_then(|request| request.get_attr("query").ok())
.and_then(|query| query.as_str().map(str::to_owned))
.unwrap_or_default();
let mut url = form_urlencoded::Serializer::new(String::new());
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
if key != "page" {
url.append_pair(&key, &value);
}
}
url.append_pair("page", &page.to_string());
Value::from(format!("?{}", url.finish()))
});
env.add_function(
"query_with",
|state: &minijinja::State,
kwargs: minijinja::value::Kwargs|
-> Result<Value, minijinja::Error> {
let query = state
.lookup("request")
.and_then(|request| request.get_attr("query").ok())
.and_then(|query| query.as_str().map(str::to_owned))
.unwrap_or_default();
let keys: Vec<String> = kwargs.args().map(str::to_owned).collect();
let mut url = form_urlencoded::Serializer::new(String::new());
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
if key != "page" && !keys.iter().any(|k| *k == key) {
url.append_pair(&key, &value);
}
}
for key in &keys {
let value: Value = kwargs.get(key)?;
if !value.is_none() && !value.is_undefined() {
url.append_pair(key, &value.to_string());
}
}
kwargs.assert_all_used()?;
Ok(Value::from(format!("?{}", url.finish())))
},
);
env.add_function(
"query_fields",
|state: &minijinja::State, except: minijinja::value::Rest<String>| -> Value {
let query = state
.lookup("request")
.and_then(|request| request.get_attr("query").ok())
.and_then(|query| query.as_str().map(str::to_owned))
.unwrap_or_default();
let mut html = String::new();
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
if key != "page" && !except.iter().any(|k| *k == key) {
html.push_str(&format!(
r#"<input type="hidden" name="{}" value="{}">"#,
crate::toast::escape(&key),
crate::toast::escape(&value)
));
}
}
Value::from_safe_string(html)
},
);
env.add_function("method_field", |method: String| {
let method: String = method.chars().filter(char::is_ascii_alphabetic).collect();
Value::from_safe_string(format!(
"<input type=\"hidden\" name=\"{}\" value=\"{}\">",
crate::method::METHOD_FIELD,
method.to_ascii_uppercase()
))
});
let versions = versions.clone();
env.add_function("asset", move |path: String| {
let mut url = String::from("/");
crate::routing::encode(&mut url, path.trim_start_matches('/'), true);
if let Some(version) = versions.version(&path) {
url.push_str("?v=");
url.push_str(&version);
}
Value::from_safe_string(url)
});
let storage = storage.clone();
env.add_function("storage_url", move |key: String| {
Value::from_safe_string(storage.url(&key))
});
for name in REQUEST_GLOBALS {
env.add_global(*name, Value::from_object(RequestGlobal(name)));
}
crate::view_stack::register(&mut env);
env.add_function("renox_ui", |kwargs: minijinja::value::Kwargs| {
let styles: Option<bool> = kwargs.get("styles")?;
kwargs.assert_all_used()?;
Ok::<_, minijinja::Error>(Value::from_safe_string(crate::assets::ui_tags(
styles.unwrap_or(true),
)))
});
env.add_function("renox_calendar", || {
Value::from_safe_string(crate::assets::calendar_tags())
});
env.add_function("renox_live", || {
Value::from_safe_string(crate::assets::live_tags())
});
env.add_function(
"renox_icon",
|name: String, kwargs: minijinja::value::Kwargs| {
let size: Option<u32> = kwargs.get("size")?;
let label: Option<String> = kwargs.get("label")?;
let class: Option<String> = kwargs.get("class")?;
kwargs.assert_all_used()?;
Ok::<_, minijinja::Error>(Value::from_safe_string(crate::icons::svg(
&name,
size.unwrap_or(20),
label.as_deref(),
class.as_deref(),
)))
},
);
env.add_function("renox_grid", || {
Value::from_safe_string(crate::assets::grid_tags())
});
env.add_function("sparkline", crate::view_filters::sparkline);
env.add_filter("number", crate::view_filters::number);
env.add_filter("date", crate::view_filters::date(zone));
env.add_filter("since", crate::view_filters::since(zone));
env.add_filter("money", crate::view_filters::money(currency.clone()));
env.add_filter("words", crate::view_filters::words);
env.add_filter("markdown", crate::view_filters::markdown);
env.add_function("chart", crate::chart::chart(currency.clone()));
env.add_function("class_names", crate::view_filters::class_names);
env.add_function("rx_attr", crate::view_filters::rx_attr);
env.add_function("rx_attr_value", crate::view_filters::attr_value);
let _scope = HookScope::enter(contracts.clone());
for hook in hooks.iter() {
hook(&mut env);
}
if watch {
notifier.watch_path(&dir, true);
}
Ok(env)
});
Self {
reloader: Arc::new(reloader),
debug,
dir: config.views_path.clone(),
embedded,
plugin_components,
typed: Arc::new(Vec::new()),
shared: Arc::new(Vec::new()),
}
}
pub(crate) fn names(&self) -> Vec<String> {
let mut names = Vec::new();
match self.embedded {
Some(files) => names.extend(files.iter().map(|(name, _)| (*name).to_owned())),
None => walk(&self.dir, "", &mut names),
}
names.extend(BUILTIN.iter().map(|(name, _)| (*name).to_owned()));
if let Ok(env) = self.reloader.acquire_env() {
names.extend(env.templates().map(|(name, _)| name.to_owned()));
}
names.sort();
names.dedup();
names
}
pub(crate) fn custom_data(&self) -> serde_json::Value {
let contracts: Vec<crate::components::Contract> = crate::components::BUILTIN
.iter()
.chain(self.plugin_components.iter())
.copied()
.collect();
let sources: Vec<(String, String)> = self
.names()
.into_iter()
.filter(|n| n.starts_with("components/") && n.ends_with(".html"))
.filter_map(|n| {
let src = load(&self.dir, self.embedded, &n).ok().flatten()?;
src.contains("<rx-props").then_some((n, src))
})
.collect();
crate::components::custom_data_for(&contracts, &sources)
}
pub(crate) fn check(&self, routes: &RouteTable) -> Vec<ViewProblem> {
let names = self.names();
let env = match self.reloader.acquire_env() {
Ok(env) => env,
Err(err) => {
return vec![ViewProblem {
template: String::new(),
line: None,
message: err.to_string(),
}];
}
};
let mut problems = Vec::new();
let known: std::collections::HashSet<&str> = names.iter().map(String::as_str).collect();
for name in &names {
match env.get_template(name) {
Err(err) => problems.push(problem(name, &err)),
Ok(template) if !name.starts_with("renox/") => {
check_route_names(name, template.source(), routes, &mut problems);
check_includes(name, template.source(), &known, &mut problems);
}
Ok(_) => {}
}
}
for typed in self.typed.iter() {
self.check_typed(&env, typed, &mut problems);
}
problems
}
pub(crate) fn with_typed(mut self, typed: Vec<TypedView>, shared: Vec<String>) -> Self {
self.typed = Arc::new(typed);
self.shared = Arc::new(shared);
self
}
fn check_typed(
&self,
env: &minijinja::Environment<'_>,
typed: &TypedView,
problems: &mut Vec<ViewProblem>,
) {
let template = match env.get_template(typed.template) {
Ok(template) => template,
Err(err) if err.kind() != ErrorKind::TemplateNotFound => return,
Err(_) => {
problems.push(ViewProblem {
template: typed.template.to_owned(),
line: None,
message: format!("not found, but {} renders it", typed.name),
});
return;
}
};
let globals: std::collections::HashSet<&str> =
env.globals().map(|(name, _)| name).collect();
let mut unknown: Vec<String> = template
.undeclared_variables(false)
.into_iter()
.filter(|var| {
!typed.fields.contains(&var.as_str())
&& !REQUEST_GLOBALS.contains(&var.as_str())
&& !globals.contains(var.as_str())
&& !self.shared.iter().any(|key| key == var)
&& !var.starts_with("__rx")
})
.collect();
unknown.sort();
for var in unknown {
problems.push(ViewProblem {
template: typed.template.to_owned(),
line: first_use(template.source(), &var),
message: format!("\"{var}\" isn't a field of {}", typed.name),
});
}
}
pub fn exists(&self, name: &str) -> bool {
self.reloader
.acquire_env()
.is_ok_and(|env| env.get_template(name).is_ok())
}
pub fn render(&self, name: &str, ctx: impl Serialize) -> anyhow::Result<String> {
let env = self.reloader.acquire_env()?;
Ok(env.get_template(name)?.render(ctx)?)
}
fn render_view(
&self,
view: &View,
shared: Value,
globals: Value,
htmx: &Htmx,
) -> anyhow::Result<String> {
let env = self.reloader.acquire_env()?;
let template = env.get_template(&view.name)?;
let _current = CurrentGlobals::set(globals.clone());
let hidden = if self.debug {
hidden_by_imports(template.source(), &[&shared, &view.ctx])
} else {
Vec::new()
};
for name in &hidden {
tracing::warn!(
template = %view.name,
name = %name,
"the view gets a value named like a macro the template imports; the macro hides it (rename one)"
);
}
let ctx = merge_maps([shared, view.ctx.clone(), globals]);
let stacks = crate::view_stack::Scope::begin();
let rendered = match &view.fragment {
Some(block) if htmx.wants_fragment() => (|| {
let mut captured = template.render_captured_to(ctx, std::io::sink())?;
let mut out = captured.with_state_mut(|state| state.render_block(block))?;
for extra in &view.also {
out.push_str(&captured.with_state_mut(|state| state.render_block(extra))?);
}
Ok::<_, minijinja::Error>(out)
})(),
_ => template.render(ctx),
};
let html = rendered.map_err(|err| {
let err = anyhow::Error::from(err);
match hidden.as_slice() {
[] => err,
names => err.context(format!(
"{} imports a macro named like a value the view gets ({}): \
inside the template the name is the macro; rename the value or import the macro `as` another name",
view.name,
names.join(", ")
)),
}
})?;
Ok(stacks.finish(html))
}
fn render_error(
&self,
page: &ErrorPage,
debug: bool,
request: &str,
globals: Option<Value>,
) -> anyhow::Result<String> {
let env = self.reloader.acquire_env()?;
let ctx = context! {
status => page.status.as_u16(),
reason => reason(page.status),
detail => page.shown_detail(debug),
debug => debug,
request_line => debug.then_some(request),
template => page.template.as_deref().filter(|_| debug),
};
for name in [
format!("errors/{}.html", page.status.as_u16()),
"errors/default.html".to_owned(),
] {
let template = match env.get_template(&name) {
Ok(template) => template,
Err(err) if err.kind() == ErrorKind::TemplateNotFound => continue,
Err(err) => {
tracing::error!(error = ?err, template = %name, "the error page failed; showing Renox's");
break;
}
};
let rendered = match &globals {
Some(globals) => {
let _current = CurrentGlobals::set(globals.clone());
let stacks = crate::view_stack::Scope::begin();
template
.render(merge_maps([globals.clone(), ctx.clone()]))
.map(|html| stacks.finish(html))
}
None => template.render(ctx.clone()),
};
match rendered {
Ok(html) => return Ok(html),
Err(err) => {
tracing::error!(error = ?err, template = %name, "the error page failed; showing Renox's");
break;
}
}
}
Ok(env.get_template("renox/error.html")?.render(ctx)?)
}
}
#[derive(Debug, Clone)]
pub(crate) struct RenderedView(pub String);
pub(crate) const REQUEST_GLOBALS: &[&str] = &[
"app",
"auth",
"t",
"can",
"request",
"route_is",
"csrf_token",
"csrf_field",
"flash",
"errors",
"errors_in",
"error",
"old",
"has_old",
"renox_head",
"seo",
"csp_nonce",
"toasts",
"once",
];
thread_local! {
static CURRENT: std::cell::RefCell<Option<Value>> = const { std::cell::RefCell::new(None) };
}
struct CurrentGlobals(Option<Value>);
impl CurrentGlobals {
fn set(globals: Value) -> Self {
Self(CURRENT.with(|c| c.borrow_mut().replace(globals)))
}
}
impl Drop for CurrentGlobals {
fn drop(&mut self) {
let previous = self.0.take();
CURRENT.with(|c| *c.borrow_mut() = previous);
}
}
#[derive(Debug)]
struct RequestGlobal(&'static str);
impl RequestGlobal {
fn current(&self) -> Option<Value> {
CURRENT
.with(|c| c.borrow().clone())
.and_then(|globals| globals.get_attr(self.0).ok())
.filter(|v| !v.is_undefined())
}
}
impl minijinja::value::Object for RequestGlobal {
fn repr(self: &Arc<Self>) -> minijinja::value::ObjectRepr {
minijinja::value::ObjectRepr::Map
}
fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
self.current()?
.get_item(key)
.ok()
.filter(|v| !v.is_undefined())
}
fn enumerate(self: &Arc<Self>) -> minijinja::value::Enumerator {
match self
.current()
.and_then(|v| v.try_iter().ok().map(|keys| keys.collect::<Vec<_>>()))
{
Some(keys)
if self
.current()
.is_some_and(|v| v.kind() == minijinja::value::ValueKind::Map) =>
{
minijinja::value::Enumerator::Values(keys)
}
_ => minijinja::value::Enumerator::Empty,
}
}
fn is_true(self: &Arc<Self>) -> bool {
self.current().is_some_and(|v| v.is_true())
}
fn call(
self: &Arc<Self>,
state: &minijinja::State<'_, '_>,
args: &[Value],
) -> Result<Value, minijinja::Error> {
match self.current() {
Some(value) => value.call(state, args),
None => Err(minijinja::Error::new(
ErrorKind::InvalidOperation,
format!("`{}` is only available while rendering a page", self.0),
)),
}
}
fn render(self: &Arc<Self>, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.current() {
Some(value) => std::fmt::Display::fmt(&value, f),
None => Ok(()),
}
}
}
fn format_value(
out: &mut minijinja::Output,
state: &minijinja::State,
value: &Value,
) -> Result<(), minijinja::Error> {
use std::fmt::Write;
if let (minijinja::AutoEscape::Html, false, Some(text)) =
(state.auto_escape(), value.is_safe(), value.as_str())
{
for c in text.chars() {
let written = match c {
'&' => out.write_str("&"),
'<' => out.write_str("<"),
'>' => out.write_str(">"),
'"' => out.write_str("""),
'\'' => out.write_str("'"),
c => out.write_char(c),
};
written.map_err(|_| {
minijinja::Error::new(ErrorKind::WriteFailure, "could not write output")
})?;
}
return Ok(());
}
minijinja::escape_formatter(out, state, value)
}
fn load(
dir: &Path,
embedded: Option<&'static [(&'static str, &'static str)]>,
name: &str,
) -> Result<Option<String>, minijinja::Error> {
if let Some(files) = embedded {
if let Some((_, source)) = files.iter().find(|(file, _)| *file == name) {
return Ok(Some((*source).to_owned()));
}
} else if let Some(path) = safe_join(dir, name) {
match std::fs::read_to_string(&path) {
Ok(source) => return Ok(Some(source)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
return Err(minijinja::Error::new(
ErrorKind::InvalidOperation,
format!("could not read template {}", path.display()),
)
.with_source(err));
}
}
}
Ok(BUILTIN
.iter()
.find(|(builtin, _)| *builtin == name)
.map(|(_, source)| (*source).to_owned()))
}
fn check_route_names(
template: &str,
source: &str,
routes: &RouteTable,
problems: &mut Vec<ViewProblem>,
) {
static ROUTE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r#"\broute\(\s*["']([^"']+)["']"#).expect("valid regex")
});
for caps in ROUTE.captures_iter(source) {
let name = &caps[1];
if routes.path(name).is_some() {
continue;
}
let hint = crate::components::suggest::did_you_mean(name, routes.names())
.map(|x| format!("; did you mean \"{x}\"?"))
.unwrap_or_default();
let offset = caps.get(0).map_or(0, |m| m.start());
problems.push(ViewProblem {
template: template.to_owned(),
line: Some(source[..offset].matches('\n').count() + 1),
message: format!("unknown route \"{name}\"{hint}"),
});
}
}
fn check_includes(
template: &str,
source: &str,
known: &std::collections::HashSet<&str>,
problems: &mut Vec<ViewProblem>,
) {
static INCLUDE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r#"\{%-?\s*include\s+["']([^"']+)["']\s*-?%\}"#).expect("valid regex")
});
for caps in INCLUDE.captures_iter(source) {
let name = &caps[1];
if known.contains(name) {
continue;
}
let hint = crate::components::suggest::did_you_mean(name, known.iter().copied())
.map(|x| format!("; did you mean \"{x}\"?"))
.unwrap_or_default();
let offset = caps.get(0).map_or(0, |m| m.start());
problems.push(ViewProblem {
template: template.to_owned(),
line: Some(source[..offset].matches('\n').count() + 1),
message: format!(
"<rx-include> (or include) names a template that does not exist: \"{name}\"{hint}"
),
});
}
}
fn problem(name: &str, err: &minijinja::Error) -> ViewProblem {
let detail = err.detail().unwrap_or("could not be compiled");
if let Some(rest) = detail.strip_prefix(name).and_then(|r| r.strip_prefix(':'))
&& let Some((line, message)) = rest.split_once(": ")
&& let Ok(line) = line.parse::<usize>()
{
return ViewProblem {
template: name.to_owned(),
line: Some(line),
message: message.to_owned(),
};
}
ViewProblem {
template: name.to_owned(),
line: err.line(),
message: detail.to_owned(),
}
}
fn safe_join(dir: &Path, name: &str) -> Option<PathBuf> {
let mut path = dir.to_path_buf();
for component in Path::new(name).components() {
match component {
PathPart::Normal(part) => path.push(part),
_ => return None,
}
}
Some(path)
}
#[derive(Clone)]
pub struct View {
name: String,
ctx: Value,
fragment: Option<String>,
also: Vec<String>,
status: StatusCode,
}
fn first_use(source: &str, var: &str) -> Option<usize> {
let word = regex::Regex::new(&format!(r"\b{}\b", regex::escape(var))).ok()?;
let mut rest = source;
let mut offset = 0;
while let Some(open) = rest.find('{') {
let after = &rest[open..];
let close = match after.get(..2) {
Some("{{") => "}}",
Some("{%") => "%}",
_ => {
offset += open + 1;
rest = &rest[open + 1..];
continue;
}
};
let end = after.find(close).map_or(after.len(), |i| i + 2);
if word.is_match(&after[..end]) {
return Some(source[..offset + open].matches('\n').count() + 1);
}
offset += open + end;
rest = &rest[open + end..];
}
None
}
pub fn view(name: impl Into<String>, ctx: impl Serialize) -> View {
View {
name: name.into(),
ctx: Value::from_serialize(ctx),
fragment: None,
also: Vec::new(),
status: StatusCode::OK,
}
}
impl View {
pub fn typed<T: ViewData>(page: T) -> View {
view(T::TEMPLATE, page)
}
pub fn fragment(mut self, block: impl Into<String>) -> Self {
self.fragment = Some(block.into());
self
}
pub fn also(mut self, block: impl Into<String>) -> Self {
self.also.push(block.into());
self
}
pub fn status(mut self, status: StatusCode) -> Self {
self.status = status;
self
}
}
impl IntoResponse for View {
fn into_response(self) -> Response {
let mut res = self.status.into_response();
res.extensions_mut().insert(self);
res
}
}
pub(crate) async fn middleware(
State(state): State<AppState>,
req: Request,
next: Next,
) -> Response {
let session = req.extensions().get::<Session>().cloned();
let current_user = req.extensions().get::<CurrentUser>().cloned();
let htmx = Htmx::from_headers(req.headers());
let method = req.method().clone();
let path = req.uri().path().to_owned();
let query = req.uri().query().unwrap_or_default().to_owned();
let route = crate::routing::CurrentRoute::of(req.method(), req.extensions(), &state)
.name()
.map(str::to_owned);
let nonce = req
.extensions()
.get::<crate::security::CspNonce>()
.map(|n| n.0.clone())
.unwrap_or_default();
let (wants_json, referer) = {
let header = |name| req.headers().get(name).and_then(|v| v.to_str().ok());
let wants_json = header(ACCEPT).is_some_and(|v| v.contains("application/json"))
|| header(CONTENT_TYPE).is_some_and(|v| v.starts_with("application/json"));
(wants_json, crate::htmx::same_site_referer(req.headers()))
};
let request_line = if query.is_empty() {
format!("{method} {path}")
} else {
format!("{method} {path}?{query}")
};
let mut res = next.run(req).await;
let locale = crate::i18n::current_locale(&state);
if let Some(crate::toast::PendingToasts(toasts)) =
res.extensions_mut().remove::<crate::toast::PendingToasts>()
{
if htmx.request
&& !res.headers().contains_key("hx-redirect")
&& !res.headers().contains_key("hx-refresh")
{
crate::htmx::add_trigger(
&mut res,
crate::toast::EVENT,
serde_json::json!({ "toasts": toasts }),
);
} else if let Some(session) = &session {
let mut waiting: Vec<crate::Toast> =
session.get(crate::toast::SESSION_KEY).unwrap_or_default();
waiting.extend(toasts);
if let Err(err) = session.put(crate::toast::SESSION_KEY, &waiting) {
return err.into_response();
}
}
}
if let Some(failed) = res.extensions_mut().remove::<ValidationError>() {
if htmx.request || wants_json {
return res;
}
if let Some(session) = &session {
let bag = failed.bag().map(str::to_owned);
let input = if failed.input.is_empty() {
crate::context::get::<crate::validation::extract::SubmittedInput>()
.map(|submitted| submitted.0)
.unwrap_or_default()
} else {
failed.input
};
let flashed = match &bag {
Some(bag) => session.flash_errors_in(bag, &failed.errors),
None => session.flash_errors(&failed.errors),
}
.and_then(|()| session.flash_input(&input));
if let Err(err) = flashed {
return err.into_response();
}
}
return Redirect::to(referer.as_deref().unwrap_or("/")).into_response();
}
if let Some(session) = &session
&& crate::analytics::has_pending(session)
&& htmx.request
&& crate::analytics::deliverable_by_htmx(&res)
{
crate::analytics::add_trigger(&mut res, crate::analytics::take(session));
}
if let Some(view) = res.extensions_mut().remove::<View>() {
let mut shared = std::collections::BTreeMap::new();
for (key, compute) in state.shares.iter() {
let ctx = ViewContext {
state: state.clone(),
user: current_user.as_ref().and_then(|c| c.user.clone()),
locale: locale.clone(),
path: path.clone(),
session: session.clone(),
};
match compute(ctx).await {
Ok(value) => {
shared.insert(key.clone(), value);
}
Err(err) => {
let err = match err {
Error::Internal(err) => err,
other => anyhow::anyhow!("{other:?}"),
};
let mut failed =
Error::Internal(err.context(format!("sharing `{key}` with views")))
.into_response();
return match failed.extensions_mut().remove::<ErrorPage>() {
Some(page) => error_response(
&state,
page,
failed,
wants_json,
&htmx,
&request_line,
None,
),
None => failed,
};
}
}
}
let events = match &session {
Some(session) if !htmx.request => crate::analytics::take(session),
_ => Vec::new(),
};
let globals = globals(
&state,
session.as_ref(),
current_user.clone(),
&htmx,
&Requested {
path: &path,
query: &query,
route: route.as_deref(),
nonce: &nonce,
events: &events,
},
&locale,
);
return match state
.views
.render_view(&view, Value::from_serialize(&shared), globals, &htmx)
{
Ok(html) => {
let mut page = with_html(res, html);
page.extensions_mut()
.insert(RenderedView(view.name.clone()));
page
}
Err(err) => {
let mut failed = Error::Internal(err.context(format!("rendering {}", view.name)))
.into_response();
match failed.extensions_mut().remove::<ErrorPage>() {
Some(page) => {
error_response(&state, page, failed, wants_json, &htmx, &request_line, None)
}
None => failed,
}
}
};
}
if let Some(page) = res.extensions_mut().remove::<ErrorPage>() {
let globals = if !wants_json || htmx.request {
let mut shared = std::collections::BTreeMap::new();
for (key, compute) in state.shares.iter() {
let ctx = ViewContext {
state: state.clone(),
user: current_user.as_ref().and_then(|c| c.user.clone()),
locale: locale.clone(),
path: path.clone(),
session: session.clone(),
};
match compute(ctx).await {
Ok(value) => {
shared.insert(key.clone(), value);
}
Err(err) => {
tracing::warn!(error = ?err, key = %key, "a shared view value failed on an error page")
}
}
}
let page_globals = globals(
&state,
session.as_ref(),
current_user,
&htmx,
&Requested {
path: &path,
query: &query,
route: route.as_deref(),
nonce: &nonce,
events: &[],
},
&locale,
);
Some(merge_maps([page_globals, Value::from_serialize(&shared)]))
} else {
None
};
return error_response(&state, page, res, wants_json, &htmx, &request_line, globals);
}
res
}
fn error_response(
state: &AppState,
page: ErrorPage,
res: Response,
wants_json: bool,
htmx: &Htmx,
request_line: &str,
globals: Option<Value>,
) -> Response {
let debug = state.config.debug;
if wants_json && !htmx.request {
let mut json = page.json(debug);
for (name, value) in res.headers() {
if name != CONTENT_TYPE && name != CONTENT_LENGTH {
json.headers_mut().insert(name.clone(), value.clone());
}
}
return json;
}
let html = state
.views
.render_error(&page, debug, request_line, globals)
.unwrap_or_else(|err| {
tracing::error!(error = ?err, "could not render the error page");
crate::error::error_page(page.status, page.shown_detail(debug))
});
with_html(res, html)
}
fn with_html(mut res: Response, html: String) -> Response {
let headers = res.headers_mut();
headers.insert(
CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
headers.remove(CONTENT_LENGTH);
*res.body_mut() = Body::from(html);
res
}
#[derive(Debug)]
struct Flashed(serde_json::Map<String, serde_json::Value>);
impl minijinja::value::Object for Flashed {
fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
let key = key.as_str()?;
Some(
self.0
.get(key)
.map(Value::from_serialize)
.unwrap_or_else(|| Value::from("")),
)
}
fn enumerate(self: &Arc<Self>) -> minijinja::value::Enumerator {
minijinja::value::Enumerator::Values(
self.0.keys().map(|k| Value::from(k.as_str())).collect(),
)
}
fn repr(self: &Arc<Self>) -> minijinja::value::ObjectRepr {
minijinja::value::ObjectRepr::Map
}
}
struct Requested<'a> {
path: &'a str,
query: &'a str,
route: Option<&'a str>,
nonce: &'a str,
events: &'a [crate::analytics::Event],
}
pub(crate) fn translate_function(state: &AppState, locale: &str) -> Value {
let translator = state.translator.clone();
let (locale, fallback) = (locale.to_owned(), state.config.fallback_locale.clone());
Value::from_function(
move |key: String, kwargs: minijinja::value::Kwargs| -> Result<Value, minijinja::Error> {
let mut params = Vec::new();
let mut count = None;
for name in kwargs.args() {
let value: Value = kwargs.get(name)?;
if name == "count" {
match i64::try_from(value.clone()) {
Ok(whole) => count = Some(whole),
Err(_) => {
let shown = value.to_string();
let number = f64::try_from(value)
.ok()
.or_else(|| shown.trim().parse::<f64>().ok());
count = number.map(plural_count);
params.push((name.to_owned(), shown));
}
}
} else {
params.push((name.to_owned(), value.to_string()));
}
}
kwargs.assert_all_used()?;
let params: Vec<(&str, String)> = params
.iter()
.map(|(k, v)| (k.as_str(), v.clone()))
.collect();
let text = translator.get(&locale, &fallback, &key);
Ok(Value::from(crate::i18n::format(&text, ¶ms, count)))
},
)
}
fn plural_count(number: f64) -> i64 {
if number == 1.0 {
1
} else {
match number.round() as i64 {
1 => 2,
other => other,
}
}
}
fn globals(
state: &AppState,
session: Option<&Session>,
current_user: Option<CurrentUser>,
htmx: &Htmx,
requested: &Requested,
locale: &str,
) -> Value {
let config = &state.config;
let token = session.map(Session::token).unwrap_or_default();
let strict = state.security.mode == crate::CspMode::Strict;
let head = Value::from_safe_string(format!(
"{}\n{}",
assets::head_tags(&token, state.live.is_some(), strict),
crate::seo::head_tags(&state.config, requested.nonce, requested.events)
));
let seo = {
let (config, path, locale) = (
state.config.clone(),
requested.path.to_owned(),
locale.to_owned(),
);
Value::from_function(
move |kwargs: minijinja::value::Kwargs| -> Result<Value, minijinja::Error> {
crate::seo::tags(&config, &path, &locale, &kwargs).map(Value::from_safe_string)
},
)
};
let nonce = requested.nonce.to_owned();
let field = Value::from_safe_string(format!(
"<input type=\"hidden\" name=\"{}\" value=\"{token}\">",
crate::csrf::CSRF_FIELD
));
let old_input = session.cloned();
let has_old = session.is_some_and(Session::has_old_input);
let toast_session = session.cloned();
let toast_texts = crate::toast::RegionTexts {
dismiss: state
.translator
.get(locale, &state.config.fallback_locale, "ui.dismiss"),
failed: state
.translator
.get(locale, &state.config.fallback_locale, "ui.request_failed"),
};
let seen = std::sync::Mutex::new(std::collections::HashSet::<String>::new());
let user = current_user.as_ref().and_then(|c| c.user.clone());
let roles = current_user
.as_ref()
.map(|c| c.grants.roles())
.unwrap_or_default();
let gate_user = current_user.and_then(|c| Some((c.user?, c.gates, c.grants)));
let error_bag = session.and_then(Session::error_bag);
let flashed_errors = match (session, &error_bag) {
(Some(session), Some(bag)) => session.errors_in(bag),
(Some(session), None) => session.errors(),
(None, _) => Default::default(),
};
let errors = if error_bag.is_none() {
flashed_errors.clone()
} else {
Default::default()
};
let first_errors: std::collections::BTreeMap<String, String> = flashed_errors
.iter()
.filter_map(|(field, messages)| {
messages
.get(0)
.and_then(|m| m.as_str())
.map(|m| (field.clone(), m.to_owned()))
})
.collect();
context! {
app => context! {
name => config.name,
env => format!("{:?}", config.env).to_lowercase(),
debug => config.debug,
url => config.url,
locale => locale,
currency => config.currency,
},
auth => context! {
check => user.is_some(),
user => user.as_deref(),
roles => roles,
},
t => translate_function(state, locale),
can => Value::from_function(move |ability: String, target: Option<Value>| {
match target {
Some(target) => target
.get_attr("_can")
.and_then(|can| can.get_attr(&ability))
.is_ok_and(|allowed| allowed.is_true()),
None => gate_user
.as_ref()
.is_some_and(|(user, gates, grants)| gates.check(user, grants, &ability)),
}
}),
request => context! {
path => requested.path,
query => requested.query,
route => requested.route,
htmx => htmx.request,
boosted => htmx.boosted,
},
route_is => {
let route = requested.route.map(str::to_owned);
Value::from_function(move |patterns: minijinja::value::Rest<String>| {
route.as_deref().is_some_and(|name| {
patterns
.iter()
.any(|pattern| crate::routing::route_name_matches(name, pattern))
})
})
},
csrf_token => token,
flash => Value::from_object(Flashed(session.map(Session::flashed).unwrap_or_default())),
errors => errors,
errors_in => {
let bag = error_bag.clone();
Value::from_function(move |name: String| {
if bag.as_deref() == Some(name.as_str()) {
Value::from_serialize(&flashed_errors)
} else {
Value::from_serialize(serde_json::Map::new())
}
})
},
error => Value::from_function(move |field: String, kwargs: minijinja::value::Kwargs| {
let bag: Option<String> = kwargs.get("bag")?;
kwargs.assert_all_used()?;
if bag != error_bag {
return Ok::<_, minijinja::Error>(String::new());
}
let field = crate::validation::nested::normalize(&field);
Ok(first_errors
.get(&field)
.or_else(|| {
let prefix = format!("{field}.");
first_errors
.iter()
.find(|(key, _)| key.starts_with(&prefix))
.map(|(_, message)| message)
})
.cloned()
.unwrap_or_default())
}),
renox_head => Value::from_function(move || head.clone()),
seo => seo,
csp_nonce => Value::from_function(move || nonce.clone()),
csrf_field => Value::from_function(move || field.clone()),
toasts => Value::from_function(move |kwargs: minijinja::value::Kwargs| {
let position: Option<String> = kwargs.get("position")?;
kwargs.assert_all_used()?;
let waiting: Vec<crate::Toast> = toast_session
.as_ref()
.and_then(|s| s.pull(crate::toast::SESSION_KEY))
.unwrap_or_default();
Ok::<_, minijinja::Error>(Value::from_safe_string(crate::toast::region(
&waiting,
&toast_texts,
position.as_deref().unwrap_or("top"),
)))
}),
once => Value::from_function(move |key: String| {
seen.lock().unwrap_or_else(|e| e.into_inner()).insert(key)
}),
has_old => Value::from_function(move || has_old),
old => Value::from_function(move |field: String, default: Option<Value>| {
old_input
.as_ref()
.and_then(|s| s.old(&field))
.map(Value::from_serialize)
.or(default)
.unwrap_or_else(|| Value::from(""))
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn views_in(dir: &Path) -> Views {
let config = Config {
views_path: dir.to_path_buf(),
..Config::default()
};
Views::new(
&config,
Arc::new(RouteTable::default()),
Storage::from_config(&config).unwrap(),
None,
Arc::new(Vec::new()),
Arc::new(Vec::new()),
config.timezone,
Arc::new(crate::embedded::AssetVersions::new(dir, None)),
)
}
#[test]
fn check_finds_every_broken_template_with_its_line() {
let dir = tempfile::tempdir().unwrap();
let write = |name: &str, body: &str| {
let path = dir.path().join(name);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, body).unwrap();
};
write("good.html", "<h1>{{ 1 + 1 }}</h1>");
write("sub/typo.html", "<p>x</p>\n<rx-tabel>y</rx-tabel>\n");
write("bad.html", "a\nb\n{% if x %}\nc\n");
write(".hidden.html", "{% if %}");
write("notes.md", "{% if %}");
let views = views_in(dir.path());
let names = views.names();
assert!(names.contains(&"good.html".to_owned()));
assert!(names.contains(&"sub/typo.html".to_owned()));
assert!(names.contains(&"renox/error.html".to_owned()));
assert!(
!names
.iter()
.any(|n| n.contains("hidden") || n.ends_with(".md"))
);
assert!(names.windows(2).all(|w| w[0] < w[1]));
let problems = views.check(&RouteTable::default());
assert_eq!(problems.len(), 2, "{problems:?}");
assert_eq!(problems[0].template, "bad.html");
assert!(problems[0].line.is_some_and(|l| l >= 3), "{problems:?}");
assert_eq!(problems[1].template, "sub/typo.html");
assert_eq!(problems[1].line, Some(2));
assert!(problems[1].message.contains("rx-tabel"), "{problems:?}");
assert!(
problems[1]
.to_string()
.starts_with("sub/typo.html:2: unknown component"),
);
}
#[test]
fn check_reports_unknown_route_names_with_a_suggestion() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("p.html"),
"<a href=\"{{ route('products.index') }}\">ok</a>\n\
<a href=\"{{ route('products.indx') }}\">typo</a>\n\
<a href=\"{{ route('shop.home') }}\">domain</a>\n\
<a href=\"{{ route('nothing.like.it') }}\">far</a>\n",
)
.unwrap();
let mut routes = RouteTable::default();
routes
.insert("products.index".into(), "/products".into())
.unwrap();
routes.insert("shop.home".into(), "/".into()).unwrap();
routes.set_domain("shop.home", "shop.example.com");
let problems = views_in(dir.path()).check(&routes);
assert_eq!(problems.len(), 2, "{problems:?}");
assert_eq!(problems[0].line, Some(2));
assert_eq!(
problems[0].to_string(),
"p.html:2: unknown route \"products.indx\"; did you mean \"products.index\"?"
);
assert_eq!(problems[1].line, Some(4));
assert!(!problems[1].message.contains("did you mean"));
}
fn env() -> Environment<'static> {
let mut env = Environment::new();
for name in REQUEST_GLOBALS {
env.add_global(*name, Value::from_object(RequestGlobal(name)));
}
env
}
#[test]
fn request_globals_read_the_page_being_rendered() {
let env = env();
let page = "{% if auth %}[{{ auth.name }}]{% endif %}\
{% for key in flash %}{{ key }}={{ flash[key] }};{% endfor %}\
{{ t('hi') }}|{{ request }}|{% for e in errors %}{{ e }}{% endfor %}.";
let outside = env
.render_str(
"{% if auth %}in{% endif %}[{{ request }}]{% for k in flash %}{{ k }}{% endfor %}",
(),
)
.unwrap();
assert_eq!(outside, "[]");
let err = env.render_str("{{ t('hi') }}", ()).unwrap_err();
assert!(
err.to_string()
.contains("`t` is only available while rendering a page"),
"{err}"
);
let globals = minijinja::context! {
auth => minijinja::context! { name => "Ann" },
flash => minijinja::context! { status => "Saved" },
t => Value::from_function(|key: String| format!("({key})")),
request => "GET /",
errors => vec!["a"],
};
let current = CurrentGlobals::set(globals);
assert_eq!(
env.render_str(page, ()).unwrap(),
"[Ann]status=Saved;(hi)|GET /|."
);
drop(current);
assert_eq!(env.render_str("{{ request }}", ()).unwrap(), "");
}
}