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