1use std::collections::BTreeMap;
4use std::fmt;
5use std::path::PathBuf;
6
7use serde::Serialize;
8
9use crate::{
10 build_application_publication_product_from_asm_v1,
11 build_application_semantic_model_for_unit_with_packages, build_binding_table_with_packages,
12 build_file_route_application_semantic_model_for_route_with_packages,
13 build_file_route_application_semantic_model_for_route_with_packages_and_v2_authoring,
14 build_file_route_application_semantic_model_for_unit_with_packages_and_v2_authoring,
15 build_layout_composition_plan_v1, build_module_graph, build_route_loader_plan_v1,
16 build_route_server_action_plan_v1, build_symbol_table, build_validated_file_route_graph_v1,
17 route_loader_plan_json_v1, route_server_action_plan_json_v1,
18 validate_application_publication_request_v1, ApplicationPublicationArtifactV1,
19 ApplicationPublicationErrorV1, ApplicationPublicationProfileV1,
20 ApplicationPublicationRequestErrorV1, ApplicationPublicationRequestV1,
21 ApplicationPublicationSourceV1, CompilationUnit, ConstantFoldingPass, ImmutableAsmPass,
22 SemanticPackageResolutionTable, SemanticPackageRuntimeModuleTable,
23 ValidatedApplicationPublicationRequestV1,
24};
25
26pub const FILE_ROUTE_PUBLICATION_MANIFEST_SCHEMA_VERSION: u32 = 1;
27pub const FILE_ROUTE_PUBLICATION_COMPILER_CONTRACT_V1: &str = "presolve-file-route-publication:1";
28pub const FILE_ROUTE_ENVIRONMENT_ARTIFACT_PATH_V1: &str = "environment.browser.json";
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct FileRoutePublicationRequestV1 {
32 pub configuration: crate::platform::WorkspaceConfiguration,
33 pub sources: Vec<ApplicationPublicationSourceV1>,
34 pub package_contracts: SemanticPackageResolutionTable,
35 pub package_runtime_modules: SemanticPackageRuntimeModuleTable,
36 pub profile: ApplicationPublicationProfileV1,
37 pub output_root: PathBuf,
38 pub environment_artifact: Option<crate::EnvironmentPublicationArtifactV1>,
42 pub v2_authoring: BTreeMap<PathBuf, crate::CanonicalAuthoredSemanticModelV1>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct FileRoutePublicationErrorV1 {
48 pub code: &'static str,
49 pub message: String,
50}
51
52impl fmt::Display for FileRoutePublicationErrorV1 {
53 fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
54 write!(output, "{}: {}", self.code, self.message)
55 }
56}
57
58impl std::error::Error for FileRoutePublicationErrorV1 {}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct FileRoutePublicationManifestV1 {
62 pub schema_version: u32,
63 pub compiler_contract: String,
64 pub profile: String,
65 pub routes: Vec<FileRoutePublicationRouteV1>,
66 pub artifacts: Vec<ApplicationPublicationArtifactV1>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
70pub struct FileRoutePublicationRouteV1 {
71 pub path: String,
72 pub entry_component_id: String,
73 pub artifact_root: String,
74 pub layout_component_ids: Vec<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct FileRoutePublicationProductV1 {
79 pub manifest: FileRoutePublicationManifestV1,
80 pub artifacts: BTreeMap<PathBuf, Vec<u8>>,
81 pub route_source_paths: BTreeMap<String, PathBuf>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum FileRouteRequestTargetV1 {
88 Redirect { location: String },
89 Artifact { path: PathBuf },
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct FileRouteRequestMatchV1 {
97 pub route_path: String,
98 pub entry_component_id: String,
99 pub parameters: BTreeMap<String, String>,
103 pub target: FileRouteRequestTargetV1,
104}
105
106pub fn build_file_route_publication_v1(
112 request: FileRoutePublicationRequestV1,
113) -> Result<FileRoutePublicationProductV1, FileRoutePublicationErrorV1> {
114 if let Some(artifact) = &request.environment_artifact {
115 crate::validate_environment_publication_artifact_v1(artifact).map_err(|error| {
116 FileRoutePublicationErrorV1 {
117 code: "PSENV1201_ARTIFACT_INVALID",
118 message: error.to_string(),
119 }
120 })?;
121 }
122 if request.sources.is_empty() {
123 return Err(FileRoutePublicationErrorV1 {
124 code: "PSROUTE2001_EMPTY_FILE_ROUTE_SOURCE_SET",
125 message: "file-route publication requires discovered application sources".into(),
126 });
127 }
128 let unit = CompilationUnit::parse_sources(
129 request
130 .sources
131 .iter()
132 .map(|source| (&source.logical_path, source.source.as_str())),
133 );
134 let model = if request.v2_authoring.is_empty() {
135 build_application_semantic_model_for_unit_with_packages(&unit, &request.package_contracts)
136 } else {
137 build_file_route_application_semantic_model_for_unit_with_packages_and_v2_authoring(
138 &unit,
139 &request.package_contracts,
140 &request.v2_authoring,
141 )
142 .map_err(|error| FileRoutePublicationErrorV1 {
143 code: error.code,
144 message: error.message,
145 })?
146 };
147 let graph = build_validated_file_route_graph_v1(&model).map_err(route_error)?;
148 build_layout_composition_plan_v1(&model, &graph).map_err(|error| {
149 FileRoutePublicationErrorV1 {
150 code: error.code,
151 message: error.message,
152 }
153 })?;
154 let symbols = build_symbol_table(&unit);
155 let modules = build_module_graph(&unit);
156 let bindings =
157 build_binding_table_with_packages(&unit, &symbols, &modules, &request.package_contracts);
158 let route_loader_plan = build_route_loader_plan_v1(&model.components, &graph, &bindings)
159 .map_err(|error| FileRoutePublicationErrorV1 {
160 code: error.code,
161 message: error.message,
162 })?;
163 let route_server_action_plan =
164 build_route_server_action_plan_v1(&model.components, &graph, &bindings).map_err(
165 |error| FileRoutePublicationErrorV1 {
166 code: error.code,
167 message: error.message,
168 },
169 )?;
170 if graph.routes.is_empty() {
171 return Err(FileRoutePublicationErrorV1 {
172 code: "PSROUTE2002_FILE_ROUTE_SET_EMPTY",
173 message: "no rendered components were discovered below app/routes".into(),
174 });
175 }
176
177 let component_modules = model
178 .components
179 .iter()
180 .map(|component| (component.id.clone(), component.module_path.clone()))
181 .collect::<BTreeMap<_, _>>();
182 let mut artifacts = BTreeMap::new();
183 let mut routes = Vec::new();
184 let mut route_source_paths = BTreeMap::new();
185 for route in &graph.routes {
186 let entry_path =
187 component_modules
188 .get(&route.component)
189 .ok_or_else(|| FileRoutePublicationErrorV1 {
190 code: "PSROUTE2003_ROUTE_COMPONENT_MODULE_MISSING",
191 message: route.component.to_string(),
192 })?;
193 let publication_request = ApplicationPublicationRequestV1 {
194 configuration: request.configuration.clone(),
195 sources: request.sources.clone(),
196 entry_path: entry_path.clone(),
197 package_contracts: request.package_contracts.clone(),
198 package_runtime_modules: request.package_runtime_modules.clone(),
199 profile: request.profile,
200 output_root: request.output_root.clone(),
201 };
202 let mut validated = if request.v2_authoring.is_empty() {
203 validate_application_publication_request_v1(publication_request)
204 .map_err(application_request_error)?
205 } else {
206 ValidatedApplicationPublicationRequestV1 {
207 request: publication_request,
208 unit: unit.clone(),
209 entry_component: route.component.clone(),
210 render_root_component: route.component.clone(),
211 }
212 };
213 let route_model = if request.v2_authoring.is_empty() {
214 build_file_route_application_semantic_model_for_route_with_packages(
215 &unit,
216 &request.package_contracts,
217 &route.component,
218 )
219 } else {
220 build_file_route_application_semantic_model_for_route_with_packages_and_v2_authoring(
221 &unit,
222 &request.package_contracts,
223 &route.component,
224 &request.v2_authoring,
225 )
226 }
227 .map_err(|error| FileRoutePublicationErrorV1 {
228 code: error.code,
229 message: error.message,
230 })?;
231 let composed = ConstantFoldingPass.transform(&route_model);
232 validated.render_root_component = route
233 .layouts
234 .first()
235 .cloned()
236 .unwrap_or_else(|| route.component.clone());
237 let product = build_application_publication_product_from_asm_v1(validated, composed)
238 .map_err(application_product_error)?;
239 let artifact_root = file_route_artifact_root_v1(&route.path);
240 for (path, bytes) in product.artifacts {
241 let path = PathBuf::from(&artifact_root).join(path);
242 if artifacts.insert(path.clone(), bytes).is_some() {
243 return Err(FileRoutePublicationErrorV1 {
244 code: "PSROUTE2004_FILE_ROUTE_ARTIFACT_COLLISION",
245 message: path.display().to_string(),
246 });
247 }
248 }
249 routes.push(FileRoutePublicationRouteV1 {
250 path: route.path.clone(),
251 entry_component_id: route.component.to_string(),
252 artifact_root,
253 layout_component_ids: route.layouts.iter().map(ToString::to_string).collect(),
254 });
255 route_source_paths.insert(route.path.clone(), entry_path.clone());
256 }
257 routes.sort_by(|left, right| left.path.cmp(&right.path));
258 artifacts.insert(
259 PathBuf::from("route-loaders.plan.json"),
260 route_loader_plan_json_v1(&route_loader_plan).into_bytes(),
261 );
262 artifacts.insert(
263 PathBuf::from("route-server-actions.plan.json"),
264 route_server_action_plan_json_v1(&route_server_action_plan).into_bytes(),
265 );
266 if let Some(artifact) = request.environment_artifact {
267 artifacts.insert(
268 PathBuf::from(FILE_ROUTE_ENVIRONMENT_ARTIFACT_PATH_V1),
269 crate::environment_publication_artifact_json_v1(&artifact).into_bytes(),
270 );
271 }
272 let manifest = FileRoutePublicationManifestV1 {
273 schema_version: FILE_ROUTE_PUBLICATION_MANIFEST_SCHEMA_VERSION,
274 compiler_contract: FILE_ROUTE_PUBLICATION_COMPILER_CONTRACT_V1.into(),
275 profile: request.profile.as_str().into(),
276 routes,
277 artifacts: artifacts
278 .iter()
279 .map(|(path, bytes)| ApplicationPublicationArtifactV1 {
280 path: path.to_string_lossy().replace('\\', "/"),
281 digest: crate::platform::Digest::sha256(bytes).to_string(),
282 })
283 .collect(),
284 };
285 artifacts.insert(
286 PathBuf::from("file-routes.manifest.json"),
287 file_route_publication_manifest_json_v1(&manifest).into_bytes(),
288 );
289 Ok(FileRoutePublicationProductV1 {
290 manifest,
291 artifacts,
292 route_source_paths,
293 })
294}
295
296#[must_use]
297pub fn file_route_publication_manifest_json_v1(value: &FileRoutePublicationManifestV1) -> String {
298 serde_json::to_string_pretty(value).expect("file-route publication manifest serializes") + "\n"
299}
300
301#[must_use]
302pub fn file_route_artifact_root_v1(path: &str) -> String {
303 if path == "/" {
304 return "routes/root".into();
305 }
306 let segments = path
307 .trim_matches('/')
308 .split('/')
309 .map(|segment| {
310 if let Some(parameter) = segment.strip_prefix(':') {
311 format!("parameter-{parameter}")
312 } else {
313 format!("segment-{segment}")
314 }
315 })
316 .collect::<Vec<_>>();
317 format!("routes/{}", segments.join("/"))
318}
319
320#[must_use]
324pub fn resolve_file_route_request_v1(
325 manifest: &FileRoutePublicationManifestV1,
326 request_path: &str,
327) -> Option<FileRouteRequestTargetV1> {
328 resolve_file_route_request_match_v1(manifest, request_path).map(|match_| match_.target)
329}
330
331#[must_use]
335pub fn resolve_file_route_request_match_v1(
336 manifest: &FileRoutePublicationManifestV1,
337 request_path: &str,
338) -> Option<FileRouteRequestMatchV1> {
339 if !request_path.starts_with('/') || request_path.contains('?') || request_path.contains('#') {
340 return None;
341 }
342 let trailing_slash = request_path.ends_with('/');
343 let segments = request_path
344 .trim_matches('/')
345 .split('/')
346 .filter(|segment| !segment.is_empty())
347 .collect::<Vec<_>>();
348 let route = manifest
349 .routes
350 .iter()
351 .filter_map(|route| {
352 let route_segments = route
353 .path
354 .trim_matches('/')
355 .split('/')
356 .filter(|segment| !segment.is_empty())
357 .collect::<Vec<_>>();
358 (segments.len() >= route_segments.len()
359 && route_segments
360 .iter()
361 .zip(&segments)
362 .all(|(route, request)| {
363 route.strip_prefix(':').is_some_and(|_| !request.is_empty())
364 || route == request
365 }))
366 .then_some((route, route_segments))
367 })
368 .max_by(|(_, left_segments), (_, right_segments)| {
369 route_match_score(left_segments).cmp(&route_match_score(right_segments))
370 })?;
371 let (route, route_segments) = route;
372 let parameters = route_segments
373 .iter()
374 .zip(&segments)
375 .filter_map(|(route_segment, request_segment)| {
376 route_segment
377 .strip_prefix(':')
378 .map(|name| (name.to_string(), (*request_segment).to_string()))
379 })
380 .collect::<BTreeMap<_, _>>();
381 let match_record = |target| FileRouteRequestMatchV1 {
382 route_path: route.path.clone(),
383 entry_component_id: route.entry_component_id.clone(),
384 parameters: parameters.clone(),
385 target,
386 };
387 if segments.len() == route_segments.len() {
388 if route.path != "/" && !trailing_slash {
389 return Some(match_record(FileRouteRequestTargetV1::Redirect {
390 location: format!("{request_path}/"),
391 }));
392 }
393 return Some(match_record(FileRouteRequestTargetV1::Artifact {
394 path: PathBuf::from(&route.artifact_root).join("index.html"),
395 }));
396 }
397 let suffix = &segments[route_segments.len()..];
398 suffix
399 .iter()
400 .all(|segment| is_safe_request_asset_segment(segment))
401 .then(|| {
402 match_record(FileRouteRequestTargetV1::Artifact {
403 path: PathBuf::from(&route.artifact_root).join(suffix.iter().collect::<PathBuf>()),
404 })
405 })
406}
407
408fn route_match_score(segments: &[&str]) -> (usize, usize) {
409 (
410 segments
411 .iter()
412 .filter(|segment| !segment.starts_with(':'))
413 .count(),
414 segments.len(),
415 )
416}
417
418fn is_safe_request_asset_segment(segment: &str) -> bool {
419 !segment.is_empty() && segment != "." && segment != ".." && !segment.contains('\\')
420}
421
422fn route_error(error: crate::RouteGraphError) -> FileRoutePublicationErrorV1 {
423 FileRoutePublicationErrorV1 {
424 code: error.code,
425 message: error.message,
426 }
427}
428
429fn application_request_error(
430 error: ApplicationPublicationRequestErrorV1,
431) -> FileRoutePublicationErrorV1 {
432 FileRoutePublicationErrorV1 {
433 code: error.code,
434 message: error.message,
435 }
436}
437
438fn application_product_error(error: ApplicationPublicationErrorV1) -> FileRoutePublicationErrorV1 {
439 FileRoutePublicationErrorV1 {
440 code: error.code,
441 message: error.message,
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 #[test]
450 fn publishes_each_file_route_under_a_distinct_compiler_owned_root() {
451 let product = build_file_route_publication_v1(FileRoutePublicationRequestV1 {
452 configuration: crate::platform::WorkspaceConfiguration::default(),
453 sources: vec![
454 ApplicationPublicationSourceV1 {
455 logical_path: "app/routes/index.tsx".into(),
456 source: r#"@component() class Home extends Component { render() { return <main>Home</main>; } }"#.into(),
457 },
458 ApplicationPublicationSourceV1 {
459 logical_path: "app/routes/about.tsx".into(),
460 source: r#"@component() class About extends Component { render() { return <main>About</main>; } }"#.into(),
461 },
462 ],
463 package_contracts: SemanticPackageResolutionTable::default(),
464 package_runtime_modules: SemanticPackageRuntimeModuleTable::default(),
465 profile: ApplicationPublicationProfileV1::Development,
466 output_root: "dist".into(),
467 environment_artifact: Some(crate::EnvironmentPublicationArtifactV1 {
468 schema_version: crate::ENVIRONMENT_PUBLICATION_SCHEMA_VERSION,
469 browser_values: BTreeMap::from([(
470 "PRESOLVE_PUBLIC_NAME".into(),
471 "Presolve".into(),
472 )]),
473 }),
474 v2_authoring: BTreeMap::new(),
475 })
476 .unwrap();
477
478 assert_eq!(product.manifest.routes.len(), 2);
479 let home =
480 String::from_utf8(product.artifacts[&PathBuf::from("routes/root/index.html")].clone())
481 .unwrap();
482 let about = String::from_utf8(
483 product.artifacts[&PathBuf::from("routes/segment-about/index.html")].clone(),
484 )
485 .unwrap();
486 assert!(home.contains(">Home</main>"));
487 assert!(!home.contains(">About</main>"));
488 assert!(about.contains(">About</main>"));
489 assert!(product
490 .artifacts
491 .contains_key(&PathBuf::from("file-routes.manifest.json")));
492 let environment = String::from_utf8(
493 product.artifacts[&PathBuf::from(FILE_ROUTE_ENVIRONMENT_ARTIFACT_PATH_V1)].clone(),
494 )
495 .unwrap();
496 assert!(environment.contains("PRESOLVE_PUBLIC_NAME"));
497 assert!(product
498 .manifest
499 .artifacts
500 .iter()
501 .any(|artifact| artifact.path == FILE_ROUTE_ENVIRONMENT_ARTIFACT_PATH_V1));
502 }
503
504 #[test]
505 fn publishes_a_route_through_its_compiler_composed_layout_root() {
506 let product = build_file_route_publication_v1(FileRoutePublicationRequestV1 {
507 configuration: crate::platform::WorkspaceConfiguration::default(),
508 sources: vec![
509 ApplicationPublicationSourceV1 {
510 logical_path: "app/layout.tsx".into(),
511 source: r#"
512@component() class AppLayout extends Component {
513 @slot() children!: SlotContent;
514 render() { return <main><slot /></main>; }
515}
516"#
517 .into(),
518 },
519 ApplicationPublicationSourceV1 {
520 logical_path: "app/routes/index.tsx".into(),
521 source: r#"@component() class Home extends Component { render() { return <article>Home</article>; } }"#.into(),
522 },
523 ],
524 package_contracts: SemanticPackageResolutionTable::default(),
525 package_runtime_modules: SemanticPackageRuntimeModuleTable::default(),
526 profile: ApplicationPublicationProfileV1::Development,
527 output_root: "dist".into(),
528 environment_artifact: None,
529 v2_authoring: BTreeMap::new(),
530 })
531 .expect("valid route layout publication");
532
533 let html =
534 String::from_utf8(product.artifacts[&PathBuf::from("routes/root/index.html")].clone())
535 .unwrap();
536 assert!(html.contains("<main"));
537 assert!(html.contains("<article"));
538 assert!(html.contains("Home"));
539 }
540
541 #[test]
542 fn publishes_an_exact_route_loader_handoff_plan_without_server_execution() {
543 let contract = crate::parse_semantic_package_contract(
544 r#"{"schema_version":1,"package":"post-service","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"loadPost":{"kind":"resource","type_signature":"RouteParameters -> Resource<Post, NotFound>","runtime_module":"dist/load-post.js","resume_policy":"reload","resource_endpoint":{"execution_boundary":"server","cancellation":"abort","resume":"reload"},"route_loader":{"input":"route_parameters","cache":{"scope":"public","max_age_seconds":60},"failure":"typed"}}}}"#,
545 )
546 .unwrap();
547 let mut contracts = SemanticPackageResolutionTable::default();
548 contracts.insert("post-service".into(), contract).unwrap();
549 let product = build_file_route_publication_v1(FileRoutePublicationRequestV1 {
550 configuration: crate::platform::WorkspaceConfiguration::default(),
551 sources: vec![ApplicationPublicationSourceV1 {
552 logical_path: "app/routes/posts/[slug].tsx".into(),
553 source: r#"
554import { loadPost } from "post-service";
555@component() class Post {
556 @loader("loadPost") post!: Resource<Post, NotFound>;
557 render() { return <article />; }
558}
559"#
560 .into(),
561 }],
562 package_contracts: contracts,
563 package_runtime_modules: SemanticPackageRuntimeModuleTable::default(),
564 profile: ApplicationPublicationProfileV1::Development,
565 output_root: "dist".into(),
566 environment_artifact: None,
567 v2_authoring: BTreeMap::new(),
568 })
569 .expect("route loader handoff publication");
570
571 let plan =
572 String::from_utf8(product.artifacts[&PathBuf::from("route-loaders.plan.json")].clone())
573 .unwrap();
574 assert!(plan.contains("post-service"));
575 assert!(plan.contains("route_parameters"));
576 assert!(plan.contains("max_age_seconds"));
577 assert!(!plan.contains("function loadPost"));
578 }
579
580 #[test]
581 fn publishes_an_exact_route_server_action_handoff_without_server_execution() {
582 let contract = crate::parse_semantic_package_contract(
583 r#"{"schema_version":1,"package":"post-service","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"savePost":{"kind":"server_action","type_signature":"FormData -> ServerActionResult","runtime_module":"dist/save-post.js","resume_policy":"cold_fallback","server_action":{"input":"form_data","response":"redirect","failure":"typed"}}}}"#,
584 )
585 .unwrap();
586 let mut contracts = SemanticPackageResolutionTable::default();
587 contracts.insert("post-service".into(), contract).unwrap();
588 let product = build_file_route_publication_v1(FileRoutePublicationRequestV1 {
589 configuration: crate::platform::WorkspaceConfiguration::default(),
590 sources: vec![ApplicationPublicationSourceV1 {
591 logical_path: "app/routes/posts/[slug].tsx".into(),
592 source: r#"
593import { savePost } from "post-service";
594@component() class Post {
595 @serverAction("savePost") save(): void {}
596 render() { return <form />; }
597}
598"#
599 .into(),
600 }],
601 package_contracts: contracts,
602 package_runtime_modules: SemanticPackageRuntimeModuleTable::default(),
603 profile: ApplicationPublicationProfileV1::Development,
604 output_root: "dist".into(),
605 environment_artifact: None,
606 v2_authoring: BTreeMap::new(),
607 })
608 .expect("route server-action handoff publication");
609
610 let plan = String::from_utf8(
611 product.artifacts[&PathBuf::from("route-server-actions.plan.json")].clone(),
612 )
613 .unwrap();
614 assert!(plan.contains("post-service"));
615 assert!(plan.contains("form_data"));
616 assert!(plan.contains("redirect"));
617 assert!(plan.contains("cold_fallback"));
618 assert!(!plan.contains("function savePost"));
619 }
620
621 #[test]
622 fn resolves_page_and_asset_paths_from_the_compiler_manifest() {
623 let manifest = FileRoutePublicationManifestV1 {
624 schema_version: 1,
625 compiler_contract: FILE_ROUTE_PUBLICATION_COMPILER_CONTRACT_V1.into(),
626 profile: "development".into(),
627 routes: vec![
628 FileRoutePublicationRouteV1 {
629 path: "/posts/:slug".into(),
630 entry_component_id: "component:post".into(),
631 artifact_root: "routes/segment-posts/parameter-slug".into(),
632 layout_component_ids: Vec::new(),
633 },
634 FileRoutePublicationRouteV1 {
635 path: "/posts/new".into(),
636 entry_component_id: "component:new".into(),
637 artifact_root: "routes/segment-posts/segment-new".into(),
638 layout_component_ids: Vec::new(),
639 },
640 ],
641 artifacts: Vec::new(),
642 };
643 assert_eq!(
644 resolve_file_route_request_v1(&manifest, "/posts/new"),
645 Some(FileRouteRequestTargetV1::Redirect {
646 location: "/posts/new/".into()
647 })
648 );
649 assert_eq!(
650 resolve_file_route_request_v1(&manifest, "/posts/hello/runtime.js"),
651 Some(FileRouteRequestTargetV1::Artifact {
652 path: "routes/segment-posts/parameter-slug/runtime.js".into()
653 })
654 );
655 }
656
657 #[test]
658 fn retains_dynamic_parameter_facts_from_the_selected_compiler_route() {
659 let manifest = FileRoutePublicationManifestV1 {
660 schema_version: 1,
661 compiler_contract: FILE_ROUTE_PUBLICATION_COMPILER_CONTRACT_V1.into(),
662 profile: "development".into(),
663 routes: vec![FileRoutePublicationRouteV1 {
664 path: "/posts/:slug/comments/:commentId".into(),
665 entry_component_id: "component:comment".into(),
666 artifact_root: "routes/comment".into(),
667 layout_component_ids: Vec::new(),
668 }],
669 artifacts: Vec::new(),
670 };
671
672 let resolved =
673 resolve_file_route_request_match_v1(&manifest, "/posts/hello-world/comments/42/")
674 .expect("selected route");
675 assert_eq!(resolved.route_path, "/posts/:slug/comments/:commentId");
676 assert_eq!(resolved.entry_component_id, "component:comment");
677 assert_eq!(
678 resolved.parameters,
679 BTreeMap::from([
680 ("slug".to_string(), "hello-world".to_string()),
681 ("commentId".to_string(), "42".to_string()),
682 ])
683 );
684 assert_eq!(
685 resolved.target,
686 FileRouteRequestTargetV1::Artifact {
687 path: "routes/comment/index.html".into(),
688 }
689 );
690 }
691}