Skip to main content

webui_parser/plugin/fast/
v2.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! FAST 2 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::super::{
11    AttributeAction, AttributeContext, ComponentBuildContext, ComponentProcessing,
12    ComponentStyleDelivery, ComponentTemplateArtifact, ElementStartContext, ParserPlugin,
13    ParserPluginArtifacts,
14};
15use super::v3::{
16    build_f_template, f_template_style_injection, require_fast_shadow_dom, style_injection_snippet,
17    FastTemplateStyle,
18};
19#[cfg(test)]
20use crate::component_registry::Component;
21use crate::html_parser::{find_element_end, find_tag_close, opening_tag_name};
22use crate::{CssLinkOptions, CssStrategy, Result};
23use webui_protocol::FastElementData;
24
25#[inline]
26fn finish_element(binding_count: u32, reset_child_index: bool) -> Option<Vec<u8>> {
27    (binding_count > 0).then(|| {
28        FastElementData { binding_count }
29            .encode_v2(reset_child_index)
30            .to_vec()
31    })
32}
33
34/// Information about a tracked component for `<f-template>` generation.
35struct TrackedComponent {
36    tag_name: String,
37    template_html: String,
38    uses_shadow_dom: bool,
39    /// Prebuilt style snippet kept inside this component's shadow root.
40    ///
41    /// FAST's client runtime builds roots from `<f-template>` rather than from
42    /// WebUI's style registry, so a Shadow component's CSS has to travel with
43    /// the template that creates the root.
44    style_injection: Option<String>,
45    styles_at_end: bool,
46}
47
48/// FAST parser plugin pinned to FAST major version 2.
49///
50/// Implements the `ParserPlugin` trait for FAST 2 compatibility:
51/// - Filters FAST-specific runtime binding attributes (`@click`, `f-ref`, etc.)
52/// - Tracks components encountered during parsing
53/// - Returns `<f-template>` artifacts with converted FAST syntax after parsing
54/// - Emits binding attribute counts as `Plugin` protocol fragment data
55pub struct FastV2ParserPlugin {
56    /// Components tracked during parsing, in discovery order.
57    components: Vec<TrackedComponent>,
58    /// Whether the next root template carries the FAST 2 host-binding boundary.
59    reset_child_index_after_root: bool,
60}
61
62impl FastV2ParserPlugin {
63    /// Create a new FAST 2 parser plugin.
64    #[must_use]
65    pub fn new() -> Self {
66        Self {
67            components: Vec::new(),
68            reset_child_index_after_root: false,
69        }
70    }
71
72    /// Take the individual component f-template strings, keyed by tag name.
73    ///
74    /// Each value is a complete `<f-template name="tag-name">...</f-template>` string
75    /// ready to be appended to a document. This is used by the JSON partial render
76    /// endpoint to send only the templates the client needs.
77    #[must_use]
78    pub fn take_component_templates(&self) -> Vec<ComponentTemplateArtifact> {
79        self.components
80            .iter()
81            .map(|comp| {
82                let tmpl = build_f_template(
83                    &comp.tag_name,
84                    &convert_btr_to_fast(&comp.template_html),
85                    &super::shared::hoisted_shadow_options(&comp.template_html),
86                    FastTemplateStyle {
87                        injection: comp.style_injection.as_deref(),
88                        module_specifier: None,
89                        at_end: comp.styles_at_end,
90                    },
91                );
92                ComponentTemplateArtifact::template(
93                    comp.tag_name.clone(),
94                    tmpl,
95                    comp.uses_shadow_dom,
96                )
97            })
98            .collect()
99    }
100
101    fn track_component(&mut self, context: ComponentBuildContext<'_>) {
102        let tag_name = context.component.tag_name.as_str();
103        if self.components.iter().any(|c| c.tag_name == tag_name) {
104            return;
105        }
106        self.components.push(TrackedComponent {
107            tag_name: tag_name.to_string(),
108            template_html: context.template.to_string(),
109            uses_shadow_dom: context.uses_shadow_dom,
110            style_injection: context.style.and_then(style_injection_snippet),
111            styles_at_end: matches!(context.style, Some(ComponentStyleDelivery::Inline { .. })),
112        });
113    }
114
115    #[cfg(test)]
116    fn register_component_template(
117        &mut self,
118        _tag_name: &str,
119        component: &Component,
120        processed_template: &str,
121    ) -> Result<()> {
122        <Self as ParserPlugin>::component_built(
123            self,
124            ComponentBuildContext {
125                component,
126                template: processed_template,
127                uses_shadow_dom: true,
128                style: None,
129            },
130        )
131    }
132
133    #[cfg(test)]
134    fn classify_attribute(&mut self, name: &str) -> AttributeAction {
135        <Self as ParserPlugin>::process_attribute(self, AttributeContext { name })
136    }
137
138    #[cfg(test)]
139    fn finish_element(&mut self, binding_count: u32) -> Option<Vec<u8>> {
140        <Self as ParserPlugin>::finish_opening_tag(
141            self,
142            ElementStartContext {
143                tag_name: "test-element",
144                binding_count,
145            },
146        )
147    }
148}
149
150impl Default for FastV2ParserPlugin {
151    fn default() -> Self {
152        Self::new()
153    }
154}
155
156impl ParserPlugin for FastV2ParserPlugin {
157    fn component_processing(&self) -> ComponentProcessing {
158        ComponentProcessing {
159            source_transform: Some(super::shared::transform_component_source),
160            process_root_template_attributes: true,
161            inline_styles_after_content: true,
162        }
163    }
164
165    fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
166        require_fast_shadow_dom(context.component.tag_name.as_str(), context.uses_shadow_dom)?;
167        self.reset_child_index_after_root = true;
168        self.track_component(context);
169        Ok(())
170    }
171
172    fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
173        super::shared::classify_attribute(context.name)
174    }
175
176    fn finish_opening_tag(&mut self, context: ElementStartContext<'_>) -> Option<Vec<u8>> {
177        let reset_child_index =
178            self.reset_child_index_after_root && context.tag_name.eq_ignore_ascii_case("template");
179        if reset_child_index {
180            self.reset_child_index_after_root = false;
181        }
182        finish_element(context.binding_count, reset_child_index)
183    }
184
185    fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
186        Ok(ParserPluginArtifacts::ComponentTemplates(
187            self.take_component_templates(),
188        ))
189    }
190}
191
192/// Generate a single f-template HTML string from component data.
193///
194/// Used by the server to generate templates on demand for route components
195/// that weren't encountered during initial parsing.
196///
197/// This uses default Link-mode CSS filenames. Call
198/// [`generate_f_template_with_css_options`] when the build uses custom CSS
199/// filename templates or a public CSS base.
200pub fn generate_f_template(
201    tag_name: &str,
202    html_content: &str,
203    css_content: Option<&str>,
204    css_strategy: CssStrategy,
205) -> String {
206    generate_f_template_with_css_options(
207        tag_name,
208        html_content,
209        css_content,
210        css_strategy,
211        &CssLinkOptions::default(),
212    )
213}
214
215#[cfg(test)]
216fn generate_f_template_from_processed(tag_name: &str, processed_template: &str) -> String {
217    build_f_template(
218        tag_name,
219        &convert_btr_to_fast(processed_template),
220        &super::shared::hoisted_shadow_options(processed_template),
221        FastTemplateStyle::default(),
222    )
223}
224
225/// Generate a FAST 2 f-template with Link CSS filename/href options.
226pub fn generate_f_template_with_css_options(
227    tag_name: &str,
228    html_content: &str,
229    css_content: Option<&str>,
230    css_strategy: CssStrategy,
231    css_link_options: &CssLinkOptions,
232) -> String {
233    let (css_injection, module_specifier) =
234        f_template_style_injection(tag_name, css_content, css_strategy, css_link_options);
235
236    build_f_template(
237        tag_name,
238        &convert_btr_to_fast(html_content),
239        &super::shared::hoisted_shadow_options(html_content),
240        FastTemplateStyle {
241            injection: css_injection.as_deref(),
242            module_specifier,
243            at_end: css_strategy == CssStrategy::Style,
244        },
245    )
246}
247
248// Convert WebUI directives and bindings to FAST syntax.
249fn convert_btr_to_fast(input: &str) -> String {
250    let mut result = String::with_capacity(input.len());
251    let bytes = input.as_bytes();
252    let len = bytes.len();
253    let mut i = 0;
254
255    while i < len {
256        if bytes[i] == b'<' {
257            if let Some(consumed) = try_convert_tag(input, i, &mut result) {
258                i += consumed;
259                continue;
260            }
261        }
262
263        // Check for {{expr}} inside :attr values — handled at attribute level
264        // in try_convert_tag. Outside of tags, double-braces are left as-is
265        // (they're text content interpolation for FAST, kept unchanged).
266        i = push_char_at(input, i, &mut result);
267    }
268
269    result
270}
271
272/// Try to convert a tag starting at position `pos`. Returns `Some(bytes_consumed)`
273/// if a conversion was performed, or `None` if this is not a convertible tag.
274fn try_convert_tag(input: &str, pos: usize, result: &mut String) -> Option<usize> {
275    let remaining = &input[pos..];
276
277    // Check for closing tags first (shorter patterns)
278    if remaining.starts_with("</if>") {
279        result.push_str("</f-when>");
280        return Some(5);
281    }
282    if remaining.starts_with("</for>") {
283        result.push_str("</f-repeat>");
284        return Some(6);
285    }
286    // <route> in f-templates becomes empty <webui-route> (client router mounts components)
287    if remaining.starts_with("</route>") {
288        result.push_str("</webui-route>");
289        return Some(8);
290    }
291
292    // <outlet /> is kept as a marker element in f-templates.
293    // The client router finds it and replaces it with <webui-route> stubs.
294    if let Some(consumed) = find_element_end(remaining, "outlet") {
295        result.push_str("<outlet></outlet>");
296        return Some(consumed);
297    }
298
299    // Check for <if condition="...">
300    if starts_with_tag_name(remaining, "if") {
301        if let Some(consumed) = convert_if_tag(remaining, result) {
302            return Some(consumed);
303        }
304    }
305
306    // Check for <for each="...">
307    if starts_with_tag_name(remaining, "for") {
308        if let Some(consumed) = convert_for_tag(remaining, result) {
309            return Some(consumed);
310        }
311    }
312
313    // Check for <route ...> → <webui-route ...> in f-templates
314    if starts_with_tag_name(remaining, "route") {
315        if let Some(consumed) = convert_route_tag(remaining, result) {
316            return Some(consumed);
317        }
318    }
319
320    // Check for tags with :attr="{{expr}}" complex attribute values
321    if remaining.starts_with("<") {
322        // Strip shadowrootmode from <template> tags
323        if starts_with_tag_name(remaining, "template") {
324            if let Some(consumed) = super::shared::strip_hoisted_shadow_options(remaining, result) {
325                return Some(consumed);
326            }
327        }
328        if let Some(consumed) = convert_complex_attrs(remaining, result) {
329            return Some(consumed);
330        }
331    }
332
333    None
334}
335
336/// Check if `s` starts with `<name` followed by whitespace or `>`.
337fn starts_with_tag_name(s: &str, name: &str) -> bool {
338    opening_tag_name(s).is_some_and(|tag_name| tag_name.eq_ignore_ascii_case(name))
339}
340
341/// Convert `<if condition="EXPR">` to `<f-when value="{{EXPR}}">`.
342/// Returns bytes consumed on success.
343fn convert_if_tag(tag_str: &str, result: &mut String) -> Option<usize> {
344    // Find the closing '>' outside of quoted attribute values
345    let close = find_tag_close(tag_str)?;
346    let tag_content = &tag_str[..=close];
347
348    // Find condition="..." attribute
349    let attr_value = extract_attribute_value(tag_content, "condition")?;
350
351    result.push_str("<f-when value=\"{{");
352    result.push_str(attr_value);
353    result.push_str("}}\">");
354
355    Some(close + 1)
356}
357
358/// Convert `<for each="EXPR">` to `<f-repeat value="{{EXPR}}">`.
359/// Returns bytes consumed on success.
360fn convert_for_tag(tag_str: &str, result: &mut String) -> Option<usize> {
361    // Find the closing '>' outside of quoted attribute values
362    let close = find_tag_close(tag_str)?;
363    let tag_content = &tag_str[..=close];
364
365    // Find each="..." attribute
366    let attr_value = extract_attribute_value(tag_content, "each")?;
367
368    result.push_str("<f-repeat value=\"{{");
369    result.push_str(attr_value);
370    result.push_str("}}\">");
371
372    Some(close + 1)
373}
374
375/// Convert `<route ...>` to `<webui-route ...>` in f-templates.
376/// Self-closing routes become `<webui-route ...></webui-route>` (empty).
377/// The component attribute is preserved for the client router.
378fn convert_route_tag(tag_str: &str, result: &mut String) -> Option<usize> {
379    let close = find_tag_close(tag_str)?;
380    let is_self_closing = tag_str[..=close].ends_with("/>");
381    let tag = &tag_str[..=close];
382
383    result.push_str("<webui-route");
384
385    if let Some(v) = extract_attribute_value(tag, "path") {
386        result.push_str(" path=\"");
387        result.push_str(v);
388        result.push('"');
389    }
390    if let Some(v) = extract_attribute_value(tag, "name") {
391        result.push_str(" name=\"");
392        result.push_str(v);
393        result.push('"');
394    }
395    if let Some(v) = extract_attribute_value(tag, "component") {
396        result.push_str(" component=\"");
397        result.push_str(v);
398        result.push('"');
399    }
400    if let Some(v) = extract_attribute_value(tag, "redirectTo") {
401        result.push_str(" redirectto=\"");
402        result.push_str(v);
403        result.push('"');
404    }
405    if tag.contains(" exact") {
406        result.push_str(" exact");
407    }
408    if tag.contains(" layout") {
409        result.push_str(" layout");
410    }
411    if let Some(v) = extract_attribute_value(tag, "query") {
412        result.push_str(" query=\"");
413        result.push_str(v);
414        result.push('"');
415    }
416    result.push_str(" style=\"display:none\"");
417
418    if is_self_closing {
419        result.push_str("></webui-route>");
420    } else {
421        result.push('>');
422    }
423
424    Some(close + 1)
425}
426
427/// Extract the value of a named attribute from a tag string.
428/// Looks for `name="value"` and returns `value`.
429fn extract_attribute_value<'a>(tag: &'a str, attr_name: &str) -> Option<&'a str> {
430    // Search for the attribute name followed by '='
431    let mut search_from = 0;
432    loop {
433        let attr_pos = tag[search_from..].find(attr_name)?;
434        let abs_pos = search_from + attr_pos;
435
436        // Verify it's preceded by whitespace (not part of another attribute name)
437        if abs_pos > 0 {
438            let prev = tag.as_bytes()[abs_pos - 1];
439            if prev != b' ' && prev != b'\t' && prev != b'\n' && prev != b'\r' {
440                search_from = abs_pos + attr_name.len();
441                continue;
442            }
443        }
444
445        let after_name = &tag[abs_pos + attr_name.len()..];
446        if !after_name.starts_with('=') {
447            search_from = abs_pos + attr_name.len();
448            continue;
449        }
450
451        let after_eq = &after_name[1..];
452        if after_eq.starts_with('"') {
453            let value_start = abs_pos + attr_name.len() + 2; // skip `="`
454            let end_quote = tag[value_start..].find('"')?;
455            return Some(&tag[value_start..value_start + end_quote]);
456        }
457
458        return None;
459    }
460}
461
462/// Convert tags that contain `:attr="{{expr}}"` complex attribute bindings.
463/// Replaces `{{expr}}` with `{expr}` in attribute values on `:` prefixed attributes.
464/// Returns bytes consumed on success, or `None` if no conversion needed.
465fn convert_complex_attrs(tag_str: &str, result: &mut String) -> Option<usize> {
466    // Find the closing '>' outside of quoted attribute values
467    let close = find_tag_close(tag_str)?;
468    let tag_content = &tag_str[..=close];
469
470    // Quick check: does this tag have both a colon-prefixed attribute and `{{`?
471    if !tag_content.contains(":") || !tag_content.contains("{{") {
472        return None;
473    }
474
475    // Process the tag character by character, converting {{}} to {} in :attr values
476    let mut converted = String::with_capacity(tag_content.len());
477    let tag_bytes = tag_content.as_bytes();
478    let tag_len = tag_bytes.len();
479    let mut j = 0;
480    let mut in_colon_attr_value = false;
481
482    while j < tag_len {
483        if !in_colon_attr_value {
484            // Look for :attrname="
485            if tag_bytes[j] == b':' && j > 0 && is_whitespace(tag_bytes[j - 1]) {
486                // Found a colon-prefixed attribute, scan to the ="
487                let attr_start = j;
488                j += 1;
489                // Skip attribute name
490                while j < tag_len && tag_bytes[j] != b'=' && !is_whitespace(tag_bytes[j]) {
491                    j += 1;
492                }
493                if j < tag_len && tag_bytes[j] == b'=' {
494                    j += 1;
495                    if j < tag_len && tag_bytes[j] == b'"' {
496                        // Push everything up to and including the opening quote
497                        converted.push_str(&tag_content[attr_start..=j]);
498                        j += 1;
499                        in_colon_attr_value = true;
500                        continue;
501                    }
502                }
503                // Not a valid :attr="..." pattern, output as-is
504                converted.push_str(&tag_content[attr_start..j]);
505                continue;
506            }
507            j = push_char_at(tag_content, j, &mut converted);
508        } else {
509            // Inside a :attr value — convert {{expr}} to {expr}
510            if tag_bytes[j] == b'"' {
511                // End of attribute value
512                converted.push('"');
513                j += 1;
514                in_colon_attr_value = false;
515            } else if j + 1 < tag_len && tag_bytes[j] == b'{' && tag_bytes[j + 1] == b'{' {
516                // Found {{ — find matching }}
517                let expr_start = j + 2;
518                if let Some(end_offset) = tag_content[expr_start..].find("}}") {
519                    let expr = &tag_content[expr_start..expr_start + end_offset];
520                    converted.push('{');
521                    converted.push_str(expr);
522                    converted.push('}');
523                    j = expr_start + end_offset + 2;
524                } else {
525                    // No matching }}, output as-is
526                    converted.push('{');
527                    converted.push('{');
528                    j += 2;
529                }
530            } else {
531                j = push_char_at(tag_content, j, &mut converted);
532            }
533        }
534    }
535
536    result.push_str(&converted);
537    Some(close + 1)
538}
539
540/// Push the single UTF-8 character starting at `pos` in `input` into `out`,
541/// returning the byte index immediately after it.
542fn push_char_at(input: &str, pos: usize, out: &mut String) -> usize {
543    let bytes = input.as_bytes();
544    if bytes[pos].is_ascii() {
545        out.push(bytes[pos] as char);
546        pos + 1
547    } else {
548        let mut end = pos + 1;
549        while end < bytes.len() && !input.is_char_boundary(end) {
550            end += 1;
551        }
552        out.push_str(&input[pos..end]);
553        end
554    }
555}
556
557/// Check if a byte is ASCII whitespace.
558fn is_whitespace(b: u8) -> bool {
559    b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'
560}
561
562#[cfg(test)]
563mod tests {
564    #![allow(clippy::disallowed_methods)]
565
566    use super::*;
567
568    fn make_component(tag: &str, html: &str, css: Option<&str>) -> Component {
569        Component {
570            tag_name: tag.to_string(),
571            html_content: html.to_string(),
572            css_content: css.map(ToString::to_string),
573            css_definitions: Vec::new(),
574            css_fallback_chains: Vec::new(),
575            is_client_owned: false,
576        }
577    }
578
579    // --- classify_attribute ---
580
581    #[test]
582    fn skip_event_binding() {
583        let mut plugin = FastV2ParserPlugin::new();
584        assert_eq!(
585            plugin.classify_attribute("@click"),
586            AttributeAction::SkipAndCountBinding
587        );
588        assert_eq!(
589            plugin.classify_attribute("@input"),
590            AttributeAction::SkipAndCountBinding
591        );
592        assert_eq!(
593            plugin.classify_attribute("@custom-event"),
594            AttributeAction::SkipAndCountBinding
595        );
596    }
597
598    #[test]
599    fn skip_f_ref() {
600        let mut plugin = FastV2ParserPlugin::new();
601        assert_eq!(
602            plugin.classify_attribute("f-ref"),
603            AttributeAction::SkipAndCountBinding
604        );
605    }
606
607    #[test]
608    fn skip_f_slotted() {
609        let mut plugin = FastV2ParserPlugin::new();
610        assert_eq!(
611            plugin.classify_attribute("f-slotted"),
612            AttributeAction::SkipAndCountBinding
613        );
614    }
615
616    #[test]
617    fn skip_f_children() {
618        let mut plugin = FastV2ParserPlugin::new();
619        assert_eq!(
620            plugin.classify_attribute("f-children"),
621            AttributeAction::SkipAndCountBinding
622        );
623    }
624
625    #[test]
626    fn skip_fast_property_binding() {
627        let mut plugin = FastV2ParserPlugin::new();
628        assert_eq!(
629            plugin.classify_attribute(":title"),
630            AttributeAction::SkipAndCountBinding
631        );
632        assert_eq!(
633            plugin.classify_attribute(":config"),
634            AttributeAction::SkipAndCountBinding
635        );
636    }
637
638    #[test]
639    fn do_not_skip_normal_attributes() {
640        let mut plugin = FastV2ParserPlugin::new();
641        assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
642        assert_eq!(plugin.classify_attribute("id"), AttributeAction::Keep);
643        assert_eq!(
644            plugin.classify_attribute("data-value"),
645            AttributeAction::Keep
646        );
647        assert_eq!(plugin.classify_attribute("f-other"), AttributeAction::Keep);
648    }
649
650    // --- finish_element ---
651
652    #[test]
653    fn element_parsed_zero_count_returns_none() {
654        let mut plugin = FastV2ParserPlugin::new();
655        assert!(plugin.finish_element(0).is_none());
656    }
657
658    #[test]
659    fn element_parsed_nonzero_count_returns_le_bytes() {
660        let mut plugin = FastV2ParserPlugin::new();
661        let data = plugin.finish_element(3);
662        assert!(data.is_some());
663        let bytes = data.as_deref().unwrap_or_default();
664        assert_eq!(
665            bytes,
666            &FastElementData { binding_count: 3 }.encode_v2(false)
667        );
668    }
669
670    #[test]
671    fn element_parsed_large_count() {
672        let mut plugin = FastV2ParserPlugin::new();
673        let data = plugin.finish_element(256);
674        assert!(data.is_some());
675        let bytes = data.as_deref().unwrap_or_default();
676        assert_eq!(
677            bytes,
678            &FastElementData { binding_count: 256 }.encode_v2(false)
679        );
680    }
681
682    #[test]
683    fn component_template_simple_component() {
684        let mut plugin = FastV2ParserPlugin::new();
685        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
686        plugin
687            .register_component_template(
688                "my-comp",
689                &comp,
690                r#"<template><link rel="stylesheet" href="my-comp.css"><div>hello</div></template>"#,
691            )
692            .unwrap();
693
694        let templates = plugin.take_component_templates();
695        assert_eq!(templates.len(), 1);
696        let name = &templates[0].tag_name;
697        let html = &templates[0].template;
698        assert_eq!(name, "my-comp");
699        assert!(html.contains("<f-template name=\"my-comp\">"));
700        assert!(html.contains("</f-template>"));
701        assert!(html.contains("<template>"));
702        assert!(html.contains("</template>"));
703        assert!(html.contains("<link rel=\"stylesheet\" href=\"my-comp.css\">"));
704        assert!(html.contains("<div>hello</div>"));
705    }
706
707    #[test]
708    fn component_template_without_css() {
709        let mut plugin = FastV2ParserPlugin::new();
710        let comp = make_component("no-css", "<span>text</span>", None);
711        plugin
712            .register_component_template("no-css", &comp, &comp.html_content)
713            .unwrap();
714
715        let templates = plugin.take_component_templates();
716        assert_eq!(templates.len(), 1);
717        let name = &templates[0].tag_name;
718        let html = &templates[0].template;
719        assert_eq!(name, "no-css");
720        assert!(html.contains("<f-template name=\"no-css\">"));
721        assert!(!html.contains("<link rel=\"stylesheet\""));
722        assert!(html.contains("<span>text</span>"));
723    }
724
725    #[test]
726    fn direct_template_generation_skips_leading_comments() {
727        let html = generate_f_template(
728            "my-comp",
729            concat!(
730                "<!-- Copyright (C) Corporation. All rights reserved. -->\n",
731                "<template shadowrootmode=\"open\"><h1>Hello</h1></template>",
732            ),
733            None,
734            CssStrategy::Link,
735        );
736
737        assert_eq!(html.matches("<template>").count(), 1);
738        assert!(!html.contains("<!--"));
739        assert!(html.contains("<template><h1>Hello</h1></template>"));
740    }
741
742    #[test]
743    fn component_template_css_strategy_style() {
744        let mut plugin = FastV2ParserPlugin::new();
745        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
746        plugin
747            .register_component_template(
748                "my-comp",
749                &comp,
750                r#"<template><style>div { color: red; }</style><div>hello</div></template>"#,
751            )
752            .unwrap();
753
754        let templates = plugin.take_component_templates();
755        assert_eq!(templates.len(), 1);
756        let html = &templates[0].template;
757        assert!(
758            html.contains("<style>div { color: red; }</style>"),
759            "Style strategy should inline CSS, got: {html}"
760        );
761        assert!(
762            !html.contains("<link"),
763            "Style strategy should not emit <link> tags"
764        );
765    }
766
767    #[test]
768    fn component_template_css_strategy_module() {
769        let mut plugin = FastV2ParserPlugin::new();
770        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
771        plugin
772            .register_component_template(
773                "my-comp",
774                &comp,
775                r#"<template shadowrootadoptedstylesheets="my-comp"><div>hello</div></template>"#,
776            )
777            .unwrap();
778
779        let templates = plugin.take_component_templates();
780        assert_eq!(templates.len(), 1);
781        let html = &templates[0].template;
782
783        // f-template should NOT contain the CSS module — that is added
784        // by the handler at SSR (inline) and SPA-partial time.
785        assert!(
786            !html.contains(r#"<style type="module""#),
787            "Legacy CSS module shape should not be baked into f-template: {html}"
788        );
789        assert!(
790            !html.contains(r#"<script type="importmap""#),
791            "CSS module importmap should not be baked into f-template: {html}"
792        );
793        // shadowrootadoptedstylesheets on the inner template
794        assert!(
795            html.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
796            "Module strategy should add shadowrootadoptedstylesheets, got: {html}"
797        );
798        // No inline <style> or <link> inside the template
799        assert!(
800            !html.contains("<style>div"),
801            "Module strategy should not have inline <style> inside template"
802        );
803        assert!(
804            !html.contains("<link"),
805            "Module strategy should not emit <link> tags"
806        );
807    }
808
809    #[test]
810    fn component_template_css_strategy_module_no_css() {
811        let mut plugin = FastV2ParserPlugin::new();
812        let comp = make_component("my-comp", "<div>hello</div>", None);
813        plugin
814            .register_component_template(
815                "my-comp",
816                &comp,
817                r#"<template><div>hello</div></template>"#,
818            )
819            .unwrap();
820
821        let templates = plugin.take_component_templates();
822        assert_eq!(templates.len(), 1);
823        let html = &templates[0].template;
824        assert!(
825            !html.contains("shadowrootadoptedstylesheets"),
826            "No CSS = no shadowrootadoptedstylesheets, got: {html}"
827        );
828        assert!(
829            !html.contains("<style"),
830            "No CSS = no style tag, got: {html}"
831        );
832    }
833
834    #[test]
835    fn component_template_multiple_components() {
836        let mut plugin = FastV2ParserPlugin::new();
837        let comp1 = make_component("comp-a", "<div>A</div>", None);
838        let comp2 = make_component("comp-b", "<div>B</div>", Some("b { }"));
839        plugin
840            .register_component_template("comp-a", &comp1, &comp1.html_content)
841            .unwrap();
842        plugin
843            .register_component_template("comp-b", &comp2, &comp2.html_content)
844            .unwrap();
845
846        let templates = plugin.take_component_templates();
847        assert_eq!(templates.len(), 2);
848
849        let names: Vec<&str> = templates
850            .iter()
851            .map(|artifact| artifact.tag_name.as_str())
852            .collect();
853        assert!(names.contains(&"comp-a"));
854        assert!(names.contains(&"comp-b"));
855
856        for artifact in &templates {
857            assert!(artifact.template.contains("<f-template name="));
858        }
859    }
860
861    #[test]
862    fn component_template_deduplicates_same_component() {
863        let mut plugin = FastV2ParserPlugin::new();
864        let comp = make_component(
865            "my-button",
866            "<button><slot></slot></button>",
867            Some(".btn{}"),
868        );
869
870        // Simulate the same component encountered multiple times
871        // (e.g., <my-button> used in todo-app, todo-item, etc.)
872        plugin
873            .register_component_template("my-button", &comp, &comp.html_content)
874            .unwrap();
875        plugin
876            .register_component_template("my-button", &comp, &comp.html_content)
877            .unwrap();
878        plugin
879            .register_component_template("my-button", &comp, &comp.html_content)
880            .unwrap();
881
882        let templates = plugin.take_component_templates();
883        assert_eq!(
884            templates.len(),
885            1,
886            "Expected exactly 1 template for my-button, got {}",
887            templates.len()
888        );
889        assert_eq!(templates[0].tag_name, "my-button");
890    }
891
892    #[test]
893    fn component_template_deduplicates_mixed_components() {
894        let mut plugin = FastV2ParserPlugin::new();
895        let btn = make_component("my-button", "<button><slot></slot></button>", None);
896        let card = make_component("my-card", "<div><slot></slot></div>", Some(".card{}"));
897
898        // my-button used in two different parent templates, my-card used once
899        plugin
900            .register_component_template("my-button", &btn, &btn.html_content)
901            .unwrap();
902        plugin
903            .register_component_template("my-card", &card, &card.html_content)
904            .unwrap();
905        plugin
906            .register_component_template("my-button", &btn, &btn.html_content)
907            .unwrap();
908
909        let templates = plugin.take_component_templates();
910        assert_eq!(templates.len(), 2);
911
912        let button_count = templates
913            .iter()
914            .filter(|artifact| artifact.tag_name == "my-button")
915            .count();
916        let card_count = templates
917            .iter()
918            .filter(|artifact| artifact.tag_name == "my-card")
919            .count();
920        assert_eq!(button_count, 1);
921        assert_eq!(card_count, 1);
922    }
923
924    // --- FAST syntax conversion ---
925
926    #[test]
927    fn convert_if_to_f_when() {
928        let input = r#"<if condition="isComplete"><span>Done</span></if>"#;
929        let output = convert_btr_to_fast(input);
930        assert_eq!(
931            output,
932            r#"<f-when value="{{isComplete}}"><span>Done</span></f-when>"#
933        );
934    }
935
936    #[test]
937    fn convert_if_with_greater_than() {
938        let input = r#"<if condition="vara > 2">Over two</if>"#;
939        let output = convert_btr_to_fast(input);
940        assert_eq!(output, r#"<f-when value="{{vara > 2}}">Over two</f-when>"#);
941    }
942
943    #[test]
944    fn convert_if_with_greater_than_or_equal() {
945        let input = r#"<if condition="vara >= 2">Two and over</if>"#;
946        let output = convert_btr_to_fast(input);
947        assert_eq!(
948            output,
949            r#"<f-when value="{{vara >= 2}}">Two and over</f-when>"#
950        );
951    }
952
953    #[test]
954    fn convert_if_with_less_than() {
955        let input = r#"<if condition="x < 10">small</if>"#;
956        let output = convert_btr_to_fast(input);
957        assert_eq!(output, r#"<f-when value="{{x < 10}}">small</f-when>"#);
958    }
959
960    #[test]
961    fn convert_for_to_f_repeat() {
962        let input = r#"<for each="tag in tags"><span>{{tag}}</span></for>"#;
963        let output = convert_btr_to_fast(input);
964        assert_eq!(
965            output,
966            r#"<f-repeat value="{{tag in tags}}"><span>{{tag}}</span></f-repeat>"#
967        );
968    }
969
970    #[test]
971    fn convert_nested_if_and_for() {
972        let input = r#"<if condition="show"><for each="x in items"><p>{{x}}</p></for></if>"#;
973        let output = convert_btr_to_fast(input);
974        assert_eq!(
975            output,
976            r#"<f-when value="{{show}}"><f-repeat value="{{x in items}}"><p>{{x}}</p></f-repeat></f-when>"#
977        );
978    }
979
980    #[test]
981    fn convert_complex_attr_double_braces() {
982        let input = r#"<div :data="{{config}}"></div>"#;
983        let output = convert_btr_to_fast(input);
984        assert_eq!(output, r#"<div :data="{config}"></div>"#);
985    }
986
987    #[test]
988    fn convert_multiple_complex_attrs() {
989        let input = r#"<div :title="{{name}}" :data-id="{{id}}"></div>"#;
990        let output = convert_btr_to_fast(input);
991        assert_eq!(output, r#"<div :title="{name}" :data-id="{id}"></div>"#);
992    }
993
994    #[test]
995    fn no_conversion_for_text_double_braces() {
996        // Double braces in text content (not in :attr) should stay as-is
997        let input = "<span>{{greeting}}</span>";
998        let output = convert_btr_to_fast(input);
999        assert_eq!(output, "<span>{{greeting}}</span>");
1000    }
1001
1002    #[test]
1003    fn no_conversion_for_regular_attr() {
1004        // Double braces in non-colon attributes should stay as-is
1005        let input = r#"<div title="{{name}}"></div>"#;
1006        let output = convert_btr_to_fast(input);
1007        assert_eq!(output, r#"<div title="{{name}}"></div>"#);
1008    }
1009
1010    #[test]
1011    fn component_template_with_btr_conversion() {
1012        let mut plugin = FastV2ParserPlugin::new();
1013        let html = r#"<div><if condition="visible"><span>hi</span></if></div>"#;
1014        let comp = make_component("my-widget", html, None);
1015        plugin
1016            .register_component_template("my-widget", &comp, &comp.html_content)
1017            .unwrap();
1018
1019        let templates = plugin.take_component_templates();
1020        assert_eq!(templates.len(), 1);
1021        let name = &templates[0].tag_name;
1022        let result = &templates[0].template;
1023        assert_eq!(name, "my-widget");
1024
1025        assert!(result.contains("<f-when value=\"{{visible}}\">"));
1026        assert!(result.contains("</f-when>"));
1027        assert!(!result.contains("<if condition="));
1028        assert!(!result.contains("</if>"));
1029    }
1030
1031    #[test]
1032    fn passthrough_normal_html() {
1033        let input = r#"<div class="container"><p>Hello</p></div>"#;
1034        let output = convert_btr_to_fast(input);
1035        assert_eq!(output, input);
1036    }
1037
1038    #[test]
1039    fn outlet_kept_as_marker_in_f_template() {
1040        for input in [
1041            r#"<div><outlet /></div>"#,
1042            r#"<div><outlet></outlet></div>"#,
1043        ] {
1044            let output = convert_btr_to_fast(input);
1045            assert_eq!(output, r#"<div><outlet></outlet></div>"#);
1046        }
1047    }
1048
1049    #[test]
1050    fn outlet_marker_in_component_template() {
1051        let mut plugin = FastV2ParserPlugin::new();
1052        let html =
1053            r#"<template shadowrootmode="open"><h1>Title</h1><main><outlet /></main></template>"#;
1054        let comp = make_component("my-shell", html, None);
1055        plugin
1056            .register_component_template("my-shell", &comp, &comp.html_content)
1057            .unwrap();
1058
1059        let templates = plugin.take_component_templates();
1060        let result = &templates[0].template;
1061        assert!(
1062            result.contains("<outlet></outlet>"),
1063            "outlet should be kept as marker in f-template: {result}"
1064        );
1065    }
1066
1067    #[test]
1068    fn strip_shadowrootmode_from_template() {
1069        let input = r#"<template shadowrootmode="open"><div>Hello</div></template>"#;
1070        let output = convert_btr_to_fast(input);
1071        assert_eq!(output, "<template><div>Hello</div></template>");
1072    }
1073
1074    #[test]
1075    fn strips_uppercase_native_shadow_wrapper() {
1076        let input = r#"<TEMPLATE SHADOWROOTMODE="open"><div>Hello</div></TEMPLATE>"#;
1077        let output = convert_btr_to_fast(input);
1078        assert_eq!(output, "<TEMPLATE><div>Hello</div></TEMPLATE>");
1079    }
1080
1081    #[test]
1082    fn rejects_effective_light_dom() {
1083        let mut plugin = FastV2ParserPlugin::new();
1084        let component = make_component("my-card", "<p>content</p>", None);
1085        let error = <FastV2ParserPlugin as ParserPlugin>::component_built(
1086            &mut plugin,
1087            ComponentBuildContext {
1088                component: &component,
1089                template: component.html_content.as_str(),
1090                uses_shadow_dom: false,
1091                style: None,
1092            },
1093        )
1094        .expect_err("FAST cannot mount Light DOM faithfully");
1095        assert!(matches!(
1096            error,
1097            crate::ParserError::Template(ref diagnostic)
1098                if diagnostic.error_code() == Some(crate::diagnostic::codes::FAST_LIGHT_DOM_UNSUPPORTED)
1099        ));
1100    }
1101
1102    #[test]
1103    fn strip_shadowrootmode_preserves_other_attrs() {
1104        let input =
1105            r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>Hi</div></template>"#;
1106        let output = convert_btr_to_fast(input);
1107        assert!(output.contains(r#"@click="{onClick(e)}""#));
1108        assert!(!output.contains("shadowrootmode"));
1109        assert!(output.starts_with("<template "));
1110    }
1111
1112    #[test]
1113    fn no_strip_when_no_shadowrootmode() {
1114        let input = r#"<template foo="bar"><div>Hi</div></template>"#;
1115        let output = convert_btr_to_fast(input);
1116        assert_eq!(output, input);
1117    }
1118
1119    #[test]
1120    fn component_template_strips_shadowrootmode_from_f_template() {
1121        let mut plugin = FastV2ParserPlugin::new();
1122        let comp = make_component(
1123            "my-comp",
1124            r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>{{title}}</div></template>"#,
1125            Some("div { color: red; }"),
1126        );
1127        plugin
1128            .register_component_template("my-comp", &comp, &comp.html_content)
1129            .unwrap();
1130        let templates = plugin.take_component_templates();
1131        assert_eq!(templates.len(), 1);
1132        let name = &templates[0].tag_name;
1133        let html = &templates[0].template;
1134        assert_eq!(name, "my-comp");
1135        // shadowrootmode is hoisted onto the <f-template> wrapper (where the
1136        // FAST runtime reads it), not left on the inner <template>.
1137        assert!(html.contains(r#"<f-template name="my-comp" shadowrootmode="open">"#));
1138        assert!(!html.contains("<template shadowrootmode"));
1139        // But should keep @click (framework attr kept in f-template mode)
1140        assert!(html.contains("@click"));
1141        // Should have the converted content
1142        assert!(html.contains("{{title}}"));
1143    }
1144
1145    #[test]
1146    fn shadow_options_hoisted_from_inner_template_to_f_template_wrapper() {
1147        // The converter bakes the wrapper's shadow-root options onto the inner
1148        // <template>; the client artifact generator hoists them back onto the
1149        // <f-template> wrapper (where the FAST runtime reads them), strips them
1150        // from the inner <template>, and keeps WebUI's CSS-module
1151        // shadowrootadoptedstylesheets on the inner template.
1152        let processed = concat!(
1153            r#"<template shadowrootmode="open" shadowrootdelegatesfocus "#,
1154            r#"shadowrootadoptedstylesheets="custom-field" @click="{clickHandler($e)}">"#,
1155            r#"<slot></slot></template>"#,
1156        );
1157        let html = generate_f_template_from_processed("custom-field", processed);
1158        assert!(
1159            html.contains(
1160                r#"<f-template name="custom-field" shadowrootmode="open" shadowrootdelegatesfocus>"#
1161            ),
1162            "wrapper must carry hoisted shadow options: {html}"
1163        );
1164        // The inner <template> keeps its bindings and the adopted-stylesheets
1165        // CSS delivery, but not the hoisted shadow-root options.
1166        assert!(html.contains(r#"@click="{clickHandler($e)}""#));
1167        assert!(html.contains(r#"shadowrootadoptedstylesheets="custom-field""#));
1168        assert!(
1169            !html.contains("<template shadowrootmode"),
1170            "inner <template> must not keep shadowrootmode: {html}"
1171        );
1172        assert!(
1173            !html.contains("<template shadowrootdelegatesfocus")
1174                && !html.contains(" shadowrootdelegatesfocus shadowrootadopted"),
1175            "inner <template> must not keep shadowrootdelegatesfocus: {html}"
1176        );
1177    }
1178
1179    #[test]
1180    fn delegatesfocus_only_without_mode_is_hoisted_and_stripped() {
1181        // Regression for the duplication bug: `shadowrootdelegatesfocus` alone
1182        // (no `shadowrootmode`) must still be hoisted onto the wrapper and
1183        // removed from the inner <template>, not only when `shadowrootmode`
1184        // happens to be present too.
1185        let processed = r#"<template shadowrootdelegatesfocus><slot></slot></template>"#;
1186        let html = generate_f_template_from_processed("custom-field", processed);
1187        assert_eq!(
1188            html.matches("shadowrootdelegatesfocus").count(),
1189            1,
1190            "shadowrootdelegatesfocus must appear exactly once (hoisted, not duplicated): {html}"
1191        );
1192        assert!(
1193            html.contains(r#"<f-template name="custom-field" shadowrootdelegatesfocus>"#),
1194            "wrapper must carry the hoisted option: {html}"
1195        );
1196        assert!(
1197            !html.contains("<template shadowrootdelegatesfocus"),
1198            "inner <template> must not keep shadowrootdelegatesfocus: {html}"
1199        );
1200    }
1201
1202    #[test]
1203    fn clonable_and_serializable_only_are_hoisted_and_stripped() {
1204        let processed =
1205            r#"<template shadowrootclonable shadowrootserializable><slot></slot></template>"#;
1206        let html = generate_f_template_from_processed("custom-field", processed);
1207        for attr in ["shadowrootclonable", "shadowrootserializable"] {
1208            assert_eq!(
1209                html.matches(attr).count(),
1210                1,
1211                "{attr} must appear exactly once (hoisted, not duplicated): {html}"
1212            );
1213        }
1214        assert!(
1215            html.contains(
1216                r#"<f-template name="custom-field" shadowrootclonable shadowrootserializable>"#
1217            ),
1218            "wrapper must carry both hoisted options: {html}"
1219        );
1220        assert!(
1221            !html.contains("<template shadowrootclonable")
1222                && !html.contains("<template shadowrootserializable"),
1223            "inner <template> must not keep either hoisted option: {html}"
1224        );
1225    }
1226
1227    #[test]
1228    fn adoptedstylesheets_only_is_neither_hoisted_nor_stripped() {
1229        let processed =
1230            r#"<template shadowrootadoptedstylesheets="custom-field"><slot></slot></template>"#;
1231        let html = generate_f_template_from_processed("custom-field", processed);
1232        assert!(
1233            html.contains("<f-template name=\"custom-field\">"),
1234            "shadowrootadoptedstylesheets is not a shadow-root-creation option and must stay off the wrapper: {html}"
1235        );
1236        assert!(
1237            html.contains(r#"<template shadowrootadoptedstylesheets="custom-field">"#),
1238            "inner <template> must keep shadowrootadoptedstylesheets: {html}"
1239        );
1240    }
1241
1242    #[test]
1243    fn mixed_shadow_options_each_appear_exactly_once_and_never_on_inner() {
1244        // Every `shadowroot*` option (mode, delegatesfocus, clonable,
1245        // serializable) plus WebUI's own shadowrootadoptedstylesheets and an
1246        // ordinary binding, all on the same inner <template>. Each hoisted
1247        // option must land on the wrapper exactly once and never duplicate
1248        // onto the inner template; shadowrootadoptedstylesheets and the
1249        // binding must stay on the inner template.
1250        let processed = concat!(
1251            r#"<template shadowrootmode="open" shadowrootdelegatesfocus shadowrootclonable "#,
1252            r#"shadowrootserializable shadowrootadoptedstylesheets="custom-field" "#,
1253            r#"@click="{clickHandler($e)}"><slot></slot></template>"#,
1254        );
1255        let html = generate_f_template_from_processed("custom-field", processed);
1256        assert!(
1257            html.contains(
1258                r#"<f-template name="custom-field" shadowrootmode="open" shadowrootdelegatesfocus shadowrootclonable shadowrootserializable>"#
1259            ),
1260            "wrapper must carry every hoisted option exactly once, in order: {html}"
1261        );
1262        assert!(
1263            html.contains(
1264                r#"<template shadowrootadoptedstylesheets="custom-field" @click="{clickHandler($e)}">"#
1265            ),
1266            "inner <template> must keep shadowrootadoptedstylesheets and the binding, and nothing hoisted: {html}"
1267        );
1268        for attr in [
1269            "shadowrootmode",
1270            "shadowrootdelegatesfocus",
1271            "shadowrootclonable",
1272            "shadowrootserializable",
1273        ] {
1274            assert_eq!(
1275                html.matches(attr).count(),
1276                1,
1277                "{attr} must appear exactly once across the wrapper and inner template: {html}"
1278            );
1279        }
1280    }
1281
1282    #[test]
1283    fn test_complex_attribute_normalization() {
1284        // All spacing variants should convert {{}} to {} in :attr values,
1285        // preserving any whitespace within the expression.
1286        let cases = [
1287            (
1288                r#"<div :foo="{{value}}"></div>"#,
1289                r#"<div :foo="{value}"></div>"#,
1290            ),
1291            (
1292                r#"<div :foo="{{ value}}"></div>"#,
1293                r#"<div :foo="{ value}"></div>"#,
1294            ),
1295            (
1296                r#"<div :foo="{{value }}"></div>"#,
1297                r#"<div :foo="{value }"></div>"#,
1298            ),
1299            (
1300                r#"<div :foo="{{ value }}"></div>"#,
1301                r#"<div :foo="{ value }"></div>"#,
1302            ),
1303        ];
1304        for (input, expected) in &cases {
1305            let output = convert_btr_to_fast(input);
1306            assert_eq!(&output, expected, "normalization failed for input: {input}");
1307        }
1308    }
1309
1310    #[test]
1311    fn test_non_js_component_inner_template() {
1312        let mut plugin = FastV2ParserPlugin::new();
1313        // A JS component whose HTML contains a non-JS component using
1314        // declarative shadow DOM (template with shadowrootmode).
1315        let html = r#"<div><child-comp><template shadowrootmode="open"><p>inner</p></template></child-comp></div>"#;
1316        let comp = make_component("parent-comp", html, None);
1317        plugin
1318            .register_component_template("parent-comp", &comp, &comp.html_content)
1319            .unwrap();
1320
1321        let templates = plugin.take_component_templates();
1322        assert_eq!(templates.len(), 1);
1323        let name = &templates[0].tag_name;
1324        let output = &templates[0].template;
1325        assert_eq!(name, "parent-comp");
1326
1327        assert!(output.contains("<f-template name=\"parent-comp\">"));
1328        // shadowrootmode must be stripped from the inner non-JS component's template
1329        assert!(!output.contains("shadowrootmode"));
1330        // The inner template structure should be preserved (without shadowrootmode)
1331        assert!(output.contains("<child-comp><template><p>inner</p></template></child-comp>"));
1332    }
1333
1334    #[test]
1335    fn route_tags_converted_to_f_route() {
1336        let input = r#"<route path="/home" name="home" exact><span>Home</span></route>"#;
1337        let output = convert_btr_to_fast(input);
1338        assert!(output.starts_with("<webui-route"));
1339        assert!(output.contains(r#"path="/home""#));
1340        assert!(output.contains(r#"name="home""#));
1341        assert!(output.contains(" exact"));
1342        assert!(output.contains("style=\"display:none\""));
1343        assert!(output.contains("<span>Home</span>"));
1344        assert!(output.ends_with("</webui-route>"));
1345    }
1346
1347    #[test]
1348    fn f_route_tags_pass_through() {
1349        // <webui-route> tags emitted by the parser should pass through unchanged
1350        let input = r#"<webui-route path="/" name="dashboard" component="cb-page-dashboard" exact style="display:none"></webui-route>"#;
1351        let output = convert_btr_to_fast(input);
1352        assert_eq!(output, input);
1353    }
1354
1355    // --- End-to-end f-template regression tests ---
1356    // Verify that generated f-template blocks always use FAST syntax
1357    // (<f-when>/<f-repeat>) instead of webui syntax (<if>/<for>).
1358
1359    #[test]
1360    fn ftemplate_for_loop_converted_to_f_repeat() {
1361        let mut plugin = FastV2ParserPlugin::new();
1362        let html = r#"<ul><for each="item in items"><li>{{item.name}}</li></for></ul>"#;
1363        let comp = make_component("my-list", html, None);
1364        plugin
1365            .register_component_template("my-list", &comp, &comp.html_content)
1366            .unwrap();
1367
1368        let templates = plugin.take_component_templates();
1369        let result = &templates[0].template;
1370
1371        assert!(
1372            result.contains("<f-repeat value=\"{{item in items}}\">"),
1373            "f-template should use <f-repeat>, got: {result}"
1374        );
1375        assert!(
1376            result.contains("</f-repeat>"),
1377            "f-template should close with </f-repeat>, got: {result}"
1378        );
1379        assert!(
1380            !result.contains("<for "),
1381            "f-template should NOT contain <for>, got: {result}"
1382        );
1383    }
1384
1385    #[test]
1386    fn ftemplate_shadow_dom_strips_shadowroot_and_converts_directives() {
1387        let mut plugin = FastV2ParserPlugin::new();
1388        let html = r#"<template shadowrootmode="open"><div><if condition="visible">Shown</if><for each="x in list"><span>{{x}}</span></for></div></template>"#;
1389        let comp = make_component("my-shadow", html, None);
1390        plugin
1391            .register_component_template("my-shadow", &comp, &comp.html_content)
1392            .unwrap();
1393
1394        let templates = plugin.take_component_templates();
1395        let result = &templates[0].template;
1396
1397        assert!(
1398            result.contains("<f-when value=\"{{visible}}\">"),
1399            "Shadow f-template should contain <f-when>, got: {result}"
1400        );
1401        assert!(
1402            result.contains("<f-repeat value=\"{{x in list}}\">"),
1403            "Shadow f-template should contain <f-repeat>, got: {result}"
1404        );
1405        assert!(
1406            result.contains(r#"<f-template name="my-shadow" shadowrootmode="open">"#),
1407            "shadowrootmode should be hoisted onto the <f-template> wrapper, got: {result}"
1408        );
1409        assert!(
1410            !result.contains("<template shadowrootmode"),
1411            "inner <template> should not keep shadowrootmode, got: {result}"
1412        );
1413        assert!(
1414            !result.contains("<if "),
1415            "Shadow f-template should NOT contain <if>, got: {result}"
1416        );
1417        assert!(
1418            !result.contains("<for "),
1419            "Shadow f-template should NOT contain <for>, got: {result}"
1420        );
1421    }
1422
1423    #[test]
1424    fn ftemplate_nested_if_and_for_both_converted() {
1425        let mut plugin = FastV2ParserPlugin::new();
1426        let html =
1427            r#"<div><if condition="show"><for each="x in items"><p>{{x}}</p></for></if></div>"#;
1428        let comp = make_component("my-nested", html, None);
1429        plugin
1430            .register_component_template("my-nested", &comp, &comp.html_content)
1431            .unwrap();
1432
1433        let templates = plugin.take_component_templates();
1434        let result = &templates[0].template;
1435
1436        assert!(
1437            result.contains("<f-when value=\"{{show}}\">"),
1438            "Nested f-template should contain <f-when>, got: {result}"
1439        );
1440        assert!(
1441            result.contains("<f-repeat value=\"{{x in items}}\">"),
1442            "Nested f-template should contain <f-repeat>, got: {result}"
1443        );
1444        assert!(
1445            !result.contains("<if "),
1446            "Nested f-template should NOT contain <if>, got: {result}"
1447        );
1448        assert!(
1449            !result.contains("<for "),
1450            "Nested f-template should NOT contain <for>, got: {result}"
1451        );
1452    }
1453
1454    #[test]
1455    fn convert_btr_preserves_utf8_text_content() {
1456        let input = "<span>✓ passed</span>";
1457        let output = convert_btr_to_fast(input);
1458        assert_eq!(output, "<span>✓ passed</span>");
1459    }
1460
1461    #[test]
1462    fn convert_btr_preserves_utf8_multibyte_chars() {
1463        let input = "<span>✓ yes ✗ no ⭐ star</span>";
1464        let output = convert_btr_to_fast(input);
1465        assert_eq!(output, "<span>✓ yes ✗ no ⭐ star</span>");
1466    }
1467
1468    #[test]
1469    fn convert_btr_preserves_utf8_with_directives() {
1470        let input = r#"<if condition="x"><span>✓</span></if>"#;
1471        let output = convert_btr_to_fast(input);
1472        assert_eq!(output, r#"<f-when value="{{x}}"><span>✓</span></f-when>"#);
1473    }
1474
1475    #[test]
1476    fn convert_complex_attrs_preserves_utf8() {
1477        let input = r#"<span :title="{{label}}" data-icon="⭐">text</span>"#;
1478        let output = convert_btr_to_fast(input);
1479        assert!(
1480            output.contains("⭐"),
1481            "UTF-8 star should be preserved: {output}"
1482        );
1483    }
1484}