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::tag_scan::find_tag_close;
13use crate::{CssStrategy, Result};
14use webui_protocol::FastElementData;
15
16/// Information about a tracked component for `<f-template>` generation.
17struct TrackedComponent {
18    tag_name: String,
19    html_content: String,
20    css_content: Option<String>,
21}
22
23/// FAST 3 parser plugin used by `fast-v3`.
24///
25/// Implements the `ParserPlugin` trait for FAST 3 applications:
26/// - Filters FAST-specific runtime binding attributes (`@click`, `f-ref`, etc.)
27/// - Tracks components encountered during parsing
28/// - Returns `<f-template>` artifacts with converted FAST syntax after parsing
29/// - Emits binding attribute counts as `Plugin` protocol fragment data
30pub struct FastV3ParserPlugin {
31    /// Components tracked during parsing, in discovery order.
32    components: Vec<TrackedComponent>,
33    /// CSS delivery strategy for f-templates.
34    css_strategy: CssStrategy,
35}
36
37impl FastV3ParserPlugin {
38    /// Create a new FAST 3 parser plugin.
39    #[must_use]
40    pub fn new() -> Self {
41        Self {
42            components: Vec::new(),
43            css_strategy: CssStrategy::Link,
44        }
45    }
46
47    /// Set the CSS delivery strategy for generated f-templates.
48    pub fn set_css_strategy(&mut self, strategy: CssStrategy) {
49        self.css_strategy = strategy;
50    }
51
52    /// Take the individual component f-template strings, keyed by tag name.
53    ///
54    /// Each value is a complete `<f-template name="tag-name">...</f-template>` string
55    /// ready to be appended to a document. This is used by the JSON partial render
56    /// endpoint to send only the templates the client needs.
57    #[must_use]
58    pub fn take_component_templates(&self) -> Vec<(String, String)> {
59        self.components
60            .iter()
61            .map(|comp| {
62                let tmpl = generate_f_template(
63                    &comp.tag_name,
64                    &comp.html_content,
65                    comp.css_content.as_deref(),
66                    self.css_strategy,
67                );
68                (comp.tag_name.clone(), tmpl)
69            })
70            .collect()
71    }
72}
73
74impl Default for FastV3ParserPlugin {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80impl ParserPlugin for FastV3ParserPlugin {
81    fn register_component_template(
82        &mut self,
83        tag_name: &str,
84        component: &Component,
85        _processed_template: &str,
86    ) -> Result<()> {
87        // Only track each component once (avoids duplicate <f-template> blocks
88        // when a component is used in multiple parent templates)
89        if self.components.iter().any(|c| c.tag_name == tag_name) {
90            return Ok(());
91        }
92        self.components.push(TrackedComponent {
93            tag_name: tag_name.to_string(),
94            html_content: component.html_content.clone(),
95            css_content: component.css_content.clone(),
96        });
97        Ok(())
98    }
99
100    fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
101        if attr_name.starts_with('@')
102            || attr_name == "f-ref"
103            || attr_name == "f-slotted"
104            || attr_name == "f-children"
105        {
106            AttributeAction::SkipAndCountBinding
107        } else {
108            AttributeAction::Keep
109        }
110    }
111
112    fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
113        if binding_attribute_count > 0 {
114            Some(
115                FastElementData {
116                    binding_count: binding_attribute_count,
117                }
118                .encode()
119                .to_vec(),
120            )
121        } else {
122            None
123        }
124    }
125
126    fn into_artifacts(self: Box<Self>) -> ParserPluginArtifacts {
127        ParserPluginArtifacts::ComponentTemplates(self.take_component_templates())
128    }
129}
130
131/// Generate a single f-template HTML string from component data.
132///
133/// Used by the server to generate templates on demand for route components
134/// that weren't encountered during initial parsing.
135pub fn generate_f_template(
136    tag_name: &str,
137    html_content: &str,
138    css_content: Option<&str>,
139    css_strategy: CssStrategy,
140) -> String {
141    let mut output = String::with_capacity(256);
142    output.push_str("<f-template name=\"");
143    output.push_str(tag_name);
144    output.push_str("\">\n");
145
146    let converted = convert_btr_to_fast(html_content);
147    let trimmed = minify_inter_tag_whitespace(converted.trim());
148
149    // Build the CSS injection string based on the configured strategy
150    let css_injection = match css_strategy {
151        CssStrategy::Link => css_content.map(|_| {
152            let mut s = String::with_capacity(40 + tag_name.len());
153            s.push_str("<link rel=\"stylesheet\" href=\"");
154            s.push_str(tag_name);
155            s.push_str(".css\">");
156            s
157        }),
158        CssStrategy::Style => css_content.map(|css| {
159            let mut s = String::with_capacity(15 + css.len());
160            s.push_str("<style>");
161            s.push_str(css.trim());
162            s.push_str("</style>");
163            s
164        }),
165        CssStrategy::Module => None,
166    };
167
168    if trimmed.starts_with("<template") {
169        if let Some(close_pos) = trimmed.find('>') {
170            output.push_str(&trimmed[..close_pos]);
171            if css_strategy == CssStrategy::Module && css_content.is_some() {
172                output.push_str(" shadowrootadoptedstylesheets=\"");
173                output.push_str(tag_name);
174                output.push('"');
175            }
176            output.push('>');
177            if let Some(ref injection) = css_injection {
178                output.push_str(injection);
179            }
180            output.push_str(&trimmed[close_pos + 1..]);
181        } else {
182            output.push_str(&trimmed);
183        }
184    } else {
185        output.push_str("<template");
186        if css_strategy == CssStrategy::Module && css_content.is_some() {
187            output.push_str(" shadowrootadoptedstylesheets=\"");
188            output.push_str(tag_name);
189            output.push('"');
190        }
191        output.push('>');
192        if let Some(ref injection) = css_injection {
193            output.push_str(injection);
194        }
195        output.push_str(&trimmed);
196        output.push_str("</template>");
197    }
198
199    output.push_str("\n</f-template>\n");
200    output
201}
202
203/// Convert WebUI Framework template syntax to FAST syntax in HTML content.
204///
205/// Performs the following transformations without regex:
206/// - `<if condition="EXPR">` → `<f-when value="{{EXPR}}">`
207/// - `</if>` → `</f-when>`
208/// - `<for each="EXPR">` → `<f-repeat value="{{EXPR}}">`
209/// - `</for>` → `</f-repeat>`
210/// - `{{expr}}` inside `:attr` complex attribute values → `{expr}`
211/// - Strips `shadowrootmode` attributes from `<template>` tags
212///   (in f-template context, shadowrootmode must be removed to prevent
213///   the browser from auto-activating it as a declarative shadow root)
214fn convert_btr_to_fast(input: &str) -> String {
215    let mut result = String::with_capacity(input.len());
216    let bytes = input.as_bytes();
217    let len = bytes.len();
218    let mut i = 0;
219
220    while i < len {
221        if bytes[i] == b'<' {
222            if let Some(consumed) = try_convert_tag(input, i, &mut result) {
223                i += consumed;
224                continue;
225            }
226        }
227
228        // Check for {{expr}} inside :attr values — handled at attribute level
229        // in try_convert_tag. Outside of tags, double-braces are left as-is
230        // (they're text content interpolation for FAST, kept unchanged).
231        i = push_char_at(input, i, &mut result);
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/// Extract the value of a named attribute from a tag string.
407/// Looks for `name="value"` and returns `value`.
408fn extract_attribute_value<'a>(tag: &'a str, attr_name: &str) -> Option<&'a str> {
409    // Search for the attribute name followed by '='
410    let mut search_from = 0;
411    loop {
412        let attr_pos = tag[search_from..].find(attr_name)?;
413        let abs_pos = search_from + attr_pos;
414
415        // Verify it's preceded by whitespace (not part of another attribute name)
416        if abs_pos > 0 {
417            let prev = tag.as_bytes()[abs_pos - 1];
418            if prev != b' ' && prev != b'\t' && prev != b'\n' && prev != b'\r' {
419                search_from = abs_pos + attr_name.len();
420                continue;
421            }
422        }
423
424        let after_name = &tag[abs_pos + attr_name.len()..];
425        if !after_name.starts_with('=') {
426            search_from = abs_pos + attr_name.len();
427            continue;
428        }
429
430        let after_eq = &after_name[1..];
431        if after_eq.starts_with('"') {
432            let value_start = abs_pos + attr_name.len() + 2; // skip `="`
433            let end_quote = tag[value_start..].find('"')?;
434            return Some(&tag[value_start..value_start + end_quote]);
435        }
436
437        return None;
438    }
439}
440
441/// Convert tags that contain `:attr="{{expr}}"` complex attribute bindings.
442/// Replaces `{{expr}}` with `{expr}` in attribute values on `:` prefixed attributes.
443/// Returns bytes consumed on success, or `None` if no conversion needed.
444fn convert_complex_attrs(tag_str: &str, result: &mut String) -> Option<usize> {
445    // Find the closing '>' outside of quoted attribute values
446    let close = find_tag_close(tag_str)?;
447    let tag_content = &tag_str[..=close];
448
449    // Quick check: does this tag have both a colon-prefixed attribute and `{{`?
450    if !tag_content.contains(":") || !tag_content.contains("{{") {
451        return None;
452    }
453
454    // Process the tag character by character, converting {{}} to {} in :attr values
455    let mut converted = String::with_capacity(tag_content.len());
456    let tag_bytes = tag_content.as_bytes();
457    let tag_len = tag_bytes.len();
458    let mut j = 0;
459    let mut in_colon_attr_value = false;
460
461    while j < tag_len {
462        if !in_colon_attr_value {
463            // Look for :attrname="
464            if tag_bytes[j] == b':' && j > 0 && is_whitespace(tag_bytes[j - 1]) {
465                // Found a colon-prefixed attribute, scan to the ="
466                let attr_start = j;
467                j += 1;
468                // Skip attribute name
469                while j < tag_len && tag_bytes[j] != b'=' && !is_whitespace(tag_bytes[j]) {
470                    j += 1;
471                }
472                if j < tag_len && tag_bytes[j] == b'=' {
473                    j += 1;
474                    if j < tag_len && tag_bytes[j] == b'"' {
475                        // Push everything up to and including the opening quote
476                        converted.push_str(&tag_content[attr_start..=j]);
477                        j += 1;
478                        in_colon_attr_value = true;
479                        continue;
480                    }
481                }
482                // Not a valid :attr="..." pattern, output as-is
483                converted.push_str(&tag_content[attr_start..j]);
484                continue;
485            }
486            j = push_char_at(tag_content, j, &mut converted);
487        } else {
488            // Inside a :attr value — convert {{expr}} to {expr}
489            if tag_bytes[j] == b'"' {
490                // End of attribute value
491                converted.push('"');
492                j += 1;
493                in_colon_attr_value = false;
494            } else if j + 1 < tag_len && tag_bytes[j] == b'{' && tag_bytes[j + 1] == b'{' {
495                // Found {{ — find matching }}
496                let expr_start = j + 2;
497                if let Some(end_offset) = tag_content[expr_start..].find("}}") {
498                    let expr = &tag_content[expr_start..expr_start + end_offset];
499                    converted.push('{');
500                    converted.push_str(expr);
501                    converted.push('}');
502                    j = expr_start + end_offset + 2;
503                } else {
504                    // No matching }}, output as-is
505                    converted.push('{');
506                    converted.push('{');
507                    j += 2;
508                }
509            } else {
510                j = push_char_at(tag_content, j, &mut converted);
511            }
512        }
513    }
514
515    result.push_str(&converted);
516    Some(close + 1)
517}
518
519/// Push the single UTF-8 character starting at `pos` in `input` into `out`,
520/// returning the byte index immediately after it.
521fn push_char_at(input: &str, pos: usize, out: &mut String) -> usize {
522    let bytes = input.as_bytes();
523    if bytes[pos].is_ascii() {
524        out.push(bytes[pos] as char);
525        pos + 1
526    } else {
527        let mut end = pos + 1;
528        while end < bytes.len() && !input.is_char_boundary(end) {
529            end += 1;
530        }
531        out.push_str(&input[pos..end]);
532        end
533    }
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            i = push_char_at(input, i, &mut result);
567        }
568    }
569
570    result
571}
572
573/// Strip `shadowrootmode` attribute from a `<template ...>` opening tag.
574/// Returns `Some(bytes_consumed)` if a `<template` tag was found and processed.
575fn strip_shadowrootmode(tag_str: &str, result: &mut String) -> Option<usize> {
576    // Find the closing '>' outside of quoted attribute values
577    let close = find_tag_close(tag_str)?;
578    let tag_content = &tag_str[..=close];
579
580    // Only process if this tag contains shadowrootmode
581    if !tag_content.contains("shadowrootmode") {
582        return None;
583    }
584
585    // Rebuild the tag without the shadowrootmode attribute
586    result.push_str("<template");
587    let attr_start = "<template".len();
588    let inner = &tag_content[attr_start..close];
589
590    // Scan through the attributes, skipping shadowrootmode
591    let inner_bytes = inner.as_bytes();
592    let inner_len = inner_bytes.len();
593    let mut j = 0;
594    while j < inner_len {
595        // Skip whitespace
596        if is_whitespace(inner_bytes[j]) {
597            j += 1;
598            continue;
599        }
600
601        // Find the end of this attribute (name="value" or just name)
602        let attr_begin = j;
603        // Find '=' or whitespace or end
604        while j < inner_len && inner_bytes[j] != b'=' && !is_whitespace(inner_bytes[j]) {
605            j += 1;
606        }
607        let attr_name = &inner[attr_begin..j];
608
609        // If there's a '=', consume the value
610        let mut attr_end = j;
611        if j < inner_len && inner_bytes[j] == b'=' {
612            j += 1; // skip '='
613            if j < inner_len && inner_bytes[j] == b'"' {
614                j += 1; // skip opening quote
615                while j < inner_len && inner_bytes[j] != b'"' {
616                    j += 1;
617                }
618                if j < inner_len {
619                    j += 1; // skip closing quote
620                }
621            } else {
622                // Unquoted value
623                while j < inner_len && !is_whitespace(inner_bytes[j]) {
624                    j += 1;
625                }
626            }
627            attr_end = j;
628        }
629
630        // Skip the shadowrootmode attribute entirely
631        if attr_name == "shadowrootmode" {
632            continue;
633        }
634
635        // Keep this attribute
636        result.push(' ');
637        result.push_str(&inner[attr_begin..attr_end]);
638    }
639
640    if tag_content.ends_with("/>") {
641        result.push_str("/>");
642    } else {
643        result.push('>');
644    }
645
646    Some(close + 1)
647}
648
649#[cfg(test)]
650mod tests {
651    #![allow(clippy::disallowed_methods)]
652
653    use super::*;
654    use std::path::PathBuf;
655
656    fn make_component(tag: &str, html: &str, css: Option<&str>) -> Component {
657        Component {
658            tag_name: tag.to_string(),
659            html_content: html.to_string(),
660            css_content: css.map(ToString::to_string),
661            css_tokens: Vec::new(),
662            source_path: PathBuf::from("/test"),
663            class_name: None,
664        }
665    }
666
667    // --- classify_attribute ---
668
669    #[test]
670    fn skip_event_binding() {
671        let mut plugin = FastV3ParserPlugin::new();
672        assert_eq!(
673            plugin.classify_attribute("@click"),
674            AttributeAction::SkipAndCountBinding
675        );
676        assert_eq!(
677            plugin.classify_attribute("@input"),
678            AttributeAction::SkipAndCountBinding
679        );
680        assert_eq!(
681            plugin.classify_attribute("@custom-event"),
682            AttributeAction::SkipAndCountBinding
683        );
684    }
685
686    #[test]
687    fn skip_f_ref() {
688        let mut plugin = FastV3ParserPlugin::new();
689        assert_eq!(
690            plugin.classify_attribute("f-ref"),
691            AttributeAction::SkipAndCountBinding
692        );
693    }
694
695    #[test]
696    fn skip_f_slotted() {
697        let mut plugin = FastV3ParserPlugin::new();
698        assert_eq!(
699            plugin.classify_attribute("f-slotted"),
700            AttributeAction::SkipAndCountBinding
701        );
702    }
703
704    #[test]
705    fn skip_f_children() {
706        let mut plugin = FastV3ParserPlugin::new();
707        assert_eq!(
708            plugin.classify_attribute("f-children"),
709            AttributeAction::SkipAndCountBinding
710        );
711    }
712
713    #[test]
714    fn do_not_skip_normal_attributes() {
715        let mut plugin = FastV3ParserPlugin::new();
716        assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
717        assert_eq!(plugin.classify_attribute("id"), AttributeAction::Keep);
718        assert_eq!(
719            plugin.classify_attribute("data-value"),
720            AttributeAction::Keep
721        );
722        assert_eq!(plugin.classify_attribute(":title"), AttributeAction::Keep);
723        assert_eq!(plugin.classify_attribute("f-other"), AttributeAction::Keep);
724    }
725
726    // --- finish_element ---
727
728    #[test]
729    fn element_parsed_zero_count_returns_none() {
730        let mut plugin = FastV3ParserPlugin::new();
731        assert!(plugin.finish_element(0).is_none());
732    }
733
734    #[test]
735    fn element_parsed_nonzero_count_returns_le_bytes() {
736        let mut plugin = FastV3ParserPlugin::new();
737        let data = plugin.finish_element(3);
738        assert!(data.is_some());
739        let bytes = data.as_deref().unwrap_or_default();
740        assert_eq!(bytes, &3u32.to_le_bytes());
741    }
742
743    #[test]
744    fn element_parsed_large_count() {
745        let mut plugin = FastV3ParserPlugin::new();
746        let data = plugin.finish_element(256);
747        assert!(data.is_some());
748        let bytes = data.as_deref().unwrap_or_default();
749        assert_eq!(bytes, &256u32.to_le_bytes());
750    }
751
752    #[test]
753    fn component_template_simple_component() {
754        let mut plugin = FastV3ParserPlugin::new();
755        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
756        plugin
757            .register_component_template("my-comp", &comp, &comp.html_content)
758            .unwrap();
759
760        let templates = plugin.take_component_templates();
761        assert_eq!(templates.len(), 1);
762        let (name, html) = &templates[0];
763        assert_eq!(name, "my-comp");
764        assert!(html.contains("<f-template name=\"my-comp\">"));
765        assert!(html.contains("</f-template>"));
766        assert!(html.contains("<template>"));
767        assert!(html.contains("</template>"));
768        assert!(html.contains("<link rel=\"stylesheet\" href=\"my-comp.css\">"));
769        assert!(html.contains("<div>hello</div>"));
770    }
771
772    #[test]
773    fn component_template_without_css() {
774        let mut plugin = FastV3ParserPlugin::new();
775        let comp = make_component("no-css", "<span>text</span>", None);
776        plugin
777            .register_component_template("no-css", &comp, &comp.html_content)
778            .unwrap();
779
780        let templates = plugin.take_component_templates();
781        assert_eq!(templates.len(), 1);
782        let (name, html) = &templates[0];
783        assert_eq!(name, "no-css");
784        assert!(html.contains("<f-template name=\"no-css\">"));
785        assert!(!html.contains("<link rel=\"stylesheet\""));
786        assert!(html.contains("<span>text</span>"));
787    }
788
789    #[test]
790    fn component_template_css_strategy_style() {
791        let mut plugin = FastV3ParserPlugin::new();
792        plugin.set_css_strategy(crate::CssStrategy::Style);
793        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
794        plugin
795            .register_component_template("my-comp", &comp, &comp.html_content)
796            .unwrap();
797
798        let templates = plugin.take_component_templates();
799        assert_eq!(templates.len(), 1);
800        let (_, html) = &templates[0];
801        assert!(
802            html.contains("<style>div { color: red; }</style>"),
803            "Style strategy should inline CSS, got: {html}"
804        );
805        assert!(
806            !html.contains("<link"),
807            "Style strategy should not emit <link> tags"
808        );
809    }
810
811    #[test]
812    fn component_template_css_strategy_module() {
813        let mut plugin = FastV3ParserPlugin::new();
814        plugin.set_css_strategy(crate::CssStrategy::Module);
815        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
816        plugin
817            .register_component_template("my-comp", &comp, &comp.html_content)
818            .unwrap();
819
820        let templates = plugin.take_component_templates();
821        assert_eq!(templates.len(), 1);
822        let (_, html) = &templates[0];
823
824        // f-template should NOT contain the CSS module — that is added
825        // by the handler (SSR) or prepend_css_module (partials).
826        assert!(
827            !html.contains(r#"<style type="module""#),
828            "CSS module should not be baked into f-template: {html}"
829        );
830        // shadowrootadoptedstylesheets on the inner template
831        assert!(
832            html.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
833            "Module strategy should add shadowrootadoptedstylesheets, got: {html}"
834        );
835        // No inline <style> or <link> inside the template
836        assert!(
837            !html.contains("<style>div"),
838            "Module strategy should not have inline <style> inside template"
839        );
840        assert!(
841            !html.contains("<link"),
842            "Module strategy should not emit <link> tags"
843        );
844    }
845
846    #[test]
847    fn component_template_css_strategy_module_no_css() {
848        let mut plugin = FastV3ParserPlugin::new();
849        plugin.set_css_strategy(crate::CssStrategy::Module);
850        let comp = make_component("my-comp", "<div>hello</div>", None);
851        plugin
852            .register_component_template("my-comp", &comp, &comp.html_content)
853            .unwrap();
854
855        let templates = plugin.take_component_templates();
856        assert_eq!(templates.len(), 1);
857        let (_, html) = &templates[0];
858        assert!(
859            !html.contains("shadowrootadoptedstylesheets"),
860            "No CSS = no shadowrootadoptedstylesheets, got: {html}"
861        );
862        assert!(
863            !html.contains("<style"),
864            "No CSS = no style tag, got: {html}"
865        );
866    }
867
868    #[test]
869    fn component_template_multiple_components() {
870        let mut plugin = FastV3ParserPlugin::new();
871        let comp1 = make_component("comp-a", "<div>A</div>", None);
872        let comp2 = make_component("comp-b", "<div>B</div>", Some("b { }"));
873        plugin
874            .register_component_template("comp-a", &comp1, &comp1.html_content)
875            .unwrap();
876        plugin
877            .register_component_template("comp-b", &comp2, &comp2.html_content)
878            .unwrap();
879
880        let templates = plugin.take_component_templates();
881        assert_eq!(templates.len(), 2);
882
883        let names: Vec<&str> = templates.iter().map(|(n, _)| n.as_str()).collect();
884        assert!(names.contains(&"comp-a"));
885        assert!(names.contains(&"comp-b"));
886
887        for (_, html) in &templates {
888            assert!(html.contains("<f-template name="));
889        }
890    }
891
892    #[test]
893    fn component_template_deduplicates_same_component() {
894        let mut plugin = FastV3ParserPlugin::new();
895        let comp = make_component(
896            "my-button",
897            "<button><slot></slot></button>",
898            Some(".btn{}"),
899        );
900
901        // Simulate the same component encountered multiple times
902        // (e.g., <my-button> used in todo-app, todo-item, etc.)
903        plugin
904            .register_component_template("my-button", &comp, &comp.html_content)
905            .unwrap();
906        plugin
907            .register_component_template("my-button", &comp, &comp.html_content)
908            .unwrap();
909        plugin
910            .register_component_template("my-button", &comp, &comp.html_content)
911            .unwrap();
912
913        let templates = plugin.take_component_templates();
914        assert_eq!(
915            templates.len(),
916            1,
917            "Expected exactly 1 template for my-button, got {}",
918            templates.len()
919        );
920        assert_eq!(templates[0].0, "my-button");
921    }
922
923    #[test]
924    fn component_template_deduplicates_mixed_components() {
925        let mut plugin = FastV3ParserPlugin::new();
926        let btn = make_component("my-button", "<button><slot></slot></button>", None);
927        let card = make_component("my-card", "<div><slot></slot></div>", Some(".card{}"));
928
929        // my-button used in two different parent templates, my-card used once
930        plugin
931            .register_component_template("my-button", &btn, &btn.html_content)
932            .unwrap();
933        plugin
934            .register_component_template("my-card", &card, &card.html_content)
935            .unwrap();
936        plugin
937            .register_component_template("my-button", &btn, &btn.html_content)
938            .unwrap();
939
940        let templates = plugin.take_component_templates();
941        assert_eq!(templates.len(), 2);
942
943        let button_count = templates.iter().filter(|(n, _)| n == "my-button").count();
944        let card_count = templates.iter().filter(|(n, _)| n == "my-card").count();
945        assert_eq!(button_count, 1);
946        assert_eq!(card_count, 1);
947    }
948
949    // --- FAST syntax conversion ---
950
951    #[test]
952    fn convert_if_to_f_when() {
953        let input = r#"<if condition="isComplete"><span>Done</span></if>"#;
954        let output = convert_btr_to_fast(input);
955        assert_eq!(
956            output,
957            r#"<f-when value="{{isComplete}}"><span>Done</span></f-when>"#
958        );
959    }
960
961    #[test]
962    fn convert_if_with_greater_than() {
963        let input = r#"<if condition="vara > 2">Over two</if>"#;
964        let output = convert_btr_to_fast(input);
965        assert_eq!(output, r#"<f-when value="{{vara > 2}}">Over two</f-when>"#);
966    }
967
968    #[test]
969    fn convert_if_with_greater_than_or_equal() {
970        let input = r#"<if condition="vara >= 2">Two and over</if>"#;
971        let output = convert_btr_to_fast(input);
972        assert_eq!(
973            output,
974            r#"<f-when value="{{vara >= 2}}">Two and over</f-when>"#
975        );
976    }
977
978    #[test]
979    fn convert_if_with_less_than() {
980        let input = r#"<if condition="x < 10">small</if>"#;
981        let output = convert_btr_to_fast(input);
982        assert_eq!(output, r#"<f-when value="{{x < 10}}">small</f-when>"#);
983    }
984
985    #[test]
986    fn convert_for_to_f_repeat() {
987        let input = r#"<for each="tag in tags"><span>{{tag}}</span></for>"#;
988        let output = convert_btr_to_fast(input);
989        assert_eq!(
990            output,
991            r#"<f-repeat value="{{tag in tags}}"><span>{{tag}}</span></f-repeat>"#
992        );
993    }
994
995    #[test]
996    fn convert_nested_if_and_for() {
997        let input = r#"<if condition="show"><for each="x in items"><p>{{x}}</p></for></if>"#;
998        let output = convert_btr_to_fast(input);
999        assert_eq!(
1000            output,
1001            r#"<f-when value="{{show}}"><f-repeat value="{{x in items}}"><p>{{x}}</p></f-repeat></f-when>"#
1002        );
1003    }
1004
1005    #[test]
1006    fn convert_complex_attr_double_braces() {
1007        let input = r#"<div :data="{{config}}"></div>"#;
1008        let output = convert_btr_to_fast(input);
1009        assert_eq!(output, r#"<div :data="{config}"></div>"#);
1010    }
1011
1012    #[test]
1013    fn convert_multiple_complex_attrs() {
1014        let input = r#"<div :title="{{name}}" :data-id="{{id}}"></div>"#;
1015        let output = convert_btr_to_fast(input);
1016        assert_eq!(output, r#"<div :title="{name}" :data-id="{id}"></div>"#);
1017    }
1018
1019    #[test]
1020    fn no_conversion_for_text_double_braces() {
1021        // Double braces in text content (not in :attr) should stay as-is
1022        let input = "<span>{{greeting}}</span>";
1023        let output = convert_btr_to_fast(input);
1024        assert_eq!(output, "<span>{{greeting}}</span>");
1025    }
1026
1027    #[test]
1028    fn no_conversion_for_regular_attr() {
1029        // Double braces in non-colon attributes should stay as-is
1030        let input = r#"<div title="{{name}}"></div>"#;
1031        let output = convert_btr_to_fast(input);
1032        assert_eq!(output, r#"<div title="{{name}}"></div>"#);
1033    }
1034
1035    #[test]
1036    fn component_template_with_btr_conversion() {
1037        let mut plugin = FastV3ParserPlugin::new();
1038        let html = r#"<div><if condition="visible"><span>hi</span></if></div>"#;
1039        let comp = make_component("my-widget", html, None);
1040        plugin
1041            .register_component_template("my-widget", &comp, &comp.html_content)
1042            .unwrap();
1043
1044        let templates = plugin.take_component_templates();
1045        assert_eq!(templates.len(), 1);
1046        let (name, result) = &templates[0];
1047        assert_eq!(name, "my-widget");
1048
1049        assert!(result.contains("<f-when value=\"{{visible}}\">"));
1050        assert!(result.contains("</f-when>"));
1051        assert!(!result.contains("<if condition="));
1052        assert!(!result.contains("</if>"));
1053    }
1054
1055    #[test]
1056    fn passthrough_normal_html() {
1057        let input = r#"<div class="container"><p>Hello</p></div>"#;
1058        let output = convert_btr_to_fast(input);
1059        assert_eq!(output, input);
1060    }
1061
1062    #[test]
1063    fn outlet_kept_as_marker_in_f_template() {
1064        let input = r#"<div><outlet /></div>"#;
1065        let output = convert_btr_to_fast(input);
1066        assert_eq!(output, r#"<div><outlet></outlet></div>"#);
1067    }
1068
1069    #[test]
1070    fn outlet_marker_in_component_template() {
1071        let mut plugin = FastV3ParserPlugin::new();
1072        let html =
1073            r#"<template shadowrootmode="open"><h1>Title</h1><main><outlet /></main></template>"#;
1074        let comp = make_component("my-shell", html, None);
1075        plugin
1076            .register_component_template("my-shell", &comp, &comp.html_content)
1077            .unwrap();
1078
1079        let templates = plugin.take_component_templates();
1080        let (_, result) = &templates[0];
1081        assert!(
1082            result.contains("<outlet></outlet>"),
1083            "outlet should be kept as marker in f-template: {result}"
1084        );
1085    }
1086
1087    #[test]
1088    fn strip_shadowrootmode_from_template() {
1089        let input = r#"<template shadowrootmode="open"><div>Hello</div></template>"#;
1090        let output = convert_btr_to_fast(input);
1091        assert_eq!(output, "<template><div>Hello</div></template>");
1092    }
1093
1094    #[test]
1095    fn strip_shadowrootmode_preserves_other_attrs() {
1096        let input =
1097            r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>Hi</div></template>"#;
1098        let output = convert_btr_to_fast(input);
1099        assert!(output.contains(r#"@click="{onClick(e)}""#));
1100        assert!(!output.contains("shadowrootmode"));
1101        assert!(output.starts_with("<template "));
1102    }
1103
1104    #[test]
1105    fn no_strip_when_no_shadowrootmode() {
1106        let input = r#"<template foo="bar"><div>Hi</div></template>"#;
1107        let output = convert_btr_to_fast(input);
1108        assert_eq!(output, input);
1109    }
1110
1111    #[test]
1112    fn component_template_strips_shadowrootmode_from_f_template() {
1113        let mut plugin = FastV3ParserPlugin::new();
1114        let comp = make_component(
1115            "my-comp",
1116            r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>{{title}}</div></template>"#,
1117            Some("div { color: red; }"),
1118        );
1119        plugin
1120            .register_component_template("my-comp", &comp, &comp.html_content)
1121            .unwrap();
1122        let templates = plugin.take_component_templates();
1123        assert_eq!(templates.len(), 1);
1124        let (name, html) = &templates[0];
1125        assert_eq!(name, "my-comp");
1126        // f-template content should NOT have shadowrootmode
1127        assert!(!html.contains("shadowrootmode"));
1128        // But should keep @click (framework attr kept in f-template mode)
1129        assert!(html.contains("@click"));
1130        // Should have the converted content
1131        assert!(html.contains("{{title}}"));
1132    }
1133
1134    #[test]
1135    fn test_complex_attribute_normalization() {
1136        // All spacing variants should convert {{}} to {} in :attr values,
1137        // preserving any whitespace within the expression.
1138        let cases = [
1139            (
1140                r#"<div :foo="{{value}}"></div>"#,
1141                r#"<div :foo="{value}"></div>"#,
1142            ),
1143            (
1144                r#"<div :foo="{{ value}}"></div>"#,
1145                r#"<div :foo="{ value}"></div>"#,
1146            ),
1147            (
1148                r#"<div :foo="{{value }}"></div>"#,
1149                r#"<div :foo="{value }"></div>"#,
1150            ),
1151            (
1152                r#"<div :foo="{{ value }}"></div>"#,
1153                r#"<div :foo="{ value }"></div>"#,
1154            ),
1155        ];
1156        for (input, expected) in &cases {
1157            let output = convert_btr_to_fast(input);
1158            assert_eq!(&output, expected, "normalization failed for input: {input}");
1159        }
1160    }
1161
1162    #[test]
1163    fn test_non_js_component_inner_template() {
1164        let mut plugin = FastV3ParserPlugin::new();
1165        // A JS component whose HTML contains a non-JS component using
1166        // declarative shadow DOM (template with shadowrootmode).
1167        let html = r#"<div><child-comp><template shadowrootmode="open"><p>inner</p></template></child-comp></div>"#;
1168        let comp = make_component("parent-comp", html, None);
1169        plugin
1170            .register_component_template("parent-comp", &comp, &comp.html_content)
1171            .unwrap();
1172
1173        let templates = plugin.take_component_templates();
1174        assert_eq!(templates.len(), 1);
1175        let (name, output) = &templates[0];
1176        assert_eq!(name, "parent-comp");
1177
1178        assert!(output.contains("<f-template name=\"parent-comp\">"));
1179        // shadowrootmode must be stripped from the inner non-JS component's template
1180        assert!(!output.contains("shadowrootmode"));
1181        // The inner template structure should be preserved (without shadowrootmode)
1182        assert!(output.contains("<child-comp><template><p>inner</p></template></child-comp>"));
1183    }
1184
1185    #[test]
1186    fn route_tags_converted_to_f_route() {
1187        let input = r#"<route path="/home" name="home" exact><span>Home</span></route>"#;
1188        let output = convert_btr_to_fast(input);
1189        assert!(output.starts_with("<webui-route"));
1190        assert!(output.contains(r#"path="/home""#));
1191        assert!(output.contains(r#"name="home""#));
1192        assert!(output.contains(" exact"));
1193        assert!(output.contains("style=\"display:none\""));
1194        assert!(output.contains("<span>Home</span>"));
1195        assert!(output.ends_with("</webui-route>"));
1196    }
1197
1198    #[test]
1199    fn f_route_tags_pass_through() {
1200        // <webui-route> tags emitted by the parser should pass through unchanged
1201        let input = r#"<webui-route path="/" name="dashboard" component="cb-page-dashboard" exact style="display:none"></webui-route>"#;
1202        let output = convert_btr_to_fast(input);
1203        assert_eq!(output, input);
1204    }
1205
1206    // --- End-to-end f-template regression tests ---
1207    // Verify that generated f-template blocks always use FAST syntax
1208    // (<f-when>/<f-repeat>) instead of webui syntax (<if>/<for>).
1209
1210    #[test]
1211    fn ftemplate_for_loop_converted_to_f_repeat() {
1212        let mut plugin = FastV3ParserPlugin::new();
1213        let html = r#"<ul><for each="item in items"><li>{{item.name}}</li></for></ul>"#;
1214        let comp = make_component("my-list", html, None);
1215        plugin
1216            .register_component_template("my-list", &comp, &comp.html_content)
1217            .unwrap();
1218
1219        let templates = plugin.take_component_templates();
1220        let (_, result) = &templates[0];
1221
1222        assert!(
1223            result.contains("<f-repeat value=\"{{item in items}}\">"),
1224            "f-template should use <f-repeat>, got: {result}"
1225        );
1226        assert!(
1227            result.contains("</f-repeat>"),
1228            "f-template should close with </f-repeat>, got: {result}"
1229        );
1230        assert!(
1231            !result.contains("<for "),
1232            "f-template should NOT contain <for>, got: {result}"
1233        );
1234    }
1235
1236    #[test]
1237    fn ftemplate_shadow_dom_strips_shadowroot_and_converts_directives() {
1238        let mut plugin = FastV3ParserPlugin::new();
1239        let html = r#"<template shadowrootmode="open"><div><if condition="visible">Shown</if><for each="x in list"><span>{{x}}</span></for></div></template>"#;
1240        let comp = make_component("my-shadow", html, None);
1241        plugin
1242            .register_component_template("my-shadow", &comp, &comp.html_content)
1243            .unwrap();
1244
1245        let templates = plugin.take_component_templates();
1246        let (_, result) = &templates[0];
1247
1248        assert!(
1249            result.contains("<f-when value=\"{{visible}}\">"),
1250            "Shadow f-template should contain <f-when>, got: {result}"
1251        );
1252        assert!(
1253            result.contains("<f-repeat value=\"{{x in list}}\">"),
1254            "Shadow f-template should contain <f-repeat>, got: {result}"
1255        );
1256        assert!(
1257            !result.contains("shadowrootmode"),
1258            "Shadow f-template should strip shadowrootmode, got: {result}"
1259        );
1260        assert!(
1261            !result.contains("<if "),
1262            "Shadow f-template should NOT contain <if>, got: {result}"
1263        );
1264        assert!(
1265            !result.contains("<for "),
1266            "Shadow f-template should NOT contain <for>, got: {result}"
1267        );
1268    }
1269
1270    #[test]
1271    fn ftemplate_nested_if_and_for_both_converted() {
1272        let mut plugin = FastV3ParserPlugin::new();
1273        let html =
1274            r#"<div><if condition="show"><for each="x in items"><p>{{x}}</p></for></if></div>"#;
1275        let comp = make_component("my-nested", html, None);
1276        plugin
1277            .register_component_template("my-nested", &comp, &comp.html_content)
1278            .unwrap();
1279
1280        let templates = plugin.take_component_templates();
1281        let (_, result) = &templates[0];
1282
1283        assert!(
1284            result.contains("<f-when value=\"{{show}}\">"),
1285            "Nested f-template should contain <f-when>, got: {result}"
1286        );
1287        assert!(
1288            result.contains("<f-repeat value=\"{{x in items}}\">"),
1289            "Nested f-template should contain <f-repeat>, got: {result}"
1290        );
1291        assert!(
1292            !result.contains("<if "),
1293            "Nested f-template should NOT contain <if>, got: {result}"
1294        );
1295        assert!(
1296            !result.contains("<for "),
1297            "Nested f-template should NOT contain <for>, got: {result}"
1298        );
1299    }
1300
1301    #[test]
1302    fn convert_btr_preserves_utf8_text_content() {
1303        let input = "<span>✓ passed</span>";
1304        let output = convert_btr_to_fast(input);
1305        assert_eq!(output, "<span>✓ passed</span>");
1306    }
1307
1308    #[test]
1309    fn convert_btr_preserves_utf8_multibyte_chars() {
1310        let input = "<span>✓ yes ✗ no ⭐ star</span>";
1311        let output = convert_btr_to_fast(input);
1312        assert_eq!(output, "<span>✓ yes ✗ no ⭐ star</span>");
1313    }
1314
1315    #[test]
1316    fn convert_btr_preserves_utf8_with_directives() {
1317        let input = r#"<if condition="x"><span>✓</span></if>"#;
1318        let output = convert_btr_to_fast(input);
1319        assert_eq!(output, r#"<f-when value="{{x}}"><span>✓</span></f-when>"#);
1320    }
1321
1322    #[test]
1323    fn minify_preserves_utf8_text_content() {
1324        let input = "<span>✓</span><span>✗</span>";
1325        let output = minify_inter_tag_whitespace(input);
1326        assert_eq!(output, "<span>✓</span><span>✗</span>");
1327    }
1328
1329    #[test]
1330    fn minify_preserves_utf8_between_elements() {
1331        let input = "<div> ✓ </div>";
1332        let output = minify_inter_tag_whitespace(input);
1333        assert_eq!(output, "<div> ✓ </div>");
1334    }
1335
1336    #[test]
1337    fn convert_complex_attrs_preserves_utf8() {
1338        let input = r#"<span :title="{{label}}" data-icon="⭐">text</span>"#;
1339        let output = convert_btr_to_fast(input);
1340        assert!(
1341            output.contains("⭐"),
1342            "UTF-8 star should be preserved: {output}"
1343        );
1344    }
1345}