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