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