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