Skip to content

Commit

Permalink
Merge pull request #13 from ckastbjerg/issue-12-support-catch-all-routes
Browse files Browse the repository at this point in the history
Support catch all routes
  • Loading branch information
ckastbjerg authored Apr 20, 2021
2 parents 1369000 + 2bb298e commit a724f0c
Show file tree
Hide file tree
Showing 23 changed files with 174 additions and 100 deletions.
78 changes: 38 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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`:
Expand Down Expand Up @@ -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 };
Expand All @@ -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;
};
```

Expand Down
4 changes: 2 additions & 2 deletions example/src/@types/next-type-safe-routes/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
9 changes: 9 additions & 0 deletions example/src/pages/catch-all/[...slug].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useRouter } from "hooks";

const CatchAll = () => {
const router = useRouter();
const slug = router.query.slug as string[];
return <div>Slugs: {slug.join(",")}</div>;
};

export default CatchAll;
21 changes: 20 additions & 1 deletion example/src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
import { Link } from "components";
import { useRouter } from "hooks";

const Home = () => {
const { push } = useRouter();
return <button onClick={() => push("/users")}>Show users</button>;
return (
<>
<button onClick={() => push("/users")}>Show users</button>
<Link to="/optional-catch-all">Optional catch all (no path)</Link>
<Link to={{ route: "/optional-catch-all", path: "/a/b/c" }}>
Optional catch all
</Link>
<Link to={{ route: "/catch-all", path: "/a/b/c" }}>Catch all</Link>
<Link
to={{
route: "/nested-catch-all/[dynamic]/slugs",
params: { dynamic: 1 },
path: "/a/b/c",
}}
>
Nested catch all (with params)
</Link>
</>
);
};

export default Home;
14 changes: 14 additions & 0 deletions example/src/pages/nested-catch-all/[dynamic]/slugs/[...slug].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useRouter } from "hooks";

const CatchAll = () => {
const router = useRouter();
const { dynamic, slug } = router.query;
return (
<div>
<div>dynamic: {dynamic}</div>
<div>Slugs: {(slug as string[]).join(",")}</div>
</div>
);
};

export default CatchAll;
9 changes: 9 additions & 0 deletions example/src/pages/optional-catch-all/[[...slug]].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useRouter } from "hooks";

const OptionalCatchAll = () => {
const router = useRouter();
const slug = router.query.slug;
return <div>{slug ? (slug as string[]).join(",") : "no slug"}</div>;
};

export default OptionalCatchAll;
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
17 changes: 13 additions & 4 deletions src/plugin/generateTypeScriptFile/generateTypeScriptFile.ts
Original file line number Diff line number Diff line change
@@ -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, {
Expand All @@ -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;
Expand Down
15 changes: 0 additions & 15 deletions src/plugin/generateTypeScriptFile/getApiRoutes.ts

This file was deleted.

32 changes: 24 additions & 8 deletions src/plugin/generateTypeScriptFile/getFileContent.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
8 changes: 8 additions & 0 deletions src/plugin/generateTypeScriptFile/getNextPageRoute.ts
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
9 changes: 7 additions & 2 deletions src/plugin/generateTypeScriptFile/getNextRouteUrlParams.ts
Original file line number Diff line number Diff line change
@@ -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;
18 changes: 0 additions & 18 deletions src/plugin/generateTypeScriptFile/getPages.ts

This file was deleted.

17 changes: 17 additions & 0 deletions src/plugin/generateTypeScriptFile/getRoutes.ts
Original file line number Diff line number Diff line change
@@ -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;
Empty file.
Empty file.
Empty file.
Empty file.
7 changes: 3 additions & 4 deletions src/plugin/generateTypeScriptFile/types.ts
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions src/plugin/generateTypeScriptFile/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const getIsCatchAllRoute = (route: string) => !!route.match(/\[\.\.\./);
export const getIsOptionalCatchAllRoute = (route: string) =>
!!route.match(/\[\[\.\.\./);
Loading

0 comments on commit a724f0c

Please sign in to comment.