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