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