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