1use regex::Regex;
7use serde_json::Value;
8use std::collections::HashMap;
9use std::sync::LazyLock;
10
11pub(crate) fn escape_html(s: &str) -> String {
14 s.replace('&', "&")
15 .replace('<', "<")
16 .replace('>', ">")
17 .replace('"', """)
18}
19
20fn escape_for_banner(s: &str) -> String {
22 escape_html(s)
23}
24
25fn 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
33const BUILTIN_TAGS: &[&str] = &[
36 "what-pagination",
37 "what-turnstile",
38 "what-fetch",
39 "what-clipboard",
40 "what-theme-toggle",
41];
42
43fn 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
53static POLL_INTERVAL_RE: LazyLock<Regex> =
55 LazyLock::new(|| Regex::new(r"^\d+(ms|s|m|h)?$").unwrap());
56
57static CODE_BLOCK_RE: LazyLock<Regex> =
59 LazyLock::new(|| Regex::new(r"(?s)<code\b[^>]*>.*?</code>").unwrap());
60
61static UNRESOLVED_VAR_RE: LazyLock<Regex> =
63 LazyLock::new(|| Regex::new(r"#([a-zA-Z_][\w.]*(?:\|[^#]+)?)#").unwrap());
64
65static DOUBLE_QUOTE_ATTR_RE: LazyLock<Regex> =
67 LazyLock::new(|| Regex::new(r#"([a-zA-Z_][\w-]*)\s*=\s*"([^"]*)""#).unwrap());
68
69static 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
77enum CompareOp {
79 Eq,
80 Ne,
81 Gt,
82 Lt,
83 Gte,
84 Lte,
85}
86
87fn 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 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 if c.is_ascii_whitespace() || b"!=<>".contains(&c) {
116 result.push(c as char);
117 i += 1;
118 continue;
119 }
120
121 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 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
160fn 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 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; 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
192fn 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 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
235fn 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 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; }
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
285static LEGACY_COND_RE: LazyLock<Regex> =
288 LazyLock::new(|| Regex::new(r"<(?:if|elseif|unless)\b[^>]*\bcond\s*=").unwrap());
289
290static TRAILING_ELSE_RE: LazyLock<Regex> =
293 LazyLock::new(|| Regex::new(r"</if>\s*<else\s*/?>").unwrap());
294
295static 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
299fn 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
311pub(crate) struct TemplateLint {
315 pub kind: &'static str, pub message: String,
317}
318
319pub(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 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 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: <{}>",
377 tag, tag
378 ),
379 });
380 }
381 }
382
383 lints
384}
385
386pub(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
407fn 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
423struct LoopInfo {
425 start: usize,
426 end: usize,
427 data_attr: String,
428 alias: String,
429 body: String,
430 per_page: Option<usize>,
432 page_expr: Option<String>,
434}
435
436pub struct RenderEngine {
438 components: ComponentRegistry,
439}
440
441impl RenderEngine {
442 pub fn new(components: ComponentRegistry) -> Self {
443 Self { components }
444 }
445
446 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 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 let t0 = std::time::Instant::now();
463 output = self.process_includes(&output, context)?;
464 let t_includes = t0.elapsed();
465
466 output = Self::process_section_auth(&output, context)?;
468
469 let t1 = std::time::Instant::now();
471 output = self.process_loops_html(&output, context)?;
472 let t_loops = t1.elapsed();
473
474 let t2 = std::time::Instant::now();
476 output = self.process_conditionals_html(&output, context)?;
477 let t_conditionals = t2.elapsed();
478
479 let t3 = std::time::Instant::now();
481 output = self.process_custom_tags_html(&output, context)?;
482 let t_components = t3.elapsed();
483
484 let t4 = std::time::Instant::now();
486 output = self.process_conditionals_html(&output, context)?;
487 let t_conditionals2 = t4.elapsed();
488
489 if let Some(secret) = validation_secret {
491 let (processed, _actions) = Self::process_validated_forms(&output, secret);
492 output = processed;
493 }
494
495 let (protected, code_blocks) = Self::protect_code_blocks(&output);
497
498 let t5 = std::time::Instant::now();
500 let replaced = replace_variables(&protected, context);
501 let t_vars = t5.elapsed();
502
503 output = Self::restore_code_blocks(&replaced, &code_blocks);
505
506 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 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 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 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 output = self.process_includes(&output, context)?;
558
559 output = Self::process_section_auth(&output, context)?;
561
562 output = self.process_loops_html(&output, context)?;
564
565 output = self.process_conditionals_html(&output, context)?;
567
568 output = self.process_custom_tags_html(&output, context)?;
570
571 output = self.process_conditionals_html(&output, context)?;
573
574 if let Some(secret) = validation_secret {
576 let (processed, _actions) = Self::process_validated_forms(&output, secret);
577 output = processed;
578 }
579
580 let (protected, code_blocks) = Self::protect_code_blocks(&output);
582
583 let mut result = replace_variables_reactive(&protected, context);
585
586 result.html = Self::restore_code_blocks(&result.html, &code_blocks);
588
589 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 fn process_validated_forms(html: &str, secret: &str) -> (String, Vec<String>) {
610 use crate::validation;
611
612 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 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 let rules = validation::parse_form_rules(form_body);
634 if rules.fields.is_empty() {
635 continue;
636 }
637
638 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 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 output.insert_str(form_tag_end, &hidden_field);
652 offset += hidden_field.len() as isize;
653
654 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
672fn 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 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 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 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 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 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 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 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 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 result.push_str(&html[pos..abs_tag_end]);
767 result.push_str(&placeholder);
768 pos = abs_close; }
770
771 (result, blocks)
772 }
773
774 fn process_section_auth(html: &str, context: &HashMap<String, Value>) -> Result<String> {
778 static AUTH_ATTR: LazyLock<Regex> = LazyLock::new(|| {
782 Regex::new(r#"(?i)<(\w+)\s[^>]*\bauth\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>"#).unwrap()
783 });
784
785 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 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; 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 output = format!("{}{}", &output[..match_start], &output[after_open..]);
881 }
882 }
883
884 Ok(output)
885 }
886
887 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 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; 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 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 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 let (stripped_content, declared_attrs) =
950 self.parse_what_block(&content);
951
952 let mut include_context = context.clone();
954
955 for (key, value) in &attrs {
960 let resolved_value = replace_variables(value, context);
961 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 let mut result = stripped_content;
977 for (key, default_value) in &declared_attrs {
978 let value = attrs.get(key).unwrap_or(default_value);
980 let resolved_value = replace_variables(value, context);
982 result = result.replace(&format!("#{}#", key), &resolved_value);
984 }
985
986 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 fn parse_what_block(&self, content: &str) -> (String, HashMap<String, String>) {
1041 let mut defaults = HashMap::new();
1042
1043 if let Some(start) = content.find("<what>") {
1045 if let Some(end) = content.find("</what>") {
1046 let what_content = &content[start + 6..end];
1048
1049 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(); let value = line[eq_pos + 1..].trim();
1057 let value = crate::parser::strip_symmetric_quotes(value).0;
1059 defaults.insert(key.to_string(), value.to_string());
1060 }
1061 }
1062 }
1063
1064 let before = &content[..start];
1066 let after = &content[end + 7..]; return (format!("{}{}", before.trim_start(), after), defaults);
1068 }
1069 }
1070
1071 (content.to_string(), defaults)
1072 }
1073
1074 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 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 let attrs = self.parse_tag_attributes(tag_content);
1099 let src = attrs.get("src")?.clone();
1100
1101 let mut pass_attrs = attrs;
1103 pass_attrs.remove("src");
1104
1105 Some((start, start + end_offset, src, pass_attrs))
1106 }
1107
1108 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 while output.contains("<loop") && iterations < MAX_ITERATIONS {
1120 iterations += 1;
1121
1122 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 fn find_outermost_loop(&self, html: &str) -> Option<LoopInfo> {
1148 self.find_loop_manual(html)
1149 }
1150
1151 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 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 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 fn extract_attr(&self, tag: &str, attr_name: &str) -> Option<String> {
1226 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 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 let var_name = data_expr.trim_matches('#');
1268 let parts: Vec<&str> = var_name.split('.').collect();
1269
1270 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 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 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 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 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 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 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 output = self.process_if_tags(&output, context)?;
1410
1411 output = self.process_unless_tags(&output, context)?;
1413
1414 Ok(output)
1415 }
1416
1417 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 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 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 let tag_content = &html[start..tag_end];
1456 let condition = self.extract_attr(tag_content, "cond").unwrap_or_else(|| {
1457 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 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 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 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 (Some(ei_pos), Some(e_pos)) if ei_pos < e_pos => {
1512 branches.push((current_condition.clone(), remaining[..ei_pos].to_string()));
1514
1515 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 (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 (_, Some(e_pos)) => {
1548 branches.push((current_condition.clone(), remaining[..e_pos].to_string()));
1549
1550 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 };
1559 let else_body = remaining[e_pos + else_len..].to_string();
1560 return (branches, Some(else_body));
1561 }
1562 (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 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 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 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 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 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 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 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 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 let condition = if !condition.contains('#') {
1741 wrap_bare_variables(condition)
1742 } else {
1743 condition.to_string()
1744 };
1745 let condition = condition.trim();
1746
1747 let condition = map_keyword_operators(condition);
1750 let condition = condition.trim();
1751
1752 if condition.starts_with('!') {
1754 return !self.evaluate_condition(&condition[1..], context);
1755 }
1756
1757 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 if let Some(idx) = find_outside_quotes(condition, op) {
1770 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 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 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 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 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 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 let resolved = replace_variables(condition, context);
1851 if resolved != condition.to_string() {
1853 return match resolved.as_str() {
1854 "" | "false" | "null" | "0" => false,
1855 _ => true,
1856 };
1857 }
1858
1859 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 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 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 if current > 1 {
1993 html.push_str(&format!(
1994 r#"<li><a href="{}?{}={}" class="what-pagination-prev" aria-label="Previous page">«</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">«</span></li>"#);
1999 }
2000
2001 for &num in &page_numbers {
2003 if num == 0 {
2004 html.push_str(r#"<li><span class="what-pagination-ellipsis">…</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 if current < total_pages {
2021 html.push_str(&format!(
2022 r#"<li><a href="{}?{}={}" class="what-pagination-next" aria-label="Next page">»</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">»</span></li>"#);
2027 }
2028
2029 html.push_str("</ul></nav>");
2030 html
2031 }
2032
2033 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); }
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); }
2060
2061 pages.push(total);
2062 pages
2063 }
2064
2065 fn render_turnstile(
2069 attrs: &HashMap<String, String>,
2070 context: &HashMap<String, Value>,
2071 ) -> String {
2072 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 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 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" => {
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 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 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 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 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 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 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 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 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 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 output = format!("{}{}{}", &output[..start], rendered, &output[end..]);
2396 found_any = true;
2397 break; }
2399 }
2400 }
2401
2402 iterations += 1;
2403 if !found_any || iterations >= MAX_ITERATIONS {
2404 break;
2405 }
2406 }
2407
2408 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 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 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><{}></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 fn find_custom_tag(
2456 &self,
2457 html: &str,
2458 tag_name: &str,
2459 ) -> Option<(usize, usize, HashMap<String, String>, String)> {
2460 let open_pattern = format!("<{}", tag_name);
2463 let start = find_tag_start(html, &open_pattern)?;
2464
2465 let tag_start_rest = &html[start..];
2468 let open_tag_end = find_outside_quotes(tag_start_rest, ">")? + start + 1;
2469
2470 let open_tag = &html[start..open_tag_end];
2472 let attrs = self.parse_tag_attributes(open_tag);
2473
2474 if open_tag.ends_with("/>") {
2476 return Some((start, open_tag_end, attrs, String::new()));
2477 }
2478
2479 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 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 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 fn parse_tag_attributes(&self, tag: &str) -> HashMap<String, String> {
2524 let mut attrs = HashMap::new();
2525
2526 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 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 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 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 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 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 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 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 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 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 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 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 let mut registry = ComponentRegistry::new();
2812 registry.register_builtins();
2813
2814 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 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 assert!(result.contains("<!DOCTYPE html>"), "Should have doctype");
2850 assert!(result.contains("<title>Test</title>"), "Should have title");
2851
2852 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 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 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 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 #[test]
3009 fn test_unclosed_if_lint_fires() {
3010 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 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 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 #[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 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 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 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 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 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 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 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 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 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><what-fetch url="/w-partial/stats" poll="5s"></what-fetch></code>"#;
3311 let html = engine.render(template, &ctx).await.unwrap();
3312 assert!(html.contains("<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 assert!(!warn_template_lints_once(
3324 std::path::Path::new("/lint-test/escaped-builtin-in-code.html"),
3325 r#"<code><what-fetch url="/x">sample</what-fetch></code>"#
3326 ));
3327 }
3328
3329 #[test]
3330 fn test_include_tag_gt_inside_attr_value() {
3331 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 assert_eq!(
3708 split_top_level_bool("a == 1 and b == 2", "and"),
3709 vec!["a == 1", "b == 2"]
3710 );
3711 assert_eq!(split_top_level_bool("a == 1", "and"), vec!["a == 1"]);
3713 assert_eq!(
3715 split_top_level_bool(r#"status == "up and running""#, "and"),
3716 vec![r#"status == "up and running""#]
3717 );
3718 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 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 let engine = make_engine();
3778 let tpl = "<if a or b and c>YES</if>";
3779
3780 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 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 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 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 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 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 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 assert!(!LEGACY_COND_RE.is_match("<if count gt 0>x</if>"));
3875 assert!(!LEGACY_COND_RE.is_match("<if cond=\"#a#\">"));
3876 assert!(!LEGACY_COND_RE.is_match("<iframe cond=\"x\">"));
3877 assert!(!LEGACY_COND_RE.is_match("<if conditional_flag>x</if>"));
3878
3879 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 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 assert!(collect_template_lints("<if a>x<else/>y</if>").is_empty());
3901 assert!(collect_template_lints("<p>plain</p>").is_empty());
3902 assert!(collect_template_lints("<what-fetch> in prose").is_empty());
3904 }
3905
3906 #[test]
3907 fn test_escape_html_helper() {
3908 assert_eq!(escape_html(r#"<script>&"#), "<script>&");
3909 assert_eq!(escape_html(r#"a"b"#), "a"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 assert!(warn_template_lints_once(path, raw));
3918 assert!(!warn_template_lints_once(path, raw));
3919 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 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 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 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 assert_eq!(result.trim(), "DIFF", "got: {}", result);
4013 }
4014
4015 #[tokio::test]
4016 async fn test_iframe_not_mistaken_for_if_tag() {
4017 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 #[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}