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