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