1use super::{AttributeAction, ComponentTemplateArtifact, ParserPlugin, ParserPluginArtifacts};
11use crate::component_registry::Component;
12use crate::html_parser::{find_element_end, find_tag_close, opening_tag_name};
13use crate::{CssLinkOptions, CssStrategy, Result};
14use webui_protocol::FastElementData;
15
16struct TrackedComponent {
18 tag_name: String,
19 template_html: String,
20}
21
22pub struct FastV3ParserPlugin {
30 components: Vec<TrackedComponent>,
32}
33
34impl FastV3ParserPlugin {
35 #[must_use]
37 pub fn new() -> Self {
38 Self {
39 components: Vec::new(),
40 }
41 }
42
43 #[must_use]
49 pub fn take_component_templates(&self) -> Vec<ComponentTemplateArtifact> {
50 self.components
51 .iter()
52 .map(|comp| {
53 let tmpl = generate_f_template_from_processed(&comp.tag_name, &comp.template_html);
54 ComponentTemplateArtifact::template(comp.tag_name.clone(), tmpl)
55 })
56 .collect()
57 }
58}
59
60impl Default for FastV3ParserPlugin {
61 fn default() -> Self {
62 Self::new()
63 }
64}
65
66impl ParserPlugin for FastV3ParserPlugin {
67 fn register_component_template(
68 &mut self,
69 tag_name: &str,
70 component: &Component,
71 processed_template: &str,
72 ) -> Result<()> {
73 if self.components.iter().any(|c| c.tag_name == tag_name) {
76 return Ok(());
77 }
78 self.components.push(TrackedComponent {
79 tag_name: tag_name.to_string(),
80 template_html: processed_template.to_string(),
81 });
82 let _ = component;
83 Ok(())
84 }
85
86 fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
87 if attr_name.starts_with('@')
88 || attr_name == "f-ref"
89 || attr_name == "f-slotted"
90 || attr_name == "f-children"
91 {
92 AttributeAction::SkipAndCountBinding
93 } else {
94 AttributeAction::Keep
95 }
96 }
97
98 fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
99 if binding_attribute_count > 0 {
100 Some(
101 FastElementData {
102 binding_count: binding_attribute_count,
103 }
104 .encode()
105 .to_vec(),
106 )
107 } else {
108 None
109 }
110 }
111
112 fn into_artifacts(self: Box<Self>) -> Result<ParserPluginArtifacts> {
113 Ok(ParserPluginArtifacts::ComponentTemplates(
114 self.take_component_templates(),
115 ))
116 }
117}
118
119pub fn generate_f_template(
128 tag_name: &str,
129 html_content: &str,
130 css_content: Option<&str>,
131 css_strategy: CssStrategy,
132) -> String {
133 generate_f_template_with_css_options(
134 tag_name,
135 html_content,
136 css_content,
137 css_strategy,
138 &CssLinkOptions::default(),
139 )
140}
141
142fn generate_f_template_from_processed(tag_name: &str, processed_template: &str) -> String {
143 let mut output = String::with_capacity(256);
144 output.push_str("<f-template name=\"");
145 output.push_str(tag_name);
146 output.push_str("\">\n");
147
148 let converted = convert_btr_to_fast(processed_template);
149 let trimmed = minify_inter_tag_whitespace(converted.trim());
150
151 if trimmed.starts_with("<template") {
152 output.push_str(&trimmed);
153 } else {
154 output.push_str("<template>");
155 output.push_str(&trimmed);
156 output.push_str("</template>");
157 }
158
159 output.push_str("\n</f-template>\n");
160 output
161}
162
163pub fn generate_f_template_with_css_options(
165 tag_name: &str,
166 html_content: &str,
167 css_content: Option<&str>,
168 css_strategy: CssStrategy,
169 css_link_options: &CssLinkOptions,
170) -> String {
171 let mut output = String::with_capacity(256);
172 output.push_str("<f-template name=\"");
173 output.push_str(tag_name);
174 output.push_str("\">\n");
175
176 let converted = convert_btr_to_fast(html_content);
177 let trimmed = minify_inter_tag_whitespace(converted.trim());
178
179 let css_injection = match css_strategy {
181 CssStrategy::Link => css_content.map(|css| {
182 let href = css_link_options.resolve(tag_name, css);
183 let mut s = String::with_capacity(40 + href.href.len());
184 s.push_str("<link rel=\"stylesheet\" href=\"");
185 s.push_str(&href.href);
186 s.push_str("\">");
187 s
188 }),
189 CssStrategy::Style => css_content.map(|css| {
190 let mut s = String::with_capacity(15 + css.len());
191 s.push_str("<style>");
192 s.push_str(css.trim());
193 s.push_str("</style>");
194 s
195 }),
196 CssStrategy::Module => None,
197 };
198
199 if trimmed.starts_with("<template") {
200 if let Some(close_pos) = find_tag_close(&trimmed) {
201 output.push_str(&trimmed[..close_pos]);
206 output.push('>');
207 if let Some(ref injection) = css_injection {
208 output.push_str(injection);
209 }
210 output.push_str(&trimmed[close_pos + 1..]);
211 } else {
212 output.push_str(&trimmed);
213 }
214 } else {
215 output.push_str("<template");
216 if css_strategy == CssStrategy::Module && css_content.is_some() {
217 output.push_str(" shadowrootadoptedstylesheets=\"");
218 output.push_str(tag_name);
219 output.push('"');
220 }
221 output.push('>');
222 if let Some(ref injection) = css_injection {
223 output.push_str(injection);
224 }
225 output.push_str(&trimmed);
226 output.push_str("</template>");
227 }
228
229 output.push_str("\n</f-template>\n");
230 output
231}
232
233fn convert_btr_to_fast(input: &str) -> String {
245 let mut result = String::with_capacity(input.len());
246 let bytes = input.as_bytes();
247 let len = bytes.len();
248 let mut i = 0;
249
250 while i < len {
251 if bytes[i] == b'<' {
252 if let Some(consumed) = try_convert_tag(input, i, &mut result) {
253 i += consumed;
254 continue;
255 }
256 }
257
258 i = push_char_at(input, i, &mut result);
262 }
263
264 result
265}
266
267fn try_convert_tag(input: &str, pos: usize, result: &mut String) -> Option<usize> {
270 let remaining = &input[pos..];
271
272 if remaining.starts_with("</if>") {
274 result.push_str("</f-when>");
275 return Some(5);
276 }
277 if remaining.starts_with("</for>") {
278 result.push_str("</f-repeat>");
279 return Some(6);
280 }
281 if remaining.starts_with("</route>") {
283 result.push_str("</webui-route>");
284 return Some(8);
285 }
286
287 if let Some(consumed) = find_element_end(remaining, "outlet") {
290 result.push_str("<outlet></outlet>");
291 return Some(consumed);
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 opening_tag_name(s).is_some_and(|tag_name| tag_name == name)
334}
335
336fn convert_if_tag(tag_str: &str, result: &mut String) -> Option<usize> {
339 let close = find_tag_close(tag_str)?;
341 let tag_content = &tag_str[..=close];
342
343 let attr_value = extract_attribute_value(tag_content, "condition")?;
345
346 result.push_str("<f-when value=\"{{");
347 result.push_str(attr_value);
348 result.push_str("}}\">");
349
350 Some(close + 1)
351}
352
353fn convert_for_tag(tag_str: &str, result: &mut String) -> Option<usize> {
356 let close = find_tag_close(tag_str)?;
358 let tag_content = &tag_str[..=close];
359
360 let attr_value = extract_attribute_value(tag_content, "each")?;
362
363 result.push_str("<f-repeat value=\"{{");
364 result.push_str(attr_value);
365 result.push_str("}}\">");
366
367 Some(close + 1)
368}
369
370fn convert_route_tag(tag_str: &str, result: &mut String) -> Option<usize> {
374 let close = find_tag_close(tag_str)?;
375 let is_self_closing = tag_str[..=close].ends_with("/>");
376 let tag = &tag_str[..=close];
377
378 result.push_str("<webui-route");
379
380 if let Some(v) = extract_attribute_value(tag, "path") {
381 result.push_str(" path=\"");
382 result.push_str(v);
383 result.push('"');
384 }
385 if let Some(v) = extract_attribute_value(tag, "name") {
386 result.push_str(" name=\"");
387 result.push_str(v);
388 result.push('"');
389 }
390 if let Some(v) = extract_attribute_value(tag, "component") {
391 result.push_str(" component=\"");
392 result.push_str(v);
393 result.push('"');
394 }
395 if let Some(v) = extract_attribute_value(tag, "redirectTo") {
396 result.push_str(" redirectto=\"");
397 result.push_str(v);
398 result.push('"');
399 }
400 if tag.contains(" exact") {
401 result.push_str(" exact");
402 }
403 if tag.contains(" layout") {
404 result.push_str(" layout");
405 }
406 if let Some(v) = extract_attribute_value(tag, "query") {
407 result.push_str(" query=\"");
408 result.push_str(v);
409 result.push('"');
410 }
411 result.push_str(" style=\"display:none\"");
412
413 if is_self_closing {
414 result.push_str("></webui-route>");
415 } else {
416 result.push('>');
417 }
418
419 Some(close + 1)
420}
421
422fn extract_attribute_value<'a>(tag: &'a str, attr_name: &str) -> Option<&'a str> {
425 let mut search_from = 0;
427 loop {
428 let attr_pos = tag[search_from..].find(attr_name)?;
429 let abs_pos = search_from + attr_pos;
430
431 if abs_pos > 0 {
433 let prev = tag.as_bytes()[abs_pos - 1];
434 if prev != b' ' && prev != b'\t' && prev != b'\n' && prev != b'\r' {
435 search_from = abs_pos + attr_name.len();
436 continue;
437 }
438 }
439
440 let after_name = &tag[abs_pos + attr_name.len()..];
441 if !after_name.starts_with('=') {
442 search_from = abs_pos + attr_name.len();
443 continue;
444 }
445
446 let after_eq = &after_name[1..];
447 if after_eq.starts_with('"') {
448 let value_start = abs_pos + attr_name.len() + 2; let end_quote = tag[value_start..].find('"')?;
450 return Some(&tag[value_start..value_start + end_quote]);
451 }
452
453 return None;
454 }
455}
456
457fn convert_complex_attrs(tag_str: &str, result: &mut String) -> Option<usize> {
461 let close = find_tag_close(tag_str)?;
463 let tag_content = &tag_str[..=close];
464
465 if !tag_content.contains(":") || !tag_content.contains("{{") {
467 return None;
468 }
469
470 let mut converted = String::with_capacity(tag_content.len());
472 let tag_bytes = tag_content.as_bytes();
473 let tag_len = tag_bytes.len();
474 let mut j = 0;
475 let mut in_colon_attr_value = false;
476
477 while j < tag_len {
478 if !in_colon_attr_value {
479 if tag_bytes[j] == b':' && j > 0 && is_whitespace(tag_bytes[j - 1]) {
481 let attr_start = j;
483 j += 1;
484 while j < tag_len && tag_bytes[j] != b'=' && !is_whitespace(tag_bytes[j]) {
486 j += 1;
487 }
488 if j < tag_len && tag_bytes[j] == b'=' {
489 j += 1;
490 if j < tag_len && tag_bytes[j] == b'"' {
491 converted.push_str(&tag_content[attr_start..=j]);
493 j += 1;
494 in_colon_attr_value = true;
495 continue;
496 }
497 }
498 converted.push_str(&tag_content[attr_start..j]);
500 continue;
501 }
502 j = push_char_at(tag_content, j, &mut converted);
503 } else {
504 if tag_bytes[j] == b'"' {
506 converted.push('"');
508 j += 1;
509 in_colon_attr_value = false;
510 } else if j + 1 < tag_len && tag_bytes[j] == b'{' && tag_bytes[j + 1] == b'{' {
511 let expr_start = j + 2;
513 if let Some(end_offset) = tag_content[expr_start..].find("}}") {
514 let expr = &tag_content[expr_start..expr_start + end_offset];
515 converted.push('{');
516 converted.push_str(expr);
517 converted.push('}');
518 j = expr_start + end_offset + 2;
519 } else {
520 converted.push('{');
522 converted.push('{');
523 j += 2;
524 }
525 } else {
526 j = push_char_at(tag_content, j, &mut converted);
527 }
528 }
529 }
530
531 result.push_str(&converted);
532 Some(close + 1)
533}
534
535fn push_char_at(input: &str, pos: usize, out: &mut String) -> usize {
538 let bytes = input.as_bytes();
539 if bytes[pos].is_ascii() {
540 out.push(bytes[pos] as char);
541 pos + 1
542 } else {
543 let mut end = pos + 1;
544 while end < bytes.len() && !input.is_char_boundary(end) {
545 end += 1;
546 }
547 out.push_str(&input[pos..end]);
548 end
549 }
550}
551
552fn is_whitespace(b: u8) -> bool {
554 b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'
555}
556
557fn minify_inter_tag_whitespace(input: &str) -> String {
561 let mut result = String::with_capacity(input.len());
562 let bytes = input.as_bytes();
563 let len = bytes.len();
564 let mut i = 0;
565
566 while i < len {
567 if bytes[i] == b'>' && i + 1 < len {
568 result.push('>');
569 i += 1;
570 let ws_start = i;
572 while i < len && is_whitespace(bytes[i]) {
573 i += 1;
574 }
575 if i >= len || bytes[i] != b'<' {
578 result.push_str(&input[ws_start..i]);
580 }
581 } else {
582 i = push_char_at(input, i, &mut result);
583 }
584 }
585
586 result
587}
588
589fn strip_shadowrootmode(tag_str: &str, result: &mut String) -> Option<usize> {
592 let close = find_tag_close(tag_str)?;
594 let tag_content = &tag_str[..=close];
595
596 if !tag_content.contains("shadowrootmode") {
598 return None;
599 }
600
601 result.push_str("<template");
603 let attr_start = "<template".len();
604 let inner = &tag_content[attr_start..close];
605
606 let inner_bytes = inner.as_bytes();
608 let inner_len = inner_bytes.len();
609 let mut j = 0;
610 while j < inner_len {
611 if is_whitespace(inner_bytes[j]) {
613 j += 1;
614 continue;
615 }
616
617 let attr_begin = j;
619 while j < inner_len && inner_bytes[j] != b'=' && !is_whitespace(inner_bytes[j]) {
621 j += 1;
622 }
623 let attr_name = &inner[attr_begin..j];
624
625 let mut attr_end = j;
627 if j < inner_len && inner_bytes[j] == b'=' {
628 j += 1; if j < inner_len && inner_bytes[j] == b'"' {
630 j += 1; while j < inner_len && inner_bytes[j] != b'"' {
632 j += 1;
633 }
634 if j < inner_len {
635 j += 1; }
637 } else {
638 while j < inner_len && !is_whitespace(inner_bytes[j]) {
640 j += 1;
641 }
642 }
643 attr_end = j;
644 }
645
646 if attr_name == "shadowrootmode" {
648 continue;
649 }
650
651 result.push(' ');
653 result.push_str(&inner[attr_begin..attr_end]);
654 }
655
656 if tag_content.ends_with("/>") {
657 result.push_str("/>");
658 } else {
659 result.push('>');
660 }
661
662 Some(close + 1)
663}
664
665#[cfg(test)]
666mod tests {
667 #![allow(clippy::disallowed_methods)]
668
669 use super::*;
670 fn make_component(tag: &str, html: &str, css: Option<&str>) -> Component {
671 Component {
672 tag_name: tag.to_string(),
673 html_content: html.to_string(),
674 css_content: css.map(ToString::to_string),
675 css_definitions: Vec::new(),
676 css_fallback_chains: Vec::new(),
677 is_client_owned: false,
678 }
679 }
680
681 #[test]
684 fn skip_event_binding() {
685 let mut plugin = FastV3ParserPlugin::new();
686 assert_eq!(
687 plugin.classify_attribute("@click"),
688 AttributeAction::SkipAndCountBinding
689 );
690 assert_eq!(
691 plugin.classify_attribute("@input"),
692 AttributeAction::SkipAndCountBinding
693 );
694 assert_eq!(
695 plugin.classify_attribute("@custom-event"),
696 AttributeAction::SkipAndCountBinding
697 );
698 }
699
700 #[test]
701 fn skip_f_ref() {
702 let mut plugin = FastV3ParserPlugin::new();
703 assert_eq!(
704 plugin.classify_attribute("f-ref"),
705 AttributeAction::SkipAndCountBinding
706 );
707 }
708
709 #[test]
710 fn skip_f_slotted() {
711 let mut plugin = FastV3ParserPlugin::new();
712 assert_eq!(
713 plugin.classify_attribute("f-slotted"),
714 AttributeAction::SkipAndCountBinding
715 );
716 }
717
718 #[test]
719 fn skip_f_children() {
720 let mut plugin = FastV3ParserPlugin::new();
721 assert_eq!(
722 plugin.classify_attribute("f-children"),
723 AttributeAction::SkipAndCountBinding
724 );
725 }
726
727 #[test]
728 fn do_not_skip_normal_attributes() {
729 let mut plugin = FastV3ParserPlugin::new();
730 assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
731 assert_eq!(plugin.classify_attribute("id"), AttributeAction::Keep);
732 assert_eq!(
733 plugin.classify_attribute("data-value"),
734 AttributeAction::Keep
735 );
736 assert_eq!(plugin.classify_attribute(":title"), AttributeAction::Keep);
737 assert_eq!(plugin.classify_attribute("f-other"), AttributeAction::Keep);
738 }
739
740 #[test]
743 fn element_parsed_zero_count_returns_none() {
744 let mut plugin = FastV3ParserPlugin::new();
745 assert!(plugin.finish_element(0).is_none());
746 }
747
748 #[test]
749 fn element_parsed_nonzero_count_returns_le_bytes() {
750 let mut plugin = FastV3ParserPlugin::new();
751 let data = plugin.finish_element(3);
752 assert!(data.is_some());
753 let bytes = data.as_deref().unwrap_or_default();
754 assert_eq!(bytes, &3u32.to_le_bytes());
755 }
756
757 #[test]
758 fn element_parsed_large_count() {
759 let mut plugin = FastV3ParserPlugin::new();
760 let data = plugin.finish_element(256);
761 assert!(data.is_some());
762 let bytes = data.as_deref().unwrap_or_default();
763 assert_eq!(bytes, &256u32.to_le_bytes());
764 }
765
766 #[test]
767 fn component_template_simple_component() {
768 let mut plugin = FastV3ParserPlugin::new();
769 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
770 plugin
771 .register_component_template(
772 "my-comp",
773 &comp,
774 r#"<template><link rel="stylesheet" href="my-comp.css"><div>hello</div></template>"#,
775 )
776 .unwrap();
777
778 let templates = plugin.take_component_templates();
779 assert_eq!(templates.len(), 1);
780 let name = &templates[0].tag_name;
781 let html = &templates[0].template;
782 assert_eq!(name, "my-comp");
783 assert!(html.contains("<f-template name=\"my-comp\">"));
784 assert!(html.contains("</f-template>"));
785 assert!(html.contains("<template>"));
786 assert!(html.contains("</template>"));
787 assert!(html.contains("<link rel=\"stylesheet\" href=\"my-comp.css\">"));
788 assert!(html.contains("<div>hello</div>"));
789 }
790
791 #[test]
792 fn component_template_without_css() {
793 let mut plugin = FastV3ParserPlugin::new();
794 let comp = make_component("no-css", "<span>text</span>", None);
795 plugin
796 .register_component_template("no-css", &comp, &comp.html_content)
797 .unwrap();
798
799 let templates = plugin.take_component_templates();
800 assert_eq!(templates.len(), 1);
801 let name = &templates[0].tag_name;
802 let html = &templates[0].template;
803 assert_eq!(name, "no-css");
804 assert!(html.contains("<f-template name=\"no-css\">"));
805 assert!(!html.contains("<link rel=\"stylesheet\""));
806 assert!(html.contains("<span>text</span>"));
807 }
808
809 #[test]
810 fn component_template_css_strategy_style() {
811 let mut plugin = FastV3ParserPlugin::new();
812 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
813 plugin
814 .register_component_template(
815 "my-comp",
816 &comp,
817 r#"<template><style>div { color: red; }</style><div>hello</div></template>"#,
818 )
819 .unwrap();
820
821 let templates = plugin.take_component_templates();
822 assert_eq!(templates.len(), 1);
823 let html = &templates[0].template;
824 assert!(
825 html.contains("<style>div { color: red; }</style>"),
826 "Style strategy should inline CSS, got: {html}"
827 );
828 assert!(
829 !html.contains("<link"),
830 "Style strategy should not emit <link> tags"
831 );
832 }
833
834 #[test]
835 fn component_template_css_strategy_module() {
836 let mut plugin = FastV3ParserPlugin::new();
837 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
838 plugin
839 .register_component_template(
840 "my-comp",
841 &comp,
842 r#"<template shadowrootadoptedstylesheets="my-comp"><div>hello</div></template>"#,
843 )
844 .unwrap();
845
846 let templates = plugin.take_component_templates();
847 assert_eq!(templates.len(), 1);
848 let html = &templates[0].template;
849
850 assert!(
853 !html.contains(r#"<style type="module""#),
854 "Legacy CSS module shape should not be baked into f-template: {html}"
855 );
856 assert!(
857 !html.contains(r#"<script type="importmap""#),
858 "CSS module importmap should not be baked into f-template: {html}"
859 );
860 assert!(
862 html.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
863 "Module strategy should add shadowrootadoptedstylesheets, got: {html}"
864 );
865 assert!(
867 !html.contains("<style>div"),
868 "Module strategy should not have inline <style> inside template"
869 );
870 assert!(
871 !html.contains("<link"),
872 "Module strategy should not emit <link> tags"
873 );
874 }
875
876 #[test]
877 fn component_template_css_strategy_module_no_css() {
878 let mut plugin = FastV3ParserPlugin::new();
879 let comp = make_component("my-comp", "<div>hello</div>", None);
880 plugin
881 .register_component_template(
882 "my-comp",
883 &comp,
884 r#"<template><div>hello</div></template>"#,
885 )
886 .unwrap();
887
888 let templates = plugin.take_component_templates();
889 assert_eq!(templates.len(), 1);
890 let html = &templates[0].template;
891 assert!(
892 !html.contains("shadowrootadoptedstylesheets"),
893 "No CSS = no shadowrootadoptedstylesheets, got: {html}"
894 );
895 assert!(
896 !html.contains("<style"),
897 "No CSS = no style tag, got: {html}"
898 );
899 }
900
901 #[test]
902 fn component_template_multiple_components() {
903 let mut plugin = FastV3ParserPlugin::new();
904 let comp1 = make_component("comp-a", "<div>A</div>", None);
905 let comp2 = make_component("comp-b", "<div>B</div>", Some("b { }"));
906 plugin
907 .register_component_template("comp-a", &comp1, &comp1.html_content)
908 .unwrap();
909 plugin
910 .register_component_template("comp-b", &comp2, &comp2.html_content)
911 .unwrap();
912
913 let templates = plugin.take_component_templates();
914 assert_eq!(templates.len(), 2);
915
916 let names: Vec<&str> = templates
917 .iter()
918 .map(|artifact| artifact.tag_name.as_str())
919 .collect();
920 assert!(names.contains(&"comp-a"));
921 assert!(names.contains(&"comp-b"));
922
923 for artifact in &templates {
924 assert!(artifact.template.contains("<f-template name="));
925 }
926 }
927
928 #[test]
929 fn component_template_deduplicates_same_component() {
930 let mut plugin = FastV3ParserPlugin::new();
931 let comp = make_component(
932 "my-button",
933 "<button><slot></slot></button>",
934 Some(".btn{}"),
935 );
936
937 plugin
940 .register_component_template("my-button", &comp, &comp.html_content)
941 .unwrap();
942 plugin
943 .register_component_template("my-button", &comp, &comp.html_content)
944 .unwrap();
945 plugin
946 .register_component_template("my-button", &comp, &comp.html_content)
947 .unwrap();
948
949 let templates = plugin.take_component_templates();
950 assert_eq!(
951 templates.len(),
952 1,
953 "Expected exactly 1 template for my-button, got {}",
954 templates.len()
955 );
956 assert_eq!(templates[0].tag_name, "my-button");
957 }
958
959 #[test]
960 fn component_template_deduplicates_mixed_components() {
961 let mut plugin = FastV3ParserPlugin::new();
962 let btn = make_component("my-button", "<button><slot></slot></button>", None);
963 let card = make_component("my-card", "<div><slot></slot></div>", Some(".card{}"));
964
965 plugin
967 .register_component_template("my-button", &btn, &btn.html_content)
968 .unwrap();
969 plugin
970 .register_component_template("my-card", &card, &card.html_content)
971 .unwrap();
972 plugin
973 .register_component_template("my-button", &btn, &btn.html_content)
974 .unwrap();
975
976 let templates = plugin.take_component_templates();
977 assert_eq!(templates.len(), 2);
978
979 let button_count = templates
980 .iter()
981 .filter(|artifact| artifact.tag_name == "my-button")
982 .count();
983 let card_count = templates
984 .iter()
985 .filter(|artifact| artifact.tag_name == "my-card")
986 .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 = FastV3ParserPlugin::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 = &templates[0].tag_name;
1089 let result = &templates[0].template;
1090 assert_eq!(name, "my-widget");
1091
1092 assert!(result.contains("<f-when value=\"{{visible}}\">"));
1093 assert!(result.contains("</f-when>"));
1094 assert!(!result.contains("<if condition="));
1095 assert!(!result.contains("</if>"));
1096 }
1097
1098 #[test]
1099 fn passthrough_normal_html() {
1100 let input = r#"<div class="container"><p>Hello</p></div>"#;
1101 let output = convert_btr_to_fast(input);
1102 assert_eq!(output, input);
1103 }
1104
1105 #[test]
1106 fn outlet_kept_as_marker_in_f_template() {
1107 for input in [
1108 r#"<div><outlet /></div>"#,
1109 r#"<div><outlet></outlet></div>"#,
1110 ] {
1111 let output = convert_btr_to_fast(input);
1112 assert_eq!(output, r#"<div><outlet></outlet></div>"#);
1113 }
1114 }
1115
1116 #[test]
1117 fn outlet_marker_in_component_template() {
1118 let mut plugin = FastV3ParserPlugin::new();
1119 let html =
1120 r#"<template shadowrootmode="open"><h1>Title</h1><main><outlet /></main></template>"#;
1121 let comp = make_component("my-shell", html, None);
1122 plugin
1123 .register_component_template("my-shell", &comp, &comp.html_content)
1124 .unwrap();
1125
1126 let templates = plugin.take_component_templates();
1127 let result = &templates[0].template;
1128 assert!(
1129 result.contains("<outlet></outlet>"),
1130 "outlet should be kept as marker in f-template: {result}"
1131 );
1132 }
1133
1134 #[test]
1135 fn strip_shadowrootmode_from_template() {
1136 let input = r#"<template shadowrootmode="open"><div>Hello</div></template>"#;
1137 let output = convert_btr_to_fast(input);
1138 assert_eq!(output, "<template><div>Hello</div></template>");
1139 }
1140
1141 #[test]
1142 fn strip_shadowrootmode_preserves_other_attrs() {
1143 let input =
1144 r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>Hi</div></template>"#;
1145 let output = convert_btr_to_fast(input);
1146 assert!(output.contains(r#"@click="{onClick(e)}""#));
1147 assert!(!output.contains("shadowrootmode"));
1148 assert!(output.starts_with("<template "));
1149 }
1150
1151 #[test]
1152 fn no_strip_when_no_shadowrootmode() {
1153 let input = r#"<template foo="bar"><div>Hi</div></template>"#;
1154 let output = convert_btr_to_fast(input);
1155 assert_eq!(output, input);
1156 }
1157
1158 #[test]
1159 fn component_template_strips_shadowrootmode_from_f_template() {
1160 let mut plugin = FastV3ParserPlugin::new();
1161 let comp = make_component(
1162 "my-comp",
1163 r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>{{title}}</div></template>"#,
1164 Some("div { color: red; }"),
1165 );
1166 plugin
1167 .register_component_template("my-comp", &comp, &comp.html_content)
1168 .unwrap();
1169 let templates = plugin.take_component_templates();
1170 assert_eq!(templates.len(), 1);
1171 let name = &templates[0].tag_name;
1172 let html = &templates[0].template;
1173 assert_eq!(name, "my-comp");
1174 assert!(!html.contains("shadowrootmode"));
1176 assert!(html.contains("@click"));
1178 assert!(html.contains("{{title}}"));
1180 }
1181
1182 #[test]
1183 fn test_complex_attribute_normalization() {
1184 let cases = [
1187 (
1188 r#"<div :foo="{{value}}"></div>"#,
1189 r#"<div :foo="{value}"></div>"#,
1190 ),
1191 (
1192 r#"<div :foo="{{ value}}"></div>"#,
1193 r#"<div :foo="{ value}"></div>"#,
1194 ),
1195 (
1196 r#"<div :foo="{{value }}"></div>"#,
1197 r#"<div :foo="{value }"></div>"#,
1198 ),
1199 (
1200 r#"<div :foo="{{ value }}"></div>"#,
1201 r#"<div :foo="{ value }"></div>"#,
1202 ),
1203 ];
1204 for (input, expected) in &cases {
1205 let output = convert_btr_to_fast(input);
1206 assert_eq!(&output, expected, "normalization failed for input: {input}");
1207 }
1208 }
1209
1210 #[test]
1211 fn test_non_js_component_inner_template() {
1212 let mut plugin = FastV3ParserPlugin::new();
1213 let html = r#"<div><child-comp><template shadowrootmode="open"><p>inner</p></template></child-comp></div>"#;
1216 let comp = make_component("parent-comp", html, None);
1217 plugin
1218 .register_component_template("parent-comp", &comp, &comp.html_content)
1219 .unwrap();
1220
1221 let templates = plugin.take_component_templates();
1222 assert_eq!(templates.len(), 1);
1223 let name = &templates[0].tag_name;
1224 let output = &templates[0].template;
1225 assert_eq!(name, "parent-comp");
1226
1227 assert!(output.contains("<f-template name=\"parent-comp\">"));
1228 assert!(!output.contains("shadowrootmode"));
1230 assert!(output.contains("<child-comp><template><p>inner</p></template></child-comp>"));
1232 }
1233
1234 #[test]
1235 fn route_tags_converted_to_f_route() {
1236 let input = r#"<route path="/home" name="home" exact><span>Home</span></route>"#;
1237 let output = convert_btr_to_fast(input);
1238 assert!(output.starts_with("<webui-route"));
1239 assert!(output.contains(r#"path="/home""#));
1240 assert!(output.contains(r#"name="home""#));
1241 assert!(output.contains(" exact"));
1242 assert!(output.contains("style=\"display:none\""));
1243 assert!(output.contains("<span>Home</span>"));
1244 assert!(output.ends_with("</webui-route>"));
1245 }
1246
1247 #[test]
1248 fn f_route_tags_pass_through() {
1249 let input = r#"<webui-route path="/" name="dashboard" component="cb-page-dashboard" exact style="display:none"></webui-route>"#;
1251 let output = convert_btr_to_fast(input);
1252 assert_eq!(output, input);
1253 }
1254
1255 #[test]
1260 fn ftemplate_for_loop_converted_to_f_repeat() {
1261 let mut plugin = FastV3ParserPlugin::new();
1262 let html = r#"<ul><for each="item in items"><li>{{item.name}}</li></for></ul>"#;
1263 let comp = make_component("my-list", html, None);
1264 plugin
1265 .register_component_template("my-list", &comp, &comp.html_content)
1266 .unwrap();
1267
1268 let templates = plugin.take_component_templates();
1269 let result = &templates[0].template;
1270
1271 assert!(
1272 result.contains("<f-repeat value=\"{{item in items}}\">"),
1273 "f-template should use <f-repeat>, got: {result}"
1274 );
1275 assert!(
1276 result.contains("</f-repeat>"),
1277 "f-template should close with </f-repeat>, got: {result}"
1278 );
1279 assert!(
1280 !result.contains("<for "),
1281 "f-template should NOT contain <for>, got: {result}"
1282 );
1283 }
1284
1285 #[test]
1286 fn ftemplate_shadow_dom_strips_shadowroot_and_converts_directives() {
1287 let mut plugin = FastV3ParserPlugin::new();
1288 let html = r#"<template shadowrootmode="open"><div><if condition="visible">Shown</if><for each="x in list"><span>{{x}}</span></for></div></template>"#;
1289 let comp = make_component("my-shadow", html, None);
1290 plugin
1291 .register_component_template("my-shadow", &comp, &comp.html_content)
1292 .unwrap();
1293
1294 let templates = plugin.take_component_templates();
1295 let result = &templates[0].template;
1296
1297 assert!(
1298 result.contains("<f-when value=\"{{visible}}\">"),
1299 "Shadow f-template should contain <f-when>, got: {result}"
1300 );
1301 assert!(
1302 result.contains("<f-repeat value=\"{{x in list}}\">"),
1303 "Shadow f-template should contain <f-repeat>, got: {result}"
1304 );
1305 assert!(
1306 !result.contains("shadowrootmode"),
1307 "Shadow f-template should strip shadowrootmode, got: {result}"
1308 );
1309 assert!(
1310 !result.contains("<if "),
1311 "Shadow f-template should NOT contain <if>, got: {result}"
1312 );
1313 assert!(
1314 !result.contains("<for "),
1315 "Shadow f-template should NOT contain <for>, got: {result}"
1316 );
1317 }
1318
1319 #[test]
1320 fn ftemplate_nested_if_and_for_both_converted() {
1321 let mut plugin = FastV3ParserPlugin::new();
1322 let html =
1323 r#"<div><if condition="show"><for each="x in items"><p>{{x}}</p></for></if></div>"#;
1324 let comp = make_component("my-nested", html, None);
1325 plugin
1326 .register_component_template("my-nested", &comp, &comp.html_content)
1327 .unwrap();
1328
1329 let templates = plugin.take_component_templates();
1330 let result = &templates[0].template;
1331
1332 assert!(
1333 result.contains("<f-when value=\"{{show}}\">"),
1334 "Nested f-template should contain <f-when>, got: {result}"
1335 );
1336 assert!(
1337 result.contains("<f-repeat value=\"{{x in items}}\">"),
1338 "Nested f-template should contain <f-repeat>, got: {result}"
1339 );
1340 assert!(
1341 !result.contains("<if "),
1342 "Nested f-template should NOT contain <if>, got: {result}"
1343 );
1344 assert!(
1345 !result.contains("<for "),
1346 "Nested f-template should NOT contain <for>, got: {result}"
1347 );
1348 }
1349
1350 #[test]
1351 fn convert_btr_preserves_utf8_text_content() {
1352 let input = "<span>✓ passed</span>";
1353 let output = convert_btr_to_fast(input);
1354 assert_eq!(output, "<span>✓ passed</span>");
1355 }
1356
1357 #[test]
1358 fn convert_btr_preserves_utf8_multibyte_chars() {
1359 let input = "<span>✓ yes ✗ no ⭐ star</span>";
1360 let output = convert_btr_to_fast(input);
1361 assert_eq!(output, "<span>✓ yes ✗ no ⭐ star</span>");
1362 }
1363
1364 #[test]
1365 fn convert_btr_preserves_utf8_with_directives() {
1366 let input = r#"<if condition="x"><span>✓</span></if>"#;
1367 let output = convert_btr_to_fast(input);
1368 assert_eq!(output, r#"<f-when value="{{x}}"><span>✓</span></f-when>"#);
1369 }
1370
1371 #[test]
1372 fn minify_preserves_utf8_text_content() {
1373 let input = "<span>✓</span><span>✗</span>";
1374 let output = minify_inter_tag_whitespace(input);
1375 assert_eq!(output, "<span>✓</span><span>✗</span>");
1376 }
1377
1378 #[test]
1379 fn minify_preserves_utf8_between_elements() {
1380 let input = "<div> ✓ </div>";
1381 let output = minify_inter_tag_whitespace(input);
1382 assert_eq!(output, "<div> ✓ </div>");
1383 }
1384
1385 #[test]
1386 fn convert_complex_attrs_preserves_utf8() {
1387 let input = r#"<span :title="{{label}}" data-icon="⭐">text</span>"#;
1388 let output = convert_btr_to_fast(input);
1389 assert!(
1390 output.contains("⭐"),
1391 "UTF-8 star should be preserved: {output}"
1392 );
1393 }
1394}