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