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) -> 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);
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) {
1275 out.push_str("function(v,s){return ");
1276 emit_js_condition_expr(condition, out);
1277 out.push('}');
1278}
1279
1280fn emit_json_condition_ref(
1283 condition: &ConditionExpr,
1284 out: &mut String,
1285 functions: &mut ConditionFunctionEmitter,
1286) {
1287 let function_index = functions.push(condition);
1288 out.push('[');
1289 let _ = write!(out, "{}", function_index);
1290 out.push_str(",[");
1291 let mut paths = Vec::new();
1292 collect_condition_paths(condition, &mut paths);
1293 for (i, path) in paths.iter().enumerate() {
1294 if i > 0 {
1295 out.push(',');
1296 }
1297 emit_js_string(path, out);
1298 }
1299 out.push_str("]]");
1300}
1301
1302fn emit_js_condition_expr(condition: &ConditionExpr, out: &mut String) {
1304 match &condition.expr {
1305 Some(condition_expr::Expr::Identifier(id)) if id.value == "true" || id.value == "false" => {
1306 out.push_str(&id.value);
1307 }
1308 Some(condition_expr::Expr::Identifier(id)) => {
1309 out.push_str("!!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>) {
1397 match &condition.expr {
1398 Some(condition_expr::Expr::Identifier(id)) if id.value == "true" || id.value == "false" => {
1399 }
1400 Some(condition_expr::Expr::Identifier(id)) => {
1401 paths.push(id.value.clone());
1402 }
1403 Some(condition_expr::Expr::Predicate(pred)) => {
1404 paths.push(pred.left.clone());
1405 let r = &pred.right;
1407 if !((r.starts_with('"') && r.ends_with('"'))
1408 || (r.starts_with('\'') && r.ends_with('\''))
1409 || r == "true"
1410 || r == "false"
1411 || r.bytes()
1412 .all(|b| b.is_ascii_digit() || b == b'.' || b == b'-'))
1413 {
1414 paths.push(r.clone());
1415 }
1416 }
1417 Some(condition_expr::Expr::Not(not_cond)) => {
1418 if let Some(inner) = not_cond.condition.as_ref() {
1419 collect_condition_paths(inner, paths);
1420 }
1421 }
1422 Some(condition_expr::Expr::Compound(compound)) => {
1423 if let Some(left) = compound.left.as_ref() {
1424 collect_condition_paths(left, paths);
1425 }
1426 if let Some(right) = compound.right.as_ref() {
1427 collect_condition_paths(right, paths);
1428 }
1429 }
1430 None => {}
1431 }
1432}
1433
1434fn emit_js_slot(slot: &SlotLocator, out: &mut String) {
1435 out.push('[');
1436 let _ = write!(out, "{}", slot.parent_index);
1437 out.push(',');
1438 let _ = write!(out, "{}", slot.before_index);
1439 if slot.order > 0 {
1440 out.push(',');
1441 let _ = write!(out, "{}", slot.order);
1442 }
1443 out.push(']');
1444}
1445
1446fn emit_js_text_parts(parts: &[CompiledAttrPart], out: &mut String) {
1447 out.push('[');
1448 for (i, part) in parts.iter().enumerate() {
1449 if i > 0 {
1450 out.push(',');
1451 }
1452 match part {
1453 CompiledAttrPart::Static(value) => emit_js_string(value, out),
1454 CompiledAttrPart::Dynamic(path) => {
1455 out.push('[');
1456 emit_js_string(path, out);
1457 out.push(']');
1458 }
1459 }
1460 }
1461 out.push(']');
1462}
1463
1464fn emit_json_template_section(
1465 meta: &TemplateSectionMeta,
1466 out: &mut String,
1467 functions: &mut ConditionFunctionEmitter,
1468) {
1469 out.push_str("\"h\":");
1470 emit_js_string(&meta.html, out);
1471
1472 if !meta.text_runs.is_empty() {
1473 out.push_str(",\"tx\":[");
1474 for (i, (slot, parts, raw)) in meta.text_runs.iter().enumerate() {
1475 if i > 0 {
1476 out.push(',');
1477 }
1478 out.push('[');
1479 emit_js_slot(slot, out);
1480 out.push(',');
1481 emit_js_text_parts(parts, out);
1482 if *raw {
1483 out.push_str(",1");
1484 }
1485 out.push(']');
1486 }
1487 out.push(']');
1488 }
1489
1490 if !meta.attr_bindings.is_empty() {
1491 out.push_str(",\"a\":[");
1492 for (i, binding) in meta.attr_bindings.iter().enumerate() {
1493 if i > 0 {
1494 out.push(',');
1495 }
1496 emit_json_attr_binding(binding, out, functions);
1497 }
1498 out.push(']');
1499 }
1500
1501 if !meta.attr_groups.is_empty() {
1502 out.push_str(",\"ag\":[");
1503 for (i, (element_index, start, count)) in meta.attr_groups.iter().enumerate() {
1504 if i > 0 {
1505 out.push(',');
1506 }
1507 out.push('[');
1508 let _ = write!(out, "{}", element_index);
1509 out.push(',');
1510 let _ = write!(out, "{}", start);
1511 out.push(',');
1512 let _ = write!(out, "{}", count);
1513 out.push(']');
1514 }
1515 out.push(']');
1516 }
1517
1518 if !meta.conditionals.is_empty() {
1519 out.push_str(",\"c\":[");
1520 for (i, ((cond, block_index), slot)) in meta
1521 .conditionals
1522 .iter()
1523 .zip(meta.condition_slots.iter())
1524 .enumerate()
1525 {
1526 if i > 0 {
1527 out.push(',');
1528 }
1529 out.push('[');
1530 emit_json_condition_ref(cond, out, functions);
1531 out.push(',');
1532 let _ = write!(out, "{}", block_index);
1533 out.push(',');
1534 emit_js_slot(slot, out);
1535 out.push(']');
1536 }
1537 out.push(']');
1538 }
1539
1540 if !meta.repeats.is_empty() {
1541 out.push_str(",\"r\":[");
1542 for (i, (repeat, slot)) in meta
1543 .repeats
1544 .iter()
1545 .zip(meta.repeat_slots.iter())
1546 .enumerate()
1547 {
1548 if i > 0 {
1549 out.push(',');
1550 }
1551 out.push('[');
1552 emit_js_string(&repeat.collection, out);
1553 out.push(',');
1554 emit_js_string(&repeat.item_var, out);
1555 out.push(',');
1556 let _ = write!(out, "{}", repeat.block_index);
1557 out.push(',');
1558 emit_js_slot(slot, out);
1559 if let Some(key_path) = &repeat.key_path {
1560 out.push(',');
1561 emit_js_string(key_path, out);
1562 }
1563 out.push(']');
1564 }
1565 out.push(']');
1566 }
1567
1568 if !meta.events.is_empty() {
1569 emit_js_event_groups(&meta.events, &meta.event_targets, out);
1570 }
1571}
1572
1573fn compile_to_metadata(
1592 component: &str,
1593 input: TemplateSource<'_>,
1594 root_events: Vec<EventBinding>,
1595) -> Result<TemplateMeta> {
1596 let mut blocks = CompileBlocks::default();
1597 let source = input.source;
1598 let mut root = compile_section(component, input, &mut blocks)?;
1599 blocks.resolve(component, source, &mut root)?;
1600 finalize_template_section(&mut root);
1601 for block in &mut blocks.blocks {
1602 finalize_template_section(block);
1603 }
1604 Ok(TemplateMeta {
1605 root,
1606 has_named_repeats: blocks.has_named_repeats(),
1607 blocks: blocks.blocks,
1608 root_events,
1609 })
1610}
1611
1612fn compile_section(
1613 component: &str,
1614 source: TemplateSource<'_>,
1615 blocks: &mut CompileBlocks,
1616) -> Result<TemplateSectionMeta> {
1617 let input = source.html();
1618 let mut meta = TemplateSectionMeta {
1619 html: String::with_capacity(input.len()),
1620 text_bindings: Vec::new(),
1621 text_marker_offsets: Vec::new(),
1622 text_runs: Vec::new(),
1623 attr_bindings: Vec::new(),
1624 attr_groups: Vec::new(),
1625 conditionals: Vec::new(),
1626 condition_slots: Vec::new(),
1627 repeats: Vec::new(),
1628 repeat_slots: Vec::new(),
1629 events: Vec::new(),
1630 event_targets: Vec::new(),
1631 };
1632
1633 let bytes = input.as_bytes();
1634 let len = bytes.len();
1635 let mut i = 0;
1636
1637 while i < len {
1638 if !input.is_char_boundary(i) {
1639 meta.html.push(bytes[i] as char);
1640 i += 1;
1641 continue;
1642 }
1643
1644 if let Some(next) = compile_text_binding_at(input, i, &mut meta) {
1645 i = next;
1646 continue;
1647 }
1648
1649 if bytes[i] == b'<' {
1651 let remaining = &input[i..];
1652 if remaining.starts_with("<!--") {
1653 if let Some(close) = remaining.find("-->") {
1654 i += close + 3;
1655 continue;
1656 }
1657 }
1658
1659 let opening_name = parse_tag(remaining)
1660 .filter(|tag| !tag.closing)
1661 .map(|tag| tag.name);
1662 if opening_name == Some("if") {
1663 if let Some((cond, body, consumed)) = parse_if_block(source.slice(i..len)) {
1664 let block_index = blocks.reserve();
1665 let block = compile_section(component, body, blocks)?;
1666 blocks.blocks[block_index] = block;
1667 let idx = meta.conditionals.len();
1668 meta.conditionals.push((cond, block_index));
1669 meta.html.push_str(&format!("<!--c:{idx}-->"));
1670 i += consumed;
1671 continue;
1672 }
1673 }
1674
1675 if opening_name == Some("for") {
1677 let tag_source = source.slice(i..len);
1678 if let Some(repeat) = parse_for_block(component, tag_source.clone())
1679 .map_err(|error| tag_source.locate_error(error))?
1680 {
1681 let block_index = blocks.repeat_index(component, &repeat)?;
1682 if repeat.definition {
1683 let block = compile_section(component, repeat.body, blocks)?;
1684 blocks.blocks[block_index] = block;
1685 }
1686 let idx = meta.repeats.len();
1687 meta.repeats.push(CompiledRepeat {
1688 collection: repeat.collection,
1689 item_var: repeat.item_var,
1690 block_index,
1691 key_path: repeat.key_path,
1692 });
1693 meta.html.push_str(&format!("<!--r:{idx}-->"));
1694 i += repeat.consumed;
1695 continue;
1696 }
1697 }
1698
1699 if let Some((open_end, close_start, close_end)) = find_style_element_bounds(input, i) {
1700 meta.html.push_str(&input[i..open_end]);
1701 compile_style_content(&input[open_end..close_start], &mut meta);
1702 meta.html.push_str(&input[close_start..close_end]);
1703 i = close_end;
1704 continue;
1705 }
1706
1707 if remaining.starts_with("<outlet") {
1710 if let Some(consumed) = find_element_end(remaining, "outlet") {
1711 meta.html.push_str("<outlet></outlet>");
1712 i += consumed;
1713 continue;
1714 }
1715 }
1716
1717 let repeat_key = source
1718 .repeat_key
1719 .and_then(|offset| offset.checked_sub(source.range.start + i));
1720 if let Some((tag_html, consumed)) =
1721 parse_regular_tag(component, remaining, &mut meta, repeat_key)?
1722 {
1723 meta.html.push_str(&tag_html);
1724 i += consumed;
1725 continue;
1726 }
1727 }
1728
1729 if bytes[i] == b'@' && is_inside_tag(input, i) {
1731 if let Some((event_name, handler, args, consumed)) =
1732 parse_event_attr(component, input, i)?
1733 {
1734 meta.events.push((event_name, handler, args));
1735 meta.html.push_str("data-ev=\"1\"");
1736 i += consumed;
1737 continue;
1738 }
1739 }
1740
1741 let ch = &input[i..];
1746 if let Some(c) = ch.chars().next() {
1747 meta.html.push(c);
1748 i += c.len_utf8();
1749 } else {
1750 i += 1;
1751 }
1752 }
1753
1754 Ok(meta)
1755}
1756
1757fn compile_text_binding_at(
1758 input: &str,
1759 index: usize,
1760 meta: &mut TemplateSectionMeta,
1761) -> Option<usize> {
1762 let bytes = input.as_bytes();
1763
1764 if index + 4 < bytes.len()
1765 && bytes[index] == b'{'
1766 && bytes[index + 1] == b'{'
1767 && bytes[index + 2] == b'{'
1768 {
1769 if let Some(end) = find_brace_end(input, index + 3, 3) {
1770 let expr = input[index + 3..end].trim();
1771 let idx = meta.text_bindings.len();
1772 meta.text_bindings.push((expr.to_string(), true));
1773 emit_text_marker(meta, idx);
1774 return Some(end + 3);
1775 }
1776 }
1777
1778 if index + 3 < bytes.len() && bytes[index] == b'{' && bytes[index + 1] == b'{' {
1779 if let Some(end) = find_brace_end(input, index + 2, 2) {
1780 let expr = input[index + 2..end].trim();
1781 let idx = meta.text_bindings.len();
1782 meta.text_bindings.push((expr.to_string(), false));
1783 emit_text_marker(meta, idx);
1784 return Some(end + 2);
1785 }
1786 }
1787
1788 None
1789}
1790
1791fn emit_text_marker(meta: &mut TemplateSectionMeta, index: usize) {
1792 meta.text_marker_offsets.push(meta.html.len());
1793 meta.html.push_str(TEXT_MARKER_PREFIX);
1794 let _ = write!(meta.html, "{index}");
1795 meta.html.push_str(TEXT_MARKER_SUFFIX);
1796}
1797
1798fn compile_style_content(input: &str, meta: &mut TemplateSectionMeta) {
1799 let bytes = input.as_bytes();
1800 let len = bytes.len();
1801 let mut index = 0usize;
1802
1803 while index < len {
1804 if index + 1 < len && bytes[index] == b'/' && bytes[index + 1] == b'*' {
1805 if let Some(close) = input[index + 2..].find("*/") {
1806 let end = index + 2 + close + 2;
1807 let comment = &input[index..end];
1808 if compile_css_signal_comment(comment, meta) {
1809 index = end;
1810 continue;
1811 }
1812 if comment_policy::is_legal_css_comment(comment) {
1813 meta.html.push_str(comment);
1814 }
1815 index = end;
1816 continue;
1817 }
1818 }
1819
1820 if comment_policy::is_css_line_comment_start(input, index) {
1821 let end = comment_policy::find_css_line_comment_end(input, index + 2);
1822 let comment = &input[index..end];
1823 if comment_policy::is_legal_css_comment(comment) {
1824 meta.html.push_str(comment);
1825 }
1826 index = end;
1827 continue;
1828 }
1829
1830 if let Some(next) = compile_text_binding_at(input, index, meta) {
1831 index = next;
1832 continue;
1833 }
1834
1835 let Some(ch) = input[index..].chars().next() else {
1836 break;
1837 };
1838 meta.html.push(ch);
1839 index += ch.len_utf8();
1840 }
1841}
1842
1843fn compile_css_signal_comment(comment: &str, meta: &mut TemplateSectionMeta) -> bool {
1844 if let Some(signal) = comment_policy::parse_css_signal_comment(comment) {
1845 let idx = meta.text_bindings.len();
1846 meta.text_bindings.push((signal.path, false));
1847 emit_text_marker(meta, idx);
1848 return true;
1849 }
1850
1851 false
1852}
1853
1854fn find_style_element_bounds(input: &str, index: usize) -> Option<(usize, usize, usize)> {
1855 let (content_start, close_start, close_end) = style_element_bounds(&input[index..])?;
1856 Some((
1857 index + content_start,
1858 index + close_start,
1859 index + close_end,
1860 ))
1861}
1862
1863#[derive(Clone)]
1864enum FragmentNode {
1865 Element(FragmentElement),
1866 Text(String),
1867 TextMarker(usize),
1868 Comment(String),
1869}
1870
1871#[derive(Clone)]
1872struct FragmentElement {
1873 tag_name: String,
1874 attrs: Vec<FragmentAttr>,
1875 children: Vec<FragmentNode>,
1876 self_closing: bool,
1877}
1878
1879#[derive(Clone)]
1880struct FragmentAttr {
1881 name: String,
1882 value: Option<String>,
1883}
1884
1885fn finalize_template_section(meta: &mut TemplateSectionMeta) {
1886 let raw_html = std::mem::take(&mut meta.html);
1887 let text_marker_offsets = std::mem::take(&mut meta.text_marker_offsets);
1888 let mut nodes = parse_fragment_nodes(&raw_html, &text_marker_offsets);
1889 insert_implied_table_containers(&mut nodes);
1890 let text_bindings = meta.text_bindings.clone();
1891 let mut finalized_html = String::with_capacity(raw_html.len());
1892 let mut text_runs = Vec::new();
1893 let mut attr_groups = Vec::new();
1894 let mut condition_slots = vec![None; meta.conditionals.len()];
1895 let mut repeat_slots = vec![None; meta.repeats.len()];
1896 let mut event_targets = vec![None; meta.events.len()];
1897 let mut event_cursor = 0usize;
1898
1899 let mut element_cursor = 0usize;
1900 process_fragment_children(
1901 &nodes,
1902 0,
1903 &text_bindings,
1904 &mut finalized_html,
1905 &mut text_runs,
1906 &mut attr_groups,
1907 &mut condition_slots,
1908 &mut repeat_slots,
1909 &mut event_targets,
1910 &mut event_cursor,
1911 &mut element_cursor,
1912 );
1913 debug_assert_eq!(event_cursor, meta.events.len());
1914
1915 meta.html = finalized_html;
1916 meta.text_runs = text_runs;
1917 meta.attr_groups = attr_groups;
1918 meta.condition_slots = condition_slots.into_iter().flatten().collect();
1919 meta.repeat_slots = repeat_slots.into_iter().flatten().collect();
1920 meta.event_targets = event_targets.into_iter().flatten().collect();
1921}
1922
1923#[allow(clippy::too_many_arguments)]
1924fn process_fragment_children(
1925 nodes: &[FragmentNode],
1926 parent_index: usize,
1927 text_bindings: &[(String, bool)],
1928 out: &mut String,
1929 text_runs: &mut Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
1930 attr_groups: &mut Vec<(usize, usize, usize)>,
1931 condition_slots: &mut [Option<SlotLocator>],
1932 repeat_slots: &mut [Option<SlotLocator>],
1933 event_targets: &mut [Option<usize>],
1934 event_cursor: &mut usize,
1935 element_cursor: &mut usize,
1936) {
1937 let mut child_index = 0usize;
1938 let mut index = 0usize;
1939 let mut previous_emitted_text = false;
1942 let mut slot_orders = std::collections::BTreeMap::<usize, usize>::new();
1943
1944 while index < nodes.len() {
1945 if let Some((parts, consumed, has_dynamic, is_raw)) =
1946 collect_text_run(&nodes[index..], text_bindings)
1947 {
1948 let adjacent_to_raw = !has_dynamic
1949 && ((index > 0 && is_raw_text_marker(&nodes[index - 1], text_bindings))
1950 || (index + consumed < nodes.len()
1951 && is_raw_text_marker(&nodes[index + consumed], text_bindings)));
1952 if has_dynamic || adjacent_to_raw {
1953 let decoded_parts = parts
1959 .into_iter()
1960 .map(|part| match part {
1961 CompiledAttrPart::Static(s) => {
1962 let decoded = html_escape::decode_html_entities(&s);
1963 CompiledAttrPart::Static(decoded.into_owned())
1964 }
1965 other => other,
1966 })
1967 .collect();
1968 let order = slot_orders.get(&child_index).copied().unwrap_or(0);
1969 slot_orders.insert(child_index, order + 1);
1970 text_runs.push((
1971 SlotLocator {
1972 parent_index,
1973 before_index: child_index,
1974 order,
1975 },
1976 decoded_parts,
1977 is_raw,
1978 ));
1979 } else {
1980 let static_text = collect_static_text(&parts);
1981 if !static_text.is_empty() {
1982 out.push_str(&static_text);
1983 if !previous_emitted_text {
1984 child_index += 1;
1985 }
1986 previous_emitted_text = true;
1987 }
1988 }
1989 index += consumed;
1990 continue;
1991 }
1992
1993 match &nodes[index] {
1994 FragmentNode::TextMarker(_) => {}
1995 FragmentNode::Comment(data) => {
1996 if let Some(idx) = parse_marker_index(data, "c:") {
1997 if let Some(slot) = condition_slots.get_mut(idx) {
1998 let order = slot_orders.get(&child_index).copied().unwrap_or(0);
1999 slot_orders.insert(child_index, order + 1);
2000 *slot = Some(SlotLocator {
2001 parent_index,
2002 before_index: child_index,
2003 order,
2004 });
2005 }
2006 } else if let Some(idx) = parse_marker_index(data, "r:") {
2007 if let Some(slot) = repeat_slots.get_mut(idx) {
2008 let order = slot_orders.get(&child_index).copied().unwrap_or(0);
2009 slot_orders.insert(child_index, order + 1);
2010 *slot = Some(SlotLocator {
2011 parent_index,
2012 before_index: child_index,
2013 order,
2014 });
2015 }
2016 } else {
2017 out.push_str("<!--");
2018 out.push_str(data);
2019 out.push_str("-->");
2020 child_index += 1;
2021 previous_emitted_text = false;
2022 }
2023 }
2024 FragmentNode::Text(text) => {
2025 out.push_str(text);
2026 if !text.is_empty() {
2027 if !previous_emitted_text {
2028 child_index += 1;
2029 }
2030 previous_emitted_text = true;
2031 }
2032 }
2033 FragmentNode::Element(element) => {
2034 *element_cursor += 1;
2035 let element_index = *element_cursor;
2036 serialize_fragment_element(
2037 element,
2038 element_index,
2039 text_bindings,
2040 out,
2041 text_runs,
2042 attr_groups,
2043 condition_slots,
2044 repeat_slots,
2045 event_targets,
2046 event_cursor,
2047 element_cursor,
2048 );
2049 child_index += 1;
2050 previous_emitted_text = false;
2051 }
2052 }
2053
2054 index += 1;
2055 }
2056}
2057
2058#[allow(clippy::too_many_arguments)]
2059fn serialize_fragment_element(
2060 element: &FragmentElement,
2061 element_index: usize,
2062 text_bindings: &[(String, bool)],
2063 out: &mut String,
2064 text_runs: &mut Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
2065 attr_groups: &mut Vec<(usize, usize, usize)>,
2066 condition_slots: &mut [Option<SlotLocator>],
2067 repeat_slots: &mut [Option<SlotLocator>],
2068 event_targets: &mut [Option<usize>],
2069 event_cursor: &mut usize,
2070 element_cursor: &mut usize,
2071) {
2072 out.push('<');
2073 out.push_str(&element.tag_name);
2074
2075 for attr in &element.attrs {
2076 if let Some((start, count)) = parse_attr_group_marker(&attr.name) {
2077 attr_groups.push((element_index, start, count));
2078 continue;
2079 }
2080
2081 if attr.name == "data-ev" {
2082 if let Some(count) = attr.value.as_deref().and_then(parse_event_marker_count) {
2083 for slot in event_targets.iter_mut().skip(*event_cursor).take(count) {
2084 *slot = Some(element_index);
2085 }
2086 *event_cursor += count;
2087 }
2088 continue;
2089 }
2090
2091 out.push(' ');
2092 out.push_str(&attr.name);
2093 if let Some(value) = &attr.value {
2094 out.push_str("=\"");
2095 emit_html_attr_value(value, out);
2096 out.push('"');
2097 }
2098 }
2099
2100 if element.self_closing {
2101 out.push_str(" />");
2102 return;
2103 }
2104
2105 out.push('>');
2106 let mut interior = *element_cursor;
2112 let counter = if element.tag_name.eq_ignore_ascii_case("template") {
2113 &mut interior
2114 } else {
2115 &mut *element_cursor
2116 };
2117 process_fragment_children(
2118 &element.children,
2119 element_index,
2120 text_bindings,
2121 out,
2122 text_runs,
2123 attr_groups,
2124 condition_slots,
2125 repeat_slots,
2126 event_targets,
2127 event_cursor,
2128 counter,
2129 );
2130 out.push_str("</");
2131 out.push_str(&element.tag_name);
2132 out.push('>');
2133}
2134
2135fn collect_text_run(
2136 nodes: &[FragmentNode],
2137 text_bindings: &[(String, bool)],
2138) -> Option<(Vec<CompiledAttrPart>, usize, bool, bool)> {
2139 let mut parts = Vec::new();
2140 let mut consumed = 0usize;
2141 let mut has_dynamic = false;
2142 let mut is_raw = false;
2143
2144 for node in nodes {
2145 match node {
2146 FragmentNode::Text(text) => {
2147 if !text.is_empty() {
2148 parts.push(CompiledAttrPart::Static(text.clone()));
2149 }
2150 consumed += 1;
2151 }
2152 FragmentNode::Comment(_) => break,
2153 FragmentNode::TextMarker(index) => {
2154 if let Some((path, raw)) = text_bindings.get(*index) {
2155 if *raw && consumed != 0 {
2156 break;
2157 }
2158 parts.push(CompiledAttrPart::Dynamic(path.clone()));
2159 has_dynamic = true;
2160 if *raw {
2161 is_raw = true;
2162 consumed += 1;
2163 break;
2164 }
2165 consumed += 1;
2166 } else {
2167 break;
2168 }
2169 }
2170 FragmentNode::Element(_) => break,
2171 }
2172 }
2173
2174 if consumed == 0 {
2175 return None;
2176 }
2177
2178 Some((parts, consumed, has_dynamic, is_raw))
2179}
2180
2181fn is_raw_text_marker(node: &FragmentNode, text_bindings: &[(String, bool)]) -> bool {
2182 let FragmentNode::TextMarker(index) = node else {
2183 return false;
2184 };
2185 text_bindings.get(*index).is_some_and(|(_, raw)| *raw)
2186}
2187
2188fn collect_static_text(parts: &[CompiledAttrPart]) -> String {
2189 let mut text = String::new();
2190 for part in parts {
2191 if let CompiledAttrPart::Static(value) = part {
2192 text.push_str(value);
2193 }
2194 }
2195 text
2196}
2197
2198fn parse_marker_index(data: &str, prefix: &str) -> Option<usize> {
2199 data.strip_prefix(prefix)?.parse().ok()
2200}
2201
2202fn parse_attr_group_marker(name: &str) -> Option<(usize, usize)> {
2203 if let Some(start) = name.strip_prefix("data-w-b-") {
2204 return start.parse::<usize>().ok().map(|index| (index, 1));
2205 }
2206
2207 let rest = name.strip_prefix("data-w-c-")?;
2208 let mut parts = rest.split('-');
2209 let start = parts.next()?.parse::<usize>().ok()?;
2210 let count = parts.next()?.parse::<usize>().ok()?;
2211 Some((start, count))
2212}
2213
2214fn parse_event_marker_count(value: &str) -> Option<usize> {
2215 let count = value.parse::<usize>().ok()?;
2216 (count > 0).then_some(count)
2217}
2218
2219fn emit_html_attr_value(value: &str, out: &mut String) {
2220 for ch in value.chars() {
2221 match ch {
2222 '"' => out.push_str("""),
2223 _ => out.push(ch),
2224 }
2225 }
2226}
2227
2228fn insert_implied_table_containers(nodes: &mut Vec<FragmentNode>) {
2244 let mut stack: Vec<Vec<usize>> = vec![Vec::new()];
2245
2246 while let Some(path) = stack.pop() {
2247 let Some(list) = child_list_at(nodes, &path) else {
2248 continue;
2249 };
2250
2251 for index in 0..list.len() {
2252 if let Some(FragmentNode::Element(element)) = list.get_mut(index) {
2253 if element.tag_name.eq_ignore_ascii_case("table") {
2254 wrap_table_run(element, "col", "colgroup", false);
2255 wrap_table_run(element, "tr", "tbody", true);
2256 }
2257 }
2258 }
2259
2260 let Some(list) = child_list_at(nodes, &path) else {
2262 continue;
2263 };
2264 for index in 0..list.len() {
2265 if matches!(list.get(index), Some(FragmentNode::Element(_))) {
2266 let mut child = path.clone();
2267 child.push(index);
2268 stack.push(child);
2269 }
2270 }
2271 }
2272}
2273
2274fn child_list_at<'a>(
2276 roots: &'a mut Vec<FragmentNode>,
2277 path: &[usize],
2278) -> Option<&'a mut Vec<FragmentNode>> {
2279 let mut list = roots;
2280 for &index in path {
2281 match list.get_mut(index) {
2282 Some(FragmentNode::Element(element)) => list = &mut element.children,
2283 _ => return None,
2284 }
2285 }
2286 Some(list)
2287}
2288
2289fn wrap_table_run(
2291 table: &mut FragmentElement,
2292 item_tag: &str,
2293 parent_tag: &str,
2294 include_structural_markers: bool,
2295) {
2296 if !table
2297 .children
2298 .iter()
2299 .any(|node| is_direct_element_named(node, item_tag))
2300 {
2301 return;
2302 }
2303
2304 let mut rewritten = Vec::with_capacity(table.children.len());
2305 let mut run: Vec<FragmentNode> = Vec::new();
2306
2307 for node in std::mem::take(&mut table.children) {
2308 if is_direct_element_named(&node, item_tag) {
2309 run.push(node);
2310 continue;
2311 }
2312 if !run.is_empty() && is_table_run_trivia(&node, include_structural_markers) {
2313 run.push(node);
2314 continue;
2315 }
2316 flush_table_run(&mut run, &mut rewritten, parent_tag);
2317 rewritten.push(node);
2318 }
2319 flush_table_run(&mut run, &mut rewritten, parent_tag);
2320
2321 table.children = rewritten;
2322}
2323
2324fn flush_table_run(run: &mut Vec<FragmentNode>, out: &mut Vec<FragmentNode>, parent_tag: &str) {
2325 if run.is_empty() {
2326 return;
2327 }
2328 out.push(FragmentNode::Element(FragmentElement {
2329 tag_name: parent_tag.to_string(),
2330 attrs: Vec::new(),
2331 children: std::mem::take(run),
2332 self_closing: false,
2333 }));
2334}
2335
2336fn is_direct_element_named(node: &FragmentNode, tag_name: &str) -> bool {
2337 matches!(node, FragmentNode::Element(element) if element.tag_name.eq_ignore_ascii_case(tag_name))
2338}
2339
2340fn is_table_run_trivia(node: &FragmentNode, include_structural_markers: bool) -> bool {
2341 matches!(node, FragmentNode::Comment(data) if include_structural_markers
2342 || (parse_marker_index(data, "c:").is_none() && parse_marker_index(data, "r:").is_none()))
2343 || matches!(node, FragmentNode::Text(text) if text.bytes().all(
2344 |byte| matches!(byte, b'\t' | b'\n' | 0x0C | b'\r' | b' ')
2345 ))
2346}
2347
2348fn parse_fragment_nodes(input: &str, text_marker_offsets: &[usize]) -> Vec<FragmentNode> {
2349 let mut roots = Vec::new();
2350 let mut stack: Vec<FragmentElement> = Vec::new();
2351 let bytes = input.as_bytes();
2352 let len = bytes.len();
2353 let mut index = 0usize;
2354
2355 while index < len {
2356 let remaining = &input[index..];
2357 if let Some((marker_index, marker_end)) =
2358 find_text_marker_comment(input, index, 0, text_marker_offsets)
2359 {
2360 push_fragment_node(
2361 &mut roots,
2362 &mut stack,
2363 FragmentNode::TextMarker(marker_index),
2364 );
2365 index = marker_end;
2366 continue;
2367 }
2368
2369 if remaining.starts_with("<!--") {
2370 if let Some(close) = remaining.find("-->") {
2371 push_fragment_node(
2372 &mut roots,
2373 &mut stack,
2374 FragmentNode::Comment(remaining[4..close].to_string()),
2375 );
2376 index += close + 3;
2377 continue;
2378 }
2379 }
2380
2381 if remaining.starts_with("</") {
2382 if let Some(close) = find_tag_close(remaining) {
2383 if let Some(element) = stack.pop() {
2384 push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
2385 }
2386 index += close + 1;
2387 continue;
2388 }
2389 }
2390
2391 if remaining.starts_with('<') {
2392 if let Some((mut element, consumed)) = parse_fragment_start_tag(remaining) {
2393 if element.self_closing {
2394 push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
2395 } else if element.tag_name.eq_ignore_ascii_case("style") {
2396 if let Some((content_offset, close_offset, close_end)) =
2397 style_element_bounds(remaining)
2398 {
2399 let content_start = index + content_offset;
2400 let close_start = index + close_offset;
2401 push_style_raw_text_nodes(
2402 &mut element.children,
2403 &input[content_start..close_start],
2404 content_start,
2405 text_marker_offsets,
2406 );
2407 push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
2408 index += close_end;
2409 continue;
2410 }
2411
2412 let content_start = index + consumed;
2413 push_style_raw_text_nodes(
2414 &mut element.children,
2415 &input[content_start..],
2416 content_start,
2417 text_marker_offsets,
2418 );
2419 push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
2420 index = len;
2421 continue;
2422 } else {
2423 stack.push(element);
2424 }
2425 index += consumed;
2426 continue;
2427 }
2428 }
2429
2430 let next = remaining.find('<').unwrap_or(remaining.len());
2431 let text = &remaining[..next];
2432 if !text.is_empty() {
2433 push_fragment_node(&mut roots, &mut stack, FragmentNode::Text(text.to_string()));
2434 }
2435 index += next.max(1);
2436 }
2437
2438 while let Some(element) = stack.pop() {
2439 push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
2440 }
2441
2442 roots
2443}
2444
2445fn push_style_raw_text_nodes(
2446 children: &mut Vec<FragmentNode>,
2447 input: &str,
2448 base_offset: usize,
2449 text_marker_offsets: &[usize],
2450) {
2451 let bytes = input.as_bytes();
2452 let len = bytes.len();
2453 let mut index = 0usize;
2454 let mut text_start = 0usize;
2455
2456 while index + MIN_TEXT_MARKER_LEN <= len {
2457 if let Some((marker_index, marker_end)) =
2458 find_text_marker_comment(input, index, base_offset, text_marker_offsets)
2459 {
2460 if text_start < index {
2461 children.push(FragmentNode::Text(input[text_start..index].to_string()));
2462 }
2463 children.push(FragmentNode::TextMarker(marker_index));
2464 index = marker_end;
2465 text_start = marker_end;
2466 continue;
2467 }
2468 index += 1;
2469 }
2470
2471 if text_start < len {
2472 children.push(FragmentNode::Text(input[text_start..].to_string()));
2473 }
2474}
2475
2476fn find_text_marker_comment(
2477 input: &str,
2478 index: usize,
2479 base_offset: usize,
2480 text_marker_offsets: &[usize],
2481) -> Option<(usize, usize)> {
2482 let bytes = input.as_bytes();
2483 if bytes.get(index..index + TEXT_MARKER_PREFIX.len()) != Some(TEXT_MARKER_PREFIX.as_bytes()) {
2484 return None;
2485 }
2486 if text_marker_offsets
2487 .binary_search(&(base_offset + index))
2488 .is_err()
2489 {
2490 return None;
2491 }
2492
2493 let mut cursor = index + TEXT_MARKER_PREFIX.len();
2494 let digit_start = cursor;
2495 let mut marker_index = 0usize;
2496 while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
2497 marker_index = marker_index
2498 .checked_mul(TEXT_MARKER_INDEX_RADIX)?
2499 .checked_add((bytes[cursor] - b'0') as usize)?;
2500 cursor += 1;
2501 }
2502 if cursor == digit_start
2503 || bytes.get(cursor..cursor + TEXT_MARKER_SUFFIX.len())
2504 != Some(TEXT_MARKER_SUFFIX.as_bytes())
2505 {
2506 return None;
2507 }
2508
2509 Some((marker_index, cursor + TEXT_MARKER_SUFFIX.len()))
2510}
2511
2512fn push_fragment_node(
2513 roots: &mut Vec<FragmentNode>,
2514 stack: &mut [FragmentElement],
2515 node: FragmentNode,
2516) {
2517 if let Some(parent) = stack.last_mut() {
2518 parent.children.push(node);
2519 } else {
2520 roots.push(node);
2521 }
2522}
2523
2524fn parse_fragment_start_tag(input: &str) -> Option<(FragmentElement, usize)> {
2525 if !input.starts_with('<') || input.starts_with("</") || input.starts_with("<!--") {
2526 return None;
2527 }
2528
2529 let end = find_tag_close(input)?;
2530 let tag_content = &input[1..end];
2531 let tag_body = tag_content.trim_end();
2532 let self_closing = tag_body.ends_with('/');
2533 let tag_body = if self_closing {
2534 tag_body[..tag_body.len().saturating_sub(1)].trim_end()
2535 } else {
2536 tag_body
2537 };
2538
2539 let body_bytes = tag_body.as_bytes();
2540 let mut cursor = 0usize;
2541 while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
2542 cursor += 1;
2543 }
2544
2545 let tag_name = &tag_body[..cursor];
2546 if tag_name.is_empty() {
2547 return None;
2548 }
2549
2550 let mut attrs = Vec::new();
2551 while cursor < body_bytes.len() {
2552 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
2553 cursor += 1;
2554 }
2555 if cursor >= body_bytes.len() {
2556 break;
2557 }
2558
2559 let attr_start = cursor;
2560 while cursor < body_bytes.len()
2561 && !body_bytes[cursor].is_ascii_whitespace()
2562 && body_bytes[cursor] != b'='
2563 {
2564 cursor += 1;
2565 }
2566 if attr_start == cursor {
2567 cursor += 1;
2568 continue;
2569 }
2570
2571 let name = tag_body[attr_start..cursor].to_string();
2572 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
2573 cursor += 1;
2574 }
2575
2576 let mut value: Option<String> = None;
2577 if cursor < body_bytes.len() && body_bytes[cursor] == b'=' {
2578 cursor += 1;
2579 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
2580 cursor += 1;
2581 }
2582
2583 if cursor < body_bytes.len()
2584 && (body_bytes[cursor] == b'"' || body_bytes[cursor] == b'\'')
2585 {
2586 let quote = body_bytes[cursor];
2587 cursor += 1;
2588 let value_start = cursor;
2589 while cursor < body_bytes.len() && body_bytes[cursor] != quote {
2590 cursor += 1;
2591 }
2592 value = Some(tag_body[value_start..cursor].to_string());
2593 if cursor < body_bytes.len() {
2594 cursor += 1;
2595 }
2596 } else {
2597 let value_start = cursor;
2598 while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
2599 cursor += 1;
2600 }
2601 value = Some(tag_body[value_start..cursor].to_string());
2602 }
2603 }
2604
2605 attrs.push(FragmentAttr { name, value });
2606 }
2607
2608 Some((
2609 FragmentElement {
2610 tag_name: tag_name.to_string(),
2611 attrs,
2612 children: Vec::new(),
2613 self_closing: self_closing || is_void_element(tag_name),
2614 },
2615 end + 1,
2616 ))
2617}
2618
2619fn find_brace_end(input: &str, start: usize, count: usize) -> Option<usize> {
2624 let bytes = input.as_bytes();
2625 let mut i = start;
2626 while i + count - 1 < bytes.len() {
2627 let all_close = (0..count).all(|j| bytes[i + j] == b'}');
2628 if all_close {
2629 return Some(i);
2630 }
2631 i += 1;
2632 }
2633 None
2634}
2635
2636fn is_inside_tag(input: &str, pos: usize) -> bool {
2639 let bytes = input.as_bytes();
2640 let mut i = pos;
2641 while i > 0 {
2642 i -= 1;
2643 if bytes[i] == b'>' {
2644 return false;
2645 }
2646 if bytes[i] == b'<' {
2647 return true;
2648 }
2649 }
2650 false
2651}
2652
2653fn shadow_template_body_range(html: &str) -> Option<Range<usize>> {
2655 let (html, offset) = leading_content(html);
2656 let tag = parse_tag(html)?;
2657 if tag.closing || !tag.name.eq_ignore_ascii_case("template") {
2658 return None;
2659 }
2660 let is_open = tag.attrs().any(|attr| {
2661 attr.name.eq_ignore_ascii_case("shadowrootmode")
2662 && attr
2663 .value
2664 .is_some_and(|value| value.eq_ignore_ascii_case("open"))
2665 });
2666 if !is_open {
2667 return None;
2668 }
2669 let inner_start = tag.close + 1;
2670 let (inner_end, close_end) = find_matching_end(html, tag.name, inner_start)?;
2671 (close_end == html.len()).then_some(offset + inner_start..offset + inner_end)
2672}
2673
2674fn parse_if_block(
2678 source: TemplateSource<'_>,
2679) -> Option<(ConditionExpr, TemplateSource<'_>, usize)> {
2680 let input = source.html();
2681 let tag = parse_tag(input)?;
2682 let condition = compile_condition_expr(tag.attr("condition")?);
2683 let body_start = tag.close + 1;
2684 let (body_end, close_end) = find_matching_end(input, tag.name, body_start)?;
2685 Some((
2686 condition,
2687 source.slice(body_start..body_end).trimmed(),
2688 close_end,
2689 ))
2690}
2691
2692fn compile_condition_expr(input: &str) -> ConditionExpr {
2693 let input = crate::strip_condition_braces(input);
2694 let parser = ConditionParser::new();
2695 match parser.parse(input) {
2696 Ok(condition) => condition,
2697 Err(_) => ConditionExpr::identifier(input),
2701 }
2702}
2703
2704fn parse_for_block<'a>(
2710 component: &str,
2711 source: TemplateSource<'a>,
2712) -> Result<Option<ParsedForBlock<'a>>> {
2713 let input = source.html();
2714 let Some(tag) = parse_tag(input) else {
2715 return Ok(None);
2716 };
2717 let mut each = None;
2718 let mut has_id = false;
2719 for attr in tag.attrs() {
2720 match attr.name {
2721 "each" if each.is_none() => each = Some(attr.value),
2722 "id" => has_id = true,
2723 "template" => return Err(crate::unsupported_for_template(component)),
2724 "key" => return Err(invalid_repeat_key_placement(component, tag.name).into()),
2725 _ => {}
2726 }
2727 }
2728 let id = if has_id {
2729 crate::validate_for_id(component, &tag)?.map(str::to_owned)
2730 } else {
2731 None
2732 };
2733 let (body_end, close_end) = if tag.self_closing && id.is_some() {
2734 (tag.close + 1, tag.close + 1)
2735 } else {
2736 let Some(bounds) = find_matching_end(input, tag.name, tag.close + 1) else {
2737 return Ok(None);
2738 };
2739 bounds
2740 };
2741 let Some(Some(each_val)) = each else {
2742 return Ok(None);
2743 };
2744
2745 let (item_var, collection) = crate::parse_for_each(component, each_val)?;
2746 let mut body = source.slice(tag.close + 1..body_end).trimmed();
2747 let repeat_key = first_child_repeat_key(component, item_var, body.html())?;
2748 body.repeat_key = repeat_key
2749 .as_ref()
2750 .map(|key| body.range.start + key.attr_range.start);
2751 let key_path = repeat_key.map(|key| key.path);
2752
2753 Ok(Some(ParsedForBlock {
2754 collection: collection.to_string(),
2755 item_var: item_var.to_string(),
2756 key_path,
2757 id,
2758 definition: !tag.self_closing,
2759 body,
2760 offset: source.range.start,
2761 consumed: close_end,
2762 }))
2763}
2764
2765fn relative_repeat_key<'a>(item_var: &str, key: &'a str) -> Option<&'a str> {
2766 if key == item_var {
2767 return Some("");
2768 }
2769 let relative = key.strip_prefix(item_var)?.strip_prefix('.')?;
2770 (!relative.is_empty() && is_valid_event_path(relative)).then_some(relative)
2771}
2772
2773fn skip_repeat_key_trivia<'a>(mut input: &'a str, offset: &mut usize) -> Option<&'a str> {
2774 loop {
2775 let trimmed = input.trim_start();
2776 *offset += input.len().saturating_sub(trimmed.len());
2777 input = trimmed;
2778 let consumed = if input.starts_with("<!--") {
2779 input.find("-->")? + 3
2780 } else if input.starts_with("<!") {
2781 find_tag_close(input)? + 1
2782 } else {
2783 return Some(input);
2784 };
2785 *offset += consumed;
2786 input = &input[consumed..];
2787 }
2788}
2789
2790fn first_child_repeat_key(
2791 component: &str,
2792 item_var: &str,
2793 body: &str,
2794) -> Result<Option<ParsedRepeatKey>> {
2795 let mut remaining = body;
2796 let mut offset = 0usize;
2797 loop {
2798 let Some(trimmed) = skip_repeat_key_trivia(remaining, &mut offset) else {
2799 return Ok(None);
2800 };
2801 remaining = trimmed;
2802 let Some(tag) = parse_tag(remaining) else {
2803 return Ok(None);
2804 };
2805 if tag.closing {
2806 return Ok(None);
2807 }
2808 let mut key_attr = None;
2809 for attr in tag.attrs() {
2810 if attr.name != "key" {
2811 continue;
2812 }
2813 if key_attr.is_some() {
2814 return Err(invalid_repeat_key_placement(component, tag.name).into());
2815 }
2816 key_attr = Some(attr);
2817 }
2818 if matches!(tag.name, "if" | "for" | "outlet") && key_attr.is_some() {
2819 return Err(invalid_repeat_key_placement(component, tag.name).into());
2820 }
2821 if tag.name == "if" {
2822 let body_start = tag.close + 1;
2823 let Some((body_end, _)) = find_matching_end(remaining, tag.name, body_start) else {
2824 return Ok(None);
2825 };
2826 offset += body_start;
2827 remaining = &remaining[body_start..body_end];
2828 continue;
2829 }
2830 if matches!(tag.name, "for" | "outlet") {
2831 return Ok(None);
2832 }
2833 let Some(attr) = key_attr else {
2834 return Ok(None);
2835 };
2836 let path = parse_repeat_key(component, item_var, attr.value.unwrap_or_default())?;
2837 return Ok(Some(ParsedRepeatKey {
2838 path,
2839 attr_range: (offset + attr.raw_range.start)..(offset + attr.raw_range.end),
2840 }));
2841 }
2842}
2843
2844fn parse_repeat_key(component: &str, item_var: &str, raw: &str) -> Result<String> {
2845 let trimmed = raw.trim();
2846 let key = trimmed
2847 .strip_prefix("{{")
2848 .and_then(|value| value.strip_suffix("}}"))
2849 .filter(|value| !value.starts_with('{') && !value.ends_with('}'))
2850 .map(str::trim);
2851 let Some(key) = key else {
2852 return Err(invalid_repeat_key(component, item_var, raw).into());
2853 };
2854 let Some(relative) = relative_repeat_key(item_var, key) else {
2855 return Err(invalid_repeat_key(component, item_var, raw).into());
2856 };
2857 Ok(relative.to_string())
2858}
2859
2860#[cold]
2861#[inline(never)]
2862fn invalid_repeat_key(component: &str, item_var: &str, key: &str) -> Diagnostic {
2863 Diagnostic::error("invalid repeat key expression")
2864 .code(codes::INVALID_FOR_KEY)
2865 .component(component)
2866 .snippet(key)
2867 .help(format!(
2868 "use key=\"{{{{{item_var}}}}}\" or key=\"{{{{{item_var}.id}}}}\" on the first concrete element inside <for>"
2869 ))
2870}
2871
2872#[cold]
2873#[inline(never)]
2874fn invalid_repeat_key_placement(component: &str, element: &str) -> Diagnostic {
2875 Diagnostic::error("invalid repeat key placement")
2876 .code(codes::INVALID_FOR_KEY)
2877 .component(component)
2878 .element(element)
2879 .snippet("key")
2880 .help(
2881 "place the key attribute on the first concrete element rendered by <for>, not on <if>, <for>, or <outlet>",
2882 )
2883}
2884
2885fn invalid_event_handler(
2890 component: &str,
2891 element: Option<&str>,
2892 event_name: &str,
2893 raw: &str,
2894) -> Diagnostic {
2895 let mut diag = Diagnostic::error(format!("invalid @{event_name} handler"))
2896 .code(crate::diagnostic::codes::INVALID_EVENT_HANDLER)
2897 .component(component)
2898 .snippet(format!("@{event_name}=\"{raw}\""))
2899 .help(format!(
2900 "use @{event_name}=\"{{handler()}}\" or @{event_name}=\"{{handler(e)}}\" \
2901 to pass the event"
2902 ));
2903 if let Some(tag) = element {
2904 diag = diag.element(tag);
2905 }
2906 diag
2907}
2908
2909#[cold]
2910#[inline(never)]
2911fn scriptless_component_events(component: &str) -> Diagnostic {
2912 Diagnostic::error("scriptless component contains event bindings")
2913 .code(codes::SCRIPTLESS_EVENT_HANDLER)
2914 .component(component)
2915 .help(
2916 "add a .ts or .js file that defines a WebUIElement for this tag, or remove the @event binding",
2917 )
2918}
2919
2920fn parse_event_attr(component: &str, input: &str, pos: usize) -> Result<Option<ParsedEventAttr>> {
2930 let remaining = &input[pos..];
2931 let Some(eq_pos) = remaining.find('=') else {
2932 return Ok(None);
2933 };
2934 let event_name = remaining[1..eq_pos].to_string(); let after_eq = &remaining[eq_pos + 1..];
2937 if after_eq.is_empty() {
2938 return Ok(None);
2939 }
2940
2941 let first = after_eq.as_bytes()[0];
2942 let (raw_value, total_consumed) = if first == b'"' || first == b'\'' {
2943 let value_start = eq_pos + 2;
2944 let Some(close) = remaining[value_start..].find(first as char) else {
2945 return Ok(None);
2946 };
2947 (
2948 &remaining[value_start..value_start + close],
2949 value_start + close + 1,
2950 )
2951 } else if first == b'{' {
2952 let value_start = eq_pos + 2;
2953 let Some(close) = remaining[value_start..].find('}') else {
2954 return Ok(None);
2955 };
2956 (
2957 &remaining[value_start..value_start + close],
2958 value_start + close + 1,
2959 )
2960 } else {
2961 return Ok(None);
2962 };
2963
2964 let inner = raw_value
2965 .trim()
2966 .strip_prefix('{')
2967 .and_then(|s| s.strip_suffix('}'))
2968 .unwrap_or(raw_value)
2969 .trim();
2970
2971 if inner.is_empty() {
2973 return Ok(None);
2974 }
2975 match parse_event_handler(inner) {
2976 EventHandler::Valid(handler_name, args) => {
2977 Ok(Some((event_name, handler_name, args, total_consumed)))
2978 }
2979 EventHandler::Empty => Ok(None),
2980 EventHandler::Invalid(_raw) => {
2981 Err(invalid_event_handler(component, None, &event_name, inner).into())
2982 }
2983 }
2984}
2985
2986fn parse_regular_tag(
2987 component: &str,
2988 input: &str,
2989 meta: &mut TemplateSectionMeta,
2990 repeat_key: Option<usize>,
2991) -> Result<Option<(String, usize)>> {
2992 if !input.starts_with('<') || input.starts_with("</") || input.starts_with("<!") {
2993 return Ok(None);
2994 }
2995
2996 let bytes = input.as_bytes();
2997 if bytes.len() < 2 || !bytes[1].is_ascii_alphabetic() {
2998 return Ok(None);
2999 }
3000
3001 let Some(end) = find_tag_close(input) else {
3002 return Ok(None);
3003 };
3004 let tag_content = &input[1..end];
3005 let tag_body = tag_content.trim_end();
3006 let self_closing = tag_body.ends_with('/');
3007 let tag_body = if self_closing {
3008 tag_body[..tag_body.len().saturating_sub(1)].trim_end()
3009 } else {
3010 tag_body
3011 };
3012
3013 let body_bytes = tag_body.as_bytes();
3014 let mut cursor = 0;
3015 while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
3016 cursor += 1;
3017 }
3018
3019 let tag_name = &tag_body[..cursor];
3020 if tag_name.is_empty() {
3021 return Ok(None);
3022 }
3023
3024 let mut out = String::with_capacity(tag_body.len() + 24);
3025 out.push('<');
3026 out.push_str(tag_name);
3027
3028 let binding_start = meta.attr_bindings.len();
3029 let event_start = meta.events.len();
3030 let mut binding_count = 0usize;
3031
3032 while cursor < body_bytes.len() {
3033 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
3034 cursor += 1;
3035 }
3036 if cursor >= body_bytes.len() {
3037 break;
3038 }
3039
3040 let attr_start = cursor;
3041 while cursor < body_bytes.len()
3042 && !body_bytes[cursor].is_ascii_whitespace()
3043 && body_bytes[cursor] != b'='
3044 {
3045 cursor += 1;
3046 }
3047 if attr_start == cursor {
3048 cursor += 1;
3049 continue;
3050 }
3051
3052 let name = &tag_body[attr_start..cursor];
3053
3054 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
3055 cursor += 1;
3056 }
3057
3058 let mut value: Option<&str> = None;
3059 if cursor < body_bytes.len() && body_bytes[cursor] == b'=' {
3060 cursor += 1;
3061 while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
3062 cursor += 1;
3063 }
3064
3065 if cursor < body_bytes.len()
3066 && (body_bytes[cursor] == b'"' || body_bytes[cursor] == b'\'')
3067 {
3068 let quote = body_bytes[cursor];
3069 cursor += 1;
3070 let value_start = cursor;
3071 while cursor < body_bytes.len() && body_bytes[cursor] != quote {
3072 cursor += 1;
3073 }
3074 value = Some(&tag_body[value_start..cursor]);
3075 if cursor < body_bytes.len() {
3076 cursor += 1;
3077 }
3078 } else {
3079 let value_start = cursor;
3080 while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
3081 cursor += 1;
3082 }
3083 value = Some(&tag_body[value_start..cursor]);
3084 }
3085 }
3086
3087 let raw_attr = tag_body[attr_start..cursor].trim();
3088 if raw_attr.is_empty() {
3089 continue;
3090 }
3091
3092 if name == "key" {
3093 if repeat_key == Some(attr_start + 1) {
3094 continue;
3095 }
3096 return Err(invalid_repeat_key_placement(component, tag_name).into());
3097 }
3098
3099 if let Some(event_name) = name.strip_prefix('@') {
3100 let Some(raw_value) = value else {
3101 continue;
3102 };
3103 match parse_event_handler(raw_value) {
3104 EventHandler::Valid(handler_name, args) => {
3105 meta.events
3106 .push((event_name.to_string(), handler_name, args));
3107 }
3108 EventHandler::Invalid(raw) => {
3109 return Err(
3110 invalid_event_handler(component, Some(tag_name), event_name, &raw).into(),
3111 );
3112 }
3113 EventHandler::Empty => {}
3114 }
3115 continue;
3116 }
3117
3118 if name == "w-ref" {
3119 if let Some(val) = value {
3122 if !val.starts_with('{') || !val.ends_with('}') {
3123 return Err(Diagnostic::error("invalid w-ref binding")
3124 .code(crate::diagnostic::codes::INVALID_W_REF)
3125 .component(component)
3126 .element(tag_name)
3127 .snippet(format!("w-ref=\"{val}\""))
3128 .help(format!(
3129 "use w-ref={{{val}}} with braces to bind to a component property"
3130 ))
3131 .into());
3132 }
3133 }
3134 out.push(' ');
3135 out.push_str(raw_attr);
3136 continue;
3137 }
3138
3139 if let Some(bool_name) = name.strip_prefix('?') {
3140 if let Some(raw_value) = value.and_then(extract_single_handlebars) {
3141 meta.attr_bindings.push(CompiledAttrBinding::Boolean {
3142 name: bool_name.to_string(),
3143 condition: compile_condition_expr(&raw_value),
3144 });
3145 binding_count += 1;
3146 }
3147 continue;
3148 }
3149
3150 if name.starts_with(':') {
3151 if let Some(raw_value) = value.and_then(extract_single_handlebars) {
3152 let prop_name = webui_protocol::attrs::attribute_to_camel(
3155 name.strip_prefix(':').unwrap_or(name),
3156 );
3157 meta.attr_bindings.push(CompiledAttrBinding::Complex {
3158 name: prop_name,
3159 value: raw_value,
3160 });
3161 binding_count += 1;
3162 }
3163 continue;
3164 }
3165
3166 if let Some(raw_value) = value {
3167 if let Some(parts) = parse_attr_parts(raw_value) {
3168 let is_simple =
3169 parts.len() == 1 && matches!(parts.first(), Some(CompiledAttrPart::Dynamic(_)));
3170 if is_simple {
3171 if let Some(CompiledAttrPart::Dynamic(path)) = parts.into_iter().next() {
3172 meta.attr_bindings.push(CompiledAttrBinding::Simple {
3173 name: name.to_string(),
3174 value: path,
3175 });
3176 }
3177 } else {
3178 meta.attr_bindings.push(CompiledAttrBinding::Template {
3179 name: name.to_string(),
3180 parts,
3181 });
3182 }
3183 binding_count += 1;
3184 continue;
3185 }
3186 }
3187
3188 out.push(' ');
3189 out.push_str(raw_attr);
3190 }
3191
3192 if binding_count == 1 {
3193 out.push_str(" data-w-b-");
3194 out.push_str(&binding_start.to_string());
3195 } else if binding_count > 1 {
3196 out.push_str(" data-w-c-");
3197 out.push_str(&binding_start.to_string());
3198 out.push('-');
3199 out.push_str(&binding_count.to_string());
3200 }
3201
3202 let event_count = meta.events.len().saturating_sub(event_start);
3203 if event_count > 0 {
3204 out.push_str(" data-ev=\"");
3205 let _ = write!(out, "{}", event_count);
3206 out.push('"');
3207 }
3208
3209 if self_closing {
3210 out.push_str(" />");
3211 } else {
3212 out.push('>');
3213 }
3214
3215 Ok(Some((out, end + 1)))
3216}
3217
3218enum EventHandler {
3219 Valid(String, Vec<EventArg>),
3220 Invalid(String),
3221 Empty,
3222}
3223
3224fn parse_event_handler(raw_value: &str) -> EventHandler {
3225 let inner = raw_value
3226 .trim()
3227 .strip_prefix('{')
3228 .and_then(|s| s.strip_suffix('}'))
3229 .unwrap_or(raw_value)
3230 .trim();
3231 if inner.is_empty() {
3232 return EventHandler::Empty;
3233 }
3234 match inner.find('(') {
3235 Some(paren) => {
3236 let close = match inner.rfind(')') {
3237 Some(close) if close > paren => close,
3238 _ => return EventHandler::Invalid(inner.to_string()),
3239 };
3240 if !inner[close + 1..].trim().is_empty() {
3241 return EventHandler::Invalid(inner.to_string());
3242 }
3243 let handler_name = inner[..paren].trim();
3244 if !is_valid_identifier(handler_name) {
3245 return EventHandler::Invalid(inner.to_string());
3246 }
3247 let Some(args) = parse_event_args(&inner[paren + 1..close]) else {
3248 return EventHandler::Invalid(inner.to_string());
3249 };
3250 EventHandler::Valid(handler_name.to_string(), args)
3251 }
3252 None => EventHandler::Invalid(inner.to_string()),
3253 }
3254}
3255
3256fn parse_event_args(raw_args: &str) -> Option<Vec<EventArg>> {
3257 if raw_args.trim().is_empty() {
3258 return Some(Vec::new());
3259 }
3260
3261 let raw_parts = split_event_args(raw_args)?;
3262 let mut args = Vec::with_capacity(raw_parts.len());
3263 for arg in raw_parts {
3264 let trimmed = arg.trim();
3265 if trimmed.is_empty() {
3266 return None;
3267 }
3268 args.push(parse_event_arg(trimmed)?);
3269 }
3270 Some(args)
3271}
3272
3273fn split_event_args(raw_args: &str) -> Option<Vec<&str>> {
3274 let mut args = Vec::new();
3275 let mut start = 0usize;
3276 let mut quote: Option<u8> = None;
3277 let mut escaped = false;
3278 for (idx, byte) in raw_args.bytes().enumerate() {
3279 if let Some(q) = quote {
3280 if escaped {
3281 escaped = false;
3282 continue;
3283 }
3284 if byte == b'\\' {
3285 escaped = true;
3286 continue;
3287 }
3288 if byte == q {
3289 quote = None;
3290 }
3291 continue;
3292 }
3293 if byte == b'"' || byte == b'\'' {
3294 quote = Some(byte);
3295 continue;
3296 }
3297 if byte == b',' {
3298 args.push(&raw_args[start..idx]);
3299 start = idx + 1;
3300 }
3301 }
3302 if quote.is_some() || escaped {
3303 return None;
3304 }
3305 args.push(&raw_args[start..]);
3306 Some(args)
3307}
3308
3309fn parse_event_arg(arg: &str) -> Option<EventArg> {
3310 if arg.is_empty() {
3311 return None;
3312 }
3313 if arg == "e" {
3314 return Some(EventArg::Event);
3315 }
3316 if arg == "true" {
3317 return Some(EventArg::Bool(true));
3318 }
3319 if arg == "false" {
3320 return Some(EventArg::Bool(false));
3321 }
3322 if arg == "null" {
3323 return Some(EventArg::Null);
3324 }
3325 if let Some(value) = parse_quoted_event_string(arg) {
3326 return Some(EventArg::String(value));
3327 }
3328 if is_quoted_event_arg_start(arg) {
3329 return None;
3330 }
3331 if is_number_literal(arg) {
3332 return Some(EventArg::Number(arg.to_string()));
3333 }
3334 is_valid_event_path(arg).then(|| EventArg::Path(arg.to_string()))
3335}
3336
3337fn parse_quoted_event_string(arg: &str) -> Option<String> {
3338 let bytes = arg.as_bytes();
3339 if bytes.len() < 2 {
3340 return None;
3341 }
3342 let quote = bytes[0];
3343 if (quote != b'"' && quote != b'\'') || bytes[bytes.len() - 1] != quote {
3344 return None;
3345 }
3346 Some(
3347 arg[1..arg.len() - 1]
3348 .replace("\\\"", "\"")
3349 .replace("\\'", "'"),
3350 )
3351}
3352
3353fn is_number_literal(arg: &str) -> bool {
3354 !arg.is_empty()
3355 && arg.parse::<f64>().is_ok()
3356 && arg
3357 .bytes()
3358 .all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+' | b'e' | b'E'))
3359}
3360
3361fn is_quoted_event_arg_start(arg: &str) -> bool {
3362 matches!(arg.as_bytes().first(), Some(b'"' | b'\''))
3363}
3364
3365fn is_valid_event_path(path: &str) -> bool {
3366 let mut parts = path.split('.');
3367 let Some(first) = parts.next() else {
3368 return false;
3369 };
3370 if !is_valid_identifier(first) {
3371 return false;
3372 }
3373 for part in parts {
3374 if part.is_empty() || (!is_valid_identifier(part) && !is_ascii_digits(part)) {
3375 return false;
3376 }
3377 }
3378 true
3379}
3380
3381fn is_valid_identifier(value: &str) -> bool {
3382 let mut bytes = value.bytes();
3383 let Some(first) = bytes.next() else {
3384 return false;
3385 };
3386 if !is_identifier_start(first) {
3387 return false;
3388 }
3389 bytes.all(is_identifier_continue)
3390}
3391
3392fn is_identifier_start(byte: u8) -> bool {
3393 byte.is_ascii_alphabetic() || byte == b'_' || byte == b'$'
3394}
3395
3396fn is_identifier_continue(byte: u8) -> bool {
3397 is_identifier_start(byte) || byte.is_ascii_digit()
3398}
3399
3400fn is_ascii_digits(value: &str) -> bool {
3401 !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
3402}
3403
3404fn extract_single_handlebars(value: &str) -> Option<String> {
3405 let trimmed = value.trim();
3406 if let Some(inner) = trimmed
3407 .strip_prefix("{{{")
3408 .and_then(|s| s.strip_suffix("}}}"))
3409 {
3410 return Some(inner.trim().to_string());
3411 }
3412
3413 if let Some(inner) = trimmed
3414 .strip_prefix("{{")
3415 .and_then(|s| s.strip_suffix("}}"))
3416 {
3417 return Some(inner.trim().to_string());
3418 }
3419
3420 None
3421}
3422
3423fn parse_attr_parts(value: &str) -> Option<Vec<CompiledAttrPart>> {
3424 let bytes = value.as_bytes();
3425 let len = bytes.len();
3426 let mut i = 0;
3427 let mut last = 0;
3428 let mut dynamic_found = false;
3429 let mut parts = Vec::new();
3430
3431 while i < len {
3432 if i + 2 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
3433 let brace_count = if i + 2 < len && bytes[i + 2] == b'{' {
3434 3
3435 } else {
3436 2
3437 };
3438
3439 if let Some(end) = find_brace_end(value, i + brace_count, brace_count) {
3440 if last < i {
3441 parts.push(CompiledAttrPart::Static(value[last..i].to_string()));
3442 }
3443 parts.push(CompiledAttrPart::Dynamic(
3444 value[i + brace_count..end].trim().to_string(),
3445 ));
3446 dynamic_found = true;
3447 i = end + brace_count;
3448 last = i;
3449 continue;
3450 }
3451 }
3452
3453 i += 1;
3454 }
3455
3456 if !dynamic_found {
3457 return None;
3458 }
3459
3460 if last < len {
3461 parts.push(CompiledAttrPart::Static(value[last..].to_string()));
3462 }
3463
3464 Some(parts)
3465}
3466
3467fn extract_root_events(component: &str, html: &str) -> Result<Vec<EventBinding>> {
3472 let (html, _) = leading_content(html);
3473 let Some(root) = parse_tag(html) else {
3474 return Ok(Vec::new());
3475 };
3476 if root.closing || !root.name.eq_ignore_ascii_case("template") {
3477 return Ok(Vec::new());
3478 }
3479 let tag = &html[..root.close];
3480 let mut events = Vec::new();
3481 let bytes = tag.as_bytes();
3482 let len = bytes.len();
3483 let mut i = 0;
3484
3485 while i < len {
3486 if bytes[i] == b'@' {
3487 if let Some((name, handler, args, consumed)) = parse_event_attr(component, tag, i)? {
3488 events.push((name, handler, args));
3489 i += consumed;
3490 continue;
3491 }
3492 }
3493 i += 1;
3494 }
3495
3496 Ok(events)
3497}
3498
3499#[cfg(test)]
3502mod whitespace_tests;
3503
3504#[cfg(test)]
3505mod tests {
3506 use super::*;
3507
3508 #[test]
3509 fn boolean_literal_conditions_have_no_state_dependencies() {
3510 for value in ["true", "false"] {
3511 let condition = ConditionExpr::identifier(value);
3512 let mut javascript = String::new();
3513 emit_js_condition_expr(&condition, &mut javascript);
3514 assert_eq!(javascript, value);
3515 let mut paths = Vec::new();
3516 collect_condition_paths(&condition, &mut paths);
3517 assert!(paths.is_empty());
3518 }
3519 }
3520
3521 #[test]
3522 fn component_metadata_erases_boundary_directive_tags() {
3523 let source = concat!(
3524 "<template shadowrootmode=\"open\">",
3525 "<boundary name=\"ready\" key=\"item.id\"><span>{{label}}</span></boundary>",
3526 "</template>",
3527 );
3528 let stripped = WebUIParserPlugin::strip_boundary_directive_tags(source);
3529 assert_eq!(
3530 stripped,
3531 "<template shadowrootmode=\"open\"><span>{{label}}</span></template>"
3532 );
3533 let metadata = generate_compiled_template("my-card", &stripped);
3534 assert!(!metadata.contains("<boundary"));
3535 assert!(!metadata.contains("</boundary>"));
3536 }
3537
3538 #[test]
3539 fn boundary_directive_tags_preserve_style_content_strings() {
3540 let source = r#"<style>.card::before { content: "<boundary name='css'>literal</boundary>"; }</style>"#;
3541 let stripped = WebUIParserPlugin::strip_boundary_directive_tags(source);
3542
3543 assert!(matches!(&stripped, Cow::Borrowed(_)));
3544 assert_eq!(stripped, source);
3545 }
3546
3547 #[test]
3548 fn boundary_directive_tags_preserve_script_strings() {
3549 let source =
3550 r#"<script>const marker = '<boundary name="script">literal</boundary>';</script>"#;
3551 let stripped = WebUIParserPlugin::strip_boundary_directive_tags(source);
3552
3553 assert!(matches!(&stripped, Cow::Borrowed(_)));
3554 assert_eq!(stripped, source);
3555 }
3556
3557 #[test]
3558 fn boundary_directive_tags_preserve_quoted_attribute_values() {
3559 let source = r#"<div data-double="<boundary name='double' data-end='>'></boundary>" data-single='<boundary name="single" data-end=">"></boundary>'></div>"#;
3560 let stripped = WebUIParserPlugin::strip_boundary_directive_tags(source);
3561
3562 assert!(matches!(&stripped, Cow::Borrowed(_)));
3563 assert_eq!(stripped, source);
3564 }
3565
3566 #[test]
3567 fn boundary_directive_tags_preserve_parser_raw_text_and_rcdata() {
3568 let source = concat!(
3569 "<textarea><boundary name=\"textarea\">literal</boundary></textarea>",
3570 "<title><boundary name=\"title\">literal</boundary></title>",
3571 "<xmp><boundary name=\"xmp\">literal</boundary></xmp>",
3572 "<iframe><boundary name=\"iframe\">literal</boundary></iframe>",
3573 "<noembed><boundary name=\"noembed\">literal</boundary></noembed>",
3574 "<noframes><boundary name=\"noframes\">literal</boundary></noframes>",
3575 "<noscript><boundary name=\"noscript\">literal</boundary></noscript>",
3576 "<plaintext><boundary name=\"plaintext\">literal</boundary></plaintext>",
3577 );
3578 let stripped = WebUIParserPlugin::strip_boundary_directive_tags(source);
3579
3580 assert!(matches!(&stripped, Cow::Borrowed(_)));
3581 assert_eq!(stripped, source);
3582 }
3583
3584 #[test]
3585 fn boundary_directive_tags_preserve_comments_and_near_matches() {
3586 let source = concat!(
3587 "<!-- <boundary name=\"comment\">literal</boundary> -->",
3588 "<p><boundary> is text</p>",
3589 "<boundary-ish>near match</boundary-ish>",
3590 "<Boundary>case-sensitive near match</Boundary>",
3591 "< boundary>spaced near match</ boundary>",
3592 "<boundary",
3593 );
3594 let stripped = WebUIParserPlugin::strip_boundary_directive_tags(source);
3595
3596 assert!(matches!(&stripped, Cow::Borrowed(_)));
3597 assert_eq!(stripped, source);
3598 }
3599
3600 #[test]
3601 fn component_metadata_boundary_directive_tags_erase_real_wrapper_only() {
3602 let source = concat!(
3603 "<template shadowrootmode=\"open\">",
3604 "<boundary name=\"ready\">",
3605 r#"<style>.x::before { content: "<boundary>style</boundary>"; }</style>"#,
3606 r#"<script>const marker = '<boundary>script</boundary>';</script>"#,
3607 r#"<div data-marker="<boundary>attribute</boundary>">child</div>"#,
3608 "</boundary>",
3609 "</template>",
3610 );
3611 let expected = concat!(
3612 "<template shadowrootmode=\"open\">",
3613 r#"<style>.x::before { content: "<boundary>style</boundary>"; }</style>"#,
3614 r#"<script>const marker = '<boundary>script</boundary>';</script>"#,
3615 r#"<div data-marker="<boundary>attribute</boundary>">child</div>"#,
3616 "</template>",
3617 );
3618 let stripped = WebUIParserPlugin::strip_boundary_directive_tags(source);
3619
3620 assert_eq!(stripped, expected);
3621 let metadata = generate_compiled_template("my-card", &stripped);
3622 assert_eq!(metadata.matches("<boundary>").count(), 3);
3623 assert_eq!(metadata.matches("</boundary>").count(), 3);
3624 assert!(!metadata.contains("ready"));
3625 }
3626
3627 #[allow(clippy::disallowed_methods)]
3631 fn generate_compiled_template(tag_name: &str, html_content: &str) -> String {
3632 super::generate_compiled_template(tag_name, html_content).expect("valid template compiles")
3633 }
3634
3635 #[allow(clippy::disallowed_methods)]
3636 fn generate_compiled_template_payload(
3637 tag_name: &str,
3638 html_content: &str,
3639 ) -> CompiledTemplatePayload {
3640 super::generate_compiled_template_with_root_source(
3641 tag_name,
3642 html_content,
3643 html_content,
3644 false,
3645 false,
3646 )
3647 .expect("valid template compiles")
3648 }
3649
3650 fn test_component(
3652 tag_name: &str,
3653 html_content: &str,
3654 css_content: Option<&str>,
3655 is_client_owned: bool,
3656 ) -> Component {
3657 Component {
3658 tag_name: tag_name.to_string(),
3659 html_content: html_content.to_string(),
3660 css_content: css_content.map(str::to_string),
3661 css_definitions: Vec::new(),
3662 css_fallback_chains: Vec::new(),
3663 is_client_owned,
3664 }
3665 }
3666
3667 #[test]
3668 fn component_render_policy_is_encoded_in_template_metadata() {
3669 let mut plugin = WebUIParserPlugin::new();
3670 let component = test_component(
3671 "lazy-card",
3672 r#"<template w-render="lazy" w-reserve-block-size="18rem"><p>hi</p></template>"#,
3673 None,
3674 true,
3675 );
3676 plugin
3677 .register_component_template(
3678 "lazy-card",
3679 &component,
3680 r#"<template shadowrootmode="open"><p>hi</p></template>"#,
3681 )
3682 .expect("component registration");
3683
3684 let templates = plugin
3685 .take_component_templates()
3686 .expect("template compilation");
3687 assert_eq!(templates.len(), 1);
3688 assert!(templates[0].template_json.contains(r#","wp":2"#));
3689 assert!(!templates[0].template_json.contains("w-render"));
3690 assert!(!templates[0].template_json.contains("w-reserve-block-size"));
3691 }
3692
3693 #[test]
3694 fn interaction_policy_is_encoded_in_metadata_and_artifact() {
3695 let mut plugin = WebUIParserPlugin::new();
3696 let component = test_component(
3697 "interaction-shell",
3698 r#"<template w-hydrate="interaction"><button>Open</button></template>"#,
3699 None,
3700 true,
3701 );
3702 plugin
3703 .register_component_template("interaction-shell", &component, "<button>Open</button>")
3704 .expect("component registration");
3705
3706 let templates = plugin
3707 .take_component_templates()
3708 .expect("template compilation");
3709 assert!(templates[0].template_json.contains(r#","wp":3"#));
3710 assert!(!templates[0].template_json.contains("w-hydrate"));
3711 }
3712
3713 #[test]
3714 fn combined_render_interaction_policy_is_encoded_once() {
3715 let mut plugin = WebUIParserPlugin::new();
3716 let component = test_component(
3717 "interaction-panel",
3718 concat!(
3719 r#"<template w-render="lazy" w-reserve-block-size="18rem" "#,
3720 r#"w-hydrate="interaction"><button>Open</button></template>"#,
3721 ),
3722 None,
3723 true,
3724 );
3725 plugin
3726 .register_component_template("interaction-panel", &component, "<button>Open</button>")
3727 .expect("component registration");
3728
3729 let templates = plugin
3730 .take_component_templates()
3731 .expect("template compilation");
3732 assert!(templates[0].template_json.contains(r#","wp":4"#));
3733 assert!(!templates[0].template_json.contains("w-render"));
3734 assert!(!templates[0].template_json.contains("w-hydrate"));
3735 }
3736
3737 fn assert_no_client_markers(result: &str) {
3738 assert!(!result.contains("<!--t:"), "text markers should be removed");
3739 assert!(
3740 !result.contains("<!--c:"),
3741 "conditional markers should be removed"
3742 );
3743 assert!(
3744 !result.contains("<!--r:"),
3745 "repeat markers should be removed"
3746 );
3747 assert!(
3748 !result.contains("data-w-b-"),
3749 "attribute binding markers should be removed"
3750 );
3751 assert!(
3752 !result.contains("data-w-c-"),
3753 "attribute binding range markers should be removed"
3754 );
3755 assert!(
3756 !result.contains("data-ev="),
3757 "event markers should be removed"
3758 );
3759 }
3760
3761 #[test]
3762 fn test_skip_event_attributes() {
3763 let mut plugin = WebUIParserPlugin::new();
3764 assert_eq!(plugin.classify_attribute("@click"), AttributeAction::Skip);
3765 assert_eq!(plugin.classify_attribute("@keydown"), AttributeAction::Skip);
3766 assert_eq!(plugin.classify_attribute("key"), AttributeAction::Skip);
3767 assert_eq!(plugin.classify_attribute("data-key"), AttributeAction::Keep);
3768 assert_eq!(plugin.classify_attribute("w-ref"), AttributeAction::Keep);
3769 assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
3770 }
3771
3772 #[test]
3773 fn test_metadata_has_text_bindings() {
3774 let result = generate_compiled_template("my-comp", "<h1>{{title}}</h1>");
3775 assert_no_client_markers(&result);
3776 assert!(result.contains(r#""h":"<h1></h1>""#));
3777 assert!(result.contains("\"title\""));
3778 assert!(result.contains(r#","tx":[[[1,0],[["title"]]]]"#));
3779 assert!(!result.contains("{{"));
3780 }
3781
3782 #[test]
3783 fn test_metadata_emits_template_roots_and_observed_attrs() {
3784 let result = generate_compiled_template(
3785 "my-comp",
3786 r#"<h1>{{displayValue}}</h1><input ?readonly="{{readOnly}}" />"#,
3787 );
3788
3789 assert!(result.contains(r#","tr":["displayValue","readOnly"]"#));
3790 assert!(result.contains(r#","ta":["display-value","readonly"]"#));
3791 }
3792
3793 #[test]
3794 fn test_metadata_excludes_repeat_scope_roots() {
3795 let result = generate_compiled_template(
3796 "my-comp",
3797 r#"<for each="item in items"><p>{{item.title}} {{heading}}</p></for>"#,
3798 );
3799 assert!(result.contains(r#","tr":["items","heading"]"#));
3800 }
3801
3802 #[test]
3803 fn test_metadata_strips_html_comments_without_processing_bindings() {
3804 let result = generate_compiled_template(
3805 "my-comp",
3806 r#"<!-- {{title}} @click="{bad()}" --><div>hello</div>"#,
3807 );
3808
3809 assert_no_client_markers(&result);
3810 assert!(result.contains(r#""h":"<div>hello</div>""#));
3811 assert!(!result.contains("title"));
3812 assert!(!result.contains("@click"));
3813 assert!(!result.contains("-->"));
3814 }
3815
3816 #[test]
3817 fn test_metadata_strips_style_comments_without_processing_bindings() {
3818 let result = generate_compiled_template(
3819 "my-comp",
3820 r#"<style>/* prose {{ignored}} */ .x { color: {{color}}; } /*! @license {{kept}} */</style>"#,
3821 );
3822
3823 assert_no_client_markers(&result);
3824 assert!(!result.contains("ignored"));
3825 assert!(result.contains(r#"["color"]"#));
3826 assert!(!result.contains(r#"[["kept"]]"#));
3827 assert!(result.contains("/*! @license {{kept}} */"));
3828 }
3829
3830 #[test]
3831 fn test_metadata_processes_style_signal_comments() {
3832 let result = generate_compiled_template(
3833 "my-comp",
3834 r#"<style>:root{/*{{{tokens.light}}}*/}</style>"#,
3835 );
3836
3837 assert_no_client_markers(&result);
3838 assert!(
3839 result.contains(r#","tx":[[[1,0],[":root{",["tokens.light"],"}"]]]"#),
3840 "raw CSS stays an ordinary text binding: {result}"
3841 );
3842 assert!(!result.contains("/*"));
3843 assert!(!result.contains("*/"));
3844 }
3845
3846 #[test]
3847 fn test_metadata_keeps_legal_style_comment_with_html_like_tag_as_raw_text() {
3848 let result = generate_compiled_template(
3849 "my-component",
3850 r#"<style>:host { display: block; }/*! @license The <my-component> element. */.container { padding: 16px; }</style><div>hello</div>"#,
3851 );
3852
3853 assert_no_client_markers(&result);
3854 assert!(result.contains("@license The <my-component> element."));
3855 assert!(!result.contains("</my-component>"));
3856 assert!(result.contains("</style><div>hello</div>"));
3857 }
3858
3859 #[test]
3860 fn test_metadata_keeps_marker_like_text_in_legal_style_comment_literal() {
3861 let result = generate_compiled_template(
3862 "my-component",
3863 r#"<p>{{title}}</p><style>/*! @license <!--t:0--> */.x { color: red; }</style>"#,
3864 );
3865
3866 assert!(result.contains("<!--t:0-->"));
3867 assert!(result.contains(
3868 r#""h":"<p></p><style>/*! @license <!--t:0--> */.x { color: red; }</style>""#
3869 ));
3870 assert!(result.contains(r#","tx":[[[1,0],[["title"]]]]"#));
3871 assert!(!result.contains(r#"[[[1],0],[["title"]]]"#));
3872 }
3873
3874 #[test]
3875 fn test_metadata_keeps_marker_like_style_text_between_real_signal_markers() {
3876 let result = generate_compiled_template(
3877 "my-component",
3878 r#"<style>/*{{first}}*/📚/*! @license <!--t:0--> <my-component> *//*{{{second}}}*/</style><div>done</div>"#,
3879 );
3880
3881 assert!(result.contains(r#"["first"]"#));
3882 assert!(result.contains(r#"["second"]"#));
3883 assert!(result.contains("📚/*! @license <!--t:0--> <my-component> */"));
3884 assert!(!result.contains("</my-component>"));
3885 assert!(result.contains("</style><div>done</div>"));
3886 }
3887
3888 #[test]
3889 fn test_metadata_strips_style_line_comments_without_processing_bindings() {
3890 let result = generate_compiled_template(
3891 "my-comp",
3892 "<style>// {{ignored}}\n.x { color: {{color}}; }\n//! @license {{kept}}</style>",
3893 );
3894
3895 assert_no_client_markers(&result);
3896 assert!(!result.contains("ignored"));
3897 assert!(result.contains(r#"["color"]"#));
3898 assert!(!result.contains(r#"[["kept"]]"#));
3899 assert!(result.contains("//! @license {{kept}}"));
3900 }
3901
3902 #[test]
3903 fn test_metadata_has_conditionals() {
3904 let payload = generate_compiled_template_payload(
3905 "my-comp",
3906 r#"<if condition="state == 'done'"><span>yes</span></if>"#,
3907 );
3908 let result = payload.template_json;
3909 assert_no_client_markers(&result);
3910 assert!(result.contains(r#""c":[[[0,["state"]],0,[0,0]]]"#));
3911 assert!(payload
3912 .template_functions
3913 .contains(r#"function(v,s){return v("state",s)=="done"}"#));
3914 assert!(result.contains("<span>yes</span>"));
3915 assert!(!result.contains("<if"));
3916 }
3917
3918 #[test]
3919 fn test_metadata_has_conditionals_with_gt_in_condition_attr() {
3920 let result = generate_compiled_template(
3921 "my-comp",
3922 r#"<if condition="count > 0"><span>yes</span></if>"#,
3923 );
3924 assert_no_client_markers(&result);
3925 assert!(
3926 result.contains(r#","c":["#),
3927 "conditional metadata expected"
3928 );
3929 assert!(result.contains("<span>yes</span>"));
3930 assert!(!result.contains("<if"));
3931 }
3932
3933 #[test]
3934 fn test_parse_if_block_ignores_close_marker_inside_attr_value() {
3935 let input = r#"<if condition="count > 0"><div data-note="</if>">yes</div></if>"#;
3936 let (_, body, consumed) = parse_if_block(input.into()).expect("if block should parse");
3937
3938 assert_eq!(body.html(), r#"<div data-note="</if>">yes</div>"#);
3939 assert_eq!(consumed, input.len());
3940 }
3941
3942 #[test]
3943 fn test_metadata_has_for_loops() {
3944 let result = generate_compiled_template(
3945 "my-comp",
3946 r#"<for each="item in items"><p>{{item.name}}</p></for>"#,
3947 );
3948 assert_no_client_markers(&result);
3949 assert!(result.contains("\"items\""));
3950 assert!(result.contains("\"item\""));
3951 assert!(result.contains(r#""r":[["items","item",0,[0,0]]]"#));
3952 assert!(!result.contains("<for"));
3953 }
3954
3955 #[test]
3956 fn test_metadata_emits_first_child_key_path() {
3957 let result = generate_compiled_template(
3958 "my-comp",
3959 r#"<for each="item in items"><p class="row" key="{{item.id}}">{{item.name}}</p></for>"#,
3960 );
3961
3962 assert!(result.contains(r#""r":[["items","item",0,[0,0],"id"]]"#));
3963 assert!(!result.contains("key="));
3964 assert!(!result.contains(r#""a":[["key","#));
3965 }
3966
3967 #[test]
3968 fn test_metadata_emits_key_from_first_element_inside_if() {
3969 let result = generate_compiled_template(
3970 "my-comp",
3971 r#"<for each="item in items"><if condition="item.visible"><span key="{{item.id}}">{{item.name}}</span></if></for>"#,
3972 );
3973
3974 assert!(result.contains(r#""r":[["items","item",0,[0,0],"id"]]"#));
3975 assert!(!result.contains("key="));
3976 }
3977
3978 #[test]
3979 fn test_metadata_rejects_key_on_for_directive() {
3980 let err = super::generate_compiled_template(
3981 "my-comp",
3982 r#"<for each="item in items" key="{{item.id}}"><span>{{item.name}}</span></for>"#,
3983 )
3984 .expect_err("key on <for> must fail compilation");
3985
3986 let crate::ParserError::Template(diag) = err else {
3987 panic!("expected template diagnostic");
3988 };
3989 assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_KEY));
3990 }
3991
3992 #[test]
3993 fn test_metadata_rejects_key_on_repeat_child_directive() {
3994 let templates = [
3995 r#"<for each="item in items"><if key="{{item.id}}" condition="item.visible"><span>{{item.name}}</span></if></for>"#,
3996 r#"<for each="item in items"><for key="{{item.id}}" each="child in item.children"><span>{{child}}</span></for></for>"#,
3997 r#"<for each="item in items"><outlet key="{{item.id}}" /></for>"#,
3998 ];
3999
4000 for template in templates {
4001 let err = super::generate_compiled_template("my-comp", template)
4002 .expect_err("key on a directive must fail compilation");
4003 let crate::ParserError::Template(diag) = err else {
4004 panic!("expected template diagnostic");
4005 };
4006 assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_KEY));
4007 }
4008 }
4009
4010 #[test]
4011 fn test_metadata_leaves_nested_for_key_with_nested_repeat() {
4012 let result = generate_compiled_template(
4013 "my-comp",
4014 r#"<for each="group in groups"><for each="item in group.items"><span key="{{item.id}}">{{item.name}}</span></for></for>"#,
4015 );
4016
4017 assert!(result.contains(r#""r":[["groups","group",0,[0,0]]]"#));
4018 assert!(result.contains(r#""r":[["group.items","item",1,[0,0],"id"]]"#));
4019 }
4020
4021 #[test]
4022 fn test_metadata_scopes_key_through_nested_for_if_for_chain() {
4023 let result = generate_compiled_template(
4024 "my-comp",
4025 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>"#,
4026 );
4027
4028 assert!(result.contains(r#"["groups","group",0,[0,0]]"#));
4029 assert!(result.contains(r#"["group.sections","section",1,[0,0]]"#));
4030 assert!(result.contains(r#"["section.items","item",3,[0,0],"id"]"#));
4031 assert!(!result.contains("key="));
4032 }
4033
4034 #[test]
4035 fn test_metadata_treats_data_key_as_ordinary_attribute() {
4036 let result = generate_compiled_template(
4037 "my-comp",
4038 r#"<for each="item in items"><p data-key="{{item.id}}">{{item.name}}</p></for>"#,
4039 );
4040
4041 assert!(result.contains(r#""r":[["items","item",0,[0,0]]]"#));
4042 assert!(result.contains(r#""a":[["data-key",0,"item.id"]]"#));
4043 }
4044
4045 #[test]
4046 fn test_metadata_emits_empty_path_for_primitive_child_key() {
4047 let result = generate_compiled_template(
4048 "my-comp",
4049 r#"<for each="item in items"><p key="{{item}}">{{item}}</p></for>"#,
4050 );
4051
4052 assert!(result.contains(r#""r":[["items","item",0,[0,0],""]]"#));
4053 }
4054
4055 #[test]
4056 fn test_metadata_rejects_invalid_first_child_key_path() {
4057 let err = super::generate_compiled_template(
4058 "my-comp",
4059 r#"<for each="item in items"><p key="{{item[0]}}">{{item}}</p></for>"#,
4060 )
4061 .expect_err("invalid explicit key must fail compilation");
4062
4063 let crate::ParserError::Template(diag) = err else {
4064 panic!("expected template diagnostic");
4065 };
4066 assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_KEY));
4067 }
4068
4069 #[test]
4070 fn test_metadata_keeps_data_key_alongside_structural_key() {
4071 let result = generate_compiled_template(
4072 "my-comp",
4073 r#"<for each="item in items"><p key="{{item.id}}" data-key="{{item.label}}">{{item}}</p></for>"#,
4074 );
4075
4076 assert!(result.contains(r#""r":[["items","item",0,[0,0],"id"]]"#));
4077 assert!(result.contains(r#""a":[["data-key",0,"item.label"]]"#));
4078 }
4079
4080 #[test]
4081 fn test_metadata_rejects_key_on_later_repeat_child() {
4082 let err = super::generate_compiled_template(
4083 "my-comp",
4084 r#"<for each="item in items"><span>{{item.name}}</span><p key="{{item.id}}">{{item.name}}</p></for>"#,
4085 )
4086 .expect_err("key on a later child must fail compilation");
4087
4088 let crate::ParserError::Template(diag) = err else {
4089 panic!("expected template diagnostic");
4090 };
4091 assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_KEY));
4092 }
4093
4094 #[test]
4095 fn test_metadata_rejects_key_outside_repeat() {
4096 let err =
4097 super::generate_compiled_template("my-comp", r#"<p key="{{row.id}}">{{row.name}}</p>"#)
4098 .expect_err("key outside a repeat must fail compilation");
4099
4100 let crate::ParserError::Template(diag) = err else {
4101 panic!("expected template diagnostic");
4102 };
4103 assert_eq!(diag.error_code(), Some(codes::INVALID_FOR_KEY));
4104 assert_eq!(
4105 diag.help_text(),
4106 Some(
4107 "place the key attribute on the first concrete element rendered by <for>, not on <if>, <for>, or <outlet>"
4108 )
4109 );
4110 }
4111
4112 #[test]
4113 fn test_metadata_finds_key_after_leading_comment() {
4114 let result = generate_compiled_template(
4115 "my-comp",
4116 r#"<for each="item in items"><!-- row --><p key="{{item.id}}">{{item.name}}</p></for>"#,
4117 );
4118
4119 assert!(result.contains(r#""r":[["items","item",0,[0,0],"id"]]"#));
4120 assert!(!result.contains("key="));
4121 }
4122
4123 #[test]
4124 fn test_parse_for_block_ignores_open_marker_inside_attr_value() {
4125 let input =
4126 r#"<for each="item in items"><div data-note="<for fake>">{{item.name}}</div></for>"#;
4127 let parsed = parse_for_block("my-comp", input.into())
4128 .expect("for block should be valid")
4129 .expect("for block should parse");
4130
4131 assert_eq!(parsed.collection, "items");
4132 assert_eq!(parsed.item_var, "item");
4133 assert_eq!(
4134 parsed.body.html(),
4135 r#"<div data-note="<for fake>">{{item.name}}</div>"#
4136 );
4137 assert_eq!(parsed.consumed, input.len());
4138 }
4139
4140 #[test]
4141 fn test_metadata_has_for_loops_with_gt_in_other_attr() {
4142 let result = generate_compiled_template(
4143 "my-comp",
4144 r#"<for each="item in items" data-note="a > b"><p>{{item.name}}</p></for>"#,
4145 );
4146 assert_no_client_markers(&result);
4147 assert!(result.contains("\"items\""));
4148 assert!(result.contains("\"item\""));
4149 assert!(!result.contains("<for"));
4150 assert!(!result.contains("{{item.name}}"));
4151 }
4152
4153 #[test]
4154 fn test_metadata_has_events() {
4155 let result =
4156 generate_compiled_template("my-comp", r#"<button @click="{onClick()}">Go</button>"#);
4157 assert_no_client_markers(&result);
4158 assert!(result.contains("\"click\""));
4159 assert!(result.contains("\"onClick\""));
4160 assert!(!result.contains("@click"));
4161 }
4162
4163 #[test]
4164 fn test_metadata_has_events_unquoted() {
4165 let result =
4166 generate_compiled_template("my-comp", r#"<button @click={onClick()}>Go</button>"#);
4167 assert_no_client_markers(&result);
4168 assert!(result.contains("\"click\""));
4169 assert!(result.contains("\"onClick\""));
4170 }
4171
4172 #[test]
4173 fn test_metadata_has_events_with_arg() {
4174 let result = generate_compiled_template("my-comp", r#"<input @keydown="{onKey(e)}" />"#);
4175 assert_no_client_markers(&result);
4176 assert!(result.contains("\"keydown\""));
4177 assert!(result.contains("\"onKey\""));
4178 assert!(result.contains(r#"["e"]"#)); }
4180
4181 #[test]
4182 fn test_metadata_has_event_path_args() {
4183 let result = generate_compiled_template(
4184 "my-comp",
4185 r#"<button @click="{selectItem(item.id, e, 'ok', 7, true, null)}">Go</button>"#,
4186 );
4187 assert_no_client_markers(&result);
4188 assert!(result.contains(r#"["p","item.id"]"#));
4189 assert!(result.contains(r#"["e"]"#));
4190 assert!(result.contains(r#"["s","ok"]"#));
4191 assert!(result.contains(r#"["n",7]"#));
4192 assert!(result.contains(r#"["b",1]"#));
4193 assert!(result.contains(r#"["z"]"#));
4194 }
4195
4196 #[test]
4197 fn test_metadata_has_simple_attr_bindings() {
4198 let result = generate_compiled_template("my-comp", r#"<input title="{{title}}" />"#);
4199 assert_no_client_markers(&result);
4200 assert!(result.contains(r#","a":["#));
4201 assert!(result.contains(r#","ag":[[1,0,1]]"#));
4202 assert!(result.contains(r#"["title",0,"title"]"#));
4203 assert!(!result.contains(r#"title=\"<!--t:"#));
4204 }
4205
4206 #[test]
4207 fn test_metadata_has_template_attr_bindings() {
4208 let result = generate_compiled_template(
4209 "my-comp",
4210 r#"<a href="/product/{{handle}}" class="card {{variant}}">Go</a>"#,
4211 );
4212 assert_no_client_markers(&result);
4213 assert!(result.contains(r#","a":["#));
4214 assert!(result.contains(r#","ag":[[1,0,2]]"#));
4215 assert!(result.contains(r#"["href",3,["/product/",["handle"]]]"#));
4216 assert!(result.contains(r#"["class",3,["card ",["variant"]]]"#));
4217 assert!(!result.contains(r#"/product/<!--t:"#));
4218 }
4219
4220 #[test]
4221 fn test_metadata_has_boolean_attr_bindings() {
4222 let payload = generate_compiled_template_payload(
4223 "my-comp",
4224 r#"<a ?data-active="{{page == 'dashboard'}}">Go</a>"#,
4225 );
4226 let result = payload.template_json;
4227 assert_no_client_markers(&result);
4228 assert!(result.contains(r#","a":["#));
4229 assert!(result.contains(r#","ag":[[1,0,1]]"#));
4230 assert!(result.contains(r#"["data-active",2,[0,["page"]]]"#));
4231 assert!(payload
4232 .template_functions
4233 .contains(r#"function(v,s){return v("page",s)=="dashboard"}"#));
4234 }
4235
4236 #[test]
4237 fn test_metadata_has_complex_attr_bindings() {
4238 let result = generate_compiled_template(
4239 "my-comp",
4240 r#"<child-view :config="{{settings}}"></child-view>"#,
4241 );
4242 assert_no_client_markers(&result);
4243 assert!(result.contains(r#","a":["#));
4244 assert!(result.contains(r#","ag":[[1,0,1]]"#));
4245 assert!(result.contains(r#""config",1,"settings""#));
4246 }
4247
4248 #[test]
4249 fn test_w_ref_stays_in_html() {
4250 let result = generate_compiled_template("my-comp", r#"<input w-ref="{myInput}" />"#);
4251 assert!(result.contains("w-ref"));
4253 assert!(result.contains("myInput"));
4254 }
4255
4256 #[test]
4257 fn test_w_ref_unquoted_stays_in_html() {
4258 let result = generate_compiled_template("my-comp", r#"<span w-ref={myLabel}>0</span>"#);
4259 assert!(result.contains("w-ref"));
4260 assert!(result.contains("myLabel"));
4261 }
4262
4263 #[test]
4264 fn test_w_ref_without_braces_is_fatal() {
4265 let err =
4266 super::generate_compiled_template("mail-inbox-page", r#"<input w-ref="myInput" />"#)
4267 .expect_err("non-braced w-ref must fail the build");
4268 assert!(err.to_string().contains("invalid w-ref binding"));
4269 }
4270
4271 #[test]
4272 fn test_w_ref_error_names_component_and_element() {
4273 let err =
4274 super::generate_compiled_template("mail-inbox-page", r#"<input w-ref="myInput" />"#)
4275 .expect_err("non-braced w-ref must fail the build");
4276 assert!(err
4277 .to_string()
4278 .contains("component <mail-inbox-page> · element <input>"));
4279 }
4280
4281 #[test]
4282 fn test_metadata_has_root_events() {
4283 let result = generate_compiled_template(
4284 "my-comp",
4285 r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>hi</div></template>"#,
4286 );
4287 assert!(result.contains(r#","re":["#));
4288 assert!(result.contains("\"click\""));
4289 assert!(result.contains("\"onClick\""));
4290 }
4291
4292 #[test]
4293 fn test_strips_shadowrootmode() {
4294 let result = generate_compiled_template(
4295 "my-comp",
4296 r#"<template shadowrootmode="open"><p>{{title}}</p></template>"#,
4297 );
4298 assert!(!result.contains("shadowrootmode"));
4299 assert_no_client_markers(&result);
4300 assert!(result.contains(r#","tx":[[[1,0],[["title"]]]]"#));
4301 }
4302
4303 #[test]
4304 fn test_preserves_ordinary_template_in_light_client_html() {
4305 let result = generate_compiled_template(
4306 "my-comp",
4307 r#"<template data-kind="ordinary"><p>content</p></template>"#,
4308 );
4309 assert!(
4310 result.contains(r#""h":"<template data-kind=\"ordinary\"><p>content</p></template>""#)
4311 );
4312 assert!(!result.contains(r#","sd":1"#));
4313 }
4314
4315 #[test]
4316 fn test_shadow_wrapper_matching_is_ascii_case_insensitive() {
4317 let result = generate_compiled_template(
4318 "my-comp",
4319 r#"<TEMPLATE SHADOWROOTMODE="OPEN"><p>content</p></TEMPLATE>"#,
4320 );
4321 assert!(result.contains(r#""h":"<p>content</p>""#));
4322 assert!(!result.contains("SHADOWROOTMODE"));
4323 }
4324
4325 #[test]
4326 fn test_root_template_allows_gt_in_quoted_attr_values() {
4327 let result = generate_compiled_template(
4328 "my-comp",
4329 r#"<template shadowrootmode="open" data-note="a > b" @click="{onClick()}"><p>hi</p></template>"#,
4330 );
4331 assert_no_client_markers(&result);
4332 assert!(result.contains(r#""h":"<p>hi</p>""#));
4333 assert!(result.contains(r#","re":[["click","onClick",[]]]"#));
4334 }
4335
4336 #[test]
4337 fn test_outlet_forms_compile_identically_without_lifting_following_blocks() {
4338 let self_closing = generate_compiled_template(
4339 "my-comp",
4340 r#"<div class="shell-content"><main><outlet /></main><if condition="showNestedAfter"><span>nested</span></if></div><if condition="showRootAfter"><span>root</span></if>"#,
4341 );
4342 let paired = generate_compiled_template(
4343 "my-comp",
4344 r#"<div class="shell-content"><main><outlet></outlet></main><if condition="showNestedAfter"><span>nested</span></if></div><if condition="showRootAfter"><span>root</span></if>"#,
4345 );
4346
4347 assert_eq!(paired, self_closing);
4348 assert!(paired.contains(r#"<main><outlet></outlet></main></div>"#));
4349 assert!(paired.contains(
4350 r#""c":[[[0,["showNestedAfter"]],0,[1,1]],[[1,["showRootAfter"]],1,[0,1]]]"#,
4351 ));
4352 }
4353
4354 #[test]
4355 fn test_uppercase_void_element_does_not_own_following_slots() {
4356 let result = generate_compiled_template(
4357 "my-comp",
4358 r#"<div><INPUT><if condition="nested"><span>nested</span></if></div><if condition="root"><span>root</span></if>"#,
4359 );
4360
4361 assert!(result.contains(r#""h":"<div><INPUT /></div>""#), "{result}");
4362 assert!(
4363 result.contains(r#""c":[[[0,["nested"]],0,[1,1]],[[1,["root"]],1,[0,1]]]"#,),
4364 "{result}"
4365 );
4366 }
4367
4368 #[test]
4369 fn test_emoji_safe() {
4370 let result = generate_compiled_template("my-comp", r#"<span>📚 {{title}}</span>"#);
4371 assert!(result.contains("📚"));
4372 assert_no_client_markers(&result);
4373 assert!(result.contains(r#","tx":[[[1,0],["📚 ",["title"]]]]"#));
4374 }
4375
4376 #[test]
4377 fn test_boolean_attr_in_for() {
4378 let payload = generate_compiled_template_payload(
4379 "my-comp",
4380 r#"<for each="s in sections"><a ?active="{{s.id == sectionId}}">{{s.name}}</a></for>"#,
4381 );
4382 let result = payload.template_json;
4383 assert_no_client_markers(&result);
4384 assert!(result.contains(r#"["active",2,[0,["s.id","sectionId"]]]"#));
4385 assert!(payload
4386 .template_functions
4387 .contains(r#"function(v,s){return v("s.id",s)==v("sectionId",s)}"#));
4388 assert!(!result.contains("?active"));
4389 }
4390
4391 #[test]
4392 fn test_authored_component_defaults_to_unknown_surface_and_records_template_roots() {
4393 let mut plugin = WebUIParserPlugin::new();
4400 let comp = test_component("test-el", "<p>{{serverTitle}}</p>", None, true);
4401 plugin
4402 .register_component_template("test-el", &comp, &comp.html_content)
4403 .unwrap();
4404 let templates = plugin.take_component_templates().unwrap();
4405 assert_eq!(templates[0].hydration, StateSurface::All);
4406 assert_eq!(templates[0].navigation, StateSurface::All);
4407 assert_eq!(templates[0].template_roots, vec!["serverTitle".to_string()]);
4408 assert!(templates[0].is_scripted);
4409 }
4410
4411 #[test]
4412 fn test_opaque_authored_component_defaults_to_unknown_surface() {
4413 let mut plugin = WebUIParserPlugin::new();
4414 let comp = test_component("test-el", "<p>{{serverTitle}}</p>", None, true);
4415
4416 plugin
4417 .register_component_template("test-el", &comp, &comp.html_content)
4418 .unwrap();
4419 let templates = plugin.take_component_templates().unwrap();
4420 assert_eq!(templates[0].hydration, StateSurface::All);
4424 assert_eq!(templates[0].navigation, StateSurface::All);
4425 assert_eq!(templates[0].template_roots, vec!["serverTitle".to_string()]);
4426 assert!(templates[0].is_scripted);
4427 assert!(!templates[0].template_json.contains(r#","th":1"#));
4428 }
4429
4430 #[test]
4431 fn test_deduplicates_components() {
4432 let mut plugin = WebUIParserPlugin::new();
4433 let comp = test_component("test-el", "<p>hi</p>", None, true);
4434 plugin
4435 .register_component_template("test-el", &comp, &comp.html_content)
4436 .unwrap();
4437 plugin
4438 .register_component_template("test-el", &comp, &comp.html_content)
4439 .unwrap();
4440 assert_eq!(plugin.take_component_templates().unwrap().len(), 1);
4441 }
4442
4443 #[test]
4444 fn test_scriptless_component_emits_dormant_navigation_template() {
4445 let mut plugin = WebUIParserPlugin::new();
4446 let comp = test_component("test-el", "<p>{{name}}</p>", None, false);
4447
4448 plugin
4449 .register_component_template("test-el", &comp, &comp.html_content)
4450 .unwrap();
4451 let templates = plugin.take_component_templates().unwrap();
4452 assert_eq!(templates.len(), 1);
4453 assert_eq!(templates[0].hydration, StateSurface::None);
4454 assert_eq!(
4455 templates[0].navigation,
4456 StateSurface::Keys(vec!["name".into()])
4457 );
4458 assert!(templates[0].template_json.contains(r#","th":1"#));
4459 }
4460
4461 #[test]
4462 fn test_fully_static_scriptless_component_still_emits_host_metadata() {
4463 let mut plugin = WebUIParserPlugin::new();
4464 let comp = test_component("test-el", "<p>Static</p>", None, false);
4465
4466 plugin
4467 .register_component_template("test-el", &comp, &comp.html_content)
4468 .unwrap();
4469 let templates = plugin.take_component_templates().unwrap();
4470 assert_eq!(templates.len(), 1);
4471 assert_eq!(templates[0].hydration, StateSurface::None);
4472 assert_eq!(templates[0].navigation, StateSurface::None);
4473 assert!(templates[0].template_json.contains(r#","th":1"#));
4474 }
4475
4476 #[test]
4477 fn test_scripted_component_emits_client_template_without_static_host_marker() {
4478 let mut plugin = WebUIParserPlugin::new();
4479 let comp = test_component("test-el", "<p>{{name}}</p>", None, true);
4480
4481 plugin
4482 .register_component_template("test-el", &comp, &comp.html_content)
4483 .unwrap();
4484 let templates = plugin.take_component_templates().unwrap();
4485 assert_eq!(templates.len(), 1);
4486 assert_eq!(templates[0].hydration, StateSurface::All);
4488 assert_eq!(templates[0].navigation, StateSurface::All);
4489 assert!(!templates[0].template_json.contains(r#","th":1"#));
4490 }
4491
4492 #[test]
4493 fn test_scriptless_event_component_template_fails() {
4494 let mut plugin = WebUIParserPlugin::new();
4495 let comp = test_component(
4496 "test-el",
4497 r#"<button @click="{onClick()}">{{name}}</button>"#,
4498 None,
4499 false,
4500 );
4501
4502 plugin
4503 .register_component_template("test-el", &comp, &comp.html_content)
4504 .unwrap();
4505 let err = plugin
4506 .take_component_templates()
4507 .expect_err("scriptless event templates should fail");
4508 let message = err.to_string();
4509 assert!(
4510 message.contains("scriptless-event-handler"),
4511 "msg: {message}"
4512 );
4513 }
4514
4515 #[test]
4516 fn test_compiled_template_preserves_link_node_in_static_html() {
4517 let result = generate_compiled_template(
4518 "my-comp",
4519 r#"<template shadowrootmode="open"><link rel="stylesheet" href="my-comp.css"><p>hi</p></template>"#,
4520 );
4521 assert!(result.contains(r#"rel=\"stylesheet\""#));
4522 assert!(result.contains(r#"href=\"my-comp.css\""#));
4523 assert!(result.contains(r#"<p>hi</p>"#));
4524 assert!(!result.contains(",css:"));
4525 }
4526
4527 #[test]
4528 fn test_compiled_template_preserves_inline_style_in_static_html() {
4529 let result = generate_compiled_template(
4530 "my-comp",
4531 r#"<template shadowrootmode="open"><style>.root{color:red}</style><p>hi</p></template>"#,
4532 );
4533 assert!(result.contains(r#""h":"<style>.root{color:red}</style><p>hi</p>""#));
4534 }
4535
4536 #[test]
4537 fn test_plugin_uses_processed_link_template_html() {
4538 let mut plugin = WebUIParserPlugin::new();
4539
4540 let comp = test_component("test-el", "<p>hi</p>", Some(".root { color: red; }"), true);
4541
4542 plugin
4543 .register_component_template(
4544 "test-el",
4545 &comp,
4546 r#"<template shadowrootmode="open"><link rel="stylesheet" href="test-el.css"><p>hi</p></template>"#,
4547 )
4548 .unwrap();
4549 let templates = plugin.take_component_templates().unwrap();
4550 assert_eq!(templates.len(), 1);
4551 assert!(templates[0].template_json.contains(r#"rel=\"stylesheet\""#));
4552 assert!(templates[0]
4553 .template_json
4554 .contains(r#"href=\"test-el.css\""#));
4555 assert!(templates[0].template_json.contains(r#"<p>hi</p>"#));
4556 }
4557
4558 #[test]
4559 fn test_plugin_preserves_root_events_from_raw_template_source() {
4560 let mut plugin = WebUIParserPlugin::new();
4561
4562 let comp = test_component(
4563 "test-el",
4564 concat!(
4565 "<!-- Copyright (C) Corporation. All rights reserved. -->\n",
4566 r#"<template shadowrootmode="open" @click="{onClick(e)}"><p>hi</p></template>"#,
4567 ),
4568 Some(".root { color: red; }"),
4569 true,
4570 );
4571
4572 plugin
4573 .register_component_template(
4574 "test-el",
4575 &comp,
4576 r#"<template shadowrootmode="open"><link rel="stylesheet" href="test-el.css"><p>hi</p></template>"#,
4577 )
4578 .unwrap();
4579 let templates = plugin.take_component_templates().unwrap();
4580 assert_eq!(templates.len(), 1);
4581 assert!(templates[0]
4582 .template_json
4583 .contains(r#","re":[["click","onClick",[["e"]]]]"#));
4584 }
4585
4586 #[test]
4587 fn module_template_does_not_emit_stylesheet_specifier_metadata() {
4588 let mut plugin = WebUIParserPlugin::new();
4589
4590 let comp = test_component("test-el", "<p>hi</p>", Some(".root { color: red; }"), true);
4591
4592 plugin
4593 .register_component_template(
4594 "test-el",
4595 &comp,
4596 r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="test-el"><p>hi</p></template>"#,
4597 )
4598 .unwrap();
4599 let templates = plugin.take_component_templates().unwrap();
4600 assert_eq!(templates.len(), 1);
4601 assert!(!templates[0].template_json.contains(r#""sa":"#));
4602 }
4603
4604 #[test]
4605 fn test_nested_if_inside_for_compiles_nested_blocks() {
4606 let result = generate_compiled_template(
4607 "my-comp",
4608 r#"<for each="item in items"><if condition="item.active"><span>{{item.name}}</span></if></for>"#,
4609 );
4610
4611 assert_no_client_markers(&result);
4612 assert!(
4613 result.contains(r#","b":["#),
4614 "compiled block table expected"
4615 );
4616 assert!(
4617 result.contains(r#"["items","item",0,[0,0]]"#),
4618 "repeat block index expected"
4619 );
4620 assert!(
4621 result.contains(r#"[[0,["item.active"]],1,[0,0]]"#),
4622 "nested conditional block index expected"
4623 );
4624 assert!(!result.contains("<if"), "nested if should be compiled");
4625 assert!(!result.contains("{{"), "nested bindings should be compiled");
4626 }
4627
4628 #[test]
4629 fn test_for_inside_if_compiles_nested_blocks() {
4630 let result = generate_compiled_template(
4631 "my-comp",
4632 r#"<if condition="showList"><for each="item in items"><p>{{item.name}}</p></for></if>"#,
4633 );
4634
4635 assert_no_client_markers(&result);
4636 assert!(
4637 result.contains(r#","b":["#),
4638 "compiled block table expected"
4639 );
4640 assert!(
4641 result.contains(r#"[[0,["showList"]],0,[0,0]]"#),
4642 "conditional block index expected"
4643 );
4644 assert!(
4645 result.contains(r#"["items","item",1,[0,0]]"#),
4646 "nested repeat block index expected"
4647 );
4648 assert!(!result.contains("<for"), "nested for should be compiled");
4649 assert!(!result.contains("{{"), "nested bindings should be compiled");
4650 }
4651
4652 #[test]
4653 fn test_nested_for_inside_for_compiles_nested_blocks() {
4654 let result = generate_compiled_template(
4655 "my-comp",
4656 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>"#,
4657 );
4658
4659 assert_no_client_markers(&result);
4660 assert!(
4661 result.contains(r#","b":["#),
4662 "compiled block table expected"
4663 );
4664 assert!(
4665 result.contains(r#"["groups","group",0,[0,0]]"#),
4666 "outer repeat block index expected"
4667 );
4668 assert!(
4669 result.contains(r#"["group.items","item",1,[1,0]]"#),
4670 "inner repeat block index expected"
4671 );
4672 assert!(!result.contains("<for"), "nested for should be compiled");
4673 assert!(!result.contains("{{"), "nested bindings should be compiled");
4674 assert!(
4675 !result.contains("?disabled"),
4676 "nested boolean attrs should be compiled"
4677 );
4678 }
4679
4680 #[test]
4681 fn test_multiple_events_on_same_element() {
4682 let result = generate_compiled_template(
4683 "my-comp",
4684 r#"<input @keydown="{onKey(e)}" @focus="{onFocus()}" />"#,
4685 );
4686 assert_no_client_markers(&result);
4687 assert!(result.contains("\"keydown\""), "keydown event expected");
4688 assert!(result.contains("\"onKey\""), "onKey handler expected");
4689 assert!(result.contains("\"focus\""), "focus event expected");
4690 assert!(result.contains("\"onFocus\""), "onFocus handler expected");
4691 }
4692
4693 #[test]
4694 fn test_regular_tag_uses_single_event_marker_per_element() {
4695 let mut meta = TemplateSectionMeta::default();
4696 let (tag_html, _) = parse_regular_tag(
4697 "test-input",
4698 r#"<input @keydown="{onKey(e)}" @focus="{onFocus()}" />"#,
4699 &mut meta,
4700 None,
4701 )
4702 .expect("regular tag parse should succeed")
4703 .expect("regular tag should produce output");
4704
4705 assert_eq!(tag_html, r#"<input data-ev="2" />"#);
4706 }
4707
4708 #[test]
4709 fn test_bare_table_row_gains_the_implied_tbody() {
4710 let result = generate_compiled_template(
4713 "my-comp",
4714 r#"<table><tr><td>{{cell}}</td></tr></table><button @click="{go()}"></button>"#,
4715 );
4716 assert!(
4717 result.contains(r#"<table><tbody><tr><td></td></tr></tbody></table>"#),
4718 "implied tbody expected: {result}"
4719 );
4720 assert!(result.contains(r#""tx":[[[4,0],[["cell"]]]]"#), "{result}");
4722 assert!(
4723 result.contains(r#""eg":[["click",[["go",[],5]]]]"#),
4724 "{result}"
4725 );
4726 }
4727
4728 #[test]
4729 fn test_implied_tbody_keeps_structural_slots_and_trailing_whitespace() {
4730 let result = generate_compiled_template(
4731 "my-comp",
4732 "<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>",
4733 );
4734
4735 assert!(
4736 result.contains(
4737 r#""h":"<table><tbody><tr><td>a</td></tr><tr><td></td></tr> </tbody></table><button></button>""#,
4738 ),
4739 "{result}"
4740 );
4741 assert!(result.contains(r#""tx":[[[6,0],[["cell"]]]]"#), "{result}");
4743 assert!(
4744 result.contains(r#""c":[[[0,["show"]],0,[2,1]]]"#),
4745 "{result}"
4746 );
4747 assert!(
4748 result.contains(r#""eg":[["click",[["go",[],7]]]]"#),
4749 "{result}"
4750 );
4751 }
4752
4753 #[test]
4754 fn test_bare_table_columns_gain_the_implied_colgroup() {
4755 let result = generate_compiled_template(
4756 "my-comp",
4757 r#"<table><col><col><tr><td>{{cell}}</td></tr></table><button @click="{go()}"></button>"#,
4758 );
4759
4760 assert!(
4761 result.contains(
4762 r#""h":"<table><colgroup><col /><col /></colgroup><tbody><tr><td></td></tr></tbody></table><button></button>""#,
4763 ),
4764 "{result}"
4765 );
4766 assert!(result.contains(r#""tx":[[[7,0],[["cell"]]]]"#), "{result}");
4768 assert!(
4769 result.contains(r#""eg":[["click",[["go",[],8]]]]"#),
4770 "{result}"
4771 );
4772 }
4773
4774 #[test]
4775 fn test_implied_colgroup_does_not_claim_following_row_repeat() {
4776 let result = generate_compiled_template(
4777 "my-comp",
4778 r#"<table><col><for each="row in rows"><tr><td>{{row.value}}</td></tr></for></table>"#,
4779 );
4780
4781 assert!(
4782 result.contains(r#""h":"<table><colgroup><col /></colgroup></table>""#),
4783 "{result}"
4784 );
4785 assert!(
4786 result.contains(r#""r":[["rows","row",0,[1,1]]]"#),
4787 "{result}"
4788 );
4789 }
4790
4791 #[test]
4792 fn test_table_run_trivia_uses_html_whitespace() {
4793 assert!(is_table_run_trivia(
4794 &FragmentNode::Text("\t\n\u{000C}\r ".to_string()),
4795 false
4796 ));
4797 assert!(!is_table_run_trivia(
4798 &FragmentNode::Text("\u{00A0}".to_string()),
4799 true
4800 ));
4801 }
4802
4803 #[test]
4804 fn test_authored_tbody_is_not_duplicated() {
4805 let result = generate_compiled_template(
4806 "my-comp",
4807 r#"<table><tbody><tr><td>{{cell}}</td></tr></tbody></table><button @click="{go()}"></button>"#,
4808 );
4809 assert!(
4810 !result.contains("<tbody><tbody>"),
4811 "no double wrap: {result}"
4812 );
4813 assert!(
4814 result.contains(r#""eg":[["click",[["go",[],5]]]]"#),
4815 "{result}"
4816 );
4817 }
4818
4819 #[test]
4820 fn test_table_sections_keep_their_own_rows() {
4821 let result = generate_compiled_template(
4822 "my-comp",
4823 r#"<table><thead><tr><th>h</th></tr></thead><tr><td>{{cell}}</td></tr></table><button @click="{go()}"></button>"#,
4824 );
4825 assert!(
4827 result
4828 .contains(r#"<thead><tr><th>h</th></tr></thead><tbody><tr><td></td></tr></tbody>"#),
4829 "{result}"
4830 );
4831 assert!(
4833 result.contains(r#""eg":[["click",[["go",[],8]]]]"#),
4834 "{result}"
4835 );
4836 }
4837
4838 #[test]
4839 fn test_template_content_is_not_numbered() {
4840 let result = generate_compiled_template(
4843 "my-comp",
4844 r#"<div><template><span></span></template></div><button @click="{go()}"></button>"#,
4845 );
4846 assert!(
4847 result.contains(r#"<div><template><span></span></template></div>"#),
4848 "template markup preserved: {result}"
4849 );
4850 assert!(
4852 result.contains(r#""eg":[["click",[["go",[],3]]]]"#),
4853 "{result}"
4854 );
4855 }
4856
4857 #[test]
4858 fn test_empty_template() {
4859 let result = generate_compiled_template("my-comp", "");
4860 assert!(result.contains(r#""h":"""#), "empty html expected");
4861 assert!(!result.contains(r#","tx":"#), "no text bindings");
4863 assert!(!result.contains(r#","c":"#), "no conditionals");
4864 assert!(!result.contains(r#","r":"#), "no repeats");
4865 assert!(!result.contains(r#","eg":"#), "no events");
4866 assert!(!result.contains(r#","re":"#), "no root events");
4867 }
4868
4869 #[test]
4870 fn test_whitespace_only_template() {
4871 let result = generate_compiled_template("my-comp", " \n ");
4872 assert!(
4873 result.contains(r#""h":"""#),
4874 "whitespace-only should produce empty html"
4875 );
4876 }
4877
4878 #[test]
4879 fn test_multiple_text_bindings() {
4880 let result = generate_compiled_template(
4881 "my-comp",
4882 r#"<p>{{first}}</p><p>{{second}}</p><p>{{third}}</p>"#,
4883 );
4884 assert_no_client_markers(&result);
4885 assert!(result
4886 .contains(r#","tx":[[[1,0],[["first"]]],[[2,0],[["second"]]],[[3,0],[["third"]]]]"#));
4887 assert!(result.contains("\"first\""));
4888 assert!(result.contains("\"second\""));
4889 assert!(result.contains("\"third\""));
4890 }
4891
4892 #[test]
4893 fn test_multiple_conditionals() {
4894 let result = generate_compiled_template(
4895 "my-comp",
4896 r#"<if condition="a"><span>A</span></if><if condition="b"><span>B</span></if>"#,
4897 );
4898 assert_no_client_markers(&result);
4899 }
4900
4901 #[test]
4902 fn test_locator_paths_merge_adjacent_static_text_nodes() {
4903 let result = generate_compiled_template(
4904 "mp-product-grid",
4905 r#"
4906<template shadowrootmode="open">
4907 <if condition="products.length == 0 && query">
4908 <p class="empty-results">There are no products that match "<strong>{{query}}</strong>"</p>
4909 </if>
4910 <div class="grid">
4911 <for each="product in products">
4912 <mp-product-card
4913 handle="{{product.handle}}"
4914 title="{{product.title}}"
4915 price="{{product.price}}"
4916 gradient="{{product.gradient}}"
4917 image-url="{{product.imageUrl}}"
4918 image-loading="lazy"
4919 variant="grid"
4920 ></mp-product-card>
4921 </for>
4922 </div>
4923</template>
4924"#,
4925 );
4926
4927 assert_no_client_markers(&result);
4928 }
4929
4930 #[test]
4931 fn test_mixed_bindings_events_refs() {
4932 let result = generate_compiled_template(
4933 "my-comp",
4934 r#"<button w-ref="{myBtn}" @click="{onClick()}">{{label}}</button>"#,
4935 );
4936 assert_no_client_markers(&result);
4938 assert!(result.contains("w-ref"));
4939 assert!(result.contains("myBtn"));
4940 assert!(result.contains("\"click\""));
4942 assert!(result.contains("\"onClick\""));
4943 assert!(result.contains(r#","tx":[[[1,0],[["label"]]]]"#));
4945 assert!(result.contains("\"label\""));
4946 }
4947
4948 #[test]
4949 fn test_event_without_e_arg() {
4950 let result =
4951 generate_compiled_template("my-comp", r#"<button @click="{onClick()}">Go</button>"#);
4952 assert!(
4953 result.contains(r#"["click",[["onClick",[],1]]]"#),
4954 "empty argument list should be preserved"
4955 );
4956 }
4957
4958 #[test]
4959 fn test_duplicate_event_groups_preserve_first_seen_order() {
4960 let result = generate_compiled_template(
4961 "my-comp",
4962 r#"<button @click="{onA()}"></button><a @mouseover="{onHover()}"></a><button @click="{onB(e)}"></button>"#,
4963 );
4964
4965 let click_pos = result.find(r#"["click","#).unwrap();
4966 let hover_pos = result.find(r#"["mouseover","#).unwrap();
4967 assert!(
4968 click_pos < hover_pos,
4969 "event groups should preserve first-seen order: {result}"
4970 );
4971 assert!(
4972 result.contains(r#"["click",[["onA",[],1],["onB",[["e"]],3,1]]]"#),
4973 "duplicate click handlers should share one group: {result}"
4974 );
4975 }
4976
4977 #[test]
4978 fn test_root_event_without_e_arg() {
4979 let result = generate_compiled_template(
4980 "my-comp",
4981 r#"<template shadowrootmode="open" @submit="{onSubmit()}"><form>hi</form></template>"#,
4982 );
4983 assert!(result.contains(r#","re":["#));
4984 assert!(result.contains("\"submit\""));
4985 assert!(result.contains("\"onSubmit\""));
4986 assert!(
4987 result.contains(r#"["submit","onSubmit",[]]"#),
4988 "empty root argument list should be preserved"
4989 );
4990 }
4991
4992 #[test]
4993 fn test_triple_brace_raw_binding() {
4994 let result = generate_compiled_template("my-comp", r#"<div>{{{rawHtml}}}</div>"#);
4995 assert_no_client_markers(&result);
4996 assert!(
4997 result.contains(r#","tx":[[[1,0],[["rawHtml"]],1]]"#),
4998 "triple brace produces text locator with raw flag: {result}"
4999 );
5000 assert!(
5001 result.contains("\"rawHtml\""),
5002 "rawHtml path in text bindings"
5003 );
5004 }
5005
5006 #[test]
5007 fn raw_binding_is_split_from_adjacent_text_and_escaped_binding() {
5008 let result =
5009 generate_compiled_template("my-comp", r#"<div>Before {{{html}}} After {{name}}</div>"#);
5010
5011 assert!(
5012 result.contains(
5013 r#","tx":[[[1,0],["Before "]],[[1,0,1],[["html"]],1],[[1,0,2],[" After ",["name"]]]]"#,
5014 ),
5015 "raw HTML must own only its signal while adjacent text remains separate: {result}"
5016 );
5017 }
5018
5019 #[test]
5020 fn adjacent_raw_bindings_compile_to_distinct_ranges() {
5021 let result = generate_compiled_template("my-comp", r#"<div>{{{first}}}{{{second}}}</div>"#);
5022
5023 assert!(
5024 result.contains(r#","tx":[[[1,0],[["first"]],1],[[1,0,1],[["second"]],1]]"#,),
5025 "each raw binding needs one metadata entry and one marker range: {result}"
5026 );
5027 }
5028
5029 #[test]
5030 fn test_for_with_complex_body() {
5031 let result = generate_compiled_template(
5032 "my-comp",
5033 r#"<for each="user in users"><div class="card"><h2>{{user.name}}</h2><p>{{user.bio}}</p></div></for>"#,
5034 );
5035 assert_no_client_markers(&result);
5036 assert!(result.contains("\"users\""), "collection path");
5037 assert!(result.contains("\"user\""), "item variable");
5038 assert!(
5039 result.contains(r#","b":["#),
5040 "compiled block table expected"
5041 );
5042 assert!(
5043 !result.contains("{{user.name}}"),
5044 "bindings should be compiled"
5045 );
5046 assert!(
5047 !result.contains("{{user.bio}}"),
5048 "bindings should be compiled"
5049 );
5050 }
5051
5052 #[test]
5053 fn test_mixed_text_uses_text_runs() {
5054 let result = generate_compiled_template("my-comp", r#"<p>Hello {{name}}!</p>"#);
5055
5056 assert_no_client_markers(&result);
5057 assert!(result.contains(r#""h":"<p></p>""#));
5058 assert!(result.contains(r#","tx":[[[1,0],["Hello ",["name"],"!"]]]"#));
5059 }
5060
5061 #[test]
5062 fn test_js_string_escaping() {
5063 let payload = generate_compiled_template_payload(
5064 "my-comp",
5065 r#"<if condition="name == "test""><span>ok</span></if>"#,
5066 );
5067 assert!(
5068 payload.template_json.contains(r#","c":["#),
5069 "conditional array present"
5070 );
5071 assert!(
5073 payload
5074 .template_functions
5075 .contains(r#"function(v,s){return v("name",s)==v(""test"",s)}"#),
5076 "condition function with escaped quotes should be present: {}",
5077 payload.template_functions
5078 );
5079 assert!(
5080 !payload.template_json.contains("function(v,s)"),
5081 "metadata JSON should not contain executable functions"
5082 );
5083 }
5084
5085 #[test]
5086 fn test_js_string_escapes_script_breakout_sequences() {
5087 let result = generate_compiled_template(
5088 "my-comp",
5089 r#"<div title="</script><script>alert(1)</script>"></div>"#,
5090 );
5091
5092 assert!(
5093 result.contains(r#"\u003C/script><script>alert(1)\u003C/script>"#),
5094 "script-closing sequences should be neutralized inside metadata strings: {result}"
5095 );
5096 assert!(
5097 !result.contains(r#"title=\"</script><script>alert(1)</script>\""#),
5098 "raw </script> must not appear inside serialized metadata strings: {result}"
5099 );
5100 }
5101
5102 #[test]
5103 fn test_js_string_escapes_line_separator_codepoints() {
5104 let html = format!("<p>before{}middle{}after</p>", '\u{2028}', '\u{2029}');
5105 let result = generate_compiled_template("my-comp", &html);
5106
5107 assert!(
5108 result.contains(r#"\u2028"#),
5109 "U+2028 should be escaped in metadata strings: {result}"
5110 );
5111 assert!(
5112 result.contains(r#"\u2029"#),
5113 "U+2029 should be escaped in metadata strings: {result}"
5114 );
5115 }
5116
5117 #[test]
5118 fn test_on_element_parsed_encodes_12_bytes() {
5119 let mut plugin = WebUIParserPlugin::new();
5120 let _ = plugin.classify_attribute("@click");
5122 let _ = plugin.classify_attribute("@keydown");
5123 let data = plugin.finish_element(3);
5125 assert!(data.is_some());
5126 let bytes = data.unwrap();
5127 assert_eq!(bytes.len(), 12, "plugin data should be 12 bytes");
5128 let binding_count = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
5129 let event_start = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
5130 let event_count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
5131 assert_eq!(binding_count, 3);
5132 assert_eq!(event_start, 0);
5133 assert_eq!(event_count, 2);
5134 }
5135
5136 #[test]
5137 fn test_on_element_parsed_no_data_when_empty() {
5138 let mut plugin = WebUIParserPlugin::new();
5139 let data = plugin.finish_element(0);
5140 assert!(
5141 data.is_none(),
5142 "no plugin data when no bindings and no events"
5143 );
5144 }
5145
5146 #[test]
5147 fn test_on_element_parsed_events_only() {
5148 let mut plugin = WebUIParserPlugin::new();
5149 let _ = plugin.classify_attribute("@click");
5150 let data = plugin.finish_element(0);
5151 assert!(
5152 data.is_some(),
5153 "should emit data for events even with 0 bindings"
5154 );
5155 let bytes = data.unwrap();
5156 let binding_count = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
5157 let event_count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
5158 assert_eq!(binding_count, 0);
5159 assert_eq!(event_count, 1);
5160 }
5161
5162 #[test]
5163 fn test_event_index_increments_across_elements() {
5164 let mut plugin = WebUIParserPlugin::new();
5165 let _ = plugin.classify_attribute("@click");
5167 let _ = plugin.classify_attribute("@focus");
5168 let data1 = plugin.finish_element(0).unwrap();
5169 let ev_start_1 = u32::from_le_bytes([data1[4], data1[5], data1[6], data1[7]]);
5170 assert_eq!(ev_start_1, 0, "first element starts at event 0");
5171
5172 let _ = plugin.classify_attribute("@blur");
5174 let data2 = plugin.finish_element(0).unwrap();
5175 let ev_start_2 = u32::from_le_bytes([data2[4], data2[5], data2[6], data2[7]]);
5176 assert_eq!(
5177 ev_start_2, 2,
5178 "second element starts at event 2 (after first element's 2 events)"
5179 );
5180 }
5181
5182 #[test]
5183 fn test_text_run_decodes_html_entities_in_static_parts() {
5184 let result =
5190 generate_compiled_template("my-comp", "<nav>{{sectionName}} > {{topicName}}</nav>");
5191 assert_no_client_markers(&result);
5192 assert!(
5194 result.contains(r#"" > ""#),
5195 "static text run part should contain decoded '>' not '>': {}",
5196 result
5197 );
5198 assert!(
5199 !result.contains(r#"" > ""#),
5200 "static text run part should not contain raw '>': {}",
5201 result
5202 );
5203 }
5204
5205 #[test]
5206 fn test_invalid_event_handler_error_names_component_and_element() {
5207 let err = super::generate_compiled_template(
5208 "mail-inbox-page",
5209 r#"<button @pointerdown="e.preventDefault()">x</button>"#,
5210 )
5211 .expect_err("invalid @event handler must fail the build");
5212 assert!(err
5213 .to_string()
5214 .contains("component <mail-inbox-page> · element <button>"));
5215 }
5216
5217 #[test]
5218 fn test_invalid_root_event_handler_error_names_component() {
5219 let err = super::generate_compiled_template(
5220 "my-card",
5221 r#"<template shadowrootmode="open" @click="e.stopPropagation()"><slot></slot></template>"#,
5222 )
5223 .expect_err("invalid root @event handler must fail the build");
5224 assert!(err.to_string().contains("in component <my-card>"));
5225 }
5226
5227 #[test]
5228 fn test_parse_event_handler_bare_name_is_invalid() {
5229 assert!(matches!(
5230 parse_event_handler("{closeMenu}"),
5231 EventHandler::Invalid(ref name) if name == "closeMenu"
5232 ));
5233 }
5234
5235 #[test]
5236 fn test_parse_event_attr_bare_name_errors() {
5237 let input = r#"<button @click="{closeMenu}">Click</button>"#;
5238 let err = parse_event_attr("test-button", input, 8)
5239 .expect_err("bare handler name must be rejected");
5240 assert!(err.to_string().contains("invalid @click handler"));
5241 }
5242
5243 #[test]
5244 fn test_parse_event_handler_with_parens() {
5245 assert!(matches!(
5246 parse_event_handler("{onClick()}"),
5247 EventHandler::Valid(ref name, ref args) if name == "onClick" && args.is_empty()
5248 ));
5249 }
5250
5251 #[test]
5252 fn test_parse_event_handler_with_event_arg() {
5253 assert!(matches!(
5254 parse_event_handler("{onClick(e)}"),
5255 EventHandler::Valid(ref name, ref args) if name == "onClick" && args == &vec![EventArg::Event]
5256 ));
5257 }
5258
5259 #[test]
5260 fn test_parse_event_handler_with_mixed_args() {
5261 assert!(matches!(
5262 parse_event_handler("{onClick(item.id, e, 'ok', 7, false, null)}"),
5263 EventHandler::Valid(ref name, ref args)
5264 if name == "onClick"
5265 && args == &vec![
5266 EventArg::Path("item.id".to_string()),
5267 EventArg::Event,
5268 EventArg::String("ok".to_string()),
5269 EventArg::Number("7".to_string()),
5270 EventArg::Bool(false),
5271 EventArg::Null,
5272 ]
5273 ));
5274 }
5275
5276 #[test]
5277 fn test_parse_event_handler_rejects_trailing_tokens() {
5278 assert!(matches!(
5279 parse_event_handler("{onClick(e)} trailing"),
5280 EventHandler::Invalid(ref raw) if raw == "{onClick(e)} trailing"
5281 ));
5282 assert!(matches!(
5283 parse_event_handler("{onClick(e) trailing}"),
5284 EventHandler::Invalid(ref raw) if raw == "onClick(e) trailing"
5285 ));
5286 }
5287
5288 #[test]
5289 fn test_parse_event_handler_rejects_empty_args() {
5290 assert!(matches!(
5291 parse_event_handler("{onClick(, e)}"),
5292 EventHandler::Invalid(ref raw) if raw == "onClick(, e)"
5293 ));
5294 assert!(matches!(
5295 parse_event_handler("{onClick(e,)}"),
5296 EventHandler::Invalid(ref raw) if raw == "onClick(e,)"
5297 ));
5298 }
5299
5300 #[test]
5301 fn test_parse_event_handler_rejects_malformed_args() {
5302 assert!(matches!(
5303 parse_event_handler("{onClick('unterminated)}"),
5304 EventHandler::Invalid(ref raw) if raw == "onClick('unterminated)"
5305 ));
5306 assert!(matches!(
5307 parse_event_handler("{onClick(other())}"),
5308 EventHandler::Invalid(ref raw) if raw == "onClick(other())"
5309 ));
5310 assert!(matches!(
5311 parse_event_handler("{onClick(count + 1)}"),
5312 EventHandler::Invalid(ref raw) if raw == "onClick(count + 1)"
5313 ));
5314 }
5315
5316 #[test]
5317 fn test_parse_event_handler_rejects_invalid_handler_name() {
5318 assert!(matches!(
5319 parse_event_handler("{handler.name()}"),
5320 EventHandler::Invalid(ref raw) if raw == "handler.name()"
5321 ));
5322 assert!(matches!(
5323 parse_event_handler("{1handler()}"),
5324 EventHandler::Invalid(ref raw) if raw == "1handler()"
5325 ));
5326 }
5327
5328 #[test]
5329 fn test_parse_event_handler_empty_value() {
5330 assert!(matches!(parse_event_handler("{}"), EventHandler::Empty));
5331 }
5332
5333 #[test]
5334 fn test_attribute_to_camel_aria() {
5335 use webui_protocol::attrs::attribute_to_camel;
5336 assert_eq!(attribute_to_camel("aria-describedby"), "ariaDescribedBy");
5337 assert_eq!(attribute_to_camel("aria-labelledby"), "ariaLabelledBy");
5338 assert_eq!(
5339 attribute_to_camel("aria-activedescendant"),
5340 "ariaActiveDescendant"
5341 );
5342 assert_eq!(attribute_to_camel("aria-label"), "ariaLabel");
5343 assert_eq!(attribute_to_camel("aria-hidden"), "ariaHidden");
5344 }
5345
5346 #[test]
5347 fn test_attribute_to_camel_html_global() {
5348 use webui_protocol::attrs::attribute_to_camel;
5349 assert_eq!(attribute_to_camel("readonly"), "readOnly");
5350 assert_eq!(attribute_to_camel("tabindex"), "tabIndex");
5351 assert_eq!(attribute_to_camel("accesskey"), "accessKey");
5352 assert_eq!(attribute_to_camel("contenteditable"), "contentEditable");
5353 assert_eq!(attribute_to_camel("inputmode"), "inputMode");
5354 assert_eq!(attribute_to_camel("maxlength"), "maxLength");
5355 assert_eq!(attribute_to_camel("formaction"), "formAction");
5356 }
5357}