1use std::collections::BTreeMap;
4
5use crate::{
6 ApplicationSemanticModel, ComponentInstancePlan, ComponentInvocationEntity,
7 ComponentInvocationId, ComponentInvocationResolutionStatus, ComponentNode, FileRouteGraphV1,
8 SemanticId, SlotKind, SourceProvenance, TemplatePositionId, TemplateSemanticEntity,
9};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct LayoutCompositionPlanV1 {
13 pub routes: Vec<LayoutCompositionRouteV1>,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct LayoutCompositionRouteV1 {
18 pub path: String,
19 pub page: SemanticId,
20 pub layouts: Vec<SemanticId>,
22 pub edges: Vec<LayoutCompositionEdgeV1>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct LayoutCompositionEdgeV1 {
29 pub invocation: ComponentInvocationId,
30 pub caller: SemanticId,
31 pub callee: SemanticId,
32 pub position: usize,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct LayoutCompositionErrorV1 {
37 pub code: &'static str,
38 pub message: String,
39}
40
41pub fn build_layout_composition_plan_v1(
44 model: &ApplicationSemanticModel,
45 graph: &FileRouteGraphV1,
46) -> Result<LayoutCompositionPlanV1, LayoutCompositionErrorV1> {
47 build_layout_composition_plan_from_components_v1(&model.components, graph)
48}
49
50pub fn build_layout_composition_plan_from_components_v1(
53 component_nodes: &[crate::ComponentNode],
54 graph: &FileRouteGraphV1,
55) -> Result<LayoutCompositionPlanV1, LayoutCompositionErrorV1> {
56 let components = component_nodes
57 .iter()
58 .map(|component| (component.id.clone(), component))
59 .collect::<BTreeMap<_, _>>();
60 let mut routes = Vec::new();
61 for route in &graph.routes {
62 for layout in &route.layouts {
63 let Some(component) = components.get(layout) else {
64 return Err(LayoutCompositionErrorV1 {
65 code: "PSROUTE1022_LAYOUT_COMPOSITION_UNSUPPORTED",
66 message: format!("missing layout component {layout}"),
67 });
68 };
69 let defaults = component
70 .slot_declarations
71 .iter()
72 .filter(|slot| slot.kind == SlotKind::Default)
73 .count();
74 match defaults {
75 0 => {
76 return Err(LayoutCompositionErrorV1 {
77 code: "PSROUTE1020_LAYOUT_DEFAULT_SLOT_MISSING",
78 message: format!("layout component {layout} has no default @slot()"),
79 })
80 }
81 1 => {}
82 _ => {
83 return Err(LayoutCompositionErrorV1 {
84 code: "PSROUTE1021_LAYOUT_DEFAULT_SLOT_AMBIGUOUS",
85 message: format!(
86 "layout component {layout} has multiple default @slot() declarations"
87 ),
88 })
89 }
90 }
91 }
92 let mut chain = route.layouts.clone();
93 chain.push(route.component.clone());
94 let edges = chain
95 .windows(2)
96 .enumerate()
97 .map(|(position, pair)| LayoutCompositionEdgeV1 {
98 invocation: ComponentInvocationId::for_layout_composition(
99 &pair[0],
100 &route.component,
101 position,
102 ),
103 caller: pair[0].clone(),
104 callee: pair[1].clone(),
105 position,
106 })
107 .collect();
108 routes.push(LayoutCompositionRouteV1 {
109 path: route.path.clone(),
110 page: route.component.clone(),
111 layouts: route.layouts.clone(),
112 edges,
113 });
114 }
115 Ok(LayoutCompositionPlanV1 { routes })
116}
117
118#[must_use]
122pub fn layout_composition_virtual_invocations_v1(
123 model: &ApplicationSemanticModel,
124 plan: &LayoutCompositionPlanV1,
125) -> BTreeMap<ComponentInvocationId, ComponentInvocationEntity> {
126 plan.routes
127 .iter()
128 .flat_map(|route| route.edges.iter())
129 .filter_map(|edge| {
130 let provenance = model.provenance.get(&edge.caller)?.clone();
131 let template_entity = edge.caller.template();
132 Some((
133 edge.invocation.clone(),
134 ComponentInvocationEntity {
135 id: edge.invocation.clone(),
136 owner_component: edge.caller.clone(),
137 target_component: Some(edge.callee.clone()),
138 authored_symbol: "<presolve-layout-child>".into(),
139 template_entity: template_entity.clone(),
140 source_position: TemplatePositionId::for_template_entity(&template_entity),
141 status: ComponentInvocationResolutionStatus::Resolved,
142 provenance,
143 virtual_layout_composition: true,
144 },
145 ))
146 })
147 .collect()
148}
149
150pub fn plan_file_route_component_instances_v1(
153 components: &[ComponentNode],
154 graph: &FileRouteGraphV1,
155 authored_invocations: &BTreeMap<ComponentInvocationId, ComponentInvocationEntity>,
156 template_entities: &[TemplateSemanticEntity],
157 provenance: &BTreeMap<SemanticId, SourceProvenance>,
158) -> Result<ComponentInstancePlan, LayoutCompositionErrorV1> {
159 let plan = build_layout_composition_plan_from_components_v1(components, graph)?;
160 let virtuals = layout_composition_virtual_invocations_from_provenance_v1(&plan, provenance);
161 Ok(crate::plan_component_instances_with_virtual_invocations(
162 components,
163 authored_invocations,
164 &virtuals,
165 template_entities,
166 provenance,
167 ))
168}
169
170#[must_use]
173pub fn layout_composition_virtual_invocations_from_provenance_v1(
174 plan: &LayoutCompositionPlanV1,
175 provenance: &BTreeMap<SemanticId, SourceProvenance>,
176) -> BTreeMap<ComponentInvocationId, ComponentInvocationEntity> {
177 plan.routes
178 .iter()
179 .flat_map(|route| route.edges.iter())
180 .filter_map(|edge| {
181 let provenance = provenance.get(&edge.caller)?.clone();
182 let template_entity = edge.caller.template();
183 Some((
184 edge.invocation.clone(),
185 ComponentInvocationEntity {
186 id: edge.invocation.clone(),
187 owner_component: edge.caller.clone(),
188 target_component: Some(edge.callee.clone()),
189 authored_symbol: "<presolve-layout-child>".into(),
190 template_entity: template_entity.clone(),
191 source_position: TemplatePositionId::for_template_entity(&template_entity),
192 status: ComponentInvocationResolutionStatus::Resolved,
193 provenance,
194 virtual_layout_composition: true,
195 },
196 ))
197 })
198 .collect()
199}
200
201#[cfg(test)]
202mod tests {
203 use crate::{
204 build_application_semantic_model_for_unit, build_validated_file_route_graph_v1,
205 CompilationUnit,
206 };
207
208 use super::*;
209
210 #[test]
211 fn requires_an_exact_default_slot_for_each_conventional_layout() {
212 let model = build_application_semantic_model_for_unit(&CompilationUnit::parse_sources([
213 (
214 "app/layout.tsx",
215 r#"@component() class Shell extends Component { @slot() children!: SlotContent; render() { return <main><slot /></main>; } }"#,
216 ),
217 (
218 "app/routes/index.tsx",
219 r#"@component() class Home extends Component { render() { return <p>Home</p>; } }"#,
220 ),
221 ]));
222 let graph = build_validated_file_route_graph_v1(&model).unwrap();
223 let plan = build_layout_composition_plan_v1(&model, &graph).unwrap();
224 assert_eq!(plan.routes[0].layouts.len(), 1);
225 assert_eq!(plan.routes[0].edges.len(), 1);
226 assert_eq!(plan.routes[0].edges[0].caller, plan.routes[0].layouts[0]);
227 assert_eq!(plan.routes[0].edges[0].callee, plan.routes[0].page);
228 let virtuals = layout_composition_virtual_invocations_v1(&model, &plan);
229 assert_eq!(virtuals.len(), 1);
230 assert!(virtuals
231 .values()
232 .all(|invocation| invocation.virtual_layout_composition));
233 }
234}