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