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