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