pub(crate) mod css_module;
pub(crate) mod html_encode;
pub mod plugin;
pub mod route_handler;
pub mod route_matcher;
pub(crate) mod route_renderer;
pub use html_encode::encode_safe;
use plugin::BootstrapExtensionContext;
use plugin::HandlerPlugin;
use plugin::WebUiTemplatePayload;
use route_matcher::CompiledRouteCache;
use serde::ser::SerializeMap;
use serde::Serialize;
use serde_json::Value;
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use thiserror::Error;
use webui_expressions::{evaluate_with_resolver, ExpressionError};
use webui_protocol::{web_ui_fragment::Fragment, WebUIFragment, WebUIProtocol};
use webui_state::find_value_by_dotted_path_ref;
const CLIENT_STATE_TOKEN_KEY: &str = "tokens";
#[derive(Debug, Error)]
pub enum HandlerError {
#[error("Rendering error: {0}")]
Rendering(String),
#[error("Rendering invariant error: {0}")]
Invariant(String),
#[error("Missing fragment: {0}")]
MissingFragment(String),
#[error("Missing data field: {0}")]
MissingData(String),
#[error("Type error: {0}")]
TypeError(String),
#[error("Protocol error: {0}")]
Protocol(#[from] webui_protocol::ProtocolError),
#[error("Evaluation error: {0}")]
Evaluation(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Writer error: {0}")]
Writer(String),
#[error("Plugin data error: {0}")]
PluginData(String),
#[error("client disconnected")]
ClientDisconnected,
#[error("streaming flush timed out")]
StreamTimeout,
}
pub type Result<T> = std::result::Result<T, HandlerError>;
pub trait ResponseWriter {
fn write(&mut self, content: &str) -> Result<()>;
fn end(&mut self) -> Result<()>;
}
pub struct RenderOptions<'a> {
pub entry_id: &'a str,
pub request_path: &'a str,
pub nonce: Option<&'a str>,
pub head_inject: Option<&'a str>,
pub body_inject: Option<&'a str>,
}
impl<'a> RenderOptions<'a> {
#[must_use]
pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
Self {
entry_id,
request_path,
nonce: None,
head_inject: None,
body_inject: None,
}
}
#[must_use]
pub fn with_nonce(mut self, nonce: &'a str) -> Self {
self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
self
}
#[must_use]
pub fn with_head_inject(mut self, html: &'a str) -> Self {
self.head_inject = if html.is_empty() { None } else { Some(html) };
self
}
#[must_use]
pub fn with_body_inject(mut self, html: &'a str) -> Self {
self.body_inject = if html.is_empty() { None } else { Some(html) };
self
}
}
pub struct WebUIHandler {
plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
}
struct WebUIProcessContext<'a> {
protocol: &'a WebUIProtocol,
state: &'a Value,
writer: &'a mut dyn ResponseWriter,
local_vars: HashMap<String, Value>,
component_attrs: HashMap<String, Value>,
request_path: &'a str,
route_base: Cow<'a, str>,
rendered_components: HashSet<String>,
plugin: Option<Box<dyn HandlerPlugin>>,
route_children: Vec<webui_protocol::WebUiFragmentRoute>,
entry_id: &'a str,
nonce: Option<&'a str>,
component_index_cache: Option<HashMap<String, u32>>,
head_inject: Option<&'a str>,
body_inject: Option<&'a str>,
head_end_emitted: bool,
body_end_emitted: bool,
route_cache: CompiledRouteCache,
route_chain_index: usize,
}
struct WebUiBootstrap<'a> {
state: &'a Value,
chain: &'a [Value],
inventory: &'a str,
nonce: Option<&'a str>,
css_hrefs: &'a [&'a str],
style_specs: &'a [&'a str],
templates: &'a [WebUiTemplatePayload<'a>],
}
fn component_attr_name(name: &str) -> String {
let stripped = name.strip_prefix(':').unwrap_or(name);
webui_protocol::attrs::attribute_to_camel(stripped)
}
fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
if n == 0 {
return writer.write("0");
}
let mut buf = [0u8; 20];
let mut pos = buf.len();
while n > 0 {
pos -= 1;
#[allow(clippy::cast_possible_truncation)]
let digit = (n % 10) as u8;
buf[pos] = b'0' + digit;
n /= 10;
}
match std::str::from_utf8(&buf[pos..]) {
Ok(s) => writer.write(s),
Err(_) => writer.write("0"),
}
}
pub(crate) fn write_script_safe_json<T>(writer: &mut dyn ResponseWriter, value: &T) -> Result<()>
where
T: Serialize + ?Sized,
{
let mut json = Vec::with_capacity(256);
serde_json::to_writer(&mut json, value)
.map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
let json = std::str::from_utf8(&json)
.map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
write_script_safe_json_str(writer, json)
}
fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
let mut start = 0;
while start < json.len() {
let rest = &json[start..];
let Some(offset) = rest.find("</") else {
writer.write(rest)?;
return Ok(());
};
if offset > 0 {
writer.write(&rest[..offset])?;
}
writer.write("<\\/")?;
start += offset + 2;
}
Ok(())
}
fn write_json_field_name(
writer: &mut dyn ResponseWriter,
wrote_field: &mut bool,
name: &str,
) -> Result<()> {
if *wrote_field {
writer.write(",")?;
}
*wrote_field = true;
writer.write("\"")?;
writer.write(name)?;
writer.write("\":")
}
fn write_json_field<T>(
writer: &mut dyn ResponseWriter,
wrote_field: &mut bool,
name: &str,
value: &T,
) -> Result<()>
where
T: Serialize + ?Sized,
{
write_json_field_name(writer, wrote_field, name)?;
write_script_safe_json(writer, value)
}
struct ClientState<'a> {
value: &'a Value,
}
impl Serialize for ClientState<'_> {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let Value::Object(map) = self.value else {
return self.value.serialize(serializer);
};
if !map.contains_key(CLIENT_STATE_TOKEN_KEY) {
return self.value.serialize(serializer);
}
let mut out = serializer.serialize_map(None)?;
for (key, value) in map {
if key == CLIENT_STATE_TOKEN_KEY {
continue;
}
out.serialize_entry(key, value)?;
}
out.end()
}
}
fn write_webui_bootstrap(
writer: &mut dyn ResponseWriter,
bootstrap: WebUiBootstrap<'_>,
) -> Result<()> {
let mut wrote_field = false;
writer.write("{")?;
if !bootstrap.chain.is_empty() {
write_json_field(writer, &mut wrote_field, "chain", bootstrap.chain)?;
}
if !bootstrap.css_hrefs.is_empty() {
write_json_field(writer, &mut wrote_field, "css", bootstrap.css_hrefs)?;
}
write_json_field(writer, &mut wrote_field, "inventory", bootstrap.inventory)?;
if let Some(nonce) = bootstrap.nonce {
write_json_field(writer, &mut wrote_field, "nonce", nonce)?;
}
write_json_field(
writer,
&mut wrote_field,
"state",
&ClientState {
value: bootstrap.state,
},
)?;
if !bootstrap.style_specs.is_empty() {
write_json_field(writer, &mut wrote_field, "styles", bootstrap.style_specs)?;
}
if bootstrap
.templates
.iter()
.any(|template| !template.template_json.is_empty())
{
write_json_field_name(writer, &mut wrote_field, "templates")?;
write_webui_template_json_map(writer, bootstrap.templates)?;
}
writer.write("}")
}
fn write_webui_data_block(
writer: &mut dyn ResponseWriter,
bootstrap: WebUiBootstrap<'_>,
) -> Result<()> {
writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
if let Some(nonce) = bootstrap.nonce {
writer.write(" nonce=\"")?;
writer.write(nonce)?;
writer.write("\"")?;
}
writer.write(">")?;
write_webui_bootstrap(writer, bootstrap)?;
writer.write("</script>\n")
}
fn write_webui_template_json_map(
writer: &mut dyn ResponseWriter,
templates: &[WebUiTemplatePayload<'_>],
) -> Result<()> {
writer.write("{")?;
let mut wrote = false;
for template in templates {
if template.template_json.is_empty() {
continue;
}
if wrote {
writer.write(",")?;
}
wrote = true;
write_script_safe_json(writer, template.tag_name)?;
writer.write(":")?;
write_script_safe_json_str(writer, template.template_json)?;
}
writer.write("}")
}
fn resolve_value_from_sources<'ctx, 'state>(
path: &str,
local_vars: &'ctx HashMap<String, Value>,
state: &'state Value,
) -> Option<Cow<'ctx, Value>>
where
'state: 'ctx,
{
if let Some(first_part) = path.split('.').next() {
if let Some(local_value) = local_vars.get(first_part) {
if first_part.len() == path.len() {
return Some(Cow::Borrowed(local_value));
}
let remaining = &path[first_part.len() + 1..];
if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
return Some(value);
}
}
}
find_value_by_dotted_path_ref(path, state)
}
impl WebUIHandler {
pub fn new() -> Self {
Self {
plugin_factory: None,
}
}
pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
Self {
plugin_factory: Some(factory),
}
}
pub fn handle<'a>(
&self,
protocol: &'a WebUIProtocol,
state: &'a Value,
options: &RenderOptions<'a>,
writer: &'a mut dyn ResponseWriter,
) -> Result<()> {
if !protocol.fragments.contains_key(options.entry_id) {
return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
}
let mut context = WebUIProcessContext {
protocol,
state,
writer,
local_vars: HashMap::new(),
component_attrs: HashMap::new(),
request_path: options.request_path,
route_base: Cow::Borrowed("/"),
rendered_components: HashSet::new(),
plugin: self.plugin_factory.map(|f| f()),
route_children: Vec::new(),
entry_id: options.entry_id,
nonce: options.nonce.filter(|s| !s.is_empty()),
head_inject: options.head_inject.filter(|s| !s.is_empty()),
body_inject: options.body_inject.filter(|s| !s.is_empty()),
head_end_emitted: false,
component_index_cache: None,
body_end_emitted: false,
route_cache: CompiledRouteCache::new(),
route_chain_index: 0,
};
self.process_fragment_id(options.entry_id, &mut context)?;
writer.end()?;
Ok(())
}
pub fn handle_as_component<'a>(
&self,
protocol: &'a WebUIProtocol,
state: &'a Value,
entry_id: &'a str,
writer: &'a mut dyn ResponseWriter,
) -> Result<()> {
if !protocol.fragments.contains_key(entry_id) {
return Err(HandlerError::MissingFragment(entry_id.to_string()));
}
let mut context = WebUIProcessContext {
protocol,
state,
writer,
local_vars: HashMap::new(),
component_attrs: HashMap::new(),
request_path: "",
route_base: Cow::Borrowed("/"),
rendered_components: HashSet::new(),
plugin: self.plugin_factory.map(|f| f()),
route_children: Vec::new(),
entry_id,
nonce: None,
head_inject: None,
body_inject: None,
head_end_emitted: false,
component_index_cache: None,
body_end_emitted: false,
route_cache: CompiledRouteCache::new(),
route_chain_index: 0,
};
if let Some(p) = &mut context.plugin {
p.push_scope();
}
self.process_fragment_id(entry_id, &mut context)?;
if let Some(p) = &mut context.plugin {
p.pop_scope();
}
writer.end()?;
Ok(())
}
fn process_fragment_id(
&self,
fragment_id: &str,
context: &mut WebUIProcessContext,
) -> Result<()> {
if let Some(fragment_list) = context.protocol.fragments.get(fragment_id) {
self.process_fragment(&fragment_list.fragments, context)
} else {
Err(HandlerError::MissingFragment(fragment_id.to_string()))
}
}
fn process_fragment(
&self,
fragments: &[WebUIFragment],
context: &mut WebUIProcessContext,
) -> Result<()> {
let best_route = route_renderer::find_best_route_match(
fragments,
context.request_path,
&context.route_base,
&mut context.route_cache,
);
for item in fragments {
match item.fragment.as_ref() {
Some(Fragment::Raw(raw)) => {
context.writer.write(&raw.value)?;
}
Some(Fragment::Component(component)) => {
self.process_component(component, context)?;
}
Some(Fragment::ForLoop(for_loop)) => {
self.process_for_loop(for_loop, context)?;
}
Some(Fragment::Signal(signal)) => {
self.process_signal(signal, context)?;
}
Some(Fragment::IfCond(if_cond)) => {
self.process_if(if_cond, context)?;
}
Some(Fragment::Attribute(attr)) => {
self.process_attribute(attr, context)?;
}
Some(Fragment::Plugin(plugin_frag)) => {
if let Some(p) = &mut context.plugin {
p.on_element_data(&plugin_frag.data, context.writer)?;
}
}
Some(Fragment::Route(route_frag)) => {
self.process_route(route_frag, &best_route, context)?;
}
Some(Fragment::Outlet(_)) => {
self.process_outlet(context)?;
}
None => {}
}
}
Ok(())
}
fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
let mut children = std::mem::take(&mut context.route_children);
if children.is_empty() {
return Ok(());
}
let request_segments = route_matcher::split_request_path(context.request_path);
let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
for (idx, child) in children.iter().enumerate() {
let resolved = route_matcher::resolve_route_path_cow(&child.path, &context.route_base);
if let Some(m) = route_matcher::match_route_cached_with_segments(
&mut context.route_cache,
resolved.as_ref(),
&request_segments,
child.exact,
) {
let is_better = best
.as_ref()
.is_none_or(|(_, prev)| m.specificity > prev.specificity);
if is_better {
best = Some((idx, m));
}
}
}
let grandchildren = if let Some((idx, _)) = &best {
std::mem::take(&mut children[*idx].children)
} else {
Vec::new()
};
if let Some((idx, ref rm)) = best {
let matched_child = &children[idx];
let comp = &matched_child.fragment_id;
if !comp.is_empty() {
let saved_route_base = context.route_base.clone();
let saved_route_children = std::mem::take(&mut context.route_children);
if rm.consumed_segments > 0 {
context.route_base = Cow::Owned(route_matcher::compute_route_base(
context.request_path,
rm.consumed_segments,
));
}
context.route_children = grandchildren;
context.writer.write("<webui-route")?;
if !matched_child.path.is_empty() {
context.writer.write(" path=\"")?;
context.writer.write(&matched_child.path)?;
context.writer.write("\"")?;
}
context.writer.write(" component=\"")?;
context.writer.write(comp)?;
context.writer.write("\"")?;
if matched_child.exact {
context.writer.write(" exact")?;
}
route_renderer::write_route_pending_attrs(context.writer, matched_child)?;
let ri = context.route_chain_index;
context.route_chain_index += 1;
context.writer.write(" data-ri=\"")?;
write_usize(context.writer, ri)?;
context.writer.write("\" active>")?;
context.writer.write("<")?;
context.writer.write(comp)?;
if let Some(p) = &context.plugin {
p.write_route_component_state(context.state, context.writer)?;
}
context.writer.write(">")?;
self.process_component(
&webui_protocol::WebUIFragmentComponent {
fragment_id: comp.clone(),
},
context,
)?;
context.writer.write("</")?;
context.writer.write(comp)?;
context.writer.write(">")?;
context.writer.write("</webui-route>")?;
context.route_base = saved_route_base;
context.route_children = saved_route_children;
}
}
for (idx, child) in children.iter().enumerate() {
let is_matched = best.as_ref().is_some_and(|(bi, _)| *bi == idx);
if !is_matched && !child.fragment_id.is_empty() {
context.writer.write("<webui-route")?;
if !child.path.is_empty() {
context.writer.write(" path=\"")?;
context.writer.write(&child.path)?;
context.writer.write("\"")?;
}
context.writer.write(" component=\"")?;
context.writer.write(&child.fragment_id)?;
context.writer.write("\"")?;
if child.exact {
context.writer.write(" exact")?;
}
route_renderer::write_route_pending_attrs(context.writer, child)?;
context
.writer
.write(" style=\"display:none\"></webui-route>")?;
}
}
Ok(())
}
fn emit_css_module_importmap(
&self,
specifier: &str,
css: &str,
context: &mut WebUIProcessContext,
) -> Result<()> {
let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
context.writer.write(&tag)?;
Ok(())
}
fn emit_css_module(
&self,
component: &webui_protocol::WebUIFragmentComponent,
context: &mut WebUIProcessContext,
) -> Result<()> {
if !context.rendered_components.contains(&component.fragment_id) {
if let Some(css) = context
.protocol
.components
.get(&component.fragment_id)
.map(|c| c.css.as_str())
.filter(|s| !s.is_empty())
{
self.emit_css_module_importmap(&component.fragment_id, css, context)?;
}
}
Ok(())
}
fn process_route(
&self,
route_frag: &webui_protocol::WebUiFragmentRoute,
best_route: &Option<(String, route_matcher::RouteMatch)>,
context: &mut WebUIProcessContext,
) -> Result<()> {
let is_matched = best_route
.as_ref()
.is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
context.writer.write("<webui-route")?;
if !route_frag.path.is_empty() {
context.writer.write(" path=\"")?;
context.writer.write(&route_frag.path)?;
context.writer.write("\"")?;
}
if !route_frag.fragment_id.is_empty() {
context.writer.write(" component=\"")?;
context.writer.write(&route_frag.fragment_id)?;
context.writer.write("\"")?;
}
if route_frag.exact {
context.writer.write(" exact")?;
}
route_renderer::write_route_pending_attrs(context.writer, route_frag)?;
if is_matched {
let ri = context.route_chain_index;
context.route_chain_index += 1;
context.writer.write(" data-ri=\"")?;
write_usize(context.writer, ri)?;
context.writer.write("\" active>")?;
if !route_frag.fragment_id.is_empty() {
let saved_route_base = context.route_base.clone();
let saved_route_children = std::mem::take(&mut context.route_children);
if let Some((_, ref rm)) = best_route {
context.route_base = Cow::Owned(route_matcher::compute_route_base(
context.request_path,
rm.consumed_segments,
));
}
context.route_children = route_frag.children.clone();
let comp = webui_protocol::WebUIFragmentComponent {
fragment_id: route_frag.fragment_id.clone(),
};
context.writer.write("<")?;
context.writer.write(&route_frag.fragment_id)?;
if let Some(p) = &context.plugin {
p.write_route_component_state(context.state, context.writer)?;
}
context.writer.write(">")?;
self.process_component(&comp, context)?;
context.writer.write("</")?;
context.writer.write(&route_frag.fragment_id)?;
context.writer.write(">")?;
context.route_base = saved_route_base;
context.route_children = saved_route_children;
}
} else {
context.writer.write(" style=\"display:none\">")?;
}
context.writer.write("</webui-route>")?;
Ok(())
}
fn process_component(
&self,
component: &webui_protocol::WebUIFragmentComponent,
context: &mut WebUIProcessContext,
) -> Result<()> {
if !context.rendered_components.contains(&component.fragment_id) {
self.emit_css_module(component, context)?;
}
context
.rendered_components
.insert(component.fragment_id.clone());
let saved_local_vars = std::mem::take(&mut context.local_vars);
let saved_component_attrs = std::mem::take(&mut context.component_attrs);
context.local_vars = saved_component_attrs;
if let Some(p) = &mut context.plugin {
p.push_scope();
}
self.process_fragment_id(&component.fragment_id, context)?;
if let Some(p) = &mut context.plugin {
p.pop_scope();
}
context.local_vars = saved_local_vars;
context.component_attrs = HashMap::new();
Ok(())
}
fn resolve_value(&self, path: &str, context: &WebUIProcessContext<'_>) -> Option<Value> {
resolve_value_from_sources(path, &context.local_vars, context.state).map(Cow::into_owned)
}
fn evaluate_condition(
&self,
condition: &webui_protocol::ConditionExpr,
context: &WebUIProcessContext,
) -> Result<bool> {
let local_vars = &context.local_vars;
let state = context.state;
match evaluate_with_resolver(condition, |path| {
resolve_value_from_sources(path, local_vars, state)
}) {
Ok(result) => Ok(result),
Err(ExpressionError::MissingValue(_)) => Ok(false),
Err(e) => Err(HandlerError::Evaluation(e.to_string())),
}
}
fn process_for_loop(
&self,
for_loop: &webui_protocol::WebUIFragmentFor,
context: &mut WebUIProcessContext,
) -> Result<()> {
let collection_name = &for_loop.collection;
let items = match self.resolve_value(collection_name, context) {
Some(Value::Array(arr)) => arr,
Some(_) => {
return Err(HandlerError::TypeError(format!(
"Collection '{}' is not an array",
collection_name
)))
}
None => Vec::new(),
};
if let Some(p) = &mut context.plugin {
p.on_for_start(&for_loop.fragment_id, context.writer)?;
}
let item_name = for_loop.item.as_str();
let saved_value = context.local_vars.remove(item_name);
if !items.is_empty() {
context
.local_vars
.insert(item_name.to_string(), Value::Null);
}
for (i, item) in items.into_iter().enumerate() {
if let Some(p) = &mut context.plugin {
p.on_repeat_item_start(i, context.writer)?;
p.push_scope();
}
if let Some(slot) = context.local_vars.get_mut(item_name) {
*slot = item;
}
self.process_fragment_id(&for_loop.fragment_id, context)?;
if let Some(p) = &mut context.plugin {
p.pop_scope();
p.on_repeat_item_end(i, context.writer)?;
}
}
match saved_value {
Some(value) => {
context.local_vars.insert(item_name.to_string(), value);
}
None => {
context.local_vars.remove(item_name);
}
}
if let Some(p) = &mut context.plugin {
p.on_for_end(&for_loop.fragment_id, context.writer)?;
}
Ok(())
}
fn process_signal(
&self,
signal: &webui_protocol::WebUIFragmentSignal,
context: &mut WebUIProcessContext,
) -> Result<()> {
if signal.raw && signal.value == "head_end" && !context.head_end_emitted {
context.head_end_emitted = true;
if let Some(nonce) = context.nonce {
context
.writer
.write("<meta name=\"webui-nonce\" content=\"")?;
context
.writer
.write(&crate::html_encode::encode_safe(nonce))?;
context.writer.write("\">")?;
}
let is_link = context.protocol.css_strategy() == webui_protocol::CssStrategy::Link;
let is_shadow = context.protocol.dom_strategy() == webui_protocol::DomStrategy::Shadow;
if is_link {
let comp_index = context.component_index_cache.get_or_insert_with(|| {
crate::route_handler::build_component_index(context.protocol)
});
let (needed_components, _) =
crate::route_handler::get_needed_components_for_request(
context.protocol,
context.entry_id,
context.request_path,
"",
comp_index,
)?;
for name in &needed_components {
if let Some(href) = context
.protocol
.components
.get(name)
.map(|c| c.css_href.as_str())
.filter(|h| !h.is_empty())
{
if is_shadow {
context.writer.write("<link rel=\"preload\" href=\"")?;
context.writer.write(href)?;
context
.writer
.write("\" as=\"style\" data-webui-ssr-preload=\"style\">")?;
} else {
context.writer.write("<link rel=\"stylesheet\" href=\"")?;
context.writer.write(href)?;
context.writer.write("\">")?;
}
}
}
}
if let Some(html) = context.head_inject {
context.writer.write(html)?;
}
}
if signal.raw && signal.value == "body_end" && !context.body_end_emitted {
context.body_end_emitted = true;
if context.plugin.is_some() {
let reachable = crate::route_handler::collect_reachable_components_for_request(
context.protocol,
context.entry_id,
context.request_path,
&mut context.route_cache,
);
for name in &reachable {
if !context.rendered_components.contains(name) {
if let Some(css) = context
.protocol
.components
.get(name)
.map(|c| c.css.as_str())
.filter(|s| !s.is_empty())
{
self.emit_css_module_importmap(name, css, context)?;
}
}
}
let template_payloads = context
.plugin
.as_ref()
.and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
if template_payloads.is_none() {
if let Some(ref p) = context.plugin {
p.emit_templates(
context.protocol,
&reachable,
context.nonce,
context.writer,
)?;
}
}
let comp_index = context.component_index_cache.get_or_insert_with(|| {
crate::route_handler::build_component_index(context.protocol)
});
let inventory_hex = crate::route_handler::encode_component_inventory(
&context.rendered_components,
comp_index,
);
let chain = crate::route_handler::collect_route_chain(
context.protocol,
context.entry_id,
context.request_path,
&mut context.route_cache,
);
let chain_json: Vec<Value> = chain
.iter()
.map(crate::route_handler::RouteChainEntry::to_json)
.collect();
let is_link = context.protocol.css_strategy() == webui_protocol::CssStrategy::Link;
let mut css_hrefs: Vec<&str> = Vec::new();
if is_link {
for name in &reachable {
if let Some(href) = context
.protocol
.components
.get(name)
.map(|c| c.css_href.as_str())
.filter(|h| !h.is_empty())
{
css_hrefs.push(href);
}
}
}
let mut style_specs: Vec<&str> = Vec::new();
for name in &reachable {
if context
.protocol
.components
.get(name)
.map(|c| !c.css.is_empty())
.unwrap_or(false)
{
style_specs.push(name);
}
}
let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
write_webui_data_block(
context.writer,
WebUiBootstrap {
state: context.state,
chain: &chain_json,
inventory: &inventory_hex,
nonce: context.nonce,
css_hrefs: &css_hrefs,
style_specs: &style_specs,
templates: payloads,
},
)?;
if let Some(ref plugin) = context.plugin {
plugin.emit_bootstrap_extension(
BootstrapExtensionContext {
protocol: context.protocol,
components: &reachable,
payloads,
nonce: context.nonce,
},
context.writer,
)?;
}
}
if let Some(html) = context.body_inject {
context.writer.write(html)?;
}
}
if let Some(p) = &mut context.plugin {
p.on_binding_start(&signal.value, context.writer)?;
}
if let Some(value) = self.resolve_value(&signal.value, context) {
self.write_signal_value(&value, signal.raw, context.writer)?;
}
if let Some(p) = &mut context.plugin {
p.on_binding_end(&signal.value, context.writer)?;
}
Ok(())
}
fn write_signal_value(
&self,
value: &Value,
raw: bool,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
if raw {
match value {
Value::String(s) => writer.write(s),
_ => writer.write(&value.to_string()),
}
} else {
match value {
Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
_ => {
let s = value.to_string();
writer.write(&crate::html_encode::encode_safe(&s))
}
}
}
}
fn process_if(
&self,
if_cond: &webui_protocol::WebUIFragmentIf,
context: &mut WebUIProcessContext,
) -> Result<()> {
let condition = if_cond
.condition
.as_ref()
.ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
let condition_met = self.evaluate_condition(condition, context)?;
if let Some(p) = &mut context.plugin {
p.on_if_start(&if_cond.fragment_id, context.writer)?;
}
if condition_met {
if let Some(p) = &mut context.plugin {
p.push_scope();
}
self.process_fragment_id(&if_cond.fragment_id, context)?;
if let Some(p) = &mut context.plugin {
p.pop_scope();
}
}
if let Some(p) = &mut context.plugin {
p.on_if_end(&if_cond.fragment_id, context.writer)?;
}
Ok(())
}
fn process_attribute(
&self,
attr: &webui_protocol::WebUIFragmentAttribute,
context: &mut WebUIProcessContext,
) -> Result<()> {
if attr.attr_start {
context.component_attrs = HashMap::new();
}
if let Some(condition) = &attr.condition_tree {
let condition_met = self.evaluate_condition(condition, context)?;
if !attr.attr_skip {
let name = component_attr_name(&attr.name);
context
.component_attrs
.insert(name, Value::Bool(condition_met));
}
if condition_met {
context.writer.write(" ")?;
context.writer.write(&attr.name)?;
}
return Ok(());
}
if !attr.template.is_empty() {
let raw_value = self.render_template_attr_value(&attr.template, context)?;
let escaped = crate::html_encode::encode_safe(&raw_value);
write_attr(context.writer, &attr.name, &escaped)?;
if !attr.attr_skip {
let name = component_attr_name(&attr.name);
context
.component_attrs
.insert(name, Value::String(raw_value));
}
return Ok(());
}
if !attr.value.is_empty() {
if attr.raw_value {
write_attr(context.writer, &attr.name, &attr.value)?;
if !attr.attr_skip {
let name = component_attr_name(&attr.name);
context
.component_attrs
.insert(name, Value::String(attr.value.clone()));
}
} else if attr.complex {
if let Some(value) = self.resolve_value(&attr.value, context) {
if !attr.attr_skip {
let stripped = attr.name.strip_prefix(':').unwrap_or(&attr.name);
let name = component_attr_name(stripped);
context.component_attrs.insert(name, value);
}
}
} else {
let value = self.resolve_value(&attr.value, context);
match &value {
Some(Value::String(s)) => {
write_attr(
context.writer,
&attr.name,
&crate::html_encode::encode_safe(s),
)?;
}
Some(Value::Null) | None => {
write_attr(context.writer, &attr.name, "")?;
}
Some(other) => {
let s = other.to_string();
write_attr(
context.writer,
&attr.name,
&crate::html_encode::encode_safe(&s),
)?;
}
}
if !attr.attr_skip {
let name = component_attr_name(&attr.name);
context
.component_attrs
.insert(name, value.unwrap_or(Value::String(String::new())));
}
}
}
Ok(())
}
fn render_template_attr_value(
&self,
template_id: &str,
context: &WebUIProcessContext,
) -> Result<String> {
let fragments = context
.protocol
.fragments
.get(template_id)
.ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
let mut raw_value = String::new();
for frag in &fragments.fragments {
match frag.fragment.as_ref() {
Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
Some(Fragment::Signal(signal)) => {
if let Some(value) = self.resolve_value(&signal.value, context) {
match &value {
Value::String(s) => raw_value.push_str(s),
_ => raw_value.push_str(&value.to_string()),
}
}
}
_ => {}
}
}
Ok(raw_value)
}
pub fn render<'a>(
&self,
protocol: &'a WebUIProtocol,
state: &'a Value,
options: &RenderOptions<'a>,
writer: &'a mut dyn ResponseWriter,
) -> Result<()> {
let mut context = WebUIProcessContext {
protocol,
state,
writer,
local_vars: HashMap::new(),
component_attrs: HashMap::new(),
request_path: options.request_path,
route_base: Cow::Borrowed("/"),
rendered_components: HashSet::new(),
plugin: self.plugin_factory.map(|f| f()),
route_children: Vec::new(),
entry_id: options.entry_id,
nonce: options.nonce.filter(|s| !s.is_empty()),
head_inject: options.head_inject.filter(|s| !s.is_empty()),
body_inject: options.body_inject.filter(|s| !s.is_empty()),
head_end_emitted: false,
component_index_cache: None,
body_end_emitted: false,
route_cache: CompiledRouteCache::new(),
route_chain_index: 0,
};
self.process_fragment_id(options.entry_id, &mut context)?;
Ok(())
}
}
impl Default for WebUIHandler {
fn default() -> Self {
Self::new()
}
}
fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
writer.write(" ")?;
writer.write(name)?;
writer.write("=\"")?;
writer.write(value)?;
writer.write("\"")
}
pub fn handle(
protocol: &WebUIProtocol,
state: &Value,
options: &RenderOptions<'_>,
writer: &mut dyn ResponseWriter,
) -> Result<()> {
let handler = WebUIHandler::new();
handler.handle(protocol, state, options, writer)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use webui_protocol::{
web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
WebUIFragmentAttribute,
};
use webui_test_utils::test_json;
struct TestWriter {
content: RefCell<String>,
ended: RefCell<bool>,
}
impl TestWriter {
fn new() -> Self {
Self {
content: RefCell::new(String::new()),
ended: RefCell::new(false),
}
}
fn get_content(&self) -> String {
self.content.borrow().clone()
}
fn is_ended(&self) -> bool {
*self.ended.borrow()
}
}
impl ResponseWriter for TestWriter {
fn write(&mut self, content: &str) -> Result<()> {
self.content.borrow_mut().push_str(content);
Ok(())
}
fn end(&mut self) -> Result<()> {
*self.ended.borrow_mut() = true;
Ok(())
}
}
#[test]
fn test_handle_raw() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
assert!(
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer
)
.is_ok(),
"Failed to handle raw protocol"
);
assert_eq!(writer.get_content(), "Hello, WebUI!");
assert!(writer.is_ended());
}
#[test]
fn test_handle_signal() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Hello, "),
WebUIFragment::signal("name", false),
WebUIFragment::raw("!"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"name": "WebUI"});
let mut writer = TestWriter::new();
assert!(
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer
)
.is_ok(),
"Failed to handle signal protocol"
);
assert_eq!(writer.get_content(), "Hello, WebUI!");
assert!(writer.is_ended());
}
#[test]
fn test_handle_for_loop() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("People: "),
WebUIFragment::for_loop("person", "people", "person-item"),
],
},
);
fragments.insert(
"person-item".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::signal("person.name", false),
WebUIFragment::raw(", "),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"people": [
{"name": "Alice"},
{"name": "Bob"},
{"name": "Charlie"}
]
});
let mut writer = TestWriter::new();
assert!(
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer
)
.is_ok(),
"Failed to handle for loop protocol"
);
assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
assert!(writer.is_ended());
}
#[test]
fn test_handle_if_condition() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Status: "),
WebUIFragment::if_cond(
webui_protocol::ConditionExpr::identifier("isActive"),
"active-content",
),
WebUIFragment::raw("End"),
],
},
);
fragments.insert(
"active-content".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("Active")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state_true = test_json!({"isActive": true});
let mut writer_true = TestWriter::new();
assert!(
handle(
&protocol,
&state_true,
&RenderOptions::new("index.html", "/"),
&mut writer_true
)
.is_ok(),
"Failed to handle if condition (true case)"
);
assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
assert!(writer_true.is_ended());
let state_false = test_json!({"isActive": false});
let mut writer_false = TestWriter::new();
assert!(
handle(
&protocol,
&state_false,
&RenderOptions::new("index.html", "/"),
&mut writer_false
)
.is_ok(),
"Failed to handle if condition (false case)"
);
assert_eq!(writer_false.get_content(), "Status: End");
assert!(writer_false.is_ended());
}
#[test]
fn test_handle_component() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Component: "),
WebUIFragment::component("my-component"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
assert!(
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer
)
.is_ok(),
"Failed to handle component protocol"
);
assert_eq!(
writer.get_content(),
"Component: <div>Component Content</div>"
);
assert!(writer.is_ended());
}
#[test]
fn test_missing_fragment() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::component("missing-component")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
let result = handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
);
assert!(result.is_err());
if let Err(HandlerError::MissingFragment(fragment_id)) = result {
assert_eq!(fragment_id, "missing-component");
} else {
panic!("Expected MissingFragment error");
}
}
#[test]
fn test_missing_signal_renders_empty() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Hello, "),
WebUIFragment::signal("missing_field", false),
WebUIFragment::raw("!"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
assert!(
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer
)
.is_ok(),
"Missing signal should not produce an error"
);
assert_eq!(writer.get_content(), "Hello, !");
assert!(writer.is_ended());
}
#[test]
fn test_boolean_attr_true() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<button"),
WebUIFragment::attribute_boolean(
"disabled",
ConditionExpr::identifier("isDisabled"),
),
WebUIFragment::raw(">Click</button>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"isDisabled": true});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<button disabled>Click</button>");
}
#[test]
fn test_boolean_attr_false() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<button"),
WebUIFragment::attribute_boolean(
"disabled",
ConditionExpr::identifier("isDisabled"),
),
WebUIFragment::raw(">Click</button>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"isDisabled": false});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<button>Click</button>");
}
#[test]
fn test_boolean_attr_missing() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input type=\"checkbox\""),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
}
#[test]
fn test_boolean_attr_multiple() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input type=\"checkbox\""),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::attribute_boolean(
"disabled",
ConditionExpr::identifier("disabled"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": true, "disabled": false});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
}
#[test]
fn test_attribute_with_value() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute("value", "inputValue"),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"inputValue": "Hello"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input value=\"Hello\">");
}
#[test]
fn test_attribute_with_falsy_numeric() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div name=\"test\""),
WebUIFragment::attribute("handle", "number"),
WebUIFragment::raw("></div>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"number": 0});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div name=\"test\" handle=\"0\"></div>"
);
}
#[test]
fn test_attribute_array_value_is_escaped() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<a"),
WebUIFragment::attribute("href", "value"),
WebUIFragment::raw(">demo</a>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let content = writer.get_content();
assert!(
content.contains("""),
"Double quotes inside attribute value must be escaped: {content}"
);
assert_eq!(
content.matches("=\"").count(),
1,
"Only one attribute assignment expected: {content}"
);
}
#[test]
fn test_attribute_object_value_is_escaped() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div"),
WebUIFragment::attribute("data-cfg", "cfg"),
WebUIFragment::raw("></div>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let content = writer.get_content();
assert!(
content.contains("""),
"Double quotes inside attribute value must be escaped: {content}"
);
assert_eq!(
content.matches("=\"").count(),
1,
"Only one attribute assignment expected: {content}"
);
}
#[test]
fn test_mixed_attribute_template() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_template("value", "attr-1"),
WebUIFragment::raw(">"),
],
},
);
fragments.insert(
"attr-1".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("hello "),
WebUIFragment::signal("item", false),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"item": "world"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input value=\"hello world\">");
}
#[test]
fn test_raw_signal_not_escaped() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::signal("html", false),
WebUIFragment::signal("html", true),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"html": "<strong>hi</strong>"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<strong>hi</strong><strong>hi</strong>"
);
}
#[test]
fn test_nested_for_loop() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outer".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"inner".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"outerItems": [
{"innerItems": [{"name": "A"}, {"name": "B"}]},
{"innerItems": [{"name": "C"}]}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
);
}
#[test]
fn test_nested_for_with_signals() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("innerItem.name", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"outerItems": [
{"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
{"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
);
}
#[test]
fn test_nested_for_with_global_state() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::signal("globalOuter", false),
WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("innerItem.name", false),
WebUIFragment::signal("globalInner", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"globalOuter": "GO",
"globalInner": "GI",
"outerItems": [
{"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
{"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
);
}
#[test]
fn test_if_in_for_uses_local_state() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
},
);
fragments.insert(
"item-tpl".to_string(),
FragmentList {
fragments: vec![WebUIFragment::if_cond(
ConditionExpr::identifier("item.visible"),
"visible-tpl",
)],
},
);
fragments.insert(
"visible-tpl".to_string(),
FragmentList {
fragments: vec![WebUIFragment::signal("item.name", false)],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "Show");
}
#[test]
fn test_for_if_local_overrides_global() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
},
);
fragments.insert(
"item-tpl".to_string(),
FragmentList {
fragments: vec![WebUIFragment::if_cond(
ConditionExpr::identifier("item.flag"),
"show-tpl",
)],
},
);
fragments.insert(
"show-tpl".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("yes")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "yes");
}
#[test]
fn test_component_attr_state_simple() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-comp"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "Attribute Title".into(),
attr_start: true,
raw_value: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-comp"),
WebUIFragment::raw("</my-comp>"),
],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"title": "Global Title"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
);
}
#[test]
fn test_component_attr_state_template() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-comp"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "title-attr".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-comp"),
WebUIFragment::raw("</my-comp>"),
],
},
);
fragments.insert(
"title-attr".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("hello "),
WebUIFragment::signal("item", false),
],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"item": "<world>"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-comp title=\"hello <world>\"><span>hello <world></span></my-comp>"
);
}
#[test]
fn test_component_attr_camel_case() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-comp"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "data-title".into(),
template: "dt-attr".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-comp"),
WebUIFragment::raw("</my-comp>"),
],
},
);
fragments.insert(
"dt-attr".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("prefix "),
WebUIFragment::signal("item", false),
],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("dataTitle", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"item": "a&b"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-comp data-title=\"prefix a&b\"><span>prefix a&b</span></my-comp>"
);
}
#[test]
fn test_component_complex_attr() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-comp"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: ":item".into(),
value: "complexItem".into(),
attr_start: true,
complex: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-comp"),
WebUIFragment::raw("</my-comp>"),
],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("item.foo", false),
WebUIFragment::raw("</span><p>"),
WebUIFragment::signal("item.bar", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-comp><span>1</span><p>true</p></my-comp>"
);
}
#[test]
fn test_component_no_parent_pollution() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<parent"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "var".into(),
value: "var".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("parent"),
WebUIFragment::raw("</parent>"),
],
},
);
fragments.insert(
"parent".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Before: "),
WebUIFragment::signal("var", false),
WebUIFragment::raw("<child foo"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "var".into(),
value: "replaced".into(),
raw_value: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("child"),
WebUIFragment::raw("Label</child>After: "),
WebUIFragment::signal("var", false),
],
},
);
fragments.insert("child".to_string(), FragmentList { fragments: vec![] });
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"var": "original"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<parent var=\"original\">Before: original<child foo var=\"replaced\">Label</child>After: original</parent>"
);
}
#[test]
fn test_component_boolean_attr_state() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-comp"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "disabled".into(),
attr_start: true,
condition_tree: Some(ConditionExpr::identifier("isDisabled")),
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-comp"),
WebUIFragment::raw("</my-comp>"),
],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![WebUIFragment::if_cond(
ConditionExpr::identifier("disabled"),
"show",
)],
},
);
fragments.insert(
"show".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("disabled!")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"isDisabled": true});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-comp disabled>disabled!</my-comp>"
);
}
fn render_signal(value: &str) -> String {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::signal("v", false)],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"v": value});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
writer.get_content()
}
#[test]
fn test_escape_ampersand() {
assert_eq!(render_signal("&"), "&");
}
#[test]
fn test_escape_less_than() {
assert_eq!(render_signal("<"), "<");
}
#[test]
fn test_escape_greater_than() {
assert_eq!(render_signal(">"), ">");
}
#[test]
fn test_escape_double_quote() {
assert_eq!(render_signal("\""), """);
}
#[test]
fn test_escape_single_quote() {
let result = render_signal("'");
assert_eq!(
result, "'",
"Expected ' for single quote, got: {result}"
);
}
#[test]
fn test_escape_multiple_special_chars() {
let result = render_signal("<script>alert('xss');</script>");
assert!(
result.contains("<") && result.contains(">"),
"Expected escaped HTML, got: {}",
result
);
assert!(
!result.contains("<script>"),
"Should not contain raw <script> tag"
);
}
#[test]
fn test_escape_no_special_chars() {
assert_eq!(render_signal("Hello World"), "Hello World");
}
#[test]
fn test_escape_empty_string() {
assert_eq!(render_signal(""), "");
}
#[test]
fn test_escape_special_at_beginning() {
let result = render_signal("<Hello");
assert!(
result.starts_with("<"),
"Expected < at start, got: {}",
result
);
}
#[test]
fn test_escape_special_at_end() {
let result = render_signal("Hello>");
assert!(
result.ends_with(">"),
"Expected > at end, got: {}",
result
);
}
#[test]
fn test_escape_special_in_middle() {
let result = render_signal("Hel&lo");
assert!(
result.contains("&"),
"Expected & in middle, got: {}",
result
);
}
#[test]
fn test_boolean_attr_truthy_values() {
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": 1});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input checked>");
}
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": "yes"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input checked>");
}
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": {}});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input>");
}
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": "false"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input checked>");
}
}
#[test]
fn test_boolean_attr_falsy_values() {
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": 0});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input>");
}
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": ""});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input>");
}
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"checked": false});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input>");
}
{
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<input"),
WebUIFragment::attribute_boolean(
"checked",
ConditionExpr::identifier("checked"),
),
WebUIFragment::raw(">"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<input>");
}
}
#[test]
fn test_boolean_attr_expression_true() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<button"),
WebUIFragment::attribute_boolean(
"disabled",
ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
),
WebUIFragment::raw(">Click</button>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"itemCount": 5});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<button disabled>Click</button>");
}
#[test]
fn test_boolean_attr_expression_false() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<button"),
WebUIFragment::attribute_boolean(
"disabled",
ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
),
WebUIFragment::raw(">Click</button>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"itemCount": 3});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<button>Click</button>");
}
#[test]
fn test_nested_component_attr_capture() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<parent-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "parent-title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("parent-component"),
WebUIFragment::raw("</parent-component>"),
],
},
);
fragments.insert(
"parent-title".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Hello "),
WebUIFragment::signal("who", false),
],
},
);
fragments.insert(
"parent-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<child-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "child-title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("child-component"),
WebUIFragment::raw("</child-component>"),
],
},
);
fragments.insert(
"child-title".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Child of "),
WebUIFragment::signal("title", false),
],
},
);
fragments.insert(
"child-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"who": "<world>"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<parent-component title=\"Hello <world>\"><child-component title=\"Child of Hello <world>\"><span>Child of Hello <world></span></child-component></parent-component>"
);
}
#[test]
fn test_grandchild_attr_propagation() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<parent-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "p-title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("parent-component"),
WebUIFragment::raw("</parent-component>"),
],
},
);
fragments.insert(
"p-title".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
},
);
fragments.insert(
"parent-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<child-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "c-title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("child-component"),
WebUIFragment::raw("</child-component>"),
],
},
);
fragments.insert(
"c-title".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("C("),
WebUIFragment::signal("title", false),
WebUIFragment::raw(")-"),
WebUIFragment::signal("cExtra", false),
],
},
);
fragments.insert(
"child-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<grandchild-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("grandchild-component"),
WebUIFragment::raw("</grandchild-component>"),
],
},
);
fragments.insert(
"grandchild-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"p": "<p>", "cExtra": "x&y"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<parent-component title=\"P:<p>\"><child-component title=\"C(P:<p>)-x&y\"><grandchild-component title=\"C(P:<p>)-x&y\"><span>C(P:<p>)-x&y</span></grandchild-component></child-component></parent-component>"
);
}
#[test]
fn test_for_loop_component_attr() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<parent-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "parent-title-loop".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("parent-component"),
WebUIFragment::raw("</parent-component>"),
],
},
);
fragments.insert(
"parent-title-loop".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Parent:"),
WebUIFragment::signal("who", false),
],
},
);
fragments.insert(
"parent-component".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
},
);
fragments.insert(
"child-loop".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<child-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "child-title-loop".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("child-component"),
WebUIFragment::raw("</child-component>"),
],
},
);
fragments.insert(
"child-title-loop".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("Hi "),
WebUIFragment::signal("item.name", false),
WebUIFragment::raw(" / "),
WebUIFragment::signal("title", false),
],
},
);
fragments.insert(
"child-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<parent-component title=\"Parent:Bob\"><child-component title=\"Hi A<1> / Parent:Bob\"><span>Hi A<1> / Parent:Bob</span></child-component><child-component title=\"Hi B&2 / Parent:Bob\"><span>Hi B&2 / Parent:Bob</span></child-component></parent-component>"
);
}
#[test]
fn test_multiple_template_attrs() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
template: "attr-title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "data-title".into(),
template: "attr-data-title".into(),
attr_start: false,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "aria-label".into(),
template: "attr-aria-label".into(),
attr_start: false,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"attr-title".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
},
);
fragments.insert(
"attr-data-title".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
},
);
fragments.insert(
"attr-aria-label".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("|"),
WebUIFragment::signal("dataTitle", false),
WebUIFragment::raw("|"),
WebUIFragment::signal("ariaLabel", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component title=\"T:<t&1>\" data-title=\"D:d<2>\" aria-label=\"A:a&3\"><span>T:<t&1>|D:d<2>|A:a&3</span></my-component>"
);
}
#[test]
fn test_attr_priority_over_global() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "Attribute Title".into(),
raw_value: true,
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"title": "Global Title"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
);
}
#[test]
fn test_attr_priority_over_local_and_global() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
},
);
fragments.insert(
"loop".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "Attribute Title".into(),
raw_value: true,
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
);
}
#[test]
fn test_boolean_attr_first_component_attr() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "disabled".into(),
attr_start: true,
condition_tree: Some(ConditionExpr::identifier("isDisabled")),
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "label".into(),
value: "Component Label".into(),
raw_value: true,
attr_start: false,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::if_cond(
ConditionExpr::identifier("disabled"),
"disabledTemplate",
),
WebUIFragment::raw("<span>"),
WebUIFragment::signal("label", false),
WebUIFragment::raw("</span>"),
],
},
);
fragments.insert(
"disabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"isDisabled": true});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
);
}
#[test]
fn test_hyphenated_attr_camelcase() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "key-hyphen".into(),
value: "Local Value".into(),
raw_value: true,
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("keyHyphen", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"keyHyphen": "Global Value"});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
);
}
#[test]
fn test_skipped_component_attrs() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<test-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "class".into(),
value: "skippedClass".into(),
attr_start: true,
attr_skip: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "style".into(),
value: "skippedStyle".into(),
attr_skip: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "role".into(),
value: "skippedRole".into(),
attr_skip: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "data-testid".into(),
value: "skippedDataTestid".into(),
attr_skip: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "aria-label".into(),
value: "skippedAriaLabel".into(),
attr_skip: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "title".into(),
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("test-component"),
WebUIFragment::raw("</test-component>"),
],
},
);
fragments.insert(
"test-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("class", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("style", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("role", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("dataTestid", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("ariaLabel", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"title": "Hello",
"skippedClass": "my-class",
"skippedStyle": "color:red",
"skippedRole": "button",
"skippedDataTestid": "test-id",
"skippedAriaLabel": "label-text"
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<test-component class=\"my-class\" style=\"color:red\" role=\"button\" data-testid=\"test-id\" aria-label=\"label-text\" title=\"Hello\"><span>Hello-----</span></test-component>"
);
}
#[test]
fn test_attr_inherit_parent_to_child() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<parent-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "Parent Title".into(),
raw_value: true,
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("parent-component"),
WebUIFragment::raw("</parent-component>"),
],
},
);
fragments.insert(
"parent-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<h1>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</h1><child-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("child-component"),
WebUIFragment::raw("</child-component>"),
],
},
);
fragments.insert(
"child-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<h2>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</h2>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
);
}
#[test]
fn test_attr_inherit_deep() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<parent-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "Parent Title".into(),
raw_value: true,
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("parent-component"),
WebUIFragment::raw("</parent-component>"),
],
},
);
fragments.insert(
"parent-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<child-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "Child Title".into(),
raw_value: true,
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("child-component"),
WebUIFragment::raw("</child-component>"),
],
},
);
fragments.insert(
"child-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<grandchild-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "title".into(),
value: "title".into(),
attr_start: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("grandchild-component"),
WebUIFragment::raw("</grandchild-component>"),
],
},
);
fragments.insert(
"grandchild-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<h3>"),
WebUIFragment::signal("title", false),
WebUIFragment::raw("</h3>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<parent-component title=\"Parent Title\"><child-component title=\"Child Title\"><grandchild-component title=\"Child Title\"><h3>Child Title</h3></grandchild-component></child-component></parent-component>"
);
}
#[test]
fn test_complex_attr_access() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: ":item".into(),
value: "complexItem".into(),
attr_start: true,
complex: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("item.foo", false),
WebUIFragment::raw("</span><p>"),
WebUIFragment::signal("item.bar", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component><span>1</span><p>true</p></my-component>"
);
}
#[test]
fn test_complex_attr_for_loop() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop(
"item",
"list.items",
"listTemplate",
)],
},
);
fragments.insert(
"listTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: ":item".into(),
value: "item".into(),
attr_start: true,
complex: true,
..Default::default()
},
)),
},
WebUIFragment::component("item_component"),
],
},
);
fragments.insert(
"item_component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("item.name", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
}
#[test]
fn test_complex_attr_nested_for() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop(
"outer",
"data.outer",
"outerTemplate",
)],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop(
"middle",
"outer.middle",
"middleTemplate",
)],
},
);
fragments.insert(
"middleTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop(
"inner",
"middle.inner",
"innerTemplate",
)],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<card"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: ":outer".into(),
value: "outer".into(),
attr_start: true,
complex: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: ":middle".into(),
value: "middle".into(),
attr_start: false,
complex: true,
..Default::default()
},
)),
},
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: ":inner".into(),
value: "inner".into(),
attr_start: false,
complex: true,
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("card_component"),
WebUIFragment::raw("</card>"),
],
},
);
fragments.insert(
"card_component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("outer.label", false),
WebUIFragment::raw(" / "),
WebUIFragment::signal("middle.label", false),
WebUIFragment::raw(" / "),
WebUIFragment::signal("inner.label", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"data": {"outer": [
{"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
{"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
]}});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
);
}
#[test]
fn test_bool_component_state_true() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "disabled".into(),
attr_start: true,
condition_tree: Some(ConditionExpr::identifier("isDisabled")),
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::if_cond(
ConditionExpr::identifier("disabled"),
"disabledTemplate",
),
WebUIFragment::if_cond(
ConditionExpr::negated(ConditionExpr::identifier("disabled")),
"enabledTemplate",
),
],
},
);
fragments.insert(
"disabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
},
);
fragments.insert(
"enabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"isDisabled": true});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component disabled><span>Disabled</span></my-component>"
);
}
#[test]
fn test_bool_component_state_false() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<my-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "disabled".into(),
attr_start: true,
condition_tree: Some(ConditionExpr::identifier("isDisabled")),
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</my-component>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::if_cond(
ConditionExpr::identifier("disabled"),
"disabledTemplate",
),
WebUIFragment::if_cond(
ConditionExpr::negated(ConditionExpr::identifier("disabled")),
"enabledTemplate",
),
],
},
);
fragments.insert(
"disabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
},
);
fragments.insert(
"enabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"isDisabled": false});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<my-component><span>Enabled</span></my-component>"
);
}
#[test]
fn test_bool_component_state_forward() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<parent-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "disabled".into(),
attr_start: true,
condition_tree: Some(ConditionExpr::identifier("isDisabled")),
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("parent-component"),
WebUIFragment::raw("</parent-component>"),
],
},
);
fragments.insert(
"parent-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::if_cond(
ConditionExpr::identifier("disabled"),
"parentDisabledTemplate",
),
WebUIFragment::raw("<child-component"),
WebUIFragment {
fragment: Some(web_ui_fragment::Fragment::Attribute(
WebUIFragmentAttribute {
name: "disabled".into(),
attr_start: true,
condition_tree: Some(ConditionExpr::identifier("disabled")),
..Default::default()
},
)),
},
WebUIFragment::raw(">"),
WebUIFragment::component("child-component"),
WebUIFragment::raw("</child-component>"),
],
},
);
fragments.insert(
"parentDisabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
},
);
fragments.insert(
"child-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::if_cond(
ConditionExpr::identifier("disabled"),
"childDisabledTemplate",
),
WebUIFragment::if_cond(
ConditionExpr::negated(ConditionExpr::identifier("disabled")),
"childEnabledTemplate",
),
],
},
);
fragments.insert(
"childDisabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
},
);
fragments.insert(
"childEnabledTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
},
);
{
let protocol = WebUIProtocol::new(fragments.clone());
let state = test_json!({"isDisabled": true});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
);
}
{
let protocol = WebUIProtocol::new(fragments.clone());
let state = test_json!({"isDisabled": false});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
);
}
}
#[test]
fn test_component_with_template() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<custom-element>"),
WebUIFragment::component("custom-element"),
WebUIFragment::raw("</custom-element>"),
],
},
);
fragments.insert(
"custom-element".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<custom-element><div>Custom Element</div></custom-element>"
);
assert!(writer.is_ended());
}
#[test]
fn test_component_with_slots() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
WebUIFragment::component("custom-element"),
WebUIFragment::raw("Hello World</custom-element>"),
],
},
);
fragments.insert(
"custom-element".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<slot></slot>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
);
assert!(writer.is_ended());
}
#[test]
fn test_multiple_nested_components() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "templateRepeat"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"custom-button".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<slot></slot>")],
},
);
fragments.insert(
"custom-element".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<custom-child>"),
WebUIFragment::component("custom-child"),
WebUIFragment::raw("</custom-child><slot></slot>"),
],
},
);
fragments.insert(
"custom-child".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
},
);
fragments.insert(
"templateRepeat".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<custom-element>"),
WebUIFragment::component("custom-element"),
WebUIFragment::raw("<custom-button>"),
WebUIFragment::component("custom-button"),
WebUIFragment::raw("Ok</custom-button></custom-element>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"items": [{"name": "Item1"}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
);
assert!(writer.is_ended());
}
#[test]
fn test_if_with_binary_expression() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::if_cond(
ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
"if-1",
),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"if-1".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
},
);
let protocol = WebUIProtocol::new(fragments);
let state_true = test_json!({"x": 10});
let mut writer_true = TestWriter::new();
handle(
&protocol,
&state_true,
&RenderOptions::new("index.html", "/"),
&mut writer_true,
)
.unwrap();
assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
let state_false = test_json!({"x": 1});
let mut writer_false = TestWriter::new();
handle(
&protocol,
&state_false,
&RenderOptions::new("index.html", "/"),
&mut writer_false,
)
.unwrap();
assert_eq!(writer_false.get_content(), "<div></div>");
}
#[test]
fn test_for_if_overlapping_local_state() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "template1"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"template1".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"ifBlock".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("item.label", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"flag": false,
"items": [
{"label": "A", "flag": true},
{"label": "B", "flag": false},
{"label": "C", "flag": true}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
);
}
#[test]
fn test_for_if_global_flag_no_effect() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "template1"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"template1".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"ifBlock".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("item.label", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"item": {"flag": true},
"items": [
{"label": "A", "flag": false},
{"label": "B", "flag": true},
{"label": "C", "flag": false}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><div></div><div><span>B</span></div><div></div></div>"
);
}
#[test]
fn test_recursive_template_refs() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
},
);
fragments.insert(
"static".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div expanded=\""),
WebUIFragment::signal("item.expanded", false),
WebUIFragment::raw("\" class=\""),
WebUIFragment::signal("testScenario", false),
WebUIFragment::raw("\"><span>"),
WebUIFragment::signal("item.name", false),
WebUIFragment::raw("</span>"),
WebUIFragment::for_loop("item", "item.children", "static"),
WebUIFragment::raw("</div>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"testScenario": "RecursiveTemplatesWithGlobalState",
"items": [
{"name": "A", "expanded": "false", "children": []},
{"name": "B", "expanded": "true", "children": [
{"name": "C", "expanded": "false"},
{"name": "D", "expanded": "false"}
]},
{"name": "E", "expanded": "false"}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>A</span></div><div expanded=\"true\" class=\"RecursiveTemplatesWithGlobalState\"><span>B</span><div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>C</span></div><div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>D</span></div></div><div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>E</span></div>"
);
}
#[test]
fn test_component_in_for_no_local_access() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "templateComponent"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"templateComponent".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<component-tag>"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</component-tag>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("name", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
);
}
#[test]
fn test_nested_for_hierarchical_state() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<section>"),
WebUIFragment::signal("globalPrefix", false),
WebUIFragment::signal("outerItem.outerLabel", false),
WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
WebUIFragment::raw("</section>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("globalPrefix", false),
WebUIFragment::signal("outerItem.outerLabel", false),
WebUIFragment::raw(": "),
WebUIFragment::signal("innerItem.innerLabel", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"globalPrefix": "Prefix: ",
"outerItems": [
{"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
{"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><section>Prefix: O1<p>Prefix: O1: I1</p><p>Prefix: O1: I2</p></section><section>Prefix: O2<p>Prefix: O2: I3</p></section></div>"
);
}
#[test]
fn test_component_in_for_global_only() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "templateComponent"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"templateComponent".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<component-tag>"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</component-tag>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("name", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("globalSuffix", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state =
test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
);
}
#[test]
fn test_component_no_item_moniker() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "templateComponent"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"templateComponent".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<component-tag>"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</component-tag>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("item.name", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("globalSuffix", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state =
test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
);
}
#[test]
fn test_for_nonqualified_uses_global() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "template1"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"template1".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("name", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><span>GlobalName</span><span>GlobalName</span></div>"
);
}
#[test]
fn test_nested_for_if_interleaved() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<section>"),
WebUIFragment::signal("globalPrefix", false),
WebUIFragment::signal("outerItem.outerLabel", false),
WebUIFragment::if_cond(
ConditionExpr::identifier("outerItem.include"),
"ifTemplate",
),
WebUIFragment::raw("</section>"),
],
},
);
fragments.insert(
"ifTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("globalSuffix", false),
WebUIFragment::raw(": "),
WebUIFragment::signal("innerItem.innerLabel", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"globalPrefix": "Prefix: ",
"globalSuffix": "Suffix",
"outerItems": [
{"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
{"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
{"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><section>Prefix: O1<div><p>Suffix: I1</p><p>Suffix: I2</p></div></section><section>Prefix: O2</section><section>Prefix: O3<div><p>Suffix: I3</p></div></section></div>"
);
}
#[test]
fn test_nested_for_if_outer_state() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<section>"),
WebUIFragment::signal("globalPrefix", false),
WebUIFragment::signal("outerItem.label", false),
WebUIFragment::for_loop(
"middleItem",
"outerItem.middleItems",
"middleTemplate",
),
WebUIFragment::raw("</section>"),
],
},
);
fragments.insert(
"middleTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::if_cond(
ConditionExpr::identifier("outerItem.active"),
"ifTemplate",
),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"ifTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("middleItem.value", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"globalPrefix": "GP-",
"outerItems": [
{"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
{"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
{"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><section>GP-O1<div><p>M1</p></div><div><p>M2</p></div></section><section>GP-O2<div></div></section><section>GP-O3<div><p>M4</p></div></section></div>"
);
}
#[test]
fn test_nested_for_if_inner_state() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<section>"),
WebUIFragment::signal("outerItem.label", false),
WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
WebUIFragment::raw("</section>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<article>"),
WebUIFragment::if_cond(
ConditionExpr::identifier("innerItem.show"),
"ifTemplate",
),
WebUIFragment::raw("</article>"),
],
},
);
fragments.insert(
"ifTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("innerItem.detail", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"outerItems": [
{"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
{"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
);
}
#[test]
fn test_for_merge_local_global_monikers() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "template1"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"template1".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("item.name", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("item.globalValue", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("item.localOnly", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("item.otherVal", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"item": {"globalValue": "GLOBAL", "otherVal": "other"},
"items": [
{"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
{"name": "Local2", "localOnly": "Only2"}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
);
}
#[test]
fn test_component_in_for_global_moniker_shadow() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("item", "items", "templateComponent"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"templateComponent".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<component-tag>"),
WebUIFragment::component("my-component"),
WebUIFragment::raw("</component-tag>"),
],
},
);
fragments.insert(
"my-component".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<span>"),
WebUIFragment::signal("name", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("item.globalValue", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("localOnly", false),
WebUIFragment::raw("-"),
WebUIFragment::signal("item.otherVal", false),
WebUIFragment::raw("</span>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"item": {"globalValue": "GLOBAL", "otherVal": "other"},
"items": [
{"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
{"name": "Local2", "localOnly": "Only2"}
]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
);
}
#[test]
fn test_if_in_nested_for_local_flag() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<section>"),
WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
WebUIFragment::raw("</section>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::if_cond(
ConditionExpr::identifier("inner_item.flag"),
"ifInner",
)],
},
);
fragments.insert(
"ifInner".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("inner_item.value", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
"inner_item": {"flag": false}
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><section><p>X</p></section></div>"
);
}
#[test]
fn test_if_in_nested_for_global_fallback() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<section>"),
WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
WebUIFragment::raw("</section>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::if_cond(
ConditionExpr::identifier("inner_item.flag"),
"ifInner",
)],
},
);
fragments.insert(
"ifInner".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("inner_item.value", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
"inner_item": {"flag": true}
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><section><p>X</p><p>Y</p></section></div>"
);
}
#[test]
fn test_if_mixed_for_monikers() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>"),
WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
WebUIFragment::raw("</div>"),
],
},
);
fragments.insert(
"outerTemplate".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<section>"),
WebUIFragment::signal("outer.outerLabel", false),
WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
WebUIFragment::raw("</section>"),
],
},
);
fragments.insert(
"innerTemplate".to_string(),
FragmentList {
fragments: vec![WebUIFragment::if_cond(
ConditionExpr::compound(
ConditionExpr::identifier("outer.active"),
LogicalOperator::And,
ConditionExpr::predicate(
"inner.value",
ComparisonOperator::GreaterThan,
"globalLimit",
),
),
"ifInner",
)],
},
);
fragments.insert(
"ifInner".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("inner.value", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"globalLimit": 10,
"list": {"outerItems": [
{"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
{"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
{"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
]}
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
);
}
fn make_route_protocol() -> WebUIProtocol {
use webui_protocol::WebUiFragmentRoute;
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<h1>Shell</h1>"),
WebUIFragment::route_from(WebUiFragmentRoute {
path: "/".into(),
fragment_id: "dash-page".into(),
exact: true,
keep_alive: false,
..Default::default()
}),
WebUIFragment::route_from(WebUiFragmentRoute {
path: "/contacts/:id".into(),
fragment_id: "detail-page".into(),
exact: true,
keep_alive: false,
..Default::default()
}),
],
},
);
fragments.insert(
"dash-page".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
},
);
fragments.insert(
"detail-page".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
},
);
WebUIProtocol::new(fragments)
}
fn make_nested_route_protocol() -> WebUIProtocol {
use webui_protocol::WebUiFragmentRoute;
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
path: "/".into(),
fragment_id: "app-shell".into(),
exact: false,
children: vec![WebUiFragmentRoute {
path: "sections/:id".into(),
fragment_id: "section-comp".into(),
exact: false,
children: vec![WebUiFragmentRoute {
path: "topics/:topicId".into(),
fragment_id: "topic-comp".into(),
exact: true,
children: vec![],
keep_alive: false,
..Default::default()
}],
keep_alive: false,
..Default::default()
}],
keep_alive: false,
..Default::default()
})],
},
);
fragments.insert(
"app-shell".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<h1>Shell</h1>"),
WebUIFragment::outlet(),
],
},
);
fragments.insert(
"section-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<h2>Section</h2>"),
WebUIFragment::outlet(),
],
},
);
fragments.insert(
"topic-comp".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
},
);
WebUIProtocol::new(fragments)
}
#[test]
fn test_route_renders_shell_always() {
let protocol = make_route_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(html.contains("<h1>Shell</h1>"), "shell should render");
assert!(html.contains(" active>"), "matched route should be active");
assert!(
html.contains("style=\"display:none\""),
"non-matched routes should be hidden"
);
}
#[test]
fn test_route_matched_renders_visible() {
let protocol = make_route_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains("<webui-route path=\"/\""),
"dashboard route should exist"
);
assert!(
html.contains("active>") && html.contains("<dash-page>"),
"matched route should be active with component tag: {html}"
);
assert!(
html.contains("<p>Dashboard</p>"),
"matched route should have content"
);
}
#[test]
fn test_route_non_matched_renders_hidden_empty() {
let protocol = make_route_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains("<webui-route path=\"/contacts/:id\""),
"detail route element should exist"
);
let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
let after_detail = &html[detail_start..];
assert!(
after_detail.contains("style=\"display:none\">")
&& !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
"non-matched route should be hidden: {after_detail}"
);
let detail_end = after_detail.find("</webui-route>").expect("closing tag");
let detail_body = &after_detail[..detail_end];
assert!(
!detail_body.contains("<detail-page>"),
"non-matched route should not render component content: {detail_body}"
);
}
#[test]
fn test_route_parameterized_match() {
let protocol = make_route_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/contacts/42"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains("active>") && html.contains("<detail-page>"),
"detail route should be active: {html}"
);
assert!(html.contains("<p>Detail</p>"), "detail should have content");
let dash_start = html
.find("component=\"dash-page\"")
.expect("dashboard route");
let after_dash = &html[dash_start..];
assert!(
after_dash.contains("style=\"display:none\">"),
"dashboard should be hidden when detail matches: {after_dash}"
);
let dash_end = after_dash.find("</webui-route>").expect("closing tag");
let dash_body = &after_dash[..dash_end];
assert!(
!dash_body.contains("<dash-page>"),
"dashboard should not render component content: {dash_body}"
);
}
#[test]
fn test_route_no_match_all_hidden_empty() {
let protocol = make_route_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/nonexistent"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(html.contains("<h1>Shell</h1>"));
assert!(
!html.contains("<p>Dashboard</p>"),
"no route content when nothing matches"
);
assert!(
!html.contains("<p>Detail</p>"),
"no route content when nothing matches"
);
}
#[test]
fn test_route_component_attr_emitted() {
let protocol = make_route_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains("component=\"dash-page\""),
"component attr should be on webui-route: {html}"
);
assert!(
html.contains("component=\"detail-page\""),
"component attr should be on webui-route: {html}"
);
}
#[test]
fn test_no_plugin_no_state_attributes() {
let protocol = make_route_protocol();
let state = test_json!({
"title": "Fish & Chips",
"cartOpen": true,
"items": [{"name": "A&B"}]
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
!html.contains("data-state"),
"no data-state without plugin: {html}"
);
assert!(
!html.contains(r#"title="Fish"#),
"no scalar attrs without plugin: {html}"
);
}
#[test]
fn test_nested_routes_render_webui_route_as_light_dom() {
let protocol = make_nested_route_protocol();
let state = test_json!({"title": "Test"});
let handler = WebUIHandler::new();
let mut writer = TestWriter::new();
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/sections/frontend"),
&mut writer,
)
.expect("render failed");
let html = writer.get_content();
assert!(
html.contains("component=\"app-shell\"") && html.contains("active>"),
"root route should be active: {html}"
);
assert!(
!html.contains("<template shadowrootmode"),
"webui-route should be light DOM (no shadow template): {html}"
);
}
#[test]
fn test_nested_routes_render_outlet_as_light_dom() {
let protocol = make_nested_route_protocol();
let state = test_json!({"title": "Test"});
let handler = WebUIHandler::new();
let mut writer = TestWriter::new();
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/sections/frontend"),
&mut writer,
)
.expect("render failed");
let html = writer.get_content();
assert!(
!html.contains("<webui-outlet>"),
"should not contain webui-outlet wrapper: {html}"
);
assert!(
html.contains("<webui-route"),
"should contain webui-route elements: {html}"
);
}
#[test]
fn test_nested_routes_match_child_at_outlet() {
let protocol = make_nested_route_protocol();
let state = test_json!({"title": "Test"});
let handler = WebUIHandler::new();
let mut writer = TestWriter::new();
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/sections/frontend"),
&mut writer,
)
.expect("render failed");
let html = writer.get_content();
assert!(
html.contains("component=\"section-comp\"") && html.contains("active>"),
"section route should be active: {html}"
);
assert!(
html.contains("<h2>Section</h2>"),
"section content should be present: {html}"
);
}
#[test]
fn test_nested_routes_three_levels_deep() {
let protocol = make_nested_route_protocol();
let state = test_json!({"title": "Test"});
let handler = WebUIHandler::new();
let mut writer = TestWriter::new();
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/sections/frontend/topics/react"),
&mut writer,
)
.expect("render failed");
let html = writer.get_content();
assert!(
html.contains("component=\"app-shell\"") && html.contains("active>"),
"root active: {html}"
);
assert!(
html.contains("component=\"section-comp\"") && html.contains("active>"),
"section active: {html}"
);
assert!(
html.contains("component=\"topic-comp\"")
&& html.contains("exact")
&& html.contains("active>"),
"topic active: {html}"
);
assert!(
html.contains("<p>Topic content</p>"),
"leaf content present: {html}"
);
}
#[test]
fn test_nested_routes_nonmatched_siblings_hidden() {
let protocol = make_nested_route_protocol();
let state = test_json!({"title": "Test"});
let handler = WebUIHandler::new();
let mut writer = TestWriter::new();
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/sections/frontend"),
&mut writer,
)
.expect("render failed");
let html = writer.get_content();
assert!(
html.contains(r#"component="topic-comp" exact style="display:none">"#),
"topic should be hidden: {html}"
);
}
#[test]
fn test_nested_routes_root_only() {
let protocol = make_nested_route_protocol();
let state = test_json!({"title": "Test"});
let handler = WebUIHandler::new();
let mut writer = TestWriter::new();
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.expect("render failed");
let html = writer.get_content();
assert!(
html.contains("component=\"app-shell\"") && html.contains("active>"),
"root active at /: {html}"
);
assert!(
html.contains("<h1>Shell</h1>"),
"shell renders at /: {html}"
);
assert!(
html.contains(r#"component="section-comp" style="display:none">"#),
"section hidden at /: {html}"
);
}
#[test]
fn test_css_module_emitted_once_inline_in_component() {
let template = r#"<p><slot></slot></p>"#;
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::raw("</head><body><div>".to_string()),
WebUIFragment::component("my-card"),
WebUIFragment::raw("A".to_string()),
WebUIFragment::component("my-card"),
WebUIFragment::raw("B</div>".to_string()),
WebUIFragment::signal("body_end", true),
WebUIFragment::raw("</body></html>".to_string()),
],
},
);
fragments.insert(
"my-card".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw(template.to_string())],
},
);
let mut protocol = WebUIProtocol::new(fragments);
protocol
.components
.entry("my-card".to_string())
.or_default()
.css = "p{color:red}".to_string();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
let count = html.matches(r#"<script type="importmap""#).count();
assert_eq!(
count, 1,
"CSS module importmap should be emitted once, got {count} in: {html}"
);
assert!(
html.contains(r#""my-card":"data:text/css,"#),
"Importmap must register my-card under a data: URI: {html}"
);
let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
assert_eq!(
tmpl_count, 2,
"Template should render twice, got {tmpl_count} in: {html}"
);
let css_pos = html
.find(r#"<script type="importmap""#)
.expect("CSS module importmap missing");
let body_pos = html.find("<body>").expect("<body> missing");
assert!(
css_pos > body_pos,
"CSS module should be inline in component, not in <head>: {html}"
);
}
#[test]
fn test_component_without_css_renders_normally() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::component("my-card")],
},
);
fragments.insert(
"my-card".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains("<p>hello</p>"),
"Non-module component should render normally: {html}"
);
}
#[test]
fn test_non_module_strategy_no_css_in_head() {
let template = r#"<p>hello</p>"#;
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::raw("</head><body>".to_string()),
WebUIFragment::component("my-card"),
WebUIFragment::raw("</body></html>".to_string()),
],
},
);
fragments.insert(
"my-card".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw(template.to_string())],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
!html.contains(r#"<style type="module""#),
"Non-module strategy should not emit legacy CSS module tags in <head>: {html}"
);
assert!(
!html.contains(r#"<script type="importmap""#),
"Non-module strategy should not emit CSS module importmaps in <head>: {html}"
);
assert!(
html.contains("<p>hello</p>"),
"Component should still render: {html}"
);
}
#[test]
fn test_style_strategy_embeds_inline_style_in_shadow_template() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::raw("</head><body><my-card>".to_string()),
WebUIFragment::component("my-card"),
WebUIFragment::raw("</my-card></body></html>".to_string()),
],
},
);
fragments.insert(
"my-card".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw(
"<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
.to_string(),
)],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains("<style>.card{color:red}</style>"),
"Style strategy should embed inline CSS in shadow template: {html}"
);
assert!(
!html.contains(r#"<style type="module""#),
"Style strategy should not emit legacy module CSS in <head>: {html}"
);
assert!(
!html.contains(r#"<script type="importmap""#),
"Style strategy should not emit CSS module importmaps in <head>: {html}"
);
}
#[test]
fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::raw("</head><body><my-card>".to_string()),
WebUIFragment::component("my-card"),
WebUIFragment::raw("</my-card>".to_string()),
WebUIFragment::signal("body_end", true),
WebUIFragment::raw("</body></html>".to_string()),
],
},
);
fragments.insert(
"my-card".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
},
);
let mut protocol = WebUIProtocol::new(fragments);
protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
let comp = protocol
.components
.entry("my-card".to_string())
.or_default();
comp.css_href = "my-card.css".to_string();
comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
let head_end = html.find("</head>").expect("</head> missing");
let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css">"#);
assert!(
link_pos.is_some_and(|p| p < head_end),
"Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
);
assert!(
!html.contains(r#"<link rel="preload""#),
"Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
);
assert!(
!html.contains(r#"<style type="module""#),
"Link strategy should not emit legacy module CSS: {html}"
);
assert!(
!html.contains(r#"<script type="importmap""#),
"Link strategy should not emit CSS module importmaps: {html}"
);
}
#[test]
fn test_link_strategy_shadow_dom_emits_preload_in_head() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::raw("</head><body><o-loading-state>".to_string()),
WebUIFragment::component("o-loading-state"),
WebUIFragment::raw("</o-loading-state><my-card>".to_string()),
WebUIFragment::component("my-card"),
WebUIFragment::raw("</my-card>".to_string()),
WebUIFragment::signal("body_end", true),
WebUIFragment::raw("</body></html>".to_string()),
],
},
);
fragments.insert(
"o-loading-state".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
},
);
fragments.insert(
"my-card".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
},
);
let mut protocol = WebUIProtocol::new(fragments);
protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
protocol.set_dom_strategy(webui_protocol::DomStrategy::Shadow);
let comp1 = protocol
.components
.entry("o-loading-state".to_string())
.or_default();
comp1.css_href = "o-loading-state.css".to_string();
comp1.template_json = r#"{"h":"<div>loading</div>"}"#.to_string();
let comp2 = protocol
.components
.entry("my-card".to_string())
.or_default();
comp2.css_href = "my-card.css".to_string();
comp2.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
let head_end = html.find("</head>").expect("</head> missing");
let head_section = &html[..head_end];
assert!(
head_section.contains(
r#"<link rel="preload" href="o-loading-state.css" as="style" data-webui-ssr-preload="style">"#
),
"Missing preload for o-loading-state.css in <head>: {html}"
);
assert!(
head_section.contains(
r#"<link rel="preload" href="my-card.css" as="style" data-webui-ssr-preload="style">"#
),
"Missing preload for my-card.css in <head>: {html}"
);
assert!(
!head_section.contains(r#"<link rel="stylesheet""#),
"Shadow DOM should NOT emit <link rel=stylesheet> in <head>: {html}"
);
}
#[test]
fn test_css_module_emitted_in_component_light_dom() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::raw("</head><body><my-card>".to_string()),
WebUIFragment::component("my-card"),
WebUIFragment::raw("</my-card>".to_string()),
WebUIFragment::raw("</body></html>".to_string()),
],
},
);
fragments.insert(
"my-card".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
},
);
let mut protocol = WebUIProtocol::new(fragments);
protocol
.components
.entry("my-card".to_string())
.or_default()
.css = "p{color:red}".to_string();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
let tag_open = html.find("<my-card>").expect("<my-card> missing");
let css_pos = html
.find(r#"<script type="importmap""#)
.expect("CSS module importmap missing");
let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
assert!(
css_pos > tag_open && css_pos < tag_close,
"CSS module should be inside component light DOM: {html}"
);
let head_end = html.find("</head>").expect("</head> missing");
assert!(
css_pos > head_end,
"CSS module should not be in <head>: {html}"
);
}
#[test]
fn test_css_module_emitted_for_route_components() {
let template = r#"<h1>Dashboard</h1>"#;
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::raw("</head><body>".to_string()),
WebUIFragment::route("/", "dash-page"),
WebUIFragment::signal("body_end", true),
WebUIFragment::raw("</body></html>".to_string()),
],
},
);
fragments.insert(
"dash-page".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw(template.to_string())],
},
);
let mut protocol = WebUIProtocol::new(fragments);
let comp = protocol
.components
.entry("dash-page".to_string())
.or_default();
comp.css = "h1{font-size:2rem}".to_string();
comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
"Route component should have CSS module importmap with data: URI: {html}"
);
assert!(
html.contains("<h1>Dashboard</h1>"),
"Route component should render content: {html}"
);
}
#[test]
fn test_head_css_link_skipped_for_components_without_css() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::raw("</head><body>".to_string()),
WebUIFragment::component("has-css"),
WebUIFragment::component("no-css"),
WebUIFragment::signal("body_end", true),
WebUIFragment::raw("</body></html>".to_string()),
],
},
);
fragments.insert(
"has-css".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
},
);
fragments.insert(
"no-css".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
},
);
let mut protocol = WebUIProtocol::new(fragments);
protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
protocol
.components
.entry("has-css".to_string())
.or_default()
.css_href = "has-css.css".to_string();
let state = test_json!({});
let mut writer = TestWriter::new();
let handler = WebUIHandler::with_plugin(|| {
Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
});
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains(r#"<link rel="stylesheet" href="has-css.css">"#),
"Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
);
assert!(
!html.contains("no-css.css"),
"Component without CSS must NOT get a <link> in <head>: {html}"
);
}
#[test]
fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::raw("</head><body><app-shell>".to_string()),
WebUIFragment::component("app-shell"),
WebUIFragment::raw("</app-shell>".to_string()),
WebUIFragment::signal("body_end", true),
WebUIFragment::raw("</body></html>".to_string()),
],
},
);
fragments.insert(
"app-shell".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<div>Shell</div>".to_string()),
WebUIFragment::component("cart-panel"),
],
},
);
fragments.insert(
"cart-panel".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<aside>".to_string()),
WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
WebUIFragment::raw("</aside>".to_string()),
],
},
);
fragments.insert(
"cart-items".to_string(),
FragmentList {
fragments: vec![WebUIFragment::component("product-card")],
},
);
fragments.insert(
"product-card".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
},
);
let mut protocol = WebUIProtocol::new(fragments);
for name in ["app-shell", "cart-panel", "product-card"] {
let comp = protocol.components.entry(name.to_string()).or_default();
comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
comp.css = format!(".{name}{{display:block}}");
if name == "product-card" {
comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
}
}
let state = test_json!({ "hasItems": false });
let mut writer = TestWriter::new();
let handler = WebUIHandler::with_plugin(|| {
Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
});
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains(r#"<script type="application/json" id="webui-data">"#),
"non-executable SSR metadata should be emitted in the webui-data block: {html}"
);
assert!(
html.contains(r#""state":{"hasItems":false}"#),
"SSR state should live in the JSON data block: {html}"
);
assert!(
html.contains(r#""inventory":"#),
"SSR inventory should live in the JSON data block: {html}"
);
assert!(
!html.contains("window.__webui={\""),
"executable bootstrap must not embed the window.__webui JSON literal: {html}"
);
assert!(
!html.contains(r#"document.getElementById("webui-data")"#),
"SSR must not parse webui-data; client packages own that lazy load: {html}"
);
assert!(
!html.contains("window.__webui=w;"),
"executable bootstrap must not replace existing window.__webui registrations: {html}"
);
assert!(
!html.contains("w.templateFns={\""),
"template function emission must not replace existing templateFns registrations: {html}"
);
assert!(
html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
"template functions should merge into the flat templateFns registry: {html}"
);
assert!(
html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
"product-card template should be emitted even when unrendered: {html}"
);
assert!(
html.contains(r#""product-card":"data:text/css,"#),
"reachable product-card CSS module importmap should be emitted: {html}"
);
assert!(
html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
"rendered app-shell template should be emitted: {html}"
);
assert!(
html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
"rendered cart-panel template should be emitted: {html}"
);
}
#[test]
fn test_css_module_emits_nonce_attribute_when_nonce_set() {
let template = r#"<h1>Dashboard</h1>"#;
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::raw("</head><body>".to_string()),
WebUIFragment::route("/", "dash-page"),
WebUIFragment::signal("body_end", true),
WebUIFragment::raw("</body></html>".to_string()),
],
},
);
fragments.insert(
"dash-page".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw(template.to_string())],
},
);
let mut protocol = WebUIProtocol::new(fragments);
let comp = protocol
.components
.entry("dash-page".to_string())
.or_default();
comp.css = "h1{font-size:2rem}".to_string();
comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains(
r#"<script type="importmap" nonce="test-nonce-123">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
),
"CSS module importmap tag should include nonce attribute in canonical order: {html}"
);
}
#[test]
fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::raw("</head><body><app-shell>".to_string()),
WebUIFragment::component("app-shell"),
WebUIFragment::raw("</app-shell>".to_string()),
WebUIFragment::signal("body_end", true),
WebUIFragment::raw("</body></html>".to_string()),
],
},
);
fragments.insert(
"app-shell".to_string(),
FragmentList {
fragments: vec![WebUIFragment::if_cond(
ConditionExpr::identifier("hasItems"),
"cart-items",
)],
},
);
fragments.insert(
"cart-items".to_string(),
FragmentList {
fragments: vec![WebUIFragment::component("product-card")],
},
);
fragments.insert(
"product-card".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
},
);
let mut protocol = WebUIProtocol::new(fragments);
for name in ["app-shell", "product-card"] {
let comp = protocol.components.entry(name.to_string()).or_default();
comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
comp.css = format!(".{name}{{display:block}}");
}
let state = test_json!({ "hasItems": false });
let mut writer = TestWriter::new();
let handler = WebUIHandler::with_plugin(|| {
Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
});
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(
html.contains(
r#"<script type="importmap" nonce="test-nonce-123">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
),
"Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
);
}
#[test]
fn client_state_strips_tokens_after_ssr_resolution() -> Result<()> {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><body><style>".to_string()),
WebUIFragment::signal("tokens.light", true),
WebUIFragment::raw("</style>".to_string()),
WebUIFragment::signal("body_end", true),
WebUIFragment::raw("</body></html>".to_string()),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"name": "Alice",
"tokens": {
"light": "--color-brand: red;"
}
});
let mut writer = TestWriter::new();
let handler = WebUIHandler::with_plugin(|| {
Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
});
handler.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)?;
let output = writer.get_content();
assert!(output.contains("--color-brand: red;"));
assert!(output.contains(r#""name":"Alice""#));
assert!(!output.contains(r#""tokens""#));
Ok(())
}
#[test]
fn test_component_attr_name_aria() {
assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
assert_eq!(
component_attr_name("aria-activedescendant"),
"ariaActiveDescendant"
);
assert_eq!(component_attr_name("aria-label"), "ariaLabel");
assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
}
#[test]
fn test_component_attr_name_html_global() {
assert_eq!(component_attr_name("readonly"), "readOnly");
assert_eq!(component_attr_name("tabindex"), "tabIndex");
assert_eq!(component_attr_name("accesskey"), "accessKey");
assert_eq!(component_attr_name("contenteditable"), "contentEditable");
assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
assert_eq!(component_attr_name("inputmode"), "inputMode");
assert_eq!(component_attr_name("maxlength"), "maxLength");
assert_eq!(component_attr_name("minlength"), "minLength");
assert_eq!(component_attr_name("novalidate"), "noValidate");
assert_eq!(component_attr_name("formaction"), "formAction");
assert_eq!(component_attr_name("ismap"), "isMap");
assert_eq!(component_attr_name("usemap"), "useMap");
}
#[test]
fn test_component_attr_name_strips_colon() {
assert_eq!(component_attr_name(":readonly"), "readOnly");
assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
assert_eq!(component_attr_name(":data-title"), "dataTitle");
}
#[test]
fn test_component_attr_name_regular() {
assert_eq!(component_attr_name("data-title"), "dataTitle");
assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
assert_eq!(component_attr_name("simple"), "simple");
}
fn make_query_route_protocol() -> WebUIProtocol {
use webui_protocol::WebUiFragmentRoute;
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
path: "/".into(),
fragment_id: "app-shell".into(),
exact: false,
children: vec![
WebUiFragmentRoute {
path: "compose".into(),
fragment_id: "compose-page".into(),
exact: true,
allowed_query: "action,to,subject".into(),
keep_alive: false,
..Default::default()
},
WebUiFragmentRoute {
path: "settings".into(),
fragment_id: "settings-page".into(),
exact: true,
keep_alive: false,
..Default::default()
},
],
keep_alive: false,
..Default::default()
})],
},
);
fragments.insert(
"app-shell".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
},
);
fragments.insert(
"compose-page".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
},
);
fragments.insert(
"settings-page".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
},
);
WebUIProtocol::new(fragments)
}
#[test]
fn test_matched_route_omits_query_attr_from_dom() {
let protocol = make_query_route_protocol();
let state = test_json!({});
let handler = WebUIHandler::new();
let mut writer = TestWriter::new();
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/compose"),
&mut writer,
)
.expect("render failed");
let html = writer.get_content();
assert!(
!html.contains(r#"query="action,to,subject""#),
"query attr should not be in DOM output (moved to SSR chain JSON): {html}"
);
}
#[test]
fn test_nonmatched_route_omits_query_attr_from_dom() {
let protocol = make_query_route_protocol();
let state = test_json!({});
let handler = WebUIHandler::new();
let mut writer = TestWriter::new();
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/settings"),
&mut writer,
)
.expect("render failed");
let html = writer.get_content();
assert!(
!html.contains(r#"query="#),
"hidden route should not have query attr: {html}"
);
}
#[test]
fn test_route_without_query_has_no_query_attr() {
let protocol = make_query_route_protocol();
let state = test_json!({});
let handler = WebUIHandler::new();
let mut writer = TestWriter::new();
handler
.handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/settings"),
&mut writer,
)
.expect("render failed");
let html = writer.get_content();
let settings_idx = html
.find(r#"component="settings-page""#)
.expect("settings route should exist");
let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
assert!(
!settings_tag.contains("query="),
"route without allowed_query should not emit query attr: {settings_tag}"
);
}
fn build_head_body_protocol() -> WebUIProtocol {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head><title>x</title>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::raw("</head><body>hello".to_string()),
WebUIFragment::signal("body_end", true),
WebUIFragment::raw("</body></html>".to_string()),
],
},
);
WebUIProtocol::new(fragments)
}
#[test]
fn head_inject_emits_at_head_end_boundary() {
let protocol = build_head_body_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
handle(&protocol, &state, &opts, &mut writer).unwrap();
let html = writer.get_content();
let inject_idx = html
.find("<link rel=preload>")
.expect("inject HTML missing");
let head_close_idx = html.find("</head>").expect("</head> missing");
assert!(
inject_idx < head_close_idx,
"head_inject must appear before </head>: {html}"
);
assert_eq!(html.matches("<link rel=preload>").count(), 1);
}
#[test]
fn body_inject_emits_at_body_end_boundary() {
let protocol = build_head_body_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
handle(&protocol, &state, &opts, &mut writer).unwrap();
let html = writer.get_content();
let inject_idx = html
.find("<script>lr</script>")
.expect("inject HTML missing");
let body_close_idx = html.find("</body>").expect("</body> missing");
assert!(
inject_idx < body_close_idx,
"body_inject must appear before </body>: {html}"
);
assert_eq!(html.matches("<script>lr</script>").count(), 1);
}
#[test]
fn injects_are_no_op_when_unset() {
let protocol = build_head_body_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
let html = writer.get_content();
assert!(!html.contains("<link rel=preload>"));
assert!(!html.contains("<script>lr</script>"));
}
#[test]
fn empty_inject_string_treated_as_unset() {
let protocol = build_head_body_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
let opts = RenderOptions::new("index.html", "/")
.with_head_inject("")
.with_body_inject("");
handle(&protocol, &state, &opts, &mut writer).unwrap();
let html = writer.get_content();
assert!(html.contains("</head>"));
assert!(html.contains("</body>"));
}
#[test]
fn inject_html_is_passed_through_verbatim() {
let protocol = build_head_body_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
let opts =
RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
handle(&protocol, &state, &opts, &mut writer).unwrap();
assert!(writer.get_content().contains("<script>var x=1;</script>"));
}
#[test]
fn injects_robust_against_marker_literals_in_content() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head><title>x</title>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::raw(
"</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
),
WebUIFragment::signal("body_end", true),
WebUIFragment::raw("</body></html>".to_string()),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
let opts = RenderOptions::new("index.html", "/")
.with_head_inject("<HEAD-INJ>")
.with_body_inject("<BODY-INJ>");
handle(&protocol, &state, &opts, &mut writer).unwrap();
let html = writer.get_content();
let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
let head_close_idx = html.find("</head>").expect("</head> missing");
assert!(head_inj_idx < head_close_idx);
let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
let body_close_idx = html.rfind("</body>").expect("</body> missing");
assert!(
body_inj_idx < body_close_idx,
"body_inject must precede the structural </body>: {html}"
);
assert!(html.contains("<!-- </body> </head> -->"));
}
#[test]
fn both_injects_fire_at_correct_boundaries() {
let protocol = build_head_body_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
let opts = RenderOptions::new("index.html", "/")
.with_head_inject("<META-HEAD>")
.with_body_inject("<SCRIPT-BODY>");
handle(&protocol, &state, &opts, &mut writer).unwrap();
let html = writer.get_content();
let head_idx = html.find("<META-HEAD>").expect("head inject missing");
let head_close = html.find("</head>").expect("</head> missing");
let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
let body_close = html.find("</body>").expect("</body> missing");
assert!(head_idx < head_close, "head_inject before </head>");
assert!(head_close < body_idx, "body_inject after </head>");
assert!(body_idx < body_close, "body_inject before </body>");
assert_eq!(html.matches("<META-HEAD>").count(), 1);
assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
}
#[test]
fn injects_dedupe_against_duplicate_signals() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<html><head>".to_string()),
WebUIFragment::signal("head_end", true),
WebUIFragment::signal("head_end", true), WebUIFragment::signal("head_end", true), WebUIFragment::raw("</head><body>".to_string()),
WebUIFragment::signal("body_end", true),
WebUIFragment::signal("body_end", true), WebUIFragment::raw("</body></html>".to_string()),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
let opts = RenderOptions::new("index.html", "/")
.with_head_inject("<HINJ>")
.with_body_inject("<BINJ>");
handle(&protocol, &state, &opts, &mut writer).unwrap();
let html = writer.get_content();
assert_eq!(
html.matches("<HINJ>").count(),
1,
"head_inject must emit exactly once even with duplicate head_end signals"
);
assert_eq!(
html.matches("<BINJ>").count(),
1,
"body_inject must emit exactly once even with duplicate body_end signals"
);
}
#[test]
fn injects_no_op_when_no_head_or_body_signals() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::raw(
"<my-component>hi</my-component>".to_string(),
)],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({});
let mut writer = TestWriter::new();
let opts = RenderOptions::new("index.html", "/")
.with_head_inject("<HINJ>")
.with_body_inject("<BINJ>");
handle(&protocol, &state, &opts, &mut writer).unwrap();
let html = writer.get_content();
assert!(!html.contains("<HINJ>"), "head_inject must not appear");
assert!(!html.contains("<BINJ>"), "body_inject must not appear");
assert!(html.contains("<my-component>"));
}
#[test]
fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
let protocol = std::sync::Arc::new(build_head_body_protocol());
let state = std::sync::Arc::new(test_json!({}));
let handler = std::sync::Arc::new(WebUIHandler::new());
const N_THREADS: usize = 16;
let mut handles = Vec::with_capacity(N_THREADS);
for tid in 0..N_THREADS {
let h = std::sync::Arc::clone(&handler);
let p = std::sync::Arc::clone(&protocol);
let s = std::sync::Arc::clone(&state);
handles.push(std::thread::spawn(move || {
let head = format!("<HEAD-T{tid}>");
let body = format!("<BODY-T{tid}>");
let mut writer = TestWriter::new();
let opts = RenderOptions::new("index.html", "/")
.with_head_inject(&head)
.with_body_inject(&body);
h.handle(&p, &s, &opts, &mut writer).unwrap();
let html = writer.get_content();
assert_eq!(html.matches(&head).count(), 1);
assert_eq!(html.matches(&body).count(), 1);
for other in 0..N_THREADS {
if other == tid {
continue;
}
let other_head = format!("<HEAD-T{other}>");
let other_body = format!("<BODY-T{other}>");
assert!(
!html.contains(&other_head),
"tid {tid} saw {other}'s head_inject"
);
assert!(
!html.contains(&other_body),
"tid {tid} saw {other}'s body_inject"
);
}
}));
}
for h in handles {
h.join().expect("worker panicked");
}
}
#[test]
fn large_inject_roundtrips_without_truncation() {
let protocol = build_head_body_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
let big = "x".repeat(1024 * 1024);
let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
handle(&protocol, &state, &opts, &mut writer).unwrap();
let html = writer.get_content();
assert!(
html.contains(&big),
"large head_inject must be present verbatim ({} bytes)",
big.len()
);
assert_eq!(html.matches(&big).count(), 1);
}
#[test]
fn empty_nonce_treated_as_unset() {
let protocol = build_head_body_protocol();
let state = test_json!({});
let mut writer = TestWriter::new();
let opts = RenderOptions::new("index.html", "/").with_nonce("");
handle(&protocol, &state, &opts, &mut writer).unwrap();
assert!(
!writer.get_content().contains("webui-nonce"),
"empty nonce must not emit <meta name=\"webui-nonce\">"
);
}
#[test]
fn empty_field_bypass_is_normalised_at_handler_init() {
let protocol = build_head_body_protocol();
let state = test_json!({});
let opts_with_empty_nonce = RenderOptions {
nonce: Some(""),
..RenderOptions::new("index.html", "/")
};
let mut writer = TestWriter::new();
handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
let html = writer.get_content();
assert!(
!html.contains("webui-nonce"),
"field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
);
assert!(
!html.contains("nonce=\"\""),
"field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
);
let opts_with_empty_injects = RenderOptions {
head_inject: Some(""),
body_inject: Some(""),
..RenderOptions::new("index.html", "/")
};
let mut writer = TestWriter::new();
handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
}
#[test]
fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("["),
WebUIFragment::for_loop("item", "outer", "outer_body"),
WebUIFragment::raw("]"),
],
},
);
fragments.insert(
"outer_body".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("(O="),
WebUIFragment::signal("item.tag", false),
WebUIFragment::for_loop("item", "inner", "inner_body"),
WebUIFragment::raw(",O="),
WebUIFragment::signal("item.tag", false),
WebUIFragment::raw(")"),
],
},
);
fragments.insert(
"inner_body".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("[I="),
WebUIFragment::signal("item.tag", false),
WebUIFragment::raw("]"),
],
},
);
let protocol = WebUIProtocol::new(fragments);
let state = test_json!({
"outer": [{"tag": "A"}, {"tag": "B"}],
"inner": [{"tag": "X"}, {"tag": "Y"}],
});
let mut writer = TestWriter::new();
handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.unwrap();
assert_eq!(
writer.get_content(),
"[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
"outer `item` must stay bound to its iteration value across the inner loop's save/restore"
);
}
}