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