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_element_end, 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 let Some(consumed) = find_element_end(remaining, "outlet") {
290        result.push_str("<outlet></outlet>");
291        return Some(consumed);
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    opening_tag_name(s).is_some_and(|tag_name| tag_name == name)
334}
335
336/// Convert `<if condition="EXPR">` to `<f-when value="{{EXPR}}">`.
337/// Returns bytes consumed on success.
338fn convert_if_tag(tag_str: &str, result: &mut String) -> Option<usize> {
339    // Find the closing '>' outside of quoted attribute values
340    let close = find_tag_close(tag_str)?;
341    let tag_content = &tag_str[..=close];
342
343    // Find condition="..." attribute
344    let attr_value = extract_attribute_value(tag_content, "condition")?;
345
346    result.push_str("<f-when value=\"{{");
347    result.push_str(attr_value);
348    result.push_str("}}\">");
349
350    Some(close + 1)
351}
352
353/// Convert `<for each="EXPR">` to `<f-repeat value="{{EXPR}}">`.
354/// Returns bytes consumed on success.
355fn convert_for_tag(tag_str: &str, result: &mut String) -> Option<usize> {
356    // Find the closing '>' outside of quoted attribute values
357    let close = find_tag_close(tag_str)?;
358    let tag_content = &tag_str[..=close];
359
360    // Find each="..." attribute
361    let attr_value = extract_attribute_value(tag_content, "each")?;
362
363    result.push_str("<f-repeat value=\"{{");
364    result.push_str(attr_value);
365    result.push_str("}}\">");
366
367    Some(close + 1)
368}
369
370/// Convert `<route ...>` to `<webui-route ...>` in f-templates.
371/// Self-closing routes become `<webui-route ...></webui-route>` (empty).
372/// The component attribute is preserved for the client router.
373fn convert_route_tag(tag_str: &str, result: &mut String) -> Option<usize> {
374    let close = find_tag_close(tag_str)?;
375    let is_self_closing = tag_str[..=close].ends_with("/>");
376    let tag = &tag_str[..=close];
377
378    result.push_str("<webui-route");
379
380    if let Some(v) = extract_attribute_value(tag, "path") {
381        result.push_str(" path=\"");
382        result.push_str(v);
383        result.push('"');
384    }
385    if let Some(v) = extract_attribute_value(tag, "name") {
386        result.push_str(" name=\"");
387        result.push_str(v);
388        result.push('"');
389    }
390    if let Some(v) = extract_attribute_value(tag, "component") {
391        result.push_str(" component=\"");
392        result.push_str(v);
393        result.push('"');
394    }
395    if let Some(v) = extract_attribute_value(tag, "redirectTo") {
396        result.push_str(" redirectto=\"");
397        result.push_str(v);
398        result.push('"');
399    }
400    if tag.contains(" exact") {
401        result.push_str(" exact");
402    }
403    if tag.contains(" layout") {
404        result.push_str(" layout");
405    }
406    if let Some(v) = extract_attribute_value(tag, "query") {
407        result.push_str(" query=\"");
408        result.push_str(v);
409        result.push('"');
410    }
411    result.push_str(" style=\"display:none\"");
412
413    if is_self_closing {
414        result.push_str("></webui-route>");
415    } else {
416        result.push('>');
417    }
418
419    Some(close + 1)
420}
421
422/// Extract the value of a named attribute from a tag string.
423/// Looks for `name="value"` and returns `value`.
424fn extract_attribute_value<'a>(tag: &'a str, attr_name: &str) -> Option<&'a str> {
425    // Search for the attribute name followed by '='
426    let mut search_from = 0;
427    loop {
428        let attr_pos = tag[search_from..].find(attr_name)?;
429        let abs_pos = search_from + attr_pos;
430
431        // Verify it's preceded by whitespace (not part of another attribute name)
432        if abs_pos > 0 {
433            let prev = tag.as_bytes()[abs_pos - 1];
434            if prev != b' ' && prev != b'\t' && prev != b'\n' && prev != b'\r' {
435                search_from = abs_pos + attr_name.len();
436                continue;
437            }
438        }
439
440        let after_name = &tag[abs_pos + attr_name.len()..];
441        if !after_name.starts_with('=') {
442            search_from = abs_pos + attr_name.len();
443            continue;
444        }
445
446        let after_eq = &after_name[1..];
447        if after_eq.starts_with('"') {
448            let value_start = abs_pos + attr_name.len() + 2; // skip `="`
449            let end_quote = tag[value_start..].find('"')?;
450            return Some(&tag[value_start..value_start + end_quote]);
451        }
452
453        return None;
454    }
455}
456
457/// Convert tags that contain `:attr="{{expr}}"` complex attribute bindings.
458/// Replaces `{{expr}}` with `{expr}` in attribute values on `:` prefixed attributes.
459/// Returns bytes consumed on success, or `None` if no conversion needed.
460fn convert_complex_attrs(tag_str: &str, result: &mut String) -> Option<usize> {
461    // Find the closing '>' outside of quoted attribute values
462    let close = find_tag_close(tag_str)?;
463    let tag_content = &tag_str[..=close];
464
465    // Quick check: does this tag have both a colon-prefixed attribute and `{{`?
466    if !tag_content.contains(":") || !tag_content.contains("{{") {
467        return None;
468    }
469
470    // Process the tag character by character, converting {{}} to {} in :attr values
471    let mut converted = String::with_capacity(tag_content.len());
472    let tag_bytes = tag_content.as_bytes();
473    let tag_len = tag_bytes.len();
474    let mut j = 0;
475    let mut in_colon_attr_value = false;
476
477    while j < tag_len {
478        if !in_colon_attr_value {
479            // Look for :attrname="
480            if tag_bytes[j] == b':' && j > 0 && is_whitespace(tag_bytes[j - 1]) {
481                // Found a colon-prefixed attribute, scan to the ="
482                let attr_start = j;
483                j += 1;
484                // Skip attribute name
485                while j < tag_len && tag_bytes[j] != b'=' && !is_whitespace(tag_bytes[j]) {
486                    j += 1;
487                }
488                if j < tag_len && tag_bytes[j] == b'=' {
489                    j += 1;
490                    if j < tag_len && tag_bytes[j] == b'"' {
491                        // Push everything up to and including the opening quote
492                        converted.push_str(&tag_content[attr_start..=j]);
493                        j += 1;
494                        in_colon_attr_value = true;
495                        continue;
496                    }
497                }
498                // Not a valid :attr="..." pattern, output as-is
499                converted.push_str(&tag_content[attr_start..j]);
500                continue;
501            }
502            j = push_char_at(tag_content, j, &mut converted);
503        } else {
504            // Inside a :attr value — convert {{expr}} to {expr}
505            if tag_bytes[j] == b'"' {
506                // End of attribute value
507                converted.push('"');
508                j += 1;
509                in_colon_attr_value = false;
510            } else if j + 1 < tag_len && tag_bytes[j] == b'{' && tag_bytes[j + 1] == b'{' {
511                // Found {{ — find matching }}
512                let expr_start = j + 2;
513                if let Some(end_offset) = tag_content[expr_start..].find("}}") {
514                    let expr = &tag_content[expr_start..expr_start + end_offset];
515                    converted.push('{');
516                    converted.push_str(expr);
517                    converted.push('}');
518                    j = expr_start + end_offset + 2;
519                } else {
520                    // No matching }}, output as-is
521                    converted.push('{');
522                    converted.push('{');
523                    j += 2;
524                }
525            } else {
526                j = push_char_at(tag_content, j, &mut converted);
527            }
528        }
529    }
530
531    result.push_str(&converted);
532    Some(close + 1)
533}
534
535/// Push the single UTF-8 character starting at `pos` in `input` into `out`,
536/// returning the byte index immediately after it.
537fn push_char_at(input: &str, pos: usize, out: &mut String) -> usize {
538    let bytes = input.as_bytes();
539    if bytes[pos].is_ascii() {
540        out.push(bytes[pos] as char);
541        pos + 1
542    } else {
543        let mut end = pos + 1;
544        while end < bytes.len() && !input.is_char_boundary(end) {
545            end += 1;
546        }
547        out.push_str(&input[pos..end]);
548        end
549    }
550}
551
552/// Check if a byte is ASCII whitespace.
553fn is_whitespace(b: u8) -> bool {
554    b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'
555}
556
557/// Collapse whitespace-only text between `>` and `<` to eliminate extra DOM
558/// text nodes that would shift element indices during hydration.
559/// This ensures the f-template DOM structure matches the minified DSD output.
560fn minify_inter_tag_whitespace(input: &str) -> String {
561    let mut result = String::with_capacity(input.len());
562    let bytes = input.as_bytes();
563    let len = bytes.len();
564    let mut i = 0;
565
566    while i < len {
567        if bytes[i] == b'>' && i + 1 < len {
568            result.push('>');
569            i += 1;
570            // Skip whitespace-only content between > and <
571            let ws_start = i;
572            while i < len && is_whitespace(bytes[i]) {
573                i += 1;
574            }
575            // If we hit '<', the whitespace was inter-tag — drop it
576            // If we hit non-'<', it's meaningful text — keep it
577            if i >= len || bytes[i] != b'<' {
578                // Keep the whitespace (it's before text content)
579                result.push_str(&input[ws_start..i]);
580            }
581        } else {
582            i = push_char_at(input, i, &mut result);
583        }
584    }
585
586    result
587}
588
589/// Strip `shadowrootmode` attribute from a `<template ...>` opening tag.
590/// Returns `Some(bytes_consumed)` if a `<template` tag was found and processed.
591fn strip_shadowrootmode(tag_str: &str, result: &mut String) -> Option<usize> {
592    // Find the closing '>' outside of quoted attribute values
593    let close = find_tag_close(tag_str)?;
594    let tag_content = &tag_str[..=close];
595
596    // Only process if this tag contains shadowrootmode
597    if !tag_content.contains("shadowrootmode") {
598        return None;
599    }
600
601    // Rebuild the tag without the shadowrootmode attribute
602    result.push_str("<template");
603    let attr_start = "<template".len();
604    let inner = &tag_content[attr_start..close];
605
606    // Scan through the attributes, skipping shadowrootmode
607    let inner_bytes = inner.as_bytes();
608    let inner_len = inner_bytes.len();
609    let mut j = 0;
610    while j < inner_len {
611        // Skip whitespace
612        if is_whitespace(inner_bytes[j]) {
613            j += 1;
614            continue;
615        }
616
617        // Find the end of this attribute (name="value" or just name)
618        let attr_begin = j;
619        // Find '=' or whitespace or end
620        while j < inner_len && inner_bytes[j] != b'=' && !is_whitespace(inner_bytes[j]) {
621            j += 1;
622        }
623        let attr_name = &inner[attr_begin..j];
624
625        // If there's a '=', consume the value
626        let mut attr_end = j;
627        if j < inner_len && inner_bytes[j] == b'=' {
628            j += 1; // skip '='
629            if j < inner_len && inner_bytes[j] == b'"' {
630                j += 1; // skip opening quote
631                while j < inner_len && inner_bytes[j] != b'"' {
632                    j += 1;
633                }
634                if j < inner_len {
635                    j += 1; // skip closing quote
636                }
637            } else {
638                // Unquoted value
639                while j < inner_len && !is_whitespace(inner_bytes[j]) {
640                    j += 1;
641                }
642            }
643            attr_end = j;
644        }
645
646        // Skip the shadowrootmode attribute entirely
647        if attr_name == "shadowrootmode" {
648            continue;
649        }
650
651        // Keep this attribute
652        result.push(' ');
653        result.push_str(&inner[attr_begin..attr_end]);
654    }
655
656    if tag_content.ends_with("/>") {
657        result.push_str("/>");
658    } else {
659        result.push('>');
660    }
661
662    Some(close + 1)
663}
664
665#[cfg(test)]
666mod tests {
667    #![allow(clippy::disallowed_methods)]
668
669    use super::*;
670    fn make_component(tag: &str, html: &str, css: Option<&str>) -> Component {
671        Component {
672            tag_name: tag.to_string(),
673            html_content: html.to_string(),
674            css_content: css.map(ToString::to_string),
675            css_definitions: Vec::new(),
676            css_fallback_chains: Vec::new(),
677            is_client_owned: false,
678        }
679    }
680
681    // --- classify_attribute ---
682
683    #[test]
684    fn skip_event_binding() {
685        let mut plugin = FastV3ParserPlugin::new();
686        assert_eq!(
687            plugin.classify_attribute("@click"),
688            AttributeAction::SkipAndCountBinding
689        );
690        assert_eq!(
691            plugin.classify_attribute("@input"),
692            AttributeAction::SkipAndCountBinding
693        );
694        assert_eq!(
695            plugin.classify_attribute("@custom-event"),
696            AttributeAction::SkipAndCountBinding
697        );
698    }
699
700    #[test]
701    fn skip_f_ref() {
702        let mut plugin = FastV3ParserPlugin::new();
703        assert_eq!(
704            plugin.classify_attribute("f-ref"),
705            AttributeAction::SkipAndCountBinding
706        );
707    }
708
709    #[test]
710    fn skip_f_slotted() {
711        let mut plugin = FastV3ParserPlugin::new();
712        assert_eq!(
713            plugin.classify_attribute("f-slotted"),
714            AttributeAction::SkipAndCountBinding
715        );
716    }
717
718    #[test]
719    fn skip_f_children() {
720        let mut plugin = FastV3ParserPlugin::new();
721        assert_eq!(
722            plugin.classify_attribute("f-children"),
723            AttributeAction::SkipAndCountBinding
724        );
725    }
726
727    #[test]
728    fn do_not_skip_normal_attributes() {
729        let mut plugin = FastV3ParserPlugin::new();
730        assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
731        assert_eq!(plugin.classify_attribute("id"), AttributeAction::Keep);
732        assert_eq!(
733            plugin.classify_attribute("data-value"),
734            AttributeAction::Keep
735        );
736        assert_eq!(plugin.classify_attribute(":title"), AttributeAction::Keep);
737        assert_eq!(plugin.classify_attribute("f-other"), AttributeAction::Keep);
738    }
739
740    // --- finish_element ---
741
742    #[test]
743    fn element_parsed_zero_count_returns_none() {
744        let mut plugin = FastV3ParserPlugin::new();
745        assert!(plugin.finish_element(0).is_none());
746    }
747
748    #[test]
749    fn element_parsed_nonzero_count_returns_le_bytes() {
750        let mut plugin = FastV3ParserPlugin::new();
751        let data = plugin.finish_element(3);
752        assert!(data.is_some());
753        let bytes = data.as_deref().unwrap_or_default();
754        assert_eq!(bytes, &3u32.to_le_bytes());
755    }
756
757    #[test]
758    fn element_parsed_large_count() {
759        let mut plugin = FastV3ParserPlugin::new();
760        let data = plugin.finish_element(256);
761        assert!(data.is_some());
762        let bytes = data.as_deref().unwrap_or_default();
763        assert_eq!(bytes, &256u32.to_le_bytes());
764    }
765
766    #[test]
767    fn component_template_simple_component() {
768        let mut plugin = FastV3ParserPlugin::new();
769        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
770        plugin
771            .register_component_template(
772                "my-comp",
773                &comp,
774                r#"<template><link rel="stylesheet" href="my-comp.css"><div>hello</div></template>"#,
775            )
776            .unwrap();
777
778        let templates = plugin.take_component_templates();
779        assert_eq!(templates.len(), 1);
780        let name = &templates[0].tag_name;
781        let html = &templates[0].template;
782        assert_eq!(name, "my-comp");
783        assert!(html.contains("<f-template name=\"my-comp\">"));
784        assert!(html.contains("</f-template>"));
785        assert!(html.contains("<template>"));
786        assert!(html.contains("</template>"));
787        assert!(html.contains("<link rel=\"stylesheet\" href=\"my-comp.css\">"));
788        assert!(html.contains("<div>hello</div>"));
789    }
790
791    #[test]
792    fn component_template_without_css() {
793        let mut plugin = FastV3ParserPlugin::new();
794        let comp = make_component("no-css", "<span>text</span>", None);
795        plugin
796            .register_component_template("no-css", &comp, &comp.html_content)
797            .unwrap();
798
799        let templates = plugin.take_component_templates();
800        assert_eq!(templates.len(), 1);
801        let name = &templates[0].tag_name;
802        let html = &templates[0].template;
803        assert_eq!(name, "no-css");
804        assert!(html.contains("<f-template name=\"no-css\">"));
805        assert!(!html.contains("<link rel=\"stylesheet\""));
806        assert!(html.contains("<span>text</span>"));
807    }
808
809    #[test]
810    fn component_template_css_strategy_style() {
811        let mut plugin = FastV3ParserPlugin::new();
812        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
813        plugin
814            .register_component_template(
815                "my-comp",
816                &comp,
817                r#"<template><style>div { color: red; }</style><div>hello</div></template>"#,
818            )
819            .unwrap();
820
821        let templates = plugin.take_component_templates();
822        assert_eq!(templates.len(), 1);
823        let html = &templates[0].template;
824        assert!(
825            html.contains("<style>div { color: red; }</style>"),
826            "Style strategy should inline CSS, got: {html}"
827        );
828        assert!(
829            !html.contains("<link"),
830            "Style strategy should not emit <link> tags"
831        );
832    }
833
834    #[test]
835    fn component_template_css_strategy_module() {
836        let mut plugin = FastV3ParserPlugin::new();
837        let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
838        plugin
839            .register_component_template(
840                "my-comp",
841                &comp,
842                r#"<template shadowrootadoptedstylesheets="my-comp"><div>hello</div></template>"#,
843            )
844            .unwrap();
845
846        let templates = plugin.take_component_templates();
847        assert_eq!(templates.len(), 1);
848        let html = &templates[0].template;
849
850        // f-template should NOT contain the CSS module — that is added
851        // by the handler at SSR (inline) and SPA-partial time.
852        assert!(
853            !html.contains(r#"<style type="module""#),
854            "Legacy CSS module shape should not be baked into f-template: {html}"
855        );
856        assert!(
857            !html.contains(r#"<script type="importmap""#),
858            "CSS module importmap should not be baked into f-template: {html}"
859        );
860        // shadowrootadoptedstylesheets on the inner template
861        assert!(
862            html.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
863            "Module strategy should add shadowrootadoptedstylesheets, got: {html}"
864        );
865        // No inline <style> or <link> inside the template
866        assert!(
867            !html.contains("<style>div"),
868            "Module strategy should not have inline <style> inside template"
869        );
870        assert!(
871            !html.contains("<link"),
872            "Module strategy should not emit <link> tags"
873        );
874    }
875
876    #[test]
877    fn component_template_css_strategy_module_no_css() {
878        let mut plugin = FastV3ParserPlugin::new();
879        let comp = make_component("my-comp", "<div>hello</div>", None);
880        plugin
881            .register_component_template(
882                "my-comp",
883                &comp,
884                r#"<template><div>hello</div></template>"#,
885            )
886            .unwrap();
887
888        let templates = plugin.take_component_templates();
889        assert_eq!(templates.len(), 1);
890        let html = &templates[0].template;
891        assert!(
892            !html.contains("shadowrootadoptedstylesheets"),
893            "No CSS = no shadowrootadoptedstylesheets, got: {html}"
894        );
895        assert!(
896            !html.contains("<style"),
897            "No CSS = no style tag, got: {html}"
898        );
899    }
900
901    #[test]
902    fn component_template_multiple_components() {
903        let mut plugin = FastV3ParserPlugin::new();
904        let comp1 = make_component("comp-a", "<div>A</div>", None);
905        let comp2 = make_component("comp-b", "<div>B</div>", Some("b { }"));
906        plugin
907            .register_component_template("comp-a", &comp1, &comp1.html_content)
908            .unwrap();
909        plugin
910            .register_component_template("comp-b", &comp2, &comp2.html_content)
911            .unwrap();
912
913        let templates = plugin.take_component_templates();
914        assert_eq!(templates.len(), 2);
915
916        let names: Vec<&str> = templates
917            .iter()
918            .map(|artifact| artifact.tag_name.as_str())
919            .collect();
920        assert!(names.contains(&"comp-a"));
921        assert!(names.contains(&"comp-b"));
922
923        for artifact in &templates {
924            assert!(artifact.template.contains("<f-template name="));
925        }
926    }
927
928    #[test]
929    fn component_template_deduplicates_same_component() {
930        let mut plugin = FastV3ParserPlugin::new();
931        let comp = make_component(
932            "my-button",
933            "<button><slot></slot></button>",
934            Some(".btn{}"),
935        );
936
937        // Simulate the same component encountered multiple times
938        // (e.g., <my-button> used in todo-app, todo-item, etc.)
939        plugin
940            .register_component_template("my-button", &comp, &comp.html_content)
941            .unwrap();
942        plugin
943            .register_component_template("my-button", &comp, &comp.html_content)
944            .unwrap();
945        plugin
946            .register_component_template("my-button", &comp, &comp.html_content)
947            .unwrap();
948
949        let templates = plugin.take_component_templates();
950        assert_eq!(
951            templates.len(),
952            1,
953            "Expected exactly 1 template for my-button, got {}",
954            templates.len()
955        );
956        assert_eq!(templates[0].tag_name, "my-button");
957    }
958
959    #[test]
960    fn component_template_deduplicates_mixed_components() {
961        let mut plugin = FastV3ParserPlugin::new();
962        let btn = make_component("my-button", "<button><slot></slot></button>", None);
963        let card = make_component("my-card", "<div><slot></slot></div>", Some(".card{}"));
964
965        // my-button used in two different parent templates, my-card used once
966        plugin
967            .register_component_template("my-button", &btn, &btn.html_content)
968            .unwrap();
969        plugin
970            .register_component_template("my-card", &card, &card.html_content)
971            .unwrap();
972        plugin
973            .register_component_template("my-button", &btn, &btn.html_content)
974            .unwrap();
975
976        let templates = plugin.take_component_templates();
977        assert_eq!(templates.len(), 2);
978
979        let button_count = templates
980            .iter()
981            .filter(|artifact| artifact.tag_name == "my-button")
982            .count();
983        let card_count = templates
984            .iter()
985            .filter(|artifact| artifact.tag_name == "my-card")
986            .count();
987        assert_eq!(button_count, 1);
988        assert_eq!(card_count, 1);
989    }
990
991    // --- FAST syntax conversion ---
992
993    #[test]
994    fn convert_if_to_f_when() {
995        let input = r#"<if condition="isComplete"><span>Done</span></if>"#;
996        let output = convert_btr_to_fast(input);
997        assert_eq!(
998            output,
999            r#"<f-when value="{{isComplete}}"><span>Done</span></f-when>"#
1000        );
1001    }
1002
1003    #[test]
1004    fn convert_if_with_greater_than() {
1005        let input = r#"<if condition="vara > 2">Over two</if>"#;
1006        let output = convert_btr_to_fast(input);
1007        assert_eq!(output, r#"<f-when value="{{vara > 2}}">Over two</f-when>"#);
1008    }
1009
1010    #[test]
1011    fn convert_if_with_greater_than_or_equal() {
1012        let input = r#"<if condition="vara >= 2">Two and over</if>"#;
1013        let output = convert_btr_to_fast(input);
1014        assert_eq!(
1015            output,
1016            r#"<f-when value="{{vara >= 2}}">Two and over</f-when>"#
1017        );
1018    }
1019
1020    #[test]
1021    fn convert_if_with_less_than() {
1022        let input = r#"<if condition="x < 10">small</if>"#;
1023        let output = convert_btr_to_fast(input);
1024        assert_eq!(output, r#"<f-when value="{{x < 10}}">small</f-when>"#);
1025    }
1026
1027    #[test]
1028    fn convert_for_to_f_repeat() {
1029        let input = r#"<for each="tag in tags"><span>{{tag}}</span></for>"#;
1030        let output = convert_btr_to_fast(input);
1031        assert_eq!(
1032            output,
1033            r#"<f-repeat value="{{tag in tags}}"><span>{{tag}}</span></f-repeat>"#
1034        );
1035    }
1036
1037    #[test]
1038    fn convert_nested_if_and_for() {
1039        let input = r#"<if condition="show"><for each="x in items"><p>{{x}}</p></for></if>"#;
1040        let output = convert_btr_to_fast(input);
1041        assert_eq!(
1042            output,
1043            r#"<f-when value="{{show}}"><f-repeat value="{{x in items}}"><p>{{x}}</p></f-repeat></f-when>"#
1044        );
1045    }
1046
1047    #[test]
1048    fn convert_complex_attr_double_braces() {
1049        let input = r#"<div :data="{{config}}"></div>"#;
1050        let output = convert_btr_to_fast(input);
1051        assert_eq!(output, r#"<div :data="{config}"></div>"#);
1052    }
1053
1054    #[test]
1055    fn convert_multiple_complex_attrs() {
1056        let input = r#"<div :title="{{name}}" :data-id="{{id}}"></div>"#;
1057        let output = convert_btr_to_fast(input);
1058        assert_eq!(output, r#"<div :title="{name}" :data-id="{id}"></div>"#);
1059    }
1060
1061    #[test]
1062    fn no_conversion_for_text_double_braces() {
1063        // Double braces in text content (not in :attr) should stay as-is
1064        let input = "<span>{{greeting}}</span>";
1065        let output = convert_btr_to_fast(input);
1066        assert_eq!(output, "<span>{{greeting}}</span>");
1067    }
1068
1069    #[test]
1070    fn no_conversion_for_regular_attr() {
1071        // Double braces in non-colon attributes should stay as-is
1072        let input = r#"<div title="{{name}}"></div>"#;
1073        let output = convert_btr_to_fast(input);
1074        assert_eq!(output, r#"<div title="{{name}}"></div>"#);
1075    }
1076
1077    #[test]
1078    fn component_template_with_btr_conversion() {
1079        let mut plugin = FastV3ParserPlugin::new();
1080        let html = r#"<div><if condition="visible"><span>hi</span></if></div>"#;
1081        let comp = make_component("my-widget", html, None);
1082        plugin
1083            .register_component_template("my-widget", &comp, &comp.html_content)
1084            .unwrap();
1085
1086        let templates = plugin.take_component_templates();
1087        assert_eq!(templates.len(), 1);
1088        let name = &templates[0].tag_name;
1089        let result = &templates[0].template;
1090        assert_eq!(name, "my-widget");
1091
1092        assert!(result.contains("<f-when value=\"{{visible}}\">"));
1093        assert!(result.contains("</f-when>"));
1094        assert!(!result.contains("<if condition="));
1095        assert!(!result.contains("</if>"));
1096    }
1097
1098    #[test]
1099    fn passthrough_normal_html() {
1100        let input = r#"<div class="container"><p>Hello</p></div>"#;
1101        let output = convert_btr_to_fast(input);
1102        assert_eq!(output, input);
1103    }
1104
1105    #[test]
1106    fn outlet_kept_as_marker_in_f_template() {
1107        for input in [
1108            r#"<div><outlet /></div>"#,
1109            r#"<div><outlet></outlet></div>"#,
1110        ] {
1111            let output = convert_btr_to_fast(input);
1112            assert_eq!(output, r#"<div><outlet></outlet></div>"#);
1113        }
1114    }
1115
1116    #[test]
1117    fn outlet_marker_in_component_template() {
1118        let mut plugin = FastV3ParserPlugin::new();
1119        let html =
1120            r#"<template shadowrootmode="open"><h1>Title</h1><main><outlet /></main></template>"#;
1121        let comp = make_component("my-shell", html, None);
1122        plugin
1123            .register_component_template("my-shell", &comp, &comp.html_content)
1124            .unwrap();
1125
1126        let templates = plugin.take_component_templates();
1127        let result = &templates[0].template;
1128        assert!(
1129            result.contains("<outlet></outlet>"),
1130            "outlet should be kept as marker in f-template: {result}"
1131        );
1132    }
1133
1134    #[test]
1135    fn strip_shadowrootmode_from_template() {
1136        let input = r#"<template shadowrootmode="open"><div>Hello</div></template>"#;
1137        let output = convert_btr_to_fast(input);
1138        assert_eq!(output, "<template><div>Hello</div></template>");
1139    }
1140
1141    #[test]
1142    fn strip_shadowrootmode_preserves_other_attrs() {
1143        let input =
1144            r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>Hi</div></template>"#;
1145        let output = convert_btr_to_fast(input);
1146        assert!(output.contains(r#"@click="{onClick(e)}""#));
1147        assert!(!output.contains("shadowrootmode"));
1148        assert!(output.starts_with("<template "));
1149    }
1150
1151    #[test]
1152    fn no_strip_when_no_shadowrootmode() {
1153        let input = r#"<template foo="bar"><div>Hi</div></template>"#;
1154        let output = convert_btr_to_fast(input);
1155        assert_eq!(output, input);
1156    }
1157
1158    #[test]
1159    fn component_template_strips_shadowrootmode_from_f_template() {
1160        let mut plugin = FastV3ParserPlugin::new();
1161        let comp = make_component(
1162            "my-comp",
1163            r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>{{title}}</div></template>"#,
1164            Some("div { color: red; }"),
1165        );
1166        plugin
1167            .register_component_template("my-comp", &comp, &comp.html_content)
1168            .unwrap();
1169        let templates = plugin.take_component_templates();
1170        assert_eq!(templates.len(), 1);
1171        let name = &templates[0].tag_name;
1172        let html = &templates[0].template;
1173        assert_eq!(name, "my-comp");
1174        // f-template content should NOT have shadowrootmode
1175        assert!(!html.contains("shadowrootmode"));
1176        // But should keep @click (framework attr kept in f-template mode)
1177        assert!(html.contains("@click"));
1178        // Should have the converted content
1179        assert!(html.contains("{{title}}"));
1180    }
1181
1182    #[test]
1183    fn test_complex_attribute_normalization() {
1184        // All spacing variants should convert {{}} to {} in :attr values,
1185        // preserving any whitespace within the expression.
1186        let cases = [
1187            (
1188                r#"<div :foo="{{value}}"></div>"#,
1189                r#"<div :foo="{value}"></div>"#,
1190            ),
1191            (
1192                r#"<div :foo="{{ value}}"></div>"#,
1193                r#"<div :foo="{ value}"></div>"#,
1194            ),
1195            (
1196                r#"<div :foo="{{value }}"></div>"#,
1197                r#"<div :foo="{value }"></div>"#,
1198            ),
1199            (
1200                r#"<div :foo="{{ value }}"></div>"#,
1201                r#"<div :foo="{ value }"></div>"#,
1202            ),
1203        ];
1204        for (input, expected) in &cases {
1205            let output = convert_btr_to_fast(input);
1206            assert_eq!(&output, expected, "normalization failed for input: {input}");
1207        }
1208    }
1209
1210    #[test]
1211    fn test_non_js_component_inner_template() {
1212        let mut plugin = FastV3ParserPlugin::new();
1213        // A JS component whose HTML contains a non-JS component using
1214        // declarative shadow DOM (template with shadowrootmode).
1215        let html = r#"<div><child-comp><template shadowrootmode="open"><p>inner</p></template></child-comp></div>"#;
1216        let comp = make_component("parent-comp", html, None);
1217        plugin
1218            .register_component_template("parent-comp", &comp, &comp.html_content)
1219            .unwrap();
1220
1221        let templates = plugin.take_component_templates();
1222        assert_eq!(templates.len(), 1);
1223        let name = &templates[0].tag_name;
1224        let output = &templates[0].template;
1225        assert_eq!(name, "parent-comp");
1226
1227        assert!(output.contains("<f-template name=\"parent-comp\">"));
1228        // shadowrootmode must be stripped from the inner non-JS component's template
1229        assert!(!output.contains("shadowrootmode"));
1230        // The inner template structure should be preserved (without shadowrootmode)
1231        assert!(output.contains("<child-comp><template><p>inner</p></template></child-comp>"));
1232    }
1233
1234    #[test]
1235    fn route_tags_converted_to_f_route() {
1236        let input = r#"<route path="/home" name="home" exact><span>Home</span></route>"#;
1237        let output = convert_btr_to_fast(input);
1238        assert!(output.starts_with("<webui-route"));
1239        assert!(output.contains(r#"path="/home""#));
1240        assert!(output.contains(r#"name="home""#));
1241        assert!(output.contains(" exact"));
1242        assert!(output.contains("style=\"display:none\""));
1243        assert!(output.contains("<span>Home</span>"));
1244        assert!(output.ends_with("</webui-route>"));
1245    }
1246
1247    #[test]
1248    fn f_route_tags_pass_through() {
1249        // <webui-route> tags emitted by the parser should pass through unchanged
1250        let input = r#"<webui-route path="/" name="dashboard" component="cb-page-dashboard" exact style="display:none"></webui-route>"#;
1251        let output = convert_btr_to_fast(input);
1252        assert_eq!(output, input);
1253    }
1254
1255    // --- End-to-end f-template regression tests ---
1256    // Verify that generated f-template blocks always use FAST syntax
1257    // (<f-when>/<f-repeat>) instead of webui syntax (<if>/<for>).
1258
1259    #[test]
1260    fn ftemplate_for_loop_converted_to_f_repeat() {
1261        let mut plugin = FastV3ParserPlugin::new();
1262        let html = r#"<ul><for each="item in items"><li>{{item.name}}</li></for></ul>"#;
1263        let comp = make_component("my-list", html, None);
1264        plugin
1265            .register_component_template("my-list", &comp, &comp.html_content)
1266            .unwrap();
1267
1268        let templates = plugin.take_component_templates();
1269        let result = &templates[0].template;
1270
1271        assert!(
1272            result.contains("<f-repeat value=\"{{item in items}}\">"),
1273            "f-template should use <f-repeat>, got: {result}"
1274        );
1275        assert!(
1276            result.contains("</f-repeat>"),
1277            "f-template should close with </f-repeat>, got: {result}"
1278        );
1279        assert!(
1280            !result.contains("<for "),
1281            "f-template should NOT contain <for>, got: {result}"
1282        );
1283    }
1284
1285    #[test]
1286    fn ftemplate_shadow_dom_strips_shadowroot_and_converts_directives() {
1287        let mut plugin = FastV3ParserPlugin::new();
1288        let html = r#"<template shadowrootmode="open"><div><if condition="visible">Shown</if><for each="x in list"><span>{{x}}</span></for></div></template>"#;
1289        let comp = make_component("my-shadow", html, None);
1290        plugin
1291            .register_component_template("my-shadow", &comp, &comp.html_content)
1292            .unwrap();
1293
1294        let templates = plugin.take_component_templates();
1295        let result = &templates[0].template;
1296
1297        assert!(
1298            result.contains("<f-when value=\"{{visible}}\">"),
1299            "Shadow f-template should contain <f-when>, got: {result}"
1300        );
1301        assert!(
1302            result.contains("<f-repeat value=\"{{x in list}}\">"),
1303            "Shadow f-template should contain <f-repeat>, got: {result}"
1304        );
1305        assert!(
1306            !result.contains("shadowrootmode"),
1307            "Shadow f-template should strip shadowrootmode, got: {result}"
1308        );
1309        assert!(
1310            !result.contains("<if "),
1311            "Shadow f-template should NOT contain <if>, got: {result}"
1312        );
1313        assert!(
1314            !result.contains("<for "),
1315            "Shadow f-template should NOT contain <for>, got: {result}"
1316        );
1317    }
1318
1319    #[test]
1320    fn ftemplate_nested_if_and_for_both_converted() {
1321        let mut plugin = FastV3ParserPlugin::new();
1322        let html =
1323            r#"<div><if condition="show"><for each="x in items"><p>{{x}}</p></for></if></div>"#;
1324        let comp = make_component("my-nested", html, None);
1325        plugin
1326            .register_component_template("my-nested", &comp, &comp.html_content)
1327            .unwrap();
1328
1329        let templates = plugin.take_component_templates();
1330        let result = &templates[0].template;
1331
1332        assert!(
1333            result.contains("<f-when value=\"{{show}}\">"),
1334            "Nested f-template should contain <f-when>, got: {result}"
1335        );
1336        assert!(
1337            result.contains("<f-repeat value=\"{{x in items}}\">"),
1338            "Nested f-template should contain <f-repeat>, got: {result}"
1339        );
1340        assert!(
1341            !result.contains("<if "),
1342            "Nested f-template should NOT contain <if>, got: {result}"
1343        );
1344        assert!(
1345            !result.contains("<for "),
1346            "Nested f-template should NOT contain <for>, got: {result}"
1347        );
1348    }
1349
1350    #[test]
1351    fn convert_btr_preserves_utf8_text_content() {
1352        let input = "<span>✓ passed</span>";
1353        let output = convert_btr_to_fast(input);
1354        assert_eq!(output, "<span>✓ passed</span>");
1355    }
1356
1357    #[test]
1358    fn convert_btr_preserves_utf8_multibyte_chars() {
1359        let input = "<span>✓ yes ✗ no ⭐ star</span>";
1360        let output = convert_btr_to_fast(input);
1361        assert_eq!(output, "<span>✓ yes ✗ no ⭐ star</span>");
1362    }
1363
1364    #[test]
1365    fn convert_btr_preserves_utf8_with_directives() {
1366        let input = r#"<if condition="x"><span>✓</span></if>"#;
1367        let output = convert_btr_to_fast(input);
1368        assert_eq!(output, r#"<f-when value="{{x}}"><span>✓</span></f-when>"#);
1369    }
1370
1371    #[test]
1372    fn minify_preserves_utf8_text_content() {
1373        let input = "<span>✓</span><span>✗</span>";
1374        let output = minify_inter_tag_whitespace(input);
1375        assert_eq!(output, "<span>✓</span><span>✗</span>");
1376    }
1377
1378    #[test]
1379    fn minify_preserves_utf8_between_elements() {
1380        let input = "<div> ✓ </div>";
1381        let output = minify_inter_tag_whitespace(input);
1382        assert_eq!(output, "<div> ✓ </div>");
1383    }
1384
1385    #[test]
1386    fn convert_complex_attrs_preserves_utf8() {
1387        let input = r#"<span :title="{{label}}" data-icon="⭐">text</span>"#;
1388        let output = convert_btr_to_fast(input);
1389        assert!(
1390            output.contains("⭐"),
1391            "UTF-8 star should be preserved: {output}"
1392        );
1393    }
1394}