1use super::{AttributeAction, ParserPlugin, ParserPluginArtifacts};
64use crate::component_registry::Component;
65use crate::{ConditionParser, CssStrategy, DomStrategy, Result};
66use std::cell::Cell;
67use std::fmt::Write;
68use webui_protocol::{condition_expr, ConditionExpr, WebUIElementData};
69
70struct TrackedComponent {
74 tag_name: String,
75 template_html: String,
76 root_event_source: String,
77}
78
79pub struct WebUIParserPlugin {
92 components: Vec<TrackedComponent>,
93 element_events: Cell<u32>,
95 next_event_idx: Cell<u32>,
97 dom_strategy: DomStrategy,
99}
100
101impl WebUIParserPlugin {
102 #[must_use]
103 pub fn new() -> Self {
104 Self {
105 components: Vec::new(),
106 element_events: Cell::new(0),
107 next_event_idx: Cell::new(0),
108 dom_strategy: DomStrategy::default(),
109 }
110 }
111
112 pub fn set_css_strategy(&mut self, strategy: CssStrategy) {
113 let _ = strategy;
114 }
115
116 pub fn set_dom_strategy(&mut self, strategy: DomStrategy) {
117 self.dom_strategy = strategy;
118 }
119
120 #[must_use]
126 pub fn take_component_templates(&self) -> Vec<(String, String)> {
127 let use_shadow = matches!(self.dom_strategy, DomStrategy::Shadow);
128 self.components
129 .iter()
130 .map(|c| {
131 let script = generate_compiled_template_with_root_source(
132 &c.tag_name,
133 &c.template_html,
134 &c.root_event_source,
135 use_shadow,
136 );
137 (c.tag_name.clone(), script)
138 })
139 .collect()
140 }
141
142 fn store_component_template(
143 &mut self,
144 tag_name: &str,
145 template_html: &str,
146 root_event_source: &str,
147 ) {
148 if let Some(component) = self.components.iter_mut().find(|c| c.tag_name == tag_name) {
149 component.template_html.clear();
150 component.template_html.push_str(template_html);
151 component.root_event_source.clear();
152 component.root_event_source.push_str(root_event_source);
153 return;
154 }
155
156 self.components.push(TrackedComponent {
157 tag_name: tag_name.to_string(),
158 template_html: template_html.to_string(),
159 root_event_source: root_event_source.to_string(),
160 });
161 }
162}
163
164impl Default for WebUIParserPlugin {
165 fn default() -> Self {
166 Self::new()
167 }
168}
169
170impl ParserPlugin for WebUIParserPlugin {
171 fn start_fragment(&mut self, _fragment_id: &str) {
172 self.element_events.set(0);
173 self.next_event_idx.set(0);
174 }
175
176 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
177 if attr_name.starts_with('@') {
178 self.element_events.set(self.element_events.get() + 1);
179 return AttributeAction::Skip;
180 }
181 AttributeAction::Keep
182 }
183
184 fn register_component_template(
185 &mut self,
186 tag_name: &str,
187 component: &Component,
188 processed_template: &str,
189 ) -> Result<()> {
190 self.store_component_template(tag_name, processed_template, &component.html_content);
191 Ok(())
192 }
193
194 fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
195 let ev_count = self.element_events.get();
196 let ev_start = self.next_event_idx.get();
197
198 self.next_event_idx.set(ev_start + ev_count);
200 self.element_events.set(0);
202
203 if binding_attribute_count == 0 && ev_count == 0 {
204 return None;
205 }
206
207 Some(
208 WebUIElementData {
209 binding_count: binding_attribute_count,
210 event_start: ev_start,
211 event_count: ev_count,
212 }
213 .encode()
214 .to_vec(),
215 )
216 }
217
218 fn into_artifacts(self: Box<Self>) -> ParserPluginArtifacts {
219 ParserPluginArtifacts::ComponentTemplates(self.take_component_templates())
220 }
221}
222
223#[derive(Default)]
233struct TemplateSectionMeta {
234 html: String,
236 text_bindings: Vec<(String, bool)>,
240 text_runs: Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
243 attr_bindings: Vec<CompiledAttrBinding>,
245 attr_groups: Vec<(Vec<usize>, usize, usize)>,
247 conditionals: Vec<(ConditionExpr, usize)>,
249 condition_slots: Vec<SlotLocator>,
251 repeats: Vec<(String, String, usize)>,
253 repeat_slots: Vec<SlotLocator>,
255 events: Vec<(String, String, bool)>,
257 event_targets: Vec<Vec<usize>>,
259}
260
261#[derive(Clone)]
262struct SlotLocator {
263 parent_path: Vec<usize>,
264 before_index: usize,
265 order: usize,
266}
267
268struct TemplateMeta {
273 root: TemplateSectionMeta,
274 blocks: Vec<TemplateSectionMeta>,
275 root_events: Vec<(String, String, bool)>,
278}
279
280enum CompiledAttrBinding {
281 Simple {
282 name: String,
283 value: String,
284 },
285 Complex {
286 name: String,
287 value: String,
288 },
289 Boolean {
290 name: String,
291 condition: ConditionExpr,
292 },
293 Template {
294 name: String,
295 parts: Vec<CompiledAttrPart>,
296 },
297}
298
299enum CompiledAttrPart {
300 Static(String),
301 Dynamic(String),
302}
303
304pub fn generate_compiled_template(tag_name: &str, html_content: &str) -> String {
335 generate_compiled_template_with_root_source(tag_name, html_content, html_content, false)
336}
337
338fn generate_compiled_template_with_root_source(
339 tag_name: &str,
340 html_content: &str,
341 root_event_source: &str,
342 shadow_dom: bool,
343) -> String {
344 let trimmed = html_content.trim();
345 let root_events = extract_root_events(root_event_source.trim());
346 let adopted_stylesheet = extract_adopted_stylesheet_specifier(trimmed);
347 let body = strip_template_wrapper(trimmed);
348 let meta = compile_to_metadata(body, root_events);
349
350 let mut out = String::with_capacity(512 + html_content.len());
351 out.push_str("(function(){var w=(window.__webui||(window.__webui={})).templates||(window.__webui.templates={});w['");
352 out.push_str(tag_name);
353 out.push_str("']={");
354
355 emit_js_template_section(&meta.root, &mut out);
356
357 if let Some(adopted_stylesheet) = adopted_stylesheet.as_deref() {
358 out.push_str(",sa:");
359 emit_js_string(adopted_stylesheet, &mut out);
360 }
361
362 if shadow_dom {
364 out.push_str(",sd:1");
365 }
366
367 if !meta.root_events.is_empty() {
369 out.push_str(",re:[");
370 for (i, (event, handler, needs_event)) in meta.root_events.iter().enumerate() {
371 if i > 0 {
372 out.push(',');
373 }
374 out.push('[');
375 emit_js_string(event, &mut out);
376 out.push(',');
377 emit_js_string(handler, &mut out);
378 out.push(',');
379 out.push(if *needs_event { '1' } else { '0' });
380 out.push(']');
381 }
382 out.push(']');
383 }
384
385 if !meta.blocks.is_empty() {
387 out.push_str(",b:[");
388 for (i, block) in meta.blocks.iter().enumerate() {
389 if i > 0 {
390 out.push(',');
391 }
392 out.push('{');
393 emit_js_template_section(block, &mut out);
394 out.push('}');
395 }
396 out.push(']');
397 }
398
399 out.push_str("};})();\n");
400 out
401}
402
403fn emit_js_string(s: &str, out: &mut String) {
404 out.push('"');
405 let mut index = 0usize;
406 while index < s.len() {
407 let remaining = &s[index..];
408 if remaining.as_bytes().starts_with(b"</")
409 && remaining
410 .as_bytes()
411 .get(2..8)
412 .map(|bytes| bytes.eq_ignore_ascii_case(b"script"))
413 .unwrap_or(false)
414 {
415 out.push_str("\\u003C");
416 index += 1;
417 continue;
418 }
419 let Some(ch) = remaining.chars().next() else {
420 break;
421 };
422 match ch {
423 '\0' => out.push_str("\\0"),
424 '"' => out.push_str("\\\""),
425 '\\' => out.push_str("\\\\"),
426 '\n' => out.push_str("\\n"),
427 '\r' => out.push_str("\\r"),
428 '\u{2028}' => out.push_str("\\u2028"),
429 '\u{2029}' => out.push_str("\\u2029"),
430 _ => out.push(ch),
431 }
432 index += ch.len_utf8();
433 }
434 out.push('"');
435}
436
437fn emit_js_attr_binding(binding: &CompiledAttrBinding, out: &mut String) {
438 out.push('[');
439 match binding {
440 CompiledAttrBinding::Simple { name, value } => {
441 emit_js_string(name, out);
442 out.push_str(",0,");
443 emit_js_string(value, out);
444 }
445 CompiledAttrBinding::Complex { name, value } => {
446 emit_js_string(name, out);
447 out.push_str(",1,");
448 emit_js_string(value, out);
449 }
450 CompiledAttrBinding::Boolean { name, condition } => {
451 emit_js_string(name, out);
452 out.push_str(",2,");
453 emit_js_condition(condition, out);
454 }
455 CompiledAttrBinding::Template { name, parts } => {
456 emit_js_string(name, out);
457 out.push_str(",3,[");
458 for (i, part) in parts.iter().enumerate() {
459 if i > 0 {
460 out.push(',');
461 }
462 match part {
463 CompiledAttrPart::Static(value) => emit_js_string(value, out),
464 CompiledAttrPart::Dynamic(path) => {
465 out.push('[');
466 emit_js_string(path, out);
467 out.push(']');
468 }
469 }
470 }
471 out.push(']');
472 }
473 }
474 out.push(']');
475}
476
477fn emit_js_condition(condition: &ConditionExpr, out: &mut String) {
481 out.push_str("[function(v,s){return ");
482 emit_js_condition_expr(condition, out);
483 out.push_str("},[");
484 let mut paths = Vec::new();
485 collect_condition_paths(condition, &mut paths);
486 for (i, path) in paths.iter().enumerate() {
487 if i > 0 {
488 out.push(',');
489 }
490 emit_js_string(path, out);
491 }
492 out.push_str("]]");
493}
494
495fn emit_js_condition_expr(condition: &ConditionExpr, out: &mut String) {
497 match &condition.expr {
498 Some(condition_expr::Expr::Identifier(id)) => {
499 out.push_str("!!v(");
501 emit_js_string(&id.value, out);
502 out.push_str(",s)");
503 }
504 Some(condition_expr::Expr::Predicate(pred)) => {
505 out.push_str("v(");
507 emit_js_string(&pred.left, out);
508 out.push_str(",s)");
509 match pred.operator {
510 1 => out.push('>'), 2 => out.push('<'), 3 => out.push_str("=="), 4 => out.push_str("!="), 5 => out.push_str(">="), 6 => out.push_str("<="), _ => out.push_str("=="),
517 }
518 emit_js_predicate_right(&pred.right, out);
520 }
521 Some(condition_expr::Expr::Not(not_cond)) => {
522 out.push('!');
523 if let Some(inner) = not_cond.condition.as_ref() {
524 out.push('(');
525 emit_js_condition_expr(inner, out);
526 out.push(')');
527 } else {
528 out.push_str("false");
529 }
530 }
531 Some(condition_expr::Expr::Compound(compound)) => {
532 out.push('(');
533 if let Some(left) = compound.left.as_ref() {
534 emit_js_condition_expr(left, out);
535 } else {
536 out.push_str("false");
537 }
538 match compound.op {
539 1 => out.push_str("&&"), 2 => out.push_str("||"), _ => out.push_str("&&"),
542 }
543 if let Some(right) = compound.right.as_ref() {
544 emit_js_condition_expr(right, out);
545 } else {
546 out.push_str("false");
547 }
548 out.push(')');
549 }
550 None => out.push_str("false"),
551 }
552}
553
554fn emit_js_predicate_right(value: &str, out: &mut String) {
557 if (value.starts_with('"') && value.ends_with('"'))
559 || (value.starts_with('\'') && value.ends_with('\''))
560 {
561 emit_js_string(&value[1..value.len() - 1], out);
563 return;
564 }
565 if value == "true" || value == "false" {
567 out.push_str(value);
568 return;
569 }
570 if value
572 .bytes()
573 .all(|b| b.is_ascii_digit() || b == b'.' || b == b'-')
574 && !value.is_empty()
575 {
576 out.push_str(value);
577 return;
578 }
579 out.push_str("v(");
581 emit_js_string(value, out);
582 out.push_str(",s)");
583}
584
585fn collect_condition_paths(condition: &ConditionExpr, paths: &mut Vec<String>) {
587 match &condition.expr {
588 Some(condition_expr::Expr::Identifier(id)) => {
589 paths.push(id.value.clone());
590 }
591 Some(condition_expr::Expr::Predicate(pred)) => {
592 paths.push(pred.left.clone());
593 let r = &pred.right;
595 if !((r.starts_with('"') && r.ends_with('"'))
596 || (r.starts_with('\'') && r.ends_with('\''))
597 || r == "true"
598 || r == "false"
599 || r.bytes()
600 .all(|b| b.is_ascii_digit() || b == b'.' || b == b'-'))
601 {
602 paths.push(r.clone());
603 }
604 }
605 Some(condition_expr::Expr::Not(not_cond)) => {
606 if let Some(inner) = not_cond.condition.as_ref() {
607 collect_condition_paths(inner, paths);
608 }
609 }
610 Some(condition_expr::Expr::Compound(compound)) => {
611 if let Some(left) = compound.left.as_ref() {
612 collect_condition_paths(left, paths);
613 }
614 if let Some(right) = compound.right.as_ref() {
615 collect_condition_paths(right, paths);
616 }
617 }
618 None => {}
619 }
620}
621
622fn emit_js_node_path(path: &[usize], out: &mut String) {
623 out.push('[');
624 for (i, index) in path.iter().enumerate() {
625 if i > 0 {
626 out.push(',');
627 }
628 let _ = write!(out, "{}", index);
629 }
630 out.push(']');
631}
632
633fn emit_js_slot(slot: &SlotLocator, out: &mut String) {
634 out.push('[');
635 emit_js_node_path(&slot.parent_path, out);
636 out.push(',');
637 let _ = write!(out, "{}", slot.before_index);
638 if slot.order > 0 {
639 out.push(',');
640 let _ = write!(out, "{}", slot.order);
641 }
642 out.push(']');
643}
644
645fn emit_js_text_parts(parts: &[CompiledAttrPart], out: &mut String) {
646 out.push('[');
647 for (i, part) in parts.iter().enumerate() {
648 if i > 0 {
649 out.push(',');
650 }
651 match part {
652 CompiledAttrPart::Static(value) => emit_js_string(value, out),
653 CompiledAttrPart::Dynamic(path) => {
654 out.push('[');
655 emit_js_string(path, out);
656 out.push(']');
657 }
658 }
659 }
660 out.push(']');
661}
662
663fn emit_js_template_section(meta: &TemplateSectionMeta, out: &mut String) {
664 out.push_str("h:");
665 emit_js_string(&meta.html, out);
666
667 if !meta.text_runs.is_empty() {
668 out.push_str(",tx:[");
669 for (i, (slot, parts, raw)) in meta.text_runs.iter().enumerate() {
670 if i > 0 {
671 out.push(',');
672 }
673 out.push('[');
674 emit_js_slot(slot, out);
675 out.push(',');
676 emit_js_text_parts(parts, out);
677 if *raw {
678 out.push_str(",1");
679 }
680 out.push(']');
681 }
682 out.push(']');
683 }
684
685 if !meta.attr_bindings.is_empty() {
686 out.push_str(",a:[");
687 for (i, binding) in meta.attr_bindings.iter().enumerate() {
688 if i > 0 {
689 out.push(',');
690 }
691 emit_js_attr_binding(binding, out);
692 }
693 out.push(']');
694 }
695
696 if !meta.attr_groups.is_empty() {
697 out.push_str(",ag:[");
698 for (i, (path, start, count)) in meta.attr_groups.iter().enumerate() {
699 if i > 0 {
700 out.push(',');
701 }
702 out.push('[');
703 emit_js_node_path(path, out);
704 out.push(',');
705 let _ = write!(out, "{}", start);
706 out.push(',');
707 let _ = write!(out, "{}", count);
708 out.push(']');
709 }
710 out.push(']');
711 }
712
713 if !meta.conditionals.is_empty() {
714 out.push_str(",c:[");
715 for (i, ((cond, block_index), slot)) in meta
716 .conditionals
717 .iter()
718 .zip(meta.condition_slots.iter())
719 .enumerate()
720 {
721 if i > 0 {
722 out.push(',');
723 }
724 out.push('[');
725 emit_js_condition(cond, out);
726 out.push(',');
727 let _ = write!(out, "{}", block_index);
728 out.push(',');
729 emit_js_slot(slot, out);
730 out.push(']');
731 }
732 out.push(']');
733 }
734
735 if !meta.repeats.is_empty() {
736 out.push_str(",r:[");
737 for (i, ((collection, item_var, block_index), slot)) in meta
738 .repeats
739 .iter()
740 .zip(meta.repeat_slots.iter())
741 .enumerate()
742 {
743 if i > 0 {
744 out.push(',');
745 }
746 out.push('[');
747 emit_js_string(collection, out);
748 out.push(',');
749 emit_js_string(item_var, out);
750 out.push(',');
751 let _ = write!(out, "{}", block_index);
752 out.push(',');
753 emit_js_slot(slot, out);
754 out.push(']');
755 }
756 out.push(']');
757 }
758
759 if !meta.events.is_empty() {
760 out.push_str(",e:[");
761 for (i, ((event, handler, needs_event), target)) in meta
762 .events
763 .iter()
764 .zip(meta.event_targets.iter())
765 .enumerate()
766 {
767 if i > 0 {
768 out.push(',');
769 }
770 out.push('[');
771 emit_js_string(event, out);
772 out.push(',');
773 emit_js_string(handler, out);
774 out.push(',');
775 out.push(if *needs_event { '1' } else { '0' });
776 out.push(',');
777 emit_js_node_path(target, out);
778 out.push(']');
779 }
780 out.push(']');
781 }
782}
783
784fn compile_to_metadata(input: &str, root_events: Vec<(String, String, bool)>) -> TemplateMeta {
803 let mut blocks = Vec::new();
804 let mut root = compile_section(input, &mut blocks);
805 finalize_template_section(&mut root);
806 for block in &mut blocks {
807 finalize_template_section(block);
808 }
809 TemplateMeta {
810 root,
811 blocks,
812 root_events,
813 }
814}
815
816fn compile_section(input: &str, blocks: &mut Vec<TemplateSectionMeta>) -> TemplateSectionMeta {
817 let mut meta = TemplateSectionMeta {
818 html: String::with_capacity(input.len()),
819 text_bindings: Vec::new(),
820 text_runs: Vec::new(),
821 attr_bindings: Vec::new(),
822 attr_groups: Vec::new(),
823 conditionals: Vec::new(),
824 condition_slots: Vec::new(),
825 repeats: Vec::new(),
826 repeat_slots: Vec::new(),
827 events: Vec::new(),
828 event_targets: Vec::new(),
829 };
830
831 let bytes = input.as_bytes();
832 let len = bytes.len();
833 let mut i = 0;
834
835 while i < len {
836 if !input.is_char_boundary(i) {
837 meta.html.push(bytes[i] as char);
838 i += 1;
839 continue;
840 }
841
842 if i + 4 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' && bytes[i + 2] == b'{' {
844 if let Some(end) = find_brace_end(input, i + 3, 3) {
845 let expr = input[i + 3..end].trim();
846 let idx = meta.text_bindings.len();
847 meta.text_bindings.push((expr.to_string(), true));
848 meta.html.push_str(&format!("<!--t:{idx}-->"));
849 i = end + 3;
850 continue;
851 }
852 }
853
854 if i + 3 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
856 if let Some(end) = find_brace_end(input, i + 2, 2) {
857 let expr = input[i + 2..end].trim();
858 let idx = meta.text_bindings.len();
859 meta.text_bindings.push((expr.to_string(), false));
860 meta.html.push_str(&format!("<!--t:{idx}-->"));
861 i = end + 2;
862 continue;
863 }
864 }
865
866 if bytes[i] == b'<' {
868 let remaining = &input[i..];
869 if remaining.starts_with("<if ") || remaining.starts_with("<if\n") {
870 if let Some((cond, body, consumed)) = parse_if_block(remaining) {
871 let block_index = blocks.len();
872 blocks.push(TemplateSectionMeta::default());
873 let block = compile_section(&body, blocks);
874 blocks[block_index] = block;
875 let idx = meta.conditionals.len();
876 meta.conditionals.push((cond, block_index));
877 meta.html.push_str(&format!("<!--c:{idx}-->"));
878 i += consumed;
879 continue;
880 }
881 }
882
883 if remaining.starts_with("<for ") || remaining.starts_with("<for\n") {
885 if let Some((collection, item_var, body, consumed)) = parse_for_block(remaining) {
886 let block_index = blocks.len();
887 blocks.push(TemplateSectionMeta::default());
888 let block = compile_section(&body, blocks);
889 blocks[block_index] = block;
890 let idx = meta.repeats.len();
891 meta.repeats.push((collection, item_var, block_index));
892 meta.html.push_str(&format!("<!--r:{idx}-->"));
893 i += consumed;
894 continue;
895 }
896 }
897
898 if remaining.starts_with("<outlet") {
900 if let Some(close) = find_tag_end(remaining) {
901 meta.html.push_str("<outlet></outlet>");
902 i += close + 1;
903 continue;
904 }
905 }
906
907 if let Some((tag_html, consumed)) = parse_regular_tag(remaining, &mut meta) {
908 meta.html.push_str(&tag_html);
909 i += consumed;
910 continue;
911 }
912 }
913
914 if bytes[i] == b'@' && is_inside_tag(input, i) {
916 if let Some((event_name, handler, needs_event, consumed)) = parse_event_attr(input, i) {
917 meta.events.push((event_name, handler, needs_event));
918 meta.html.push_str("data-ev=\"1\"");
919 i += consumed;
920 continue;
921 }
922 }
923
924 let ch = &input[i..];
929 if let Some(c) = ch.chars().next() {
930 meta.html.push(c);
931 i += c.len_utf8();
932 } else {
933 i += 1;
934 }
935 }
936
937 meta
938}
939
940#[derive(Clone)]
941enum FragmentNode {
942 Element(FragmentElement),
943 Text(String),
944 Comment(String),
945}
946
947#[derive(Clone)]
948struct FragmentElement {
949 tag_name: String,
950 attrs: Vec<FragmentAttr>,
951 children: Vec<FragmentNode>,
952 self_closing: bool,
953}
954
955#[derive(Clone)]
956struct FragmentAttr {
957 name: String,
958 value: Option<String>,
959}
960
961fn finalize_template_section(meta: &mut TemplateSectionMeta) {
962 let raw_html = std::mem::take(&mut meta.html);
963 let nodes = parse_fragment_nodes(&raw_html);
964 let text_bindings = meta.text_bindings.clone();
965 let mut finalized_html = String::with_capacity(raw_html.len());
966 let mut text_runs = Vec::new();
967 let mut attr_groups = Vec::new();
968 let mut condition_slots = vec![None; meta.conditionals.len()];
969 let mut repeat_slots = vec![None; meta.repeats.len()];
970 let mut event_targets = vec![None; meta.events.len()];
971 let mut event_cursor = 0usize;
972
973 process_fragment_children(
974 &nodes,
975 &[],
976 &text_bindings,
977 &mut finalized_html,
978 &mut text_runs,
979 &mut attr_groups,
980 &mut condition_slots,
981 &mut repeat_slots,
982 &mut event_targets,
983 &mut event_cursor,
984 );
985 debug_assert_eq!(event_cursor, meta.events.len());
986
987 meta.html = finalized_html;
988 meta.text_runs = text_runs;
989 meta.attr_groups = attr_groups;
990 meta.condition_slots = condition_slots.into_iter().flatten().collect();
991 meta.repeat_slots = repeat_slots.into_iter().flatten().collect();
992 meta.event_targets = event_targets.into_iter().flatten().collect();
993}
994
995#[allow(clippy::too_many_arguments)]
996fn process_fragment_children(
997 nodes: &[FragmentNode],
998 parent_path: &[usize],
999 text_bindings: &[(String, bool)],
1000 out: &mut String,
1001 text_runs: &mut Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
1002 attr_groups: &mut Vec<(Vec<usize>, usize, usize)>,
1003 condition_slots: &mut [Option<SlotLocator>],
1004 repeat_slots: &mut [Option<SlotLocator>],
1005 event_targets: &mut [Option<Vec<usize>>],
1006 event_cursor: &mut usize,
1007) {
1008 let mut child_index = 0usize;
1009 let mut index = 0usize;
1010 let mut previous_emitted_text = false;
1013 let mut slot_orders = std::collections::BTreeMap::<usize, usize>::new();
1014
1015 while index < nodes.len() {
1016 if let Some((parts, consumed, has_dynamic, is_raw)) =
1017 collect_text_run(&nodes[index..], text_bindings)
1018 {
1019 if has_dynamic {
1020 let decoded_parts = parts
1026 .into_iter()
1027 .map(|part| match part {
1028 CompiledAttrPart::Static(s) => {
1029 let decoded = html_escape::decode_html_entities(&s);
1030 CompiledAttrPart::Static(decoded.into_owned())
1031 }
1032 other => other,
1033 })
1034 .collect();
1035 let order = slot_orders.get(&child_index).copied().unwrap_or(0);
1036 slot_orders.insert(child_index, order + 1);
1037 text_runs.push((
1038 SlotLocator {
1039 parent_path: parent_path.to_vec(),
1040 before_index: child_index,
1041 order,
1042 },
1043 decoded_parts,
1044 is_raw,
1045 ));
1046 } else {
1047 let static_text = collect_static_text(&parts);
1048 if !static_text.is_empty() {
1049 out.push_str(&static_text);
1050 if !previous_emitted_text {
1051 child_index += 1;
1052 }
1053 previous_emitted_text = true;
1054 }
1055 }
1056 index += consumed;
1057 continue;
1058 }
1059
1060 match &nodes[index] {
1061 FragmentNode::Comment(data) => {
1062 if let Some(idx) = parse_marker_index(data, "c:") {
1063 if let Some(slot) = condition_slots.get_mut(idx) {
1064 let order = slot_orders.get(&child_index).copied().unwrap_or(0);
1065 slot_orders.insert(child_index, order + 1);
1066 *slot = Some(SlotLocator {
1067 parent_path: parent_path.to_vec(),
1068 before_index: child_index,
1069 order,
1070 });
1071 }
1072 } else if let Some(idx) = parse_marker_index(data, "r:") {
1073 if let Some(slot) = repeat_slots.get_mut(idx) {
1074 let order = slot_orders.get(&child_index).copied().unwrap_or(0);
1075 slot_orders.insert(child_index, order + 1);
1076 *slot = Some(SlotLocator {
1077 parent_path: parent_path.to_vec(),
1078 before_index: child_index,
1079 order,
1080 });
1081 }
1082 } else {
1083 out.push_str("<!--");
1084 out.push_str(data);
1085 out.push_str("-->");
1086 child_index += 1;
1087 previous_emitted_text = false;
1088 }
1089 }
1090 FragmentNode::Text(text) => {
1091 out.push_str(text);
1092 if !text.is_empty() {
1093 if !previous_emitted_text {
1094 child_index += 1;
1095 }
1096 previous_emitted_text = true;
1097 }
1098 }
1099 FragmentNode::Element(element) => {
1100 let mut element_path = parent_path.to_vec();
1101 element_path.push(child_index);
1102 serialize_fragment_element(
1103 element,
1104 &element_path,
1105 text_bindings,
1106 out,
1107 text_runs,
1108 attr_groups,
1109 condition_slots,
1110 repeat_slots,
1111 event_targets,
1112 event_cursor,
1113 );
1114 child_index += 1;
1115 previous_emitted_text = false;
1116 }
1117 }
1118
1119 index += 1;
1120 }
1121}
1122
1123#[allow(clippy::too_many_arguments)]
1124fn serialize_fragment_element(
1125 element: &FragmentElement,
1126 element_path: &[usize],
1127 text_bindings: &[(String, bool)],
1128 out: &mut String,
1129 text_runs: &mut Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
1130 attr_groups: &mut Vec<(Vec<usize>, usize, usize)>,
1131 condition_slots: &mut [Option<SlotLocator>],
1132 repeat_slots: &mut [Option<SlotLocator>],
1133 event_targets: &mut [Option<Vec<usize>>],
1134 event_cursor: &mut usize,
1135) {
1136 out.push('<');
1137 out.push_str(&element.tag_name);
1138
1139 for attr in &element.attrs {
1140 if let Some((start, count)) = parse_attr_group_marker(&attr.name) {
1141 attr_groups.push((element_path.to_vec(), start, count));
1142 continue;
1143 }
1144
1145 if attr.name == "data-ev" {
1146 if let Some(count) = attr.value.as_deref().and_then(parse_event_marker_count) {
1147 let target_path = element_path.to_vec();
1148 for slot in event_targets.iter_mut().skip(*event_cursor).take(count) {
1149 *slot = Some(target_path.clone());
1150 }
1151 *event_cursor += count;
1152 }
1153 continue;
1154 }
1155
1156 out.push(' ');
1157 out.push_str(&attr.name);
1158 if let Some(value) = &attr.value {
1159 out.push_str("=\"");
1160 emit_html_attr_value(value, out);
1161 out.push('"');
1162 }
1163 }
1164
1165 if element.self_closing {
1166 out.push_str(" />");
1167 return;
1168 }
1169
1170 out.push('>');
1171 process_fragment_children(
1172 &element.children,
1173 element_path,
1174 text_bindings,
1175 out,
1176 text_runs,
1177 attr_groups,
1178 condition_slots,
1179 repeat_slots,
1180 event_targets,
1181 event_cursor,
1182 );
1183 out.push_str("</");
1184 out.push_str(&element.tag_name);
1185 out.push('>');
1186}
1187
1188fn collect_text_run(
1189 nodes: &[FragmentNode],
1190 text_bindings: &[(String, bool)],
1191) -> Option<(Vec<CompiledAttrPart>, usize, bool, bool)> {
1192 let mut parts = Vec::new();
1193 let mut consumed = 0usize;
1194 let mut has_dynamic = false;
1195 let mut is_raw = false;
1196
1197 for node in nodes {
1198 match node {
1199 FragmentNode::Text(text) => {
1200 if !text.is_empty() {
1201 parts.push(CompiledAttrPart::Static(text.clone()));
1202 }
1203 consumed += 1;
1204 }
1205 FragmentNode::Comment(data) => {
1206 if let Some(index) = parse_marker_index(data, "t:") {
1207 if let Some((path, raw)) = text_bindings.get(index) {
1208 parts.push(CompiledAttrPart::Dynamic(path.clone()));
1209 has_dynamic = true;
1210 if *raw {
1211 is_raw = true;
1212 }
1213 }
1214 consumed += 1;
1215 } else {
1216 break;
1217 }
1218 }
1219 FragmentNode::Element(_) => break,
1220 }
1221 }
1222
1223 if consumed == 0 {
1224 return None;
1225 }
1226
1227 Some((parts, consumed, has_dynamic, is_raw))
1228}
1229
1230fn collect_static_text(parts: &[CompiledAttrPart]) -> String {
1231 let mut text = String::new();
1232 for part in parts {
1233 if let CompiledAttrPart::Static(value) = part {
1234 text.push_str(value);
1235 }
1236 }
1237 text
1238}
1239
1240fn parse_marker_index(data: &str, prefix: &str) -> Option<usize> {
1241 data.strip_prefix(prefix)?.parse().ok()
1242}
1243
1244fn parse_attr_group_marker(name: &str) -> Option<(usize, usize)> {
1245 if let Some(start) = name.strip_prefix("data-w-b-") {
1246 return start.parse::<usize>().ok().map(|index| (index, 1));
1247 }
1248
1249 let rest = name.strip_prefix("data-w-c-")?;
1250 let mut parts = rest.split('-');
1251 let start = parts.next()?.parse::<usize>().ok()?;
1252 let count = parts.next()?.parse::<usize>().ok()?;
1253 Some((start, count))
1254}
1255
1256fn parse_event_marker_count(value: &str) -> Option<usize> {
1257 let count = value.parse::<usize>().ok()?;
1258 (count > 0).then_some(count)
1259}
1260
1261fn emit_html_attr_value(value: &str, out: &mut String) {
1262 for ch in value.chars() {
1263 match ch {
1264 '"' => out.push_str("""),
1265 _ => out.push(ch),
1266 }
1267 }
1268}
1269
1270fn parse_fragment_nodes(input: &str) -> Vec<FragmentNode> {
1271 let mut roots = Vec::new();
1272 let mut stack: Vec<FragmentElement> = Vec::new();
1273 let bytes = input.as_bytes();
1274 let len = bytes.len();
1275 let mut index = 0usize;
1276
1277 while index < len {
1278 let remaining = &input[index..];
1279 if remaining.starts_with("<!--") {
1280 if let Some(close) = remaining.find("-->") {
1281 push_fragment_node(
1282 &mut roots,
1283 &mut stack,
1284 FragmentNode::Comment(remaining[4..close].to_string()),
1285 );
1286 index += close + 3;
1287 continue;
1288 }
1289 }
1290
1291 if remaining.starts_with("</") {
1292 if let Some(close) = remaining.find('>') {
1293 if let Some(element) = stack.pop() {
1294 push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1295 }
1296 index += close + 1;
1297 continue;
1298 }
1299 }
1300
1301 if remaining.starts_with('<') {
1302 if let Some((element, consumed)) = parse_fragment_start_tag(remaining) {
1303 if element.self_closing {
1304 push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1305 } else {
1306 stack.push(element);
1307 }
1308 index += consumed;
1309 continue;
1310 }
1311 }
1312
1313 let next = remaining.find('<').unwrap_or(remaining.len());
1314 let text = &remaining[..next];
1315 if !text.is_empty() {
1316 push_fragment_node(&mut roots, &mut stack, FragmentNode::Text(text.to_string()));
1317 }
1318 index += next.max(1);
1319 }
1320
1321 while let Some(element) = stack.pop() {
1322 push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1323 }
1324
1325 roots
1326}
1327
1328fn push_fragment_node(
1329 roots: &mut Vec<FragmentNode>,
1330 stack: &mut [FragmentElement],
1331 node: FragmentNode,
1332) {
1333 if let Some(parent) = stack.last_mut() {
1334 parent.children.push(node);
1335 } else {
1336 roots.push(node);
1337 }
1338}
1339
1340fn parse_fragment_start_tag(input: &str) -> Option<(FragmentElement, usize)> {
1341 if !input.starts_with('<') || input.starts_with("</") || input.starts_with("<!--") {
1342 return None;
1343 }
1344
1345 let end = find_tag_end(input)?;
1346 let tag_content = &input[1..end];
1347 let tag_body = tag_content.trim_end();
1348 let self_closing = tag_body.ends_with('/');
1349 let tag_body = if self_closing {
1350 tag_body[..tag_body.len().saturating_sub(1)].trim_end()
1351 } else {
1352 tag_body
1353 };
1354
1355 let body_bytes = tag_body.as_bytes();
1356 let mut cursor = 0usize;
1357 while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
1358 cursor += 1;
1359 }
1360
1361 let tag_name = &tag_body[..cursor];
1362 if tag_name.is_empty() {
1363 return None;
1364 }
1365
1366 let mut attrs = Vec::new();
1367 while cursor < body_bytes.len() {
1368 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
1369 cursor += 1;
1370 }
1371 if cursor >= body_bytes.len() {
1372 break;
1373 }
1374
1375 let attr_start = cursor;
1376 while cursor < body_bytes.len()
1377 && !body_bytes[cursor].is_ascii_whitespace()
1378 && body_bytes[cursor] != b'='
1379 {
1380 cursor += 1;
1381 }
1382 if attr_start == cursor {
1383 cursor += 1;
1384 continue;
1385 }
1386
1387 let name = tag_body[attr_start..cursor].to_string();
1388 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
1389 cursor += 1;
1390 }
1391
1392 let mut value: Option<String> = None;
1393 if cursor < body_bytes.len() && body_bytes[cursor] == b'=' {
1394 cursor += 1;
1395 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
1396 cursor += 1;
1397 }
1398
1399 if cursor < body_bytes.len()
1400 && (body_bytes[cursor] == b'"' || body_bytes[cursor] == b'\'')
1401 {
1402 let quote = body_bytes[cursor];
1403 cursor += 1;
1404 let value_start = cursor;
1405 while cursor < body_bytes.len() && body_bytes[cursor] != quote {
1406 cursor += 1;
1407 }
1408 value = Some(tag_body[value_start..cursor].to_string());
1409 if cursor < body_bytes.len() {
1410 cursor += 1;
1411 }
1412 } else {
1413 let value_start = cursor;
1414 while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
1415 cursor += 1;
1416 }
1417 value = Some(tag_body[value_start..cursor].to_string());
1418 }
1419 }
1420
1421 attrs.push(FragmentAttr { name, value });
1422 }
1423
1424 Some((
1425 FragmentElement {
1426 tag_name: tag_name.to_string(),
1427 attrs,
1428 children: Vec::new(),
1429 self_closing: self_closing || is_void_element(tag_name),
1430 },
1431 end + 1,
1432 ))
1433}
1434
1435fn is_void_element(tag_name: &str) -> bool {
1436 matches!(
1437 tag_name,
1438 "area"
1439 | "base"
1440 | "br"
1441 | "col"
1442 | "embed"
1443 | "hr"
1444 | "img"
1445 | "input"
1446 | "link"
1447 | "meta"
1448 | "param"
1449 | "source"
1450 | "track"
1451 | "wbr"
1452 )
1453}
1454
1455fn find_brace_end(input: &str, start: usize, count: usize) -> Option<usize> {
1460 let bytes = input.as_bytes();
1461 let mut i = start;
1462 while i + count - 1 < bytes.len() {
1463 let all_close = (0..count).all(|j| bytes[i + j] == b'}');
1464 if all_close {
1465 return Some(i);
1466 }
1467 i += 1;
1468 }
1469 None
1470}
1471
1472fn is_inside_tag(input: &str, pos: usize) -> bool {
1475 let bytes = input.as_bytes();
1476 let mut i = pos;
1477 while i > 0 {
1478 i -= 1;
1479 if bytes[i] == b'>' {
1480 return false;
1481 }
1482 if bytes[i] == b'<' {
1483 return true;
1484 }
1485 }
1486 false
1487}
1488
1489fn strip_template_wrapper(html: &str) -> &str {
1492 if !html.starts_with("<template") {
1493 return html;
1494 }
1495 let Some(open_end) = find_tag_end(html) else {
1496 return html;
1497 };
1498 let inner_start = open_end + 1;
1499 let inner_end = html.rfind("</template>").unwrap_or(html.len());
1500 if inner_start >= inner_end {
1501 return "";
1502 }
1503 &html[inner_start..inner_end]
1504}
1505
1506fn find_next_block_token(input: &str, cursor: usize, token: &str) -> Option<usize> {
1507 let bytes = input.as_bytes();
1508 let token_bytes = token.as_bytes();
1509 let mut index = cursor;
1510 let mut in_tag = false;
1511 let mut in_comment = false;
1512 let mut quote: Option<u8> = None;
1513
1514 while index + token_bytes.len() <= bytes.len() {
1515 if in_comment {
1516 if bytes[index..].starts_with(b"-->") {
1517 in_comment = false;
1518 index += 3;
1519 continue;
1520 }
1521 index += 1;
1522 continue;
1523 }
1524
1525 if let Some(active) = quote {
1526 if bytes[index] == active {
1527 quote = None;
1528 }
1529 index += 1;
1530 continue;
1531 }
1532
1533 if in_tag {
1534 match bytes[index] {
1535 b'"' | b'\'' => quote = Some(bytes[index]),
1536 b'>' => in_tag = false,
1537 _ => {}
1538 }
1539 index += 1;
1540 continue;
1541 }
1542
1543 if &bytes[index..index + token_bytes.len()] == token_bytes {
1544 return Some(index);
1545 }
1546
1547 if bytes[index] == b'<' {
1548 if bytes[index..].starts_with(b"<!--") {
1549 in_comment = true;
1550 index += 4;
1551 continue;
1552 }
1553 in_tag = true;
1554 }
1555
1556 index += 1;
1557 }
1558
1559 None
1560}
1561
1562fn find_next_block_open(input: &str, cursor: usize, name: &str) -> Option<usize> {
1563 let token = format!("<{name}");
1564 let bytes = input.as_bytes();
1565 let token_bytes = token.as_bytes();
1566 let mut search = cursor;
1567
1568 while let Some(index) = find_next_block_token(input, search, &token) {
1569 let next = bytes.get(index + token_bytes.len()).copied();
1570 if match next {
1571 None => true,
1572 Some(byte) => byte == b'>' || byte.is_ascii_whitespace(),
1573 } {
1574 return Some(index);
1575 }
1576 search = index + 1;
1577 }
1578
1579 None
1580}
1581
1582fn find_next_block_close(input: &str, cursor: usize, name: &str) -> Option<usize> {
1583 let token = format!("</{name}>");
1584 find_next_block_token(input, cursor, &token)
1585}
1586
1587fn find_matching_block_end(input: &str, name: &str) -> Option<usize> {
1588 let open_token = format!("<{name}");
1589 let close_token = format!("</{name}>");
1590 if !input.starts_with(&open_token) {
1591 return None;
1592 }
1593
1594 let mut depth = 1usize;
1595 let mut cursor = open_token.len();
1596
1597 while cursor < input.len() {
1598 let next_open = find_next_block_open(input, cursor, name);
1599 let next_close = find_next_block_close(input, cursor, name);
1600
1601 match (next_open, next_close) {
1602 (_, None) => return None,
1603 (Some(open_pos), Some(close_pos)) if open_pos < close_pos => {
1604 depth += 1;
1605 cursor = open_pos + open_token.len();
1606 }
1607 (_, Some(close_pos)) => {
1608 depth = depth.saturating_sub(1);
1609 if depth == 0 {
1610 return Some(close_pos);
1611 }
1612 cursor = close_pos + close_token.len();
1613 }
1614 }
1615 }
1616
1617 None
1618}
1619
1620fn parse_if_block(input: &str) -> Option<(ConditionExpr, String, usize)> {
1625 let close_tag = "</if>";
1626 let end_pos = find_matching_block_end(input, "if")?;
1627 let tag_content = &input[..end_pos];
1628
1629 let cond_start = tag_content.find("condition=\"")? + "condition=\"".len();
1631 let cond_end = tag_content[cond_start..].find('"')? + cond_start;
1632 let condition = compile_condition_expr(&tag_content[cond_start..cond_end]);
1633
1634 let body_start = find_tag_end(tag_content)? + 1;
1636 let body = tag_content[body_start..].trim().to_string();
1637
1638 Some((condition, body, end_pos + close_tag.len()))
1639}
1640
1641fn compile_condition_expr(input: &str) -> ConditionExpr {
1642 let parser = ConditionParser::new();
1643 match parser.parse(input) {
1644 Ok(condition) => condition,
1645 Err(_) => ConditionExpr::identifier(input),
1649 }
1650}
1651
1652fn parse_for_block(input: &str) -> Option<(String, String, String, usize)> {
1658 let close_tag = "</for>";
1659 let end_pos = find_matching_block_end(input, "for")?;
1660 let tag_content = &input[..end_pos];
1661
1662 let each_start = tag_content.find("each=\"")? + "each=\"".len();
1664 let each_end = tag_content[each_start..].find('"')? + each_start;
1665 let each_val = &tag_content[each_start..each_end];
1666
1667 let parts: Vec<&str> = each_val.splitn(3, ' ').collect();
1668 if parts.len() != 3 || parts[1] != "in" {
1669 return None;
1670 }
1671 let item_var = parts[0].to_string();
1672 let collection = parts[2].to_string();
1673
1674 let body_start = find_tag_end(tag_content)? + 1;
1676 let body = tag_content[body_start..].trim().to_string();
1677
1678 Some((collection, item_var, body, end_pos + close_tag.len()))
1679}
1680
1681fn parse_event_attr(input: &str, pos: usize) -> Option<(String, String, bool, usize)> {
1691 let remaining = &input[pos..];
1692 let eq_pos = remaining.find('=')?;
1693 let event_name = remaining[1..eq_pos].to_string(); let after_eq = &remaining[eq_pos + 1..];
1696 if after_eq.is_empty() {
1697 return None;
1698 }
1699
1700 let first = after_eq.as_bytes()[0];
1701 let (raw_value, total_consumed) = if first == b'"' || first == b'\'' {
1702 let value_start = eq_pos + 2;
1703 let close = remaining[value_start..].find(first as char)?;
1704 (
1705 &remaining[value_start..value_start + close],
1706 value_start + close + 1,
1707 )
1708 } else if first == b'{' {
1709 let value_start = eq_pos + 2;
1710 let close = remaining[value_start..].find('}')?;
1711 (
1712 &remaining[value_start..value_start + close],
1713 value_start + close + 1,
1714 )
1715 } else {
1716 return None;
1717 };
1718
1719 let inner = raw_value
1720 .trim()
1721 .strip_prefix('{')
1722 .and_then(|s| s.strip_suffix('}'))
1723 .unwrap_or(raw_value)
1724 .trim();
1725
1726 if inner.is_empty() {
1728 return None;
1729 }
1730 let paren = match inner.find('(') {
1731 Some(p) => p,
1732 None => panic!(
1733 "Invalid @{event_name} handler: \"{inner}\". \
1734 Use @{event_name}=\"{{handler()}}\" or \
1735 @{event_name}=\"{{handler(e)}}\" to pass the event.",
1736 ),
1737 };
1738 let handler_name = inner[..paren].to_string();
1739 let needs_event = inner.contains("(e)");
1740
1741 Some((event_name, handler_name, needs_event, total_consumed))
1742}
1743
1744fn parse_regular_tag(input: &str, meta: &mut TemplateSectionMeta) -> Option<(String, usize)> {
1745 if !input.starts_with('<') || input.starts_with("</") || input.starts_with("<!") {
1746 return None;
1747 }
1748
1749 let bytes = input.as_bytes();
1750 if bytes.len() < 2 || !bytes[1].is_ascii_alphabetic() {
1751 return None;
1752 }
1753
1754 let end = find_tag_end(input)?;
1755 let tag_content = &input[1..end];
1756 let tag_body = tag_content.trim_end();
1757 let self_closing = tag_body.ends_with('/');
1758 let tag_body = if self_closing {
1759 tag_body[..tag_body.len().saturating_sub(1)].trim_end()
1760 } else {
1761 tag_body
1762 };
1763
1764 let body_bytes = tag_body.as_bytes();
1765 let mut cursor = 0;
1766 while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
1767 cursor += 1;
1768 }
1769
1770 let tag_name = &tag_body[..cursor];
1771 if tag_name.is_empty() {
1772 return None;
1773 }
1774
1775 let mut out = String::with_capacity(tag_body.len() + 24);
1776 out.push('<');
1777 out.push_str(tag_name);
1778
1779 let binding_start = meta.attr_bindings.len();
1780 let event_start = meta.events.len();
1781 let mut binding_count = 0usize;
1782
1783 while cursor < body_bytes.len() {
1784 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
1785 cursor += 1;
1786 }
1787 if cursor >= body_bytes.len() {
1788 break;
1789 }
1790
1791 let attr_start = cursor;
1792 while cursor < body_bytes.len()
1793 && !body_bytes[cursor].is_ascii_whitespace()
1794 && body_bytes[cursor] != b'='
1795 {
1796 cursor += 1;
1797 }
1798 if attr_start == cursor {
1799 cursor += 1;
1800 continue;
1801 }
1802
1803 let name = &tag_body[attr_start..cursor];
1804
1805 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
1806 cursor += 1;
1807 }
1808
1809 let mut value: Option<&str> = None;
1810 if cursor < body_bytes.len() && body_bytes[cursor] == b'=' {
1811 cursor += 1;
1812 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
1813 cursor += 1;
1814 }
1815
1816 if cursor < body_bytes.len()
1817 && (body_bytes[cursor] == b'"' || body_bytes[cursor] == b'\'')
1818 {
1819 let quote = body_bytes[cursor];
1820 cursor += 1;
1821 let value_start = cursor;
1822 while cursor < body_bytes.len() && body_bytes[cursor] != quote {
1823 cursor += 1;
1824 }
1825 value = Some(&tag_body[value_start..cursor]);
1826 if cursor < body_bytes.len() {
1827 cursor += 1;
1828 }
1829 } else {
1830 let value_start = cursor;
1831 while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
1832 cursor += 1;
1833 }
1834 value = Some(&tag_body[value_start..cursor]);
1835 }
1836 }
1837
1838 let raw_attr = tag_body[attr_start..cursor].trim();
1839 if raw_attr.is_empty() {
1840 continue;
1841 }
1842
1843 if let Some(event_name) = name.strip_prefix('@') {
1844 let Some(raw_value) = value else {
1845 continue;
1846 };
1847 match parse_event_handler(raw_value) {
1848 EventHandler::Valid(handler_name, needs_event) => {
1849 meta.events
1850 .push((event_name.to_string(), handler_name, needs_event));
1851 }
1852 EventHandler::Invalid(raw) => {
1853 panic!(
1854 "Invalid @{event_name} handler: \"{raw}\". \
1855 Use @{event_name}=\"{{handler()}}\" or \
1856 @{event_name}=\"{{handler(e)}}\" to pass the event.",
1857 );
1858 }
1859 EventHandler::Empty => {}
1860 }
1861 continue;
1862 }
1863
1864 if name == "w-ref" {
1865 if let Some(val) = value {
1868 if !val.starts_with('{') || !val.ends_with('}') {
1869 eprintln!(
1870 "\x1b[1;31merror\x1b[0m: invalid w-ref=\"{}\" in <{}> — \
1871 use w-ref={{{}}} with braces to bind to a component property",
1872 val, tag_name, val
1873 );
1874 }
1875 }
1876 out.push(' ');
1877 out.push_str(raw_attr);
1878 continue;
1879 }
1880
1881 if let Some(bool_name) = name.strip_prefix('?') {
1882 if let Some(raw_value) = value.and_then(extract_single_handlebars) {
1883 meta.attr_bindings.push(CompiledAttrBinding::Boolean {
1884 name: bool_name.to_string(),
1885 condition: compile_condition_expr(&raw_value),
1886 });
1887 binding_count += 1;
1888 }
1889 continue;
1890 }
1891
1892 if name.starts_with(':') {
1893 if let Some(raw_value) = value.and_then(extract_single_handlebars) {
1894 let prop_name = webui_protocol::attrs::attribute_to_camel(
1897 name.strip_prefix(':').unwrap_or(name),
1898 );
1899 meta.attr_bindings.push(CompiledAttrBinding::Complex {
1900 name: prop_name,
1901 value: raw_value,
1902 });
1903 binding_count += 1;
1904 }
1905 continue;
1906 }
1907
1908 if let Some(raw_value) = value {
1909 if let Some(parts) = parse_attr_parts(raw_value) {
1910 let is_simple =
1911 parts.len() == 1 && matches!(parts.first(), Some(CompiledAttrPart::Dynamic(_)));
1912 if is_simple {
1913 if let Some(CompiledAttrPart::Dynamic(path)) = parts.into_iter().next() {
1914 meta.attr_bindings.push(CompiledAttrBinding::Simple {
1915 name: name.to_string(),
1916 value: path,
1917 });
1918 }
1919 } else {
1920 meta.attr_bindings.push(CompiledAttrBinding::Template {
1921 name: name.to_string(),
1922 parts,
1923 });
1924 }
1925 binding_count += 1;
1926 continue;
1927 }
1928 }
1929
1930 out.push(' ');
1931 out.push_str(raw_attr);
1932 }
1933
1934 if binding_count == 1 {
1935 out.push_str(" data-w-b-");
1936 out.push_str(&binding_start.to_string());
1937 } else if binding_count > 1 {
1938 out.push_str(" data-w-c-");
1939 out.push_str(&binding_start.to_string());
1940 out.push('-');
1941 out.push_str(&binding_count.to_string());
1942 }
1943
1944 let event_count = meta.events.len().saturating_sub(event_start);
1945 if event_count > 0 {
1946 out.push_str(" data-ev=\"");
1947 let _ = write!(out, "{}", event_count);
1948 out.push('"');
1949 }
1950
1951 if self_closing {
1952 out.push_str(" />");
1953 } else {
1954 out.push('>');
1955 }
1956
1957 Some((out, end + 1))
1958}
1959
1960fn find_tag_end(input: &str) -> Option<usize> {
1961 let bytes = input.as_bytes();
1962 let mut quote: Option<u8> = None;
1963 let mut i = 1;
1964
1965 while i < bytes.len() {
1966 let ch = bytes[i];
1967 if let Some(active) = quote {
1968 if ch == active {
1969 quote = None;
1970 }
1971 } else if ch == b'"' || ch == b'\'' {
1972 quote = Some(ch);
1973 } else if ch == b'>' {
1974 return Some(i);
1975 }
1976 i += 1;
1977 }
1978
1979 None
1980}
1981
1982enum EventHandler {
1983 Valid(String, bool),
1984 Invalid(String),
1985 Empty,
1986}
1987
1988fn parse_event_handler(raw_value: &str) -> EventHandler {
1989 let inner = raw_value
1990 .trim()
1991 .strip_prefix('{')
1992 .and_then(|s| s.strip_suffix('}'))
1993 .unwrap_or(raw_value)
1994 .trim();
1995 if inner.is_empty() {
1996 return EventHandler::Empty;
1997 }
1998 match inner.find('(') {
2000 Some(paren) => {
2001 EventHandler::Valid(inner[..paren].trim().to_string(), inner.contains("(e)"))
2002 }
2003 None => EventHandler::Invalid(inner.to_string()),
2004 }
2005}
2006
2007fn extract_single_handlebars(value: &str) -> Option<String> {
2008 let trimmed = value.trim();
2009 if let Some(inner) = trimmed
2010 .strip_prefix("{{{")
2011 .and_then(|s| s.strip_suffix("}}}"))
2012 {
2013 return Some(inner.trim().to_string());
2014 }
2015
2016 if let Some(inner) = trimmed
2017 .strip_prefix("{{")
2018 .and_then(|s| s.strip_suffix("}}"))
2019 {
2020 return Some(inner.trim().to_string());
2021 }
2022
2023 None
2024}
2025
2026fn parse_attr_parts(value: &str) -> Option<Vec<CompiledAttrPart>> {
2027 let bytes = value.as_bytes();
2028 let len = bytes.len();
2029 let mut i = 0;
2030 let mut last = 0;
2031 let mut dynamic_found = false;
2032 let mut parts = Vec::new();
2033
2034 while i < len {
2035 if i + 2 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
2036 let brace_count = if i + 2 < len && bytes[i + 2] == b'{' {
2037 3
2038 } else {
2039 2
2040 };
2041
2042 if let Some(end) = find_brace_end(value, i + brace_count, brace_count) {
2043 if last < i {
2044 parts.push(CompiledAttrPart::Static(value[last..i].to_string()));
2045 }
2046 parts.push(CompiledAttrPart::Dynamic(
2047 value[i + brace_count..end].trim().to_string(),
2048 ));
2049 dynamic_found = true;
2050 i = end + brace_count;
2051 last = i;
2052 continue;
2053 }
2054 }
2055
2056 i += 1;
2057 }
2058
2059 if !dynamic_found {
2060 return None;
2061 }
2062
2063 if last < len {
2064 parts.push(CompiledAttrPart::Static(value[last..].to_string()));
2065 }
2066
2067 Some(parts)
2068}
2069
2070fn extract_root_events(html: &str) -> Vec<(String, String, bool)> {
2075 if !html.starts_with("<template") {
2076 return Vec::new();
2077 }
2078 let Some(close) = find_tag_end(html) else {
2079 return Vec::new();
2080 };
2081 let tag = &html[..close];
2082 let mut events = Vec::new();
2083 let bytes = tag.as_bytes();
2084 let len = bytes.len();
2085 let mut i = 0;
2086
2087 while i < len {
2088 if bytes[i] == b'@' {
2089 if let Some((name, handler, needs_event, consumed)) = parse_event_attr(tag, i) {
2090 events.push((name, handler, needs_event));
2091 i += consumed;
2092 continue;
2093 }
2094 }
2095 i += 1;
2096 }
2097
2098 events
2099}
2100
2101fn extract_adopted_stylesheet_specifier(html: &str) -> Option<String> {
2102 if !html.starts_with("<template") {
2103 return None;
2104 }
2105 let close = find_tag_end(html)?;
2106 let tag = &html[..close];
2107 let attr = "shadowrootadoptedstylesheets=\"";
2108 let start = tag.find(attr)? + attr.len();
2109 let end = tag[start..].find('"')? + start;
2110 Some(tag[start..end].to_string())
2111}
2112
2113#[cfg(test)]
2116mod tests {
2117 use super::*;
2118
2119 fn assert_no_client_markers(result: &str) {
2120 assert!(!result.contains("<!--t:"), "text markers should be removed");
2121 assert!(
2122 !result.contains("<!--c:"),
2123 "conditional markers should be removed"
2124 );
2125 assert!(
2126 !result.contains("<!--r:"),
2127 "repeat markers should be removed"
2128 );
2129 assert!(
2130 !result.contains("data-w-b-"),
2131 "attribute binding markers should be removed"
2132 );
2133 assert!(
2134 !result.contains("data-w-c-"),
2135 "attribute binding range markers should be removed"
2136 );
2137 assert!(
2138 !result.contains("data-ev="),
2139 "event markers should be removed"
2140 );
2141 }
2142
2143 #[test]
2144 fn test_skip_event_attributes() {
2145 let mut plugin = WebUIParserPlugin::new();
2146 assert_eq!(plugin.classify_attribute("@click"), AttributeAction::Skip);
2147 assert_eq!(plugin.classify_attribute("@keydown"), AttributeAction::Skip);
2148 assert_eq!(plugin.classify_attribute("w-ref"), AttributeAction::Keep);
2149 assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
2150 }
2151
2152 #[test]
2153 fn test_metadata_has_text_bindings() {
2154 let result = generate_compiled_template("my-comp", "<h1>{{title}}</h1>");
2155 assert_no_client_markers(&result);
2156 assert!(result.contains(r#"h:"<h1></h1>""#));
2157 assert!(result.contains("\"title\""));
2158 assert!(result.contains(r#",tx:[[[[0],0],[["title"]]]]"#));
2159 assert!(!result.contains("{{"));
2160 }
2161
2162 #[test]
2163 fn test_metadata_has_conditionals() {
2164 let result = generate_compiled_template(
2165 "my-comp",
2166 r#"<if condition="state == 'done'"><span>yes</span></if>"#,
2167 );
2168 assert_no_client_markers(&result);
2169 assert!(result
2170 .contains(r#"c:[[[function(v,s){return v("state",s)=="done"},["state"]],0,[[],0]]]"#));
2171 assert!(result.contains("<span>yes</span>"));
2172 assert!(!result.contains("<if"));
2173 }
2174
2175 #[test]
2176 fn test_metadata_has_conditionals_with_gt_in_condition_attr() {
2177 let result = generate_compiled_template(
2178 "my-comp",
2179 r#"<if condition="count > 0"><span>yes</span></if>"#,
2180 );
2181 assert_no_client_markers(&result);
2182 assert!(result.contains(",c:["), "conditional metadata expected");
2183 assert!(result.contains("<span>yes</span>"));
2184 assert!(!result.contains("<if"));
2185 }
2186
2187 #[test]
2188 fn test_parse_if_block_ignores_close_marker_inside_attr_value() {
2189 let input = r#"<if condition="count > 0"><div data-note="</if>">yes</div></if>"#;
2190 let (_, body, consumed) = parse_if_block(input).expect("if block should parse");
2191
2192 assert_eq!(body, r#"<div data-note="</if>">yes</div>"#);
2193 assert_eq!(consumed, input.len());
2194 }
2195
2196 #[test]
2197 fn test_metadata_has_for_loops() {
2198 let result = generate_compiled_template(
2199 "my-comp",
2200 r#"<for each="item in items"><p>{{item.name}}</p></for>"#,
2201 );
2202 assert_no_client_markers(&result);
2203 assert!(result.contains("\"items\""));
2204 assert!(result.contains("\"item\""));
2205 assert!(!result.contains("<for"));
2206 }
2207
2208 #[test]
2209 fn test_parse_for_block_ignores_open_marker_inside_attr_value() {
2210 let input =
2211 r#"<for each="item in items"><div data-note="<for fake>">{{item.name}}</div></for>"#;
2212 let (collection, item_var, body, consumed) =
2213 parse_for_block(input).expect("for block should parse");
2214
2215 assert_eq!(collection, "items");
2216 assert_eq!(item_var, "item");
2217 assert_eq!(body, r#"<div data-note="<for fake>">{{item.name}}</div>"#);
2218 assert_eq!(consumed, input.len());
2219 }
2220
2221 #[test]
2222 fn test_metadata_has_for_loops_with_gt_in_other_attr() {
2223 let result = generate_compiled_template(
2224 "my-comp",
2225 r#"<for each="item in items" data-note="a > b"><p>{{item.name}}</p></for>"#,
2226 );
2227 assert_no_client_markers(&result);
2228 assert!(result.contains("\"items\""));
2229 assert!(result.contains("\"item\""));
2230 assert!(!result.contains("<for"));
2231 assert!(!result.contains("{{item.name}}"));
2232 }
2233
2234 #[test]
2235 fn test_metadata_has_events() {
2236 let result =
2237 generate_compiled_template("my-comp", r#"<button @click="{onClick()}">Go</button>"#);
2238 assert_no_client_markers(&result);
2239 assert!(result.contains("\"click\""));
2240 assert!(result.contains("\"onClick\""));
2241 assert!(!result.contains("@click"));
2242 }
2243
2244 #[test]
2245 fn test_metadata_has_events_unquoted() {
2246 let result =
2247 generate_compiled_template("my-comp", r#"<button @click={onClick()}>Go</button>"#);
2248 assert_no_client_markers(&result);
2249 assert!(result.contains("\"click\""));
2250 assert!(result.contains("\"onClick\""));
2251 }
2252
2253 #[test]
2254 fn test_metadata_has_events_with_arg() {
2255 let result = generate_compiled_template("my-comp", r#"<input @keydown="{onKey(e)}" />"#);
2256 assert_no_client_markers(&result);
2257 assert!(result.contains("\"keydown\""));
2258 assert!(result.contains("\"onKey\""));
2259 assert!(result.contains(",1")); }
2261
2262 #[test]
2263 fn test_metadata_has_simple_attr_bindings() {
2264 let result = generate_compiled_template("my-comp", r#"<input title="{{title}}" />"#);
2265 assert_no_client_markers(&result);
2266 assert!(result.contains(",a:["));
2267 assert!(result.contains(",ag:[[[0],0,1]]"));
2268 assert!(result.contains(r#"["title",0,"title"]"#));
2269 assert!(!result.contains(r#"title=\"<!--t:"#));
2270 }
2271
2272 #[test]
2273 fn test_metadata_has_template_attr_bindings() {
2274 let result = generate_compiled_template(
2275 "my-comp",
2276 r#"<a href="/product/{{handle}}" class="card {{variant}}">Go</a>"#,
2277 );
2278 assert_no_client_markers(&result);
2279 assert!(result.contains(",a:["));
2280 assert!(result.contains(",ag:[[[0],0,2]]"));
2281 assert!(result.contains(r#"["href",3,["/product/",["handle"]]]"#));
2282 assert!(result.contains(r#"["class",3,["card ",["variant"]]]"#));
2283 assert!(!result.contains(r#"/product/<!--t:"#));
2284 }
2285
2286 #[test]
2287 fn test_metadata_has_boolean_attr_bindings() {
2288 let result = generate_compiled_template(
2289 "my-comp",
2290 r#"<a ?data-active="{{page == 'dashboard'}}">Go</a>"#,
2291 );
2292 assert_no_client_markers(&result);
2293 assert!(result.contains(",a:["));
2294 assert!(result.contains(",ag:[[[0],0,1]]"));
2295 assert!(result.contains(
2296 r#"["data-active",2,[function(v,s){return v("page",s)=="dashboard"},["page"]]]"#
2297 ));
2298 }
2299
2300 #[test]
2301 fn test_metadata_has_complex_attr_bindings() {
2302 let result = generate_compiled_template(
2303 "my-comp",
2304 r#"<child-view :config="{{settings}}"></child-view>"#,
2305 );
2306 assert_no_client_markers(&result);
2307 assert!(result.contains(",a:["));
2308 assert!(result.contains(",ag:[[[0],0,1]]"));
2309 assert!(result.contains(r#""config",1,"settings""#));
2310 }
2311
2312 #[test]
2313 fn test_w_ref_stays_in_html() {
2314 let result = generate_compiled_template("my-comp", r#"<input w-ref="{myInput}" />"#);
2315 assert!(result.contains("w-ref"));
2317 assert!(result.contains("myInput"));
2318 }
2319
2320 #[test]
2321 fn test_w_ref_unquoted_stays_in_html() {
2322 let result = generate_compiled_template("my-comp", r#"<span w-ref={myLabel}>0</span>"#);
2323 assert!(result.contains("w-ref"));
2324 assert!(result.contains("myLabel"));
2325 }
2326
2327 #[test]
2328 fn test_metadata_has_root_events() {
2329 let result = generate_compiled_template(
2330 "my-comp",
2331 r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>hi</div></template>"#,
2332 );
2333 assert!(result.contains(",re:["));
2334 assert!(result.contains("\"click\""));
2335 assert!(result.contains("\"onClick\""));
2336 }
2337
2338 #[test]
2339 fn test_strips_shadowrootmode() {
2340 let result = generate_compiled_template(
2341 "my-comp",
2342 r#"<template shadowrootmode="open"><p>{{title}}</p></template>"#,
2343 );
2344 assert!(!result.contains("shadowrootmode"));
2345 assert_no_client_markers(&result);
2346 assert!(result.contains(r#",tx:[[[[0],0],[["title"]]]]"#));
2347 }
2348
2349 #[test]
2350 fn test_root_template_allows_gt_in_quoted_attr_values() {
2351 let result = generate_compiled_template(
2352 "my-comp",
2353 r#"<template shadowrootmode="open" data-note="a > b" @click="{onClick()}"><p>hi</p></template>"#,
2354 );
2355 assert_no_client_markers(&result);
2356 assert!(result.contains(r#"h:"<p>hi</p>""#));
2357 assert!(result.contains(r#",re:[["click","onClick",0]]"#));
2358 }
2359
2360 #[test]
2361 fn test_outlet_converted() {
2362 let result = generate_compiled_template("my-comp", r#"<main><outlet /></main>"#);
2363 assert!(result.contains("<outlet></outlet>"));
2364 }
2365
2366 #[test]
2367 fn test_emoji_safe() {
2368 let result = generate_compiled_template("my-comp", r#"<span>📚 {{title}}</span>"#);
2369 assert!(result.contains("📚"));
2370 assert_no_client_markers(&result);
2371 assert!(result.contains(r#",tx:[[[[0],0],["📚 ",["title"]]]]"#));
2372 }
2373
2374 #[test]
2375 fn test_boolean_attr_in_for() {
2376 let result = generate_compiled_template(
2377 "my-comp",
2378 r#"<for each="s in sections"><a ?active="{{s.id == sectionId}}">{{s.name}}</a></for>"#,
2379 );
2380 assert_no_client_markers(&result);
2381 assert!(result.contains(r#"["active",2,[function(v,s){return v("s.id",s)==v("sectionId",s)},["s.id","sectionId"]]]"#));
2382 assert!(!result.contains("?active"));
2383 }
2384
2385 #[test]
2386 fn test_deduplicates_components() {
2387 let mut plugin = WebUIParserPlugin::new();
2388 let comp = Component {
2389 tag_name: "test-el".to_string(),
2390 html_content: "<p>hi</p>".to_string(),
2391 css_content: None,
2392 css_tokens: Vec::new(),
2393 source_path: std::path::PathBuf::new(),
2394 class_name: None,
2395 };
2396 plugin
2397 .register_component_template("test-el", &comp, &comp.html_content)
2398 .unwrap();
2399 plugin
2400 .register_component_template("test-el", &comp, &comp.html_content)
2401 .unwrap();
2402 assert_eq!(plugin.take_component_templates().len(), 1);
2403 }
2404
2405 #[test]
2406 fn test_compiled_template_preserves_link_node_in_static_html() {
2407 let result = generate_compiled_template(
2408 "my-comp",
2409 r#"<template shadowrootmode="open"><link rel="stylesheet" href="my-comp.css"><p>hi</p></template>"#,
2410 );
2411 assert!(result.contains(r#"rel=\"stylesheet\""#));
2412 assert!(result.contains(r#"href=\"my-comp.css\""#));
2413 assert!(result.contains(r#"<p>hi</p>"#));
2414 assert!(!result.contains(",css:"));
2415 }
2416
2417 #[test]
2418 fn test_compiled_template_preserves_inline_style_in_static_html() {
2419 let result = generate_compiled_template(
2420 "my-comp",
2421 r#"<template shadowrootmode="open"><style>.root{color:red}</style><p>hi</p></template>"#,
2422 );
2423 assert!(result.contains(r#"h:"<style>.root{color:red}</style><p>hi</p>""#));
2424 }
2425
2426 #[test]
2427 fn test_compiled_template_emits_adopted_stylesheet_specifier() {
2428 let result = generate_compiled_template(
2429 "my-comp",
2430 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><p>hi</p></template>"#,
2431 );
2432 assert!(result.contains(r#",sa:"my-comp""#));
2433 }
2434
2435 #[test]
2436 fn test_plugin_uses_processed_link_template_html() {
2437 let mut plugin = WebUIParserPlugin::new();
2438
2439 let comp = Component {
2440 tag_name: "test-el".to_string(),
2441 html_content: "<p>hi</p>".to_string(),
2442 css_content: Some(".root { color: red; }".to_string()),
2443 css_tokens: Vec::new(),
2444 source_path: std::path::PathBuf::new(),
2445 class_name: None,
2446 };
2447
2448 plugin
2449 .register_component_template(
2450 "test-el",
2451 &comp,
2452 r#"<template shadowrootmode="open"><link rel="stylesheet" href="test-el.css"><p>hi</p></template>"#,
2453 )
2454 .unwrap();
2455 let templates = plugin.take_component_templates();
2456 assert_eq!(templates.len(), 1);
2457 assert!(templates[0].1.contains(r#"rel=\"stylesheet\""#));
2458 assert!(templates[0].1.contains(r#"href=\"test-el.css\""#));
2459 assert!(templates[0].1.contains(r#"<p>hi</p>"#));
2460 }
2461
2462 #[test]
2463 fn test_plugin_preserves_root_events_from_raw_template_source() {
2464 let mut plugin = WebUIParserPlugin::new();
2465
2466 let comp = Component {
2467 tag_name: "test-el".to_string(),
2468 html_content:
2469 r#"<template shadowrootmode="open" @click="{onClick(e)}"><p>hi</p></template>"#
2470 .to_string(),
2471 css_content: Some(".root { color: red; }".to_string()),
2472 css_tokens: Vec::new(),
2473 source_path: std::path::PathBuf::new(),
2474 class_name: None,
2475 };
2476
2477 plugin
2478 .register_component_template(
2479 "test-el",
2480 &comp,
2481 r#"<template shadowrootmode="open"><link rel="stylesheet" href="test-el.css"><p>hi</p></template>"#,
2482 )
2483 .unwrap();
2484 let templates = plugin.take_component_templates();
2485 assert_eq!(templates.len(), 1);
2486 assert!(templates[0].1.contains(r#",re:[["click","onClick",1]]"#));
2487 }
2488
2489 #[test]
2490 fn test_plugin_uses_processed_module_template_html() {
2491 let mut plugin = WebUIParserPlugin::new();
2492
2493 let comp = Component {
2494 tag_name: "test-el".to_string(),
2495 html_content: "<p>hi</p>".to_string(),
2496 css_content: Some(".root { color: red; }".to_string()),
2497 css_tokens: Vec::new(),
2498 source_path: std::path::PathBuf::new(),
2499 class_name: None,
2500 };
2501
2502 plugin
2503 .register_component_template(
2504 "test-el",
2505 &comp,
2506 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="test-el"><p>hi</p></template>"#,
2507 )
2508 .unwrap();
2509 let templates = plugin.take_component_templates();
2510 assert_eq!(templates.len(), 1);
2511 assert!(templates[0].1.contains(r#",sa:"test-el""#));
2512 }
2513
2514 #[test]
2515 fn test_nested_if_inside_for_compiles_nested_blocks() {
2516 let result = generate_compiled_template(
2517 "my-comp",
2518 r#"<for each="item in items"><if condition="item.active"><span>{{item.name}}</span></if></for>"#,
2519 );
2520
2521 assert_no_client_markers(&result);
2522 assert!(result.contains(",b:["), "compiled block table expected");
2523 assert!(
2524 result.contains(r#"["items","item",0,[[],0]]"#),
2525 "repeat block index expected"
2526 );
2527 assert!(
2528 result.contains(
2529 r#"[[function(v,s){return !!v("item.active",s)},["item.active"]],1,[[],0]]"#
2530 ),
2531 "nested conditional block index expected"
2532 );
2533 assert!(!result.contains("<if"), "nested if should be compiled");
2534 assert!(!result.contains("{{"), "nested bindings should be compiled");
2535 }
2536
2537 #[test]
2538 fn test_for_inside_if_compiles_nested_blocks() {
2539 let result = generate_compiled_template(
2540 "my-comp",
2541 r#"<if condition="showList"><for each="item in items"><p>{{item.name}}</p></for></if>"#,
2542 );
2543
2544 assert_no_client_markers(&result);
2545 assert!(result.contains(",b:["), "compiled block table expected");
2546 assert!(
2547 result.contains(r#"[[function(v,s){return !!v("showList",s)},["showList"]],0,[[],0]]"#),
2548 "conditional block index expected"
2549 );
2550 assert!(
2551 result.contains(r#"["items","item",1,[[],0]]"#),
2552 "nested repeat block index expected"
2553 );
2554 assert!(!result.contains("<for"), "nested for should be compiled");
2555 assert!(!result.contains("{{"), "nested bindings should be compiled");
2556 }
2557
2558 #[test]
2559 fn test_nested_for_inside_for_compiles_nested_blocks() {
2560 let result = generate_compiled_template(
2561 "my-comp",
2562 r#"<for each="group in groups"><div><for each="item in group.items"><button data-group="{{group.name}}" ?disabled="{{item.disabled}}">{{item.label}}</button></for></div></for>"#,
2563 );
2564
2565 assert_no_client_markers(&result);
2566 assert!(result.contains(",b:["), "compiled block table expected");
2567 assert!(
2568 result.contains(r#"["groups","group",0,[[],0]]"#),
2569 "outer repeat block index expected"
2570 );
2571 assert!(
2572 result.contains(r#"["group.items","item",1,[[0],0]]"#),
2573 "inner repeat block index expected"
2574 );
2575 assert!(!result.contains("<for"), "nested for should be compiled");
2576 assert!(!result.contains("{{"), "nested bindings should be compiled");
2577 assert!(
2578 !result.contains("?disabled"),
2579 "nested boolean attrs should be compiled"
2580 );
2581 }
2582
2583 #[test]
2584 fn test_multiple_events_on_same_element() {
2585 let result = generate_compiled_template(
2586 "my-comp",
2587 r#"<input @keydown="{onKey(e)}" @focus="{onFocus()}" />"#,
2588 );
2589 assert_no_client_markers(&result);
2590 assert!(result.contains("\"keydown\""), "keydown event expected");
2591 assert!(result.contains("\"onKey\""), "onKey handler expected");
2592 assert!(result.contains("\"focus\""), "focus event expected");
2593 assert!(result.contains("\"onFocus\""), "onFocus handler expected");
2594 }
2595
2596 #[test]
2597 fn test_regular_tag_uses_single_event_marker_per_element() {
2598 let mut meta = TemplateSectionMeta::default();
2599 let (tag_html, _) = parse_regular_tag(
2600 r#"<input @keydown="{onKey(e)}" @focus="{onFocus()}" />"#,
2601 &mut meta,
2602 )
2603 .expect("regular tag should parse");
2604
2605 assert_eq!(tag_html, r#"<input data-ev="2" />"#);
2606 }
2607
2608 #[test]
2609 fn test_empty_template() {
2610 let result = generate_compiled_template("my-comp", "");
2611 assert!(result.contains("__webui.templates"));
2612 assert!(result.contains("h:\"\""), "empty html expected");
2613 assert!(!result.contains(",t:"), "no text bindings");
2615 assert!(!result.contains(",c:"), "no conditionals");
2616 assert!(!result.contains(",r:"), "no repeats");
2617 assert!(!result.contains(",e:"), "no events");
2618 assert!(!result.contains(",re:"), "no root events");
2619 }
2620
2621 #[test]
2622 fn test_whitespace_only_template() {
2623 let result = generate_compiled_template("my-comp", " \n ");
2624 assert!(result.contains("__webui.templates"));
2625 assert!(
2626 result.contains("h:\"\""),
2627 "whitespace-only should produce empty html"
2628 );
2629 }
2630
2631 #[test]
2632 fn test_multiple_text_bindings() {
2633 let result = generate_compiled_template(
2634 "my-comp",
2635 r#"<p>{{first}}</p><p>{{second}}</p><p>{{third}}</p>"#,
2636 );
2637 assert_no_client_markers(&result);
2638 assert!(result.contains(
2639 r#",tx:[[[[0],0],[["first"]]],[[[1],0],[["second"]]],[[[2],0],[["third"]]]]"#
2640 ));
2641 assert!(result.contains("\"first\""));
2642 assert!(result.contains("\"second\""));
2643 assert!(result.contains("\"third\""));
2644 }
2645
2646 #[test]
2647 fn test_multiple_conditionals() {
2648 let result = generate_compiled_template(
2649 "my-comp",
2650 r#"<if condition="a"><span>A</span></if><if condition="b"><span>B</span></if>"#,
2651 );
2652 assert_no_client_markers(&result);
2653 }
2654
2655 #[test]
2656 fn test_locator_paths_merge_adjacent_static_text_nodes() {
2657 let result = generate_compiled_template(
2658 "mp-product-grid",
2659 r#"
2660<template shadowrootmode="open">
2661 <if condition="products.length == 0 && query">
2662 <p class="empty-results">There are no products that match "<strong>{{query}}</strong>"</p>
2663 </if>
2664 <div class="grid">
2665 <for each="product in products">
2666 <mp-product-card
2667 handle="{{product.handle}}"
2668 title="{{product.title}}"
2669 price="{{product.price}}"
2670 gradient="{{product.gradient}}"
2671 image-url="{{product.imageUrl}}"
2672 image-loading="lazy"
2673 variant="grid"
2674 ></mp-product-card>
2675 </for>
2676 </div>
2677</template>
2678"#,
2679 );
2680
2681 assert_no_client_markers(&result);
2682 }
2683
2684 #[test]
2685 fn test_mixed_bindings_events_refs() {
2686 let result = generate_compiled_template(
2687 "my-comp",
2688 r#"<button w-ref="{myBtn}" @click="{onClick()}">{{label}}</button>"#,
2689 );
2690 assert_no_client_markers(&result);
2692 assert!(result.contains("w-ref"));
2693 assert!(result.contains("myBtn"));
2694 assert!(result.contains("\"click\""));
2696 assert!(result.contains("\"onClick\""));
2697 assert!(result.contains(r#",tx:[[[[0],0],[["label"]]]]"#));
2699 assert!(result.contains("\"label\""));
2700 }
2701
2702 #[test]
2703 fn test_event_without_e_arg() {
2704 let result =
2705 generate_compiled_template("my-comp", r#"<button @click="{onClick()}">Go</button>"#);
2706 assert!(
2707 result.contains(r#"["click","onClick",0,[0]]"#),
2708 "needs_event should be 0"
2709 );
2710 }
2711
2712 #[test]
2713 fn test_root_event_without_e_arg() {
2714 let result = generate_compiled_template(
2715 "my-comp",
2716 r#"<template shadowrootmode="open" @submit="{onSubmit()}"><form>hi</form></template>"#,
2717 );
2718 assert!(result.contains(",re:["));
2719 assert!(result.contains("\"submit\""));
2720 assert!(result.contains("\"onSubmit\""));
2721 assert!(result.contains(",0]"), "needs_event should be 0 for no (e)");
2722 }
2723
2724 #[test]
2725 fn test_triple_brace_raw_binding() {
2726 let result = generate_compiled_template("my-comp", r#"<div>{{{rawHtml}}}</div>"#);
2727 assert_no_client_markers(&result);
2728 assert!(
2729 result.contains(r#",tx:[[[[0],0],[["rawHtml"]],1]]"#),
2730 "triple brace produces text locator with raw flag: {result}"
2731 );
2732 assert!(
2733 result.contains("\"rawHtml\""),
2734 "rawHtml path in text bindings"
2735 );
2736 }
2737
2738 #[test]
2739 fn test_for_with_complex_body() {
2740 let result = generate_compiled_template(
2741 "my-comp",
2742 r#"<for each="user in users"><div class="card"><h2>{{user.name}}</h2><p>{{user.bio}}</p></div></for>"#,
2743 );
2744 assert_no_client_markers(&result);
2745 assert!(result.contains("\"users\""), "collection path");
2746 assert!(result.contains("\"user\""), "item variable");
2747 assert!(result.contains(",b:["), "compiled block table expected");
2748 assert!(
2749 !result.contains("{{user.name}}"),
2750 "bindings should be compiled"
2751 );
2752 assert!(
2753 !result.contains("{{user.bio}}"),
2754 "bindings should be compiled"
2755 );
2756 }
2757
2758 #[test]
2759 fn test_mixed_text_uses_text_runs() {
2760 let result = generate_compiled_template("my-comp", r#"<p>Hello {{name}}!</p>"#);
2761
2762 assert_no_client_markers(&result);
2763 assert!(result.contains(r#"h:"<p></p>""#));
2764 assert!(result.contains(r#",tx:[[[[0],0],["Hello ",["name"],"!"]]]"#));
2765 }
2766
2767 #[test]
2768 fn test_js_string_escaping() {
2769 let result = generate_compiled_template(
2770 "my-comp",
2771 r#"<if condition="name == "test""><span>ok</span></if>"#,
2772 );
2773 assert!(result.contains(",c:["), "conditional array present");
2774 assert!(
2776 result.contains(r#"function(v,s){return v("name",s)==v(""test"",s)}"#),
2777 "condition function with escaped quotes should be present: {}",
2778 result
2779 );
2780 }
2781
2782 #[test]
2783 fn test_js_string_escapes_script_breakout_sequences() {
2784 let result = generate_compiled_template(
2785 "my-comp",
2786 r#"<div title="</script><script>alert(1)</script>"></div>"#,
2787 );
2788
2789 assert!(
2790 result.contains(r#"\u003C/script><script>alert(1)\u003C/script>"#),
2791 "script-closing sequences should be neutralized inside metadata strings: {result}"
2792 );
2793 assert!(
2794 !result.contains(r#"title=\"</script><script>alert(1)</script>\""#),
2795 "raw </script> must not appear inside serialized metadata strings: {result}"
2796 );
2797 }
2798
2799 #[test]
2800 fn test_js_string_escapes_line_separator_codepoints() {
2801 let html = format!("<p>before{}middle{}after</p>", '\u{2028}', '\u{2029}');
2802 let result = generate_compiled_template("my-comp", &html);
2803
2804 assert!(
2805 result.contains(r#"\u2028"#),
2806 "U+2028 should be escaped in metadata strings: {result}"
2807 );
2808 assert!(
2809 result.contains(r#"\u2029"#),
2810 "U+2029 should be escaped in metadata strings: {result}"
2811 );
2812 }
2813
2814 #[test]
2815 fn test_on_element_parsed_encodes_12_bytes() {
2816 let mut plugin = WebUIParserPlugin::new();
2817 let _ = plugin.classify_attribute("@click");
2819 let _ = plugin.classify_attribute("@keydown");
2820 let data = plugin.finish_element(3);
2822 assert!(data.is_some());
2823 let bytes = data.unwrap();
2824 assert_eq!(bytes.len(), 12, "plugin data should be 12 bytes");
2825 let binding_count = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
2826 let event_start = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
2827 let event_count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
2828 assert_eq!(binding_count, 3);
2829 assert_eq!(event_start, 0);
2830 assert_eq!(event_count, 2);
2831 }
2832
2833 #[test]
2834 fn test_on_element_parsed_no_data_when_empty() {
2835 let mut plugin = WebUIParserPlugin::new();
2836 let data = plugin.finish_element(0);
2837 assert!(
2838 data.is_none(),
2839 "no plugin data when no bindings and no events"
2840 );
2841 }
2842
2843 #[test]
2844 fn test_on_element_parsed_events_only() {
2845 let mut plugin = WebUIParserPlugin::new();
2846 let _ = plugin.classify_attribute("@click");
2847 let data = plugin.finish_element(0);
2848 assert!(
2849 data.is_some(),
2850 "should emit data for events even with 0 bindings"
2851 );
2852 let bytes = data.unwrap();
2853 let binding_count = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
2854 let event_count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
2855 assert_eq!(binding_count, 0);
2856 assert_eq!(event_count, 1);
2857 }
2858
2859 #[test]
2860 fn test_event_index_increments_across_elements() {
2861 let mut plugin = WebUIParserPlugin::new();
2862 let _ = plugin.classify_attribute("@click");
2864 let _ = plugin.classify_attribute("@focus");
2865 let data1 = plugin.finish_element(0).unwrap();
2866 let ev_start_1 = u32::from_le_bytes([data1[4], data1[5], data1[6], data1[7]]);
2867 assert_eq!(ev_start_1, 0, "first element starts at event 0");
2868
2869 let _ = plugin.classify_attribute("@blur");
2871 let data2 = plugin.finish_element(0).unwrap();
2872 let ev_start_2 = u32::from_le_bytes([data2[4], data2[5], data2[6], data2[7]]);
2873 assert_eq!(
2874 ev_start_2, 2,
2875 "second element starts at event 2 (after first element's 2 events)"
2876 );
2877 }
2878
2879 #[test]
2880 fn test_text_run_decodes_html_entities_in_static_parts() {
2881 let result =
2887 generate_compiled_template("my-comp", "<nav>{{sectionName}} > {{topicName}}</nav>");
2888 assert_no_client_markers(&result);
2889 assert!(
2891 result.contains(r#"" > ""#),
2892 "static text run part should contain decoded '>' not '>': {}",
2893 result
2894 );
2895 assert!(
2896 !result.contains(r#"" > ""#),
2897 "static text run part should not contain raw '>': {}",
2898 result
2899 );
2900 }
2901
2902 #[test]
2903 fn test_parse_event_handler_bare_name_is_invalid() {
2904 assert!(matches!(
2905 parse_event_handler("{closeMenu}"),
2906 EventHandler::Invalid(ref name) if name == "closeMenu"
2907 ));
2908 }
2909
2910 #[test]
2911 #[should_panic(expected = "Invalid @click handler")]
2912 fn test_parse_event_attr_bare_name_panics() {
2913 let input = r#"<button @click="{closeMenu}">Click</button>"#;
2914 parse_event_attr(input, 8);
2915 }
2916
2917 #[test]
2918 fn test_parse_event_handler_with_parens() {
2919 assert!(matches!(
2920 parse_event_handler("{onClick()}"),
2921 EventHandler::Valid(ref name, false) if name == "onClick"
2922 ));
2923 }
2924
2925 #[test]
2926 fn test_parse_event_handler_with_event_arg() {
2927 assert!(matches!(
2928 parse_event_handler("{onClick(e)}"),
2929 EventHandler::Valid(ref name, true) if name == "onClick"
2930 ));
2931 }
2932
2933 #[test]
2934 fn test_parse_event_handler_empty_value() {
2935 assert!(matches!(parse_event_handler("{}"), EventHandler::Empty));
2936 }
2937
2938 #[test]
2939 fn test_attribute_to_camel_aria() {
2940 use webui_protocol::attrs::attribute_to_camel;
2941 assert_eq!(attribute_to_camel("aria-describedby"), "ariaDescribedBy");
2942 assert_eq!(attribute_to_camel("aria-labelledby"), "ariaLabelledBy");
2943 assert_eq!(
2944 attribute_to_camel("aria-activedescendant"),
2945 "ariaActiveDescendant"
2946 );
2947 assert_eq!(attribute_to_camel("aria-label"), "ariaLabel");
2948 assert_eq!(attribute_to_camel("aria-hidden"), "ariaHidden");
2949 }
2950
2951 #[test]
2952 fn test_attribute_to_camel_html_global() {
2953 use webui_protocol::attrs::attribute_to_camel;
2954 assert_eq!(attribute_to_camel("readonly"), "readOnly");
2955 assert_eq!(attribute_to_camel("tabindex"), "tabIndex");
2956 assert_eq!(attribute_to_camel("accesskey"), "accessKey");
2957 assert_eq!(attribute_to_camel("contenteditable"), "contentEditable");
2958 assert_eq!(attribute_to_camel("inputmode"), "inputMode");
2959 assert_eq!(attribute_to_camel("maxlength"), "maxLength");
2960 assert_eq!(attribute_to_camel("formaction"), "formAction");
2961 }
2962}