1use crate::route_matcher;
12use serde_json::Value;
13use std::collections::{HashMap, HashSet};
14use webui_protocol::{web_ui_fragment::Fragment, WebUIFragment, WebUIFragmentRoute, WebUIProtocol};
15
16fn component_bit_position(name: &str) -> u32 {
21 let mut hash: u32 = 0x811c_9dc5;
22 for byte in name.as_bytes() {
23 hash ^= u32::from(*byte);
24 hash = hash.wrapping_mul(0x0100_0193);
25 }
26 hash % 256
27}
28
29fn has_component(inventory: &[u8], name: &str) -> bool {
31 let bit = component_bit_position(name);
32 let byte_idx = (bit / 8) as usize;
33 let bit_idx = bit % 8;
34 byte_idx < inventory.len() && inventory[byte_idx] & (1 << bit_idx) != 0
35}
36
37pub fn parse_inventory(hex: &str) -> Vec<u8> {
39 let mut bytes = Vec::with_capacity(hex.len() / 2);
40 let mut chars = hex.bytes();
41 while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) {
42 if let (Some(hi_nibble), Some(lo_nibble)) = (hex_nibble(hi), hex_nibble(lo)) {
43 bytes.push((hi_nibble << 4) | lo_nibble);
44 }
45 }
46 bytes
47}
48
49fn hex_nibble(byte: u8) -> Option<u8> {
50 match byte {
51 b'0'..=b'9' => Some(byte - b'0'),
52 b'a'..=b'f' => Some(byte - b'a' + 10),
53 b'A'..=b'F' => Some(byte - b'A' + 10),
54 _ => None,
55 }
56}
57
58pub fn encode_inventory(inv: &[u8]) -> String {
60 inv.iter()
61 .fold(String::with_capacity(inv.len() * 2), |mut acc, b| {
62 use std::fmt::Write;
63 let _ = write!(acc, "{b:02x}");
64 acc
65 })
66}
67
68pub fn get_needed_components(
76 protocol: &WebUIProtocol,
77 entry_id: &str,
78 inventory_hex: &str,
79) -> (Vec<String>, String) {
80 let component_names = collect_inventoryable_components(protocol, entry_id, None, true);
81 filter_needed_components(&component_names, inventory_hex)
82}
83
84pub fn get_needed_components_for_request(
97 protocol: &WebUIProtocol,
98 entry_id: &str,
99 request_path: &str,
100 inventory_hex: &str,
101) -> (Vec<String>, String) {
102 let component_names =
103 collect_inventoryable_components(protocol, entry_id, Some(request_path), false);
104 filter_needed_components(&component_names, inventory_hex)
105}
106
107#[must_use]
116pub fn filter_needed_components(
117 component_names: &HashSet<String>,
118 inventory_hex: &str,
119) -> (Vec<String>, String) {
120 let client_inv = parse_inventory(inventory_hex);
121 let mut updated_inv = client_inv.clone();
122 updated_inv.resize(32, 0);
123
124 let mut ordered_names: Vec<&String> = component_names.iter().collect();
125 ordered_names.sort_unstable();
126
127 let mut needed = Vec::with_capacity(ordered_names.len());
128 for name in ordered_names {
129 if has_component(&client_inv, name) {
133 let bit = component_bit_position(name);
135 let byte_idx = (bit / 8) as usize;
136 let bit_idx = bit % 8;
137 if byte_idx < updated_inv.len() {
138 updated_inv[byte_idx] |= 1 << bit_idx;
139 }
140 continue;
141 }
142
143 let bit = component_bit_position(name);
144 let byte_idx = (bit / 8) as usize;
145 let bit_idx = bit % 8;
146 if byte_idx < updated_inv.len() {
147 updated_inv[byte_idx] |= 1 << bit_idx;
148 }
149 needed.push(name.clone());
150 }
151
152 (needed, encode_inventory(&updated_inv))
153}
154
155#[derive(Debug)]
156struct QueuedFragment {
157 id: String,
158 inventoryable: bool,
159 route_base: String,
161}
162
163fn collect_inventoryable_components(
173 protocol: &WebUIProtocol,
174 entry_id: &str,
175 request_path: Option<&str>,
176 root_inventoryable: bool,
177) -> HashSet<String> {
178 let mut visited_fragments = HashSet::new();
179 let mut component_ids = HashSet::new();
180 let mut stack = vec![QueuedFragment {
181 id: entry_id.to_string(),
182 inventoryable: root_inventoryable,
183 route_base: "/".to_string(),
184 }];
185
186 while let Some(queued) = stack.pop() {
187 if queued.id.is_empty() {
188 continue;
189 }
190
191 if queued.inventoryable {
192 component_ids.insert(queued.id.clone());
193 }
194
195 if !visited_fragments.insert(queued.id.clone()) {
196 continue;
197 }
198
199 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
200 continue;
201 };
202
203 let matched_route = request_path
204 .and_then(|path| find_best_route_match(&frag_list.fragments, path, &queued.route_base));
205
206 for frag in &frag_list.fragments {
207 match frag.fragment.as_ref() {
208 Some(Fragment::Component(component)) => {
209 stack.push(QueuedFragment {
210 id: component.fragment_id.clone(),
211 inventoryable: true,
212 route_base: queued.route_base.clone(),
213 });
214 }
215 Some(Fragment::ForLoop(for_loop)) => {
216 stack.push(QueuedFragment {
217 id: for_loop.fragment_id.clone(),
218 inventoryable: false,
219 route_base: queued.route_base.clone(),
220 });
221 }
222 Some(Fragment::IfCond(if_cond)) => {
223 stack.push(QueuedFragment {
224 id: if_cond.fragment_id.clone(),
225 inventoryable: false,
226 route_base: queued.route_base.clone(),
227 });
228 }
229 Some(Fragment::Attribute(attr)) if !attr.template.is_empty() => {
230 stack.push(QueuedFragment {
231 id: attr.template.clone(),
232 inventoryable: false,
233 route_base: queued.route_base.clone(),
234 });
235 }
236 Some(Fragment::Route(route_frag)) => {
237 let is_selected = matched_route
238 .as_ref()
239 .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
240 if is_selected && !route_frag.fragment_id.is_empty() {
241 let child_route_base = if let Some((_, ref rm)) = matched_route {
243 if let Some(path) = request_path {
244 route_matcher::compute_route_base(path, rm.consumed_segments)
245 } else {
246 queued.route_base.clone()
247 }
248 } else {
249 queued.route_base.clone()
250 };
251
252 stack.push(QueuedFragment {
253 id: route_frag.fragment_id.clone(),
254 inventoryable: protocol
255 .components
256 .get(&route_frag.fragment_id)
257 .is_some_and(|c| !c.template.is_empty()),
258 route_base: child_route_base.clone(),
259 });
260
261 if !route_frag.children.is_empty() {
265 if let Some(path) = request_path {
266 walk_route_children(
267 &route_frag.children,
268 path,
269 &child_route_base,
270 protocol,
271 &mut stack,
272 );
273 }
274 }
275 }
276 }
277 _ => {}
278 }
279 }
280 }
281
282 component_ids
283}
284
285fn walk_route_children(
288 children: &[WebUIFragmentRoute],
289 request_path: &str,
290 route_base: &str,
291 protocol: &WebUIProtocol,
292 stack: &mut Vec<QueuedFragment>,
293) {
294 let mut current = children;
295 let mut base = route_base.to_string();
296
297 loop {
298 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
299 for (idx, child) in current.iter().enumerate() {
300 let resolved = route_matcher::resolve_route_path(&child.path, &base);
301 if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
302 {
303 let is_better = best
304 .as_ref()
305 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
306 if is_better {
307 best = Some((idx, m));
308 }
309 }
310 }
311
312 let Some((idx, ref rm)) = best else { break };
313 let matched = ¤t[idx];
314 if matched.fragment_id.is_empty() {
315 break;
316 }
317
318 let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
319
320 stack.push(QueuedFragment {
321 id: matched.fragment_id.clone(),
322 inventoryable: protocol
323 .components
324 .get(&matched.fragment_id)
325 .is_some_and(|c| !c.template.is_empty()),
326 route_base: child_base.clone(),
327 });
328
329 if matched.children.is_empty() {
330 break;
331 }
332 current = &matched.children;
333 base = child_base;
334 }
335}
336
337fn find_best_route_match(
341 fragments: &[WebUIFragment],
342 request_path: &str,
343 route_base: &str,
344) -> Option<(String, route_matcher::RouteMatch)> {
345 let mut best: Option<(String, route_matcher::RouteMatch)> = None;
346
347 for item in fragments {
348 if let Some(Fragment::Route(route_frag)) = item.fragment.as_ref() {
349 let resolved_path = route_matcher::resolve_route_path(&route_frag.path, route_base);
350 if let Some(m) =
351 route_matcher::match_single_route(&resolved_path, request_path, route_frag.exact)
352 {
353 let is_better = best
354 .as_ref()
355 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
356
357 if is_better {
358 best = Some((route_fragment_key(route_frag).to_string(), m));
359 }
360 }
361 }
362 }
363
364 best
365}
366
367fn route_fragment_key(route_frag: &WebUIFragmentRoute) -> &str {
368 route_frag.fragment_id.as_str()
369}
370
371pub fn collect_nested_route_params(
376 protocol: &WebUIProtocol,
377 entry_id: &str,
378 request_path: &str,
379) -> HashMap<String, String> {
380 let mut all_params = HashMap::new();
381 let mut visited_fragments = HashSet::new();
382 let mut stack = vec![QueuedFragment {
383 id: entry_id.to_string(),
384 inventoryable: false,
385 route_base: "/".to_string(),
386 }];
387
388 while let Some(queued) = stack.pop() {
389 if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
390 continue;
391 }
392
393 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
394 continue;
395 };
396
397 let matched_route =
398 find_best_route_match(&frag_list.fragments, request_path, &queued.route_base);
399
400 for frag in &frag_list.fragments {
401 match frag.fragment.as_ref() {
402 Some(Fragment::Component(component)) => {
403 stack.push(QueuedFragment {
404 id: component.fragment_id.clone(),
405 inventoryable: false,
406 route_base: queued.route_base.clone(),
407 });
408 }
409 Some(Fragment::Route(route_frag)) => {
410 let is_selected = matched_route
411 .as_ref()
412 .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
413 if is_selected && !route_frag.fragment_id.is_empty() {
414 if let Some((_, ref rm)) = matched_route {
415 all_params.extend(rm.params.clone());
417
418 let child_route_base = route_matcher::compute_route_base(
419 request_path,
420 rm.consumed_segments,
421 );
422
423 stack.push(QueuedFragment {
424 id: route_frag.fragment_id.clone(),
425 inventoryable: false,
426 route_base: child_route_base.clone(),
427 });
428
429 collect_params_from_children(
431 &route_frag.children,
432 request_path,
433 &child_route_base,
434 &mut all_params,
435 );
436 }
437 }
438 }
439 _ => {}
440 }
441 }
442 }
443
444 all_params
445}
446
447fn collect_params_from_children(
449 children: &[WebUIFragmentRoute],
450 request_path: &str,
451 route_base: &str,
452 all_params: &mut HashMap<String, String>,
453) {
454 let mut current = children;
455 let mut base = route_base.to_string();
456
457 loop {
458 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
459 for (idx, child) in current.iter().enumerate() {
460 let resolved = route_matcher::resolve_route_path(&child.path, &base);
461 if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
462 {
463 let is_better = best
464 .as_ref()
465 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
466 if is_better {
467 best = Some((idx, m));
468 }
469 }
470 }
471
472 let Some((idx, rm)) = best else { break };
473 all_params.extend(rm.params);
474 let matched = ¤t[idx];
475 if matched.children.is_empty() {
476 break;
477 }
478 let child_base = route_matcher::compute_route_base(request_path, rm.consumed_segments);
479 current = &matched.children;
480 base = child_base;
481 }
482}
483
484#[must_use]
500pub fn render_partial(
501 protocol: &WebUIProtocol,
502 state: Value,
503 entry_id: &str,
504 request_path: &str,
505 inventory_hex: &str,
506) -> Value {
507 let (needed_names, updated_inv) =
509 get_needed_components_for_request(protocol, entry_id, request_path, inventory_hex);
510
511 let chain = collect_route_chain(protocol, entry_id, request_path);
513
514 let mut style_array = Vec::new();
522 let mut tmpl_array = Vec::with_capacity(needed_names.len());
523 let mut sorted_names: Vec<&String> = needed_names.iter().collect();
524 sorted_names.sort_unstable();
525 for name in sorted_names {
526 let Some(component) = protocol.components.get(name) else {
527 continue;
528 };
529 if component.template.is_empty() {
530 continue;
531 }
532 if !component.css.is_empty() {
533 let mut s = String::with_capacity(40 + name.len() + component.css.len());
534 s.push_str("<style type=\"module\" specifier=\"");
535 s.push_str(name);
536 s.push_str("\">");
537 s.push_str(&component.css);
538 s.push_str("</style>");
539 style_array.push(Value::String(s));
540 }
541 tmpl_array.push(Value::String(component.template.clone()));
542 }
543
544 let chain_array = Value::Array(chain.iter().map(RouteChainEntry::to_json).collect());
545
546 let mut result = serde_json::Map::with_capacity(6);
547 result.insert("state".into(), state);
548 result.insert("templateStyles".into(), Value::Array(style_array));
549 result.insert("templates".into(), Value::Array(tmpl_array));
550 result.insert("inventory".into(), Value::String(updated_inv));
551 result.insert("path".into(), Value::String(request_path.to_string()));
552 result.insert("chain".into(), chain_array);
553 Value::Object(result)
554}
555
556#[derive(Debug, Clone)]
560pub struct RouteChainEntry {
561 pub component: String,
563 pub path: String,
565 pub params: HashMap<String, String>,
567 pub exact: bool,
569}
570
571impl RouteChainEntry {
572 #[must_use]
574 pub fn to_json(&self) -> Value {
575 let mut obj = serde_json::Map::with_capacity(4);
576 obj.insert("component".into(), Value::String(self.component.clone()));
577 obj.insert("path".into(), Value::String(self.path.clone()));
578 if !self.params.is_empty() {
579 let params_obj: serde_json::Map<String, Value> = self
580 .params
581 .iter()
582 .map(|(k, v)| (k.clone(), Value::String(v.clone())))
583 .collect();
584 obj.insert("params".into(), Value::Object(params_obj));
585 }
586 if self.exact {
587 obj.insert("exact".into(), Value::Bool(true));
588 }
589 Value::Object(obj)
590 }
591}
592
593#[must_use]
600pub fn collect_route_chain_json(
601 protocol: &WebUIProtocol,
602 entry_id: &str,
603 request_path: &str,
604) -> Value {
605 let chain = collect_route_chain(protocol, entry_id, request_path);
606 Value::Array(chain.iter().map(RouteChainEntry::to_json).collect())
607}
608
609pub fn collect_route_chain(
614 protocol: &WebUIProtocol,
615 entry_id: &str,
616 request_path: &str,
617) -> Vec<RouteChainEntry> {
618 let mut chain = Vec::new();
619 let mut visited_fragments = HashSet::new();
620 let mut stack = vec![QueuedFragment {
621 id: entry_id.to_string(),
622 inventoryable: false,
623 route_base: "/".to_string(),
624 }];
625
626 while let Some(queued) = stack.pop() {
627 if queued.id.is_empty() || !visited_fragments.insert(queued.id.clone()) {
628 continue;
629 }
630
631 let Some(frag_list) = protocol.fragments.get(&queued.id) else {
632 continue;
633 };
634
635 let matched_route =
636 find_best_route_match(&frag_list.fragments, request_path, &queued.route_base);
637
638 for frag in &frag_list.fragments {
639 match frag.fragment.as_ref() {
640 Some(Fragment::Component(component)) => {
641 stack.push(QueuedFragment {
642 id: component.fragment_id.clone(),
643 inventoryable: false,
644 route_base: queued.route_base.clone(),
645 });
646 }
647 Some(Fragment::Route(route_frag)) => {
648 let is_selected = matched_route
649 .as_ref()
650 .is_some_and(|(best_key, _)| best_key == route_fragment_key(route_frag));
651 if is_selected && !route_frag.fragment_id.is_empty() {
652 if let Some((_, ref rm)) = matched_route {
653 chain.push(RouteChainEntry {
654 component: route_frag.fragment_id.clone(),
655 path: route_frag.path.clone(),
656 params: rm.params.clone(),
657 exact: route_frag.exact,
658 });
659
660 let child_route_base = route_matcher::compute_route_base(
661 request_path,
662 rm.consumed_segments,
663 );
664
665 stack.push(QueuedFragment {
666 id: route_frag.fragment_id.clone(),
667 inventoryable: false,
668 route_base: child_route_base.clone(),
669 });
670
671 collect_chain_from_children(
673 &route_frag.children,
674 request_path,
675 &child_route_base,
676 &mut chain,
677 );
678 }
679 }
680 }
681 _ => {}
682 }
683 }
684 }
685
686 chain
687}
688
689fn collect_chain_from_children(
691 children: &[WebUIFragmentRoute],
692 request_path: &str,
693 route_base: &str,
694 chain: &mut Vec<RouteChainEntry>,
695) {
696 let mut pending: Vec<(&[WebUIFragmentRoute], String)> =
697 vec![(children, route_base.to_string())];
698
699 while let Some((current, base)) = pending.pop() {
700 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
701 for (idx, child) in current.iter().enumerate() {
702 let resolved = route_matcher::resolve_route_path(&child.path, &base);
703 if let Some(m) = route_matcher::match_single_route(&resolved, request_path, child.exact)
704 {
705 let is_better = best
706 .as_ref()
707 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
708 if is_better {
709 best = Some((idx, m));
710 }
711 }
712 }
713
714 if let Some((idx, rm)) = best {
715 let matched = ¤t[idx];
716 chain.push(RouteChainEntry {
717 component: matched.fragment_id.clone(),
718 path: matched.path.clone(),
719 params: rm.params,
720 exact: matched.exact,
721 });
722 if !matched.children.is_empty() {
723 let child_base =
724 route_matcher::compute_route_base(request_path, rm.consumed_segments);
725 pending.push((&matched.children, child_base));
726 }
727 }
728 }
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734 use std::collections::HashMap;
735 use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
736
737 #[test]
738 fn test_component_bit_position_deterministic() {
739 let pos1 = component_bit_position("my-component");
740 let pos2 = component_bit_position("my-component");
741 assert_eq!(pos1, pos2);
742 assert!(pos1 < 256);
743 }
744
745 #[test]
746 fn test_has_component_present() {
747 let mut inv = vec![0u8; 32];
748 let bit = component_bit_position("test-comp");
749 let byte_idx = (bit / 8) as usize;
750 let bit_idx = bit % 8;
751 inv[byte_idx] |= 1 << bit_idx;
752 assert!(has_component(&inv, "test-comp"));
753 }
754
755 #[test]
756 fn test_has_component_absent() {
757 let inv = vec![0u8; 32];
758 assert!(!has_component(&inv, "test-comp"));
759 }
760
761 #[test]
762 fn test_parse_encode_inventory_roundtrip() {
763 let original = vec![0xABu8, 0xCD, 0xEF, 0x01];
764 let hex = encode_inventory(&original);
765 let decoded = parse_inventory(&hex);
766 assert_eq!(original, decoded);
767 }
768
769 #[test]
770 fn test_filter_needed_components_hash_collision_does_not_drop_components() {
771 assert_eq!(
774 component_bit_position("mp-app"),
775 component_bit_position("mp-cart-panel"),
776 "test precondition: mp-app and mp-cart-panel should collide"
777 );
778
779 let mut names = HashSet::new();
780 names.insert("mp-app".to_string());
781 names.insert("mp-cart-panel".to_string());
782 names.insert("mp-footer".to_string());
783
784 let (needed, _inv) = filter_needed_components(&names, "");
785 assert!(
786 needed.contains(&"mp-app".to_string()),
787 "mp-app should be needed: {needed:?}"
788 );
789 assert!(
790 needed.contains(&"mp-cart-panel".to_string()),
791 "mp-cart-panel should be needed despite hash collision with mp-app: {needed:?}"
792 );
793 assert!(
794 needed.contains(&"mp-footer".to_string()),
795 "mp-footer should be needed: {needed:?}"
796 );
797 }
798
799 #[test]
800 fn test_get_needed_components_empty_inventory() {
801 let mut fragments = HashMap::new();
802 fragments.insert(
803 "app-shell".to_string(),
804 FragmentList {
805 fragments: vec![WebUIFragment::component("my-card")],
806 },
807 );
808 fragments.insert(
809 "my-card".to_string(),
810 FragmentList {
811 fragments: vec![WebUIFragment::raw("card")],
812 },
813 );
814
815 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
816 let (needed, _inv) = get_needed_components(&protocol, "app-shell", "");
817 assert!(needed.contains(&"app-shell".to_string()));
818 assert!(needed.contains(&"my-card".to_string()));
819 }
820
821 #[test]
822 fn test_get_needed_components_skips_inventory() {
823 let mut fragments = HashMap::new();
824 fragments.insert(
825 "app-shell".to_string(),
826 FragmentList {
827 fragments: vec![WebUIFragment::component("my-card")],
828 },
829 );
830 fragments.insert(
831 "my-card".to_string(),
832 FragmentList {
833 fragments: vec![WebUIFragment::raw("card")],
834 },
835 );
836
837 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
838
839 let (_needed, inv_hex) = get_needed_components(&protocol, "app-shell", "");
840 let (needed2, _) = get_needed_components(&protocol, "app-shell", &inv_hex);
841 assert!(needed2.is_empty());
842 }
843
844 #[test]
845 fn test_get_needed_components_skips_control_fragments() {
846 let mut fragments = HashMap::new();
847 fragments.insert(
848 "app-shell".to_string(),
849 FragmentList {
850 fragments: vec![
851 WebUIFragment::if_cond(
852 webui_protocol::ConditionExpr::identifier("showNav"),
853 "if-shell",
854 ),
855 WebUIFragment::for_loop("item", "items", "for-items"),
856 WebUIFragment::attribute_template("title", "attr-title"),
857 ],
858 },
859 );
860 fragments.insert(
861 "if-shell".to_string(),
862 FragmentList {
863 fragments: vec![WebUIFragment::component("mp-category-nav")],
864 },
865 );
866 fragments.insert(
867 "for-items".to_string(),
868 FragmentList {
869 fragments: vec![WebUIFragment::component("mp-product-card")],
870 },
871 );
872 fragments.insert(
873 "attr-title".to_string(),
874 FragmentList {
875 fragments: vec![WebUIFragment::raw("Products")],
876 },
877 );
878 fragments.insert(
879 "mp-category-nav".to_string(),
880 FragmentList {
881 fragments: vec![WebUIFragment::raw("<nav></nav>")],
882 },
883 );
884 fragments.insert(
885 "mp-product-card".to_string(),
886 FragmentList {
887 fragments: vec![WebUIFragment::raw("<article></article>")],
888 },
889 );
890
891 let protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
892 let needed: HashSet<String> = get_needed_components(&protocol, "app-shell", "")
893 .0
894 .into_iter()
895 .collect();
896
897 assert!(needed.contains("app-shell"));
898 assert!(needed.contains("mp-category-nav"));
899 assert!(needed.contains("mp-product-card"));
900 assert!(!needed.contains("if-shell"));
901 assert!(!needed.contains("for-items"));
902 assert!(!needed.contains("attr-title"));
903 }
904
905 #[test]
906 fn test_get_needed_components_for_request_includes_shell_and_matched_route_chain() {
907 let mut fragments = HashMap::new();
908 fragments.insert(
909 "index.html".to_string(),
910 FragmentList {
911 fragments: vec![WebUIFragment::component("mp-app")],
912 },
913 );
914 fragments.insert(
915 "mp-app".to_string(),
916 FragmentList {
917 fragments: vec![
918 WebUIFragment::component("mp-category-nav"),
919 WebUIFragment::route_from(WebUiFragmentRoute {
920 path: "/search/:category".into(),
921 fragment_id: "mp-search-page".into(),
922 exact: true,
923 ..Default::default()
924 }),
925 WebUIFragment::route_from(WebUiFragmentRoute {
926 path: "/product/:handle".into(),
927 fragment_id: "mp-product-page".into(),
928 exact: true,
929 ..Default::default()
930 }),
931 ],
932 },
933 );
934 fragments.insert(
935 "mp-category-nav".to_string(),
936 FragmentList {
937 fragments: vec![WebUIFragment::raw("<nav></nav>")],
938 },
939 );
940 fragments.insert(
941 "mp-search-page".to_string(),
942 FragmentList {
943 fragments: vec![WebUIFragment::component("mp-product-grid")],
944 },
945 );
946 fragments.insert(
947 "mp-product-grid".to_string(),
948 FragmentList {
949 fragments: vec![WebUIFragment::raw("<div>grid</div>")],
950 },
951 );
952 fragments.insert(
953 "mp-product-page".to_string(),
954 FragmentList {
955 fragments: vec![WebUIFragment::component("mp-product-detail")],
956 },
957 );
958 fragments.insert(
959 "mp-product-detail".to_string(),
960 FragmentList {
961 fragments: vec![WebUIFragment::raw("<div>detail</div>")],
962 },
963 );
964
965 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
966 protocol
967 .components
968 .entry("mp-search-page".to_string())
969 .or_default()
970 .template = "<mp-search-page></mp-search-page>".to_string();
971 protocol
972 .components
973 .entry("mp-product-page".to_string())
974 .or_default()
975 .template = "<mp-product-page></mp-product-page>".to_string();
976
977 let (needed, _inv) =
978 get_needed_components_for_request(&protocol, "index.html", "/search/shirts", "");
979 let needed: HashSet<String> = needed.into_iter().collect();
980
981 assert!(needed.contains("mp-app"));
982 assert!(needed.contains("mp-category-nav"));
983 assert!(needed.contains("mp-search-page"));
984 assert!(needed.contains("mp-product-grid"));
985 assert!(!needed.contains("mp-product-page"));
986 assert!(!needed.contains("mp-product-detail"));
987 }
988
989 #[test]
990 fn test_get_needed_components_for_request_follows_nested_route_chain() {
991 let mut fragments = HashMap::new();
992 fragments.insert(
993 "index.html".to_string(),
994 FragmentList {
995 fragments: vec![WebUIFragment::component("mp-app")],
996 },
997 );
998 fragments.insert(
999 "mp-app".to_string(),
1000 FragmentList {
1001 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1002 path: "/account/*rest".into(),
1003 fragment_id: "mp-account-shell".into(),
1004 exact: false,
1005 ..Default::default()
1006 })],
1007 },
1008 );
1009 fragments.insert(
1010 "mp-account-shell".to_string(),
1011 FragmentList {
1012 fragments: vec![
1013 WebUIFragment::component("mp-account-nav"),
1014 WebUIFragment::route_from(WebUiFragmentRoute {
1015 path: "/account/profile".into(),
1016 fragment_id: "mp-profile-page".into(),
1017 exact: true,
1018 ..Default::default()
1019 }),
1020 WebUIFragment::route_from(WebUiFragmentRoute {
1021 path: "/account/orders/:id".into(),
1022 fragment_id: "mp-order-page".into(),
1023 exact: true,
1024 ..Default::default()
1025 }),
1026 ],
1027 },
1028 );
1029 fragments.insert(
1030 "mp-account-nav".to_string(),
1031 FragmentList {
1032 fragments: vec![WebUIFragment::raw("<nav></nav>")],
1033 },
1034 );
1035 fragments.insert(
1036 "mp-profile-page".to_string(),
1037 FragmentList {
1038 fragments: vec![WebUIFragment::raw("<profile></profile>")],
1039 },
1040 );
1041 fragments.insert(
1042 "mp-order-page".to_string(),
1043 FragmentList {
1044 fragments: vec![WebUIFragment::component("mp-order-detail")],
1045 },
1046 );
1047 fragments.insert(
1048 "mp-order-detail".to_string(),
1049 FragmentList {
1050 fragments: vec![WebUIFragment::raw("<detail></detail>")],
1051 },
1052 );
1053
1054 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1055 protocol
1056 .components
1057 .entry("mp-account-shell".to_string())
1058 .or_default()
1059 .template = "<mp-account-shell></mp-account-shell>".to_string();
1060 protocol
1061 .components
1062 .entry("mp-profile-page".to_string())
1063 .or_default()
1064 .template = "<mp-profile-page></mp-profile-page>".to_string();
1065 protocol
1066 .components
1067 .entry("mp-order-page".to_string())
1068 .or_default()
1069 .template = "<mp-order-page></mp-order-page>".to_string();
1070
1071 let (needed, _inv) =
1072 get_needed_components_for_request(&protocol, "index.html", "/account/orders/42", "");
1073 let needed: HashSet<String> = needed.into_iter().collect();
1074
1075 assert!(needed.contains("mp-app"));
1076 assert!(needed.contains("mp-account-shell"));
1077 assert!(needed.contains("mp-account-nav"));
1078 assert!(needed.contains("mp-order-page"));
1079 assert!(needed.contains("mp-order-detail"));
1080 assert!(!needed.contains("mp-profile-page"));
1081 }
1082
1083 #[test]
1084 fn test_get_needed_components_for_request_over_ships_if_and_loop_within_matched_route() {
1085 let mut fragments = HashMap::new();
1086 fragments.insert(
1087 "index.html".to_string(),
1088 FragmentList {
1089 fragments: vec![WebUIFragment::component("mp-app")],
1090 },
1091 );
1092 fragments.insert(
1093 "mp-app".to_string(),
1094 FragmentList {
1095 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1096 path: "/search".into(),
1097 fragment_id: "mp-search-page".into(),
1098 exact: true,
1099 ..Default::default()
1100 })],
1101 },
1102 );
1103 fragments.insert(
1104 "mp-search-page".to_string(),
1105 FragmentList {
1106 fragments: vec![
1107 WebUIFragment::if_cond(
1108 webui_protocol::ConditionExpr::identifier("showFilters"),
1109 "if-filters",
1110 ),
1111 WebUIFragment::for_loop("item", "items", "item-loop"),
1112 ],
1113 },
1114 );
1115 fragments.insert(
1116 "if-filters".to_string(),
1117 FragmentList {
1118 fragments: vec![WebUIFragment::component("mp-filter-panel")],
1119 },
1120 );
1121 fragments.insert(
1122 "item-loop".to_string(),
1123 FragmentList {
1124 fragments: vec![WebUIFragment::component("mp-item-card")],
1125 },
1126 );
1127 fragments.insert(
1128 "mp-filter-panel".to_string(),
1129 FragmentList {
1130 fragments: vec![WebUIFragment::raw("<filters></filters>")],
1131 },
1132 );
1133 fragments.insert(
1134 "mp-item-card".to_string(),
1135 FragmentList {
1136 fragments: vec![WebUIFragment::raw("<item></item>")],
1137 },
1138 );
1139
1140 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1141 protocol
1142 .components
1143 .entry("mp-search-page".to_string())
1144 .or_default()
1145 .template = "<mp-search-page></mp-search-page>".to_string();
1146
1147 let (needed, _inv) =
1148 get_needed_components_for_request(&protocol, "index.html", "/search", "");
1149 let needed: HashSet<String> = needed.into_iter().collect();
1150
1151 assert!(needed.contains("mp-app"));
1152 assert!(needed.contains("mp-search-page"));
1153 assert!(needed.contains("mp-filter-panel"));
1154 assert!(needed.contains("mp-item-card"));
1155 }
1156
1157 #[test]
1158 fn test_get_needed_components_for_request_respects_inventory() {
1159 let mut fragments = HashMap::new();
1160 fragments.insert(
1161 "index.html".to_string(),
1162 FragmentList {
1163 fragments: vec![WebUIFragment::component("mp-app")],
1164 },
1165 );
1166 fragments.insert(
1167 "mp-app".to_string(),
1168 FragmentList {
1169 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
1170 path: "/search".into(),
1171 fragment_id: "mp-search-page".into(),
1172 exact: true,
1173 ..Default::default()
1174 })],
1175 },
1176 );
1177 fragments.insert(
1178 "mp-search-page".to_string(),
1179 FragmentList {
1180 fragments: vec![WebUIFragment::component("mp-product-grid")],
1181 },
1182 );
1183 fragments.insert(
1184 "mp-product-grid".to_string(),
1185 FragmentList {
1186 fragments: vec![WebUIFragment::raw("<grid></grid>")],
1187 },
1188 );
1189
1190 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1191 protocol
1192 .components
1193 .entry("mp-search-page".to_string())
1194 .or_default()
1195 .template = "<mp-search-page></mp-search-page>".to_string();
1196
1197 let (_needed, inventory) =
1198 get_needed_components_for_request(&protocol, "index.html", "/search", "");
1199 let (needed_again, _) =
1200 get_needed_components_for_request(&protocol, "index.html", "/search", &inventory);
1201 assert!(needed_again.is_empty());
1202 }
1203
1204 #[test]
1205 fn test_render_partial_separates_module_styles_from_templates() {
1206 let mut fragments = HashMap::new();
1207 fragments.insert(
1208 "index.html".to_string(),
1209 FragmentList {
1210 fragments: vec![WebUIFragment::component("my-page")],
1211 },
1212 );
1213 fragments.insert(
1214 "my-page".to_string(),
1215 FragmentList {
1216 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1217 },
1218 );
1219
1220 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1221 let component = protocol
1222 .components
1223 .entry("my-page".to_string())
1224 .or_default();
1225 component.template =
1226 "(function(){window.__webui_templates['my-page']={h:'<p>page</p>'};})();".to_string();
1227 component.css = ".page{color:red}".to_string();
1228
1229 let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1230
1231 let styles = partial["templateStyles"]
1233 .as_array()
1234 .expect("templateStyles should be an array");
1235 assert_eq!(styles.len(), 1);
1236 assert!(
1237 styles[0]
1238 .as_str()
1239 .unwrap_or_default()
1240 .contains("specifier=\"my-page\""),
1241 "module style should carry the component specifier"
1242 );
1243 assert!(
1244 styles[0]
1245 .as_str()
1246 .unwrap_or_default()
1247 .contains(".page{color:red}"),
1248 "module style should contain the CSS content"
1249 );
1250
1251 let templates = partial["templates"]
1253 .as_array()
1254 .expect("templates should be an array");
1255 assert_eq!(templates.len(), 1);
1256 assert!(
1257 !templates[0].as_str().unwrap_or_default().contains("<style"),
1258 "template should not contain any style tags"
1259 );
1260 assert!(
1261 templates[0]
1262 .as_str()
1263 .unwrap_or_default()
1264 .starts_with("(function()"),
1265 "template should be a raw JS IIFE"
1266 );
1267 }
1268
1269 #[test]
1270 fn test_render_partial_link_strategy_has_empty_template_styles() {
1271 let mut fragments = HashMap::new();
1274 fragments.insert(
1275 "index.html".to_string(),
1276 FragmentList {
1277 fragments: vec![WebUIFragment::component("my-page")],
1278 },
1279 );
1280 fragments.insert(
1281 "my-page".to_string(),
1282 FragmentList {
1283 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1284 },
1285 );
1286
1287 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1288 let component = protocol
1289 .components
1290 .entry("my-page".to_string())
1291 .or_default();
1292 component.template = "(function(){})();".to_string();
1293 component.css_href = "/my-page.css".to_string();
1294 let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1297 let styles = partial["templateStyles"]
1298 .as_array()
1299 .expect("templateStyles should be an array");
1300 let templates = partial["templates"]
1301 .as_array()
1302 .expect("templates should be an array");
1303
1304 assert!(
1305 styles.is_empty(),
1306 "Link strategy should produce empty templateStyles: {styles:?}"
1307 );
1308 assert_eq!(templates.len(), 1, "should include the template IIFE");
1309 }
1310
1311 #[test]
1312 fn test_render_partial_style_strategy_has_empty_template_styles() {
1313 let mut fragments = HashMap::new();
1317 fragments.insert(
1318 "index.html".to_string(),
1319 FragmentList {
1320 fragments: vec![WebUIFragment::component("my-page")],
1321 },
1322 );
1323 fragments.insert(
1324 "my-page".to_string(),
1325 FragmentList {
1326 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1327 },
1328 );
1329
1330 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1331 let component = protocol
1332 .components
1333 .entry("my-page".to_string())
1334 .or_default();
1335 component.template =
1337 "(function(){var w=window.__webui_templates;w['my-page']={h:'<style>.p{color:red}</style><p>page</p>'};})();"
1338 .to_string();
1339 let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1342 let styles = partial["templateStyles"]
1343 .as_array()
1344 .expect("templateStyles should be an array");
1345 let templates = partial["templates"]
1346 .as_array()
1347 .expect("templates should be an array");
1348
1349 assert!(
1350 styles.is_empty(),
1351 "Style strategy should produce empty templateStyles: {styles:?}"
1352 );
1353 assert_eq!(templates.len(), 1, "should include the template IIFE");
1354 assert!(
1355 templates[0]
1356 .as_str()
1357 .unwrap_or_default()
1358 .contains("<style>"),
1359 "Style strategy template should contain inline <style> tag"
1360 );
1361 }
1362
1363 #[test]
1364 fn test_render_partial_empty_styles_for_no_css_components() {
1365 let mut fragments = HashMap::new();
1366 fragments.insert(
1367 "index.html".to_string(),
1368 FragmentList {
1369 fragments: vec![WebUIFragment::component("my-page")],
1370 },
1371 );
1372 fragments.insert(
1373 "my-page".to_string(),
1374 FragmentList {
1375 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1376 },
1377 );
1378
1379 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1380 let component = protocol
1381 .components
1382 .entry("my-page".to_string())
1383 .or_default();
1384 component.template = "(function(){})();".to_string();
1385 let partial = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1388 let styles = partial["templateStyles"]
1389 .as_array()
1390 .expect("templateStyles should be an array");
1391 assert!(
1392 styles.is_empty(),
1393 "templateStyles should be empty when components have no CSS"
1394 );
1395 }
1396
1397 #[test]
1398 fn test_render_partial_sends_styles_even_when_templates_filtered_by_inventory() {
1399 let mut fragments = HashMap::new();
1400 fragments.insert(
1401 "index.html".to_string(),
1402 FragmentList {
1403 fragments: vec![WebUIFragment::component("my-page")],
1404 },
1405 );
1406 fragments.insert(
1407 "my-page".to_string(),
1408 FragmentList {
1409 fragments: vec![WebUIFragment::raw("<p>page</p>")],
1410 },
1411 );
1412
1413 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1414 let component = protocol
1415 .components
1416 .entry("my-page".to_string())
1417 .or_default();
1418 component.template = "(function(){})();".to_string();
1419 component.css = ".page{color:red}".to_string();
1420
1421 let partial1 = render_partial(&protocol, serde_json::json!({}), "index.html", "/", "");
1423 let inv = partial1["inventory"].as_str().unwrap_or_default();
1424 assert!(!inv.is_empty());
1425
1426 let partial2 = render_partial(&protocol, serde_json::json!({}), "index.html", "/", inv);
1431 let styles = partial2["templateStyles"]
1432 .as_array()
1433 .expect("templateStyles should be an array");
1434 let templates = partial2["templates"]
1435 .as_array()
1436 .expect("templates should be an array");
1437
1438 assert!(
1439 templates.is_empty(),
1440 "templates should be empty when inventory is full"
1441 );
1442 assert!(
1443 styles.is_empty(),
1444 "module styles should be empty when inventory is full — SSR already placed them"
1445 );
1446 }
1447
1448 #[test]
1449 fn test_non_route_siblings_included_in_needed_components() {
1450 let mut fragments = HashMap::new();
1455
1456 fragments.insert(
1458 "index.html".to_string(),
1459 FragmentList {
1460 fragments: vec![WebUIFragment::component("app-shell")],
1461 },
1462 );
1463
1464 fragments.insert(
1466 "app-shell".to_string(),
1467 FragmentList {
1468 fragments: vec![
1469 WebUIFragment::component("my-navbar"),
1470 WebUIFragment::route_from(WebUiFragmentRoute {
1471 path: "/about".into(),
1472 fragment_id: "page-about".into(),
1473 exact: true,
1474 ..Default::default()
1475 }),
1476 WebUIFragment::component("cart-panel"),
1477 ],
1478 },
1479 );
1480
1481 fragments.insert(
1483 "my-navbar".to_string(),
1484 FragmentList {
1485 fragments: vec![WebUIFragment::raw("<nav/>")],
1486 },
1487 );
1488 fragments.insert(
1489 "page-about".to_string(),
1490 FragmentList {
1491 fragments: vec![WebUIFragment::raw("<h1>About</h1>")],
1492 },
1493 );
1494 fragments.insert(
1495 "cart-panel".to_string(),
1496 FragmentList {
1497 fragments: vec![WebUIFragment::raw("<aside>Cart</aside>")],
1498 },
1499 );
1500
1501 let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
1502 for name in ["app-shell", "my-navbar", "page-about", "cart-panel"] {
1503 let comp = protocol.components.entry(name.to_string()).or_default();
1504 comp.template = format!("(function(){{/* {name} */}})();");
1505 comp.css = format!(".{name}{{display:block}}");
1506 }
1507
1508 let (needed, _inv) =
1509 get_needed_components_for_request(&protocol, "index.html", "/about", "");
1510
1511 assert!(
1512 needed.contains(&"app-shell".to_string()),
1513 "app-shell should be needed: {needed:?}"
1514 );
1515 assert!(
1516 needed.contains(&"my-navbar".to_string()),
1517 "my-navbar (non-route sibling) should be needed: {needed:?}"
1518 );
1519 assert!(
1520 needed.contains(&"page-about".to_string()),
1521 "page-about (active route) should be needed: {needed:?}"
1522 );
1523 assert!(
1524 needed.contains(&"cart-panel".to_string()),
1525 "cart-panel (non-route sibling) should be needed: {needed:?}"
1526 );
1527 }
1528}