1use super::ParserPlugin;
11use crate::component_registry::Component;
12use crate::{CssStrategy, Result};
13
14struct TrackedComponent {
16 tag_name: String,
17 html_content: String,
18 css_content: Option<String>,
19}
20
21pub struct FastParserPlugin {
29 components: Vec<TrackedComponent>,
31 css_strategy: CssStrategy,
33}
34
35impl FastParserPlugin {
36 #[must_use]
38 pub fn new() -> Self {
39 Self {
40 components: Vec::new(),
41 css_strategy: CssStrategy::Link,
42 }
43 }
44
45 pub fn set_css_strategy(&mut self, strategy: CssStrategy) {
47 self.css_strategy = strategy;
48 }
49
50 #[must_use]
56 pub fn take_component_templates(&self) -> Vec<(String, String)> {
57 self.components
58 .iter()
59 .map(|comp| {
60 let tmpl = generate_f_template(
61 &comp.tag_name,
62 &comp.html_content,
63 comp.css_content.as_deref(),
64 self.css_strategy,
65 );
66 (comp.tag_name.clone(), tmpl)
67 })
68 .collect()
69 }
70}
71
72impl Default for FastParserPlugin {
73 fn default() -> Self {
74 Self::new()
75 }
76}
77
78impl ParserPlugin for FastParserPlugin {
79 fn should_skip_attribute(&self, attr_name: &str) -> bool {
80 attr_name.starts_with('@')
81 || attr_name == "f-ref"
82 || attr_name == "f-slotted"
83 || attr_name == "f-children"
84 }
85
86 fn on_parse_component(&mut self, tag_name: &str, component: &Component) -> Result<()> {
87 if self.components.iter().any(|c| c.tag_name == tag_name) {
90 return Ok(());
91 }
92 self.components.push(TrackedComponent {
93 tag_name: tag_name.to_string(),
94 html_content: component.html_content.clone(),
95 css_content: component.css_content.clone(),
96 });
97 Ok(())
98 }
99
100 fn on_element_parsed(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
101 if binding_attribute_count > 0 {
102 Some(binding_attribute_count.to_le_bytes().to_vec())
103 } else {
104 None
105 }
106 }
107
108 fn on_body_end(&mut self) -> Option<String> {
109 None
112 }
113
114 fn as_any(&self) -> &dyn std::any::Any {
115 self
116 }
117
118 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
119 self
120 }
121}
122
123pub fn generate_f_template(
128 tag_name: &str,
129 html_content: &str,
130 css_content: Option<&str>,
131 css_strategy: CssStrategy,
132) -> String {
133 let mut output = String::with_capacity(256);
134 output.push_str("<f-template name=\"");
135 output.push_str(tag_name);
136 output.push_str("\">\n");
137
138 let converted = convert_btr_to_fast(html_content);
139 let trimmed = minify_inter_tag_whitespace(converted.trim());
140
141 let css_injection = match css_strategy {
143 CssStrategy::Link => css_content.map(|_| {
144 let mut s = String::with_capacity(40 + tag_name.len());
145 s.push_str("<link rel=\"stylesheet\" href=\"/");
146 s.push_str(tag_name);
147 s.push_str(".css\">");
148 s
149 }),
150 CssStrategy::Style => css_content.map(|css| {
151 let mut s = String::with_capacity(15 + css.len());
152 s.push_str("<style>");
153 s.push_str(css.trim());
154 s.push_str("</style>");
155 s
156 }),
157 CssStrategy::Module => None,
158 };
159
160 if trimmed.starts_with("<template") {
161 if let Some(close_pos) = trimmed.find('>') {
162 output.push_str(&trimmed[..close_pos]);
163 if css_strategy == CssStrategy::Module && css_content.is_some() {
164 output.push_str(" shadowrootadoptedstylesheets=\"");
165 output.push_str(tag_name);
166 output.push('"');
167 }
168 output.push('>');
169 if let Some(ref injection) = css_injection {
170 output.push_str(injection);
171 }
172 output.push_str(&trimmed[close_pos + 1..]);
173 } else {
174 output.push_str(&trimmed);
175 }
176 } else {
177 output.push_str("<template");
178 if css_strategy == CssStrategy::Module && css_content.is_some() {
179 output.push_str(" shadowrootadoptedstylesheets=\"");
180 output.push_str(tag_name);
181 output.push('"');
182 }
183 output.push('>');
184 if let Some(ref injection) = css_injection {
185 output.push_str(injection);
186 }
187 output.push_str(&trimmed);
188 output.push_str("</template>");
189 }
190
191 output.push_str("\n</f-template>\n");
192 output
193}
194
195fn convert_btr_to_fast(input: &str) -> String {
207 let mut result = String::with_capacity(input.len());
208 let bytes = input.as_bytes();
209 let len = bytes.len();
210 let mut i = 0;
211
212 while i < len {
213 if bytes[i] == b'<' {
214 if let Some(consumed) = try_convert_tag(input, i, &mut result) {
215 i += consumed;
216 continue;
217 }
218 }
219
220 result.push(bytes[i] as char);
224 i += 1;
225 }
226
227 result
228}
229
230fn try_convert_tag(input: &str, pos: usize, result: &mut String) -> Option<usize> {
233 let remaining = &input[pos..];
234
235 if remaining.starts_with("</if>") {
237 result.push_str("</f-when>");
238 return Some(5);
239 }
240 if remaining.starts_with("</for>") {
241 result.push_str("</f-repeat>");
242 return Some(6);
243 }
244 if remaining.starts_with("</route>") {
246 result.push_str("</webui-route>");
247 return Some(8);
248 }
249
250 if starts_with_tag_name(remaining, "outlet") {
253 if let Some(close) = remaining.find('>') {
254 result.push_str("<outlet></outlet>");
255 return Some(close + 1);
256 }
257 }
258
259 if starts_with_tag_name(remaining, "if") {
261 if let Some(consumed) = convert_if_tag(remaining, result) {
262 return Some(consumed);
263 }
264 }
265
266 if starts_with_tag_name(remaining, "for") {
268 if let Some(consumed) = convert_for_tag(remaining, result) {
269 return Some(consumed);
270 }
271 }
272
273 if starts_with_tag_name(remaining, "route") {
275 if let Some(consumed) = convert_route_tag(remaining, result) {
276 return Some(consumed);
277 }
278 }
279
280 if remaining.starts_with("<") {
282 if starts_with_tag_name(remaining, "template") {
284 if let Some(consumed) = strip_shadowrootmode(remaining, result) {
285 return Some(consumed);
286 }
287 }
288 if let Some(consumed) = convert_complex_attrs(remaining, result) {
289 return Some(consumed);
290 }
291 }
292
293 None
294}
295
296fn starts_with_tag_name(s: &str, name: &str) -> bool {
298 if !s.starts_with('<') {
299 return false;
300 }
301 let after_bracket = &s[1..];
302 if !after_bracket.starts_with(name) {
303 return false;
304 }
305 let after_name = &after_bracket[name.len()..];
306 if after_name.is_empty() {
307 return false;
308 }
309 let next = after_name.as_bytes()[0];
310 next == b' ' || next == b'\t' || next == b'\n' || next == b'\r' || next == b'>'
311}
312
313fn convert_if_tag(tag_str: &str, result: &mut String) -> Option<usize> {
316 let close = tag_str.find('>')?;
318 let tag_content = &tag_str[..=close];
319
320 let attr_value = extract_attribute_value(tag_content, "condition")?;
322
323 result.push_str("<f-when value=\"{");
324 result.push_str(attr_value);
325 result.push_str("}\">");
326
327 Some(close + 1)
328}
329
330fn convert_for_tag(tag_str: &str, result: &mut String) -> Option<usize> {
333 let close = tag_str.find('>')?;
335 let tag_content = &tag_str[..=close];
336
337 let attr_value = extract_attribute_value(tag_content, "each")?;
339
340 result.push_str("<f-repeat value=\"{");
341 result.push_str(attr_value);
342 result.push_str("}\">");
343
344 Some(close + 1)
345}
346
347fn convert_route_tag(tag_str: &str, result: &mut String) -> Option<usize> {
351 let close = tag_str.find('>')?;
352 let is_self_closing = tag_str[..=close].ends_with("/>");
353 let tag = &tag_str[..=close];
354
355 result.push_str("<webui-route");
356
357 if let Some(v) = extract_attribute_value(tag, "path") {
358 result.push_str(" path=\"");
359 result.push_str(v);
360 result.push('"');
361 }
362 if let Some(v) = extract_attribute_value(tag, "name") {
363 result.push_str(" name=\"");
364 result.push_str(v);
365 result.push('"');
366 }
367 if let Some(v) = extract_attribute_value(tag, "component") {
368 result.push_str(" component=\"");
369 result.push_str(v);
370 result.push('"');
371 }
372 if let Some(v) = extract_attribute_value(tag, "redirectTo") {
373 result.push_str(" redirectto=\"");
374 result.push_str(v);
375 result.push('"');
376 }
377 if tag.contains(" exact") {
378 result.push_str(" exact");
379 }
380 if tag.contains(" layout") {
381 result.push_str(" layout");
382 }
383 result.push_str(" style=\"display:none\"");
384
385 if is_self_closing {
386 result.push_str("></webui-route>");
387 } else {
388 result.push('>');
389 }
390
391 Some(close + 1)
392}
393
394fn extract_attribute_value<'a>(tag: &'a str, attr_name: &str) -> Option<&'a str> {
397 let mut search_from = 0;
399 loop {
400 let attr_pos = tag[search_from..].find(attr_name)?;
401 let abs_pos = search_from + attr_pos;
402
403 if abs_pos > 0 {
405 let prev = tag.as_bytes()[abs_pos - 1];
406 if prev != b' ' && prev != b'\t' && prev != b'\n' && prev != b'\r' {
407 search_from = abs_pos + attr_name.len();
408 continue;
409 }
410 }
411
412 let after_name = &tag[abs_pos + attr_name.len()..];
413 if !after_name.starts_with('=') {
414 search_from = abs_pos + attr_name.len();
415 continue;
416 }
417
418 let after_eq = &after_name[1..];
419 if after_eq.starts_with('"') {
420 let value_start = abs_pos + attr_name.len() + 2; let end_quote = tag[value_start..].find('"')?;
422 return Some(&tag[value_start..value_start + end_quote]);
423 }
424
425 return None;
426 }
427}
428
429fn convert_complex_attrs(tag_str: &str, result: &mut String) -> Option<usize> {
433 let close = tag_str.find('>')?;
435 let tag_content = &tag_str[..=close];
436
437 if !tag_content.contains(":") || !tag_content.contains("{{") {
439 return None;
440 }
441
442 let mut converted = String::with_capacity(tag_content.len());
444 let tag_bytes = tag_content.as_bytes();
445 let tag_len = tag_bytes.len();
446 let mut j = 0;
447 let mut in_colon_attr_value = false;
448
449 while j < tag_len {
450 if !in_colon_attr_value {
451 if tag_bytes[j] == b':' && j > 0 && is_whitespace(tag_bytes[j - 1]) {
453 let attr_start = j;
455 j += 1;
456 while j < tag_len && tag_bytes[j] != b'=' && !is_whitespace(tag_bytes[j]) {
458 j += 1;
459 }
460 if j < tag_len && tag_bytes[j] == b'=' {
461 j += 1;
462 if j < tag_len && tag_bytes[j] == b'"' {
463 converted.push_str(&tag_content[attr_start..=j]);
465 j += 1;
466 in_colon_attr_value = true;
467 continue;
468 }
469 }
470 converted.push_str(&tag_content[attr_start..j]);
472 continue;
473 }
474 converted.push(tag_bytes[j] as char);
475 j += 1;
476 } else {
477 if tag_bytes[j] == b'"' {
479 converted.push('"');
481 j += 1;
482 in_colon_attr_value = false;
483 } else if j + 1 < tag_len && tag_bytes[j] == b'{' && tag_bytes[j + 1] == b'{' {
484 let expr_start = j + 2;
486 if let Some(end_offset) = tag_content[expr_start..].find("}}") {
487 let expr = &tag_content[expr_start..expr_start + end_offset];
488 converted.push('{');
489 converted.push_str(expr);
490 converted.push('}');
491 j = expr_start + end_offset + 2;
492 } else {
493 converted.push('{');
495 converted.push('{');
496 j += 2;
497 }
498 } else {
499 converted.push(tag_bytes[j] as char);
500 j += 1;
501 }
502 }
503 }
504
505 result.push_str(&converted);
506 Some(close + 1)
507}
508
509fn is_whitespace(b: u8) -> bool {
511 b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'
512}
513
514fn minify_inter_tag_whitespace(input: &str) -> String {
518 let mut result = String::with_capacity(input.len());
519 let bytes = input.as_bytes();
520 let len = bytes.len();
521 let mut i = 0;
522
523 while i < len {
524 if bytes[i] == b'>' && i + 1 < len {
525 result.push('>');
526 i += 1;
527 let ws_start = i;
529 while i < len && is_whitespace(bytes[i]) {
530 i += 1;
531 }
532 if i >= len || bytes[i] != b'<' {
535 result.push_str(&input[ws_start..i]);
537 }
538 } else {
539 result.push(bytes[i] as char);
540 i += 1;
541 }
542 }
543
544 result
545}
546
547fn strip_shadowrootmode(tag_str: &str, result: &mut String) -> Option<usize> {
550 let close = tag_str.find('>')?;
552 let tag_content = &tag_str[..=close];
553
554 if !tag_content.contains("shadowrootmode") {
556 return None;
557 }
558
559 result.push_str("<template");
561 let attr_start = "<template".len();
562 let inner = &tag_content[attr_start..close];
563
564 let inner_bytes = inner.as_bytes();
566 let inner_len = inner_bytes.len();
567 let mut j = 0;
568 while j < inner_len {
569 if is_whitespace(inner_bytes[j]) {
571 j += 1;
572 continue;
573 }
574
575 let attr_begin = j;
577 while j < inner_len && inner_bytes[j] != b'=' && !is_whitespace(inner_bytes[j]) {
579 j += 1;
580 }
581 let attr_name = &inner[attr_begin..j];
582
583 let mut attr_end = j;
585 if j < inner_len && inner_bytes[j] == b'=' {
586 j += 1; if j < inner_len && inner_bytes[j] == b'"' {
588 j += 1; while j < inner_len && inner_bytes[j] != b'"' {
590 j += 1;
591 }
592 if j < inner_len {
593 j += 1; }
595 } else {
596 while j < inner_len && !is_whitespace(inner_bytes[j]) {
598 j += 1;
599 }
600 }
601 attr_end = j;
602 }
603
604 if attr_name == "shadowrootmode" {
606 continue;
607 }
608
609 result.push(' ');
611 result.push_str(&inner[attr_begin..attr_end]);
612 }
613
614 if tag_content.ends_with("/>") {
615 result.push_str("/>");
616 } else {
617 result.push('>');
618 }
619
620 Some(close + 1)
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626 use std::path::PathBuf;
627
628 fn make_component(tag: &str, html: &str, css: Option<&str>) -> Component {
629 Component {
630 tag_name: tag.to_string(),
631 html_content: html.to_string(),
632 css_content: css.map(ToString::to_string),
633 css_tokens: Vec::new(),
634 source_path: PathBuf::from("/test"),
635 class_name: None,
636 }
637 }
638
639 #[test]
642 fn skip_event_binding() {
643 let plugin = FastParserPlugin::new();
644 assert!(plugin.should_skip_attribute("@click"));
645 assert!(plugin.should_skip_attribute("@input"));
646 assert!(plugin.should_skip_attribute("@custom-event"));
647 }
648
649 #[test]
650 fn skip_f_ref() {
651 let plugin = FastParserPlugin::new();
652 assert!(plugin.should_skip_attribute("f-ref"));
653 }
654
655 #[test]
656 fn skip_f_slotted() {
657 let plugin = FastParserPlugin::new();
658 assert!(plugin.should_skip_attribute("f-slotted"));
659 }
660
661 #[test]
662 fn skip_f_children() {
663 let plugin = FastParserPlugin::new();
664 assert!(plugin.should_skip_attribute("f-children"));
665 }
666
667 #[test]
668 fn do_not_skip_normal_attributes() {
669 let plugin = FastParserPlugin::new();
670 assert!(!plugin.should_skip_attribute("class"));
671 assert!(!plugin.should_skip_attribute("id"));
672 assert!(!plugin.should_skip_attribute("data-value"));
673 assert!(!plugin.should_skip_attribute(":title"));
674 assert!(!plugin.should_skip_attribute("f-other"));
675 }
676
677 #[test]
680 fn element_parsed_zero_count_returns_none() {
681 let mut plugin = FastParserPlugin::new();
682 assert!(plugin.on_element_parsed(0).is_none());
683 }
684
685 #[test]
686 fn element_parsed_nonzero_count_returns_le_bytes() {
687 let mut plugin = FastParserPlugin::new();
688 let data = plugin.on_element_parsed(3);
689 assert!(data.is_some());
690 let bytes = data.as_deref().unwrap_or_default();
691 assert_eq!(bytes, &3u32.to_le_bytes());
692 }
693
694 #[test]
695 fn element_parsed_large_count() {
696 let mut plugin = FastParserPlugin::new();
697 let data = plugin.on_element_parsed(256);
698 assert!(data.is_some());
699 let bytes = data.as_deref().unwrap_or_default();
700 assert_eq!(bytes, &256u32.to_le_bytes());
701 }
702
703 #[test]
706 fn body_end_no_components_returns_none() {
707 let mut plugin = FastParserPlugin::new();
708 assert!(plugin.on_body_end().is_none());
709 }
710
711 #[test]
712 fn component_template_simple_component() {
713 let mut plugin = FastParserPlugin::new();
714 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
715 plugin.on_parse_component("my-comp", &comp).unwrap();
716
717 let templates = plugin.take_component_templates();
718 assert_eq!(templates.len(), 1);
719 let (name, html) = &templates[0];
720 assert_eq!(name, "my-comp");
721 assert!(html.contains("<f-template name=\"my-comp\">"));
722 assert!(html.contains("</f-template>"));
723 assert!(html.contains("<template>"));
724 assert!(html.contains("</template>"));
725 assert!(html.contains("<link rel=\"stylesheet\" href=\"/my-comp.css\">"));
726 assert!(html.contains("<div>hello</div>"));
727 }
728
729 #[test]
730 fn component_template_without_css() {
731 let mut plugin = FastParserPlugin::new();
732 let comp = make_component("no-css", "<span>text</span>", None);
733 plugin.on_parse_component("no-css", &comp).unwrap();
734
735 let templates = plugin.take_component_templates();
736 assert_eq!(templates.len(), 1);
737 let (name, html) = &templates[0];
738 assert_eq!(name, "no-css");
739 assert!(html.contains("<f-template name=\"no-css\">"));
740 assert!(!html.contains("<link rel=\"stylesheet\""));
741 assert!(html.contains("<span>text</span>"));
742 }
743
744 #[test]
745 fn component_template_css_strategy_style() {
746 let mut plugin = FastParserPlugin::new();
747 plugin.set_css_strategy(crate::CssStrategy::Style);
748 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
749 plugin.on_parse_component("my-comp", &comp).unwrap();
750
751 let templates = plugin.take_component_templates();
752 assert_eq!(templates.len(), 1);
753 let (_, html) = &templates[0];
754 assert!(
755 html.contains("<style>div { color: red; }</style>"),
756 "Style strategy should inline CSS, got: {html}"
757 );
758 assert!(
759 !html.contains("<link"),
760 "Style strategy should not emit <link> tags"
761 );
762 }
763
764 #[test]
765 fn component_template_css_strategy_module() {
766 let mut plugin = FastParserPlugin::new();
767 plugin.set_css_strategy(crate::CssStrategy::Module);
768 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
769 plugin.on_parse_component("my-comp", &comp).unwrap();
770
771 let templates = plugin.take_component_templates();
772 assert_eq!(templates.len(), 1);
773 let (_, html) = &templates[0];
774
775 assert!(
778 !html.contains(r#"<style type="module""#),
779 "CSS module should not be baked into f-template: {html}"
780 );
781 assert!(
783 html.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
784 "Module strategy should add shadowrootadoptedstylesheets, got: {html}"
785 );
786 assert!(
788 !html.contains("<style>div"),
789 "Module strategy should not have inline <style> inside template"
790 );
791 assert!(
792 !html.contains("<link"),
793 "Module strategy should not emit <link> tags"
794 );
795 }
796
797 #[test]
798 fn component_template_css_strategy_module_no_css() {
799 let mut plugin = FastParserPlugin::new();
800 plugin.set_css_strategy(crate::CssStrategy::Module);
801 let comp = make_component("my-comp", "<div>hello</div>", None);
802 plugin.on_parse_component("my-comp", &comp).unwrap();
803
804 let templates = plugin.take_component_templates();
805 assert_eq!(templates.len(), 1);
806 let (_, html) = &templates[0];
807 assert!(
808 !html.contains("shadowrootadoptedstylesheets"),
809 "No CSS = no shadowrootadoptedstylesheets, got: {html}"
810 );
811 assert!(
812 !html.contains("<style"),
813 "No CSS = no style tag, got: {html}"
814 );
815 }
816
817 #[test]
818 fn component_template_multiple_components() {
819 let mut plugin = FastParserPlugin::new();
820 let comp1 = make_component("comp-a", "<div>A</div>", None);
821 let comp2 = make_component("comp-b", "<div>B</div>", Some("b { }"));
822 plugin.on_parse_component("comp-a", &comp1).unwrap();
823 plugin.on_parse_component("comp-b", &comp2).unwrap();
824
825 let templates = plugin.take_component_templates();
826 assert_eq!(templates.len(), 2);
827
828 let names: Vec<&str> = templates.iter().map(|(n, _)| n.as_str()).collect();
829 assert!(names.contains(&"comp-a"));
830 assert!(names.contains(&"comp-b"));
831
832 for (_, html) in &templates {
833 assert!(html.contains("<f-template name="));
834 }
835 }
836
837 #[test]
838 fn component_template_deduplicates_same_component() {
839 let mut plugin = FastParserPlugin::new();
840 let comp = make_component(
841 "my-button",
842 "<button><slot></slot></button>",
843 Some(".btn{}"),
844 );
845
846 plugin.on_parse_component("my-button", &comp).unwrap();
849 plugin.on_parse_component("my-button", &comp).unwrap();
850 plugin.on_parse_component("my-button", &comp).unwrap();
851
852 let templates = plugin.take_component_templates();
853 assert_eq!(
854 templates.len(),
855 1,
856 "Expected exactly 1 template for my-button, got {}",
857 templates.len()
858 );
859 assert_eq!(templates[0].0, "my-button");
860 }
861
862 #[test]
863 fn component_template_deduplicates_mixed_components() {
864 let mut plugin = FastParserPlugin::new();
865 let btn = make_component("my-button", "<button><slot></slot></button>", None);
866 let card = make_component("my-card", "<div><slot></slot></div>", Some(".card{}"));
867
868 plugin.on_parse_component("my-button", &btn).unwrap();
870 plugin.on_parse_component("my-card", &card).unwrap();
871 plugin.on_parse_component("my-button", &btn).unwrap();
872
873 let templates = plugin.take_component_templates();
874 assert_eq!(templates.len(), 2);
875
876 let button_count = templates.iter().filter(|(n, _)| n == "my-button").count();
877 let card_count = templates.iter().filter(|(n, _)| n == "my-card").count();
878 assert_eq!(button_count, 1);
879 assert_eq!(card_count, 1);
880 }
881
882 #[test]
885 fn convert_if_to_f_when() {
886 let input = r#"<if condition="isComplete"><span>Done</span></if>"#;
887 let output = convert_btr_to_fast(input);
888 assert_eq!(
889 output,
890 r#"<f-when value="{isComplete}"><span>Done</span></f-when>"#
891 );
892 }
893
894 #[test]
895 fn convert_for_to_f_repeat() {
896 let input = r#"<for each="tag in tags"><span>{{tag}}</span></for>"#;
897 let output = convert_btr_to_fast(input);
898 assert_eq!(
899 output,
900 r#"<f-repeat value="{tag in tags}"><span>{{tag}}</span></f-repeat>"#
901 );
902 }
903
904 #[test]
905 fn convert_nested_if_and_for() {
906 let input = r#"<if condition="show"><for each="x in items"><p>{{x}}</p></for></if>"#;
907 let output = convert_btr_to_fast(input);
908 assert_eq!(
909 output,
910 r#"<f-when value="{show}"><f-repeat value="{x in items}"><p>{{x}}</p></f-repeat></f-when>"#
911 );
912 }
913
914 #[test]
915 fn convert_complex_attr_double_braces() {
916 let input = r#"<div :data="{{config}}"></div>"#;
917 let output = convert_btr_to_fast(input);
918 assert_eq!(output, r#"<div :data="{config}"></div>"#);
919 }
920
921 #[test]
922 fn convert_multiple_complex_attrs() {
923 let input = r#"<div :title="{{name}}" :data-id="{{id}}"></div>"#;
924 let output = convert_btr_to_fast(input);
925 assert_eq!(output, r#"<div :title="{name}" :data-id="{id}"></div>"#);
926 }
927
928 #[test]
929 fn no_conversion_for_text_double_braces() {
930 let input = "<span>{{greeting}}</span>";
932 let output = convert_btr_to_fast(input);
933 assert_eq!(output, "<span>{{greeting}}</span>");
934 }
935
936 #[test]
937 fn no_conversion_for_regular_attr() {
938 let input = r#"<div title="{{name}}"></div>"#;
940 let output = convert_btr_to_fast(input);
941 assert_eq!(output, r#"<div title="{{name}}"></div>"#);
942 }
943
944 #[test]
945 fn component_template_with_btr_conversion() {
946 let mut plugin = FastParserPlugin::new();
947 let html = r#"<div><if condition="visible"><span>hi</span></if></div>"#;
948 let comp = make_component("my-widget", html, None);
949 plugin.on_parse_component("my-widget", &comp).unwrap();
950
951 let templates = plugin.take_component_templates();
952 assert_eq!(templates.len(), 1);
953 let (name, result) = &templates[0];
954 assert_eq!(name, "my-widget");
955
956 assert!(result.contains("<f-when value=\"{visible}\">"));
957 assert!(result.contains("</f-when>"));
958 assert!(!result.contains("<if condition="));
959 assert!(!result.contains("</if>"));
960 }
961
962 #[test]
963 fn passthrough_normal_html() {
964 let input = r#"<div class="container"><p>Hello</p></div>"#;
965 let output = convert_btr_to_fast(input);
966 assert_eq!(output, input);
967 }
968
969 #[test]
970 fn outlet_kept_as_marker_in_f_template() {
971 let input = r#"<div><outlet /></div>"#;
972 let output = convert_btr_to_fast(input);
973 assert_eq!(output, r#"<div><outlet></outlet></div>"#);
974 }
975
976 #[test]
977 fn outlet_marker_in_component_template() {
978 let mut plugin = FastParserPlugin::new();
979 let html =
980 r#"<template shadowrootmode="open"><h1>Title</h1><main><outlet /></main></template>"#;
981 let comp = make_component("my-shell", html, None);
982 plugin.on_parse_component("my-shell", &comp).unwrap();
983
984 let templates = plugin.take_component_templates();
985 let (_, result) = &templates[0];
986 assert!(
987 result.contains("<outlet></outlet>"),
988 "outlet should be kept as marker in f-template: {result}"
989 );
990 }
991
992 #[test]
993 fn strip_shadowrootmode_from_template() {
994 let input = r#"<template shadowrootmode="open"><div>Hello</div></template>"#;
995 let output = convert_btr_to_fast(input);
996 assert_eq!(output, "<template><div>Hello</div></template>");
997 }
998
999 #[test]
1000 fn strip_shadowrootmode_preserves_other_attrs() {
1001 let input =
1002 r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>Hi</div></template>"#;
1003 let output = convert_btr_to_fast(input);
1004 assert!(output.contains(r#"@click="{onClick(e)}""#));
1005 assert!(!output.contains("shadowrootmode"));
1006 assert!(output.starts_with("<template "));
1007 }
1008
1009 #[test]
1010 fn no_strip_when_no_shadowrootmode() {
1011 let input = r#"<template foo="bar"><div>Hi</div></template>"#;
1012 let output = convert_btr_to_fast(input);
1013 assert_eq!(output, input);
1014 }
1015
1016 #[test]
1017 fn component_template_strips_shadowrootmode_from_f_template() {
1018 let mut plugin = FastParserPlugin::new();
1019 let comp = make_component(
1020 "my-comp",
1021 r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>{{title}}</div></template>"#,
1022 Some("div { color: red; }"),
1023 );
1024 plugin.on_parse_component("my-comp", &comp).unwrap();
1025 let templates = plugin.take_component_templates();
1026 assert_eq!(templates.len(), 1);
1027 let (name, html) = &templates[0];
1028 assert_eq!(name, "my-comp");
1029 assert!(!html.contains("shadowrootmode"));
1031 assert!(html.contains("@click"));
1033 assert!(html.contains("{{title}}"));
1035 }
1036
1037 #[test]
1038 fn test_complex_attribute_normalization() {
1039 let cases = [
1042 (
1043 r#"<div :foo="{{value}}"></div>"#,
1044 r#"<div :foo="{value}"></div>"#,
1045 ),
1046 (
1047 r#"<div :foo="{{ value}}"></div>"#,
1048 r#"<div :foo="{ value}"></div>"#,
1049 ),
1050 (
1051 r#"<div :foo="{{value }}"></div>"#,
1052 r#"<div :foo="{value }"></div>"#,
1053 ),
1054 (
1055 r#"<div :foo="{{ value }}"></div>"#,
1056 r#"<div :foo="{ value }"></div>"#,
1057 ),
1058 ];
1059 for (input, expected) in &cases {
1060 let output = convert_btr_to_fast(input);
1061 assert_eq!(&output, expected, "normalization failed for input: {input}");
1062 }
1063 }
1064
1065 #[test]
1066 fn test_non_js_component_inner_template() {
1067 let mut plugin = FastParserPlugin::new();
1068 let html = r#"<div><child-comp><template shadowrootmode="open"><p>inner</p></template></child-comp></div>"#;
1071 let comp = make_component("parent-comp", html, None);
1072 plugin.on_parse_component("parent-comp", &comp).unwrap();
1073
1074 let templates = plugin.take_component_templates();
1075 assert_eq!(templates.len(), 1);
1076 let (name, output) = &templates[0];
1077 assert_eq!(name, "parent-comp");
1078
1079 assert!(output.contains("<f-template name=\"parent-comp\">"));
1080 assert!(!output.contains("shadowrootmode"));
1082 assert!(output.contains("<child-comp><template><p>inner</p></template></child-comp>"));
1084 }
1085
1086 #[test]
1087 fn route_tags_converted_to_f_route() {
1088 let input = r#"<route path="/home" name="home" exact><span>Home</span></route>"#;
1089 let output = convert_btr_to_fast(input);
1090 assert!(output.starts_with("<webui-route"));
1091 assert!(output.contains(r#"path="/home""#));
1092 assert!(output.contains(r#"name="home""#));
1093 assert!(output.contains(" exact"));
1094 assert!(output.contains("style=\"display:none\""));
1095 assert!(output.contains("<span>Home</span>"));
1096 assert!(output.ends_with("</webui-route>"));
1097 }
1098
1099 #[test]
1100 fn f_route_tags_pass_through() {
1101 let input = r#"<webui-route path="/" name="dashboard" component="cb-page-dashboard" exact style="display:none"></webui-route>"#;
1103 let output = convert_btr_to_fast(input);
1104 assert_eq!(output, input);
1105 }
1106}