1use super::{AttributeAction, ComponentTemplateArtifact, ParserPlugin, ParserPluginArtifacts};
11use crate::component_registry::Component;
12use crate::html_parser::{find_element_end, find_tag_close, leading_content, opening_tag_name};
13use crate::{CssLinkOptions, CssStrategy, Result};
14use webui_protocol::FastElementData;
15
16struct TrackedComponent {
18 tag_name: String,
19 template_html: String,
20}
21
22pub struct FastV3ParserPlugin {
30 components: Vec<TrackedComponent>,
32}
33
34impl FastV3ParserPlugin {
35 #[must_use]
37 pub fn new() -> Self {
38 Self {
39 components: Vec::new(),
40 }
41 }
42
43 #[must_use]
49 pub fn take_component_templates(&self) -> Vec<ComponentTemplateArtifact> {
50 self.components
51 .iter()
52 .map(|comp| {
53 let tmpl = generate_f_template_from_processed(&comp.tag_name, &comp.template_html);
54 ComponentTemplateArtifact::template(comp.tag_name.clone(), tmpl)
55 })
56 .collect()
57 }
58}
59
60impl Default for FastV3ParserPlugin {
61 fn default() -> Self {
62 Self::new()
63 }
64}
65
66impl ParserPlugin for FastV3ParserPlugin {
67 fn register_component_template(
68 &mut self,
69 tag_name: &str,
70 component: &Component,
71 processed_template: &str,
72 ) -> Result<()> {
73 if self.components.iter().any(|c| c.tag_name == tag_name) {
76 return Ok(());
77 }
78 self.components.push(TrackedComponent {
79 tag_name: tag_name.to_string(),
80 template_html: processed_template.to_string(),
81 });
82 let _ = component;
83 Ok(())
84 }
85
86 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
87 if attr_name.starts_with('@')
88 || attr_name == "f-ref"
89 || attr_name == "f-slotted"
90 || attr_name == "f-children"
91 {
92 AttributeAction::SkipAndCountBinding
93 } else {
94 AttributeAction::Keep
95 }
96 }
97
98 fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
99 if binding_attribute_count > 0 {
100 Some(
101 FastElementData {
102 binding_count: binding_attribute_count,
103 }
104 .encode()
105 .to_vec(),
106 )
107 } else {
108 None
109 }
110 }
111
112 fn into_artifacts(self: Box<Self>) -> Result<ParserPluginArtifacts> {
113 Ok(ParserPluginArtifacts::ComponentTemplates(
114 self.take_component_templates(),
115 ))
116 }
117}
118
119pub fn generate_f_template(
128 tag_name: &str,
129 html_content: &str,
130 css_content: Option<&str>,
131 css_strategy: CssStrategy,
132) -> String {
133 generate_f_template_with_css_options(
134 tag_name,
135 html_content,
136 css_content,
137 css_strategy,
138 &CssLinkOptions::default(),
139 )
140}
141
142fn generate_f_template_from_processed(tag_name: &str, processed_template: &str) -> String {
143 let mut output = String::with_capacity(256);
144 output.push_str("<f-template name=\"");
145 output.push_str(tag_name);
146 output.push_str("\">\n");
147
148 let converted = convert_btr_to_fast(processed_template);
149 let trimmed = minify_inter_tag_whitespace(converted.trim());
150 let (trimmed, _) = leading_content(&trimmed);
151
152 if trimmed.starts_with("<template") {
153 output.push_str(trimmed);
154 } else {
155 output.push_str("<template>");
156 output.push_str(trimmed);
157 output.push_str("</template>");
158 }
159
160 output.push_str("\n</f-template>\n");
161 output
162}
163
164pub fn generate_f_template_with_css_options(
166 tag_name: &str,
167 html_content: &str,
168 css_content: Option<&str>,
169 css_strategy: CssStrategy,
170 css_link_options: &CssLinkOptions,
171) -> String {
172 let mut output = String::with_capacity(256);
173 output.push_str("<f-template name=\"");
174 output.push_str(tag_name);
175 output.push_str("\">\n");
176
177 let converted = convert_btr_to_fast(html_content);
178 let trimmed = minify_inter_tag_whitespace(converted.trim());
179 let (trimmed, _) = leading_content(&trimmed);
180
181 let css_injection = match css_strategy {
183 CssStrategy::Link => css_content.map(|css| {
184 let href = css_link_options.resolve(tag_name, css);
185 let mut s = String::with_capacity(40 + href.href.len());
186 s.push_str("<link rel=\"stylesheet\" href=\"");
187 s.push_str(&href.href);
188 s.push_str("\">");
189 s
190 }),
191 CssStrategy::Style => css_content.map(|css| {
192 let mut s = String::with_capacity(15 + css.len());
193 s.push_str("<style>");
194 s.push_str(css.trim());
195 s.push_str("</style>");
196 s
197 }),
198 CssStrategy::Module => None,
199 };
200
201 if trimmed.starts_with("<template") {
202 if let Some(close_pos) = find_tag_close(trimmed) {
203 output.push_str(&trimmed[..close_pos]);
208 output.push('>');
209 if let Some(ref injection) = css_injection {
210 output.push_str(injection);
211 }
212 output.push_str(&trimmed[close_pos + 1..]);
213 } else {
214 output.push_str(trimmed);
215 }
216 } else {
217 output.push_str("<template");
218 if css_strategy == CssStrategy::Module && css_content.is_some() {
219 output.push_str(" shadowrootadoptedstylesheets=\"");
220 output.push_str(tag_name);
221 output.push('"');
222 }
223 output.push('>');
224 if let Some(ref injection) = css_injection {
225 output.push_str(injection);
226 }
227 output.push_str(trimmed);
228 output.push_str("</template>");
229 }
230
231 output.push_str("\n</f-template>\n");
232 output
233}
234
235fn convert_btr_to_fast(input: &str) -> String {
247 let mut result = String::with_capacity(input.len());
248 let bytes = input.as_bytes();
249 let len = bytes.len();
250 let mut i = 0;
251
252 while i < len {
253 if bytes[i] == b'<' {
254 if let Some(consumed) = try_convert_tag(input, i, &mut result) {
255 i += consumed;
256 continue;
257 }
258 }
259
260 i = push_char_at(input, i, &mut result);
264 }
265
266 result
267}
268
269fn try_convert_tag(input: &str, pos: usize, result: &mut String) -> Option<usize> {
272 let remaining = &input[pos..];
273
274 if remaining.starts_with("</if>") {
276 result.push_str("</f-when>");
277 return Some(5);
278 }
279 if remaining.starts_with("</for>") {
280 result.push_str("</f-repeat>");
281 return Some(6);
282 }
283 if remaining.starts_with("</route>") {
285 result.push_str("</webui-route>");
286 return Some(8);
287 }
288
289 if let Some(consumed) = find_element_end(remaining, "outlet") {
292 result.push_str("<outlet></outlet>");
293 return Some(consumed);
294 }
295
296 if starts_with_tag_name(remaining, "if") {
298 if let Some(consumed) = convert_if_tag(remaining, result) {
299 return Some(consumed);
300 }
301 }
302
303 if starts_with_tag_name(remaining, "for") {
305 if let Some(consumed) = convert_for_tag(remaining, result) {
306 return Some(consumed);
307 }
308 }
309
310 if starts_with_tag_name(remaining, "route") {
312 if let Some(consumed) = convert_route_tag(remaining, result) {
313 return Some(consumed);
314 }
315 }
316
317 if remaining.starts_with("<") {
319 if starts_with_tag_name(remaining, "template") {
321 if let Some(consumed) = strip_shadowrootmode(remaining, result) {
322 return Some(consumed);
323 }
324 }
325 if let Some(consumed) = convert_complex_attrs(remaining, result) {
326 return Some(consumed);
327 }
328 }
329
330 None
331}
332
333fn starts_with_tag_name(s: &str, name: &str) -> bool {
335 opening_tag_name(s).is_some_and(|tag_name| tag_name == name)
336}
337
338fn convert_if_tag(tag_str: &str, result: &mut String) -> Option<usize> {
341 let close = find_tag_close(tag_str)?;
343 let tag_content = &tag_str[..=close];
344
345 let attr_value = extract_attribute_value(tag_content, "condition")?;
347
348 result.push_str("<f-when value=\"{{");
349 result.push_str(attr_value);
350 result.push_str("}}\">");
351
352 Some(close + 1)
353}
354
355fn convert_for_tag(tag_str: &str, result: &mut String) -> Option<usize> {
358 let close = find_tag_close(tag_str)?;
360 let tag_content = &tag_str[..=close];
361
362 let attr_value = extract_attribute_value(tag_content, "each")?;
364
365 result.push_str("<f-repeat value=\"{{");
366 result.push_str(attr_value);
367 result.push_str("}}\">");
368
369 Some(close + 1)
370}
371
372fn convert_route_tag(tag_str: &str, result: &mut String) -> Option<usize> {
376 let close = find_tag_close(tag_str)?;
377 let is_self_closing = tag_str[..=close].ends_with("/>");
378 let tag = &tag_str[..=close];
379
380 result.push_str("<webui-route");
381
382 if let Some(v) = extract_attribute_value(tag, "path") {
383 result.push_str(" path=\"");
384 result.push_str(v);
385 result.push('"');
386 }
387 if let Some(v) = extract_attribute_value(tag, "name") {
388 result.push_str(" name=\"");
389 result.push_str(v);
390 result.push('"');
391 }
392 if let Some(v) = extract_attribute_value(tag, "component") {
393 result.push_str(" component=\"");
394 result.push_str(v);
395 result.push('"');
396 }
397 if let Some(v) = extract_attribute_value(tag, "redirectTo") {
398 result.push_str(" redirectto=\"");
399 result.push_str(v);
400 result.push('"');
401 }
402 if tag.contains(" exact") {
403 result.push_str(" exact");
404 }
405 if tag.contains(" layout") {
406 result.push_str(" layout");
407 }
408 if let Some(v) = extract_attribute_value(tag, "query") {
409 result.push_str(" query=\"");
410 result.push_str(v);
411 result.push('"');
412 }
413 result.push_str(" style=\"display:none\"");
414
415 if is_self_closing {
416 result.push_str("></webui-route>");
417 } else {
418 result.push('>');
419 }
420
421 Some(close + 1)
422}
423
424fn extract_attribute_value<'a>(tag: &'a str, attr_name: &str) -> Option<&'a str> {
427 let mut search_from = 0;
429 loop {
430 let attr_pos = tag[search_from..].find(attr_name)?;
431 let abs_pos = search_from + attr_pos;
432
433 if abs_pos > 0 {
435 let prev = tag.as_bytes()[abs_pos - 1];
436 if prev != b' ' && prev != b'\t' && prev != b'\n' && prev != b'\r' {
437 search_from = abs_pos + attr_name.len();
438 continue;
439 }
440 }
441
442 let after_name = &tag[abs_pos + attr_name.len()..];
443 if !after_name.starts_with('=') {
444 search_from = abs_pos + attr_name.len();
445 continue;
446 }
447
448 let after_eq = &after_name[1..];
449 if after_eq.starts_with('"') {
450 let value_start = abs_pos + attr_name.len() + 2; let end_quote = tag[value_start..].find('"')?;
452 return Some(&tag[value_start..value_start + end_quote]);
453 }
454
455 return None;
456 }
457}
458
459fn convert_complex_attrs(tag_str: &str, result: &mut String) -> Option<usize> {
463 let close = find_tag_close(tag_str)?;
465 let tag_content = &tag_str[..=close];
466
467 if !tag_content.contains(":") || !tag_content.contains("{{") {
469 return None;
470 }
471
472 let mut converted = String::with_capacity(tag_content.len());
474 let tag_bytes = tag_content.as_bytes();
475 let tag_len = tag_bytes.len();
476 let mut j = 0;
477 let mut in_colon_attr_value = false;
478
479 while j < tag_len {
480 if !in_colon_attr_value {
481 if tag_bytes[j] == b':' && j > 0 && is_whitespace(tag_bytes[j - 1]) {
483 let attr_start = j;
485 j += 1;
486 while j < tag_len && tag_bytes[j] != b'=' && !is_whitespace(tag_bytes[j]) {
488 j += 1;
489 }
490 if j < tag_len && tag_bytes[j] == b'=' {
491 j += 1;
492 if j < tag_len && tag_bytes[j] == b'"' {
493 converted.push_str(&tag_content[attr_start..=j]);
495 j += 1;
496 in_colon_attr_value = true;
497 continue;
498 }
499 }
500 converted.push_str(&tag_content[attr_start..j]);
502 continue;
503 }
504 j = push_char_at(tag_content, j, &mut converted);
505 } else {
506 if tag_bytes[j] == b'"' {
508 converted.push('"');
510 j += 1;
511 in_colon_attr_value = false;
512 } else if j + 1 < tag_len && tag_bytes[j] == b'{' && tag_bytes[j + 1] == b'{' {
513 let expr_start = j + 2;
515 if let Some(end_offset) = tag_content[expr_start..].find("}}") {
516 let expr = &tag_content[expr_start..expr_start + end_offset];
517 converted.push('{');
518 converted.push_str(expr);
519 converted.push('}');
520 j = expr_start + end_offset + 2;
521 } else {
522 converted.push('{');
524 converted.push('{');
525 j += 2;
526 }
527 } else {
528 j = push_char_at(tag_content, j, &mut converted);
529 }
530 }
531 }
532
533 result.push_str(&converted);
534 Some(close + 1)
535}
536
537fn push_char_at(input: &str, pos: usize, out: &mut String) -> usize {
540 let bytes = input.as_bytes();
541 if bytes[pos].is_ascii() {
542 out.push(bytes[pos] as char);
543 pos + 1
544 } else {
545 let mut end = pos + 1;
546 while end < bytes.len() && !input.is_char_boundary(end) {
547 end += 1;
548 }
549 out.push_str(&input[pos..end]);
550 end
551 }
552}
553
554fn is_whitespace(b: u8) -> bool {
556 b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'
557}
558
559fn minify_inter_tag_whitespace(input: &str) -> String {
563 let mut result = String::with_capacity(input.len());
564 let bytes = input.as_bytes();
565 let len = bytes.len();
566 let mut i = 0;
567
568 while i < len {
569 if bytes[i] == b'>' && i + 1 < len {
570 result.push('>');
571 i += 1;
572 let ws_start = i;
574 while i < len && is_whitespace(bytes[i]) {
575 i += 1;
576 }
577 if i >= len || bytes[i] != b'<' {
580 result.push_str(&input[ws_start..i]);
582 }
583 } else {
584 i = push_char_at(input, i, &mut result);
585 }
586 }
587
588 result
589}
590
591fn strip_shadowrootmode(tag_str: &str, result: &mut String) -> Option<usize> {
594 let close = find_tag_close(tag_str)?;
596 let tag_content = &tag_str[..=close];
597
598 if !tag_content.contains("shadowrootmode") {
600 return None;
601 }
602
603 result.push_str("<template");
605 let attr_start = "<template".len();
606 let inner = &tag_content[attr_start..close];
607
608 let inner_bytes = inner.as_bytes();
610 let inner_len = inner_bytes.len();
611 let mut j = 0;
612 while j < inner_len {
613 if is_whitespace(inner_bytes[j]) {
615 j += 1;
616 continue;
617 }
618
619 let attr_begin = j;
621 while j < inner_len && inner_bytes[j] != b'=' && !is_whitespace(inner_bytes[j]) {
623 j += 1;
624 }
625 let attr_name = &inner[attr_begin..j];
626
627 let mut attr_end = j;
629 if j < inner_len && inner_bytes[j] == b'=' {
630 j += 1; if j < inner_len && inner_bytes[j] == b'"' {
632 j += 1; while j < inner_len && inner_bytes[j] != b'"' {
634 j += 1;
635 }
636 if j < inner_len {
637 j += 1; }
639 } else {
640 while j < inner_len && !is_whitespace(inner_bytes[j]) {
642 j += 1;
643 }
644 }
645 attr_end = j;
646 }
647
648 if attr_name == "shadowrootmode" {
650 continue;
651 }
652
653 result.push(' ');
655 result.push_str(&inner[attr_begin..attr_end]);
656 }
657
658 if tag_content.ends_with("/>") {
659 result.push_str("/>");
660 } else {
661 result.push('>');
662 }
663
664 Some(close + 1)
665}
666
667#[cfg(test)]
668mod tests {
669 #![allow(clippy::disallowed_methods)]
670
671 use super::*;
672 fn make_component(tag: &str, html: &str, css: Option<&str>) -> Component {
673 Component {
674 tag_name: tag.to_string(),
675 html_content: html.to_string(),
676 css_content: css.map(ToString::to_string),
677 css_definitions: Vec::new(),
678 css_fallback_chains: Vec::new(),
679 is_client_owned: false,
680 }
681 }
682
683 #[test]
686 fn skip_event_binding() {
687 let mut plugin = FastV3ParserPlugin::new();
688 assert_eq!(
689 plugin.classify_attribute("@click"),
690 AttributeAction::SkipAndCountBinding
691 );
692 assert_eq!(
693 plugin.classify_attribute("@input"),
694 AttributeAction::SkipAndCountBinding
695 );
696 assert_eq!(
697 plugin.classify_attribute("@custom-event"),
698 AttributeAction::SkipAndCountBinding
699 );
700 }
701
702 #[test]
703 fn skip_f_ref() {
704 let mut plugin = FastV3ParserPlugin::new();
705 assert_eq!(
706 plugin.classify_attribute("f-ref"),
707 AttributeAction::SkipAndCountBinding
708 );
709 }
710
711 #[test]
712 fn skip_f_slotted() {
713 let mut plugin = FastV3ParserPlugin::new();
714 assert_eq!(
715 plugin.classify_attribute("f-slotted"),
716 AttributeAction::SkipAndCountBinding
717 );
718 }
719
720 #[test]
721 fn skip_f_children() {
722 let mut plugin = FastV3ParserPlugin::new();
723 assert_eq!(
724 plugin.classify_attribute("f-children"),
725 AttributeAction::SkipAndCountBinding
726 );
727 }
728
729 #[test]
730 fn do_not_skip_normal_attributes() {
731 let mut plugin = FastV3ParserPlugin::new();
732 assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
733 assert_eq!(plugin.classify_attribute("id"), AttributeAction::Keep);
734 assert_eq!(
735 plugin.classify_attribute("data-value"),
736 AttributeAction::Keep
737 );
738 assert_eq!(plugin.classify_attribute(":title"), AttributeAction::Keep);
739 assert_eq!(plugin.classify_attribute("f-other"), AttributeAction::Keep);
740 }
741
742 #[test]
745 fn element_parsed_zero_count_returns_none() {
746 let mut plugin = FastV3ParserPlugin::new();
747 assert!(plugin.finish_element(0).is_none());
748 }
749
750 #[test]
751 fn element_parsed_nonzero_count_returns_le_bytes() {
752 let mut plugin = FastV3ParserPlugin::new();
753 let data = plugin.finish_element(3);
754 assert!(data.is_some());
755 let bytes = data.as_deref().unwrap_or_default();
756 assert_eq!(bytes, &3u32.to_le_bytes());
757 }
758
759 #[test]
760 fn element_parsed_large_count() {
761 let mut plugin = FastV3ParserPlugin::new();
762 let data = plugin.finish_element(256);
763 assert!(data.is_some());
764 let bytes = data.as_deref().unwrap_or_default();
765 assert_eq!(bytes, &256u32.to_le_bytes());
766 }
767
768 #[test]
769 fn component_template_simple_component() {
770 let mut plugin = FastV3ParserPlugin::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><link rel="stylesheet" href="my-comp.css"><div>hello</div></template>"#,
777 )
778 .unwrap();
779
780 let templates = plugin.take_component_templates();
781 assert_eq!(templates.len(), 1);
782 let name = &templates[0].tag_name;
783 let html = &templates[0].template;
784 assert_eq!(name, "my-comp");
785 assert!(html.contains("<f-template name=\"my-comp\">"));
786 assert!(html.contains("</f-template>"));
787 assert!(html.contains("<template>"));
788 assert!(html.contains("</template>"));
789 assert!(html.contains("<link rel=\"stylesheet\" href=\"my-comp.css\">"));
790 assert!(html.contains("<div>hello</div>"));
791 }
792
793 #[test]
794 fn component_template_without_css() {
795 let mut plugin = FastV3ParserPlugin::new();
796 let comp = make_component("no-css", "<span>text</span>", None);
797 plugin
798 .register_component_template("no-css", &comp, &comp.html_content)
799 .unwrap();
800
801 let templates = plugin.take_component_templates();
802 assert_eq!(templates.len(), 1);
803 let name = &templates[0].tag_name;
804 let html = &templates[0].template;
805 assert_eq!(name, "no-css");
806 assert!(html.contains("<f-template name=\"no-css\">"));
807 assert!(!html.contains("<link rel=\"stylesheet\""));
808 assert!(html.contains("<span>text</span>"));
809 }
810
811 #[test]
812 fn direct_template_generation_skips_leading_comments() {
813 let html = generate_f_template(
814 "my-comp",
815 concat!(
816 "<!-- Copyright (C) Corporation. All rights reserved. -->\n",
817 "<template shadowrootmode=\"open\"><h1>Hello</h1></template>",
818 ),
819 None,
820 CssStrategy::Link,
821 );
822
823 assert_eq!(html.matches("<template>").count(), 1);
824 assert!(!html.contains("<!--"));
825 assert!(html.contains("<template><h1>Hello</h1></template>"));
826 }
827
828 #[test]
829 fn component_template_css_strategy_style() {
830 let mut plugin = FastV3ParserPlugin::new();
831 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
832 plugin
833 .register_component_template(
834 "my-comp",
835 &comp,
836 r#"<template><style>div { color: red; }</style><div>hello</div></template>"#,
837 )
838 .unwrap();
839
840 let templates = plugin.take_component_templates();
841 assert_eq!(templates.len(), 1);
842 let html = &templates[0].template;
843 assert!(
844 html.contains("<style>div { color: red; }</style>"),
845 "Style strategy should inline CSS, got: {html}"
846 );
847 assert!(
848 !html.contains("<link"),
849 "Style strategy should not emit <link> tags"
850 );
851 }
852
853 #[test]
854 fn component_template_css_strategy_module() {
855 let mut plugin = FastV3ParserPlugin::new();
856 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
857 plugin
858 .register_component_template(
859 "my-comp",
860 &comp,
861 r#"<template shadowrootadoptedstylesheets="my-comp"><div>hello</div></template>"#,
862 )
863 .unwrap();
864
865 let templates = plugin.take_component_templates();
866 assert_eq!(templates.len(), 1);
867 let html = &templates[0].template;
868
869 assert!(
872 !html.contains(r#"<style type="module""#),
873 "Legacy CSS module shape should not be baked into f-template: {html}"
874 );
875 assert!(
876 !html.contains(r#"<script type="importmap""#),
877 "CSS module importmap should not be baked into f-template: {html}"
878 );
879 assert!(
881 html.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
882 "Module strategy should add shadowrootadoptedstylesheets, got: {html}"
883 );
884 assert!(
886 !html.contains("<style>div"),
887 "Module strategy should not have inline <style> inside template"
888 );
889 assert!(
890 !html.contains("<link"),
891 "Module strategy should not emit <link> tags"
892 );
893 }
894
895 #[test]
896 fn component_template_css_strategy_module_no_css() {
897 let mut plugin = FastV3ParserPlugin::new();
898 let comp = make_component("my-comp", "<div>hello</div>", None);
899 plugin
900 .register_component_template(
901 "my-comp",
902 &comp,
903 r#"<template><div>hello</div></template>"#,
904 )
905 .unwrap();
906
907 let templates = plugin.take_component_templates();
908 assert_eq!(templates.len(), 1);
909 let html = &templates[0].template;
910 assert!(
911 !html.contains("shadowrootadoptedstylesheets"),
912 "No CSS = no shadowrootadoptedstylesheets, got: {html}"
913 );
914 assert!(
915 !html.contains("<style"),
916 "No CSS = no style tag, got: {html}"
917 );
918 }
919
920 #[test]
921 fn component_template_multiple_components() {
922 let mut plugin = FastV3ParserPlugin::new();
923 let comp1 = make_component("comp-a", "<div>A</div>", None);
924 let comp2 = make_component("comp-b", "<div>B</div>", Some("b { }"));
925 plugin
926 .register_component_template("comp-a", &comp1, &comp1.html_content)
927 .unwrap();
928 plugin
929 .register_component_template("comp-b", &comp2, &comp2.html_content)
930 .unwrap();
931
932 let templates = plugin.take_component_templates();
933 assert_eq!(templates.len(), 2);
934
935 let names: Vec<&str> = templates
936 .iter()
937 .map(|artifact| artifact.tag_name.as_str())
938 .collect();
939 assert!(names.contains(&"comp-a"));
940 assert!(names.contains(&"comp-b"));
941
942 for artifact in &templates {
943 assert!(artifact.template.contains("<f-template name="));
944 }
945 }
946
947 #[test]
948 fn component_template_deduplicates_same_component() {
949 let mut plugin = FastV3ParserPlugin::new();
950 let comp = make_component(
951 "my-button",
952 "<button><slot></slot></button>",
953 Some(".btn{}"),
954 );
955
956 plugin
959 .register_component_template("my-button", &comp, &comp.html_content)
960 .unwrap();
961 plugin
962 .register_component_template("my-button", &comp, &comp.html_content)
963 .unwrap();
964 plugin
965 .register_component_template("my-button", &comp, &comp.html_content)
966 .unwrap();
967
968 let templates = plugin.take_component_templates();
969 assert_eq!(
970 templates.len(),
971 1,
972 "Expected exactly 1 template for my-button, got {}",
973 templates.len()
974 );
975 assert_eq!(templates[0].tag_name, "my-button");
976 }
977
978 #[test]
979 fn component_template_deduplicates_mixed_components() {
980 let mut plugin = FastV3ParserPlugin::new();
981 let btn = make_component("my-button", "<button><slot></slot></button>", None);
982 let card = make_component("my-card", "<div><slot></slot></div>", Some(".card{}"));
983
984 plugin
986 .register_component_template("my-button", &btn, &btn.html_content)
987 .unwrap();
988 plugin
989 .register_component_template("my-card", &card, &card.html_content)
990 .unwrap();
991 plugin
992 .register_component_template("my-button", &btn, &btn.html_content)
993 .unwrap();
994
995 let templates = plugin.take_component_templates();
996 assert_eq!(templates.len(), 2);
997
998 let button_count = templates
999 .iter()
1000 .filter(|artifact| artifact.tag_name == "my-button")
1001 .count();
1002 let card_count = templates
1003 .iter()
1004 .filter(|artifact| artifact.tag_name == "my-card")
1005 .count();
1006 assert_eq!(button_count, 1);
1007 assert_eq!(card_count, 1);
1008 }
1009
1010 #[test]
1013 fn convert_if_to_f_when() {
1014 let input = r#"<if condition="isComplete"><span>Done</span></if>"#;
1015 let output = convert_btr_to_fast(input);
1016 assert_eq!(
1017 output,
1018 r#"<f-when value="{{isComplete}}"><span>Done</span></f-when>"#
1019 );
1020 }
1021
1022 #[test]
1023 fn convert_if_with_greater_than() {
1024 let input = r#"<if condition="vara > 2">Over two</if>"#;
1025 let output = convert_btr_to_fast(input);
1026 assert_eq!(output, r#"<f-when value="{{vara > 2}}">Over two</f-when>"#);
1027 }
1028
1029 #[test]
1030 fn convert_if_with_greater_than_or_equal() {
1031 let input = r#"<if condition="vara >= 2">Two and over</if>"#;
1032 let output = convert_btr_to_fast(input);
1033 assert_eq!(
1034 output,
1035 r#"<f-when value="{{vara >= 2}}">Two and over</f-when>"#
1036 );
1037 }
1038
1039 #[test]
1040 fn convert_if_with_less_than() {
1041 let input = r#"<if condition="x < 10">small</if>"#;
1042 let output = convert_btr_to_fast(input);
1043 assert_eq!(output, r#"<f-when value="{{x < 10}}">small</f-when>"#);
1044 }
1045
1046 #[test]
1047 fn convert_for_to_f_repeat() {
1048 let input = r#"<for each="tag in tags"><span>{{tag}}</span></for>"#;
1049 let output = convert_btr_to_fast(input);
1050 assert_eq!(
1051 output,
1052 r#"<f-repeat value="{{tag in tags}}"><span>{{tag}}</span></f-repeat>"#
1053 );
1054 }
1055
1056 #[test]
1057 fn convert_nested_if_and_for() {
1058 let input = r#"<if condition="show"><for each="x in items"><p>{{x}}</p></for></if>"#;
1059 let output = convert_btr_to_fast(input);
1060 assert_eq!(
1061 output,
1062 r#"<f-when value="{{show}}"><f-repeat value="{{x in items}}"><p>{{x}}</p></f-repeat></f-when>"#
1063 );
1064 }
1065
1066 #[test]
1067 fn convert_complex_attr_double_braces() {
1068 let input = r#"<div :data="{{config}}"></div>"#;
1069 let output = convert_btr_to_fast(input);
1070 assert_eq!(output, r#"<div :data="{config}"></div>"#);
1071 }
1072
1073 #[test]
1074 fn convert_multiple_complex_attrs() {
1075 let input = r#"<div :title="{{name}}" :data-id="{{id}}"></div>"#;
1076 let output = convert_btr_to_fast(input);
1077 assert_eq!(output, r#"<div :title="{name}" :data-id="{id}"></div>"#);
1078 }
1079
1080 #[test]
1081 fn no_conversion_for_text_double_braces() {
1082 let input = "<span>{{greeting}}</span>";
1084 let output = convert_btr_to_fast(input);
1085 assert_eq!(output, "<span>{{greeting}}</span>");
1086 }
1087
1088 #[test]
1089 fn no_conversion_for_regular_attr() {
1090 let input = r#"<div title="{{name}}"></div>"#;
1092 let output = convert_btr_to_fast(input);
1093 assert_eq!(output, r#"<div title="{{name}}"></div>"#);
1094 }
1095
1096 #[test]
1097 fn component_template_with_btr_conversion() {
1098 let mut plugin = FastV3ParserPlugin::new();
1099 let html = r#"<div><if condition="visible"><span>hi</span></if></div>"#;
1100 let comp = make_component("my-widget", html, None);
1101 plugin
1102 .register_component_template("my-widget", &comp, &comp.html_content)
1103 .unwrap();
1104
1105 let templates = plugin.take_component_templates();
1106 assert_eq!(templates.len(), 1);
1107 let name = &templates[0].tag_name;
1108 let result = &templates[0].template;
1109 assert_eq!(name, "my-widget");
1110
1111 assert!(result.contains("<f-when value=\"{{visible}}\">"));
1112 assert!(result.contains("</f-when>"));
1113 assert!(!result.contains("<if condition="));
1114 assert!(!result.contains("</if>"));
1115 }
1116
1117 #[test]
1118 fn passthrough_normal_html() {
1119 let input = r#"<div class="container"><p>Hello</p></div>"#;
1120 let output = convert_btr_to_fast(input);
1121 assert_eq!(output, input);
1122 }
1123
1124 #[test]
1125 fn outlet_kept_as_marker_in_f_template() {
1126 for input in [
1127 r#"<div><outlet /></div>"#,
1128 r#"<div><outlet></outlet></div>"#,
1129 ] {
1130 let output = convert_btr_to_fast(input);
1131 assert_eq!(output, r#"<div><outlet></outlet></div>"#);
1132 }
1133 }
1134
1135 #[test]
1136 fn outlet_marker_in_component_template() {
1137 let mut plugin = FastV3ParserPlugin::new();
1138 let html =
1139 r#"<template shadowrootmode="open"><h1>Title</h1><main><outlet /></main></template>"#;
1140 let comp = make_component("my-shell", html, None);
1141 plugin
1142 .register_component_template("my-shell", &comp, &comp.html_content)
1143 .unwrap();
1144
1145 let templates = plugin.take_component_templates();
1146 let result = &templates[0].template;
1147 assert!(
1148 result.contains("<outlet></outlet>"),
1149 "outlet should be kept as marker in f-template: {result}"
1150 );
1151 }
1152
1153 #[test]
1154 fn strip_shadowrootmode_from_template() {
1155 let input = r#"<template shadowrootmode="open"><div>Hello</div></template>"#;
1156 let output = convert_btr_to_fast(input);
1157 assert_eq!(output, "<template><div>Hello</div></template>");
1158 }
1159
1160 #[test]
1161 fn strip_shadowrootmode_preserves_other_attrs() {
1162 let input =
1163 r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>Hi</div></template>"#;
1164 let output = convert_btr_to_fast(input);
1165 assert!(output.contains(r#"@click="{onClick(e)}""#));
1166 assert!(!output.contains("shadowrootmode"));
1167 assert!(output.starts_with("<template "));
1168 }
1169
1170 #[test]
1171 fn no_strip_when_no_shadowrootmode() {
1172 let input = r#"<template foo="bar"><div>Hi</div></template>"#;
1173 let output = convert_btr_to_fast(input);
1174 assert_eq!(output, input);
1175 }
1176
1177 #[test]
1178 fn component_template_strips_shadowrootmode_from_f_template() {
1179 let mut plugin = FastV3ParserPlugin::new();
1180 let comp = make_component(
1181 "my-comp",
1182 r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>{{title}}</div></template>"#,
1183 Some("div { color: red; }"),
1184 );
1185 plugin
1186 .register_component_template("my-comp", &comp, &comp.html_content)
1187 .unwrap();
1188 let templates = plugin.take_component_templates();
1189 assert_eq!(templates.len(), 1);
1190 let name = &templates[0].tag_name;
1191 let html = &templates[0].template;
1192 assert_eq!(name, "my-comp");
1193 assert!(!html.contains("shadowrootmode"));
1195 assert!(html.contains("@click"));
1197 assert!(html.contains("{{title}}"));
1199 }
1200
1201 #[test]
1202 fn test_complex_attribute_normalization() {
1203 let cases = [
1206 (
1207 r#"<div :foo="{{value}}"></div>"#,
1208 r#"<div :foo="{value}"></div>"#,
1209 ),
1210 (
1211 r#"<div :foo="{{ value}}"></div>"#,
1212 r#"<div :foo="{ value}"></div>"#,
1213 ),
1214 (
1215 r#"<div :foo="{{value }}"></div>"#,
1216 r#"<div :foo="{value }"></div>"#,
1217 ),
1218 (
1219 r#"<div :foo="{{ value }}"></div>"#,
1220 r#"<div :foo="{ value }"></div>"#,
1221 ),
1222 ];
1223 for (input, expected) in &cases {
1224 let output = convert_btr_to_fast(input);
1225 assert_eq!(&output, expected, "normalization failed for input: {input}");
1226 }
1227 }
1228
1229 #[test]
1230 fn test_non_js_component_inner_template() {
1231 let mut plugin = FastV3ParserPlugin::new();
1232 let html = r#"<div><child-comp><template shadowrootmode="open"><p>inner</p></template></child-comp></div>"#;
1235 let comp = make_component("parent-comp", html, None);
1236 plugin
1237 .register_component_template("parent-comp", &comp, &comp.html_content)
1238 .unwrap();
1239
1240 let templates = plugin.take_component_templates();
1241 assert_eq!(templates.len(), 1);
1242 let name = &templates[0].tag_name;
1243 let output = &templates[0].template;
1244 assert_eq!(name, "parent-comp");
1245
1246 assert!(output.contains("<f-template name=\"parent-comp\">"));
1247 assert!(!output.contains("shadowrootmode"));
1249 assert!(output.contains("<child-comp><template><p>inner</p></template></child-comp>"));
1251 }
1252
1253 #[test]
1254 fn route_tags_converted_to_f_route() {
1255 let input = r#"<route path="/home" name="home" exact><span>Home</span></route>"#;
1256 let output = convert_btr_to_fast(input);
1257 assert!(output.starts_with("<webui-route"));
1258 assert!(output.contains(r#"path="/home""#));
1259 assert!(output.contains(r#"name="home""#));
1260 assert!(output.contains(" exact"));
1261 assert!(output.contains("style=\"display:none\""));
1262 assert!(output.contains("<span>Home</span>"));
1263 assert!(output.ends_with("</webui-route>"));
1264 }
1265
1266 #[test]
1267 fn f_route_tags_pass_through() {
1268 let input = r#"<webui-route path="/" name="dashboard" component="cb-page-dashboard" exact style="display:none"></webui-route>"#;
1270 let output = convert_btr_to_fast(input);
1271 assert_eq!(output, input);
1272 }
1273
1274 #[test]
1279 fn ftemplate_for_loop_converted_to_f_repeat() {
1280 let mut plugin = FastV3ParserPlugin::new();
1281 let html = r#"<ul><for each="item in items"><li>{{item.name}}</li></for></ul>"#;
1282 let comp = make_component("my-list", html, None);
1283 plugin
1284 .register_component_template("my-list", &comp, &comp.html_content)
1285 .unwrap();
1286
1287 let templates = plugin.take_component_templates();
1288 let result = &templates[0].template;
1289
1290 assert!(
1291 result.contains("<f-repeat value=\"{{item in items}}\">"),
1292 "f-template should use <f-repeat>, got: {result}"
1293 );
1294 assert!(
1295 result.contains("</f-repeat>"),
1296 "f-template should close with </f-repeat>, got: {result}"
1297 );
1298 assert!(
1299 !result.contains("<for "),
1300 "f-template should NOT contain <for>, got: {result}"
1301 );
1302 }
1303
1304 #[test]
1305 fn ftemplate_shadow_dom_strips_shadowroot_and_converts_directives() {
1306 let mut plugin = FastV3ParserPlugin::new();
1307 let html = r#"<template shadowrootmode="open"><div><if condition="visible">Shown</if><for each="x in list"><span>{{x}}</span></for></div></template>"#;
1308 let comp = make_component("my-shadow", html, None);
1309 plugin
1310 .register_component_template("my-shadow", &comp, &comp.html_content)
1311 .unwrap();
1312
1313 let templates = plugin.take_component_templates();
1314 let result = &templates[0].template;
1315
1316 assert!(
1317 result.contains("<f-when value=\"{{visible}}\">"),
1318 "Shadow f-template should contain <f-when>, got: {result}"
1319 );
1320 assert!(
1321 result.contains("<f-repeat value=\"{{x in list}}\">"),
1322 "Shadow f-template should contain <f-repeat>, got: {result}"
1323 );
1324 assert!(
1325 !result.contains("shadowrootmode"),
1326 "Shadow f-template should strip shadowrootmode, got: {result}"
1327 );
1328 assert!(
1329 !result.contains("<if "),
1330 "Shadow f-template should NOT contain <if>, got: {result}"
1331 );
1332 assert!(
1333 !result.contains("<for "),
1334 "Shadow f-template should NOT contain <for>, got: {result}"
1335 );
1336 }
1337
1338 #[test]
1339 fn ftemplate_nested_if_and_for_both_converted() {
1340 let mut plugin = FastV3ParserPlugin::new();
1341 let html =
1342 r#"<div><if condition="show"><for each="x in items"><p>{{x}}</p></for></if></div>"#;
1343 let comp = make_component("my-nested", html, None);
1344 plugin
1345 .register_component_template("my-nested", &comp, &comp.html_content)
1346 .unwrap();
1347
1348 let templates = plugin.take_component_templates();
1349 let result = &templates[0].template;
1350
1351 assert!(
1352 result.contains("<f-when value=\"{{show}}\">"),
1353 "Nested f-template should contain <f-when>, got: {result}"
1354 );
1355 assert!(
1356 result.contains("<f-repeat value=\"{{x in items}}\">"),
1357 "Nested f-template should contain <f-repeat>, got: {result}"
1358 );
1359 assert!(
1360 !result.contains("<if "),
1361 "Nested f-template should NOT contain <if>, got: {result}"
1362 );
1363 assert!(
1364 !result.contains("<for "),
1365 "Nested f-template should NOT contain <for>, got: {result}"
1366 );
1367 }
1368
1369 #[test]
1370 fn convert_btr_preserves_utf8_text_content() {
1371 let input = "<span>✓ passed</span>";
1372 let output = convert_btr_to_fast(input);
1373 assert_eq!(output, "<span>✓ passed</span>");
1374 }
1375
1376 #[test]
1377 fn convert_btr_preserves_utf8_multibyte_chars() {
1378 let input = "<span>✓ yes ✗ no ⭐ star</span>";
1379 let output = convert_btr_to_fast(input);
1380 assert_eq!(output, "<span>✓ yes ✗ no ⭐ star</span>");
1381 }
1382
1383 #[test]
1384 fn convert_btr_preserves_utf8_with_directives() {
1385 let input = r#"<if condition="x"><span>✓</span></if>"#;
1386 let output = convert_btr_to_fast(input);
1387 assert_eq!(output, r#"<f-when value="{{x}}"><span>✓</span></f-when>"#);
1388 }
1389
1390 #[test]
1391 fn minify_preserves_utf8_text_content() {
1392 let input = "<span>✓</span><span>✗</span>";
1393 let output = minify_inter_tag_whitespace(input);
1394 assert_eq!(output, "<span>✓</span><span>✗</span>");
1395 }
1396
1397 #[test]
1398 fn minify_preserves_utf8_between_elements() {
1399 let input = "<div> ✓ </div>";
1400 let output = minify_inter_tag_whitespace(input);
1401 assert_eq!(output, "<div> ✓ </div>");
1402 }
1403
1404 #[test]
1405 fn convert_complex_attrs_preserves_utf8() {
1406 let input = r#"<span :title="{{label}}" data-icon="⭐">text</span>"#;
1407 let output = convert_btr_to_fast(input);
1408 assert!(
1409 output.contains("⭐"),
1410 "UTF-8 star should be preserved: {output}"
1411 );
1412 }
1413}