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