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