diff --git a/README.md b/README.md index 077a951..04d1813 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,12 @@ You can now import the `getRoute` util from `next-type-safe-routes` and use it t ```ts import { getRoute } from "next-type-safe-routes"; -// for simple routes +// for simple routes (e.g. the file `/pages/users.tsx`) getRoute("/users"); -// for dynamic routes +// for dynamic routes (e.g. the file `/pages/users/[userId]/index.tsx`) getRoute({ route: "/users/[userId]", params: { userId: "1" } }); +// for catch all routes (e.g. the file `/pages/catch-all/[[...slug]].tsx`) +getRoute({ route: "/catch-all", path: "/a/b/c" }); ``` Now you just need to decide how you want to integrate `next-type-safe-routes` in your project. If you want inspiration, we demonstrate how to create a simple abstraction for the Next.js `Link` and `router` in [the example project](/example/src). @@ -99,69 +101,48 @@ How you ensure that only links to existing pages is essentially up to you, but w A simple method that converts a type-safe route to an "actual" route. -First, import the method: +**Examples:** ```ts import { getRoute } from "next-type-safe-routes"; -``` - -For simple (non-dynamic) routes, you can simply do: - -```ts -const route = getRoute("/users"); -``` -This will simply return the string `/users`. +// For simple (non-dynamic) routes +const route = getRoute("/users"); // => "/users" -If you need to include a (non-typed) query (or just prefer being more explicit), you can pass an object like so: - -```ts +// With query params const route = getRoute({ route: "/users", query: { "not-typed": "whatevs" }, -}); -``` - -This will return the string `/users?not-typed=whatevs`. +}); // => "/users?not-typed=whatevs" -```ts +// For dynamic routes const route = getRoute({ route: "/users/[userId]", params: { userId: 1234 }, -}); +}); // => "/users/1234" + +// For catch all routes +const route = getRoute({ + route: "/catch-all", + path: "/can/be/anything", +}); // => "/catch-all/can/be/anything" ``` -This will return the string `/users/1234`. +> [Optional catch all routes](https://nextjs.org/docs/routing/dynamic-routes#optional-catch-all-routes) are also supported. #### The `getPathname` method -A simple method that just returns the pathname for a type-safe route. - -First, import the method: +The `getPathname` works similarly to the `getRoute`. It just returs a [Next.js pathname](https://nextjs.org/docs/api-reference/next/router#router-object). For instance: ```ts import { getPathname } from "next-type-safe-routes"; -``` - -For simple (non-dynamic) routes, you can simply do: - -```ts -const path = getPathname("/users"); -``` - -This will return the string `/users`. - -And for -```ts const path = getPathname({ route: "/users/[userId]", params: { userId: 1234 }, -}); +}); // => `/users/[userId]` ``` -This will return the string `/users/[userId]`. - #### The `TypeSafePage` and `TypeSafeApiRoute` types These can be useful for making your own abstraction. For instance, if you want to make a tiny abstraction ontop of the `next/router`: @@ -203,7 +184,18 @@ And for dynamic routes, the type is always: } ``` -**Example**: +And for [catch all routes](https://nextjs.org/docs/routing/dynamic-routes#catch-all-routes), a (non-typed) `path` will also be required (or optional for [optional catch all routes](https://nextjs.org/docs/routing/dynamic-routes#optional-catch-all-routes)): + +```ts +{ + route: string, + path: string, + params: { ... }, // based on the file name + query?: { ... } // any key value pairs (not type-safe) +} +``` + +**Examples**: ```ts type Query = { [key: string]: any }; @@ -214,6 +206,12 @@ export type TypeSafePage = route: "/users/[userId]"; params: { userId: string | number }; query?: Query; + } + | { + route: "/users/[userId]/catch-all-route"; + params: { userId: string | number }; + path="/catch/all/path" + query?: Query; }; ``` diff --git a/example/src/@types/next-type-safe-routes/index.d.ts b/example/src/@types/next-type-safe-routes/index.d.ts index e89b255..2b380ff 100644 --- a/example/src/@types/next-type-safe-routes/index.d.ts +++ b/example/src/@types/next-type-safe-routes/index.d.ts @@ -3,8 +3,8 @@ declare module "next-type-safe-routes" { type Query = { [key: string]: any }; - export type TypeSafePage = "/" | { route: "/", query?: Query } | { route: "/users/[userId]", params: { userId: string | string[] | number }, query?: Query } | "/users" | { route: "/users", query?: Query }; - export type TypeSafeApiRoute = "/api/mocks" | { route: "/api/mocks", query?: Query } | { route: "/api/users/[userId]", params: { userId: string | string[] | number }, query?: Query } | "/api/users" | { route: "/api/users", query?: Query }; + export type TypeSafePage = { route: "/catch-all", path: string, query?: Query } | "/" | { route: "/", query?: Query } | { route: "/nested-catch-all/[dynamic]/slugs", path: string, params: { dynamic: string | number }, query?: Query } | "/optional-catch-all" | { route: "/optional-catch-all", path?: string, query?: Query } | { route: "/users/[userId]", params: { userId: string | number }, query?: Query } | "/users" | { route: "/users", query?: Query }; + export type TypeSafeApiRoute = "/api/mocks" | { route: "/api/mocks", query?: Query } | { route: "/api/users/[userId]", params: { userId: string | number }, query?: Query } | "/api/users" | { route: "/api/users", query?: Query }; export const getPathname = (typeSafeUrl: TypeSafePage | TypeSafeApiRoute) => string; export const getRoute = (typeSafeUrl: TypeSafePage | TypeSafeApiRoute) => string; } diff --git a/example/src/pages/catch-all/[...slug].tsx b/example/src/pages/catch-all/[...slug].tsx new file mode 100644 index 0000000..391eace --- /dev/null +++ b/example/src/pages/catch-all/[...slug].tsx @@ -0,0 +1,9 @@ +import { useRouter } from "hooks"; + +const CatchAll = () => { + const router = useRouter(); + const slug = router.query.slug as string[]; + return
Slugs: {slug.join(",")}
; +}; + +export default CatchAll; diff --git a/example/src/pages/index.tsx b/example/src/pages/index.tsx index 1b2f4ca..d498b01 100644 --- a/example/src/pages/index.tsx +++ b/example/src/pages/index.tsx @@ -1,8 +1,27 @@ +import { Link } from "components"; import { useRouter } from "hooks"; const Home = () => { const { push } = useRouter(); - return ; + return ( + <> + + Optional catch all (no path) + + Optional catch all + + Catch all + + Nested catch all (with params) + + + ); }; export default Home; diff --git a/example/src/pages/nested-catch-all/[dynamic]/slugs/[...slug].tsx b/example/src/pages/nested-catch-all/[dynamic]/slugs/[...slug].tsx new file mode 100644 index 0000000..cc9a240 --- /dev/null +++ b/example/src/pages/nested-catch-all/[dynamic]/slugs/[...slug].tsx @@ -0,0 +1,14 @@ +import { useRouter } from "hooks"; + +const CatchAll = () => { + const router = useRouter(); + const { dynamic, slug } = router.query; + return ( +
+
dynamic: {dynamic}
+
Slugs: {(slug as string[]).join(",")}
+
+ ); +}; + +export default CatchAll; diff --git a/example/src/pages/optional-catch-all/[[...slug]].tsx b/example/src/pages/optional-catch-all/[[...slug]].tsx new file mode 100644 index 0000000..f830686 --- /dev/null +++ b/example/src/pages/optional-catch-all/[[...slug]].tsx @@ -0,0 +1,9 @@ +import { useRouter } from "hooks"; + +const OptionalCatchAll = () => { + const router = useRouter(); + const slug = router.query.slug; + return
{slug ? (slug as string[]).join(",") : "no slug"}
; +}; + +export default OptionalCatchAll; diff --git a/package.json b/package.json index 14c0d8a..613bdcd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "next-type-safe-routes", - "version": "0.2.0-alpha.1", + "version": "0.3.0-alpha.1", "description": "Never should your users experience broken links again!", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/plugin/generateTypeScriptFile/__snapshots__/generateTypeScriptFile.test.ts.snap b/src/plugin/generateTypeScriptFile/__snapshots__/generateTypeScriptFile.test.ts.snap index ad21c7d..766efe3 100644 --- a/src/plugin/generateTypeScriptFile/__snapshots__/generateTypeScriptFile.test.ts.snap +++ b/src/plugin/generateTypeScriptFile/__snapshots__/generateTypeScriptFile.test.ts.snap @@ -6,8 +6,8 @@ exports[`plugin/generateTypeScriptFile works as expected 1`] = ` declare module \\"next-type-safe-routes\\" { type Query = { [key: string]: any }; - export type TypeSafePage = \\"/404\\" | { route: \\"/404\\", query?: Query } | \\"/\\" | { route: \\"/\\", query?: Query } | { route: \\"/users/[userId]\\", params: { userId: string | string[] | number }, query?: Query } | \\"/users\\" | { route: \\"/users\\", query?: Query }; - export type TypeSafeApiRoute = { route: \\"/api/[authId]\\", params: { authId: string | string[] | number }, query?: Query } | { route: \\"/api/users/[userId]\\", params: { userId: string | string[] | number }, query?: Query } | \\"/api/users\\" | { route: \\"/api/users\\", query?: Query }; + export type TypeSafePage = \\"/404\\" | { route: \\"/404\\", query?: Query } | { route: \\"/catch-all\\", path: string, query?: Query } | \\"/\\" | { route: \\"/\\", query?: Query } | { route: \\"/nested-catch-all/[dynamic]/slugs\\", path: string, params: { dynamic: string | number }, query?: Query } | \\"/optional-catch-all\\" | { route: \\"/optional-catch-all\\", path?: string, query?: Query } | { route: \\"/users/[userId]\\", params: { userId: string | number }, query?: Query } | \\"/users\\" | { route: \\"/users\\", query?: Query }; + export type TypeSafeApiRoute = { route: \\"/api/[authId]\\", params: { authId: string | number }, query?: Query } | { route: \\"/api/catch-all\\", path: string, query?: Query } | \\"/api/optional-catch-all\\" | { route: \\"/api/optional-catch-all\\", path?: string, query?: Query } | { route: \\"/api/users/[userId]\\", params: { userId: string | number }, query?: Query } | \\"/api/users\\" | { route: \\"/api/users\\", query?: Query }; export const getPathname = (typeSafeUrl: TypeSafePage | TypeSafeApiRoute) => string; export const getRoute = (typeSafeUrl: TypeSafePage | TypeSafeApiRoute) => string; } diff --git a/src/plugin/generateTypeScriptFile/generateTypeScriptFile.ts b/src/plugin/generateTypeScriptFile/generateTypeScriptFile.ts index fc11efb..1767634 100644 --- a/src/plugin/generateTypeScriptFile/generateTypeScriptFile.ts +++ b/src/plugin/generateTypeScriptFile/generateTypeScriptFile.ts @@ -1,8 +1,12 @@ import walkSync from "walk-sync"; -import getApiRoutes from "./getApiRoutes"; import getFileContent from "./getFileContent"; -import getPages from "./getPages"; +import getRoutes from "./getRoutes"; + +const ignorePagesRoutes = ["_app.tsx", "_document.tsx"]; +const shouldIncludePageEntry = (route: string) => + route.match(".tsx") && !ignorePagesRoutes.includes(route); +const shouldIncludeApiRouteEntry = (endpoint: string) => endpoint.match(".ts"); const generateTypeScriptFile = (pagesDir: string) => { const pagesFiles = walkSync(pagesDir, { @@ -13,8 +17,13 @@ const generateTypeScriptFile = (pagesDir: string) => { directories: false, }); - const pages = getPages(pagesFiles.map((page) => `/${page}`)); - const apiRoutes = getApiRoutes(apiRouteFiles.map((page) => `/api/${page}`)); + const relevantPages = pagesFiles.filter(shouldIncludePageEntry); + const pages = getRoutes(relevantPages.map((page) => `/${page}`)); + const relavantApiRoutes = apiRouteFiles.filter(shouldIncludeApiRouteEntry); + const apiRoutes = getRoutes( + relavantApiRoutes.map((route) => `/api/${route}`) + ); + const fileContent = getFileContent({ pages, apiRoutes }); return fileContent; diff --git a/src/plugin/generateTypeScriptFile/getApiRoutes.ts b/src/plugin/generateTypeScriptFile/getApiRoutes.ts deleted file mode 100644 index 5e20526..0000000 --- a/src/plugin/generateTypeScriptFile/getApiRoutes.ts +++ /dev/null @@ -1,15 +0,0 @@ -import getNextPageRoute from "./getNextPageRoute"; -import getNextRouteUrlParams from "./getNextRouteUrlParams"; -import { ApiRoute } from "./types"; - -const shouldIncludeEntry = (endpoint: string) => endpoint.match(".ts"); - -const getApiRoutes = (fileNames: string[]): ApiRoute[] => { - return fileNames.filter(shouldIncludeEntry).map((fileName) => { - const route = getNextPageRoute(fileName); - const params = getNextRouteUrlParams(route); - return { route, params }; - }); -}; - -export default getApiRoutes; diff --git a/src/plugin/generateTypeScriptFile/getFileContent.ts b/src/plugin/generateTypeScriptFile/getFileContent.ts index e21622c..814f751 100644 --- a/src/plugin/generateTypeScriptFile/getFileContent.ts +++ b/src/plugin/generateTypeScriptFile/getFileContent.ts @@ -1,15 +1,31 @@ import { ApiRoute, Page } from "./types"; -const getParam = (param: string) => `${param}: string | string[] | number`; - -const getTypeSafeRoute = ({ route, params }: ApiRoute) => { +const getParam = (param: string) => `${param}: string | number`; + +const getTypeSafeRoute = ({ + route, + params, + isCatchAllRoute, + isOptionalCatchAllRoute, +}: ApiRoute) => { if (!params?.length) { - return `"${route}" | { route: "${route}", query?: Query }`; + if (isOptionalCatchAllRoute) { + return `"${route}" | { route: "${route}", path?: string, query?: Query }`; + } else if (isCatchAllRoute) { + return `{ route: "${route}", path: string, query?: Query }`; + } else { + return `"${route}" | { route: "${route}", query?: Query }`; + } + } else { + const paramsString = params.map(getParam).join(","); + if (isOptionalCatchAllRoute) { + return `"${route}" | { route: "${route}", path?: string, params: { ${paramsString} }, query?: Query }`; + } else if (isCatchAllRoute) { + return `{ route: "${route}", path: string, params: { ${paramsString} }, query?: Query }`; + } else { + return `{ route: "${route}", params: { ${paramsString} }, query?: Query }`; + } } - - const paramsString = params.map(getParam).join(","); - - return `{ route: "${route}", params: { ${paramsString} }, query?: Query }`; }; type Args = { diff --git a/src/plugin/generateTypeScriptFile/getNextPageRoute.ts b/src/plugin/generateTypeScriptFile/getNextPageRoute.ts index 2c78e92..38e5ebf 100644 --- a/src/plugin/generateTypeScriptFile/getNextPageRoute.ts +++ b/src/plugin/generateTypeScriptFile/getNextPageRoute.ts @@ -1,4 +1,12 @@ +import { getIsCatchAllRoute, getIsOptionalCatchAllRoute } from "./utils"; + const getNextPageRoute = (fileName: string) => { + if (getIsOptionalCatchAllRoute(fileName)) { + return fileName.split("/[[...")[0]; + } else if (getIsCatchAllRoute(fileName)) { + return fileName.split("/[...")[0]; + } + const route = fileName // remove the file extension .split(".")[0] diff --git a/src/plugin/generateTypeScriptFile/getNextRouteUrlParams.ts b/src/plugin/generateTypeScriptFile/getNextRouteUrlParams.ts index 115c540..ea8bef1 100644 --- a/src/plugin/generateTypeScriptFile/getNextRouteUrlParams.ts +++ b/src/plugin/generateTypeScriptFile/getNextRouteUrlParams.ts @@ -1,6 +1,11 @@ +const isCatchAllParam = (param: string) => param.match(/\.\.\./); const getNextRouteUrlParams = (href: string) => { - const params = href.match(/\[([^\]]+)\]/g); - return params?.map((param) => param.replace("[", "").replace("]", "")); + const paramStrings = href.match(/\[([^\]]+)\]/g); + const params = paramStrings + ?.filter((param) => !isCatchAllParam(param)) + .map((param) => param.replace("[", "").replace("]", "")); + + return !!params?.length ? params : undefined; }; export default getNextRouteUrlParams; diff --git a/src/plugin/generateTypeScriptFile/getPages.ts b/src/plugin/generateTypeScriptFile/getPages.ts deleted file mode 100644 index 607b0f8..0000000 --- a/src/plugin/generateTypeScriptFile/getPages.ts +++ /dev/null @@ -1,18 +0,0 @@ -import getNextPageRoute from "./getNextPageRoute"; -import getNextRouteUrlParams from "./getNextRouteUrlParams"; -import { Page } from "./types"; - -const ignoreRoutes = ["/_app.tsx", "/_document.tsx"]; - -const shouldIncludeEntry = (route: string) => - route.match(".tsx") && !ignoreRoutes.includes(route); - -const getPages = (fileNames: string[]): Page[] => { - return fileNames.filter(shouldIncludeEntry).map((fileName) => { - const route = getNextPageRoute(fileName); - const params = getNextRouteUrlParams(route); - return { route, params }; - }); -}; - -export default getPages; diff --git a/src/plugin/generateTypeScriptFile/getRoutes.ts b/src/plugin/generateTypeScriptFile/getRoutes.ts new file mode 100644 index 0000000..208dfc9 --- /dev/null +++ b/src/plugin/generateTypeScriptFile/getRoutes.ts @@ -0,0 +1,17 @@ +import getNextPageRoute from "./getNextPageRoute"; +import getNextRouteUrlParams from "./getNextRouteUrlParams"; +import { Page } from "./types"; +import { getIsCatchAllRoute, getIsOptionalCatchAllRoute } from "./utils"; + +const getRoutes = (fileNames: string[]): Page[] => { + return fileNames.map((fileName) => { + return { + route: getNextPageRoute(fileName), + params: getNextRouteUrlParams(fileName), + isCatchAllRoute: getIsCatchAllRoute(fileName), + isOptionalCatchAllRoute: getIsOptionalCatchAllRoute(fileName), + }; + }); +}; + +export default getRoutes; diff --git a/src/plugin/generateTypeScriptFile/mocks/pages/api/catch-all/[...slug].ts b/src/plugin/generateTypeScriptFile/mocks/pages/api/catch-all/[...slug].ts new file mode 100644 index 0000000..e69de29 diff --git a/src/plugin/generateTypeScriptFile/mocks/pages/api/optional-catch-all/[[...slug]].ts b/src/plugin/generateTypeScriptFile/mocks/pages/api/optional-catch-all/[[...slug]].ts new file mode 100644 index 0000000..e69de29 diff --git a/src/plugin/generateTypeScriptFile/mocks/pages/catch-all/[...slug].tsx b/src/plugin/generateTypeScriptFile/mocks/pages/catch-all/[...slug].tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/plugin/generateTypeScriptFile/mocks/pages/nested-catch-all/[dynamic]/slugs/[...slug].tsx b/src/plugin/generateTypeScriptFile/mocks/pages/nested-catch-all/[dynamic]/slugs/[...slug].tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/plugin/generateTypeScriptFile/mocks/pages/optional-catch-all/[[...slug]].tsx b/src/plugin/generateTypeScriptFile/mocks/pages/optional-catch-all/[[...slug]].tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/plugin/generateTypeScriptFile/types.ts b/src/plugin/generateTypeScriptFile/types.ts index 18c88d6..f3a5d74 100644 --- a/src/plugin/generateTypeScriptFile/types.ts +++ b/src/plugin/generateTypeScriptFile/types.ts @@ -1,9 +1,8 @@ export type Page = { route: string; params?: string[]; + isCatchAllRoute: boolean; + isOptionalCatchAllRoute: boolean; }; -export type ApiRoute = { - route: string; - params?: string[]; -}; +export type ApiRoute = Page; diff --git a/src/plugin/generateTypeScriptFile/utils.ts b/src/plugin/generateTypeScriptFile/utils.ts new file mode 100644 index 0000000..6c55007 --- /dev/null +++ b/src/plugin/generateTypeScriptFile/utils.ts @@ -0,0 +1,3 @@ +export const getIsCatchAllRoute = (route: string) => !!route.match(/\[\.\.\./); +export const getIsOptionalCatchAllRoute = (route: string) => + !!route.match(/\[\[\.\.\./); diff --git a/src/utils.ts b/src/utils.ts index 30df446..448f164 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,8 +3,8 @@ type Query = { [key: string]: any }; type TypeSafePage = | string - | { route: string; query?: Query } - | { route: string; params: any; query?: Query }; + | { route: string; path?: string; query?: Query } + | { route: string; path?: string; params: any; query?: Query }; type TypeSafeApiRoute = TypeSafePage; export const getPathname = (typeSafeUrl: TypeSafePage | TypeSafeApiRoute) => { @@ -36,6 +36,7 @@ export const getRoute = (typeSafeUrl: TypeSafePage | TypeSafeApiRoute) => { Object.keys(params).forEach((param) => { route = route.replace(`[${param}]`, (params as any)[param]); }); + const path = typeSafeUrl.path || ""; - return `${route}${searchParams}`; + return `${route}${path}${searchParams}`; };