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