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