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