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