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