Skip to main content

webui_parser/plugin/
fast_v3.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! FAST 3 parser plugin for the WebUI parser.
5//!
6//! Tracks component definitions during HTML parsing and returns `<f-template>`
7//! artifacts after parsing. Converts WebUI Framework template syntax (`<if>`, `<for>`, `{{}}`)
8//! into FAST-compatible syntax (`<f-when>`, `<f-repeat>`, `{}`).
9
10use super::{AttributeAction, ParserPlugin, ParserPluginArtifacts};
11use crate::component_registry::Component;
12use crate::{CssStrategy, Result};
13use webui_protocol::FastElementData;
14
15/// Information about a tracked component for `<f-template>` generation.
16struct TrackedComponent {
17    tag_name: String,
18    html_content: String,
19    css_content: Option<String>,
20}
21
22/// FAST 3 parser plugin used by `fast-v3`.
23///
24/// Implements the `ParserPlugin` trait for FAST 3 applications:
25/// - Filters FAST-specific runtime binding attributes (`@click`, `f-ref`, etc.)
26/// - Tracks components encountered during parsing
27/// - Returns `<f-template>` artifacts with converted FAST syntax after parsing
28/// - Emits binding attribute counts as `Plugin` protocol fragment data
29pub struct FastV3ParserPlugin {
30    /// Components tracked during parsing, in discovery order.
31    components: Vec<TrackedComponent>,
32    /// CSS delivery strategy for f-templates.
33    css_strategy: CssStrategy,
34}
35
36impl FastV3ParserPlugin {
37    /// Create a new FAST 3 parser plugin.
38    #[must_use]
39    pub fn new() -> Self {
40        Self {
41            components: Vec::new(),
42            css_strategy: CssStrategy::Link,
43        }
44    }
45
46    /// Set the CSS delivery strategy for generated f-templates.
47    pub fn set_css_strategy(&mut self, strategy: CssStrategy) {
48        self.css_strategy = strategy;
49    }
50
51    /// Take the individual component f-template strings, keyed by tag name.
52    ///
53    /// Each value is a complete `<f-template name="tag-name">...</f-template>` string
54    /// ready to be appended to a document. This is used by the JSON partial render
55    /// endpoint to send only the templates the client needs.
56    #[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 FastV3ParserPlugin {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl ParserPlugin for FastV3ParserPlugin {
80    fn register_component_template(
81        &mut self,
82        tag_name: &str,
83        component: &Component,
84        _processed_template: &str,
85    ) -> Result<()> {
86        // Only track each component once (avoids duplicate <f-template> blocks
87        // when a component is used in multiple parent templates)
88        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
130/// Generate a single f-template HTML string from component data.
131///
132/// Used by the server to generate templates on demand for route components
133/// that weren't encountered during initial parsing.
134pub 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    // Build the CSS injection string based on the configured strategy
149    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
202/// Convert WebUI Framework template syntax to FAST syntax in HTML content.
203///
204/// Performs the following transformations without regex:
205/// - `<if condition="EXPR">` → `<f-when value="{{EXPR}}">`
206/// - `</if>` → `</f-when>`
207/// - `<for each="EXPR">` → `<f-repeat value="{{EXPR}}">`
208/// - `</for>` → `</f-repeat>`
209/// - `{{expr}}` inside `:attr` complex attribute values → `{expr}`
210/// - Strips `shadowrootmode` attributes from `<template>` tags
211///   (in f-template context, shadowrootmode must be removed to prevent
212///   the browser from auto-activating it as a declarative shadow root)
213fn 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        // Check for {{expr}} inside :attr values — handled at attribute level
228        // in try_convert_tag. Outside of tags, double-braces are left as-is
229        // (they're text content interpolation for FAST, kept unchanged).
230        i = push_char_at(input, i, &mut result);
231    }
232
233    result
234}
235
236/// Try to convert a tag starting at position `pos`. Returns `Some(bytes_consumed)`
237/// if a conversion was performed, or `None` if this is not a convertible tag.
238fn try_convert_tag(input: &str, pos: usize, result: &mut String) -> Option<usize> {
239    let remaining = &input[pos..];
240
241    // Check for closing tags first (shorter patterns)
242    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    // <route> in f-templates becomes empty <webui-route> (client router mounts components)
251    if remaining.starts_with("</route>") {
252        result.push_str("</webui-route>");
253        return Some(8);
254    }
255
256    // <outlet /> is kept as a marker element in f-templates.
257    // The client router finds it and replaces it with <webui-route> stubs.
258    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    // Check for <if condition="...">
266    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    // Check for <for each="...">
273    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    // Check for <route ...> → <webui-route ...> in f-templates
280    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    // Check for tags with :attr="{{expr}}" complex attribute values
287    if remaining.starts_with("<") {
288        // Strip shadowrootmode from <template> tags
289        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
302/// Check if `s` starts with `<name` followed by whitespace or `>`.
303fn 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
319/// Convert `<if condition="EXPR">` to `<f-when value="{{EXPR}}">`.
320/// Returns bytes consumed on success.
321fn convert_if_tag(tag_str: &str, result: &mut String) -> Option<usize> {
322    // Find the closing '>' outside of quoted attribute values
323    let close = find_tag_close(tag_str)?;
324    let tag_content = &tag_str[..=close];
325
326    // Find condition="..." attribute
327    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
336/// Convert `<for each="EXPR">` to `<f-repeat value="{{EXPR}}">`.
337/// Returns bytes consumed on success.
338fn convert_for_tag(tag_str: &str, result: &mut String) -> Option<usize> {
339    // Find the closing '>' outside of quoted attribute values
340    let close = find_tag_close(tag_str)?;
341    let tag_content = &tag_str[..=close];
342
343    // Find each="..." attribute
344    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
353/// Convert `<route ...>` to `<webui-route ...>` in f-templates.
354/// Self-closing routes become `<webui-route ...></webui-route>` (empty).
355/// The component attribute is preserved for the client router.
356fn 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
405/// Find the position of the tag-closing `>` that is NOT inside a quoted
406/// attribute value.  Returns `None` when no unquoted `>` is found.
407fn 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
420/// Extract the value of a named attribute from a tag string.
421/// Looks for `name="value"` and returns `value`.
422fn extract_attribute_value<'a>(tag: &'a str, attr_name: &str) -> Option<&'a str> {
423    // Search for the attribute name followed by '='
424    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        // Verify it's preceded by whitespace (not part of another attribute name)
430        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; // skip `="`
447            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
455/// Convert tags that contain `:attr="{{expr}}"` complex attribute bindings.
456/// Replaces `{{expr}}` with `{expr}` in attribute values on `:` prefixed attributes.
457/// Returns bytes consumed on success, or `None` if no conversion needed.
458fn convert_complex_attrs(tag_str: &str, result: &mut String) -> Option<usize> {
459    // Find the closing '>' outside of quoted attribute values
460    let close = find_tag_close(tag_str)?;
461    let tag_content = &tag_str[..=close];
462
463    // Quick check: does this tag have both a colon-prefixed attribute and `{{`?
464    if !tag_content.contains(":") || !tag_content.contains("{{") {
465        return None;
466    }
467
468    // Process the tag character by character, converting {{}} to {} in :attr values
469    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            // Look for :attrname="
478            if tag_bytes[j] == b':' && j > 0 && is_whitespace(tag_bytes[j - 1]) {
479                // Found a colon-prefixed attribute, scan to the ="
480                let attr_start = j;
481                j += 1;
482                // Skip attribute name
483                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                        // Push everything up to and including the opening quote
490                        converted.push_str(&tag_content[attr_start..=j]);
491                        j += 1;
492                        in_colon_attr_value = true;
493                        continue;
494                    }
495                }
496                // Not a valid :attr="..." pattern, output as-is
497                converted.push_str(&tag_content[attr_start..j]);
498                continue;
499            }
500            j = push_char_at(tag_content, j, &mut converted);
501        } else {
502            // Inside a :attr value — convert {{expr}} to {expr}
503            if tag_bytes[j] == b'"' {
504                // End of attribute value
505                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                // Found {{ — find matching }}
510                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                    // No matching }}, output as-is
519                    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
533/// Push the single UTF-8 character starting at `pos` in `input` into `out`,
534/// returning the byte index immediately after it.
535fn 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
550/// Check if a byte is ASCII whitespace.
551fn is_whitespace(b: u8) -> bool {
552    b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'
553}
554
555/// Collapse whitespace-only text between `>` and `<` to eliminate extra DOM
556/// text nodes that would shift element indices during hydration.
557/// This ensures the f-template DOM structure matches the minified DSD output.
558fn 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            // Skip whitespace-only content between > and <
569            let ws_start = i;
570            while i < len && is_whitespace(bytes[i]) {
571                i += 1;
572            }
573            // If we hit '<', the whitespace was inter-tag — drop it
574            // If we hit non-'<', it's meaningful text — keep it
575            if i >= len || bytes[i] != b'<' {
576                // Keep the whitespace (it's before text content)
577                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
587/// Strip `shadowrootmode` attribute from a `<template ...>` opening tag.
588/// Returns `Some(bytes_consumed)` if a `<template` tag was found and processed.
589fn strip_shadowrootmode(tag_str: &str, result: &mut String) -> Option<usize> {
590    // Find the closing '>' outside of quoted attribute values
591    let close = find_tag_close(tag_str)?;
592    let tag_content = &tag_str[..=close];
593
594    // Only process if this tag contains shadowrootmode
595    if !tag_content.contains("shadowrootmode") {
596        return None;
597    }
598
599    // Rebuild the tag without the shadowrootmode attribute
600    result.push_str("<template");
601    let attr_start = "<template".len();
602    let inner = &tag_content[attr_start..close];
603
604    // Scan through the attributes, skipping shadowrootmode
605    let inner_bytes = inner.as_bytes();
606    let inner_len = inner_bytes.len();
607    let mut j = 0;
608    while j < inner_len {
609        // Skip whitespace
610        if is_whitespace(inner_bytes[j]) {
611            j += 1;
612            continue;
613        }
614
615        // Find the end of this attribute (name="value" or just name)
616        let attr_begin = j;
617        // Find '=' or whitespace or end
618        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        // If there's a '=', consume the value
624        let mut attr_end = j;
625        if j < inner_len && inner_bytes[j] == b'=' {
626            j += 1; // skip '='
627            if j < inner_len && inner_bytes[j] == b'"' {
628                j += 1; // skip opening quote
629                while j < inner_len && inner_bytes[j] != b'"' {
630                    j += 1;
631                }
632                if j < inner_len {
633                    j += 1; // skip closing quote
634                }
635            } else {
636                // Unquoted value
637                while j < inner_len && !is_whitespace(inner_bytes[j]) {
638                    j += 1;
639                }
640            }
641            attr_end = j;
642        }
643
644        // Skip the shadowrootmode attribute entirely
645        if attr_name == "shadowrootmode" {
646            continue;
647        }
648
649        // Keep this attribute
650        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    // --- classify_attribute ---
682
683    #[test]
684    fn skip_event_binding() {
685        let mut plugin = FastV3ParserPlugin::new();
686        assert_eq!(
687            plugin.classify_attribute("@click"),
688            AttributeAction::SkipAndCountBinding
689        );
690        assert_eq!(
691            plugin.classify_attribute("@input"),
692            AttributeAction::SkipAndCountBinding
693        );
694        assert_eq!(
695            plugin.classify_attribute("@custom-event"),
696            AttributeAction::SkipAndCountBinding
697        );
698    }
699
700    #[test]
701    fn skip_f_ref() {
702        let mut plugin = FastV3ParserPlugin::new();
703        assert_eq!(
704            plugin.classify_attribute("f-ref"),
705            AttributeAction::SkipAndCountBinding
706        );
707    }
708
709    #[test]
710    fn skip_f_slotted() {
711        let mut plugin = FastV3ParserPlugin::new();
712        assert_eq!(
713            plugin.classify_attribute("f-slotted"),
714            AttributeAction::SkipAndCountBinding
715        );
716    }
717
718    #[test]
719    fn skip_f_children() {
720        let mut plugin = FastV3ParserPlugin::new();
721        assert_eq!(
722            plugin.classify_attribute("f-children"),
723            AttributeAction::SkipAndCountBinding
724        );
725    }
726
727    #[test]
728    fn do_not_skip_normal_attributes() {
729        let mut plugin = FastV3ParserPlugin::new();
730        assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
731        assert_eq!(plugin.classify_attribute("id"), AttributeAction::Keep);
732        assert_eq!(
733            plugin.classify_attribute("data-value"),
734            AttributeAction::Keep
735        );
736        assert_eq!(plugin.classify_attribute(":title"), AttributeAction::Keep);
737        assert_eq!(plugin.classify_attribute("f-other"), AttributeAction::Keep);
738    }
739
740    // --- finish_element ---
741
742    #[test]
743    fn element_parsed_zero_count_returns_none() {
744        let mut plugin = FastV3ParserPlugin::new();
745        assert!(plugin.finish_element(0).is_none());
746    }
747
748    #[test]
749    fn element_parsed_nonzero_count_returns_le_bytes() {
750        let mut plugin = FastV3ParserPlugin::new();
751        let data = plugin.finish_element(3);
752        assert!(data.is_some());
753        let bytes = data.as_deref().unwrap_or_default();
754        assert_eq!(bytes, &3u32.to_le_bytes());
755    }
756
757    #[test]
758    fn element_parsed_large_count() {
759        let mut plugin = FastV3ParserPlugin::new();
760        let data = plugin.finish_element(256);
761        assert!(data.is_some());
762        let bytes = data.as_deref().unwrap_or_default();
763        assert_eq!(bytes, &256u32.to_le_bytes());
764    }
765
766    #[test]
767    fn component_template_simple_component() {
768        let mut plugin = FastV3ParserPlugin::new();
769        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
770        plugin
771            .register_component_template("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 = FastV3ParserPlugin::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 = FastV3ParserPlugin::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 = FastV3ParserPlugin::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        // f-template should NOT contain the CSS module — that is added
839        // by the handler (SSR) or prepend_css_module (partials).
840        assert!(
841            !html.contains(r#"<style type="module""#),
842            "CSS module should not be baked into f-template: {html}"
843        );
844        // shadowrootadoptedstylesheets on the inner template
845        assert!(
846            html.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
847            "Module strategy should add shadowrootadoptedstylesheets, got: {html}"
848        );
849        // No inline <style> or <link> inside the template
850        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 = FastV3ParserPlugin::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 = FastV3ParserPlugin::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 = FastV3ParserPlugin::new();
909        let comp = make_component(
910            "my-button",
911            "<button><slot></slot></button>",
912            Some(".btn{}"),
913        );
914
915        // Simulate the same component encountered multiple times
916        // (e.g., <my-button> used in todo-app, todo-item, etc.)
917        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 = FastV3ParserPlugin::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        // my-button used in two different parent templates, my-card used once
944        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    // --- FAST syntax conversion ---
964
965    #[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        // Double braces in text content (not in :attr) should stay as-is
1036        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        // Double braces in non-colon attributes should stay as-is
1044        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 = FastV3ParserPlugin::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 = FastV3ParserPlugin::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 = FastV3ParserPlugin::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        // f-template content should NOT have shadowrootmode
1141        assert!(!html.contains("shadowrootmode"));
1142        // But should keep @click (framework attr kept in f-template mode)
1143        assert!(html.contains("@click"));
1144        // Should have the converted content
1145        assert!(html.contains("{{title}}"));
1146    }
1147
1148    #[test]
1149    fn test_complex_attribute_normalization() {
1150        // All spacing variants should convert {{}} to {} in :attr values,
1151        // preserving any whitespace within the expression.
1152        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 = FastV3ParserPlugin::new();
1179        // A JS component whose HTML contains a non-JS component using
1180        // declarative shadow DOM (template with shadowrootmode).
1181        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        // shadowrootmode must be stripped from the inner non-JS component's template
1194        assert!(!output.contains("shadowrootmode"));
1195        // The inner template structure should be preserved (without shadowrootmode)
1196        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        // <webui-route> tags emitted by the parser should pass through unchanged
1215        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    // --- End-to-end f-template regression tests ---
1221    // Verify that generated f-template blocks always use FAST syntax
1222    // (<f-when>/<f-repeat>) instead of webui syntax (<if>/<for>).
1223
1224    #[test]
1225    fn ftemplate_for_loop_converted_to_f_repeat() {
1226        let mut plugin = FastV3ParserPlugin::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 = FastV3ParserPlugin::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 = FastV3ParserPlugin::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) // the `>` after the closing quote
1365        );
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}