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