1pub(crate) mod html_encode;
10pub mod plugin;
11pub mod route_handler;
12pub mod route_matcher;
13pub(crate) mod route_renderer;
14
15use plugin::HandlerPlugin;
16use route_matcher::CompiledRouteCache;
17use serde_json::Value;
18use std::collections::{HashMap, HashSet};
19use thiserror::Error;
20use webui_expressions::{evaluate_with_resolver, ExpressionError};
21use webui_protocol::{web_ui_fragment::Fragment, WebUIFragment, WebUIProtocol};
22use webui_state::find_value_by_dotted_path;
23
24#[derive(Debug, Error)]
26pub enum HandlerError {
27 #[error("Rendering error: {0}")]
28 Rendering(String),
29
30 #[error("Rendering invariant error: {0}")]
31 Invariant(String),
32
33 #[error("Missing fragment: {0}")]
34 MissingFragment(String),
35
36 #[error("Missing data field: {0}")]
37 MissingData(String),
38
39 #[error("Type error: {0}")]
40 TypeError(String),
41
42 #[error("Protocol error: {0}")]
43 Protocol(#[from] webui_protocol::ProtocolError),
44
45 #[error("Evaluation error: {0}")]
46 Evaluation(String),
47
48 #[error("I/O error: {0}")]
49 Io(#[from] std::io::Error),
50
51 #[error("Writer error: {0}")]
52 Writer(String),
53
54 #[error("Plugin data error: {0}")]
55 PluginData(String),
56}
57
58pub type Result<T> = std::result::Result<T, HandlerError>;
59
60pub trait ResponseWriter {
62 fn write(&mut self, content: &str) -> Result<()>;
64
65 fn end(&mut self) -> Result<()>;
67}
68
69pub struct RenderOptions<'a> {
74 pub entry_id: &'a str,
76 pub request_path: &'a str,
78 pub nonce: Option<&'a str>,
82}
83
84impl<'a> RenderOptions<'a> {
85 #[must_use]
87 pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
88 Self {
89 entry_id,
90 request_path,
91 nonce: None,
92 }
93 }
94
95 #[must_use]
97 pub fn with_nonce(mut self, nonce: &'a str) -> Self {
98 self.nonce = Some(nonce);
99 self
100 }
101}
102
103pub struct WebUIHandler {
108 plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
109}
110
111struct WebUIProcessContext<'a> {
113 protocol: &'a WebUIProtocol,
114 state: &'a Value,
115 writer: &'a mut dyn ResponseWriter,
116 local_vars: HashMap<String, Value>,
117 component_attrs: HashMap<String, Value>,
119 request_path: String,
121 route_base: String,
124 rendered_components: HashSet<String>,
128 plugin: Option<Box<dyn HandlerPlugin>>,
130 route_children: Vec<webui_protocol::WebUiFragmentRoute>,
133 entry_id: String,
135 nonce: Option<String>,
137 route_cache: CompiledRouteCache,
139 route_chain_index: usize,
143}
144
145fn component_attr_name(name: &str) -> String {
151 let stripped = name.strip_prefix(':').unwrap_or(name);
152 webui_protocol::attrs::attribute_to_camel(stripped)
153}
154
155fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
157 if n == 0 {
158 return writer.write("0");
159 }
160 let mut buf = [0u8; 20];
162 let mut pos = buf.len();
163 while n > 0 {
164 pos -= 1;
165 #[allow(clippy::cast_possible_truncation)]
167 let digit = (n % 10) as u8;
168 buf[pos] = b'0' + digit;
169 n /= 10;
170 }
171 match std::str::from_utf8(&buf[pos..]) {
173 Ok(s) => writer.write(s),
174 Err(_) => writer.write("0"),
175 }
176}
177
178impl WebUIHandler {
179 pub fn new() -> Self {
181 Self {
182 plugin_factory: None,
183 }
184 }
185
186 pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
191 Self {
192 plugin_factory: Some(factory),
193 }
194 }
195
196 pub fn handle(
201 &self,
202 protocol: &WebUIProtocol,
203 state: &Value,
204 options: &RenderOptions<'_>,
205 writer: &mut dyn ResponseWriter,
206 ) -> Result<()> {
207 if !protocol.fragments.contains_key(options.entry_id) {
208 return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
209 }
210
211 let mut context = WebUIProcessContext {
212 protocol,
213 state,
214 writer,
215 local_vars: HashMap::new(),
216 component_attrs: HashMap::new(),
217 request_path: options.request_path.to_string(),
218 route_base: "/".to_string(),
219 rendered_components: HashSet::new(),
220 plugin: self.plugin_factory.map(|f| f()),
221 route_children: Vec::new(),
222 entry_id: options.entry_id.to_string(),
223 nonce: options.nonce.map(String::from),
224 route_cache: CompiledRouteCache::new(),
225 route_chain_index: 0,
226 };
227 self.process_fragment_id(options.entry_id, &mut context)?;
228
229 writer.end()?;
230
231 Ok(())
232 }
233
234 pub fn handle_as_component(
239 &self,
240 protocol: &WebUIProtocol,
241 state: &Value,
242 entry_id: &str,
243 writer: &mut dyn ResponseWriter,
244 ) -> Result<()> {
245 if !protocol.fragments.contains_key(entry_id) {
246 return Err(HandlerError::MissingFragment(entry_id.to_string()));
247 }
248
249 let mut context = WebUIProcessContext {
250 protocol,
251 state,
252 writer,
253 local_vars: HashMap::new(),
254 component_attrs: HashMap::new(),
255 request_path: String::new(),
256 route_base: "/".to_string(),
257 rendered_components: HashSet::new(),
258 plugin: self.plugin_factory.map(|f| f()),
259 route_children: Vec::new(),
260 entry_id: entry_id.to_string(),
261 nonce: None,
262 route_cache: CompiledRouteCache::new(),
263 route_chain_index: 0,
264 };
265
266 if let Some(p) = &mut context.plugin {
267 p.push_scope();
268 }
269
270 self.process_fragment_id(entry_id, &mut context)?;
271
272 if let Some(p) = &mut context.plugin {
273 p.pop_scope();
274 }
275
276 writer.end()?;
277
278 Ok(())
279 }
280
281 fn process_fragment_id(
286 &self,
287 fragment_id: &str,
288 context: &mut WebUIProcessContext,
289 ) -> Result<()> {
290 if let Some(fragment_list) = context.protocol.fragments.get(fragment_id) {
291 self.process_fragment(&fragment_list.fragments, context)
292 } else {
293 Err(HandlerError::MissingFragment(fragment_id.to_string()))
294 }
295 }
296
297 fn process_fragment(
302 &self,
303 fragments: &[WebUIFragment],
304 context: &mut WebUIProcessContext,
305 ) -> Result<()> {
306 let best_route = route_renderer::find_best_route_match(
310 fragments,
311 &context.request_path,
312 &context.route_base,
313 &mut context.route_cache,
314 );
315
316 for item in fragments {
317 match item.fragment.as_ref() {
318 Some(Fragment::Raw(raw)) => {
319 context.writer.write(&raw.value)?;
320 }
321 Some(Fragment::Component(component)) => {
322 self.process_component(component, context)?;
323 }
324 Some(Fragment::ForLoop(for_loop)) => {
325 self.process_for_loop(for_loop, context)?;
326 }
327 Some(Fragment::Signal(signal)) => {
328 self.process_signal(signal, context)?;
329 }
330 Some(Fragment::IfCond(if_cond)) => {
331 self.process_if(if_cond, context)?;
332 }
333 Some(Fragment::Attribute(attr)) => {
334 self.process_attribute(attr, context)?;
335 }
336 Some(Fragment::Plugin(plugin_frag)) => {
337 if let Some(p) = &mut context.plugin {
338 p.on_element_data(&plugin_frag.data, context.writer)?;
339 }
340 }
341 Some(Fragment::Route(route_frag)) => {
342 self.process_route(route_frag, &best_route, context)?;
343 }
344 Some(Fragment::Outlet(_)) => {
345 self.process_outlet(context)?;
346 }
347 None => {}
348 }
349 }
350 Ok(())
351 }
352
353 fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
359 let mut children = std::mem::take(&mut context.route_children);
360 if children.is_empty() {
361 return Ok(());
362 }
363
364 let request_segments = route_matcher::split_request_path(&context.request_path);
366 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
367 for (idx, child) in children.iter().enumerate() {
368 let resolved = route_matcher::resolve_route_path(&child.path, &context.route_base);
369 if let Some(m) = route_matcher::match_route_cached_with_segments(
370 &mut context.route_cache,
371 &resolved,
372 &request_segments,
373 child.exact,
374 ) {
375 let is_better = best
376 .as_ref()
377 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
378 if is_better {
379 best = Some((idx, m));
380 }
381 }
382 }
383
384 let grandchildren = if let Some((idx, _)) = &best {
388 std::mem::take(&mut children[*idx].children)
389 } else {
390 Vec::new()
391 };
392
393 if let Some((idx, ref rm)) = best {
394 let matched_child = &children[idx];
395 let comp = &matched_child.fragment_id;
396
397 if !comp.is_empty() {
398 let saved_route_base = context.route_base.clone();
399 let saved_route_children = std::mem::take(&mut context.route_children);
400
401 if rm.consumed_segments > 0 {
402 context.route_base = route_matcher::compute_route_base(
403 &context.request_path,
404 rm.consumed_segments,
405 );
406 }
407
408 context.route_children = grandchildren;
409
410 context.writer.write("<webui-route")?;
412 if !matched_child.path.is_empty() {
413 context.writer.write(" path=\"")?;
414 context.writer.write(&matched_child.path)?;
415 context.writer.write("\"")?;
416 }
417 context.writer.write(" component=\"")?;
418 context.writer.write(comp)?;
419 context.writer.write("\"")?;
420 if matched_child.exact {
421 context.writer.write(" exact")?;
422 }
423 route_renderer::write_route_pending_attrs(context.writer, matched_child)?;
424 let ri = context.route_chain_index;
426 context.route_chain_index += 1;
427 context.writer.write(" data-ri=\"")?;
428 write_usize(context.writer, ri)?;
429 context.writer.write("\" active>")?;
430
431 context.writer.write("<")?;
432 context.writer.write(comp)?;
433 if let Some(p) = &context.plugin {
434 p.write_route_component_state(context.state, context.writer)?;
435 }
436 context.writer.write(">")?;
437
438 self.process_component(
439 &webui_protocol::WebUIFragmentComponent {
440 fragment_id: comp.clone(),
441 },
442 context,
443 )?;
444
445 context.writer.write("</")?;
446 context.writer.write(comp)?;
447 context.writer.write(">")?;
448 context.writer.write("</webui-route>")?;
449
450 context.route_base = saved_route_base;
451 context.route_children = saved_route_children;
452 }
453 }
454
455 for (idx, child) in children.iter().enumerate() {
457 let is_matched = best.as_ref().is_some_and(|(bi, _)| *bi == idx);
458 if !is_matched && !child.fragment_id.is_empty() {
459 context.writer.write("<webui-route")?;
460 if !child.path.is_empty() {
461 context.writer.write(" path=\"")?;
462 context.writer.write(&child.path)?;
463 context.writer.write("\"")?;
464 }
465 context.writer.write(" component=\"")?;
466 context.writer.write(&child.fragment_id)?;
467 context.writer.write("\"")?;
468 if child.exact {
469 context.writer.write(" exact")?;
470 }
471 route_renderer::write_route_pending_attrs(context.writer, child)?;
472 context
473 .writer
474 .write(" style=\"display:none\"></webui-route>")?;
475 }
476 }
477
478 Ok(())
479 }
480
481 fn emit_css_module(
491 &self,
492 component: &webui_protocol::WebUIFragmentComponent,
493 context: &mut WebUIProcessContext,
494 ) -> Result<()> {
495 if !context.rendered_components.contains(&component.fragment_id) {
496 if let Some(css) = context
497 .protocol
498 .components
499 .get(&component.fragment_id)
500 .map(|c| c.css.as_str())
501 .filter(|s| !s.is_empty())
502 {
503 context
504 .writer
505 .write("<style type=\"module\" specifier=\"")?;
506 context.writer.write(&component.fragment_id)?;
507 context.writer.write("\">")?;
508 context.writer.write(css)?;
509 context.writer.write("</style>")?;
510 }
511 }
512 Ok(())
513 }
514
515 fn process_route(
517 &self,
518 route_frag: &webui_protocol::WebUiFragmentRoute,
519 best_route: &Option<(String, route_matcher::RouteMatch)>,
520 context: &mut WebUIProcessContext,
521 ) -> Result<()> {
522 let is_matched = best_route
523 .as_ref()
524 .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
525
526 context.writer.write("<webui-route")?;
527 if !route_frag.path.is_empty() {
528 context.writer.write(" path=\"")?;
529 context.writer.write(&route_frag.path)?;
530 context.writer.write("\"")?;
531 }
532 if !route_frag.fragment_id.is_empty() {
533 context.writer.write(" component=\"")?;
534 context.writer.write(&route_frag.fragment_id)?;
535 context.writer.write("\"")?;
536 }
537 if route_frag.exact {
538 context.writer.write(" exact")?;
539 }
540 route_renderer::write_route_pending_attrs(context.writer, route_frag)?;
541
542 if is_matched {
543 let ri = context.route_chain_index;
545 context.route_chain_index += 1;
546 context.writer.write(" data-ri=\"")?;
547 write_usize(context.writer, ri)?;
548 context.writer.write("\" active>")?;
549
550 if !route_frag.fragment_id.is_empty() {
551 let saved_route_base = context.route_base.clone();
552 let saved_route_children = std::mem::take(&mut context.route_children);
553 if let Some((_, ref rm)) = best_route {
554 context.route_base = route_matcher::compute_route_base(
555 &context.request_path,
556 rm.consumed_segments,
557 );
558 }
559
560 context.route_children = route_frag.children.clone();
561
562 let comp = webui_protocol::WebUIFragmentComponent {
563 fragment_id: route_frag.fragment_id.clone(),
564 };
565
566 context.writer.write("<")?;
567 context.writer.write(&route_frag.fragment_id)?;
568 if let Some(p) = &context.plugin {
569 p.write_route_component_state(context.state, context.writer)?;
570 }
571 context.writer.write(">")?;
572
573 self.process_component(&comp, context)?;
574
575 context.writer.write("</")?;
576 context.writer.write(&route_frag.fragment_id)?;
577 context.writer.write(">")?;
578
579 context.route_base = saved_route_base;
580 context.route_children = saved_route_children;
581 }
582 } else {
583 context.writer.write(" style=\"display:none\">")?;
584 }
585
586 context.writer.write("</webui-route>")?;
587 Ok(())
588 }
589
590 fn process_component(
592 &self,
593 component: &webui_protocol::WebUIFragmentComponent,
594 context: &mut WebUIProcessContext,
595 ) -> Result<()> {
596 if !context.rendered_components.contains(&component.fragment_id) {
602 self.emit_css_module(component, context)?;
603 }
604
605 context
607 .rendered_components
608 .insert(component.fragment_id.clone());
609
610 let saved_local_vars = std::mem::take(&mut context.local_vars);
612 let saved_component_attrs = std::mem::take(&mut context.component_attrs);
613
614 context.local_vars = saved_component_attrs;
616
617 if let Some(p) = &mut context.plugin {
618 p.push_scope();
619 }
620
621 self.process_fragment_id(&component.fragment_id, context)?;
622
623 if let Some(p) = &mut context.plugin {
624 p.pop_scope();
625 }
626
627 context.local_vars = saved_local_vars;
629 context.component_attrs = HashMap::new();
630
631 Ok(())
632 }
633
634 fn resolve_value(&self, path: &str, context: &WebUIProcessContext) -> Option<Value> {
636 if let Some(first_part) = path.split('.').next() {
638 if let Some(local_value) = context.local_vars.get(first_part) {
639 if first_part.len() == path.len() {
640 return Some(local_value.clone());
641 }
642 let remaining = &path[first_part.len() + 1..];
643 if let Some(v) = find_value_by_dotted_path(remaining, local_value) {
644 return Some(v);
645 }
646 }
647 }
648 find_value_by_dotted_path(path, context.state)
650 }
651
652 fn evaluate_condition(
658 &self,
659 condition: &webui_protocol::ConditionExpr,
660 context: &WebUIProcessContext,
661 ) -> Result<bool> {
662 let local_vars = &context.local_vars;
663 let state = context.state;
664 match evaluate_with_resolver(condition, |path| {
665 if let Some(first_part) = path.split('.').next() {
666 if let Some(local_value) = local_vars.get(first_part) {
667 if first_part.len() == path.len() {
668 return Some(local_value.clone());
669 }
670 let remaining = &path[first_part.len() + 1..];
671 if let Some(v) = find_value_by_dotted_path(remaining, local_value) {
672 return Some(v);
673 }
674 }
675 }
676 find_value_by_dotted_path(path, state)
677 }) {
678 Ok(result) => Ok(result),
679 Err(ExpressionError::MissingValue(_)) => Ok(false),
680 Err(e) => Err(HandlerError::Evaluation(e.to_string())),
681 }
682 }
683
684 fn process_for_loop(
690 &self,
691 for_loop: &webui_protocol::WebUIFragmentFor,
692 context: &mut WebUIProcessContext,
693 ) -> Result<()> {
694 let collection_name = &for_loop.collection;
695
696 let items = match self.resolve_value(collection_name, context) {
699 Some(Value::Array(arr)) => arr,
700 Some(_) => {
701 return Err(HandlerError::TypeError(format!(
702 "Collection '{}' is not an array",
703 collection_name
704 )))
705 }
706 None => Vec::new(),
707 };
708
709 if let Some(p) = &mut context.plugin {
710 p.on_for_start(&for_loop.fragment_id, context.writer)?;
711 }
712
713 let item_name = &for_loop.item;
714 for (i, item) in items.into_iter().enumerate() {
715 if let Some(p) = &mut context.plugin {
716 p.on_repeat_item_start(i, context.writer)?;
717 p.push_scope();
718 }
719
720 let saved_value = context.local_vars.insert(item_name.clone(), item);
722 self.process_fragment_id(&for_loop.fragment_id, context)?;
723 match saved_value {
724 Some(v) => {
725 context.local_vars.insert(item_name.clone(), v);
726 }
727 None => {
728 context.local_vars.remove(item_name.as_str());
729 }
730 }
731
732 if let Some(p) = &mut context.plugin {
733 p.pop_scope();
734 p.on_repeat_item_end(i, context.writer)?;
735 }
736 }
737
738 if let Some(p) = &mut context.plugin {
739 p.on_for_end(&for_loop.fragment_id, context.writer)?;
740 }
741
742 Ok(())
743 }
744
745 fn process_signal(
751 &self,
752 signal: &webui_protocol::WebUIFragmentSignal,
753 context: &mut WebUIProcessContext,
754 ) -> Result<()> {
755 if signal.raw && signal.value == "head_end" {
757 if let Some(ref nonce) = context.nonce {
758 context
759 .writer
760 .write("<meta name=\"webui-nonce\" content=\"")?;
761 context
762 .writer
763 .write(&crate::html_encode::encode_safe(nonce))?;
764 context.writer.write("\">")?;
765 }
766
767 let is_link = context.protocol.css_strategy() == webui_protocol::CssStrategy::Link;
778 let is_shadow = context.protocol.dom_strategy() == webui_protocol::DomStrategy::Shadow;
779
780 if is_link {
781 let comp_index = crate::route_handler::build_component_index(context.protocol);
782 let (needed_components, _) =
783 crate::route_handler::get_needed_components_for_request(
784 context.protocol,
785 &context.entry_id,
786 &context.request_path,
787 "",
788 &comp_index,
789 )?;
790
791 for name in &needed_components {
792 if let Some(href) = context
793 .protocol
794 .components
795 .get(name)
796 .map(|c| c.css_href.as_str())
797 .filter(|h| !h.is_empty())
798 {
799 if is_shadow {
800 context.writer.write("<link rel=\"preload\" href=\"")?;
801 context.writer.write(href)?;
802 context
803 .writer
804 .write("\" as=\"style\" data-webui-ssr-preload=\"style\">")?;
805 } else {
806 context.writer.write("<link rel=\"stylesheet\" href=\"")?;
807 context.writer.write(href)?;
808 context.writer.write("\">")?;
809 }
810 }
811 }
812 }
813 }
814
815 if signal.raw && signal.value == "body_end" && context.plugin.is_some() {
817 let comp_index = crate::route_handler::build_component_index(context.protocol);
819
820 let (_, inventory_hex) = crate::route_handler::filter_needed_components(
822 &context.rendered_components,
823 "",
824 &comp_index,
825 )?;
826
827 let (reachable_names, _) = crate::route_handler::get_needed_components_for_request(
835 context.protocol,
836 &context.entry_id,
837 &context.request_path,
838 "",
839 &comp_index,
840 )?;
841 let reachable: std::collections::HashSet<String> =
842 reachable_names.into_iter().collect();
843
844 for name in &reachable {
850 if !context.rendered_components.contains(name) {
851 if let Some(css) = context
852 .protocol
853 .components
854 .get(name)
855 .map(|c| c.css.as_str())
856 .filter(|s| !s.is_empty())
857 {
858 context
859 .writer
860 .write("<style type=\"module\" specifier=\"")?;
861 context.writer.write(name)?;
862 context.writer.write("\">")?;
863 context.writer.write(css)?;
864 context.writer.write("</style>")?;
865 }
866 }
867 }
868
869 let template_js = context
873 .plugin
874 .as_ref()
875 .and_then(|p| p.collect_template_js(context.protocol, &reachable));
876
877 if template_js.is_none() {
878 if let Some(ref p) = context.plugin {
880 p.emit_templates(
881 context.protocol,
882 &reachable,
883 context.nonce.as_deref(),
884 context.writer,
885 )?;
886 }
887 }
888
889 let mut webui_obj = serde_json::Map::new();
900
901 webui_obj.insert(
903 "templates".into(),
904 serde_json::Value::Object(serde_json::Map::new()),
905 );
906
907 webui_obj.insert("state".into(), context.state.clone());
909
910 let chain = crate::route_handler::collect_route_chain(
912 context.protocol,
913 &context.entry_id,
914 &context.request_path,
915 &mut context.route_cache,
916 );
917 if !chain.is_empty() {
918 let chain_json = serde_json::Value::Array(
919 chain
920 .iter()
921 .map(crate::route_handler::RouteChainEntry::to_json)
922 .collect(),
923 );
924 webui_obj.insert("chain".into(), chain_json);
925 }
926
927 webui_obj.insert("inventory".into(), serde_json::Value::String(inventory_hex));
929
930 if let Some(ref nonce) = context.nonce {
932 webui_obj.insert("nonce".into(), serde_json::Value::String(nonce.clone()));
933 }
934
935 let is_link = context.protocol.css_strategy() == webui_protocol::CssStrategy::Link;
937 if is_link {
938 let mut css_hrefs: Vec<serde_json::Value> = Vec::new();
939 for name in &reachable {
940 if let Some(href) = context
941 .protocol
942 .components
943 .get(name)
944 .map(|c| c.css_href.as_str())
945 .filter(|h| !h.is_empty())
946 {
947 css_hrefs.push(serde_json::Value::String(href.to_string()));
948 }
949 }
950 if !css_hrefs.is_empty() {
951 webui_obj.insert("css".into(), serde_json::Value::Array(css_hrefs));
952 }
953 }
954
955 {
957 let mut style_specs: Vec<serde_json::Value> = Vec::new();
958 for name in &reachable {
959 if context
960 .protocol
961 .components
962 .get(name)
963 .map(|c| !c.css.is_empty())
964 .unwrap_or(false)
965 {
966 style_specs.push(serde_json::Value::String(name.clone()));
967 }
968 }
969 if !style_specs.is_empty() {
970 webui_obj.insert("styles".into(), serde_json::Value::Array(style_specs));
971 }
972 }
973
974 if let Some(ref nonce) = context.nonce {
976 context.writer.write("<script nonce=\"")?;
977 context.writer.write(nonce)?;
978 context.writer.write("\">")?;
979 } else {
980 context.writer.write("<script>")?;
981 }
982
983 context.writer.write("window.__webui=")?;
986 let webui_str =
987 serde_json::to_string(&serde_json::Value::Object(webui_obj)).unwrap_or_default();
988 let safe_webui = webui_str.replace("</", "<\\/");
989 context.writer.write(&safe_webui)?;
990 context.writer.write(";")?;
991
992 if let Some(ref tmpls) = template_js {
995 context.writer.write("\n")?;
996 for tmpl in tmpls {
997 context.writer.write(tmpl)?;
998 }
999 }
1000
1001 context.writer.write("</script>\n")?;
1002 }
1003
1004 if let Some(p) = &mut context.plugin {
1005 p.on_binding_start(&signal.value, context.writer)?;
1006 }
1007
1008 if let Some(value) = self.resolve_value(&signal.value, context) {
1009 self.write_signal_value(&value, signal.raw, context.writer)?;
1010 }
1011
1012 if let Some(p) = &mut context.plugin {
1013 p.on_binding_end(&signal.value, context.writer)?;
1014 }
1015 Ok(())
1016 }
1017
1018 fn write_signal_value(
1021 &self,
1022 value: &Value,
1023 raw: bool,
1024 writer: &mut dyn ResponseWriter,
1025 ) -> Result<()> {
1026 if raw {
1027 match value {
1028 Value::String(s) => writer.write(s),
1029 _ => writer.write(&value.to_string()),
1030 }
1031 } else {
1032 match value {
1033 Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
1034 _ => {
1035 let s = value.to_string();
1036 writer.write(&crate::html_encode::encode_safe(&s))
1037 }
1038 }
1039 }
1040 }
1041
1042 fn process_if(
1044 &self,
1045 if_cond: &webui_protocol::WebUIFragmentIf,
1046 context: &mut WebUIProcessContext,
1047 ) -> Result<()> {
1048 let condition = if_cond
1049 .condition
1050 .as_ref()
1051 .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
1052 let condition_met = self.evaluate_condition(condition, context)?;
1053
1054 if let Some(p) = &mut context.plugin {
1055 p.on_if_start(&if_cond.fragment_id, context.writer)?;
1056 }
1057
1058 if condition_met {
1059 if let Some(p) = &mut context.plugin {
1060 p.push_scope();
1061 }
1062
1063 self.process_fragment_id(&if_cond.fragment_id, context)?;
1064
1065 if let Some(p) = &mut context.plugin {
1066 p.pop_scope();
1067 }
1068 }
1069
1070 if let Some(p) = &mut context.plugin {
1071 p.on_if_end(&if_cond.fragment_id, context.writer)?;
1072 }
1073
1074 Ok(())
1075 }
1076
1077 fn process_attribute(
1079 &self,
1080 attr: &webui_protocol::WebUIFragmentAttribute,
1081 context: &mut WebUIProcessContext,
1082 ) -> Result<()> {
1083 if attr.attr_start {
1085 context.component_attrs = HashMap::new();
1086 }
1087
1088 if let Some(condition) = &attr.condition_tree {
1090 let condition_met = self.evaluate_condition(condition, context)?;
1091
1092 if !attr.attr_skip {
1093 let name = component_attr_name(&attr.name);
1094 context
1095 .component_attrs
1096 .insert(name, Value::Bool(condition_met));
1097 }
1098
1099 if condition_met {
1100 context.writer.write(" ")?;
1101 context.writer.write(&attr.name)?;
1102 }
1103 return Ok(());
1104 }
1105
1106 if !attr.template.is_empty() {
1108 let raw_value = self.render_template_attr_value(&attr.template, context)?;
1109 let escaped = crate::html_encode::encode_safe(&raw_value);
1110 write_attr(context.writer, &attr.name, &escaped)?;
1111
1112 if !attr.attr_skip {
1113 let name = component_attr_name(&attr.name);
1114 context
1115 .component_attrs
1116 .insert(name, Value::String(raw_value));
1117 }
1118 return Ok(());
1119 }
1120
1121 if !attr.value.is_empty() {
1123 if attr.raw_value {
1124 write_attr(context.writer, &attr.name, &attr.value)?;
1126 if !attr.attr_skip {
1127 let name = component_attr_name(&attr.name);
1128 context
1129 .component_attrs
1130 .insert(name, Value::String(attr.value.clone()));
1131 }
1132 } else if attr.complex {
1133 if let Some(value) = self.resolve_value(&attr.value, context) {
1135 if !attr.attr_skip {
1136 let stripped = attr.name.strip_prefix(':').unwrap_or(&attr.name);
1137 let name = component_attr_name(stripped);
1138 context.component_attrs.insert(name, value);
1139 }
1140 }
1141 } else {
1142 let value = self.resolve_value(&attr.value, context);
1144 match &value {
1147 Some(Value::String(s)) => {
1148 write_attr(
1149 context.writer,
1150 &attr.name,
1151 &crate::html_encode::encode_safe(s),
1152 )?;
1153 }
1154 Some(Value::Null) | None => {
1155 write_attr(context.writer, &attr.name, "")?;
1156 }
1157 Some(other) => {
1158 let s = other.to_string();
1159 write_attr(
1160 context.writer,
1161 &attr.name,
1162 &crate::html_encode::encode_safe(&s),
1163 )?;
1164 }
1165 }
1166
1167 if !attr.attr_skip {
1168 let name = component_attr_name(&attr.name);
1169 context
1170 .component_attrs
1171 .insert(name, value.unwrap_or(Value::String(String::new())));
1172 }
1173 }
1174 }
1175
1176 Ok(())
1177 }
1178
1179 fn render_template_attr_value(
1181 &self,
1182 template_id: &str,
1183 context: &WebUIProcessContext,
1184 ) -> Result<String> {
1185 let fragments = context
1186 .protocol
1187 .fragments
1188 .get(template_id)
1189 .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
1190 let mut raw_value = String::new();
1191 for frag in &fragments.fragments {
1192 match frag.fragment.as_ref() {
1193 Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
1194 Some(Fragment::Signal(signal)) => {
1195 if let Some(value) = self.resolve_value(&signal.value, context) {
1196 match &value {
1197 Value::String(s) => raw_value.push_str(s),
1198 _ => raw_value.push_str(&value.to_string()),
1199 }
1200 }
1201 }
1202 _ => {}
1203 }
1204 }
1205 Ok(raw_value)
1206 }
1207
1208 pub fn render(
1212 &self,
1213 protocol: &WebUIProtocol,
1214 state: &Value,
1215 options: &RenderOptions<'_>,
1216 writer: &mut dyn ResponseWriter,
1217 ) -> Result<()> {
1218 let mut context = WebUIProcessContext {
1219 protocol,
1220 state,
1221 writer,
1222 local_vars: HashMap::new(),
1223 component_attrs: HashMap::new(),
1224 request_path: options.request_path.to_string(),
1225 route_base: "/".to_string(),
1226 rendered_components: HashSet::new(),
1227 plugin: self.plugin_factory.map(|f| f()),
1228 route_children: Vec::new(),
1229 entry_id: options.entry_id.to_string(),
1230 nonce: options.nonce.map(String::from),
1231 route_cache: CompiledRouteCache::new(),
1232 route_chain_index: 0,
1233 };
1234
1235 self.process_fragment_id(options.entry_id, &mut context)?;
1236
1237 Ok(())
1238 }
1239}
1240
1241impl Default for WebUIHandler {
1242 fn default() -> Self {
1243 Self::new()
1244 }
1245}
1246
1247fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
1249 writer.write(" ")?;
1250 writer.write(name)?;
1251 writer.write("=\"")?;
1252 writer.write(value)?;
1253 writer.write("\"")
1254}
1255
1256pub fn handle(
1259 protocol: &WebUIProtocol,
1260 state: &Value,
1261 options: &RenderOptions<'_>,
1262 writer: &mut dyn ResponseWriter,
1263) -> Result<()> {
1264 let handler = WebUIHandler::new();
1265 handler.handle(protocol, state, options, writer)
1266}
1267
1268#[cfg(test)]
1269mod tests {
1270 use super::*;
1271 use std::cell::RefCell;
1272 use webui_protocol::{
1273 web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
1274 WebUIFragmentAttribute,
1275 };
1276 use webui_test_utils::test_json;
1277
1278 struct TestWriter {
1280 content: RefCell<String>,
1281 ended: RefCell<bool>,
1282 }
1283
1284 impl TestWriter {
1285 fn new() -> Self {
1286 Self {
1287 content: RefCell::new(String::new()),
1288 ended: RefCell::new(false),
1289 }
1290 }
1291
1292 fn get_content(&self) -> String {
1293 self.content.borrow().clone()
1294 }
1295
1296 fn is_ended(&self) -> bool {
1297 *self.ended.borrow()
1298 }
1299 }
1300
1301 impl ResponseWriter for TestWriter {
1302 fn write(&mut self, content: &str) -> Result<()> {
1303 self.content.borrow_mut().push_str(content);
1304 Ok(())
1305 }
1306
1307 fn end(&mut self) -> Result<()> {
1308 *self.ended.borrow_mut() = true;
1309 Ok(())
1310 }
1311 }
1312
1313 #[test]
1314 fn test_handle_raw() {
1315 let mut fragments = HashMap::new();
1317 fragments.insert(
1318 "index.html".to_string(),
1319 FragmentList {
1320 fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
1321 },
1322 );
1323
1324 let protocol = WebUIProtocol::new(fragments);
1325 let state = test_json!({});
1326
1327 let mut writer = TestWriter::new();
1329
1330 assert!(
1332 handle(
1333 &protocol,
1334 &state,
1335 &RenderOptions::new("index.html", "/"),
1336 &mut writer
1337 )
1338 .is_ok(),
1339 "Failed to handle raw protocol"
1340 );
1341
1342 assert_eq!(writer.get_content(), "Hello, WebUI!");
1344 assert!(writer.is_ended());
1345 }
1346
1347 #[test]
1348 fn test_handle_signal() {
1349 let mut fragments = HashMap::new();
1351 fragments.insert(
1352 "index.html".to_string(),
1353 FragmentList {
1354 fragments: vec![
1355 WebUIFragment::raw("Hello, "),
1356 WebUIFragment::signal("name", false),
1357 WebUIFragment::raw("!"),
1358 ],
1359 },
1360 );
1361
1362 let protocol = WebUIProtocol::new(fragments);
1363 let state = test_json!({"name": "WebUI"});
1364
1365 let mut writer = TestWriter::new();
1367
1368 assert!(
1370 handle(
1371 &protocol,
1372 &state,
1373 &RenderOptions::new("index.html", "/"),
1374 &mut writer
1375 )
1376 .is_ok(),
1377 "Failed to handle signal protocol"
1378 );
1379
1380 assert_eq!(writer.get_content(), "Hello, WebUI!");
1382 assert!(writer.is_ended());
1383 }
1384
1385 #[test]
1386 fn test_handle_for_loop() {
1387 let mut fragments = HashMap::new();
1389 fragments.insert(
1390 "index.html".to_string(),
1391 FragmentList {
1392 fragments: vec![
1393 WebUIFragment::raw("People: "),
1394 WebUIFragment::for_loop("person", "people", "person-item"),
1395 ],
1396 },
1397 );
1398
1399 fragments.insert(
1400 "person-item".to_string(),
1401 FragmentList {
1402 fragments: vec![
1403 WebUIFragment::signal("person.name", false),
1404 WebUIFragment::raw(", "),
1405 ],
1406 },
1407 );
1408
1409 let protocol = WebUIProtocol::new(fragments);
1410 let state = test_json!({
1411 "people": [
1412 {"name": "Alice"},
1413 {"name": "Bob"},
1414 {"name": "Charlie"}
1415 ]
1416 });
1417
1418 let mut writer = TestWriter::new();
1420
1421 assert!(
1423 handle(
1424 &protocol,
1425 &state,
1426 &RenderOptions::new("index.html", "/"),
1427 &mut writer
1428 )
1429 .is_ok(),
1430 "Failed to handle for loop protocol"
1431 );
1432
1433 assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
1435 assert!(writer.is_ended());
1436 }
1437
1438 #[test]
1439 fn test_handle_if_condition() {
1440 let mut fragments = HashMap::new();
1442 fragments.insert(
1443 "index.html".to_string(),
1444 FragmentList {
1445 fragments: vec![
1446 WebUIFragment::raw("Status: "),
1447 WebUIFragment::if_cond(
1448 webui_protocol::ConditionExpr::identifier("isActive"),
1449 "active-content",
1450 ),
1451 WebUIFragment::raw("End"),
1452 ],
1453 },
1454 );
1455
1456 fragments.insert(
1457 "active-content".to_string(),
1458 FragmentList {
1459 fragments: vec![WebUIFragment::raw("Active")],
1460 },
1461 );
1462
1463 let protocol = WebUIProtocol::new(fragments);
1464
1465 let state_true = test_json!({"isActive": true});
1467 let mut writer_true = TestWriter::new();
1468 assert!(
1469 handle(
1470 &protocol,
1471 &state_true,
1472 &RenderOptions::new("index.html", "/"),
1473 &mut writer_true
1474 )
1475 .is_ok(),
1476 "Failed to handle if condition (true case)"
1477 );
1478 assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
1479 assert!(writer_true.is_ended());
1480
1481 let state_false = test_json!({"isActive": false});
1483 let mut writer_false = TestWriter::new();
1484 assert!(
1485 handle(
1486 &protocol,
1487 &state_false,
1488 &RenderOptions::new("index.html", "/"),
1489 &mut writer_false
1490 )
1491 .is_ok(),
1492 "Failed to handle if condition (false case)"
1493 );
1494 assert_eq!(writer_false.get_content(), "Status: End");
1495 assert!(writer_false.is_ended());
1496 }
1497
1498 #[test]
1499 fn test_handle_component() {
1500 let mut fragments = HashMap::new();
1502 fragments.insert(
1503 "index.html".to_string(),
1504 FragmentList {
1505 fragments: vec![
1506 WebUIFragment::raw("Component: "),
1507 WebUIFragment::component("my-component"),
1508 ],
1509 },
1510 );
1511
1512 fragments.insert(
1513 "my-component".to_string(),
1514 FragmentList {
1515 fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
1516 },
1517 );
1518
1519 let protocol = WebUIProtocol::new(fragments);
1520 let state = test_json!({});
1521
1522 let mut writer = TestWriter::new();
1524
1525 assert!(
1527 handle(
1528 &protocol,
1529 &state,
1530 &RenderOptions::new("index.html", "/"),
1531 &mut writer
1532 )
1533 .is_ok(),
1534 "Failed to handle component protocol"
1535 );
1536
1537 assert_eq!(
1539 writer.get_content(),
1540 "Component: <div>Component Content</div>"
1541 );
1542 assert!(writer.is_ended());
1543 }
1544
1545 #[test]
1546 fn test_missing_fragment() {
1547 let mut fragments = HashMap::new();
1549 fragments.insert(
1550 "index.html".to_string(),
1551 FragmentList {
1552 fragments: vec![WebUIFragment::component("missing-component")],
1553 },
1554 );
1555
1556 let protocol = WebUIProtocol::new(fragments);
1557 let state = test_json!({});
1558
1559 let mut writer = TestWriter::new();
1561
1562 let result = handle(
1564 &protocol,
1565 &state,
1566 &RenderOptions::new("index.html", "/"),
1567 &mut writer,
1568 );
1569
1570 assert!(result.is_err());
1572 if let Err(HandlerError::MissingFragment(fragment_id)) = result {
1573 assert_eq!(fragment_id, "missing-component");
1574 } else {
1575 panic!("Expected MissingFragment error");
1576 }
1577 }
1578
1579 #[test]
1580 fn test_missing_signal_renders_empty() {
1581 let mut fragments = HashMap::new();
1583 fragments.insert(
1584 "index.html".to_string(),
1585 FragmentList {
1586 fragments: vec![
1587 WebUIFragment::raw("Hello, "),
1588 WebUIFragment::signal("missing_field", false),
1589 WebUIFragment::raw("!"),
1590 ],
1591 },
1592 );
1593
1594 let protocol = WebUIProtocol::new(fragments);
1595 let state = test_json!({});
1596
1597 let mut writer = TestWriter::new();
1598
1599 assert!(
1600 handle(
1601 &protocol,
1602 &state,
1603 &RenderOptions::new("index.html", "/"),
1604 &mut writer
1605 )
1606 .is_ok(),
1607 "Missing signal should not produce an error"
1608 );
1609
1610 assert_eq!(writer.get_content(), "Hello, !");
1611 assert!(writer.is_ended());
1612 }
1613
1614 #[test]
1617 fn test_boolean_attr_true() {
1618 let mut fragments = HashMap::new();
1619 fragments.insert(
1620 "index.html".to_string(),
1621 FragmentList {
1622 fragments: vec![
1623 WebUIFragment::raw("<button"),
1624 WebUIFragment::attribute_boolean(
1625 "disabled",
1626 ConditionExpr::identifier("isDisabled"),
1627 ),
1628 WebUIFragment::raw(">Click</button>"),
1629 ],
1630 },
1631 );
1632 let protocol = WebUIProtocol::new(fragments);
1633 let state = test_json!({"isDisabled": true});
1634 let mut writer = TestWriter::new();
1635 handle(
1636 &protocol,
1637 &state,
1638 &RenderOptions::new("index.html", "/"),
1639 &mut writer,
1640 )
1641 .unwrap();
1642 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
1643 }
1644
1645 #[test]
1646 fn test_boolean_attr_false() {
1647 let mut fragments = HashMap::new();
1648 fragments.insert(
1649 "index.html".to_string(),
1650 FragmentList {
1651 fragments: vec![
1652 WebUIFragment::raw("<button"),
1653 WebUIFragment::attribute_boolean(
1654 "disabled",
1655 ConditionExpr::identifier("isDisabled"),
1656 ),
1657 WebUIFragment::raw(">Click</button>"),
1658 ],
1659 },
1660 );
1661 let protocol = WebUIProtocol::new(fragments);
1662 let state = test_json!({"isDisabled": false});
1663 let mut writer = TestWriter::new();
1664 handle(
1665 &protocol,
1666 &state,
1667 &RenderOptions::new("index.html", "/"),
1668 &mut writer,
1669 )
1670 .unwrap();
1671 assert_eq!(writer.get_content(), "<button>Click</button>");
1672 }
1673
1674 #[test]
1675 fn test_boolean_attr_missing() {
1676 let mut fragments = HashMap::new();
1677 fragments.insert(
1678 "index.html".to_string(),
1679 FragmentList {
1680 fragments: vec![
1681 WebUIFragment::raw("<input type=\"checkbox\""),
1682 WebUIFragment::attribute_boolean(
1683 "checked",
1684 ConditionExpr::identifier("checked"),
1685 ),
1686 WebUIFragment::raw(">"),
1687 ],
1688 },
1689 );
1690 let protocol = WebUIProtocol::new(fragments);
1691 let state = test_json!({});
1692 let mut writer = TestWriter::new();
1693 handle(
1694 &protocol,
1695 &state,
1696 &RenderOptions::new("index.html", "/"),
1697 &mut writer,
1698 )
1699 .unwrap();
1700 assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
1701 }
1702
1703 #[test]
1704 fn test_boolean_attr_multiple() {
1705 let mut fragments = HashMap::new();
1706 fragments.insert(
1707 "index.html".to_string(),
1708 FragmentList {
1709 fragments: vec![
1710 WebUIFragment::raw("<input type=\"checkbox\""),
1711 WebUIFragment::attribute_boolean(
1712 "checked",
1713 ConditionExpr::identifier("checked"),
1714 ),
1715 WebUIFragment::attribute_boolean(
1716 "disabled",
1717 ConditionExpr::identifier("disabled"),
1718 ),
1719 WebUIFragment::raw(">"),
1720 ],
1721 },
1722 );
1723 let protocol = WebUIProtocol::new(fragments);
1724 let state = test_json!({"checked": true, "disabled": false});
1725 let mut writer = TestWriter::new();
1726 handle(
1727 &protocol,
1728 &state,
1729 &RenderOptions::new("index.html", "/"),
1730 &mut writer,
1731 )
1732 .unwrap();
1733 assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
1734 }
1735
1736 #[test]
1739 fn test_attribute_with_value() {
1740 let mut fragments = HashMap::new();
1741 fragments.insert(
1742 "index.html".to_string(),
1743 FragmentList {
1744 fragments: vec![
1745 WebUIFragment::raw("<input"),
1746 WebUIFragment::attribute("value", "inputValue"),
1747 WebUIFragment::raw(">"),
1748 ],
1749 },
1750 );
1751 let protocol = WebUIProtocol::new(fragments);
1752 let state = test_json!({"inputValue": "Hello"});
1753 let mut writer = TestWriter::new();
1754 handle(
1755 &protocol,
1756 &state,
1757 &RenderOptions::new("index.html", "/"),
1758 &mut writer,
1759 )
1760 .unwrap();
1761 assert_eq!(writer.get_content(), "<input value=\"Hello\">");
1762 }
1763
1764 #[test]
1765 fn test_attribute_with_falsy_numeric() {
1766 let mut fragments = HashMap::new();
1767 fragments.insert(
1768 "index.html".to_string(),
1769 FragmentList {
1770 fragments: vec![
1771 WebUIFragment::raw("<div name=\"test\""),
1772 WebUIFragment::attribute("handle", "number"),
1773 WebUIFragment::raw("></div>"),
1774 ],
1775 },
1776 );
1777 let protocol = WebUIProtocol::new(fragments);
1778 let state = test_json!({"number": 0});
1779 let mut writer = TestWriter::new();
1780 handle(
1781 &protocol,
1782 &state,
1783 &RenderOptions::new("index.html", "/"),
1784 &mut writer,
1785 )
1786 .unwrap();
1787 assert_eq!(
1788 writer.get_content(),
1789 "<div name=\"test\" handle=\"0\"></div>"
1790 );
1791 }
1792
1793 #[test]
1796 fn test_attribute_array_value_is_escaped() {
1797 let mut fragments = HashMap::new();
1798 fragments.insert(
1799 "index.html".to_string(),
1800 FragmentList {
1801 fragments: vec![
1802 WebUIFragment::raw("<a"),
1803 WebUIFragment::attribute("href", "value"),
1804 WebUIFragment::raw(">demo</a>"),
1805 ],
1806 },
1807 );
1808 let protocol = WebUIProtocol::new(fragments);
1809 let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
1810 let mut writer = TestWriter::new();
1811 handle(
1812 &protocol,
1813 &state,
1814 &RenderOptions::new("index.html", "/"),
1815 &mut writer,
1816 )
1817 .unwrap();
1818 let content = writer.get_content();
1819 assert!(
1822 content.contains("""),
1823 "Double quotes inside attribute value must be escaped: {content}"
1824 );
1825 assert_eq!(
1828 content.matches("=\"").count(),
1829 1,
1830 "Only one attribute assignment expected: {content}"
1831 );
1832 }
1833
1834 #[test]
1835 fn test_attribute_object_value_is_escaped() {
1836 let mut fragments = HashMap::new();
1837 fragments.insert(
1838 "index.html".to_string(),
1839 FragmentList {
1840 fragments: vec![
1841 WebUIFragment::raw("<div"),
1842 WebUIFragment::attribute("data-cfg", "cfg"),
1843 WebUIFragment::raw("></div>"),
1844 ],
1845 },
1846 );
1847 let protocol = WebUIProtocol::new(fragments);
1848 let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
1849 let mut writer = TestWriter::new();
1850 handle(
1851 &protocol,
1852 &state,
1853 &RenderOptions::new("index.html", "/"),
1854 &mut writer,
1855 )
1856 .unwrap();
1857 let content = writer.get_content();
1858 assert!(
1859 content.contains("""),
1860 "Double quotes inside attribute value must be escaped: {content}"
1861 );
1862 assert_eq!(
1863 content.matches("=\"").count(),
1864 1,
1865 "Only one attribute assignment expected: {content}"
1866 );
1867 }
1868
1869 #[test]
1872 fn test_mixed_attribute_template() {
1873 let mut fragments = HashMap::new();
1874 fragments.insert(
1875 "index.html".to_string(),
1876 FragmentList {
1877 fragments: vec![
1878 WebUIFragment::raw("<input"),
1879 WebUIFragment::attribute_template("value", "attr-1"),
1880 WebUIFragment::raw(">"),
1881 ],
1882 },
1883 );
1884 fragments.insert(
1885 "attr-1".to_string(),
1886 FragmentList {
1887 fragments: vec![
1888 WebUIFragment::raw("hello "),
1889 WebUIFragment::signal("item", false),
1890 ],
1891 },
1892 );
1893 let protocol = WebUIProtocol::new(fragments);
1894 let state = test_json!({"item": "world"});
1895 let mut writer = TestWriter::new();
1896 handle(
1897 &protocol,
1898 &state,
1899 &RenderOptions::new("index.html", "/"),
1900 &mut writer,
1901 )
1902 .unwrap();
1903 assert_eq!(writer.get_content(), "<input value=\"hello world\">");
1904 }
1905
1906 #[test]
1909 fn test_raw_signal_not_escaped() {
1910 let mut fragments = HashMap::new();
1911 fragments.insert(
1912 "index.html".to_string(),
1913 FragmentList {
1914 fragments: vec![
1915 WebUIFragment::signal("html", false),
1916 WebUIFragment::signal("html", true),
1917 ],
1918 },
1919 );
1920 let protocol = WebUIProtocol::new(fragments);
1921 let state = test_json!({"html": "<strong>hi</strong>"});
1922 let mut writer = TestWriter::new();
1923 handle(
1924 &protocol,
1925 &state,
1926 &RenderOptions::new("index.html", "/"),
1927 &mut writer,
1928 )
1929 .unwrap();
1930 assert_eq!(
1931 writer.get_content(),
1932 "<strong>hi</strong><strong>hi</strong>"
1933 );
1934 }
1935
1936 #[test]
1939 fn test_nested_for_loop() {
1940 let mut fragments = HashMap::new();
1941 fragments.insert(
1942 "index.html".to_string(),
1943 FragmentList {
1944 fragments: vec![
1945 WebUIFragment::raw("<div>"),
1946 WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
1947 WebUIFragment::raw("</div>"),
1948 ],
1949 },
1950 );
1951 fragments.insert(
1952 "outer".to_string(),
1953 FragmentList {
1954 fragments: vec![
1955 WebUIFragment::raw("<div>"),
1956 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
1957 WebUIFragment::raw("</div>"),
1958 ],
1959 },
1960 );
1961 fragments.insert(
1962 "inner".to_string(),
1963 FragmentList {
1964 fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
1965 },
1966 );
1967 let protocol = WebUIProtocol::new(fragments);
1968 let state = test_json!({
1969 "outerItems": [
1970 {"innerItems": [{"name": "A"}, {"name": "B"}]},
1971 {"innerItems": [{"name": "C"}]}
1972 ]
1973 });
1974 let mut writer = TestWriter::new();
1975 handle(
1976 &protocol,
1977 &state,
1978 &RenderOptions::new("index.html", "/"),
1979 &mut writer,
1980 )
1981 .unwrap();
1982 assert_eq!(
1983 writer.get_content(),
1984 "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
1985 );
1986 }
1987
1988 #[test]
1989 fn test_nested_for_with_signals() {
1990 let mut fragments = HashMap::new();
1991 fragments.insert(
1992 "index.html".to_string(),
1993 FragmentList {
1994 fragments: vec![
1995 WebUIFragment::raw("<div>"),
1996 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
1997 WebUIFragment::raw("</div>"),
1998 ],
1999 },
2000 );
2001 fragments.insert(
2002 "outerTemplate".to_string(),
2003 FragmentList {
2004 fragments: vec![
2005 WebUIFragment::raw("<div>"),
2006 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
2007 WebUIFragment::raw("</div>"),
2008 ],
2009 },
2010 );
2011 fragments.insert(
2012 "innerTemplate".to_string(),
2013 FragmentList {
2014 fragments: vec![
2015 WebUIFragment::raw("<span>"),
2016 WebUIFragment::signal("innerItem.name", false),
2017 WebUIFragment::raw("</span>"),
2018 ],
2019 },
2020 );
2021 let protocol = WebUIProtocol::new(fragments);
2022 let state = test_json!({
2023 "outerItems": [
2024 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
2025 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
2026 ]
2027 });
2028 let mut writer = TestWriter::new();
2029 handle(
2030 &protocol,
2031 &state,
2032 &RenderOptions::new("index.html", "/"),
2033 &mut writer,
2034 )
2035 .unwrap();
2036 assert_eq!(
2037 writer.get_content(),
2038 "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
2039 );
2040 }
2041
2042 #[test]
2043 fn test_nested_for_with_global_state() {
2044 let mut fragments = HashMap::new();
2045 fragments.insert(
2046 "index.html".to_string(),
2047 FragmentList {
2048 fragments: vec![
2049 WebUIFragment::raw("<div>"),
2050 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
2051 WebUIFragment::raw("</div>"),
2052 ],
2053 },
2054 );
2055 fragments.insert(
2056 "outerTemplate".to_string(),
2057 FragmentList {
2058 fragments: vec![
2059 WebUIFragment::raw("<div>"),
2060 WebUIFragment::signal("globalOuter", false),
2061 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
2062 WebUIFragment::raw("</div>"),
2063 ],
2064 },
2065 );
2066 fragments.insert(
2067 "innerTemplate".to_string(),
2068 FragmentList {
2069 fragments: vec![
2070 WebUIFragment::raw("<span>"),
2071 WebUIFragment::signal("innerItem.name", false),
2072 WebUIFragment::signal("globalInner", false),
2073 WebUIFragment::raw("</span>"),
2074 ],
2075 },
2076 );
2077 let protocol = WebUIProtocol::new(fragments);
2078 let state = test_json!({
2079 "globalOuter": "GO",
2080 "globalInner": "GI",
2081 "outerItems": [
2082 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
2083 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
2084 ]
2085 });
2086 let mut writer = TestWriter::new();
2087 handle(
2088 &protocol,
2089 &state,
2090 &RenderOptions::new("index.html", "/"),
2091 &mut writer,
2092 )
2093 .unwrap();
2094 assert_eq!(
2095 writer.get_content(),
2096 "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
2097 );
2098 }
2099
2100 #[test]
2103 fn test_if_in_for_uses_local_state() {
2104 let mut fragments = HashMap::new();
2105 fragments.insert(
2106 "index.html".to_string(),
2107 FragmentList {
2108 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
2109 },
2110 );
2111 fragments.insert(
2112 "item-tpl".to_string(),
2113 FragmentList {
2114 fragments: vec![WebUIFragment::if_cond(
2115 ConditionExpr::identifier("item.visible"),
2116 "visible-tpl",
2117 )],
2118 },
2119 );
2120 fragments.insert(
2121 "visible-tpl".to_string(),
2122 FragmentList {
2123 fragments: vec![WebUIFragment::signal("item.name", false)],
2124 },
2125 );
2126 let protocol = WebUIProtocol::new(fragments);
2127 let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
2128 let mut writer = TestWriter::new();
2129 handle(
2130 &protocol,
2131 &state,
2132 &RenderOptions::new("index.html", "/"),
2133 &mut writer,
2134 )
2135 .unwrap();
2136 assert_eq!(writer.get_content(), "Show");
2137 }
2138
2139 #[test]
2140 fn test_for_if_local_overrides_global() {
2141 let mut fragments = HashMap::new();
2142 fragments.insert(
2143 "index.html".to_string(),
2144 FragmentList {
2145 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
2146 },
2147 );
2148 fragments.insert(
2149 "item-tpl".to_string(),
2150 FragmentList {
2151 fragments: vec![WebUIFragment::if_cond(
2152 ConditionExpr::identifier("item.flag"),
2153 "show-tpl",
2154 )],
2155 },
2156 );
2157 fragments.insert(
2158 "show-tpl".to_string(),
2159 FragmentList {
2160 fragments: vec![WebUIFragment::raw("yes")],
2161 },
2162 );
2163 let protocol = WebUIProtocol::new(fragments);
2164 let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
2166 let mut writer = TestWriter::new();
2167 handle(
2168 &protocol,
2169 &state,
2170 &RenderOptions::new("index.html", "/"),
2171 &mut writer,
2172 )
2173 .unwrap();
2174 assert_eq!(writer.get_content(), "yes");
2175 }
2176
2177 #[test]
2180 fn test_component_attr_state_simple() {
2181 let mut fragments = HashMap::new();
2182 fragments.insert(
2183 "index.html".to_string(),
2184 FragmentList {
2185 fragments: vec![
2186 WebUIFragment::raw("<my-comp"),
2187 WebUIFragment {
2188 fragment: Some(web_ui_fragment::Fragment::Attribute(
2189 WebUIFragmentAttribute {
2190 name: "title".into(),
2191 value: "Attribute Title".into(),
2192 attr_start: true,
2193 raw_value: true,
2194 ..Default::default()
2195 },
2196 )),
2197 },
2198 WebUIFragment::raw(">"),
2199 WebUIFragment::component("my-comp"),
2200 WebUIFragment::raw("</my-comp>"),
2201 ],
2202 },
2203 );
2204 fragments.insert(
2205 "my-comp".to_string(),
2206 FragmentList {
2207 fragments: vec![
2208 WebUIFragment::raw("<span>"),
2209 WebUIFragment::signal("title", false),
2210 WebUIFragment::raw("</span>"),
2211 ],
2212 },
2213 );
2214 let protocol = WebUIProtocol::new(fragments);
2215 let state = test_json!({"title": "Global Title"});
2216 let mut writer = TestWriter::new();
2217 handle(
2218 &protocol,
2219 &state,
2220 &RenderOptions::new("index.html", "/"),
2221 &mut writer,
2222 )
2223 .unwrap();
2224 assert_eq!(
2225 writer.get_content(),
2226 "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
2227 );
2228 }
2229
2230 #[test]
2231 fn test_component_attr_state_template() {
2232 let mut fragments = HashMap::new();
2233 fragments.insert(
2234 "index.html".to_string(),
2235 FragmentList {
2236 fragments: vec![
2237 WebUIFragment::raw("<my-comp"),
2238 WebUIFragment {
2239 fragment: Some(web_ui_fragment::Fragment::Attribute(
2240 WebUIFragmentAttribute {
2241 name: "title".into(),
2242 template: "title-attr".into(),
2243 attr_start: true,
2244 ..Default::default()
2245 },
2246 )),
2247 },
2248 WebUIFragment::raw(">"),
2249 WebUIFragment::component("my-comp"),
2250 WebUIFragment::raw("</my-comp>"),
2251 ],
2252 },
2253 );
2254 fragments.insert(
2255 "title-attr".to_string(),
2256 FragmentList {
2257 fragments: vec![
2258 WebUIFragment::raw("hello "),
2259 WebUIFragment::signal("item", false),
2260 ],
2261 },
2262 );
2263 fragments.insert(
2264 "my-comp".to_string(),
2265 FragmentList {
2266 fragments: vec![
2267 WebUIFragment::raw("<span>"),
2268 WebUIFragment::signal("title", false),
2269 WebUIFragment::raw("</span>"),
2270 ],
2271 },
2272 );
2273 let protocol = WebUIProtocol::new(fragments);
2274 let state = test_json!({"item": "<world>"});
2275 let mut writer = TestWriter::new();
2276 handle(
2277 &protocol,
2278 &state,
2279 &RenderOptions::new("index.html", "/"),
2280 &mut writer,
2281 )
2282 .unwrap();
2283 assert_eq!(
2284 writer.get_content(),
2285 "<my-comp title=\"hello <world>\"><span>hello <world></span></my-comp>"
2286 );
2287 }
2288
2289 #[test]
2290 fn test_component_attr_camel_case() {
2291 let mut fragments = HashMap::new();
2292 fragments.insert(
2293 "index.html".to_string(),
2294 FragmentList {
2295 fragments: vec![
2296 WebUIFragment::raw("<my-comp"),
2297 WebUIFragment {
2298 fragment: Some(web_ui_fragment::Fragment::Attribute(
2299 WebUIFragmentAttribute {
2300 name: "data-title".into(),
2301 template: "dt-attr".into(),
2302 attr_start: true,
2303 ..Default::default()
2304 },
2305 )),
2306 },
2307 WebUIFragment::raw(">"),
2308 WebUIFragment::component("my-comp"),
2309 WebUIFragment::raw("</my-comp>"),
2310 ],
2311 },
2312 );
2313 fragments.insert(
2314 "dt-attr".to_string(),
2315 FragmentList {
2316 fragments: vec![
2317 WebUIFragment::raw("prefix "),
2318 WebUIFragment::signal("item", false),
2319 ],
2320 },
2321 );
2322 fragments.insert(
2323 "my-comp".to_string(),
2324 FragmentList {
2325 fragments: vec![
2326 WebUIFragment::raw("<span>"),
2327 WebUIFragment::signal("dataTitle", false),
2328 WebUIFragment::raw("</span>"),
2329 ],
2330 },
2331 );
2332 let protocol = WebUIProtocol::new(fragments);
2333 let state = test_json!({"item": "a&b"});
2334 let mut writer = TestWriter::new();
2335 handle(
2336 &protocol,
2337 &state,
2338 &RenderOptions::new("index.html", "/"),
2339 &mut writer,
2340 )
2341 .unwrap();
2342 assert_eq!(
2343 writer.get_content(),
2344 "<my-comp data-title=\"prefix a&b\"><span>prefix a&b</span></my-comp>"
2345 );
2346 }
2347
2348 #[test]
2349 fn test_component_complex_attr() {
2350 let mut fragments = HashMap::new();
2351 fragments.insert(
2352 "index.html".to_string(),
2353 FragmentList {
2354 fragments: vec![
2355 WebUIFragment::raw("<my-comp"),
2356 WebUIFragment {
2357 fragment: Some(web_ui_fragment::Fragment::Attribute(
2358 WebUIFragmentAttribute {
2359 name: ":item".into(),
2360 value: "complexItem".into(),
2361 attr_start: true,
2362 complex: true,
2363 ..Default::default()
2364 },
2365 )),
2366 },
2367 WebUIFragment::raw(">"),
2368 WebUIFragment::component("my-comp"),
2369 WebUIFragment::raw("</my-comp>"),
2370 ],
2371 },
2372 );
2373 fragments.insert(
2374 "my-comp".to_string(),
2375 FragmentList {
2376 fragments: vec![
2377 WebUIFragment::raw("<span>"),
2378 WebUIFragment::signal("item.foo", false),
2379 WebUIFragment::raw("</span><p>"),
2380 WebUIFragment::signal("item.bar", false),
2381 WebUIFragment::raw("</p>"),
2382 ],
2383 },
2384 );
2385 let protocol = WebUIProtocol::new(fragments);
2386 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
2387 let mut writer = TestWriter::new();
2388 handle(
2389 &protocol,
2390 &state,
2391 &RenderOptions::new("index.html", "/"),
2392 &mut writer,
2393 )
2394 .unwrap();
2395 assert_eq!(
2396 writer.get_content(),
2397 "<my-comp><span>1</span><p>true</p></my-comp>"
2398 );
2399 }
2400
2401 #[test]
2402 fn test_component_no_parent_pollution() {
2403 let mut fragments = HashMap::new();
2404 fragments.insert(
2405 "index.html".to_string(),
2406 FragmentList {
2407 fragments: vec![
2408 WebUIFragment::raw("<parent"),
2409 WebUIFragment {
2410 fragment: Some(web_ui_fragment::Fragment::Attribute(
2411 WebUIFragmentAttribute {
2412 name: "var".into(),
2413 value: "var".into(),
2414 attr_start: true,
2415 ..Default::default()
2416 },
2417 )),
2418 },
2419 WebUIFragment::raw(">"),
2420 WebUIFragment::component("parent"),
2421 WebUIFragment::raw("</parent>"),
2422 ],
2423 },
2424 );
2425 fragments.insert(
2426 "parent".to_string(),
2427 FragmentList {
2428 fragments: vec![
2429 WebUIFragment::raw("Before: "),
2430 WebUIFragment::signal("var", false),
2431 WebUIFragment::raw("<child foo"),
2432 WebUIFragment {
2433 fragment: Some(web_ui_fragment::Fragment::Attribute(
2434 WebUIFragmentAttribute {
2435 name: "var".into(),
2436 value: "replaced".into(),
2437 raw_value: true,
2438 ..Default::default()
2439 },
2440 )),
2441 },
2442 WebUIFragment::raw(">"),
2443 WebUIFragment::component("child"),
2444 WebUIFragment::raw("Label</child>After: "),
2445 WebUIFragment::signal("var", false),
2446 ],
2447 },
2448 );
2449 fragments.insert("child".to_string(), FragmentList { fragments: vec![] });
2450 let protocol = WebUIProtocol::new(fragments);
2451 let state = test_json!({"var": "original"});
2452 let mut writer = TestWriter::new();
2453 handle(
2454 &protocol,
2455 &state,
2456 &RenderOptions::new("index.html", "/"),
2457 &mut writer,
2458 )
2459 .unwrap();
2460 assert_eq!(
2461 writer.get_content(),
2462 "<parent var=\"original\">Before: original<child foo var=\"replaced\">Label</child>After: original</parent>"
2463 );
2464 }
2465
2466 #[test]
2467 fn test_component_boolean_attr_state() {
2468 let mut fragments = HashMap::new();
2469 fragments.insert(
2470 "index.html".to_string(),
2471 FragmentList {
2472 fragments: vec![
2473 WebUIFragment::raw("<my-comp"),
2474 WebUIFragment {
2475 fragment: Some(web_ui_fragment::Fragment::Attribute(
2476 WebUIFragmentAttribute {
2477 name: "disabled".into(),
2478 attr_start: true,
2479 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
2480 ..Default::default()
2481 },
2482 )),
2483 },
2484 WebUIFragment::raw(">"),
2485 WebUIFragment::component("my-comp"),
2486 WebUIFragment::raw("</my-comp>"),
2487 ],
2488 },
2489 );
2490 fragments.insert(
2491 "my-comp".to_string(),
2492 FragmentList {
2493 fragments: vec![WebUIFragment::if_cond(
2494 ConditionExpr::identifier("disabled"),
2495 "show",
2496 )],
2497 },
2498 );
2499 fragments.insert(
2500 "show".to_string(),
2501 FragmentList {
2502 fragments: vec![WebUIFragment::raw("disabled!")],
2503 },
2504 );
2505 let protocol = WebUIProtocol::new(fragments);
2506 let state = test_json!({"isDisabled": true});
2507 let mut writer = TestWriter::new();
2508 handle(
2509 &protocol,
2510 &state,
2511 &RenderOptions::new("index.html", "/"),
2512 &mut writer,
2513 )
2514 .unwrap();
2515 assert_eq!(
2516 writer.get_content(),
2517 "<my-comp disabled>disabled!</my-comp>"
2518 );
2519 }
2520
2521 fn render_signal(value: &str) -> String {
2525 let mut fragments = HashMap::new();
2526 fragments.insert(
2527 "index.html".to_string(),
2528 FragmentList {
2529 fragments: vec![WebUIFragment::signal("v", false)],
2530 },
2531 );
2532 let protocol = WebUIProtocol::new(fragments);
2533 let state = test_json!({"v": value});
2534 let mut writer = TestWriter::new();
2535 handle(
2536 &protocol,
2537 &state,
2538 &RenderOptions::new("index.html", "/"),
2539 &mut writer,
2540 )
2541 .unwrap();
2542 writer.get_content()
2543 }
2544
2545 #[test]
2546 fn test_escape_ampersand() {
2547 assert_eq!(render_signal("&"), "&");
2548 }
2549
2550 #[test]
2551 fn test_escape_less_than() {
2552 assert_eq!(render_signal("<"), "<");
2553 }
2554
2555 #[test]
2556 fn test_escape_greater_than() {
2557 assert_eq!(render_signal(">"), ">");
2558 }
2559
2560 #[test]
2561 fn test_escape_double_quote() {
2562 assert_eq!(render_signal("\""), """);
2563 }
2564
2565 #[test]
2566 fn test_escape_single_quote() {
2567 let result = render_signal("'");
2569 assert_eq!(
2570 result, "'",
2571 "Expected ' for single quote, got: {result}"
2572 );
2573 }
2574
2575 #[test]
2576 fn test_escape_multiple_special_chars() {
2577 let result = render_signal("<script>alert('xss');</script>");
2578 assert!(
2579 result.contains("<") && result.contains(">"),
2580 "Expected escaped HTML, got: {}",
2581 result
2582 );
2583 assert!(
2584 !result.contains("<script>"),
2585 "Should not contain raw <script> tag"
2586 );
2587 }
2588
2589 #[test]
2590 fn test_escape_no_special_chars() {
2591 assert_eq!(render_signal("Hello World"), "Hello World");
2592 }
2593
2594 #[test]
2595 fn test_escape_empty_string() {
2596 assert_eq!(render_signal(""), "");
2597 }
2598
2599 #[test]
2600 fn test_escape_special_at_beginning() {
2601 let result = render_signal("<Hello");
2602 assert!(
2603 result.starts_with("<"),
2604 "Expected < at start, got: {}",
2605 result
2606 );
2607 }
2608
2609 #[test]
2610 fn test_escape_special_at_end() {
2611 let result = render_signal("Hello>");
2612 assert!(
2613 result.ends_with(">"),
2614 "Expected > at end, got: {}",
2615 result
2616 );
2617 }
2618
2619 #[test]
2620 fn test_escape_special_in_middle() {
2621 let result = render_signal("Hel&lo");
2622 assert!(
2623 result.contains("&"),
2624 "Expected & in middle, got: {}",
2625 result
2626 );
2627 }
2628
2629 #[test]
2632 fn test_boolean_attr_truthy_values() {
2633 {
2635 let mut fragments = HashMap::new();
2636 fragments.insert(
2637 "index.html".to_string(),
2638 FragmentList {
2639 fragments: vec![
2640 WebUIFragment::raw("<input"),
2641 WebUIFragment::attribute_boolean(
2642 "checked",
2643 ConditionExpr::identifier("checked"),
2644 ),
2645 WebUIFragment::raw(">"),
2646 ],
2647 },
2648 );
2649 let protocol = WebUIProtocol::new(fragments);
2650 let state = test_json!({"checked": 1});
2651 let mut writer = TestWriter::new();
2652 handle(
2653 &protocol,
2654 &state,
2655 &RenderOptions::new("index.html", "/"),
2656 &mut writer,
2657 )
2658 .unwrap();
2659 assert_eq!(writer.get_content(), "<input checked>");
2660 }
2661 {
2663 let mut fragments = HashMap::new();
2664 fragments.insert(
2665 "index.html".to_string(),
2666 FragmentList {
2667 fragments: vec![
2668 WebUIFragment::raw("<input"),
2669 WebUIFragment::attribute_boolean(
2670 "checked",
2671 ConditionExpr::identifier("checked"),
2672 ),
2673 WebUIFragment::raw(">"),
2674 ],
2675 },
2676 );
2677 let protocol = WebUIProtocol::new(fragments);
2678 let state = test_json!({"checked": "yes"});
2679 let mut writer = TestWriter::new();
2680 handle(
2681 &protocol,
2682 &state,
2683 &RenderOptions::new("index.html", "/"),
2684 &mut writer,
2685 )
2686 .unwrap();
2687 assert_eq!(writer.get_content(), "<input checked>");
2688 }
2689 {
2691 let mut fragments = HashMap::new();
2692 fragments.insert(
2693 "index.html".to_string(),
2694 FragmentList {
2695 fragments: vec![
2696 WebUIFragment::raw("<input"),
2697 WebUIFragment::attribute_boolean(
2698 "checked",
2699 ConditionExpr::identifier("checked"),
2700 ),
2701 WebUIFragment::raw(">"),
2702 ],
2703 },
2704 );
2705 let protocol = WebUIProtocol::new(fragments);
2706 let state = test_json!({"checked": {}});
2707 let mut writer = TestWriter::new();
2708 handle(
2709 &protocol,
2710 &state,
2711 &RenderOptions::new("index.html", "/"),
2712 &mut writer,
2713 )
2714 .unwrap();
2715 assert_eq!(writer.get_content(), "<input>");
2717 }
2718 {
2720 let mut fragments = HashMap::new();
2721 fragments.insert(
2722 "index.html".to_string(),
2723 FragmentList {
2724 fragments: vec![
2725 WebUIFragment::raw("<input"),
2726 WebUIFragment::attribute_boolean(
2727 "checked",
2728 ConditionExpr::identifier("checked"),
2729 ),
2730 WebUIFragment::raw(">"),
2731 ],
2732 },
2733 );
2734 let protocol = WebUIProtocol::new(fragments);
2735 let state = test_json!({"checked": "false"});
2736 let mut writer = TestWriter::new();
2737 handle(
2738 &protocol,
2739 &state,
2740 &RenderOptions::new("index.html", "/"),
2741 &mut writer,
2742 )
2743 .unwrap();
2744 assert_eq!(writer.get_content(), "<input checked>");
2745 }
2746 }
2747
2748 #[test]
2749 fn test_boolean_attr_falsy_values() {
2750 {
2752 let mut fragments = HashMap::new();
2753 fragments.insert(
2754 "index.html".to_string(),
2755 FragmentList {
2756 fragments: vec![
2757 WebUIFragment::raw("<input"),
2758 WebUIFragment::attribute_boolean(
2759 "checked",
2760 ConditionExpr::identifier("checked"),
2761 ),
2762 WebUIFragment::raw(">"),
2763 ],
2764 },
2765 );
2766 let protocol = WebUIProtocol::new(fragments);
2767 let state = test_json!({"checked": 0});
2768 let mut writer = TestWriter::new();
2769 handle(
2770 &protocol,
2771 &state,
2772 &RenderOptions::new("index.html", "/"),
2773 &mut writer,
2774 )
2775 .unwrap();
2776 assert_eq!(writer.get_content(), "<input>");
2777 }
2778 {
2780 let mut fragments = HashMap::new();
2781 fragments.insert(
2782 "index.html".to_string(),
2783 FragmentList {
2784 fragments: vec![
2785 WebUIFragment::raw("<input"),
2786 WebUIFragment::attribute_boolean(
2787 "checked",
2788 ConditionExpr::identifier("checked"),
2789 ),
2790 WebUIFragment::raw(">"),
2791 ],
2792 },
2793 );
2794 let protocol = WebUIProtocol::new(fragments);
2795 let state = test_json!({"checked": ""});
2796 let mut writer = TestWriter::new();
2797 handle(
2798 &protocol,
2799 &state,
2800 &RenderOptions::new("index.html", "/"),
2801 &mut writer,
2802 )
2803 .unwrap();
2804 assert_eq!(writer.get_content(), "<input>");
2805 }
2806 {
2808 let mut fragments = HashMap::new();
2809 fragments.insert(
2810 "index.html".to_string(),
2811 FragmentList {
2812 fragments: vec![
2813 WebUIFragment::raw("<input"),
2814 WebUIFragment::attribute_boolean(
2815 "checked",
2816 ConditionExpr::identifier("checked"),
2817 ),
2818 WebUIFragment::raw(">"),
2819 ],
2820 },
2821 );
2822 let protocol = WebUIProtocol::new(fragments);
2823 let state = test_json!({"checked": false});
2824 let mut writer = TestWriter::new();
2825 handle(
2826 &protocol,
2827 &state,
2828 &RenderOptions::new("index.html", "/"),
2829 &mut writer,
2830 )
2831 .unwrap();
2832 assert_eq!(writer.get_content(), "<input>");
2833 }
2834 {
2836 let mut fragments = HashMap::new();
2837 fragments.insert(
2838 "index.html".to_string(),
2839 FragmentList {
2840 fragments: vec![
2841 WebUIFragment::raw("<input"),
2842 WebUIFragment::attribute_boolean(
2843 "checked",
2844 ConditionExpr::identifier("checked"),
2845 ),
2846 WebUIFragment::raw(">"),
2847 ],
2848 },
2849 );
2850 let protocol = WebUIProtocol::new(fragments);
2851 let state = test_json!({});
2852 let mut writer = TestWriter::new();
2853 handle(
2854 &protocol,
2855 &state,
2856 &RenderOptions::new("index.html", "/"),
2857 &mut writer,
2858 )
2859 .unwrap();
2860 assert_eq!(writer.get_content(), "<input>");
2861 }
2862 }
2863
2864 #[test]
2865 fn test_boolean_attr_expression_true() {
2866 let mut fragments = HashMap::new();
2867 fragments.insert(
2868 "index.html".to_string(),
2869 FragmentList {
2870 fragments: vec![
2871 WebUIFragment::raw("<button"),
2872 WebUIFragment::attribute_boolean(
2873 "disabled",
2874 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
2875 ),
2876 WebUIFragment::raw(">Click</button>"),
2877 ],
2878 },
2879 );
2880 let protocol = WebUIProtocol::new(fragments);
2881 let state = test_json!({"itemCount": 5});
2882 let mut writer = TestWriter::new();
2883 handle(
2884 &protocol,
2885 &state,
2886 &RenderOptions::new("index.html", "/"),
2887 &mut writer,
2888 )
2889 .unwrap();
2890 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
2891 }
2892
2893 #[test]
2894 fn test_boolean_attr_expression_false() {
2895 let mut fragments = HashMap::new();
2896 fragments.insert(
2897 "index.html".to_string(),
2898 FragmentList {
2899 fragments: vec![
2900 WebUIFragment::raw("<button"),
2901 WebUIFragment::attribute_boolean(
2902 "disabled",
2903 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
2904 ),
2905 WebUIFragment::raw(">Click</button>"),
2906 ],
2907 },
2908 );
2909 let protocol = WebUIProtocol::new(fragments);
2910 let state = test_json!({"itemCount": 3});
2911 let mut writer = TestWriter::new();
2912 handle(
2913 &protocol,
2914 &state,
2915 &RenderOptions::new("index.html", "/"),
2916 &mut writer,
2917 )
2918 .unwrap();
2919 assert_eq!(writer.get_content(), "<button>Click</button>");
2920 }
2921
2922 #[test]
2925 fn test_nested_component_attr_capture() {
2926 let mut fragments = HashMap::new();
2927 fragments.insert(
2928 "index.html".to_string(),
2929 FragmentList {
2930 fragments: vec![
2931 WebUIFragment::raw("<parent-component"),
2932 WebUIFragment {
2933 fragment: Some(web_ui_fragment::Fragment::Attribute(
2934 WebUIFragmentAttribute {
2935 name: "title".into(),
2936 template: "parent-title".into(),
2937 attr_start: true,
2938 ..Default::default()
2939 },
2940 )),
2941 },
2942 WebUIFragment::raw(">"),
2943 WebUIFragment::component("parent-component"),
2944 WebUIFragment::raw("</parent-component>"),
2945 ],
2946 },
2947 );
2948 fragments.insert(
2949 "parent-title".to_string(),
2950 FragmentList {
2951 fragments: vec![
2952 WebUIFragment::raw("Hello "),
2953 WebUIFragment::signal("who", false),
2954 ],
2955 },
2956 );
2957 fragments.insert(
2958 "parent-component".to_string(),
2959 FragmentList {
2960 fragments: vec![
2961 WebUIFragment::raw("<child-component"),
2962 WebUIFragment {
2963 fragment: Some(web_ui_fragment::Fragment::Attribute(
2964 WebUIFragmentAttribute {
2965 name: "title".into(),
2966 template: "child-title".into(),
2967 attr_start: true,
2968 ..Default::default()
2969 },
2970 )),
2971 },
2972 WebUIFragment::raw(">"),
2973 WebUIFragment::component("child-component"),
2974 WebUIFragment::raw("</child-component>"),
2975 ],
2976 },
2977 );
2978 fragments.insert(
2979 "child-title".to_string(),
2980 FragmentList {
2981 fragments: vec![
2982 WebUIFragment::raw("Child of "),
2983 WebUIFragment::signal("title", false),
2984 ],
2985 },
2986 );
2987 fragments.insert(
2988 "child-component".to_string(),
2989 FragmentList {
2990 fragments: vec![
2991 WebUIFragment::raw("<span>"),
2992 WebUIFragment::signal("title", false),
2993 WebUIFragment::raw("</span>"),
2994 ],
2995 },
2996 );
2997 let protocol = WebUIProtocol::new(fragments);
2998 let state = test_json!({"who": "<world>"});
2999 let mut writer = TestWriter::new();
3000 handle(
3001 &protocol,
3002 &state,
3003 &RenderOptions::new("index.html", "/"),
3004 &mut writer,
3005 )
3006 .unwrap();
3007 assert_eq!(
3008 writer.get_content(),
3009 "<parent-component title=\"Hello <world>\"><child-component title=\"Child of Hello <world>\"><span>Child of Hello <world></span></child-component></parent-component>"
3010 );
3011 }
3012
3013 #[test]
3014 fn test_grandchild_attr_propagation() {
3015 let mut fragments = HashMap::new();
3016 fragments.insert(
3017 "index.html".to_string(),
3018 FragmentList {
3019 fragments: vec![
3020 WebUIFragment::raw("<parent-component"),
3021 WebUIFragment {
3022 fragment: Some(web_ui_fragment::Fragment::Attribute(
3023 WebUIFragmentAttribute {
3024 name: "title".into(),
3025 template: "p-title".into(),
3026 attr_start: true,
3027 ..Default::default()
3028 },
3029 )),
3030 },
3031 WebUIFragment::raw(">"),
3032 WebUIFragment::component("parent-component"),
3033 WebUIFragment::raw("</parent-component>"),
3034 ],
3035 },
3036 );
3037 fragments.insert(
3038 "p-title".to_string(),
3039 FragmentList {
3040 fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
3041 },
3042 );
3043 fragments.insert(
3044 "parent-component".to_string(),
3045 FragmentList {
3046 fragments: vec![
3047 WebUIFragment::raw("<child-component"),
3048 WebUIFragment {
3049 fragment: Some(web_ui_fragment::Fragment::Attribute(
3050 WebUIFragmentAttribute {
3051 name: "title".into(),
3052 template: "c-title".into(),
3053 attr_start: true,
3054 ..Default::default()
3055 },
3056 )),
3057 },
3058 WebUIFragment::raw(">"),
3059 WebUIFragment::component("child-component"),
3060 WebUIFragment::raw("</child-component>"),
3061 ],
3062 },
3063 );
3064 fragments.insert(
3065 "c-title".to_string(),
3066 FragmentList {
3067 fragments: vec![
3068 WebUIFragment::raw("C("),
3069 WebUIFragment::signal("title", false),
3070 WebUIFragment::raw(")-"),
3071 WebUIFragment::signal("cExtra", false),
3072 ],
3073 },
3074 );
3075 fragments.insert(
3076 "child-component".to_string(),
3077 FragmentList {
3078 fragments: vec![
3079 WebUIFragment::raw("<grandchild-component"),
3080 WebUIFragment {
3081 fragment: Some(web_ui_fragment::Fragment::Attribute(
3082 WebUIFragmentAttribute {
3083 name: "title".into(),
3084 value: "title".into(),
3085 attr_start: true,
3086 ..Default::default()
3087 },
3088 )),
3089 },
3090 WebUIFragment::raw(">"),
3091 WebUIFragment::component("grandchild-component"),
3092 WebUIFragment::raw("</grandchild-component>"),
3093 ],
3094 },
3095 );
3096 fragments.insert(
3097 "grandchild-component".to_string(),
3098 FragmentList {
3099 fragments: vec![
3100 WebUIFragment::raw("<span>"),
3101 WebUIFragment::signal("title", false),
3102 WebUIFragment::raw("</span>"),
3103 ],
3104 },
3105 );
3106 let protocol = WebUIProtocol::new(fragments);
3107 let state = test_json!({"p": "<p>", "cExtra": "x&y"});
3108 let mut writer = TestWriter::new();
3109 handle(
3110 &protocol,
3111 &state,
3112 &RenderOptions::new("index.html", "/"),
3113 &mut writer,
3114 )
3115 .unwrap();
3116 assert_eq!(
3117 writer.get_content(),
3118 "<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>"
3119 );
3120 }
3121
3122 #[test]
3123 fn test_for_loop_component_attr() {
3124 let mut fragments = HashMap::new();
3125 fragments.insert(
3126 "index.html".to_string(),
3127 FragmentList {
3128 fragments: vec![
3129 WebUIFragment::raw("<parent-component"),
3130 WebUIFragment {
3131 fragment: Some(web_ui_fragment::Fragment::Attribute(
3132 WebUIFragmentAttribute {
3133 name: "title".into(),
3134 template: "parent-title-loop".into(),
3135 attr_start: true,
3136 ..Default::default()
3137 },
3138 )),
3139 },
3140 WebUIFragment::raw(">"),
3141 WebUIFragment::component("parent-component"),
3142 WebUIFragment::raw("</parent-component>"),
3143 ],
3144 },
3145 );
3146 fragments.insert(
3147 "parent-title-loop".to_string(),
3148 FragmentList {
3149 fragments: vec![
3150 WebUIFragment::raw("Parent:"),
3151 WebUIFragment::signal("who", false),
3152 ],
3153 },
3154 );
3155 fragments.insert(
3156 "parent-component".to_string(),
3157 FragmentList {
3158 fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
3159 },
3160 );
3161 fragments.insert(
3162 "child-loop".to_string(),
3163 FragmentList {
3164 fragments: vec![
3165 WebUIFragment::raw("<child-component"),
3166 WebUIFragment {
3167 fragment: Some(web_ui_fragment::Fragment::Attribute(
3168 WebUIFragmentAttribute {
3169 name: "title".into(),
3170 template: "child-title-loop".into(),
3171 attr_start: true,
3172 ..Default::default()
3173 },
3174 )),
3175 },
3176 WebUIFragment::raw(">"),
3177 WebUIFragment::component("child-component"),
3178 WebUIFragment::raw("</child-component>"),
3179 ],
3180 },
3181 );
3182 fragments.insert(
3183 "child-title-loop".to_string(),
3184 FragmentList {
3185 fragments: vec![
3186 WebUIFragment::raw("Hi "),
3187 WebUIFragment::signal("item.name", false),
3188 WebUIFragment::raw(" / "),
3189 WebUIFragment::signal("title", false),
3190 ],
3191 },
3192 );
3193 fragments.insert(
3194 "child-component".to_string(),
3195 FragmentList {
3196 fragments: vec![
3197 WebUIFragment::raw("<span>"),
3198 WebUIFragment::signal("title", false),
3199 WebUIFragment::raw("</span>"),
3200 ],
3201 },
3202 );
3203 let protocol = WebUIProtocol::new(fragments);
3204 let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
3205 let mut writer = TestWriter::new();
3206 handle(
3207 &protocol,
3208 &state,
3209 &RenderOptions::new("index.html", "/"),
3210 &mut writer,
3211 )
3212 .unwrap();
3213 assert_eq!(
3214 writer.get_content(),
3215 "<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>"
3216 );
3217 }
3218
3219 #[test]
3220 fn test_multiple_template_attrs() {
3221 let mut fragments = HashMap::new();
3222 fragments.insert(
3223 "index.html".to_string(),
3224 FragmentList {
3225 fragments: vec![
3226 WebUIFragment::raw("<my-component"),
3227 WebUIFragment {
3228 fragment: Some(web_ui_fragment::Fragment::Attribute(
3229 WebUIFragmentAttribute {
3230 name: "title".into(),
3231 template: "attr-title".into(),
3232 attr_start: true,
3233 ..Default::default()
3234 },
3235 )),
3236 },
3237 WebUIFragment {
3238 fragment: Some(web_ui_fragment::Fragment::Attribute(
3239 WebUIFragmentAttribute {
3240 name: "data-title".into(),
3241 template: "attr-data-title".into(),
3242 attr_start: false,
3243 ..Default::default()
3244 },
3245 )),
3246 },
3247 WebUIFragment {
3248 fragment: Some(web_ui_fragment::Fragment::Attribute(
3249 WebUIFragmentAttribute {
3250 name: "aria-label".into(),
3251 template: "attr-aria-label".into(),
3252 attr_start: false,
3253 ..Default::default()
3254 },
3255 )),
3256 },
3257 WebUIFragment::raw(">"),
3258 WebUIFragment::component("my-component"),
3259 WebUIFragment::raw("</my-component>"),
3260 ],
3261 },
3262 );
3263 fragments.insert(
3264 "attr-title".to_string(),
3265 FragmentList {
3266 fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
3267 },
3268 );
3269 fragments.insert(
3270 "attr-data-title".to_string(),
3271 FragmentList {
3272 fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
3273 },
3274 );
3275 fragments.insert(
3276 "attr-aria-label".to_string(),
3277 FragmentList {
3278 fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
3279 },
3280 );
3281 fragments.insert(
3282 "my-component".to_string(),
3283 FragmentList {
3284 fragments: vec![
3285 WebUIFragment::raw("<span>"),
3286 WebUIFragment::signal("title", false),
3287 WebUIFragment::raw("|"),
3288 WebUIFragment::signal("dataTitle", false),
3289 WebUIFragment::raw("|"),
3290 WebUIFragment::signal("ariaLabel", false),
3291 WebUIFragment::raw("</span>"),
3292 ],
3293 },
3294 );
3295 let protocol = WebUIProtocol::new(fragments);
3296 let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
3297 let mut writer = TestWriter::new();
3298 handle(
3299 &protocol,
3300 &state,
3301 &RenderOptions::new("index.html", "/"),
3302 &mut writer,
3303 )
3304 .unwrap();
3305 assert_eq!(
3306 writer.get_content(),
3307 "<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>"
3308 );
3309 }
3310
3311 #[test]
3312 fn test_attr_priority_over_global() {
3313 let mut fragments = HashMap::new();
3314 fragments.insert(
3315 "index.html".to_string(),
3316 FragmentList {
3317 fragments: vec![
3318 WebUIFragment::raw("<my-component"),
3319 WebUIFragment {
3320 fragment: Some(web_ui_fragment::Fragment::Attribute(
3321 WebUIFragmentAttribute {
3322 name: "title".into(),
3323 value: "Attribute Title".into(),
3324 raw_value: true,
3325 attr_start: true,
3326 ..Default::default()
3327 },
3328 )),
3329 },
3330 WebUIFragment::raw(">"),
3331 WebUIFragment::component("my-component"),
3332 WebUIFragment::raw("</my-component>"),
3333 ],
3334 },
3335 );
3336 fragments.insert(
3337 "my-component".to_string(),
3338 FragmentList {
3339 fragments: vec![
3340 WebUIFragment::raw("<span>"),
3341 WebUIFragment::signal("title", false),
3342 WebUIFragment::raw("</span>"),
3343 ],
3344 },
3345 );
3346 let protocol = WebUIProtocol::new(fragments);
3347 let state = test_json!({"title": "Global Title"});
3348 let mut writer = TestWriter::new();
3349 handle(
3350 &protocol,
3351 &state,
3352 &RenderOptions::new("index.html", "/"),
3353 &mut writer,
3354 )
3355 .unwrap();
3356 assert_eq!(
3357 writer.get_content(),
3358 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
3359 );
3360 }
3361
3362 #[test]
3363 fn test_attr_priority_over_local_and_global() {
3364 let mut fragments = HashMap::new();
3365 fragments.insert(
3366 "index.html".to_string(),
3367 FragmentList {
3368 fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
3369 },
3370 );
3371 fragments.insert(
3372 "loop".to_string(),
3373 FragmentList {
3374 fragments: vec![
3375 WebUIFragment::raw("<my-component"),
3376 WebUIFragment {
3377 fragment: Some(web_ui_fragment::Fragment::Attribute(
3378 WebUIFragmentAttribute {
3379 name: "title".into(),
3380 value: "Attribute Title".into(),
3381 raw_value: true,
3382 attr_start: true,
3383 ..Default::default()
3384 },
3385 )),
3386 },
3387 WebUIFragment::raw(">"),
3388 WebUIFragment::component("my-component"),
3389 WebUIFragment::raw("</my-component>"),
3390 ],
3391 },
3392 );
3393 fragments.insert(
3394 "my-component".to_string(),
3395 FragmentList {
3396 fragments: vec![
3397 WebUIFragment::raw("<span>"),
3398 WebUIFragment::signal("title", false),
3399 WebUIFragment::raw("</span>"),
3400 ],
3401 },
3402 );
3403 let protocol = WebUIProtocol::new(fragments);
3404 let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
3405 let mut writer = TestWriter::new();
3406 handle(
3407 &protocol,
3408 &state,
3409 &RenderOptions::new("index.html", "/"),
3410 &mut writer,
3411 )
3412 .unwrap();
3413 assert_eq!(
3414 writer.get_content(),
3415 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
3416 );
3417 }
3418
3419 #[test]
3420 fn test_boolean_attr_first_component_attr() {
3421 let mut fragments = HashMap::new();
3422 fragments.insert(
3423 "index.html".to_string(),
3424 FragmentList {
3425 fragments: vec![
3426 WebUIFragment::raw("<my-component"),
3427 WebUIFragment {
3428 fragment: Some(web_ui_fragment::Fragment::Attribute(
3429 WebUIFragmentAttribute {
3430 name: "disabled".into(),
3431 attr_start: true,
3432 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
3433 ..Default::default()
3434 },
3435 )),
3436 },
3437 WebUIFragment {
3438 fragment: Some(web_ui_fragment::Fragment::Attribute(
3439 WebUIFragmentAttribute {
3440 name: "label".into(),
3441 value: "Component Label".into(),
3442 raw_value: true,
3443 attr_start: false,
3444 ..Default::default()
3445 },
3446 )),
3447 },
3448 WebUIFragment::raw(">"),
3449 WebUIFragment::component("my-component"),
3450 WebUIFragment::raw("</my-component>"),
3451 ],
3452 },
3453 );
3454 fragments.insert(
3455 "my-component".to_string(),
3456 FragmentList {
3457 fragments: vec![
3458 WebUIFragment::if_cond(
3459 ConditionExpr::identifier("disabled"),
3460 "disabledTemplate",
3461 ),
3462 WebUIFragment::raw("<span>"),
3463 WebUIFragment::signal("label", false),
3464 WebUIFragment::raw("</span>"),
3465 ],
3466 },
3467 );
3468 fragments.insert(
3469 "disabledTemplate".to_string(),
3470 FragmentList {
3471 fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
3472 },
3473 );
3474 let protocol = WebUIProtocol::new(fragments);
3475 let state = test_json!({"isDisabled": true});
3476 let mut writer = TestWriter::new();
3477 handle(
3478 &protocol,
3479 &state,
3480 &RenderOptions::new("index.html", "/"),
3481 &mut writer,
3482 )
3483 .unwrap();
3484 assert_eq!(
3485 writer.get_content(),
3486 "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
3487 );
3488 }
3489
3490 #[test]
3491 fn test_hyphenated_attr_camelcase() {
3492 let mut fragments = HashMap::new();
3493 fragments.insert(
3494 "index.html".to_string(),
3495 FragmentList {
3496 fragments: vec![
3497 WebUIFragment::raw("<my-component"),
3498 WebUIFragment {
3499 fragment: Some(web_ui_fragment::Fragment::Attribute(
3500 WebUIFragmentAttribute {
3501 name: "key-hyphen".into(),
3502 value: "Local Value".into(),
3503 raw_value: true,
3504 attr_start: true,
3505 ..Default::default()
3506 },
3507 )),
3508 },
3509 WebUIFragment::raw(">"),
3510 WebUIFragment::component("my-component"),
3511 WebUIFragment::raw("</my-component>"),
3512 ],
3513 },
3514 );
3515 fragments.insert(
3516 "my-component".to_string(),
3517 FragmentList {
3518 fragments: vec![
3519 WebUIFragment::raw("<span>"),
3520 WebUIFragment::signal("keyHyphen", false),
3521 WebUIFragment::raw("</span>"),
3522 ],
3523 },
3524 );
3525 let protocol = WebUIProtocol::new(fragments);
3526 let state = test_json!({"keyHyphen": "Global Value"});
3527 let mut writer = TestWriter::new();
3528 handle(
3529 &protocol,
3530 &state,
3531 &RenderOptions::new("index.html", "/"),
3532 &mut writer,
3533 )
3534 .unwrap();
3535 assert_eq!(
3536 writer.get_content(),
3537 "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
3538 );
3539 }
3540
3541 #[test]
3542 fn test_skipped_component_attrs() {
3543 let mut fragments = HashMap::new();
3547 fragments.insert(
3548 "index.html".to_string(),
3549 FragmentList {
3550 fragments: vec![
3551 WebUIFragment::raw("<test-component"),
3552 WebUIFragment {
3554 fragment: Some(web_ui_fragment::Fragment::Attribute(
3555 WebUIFragmentAttribute {
3556 name: "class".into(),
3557 value: "skippedClass".into(),
3558 attr_start: true,
3559 attr_skip: true,
3560 ..Default::default()
3561 },
3562 )),
3563 },
3564 WebUIFragment {
3566 fragment: Some(web_ui_fragment::Fragment::Attribute(
3567 WebUIFragmentAttribute {
3568 name: "style".into(),
3569 value: "skippedStyle".into(),
3570 attr_skip: true,
3571 ..Default::default()
3572 },
3573 )),
3574 },
3575 WebUIFragment {
3577 fragment: Some(web_ui_fragment::Fragment::Attribute(
3578 WebUIFragmentAttribute {
3579 name: "role".into(),
3580 value: "skippedRole".into(),
3581 attr_skip: true,
3582 ..Default::default()
3583 },
3584 )),
3585 },
3586 WebUIFragment {
3588 fragment: Some(web_ui_fragment::Fragment::Attribute(
3589 WebUIFragmentAttribute {
3590 name: "data-testid".into(),
3591 value: "skippedDataTestid".into(),
3592 attr_skip: true,
3593 ..Default::default()
3594 },
3595 )),
3596 },
3597 WebUIFragment {
3599 fragment: Some(web_ui_fragment::Fragment::Attribute(
3600 WebUIFragmentAttribute {
3601 name: "aria-label".into(),
3602 value: "skippedAriaLabel".into(),
3603 attr_skip: true,
3604 ..Default::default()
3605 },
3606 )),
3607 },
3608 WebUIFragment {
3610 fragment: Some(web_ui_fragment::Fragment::Attribute(
3611 WebUIFragmentAttribute {
3612 name: "title".into(),
3613 value: "title".into(),
3614 ..Default::default()
3615 },
3616 )),
3617 },
3618 WebUIFragment::raw(">"),
3619 WebUIFragment::component("test-component"),
3620 WebUIFragment::raw("</test-component>"),
3621 ],
3622 },
3623 );
3624 fragments.insert(
3625 "test-component".to_string(),
3626 FragmentList {
3627 fragments: vec![
3628 WebUIFragment::raw("<span>"),
3629 WebUIFragment::signal("title", false),
3630 WebUIFragment::raw("-"),
3631 WebUIFragment::signal("class", false),
3632 WebUIFragment::raw("-"),
3633 WebUIFragment::signal("style", false),
3634 WebUIFragment::raw("-"),
3635 WebUIFragment::signal("role", false),
3636 WebUIFragment::raw("-"),
3637 WebUIFragment::signal("dataTestid", false),
3638 WebUIFragment::raw("-"),
3639 WebUIFragment::signal("ariaLabel", false),
3640 WebUIFragment::raw("</span>"),
3641 ],
3642 },
3643 );
3644 let protocol = WebUIProtocol::new(fragments);
3645 let state = test_json!({
3646 "title": "Hello",
3647 "skippedClass": "my-class",
3648 "skippedStyle": "color:red",
3649 "skippedRole": "button",
3650 "skippedDataTestid": "test-id",
3651 "skippedAriaLabel": "label-text"
3652 });
3653 let mut writer = TestWriter::new();
3654 handle(
3655 &protocol,
3656 &state,
3657 &RenderOptions::new("index.html", "/"),
3658 &mut writer,
3659 )
3660 .unwrap();
3661 assert_eq!(
3665 writer.get_content(),
3666 "<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>"
3667 );
3668 }
3669
3670 #[test]
3673 fn test_attr_inherit_parent_to_child() {
3674 let mut fragments = HashMap::new();
3675 fragments.insert(
3676 "index.html".to_string(),
3677 FragmentList {
3678 fragments: vec![
3679 WebUIFragment::raw("<parent-component"),
3680 WebUIFragment {
3681 fragment: Some(web_ui_fragment::Fragment::Attribute(
3682 WebUIFragmentAttribute {
3683 name: "title".into(),
3684 value: "Parent Title".into(),
3685 raw_value: true,
3686 attr_start: true,
3687 ..Default::default()
3688 },
3689 )),
3690 },
3691 WebUIFragment::raw(">"),
3692 WebUIFragment::component("parent-component"),
3693 WebUIFragment::raw("</parent-component>"),
3694 ],
3695 },
3696 );
3697 fragments.insert(
3698 "parent-component".to_string(),
3699 FragmentList {
3700 fragments: vec![
3701 WebUIFragment::raw("<h1>"),
3702 WebUIFragment::signal("title", false),
3703 WebUIFragment::raw("</h1><child-component"),
3704 WebUIFragment {
3705 fragment: Some(web_ui_fragment::Fragment::Attribute(
3706 WebUIFragmentAttribute {
3707 name: "title".into(),
3708 value: "title".into(),
3709 attr_start: true,
3710 ..Default::default()
3711 },
3712 )),
3713 },
3714 WebUIFragment::raw(">"),
3715 WebUIFragment::component("child-component"),
3716 WebUIFragment::raw("</child-component>"),
3717 ],
3718 },
3719 );
3720 fragments.insert(
3721 "child-component".to_string(),
3722 FragmentList {
3723 fragments: vec![
3724 WebUIFragment::raw("<h2>"),
3725 WebUIFragment::signal("title", false),
3726 WebUIFragment::raw("</h2>"),
3727 ],
3728 },
3729 );
3730 let protocol = WebUIProtocol::new(fragments);
3731 let state = test_json!({});
3732 let mut writer = TestWriter::new();
3733 handle(
3734 &protocol,
3735 &state,
3736 &RenderOptions::new("index.html", "/"),
3737 &mut writer,
3738 )
3739 .unwrap();
3740 assert_eq!(
3741 writer.get_content(),
3742 "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
3743 );
3744 }
3745
3746 #[test]
3747 fn test_attr_inherit_deep() {
3748 let mut fragments = HashMap::new();
3749 fragments.insert(
3750 "index.html".to_string(),
3751 FragmentList {
3752 fragments: vec![
3753 WebUIFragment::raw("<parent-component"),
3754 WebUIFragment {
3755 fragment: Some(web_ui_fragment::Fragment::Attribute(
3756 WebUIFragmentAttribute {
3757 name: "title".into(),
3758 value: "Parent Title".into(),
3759 raw_value: true,
3760 attr_start: true,
3761 ..Default::default()
3762 },
3763 )),
3764 },
3765 WebUIFragment::raw(">"),
3766 WebUIFragment::component("parent-component"),
3767 WebUIFragment::raw("</parent-component>"),
3768 ],
3769 },
3770 );
3771 fragments.insert(
3772 "parent-component".to_string(),
3773 FragmentList {
3774 fragments: vec![
3775 WebUIFragment::raw("<child-component"),
3776 WebUIFragment {
3777 fragment: Some(web_ui_fragment::Fragment::Attribute(
3778 WebUIFragmentAttribute {
3779 name: "title".into(),
3780 value: "Child Title".into(),
3781 raw_value: true,
3782 attr_start: true,
3783 ..Default::default()
3784 },
3785 )),
3786 },
3787 WebUIFragment::raw(">"),
3788 WebUIFragment::component("child-component"),
3789 WebUIFragment::raw("</child-component>"),
3790 ],
3791 },
3792 );
3793 fragments.insert(
3794 "child-component".to_string(),
3795 FragmentList {
3796 fragments: vec![
3797 WebUIFragment::raw("<grandchild-component"),
3798 WebUIFragment {
3799 fragment: Some(web_ui_fragment::Fragment::Attribute(
3800 WebUIFragmentAttribute {
3801 name: "title".into(),
3802 value: "title".into(),
3803 attr_start: true,
3804 ..Default::default()
3805 },
3806 )),
3807 },
3808 WebUIFragment::raw(">"),
3809 WebUIFragment::component("grandchild-component"),
3810 WebUIFragment::raw("</grandchild-component>"),
3811 ],
3812 },
3813 );
3814 fragments.insert(
3815 "grandchild-component".to_string(),
3816 FragmentList {
3817 fragments: vec![
3818 WebUIFragment::raw("<h3>"),
3819 WebUIFragment::signal("title", false),
3820 WebUIFragment::raw("</h3>"),
3821 ],
3822 },
3823 );
3824 let protocol = WebUIProtocol::new(fragments);
3825 let state = test_json!({});
3826 let mut writer = TestWriter::new();
3827 handle(
3828 &protocol,
3829 &state,
3830 &RenderOptions::new("index.html", "/"),
3831 &mut writer,
3832 )
3833 .unwrap();
3834 assert_eq!(
3835 writer.get_content(),
3836 "<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>"
3837 );
3838 }
3839
3840 #[test]
3841 fn test_complex_attr_access() {
3842 let mut fragments = HashMap::new();
3843 fragments.insert(
3844 "index.html".to_string(),
3845 FragmentList {
3846 fragments: vec![
3847 WebUIFragment::raw("<my-component"),
3848 WebUIFragment {
3849 fragment: Some(web_ui_fragment::Fragment::Attribute(
3850 WebUIFragmentAttribute {
3851 name: ":item".into(),
3852 value: "complexItem".into(),
3853 attr_start: true,
3854 complex: true,
3855 ..Default::default()
3856 },
3857 )),
3858 },
3859 WebUIFragment::raw(">"),
3860 WebUIFragment::component("my-component"),
3861 WebUIFragment::raw("</my-component>"),
3862 ],
3863 },
3864 );
3865 fragments.insert(
3866 "my-component".to_string(),
3867 FragmentList {
3868 fragments: vec![
3869 WebUIFragment::raw("<span>"),
3870 WebUIFragment::signal("item.foo", false),
3871 WebUIFragment::raw("</span><p>"),
3872 WebUIFragment::signal("item.bar", false),
3873 WebUIFragment::raw("</p>"),
3874 ],
3875 },
3876 );
3877 let protocol = WebUIProtocol::new(fragments);
3878 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
3879 let mut writer = TestWriter::new();
3880 handle(
3881 &protocol,
3882 &state,
3883 &RenderOptions::new("index.html", "/"),
3884 &mut writer,
3885 )
3886 .unwrap();
3887 assert_eq!(
3888 writer.get_content(),
3889 "<my-component><span>1</span><p>true</p></my-component>"
3890 );
3891 }
3892
3893 #[test]
3894 fn test_complex_attr_for_loop() {
3895 let mut fragments = HashMap::new();
3896 fragments.insert(
3897 "index.html".to_string(),
3898 FragmentList {
3899 fragments: vec![WebUIFragment::for_loop(
3900 "item",
3901 "list.items",
3902 "listTemplate",
3903 )],
3904 },
3905 );
3906 fragments.insert(
3907 "listTemplate".to_string(),
3908 FragmentList {
3909 fragments: vec![
3910 WebUIFragment {
3911 fragment: Some(web_ui_fragment::Fragment::Attribute(
3912 WebUIFragmentAttribute {
3913 name: ":item".into(),
3914 value: "item".into(),
3915 attr_start: true,
3916 complex: true,
3917 ..Default::default()
3918 },
3919 )),
3920 },
3921 WebUIFragment::component("item_component"),
3922 ],
3923 },
3924 );
3925 fragments.insert(
3926 "item_component".to_string(),
3927 FragmentList {
3928 fragments: vec![
3929 WebUIFragment::raw("<span>"),
3930 WebUIFragment::signal("item.name", false),
3931 WebUIFragment::raw("</span>"),
3932 ],
3933 },
3934 );
3935 let protocol = WebUIProtocol::new(fragments);
3936 let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
3937 let mut writer = TestWriter::new();
3938 handle(
3939 &protocol,
3940 &state,
3941 &RenderOptions::new("index.html", "/"),
3942 &mut writer,
3943 )
3944 .unwrap();
3945 assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
3946 }
3947
3948 #[test]
3949 fn test_complex_attr_nested_for() {
3950 let mut fragments = HashMap::new();
3951 fragments.insert(
3952 "index.html".to_string(),
3953 FragmentList {
3954 fragments: vec![WebUIFragment::for_loop(
3955 "outer",
3956 "data.outer",
3957 "outerTemplate",
3958 )],
3959 },
3960 );
3961 fragments.insert(
3962 "outerTemplate".to_string(),
3963 FragmentList {
3964 fragments: vec![WebUIFragment::for_loop(
3965 "middle",
3966 "outer.middle",
3967 "middleTemplate",
3968 )],
3969 },
3970 );
3971 fragments.insert(
3972 "middleTemplate".to_string(),
3973 FragmentList {
3974 fragments: vec![WebUIFragment::for_loop(
3975 "inner",
3976 "middle.inner",
3977 "innerTemplate",
3978 )],
3979 },
3980 );
3981 fragments.insert(
3982 "innerTemplate".to_string(),
3983 FragmentList {
3984 fragments: vec![
3985 WebUIFragment::raw("<card"),
3986 WebUIFragment {
3987 fragment: Some(web_ui_fragment::Fragment::Attribute(
3988 WebUIFragmentAttribute {
3989 name: ":outer".into(),
3990 value: "outer".into(),
3991 attr_start: true,
3992 complex: true,
3993 ..Default::default()
3994 },
3995 )),
3996 },
3997 WebUIFragment {
3998 fragment: Some(web_ui_fragment::Fragment::Attribute(
3999 WebUIFragmentAttribute {
4000 name: ":middle".into(),
4001 value: "middle".into(),
4002 attr_start: false,
4003 complex: true,
4004 ..Default::default()
4005 },
4006 )),
4007 },
4008 WebUIFragment {
4009 fragment: Some(web_ui_fragment::Fragment::Attribute(
4010 WebUIFragmentAttribute {
4011 name: ":inner".into(),
4012 value: "inner".into(),
4013 attr_start: false,
4014 complex: true,
4015 ..Default::default()
4016 },
4017 )),
4018 },
4019 WebUIFragment::raw(">"),
4020 WebUIFragment::component("card_component"),
4021 WebUIFragment::raw("</card>"),
4022 ],
4023 },
4024 );
4025 fragments.insert(
4026 "card_component".to_string(),
4027 FragmentList {
4028 fragments: vec![
4029 WebUIFragment::raw("<p>"),
4030 WebUIFragment::signal("outer.label", false),
4031 WebUIFragment::raw(" / "),
4032 WebUIFragment::signal("middle.label", false),
4033 WebUIFragment::raw(" / "),
4034 WebUIFragment::signal("inner.label", false),
4035 WebUIFragment::raw("</p>"),
4036 ],
4037 },
4038 );
4039 let protocol = WebUIProtocol::new(fragments);
4040 let state = test_json!({"data": {"outer": [
4041 {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
4042 {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
4043 ]}});
4044 let mut writer = TestWriter::new();
4045 handle(
4046 &protocol,
4047 &state,
4048 &RenderOptions::new("index.html", "/"),
4049 &mut writer,
4050 )
4051 .unwrap();
4052 assert_eq!(
4053 writer.get_content(),
4054 "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
4055 );
4056 }
4057
4058 #[test]
4061 fn test_bool_component_state_true() {
4062 let mut fragments = HashMap::new();
4063 fragments.insert(
4064 "index.html".to_string(),
4065 FragmentList {
4066 fragments: vec![
4067 WebUIFragment::raw("<my-component"),
4068 WebUIFragment {
4069 fragment: Some(web_ui_fragment::Fragment::Attribute(
4070 WebUIFragmentAttribute {
4071 name: "disabled".into(),
4072 attr_start: true,
4073 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4074 ..Default::default()
4075 },
4076 )),
4077 },
4078 WebUIFragment::raw(">"),
4079 WebUIFragment::component("my-component"),
4080 WebUIFragment::raw("</my-component>"),
4081 ],
4082 },
4083 );
4084 fragments.insert(
4085 "my-component".to_string(),
4086 FragmentList {
4087 fragments: vec![
4088 WebUIFragment::if_cond(
4089 ConditionExpr::identifier("disabled"),
4090 "disabledTemplate",
4091 ),
4092 WebUIFragment::if_cond(
4093 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
4094 "enabledTemplate",
4095 ),
4096 ],
4097 },
4098 );
4099 fragments.insert(
4100 "disabledTemplate".to_string(),
4101 FragmentList {
4102 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
4103 },
4104 );
4105 fragments.insert(
4106 "enabledTemplate".to_string(),
4107 FragmentList {
4108 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
4109 },
4110 );
4111 let protocol = WebUIProtocol::new(fragments);
4112 let state = test_json!({"isDisabled": true});
4113 let mut writer = TestWriter::new();
4114 handle(
4115 &protocol,
4116 &state,
4117 &RenderOptions::new("index.html", "/"),
4118 &mut writer,
4119 )
4120 .unwrap();
4121 assert_eq!(
4122 writer.get_content(),
4123 "<my-component disabled><span>Disabled</span></my-component>"
4124 );
4125 }
4126
4127 #[test]
4128 fn test_bool_component_state_false() {
4129 let mut fragments = HashMap::new();
4130 fragments.insert(
4131 "index.html".to_string(),
4132 FragmentList {
4133 fragments: vec![
4134 WebUIFragment::raw("<my-component"),
4135 WebUIFragment {
4136 fragment: Some(web_ui_fragment::Fragment::Attribute(
4137 WebUIFragmentAttribute {
4138 name: "disabled".into(),
4139 attr_start: true,
4140 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4141 ..Default::default()
4142 },
4143 )),
4144 },
4145 WebUIFragment::raw(">"),
4146 WebUIFragment::component("my-component"),
4147 WebUIFragment::raw("</my-component>"),
4148 ],
4149 },
4150 );
4151 fragments.insert(
4152 "my-component".to_string(),
4153 FragmentList {
4154 fragments: vec![
4155 WebUIFragment::if_cond(
4156 ConditionExpr::identifier("disabled"),
4157 "disabledTemplate",
4158 ),
4159 WebUIFragment::if_cond(
4160 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
4161 "enabledTemplate",
4162 ),
4163 ],
4164 },
4165 );
4166 fragments.insert(
4167 "disabledTemplate".to_string(),
4168 FragmentList {
4169 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
4170 },
4171 );
4172 fragments.insert(
4173 "enabledTemplate".to_string(),
4174 FragmentList {
4175 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
4176 },
4177 );
4178 let protocol = WebUIProtocol::new(fragments);
4179 let state = test_json!({"isDisabled": false});
4180 let mut writer = TestWriter::new();
4181 handle(
4182 &protocol,
4183 &state,
4184 &RenderOptions::new("index.html", "/"),
4185 &mut writer,
4186 )
4187 .unwrap();
4188 assert_eq!(
4189 writer.get_content(),
4190 "<my-component><span>Enabled</span></my-component>"
4191 );
4192 }
4193
4194 #[test]
4195 fn test_bool_component_state_forward() {
4196 let mut fragments = HashMap::new();
4197 fragments.insert(
4198 "index.html".to_string(),
4199 FragmentList {
4200 fragments: vec![
4201 WebUIFragment::raw("<parent-component"),
4202 WebUIFragment {
4203 fragment: Some(web_ui_fragment::Fragment::Attribute(
4204 WebUIFragmentAttribute {
4205 name: "disabled".into(),
4206 attr_start: true,
4207 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4208 ..Default::default()
4209 },
4210 )),
4211 },
4212 WebUIFragment::raw(">"),
4213 WebUIFragment::component("parent-component"),
4214 WebUIFragment::raw("</parent-component>"),
4215 ],
4216 },
4217 );
4218 fragments.insert(
4219 "parent-component".to_string(),
4220 FragmentList {
4221 fragments: vec![
4222 WebUIFragment::if_cond(
4223 ConditionExpr::identifier("disabled"),
4224 "parentDisabledTemplate",
4225 ),
4226 WebUIFragment::raw("<child-component"),
4227 WebUIFragment {
4228 fragment: Some(web_ui_fragment::Fragment::Attribute(
4229 WebUIFragmentAttribute {
4230 name: "disabled".into(),
4231 attr_start: true,
4232 condition_tree: Some(ConditionExpr::identifier("disabled")),
4233 ..Default::default()
4234 },
4235 )),
4236 },
4237 WebUIFragment::raw(">"),
4238 WebUIFragment::component("child-component"),
4239 WebUIFragment::raw("</child-component>"),
4240 ],
4241 },
4242 );
4243 fragments.insert(
4244 "parentDisabledTemplate".to_string(),
4245 FragmentList {
4246 fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
4247 },
4248 );
4249 fragments.insert(
4250 "child-component".to_string(),
4251 FragmentList {
4252 fragments: vec![
4253 WebUIFragment::if_cond(
4254 ConditionExpr::identifier("disabled"),
4255 "childDisabledTemplate",
4256 ),
4257 WebUIFragment::if_cond(
4258 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
4259 "childEnabledTemplate",
4260 ),
4261 ],
4262 },
4263 );
4264 fragments.insert(
4265 "childDisabledTemplate".to_string(),
4266 FragmentList {
4267 fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
4268 },
4269 );
4270 fragments.insert(
4271 "childEnabledTemplate".to_string(),
4272 FragmentList {
4273 fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
4274 },
4275 );
4276
4277 {
4279 let protocol = WebUIProtocol::new(fragments.clone());
4280 let state = test_json!({"isDisabled": true});
4281 let mut writer = TestWriter::new();
4282 handle(
4283 &protocol,
4284 &state,
4285 &RenderOptions::new("index.html", "/"),
4286 &mut writer,
4287 )
4288 .unwrap();
4289 assert_eq!(
4290 writer.get_content(),
4291 "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
4292 );
4293 }
4294
4295 {
4297 let protocol = WebUIProtocol::new(fragments.clone());
4298 let state = test_json!({"isDisabled": false});
4299 let mut writer = TestWriter::new();
4300 handle(
4301 &protocol,
4302 &state,
4303 &RenderOptions::new("index.html", "/"),
4304 &mut writer,
4305 )
4306 .unwrap();
4307 assert_eq!(
4308 writer.get_content(),
4309 "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
4310 );
4311 }
4312 }
4313
4314 #[test]
4321 fn test_component_with_template() {
4322 let mut fragments = HashMap::new();
4323 fragments.insert(
4324 "index.html".to_string(),
4325 FragmentList {
4326 fragments: vec![
4327 WebUIFragment::raw("<custom-element>"),
4328 WebUIFragment::component("custom-element"),
4329 WebUIFragment::raw("</custom-element>"),
4330 ],
4331 },
4332 );
4333 fragments.insert(
4334 "custom-element".to_string(),
4335 FragmentList {
4336 fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
4337 },
4338 );
4339 let protocol = WebUIProtocol::new(fragments);
4340 let state = test_json!({});
4341 let mut writer = TestWriter::new();
4342 handle(
4343 &protocol,
4344 &state,
4345 &RenderOptions::new("index.html", "/"),
4346 &mut writer,
4347 )
4348 .unwrap();
4349 assert_eq!(
4350 writer.get_content(),
4351 "<custom-element><div>Custom Element</div></custom-element>"
4352 );
4353 assert!(writer.is_ended());
4354 }
4355
4356 #[test]
4357 fn test_component_with_slots() {
4358 let mut fragments = HashMap::new();
4359 fragments.insert(
4360 "index.html".to_string(),
4361 FragmentList {
4362 fragments: vec![
4363 WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
4364 WebUIFragment::component("custom-element"),
4365 WebUIFragment::raw("Hello World</custom-element>"),
4366 ],
4367 },
4368 );
4369 fragments.insert(
4370 "custom-element".to_string(),
4371 FragmentList {
4372 fragments: vec![WebUIFragment::raw("<slot></slot>")],
4373 },
4374 );
4375 let protocol = WebUIProtocol::new(fragments);
4376 let state = test_json!({});
4377 let mut writer = TestWriter::new();
4378 handle(
4379 &protocol,
4380 &state,
4381 &RenderOptions::new("index.html", "/"),
4382 &mut writer,
4383 )
4384 .unwrap();
4385 assert_eq!(
4386 writer.get_content(),
4387 "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
4388 );
4389 assert!(writer.is_ended());
4390 }
4391
4392 #[test]
4393 fn test_multiple_nested_components() {
4394 let mut fragments = HashMap::new();
4395 fragments.insert(
4396 "index.html".to_string(),
4397 FragmentList {
4398 fragments: vec![
4399 WebUIFragment::raw("<div>"),
4400 WebUIFragment::for_loop("item", "items", "templateRepeat"),
4401 WebUIFragment::raw("</div>"),
4402 ],
4403 },
4404 );
4405 fragments.insert(
4406 "custom-button".to_string(),
4407 FragmentList {
4408 fragments: vec![WebUIFragment::raw("<slot></slot>")],
4409 },
4410 );
4411 fragments.insert(
4412 "custom-element".to_string(),
4413 FragmentList {
4414 fragments: vec![
4415 WebUIFragment::raw("<custom-child>"),
4416 WebUIFragment::component("custom-child"),
4417 WebUIFragment::raw("</custom-child><slot></slot>"),
4418 ],
4419 },
4420 );
4421 fragments.insert(
4422 "custom-child".to_string(),
4423 FragmentList {
4424 fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
4425 },
4426 );
4427 fragments.insert(
4428 "templateRepeat".to_string(),
4429 FragmentList {
4430 fragments: vec![
4431 WebUIFragment::raw("<custom-element>"),
4432 WebUIFragment::component("custom-element"),
4433 WebUIFragment::raw("<custom-button>"),
4434 WebUIFragment::component("custom-button"),
4435 WebUIFragment::raw("Ok</custom-button></custom-element>"),
4436 ],
4437 },
4438 );
4439 let protocol = WebUIProtocol::new(fragments);
4440 let state = test_json!({"items": [{"name": "Item1"}]});
4441 let mut writer = TestWriter::new();
4442 handle(
4443 &protocol,
4444 &state,
4445 &RenderOptions::new("index.html", "/"),
4446 &mut writer,
4447 )
4448 .unwrap();
4449 assert_eq!(
4450 writer.get_content(),
4451 "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
4452 );
4453 assert!(writer.is_ended());
4454 }
4455
4456 #[test]
4459 fn test_if_with_binary_expression() {
4460 let mut fragments = HashMap::new();
4461 fragments.insert(
4462 "index.html".to_string(),
4463 FragmentList {
4464 fragments: vec![
4465 WebUIFragment::raw("<div>"),
4466 WebUIFragment::if_cond(
4467 ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
4468 "if-1",
4469 ),
4470 WebUIFragment::raw("</div>"),
4471 ],
4472 },
4473 );
4474 fragments.insert(
4475 "if-1".to_string(),
4476 FragmentList {
4477 fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
4478 },
4479 );
4480 let protocol = WebUIProtocol::new(fragments);
4481
4482 let state_true = test_json!({"x": 10});
4484 let mut writer_true = TestWriter::new();
4485 handle(
4486 &protocol,
4487 &state_true,
4488 &RenderOptions::new("index.html", "/"),
4489 &mut writer_true,
4490 )
4491 .unwrap();
4492 assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
4493
4494 let state_false = test_json!({"x": 1});
4496 let mut writer_false = TestWriter::new();
4497 handle(
4498 &protocol,
4499 &state_false,
4500 &RenderOptions::new("index.html", "/"),
4501 &mut writer_false,
4502 )
4503 .unwrap();
4504 assert_eq!(writer_false.get_content(), "<div></div>");
4505 }
4506
4507 #[test]
4508 fn test_for_if_overlapping_local_state() {
4509 let mut fragments = HashMap::new();
4510 fragments.insert(
4511 "index.html".to_string(),
4512 FragmentList {
4513 fragments: vec![
4514 WebUIFragment::raw("<div>"),
4515 WebUIFragment::for_loop("item", "items", "template1"),
4516 WebUIFragment::raw("</div>"),
4517 ],
4518 },
4519 );
4520 fragments.insert(
4521 "template1".to_string(),
4522 FragmentList {
4523 fragments: vec![
4524 WebUIFragment::raw("<div>"),
4525 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
4526 WebUIFragment::raw("</div>"),
4527 ],
4528 },
4529 );
4530 fragments.insert(
4531 "ifBlock".to_string(),
4532 FragmentList {
4533 fragments: vec![
4534 WebUIFragment::raw("<span>"),
4535 WebUIFragment::signal("item.label", false),
4536 WebUIFragment::raw("</span>"),
4537 ],
4538 },
4539 );
4540 let protocol = WebUIProtocol::new(fragments);
4541 let state = test_json!({
4542 "flag": false,
4543 "items": [
4544 {"label": "A", "flag": true},
4545 {"label": "B", "flag": false},
4546 {"label": "C", "flag": true}
4547 ]
4548 });
4549 let mut writer = TestWriter::new();
4550 handle(
4551 &protocol,
4552 &state,
4553 &RenderOptions::new("index.html", "/"),
4554 &mut writer,
4555 )
4556 .unwrap();
4557 assert_eq!(
4558 writer.get_content(),
4559 "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
4560 );
4561 }
4562
4563 #[test]
4564 fn test_for_if_global_flag_no_effect() {
4565 let mut fragments = HashMap::new();
4566 fragments.insert(
4567 "index.html".to_string(),
4568 FragmentList {
4569 fragments: vec![
4570 WebUIFragment::raw("<div>"),
4571 WebUIFragment::for_loop("item", "items", "template1"),
4572 WebUIFragment::raw("</div>"),
4573 ],
4574 },
4575 );
4576 fragments.insert(
4577 "template1".to_string(),
4578 FragmentList {
4579 fragments: vec![
4580 WebUIFragment::raw("<div>"),
4581 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
4582 WebUIFragment::raw("</div>"),
4583 ],
4584 },
4585 );
4586 fragments.insert(
4587 "ifBlock".to_string(),
4588 FragmentList {
4589 fragments: vec![
4590 WebUIFragment::raw("<span>"),
4591 WebUIFragment::signal("item.label", false),
4592 WebUIFragment::raw("</span>"),
4593 ],
4594 },
4595 );
4596 let protocol = WebUIProtocol::new(fragments);
4597 let state = test_json!({
4598 "item": {"flag": true},
4599 "items": [
4600 {"label": "A", "flag": false},
4601 {"label": "B", "flag": true},
4602 {"label": "C", "flag": false}
4603 ]
4604 });
4605 let mut writer = TestWriter::new();
4606 handle(
4607 &protocol,
4608 &state,
4609 &RenderOptions::new("index.html", "/"),
4610 &mut writer,
4611 )
4612 .unwrap();
4613 assert_eq!(
4614 writer.get_content(),
4615 "<div><div></div><div><span>B</span></div><div></div></div>"
4616 );
4617 }
4618
4619 #[test]
4622 fn test_recursive_template_refs() {
4623 let mut fragments = HashMap::new();
4624 fragments.insert(
4625 "index.html".to_string(),
4626 FragmentList {
4627 fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
4628 },
4629 );
4630 fragments.insert(
4631 "static".to_string(),
4632 FragmentList {
4633 fragments: vec![
4634 WebUIFragment::raw("<div expanded=\""),
4635 WebUIFragment::signal("item.expanded", false),
4636 WebUIFragment::raw("\" class=\""),
4637 WebUIFragment::signal("testScenario", false),
4638 WebUIFragment::raw("\"><span>"),
4639 WebUIFragment::signal("item.name", false),
4640 WebUIFragment::raw("</span>"),
4641 WebUIFragment::for_loop("item", "item.children", "static"),
4642 WebUIFragment::raw("</div>"),
4643 ],
4644 },
4645 );
4646 let protocol = WebUIProtocol::new(fragments);
4647 let state = test_json!({
4648 "testScenario": "RecursiveTemplatesWithGlobalState",
4649 "items": [
4650 {"name": "A", "expanded": "false", "children": []},
4651 {"name": "B", "expanded": "true", "children": [
4652 {"name": "C", "expanded": "false"},
4653 {"name": "D", "expanded": "false"}
4654 ]},
4655 {"name": "E", "expanded": "false"}
4656 ]
4657 });
4658 let mut writer = TestWriter::new();
4659 handle(
4660 &protocol,
4661 &state,
4662 &RenderOptions::new("index.html", "/"),
4663 &mut writer,
4664 )
4665 .unwrap();
4666 assert_eq!(
4667 writer.get_content(),
4668 "<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>"
4669 );
4670 }
4671
4672 #[test]
4675 fn test_component_in_for_no_local_access() {
4676 let mut fragments = HashMap::new();
4677 fragments.insert(
4678 "index.html".to_string(),
4679 FragmentList {
4680 fragments: vec![
4681 WebUIFragment::raw("<div>"),
4682 WebUIFragment::for_loop("item", "items", "templateComponent"),
4683 WebUIFragment::raw("</div>"),
4684 ],
4685 },
4686 );
4687 fragments.insert(
4688 "templateComponent".to_string(),
4689 FragmentList {
4690 fragments: vec![
4691 WebUIFragment::raw("<component-tag>"),
4692 WebUIFragment::component("my-component"),
4693 WebUIFragment::raw("</component-tag>"),
4694 ],
4695 },
4696 );
4697 fragments.insert(
4698 "my-component".to_string(),
4699 FragmentList {
4700 fragments: vec![
4701 WebUIFragment::raw("<span>"),
4702 WebUIFragment::signal("name", false),
4703 WebUIFragment::raw("</span>"),
4704 ],
4705 },
4706 );
4707 let protocol = WebUIProtocol::new(fragments);
4708 let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
4709 let mut writer = TestWriter::new();
4710 handle(
4711 &protocol,
4712 &state,
4713 &RenderOptions::new("index.html", "/"),
4714 &mut writer,
4715 )
4716 .unwrap();
4717 assert_eq!(
4718 writer.get_content(),
4719 "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
4720 );
4721 }
4722
4723 #[test]
4724 fn test_nested_for_hierarchical_state() {
4725 let mut fragments = HashMap::new();
4726 fragments.insert(
4727 "index.html".to_string(),
4728 FragmentList {
4729 fragments: vec![
4730 WebUIFragment::raw("<div>"),
4731 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
4732 WebUIFragment::raw("</div>"),
4733 ],
4734 },
4735 );
4736 fragments.insert(
4737 "outerTemplate".to_string(),
4738 FragmentList {
4739 fragments: vec![
4740 WebUIFragment::raw("<section>"),
4741 WebUIFragment::signal("globalPrefix", false),
4742 WebUIFragment::signal("outerItem.outerLabel", false),
4743 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
4744 WebUIFragment::raw("</section>"),
4745 ],
4746 },
4747 );
4748 fragments.insert(
4749 "innerTemplate".to_string(),
4750 FragmentList {
4751 fragments: vec![
4752 WebUIFragment::raw("<p>"),
4753 WebUIFragment::signal("globalPrefix", false),
4754 WebUIFragment::signal("outerItem.outerLabel", false),
4755 WebUIFragment::raw(": "),
4756 WebUIFragment::signal("innerItem.innerLabel", false),
4757 WebUIFragment::raw("</p>"),
4758 ],
4759 },
4760 );
4761 let protocol = WebUIProtocol::new(fragments);
4762 let state = test_json!({
4763 "globalPrefix": "Prefix: ",
4764 "outerItems": [
4765 {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
4766 {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
4767 ]
4768 });
4769 let mut writer = TestWriter::new();
4770 handle(
4771 &protocol,
4772 &state,
4773 &RenderOptions::new("index.html", "/"),
4774 &mut writer,
4775 )
4776 .unwrap();
4777 assert_eq!(
4778 writer.get_content(),
4779 "<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>"
4780 );
4781 }
4782
4783 #[test]
4784 fn test_component_in_for_global_only() {
4785 let mut fragments = HashMap::new();
4786 fragments.insert(
4787 "index.html".to_string(),
4788 FragmentList {
4789 fragments: vec![
4790 WebUIFragment::raw("<div>"),
4791 WebUIFragment::for_loop("item", "items", "templateComponent"),
4792 WebUIFragment::raw("</div>"),
4793 ],
4794 },
4795 );
4796 fragments.insert(
4797 "templateComponent".to_string(),
4798 FragmentList {
4799 fragments: vec![
4800 WebUIFragment::raw("<component-tag>"),
4801 WebUIFragment::component("my-component"),
4802 WebUIFragment::raw("</component-tag>"),
4803 ],
4804 },
4805 );
4806 fragments.insert(
4807 "my-component".to_string(),
4808 FragmentList {
4809 fragments: vec![
4810 WebUIFragment::raw("<span>"),
4811 WebUIFragment::signal("name", false),
4812 WebUIFragment::raw("-"),
4813 WebUIFragment::signal("globalSuffix", false),
4814 WebUIFragment::raw("</span>"),
4815 ],
4816 },
4817 );
4818 let protocol = WebUIProtocol::new(fragments);
4819 let state =
4820 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
4821 let mut writer = TestWriter::new();
4822 handle(
4823 &protocol,
4824 &state,
4825 &RenderOptions::new("index.html", "/"),
4826 &mut writer,
4827 )
4828 .unwrap();
4829 assert_eq!(
4830 writer.get_content(),
4831 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
4832 );
4833 }
4834
4835 #[test]
4836 fn test_component_no_item_moniker() {
4837 let mut fragments = HashMap::new();
4838 fragments.insert(
4839 "index.html".to_string(),
4840 FragmentList {
4841 fragments: vec![
4842 WebUIFragment::raw("<div>"),
4843 WebUIFragment::for_loop("item", "items", "templateComponent"),
4844 WebUIFragment::raw("</div>"),
4845 ],
4846 },
4847 );
4848 fragments.insert(
4849 "templateComponent".to_string(),
4850 FragmentList {
4851 fragments: vec![
4852 WebUIFragment::raw("<component-tag>"),
4853 WebUIFragment::component("my-component"),
4854 WebUIFragment::raw("</component-tag>"),
4855 ],
4856 },
4857 );
4858 fragments.insert(
4859 "my-component".to_string(),
4860 FragmentList {
4861 fragments: vec![
4862 WebUIFragment::raw("<span>"),
4863 WebUIFragment::signal("item.name", false),
4864 WebUIFragment::raw("-"),
4865 WebUIFragment::signal("globalSuffix", false),
4866 WebUIFragment::raw("</span>"),
4867 ],
4868 },
4869 );
4870 let protocol = WebUIProtocol::new(fragments);
4871 let state =
4872 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
4873 let mut writer = TestWriter::new();
4874 handle(
4875 &protocol,
4876 &state,
4877 &RenderOptions::new("index.html", "/"),
4878 &mut writer,
4879 )
4880 .unwrap();
4881 assert_eq!(
4882 writer.get_content(),
4883 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
4884 );
4885 }
4886
4887 #[test]
4888 fn test_for_nonqualified_uses_global() {
4889 let mut fragments = HashMap::new();
4890 fragments.insert(
4891 "index.html".to_string(),
4892 FragmentList {
4893 fragments: vec![
4894 WebUIFragment::raw("<div>"),
4895 WebUIFragment::for_loop("item", "items", "template1"),
4896 WebUIFragment::raw("</div>"),
4897 ],
4898 },
4899 );
4900 fragments.insert(
4901 "template1".to_string(),
4902 FragmentList {
4903 fragments: vec![
4904 WebUIFragment::raw("<span>"),
4905 WebUIFragment::signal("name", false),
4906 WebUIFragment::raw("</span>"),
4907 ],
4908 },
4909 );
4910 let protocol = WebUIProtocol::new(fragments);
4911 let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
4912 let mut writer = TestWriter::new();
4913 handle(
4914 &protocol,
4915 &state,
4916 &RenderOptions::new("index.html", "/"),
4917 &mut writer,
4918 )
4919 .unwrap();
4920 assert_eq!(
4921 writer.get_content(),
4922 "<div><span>GlobalName</span><span>GlobalName</span></div>"
4923 );
4924 }
4925
4926 #[test]
4927 fn test_nested_for_if_interleaved() {
4928 let mut fragments = HashMap::new();
4929 fragments.insert(
4930 "index.html".to_string(),
4931 FragmentList {
4932 fragments: vec![
4933 WebUIFragment::raw("<div>"),
4934 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
4935 WebUIFragment::raw("</div>"),
4936 ],
4937 },
4938 );
4939 fragments.insert(
4940 "outerTemplate".to_string(),
4941 FragmentList {
4942 fragments: vec![
4943 WebUIFragment::raw("<section>"),
4944 WebUIFragment::signal("globalPrefix", false),
4945 WebUIFragment::signal("outerItem.outerLabel", false),
4946 WebUIFragment::if_cond(
4947 ConditionExpr::identifier("outerItem.include"),
4948 "ifTemplate",
4949 ),
4950 WebUIFragment::raw("</section>"),
4951 ],
4952 },
4953 );
4954 fragments.insert(
4955 "ifTemplate".to_string(),
4956 FragmentList {
4957 fragments: vec![
4958 WebUIFragment::raw("<div>"),
4959 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
4960 WebUIFragment::raw("</div>"),
4961 ],
4962 },
4963 );
4964 fragments.insert(
4965 "innerTemplate".to_string(),
4966 FragmentList {
4967 fragments: vec![
4968 WebUIFragment::raw("<p>"),
4969 WebUIFragment::signal("globalSuffix", false),
4970 WebUIFragment::raw(": "),
4971 WebUIFragment::signal("innerItem.innerLabel", false),
4972 WebUIFragment::raw("</p>"),
4973 ],
4974 },
4975 );
4976 let protocol = WebUIProtocol::new(fragments);
4977 let state = test_json!({
4978 "globalPrefix": "Prefix: ",
4979 "globalSuffix": "Suffix",
4980 "outerItems": [
4981 {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
4982 {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
4983 {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
4984 ]
4985 });
4986 let mut writer = TestWriter::new();
4987 handle(
4988 &protocol,
4989 &state,
4990 &RenderOptions::new("index.html", "/"),
4991 &mut writer,
4992 )
4993 .unwrap();
4994 assert_eq!(
4995 writer.get_content(),
4996 "<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>"
4997 );
4998 }
4999
5000 #[test]
5001 fn test_nested_for_if_outer_state() {
5002 let mut fragments = HashMap::new();
5003 fragments.insert(
5004 "index.html".to_string(),
5005 FragmentList {
5006 fragments: vec![
5007 WebUIFragment::raw("<div>"),
5008 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5009 WebUIFragment::raw("</div>"),
5010 ],
5011 },
5012 );
5013 fragments.insert(
5014 "outerTemplate".to_string(),
5015 FragmentList {
5016 fragments: vec![
5017 WebUIFragment::raw("<section>"),
5018 WebUIFragment::signal("globalPrefix", false),
5019 WebUIFragment::signal("outerItem.label", false),
5020 WebUIFragment::for_loop(
5021 "middleItem",
5022 "outerItem.middleItems",
5023 "middleTemplate",
5024 ),
5025 WebUIFragment::raw("</section>"),
5026 ],
5027 },
5028 );
5029 fragments.insert(
5030 "middleTemplate".to_string(),
5031 FragmentList {
5032 fragments: vec![
5033 WebUIFragment::raw("<div>"),
5034 WebUIFragment::if_cond(
5035 ConditionExpr::identifier("outerItem.active"),
5036 "ifTemplate",
5037 ),
5038 WebUIFragment::raw("</div>"),
5039 ],
5040 },
5041 );
5042 fragments.insert(
5043 "ifTemplate".to_string(),
5044 FragmentList {
5045 fragments: vec![
5046 WebUIFragment::raw("<p>"),
5047 WebUIFragment::signal("middleItem.value", false),
5048 WebUIFragment::raw("</p>"),
5049 ],
5050 },
5051 );
5052 let protocol = WebUIProtocol::new(fragments);
5053 let state = test_json!({
5054 "globalPrefix": "GP-",
5055 "outerItems": [
5056 {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
5057 {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
5058 {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
5059 ]
5060 });
5061 let mut writer = TestWriter::new();
5062 handle(
5063 &protocol,
5064 &state,
5065 &RenderOptions::new("index.html", "/"),
5066 &mut writer,
5067 )
5068 .unwrap();
5069 assert_eq!(
5070 writer.get_content(),
5071 "<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>"
5072 );
5073 }
5074
5075 #[test]
5076 fn test_nested_for_if_inner_state() {
5077 let mut fragments = HashMap::new();
5078 fragments.insert(
5079 "index.html".to_string(),
5080 FragmentList {
5081 fragments: vec![
5082 WebUIFragment::raw("<div>"),
5083 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5084 WebUIFragment::raw("</div>"),
5085 ],
5086 },
5087 );
5088 fragments.insert(
5089 "outerTemplate".to_string(),
5090 FragmentList {
5091 fragments: vec![
5092 WebUIFragment::raw("<section>"),
5093 WebUIFragment::signal("outerItem.label", false),
5094 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5095 WebUIFragment::raw("</section>"),
5096 ],
5097 },
5098 );
5099 fragments.insert(
5100 "innerTemplate".to_string(),
5101 FragmentList {
5102 fragments: vec![
5103 WebUIFragment::raw("<article>"),
5104 WebUIFragment::if_cond(
5105 ConditionExpr::identifier("innerItem.show"),
5106 "ifTemplate",
5107 ),
5108 WebUIFragment::raw("</article>"),
5109 ],
5110 },
5111 );
5112 fragments.insert(
5113 "ifTemplate".to_string(),
5114 FragmentList {
5115 fragments: vec![
5116 WebUIFragment::raw("<p>"),
5117 WebUIFragment::signal("innerItem.detail", false),
5118 WebUIFragment::raw("</p>"),
5119 ],
5120 },
5121 );
5122 let protocol = WebUIProtocol::new(fragments);
5123 let state = test_json!({
5124 "outerItems": [
5125 {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
5126 {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
5127 ]
5128 });
5129 let mut writer = TestWriter::new();
5130 handle(
5131 &protocol,
5132 &state,
5133 &RenderOptions::new("index.html", "/"),
5134 &mut writer,
5135 )
5136 .unwrap();
5137 assert_eq!(
5138 writer.get_content(),
5139 "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
5140 );
5141 }
5142
5143 #[test]
5144 fn test_for_merge_local_global_monikers() {
5145 let mut fragments = HashMap::new();
5146 fragments.insert(
5147 "index.html".to_string(),
5148 FragmentList {
5149 fragments: vec![
5150 WebUIFragment::raw("<div>"),
5151 WebUIFragment::for_loop("item", "items", "template1"),
5152 WebUIFragment::raw("</div>"),
5153 ],
5154 },
5155 );
5156 fragments.insert(
5157 "template1".to_string(),
5158 FragmentList {
5159 fragments: vec![
5160 WebUIFragment::raw("<span>"),
5161 WebUIFragment::signal("item.name", false),
5162 WebUIFragment::raw("-"),
5163 WebUIFragment::signal("item.globalValue", false),
5164 WebUIFragment::raw("-"),
5165 WebUIFragment::signal("item.localOnly", false),
5166 WebUIFragment::raw("-"),
5167 WebUIFragment::signal("item.otherVal", false),
5168 WebUIFragment::raw("</span>"),
5169 ],
5170 },
5171 );
5172 let protocol = WebUIProtocol::new(fragments);
5173 let state = test_json!({
5174 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
5175 "items": [
5176 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
5177 {"name": "Local2", "localOnly": "Only2"}
5178 ]
5179 });
5180 let mut writer = TestWriter::new();
5181 handle(
5182 &protocol,
5183 &state,
5184 &RenderOptions::new("index.html", "/"),
5185 &mut writer,
5186 )
5187 .unwrap();
5188 assert_eq!(
5189 writer.get_content(),
5190 "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
5191 );
5192 }
5193
5194 #[test]
5195 fn test_component_in_for_global_moniker_shadow() {
5196 let mut fragments = HashMap::new();
5197 fragments.insert(
5198 "index.html".to_string(),
5199 FragmentList {
5200 fragments: vec![
5201 WebUIFragment::raw("<div>"),
5202 WebUIFragment::for_loop("item", "items", "templateComponent"),
5203 WebUIFragment::raw("</div>"),
5204 ],
5205 },
5206 );
5207 fragments.insert(
5208 "templateComponent".to_string(),
5209 FragmentList {
5210 fragments: vec![
5211 WebUIFragment::raw("<component-tag>"),
5212 WebUIFragment::component("my-component"),
5213 WebUIFragment::raw("</component-tag>"),
5214 ],
5215 },
5216 );
5217 fragments.insert(
5218 "my-component".to_string(),
5219 FragmentList {
5220 fragments: vec![
5221 WebUIFragment::raw("<span>"),
5222 WebUIFragment::signal("name", false),
5223 WebUIFragment::raw("-"),
5224 WebUIFragment::signal("item.globalValue", false),
5225 WebUIFragment::raw("-"),
5226 WebUIFragment::signal("localOnly", false),
5227 WebUIFragment::raw("-"),
5228 WebUIFragment::signal("item.otherVal", false),
5229 WebUIFragment::raw("</span>"),
5230 ],
5231 },
5232 );
5233 let protocol = WebUIProtocol::new(fragments);
5234 let state = test_json!({
5235 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
5236 "items": [
5237 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
5238 {"name": "Local2", "localOnly": "Only2"}
5239 ]
5240 });
5241 let mut writer = TestWriter::new();
5242 handle(
5243 &protocol,
5244 &state,
5245 &RenderOptions::new("index.html", "/"),
5246 &mut writer,
5247 )
5248 .unwrap();
5249 assert_eq!(
5250 writer.get_content(),
5251 "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
5252 );
5253 }
5254
5255 #[test]
5256 fn test_if_in_nested_for_local_flag() {
5257 let mut fragments = HashMap::new();
5258 fragments.insert(
5259 "index.html".to_string(),
5260 FragmentList {
5261 fragments: vec![
5262 WebUIFragment::raw("<div>"),
5263 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
5264 WebUIFragment::raw("</div>"),
5265 ],
5266 },
5267 );
5268 fragments.insert(
5269 "outerTemplate".to_string(),
5270 FragmentList {
5271 fragments: vec![
5272 WebUIFragment::raw("<section>"),
5273 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
5274 WebUIFragment::raw("</section>"),
5275 ],
5276 },
5277 );
5278 fragments.insert(
5279 "innerTemplate".to_string(),
5280 FragmentList {
5281 fragments: vec![WebUIFragment::if_cond(
5282 ConditionExpr::identifier("inner_item.flag"),
5283 "ifInner",
5284 )],
5285 },
5286 );
5287 fragments.insert(
5288 "ifInner".to_string(),
5289 FragmentList {
5290 fragments: vec![
5291 WebUIFragment::raw("<p>"),
5292 WebUIFragment::signal("inner_item.value", false),
5293 WebUIFragment::raw("</p>"),
5294 ],
5295 },
5296 );
5297 let protocol = WebUIProtocol::new(fragments);
5298 let state = test_json!({
5299 "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
5300 "inner_item": {"flag": false}
5301 });
5302 let mut writer = TestWriter::new();
5303 handle(
5304 &protocol,
5305 &state,
5306 &RenderOptions::new("index.html", "/"),
5307 &mut writer,
5308 )
5309 .unwrap();
5310 assert_eq!(
5311 writer.get_content(),
5312 "<div><section><p>X</p></section></div>"
5313 );
5314 }
5315
5316 #[test]
5317 fn test_if_in_nested_for_global_fallback() {
5318 let mut fragments = HashMap::new();
5319 fragments.insert(
5320 "index.html".to_string(),
5321 FragmentList {
5322 fragments: vec![
5323 WebUIFragment::raw("<div>"),
5324 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
5325 WebUIFragment::raw("</div>"),
5326 ],
5327 },
5328 );
5329 fragments.insert(
5330 "outerTemplate".to_string(),
5331 FragmentList {
5332 fragments: vec![
5333 WebUIFragment::raw("<section>"),
5334 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
5335 WebUIFragment::raw("</section>"),
5336 ],
5337 },
5338 );
5339 fragments.insert(
5340 "innerTemplate".to_string(),
5341 FragmentList {
5342 fragments: vec![WebUIFragment::if_cond(
5343 ConditionExpr::identifier("inner_item.flag"),
5344 "ifInner",
5345 )],
5346 },
5347 );
5348 fragments.insert(
5349 "ifInner".to_string(),
5350 FragmentList {
5351 fragments: vec![
5352 WebUIFragment::raw("<p>"),
5353 WebUIFragment::signal("inner_item.value", false),
5354 WebUIFragment::raw("</p>"),
5355 ],
5356 },
5357 );
5358 let protocol = WebUIProtocol::new(fragments);
5359 let state = test_json!({
5360 "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
5361 "inner_item": {"flag": true}
5362 });
5363 let mut writer = TestWriter::new();
5364 handle(
5365 &protocol,
5366 &state,
5367 &RenderOptions::new("index.html", "/"),
5368 &mut writer,
5369 )
5370 .unwrap();
5371 assert_eq!(
5372 writer.get_content(),
5373 "<div><section><p>X</p><p>Y</p></section></div>"
5374 );
5375 }
5376
5377 #[test]
5378 fn test_if_mixed_for_monikers() {
5379 let mut fragments = HashMap::new();
5380 fragments.insert(
5381 "index.html".to_string(),
5382 FragmentList {
5383 fragments: vec![
5384 WebUIFragment::raw("<div>"),
5385 WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
5386 WebUIFragment::raw("</div>"),
5387 ],
5388 },
5389 );
5390 fragments.insert(
5391 "outerTemplate".to_string(),
5392 FragmentList {
5393 fragments: vec![
5394 WebUIFragment::raw("<section>"),
5395 WebUIFragment::signal("outer.outerLabel", false),
5396 WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
5397 WebUIFragment::raw("</section>"),
5398 ],
5399 },
5400 );
5401 fragments.insert(
5402 "innerTemplate".to_string(),
5403 FragmentList {
5404 fragments: vec![WebUIFragment::if_cond(
5405 ConditionExpr::compound(
5406 ConditionExpr::identifier("outer.active"),
5407 LogicalOperator::And,
5408 ConditionExpr::predicate(
5409 "inner.value",
5410 ComparisonOperator::GreaterThan,
5411 "globalLimit",
5412 ),
5413 ),
5414 "ifInner",
5415 )],
5416 },
5417 );
5418 fragments.insert(
5419 "ifInner".to_string(),
5420 FragmentList {
5421 fragments: vec![
5422 WebUIFragment::raw("<p>"),
5423 WebUIFragment::signal("inner.value", false),
5424 WebUIFragment::raw("</p>"),
5425 ],
5426 },
5427 );
5428 let protocol = WebUIProtocol::new(fragments);
5429 let state = test_json!({
5430 "globalLimit": 10,
5431 "list": {"outerItems": [
5432 {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
5433 {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
5434 {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
5435 ]}
5436 });
5437 let mut writer = TestWriter::new();
5438 handle(
5439 &protocol,
5440 &state,
5441 &RenderOptions::new("index.html", "/"),
5442 &mut writer,
5443 )
5444 .unwrap();
5445 assert_eq!(
5446 writer.get_content(),
5447 "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
5448 );
5449 }
5450
5451 fn make_route_protocol() -> WebUIProtocol {
5454 use webui_protocol::WebUiFragmentRoute;
5455
5456 let mut fragments = HashMap::new();
5457
5458 fragments.insert(
5460 "index.html".to_string(),
5461 FragmentList {
5462 fragments: vec![
5463 WebUIFragment::raw("<h1>Shell</h1>"),
5464 WebUIFragment::route_from(WebUiFragmentRoute {
5465 path: "/".into(),
5466 fragment_id: "dash-page".into(),
5467 exact: true,
5468 keep_alive: false,
5469 ..Default::default()
5470 }),
5471 WebUIFragment::route_from(WebUiFragmentRoute {
5472 path: "/contacts/:id".into(),
5473 fragment_id: "detail-page".into(),
5474 exact: true,
5475 keep_alive: false,
5476 ..Default::default()
5477 }),
5478 ],
5479 },
5480 );
5481
5482 fragments.insert(
5484 "dash-page".to_string(),
5485 FragmentList {
5486 fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
5487 },
5488 );
5489
5490 fragments.insert(
5492 "detail-page".to_string(),
5493 FragmentList {
5494 fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
5495 },
5496 );
5497
5498 WebUIProtocol::new(fragments)
5499 }
5500
5501 fn make_nested_route_protocol() -> WebUIProtocol {
5502 use webui_protocol::WebUiFragmentRoute;
5503
5504 let mut fragments = HashMap::new();
5505
5506 fragments.insert(
5507 "index.html".to_string(),
5508 FragmentList {
5509 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
5510 path: "/".into(),
5511 fragment_id: "app-shell".into(),
5512 exact: false,
5513 children: vec![WebUiFragmentRoute {
5514 path: "sections/:id".into(),
5515 fragment_id: "section-comp".into(),
5516 exact: false,
5517 children: vec![WebUiFragmentRoute {
5518 path: "topics/:topicId".into(),
5519 fragment_id: "topic-comp".into(),
5520 exact: true,
5521 children: vec![],
5522 keep_alive: false,
5523 ..Default::default()
5524 }],
5525 keep_alive: false,
5526 ..Default::default()
5527 }],
5528 keep_alive: false,
5529 ..Default::default()
5530 })],
5531 },
5532 );
5533
5534 fragments.insert(
5535 "app-shell".to_string(),
5536 FragmentList {
5537 fragments: vec![
5538 WebUIFragment::raw("<h1>Shell</h1>"),
5539 WebUIFragment::outlet(),
5540 ],
5541 },
5542 );
5543
5544 fragments.insert(
5545 "section-comp".to_string(),
5546 FragmentList {
5547 fragments: vec![
5548 WebUIFragment::raw("<h2>Section</h2>"),
5549 WebUIFragment::outlet(),
5550 ],
5551 },
5552 );
5553
5554 fragments.insert(
5555 "topic-comp".to_string(),
5556 FragmentList {
5557 fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
5558 },
5559 );
5560
5561 WebUIProtocol::new(fragments)
5562 }
5563
5564 #[test]
5565 fn test_route_renders_shell_always() {
5566 let protocol = make_route_protocol();
5567 let state = test_json!({});
5568 let mut writer = TestWriter::new();
5569 handle(
5570 &protocol,
5571 &state,
5572 &RenderOptions::new("index.html", "/"),
5573 &mut writer,
5574 )
5575 .unwrap();
5576 let html = writer.get_content();
5577
5578 assert!(html.contains("<h1>Shell</h1>"), "shell should render");
5580 assert!(html.contains(" active>"), "matched route should be active");
5582 assert!(
5584 html.contains("style=\"display:none\""),
5585 "non-matched routes should be hidden"
5586 );
5587 }
5588
5589 #[test]
5590 fn test_route_matched_renders_visible() {
5591 let protocol = make_route_protocol();
5592 let state = test_json!({});
5593 let mut writer = TestWriter::new();
5594 handle(
5595 &protocol,
5596 &state,
5597 &RenderOptions::new("index.html", "/"),
5598 &mut writer,
5599 )
5600 .unwrap();
5601 let html = writer.get_content();
5602
5603 assert!(
5605 html.contains("<webui-route path=\"/\""),
5606 "dashboard route should exist"
5607 );
5608 assert!(
5609 html.contains("active>") && html.contains("<dash-page>"),
5610 "matched route should be active with component tag: {html}"
5611 );
5612 assert!(
5613 html.contains("<p>Dashboard</p>"),
5614 "matched route should have content"
5615 );
5616 }
5617
5618 #[test]
5619 fn test_route_non_matched_renders_hidden_empty() {
5620 let protocol = make_route_protocol();
5621 let state = test_json!({});
5622 let mut writer = TestWriter::new();
5623 handle(
5624 &protocol,
5625 &state,
5626 &RenderOptions::new("index.html", "/"),
5627 &mut writer,
5628 )
5629 .unwrap();
5630 let html = writer.get_content();
5631
5632 assert!(
5634 html.contains("<webui-route path=\"/contacts/:id\""),
5635 "detail route element should exist"
5636 );
5637 let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
5639 let after_detail = &html[detail_start..];
5640 assert!(
5641 after_detail.contains("style=\"display:none\">")
5642 && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
5643 "non-matched route should be hidden: {after_detail}"
5644 );
5645 let detail_end = after_detail.find("</webui-route>").expect("closing tag");
5647 let detail_body = &after_detail[..detail_end];
5648 assert!(
5649 !detail_body.contains("<detail-page>"),
5650 "non-matched route should not render component content: {detail_body}"
5651 );
5652 }
5653
5654 #[test]
5655 fn test_route_parameterized_match() {
5656 let protocol = make_route_protocol();
5657 let state = test_json!({});
5658 let mut writer = TestWriter::new();
5659 handle(
5660 &protocol,
5661 &state,
5662 &RenderOptions::new("index.html", "/contacts/42"),
5663 &mut writer,
5664 )
5665 .unwrap();
5666 let html = writer.get_content();
5667
5668 assert!(
5670 html.contains("active>") && html.contains("<detail-page>"),
5671 "detail route should be active: {html}"
5672 );
5673 assert!(html.contains("<p>Detail</p>"), "detail should have content");
5674 let dash_start = html
5676 .find("component=\"dash-page\"")
5677 .expect("dashboard route");
5678 let after_dash = &html[dash_start..];
5679 assert!(
5680 after_dash.contains("style=\"display:none\">"),
5681 "dashboard should be hidden when detail matches: {after_dash}"
5682 );
5683 let dash_end = after_dash.find("</webui-route>").expect("closing tag");
5684 let dash_body = &after_dash[..dash_end];
5685 assert!(
5686 !dash_body.contains("<dash-page>"),
5687 "dashboard should not render component content: {dash_body}"
5688 );
5689 }
5690
5691 #[test]
5692 fn test_route_no_match_all_hidden_empty() {
5693 let protocol = make_route_protocol();
5694 let state = test_json!({});
5695 let mut writer = TestWriter::new();
5696 handle(
5697 &protocol,
5698 &state,
5699 &RenderOptions::new("index.html", "/nonexistent"),
5700 &mut writer,
5701 )
5702 .unwrap();
5703 let html = writer.get_content();
5704
5705 assert!(html.contains("<h1>Shell</h1>"));
5707 assert!(
5709 !html.contains("<p>Dashboard</p>"),
5710 "no route content when nothing matches"
5711 );
5712 assert!(
5713 !html.contains("<p>Detail</p>"),
5714 "no route content when nothing matches"
5715 );
5716 }
5717
5718 #[test]
5719 fn test_route_component_attr_emitted() {
5720 let protocol = make_route_protocol();
5721 let state = test_json!({});
5722 let mut writer = TestWriter::new();
5723 handle(
5724 &protocol,
5725 &state,
5726 &RenderOptions::new("index.html", "/"),
5727 &mut writer,
5728 )
5729 .unwrap();
5730 let html = writer.get_content();
5731 assert!(
5733 html.contains("component=\"dash-page\""),
5734 "component attr should be on webui-route: {html}"
5735 );
5736 assert!(
5737 html.contains("component=\"detail-page\""),
5738 "component attr should be on webui-route: {html}"
5739 );
5740 }
5741
5742 #[test]
5743 fn test_no_plugin_no_state_attributes() {
5744 let protocol = make_route_protocol();
5745 let state = test_json!({
5746 "title": "Fish & Chips",
5747 "cartOpen": true,
5748 "items": [{"name": "A&B"}]
5749 });
5750 let mut writer = TestWriter::new();
5751
5752 handle(
5753 &protocol,
5754 &state,
5755 &RenderOptions::new("index.html", "/"),
5756 &mut writer,
5757 )
5758 .unwrap();
5759
5760 let html = writer.get_content();
5761 assert!(
5763 !html.contains("data-state"),
5764 "no data-state without plugin: {html}"
5765 );
5766 assert!(
5767 !html.contains(r#"title="Fish"#),
5768 "no scalar attrs without plugin: {html}"
5769 );
5770 }
5771
5772 #[test]
5773 fn test_nested_routes_render_webui_route_as_light_dom() {
5774 let protocol = make_nested_route_protocol();
5775 let state = test_json!({"title": "Test"});
5776 let handler = WebUIHandler::new();
5777 let mut writer = TestWriter::new();
5778
5779 handler
5780 .handle(
5781 &protocol,
5782 &state,
5783 &RenderOptions::new("index.html", "/sections/frontend"),
5784 &mut writer,
5785 )
5786 .expect("render failed");
5787
5788 let html = writer.get_content();
5789
5790 assert!(
5791 html.contains("component=\"app-shell\"") && html.contains("active>"),
5792 "root route should be active: {html}"
5793 );
5794 assert!(
5796 !html.contains("<template shadowrootmode"),
5797 "webui-route should be light DOM (no shadow template): {html}"
5798 );
5799 }
5800
5801 #[test]
5802 fn test_nested_routes_render_outlet_as_light_dom() {
5803 let protocol = make_nested_route_protocol();
5804 let state = test_json!({"title": "Test"});
5805 let handler = WebUIHandler::new();
5806 let mut writer = TestWriter::new();
5807
5808 handler
5809 .handle(
5810 &protocol,
5811 &state,
5812 &RenderOptions::new("index.html", "/sections/frontend"),
5813 &mut writer,
5814 )
5815 .expect("render failed");
5816
5817 let html = writer.get_content();
5818
5819 assert!(
5821 !html.contains("<webui-outlet>"),
5822 "should not contain webui-outlet wrapper: {html}"
5823 );
5824 assert!(
5826 html.contains("<webui-route"),
5827 "should contain webui-route elements: {html}"
5828 );
5829 }
5830
5831 #[test]
5832 fn test_nested_routes_match_child_at_outlet() {
5833 let protocol = make_nested_route_protocol();
5834 let state = test_json!({"title": "Test"});
5835 let handler = WebUIHandler::new();
5836 let mut writer = TestWriter::new();
5837
5838 handler
5839 .handle(
5840 &protocol,
5841 &state,
5842 &RenderOptions::new("index.html", "/sections/frontend"),
5843 &mut writer,
5844 )
5845 .expect("render failed");
5846
5847 let html = writer.get_content();
5848
5849 assert!(
5850 html.contains("component=\"section-comp\"") && html.contains("active>"),
5851 "section route should be active: {html}"
5852 );
5853 assert!(
5854 html.contains("<h2>Section</h2>"),
5855 "section content should be present: {html}"
5856 );
5857 }
5858
5859 #[test]
5860 fn test_nested_routes_three_levels_deep() {
5861 let protocol = make_nested_route_protocol();
5862 let state = test_json!({"title": "Test"});
5863 let handler = WebUIHandler::new();
5864 let mut writer = TestWriter::new();
5865
5866 handler
5867 .handle(
5868 &protocol,
5869 &state,
5870 &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
5871 &mut writer,
5872 )
5873 .expect("render failed");
5874
5875 let html = writer.get_content();
5876
5877 assert!(
5878 html.contains("component=\"app-shell\"") && html.contains("active>"),
5879 "root active: {html}"
5880 );
5881 assert!(
5882 html.contains("component=\"section-comp\"") && html.contains("active>"),
5883 "section active: {html}"
5884 );
5885 assert!(
5886 html.contains("component=\"topic-comp\"")
5887 && html.contains("exact")
5888 && html.contains("active>"),
5889 "topic active: {html}"
5890 );
5891 assert!(
5892 html.contains("<p>Topic content</p>"),
5893 "leaf content present: {html}"
5894 );
5895 }
5896
5897 #[test]
5898 fn test_nested_routes_nonmatched_siblings_hidden() {
5899 let protocol = make_nested_route_protocol();
5900 let state = test_json!({"title": "Test"});
5901 let handler = WebUIHandler::new();
5902 let mut writer = TestWriter::new();
5903
5904 handler
5905 .handle(
5906 &protocol,
5907 &state,
5908 &RenderOptions::new("index.html", "/sections/frontend"),
5909 &mut writer,
5910 )
5911 .expect("render failed");
5912
5913 let html = writer.get_content();
5914
5915 assert!(
5916 html.contains(r#"component="topic-comp" exact style="display:none">"#),
5917 "topic should be hidden: {html}"
5918 );
5919 }
5920
5921 #[test]
5922 fn test_nested_routes_root_only() {
5923 let protocol = make_nested_route_protocol();
5924 let state = test_json!({"title": "Test"});
5925 let handler = WebUIHandler::new();
5926 let mut writer = TestWriter::new();
5927
5928 handler
5929 .handle(
5930 &protocol,
5931 &state,
5932 &RenderOptions::new("index.html", "/"),
5933 &mut writer,
5934 )
5935 .expect("render failed");
5936
5937 let html = writer.get_content();
5938
5939 assert!(
5940 html.contains("component=\"app-shell\"") && html.contains("active>"),
5941 "root active at /: {html}"
5942 );
5943 assert!(
5944 html.contains("<h1>Shell</h1>"),
5945 "shell renders at /: {html}"
5946 );
5947 assert!(
5948 html.contains(r#"component="section-comp" style="display:none">"#),
5949 "section hidden at /: {html}"
5950 );
5951 }
5952
5953 #[test]
5956 fn test_css_module_emitted_once_inline_in_component() {
5957 let template = r#"<p><slot></slot></p>"#;
5960
5961 let mut fragments = HashMap::new();
5962 fragments.insert(
5963 "index.html".to_string(),
5964 FragmentList {
5965 fragments: vec![
5966 WebUIFragment::raw("<html><head>".to_string()),
5967 WebUIFragment::signal("head_end", true),
5968 WebUIFragment::raw("</head><body><div>".to_string()),
5969 WebUIFragment::component("my-card"),
5970 WebUIFragment::raw("A".to_string()),
5971 WebUIFragment::component("my-card"),
5972 WebUIFragment::raw("B</div>".to_string()),
5973 WebUIFragment::signal("body_end", true),
5974 WebUIFragment::raw("</body></html>".to_string()),
5975 ],
5976 },
5977 );
5978 fragments.insert(
5979 "my-card".to_string(),
5980 FragmentList {
5981 fragments: vec![WebUIFragment::raw(template.to_string())],
5982 },
5983 );
5984
5985 let mut protocol = WebUIProtocol::new(fragments);
5986 protocol
5987 .components
5988 .entry("my-card".to_string())
5989 .or_default()
5990 .css = "p{color:red}".to_string();
5991 let state = test_json!({});
5992 let mut writer = TestWriter::new();
5993
5994 handle(
5995 &protocol,
5996 &state,
5997 &RenderOptions::new("index.html", "/"),
5998 &mut writer,
5999 )
6000 .unwrap();
6001
6002 let html = writer.get_content();
6003
6004 let count = html
6006 .matches(r#"<style type="module" specifier="my-card">"#)
6007 .count();
6008 assert_eq!(
6009 count, 1,
6010 "CSS module should be emitted once, got {count} in: {html}"
6011 );
6012
6013 let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
6015 assert_eq!(
6016 tmpl_count, 2,
6017 "Template should render twice, got {tmpl_count} in: {html}"
6018 );
6019
6020 let css_pos = html
6022 .find(r#"<style type="module""#)
6023 .expect("CSS module missing");
6024 let body_pos = html.find("<body>").expect("<body> missing");
6025 assert!(
6026 css_pos > body_pos,
6027 "CSS module should be inline in component, not in <head>: {html}"
6028 );
6029 }
6030
6031 #[test]
6032 fn test_component_without_css_renders_normally() {
6033 let mut fragments = HashMap::new();
6035 fragments.insert(
6036 "index.html".to_string(),
6037 FragmentList {
6038 fragments: vec![WebUIFragment::component("my-card")],
6039 },
6040 );
6041 fragments.insert(
6042 "my-card".to_string(),
6043 FragmentList {
6044 fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
6045 },
6046 );
6047
6048 let protocol = WebUIProtocol::new(fragments);
6049 let state = test_json!({});
6050 let mut writer = TestWriter::new();
6051
6052 handle(
6053 &protocol,
6054 &state,
6055 &RenderOptions::new("index.html", "/"),
6056 &mut writer,
6057 )
6058 .unwrap();
6059
6060 let html = writer.get_content();
6061 assert!(
6062 html.contains("<p>hello</p>"),
6063 "Non-module component should render normally: {html}"
6064 );
6065 }
6066
6067 #[test]
6068 fn test_non_module_strategy_no_css_in_head() {
6069 let template = r#"<p>hello</p>"#;
6072
6073 let mut fragments = HashMap::new();
6074 fragments.insert(
6075 "index.html".to_string(),
6076 FragmentList {
6077 fragments: vec![
6078 WebUIFragment::raw("<html><head>".to_string()),
6079 WebUIFragment::signal("head_end", true),
6080 WebUIFragment::raw("</head><body>".to_string()),
6081 WebUIFragment::component("my-card"),
6082 WebUIFragment::raw("</body></html>".to_string()),
6083 ],
6084 },
6085 );
6086 fragments.insert(
6087 "my-card".to_string(),
6088 FragmentList {
6089 fragments: vec![WebUIFragment::raw(template.to_string())],
6090 },
6091 );
6092
6093 let protocol = WebUIProtocol::new(fragments);
6095 let state = test_json!({});
6096 let mut writer = TestWriter::new();
6097
6098 handle(
6099 &protocol,
6100 &state,
6101 &RenderOptions::new("index.html", "/"),
6102 &mut writer,
6103 )
6104 .unwrap();
6105
6106 let html = writer.get_content();
6107
6108 assert!(
6109 !html.contains(r#"<style type="module""#),
6110 "Non-module strategy should not emit CSS modules in <head>: {html}"
6111 );
6112 assert!(
6113 html.contains("<p>hello</p>"),
6114 "Component should still render: {html}"
6115 );
6116 }
6117
6118 #[test]
6119 fn test_style_strategy_embeds_inline_style_in_shadow_template() {
6120 let mut fragments = HashMap::new();
6121 fragments.insert(
6122 "index.html".to_string(),
6123 FragmentList {
6124 fragments: vec![
6125 WebUIFragment::raw("<html><head>".to_string()),
6126 WebUIFragment::signal("head_end", true),
6127 WebUIFragment::raw("</head><body><my-card>".to_string()),
6128 WebUIFragment::component("my-card"),
6129 WebUIFragment::raw("</my-card></body></html>".to_string()),
6130 ],
6131 },
6132 );
6133 fragments.insert(
6134 "my-card".to_string(),
6135 FragmentList {
6136 fragments: vec![WebUIFragment::raw(
6137 "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
6138 .to_string(),
6139 )],
6140 },
6141 );
6142
6143 let protocol = WebUIProtocol::new(fragments);
6144 let state = test_json!({});
6145 let mut writer = TestWriter::new();
6146
6147 handle(
6148 &protocol,
6149 &state,
6150 &RenderOptions::new("index.html", "/"),
6151 &mut writer,
6152 )
6153 .unwrap();
6154
6155 let html = writer.get_content();
6156
6157 assert!(
6158 html.contains("<style>.card{color:red}</style>"),
6159 "Style strategy should embed inline CSS in shadow template: {html}"
6160 );
6161 assert!(
6162 !html.contains(r#"<style type="module""#),
6163 "Style strategy should not emit module CSS in <head>: {html}"
6164 );
6165 }
6166
6167 #[test]
6168 fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
6169 let mut fragments = HashMap::new();
6172 fragments.insert(
6173 "index.html".to_string(),
6174 FragmentList {
6175 fragments: vec![
6176 WebUIFragment::raw("<html><head>".to_string()),
6177 WebUIFragment::signal("head_end", true),
6178 WebUIFragment::raw("</head><body><my-card>".to_string()),
6179 WebUIFragment::component("my-card"),
6180 WebUIFragment::raw("</my-card>".to_string()),
6181 WebUIFragment::signal("body_end", true),
6182 WebUIFragment::raw("</body></html>".to_string()),
6183 ],
6184 },
6185 );
6186 fragments.insert(
6187 "my-card".to_string(),
6188 FragmentList {
6189 fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
6190 },
6191 );
6192
6193 let mut protocol = WebUIProtocol::new(fragments);
6194 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
6195 protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
6196
6197 let comp = protocol
6198 .components
6199 .entry("my-card".to_string())
6200 .or_default();
6201 comp.css_href = "my-card.css".to_string();
6202 comp.template = "(function(){})();".to_string();
6203
6204 let state = test_json!({});
6205 let mut writer = TestWriter::new();
6206
6207 handle(
6208 &protocol,
6209 &state,
6210 &RenderOptions::new("index.html", "/"),
6211 &mut writer,
6212 )
6213 .unwrap();
6214
6215 let html = writer.get_content();
6216
6217 let head_end = html.find("</head>").expect("</head> missing");
6218 let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css">"#);
6219 assert!(
6220 link_pos.is_some_and(|p| p < head_end),
6221 "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
6222 );
6223 assert!(
6224 !html.contains(r#"<link rel="preload""#),
6225 "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
6226 );
6227 assert!(
6228 !html.contains(r#"<style type="module""#),
6229 "Link strategy should not emit module CSS: {html}"
6230 );
6231 }
6232
6233 #[test]
6234 fn test_link_strategy_shadow_dom_emits_preload_in_head() {
6235 let mut fragments = HashMap::new();
6239 fragments.insert(
6240 "index.html".to_string(),
6241 FragmentList {
6242 fragments: vec![
6243 WebUIFragment::raw("<html><head>".to_string()),
6244 WebUIFragment::signal("head_end", true),
6245 WebUIFragment::raw("</head><body><o-loading-state>".to_string()),
6246 WebUIFragment::component("o-loading-state"),
6247 WebUIFragment::raw("</o-loading-state><my-card>".to_string()),
6248 WebUIFragment::component("my-card"),
6249 WebUIFragment::raw("</my-card>".to_string()),
6250 WebUIFragment::signal("body_end", true),
6251 WebUIFragment::raw("</body></html>".to_string()),
6252 ],
6253 },
6254 );
6255 fragments.insert(
6256 "o-loading-state".to_string(),
6257 FragmentList {
6258 fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
6259 },
6260 );
6261 fragments.insert(
6262 "my-card".to_string(),
6263 FragmentList {
6264 fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
6265 },
6266 );
6267
6268 let mut protocol = WebUIProtocol::new(fragments);
6269 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
6270 protocol.set_dom_strategy(webui_protocol::DomStrategy::Shadow);
6271
6272 let comp1 = protocol
6273 .components
6274 .entry("o-loading-state".to_string())
6275 .or_default();
6276 comp1.css_href = "o-loading-state.css".to_string();
6277 comp1.template = "(function(){})();".to_string();
6278
6279 let comp2 = protocol
6280 .components
6281 .entry("my-card".to_string())
6282 .or_default();
6283 comp2.css_href = "my-card.css".to_string();
6284 comp2.template = "(function(){})();".to_string();
6285
6286 let state = test_json!({});
6287 let mut writer = TestWriter::new();
6288
6289 handle(
6290 &protocol,
6291 &state,
6292 &RenderOptions::new("index.html", "/"),
6293 &mut writer,
6294 )
6295 .unwrap();
6296
6297 let html = writer.get_content();
6298 let head_end = html.find("</head>").expect("</head> missing");
6299 let head_section = &html[..head_end];
6300
6301 assert!(
6303 head_section.contains(
6304 r#"<link rel="preload" href="o-loading-state.css" as="style" data-webui-ssr-preload="style">"#
6305 ),
6306 "Missing preload for o-loading-state.css in <head>: {html}"
6307 );
6308 assert!(
6309 head_section.contains(
6310 r#"<link rel="preload" href="my-card.css" as="style" data-webui-ssr-preload="style">"#
6311 ),
6312 "Missing preload for my-card.css in <head>: {html}"
6313 );
6314
6315 assert!(
6317 !head_section.contains(r#"<link rel="stylesheet""#),
6318 "Shadow DOM should NOT emit <link rel=stylesheet> in <head>: {html}"
6319 );
6320 }
6321
6322 #[test]
6323 fn test_css_module_emitted_in_component_light_dom() {
6324 let mut fragments = HashMap::new();
6328 fragments.insert(
6329 "index.html".to_string(),
6330 FragmentList {
6331 fragments: vec![
6332 WebUIFragment::raw("<html><head>".to_string()),
6333 WebUIFragment::signal("head_end", true),
6334 WebUIFragment::raw("</head><body><my-card>".to_string()),
6335 WebUIFragment::component("my-card"),
6336 WebUIFragment::raw("</my-card>".to_string()),
6337 WebUIFragment::signal("body_end", true),
6338 WebUIFragment::raw("</body></html>".to_string()),
6339 ],
6340 },
6341 );
6342 fragments.insert(
6343 "my-card".to_string(),
6344 FragmentList {
6345 fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
6346 },
6347 );
6348
6349 let mut protocol = WebUIProtocol::new(fragments);
6350 protocol
6351 .components
6352 .entry("my-card".to_string())
6353 .or_default()
6354 .css = "p{color:red}".to_string();
6355 let state = test_json!({});
6356 let mut writer = TestWriter::new();
6357
6358 handle(
6359 &protocol,
6360 &state,
6361 &RenderOptions::new("index.html", "/"),
6362 &mut writer,
6363 )
6364 .unwrap();
6365
6366 let html = writer.get_content();
6367
6368 let tag_open = html.find("<my-card>").expect("<my-card> missing");
6370 let css_pos = html
6371 .find(r#"<style type="module""#)
6372 .expect("CSS module missing");
6373 let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
6374 assert!(
6375 css_pos > tag_open && css_pos < tag_close,
6376 "CSS module should be inside component light DOM: {html}"
6377 );
6378
6379 let head_end = html.find("</head>").expect("</head> missing");
6381 assert!(
6382 css_pos > head_end,
6383 "CSS module should not be in <head>: {html}"
6384 );
6385 }
6386
6387 #[test]
6388 fn test_css_module_emitted_for_route_components() {
6389 let template = r#"<h1>Dashboard</h1>"#;
6391
6392 let mut fragments = HashMap::new();
6393 fragments.insert(
6394 "index.html".to_string(),
6395 FragmentList {
6396 fragments: vec![
6397 WebUIFragment::raw("<html><head>".to_string()),
6398 WebUIFragment::signal("head_end", true),
6399 WebUIFragment::raw("</head><body>".to_string()),
6400 WebUIFragment::route("/", "dash-page"),
6401 WebUIFragment::signal("body_end", true),
6402 WebUIFragment::raw("</body></html>".to_string()),
6403 ],
6404 },
6405 );
6406 fragments.insert(
6407 "dash-page".to_string(),
6408 FragmentList {
6409 fragments: vec![WebUIFragment::raw(template.to_string())],
6410 },
6411 );
6412
6413 let mut protocol = WebUIProtocol::new(fragments);
6414 let comp = protocol
6415 .components
6416 .entry("dash-page".to_string())
6417 .or_default();
6418 comp.css = "h1{font-size:2rem}".to_string();
6419 comp.template = "(function(){})();".to_string();
6420 let state = test_json!({});
6421 let mut writer = TestWriter::new();
6422
6423 handle(
6424 &protocol,
6425 &state,
6426 &RenderOptions::new("index.html", "/"),
6427 &mut writer,
6428 )
6429 .unwrap();
6430
6431 let html = writer.get_content();
6432
6433 assert!(
6434 html.contains(
6435 r#"<style type="module" specifier="dash-page">h1{font-size:2rem}</style>"#
6436 ),
6437 "Route component should have CSS module: {html}"
6438 );
6439 assert!(
6440 html.contains("<h1>Dashboard</h1>"),
6441 "Route component should render content: {html}"
6442 );
6443 }
6444
6445 #[test]
6446 fn test_head_css_link_skipped_for_components_without_css() {
6447 let mut fragments = HashMap::new();
6450 fragments.insert(
6451 "index.html".to_string(),
6452 FragmentList {
6453 fragments: vec![
6454 WebUIFragment::raw("<html><head>".to_string()),
6455 WebUIFragment::signal("head_end", true),
6456 WebUIFragment::raw("</head><body>".to_string()),
6457 WebUIFragment::component("has-css"),
6458 WebUIFragment::component("no-css"),
6459 WebUIFragment::raw("</body></html>".to_string()),
6460 ],
6461 },
6462 );
6463 fragments.insert(
6464 "has-css".to_string(),
6465 FragmentList {
6466 fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
6467 },
6468 );
6469 fragments.insert(
6470 "no-css".to_string(),
6471 FragmentList {
6472 fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
6473 },
6474 );
6475
6476 let mut protocol = WebUIProtocol::new(fragments);
6477 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
6478 protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
6479
6480 protocol
6482 .components
6483 .entry("has-css".to_string())
6484 .or_default()
6485 .css_href = "has-css.css".to_string();
6486
6487 let state = test_json!({});
6488 let mut writer = TestWriter::new();
6489
6490 let handler = WebUIHandler::with_plugin(|| {
6491 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
6492 });
6493 handler
6494 .handle(
6495 &protocol,
6496 &state,
6497 &RenderOptions::new("index.html", "/"),
6498 &mut writer,
6499 )
6500 .unwrap();
6501
6502 let html = writer.get_content();
6503 assert!(
6504 html.contains(r#"<link rel="stylesheet" href="has-css.css">"#),
6505 "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
6506 );
6507 assert!(
6508 !html.contains("no-css.css"),
6509 "Component without CSS must NOT get a <link> in <head>: {html}"
6510 );
6511 }
6512
6513 #[test]
6514 fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
6515 let mut fragments = HashMap::new();
6523 fragments.insert(
6524 "index.html".to_string(),
6525 FragmentList {
6526 fragments: vec![
6527 WebUIFragment::raw("<html><head>".to_string()),
6528 WebUIFragment::signal("head_end", true),
6529 WebUIFragment::raw("</head><body><app-shell>".to_string()),
6530 WebUIFragment::component("app-shell"),
6531 WebUIFragment::raw("</app-shell>".to_string()),
6532 WebUIFragment::signal("body_end", true),
6533 WebUIFragment::raw("</body></html>".to_string()),
6534 ],
6535 },
6536 );
6537 fragments.insert(
6539 "app-shell".to_string(),
6540 FragmentList {
6541 fragments: vec![
6542 WebUIFragment::raw("<div>Shell</div>".to_string()),
6543 WebUIFragment::component("cart-panel"),
6544 ],
6545 },
6546 );
6547 fragments.insert(
6549 "cart-panel".to_string(),
6550 FragmentList {
6551 fragments: vec![
6552 WebUIFragment::raw("<aside>".to_string()),
6553 WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
6554 WebUIFragment::raw("</aside>".to_string()),
6555 ],
6556 },
6557 );
6558 fragments.insert(
6560 "cart-items".to_string(),
6561 FragmentList {
6562 fragments: vec![WebUIFragment::component("product-card")],
6563 },
6564 );
6565 fragments.insert(
6566 "product-card".to_string(),
6567 FragmentList {
6568 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
6569 },
6570 );
6571
6572 let mut protocol = WebUIProtocol::new(fragments);
6573 for name in ["app-shell", "cart-panel", "product-card"] {
6574 let comp = protocol.components.entry(name.to_string()).or_default();
6575 comp.template = format!(
6576 "(function(){{var w=(window.__webui||(window.__webui={{}})).templates||(window.__webui.templates={{}});w['{name}']={{h:'<div/>'}};}})();\n"
6577 );
6578 comp.css = format!(".{name}{{display:block}}");
6579 }
6580
6581 let state = test_json!({ "hasItems": false });
6583 let mut writer = TestWriter::new();
6584
6585 let handler = WebUIHandler::with_plugin(|| {
6586 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
6587 });
6588 handler
6589 .handle(
6590 &protocol,
6591 &state,
6592 &RenderOptions::new("index.html", "/"),
6593 &mut writer,
6594 )
6595 .unwrap();
6596
6597 let html = writer.get_content();
6598
6599 assert!(
6602 html.contains("w['product-card']"),
6603 "product-card template should be emitted even when unrendered: {html}"
6604 );
6605
6606 assert!(
6609 html.contains(r#"specifier="product-card""#),
6610 "reachable product-card CSS module should be emitted: {html}"
6611 );
6612
6613 assert!(
6615 html.contains("w['app-shell']"),
6616 "rendered app-shell template should be emitted: {html}"
6617 );
6618 assert!(
6619 html.contains("w['cart-panel']"),
6620 "rendered cart-panel template should be emitted: {html}"
6621 );
6622 }
6623
6624 #[test]
6625 fn test_component_attr_name_aria() {
6626 assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
6628 assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
6629 assert_eq!(
6630 component_attr_name("aria-activedescendant"),
6631 "ariaActiveDescendant"
6632 );
6633 assert_eq!(component_attr_name("aria-label"), "ariaLabel");
6634 assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
6635 }
6636
6637 #[test]
6638 fn test_component_attr_name_html_global() {
6639 assert_eq!(component_attr_name("readonly"), "readOnly");
6640 assert_eq!(component_attr_name("tabindex"), "tabIndex");
6641 assert_eq!(component_attr_name("accesskey"), "accessKey");
6642 assert_eq!(component_attr_name("contenteditable"), "contentEditable");
6643 assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
6644 assert_eq!(component_attr_name("inputmode"), "inputMode");
6645 assert_eq!(component_attr_name("maxlength"), "maxLength");
6646 assert_eq!(component_attr_name("minlength"), "minLength");
6647 assert_eq!(component_attr_name("novalidate"), "noValidate");
6648 assert_eq!(component_attr_name("formaction"), "formAction");
6649 assert_eq!(component_attr_name("ismap"), "isMap");
6650 assert_eq!(component_attr_name("usemap"), "useMap");
6651 }
6652
6653 #[test]
6654 fn test_component_attr_name_strips_colon() {
6655 assert_eq!(component_attr_name(":readonly"), "readOnly");
6656 assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
6657 assert_eq!(component_attr_name(":data-title"), "dataTitle");
6658 }
6659
6660 #[test]
6661 fn test_component_attr_name_regular() {
6662 assert_eq!(component_attr_name("data-title"), "dataTitle");
6663 assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
6664 assert_eq!(component_attr_name("simple"), "simple");
6665 }
6666
6667 fn make_query_route_protocol() -> WebUIProtocol {
6670 use webui_protocol::WebUiFragmentRoute;
6671
6672 let mut fragments = HashMap::new();
6673
6674 fragments.insert(
6675 "index.html".to_string(),
6676 FragmentList {
6677 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
6678 path: "/".into(),
6679 fragment_id: "app-shell".into(),
6680 exact: false,
6681 children: vec![
6682 WebUiFragmentRoute {
6683 path: "compose".into(),
6684 fragment_id: "compose-page".into(),
6685 exact: true,
6686 allowed_query: "action,to,subject".into(),
6687 keep_alive: false,
6688 ..Default::default()
6689 },
6690 WebUiFragmentRoute {
6691 path: "settings".into(),
6692 fragment_id: "settings-page".into(),
6693 exact: true,
6694 keep_alive: false,
6695 ..Default::default()
6696 },
6697 ],
6698 keep_alive: false,
6699 ..Default::default()
6700 })],
6701 },
6702 );
6703
6704 fragments.insert(
6705 "app-shell".to_string(),
6706 FragmentList {
6707 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
6708 },
6709 );
6710 fragments.insert(
6711 "compose-page".to_string(),
6712 FragmentList {
6713 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
6714 },
6715 );
6716 fragments.insert(
6717 "settings-page".to_string(),
6718 FragmentList {
6719 fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
6720 },
6721 );
6722
6723 WebUIProtocol::new(fragments)
6724 }
6725
6726 #[test]
6727 fn test_matched_route_omits_query_attr_from_dom() {
6728 let protocol = make_query_route_protocol();
6729 let state = test_json!({});
6730 let handler = WebUIHandler::new();
6731 let mut writer = TestWriter::new();
6732
6733 handler
6734 .handle(
6735 &protocol,
6736 &state,
6737 &RenderOptions::new("index.html", "/compose"),
6738 &mut writer,
6739 )
6740 .expect("render failed");
6741
6742 let html = writer.get_content();
6743 assert!(
6745 !html.contains(r#"query="action,to,subject""#),
6746 "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
6747 );
6748 }
6749
6750 #[test]
6751 fn test_nonmatched_route_omits_query_attr_from_dom() {
6752 let protocol = make_query_route_protocol();
6753 let state = test_json!({});
6754 let handler = WebUIHandler::new();
6755 let mut writer = TestWriter::new();
6756
6757 handler
6758 .handle(
6759 &protocol,
6760 &state,
6761 &RenderOptions::new("index.html", "/settings"),
6762 &mut writer,
6763 )
6764 .expect("render failed");
6765
6766 let html = writer.get_content();
6767 assert!(
6769 !html.contains(r#"query="#),
6770 "hidden route should not have query attr: {html}"
6771 );
6772 }
6773
6774 #[test]
6775 fn test_route_without_query_has_no_query_attr() {
6776 let protocol = make_query_route_protocol();
6777 let state = test_json!({});
6778 let handler = WebUIHandler::new();
6779 let mut writer = TestWriter::new();
6780
6781 handler
6782 .handle(
6783 &protocol,
6784 &state,
6785 &RenderOptions::new("index.html", "/settings"),
6786 &mut writer,
6787 )
6788 .expect("render failed");
6789
6790 let html = writer.get_content();
6791 let settings_idx = html
6793 .find(r#"component="settings-page""#)
6794 .expect("settings route should exist");
6795 let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
6796 assert!(
6797 !settings_tag.contains("query="),
6798 "route without allowed_query should not emit query attr: {settings_tag}"
6799 );
6800 }
6801}