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 FastParserPlugin {
30 components: Vec<TrackedComponent>,
32 css_strategy: CssStrategy,
34}
35
36impl FastParserPlugin {
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 FastParserPlugin {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79impl ParserPlugin for FastParserPlugin {
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 result.push(bytes[i] as char);
231 i += 1;
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 find_tag_close(tag_str: &str) -> Option<usize> {
409 let bytes = tag_str.as_bytes();
410 let mut in_quote = false;
411 for (i, &b) in bytes.iter().enumerate() {
412 match b {
413 b'"' => in_quote = !in_quote,
414 b'>' if !in_quote => return Some(i),
415 _ => {}
416 }
417 }
418 None
419}
420
421fn extract_attribute_value<'a>(tag: &'a str, attr_name: &str) -> Option<&'a str> {
424 let mut search_from = 0;
426 loop {
427 let attr_pos = tag[search_from..].find(attr_name)?;
428 let abs_pos = search_from + attr_pos;
429
430 if abs_pos > 0 {
432 let prev = tag.as_bytes()[abs_pos - 1];
433 if prev != b' ' && prev != b'\t' && prev != b'\n' && prev != b'\r' {
434 search_from = abs_pos + attr_name.len();
435 continue;
436 }
437 }
438
439 let after_name = &tag[abs_pos + attr_name.len()..];
440 if !after_name.starts_with('=') {
441 search_from = abs_pos + attr_name.len();
442 continue;
443 }
444
445 let after_eq = &after_name[1..];
446 if after_eq.starts_with('"') {
447 let value_start = abs_pos + attr_name.len() + 2; let end_quote = tag[value_start..].find('"')?;
449 return Some(&tag[value_start..value_start + end_quote]);
450 }
451
452 return None;
453 }
454}
455
456fn convert_complex_attrs(tag_str: &str, result: &mut String) -> Option<usize> {
460 let close = find_tag_close(tag_str)?;
462 let tag_content = &tag_str[..=close];
463
464 if !tag_content.contains(":") || !tag_content.contains("{{") {
466 return None;
467 }
468
469 let mut converted = String::with_capacity(tag_content.len());
471 let tag_bytes = tag_content.as_bytes();
472 let tag_len = tag_bytes.len();
473 let mut j = 0;
474 let mut in_colon_attr_value = false;
475
476 while j < tag_len {
477 if !in_colon_attr_value {
478 if tag_bytes[j] == b':' && j > 0 && is_whitespace(tag_bytes[j - 1]) {
480 let attr_start = j;
482 j += 1;
483 while j < tag_len && tag_bytes[j] != b'=' && !is_whitespace(tag_bytes[j]) {
485 j += 1;
486 }
487 if j < tag_len && tag_bytes[j] == b'=' {
488 j += 1;
489 if j < tag_len && tag_bytes[j] == b'"' {
490 converted.push_str(&tag_content[attr_start..=j]);
492 j += 1;
493 in_colon_attr_value = true;
494 continue;
495 }
496 }
497 converted.push_str(&tag_content[attr_start..j]);
499 continue;
500 }
501 converted.push(tag_bytes[j] as char);
502 j += 1;
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 converted.push(tag_bytes[j] as char);
527 j += 1;
528 }
529 }
530 }
531
532 result.push_str(&converted);
533 Some(close + 1)
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 result.push(bytes[i] as char);
567 i += 1;
568 }
569 }
570
571 result
572}
573
574fn strip_shadowrootmode(tag_str: &str, result: &mut String) -> Option<usize> {
577 let close = find_tag_close(tag_str)?;
579 let tag_content = &tag_str[..=close];
580
581 if !tag_content.contains("shadowrootmode") {
583 return None;
584 }
585
586 result.push_str("<template");
588 let attr_start = "<template".len();
589 let inner = &tag_content[attr_start..close];
590
591 let inner_bytes = inner.as_bytes();
593 let inner_len = inner_bytes.len();
594 let mut j = 0;
595 while j < inner_len {
596 if is_whitespace(inner_bytes[j]) {
598 j += 1;
599 continue;
600 }
601
602 let attr_begin = j;
604 while j < inner_len && inner_bytes[j] != b'=' && !is_whitespace(inner_bytes[j]) {
606 j += 1;
607 }
608 let attr_name = &inner[attr_begin..j];
609
610 let mut attr_end = j;
612 if j < inner_len && inner_bytes[j] == b'=' {
613 j += 1; if j < inner_len && inner_bytes[j] == b'"' {
615 j += 1; while j < inner_len && inner_bytes[j] != b'"' {
617 j += 1;
618 }
619 if j < inner_len {
620 j += 1; }
622 } else {
623 while j < inner_len && !is_whitespace(inner_bytes[j]) {
625 j += 1;
626 }
627 }
628 attr_end = j;
629 }
630
631 if attr_name == "shadowrootmode" {
633 continue;
634 }
635
636 result.push(' ');
638 result.push_str(&inner[attr_begin..attr_end]);
639 }
640
641 if tag_content.ends_with("/>") {
642 result.push_str("/>");
643 } else {
644 result.push('>');
645 }
646
647 Some(close + 1)
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653 use std::path::PathBuf;
654
655 fn make_component(tag: &str, html: &str, css: Option<&str>) -> Component {
656 Component {
657 tag_name: tag.to_string(),
658 html_content: html.to_string(),
659 css_content: css.map(ToString::to_string),
660 css_tokens: Vec::new(),
661 source_path: PathBuf::from("/test"),
662 class_name: None,
663 }
664 }
665
666 #[test]
669 fn skip_event_binding() {
670 let mut plugin = FastParserPlugin::new();
671 assert_eq!(
672 plugin.classify_attribute("@click"),
673 AttributeAction::SkipAndCountBinding
674 );
675 assert_eq!(
676 plugin.classify_attribute("@input"),
677 AttributeAction::SkipAndCountBinding
678 );
679 assert_eq!(
680 plugin.classify_attribute("@custom-event"),
681 AttributeAction::SkipAndCountBinding
682 );
683 }
684
685 #[test]
686 fn skip_f_ref() {
687 let mut plugin = FastParserPlugin::new();
688 assert_eq!(
689 plugin.classify_attribute("f-ref"),
690 AttributeAction::SkipAndCountBinding
691 );
692 }
693
694 #[test]
695 fn skip_f_slotted() {
696 let mut plugin = FastParserPlugin::new();
697 assert_eq!(
698 plugin.classify_attribute("f-slotted"),
699 AttributeAction::SkipAndCountBinding
700 );
701 }
702
703 #[test]
704 fn skip_f_children() {
705 let mut plugin = FastParserPlugin::new();
706 assert_eq!(
707 plugin.classify_attribute("f-children"),
708 AttributeAction::SkipAndCountBinding
709 );
710 }
711
712 #[test]
713 fn do_not_skip_normal_attributes() {
714 let mut plugin = FastParserPlugin::new();
715 assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
716 assert_eq!(plugin.classify_attribute("id"), AttributeAction::Keep);
717 assert_eq!(
718 plugin.classify_attribute("data-value"),
719 AttributeAction::Keep
720 );
721 assert_eq!(plugin.classify_attribute(":title"), AttributeAction::Keep);
722 assert_eq!(plugin.classify_attribute("f-other"), AttributeAction::Keep);
723 }
724
725 #[test]
728 fn element_parsed_zero_count_returns_none() {
729 let mut plugin = FastParserPlugin::new();
730 assert!(plugin.finish_element(0).is_none());
731 }
732
733 #[test]
734 fn element_parsed_nonzero_count_returns_le_bytes() {
735 let mut plugin = FastParserPlugin::new();
736 let data = plugin.finish_element(3);
737 assert!(data.is_some());
738 let bytes = data.as_deref().unwrap_or_default();
739 assert_eq!(bytes, &3u32.to_le_bytes());
740 }
741
742 #[test]
743 fn element_parsed_large_count() {
744 let mut plugin = FastParserPlugin::new();
745 let data = plugin.finish_element(256);
746 assert!(data.is_some());
747 let bytes = data.as_deref().unwrap_or_default();
748 assert_eq!(bytes, &256u32.to_le_bytes());
749 }
750
751 #[test]
752 fn component_template_simple_component() {
753 let mut plugin = FastParserPlugin::new();
754 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
755 plugin
756 .register_component_template("my-comp", &comp, &comp.html_content)
757 .unwrap();
758
759 let templates = plugin.take_component_templates();
760 assert_eq!(templates.len(), 1);
761 let (name, html) = &templates[0];
762 assert_eq!(name, "my-comp");
763 assert!(html.contains("<f-template name=\"my-comp\">"));
764 assert!(html.contains("</f-template>"));
765 assert!(html.contains("<template>"));
766 assert!(html.contains("</template>"));
767 assert!(html.contains("<link rel=\"stylesheet\" href=\"/my-comp.css\">"));
768 assert!(html.contains("<div>hello</div>"));
769 }
770
771 #[test]
772 fn component_template_without_css() {
773 let mut plugin = FastParserPlugin::new();
774 let comp = make_component("no-css", "<span>text</span>", None);
775 plugin
776 .register_component_template("no-css", &comp, &comp.html_content)
777 .unwrap();
778
779 let templates = plugin.take_component_templates();
780 assert_eq!(templates.len(), 1);
781 let (name, html) = &templates[0];
782 assert_eq!(name, "no-css");
783 assert!(html.contains("<f-template name=\"no-css\">"));
784 assert!(!html.contains("<link rel=\"stylesheet\""));
785 assert!(html.contains("<span>text</span>"));
786 }
787
788 #[test]
789 fn component_template_css_strategy_style() {
790 let mut plugin = FastParserPlugin::new();
791 plugin.set_css_strategy(crate::CssStrategy::Style);
792 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
793 plugin
794 .register_component_template("my-comp", &comp, &comp.html_content)
795 .unwrap();
796
797 let templates = plugin.take_component_templates();
798 assert_eq!(templates.len(), 1);
799 let (_, html) = &templates[0];
800 assert!(
801 html.contains("<style>div { color: red; }</style>"),
802 "Style strategy should inline CSS, got: {html}"
803 );
804 assert!(
805 !html.contains("<link"),
806 "Style strategy should not emit <link> tags"
807 );
808 }
809
810 #[test]
811 fn component_template_css_strategy_module() {
812 let mut plugin = FastParserPlugin::new();
813 plugin.set_css_strategy(crate::CssStrategy::Module);
814 let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
815 plugin
816 .register_component_template("my-comp", &comp, &comp.html_content)
817 .unwrap();
818
819 let templates = plugin.take_component_templates();
820 assert_eq!(templates.len(), 1);
821 let (_, html) = &templates[0];
822
823 assert!(
826 !html.contains(r#"<style type="module""#),
827 "CSS module should not be baked into f-template: {html}"
828 );
829 assert!(
831 html.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
832 "Module strategy should add shadowrootadoptedstylesheets, got: {html}"
833 );
834 assert!(
836 !html.contains("<style>div"),
837 "Module strategy should not have inline <style> inside template"
838 );
839 assert!(
840 !html.contains("<link"),
841 "Module strategy should not emit <link> tags"
842 );
843 }
844
845 #[test]
846 fn component_template_css_strategy_module_no_css() {
847 let mut plugin = FastParserPlugin::new();
848 plugin.set_css_strategy(crate::CssStrategy::Module);
849 let comp = make_component("my-comp", "<div>hello</div>", None);
850 plugin
851 .register_component_template("my-comp", &comp, &comp.html_content)
852 .unwrap();
853
854 let templates = plugin.take_component_templates();
855 assert_eq!(templates.len(), 1);
856 let (_, html) = &templates[0];
857 assert!(
858 !html.contains("shadowrootadoptedstylesheets"),
859 "No CSS = no shadowrootadoptedstylesheets, got: {html}"
860 );
861 assert!(
862 !html.contains("<style"),
863 "No CSS = no style tag, got: {html}"
864 );
865 }
866
867 #[test]
868 fn component_template_multiple_components() {
869 let mut plugin = FastParserPlugin::new();
870 let comp1 = make_component("comp-a", "<div>A</div>", None);
871 let comp2 = make_component("comp-b", "<div>B</div>", Some("b { }"));
872 plugin
873 .register_component_template("comp-a", &comp1, &comp1.html_content)
874 .unwrap();
875 plugin
876 .register_component_template("comp-b", &comp2, &comp2.html_content)
877 .unwrap();
878
879 let templates = plugin.take_component_templates();
880 assert_eq!(templates.len(), 2);
881
882 let names: Vec<&str> = templates.iter().map(|(n, _)| n.as_str()).collect();
883 assert!(names.contains(&"comp-a"));
884 assert!(names.contains(&"comp-b"));
885
886 for (_, html) in &templates {
887 assert!(html.contains("<f-template name="));
888 }
889 }
890
891 #[test]
892 fn component_template_deduplicates_same_component() {
893 let mut plugin = FastParserPlugin::new();
894 let comp = make_component(
895 "my-button",
896 "<button><slot></slot></button>",
897 Some(".btn{}"),
898 );
899
900 plugin
903 .register_component_template("my-button", &comp, &comp.html_content)
904 .unwrap();
905 plugin
906 .register_component_template("my-button", &comp, &comp.html_content)
907 .unwrap();
908 plugin
909 .register_component_template("my-button", &comp, &comp.html_content)
910 .unwrap();
911
912 let templates = plugin.take_component_templates();
913 assert_eq!(
914 templates.len(),
915 1,
916 "Expected exactly 1 template for my-button, got {}",
917 templates.len()
918 );
919 assert_eq!(templates[0].0, "my-button");
920 }
921
922 #[test]
923 fn component_template_deduplicates_mixed_components() {
924 let mut plugin = FastParserPlugin::new();
925 let btn = make_component("my-button", "<button><slot></slot></button>", None);
926 let card = make_component("my-card", "<div><slot></slot></div>", Some(".card{}"));
927
928 plugin
930 .register_component_template("my-button", &btn, &btn.html_content)
931 .unwrap();
932 plugin
933 .register_component_template("my-card", &card, &card.html_content)
934 .unwrap();
935 plugin
936 .register_component_template("my-button", &btn, &btn.html_content)
937 .unwrap();
938
939 let templates = plugin.take_component_templates();
940 assert_eq!(templates.len(), 2);
941
942 let button_count = templates.iter().filter(|(n, _)| n == "my-button").count();
943 let card_count = templates.iter().filter(|(n, _)| n == "my-card").count();
944 assert_eq!(button_count, 1);
945 assert_eq!(card_count, 1);
946 }
947
948 #[test]
951 fn convert_if_to_f_when() {
952 let input = r#"<if condition="isComplete"><span>Done</span></if>"#;
953 let output = convert_btr_to_fast(input);
954 assert_eq!(
955 output,
956 r#"<f-when value="{{isComplete}}"><span>Done</span></f-when>"#
957 );
958 }
959
960 #[test]
961 fn convert_if_with_greater_than() {
962 let input = r#"<if condition="vara > 2">Over two</if>"#;
963 let output = convert_btr_to_fast(input);
964 assert_eq!(output, r#"<f-when value="{{vara > 2}}">Over two</f-when>"#);
965 }
966
967 #[test]
968 fn convert_if_with_greater_than_or_equal() {
969 let input = r#"<if condition="vara >= 2">Two and over</if>"#;
970 let output = convert_btr_to_fast(input);
971 assert_eq!(
972 output,
973 r#"<f-when value="{{vara >= 2}}">Two and over</f-when>"#
974 );
975 }
976
977 #[test]
978 fn convert_if_with_less_than() {
979 let input = r#"<if condition="x < 10">small</if>"#;
980 let output = convert_btr_to_fast(input);
981 assert_eq!(output, r#"<f-when value="{{x < 10}}">small</f-when>"#);
982 }
983
984 #[test]
985 fn convert_for_to_f_repeat() {
986 let input = r#"<for each="tag in tags"><span>{{tag}}</span></for>"#;
987 let output = convert_btr_to_fast(input);
988 assert_eq!(
989 output,
990 r#"<f-repeat value="{{tag in tags}}"><span>{{tag}}</span></f-repeat>"#
991 );
992 }
993
994 #[test]
995 fn convert_nested_if_and_for() {
996 let input = r#"<if condition="show"><for each="x in items"><p>{{x}}</p></for></if>"#;
997 let output = convert_btr_to_fast(input);
998 assert_eq!(
999 output,
1000 r#"<f-when value="{{show}}"><f-repeat value="{{x in items}}"><p>{{x}}</p></f-repeat></f-when>"#
1001 );
1002 }
1003
1004 #[test]
1005 fn convert_complex_attr_double_braces() {
1006 let input = r#"<div :data="{{config}}"></div>"#;
1007 let output = convert_btr_to_fast(input);
1008 assert_eq!(output, r#"<div :data="{config}"></div>"#);
1009 }
1010
1011 #[test]
1012 fn convert_multiple_complex_attrs() {
1013 let input = r#"<div :title="{{name}}" :data-id="{{id}}"></div>"#;
1014 let output = convert_btr_to_fast(input);
1015 assert_eq!(output, r#"<div :title="{name}" :data-id="{id}"></div>"#);
1016 }
1017
1018 #[test]
1019 fn no_conversion_for_text_double_braces() {
1020 let input = "<span>{{greeting}}</span>";
1022 let output = convert_btr_to_fast(input);
1023 assert_eq!(output, "<span>{{greeting}}</span>");
1024 }
1025
1026 #[test]
1027 fn no_conversion_for_regular_attr() {
1028 let input = r#"<div title="{{name}}"></div>"#;
1030 let output = convert_btr_to_fast(input);
1031 assert_eq!(output, r#"<div title="{{name}}"></div>"#);
1032 }
1033
1034 #[test]
1035 fn component_template_with_btr_conversion() {
1036 let mut plugin = FastParserPlugin::new();
1037 let html = r#"<div><if condition="visible"><span>hi</span></if></div>"#;
1038 let comp = make_component("my-widget", html, None);
1039 plugin
1040 .register_component_template("my-widget", &comp, &comp.html_content)
1041 .unwrap();
1042
1043 let templates = plugin.take_component_templates();
1044 assert_eq!(templates.len(), 1);
1045 let (name, result) = &templates[0];
1046 assert_eq!(name, "my-widget");
1047
1048 assert!(result.contains("<f-when value=\"{{visible}}\">"));
1049 assert!(result.contains("</f-when>"));
1050 assert!(!result.contains("<if condition="));
1051 assert!(!result.contains("</if>"));
1052 }
1053
1054 #[test]
1055 fn passthrough_normal_html() {
1056 let input = r#"<div class="container"><p>Hello</p></div>"#;
1057 let output = convert_btr_to_fast(input);
1058 assert_eq!(output, input);
1059 }
1060
1061 #[test]
1062 fn outlet_kept_as_marker_in_f_template() {
1063 let input = r#"<div><outlet /></div>"#;
1064 let output = convert_btr_to_fast(input);
1065 assert_eq!(output, r#"<div><outlet></outlet></div>"#);
1066 }
1067
1068 #[test]
1069 fn outlet_marker_in_component_template() {
1070 let mut plugin = FastParserPlugin::new();
1071 let html =
1072 r#"<template shadowrootmode="open"><h1>Title</h1><main><outlet /></main></template>"#;
1073 let comp = make_component("my-shell", html, None);
1074 plugin
1075 .register_component_template("my-shell", &comp, &comp.html_content)
1076 .unwrap();
1077
1078 let templates = plugin.take_component_templates();
1079 let (_, result) = &templates[0];
1080 assert!(
1081 result.contains("<outlet></outlet>"),
1082 "outlet should be kept as marker in f-template: {result}"
1083 );
1084 }
1085
1086 #[test]
1087 fn strip_shadowrootmode_from_template() {
1088 let input = r#"<template shadowrootmode="open"><div>Hello</div></template>"#;
1089 let output = convert_btr_to_fast(input);
1090 assert_eq!(output, "<template><div>Hello</div></template>");
1091 }
1092
1093 #[test]
1094 fn strip_shadowrootmode_preserves_other_attrs() {
1095 let input =
1096 r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>Hi</div></template>"#;
1097 let output = convert_btr_to_fast(input);
1098 assert!(output.contains(r#"@click="{onClick(e)}""#));
1099 assert!(!output.contains("shadowrootmode"));
1100 assert!(output.starts_with("<template "));
1101 }
1102
1103 #[test]
1104 fn no_strip_when_no_shadowrootmode() {
1105 let input = r#"<template foo="bar"><div>Hi</div></template>"#;
1106 let output = convert_btr_to_fast(input);
1107 assert_eq!(output, input);
1108 }
1109
1110 #[test]
1111 fn component_template_strips_shadowrootmode_from_f_template() {
1112 let mut plugin = FastParserPlugin::new();
1113 let comp = make_component(
1114 "my-comp",
1115 r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>{{title}}</div></template>"#,
1116 Some("div { color: red; }"),
1117 );
1118 plugin
1119 .register_component_template("my-comp", &comp, &comp.html_content)
1120 .unwrap();
1121 let templates = plugin.take_component_templates();
1122 assert_eq!(templates.len(), 1);
1123 let (name, html) = &templates[0];
1124 assert_eq!(name, "my-comp");
1125 assert!(!html.contains("shadowrootmode"));
1127 assert!(html.contains("@click"));
1129 assert!(html.contains("{{title}}"));
1131 }
1132
1133 #[test]
1134 fn test_complex_attribute_normalization() {
1135 let cases = [
1138 (
1139 r#"<div :foo="{{value}}"></div>"#,
1140 r#"<div :foo="{value}"></div>"#,
1141 ),
1142 (
1143 r#"<div :foo="{{ value}}"></div>"#,
1144 r#"<div :foo="{ value}"></div>"#,
1145 ),
1146 (
1147 r#"<div :foo="{{value }}"></div>"#,
1148 r#"<div :foo="{value }"></div>"#,
1149 ),
1150 (
1151 r#"<div :foo="{{ value }}"></div>"#,
1152 r#"<div :foo="{ value }"></div>"#,
1153 ),
1154 ];
1155 for (input, expected) in &cases {
1156 let output = convert_btr_to_fast(input);
1157 assert_eq!(&output, expected, "normalization failed for input: {input}");
1158 }
1159 }
1160
1161 #[test]
1162 fn test_non_js_component_inner_template() {
1163 let mut plugin = FastParserPlugin::new();
1164 let html = r#"<div><child-comp><template shadowrootmode="open"><p>inner</p></template></child-comp></div>"#;
1167 let comp = make_component("parent-comp", html, None);
1168 plugin
1169 .register_component_template("parent-comp", &comp, &comp.html_content)
1170 .unwrap();
1171
1172 let templates = plugin.take_component_templates();
1173 assert_eq!(templates.len(), 1);
1174 let (name, output) = &templates[0];
1175 assert_eq!(name, "parent-comp");
1176
1177 assert!(output.contains("<f-template name=\"parent-comp\">"));
1178 assert!(!output.contains("shadowrootmode"));
1180 assert!(output.contains("<child-comp><template><p>inner</p></template></child-comp>"));
1182 }
1183
1184 #[test]
1185 fn route_tags_converted_to_f_route() {
1186 let input = r#"<route path="/home" name="home" exact><span>Home</span></route>"#;
1187 let output = convert_btr_to_fast(input);
1188 assert!(output.starts_with("<webui-route"));
1189 assert!(output.contains(r#"path="/home""#));
1190 assert!(output.contains(r#"name="home""#));
1191 assert!(output.contains(" exact"));
1192 assert!(output.contains("style=\"display:none\""));
1193 assert!(output.contains("<span>Home</span>"));
1194 assert!(output.ends_with("</webui-route>"));
1195 }
1196
1197 #[test]
1198 fn f_route_tags_pass_through() {
1199 let input = r#"<webui-route path="/" name="dashboard" component="cb-page-dashboard" exact style="display:none"></webui-route>"#;
1201 let output = convert_btr_to_fast(input);
1202 assert_eq!(output, input);
1203 }
1204
1205 #[test]
1210 fn ftemplate_for_loop_converted_to_f_repeat() {
1211 let mut plugin = FastParserPlugin::new();
1212 let html = r#"<ul><for each="item in items"><li>{{item.name}}</li></for></ul>"#;
1213 let comp = make_component("my-list", html, None);
1214 plugin
1215 .register_component_template("my-list", &comp, &comp.html_content)
1216 .unwrap();
1217
1218 let templates = plugin.take_component_templates();
1219 let (_, result) = &templates[0];
1220
1221 assert!(
1222 result.contains("<f-repeat value=\"{{item in items}}\">"),
1223 "f-template should use <f-repeat>, got: {result}"
1224 );
1225 assert!(
1226 result.contains("</f-repeat>"),
1227 "f-template should close with </f-repeat>, got: {result}"
1228 );
1229 assert!(
1230 !result.contains("<for "),
1231 "f-template should NOT contain <for>, got: {result}"
1232 );
1233 }
1234
1235 #[test]
1236 fn ftemplate_shadow_dom_strips_shadowroot_and_converts_directives() {
1237 let mut plugin = FastParserPlugin::new();
1238 let html = r#"<template shadowrootmode="open"><div><if condition="visible">Shown</if><for each="x in list"><span>{{x}}</span></for></div></template>"#;
1239 let comp = make_component("my-shadow", html, None);
1240 plugin
1241 .register_component_template("my-shadow", &comp, &comp.html_content)
1242 .unwrap();
1243
1244 let templates = plugin.take_component_templates();
1245 let (_, result) = &templates[0];
1246
1247 assert!(
1248 result.contains("<f-when value=\"{{visible}}\">"),
1249 "Shadow f-template should contain <f-when>, got: {result}"
1250 );
1251 assert!(
1252 result.contains("<f-repeat value=\"{{x in list}}\">"),
1253 "Shadow f-template should contain <f-repeat>, got: {result}"
1254 );
1255 assert!(
1256 !result.contains("shadowrootmode"),
1257 "Shadow f-template should strip shadowrootmode, got: {result}"
1258 );
1259 assert!(
1260 !result.contains("<if "),
1261 "Shadow f-template should NOT contain <if>, got: {result}"
1262 );
1263 assert!(
1264 !result.contains("<for "),
1265 "Shadow f-template should NOT contain <for>, got: {result}"
1266 );
1267 }
1268
1269 #[test]
1270 fn ftemplate_nested_if_and_for_both_converted() {
1271 let mut plugin = FastParserPlugin::new();
1272 let html =
1273 r#"<div><if condition="show"><for each="x in items"><p>{{x}}</p></for></if></div>"#;
1274 let comp = make_component("my-nested", html, None);
1275 plugin
1276 .register_component_template("my-nested", &comp, &comp.html_content)
1277 .unwrap();
1278
1279 let templates = plugin.take_component_templates();
1280 let (_, result) = &templates[0];
1281
1282 assert!(
1283 result.contains("<f-when value=\"{{show}}\">"),
1284 "Nested f-template should contain <f-when>, got: {result}"
1285 );
1286 assert!(
1287 result.contains("<f-repeat value=\"{{x in items}}\">"),
1288 "Nested f-template should contain <f-repeat>, got: {result}"
1289 );
1290 assert!(
1291 !result.contains("<if "),
1292 "Nested f-template should NOT contain <if>, got: {result}"
1293 );
1294 assert!(
1295 !result.contains("<for "),
1296 "Nested f-template should NOT contain <for>, got: {result}"
1297 );
1298 }
1299
1300 #[test]
1301 fn find_tag_close_skips_quoted_gt() {
1302 assert_eq!(
1303 find_tag_close(r#"<if condition="a > b">"#),
1304 Some(21) );
1306 assert_eq!(find_tag_close(r#"<if condition="a >= b">"#), Some(22));
1307 assert_eq!(find_tag_close("<br>"), Some(3));
1308 assert_eq!(find_tag_close("<br/>"), Some(4));
1309 }
1310}