Skip to main content

what_core/server/
engine.rs

1//! Template rendering engine
2//!
3//! Handles variable replacement, custom components, loops, and conditionals.
4//! Uses scraper/html5ever for robust HTML parsing.
5
6use regex::Regex;
7use serde_json::Value;
8use std::collections::HashMap;
9use std::sync::LazyLock;
10
11/// Escape HTML special characters (`& < > "`) to prevent XSS when interpolating
12/// untrusted values into framework-generated HTML (dev banners, the inspector).
13pub(crate) fn escape_html(s: &str) -> String {
14    s.replace('&', "&amp;")
15        .replace('<', "&lt;")
16        .replace('>', "&gt;")
17        .replace('"', "&quot;")
18}
19
20/// Escape HTML special characters to prevent XSS in dev-mode error banners
21fn escape_for_banner(s: &str) -> String {
22    escape_html(s)
23}
24
25/// Dev-mode inline error banner (same styling as the unresolved-component banner)
26fn dev_banner(msg: &str) -> String {
27    format!(
28        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>"#,
29        escape_for_banner(msg)
30    )
31}
32
33/// Built-in `<what-*>` tags rendered programmatically by the engine (not from
34/// components/*.html). Shared by tag dispatch and the unresolved-tag banner.
35const BUILTIN_TAGS: &[&str] = &[
36    "what-pagination",
37    "what-turnstile",
38    "what-fetch",
39    "what-clipboard",
40    "what-theme-toggle",
41];
42
43/// Emit one HTML attribute, picking a quote style that survives the value
44/// (w-params JSON is typically written with single quotes around double).
45fn push_attr(out: &mut String, name: &str, value: &str) {
46    if value.contains('"') {
47        out.push_str(&format!(" {}='{}'", name, value));
48    } else {
49        out.push_str(&format!(" {}=\"{}\"", name, value));
50    }
51}
52
53/// Valid poll interval: bare seconds or number + ms|s|m|h (mirrors what.js)
54static POLL_INTERVAL_RE: LazyLock<Regex> =
55    LazyLock::new(|| Regex::new(r"^\d+(ms|s|m|h)?$").unwrap());
56
57/// `<code>` block spans, for linting raw built-in tags inside code samples
58static CODE_BLOCK_RE: LazyLock<Regex> =
59    LazyLock::new(|| Regex::new(r"(?s)<code\b[^>]*>.*?</code>").unwrap());
60
61/// Regex matching unresolved #variable# patterns (for strict mode warnings)
62static UNRESOLVED_VAR_RE: LazyLock<Regex> =
63    LazyLock::new(|| Regex::new(r"#([a-zA-Z_][\w.]*(?:\|[^#]+)?)#").unwrap());
64
65/// Regex for double-quoted attributes: attr="value"
66static DOUBLE_QUOTE_ATTR_RE: LazyLock<Regex> =
67    LazyLock::new(|| Regex::new(r#"([a-zA-Z_][\w-]*)\s*=\s*"([^"]*)""#).unwrap());
68
69/// Regex for single-quoted attributes: attr='value'
70static SINGLE_QUOTE_ATTR_RE: LazyLock<Regex> =
71    LazyLock::new(|| Regex::new(r"([a-zA-Z_][\w-]*)\s*=\s*'([^']*)'").unwrap());
72
73use crate::Result;
74use crate::components::{Component, ComponentRegistry};
75use crate::parser::{ReactiveReplaceResult, replace_variables, replace_variables_reactive};
76
77/// Comparison operators for <if> conditions
78enum CompareOp {
79    Eq,
80    Ne,
81    Gt,
82    Lt,
83    Gte,
84    Lte,
85}
86
87/// Auto-wrap bare variable names in `#` for simplified <if> syntax.
88/// `active_step == 2` → `#active_step# == 2`
89/// Skips: quoted strings, numbers, booleans, operators, keywords.
90fn wrap_bare_variables(expr: &str) -> String {
91    let mut result = String::new();
92    let bytes = expr.as_bytes();
93    let mut i = 0;
94
95    while i < bytes.len() {
96        let c = bytes[i];
97
98        // Skip quoted strings
99        if c == b'"' || c == b'\'' {
100            let quote = c;
101            result.push(c as char);
102            i += 1;
103            while i < bytes.len() && bytes[i] != quote {
104                result.push(bytes[i] as char);
105                i += 1;
106            }
107            if i < bytes.len() {
108                result.push(bytes[i] as char);
109                i += 1;
110            }
111            continue;
112        }
113
114        // Skip whitespace and operators
115        if c.is_ascii_whitespace() || b"!=<>".contains(&c) {
116            result.push(c as char);
117            i += 1;
118            continue;
119        }
120
121        // Word characters — collect the full word
122        if c.is_ascii_alphabetic() || c == b'_' {
123            let start = i;
124            while i < bytes.len()
125                && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' || bytes[i] == b'.')
126            {
127                i += 1;
128            }
129            let word = &expr[start..i];
130            match word {
131                "true" | "false" | "contains" | "gt" | "gte" | "lt" | "lte" => {
132                    result.push_str(word);
133                }
134                _ => {
135                    result.push('#');
136                    result.push_str(word);
137                    result.push('#');
138                }
139            }
140            continue;
141        }
142
143        // Numbers (including decimals)
144        if c.is_ascii_digit() || (c == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit())
145        {
146            while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
147                result.push(bytes[i] as char);
148                i += 1;
149            }
150            continue;
151        }
152
153        result.push(c as char);
154        i += 1;
155    }
156
157    result
158}
159
160/// Find the byte offset of the first occurrence of `needle` in `s` that sits
161/// outside single- or double-quoted segments. Lets conditional tags carry
162/// operators like `>` inside quoted attribute values without truncating the
163/// tag: `<if cond="#count# > 0">` ends at the final `>`, not the quoted one.
164fn find_outside_quotes(s: &str, needle: &str) -> Option<usize> {
165    let bytes = s.as_bytes();
166    let needle_bytes = needle.as_bytes();
167    let mut i = 0;
168
169    while i < bytes.len() {
170        let c = bytes[i];
171
172        // Skip quoted segments (same state machine as wrap_bare_variables)
173        if c == b'"' || c == b'\'' {
174            let quote = c;
175            i += 1;
176            while i < bytes.len() && bytes[i] != quote {
177                i += 1;
178            }
179            i += 1; // consume closing quote (or run off the end on unmatched quotes)
180            continue;
181        }
182
183        if bytes[i..].starts_with(needle_bytes) {
184            return Some(i);
185        }
186        i += 1;
187    }
188
189    None
190}
191
192/// Split an expression on a top-level boolean keyword (`and` / `or`).
193/// The keyword must be whitespace-delimited on both sides and sit outside
194/// quoted strings, so `"up and running"` and identifiers like `android`
195/// never split. Returns a single-element vec when the keyword is absent.
196fn split_top_level_bool(expr: &str, keyword: &str) -> Vec<String> {
197    let bytes = expr.as_bytes();
198    let kw = keyword.as_bytes();
199    let mut parts = Vec::new();
200    let mut seg_start = 0;
201    let mut i = 0;
202
203    while i < bytes.len() {
204        let c = bytes[i];
205
206        // Skip quoted segments (same state machine as wrap_bare_variables)
207        if c == b'"' || c == b'\'' {
208            let quote = c;
209            i += 1;
210            while i < bytes.len() && bytes[i] != quote {
211                i += 1;
212            }
213            i += 1;
214            continue;
215        }
216
217        if bytes[i..].starts_with(kw) {
218            let ws_before = i > 0 && bytes[i - 1].is_ascii_whitespace();
219            let after = i + kw.len();
220            let ws_after = after < bytes.len() && bytes[after].is_ascii_whitespace();
221            if ws_before && ws_after {
222                parts.push(expr[seg_start..i].trim().to_string());
223                i = after + 1;
224                seg_start = i;
225                continue;
226            }
227        }
228        i += 1;
229    }
230
231    parts.push(expr[seg_start..].trim().to_string());
232    parts
233}
234
235/// Map keyword comparison operators (` gte ` / ` lte ` / ` gt ` / ` lt `) to
236/// their symbol forms, skipping quoted segments so a literal like
237/// `"the gt debate"` passes through untouched (same quote state machine as
238/// find_outside_quotes). A plain `.replace()` would rewrite user content
239/// inside string operands.
240fn map_keyword_operators(condition: &str) -> String {
241    const OPS: [(&str, &str); 4] = [
242        (" gte ", " >= "),
243        (" lte ", " <= "),
244        (" gt ", " > "),
245        (" lt ", " < "),
246    ];
247    let bytes = condition.as_bytes();
248    let mut out = String::with_capacity(condition.len());
249    let mut seg_start = 0;
250    let mut i = 0;
251
252    while i < bytes.len() {
253        let c = bytes[i];
254
255        // Skip quoted segments (same state machine as find_outside_quotes)
256        if c == b'"' || c == b'\'' {
257            let quote = c;
258            i += 1;
259            while i < bytes.len() && bytes[i] != quote {
260                i += 1;
261            }
262            if i < bytes.len() {
263                i += 1; // consume closing quote
264            }
265            continue;
266        }
267
268        if let Some((kw, sym)) = OPS
269            .iter()
270            .find(|(kw, _)| bytes[i..].starts_with(kw.as_bytes()))
271        {
272            out.push_str(&condition[seg_start..i]);
273            out.push_str(sym);
274            i += kw.len();
275            seg_start = i;
276            continue;
277        }
278        i += 1;
279    }
280
281    out.push_str(&condition[seg_start..]);
282    out
283}
284
285/// Legacy `cond="..."` attribute on a conditional tag (deprecated in favor of
286/// the simplified form, e.g. `<if count gt 0>`).
287static LEGACY_COND_RE: LazyLock<Regex> =
288    LazyLock::new(|| Regex::new(r"<(?:if|elseif|unless)\b[^>]*\bcond\s*=").unwrap());
289
290/// Malformed trailing else: `</if>` followed by `<else/>`. The engine has no
291/// support for an else branch outside the `<if>` block — it always renders.
292static TRAILING_ELSE_RE: LazyLock<Regex> =
293    LazyLock::new(|| Regex::new(r"</if>\s*<else\s*/?>").unwrap());
294
295/// Template files already linted this process (warn once per file).
296static WARNED_TEMPLATE_LINTS: LazyLock<std::sync::Mutex<std::collections::HashSet<std::path::PathBuf>>> =
297    LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
298
299/// Count word-boundary opening tags (`<if `, `<if>`, ...) — same matching
300/// rule as find_tag_start, so `<iframe` never counts as `<if`.
301fn count_tag_starts(s: &str, tag: &str) -> usize {
302    let mut n = 0;
303    let mut from = 0;
304    while let Some(pos) = find_tag_start(&s[from..], tag) {
305        n += 1;
306        from += pos + tag.len();
307    }
308    n
309}
310
311/// A single template-lint finding. `message` is a self-contained sentence
312/// (no file path prefix) so it can be logged with a path or shown in the
313/// dev inspector as-is.
314pub(crate) struct TemplateLint {
315    pub kind: &'static str, // "legacy-cond" | "trailing-else" | "unclosed" | "raw-builtin-in-code"
316    pub message: String,
317}
318
319/// Detect template-authoring mistakes in a raw template. Pure function — no
320/// logging, no dedup. Used by both the dev-render warning path and the
321/// dev inspector.
322pub(crate) fn collect_template_lints(raw: &str) -> Vec<TemplateLint> {
323    let mut lints = Vec::new();
324
325    if raw.contains("cond") && LEGACY_COND_RE.is_match(raw) {
326        lints.push(TemplateLint {
327            kind: "legacy-cond",
328            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(),
329        });
330    }
331
332    if raw.contains("<else") && TRAILING_ELSE_RE.is_match(raw) {
333        lints.push(TemplateLint {
334            kind: "trailing-else",
335            message: "Malformed conditional: <else/> placed after </if> ALWAYS renders. Move it inside the block: <if cond>A<else/>B</if>".to_string(),
336        });
337    }
338
339    // Unclosed conditional/loop blocks: the engine leaves the raw tag in the
340    // output and the "conditional" content renders unconditionally — with no
341    // other symptom. Only more-opens-than-closes is a defect signal.
342    for t in ["if", "loop", "unless"] {
343        let opens = count_tag_starts(raw, &format!("<{}", t));
344        if opens == 0 {
345            continue;
346        }
347        let closes = raw.matches(&format!("</{}>", t)).count();
348        if opens > closes {
349            lints.push(TemplateLint {
350                kind: "unclosed",
351                message: format!(
352                    "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.",
353                    t, opens, closes, t, t
354                ),
355            });
356        }
357    }
358
359    // Raw built-in <what-*> tags inside <code> blocks: tag expansion runs
360    // BEFORE code-block protection, so the sample expands instead of
361    // displaying. Code samples must entity-escape: &lt;what-fetch&gt;
362    if raw.contains("<code") {
363        let tags: std::collections::HashSet<&str> = CODE_BLOCK_RE
364            .find_iter(raw)
365            .flat_map(|m| {
366                BUILTIN_TAGS
367                    .iter()
368                    .filter(move |t| m.as_str().contains(&format!("<{}", t)))
369                    .copied()
370            })
371            .collect();
372        for tag in tags {
373            lints.push(TemplateLint {
374                kind: "raw-builtin-in-code",
375                message: format!(
376                    "Raw <{}> inside a <code> block — built-in tags expand BEFORE code-block protection, so the sample will render instead of displaying. Entity-escape it: &lt;{}&gt;",
377                    tag, tag
378                ),
379            });
380        }
381    }
382
383    lints
384}
385
386/// Dev-mode template lints, emitted once per file per process.
387/// Returns true if any warning was emitted (false = clean file or already warned).
388pub(crate) fn warn_template_lints_once(path: &std::path::Path, raw: &str) -> bool {
389    let lints = collect_template_lints(raw);
390    if lints.is_empty() {
391        return false;
392    }
393
394    let mut warned = WARNED_TEMPLATE_LINTS
395        .lock()
396        .unwrap_or_else(|e| e.into_inner());
397    if !warned.insert(path.to_path_buf()) {
398        return false;
399    }
400
401    for lint in &lints {
402        tracing::warn!("{} in {}", lint.message, path.display());
403    }
404    true
405}
406
407/// Find an opening tag prefix (e.g. `<if`) at a word boundary: the next byte
408/// must be whitespace, `>`, or `/`. Prevents `<iframe` from matching `<if`
409/// (which would corrupt depth tracking and swallow content up to a real `</if>`).
410fn find_tag_start(s: &str, tag: &str) -> Option<usize> {
411    let mut from = 0;
412    while let Some(rel) = s[from..].find(tag) {
413        let pos = from + rel;
414        match s.as_bytes().get(pos + tag.len()) {
415            Some(b) if b.is_ascii_whitespace() || *b == b'>' || *b == b'/' => return Some(pos),
416            None => return Some(pos),
417            _ => from = pos + tag.len(),
418        }
419    }
420    None
421}
422
423/// Parsed loop tag information
424struct LoopInfo {
425    start: usize,
426    end: usize,
427    data_attr: String,
428    alias: String,
429    body: String,
430    /// Items per page (from `paginate` attribute)
431    per_page: Option<usize>,
432    /// Page expression (from `page` attribute, e.g., "#query.page|1#")
433    page_expr: Option<String>,
434}
435
436/// Template rendering engine using html5ever for parsing
437pub struct RenderEngine {
438    components: ComponentRegistry,
439}
440
441impl RenderEngine {
442    pub fn new(components: ComponentRegistry) -> Self {
443        Self { components }
444    }
445
446    /// Render a template with the given context
447    pub async fn render(&self, template: &str, context: &HashMap<String, Value>) -> Result<String> {
448        self.render_with_secret(template, context, None).await
449    }
450
451    /// Render a template with an optional validation secret for form signing
452    pub async fn render_with_secret(
453        &self,
454        template: &str,
455        context: &HashMap<String, Value>,
456        validation_secret: Option<&str>,
457    ) -> Result<String> {
458        let t_start = std::time::Instant::now();
459        let mut output = template.to_string();
460
461        // Process includes first (so included content can have loops, tags, etc.)
462        let t0 = std::time::Instant::now();
463        output = self.process_includes(&output, context)?;
464        let t_includes = t0.elapsed();
465
466        // Process section-level auth (strip elements with auth= attribute if access denied)
467        output = Self::process_section_auth(&output, context)?;
468
469        // Process loops using scraper
470        let t1 = std::time::Instant::now();
471        output = self.process_loops_html(&output, context)?;
472        let t_loops = t1.elapsed();
473
474        // Process conditionals using scraper
475        let t2 = std::time::Instant::now();
476        output = self.process_conditionals_html(&output, context)?;
477        let t_conditionals = t2.elapsed();
478
479        // Process custom tags
480        let t3 = std::time::Instant::now();
481        output = self.process_custom_tags_html(&output, context)?;
482        let t_components = t3.elapsed();
483
484        // Process conditionals again — component output may contain <if> tags
485        let t4 = std::time::Instant::now();
486        output = self.process_conditionals_html(&output, context)?;
487        let t_conditionals2 = t4.elapsed();
488
489        // Process validated forms (sign w-* rules as JWT hidden field)
490        if let Some(secret) = validation_secret {
491            let (processed, _actions) = Self::process_validated_forms(&output, secret);
492            output = processed;
493        }
494
495        // Protect <code> block content from variable replacement
496        let (protected, code_blocks) = Self::protect_code_blocks(&output);
497
498        // Replace remaining variables (simple regex is fine for #var# syntax)
499        let t5 = std::time::Instant::now();
500        let replaced = replace_variables(&protected, context);
501        let t_vars = t5.elapsed();
502
503        // Restore code blocks
504        output = Self::restore_code_blocks(&replaced, &code_blocks);
505
506        // Strict mode: warn about unresolved variables
507        let is_strict = context
508            .get("_strict")
509            .and_then(|v| v.as_bool())
510            .unwrap_or(false);
511        if is_strict {
512            for cap in UNRESOLVED_VAR_RE.captures_iter(&output) {
513                let var = &cap[1];
514                // Skip internal/system variables and CSS color codes
515                if !var.starts_with('_') {
516                    tracing::warn!("Strict: unresolved variable #{}#", var);
517                }
518            }
519        }
520
521        let t_total = t_start.elapsed();
522        tracing::debug!(
523            "Template timing: includes={:.2}ms loops={:.2}ms conditionals={:.2}ms components={:.2}ms conditionals2={:.2}ms vars={:.2}ms total={:.2}ms",
524            t_includes.as_secs_f64() * 1000.0,
525            t_loops.as_secs_f64() * 1000.0,
526            t_conditionals.as_secs_f64() * 1000.0,
527            t_components.as_secs_f64() * 1000.0,
528            t_conditionals2.as_secs_f64() * 1000.0,
529            t_vars.as_secs_f64() * 1000.0,
530            t_total.as_secs_f64() * 1000.0,
531        );
532
533        Ok(output)
534    }
535
536    /// Render a template with reactive session variable wrapping
537    /// Returns both the rendered HTML and the set of session keys used
538    pub async fn render_reactive(
539        &self,
540        template: &str,
541        context: &HashMap<String, Value>,
542    ) -> Result<ReactiveReplaceResult> {
543        self.render_reactive_with_secret(template, context, None)
544            .await
545    }
546
547    /// Render reactive with optional validation secret
548    pub async fn render_reactive_with_secret(
549        &self,
550        template: &str,
551        context: &HashMap<String, Value>,
552        validation_secret: Option<&str>,
553    ) -> Result<ReactiveReplaceResult> {
554        let mut output = template.to_string();
555
556        // Process includes first (so included content can have loops, tags, etc.)
557        output = self.process_includes(&output, context)?;
558
559        // Process section-level auth (strip elements with auth= attribute if access denied)
560        output = Self::process_section_auth(&output, context)?;
561
562        // Process loops using scraper
563        output = self.process_loops_html(&output, context)?;
564
565        // Process conditionals using scraper
566        output = self.process_conditionals_html(&output, context)?;
567
568        // Process custom tags
569        output = self.process_custom_tags_html(&output, context)?;
570
571        // Process conditionals again — component output may contain <if> tags
572        output = self.process_conditionals_html(&output, context)?;
573
574        // Process validated forms
575        if let Some(secret) = validation_secret {
576            let (processed, _actions) = Self::process_validated_forms(&output, secret);
577            output = processed;
578        }
579
580        // Protect <code> block content from variable replacement
581        let (protected, code_blocks) = Self::protect_code_blocks(&output);
582
583        // Replace variables with reactive wrapping for session variables
584        let mut result = replace_variables_reactive(&protected, context);
585
586        // Restore code blocks in the result
587        result.html = Self::restore_code_blocks(&result.html, &code_blocks);
588
589        // Strict mode: warn about unresolved variables
590        let is_strict = context
591            .get("_strict")
592            .and_then(|v| v.as_bool())
593            .unwrap_or(false);
594        if is_strict {
595            for cap in UNRESOLVED_VAR_RE.captures_iter(&result.html) {
596                let var = &cap[1];
597                if !var.starts_with('_') {
598                    tracing::warn!("Strict: unresolved variable #{}#", var);
599                }
600            }
601        }
602
603        Ok(result)
604    }
605
606    /// Process `<form w-validate>` tags: extract w-* validation attributes from inputs,
607    /// encode as JWT, and inject a hidden `<input name="w-rules">` field.
608    /// Returns (processed_html, list_of_action_urls_with_validation).
609    fn process_validated_forms(html: &str, secret: &str) -> (String, Vec<String>) {
610        use crate::validation;
611
612        // Hoisted: this runs on every render of a page containing forms
613        static FORM_RE: LazyLock<Regex> =
614            LazyLock::new(|| Regex::new(r"(?si)<form\b[^>]*\bw-validate\b[^>]*>").unwrap());
615        static ACTION_RE: LazyLock<Regex> =
616            LazyLock::new(|| Regex::new(r#"(?i)action="([^"]+)""#).unwrap());
617        let form_re = &*FORM_RE;
618        let action_re = &*ACTION_RE;
619        let mut output = html.to_string();
620        let mut offset: isize = 0;
621        let mut validated_actions = Vec::new();
622
623        let captures: Vec<_> = form_re.find_iter(html).collect();
624        for mat in captures {
625            let form_tag_end = (mat.end() as isize + offset) as usize;
626
627            // Find matching </form>
628            if let Some(close_pos) = output[form_tag_end..].find("</form>") {
629                let abs_close = form_tag_end + close_pos;
630                let form_body = &output[form_tag_end..abs_close];
631
632                // Parse validation rules from the form's inputs
633                let rules = validation::parse_form_rules(form_body);
634                if rules.fields.is_empty() {
635                    continue;
636                }
637
638                // Extract form action URL for the validation registry
639                let form_tag = mat.as_str();
640                if let Some(cap) = action_re.captures(form_tag) {
641                    if let Some(action) = cap.get(1) {
642                        validated_actions.push(action.as_str().to_string());
643                    }
644                }
645
646                // Encode rules as JWT
647                if let Some(token) = validation::encode_rules(&rules, secret) {
648                    let hidden_field =
649                        format!(r#"<input type="hidden" name="w-rules" value="{}">"#, token);
650                    // Insert hidden field right after the opening <form> tag
651                    output.insert_str(form_tag_end, &hidden_field);
652                    offset += hidden_field.len() as isize;
653
654                    // Inject HTML5 validation attributes onto inputs
655                    let updated_end = (mat.end() as isize + offset) as usize;
656                    if let Some(close_pos2) = output[updated_end..].find("</form>") {
657                        let abs_close2 = updated_end + close_pos2;
658                        let form_section = output[updated_end..abs_close2].to_string();
659                        let enhanced = inject_html5_validation_attrs(&form_section, &rules);
660                        let diff = enhanced.len() as isize - form_section.len() as isize;
661                        output.replace_range(updated_end..abs_close2, &enhanced);
662                        offset += diff;
663                    }
664                }
665            }
666        }
667
668        (output, validated_actions)
669    }
670}
671
672/// Inject HTML5 validation attributes onto form inputs based on parsed rules.
673/// Maps: w-required → required, w-min → minlength, w-max → maxlength, w-type → type, w-pattern → pattern.
674/// Also strips w-* validation attributes from the output.
675fn inject_html5_validation_attrs(form_body: &str, rules: &crate::validation::FormRules) -> String {
676    let mut result = form_body.to_string();
677
678    for (field_name, field_rules) in &rules.fields {
679        let name_attr = format!(r#"name="{}""#, field_name);
680        if let Some(pos) = result.find(&name_attr) {
681            // Find the end of this tag (closing >)
682            let tag_end = result[pos..].find('>').map(|p| pos + p);
683            let mut attrs = String::new();
684
685            if field_rules.required {
686                attrs.push_str(" required");
687            }
688            if let Some(min) = field_rules.min {
689                attrs.push_str(&format!(r#" minlength="{}""#, min));
690            }
691            if let Some(max) = field_rules.max {
692                attrs.push_str(&format!(r#" maxlength="{}""#, max));
693            }
694            if let Some(ref ft) = field_rules.field_type {
695                // Only inject type if the input doesn't already have one
696                let tag_start = result[..pos].rfind('<').unwrap_or(0);
697                let tag_str = &result[tag_start..tag_end.unwrap_or(result.len())];
698                if !tag_str.contains("type=") {
699                    match ft.as_str() {
700                        "email" => attrs.push_str(r#" type="email""#),
701                        "url" => attrs.push_str(r#" type="url""#),
702                        "number" => attrs.push_str(r#" type="number""#),
703                        "phone" => attrs.push_str(r#" type="tel""#),
704                        "date" => attrs.push_str(r#" type="date""#),
705                        "time" => attrs.push_str(r#" type="time""#),
706                        _ => {}
707                    }
708                }
709            }
710            if let Some(ref pattern) = field_rules.pattern {
711                attrs.push_str(&format!(r#" pattern="{}""#, pattern));
712            }
713
714            if !attrs.is_empty() {
715                let insert_pos = pos + name_attr.len();
716                result.insert_str(insert_pos, &attrs);
717            }
718        }
719    }
720
721    // Strip w-* validation attributes from output (they're internal directives)
722    let w_attr_re = regex::Regex::new(
723        r#"\s*w-(required|min|max|type|pattern|match|unique|error)\s*(?:=\s*"[^"]*")?"#,
724    )
725    .unwrap();
726    w_attr_re.replace_all(&result, "").to_string()
727}
728
729impl RenderEngine {
730    /// Extract content inside <code> blocks, replacing with placeholders.
731    /// Returns (protected_html, extracted_blocks).
732    fn protect_code_blocks(html: &str) -> (String, Vec<String>) {
733        let mut result = String::with_capacity(html.len());
734        let mut blocks = Vec::new();
735        let mut pos = 0;
736
737        while pos < html.len() {
738            // Find next <code
739            let remaining = &html[pos..];
740            let Some(code_start) = remaining.find("<code") else {
741                result.push_str(remaining);
742                break;
743            };
744            let abs_code_start = pos + code_start;
745
746            // Find end of opening tag
747            let Some(tag_end_offset) = html[abs_code_start..].find('>') else {
748                result.push_str(remaining);
749                break;
750            };
751            let abs_tag_end = abs_code_start + tag_end_offset + 1;
752
753            // Find </code>
754            let Some(close_offset) = html[abs_tag_end..].find("</code>") else {
755                result.push_str(remaining);
756                break;
757            };
758            let abs_close = abs_tag_end + close_offset;
759
760            // Extract the inner content and replace with placeholder
761            let inner_content = &html[abs_tag_end..abs_close];
762            let placeholder = format!("__WHAT_CODE_{}__", blocks.len());
763            blocks.push(inner_content.to_string());
764
765            // Write: everything before <code...>, the opening tag, the placeholder, </code>
766            result.push_str(&html[pos..abs_tag_end]);
767            result.push_str(&placeholder);
768            pos = abs_close; // continue from </code> (will be added on next iteration or at end)
769        }
770
771        (result, blocks)
772    }
773
774    /// Process section-level auth: strip any element with `auth="..."` attribute
775    /// if the current user doesn't have access. Server-side — denied content never
776    /// reaches the client.
777    fn process_section_auth(html: &str, context: &HashMap<String, Value>) -> Result<String> {
778        // Single AND double quotes: `auth='admin'` must gate exactly like
779        // `auth="admin"` — a quote-style mismatch here would silently serve
780        // the protected content to everyone.
781        static AUTH_ATTR: LazyLock<Regex> = LazyLock::new(|| {
782            Regex::new(r#"(?i)<(\w+)\s[^>]*\bauth\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>"#).unwrap()
783        });
784
785        // Extract user info from context
786        let authenticated = context
787            .get("user")
788            .and_then(|u| u.get("authenticated"))
789            .and_then(|v| v.as_bool())
790            .unwrap_or(false);
791        let user_role = context
792            .get("user")
793            .and_then(|u| u.get("role"))
794            .and_then(|v| v.as_str())
795            .unwrap_or("");
796        let user_roles: Vec<String> = if user_role.is_empty() {
797            vec![]
798        } else {
799            vec![user_role.to_string()]
800        };
801
802        let mut output = html.to_string();
803        let mut iterations = 0;
804        const MAX_ITERATIONS: usize = 100;
805
806        loop {
807            if iterations >= MAX_ITERATIONS {
808                break;
809            }
810
811            let Some(caps) = AUTH_ATTR.captures(&output) else {
812                break;
813            };
814            iterations += 1;
815
816            let match_start = caps.get(0).unwrap().start();
817            let after_open = caps.get(0).unwrap().end();
818            let tag_name = caps[1].to_lowercase();
819            let auth_value = caps
820                .get(2)
821                .or_else(|| caps.get(3))
822                .map(|m| m.as_str())
823                .unwrap_or("")
824                .to_string();
825
826            // Find the matching closing tag (handle nesting)
827            let close_tag = format!("</{}>", tag_name);
828            let open_pattern = format!("<{}", tag_name);
829            let mut depth = 1;
830            let mut pos = after_open;
831
832            let mut found_end: Option<(usize, usize)> = None; // (inner_end, tag_end)
833            while depth > 0 && pos < output.len() {
834                if let Some(idx) = output[pos..].find('<') {
835                    let abs = pos + idx;
836                    if output[abs..].starts_with(&close_tag) {
837                        depth -= 1;
838                        if depth == 0 {
839                            found_end = Some((abs, abs + close_tag.len()));
840                            break;
841                        }
842                        pos = abs + close_tag.len();
843                    } else if output[abs..].starts_with(&open_pattern)
844                        && output
845                            .as_bytes()
846                            .get(abs + open_pattern.len())
847                            .is_some_and(|&b| b == b' ' || b == b'>' || b == b'/')
848                    {
849                        depth += 1;
850                        pos = abs + 1;
851                    } else {
852                        pos = abs + 1;
853                    }
854                } else {
855                    break;
856                }
857            }
858
859            if let Some((inner_end, tag_end)) = found_end {
860                let inner = output[after_open..inner_end].to_string();
861
862                let auth_level = crate::parser::parse_auth_level(&auth_value);
863                let has_access = match &auth_level {
864                    crate::parser::AuthLevel::All => true,
865                    crate::parser::AuthLevel::User => authenticated,
866                    crate::parser::AuthLevel::Roles(required) => {
867                        authenticated && required.iter().any(|r| user_roles.contains(r))
868                    }
869                };
870
871                let replacement = if has_access { inner } else { String::new() };
872                output = format!(
873                    "{}{}{}",
874                    &output[..match_start],
875                    replacement,
876                    &output[tag_end..]
877                );
878            } else {
879                // No closing tag found — remove opening tag to prevent infinite loop
880                output = format!("{}{}", &output[..match_start], &output[after_open..]);
881            }
882        }
883
884        Ok(output)
885    }
886
887    /// Restore <code> blocks from placeholders
888    fn restore_code_blocks(html: &str, blocks: &[String]) -> String {
889        let mut result = html.to_string();
890        for (i, block) in blocks.iter().enumerate() {
891            let placeholder = format!("__WHAT_CODE_{}__", i);
892            result = result.replacen(&placeholder, block, 1);
893        }
894        result
895    }
896
897    /// Process <include src="path" attr="value"/> tags with attribute passing
898    fn process_includes(&self, template: &str, context: &HashMap<String, Value>) -> Result<String> {
899        let mut output = template.to_string();
900        let mut iterations = 0;
901        const MAX_ITERATIONS: usize = 50; // Prevent infinite include loops
902
903        // Get base path from context (set by server)
904        let base_path = context
905            .get("_base_path")
906            .and_then(|v| v.as_str())
907            .unwrap_or(".");
908
909        while output.contains("<include") && iterations < MAX_ITERATIONS {
910            iterations += 1;
911
912            if let Some((start, end, src, attrs)) = self.find_include_tag(&output) {
913                // Dev-mode hint: suggest <what-*> component syntax instead of <include src="components/...">
914                let is_dev = context
915                    .get("_dev_mode")
916                    .and_then(|v| v.as_bool())
917                    .unwrap_or(false);
918                if is_dev && src.starts_with("components/") {
919                    let filename = src
920                        .trim_start_matches("components/")
921                        .trim_end_matches(".html");
922                    tracing::info!(
923                        "Hint: <include src=\"{}\"> can be written as <what-{}> (component syntax)",
924                        src,
925                        filename
926                    );
927                }
928
929                // Resolve the path: try base_path (project root) first, then content_dir (site/)
930                let include_path = std::path::Path::new(base_path).join(&src);
931                let include_path = if include_path.exists() {
932                    include_path
933                } else if let Some(content_dir) =
934                    context.get("_content_dir").and_then(|v| v.as_str())
935                {
936                    let alt = std::path::Path::new(content_dir).join(&src);
937                    if alt.exists() { alt } else { include_path }
938                } else {
939                    include_path
940                };
941
942                let included_content = if include_path.exists() {
943                    match std::fs::read_to_string(&include_path) {
944                        Ok(content) => {
945                            if is_dev {
946                                warn_template_lints_once(&include_path, &content);
947                            }
948                            // Parse and strip <what> block, get declared attributes with defaults
949                            let (stripped_content, declared_attrs) =
950                                self.parse_what_block(&content);
951
952                            // Create a merged context for this include
953                            let mut include_context = context.clone();
954
955                            // Process all passed attributes (not just declared ones for JSON)
956                            // JSON attributes must use strict JSON format with quoted keys:
957                            //   groups='[{"id":1,"name":"Admins"}]'
958                            // Use single quotes for the attribute to allow double quotes in JSON
959                            for (key, value) in &attrs {
960                                let resolved_value = replace_variables(value, context);
961                                // Try to parse as JSON if it looks like JSON array or object
962                                let trimmed = resolved_value.trim();
963                                if (trimmed.starts_with('[') && trimmed.ends_with(']'))
964                                    || (trimmed.starts_with('{') && trimmed.ends_with('}'))
965                                {
966                                    if let Ok(json_value) =
967                                        serde_json::from_str::<Value>(&resolved_value)
968                                    {
969                                        include_context.insert(key.clone(), json_value);
970                                    }
971                                }
972                            }
973
974                            // STRICT MODE: Only process declared attributes for string replacement
975                            // Passed attributes override defaults, but undeclared attrs are ignored
976                            let mut result = stripped_content;
977                            for (key, default_value) in &declared_attrs {
978                                // Use passed value if provided, otherwise use default
979                                let value = attrs.get(key).unwrap_or(default_value);
980                                // Resolve any variables in the attribute value
981                                let resolved_value = replace_variables(value, context);
982                                // Replace #key# in the included content
983                                result = result.replace(&format!("#{}#", key), &resolved_value);
984                            }
985
986                            // Process loops and conditionals in the included content with the merged context
987                            if let Ok(processed) =
988                                self.process_loops_html(&result, &include_context)
989                            {
990                                result = processed;
991                            }
992                            if let Ok(processed) =
993                                self.process_conditionals_html(&result, &include_context)
994                            {
995                                result = processed;
996                            }
997
998                            result
999                        }
1000                        Err(e) => {
1001                            if is_dev {
1002                                tracing::warn!(
1003                                    "Template error: failed to read include '{}': {}",
1004                                    src,
1005                                    e
1006                                );
1007                                format!(
1008                                    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>"#,
1009                                    escape_for_banner(&src),
1010                                    escape_for_banner(&e.to_string())
1011                                )
1012                            } else {
1013                                format!("<!-- include error: {} -->", e)
1014                            }
1015                        }
1016                    }
1017                } else {
1018                    if is_dev {
1019                        tracing::warn!("Template error: include not found '{}'", src);
1020                        format!(
1021                            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>"#,
1022                            escape_for_banner(&src)
1023                        )
1024                    } else {
1025                        format!("<!-- include not found: {} -->", src)
1026                    }
1027                };
1028
1029                output = format!("{}{}{}", &output[..start], included_content, &output[end..]);
1030            } else {
1031                break;
1032            }
1033        }
1034
1035        Ok(output)
1036    }
1037
1038    /// Parse a <what> block from the content and extract attribute defaults
1039    /// Returns (content_without_what_block, default_attributes)
1040    fn parse_what_block(&self, content: &str) -> (String, HashMap<String, String>) {
1041        let mut defaults = HashMap::new();
1042
1043        // Find <what> block
1044        if let Some(start) = content.find("<what>") {
1045            if let Some(end) = content.find("</what>") {
1046                // Extract the what block content
1047                let what_content = &content[start + 6..end];
1048
1049                // Parse attribute definitions
1050                // Format: attribute.name = "value" or attribute.name = value
1051                for line in what_content.lines() {
1052                    let line = line.trim();
1053                    if line.starts_with("attribute.") {
1054                        if let Some(eq_pos) = line.find('=') {
1055                            let key = line[10..eq_pos].trim(); // Skip "attribute."
1056                            let value = line[eq_pos + 1..].trim();
1057                            // Remove quotes if present (one symmetric pair only)
1058                            let value = crate::parser::strip_symmetric_quotes(value).0;
1059                            defaults.insert(key.to_string(), value.to_string());
1060                        }
1061                    }
1062                }
1063
1064                // Return content without the <what> block
1065                let before = &content[..start];
1066                let after = &content[end + 7..]; // Skip </what>
1067                return (format!("{}{}", before.trim_start(), after), defaults);
1068            }
1069        }
1070
1071        (content.to_string(), defaults)
1072    }
1073
1074    /// Find an <include src="..." attr="value"/> tag and return (start, end, src, attrs)
1075    fn find_include_tag(
1076        &self,
1077        html: &str,
1078    ) -> Option<(usize, usize, String, HashMap<String, String>)> {
1079        let start = find_tag_start(html, "<include")?;
1080        let rest = &html[start..];
1081
1082        // End of the opening tag — quote-aware, so attribute values that
1083        // contain `>` or `/>` (e.g. title="5 > 3") don't truncate the tag.
1084        // Self-closing iff the char before that `>` is `/`.
1085        let tag_close = find_outside_quotes(rest, ">")?;
1086        let tag_content = &rest[..tag_close + 1];
1087        let self_closing = rest.as_bytes()[tag_close - 1] == b'/';
1088
1089        let end_offset = if self_closing {
1090            tag_close + 1
1091        } else if let Some(close_start) = rest.find("</include>") {
1092            close_start + "</include>".len()
1093        } else {
1094            tag_close + 1
1095        };
1096
1097        // Parse all attributes
1098        let attrs = self.parse_tag_attributes(tag_content);
1099        let src = attrs.get("src")?.clone();
1100
1101        // Return attrs without 'src' for passing to the include
1102        let mut pass_attrs = attrs;
1103        pass_attrs.remove("src");
1104
1105        Some((start, start + end_offset, src, pass_attrs))
1106    }
1107
1108    /// Process <loop> tags using HTML parser
1109    fn process_loops_html(
1110        &self,
1111        template: &str,
1112        context: &HashMap<String, Value>,
1113    ) -> Result<String> {
1114        let mut output = template.to_string();
1115        let mut iterations = 0;
1116        const MAX_ITERATIONS: usize = 100;
1117
1118        // Keep processing until no more loop tags
1119        while output.contains("<loop") && iterations < MAX_ITERATIONS {
1120            iterations += 1;
1121
1122            // Find the outermost loop — inner loops are handled recursively in render_loop
1123            if let Some(info) = self.find_outermost_loop(&output) {
1124                let rendered = self.render_loop(
1125                    &info.data_attr,
1126                    &info.alias,
1127                    &info.body,
1128                    context,
1129                    info.per_page,
1130                    info.page_expr.as_deref(),
1131                );
1132                output = format!(
1133                    "{}{}{}",
1134                    &output[..info.start],
1135                    rendered,
1136                    &output[info.end..]
1137                );
1138            } else {
1139                break;
1140            }
1141        }
1142
1143        Ok(output)
1144    }
1145
1146    /// Find the outermost <loop> tag (handles nesting via depth tracking)
1147    fn find_outermost_loop(&self, html: &str) -> Option<LoopInfo> {
1148        self.find_loop_manual(html)
1149    }
1150
1151    /// Manual loop finding as fallback
1152    fn find_loop_manual(&self, html: &str) -> Option<LoopInfo> {
1153        let start_tag = "<loop";
1154        let end_tag = "</loop>";
1155
1156        let start = html.find(start_tag)?;
1157        let tag_end = html[start..].find('>')? + start + 1;
1158
1159        // Parse attributes from the opening tag
1160        let tag_content = &html[start..tag_end];
1161        let data_attr = self.extract_attr(tag_content, "data").unwrap_or_default();
1162        let alias = self
1163            .extract_attr(tag_content, "as")
1164            .unwrap_or_else(|| "item".to_string());
1165        let per_page = self
1166            .extract_attr(tag_content, "paginate")
1167            .and_then(|v| v.parse().ok());
1168        let page_expr = self.extract_attr(tag_content, "page");
1169
1170        // Find matching end tag (handle nesting)
1171        let mut depth = 1;
1172        let mut pos = tag_end;
1173        while depth > 0 && pos < html.len() {
1174            if let Some(next_start) = html[pos..].find(start_tag) {
1175                if let Some(next_end) = html[pos..].find(end_tag) {
1176                    if next_start < next_end {
1177                        depth += 1;
1178                        pos = pos + next_start + start_tag.len();
1179                    } else {
1180                        depth -= 1;
1181                        if depth == 0 {
1182                            let body = html[tag_end..pos + next_end].to_string();
1183                            let end = pos + next_end + end_tag.len();
1184                            return Some(LoopInfo {
1185                                start,
1186                                end,
1187                                data_attr,
1188                                alias,
1189                                body,
1190                                per_page,
1191                                page_expr,
1192                            });
1193                        }
1194                        pos = pos + next_end + end_tag.len();
1195                    }
1196                } else {
1197                    break;
1198                }
1199            } else if let Some(next_end) = html[pos..].find(end_tag) {
1200                depth -= 1;
1201                if depth == 0 {
1202                    let body = html[tag_end..pos + next_end].to_string();
1203                    let end = pos + next_end + end_tag.len();
1204                    return Some(LoopInfo {
1205                        start,
1206                        end,
1207                        data_attr,
1208                        alias,
1209                        body,
1210                        per_page,
1211                        page_expr,
1212                    });
1213                }
1214                pos = pos + next_end + end_tag.len();
1215            } else {
1216                break;
1217            }
1218        }
1219
1220        None
1221    }
1222
1223    /// Extract an attribute value from a tag string
1224    /// Supports both double and single quoted values
1225    fn extract_attr(&self, tag: &str, attr_name: &str) -> Option<String> {
1226        // Scan every occurrence at a word boundary: a bare `find` matched
1227        // substrings, so extracting `as` found the `as` inside `class="…"`,
1228        // failed the `=` check there, and gave up — missing the real attr.
1229        let mut from = 0;
1230        while let Some(rel) = tag[from..].find(attr_name) {
1231            let pos = from + rel;
1232            from = pos + attr_name.len();
1233            let preceded_ok = pos == 0 || tag.as_bytes()[pos - 1].is_ascii_whitespace();
1234            if !preceded_ok {
1235                continue;
1236            }
1237            let rest = tag[pos + attr_name.len()..].trim_start();
1238            let Some(rest) = rest.strip_prefix('=') else {
1239                continue;
1240            };
1241            let rest = rest.trim_start();
1242            if let Some(rest) = rest.strip_prefix('"') {
1243                if let Some(end) = rest.find('"') {
1244                    return Some(rest[..end].to_string());
1245                }
1246            } else if let Some(rest) = rest.strip_prefix('\'') {
1247                if let Some(end) = rest.find('\'') {
1248                    return Some(rest[..end].to_string());
1249                }
1250            }
1251        }
1252
1253        None
1254    }
1255
1256    /// Render a single loop with optional pagination
1257    fn render_loop(
1258        &self,
1259        data_expr: &str,
1260        alias: &str,
1261        body: &str,
1262        context: &HashMap<String, Value>,
1263        per_page: Option<usize>,
1264        page_expr: Option<&str>,
1265    ) -> String {
1266        // Extract variable from #expression#
1267        let var_name = data_expr.trim_matches('#');
1268        let parts: Vec<&str> = var_name.split('.').collect();
1269
1270        // Resolve the data
1271        let data = if let Some(first) = parts.first() {
1272            let mut current = context.get(*first);
1273            for part in parts.iter().skip(1) {
1274                current = current.and_then(|v| {
1275                    if let Value::Object(obj) = v {
1276                        obj.get(*part)
1277                    } else {
1278                        None
1279                    }
1280                });
1281            }
1282            current
1283        } else {
1284            None
1285        };
1286
1287        match data {
1288            Some(Value::Array(items)) => {
1289                let total = items.len();
1290
1291                // Apply pagination if specified
1292                let (page_items, page_num, total_pages) = if let Some(per_page) = per_page {
1293                    let per_page = per_page.max(1);
1294                    let total_pages = (total + per_page - 1) / per_page;
1295
1296                    // Resolve page expression or default to 1
1297                    let page_num = page_expr
1298                        .map(|expr| {
1299                            let resolved = replace_variables(expr, context);
1300                            resolved.parse::<usize>().unwrap_or(1)
1301                        })
1302                        .unwrap_or(1)
1303                        .max(1)
1304                        .min(total_pages.max(1));
1305
1306                    let start = (page_num - 1) * per_page;
1307                    let end = (start + per_page).min(total);
1308                    let slice: Vec<&Value> = items[start..end].iter().collect();
1309                    (slice, page_num, total_pages)
1310                } else {
1311                    let all: Vec<&Value> = items.iter().collect();
1312                    (all, 1, 1)
1313                };
1314
1315                page_items
1316                    .iter()
1317                    .enumerate()
1318                    .map(|(index, item)| {
1319                        let mut loop_context = context.clone();
1320                        loop_context.insert(alias.to_string(), (*item).clone());
1321                        loop_context.insert("index".to_string(), Value::Number(index.into()));
1322                        loop_context
1323                            .insert("index1".to_string(), Value::Number((index + 1).into()));
1324                        loop_context.insert("first".to_string(), Value::Bool(index == 0));
1325                        loop_context.insert(
1326                            "last".to_string(),
1327                            Value::Bool(index == page_items.len() - 1),
1328                        );
1329                        // Pagination context vars
1330                        loop_context.insert("loop_total".to_string(), Value::Number(total.into()));
1331                        loop_context
1332                            .insert("loop_pages".to_string(), Value::Number(total_pages.into()));
1333                        loop_context
1334                            .insert("loop_page".to_string(), Value::Number(page_num.into()));
1335
1336                        // Recursively process inner loops, then conditionals, so that
1337                        // `<if alias.field == ...>` / `<unless alias.field>` inside the
1338                        // loop body resolve against the current item (not the global
1339                        // context, where the loop alias does not exist).
1340                        let processed = self
1341                            .process_loops_html(body, &loop_context)
1342                            .unwrap_or_else(|_| body.to_string());
1343                        let processed = match self
1344                            .process_conditionals_html(&processed, &loop_context)
1345                        {
1346                            Ok(p) => p,
1347                            Err(_) => processed,
1348                        };
1349                        replace_variables(&processed, &loop_context)
1350                    })
1351                    .collect::<Vec<_>>()
1352                    .join("\n")
1353            }
1354            Some(Value::Object(obj)) => {
1355                obj.iter()
1356                    .enumerate()
1357                    .map(|(index, (key, value))| {
1358                        let mut loop_context = context.clone();
1359                        loop_context.insert("key".to_string(), Value::String(key.clone()));
1360                        loop_context.insert("value".to_string(), value.clone());
1361                        loop_context.insert(alias.to_string(), value.clone());
1362                        loop_context.insert("index".to_string(), Value::Number(index.into()));
1363
1364                        // Recursively process inner loops, then conditionals, so that
1365                        // `<if alias.field == ...>` / `<unless alias.field>` inside the
1366                        // loop body resolve against the current item (not the global
1367                        // context, where the loop alias does not exist).
1368                        let processed = self
1369                            .process_loops_html(body, &loop_context)
1370                            .unwrap_or_else(|_| body.to_string());
1371                        let processed = match self
1372                            .process_conditionals_html(&processed, &loop_context)
1373                        {
1374                            Ok(p) => p,
1375                            Err(_) => processed,
1376                        };
1377                        replace_variables(&processed, &loop_context)
1378                    })
1379                    .collect::<Vec<_>>()
1380                    .join("\n")
1381            }
1382            _ => {
1383                let is_dev = context
1384                    .get("_dev_mode")
1385                    .and_then(|v| v.as_bool())
1386                    .unwrap_or(false);
1387                if is_dev {
1388                    tracing::warn!("Template error: <loop> has no data for '{}'", var_name);
1389                    format!(
1390                        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>"#,
1391                        escape_for_banner(var_name)
1392                    )
1393                } else {
1394                    format!("<!-- loop: no data for {} -->", var_name)
1395                }
1396            }
1397        }
1398    }
1399
1400    /// Process conditional tags using HTML parser
1401    fn process_conditionals_html(
1402        &self,
1403        template: &str,
1404        context: &HashMap<String, Value>,
1405    ) -> Result<String> {
1406        let mut output = template.to_string();
1407
1408        // Process <if> tags
1409        output = self.process_if_tags(&output, context)?;
1410
1411        // Process <unless> tags
1412        output = self.process_unless_tags(&output, context)?;
1413
1414        Ok(output)
1415    }
1416
1417    /// Process <if> tags (with optional <elseif/> and <else/>)
1418    fn process_if_tags(&self, html: &str, context: &HashMap<String, Value>) -> Result<String> {
1419        let mut output = html.to_string();
1420        let mut iterations = 0;
1421        const MAX_ITERATIONS: usize = 100;
1422
1423        while output.contains("<if") && iterations < MAX_ITERATIONS {
1424            iterations += 1;
1425
1426            if let Some((start, end, branches, else_body)) = self.find_if_tag(&output) {
1427                // Evaluate branches in order, return first matching
1428                let mut result = None;
1429                for (condition, body) in branches {
1430                    if self.evaluate_condition(&condition, context) {
1431                        result = Some(body);
1432                        break;
1433                    }
1434                }
1435                let result = result.unwrap_or_else(|| else_body.unwrap_or_default());
1436                output = format!("{}{}{}", &output[..start], result, &output[end..]);
1437            } else {
1438                break;
1439            }
1440        }
1441
1442        Ok(output)
1443    }
1444
1445    /// Find an <if> tag and its components (supports <elseif/> and <else/>)
1446    /// Returns: (start, end, branches: Vec<(condition, body)>, else_body)
1447    fn find_if_tag(
1448        &self,
1449        html: &str,
1450    ) -> Option<(usize, usize, Vec<(String, String)>, Option<String>)> {
1451        let start = find_tag_start(html, "<if")?;
1452        let tag_end = find_outside_quotes(&html[start..], ">")? + start + 1;
1453
1454        // Parse condition from the opening tag
1455        let tag_content = &html[start..tag_end];
1456        let condition = self.extract_attr(tag_content, "cond").unwrap_or_else(|| {
1457            // Simplified syntax: <if active_step == 2>
1458            let inner = tag_content.strip_prefix("<if").unwrap_or("").trim();
1459            let inner = inner.strip_suffix(">").unwrap_or(inner).trim();
1460            inner.to_string()
1461        });
1462
1463        // Find the closing </if>
1464        let end_tag = "</if>";
1465        let mut depth = 1;
1466        let mut pos = tag_end;
1467
1468        while depth > 0 && pos < html.len() {
1469            let next_start = find_tag_start(&html[pos..], "<if");
1470            let next_end = html[pos..].find(end_tag);
1471
1472            match (next_start, next_end) {
1473                (Some(s), Some(e)) if s < e => {
1474                    depth += 1;
1475                    pos = pos + s + 3;
1476                }
1477                (_, Some(e)) => {
1478                    depth -= 1;
1479                    if depth == 0 {
1480                        let body = &html[tag_end..pos + e];
1481                        let (branches, else_body) = self.parse_if_body(body, &condition);
1482                        let end = pos + e + end_tag.len();
1483                        return Some((start, end, branches, else_body));
1484                    }
1485                    pos = pos + e + end_tag.len();
1486                }
1487                _ => break,
1488            }
1489        }
1490
1491        None
1492    }
1493
1494    /// Parse the body of an <if> tag into branches and optional else
1495    fn parse_if_body(
1496        &self,
1497        body: &str,
1498        initial_condition: &str,
1499    ) -> (Vec<(String, String)>, Option<String>) {
1500        let mut branches = Vec::new();
1501        let mut remaining = body.to_string();
1502        let mut current_condition = initial_condition.to_string();
1503
1504        loop {
1505            // Look for <elseif or <else at the top level (not inside nested <if>)
1506            let elseif_pos = self.find_top_level_tag(&remaining, "<elseif");
1507            let else_pos = self.find_top_level_else(&remaining);
1508
1509            match (elseif_pos, else_pos) {
1510                // Found <elseif before <else>
1511                (Some(ei_pos), Some(e_pos)) if ei_pos < e_pos => {
1512                    // Add current branch
1513                    branches.push((current_condition.clone(), remaining[..ei_pos].to_string()));
1514
1515                    // Extract elseif condition
1516                    let after_elseif = &remaining[ei_pos..];
1517                    if let Some(tag_end) = find_outside_quotes(after_elseif, "/>") {
1518                        let tag = &after_elseif[..tag_end + 2];
1519                        current_condition = self.extract_attr(tag, "cond").unwrap_or_else(|| {
1520                            let inner = tag.strip_prefix("<elseif").unwrap_or("").trim();
1521                            let inner = inner.strip_suffix("/>").unwrap_or(inner).trim();
1522                            inner.to_string()
1523                        });
1524                        remaining = after_elseif[tag_end + 2..].to_string();
1525                    } else {
1526                        break;
1527                    }
1528                }
1529                // Found <elseif only
1530                (Some(ei_pos), None) => {
1531                    branches.push((current_condition.clone(), remaining[..ei_pos].to_string()));
1532
1533                    let after_elseif = &remaining[ei_pos..];
1534                    if let Some(tag_end) = find_outside_quotes(after_elseif, "/>") {
1535                        let tag = &after_elseif[..tag_end + 2];
1536                        current_condition = self.extract_attr(tag, "cond").unwrap_or_else(|| {
1537                            let inner = tag.strip_prefix("<elseif").unwrap_or("").trim();
1538                            let inner = inner.strip_suffix("/>").unwrap_or(inner).trim();
1539                            inner.to_string()
1540                        });
1541                        remaining = after_elseif[tag_end + 2..].to_string();
1542                    } else {
1543                        break;
1544                    }
1545                }
1546                // Found <else> (with or without <elseif before it, but <else> comes first now)
1547                (_, Some(e_pos)) => {
1548                    branches.push((current_condition.clone(), remaining[..e_pos].to_string()));
1549
1550                    // Find the actual else tag to skip it
1551                    let after_else_start = &remaining[e_pos..];
1552                    let else_len = if after_else_start.starts_with("<else/>") {
1553                        7
1554                    } else if after_else_start.starts_with("<else />") {
1555                        8
1556                    } else {
1557                        7 // default
1558                    };
1559                    let else_body = remaining[e_pos + else_len..].to_string();
1560                    return (branches, Some(else_body));
1561                }
1562                // No more elseif or else
1563                (None, None) => {
1564                    branches.push((current_condition, remaining));
1565                    return (branches, None);
1566                }
1567            }
1568        }
1569
1570        branches.push((current_condition, remaining));
1571        (branches, None)
1572    }
1573
1574    /// Find top-level <elseif tag (not inside nested <if>)
1575    fn find_top_level_tag(&self, html: &str, tag: &str) -> Option<usize> {
1576        let mut depth = 0;
1577        let mut pos = 0;
1578
1579        while pos < html.len() {
1580            let next_if = find_tag_start(&html[pos..], "<if");
1581            let next_endif = html[pos..].find("</if>");
1582            let next_target = html[pos..].find(tag);
1583
1584            // Find the earliest occurrence
1585            let events: Vec<(usize, &str)> = [
1586                next_if.map(|p| (p, "if")),
1587                next_endif.map(|p| (p, "endif")),
1588                next_target.map(|p| (p, "target")),
1589            ]
1590            .into_iter()
1591            .flatten()
1592            .collect();
1593
1594            if events.is_empty() {
1595                break;
1596            }
1597
1598            let (offset, event_type) = events.into_iter().min_by_key(|(p, _)| *p)?;
1599
1600            match event_type {
1601                "if" => {
1602                    depth += 1;
1603                    pos = pos + offset + 3;
1604                }
1605                "endif" => {
1606                    depth -= 1;
1607                    pos = pos + offset + 5;
1608                }
1609                "target" => {
1610                    if depth == 0 {
1611                        return Some(pos + offset);
1612                    }
1613                    pos = pos + offset + tag.len();
1614                }
1615                _ => break,
1616            }
1617        }
1618
1619        None
1620    }
1621
1622    /// Find top-level <else/> or <else /> tag
1623    fn find_top_level_else(&self, html: &str) -> Option<usize> {
1624        let mut depth = 0;
1625        let mut pos = 0;
1626
1627        while pos < html.len() {
1628            let next_if = find_tag_start(&html[pos..], "<if");
1629            let next_endif = html[pos..].find("</if>");
1630            let next_else = html[pos..].find("<else");
1631
1632            let events: Vec<(usize, &str)> = [
1633                next_if.map(|p| (p, "if")),
1634                next_endif.map(|p| (p, "endif")),
1635                next_else.map(|p| (p, "else")),
1636            ]
1637            .into_iter()
1638            .flatten()
1639            .collect();
1640
1641            if events.is_empty() {
1642                break;
1643            }
1644
1645            let (offset, event_type) = events.into_iter().min_by_key(|(p, _)| *p)?;
1646
1647            match event_type {
1648                "if" => {
1649                    depth += 1;
1650                    pos = pos + offset + 3;
1651                }
1652                "endif" => {
1653                    depth -= 1;
1654                    pos = pos + offset + 5;
1655                }
1656                "else" => {
1657                    if depth == 0 {
1658                        // Make sure it's <else/> or <else /> not <elseif
1659                        let after = &html[pos + offset..];
1660                        if after.starts_with("<else/>") || after.starts_with("<else />") {
1661                            return Some(pos + offset);
1662                        }
1663                    }
1664                    pos = pos + offset + 5;
1665                }
1666                _ => break,
1667            }
1668        }
1669
1670        None
1671    }
1672
1673    /// Process <unless> tags
1674    fn process_unless_tags(&self, html: &str, context: &HashMap<String, Value>) -> Result<String> {
1675        let mut output = html.to_string();
1676        let mut iterations = 0;
1677        const MAX_ITERATIONS: usize = 100;
1678
1679        while output.contains("<unless") && iterations < MAX_ITERATIONS {
1680            iterations += 1;
1681
1682            if let Some((start, end, condition, body)) = self.find_unless_tag(&output) {
1683                let result = if !self.evaluate_condition(&condition, context) {
1684                    body
1685                } else {
1686                    String::new()
1687                };
1688                output = format!("{}{}{}", &output[..start], result, &output[end..]);
1689            } else {
1690                break;
1691            }
1692        }
1693
1694        Ok(output)
1695    }
1696
1697    /// Find an <unless> tag
1698    fn find_unless_tag(&self, html: &str) -> Option<(usize, usize, String, String)> {
1699        let start = find_tag_start(html, "<unless")?;
1700        let tag_end = find_outside_quotes(&html[start..], ">")? + start + 1;
1701
1702        let tag_content = &html[start..tag_end];
1703        let condition = self.extract_attr(tag_content, "cond").unwrap_or_else(|| {
1704            let inner = tag_content.strip_prefix("<unless").unwrap_or("").trim();
1705            let inner = inner.strip_suffix(">").unwrap_or(inner).trim();
1706            inner.to_string()
1707        });
1708
1709        let end_tag = "</unless>";
1710        if let Some(end_pos) = html[tag_end..].find(end_tag) {
1711            let body = html[tag_end..tag_end + end_pos].to_string();
1712            let end = tag_end + end_pos + end_tag.len();
1713            return Some((start, end, condition, body));
1714        }
1715
1716        None
1717    }
1718
1719    /// Evaluate a condition expression
1720    fn evaluate_condition(&self, condition: &str, context: &HashMap<String, Value>) -> bool {
1721        let condition = condition.trim();
1722        if condition.is_empty() {
1723            return false;
1724        }
1725
1726        // Boolean combinators: split on top-level `or` first, then `and`,
1727        // so `and` binds tighter (`a or b and c` == `a or (b and c)`).
1728        // Each part recurses, so leaf logic below stays untouched and
1729        // any()/all() give short-circuit evaluation.
1730        let or_parts = split_top_level_bool(condition, "or");
1731        if or_parts.len() > 1 {
1732            return or_parts.iter().any(|p| self.evaluate_condition(p, context));
1733        }
1734        let and_parts = split_top_level_bool(condition, "and");
1735        if and_parts.len() > 1 {
1736            return and_parts.iter().all(|p| self.evaluate_condition(p, context));
1737        }
1738
1739        // Auto-wrap bare variable names if condition uses simplified syntax (no #)
1740        let condition = if !condition.contains('#') {
1741            wrap_bare_variables(condition)
1742        } else {
1743            condition.to_string()
1744        };
1745        let condition = condition.trim();
1746
1747        // Map keyword operators to symbols (for simplified syntax) —
1748        // quote-aware, so operands containing " gt " etc. stay intact
1749        let condition = map_keyword_operators(condition);
1750        let condition = condition.trim();
1751
1752        // Handle negation: !#variable#
1753        if condition.starts_with('!') {
1754            return !self.evaluate_condition(&condition[1..], context);
1755        }
1756
1757        // Handle comparison operators (check multi-char first to avoid partial matches)
1758        // Order: >=, <=, !=, ==, >, <, contains
1759        for (op, cmp_fn) in &[
1760            (">=", CompareOp::Gte),
1761            ("<=", CompareOp::Lte),
1762            ("!=", CompareOp::Ne),
1763            ("==", CompareOp::Eq),
1764            (">", CompareOp::Gt),
1765            ("<", CompareOp::Lt),
1766        ] {
1767            // Quote-aware: an operator character inside a quoted operand
1768            // (e.g. `name == "a > b"`) must not split the condition
1769            if let Some(idx) = find_outside_quotes(condition, op) {
1770                // Strip one symmetric pair of quotes (single OR double) from
1771                // BOTH sides so quoted operands (`"#a#" == "#b#"`, `'admin'`)
1772                // compare symmetrically. Single-quoted literals previously
1773                // kept their quotes and the comparison never matched.
1774                let (left_raw, left_quoted) =
1775                    crate::parser::strip_symmetric_quotes(condition[..idx].trim());
1776                let (right_raw, right_quoted) =
1777                    crate::parser::strip_symmetric_quotes(condition[idx + op.len()..].trim());
1778                // Variables resolve HTML-escaped (output semantics); compare
1779                // the unescaped text so `company == "Ben & Jerry"` and names
1780                // like O'Brien match their literals.
1781                let left =
1782                    crate::parser::html_unescape(&replace_variables(left_raw, context));
1783                let right =
1784                    crate::parser::html_unescape(&replace_variables(right_raw, context));
1785
1786                // Try arithmetic evaluation on both sides
1787                let left_num = crate::parser::evaluate_arithmetic(&left)
1788                    .or_else(|| left.parse::<f64>().ok());
1789                let right_num = crate::parser::evaluate_arithmetic(&right)
1790                    .or_else(|| right.parse::<f64>().ok());
1791
1792                // Equality is numeric when both sides are numbers and neither
1793                // was quoted (10 == 10.0 matches, like gt/lt already do).
1794                // Quoting forces string semantics: `zip == "01234"` compares
1795                // text, consistent with quoting in <what> blocks.
1796                let force_string = left_quoted || right_quoted;
1797
1798                return match cmp_fn {
1799                    CompareOp::Eq => match (left_num, right_num) {
1800                        (Some(l), Some(r)) if !force_string => l == r,
1801                        _ => left == right,
1802                    },
1803                    CompareOp::Ne => match (left_num, right_num) {
1804                        (Some(l), Some(r)) if !force_string => l != r,
1805                        _ => left != right,
1806                    },
1807                    CompareOp::Gt => match (left_num, right_num) {
1808                        (Some(l), Some(r)) => l > r,
1809                        _ => left > right,
1810                    },
1811                    CompareOp::Lt => match (left_num, right_num) {
1812                        (Some(l), Some(r)) => l < r,
1813                        _ => left < right,
1814                    },
1815                    CompareOp::Gte => match (left_num, right_num) {
1816                        (Some(l), Some(r)) => l >= r,
1817                        _ => left >= right,
1818                    },
1819                    CompareOp::Lte => match (left_num, right_num) {
1820                        (Some(l), Some(r)) => l <= r,
1821                        _ => left <= right,
1822                    },
1823                };
1824            }
1825        }
1826
1827        // Handle contains: #variable# contains "value" (quote-aware, and the
1828        // literal may use single or double quotes)
1829        if let Some(idx) = find_outside_quotes(condition, " contains ") {
1830            let left = crate::parser::html_unescape(&replace_variables(
1831                condition[..idx].trim(),
1832                context,
1833            ));
1834            let right_raw = condition[idx + " contains ".len()..].trim();
1835            let right = crate::parser::strip_symmetric_quotes(right_raw).0;
1836            return left.contains(right);
1837        }
1838
1839        // Exact variable reference: use the underlying JSON value rather than the
1840        // rendered string so empty arrays/objects remain falsey in <if>/<unless>.
1841        if let Some(var_name) = condition
1842            .strip_prefix('#')
1843            .and_then(|s| s.strip_suffix('#'))
1844            .filter(|s| !s.contains('#'))
1845        {
1846            return Self::is_truthy(self.lookup_context_value(var_name, context));
1847        }
1848
1849        // Simple truthy check: #variable#
1850        let resolved = replace_variables(condition, context);
1851        // If it resolved to something different, check truthiness of the resolved value
1852        if resolved != condition.to_string() {
1853            return match resolved.as_str() {
1854                "" | "false" | "null" | "0" => false,
1855                _ => true,
1856            };
1857        }
1858
1859        // Direct context lookup for truthy check
1860        let var_name = condition.trim_matches('#');
1861        Self::is_truthy(self.lookup_context_value(var_name, context))
1862    }
1863
1864    fn lookup_context_value<'a>(
1865        &self,
1866        var_name: &str,
1867        context: &'a HashMap<String, Value>,
1868    ) -> Option<&'a Value> {
1869        let parts: Vec<&str> = var_name.split('.').collect();
1870        let first = parts.first()?;
1871        let mut current = context.get(*first);
1872        for part in parts.iter().skip(1) {
1873            current = current.and_then(|v| match v {
1874                Value::Object(obj) => obj.get(*part),
1875                _ => None,
1876            });
1877        }
1878        current
1879    }
1880
1881    fn is_truthy(value: Option<&Value>) -> bool {
1882        match value {
1883            Some(Value::Bool(b)) => *b,
1884            Some(Value::Null) => false,
1885            Some(Value::String(s)) => !s.is_empty(),
1886            Some(Value::Number(n)) => n.as_f64().map(|v| v != 0.0).unwrap_or(true),
1887            Some(Value::Array(arr)) => !arr.is_empty(),
1888            Some(Value::Object(obj)) => !obj.is_empty(),
1889            None => false,
1890        }
1891    }
1892
1893    fn component_attr_value(value: &str) -> Value {
1894        let trimmed = value.trim();
1895        if (trimmed.starts_with('[') && trimmed.ends_with(']'))
1896            || (trimmed.starts_with('{') && trimmed.ends_with('}'))
1897        {
1898            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
1899                return parsed;
1900            }
1901        }
1902
1903        Value::String(value.to_string())
1904    }
1905
1906    fn build_component_context(
1907        component: &Component,
1908        attrs: &HashMap<String, String>,
1909        context: &HashMap<String, Value>,
1910    ) -> HashMap<String, Value> {
1911        let mut component_context = context.clone();
1912
1913        for prop_name in &component.props {
1914            if !attrs.contains_key(prop_name) && !component.defaults.contains_key(prop_name) {
1915                component_context.insert(prop_name.clone(), Value::String(String::new()));
1916            }
1917        }
1918
1919        for (key, value) in &component.defaults {
1920            if !attrs.contains_key(key) {
1921                component_context.insert(key.clone(), Value::String(value.clone()));
1922            }
1923        }
1924
1925        for (key, value) in attrs {
1926            // Resolve #var# references in attribute values before passing to component
1927            let resolved = replace_variables(value, context);
1928            component_context.insert(key.clone(), Self::component_attr_value(&resolved));
1929        }
1930
1931        component_context
1932    }
1933
1934    fn apply_component_slots(mut rendered: String, children: Option<&str>) -> String {
1935        if let Some(children) = children {
1936            rendered = rendered.replace("<slot/>", children);
1937            rendered = rendered.replace("<slot />", children);
1938        } else {
1939            rendered = rendered.replace("<slot/>", "");
1940            rendered = rendered.replace("<slot />", "");
1941        }
1942
1943        rendered
1944    }
1945
1946    /// Render pagination HTML programmatically
1947    fn render_pagination(
1948        attrs: &HashMap<String, String>,
1949        context: &HashMap<String, Value>,
1950    ) -> String {
1951        let total: usize = attrs
1952            .get("total")
1953            .map(|v| replace_variables(v, context))
1954            .and_then(|v| v.parse().ok())
1955            .unwrap_or(0);
1956        let per_page: usize = attrs
1957            .get("per-page")
1958            .map(|v| replace_variables(v, context))
1959            .and_then(|v| v.parse().ok())
1960            .unwrap_or(10)
1961            .max(1);
1962        let current: usize = attrs
1963            .get("current")
1964            .map(|v| replace_variables(v, context))
1965            .and_then(|v| v.parse().ok())
1966            .unwrap_or(1)
1967            .max(1);
1968        let base_url = attrs
1969            .get("base-url")
1970            .map(|v| replace_variables(v, context))
1971            .unwrap_or_else(|| "/".to_string());
1972        let param = attrs
1973            .get("param")
1974            .cloned()
1975            .unwrap_or_else(|| "page".to_string());
1976
1977        let total_pages = if total == 0 {
1978            0
1979        } else {
1980            (total + per_page - 1) / per_page
1981        };
1982        if total_pages <= 1 {
1983            return String::new();
1984        }
1985
1986        let current = current.min(total_pages);
1987        let page_numbers = Self::compute_page_numbers(current, total_pages);
1988
1989        let mut html = String::from(r#"<nav class="what-pagination" aria-label="Pagination"><ul>"#);
1990
1991        // Previous button
1992        if current > 1 {
1993            html.push_str(&format!(
1994                r#"<li><a href="{}?{}={}" class="what-pagination-prev" aria-label="Previous page">&laquo;</a></li>"#,
1995                base_url, param, current - 1
1996            ));
1997        } else {
1998            html.push_str(r#"<li><span class="what-pagination-prev what-pagination-disabled" aria-disabled="true">&laquo;</span></li>"#);
1999        }
2000
2001        // Page numbers with ellipsis
2002        for &num in &page_numbers {
2003            if num == 0 {
2004                // Ellipsis
2005                html.push_str(r#"<li><span class="what-pagination-ellipsis">&hellip;</span></li>"#);
2006            } else if num == current {
2007                html.push_str(&format!(
2008                    r#"<li><span class="what-pagination-active" aria-current="page">{}</span></li>"#,
2009                    num
2010                ));
2011            } else {
2012                html.push_str(&format!(
2013                    r#"<li><a href="{}?{}={}">{}</a></li>"#,
2014                    base_url, param, num, num
2015                ));
2016            }
2017        }
2018
2019        // Next button
2020        if current < total_pages {
2021            html.push_str(&format!(
2022                r#"<li><a href="{}?{}={}" class="what-pagination-next" aria-label="Next page">&raquo;</a></li>"#,
2023                base_url, param, current + 1
2024            ));
2025        } else {
2026            html.push_str(r#"<li><span class="what-pagination-next what-pagination-disabled" aria-disabled="true">&raquo;</span></li>"#);
2027        }
2028
2029        html.push_str("</ul></nav>");
2030        html
2031    }
2032
2033    /// Compute page numbers for display: [1, ..., 4, 5, 6, ..., 10]
2034    fn compute_page_numbers(current: usize, total: usize) -> Vec<usize> {
2035        if total <= 7 {
2036            return (1..=total).collect();
2037        }
2038
2039        let mut pages = Vec::new();
2040        pages.push(1);
2041
2042        if current > 3 {
2043            pages.push(0); // ellipsis
2044        }
2045
2046        let range_start = if current <= 3 { 2 } else { current - 1 };
2047        let range_end = if current >= total - 2 {
2048            total - 1
2049        } else {
2050            current + 1
2051        };
2052
2053        for p in range_start..=range_end {
2054            pages.push(p);
2055        }
2056
2057        if current < total - 2 {
2058            pages.push(0); // ellipsis
2059        }
2060
2061        pages.push(total);
2062        pages
2063    }
2064
2065    /// Render a Cloudflare Turnstile widget
2066    /// Usage: <what-turnstile/> or <what-turnstile theme="dark"/>
2067    /// Requires [cloudflare] turnstile_site_key in what.toml
2068    fn render_turnstile(
2069        attrs: &HashMap<String, String>,
2070        context: &HashMap<String, Value>,
2071    ) -> String {
2072        // Get site key from context (injected by server from config)
2073        let site_key = context
2074            .get("_turnstile_site_key")
2075            .and_then(|v| v.as_str())
2076            .unwrap_or("");
2077
2078        if site_key.is_empty() {
2079            let is_dev = context
2080                .get("_dev_mode")
2081                .and_then(|v| v.as_bool())
2082                .unwrap_or(false);
2083            if is_dev {
2084                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();
2085            }
2086            return String::new();
2087        }
2088
2089        let theme = attrs.get("theme").map(|s| s.as_str()).unwrap_or("auto");
2090        let size = attrs.get("size").map(|s| s.as_str()).unwrap_or("normal");
2091
2092        format!(
2093            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>"#,
2094            site_key, theme, size
2095        )
2096    }
2097
2098    /// Copy attrs not consumed by a built-in tag through to the output,
2099    /// sorted for deterministic rendering.
2100    fn push_extra_attrs(out: &mut String, attrs: &HashMap<String, String>, handled: &[&str]) {
2101        let mut extras: Vec<(&String, &String)> = attrs
2102            .iter()
2103            .filter(|(k, _)| !handled.contains(&k.as_str()))
2104            .collect();
2105        extras.sort();
2106        for (k, v) in extras {
2107            push_attr(out, k, v);
2108        }
2109    }
2110
2111    /// Render `<what-fetch>` — a declarative fetch-and-inject region.
2112    /// Usage: <what-fetch url="/w-partial/stats" poll="5s">fallback</what-fetch>
2113    ///        <what-fetch url="/w-partial/comments" when="visible">Loading…</what-fetch>
2114    /// Expands to a container carrying w-get/w-post + w-trigger; children are
2115    /// the server-rendered initial content. `#var#` in attributes resolves in
2116    /// the later variable-replacement stage.
2117    fn render_what_fetch(
2118        attrs: &HashMap<String, String>,
2119        children: &str,
2120        context: &HashMap<String, Value>,
2121    ) -> String {
2122        let is_dev = context
2123            .get("_dev_mode")
2124            .and_then(|v| v.as_bool())
2125            .unwrap_or(false);
2126
2127        let url = match attrs.get("url").filter(|u| !u.is_empty()) {
2128            Some(u) => u,
2129            None => {
2130                return if is_dev {
2131                    dev_banner("<what-fetch> requires a url attribute")
2132                } else {
2133                    String::new()
2134                };
2135            }
2136        };
2137
2138        let method = attrs
2139            .get("method")
2140            .map(|s| s.to_lowercase())
2141            .unwrap_or_else(|| "get".to_string());
2142        let fetch_attr = if method == "post" { "w-post" } else { "w-get" };
2143
2144        let when = attrs.get("when").map(|s| s.as_str()).unwrap_or("load");
2145        let poll = attrs.get("poll");
2146        if let Some(p) = poll {
2147            if !POLL_INTERVAL_RE.is_match(p) {
2148                if is_dev {
2149                    return dev_banner(&format!(
2150                        "<what-fetch> invalid poll=\"{}\" — use e.g. 500ms, 5s, 2m, 1h or bare seconds",
2151                        p
2152                    ));
2153                }
2154            }
2155        }
2156        let mut triggers: Vec<String> = Vec::new();
2157        match when {
2158            "load" => triggers.push("load".to_string()),
2159            "visible" => triggers.push("revealed".to_string()),
2160            // click is the client-side default for w-get, but the token is
2161            // needed when combined with poll (otherwise clicks are gated off)
2162            "click" => {
2163                if poll.is_some() {
2164                    triggers.push("click".to_string());
2165                }
2166            }
2167            other => {
2168                if is_dev {
2169                    return dev_banner(&format!(
2170                        "<what-fetch> unknown when=\"{}\" — expected load, visible, or click",
2171                        other
2172                    ));
2173                }
2174                triggers.push("load".to_string());
2175            }
2176        }
2177        if let Some(p) = poll {
2178            if POLL_INTERVAL_RE.is_match(p) {
2179                triggers.push(format!("poll {}", p));
2180            }
2181        }
2182
2183        let wrapper = attrs.get("as").map(|s| s.as_str()).unwrap_or("div");
2184        let mut class = String::from("w-fetch");
2185        if let Some(c) = attrs.get("class").filter(|c| !c.is_empty()) {
2186            class.push(' ');
2187            class.push_str(c);
2188        }
2189
2190        let mut out = format!("<{}", wrapper);
2191        push_attr(&mut out, "class", &class);
2192        push_attr(&mut out, fetch_attr, url);
2193        if !triggers.is_empty() {
2194            push_attr(&mut out, "w-trigger", &triggers.join(", "));
2195        }
2196        for (attr, w_attr) in [
2197            ("target", "w-target"),
2198            ("swap", "w-swap"),
2199            ("params", "w-params"),
2200            ("include", "w-include"),
2201            ("loading", "w-loading"),
2202            ("confirm", "w-confirm"),
2203        ] {
2204            if let Some(v) = attrs.get(attr) {
2205                push_attr(&mut out, w_attr, v);
2206            }
2207        }
2208        Self::push_extra_attrs(
2209            &mut out,
2210            attrs,
2211            &[
2212                "url", "when", "poll", "method", "target", "swap", "params", "include",
2213                "loading", "confirm", "as", "class",
2214            ],
2215        );
2216        out.push('>');
2217        out.push_str(children);
2218        out.push_str(&format!("</{}>", wrapper));
2219        out
2220    }
2221
2222    /// Render `<what-clipboard>` — a declarative copy-to-clipboard button.
2223    /// Usage: <what-clipboard value="text to copy">Copy</what-clipboard>
2224    ///        <what-clipboard from="#room-link" copied-label="copied!">copy</what-clipboard>
2225    fn render_what_clipboard(
2226        attrs: &HashMap<String, String>,
2227        children: &str,
2228        context: &HashMap<String, Value>,
2229    ) -> String {
2230        let is_dev = context
2231            .get("_dev_mode")
2232            .and_then(|v| v.as_bool())
2233            .unwrap_or(false);
2234
2235        let value = attrs.get("value");
2236        let from = attrs.get("from");
2237        if value.is_none() && from.is_none() {
2238            return if is_dev {
2239                dev_banner("<what-clipboard> requires value=\"text\" or from=\"selector\"")
2240            } else {
2241                String::new()
2242            };
2243        }
2244
2245        let mut out = String::from("<button type=\"button\"");
2246        // Emit only the provided source attribute — an empty w-clipboard=""
2247        // would still match the client selector and copy the empty string
2248        if let Some(v) = value {
2249            push_attr(&mut out, "w-clipboard", v);
2250        } else if let Some(f) = from {
2251            push_attr(&mut out, "w-clipboard-from", f);
2252        }
2253        if let Some(l) = attrs.get("copied-label") {
2254            push_attr(&mut out, "w-copied-label", l);
2255        }
2256        Self::push_extra_attrs(&mut out, attrs, &["value", "from", "copied-label"]);
2257        out.push('>');
2258        out.push_str(if children.trim().is_empty() {
2259            "Copy"
2260        } else {
2261            children
2262        });
2263        out.push_str("</button>");
2264        out
2265    }
2266
2267    /// Render `<what-theme-toggle>` — a declarative dark/light theme button.
2268    /// Children replace the default sun/moon icons; state persists via the
2269    /// w-theme localStorage key and is restored pre-paint by the injected
2270    /// head snippet.
2271    fn render_what_theme_toggle(attrs: &HashMap<String, String>, children: &str) -> String {
2272        let mut class = String::from("w-theme-toggle");
2273        if let Some(c) = attrs.get("class").filter(|c| !c.is_empty()) {
2274            class.push(' ');
2275            class.push_str(c);
2276        }
2277
2278        let mut out = String::from("<button type=\"button\" w-theme-toggle");
2279        push_attr(&mut out, "class", &class);
2280        if !attrs.contains_key("aria-label") {
2281            push_attr(&mut out, "aria-label", "Toggle theme");
2282        }
2283        Self::push_extra_attrs(&mut out, attrs, &["class"]);
2284        out.push('>');
2285        if children.trim().is_empty() {
2286            out.push_str(
2287                r#"<span class="w-theme-icon-light">☀</span><span class="w-theme-icon-dark">☾</span>"#,
2288            );
2289        } else {
2290            out.push_str(children);
2291        }
2292        out.push_str("</button>");
2293        out
2294    }
2295
2296    /// Process custom components by finding them manually (scraper normalizes HTML which breaks string matching)
2297    fn process_custom_tags_html(
2298        &self,
2299        template: &str,
2300        context: &HashMap<String, Value>,
2301    ) -> Result<String> {
2302        let mut output = template.to_string();
2303        let mut iterations = 0;
2304        const MAX_ITERATIONS: usize = 100;
2305
2306        let component_names = self.get_component_names();
2307
2308        loop {
2309            let mut found_any = false;
2310
2311            // Special-case: <what-pagination> is rendered programmatically
2312            if let Some((start, end, attrs, _children)) =
2313                self.find_custom_tag(&output, "what-pagination")
2314            {
2315                let rendered = Self::render_pagination(&attrs, context);
2316                output = format!("{}{}{}", &output[..start], rendered, &output[end..]);
2317                iterations += 1;
2318                if iterations >= MAX_ITERATIONS {
2319                    break;
2320                }
2321                continue;
2322            }
2323
2324            // Special-case: <what-turnstile> renders Cloudflare Turnstile widget
2325            if let Some((start, end, attrs, _children)) =
2326                self.find_custom_tag(&output, "what-turnstile")
2327            {
2328                let rendered = Self::render_turnstile(&attrs, context);
2329                output = format!("{}{}{}", &output[..start], rendered, &output[end..]);
2330                iterations += 1;
2331                if iterations >= MAX_ITERATIONS {
2332                    break;
2333                }
2334                continue;
2335            }
2336
2337            // Special-case: declarative built-ins (fetch region, clipboard,
2338            // theme toggle) — server-expanded into w-* attribute form
2339            let mut handled_builtin = false;
2340            for tag in ["what-fetch", "what-clipboard", "what-theme-toggle"] {
2341                if let Some((start, end, attrs, children)) = self.find_custom_tag(&output, tag) {
2342                    let rendered = match tag {
2343                        "what-fetch" => Self::render_what_fetch(&attrs, &children, context),
2344                        "what-clipboard" => Self::render_what_clipboard(&attrs, &children, context),
2345                        _ => Self::render_what_theme_toggle(&attrs, &children),
2346                    };
2347                    output = format!("{}{}{}", &output[..start], rendered, &output[end..]);
2348                    handled_builtin = true;
2349                    break;
2350                }
2351            }
2352            if handled_builtin {
2353                iterations += 1;
2354                if iterations >= MAX_ITERATIONS {
2355                    break;
2356                }
2357                continue;
2358            }
2359
2360            // Check each registered component
2361            for component_name in &component_names {
2362                if let Some((start, end, attrs, children)) =
2363                    self.find_custom_tag(&output, component_name)
2364                {
2365                    if let Some(component_def) = self.components.get(component_name) {
2366                        let children = if children.is_empty() {
2367                            None
2368                        } else {
2369                            Some(children.as_str())
2370                        };
2371                        let component_context =
2372                            Self::build_component_context(&component_def, &attrs, context);
2373
2374                        // Resolve component control-flow before variable replacement so
2375                        // `#prop#` keeps its JSON/string semantics.
2376                        let mut rendered = component_def.template.clone();
2377                        if rendered.contains("<loop") {
2378                            if let Ok(processed) =
2379                                self.process_loops_html(&rendered, &component_context)
2380                            {
2381                                rendered = processed;
2382                            }
2383                        }
2384                        if rendered.contains("<if") {
2385                            if let Ok(processed) =
2386                                self.process_conditionals_html(&rendered, &component_context)
2387                            {
2388                                rendered = processed;
2389                            }
2390                        }
2391                        rendered = replace_variables(&rendered, &component_context);
2392                        rendered = Self::apply_component_slots(rendered, children);
2393
2394                        // Replace in output
2395                        output = format!("{}{}{}", &output[..start], rendered, &output[end..]);
2396                        found_any = true;
2397                        break; // Start over after modification
2398                    }
2399                }
2400            }
2401
2402            iterations += 1;
2403            if !found_any || iterations >= MAX_ITERATIONS {
2404                break;
2405            }
2406        }
2407
2408        // Dev-mode: unresolved <what-*> tags get a visible inline banner (the
2409        // same treatment as a missing <include>) — a log line alone leaves
2410        // nothing in the browser and the typo'd tag silently renders nothing.
2411        let is_dev = context
2412            .get("_dev_mode")
2413            .and_then(|v| v.as_bool())
2414            .unwrap_or(false);
2415        if is_dev {
2416            let mut unresolved: Vec<(usize, String)> = Vec::new();
2417            let mut search_from = 0;
2418            while let Some(pos) = output[search_from..].find("<what-") {
2419                let abs_pos = search_from + pos;
2420                let rest = &output[abs_pos..];
2421                if let Some(end) = rest.find('>') {
2422                    let tag = &rest[1..end].split_whitespace().next().unwrap_or("");
2423                    // Skip known built-in tags and closing tags
2424                    if !tag.starts_with('/')
2425                        && !BUILTIN_TAGS.contains(&tag.trim_end_matches('/'))
2426                    {
2427                        let tag_name = tag.trim_end_matches('/');
2428                        if !component_names
2429                            .iter()
2430                            .any(|c| format!("what-{}", c) == tag_name || *c == tag_name)
2431                        {
2432                            tracing::warn!("Template warning: unresolved component <{}>", tag_name);
2433                            unresolved.push((abs_pos, tag_name.to_string()));
2434                        }
2435                    }
2436                    search_from = abs_pos + end + 1;
2437                } else {
2438                    break;
2439                }
2440            }
2441            // Insert banners back-to-front so recorded positions stay valid
2442            for (pos, tag_name) in unresolved.into_iter().rev() {
2443                let banner = format!(
2444                    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>&lt;{}&gt;</b> — no matching file in components/</div>"#,
2445                    escape_for_banner(&tag_name)
2446                );
2447                output.insert_str(pos, &banner);
2448            }
2449        }
2450
2451        Ok(output)
2452    }
2453
2454    /// Find a custom tag in HTML and return (start, end, attrs, children)
2455    fn find_custom_tag(
2456        &self,
2457        html: &str,
2458        tag_name: &str,
2459    ) -> Option<(usize, usize, HashMap<String, String>, String)> {
2460        // Find opening tag at a word boundary — a bare `find` would let
2461        // `<what-card` match inside `<what-card-header`
2462        let open_pattern = format!("<{}", tag_name);
2463        let start = find_tag_start(html, &open_pattern)?;
2464
2465        // Find end of opening tag — quote-aware, so attribute values that
2466        // contain `>` (e.g. label="a > b") don't truncate the tag
2467        let tag_start_rest = &html[start..];
2468        let open_tag_end = find_outside_quotes(tag_start_rest, ">")? + start + 1;
2469
2470        // Parse attributes from opening tag
2471        let open_tag = &html[start..open_tag_end];
2472        let attrs = self.parse_tag_attributes(open_tag);
2473
2474        // Check for self-closing tag
2475        if open_tag.ends_with("/>") {
2476            return Some((start, open_tag_end, attrs, String::new()));
2477        }
2478
2479        // Find closing tag - need to handle nesting
2480        let close_tag = format!("</{}>", tag_name);
2481        let mut depth = 1;
2482        let mut pos = open_tag_end;
2483
2484        while depth > 0 && pos < html.len() {
2485            let rest = &html[pos..];
2486
2487            // Find next occurrence of open or close tag
2488            let next_open = rest.find(&open_pattern);
2489            let next_close = rest.find(&close_tag);
2490
2491            match (next_open, next_close) {
2492                (Some(o), Some(c)) if o < c => {
2493                    // Found another opening tag first - check if it's a real tag (has > after)
2494                    let after_open = &rest[o..];
2495                    if after_open
2496                        .chars()
2497                        .skip(open_pattern.len())
2498                        .next()
2499                        .map(|c| c == ' ' || c == '>' || c == '/')
2500                        .unwrap_or(false)
2501                    {
2502                        depth += 1;
2503                    }
2504                    pos = pos + o + open_pattern.len();
2505                }
2506                (_, Some(c)) => {
2507                    depth -= 1;
2508                    if depth == 0 {
2509                        let children = html[open_tag_end..pos + c].to_string();
2510                        let end = pos + c + close_tag.len();
2511                        return Some((start, end, attrs, children));
2512                    }
2513                    pos = pos + c + close_tag.len();
2514                }
2515                _ => break,
2516            }
2517        }
2518
2519        None
2520    }
2521
2522    /// Parse attributes from an opening tag string like `<page title="Test" class="foo">`
2523    fn parse_tag_attributes(&self, tag: &str) -> HashMap<String, String> {
2524        let mut attrs = HashMap::new();
2525
2526        // Match attribute="value" (double quotes, can contain single quotes)
2527        for cap in DOUBLE_QUOTE_ATTR_RE.captures_iter(tag) {
2528            if let (Some(name), Some(value)) = (cap.get(1), cap.get(2)) {
2529                attrs.insert(name.as_str().to_string(), value.as_str().to_string());
2530            }
2531        }
2532
2533        // Match attribute='value' (single quotes, can contain double quotes)
2534        for cap in SINGLE_QUOTE_ATTR_RE.captures_iter(tag) {
2535            if let (Some(name), Some(value)) = (cap.get(1), cap.get(2)) {
2536                attrs.insert(name.as_str().to_string(), value.as_str().to_string());
2537            }
2538        }
2539
2540        attrs
2541    }
2542
2543    /// Get list of registered component names
2544    fn get_component_names(&self) -> Vec<String> {
2545        self.components.component_names()
2546    }
2547}
2548
2549#[cfg(test)]
2550mod tests {
2551    use super::*;
2552    use crate::components::ComponentRegistry;
2553    use scraper::{Html, Selector};
2554    use serde_json::json;
2555
2556    fn make_engine() -> RenderEngine {
2557        let mut components = ComponentRegistry::new();
2558        components.register_builtins();
2559        RenderEngine::new(components)
2560    }
2561
2562    #[tokio::test]
2563    async fn test_loop_array() {
2564        let engine = make_engine();
2565        let mut context = HashMap::new();
2566        context.insert(
2567            "users".to_string(),
2568            json!([
2569                {"name": "Alice"},
2570                {"name": "Bob"}
2571            ]),
2572        );
2573
2574        let template = r##"<loop data="#users#"><li>#item.name#</li></loop>"##;
2575        let result = engine.render(template, &context).await.unwrap();
2576
2577        assert!(result.contains("<li>Alice</li>"));
2578        assert!(result.contains("<li>Bob</li>"));
2579    }
2580
2581    #[tokio::test]
2582    async fn test_loop_with_alias() {
2583        let engine = make_engine();
2584        let mut context = HashMap::new();
2585        context.insert(
2586            "posts".to_string(),
2587            json!([
2588                {"title": "Post 1"},
2589                {"title": "Post 2"}
2590            ]),
2591        );
2592
2593        let template = r##"<loop data="#posts#" as="post"><h2>#post.title#</h2></loop>"##;
2594        let result = engine.render(template, &context).await.unwrap();
2595
2596        assert!(result.contains("<h2>Post 1</h2>"));
2597        assert!(result.contains("<h2>Post 2</h2>"));
2598    }
2599
2600    #[tokio::test]
2601    async fn test_if_condition() {
2602        let engine = make_engine();
2603        let mut context = HashMap::new();
2604        context.insert("logged_in".to_string(), json!(true));
2605
2606        let template = r##"<if cond="#logged_in#">Welcome!</if>"##;
2607        let result = engine.render(template, &context).await.unwrap();
2608
2609        assert_eq!(result.trim(), "Welcome!");
2610    }
2611
2612    #[tokio::test]
2613    async fn test_if_else() {
2614        let engine = make_engine();
2615        let mut context = HashMap::new();
2616        context.insert("logged_in".to_string(), json!(false));
2617
2618        let template = r##"<if cond="#logged_in#">Dashboard<else/>Login</if>"##;
2619        let result = engine.render(template, &context).await.unwrap();
2620
2621        assert_eq!(result.trim(), "Login");
2622    }
2623
2624    #[tokio::test]
2625    async fn test_elseif() {
2626        let engine = make_engine();
2627
2628        // Test first branch matches
2629        let mut context = HashMap::new();
2630        context.insert("status".to_string(), json!("success"));
2631        let template = r##"<if cond='#status# == "success"'>OK<elseif cond='#status# == "error"'/>ERR<else/>UNKNOWN</if>"##;
2632        let result = engine.render(template, &context).await.unwrap();
2633        assert_eq!(result.trim(), "OK");
2634
2635        // Test elseif branch matches
2636        let mut context = HashMap::new();
2637        context.insert("status".to_string(), json!("error"));
2638        let result = engine.render(template, &context).await.unwrap();
2639        assert_eq!(result.trim(), "ERR");
2640
2641        // Test else branch (no match)
2642        let mut context = HashMap::new();
2643        context.insert("status".to_string(), json!("pending"));
2644        let result = engine.render(template, &context).await.unwrap();
2645        assert_eq!(result.trim(), "UNKNOWN");
2646    }
2647
2648    #[tokio::test]
2649    async fn test_elseif_multiple() {
2650        let engine = make_engine();
2651
2652        let template = r##"<if cond='#level# == "high"'>HIGH<elseif cond='#level# == "medium"'/>MEDIUM<elseif cond='#level# == "low"'/>LOW<else/>NONE</if>"##;
2653
2654        let mut context = HashMap::new();
2655        context.insert("level".to_string(), json!("medium"));
2656        let result = engine.render(template, &context).await.unwrap();
2657        assert_eq!(result.trim(), "MEDIUM");
2658
2659        context.insert("level".to_string(), json!("low"));
2660        let result = engine.render(template, &context).await.unwrap();
2661        assert_eq!(result.trim(), "LOW");
2662    }
2663
2664    #[tokio::test]
2665    async fn test_unless() {
2666        let engine = make_engine();
2667        let mut context = HashMap::new();
2668        context.insert("error".to_string(), json!(null));
2669
2670        let template = r##"<unless cond="#error#">All good!</unless>"##;
2671        let result = engine.render(template, &context).await.unwrap();
2672
2673        assert_eq!(result.trim(), "All good!");
2674    }
2675
2676    #[tokio::test]
2677    async fn test_unless_empty_array_is_falsey() {
2678        let engine = make_engine();
2679        let mut context = HashMap::new();
2680        context.insert("items".to_string(), json!([]));
2681
2682        let template = r##"<unless cond="#items#">No items</unless>"##;
2683        let result = engine.render(template, &context).await.unwrap();
2684
2685        assert_eq!(result.trim(), "No items");
2686    }
2687
2688    #[tokio::test]
2689    async fn test_comparison() {
2690        let engine = make_engine();
2691        let mut context = HashMap::new();
2692        context.insert("role".to_string(), json!("admin"));
2693
2694        // Use single quotes for attribute to allow embedded double quotes
2695        let template = r##"<if cond='#role# == "admin"'>Admin Panel</if>"##;
2696        let result = engine.render(template, &context).await.unwrap();
2697
2698        assert!(result.contains("Admin Panel"));
2699    }
2700
2701    #[tokio::test]
2702    async fn test_contains_operator() {
2703        let engine = make_engine();
2704        let mut context = HashMap::new();
2705        context.insert("lines".to_string(), json!("XOX|XXX|OXO"));
2706
2707        // Match — "XXX" is in the string
2708        let template = r##"<if cond='#lines# contains "XXX"'>Winner</if>"##;
2709        let result = engine.render(template, &context).await.unwrap();
2710        assert!(result.contains("Winner"));
2711
2712        // No match
2713        let template = r##"<if cond='#lines# contains "OOO"'>Winner<else/>No winner</if>"##;
2714        let result = engine.render(template, &context).await.unwrap();
2715        assert!(result.contains("No winner"));
2716    }
2717
2718    #[tokio::test]
2719    async fn test_custom_component_page() {
2720        let engine = make_engine();
2721        let context = HashMap::new();
2722
2723        let template = r##"<what-page title="Test Page"><div>Content</div></what-page>"##;
2724        let result = engine.render(template, &context).await.unwrap();
2725
2726        println!("Result: {}", result);
2727        assert!(result.contains("<!DOCTYPE html>"));
2728        assert!(result.contains("<title>Test Page</title>"));
2729        assert!(result.contains("<div>Content</div>"));
2730    }
2731
2732    #[tokio::test]
2733    async fn test_scraper_custom_components() {
2734        // Direct test of scraper with custom components
2735        let html = r##"<what-page title="Test"><div>Hello</div></what-page>"##;
2736        let doc = Html::parse_fragment(html);
2737
2738        println!("Parsed HTML: {}", doc.html());
2739
2740        let selector = Selector::parse("what-page").unwrap();
2741        let count = doc.select(&selector).count();
2742        println!("Found {} what-page elements", count);
2743
2744        for el in doc.select(&selector) {
2745            println!("Element outer: {}", el.html());
2746            println!("Element inner: {}", el.inner_html());
2747        }
2748
2749        assert!(
2750            count > 0,
2751            "Scraper should find custom <what-page> component"
2752        );
2753    }
2754
2755    #[tokio::test]
2756    async fn test_what_nav_component() {
2757        use crate::components::{Component, ComponentRegistry};
2758
2759        // Load the actual nav.html from demo
2760        let nav_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2761            .parent()
2762            .unwrap()
2763            .parent()
2764            .unwrap()
2765            .join("examples/demo/components/nav.html");
2766
2767        let mut nav_component =
2768            Component::from_file_with_name(&nav_path).expect("Failed to load nav.html");
2769        nav_component.name = format!("what-{}", nav_component.name);
2770        println!("Component name: {}", nav_component.name);
2771        println!(
2772            "Component template length: {}",
2773            nav_component.template.len()
2774        );
2775        println!("Component template: '{}'", nav_component.template);
2776
2777        let mut registry = ComponentRegistry::new();
2778        registry.register(nav_component);
2779
2780        println!(
2781            "Component names in registry: {:?}",
2782            registry.component_names()
2783        );
2784
2785        let engine = RenderEngine::new(registry);
2786        let context = HashMap::new();
2787
2788        // Test self-closing tag
2789        let template = r##"<what-nav active="home"/>"##;
2790        println!("Input template: '{}'", template);
2791
2792        let result = engine.render(template, &context).await.unwrap();
2793        println!("Rendered result: '{}'", result);
2794
2795        assert!(
2796            result.contains("<header"),
2797            "Result should contain <header>, got: '{}'",
2798            result
2799        );
2800        assert!(
2801            result.contains("nav-brand"),
2802            "Result should contain nav-brand"
2803        );
2804    }
2805
2806    #[tokio::test]
2807    async fn test_full_page_with_nav() {
2808        use crate::components::{Component, ComponentRegistry};
2809
2810        // Create registry with builtins
2811        let mut registry = ComponentRegistry::new();
2812        registry.register_builtins();
2813
2814        // Load the actual nav.html from demo
2815        let nav_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2816            .parent()
2817            .unwrap()
2818            .parent()
2819            .unwrap()
2820            .join("examples/demo/components/nav.html");
2821
2822        let mut nav_component =
2823            Component::from_file_with_name(&nav_path).expect("Failed to load nav.html");
2824        nav_component.name = format!("what-{}", nav_component.name);
2825        println!(
2826            "Registering nav component with name: {}",
2827            nav_component.name
2828        );
2829        println!("Nav template length: {}", nav_component.template.len());
2830        registry.register(nav_component);
2831
2832        println!("All component names: {:?}", registry.component_names());
2833
2834        let engine = RenderEngine::new(registry);
2835        let context = HashMap::new();
2836
2837        // Test rendering a page that contains what-nav
2838        let template = r##"<page title="Test">
2839  <what-nav active="home"/>
2840  <main>Content</main>
2841</page>"##;
2842
2843        println!("Input template:\n{}", template);
2844
2845        let result = engine.render(template, &context).await.unwrap();
2846        println!("Rendered result:\n{}", result);
2847
2848        // Check that the page wrapper is there
2849        assert!(result.contains("<!DOCTYPE html>"), "Should have doctype");
2850        assert!(result.contains("<title>Test</title>"), "Should have title");
2851
2852        // Check that nav content is present
2853        assert!(
2854            result.contains("<header"),
2855            "Result should contain <header> from nav"
2856        );
2857        assert!(
2858            result.contains("nav-brand"),
2859            "Result should contain nav-brand from nav"
2860        );
2861        assert!(
2862            result.contains("<main>Content</main>"),
2863            "Should have main content"
2864        );
2865    }
2866
2867    #[tokio::test]
2868    async fn test_code_block_vars_preserved() {
2869        let engine = make_engine();
2870        let mut context = HashMap::new();
2871        context.insert("name".to_string(), json!("Alice"));
2872
2873        // Variables inside <code> should NOT be replaced
2874        let template = r##"<p>Hello #name#</p><code class="example-code">#name# syntax</code>"##;
2875        let result = engine.render(template, &context).await.unwrap();
2876
2877        assert!(
2878            result.contains("<p>Hello Alice</p>"),
2879            "Variable outside code should be replaced"
2880        );
2881        assert!(
2882            result.contains("#name# syntax"),
2883            "Variable inside code should be preserved"
2884        );
2885    }
2886
2887    #[tokio::test]
2888    async fn test_code_block_env_vars_preserved() {
2889        let engine = make_engine();
2890        let context = HashMap::new();
2891
2892        let template = r##"<code class="example-code">#env.API_KEY# and #env.DEBUG#</code>"##;
2893        let result = engine.render(template, &context).await.unwrap();
2894
2895        assert!(
2896            result.contains("#env.API_KEY#"),
2897            "Env var in code block should be preserved"
2898        );
2899        assert!(
2900            result.contains("#env.DEBUG#"),
2901            "Env var in code block should be preserved"
2902        );
2903    }
2904
2905    #[tokio::test]
2906    async fn test_code_block_multiple_blocks() {
2907        let engine = make_engine();
2908        let mut context = HashMap::new();
2909        context.insert("x".to_string(), json!("replaced"));
2910
2911        let template = r##"<code>#x#</code><p>#x#</p><code>#x# again</code>"##;
2912        let result = engine.render(template, &context).await.unwrap();
2913
2914        assert!(
2915            result.contains("<p>replaced</p>"),
2916            "Var outside code replaced"
2917        );
2918        // Both code blocks should preserve their content
2919        assert_eq!(
2920            result.matches("#x#").count(),
2921            2,
2922            "Both code block vars preserved"
2923        );
2924    }
2925
2926    #[tokio::test]
2927    async fn test_component_json_array_loop() {
2928        use crate::components::Component;
2929
2930        let component = Component {
2931            name: "what-groups".to_string(),
2932            props: vec!["groups".to_string()],
2933            defaults: HashMap::new(),
2934            template: r##"<ul><loop data="#groups#" as="g"><li>#g.name#</li></loop></ul>"##
2935                .to_string(),
2936        };
2937
2938        let mut registry = ComponentRegistry::new();
2939        registry.register(component);
2940        let engine = RenderEngine::new(registry);
2941        let context = HashMap::new();
2942
2943        let template =
2944            r##"<what-groups groups='[{"id":1,"name":"Admins"},{"id":2,"name":"Editors"}]'/>"##;
2945        let result = engine.render(template, &context).await.unwrap();
2946
2947        println!("Component loop result: {}", result);
2948        assert!(
2949            result.contains("Admins"),
2950            "Should contain Admins, got: {}",
2951            result
2952        );
2953        assert!(
2954            result.contains("Editors"),
2955            "Should contain Editors, got: {}",
2956            result
2957        );
2958        assert!(
2959            !result.contains("loop: no data"),
2960            "Should not have loop error, got: {}",
2961            result
2962        );
2963    }
2964
2965    #[tokio::test]
2966    async fn test_render_with_timing_produces_correct_output() {
2967        // Template timing is debug logging only — verify rendering still works correctly
2968        let engine = make_engine();
2969        let mut context = HashMap::new();
2970        context.insert("name".to_string(), json!("World"));
2971        context.insert("items".to_string(), json!([{"label": "A"}, {"label": "B"}]));
2972        context.insert("show".to_string(), json!(true));
2973
2974        let template = r##"<p>Hello #name#</p>
2975<loop data="#items#" as="item"><span>#item.label#</span></loop>
2976<if cond="#show#">Visible</if>"##;
2977
2978        let result = engine.render(template, &context).await.unwrap();
2979        assert!(result.contains("Hello World"));
2980        assert!(result.contains("<span>A</span>"));
2981        assert!(result.contains("<span>B</span>"));
2982        assert!(result.contains("Visible"));
2983    }
2984
2985    #[tokio::test]
2986    async fn test_code_block_reactive_preserved() {
2987        let engine = make_engine();
2988        let mut context = HashMap::new();
2989        context.insert("session".to_string(), json!({"count": 5}));
2990
2991        let template = r##"<p>#session.count#</p><code>#session.count#</code>"##;
2992        let result = engine.render_reactive(template, &context).await.unwrap();
2993
2994        assert!(
2995            result.html.contains("w-bind"),
2996            "Session var outside code should be wrapped"
2997        );
2998        assert!(
2999            result.html.contains("#session.count#"),
3000            "Session var inside code should be preserved"
3001        );
3002    }
3003
3004    // =========================================================================
3005    // Section Auth Tests
3006    // =========================================================================
3007
3008    #[test]
3009    fn test_unclosed_if_lint_fires() {
3010        // Distinct fake paths — the lint dedupes per path per process
3011        assert!(warn_template_lints_once(
3012            std::path::Path::new("/lint-test/unclosed-if.html"),
3013            "<if user.name>Hello #user.name#"
3014        ));
3015        assert!(warn_template_lints_once(
3016            std::path::Path::new("/lint-test/unclosed-loop.html"),
3017            r##"<loop data="#items#" as="it">#it.name#"##
3018        ));
3019        // Balanced templates stay silent
3020        assert!(!warn_template_lints_once(
3021            std::path::Path::new("/lint-test/balanced.html"),
3022            "<if user.name>Hello</if><loop data=\"#items#\" as=\"it\">x</loop>"
3023        ));
3024        // <iframe> must not count as <if (word-boundary rule)
3025        assert!(!warn_template_lints_once(
3026            std::path::Path::new("/lint-test/iframe.html"),
3027            r#"<iframe src="x"></iframe>"#
3028        ));
3029    }
3030
3031    #[tokio::test]
3032    async fn test_unresolved_component_banner_in_dev_mode() {
3033        let engine = make_engine();
3034        let mut ctx = HashMap::new();
3035        ctx.insert("_dev_mode".to_string(), json!(true));
3036        let dev = engine
3037            .render("<what-tyop>oops</what-tyop>", &ctx)
3038            .await
3039            .unwrap();
3040        assert!(
3041            dev.contains("Unknown component"),
3042            "dev mode should show a banner: {}",
3043            dev
3044        );
3045
3046        let mut prod_ctx = HashMap::new();
3047        prod_ctx.insert("_dev_mode".to_string(), json!(false));
3048        let prod = engine
3049            .render("<what-tyop>oops</what-tyop>", &prod_ctx)
3050            .await
3051            .unwrap();
3052        assert!(
3053            !prod.contains("Unknown component"),
3054            "prod must not show banners: {}",
3055            prod
3056        );
3057    }
3058
3059    // =========================================================================
3060    // Built-in Declarative Tags (<what-fetch>, <what-clipboard>, <what-theme-toggle>)
3061    // =========================================================================
3062
3063    #[tokio::test]
3064    async fn test_what_fetch_default_when_is_load() {
3065        let engine = make_engine();
3066        let ctx = HashMap::new();
3067        let html = engine
3068            .render(
3069                r#"<what-fetch url="/w-partial/stats">fallback</what-fetch>"#,
3070                &ctx,
3071            )
3072            .await
3073            .unwrap();
3074        assert!(html.contains(r#"w-get="/w-partial/stats""#), "{}", html);
3075        assert!(html.contains(r#"w-trigger="load""#), "{}", html);
3076        assert!(html.contains(r#"class="w-fetch""#), "{}", html);
3077        assert!(html.contains("fallback"), "{}", html);
3078        assert!(html.contains("</div>"), "{}", html);
3079        assert!(!html.contains("<what-fetch"), "{}", html);
3080    }
3081
3082    #[tokio::test]
3083    async fn test_what_fetch_triggers_and_poll_grammar() {
3084        let engine = make_engine();
3085        let ctx = HashMap::new();
3086
3087        // visible → revealed
3088        let html = engine
3089            .render(
3090                r#"<what-fetch url="/w-partial/c" when="visible">Loading…</what-fetch>"#,
3091                &ctx,
3092            )
3093            .await
3094            .unwrap();
3095        assert!(html.contains(r#"w-trigger="revealed""#), "{}", html);
3096
3097        // default when + poll combine
3098        let html = engine
3099            .render(r#"<what-fetch url="/w-partial/t" poll="5s"/>"#, &ctx)
3100            .await
3101            .unwrap();
3102        assert!(html.contains(r#"w-trigger="load, poll 5s""#), "{}", html);
3103
3104        // click + poll keeps the click token (clicks would be gated off otherwise)
3105        let html = engine
3106            .render(
3107                r#"<what-fetch url="/w-partial/t" when="click" poll="30s"/>"#,
3108                &ctx,
3109            )
3110            .await
3111            .unwrap();
3112        assert!(html.contains(r#"w-trigger="click, poll 30s""#), "{}", html);
3113
3114        // plain click emits no w-trigger at all (client default)
3115        let html = engine
3116            .render(r#"<what-fetch url="/w-partial/t" when="click"/>"#, &ctx)
3117            .await
3118            .unwrap();
3119        assert!(!html.contains("w-trigger"), "{}", html);
3120
3121        // interval units: ms, m, h, and bare seconds
3122        for poll in ["500ms", "2m", "1h", "45"] {
3123            let html = engine
3124                .render(
3125                    &format!(r#"<what-fetch url="/w-partial/t" poll="{}"/>"#, poll),
3126                    &ctx,
3127                )
3128                .await
3129                .unwrap();
3130            assert!(
3131                html.contains(&format!("poll {}", poll)),
3132                "poll={} → {}",
3133                poll,
3134                html
3135            );
3136        }
3137    }
3138
3139    #[tokio::test]
3140    async fn test_what_fetch_method_target_swap_as_passthrough() {
3141        let engine = make_engine();
3142        let ctx = HashMap::new();
3143        let html = engine
3144            .render(
3145                r##"<what-fetch url="/w-partial/rows" method="post" target="#list" swap="append" as="tbody" id="rows" data-x="1">seed</what-fetch>"##,
3146                &ctx,
3147            )
3148            .await
3149            .unwrap();
3150        assert!(html.contains(r#"w-post="/w-partial/rows""#), "{}", html);
3151        assert!(!html.contains("w-get"), "{}", html);
3152        assert!(html.contains(r##"w-target="#list""##), "{}", html);
3153        assert!(html.contains(r#"w-swap="append""#), "{}", html);
3154        assert!(html.starts_with("<tbody"), "{}", html);
3155        assert!(html.ends_with("</tbody>"), "{}", html);
3156        assert!(html.contains(r#"id="rows""#), "{}", html);
3157        assert!(html.contains(r#"data-x="1""#), "{}", html);
3158    }
3159
3160    #[tokio::test]
3161    async fn test_what_fetch_dev_banners_and_prod_fallbacks() {
3162        let engine = make_engine();
3163        let mut dev = HashMap::new();
3164        dev.insert("_dev_mode".to_string(), json!(true));
3165        let mut prod = HashMap::new();
3166        prod.insert("_dev_mode".to_string(), json!(false));
3167
3168        // missing url
3169        let html = engine.render("<what-fetch>x</what-fetch>", &dev).await.unwrap();
3170        assert!(html.contains("requires a url"), "{}", html);
3171        let html = engine.render("<what-fetch>x</what-fetch>", &prod).await.unwrap();
3172        assert!(!html.contains("what-fetch"), "{}", html);
3173
3174        // bad poll interval
3175        let html = engine
3176            .render(r#"<what-fetch url="/x" poll="soon"/>"#, &dev)
3177            .await
3178            .unwrap();
3179        assert!(html.contains("invalid poll"), "{}", html);
3180        let html = engine
3181            .render(r#"<what-fetch url="/x" poll="soon"/>"#, &prod)
3182            .await
3183            .unwrap();
3184        assert!(!html.contains("poll"), "{}", html);
3185
3186        // unknown when
3187        let html = engine
3188            .render(r#"<what-fetch url="/x" when="hover"/>"#, &dev)
3189            .await
3190            .unwrap();
3191        assert!(html.contains("unknown when"), "{}", html);
3192        let html = engine
3193            .render(r#"<what-fetch url="/x" when="hover"/>"#, &prod)
3194            .await
3195            .unwrap();
3196        assert!(html.contains(r#"w-trigger="load""#), "{}", html);
3197    }
3198
3199    #[tokio::test]
3200    async fn test_what_fetch_var_in_url_resolves() {
3201        let engine = make_engine();
3202        let mut ctx = HashMap::new();
3203        ctx.insert("uid".to_string(), json!(7));
3204        let html = engine
3205            .render(r#"<what-fetch url="/w-partial/user/#uid#"/>"#, &ctx)
3206            .await
3207            .unwrap();
3208        assert!(html.contains(r#"w-get="/w-partial/user/7""#), "{}", html);
3209    }
3210
3211    #[tokio::test]
3212    async fn test_what_fetch_nested_regions_both_expand() {
3213        let engine = make_engine();
3214        let ctx = HashMap::new();
3215        let html = engine
3216            .render(
3217                r#"<what-fetch url="/w-partial/outer"><what-fetch url="/w-partial/inner" when="visible">inner</what-fetch></what-fetch>"#,
3218                &ctx,
3219            )
3220            .await
3221            .unwrap();
3222        assert!(html.contains(r#"w-get="/w-partial/outer""#), "{}", html);
3223        assert!(html.contains(r#"w-get="/w-partial/inner""#), "{}", html);
3224        assert!(!html.contains("<what-fetch"), "{}", html);
3225    }
3226
3227    #[tokio::test]
3228    async fn test_what_clipboard_value_and_from() {
3229        let engine = make_engine();
3230        let ctx = HashMap::new();
3231
3232        let html = engine
3233            .render(
3234                r#"<what-clipboard value="cargo install run-what"/>"#,
3235                &ctx,
3236            )
3237            .await
3238            .unwrap();
3239        assert!(
3240            html.contains(r#"<button type="button" w-clipboard="cargo install run-what""#),
3241            "{}",
3242            html
3243        );
3244        assert!(html.contains(">Copy</button>"), "{}", html);
3245
3246        let html = engine
3247            .render(
3248                r##"<what-clipboard from="#room-link" copied-label="copied!">copy</what-clipboard>"##,
3249                &ctx,
3250            )
3251            .await
3252            .unwrap();
3253        assert!(html.contains(r##"w-clipboard-from="#room-link""##), "{}", html);
3254        assert!(html.contains(r#"w-copied-label="copied!""#), "{}", html);
3255        assert!(html.contains(">copy</button>"), "{}", html);
3256        // from-only must not emit an empty w-clipboard="" (it would match the
3257        // client selector and copy the empty string)
3258        assert!(!html.contains("w-clipboard="), "{}", html);
3259    }
3260
3261    #[tokio::test]
3262    async fn test_what_clipboard_missing_source() {
3263        let engine = make_engine();
3264        let mut dev = HashMap::new();
3265        dev.insert("_dev_mode".to_string(), json!(true));
3266        let html = engine
3267            .render("<what-clipboard>Copy</what-clipboard>", &dev)
3268            .await
3269            .unwrap();
3270        assert!(html.contains("requires value="), "{}", html);
3271
3272        let mut prod = HashMap::new();
3273        prod.insert("_dev_mode".to_string(), json!(false));
3274        let html = engine
3275            .render("<what-clipboard>Copy</what-clipboard>", &prod)
3276            .await
3277            .unwrap();
3278        assert!(!html.contains("button"), "{}", html);
3279    }
3280
3281    #[tokio::test]
3282    async fn test_what_theme_toggle_defaults_and_children() {
3283        let engine = make_engine();
3284        let ctx = HashMap::new();
3285
3286        let html = engine.render("<what-theme-toggle/>", &ctx).await.unwrap();
3287        assert!(html.contains("w-theme-toggle"), "{}", html);
3288        assert!(html.contains(r#"class="w-theme-toggle""#), "{}", html);
3289        assert!(html.contains("w-theme-icon-light"), "{}", html);
3290        assert!(html.contains("w-theme-icon-dark"), "{}", html);
3291        assert!(html.contains(r#"aria-label="Toggle theme""#), "{}", html);
3292
3293        let html = engine
3294            .render(
3295                r#"<what-theme-toggle class="nav-btn">Theme</what-theme-toggle>"#,
3296                &ctx,
3297            )
3298            .await
3299            .unwrap();
3300        assert!(html.contains(r#"class="w-theme-toggle nav-btn""#), "{}", html);
3301        assert!(html.contains(">Theme</button>"), "{}", html);
3302        assert!(!html.contains("w-theme-icon"), "{}", html);
3303    }
3304
3305    #[tokio::test]
3306    async fn test_escaped_builtin_in_code_survives() {
3307        let engine = make_engine();
3308        let ctx = HashMap::new();
3309        let template =
3310            r#"<code>&lt;what-fetch url="/w-partial/stats" poll="5s"&gt;&lt;/what-fetch&gt;</code>"#;
3311        let html = engine.render(template, &ctx).await.unwrap();
3312        assert!(html.contains("&lt;what-fetch"), "{}", html);
3313        assert!(!html.contains("w-trigger"), "{}", html);
3314    }
3315
3316    #[test]
3317    fn test_raw_builtin_in_code_lint() {
3318        assert!(warn_template_lints_once(
3319            std::path::Path::new("/lint-test/raw-builtin-in-code.html"),
3320            r#"<code><what-fetch url="/x">sample</what-fetch></code>"#
3321        ));
3322        // Entity-escaped samples are fine
3323        assert!(!warn_template_lints_once(
3324            std::path::Path::new("/lint-test/escaped-builtin-in-code.html"),
3325            r#"<code>&lt;what-fetch url="/x"&gt;sample&lt;/what-fetch&gt;</code>"#
3326        ));
3327    }
3328
3329    #[test]
3330    fn test_include_tag_gt_inside_attr_value() {
3331        // Regression: the tag-end scan stopped at the first raw `>`, so an
3332        // attribute value containing `>` truncated the tag mid-attribute.
3333        let engine = make_engine();
3334        let html = r#"<p>before</p><include src="box.html" title="5 > 3"/><p>after</p>"#;
3335        let (start, end, src, attrs) = engine.find_include_tag(html).unwrap();
3336        assert_eq!(src, "box.html");
3337        assert_eq!(attrs.get("title").map(String::as_str), Some("5 > 3"));
3338        assert_eq!(
3339            &html[start..end],
3340            r#"<include src="box.html" title="5 > 3"/>"#
3341        );
3342    }
3343
3344    #[test]
3345    fn test_extract_attr_word_boundary() {
3346        // `as` must not match inside `class="…"` (and then give up); the
3347        // real as="item" later in the tag must be found
3348        let engine = make_engine();
3349        let tag = r##"<loop class="list" data="#items#" as="item">"##;
3350        assert_eq!(engine.extract_attr(tag, "as").as_deref(), Some("item"));
3351        // `target` must not read w-target's value
3352        let tag2 = r##"<a w-target="#panel" href="/x">"##;
3353        assert_eq!(engine.extract_attr(tag2, "target"), None);
3354    }
3355
3356    #[test]
3357    fn test_custom_tag_prefix_name_no_collision() {
3358        // <what-card must not match inside <what-card-header
3359        let engine = make_engine();
3360        let html = "<what-card-header>H</what-card-header><what-card>C</what-card>";
3361        let (start, _end, _attrs, children) = engine.find_custom_tag(html, "what-card").unwrap();
3362        assert_eq!(children, "C", "matched the wrong tag at {}", start);
3363    }
3364
3365    #[test]
3366    fn test_custom_tag_gt_inside_attr_value() {
3367        let engine = make_engine();
3368        let html = r#"<what-badge label="a > b">child</what-badge>"#;
3369        let (start, end, attrs, children) = engine.find_custom_tag(html, "what-badge").unwrap();
3370        assert_eq!(start, 0);
3371        assert_eq!(end, html.len());
3372        assert_eq!(attrs.get("label").map(String::as_str), Some("a > b"));
3373        assert_eq!(children, "child");
3374    }
3375
3376    #[test]
3377    fn section_auth_admin_sees_admin_content() {
3378        let mut context = HashMap::new();
3379        context.insert(
3380            "user".to_string(),
3381            json!({"authenticated": true, "role": "admin"}),
3382        );
3383
3384        let html = r#"<section auth="admin"><p>Admin panel</p></section>"#;
3385        let result = RenderEngine::process_section_auth(html, &context).unwrap();
3386        assert!(result.contains("Admin panel"));
3387    }
3388
3389    #[test]
3390    fn section_auth_user_denied_admin_content() {
3391        let mut context = HashMap::new();
3392        context.insert(
3393            "user".to_string(),
3394            json!({"authenticated": true, "role": "user"}),
3395        );
3396
3397        let html = r#"<section auth="admin"><p>Admin panel</p></section>"#;
3398        let result = RenderEngine::process_section_auth(html, &context).unwrap();
3399        assert!(!result.contains("Admin panel"));
3400    }
3401
3402    #[test]
3403    fn section_auth_anonymous_denied() {
3404        let mut context = HashMap::new();
3405        context.insert("user".to_string(), json!({"authenticated": false}));
3406
3407        let html = r#"<div auth="user"><p>Members only</p></div>"#;
3408        let result = RenderEngine::process_section_auth(html, &context).unwrap();
3409        assert!(!result.contains("Members only"));
3410    }
3411
3412    #[test]
3413    fn section_auth_authenticated_sees_user_content() {
3414        let mut context = HashMap::new();
3415        context.insert(
3416            "user".to_string(),
3417            json!({"authenticated": true, "role": "viewer"}),
3418        );
3419
3420        let html = r#"<div auth="user"><p>Welcome back</p></div>"#;
3421        let result = RenderEngine::process_section_auth(html, &context).unwrap();
3422        assert!(result.contains("Welcome back"));
3423    }
3424
3425    #[test]
3426    fn section_auth_multiple_roles() {
3427        let mut context = HashMap::new();
3428        context.insert(
3429            "user".to_string(),
3430            json!({"authenticated": true, "role": "editor"}),
3431        );
3432
3433        let html = r#"<section auth="admin, editor"><p>Staff tools</p></section>"#;
3434        let result = RenderEngine::process_section_auth(html, &context).unwrap();
3435        assert!(result.contains("Staff tools"));
3436    }
3437
3438    #[test]
3439    fn section_auth_single_quoted_attr_denies_anonymous() {
3440        // Regression: `auth='admin'` (single quotes) previously failed to
3441        // match the double-quote-only gate regex, so the protected content
3442        // was served to everyone.
3443        let mut context = HashMap::new();
3444        context.insert("user".to_string(), json!({"authenticated": false}));
3445
3446        let html = r#"<section auth='admin'><p>Admin panel</p></section>"#;
3447        let result = RenderEngine::process_section_auth(html, &context).unwrap();
3448        assert!(!result.contains("Admin panel"), "got: {}", result);
3449    }
3450
3451    #[test]
3452    fn section_auth_single_quoted_attr_allows_matching_role() {
3453        let mut context = HashMap::new();
3454        context.insert(
3455            "user".to_string(),
3456            json!({"authenticated": true, "role": "admin"}),
3457        );
3458
3459        let html = r#"<section auth='admin'><p>Admin panel</p></section>"#;
3460        let result = RenderEngine::process_section_auth(html, &context).unwrap();
3461        assert!(result.contains("Admin panel"), "got: {}", result);
3462    }
3463
3464    #[test]
3465    fn section_auth_public_always_shown() {
3466        let mut context = HashMap::new();
3467        context.insert("user".to_string(), json!({"authenticated": false}));
3468
3469        let html = r#"<div auth="all"><p>Public info</p></div>"#;
3470        let result = RenderEngine::process_section_auth(html, &context).unwrap();
3471        assert!(result.contains("Public info"));
3472    }
3473
3474    #[test]
3475    fn section_auth_preserves_non_auth_elements() {
3476        let mut context = HashMap::new();
3477        context.insert("user".to_string(), json!({"authenticated": false}));
3478
3479        let html = r#"<div><p>Visible</p></div><section auth="admin"><p>Hidden</p></section><p>Also visible</p>"#;
3480        let result = RenderEngine::process_section_auth(html, &context).unwrap();
3481        assert!(result.contains("Visible"));
3482        assert!(result.contains("Also visible"));
3483        assert!(!result.contains("Hidden"));
3484    }
3485
3486    // =========================================================================
3487    // Simplified <if> Syntax + Numeric Comparisons
3488    // =========================================================================
3489
3490    #[tokio::test]
3491    async fn test_simplified_if_truthy() {
3492        let engine = make_engine();
3493        let mut ctx = HashMap::new();
3494        ctx.insert("logged_in".to_string(), json!(true));
3495        let result = engine
3496            .render("<if logged_in>Welcome!</if>", &ctx)
3497            .await
3498            .unwrap();
3499        assert!(result.contains("Welcome!"));
3500    }
3501
3502    #[tokio::test]
3503    async fn test_if_inside_loop_resolves_alias() {
3504        // Regression: `<if alias.field == "x">` / `<unless alias.field == "x">`
3505        // inside a loop must resolve against the current item, not the global
3506        // context (where the loop alias does not exist).
3507        let engine = make_engine();
3508        let mut ctx = HashMap::new();
3509        ctx.insert(
3510            "items".to_string(),
3511            json!([
3512                {"name": "A", "done": "true"},
3513                {"name": "B", "done": "false"}
3514            ]),
3515        );
3516        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>"##;
3517        let result = engine.render(tpl, &ctx).await.unwrap();
3518        assert!(result.contains("DONE:A"), "got: {}", result);
3519        assert!(result.contains("TODO:B"), "got: {}", result);
3520        assert!(!result.contains("DONE:B"), "got: {}", result);
3521        assert!(!result.contains("TODO:A"), "got: {}", result);
3522    }
3523
3524    #[tokio::test]
3525    async fn test_if_quoted_both_sides() {
3526        // Regression: quoting both operands (e.g. `"#a#" == "#b#"`, as the chat
3527        // demo does to highlight own messages) must compare symmetrically.
3528        let engine = make_engine();
3529        let mut ctx = HashMap::new();
3530        ctx.insert("a".to_string(), json!("alice"));
3531        ctx.insert("b".to_string(), json!("alice"));
3532        ctx.insert("c".to_string(), json!("bob"));
3533        let same = engine.render(r##"<if "#a#" == "#b#">MATCH</if>"##, &ctx).await.unwrap();
3534        let diff = engine.render(r##"<if "#a#" == "#c#">MATCH</if>"##, &ctx).await.unwrap();
3535        assert!(same.contains("MATCH"), "equal quoted operands should match: {}", same);
3536        assert!(!diff.contains("MATCH"), "unequal quoted operands should not match: {}", diff);
3537    }
3538
3539    #[tokio::test]
3540    async fn test_if_equality_unescapes_operands() {
3541        // Regression: variables resolve HTML-escaped, so values containing
3542        // & < > ' " never compared equal to their author-written literals.
3543        let engine = make_engine();
3544        let mut ctx = HashMap::new();
3545        ctx.insert("company".to_string(), json!("Ben & Jerry"));
3546        ctx.insert("name".to_string(), json!("O'Brien"));
3547        let amp = engine
3548            .render(r#"<if company == "Ben & Jerry">HIT</if>"#, &ctx)
3549            .await
3550            .unwrap();
3551        let apos = engine
3552            .render(r#"<if name == "O'Brien">HIT</if>"#, &ctx)
3553            .await
3554            .unwrap();
3555        assert!(amp.contains("HIT"), "ampersand value should match: {}", amp);
3556        assert!(apos.contains("HIT"), "apostrophe value should match: {}", apos);
3557    }
3558
3559    #[tokio::test]
3560    async fn test_if_numeric_equality_coerces() {
3561        // 10 == 10.0 must match, consistent with gt/lt which already
3562        // compare numerically.
3563        let engine = make_engine();
3564        let mut ctx = HashMap::new();
3565        ctx.insert("price".to_string(), json!(10.0));
3566        let eq = engine.render("<if price == 10>HIT</if>", &ctx).await.unwrap();
3567        let ne = engine.render("<if price != 10>MISS</if>", &ctx).await.unwrap();
3568        assert!(eq.contains("HIT"), "10.0 == 10 should match: {}", eq);
3569        assert!(!ne.contains("MISS"), "10.0 != 10 should not match: {}", ne);
3570    }
3571
3572    #[tokio::test]
3573    async fn test_if_quoted_literal_forces_string_equality() {
3574        // Quoting forces string semantics (as in <what> blocks): a quoted
3575        // "01234" never numerically equals 1234.
3576        let engine = make_engine();
3577        let mut ctx = HashMap::new();
3578        ctx.insert("zip".to_string(), json!("01234"));
3579        ctx.insert("num".to_string(), json!(1234));
3580        let string_match = engine
3581            .render(r#"<if zip == "01234">HIT</if>"#, &ctx)
3582            .await
3583            .unwrap();
3584        let no_coerce = engine
3585            .render(r#"<if num == "01234">MISS</if>"#, &ctx)
3586            .await
3587            .unwrap();
3588        assert!(string_match.contains("HIT"), "got: {}", string_match);
3589        assert!(!no_coerce.contains("MISS"), "quoted literal must not numeric-coerce: {}", no_coerce);
3590    }
3591
3592    #[tokio::test]
3593    async fn test_if_single_quoted_literal() {
3594        // Regression: single-quoted literals kept their quotes (only `"` was
3595        // stripped) and the comparison never matched.
3596        let engine = make_engine();
3597        let mut ctx = HashMap::new();
3598        ctx.insert("role".to_string(), json!("admin"));
3599        let hit = engine
3600            .render(r#"<if role == 'admin'>Panel</if>"#, &ctx)
3601            .await
3602            .unwrap();
3603        let miss = engine
3604            .render(r#"<if role == 'editor'>Panel</if>"#, &ctx)
3605            .await
3606            .unwrap();
3607        assert!(hit.contains("Panel"), "single-quoted literal should match: {}", hit);
3608        assert!(!miss.contains("Panel"), "wrong literal should not match: {}", miss);
3609    }
3610
3611    #[tokio::test]
3612    async fn test_if_keyword_operator_inside_quoted_literal() {
3613        // Regression: ` gt ` / ` lt ` etc. were string-replaced even inside
3614        // quoted operands, corrupting the literal before comparison.
3615        let engine = make_engine();
3616        let mut ctx = HashMap::new();
3617        ctx.insert("title".to_string(), json!("the gt debate"));
3618        let result = engine
3619            .render(r#"<if title == "the gt debate">HIT</if>"#, &ctx)
3620            .await
3621            .unwrap();
3622        assert!(result.contains("HIT"), "got: {}", result);
3623    }
3624
3625    #[tokio::test]
3626    async fn test_if_operator_inside_quoted_left_operand() {
3627        // An operator sequence inside a quoted operand must not split the
3628        // condition — the real operator is the one outside quotes.
3629        let engine = make_engine();
3630        let mut ctx = HashMap::new();
3631        ctx.insert("mode".to_string(), json!("a == b"));
3632        let result = engine
3633            .render(r#"<if "a == b" == mode>HIT</if>"#, &ctx)
3634            .await
3635            .unwrap();
3636        assert!(result.contains("HIT"), "got: {}", result);
3637    }
3638
3639    #[tokio::test]
3640    async fn test_contains_single_quoted_literal() {
3641        let engine = make_engine();
3642        let mut ctx = HashMap::new();
3643        ctx.insert("title".to_string(), json!("the gt debate"));
3644        let result = engine
3645            .render(r#"<if title contains 'debate'>HIT</if>"#, &ctx)
3646            .await
3647            .unwrap();
3648        assert!(result.contains("HIT"), "got: {}", result);
3649    }
3650
3651    #[tokio::test]
3652    async fn test_simplified_if_equality() {
3653        let engine = make_engine();
3654        let mut ctx = HashMap::new();
3655        ctx.insert("status".to_string(), json!("admin"));
3656        let result = engine
3657            .render(r#"<if status == "admin">Panel</if>"#, &ctx)
3658            .await
3659            .unwrap();
3660        assert!(result.contains("Panel"));
3661    }
3662
3663    #[tokio::test]
3664    async fn test_simplified_if_numeric_eq() {
3665        let engine = make_engine();
3666        let mut ctx = HashMap::new();
3667        ctx.insert("active_step".to_string(), json!(2));
3668        let result = engine
3669            .render("<if active_step == 2>Step 2!</if>", &ctx)
3670            .await
3671            .unwrap();
3672        assert!(result.contains("Step 2!"));
3673    }
3674
3675    #[tokio::test]
3676    async fn test_simplified_elseif() {
3677        let engine = make_engine();
3678        let mut ctx = HashMap::new();
3679        ctx.insert("level".to_string(), json!("low"));
3680        let result = engine
3681            .render(
3682                r#"<if level == "high">H<elseif level == "low"/>L<else/>M</if>"#,
3683                &ctx,
3684            )
3685            .await
3686            .unwrap();
3687        assert!(result.contains("L"));
3688        assert!(!result.contains("H"));
3689        assert!(!result.contains("M"));
3690    }
3691
3692    #[tokio::test]
3693    async fn test_simplified_unless() {
3694        let engine = make_engine();
3695        let mut ctx = HashMap::new();
3696        ctx.insert("error".to_string(), json!(false));
3697        let result = engine
3698            .render("<unless error>All good!</unless>", &ctx)
3699            .await
3700            .unwrap();
3701        assert!(result.contains("All good!"));
3702    }
3703
3704    #[test]
3705    fn test_split_top_level_bool() {
3706        // Basic split
3707        assert_eq!(
3708            split_top_level_bool("a == 1 and b == 2", "and"),
3709            vec!["a == 1", "b == 2"]
3710        );
3711        // No keyword → single element
3712        assert_eq!(split_top_level_bool("a == 1", "and"), vec!["a == 1"]);
3713        // Keyword inside quotes must not split
3714        assert_eq!(
3715            split_top_level_bool(r#"status == "up and running""#, "and"),
3716            vec![r#"status == "up and running""#]
3717        );
3718        // Identifiers containing the keyword must not split
3719        assert_eq!(
3720            split_top_level_bool("android == 1", "and"),
3721            vec!["android == 1"]
3722        );
3723        assert_eq!(
3724            split_top_level_bool("category == 2", "or"),
3725            vec!["category == 2"]
3726        );
3727        // Multiple keywords
3728        assert_eq!(
3729            split_top_level_bool("a and b and c", "and"),
3730            vec!["a", "b", "c"]
3731        );
3732    }
3733
3734    #[tokio::test]
3735    async fn test_if_and_both_true() {
3736        let engine = make_engine();
3737        let mut ctx = HashMap::new();
3738        ctx.insert("count".to_string(), json!(5));
3739        ctx.insert("role".to_string(), json!("admin"));
3740        let result = engine
3741            .render(r#"<if count gt 0 and role == "admin">BOTH</if>"#, &ctx)
3742            .await
3743            .unwrap();
3744        assert!(result.contains("BOTH"), "got: {}", result);
3745    }
3746
3747    #[tokio::test]
3748    async fn test_if_and_one_false() {
3749        let engine = make_engine();
3750        let mut ctx = HashMap::new();
3751        ctx.insert("count".to_string(), json!(0));
3752        ctx.insert("role".to_string(), json!("admin"));
3753        let result = engine
3754            .render(r#"<if count gt 0 and role == "admin">BOTH</if>"#, &ctx)
3755            .await
3756            .unwrap();
3757        assert!(!result.contains("BOTH"), "got: {}", result);
3758    }
3759
3760    #[tokio::test]
3761    async fn test_if_or() {
3762        let engine = make_engine();
3763        let mut ctx = HashMap::new();
3764        ctx.insert("role".to_string(), json!("editor"));
3765        let tpl = r#"<if role == "admin" or role == "editor">STAFF</if>"#;
3766        let result = engine.render(tpl, &ctx).await.unwrap();
3767        assert!(result.contains("STAFF"), "got: {}", result);
3768
3769        ctx.insert("role".to_string(), json!("guest"));
3770        let result = engine.render(tpl, &ctx).await.unwrap();
3771        assert!(!result.contains("STAFF"), "got: {}", result);
3772    }
3773
3774    #[tokio::test]
3775    async fn test_and_binds_tighter_than_or() {
3776        // `a or b and c` must read as `a or (b and c)`.
3777        let engine = make_engine();
3778        let tpl = "<if a or b and c>YES</if>";
3779
3780        // a=true, b/c false → true via the `a` disjunct
3781        let mut ctx = HashMap::new();
3782        ctx.insert("a".to_string(), json!(true));
3783        ctx.insert("b".to_string(), json!(false));
3784        ctx.insert("c".to_string(), json!(false));
3785        let result = engine.render(tpl, &ctx).await.unwrap();
3786        assert!(result.contains("YES"), "a alone should satisfy: {}", result);
3787
3788        // a=false, b=true, c=false → (b and c) is false → false
3789        ctx.insert("a".to_string(), json!(false));
3790        ctx.insert("b".to_string(), json!(true));
3791        let result = engine.render(tpl, &ctx).await.unwrap();
3792        assert!(!result.contains("YES"), "b alone must not satisfy: {}", result);
3793
3794        // a=false, b=true, c=true → (b and c) true → true
3795        ctx.insert("c".to_string(), json!(true));
3796        let result = engine.render(tpl, &ctx).await.unwrap();
3797        assert!(result.contains("YES"), "b and c should satisfy: {}", result);
3798    }
3799
3800    #[tokio::test]
3801    async fn test_and_with_quoted_operand_containing_keyword() {
3802        let engine = make_engine();
3803        let mut ctx = HashMap::new();
3804        ctx.insert("status".to_string(), json!("up and running"));
3805        ctx.insert("ok".to_string(), json!(true));
3806        let result = engine
3807            .render(r#"<if status == "up and running" and ok>LIVE</if>"#, &ctx)
3808            .await
3809            .unwrap();
3810        assert!(result.contains("LIVE"), "got: {}", result);
3811    }
3812
3813    #[tokio::test]
3814    async fn test_elseif_with_and() {
3815        let engine = make_engine();
3816        let mut ctx = HashMap::new();
3817        ctx.insert("n".to_string(), json!(7));
3818        ctx.insert("enabled".to_string(), json!(true));
3819        let tpl = "<if n gt 10>BIG<elseif n gt 5 and enabled/>MID<else/>SMALL</if>";
3820        let result = engine.render(tpl, &ctx).await.unwrap();
3821        assert!(result.contains("MID"), "got: {}", result);
3822    }
3823
3824    #[tokio::test]
3825    async fn test_unless_with_and_de_morgan() {
3826        // <unless a and b> renders when NOT (a && b).
3827        let engine = make_engine();
3828        let tpl = "<unless a and b>SHOWN</unless>";
3829
3830        let mut ctx = HashMap::new();
3831        ctx.insert("a".to_string(), json!(true));
3832        ctx.insert("b".to_string(), json!(false));
3833        let result = engine.render(tpl, &ctx).await.unwrap();
3834        assert!(result.contains("SHOWN"), "got: {}", result);
3835
3836        ctx.insert("b".to_string(), json!(true));
3837        let result = engine.render(tpl, &ctx).await.unwrap();
3838        assert!(!result.contains("SHOWN"), "got: {}", result);
3839    }
3840
3841    #[tokio::test]
3842    async fn test_negation_applies_per_leaf() {
3843        // `!a and b` reads as `(!a) and b`.
3844        let engine = make_engine();
3845        let mut ctx = HashMap::new();
3846        ctx.insert("a".to_string(), json!(false));
3847        ctx.insert("b".to_string(), json!(true));
3848        let result = engine.render("<if !a and b>OK</if>", &ctx).await.unwrap();
3849        assert!(result.contains("OK"), "got: {}", result);
3850    }
3851
3852    #[tokio::test]
3853    async fn test_legacy_cond_attr_supports_and() {
3854        // cond= shares the evaluation path, so AND/OR works there too
3855        // (documented only for the simplified syntax).
3856        let engine = make_engine();
3857        let mut ctx = HashMap::new();
3858        ctx.insert("count".to_string(), json!(3));
3859        ctx.insert("active".to_string(), json!(true));
3860        let result = engine
3861            .render(r##"<if cond="#count# gt 0 and #active#">ON</if>"##, &ctx)
3862            .await
3863            .unwrap();
3864        assert!(result.contains("ON"), "got: {}", result);
3865    }
3866
3867    #[test]
3868    fn test_template_lint_regexes() {
3869        // Legacy cond= matches all three conditional tags
3870        assert!(LEGACY_COND_RE.is_match(r##"<if cond="#a#">x</if>"##));
3871        assert!(LEGACY_COND_RE.is_match(r##"<elseif cond='#a#'/>"##));
3872        assert!(LEGACY_COND_RE.is_match(r##"<unless cond = "#a#">x</unless>"##));
3873        // Simplified syntax and escaped doc examples must NOT match
3874        assert!(!LEGACY_COND_RE.is_match("<if count gt 0>x</if>"));
3875        assert!(!LEGACY_COND_RE.is_match("&lt;if cond=\"#a#\"&gt;"));
3876        assert!(!LEGACY_COND_RE.is_match("<iframe cond=\"x\">"));
3877        assert!(!LEGACY_COND_RE.is_match("<if conditional_flag>x</if>"));
3878
3879        // Trailing else after </if> (always-renders bug)
3880        assert!(TRAILING_ELSE_RE.is_match("</if><else/>oops</else>"));
3881        assert!(TRAILING_ELSE_RE.is_match("</if>\n  <else/>"));
3882        assert!(TRAILING_ELSE_RE.is_match("</if> <else />"));
3883        // The valid inline form must NOT match
3884        assert!(!TRAILING_ELSE_RE.is_match("<if a>x<else/>y</if>"));
3885    }
3886
3887    #[test]
3888    fn test_collect_template_lints_kinds() {
3889        let kinds = |raw: &str| -> Vec<&'static str> {
3890            collect_template_lints(raw).iter().map(|l| l.kind).collect()
3891        };
3892        assert_eq!(kinds(r##"<if cond="#a#">x</if>"##), vec!["legacy-cond"]);
3893        assert_eq!(kinds("</if><else/>oops</else>"), vec!["trailing-else"]);
3894        assert_eq!(kinds("<if x>oops"), vec!["unclosed"]);
3895        assert_eq!(
3896            kinds(r#"<code><what-fetch url="/x">y</what-fetch></code>"#),
3897            vec!["raw-builtin-in-code"]
3898        );
3899        // Clean templates produce no findings
3900        assert!(collect_template_lints("<if a>x<else/>y</if>").is_empty());
3901        assert!(collect_template_lints("<p>plain</p>").is_empty());
3902        // escaped doc sample is clean
3903        assert!(collect_template_lints("&lt;what-fetch&gt; in prose").is_empty());
3904    }
3905
3906    #[test]
3907    fn test_escape_html_helper() {
3908        assert_eq!(escape_html(r#"<script>&"#), "&lt;script&gt;&amp;");
3909        assert_eq!(escape_html(r#"a"b"#), "a&quot;b");
3910    }
3911
3912    #[test]
3913    fn test_warn_template_lints_once_dedup() {
3914        let path = std::path::Path::new("/tmp/lint-test-template-a.html");
3915        let raw = r##"<if cond="#a#">x</if>"##;
3916        // First call warns, second is deduplicated
3917        assert!(warn_template_lints_once(path, raw));
3918        assert!(!warn_template_lints_once(path, raw));
3919        // Clean content never warns
3920        let clean_path = std::path::Path::new("/tmp/lint-test-template-b.html");
3921        assert!(!warn_template_lints_once(clean_path, "<if a>x<else/>y</if>"));
3922    }
3923
3924    #[test]
3925    fn test_find_outside_quotes_skips_quoted_segments() {
3926        assert_eq!(find_outside_quotes(r##"cond="#count# > 0">"##, ">"), Some(18));
3927        assert_eq!(find_outside_quotes("plain > here", ">"), Some(6));
3928        assert_eq!(find_outside_quotes(r#""all > quoted""#, ">"), None);
3929        assert_eq!(find_outside_quotes(r#"a='x > y'/>"#, "/>"), Some(9));
3930    }
3931
3932    #[test]
3933    fn test_find_tag_start_requires_word_boundary() {
3934        assert_eq!(find_tag_start("<iframe src='x'>", "<if"), None);
3935        assert_eq!(find_tag_start("<iframe><if a>", "<if"), Some(8));
3936        assert_eq!(find_tag_start("<if a == 1>", "<if"), Some(0));
3937        assert_eq!(find_tag_start("text <if>", "<if"), Some(5));
3938    }
3939
3940    #[tokio::test]
3941    async fn test_cond_attr_with_gt_symbol() {
3942        // Regression: the tag scanner used the first `>` in the document, so
3943        // `cond="#count# > 0"` truncated the tag and the condition never matched.
3944        let engine = make_engine();
3945        let mut ctx = HashMap::new();
3946        ctx.insert("count".to_string(), json!(5));
3947        let result = engine
3948            .render(r##"<if cond="#count# > 0">HAS_ITEMS</if>"##, &ctx)
3949            .await
3950            .unwrap();
3951        assert!(result.contains("HAS_ITEMS"), "got: {}", result);
3952
3953        ctx.insert("count".to_string(), json!(0));
3954        let result = engine
3955            .render(r##"<if cond="#count# > 0">HAS_ITEMS</if>"##, &ctx)
3956            .await
3957            .unwrap();
3958        assert!(!result.contains("HAS_ITEMS"), "got: {}", result);
3959    }
3960
3961    #[tokio::test]
3962    async fn test_cond_attr_with_gte_symbol_and_elseif() {
3963        let engine = make_engine();
3964        let mut ctx = HashMap::new();
3965        ctx.insert("score".to_string(), json!(50));
3966        let tpl = r##"<if cond="#score# >= 90">GRADE_A<elseif cond="#score# >= 50"/>GRADE_PASS<else/>GRADE_FAIL</if>"##;
3967        let result = engine.render(tpl, &ctx).await.unwrap();
3968        assert!(result.contains("GRADE_PASS"), "got: {}", result);
3969        assert!(!result.contains("GRADE_A"), "got: {}", result);
3970        assert!(!result.contains("GRADE_FAIL"), "got: {}", result);
3971    }
3972
3973    #[tokio::test]
3974    async fn test_unless_cond_attr_with_gt_symbol() {
3975        let engine = make_engine();
3976        let mut ctx = HashMap::new();
3977        ctx.insert("count".to_string(), json!(0));
3978        let result = engine
3979            .render(r##"<unless cond="#count# > 0">EMPTY</unless>"##, &ctx)
3980            .await
3981            .unwrap();
3982        assert!(result.contains("EMPTY"), "got: {}", result);
3983    }
3984
3985    #[tokio::test]
3986    async fn test_simplified_if_quoted_operand_with_spaces() {
3987        // Quoted operands containing spaces must survive wrapping and comparison.
3988        let engine = make_engine();
3989        let mut ctx = HashMap::new();
3990        ctx.insert("status".to_string(), json!("up and running"));
3991        let result = engine
3992            .render(r#"<if status == "up and running">HEALTHY</if>"#, &ctx)
3993            .await
3994            .unwrap();
3995        assert!(result.contains("HEALTHY"), "got: {}", result);
3996    }
3997
3998    #[tokio::test]
3999    async fn test_simplified_if_quoted_gt_does_not_truncate_tag() {
4000        // A literal `>` inside a quoted operand must not terminate the tag.
4001        // (Note: comparison operands are HTML-escaped during resolution, so a
4002        // value containing `>` never equals its literal — use `!=` to prove the
4003        // tag boundary without tripping over escaping.)
4004        let engine = make_engine();
4005        let mut ctx = HashMap::new();
4006        ctx.insert("note".to_string(), json!("something else"));
4007        let result = engine
4008            .render(r#"<if note != "x > y">DIFF</if>"#, &ctx)
4009            .await
4010            .unwrap();
4011        // Truncation at the quoted `>` would leak `y">` into the body.
4012        assert_eq!(result.trim(), "DIFF", "got: {}", result);
4013    }
4014
4015    #[tokio::test]
4016    async fn test_iframe_not_mistaken_for_if_tag() {
4017        // `<iframe` must not match the `<if` scanner: before a real <if> it would
4018        // swallow content up to the real </if>; inside a body it corrupts depth.
4019        let engine = make_engine();
4020        let mut ctx = HashMap::new();
4021        ctx.insert("flag".to_string(), json!(true));
4022        let tpl = r#"<iframe src="/embed"></iframe><p>KEEP</p><if flag><iframe src="/inner"></iframe>YES</if>"#;
4023        let result = engine.render(tpl, &ctx).await.unwrap();
4024        assert!(result.contains("<iframe src=\"/embed\">"), "got: {}", result);
4025        assert!(result.contains("KEEP"), "got: {}", result);
4026        assert!(result.contains("YES"), "got: {}", result);
4027        assert!(result.contains("<iframe src=\"/inner\">"), "got: {}", result);
4028    }
4029
4030    #[tokio::test]
4031    async fn test_numeric_gt_keyword() {
4032        let engine = make_engine();
4033        let mut ctx = HashMap::new();
4034        ctx.insert("age".to_string(), json!(25));
4035        let result = engine
4036            .render("<if age gt 18>Adult</if>", &ctx)
4037            .await
4038            .unwrap();
4039        assert!(result.contains("Adult"));
4040    }
4041
4042    #[tokio::test]
4043    async fn test_numeric_lte_keyword() {
4044        let engine = make_engine();
4045        let mut ctx = HashMap::new();
4046        ctx.insert("count".to_string(), json!(5));
4047        let result = engine
4048            .render("<if count lte 5>Ok</if>", &ctx)
4049            .await
4050            .unwrap();
4051        assert!(result.contains("Ok"));
4052    }
4053
4054    #[tokio::test]
4055    async fn test_numeric_gt_cond_attr() {
4056        let engine = make_engine();
4057        let mut ctx = HashMap::new();
4058        ctx.insert("age".to_string(), json!(25));
4059        let result = engine
4060            .render(r##"<if cond="#age# > 18">Adult</if>"##, &ctx)
4061            .await
4062            .unwrap();
4063        assert!(result.contains("Adult"));
4064    }
4065
4066    // =========================================================================
4067    // Nested Loop Tests
4068    // =========================================================================
4069
4070    #[tokio::test]
4071    async fn test_nested_loop() {
4072        let engine = make_engine();
4073        let mut ctx = HashMap::new();
4074        ctx.insert(
4075            "categories".to_string(),
4076            json!([
4077                {"name": "Fruit", "items": [{"label": "Apple"}, {"label": "Banana"}]},
4078                {"name": "Veggie", "items": [{"label": "Carrot"}]},
4079            ]),
4080        );
4081        let template = r##"<loop data="#categories#" as="cat"><h2>#cat.name#</h2><loop data="#cat.items#" as="item"><li>#item.label#</li></loop></loop>"##;
4082        let result = engine.render(template, &ctx).await.unwrap();
4083        assert!(result.contains("Fruit"));
4084        assert!(result.contains("Apple"));
4085        assert!(result.contains("Banana"));
4086        assert!(result.contains("Veggie"));
4087        assert!(result.contains("Carrot"));
4088    }
4089
4090    #[tokio::test]
4091    async fn test_nested_loop_three_levels() {
4092        let engine = make_engine();
4093        let mut ctx = HashMap::new();
4094        ctx.insert(
4095            "data".to_string(),
4096            json!([
4097                {"groups": [{"items": ["a", "b"]}]}
4098            ]),
4099        );
4100        let template = r##"<loop data="#data#" as="d"><loop data="#d.groups#" as="g"><loop data="#g.items#" as="i">[#i#]</loop></loop></loop>"##;
4101        let result = engine.render(template, &ctx).await.unwrap();
4102        assert!(
4103            result.contains("[a]"),
4104            "Should contain [a], got: {}",
4105            result
4106        );
4107        assert!(
4108            result.contains("[b]"),
4109            "Should contain [b], got: {}",
4110            result
4111        );
4112    }
4113
4114    #[tokio::test]
4115    async fn test_nested_loop_empty_inner() {
4116        let engine = make_engine();
4117        let mut ctx = HashMap::new();
4118        ctx.insert(
4119            "categories".to_string(),
4120            json!([
4121                {"name": "Empty", "items": []},
4122            ]),
4123        );
4124        let template = r##"<loop data="#categories#" as="cat"><h2>#cat.name#</h2><loop data="#cat.items#" as="item"><li>#item.label#</li></loop></loop>"##;
4125        let result = engine.render(template, &ctx).await.unwrap();
4126        assert!(result.contains("Empty"));
4127        assert!(!result.contains("<li>"));
4128    }
4129
4130    #[tokio::test]
4131    async fn test_backward_compat_cond() {
4132        let engine = make_engine();
4133        let mut ctx = HashMap::new();
4134        ctx.insert("show".to_string(), json!(true));
4135        let result = engine
4136            .render(r##"<if cond="#show#">Visible</if>"##, &ctx)
4137            .await
4138            .unwrap();
4139        assert!(result.contains("Visible"));
4140    }
4141}