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