Repository: tatethurston/nextjs-routes (ts)

Issue:
[Question] Non-Link usage
I wanted to use this in a non-link. For example a fetch like `fetch('/foo')`, is there anyway to typecheck that foo?

I was thinking of doing a type like:

```
const ResourcePath = Parameters<typeof useRouter>[0]['path'];
const path: ResourcePath = '/foo';
fetch(path);
```

But it's kind of extra to create a variable to hold path then type that. 

Pull request description:
Add route helper for runtime route generation
Fixes #36 #38 #56

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 68d1c09..f751506 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,32 @@
 # Changelog
 
+## 0.0.21
+
+- Add `route` runtime for generating type safe pathnames from a `Route` object
+
+This can be used to fetch from API routes:
+
+```ts
+import { route } from "nextjs-routes";
+
+fetch(route({ pathname: "/api/foos/[foo]", query: { foo: "foobar" } }));
+```
+
+Or for type safe redirects from `getServerSideProps`:
+
+```ts
+import { route } from "nextjs-routes";
+
+export const getServerSideProps: GetServerSideProps = async (context) => {
+  return {
+    redirect: {
+      destination: route({ pathname: "/foos/[foo]", query: { foo: "foobar" } }),
+      permanent: false,
+    },
+  };
+};
+```
+
 ## 0.0.20
 
 - Move `chokidar` from `devDependencies` to `dependencies` so it's installed automatically.
diff --git a/README.md b/README.md
index 1033eb6..c66d13b 100644
--- a/README.md
+++ b/README.md
@@ -150,6 +150,35 @@ This wiring will only run in Next.js' development server (eg `npx next dev`) and
 
 `nextjs-routes` generates types for the `pathname` and `query` for every page in your `pages` directory. The generated types are written to `nextjs-routes.d.ts` which is automatically referenced by your Next project's `tsconfig.json`. `nextjs-routes.d.ts` redefines the types for `next/link` and `next/router` and applies the generated route types.
 
+## What if I need a runtime?
+
+There are some cases where you may want to generate a type safe pathname from a `Route` object, such as `fetch`ing from an API route or serving type safe redirects from `getServerSideProps`. These accept `strings` instead of the `Route` object that `Link` and `useRouter` accept. This requires a small amount of runtime code instead of a type only solution.
+
+For these cases, you can use `route` from `nextjs-routes`:
+
+### fetch
+
+```ts
+import { route } from "nextjs-routes";
+
+fetch(route({ pathname: "/api/foos/[foo]", query: { foo: "foobar" } }));
+```
+
+### getServerSideProps
+
+```ts
+import { route } from "nextjs-routes";
+
+export const getServerSideProps: GetServerSideProps = async (context) => {
+  return {
+    redirect: {
+      destination: route({ pathname: "/foos/[foo]", query: { foo: "foobar" } }),
+      permanent: false,
+    },
+  };
+};
+```
+
 ## Contributing 👫
 
 PR's and issues welcomed! For more guidance check out [CONTRIBUTING.md](https://github.com/tatethurston/nextjs-routes/blob/main/CONTRIBUTING.md)
diff --git a/examples/src-pages/nextjs-routes.d.ts b/examples/src-pages/nextjs-routes.d.ts
index aea5095..df57518 100644
--- a/examples/src-pages/nextjs-routes.d.ts
+++ b/examples/src-pages/nextjs-routes.d.ts
@@ -11,6 +11,13 @@ declare module "nextjs-routes" {
     | { pathname: "/"; query?: Query | undefined };
 
   type Query<Params = {}> = Params & { [key: string]: string | string[] | undefined };
+
+  /**
+   * A typesafe utility function for generating paths in your application.
+   *
+   * route({ pathname: '/foos/[foo]', query: { foo: 'bar' }}) will produce '/foos/bar'.
+   */
+  export declare function route(r: Route): string;
 }
 
 // prettier-ignore
diff --git a/examples/typescript/nextjs-routes.d.ts b/examples/typescript/nextjs-routes.d.ts
index aea5095..df57518 100644
--- a/examples/typescript/nextjs-routes.d.ts
+++ b/examples/typescript/nextjs-routes.d.ts
@@ -11,6 +11,13 @@ declare module "nextjs-routes" {
     | { pathname: "/"; query?: Query | undefined };
 
   type Query<Params = {}> = Params & { [key: string]: string | string[] | undefined };
+
+  /**
+   * A typesafe utility function for generating paths in your application.
+   *
+   * route({ pathname: '/foos/[foo]', query: { foo: 'bar' }}) will produce '/foos/bar'.
+   */
+  export declare function route(r: Route): string;
 }
 
 // prettier-ignore
