Skip to main content

webui_parser/plugin/
fast.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! FAST parser plugin for the WebUI parser.
5//!
6//! Tracks component definitions during HTML parsing and generates `<f-template>`
7//! wrappers at body end. Converts WebUI Framework template syntax (`<if>`, `<for>`, `{{}}`)
8//! into FAST-compatible syntax (`<f-when>`, `<f-repeat>`, `{}`).
9
10use super::ParserPlugin;
11use crate::component_registry::Component;
12use crate::{CssStrategy, Result};
13
14/// Information about a tracked component for `<f-template>` generation.
15struct TrackedComponent {
16    tag_name: String,
17    html_content: String,
18    css_content: Option<String>,
19}
20
21/// FAST parser plugin.
22///
23/// Implements the `ParserPlugin` trait for the FAST framework:
24/// - Filters FAST-specific runtime binding attributes (`@click`, `f-ref`, etc.)
25/// - Tracks components encountered during parsing
26/// - Generates `<f-template>` wrappers with converted FAST syntax at body end
27/// - Emits binding attribute counts as `Plugin` protocol fragment data
28pub struct FastParserPlugin {
29    /// Components tracked during parsing, in discovery order.
30    components: Vec<TrackedComponent>,
31    /// CSS delivery strategy for f-templates.
32    css_strategy: CssStrategy,
33}
34
35impl FastParserPlugin {
36    /// Create a new FAST parser plugin.
37    #[must_use]
38    pub fn new() -> Self {
39        Self {
40            components: Vec::new(),
41            css_strategy: CssStrategy::Link,
42        }
43    }
44
45    /// Set the CSS delivery strategy for generated f-templates.
46    pub fn set_css_strategy(&mut self, strategy: CssStrategy) {
47        self.css_strategy = strategy;
48    }
49
50    /// Take the individual component f-template strings, keyed by tag name.
51    ///
52    /// Each value is a complete `<f-template name="tag-name">...</f-template>` string
53    /// ready to be appended to a document. This is used by the JSON partial render
54    /// endpoint to send only the templates the client needs.
55    #[must_use]
56    pub fn take_component_templates(&self) -> Vec<(String, String)> {
57        self.components
58            .iter()
59            .map(|comp| {
60                let tmpl = generate_f_template(
61                    &comp.tag_name,
62                    &comp.html_content,
63                    comp.css_content.as_deref(),
64                    self.css_strategy,
65                );
66                (comp.tag_name.clone(), tmpl)
67            })
68            .collect()
69    }
70}
71
72impl Default for FastParserPlugin {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78impl ParserPlugin for FastParserPlugin {
79    fn should_skip_attribute(&self, attr_name: &str) -> bool {
80        attr_name.starts_with('@')
81            || attr_name == "f-ref"
82            || attr_name == "f-slotted"
83            || attr_name == "f-children"
84    }
85
86    fn on_parse_component(&mut self, tag_name: &str, component: &Component) -> Result<()> {
87        // Only track each component once (avoids duplicate <f-template> blocks
88        // when a component is used in multiple parent templates)
89        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 on_element_parsed(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
101        if binding_attribute_count > 0 {
102            Some(binding_attribute_count.to_le_bytes().to_vec())
103        } else {
104            None
105        }
106    }
107
108    fn on_body_end(&mut self) -> Option<String> {
109        // f-templates are stored in protocol.components and emitted
110        // selectively by the handler based on which components were rendered.
111        None
112    }
113
114    fn as_any(&self) -> &dyn std::any::Any {
115        self
116    }
117
118    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
119        self
120    }
121}
122
123/// Generate a single f-template HTML string from component data.
124///
125/// Used by the server to generate templates on demand for route components
126/// that weren't encountered during initial parsing.
127pub fn generate_f_template(
128    tag_name: &str,
129    html_content: &str,
130    css_content: Option<&str>,
131    css_strategy: CssStrategy,
132) -> String {
133    let mut output = String::with_capacity(256);
134    output.push_str("<f-template name=\"");
135    output.push_str(tag_name);
136    output.push_str("\">\n");
137
138    let converted = convert_btr_to_fast(html_content);
139    let trimmed = minify_inter_tag_whitespace(converted.trim());
140
141    // Build the CSS injection string based on the configured strategy
142    let css_injection = match css_strategy {
143        CssStrategy::Link => css_content.map(|_| {
144            let mut s = String::with_capacity(40 + tag_name.len());
145            s.push_str("<link rel=\"stylesheet\" href=\"/");
146            s.push_str(tag_name);
147            s.push_str(".css\">");
148            s
149        }),
150        CssStrategy::Style => css_content.map(|css| {
151            let mut s = String::with_capacity(15 + css.len());
152            s.push_str("<style>");
153            s.push_str(css.trim());
154            s.push_str("</style>");
155            s
156        }),
157        CssStrategy::Module => None,
158    };
159
160    if trimmed.starts_with("<template") {
161        if let Some(close_pos) = trimmed.find('>') {
162            output.push_str(&trimmed[..close_pos]);
163            if css_strategy == CssStrategy::Module && css_content.is_some() {
164                output.push_str(" shadowrootadoptedstylesheets=\"");
165                output.push_str(tag_name);
166                output.push('"');
167            }
168            output.push('>');
169            if let Some(ref injection) = css_injection {
170                output.push_str(injection);
171            }
172            output.push_str(&trimmed[close_pos + 1..]);
173        } else {
174            output.push_str(&trimmed);
175        }
176    } else {
177        output.push_str("<template");
178        if css_strategy == CssStrategy::Module && css_content.is_some() {
179            output.push_str(" shadowrootadoptedstylesheets=\"");
180            output.push_str(tag_name);
181            output.push('"');
182        }
183        output.push('>');
184        if let Some(ref injection) = css_injection {
185            output.push_str(injection);
186        }
187        output.push_str(&trimmed);
188        output.push_str("</template>");
189    }
190
191    output.push_str("\n</f-template>\n");
192    output
193}
194
195/// Convert WebUI Framework template syntax to FAST syntax in HTML content.
196///
197/// Performs the following transformations without regex:
198/// - `<if condition="EXPR">` → `<f-when value="{EXPR}">`
199/// - `</if>` → `</f-when>`
200/// - `<for each="EXPR">` → `<f-repeat value="{EXPR}">`
201/// - `</for>` → `</f-repeat>`
202/// - `{{expr}}` inside `:attr` complex attribute values → `{expr}`
203/// - Strips `shadowrootmode` attributes from `<template>` tags
204///   (in f-template context, shadowrootmode must be removed to prevent
205///   the browser from auto-activating it as a declarative shadow root)
206fn convert_btr_to_fast(input: &str) -> String {
207    let mut result = String::with_capacity(input.len());
208    let bytes = input.as_bytes();
209    let len = bytes.len();
210    let mut i = 0;
211
212    while i < len {
213        if bytes[i] == b'<' {
214            if let Some(consumed) = try_convert_tag(input, i, &mut result) {
215                i += consumed;
216                continue;
217            }
218        }
219
220        // Check for {{expr}} inside :attr values — handled at attribute level
221        // in try_convert_tag. Outside of tags, double-braces are left as-is
222        // (they're text content interpolation for FAST, kept unchanged).
223        result.push(bytes[i] as char);
224        i += 1;
225    }
226
227    result
228}
229
230/// Try to convert a tag starting at position `pos`. Returns `Some(bytes_consumed)`
231/// if a conversion was performed, or `None` if this is not a convertible tag.
232fn try_convert_tag(input: &str, pos: usize, result: &mut String) -> Option<usize> {
233    let remaining = &input[pos..];
234
235    // Check for closing tags first (shorter patterns)
236    if remaining.starts_with("</if>") {
237        result.push_str("</f-when>");
238        return Some(5);
239    }
240    if remaining.starts_with("</for>") {
241        result.push_str("</f-repeat>");
242        return Some(6);
243    }
244    // <route> in f-templates becomes empty <webui-route> (client router mounts components)
245    if remaining.starts_with("</route>") {
246        result.push_str("</webui-route>");
247        return Some(8);
248    }
249
250    // <outlet /> is kept as a marker element in f-templates.
251    // The client router finds it and replaces it with <webui-route> stubs.
252    if starts_with_tag_name(remaining, "outlet") {
253        if let Some(close) = remaining.find('>') {
254            result.push_str("<outlet></outlet>");
255            return Some(close + 1);
256        }
257    }
258
259    // Check for <if condition="...">
260    if starts_with_tag_name(remaining, "if") {
261        if let Some(consumed) = convert_if_tag(remaining, result) {
262            return Some(consumed);
263        }
264    }
265
266    // Check for <for each="...">
267    if starts_with_tag_name(remaining, "for") {
268        if let Some(consumed) = convert_for_tag(remaining, result) {
269            return Some(consumed);
270        }
271    }
272
273    // Check for <route ...> → <webui-route ...> in f-templates
274    if starts_with_tag_name(remaining, "route") {
275        if let Some(consumed) = convert_route_tag(remaining, result) {
276            return Some(consumed);
277        }
278    }
279
280    // Check for tags with :attr="{{expr}}" complex attribute values
281    if remaining.starts_with("<") {
282        // Strip shadowrootmode from <template> tags
283        if starts_with_tag_name(remaining, "template") {
284            if let Some(consumed) = strip_shadowrootmode(remaining, result) {
285                return Some(consumed);
286            }
287        }
288        if let Some(consumed) = convert_complex_attrs(remaining, result) {
289            return Some(consumed);
290        }
291    }
292
293    None
294}
295
296/// Check if `s` starts with `<name` followed by whitespace or `>`.
297fn starts_with_tag_name(s: &str, name: &str) -> bool {
298    if !s.starts_with('<') {
299        return false;
300    }
301    let after_bracket = &s[1..];
302    if !after_bracket.starts_with(name) {
303        return false;
304    }
305    let after_name = &after_bracket[name.len()..];
306    if after_name.is_empty() {
307        return false;
308    }
309    let next = after_name.as_bytes()[0];
310    next == b' ' || next == b'\t' || next == b'\n' || next == b'\r' || next == b'>'
311}
312
313/// Convert `<if condition="EXPR">` to `<f-when value="{EXPR}">`.
314/// Returns bytes consumed on success.
315fn convert_if_tag(tag_str: &str, result: &mut String) -> Option<usize> {
316    // Find the closing '>'
317    let close = tag_str.find('>')?;
318    let tag_content = &tag_str[..=close];
319
320    // Find condition="..." attribute
321    let attr_value = extract_attribute_value(tag_content, "condition")?;
322
323    result.push_str("<f-when value=\"{");
324    result.push_str(attr_value);
325    result.push_str("}\">");
326
327    Some(close + 1)
328}
329
330/// Convert `<for each="EXPR">` to `<f-repeat value="{EXPR}">`.
331/// Returns bytes consumed on success.
332fn convert_for_tag(tag_str: &str, result: &mut String) -> Option<usize> {
333    // Find the closing '>'
334    let close = tag_str.find('>')?;
335    let tag_content = &tag_str[..=close];
336
337    // Find each="..." attribute
338    let attr_value = extract_attribute_value(tag_content, "each")?;
339
340    result.push_str("<f-repeat value=\"{");
341    result.push_str(attr_value);
342    result.push_str("}\">");
343
344    Some(close + 1)
345}
346
347/// Convert `<route ...>` to `<webui-route ...>` in f-templates.
348/// Self-closing routes become `<webui-route ...></webui-route>` (empty).
349/// The component attribute is preserved for the client router.
350fn convert_route_tag(tag_str: &str, result: &mut String) -> Option<usize> {
351    let close = tag_str.find('>')?;
352    let is_self_closing = tag_str[..=close].ends_with("/>");
353    let tag = &tag_str[..=close];
354
355    result.push_str("<webui-route");
356
357    if let Some(v) = extract_attribute_value(tag, "path") {
358        result.push_str(" path=\"");
359        result.push_str(v);
360        result.push('"');
361    }
362    if let Some(v) = extract_attribute_value(tag, "name") {
363        result.push_str(" name=\"");
364        result.push_str(v);
365        result.push('"');
366    }
367    if let Some(v) = extract_attribute_value(tag, "component") {
368        result.push_str(" component=\"");
369        result.push_str(v);
370        result.push('"');
371    }
372    if let Some(v) = extract_attribute_value(tag, "redirectTo") {
373        result.push_str(" redirectto=\"");
374        result.push_str(v);
375        result.push('"');
376    }
377    if tag.contains(" exact") {
378        result.push_str(" exact");
379    }
380    if tag.contains(" layout") {
381        result.push_str(" layout");
382    }
383    result.push_str(" style=\"display:none\"");
384
385    if is_self_closing {
386        result.push_str("></webui-route>");
387    } else {
388        result.push('>');
389    }
390
391    Some(close + 1)
392}
393
394/// Extract the value of a named attribute from a tag string.
395/// Looks for `name="value"` and returns `value`.
396fn extract_attribute_value<'a>(tag: &'a str, attr_name: &str) -> Option<&'a str> {
397    // Search for the attribute name followed by '='
398    let mut search_from = 0;
399    loop {
400        let attr_pos = tag[search_from..].find(attr_name)?;
401        let abs_pos = search_from + attr_pos;
402
403        // Verify it's preceded by whitespace (not part of another attribute name)
404        if abs_pos > 0 {
405            let prev = tag.as_bytes()[abs_pos - 1];
406            if prev != b' ' && prev != b'\t' && prev != b'\n' && prev != b'\r' {
407                search_from = abs_pos + attr_name.len();
408                continue;
409            }
410        }
411
412        let after_name = &tag[abs_pos + attr_name.len()..];
413        if !after_name.starts_with('=') {
414            search_from = abs_pos + attr_name.len();
415            continue;
416        }
417
418        let after_eq = &after_name[1..];
419        if after_eq.starts_with('"') {
420            let value_start = abs_pos + attr_name.len() + 2; // skip `="`
421            let end_quote = tag[value_start..].find('"')?;
422            return Some(&tag[value_start..value_start + end_quote]);
423        }
424
425        return None;
426    }
427}
428
429/// Convert tags that contain `:attr="{{expr}}"` complex attribute bindings.
430/// Replaces `{{expr}}` with `{expr}` in attribute values on `:` prefixed attributes.
431/// Returns bytes consumed on success, or `None` if no conversion needed.
432fn convert_complex_attrs(tag_str: &str, result: &mut String) -> Option<usize> {
433    // Find the closing '>'
434    let close = tag_str.find('>')?;
435    let tag_content = &tag_str[..=close];
436
437    // Quick check: does this tag have both a colon-prefixed attribute and `{{`?
438    if !tag_content.contains(":") || !tag_content.contains("{{") {
439        return None;
440    }
441
442    // Process the tag character by character, converting {{}} to {} in :attr values
443    let mut converted = String::with_capacity(tag_content.len());
444    let tag_bytes = tag_content.as_bytes();
445    let tag_len = tag_bytes.len();
446    let mut j = 0;
447    let mut in_colon_attr_value = false;
448
449    while j < tag_len {
450        if !in_colon_attr_value {
451            // Look for :attrname="
452            if tag_bytes[j] == b':' && j > 0 && is_whitespace(tag_bytes[j - 1]) {
453                // Found a colon-prefixed attribute, scan to the ="
454                let attr_start = j;
455                j += 1;
456                // Skip attribute name
457                while j < tag_len && tag_bytes[j] != b'=' && !is_whitespace(tag_bytes[j]) {
458                    j += 1;
459                }
460                if j < tag_len && tag_bytes[j] == b'=' {
461                    j += 1;
462                    if j < tag_len && tag_bytes[j] == b'"' {
463                        // Push everything up to and including the opening quote
464                        converted.push_str(&tag_content[attr_start..=j]);
465                        j += 1;
466                        in_colon_attr_value = true;
467                        continue;
468                    }
469                }
470                // Not a valid :attr="..." pattern, output as-is
471                converted.push_str(&tag_content[attr_start..j]);
472                continue;
473            }
474            converted.push(tag_bytes[j] as char);
475            j += 1;
476        } else {
477            // Inside a :attr value — convert {{expr}} to {expr}
478            if tag_bytes[j] == b'"' {
479                // End of attribute value
480                converted.push('"');
481                j += 1;
482                in_colon_attr_value = false;
483            } else if j + 1 < tag_len && tag_bytes[j] == b'{' && tag_bytes[j + 1] == b'{' {
484                // Found {{ — find matching }}
485                let expr_start = j + 2;
486                if let Some(end_offset) = tag_content[expr_start..].find("}}") {
487                    let expr = &tag_content[expr_start..expr_start + end_offset];
488                    converted.push('{');
489                    converted.push_str(expr);
490                    converted.push('}');
491                    j = expr_start + end_offset + 2;
492                } else {
493                    // No matching }}, output as-is
494                    converted.push('{');
495                    converted.push('{');
496                    j += 2;
497                }
498            } else {
499                converted.push(tag_bytes[j] as char);
500                j += 1;
501            }
502        }
503    }
504
505    result.push_str(&converted);
506    Some(close + 1)
507}
508
509/// Check if a byte is ASCII whitespace.
510fn is_whitespace(b: u8) -> bool {
511    b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'
512}
513
514/// Collapse whitespace-only text between `>` and `<` to eliminate extra DOM
515/// text nodes that would shift element indices during hydration.
516/// This ensures the f-template DOM structure matches the minified DSD output.
517fn minify_inter_tag_whitespace(input: &str) -> String {
518    let mut result = String::with_capacity(input.len());
519    let bytes = input.as_bytes();
520    let len = bytes.len();
521    let mut i = 0;
522
523    while i < len {
524        if bytes[i] == b'>' && i + 1 < len {
525            result.push('>');
526            i += 1;
527            // Skip whitespace-only content between > and <
528            let ws_start = i;
529            while i < len && is_whitespace(bytes[i]) {
530                i += 1;
531            }
532            // If we hit '<', the whitespace was inter-tag — drop it
533            // If we hit non-'<', it's meaningful text — keep it
534            if i >= len || bytes[i] != b'<' {
535                // Keep the whitespace (it's before text content)
536                result.push_str(&input[ws_start..i]);
537            }
538        } else {
539            result.push(bytes[i] as char);
540            i += 1;
541        }
542    }
543
544    result
545}
546
547/// Strip `shadowrootmode` attribute from a `<template ...>` opening tag.
548/// Returns `Some(bytes_consumed)` if a `<template` tag was found and processed.
549fn strip_shadowrootmode(tag_str: &str, result: &mut String) -> Option<usize> {
550    // Find the closing '>'
551    let close = tag_str.find('>')?;
552    let tag_content = &tag_str[..=close];
553
554    // Only process if this tag contains shadowrootmode
555    if !tag_content.contains("shadowrootmode") {
556        return None;
557    }
558
559    // Rebuild the tag without the shadowrootmode attribute
560    result.push_str("<template");
561    let attr_start = "<template".len();
562    let inner = &tag_content[attr_start..close];
563
564    // Scan through the attributes, skipping shadowrootmode
565    let inner_bytes = inner.as_bytes();
566    let inner_len = inner_bytes.len();
567    let mut j = 0;
568    while j < inner_len {
569        // Skip whitespace
570        if is_whitespace(inner_bytes[j]) {
571            j += 1;
572            continue;
573        }
574
575        // Find the end of this attribute (name="value" or just name)
576        let attr_begin = j;
577        // Find '=' or whitespace or end
578        while j < inner_len && inner_bytes[j] != b'=' && !is_whitespace(inner_bytes[j]) {
579            j += 1;
580        }
581        let attr_name = &inner[attr_begin..j];
582
583        // If there's a '=', consume the value
584        let mut attr_end = j;
585        if j < inner_len && inner_bytes[j] == b'=' {
586            j += 1; // skip '='
587            if j < inner_len && inner_bytes[j] == b'"' {
588                j += 1; // skip opening quote
589                while j < inner_len && inner_bytes[j] != b'"' {
590                    j += 1;
591                }
592                if j < inner_len {
593                    j += 1; // skip closing quote
594                }
595            } else {
596                // Unquoted value
597                while j < inner_len && !is_whitespace(inner_bytes[j]) {
598                    j += 1;
599                }
600            }
601            attr_end = j;
602        }
603
604        // Skip the shadowrootmode attribute entirely
605        if attr_name == "shadowrootmode" {
606            continue;
607        }
608
609        // Keep this attribute
610        result.push(' ');
611        result.push_str(&inner[attr_begin..attr_end]);
612    }
613
614    if tag_content.ends_with("/>") {
615        result.push_str("/>");
616    } else {
617        result.push('>');
618    }
619
620    Some(close + 1)
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use std::path::PathBuf;
627
628    fn make_component(tag: &str, html: &str, css: Option<&str>) -> Component {
629        Component {
630            tag_name: tag.to_string(),
631            html_content: html.to_string(),
632            css_content: css.map(ToString::to_string),
633            css_tokens: Vec::new(),
634            source_path: PathBuf::from("/test"),
635            class_name: None,
636        }
637    }
638
639    // --- should_skip_attribute ---
640
641    #[test]
642    fn skip_event_binding() {
643        let plugin = FastParserPlugin::new();
644        assert!(plugin.should_skip_attribute("@click"));
645        assert!(plugin.should_skip_attribute("@input"));
646        assert!(plugin.should_skip_attribute("@custom-event"));
647    }
648
649    #[test]
650    fn skip_f_ref() {
651        let plugin = FastParserPlugin::new();
652        assert!(plugin.should_skip_attribute("f-ref"));
653    }
654
655    #[test]
656    fn skip_f_slotted() {
657        let plugin = FastParserPlugin::new();
658        assert!(plugin.should_skip_attribute("f-slotted"));
659    }
660
661    #[test]
662    fn skip_f_children() {
663        let plugin = FastParserPlugin::new();
664        assert!(plugin.should_skip_attribute("f-children"));
665    }
666
667    #[test]
668    fn do_not_skip_normal_attributes() {
669        let plugin = FastParserPlugin::new();
670        assert!(!plugin.should_skip_attribute("class"));
671        assert!(!plugin.should_skip_attribute("id"));
672        assert!(!plugin.should_skip_attribute("data-value"));
673        assert!(!plugin.should_skip_attribute(":title"));
674        assert!(!plugin.should_skip_attribute("f-other"));
675    }
676
677    // --- on_element_parsed ---
678
679    #[test]
680    fn element_parsed_zero_count_returns_none() {
681        let mut plugin = FastParserPlugin::new();
682        assert!(plugin.on_element_parsed(0).is_none());
683    }
684
685    #[test]
686    fn element_parsed_nonzero_count_returns_le_bytes() {
687        let mut plugin = FastParserPlugin::new();
688        let data = plugin.on_element_parsed(3);
689        assert!(data.is_some());
690        let bytes = data.as_deref().unwrap_or_default();
691        assert_eq!(bytes, &3u32.to_le_bytes());
692    }
693
694    #[test]
695    fn element_parsed_large_count() {
696        let mut plugin = FastParserPlugin::new();
697        let data = plugin.on_element_parsed(256);
698        assert!(data.is_some());
699        let bytes = data.as_deref().unwrap_or_default();
700        assert_eq!(bytes, &256u32.to_le_bytes());
701    }
702
703    // --- on_body_end ---
704
705    #[test]
706    fn body_end_no_components_returns_none() {
707        let mut plugin = FastParserPlugin::new();
708        assert!(plugin.on_body_end().is_none());
709    }
710
711    #[test]
712    fn component_template_simple_component() {
713        let mut plugin = FastParserPlugin::new();
714        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
715        plugin.on_parse_component("my-comp", &comp).unwrap();
716
717        let templates = plugin.take_component_templates();
718        assert_eq!(templates.len(), 1);
719        let (name, html) = &templates[0];
720        assert_eq!(name, "my-comp");
721        assert!(html.contains("<f-template name=\"my-comp\">"));
722        assert!(html.contains("</f-template>"));
723        assert!(html.contains("<template>"));
724        assert!(html.contains("</template>"));
725        assert!(html.contains("<link rel=\"stylesheet\" href=\"/my-comp.css\">"));
726        assert!(html.contains("<div>hello</div>"));
727    }
728
729    #[test]
730    fn component_template_without_css() {
731        let mut plugin = FastParserPlugin::new();
732        let comp = make_component("no-css", "<span>text</span>", None);
733        plugin.on_parse_component("no-css", &comp).unwrap();
734
735        let templates = plugin.take_component_templates();
736        assert_eq!(templates.len(), 1);
737        let (name, html) = &templates[0];
738        assert_eq!(name, "no-css");
739        assert!(html.contains("<f-template name=\"no-css\">"));
740        assert!(!html.contains("<link rel=\"stylesheet\""));
741        assert!(html.contains("<span>text</span>"));
742    }
743
744    #[test]
745    fn component_template_css_strategy_style() {
746        let mut plugin = FastParserPlugin::new();
747        plugin.set_css_strategy(crate::CssStrategy::Style);
748        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
749        plugin.on_parse_component("my-comp", &comp).unwrap();
750
751        let templates = plugin.take_component_templates();
752        assert_eq!(templates.len(), 1);
753        let (_, html) = &templates[0];
754        assert!(
755            html.contains("<style>div { color: red; }</style>"),
756            "Style strategy should inline CSS, got: {html}"
757        );
758        assert!(
759            !html.contains("<link"),
760            "Style strategy should not emit <link> tags"
761        );
762    }
763
764    #[test]
765    fn component_template_css_strategy_module() {
766        let mut plugin = FastParserPlugin::new();
767        plugin.set_css_strategy(crate::CssStrategy::Module);
768        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
769        plugin.on_parse_component("my-comp", &comp).unwrap();
770
771        let templates = plugin.take_component_templates();
772        assert_eq!(templates.len(), 1);
773        let (_, html) = &templates[0];
774
775        // f-template should NOT contain the CSS module — that is added
776        // by the handler (SSR) or prepend_css_module (partials).
777        assert!(
778            !html.contains(r#"<style type="module""#),
779            "CSS module should not be baked into f-template: {html}"
780        );
781        // shadowrootadoptedstylesheets on the inner template
782        assert!(
783            html.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
784            "Module strategy should add shadowrootadoptedstylesheets, got: {html}"
785        );
786        // No inline <style> or <link> inside the template
787        assert!(
788            !html.contains("<style>div"),
789            "Module strategy should not have inline <style> inside template"
790        );
791        assert!(
792            !html.contains("<link"),
793            "Module strategy should not emit <link> tags"
794        );
795    }
796
797    #[test]
798    fn component_template_css_strategy_module_no_css() {
799        let mut plugin = FastParserPlugin::new();
800        plugin.set_css_strategy(crate::CssStrategy::Module);
801        let comp = make_component("my-comp", "<div>hello</div>", None);
802        plugin.on_parse_component("my-comp", &comp).unwrap();
803
804        let templates = plugin.take_component_templates();
805        assert_eq!(templates.len(), 1);
806        let (_, html) = &templates[0];
807        assert!(
808            !html.contains("shadowrootadoptedstylesheets"),
809            "No CSS = no shadowrootadoptedstylesheets, got: {html}"
810        );
811        assert!(
812            !html.contains("<style"),
813            "No CSS = no style tag, got: {html}"
814        );
815    }
816
817    #[test]
818    fn component_template_multiple_components() {
819        let mut plugin = FastParserPlugin::new();
820        let comp1 = make_component("comp-a", "<div>A</div>", None);
821        let comp2 = make_component("comp-b", "<div>B</div>", Some("b { }"));
822        plugin.on_parse_component("comp-a", &comp1).unwrap();
823        plugin.on_parse_component("comp-b", &comp2).unwrap();
824
825        let templates = plugin.take_component_templates();
826        assert_eq!(templates.len(), 2);
827
828        let names: Vec<&str> = templates.iter().map(|(n, _)| n.as_str()).collect();
829        assert!(names.contains(&"comp-a"));
830        assert!(names.contains(&"comp-b"));
831
832        for (_, html) in &templates {
833            assert!(html.contains("<f-template name="));
834        }
835    }
836
837    #[test]
838    fn component_template_deduplicates_same_component() {
839        let mut plugin = FastParserPlugin::new();
840        let comp = make_component(
841            "my-button",
842            "<button><slot></slot></button>",
843            Some(".btn{}"),
844        );
845
846        // Simulate the same component encountered multiple times
847        // (e.g., <my-button> used in todo-app, todo-item, etc.)
848        plugin.on_parse_component("my-button", &comp).unwrap();
849        plugin.on_parse_component("my-button", &comp).unwrap();
850        plugin.on_parse_component("my-button", &comp).unwrap();
851
852        let templates = plugin.take_component_templates();
853        assert_eq!(
854            templates.len(),
855            1,
856            "Expected exactly 1 template for my-button, got {}",
857            templates.len()
858        );
859        assert_eq!(templates[0].0, "my-button");
860    }
861
862    #[test]
863    fn component_template_deduplicates_mixed_components() {
864        let mut plugin = FastParserPlugin::new();
865        let btn = make_component("my-button", "<button><slot></slot></button>", None);
866        let card = make_component("my-card", "<div><slot></slot></div>", Some(".card{}"));
867
868        // my-button used in two different parent templates, my-card used once
869        plugin.on_parse_component("my-button", &btn).unwrap();
870        plugin.on_parse_component("my-card", &card).unwrap();
871        plugin.on_parse_component("my-button", &btn).unwrap();
872
873        let templates = plugin.take_component_templates();
874        assert_eq!(templates.len(), 2);
875
876        let button_count = templates.iter().filter(|(n, _)| n == "my-button").count();
877        let card_count = templates.iter().filter(|(n, _)| n == "my-card").count();
878        assert_eq!(button_count, 1);
879        assert_eq!(card_count, 1);
880    }
881
882    // --- FAST syntax conversion ---
883
884    #[test]
885    fn convert_if_to_f_when() {
886        let input = r#"<if condition="isComplete"><span>Done</span></if>"#;
887        let output = convert_btr_to_fast(input);
888        assert_eq!(
889            output,
890            r#"<f-when value="{isComplete}"><span>Done</span></f-when>"#
891        );
892    }
893
894    #[test]
895    fn convert_for_to_f_repeat() {
896        let input = r#"<for each="tag in tags"><span>{{tag}}</span></for>"#;
897        let output = convert_btr_to_fast(input);
898        assert_eq!(
899            output,
900            r#"<f-repeat value="{tag in tags}"><span>{{tag}}</span></f-repeat>"#
901        );
902    }
903
904    #[test]
905    fn convert_nested_if_and_for() {
906        let input = r#"<if condition="show"><for each="x in items"><p>{{x}}</p></for></if>"#;
907        let output = convert_btr_to_fast(input);
908        assert_eq!(
909            output,
910            r#"<f-when value="{show}"><f-repeat value="{x in items}"><p>{{x}}</p></f-repeat></f-when>"#
911        );
912    }
913
914    #[test]
915    fn convert_complex_attr_double_braces() {
916        let input = r#"<div :data="{{config}}"></div>"#;
917        let output = convert_btr_to_fast(input);
918        assert_eq!(output, r#"<div :data="{config}"></div>"#);
919    }
920
921    #[test]
922    fn convert_multiple_complex_attrs() {
923        let input = r#"<div :title="{{name}}" :data-id="{{id}}"></div>"#;
924        let output = convert_btr_to_fast(input);
925        assert_eq!(output, r#"<div :title="{name}" :data-id="{id}"></div>"#);
926    }
927
928    #[test]
929    fn no_conversion_for_text_double_braces() {
930        // Double braces in text content (not in :attr) should stay as-is
931        let input = "<span>{{greeting}}</span>";
932        let output = convert_btr_to_fast(input);
933        assert_eq!(output, "<span>{{greeting}}</span>");
934    }
935
936    #[test]
937    fn no_conversion_for_regular_attr() {
938        // Double braces in non-colon attributes should stay as-is
939        let input = r#"<div title="{{name}}"></div>"#;
940        let output = convert_btr_to_fast(input);
941        assert_eq!(output, r#"<div title="{{name}}"></div>"#);
942    }
943
944    #[test]
945    fn component_template_with_btr_conversion() {
946        let mut plugin = FastParserPlugin::new();
947        let html = r#"<div><if condition="visible"><span>hi</span></if></div>"#;
948        let comp = make_component("my-widget", html, None);
949        plugin.on_parse_component("my-widget", &comp).unwrap();
950
951        let templates = plugin.take_component_templates();
952        assert_eq!(templates.len(), 1);
953        let (name, result) = &templates[0];
954        assert_eq!(name, "my-widget");
955
956        assert!(result.contains("<f-when value=\"{visible}\">"));
957        assert!(result.contains("</f-when>"));
958        assert!(!result.contains("<if condition="));
959        assert!(!result.contains("</if>"));
960    }
961
962    #[test]
963    fn passthrough_normal_html() {
964        let input = r#"<div class="container"><p>Hello</p></div>"#;
965        let output = convert_btr_to_fast(input);
966        assert_eq!(output, input);
967    }
968
969    #[test]
970    fn outlet_kept_as_marker_in_f_template() {
971        let input = r#"<div><outlet /></div>"#;
972        let output = convert_btr_to_fast(input);
973        assert_eq!(output, r#"<div><outlet></outlet></div>"#);
974    }
975
976    #[test]
977    fn outlet_marker_in_component_template() {
978        let mut plugin = FastParserPlugin::new();
979        let html =
980            r#"<template shadowrootmode="open"><h1>Title</h1><main><outlet /></main></template>"#;
981        let comp = make_component("my-shell", html, None);
982        plugin.on_parse_component("my-shell", &comp).unwrap();
983
984        let templates = plugin.take_component_templates();
985        let (_, result) = &templates[0];
986        assert!(
987            result.contains("<outlet></outlet>"),
988            "outlet should be kept as marker in f-template: {result}"
989        );
990    }
991
992    #[test]
993    fn strip_shadowrootmode_from_template() {
994        let input = r#"<template shadowrootmode="open"><div>Hello</div></template>"#;
995        let output = convert_btr_to_fast(input);
996        assert_eq!(output, "<template><div>Hello</div></template>");
997    }
998
999    #[test]
1000    fn strip_shadowrootmode_preserves_other_attrs() {
1001        let input =
1002            r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>Hi</div></template>"#;
1003        let output = convert_btr_to_fast(input);
1004        assert!(output.contains(r#"@click="{onClick(e)}""#));
1005        assert!(!output.contains("shadowrootmode"));
1006        assert!(output.starts_with("<template "));
1007    }
1008
1009    #[test]
1010    fn no_strip_when_no_shadowrootmode() {
1011        let input = r#"<template foo="bar"><div>Hi</div></template>"#;
1012        let output = convert_btr_to_fast(input);
1013        assert_eq!(output, input);
1014    }
1015
1016    #[test]
1017    fn component_template_strips_shadowrootmode_from_f_template() {
1018        let mut plugin = FastParserPlugin::new();
1019        let comp = make_component(
1020            "my-comp",
1021            r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>{{title}}</div></template>"#,
1022            Some("div { color: red; }"),
1023        );
1024        plugin.on_parse_component("my-comp", &comp).unwrap();
1025        let templates = plugin.take_component_templates();
1026        assert_eq!(templates.len(), 1);
1027        let (name, html) = &templates[0];
1028        assert_eq!(name, "my-comp");
1029        // f-template content should NOT have shadowrootmode
1030        assert!(!html.contains("shadowrootmode"));
1031        // But should keep @click (framework attr kept in f-template mode)
1032        assert!(html.contains("@click"));
1033        // Should have the converted content
1034        assert!(html.contains("{{title}}"));
1035    }
1036
1037    #[test]
1038    fn test_complex_attribute_normalization() {
1039        // All spacing variants should convert {{}} to {} in :attr values,
1040        // preserving any whitespace within the expression.
1041        let cases = [
1042            (
1043                r#"<div :foo="{{value}}"></div>"#,
1044                r#"<div :foo="{value}"></div>"#,
1045            ),
1046            (
1047                r#"<div :foo="{{ value}}"></div>"#,
1048                r#"<div :foo="{ value}"></div>"#,
1049            ),
1050            (
1051                r#"<div :foo="{{value }}"></div>"#,
1052                r#"<div :foo="{value }"></div>"#,
1053            ),
1054            (
1055                r#"<div :foo="{{ value }}"></div>"#,
1056                r#"<div :foo="{ value }"></div>"#,
1057            ),
1058        ];
1059        for (input, expected) in &cases {
1060            let output = convert_btr_to_fast(input);
1061            assert_eq!(&output, expected, "normalization failed for input: {input}");
1062        }
1063    }
1064
1065    #[test]
1066    fn test_non_js_component_inner_template() {
1067        let mut plugin = FastParserPlugin::new();
1068        // A JS component whose HTML contains a non-JS component using
1069        // declarative shadow DOM (template with shadowrootmode).
1070        let html = r#"<div><child-comp><template shadowrootmode="open"><p>inner</p></template></child-comp></div>"#;
1071        let comp = make_component("parent-comp", html, None);
1072        plugin.on_parse_component("parent-comp", &comp).unwrap();
1073
1074        let templates = plugin.take_component_templates();
1075        assert_eq!(templates.len(), 1);
1076        let (name, output) = &templates[0];
1077        assert_eq!(name, "parent-comp");
1078
1079        assert!(output.contains("<f-template name=\"parent-comp\">"));
1080        // shadowrootmode must be stripped from the inner non-JS component's template
1081        assert!(!output.contains("shadowrootmode"));
1082        // The inner template structure should be preserved (without shadowrootmode)
1083        assert!(output.contains("<child-comp><template><p>inner</p></template></child-comp>"));
1084    }
1085
1086    #[test]
1087    fn route_tags_converted_to_f_route() {
1088        let input = r#"<route path="/home" name="home" exact><span>Home</span></route>"#;
1089        let output = convert_btr_to_fast(input);
1090        assert!(output.starts_with("<webui-route"));
1091        assert!(output.contains(r#"path="/home""#));
1092        assert!(output.contains(r#"name="home""#));
1093        assert!(output.contains(" exact"));
1094        assert!(output.contains("style=\"display:none\""));
1095        assert!(output.contains("<span>Home</span>"));
1096        assert!(output.ends_with("</webui-route>"));
1097    }
1098
1099    #[test]
1100    fn f_route_tags_pass_through() {
1101        // <webui-route> tags emitted by the parser should pass through unchanged
1102        let input = r#"<webui-route path="/" name="dashboard" component="cb-page-dashboard" exact style="display:none"></webui-route>"#;
1103        let output = convert_btr_to_fast(input);
1104        assert_eq!(output, input);
1105    }
1106}