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