feat: create [...catch_all] route (#210)

This commit is contained in:
Valerio Ageno
2024-12-10 19:30:35 +01:00
committed by GitHub
parent 704255774d
commit 8467506915
12 changed files with 152 additions and 41 deletions
+15 -12
View File
@@ -22,12 +22,12 @@ pub struct AxumInfo {
} }
impl AxumInfo { impl AxumInfo {
pub fn new(path: String) -> Self { pub fn new(route: &Route) -> Self {
// Remove first slash // Remove first slash
let mut module = path.chars(); let mut module = route.path.chars();
module.next(); module.next();
let axum_route = path.replace("/index", ""); let axum_route = route.path.replace("/index", "");
let module_import = module let module_import = module
.as_str() .as_str()
@@ -44,7 +44,7 @@ impl AxumInfo {
}; };
} }
if has_dynamic_path(&path) { if route.is_dynamic {
return AxumInfo { return AxumInfo {
module_import: module module_import: module
.as_str() .as_str()
@@ -52,8 +52,12 @@ impl AxumInfo {
.replace('/', "_") .replace('/', "_")
.replace('-', "_hyphen_") .replace('-', "_hyphen_")
.replace('[', "dyn_") .replace('[', "dyn_")
.replace("...", "_catch_all_")
.replace(']', ""),
axum_route: axum_route
.replace("[...", "*")
.replace('[', ":")
.replace(']', ""), .replace(']', ""),
axum_route: axum_route.replace('[', ":").replace(']', ""),
}; };
} }
@@ -134,7 +138,7 @@ impl Route {
} }
pub fn update_axum_info(&mut self) { 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) { pub fn save_ssg_file(&self, reqwest: &Client) {
@@ -159,8 +163,7 @@ impl Route {
// Saving also the server response // Saving also the server response
if self.axum_info.is_some() { if self.axum_info.is_some() {
let data_file_path = let data_file_path = PathBuf::from(&format!("out/static/__tuono/data{path}"));
PathBuf::from(&format!("out/static/__tuono/data{}/data.json", path));
let data_parent_dir = data_file_path.parent().unwrap(); let data_parent_dir = data_file_path.parent().unwrap();
@@ -169,11 +172,11 @@ impl Route {
.expect("Failed to create data parent directories"); .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 path = if path == "/" { "" } else { path };
let pathname = &format!("/__tuono/data{path}/data.json"); let pathname = &format!("/__tuono/data{path}");
let url = base let url = base
.join(pathname) .join(pathname)
@@ -229,12 +232,12 @@ mod tests {
#[test] #[test]
fn should_correctly_create_the_axum_infos() { 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.axum_route, "/");
assert_eq!(info.module_import, "index"); 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.axum_route, "/:posts");
assert_eq!(dyn_info.module_import, "dyn_posts"); assert_eq!(dyn_info.module_import, "dyn_posts");
+1 -2
View File
@@ -86,10 +86,9 @@ fn create_routes_declaration(routes: &HashMap<String, Route>) -> String {
route_declarations.push_str(&format!( route_declarations.push_str(&format!(
r#".route("{axum_route}", get({module_import}::route))"# r#".route("{axum_route}", get({module_import}::route))"#
)); ));
let slash = if axum_route.ends_with('/') { "" } else { "/" };
route_declarations.push_str(&format!( 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 { } else {
for method in route.api_data.as_ref().unwrap().methods.clone() { for method in route.api_data.as_ref().unwrap().methods.clone() {
+43 -3
View File
@@ -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(r#"#[path="../src/routes/index.rs"]"#));
assert!(temp_main_rs_content.contains("mod index;")); assert!(temp_main_rs_content.contains("mod index;"));
assert!(temp_main_rs_content.contains( assert!(temp_main_rs_content
r#".route("/", get(index::route)).route("/__tuono/data/data.json", get(index::api))"# .contains(r#".route("/", get(index::route)).route("/__tuono/data/", get(index::api))"#));
));
} }
#[test] #[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))"# 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))"#));
}
+45 -1
View File
@@ -84,7 +84,7 @@ impl<'a> Payload<'a> {
.filter(|path| !path.is_empty()) .filter(|path| !path.is_empty())
.collect::<Vec<&str>>(); .collect::<Vec<&str>>();
for dyn_route in dynamic_routes.iter() { '_dynamic_routes_loop: for dyn_route in dynamic_routes.iter() {
let dyn_route_segments = dyn_route let dyn_route_segments = dyn_route
.split('/') .split('/')
.filter(|path| !path.is_empty()) .filter(|path| !path.is_empty())
@@ -93,6 +93,20 @@ impl<'a> Payload<'a> {
let mut route_segments_collector: Vec<&str> = vec![]; let mut route_segments_collector: Vec<&str> = vec![];
for i in 0..dyn_route_segments.len() { 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 { if path_segments.len() == i {
break; break;
} }
@@ -168,6 +182,14 @@ mod tests {
css: vec!["assets/posts/[post]/[comment].css".to_string()], 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( manifest_mock.insert(
"/posts/custom-post".to_string(), "/posts/custom-post".to_string(),
BundleInfo { 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] #[test]
fn should_load_the_defined_path_bundles() { fn should_load_the_defined_path_bundles() {
let mut payload = prepare_payload(Some("http://localhost:3000/about"), Mode::Prod); let mut payload = prepare_payload(Some("http://localhost:3000/about"), Mode::Prod);
@@ -15,7 +15,6 @@ import {
removeGroups, removeGroups,
removeLastSlash, removeLastSlash,
removeUnderscores, removeUnderscores,
removeLayoutSegments,
} from './utils' } from './utils'
import type { Config, RouteNode } from './types' import type { Config, RouteNode } from './types'
@@ -61,8 +60,7 @@ async function getRouteNodes(
} }
const filePath = replaceBackslash(path.join(dir, dirent.name)) const filePath = replaceBackslash(path.join(dir, dirent.name))
const filePathNoExt = removeExt(filePath) const filePathNoExt = removeExt(filePath)
let routePath = let routePath = cleanPath(`/${filePathNoExt}`) || ''
cleanPath(`/${filePathNoExt.split('.').join('/')}`) || ''
const variableName = routePathToVariable(routePath) const variableName = routePathToVariable(routePath)
@@ -139,7 +137,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
node.path = determineNodePath(node) node.path = determineNodePath(node)
node.cleanedPath = removeLastSlash( node.cleanedPath = removeLastSlash(
removeGroups(removeUnderscores(removeLayoutSegments(node.path)) ?? ''), removeGroups(removeUnderscores(node.path) ?? ''),
) )
if (node.parent) { if (node.parent) {
+1 -17
View File
@@ -1,7 +1,5 @@
import type { RouteNode } from './types' import type { RouteNode } from './types'
const ROUTE_GROUP_PATTERN_REGEX = /\(.+\)/g
export function removeExt(d: string, keepExtension: boolean = false): string { export function removeExt(d: string, keepExtension: boolean = false): string {
return keepExtension ? d : d.substring(0, d.lastIndexOf('.')) || d 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, '/') 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 { export function removeGroups(s: string): string {
return s.replaceAll(ROUTE_GROUP_PATTERN_REGEX, '').replaceAll('//', '/') return s.replaceAll('//', '/')
} }
export function trimPathLeft(pathToTrim: string): string { export function trimPathLeft(pathToTrim: string): string {
@@ -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])
@@ -0,0 +1 @@
/** export default */
@@ -0,0 +1 @@
/** export default */
@@ -17,6 +17,7 @@ describe('Test useRoute fn', () => {
'/posts/[post]': { id: '/posts/[post]' }, '/posts/[post]': { id: '/posts/[post]' },
'/posts/defined-post': { id: '/posts/defined-post' }, '/posts/defined-post': { id: '/posts/defined-post' },
'/posts/[post]/[comment]': { id: '/posts/[post]/[comment]' }, '/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( expect(useRoute('/posts/dynamic-post/dynamic-comment')?.id).toBe(
'/posts/[post]/[comment]', '/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]',
)
}) })
}) })
+5
View File
@@ -50,6 +50,11 @@ export default function useRoute(pathname?: string): Route | undefined {
const routeSegmentsCollector: Array<string> = [] const routeSegmentsCollector: Array<string> = []
for (let i = 0; i < dynamicRouteSegments.length; i++) { for (let i = 0; i < dynamicRouteSegments.length; i++) {
if (dynamicRouteSegments[i]?.startsWith('[...')) {
routeSegmentsCollector.push(dynamicRouteSegments[i] ?? '')
match = `/${routeSegmentsCollector.join('/')}`
break
}
if ( if (
dynamicRouteSegments[i] === pathSegments[i] || dynamicRouteSegments[i] === pathSegments[i] ||
DYNAMIC_PATH_REGEX.test(dynamicRouteSegments[i] || '') DYNAMIC_PATH_REGEX.test(dynamicRouteSegments[i] || '')
@@ -20,8 +20,7 @@ interface TuonoApi {
} }
const fetchClientSideData = async (): Promise<TuonoApi> => { const fetchClientSideData = async (): Promise<TuonoApi> => {
const slash = location.pathname.endsWith('/') ? '' : '/' const res = await fetch(`/__tuono/data${location.pathname}`)
const res = await fetch(`/__tuono/data${location.pathname}${slash}data.json`)
const data = (await res.json()) as TuonoApi const data = (await res.json()) as TuonoApi
return data return data
} }