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