use super::super::{
AttributeAction, AttributeContext, ComponentBuildContext, ComponentProcessing,
ComponentStyleDelivery, ComponentTemplateArtifact, ElementStartContext, ParserPlugin,
ParserPluginArtifacts,
};
use super::v3::{
build_f_template, f_template_style_injection, require_fast_shadow_dom, style_injection_snippet,
FastTemplateStyle,
};
#[cfg(test)]
use crate::component_registry::Component;
use crate::html_parser::{find_element_end, find_tag_close, opening_tag_name};
use crate::{CssLinkOptions, CssStrategy, Result};
use webui_protocol::FastElementData;
#[inline]
fn finish_element(binding_count: u32, reset_child_index: bool) -> Option<Vec<u8>> {
(binding_count > 0).then(|| {
FastElementData { binding_count }
.encode_v2(reset_child_index)
.to_vec()
})
}
struct TrackedComponent {
tag_name: String,
template_html: String,
uses_shadow_dom: bool,
style_injection: Option<String>,
styles_at_end: bool,
}
pub struct FastV2ParserPlugin {
components: Vec<TrackedComponent>,
reset_child_index_after_root: bool,
}
impl FastV2ParserPlugin {
#[must_use]
pub fn new() -> Self {
Self {
components: Vec::new(),
reset_child_index_after_root: false,
}
}
#[must_use]
pub fn take_component_templates(&self) -> Vec<ComponentTemplateArtifact> {
self.components
.iter()
.map(|comp| {
let tmpl = build_f_template(
&comp.tag_name,
&convert_btr_to_fast(&comp.template_html),
&super::shared::hoisted_shadow_options(&comp.template_html),
FastTemplateStyle {
injection: comp.style_injection.as_deref(),
module_specifier: None,
at_end: comp.styles_at_end,
},
);
ComponentTemplateArtifact::template(
comp.tag_name.clone(),
tmpl,
comp.uses_shadow_dom,
)
})
.collect()
}
fn track_component(&mut self, context: ComponentBuildContext<'_>) {
let tag_name = context.component.tag_name.as_str();
if self.components.iter().any(|c| c.tag_name == tag_name) {
return;
}
self.components.push(TrackedComponent {
tag_name: tag_name.to_string(),
template_html: context.template.to_string(),
uses_shadow_dom: context.uses_shadow_dom,
style_injection: context.style.and_then(style_injection_snippet),
styles_at_end: matches!(context.style, Some(ComponentStyleDelivery::Inline { .. })),
});
}
#[cfg(test)]
fn register_component_template(
&mut self,
_tag_name: &str,
component: &Component,
processed_template: &str,
) -> Result<()> {
<Self as ParserPlugin>::component_built(
self,
ComponentBuildContext {
component,
template: processed_template,
uses_shadow_dom: true,
style: None,
},
)
}
#[cfg(test)]
fn classify_attribute(&mut self, name: &str) -> AttributeAction {
<Self as ParserPlugin>::process_attribute(self, AttributeContext { name })
}
#[cfg(test)]
fn finish_element(&mut self, binding_count: u32) -> Option<Vec<u8>> {
<Self as ParserPlugin>::finish_opening_tag(
self,
ElementStartContext {
tag_name: "test-element",
binding_count,
},
)
}
}
impl Default for FastV2ParserPlugin {
fn default() -> Self {
Self::new()
}
}
impl ParserPlugin for FastV2ParserPlugin {
fn component_processing(&self) -> ComponentProcessing {
ComponentProcessing {
source_transform: Some(super::shared::transform_component_source),
process_root_template_attributes: true,
inline_styles_after_content: true,
}
}
fn component_built(&mut self, context: ComponentBuildContext<'_>) -> Result<()> {
require_fast_shadow_dom(context.component.tag_name.as_str(), context.uses_shadow_dom)?;
self.reset_child_index_after_root = true;
self.track_component(context);
Ok(())
}
fn process_attribute(&mut self, context: AttributeContext<'_>) -> AttributeAction {
super::shared::classify_attribute(context.name)
}
fn finish_opening_tag(&mut self, context: ElementStartContext<'_>) -> Option<Vec<u8>> {
let reset_child_index =
self.reset_child_index_after_root && context.tag_name.eq_ignore_ascii_case("template");
if reset_child_index {
self.reset_child_index_after_root = false;
}
finish_element(context.binding_count, reset_child_index)
}
fn finish(self: Box<Self>) -> Result<ParserPluginArtifacts> {
Ok(ParserPluginArtifacts::ComponentTemplates(
self.take_component_templates(),
))
}
}
pub fn generate_f_template(
tag_name: &str,
html_content: &str,
css_content: Option<&str>,
css_strategy: CssStrategy,
) -> String {
generate_f_template_with_css_options(
tag_name,
html_content,
css_content,
css_strategy,
&CssLinkOptions::default(),
)
}
#[cfg(test)]
fn generate_f_template_from_processed(tag_name: &str, processed_template: &str) -> String {
build_f_template(
tag_name,
&convert_btr_to_fast(processed_template),
&super::shared::hoisted_shadow_options(processed_template),
FastTemplateStyle::default(),
)
}
pub fn generate_f_template_with_css_options(
tag_name: &str,
html_content: &str,
css_content: Option<&str>,
css_strategy: CssStrategy,
css_link_options: &CssLinkOptions,
) -> String {
let (css_injection, module_specifier) =
f_template_style_injection(tag_name, css_content, css_strategy, css_link_options);
build_f_template(
tag_name,
&convert_btr_to_fast(html_content),
&super::shared::hoisted_shadow_options(html_content),
FastTemplateStyle {
injection: css_injection.as_deref(),
module_specifier,
at_end: css_strategy == CssStrategy::Style,
},
)
}
fn convert_btr_to_fast(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let bytes = input.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] == b'<' {
if let Some(consumed) = try_convert_tag(input, i, &mut result) {
i += consumed;
continue;
}
}
i = push_char_at(input, i, &mut result);
}
result
}
fn try_convert_tag(input: &str, pos: usize, result: &mut String) -> Option<usize> {
let remaining = &input[pos..];
if remaining.starts_with("</if>") {
result.push_str("</f-when>");
return Some(5);
}
if remaining.starts_with("</for>") {
result.push_str("</f-repeat>");
return Some(6);
}
if remaining.starts_with("</route>") {
result.push_str("</webui-route>");
return Some(8);
}
if let Some(consumed) = find_element_end(remaining, "outlet") {
result.push_str("<outlet></outlet>");
return Some(consumed);
}
if starts_with_tag_name(remaining, "if") {
if let Some(consumed) = convert_if_tag(remaining, result) {
return Some(consumed);
}
}
if starts_with_tag_name(remaining, "for") {
if let Some(consumed) = convert_for_tag(remaining, result) {
return Some(consumed);
}
}
if starts_with_tag_name(remaining, "route") {
if let Some(consumed) = convert_route_tag(remaining, result) {
return Some(consumed);
}
}
if remaining.starts_with("<") {
if starts_with_tag_name(remaining, "template") {
if let Some(consumed) = super::shared::strip_hoisted_shadow_options(remaining, result) {
return Some(consumed);
}
}
if let Some(consumed) = convert_complex_attrs(remaining, result) {
return Some(consumed);
}
}
None
}
fn starts_with_tag_name(s: &str, name: &str) -> bool {
opening_tag_name(s).is_some_and(|tag_name| tag_name.eq_ignore_ascii_case(name))
}
fn convert_if_tag(tag_str: &str, result: &mut String) -> Option<usize> {
let close = find_tag_close(tag_str)?;
let tag_content = &tag_str[..=close];
let attr_value = extract_attribute_value(tag_content, "condition")?;
result.push_str("<f-when value=\"{{");
result.push_str(attr_value);
result.push_str("}}\">");
Some(close + 1)
}
fn convert_for_tag(tag_str: &str, result: &mut String) -> Option<usize> {
let close = find_tag_close(tag_str)?;
let tag_content = &tag_str[..=close];
let attr_value = extract_attribute_value(tag_content, "each")?;
result.push_str("<f-repeat value=\"{{");
result.push_str(attr_value);
result.push_str("}}\">");
Some(close + 1)
}
fn convert_route_tag(tag_str: &str, result: &mut String) -> Option<usize> {
let close = find_tag_close(tag_str)?;
let is_self_closing = tag_str[..=close].ends_with("/>");
let tag = &tag_str[..=close];
result.push_str("<webui-route");
if let Some(v) = extract_attribute_value(tag, "path") {
result.push_str(" path=\"");
result.push_str(v);
result.push('"');
}
if let Some(v) = extract_attribute_value(tag, "name") {
result.push_str(" name=\"");
result.push_str(v);
result.push('"');
}
if let Some(v) = extract_attribute_value(tag, "component") {
result.push_str(" component=\"");
result.push_str(v);
result.push('"');
}
if let Some(v) = extract_attribute_value(tag, "redirectTo") {
result.push_str(" redirectto=\"");
result.push_str(v);
result.push('"');
}
if tag.contains(" exact") {
result.push_str(" exact");
}
if tag.contains(" layout") {
result.push_str(" layout");
}
if let Some(v) = extract_attribute_value(tag, "query") {
result.push_str(" query=\"");
result.push_str(v);
result.push('"');
}
result.push_str(" style=\"display:none\"");
if is_self_closing {
result.push_str("></webui-route>");
} else {
result.push('>');
}
Some(close + 1)
}
fn extract_attribute_value<'a>(tag: &'a str, attr_name: &str) -> Option<&'a str> {
let mut search_from = 0;
loop {
let attr_pos = tag[search_from..].find(attr_name)?;
let abs_pos = search_from + attr_pos;
if abs_pos > 0 {
let prev = tag.as_bytes()[abs_pos - 1];
if prev != b' ' && prev != b'\t' && prev != b'\n' && prev != b'\r' {
search_from = abs_pos + attr_name.len();
continue;
}
}
let after_name = &tag[abs_pos + attr_name.len()..];
if !after_name.starts_with('=') {
search_from = abs_pos + attr_name.len();
continue;
}
let after_eq = &after_name[1..];
if after_eq.starts_with('"') {
let value_start = abs_pos + attr_name.len() + 2; let end_quote = tag[value_start..].find('"')?;
return Some(&tag[value_start..value_start + end_quote]);
}
return None;
}
}
fn convert_complex_attrs(tag_str: &str, result: &mut String) -> Option<usize> {
let close = find_tag_close(tag_str)?;
let tag_content = &tag_str[..=close];
if !tag_content.contains(":") || !tag_content.contains("{{") {
return None;
}
let mut converted = String::with_capacity(tag_content.len());
let tag_bytes = tag_content.as_bytes();
let tag_len = tag_bytes.len();
let mut j = 0;
let mut in_colon_attr_value = false;
while j < tag_len {
if !in_colon_attr_value {
if tag_bytes[j] == b':' && j > 0 && is_whitespace(tag_bytes[j - 1]) {
let attr_start = j;
j += 1;
while j < tag_len && tag_bytes[j] != b'=' && !is_whitespace(tag_bytes[j]) {
j += 1;
}
if j < tag_len && tag_bytes[j] == b'=' {
j += 1;
if j < tag_len && tag_bytes[j] == b'"' {
converted.push_str(&tag_content[attr_start..=j]);
j += 1;
in_colon_attr_value = true;
continue;
}
}
converted.push_str(&tag_content[attr_start..j]);
continue;
}
j = push_char_at(tag_content, j, &mut converted);
} else {
if tag_bytes[j] == b'"' {
converted.push('"');
j += 1;
in_colon_attr_value = false;
} else if j + 1 < tag_len && tag_bytes[j] == b'{' && tag_bytes[j + 1] == b'{' {
let expr_start = j + 2;
if let Some(end_offset) = tag_content[expr_start..].find("}}") {
let expr = &tag_content[expr_start..expr_start + end_offset];
converted.push('{');
converted.push_str(expr);
converted.push('}');
j = expr_start + end_offset + 2;
} else {
converted.push('{');
converted.push('{');
j += 2;
}
} else {
j = push_char_at(tag_content, j, &mut converted);
}
}
}
result.push_str(&converted);
Some(close + 1)
}
fn push_char_at(input: &str, pos: usize, out: &mut String) -> usize {
let bytes = input.as_bytes();
if bytes[pos].is_ascii() {
out.push(bytes[pos] as char);
pos + 1
} else {
let mut end = pos + 1;
while end < bytes.len() && !input.is_char_boundary(end) {
end += 1;
}
out.push_str(&input[pos..end]);
end
}
}
fn is_whitespace(b: u8) -> bool {
b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'
}
#[cfg(test)]
mod tests {
#![allow(clippy::disallowed_methods)]
use super::*;
fn make_component(tag: &str, html: &str, css: Option<&str>) -> Component {
Component {
tag_name: tag.to_string(),
html_content: html.to_string(),
css_content: css.map(ToString::to_string),
css_definitions: Vec::new(),
css_fallback_chains: Vec::new(),
is_client_owned: false,
}
}
#[test]
fn skip_event_binding() {
let mut plugin = FastV2ParserPlugin::new();
assert_eq!(
plugin.classify_attribute("@click"),
AttributeAction::SkipAndCountBinding
);
assert_eq!(
plugin.classify_attribute("@input"),
AttributeAction::SkipAndCountBinding
);
assert_eq!(
plugin.classify_attribute("@custom-event"),
AttributeAction::SkipAndCountBinding
);
}
#[test]
fn skip_f_ref() {
let mut plugin = FastV2ParserPlugin::new();
assert_eq!(
plugin.classify_attribute("f-ref"),
AttributeAction::SkipAndCountBinding
);
}
#[test]
fn skip_f_slotted() {
let mut plugin = FastV2ParserPlugin::new();
assert_eq!(
plugin.classify_attribute("f-slotted"),
AttributeAction::SkipAndCountBinding
);
}
#[test]
fn skip_f_children() {
let mut plugin = FastV2ParserPlugin::new();
assert_eq!(
plugin.classify_attribute("f-children"),
AttributeAction::SkipAndCountBinding
);
}
#[test]
fn skip_fast_property_binding() {
let mut plugin = FastV2ParserPlugin::new();
assert_eq!(
plugin.classify_attribute(":title"),
AttributeAction::SkipAndCountBinding
);
assert_eq!(
plugin.classify_attribute(":config"),
AttributeAction::SkipAndCountBinding
);
}
#[test]
fn do_not_skip_normal_attributes() {
let mut plugin = FastV2ParserPlugin::new();
assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
assert_eq!(plugin.classify_attribute("id"), AttributeAction::Keep);
assert_eq!(
plugin.classify_attribute("data-value"),
AttributeAction::Keep
);
assert_eq!(plugin.classify_attribute("f-other"), AttributeAction::Keep);
}
#[test]
fn element_parsed_zero_count_returns_none() {
let mut plugin = FastV2ParserPlugin::new();
assert!(plugin.finish_element(0).is_none());
}
#[test]
fn element_parsed_nonzero_count_returns_le_bytes() {
let mut plugin = FastV2ParserPlugin::new();
let data = plugin.finish_element(3);
assert!(data.is_some());
let bytes = data.as_deref().unwrap_or_default();
assert_eq!(
bytes,
&FastElementData { binding_count: 3 }.encode_v2(false)
);
}
#[test]
fn element_parsed_large_count() {
let mut plugin = FastV2ParserPlugin::new();
let data = plugin.finish_element(256);
assert!(data.is_some());
let bytes = data.as_deref().unwrap_or_default();
assert_eq!(
bytes,
&FastElementData { binding_count: 256 }.encode_v2(false)
);
}
#[test]
fn component_template_simple_component() {
let mut plugin = FastV2ParserPlugin::new();
let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
plugin
.register_component_template(
"my-comp",
&comp,
r#"<template><link rel="stylesheet" href="my-comp.css"><div>hello</div></template>"#,
)
.unwrap();
let templates = plugin.take_component_templates();
assert_eq!(templates.len(), 1);
let name = &templates[0].tag_name;
let html = &templates[0].template;
assert_eq!(name, "my-comp");
assert!(html.contains("<f-template name=\"my-comp\">"));
assert!(html.contains("</f-template>"));
assert!(html.contains("<template>"));
assert!(html.contains("</template>"));
assert!(html.contains("<link rel=\"stylesheet\" href=\"my-comp.css\">"));
assert!(html.contains("<div>hello</div>"));
}
#[test]
fn component_template_without_css() {
let mut plugin = FastV2ParserPlugin::new();
let comp = make_component("no-css", "<span>text</span>", None);
plugin
.register_component_template("no-css", &comp, &comp.html_content)
.unwrap();
let templates = plugin.take_component_templates();
assert_eq!(templates.len(), 1);
let name = &templates[0].tag_name;
let html = &templates[0].template;
assert_eq!(name, "no-css");
assert!(html.contains("<f-template name=\"no-css\">"));
assert!(!html.contains("<link rel=\"stylesheet\""));
assert!(html.contains("<span>text</span>"));
}
#[test]
fn direct_template_generation_skips_leading_comments() {
let html = generate_f_template(
"my-comp",
concat!(
"<!-- Copyright (C) Corporation. All rights reserved. -->\n",
"<template shadowrootmode=\"open\"><h1>Hello</h1></template>",
),
None,
CssStrategy::Link,
);
assert_eq!(html.matches("<template>").count(), 1);
assert!(!html.contains("<!--"));
assert!(html.contains("<template><h1>Hello</h1></template>"));
}
#[test]
fn component_template_css_strategy_style() {
let mut plugin = FastV2ParserPlugin::new();
let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
plugin
.register_component_template(
"my-comp",
&comp,
r#"<template><style>div { color: red; }</style><div>hello</div></template>"#,
)
.unwrap();
let templates = plugin.take_component_templates();
assert_eq!(templates.len(), 1);
let html = &templates[0].template;
assert!(
html.contains("<style>div { color: red; }</style>"),
"Style strategy should inline CSS, got: {html}"
);
assert!(
!html.contains("<link"),
"Style strategy should not emit <link> tags"
);
}
#[test]
fn component_template_css_strategy_module() {
let mut plugin = FastV2ParserPlugin::new();
let comp = make_component("my-comp", "<div>hello</div>", Some("div { color: red; }"));
plugin
.register_component_template(
"my-comp",
&comp,
r#"<template shadowrootadoptedstylesheets="my-comp"><div>hello</div></template>"#,
)
.unwrap();
let templates = plugin.take_component_templates();
assert_eq!(templates.len(), 1);
let html = &templates[0].template;
assert!(
!html.contains(r#"<style type="module""#),
"Legacy CSS module shape should not be baked into f-template: {html}"
);
assert!(
!html.contains(r#"<script type="importmap""#),
"CSS module importmap should not be baked into f-template: {html}"
);
assert!(
html.contains(r#"shadowrootadoptedstylesheets="my-comp""#),
"Module strategy should add shadowrootadoptedstylesheets, got: {html}"
);
assert!(
!html.contains("<style>div"),
"Module strategy should not have inline <style> inside template"
);
assert!(
!html.contains("<link"),
"Module strategy should not emit <link> tags"
);
}
#[test]
fn component_template_css_strategy_module_no_css() {
let mut plugin = FastV2ParserPlugin::new();
let comp = make_component("my-comp", "<div>hello</div>", None);
plugin
.register_component_template(
"my-comp",
&comp,
r#"<template><div>hello</div></template>"#,
)
.unwrap();
let templates = plugin.take_component_templates();
assert_eq!(templates.len(), 1);
let html = &templates[0].template;
assert!(
!html.contains("shadowrootadoptedstylesheets"),
"No CSS = no shadowrootadoptedstylesheets, got: {html}"
);
assert!(
!html.contains("<style"),
"No CSS = no style tag, got: {html}"
);
}
#[test]
fn component_template_multiple_components() {
let mut plugin = FastV2ParserPlugin::new();
let comp1 = make_component("comp-a", "<div>A</div>", None);
let comp2 = make_component("comp-b", "<div>B</div>", Some("b { }"));
plugin
.register_component_template("comp-a", &comp1, &comp1.html_content)
.unwrap();
plugin
.register_component_template("comp-b", &comp2, &comp2.html_content)
.unwrap();
let templates = plugin.take_component_templates();
assert_eq!(templates.len(), 2);
let names: Vec<&str> = templates
.iter()
.map(|artifact| artifact.tag_name.as_str())
.collect();
assert!(names.contains(&"comp-a"));
assert!(names.contains(&"comp-b"));
for artifact in &templates {
assert!(artifact.template.contains("<f-template name="));
}
}
#[test]
fn component_template_deduplicates_same_component() {
let mut plugin = FastV2ParserPlugin::new();
let comp = make_component(
"my-button",
"<button><slot></slot></button>",
Some(".btn{}"),
);
plugin
.register_component_template("my-button", &comp, &comp.html_content)
.unwrap();
plugin
.register_component_template("my-button", &comp, &comp.html_content)
.unwrap();
plugin
.register_component_template("my-button", &comp, &comp.html_content)
.unwrap();
let templates = plugin.take_component_templates();
assert_eq!(
templates.len(),
1,
"Expected exactly 1 template for my-button, got {}",
templates.len()
);
assert_eq!(templates[0].tag_name, "my-button");
}
#[test]
fn component_template_deduplicates_mixed_components() {
let mut plugin = FastV2ParserPlugin::new();
let btn = make_component("my-button", "<button><slot></slot></button>", None);
let card = make_component("my-card", "<div><slot></slot></div>", Some(".card{}"));
plugin
.register_component_template("my-button", &btn, &btn.html_content)
.unwrap();
plugin
.register_component_template("my-card", &card, &card.html_content)
.unwrap();
plugin
.register_component_template("my-button", &btn, &btn.html_content)
.unwrap();
let templates = plugin.take_component_templates();
assert_eq!(templates.len(), 2);
let button_count = templates
.iter()
.filter(|artifact| artifact.tag_name == "my-button")
.count();
let card_count = templates
.iter()
.filter(|artifact| artifact.tag_name == "my-card")
.count();
assert_eq!(button_count, 1);
assert_eq!(card_count, 1);
}
#[test]
fn convert_if_to_f_when() {
let input = r#"<if condition="isComplete"><span>Done</span></if>"#;
let output = convert_btr_to_fast(input);
assert_eq!(
output,
r#"<f-when value="{{isComplete}}"><span>Done</span></f-when>"#
);
}
#[test]
fn convert_if_with_greater_than() {
let input = r#"<if condition="vara > 2">Over two</if>"#;
let output = convert_btr_to_fast(input);
assert_eq!(output, r#"<f-when value="{{vara > 2}}">Over two</f-when>"#);
}
#[test]
fn convert_if_with_greater_than_or_equal() {
let input = r#"<if condition="vara >= 2">Two and over</if>"#;
let output = convert_btr_to_fast(input);
assert_eq!(
output,
r#"<f-when value="{{vara >= 2}}">Two and over</f-when>"#
);
}
#[test]
fn convert_if_with_less_than() {
let input = r#"<if condition="x < 10">small</if>"#;
let output = convert_btr_to_fast(input);
assert_eq!(output, r#"<f-when value="{{x < 10}}">small</f-when>"#);
}
#[test]
fn convert_for_to_f_repeat() {
let input = r#"<for each="tag in tags"><span>{{tag}}</span></for>"#;
let output = convert_btr_to_fast(input);
assert_eq!(
output,
r#"<f-repeat value="{{tag in tags}}"><span>{{tag}}</span></f-repeat>"#
);
}
#[test]
fn convert_nested_if_and_for() {
let input = r#"<if condition="show"><for each="x in items"><p>{{x}}</p></for></if>"#;
let output = convert_btr_to_fast(input);
assert_eq!(
output,
r#"<f-when value="{{show}}"><f-repeat value="{{x in items}}"><p>{{x}}</p></f-repeat></f-when>"#
);
}
#[test]
fn convert_complex_attr_double_braces() {
let input = r#"<div :data="{{config}}"></div>"#;
let output = convert_btr_to_fast(input);
assert_eq!(output, r#"<div :data="{config}"></div>"#);
}
#[test]
fn convert_multiple_complex_attrs() {
let input = r#"<div :title="{{name}}" :data-id="{{id}}"></div>"#;
let output = convert_btr_to_fast(input);
assert_eq!(output, r#"<div :title="{name}" :data-id="{id}"></div>"#);
}
#[test]
fn no_conversion_for_text_double_braces() {
let input = "<span>{{greeting}}</span>";
let output = convert_btr_to_fast(input);
assert_eq!(output, "<span>{{greeting}}</span>");
}
#[test]
fn no_conversion_for_regular_attr() {
let input = r#"<div title="{{name}}"></div>"#;
let output = convert_btr_to_fast(input);
assert_eq!(output, r#"<div title="{{name}}"></div>"#);
}
#[test]
fn component_template_with_btr_conversion() {
let mut plugin = FastV2ParserPlugin::new();
let html = r#"<div><if condition="visible"><span>hi</span></if></div>"#;
let comp = make_component("my-widget", html, None);
plugin
.register_component_template("my-widget", &comp, &comp.html_content)
.unwrap();
let templates = plugin.take_component_templates();
assert_eq!(templates.len(), 1);
let name = &templates[0].tag_name;
let result = &templates[0].template;
assert_eq!(name, "my-widget");
assert!(result.contains("<f-when value=\"{{visible}}\">"));
assert!(result.contains("</f-when>"));
assert!(!result.contains("<if condition="));
assert!(!result.contains("</if>"));
}
#[test]
fn passthrough_normal_html() {
let input = r#"<div class="container"><p>Hello</p></div>"#;
let output = convert_btr_to_fast(input);
assert_eq!(output, input);
}
#[test]
fn outlet_kept_as_marker_in_f_template() {
for input in [
r#"<div><outlet /></div>"#,
r#"<div><outlet></outlet></div>"#,
] {
let output = convert_btr_to_fast(input);
assert_eq!(output, r#"<div><outlet></outlet></div>"#);
}
}
#[test]
fn outlet_marker_in_component_template() {
let mut plugin = FastV2ParserPlugin::new();
let html =
r#"<template shadowrootmode="open"><h1>Title</h1><main><outlet /></main></template>"#;
let comp = make_component("my-shell", html, None);
plugin
.register_component_template("my-shell", &comp, &comp.html_content)
.unwrap();
let templates = plugin.take_component_templates();
let result = &templates[0].template;
assert!(
result.contains("<outlet></outlet>"),
"outlet should be kept as marker in f-template: {result}"
);
}
#[test]
fn strip_shadowrootmode_from_template() {
let input = r#"<template shadowrootmode="open"><div>Hello</div></template>"#;
let output = convert_btr_to_fast(input);
assert_eq!(output, "<template><div>Hello</div></template>");
}
#[test]
fn strips_uppercase_native_shadow_wrapper() {
let input = r#"<TEMPLATE SHADOWROOTMODE="open"><div>Hello</div></TEMPLATE>"#;
let output = convert_btr_to_fast(input);
assert_eq!(output, "<TEMPLATE><div>Hello</div></TEMPLATE>");
}
#[test]
fn rejects_effective_light_dom() {
let mut plugin = FastV2ParserPlugin::new();
let component = make_component("my-card", "<p>content</p>", None);
let error = <FastV2ParserPlugin as ParserPlugin>::component_built(
&mut plugin,
ComponentBuildContext {
component: &component,
template: component.html_content.as_str(),
uses_shadow_dom: false,
style: None,
},
)
.expect_err("FAST cannot mount Light DOM faithfully");
assert!(matches!(
error,
crate::ParserError::Template(ref diagnostic)
if diagnostic.error_code() == Some(crate::diagnostic::codes::FAST_LIGHT_DOM_UNSUPPORTED)
));
}
#[test]
fn strip_shadowrootmode_preserves_other_attrs() {
let input =
r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>Hi</div></template>"#;
let output = convert_btr_to_fast(input);
assert!(output.contains(r#"@click="{onClick(e)}""#));
assert!(!output.contains("shadowrootmode"));
assert!(output.starts_with("<template "));
}
#[test]
fn no_strip_when_no_shadowrootmode() {
let input = r#"<template foo="bar"><div>Hi</div></template>"#;
let output = convert_btr_to_fast(input);
assert_eq!(output, input);
}
#[test]
fn component_template_strips_shadowrootmode_from_f_template() {
let mut plugin = FastV2ParserPlugin::new();
let comp = make_component(
"my-comp",
r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>{{title}}</div></template>"#,
Some("div { color: red; }"),
);
plugin
.register_component_template("my-comp", &comp, &comp.html_content)
.unwrap();
let templates = plugin.take_component_templates();
assert_eq!(templates.len(), 1);
let name = &templates[0].tag_name;
let html = &templates[0].template;
assert_eq!(name, "my-comp");
assert!(html.contains(r#"<f-template name="my-comp" shadowrootmode="open">"#));
assert!(!html.contains("<template shadowrootmode"));
assert!(html.contains("@click"));
assert!(html.contains("{{title}}"));
}
#[test]
fn shadow_options_hoisted_from_inner_template_to_f_template_wrapper() {
let processed = concat!(
r#"<template shadowrootmode="open" shadowrootdelegatesfocus "#,
r#"shadowrootadoptedstylesheets="custom-field" @click="{clickHandler($e)}">"#,
r#"<slot></slot></template>"#,
);
let html = generate_f_template_from_processed("custom-field", processed);
assert!(
html.contains(
r#"<f-template name="custom-field" shadowrootmode="open" shadowrootdelegatesfocus>"#
),
"wrapper must carry hoisted shadow options: {html}"
);
assert!(html.contains(r#"@click="{clickHandler($e)}""#));
assert!(html.contains(r#"shadowrootadoptedstylesheets="custom-field""#));
assert!(
!html.contains("<template shadowrootmode"),
"inner <template> must not keep shadowrootmode: {html}"
);
assert!(
!html.contains("<template shadowrootdelegatesfocus")
&& !html.contains(" shadowrootdelegatesfocus shadowrootadopted"),
"inner <template> must not keep shadowrootdelegatesfocus: {html}"
);
}
#[test]
fn delegatesfocus_only_without_mode_is_hoisted_and_stripped() {
let processed = r#"<template shadowrootdelegatesfocus><slot></slot></template>"#;
let html = generate_f_template_from_processed("custom-field", processed);
assert_eq!(
html.matches("shadowrootdelegatesfocus").count(),
1,
"shadowrootdelegatesfocus must appear exactly once (hoisted, not duplicated): {html}"
);
assert!(
html.contains(r#"<f-template name="custom-field" shadowrootdelegatesfocus>"#),
"wrapper must carry the hoisted option: {html}"
);
assert!(
!html.contains("<template shadowrootdelegatesfocus"),
"inner <template> must not keep shadowrootdelegatesfocus: {html}"
);
}
#[test]
fn clonable_and_serializable_only_are_hoisted_and_stripped() {
let processed =
r#"<template shadowrootclonable shadowrootserializable><slot></slot></template>"#;
let html = generate_f_template_from_processed("custom-field", processed);
for attr in ["shadowrootclonable", "shadowrootserializable"] {
assert_eq!(
html.matches(attr).count(),
1,
"{attr} must appear exactly once (hoisted, not duplicated): {html}"
);
}
assert!(
html.contains(
r#"<f-template name="custom-field" shadowrootclonable shadowrootserializable>"#
),
"wrapper must carry both hoisted options: {html}"
);
assert!(
!html.contains("<template shadowrootclonable")
&& !html.contains("<template shadowrootserializable"),
"inner <template> must not keep either hoisted option: {html}"
);
}
#[test]
fn adoptedstylesheets_only_is_neither_hoisted_nor_stripped() {
let processed =
r#"<template shadowrootadoptedstylesheets="custom-field"><slot></slot></template>"#;
let html = generate_f_template_from_processed("custom-field", processed);
assert!(
html.contains("<f-template name=\"custom-field\">"),
"shadowrootadoptedstylesheets is not a shadow-root-creation option and must stay off the wrapper: {html}"
);
assert!(
html.contains(r#"<template shadowrootadoptedstylesheets="custom-field">"#),
"inner <template> must keep shadowrootadoptedstylesheets: {html}"
);
}
#[test]
fn mixed_shadow_options_each_appear_exactly_once_and_never_on_inner() {
let processed = concat!(
r#"<template shadowrootmode="open" shadowrootdelegatesfocus shadowrootclonable "#,
r#"shadowrootserializable shadowrootadoptedstylesheets="custom-field" "#,
r#"@click="{clickHandler($e)}"><slot></slot></template>"#,
);
let html = generate_f_template_from_processed("custom-field", processed);
assert!(
html.contains(
r#"<f-template name="custom-field" shadowrootmode="open" shadowrootdelegatesfocus shadowrootclonable shadowrootserializable>"#
),
"wrapper must carry every hoisted option exactly once, in order: {html}"
);
assert!(
html.contains(
r#"<template shadowrootadoptedstylesheets="custom-field" @click="{clickHandler($e)}">"#
),
"inner <template> must keep shadowrootadoptedstylesheets and the binding, and nothing hoisted: {html}"
);
for attr in [
"shadowrootmode",
"shadowrootdelegatesfocus",
"shadowrootclonable",
"shadowrootserializable",
] {
assert_eq!(
html.matches(attr).count(),
1,
"{attr} must appear exactly once across the wrapper and inner template: {html}"
);
}
}
#[test]
fn test_complex_attribute_normalization() {
let cases = [
(
r#"<div :foo="{{value}}"></div>"#,
r#"<div :foo="{value}"></div>"#,
),
(
r#"<div :foo="{{ value}}"></div>"#,
r#"<div :foo="{ value}"></div>"#,
),
(
r#"<div :foo="{{value }}"></div>"#,
r#"<div :foo="{value }"></div>"#,
),
(
r#"<div :foo="{{ value }}"></div>"#,
r#"<div :foo="{ value }"></div>"#,
),
];
for (input, expected) in &cases {
let output = convert_btr_to_fast(input);
assert_eq!(&output, expected, "normalization failed for input: {input}");
}
}
#[test]
fn test_non_js_component_inner_template() {
let mut plugin = FastV2ParserPlugin::new();
let html = r#"<div><child-comp><template shadowrootmode="open"><p>inner</p></template></child-comp></div>"#;
let comp = make_component("parent-comp", html, None);
plugin
.register_component_template("parent-comp", &comp, &comp.html_content)
.unwrap();
let templates = plugin.take_component_templates();
assert_eq!(templates.len(), 1);
let name = &templates[0].tag_name;
let output = &templates[0].template;
assert_eq!(name, "parent-comp");
assert!(output.contains("<f-template name=\"parent-comp\">"));
assert!(!output.contains("shadowrootmode"));
assert!(output.contains("<child-comp><template><p>inner</p></template></child-comp>"));
}
#[test]
fn route_tags_converted_to_f_route() {
let input = r#"<route path="/home" name="home" exact><span>Home</span></route>"#;
let output = convert_btr_to_fast(input);
assert!(output.starts_with("<webui-route"));
assert!(output.contains(r#"path="/home""#));
assert!(output.contains(r#"name="home""#));
assert!(output.contains(" exact"));
assert!(output.contains("style=\"display:none\""));
assert!(output.contains("<span>Home</span>"));
assert!(output.ends_with("</webui-route>"));
}
#[test]
fn f_route_tags_pass_through() {
let input = r#"<webui-route path="/" name="dashboard" component="cb-page-dashboard" exact style="display:none"></webui-route>"#;
let output = convert_btr_to_fast(input);
assert_eq!(output, input);
}
#[test]
fn ftemplate_for_loop_converted_to_f_repeat() {
let mut plugin = FastV2ParserPlugin::new();
let html = r#"<ul><for each="item in items"><li>{{item.name}}</li></for></ul>"#;
let comp = make_component("my-list", html, None);
plugin
.register_component_template("my-list", &comp, &comp.html_content)
.unwrap();
let templates = plugin.take_component_templates();
let result = &templates[0].template;
assert!(
result.contains("<f-repeat value=\"{{item in items}}\">"),
"f-template should use <f-repeat>, got: {result}"
);
assert!(
result.contains("</f-repeat>"),
"f-template should close with </f-repeat>, got: {result}"
);
assert!(
!result.contains("<for "),
"f-template should NOT contain <for>, got: {result}"
);
}
#[test]
fn ftemplate_shadow_dom_strips_shadowroot_and_converts_directives() {
let mut plugin = FastV2ParserPlugin::new();
let html = r#"<template shadowrootmode="open"><div><if condition="visible">Shown</if><for each="x in list"><span>{{x}}</span></for></div></template>"#;
let comp = make_component("my-shadow", html, None);
plugin
.register_component_template("my-shadow", &comp, &comp.html_content)
.unwrap();
let templates = plugin.take_component_templates();
let result = &templates[0].template;
assert!(
result.contains("<f-when value=\"{{visible}}\">"),
"Shadow f-template should contain <f-when>, got: {result}"
);
assert!(
result.contains("<f-repeat value=\"{{x in list}}\">"),
"Shadow f-template should contain <f-repeat>, got: {result}"
);
assert!(
result.contains(r#"<f-template name="my-shadow" shadowrootmode="open">"#),
"shadowrootmode should be hoisted onto the <f-template> wrapper, got: {result}"
);
assert!(
!result.contains("<template shadowrootmode"),
"inner <template> should not keep shadowrootmode, got: {result}"
);
assert!(
!result.contains("<if "),
"Shadow f-template should NOT contain <if>, got: {result}"
);
assert!(
!result.contains("<for "),
"Shadow f-template should NOT contain <for>, got: {result}"
);
}
#[test]
fn ftemplate_nested_if_and_for_both_converted() {
let mut plugin = FastV2ParserPlugin::new();
let html =
r#"<div><if condition="show"><for each="x in items"><p>{{x}}</p></for></if></div>"#;
let comp = make_component("my-nested", html, None);
plugin
.register_component_template("my-nested", &comp, &comp.html_content)
.unwrap();
let templates = plugin.take_component_templates();
let result = &templates[0].template;
assert!(
result.contains("<f-when value=\"{{show}}\">"),
"Nested f-template should contain <f-when>, got: {result}"
);
assert!(
result.contains("<f-repeat value=\"{{x in items}}\">"),
"Nested f-template should contain <f-repeat>, got: {result}"
);
assert!(
!result.contains("<if "),
"Nested f-template should NOT contain <if>, got: {result}"
);
assert!(
!result.contains("<for "),
"Nested f-template should NOT contain <for>, got: {result}"
);
}
#[test]
fn convert_btr_preserves_utf8_text_content() {
let input = "<span>✓ passed</span>";
let output = convert_btr_to_fast(input);
assert_eq!(output, "<span>✓ passed</span>");
}
#[test]
fn convert_btr_preserves_utf8_multibyte_chars() {
let input = "<span>✓ yes ✗ no ⭐ star</span>";
let output = convert_btr_to_fast(input);
assert_eq!(output, "<span>✓ yes ✗ no ⭐ star</span>");
}
#[test]
fn convert_btr_preserves_utf8_with_directives() {
let input = r#"<if condition="x"><span>✓</span></if>"#;
let output = convert_btr_to_fast(input);
assert_eq!(output, r#"<f-when value="{{x}}"><span>✓</span></f-when>"#);
}
#[test]
fn convert_complex_attrs_preserves_utf8() {
let input = r#"<span :title="{{label}}" data-icon="⭐">text</span>"#;
let output = convert_btr_to_fast(input);
assert!(
output.contains("⭐"),
"UTF-8 star should be preserved: {output}"
);
}
}