use super::{AttributeAction, ParserPlugin, ParserPluginArtifacts};
use crate::component_registry::Component;
use crate::{ConditionParser, CssStrategy, DomStrategy, Result};
use std::cell::Cell;
use std::fmt::Write;
use webui_protocol::{condition_expr, ConditionExpr, WebUIElementData};
struct TrackedComponent {
tag_name: String,
template_html: String,
root_event_source: String,
}
pub struct WebUIParserPlugin {
components: Vec<TrackedComponent>,
element_events: Cell<u32>,
next_event_idx: Cell<u32>,
dom_strategy: DomStrategy,
}
impl WebUIParserPlugin {
#[must_use]
pub fn new() -> Self {
Self {
components: Vec::new(),
element_events: Cell::new(0),
next_event_idx: Cell::new(0),
dom_strategy: DomStrategy::default(),
}
}
pub fn set_css_strategy(&mut self, strategy: CssStrategy) {
let _ = strategy;
}
pub fn set_dom_strategy(&mut self, strategy: DomStrategy) {
self.dom_strategy = strategy;
}
#[must_use]
pub fn take_component_templates(&self) -> Vec<(String, String)> {
let use_shadow = matches!(self.dom_strategy, DomStrategy::Shadow);
self.components
.iter()
.map(|c| {
let script = generate_compiled_template_with_root_source(
&c.tag_name,
&c.template_html,
&c.root_event_source,
use_shadow,
);
(c.tag_name.clone(), script)
})
.collect()
}
fn store_component_template(
&mut self,
tag_name: &str,
template_html: &str,
root_event_source: &str,
) {
if let Some(component) = self.components.iter_mut().find(|c| c.tag_name == tag_name) {
component.template_html.clear();
component.template_html.push_str(template_html);
component.root_event_source.clear();
component.root_event_source.push_str(root_event_source);
return;
}
self.components.push(TrackedComponent {
tag_name: tag_name.to_string(),
template_html: template_html.to_string(),
root_event_source: root_event_source.to_string(),
});
}
}
impl Default for WebUIParserPlugin {
fn default() -> Self {
Self::new()
}
}
impl ParserPlugin for WebUIParserPlugin {
fn start_fragment(&mut self, _fragment_id: &str) {
self.element_events.set(0);
self.next_event_idx.set(0);
}
fn classify_attribute(&mut self, attr_name: &str) -> AttributeAction {
if attr_name.starts_with('@') {
self.element_events.set(self.element_events.get() + 1);
return AttributeAction::Skip;
}
AttributeAction::Keep
}
fn register_component_template(
&mut self,
tag_name: &str,
component: &Component,
processed_template: &str,
) -> Result<()> {
self.store_component_template(tag_name, processed_template, &component.html_content);
Ok(())
}
fn finish_element(&mut self, binding_attribute_count: u32) -> Option<Vec<u8>> {
let ev_count = self.element_events.get();
let ev_start = self.next_event_idx.get();
self.next_event_idx.set(ev_start + ev_count);
self.element_events.set(0);
if binding_attribute_count == 0 && ev_count == 0 {
return None;
}
Some(
WebUIElementData {
binding_count: binding_attribute_count,
event_start: ev_start,
event_count: ev_count,
}
.encode()
.to_vec(),
)
}
fn into_artifacts(self: Box<Self>) -> ParserPluginArtifacts {
ParserPluginArtifacts::ComponentTemplates(self.take_component_templates())
}
}
#[derive(Default)]
struct TemplateSectionMeta {
html: String,
text_bindings: Vec<(String, bool)>,
text_runs: Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
attr_bindings: Vec<CompiledAttrBinding>,
attr_groups: Vec<(Vec<usize>, usize, usize)>,
conditionals: Vec<(ConditionExpr, usize)>,
condition_slots: Vec<SlotLocator>,
repeats: Vec<(String, String, usize)>,
repeat_slots: Vec<SlotLocator>,
events: Vec<(String, String, bool)>,
event_targets: Vec<Vec<usize>>,
}
#[derive(Clone)]
struct SlotLocator {
parent_path: Vec<usize>,
before_index: usize,
order: usize,
}
struct TemplateMeta {
root: TemplateSectionMeta,
blocks: Vec<TemplateSectionMeta>,
root_events: Vec<(String, String, bool)>,
}
enum CompiledAttrBinding {
Simple {
name: String,
value: String,
},
Complex {
name: String,
value: String,
},
Boolean {
name: String,
condition: ConditionExpr,
},
Template {
name: String,
parts: Vec<CompiledAttrPart>,
},
}
enum CompiledAttrPart {
Static(String),
Dynamic(String),
}
pub fn generate_compiled_template(tag_name: &str, html_content: &str) -> String {
generate_compiled_template_with_root_source(tag_name, html_content, html_content, false)
}
fn generate_compiled_template_with_root_source(
tag_name: &str,
html_content: &str,
root_event_source: &str,
shadow_dom: bool,
) -> String {
let trimmed = html_content.trim();
let root_events = extract_root_events(root_event_source.trim());
let adopted_stylesheet = extract_adopted_stylesheet_specifier(trimmed);
let body = strip_template_wrapper(trimmed);
let meta = compile_to_metadata(body, root_events);
let mut out = String::with_capacity(512 + html_content.len());
out.push_str("(function(){var w=(window.__webui||(window.__webui={})).templates||(window.__webui.templates={});w['");
out.push_str(tag_name);
out.push_str("']={");
emit_js_template_section(&meta.root, &mut out);
if let Some(adopted_stylesheet) = adopted_stylesheet.as_deref() {
out.push_str(",sa:");
emit_js_string(adopted_stylesheet, &mut out);
}
if shadow_dom {
out.push_str(",sd:1");
}
if !meta.root_events.is_empty() {
out.push_str(",re:[");
for (i, (event, handler, needs_event)) in meta.root_events.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push('[');
emit_js_string(event, &mut out);
out.push(',');
emit_js_string(handler, &mut out);
out.push(',');
out.push(if *needs_event { '1' } else { '0' });
out.push(']');
}
out.push(']');
}
if !meta.blocks.is_empty() {
out.push_str(",b:[");
for (i, block) in meta.blocks.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push('{');
emit_js_template_section(block, &mut out);
out.push('}');
}
out.push(']');
}
out.push_str("};})();\n");
out
}
fn emit_js_string(s: &str, out: &mut String) {
out.push('"');
let mut index = 0usize;
while index < s.len() {
let remaining = &s[index..];
if remaining.as_bytes().starts_with(b"</")
&& remaining
.as_bytes()
.get(2..8)
.map(|bytes| bytes.eq_ignore_ascii_case(b"script"))
.unwrap_or(false)
{
out.push_str("\\u003C");
index += 1;
continue;
}
let Some(ch) = remaining.chars().next() else {
break;
};
match ch {
'\0' => out.push_str("\\0"),
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
_ => out.push(ch),
}
index += ch.len_utf8();
}
out.push('"');
}
fn emit_js_attr_binding(binding: &CompiledAttrBinding, out: &mut String) {
out.push('[');
match binding {
CompiledAttrBinding::Simple { name, value } => {
emit_js_string(name, out);
out.push_str(",0,");
emit_js_string(value, out);
}
CompiledAttrBinding::Complex { name, value } => {
emit_js_string(name, out);
out.push_str(",1,");
emit_js_string(value, out);
}
CompiledAttrBinding::Boolean { name, condition } => {
emit_js_string(name, out);
out.push_str(",2,");
emit_js_condition(condition, out);
}
CompiledAttrBinding::Template { name, parts } => {
emit_js_string(name, out);
out.push_str(",3,[");
for (i, part) in parts.iter().enumerate() {
if i > 0 {
out.push(',');
}
match part {
CompiledAttrPart::Static(value) => emit_js_string(value, out),
CompiledAttrPart::Dynamic(path) => {
out.push('[');
emit_js_string(path, out);
out.push(']');
}
}
}
out.push(']');
}
}
out.push(']');
}
fn emit_js_condition(condition: &ConditionExpr, out: &mut String) {
out.push_str("[function(v,s){return ");
emit_js_condition_expr(condition, out);
out.push_str("},[");
let mut paths = Vec::new();
collect_condition_paths(condition, &mut paths);
for (i, path) in paths.iter().enumerate() {
if i > 0 {
out.push(',');
}
emit_js_string(path, out);
}
out.push_str("]]");
}
fn emit_js_condition_expr(condition: &ConditionExpr, out: &mut String) {
match &condition.expr {
Some(condition_expr::Expr::Identifier(id)) => {
out.push_str("!!v(");
emit_js_string(&id.value, out);
out.push_str(",s)");
}
Some(condition_expr::Expr::Predicate(pred)) => {
out.push_str("v(");
emit_js_string(&pred.left, out);
out.push_str(",s)");
match pred.operator {
1 => out.push('>'), 2 => out.push('<'), 3 => out.push_str("=="), 4 => out.push_str("!="), 5 => out.push_str(">="), 6 => out.push_str("<="), _ => out.push_str("=="),
}
emit_js_predicate_right(&pred.right, out);
}
Some(condition_expr::Expr::Not(not_cond)) => {
out.push('!');
if let Some(inner) = not_cond.condition.as_ref() {
out.push('(');
emit_js_condition_expr(inner, out);
out.push(')');
} else {
out.push_str("false");
}
}
Some(condition_expr::Expr::Compound(compound)) => {
out.push('(');
if let Some(left) = compound.left.as_ref() {
emit_js_condition_expr(left, out);
} else {
out.push_str("false");
}
match compound.op {
1 => out.push_str("&&"), 2 => out.push_str("||"), _ => out.push_str("&&"),
}
if let Some(right) = compound.right.as_ref() {
emit_js_condition_expr(right, out);
} else {
out.push_str("false");
}
out.push(')');
}
None => out.push_str("false"),
}
}
fn emit_js_predicate_right(value: &str, out: &mut String) {
if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
emit_js_string(&value[1..value.len() - 1], out);
return;
}
if value == "true" || value == "false" {
out.push_str(value);
return;
}
if value
.bytes()
.all(|b| b.is_ascii_digit() || b == b'.' || b == b'-')
&& !value.is_empty()
{
out.push_str(value);
return;
}
out.push_str("v(");
emit_js_string(value, out);
out.push_str(",s)");
}
fn collect_condition_paths(condition: &ConditionExpr, paths: &mut Vec<String>) {
match &condition.expr {
Some(condition_expr::Expr::Identifier(id)) => {
paths.push(id.value.clone());
}
Some(condition_expr::Expr::Predicate(pred)) => {
paths.push(pred.left.clone());
let r = &pred.right;
if !((r.starts_with('"') && r.ends_with('"'))
|| (r.starts_with('\'') && r.ends_with('\''))
|| r == "true"
|| r == "false"
|| r.bytes()
.all(|b| b.is_ascii_digit() || b == b'.' || b == b'-'))
{
paths.push(r.clone());
}
}
Some(condition_expr::Expr::Not(not_cond)) => {
if let Some(inner) = not_cond.condition.as_ref() {
collect_condition_paths(inner, paths);
}
}
Some(condition_expr::Expr::Compound(compound)) => {
if let Some(left) = compound.left.as_ref() {
collect_condition_paths(left, paths);
}
if let Some(right) = compound.right.as_ref() {
collect_condition_paths(right, paths);
}
}
None => {}
}
}
fn emit_js_node_path(path: &[usize], out: &mut String) {
out.push('[');
for (i, index) in path.iter().enumerate() {
if i > 0 {
out.push(',');
}
let _ = write!(out, "{}", index);
}
out.push(']');
}
fn emit_js_slot(slot: &SlotLocator, out: &mut String) {
out.push('[');
emit_js_node_path(&slot.parent_path, out);
out.push(',');
let _ = write!(out, "{}", slot.before_index);
if slot.order > 0 {
out.push(',');
let _ = write!(out, "{}", slot.order);
}
out.push(']');
}
fn emit_js_text_parts(parts: &[CompiledAttrPart], out: &mut String) {
out.push('[');
for (i, part) in parts.iter().enumerate() {
if i > 0 {
out.push(',');
}
match part {
CompiledAttrPart::Static(value) => emit_js_string(value, out),
CompiledAttrPart::Dynamic(path) => {
out.push('[');
emit_js_string(path, out);
out.push(']');
}
}
}
out.push(']');
}
fn emit_js_template_section(meta: &TemplateSectionMeta, out: &mut String) {
out.push_str("h:");
emit_js_string(&meta.html, out);
if !meta.text_runs.is_empty() {
out.push_str(",tx:[");
for (i, (slot, parts, raw)) in meta.text_runs.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push('[');
emit_js_slot(slot, out);
out.push(',');
emit_js_text_parts(parts, out);
if *raw {
out.push_str(",1");
}
out.push(']');
}
out.push(']');
}
if !meta.attr_bindings.is_empty() {
out.push_str(",a:[");
for (i, binding) in meta.attr_bindings.iter().enumerate() {
if i > 0 {
out.push(',');
}
emit_js_attr_binding(binding, out);
}
out.push(']');
}
if !meta.attr_groups.is_empty() {
out.push_str(",ag:[");
for (i, (path, start, count)) in meta.attr_groups.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push('[');
emit_js_node_path(path, out);
out.push(',');
let _ = write!(out, "{}", start);
out.push(',');
let _ = write!(out, "{}", count);
out.push(']');
}
out.push(']');
}
if !meta.conditionals.is_empty() {
out.push_str(",c:[");
for (i, ((cond, block_index), slot)) in meta
.conditionals
.iter()
.zip(meta.condition_slots.iter())
.enumerate()
{
if i > 0 {
out.push(',');
}
out.push('[');
emit_js_condition(cond, out);
out.push(',');
let _ = write!(out, "{}", block_index);
out.push(',');
emit_js_slot(slot, out);
out.push(']');
}
out.push(']');
}
if !meta.repeats.is_empty() {
out.push_str(",r:[");
for (i, ((collection, item_var, block_index), slot)) in meta
.repeats
.iter()
.zip(meta.repeat_slots.iter())
.enumerate()
{
if i > 0 {
out.push(',');
}
out.push('[');
emit_js_string(collection, out);
out.push(',');
emit_js_string(item_var, out);
out.push(',');
let _ = write!(out, "{}", block_index);
out.push(',');
emit_js_slot(slot, out);
out.push(']');
}
out.push(']');
}
if !meta.events.is_empty() {
out.push_str(",e:[");
for (i, ((event, handler, needs_event), target)) in meta
.events
.iter()
.zip(meta.event_targets.iter())
.enumerate()
{
if i > 0 {
out.push(',');
}
out.push('[');
emit_js_string(event, out);
out.push(',');
emit_js_string(handler, out);
out.push(',');
out.push(if *needs_event { '1' } else { '0' });
out.push(',');
emit_js_node_path(target, out);
out.push(']');
}
out.push(']');
}
}
fn compile_to_metadata(input: &str, root_events: Vec<(String, String, bool)>) -> TemplateMeta {
let mut blocks = Vec::new();
let mut root = compile_section(input, &mut blocks);
finalize_template_section(&mut root);
for block in &mut blocks {
finalize_template_section(block);
}
TemplateMeta {
root,
blocks,
root_events,
}
}
fn compile_section(input: &str, blocks: &mut Vec<TemplateSectionMeta>) -> TemplateSectionMeta {
let mut meta = TemplateSectionMeta {
html: String::with_capacity(input.len()),
text_bindings: Vec::new(),
text_runs: Vec::new(),
attr_bindings: Vec::new(),
attr_groups: Vec::new(),
conditionals: Vec::new(),
condition_slots: Vec::new(),
repeats: Vec::new(),
repeat_slots: Vec::new(),
events: Vec::new(),
event_targets: Vec::new(),
};
let bytes = input.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if !input.is_char_boundary(i) {
meta.html.push(bytes[i] as char);
i += 1;
continue;
}
if i + 4 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' && bytes[i + 2] == b'{' {
if let Some(end) = find_brace_end(input, i + 3, 3) {
let expr = input[i + 3..end].trim();
let idx = meta.text_bindings.len();
meta.text_bindings.push((expr.to_string(), true));
meta.html.push_str(&format!("<!--t:{idx}-->"));
i = end + 3;
continue;
}
}
if i + 3 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
if let Some(end) = find_brace_end(input, i + 2, 2) {
let expr = input[i + 2..end].trim();
let idx = meta.text_bindings.len();
meta.text_bindings.push((expr.to_string(), false));
meta.html.push_str(&format!("<!--t:{idx}-->"));
i = end + 2;
continue;
}
}
if bytes[i] == b'<' {
let remaining = &input[i..];
if remaining.starts_with("<if ") || remaining.starts_with("<if\n") {
if let Some((cond, body, consumed)) = parse_if_block(remaining) {
let block_index = blocks.len();
blocks.push(TemplateSectionMeta::default());
let block = compile_section(&body, blocks);
blocks[block_index] = block;
let idx = meta.conditionals.len();
meta.conditionals.push((cond, block_index));
meta.html.push_str(&format!("<!--c:{idx}-->"));
i += consumed;
continue;
}
}
if remaining.starts_with("<for ") || remaining.starts_with("<for\n") {
if let Some((collection, item_var, body, consumed)) = parse_for_block(remaining) {
let block_index = blocks.len();
blocks.push(TemplateSectionMeta::default());
let block = compile_section(&body, blocks);
blocks[block_index] = block;
let idx = meta.repeats.len();
meta.repeats.push((collection, item_var, block_index));
meta.html.push_str(&format!("<!--r:{idx}-->"));
i += consumed;
continue;
}
}
if remaining.starts_with("<outlet") {
if let Some(close) = find_tag_end(remaining) {
meta.html.push_str("<outlet></outlet>");
i += close + 1;
continue;
}
}
if let Some((tag_html, consumed)) = parse_regular_tag(remaining, &mut meta) {
meta.html.push_str(&tag_html);
i += consumed;
continue;
}
}
if bytes[i] == b'@' && is_inside_tag(input, i) {
if let Some((event_name, handler, needs_event, consumed)) = parse_event_attr(input, i) {
meta.events.push((event_name, handler, needs_event));
meta.html.push_str("data-ev=\"1\"");
i += consumed;
continue;
}
}
let ch = &input[i..];
if let Some(c) = ch.chars().next() {
meta.html.push(c);
i += c.len_utf8();
} else {
i += 1;
}
}
meta
}
#[derive(Clone)]
enum FragmentNode {
Element(FragmentElement),
Text(String),
Comment(String),
}
#[derive(Clone)]
struct FragmentElement {
tag_name: String,
attrs: Vec<FragmentAttr>,
children: Vec<FragmentNode>,
self_closing: bool,
}
#[derive(Clone)]
struct FragmentAttr {
name: String,
value: Option<String>,
}
fn finalize_template_section(meta: &mut TemplateSectionMeta) {
let raw_html = std::mem::take(&mut meta.html);
let nodes = parse_fragment_nodes(&raw_html);
let text_bindings = meta.text_bindings.clone();
let mut finalized_html = String::with_capacity(raw_html.len());
let mut text_runs = Vec::new();
let mut attr_groups = Vec::new();
let mut condition_slots = vec![None; meta.conditionals.len()];
let mut repeat_slots = vec![None; meta.repeats.len()];
let mut event_targets = vec![None; meta.events.len()];
let mut event_cursor = 0usize;
process_fragment_children(
&nodes,
&[],
&text_bindings,
&mut finalized_html,
&mut text_runs,
&mut attr_groups,
&mut condition_slots,
&mut repeat_slots,
&mut event_targets,
&mut event_cursor,
);
debug_assert_eq!(event_cursor, meta.events.len());
meta.html = finalized_html;
meta.text_runs = text_runs;
meta.attr_groups = attr_groups;
meta.condition_slots = condition_slots.into_iter().flatten().collect();
meta.repeat_slots = repeat_slots.into_iter().flatten().collect();
meta.event_targets = event_targets.into_iter().flatten().collect();
}
#[allow(clippy::too_many_arguments)]
fn process_fragment_children(
nodes: &[FragmentNode],
parent_path: &[usize],
text_bindings: &[(String, bool)],
out: &mut String,
text_runs: &mut Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
attr_groups: &mut Vec<(Vec<usize>, usize, usize)>,
condition_slots: &mut [Option<SlotLocator>],
repeat_slots: &mut [Option<SlotLocator>],
event_targets: &mut [Option<Vec<usize>>],
event_cursor: &mut usize,
) {
let mut child_index = 0usize;
let mut index = 0usize;
let mut previous_emitted_text = false;
let mut slot_orders = std::collections::BTreeMap::<usize, usize>::new();
while index < nodes.len() {
if let Some((parts, consumed, has_dynamic, is_raw)) =
collect_text_run(&nodes[index..], text_bindings)
{
if has_dynamic {
let decoded_parts = parts
.into_iter()
.map(|part| match part {
CompiledAttrPart::Static(s) => {
let decoded = html_escape::decode_html_entities(&s);
CompiledAttrPart::Static(decoded.into_owned())
}
other => other,
})
.collect();
let order = slot_orders.get(&child_index).copied().unwrap_or(0);
slot_orders.insert(child_index, order + 1);
text_runs.push((
SlotLocator {
parent_path: parent_path.to_vec(),
before_index: child_index,
order,
},
decoded_parts,
is_raw,
));
} else {
let static_text = collect_static_text(&parts);
if !static_text.is_empty() {
out.push_str(&static_text);
if !previous_emitted_text {
child_index += 1;
}
previous_emitted_text = true;
}
}
index += consumed;
continue;
}
match &nodes[index] {
FragmentNode::Comment(data) => {
if let Some(idx) = parse_marker_index(data, "c:") {
if let Some(slot) = condition_slots.get_mut(idx) {
let order = slot_orders.get(&child_index).copied().unwrap_or(0);
slot_orders.insert(child_index, order + 1);
*slot = Some(SlotLocator {
parent_path: parent_path.to_vec(),
before_index: child_index,
order,
});
}
} else if let Some(idx) = parse_marker_index(data, "r:") {
if let Some(slot) = repeat_slots.get_mut(idx) {
let order = slot_orders.get(&child_index).copied().unwrap_or(0);
slot_orders.insert(child_index, order + 1);
*slot = Some(SlotLocator {
parent_path: parent_path.to_vec(),
before_index: child_index,
order,
});
}
} else {
out.push_str("<!--");
out.push_str(data);
out.push_str("-->");
child_index += 1;
previous_emitted_text = false;
}
}
FragmentNode::Text(text) => {
out.push_str(text);
if !text.is_empty() {
if !previous_emitted_text {
child_index += 1;
}
previous_emitted_text = true;
}
}
FragmentNode::Element(element) => {
let mut element_path = parent_path.to_vec();
element_path.push(child_index);
serialize_fragment_element(
element,
&element_path,
text_bindings,
out,
text_runs,
attr_groups,
condition_slots,
repeat_slots,
event_targets,
event_cursor,
);
child_index += 1;
previous_emitted_text = false;
}
}
index += 1;
}
}
#[allow(clippy::too_many_arguments)]
fn serialize_fragment_element(
element: &FragmentElement,
element_path: &[usize],
text_bindings: &[(String, bool)],
out: &mut String,
text_runs: &mut Vec<(SlotLocator, Vec<CompiledAttrPart>, bool)>,
attr_groups: &mut Vec<(Vec<usize>, usize, usize)>,
condition_slots: &mut [Option<SlotLocator>],
repeat_slots: &mut [Option<SlotLocator>],
event_targets: &mut [Option<Vec<usize>>],
event_cursor: &mut usize,
) {
out.push('<');
out.push_str(&element.tag_name);
for attr in &element.attrs {
if let Some((start, count)) = parse_attr_group_marker(&attr.name) {
attr_groups.push((element_path.to_vec(), start, count));
continue;
}
if attr.name == "data-ev" {
if let Some(count) = attr.value.as_deref().and_then(parse_event_marker_count) {
let target_path = element_path.to_vec();
for slot in event_targets.iter_mut().skip(*event_cursor).take(count) {
*slot = Some(target_path.clone());
}
*event_cursor += count;
}
continue;
}
out.push(' ');
out.push_str(&attr.name);
if let Some(value) = &attr.value {
out.push_str("=\"");
emit_html_attr_value(value, out);
out.push('"');
}
}
if element.self_closing {
out.push_str(" />");
return;
}
out.push('>');
process_fragment_children(
&element.children,
element_path,
text_bindings,
out,
text_runs,
attr_groups,
condition_slots,
repeat_slots,
event_targets,
event_cursor,
);
out.push_str("</");
out.push_str(&element.tag_name);
out.push('>');
}
fn collect_text_run(
nodes: &[FragmentNode],
text_bindings: &[(String, bool)],
) -> Option<(Vec<CompiledAttrPart>, usize, bool, bool)> {
let mut parts = Vec::new();
let mut consumed = 0usize;
let mut has_dynamic = false;
let mut is_raw = false;
for node in nodes {
match node {
FragmentNode::Text(text) => {
if !text.is_empty() {
parts.push(CompiledAttrPart::Static(text.clone()));
}
consumed += 1;
}
FragmentNode::Comment(data) => {
if let Some(index) = parse_marker_index(data, "t:") {
if let Some((path, raw)) = text_bindings.get(index) {
parts.push(CompiledAttrPart::Dynamic(path.clone()));
has_dynamic = true;
if *raw {
is_raw = true;
}
}
consumed += 1;
} else {
break;
}
}
FragmentNode::Element(_) => break,
}
}
if consumed == 0 {
return None;
}
Some((parts, consumed, has_dynamic, is_raw))
}
fn collect_static_text(parts: &[CompiledAttrPart]) -> String {
let mut text = String::new();
for part in parts {
if let CompiledAttrPart::Static(value) = part {
text.push_str(value);
}
}
text
}
fn parse_marker_index(data: &str, prefix: &str) -> Option<usize> {
data.strip_prefix(prefix)?.parse().ok()
}
fn parse_attr_group_marker(name: &str) -> Option<(usize, usize)> {
if let Some(start) = name.strip_prefix("data-w-b-") {
return start.parse::<usize>().ok().map(|index| (index, 1));
}
let rest = name.strip_prefix("data-w-c-")?;
let mut parts = rest.split('-');
let start = parts.next()?.parse::<usize>().ok()?;
let count = parts.next()?.parse::<usize>().ok()?;
Some((start, count))
}
fn parse_event_marker_count(value: &str) -> Option<usize> {
let count = value.parse::<usize>().ok()?;
(count > 0).then_some(count)
}
fn emit_html_attr_value(value: &str, out: &mut String) {
for ch in value.chars() {
match ch {
'"' => out.push_str("""),
_ => out.push(ch),
}
}
}
fn parse_fragment_nodes(input: &str) -> Vec<FragmentNode> {
let mut roots = Vec::new();
let mut stack: Vec<FragmentElement> = Vec::new();
let bytes = input.as_bytes();
let len = bytes.len();
let mut index = 0usize;
while index < len {
let remaining = &input[index..];
if remaining.starts_with("<!--") {
if let Some(close) = remaining.find("-->") {
push_fragment_node(
&mut roots,
&mut stack,
FragmentNode::Comment(remaining[4..close].to_string()),
);
index += close + 3;
continue;
}
}
if remaining.starts_with("</") {
if let Some(close) = remaining.find('>') {
if let Some(element) = stack.pop() {
push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
}
index += close + 1;
continue;
}
}
if remaining.starts_with('<') {
if let Some((element, consumed)) = parse_fragment_start_tag(remaining) {
if element.self_closing {
push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
} else {
stack.push(element);
}
index += consumed;
continue;
}
}
let next = remaining.find('<').unwrap_or(remaining.len());
let text = &remaining[..next];
if !text.is_empty() {
push_fragment_node(&mut roots, &mut stack, FragmentNode::Text(text.to_string()));
}
index += next.max(1);
}
while let Some(element) = stack.pop() {
push_fragment_node(&mut roots, &mut stack, FragmentNode::Element(element));
}
roots
}
fn push_fragment_node(
roots: &mut Vec<FragmentNode>,
stack: &mut [FragmentElement],
node: FragmentNode,
) {
if let Some(parent) = stack.last_mut() {
parent.children.push(node);
} else {
roots.push(node);
}
}
fn parse_fragment_start_tag(input: &str) -> Option<(FragmentElement, usize)> {
if !input.starts_with('<') || input.starts_with("</") || input.starts_with("<!--") {
return None;
}
let end = find_tag_end(input)?;
let tag_content = &input[1..end];
let tag_body = tag_content.trim_end();
let self_closing = tag_body.ends_with('/');
let tag_body = if self_closing {
tag_body[..tag_body.len().saturating_sub(1)].trim_end()
} else {
tag_body
};
let body_bytes = tag_body.as_bytes();
let mut cursor = 0usize;
while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
let tag_name = &tag_body[..cursor];
if tag_name.is_empty() {
return None;
}
let mut attrs = Vec::new();
while cursor < body_bytes.len() {
while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor >= body_bytes.len() {
break;
}
let attr_start = cursor;
while cursor < body_bytes.len()
&& !body_bytes[cursor].is_ascii_whitespace()
&& body_bytes[cursor] != b'='
{
cursor += 1;
}
if attr_start == cursor {
cursor += 1;
continue;
}
let name = tag_body[attr_start..cursor].to_string();
while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
let mut value: Option<String> = None;
if cursor < body_bytes.len() && body_bytes[cursor] == b'=' {
cursor += 1;
while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor < body_bytes.len()
&& (body_bytes[cursor] == b'"' || body_bytes[cursor] == b'\'')
{
let quote = body_bytes[cursor];
cursor += 1;
let value_start = cursor;
while cursor < body_bytes.len() && body_bytes[cursor] != quote {
cursor += 1;
}
value = Some(tag_body[value_start..cursor].to_string());
if cursor < body_bytes.len() {
cursor += 1;
}
} else {
let value_start = cursor;
while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
value = Some(tag_body[value_start..cursor].to_string());
}
}
attrs.push(FragmentAttr { name, value });
}
Some((
FragmentElement {
tag_name: tag_name.to_string(),
attrs,
children: Vec::new(),
self_closing: self_closing || is_void_element(tag_name),
},
end + 1,
))
}
fn is_void_element(tag_name: &str) -> bool {
matches!(
tag_name,
"area"
| "base"
| "br"
| "col"
| "embed"
| "hr"
| "img"
| "input"
| "link"
| "meta"
| "param"
| "source"
| "track"
| "wbr"
)
}
fn find_brace_end(input: &str, start: usize, count: usize) -> Option<usize> {
let bytes = input.as_bytes();
let mut i = start;
while i + count - 1 < bytes.len() {
let all_close = (0..count).all(|j| bytes[i + j] == b'}');
if all_close {
return Some(i);
}
i += 1;
}
None
}
fn is_inside_tag(input: &str, pos: usize) -> bool {
let bytes = input.as_bytes();
let mut i = pos;
while i > 0 {
i -= 1;
if bytes[i] == b'>' {
return false;
}
if bytes[i] == b'<' {
return true;
}
}
false
}
fn strip_template_wrapper(html: &str) -> &str {
if !html.starts_with("<template") {
return html;
}
let Some(open_end) = find_tag_end(html) else {
return html;
};
let inner_start = open_end + 1;
let inner_end = html.rfind("</template>").unwrap_or(html.len());
if inner_start >= inner_end {
return "";
}
&html[inner_start..inner_end]
}
fn find_next_block_token(input: &str, cursor: usize, token: &str) -> Option<usize> {
let bytes = input.as_bytes();
let token_bytes = token.as_bytes();
let mut index = cursor;
let mut in_tag = false;
let mut in_comment = false;
let mut quote: Option<u8> = None;
while index + token_bytes.len() <= bytes.len() {
if in_comment {
if bytes[index..].starts_with(b"-->") {
in_comment = false;
index += 3;
continue;
}
index += 1;
continue;
}
if let Some(active) = quote {
if bytes[index] == active {
quote = None;
}
index += 1;
continue;
}
if in_tag {
match bytes[index] {
b'"' | b'\'' => quote = Some(bytes[index]),
b'>' => in_tag = false,
_ => {}
}
index += 1;
continue;
}
if &bytes[index..index + token_bytes.len()] == token_bytes {
return Some(index);
}
if bytes[index] == b'<' {
if bytes[index..].starts_with(b"<!--") {
in_comment = true;
index += 4;
continue;
}
in_tag = true;
}
index += 1;
}
None
}
fn find_next_block_open(input: &str, cursor: usize, name: &str) -> Option<usize> {
let token = format!("<{name}");
let bytes = input.as_bytes();
let token_bytes = token.as_bytes();
let mut search = cursor;
while let Some(index) = find_next_block_token(input, search, &token) {
let next = bytes.get(index + token_bytes.len()).copied();
if match next {
None => true,
Some(byte) => byte == b'>' || byte.is_ascii_whitespace(),
} {
return Some(index);
}
search = index + 1;
}
None
}
fn find_next_block_close(input: &str, cursor: usize, name: &str) -> Option<usize> {
let token = format!("</{name}>");
find_next_block_token(input, cursor, &token)
}
fn find_matching_block_end(input: &str, name: &str) -> Option<usize> {
let open_token = format!("<{name}");
let close_token = format!("</{name}>");
if !input.starts_with(&open_token) {
return None;
}
let mut depth = 1usize;
let mut cursor = open_token.len();
while cursor < input.len() {
let next_open = find_next_block_open(input, cursor, name);
let next_close = find_next_block_close(input, cursor, name);
match (next_open, next_close) {
(_, None) => return None,
(Some(open_pos), Some(close_pos)) if open_pos < close_pos => {
depth += 1;
cursor = open_pos + open_token.len();
}
(_, Some(close_pos)) => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some(close_pos);
}
cursor = close_pos + close_token.len();
}
}
}
None
}
fn parse_if_block(input: &str) -> Option<(ConditionExpr, String, usize)> {
let close_tag = "</if>";
let end_pos = find_matching_block_end(input, "if")?;
let tag_content = &input[..end_pos];
let cond_start = tag_content.find("condition=\"")? + "condition=\"".len();
let cond_end = tag_content[cond_start..].find('"')? + cond_start;
let condition = compile_condition_expr(&tag_content[cond_start..cond_end]);
let body_start = find_tag_end(tag_content)? + 1;
let body = tag_content[body_start..].trim().to_string();
Some((condition, body, end_pos + close_tag.len()))
}
fn compile_condition_expr(input: &str) -> ConditionExpr {
let parser = ConditionParser::new();
match parser.parse(input) {
Ok(condition) => condition,
Err(_) => ConditionExpr::identifier(input),
}
}
fn parse_for_block(input: &str) -> Option<(String, String, String, usize)> {
let close_tag = "</for>";
let end_pos = find_matching_block_end(input, "for")?;
let tag_content = &input[..end_pos];
let each_start = tag_content.find("each=\"")? + "each=\"".len();
let each_end = tag_content[each_start..].find('"')? + each_start;
let each_val = &tag_content[each_start..each_end];
let parts: Vec<&str> = each_val.splitn(3, ' ').collect();
if parts.len() != 3 || parts[1] != "in" {
return None;
}
let item_var = parts[0].to_string();
let collection = parts[2].to_string();
let body_start = find_tag_end(tag_content)? + 1;
let body = tag_content[body_start..].trim().to_string();
Some((collection, item_var, body, end_pos + close_tag.len()))
}
fn parse_event_attr(input: &str, pos: usize) -> Option<(String, String, bool, usize)> {
let remaining = &input[pos..];
let eq_pos = remaining.find('=')?;
let event_name = remaining[1..eq_pos].to_string();
let after_eq = &remaining[eq_pos + 1..];
if after_eq.is_empty() {
return None;
}
let first = after_eq.as_bytes()[0];
let (raw_value, total_consumed) = if first == b'"' || first == b'\'' {
let value_start = eq_pos + 2;
let close = remaining[value_start..].find(first as char)?;
(
&remaining[value_start..value_start + close],
value_start + close + 1,
)
} else if first == b'{' {
let value_start = eq_pos + 2;
let close = remaining[value_start..].find('}')?;
(
&remaining[value_start..value_start + close],
value_start + close + 1,
)
} else {
return None;
};
let inner = raw_value
.trim()
.strip_prefix('{')
.and_then(|s| s.strip_suffix('}'))
.unwrap_or(raw_value)
.trim();
if inner.is_empty() {
return None;
}
let paren = match inner.find('(') {
Some(p) => p,
None => panic!(
"Invalid @{event_name} handler: \"{inner}\". \
Use @{event_name}=\"{{handler()}}\" or \
@{event_name}=\"{{handler(e)}}\" to pass the event.",
),
};
let handler_name = inner[..paren].to_string();
let needs_event = inner.contains("(e)");
Some((event_name, handler_name, needs_event, total_consumed))
}
fn parse_regular_tag(input: &str, meta: &mut TemplateSectionMeta) -> Option<(String, usize)> {
if !input.starts_with('<') || input.starts_with("</") || input.starts_with("<!") {
return None;
}
let bytes = input.as_bytes();
if bytes.len() < 2 || !bytes[1].is_ascii_alphabetic() {
return None;
}
let end = find_tag_end(input)?;
let tag_content = &input[1..end];
let tag_body = tag_content.trim_end();
let self_closing = tag_body.ends_with('/');
let tag_body = if self_closing {
tag_body[..tag_body.len().saturating_sub(1)].trim_end()
} else {
tag_body
};
let body_bytes = tag_body.as_bytes();
let mut cursor = 0;
while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
let tag_name = &tag_body[..cursor];
if tag_name.is_empty() {
return None;
}
let mut out = String::with_capacity(tag_body.len() + 24);
out.push('<');
out.push_str(tag_name);
let binding_start = meta.attr_bindings.len();
let event_start = meta.events.len();
let mut binding_count = 0usize;
while cursor < body_bytes.len() {
while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor >= body_bytes.len() {
break;
}
let attr_start = cursor;
while cursor < body_bytes.len()
&& !body_bytes[cursor].is_ascii_whitespace()
&& body_bytes[cursor] != b'='
{
cursor += 1;
}
if attr_start == cursor {
cursor += 1;
continue;
}
let name = &tag_body[attr_start..cursor];
while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
let mut value: Option<&str> = None;
if cursor < body_bytes.len() && body_bytes[cursor] == b'=' {
cursor += 1;
while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor < body_bytes.len()
&& (body_bytes[cursor] == b'"' || body_bytes[cursor] == b'\'')
{
let quote = body_bytes[cursor];
cursor += 1;
let value_start = cursor;
while cursor < body_bytes.len() && body_bytes[cursor] != quote {
cursor += 1;
}
value = Some(&tag_body[value_start..cursor]);
if cursor < body_bytes.len() {
cursor += 1;
}
} else {
let value_start = cursor;
while cursor < body_bytes.len() && !body_bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
value = Some(&tag_body[value_start..cursor]);
}
}
let raw_attr = tag_body[attr_start..cursor].trim();
if raw_attr.is_empty() {
continue;
}
if let Some(event_name) = name.strip_prefix('@') {
let Some(raw_value) = value else {
continue;
};
match parse_event_handler(raw_value) {
EventHandler::Valid(handler_name, needs_event) => {
meta.events
.push((event_name.to_string(), handler_name, needs_event));
}
EventHandler::Invalid(raw) => {
panic!(
"Invalid @{event_name} handler: \"{raw}\". \
Use @{event_name}=\"{{handler()}}\" or \
@{event_name}=\"{{handler(e)}}\" to pass the event.",
);
}
EventHandler::Empty => {}
}
continue;
}
if name == "w-ref" {
if let Some(val) = value {
if !val.starts_with('{') || !val.ends_with('}') {
eprintln!(
"\x1b[1;31merror\x1b[0m: invalid w-ref=\"{}\" in <{}> — \
use w-ref={{{}}} with braces to bind to a component property",
val, tag_name, val
);
}
}
out.push(' ');
out.push_str(raw_attr);
continue;
}
if let Some(bool_name) = name.strip_prefix('?') {
if let Some(raw_value) = value.and_then(extract_single_handlebars) {
meta.attr_bindings.push(CompiledAttrBinding::Boolean {
name: bool_name.to_string(),
condition: compile_condition_expr(&raw_value),
});
binding_count += 1;
}
continue;
}
if name.starts_with(':') {
if let Some(raw_value) = value.and_then(extract_single_handlebars) {
let prop_name = webui_protocol::attrs::attribute_to_camel(
name.strip_prefix(':').unwrap_or(name),
);
meta.attr_bindings.push(CompiledAttrBinding::Complex {
name: prop_name,
value: raw_value,
});
binding_count += 1;
}
continue;
}
if let Some(raw_value) = value {
if let Some(parts) = parse_attr_parts(raw_value) {
let is_simple =
parts.len() == 1 && matches!(parts.first(), Some(CompiledAttrPart::Dynamic(_)));
if is_simple {
if let Some(CompiledAttrPart::Dynamic(path)) = parts.into_iter().next() {
meta.attr_bindings.push(CompiledAttrBinding::Simple {
name: name.to_string(),
value: path,
});
}
} else {
meta.attr_bindings.push(CompiledAttrBinding::Template {
name: name.to_string(),
parts,
});
}
binding_count += 1;
continue;
}
}
out.push(' ');
out.push_str(raw_attr);
}
if binding_count == 1 {
out.push_str(" data-w-b-");
out.push_str(&binding_start.to_string());
} else if binding_count > 1 {
out.push_str(" data-w-c-");
out.push_str(&binding_start.to_string());
out.push('-');
out.push_str(&binding_count.to_string());
}
let event_count = meta.events.len().saturating_sub(event_start);
if event_count > 0 {
out.push_str(" data-ev=\"");
let _ = write!(out, "{}", event_count);
out.push('"');
}
if self_closing {
out.push_str(" />");
} else {
out.push('>');
}
Some((out, end + 1))
}
fn find_tag_end(input: &str) -> Option<usize> {
let bytes = input.as_bytes();
let mut quote: Option<u8> = None;
let mut i = 1;
while i < bytes.len() {
let ch = bytes[i];
if let Some(active) = quote {
if ch == active {
quote = None;
}
} else if ch == b'"' || ch == b'\'' {
quote = Some(ch);
} else if ch == b'>' {
return Some(i);
}
i += 1;
}
None
}
enum EventHandler {
Valid(String, bool),
Invalid(String),
Empty,
}
fn parse_event_handler(raw_value: &str) -> EventHandler {
let inner = raw_value
.trim()
.strip_prefix('{')
.and_then(|s| s.strip_suffix('}'))
.unwrap_or(raw_value)
.trim();
if inner.is_empty() {
return EventHandler::Empty;
}
match inner.find('(') {
Some(paren) => {
EventHandler::Valid(inner[..paren].trim().to_string(), inner.contains("(e)"))
}
None => EventHandler::Invalid(inner.to_string()),
}
}
fn extract_single_handlebars(value: &str) -> Option<String> {
let trimmed = value.trim();
if let Some(inner) = trimmed
.strip_prefix("{{{")
.and_then(|s| s.strip_suffix("}}}"))
{
return Some(inner.trim().to_string());
}
if let Some(inner) = trimmed
.strip_prefix("{{")
.and_then(|s| s.strip_suffix("}}"))
{
return Some(inner.trim().to_string());
}
None
}
fn parse_attr_parts(value: &str) -> Option<Vec<CompiledAttrPart>> {
let bytes = value.as_bytes();
let len = bytes.len();
let mut i = 0;
let mut last = 0;
let mut dynamic_found = false;
let mut parts = Vec::new();
while i < len {
if i + 2 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
let brace_count = if i + 2 < len && bytes[i + 2] == b'{' {
3
} else {
2
};
if let Some(end) = find_brace_end(value, i + brace_count, brace_count) {
if last < i {
parts.push(CompiledAttrPart::Static(value[last..i].to_string()));
}
parts.push(CompiledAttrPart::Dynamic(
value[i + brace_count..end].trim().to_string(),
));
dynamic_found = true;
i = end + brace_count;
last = i;
continue;
}
}
i += 1;
}
if !dynamic_found {
return None;
}
if last < len {
parts.push(CompiledAttrPart::Static(value[last..].to_string()));
}
Some(parts)
}
fn extract_root_events(html: &str) -> Vec<(String, String, bool)> {
if !html.starts_with("<template") {
return Vec::new();
}
let Some(close) = find_tag_end(html) else {
return Vec::new();
};
let tag = &html[..close];
let mut events = Vec::new();
let bytes = tag.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] == b'@' {
if let Some((name, handler, needs_event, consumed)) = parse_event_attr(tag, i) {
events.push((name, handler, needs_event));
i += consumed;
continue;
}
}
i += 1;
}
events
}
fn extract_adopted_stylesheet_specifier(html: &str) -> Option<String> {
if !html.starts_with("<template") {
return None;
}
let close = find_tag_end(html)?;
let tag = &html[..close];
let attr = "shadowrootadoptedstylesheets=\"";
let start = tag.find(attr)? + attr.len();
let end = tag[start..].find('"')? + start;
Some(tag[start..end].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_no_client_markers(result: &str) {
assert!(!result.contains("<!--t:"), "text markers should be removed");
assert!(
!result.contains("<!--c:"),
"conditional markers should be removed"
);
assert!(
!result.contains("<!--r:"),
"repeat markers should be removed"
);
assert!(
!result.contains("data-w-b-"),
"attribute binding markers should be removed"
);
assert!(
!result.contains("data-w-c-"),
"attribute binding range markers should be removed"
);
assert!(
!result.contains("data-ev="),
"event markers should be removed"
);
}
#[test]
fn test_skip_event_attributes() {
let mut plugin = WebUIParserPlugin::new();
assert_eq!(plugin.classify_attribute("@click"), AttributeAction::Skip);
assert_eq!(plugin.classify_attribute("@keydown"), AttributeAction::Skip);
assert_eq!(plugin.classify_attribute("w-ref"), AttributeAction::Keep);
assert_eq!(plugin.classify_attribute("class"), AttributeAction::Keep);
}
#[test]
fn test_metadata_has_text_bindings() {
let result = generate_compiled_template("my-comp", "<h1>{{title}}</h1>");
assert_no_client_markers(&result);
assert!(result.contains(r#"h:"<h1></h1>""#));
assert!(result.contains("\"title\""));
assert!(result.contains(r#",tx:[[[[0],0],[["title"]]]]"#));
assert!(!result.contains("{{"));
}
#[test]
fn test_metadata_has_conditionals() {
let result = generate_compiled_template(
"my-comp",
r#"<if condition="state == 'done'"><span>yes</span></if>"#,
);
assert_no_client_markers(&result);
assert!(result
.contains(r#"c:[[[function(v,s){return v("state",s)=="done"},["state"]],0,[[],0]]]"#));
assert!(result.contains("<span>yes</span>"));
assert!(!result.contains("<if"));
}
#[test]
fn test_metadata_has_conditionals_with_gt_in_condition_attr() {
let result = generate_compiled_template(
"my-comp",
r#"<if condition="count > 0"><span>yes</span></if>"#,
);
assert_no_client_markers(&result);
assert!(result.contains(",c:["), "conditional metadata expected");
assert!(result.contains("<span>yes</span>"));
assert!(!result.contains("<if"));
}
#[test]
fn test_parse_if_block_ignores_close_marker_inside_attr_value() {
let input = r#"<if condition="count > 0"><div data-note="</if>">yes</div></if>"#;
let (_, body, consumed) = parse_if_block(input).expect("if block should parse");
assert_eq!(body, r#"<div data-note="</if>">yes</div>"#);
assert_eq!(consumed, input.len());
}
#[test]
fn test_metadata_has_for_loops() {
let result = generate_compiled_template(
"my-comp",
r#"<for each="item in items"><p>{{item.name}}</p></for>"#,
);
assert_no_client_markers(&result);
assert!(result.contains("\"items\""));
assert!(result.contains("\"item\""));
assert!(!result.contains("<for"));
}
#[test]
fn test_parse_for_block_ignores_open_marker_inside_attr_value() {
let input =
r#"<for each="item in items"><div data-note="<for fake>">{{item.name}}</div></for>"#;
let (collection, item_var, body, consumed) =
parse_for_block(input).expect("for block should parse");
assert_eq!(collection, "items");
assert_eq!(item_var, "item");
assert_eq!(body, r#"<div data-note="<for fake>">{{item.name}}</div>"#);
assert_eq!(consumed, input.len());
}
#[test]
fn test_metadata_has_for_loops_with_gt_in_other_attr() {
let result = generate_compiled_template(
"my-comp",
r#"<for each="item in items" data-note="a > b"><p>{{item.name}}</p></for>"#,
);
assert_no_client_markers(&result);
assert!(result.contains("\"items\""));
assert!(result.contains("\"item\""));
assert!(!result.contains("<for"));
assert!(!result.contains("{{item.name}}"));
}
#[test]
fn test_metadata_has_events() {
let result =
generate_compiled_template("my-comp", r#"<button @click="{onClick()}">Go</button>"#);
assert_no_client_markers(&result);
assert!(result.contains("\"click\""));
assert!(result.contains("\"onClick\""));
assert!(!result.contains("@click"));
}
#[test]
fn test_metadata_has_events_unquoted() {
let result =
generate_compiled_template("my-comp", r#"<button @click={onClick()}>Go</button>"#);
assert_no_client_markers(&result);
assert!(result.contains("\"click\""));
assert!(result.contains("\"onClick\""));
}
#[test]
fn test_metadata_has_events_with_arg() {
let result = generate_compiled_template("my-comp", r#"<input @keydown="{onKey(e)}" />"#);
assert_no_client_markers(&result);
assert!(result.contains("\"keydown\""));
assert!(result.contains("\"onKey\""));
assert!(result.contains(",1")); }
#[test]
fn test_metadata_has_simple_attr_bindings() {
let result = generate_compiled_template("my-comp", r#"<input title="{{title}}" />"#);
assert_no_client_markers(&result);
assert!(result.contains(",a:["));
assert!(result.contains(",ag:[[[0],0,1]]"));
assert!(result.contains(r#"["title",0,"title"]"#));
assert!(!result.contains(r#"title=\"<!--t:"#));
}
#[test]
fn test_metadata_has_template_attr_bindings() {
let result = generate_compiled_template(
"my-comp",
r#"<a href="/product/{{handle}}" class="card {{variant}}">Go</a>"#,
);
assert_no_client_markers(&result);
assert!(result.contains(",a:["));
assert!(result.contains(",ag:[[[0],0,2]]"));
assert!(result.contains(r#"["href",3,["/product/",["handle"]]]"#));
assert!(result.contains(r#"["class",3,["card ",["variant"]]]"#));
assert!(!result.contains(r#"/product/<!--t:"#));
}
#[test]
fn test_metadata_has_boolean_attr_bindings() {
let result = generate_compiled_template(
"my-comp",
r#"<a ?data-active="{{page == 'dashboard'}}">Go</a>"#,
);
assert_no_client_markers(&result);
assert!(result.contains(",a:["));
assert!(result.contains(",ag:[[[0],0,1]]"));
assert!(result.contains(
r#"["data-active",2,[function(v,s){return v("page",s)=="dashboard"},["page"]]]"#
));
}
#[test]
fn test_metadata_has_complex_attr_bindings() {
let result = generate_compiled_template(
"my-comp",
r#"<child-view :config="{{settings}}"></child-view>"#,
);
assert_no_client_markers(&result);
assert!(result.contains(",a:["));
assert!(result.contains(",ag:[[[0],0,1]]"));
assert!(result.contains(r#""config",1,"settings""#));
}
#[test]
fn test_w_ref_stays_in_html() {
let result = generate_compiled_template("my-comp", r#"<input w-ref="{myInput}" />"#);
assert!(result.contains("w-ref"));
assert!(result.contains("myInput"));
}
#[test]
fn test_w_ref_unquoted_stays_in_html() {
let result = generate_compiled_template("my-comp", r#"<span w-ref={myLabel}>0</span>"#);
assert!(result.contains("w-ref"));
assert!(result.contains("myLabel"));
}
#[test]
fn test_metadata_has_root_events() {
let result = generate_compiled_template(
"my-comp",
r#"<template shadowrootmode="open" @click="{onClick(e)}"><div>hi</div></template>"#,
);
assert!(result.contains(",re:["));
assert!(result.contains("\"click\""));
assert!(result.contains("\"onClick\""));
}
#[test]
fn test_strips_shadowrootmode() {
let result = generate_compiled_template(
"my-comp",
r#"<template shadowrootmode="open"><p>{{title}}</p></template>"#,
);
assert!(!result.contains("shadowrootmode"));
assert_no_client_markers(&result);
assert!(result.contains(r#",tx:[[[[0],0],[["title"]]]]"#));
}
#[test]
fn test_root_template_allows_gt_in_quoted_attr_values() {
let result = generate_compiled_template(
"my-comp",
r#"<template shadowrootmode="open" data-note="a > b" @click="{onClick()}"><p>hi</p></template>"#,
);
assert_no_client_markers(&result);
assert!(result.contains(r#"h:"<p>hi</p>""#));
assert!(result.contains(r#",re:[["click","onClick",0]]"#));
}
#[test]
fn test_outlet_converted() {
let result = generate_compiled_template("my-comp", r#"<main><outlet /></main>"#);
assert!(result.contains("<outlet></outlet>"));
}
#[test]
fn test_emoji_safe() {
let result = generate_compiled_template("my-comp", r#"<span>📚 {{title}}</span>"#);
assert!(result.contains("📚"));
assert_no_client_markers(&result);
assert!(result.contains(r#",tx:[[[[0],0],["📚 ",["title"]]]]"#));
}
#[test]
fn test_boolean_attr_in_for() {
let result = generate_compiled_template(
"my-comp",
r#"<for each="s in sections"><a ?active="{{s.id == sectionId}}">{{s.name}}</a></for>"#,
);
assert_no_client_markers(&result);
assert!(result.contains(r#"["active",2,[function(v,s){return v("s.id",s)==v("sectionId",s)},["s.id","sectionId"]]]"#));
assert!(!result.contains("?active"));
}
#[test]
fn test_deduplicates_components() {
let mut plugin = WebUIParserPlugin::new();
let comp = Component {
tag_name: "test-el".to_string(),
html_content: "<p>hi</p>".to_string(),
css_content: None,
css_tokens: Vec::new(),
source_path: std::path::PathBuf::new(),
class_name: None,
};
plugin
.register_component_template("test-el", &comp, &comp.html_content)
.unwrap();
plugin
.register_component_template("test-el", &comp, &comp.html_content)
.unwrap();
assert_eq!(plugin.take_component_templates().len(), 1);
}
#[test]
fn test_compiled_template_preserves_link_node_in_static_html() {
let result = generate_compiled_template(
"my-comp",
r#"<template shadowrootmode="open"><link rel="stylesheet" href="my-comp.css"><p>hi</p></template>"#,
);
assert!(result.contains(r#"rel=\"stylesheet\""#));
assert!(result.contains(r#"href=\"my-comp.css\""#));
assert!(result.contains(r#"<p>hi</p>"#));
assert!(!result.contains(",css:"));
}
#[test]
fn test_compiled_template_preserves_inline_style_in_static_html() {
let result = generate_compiled_template(
"my-comp",
r#"<template shadowrootmode="open"><style>.root{color:red}</style><p>hi</p></template>"#,
);
assert!(result.contains(r#"h:"<style>.root{color:red}</style><p>hi</p>""#));
}
#[test]
fn test_compiled_template_emits_adopted_stylesheet_specifier() {
let result = generate_compiled_template(
"my-comp",
r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="my-comp"><p>hi</p></template>"#,
);
assert!(result.contains(r#",sa:"my-comp""#));
}
#[test]
fn test_plugin_uses_processed_link_template_html() {
let mut plugin = WebUIParserPlugin::new();
let comp = Component {
tag_name: "test-el".to_string(),
html_content: "<p>hi</p>".to_string(),
css_content: Some(".root { color: red; }".to_string()),
css_tokens: Vec::new(),
source_path: std::path::PathBuf::new(),
class_name: None,
};
plugin
.register_component_template(
"test-el",
&comp,
r#"<template shadowrootmode="open"><link rel="stylesheet" href="test-el.css"><p>hi</p></template>"#,
)
.unwrap();
let templates = plugin.take_component_templates();
assert_eq!(templates.len(), 1);
assert!(templates[0].1.contains(r#"rel=\"stylesheet\""#));
assert!(templates[0].1.contains(r#"href=\"test-el.css\""#));
assert!(templates[0].1.contains(r#"<p>hi</p>"#));
}
#[test]
fn test_plugin_preserves_root_events_from_raw_template_source() {
let mut plugin = WebUIParserPlugin::new();
let comp = Component {
tag_name: "test-el".to_string(),
html_content:
r#"<template shadowrootmode="open" @click="{onClick(e)}"><p>hi</p></template>"#
.to_string(),
css_content: Some(".root { color: red; }".to_string()),
css_tokens: Vec::new(),
source_path: std::path::PathBuf::new(),
class_name: None,
};
plugin
.register_component_template(
"test-el",
&comp,
r#"<template shadowrootmode="open"><link rel="stylesheet" href="test-el.css"><p>hi</p></template>"#,
)
.unwrap();
let templates = plugin.take_component_templates();
assert_eq!(templates.len(), 1);
assert!(templates[0].1.contains(r#",re:[["click","onClick",1]]"#));
}
#[test]
fn test_plugin_uses_processed_module_template_html() {
let mut plugin = WebUIParserPlugin::new();
let comp = Component {
tag_name: "test-el".to_string(),
html_content: "<p>hi</p>".to_string(),
css_content: Some(".root { color: red; }".to_string()),
css_tokens: Vec::new(),
source_path: std::path::PathBuf::new(),
class_name: None,
};
plugin
.register_component_template(
"test-el",
&comp,
r#"<template shadowrootmode="open" shadowrootadoptedstylesheets="test-el"><p>hi</p></template>"#,
)
.unwrap();
let templates = plugin.take_component_templates();
assert_eq!(templates.len(), 1);
assert!(templates[0].1.contains(r#",sa:"test-el""#));
}
#[test]
fn test_nested_if_inside_for_compiles_nested_blocks() {
let result = generate_compiled_template(
"my-comp",
r#"<for each="item in items"><if condition="item.active"><span>{{item.name}}</span></if></for>"#,
);
assert_no_client_markers(&result);
assert!(result.contains(",b:["), "compiled block table expected");
assert!(
result.contains(r#"["items","item",0,[[],0]]"#),
"repeat block index expected"
);
assert!(
result.contains(
r#"[[function(v,s){return !!v("item.active",s)},["item.active"]],1,[[],0]]"#
),
"nested conditional block index expected"
);
assert!(!result.contains("<if"), "nested if should be compiled");
assert!(!result.contains("{{"), "nested bindings should be compiled");
}
#[test]
fn test_for_inside_if_compiles_nested_blocks() {
let result = generate_compiled_template(
"my-comp",
r#"<if condition="showList"><for each="item in items"><p>{{item.name}}</p></for></if>"#,
);
assert_no_client_markers(&result);
assert!(result.contains(",b:["), "compiled block table expected");
assert!(
result.contains(r#"[[function(v,s){return !!v("showList",s)},["showList"]],0,[[],0]]"#),
"conditional block index expected"
);
assert!(
result.contains(r#"["items","item",1,[[],0]]"#),
"nested repeat block index expected"
);
assert!(!result.contains("<for"), "nested for should be compiled");
assert!(!result.contains("{{"), "nested bindings should be compiled");
}
#[test]
fn test_nested_for_inside_for_compiles_nested_blocks() {
let result = generate_compiled_template(
"my-comp",
r#"<for each="group in groups"><div><for each="item in group.items"><button data-group="{{group.name}}" ?disabled="{{item.disabled}}">{{item.label}}</button></for></div></for>"#,
);
assert_no_client_markers(&result);
assert!(result.contains(",b:["), "compiled block table expected");
assert!(
result.contains(r#"["groups","group",0,[[],0]]"#),
"outer repeat block index expected"
);
assert!(
result.contains(r#"["group.items","item",1,[[0],0]]"#),
"inner repeat block index expected"
);
assert!(!result.contains("<for"), "nested for should be compiled");
assert!(!result.contains("{{"), "nested bindings should be compiled");
assert!(
!result.contains("?disabled"),
"nested boolean attrs should be compiled"
);
}
#[test]
fn test_multiple_events_on_same_element() {
let result = generate_compiled_template(
"my-comp",
r#"<input @keydown="{onKey(e)}" @focus="{onFocus()}" />"#,
);
assert_no_client_markers(&result);
assert!(result.contains("\"keydown\""), "keydown event expected");
assert!(result.contains("\"onKey\""), "onKey handler expected");
assert!(result.contains("\"focus\""), "focus event expected");
assert!(result.contains("\"onFocus\""), "onFocus handler expected");
}
#[test]
fn test_regular_tag_uses_single_event_marker_per_element() {
let mut meta = TemplateSectionMeta::default();
let (tag_html, _) = parse_regular_tag(
r#"<input @keydown="{onKey(e)}" @focus="{onFocus()}" />"#,
&mut meta,
)
.expect("regular tag should parse");
assert_eq!(tag_html, r#"<input data-ev="2" />"#);
}
#[test]
fn test_empty_template() {
let result = generate_compiled_template("my-comp", "");
assert!(result.contains("__webui.templates"));
assert!(result.contains("h:\"\""), "empty html expected");
assert!(!result.contains(",t:"), "no text bindings");
assert!(!result.contains(",c:"), "no conditionals");
assert!(!result.contains(",r:"), "no repeats");
assert!(!result.contains(",e:"), "no events");
assert!(!result.contains(",re:"), "no root events");
}
#[test]
fn test_whitespace_only_template() {
let result = generate_compiled_template("my-comp", " \n ");
assert!(result.contains("__webui.templates"));
assert!(
result.contains("h:\"\""),
"whitespace-only should produce empty html"
);
}
#[test]
fn test_multiple_text_bindings() {
let result = generate_compiled_template(
"my-comp",
r#"<p>{{first}}</p><p>{{second}}</p><p>{{third}}</p>"#,
);
assert_no_client_markers(&result);
assert!(result.contains(
r#",tx:[[[[0],0],[["first"]]],[[[1],0],[["second"]]],[[[2],0],[["third"]]]]"#
));
assert!(result.contains("\"first\""));
assert!(result.contains("\"second\""));
assert!(result.contains("\"third\""));
}
#[test]
fn test_multiple_conditionals() {
let result = generate_compiled_template(
"my-comp",
r#"<if condition="a"><span>A</span></if><if condition="b"><span>B</span></if>"#,
);
assert_no_client_markers(&result);
}
#[test]
fn test_locator_paths_merge_adjacent_static_text_nodes() {
let result = generate_compiled_template(
"mp-product-grid",
r#"
<template shadowrootmode="open">
<if condition="products.length == 0 && query">
<p class="empty-results">There are no products that match "<strong>{{query}}</strong>"</p>
</if>
<div class="grid">
<for each="product in products">
<mp-product-card
handle="{{product.handle}}"
title="{{product.title}}"
price="{{product.price}}"
gradient="{{product.gradient}}"
image-url="{{product.imageUrl}}"
image-loading="lazy"
variant="grid"
></mp-product-card>
</for>
</div>
</template>
"#,
);
assert_no_client_markers(&result);
}
#[test]
fn test_mixed_bindings_events_refs() {
let result = generate_compiled_template(
"my-comp",
r#"<button w-ref="{myBtn}" @click="{onClick()}">{{label}}</button>"#,
);
assert_no_client_markers(&result);
assert!(result.contains("w-ref"));
assert!(result.contains("myBtn"));
assert!(result.contains("\"click\""));
assert!(result.contains("\"onClick\""));
assert!(result.contains(r#",tx:[[[[0],0],[["label"]]]]"#));
assert!(result.contains("\"label\""));
}
#[test]
fn test_event_without_e_arg() {
let result =
generate_compiled_template("my-comp", r#"<button @click="{onClick()}">Go</button>"#);
assert!(
result.contains(r#"["click","onClick",0,[0]]"#),
"needs_event should be 0"
);
}
#[test]
fn test_root_event_without_e_arg() {
let result = generate_compiled_template(
"my-comp",
r#"<template shadowrootmode="open" @submit="{onSubmit()}"><form>hi</form></template>"#,
);
assert!(result.contains(",re:["));
assert!(result.contains("\"submit\""));
assert!(result.contains("\"onSubmit\""));
assert!(result.contains(",0]"), "needs_event should be 0 for no (e)");
}
#[test]
fn test_triple_brace_raw_binding() {
let result = generate_compiled_template("my-comp", r#"<div>{{{rawHtml}}}</div>"#);
assert_no_client_markers(&result);
assert!(
result.contains(r#",tx:[[[[0],0],[["rawHtml"]],1]]"#),
"triple brace produces text locator with raw flag: {result}"
);
assert!(
result.contains("\"rawHtml\""),
"rawHtml path in text bindings"
);
}
#[test]
fn test_for_with_complex_body() {
let result = generate_compiled_template(
"my-comp",
r#"<for each="user in users"><div class="card"><h2>{{user.name}}</h2><p>{{user.bio}}</p></div></for>"#,
);
assert_no_client_markers(&result);
assert!(result.contains("\"users\""), "collection path");
assert!(result.contains("\"user\""), "item variable");
assert!(result.contains(",b:["), "compiled block table expected");
assert!(
!result.contains("{{user.name}}"),
"bindings should be compiled"
);
assert!(
!result.contains("{{user.bio}}"),
"bindings should be compiled"
);
}
#[test]
fn test_mixed_text_uses_text_runs() {
let result = generate_compiled_template("my-comp", r#"<p>Hello {{name}}!</p>"#);
assert_no_client_markers(&result);
assert!(result.contains(r#"h:"<p></p>""#));
assert!(result.contains(r#",tx:[[[[0],0],["Hello ",["name"],"!"]]]"#));
}
#[test]
fn test_js_string_escaping() {
let result = generate_compiled_template(
"my-comp",
r#"<if condition="name == "test""><span>ok</span></if>"#,
);
assert!(result.contains(",c:["), "conditional array present");
assert!(
result.contains(r#"function(v,s){return v("name",s)==v(""test"",s)}"#),
"condition function with escaped quotes should be present: {}",
result
);
}
#[test]
fn test_js_string_escapes_script_breakout_sequences() {
let result = generate_compiled_template(
"my-comp",
r#"<div title="</script><script>alert(1)</script>"></div>"#,
);
assert!(
result.contains(r#"\u003C/script><script>alert(1)\u003C/script>"#),
"script-closing sequences should be neutralized inside metadata strings: {result}"
);
assert!(
!result.contains(r#"title=\"</script><script>alert(1)</script>\""#),
"raw </script> must not appear inside serialized metadata strings: {result}"
);
}
#[test]
fn test_js_string_escapes_line_separator_codepoints() {
let html = format!("<p>before{}middle{}after</p>", '\u{2028}', '\u{2029}');
let result = generate_compiled_template("my-comp", &html);
assert!(
result.contains(r#"\u2028"#),
"U+2028 should be escaped in metadata strings: {result}"
);
assert!(
result.contains(r#"\u2029"#),
"U+2029 should be escaped in metadata strings: {result}"
);
}
#[test]
fn test_on_element_parsed_encodes_12_bytes() {
let mut plugin = WebUIParserPlugin::new();
let _ = plugin.classify_attribute("@click");
let _ = plugin.classify_attribute("@keydown");
let data = plugin.finish_element(3);
assert!(data.is_some());
let bytes = data.unwrap();
assert_eq!(bytes.len(), 12, "plugin data should be 12 bytes");
let binding_count = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let event_start = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let event_count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
assert_eq!(binding_count, 3);
assert_eq!(event_start, 0);
assert_eq!(event_count, 2);
}
#[test]
fn test_on_element_parsed_no_data_when_empty() {
let mut plugin = WebUIParserPlugin::new();
let data = plugin.finish_element(0);
assert!(
data.is_none(),
"no plugin data when no bindings and no events"
);
}
#[test]
fn test_on_element_parsed_events_only() {
let mut plugin = WebUIParserPlugin::new();
let _ = plugin.classify_attribute("@click");
let data = plugin.finish_element(0);
assert!(
data.is_some(),
"should emit data for events even with 0 bindings"
);
let bytes = data.unwrap();
let binding_count = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let event_count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
assert_eq!(binding_count, 0);
assert_eq!(event_count, 1);
}
#[test]
fn test_event_index_increments_across_elements() {
let mut plugin = WebUIParserPlugin::new();
let _ = plugin.classify_attribute("@click");
let _ = plugin.classify_attribute("@focus");
let data1 = plugin.finish_element(0).unwrap();
let ev_start_1 = u32::from_le_bytes([data1[4], data1[5], data1[6], data1[7]]);
assert_eq!(ev_start_1, 0, "first element starts at event 0");
let _ = plugin.classify_attribute("@blur");
let data2 = plugin.finish_element(0).unwrap();
let ev_start_2 = u32::from_le_bytes([data2[4], data2[5], data2[6], data2[7]]);
assert_eq!(
ev_start_2, 2,
"second element starts at event 2 (after first element's 2 events)"
);
}
#[test]
fn test_text_run_decodes_html_entities_in_static_parts() {
let result =
generate_compiled_template("my-comp", "<nav>{{sectionName}} > {{topicName}}</nav>");
assert_no_client_markers(&result);
assert!(
result.contains(r#"" > ""#),
"static text run part should contain decoded '>' not '>': {}",
result
);
assert!(
!result.contains(r#"" > ""#),
"static text run part should not contain raw '>': {}",
result
);
}
#[test]
fn test_parse_event_handler_bare_name_is_invalid() {
assert!(matches!(
parse_event_handler("{closeMenu}"),
EventHandler::Invalid(ref name) if name == "closeMenu"
));
}
#[test]
#[should_panic(expected = "Invalid @click handler")]
fn test_parse_event_attr_bare_name_panics() {
let input = r#"<button @click="{closeMenu}">Click</button>"#;
parse_event_attr(input, 8);
}
#[test]
fn test_parse_event_handler_with_parens() {
assert!(matches!(
parse_event_handler("{onClick()}"),
EventHandler::Valid(ref name, false) if name == "onClick"
));
}
#[test]
fn test_parse_event_handler_with_event_arg() {
assert!(matches!(
parse_event_handler("{onClick(e)}"),
EventHandler::Valid(ref name, true) if name == "onClick"
));
}
#[test]
fn test_parse_event_handler_empty_value() {
assert!(matches!(parse_event_handler("{}"), EventHandler::Empty));
}
#[test]
fn test_attribute_to_camel_aria() {
use webui_protocol::attrs::attribute_to_camel;
assert_eq!(attribute_to_camel("aria-describedby"), "ariaDescribedBy");
assert_eq!(attribute_to_camel("aria-labelledby"), "ariaLabelledBy");
assert_eq!(
attribute_to_camel("aria-activedescendant"),
"ariaActiveDescendant"
);
assert_eq!(attribute_to_camel("aria-label"), "ariaLabel");
assert_eq!(attribute_to_camel("aria-hidden"), "ariaHidden");
}
#[test]
fn test_attribute_to_camel_html_global() {
use webui_protocol::attrs::attribute_to_camel;
assert_eq!(attribute_to_camel("readonly"), "readOnly");
assert_eq!(attribute_to_camel("tabindex"), "tabIndex");
assert_eq!(attribute_to_camel("accesskey"), "accessKey");
assert_eq!(attribute_to_camel("contenteditable"), "contentEditable");
assert_eq!(attribute_to_camel("inputmode"), "inputMode");
assert_eq!(attribute_to_camel("maxlength"), "maxLength");
assert_eq!(attribute_to_camel("formaction"), "formAction");
}
}