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