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