1use crate::application_semantic_model::ApplicationSemanticModel;
2use crate::semantic_id::SemanticId;
3use crate::{
4 build_application_publication_product_v1, validate_application_publication_request_v1,
5 ApplicationPublicationErrorV1, ApplicationPublicationProductV1,
6 ApplicationPublicationRequestV1,
7};
8use serde::Serialize;
9use std::collections::BTreeMap;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct RouteGraph {
13 pub routes: Vec<RouteNode>,
14}
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct RouteNode {
17 pub path: String,
18 pub component: SemanticId,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct FileRouteGraphV1 {
28 pub routes: Vec<FileRouteNodeV1>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct FileRouteNodeV1 {
33 pub path: String,
34 pub component: SemanticId,
35 pub layouts: Vec<SemanticId>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct RouteGraphError {
41 pub code: &'static str,
42 pub message: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
46pub struct RouteManifestV1 {
47 pub schema_version: u32,
48 pub routes: Vec<RouteManifestEntryV1>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct RouteManifestEntryV1 {
53 pub path: String,
54 pub component_id: String,
55 pub artifact_root: String,
56 pub parent_path: Option<String>,
57}
58
59#[must_use]
60pub fn build_route_graph(model: &ApplicationSemanticModel) -> RouteGraph {
61 RouteGraph {
62 routes: model
63 .components
64 .iter()
65 .filter_map(|component| {
66 component.route_path.as_ref().map(|path| RouteNode {
67 path: path.clone(),
68 component: component.id.clone(),
69 })
70 })
71 .collect(),
72 }
73}
74
75#[must_use]
78pub fn build_file_route_graph_v1(model: &ApplicationSemanticModel) -> RouteGraph {
79 RouteGraph {
80 routes: model
81 .components
82 .iter()
83 .filter_map(|component| {
84 component
85 .route_path
86 .clone()
87 .or_else(|| file_route_path(&component.module_path))
88 .map(|path| RouteNode {
89 path,
90 component: component.id.clone(),
91 })
92 })
93 .collect(),
94 }
95}
96
97#[must_use]
101pub fn build_file_route_graph_from_components_v1(
102 components: &[crate::ComponentNode],
103) -> RouteGraph {
104 RouteGraph {
105 routes: components
106 .iter()
107 .filter_map(|component| {
108 component
109 .route_path
110 .clone()
111 .or_else(|| file_route_path(&component.module_path))
112 .map(|path| RouteNode {
113 path,
114 component: component.id.clone(),
115 })
116 })
117 .collect(),
118 }
119}
120
121pub fn build_validated_file_route_graph_v1(
131 model: &ApplicationSemanticModel,
132) -> Result<FileRouteGraphV1, RouteGraphError> {
133 build_validated_file_route_graph_from_components_v1(&model.components)
134}
135
136pub fn build_validated_file_route_graph_from_components_v1(
148 components: &[crate::ComponentNode],
149) -> Result<FileRouteGraphV1, RouteGraphError> {
150 let mut layouts = BTreeMap::<Vec<String>, SemanticId>::new();
151 for component in components {
152 let Some(scope) = file_layout_scope(&component.module_path) else {
153 continue;
154 };
155 let scope = normalize_route_scope(scope);
156 if component.route_path.is_some() {
157 return Err(RouteGraphError {
158 code: "PSROUTE1010_LAYOUT_CANNOT_DECLARE_ROUTE",
159 message: format!(
160 "layout component `{}` must not declare @route()",
161 component.id
162 ),
163 });
164 }
165 if layouts
166 .insert(scope.clone(), component.id.clone())
167 .is_some()
168 {
169 return Err(RouteGraphError {
170 code: "PSROUTE1011_LAYOUT_COMPONENT_AMBIGUOUS",
171 message: format!(
172 "layout scope `{}` must declare exactly one component",
173 route_scope_display(&scope)
174 ),
175 });
176 }
177 }
178
179 let mut routes = Vec::new();
180 for component in components {
181 if file_layout_scope(&component.module_path).is_some() {
182 continue;
183 }
184 let Some(file_path) = file_route_path(&component.module_path) else {
185 continue;
186 };
187 let path = component.route_path.clone().unwrap_or(file_path);
188 if !is_file_route_path(&path) {
189 return Err(RouteGraphError {
190 code: "PSROUTE1012_INVALID_FILE_ROUTE_PATH",
191 message: format!("route path `{path}` is not a supported file-route path"),
192 });
193 }
194 let layouts = layout_chain_for_route(&path, &layouts);
195 routes.push(FileRouteNodeV1 {
196 path,
197 component: component.id.clone(),
198 layouts,
199 });
200 }
201 routes.sort_by(|left, right| {
202 route_match_shape(&left.path)
203 .cmp(&route_match_shape(&right.path))
204 .then_with(|| left.path.cmp(&right.path))
205 .then_with(|| left.component.cmp(&right.component))
206 });
207 for pair in routes.windows(2) {
208 if route_match_shape(&pair[0].path) == route_match_shape(&pair[1].path) {
209 return Err(RouteGraphError {
210 code: "PSROUTE1013_FILE_ROUTE_CONFLICT",
211 message: format!(
212 "routes `{}` and `{}` match the same request path",
213 pair[0].path, pair[1].path
214 ),
215 });
216 }
217 }
218 Ok(FileRouteGraphV1 { routes })
219}
220
221pub fn build_validated_route_graph_v1(
227 model: &ApplicationSemanticModel,
228) -> Result<RouteGraph, RouteGraphError> {
229 let mut graph = build_route_graph(model);
230 graph
231 .routes
232 .sort_by(|left, right| left.path.cmp(&right.path));
233 for route in &graph.routes {
234 if !is_static_route_path(&route.path) {
235 return Err(RouteGraphError {
236 code: "PSROUTE1001_INVALID_STATIC_ROUTE_PATH",
237 message: format!(
238 "route path `{}` must begin with / and contain no dynamic segments",
239 route.path
240 ),
241 });
242 }
243 }
244 if graph
245 .routes
246 .windows(2)
247 .any(|pair| pair[0].path == pair[1].path)
248 {
249 return Err(RouteGraphError {
250 code: "PSROUTE1002_DUPLICATE_ROUTE_PATH",
251 message: "each static route path must identify exactly one component".into(),
252 });
253 }
254 Ok(graph)
255}
256
257#[must_use]
258pub fn route_manifest_v1(graph: &RouteGraph) -> RouteManifestV1 {
259 RouteManifestV1 {
260 schema_version: 1,
261 routes: graph
262 .routes
263 .iter()
264 .map(|route| RouteManifestEntryV1 {
265 path: route.path.clone(),
266 component_id: route.component.to_string(),
267 artifact_root: route_artifact_root(&route.path),
268 parent_path: route_parent_path(&route.path),
269 })
270 .collect(),
271 }
272}
273
274pub fn build_static_route_publication_v1(
282 requests: Vec<ApplicationPublicationRequestV1>,
283) -> Result<(RouteManifestV1, BTreeMap<std::path::PathBuf, Vec<u8>>), RouteGraphError> {
284 if requests.is_empty() {
285 return Err(RouteGraphError {
286 code: "PSROUTE1003_EMPTY_ROUTE_SET",
287 message: "static route publication requires explicit route entries".into(),
288 });
289 }
290 let mut artifacts = BTreeMap::new();
291 let mut routes = Vec::new();
292 for request in requests {
293 let validated =
294 validate_application_publication_request_v1(request).map_err(route_request_error)?;
295 let model = crate::build_application_semantic_model_for_unit_with_packages(
296 &validated.unit,
297 &validated.request.package_contracts,
298 );
299 let graph = build_validated_route_graph_v1(&model)?;
300 let route = graph
301 .routes
302 .iter()
303 .find(|route| route.component == validated.entry_component)
304 .ok_or_else(|| RouteGraphError {
305 code: "PSROUTE1004_ENTRY_ROUTE_MISSING",
306 message: "each route publication entry must declare one matching @route path"
307 .into(),
308 })?;
309 let root = route_artifact_root(&route.path);
310 if routes
311 .iter()
312 .any(|entry: &RouteManifestEntryV1| entry.path == route.path)
313 {
314 return Err(RouteGraphError {
315 code: "PSROUTE1002_DUPLICATE_ROUTE_PATH",
316 message: "each static route path must identify exactly one component".into(),
317 });
318 }
319 let product =
320 build_application_publication_product_v1(validated).map_err(route_product_error)?;
321 insert_route_product(&mut artifacts, &root, product)?;
322 routes.push(RouteManifestEntryV1 {
323 path: route.path.clone(),
324 component_id: route.component.to_string(),
325 artifact_root: root,
326 parent_path: route_parent_path(&route.path),
327 });
328 }
329 routes.sort_by(|left, right| left.path.cmp(&right.path));
330 let manifest = RouteManifestV1 {
331 schema_version: 1,
332 routes,
333 };
334 artifacts.insert(
335 std::path::PathBuf::from("routes.manifest.json"),
336 route_manifest_json_v1(&manifest).into_bytes(),
337 );
338 Ok((manifest, artifacts))
339}
340
341fn insert_route_product(
342 artifacts: &mut BTreeMap<std::path::PathBuf, Vec<u8>>,
343 root: &str,
344 product: ApplicationPublicationProductV1,
345) -> Result<(), RouteGraphError> {
346 for (path, bytes) in product.artifacts {
347 let path = std::path::PathBuf::from(root).join(path);
348 if artifacts.insert(path.clone(), bytes).is_some() {
349 return Err(RouteGraphError {
350 code: "PSROUTE1005_ARTIFACT_PATH_COLLISION",
351 message: format!(
352 "route publication generated colliding artifact {}",
353 path.display()
354 ),
355 });
356 }
357 }
358 Ok(())
359}
360
361fn route_request_error(error: crate::ApplicationPublicationRequestErrorV1) -> RouteGraphError {
362 RouteGraphError {
363 code: error.code,
364 message: error.message,
365 }
366}
367fn route_product_error(error: ApplicationPublicationErrorV1) -> RouteGraphError {
368 RouteGraphError {
369 code: error.code,
370 message: error.message,
371 }
372}
373fn route_artifact_root(path: &str) -> String {
374 if path == "/" {
375 return "routes/root".into();
376 }
377 format!("routes/{}", path.trim_matches('/').replace('/', "__"))
378}
379fn route_parent_path(path: &str) -> Option<String> {
380 if path == "/" {
381 return None;
382 }
383 let trimmed = path.trim_matches('/');
384 let parent = trimmed.rsplit_once('/').map_or("", |(parent, _)| parent);
385 Some(if parent.is_empty() {
386 "/".into()
387 } else {
388 format!("/{parent}")
389 })
390}
391
392#[must_use]
393pub fn route_manifest_json_v1(manifest: &RouteManifestV1) -> String {
394 serde_json::to_string_pretty(manifest).expect("route manifest is serializable") + "\n"
395}
396
397fn is_static_route_path(path: &str) -> bool {
398 path.starts_with('/')
399 && !path.contains("//")
400 && !path.contains('*')
401 && !path.contains(':')
402 && !path.contains('{')
403 && !path.contains('}')
404 && path.split('/').all(|segment| !segment.contains(".."))
405}
406
407fn is_file_route_path(path: &str) -> bool {
408 path.starts_with('/')
409 && !path.contains("//")
410 && path.split('/').all(|segment| {
411 segment.is_empty()
412 || (segment.starts_with(':')
413 && segment.len() > 1
414 && segment[1..]
415 .chars()
416 .all(|character| character.is_ascii_alphanumeric() || character == '_'))
417 || (segment != "."
418 && segment != ".."
419 && !segment.contains('*')
420 && !segment.contains('{')
421 && !segment.contains('}'))
422 })
423}
424
425fn file_route_path(path: &std::path::Path) -> Option<String> {
426 let values = path
427 .components()
428 .map(|component| component.as_os_str().to_str())
429 .collect::<Option<Vec<_>>>()?;
430 let index = values.iter().position(|value| *value == "routes")?;
431 if values.get(index.checked_sub(1)?)? != &"app" {
432 return None;
433 }
434 let mut segments = values[index + 1..]
435 .iter()
436 .map(|value| (*value).to_string())
437 .collect::<Vec<_>>();
438 let filename = segments.pop()?;
439 let stem = filename
440 .strip_suffix(".tsx")
441 .or_else(|| filename.strip_suffix(".ts"))?;
442 if stem != "index" {
443 segments.push(route_segment(stem)?);
444 }
445 for segment in &mut segments {
446 *segment = route_segment(segment)?;
447 }
448 Some(if segments.is_empty() {
449 "/".into()
450 } else {
451 format!("/{}", segments.join("/"))
452 })
453}
454
455fn file_layout_scope(path: &std::path::Path) -> Option<Vec<String>> {
456 let values = path
457 .components()
458 .map(|component| component.as_os_str().to_str())
459 .collect::<Option<Vec<_>>>()?;
460 let filename = values.last()?;
461 if !matches!(*filename, "layout.ts" | "layout.tsx") {
462 return None;
463 }
464 match values.as_slice() {
465 ["app", _] => Some(Vec::new()),
466 ["app", "routes", rest @ .., _] => {
467 rest.iter().map(|segment| route_segment(segment)).collect()
468 }
469 _ => None,
470 }
471}
472
473fn layout_chain_for_route(
474 path: &str,
475 layouts: &BTreeMap<Vec<String>, SemanticId>,
476) -> Vec<SemanticId> {
477 let segments = path
478 .trim_matches('/')
479 .split('/')
480 .filter(|segment| !segment.is_empty())
481 .map(route_match_segment)
482 .collect::<Vec<_>>();
483 (0..=segments.len())
484 .filter_map(|length| layouts.get(&segments[..length]).cloned())
485 .collect()
486}
487
488fn route_match_shape(path: &str) -> String {
489 path.split('/')
490 .map(route_match_segment)
491 .collect::<Vec<_>>()
492 .join("/")
493}
494
495fn route_match_segment(segment: &str) -> String {
496 if segment.starts_with(':') {
497 ":".into()
498 } else {
499 segment.into()
500 }
501}
502
503fn normalize_route_scope(scope: Vec<String>) -> Vec<String> {
504 scope
505 .into_iter()
506 .map(|segment| route_match_segment(&segment))
507 .collect()
508}
509
510fn route_scope_display(scope: &[String]) -> String {
511 if scope.is_empty() {
512 "/".into()
513 } else {
514 format!("/{}", scope.join("/"))
515 }
516}
517
518fn route_segment(segment: &str) -> Option<String> {
519 if let Some(parameter) = segment
520 .strip_prefix('[')
521 .and_then(|value| value.strip_suffix(']'))
522 {
523 if parameter.is_empty()
524 || !parameter
525 .chars()
526 .all(|character| character.is_ascii_alphanumeric() || character == '_')
527 {
528 return None;
529 }
530 return Some(format!(":{parameter}"));
531 }
532 (!segment.is_empty()).then(|| segment.into())
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use crate::{
539 build_application_semantic_model, build_application_semantic_model_for_unit,
540 CompilationUnit,
541 };
542 use presolve_parser::parse_file;
543
544 #[test]
545 fn validates_and_sorts_static_route_identity() {
546 let source = r#"@route("/about") @component("x-about") class About extends Component { render() { return <main />; } } @route("/") @component("x-home") class Home extends Component { render() { return <main />; } }"#;
547 let model = build_application_semantic_model(&parse_file("src/routes.tsx", source));
548 let graph = build_validated_route_graph_v1(&model).unwrap();
549 assert_eq!(
550 graph
551 .routes
552 .iter()
553 .map(|route| route.path.as_str())
554 .collect::<Vec<_>>(),
555 vec!["/", "/about"]
556 );
557 assert!(route_manifest_json_v1(&route_manifest_v1(&graph)).contains("schema_version"));
558 }
559
560 #[test]
561 fn derives_file_routes_and_keeps_explicit_route_override() {
562 let source =
563 r#"@component() class Home extends Component { render() { return <main />; } }"#;
564 let model = build_application_semantic_model(&parse_file("app/routes/index.tsx", source));
565 assert_eq!(build_file_route_graph_v1(&model).routes[0].path, "/");
566 let source = r#"@route("/welcome") @component() class Home extends Component { render() { return <main />; } }"#;
567 let model = build_application_semantic_model(&parse_file("app/routes/index.tsx", source));
568 assert_eq!(build_file_route_graph_v1(&model).routes[0].path, "/welcome");
569 }
570
571 #[test]
572 fn derives_typed_parameter_segment_from_file_name() {
573 let source =
574 r#"@component() class Post extends Component { render() { return <main />; } }"#;
575 let model =
576 build_application_semantic_model(&parse_file("app/routes/blog/[slug].tsx", source));
577 assert_eq!(
578 build_file_route_graph_v1(&model).routes[0].path,
579 "/blog/:slug"
580 );
581 }
582
583 #[test]
584 fn derives_nested_layout_chains_for_file_routes() {
585 let model = build_application_semantic_model_for_unit(&CompilationUnit::parse_sources([
586 (
587 "app/layout.tsx",
588 r#"@component() class AppLayout extends Component { render() { return <main />; } }"#,
589 ),
590 (
591 "app/routes/blog/layout.tsx",
592 r#"@component() class BlogLayout extends Component { render() { return <section />; } }"#,
593 ),
594 (
595 "app/routes/blog/[slug].tsx",
596 r#"@component() class Post extends Component { render() { return <article />; } }"#,
597 ),
598 ]));
599
600 let graph = build_validated_file_route_graph_v1(&model).unwrap();
601 let component_graph =
602 build_validated_file_route_graph_from_components_v1(&model.components).unwrap();
603
604 assert_eq!(graph.routes.len(), 1);
605 assert_eq!(component_graph, graph);
606 assert_eq!(graph.routes[0].path, "/blog/:slug");
607 assert_eq!(
608 graph.routes[0]
609 .layouts
610 .iter()
611 .map(ToString::to_string)
612 .collect::<Vec<_>>(),
613 vec![
614 "module:app/layout.tsx/component:presolve-app-layout",
615 "module:app/routes/blog/layout.tsx/component:presolve-blog-layout"
616 ]
617 );
618 }
619
620 #[test]
621 fn rejects_file_routes_that_differ_only_by_parameter_name() {
622 let model = build_application_semantic_model_for_unit(&CompilationUnit::parse_sources([
623 (
624 "app/routes/blog/[id].tsx",
625 r#"@component() class ById extends Component { render() { return <article />; } }"#,
626 ),
627 (
628 "app/routes/blog/[slug].tsx",
629 r#"@component() class BySlug extends Component { render() { return <article />; } }"#,
630 ),
631 ]));
632
633 assert_eq!(
634 build_validated_file_route_graph_v1(&model)
635 .unwrap_err()
636 .code,
637 "PSROUTE1013_FILE_ROUTE_CONFLICT"
638 );
639 }
640}