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