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 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 parser plugin.
23///
24/// Implements the `ParserPlugin` trait for the FAST framework:
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 FastParserPlugin {
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 FastParserPlugin {
37    /// Create a new FAST 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 FastParserPlugin {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl ParserPlugin for FastParserPlugin {
80    fn register_component_template(
81        &mut self,
82        tag_name: &str,
83        component: &Component,
84        _processed_template: &str,
85    ) -> Result<()> {
86        // 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        result.push(bytes[i] as char);
231        i += 1;
232    }
233
234    result
235}
236
237/// Try to convert a tag starting at position `pos`. Returns `Some(bytes_consumed)`
238/// if a conversion was performed, or `None` if this is not a convertible tag.
239fn try_convert_tag(input: &str, pos: usize, result: &mut String) -> Option<usize> {
240    let remaining = &input[pos..];
241
242    // Check for closing tags first (shorter patterns)
243    if remaining.starts_with("</if>") {
244        result.push_str("</f-when>");
245        return Some(5);
246    }
247    if remaining.starts_with("</for>") {
248        result.push_str("</f-repeat>");
249        return Some(6);
250    }
251    // <route> in f-templates becomes empty <webui-route> (client router mounts components)
252    if remaining.starts_with("</route>") {
253        result.push_str("</webui-route>");
254        return Some(8);
255    }
256
257    // <outlet /> is kept as a marker element in f-templates.
258    // The client router finds it and replaces it with <webui-route> stubs.
259    if starts_with_tag_name(remaining, "outlet") {
260        if let Some(close) = remaining.find('>') {
261            result.push_str("<outlet></outlet>");
262            return Some(close + 1);
263        }
264    }
265
266    // Check for <if condition="...">
267    if starts_with_tag_name(remaining, "if") {
268        if let Some(consumed) = convert_if_tag(remaining, result) {
269            return Some(consumed);
270        }
271    }
272
273    // Check for <for each="...">
274    if starts_with_tag_name(remaining, "for") {
275        if let Some(consumed) = convert_for_tag(remaining, result) {
276            return Some(consumed);
277        }
278    }
279
280    // Check for <route ...> → <webui-route ...> in f-templates
281    if starts_with_tag_name(remaining, "route") {
282        if let Some(consumed) = convert_route_tag(remaining, result) {
283            return Some(consumed);
284        }
285    }
286
287    // Check for tags with :attr="{{expr}}" complex attribute values
288    if remaining.starts_with("<") {
289        // Strip shadowrootmode from <template> tags
290        if starts_with_tag_name(remaining, "template") {
291            if let Some(consumed) = strip_shadowrootmode(remaining, result) {
292                return Some(consumed);
293            }
294        }
295        if let Some(consumed) = convert_complex_attrs(remaining, result) {
296            return Some(consumed);
297        }
298    }
299
300    None
301}
302
303/// Check if `s` starts with `<name` followed by whitespace or `>`.
304fn starts_with_tag_name(s: &str, name: &str) -> bool {
305    if !s.starts_with('<') {
306        return false;
307    }
308    let after_bracket = &s[1..];
309    if !after_bracket.starts_with(name) {
310        return false;
311    }
312    let after_name = &after_bracket[name.len()..];
313    if after_name.is_empty() {
314        return false;
315    }
316    let next = after_name.as_bytes()[0];
317    next == b' ' || next == b'\t' || next == b'\n' || next == b'\r' || next == b'>'
318}
319
320/// Convert `<if condition="EXPR">` to `<f-when value="{{EXPR}}">`.
321/// Returns bytes consumed on success.
322fn convert_if_tag(tag_str: &str, result: &mut String) -> Option<usize> {
323    // Find the closing '>' outside of quoted attribute values
324    let close = find_tag_close(tag_str)?;
325    let tag_content = &tag_str[..=close];
326
327    // Find condition="..." attribute
328    let attr_value = extract_attribute_value(tag_content, "condition")?;
329
330    result.push_str("<f-when value=\"{{");
331    result.push_str(attr_value);
332    result.push_str("}}\">");
333
334    Some(close + 1)
335}
336
337/// Convert `<for each="EXPR">` to `<f-repeat value="{{EXPR}}">`.
338/// Returns bytes consumed on success.
339fn convert_for_tag(tag_str: &str, result: &mut String) -> Option<usize> {
340    // Find the closing '>' outside of quoted attribute values
341    let close = find_tag_close(tag_str)?;
342    let tag_content = &tag_str[..=close];
343
344    // Find each="..." attribute
345    let attr_value = extract_attribute_value(tag_content, "each")?;
346
347    result.push_str("<f-repeat value=\"{{");
348    result.push_str(attr_value);
349    result.push_str("}}\">");
350
351    Some(close + 1)
352}
353
354/// Convert `<route ...>` to `<webui-route ...>` in f-templates.
355/// Self-closing routes become `<webui-route ...></webui-route>` (empty).
356/// The component attribute is preserved for the client router.
357fn convert_route_tag(tag_str: &str, result: &mut String) -> Option<usize> {
358    let close = find_tag_close(tag_str)?;
359    let is_self_closing = tag_str[..=close].ends_with("/>");
360    let tag = &tag_str[..=close];
361
362    result.push_str("<webui-route");
363
364    if let Some(v) = extract_attribute_value(tag, "path") {
365        result.push_str(" path=\"");
366        result.push_str(v);
367        result.push('"');
368    }
369    if let Some(v) = extract_attribute_value(tag, "name") {
370        result.push_str(" name=\"");
371        result.push_str(v);
372        result.push('"');
373    }
374    if let Some(v) = extract_attribute_value(tag, "component") {
375        result.push_str(" component=\"");
376        result.push_str(v);
377        result.push('"');
378    }
379    if let Some(v) = extract_attribute_value(tag, "redirectTo") {
380        result.push_str(" redirectto=\"");
381        result.push_str(v);
382        result.push('"');
383    }
384    if tag.contains(" exact") {
385        result.push_str(" exact");
386    }
387    if tag.contains(" layout") {
388        result.push_str(" layout");
389    }
390    if let Some(v) = extract_attribute_value(tag, "query") {
391        result.push_str(" query=\"");
392        result.push_str(v);
393        result.push('"');
394    }
395    result.push_str(" style=\"display:none\"");
396
397    if is_self_closing {
398        result.push_str("></webui-route>");
399    } else {
400        result.push('>');
401    }
402
403    Some(close + 1)
404}
405
406/// Find the position of the tag-closing `>` that is NOT inside a quoted
407/// attribute value.  Returns `None` when no unquoted `>` is found.
408fn find_tag_close(tag_str: &str) -> Option<usize> {
409    let bytes = tag_str.as_bytes();
410    let mut in_quote = false;
411    for (i, &b) in bytes.iter().enumerate() {
412        match b {
413            b'"' => in_quote = !in_quote,
414            b'>' if !in_quote => return Some(i),
415            _ => {}
416        }
417    }
418    None
419}
420
421/// Extract the value of a named attribute from a tag string.
422/// Looks for `name="value"` and returns `value`.
423fn extract_attribute_value<'a>(tag: &'a str, attr_name: &str) -> Option<&'a str> {
424    // Search for the attribute name followed by '='
425    let mut search_from = 0;
426    loop {
427        let attr_pos = tag[search_from..].find(attr_name)?;
428        let abs_pos = search_from + attr_pos;
429
430        // Verify it's preceded by whitespace (not part of another attribute name)
431        if abs_pos > 0 {
432            let prev = tag.as_bytes()[abs_pos - 1];
433            if prev != b' ' && prev != b'\t' && prev != b'\n' && prev != b'\r' {
434                search_from = abs_pos + attr_name.len();
435                continue;
436            }
437        }
438
439        let after_name = &tag[abs_pos + attr_name.len()..];
440        if !after_name.starts_with('=') {
441            search_from = abs_pos + attr_name.len();
442            continue;
443        }
444
445        let after_eq = &after_name[1..];
446        if after_eq.starts_with('"') {
447            let value_start = abs_pos + attr_name.len() + 2; // skip `="`
448            let end_quote = tag[value_start..].find('"')?;
449            return Some(&tag[value_start..value_start + end_quote]);
450        }
451
452        return None;
453    }
454}
455
456/// Convert tags that contain `:attr="{{expr}}"` complex attribute bindings.
457/// Replaces `{{expr}}` with `{expr}` in attribute values on `:` prefixed attributes.
458/// Returns bytes consumed on success, or `None` if no conversion needed.
459fn convert_complex_attrs(tag_str: &str, result: &mut String) -> Option<usize> {
460    // Find the closing '>' outside of quoted attribute values
461    let close = find_tag_close(tag_str)?;
462    let tag_content = &tag_str[..=close];
463
464    // Quick check: does this tag have both a colon-prefixed attribute and `{{`?
465    if !tag_content.contains(":") || !tag_content.contains("{{") {
466        return None;
467    }
468
469    // Process the tag character by character, converting {{}} to {} in :attr values
470    let mut converted = String::with_capacity(tag_content.len());
471    let tag_bytes = tag_content.as_bytes();
472    let tag_len = tag_bytes.len();
473    let mut j = 0;
474    let mut in_colon_attr_value = false;
475
476    while j < tag_len {
477        if !in_colon_attr_value {
478            // Look for :attrname="
479            if tag_bytes[j] == b':' && j > 0 && is_whitespace(tag_bytes[j - 1]) {
480                // Found a colon-prefixed attribute, scan to the ="
481                let attr_start = j;
482                j += 1;
483                // Skip attribute name
484                while j < tag_len && tag_bytes[j] != b'=' && !is_whitespace(tag_bytes[j]) {
485                    j += 1;
486                }
487                if j < tag_len && tag_bytes[j] == b'=' {
488                    j += 1;
489                    if j < tag_len && tag_bytes[j] == b'"' {
490                        // Push everything up to and including the opening quote
491                        converted.push_str(&tag_content[attr_start..=j]);
492                        j += 1;
493                        in_colon_attr_value = true;
494                        continue;
495                    }
496                }
497                // Not a valid :attr="..." pattern, output as-is
498                converted.push_str(&tag_content[attr_start..j]);
499                continue;
500            }
501            converted.push(tag_bytes[j] as char);
502            j += 1;
503        } else {
504            // Inside a :attr value — convert {{expr}} to {expr}
505            if tag_bytes[j] == b'"' {
506                // End of attribute value
507                converted.push('"');
508                j += 1;
509                in_colon_attr_value = false;
510            } else if j + 1 < tag_len && tag_bytes[j] == b'{' && tag_bytes[j + 1] == b'{' {
511                // Found {{ — find matching }}
512                let expr_start = j + 2;
513                if let Some(end_offset) = tag_content[expr_start..].find("}}") {
514                    let expr = &tag_content[expr_start..expr_start + end_offset];
515                    converted.push('{');
516                    converted.push_str(expr);
517                    converted.push('}');
518                    j = expr_start + end_offset + 2;
519                } else {
520                    // No matching }}, output as-is
521                    converted.push('{');
522                    converted.push('{');
523                    j += 2;
524                }
525            } else {
526                converted.push(tag_bytes[j] as char);
527                j += 1;
528            }
529        }
530    }
531
532    result.push_str(&converted);
533    Some(close + 1)
534}
535
536/// Check if a byte is ASCII whitespace.
537fn is_whitespace(b: u8) -> bool {
538    b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'
539}
540
541/// Collapse whitespace-only text between `>` and `<` to eliminate extra DOM
542/// text nodes that would shift element indices during hydration.
543/// This ensures the f-template DOM structure matches the minified DSD output.
544fn minify_inter_tag_whitespace(input: &str) -> String {
545    let mut result = String::with_capacity(input.len());
546    let bytes = input.as_bytes();
547    let len = bytes.len();
548    let mut i = 0;
549
550    while i < len {
551        if bytes[i] == b'>' && i + 1 < len {
552            result.push('>');
553            i += 1;
554            // Skip whitespace-only content between > and <
555            let ws_start = i;
556            while i < len && is_whitespace(bytes[i]) {
557                i += 1;
558            }
559            // If we hit '<', the whitespace was inter-tag — drop it
560            // If we hit non-'<', it's meaningful text — keep it
561            if i >= len || bytes[i] != b'<' {
562                // Keep the whitespace (it's before text content)
563                result.push_str(&input[ws_start..i]);
564            }
565        } else {
566            result.push(bytes[i] as char);
567            i += 1;
568        }
569    }
570
571    result
572}
573
574/// Strip `shadowrootmode` attribute from a `<template ...>` opening tag.
575/// Returns `Some(bytes_consumed)` if a `<template` tag was found and processed.
576fn strip_shadowrootmode(tag_str: &str, result: &mut String) -> Option<usize> {
577    // Find the closing '>' outside of quoted attribute values
578    let close = find_tag_close(tag_str)?;
579    let tag_content = &tag_str[..=close];
580
581    // Only process if this tag contains shadowrootmode
582    if !tag_content.contains("shadowrootmode") {
583        return None;
584    }
585
586    // Rebuild the tag without the shadowrootmode attribute
587    result.push_str("<template");
588    let attr_start = "<template".len();
589    let inner = &tag_content[attr_start..close];
590
591    // Scan through the attributes, skipping shadowrootmode
592    let inner_bytes = inner.as_bytes();
593    let inner_len = inner_bytes.len();
594    let mut j = 0;
595    while j < inner_len {
596        // Skip whitespace
597        if is_whitespace(inner_bytes[j]) {
598            j += 1;
599            continue;
600        }
601
602        // Find the end of this attribute (name="value" or just name)
603        let attr_begin = j;
604        // Find '=' or whitespace or end
605        while j < inner_len && inner_bytes[j] != b'=' && !is_whitespace(inner_bytes[j]) {
606            j += 1;
607        }
608        let attr_name = &inner[attr_begin..j];
609
610        // If there's a '=', consume the value
611        let mut attr_end = j;
612        if j < inner_len && inner_bytes[j] == b'=' {
613            j += 1; // skip '='
614            if j < inner_len && inner_bytes[j] == b'"' {
615                j += 1; // skip opening quote
616                while j < inner_len && inner_bytes[j] != b'"' {
617                    j += 1;
618                }
619                if j < inner_len {
620                    j += 1; // skip closing quote
621                }
622            } else {
623                // Unquoted value
624                while j < inner_len && !is_whitespace(inner_bytes[j]) {
625                    j += 1;
626                }
627            }
628            attr_end = j;
629        }
630
631        // Skip the shadowrootmode attribute entirely
632        if attr_name == "shadowrootmode" {
633            continue;
634        }
635
636        // Keep this attribute
637        result.push(' ');
638        result.push_str(&inner[attr_begin..attr_end]);
639    }
640
641    if tag_content.ends_with("/>") {
642        result.push_str("/>");
643    } else {
644        result.push('>');
645    }
646
647    Some(close + 1)
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use std::path::PathBuf;
654
655    fn make_component(tag: &str, html: &str, css: Option<&str>) -> Component {
656        Component {
657            tag_name: tag.to_string(),
658            html_content: html.to_string(),
659            css_content: css.map(ToString::to_string),
660            css_tokens: Vec::new(),
661            source_path: PathBuf::from("/test"),
662            class_name: None,
663        }
664    }
665
666    // --- classify_attribute ---
667
668    #[test]
669    fn skip_event_binding() {
670        let mut plugin = FastParserPlugin::new();
671        assert_eq!(
672            plugin.classify_attribute("@click"),
673            AttributeAction::SkipAndCountBinding
674        );
675        assert_eq!(
676            plugin.classify_attribute("@input"),
677            AttributeAction::SkipAndCountBinding
678        );
679        assert_eq!(
680            plugin.classify_attribute("@custom-event"),
681            AttributeAction::SkipAndCountBinding
682        );
683    }
684
685    #[test]
686    fn skip_f_ref() {
687        let mut plugin = FastParserPlugin::new();
688        assert_eq!(
689            plugin.classify_attribute("f-ref"),
690            AttributeAction::SkipAndCountBinding
691        );
692    }
693
694    #[test]
695    fn skip_f_slotted() {
696        let mut plugin = FastParserPlugin::new();
697        assert_eq!(
698            plugin.classify_attribute("f-slotted"),
699            AttributeAction::SkipAndCountBinding
700        );
701    }
702
703    #[test]
704    fn skip_f_children() {
705        let mut plugin = FastParserPlugin::new();
706        assert_eq!(
707            plugin.classify_attribute("f-children"),
708            AttributeAction::SkipAndCountBinding
709        );
710    }
711
712    #[test]
713    fn do_not_skip_normal_attributes() {
714        let mut plugin = FastParserPlugin::new();
715        assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
716        assert_eq!(plugin.classify_attribute("id"), AttributeAction::Keep);
717        assert_eq!(
718            plugin.classify_attribute("data-value"),
719            AttributeAction::Keep
720        );
721        assert_eq!(plugin.classify_attribute(":title"), AttributeAction::Keep);
722        assert_eq!(plugin.classify_attribute("f-other"), AttributeAction::Keep);
723    }
724
725    // --- finish_element ---
726
727    #[test]
728    fn element_parsed_zero_count_returns_none() {
729        let mut plugin = FastParserPlugin::new();
730        assert!(plugin.finish_element(0).is_none());
731    }
732
733    #[test]
734    fn element_parsed_nonzero_count_returns_le_bytes() {
735        let mut plugin = FastParserPlugin::new();
736        let data = plugin.finish_element(3);
737        assert!(data.is_some());
738        let bytes = data.as_deref().unwrap_or_default();
739        assert_eq!(bytes, &3u32.to_le_bytes());
740    }
741
742    #[test]
743    fn element_parsed_large_count() {
744        let mut plugin = FastParserPlugin::new();
745        let data = plugin.finish_element(256);
746        assert!(data.is_some());
747        let bytes = data.as_deref().unwrap_or_default();
748        assert_eq!(bytes, &256u32.to_le_bytes());
749    }
750
751    #[test]
752    fn component_template_simple_component() {
753        let mut plugin = FastParserPlugin::new();
754        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
755        plugin
756            .register_component_template("my-comp", &comp, &comp.html_content)
757            .unwrap();
758
759        let templates = plugin.take_component_templates();
760        assert_eq!(templates.len(), 1);
761        let (name, html) = &templates[0];
762        assert_eq!(name, "my-comp");
763        assert!(html.contains("<f-template name=\"my-comp\">"));
764        assert!(html.contains("</f-template>"));
765        assert!(html.contains("<template>"));
766        assert!(html.contains("</template>"));
767        assert!(html.contains("<link rel=\"stylesheet\" href=\"/my-comp.css\">"));
768        assert!(html.contains("<div>hello</div>"));
769    }
770
771    #[test]
772    fn component_template_without_css() {
773        let mut plugin = FastParserPlugin::new();
774        let comp = make_component("no-css", "<span>text</span>", None);
775        plugin
776            .register_component_template("no-css", &comp, &comp.html_content)
777            .unwrap();
778
779        let templates = plugin.take_component_templates();
780        assert_eq!(templates.len(), 1);
781        let (name, html) = &templates[0];
782        assert_eq!(name, "no-css");
783        assert!(html.contains("<f-template name=\"no-css\">"));
784        assert!(!html.contains("<link rel=\"stylesheet\""));
785        assert!(html.contains("<span>text</span>"));
786    }
787
788    #[test]
789    fn component_template_css_strategy_style() {
790        let mut plugin = FastParserPlugin::new();
791        plugin.set_css_strategy(crate::CssStrategy::Style);
792        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
793        plugin
794            .register_component_template("my-comp", &comp, &comp.html_content)
795            .unwrap();
796
797        let templates = plugin.take_component_templates();
798        assert_eq!(templates.len(), 1);
799        let (_, html) = &templates[0];
800        assert!(
801            html.contains("<style>div { color: red; }</style>"),
802            "Style strategy should inline CSS, got: {html}"
803        );
804        assert!(
805            !html.contains("<link"),
806            "Style strategy should not emit <link> tags"
807        );
808    }
809
810    #[test]
811    fn component_template_css_strategy_module() {
812        let mut plugin = FastParserPlugin::new();
813        plugin.set_css_strategy(crate::CssStrategy::Module);
814        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
815        plugin
816            .register_component_template("my-comp", &comp, &comp.html_content)
817            .unwrap();
818
819        let templates = plugin.take_component_templates();
820        assert_eq!(templates.len(), 1);
821        let (_, html) = &templates[0];
822
823        // f-template should NOT contain the CSS module — that is added
824        // by the handler (SSR) or prepend_css_module (partials).
825        assert!(
826            !html.contains(r#"<style type="module""#),
827            "CSS module should not be baked into f-template: {html}"
828        );
829        // shadowrootadoptedstylesheets on the inner template
830        assert!(
831            html.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
832            "Module strategy should add shadowrootadoptedstylesheets, got: {html}"
833        );
834        // No inline <style> or <link> inside the template
835        assert!(
836            !html.contains("<style>div"),
837            "Module strategy should not have inline <style> inside template"
838        );
839        assert!(
840            !html.contains("<link"),
841            "Module strategy should not emit <link> tags"
842        );
843    }
844
845    #[test]
846    fn component_template_css_strategy_module_no_css() {
847        let mut plugin = FastParserPlugin::new();
848        plugin.set_css_strategy(crate::CssStrategy::Module);
849        let comp = make_component("my-comp", "<div>hello</div>", None);
850        plugin
851            .register_component_template("my-comp", &comp, &comp.html_content)
852            .unwrap();
853
854        let templates = plugin.take_component_templates();
855        assert_eq!(templates.len(), 1);
856        let (_, html) = &templates[0];
857        assert!(
858            !html.contains("shadowrootadoptedstylesheets"),
859            "No CSS = no shadowrootadoptedstylesheets, got: {html}"
860        );
861        assert!(
862            !html.contains("<style"),
863            "No CSS = no style tag, got: {html}"
864        );
865    }
866
867    #[test]
868    fn component_template_multiple_components() {
869        let mut plugin = FastParserPlugin::new();
870        let comp1 = make_component("comp-a", "<div>A</div>", None);
871        let comp2 = make_component("comp-b", "<div>B</div>", Some("b { }"));
872        plugin
873            .register_component_template("comp-a", &comp1, &comp1.html_content)
874            .unwrap();
875        plugin
876            .register_component_template("comp-b", &comp2, &comp2.html_content)
877            .unwrap();
878
879        let templates = plugin.take_component_templates();
880        assert_eq!(templates.len(), 2);
881
882        let names: Vec<&str> = templates.iter().map(|(n, _)| n.as_str()).collect();
883        assert!(names.contains(&"comp-a"));
884        assert!(names.contains(&"comp-b"));
885
886        for (_, html) in &templates {
887            assert!(html.contains("<f-template name="));
888        }
889    }
890
891    #[test]
892    fn component_template_deduplicates_same_component() {
893        let mut plugin = FastParserPlugin::new();
894        let comp = make_component(
895            "my-button",
896            "<button><slot></slot></button>",
897            Some(".btn{}"),
898        );
899
900        // Simulate the same component encountered multiple times
901        // (e.g., <my-button> used in todo-app, todo-item, etc.)
902        plugin
903            .register_component_template("my-button", &comp, &comp.html_content)
904            .unwrap();
905        plugin
906            .register_component_template("my-button", &comp, &comp.html_content)
907            .unwrap();
908        plugin
909            .register_component_template("my-button", &comp, &comp.html_content)
910            .unwrap();
911
912        let templates = plugin.take_component_templates();
913        assert_eq!(
914            templates.len(),
915            1,
916            "Expected exactly 1 template for my-button, got {}",
917            templates.len()
918        );
919        assert_eq!(templates[0].0, "my-button");
920    }
921
922    #[test]
923    fn component_template_deduplicates_mixed_components() {
924        let mut plugin = FastParserPlugin::new();
925        let btn = make_component("my-button", "<button><slot></slot></button>", None);
926        let card = make_component("my-card", "<div><slot></slot></div>", Some(".card{}"));
927
928        // my-button used in two different parent templates, my-card used once
929        plugin
930            .register_component_template("my-button", &btn, &btn.html_content)
931            .unwrap();
932        plugin
933            .register_component_template("my-card", &card, &card.html_content)
934            .unwrap();
935        plugin
936            .register_component_template("my-button", &btn, &btn.html_content)
937            .unwrap();
938
939        let templates = plugin.take_component_templates();
940        assert_eq!(templates.len(), 2);
941
942        let button_count = templates.iter().filter(|(n, _)| n == "my-button").count();
943        let card_count = templates.iter().filter(|(n, _)| n == "my-card").count();
944        assert_eq!(button_count, 1);
945        assert_eq!(card_count, 1);
946    }
947
948    // --- FAST syntax conversion ---
949
950    #[test]
951    fn convert_if_to_f_when() {
952        let input = r#"<if condition="isComplete"><span>Done</span></if>"#;
953        let output = convert_btr_to_fast(input);
954        assert_eq!(
955            output,
956            r#"<f-when value="{{isComplete}}"><span>Done</span></f-when>"#
957        );
958    }
959
960    #[test]
961    fn convert_if_with_greater_than() {
962        let input = r#"<if condition="vara > 2">Over two</if>"#;
963        let output = convert_btr_to_fast(input);
964        assert_eq!(output, r#"<f-when value="{{vara > 2}}">Over two</f-when>"#);
965    }
966
967    #[test]
968    fn convert_if_with_greater_than_or_equal() {
969        let input = r#"<if condition="vara >= 2">Two and over</if>"#;
970        let output = convert_btr_to_fast(input);
971        assert_eq!(
972            output,
973            r#"<f-when value="{{vara >= 2}}">Two and over</f-when>"#
974        );
975    }
976
977    #[test]
978    fn convert_if_with_less_than() {
979        let input = r#"<if condition="x < 10">small</if>"#;
980        let output = convert_btr_to_fast(input);
981        assert_eq!(output, r#"<f-when value="{{x < 10}}">small</f-when>"#);
982    }
983
984    #[test]
985    fn convert_for_to_f_repeat() {
986        let input = r#"<for each="tag in tags"><span>{{tag}}</span></for>"#;
987        let output = convert_btr_to_fast(input);
988        assert_eq!(
989            output,
990            r#"<f-repeat value="{{tag in tags}}"><span>{{tag}}</span></f-repeat>"#
991        );
992    }
993
994    #[test]
995    fn convert_nested_if_and_for() {
996        let input = r#"<if condition="show"><for each="x in items"><p>{{x}}</p></for></if>"#;
997        let output = convert_btr_to_fast(input);
998        assert_eq!(
999            output,
1000            r#"<f-when value="{{show}}"><f-repeat value="{{x in items}}"><p>{{x}}</p></f-repeat></f-when>"#
1001        );
1002    }
1003
1004    #[test]
1005    fn convert_complex_attr_double_braces() {
1006        let input = r#"<div :data="{{config}}"></div>"#;
1007        let output = convert_btr_to_fast(input);
1008        assert_eq!(output, r#"<div :data="{config}"></div>"#);
1009    }
1010
1011    #[test]
1012    fn convert_multiple_complex_attrs() {
1013        let input = r#"<div :title="{{name}}" :data-id="{{id}}"></div>"#;
1014        let output = convert_btr_to_fast(input);
1015        assert_eq!(output, r#"<div :title="{name}" :data-id="{id}"></div>"#);
1016    }
1017
1018    #[test]
1019    fn no_conversion_for_text_double_braces() {
1020        // Double braces in text content (not in :attr) should stay as-is
1021        let input = "<span>{{greeting}}</span>";
1022        let output = convert_btr_to_fast(input);
1023        assert_eq!(output, "<span>{{greeting}}</span>");
1024    }
1025
1026    #[test]
1027    fn no_conversion_for_regular_attr() {
1028        // Double braces in non-colon attributes should stay as-is
1029        let input = r#"<div title="{{name}}"></div>"#;
1030        let output = convert_btr_to_fast(input);
1031        assert_eq!(output, r#"<div title="{{name}}"></div>"#);
1032    }
1033
1034    #[test]
1035    fn component_template_with_btr_conversion() {
1036        let mut plugin = FastParserPlugin::new();
1037        let html = r#"<div><if condition="visible"><span>hi</span></if></div>"#;
1038        let comp = make_component("my-widget", html, None);
1039        plugin
1040            .register_component_template("my-widget", &comp, &comp.html_content)
1041            .unwrap();
1042
1043        let templates = plugin.take_component_templates();
1044        assert_eq!(templates.len(), 1);
1045        let (name, result) = &templates[0];
1046        assert_eq!(name, "my-widget");
1047
1048        assert!(result.contains("<f-when value=\"{{visible}}\">"));
1049        assert!(result.contains("</f-when>"));
1050        assert!(!result.contains("<if condition="));
1051        assert!(!result.contains("</if>"));
1052    }
1053
1054    #[test]
1055    fn passthrough_normal_html() {
1056        let input = r#"<div class="container"><p>Hello</p></div>"#;
1057        let output = convert_btr_to_fast(input);
1058        assert_eq!(output, input);
1059    }
1060
1061    #[test]
1062    fn outlet_kept_as_marker_in_f_template() {
1063        let input = r#"<div><outlet /></div>"#;
1064        let output = convert_btr_to_fast(input);
1065        assert_eq!(output, r#"<div><outlet></outlet></div>"#);
1066    }
1067
1068    #[test]
1069    fn outlet_marker_in_component_template() {
1070        let mut plugin = FastParserPlugin::new();
1071        let html =
1072            r#"<template shadowrootmode="open"><h1>Title</h1><main><outlet /></main></template>"#;
1073        let comp = make_component("my-shell", html, None);
1074        plugin
1075            .register_component_template("my-shell", &comp, &comp.html_content)
1076            .unwrap();
1077
1078        let templates = plugin.take_component_templates();
1079        let (_, result) = &templates[0];
1080        assert!(
1081            result.contains("<outlet></outlet>"),
1082            "outlet should be kept as marker in f-template: {result}"
1083        );
1084    }
1085
1086    #[test]
1087    fn strip_shadowrootmode_from_template() {
1088        let input = r#"<template shadowrootmode="open"><div>Hello</div></template>"#;
1089        let output = convert_btr_to_fast(input);
1090        assert_eq!(output, "<template><div>Hello</div></template>");
1091    }
1092
1093    #[test]
1094    fn strip_shadowrootmode_preserves_other_attrs() {
1095        let input =
1096            r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>Hi</div></template>"#;
1097        let output = convert_btr_to_fast(input);
1098        assert!(output.contains(r#"@click="{onClick(e)}""#));
1099        assert!(!output.contains("shadowrootmode"));
1100        assert!(output.starts_with("<template "));
1101    }
1102
1103    #[test]
1104    fn no_strip_when_no_shadowrootmode() {
1105        let input = r#"<template foo="bar"><div>Hi</div></template>"#;
1106        let output = convert_btr_to_fast(input);
1107        assert_eq!(output, input);
1108    }
1109
1110    #[test]
1111    fn component_template_strips_shadowrootmode_from_f_template() {
1112        let mut plugin = FastParserPlugin::new();
1113        let comp = make_component(
1114            "my-comp",
1115            r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>{{title}}</div></template>"#,
1116            Some("div { color: red; }"),
1117        );
1118        plugin
1119            .register_component_template("my-comp", &comp, &comp.html_content)
1120            .unwrap();
1121        let templates = plugin.take_component_templates();
1122        assert_eq!(templates.len(), 1);
1123        let (name, html) = &templates[0];
1124        assert_eq!(name, "my-comp");
1125        // f-template content should NOT have shadowrootmode
1126        assert!(!html.contains("shadowrootmode"));
1127        // But should keep @click (framework attr kept in f-template mode)
1128        assert!(html.contains("@click"));
1129        // Should have the converted content
1130        assert!(html.contains("{{title}}"));
1131    }
1132
1133    #[test]
1134    fn test_complex_attribute_normalization() {
1135        // All spacing variants should convert {{}} to {} in :attr values,
1136        // preserving any whitespace within the expression.
1137        let cases = [
1138            (
1139                r#"<div :foo="{{value}}"></div>"#,
1140                r#"<div :foo="{value}"></div>"#,
1141            ),
1142            (
1143                r#"<div :foo="{{ value}}"></div>"#,
1144                r#"<div :foo="{ value}"></div>"#,
1145            ),
1146            (
1147                r#"<div :foo="{{value }}"></div>"#,
1148                r#"<div :foo="{value }"></div>"#,
1149            ),
1150            (
1151                r#"<div :foo="{{ value }}"></div>"#,
1152                r#"<div :foo="{ value }"></div>"#,
1153            ),
1154        ];
1155        for (input, expected) in &cases {
1156            let output = convert_btr_to_fast(input);
1157            assert_eq!(&output, expected, "normalization failed for input: {input}");
1158        }
1159    }
1160
1161    #[test]
1162    fn test_non_js_component_inner_template() {
1163        let mut plugin = FastParserPlugin::new();
1164        // A JS component whose HTML contains a non-JS component using
1165        // declarative shadow DOM (template with shadowrootmode).
1166        let html = r#"<div><child-comp><template shadowrootmode="open"><p>inner</p></template></child-comp></div>"#;
1167        let comp = make_component("parent-comp", html, None);
1168        plugin
1169            .register_component_template("parent-comp", &comp, &comp.html_content)
1170            .unwrap();
1171
1172        let templates = plugin.take_component_templates();
1173        assert_eq!(templates.len(), 1);
1174        let (name, output) = &templates[0];
1175        assert_eq!(name, "parent-comp");
1176
1177        assert!(output.contains("<f-template name=\"parent-comp\">"));
1178        // shadowrootmode must be stripped from the inner non-JS component's template
1179        assert!(!output.contains("shadowrootmode"));
1180        // The inner template structure should be preserved (without shadowrootmode)
1181        assert!(output.contains("<child-comp><template><p>inner</p></template></child-comp>"));
1182    }
1183
1184    #[test]
1185    fn route_tags_converted_to_f_route() {
1186        let input = r#"<route path="/home" name="home" exact><span>Home</span></route>"#;
1187        let output = convert_btr_to_fast(input);
1188        assert!(output.starts_with("<webui-route"));
1189        assert!(output.contains(r#"path="/home""#));
1190        assert!(output.contains(r#"name="home""#));
1191        assert!(output.contains(" exact"));
1192        assert!(output.contains("style=\"display:none\""));
1193        assert!(output.contains("<span>Home</span>"));
1194        assert!(output.ends_with("</webui-route>"));
1195    }
1196
1197    #[test]
1198    fn f_route_tags_pass_through() {
1199        // <webui-route> tags emitted by the parser should pass through unchanged
1200        let input = r#"<webui-route path="/" name="dashboard" component="cb-page-dashboard" exact style="display:none"></webui-route>"#;
1201        let output = convert_btr_to_fast(input);
1202        assert_eq!(output, input);
1203    }
1204
1205    // --- End-to-end f-template regression tests ---
1206    // Verify that generated f-template blocks always use FAST syntax
1207    // (<f-when>/<f-repeat>) instead of webui syntax (<if>/<for>).
1208
1209    #[test]
1210    fn ftemplate_for_loop_converted_to_f_repeat() {
1211        let mut plugin = FastParserPlugin::new();
1212        let html = r#"<ul><for each="item in items"><li>{{item.name}}</li></for></ul>"#;
1213        let comp = make_component("my-list", html, None);
1214        plugin
1215            .register_component_template("my-list", &comp, &comp.html_content)
1216            .unwrap();
1217
1218        let templates = plugin.take_component_templates();
1219        let (_, result) = &templates[0];
1220
1221        assert!(
1222            result.contains("<f-repeat value=\"{{item in items}}\">"),
1223            "f-template should use <f-repeat>, got: {result}"
1224        );
1225        assert!(
1226            result.contains("</f-repeat>"),
1227            "f-template should close with </f-repeat>, got: {result}"
1228        );
1229        assert!(
1230            !result.contains("<for "),
1231            "f-template should NOT contain <for>, got: {result}"
1232        );
1233    }
1234
1235    #[test]
1236    fn ftemplate_shadow_dom_strips_shadowroot_and_converts_directives() {
1237        let mut plugin = FastParserPlugin::new();
1238        let html = r#"<template shadowrootmode="open"><div><if condition="visible">Shown</if><for each="x in list"><span>{{x}}</span></for></div></template>"#;
1239        let comp = make_component("my-shadow", html, None);
1240        plugin
1241            .register_component_template("my-shadow", &comp, &comp.html_content)
1242            .unwrap();
1243
1244        let templates = plugin.take_component_templates();
1245        let (_, result) = &templates[0];
1246
1247        assert!(
1248            result.contains("<f-when value=\"{{visible}}\">"),
1249            "Shadow f-template should contain <f-when>, got: {result}"
1250        );
1251        assert!(
1252            result.contains("<f-repeat value=\"{{x in list}}\">"),
1253            "Shadow f-template should contain <f-repeat>, got: {result}"
1254        );
1255        assert!(
1256            !result.contains("shadowrootmode"),
1257            "Shadow f-template should strip shadowrootmode, got: {result}"
1258        );
1259        assert!(
1260            !result.contains("<if "),
1261            "Shadow f-template should NOT contain <if>, got: {result}"
1262        );
1263        assert!(
1264            !result.contains("<for "),
1265            "Shadow f-template should NOT contain <for>, got: {result}"
1266        );
1267    }
1268
1269    #[test]
1270    fn ftemplate_nested_if_and_for_both_converted() {
1271        let mut plugin = FastParserPlugin::new();
1272        let html =
1273            r#"<div><if condition="show"><for each="x in items"><p>{{x}}</p></for></if></div>"#;
1274        let comp = make_component("my-nested", html, None);
1275        plugin
1276            .register_component_template("my-nested", &comp, &comp.html_content)
1277            .unwrap();
1278
1279        let templates = plugin.take_component_templates();
1280        let (_, result) = &templates[0];
1281
1282        assert!(
1283            result.contains("<f-when value=\"{{show}}\">"),
1284            "Nested f-template should contain <f-when>, got: {result}"
1285        );
1286        assert!(
1287            result.contains("<f-repeat value=\"{{x in items}}\">"),
1288            "Nested f-template should contain <f-repeat>, got: {result}"
1289        );
1290        assert!(
1291            !result.contains("<if "),
1292            "Nested f-template should NOT contain <if>, got: {result}"
1293        );
1294        assert!(
1295            !result.contains("<for "),
1296            "Nested f-template should NOT contain <for>, got: {result}"
1297        );
1298    }
1299
1300    #[test]
1301    fn find_tag_close_skips_quoted_gt() {
1302        assert_eq!(
1303            find_tag_close(r#"<if condition="a > b">"#),
1304            Some(21) // the `>` after the closing quote
1305        );
1306        assert_eq!(find_tag_close(r#"<if condition="a >= b">"#), Some(22));
1307        assert_eq!(find_tag_close("<br>"), Some(3));
1308        assert_eq!(find_tag_close("<br/>"), Some(4));
1309    }
1310}