diff --git a/examples/typescript/pages/foos/[foo].tsx b/examples/typescript/pages/foos/[foo].tsx
index 945fb61..9798825 100644
--- a/examples/typescript/pages/foos/[foo].tsx
+++ b/examples/typescript/pages/foos/[foo].tsx
@@ -1,5 +1,6 @@
-import { NextPage } from "next";
+import { GetServerSideProps, NextPage } from "next";
 import { useRouter } from "next/router";
+import { route } from "nextjs-routes";
 
 const Foo: NextPage = () => {
   const router = useRouter<"/foos/[foo]">();
@@ -7,4 +8,13 @@ const Foo: NextPage = () => {
   return <div>Foo: {foo}</div>;
 };
 
+export const getServerSideProps: GetServerSideProps = async () => {
+  return {
+    redirect: {
+      destination: route({ pathname: "/bars/[bar]", query: { bar: "foo" } }),
+      permanent: false,
+    },
+  };
+};
+
 export default Foo;
diff --git a/examples/typescript/pages/index.tsx b/examples/typescript/pages/index.tsx
index 9ba85f3..c06182e 100644
--- a/examples/typescript/pages/index.tsx
+++ b/examples/typescript/pages/index.tsx
@@ -1,8 +1,16 @@
 import type { NextPage } from "next";
 import Head from "next/head";
 import Link from "next/link";
+import { route } from "nextjs-routes";
+import { useEffect } from "react";
 
 const Home: NextPage = () => {
+  useEffect(() => {
+    fetch(route({ pathname: "/api/hello" }))
+      .then((r) => r.json())
+      .then(console.log);
+  }, []);
+
   return (
     <>
       <Head>
diff --git a/package.json b/package.json
index 02524d6..b925e5e 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,7 @@
     "lint:fix": "prettier --write '**/*.{ts,tsx,md,json}' && prettier-package-json --write '{,public.,example/}package.json' && eslint --fix .",
     "package:build": "npm install && npm run build:module && npm run build:commonjs && npm run package:prune && npm run package:copy:files && chmod +x dist/cli.js",
     "package:copy:files": "cp ./LICENSE ./README.md dist/ && cp ./public.package.json dist/package.json",
-    "package:prune": "find dist -name test.* | xargs rm -f",
+    "package:prune": "find dist -name *.test.* | xargs rm -f",
     "prepare": "husky install",
     "test": "npm run e2e:setup && jest",
     "test:ci": "test --coverage",
diff --git a/public.package.json b/public.package.json
index a51c09c..819e74e 100644
--- a/public.package.json
+++ b/public.package.json
@@ -1,6 +1,6 @@
 {
   "name": "nextjs-routes",
-  "version": "0.0.20",
+  "version": "0.0.21",
   "description": "Type safe routing for Next.js",
   "license": "MIT",
   "author": "Tate <tatethurston@gmail.com>",
@@ -29,6 +29,7 @@
     "typescript"
   ],
   "exports": {
+    ".": "./index.js",
     "./package.json": "./package.json",
     "./next-config.cjs": "./next-config.cjs"
   }
diff --git a/src/core.ts b/src/core.ts
index 33a8b3f..5ba55fc 100644
--- a/src/core.ts
+++ b/src/core.ts
@@ -116,6 +116,13 @@ declare module "nextjs-routes" {
     };
 
   type Query<Params = {}> = Params & ${unknownQueryParamsType};
+
+  /**
+   * A typesafe utility function for generating paths in your application.
+   *
+   * route({ pathname: '/foos/[foo]', query: { foo: 'bar' }}) will produce '/foos/bar'.
+   */
+  export declare function route(r: Route): string;
 }
 
 // prettier-ignore
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..3eea781
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,63 @@
+interface Route {
+  pathname: string;
+  query?: { [key: string]: string | string[] | undefined };
+}
+
+interface OptionalCatchAll extends Route {
+  query?: { [key: string]: string[] | undefined };
+}
+
+interface CatchAll extends Route {
+  query: { [key: string]: string[] };
+}
+
+interface Dynamic extends Route {
+  query: { [key: string]: string };
+}
+
+export function route(r: Route): string {
+  const params = new Set<string>();
+  const path =
+    "/" +
+    r.pathname
+      .split("/")
+      .map((segment) => {
+        // optional catch all
+        if (segment.startsWith("[[...") && segment.endsWith("]]")) {
+          const query = segment.slice(5, -2);
+          params.add(query);
+          return (r as OptionalCatchAll).query?.[query]?.join("/");
+        }
+        // catch all
+        if (segment.startsWith("[...") && segment.endsWith("]")) {
+          const query = segment.slice(4, -1);
+          params.add(query);
+          return (r as CatchAll).query[query].join("/");
+        }
+        // dynamic
+        if (segment.startsWith("[") && segment.endsWith("]")) {
+          const query = segment.slice(1, -1);
+          params.add(query);
+          return (r as Dynamic).query[query];
+        }
+        return segment;
+      })
+      // removes optional catch all if no query is supplied
+      .filter(Boolean)
+      .join("/");
+
+  const search = new URLSearchParams();
+  for (const key in r.query) {
+    if (!params.has(key)) {
+      const value = r.query[key];
+      if (Array.isArray(value)) {
+        value.forEach((val) => search.append(key, val));
+      } else {
+        search.append(key, value as string);
+      }
+    }
+  }
+  const qs = search.toString().length > 0 ? "?" + search.toString() : "";
+
+  return path + qs;
+}


Test patch:
diff --git a/e2e/nextjs-routes.d.ts b/e2e/nextjs-routes.d.ts
index 7aa68f5..5eeb572 100644
--- a/e2e/nextjs-routes.d.ts
+++ b/e2e/nextjs-routes.d.ts
@@ -9,6 +9,13 @@ declare module "nextjs-routes" {
     | { pathname: "/"; query?: Query | undefined };
 
   type Query<Params = {}> = Params & { [key: string]: string | string[] | undefined };
+
+  /**
+   * A typesafe utility function for generating paths in your application.
+   *
+   * route({ pathname: '/foos/[foo]', query: { foo: 'bar' }}) will produce '/foos/bar'.
+   */
+  export declare function route(r: Route): string;
 }
 
 // prettier-ignore
diff --git a/e2e/package-lock.json b/e2e/package-lock.json
index 4de423c..3780496 100644
--- a/e2e/package-lock.json
+++ b/e2e/package-lock.json
@@ -20,15 +20,15 @@
     },
     "../dist": {
       "name": "nextjs-routes",
-      "version": "0.0.19",
+      "version": "0.0.21",
       "dev": true,
       "license": "MIT",
+      "dependencies": {
+        "chokidar": "^3.5.3"
+      },
       "bin": {
         "nextjs-routes": "cli.js"
       },
-      "devDependencies": {
-        "chokidar": "^3.5.3"
-      },
       "peerDependencies": {
         "next": "*"
       }
diff --git a/e2e/typetest.tsx b/e2e/typetest.tsx
index 77e7f46..6f59742 100644
--- a/e2e/typetest.tsx
+++ b/e2e/typetest.tsx
@@ -1,7 +1,7 @@
 import Link from "next/link";
 import { LinkProps } from "next/link";
 import { useRouter, RouterEvent, NextRouter } from "next/router";
-import type { Route } from "nextjs-routes";
+import { route, type Route } from "nextjs-routes";
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function
 function expectType<T>(_value: T) {}
@@ -171,23 +171,33 @@ nextRouter.push({ pathname: "/invalid" });
 
 // Route
 
-let route: Route;
+let r: Route;
 
 // Path without dynamic segments
-route = { pathname: "/" };
-route = { pathname: "/", query: undefined };
-route = { pathname: "/", query: {} };
-route = { pathname: "/", query: { bar: "baz" } };
-route = { pathname: "/", query: { bar: ["foo", "baz"] } };
+r = { pathname: "/" };
+r = { pathname: "/", query: undefined };
+r = { pathname: "/", query: {} };
+r = { pathname: "/", query: { bar: "baz" } };
+r = { pathname: "/", query: { bar: ["foo", "baz"] } };
 
 // Path with dynamic segments
-route = { pathname: "/foos/[foo]", query: { foo: "baz" } };
+r = { pathname: "/foos/[foo]", query: { foo: "baz" } };
 // @ts-expect-error missing 'foo' in query
-route = { pathname: "/foos/[foo]", query: { bar: "baz" } };
+r = { pathname: "/foos/[foo]", query: { bar: "baz" } };
 // @ts-expect-error missing 'foo' in query
-route = { pathname: "/foos/[foo]", query: undefined };
+r = { pathname: "/foos/[foo]", query: undefined };
 // @ts-expect-error missing 'foo' in query
-route = { pathname: "/foos/[foo]", query: {} };
-route = { pathname: "/foos/[foo]", query: { foo: "baz", bar: "baz" } };
+r = { pathname: "/foos/[foo]", query: {} };
+r = { pathname: "/foos/[foo]", query: { foo: "baz", bar: "baz" } };
 // @ts-expect-error 'foo' must be a string, not string[]
-route = { pathname: "/foos/[foo]", query: { foo: ["bar", "baz"] } };
+r = { pathname: "/foos/[foo]", query: { foo: ["bar", "baz"] } };
+
+// route
+// Path without dynamic segments
+route({ pathname: "/" });
+// Path with dynamic segments
+route({ pathname: "/foos/[foo]", query: { foo: "baz" } });
+// @ts-expect-error missing 'foo' in query
+route({ pathname: "/foos/[foo]", query: { bar: "baz" } });
+// @ts-expect-error 'foo' must be a string, not string[]
+route({ pathname: "/foos/[foo]", query: { foo: ["bar", "baz"] } });
diff --git a/src/__snapshots__/core.test.ts.snap b/src/__snapshots__/core.test.ts.snap
index d7158ad..9792b95 100644
--- a/src/__snapshots__/core.test.ts.snap
+++ b/src/__snapshots__/core.test.ts.snap
@@ -14,6 +14,13 @@ declare module \\"nextjs-routes\\" {
     never;
 
   type Query<Params = {}> = Params & { [key: string]: string | string[] | undefined };
+
+  /**
+   * A typesafe utility function for generating paths in your application.
+   *
+   * route({ pathname: '/foos/[foo]', query: { foo: 'bar' }}) will produce '/foos/bar'.
+   */
+  export declare function route(r: Route): string;
 }
 
 // prettier-ignore
@@ -116,6 +123,13 @@ declare module \\"nextjs-routes\\" {
     | { pathname: \\"/settings\\"; query?: Query | undefined };
 
   type Query<Params = {}> = Params & { [key: string]: string | string[] | undefined };
+
+  /**
+   * A typesafe utility function for generating paths in your application.
+   *
+   * route({ pathname: '/foos/[foo]', query: { foo: 'bar' }}) will produce '/foos/bar'.
+   */
+  export declare function route(r: Route): string;
 }
 
 // prettier-ignore
diff --git a/src/index.test.ts b/src/index.test.ts
new file mode 100644
index 0000000..00b7b18
--- /dev/null
+++ b/src/index.test.ts
@@ -0,0 +1,57 @@
+import { route } from "./index.js";
+
+describe(route, () => {
+  it("generates paths", () => {
+    // static
+    expect(route({ pathname: "/404" })).toMatchInlineSnapshot(`"/404"`);
+    expect(route({ pathname: "/settings/about" })).toMatchInlineSnapshot(
+      `"/settings/about"`
+    );
+    // dynamic
+    expect(
+      route({ pathname: "/foos/[foo]", query: { foo: "bar" } })
+    ).toMatchInlineSnapshot(`"/foos/bar"`);
+    expect(
+      route({
+        pathname: "/foos/[foo]/bars/[bar]",
+        query: { foo: "bar", bar: "baz" },
+      })
+    ).toMatchInlineSnapshot(`"/foos/bar/bars/baz"`);
+    expect(
+      route({
+        pathname: "/[foo]/[bar]/[baz]",
+        query: { foo: "foo", bar: "bar", baz: "baz" },
+      })
+    ).toMatchInlineSnapshot(`"/foo/bar/baz"`);
+    // catch all
+    expect(
+      route({ pathname: "/[...segments]", query: { segments: ["foo"] } })
+    ).toMatchInlineSnapshot(`"/foo"`);
+    expect(
+      route({ pathname: "/[...segments]", query: { segments: ["foo", "bar"] } })
+    ).toMatchInlineSnapshot(`"/foo/bar"`);
+    // optional catch all
+    expect(
+      route({ pathname: "/[[...segments]]", query: { segments: [] } })
+    ).toMatchInlineSnapshot(`"/"`);
+    expect(
+      route({ pathname: "/[[...segments]]", query: { segments: undefined } })
+    ).toMatchInlineSnapshot(`"/"`);
+    expect(
+      route({
+        pathname: "/[[...segments]]/foos",
+        query: { segments: undefined },
+      })
+    ).toMatchInlineSnapshot(`"/foos"`);
+    // query params
+    expect(
+      route({ pathname: "/foos/[foo]", query: { foo: "foo", bar: "bar" } })
+    ).toMatchInlineSnapshot(`"/foos/foo?bar=bar"`);
+    expect(
+      route({
+        pathname: "/foos/[foo]",
+        query: { foo: "foo", bar: "bar", baz: ["1", "2", "3"] },
+      })
+    ).toMatchInlineSnapshot(`"/foos/foo?bar=bar&baz=1&baz=2&baz=3"`);
+  });
+});