use regex::Regex;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::LazyLock;
pub(crate) fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn escape_for_banner(s: &str) -> String {
escape_html(s)
}
fn dev_banner(msg: &str) -> String {
format!(
r#"<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;padding:8px 12px;margin:4px 0;border-radius:4px;font-family:monospace;font-size:13px">{}</div>"#,
escape_for_banner(msg)
)
}
const BUILTIN_TAGS: &[&str] = &[
"what-pagination",
"what-turnstile",
"what-fetch",
"what-clipboard",
"what-theme-toggle",
];
fn push_attr(out: &mut String, name: &str, value: &str) {
if value.contains('"') {
out.push_str(&format!(" {}='{}'", name, value));
} else {
out.push_str(&format!(" {}=\"{}\"", name, value));
}
}
static POLL_INTERVAL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\d+(ms|s|m|h)?$").unwrap());
static CODE_BLOCK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)<code\b[^>]*>.*?</code>").unwrap());
static UNRESOLVED_VAR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"#([a-zA-Z_][\w.]*(?:\|[^#]+)?)#").unwrap());
static DOUBLE_QUOTE_ATTR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"([a-zA-Z_][\w-]*)\s*=\s*"([^"]*)""#).unwrap());
static SINGLE_QUOTE_ATTR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"([a-zA-Z_][\w-]*)\s*=\s*'([^']*)'").unwrap());
use crate::Result;
use crate::components::{Component, ComponentRegistry};
use crate::parser::{ReactiveReplaceResult, replace_variables, replace_variables_reactive};
enum CompareOp {
Eq,
Ne,
Gt,
Lt,
Gte,
Lte,
}
fn wrap_bare_variables(expr: &str) -> String {
let mut result = String::new();
let bytes = expr.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'"' || c == b'\'' {
let quote = c;
result.push(c as char);
i += 1;
while i < bytes.len() && bytes[i] != quote {
result.push(bytes[i] as char);
i += 1;
}
if i < bytes.len() {
result.push(bytes[i] as char);
i += 1;
}
continue;
}
if c.is_ascii_whitespace() || b"!=<>".contains(&c) {
result.push(c as char);
i += 1;
continue;
}
if c.is_ascii_alphabetic() || c == b'_' {
let start = i;
while i < bytes.len()
&& (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' || bytes[i] == b'.')
{
i += 1;
}
let word = &expr[start..i];
match word {
"true" | "false" | "contains" | "gt" | "gte" | "lt" | "lte" => {
result.push_str(word);
}
_ => {
result.push('#');
result.push_str(word);
result.push('#');
}
}
continue;
}
if c.is_ascii_digit() || (c == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit())
{
while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
result.push(bytes[i] as char);
i += 1;
}
continue;
}
result.push(c as char);
i += 1;
}
result
}
fn find_outside_quotes(s: &str, needle: &str) -> Option<usize> {
let bytes = s.as_bytes();
let needle_bytes = needle.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'"' || c == b'\'' {
let quote = c;
i += 1;
while i < bytes.len() && bytes[i] != quote {
i += 1;
}
i += 1; continue;
}
if bytes[i..].starts_with(needle_bytes) {
return Some(i);
}
i += 1;
}
None
}
fn split_top_level_bool(expr: &str, keyword: &str) -> Vec<String> {
let bytes = expr.as_bytes();
let kw = keyword.as_bytes();
let mut parts = Vec::new();
let mut seg_start = 0;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'"' || c == b'\'' {
let quote = c;
i += 1;
while i < bytes.len() && bytes[i] != quote {
i += 1;
}
i += 1;
continue;
}
if bytes[i..].starts_with(kw) {
let ws_before = i > 0 && bytes[i - 1].is_ascii_whitespace();
let after = i + kw.len();
let ws_after = after < bytes.len() && bytes[after].is_ascii_whitespace();
if ws_before && ws_after {
parts.push(expr[seg_start..i].trim().to_string());
i = after + 1;
seg_start = i;
continue;
}
}
i += 1;
}
parts.push(expr[seg_start..].trim().to_string());
parts
}
fn map_keyword_operators(condition: &str) -> String {
const OPS: [(&str, &str); 4] = [
(" gte ", " >= "),
(" lte ", " <= "),
(" gt ", " > "),
(" lt ", " < "),
];
let bytes = condition.as_bytes();
let mut out = String::with_capacity(condition.len());
let mut seg_start = 0;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'"' || c == b'\'' {
let quote = c;
i += 1;
while i < bytes.len() && bytes[i] != quote {
i += 1;
}
if i < bytes.len() {
i += 1; }
continue;
}
if let Some((kw, sym)) = OPS
.iter()
.find(|(kw, _)| bytes[i..].starts_with(kw.as_bytes()))
{
out.push_str(&condition[seg_start..i]);
out.push_str(sym);
i += kw.len();
seg_start = i;
continue;
}
i += 1;
}
out.push_str(&condition[seg_start..]);
out
}
static LEGACY_COND_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<(?:if|elseif|unless)\b[^>]*\bcond\s*=").unwrap());
static TRAILING_ELSE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"</if>\s*<else\s*/?>").unwrap());
static WARNED_TEMPLATE_LINTS: LazyLock<std::sync::Mutex<std::collections::HashSet<std::path::PathBuf>>> =
LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
fn count_tag_starts(s: &str, tag: &str) -> usize {
let mut n = 0;
let mut from = 0;
while let Some(pos) = find_tag_start(&s[from..], tag) {
n += 1;
from += pos + tag.len();
}
n
}
pub(crate) struct TemplateLint {
pub kind: &'static str, pub message: String,
}
pub(crate) fn collect_template_lints(raw: &str) -> Vec<TemplateLint> {
let mut lints = Vec::new();
if raw.contains("cond") && LEGACY_COND_RE.is_match(raw) {
lints.push(TemplateLint {
kind: "legacy-cond",
message: "Deprecated cond=\"...\" — use the simplified form, e.g. <if count gt 0> or <if user.role == \"admin\">. The cond attribute still works but the simplified form is recommended.".to_string(),
});
}
if raw.contains("<else") && TRAILING_ELSE_RE.is_match(raw) {
lints.push(TemplateLint {
kind: "trailing-else",
message: "Malformed conditional: <else/> placed after </if> ALWAYS renders. Move it inside the block: <if cond>A<else/>B</if>".to_string(),
});
}
for t in ["if", "loop", "unless"] {
let opens = count_tag_starts(raw, &format!("<{}", t));
if opens == 0 {
continue;
}
let closes = raw.matches(&format!("</{}>", t)).count();
if opens > closes {
lints.push(TemplateLint {
kind: "unclosed",
message: format!(
"Unclosed <{}>: {} opening tag(s) but {} </{}> — the unclosed block is skipped by the engine, so its raw <{}> markup leaks into the page and the content renders unconditionally.",
t, opens, closes, t, t
),
});
}
}
if raw.contains("<code") {
let tags: std::collections::HashSet<&str> = CODE_BLOCK_RE
.find_iter(raw)
.flat_map(|m| {
BUILTIN_TAGS
.iter()
.filter(move |t| m.as_str().contains(&format!("<{}", t)))
.copied()
})
.collect();
for tag in tags {
lints.push(TemplateLint {
kind: "raw-builtin-in-code",
message: format!(
"Raw <{}> inside a <code> block — built-in tags expand BEFORE code-block protection, so the sample will render instead of displaying. Entity-escape it: <{}>",
tag, tag
),
});
}
}
lints
}
pub(crate) fn warn_template_lints_once(path: &std::path::Path, raw: &str) -> bool {
let lints = collect_template_lints(raw);
if lints.is_empty() {
return false;
}
let mut warned = WARNED_TEMPLATE_LINTS
.lock()
.unwrap_or_else(|e| e.into_inner());
if !warned.insert(path.to_path_buf()) {
return false;
}
for lint in &lints {
tracing::warn!("{} in {}", lint.message, path.display());
}
true
}
fn find_tag_start(s: &str, tag: &str) -> Option<usize> {
let mut from = 0;
while let Some(rel) = s[from..].find(tag) {
let pos = from + rel;
match s.as_bytes().get(pos + tag.len()) {
Some(b) if b.is_ascii_whitespace() || *b == b'>' || *b == b'/' => return Some(pos),
None => return Some(pos),
_ => from = pos + tag.len(),
}
}
None
}
struct LoopInfo {
start: usize,
end: usize,
data_attr: String,
alias: String,
body: String,
per_page: Option<usize>,
page_expr: Option<String>,
}
pub struct RenderEngine {
components: ComponentRegistry,
}
impl RenderEngine {
pub fn new(components: ComponentRegistry) -> Self {
Self { components }
}
pub async fn render(&self, template: &str, context: &HashMap<String, Value>) -> Result<String> {
self.render_with_secret(template, context, None).await
}
pub async fn render_with_secret(
&self,
template: &str,
context: &HashMap<String, Value>,
validation_secret: Option<&str>,
) -> Result<String> {
let t_start = std::time::Instant::now();
let mut output = template.to_string();
let t0 = std::time::Instant::now();
output = self.process_includes(&output, context)?;
let t_includes = t0.elapsed();
output = Self::process_section_auth(&output, context)?;
let t1 = std::time::Instant::now();
output = self.process_loops_html(&output, context)?;
let t_loops = t1.elapsed();
let t2 = std::time::Instant::now();
output = self.process_conditionals_html(&output, context)?;
let t_conditionals = t2.elapsed();
let t3 = std::time::Instant::now();
output = self.process_custom_tags_html(&output, context)?;
let t_components = t3.elapsed();
let t4 = std::time::Instant::now();
output = self.process_conditionals_html(&output, context)?;
let t_conditionals2 = t4.elapsed();
if let Some(secret) = validation_secret {
let (processed, _actions) = Self::process_validated_forms(&output, secret);
output = processed;
}
let (protected, code_blocks) = Self::protect_code_blocks(&output);
let t5 = std::time::Instant::now();
let replaced = replace_variables(&protected, context);
let t_vars = t5.elapsed();
output = Self::restore_code_blocks(&replaced, &code_blocks);
let is_strict = context
.get("_strict")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_strict {
for cap in UNRESOLVED_VAR_RE.captures_iter(&output) {
let var = &cap[1];
if !var.starts_with('_') {
tracing::warn!("Strict: unresolved variable #{}#", var);
}
}
}
let t_total = t_start.elapsed();
tracing::debug!(
"Template timing: includes={:.2}ms loops={:.2}ms conditionals={:.2}ms components={:.2}ms conditionals2={:.2}ms vars={:.2}ms total={:.2}ms",
t_includes.as_secs_f64() * 1000.0,
t_loops.as_secs_f64() * 1000.0,
t_conditionals.as_secs_f64() * 1000.0,
t_components.as_secs_f64() * 1000.0,
t_conditionals2.as_secs_f64() * 1000.0,
t_vars.as_secs_f64() * 1000.0,
t_total.as_secs_f64() * 1000.0,
);
Ok(output)
}
pub async fn render_reactive(
&self,
template: &str,
context: &HashMap<String, Value>,
) -> Result<ReactiveReplaceResult> {
self.render_reactive_with_secret(template, context, None)
.await
}
pub async fn render_reactive_with_secret(
&self,
template: &str,
context: &HashMap<String, Value>,
validation_secret: Option<&str>,
) -> Result<ReactiveReplaceResult> {
let mut output = template.to_string();
output = self.process_includes(&output, context)?;
output = Self::process_section_auth(&output, context)?;
output = self.process_loops_html(&output, context)?;
output = self.process_conditionals_html(&output, context)?;
output = self.process_custom_tags_html(&output, context)?;
output = self.process_conditionals_html(&output, context)?;
if let Some(secret) = validation_secret {
let (processed, _actions) = Self::process_validated_forms(&output, secret);
output = processed;
}
let (protected, code_blocks) = Self::protect_code_blocks(&output);
let mut result = replace_variables_reactive(&protected, context);
result.html = Self::restore_code_blocks(&result.html, &code_blocks);
let is_strict = context
.get("_strict")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_strict {
for cap in UNRESOLVED_VAR_RE.captures_iter(&result.html) {
let var = &cap[1];
if !var.starts_with('_') {
tracing::warn!("Strict: unresolved variable #{}#", var);
}
}
}
Ok(result)
}
fn process_validated_forms(html: &str, secret: &str) -> (String, Vec<String>) {
use crate::validation;
static FORM_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?si)<form\b[^>]*\bw-validate\b[^>]*>").unwrap());
static ACTION_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"(?i)action="([^"]+)""#).unwrap());
let form_re = &*FORM_RE;
let action_re = &*ACTION_RE;
let mut output = html.to_string();
let mut offset: isize = 0;
let mut validated_actions = Vec::new();
let captures: Vec<_> = form_re.find_iter(html).collect();
for mat in captures {
let form_tag_end = (mat.end() as isize + offset) as usize;
if let Some(close_pos) = output[form_tag_end..].find("</form>") {
let abs_close = form_tag_end + close_pos;
let form_body = &output[form_tag_end..abs_close];
let rules = validation::parse_form_rules(form_body);
if rules.fields.is_empty() {
continue;
}
let form_tag = mat.as_str();
if let Some(cap) = action_re.captures(form_tag) {
if let Some(action) = cap.get(1) {
validated_actions.push(action.as_str().to_string());
}
}
if let Some(token) = validation::encode_rules(&rules, secret) {
let hidden_field =
format!(r#"<input type="hidden" name="w-rules" value="{}">"#, token);
output.insert_str(form_tag_end, &hidden_field);
offset += hidden_field.len() as isize;
let updated_end = (mat.end() as isize + offset) as usize;
if let Some(close_pos2) = output[updated_end..].find("</form>") {
let abs_close2 = updated_end + close_pos2;
let form_section = output[updated_end..abs_close2].to_string();
let enhanced = inject_html5_validation_attrs(&form_section, &rules);
let diff = enhanced.len() as isize - form_section.len() as isize;
output.replace_range(updated_end..abs_close2, &enhanced);
offset += diff;
}
}
}
}
(output, validated_actions)
}
}
fn inject_html5_validation_attrs(form_body: &str, rules: &crate::validation::FormRules) -> String {
let mut result = form_body.to_string();
for (field_name, field_rules) in &rules.fields {
let name_attr = format!(r#"name="{}""#, field_name);
if let Some(pos) = result.find(&name_attr) {
let tag_end = result[pos..].find('>').map(|p| pos + p);
let mut attrs = String::new();
if field_rules.required {
attrs.push_str(" required");
}
if let Some(min) = field_rules.min {
attrs.push_str(&format!(r#" minlength="{}""#, min));
}
if let Some(max) = field_rules.max {
attrs.push_str(&format!(r#" maxlength="{}""#, max));
}
if let Some(ref ft) = field_rules.field_type {
let tag_start = result[..pos].rfind('<').unwrap_or(0);
let tag_str = &result[tag_start..tag_end.unwrap_or(result.len())];
if !tag_str.contains("type=") {
match ft.as_str() {
"email" => attrs.push_str(r#" type="email""#),
"url" => attrs.push_str(r#" type="url""#),
"number" => attrs.push_str(r#" type="number""#),
"phone" => attrs.push_str(r#" type="tel""#),
"date" => attrs.push_str(r#" type="date""#),
"time" => attrs.push_str(r#" type="time""#),
_ => {}
}
}
}
if let Some(ref pattern) = field_rules.pattern {
attrs.push_str(&format!(r#" pattern="{}""#, pattern));
}
if !attrs.is_empty() {
let insert_pos = pos + name_attr.len();
result.insert_str(insert_pos, &attrs);
}
}
}
let w_attr_re = regex::Regex::new(
r#"\s*w-(required|min|max|type|pattern|match|unique|error)\s*(?:=\s*"[^"]*")?"#,
)
.unwrap();
w_attr_re.replace_all(&result, "").to_string()
}
impl RenderEngine {
fn protect_code_blocks(html: &str) -> (String, Vec<String>) {
let mut result = String::with_capacity(html.len());
let mut blocks = Vec::new();
let mut pos = 0;
while pos < html.len() {
let remaining = &html[pos..];
let Some(code_start) = remaining.find("<code") else {
result.push_str(remaining);
break;
};
let abs_code_start = pos + code_start;
let Some(tag_end_offset) = html[abs_code_start..].find('>') else {
result.push_str(remaining);
break;
};
let abs_tag_end = abs_code_start + tag_end_offset + 1;
let Some(close_offset) = html[abs_tag_end..].find("</code>") else {
result.push_str(remaining);
break;
};
let abs_close = abs_tag_end + close_offset;
let inner_content = &html[abs_tag_end..abs_close];
let placeholder = format!("__WHAT_CODE_{}__", blocks.len());
blocks.push(inner_content.to_string());
result.push_str(&html[pos..abs_tag_end]);
result.push_str(&placeholder);
pos = abs_close; }
(result, blocks)
}
fn process_section_auth(html: &str, context: &HashMap<String, Value>) -> Result<String> {
static AUTH_ATTR: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)<(\w+)\s[^>]*\bauth\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>"#).unwrap()
});
let authenticated = context
.get("user")
.and_then(|u| u.get("authenticated"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let user_role = context
.get("user")
.and_then(|u| u.get("role"))
.and_then(|v| v.as_str())
.unwrap_or("");
let user_roles: Vec<String> = if user_role.is_empty() {
vec![]
} else {
vec![user_role.to_string()]
};
let mut output = html.to_string();
let mut iterations = 0;
const MAX_ITERATIONS: usize = 100;
loop {
if iterations >= MAX_ITERATIONS {
break;
}
let Some(caps) = AUTH_ATTR.captures(&output) else {
break;
};
iterations += 1;
let match_start = caps.get(0).unwrap().start();
let after_open = caps.get(0).unwrap().end();
let tag_name = caps[1].to_lowercase();
let auth_value = caps
.get(2)
.or_else(|| caps.get(3))
.map(|m| m.as_str())
.unwrap_or("")
.to_string();
let close_tag = format!("</{}>", tag_name);
let open_pattern = format!("<{}", tag_name);
let mut depth = 1;
let mut pos = after_open;
let mut found_end: Option<(usize, usize)> = None; while depth > 0 && pos < output.len() {
if let Some(idx) = output[pos..].find('<') {
let abs = pos + idx;
if output[abs..].starts_with(&close_tag) {
depth -= 1;
if depth == 0 {
found_end = Some((abs, abs + close_tag.len()));
break;
}
pos = abs + close_tag.len();
} else if output[abs..].starts_with(&open_pattern)
&& output
.as_bytes()
.get(abs + open_pattern.len())
.is_some_and(|&b| b == b' ' || b == b'>' || b == b'/')
{
depth += 1;
pos = abs + 1;
} else {
pos = abs + 1;
}
} else {
break;
}
}
if let Some((inner_end, tag_end)) = found_end {
let inner = output[after_open..inner_end].to_string();
let auth_level = crate::parser::parse_auth_level(&auth_value);
let has_access = match &auth_level {
crate::parser::AuthLevel::All => true,
crate::parser::AuthLevel::User => authenticated,
crate::parser::AuthLevel::Roles(required) => {
authenticated && required.iter().any(|r| user_roles.contains(r))
}
};
let replacement = if has_access { inner } else { String::new() };
output = format!(
"{}{}{}",
&output[..match_start],
replacement,
&output[tag_end..]
);
} else {
output = format!("{}{}", &output[..match_start], &output[after_open..]);
}
}
Ok(output)
}
fn restore_code_blocks(html: &str, blocks: &[String]) -> String {
let mut result = html.to_string();
for (i, block) in blocks.iter().enumerate() {
let placeholder = format!("__WHAT_CODE_{}__", i);
result = result.replacen(&placeholder, block, 1);
}
result
}
fn process_includes(&self, template: &str, context: &HashMap<String, Value>) -> Result<String> {
let mut output = template.to_string();
let mut iterations = 0;
const MAX_ITERATIONS: usize = 50;
let base_path = context
.get("_base_path")
.and_then(|v| v.as_str())
.unwrap_or(".");
while output.contains("<include") && iterations < MAX_ITERATIONS {
iterations += 1;
if let Some((start, end, src, attrs)) = self.find_include_tag(&output) {
let is_dev = context
.get("_dev_mode")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_dev && src.starts_with("components/") {
let filename = src
.trim_start_matches("components/")
.trim_end_matches(".html");
tracing::info!(
"Hint: <include src=\"{}\"> can be written as <what-{}> (component syntax)",
src,
filename
);
}
let include_path = std::path::Path::new(base_path).join(&src);
let include_path = if include_path.exists() {
include_path
} else if let Some(content_dir) =
context.get("_content_dir").and_then(|v| v.as_str())
{
let alt = std::path::Path::new(content_dir).join(&src);
if alt.exists() { alt } else { include_path }
} else {
include_path
};
let included_content = if include_path.exists() {
match std::fs::read_to_string(&include_path) {
Ok(content) => {
if is_dev {
warn_template_lints_once(&include_path, &content);
}
let (stripped_content, declared_attrs) =
self.parse_what_block(&content);
let mut include_context = context.clone();
for (key, value) in &attrs {
let resolved_value = replace_variables(value, context);
let trimmed = resolved_value.trim();
if (trimmed.starts_with('[') && trimmed.ends_with(']'))
|| (trimmed.starts_with('{') && trimmed.ends_with('}'))
{
if let Ok(json_value) =
serde_json::from_str::<Value>(&resolved_value)
{
include_context.insert(key.clone(), json_value);
}
}
}
let mut result = stripped_content;
for (key, default_value) in &declared_attrs {
let value = attrs.get(key).unwrap_or(default_value);
let resolved_value = replace_variables(value, context);
result = result.replace(&format!("#{}#", key), &resolved_value);
}
if let Ok(processed) =
self.process_loops_html(&result, &include_context)
{
result = processed;
}
if let Ok(processed) =
self.process_conditionals_html(&result, &include_context)
{
result = processed;
}
result
}
Err(e) => {
if is_dev {
tracing::warn!(
"Template error: failed to read include '{}': {}",
src,
e
);
format!(
r#"<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;padding:8px 12px;margin:4px 0;border-radius:4px;font-family:monospace;font-size:13px">Include error: <b>{}</b> — {}</div>"#,
escape_for_banner(&src),
escape_for_banner(&e.to_string())
)
} else {
format!("<!-- include error: {} -->", e)
}
}
}
} else {
if is_dev {
tracing::warn!("Template error: include not found '{}'", src);
format!(
r#"<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;padding:8px 12px;margin:4px 0;border-radius:4px;font-family:monospace;font-size:13px">Include not found: <b>{}</b></div>"#,
escape_for_banner(&src)
)
} else {
format!("<!-- include not found: {} -->", src)
}
};
output = format!("{}{}{}", &output[..start], included_content, &output[end..]);
} else {
break;
}
}
Ok(output)
}
fn parse_what_block(&self, content: &str) -> (String, HashMap<String, String>) {
let mut defaults = HashMap::new();
if let Some(start) = content.find("<what>") {
if let Some(end) = content.find("</what>") {
let what_content = &content[start + 6..end];
for line in what_content.lines() {
let line = line.trim();
if line.starts_with("attribute.") {
if let Some(eq_pos) = line.find('=') {
let key = line[10..eq_pos].trim(); let value = line[eq_pos + 1..].trim();
let value = crate::parser::strip_symmetric_quotes(value).0;
defaults.insert(key.to_string(), value.to_string());
}
}
}
let before = &content[..start];
let after = &content[end + 7..]; return (format!("{}{}", before.trim_start(), after), defaults);
}
}
(content.to_string(), defaults)
}
fn find_include_tag(
&self,
html: &str,
) -> Option<(usize, usize, String, HashMap<String, String>)> {
let start = find_tag_start(html, "<include")?;
let rest = &html[start..];
let tag_close = find_outside_quotes(rest, ">")?;
let tag_content = &rest[..tag_close + 1];
let self_closing = rest.as_bytes()[tag_close - 1] == b'/';
let end_offset = if self_closing {
tag_close + 1
} else if let Some(close_start) = rest.find("</include>") {
close_start + "</include>".len()
} else {
tag_close + 1
};
let attrs = self.parse_tag_attributes(tag_content);
let src = attrs.get("src")?.clone();
let mut pass_attrs = attrs;
pass_attrs.remove("src");
Some((start, start + end_offset, src, pass_attrs))
}
fn process_loops_html(
&self,
template: &str,
context: &HashMap<String, Value>,
) -> Result<String> {
let mut output = template.to_string();
let mut iterations = 0;
const MAX_ITERATIONS: usize = 100;
while output.contains("<loop") && iterations < MAX_ITERATIONS {
iterations += 1;
if let Some(info) = self.find_outermost_loop(&output) {
let rendered = self.render_loop(
&info.data_attr,
&info.alias,
&info.body,
context,
info.per_page,
info.page_expr.as_deref(),
);
output = format!(
"{}{}{}",
&output[..info.start],
rendered,
&output[info.end..]
);
} else {
break;
}
}
Ok(output)
}
fn find_outermost_loop(&self, html: &str) -> Option<LoopInfo> {
self.find_loop_manual(html)
}
fn find_loop_manual(&self, html: &str) -> Option<LoopInfo> {
let start_tag = "<loop";
let end_tag = "</loop>";
let start = html.find(start_tag)?;
let tag_end = html[start..].find('>')? + start + 1;
let tag_content = &html[start..tag_end];
let data_attr = self.extract_attr(tag_content, "data").unwrap_or_default();
let alias = self
.extract_attr(tag_content, "as")
.unwrap_or_else(|| "item".to_string());
let per_page = self
.extract_attr(tag_content, "paginate")
.and_then(|v| v.parse().ok());
let page_expr = self.extract_attr(tag_content, "page");
let mut depth = 1;
let mut pos = tag_end;
while depth > 0 && pos < html.len() {
if let Some(next_start) = html[pos..].find(start_tag) {
if let Some(next_end) = html[pos..].find(end_tag) {
if next_start < next_end {
depth += 1;
pos = pos + next_start + start_tag.len();
} else {
depth -= 1;
if depth == 0 {
let body = html[tag_end..pos + next_end].to_string();
let end = pos + next_end + end_tag.len();
return Some(LoopInfo {
start,
end,
data_attr,
alias,
body,
per_page,
page_expr,
});
}
pos = pos + next_end + end_tag.len();
}
} else {
break;
}
} else if let Some(next_end) = html[pos..].find(end_tag) {
depth -= 1;
if depth == 0 {
let body = html[tag_end..pos + next_end].to_string();
let end = pos + next_end + end_tag.len();
return Some(LoopInfo {
start,
end,
data_attr,
alias,
body,
per_page,
page_expr,
});
}
pos = pos + next_end + end_tag.len();
} else {
break;
}
}
None
}
fn extract_attr(&self, tag: &str, attr_name: &str) -> Option<String> {
let mut from = 0;
while let Some(rel) = tag[from..].find(attr_name) {
let pos = from + rel;
from = pos + attr_name.len();
let preceded_ok = pos == 0 || tag.as_bytes()[pos - 1].is_ascii_whitespace();
if !preceded_ok {
continue;
}
let rest = tag[pos + attr_name.len()..].trim_start();
let Some(rest) = rest.strip_prefix('=') else {
continue;
};
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix('"') {
if let Some(end) = rest.find('"') {
return Some(rest[..end].to_string());
}
} else if let Some(rest) = rest.strip_prefix('\'') {
if let Some(end) = rest.find('\'') {
return Some(rest[..end].to_string());
}
}
}
None
}
fn render_loop(
&self,
data_expr: &str,
alias: &str,
body: &str,
context: &HashMap<String, Value>,
per_page: Option<usize>,
page_expr: Option<&str>,
) -> String {
let var_name = data_expr.trim_matches('#');
let parts: Vec<&str> = var_name.split('.').collect();
let data = if let Some(first) = parts.first() {
let mut current = context.get(*first);
for part in parts.iter().skip(1) {
current = current.and_then(|v| {
if let Value::Object(obj) = v {
obj.get(*part)
} else {
None
}
});
}
current
} else {
None
};
match data {
Some(Value::Array(items)) => {
let total = items.len();
let (page_items, page_num, total_pages) = if let Some(per_page) = per_page {
let per_page = per_page.max(1);
let total_pages = (total + per_page - 1) / per_page;
let page_num = page_expr
.map(|expr| {
let resolved = replace_variables(expr, context);
resolved.parse::<usize>().unwrap_or(1)
})
.unwrap_or(1)
.max(1)
.min(total_pages.max(1));
let start = (page_num - 1) * per_page;
let end = (start + per_page).min(total);
let slice: Vec<&Value> = items[start..end].iter().collect();
(slice, page_num, total_pages)
} else {
let all: Vec<&Value> = items.iter().collect();
(all, 1, 1)
};
page_items
.iter()
.enumerate()
.map(|(index, item)| {
let mut loop_context = context.clone();
loop_context.insert(alias.to_string(), (*item).clone());
loop_context.insert("index".to_string(), Value::Number(index.into()));
loop_context
.insert("index1".to_string(), Value::Number((index + 1).into()));
loop_context.insert("first".to_string(), Value::Bool(index == 0));
loop_context.insert(
"last".to_string(),
Value::Bool(index == page_items.len() - 1),
);
loop_context.insert("loop_total".to_string(), Value::Number(total.into()));
loop_context
.insert("loop_pages".to_string(), Value::Number(total_pages.into()));
loop_context
.insert("loop_page".to_string(), Value::Number(page_num.into()));
let processed = self
.process_loops_html(body, &loop_context)
.unwrap_or_else(|_| body.to_string());
let processed = match self
.process_conditionals_html(&processed, &loop_context)
{
Ok(p) => p,
Err(_) => processed,
};
replace_variables(&processed, &loop_context)
})
.collect::<Vec<_>>()
.join("\n")
}
Some(Value::Object(obj)) => {
obj.iter()
.enumerate()
.map(|(index, (key, value))| {
let mut loop_context = context.clone();
loop_context.insert("key".to_string(), Value::String(key.clone()));
loop_context.insert("value".to_string(), value.clone());
loop_context.insert(alias.to_string(), value.clone());
loop_context.insert("index".to_string(), Value::Number(index.into()));
let processed = self
.process_loops_html(body, &loop_context)
.unwrap_or_else(|_| body.to_string());
let processed = match self
.process_conditionals_html(&processed, &loop_context)
{
Ok(p) => p,
Err(_) => processed,
};
replace_variables(&processed, &loop_context)
})
.collect::<Vec<_>>()
.join("\n")
}
_ => {
let is_dev = context
.get("_dev_mode")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_dev {
tracing::warn!("Template error: <loop> has no data for '{}'", var_name);
format!(
r#"<div style="background:#fefce8;border:1px solid #fde047;color:#854d0e;padding:8px 12px;margin:4px 0;border-radius:4px;font-family:monospace;font-size:13px">Loop: no data for <b>{}</b></div>"#,
escape_for_banner(var_name)
)
} else {
format!("<!-- loop: no data for {} -->", var_name)
}
}
}
}
fn process_conditionals_html(
&self,
template: &str,
context: &HashMap<String, Value>,
) -> Result<String> {
let mut output = template.to_string();
output = self.process_if_tags(&output, context)?;
output = self.process_unless_tags(&output, context)?;
Ok(output)
}
fn process_if_tags(&self, html: &str, context: &HashMap<String, Value>) -> Result<String> {
let mut output = html.to_string();
let mut iterations = 0;
const MAX_ITERATIONS: usize = 100;
while output.contains("<if") && iterations < MAX_ITERATIONS {
iterations += 1;
if let Some((start, end, branches, else_body)) = self.find_if_tag(&output) {
let mut result = None;
for (condition, body) in branches {
if self.evaluate_condition(&condition, context) {
result = Some(body);
break;
}
}
let result = result.unwrap_or_else(|| else_body.unwrap_or_default());
output = format!("{}{}{}", &output[..start], result, &output[end..]);
} else {
break;
}
}
Ok(output)
}
fn find_if_tag(
&self,
html: &str,
) -> Option<(usize, usize, Vec<(String, String)>, Option<String>)> {
let start = find_tag_start(html, "<if")?;
let tag_end = find_outside_quotes(&html[start..], ">")? + start + 1;
let tag_content = &html[start..tag_end];
let condition = self.extract_attr(tag_content, "cond").unwrap_or_else(|| {
let inner = tag_content.strip_prefix("<if").unwrap_or("").trim();
let inner = inner.strip_suffix(">").unwrap_or(inner).trim();
inner.to_string()
});
let end_tag = "</if>";
let mut depth = 1;
let mut pos = tag_end;
while depth > 0 && pos < html.len() {
let next_start = find_tag_start(&html[pos..], "<if");
let next_end = html[pos..].find(end_tag);
match (next_start, next_end) {
(Some(s), Some(e)) if s < e => {
depth += 1;
pos = pos + s + 3;
}
(_, Some(e)) => {
depth -= 1;
if depth == 0 {
let body = &html[tag_end..pos + e];
let (branches, else_body) = self.parse_if_body(body, &condition);
let end = pos + e + end_tag.len();
return Some((start, end, branches, else_body));
}
pos = pos + e + end_tag.len();
}
_ => break,
}
}
None
}
fn parse_if_body(
&self,
body: &str,
initial_condition: &str,
) -> (Vec<(String, String)>, Option<String>) {
let mut branches = Vec::new();
let mut remaining = body.to_string();
let mut current_condition = initial_condition.to_string();
loop {
let elseif_pos = self.find_top_level_tag(&remaining, "<elseif");
let else_pos = self.find_top_level_else(&remaining);
match (elseif_pos, else_pos) {
(Some(ei_pos), Some(e_pos)) if ei_pos < e_pos => {
branches.push((current_condition.clone(), remaining[..ei_pos].to_string()));
let after_elseif = &remaining[ei_pos..];
if let Some(tag_end) = find_outside_quotes(after_elseif, "/>") {
let tag = &after_elseif[..tag_end + 2];
current_condition = self.extract_attr(tag, "cond").unwrap_or_else(|| {
let inner = tag.strip_prefix("<elseif").unwrap_or("").trim();
let inner = inner.strip_suffix("/>").unwrap_or(inner).trim();
inner.to_string()
});
remaining = after_elseif[tag_end + 2..].to_string();
} else {
break;
}
}
(Some(ei_pos), None) => {
branches.push((current_condition.clone(), remaining[..ei_pos].to_string()));
let after_elseif = &remaining[ei_pos..];
if let Some(tag_end) = find_outside_quotes(after_elseif, "/>") {
let tag = &after_elseif[..tag_end + 2];
current_condition = self.extract_attr(tag, "cond").unwrap_or_else(|| {
let inner = tag.strip_prefix("<elseif").unwrap_or("").trim();
let inner = inner.strip_suffix("/>").unwrap_or(inner).trim();
inner.to_string()
});
remaining = after_elseif[tag_end + 2..].to_string();
} else {
break;
}
}
(_, Some(e_pos)) => {
branches.push((current_condition.clone(), remaining[..e_pos].to_string()));
let after_else_start = &remaining[e_pos..];
let else_len = if after_else_start.starts_with("<else/>") {
7
} else if after_else_start.starts_with("<else />") {
8
} else {
7 };
let else_body = remaining[e_pos + else_len..].to_string();
return (branches, Some(else_body));
}
(None, None) => {
branches.push((current_condition, remaining));
return (branches, None);
}
}
}
branches.push((current_condition, remaining));
(branches, None)
}
fn find_top_level_tag(&self, html: &str, tag: &str) -> Option<usize> {
let mut depth = 0;
let mut pos = 0;
while pos < html.len() {
let next_if = find_tag_start(&html[pos..], "<if");
let next_endif = html[pos..].find("</if>");
let next_target = html[pos..].find(tag);
let events: Vec<(usize, &str)> = [
next_if.map(|p| (p, "if")),
next_endif.map(|p| (p, "endif")),
next_target.map(|p| (p, "target")),
]
.into_iter()
.flatten()
.collect();
if events.is_empty() {
break;
}
let (offset, event_type) = events.into_iter().min_by_key(|(p, _)| *p)?;
match event_type {
"if" => {
depth += 1;
pos = pos + offset + 3;
}
"endif" => {
depth -= 1;
pos = pos + offset + 5;
}
"target" => {
if depth == 0 {
return Some(pos + offset);
}
pos = pos + offset + tag.len();
}
_ => break,
}
}
None
}
fn find_top_level_else(&self, html: &str) -> Option<usize> {
let mut depth = 0;
let mut pos = 0;
while pos < html.len() {
let next_if = find_tag_start(&html[pos..], "<if");
let next_endif = html[pos..].find("</if>");
let next_else = html[pos..].find("<else");
let events: Vec<(usize, &str)> = [
next_if.map(|p| (p, "if")),
next_endif.map(|p| (p, "endif")),
next_else.map(|p| (p, "else")),
]
.into_iter()
.flatten()
.collect();
if events.is_empty() {
break;
}
let (offset, event_type) = events.into_iter().min_by_key(|(p, _)| *p)?;
match event_type {
"if" => {
depth += 1;
pos = pos + offset + 3;
}
"endif" => {
depth -= 1;
pos = pos + offset + 5;
}
"else" => {
if depth == 0 {
let after = &html[pos + offset..];
if after.starts_with("<else/>") || after.starts_with("<else />") {
return Some(pos + offset);
}
}
pos = pos + offset + 5;
}
_ => break,
}
}
None
}
fn process_unless_tags(&self, html: &str, context: &HashMap<String, Value>) -> Result<String> {
let mut output = html.to_string();
let mut iterations = 0;
const MAX_ITERATIONS: usize = 100;
while output.contains("<unless") && iterations < MAX_ITERATIONS {
iterations += 1;
if let Some((start, end, condition, body)) = self.find_unless_tag(&output) {
let result = if !self.evaluate_condition(&condition, context) {
body
} else {
String::new()
};
output = format!("{}{}{}", &output[..start], result, &output[end..]);
} else {
break;
}
}
Ok(output)
}
fn find_unless_tag(&self, html: &str) -> Option<(usize, usize, String, String)> {
let start = find_tag_start(html, "<unless")?;
let tag_end = find_outside_quotes(&html[start..], ">")? + start + 1;
let tag_content = &html[start..tag_end];
let condition = self.extract_attr(tag_content, "cond").unwrap_or_else(|| {
let inner = tag_content.strip_prefix("<unless").unwrap_or("").trim();
let inner = inner.strip_suffix(">").unwrap_or(inner).trim();
inner.to_string()
});
let end_tag = "</unless>";
if let Some(end_pos) = html[tag_end..].find(end_tag) {
let body = html[tag_end..tag_end + end_pos].to_string();
let end = tag_end + end_pos + end_tag.len();
return Some((start, end, condition, body));
}
None
}
fn evaluate_condition(&self, condition: &str, context: &HashMap<String, Value>) -> bool {
let condition = condition.trim();
if condition.is_empty() {
return false;
}
let or_parts = split_top_level_bool(condition, "or");
if or_parts.len() > 1 {
return or_parts.iter().any(|p| self.evaluate_condition(p, context));
}
let and_parts = split_top_level_bool(condition, "and");
if and_parts.len() > 1 {
return and_parts.iter().all(|p| self.evaluate_condition(p, context));
}
let condition = if !condition.contains('#') {
wrap_bare_variables(condition)
} else {
condition.to_string()
};
let condition = condition.trim();
let condition = map_keyword_operators(condition);
let condition = condition.trim();
if condition.starts_with('!') {
return !self.evaluate_condition(&condition[1..], context);
}
for (op, cmp_fn) in &[
(">=", CompareOp::Gte),
("<=", CompareOp::Lte),
("!=", CompareOp::Ne),
("==", CompareOp::Eq),
(">", CompareOp::Gt),
("<", CompareOp::Lt),
] {
if let Some(idx) = find_outside_quotes(condition, op) {
let (left_raw, left_quoted) =
crate::parser::strip_symmetric_quotes(condition[..idx].trim());
let (right_raw, right_quoted) =
crate::parser::strip_symmetric_quotes(condition[idx + op.len()..].trim());
let left =
crate::parser::html_unescape(&replace_variables(left_raw, context));
let right =
crate::parser::html_unescape(&replace_variables(right_raw, context));
let left_num = crate::parser::evaluate_arithmetic(&left)
.or_else(|| left.parse::<f64>().ok());
let right_num = crate::parser::evaluate_arithmetic(&right)
.or_else(|| right.parse::<f64>().ok());
let force_string = left_quoted || right_quoted;
return match cmp_fn {
CompareOp::Eq => match (left_num, right_num) {
(Some(l), Some(r)) if !force_string => l == r,
_ => left == right,
},
CompareOp::Ne => match (left_num, right_num) {
(Some(l), Some(r)) if !force_string => l != r,
_ => left != right,
},
CompareOp::Gt => match (left_num, right_num) {
(Some(l), Some(r)) => l > r,
_ => left > right,
},
CompareOp::Lt => match (left_num, right_num) {
(Some(l), Some(r)) => l < r,
_ => left < right,
},
CompareOp::Gte => match (left_num, right_num) {
(Some(l), Some(r)) => l >= r,
_ => left >= right,
},
CompareOp::Lte => match (left_num, right_num) {
(Some(l), Some(r)) => l <= r,
_ => left <= right,
},
};
}
}
if let Some(idx) = find_outside_quotes(condition, " contains ") {
let left = crate::parser::html_unescape(&replace_variables(
condition[..idx].trim(),
context,
));
let right_raw = condition[idx + " contains ".len()..].trim();
let right = crate::parser::strip_symmetric_quotes(right_raw).0;
return left.contains(right);
}
if let Some(var_name) = condition
.strip_prefix('#')
.and_then(|s| s.strip_suffix('#'))
.filter(|s| !s.contains('#'))
{
return Self::is_truthy(self.lookup_context_value(var_name, context));
}
let resolved = replace_variables(condition, context);
if resolved != condition.to_string() {
return match resolved.as_str() {
"" | "false" | "null" | "0" => false,
_ => true,
};
}
let var_name = condition.trim_matches('#');
Self::is_truthy(self.lookup_context_value(var_name, context))
}
fn lookup_context_value<'a>(
&self,
var_name: &str,
context: &'a HashMap<String, Value>,
) -> Option<&'a Value> {
let parts: Vec<&str> = var_name.split('.').collect();
let first = parts.first()?;
let mut current = context.get(*first);
for part in parts.iter().skip(1) {
current = current.and_then(|v| match v {
Value::Object(obj) => obj.get(*part),
_ => None,
});
}
current
}
fn is_truthy(value: Option<&Value>) -> bool {
match value {
Some(Value::Bool(b)) => *b,
Some(Value::Null) => false,
Some(Value::String(s)) => !s.is_empty(),
Some(Value::Number(n)) => n.as_f64().map(|v| v != 0.0).unwrap_or(true),
Some(Value::Array(arr)) => !arr.is_empty(),
Some(Value::Object(obj)) => !obj.is_empty(),
None => false,
}
}
fn component_attr_value(value: &str) -> Value {
let trimmed = value.trim();
if (trimmed.starts_with('[') && trimmed.ends_with(']'))
|| (trimmed.starts_with('{') && trimmed.ends_with('}'))
{
if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
return parsed;
}
}
Value::String(value.to_string())
}
fn build_component_context(
component: &Component,
attrs: &HashMap<String, String>,
context: &HashMap<String, Value>,
) -> HashMap<String, Value> {
let mut component_context = context.clone();
for prop_name in &component.props {
if !attrs.contains_key(prop_name) && !component.defaults.contains_key(prop_name) {
component_context.insert(prop_name.clone(), Value::String(String::new()));
}
}
for (key, value) in &component.defaults {
if !attrs.contains_key(key) {
component_context.insert(key.clone(), Value::String(value.clone()));
}
}
for (key, value) in attrs {
let resolved = replace_variables(value, context);
component_context.insert(key.clone(), Self::component_attr_value(&resolved));
}
component_context
}
fn apply_component_slots(mut rendered: String, children: Option<&str>) -> String {
if let Some(children) = children {
rendered = rendered.replace("<slot/>", children);
rendered = rendered.replace("<slot />", children);
} else {
rendered = rendered.replace("<slot/>", "");
rendered = rendered.replace("<slot />", "");
}
rendered
}
fn render_pagination(
attrs: &HashMap<String, String>,
context: &HashMap<String, Value>,
) -> String {
let total: usize = attrs
.get("total")
.map(|v| replace_variables(v, context))
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let per_page: usize = attrs
.get("per-page")
.map(|v| replace_variables(v, context))
.and_then(|v| v.parse().ok())
.unwrap_or(10)
.max(1);
let current: usize = attrs
.get("current")
.map(|v| replace_variables(v, context))
.and_then(|v| v.parse().ok())
.unwrap_or(1)
.max(1);
let base_url = attrs
.get("base-url")
.map(|v| replace_variables(v, context))
.unwrap_or_else(|| "/".to_string());
let param = attrs
.get("param")
.cloned()
.unwrap_or_else(|| "page".to_string());
let total_pages = if total == 0 {
0
} else {
(total + per_page - 1) / per_page
};
if total_pages <= 1 {
return String::new();
}
let current = current.min(total_pages);
let page_numbers = Self::compute_page_numbers(current, total_pages);
let mut html = String::from(r#"<nav class="what-pagination" aria-label="Pagination"><ul>"#);
if current > 1 {
html.push_str(&format!(
r#"<li><a href="{}?{}={}" class="what-pagination-prev" aria-label="Previous page">«</a></li>"#,
base_url, param, current - 1
));
} else {
html.push_str(r#"<li><span class="what-pagination-prev what-pagination-disabled" aria-disabled="true">«</span></li>"#);
}
for &num in &page_numbers {
if num == 0 {
html.push_str(r#"<li><span class="what-pagination-ellipsis">…</span></li>"#);
} else if num == current {
html.push_str(&format!(
r#"<li><span class="what-pagination-active" aria-current="page">{}</span></li>"#,
num
));
} else {
html.push_str(&format!(
r#"<li><a href="{}?{}={}">{}</a></li>"#,
base_url, param, num, num
));
}
}
if current < total_pages {
html.push_str(&format!(
r#"<li><a href="{}?{}={}" class="what-pagination-next" aria-label="Next page">»</a></li>"#,
base_url, param, current + 1
));
} else {
html.push_str(r#"<li><span class="what-pagination-next what-pagination-disabled" aria-disabled="true">»</span></li>"#);
}
html.push_str("</ul></nav>");
html
}
fn compute_page_numbers(current: usize, total: usize) -> Vec<usize> {
if total <= 7 {
return (1..=total).collect();
}
let mut pages = Vec::new();
pages.push(1);
if current > 3 {
pages.push(0); }
let range_start = if current <= 3 { 2 } else { current - 1 };
let range_end = if current >= total - 2 {
total - 1
} else {
current + 1
};
for p in range_start..=range_end {
pages.push(p);
}
if current < total - 2 {
pages.push(0); }
pages.push(total);
pages
}
fn render_turnstile(
attrs: &HashMap<String, String>,
context: &HashMap<String, Value>,
) -> String {
let site_key = context
.get("_turnstile_site_key")
.and_then(|v| v.as_str())
.unwrap_or("");
if site_key.is_empty() {
let is_dev = context
.get("_dev_mode")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_dev {
return r#"<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;padding:8px 12px;margin:4px 0;border-radius:4px;font-family:monospace;font-size:13px">Turnstile: missing [cloudflare] turnstile_site_key</div>"#.to_string();
}
return String::new();
}
let theme = attrs.get("theme").map(|s| s.as_str()).unwrap_or("auto");
let size = attrs.get("size").map(|s| s.as_str()).unwrap_or("normal");
format!(
r#"<div class="cf-turnstile" data-sitekey="{}" data-theme="{}" data-size="{}"></div><script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>"#,
site_key, theme, size
)
}
fn push_extra_attrs(out: &mut String, attrs: &HashMap<String, String>, handled: &[&str]) {
let mut extras: Vec<(&String, &String)> = attrs
.iter()
.filter(|(k, _)| !handled.contains(&k.as_str()))
.collect();
extras.sort();
for (k, v) in extras {
push_attr(out, k, v);
}
}
fn render_what_fetch(
attrs: &HashMap<String, String>,
children: &str,
context: &HashMap<String, Value>,
) -> String {
let is_dev = context
.get("_dev_mode")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let url = match attrs.get("url").filter(|u| !u.is_empty()) {
Some(u) => u,
None => {
return if is_dev {
dev_banner("<what-fetch> requires a url attribute")
} else {
String::new()
};
}
};
let method = attrs
.get("method")
.map(|s| s.to_lowercase())
.unwrap_or_else(|| "get".to_string());
let fetch_attr = if method == "post" { "w-post" } else { "w-get" };
let when = attrs.get("when").map(|s| s.as_str()).unwrap_or("load");
let poll = attrs.get("poll");
if let Some(p) = poll {
if !POLL_INTERVAL_RE.is_match(p) {
if is_dev {
return dev_banner(&format!(
"<what-fetch> invalid poll=\"{}\" — use e.g. 500ms, 5s, 2m, 1h or bare seconds",
p
));
}
}
}
let mut triggers: Vec<String> = Vec::new();
match when {
"load" => triggers.push("load".to_string()),
"visible" => triggers.push("revealed".to_string()),
"click" => {
if poll.is_some() {
triggers.push("click".to_string());
}
}
other => {
if is_dev {
return dev_banner(&format!(
"<what-fetch> unknown when=\"{}\" — expected load, visible, or click",
other
));
}
triggers.push("load".to_string());
}
}
if let Some(p) = poll {
if POLL_INTERVAL_RE.is_match(p) {
triggers.push(format!("poll {}", p));
}
}
let wrapper = attrs.get("as").map(|s| s.as_str()).unwrap_or("div");
let mut class = String::from("w-fetch");
if let Some(c) = attrs.get("class").filter(|c| !c.is_empty()) {
class.push(' ');
class.push_str(c);
}
let mut out = format!("<{}", wrapper);
push_attr(&mut out, "class", &class);
push_attr(&mut out, fetch_attr, url);
if !triggers.is_empty() {
push_attr(&mut out, "w-trigger", &triggers.join(", "));
}
for (attr, w_attr) in [
("target", "w-target"),
("swap", "w-swap"),
("params", "w-params"),
("include", "w-include"),
("loading", "w-loading"),
("confirm", "w-confirm"),
] {
if let Some(v) = attrs.get(attr) {
push_attr(&mut out, w_attr, v);
}
}
Self::push_extra_attrs(
&mut out,
attrs,
&[
"url", "when", "poll", "method", "target", "swap", "params", "include",
"loading", "confirm", "as", "class",
],
);
out.push('>');
out.push_str(children);
out.push_str(&format!("</{}>", wrapper));
out
}
fn render_what_clipboard(
attrs: &HashMap<String, String>,
children: &str,
context: &HashMap<String, Value>,
) -> String {
let is_dev = context
.get("_dev_mode")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let value = attrs.get("value");
let from = attrs.get("from");
if value.is_none() && from.is_none() {
return if is_dev {
dev_banner("<what-clipboard> requires value=\"text\" or from=\"selector\"")
} else {
String::new()
};
}
let mut out = String::from("<button type=\"button\"");
if let Some(v) = value {
push_attr(&mut out, "w-clipboard", v);
} else if let Some(f) = from {
push_attr(&mut out, "w-clipboard-from", f);
}
if let Some(l) = attrs.get("copied-label") {
push_attr(&mut out, "w-copied-label", l);
}
Self::push_extra_attrs(&mut out, attrs, &["value", "from", "copied-label"]);
out.push('>');
out.push_str(if children.trim().is_empty() {
"Copy"
} else {
children
});
out.push_str("</button>");
out
}
fn render_what_theme_toggle(attrs: &HashMap<String, String>, children: &str) -> String {
let mut class = String::from("w-theme-toggle");
if let Some(c) = attrs.get("class").filter(|c| !c.is_empty()) {
class.push(' ');
class.push_str(c);
}
let mut out = String::from("<button type=\"button\" w-theme-toggle");
push_attr(&mut out, "class", &class);
if !attrs.contains_key("aria-label") {
push_attr(&mut out, "aria-label", "Toggle theme");
}
Self::push_extra_attrs(&mut out, attrs, &["class"]);
out.push('>');
if children.trim().is_empty() {
out.push_str(
r#"<span class="w-theme-icon-light">☀</span><span class="w-theme-icon-dark">☾</span>"#,
);
} else {
out.push_str(children);
}
out.push_str("</button>");
out
}
fn process_custom_tags_html(
&self,
template: &str,
context: &HashMap<String, Value>,
) -> Result<String> {
let mut output = template.to_string();
let mut iterations = 0;
const MAX_ITERATIONS: usize = 100;
let component_names = self.get_component_names();
loop {
let mut found_any = false;
if let Some((start, end, attrs, _children)) =
self.find_custom_tag(&output, "what-pagination")
{
let rendered = Self::render_pagination(&attrs, context);
output = format!("{}{}{}", &output[..start], rendered, &output[end..]);
iterations += 1;
if iterations >= MAX_ITERATIONS {
break;
}
continue;
}
if let Some((start, end, attrs, _children)) =
self.find_custom_tag(&output, "what-turnstile")
{
let rendered = Self::render_turnstile(&attrs, context);
output = format!("{}{}{}", &output[..start], rendered, &output[end..]);
iterations += 1;
if iterations >= MAX_ITERATIONS {
break;
}
continue;
}
let mut handled_builtin = false;
for tag in ["what-fetch", "what-clipboard", "what-theme-toggle"] {
if let Some((start, end, attrs, children)) = self.find_custom_tag(&output, tag) {
let rendered = match tag {
"what-fetch" => Self::render_what_fetch(&attrs, &children, context),
"what-clipboard" => Self::render_what_clipboard(&attrs, &children, context),
_ => Self::render_what_theme_toggle(&attrs, &children),
};
output = format!("{}{}{}", &output[..start], rendered, &output[end..]);
handled_builtin = true;
break;
}
}
if handled_builtin {
iterations += 1;
if iterations >= MAX_ITERATIONS {
break;
}
continue;
}
for component_name in &component_names {
if let Some((start, end, attrs, children)) =
self.find_custom_tag(&output, component_name)
{
if let Some(component_def) = self.components.get(component_name) {
let children = if children.is_empty() {
None
} else {
Some(children.as_str())
};
let component_context =
Self::build_component_context(&component_def, &attrs, context);
let mut rendered = component_def.template.clone();
if rendered.contains("<loop") {
if let Ok(processed) =
self.process_loops_html(&rendered, &component_context)
{
rendered = processed;
}
}
if rendered.contains("<if") {
if let Ok(processed) =
self.process_conditionals_html(&rendered, &component_context)
{
rendered = processed;
}
}
rendered = replace_variables(&rendered, &component_context);
rendered = Self::apply_component_slots(rendered, children);
output = format!("{}{}{}", &output[..start], rendered, &output[end..]);
found_any = true;
break; }
}
}
iterations += 1;
if !found_any || iterations >= MAX_ITERATIONS {
break;
}
}
let is_dev = context
.get("_dev_mode")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_dev {
let mut unresolved: Vec<(usize, String)> = Vec::new();
let mut search_from = 0;
while let Some(pos) = output[search_from..].find("<what-") {
let abs_pos = search_from + pos;
let rest = &output[abs_pos..];
if let Some(end) = rest.find('>') {
let tag = &rest[1..end].split_whitespace().next().unwrap_or("");
if !tag.starts_with('/')
&& !BUILTIN_TAGS.contains(&tag.trim_end_matches('/'))
{
let tag_name = tag.trim_end_matches('/');
if !component_names
.iter()
.any(|c| format!("what-{}", c) == tag_name || *c == tag_name)
{
tracing::warn!("Template warning: unresolved component <{}>", tag_name);
unresolved.push((abs_pos, tag_name.to_string()));
}
}
search_from = abs_pos + end + 1;
} else {
break;
}
}
for (pos, tag_name) in unresolved.into_iter().rev() {
let banner = format!(
r#"<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;padding:8px 12px;margin:4px 0;border-radius:4px;font-family:monospace;font-size:13px">Unknown component: <b><{}></b> — no matching file in components/</div>"#,
escape_for_banner(&tag_name)
);
output.insert_str(pos, &banner);
}
}
Ok(output)
}
fn find_custom_tag(
&self,
html: &str,
tag_name: &str,
) -> Option<(usize, usize, HashMap<String, String>, String)> {
let open_pattern = format!("<{}", tag_name);
let start = find_tag_start(html, &open_pattern)?;
let tag_start_rest = &html[start..];
let open_tag_end = find_outside_quotes(tag_start_rest, ">")? + start + 1;
let open_tag = &html[start..open_tag_end];
let attrs = self.parse_tag_attributes(open_tag);
if open_tag.ends_with("/>") {
return Some((start, open_tag_end, attrs, String::new()));
}
let close_tag = format!("</{}>", tag_name);
let mut depth = 1;
let mut pos = open_tag_end;
while depth > 0 && pos < html.len() {
let rest = &html[pos..];
let next_open = rest.find(&open_pattern);
let next_close = rest.find(&close_tag);
match (next_open, next_close) {
(Some(o), Some(c)) if o < c => {
let after_open = &rest[o..];
if after_open
.chars()
.skip(open_pattern.len())
.next()
.map(|c| c == ' ' || c == '>' || c == '/')
.unwrap_or(false)
{
depth += 1;
}
pos = pos + o + open_pattern.len();
}
(_, Some(c)) => {
depth -= 1;
if depth == 0 {
let children = html[open_tag_end..pos + c].to_string();
let end = pos + c + close_tag.len();
return Some((start, end, attrs, children));
}
pos = pos + c + close_tag.len();
}
_ => break,
}
}
None
}
fn parse_tag_attributes(&self, tag: &str) -> HashMap<String, String> {
let mut attrs = HashMap::new();
for cap in DOUBLE_QUOTE_ATTR_RE.captures_iter(tag) {
if let (Some(name), Some(value)) = (cap.get(1), cap.get(2)) {
attrs.insert(name.as_str().to_string(), value.as_str().to_string());
}
}
for cap in SINGLE_QUOTE_ATTR_RE.captures_iter(tag) {
if let (Some(name), Some(value)) = (cap.get(1), cap.get(2)) {
attrs.insert(name.as_str().to_string(), value.as_str().to_string());
}
}
attrs
}
fn get_component_names(&self) -> Vec<String> {
self.components.component_names()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::ComponentRegistry;
use scraper::{Html, Selector};
use serde_json::json;
fn make_engine() -> RenderEngine {
let mut components = ComponentRegistry::new();
components.register_builtins();
RenderEngine::new(components)
}
#[tokio::test]
async fn test_loop_array() {
let engine = make_engine();
let mut context = HashMap::new();
context.insert(
"users".to_string(),
json!([
{"name": "Alice"},
{"name": "Bob"}
]),
);
let template = r##"<loop data="#users#"><li>#item.name#</li></loop>"##;
let result = engine.render(template, &context).await.unwrap();
assert!(result.contains("<li>Alice</li>"));
assert!(result.contains("<li>Bob</li>"));
}
#[tokio::test]
async fn test_loop_with_alias() {
let engine = make_engine();
let mut context = HashMap::new();
context.insert(
"posts".to_string(),
json!([
{"title": "Post 1"},
{"title": "Post 2"}
]),
);
let template = r##"<loop data="#posts#" as="post"><h2>#post.title#</h2></loop>"##;
let result = engine.render(template, &context).await.unwrap();
assert!(result.contains("<h2>Post 1</h2>"));
assert!(result.contains("<h2>Post 2</h2>"));
}
#[tokio::test]
async fn test_if_condition() {
let engine = make_engine();
let mut context = HashMap::new();
context.insert("logged_in".to_string(), json!(true));
let template = r##"<if cond="#logged_in#">Welcome!</if>"##;
let result = engine.render(template, &context).await.unwrap();
assert_eq!(result.trim(), "Welcome!");
}
#[tokio::test]
async fn test_if_else() {
let engine = make_engine();
let mut context = HashMap::new();
context.insert("logged_in".to_string(), json!(false));
let template = r##"<if cond="#logged_in#">Dashboard<else/>Login</if>"##;
let result = engine.render(template, &context).await.unwrap();
assert_eq!(result.trim(), "Login");
}
#[tokio::test]
async fn test_elseif() {
let engine = make_engine();
let mut context = HashMap::new();
context.insert("status".to_string(), json!("success"));
let template = r##"<if cond='#status# == "success"'>OK<elseif cond='#status# == "error"'/>ERR<else/>UNKNOWN</if>"##;
let result = engine.render(template, &context).await.unwrap();
assert_eq!(result.trim(), "OK");
let mut context = HashMap::new();
context.insert("status".to_string(), json!("error"));
let result = engine.render(template, &context).await.unwrap();
assert_eq!(result.trim(), "ERR");
let mut context = HashMap::new();
context.insert("status".to_string(), json!("pending"));
let result = engine.render(template, &context).await.unwrap();
assert_eq!(result.trim(), "UNKNOWN");
}
#[tokio::test]
async fn test_elseif_multiple() {
let engine = make_engine();
let template = r##"<if cond='#level# == "high"'>HIGH<elseif cond='#level# == "medium"'/>MEDIUM<elseif cond='#level# == "low"'/>LOW<else/>NONE</if>"##;
let mut context = HashMap::new();
context.insert("level".to_string(), json!("medium"));
let result = engine.render(template, &context).await.unwrap();
assert_eq!(result.trim(), "MEDIUM");
context.insert("level".to_string(), json!("low"));
let result = engine.render(template, &context).await.unwrap();
assert_eq!(result.trim(), "LOW");
}
#[tokio::test]
async fn test_unless() {
let engine = make_engine();
let mut context = HashMap::new();
context.insert("error".to_string(), json!(null));
let template = r##"<unless cond="#error#">All good!</unless>"##;
let result = engine.render(template, &context).await.unwrap();
assert_eq!(result.trim(), "All good!");
}
#[tokio::test]
async fn test_unless_empty_array_is_falsey() {
let engine = make_engine();
let mut context = HashMap::new();
context.insert("items".to_string(), json!([]));
let template = r##"<unless cond="#items#">No items</unless>"##;
let result = engine.render(template, &context).await.unwrap();
assert_eq!(result.trim(), "No items");
}
#[tokio::test]
async fn test_comparison() {
let engine = make_engine();
let mut context = HashMap::new();
context.insert("role".to_string(), json!("admin"));
let template = r##"<if cond='#role# == "admin"'>Admin Panel</if>"##;
let result = engine.render(template, &context).await.unwrap();
assert!(result.contains("Admin Panel"));
}
#[tokio::test]
async fn test_contains_operator() {
let engine = make_engine();
let mut context = HashMap::new();
context.insert("lines".to_string(), json!("XOX|XXX|OXO"));
let template = r##"<if cond='#lines# contains "XXX"'>Winner</if>"##;
let result = engine.render(template, &context).await.unwrap();
assert!(result.contains("Winner"));
let template = r##"<if cond='#lines# contains "OOO"'>Winner<else/>No winner</if>"##;
let result = engine.render(template, &context).await.unwrap();
assert!(result.contains("No winner"));
}
#[tokio::test]
async fn test_custom_component_page() {
let engine = make_engine();
let context = HashMap::new();
let template = r##"<what-page title="Test Page"><div>Content</div></what-page>"##;
let result = engine.render(template, &context).await.unwrap();
println!("Result: {}", result);
assert!(result.contains("<!DOCTYPE html>"));
assert!(result.contains("<title>Test Page</title>"));
assert!(result.contains("<div>Content</div>"));
}
#[tokio::test]
async fn test_scraper_custom_components() {
let html = r##"<what-page title="Test"><div>Hello</div></what-page>"##;
let doc = Html::parse_fragment(html);
println!("Parsed HTML: {}", doc.html());
let selector = Selector::parse("what-page").unwrap();
let count = doc.select(&selector).count();
println!("Found {} what-page elements", count);
for el in doc.select(&selector) {
println!("Element outer: {}", el.html());
println!("Element inner: {}", el.inner_html());
}
assert!(
count > 0,
"Scraper should find custom <what-page> component"
);
}
#[tokio::test]
async fn test_what_nav_component() {
use crate::components::{Component, ComponentRegistry};
let nav_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("examples/demo/components/nav.html");
let mut nav_component =
Component::from_file_with_name(&nav_path).expect("Failed to load nav.html");
nav_component.name = format!("what-{}", nav_component.name);
println!("Component name: {}", nav_component.name);
println!(
"Component template length: {}",
nav_component.template.len()
);
println!("Component template: '{}'", nav_component.template);
let mut registry = ComponentRegistry::new();
registry.register(nav_component);
println!(
"Component names in registry: {:?}",
registry.component_names()
);
let engine = RenderEngine::new(registry);
let context = HashMap::new();
let template = r##"<what-nav active="home"/>"##;
println!("Input template: '{}'", template);
let result = engine.render(template, &context).await.unwrap();
println!("Rendered result: '{}'", result);
assert!(
result.contains("<header"),
"Result should contain <header>, got: '{}'",
result
);
assert!(
result.contains("nav-brand"),
"Result should contain nav-brand"
);
}
#[tokio::test]
async fn test_full_page_with_nav() {
use crate::components::{Component, ComponentRegistry};
let mut registry = ComponentRegistry::new();
registry.register_builtins();
let nav_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("examples/demo/components/nav.html");
let mut nav_component =
Component::from_file_with_name(&nav_path).expect("Failed to load nav.html");
nav_component.name = format!("what-{}", nav_component.name);
println!(
"Registering nav component with name: {}",
nav_component.name
);
println!("Nav template length: {}", nav_component.template.len());
registry.register(nav_component);
println!("All component names: {:?}", registry.component_names());
let engine = RenderEngine::new(registry);
let context = HashMap::new();
let template = r##"<page title="Test">
<what-nav active="home"/>
<main>Content</main>
</page>"##;
println!("Input template:\n{}", template);
let result = engine.render(template, &context).await.unwrap();
println!("Rendered result:\n{}", result);
assert!(result.contains("<!DOCTYPE html>"), "Should have doctype");
assert!(result.contains("<title>Test</title>"), "Should have title");
assert!(
result.contains("<header"),
"Result should contain <header> from nav"
);
assert!(
result.contains("nav-brand"),
"Result should contain nav-brand from nav"
);
assert!(
result.contains("<main>Content</main>"),
"Should have main content"
);
}
#[tokio::test]
async fn test_code_block_vars_preserved() {
let engine = make_engine();
let mut context = HashMap::new();
context.insert("name".to_string(), json!("Alice"));
let template = r##"<p>Hello #name#</p><code class="example-code">#name# syntax</code>"##;
let result = engine.render(template, &context).await.unwrap();
assert!(
result.contains("<p>Hello Alice</p>"),
"Variable outside code should be replaced"
);
assert!(
result.contains("#name# syntax"),
"Variable inside code should be preserved"
);
}
#[tokio::test]
async fn test_code_block_env_vars_preserved() {
let engine = make_engine();
let context = HashMap::new();
let template = r##"<code class="example-code">#env.API_KEY# and #env.DEBUG#</code>"##;
let result = engine.render(template, &context).await.unwrap();
assert!(
result.contains("#env.API_KEY#"),
"Env var in code block should be preserved"
);
assert!(
result.contains("#env.DEBUG#"),
"Env var in code block should be preserved"
);
}
#[tokio::test]
async fn test_code_block_multiple_blocks() {
let engine = make_engine();
let mut context = HashMap::new();
context.insert("x".to_string(), json!("replaced"));
let template = r##"<code>#x#</code><p>#x#</p><code>#x# again</code>"##;
let result = engine.render(template, &context).await.unwrap();
assert!(
result.contains("<p>replaced</p>"),
"Var outside code replaced"
);
assert_eq!(
result.matches("#x#").count(),
2,
"Both code block vars preserved"
);
}
#[tokio::test]
async fn test_component_json_array_loop() {
use crate::components::Component;
let component = Component {
name: "what-groups".to_string(),
props: vec!["groups".to_string()],
defaults: HashMap::new(),
template: r##"<ul><loop data="#groups#" as="g"><li>#g.name#</li></loop></ul>"##
.to_string(),
};
let mut registry = ComponentRegistry::new();
registry.register(component);
let engine = RenderEngine::new(registry);
let context = HashMap::new();
let template =
r##"<what-groups groups='[{"id":1,"name":"Admins"},{"id":2,"name":"Editors"}]'/>"##;
let result = engine.render(template, &context).await.unwrap();
println!("Component loop result: {}", result);
assert!(
result.contains("Admins"),
"Should contain Admins, got: {}",
result
);
assert!(
result.contains("Editors"),
"Should contain Editors, got: {}",
result
);
assert!(
!result.contains("loop: no data"),
"Should not have loop error, got: {}",
result
);
}
#[tokio::test]
async fn test_render_with_timing_produces_correct_output() {
let engine = make_engine();
let mut context = HashMap::new();
context.insert("name".to_string(), json!("World"));
context.insert("items".to_string(), json!([{"label": "A"}, {"label": "B"}]));
context.insert("show".to_string(), json!(true));
let template = r##"<p>Hello #name#</p>
<loop data="#items#" as="item"><span>#item.label#</span></loop>
<if cond="#show#">Visible</if>"##;
let result = engine.render(template, &context).await.unwrap();
assert!(result.contains("Hello World"));
assert!(result.contains("<span>A</span>"));
assert!(result.contains("<span>B</span>"));
assert!(result.contains("Visible"));
}
#[tokio::test]
async fn test_code_block_reactive_preserved() {
let engine = make_engine();
let mut context = HashMap::new();
context.insert("session".to_string(), json!({"count": 5}));
let template = r##"<p>#session.count#</p><code>#session.count#</code>"##;
let result = engine.render_reactive(template, &context).await.unwrap();
assert!(
result.html.contains("w-bind"),
"Session var outside code should be wrapped"
);
assert!(
result.html.contains("#session.count#"),
"Session var inside code should be preserved"
);
}
#[test]
fn test_unclosed_if_lint_fires() {
assert!(warn_template_lints_once(
std::path::Path::new("/lint-test/unclosed-if.html"),
"<if user.name>Hello #user.name#"
));
assert!(warn_template_lints_once(
std::path::Path::new("/lint-test/unclosed-loop.html"),
r##"<loop data="#items#" as="it">#it.name#"##
));
assert!(!warn_template_lints_once(
std::path::Path::new("/lint-test/balanced.html"),
"<if user.name>Hello</if><loop data=\"#items#\" as=\"it\">x</loop>"
));
assert!(!warn_template_lints_once(
std::path::Path::new("/lint-test/iframe.html"),
r#"<iframe src="x"></iframe>"#
));
}
#[tokio::test]
async fn test_unresolved_component_banner_in_dev_mode() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("_dev_mode".to_string(), json!(true));
let dev = engine
.render("<what-tyop>oops</what-tyop>", &ctx)
.await
.unwrap();
assert!(
dev.contains("Unknown component"),
"dev mode should show a banner: {}",
dev
);
let mut prod_ctx = HashMap::new();
prod_ctx.insert("_dev_mode".to_string(), json!(false));
let prod = engine
.render("<what-tyop>oops</what-tyop>", &prod_ctx)
.await
.unwrap();
assert!(
!prod.contains("Unknown component"),
"prod must not show banners: {}",
prod
);
}
#[tokio::test]
async fn test_what_fetch_default_when_is_load() {
let engine = make_engine();
let ctx = HashMap::new();
let html = engine
.render(
r#"<what-fetch url="/w-partial/stats">fallback</what-fetch>"#,
&ctx,
)
.await
.unwrap();
assert!(html.contains(r#"w-get="/w-partial/stats""#), "{}", html);
assert!(html.contains(r#"w-trigger="load""#), "{}", html);
assert!(html.contains(r#"class="w-fetch""#), "{}", html);
assert!(html.contains("fallback"), "{}", html);
assert!(html.contains("</div>"), "{}", html);
assert!(!html.contains("<what-fetch"), "{}", html);
}
#[tokio::test]
async fn test_what_fetch_triggers_and_poll_grammar() {
let engine = make_engine();
let ctx = HashMap::new();
let html = engine
.render(
r#"<what-fetch url="/w-partial/c" when="visible">Loading…</what-fetch>"#,
&ctx,
)
.await
.unwrap();
assert!(html.contains(r#"w-trigger="revealed""#), "{}", html);
let html = engine
.render(r#"<what-fetch url="/w-partial/t" poll="5s"/>"#, &ctx)
.await
.unwrap();
assert!(html.contains(r#"w-trigger="load, poll 5s""#), "{}", html);
let html = engine
.render(
r#"<what-fetch url="/w-partial/t" when="click" poll="30s"/>"#,
&ctx,
)
.await
.unwrap();
assert!(html.contains(r#"w-trigger="click, poll 30s""#), "{}", html);
let html = engine
.render(r#"<what-fetch url="/w-partial/t" when="click"/>"#, &ctx)
.await
.unwrap();
assert!(!html.contains("w-trigger"), "{}", html);
for poll in ["500ms", "2m", "1h", "45"] {
let html = engine
.render(
&format!(r#"<what-fetch url="/w-partial/t" poll="{}"/>"#, poll),
&ctx,
)
.await
.unwrap();
assert!(
html.contains(&format!("poll {}", poll)),
"poll={} → {}",
poll,
html
);
}
}
#[tokio::test]
async fn test_what_fetch_method_target_swap_as_passthrough() {
let engine = make_engine();
let ctx = HashMap::new();
let html = engine
.render(
r##"<what-fetch url="/w-partial/rows" method="post" target="#list" swap="append" as="tbody" id="rows" data-x="1">seed</what-fetch>"##,
&ctx,
)
.await
.unwrap();
assert!(html.contains(r#"w-post="/w-partial/rows""#), "{}", html);
assert!(!html.contains("w-get"), "{}", html);
assert!(html.contains(r##"w-target="#list""##), "{}", html);
assert!(html.contains(r#"w-swap="append""#), "{}", html);
assert!(html.starts_with("<tbody"), "{}", html);
assert!(html.ends_with("</tbody>"), "{}", html);
assert!(html.contains(r#"id="rows""#), "{}", html);
assert!(html.contains(r#"data-x="1""#), "{}", html);
}
#[tokio::test]
async fn test_what_fetch_dev_banners_and_prod_fallbacks() {
let engine = make_engine();
let mut dev = HashMap::new();
dev.insert("_dev_mode".to_string(), json!(true));
let mut prod = HashMap::new();
prod.insert("_dev_mode".to_string(), json!(false));
let html = engine.render("<what-fetch>x</what-fetch>", &dev).await.unwrap();
assert!(html.contains("requires a url"), "{}", html);
let html = engine.render("<what-fetch>x</what-fetch>", &prod).await.unwrap();
assert!(!html.contains("what-fetch"), "{}", html);
let html = engine
.render(r#"<what-fetch url="/x" poll="soon"/>"#, &dev)
.await
.unwrap();
assert!(html.contains("invalid poll"), "{}", html);
let html = engine
.render(r#"<what-fetch url="/x" poll="soon"/>"#, &prod)
.await
.unwrap();
assert!(!html.contains("poll"), "{}", html);
let html = engine
.render(r#"<what-fetch url="/x" when="hover"/>"#, &dev)
.await
.unwrap();
assert!(html.contains("unknown when"), "{}", html);
let html = engine
.render(r#"<what-fetch url="/x" when="hover"/>"#, &prod)
.await
.unwrap();
assert!(html.contains(r#"w-trigger="load""#), "{}", html);
}
#[tokio::test]
async fn test_what_fetch_var_in_url_resolves() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("uid".to_string(), json!(7));
let html = engine
.render(r#"<what-fetch url="/w-partial/user/#uid#"/>"#, &ctx)
.await
.unwrap();
assert!(html.contains(r#"w-get="/w-partial/user/7""#), "{}", html);
}
#[tokio::test]
async fn test_what_fetch_nested_regions_both_expand() {
let engine = make_engine();
let ctx = HashMap::new();
let html = engine
.render(
r#"<what-fetch url="/w-partial/outer"><what-fetch url="/w-partial/inner" when="visible">inner</what-fetch></what-fetch>"#,
&ctx,
)
.await
.unwrap();
assert!(html.contains(r#"w-get="/w-partial/outer""#), "{}", html);
assert!(html.contains(r#"w-get="/w-partial/inner""#), "{}", html);
assert!(!html.contains("<what-fetch"), "{}", html);
}
#[tokio::test]
async fn test_what_clipboard_value_and_from() {
let engine = make_engine();
let ctx = HashMap::new();
let html = engine
.render(
r#"<what-clipboard value="cargo install run-what"/>"#,
&ctx,
)
.await
.unwrap();
assert!(
html.contains(r#"<button type="button" w-clipboard="cargo install run-what""#),
"{}",
html
);
assert!(html.contains(">Copy</button>"), "{}", html);
let html = engine
.render(
r##"<what-clipboard from="#room-link" copied-label="copied!">copy</what-clipboard>"##,
&ctx,
)
.await
.unwrap();
assert!(html.contains(r##"w-clipboard-from="#room-link""##), "{}", html);
assert!(html.contains(r#"w-copied-label="copied!""#), "{}", html);
assert!(html.contains(">copy</button>"), "{}", html);
assert!(!html.contains("w-clipboard="), "{}", html);
}
#[tokio::test]
async fn test_what_clipboard_missing_source() {
let engine = make_engine();
let mut dev = HashMap::new();
dev.insert("_dev_mode".to_string(), json!(true));
let html = engine
.render("<what-clipboard>Copy</what-clipboard>", &dev)
.await
.unwrap();
assert!(html.contains("requires value="), "{}", html);
let mut prod = HashMap::new();
prod.insert("_dev_mode".to_string(), json!(false));
let html = engine
.render("<what-clipboard>Copy</what-clipboard>", &prod)
.await
.unwrap();
assert!(!html.contains("button"), "{}", html);
}
#[tokio::test]
async fn test_what_theme_toggle_defaults_and_children() {
let engine = make_engine();
let ctx = HashMap::new();
let html = engine.render("<what-theme-toggle/>", &ctx).await.unwrap();
assert!(html.contains("w-theme-toggle"), "{}", html);
assert!(html.contains(r#"class="w-theme-toggle""#), "{}", html);
assert!(html.contains("w-theme-icon-light"), "{}", html);
assert!(html.contains("w-theme-icon-dark"), "{}", html);
assert!(html.contains(r#"aria-label="Toggle theme""#), "{}", html);
let html = engine
.render(
r#"<what-theme-toggle class="nav-btn">Theme</what-theme-toggle>"#,
&ctx,
)
.await
.unwrap();
assert!(html.contains(r#"class="w-theme-toggle nav-btn""#), "{}", html);
assert!(html.contains(">Theme</button>"), "{}", html);
assert!(!html.contains("w-theme-icon"), "{}", html);
}
#[tokio::test]
async fn test_escaped_builtin_in_code_survives() {
let engine = make_engine();
let ctx = HashMap::new();
let template =
r#"<code><what-fetch url="/w-partial/stats" poll="5s"></what-fetch></code>"#;
let html = engine.render(template, &ctx).await.unwrap();
assert!(html.contains("<what-fetch"), "{}", html);
assert!(!html.contains("w-trigger"), "{}", html);
}
#[test]
fn test_raw_builtin_in_code_lint() {
assert!(warn_template_lints_once(
std::path::Path::new("/lint-test/raw-builtin-in-code.html"),
r#"<code><what-fetch url="/x">sample</what-fetch></code>"#
));
assert!(!warn_template_lints_once(
std::path::Path::new("/lint-test/escaped-builtin-in-code.html"),
r#"<code><what-fetch url="/x">sample</what-fetch></code>"#
));
}
#[test]
fn test_include_tag_gt_inside_attr_value() {
let engine = make_engine();
let html = r#"<p>before</p><include src="box.html" title="5 > 3"/><p>after</p>"#;
let (start, end, src, attrs) = engine.find_include_tag(html).unwrap();
assert_eq!(src, "box.html");
assert_eq!(attrs.get("title").map(String::as_str), Some("5 > 3"));
assert_eq!(
&html[start..end],
r#"<include src="box.html" title="5 > 3"/>"#
);
}
#[test]
fn test_extract_attr_word_boundary() {
let engine = make_engine();
let tag = r##"<loop class="list" data="#items#" as="item">"##;
assert_eq!(engine.extract_attr(tag, "as").as_deref(), Some("item"));
let tag2 = r##"<a w-target="#panel" href="/x">"##;
assert_eq!(engine.extract_attr(tag2, "target"), None);
}
#[test]
fn test_custom_tag_prefix_name_no_collision() {
let engine = make_engine();
let html = "<what-card-header>H</what-card-header><what-card>C</what-card>";
let (start, _end, _attrs, children) = engine.find_custom_tag(html, "what-card").unwrap();
assert_eq!(children, "C", "matched the wrong tag at {}", start);
}
#[test]
fn test_custom_tag_gt_inside_attr_value() {
let engine = make_engine();
let html = r#"<what-badge label="a > b">child</what-badge>"#;
let (start, end, attrs, children) = engine.find_custom_tag(html, "what-badge").unwrap();
assert_eq!(start, 0);
assert_eq!(end, html.len());
assert_eq!(attrs.get("label").map(String::as_str), Some("a > b"));
assert_eq!(children, "child");
}
#[test]
fn section_auth_admin_sees_admin_content() {
let mut context = HashMap::new();
context.insert(
"user".to_string(),
json!({"authenticated": true, "role": "admin"}),
);
let html = r#"<section auth="admin"><p>Admin panel</p></section>"#;
let result = RenderEngine::process_section_auth(html, &context).unwrap();
assert!(result.contains("Admin panel"));
}
#[test]
fn section_auth_user_denied_admin_content() {
let mut context = HashMap::new();
context.insert(
"user".to_string(),
json!({"authenticated": true, "role": "user"}),
);
let html = r#"<section auth="admin"><p>Admin panel</p></section>"#;
let result = RenderEngine::process_section_auth(html, &context).unwrap();
assert!(!result.contains("Admin panel"));
}
#[test]
fn section_auth_anonymous_denied() {
let mut context = HashMap::new();
context.insert("user".to_string(), json!({"authenticated": false}));
let html = r#"<div auth="user"><p>Members only</p></div>"#;
let result = RenderEngine::process_section_auth(html, &context).unwrap();
assert!(!result.contains("Members only"));
}
#[test]
fn section_auth_authenticated_sees_user_content() {
let mut context = HashMap::new();
context.insert(
"user".to_string(),
json!({"authenticated": true, "role": "viewer"}),
);
let html = r#"<div auth="user"><p>Welcome back</p></div>"#;
let result = RenderEngine::process_section_auth(html, &context).unwrap();
assert!(result.contains("Welcome back"));
}
#[test]
fn section_auth_multiple_roles() {
let mut context = HashMap::new();
context.insert(
"user".to_string(),
json!({"authenticated": true, "role": "editor"}),
);
let html = r#"<section auth="admin, editor"><p>Staff tools</p></section>"#;
let result = RenderEngine::process_section_auth(html, &context).unwrap();
assert!(result.contains("Staff tools"));
}
#[test]
fn section_auth_single_quoted_attr_denies_anonymous() {
let mut context = HashMap::new();
context.insert("user".to_string(), json!({"authenticated": false}));
let html = r#"<section auth='admin'><p>Admin panel</p></section>"#;
let result = RenderEngine::process_section_auth(html, &context).unwrap();
assert!(!result.contains("Admin panel"), "got: {}", result);
}
#[test]
fn section_auth_single_quoted_attr_allows_matching_role() {
let mut context = HashMap::new();
context.insert(
"user".to_string(),
json!({"authenticated": true, "role": "admin"}),
);
let html = r#"<section auth='admin'><p>Admin panel</p></section>"#;
let result = RenderEngine::process_section_auth(html, &context).unwrap();
assert!(result.contains("Admin panel"), "got: {}", result);
}
#[test]
fn section_auth_public_always_shown() {
let mut context = HashMap::new();
context.insert("user".to_string(), json!({"authenticated": false}));
let html = r#"<div auth="all"><p>Public info</p></div>"#;
let result = RenderEngine::process_section_auth(html, &context).unwrap();
assert!(result.contains("Public info"));
}
#[test]
fn section_auth_preserves_non_auth_elements() {
let mut context = HashMap::new();
context.insert("user".to_string(), json!({"authenticated": false}));
let html = r#"<div><p>Visible</p></div><section auth="admin"><p>Hidden</p></section><p>Also visible</p>"#;
let result = RenderEngine::process_section_auth(html, &context).unwrap();
assert!(result.contains("Visible"));
assert!(result.contains("Also visible"));
assert!(!result.contains("Hidden"));
}
#[tokio::test]
async fn test_simplified_if_truthy() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("logged_in".to_string(), json!(true));
let result = engine
.render("<if logged_in>Welcome!</if>", &ctx)
.await
.unwrap();
assert!(result.contains("Welcome!"));
}
#[tokio::test]
async fn test_if_inside_loop_resolves_alias() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert(
"items".to_string(),
json!([
{"name": "A", "done": "true"},
{"name": "B", "done": "false"}
]),
);
let tpl = r##"<loop data="#items#" as="it"><if it.done == "true">DONE:#it.name#</if><unless it.done == "true">TODO:#it.name#</unless></loop>"##;
let result = engine.render(tpl, &ctx).await.unwrap();
assert!(result.contains("DONE:A"), "got: {}", result);
assert!(result.contains("TODO:B"), "got: {}", result);
assert!(!result.contains("DONE:B"), "got: {}", result);
assert!(!result.contains("TODO:A"), "got: {}", result);
}
#[tokio::test]
async fn test_if_quoted_both_sides() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("a".to_string(), json!("alice"));
ctx.insert("b".to_string(), json!("alice"));
ctx.insert("c".to_string(), json!("bob"));
let same = engine.render(r##"<if "#a#" == "#b#">MATCH</if>"##, &ctx).await.unwrap();
let diff = engine.render(r##"<if "#a#" == "#c#">MATCH</if>"##, &ctx).await.unwrap();
assert!(same.contains("MATCH"), "equal quoted operands should match: {}", same);
assert!(!diff.contains("MATCH"), "unequal quoted operands should not match: {}", diff);
}
#[tokio::test]
async fn test_if_equality_unescapes_operands() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("company".to_string(), json!("Ben & Jerry"));
ctx.insert("name".to_string(), json!("O'Brien"));
let amp = engine
.render(r#"<if company == "Ben & Jerry">HIT</if>"#, &ctx)
.await
.unwrap();
let apos = engine
.render(r#"<if name == "O'Brien">HIT</if>"#, &ctx)
.await
.unwrap();
assert!(amp.contains("HIT"), "ampersand value should match: {}", amp);
assert!(apos.contains("HIT"), "apostrophe value should match: {}", apos);
}
#[tokio::test]
async fn test_if_numeric_equality_coerces() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("price".to_string(), json!(10.0));
let eq = engine.render("<if price == 10>HIT</if>", &ctx).await.unwrap();
let ne = engine.render("<if price != 10>MISS</if>", &ctx).await.unwrap();
assert!(eq.contains("HIT"), "10.0 == 10 should match: {}", eq);
assert!(!ne.contains("MISS"), "10.0 != 10 should not match: {}", ne);
}
#[tokio::test]
async fn test_if_quoted_literal_forces_string_equality() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("zip".to_string(), json!("01234"));
ctx.insert("num".to_string(), json!(1234));
let string_match = engine
.render(r#"<if zip == "01234">HIT</if>"#, &ctx)
.await
.unwrap();
let no_coerce = engine
.render(r#"<if num == "01234">MISS</if>"#, &ctx)
.await
.unwrap();
assert!(string_match.contains("HIT"), "got: {}", string_match);
assert!(!no_coerce.contains("MISS"), "quoted literal must not numeric-coerce: {}", no_coerce);
}
#[tokio::test]
async fn test_if_single_quoted_literal() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("role".to_string(), json!("admin"));
let hit = engine
.render(r#"<if role == 'admin'>Panel</if>"#, &ctx)
.await
.unwrap();
let miss = engine
.render(r#"<if role == 'editor'>Panel</if>"#, &ctx)
.await
.unwrap();
assert!(hit.contains("Panel"), "single-quoted literal should match: {}", hit);
assert!(!miss.contains("Panel"), "wrong literal should not match: {}", miss);
}
#[tokio::test]
async fn test_if_keyword_operator_inside_quoted_literal() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("title".to_string(), json!("the gt debate"));
let result = engine
.render(r#"<if title == "the gt debate">HIT</if>"#, &ctx)
.await
.unwrap();
assert!(result.contains("HIT"), "got: {}", result);
}
#[tokio::test]
async fn test_if_operator_inside_quoted_left_operand() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("mode".to_string(), json!("a == b"));
let result = engine
.render(r#"<if "a == b" == mode>HIT</if>"#, &ctx)
.await
.unwrap();
assert!(result.contains("HIT"), "got: {}", result);
}
#[tokio::test]
async fn test_contains_single_quoted_literal() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("title".to_string(), json!("the gt debate"));
let result = engine
.render(r#"<if title contains 'debate'>HIT</if>"#, &ctx)
.await
.unwrap();
assert!(result.contains("HIT"), "got: {}", result);
}
#[tokio::test]
async fn test_simplified_if_equality() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("status".to_string(), json!("admin"));
let result = engine
.render(r#"<if status == "admin">Panel</if>"#, &ctx)
.await
.unwrap();
assert!(result.contains("Panel"));
}
#[tokio::test]
async fn test_simplified_if_numeric_eq() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("active_step".to_string(), json!(2));
let result = engine
.render("<if active_step == 2>Step 2!</if>", &ctx)
.await
.unwrap();
assert!(result.contains("Step 2!"));
}
#[tokio::test]
async fn test_simplified_elseif() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("level".to_string(), json!("low"));
let result = engine
.render(
r#"<if level == "high">H<elseif level == "low"/>L<else/>M</if>"#,
&ctx,
)
.await
.unwrap();
assert!(result.contains("L"));
assert!(!result.contains("H"));
assert!(!result.contains("M"));
}
#[tokio::test]
async fn test_simplified_unless() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("error".to_string(), json!(false));
let result = engine
.render("<unless error>All good!</unless>", &ctx)
.await
.unwrap();
assert!(result.contains("All good!"));
}
#[test]
fn test_split_top_level_bool() {
assert_eq!(
split_top_level_bool("a == 1 and b == 2", "and"),
vec!["a == 1", "b == 2"]
);
assert_eq!(split_top_level_bool("a == 1", "and"), vec!["a == 1"]);
assert_eq!(
split_top_level_bool(r#"status == "up and running""#, "and"),
vec![r#"status == "up and running""#]
);
assert_eq!(
split_top_level_bool("android == 1", "and"),
vec!["android == 1"]
);
assert_eq!(
split_top_level_bool("category == 2", "or"),
vec!["category == 2"]
);
assert_eq!(
split_top_level_bool("a and b and c", "and"),
vec!["a", "b", "c"]
);
}
#[tokio::test]
async fn test_if_and_both_true() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("count".to_string(), json!(5));
ctx.insert("role".to_string(), json!("admin"));
let result = engine
.render(r#"<if count gt 0 and role == "admin">BOTH</if>"#, &ctx)
.await
.unwrap();
assert!(result.contains("BOTH"), "got: {}", result);
}
#[tokio::test]
async fn test_if_and_one_false() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("count".to_string(), json!(0));
ctx.insert("role".to_string(), json!("admin"));
let result = engine
.render(r#"<if count gt 0 and role == "admin">BOTH</if>"#, &ctx)
.await
.unwrap();
assert!(!result.contains("BOTH"), "got: {}", result);
}
#[tokio::test]
async fn test_if_or() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("role".to_string(), json!("editor"));
let tpl = r#"<if role == "admin" or role == "editor">STAFF</if>"#;
let result = engine.render(tpl, &ctx).await.unwrap();
assert!(result.contains("STAFF"), "got: {}", result);
ctx.insert("role".to_string(), json!("guest"));
let result = engine.render(tpl, &ctx).await.unwrap();
assert!(!result.contains("STAFF"), "got: {}", result);
}
#[tokio::test]
async fn test_and_binds_tighter_than_or() {
let engine = make_engine();
let tpl = "<if a or b and c>YES</if>";
let mut ctx = HashMap::new();
ctx.insert("a".to_string(), json!(true));
ctx.insert("b".to_string(), json!(false));
ctx.insert("c".to_string(), json!(false));
let result = engine.render(tpl, &ctx).await.unwrap();
assert!(result.contains("YES"), "a alone should satisfy: {}", result);
ctx.insert("a".to_string(), json!(false));
ctx.insert("b".to_string(), json!(true));
let result = engine.render(tpl, &ctx).await.unwrap();
assert!(!result.contains("YES"), "b alone must not satisfy: {}", result);
ctx.insert("c".to_string(), json!(true));
let result = engine.render(tpl, &ctx).await.unwrap();
assert!(result.contains("YES"), "b and c should satisfy: {}", result);
}
#[tokio::test]
async fn test_and_with_quoted_operand_containing_keyword() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("status".to_string(), json!("up and running"));
ctx.insert("ok".to_string(), json!(true));
let result = engine
.render(r#"<if status == "up and running" and ok>LIVE</if>"#, &ctx)
.await
.unwrap();
assert!(result.contains("LIVE"), "got: {}", result);
}
#[tokio::test]
async fn test_elseif_with_and() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("n".to_string(), json!(7));
ctx.insert("enabled".to_string(), json!(true));
let tpl = "<if n gt 10>BIG<elseif n gt 5 and enabled/>MID<else/>SMALL</if>";
let result = engine.render(tpl, &ctx).await.unwrap();
assert!(result.contains("MID"), "got: {}", result);
}
#[tokio::test]
async fn test_unless_with_and_de_morgan() {
let engine = make_engine();
let tpl = "<unless a and b>SHOWN</unless>";
let mut ctx = HashMap::new();
ctx.insert("a".to_string(), json!(true));
ctx.insert("b".to_string(), json!(false));
let result = engine.render(tpl, &ctx).await.unwrap();
assert!(result.contains("SHOWN"), "got: {}", result);
ctx.insert("b".to_string(), json!(true));
let result = engine.render(tpl, &ctx).await.unwrap();
assert!(!result.contains("SHOWN"), "got: {}", result);
}
#[tokio::test]
async fn test_negation_applies_per_leaf() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("a".to_string(), json!(false));
ctx.insert("b".to_string(), json!(true));
let result = engine.render("<if !a and b>OK</if>", &ctx).await.unwrap();
assert!(result.contains("OK"), "got: {}", result);
}
#[tokio::test]
async fn test_legacy_cond_attr_supports_and() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("count".to_string(), json!(3));
ctx.insert("active".to_string(), json!(true));
let result = engine
.render(r##"<if cond="#count# gt 0 and #active#">ON</if>"##, &ctx)
.await
.unwrap();
assert!(result.contains("ON"), "got: {}", result);
}
#[test]
fn test_template_lint_regexes() {
assert!(LEGACY_COND_RE.is_match(r##"<if cond="#a#">x</if>"##));
assert!(LEGACY_COND_RE.is_match(r##"<elseif cond='#a#'/>"##));
assert!(LEGACY_COND_RE.is_match(r##"<unless cond = "#a#">x</unless>"##));
assert!(!LEGACY_COND_RE.is_match("<if count gt 0>x</if>"));
assert!(!LEGACY_COND_RE.is_match("<if cond=\"#a#\">"));
assert!(!LEGACY_COND_RE.is_match("<iframe cond=\"x\">"));
assert!(!LEGACY_COND_RE.is_match("<if conditional_flag>x</if>"));
assert!(TRAILING_ELSE_RE.is_match("</if><else/>oops</else>"));
assert!(TRAILING_ELSE_RE.is_match("</if>\n <else/>"));
assert!(TRAILING_ELSE_RE.is_match("</if> <else />"));
assert!(!TRAILING_ELSE_RE.is_match("<if a>x<else/>y</if>"));
}
#[test]
fn test_collect_template_lints_kinds() {
let kinds = |raw: &str| -> Vec<&'static str> {
collect_template_lints(raw).iter().map(|l| l.kind).collect()
};
assert_eq!(kinds(r##"<if cond="#a#">x</if>"##), vec!["legacy-cond"]);
assert_eq!(kinds("</if><else/>oops</else>"), vec!["trailing-else"]);
assert_eq!(kinds("<if x>oops"), vec!["unclosed"]);
assert_eq!(
kinds(r#"<code><what-fetch url="/x">y</what-fetch></code>"#),
vec!["raw-builtin-in-code"]
);
assert!(collect_template_lints("<if a>x<else/>y</if>").is_empty());
assert!(collect_template_lints("<p>plain</p>").is_empty());
assert!(collect_template_lints("<what-fetch> in prose").is_empty());
}
#[test]
fn test_escape_html_helper() {
assert_eq!(escape_html(r#"<script>&"#), "<script>&");
assert_eq!(escape_html(r#"a"b"#), "a"b");
}
#[test]
fn test_warn_template_lints_once_dedup() {
let path = std::path::Path::new("/tmp/lint-test-template-a.html");
let raw = r##"<if cond="#a#">x</if>"##;
assert!(warn_template_lints_once(path, raw));
assert!(!warn_template_lints_once(path, raw));
let clean_path = std::path::Path::new("/tmp/lint-test-template-b.html");
assert!(!warn_template_lints_once(clean_path, "<if a>x<else/>y</if>"));
}
#[test]
fn test_find_outside_quotes_skips_quoted_segments() {
assert_eq!(find_outside_quotes(r##"cond="#count# > 0">"##, ">"), Some(18));
assert_eq!(find_outside_quotes("plain > here", ">"), Some(6));
assert_eq!(find_outside_quotes(r#""all > quoted""#, ">"), None);
assert_eq!(find_outside_quotes(r#"a='x > y'/>"#, "/>"), Some(9));
}
#[test]
fn test_find_tag_start_requires_word_boundary() {
assert_eq!(find_tag_start("<iframe src='x'>", "<if"), None);
assert_eq!(find_tag_start("<iframe><if a>", "<if"), Some(8));
assert_eq!(find_tag_start("<if a == 1>", "<if"), Some(0));
assert_eq!(find_tag_start("text <if>", "<if"), Some(5));
}
#[tokio::test]
async fn test_cond_attr_with_gt_symbol() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("count".to_string(), json!(5));
let result = engine
.render(r##"<if cond="#count# > 0">HAS_ITEMS</if>"##, &ctx)
.await
.unwrap();
assert!(result.contains("HAS_ITEMS"), "got: {}", result);
ctx.insert("count".to_string(), json!(0));
let result = engine
.render(r##"<if cond="#count# > 0">HAS_ITEMS</if>"##, &ctx)
.await
.unwrap();
assert!(!result.contains("HAS_ITEMS"), "got: {}", result);
}
#[tokio::test]
async fn test_cond_attr_with_gte_symbol_and_elseif() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("score".to_string(), json!(50));
let tpl = r##"<if cond="#score# >= 90">GRADE_A<elseif cond="#score# >= 50"/>GRADE_PASS<else/>GRADE_FAIL</if>"##;
let result = engine.render(tpl, &ctx).await.unwrap();
assert!(result.contains("GRADE_PASS"), "got: {}", result);
assert!(!result.contains("GRADE_A"), "got: {}", result);
assert!(!result.contains("GRADE_FAIL"), "got: {}", result);
}
#[tokio::test]
async fn test_unless_cond_attr_with_gt_symbol() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("count".to_string(), json!(0));
let result = engine
.render(r##"<unless cond="#count# > 0">EMPTY</unless>"##, &ctx)
.await
.unwrap();
assert!(result.contains("EMPTY"), "got: {}", result);
}
#[tokio::test]
async fn test_simplified_if_quoted_operand_with_spaces() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("status".to_string(), json!("up and running"));
let result = engine
.render(r#"<if status == "up and running">HEALTHY</if>"#, &ctx)
.await
.unwrap();
assert!(result.contains("HEALTHY"), "got: {}", result);
}
#[tokio::test]
async fn test_simplified_if_quoted_gt_does_not_truncate_tag() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("note".to_string(), json!("something else"));
let result = engine
.render(r#"<if note != "x > y">DIFF</if>"#, &ctx)
.await
.unwrap();
assert_eq!(result.trim(), "DIFF", "got: {}", result);
}
#[tokio::test]
async fn test_iframe_not_mistaken_for_if_tag() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("flag".to_string(), json!(true));
let tpl = r#"<iframe src="/embed"></iframe><p>KEEP</p><if flag><iframe src="/inner"></iframe>YES</if>"#;
let result = engine.render(tpl, &ctx).await.unwrap();
assert!(result.contains("<iframe src=\"/embed\">"), "got: {}", result);
assert!(result.contains("KEEP"), "got: {}", result);
assert!(result.contains("YES"), "got: {}", result);
assert!(result.contains("<iframe src=\"/inner\">"), "got: {}", result);
}
#[tokio::test]
async fn test_numeric_gt_keyword() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("age".to_string(), json!(25));
let result = engine
.render("<if age gt 18>Adult</if>", &ctx)
.await
.unwrap();
assert!(result.contains("Adult"));
}
#[tokio::test]
async fn test_numeric_lte_keyword() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("count".to_string(), json!(5));
let result = engine
.render("<if count lte 5>Ok</if>", &ctx)
.await
.unwrap();
assert!(result.contains("Ok"));
}
#[tokio::test]
async fn test_numeric_gt_cond_attr() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("age".to_string(), json!(25));
let result = engine
.render(r##"<if cond="#age# > 18">Adult</if>"##, &ctx)
.await
.unwrap();
assert!(result.contains("Adult"));
}
#[tokio::test]
async fn test_nested_loop() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert(
"categories".to_string(),
json!([
{"name": "Fruit", "items": [{"label": "Apple"}, {"label": "Banana"}]},
{"name": "Veggie", "items": [{"label": "Carrot"}]},
]),
);
let template = r##"<loop data="#categories#" as="cat"><h2>#cat.name#</h2><loop data="#cat.items#" as="item"><li>#item.label#</li></loop></loop>"##;
let result = engine.render(template, &ctx).await.unwrap();
assert!(result.contains("Fruit"));
assert!(result.contains("Apple"));
assert!(result.contains("Banana"));
assert!(result.contains("Veggie"));
assert!(result.contains("Carrot"));
}
#[tokio::test]
async fn test_nested_loop_three_levels() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert(
"data".to_string(),
json!([
{"groups": [{"items": ["a", "b"]}]}
]),
);
let template = r##"<loop data="#data#" as="d"><loop data="#d.groups#" as="g"><loop data="#g.items#" as="i">[#i#]</loop></loop></loop>"##;
let result = engine.render(template, &ctx).await.unwrap();
assert!(
result.contains("[a]"),
"Should contain [a], got: {}",
result
);
assert!(
result.contains("[b]"),
"Should contain [b], got: {}",
result
);
}
#[tokio::test]
async fn test_nested_loop_empty_inner() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert(
"categories".to_string(),
json!([
{"name": "Empty", "items": []},
]),
);
let template = r##"<loop data="#categories#" as="cat"><h2>#cat.name#</h2><loop data="#cat.items#" as="item"><li>#item.label#</li></loop></loop>"##;
let result = engine.render(template, &ctx).await.unwrap();
assert!(result.contains("Empty"));
assert!(!result.contains("<li>"));
}
#[tokio::test]
async fn test_backward_compat_cond() {
let engine = make_engine();
let mut ctx = HashMap::new();
ctx.insert("show".to_string(), json!(true));
let result = engine
.render(r##"<if cond="#show#">Visible</if>"##, &ctx)
.await
.unwrap();
assert!(result.contains("Visible"));
}
}