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