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