1use super::super::{
11 AttributeAction, AttributeContext, ComponentBuildContext, ComponentProcessing,
12 ComponentStyleDelivery, ComponentTemplateArtifact, ElementStartContext, ParserPlugin,
13 ParserPluginArtifacts,
14};
15use super::v3::{
16 build_f_template, f_template_style_injection, require_fast_shadow_dom, style_injection_snippet,
17 FastTemplateStyle,
18};
19#[cfg(test)]
20use crate::component_registry::Component;
21use crate::html_parser::{find_element_end, find_tag_close, opening_tag_name};
22use crate::{CssLinkOptions, CssStrategy, Result};
23
24struct TrackedComponent {
26 tag_name: String,
27 template_html: String,
28 uses_shadow_dom: bool,
29 style_injection: Option<String>,
35 styles_at_end: bool,
36}
37
38pub struct FastV2ParserPlugin {
46 components: Vec<TrackedComponent>,
48}
49
50impl FastV2ParserPlugin {
51 #[must_use]
53 pub fn new() -> Self {
54 Self {
55 components: Vec::new(),
56 }
57 }
58
59 #[must_use]
65 pub fn take_component_templates(&self) -> Vec<ComponentTemplateArtifact> {
66 self.components
67 .iter()
68 .map(|comp| {
69 let tmpl = build_f_template(
70 &comp.tag_name,
71 &convert_btr_to_fast(&comp.template_html),
72 &super::shared::hoisted_shadow_options(&comp.template_html),
73 FastTemplateStyle {
74 injection: comp.style_injection.as_deref(),
75 module_specifier: None,
76 at_end: comp.styles_at_end,
77 },
78 );
79 ComponentTemplateArtifact::template(
80 comp.tag_name.clone(),
81 tmpl,
82 comp.uses_shadow_dom,
83 )
84 })
85 .collect()
86 }
87
88 fn track_component(&mut self, context: ComponentBuildContext<'_>) {
89 let tag_name = context.component.tag_name.as_str();
90 if self.components.iter().any(|c| c.tag_name == tag_name) {
91 return;
92 }
93 self.components.push(TrackedComponent {
94 tag_name: tag_name.to_string(),
95 template_html: context.template.to_string(),
96 uses_shadow_dom: context.uses_shadow_dom,
97 style_injection: context.style.and_then(style_injection_snippet),
98 styles_at_end: matches!(context.style, Some(ComponentStyleDelivery::Inline { .. })),
99 });
100 }
101
102 #[cfg(test)]
103 fn register_component_template(
104 &mut self,
105 _tag_name: &str,
106 component: &Component,
107 processed_template: &str,
108 ) -> Result<()> {
109 <Self as ParserPlugin>::component_built(
110 self,
111 ComponentBuildContext {
112 component,
113 template: processed_template,
114 uses_shadow_dom: true,
115 style: None,
116 },
117 )
118 }
119
120 #[cfg(test)]
121 fn classify_attribute(&mut self, name: &str) -> AttributeAction {
122 <Self as ParserPlugin>::process_attribute(self, AttributeContext { name })
123 }
124
125 #[cfg(test)]
126 fn finish_element(&mut self, binding_count: u32) -> Option<Vec<u8>> {
127 <Self as ParserPlugin>::finish_opening_tag(
128 self,
129 ElementStartContext {
130 tag_name: "test-element",
131 binding_count,
132 },
133 )
134 }
135}
136
137impl Default for FastV2ParserPlugin {
138 fn default() -> Self {
139 Self::new()
140 }
141}
142
143impl ParserPlugin for FastV2ParserPlugin {
144 fn component_processing(&self) -> ComponentProcessing {
145 ComponentProcessing {
146 source_transform: Some(super::shared::transform_component_source),
147 process_root_template_attributes: true,
148 inline_styles_after_content: true,
149 }
150 }
151
152 fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
153 require_fast_shadow_dom(context.component.tag_name.as_str(), context.uses_shadow_dom)?;
154 self.track_component(context);
155 Ok(())
156 }
157
158 fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
159 super::shared::classify_attribute(context.name)
160 }
161
162 fn finish_opening_tag(&mut self, context: ElementStartContext<'_>) -> Option<Vec<u8>> {
163 super::shared::finish_element(context.binding_count)
164 }
165
166 fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
167 Ok(ParserPluginArtifacts::ComponentTemplates(
168 self.take_component_templates(),
169 ))
170 }
171}
172
173pub fn generate_f_template(
182 tag_name: &str,
183 html_content: &str,
184 css_content: Option<&str>,
185 css_strategy: CssStrategy,
186) -> String {
187 generate_f_template_with_css_options(
188 tag_name,
189 html_content,
190 css_content,
191 css_strategy,
192 &CssLinkOptions::default(),
193 )
194}
195
196#[cfg(test)]
197fn generate_f_template_from_processed(tag_name: &str, processed_template: &str) -> String {
198 build_f_template(
199 tag_name,
200 &convert_btr_to_fast(processed_template),
201 &super::shared::hoisted_shadow_options(processed_template),
202 FastTemplateStyle::default(),
203 )
204}
205
206pub fn generate_f_template_with_css_options(
208 tag_name: &str,
209 html_content: &str,
210 css_content: Option<&str>,
211 css_strategy: CssStrategy,
212 css_link_options: &CssLinkOptions,
213) -> String {
214 let (css_injection, module_specifier) =
215 f_template_style_injection(tag_name, css_content, css_strategy, css_link_options);
216
217 build_f_template(
218 tag_name,
219 &convert_btr_to_fast(html_content),
220 &super::shared::hoisted_shadow_options(html_content),
221 FastTemplateStyle {
222 injection: css_injection.as_deref(),
223 module_specifier,
224 at_end: css_strategy == CssStrategy::Style,
225 },
226 )
227}
228
229fn convert_btr_to_fast(input: &str) -> String {
231 let mut result = String::with_capacity(input.len());
232 let bytes = input.as_bytes();
233 let len = bytes.len();
234 let mut i = 0;
235
236 while i < len {
237 if bytes[i] == b'<' {
238 if let Some(consumed) = try_convert_tag(input, i, &mut result) {
239 i += consumed;
240 continue;
241 }
242 }
243
244 i = push_char_at(input, i, &mut result);
248 }
249
250 result
251}
252
253fn try_convert_tag(input: &str, pos: usize, result: &mut String) -> Option<usize> {
256 let remaining = &input[pos..];
257
258 if remaining.starts_with("</if>") {
260 result.push_str("</f-when>");
261 return Some(5);
262 }
263 if remaining.starts_with("</for>") {
264 result.push_str("</f-repeat>");
265 return Some(6);
266 }
267 if remaining.starts_with("</route>") {
269 result.push_str("</webui-route>");
270 return Some(8);
271 }
272
273 if let Some(consumed) = find_element_end(remaining, "outlet") {
276 result.push_str("<outlet></outlet>");
277 return Some(consumed);
278 }
279
280 if starts_with_tag_name(remaining, "if") {
282 if let Some(consumed) = convert_if_tag(remaining, result) {
283 return Some(consumed);
284 }
285 }
286
287 if starts_with_tag_name(remaining, "for") {
289 if let Some(consumed) = convert_for_tag(remaining, result) {
290 return Some(consumed);
291 }
292 }
293
294 if starts_with_tag_name(remaining, "route") {
296 if let Some(consumed) = convert_route_tag(remaining, result) {
297 return Some(consumed);
298 }
299 }
300
301 if remaining.starts_with("<") {
303 if starts_with_tag_name(remaining, "template") {
305 if let Some(consumed) = super::shared::strip_hoisted_shadow_options(remaining, result) {
306 return Some(consumed);
307 }
308 }
309 if let Some(consumed) = convert_complex_attrs(remaining, result) {
310 return Some(consumed);
311 }
312 }
313
314 None
315}
316
317fn starts_with_tag_name(s: &str, name: &str) -> bool {
319 opening_tag_name(s).is_some_and(|tag_name| tag_name.eq_ignore_ascii_case(name))
320}
321
322fn convert_if_tag(tag_str: &str, result: &mut String) -> Option<usize> {
325 let close = find_tag_close(tag_str)?;
327 let tag_content = &tag_str[..=close];
328
329 let attr_value = extract_attribute_value(tag_content, "condition")?;
331
332 result.push_str("<f-when value=\"{{");
333 result.push_str(attr_value);
334 result.push_str("}}\">");
335
336 Some(close + 1)
337}
338
339fn convert_for_tag(tag_str: &str, result: &mut String) -> Option<usize> {
342 let close = find_tag_close(tag_str)?;
344 let tag_content = &tag_str[..=close];
345
346 let attr_value = extract_attribute_value(tag_content, "each")?;
348
349 result.push_str("<f-repeat value=\"{{");
350 result.push_str(attr_value);
351 result.push_str("}}\">");
352
353 Some(close + 1)
354}
355
356fn convert_route_tag(tag_str: &str, result: &mut String) -> Option<usize> {
360 let close = find_tag_close(tag_str)?;
361 let is_self_closing = tag_str[..=close].ends_with("/>");
362 let tag = &tag_str[..=close];
363
364 result.push_str("<webui-route");
365
366 if let Some(v) = extract_attribute_value(tag, "path") {
367 result.push_str(" path=\"");
368 result.push_str(v);
369 result.push('"');
370 }
371 if let Some(v) = extract_attribute_value(tag, "name") {
372 result.push_str(" name=\"");
373 result.push_str(v);
374 result.push('"');
375 }
376 if let Some(v) = extract_attribute_value(tag, "component") {
377 result.push_str(" component=\"");
378 result.push_str(v);
379 result.push('"');
380 }
381 if let Some(v) = extract_attribute_value(tag, "redirectTo") {
382 result.push_str(" redirectto=\"");
383 result.push_str(v);
384 result.push('"');
385 }
386 if tag.contains(" exact") {
387 result.push_str(" exact");
388 }
389 if tag.contains(" layout") {
390 result.push_str(" layout");
391 }
392 if let Some(v) = extract_attribute_value(tag, "query") {
393 result.push_str(" query=\"");
394 result.push_str(v);
395 result.push('"');
396 }
397 result.push_str(" style=\"display:none\"");
398
399 if is_self_closing {
400 result.push_str("></webui-route>");
401 } else {
402 result.push('>');
403 }
404
405 Some(close + 1)
406}
407
408fn extract_attribute_value<'a>(tag: &'a str, attr_name: &str) -> Option<&'a str> {
411 let mut search_from = 0;
413 loop {
414 let attr_pos = tag[search_from..].find(attr_name)?;
415 let abs_pos = search_from + attr_pos;
416
417 if abs_pos > 0 {
419 let prev = tag.as_bytes()[abs_pos - 1];
420 if prev != b' ' && prev != b'\t' && prev != b'\n' && prev != b'\r' {
421 search_from = abs_pos + attr_name.len();
422 continue;
423 }
424 }
425
426 let after_name = &tag[abs_pos + attr_name.len()..];
427 if !after_name.starts_with('=') {
428 search_from = abs_pos + attr_name.len();
429 continue;
430 }
431
432 let after_eq = &after_name[1..];
433 if after_eq.starts_with('"') {
434 let value_start = abs_pos + attr_name.len() + 2; let end_quote = tag[value_start..].find('"')?;
436 return Some(&tag[value_start..value_start + end_quote]);
437 }
438
439 return None;
440 }
441}
442
443fn convert_complex_attrs(tag_str: &str, result: &mut String) -> Option<usize> {
447 let close = find_tag_close(tag_str)?;
449 let tag_content = &tag_str[..=close];
450
451 if !tag_content.contains(":") || !tag_content.contains("{{") {
453 return None;
454 }
455
456 let mut converted = String::with_capacity(tag_content.len());
458 let tag_bytes = tag_content.as_bytes();
459 let tag_len = tag_bytes.len();
460 let mut j = 0;
461 let mut in_colon_attr_value = false;
462
463 while j < tag_len {
464 if !in_colon_attr_value {
465 if tag_bytes[j] == b':' && j > 0 && is_whitespace(tag_bytes[j - 1]) {
467 let attr_start = j;
469 j += 1;
470 while j < tag_len && tag_bytes[j] != b'=' && !is_whitespace(tag_bytes[j]) {
472 j += 1;
473 }
474 if j < tag_len && tag_bytes[j] == b'=' {
475 j += 1;
476 if j < tag_len && tag_bytes[j] == b'"' {
477 converted.push_str(&tag_content[attr_start..=j]);
479 j += 1;
480 in_colon_attr_value = true;
481 continue;
482 }
483 }
484 converted.push_str(&tag_content[attr_start..j]);
486 continue;
487 }
488 j = push_char_at(tag_content, j, &mut converted);
489 } else {
490 if tag_bytes[j] == b'"' {
492 converted.push('"');
494 j += 1;
495 in_colon_attr_value = false;
496 } else if j + 1 < tag_len && tag_bytes[j] == b'{' && tag_bytes[j + 1] == b'{' {
497 let expr_start = j + 2;
499 if let Some(end_offset) = tag_content[expr_start..].find("}}") {
500 let expr = &tag_content[expr_start..expr_start + end_offset];
501 converted.push('{');
502 converted.push_str(expr);
503 converted.push('}');
504 j = expr_start + end_offset + 2;
505 } else {
506 converted.push('{');
508 converted.push('{');
509 j += 2;
510 }
511 } else {
512 j = push_char_at(tag_content, j, &mut converted);
513 }
514 }
515 }
516
517 result.push_str(&converted);
518 Some(close + 1)
519}
520
521fn push_char_at(input: &str, pos: usize, out: &mut String) -> usize {
524 let bytes = input.as_bytes();
525 if bytes[pos].is_ascii() {
526 out.push(bytes[pos] as char);
527 pos + 1
528 } else {
529 let mut end = pos + 1;
530 while end < bytes.len() && !input.is_char_boundary(end) {
531 end += 1;
532 }
533 out.push_str(&input[pos..end]);
534 end
535 }
536}
537
538fn is_whitespace(b: u8) -> bool {
540 b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'
541}
542
543#[cfg(test)]
544mod tests {
545 #![allow(clippy::disallowed_methods)]
546
547 use super::*;
548 fn make_component(tag: &str, html: &str, css: Option<&str>) -> Component {
549 Component {
550 tag_name: tag.to_string(),
551 html_content: html.to_string(),
552 css_content: css.map(ToString::to_string),
553 css_definitions: Vec::new(),
554 css_fallback_chains: Vec::new(),
555 is_client_owned: false,
556 }
557 }
558
559 #[test]
562 fn skip_event_binding() {
563 let mut plugin = FastV2ParserPlugin::new();
564 assert_eq!(
565 plugin.classify_attribute("@click"),
566 AttributeAction::SkipAndCountBinding
567 );
568 assert_eq!(
569 plugin.classify_attribute("@input"),
570 AttributeAction::SkipAndCountBinding
571 );
572 assert_eq!(
573 plugin.classify_attribute("@custom-event"),
574 AttributeAction::SkipAndCountBinding
575 );
576 }
577
578 #[test]
579 fn skip_f_ref() {
580 let mut plugin = FastV2ParserPlugin::new();
581 assert_eq!(
582 plugin.classify_attribute("f-ref"),
583 AttributeAction::SkipAndCountBinding
584 );
585 }
586
587 #[test]
588 fn skip_f_slotted() {
589 let mut plugin = FastV2ParserPlugin::new();
590 assert_eq!(
591 plugin.classify_attribute("f-slotted"),
592 AttributeAction::SkipAndCountBinding
593 );
594 }
595
596 #[test]
597 fn skip_f_children() {
598 let mut plugin = FastV2ParserPlugin::new();
599 assert_eq!(
600 plugin.classify_attribute("f-children"),
601 AttributeAction::SkipAndCountBinding
602 );
603 }
604
605 #[test]
606 fn skip_fast_property_binding() {
607 let mut plugin = FastV2ParserPlugin::new();
608 assert_eq!(
609 plugin.classify_attribute(":title"),
610 AttributeAction::SkipAndCountBinding
611 );
612 assert_eq!(
613 plugin.classify_attribute(":config"),
614 AttributeAction::SkipAndCountBinding
615 );
616 }
617
618 #[test]
619 fn do_not_skip_normal_attributes() {
620 let mut plugin = FastV2ParserPlugin::new();
621 assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
622 assert_eq!(plugin.classify_attribute("id"), AttributeAction::Keep);
623 assert_eq!(
624 plugin.classify_attribute("data-value"),
625 AttributeAction::Keep
626 );
627 assert_eq!(plugin.classify_attribute("f-other"), AttributeAction::Keep);
628 }
629
630 #[test]
633 fn element_parsed_zero_count_returns_none() {
634 let mut plugin = FastV2ParserPlugin::new();
635 assert!(plugin.finish_element(0).is_none());
636 }
637
638 #[test]
639 fn element_parsed_nonzero_count_returns_le_bytes() {
640 let mut plugin = FastV2ParserPlugin::new();
641 let data = plugin.finish_element(3);
642 assert!(data.is_some());
643 let bytes = data.as_deref().unwrap_or_default();
644 assert_eq!(bytes, &3u32.to_le_bytes());
645 }
646
647 #[test]
648 fn element_parsed_large_count() {
649 let mut plugin = FastV2ParserPlugin::new();
650 let data = plugin.finish_element(256);
651 assert!(data.is_some());
652 let bytes = data.as_deref().unwrap_or_default();
653 assert_eq!(bytes, &256u32.to_le_bytes());
654 }
655
656 #[test]
657 fn component_template_simple_component() {
658 let mut plugin = FastV2ParserPlugin::new();
659 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
660 plugin
661 .register_component_template(
662 "my-comp",
663 &comp,
664 r#"<template><link rel="stylesheet" href="my-comp.css"><div>hello</div></template>"#,
665 )
666 .unwrap();
667
668 let templates = plugin.take_component_templates();
669 assert_eq!(templates.len(), 1);
670 let name = &templates[0].tag_name;
671 let html = &templates[0].template;
672 assert_eq!(name, "my-comp");
673 assert!(html.contains("<f-template name=\"my-comp\">"));
674 assert!(html.contains("</f-template>"));
675 assert!(html.contains("<template>"));
676 assert!(html.contains("</template>"));
677 assert!(html.contains("<link rel=\"stylesheet\" href=\"my-comp.css\">"));
678 assert!(html.contains("<div>hello</div>"));
679 }
680
681 #[test]
682 fn component_template_without_css() {
683 let mut plugin = FastV2ParserPlugin::new();
684 let comp = make_component("no-css", "<span>text</span>", None);
685 plugin
686 .register_component_template("no-css", &comp, &comp.html_content)
687 .unwrap();
688
689 let templates = plugin.take_component_templates();
690 assert_eq!(templates.len(), 1);
691 let name = &templates[0].tag_name;
692 let html = &templates[0].template;
693 assert_eq!(name, "no-css");
694 assert!(html.contains("<f-template name=\"no-css\">"));
695 assert!(!html.contains("<link rel=\"stylesheet\""));
696 assert!(html.contains("<span>text</span>"));
697 }
698
699 #[test]
700 fn direct_template_generation_skips_leading_comments() {
701 let html = generate_f_template(
702 "my-comp",
703 concat!(
704 "<!-- Copyright (C) Corporation. All rights reserved. -->\n",
705 "<template shadowrootmode=\"open\"><h1>Hello</h1></template>",
706 ),
707 None,
708 CssStrategy::Link,
709 );
710
711 assert_eq!(html.matches("<template>").count(), 1);
712 assert!(!html.contains("<!--"));
713 assert!(html.contains("<template><h1>Hello</h1></template>"));
714 }
715
716 #[test]
717 fn component_template_css_strategy_style() {
718 let mut plugin = FastV2ParserPlugin::new();
719 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
720 plugin
721 .register_component_template(
722 "my-comp",
723 &comp,
724 r#"<template><style>div { color: red; }</style><div>hello</div></template>"#,
725 )
726 .unwrap();
727
728 let templates = plugin.take_component_templates();
729 assert_eq!(templates.len(), 1);
730 let html = &templates[0].template;
731 assert!(
732 html.contains("<style>div { color: red; }</style>"),
733 "Style strategy should inline CSS, got: {html}"
734 );
735 assert!(
736 !html.contains("<link"),
737 "Style strategy should not emit <link> tags"
738 );
739 }
740
741 #[test]
742 fn component_template_css_strategy_module() {
743 let mut plugin = FastV2ParserPlugin::new();
744 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
745 plugin
746 .register_component_template(
747 "my-comp",
748 &comp,
749 r#"<template shadowrootadoptedstylesheets="my-comp"><div>hello</div></template>"#,
750 )
751 .unwrap();
752
753 let templates = plugin.take_component_templates();
754 assert_eq!(templates.len(), 1);
755 let html = &templates[0].template;
756
757 assert!(
760 !html.contains(r#"<style type="module""#),
761 "Legacy CSS module shape should not be baked into f-template: {html}"
762 );
763 assert!(
764 !html.contains(r#"<script type="importmap""#),
765 "CSS module importmap should not be baked into f-template: {html}"
766 );
767 assert!(
769 html.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
770 "Module strategy should add shadowrootadoptedstylesheets, got: {html}"
771 );
772 assert!(
774 !html.contains("<style>div"),
775 "Module strategy should not have inline <style> inside template"
776 );
777 assert!(
778 !html.contains("<link"),
779 "Module strategy should not emit <link> tags"
780 );
781 }
782
783 #[test]
784 fn component_template_css_strategy_module_no_css() {
785 let mut plugin = FastV2ParserPlugin::new();
786 let comp = make_component("my-comp", "<div>hello</div>", None);
787 plugin
788 .register_component_template(
789 "my-comp",
790 &comp,
791 r#"<template><div>hello</div></template>"#,
792 )
793 .unwrap();
794
795 let templates = plugin.take_component_templates();
796 assert_eq!(templates.len(), 1);
797 let html = &templates[0].template;
798 assert!(
799 !html.contains("shadowrootadoptedstylesheets"),
800 "No CSS = no shadowrootadoptedstylesheets, got: {html}"
801 );
802 assert!(
803 !html.contains("<style"),
804 "No CSS = no style tag, got: {html}"
805 );
806 }
807
808 #[test]
809 fn component_template_multiple_components() {
810 let mut plugin = FastV2ParserPlugin::new();
811 let comp1 = make_component("comp-a", "<div>A</div>", None);
812 let comp2 = make_component("comp-b", "<div>B</div>", Some("b { }"));
813 plugin
814 .register_component_template("comp-a", &comp1, &comp1.html_content)
815 .unwrap();
816 plugin
817 .register_component_template("comp-b", &comp2, &comp2.html_content)
818 .unwrap();
819
820 let templates = plugin.take_component_templates();
821 assert_eq!(templates.len(), 2);
822
823 let names: Vec<&str> = templates
824 .iter()
825 .map(|artifact| artifact.tag_name.as_str())
826 .collect();
827 assert!(names.contains(&"comp-a"));
828 assert!(names.contains(&"comp-b"));
829
830 for artifact in &templates {
831 assert!(artifact.template.contains("<f-template name="));
832 }
833 }
834
835 #[test]
836 fn component_template_deduplicates_same_component() {
837 let mut plugin = FastV2ParserPlugin::new();
838 let comp = make_component(
839 "my-button",
840 "<button><slot></slot></button>",
841 Some(".btn{}"),
842 );
843
844 plugin
847 .register_component_template("my-button", &comp, &comp.html_content)
848 .unwrap();
849 plugin
850 .register_component_template("my-button", &comp, &comp.html_content)
851 .unwrap();
852 plugin
853 .register_component_template("my-button", &comp, &comp.html_content)
854 .unwrap();
855
856 let templates = plugin.take_component_templates();
857 assert_eq!(
858 templates.len(),
859 1,
860 "Expected exactly 1 template for my-button, got {}",
861 templates.len()
862 );
863 assert_eq!(templates[0].tag_name, "my-button");
864 }
865
866 #[test]
867 fn component_template_deduplicates_mixed_components() {
868 let mut plugin = FastV2ParserPlugin::new();
869 let btn = make_component("my-button", "<button><slot></slot></button>", None);
870 let card = make_component("my-card", "<div><slot></slot></div>", Some(".card{}"));
871
872 plugin
874 .register_component_template("my-button", &btn, &btn.html_content)
875 .unwrap();
876 plugin
877 .register_component_template("my-card", &card, &card.html_content)
878 .unwrap();
879 plugin
880 .register_component_template("my-button", &btn, &btn.html_content)
881 .unwrap();
882
883 let templates = plugin.take_component_templates();
884 assert_eq!(templates.len(), 2);
885
886 let button_count = templates
887 .iter()
888 .filter(|artifact| artifact.tag_name == "my-button")
889 .count();
890 let card_count = templates
891 .iter()
892 .filter(|artifact| artifact.tag_name == "my-card")
893 .count();
894 assert_eq!(button_count, 1);
895 assert_eq!(card_count, 1);
896 }
897
898 #[test]
901 fn convert_if_to_f_when() {
902 let input = r#"<if condition="isComplete"><span>Done</span></if>"#;
903 let output = convert_btr_to_fast(input);
904 assert_eq!(
905 output,
906 r#"<f-when value="{{isComplete}}"><span>Done</span></f-when>"#
907 );
908 }
909
910 #[test]
911 fn convert_if_with_greater_than() {
912 let input = r#"<if condition="vara > 2">Over two</if>"#;
913 let output = convert_btr_to_fast(input);
914 assert_eq!(output, r#"<f-when value="{{vara > 2}}">Over two</f-when>"#);
915 }
916
917 #[test]
918 fn convert_if_with_greater_than_or_equal() {
919 let input = r#"<if condition="vara >= 2">Two and over</if>"#;
920 let output = convert_btr_to_fast(input);
921 assert_eq!(
922 output,
923 r#"<f-when value="{{vara >= 2}}">Two and over</f-when>"#
924 );
925 }
926
927 #[test]
928 fn convert_if_with_less_than() {
929 let input = r#"<if condition="x < 10">small</if>"#;
930 let output = convert_btr_to_fast(input);
931 assert_eq!(output, r#"<f-when value="{{x < 10}}">small</f-when>"#);
932 }
933
934 #[test]
935 fn convert_for_to_f_repeat() {
936 let input = r#"<for each="tag in tags"><span>{{tag}}</span></for>"#;
937 let output = convert_btr_to_fast(input);
938 assert_eq!(
939 output,
940 r#"<f-repeat value="{{tag in tags}}"><span>{{tag}}</span></f-repeat>"#
941 );
942 }
943
944 #[test]
945 fn convert_nested_if_and_for() {
946 let input = r#"<if condition="show"><for each="x in items"><p>{{x}}</p></for></if>"#;
947 let output = convert_btr_to_fast(input);
948 assert_eq!(
949 output,
950 r#"<f-when value="{{show}}"><f-repeat value="{{x in items}}"><p>{{x}}</p></f-repeat></f-when>"#
951 );
952 }
953
954 #[test]
955 fn convert_complex_attr_double_braces() {
956 let input = r#"<div :data="{{config}}"></div>"#;
957 let output = convert_btr_to_fast(input);
958 assert_eq!(output, r#"<div :data="{config}"></div>"#);
959 }
960
961 #[test]
962 fn convert_multiple_complex_attrs() {
963 let input = r#"<div :title="{{name}}" :data-id="{{id}}"></div>"#;
964 let output = convert_btr_to_fast(input);
965 assert_eq!(output, r#"<div :title="{name}" :data-id="{id}"></div>"#);
966 }
967
968 #[test]
969 fn no_conversion_for_text_double_braces() {
970 let input = "<span>{{greeting}}</span>";
972 let output = convert_btr_to_fast(input);
973 assert_eq!(output, "<span>{{greeting}}</span>");
974 }
975
976 #[test]
977 fn no_conversion_for_regular_attr() {
978 let input = r#"<div title="{{name}}"></div>"#;
980 let output = convert_btr_to_fast(input);
981 assert_eq!(output, r#"<div title="{{name}}"></div>"#);
982 }
983
984 #[test]
985 fn component_template_with_btr_conversion() {
986 let mut plugin = FastV2ParserPlugin::new();
987 let html = r#"<div><if condition="visible"><span>hi</span></if></div>"#;
988 let comp = make_component("my-widget", html, None);
989 plugin
990 .register_component_template("my-widget", &comp, &comp.html_content)
991 .unwrap();
992
993 let templates = plugin.take_component_templates();
994 assert_eq!(templates.len(), 1);
995 let name = &templates[0].tag_name;
996 let result = &templates[0].template;
997 assert_eq!(name, "my-widget");
998
999 assert!(result.contains("<f-when value=\"{{visible}}\">"));
1000 assert!(result.contains("</f-when>"));
1001 assert!(!result.contains("<if condition="));
1002 assert!(!result.contains("</if>"));
1003 }
1004
1005 #[test]
1006 fn passthrough_normal_html() {
1007 let input = r#"<div class="container"><p>Hello</p></div>"#;
1008 let output = convert_btr_to_fast(input);
1009 assert_eq!(output, input);
1010 }
1011
1012 #[test]
1013 fn outlet_kept_as_marker_in_f_template() {
1014 for input in [
1015 r#"<div><outlet /></div>"#,
1016 r#"<div><outlet></outlet></div>"#,
1017 ] {
1018 let output = convert_btr_to_fast(input);
1019 assert_eq!(output, r#"<div><outlet></outlet></div>"#);
1020 }
1021 }
1022
1023 #[test]
1024 fn outlet_marker_in_component_template() {
1025 let mut plugin = FastV2ParserPlugin::new();
1026 let html =
1027 r#"<template shadowrootmode="open"><h1>Title</h1><main><outlet /></main></template>"#;
1028 let comp = make_component("my-shell", html, None);
1029 plugin
1030 .register_component_template("my-shell", &comp, &comp.html_content)
1031 .unwrap();
1032
1033 let templates = plugin.take_component_templates();
1034 let result = &templates[0].template;
1035 assert!(
1036 result.contains("<outlet></outlet>"),
1037 "outlet should be kept as marker in f-template: {result}"
1038 );
1039 }
1040
1041 #[test]
1042 fn strip_shadowrootmode_from_template() {
1043 let input = r#"<template shadowrootmode="open"><div>Hello</div></template>"#;
1044 let output = convert_btr_to_fast(input);
1045 assert_eq!(output, "<template><div>Hello</div></template>");
1046 }
1047
1048 #[test]
1049 fn strips_uppercase_native_shadow_wrapper() {
1050 let input = r#"<TEMPLATE SHADOWROOTMODE="open"><div>Hello</div></TEMPLATE>"#;
1051 let output = convert_btr_to_fast(input);
1052 assert_eq!(output, "<TEMPLATE><div>Hello</div></TEMPLATE>");
1053 }
1054
1055 #[test]
1056 fn rejects_effective_light_dom() {
1057 let mut plugin = FastV2ParserPlugin::new();
1058 let component = make_component("my-card", "<p>content</p>", None);
1059 let error = <FastV2ParserPlugin as ParserPlugin>::component_built(
1060 &mut plugin,
1061 ComponentBuildContext {
1062 component: &component,
1063 template: component.html_content.as_str(),
1064 uses_shadow_dom: false,
1065 style: None,
1066 },
1067 )
1068 .expect_err("FAST cannot mount Light DOM faithfully");
1069 assert!(matches!(
1070 error,
1071 crate::ParserError::Template(ref diagnostic)
1072 if diagnostic.error_code() == Some(crate::diagnostic::codes::FAST_LIGHT_DOM_UNSUPPORTED)
1073 ));
1074 }
1075
1076 #[test]
1077 fn strip_shadowrootmode_preserves_other_attrs() {
1078 let input =
1079 r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>Hi</div></template>"#;
1080 let output = convert_btr_to_fast(input);
1081 assert!(output.contains(r#"@click="{onClick(e)}""#));
1082 assert!(!output.contains("shadowrootmode"));
1083 assert!(output.starts_with("<template "));
1084 }
1085
1086 #[test]
1087 fn no_strip_when_no_shadowrootmode() {
1088 let input = r#"<template foo="bar"><div>Hi</div></template>"#;
1089 let output = convert_btr_to_fast(input);
1090 assert_eq!(output, input);
1091 }
1092
1093 #[test]
1094 fn component_template_strips_shadowrootmode_from_f_template() {
1095 let mut plugin = FastV2ParserPlugin::new();
1096 let comp = make_component(
1097 "my-comp",
1098 r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>{{title}}</div></template>"#,
1099 Some("div { color: red; }"),
1100 );
1101 plugin
1102 .register_component_template("my-comp", &comp, &comp.html_content)
1103 .unwrap();
1104 let templates = plugin.take_component_templates();
1105 assert_eq!(templates.len(), 1);
1106 let name = &templates[0].tag_name;
1107 let html = &templates[0].template;
1108 assert_eq!(name, "my-comp");
1109 assert!(html.contains(r#"<f-template name="my-comp" shadowrootmode="open">"#));
1112 assert!(!html.contains("<template shadowrootmode"));
1113 assert!(html.contains("@click"));
1115 assert!(html.contains("{{title}}"));
1117 }
1118
1119 #[test]
1120 fn shadow_options_hoisted_from_inner_template_to_f_template_wrapper() {
1121 let processed = concat!(
1127 r#"<template shadowrootmode="open" shadowrootdelegatesfocus "#,
1128 r#"shadowrootadoptedstylesheets="custom-field" @click="{clickHandler($e)}">"#,
1129 r#"<slot></slot></template>"#,
1130 );
1131 let html = generate_f_template_from_processed("custom-field", processed);
1132 assert!(
1133 html.contains(
1134 r#"<f-template name="custom-field" shadowrootmode="open" shadowrootdelegatesfocus>"#
1135 ),
1136 "wrapper must carry hoisted shadow options: {html}"
1137 );
1138 assert!(html.contains(r#"@click="{clickHandler($e)}""#));
1141 assert!(html.contains(r#"shadowrootadoptedstylesheets="custom-field""#));
1142 assert!(
1143 !html.contains("<template shadowrootmode"),
1144 "inner <template> must not keep shadowrootmode: {html}"
1145 );
1146 assert!(
1147 !html.contains("<template shadowrootdelegatesfocus")
1148 && !html.contains(" shadowrootdelegatesfocus shadowrootadopted"),
1149 "inner <template> must not keep shadowrootdelegatesfocus: {html}"
1150 );
1151 }
1152
1153 #[test]
1154 fn delegatesfocus_only_without_mode_is_hoisted_and_stripped() {
1155 let processed = r#"<template shadowrootdelegatesfocus><slot></slot></template>"#;
1160 let html = generate_f_template_from_processed("custom-field", processed);
1161 assert_eq!(
1162 html.matches("shadowrootdelegatesfocus").count(),
1163 1,
1164 "shadowrootdelegatesfocus must appear exactly once (hoisted, not duplicated): {html}"
1165 );
1166 assert!(
1167 html.contains(r#"<f-template name="custom-field" shadowrootdelegatesfocus>"#),
1168 "wrapper must carry the hoisted option: {html}"
1169 );
1170 assert!(
1171 !html.contains("<template shadowrootdelegatesfocus"),
1172 "inner <template> must not keep shadowrootdelegatesfocus: {html}"
1173 );
1174 }
1175
1176 #[test]
1177 fn clonable_and_serializable_only_are_hoisted_and_stripped() {
1178 let processed =
1179 r#"<template shadowrootclonable shadowrootserializable><slot></slot></template>"#;
1180 let html = generate_f_template_from_processed("custom-field", processed);
1181 for attr in ["shadowrootclonable", "shadowrootserializable"] {
1182 assert_eq!(
1183 html.matches(attr).count(),
1184 1,
1185 "{attr} must appear exactly once (hoisted, not duplicated): {html}"
1186 );
1187 }
1188 assert!(
1189 html.contains(
1190 r#"<f-template name="custom-field" shadowrootclonable shadowrootserializable>"#
1191 ),
1192 "wrapper must carry both hoisted options: {html}"
1193 );
1194 assert!(
1195 !html.contains("<template shadowrootclonable")
1196 && !html.contains("<template shadowrootserializable"),
1197 "inner <template> must not keep either hoisted option: {html}"
1198 );
1199 }
1200
1201 #[test]
1202 fn adoptedstylesheets_only_is_neither_hoisted_nor_stripped() {
1203 let processed =
1204 r#"<template shadowrootadoptedstylesheets="custom-field"><slot></slot></template>"#;
1205 let html = generate_f_template_from_processed("custom-field", processed);
1206 assert!(
1207 html.contains("<f-template name=\"custom-field\">"),
1208 "shadowrootadoptedstylesheets is not a shadow-root-creation option and must stay off the wrapper: {html}"
1209 );
1210 assert!(
1211 html.contains(r#"<template shadowrootadoptedstylesheets="custom-field">"#),
1212 "inner <template> must keep shadowrootadoptedstylesheets: {html}"
1213 );
1214 }
1215
1216 #[test]
1217 fn mixed_shadow_options_each_appear_exactly_once_and_never_on_inner() {
1218 let processed = concat!(
1225 r#"<template shadowrootmode="open" shadowrootdelegatesfocus shadowrootclonable "#,
1226 r#"shadowrootserializable shadowrootadoptedstylesheets="custom-field" "#,
1227 r#"@click="{clickHandler($e)}"><slot></slot></template>"#,
1228 );
1229 let html = generate_f_template_from_processed("custom-field", processed);
1230 assert!(
1231 html.contains(
1232 r#"<f-template name="custom-field" shadowrootmode="open" shadowrootdelegatesfocus shadowrootclonable shadowrootserializable>"#
1233 ),
1234 "wrapper must carry every hoisted option exactly once, in order: {html}"
1235 );
1236 assert!(
1237 html.contains(
1238 r#"<template shadowrootadoptedstylesheets="custom-field" @click="{clickHandler($e)}">"#
1239 ),
1240 "inner <template> must keep shadowrootadoptedstylesheets and the binding, and nothing hoisted: {html}"
1241 );
1242 for attr in [
1243 "shadowrootmode",
1244 "shadowrootdelegatesfocus",
1245 "shadowrootclonable",
1246 "shadowrootserializable",
1247 ] {
1248 assert_eq!(
1249 html.matches(attr).count(),
1250 1,
1251 "{attr} must appear exactly once across the wrapper and inner template: {html}"
1252 );
1253 }
1254 }
1255
1256 #[test]
1257 fn test_complex_attribute_normalization() {
1258 let cases = [
1261 (
1262 r#"<div :foo="{{value}}"></div>"#,
1263 r#"<div :foo="{value}"></div>"#,
1264 ),
1265 (
1266 r#"<div :foo="{{ value}}"></div>"#,
1267 r#"<div :foo="{ value}"></div>"#,
1268 ),
1269 (
1270 r#"<div :foo="{{value }}"></div>"#,
1271 r#"<div :foo="{value }"></div>"#,
1272 ),
1273 (
1274 r#"<div :foo="{{ value }}"></div>"#,
1275 r#"<div :foo="{ value }"></div>"#,
1276 ),
1277 ];
1278 for (input, expected) in &cases {
1279 let output = convert_btr_to_fast(input);
1280 assert_eq!(&output, expected, "normalization failed for input: {input}");
1281 }
1282 }
1283
1284 #[test]
1285 fn test_non_js_component_inner_template() {
1286 let mut plugin = FastV2ParserPlugin::new();
1287 let html = r#"<div><child-comp><template shadowrootmode="open"><p>inner</p></template></child-comp></div>"#;
1290 let comp = make_component("parent-comp", html, None);
1291 plugin
1292 .register_component_template("parent-comp", &comp, &comp.html_content)
1293 .unwrap();
1294
1295 let templates = plugin.take_component_templates();
1296 assert_eq!(templates.len(), 1);
1297 let name = &templates[0].tag_name;
1298 let output = &templates[0].template;
1299 assert_eq!(name, "parent-comp");
1300
1301 assert!(output.contains("<f-template name=\"parent-comp\">"));
1302 assert!(!output.contains("shadowrootmode"));
1304 assert!(output.contains("<child-comp><template><p>inner</p></template></child-comp>"));
1306 }
1307
1308 #[test]
1309 fn route_tags_converted_to_f_route() {
1310 let input = r#"<route path="/home" name="home" exact><span>Home</span></route>"#;
1311 let output = convert_btr_to_fast(input);
1312 assert!(output.starts_with("<webui-route"));
1313 assert!(output.contains(r#"path="/home""#));
1314 assert!(output.contains(r#"name="home""#));
1315 assert!(output.contains(" exact"));
1316 assert!(output.contains("style=\"display:none\""));
1317 assert!(output.contains("<span>Home</span>"));
1318 assert!(output.ends_with("</webui-route>"));
1319 }
1320
1321 #[test]
1322 fn f_route_tags_pass_through() {
1323 let input = r#"<webui-route path="/" name="dashboard" component="cb-page-dashboard" exact style="display:none"></webui-route>"#;
1325 let output = convert_btr_to_fast(input);
1326 assert_eq!(output, input);
1327 }
1328
1329 #[test]
1334 fn ftemplate_for_loop_converted_to_f_repeat() {
1335 let mut plugin = FastV2ParserPlugin::new();
1336 let html = r#"<ul><for each="item in items"><li>{{item.name}}</li></for></ul>"#;
1337 let comp = make_component("my-list", html, None);
1338 plugin
1339 .register_component_template("my-list", &comp, &comp.html_content)
1340 .unwrap();
1341
1342 let templates = plugin.take_component_templates();
1343 let result = &templates[0].template;
1344
1345 assert!(
1346 result.contains("<f-repeat value=\"{{item in items}}\">"),
1347 "f-template should use <f-repeat>, got: {result}"
1348 );
1349 assert!(
1350 result.contains("</f-repeat>"),
1351 "f-template should close with </f-repeat>, got: {result}"
1352 );
1353 assert!(
1354 !result.contains("<for "),
1355 "f-template should NOT contain <for>, got: {result}"
1356 );
1357 }
1358
1359 #[test]
1360 fn ftemplate_shadow_dom_strips_shadowroot_and_converts_directives() {
1361 let mut plugin = FastV2ParserPlugin::new();
1362 let html = r#"<template shadowrootmode="open"><div><if condition="visible">Shown</if><for each="x in list"><span>{{x}}</span></for></div></template>"#;
1363 let comp = make_component("my-shadow", html, None);
1364 plugin
1365 .register_component_template("my-shadow", &comp, &comp.html_content)
1366 .unwrap();
1367
1368 let templates = plugin.take_component_templates();
1369 let result = &templates[0].template;
1370
1371 assert!(
1372 result.contains("<f-when value=\"{{visible}}\">"),
1373 "Shadow f-template should contain <f-when>, got: {result}"
1374 );
1375 assert!(
1376 result.contains("<f-repeat value=\"{{x in list}}\">"),
1377 "Shadow f-template should contain <f-repeat>, got: {result}"
1378 );
1379 assert!(
1380 result.contains(r#"<f-template name="my-shadow" shadowrootmode="open">"#),
1381 "shadowrootmode should be hoisted onto the <f-template> wrapper, got: {result}"
1382 );
1383 assert!(
1384 !result.contains("<template shadowrootmode"),
1385 "inner <template> should not keep shadowrootmode, got: {result}"
1386 );
1387 assert!(
1388 !result.contains("<if "),
1389 "Shadow f-template should NOT contain <if>, got: {result}"
1390 );
1391 assert!(
1392 !result.contains("<for "),
1393 "Shadow f-template should NOT contain <for>, got: {result}"
1394 );
1395 }
1396
1397 #[test]
1398 fn ftemplate_nested_if_and_for_both_converted() {
1399 let mut plugin = FastV2ParserPlugin::new();
1400 let html =
1401 r#"<div><if condition="show"><for each="x in items"><p>{{x}}</p></for></if></div>"#;
1402 let comp = make_component("my-nested", html, None);
1403 plugin
1404 .register_component_template("my-nested", &comp, &comp.html_content)
1405 .unwrap();
1406
1407 let templates = plugin.take_component_templates();
1408 let result = &templates[0].template;
1409
1410 assert!(
1411 result.contains("<f-when value=\"{{show}}\">"),
1412 "Nested f-template should contain <f-when>, got: {result}"
1413 );
1414 assert!(
1415 result.contains("<f-repeat value=\"{{x in items}}\">"),
1416 "Nested f-template should contain <f-repeat>, got: {result}"
1417 );
1418 assert!(
1419 !result.contains("<if "),
1420 "Nested f-template should NOT contain <if>, got: {result}"
1421 );
1422 assert!(
1423 !result.contains("<for "),
1424 "Nested f-template should NOT contain <for>, got: {result}"
1425 );
1426 }
1427
1428 #[test]
1429 fn convert_btr_preserves_utf8_text_content() {
1430 let input = "<span>✓ passed</span>";
1431 let output = convert_btr_to_fast(input);
1432 assert_eq!(output, "<span>✓ passed</span>");
1433 }
1434
1435 #[test]
1436 fn convert_btr_preserves_utf8_multibyte_chars() {
1437 let input = "<span>✓ yes ✗ no ⭐ star</span>";
1438 let output = convert_btr_to_fast(input);
1439 assert_eq!(output, "<span>✓ yes ✗ no ⭐ star</span>");
1440 }
1441
1442 #[test]
1443 fn convert_btr_preserves_utf8_with_directives() {
1444 let input = r#"<if condition="x"><span>✓</span></if>"#;
1445 let output = convert_btr_to_fast(input);
1446 assert_eq!(output, r#"<f-when value="{{x}}"><span>✓</span></f-when>"#);
1447 }
1448
1449 #[test]
1450 fn convert_complex_attrs_preserves_utf8() {
1451 let input = r#"<span :title="{{label}}" data-icon="⭐">text</span>"#;
1452 let output = convert_btr_to_fast(input);
1453 assert!(
1454 output.contains("⭐"),
1455 "UTF-8 star should be preserved: {output}"
1456 );
1457 }
1458}