1use super::super::{
11 AttributeAction, AttributeContext, ComponentBuildContext, ComponentProcessing,
12 ComponentStyleDelivery, ComponentTemplateArtifact, ElementStartContext, ParserPlugin,
13 ParserPluginArtifacts,
14};
15#[cfg(test)]
16use crate::component_registry::Component;
17use crate::html_parser::{
18 find_element_end, find_tag_close, leading_content, opening_tag_name, starts_with_html_tag_name,
19};
20use crate::{CssLinkOptions, CssStrategy, Result};
21use webui_protocol::FastElementData;
22
23#[inline]
24fn finish_element(binding_count: u32) -> Option<Vec<u8>> {
25 (binding_count > 0).then(|| FastElementData { binding_count }.encode_v3().to_vec())
26}
27
28struct TrackedComponent {
30 tag_name: String,
31 template_html: String,
32 uses_shadow_dom: bool,
33 style_injection: Option<String>,
39 styles_at_end: bool,
40}
41
42pub struct FastV3ParserPlugin {
50 components: Vec<TrackedComponent>,
52}
53
54impl FastV3ParserPlugin {
55 #[must_use]
57 pub fn new() -> Self {
58 Self {
59 components: Vec::new(),
60 }
61 }
62
63 #[must_use]
69 pub fn take_component_templates(&self) -> Vec<ComponentTemplateArtifact> {
70 self.components
71 .iter()
72 .map(|comp| {
73 let tmpl = build_f_template(
74 &comp.tag_name,
75 &convert_btr_to_fast(&comp.template_html),
76 &super::shared::hoisted_shadow_options(&comp.template_html),
77 FastTemplateStyle {
78 injection: comp.style_injection.as_deref(),
79 module_specifier: None,
80 at_end: comp.styles_at_end,
81 },
82 );
83 ComponentTemplateArtifact::template(
84 comp.tag_name.clone(),
85 tmpl,
86 comp.uses_shadow_dom,
87 )
88 })
89 .collect()
90 }
91
92 fn track_component(&mut self, context: ComponentBuildContext<'_>) {
93 let tag_name = context.component.tag_name.as_str();
94 if self.components.iter().any(|c| c.tag_name == tag_name) {
95 return;
96 }
97 self.components.push(TrackedComponent {
98 tag_name: tag_name.to_string(),
99 template_html: context.template.to_string(),
100 uses_shadow_dom: context.uses_shadow_dom,
101 style_injection: context.style.and_then(style_injection_snippet),
102 styles_at_end: matches!(context.style, Some(ComponentStyleDelivery::Inline { .. })),
103 });
104 }
105
106 #[cfg(test)]
107 fn register_component_template(
108 &mut self,
109 _tag_name: &str,
110 component: &Component,
111 processed_template: &str,
112 ) -> Result<()> {
113 <Self as ParserPlugin>::component_built(
114 self,
115 ComponentBuildContext {
116 component,
117 template: processed_template,
118 uses_shadow_dom: true,
119 style: None,
120 },
121 )
122 }
123
124 #[cfg(test)]
125 fn classify_attribute(&mut self, name: &str) -> AttributeAction {
126 <Self as ParserPlugin>::process_attribute(self, AttributeContext { name })
127 }
128
129 #[cfg(test)]
130 fn finish_element(&mut self, binding_count: u32) -> Option<Vec<u8>> {
131 <Self as ParserPlugin>::finish_opening_tag(
132 self,
133 ElementStartContext {
134 tag_name: "test-element",
135 binding_count,
136 },
137 )
138 }
139}
140
141impl Default for FastV3ParserPlugin {
142 fn default() -> Self {
143 Self::new()
144 }
145}
146
147impl ParserPlugin for FastV3ParserPlugin {
148 fn component_processing(&self) -> ComponentProcessing {
149 ComponentProcessing {
150 source_transform: Some(super::shared::transform_component_source),
151 process_root_template_attributes: true,
152 inline_styles_after_content: true,
153 }
154 }
155
156 fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
157 require_fast_shadow_dom(context.component.tag_name.as_str(), context.uses_shadow_dom)?;
158 self.track_component(context);
159 Ok(())
160 }
161
162 fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
163 super::shared::classify_attribute(context.name)
164 }
165
166 fn finish_opening_tag(&mut self, context: ElementStartContext<'_>) -> Option<Vec<u8>> {
167 finish_element(context.binding_count)
168 }
169
170 fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
171 Ok(ParserPluginArtifacts::ComponentTemplates(
172 self.take_component_templates(),
173 ))
174 }
175}
176
177pub fn generate_f_template(
186 tag_name: &str,
187 html_content: &str,
188 css_content: Option<&str>,
189 css_strategy: CssStrategy,
190) -> String {
191 generate_f_template_with_css_options(
192 tag_name,
193 html_content,
194 css_content,
195 css_strategy,
196 &CssLinkOptions::default(),
197 )
198}
199
200#[cfg(test)]
201fn generate_f_template_from_processed(tag_name: &str, processed_template: &str) -> String {
202 build_f_template(
203 tag_name,
204 &convert_btr_to_fast(processed_template),
205 &super::shared::hoisted_shadow_options(processed_template),
206 FastTemplateStyle::default(),
207 )
208}
209
210pub(super) fn style_injection_snippet(style: ComponentStyleDelivery<'_>) -> Option<String> {
215 match style {
216 ComponentStyleDelivery::Link { href } => {
217 let mut link = String::with_capacity(30 + href.len());
218 link.push_str("<link rel=\"stylesheet\" href=\"");
219 link.push_str(href);
220 link.push_str("\">");
221 Some(link)
222 }
223 ComponentStyleDelivery::Inline { css } => {
224 let trimmed = css.trim();
225 let mut style = String::with_capacity(15 + trimmed.len());
226 style.push_str("<style>");
227 push_style_text(&mut style, trimmed);
228 style.push_str("</style>");
229 Some(style)
230 }
231 ComponentStyleDelivery::Adopted { .. } => None,
232 }
233}
234
235fn push_style_text(output: &mut String, css: &str) {
238 let bytes = css.as_bytes();
239 let mut chunk_start = 0usize;
240 let mut index = 0usize;
241 while index + 7 <= bytes.len() {
242 if bytes[index] == b'<'
243 && bytes[index + 1] == b'/'
244 && bytes[index + 2].eq_ignore_ascii_case(&b's')
245 && bytes[index + 3].eq_ignore_ascii_case(&b't')
246 && bytes[index + 4].eq_ignore_ascii_case(&b'y')
247 && bytes[index + 5].eq_ignore_ascii_case(&b'l')
248 && bytes[index + 6].eq_ignore_ascii_case(&b'e')
249 {
250 output.push_str(&css[chunk_start..index + 1]);
251 output.push_str("\\/");
252 chunk_start = index + 2;
253 index += 7;
254 } else {
255 index += 1;
256 }
257 }
258 output.push_str(&css[chunk_start..]);
259}
260
261#[derive(Default)]
271pub(super) struct FastTemplateStyle<'a> {
272 pub(super) injection: Option<&'a str>,
273 pub(super) module_specifier: Option<&'a str>,
274 pub(super) at_end: bool,
275}
276
277pub(super) fn build_f_template(
278 tag_name: &str,
279 converted_html: &str,
280 hoisted_shadow_options: &str,
281 style: FastTemplateStyle<'_>,
282) -> String {
283 let mut output = String::with_capacity(256);
284 output.push_str("<f-template name=\"");
285 output.push_str(tag_name);
286 output.push('"');
287 output.push_str(hoisted_shadow_options);
291 output.push_str(">\n");
292
293 let trimmed = minify_inter_tag_whitespace(converted_html.trim());
294 let (trimmed, _) = leading_content(&trimmed);
295
296 if starts_with_html_tag_name(trimmed, "template") {
297 if let Some(close_pos) = find_tag_close(trimmed) {
298 output.push_str(&trimmed[..close_pos]);
303 output.push('>');
304 super::shared::push_body_with_css_injection(
305 &mut output,
306 &trimmed[close_pos + 1..],
307 style.injection,
308 style.at_end,
309 );
310 } else {
311 output.push_str(trimmed);
312 }
313 } else {
314 output.push_str("<template");
315 if let Some(specifier) = style.module_specifier {
316 output.push_str(" shadowrootadoptedstylesheets=\"");
317 output.push_str(specifier);
318 output.push('"');
319 }
320 output.push('>');
321 super::shared::push_body_with_css_injection(
322 &mut output,
323 trimmed,
324 style.injection,
325 style.at_end,
326 );
327 output.push_str("</template>");
328 }
329
330 output.push_str("\n</f-template>\n");
331 output
332}
333
334pub(super) fn f_template_style_injection<'a>(
337 tag_name: &'a str,
338 css_content: Option<&str>,
339 css_strategy: CssStrategy,
340 css_link_options: &CssLinkOptions,
341) -> (Option<String>, Option<&'a str>) {
342 let css_injection = css_content.and_then(|css| match css_strategy {
343 CssStrategy::Link => style_injection_snippet(ComponentStyleDelivery::Link {
344 href: &css_link_options.resolve(tag_name, css).href,
345 }),
346 CssStrategy::Style => style_injection_snippet(ComponentStyleDelivery::Inline { css }),
347 CssStrategy::Module => None,
348 });
349 let module_specifier = match css_strategy {
350 CssStrategy::Module if css_content.is_some() => Some(tag_name),
351 _ => None,
352 };
353 (css_injection, module_specifier)
354}
355
356pub fn generate_f_template_with_css_options(
358 tag_name: &str,
359 html_content: &str,
360 css_content: Option<&str>,
361 css_strategy: CssStrategy,
362 css_link_options: &CssLinkOptions,
363) -> String {
364 let (css_injection, module_specifier) =
365 f_template_style_injection(tag_name, css_content, css_strategy, css_link_options);
366
367 build_f_template(
368 tag_name,
369 &convert_btr_to_fast(html_content),
370 &super::shared::hoisted_shadow_options(html_content),
371 FastTemplateStyle {
372 injection: css_injection.as_deref(),
373 module_specifier,
374 at_end: css_strategy == CssStrategy::Style,
375 },
376 )
377}
378
379fn convert_btr_to_fast(input: &str) -> String {
381 let mut result = String::with_capacity(input.len());
382 let bytes = input.as_bytes();
383 let len = bytes.len();
384 let mut i = 0;
385
386 while i < len {
387 if bytes[i] == b'<' {
388 if let Some(consumed) = try_convert_tag(input, i, &mut result) {
389 i += consumed;
390 continue;
391 }
392 }
393
394 i = push_char_at(input, i, &mut result);
398 }
399
400 result
401}
402
403fn try_convert_tag(input: &str, pos: usize, result: &mut String) -> Option<usize> {
406 let remaining = &input[pos..];
407
408 if remaining.starts_with("</if>") {
410 result.push_str("</f-when>");
411 return Some(5);
412 }
413 if remaining.starts_with("</for>") {
414 result.push_str("</f-repeat>");
415 return Some(6);
416 }
417 if remaining.starts_with("</route>") {
419 result.push_str("</webui-route>");
420 return Some(8);
421 }
422
423 if let Some(consumed) = find_element_end(remaining, "outlet") {
426 result.push_str("<outlet></outlet>");
427 return Some(consumed);
428 }
429
430 if starts_with_tag_name(remaining, "if") {
432 if let Some(consumed) = convert_if_tag(remaining, result) {
433 return Some(consumed);
434 }
435 }
436
437 if starts_with_tag_name(remaining, "for") {
439 if let Some(consumed) = convert_for_tag(remaining, result) {
440 return Some(consumed);
441 }
442 }
443
444 if starts_with_tag_name(remaining, "route") {
446 if let Some(consumed) = convert_route_tag(remaining, result) {
447 return Some(consumed);
448 }
449 }
450
451 if remaining.starts_with("<") {
453 if starts_with_tag_name(remaining, "template") {
455 if let Some(consumed) = super::shared::strip_hoisted_shadow_options(remaining, result) {
456 return Some(consumed);
457 }
458 }
459 if let Some(consumed) = convert_complex_attrs(remaining, result) {
460 return Some(consumed);
461 }
462 }
463
464 None
465}
466
467fn starts_with_tag_name(s: &str, name: &str) -> bool {
469 opening_tag_name(s).is_some_and(|tag_name| tag_name.eq_ignore_ascii_case(name))
470}
471
472fn convert_if_tag(tag_str: &str, result: &mut String) -> Option<usize> {
475 let close = find_tag_close(tag_str)?;
477 let tag_content = &tag_str[..=close];
478
479 let attr_value = extract_attribute_value(tag_content, "condition")?;
481
482 result.push_str("<f-when value=\"{{");
483 result.push_str(attr_value);
484 result.push_str("}}\">");
485
486 Some(close + 1)
487}
488
489fn convert_for_tag(tag_str: &str, result: &mut String) -> Option<usize> {
492 let close = find_tag_close(tag_str)?;
494 let tag_content = &tag_str[..=close];
495
496 let attr_value = extract_attribute_value(tag_content, "each")?;
498
499 result.push_str("<f-repeat value=\"{{");
500 result.push_str(attr_value);
501 result.push_str("}}\">");
502
503 Some(close + 1)
504}
505
506fn convert_route_tag(tag_str: &str, result: &mut String) -> Option<usize> {
510 let close = find_tag_close(tag_str)?;
511 let is_self_closing = tag_str[..=close].ends_with("/>");
512 let tag = &tag_str[..=close];
513
514 result.push_str("<webui-route");
515
516 if let Some(v) = extract_attribute_value(tag, "path") {
517 result.push_str(" path=\"");
518 result.push_str(v);
519 result.push('"');
520 }
521 if let Some(v) = extract_attribute_value(tag, "name") {
522 result.push_str(" name=\"");
523 result.push_str(v);
524 result.push('"');
525 }
526 if let Some(v) = extract_attribute_value(tag, "component") {
527 result.push_str(" component=\"");
528 result.push_str(v);
529 result.push('"');
530 }
531 if let Some(v) = extract_attribute_value(tag, "redirectTo") {
532 result.push_str(" redirectto=\"");
533 result.push_str(v);
534 result.push('"');
535 }
536 if tag.contains(" exact") {
537 result.push_str(" exact");
538 }
539 if tag.contains(" layout") {
540 result.push_str(" layout");
541 }
542 if let Some(v) = extract_attribute_value(tag, "query") {
543 result.push_str(" query=\"");
544 result.push_str(v);
545 result.push('"');
546 }
547 result.push_str(" style=\"display:none\"");
548
549 if is_self_closing {
550 result.push_str("></webui-route>");
551 } else {
552 result.push('>');
553 }
554
555 Some(close + 1)
556}
557
558fn extract_attribute_value<'a>(tag: &'a str, attr_name: &str) -> Option<&'a str> {
561 let mut search_from = 0;
563 loop {
564 let attr_pos = tag[search_from..].find(attr_name)?;
565 let abs_pos = search_from + attr_pos;
566
567 if abs_pos > 0 {
569 let prev = tag.as_bytes()[abs_pos - 1];
570 if prev != b' ' && prev != b'\t' && prev != b'\n' && prev != b'\r' {
571 search_from = abs_pos + attr_name.len();
572 continue;
573 }
574 }
575
576 let after_name = &tag[abs_pos + attr_name.len()..];
577 if !after_name.starts_with('=') {
578 search_from = abs_pos + attr_name.len();
579 continue;
580 }
581
582 let after_eq = &after_name[1..];
583 if after_eq.starts_with('"') {
584 let value_start = abs_pos + attr_name.len() + 2; let end_quote = tag[value_start..].find('"')?;
586 return Some(&tag[value_start..value_start + end_quote]);
587 }
588
589 return None;
590 }
591}
592
593fn convert_complex_attrs(tag_str: &str, result: &mut String) -> Option<usize> {
597 let close = find_tag_close(tag_str)?;
599 let tag_content = &tag_str[..=close];
600
601 if !tag_content.contains(":") || !tag_content.contains("{{") {
603 return None;
604 }
605
606 let mut converted = String::with_capacity(tag_content.len());
608 let tag_bytes = tag_content.as_bytes();
609 let tag_len = tag_bytes.len();
610 let mut j = 0;
611 let mut in_colon_attr_value = false;
612
613 while j < tag_len {
614 if !in_colon_attr_value {
615 if tag_bytes[j] == b':' && j > 0 && is_whitespace(tag_bytes[j - 1]) {
617 let attr_start = j;
619 j += 1;
620 while j < tag_len && tag_bytes[j] != b'=' && !is_whitespace(tag_bytes[j]) {
622 j += 1;
623 }
624 if j < tag_len && tag_bytes[j] == b'=' {
625 j += 1;
626 if j < tag_len && tag_bytes[j] == b'"' {
627 converted.push_str(&tag_content[attr_start..=j]);
629 j += 1;
630 in_colon_attr_value = true;
631 continue;
632 }
633 }
634 converted.push_str(&tag_content[attr_start..j]);
636 continue;
637 }
638 j = push_char_at(tag_content, j, &mut converted);
639 } else {
640 if tag_bytes[j] == b'"' {
642 converted.push('"');
644 j += 1;
645 in_colon_attr_value = false;
646 } else if j + 1 < tag_len && tag_bytes[j] == b'{' && tag_bytes[j + 1] == b'{' {
647 let expr_start = j + 2;
649 if let Some(end_offset) = tag_content[expr_start..].find("}}") {
650 let expr = &tag_content[expr_start..expr_start + end_offset];
651 converted.push('{');
652 converted.push_str(expr);
653 converted.push('}');
654 j = expr_start + end_offset + 2;
655 } else {
656 converted.push('{');
658 converted.push('{');
659 j += 2;
660 }
661 } else {
662 j = push_char_at(tag_content, j, &mut converted);
663 }
664 }
665 }
666
667 result.push_str(&converted);
668 Some(close + 1)
669}
670
671fn push_char_at(input: &str, pos: usize, out: &mut String) -> usize {
674 let bytes = input.as_bytes();
675 if bytes[pos].is_ascii() {
676 out.push(bytes[pos] as char);
677 pos + 1
678 } else {
679 let mut end = pos + 1;
680 while end < bytes.len() && !input.is_char_boundary(end) {
681 end += 1;
682 }
683 out.push_str(&input[pos..end]);
684 end
685 }
686}
687
688fn is_whitespace(b: u8) -> bool {
690 b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'
691}
692
693fn minify_inter_tag_whitespace(input: &str) -> String {
697 let mut result = String::with_capacity(input.len());
698 let bytes = input.as_bytes();
699 let len = bytes.len();
700 let mut i = 0;
701
702 while i < len {
703 if bytes[i] == b'>' && i + 1 < len {
704 result.push('>');
705 i += 1;
706 let ws_start = i;
708 while i < len && is_whitespace(bytes[i]) {
709 i += 1;
710 }
711 if i >= len || bytes[i] != b'<' {
714 result.push_str(&input[ws_start..i]);
716 }
717 } else {
718 i = push_char_at(input, i, &mut result);
719 }
720 }
721
722 result
723}
724
725pub(super) fn require_fast_shadow_dom(tag_name: &str, uses_shadow_dom: bool) -> Result<()> {
726 if uses_shadow_dom {
727 return Ok(());
728 }
729 Err(super::diagnostic::light_dom_unsupported(tag_name))
730}
731
732#[cfg(test)]
733mod tests {
734 #![allow(clippy::disallowed_methods)]
735
736 use super::*;
737 use crate::diagnostic::codes;
738 use crate::ParserError;
739 fn make_component(tag: &str, html: &str, css: Option<&str>) -> Component {
740 Component {
741 tag_name: tag.to_string(),
742 html_content: html.to_string(),
743 css_content: css.map(ToString::to_string),
744 css_definitions: Vec::new(),
745 css_fallback_chains: Vec::new(),
746 is_client_owned: false,
747 }
748 }
749
750 #[test]
753 fn skip_event_binding() {
754 let mut plugin = FastV3ParserPlugin::new();
755 assert_eq!(
756 plugin.classify_attribute("@click"),
757 AttributeAction::SkipAndCountBinding
758 );
759 assert_eq!(
760 plugin.classify_attribute("@input"),
761 AttributeAction::SkipAndCountBinding
762 );
763 assert_eq!(
764 plugin.classify_attribute("@custom-event"),
765 AttributeAction::SkipAndCountBinding
766 );
767 }
768
769 #[test]
770 fn skip_f_ref() {
771 let mut plugin = FastV3ParserPlugin::new();
772 assert_eq!(
773 plugin.classify_attribute("f-ref"),
774 AttributeAction::SkipAndCountBinding
775 );
776 }
777
778 #[test]
779 fn skip_f_slotted() {
780 let mut plugin = FastV3ParserPlugin::new();
781 assert_eq!(
782 plugin.classify_attribute("f-slotted"),
783 AttributeAction::SkipAndCountBinding
784 );
785 }
786
787 #[test]
788 fn skip_f_children() {
789 let mut plugin = FastV3ParserPlugin::new();
790 assert_eq!(
791 plugin.classify_attribute("f-children"),
792 AttributeAction::SkipAndCountBinding
793 );
794 }
795
796 #[test]
797 fn skip_fast_property_binding() {
798 let mut plugin = FastV3ParserPlugin::new();
799 assert_eq!(
800 plugin.classify_attribute(":title"),
801 AttributeAction::SkipAndCountBinding
802 );
803 assert_eq!(
804 plugin.classify_attribute(":config"),
805 AttributeAction::SkipAndCountBinding
806 );
807 }
808
809 #[test]
810 fn do_not_skip_normal_attributes() {
811 let mut plugin = FastV3ParserPlugin::new();
812 assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
813 assert_eq!(plugin.classify_attribute("id"), AttributeAction::Keep);
814 assert_eq!(
815 plugin.classify_attribute("data-value"),
816 AttributeAction::Keep
817 );
818 assert_eq!(plugin.classify_attribute("f-other"), AttributeAction::Keep);
819 }
820
821 #[test]
824 fn element_parsed_zero_count_returns_none() {
825 let mut plugin = FastV3ParserPlugin::new();
826 assert!(plugin.finish_element(0).is_none());
827 }
828
829 #[test]
830 fn element_parsed_nonzero_count_returns_le_bytes() {
831 let mut plugin = FastV3ParserPlugin::new();
832 let data = plugin.finish_element(3);
833 assert!(data.is_some());
834 let bytes = data.as_deref().unwrap_or_default();
835 assert_eq!(bytes, &3u32.to_le_bytes());
836 }
837
838 #[test]
839 fn element_parsed_large_count() {
840 let mut plugin = FastV3ParserPlugin::new();
841 let data = plugin.finish_element(256);
842 assert!(data.is_some());
843 let bytes = data.as_deref().unwrap_or_default();
844 assert_eq!(bytes, &256u32.to_le_bytes());
845 }
846
847 #[test]
848 fn component_template_simple_component() {
849 let mut plugin = FastV3ParserPlugin::new();
850 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
851 plugin
852 .register_component_template(
853 "my-comp",
854 &comp,
855 r#"<template><link rel="stylesheet" href="my-comp.css"><div>hello</div></template>"#,
856 )
857 .unwrap();
858
859 let templates = plugin.take_component_templates();
860 assert_eq!(templates.len(), 1);
861 let name = &templates[0].tag_name;
862 let html = &templates[0].template;
863 assert_eq!(name, "my-comp");
864 assert!(html.contains("<f-template name=\"my-comp\">"));
865 assert!(html.contains("</f-template>"));
866 assert!(html.contains("<template>"));
867 assert!(html.contains("</template>"));
868 assert!(html.contains("<link rel=\"stylesheet\" href=\"my-comp.css\">"));
869 assert!(html.contains("<div>hello</div>"));
870 }
871
872 #[test]
873 fn component_template_without_css() {
874 let mut plugin = FastV3ParserPlugin::new();
875 let comp = make_component("no-css", "<span>text</span>", None);
876 plugin
877 .register_component_template("no-css", &comp, &comp.html_content)
878 .unwrap();
879
880 let templates = plugin.take_component_templates();
881 assert_eq!(templates.len(), 1);
882 let name = &templates[0].tag_name;
883 let html = &templates[0].template;
884 assert_eq!(name, "no-css");
885 assert!(html.contains("<f-template name=\"no-css\">"));
886 assert!(!html.contains("<link rel=\"stylesheet\""));
887 assert!(html.contains("<span>text</span>"));
888 }
889
890 #[test]
891 fn direct_template_generation_skips_leading_comments() {
892 let html = generate_f_template(
893 "my-comp",
894 concat!(
895 "<!-- Copyright (C) Corporation. All rights reserved. -->\n",
896 "<template shadowrootmode=\"open\"><h1>Hello</h1></template>",
897 ),
898 None,
899 CssStrategy::Link,
900 );
901
902 assert_eq!(html.matches("<template>").count(), 1);
903 assert!(!html.contains("<!--"));
904 assert!(html.contains("<template><h1>Hello</h1></template>"));
905 }
906
907 #[test]
908 fn component_template_css_strategy_style() {
909 let mut plugin = FastV3ParserPlugin::new();
910 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
911 plugin
912 .register_component_template(
913 "my-comp",
914 &comp,
915 r#"<template><style>div { color: red; }</style><div>hello</div></template>"#,
916 )
917 .unwrap();
918
919 let templates = plugin.take_component_templates();
920 assert_eq!(templates.len(), 1);
921 let html = &templates[0].template;
922 assert!(
923 html.contains("<style>div { color: red; }</style>"),
924 "Style strategy should inline CSS, got: {html}"
925 );
926 assert!(
927 !html.contains("<link"),
928 "Style strategy should not emit <link> tags"
929 );
930 }
931
932 #[test]
939 fn style_strategy_trails_style_after_bindings_and_body() {
940 let out = generate_f_template_with_css_options(
943 "my-btn",
944 r#"<template @click="{click($e)}"><slot name="start" f-ref="{start}"></slot><span>x</span></template>"#,
945 Some("@media screen { :host { color: red; } } .content { display: inherit; }"),
946 CssStrategy::Style,
947 &CssLinkOptions::default(),
948 );
949 let style_at = out.find("<style>").expect("style present");
950 let close_at = out.rfind("</template>").expect("template close present");
951 let style_end = out.find("</style>").expect("style end present") + "</style>".len();
952 assert!(
954 out.find("f-ref=").expect("f-ref present") < style_at,
955 "f-ref binding must precede the <style>: {out}"
956 );
957 assert!(
958 out.find("@click=").expect("@click present") < style_at,
959 "@click binding must precede the <style>: {out}"
960 );
961 assert!(
962 out[style_end..close_at].trim().is_empty(),
963 "nothing but the <style> may sit between the body and </template>: {out}"
964 );
965 }
966
967 #[test]
968 fn style_strategy_preserves_braces_at_rules_and_custom_properties() {
969 let css = concat!(
972 ":host { --icon-spacing: var(--space, 4px); display: inline-flex; }\n",
973 "@media (forced-colors: active) { :host { color: CanvasText; } }\n",
974 "@supports (display: grid) { .grid { display: grid; } }",
975 );
976 let out = generate_f_template_with_css_options(
977 "my-btn",
978 r#"<template><slot f-slotted="{slotted}"></slot></template>"#,
979 Some(css),
980 CssStrategy::Style,
981 &CssLinkOptions::default(),
982 );
983 let expected = format!("<style>{css}</style>");
984 assert!(
985 out.contains(&expected),
986 "CSS (braces, at-rules, custom props) must be byte-preserved: {out}"
987 );
988 assert!(
989 out.find("f-slotted=").expect("binding present") < out.find("<style>").expect("style"),
990 "binding must precede the trailing <style>: {out}"
991 );
992 }
993
994 #[test]
995 fn style_strategy_preserves_braces_in_css_strings_and_comments() {
996 let css = concat!(
1000 r#".a::after { content: "}"; } /* a { brace } in a comment */"#,
1001 "\n",
1002 r#".b::before { content: "</template>"; }"#,
1003 );
1004 let out = generate_f_template_with_css_options(
1005 "my-btn",
1006 r#"<template><slot f-ref="{end}"></slot></template>"#,
1007 Some(css),
1008 CssStrategy::Style,
1009 &CssLinkOptions::default(),
1010 );
1011 let expected = format!("<style>{css}</style>");
1012 assert!(
1013 out.contains(&expected),
1014 "CSS string/comment braces and end-tag-like text must be byte-preserved: {out}"
1015 );
1016 assert!(
1021 out.contains("</style></template>"),
1022 "the <style> must be the last body node before </template>: {out}"
1023 );
1024 assert!(
1025 out.find("f-ref=").expect("binding present") < out.find("<style>").expect("style"),
1026 "binding must precede the trailing <style>: {out}"
1027 );
1028 }
1029
1030 #[test]
1031 fn link_strategy_injection_stays_at_template_start() {
1032 let out = generate_f_template_with_css_options(
1035 "my-btn",
1036 r#"<template><slot f-ref="{start}"></slot></template>"#,
1037 Some("div { color: red; }"),
1038 CssStrategy::Link,
1039 &CssLinkOptions::default(),
1040 );
1041 assert!(out.contains(r#"<link rel="stylesheet" href="my-btn.css">"#));
1042 assert!(
1043 out.find("<link").expect("link present") < out.find("f-ref=").expect("binding present"),
1044 "Link stays ahead of the body (unchanged placement): {out}"
1045 );
1046 }
1047
1048 #[test]
1049 fn inline_component_css_cannot_terminate_the_style_element() {
1050 let snippet = style_injection_snippet(ComponentStyleDelivery::Inline {
1051 css: ".a{content:'</StYlE>'}</template><img src=x onerror=alert(1)>",
1052 })
1053 .expect("inline style snippet");
1054
1055 assert_eq!(
1056 snippet,
1057 "<style>.a{content:'<\\/StYlE>'}</template><img src=x onerror=alert(1)></style>"
1058 );
1059 }
1060
1061 #[test]
1062 fn component_template_css_strategy_module() {
1063 let mut plugin = FastV3ParserPlugin::new();
1064 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
1065 plugin
1066 .register_component_template(
1067 "my-comp",
1068 &comp,
1069 r#"<template shadowrootadoptedstylesheets="my-comp"><div>hello</div></template>"#,
1070 )
1071 .unwrap();
1072
1073 let templates = plugin.take_component_templates();
1074 assert_eq!(templates.len(), 1);
1075 let html = &templates[0].template;
1076
1077 assert!(
1080 !html.contains(r#"<style type="module""#),
1081 "Legacy CSS module shape should not be baked into f-template: {html}"
1082 );
1083 assert!(
1084 !html.contains(r#"<script type="importmap""#),
1085 "CSS module importmap should not be baked into f-template: {html}"
1086 );
1087 assert!(
1089 html.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
1090 "Module strategy should add shadowrootadoptedstylesheets, got: {html}"
1091 );
1092 assert!(
1094 !html.contains("<style>div"),
1095 "Module strategy should not have inline <style> inside template"
1096 );
1097 assert!(
1098 !html.contains("<link"),
1099 "Module strategy should not emit <link> tags"
1100 );
1101 }
1102
1103 #[test]
1104 fn component_template_css_strategy_module_no_css() {
1105 let mut plugin = FastV3ParserPlugin::new();
1106 let comp = make_component("my-comp", "<div>hello</div>", None);
1107 plugin
1108 .register_component_template(
1109 "my-comp",
1110 &comp,
1111 r#"<template><div>hello</div></template>"#,
1112 )
1113 .unwrap();
1114
1115 let templates = plugin.take_component_templates();
1116 assert_eq!(templates.len(), 1);
1117 let html = &templates[0].template;
1118 assert!(
1119 !html.contains("shadowrootadoptedstylesheets"),
1120 "No CSS = no shadowrootadoptedstylesheets, got: {html}"
1121 );
1122 assert!(
1123 !html.contains("<style"),
1124 "No CSS = no style tag, got: {html}"
1125 );
1126 }
1127
1128 #[test]
1129 fn component_template_multiple_components() {
1130 let mut plugin = FastV3ParserPlugin::new();
1131 let comp1 = make_component("comp-a", "<div>A</div>", None);
1132 let comp2 = make_component("comp-b", "<div>B</div>", Some("b { }"));
1133 plugin
1134 .register_component_template("comp-a", &comp1, &comp1.html_content)
1135 .unwrap();
1136 plugin
1137 .register_component_template("comp-b", &comp2, &comp2.html_content)
1138 .unwrap();
1139
1140 let templates = plugin.take_component_templates();
1141 assert_eq!(templates.len(), 2);
1142
1143 let names: Vec<&str> = templates
1144 .iter()
1145 .map(|artifact| artifact.tag_name.as_str())
1146 .collect();
1147 assert!(names.contains(&"comp-a"));
1148 assert!(names.contains(&"comp-b"));
1149
1150 for artifact in &templates {
1151 assert!(artifact.template.contains("<f-template name="));
1152 }
1153 }
1154
1155 #[test]
1156 fn component_template_deduplicates_same_component() {
1157 let mut plugin = FastV3ParserPlugin::new();
1158 let comp = make_component(
1159 "my-button",
1160 "<button><slot></slot></button>",
1161 Some(".btn{}"),
1162 );
1163
1164 plugin
1167 .register_component_template("my-button", &comp, &comp.html_content)
1168 .unwrap();
1169 plugin
1170 .register_component_template("my-button", &comp, &comp.html_content)
1171 .unwrap();
1172 plugin
1173 .register_component_template("my-button", &comp, &comp.html_content)
1174 .unwrap();
1175
1176 let templates = plugin.take_component_templates();
1177 assert_eq!(
1178 templates.len(),
1179 1,
1180 "Expected exactly 1 template for my-button, got {}",
1181 templates.len()
1182 );
1183 assert_eq!(templates[0].tag_name, "my-button");
1184 }
1185
1186 #[test]
1187 fn component_template_deduplicates_mixed_components() {
1188 let mut plugin = FastV3ParserPlugin::new();
1189 let btn = make_component("my-button", "<button><slot></slot></button>", None);
1190 let card = make_component("my-card", "<div><slot></slot></div>", Some(".card{}"));
1191
1192 plugin
1194 .register_component_template("my-button", &btn, &btn.html_content)
1195 .unwrap();
1196 plugin
1197 .register_component_template("my-card", &card, &card.html_content)
1198 .unwrap();
1199 plugin
1200 .register_component_template("my-button", &btn, &btn.html_content)
1201 .unwrap();
1202
1203 let templates = plugin.take_component_templates();
1204 assert_eq!(templates.len(), 2);
1205
1206 let button_count = templates
1207 .iter()
1208 .filter(|artifact| artifact.tag_name == "my-button")
1209 .count();
1210 let card_count = templates
1211 .iter()
1212 .filter(|artifact| artifact.tag_name == "my-card")
1213 .count();
1214 assert_eq!(button_count, 1);
1215 assert_eq!(card_count, 1);
1216 }
1217
1218 #[test]
1221 fn convert_if_to_f_when() {
1222 let input = r#"<if condition="isComplete"><span>Done</span></if>"#;
1223 let output = convert_btr_to_fast(input);
1224 assert_eq!(
1225 output,
1226 r#"<f-when value="{{isComplete}}"><span>Done</span></f-when>"#
1227 );
1228 }
1229
1230 #[test]
1231 fn convert_if_with_greater_than() {
1232 let input = r#"<if condition="vara > 2">Over two</if>"#;
1233 let output = convert_btr_to_fast(input);
1234 assert_eq!(output, r#"<f-when value="{{vara > 2}}">Over two</f-when>"#);
1235 }
1236
1237 #[test]
1238 fn convert_if_with_greater_than_or_equal() {
1239 let input = r#"<if condition="vara >= 2">Two and over</if>"#;
1240 let output = convert_btr_to_fast(input);
1241 assert_eq!(
1242 output,
1243 r#"<f-when value="{{vara >= 2}}">Two and over</f-when>"#
1244 );
1245 }
1246
1247 #[test]
1248 fn convert_if_with_less_than() {
1249 let input = r#"<if condition="x < 10">small</if>"#;
1250 let output = convert_btr_to_fast(input);
1251 assert_eq!(output, r#"<f-when value="{{x < 10}}">small</f-when>"#);
1252 }
1253
1254 #[test]
1255 fn convert_for_to_f_repeat() {
1256 let input = r#"<for each="tag in tags"><span>{{tag}}</span></for>"#;
1257 let output = convert_btr_to_fast(input);
1258 assert_eq!(
1259 output,
1260 r#"<f-repeat value="{{tag in tags}}"><span>{{tag}}</span></f-repeat>"#
1261 );
1262 }
1263
1264 #[test]
1265 fn convert_nested_if_and_for() {
1266 let input = r#"<if condition="show"><for each="x in items"><p>{{x}}</p></for></if>"#;
1267 let output = convert_btr_to_fast(input);
1268 assert_eq!(
1269 output,
1270 r#"<f-when value="{{show}}"><f-repeat value="{{x in items}}"><p>{{x}}</p></f-repeat></f-when>"#
1271 );
1272 }
1273
1274 #[test]
1275 fn convert_complex_attr_double_braces() {
1276 let input = r#"<div :data="{{config}}"></div>"#;
1277 let output = convert_btr_to_fast(input);
1278 assert_eq!(output, r#"<div :data="{config}"></div>"#);
1279 }
1280
1281 #[test]
1282 fn convert_multiple_complex_attrs() {
1283 let input = r#"<div :title="{{name}}" :data-id="{{id}}"></div>"#;
1284 let output = convert_btr_to_fast(input);
1285 assert_eq!(output, r#"<div :title="{name}" :data-id="{id}"></div>"#);
1286 }
1287
1288 #[test]
1289 fn no_conversion_for_text_double_braces() {
1290 let input = "<span>{{greeting}}</span>";
1292 let output = convert_btr_to_fast(input);
1293 assert_eq!(output, "<span>{{greeting}}</span>");
1294 }
1295
1296 #[test]
1297 fn no_conversion_for_regular_attr() {
1298 let input = r#"<div title="{{name}}"></div>"#;
1300 let output = convert_btr_to_fast(input);
1301 assert_eq!(output, r#"<div title="{{name}}"></div>"#);
1302 }
1303
1304 #[test]
1305 fn component_template_with_btr_conversion() {
1306 let mut plugin = FastV3ParserPlugin::new();
1307 let html = r#"<div><if condition="visible"><span>hi</span></if></div>"#;
1308 let comp = make_component("my-widget", html, None);
1309 plugin
1310 .register_component_template("my-widget", &comp, &comp.html_content)
1311 .unwrap();
1312
1313 let templates = plugin.take_component_templates();
1314 assert_eq!(templates.len(), 1);
1315 let name = &templates[0].tag_name;
1316 let result = &templates[0].template;
1317 assert_eq!(name, "my-widget");
1318
1319 assert!(result.contains("<f-when value=\"{{visible}}\">"));
1320 assert!(result.contains("</f-when>"));
1321 assert!(!result.contains("<if condition="));
1322 assert!(!result.contains("</if>"));
1323 }
1324
1325 #[test]
1326 fn passthrough_normal_html() {
1327 let input = r#"<div class="container"><p>Hello</p></div>"#;
1328 let output = convert_btr_to_fast(input);
1329 assert_eq!(output, input);
1330 }
1331
1332 #[test]
1333 fn outlet_kept_as_marker_in_f_template() {
1334 for input in [
1335 r#"<div><outlet /></div>"#,
1336 r#"<div><outlet></outlet></div>"#,
1337 ] {
1338 let output = convert_btr_to_fast(input);
1339 assert_eq!(output, r#"<div><outlet></outlet></div>"#);
1340 }
1341 }
1342
1343 #[test]
1344 fn outlet_marker_in_component_template() {
1345 let mut plugin = FastV3ParserPlugin::new();
1346 let html =
1347 r#"<template shadowrootmode="open"><h1>Title</h1><main><outlet /></main></template>"#;
1348 let comp = make_component("my-shell", html, None);
1349 plugin
1350 .register_component_template("my-shell", &comp, &comp.html_content)
1351 .unwrap();
1352
1353 let templates = plugin.take_component_templates();
1354 let result = &templates[0].template;
1355 assert!(
1356 result.contains("<outlet></outlet>"),
1357 "outlet should be kept as marker in f-template: {result}"
1358 );
1359 }
1360
1361 #[test]
1362 fn strip_shadowrootmode_from_template() {
1363 let input = r#"<template shadowrootmode="open"><div>Hello</div></template>"#;
1364 let output = convert_btr_to_fast(input);
1365 assert_eq!(output, "<template><div>Hello</div></template>");
1366 }
1367
1368 #[test]
1369 fn strips_uppercase_native_shadow_wrapper() {
1370 let input = r#"<TEMPLATE SHADOWROOTMODE="open"><div>Hello</div></TEMPLATE>"#;
1371 let output = convert_btr_to_fast(input);
1372 assert_eq!(output, "<TEMPLATE><div>Hello</div></TEMPLATE>");
1373 }
1374
1375 #[test]
1376 fn shadow_mode_stripping_preserves_unrelated_attribute_bytes() {
1377 let input = "<TEMPLATE SHADOWROOTMODE = 'open' aria-label='keep shadowrootmode word' data-x = \"y\">";
1378 let output = convert_btr_to_fast(input);
1379 assert_eq!(
1380 output,
1381 "<TEMPLATE aria-label='keep shadowrootmode word' data-x = \"y\">"
1382 );
1383 }
1384
1385 #[test]
1386 fn rejects_effective_light_dom() {
1387 let mut plugin = FastV3ParserPlugin::new();
1388 let component = make_component("my-card", "<p>content</p>", None);
1389 let error = <FastV3ParserPlugin as ParserPlugin>::component_built(
1390 &mut plugin,
1391 ComponentBuildContext {
1392 component: &component,
1393 template: component.html_content.as_str(),
1394 uses_shadow_dom: false,
1395 style: None,
1396 },
1397 )
1398 .expect_err("FAST cannot mount Light DOM faithfully");
1399 assert!(matches!(
1400 error,
1401 ParserError::Template(ref diagnostic)
1402 if diagnostic.error_code() == Some(codes::FAST_LIGHT_DOM_UNSUPPORTED)
1403 ));
1404 }
1405
1406 #[test]
1407 fn strip_shadowrootmode_preserves_other_attrs() {
1408 let input =
1409 r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>Hi</div></template>"#;
1410 let output = convert_btr_to_fast(input);
1411 assert!(output.contains(r#"@click="{onClick(e)}""#));
1412 assert!(!output.contains("shadowrootmode"));
1413 assert!(output.starts_with("<template "));
1414 }
1415
1416 #[test]
1417 fn no_strip_when_no_shadowrootmode() {
1418 let input = r#"<template foo="bar"><div>Hi</div></template>"#;
1419 let output = convert_btr_to_fast(input);
1420 assert_eq!(output, input);
1421 }
1422
1423 #[test]
1424 fn component_template_strips_shadowrootmode_from_f_template() {
1425 let mut plugin = FastV3ParserPlugin::new();
1426 let comp = make_component(
1427 "my-comp",
1428 r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>{{title}}</div></template>"#,
1429 Some("div { color: red; }"),
1430 );
1431 plugin
1432 .register_component_template("my-comp", &comp, &comp.html_content)
1433 .unwrap();
1434 let templates = plugin.take_component_templates();
1435 assert_eq!(templates.len(), 1);
1436 let name = &templates[0].tag_name;
1437 let html = &templates[0].template;
1438 assert_eq!(name, "my-comp");
1439 assert!(html.contains(r#"<f-template name="my-comp" shadowrootmode="open">"#));
1442 assert!(!html.contains("<template shadowrootmode"));
1443 assert!(html.contains("@click"));
1445 assert!(html.contains("{{title}}"));
1447 }
1448
1449 #[test]
1450 fn shadow_options_hoisted_from_inner_template_to_f_template_wrapper() {
1451 let processed = concat!(
1457 r#"<template shadowrootmode="open" shadowrootdelegatesfocus "#,
1458 r#"shadowrootadoptedstylesheets="custom-field" @click="{clickHandler($e)}">"#,
1459 r#"<slot></slot></template>"#,
1460 );
1461 let html = generate_f_template_from_processed("custom-field", processed);
1462 assert!(
1463 html.contains(
1464 r#"<f-template name="custom-field" shadowrootmode="open" shadowrootdelegatesfocus>"#
1465 ),
1466 "wrapper must carry hoisted shadow options: {html}"
1467 );
1468 assert!(html.contains(r#"@click="{clickHandler($e)}""#));
1471 assert!(html.contains(r#"shadowrootadoptedstylesheets="custom-field""#));
1472 assert!(
1473 !html.contains("<template shadowrootmode"),
1474 "inner <template> must not keep shadowrootmode: {html}"
1475 );
1476 assert!(
1477 !html.contains("<template shadowrootdelegatesfocus")
1478 && !html.contains(" shadowrootdelegatesfocus shadowrootadopted"),
1479 "inner <template> must not keep shadowrootdelegatesfocus: {html}"
1480 );
1481 }
1482
1483 #[test]
1484 fn delegatesfocus_only_without_mode_is_hoisted_and_stripped() {
1485 let processed = r#"<template shadowrootdelegatesfocus><slot></slot></template>"#;
1490 let html = generate_f_template_from_processed("custom-field", processed);
1491 assert_eq!(
1492 html.matches("shadowrootdelegatesfocus").count(),
1493 1,
1494 "shadowrootdelegatesfocus must appear exactly once (hoisted, not duplicated): {html}"
1495 );
1496 assert!(
1497 html.contains(r#"<f-template name="custom-field" shadowrootdelegatesfocus>"#),
1498 "wrapper must carry the hoisted option: {html}"
1499 );
1500 assert!(
1501 !html.contains("<template shadowrootdelegatesfocus"),
1502 "inner <template> must not keep shadowrootdelegatesfocus: {html}"
1503 );
1504 }
1505
1506 #[test]
1507 fn clonable_and_serializable_only_are_hoisted_and_stripped() {
1508 let processed =
1509 r#"<template shadowrootclonable shadowrootserializable><slot></slot></template>"#;
1510 let html = generate_f_template_from_processed("custom-field", processed);
1511 for attr in ["shadowrootclonable", "shadowrootserializable"] {
1512 assert_eq!(
1513 html.matches(attr).count(),
1514 1,
1515 "{attr} must appear exactly once (hoisted, not duplicated): {html}"
1516 );
1517 }
1518 assert!(
1519 html.contains(
1520 r#"<f-template name="custom-field" shadowrootclonable shadowrootserializable>"#
1521 ),
1522 "wrapper must carry both hoisted options: {html}"
1523 );
1524 assert!(
1525 !html.contains("<template shadowrootclonable")
1526 && !html.contains("<template shadowrootserializable"),
1527 "inner <template> must not keep either hoisted option: {html}"
1528 );
1529 }
1530
1531 #[test]
1532 fn adoptedstylesheets_only_is_neither_hoisted_nor_stripped() {
1533 let processed =
1534 r#"<template shadowrootadoptedstylesheets="custom-field"><slot></slot></template>"#;
1535 let html = generate_f_template_from_processed("custom-field", processed);
1536 assert!(
1537 html.contains("<f-template name=\"custom-field\">"),
1538 "shadowrootadoptedstylesheets is not a shadow-root-creation option and must stay off the wrapper: {html}"
1539 );
1540 assert!(
1541 html.contains(r#"<template shadowrootadoptedstylesheets="custom-field">"#),
1542 "inner <template> must keep shadowrootadoptedstylesheets: {html}"
1543 );
1544 }
1545
1546 #[test]
1547 fn mixed_shadow_options_each_appear_exactly_once_and_never_on_inner() {
1548 let processed = concat!(
1555 r#"<template shadowrootmode="open" shadowrootdelegatesfocus shadowrootclonable "#,
1556 r#"shadowrootserializable shadowrootadoptedstylesheets="custom-field" "#,
1557 r#"@click="{clickHandler($e)}"><slot></slot></template>"#,
1558 );
1559 let html = generate_f_template_from_processed("custom-field", processed);
1560 assert!(
1561 html.contains(
1562 r#"<f-template name="custom-field" shadowrootmode="open" shadowrootdelegatesfocus shadowrootclonable shadowrootserializable>"#
1563 ),
1564 "wrapper must carry every hoisted option exactly once, in order: {html}"
1565 );
1566 assert!(
1567 html.contains(
1568 r#"<template shadowrootadoptedstylesheets="custom-field" @click="{clickHandler($e)}">"#
1569 ),
1570 "inner <template> must keep shadowrootadoptedstylesheets and the binding, and nothing hoisted: {html}"
1571 );
1572 for attr in [
1573 "shadowrootmode",
1574 "shadowrootdelegatesfocus",
1575 "shadowrootclonable",
1576 "shadowrootserializable",
1577 ] {
1578 assert_eq!(
1579 html.matches(attr).count(),
1580 1,
1581 "{attr} must appear exactly once across the wrapper and inner template: {html}"
1582 );
1583 }
1584 }
1585
1586 #[test]
1587 fn test_complex_attribute_normalization() {
1588 let cases = [
1591 (
1592 r#"<div :foo="{{value}}"></div>"#,
1593 r#"<div :foo="{value}"></div>"#,
1594 ),
1595 (
1596 r#"<div :foo="{{ value}}"></div>"#,
1597 r#"<div :foo="{ value}"></div>"#,
1598 ),
1599 (
1600 r#"<div :foo="{{value }}"></div>"#,
1601 r#"<div :foo="{value }"></div>"#,
1602 ),
1603 (
1604 r#"<div :foo="{{ value }}"></div>"#,
1605 r#"<div :foo="{ value }"></div>"#,
1606 ),
1607 ];
1608 for (input, expected) in &cases {
1609 let output = convert_btr_to_fast(input);
1610 assert_eq!(&output, expected, "normalization failed for input: {input}");
1611 }
1612 }
1613
1614 #[test]
1615 fn test_non_js_component_inner_template() {
1616 let mut plugin = FastV3ParserPlugin::new();
1617 let html = r#"<div><child-comp><template shadowrootmode="open"><p>inner</p></template></child-comp></div>"#;
1620 let comp = make_component("parent-comp", html, None);
1621 plugin
1622 .register_component_template("parent-comp", &comp, &comp.html_content)
1623 .unwrap();
1624
1625 let templates = plugin.take_component_templates();
1626 assert_eq!(templates.len(), 1);
1627 let name = &templates[0].tag_name;
1628 let output = &templates[0].template;
1629 assert_eq!(name, "parent-comp");
1630
1631 assert!(output.contains("<f-template name=\"parent-comp\">"));
1632 assert!(!output.contains("shadowrootmode"));
1634 assert!(output.contains("<child-comp><template><p>inner</p></template></child-comp>"));
1636 }
1637
1638 #[test]
1639 fn route_tags_converted_to_f_route() {
1640 let input = r#"<route path="/home" name="home" exact><span>Home</span></route>"#;
1641 let output = convert_btr_to_fast(input);
1642 assert!(output.starts_with("<webui-route"));
1643 assert!(output.contains(r#"path="/home""#));
1644 assert!(output.contains(r#"name="home""#));
1645 assert!(output.contains(" exact"));
1646 assert!(output.contains("style=\"display:none\""));
1647 assert!(output.contains("<span>Home</span>"));
1648 assert!(output.ends_with("</webui-route>"));
1649 }
1650
1651 #[test]
1652 fn f_route_tags_pass_through() {
1653 let input = r#"<webui-route path="/" name="dashboard" component="cb-page-dashboard" exact style="display:none"></webui-route>"#;
1655 let output = convert_btr_to_fast(input);
1656 assert_eq!(output, input);
1657 }
1658
1659 #[test]
1664 fn ftemplate_for_loop_converted_to_f_repeat() {
1665 let mut plugin = FastV3ParserPlugin::new();
1666 let html = r#"<ul><for each="item in items"><li>{{item.name}}</li></for></ul>"#;
1667 let comp = make_component("my-list", html, None);
1668 plugin
1669 .register_component_template("my-list", &comp, &comp.html_content)
1670 .unwrap();
1671
1672 let templates = plugin.take_component_templates();
1673 let result = &templates[0].template;
1674
1675 assert!(
1676 result.contains("<f-repeat value=\"{{item in items}}\">"),
1677 "f-template should use <f-repeat>, got: {result}"
1678 );
1679 assert!(
1680 result.contains("</f-repeat>"),
1681 "f-template should close with </f-repeat>, got: {result}"
1682 );
1683 assert!(
1684 !result.contains("<for "),
1685 "f-template should NOT contain <for>, got: {result}"
1686 );
1687 }
1688
1689 #[test]
1690 fn ftemplate_shadow_dom_strips_shadowroot_and_converts_directives() {
1691 let mut plugin = FastV3ParserPlugin::new();
1692 let html = r#"<template shadowrootmode="open"><div><if condition="visible">Shown</if><for each="x in list"><span>{{x}}</span></for></div></template>"#;
1693 let comp = make_component("my-shadow", html, None);
1694 plugin
1695 .register_component_template("my-shadow", &comp, &comp.html_content)
1696 .unwrap();
1697
1698 let templates = plugin.take_component_templates();
1699 let result = &templates[0].template;
1700
1701 assert!(
1702 result.contains("<f-when value=\"{{visible}}\">"),
1703 "Shadow f-template should contain <f-when>, got: {result}"
1704 );
1705 assert!(
1706 result.contains("<f-repeat value=\"{{x in list}}\">"),
1707 "Shadow f-template should contain <f-repeat>, got: {result}"
1708 );
1709 assert!(
1710 result.contains(r#"<f-template name="my-shadow" shadowrootmode="open">"#),
1711 "shadowrootmode should be hoisted onto the <f-template> wrapper, got: {result}"
1712 );
1713 assert!(
1714 !result.contains("<template shadowrootmode"),
1715 "inner <template> should not keep shadowrootmode, got: {result}"
1716 );
1717 assert!(
1718 !result.contains("<if "),
1719 "Shadow f-template should NOT contain <if>, got: {result}"
1720 );
1721 assert!(
1722 !result.contains("<for "),
1723 "Shadow f-template should NOT contain <for>, got: {result}"
1724 );
1725 }
1726
1727 #[test]
1728 fn ftemplate_nested_if_and_for_both_converted() {
1729 let mut plugin = FastV3ParserPlugin::new();
1730 let html =
1731 r#"<div><if condition="show"><for each="x in items"><p>{{x}}</p></for></if></div>"#;
1732 let comp = make_component("my-nested", html, None);
1733 plugin
1734 .register_component_template("my-nested", &comp, &comp.html_content)
1735 .unwrap();
1736
1737 let templates = plugin.take_component_templates();
1738 let result = &templates[0].template;
1739
1740 assert!(
1741 result.contains("<f-when value=\"{{show}}\">"),
1742 "Nested f-template should contain <f-when>, got: {result}"
1743 );
1744 assert!(
1745 result.contains("<f-repeat value=\"{{x in items}}\">"),
1746 "Nested f-template should contain <f-repeat>, got: {result}"
1747 );
1748 assert!(
1749 !result.contains("<if "),
1750 "Nested f-template should NOT contain <if>, got: {result}"
1751 );
1752 assert!(
1753 !result.contains("<for "),
1754 "Nested f-template should NOT contain <for>, got: {result}"
1755 );
1756 }
1757
1758 #[test]
1759 fn convert_btr_preserves_utf8_text_content() {
1760 let input = "<span>✓ passed</span>";
1761 let output = convert_btr_to_fast(input);
1762 assert_eq!(output, "<span>✓ passed</span>");
1763 }
1764
1765 #[test]
1766 fn convert_btr_preserves_utf8_multibyte_chars() {
1767 let input = "<span>✓ yes ✗ no ⭐ star</span>";
1768 let output = convert_btr_to_fast(input);
1769 assert_eq!(output, "<span>✓ yes ✗ no ⭐ star</span>");
1770 }
1771
1772 #[test]
1773 fn convert_btr_preserves_utf8_with_directives() {
1774 let input = r#"<if condition="x"><span>✓</span></if>"#;
1775 let output = convert_btr_to_fast(input);
1776 assert_eq!(output, r#"<f-when value="{{x}}"><span>✓</span></f-when>"#);
1777 }
1778
1779 #[test]
1780 fn minify_preserves_utf8_text_content() {
1781 let input = "<span>✓</span><span>✗</span>";
1782 let output = minify_inter_tag_whitespace(input);
1783 assert_eq!(output, "<span>✓</span><span>✗</span>");
1784 }
1785
1786 #[test]
1787 fn minify_preserves_utf8_between_elements() {
1788 let input = "<div> ✓ </div>";
1789 let output = minify_inter_tag_whitespace(input);
1790 assert_eq!(output, "<div> ✓ </div>");
1791 }
1792
1793 #[test]
1794 fn convert_complex_attrs_preserves_utf8() {
1795 let input = r#"<span :title="{{label}}" data-icon="⭐">text</span>"#;
1796 let output = convert_btr_to_fast(input);
1797 assert!(
1798 output.contains("⭐"),
1799 "UTF-8 star should be preserved: {output}"
1800 );
1801 }
1802}