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