From 84675069159c8e24dba0b6ada791deda88b77d20 Mon Sep 17 00:00:00 2001 From: Valerio Ageno <51341197+Valerioageno@users.noreply.github.com> Date: Tue, 10 Dec 2024 19:30:35 +0100 Subject: [PATCH] feat: create `[...catch_all]` route (#210) --- crates/tuono/src/route.rs | 27 ++++++----- crates/tuono/src/source_builder.rs | 3 +- crates/tuono/tests/cli_tests.rs | 46 +++++++++++++++++-- crates/tuono_lib/src/payload.rs | 46 ++++++++++++++++++- .../fs-router-vite-plugin/src/generator.ts | 6 +-- packages/fs-router-vite-plugin/src/utils.ts | 18 +------- .../generator/catch_all/routeTree.expected.ts | 31 +++++++++++++ .../generator/catch_all/routes/index.tsx | 1 + .../catch_all/routes/posts/[...catch_all].tsx | 1 + packages/router/src/hooks/useRoute.spec.tsx | 6 +++ packages/router/src/hooks/useRoute.tsx | 5 ++ .../router/src/hooks/useServerSideProps.tsx | 3 +- 12 files changed, 152 insertions(+), 41 deletions(-) create mode 100644 packages/fs-router-vite-plugin/tests/generator/catch_all/routeTree.expected.ts create mode 100644 packages/fs-router-vite-plugin/tests/generator/catch_all/routes/index.tsx create mode 100644 packages/fs-router-vite-plugin/tests/generator/catch_all/routes/posts/[...catch_all].tsx diff --git a/crates/tuono/src/route.rs b/crates/tuono/src/route.rs index b37a181b..214bf8f6 100644 --- a/crates/tuono/src/route.rs +++ b/crates/tuono/src/route.rs @@ -22,12 +22,12 @@ pub struct AxumInfo { } impl AxumInfo { - pub fn new(path: String) -> Self { + pub fn new(route: &Route) -> Self { // Remove first slash - let mut module = path.chars(); + let mut module = route.path.chars(); module.next(); - let axum_route = path.replace("/index", ""); + let axum_route = route.path.replace("/index", ""); let module_import = module .as_str() @@ -44,7 +44,7 @@ impl AxumInfo { }; } - if has_dynamic_path(&path) { + if route.is_dynamic { return AxumInfo { module_import: module .as_str() @@ -52,8 +52,12 @@ impl AxumInfo { .replace('/', "_") .replace('-', "_hyphen_") .replace('[', "dyn_") + .replace("...", "_catch_all_") + .replace(']', ""), + axum_route: axum_route + .replace("[...", "*") + .replace('[', ":") .replace(']', ""), - axum_route: axum_route.replace('[', ":").replace(']', ""), }; } @@ -134,7 +138,7 @@ impl Route { } pub fn update_axum_info(&mut self) { - self.axum_info = Some(AxumInfo::new(self.path.clone())) + self.axum_info = Some(AxumInfo::new(self)) } pub fn save_ssg_file(&self, reqwest: &Client) { @@ -159,8 +163,7 @@ impl Route { // Saving also the server response if self.axum_info.is_some() { - let data_file_path = - PathBuf::from(&format!("out/static/__tuono/data{}/data.json", path)); + let data_file_path = PathBuf::from(&format!("out/static/__tuono/data{path}")); let data_parent_dir = data_file_path.parent().unwrap(); @@ -169,11 +172,11 @@ impl Route { .expect("Failed to create data parent directories"); } - let base = Url::parse("http://localhost:3000/__tuono/data/data.json").unwrap(); + let base = Url::parse("http://localhost:3000/__tuono/data").unwrap(); let path = if path == "/" { "" } else { path }; - let pathname = &format!("/__tuono/data{path}/data.json"); + let pathname = &format!("/__tuono/data{path}"); let url = base .join(pathname) @@ -229,12 +232,12 @@ mod tests { #[test] fn should_correctly_create_the_axum_infos() { - let info = AxumInfo::new("/index".to_string()); + let info = AxumInfo::new(&Route::new("/index".to_string())); assert_eq!(info.axum_route, "/"); assert_eq!(info.module_import, "index"); - let dyn_info = AxumInfo::new("/[posts]".to_string()); + let dyn_info = AxumInfo::new(&Route::new("/[posts]".to_string())); assert_eq!(dyn_info.axum_route, "/:posts"); assert_eq!(dyn_info.module_import, "dyn_posts"); diff --git a/crates/tuono/src/source_builder.rs b/crates/tuono/src/source_builder.rs index 0fc67a1f..4199dc12 100644 --- a/crates/tuono/src/source_builder.rs +++ b/crates/tuono/src/source_builder.rs @@ -86,10 +86,9 @@ fn create_routes_declaration(routes: &HashMap) -> String { route_declarations.push_str(&format!( r#".route("{axum_route}", get({module_import}::route))"# )); - let slash = if axum_route.ends_with('/') { "" } else { "/" }; route_declarations.push_str(&format!( - r#".route("/__tuono/data{axum_route}{slash}data.json", get({module_import}::api))"# + r#".route("/__tuono/data{axum_route}", get({module_import}::api))"# )); } else { for method in route.api_data.as_ref().unwrap().methods.clone() { diff --git a/crates/tuono/tests/cli_tests.rs b/crates/tuono/tests/cli_tests.rs index cce8a474..b2b8a3ff 100644 --- a/crates/tuono/tests/cli_tests.rs +++ b/crates/tuono/tests/cli_tests.rs @@ -29,9 +29,8 @@ fn it_successfully_create_the_index_route() { assert!(temp_main_rs_content.contains(r#"#[path="../src/routes/index.rs"]"#)); assert!(temp_main_rs_content.contains("mod index;")); - assert!(temp_main_rs_content.contains( - r#".route("/", get(index::route)).route("/__tuono/data/data.json", get(index::api))"# - )); + assert!(temp_main_rs_content + .contains(r#".route("/", get(index::route)).route("/__tuono/data/", get(index::api))"#)); } #[test] @@ -95,3 +94,44 @@ fn it_successfully_create_multiple_api_for_the_same_file() { r#".route("/api/health_check", get(api_health_check::get__tuono_internal_api))"# )); } + +#[test] +#[serial] +fn it_successfully_create_catch_all_routes() { + let temp_tuono_project = TempTuonoProject::new(); + + temp_tuono_project.add_route("./src/routes/[...all_routes].rs"); + + temp_tuono_project.add_api( + "./src/routes/api/[...all_apis].rs", + &format!("{POST_API_FILE}"), + ); + + let mut test_tuono_build = Command::cargo_bin("tuono").unwrap(); + test_tuono_build + .arg("build") + .arg("--no-js-emit") + .assert() + .success(); + + let temp_main_rs_path = temp_tuono_project.path().join(".tuono/main.rs"); + + let temp_main_rs_content = + fs::read_to_string(&temp_main_rs_path).expect("Failed to read '.tuono/main.rs' content."); + + assert!(temp_main_rs_content.contains(r#"#[path="../src/routes/api/[...all_apis].rs"]"#)); + assert!(temp_main_rs_content.contains("mod api_dyn__catch_all_all_apis;")); + + assert!(temp_main_rs_content.contains(r#"#[path="../src/routes/[...all_routes].rs"]"#)); + assert!(temp_main_rs_content.contains("mod dyn__catch_all_all_routes;")); + + assert!(temp_main_rs_content.contains( + r#".route("/api/*all_apis", post(api_dyn__catch_all_all_apis::post__tuono_internal_api))"# + )); + + assert!(temp_main_rs_content + .contains(r#".route("/*all_routes", get(dyn__catch_all_all_routes::route))"#)); + + assert!(temp_main_rs_content + .contains(r#".route("/__tuono/data/*all_routes", get(dyn__catch_all_all_routes::api))"#)); +} diff --git a/crates/tuono_lib/src/payload.rs b/crates/tuono_lib/src/payload.rs index 609a4891..22efacea 100644 --- a/crates/tuono_lib/src/payload.rs +++ b/crates/tuono_lib/src/payload.rs @@ -84,7 +84,7 @@ impl<'a> Payload<'a> { .filter(|path| !path.is_empty()) .collect::>(); - for dyn_route in dynamic_routes.iter() { + '_dynamic_routes_loop: for dyn_route in dynamic_routes.iter() { let dyn_route_segments = dyn_route .split('/') .filter(|path| !path.is_empty()) @@ -93,6 +93,20 @@ impl<'a> Payload<'a> { let mut route_segments_collector: Vec<&str> = vec![]; for i in 0..dyn_route_segments.len() { + if dyn_route_segments[i].starts_with("[...") { + route_segments_collector.push(dyn_route_segments[i]); + + let manifest_key = route_segments_collector.join("/"); + + let route_data = manifest.get(&format!("/{manifest_key}")); + if let Some(data) = route_data { + js_bundles_sources.push(&data.file); + data.css + .iter() + .for_each(|source| css_bundles_sources.push(source)) + } + break '_dynamic_routes_loop; + } if path_segments.len() == i { break; } @@ -168,6 +182,14 @@ mod tests { css: vec!["assets/posts/[post]/[comment].css".to_string()], }, ); + manifest_mock.insert( + "/pokemons/[...catch_all]".to_string(), + BundleInfo { + file: "assets/catch_all.js".to_string(), + css: vec!["assets/catch_all.css".to_string()], + }, + ); + manifest_mock.insert( "/posts/custom-post".to_string(), BundleInfo { @@ -274,6 +296,28 @@ mod tests { ); } + #[test] + fn should_load_the_correct_catch_all_bundles() { + let mut payload = prepare_payload( + Some("http://localhost:3000/pokemons/a-poke/a-poke"), + Mode::Prod, + ); + let _ = payload.client_payload(); + assert_eq!( + payload.js_bundles, + Some(vec![ + &"assets/bundled-file.js".to_string(), + &"assets/catch_all.js".to_string() + ]) + ); + assert_eq!( + payload.css_bundles, + Some(vec![ + &"assets/bundled-file.css".to_string(), + &"assets/catch_all.css".to_string() + ]) + ); + } #[test] fn should_load_the_defined_path_bundles() { let mut payload = prepare_payload(Some("http://localhost:3000/about"), Mode::Prod); diff --git a/packages/fs-router-vite-plugin/src/generator.ts b/packages/fs-router-vite-plugin/src/generator.ts index 41a7628b..20f640c7 100644 --- a/packages/fs-router-vite-plugin/src/generator.ts +++ b/packages/fs-router-vite-plugin/src/generator.ts @@ -15,7 +15,6 @@ import { removeGroups, removeLastSlash, removeUnderscores, - removeLayoutSegments, } from './utils' import type { Config, RouteNode } from './types' @@ -61,8 +60,7 @@ async function getRouteNodes( } const filePath = replaceBackslash(path.join(dir, dirent.name)) const filePathNoExt = removeExt(filePath) - let routePath = - cleanPath(`/${filePathNoExt.split('.').join('/')}`) || '' + let routePath = cleanPath(`/${filePathNoExt}`) || '' const variableName = routePathToVariable(routePath) @@ -139,7 +137,7 @@ export async function routeGenerator(config = defaultConfig): Promise { node.path = determineNodePath(node) node.cleanedPath = removeLastSlash( - removeGroups(removeUnderscores(removeLayoutSegments(node.path)) ?? ''), + removeGroups(removeUnderscores(node.path) ?? ''), ) if (node.parent) { diff --git a/packages/fs-router-vite-plugin/src/utils.ts b/packages/fs-router-vite-plugin/src/utils.ts index c1d949a5..37fac9d6 100644 --- a/packages/fs-router-vite-plugin/src/utils.ts +++ b/packages/fs-router-vite-plugin/src/utils.ts @@ -1,7 +1,5 @@ import type { RouteNode } from './types' -const ROUTE_GROUP_PATTERN_REGEX = /\(.+\)/g - export function removeExt(d: string, keepExtension: boolean = false): string { return keepExtension ? d : d.substring(0, d.lastIndexOf('.')) || d } @@ -78,22 +76,8 @@ export function removeTrailingUnderscores(s?: string): string | undefined { return s?.replaceAll(/(_$)/gi, '').replaceAll(/(_\/)/gi, '/') } -/** - * Removes all segments from a given path that start with an underscore ('_'). - * - * @param routePath - The path from which to remove segments. Defaults to '/'. - * @returns The path with all underscore-prefixed segments removed. - * @example - * removeLayoutSegments('/workspace/_auth/foo') // '/workspace/foo' - */ -export function removeLayoutSegments(routePath: string = '/'): string { - const segments = routePath.split('/') - const newSegments = segments.filter((segment) => !segment.startsWith('_')) - return newSegments.join('/') -} - export function removeGroups(s: string): string { - return s.replaceAll(ROUTE_GROUP_PATTERN_REGEX, '').replaceAll('//', '/') + return s.replaceAll('//', '/') } export function trimPathLeft(pathToTrim: string): string { diff --git a/packages/fs-router-vite-plugin/tests/generator/catch_all/routeTree.expected.ts b/packages/fs-router-vite-plugin/tests/generator/catch_all/routeTree.expected.ts new file mode 100644 index 00000000..6c489f80 --- /dev/null +++ b/packages/fs-router-vite-plugin/tests/generator/catch_all/routeTree.expected.ts @@ -0,0 +1,31 @@ +// This file is auto-generated by Tuono + +import { createRoute, dynamic } from 'tuono' + +import RootImport from './routes/__root' + +const IndexImport = dynamic(() => import('./routes/index')) +const PostscatchallImport = dynamic( + () => import('./routes/posts/[...catch_all]'), +) + +const rootRoute = createRoute({ isRoot: true, component: RootImport }) + +const Index = createRoute({ component: IndexImport }) +const Postscatchall = createRoute({ component: PostscatchallImport }) + +// Create/Update Routes + +const IndexRoute = Index.update({ + path: '/', + getParentRoute: () => rootRoute, +}) + +const PostscatchallRoute = Postscatchall.update({ + path: '/posts/[...catch_all]', + getParentRoute: () => rootRoute, +}) + +// Create and export the route tree + +export const routeTree = rootRoute.addChildren([IndexRoute, PostscatchallRoute]) diff --git a/packages/fs-router-vite-plugin/tests/generator/catch_all/routes/index.tsx b/packages/fs-router-vite-plugin/tests/generator/catch_all/routes/index.tsx new file mode 100644 index 00000000..cc6be575 --- /dev/null +++ b/packages/fs-router-vite-plugin/tests/generator/catch_all/routes/index.tsx @@ -0,0 +1 @@ +/** export default */ diff --git a/packages/fs-router-vite-plugin/tests/generator/catch_all/routes/posts/[...catch_all].tsx b/packages/fs-router-vite-plugin/tests/generator/catch_all/routes/posts/[...catch_all].tsx new file mode 100644 index 00000000..cc6be575 --- /dev/null +++ b/packages/fs-router-vite-plugin/tests/generator/catch_all/routes/posts/[...catch_all].tsx @@ -0,0 +1 @@ +/** export default */ diff --git a/packages/router/src/hooks/useRoute.spec.tsx b/packages/router/src/hooks/useRoute.spec.tsx index 60c5dbfa..f3a5eae2 100644 --- a/packages/router/src/hooks/useRoute.spec.tsx +++ b/packages/router/src/hooks/useRoute.spec.tsx @@ -17,6 +17,7 @@ describe('Test useRoute fn', () => { '/posts/[post]': { id: '/posts/[post]' }, '/posts/defined-post': { id: '/posts/defined-post' }, '/posts/[post]/[comment]': { id: '/posts/[post]/[comment]' }, + '/blog/[...catch_all]': { id: '/blog/[...catch_all]' }, }, } }, @@ -31,5 +32,10 @@ describe('Test useRoute fn', () => { expect(useRoute('/posts/dynamic-post/dynamic-comment')?.id).toBe( '/posts/[post]/[comment]', ) + expect(useRoute('/blog/catch_all')?.id).toBe('/blog/[...catch_all]') + expect(useRoute('/blog')?.id).toBe('/blog/[...catch_all]') + expect(useRoute('/blog/catch_all/catch_all')?.id).toBe( + '/blog/[...catch_all]', + ) }) }) diff --git a/packages/router/src/hooks/useRoute.tsx b/packages/router/src/hooks/useRoute.tsx index b7566066..73d0bc4f 100644 --- a/packages/router/src/hooks/useRoute.tsx +++ b/packages/router/src/hooks/useRoute.tsx @@ -50,6 +50,11 @@ export default function useRoute(pathname?: string): Route | undefined { const routeSegmentsCollector: Array = [] for (let i = 0; i < dynamicRouteSegments.length; i++) { + if (dynamicRouteSegments[i]?.startsWith('[...')) { + routeSegmentsCollector.push(dynamicRouteSegments[i] ?? '') + match = `/${routeSegmentsCollector.join('/')}` + break + } if ( dynamicRouteSegments[i] === pathSegments[i] || DYNAMIC_PATH_REGEX.test(dynamicRouteSegments[i] || '') diff --git a/packages/router/src/hooks/useServerSideProps.tsx b/packages/router/src/hooks/useServerSideProps.tsx index 77030d22..d6a8700f 100644 --- a/packages/router/src/hooks/useServerSideProps.tsx +++ b/packages/router/src/hooks/useServerSideProps.tsx @@ -20,8 +20,7 @@ interface TuonoApi { } const fetchClientSideData = async (): Promise => { - const slash = location.pathname.endsWith('/') ? '' : '/' - const res = await fetch(`/__tuono/data${location.pathname}${slash}data.json`) + const res = await fetch(`/__tuono/data${location.pathname}`) const data = (await res.json()) as TuonoApi return data }