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