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