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
21pub(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#[derive(Clone)]
119pub struct Views {
120 reloader: Arc<AutoReloader>,
121 debug: bool,
123 dir: PathBuf,
125 embedded: Option<&'static [(&'static str, &'static str)]>,
127 plugin_components: Arc<Vec<crate::components::Contract>>,
129 typed: Arc<Vec<TypedView>>,
131 shared: Arc<Vec<String>>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137pub(crate) struct TypedView {
138 pub name: String,
140 pub template: &'static str,
142 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
156fn 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
163pub trait ViewData: Serialize {
194 const TEMPLATE: &'static str;
196 const FIELDS: &'static [&'static str];
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
203pub(crate) struct ViewProblem {
204 pub template: String,
206 pub line: Option<usize>,
208 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
221fn 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#[non_exhaustive]
248#[derive(Clone)]
249pub struct ViewContext {
250 pub state: AppState,
252 pub user: Option<Arc<crate::auth::User>>,
254 pub locale: String,
256 pub path: String,
258 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
295fn 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
336pub(crate) type TemplateHook = Arc<dyn Fn(&mut Environment<'static>) + Send + Sync>;
339
340thread_local! {
341 static HOOK_COMPONENTS: std::cell::RefCell<Option<Arc<Vec<crate::components::Contract>>>> =
343 const { std::cell::RefCell::new(None) };
344}
345
346struct 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
364pub 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 #[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 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 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 let mut url = routes.url(&name, ¶ms).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('&', "&"));
483 }
484 }
485 Ok(Value::from_safe_string(url))
486 },
487 );
488 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 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 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 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 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 env.add_function("renox_live", || {
601 Value::from_safe_string(crate::assets::live_tags())
602 });
603 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 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 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 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 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 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 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 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 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 && !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 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 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 let _current = CurrentGlobals::set(globals.clone());
801 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 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 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 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#[derive(Debug, Clone)]
902pub(crate) struct RenderedView(pub String);
903
904pub(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 static CURRENT: std::cell::RefCell<Option<Value>> = const { std::cell::RefCell::new(None) };
931}
932
933struct 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#[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
1018fn 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("&"),
1033 '<' => out.write_str("<"),
1034 '>' => out.write_str(">"),
1035 '"' => out.write_str("""),
1036 '\'' => out.write_str("'"),
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
1076fn 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
1103fn 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
1134fn 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#[derive(Clone)]
1182pub struct View {
1183 name: String,
1184 ctx: Value,
1185 fragment: Option<String>,
1186 also: Vec<String>,
1188 status: StatusCode,
1189}
1190
1191fn 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
1217pub 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 pub fn typed<T: ViewData>(page: T) -> View {
1247 view(T::TEMPLATE, page)
1248 }
1249
1250 pub fn fragment(mut self, block: impl Into<String>) -> Self {
1252 self.fragment = Some(block.into());
1253 self
1254 }
1255
1256 pub fn also(mut self, block: impl Into<String>) -> Self {
1268 self.also.push(block.into());
1269 self
1270 }
1271
1272 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
1287pub(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 (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 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 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 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 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 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 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 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 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
1514fn 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 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#[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
1583struct Requested<'a> {
1585 path: &'a str,
1586 query: &'a str,
1587 route: Option<&'a str>,
1589 nonce: &'a str,
1590 events: &'a [crate::analytics::Event],
1592}
1593
1594pub(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 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, ¶ms, count)))
1630 },
1631 )
1632}
1633
1634fn 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 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 => 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 => {
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 => {
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 => 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 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 => 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 once => Value::from_function(move |key: String| {
1828 seen.lock().unwrap_or_else(|e| e.into_inner()).insert(key)
1829 }),
1830 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 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 #[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 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}