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