mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-29 13:52:46 -07:00
feat: create [...catch_all] route (#210)
This commit is contained in:
+15
-12
@@ -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");
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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))"#));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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 */
|
||||||
+1
@@ -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]',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user