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