1use super::{AttributeAction, ParserPlugin, ParserPluginArtifacts};
63use crate::comment_policy;
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
220const TEXT_MARKER_PREFIX: &str = "<!--t:";
223const TEXT_MARKER_SUFFIX: &str = "-->";
224const MIN_TEXT_MARKER_DIGITS: usize = 1;
225const MIN_TEXT_MARKER_LEN: usize =
226 TEXT_MARKER_PREFIX.len() + MIN_TEXT_MARKER_DIGITS + TEXT_MARKER_SUFFIX.len();
227const TEXT_MARKER_INDEX_RADIX: usize = 10;
228
229#[derive(Default)]
237struct TemplateSectionMeta {
238 html: String,
240 text_bindings: Vec<(String, bool)>,
244 text_marker_offsets: Vec<usize>,
247 text_runs: Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
250 attr_bindings: Vec<CompiledAttrBinding>,
252 attr_groups: Vec<(Vec<usize>, usize, usize)>,
254 conditionals: Vec<(ConditionExpr, usize)>,
256 condition_slots: Vec<SlotLocator>,
258 repeats: Vec<(String, String, usize)>,
260 repeat_slots: Vec<SlotLocator>,
262 events: Vec<EventBinding>,
264 event_targets: Vec<Vec<usize>>,
266}
267
268#[derive(Clone)]
269struct SlotLocator {
270 parent_path: Vec<usize>,
271 before_index: usize,
272 order: usize,
273}
274
275struct TemplateMeta {
280 root: TemplateSectionMeta,
281 blocks: Vec<TemplateSectionMeta>,
282 root_events: Vec<EventBinding>,
285}
286
287type EventBinding = (String, String, Vec<EventArg>);
288
289#[derive(Clone, Debug, PartialEq)]
290enum EventArg {
291 Event,
292 Path(String),
293 String(String),
294 Number(String),
295 Bool(bool),
296 Null,
297}
298
299enum CompiledAttrBinding {
300 Simple {
301 name: String,
302 value: String,
303 },
304 Complex {
305 name: String,
306 value: String,
307 },
308 Boolean {
309 name: String,
310 condition: ConditionExpr,
311 },
312 Template {
313 name: String,
314 parts: Vec<CompiledAttrPart>,
315 },
316}
317
318enum CompiledAttrPart {
319 Static(String),
320 Dynamic(String),
321}
322
323pub fn generate_compiled_template(tag_name: &str, html_content: &str) -> String {
354 generate_compiled_template_with_root_source(tag_name, html_content, html_content, false)
355}
356
357fn generate_compiled_template_with_root_source(
358 tag_name: &str,
359 html_content: &str,
360 root_event_source: &str,
361 shadow_dom: bool,
362) -> String {
363 let trimmed = html_content.trim();
364 let root_events = extract_root_events(root_event_source.trim());
365 let adopted_stylesheet = extract_adopted_stylesheet_specifier(trimmed);
366 let body = strip_template_wrapper(trimmed);
367 let meta = compile_to_metadata(body, root_events);
368
369 let mut out = String::with_capacity(512 + html_content.len());
370 out.push_str("(function(){var w=(window.__webui||(window.__webui={})).templates||(window.__webui.templates={});w['");
371 out.push_str(tag_name);
372 out.push_str("']={");
373
374 emit_js_template_section(&meta.root, &mut out);
375
376 if let Some(adopted_stylesheet) = adopted_stylesheet.as_deref() {
377 out.push_str(",sa:");
378 emit_js_string(adopted_stylesheet, &mut out);
379 }
380
381 if shadow_dom {
383 out.push_str(",sd:1");
384 }
385
386 if !meta.root_events.is_empty() {
388 out.push_str(",re:[");
389 for (i, (event, handler, args)) in meta.root_events.iter().enumerate() {
390 if i > 0 {
391 out.push(',');
392 }
393 out.push('[');
394 emit_js_string(event, &mut out);
395 out.push(',');
396 emit_js_string(handler, &mut out);
397 out.push(',');
398 emit_js_event_args(args, &mut out);
399 out.push(']');
400 }
401 out.push(']');
402 }
403
404 if !meta.blocks.is_empty() {
406 out.push_str(",b:[");
407 for (i, block) in meta.blocks.iter().enumerate() {
408 if i > 0 {
409 out.push(',');
410 }
411 out.push('{');
412 emit_js_template_section(block, &mut out);
413 out.push('}');
414 }
415 out.push(']');
416 }
417
418 out.push_str("};})();\n");
419 out
420}
421
422fn emit_js_string(s: &str, out: &mut String) {
423 out.push('"');
424 let mut index = 0usize;
425 while index < s.len() {
426 let remaining = &s[index..];
427 if remaining.as_bytes().starts_with(b"</")
428 && remaining
429 .as_bytes()
430 .get(2..8)
431 .map(|bytes| bytes.eq_ignore_ascii_case(b"script"))
432 .unwrap_or(false)
433 {
434 out.push_str("\\u003C");
435 index += 1;
436 continue;
437 }
438 let Some(ch) = remaining.chars().next() else {
439 break;
440 };
441 match ch {
442 '\0' => out.push_str("\\0"),
443 '"' => out.push_str("\\\""),
444 '\\' => out.push_str("\\\\"),
445 '\n' => out.push_str("\\n"),
446 '\r' => out.push_str("\\r"),
447 '\u{2028}' => out.push_str("\\u2028"),
448 '\u{2029}' => out.push_str("\\u2029"),
449 _ => out.push(ch),
450 }
451 index += ch.len_utf8();
452 }
453 out.push('"');
454}
455
456fn emit_js_event_args(args: &[EventArg], out: &mut String) {
457 out.push('[');
458 for (i, arg) in args.iter().enumerate() {
459 if i > 0 {
460 out.push(',');
461 }
462 match arg {
463 EventArg::Event => out.push_str(r#"["e"]"#),
464 EventArg::Path(path) => {
465 out.push_str(r#"["p","#);
466 emit_js_string(path, out);
467 out.push(']');
468 }
469 EventArg::String(value) => {
470 out.push_str(r#"["s","#);
471 emit_js_string(value, out);
472 out.push(']');
473 }
474 EventArg::Number(value) => {
475 out.push_str(r#"["n","#);
476 out.push_str(value);
477 out.push(']');
478 }
479 EventArg::Bool(value) => out.push_str(if *value { r#"["b",1]"# } else { r#"["b",0]"# }),
480 EventArg::Null => out.push_str(r#"["z"]"#),
481 }
482 }
483 out.push(']');
484}
485
486fn emit_js_attr_binding(binding: &CompiledAttrBinding, out: &mut String) {
487 out.push('[');
488 match binding {
489 CompiledAttrBinding::Simple { name, value } => {
490 emit_js_string(name, out);
491 out.push_str(",0,");
492 emit_js_string(value, out);
493 }
494 CompiledAttrBinding::Complex { name, value } => {
495 emit_js_string(name, out);
496 out.push_str(",1,");
497 emit_js_string(value, out);
498 }
499 CompiledAttrBinding::Boolean { name, condition } => {
500 emit_js_string(name, out);
501 out.push_str(",2,");
502 emit_js_condition(condition, out);
503 }
504 CompiledAttrBinding::Template { name, parts } => {
505 emit_js_string(name, out);
506 out.push_str(",3,[");
507 for (i, part) in parts.iter().enumerate() {
508 if i > 0 {
509 out.push(',');
510 }
511 match part {
512 CompiledAttrPart::Static(value) => emit_js_string(value, out),
513 CompiledAttrPart::Dynamic(path) => {
514 out.push('[');
515 emit_js_string(path, out);
516 out.push(']');
517 }
518 }
519 }
520 out.push(']');
521 }
522 }
523 out.push(']');
524}
525
526fn emit_js_condition(condition: &ConditionExpr, out: &mut String) {
530 out.push_str("[function(v,s){return ");
531 emit_js_condition_expr(condition, out);
532 out.push_str("},[");
533 let mut paths = Vec::new();
534 collect_condition_paths(condition, &mut paths);
535 for (i, path) in paths.iter().enumerate() {
536 if i > 0 {
537 out.push(',');
538 }
539 emit_js_string(path, out);
540 }
541 out.push_str("]]");
542}
543
544fn emit_js_condition_expr(condition: &ConditionExpr, out: &mut String) {
546 match &condition.expr {
547 Some(condition_expr::Expr::Identifier(id)) => {
548 out.push_str("!!v(");
550 emit_js_string(&id.value, out);
551 out.push_str(",s)");
552 }
553 Some(condition_expr::Expr::Predicate(pred)) => {
554 out.push_str("v(");
556 emit_js_string(&pred.left, out);
557 out.push_str(",s)");
558 match pred.operator {
559 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("=="),
566 }
567 emit_js_predicate_right(&pred.right, out);
569 }
570 Some(condition_expr::Expr::Not(not_cond)) => {
571 out.push('!');
572 if let Some(inner) = not_cond.condition.as_ref() {
573 out.push('(');
574 emit_js_condition_expr(inner, out);
575 out.push(')');
576 } else {
577 out.push_str("false");
578 }
579 }
580 Some(condition_expr::Expr::Compound(compound)) => {
581 out.push('(');
582 if let Some(left) = compound.left.as_ref() {
583 emit_js_condition_expr(left, out);
584 } else {
585 out.push_str("false");
586 }
587 match compound.op {
588 1 => out.push_str("&&"), 2 => out.push_str("||"), _ => out.push_str("&&"),
591 }
592 if let Some(right) = compound.right.as_ref() {
593 emit_js_condition_expr(right, out);
594 } else {
595 out.push_str("false");
596 }
597 out.push(')');
598 }
599 None => out.push_str("false"),
600 }
601}
602
603fn emit_js_predicate_right(value: &str, out: &mut String) {
606 if (value.starts_with('"') && value.ends_with('"'))
608 || (value.starts_with('\'') && value.ends_with('\''))
609 {
610 emit_js_string(&value[1..value.len() - 1], out);
612 return;
613 }
614 if value == "true" || value == "false" {
616 out.push_str(value);
617 return;
618 }
619 if value
621 .bytes()
622 .all(|b| b.is_ascii_digit() || b == b'.' || b == b'-')
623 && !value.is_empty()
624 {
625 out.push_str(value);
626 return;
627 }
628 out.push_str("v(");
630 emit_js_string(value, out);
631 out.push_str(",s)");
632}
633
634fn collect_condition_paths(condition: &ConditionExpr, paths: &mut Vec<String>) {
636 match &condition.expr {
637 Some(condition_expr::Expr::Identifier(id)) => {
638 paths.push(id.value.clone());
639 }
640 Some(condition_expr::Expr::Predicate(pred)) => {
641 paths.push(pred.left.clone());
642 let r = &pred.right;
644 if !((r.starts_with('"') && r.ends_with('"'))
645 || (r.starts_with('\'') && r.ends_with('\''))
646 || r == "true"
647 || r == "false"
648 || r.bytes()
649 .all(|b| b.is_ascii_digit() || b == b'.' || b == b'-'))
650 {
651 paths.push(r.clone());
652 }
653 }
654 Some(condition_expr::Expr::Not(not_cond)) => {
655 if let Some(inner) = not_cond.condition.as_ref() {
656 collect_condition_paths(inner, paths);
657 }
658 }
659 Some(condition_expr::Expr::Compound(compound)) => {
660 if let Some(left) = compound.left.as_ref() {
661 collect_condition_paths(left, paths);
662 }
663 if let Some(right) = compound.right.as_ref() {
664 collect_condition_paths(right, paths);
665 }
666 }
667 None => {}
668 }
669}
670
671fn emit_js_node_path(path: &[usize], out: &mut String) {
672 out.push('[');
673 for (i, index) in path.iter().enumerate() {
674 if i > 0 {
675 out.push(',');
676 }
677 let _ = write!(out, "{}", index);
678 }
679 out.push(']');
680}
681
682fn emit_js_slot(slot: &SlotLocator, out: &mut String) {
683 out.push('[');
684 emit_js_node_path(&slot.parent_path, out);
685 out.push(',');
686 let _ = write!(out, "{}", slot.before_index);
687 if slot.order > 0 {
688 out.push(',');
689 let _ = write!(out, "{}", slot.order);
690 }
691 out.push(']');
692}
693
694fn emit_js_text_parts(parts: &[CompiledAttrPart], out: &mut String) {
695 out.push('[');
696 for (i, part) in parts.iter().enumerate() {
697 if i > 0 {
698 out.push(',');
699 }
700 match part {
701 CompiledAttrPart::Static(value) => emit_js_string(value, out),
702 CompiledAttrPart::Dynamic(path) => {
703 out.push('[');
704 emit_js_string(path, out);
705 out.push(']');
706 }
707 }
708 }
709 out.push(']');
710}
711
712fn emit_js_template_section(meta: &TemplateSectionMeta, out: &mut String) {
713 out.push_str("h:");
714 emit_js_string(&meta.html, out);
715
716 if !meta.text_runs.is_empty() {
717 out.push_str(",tx:[");
718 for (i, (slot, parts, raw)) in meta.text_runs.iter().enumerate() {
719 if i > 0 {
720 out.push(',');
721 }
722 out.push('[');
723 emit_js_slot(slot, out);
724 out.push(',');
725 emit_js_text_parts(parts, out);
726 if *raw {
727 out.push_str(",1");
728 }
729 out.push(']');
730 }
731 out.push(']');
732 }
733
734 if !meta.attr_bindings.is_empty() {
735 out.push_str(",a:[");
736 for (i, binding) in meta.attr_bindings.iter().enumerate() {
737 if i > 0 {
738 out.push(',');
739 }
740 emit_js_attr_binding(binding, out);
741 }
742 out.push(']');
743 }
744
745 if !meta.attr_groups.is_empty() {
746 out.push_str(",ag:[");
747 for (i, (path, start, count)) in meta.attr_groups.iter().enumerate() {
748 if i > 0 {
749 out.push(',');
750 }
751 out.push('[');
752 emit_js_node_path(path, out);
753 out.push(',');
754 let _ = write!(out, "{}", start);
755 out.push(',');
756 let _ = write!(out, "{}", count);
757 out.push(']');
758 }
759 out.push(']');
760 }
761
762 if !meta.conditionals.is_empty() {
763 out.push_str(",c:[");
764 for (i, ((cond, block_index), slot)) in meta
765 .conditionals
766 .iter()
767 .zip(meta.condition_slots.iter())
768 .enumerate()
769 {
770 if i > 0 {
771 out.push(',');
772 }
773 out.push('[');
774 emit_js_condition(cond, out);
775 out.push(',');
776 let _ = write!(out, "{}", block_index);
777 out.push(',');
778 emit_js_slot(slot, out);
779 out.push(']');
780 }
781 out.push(']');
782 }
783
784 if !meta.repeats.is_empty() {
785 out.push_str(",r:[");
786 for (i, ((collection, item_var, block_index), slot)) in meta
787 .repeats
788 .iter()
789 .zip(meta.repeat_slots.iter())
790 .enumerate()
791 {
792 if i > 0 {
793 out.push(',');
794 }
795 out.push('[');
796 emit_js_string(collection, out);
797 out.push(',');
798 emit_js_string(item_var, out);
799 out.push(',');
800 let _ = write!(out, "{}", block_index);
801 out.push(',');
802 emit_js_slot(slot, out);
803 out.push(']');
804 }
805 out.push(']');
806 }
807
808 if !meta.events.is_empty() {
809 out.push_str(",e:[");
810 for (i, ((event, handler, args), target)) in meta
811 .events
812 .iter()
813 .zip(meta.event_targets.iter())
814 .enumerate()
815 {
816 if i > 0 {
817 out.push(',');
818 }
819 out.push('[');
820 emit_js_string(event, out);
821 out.push(',');
822 emit_js_string(handler, out);
823 out.push(',');
824 emit_js_event_args(args, out);
825 out.push(',');
826 emit_js_node_path(target, out);
827 out.push(']');
828 }
829 out.push(']');
830 }
831}
832
833fn compile_to_metadata(input: &str, root_events: Vec<EventBinding>) -> TemplateMeta {
852 let mut blocks = Vec::new();
853 let mut root = compile_section(input, &mut blocks);
854 finalize_template_section(&mut root);
855 for block in &mut blocks {
856 finalize_template_section(block);
857 }
858 TemplateMeta {
859 root,
860 blocks,
861 root_events,
862 }
863}
864
865fn compile_section(input: &str, blocks: &mut Vec<TemplateSectionMeta>) -> TemplateSectionMeta {
866 let mut meta = TemplateSectionMeta {
867 html: String::with_capacity(input.len()),
868 text_bindings: Vec::new(),
869 text_marker_offsets: Vec::new(),
870 text_runs: Vec::new(),
871 attr_bindings: Vec::new(),
872 attr_groups: Vec::new(),
873 conditionals: Vec::new(),
874 condition_slots: Vec::new(),
875 repeats: Vec::new(),
876 repeat_slots: Vec::new(),
877 events: Vec::new(),
878 event_targets: Vec::new(),
879 };
880
881 let bytes = input.as_bytes();
882 let len = bytes.len();
883 let mut i = 0;
884
885 while i < len {
886 if !input.is_char_boundary(i) {
887 meta.html.push(bytes[i] as char);
888 i += 1;
889 continue;
890 }
891
892 if let Some(next) = compile_text_binding_at(input, i, &mut meta) {
893 i = next;
894 continue;
895 }
896
897 if bytes[i] == b'<' {
899 let remaining = &input[i..];
900 if remaining.starts_with("<!--") {
901 if let Some(close) = remaining.find("-->") {
902 i += close + 3;
903 continue;
904 }
905 }
906
907 if remaining.starts_with("<if ") || remaining.starts_with("<if\n") {
908 if let Some((cond, body, consumed)) = parse_if_block(remaining) {
909 let block_index = blocks.len();
910 blocks.push(TemplateSectionMeta::default());
911 let block = compile_section(&body, blocks);
912 blocks[block_index] = block;
913 let idx = meta.conditionals.len();
914 meta.conditionals.push((cond, block_index));
915 meta.html.push_str(&format!("<!--c:{idx}-->"));
916 i += consumed;
917 continue;
918 }
919 }
920
921 if remaining.starts_with("<for ") || remaining.starts_with("<for\n") {
923 if let Some((collection, item_var, body, consumed)) = parse_for_block(remaining) {
924 let block_index = blocks.len();
925 blocks.push(TemplateSectionMeta::default());
926 let block = compile_section(&body, blocks);
927 blocks[block_index] = block;
928 let idx = meta.repeats.len();
929 meta.repeats.push((collection, item_var, block_index));
930 meta.html.push_str(&format!("<!--r:{idx}-->"));
931 i += consumed;
932 continue;
933 }
934 }
935
936 if let Some((open_end, close_start, close_end)) = find_style_element_bounds(input, i) {
937 meta.html.push_str(&input[i..open_end]);
938 compile_style_content(&input[open_end..close_start], &mut meta);
939 meta.html.push_str(&input[close_start..close_end]);
940 i = close_end;
941 continue;
942 }
943
944 if remaining.starts_with("<outlet") {
946 if let Some(close) = find_tag_close(remaining) {
947 meta.html.push_str("<outlet></outlet>");
948 i += close + 1;
949 continue;
950 }
951 }
952
953 if let Some((tag_html, consumed)) = parse_regular_tag(remaining, &mut meta) {
954 meta.html.push_str(&tag_html);
955 i += consumed;
956 continue;
957 }
958 }
959
960 if bytes[i] == b'@' && is_inside_tag(input, i) {
962 if let Some((event_name, handler, args, consumed)) = parse_event_attr(input, i) {
963 meta.events.push((event_name, handler, args));
964 meta.html.push_str("data-ev=\"1\"");
965 i += consumed;
966 continue;
967 }
968 }
969
970 let ch = &input[i..];
975 if let Some(c) = ch.chars().next() {
976 meta.html.push(c);
977 i += c.len_utf8();
978 } else {
979 i += 1;
980 }
981 }
982
983 meta
984}
985
986fn compile_text_binding_at(
987 input: &str,
988 index: usize,
989 meta: &mut TemplateSectionMeta,
990) -> Option<usize> {
991 let bytes = input.as_bytes();
992
993 if index + 4 < bytes.len()
994 && bytes[index] == b'{'
995 && bytes[index + 1] == b'{'
996 && bytes[index + 2] == b'{'
997 {
998 if let Some(end) = find_brace_end(input, index + 3, 3) {
999 let expr = input[index + 3..end].trim();
1000 let idx = meta.text_bindings.len();
1001 meta.text_bindings.push((expr.to_string(), true));
1002 emit_text_marker(meta, idx);
1003 return Some(end + 3);
1004 }
1005 }
1006
1007 if index + 3 < bytes.len() && bytes[index] == b'{' && bytes[index + 1] == b'{' {
1008 if let Some(end) = find_brace_end(input, index + 2, 2) {
1009 let expr = input[index + 2..end].trim();
1010 let idx = meta.text_bindings.len();
1011 meta.text_bindings.push((expr.to_string(), false));
1012 emit_text_marker(meta, idx);
1013 return Some(end + 2);
1014 }
1015 }
1016
1017 None
1018}
1019
1020fn emit_text_marker(meta: &mut TemplateSectionMeta, index: usize) {
1021 meta.text_marker_offsets.push(meta.html.len());
1022 meta.html.push_str(TEXT_MARKER_PREFIX);
1023 let _ = write!(meta.html, "{index}");
1024 meta.html.push_str(TEXT_MARKER_SUFFIX);
1025}
1026
1027fn compile_style_content(input: &str, meta: &mut TemplateSectionMeta) {
1028 let bytes = input.as_bytes();
1029 let len = bytes.len();
1030 let mut index = 0usize;
1031
1032 while index < len {
1033 if index + 1 < len && bytes[index] == b'/' && bytes[index + 1] == b'*' {
1034 if let Some(close) = input[index + 2..].find("*/") {
1035 let end = index + 2 + close + 2;
1036 let comment = &input[index..end];
1037 if compile_css_signal_comment(comment, meta) {
1038 index = end;
1039 continue;
1040 }
1041 if comment_policy::is_legal_css_comment(comment) {
1042 meta.html.push_str(comment);
1043 }
1044 index = end;
1045 continue;
1046 }
1047 }
1048
1049 if comment_policy::is_css_line_comment_start(input, index) {
1050 let end = comment_policy::find_css_line_comment_end(input, index + 2);
1051 let comment = &input[index..end];
1052 if comment_policy::is_legal_css_comment(comment) {
1053 meta.html.push_str(comment);
1054 }
1055 index = end;
1056 continue;
1057 }
1058
1059 if let Some(next) = compile_text_binding_at(input, index, meta) {
1060 index = next;
1061 continue;
1062 }
1063
1064 let Some(ch) = input[index..].chars().next() else {
1065 break;
1066 };
1067 meta.html.push(ch);
1068 index += ch.len_utf8();
1069 }
1070}
1071
1072fn compile_css_signal_comment(comment: &str, meta: &mut TemplateSectionMeta) -> bool {
1073 if let Some(signal) = comment_policy::parse_css_signal_comment(comment) {
1074 let idx = meta.text_bindings.len();
1075 meta.text_bindings.push((signal.path, signal.raw));
1076 emit_text_marker(meta, idx);
1077 return true;
1078 }
1079
1080 false
1081}
1082
1083fn find_style_element_bounds(input: &str, index: usize) -> Option<(usize, usize, usize)> {
1084 let remaining = &input[index..];
1085 if !starts_with_style_start_tag(remaining) {
1086 return None;
1087 }
1088
1089 let open_close = find_tag_close(remaining)?;
1090 let content_start = index + open_close + 1;
1091 let (close_start, close_end) = find_style_end_tag(input, content_start)?;
1092 Some((content_start, close_start, close_end))
1093}
1094
1095fn starts_with_style_start_tag(input: &str) -> bool {
1096 let bytes = input.as_bytes();
1097 if bytes.len() < 6 || bytes[0] != b'<' || bytes.get(1) == Some(&b'/') {
1098 return false;
1099 }
1100 ascii_starts_with_ignore_case(&bytes[1..], b"style")
1101 && bytes
1102 .get(6)
1103 .is_some_and(|b| b.is_ascii_whitespace() || *b == b'>')
1104}
1105
1106fn find_style_end_tag(input: &str, start: usize) -> Option<(usize, usize)> {
1107 let bytes = input.as_bytes();
1108 let mut index = start;
1109
1110 while index + 8 <= bytes.len() {
1111 if bytes[index] == b'<'
1112 && bytes[index + 1] == b'/'
1113 && ascii_starts_with_ignore_case(&bytes[index + 2..], b"style")
1114 && bytes
1115 .get(index + 7)
1116 .is_some_and(|b| b.is_ascii_whitespace() || *b == b'>')
1117 {
1118 let close = find_tag_close(&input[index..])?;
1119 return Some((index, index + close + 1));
1120 }
1121 index += 1;
1122 }
1123
1124 None
1125}
1126
1127fn ascii_starts_with_ignore_case(input: &[u8], prefix: &[u8]) -> bool {
1128 input
1129 .get(..prefix.len())
1130 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
1131}
1132
1133#[derive(Clone)]
1134enum FragmentNode {
1135 Element(FragmentElement),
1136 Text(String),
1137 TextMarker(usize),
1138 Comment(String),
1139}
1140
1141#[derive(Clone)]
1142struct FragmentElement {
1143 tag_name: String,
1144 attrs: Vec<FragmentAttr>,
1145 children: Vec<FragmentNode>,
1146 self_closing: bool,
1147}
1148
1149#[derive(Clone)]
1150struct FragmentAttr {
1151 name: String,
1152 value: Option<String>,
1153}
1154
1155fn finalize_template_section(meta: &mut TemplateSectionMeta) {
1156 let raw_html = std::mem::take(&mut meta.html);
1157 let text_marker_offsets = std::mem::take(&mut meta.text_marker_offsets);
1158 let nodes = parse_fragment_nodes(&raw_html, &text_marker_offsets);
1159 let text_bindings = meta.text_bindings.clone();
1160 let mut finalized_html = String::with_capacity(raw_html.len());
1161 let mut text_runs = Vec::new();
1162 let mut attr_groups = Vec::new();
1163 let mut condition_slots = vec![None; meta.conditionals.len()];
1164 let mut repeat_slots = vec![None; meta.repeats.len()];
1165 let mut event_targets = vec![None; meta.events.len()];
1166 let mut event_cursor = 0usize;
1167
1168 process_fragment_children(
1169 &nodes,
1170 &[],
1171 &text_bindings,
1172 &mut finalized_html,
1173 &mut text_runs,
1174 &mut attr_groups,
1175 &mut condition_slots,
1176 &mut repeat_slots,
1177 &mut event_targets,
1178 &mut event_cursor,
1179 );
1180 debug_assert_eq!(event_cursor, meta.events.len());
1181
1182 meta.html = finalized_html;
1183 meta.text_runs = text_runs;
1184 meta.attr_groups = attr_groups;
1185 meta.condition_slots = condition_slots.into_iter().flatten().collect();
1186 meta.repeat_slots = repeat_slots.into_iter().flatten().collect();
1187 meta.event_targets = event_targets.into_iter().flatten().collect();
1188}
1189
1190#[allow(clippy::too_many_arguments)]
1191fn process_fragment_children(
1192 nodes: &[FragmentNode],
1193 parent_path: &[usize],
1194 text_bindings: &[(String, bool)],
1195 out: &mut String,
1196 text_runs: &mut Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
1197 attr_groups: &mut Vec<(Vec<usize>, usize, usize)>,
1198 condition_slots: &mut [Option<SlotLocator>],
1199 repeat_slots: &mut [Option<SlotLocator>],
1200 event_targets: &mut [Option<Vec<usize>>],
1201 event_cursor: &mut usize,
1202) {
1203 let mut child_index = 0usize;
1204 let mut index = 0usize;
1205 let mut previous_emitted_text = false;
1208 let mut slot_orders = std::collections::BTreeMap::<usize, usize>::new();
1209
1210 while index < nodes.len() {
1211 if let Some((parts, consumed, has_dynamic, is_raw)) =
1212 collect_text_run(&nodes[index..], text_bindings)
1213 {
1214 if has_dynamic {
1215 let decoded_parts = parts
1221 .into_iter()
1222 .map(|part| match part {
1223 CompiledAttrPart::Static(s) => {
1224 let decoded = html_escape::decode_html_entities(&s);
1225 CompiledAttrPart::Static(decoded.into_owned())
1226 }
1227 other => other,
1228 })
1229 .collect();
1230 let order = slot_orders.get(&child_index).copied().unwrap_or(0);
1231 slot_orders.insert(child_index, order + 1);
1232 text_runs.push((
1233 SlotLocator {
1234 parent_path: parent_path.to_vec(),
1235 before_index: child_index,
1236 order,
1237 },
1238 decoded_parts,
1239 is_raw,
1240 ));
1241 } else {
1242 let static_text = collect_static_text(&parts);
1243 if !static_text.is_empty() {
1244 out.push_str(&static_text);
1245 if !previous_emitted_text {
1246 child_index += 1;
1247 }
1248 previous_emitted_text = true;
1249 }
1250 }
1251 index += consumed;
1252 continue;
1253 }
1254
1255 match &nodes[index] {
1256 FragmentNode::TextMarker(_) => {}
1257 FragmentNode::Comment(data) => {
1258 if let Some(idx) = parse_marker_index(data, "c:") {
1259 if let Some(slot) = condition_slots.get_mut(idx) {
1260 let order = slot_orders.get(&child_index).copied().unwrap_or(0);
1261 slot_orders.insert(child_index, order + 1);
1262 *slot = Some(SlotLocator {
1263 parent_path: parent_path.to_vec(),
1264 before_index: child_index,
1265 order,
1266 });
1267 }
1268 } else if let Some(idx) = parse_marker_index(data, "r:") {
1269 if let Some(slot) = repeat_slots.get_mut(idx) {
1270 let order = slot_orders.get(&child_index).copied().unwrap_or(0);
1271 slot_orders.insert(child_index, order + 1);
1272 *slot = Some(SlotLocator {
1273 parent_path: parent_path.to_vec(),
1274 before_index: child_index,
1275 order,
1276 });
1277 }
1278 } else {
1279 out.push_str("<!--");
1280 out.push_str(data);
1281 out.push_str("-->");
1282 child_index += 1;
1283 previous_emitted_text = false;
1284 }
1285 }
1286 FragmentNode::Text(text) => {
1287 out.push_str(text);
1288 if !text.is_empty() {
1289 if !previous_emitted_text {
1290 child_index += 1;
1291 }
1292 previous_emitted_text = true;
1293 }
1294 }
1295 FragmentNode::Element(element) => {
1296 let mut element_path = parent_path.to_vec();
1297 element_path.push(child_index);
1298 serialize_fragment_element(
1299 element,
1300 &element_path,
1301 text_bindings,
1302 out,
1303 text_runs,
1304 attr_groups,
1305 condition_slots,
1306 repeat_slots,
1307 event_targets,
1308 event_cursor,
1309 );
1310 child_index += 1;
1311 previous_emitted_text = false;
1312 }
1313 }
1314
1315 index += 1;
1316 }
1317}
1318
1319#[allow(clippy::too_many_arguments)]
1320fn serialize_fragment_element(
1321 element: &FragmentElement,
1322 element_path: &[usize],
1323 text_bindings: &[(String, bool)],
1324 out: &mut String,
1325 text_runs: &mut Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
1326 attr_groups: &mut Vec<(Vec<usize>, usize, usize)>,
1327 condition_slots: &mut [Option<SlotLocator>],
1328 repeat_slots: &mut [Option<SlotLocator>],
1329 event_targets: &mut [Option<Vec<usize>>],
1330 event_cursor: &mut usize,
1331) {
1332 out.push('<');
1333 out.push_str(&element.tag_name);
1334
1335 for attr in &element.attrs {
1336 if let Some((start, count)) = parse_attr_group_marker(&attr.name) {
1337 attr_groups.push((element_path.to_vec(), start, count));
1338 continue;
1339 }
1340
1341 if attr.name == "data-ev" {
1342 if let Some(count) = attr.value.as_deref().and_then(parse_event_marker_count) {
1343 let target_path = element_path.to_vec();
1344 for slot in event_targets.iter_mut().skip(*event_cursor).take(count) {
1345 *slot = Some(target_path.clone());
1346 }
1347 *event_cursor += count;
1348 }
1349 continue;
1350 }
1351
1352 out.push(' ');
1353 out.push_str(&attr.name);
1354 if let Some(value) = &attr.value {
1355 out.push_str("=\"");
1356 emit_html_attr_value(value, out);
1357 out.push('"');
1358 }
1359 }
1360
1361 if element.self_closing {
1362 out.push_str(" />");
1363 return;
1364 }
1365
1366 out.push('>');
1367 process_fragment_children(
1368 &element.children,
1369 element_path,
1370 text_bindings,
1371 out,
1372 text_runs,
1373 attr_groups,
1374 condition_slots,
1375 repeat_slots,
1376 event_targets,
1377 event_cursor,
1378 );
1379 out.push_str("</");
1380 out.push_str(&element.tag_name);
1381 out.push('>');
1382}
1383
1384fn collect_text_run(
1385 nodes: &[FragmentNode],
1386 text_bindings: &[(String, bool)],
1387) -> Option<(Vec<CompiledAttrPart>, usize, bool, bool)> {
1388 let mut parts = Vec::new();
1389 let mut consumed = 0usize;
1390 let mut has_dynamic = false;
1391 let mut is_raw = false;
1392
1393 for node in nodes {
1394 match node {
1395 FragmentNode::Text(text) => {
1396 if !text.is_empty() {
1397 parts.push(CompiledAttrPart::Static(text.clone()));
1398 }
1399 consumed += 1;
1400 }
1401 FragmentNode::Comment(_) => break,
1402 FragmentNode::TextMarker(index) => {
1403 if let Some((path, raw)) = text_bindings.get(*index) {
1404 parts.push(CompiledAttrPart::Dynamic(path.clone()));
1405 has_dynamic = true;
1406 if *raw {
1407 is_raw = true;
1408 }
1409 consumed += 1;
1410 } else {
1411 break;
1412 }
1413 }
1414 FragmentNode::Element(_) => break,
1415 }
1416 }
1417
1418 if consumed == 0 {
1419 return None;
1420 }
1421
1422 Some((parts, consumed, has_dynamic, is_raw))
1423}
1424
1425fn collect_static_text(parts: &[CompiledAttrPart]) -> String {
1426 let mut text = String::new();
1427 for part in parts {
1428 if let CompiledAttrPart::Static(value) = part {
1429 text.push_str(value);
1430 }
1431 }
1432 text
1433}
1434
1435fn parse_marker_index(data: &str, prefix: &str) -> Option<usize> {
1436 data.strip_prefix(prefix)?.parse().ok()
1437}
1438
1439fn parse_attr_group_marker(name: &str) -> Option<(usize, usize)> {
1440 if let Some(start) = name.strip_prefix("data-w-b-") {
1441 return start.parse::<usize>().ok().map(|index| (index, 1));
1442 }
1443
1444 let rest = name.strip_prefix("data-w-c-")?;
1445 let mut parts = rest.split('-');
1446 let start = parts.next()?.parse::<usize>().ok()?;
1447 let count = parts.next()?.parse::<usize>().ok()?;
1448 Some((start, count))
1449}
1450
1451fn parse_event_marker_count(value: &str) -> Option<usize> {
1452 let count = value.parse::<usize>().ok()?;
1453 (count > 0).then_some(count)
1454}
1455
1456fn emit_html_attr_value(value: &str, out: &mut String) {
1457 for ch in value.chars() {
1458 match ch {
1459 '"' => out.push_str("""),
1460 _ => out.push(ch),
1461 }
1462 }
1463}
1464
1465fn parse_fragment_nodes(input: &str, text_marker_offsets: &[usize]) -> Vec<FragmentNode> {
1466 let mut roots = Vec::new();
1467 let mut stack: Vec<FragmentElement> = Vec::new();
1468 let bytes = input.as_bytes();
1469 let len = bytes.len();
1470 let mut index = 0usize;
1471
1472 while index < len {
1473 let remaining = &input[index..];
1474 if let Some((marker_index, marker_end)) =
1475 find_text_marker_comment(input, index, 0, text_marker_offsets)
1476 {
1477 push_fragment_node(
1478 &mut roots,
1479 &mut stack,
1480 FragmentNode::TextMarker(marker_index),
1481 );
1482 index = marker_end;
1483 continue;
1484 }
1485
1486 if remaining.starts_with("<!--") {
1487 if let Some(close) = remaining.find("-->") {
1488 push_fragment_node(
1489 &mut roots,
1490 &mut stack,
1491 FragmentNode::Comment(remaining[4..close].to_string()),
1492 );
1493 index += close + 3;
1494 continue;
1495 }
1496 }
1497
1498 if remaining.starts_with("</") {
1499 if let Some(close) = remaining.find('>') {
1500 if let Some(element) = stack.pop() {
1501 push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1502 }
1503 index += close + 1;
1504 continue;
1505 }
1506 }
1507
1508 if remaining.starts_with('<') {
1509 if let Some((mut element, consumed)) = parse_fragment_start_tag(remaining) {
1510 if element.self_closing {
1511 push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1512 } else if element.tag_name.eq_ignore_ascii_case("style") {
1513 let content_start = index + consumed;
1514 if let Some((close_start, close_end)) = find_style_end_tag(input, content_start)
1515 {
1516 push_style_raw_text_nodes(
1517 &mut element.children,
1518 &input[content_start..close_start],
1519 content_start,
1520 text_marker_offsets,
1521 );
1522 push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1523 index = close_end;
1524 continue;
1525 }
1526
1527 push_style_raw_text_nodes(
1528 &mut element.children,
1529 &input[content_start..],
1530 content_start,
1531 text_marker_offsets,
1532 );
1533 push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1534 index = len;
1535 continue;
1536 } else {
1537 stack.push(element);
1538 }
1539 index += consumed;
1540 continue;
1541 }
1542 }
1543
1544 let next = remaining.find('<').unwrap_or(remaining.len());
1545 let text = &remaining[..next];
1546 if !text.is_empty() {
1547 push_fragment_node(&mut roots, &mut stack, FragmentNode::Text(text.to_string()));
1548 }
1549 index += next.max(1);
1550 }
1551
1552 while let Some(element) = stack.pop() {
1553 push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
1554 }
1555
1556 roots
1557}
1558
1559fn push_style_raw_text_nodes(
1560 children: &mut Vec<FragmentNode>,
1561 input: &str,
1562 base_offset: usize,
1563 text_marker_offsets: &[usize],
1564) {
1565 let bytes = input.as_bytes();
1566 let len = bytes.len();
1567 let mut index = 0usize;
1568 let mut text_start = 0usize;
1569
1570 while index + MIN_TEXT_MARKER_LEN <= len {
1571 if let Some((marker_index, marker_end)) =
1572 find_text_marker_comment(input, index, base_offset, text_marker_offsets)
1573 {
1574 if text_start < index {
1575 children.push(FragmentNode::Text(input[text_start..index].to_string()));
1576 }
1577 children.push(FragmentNode::TextMarker(marker_index));
1578 index = marker_end;
1579 text_start = marker_end;
1580 continue;
1581 }
1582 index += 1;
1583 }
1584
1585 if text_start < len {
1586 children.push(FragmentNode::Text(input[text_start..].to_string()));
1587 }
1588}
1589
1590fn find_text_marker_comment(
1591 input: &str,
1592 index: usize,
1593 base_offset: usize,
1594 text_marker_offsets: &[usize],
1595) -> Option<(usize, usize)> {
1596 let bytes = input.as_bytes();
1597 if bytes.get(index..index + TEXT_MARKER_PREFIX.len()) != Some(TEXT_MARKER_PREFIX.as_bytes()) {
1598 return None;
1599 }
1600 if text_marker_offsets
1601 .binary_search(&(base_offset + index))
1602 .is_err()
1603 {
1604 return None;
1605 }
1606
1607 let mut cursor = index + TEXT_MARKER_PREFIX.len();
1608 let digit_start = cursor;
1609 let mut marker_index = 0usize;
1610 while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
1611 marker_index = marker_index
1612 .checked_mul(TEXT_MARKER_INDEX_RADIX)?
1613 .checked_add((bytes[cursor] - b'0') as usize)?;
1614 cursor += 1;
1615 }
1616 if cursor == digit_start
1617 || bytes.get(cursor..cursor + TEXT_MARKER_SUFFIX.len())
1618 != Some(TEXT_MARKER_SUFFIX.as_bytes())
1619 {
1620 return None;
1621 }
1622
1623 Some((marker_index, cursor + TEXT_MARKER_SUFFIX.len()))
1624}
1625
1626fn push_fragment_node(
1627 roots: &mut Vec<FragmentNode>,
1628 stack: &mut [FragmentElement],
1629 node: FragmentNode,
1630) {
1631 if let Some(parent) = stack.last_mut() {
1632 parent.children.push(node);
1633 } else {
1634 roots.push(node);
1635 }
1636}
1637
1638fn parse_fragment_start_tag(input: &str) -> Option<(FragmentElement, usize)> {
1639 if !input.starts_with('<') || input.starts_with("</") || input.starts_with("<!--") {
1640 return None;
1641 }
1642
1643 let end = find_tag_close(input)?;
1644 let tag_content = &input[1..end];
1645 let tag_body = tag_content.trim_end();
1646 let self_closing = tag_body.ends_with('/');
1647 let tag_body = if self_closing {
1648 tag_body[..tag_body.len().saturating_sub(1)].trim_end()
1649 } else {
1650 tag_body
1651 };
1652
1653 let body_bytes = tag_body.as_bytes();
1654 let mut cursor = 0usize;
1655 while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
1656 cursor += 1;
1657 }
1658
1659 let tag_name = &tag_body[..cursor];
1660 if tag_name.is_empty() {
1661 return None;
1662 }
1663
1664 let mut attrs = Vec::new();
1665 while cursor < body_bytes.len() {
1666 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
1667 cursor += 1;
1668 }
1669 if cursor >= body_bytes.len() {
1670 break;
1671 }
1672
1673 let attr_start = cursor;
1674 while cursor < body_bytes.len()
1675 && !body_bytes[cursor].is_ascii_whitespace()
1676 && body_bytes[cursor] != b'='
1677 {
1678 cursor += 1;
1679 }
1680 if attr_start == cursor {
1681 cursor += 1;
1682 continue;
1683 }
1684
1685 let name = tag_body[attr_start..cursor].to_string();
1686 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
1687 cursor += 1;
1688 }
1689
1690 let mut value: Option<String> = None;
1691 if cursor < body_bytes.len() && body_bytes[cursor] == b'=' {
1692 cursor += 1;
1693 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
1694 cursor += 1;
1695 }
1696
1697 if cursor < body_bytes.len()
1698 && (body_bytes[cursor] == b'"' || body_bytes[cursor] == b'\'')
1699 {
1700 let quote = body_bytes[cursor];
1701 cursor += 1;
1702 let value_start = cursor;
1703 while cursor < body_bytes.len() && body_bytes[cursor] != quote {
1704 cursor += 1;
1705 }
1706 value = Some(tag_body[value_start..cursor].to_string());
1707 if cursor < body_bytes.len() {
1708 cursor += 1;
1709 }
1710 } else {
1711 let value_start = cursor;
1712 while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
1713 cursor += 1;
1714 }
1715 value = Some(tag_body[value_start..cursor].to_string());
1716 }
1717 }
1718
1719 attrs.push(FragmentAttr { name, value });
1720 }
1721
1722 Some((
1723 FragmentElement {
1724 tag_name: tag_name.to_string(),
1725 attrs,
1726 children: Vec::new(),
1727 self_closing: self_closing || is_void_element(tag_name),
1728 },
1729 end + 1,
1730 ))
1731}
1732
1733fn is_void_element(tag_name: &str) -> bool {
1734 matches!(
1735 tag_name,
1736 "area"
1737 | "base"
1738 | "br"
1739 | "col"
1740 | "embed"
1741 | "hr"
1742 | "img"
1743 | "input"
1744 | "link"
1745 | "meta"
1746 | "param"
1747 | "source"
1748 | "track"
1749 | "wbr"
1750 )
1751}
1752
1753fn find_brace_end(input: &str, start: usize, count: usize) -> Option<usize> {
1758 let bytes = input.as_bytes();
1759 let mut i = start;
1760 while i + count - 1 < bytes.len() {
1761 let all_close = (0..count).all(|j| bytes[i + j] == b'}');
1762 if all_close {
1763 return Some(i);
1764 }
1765 i += 1;
1766 }
1767 None
1768}
1769
1770fn is_inside_tag(input: &str, pos: usize) -> bool {
1773 let bytes = input.as_bytes();
1774 let mut i = pos;
1775 while i > 0 {
1776 i -= 1;
1777 if bytes[i] == b'>' {
1778 return false;
1779 }
1780 if bytes[i] == b'<' {
1781 return true;
1782 }
1783 }
1784 false
1785}
1786
1787fn strip_template_wrapper(html: &str) -> &str {
1790 if !html.starts_with("<template") {
1791 return html;
1792 }
1793 let Some(open_end) = find_tag_close(html) else {
1794 return html;
1795 };
1796 let inner_start = open_end + 1;
1797 let inner_end = html.rfind("</template>").unwrap_or(html.len());
1798 if inner_start >= inner_end {
1799 return "";
1800 }
1801 &html[inner_start..inner_end]
1802}
1803
1804fn find_next_block_token(input: &str, cursor: usize, token: &str) -> Option<usize> {
1805 let bytes = input.as_bytes();
1806 let token_bytes = token.as_bytes();
1807 let mut index = cursor;
1808 let mut in_tag = false;
1809 let mut in_comment = false;
1810 let mut quote: Option<u8> = None;
1811
1812 while index + token_bytes.len() <= bytes.len() {
1813 if in_comment {
1814 if bytes[index..].starts_with(b"-->") {
1815 in_comment = false;
1816 index += 3;
1817 continue;
1818 }
1819 index += 1;
1820 continue;
1821 }
1822
1823 if let Some(active) = quote {
1824 if bytes[index] == active {
1825 quote = None;
1826 }
1827 index += 1;
1828 continue;
1829 }
1830
1831 if in_tag {
1832 match bytes[index] {
1833 b'"' | b'\'' => quote = Some(bytes[index]),
1834 b'>' => in_tag = false,
1835 _ => {}
1836 }
1837 index += 1;
1838 continue;
1839 }
1840
1841 if &bytes[index..index + token_bytes.len()] == token_bytes {
1842 return Some(index);
1843 }
1844
1845 if bytes[index] == b'<' {
1846 if bytes[index..].starts_with(b"<!--") {
1847 in_comment = true;
1848 index += 4;
1849 continue;
1850 }
1851 in_tag = true;
1852 }
1853
1854 index += 1;
1855 }
1856
1857 None
1858}
1859
1860fn find_next_block_open(input: &str, cursor: usize, name: &str) -> Option<usize> {
1861 let token = format!("<{name}");
1862 let bytes = input.as_bytes();
1863 let token_bytes = token.as_bytes();
1864 let mut search = cursor;
1865
1866 while let Some(index) = find_next_block_token(input, search, &token) {
1867 let next = bytes.get(index + token_bytes.len()).copied();
1868 if match next {
1869 None => true,
1870 Some(byte) => byte == b'>' || byte.is_ascii_whitespace(),
1871 } {
1872 return Some(index);
1873 }
1874 search = index + 1;
1875 }
1876
1877 None
1878}
1879
1880fn find_next_block_close(input: &str, cursor: usize, name: &str) -> Option<usize> {
1881 let token = format!("</{name}>");
1882 find_next_block_token(input, cursor, &token)
1883}
1884
1885fn find_matching_block_end(input: &str, name: &str) -> Option<usize> {
1886 let open_token = format!("<{name}");
1887 let close_token = format!("</{name}>");
1888 if !input.starts_with(&open_token) {
1889 return None;
1890 }
1891
1892 let mut depth = 1usize;
1893 let mut cursor = open_token.len();
1894
1895 while cursor < input.len() {
1896 let next_open = find_next_block_open(input, cursor, name);
1897 let next_close = find_next_block_close(input, cursor, name);
1898
1899 match (next_open, next_close) {
1900 (_, None) => return None,
1901 (Some(open_pos), Some(close_pos)) if open_pos < close_pos => {
1902 depth += 1;
1903 cursor = open_pos + open_token.len();
1904 }
1905 (_, Some(close_pos)) => {
1906 depth = depth.saturating_sub(1);
1907 if depth == 0 {
1908 return Some(close_pos);
1909 }
1910 cursor = close_pos + close_token.len();
1911 }
1912 }
1913 }
1914
1915 None
1916}
1917
1918fn parse_if_block(input: &str) -> Option<(ConditionExpr, String, usize)> {
1923 let close_tag = "</if>";
1924 let end_pos = find_matching_block_end(input, "if")?;
1925 let tag_content = &input[..end_pos];
1926
1927 let cond_start = tag_content.find("condition=\"")? + "condition=\"".len();
1929 let cond_end = tag_content[cond_start..].find('"')? + cond_start;
1930 let condition = compile_condition_expr(&tag_content[cond_start..cond_end]);
1931
1932 let body_start = find_tag_close(tag_content)? + 1;
1934 let body = tag_content[body_start..].trim().to_string();
1935
1936 Some((condition, body, end_pos + close_tag.len()))
1937}
1938
1939fn compile_condition_expr(input: &str) -> ConditionExpr {
1940 let parser = ConditionParser::new();
1941 match parser.parse(input) {
1942 Ok(condition) => condition,
1943 Err(_) => ConditionExpr::identifier(input),
1947 }
1948}
1949
1950fn parse_for_block(input: &str) -> Option<(String, String, String, usize)> {
1956 let close_tag = "</for>";
1957 let end_pos = find_matching_block_end(input, "for")?;
1958 let tag_content = &input[..end_pos];
1959
1960 let each_start = tag_content.find("each=\"")? + "each=\"".len();
1962 let each_end = tag_content[each_start..].find('"')? + each_start;
1963 let each_val = &tag_content[each_start..each_end];
1964
1965 let parts: Vec<&str> = each_val.splitn(3, ' ').collect();
1966 if parts.len() != 3 || parts[1] != "in" {
1967 return None;
1968 }
1969 let item_var = parts[0].to_string();
1970 let collection = parts[2].to_string();
1971
1972 let body_start = find_tag_close(tag_content)? + 1;
1974 let body = tag_content[body_start..].trim().to_string();
1975
1976 Some((collection, item_var, body, end_pos + close_tag.len()))
1977}
1978
1979fn parse_event_attr(input: &str, pos: usize) -> Option<(String, String, Vec<EventArg>, usize)> {
1988 let remaining = &input[pos..];
1989 let eq_pos = remaining.find('=')?;
1990 let event_name = remaining[1..eq_pos].to_string(); let after_eq = &remaining[eq_pos + 1..];
1993 if after_eq.is_empty() {
1994 return None;
1995 }
1996
1997 let first = after_eq.as_bytes()[0];
1998 let (raw_value, total_consumed) = if first == b'"' || first == b'\'' {
1999 let value_start = eq_pos + 2;
2000 let close = remaining[value_start..].find(first as char)?;
2001 (
2002 &remaining[value_start..value_start + close],
2003 value_start + close + 1,
2004 )
2005 } else if first == b'{' {
2006 let value_start = eq_pos + 2;
2007 let close = remaining[value_start..].find('}')?;
2008 (
2009 &remaining[value_start..value_start + close],
2010 value_start + close + 1,
2011 )
2012 } else {
2013 return None;
2014 };
2015
2016 let inner = raw_value
2017 .trim()
2018 .strip_prefix('{')
2019 .and_then(|s| s.strip_suffix('}'))
2020 .unwrap_or(raw_value)
2021 .trim();
2022
2023 if inner.is_empty() {
2025 return None;
2026 }
2027 match parse_event_handler(inner) {
2028 EventHandler::Valid(handler_name, args) => {
2029 Some((event_name, handler_name, args, total_consumed))
2030 }
2031 EventHandler::Empty => None,
2032 EventHandler::Invalid(_raw) => panic!(
2033 "Invalid @{event_name} handler: \"{inner}\". \
2034 Use @{event_name}=\"{{handler()}}\" or \
2035 @{event_name}=\"{{handler(e)}}\" to pass the event.",
2036 ),
2037 }
2038}
2039
2040fn parse_regular_tag(input: &str, meta: &mut TemplateSectionMeta) -> Option<(String, usize)> {
2041 if !input.starts_with('<') || input.starts_with("</") || input.starts_with("<!") {
2042 return None;
2043 }
2044
2045 let bytes = input.as_bytes();
2046 if bytes.len() < 2 || !bytes[1].is_ascii_alphabetic() {
2047 return None;
2048 }
2049
2050 let end = find_tag_close(input)?;
2051 let tag_content = &input[1..end];
2052 let tag_body = tag_content.trim_end();
2053 let self_closing = tag_body.ends_with('/');
2054 let tag_body = if self_closing {
2055 tag_body[..tag_body.len().saturating_sub(1)].trim_end()
2056 } else {
2057 tag_body
2058 };
2059
2060 let body_bytes = tag_body.as_bytes();
2061 let mut cursor = 0;
2062 while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
2063 cursor += 1;
2064 }
2065
2066 let tag_name = &tag_body[..cursor];
2067 if tag_name.is_empty() {
2068 return None;
2069 }
2070
2071 let mut out = String::with_capacity(tag_body.len() + 24);
2072 out.push('<');
2073 out.push_str(tag_name);
2074
2075 let binding_start = meta.attr_bindings.len();
2076 let event_start = meta.events.len();
2077 let mut binding_count = 0usize;
2078
2079 while cursor < body_bytes.len() {
2080 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
2081 cursor += 1;
2082 }
2083 if cursor >= body_bytes.len() {
2084 break;
2085 }
2086
2087 let attr_start = cursor;
2088 while cursor < body_bytes.len()
2089 && !body_bytes[cursor].is_ascii_whitespace()
2090 && body_bytes[cursor] != b'='
2091 {
2092 cursor += 1;
2093 }
2094 if attr_start == cursor {
2095 cursor += 1;
2096 continue;
2097 }
2098
2099 let name = &tag_body[attr_start..cursor];
2100
2101 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
2102 cursor += 1;
2103 }
2104
2105 let mut value: Option<&str> = None;
2106 if cursor < body_bytes.len() && body_bytes[cursor] == b'=' {
2107 cursor += 1;
2108 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
2109 cursor += 1;
2110 }
2111
2112 if cursor < body_bytes.len()
2113 && (body_bytes[cursor] == b'"' || body_bytes[cursor] == b'\'')
2114 {
2115 let quote = body_bytes[cursor];
2116 cursor += 1;
2117 let value_start = cursor;
2118 while cursor < body_bytes.len() && body_bytes[cursor] != quote {
2119 cursor += 1;
2120 }
2121 value = Some(&tag_body[value_start..cursor]);
2122 if cursor < body_bytes.len() {
2123 cursor += 1;
2124 }
2125 } else {
2126 let value_start = cursor;
2127 while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
2128 cursor += 1;
2129 }
2130 value = Some(&tag_body[value_start..cursor]);
2131 }
2132 }
2133
2134 let raw_attr = tag_body[attr_start..cursor].trim();
2135 if raw_attr.is_empty() {
2136 continue;
2137 }
2138
2139 if let Some(event_name) = name.strip_prefix('@') {
2140 let Some(raw_value) = value else {
2141 continue;
2142 };
2143 match parse_event_handler(raw_value) {
2144 EventHandler::Valid(handler_name, args) => {
2145 meta.events
2146 .push((event_name.to_string(), handler_name, args));
2147 }
2148 EventHandler::Invalid(raw) => {
2149 panic!(
2150 "Invalid @{event_name} handler: \"{raw}\". \
2151 Use @{event_name}=\"{{handler()}}\" or \
2152 @{event_name}=\"{{handler(e)}}\" to pass the event.",
2153 );
2154 }
2155 EventHandler::Empty => {}
2156 }
2157 continue;
2158 }
2159
2160 if name == "w-ref" {
2161 if let Some(val) = value {
2164 if !val.starts_with('{') || !val.ends_with('}') {
2165 eprintln!(
2166 "\x1b[1;31merror\x1b[0m: invalid w-ref=\"{}\" in <{}> — \
2167 use w-ref={{{}}} with braces to bind to a component property",
2168 val, tag_name, val
2169 );
2170 }
2171 }
2172 out.push(' ');
2173 out.push_str(raw_attr);
2174 continue;
2175 }
2176
2177 if let Some(bool_name) = name.strip_prefix('?') {
2178 if let Some(raw_value) = value.and_then(extract_single_handlebars) {
2179 meta.attr_bindings.push(CompiledAttrBinding::Boolean {
2180 name: bool_name.to_string(),
2181 condition: compile_condition_expr(&raw_value),
2182 });
2183 binding_count += 1;
2184 }
2185 continue;
2186 }
2187
2188 if name.starts_with(':') {
2189 if let Some(raw_value) = value.and_then(extract_single_handlebars) {
2190 let prop_name = webui_protocol::attrs::attribute_to_camel(
2193 name.strip_prefix(':').unwrap_or(name),
2194 );
2195 meta.attr_bindings.push(CompiledAttrBinding::Complex {
2196 name: prop_name,
2197 value: raw_value,
2198 });
2199 binding_count += 1;
2200 }
2201 continue;
2202 }
2203
2204 if let Some(raw_value) = value {
2205 if let Some(parts) = parse_attr_parts(raw_value) {
2206 let is_simple =
2207 parts.len() == 1 && matches!(parts.first(), Some(CompiledAttrPart::Dynamic(_)));
2208 if is_simple {
2209 if let Some(CompiledAttrPart::Dynamic(path)) = parts.into_iter().next() {
2210 meta.attr_bindings.push(CompiledAttrBinding::Simple {
2211 name: name.to_string(),
2212 value: path,
2213 });
2214 }
2215 } else {
2216 meta.attr_bindings.push(CompiledAttrBinding::Template {
2217 name: name.to_string(),
2218 parts,
2219 });
2220 }
2221 binding_count += 1;
2222 continue;
2223 }
2224 }
2225
2226 out.push(' ');
2227 out.push_str(raw_attr);
2228 }
2229
2230 if binding_count == 1 {
2231 out.push_str(" data-w-b-");
2232 out.push_str(&binding_start.to_string());
2233 } else if binding_count > 1 {
2234 out.push_str(" data-w-c-");
2235 out.push_str(&binding_start.to_string());
2236 out.push('-');
2237 out.push_str(&binding_count.to_string());
2238 }
2239
2240 let event_count = meta.events.len().saturating_sub(event_start);
2241 if event_count > 0 {
2242 out.push_str(" data-ev=\"");
2243 let _ = write!(out, "{}", event_count);
2244 out.push('"');
2245 }
2246
2247 if self_closing {
2248 out.push_str(" />");
2249 } else {
2250 out.push('>');
2251 }
2252
2253 Some((out, end + 1))
2254}
2255
2256enum EventHandler {
2257 Valid(String, Vec<EventArg>),
2258 Invalid(String),
2259 Empty,
2260}
2261
2262fn parse_event_handler(raw_value: &str) -> EventHandler {
2263 let inner = raw_value
2264 .trim()
2265 .strip_prefix('{')
2266 .and_then(|s| s.strip_suffix('}'))
2267 .unwrap_or(raw_value)
2268 .trim();
2269 if inner.is_empty() {
2270 return EventHandler::Empty;
2271 }
2272 match inner.find('(') {
2273 Some(paren) => {
2274 let close = match inner.rfind(')') {
2275 Some(close) if close > paren => close,
2276 _ => return EventHandler::Invalid(inner.to_string()),
2277 };
2278 if !inner[close + 1..].trim().is_empty() {
2279 return EventHandler::Invalid(inner.to_string());
2280 }
2281 let handler_name = inner[..paren].trim();
2282 if !is_valid_identifier(handler_name) {
2283 return EventHandler::Invalid(inner.to_string());
2284 }
2285 let Some(args) = parse_event_args(&inner[paren + 1..close]) else {
2286 return EventHandler::Invalid(inner.to_string());
2287 };
2288 EventHandler::Valid(handler_name.to_string(), args)
2289 }
2290 None => EventHandler::Invalid(inner.to_string()),
2291 }
2292}
2293
2294fn parse_event_args(raw_args: &str) -> Option<Vec<EventArg>> {
2295 if raw_args.trim().is_empty() {
2296 return Some(Vec::new());
2297 }
2298
2299 let raw_parts = split_event_args(raw_args)?;
2300 let mut args = Vec::with_capacity(raw_parts.len());
2301 for arg in raw_parts {
2302 let trimmed = arg.trim();
2303 if trimmed.is_empty() {
2304 return None;
2305 }
2306 args.push(parse_event_arg(trimmed)?);
2307 }
2308 Some(args)
2309}
2310
2311fn split_event_args(raw_args: &str) -> Option<Vec<&str>> {
2312 let mut args = Vec::new();
2313 let mut start = 0usize;
2314 let mut quote: Option<u8> = None;
2315 let mut escaped = false;
2316 for (idx, byte) in raw_args.bytes().enumerate() {
2317 if let Some(q) = quote {
2318 if escaped {
2319 escaped = false;
2320 continue;
2321 }
2322 if byte == b'\\' {
2323 escaped = true;
2324 continue;
2325 }
2326 if byte == q {
2327 quote = None;
2328 }
2329 continue;
2330 }
2331 if byte == b'"' || byte == b'\'' {
2332 quote = Some(byte);
2333 continue;
2334 }
2335 if byte == b',' {
2336 args.push(&raw_args[start..idx]);
2337 start = idx + 1;
2338 }
2339 }
2340 if quote.is_some() || escaped {
2341 return None;
2342 }
2343 args.push(&raw_args[start..]);
2344 Some(args)
2345}
2346
2347fn parse_event_arg(arg: &str) -> Option<EventArg> {
2348 if arg.is_empty() {
2349 return None;
2350 }
2351 if arg == "e" {
2352 return Some(EventArg::Event);
2353 }
2354 if arg == "true" {
2355 return Some(EventArg::Bool(true));
2356 }
2357 if arg == "false" {
2358 return Some(EventArg::Bool(false));
2359 }
2360 if arg == "null" {
2361 return Some(EventArg::Null);
2362 }
2363 if let Some(value) = parse_quoted_event_string(arg) {
2364 return Some(EventArg::String(value));
2365 }
2366 if is_quoted_event_arg_start(arg) {
2367 return None;
2368 }
2369 if is_number_literal(arg) {
2370 return Some(EventArg::Number(arg.to_string()));
2371 }
2372 is_valid_event_path(arg).then(|| EventArg::Path(arg.to_string()))
2373}
2374
2375fn parse_quoted_event_string(arg: &str) -> Option<String> {
2376 let bytes = arg.as_bytes();
2377 if bytes.len() < 2 {
2378 return None;
2379 }
2380 let quote = bytes[0];
2381 if (quote != b'"' && quote != b'\'') || bytes[bytes.len() - 1] != quote {
2382 return None;
2383 }
2384 Some(
2385 arg[1..arg.len() - 1]
2386 .replace("\\\"", "\"")
2387 .replace("\\'", "'"),
2388 )
2389}
2390
2391fn is_number_literal(arg: &str) -> bool {
2392 !arg.is_empty()
2393 && arg.parse::<f64>().is_ok()
2394 && arg
2395 .bytes()
2396 .all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+' | b'e' | b'E'))
2397}
2398
2399fn is_quoted_event_arg_start(arg: &str) -> bool {
2400 matches!(arg.as_bytes().first(), Some(b'"' | b'\''))
2401}
2402
2403fn is_valid_event_path(path: &str) -> bool {
2404 let mut parts = path.split('.');
2405 let Some(first) = parts.next() else {
2406 return false;
2407 };
2408 if !is_valid_identifier(first) {
2409 return false;
2410 }
2411 for part in parts {
2412 if part.is_empty() || (!is_valid_identifier(part) && !is_ascii_digits(part)) {
2413 return false;
2414 }
2415 }
2416 true
2417}
2418
2419fn is_valid_identifier(value: &str) -> bool {
2420 let mut bytes = value.bytes();
2421 let Some(first) = bytes.next() else {
2422 return false;
2423 };
2424 if !is_identifier_start(first) {
2425 return false;
2426 }
2427 bytes.all(is_identifier_continue)
2428}
2429
2430fn is_identifier_start(byte: u8) -> bool {
2431 byte.is_ascii_alphabetic() || byte == b'_' || byte == b'$'
2432}
2433
2434fn is_identifier_continue(byte: u8) -> bool {
2435 is_identifier_start(byte) || byte.is_ascii_digit()
2436}
2437
2438fn is_ascii_digits(value: &str) -> bool {
2439 !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
2440}
2441
2442fn extract_single_handlebars(value: &str) -> Option<String> {
2443 let trimmed = value.trim();
2444 if let Some(inner) = trimmed
2445 .strip_prefix("{{{")
2446 .and_then(|s| s.strip_suffix("}}}"))
2447 {
2448 return Some(inner.trim().to_string());
2449 }
2450
2451 if let Some(inner) = trimmed
2452 .strip_prefix("{{")
2453 .and_then(|s| s.strip_suffix("}}"))
2454 {
2455 return Some(inner.trim().to_string());
2456 }
2457
2458 None
2459}
2460
2461fn parse_attr_parts(value: &str) -> Option<Vec<CompiledAttrPart>> {
2462 let bytes = value.as_bytes();
2463 let len = bytes.len();
2464 let mut i = 0;
2465 let mut last = 0;
2466 let mut dynamic_found = false;
2467 let mut parts = Vec::new();
2468
2469 while i < len {
2470 if i + 2 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
2471 let brace_count = if i + 2 < len && bytes[i + 2] == b'{' {
2472 3
2473 } else {
2474 2
2475 };
2476
2477 if let Some(end) = find_brace_end(value, i + brace_count, brace_count) {
2478 if last < i {
2479 parts.push(CompiledAttrPart::Static(value[last..i].to_string()));
2480 }
2481 parts.push(CompiledAttrPart::Dynamic(
2482 value[i + brace_count..end].trim().to_string(),
2483 ));
2484 dynamic_found = true;
2485 i = end + brace_count;
2486 last = i;
2487 continue;
2488 }
2489 }
2490
2491 i += 1;
2492 }
2493
2494 if !dynamic_found {
2495 return None;
2496 }
2497
2498 if last < len {
2499 parts.push(CompiledAttrPart::Static(value[last..].to_string()));
2500 }
2501
2502 Some(parts)
2503}
2504
2505fn extract_root_events(html: &str) -> Vec<EventBinding> {
2510 if !html.starts_with("<template") {
2511 return Vec::new();
2512 }
2513 let Some(close) = find_tag_close(html) else {
2514 return Vec::new();
2515 };
2516 let tag = &html[..close];
2517 let mut events = Vec::new();
2518 let bytes = tag.as_bytes();
2519 let len = bytes.len();
2520 let mut i = 0;
2521
2522 while i < len {
2523 if bytes[i] == b'@' {
2524 if let Some((name, handler, args, consumed)) = parse_event_attr(tag, i) {
2525 events.push((name, handler, args));
2526 i += consumed;
2527 continue;
2528 }
2529 }
2530 i += 1;
2531 }
2532
2533 events
2534}
2535
2536fn extract_adopted_stylesheet_specifier(html: &str) -> Option<String> {
2537 if !html.starts_with("<template") {
2538 return None;
2539 }
2540 let close = find_tag_close(html)?;
2541 let tag = &html[..close];
2542 let attr = "shadowrootadoptedstylesheets=\"";
2543 let start = tag.find(attr)? + attr.len();
2544 let end = tag[start..].find('"')? + start;
2545 Some(tag[start..end].to_string())
2546}
2547
2548#[cfg(test)]
2551mod tests {
2552 use super::*;
2553
2554 fn assert_no_client_markers(result: &str) {
2555 assert!(!result.contains("<!--t:"), "text markers should be removed");
2556 assert!(
2557 !result.contains("<!--c:"),
2558 "conditional markers should be removed"
2559 );
2560 assert!(
2561 !result.contains("<!--r:"),
2562 "repeat markers should be removed"
2563 );
2564 assert!(
2565 !result.contains("data-w-b-"),
2566 "attribute binding markers should be removed"
2567 );
2568 assert!(
2569 !result.contains("data-w-c-"),
2570 "attribute binding range markers should be removed"
2571 );
2572 assert!(
2573 !result.contains("data-ev="),
2574 "event markers should be removed"
2575 );
2576 }
2577
2578 #[test]
2579 fn test_skip_event_attributes() {
2580 let mut plugin = WebUIParserPlugin::new();
2581 assert_eq!(plugin.classify_attribute("@click"), AttributeAction::Skip);
2582 assert_eq!(plugin.classify_attribute("@keydown"), AttributeAction::Skip);
2583 assert_eq!(plugin.classify_attribute("w-ref"), AttributeAction::Keep);
2584 assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
2585 }
2586
2587 #[test]
2588 fn test_metadata_has_text_bindings() {
2589 let result = generate_compiled_template("my-comp", "<h1>{{title}}</h1>");
2590 assert_no_client_markers(&result);
2591 assert!(result.contains(r#"h:"<h1></h1>""#));
2592 assert!(result.contains("\"title\""));
2593 assert!(result.contains(r#",tx:[[[[0],0],[["title"]]]]"#));
2594 assert!(!result.contains("{{"));
2595 }
2596
2597 #[test]
2598 fn test_metadata_strips_html_comments_without_processing_bindings() {
2599 let result = generate_compiled_template(
2600 "my-comp",
2601 r#"<!-- {{title}} @click="{bad()}" --><div>hello</div>"#,
2602 );
2603
2604 assert_no_client_markers(&result);
2605 assert!(result.contains(r#"h:"<div>hello</div>""#));
2606 assert!(!result.contains("title"));
2607 assert!(!result.contains("@click"));
2608 assert!(!result.contains("-->"));
2609 }
2610
2611 #[test]
2612 fn test_metadata_strips_style_comments_without_processing_bindings() {
2613 let result = generate_compiled_template(
2614 "my-comp",
2615 r#"<style>/* prose {{ignored}} */ .x { color: {{color}}; } /*! @license {{kept}} */</style>"#,
2616 );
2617
2618 assert_no_client_markers(&result);
2619 assert!(!result.contains("ignored"));
2620 assert!(result.contains(r#"["color"]"#));
2621 assert!(!result.contains(r#"[["kept"]]"#));
2622 assert!(result.contains("/*! @license {{kept}} */"));
2623 }
2624
2625 #[test]
2626 fn test_metadata_processes_style_signal_comments() {
2627 let result = generate_compiled_template(
2628 "my-comp",
2629 r#"<style>:root{/*{{{tokens.light}}}*/}</style>"#,
2630 );
2631
2632 assert_no_client_markers(&result);
2633 assert!(result.contains(r#"["tokens.light"]"#));
2634 assert!(!result.contains("/*"));
2635 assert!(!result.contains("*/"));
2636 }
2637
2638 #[test]
2639 fn test_metadata_keeps_legal_style_comment_with_html_like_tag_as_raw_text() {
2640 let result = generate_compiled_template(
2641 "my-component",
2642 r#"<style>:host { display: block; }/*! @license The <my-component> element. */.container { padding: 16px; }</style><div>hello</div>"#,
2643 );
2644
2645 assert_no_client_markers(&result);
2646 assert!(result.contains("@license The <my-component> element."));
2647 assert!(!result.contains("</my-component>"));
2648 assert!(result.contains("</style><div>hello</div>"));
2649 }
2650
2651 #[test]
2652 fn test_metadata_keeps_marker_like_text_in_legal_style_comment_literal() {
2653 let result = generate_compiled_template(
2654 "my-component",
2655 r#"<p>{{title}}</p><style>/*! @license <!--t:0--> */.x { color: red; }</style>"#,
2656 );
2657
2658 assert!(result.contains("<!--t:0-->"));
2659 assert!(result
2660 .contains(r#"h:"<p></p><style>/*! @license <!--t:0--> */.x { color: red; }</style>""#));
2661 assert!(result.contains(r#",tx:[[[[0],0],[["title"]]]]"#));
2662 assert!(!result.contains(r#"[[[1],0],[["title"]]]"#));
2663 }
2664
2665 #[test]
2666 fn test_metadata_keeps_marker_like_style_text_between_real_signal_markers() {
2667 let result = generate_compiled_template(
2668 "my-component",
2669 r#"<style>/*{{first}}*/📚/*! @license <!--t:0--> <my-component> *//*{{{second}}}*/</style><div>done</div>"#,
2670 );
2671
2672 assert!(result.contains(r#"["first"]"#));
2673 assert!(result.contains(r#"["second"]"#));
2674 assert!(result.contains("📚/*! @license <!--t:0--> <my-component> */"));
2675 assert!(!result.contains("</my-component>"));
2676 assert!(result.contains("</style><div>done</div>"));
2677 }
2678
2679 #[test]
2680 fn test_metadata_strips_style_line_comments_without_processing_bindings() {
2681 let result = generate_compiled_template(
2682 "my-comp",
2683 "<style>// {{ignored}}\n.x { color: {{color}}; }\n//! @license {{kept}}</style>",
2684 );
2685
2686 assert_no_client_markers(&result);
2687 assert!(!result.contains("ignored"));
2688 assert!(result.contains(r#"["color"]"#));
2689 assert!(!result.contains(r#"[["kept"]]"#));
2690 assert!(result.contains("//! @license {{kept}}"));
2691 }
2692
2693 #[test]
2694 fn test_metadata_has_conditionals() {
2695 let result = generate_compiled_template(
2696 "my-comp",
2697 r#"<if condition="state == 'done'"><span>yes</span></if>"#,
2698 );
2699 assert_no_client_markers(&result);
2700 assert!(result
2701 .contains(r#"c:[[[function(v,s){return v("state",s)=="done"},["state"]],0,[[],0]]]"#));
2702 assert!(result.contains("<span>yes</span>"));
2703 assert!(!result.contains("<if"));
2704 }
2705
2706 #[test]
2707 fn test_metadata_has_conditionals_with_gt_in_condition_attr() {
2708 let result = generate_compiled_template(
2709 "my-comp",
2710 r#"<if condition="count > 0"><span>yes</span></if>"#,
2711 );
2712 assert_no_client_markers(&result);
2713 assert!(result.contains(",c:["), "conditional metadata expected");
2714 assert!(result.contains("<span>yes</span>"));
2715 assert!(!result.contains("<if"));
2716 }
2717
2718 #[test]
2719 fn test_parse_if_block_ignores_close_marker_inside_attr_value() {
2720 let input = r#"<if condition="count > 0"><div data-note="</if>">yes</div></if>"#;
2721 let (_, body, consumed) = parse_if_block(input).expect("if block should parse");
2722
2723 assert_eq!(body, r#"<div data-note="</if>">yes</div>"#);
2724 assert_eq!(consumed, input.len());
2725 }
2726
2727 #[test]
2728 fn test_metadata_has_for_loops() {
2729 let result = generate_compiled_template(
2730 "my-comp",
2731 r#"<for each="item in items"><p>{{item.name}}</p></for>"#,
2732 );
2733 assert_no_client_markers(&result);
2734 assert!(result.contains("\"items\""));
2735 assert!(result.contains("\"item\""));
2736 assert!(!result.contains("<for"));
2737 }
2738
2739 #[test]
2740 fn test_parse_for_block_ignores_open_marker_inside_attr_value() {
2741 let input =
2742 r#"<for each="item in items"><div data-note="<for fake>">{{item.name}}</div></for>"#;
2743 let (collection, item_var, body, consumed) =
2744 parse_for_block(input).expect("for block should parse");
2745
2746 assert_eq!(collection, "items");
2747 assert_eq!(item_var, "item");
2748 assert_eq!(body, r#"<div data-note="<for fake>">{{item.name}}</div>"#);
2749 assert_eq!(consumed, input.len());
2750 }
2751
2752 #[test]
2753 fn test_metadata_has_for_loops_with_gt_in_other_attr() {
2754 let result = generate_compiled_template(
2755 "my-comp",
2756 r#"<for each="item in items" data-note="a > b"><p>{{item.name}}</p></for>"#,
2757 );
2758 assert_no_client_markers(&result);
2759 assert!(result.contains("\"items\""));
2760 assert!(result.contains("\"item\""));
2761 assert!(!result.contains("<for"));
2762 assert!(!result.contains("{{item.name}}"));
2763 }
2764
2765 #[test]
2766 fn test_metadata_has_events() {
2767 let result =
2768 generate_compiled_template("my-comp", r#"<button @click="{onClick()}">Go</button>"#);
2769 assert_no_client_markers(&result);
2770 assert!(result.contains("\"click\""));
2771 assert!(result.contains("\"onClick\""));
2772 assert!(!result.contains("@click"));
2773 }
2774
2775 #[test]
2776 fn test_metadata_has_events_unquoted() {
2777 let result =
2778 generate_compiled_template("my-comp", r#"<button @click={onClick()}>Go</button>"#);
2779 assert_no_client_markers(&result);
2780 assert!(result.contains("\"click\""));
2781 assert!(result.contains("\"onClick\""));
2782 }
2783
2784 #[test]
2785 fn test_metadata_has_events_with_arg() {
2786 let result = generate_compiled_template("my-comp", r#"<input @keydown="{onKey(e)}" />"#);
2787 assert_no_client_markers(&result);
2788 assert!(result.contains("\"keydown\""));
2789 assert!(result.contains("\"onKey\""));
2790 assert!(result.contains(r#"["e"]"#)); }
2792
2793 #[test]
2794 fn test_metadata_has_event_path_args() {
2795 let result = generate_compiled_template(
2796 "my-comp",
2797 r#"<button @click="{selectItem(item.id, e, 'ok', 7, true, null)}">Go</button>"#,
2798 );
2799 assert_no_client_markers(&result);
2800 assert!(result.contains(r#"["p","item.id"]"#));
2801 assert!(result.contains(r#"["e"]"#));
2802 assert!(result.contains(r#"["s","ok"]"#));
2803 assert!(result.contains(r#"["n",7]"#));
2804 assert!(result.contains(r#"["b",1]"#));
2805 assert!(result.contains(r#"["z"]"#));
2806 }
2807
2808 #[test]
2809 fn test_metadata_has_simple_attr_bindings() {
2810 let result = generate_compiled_template("my-comp", r#"<input title="{{title}}" />"#);
2811 assert_no_client_markers(&result);
2812 assert!(result.contains(",a:["));
2813 assert!(result.contains(",ag:[[[0],0,1]]"));
2814 assert!(result.contains(r#"["title",0,"title"]"#));
2815 assert!(!result.contains(r#"title=\"<!--t:"#));
2816 }
2817
2818 #[test]
2819 fn test_metadata_has_template_attr_bindings() {
2820 let result = generate_compiled_template(
2821 "my-comp",
2822 r#"<a href="/product/{{handle}}" class="card {{variant}}">Go</a>"#,
2823 );
2824 assert_no_client_markers(&result);
2825 assert!(result.contains(",a:["));
2826 assert!(result.contains(",ag:[[[0],0,2]]"));
2827 assert!(result.contains(r#"["href",3,["/product/",["handle"]]]"#));
2828 assert!(result.contains(r#"["class",3,["card ",["variant"]]]"#));
2829 assert!(!result.contains(r#"/product/<!--t:"#));
2830 }
2831
2832 #[test]
2833 fn test_metadata_has_boolean_attr_bindings() {
2834 let result = generate_compiled_template(
2835 "my-comp",
2836 r#"<a ?data-active="{{page == 'dashboard'}}">Go</a>"#,
2837 );
2838 assert_no_client_markers(&result);
2839 assert!(result.contains(",a:["));
2840 assert!(result.contains(",ag:[[[0],0,1]]"));
2841 assert!(result.contains(
2842 r#"["data-active",2,[function(v,s){return v("page",s)=="dashboard"},["page"]]]"#
2843 ));
2844 }
2845
2846 #[test]
2847 fn test_metadata_has_complex_attr_bindings() {
2848 let result = generate_compiled_template(
2849 "my-comp",
2850 r#"<child-view :config="{{settings}}"></child-view>"#,
2851 );
2852 assert_no_client_markers(&result);
2853 assert!(result.contains(",a:["));
2854 assert!(result.contains(",ag:[[[0],0,1]]"));
2855 assert!(result.contains(r#""config",1,"settings""#));
2856 }
2857
2858 #[test]
2859 fn test_w_ref_stays_in_html() {
2860 let result = generate_compiled_template("my-comp", r#"<input w-ref="{myInput}" />"#);
2861 assert!(result.contains("w-ref"));
2863 assert!(result.contains("myInput"));
2864 }
2865
2866 #[test]
2867 fn test_w_ref_unquoted_stays_in_html() {
2868 let result = generate_compiled_template("my-comp", r#"<span w-ref={myLabel}>0</span>"#);
2869 assert!(result.contains("w-ref"));
2870 assert!(result.contains("myLabel"));
2871 }
2872
2873 #[test]
2874 fn test_metadata_has_root_events() {
2875 let result = generate_compiled_template(
2876 "my-comp",
2877 r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>hi</div></template>"#,
2878 );
2879 assert!(result.contains(",re:["));
2880 assert!(result.contains("\"click\""));
2881 assert!(result.contains("\"onClick\""));
2882 }
2883
2884 #[test]
2885 fn test_strips_shadowrootmode() {
2886 let result = generate_compiled_template(
2887 "my-comp",
2888 r#"<template shadowrootmode="open"><p>{{title}}</p></template>"#,
2889 );
2890 assert!(!result.contains("shadowrootmode"));
2891 assert_no_client_markers(&result);
2892 assert!(result.contains(r#",tx:[[[[0],0],[["title"]]]]"#));
2893 }
2894
2895 #[test]
2896 fn test_root_template_allows_gt_in_quoted_attr_values() {
2897 let result = generate_compiled_template(
2898 "my-comp",
2899 r#"<template shadowrootmode="open" data-note="a > b" @click="{onClick()}"><p>hi</p></template>"#,
2900 );
2901 assert_no_client_markers(&result);
2902 assert!(result.contains(r#"h:"<p>hi</p>""#));
2903 assert!(result.contains(r#",re:[["click","onClick",[]]]"#));
2904 }
2905
2906 #[test]
2907 fn test_outlet_converted() {
2908 let result = generate_compiled_template("my-comp", r#"<main><outlet /></main>"#);
2909 assert!(result.contains("<outlet></outlet>"));
2910 }
2911
2912 #[test]
2913 fn test_emoji_safe() {
2914 let result = generate_compiled_template("my-comp", r#"<span>📚 {{title}}</span>"#);
2915 assert!(result.contains("📚"));
2916 assert_no_client_markers(&result);
2917 assert!(result.contains(r#",tx:[[[[0],0],["📚 ",["title"]]]]"#));
2918 }
2919
2920 #[test]
2921 fn test_boolean_attr_in_for() {
2922 let result = generate_compiled_template(
2923 "my-comp",
2924 r#"<for each="s in sections"><a ?active="{{s.id == sectionId}}">{{s.name}}</a></for>"#,
2925 );
2926 assert_no_client_markers(&result);
2927 assert!(result.contains(r#"["active",2,[function(v,s){return v("s.id",s)==v("sectionId",s)},["s.id","sectionId"]]]"#));
2928 assert!(!result.contains("?active"));
2929 }
2930
2931 #[test]
2932 fn test_deduplicates_components() {
2933 let mut plugin = WebUIParserPlugin::new();
2934 let comp = Component {
2935 tag_name: "test-el".to_string(),
2936 html_content: "<p>hi</p>".to_string(),
2937 css_content: None,
2938 css_tokens: Vec::new(),
2939 source_path: std::path::PathBuf::new(),
2940 class_name: None,
2941 };
2942 plugin
2943 .register_component_template("test-el", &comp, &comp.html_content)
2944 .unwrap();
2945 plugin
2946 .register_component_template("test-el", &comp, &comp.html_content)
2947 .unwrap();
2948 assert_eq!(plugin.take_component_templates().len(), 1);
2949 }
2950
2951 #[test]
2952 fn test_compiled_template_preserves_link_node_in_static_html() {
2953 let result = generate_compiled_template(
2954 "my-comp",
2955 r#"<template shadowrootmode="open"><link rel="stylesheet" href="my-comp.css"><p>hi</p></template>"#,
2956 );
2957 assert!(result.contains(r#"rel=\"stylesheet\""#));
2958 assert!(result.contains(r#"href=\"my-comp.css\""#));
2959 assert!(result.contains(r#"<p>hi</p>"#));
2960 assert!(!result.contains(",css:"));
2961 }
2962
2963 #[test]
2964 fn test_compiled_template_preserves_inline_style_in_static_html() {
2965 let result = generate_compiled_template(
2966 "my-comp",
2967 r#"<template shadowrootmode="open"><style>.root{color:red}</style><p>hi</p></template>"#,
2968 );
2969 assert!(result.contains(r#"h:"<style>.root{color:red}</style><p>hi</p>""#));
2970 }
2971
2972 #[test]
2973 fn test_compiled_template_emits_adopted_stylesheet_specifier() {
2974 let result = generate_compiled_template(
2975 "my-comp",
2976 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><p>hi</p></template>"#,
2977 );
2978 assert!(result.contains(r#",sa:"my-comp""#));
2979 }
2980
2981 #[test]
2982 fn test_plugin_uses_processed_link_template_html() {
2983 let mut plugin = WebUIParserPlugin::new();
2984
2985 let comp = Component {
2986 tag_name: "test-el".to_string(),
2987 html_content: "<p>hi</p>".to_string(),
2988 css_content: Some(".root { color: red; }".to_string()),
2989 css_tokens: Vec::new(),
2990 source_path: std::path::PathBuf::new(),
2991 class_name: None,
2992 };
2993
2994 plugin
2995 .register_component_template(
2996 "test-el",
2997 &comp,
2998 r#"<template shadowrootmode="open"><link rel="stylesheet" href="test-el.css"><p>hi</p></template>"#,
2999 )
3000 .unwrap();
3001 let templates = plugin.take_component_templates();
3002 assert_eq!(templates.len(), 1);
3003 assert!(templates[0].1.contains(r#"rel=\"stylesheet\""#));
3004 assert!(templates[0].1.contains(r#"href=\"test-el.css\""#));
3005 assert!(templates[0].1.contains(r#"<p>hi</p>"#));
3006 }
3007
3008 #[test]
3009 fn test_plugin_preserves_root_events_from_raw_template_source() {
3010 let mut plugin = WebUIParserPlugin::new();
3011
3012 let comp = Component {
3013 tag_name: "test-el".to_string(),
3014 html_content:
3015 r#"<template shadowrootmode="open" @click="{onClick(e)}"><p>hi</p></template>"#
3016 .to_string(),
3017 css_content: Some(".root { color: red; }".to_string()),
3018 css_tokens: Vec::new(),
3019 source_path: std::path::PathBuf::new(),
3020 class_name: None,
3021 };
3022
3023 plugin
3024 .register_component_template(
3025 "test-el",
3026 &comp,
3027 r#"<template shadowrootmode="open"><link rel="stylesheet" href="test-el.css"><p>hi</p></template>"#,
3028 )
3029 .unwrap();
3030 let templates = plugin.take_component_templates();
3031 assert_eq!(templates.len(), 1);
3032 assert!(templates[0]
3033 .1
3034 .contains(r#",re:[["click","onClick",[["e"]]]]"#));
3035 }
3036
3037 #[test]
3038 fn test_plugin_uses_processed_module_template_html() {
3039 let mut plugin = WebUIParserPlugin::new();
3040
3041 let comp = Component {
3042 tag_name: "test-el".to_string(),
3043 html_content: "<p>hi</p>".to_string(),
3044 css_content: Some(".root { color: red; }".to_string()),
3045 css_tokens: Vec::new(),
3046 source_path: std::path::PathBuf::new(),
3047 class_name: None,
3048 };
3049
3050 plugin
3051 .register_component_template(
3052 "test-el",
3053 &comp,
3054 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="test-el"><p>hi</p></template>"#,
3055 )
3056 .unwrap();
3057 let templates = plugin.take_component_templates();
3058 assert_eq!(templates.len(), 1);
3059 assert!(templates[0].1.contains(r#",sa:"test-el""#));
3060 }
3061
3062 #[test]
3063 fn test_nested_if_inside_for_compiles_nested_blocks() {
3064 let result = generate_compiled_template(
3065 "my-comp",
3066 r#"<for each="item in items"><if condition="item.active"><span>{{item.name}}</span></if></for>"#,
3067 );
3068
3069 assert_no_client_markers(&result);
3070 assert!(result.contains(",b:["), "compiled block table expected");
3071 assert!(
3072 result.contains(r#"["items","item",0,[[],0]]"#),
3073 "repeat block index expected"
3074 );
3075 assert!(
3076 result.contains(
3077 r#"[[function(v,s){return !!v("item.active",s)},["item.active"]],1,[[],0]]"#
3078 ),
3079 "nested conditional block index expected"
3080 );
3081 assert!(!result.contains("<if"), "nested if should be compiled");
3082 assert!(!result.contains("{{"), "nested bindings should be compiled");
3083 }
3084
3085 #[test]
3086 fn test_for_inside_if_compiles_nested_blocks() {
3087 let result = generate_compiled_template(
3088 "my-comp",
3089 r#"<if condition="showList"><for each="item in items"><p>{{item.name}}</p></for></if>"#,
3090 );
3091
3092 assert_no_client_markers(&result);
3093 assert!(result.contains(",b:["), "compiled block table expected");
3094 assert!(
3095 result.contains(r#"[[function(v,s){return !!v("showList",s)},["showList"]],0,[[],0]]"#),
3096 "conditional block index expected"
3097 );
3098 assert!(
3099 result.contains(r#"["items","item",1,[[],0]]"#),
3100 "nested repeat block index expected"
3101 );
3102 assert!(!result.contains("<for"), "nested for should be compiled");
3103 assert!(!result.contains("{{"), "nested bindings should be compiled");
3104 }
3105
3106 #[test]
3107 fn test_nested_for_inside_for_compiles_nested_blocks() {
3108 let result = generate_compiled_template(
3109 "my-comp",
3110 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>"#,
3111 );
3112
3113 assert_no_client_markers(&result);
3114 assert!(result.contains(",b:["), "compiled block table expected");
3115 assert!(
3116 result.contains(r#"["groups","group",0,[[],0]]"#),
3117 "outer repeat block index expected"
3118 );
3119 assert!(
3120 result.contains(r#"["group.items","item",1,[[0],0]]"#),
3121 "inner repeat block index expected"
3122 );
3123 assert!(!result.contains("<for"), "nested for should be compiled");
3124 assert!(!result.contains("{{"), "nested bindings should be compiled");
3125 assert!(
3126 !result.contains("?disabled"),
3127 "nested boolean attrs should be compiled"
3128 );
3129 }
3130
3131 #[test]
3132 fn test_multiple_events_on_same_element() {
3133 let result = generate_compiled_template(
3134 "my-comp",
3135 r#"<input @keydown="{onKey(e)}" @focus="{onFocus()}" />"#,
3136 );
3137 assert_no_client_markers(&result);
3138 assert!(result.contains("\"keydown\""), "keydown event expected");
3139 assert!(result.contains("\"onKey\""), "onKey handler expected");
3140 assert!(result.contains("\"focus\""), "focus event expected");
3141 assert!(result.contains("\"onFocus\""), "onFocus handler expected");
3142 }
3143
3144 #[test]
3145 fn test_regular_tag_uses_single_event_marker_per_element() {
3146 let mut meta = TemplateSectionMeta::default();
3147 let (tag_html, _) = parse_regular_tag(
3148 r#"<input @keydown="{onKey(e)}" @focus="{onFocus()}" />"#,
3149 &mut meta,
3150 )
3151 .expect("regular tag should parse");
3152
3153 assert_eq!(tag_html, r#"<input data-ev="2" />"#);
3154 }
3155
3156 #[test]
3157 fn test_empty_template() {
3158 let result = generate_compiled_template("my-comp", "");
3159 assert!(result.contains("__webui.templates"));
3160 assert!(result.contains("h:\"\""), "empty html expected");
3161 assert!(!result.contains(",t:"), "no text bindings");
3163 assert!(!result.contains(",c:"), "no conditionals");
3164 assert!(!result.contains(",r:"), "no repeats");
3165 assert!(!result.contains(",e:"), "no events");
3166 assert!(!result.contains(",re:"), "no root events");
3167 }
3168
3169 #[test]
3170 fn test_whitespace_only_template() {
3171 let result = generate_compiled_template("my-comp", " \n ");
3172 assert!(result.contains("__webui.templates"));
3173 assert!(
3174 result.contains("h:\"\""),
3175 "whitespace-only should produce empty html"
3176 );
3177 }
3178
3179 #[test]
3180 fn test_multiple_text_bindings() {
3181 let result = generate_compiled_template(
3182 "my-comp",
3183 r#"<p>{{first}}</p><p>{{second}}</p><p>{{third}}</p>"#,
3184 );
3185 assert_no_client_markers(&result);
3186 assert!(result.contains(
3187 r#",tx:[[[[0],0],[["first"]]],[[[1],0],[["second"]]],[[[2],0],[["third"]]]]"#
3188 ));
3189 assert!(result.contains("\"first\""));
3190 assert!(result.contains("\"second\""));
3191 assert!(result.contains("\"third\""));
3192 }
3193
3194 #[test]
3195 fn test_multiple_conditionals() {
3196 let result = generate_compiled_template(
3197 "my-comp",
3198 r#"<if condition="a"><span>A</span></if><if condition="b"><span>B</span></if>"#,
3199 );
3200 assert_no_client_markers(&result);
3201 }
3202
3203 #[test]
3204 fn test_locator_paths_merge_adjacent_static_text_nodes() {
3205 let result = generate_compiled_template(
3206 "mp-product-grid",
3207 r#"
3208<template shadowrootmode="open">
3209 <if condition="products.length == 0 && query">
3210 <p class="empty-results">There are no products that match "<strong>{{query}}</strong>"</p>
3211 </if>
3212 <div class="grid">
3213 <for each="product in products">
3214 <mp-product-card
3215 handle="{{product.handle}}"
3216 title="{{product.title}}"
3217 price="{{product.price}}"
3218 gradient="{{product.gradient}}"
3219 image-url="{{product.imageUrl}}"
3220 image-loading="lazy"
3221 variant="grid"
3222 ></mp-product-card>
3223 </for>
3224 </div>
3225</template>
3226"#,
3227 );
3228
3229 assert_no_client_markers(&result);
3230 }
3231
3232 #[test]
3233 fn test_mixed_bindings_events_refs() {
3234 let result = generate_compiled_template(
3235 "my-comp",
3236 r#"<button w-ref="{myBtn}" @click="{onClick()}">{{label}}</button>"#,
3237 );
3238 assert_no_client_markers(&result);
3240 assert!(result.contains("w-ref"));
3241 assert!(result.contains("myBtn"));
3242 assert!(result.contains("\"click\""));
3244 assert!(result.contains("\"onClick\""));
3245 assert!(result.contains(r#",tx:[[[[0],0],[["label"]]]]"#));
3247 assert!(result.contains("\"label\""));
3248 }
3249
3250 #[test]
3251 fn test_event_without_e_arg() {
3252 let result =
3253 generate_compiled_template("my-comp", r#"<button @click="{onClick()}">Go</button>"#);
3254 assert!(
3255 result.contains(r#"["click","onClick",[],[0]]"#),
3256 "empty argument list should be preserved"
3257 );
3258 }
3259
3260 #[test]
3261 fn test_root_event_without_e_arg() {
3262 let result = generate_compiled_template(
3263 "my-comp",
3264 r#"<template shadowrootmode="open" @submit="{onSubmit()}"><form>hi</form></template>"#,
3265 );
3266 assert!(result.contains(",re:["));
3267 assert!(result.contains("\"submit\""));
3268 assert!(result.contains("\"onSubmit\""));
3269 assert!(
3270 result.contains(r#"["submit","onSubmit",[]]"#),
3271 "empty root argument list should be preserved"
3272 );
3273 }
3274
3275 #[test]
3276 fn test_triple_brace_raw_binding() {
3277 let result = generate_compiled_template("my-comp", r#"<div>{{{rawHtml}}}</div>"#);
3278 assert_no_client_markers(&result);
3279 assert!(
3280 result.contains(r#",tx:[[[[0],0],[["rawHtml"]],1]]"#),
3281 "triple brace produces text locator with raw flag: {result}"
3282 );
3283 assert!(
3284 result.contains("\"rawHtml\""),
3285 "rawHtml path in text bindings"
3286 );
3287 }
3288
3289 #[test]
3290 fn test_for_with_complex_body() {
3291 let result = generate_compiled_template(
3292 "my-comp",
3293 r#"<for each="user in users"><div class="card"><h2>{{user.name}}</h2><p>{{user.bio}}</p></div></for>"#,
3294 );
3295 assert_no_client_markers(&result);
3296 assert!(result.contains("\"users\""), "collection path");
3297 assert!(result.contains("\"user\""), "item variable");
3298 assert!(result.contains(",b:["), "compiled block table expected");
3299 assert!(
3300 !result.contains("{{user.name}}"),
3301 "bindings should be compiled"
3302 );
3303 assert!(
3304 !result.contains("{{user.bio}}"),
3305 "bindings should be compiled"
3306 );
3307 }
3308
3309 #[test]
3310 fn test_mixed_text_uses_text_runs() {
3311 let result = generate_compiled_template("my-comp", r#"<p>Hello {{name}}!</p>"#);
3312
3313 assert_no_client_markers(&result);
3314 assert!(result.contains(r#"h:"<p></p>""#));
3315 assert!(result.contains(r#",tx:[[[[0],0],["Hello ",["name"],"!"]]]"#));
3316 }
3317
3318 #[test]
3319 fn test_js_string_escaping() {
3320 let result = generate_compiled_template(
3321 "my-comp",
3322 r#"<if condition="name == "test""><span>ok</span></if>"#,
3323 );
3324 assert!(result.contains(",c:["), "conditional array present");
3325 assert!(
3327 result.contains(r#"function(v,s){return v("name",s)==v(""test"",s)}"#),
3328 "condition function with escaped quotes should be present: {}",
3329 result
3330 );
3331 }
3332
3333 #[test]
3334 fn test_js_string_escapes_script_breakout_sequences() {
3335 let result = generate_compiled_template(
3336 "my-comp",
3337 r#"<div title="</script><script>alert(1)</script>"></div>"#,
3338 );
3339
3340 assert!(
3341 result.contains(r#"\u003C/script><script>alert(1)\u003C/script>"#),
3342 "script-closing sequences should be neutralized inside metadata strings: {result}"
3343 );
3344 assert!(
3345 !result.contains(r#"title=\"</script><script>alert(1)</script>\""#),
3346 "raw </script> must not appear inside serialized metadata strings: {result}"
3347 );
3348 }
3349
3350 #[test]
3351 fn test_js_string_escapes_line_separator_codepoints() {
3352 let html = format!("<p>before{}middle{}after</p>", '\u{2028}', '\u{2029}');
3353 let result = generate_compiled_template("my-comp", &html);
3354
3355 assert!(
3356 result.contains(r#"\u2028"#),
3357 "U+2028 should be escaped in metadata strings: {result}"
3358 );
3359 assert!(
3360 result.contains(r#"\u2029"#),
3361 "U+2029 should be escaped in metadata strings: {result}"
3362 );
3363 }
3364
3365 #[test]
3366 fn test_on_element_parsed_encodes_12_bytes() {
3367 let mut plugin = WebUIParserPlugin::new();
3368 let _ = plugin.classify_attribute("@click");
3370 let _ = plugin.classify_attribute("@keydown");
3371 let data = plugin.finish_element(3);
3373 assert!(data.is_some());
3374 let bytes = data.unwrap();
3375 assert_eq!(bytes.len(), 12, "plugin data should be 12 bytes");
3376 let binding_count = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
3377 let event_start = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
3378 let event_count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
3379 assert_eq!(binding_count, 3);
3380 assert_eq!(event_start, 0);
3381 assert_eq!(event_count, 2);
3382 }
3383
3384 #[test]
3385 fn test_on_element_parsed_no_data_when_empty() {
3386 let mut plugin = WebUIParserPlugin::new();
3387 let data = plugin.finish_element(0);
3388 assert!(
3389 data.is_none(),
3390 "no plugin data when no bindings and no events"
3391 );
3392 }
3393
3394 #[test]
3395 fn test_on_element_parsed_events_only() {
3396 let mut plugin = WebUIParserPlugin::new();
3397 let _ = plugin.classify_attribute("@click");
3398 let data = plugin.finish_element(0);
3399 assert!(
3400 data.is_some(),
3401 "should emit data for events even with 0 bindings"
3402 );
3403 let bytes = data.unwrap();
3404 let binding_count = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
3405 let event_count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
3406 assert_eq!(binding_count, 0);
3407 assert_eq!(event_count, 1);
3408 }
3409
3410 #[test]
3411 fn test_event_index_increments_across_elements() {
3412 let mut plugin = WebUIParserPlugin::new();
3413 let _ = plugin.classify_attribute("@click");
3415 let _ = plugin.classify_attribute("@focus");
3416 let data1 = plugin.finish_element(0).unwrap();
3417 let ev_start_1 = u32::from_le_bytes([data1[4], data1[5], data1[6], data1[7]]);
3418 assert_eq!(ev_start_1, 0, "first element starts at event 0");
3419
3420 let _ = plugin.classify_attribute("@blur");
3422 let data2 = plugin.finish_element(0).unwrap();
3423 let ev_start_2 = u32::from_le_bytes([data2[4], data2[5], data2[6], data2[7]]);
3424 assert_eq!(
3425 ev_start_2, 2,
3426 "second element starts at event 2 (after first element's 2 events)"
3427 );
3428 }
3429
3430 #[test]
3431 fn test_text_run_decodes_html_entities_in_static_parts() {
3432 let result =
3438 generate_compiled_template("my-comp", "<nav>{{sectionName}} > {{topicName}}</nav>");
3439 assert_no_client_markers(&result);
3440 assert!(
3442 result.contains(r#"" > ""#),
3443 "static text run part should contain decoded '>' not '>': {}",
3444 result
3445 );
3446 assert!(
3447 !result.contains(r#"" > ""#),
3448 "static text run part should not contain raw '>': {}",
3449 result
3450 );
3451 }
3452
3453 #[test]
3454 fn test_parse_event_handler_bare_name_is_invalid() {
3455 assert!(matches!(
3456 parse_event_handler("{closeMenu}"),
3457 EventHandler::Invalid(ref name) if name == "closeMenu"
3458 ));
3459 }
3460
3461 #[test]
3462 #[should_panic(expected = "Invalid @click handler")]
3463 fn test_parse_event_attr_bare_name_panics() {
3464 let input = r#"<button @click="{closeMenu}">Click</button>"#;
3465 parse_event_attr(input, 8);
3466 }
3467
3468 #[test]
3469 fn test_parse_event_handler_with_parens() {
3470 assert!(matches!(
3471 parse_event_handler("{onClick()}"),
3472 EventHandler::Valid(ref name, ref args) if name == "onClick" && args.is_empty()
3473 ));
3474 }
3475
3476 #[test]
3477 fn test_parse_event_handler_with_event_arg() {
3478 assert!(matches!(
3479 parse_event_handler("{onClick(e)}"),
3480 EventHandler::Valid(ref name, ref args) if name == "onClick" && args == &vec![EventArg::Event]
3481 ));
3482 }
3483
3484 #[test]
3485 fn test_parse_event_handler_with_mixed_args() {
3486 assert!(matches!(
3487 parse_event_handler("{onClick(item.id, e, 'ok', 7, false, null)}"),
3488 EventHandler::Valid(ref name, ref args)
3489 if name == "onClick"
3490 && args == &vec![
3491 EventArg::Path("item.id".to_string()),
3492 EventArg::Event,
3493 EventArg::String("ok".to_string()),
3494 EventArg::Number("7".to_string()),
3495 EventArg::Bool(false),
3496 EventArg::Null,
3497 ]
3498 ));
3499 }
3500
3501 #[test]
3502 fn test_parse_event_handler_rejects_trailing_tokens() {
3503 assert!(matches!(
3504 parse_event_handler("{onClick(e)} trailing"),
3505 EventHandler::Invalid(ref raw) if raw == "{onClick(e)} trailing"
3506 ));
3507 assert!(matches!(
3508 parse_event_handler("{onClick(e) trailing}"),
3509 EventHandler::Invalid(ref raw) if raw == "onClick(e) trailing"
3510 ));
3511 }
3512
3513 #[test]
3514 fn test_parse_event_handler_rejects_empty_args() {
3515 assert!(matches!(
3516 parse_event_handler("{onClick(, e)}"),
3517 EventHandler::Invalid(ref raw) if raw == "onClick(, e)"
3518 ));
3519 assert!(matches!(
3520 parse_event_handler("{onClick(e,)}"),
3521 EventHandler::Invalid(ref raw) if raw == "onClick(e,)"
3522 ));
3523 }
3524
3525 #[test]
3526 fn test_parse_event_handler_rejects_malformed_args() {
3527 assert!(matches!(
3528 parse_event_handler("{onClick('unterminated)}"),
3529 EventHandler::Invalid(ref raw) if raw == "onClick('unterminated)"
3530 ));
3531 assert!(matches!(
3532 parse_event_handler("{onClick(other())}"),
3533 EventHandler::Invalid(ref raw) if raw == "onClick(other())"
3534 ));
3535 assert!(matches!(
3536 parse_event_handler("{onClick(count + 1)}"),
3537 EventHandler::Invalid(ref raw) if raw == "onClick(count + 1)"
3538 ));
3539 }
3540
3541 #[test]
3542 fn test_parse_event_handler_rejects_invalid_handler_name() {
3543 assert!(matches!(
3544 parse_event_handler("{handler.name()}"),
3545 EventHandler::Invalid(ref raw) if raw == "handler.name()"
3546 ));
3547 assert!(matches!(
3548 parse_event_handler("{1handler()}"),
3549 EventHandler::Invalid(ref raw) if raw == "1handler()"
3550 ));
3551 }
3552
3553 #[test]
3554 fn test_parse_event_handler_empty_value() {
3555 assert!(matches!(parse_event_handler("{}"), EventHandler::Empty));
3556 }
3557
3558 #[test]
3559 fn test_attribute_to_camel_aria() {
3560 use webui_protocol::attrs::attribute_to_camel;
3561 assert_eq!(attribute_to_camel("aria-describedby"), "ariaDescribedBy");
3562 assert_eq!(attribute_to_camel("aria-labelledby"), "ariaLabelledBy");
3563 assert_eq!(
3564 attribute_to_camel("aria-activedescendant"),
3565 "ariaActiveDescendant"
3566 );
3567 assert_eq!(attribute_to_camel("aria-label"), "ariaLabel");
3568 assert_eq!(attribute_to_camel("aria-hidden"), "ariaHidden");
3569 }
3570
3571 #[test]
3572 fn test_attribute_to_camel_html_global() {
3573 use webui_protocol::attrs::attribute_to_camel;
3574 assert_eq!(attribute_to_camel("readonly"), "readOnly");
3575 assert_eq!(attribute_to_camel("tabindex"), "tabIndex");
3576 assert_eq!(attribute_to_camel("accesskey"), "accessKey");
3577 assert_eq!(attribute_to_camel("contenteditable"), "contentEditable");
3578 assert_eq!(attribute_to_camel("inputmode"), "inputMode");
3579 assert_eq!(attribute_to_camel("maxlength"), "maxLength");
3580 assert_eq!(attribute_to_camel("formaction"), "formAction");
3581 }
3582}