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