Skip to main content

webui_parser/plugin/fast/
v3.rs

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