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