use std::collections::HashSet;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::OnceLock;
use minijinja::{AutoEscape, Environment};
use syntect::highlighting::ThemeSet;
use syntect::html::{ClassStyle, ClassedHTMLGenerator, css_for_theme_with_class_style};
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
tokio::task_local! {
pub static CURRENT_USER: Option<minijinja::Value>;
pub static CURRENT_CSRF: Option<String>;
pub static CURRENT_USER_LAZY: LazyUser;
}
type UserFut = Pin<Box<dyn Future<Output = minijinja::Value> + Send>>;
type UserResolver = Arc<dyn Fn() -> UserFut + Send + Sync>;
#[derive(Clone)]
pub struct LazyUser {
cell: Arc<tokio::sync::OnceCell<minijinja::Value>>,
resolver: UserResolver,
}
impl LazyUser {
pub fn new<F, Fut>(resolver: F) -> Self
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = minijinja::Value> + Send + 'static,
{
Self {
cell: Arc::new(tokio::sync::OnceCell::new()),
resolver: Arc::new(move || Box::pin(resolver())),
}
}
fn resolve_blocking(&self) -> minijinja::Value {
use tokio::runtime::{Handle, RuntimeFlavor};
let Ok(handle) = Handle::try_current() else {
return anonymous_user_value();
};
if handle.runtime_flavor() == RuntimeFlavor::CurrentThread {
tracing::warn!(
"umbral::templates: lazy `user` needs a multi-thread runtime; rendering anonymous"
);
return anonymous_user_value();
}
let cell = self.cell.clone();
let resolver = self.resolver.clone();
tokio::task::block_in_place(move || {
handle.block_on(async move { cell.get_or_init(|| resolver()).await.clone() })
})
}
fn into_proxy_value(self) -> minijinja::Value {
minijinja::Value::from_object(LazyUserProxy(self))
}
}
struct LazyUserProxy(LazyUser);
impl std::fmt::Debug for LazyUserProxy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("LazyUserProxy")
}
}
impl std::fmt::Display for LazyUserProxy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let resolved = self.0.resolve_blocking();
std::fmt::Display::fmt(&resolved, f)
}
}
impl minijinja::value::Object for LazyUserProxy {
fn get_value(self: &Arc<Self>, key: &minijinja::Value) -> Option<minijinja::Value> {
let resolved = self.0.resolve_blocking();
resolved.get_item(key).ok()
}
fn is_true(self: &Arc<Self>) -> bool {
self.0.resolve_blocking().is_true()
}
}
pub async fn with_current_user_lazy<F: Future>(lazy: LazyUser, fut: F) -> F::Output {
CURRENT_USER_LAZY.scope(lazy, fut).await
}
pub async fn with_current_user<F: std::future::Future>(
user: Option<minijinja::Value>,
fut: F,
) -> F::Output {
CURRENT_USER.scope(user, fut).await
}
pub async fn with_current_csrf<F: std::future::Future>(token: Option<String>, fut: F) -> F::Output {
CURRENT_CSRF.scope(token, fut).await
}
pub fn current_csrf() -> Option<String> {
CURRENT_CSRF.try_with(|t| t.clone()).ok().flatten()
}
static WATCHED_DIRS: OnceLock<Vec<PathBuf>> = OnceLock::new();
use serde::Serialize;
static ENGINE: OnceLock<Environment<'static>> = OnceLock::new();
pub type TemplateRegistrar = Box<dyn Fn(&mut Environment<'static>) + Send + Sync>;
static REGISTRARS: OnceLock<Vec<TemplateRegistrar>> = OnceLock::new();
fn url_scheme_is_safe(url: &str) -> bool {
let trimmed = url.trim();
let mut scheme_end = None;
for (i, c) in trimmed.char_indices() {
match c {
':' => {
scheme_end = Some(i);
break;
}
'/' | '?' | '#' => break,
_ => {}
}
}
let Some(end) = scheme_end else {
return true; };
let scheme = &trimmed[..end];
let mut chars = scheme.chars();
let well_formed = matches!(chars.next(), Some(c) if c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'));
if !well_formed {
return false;
}
let lower = scheme.to_ascii_lowercase();
lower == "http" || lower == "https"
}
fn register_img_filter(env: &mut Environment<'static>) {
env.add_filter(
"img",
|url: String,
kwargs: minijinja::value::Kwargs|
-> Result<minijinja::Value, minijinja::Error> {
let alt: String = kwargs.get::<Option<String>>("alt")?.unwrap_or_default();
let width: Option<i64> = kwargs.get("width")?;
let height: Option<i64> = kwargs.get("height")?;
let class: Option<String> = kwargs.get("class")?;
kwargs.assert_all_used()?;
let url = if url_scheme_is_safe(&url) {
url
} else {
String::new()
};
let mut out = String::with_capacity(url.len() + 128);
out.push_str("<img src=\"");
html_escape_into(&mut out, &url);
out.push_str("\" alt=\"");
html_escape_into(&mut out, &alt);
out.push_str("\" loading=\"lazy\" decoding=\"async\"");
if let Some(w) = width {
out.push_str(" width=\"");
out.push_str(&w.to_string());
out.push('"');
}
if let Some(h) = height {
out.push_str(" height=\"");
out.push_str(&h.to_string());
out.push('"');
}
if let Some(c) = class {
if !c.is_empty() {
out.push_str(" class=\"");
html_escape_into(&mut out, &c);
out.push('"');
}
}
out.push('>');
Ok(minijinja::Value::from_safe_string(out))
},
);
}
fn register_static_function(env: &mut Environment<'static>, static_url: String) {
env.add_function("static", move |path: String| -> String {
if let Some(hashed) = crate::static_files::manifest_lookup(&path) {
return join_static_url(&static_url, hashed);
}
join_static_url(&static_url, &path)
});
}
fn join_static_url(static_url: &str, path: &str) -> String {
format!("{}{}", static_url, path.trim_start_matches('/'))
}
pub fn resolve_static_url(path: &str) -> String {
let static_url = crate::settings::get_opt()
.map(|s| s.static_url.clone())
.unwrap_or_else(|| "/static/".to_string());
if let Some(hashed) = crate::static_files::manifest_lookup(path) {
return join_static_url(&static_url, hashed);
}
join_static_url(&static_url, path)
}
fn register_media_url_function(env: &mut Environment<'static>) {
env.add_function("media_url", |key: String| -> String {
if key.is_empty() {
return String::new();
}
crate::storage::storage_opt()
.map(|s| s.url(&key))
.unwrap_or(key)
});
}
fn register_querystring_with_function(env: &mut Environment<'static>) {
env.add_function(
"querystring_with",
|current_query: String, key: String, value: minijinja::Value| -> String {
crate::pagination::querystring_with(¤t_query, &key, &value.to_string())
},
);
}
fn register_markdown_filter(env: &mut Environment<'static>) {
env.add_filter("markdown", |input: String| -> minijinja::Value {
minijinja::Value::from_safe_string(render_markdown(&input))
});
}
fn register_highlight_styles_function(env: &mut Environment<'static>) {
env.add_function("highlight_styles", || -> minijinja::Value {
minijinja::Value::from_safe_string(format!("<style>{}</style>", highlight_css()))
});
}
fn register_now_function(env: &mut Environment<'static>) {
env.add_function("now", |fmt: Option<String>| -> String {
let now = chrono::Utc::now();
match fmt {
Some(f) if !f.is_empty() => now.format(&f).to_string(),
_ => now.to_rfc3339(),
}
});
}
fn register_currency_filter(env: &mut Environment<'static>) {
env.add_filter("currency", |amount: f64, code: Option<String>| -> String {
let code = code.unwrap_or_else(|| "USD".to_string());
let symbol = match code.as_str() {
"USD" | "AUD" | "CAD" | "NZD" => "$",
"EUR" => "€",
"GBP" => "£",
"JPY" | "CNY" => "¥",
"KES" => "KSh ",
_ => "",
};
let sign = if amount < 0.0 { "-" } else { "" };
let body = group_thousands(amount.abs());
if symbol.is_empty() {
format!("{sign}{body} {code}")
} else {
format!("{sign}{symbol}{body}")
}
});
}
fn group_thousands(amount: f64) -> String {
let negative = amount.is_sign_negative() && amount != 0.0;
let formatted = format!("{:.2}", amount.abs());
let (int_part, frac_part) = formatted.split_once('.').unwrap_or((&formatted, "00"));
let mut grouped = String::new();
let digits: Vec<char> = int_part.chars().collect();
for (i, ch) in digits.iter().enumerate() {
if i > 0 && (digits.len() - i) % 3 == 0 {
grouped.push(',');
}
grouped.push(*ch);
}
format!("{}{grouped}.{frac_part}", if negative { "-" } else { "" })
}
fn register_sanitize_filter(env: &mut Environment<'static>) {
env.add_filter("sanitize", |input: String| -> minijinja::Value {
minijinja::Value::from_safe_string(sanitize_html(&input))
});
}
pub fn sanitize_html(input: &str) -> String {
ammonia::clean(input)
}
const HL_PREFIX: &str = "hl-";
fn hl_class_style() -> ClassStyle {
ClassStyle::SpacedPrefixed { prefix: HL_PREFIX }
}
fn syntax_set() -> &'static SyntaxSet {
static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
}
pub fn highlight_css() -> &'static str {
static HIGHLIGHT_CSS: OnceLock<String> = OnceLock::new();
HIGHLIGHT_CSS
.get_or_init(|| {
let themes = ThemeSet::load_defaults();
match themes.themes.get("base16-ocean.dark") {
Some(theme) => {
css_for_theme_with_class_style(theme, hl_class_style()).unwrap_or_default()
}
None => String::new(),
}
})
.as_str()
}
fn fence_lang_is_safe(lang: &str) -> bool {
!lang.is_empty()
&& lang.len() <= 64
&& lang.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, '+' | '-' | '_' | '.' | '#' | '/' | '@')
})
}
fn highlight_code_block(lang: Option<&str>, src: &str) -> String {
let lang = lang.filter(|l| fence_lang_is_safe(l));
let ss = syntax_set();
let syntax = lang.and_then(|l| {
ss.find_syntax_by_token(l)
.or_else(|| ss.find_syntax_by_extension(l))
});
if let Some(syntax) = syntax {
let mut generator =
ClassedHTMLGenerator::new_with_class_style(syntax, ss, hl_class_style());
let mut ok = true;
for line in LinesWithEndings::from(src) {
if generator
.parse_html_for_line_which_includes_newline(line)
.is_err()
{
ok = false;
break;
}
}
if ok {
return wrap_code_block(lang, &generator.finalize());
}
}
let mut escaped = String::with_capacity(src.len());
html_escape_into(&mut escaped, src);
wrap_code_block(lang, &escaped)
}
fn wrap_code_block(lang: Option<&str>, inner: &str) -> String {
let mut out = String::with_capacity(inner.len() + 48);
out.push_str("<pre><code");
if let Some(l) = lang {
out.push_str(" class=\"language-");
html_escape_into(&mut out, l);
out.push('"');
}
out.push('>');
out.push_str(inner);
out.push_str("</code></pre>");
out
}
pub fn render_markdown(input: &str) -> String {
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd, html};
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_FOOTNOTES);
let parser = Parser::new_ext(input, options);
let mut events: Vec<Event> = Vec::new();
let mut in_code = false;
let mut code_lang: Option<String> = None;
let mut code_buf = String::new();
for event in parser {
match event {
Event::Start(Tag::CodeBlock(kind)) => {
in_code = true;
code_buf.clear();
code_lang = match kind {
CodeBlockKind::Fenced(info) => {
info.split_whitespace().next().map(str::to_string)
}
CodeBlockKind::Indented => None,
};
}
Event::End(TagEnd::CodeBlock) => {
in_code = false;
let highlighted = highlight_code_block(code_lang.as_deref(), &code_buf);
events.push(Event::Html(highlighted.into()));
}
Event::Text(text) if in_code => code_buf.push_str(&text),
other => events.push(other),
}
}
let mut rendered = String::new();
html::push_html(&mut rendered, events.into_iter());
let mut cleaner = ammonia::Builder::default();
cleaner.add_tag_attributes("pre", &["class"]);
cleaner.add_tag_attributes("code", &["class"]);
cleaner.add_tag_attributes("span", &["class"]);
cleaner.clean(&rendered).to_string()
}
fn html_escape_into(out: &mut String, s: &str) {
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
}
fn register_default_templates(
env: &mut Environment<'static>,
seen: &mut std::collections::HashSet<String>,
) {
let entries = [
(
crate::errors::DEFAULT_404_TEMPLATE_NAME,
crate::errors::DEFAULT_404_HTML,
),
(
crate::errors::DEFAULT_500_TEMPLATE_NAME,
crate::errors::DEFAULT_500_HTML,
),
];
for (name, source) in entries {
if seen.contains(name) {
continue; }
if env.add_template(name, source).is_ok() {
seen.insert(name.to_string());
}
}
}
pub fn init(dirs: &[PathBuf]) -> Result<Vec<String>, TemplateError> {
let (env, collisions) = build_env(dirs)?;
for name in &collisions {
tracing::warn!(
template = %name,
"umbral templates: template `{name}` is provided by multiple directories; \
the first-registered copy wins"
);
}
let _ = WATCHED_DIRS.set(dirs.to_vec());
ENGINE
.set(env)
.map_err(|_| TemplateError::AlreadyInitialised)?;
Ok(collisions)
}
pub fn init_with(
dirs: &[PathBuf],
registrars: Vec<TemplateRegistrar>,
) -> Result<Vec<String>, TemplateError> {
let _ = REGISTRARS.set(registrars);
init(dirs)
}
fn build_env(dirs: &[PathBuf]) -> Result<(Environment<'static>, Vec<String>), TemplateError> {
let mut env = Environment::new();
env.set_auto_escape_callback(|name| {
if name.ends_with(".html") || name.ends_with(".htm") {
AutoEscape::Html
} else {
AutoEscape::None
}
});
register_img_filter(&mut env);
register_highlight_styles_function(&mut env);
let static_url = crate::settings::get_opt()
.map(|s| s.static_url.clone())
.unwrap_or_else(|| "/static/".to_string());
register_static_function(&mut env, static_url);
register_media_url_function(&mut env);
register_markdown_filter(&mut env);
register_sanitize_filter(&mut env);
env.set_formatter(|out, state, value| {
if value.is_none() || value.is_undefined() {
return Ok(());
}
minijinja::escape_formatter(out, state, value)
});
register_now_function(&mut env);
register_currency_filter(&mut env);
register_querystring_with_function(&mut env);
if let Some(registrars) = REGISTRARS.get() {
for registrar in registrars {
registrar(&mut env);
}
}
let mut seen: HashSet<String> = HashSet::new();
let mut collisions: Vec<String> = Vec::new();
register_default_templates(&mut env, &mut seen);
for dir in dirs {
if dir.exists() {
load_directory(&mut env, dir, dir, &mut seen, &mut collisions)?;
}
}
Ok((env, collisions))
}
pub fn render<C: Serialize>(name: &str, ctx: &C) -> Result<String, TemplateError> {
if dev_mode_active() {
if let Some(dirs) = WATCHED_DIRS.get() {
match build_env(dirs) {
Ok((env, _collisions)) => return render_with(&env, name, ctx),
Err(e) => return Err(e),
}
}
}
let env = ENGINE.get().ok_or(TemplateError::NotInitialised)?;
render_with(env, name, ctx)
}
#[doc(hidden)]
pub fn render_str<C: Serialize>(src: &str, ctx: &C) -> Result<String, TemplateError> {
let mut env = minijinja::Environment::new();
env.add_template("__inline", src)
.map_err(TemplateError::Render)?;
render_with(&env, "__inline", ctx)
}
fn dev_mode_active() -> bool {
crate::settings::get_opt()
.map(|s| matches!(s.environment, crate::settings::Environment::Dev))
.unwrap_or(false)
}
fn render_with<C: Serialize>(
env: &Environment<'_>,
name: &str,
ctx: &C,
) -> Result<String, TemplateError> {
let tmpl = env.get_template(name).map_err(|e| match e.kind() {
minijinja::ErrorKind::TemplateNotFound => TemplateError::Missing(name.to_string()),
_ => TemplateError::Render(e),
})?;
let merged = merge_ambient_context(ctx);
tmpl.render(&merged).map_err(TemplateError::Render)
}
pub fn merge_ambient_context<C: Serialize>(ctx: &C) -> minijinja::Value {
let ctx_value = minijinja::Value::from_serialize(ctx);
merge_ambient_value(ctx_value)
}
pub fn merge_ambient_value(ctx_value: minijinja::Value) -> minijinja::Value {
let has = |key: &str| {
ctx_value
.get_attr(key)
.map(|v| !v.is_undefined())
.unwrap_or(false)
};
let need_user = !has("user");
let csrf = current_csrf();
let need_csrf = csrf.is_some() && !(has("csrf_token") && has("csrf_input"));
if !need_user && !need_csrf {
return ctx_value;
}
let mut pairs: Vec<(String, minijinja::Value)> = Vec::new();
if let Ok(keys) = ctx_value.try_iter() {
for key in keys {
let key_str = key.to_string();
if let Ok(v) = ctx_value.get_item(&key) {
pairs.push((key_str, v));
}
}
}
if need_user {
let user_value = if let Ok(lazy) = CURRENT_USER_LAZY.try_with(|lazy| lazy.clone()) {
lazy.into_proxy_value()
} else if let Some(v) = CURRENT_USER.try_with(|u| u.clone()).ok().flatten() {
v
} else {
anonymous_user_value()
};
pairs.push(("user".to_string(), user_value));
}
if let Some(token) = csrf {
if !has("csrf_token") {
pairs.push((
"csrf_token".to_string(),
minijinja::Value::from(token.clone()),
));
}
if !has("csrf_input") {
let escaped = token
.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">");
pairs.push((
"csrf_input".to_string(),
minijinja::Value::from_safe_string(format!(
r#"<input type="hidden" name="csrf_token" value="{escaped}">"#
)),
));
}
}
minijinja::Value::from_iter(pairs)
}
fn anonymous_user_value() -> minijinja::Value {
let mut map = serde_json::Map::new();
map.insert(
"is_authenticated".to_string(),
serde_json::Value::Bool(false),
);
map.insert("is_staff".to_string(), serde_json::Value::Bool(false));
map.insert("is_superuser".to_string(), serde_json::Value::Bool(false));
minijinja::Value::from_serialize(serde_json::Value::Object(map))
}
fn load_directory(
env: &mut Environment<'static>,
root: &Path,
dir: &Path,
seen: &mut HashSet<String>,
collisions: &mut Vec<String>,
) -> Result<(), TemplateError> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
load_directory(env, root, &path, seen, collisions)?;
continue;
}
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
continue;
};
if !matches!(ext, "html" | "htm" | "txt") {
continue;
}
let rel: PathBuf = path
.strip_prefix(root)
.expect("walked path is rooted at the templates dir")
.to_path_buf();
let name: String = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/");
if seen.contains(&name) {
if !collisions.contains(&name) {
collisions.push(name.clone());
}
continue;
}
let source = std::fs::read_to_string(&path)?;
env.add_template_owned(name.clone(), source)
.map_err(TemplateError::Render)?;
seen.insert(name);
}
Ok(())
}
#[derive(Debug)]
pub enum TemplateError {
NotInitialised,
AlreadyInitialised,
Io(std::io::Error),
Missing(String),
Render(minijinja::Error),
}
impl std::fmt::Display for TemplateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TemplateError::NotInitialised => write!(
f,
"umbral templates: engine not initialised — call App::build() first"
),
TemplateError::AlreadyInitialised => {
write!(f, "umbral templates: init called more than once")
}
TemplateError::Io(e) => write!(f, "umbral templates: io: {e}"),
TemplateError::Missing(name) => write!(
f,
"umbral templates: no template named `{name}`; check the templates directory"
),
TemplateError::Render(e) => write!(f, "umbral templates: {e}"),
}
}
}
impl std::error::Error for TemplateError {}
impl From<std::io::Error> for TemplateError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn img_url_scheme_safety() {
assert!(url_scheme_is_safe("/media/cat.png"));
assert!(url_scheme_is_safe("cat.png"));
assert!(url_scheme_is_safe("../up/cat.png"));
assert!(url_scheme_is_safe("http://example.com/cat.png"));
assert!(url_scheme_is_safe("https://example.com/cat.png"));
assert!(url_scheme_is_safe("HTTPS://EXAMPLE.com/cat.png"));
assert!(url_scheme_is_safe("?query=only"));
assert!(url_scheme_is_safe("#fragment"));
assert!(!url_scheme_is_safe("javascript:alert(1)"));
assert!(!url_scheme_is_safe(" javascript:alert(1)"));
assert!(!url_scheme_is_safe("JaVaScRiPt:alert(1)"));
assert!(!url_scheme_is_safe(
"data:text/html,<script>alert(1)</script>"
));
assert!(!url_scheme_is_safe("vbscript:msgbox(1)"));
assert!(!url_scheme_is_safe("mailto:a@b.com"));
assert!(!url_scheme_is_safe("java\u{0}script:alert(1)"));
}
#[test]
fn img_filter_neutralises_javascript_url() {
let mut env = minijinja::Environment::new();
register_img_filter(&mut env);
env.add_template("t", "{{ url | img }}").unwrap();
let tmpl = env.get_template("t").unwrap();
let out = tmpl
.render(minijinja::context! { url => "javascript:alert(1)" })
.unwrap();
assert!(
!out.contains("javascript:"),
"javascript: URL must be neutralised; got {out}"
);
assert!(out.contains("src=\"\""), "expected empty src; got {out}");
}
#[test]
fn nested_template_names_are_relative_to_templates_root() {
let tmp = tempfile::tempdir().expect("create temp dir");
let templates = tmp.path().join("templates");
std::fs::create_dir_all(templates.join("base")).expect("create base template dir");
std::fs::create_dir_all(templates.join("content")).expect("create content template dir");
std::fs::write(
templates.join("base").join("site.html"),
"<main>{% block content %}{% endblock %}</main>",
)
.expect("write nested base template");
std::fs::write(
templates.join("content").join("contact.html"),
r#"{% extends "base/site.html" %}{% block content %}<h1>{{ title }}</h1><p>Contact from nested content.</p>{% endblock %}"#,
)
.expect("write nested content template");
let (env, collisions) = build_env(&[templates]).expect("build template env");
assert!(collisions.is_empty());
let rendered = render_with(
&env,
"content/contact.html",
&json!({ "title": "Nested contact" }),
)
.expect("render nested template by relative name");
assert!(rendered.contains("<main>"));
assert!(rendered.contains("<h1>Nested contact</h1>"));
assert!(rendered.contains("Contact from nested content."));
}
fn render_static(static_url: &str, arg: &str) -> String {
let mut env = Environment::new();
register_static_function(&mut env, static_url.to_string());
env.add_template("t.txt", "{{ static(arg) }}")
.expect("add template");
let tmpl = env.get_template("t.txt").expect("get template");
tmpl.render(json!({ "arg": arg })).expect("render")
}
#[test]
fn static_helper_prepends_root_relative_url() {
assert_eq!(
render_static("/static/", "admin/admin.css"),
"/static/admin/admin.css"
);
}
#[test]
fn static_helper_prepends_cdn_origin() {
assert_eq!(
render_static("https://cdn.example.com/s/", "admin/admin.css"),
"https://cdn.example.com/s/admin/admin.css"
);
}
#[test]
fn static_helper_does_not_double_slash_on_leading_slash_arg() {
assert_eq!(render_static("/static/", "/admin/x"), "/static/admin/x");
}
#[test]
fn highlight_css_contains_hl_rules() {
let css = highlight_css();
assert!(!css.is_empty(), "generated theme CSS should not be empty");
assert!(
css.contains(".hl-"),
"theme CSS must target hl- classes: {css}"
);
}
#[test]
fn fenced_rust_block_gets_syntect_token_spans() {
let html = render_markdown("```rust\nfn main() {}\n```\n");
assert!(
html.contains("language-rust"),
"keeps the language class for the md-enhance label: {html}"
);
assert!(
html.contains("class=\"hl-"),
"emits syntect hl- token spans: {html}"
);
}
#[test]
fn script_in_code_fence_is_escaped_not_executed() {
let html = render_markdown("```\n<script>alert(1)</script>\n```\n");
assert!(!html.contains("<script>"), "no live script tag: {html}");
assert!(
html.contains("<script>"),
"rendered as inert text: {html}"
);
}
#[test]
fn prose_script_is_still_stripped() {
let html = render_markdown("hello <script>alert(1)</script> world");
assert!(!html.contains("<script>"), "prose script stripped: {html}");
}
#[test]
fn markdown_allows_class_but_not_style() {
let html = render_markdown("<span class=\"x\" style=\"color:red\">hi</span>");
assert!(html.contains("class=\"x\""), "class survives: {html}");
assert!(!html.contains("style="), "style stripped: {html}");
}
#[test]
fn unknown_and_plain_fences_do_not_panic() {
let unknown = render_markdown("```notalanguage\nx := 1\n```\n");
let plain = render_markdown("```\nplain text\n```\n");
assert!(
unknown.contains("<pre><code"),
"unknown lang block: {unknown}"
);
assert!(plain.contains("<pre><code"), "plain block: {plain}");
assert!(
unknown.contains("language-notalanguage"),
"unknown lang still labelled: {unknown}"
);
}
#[test]
fn hostile_fence_info_string_is_escaped_and_language_class_survives() {
let hostile = render_markdown("```<script>alert(1)</script>\ncode\n```\n");
assert!(
!hostile.contains("<script>"),
"live <script> from fence info must be stripped: {hostile}"
);
assert!(
hostile.contains("<pre><code"),
"code block structure must survive: {hostile}"
);
let class_inject = render_markdown("```evil\" onmouseover=\"alert(1)\ncode\n```\n");
assert!(
!class_inject.contains("onmouseover"),
"event handler injected via fence info must not survive: {class_inject}"
);
let safe = render_markdown("```rust\nfn ok() {}\n```\n");
assert!(
safe.contains("language-rust"),
"language-rust class must survive sanitization (gaps2 #36a): {safe}"
);
assert!(
safe.contains("class=\"hl-"),
"syntect hl- token spans must survive sanitization: {safe}"
);
}
#[test]
fn highlight_styles_global_emits_a_style_block() {
let mut env = Environment::new();
register_highlight_styles_function(&mut env);
env.add_template("t", "{{ highlight_styles() }}")
.expect("add template");
let out = env
.get_template("t")
.expect("get template")
.render(())
.expect("render");
assert!(out.starts_with("<style>"), "wraps in a style block: {out}");
assert!(out.contains(".hl-"), "carries the token CSS: {out}");
assert!(
out.trim_end().ends_with("</style>"),
"closes the style block: {out}"
);
}
}