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