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