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