Repository: tatethurston/nextjs-routes
Pull request #40 (2022-07-12 19:05:09)

refactor: extract logic from cli into a function


Patch:
diff --git a/src/cli.ts b/src/cli.ts
index 44ea37e..befa263 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,26 +1,5 @@
 #!/usr/bin/env node
 
-import { writeFileSync } from "fs";
-import { join } from "path";
-import { getPagesDirectory, findFiles, generate, nextRoutes } from "./utils.js";
+import { cli } from "./core.js";
 
-const logger: Pick<Console, "error"> = {
-  error: (str: string) => console.error("[nextjs-routes] " + str),
-};
-
-const pagesDirectory = getPagesDirectory();
-// istanbul ignore else: covered by e2e.test
-if (!pagesDirectory) {
-  logger.error(`Could not find a Next.js pages directory. Expected to find either pages(1) or src/pages(2).
-
-1. https://nextjs.org/docs/basic-features/pages
-2. https://nextjs.org/docs/advanced-features/src-directory
-`);
-  process.exit(1);
-} else {
-  const files = findFiles(join(".", pagesDirectory));
-  const routes = nextRoutes(files, pagesDirectory);
-  const generated = generate(routes);
-
-  writeFileSync("nextjs-routes.d.ts", generated);
-}
+cli();
diff --git a/src/core.ts b/src/core.ts
new file mode 100644
index 0000000..0fe8bcf
--- /dev/null
+++ b/src/core.ts
@@ -0,0 +1,200 @@
+import { writeFileSync } from "fs";
+import { join, parse } from "path";
+import { findFiles, getPagesDirectory } from "./utils.js";
+
+const NEXTJS_NON_ROUTABLE = ["/_app", "/_document", "/_error", "/middleware"];
+const DYNAMIC_SEGMENT_RE = /\[(.*?)\]/g;
+
+type QueryType = "dynamic" | "catch-all" | "optional-catch-all";
+
+interface Route {
+  pathname: string;
+  query: Record<string, QueryType>;
+}
+
+function convertWindowsPathToUnix(file: string): string {
+  return file.replace(/\\/g, "/");
+}
+
+export function nextRoutes(files: string[], pagesDirectory: string): Route[] {
+  const filenames = files
+    .map((file) => file.replace(pagesDirectory, ""))
+    .map((file) => file.replace(parse(file).ext, ""))
+    .map(convertWindowsPathToUnix)
+    .filter((file) => !NEXTJS_NON_ROUTABLE.includes(file));
+
+  return filenames.map((filename) => {
+    const segments = filename.match(DYNAMIC_SEGMENT_RE) ?? [];
+    const query = segments.reduce<Route["query"]>((acc, cur) => {
+      const param = cur
+        .replace(/\[/g, "")
+        .replace(/\]/g, "")
+        .replace("...", "");
+      let queryType: QueryType = "dynamic";
+      if (cur.startsWith("[[")) {
+        queryType = "optional-catch-all";
+      } else if (cur.startsWith("[...")) {
+        queryType = "catch-all";
+      }
+      acc[param] = queryType;
+      return acc;
+    }, {});
+
+    const pathWithoutIndexSuffix = filename.replace(/index$/, "");
+    const pathWithoutTrailingSlash =
+      pathWithoutIndexSuffix.endsWith("/") && pathWithoutIndexSuffix.length > 2
+        ? pathWithoutIndexSuffix.slice(0, -1)
+        : pathWithoutIndexSuffix;
+
+    return {
+      pathname: pathWithoutTrailingSlash,
+      query,
+    };
+  });
+}
+
+function getQueryInterface(
+  query: Route["query"]
+): [query: string, requiredKeys: number] {
+  let requiredKeys = 0;
+  const keys = Object.entries(query)
+    .map(([key, value]) => {
+      switch (value) {
+        case "dynamic": {
+          requiredKeys += 1;
+          return `${key}: string`;
+        }
+        case "catch-all": {
+          requiredKeys += 1;
+          return `${key}: string[]`;
+        }
+        case "optional-catch-all": {
+          return `${key}?: string[] | undefined`;
+        }
+        // istanbul ignore next
+        default: {
+          const _exhaust: never = value;
+          return _exhaust;
+        }
+      }
+    })
+    .join("; ");
+
+  return [`{ ${keys} }`, requiredKeys];
+}
+
+function generate(routes: Route[]): string {
+  return `\
+// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+// Run \`npx nextjs-routes\` to regenerate this file.
+/* eslint-disable */
+
+// prettier-ignore
+declare module "nextjs-routes" {
+  export type Route =
+    ${
+      !routes.length
+        ? "never"
+        : `| ${routes
+            .map((route) => {
+              const [query, requiredKeys] = getQueryInterface(route.query);
+              if (requiredKeys > 0) {
+                return `{ pathname: "${route.pathname}"; query: Query<${query}> }`;
+              } else {
+                return `{ pathname: "${route.pathname}"; query?: Query | undefined }`;
+              }
+            })
+            .join("\n    | ")}`
+    };
+
+  type Query<Params = {}> = Params & { [key: string]: string | undefined };
+}
+
+// prettier-ignore
+declare module "next/link" {
+  import type { Route } from "nextjs-routes";
+  import type { LinkProps as NextLinkProps } from "next/dist/client/link";
+  import type { PropsWithChildren, MouseEventHandler } from "react";
+  export * from "next/dist/client/link";
+
+  type RouteOrQuery = Route | { query?: { [key: string]: string | undefined } };
+
+  export interface LinkProps extends Omit<NextLinkProps, "href"> {
+    href: RouteOrQuery;
+  }
+
+  declare function Link(
+    props: PropsWithChildren<LinkProps>
+  ): DetailedReactHTMLElement<
+    {
+      onMouseEnter?: MouseEventHandler<Element> | undefined;
+      onClick: MouseEventHandler;
+      href?: string | undefined;
+      ref?: any;
+    },
+    HTMLElement
+  >;
+
+  export default Link;
+}
+
+// prettier-ignore
+declare module "next/router" {
+  import type { Route } from "nextjs-routes";
+  import type { NextRouter as Router } from "next/dist/client/router";
+  export * from "next/dist/client/router";
+  export { default } from "next/dist/client/router";
+
+  type TransitionOptions = Parameters<Router["push"]>[2];
+
+  type Pathname = Route["pathname"];
+
+  type QueryForPathname = {
+    [K in Route as K["pathname"]]: Exclude<K["query"], undefined>;
+  };
+
+  type RouteOrQuery = Route | { query: { [key: string]: string | undefined } };
+
+  export interface NextRouter<P extends Pathname = Pathname>
+    extends Omit<Router, "push" | "replace"> {
+    pathname: P;
+    route: P;
+    query: QueryForPathname[P];
+    push(
+      url: RouteOrQuery,
+      as?: string,
+      options?: TransitionOptions
+    ): Promise<boolean>;
+    replace(
+      url: RouteOrQuery,
+      as?: string,
+      options?: TransitionOptions
+    ): Promise<boolean>;
+  }
+
+  export function useRouter<P extends Pathname>(): NextRouter<P>;
+}
+`;
+}
+
+const logger: Pick<Console, "error"> = {
+  error: (str: string) => console.error("[nextjs-routes] " + str),
+};
+
+export function cli(): void {
+  const pagesDirectory = getPagesDirectory();
+  if (!pagesDirectory) {
+    logger.error(`Could not find a Next.js pages directory. Expected to find either pages(1) or src/pages(2).
+
+  1. https://nextjs.org/docs/basic-features/pages
+  2. https://nextjs.org/docs/advanced-features/src-directory
+  `);
+    process.exit(1);
+  } else {
+    const files = findFiles(join(".", pagesDirectory));
+    const routes = nextRoutes(files, pagesDirectory);
+    const generated = generate(routes);
+
+    writeFileSync("nextjs-routes.d.ts", generated);
+  }
+}
diff --git a/src/utils.ts b/src/utils.ts
index 7ad37f5..8171717 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,8 +1,5 @@
 import { existsSync, readdirSync, statSync } from "fs";
-import { join, parse } from "path";
-
-const NEXTJS_NON_ROUTABLE = ["/_app", "/_document", "/_error", "/middleware"];
-const DYNAMIC_SEGMENT_RE = /\[(.*?)\]/g;
+import { join } from "path";
 
 // istanbul ignore next: io mocking not worthwhile
 export function getPagesDirectory(): string | undefined {
@@ -27,175 +24,3 @@ export function findFiles(entry: string): string[] {
     return filepath;
   });
 }
-
-type QueryType = "dynamic" | "catch-all" | "optional-catch-all";
-
-interface Route {
-  pathname: string;
-  query: Record<string, QueryType>;
-}
-
-function convertWindowsPathToUnix(file: string): string {
-  return file.replace(/\\/g, "/");
-}
-
-export function nextRoutes(files: string[], pagesDirectory: string): Route[] {
-  const filenames = files
-    .map((file) => file.replace(pagesDirectory, ""))
-    .map((file) => file.replace(parse(file).ext, ""))
-    .map(convertWindowsPathToUnix)
-    .filter((file) => !NEXTJS_NON_ROUTABLE.includes(file));
-
-  return filenames.map((filename) => {
-    const segments = filename.match(DYNAMIC_SEGMENT_RE) ?? [];
-    const query = segments.reduce<Route["query"]>((acc, cur) => {
-      const param = cur
-        .replace(/\[/g, "")
-        .replace(/\]/g, "")
-        .replace("...", "");
-      let queryType: QueryType = "dynamic";
-      if (cur.startsWith("[[")) {
-        queryType = "optional-catch-all";
-      } else if (cur.startsWith("[...")) {
-        queryType = "catch-all";
-      }
-      acc[param] = queryType;
-      return acc;
-    }, {});
-
-    const pathWithoutIndexSuffix = filename.replace(/index$/, "");
-    const pathWithoutTrailingSlash =
-      pathWithoutIndexSuffix.endsWith("/") && pathWithoutIndexSuffix.length > 2
-        ? pathWithoutIndexSuffix.slice(0, -1)
-        : pathWithoutIndexSuffix;
-
-    return {
-      pathname: pathWithoutTrailingSlash,
-      query,
-    };
-  });
-}
-
-function getQueryInterface(
-  query: Route["query"]
-): [query: string, requiredKeys: number] {
-  let requiredKeys = 0;
-  const keys = Object.entries(query)
-    .map(([key, value]) => {
-      switch (value) {
-        case "dynamic": {
-          requiredKeys += 1;
-          return `${key}: string`;
-        }
-        case "catch-all": {
-          requiredKeys += 1;
-          return `${key}: string[]`;
-        }
-        case "optional-catch-all": {
-          return `${key}?: string[] | undefined`;
-        }
-        // istanbul ignore next
-        default: {
-          const _exhaust: never = value;
-          return _exhaust;
-        }
-      }
-    })
-    .join("; ");
-
-  return [`{ ${keys} }`, requiredKeys];
-}
-
-export function generate(routes: Route[]): string {
-  return `\
-// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
-// Run \`npx nextjs-routes\` to regenerate this file.
-/* eslint-disable */
-
-// prettier-ignore
-declare module "nextjs-routes" {
-  export type Route =
-    ${
-      !routes.length
-        ? "never"
-        : `| ${routes
-            .map((route) => {
-              const [query, requiredKeys] = getQueryInterface(route.query);
-              if (requiredKeys > 0) {
-                return `{ pathname: "${route.pathname}"; query: Query<${query}> }`;
-              } else {
-                return `{ pathname: "${route.pathname}"; query?: Query | undefined }`;
-              }
-            })
-            .join("\n    | ")}`
-    };
-
-  type Query<Params = {}> = Params & { [key: string]: string | undefined };
-}
-
-// prettier-ignore
-declare module "next/link" {
-  import type { Route } from "nextjs-routes";
-  import type { LinkProps as NextLinkProps } from "next/dist/client/link";
-  import type { PropsWithChildren, MouseEventHandler } from "react";
-  export * from "next/dist/client/link";
-
-  type RouteOrQuery = Route | { query?: { [key: string]: string | undefined } };
-
-  export interface LinkProps extends Omit<NextLinkProps, "href"> {
-    href: RouteOrQuery;
-  }
-
-  declare function Link(
-    props: PropsWithChildren<LinkProps>
-  ): DetailedReactHTMLElement<
-    {
-      onMouseEnter?: MouseEventHandler<Element> | undefined;
-      onClick: MouseEventHandler;
-      href?: string | undefined;
-      ref?: any;
-    },
-    HTMLElement
-  >;
-
-  export default Link;
-}
-
-// prettier-ignore
-declare module "next/router" {
-  import type { Route } from "nextjs-routes";
-  import type { NextRouter as Router } from "next/dist/client/router";
-  export * from "next/dist/client/router";
-  export { default } from "next/dist/client/router";
-
-  type TransitionOptions = Parameters<Router["push"]>[2];
-
-  type Pathname = Route["pathname"];
-
-  type QueryForPathname = {
-    [K in Route as K["pathname"]]: Exclude<K["query"], undefined>;
-  };
-
-  type RouteOrQuery = Route | { query: { [key: string]: string | undefined } };
-
-  export interface NextRouter<P extends Pathname = Pathname>
-    extends Omit<Router, "push" | "replace"> {
-    pathname: P;
-    route: P;
-    query: QueryForPathname[P];
-    push(
-      url: RouteOrQuery,
-      as?: string,
-      options?: TransitionOptions
-    ): Promise<boolean>;
-    replace(
-      url: RouteOrQuery,
-      as?: string,
-      options?: TransitionOptions
-    ): Promise<boolean>;
-  }
-
-  export function useRouter<P extends Pathname>(): NextRouter<P>;
-}
-`;
-}


Test patch:
diff --git a/src/__snapshots__/utils.test.ts.snap b/src/__snapshots__/core.test.ts.snap
similarity index 95%
rename from src/__snapshots__/utils.test.ts.snap
rename to src/__snapshots__/core.test.ts.snap
index 8550a2a..9017694 100644
--- a/src/__snapshots__/utils.test.ts.snap
+++ b/src/__snapshots__/core.test.ts.snap
@@ -1,7 +1,10 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`route generation no routes 1`] = `
-"// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+Array [
+  Array [
+    "nextjs-routes.d.ts",
+    "// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
 // Run \`npx nextjs-routes\` to regenerate this file.
 /* eslint-disable */
 
@@ -77,11 +80,16 @@ declare module \\"next/router\\" {
 
   export function useRouter<P extends Pathname>(): NextRouter<P>;
 }
-"
+",
+  ],
+]
 `;
 
 exports[`route generation typescript 1`] = `
-"// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+Array [
+  Array [
+    "nextjs-routes.d.ts",
+    "// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
 // Run \`npx nextjs-routes\` to regenerate this file.
 /* eslint-disable */
 
@@ -175,5 +183,7 @@ declare module \\"next/router\\" {
 
   export function useRouter<P extends Pathname>(): NextRouter<P>;
 }
-"
+",
+  ],
+]
 `;
diff --git a/src/cli.test.ts b/src/cli.test.ts
deleted file mode 100644
index cf29f4f..0000000
--- a/src/cli.test.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { getPagesDirectory } from "./utils.js";
-
-jest.mock("./utils", () => ({
-  getPagesDirectory: jest.fn(),
-}));
-
-const consoleError = jest
-  .spyOn(console, "error")
-  .mockImplementationOnce(() => undefined);
-
-const processExit = jest
-  .spyOn(process, "exit")
-  .mockImplementationOnce(() => undefined as never);
-
-(getPagesDirectory as jest.Mock).mockImplementation(() => undefined);
-
-describe("cli", () => {
-  it("missing pages directory", async () => {
-    await import("./cli");
-    expect(consoleError.mock.calls).toMatchInlineSnapshot(`
-      Array [
-        Array [
-          "[nextjs-routes] Could not find a Next.js pages directory. Expected to find either pages(1) or src/pages(2).
-
-      1. https://nextjs.org/docs/basic-features/pages
-      2. https://nextjs.org/docs/advanced-features/src-directory
-      ",
-        ],
-      ]
-    `);
-    expect(processExit).toHaveBeenCalledWith(1);
-  });
-});
diff --git a/src/core.test.ts b/src/core.test.ts
new file mode 100644
index 0000000..5dec838
--- /dev/null
+++ b/src/core.test.ts
@@ -0,0 +1,92 @@
+import { writeFileSync } from "fs";
+import { cli, nextRoutes } from "./core.js";
+import { findFiles, getPagesDirectory } from "./utils.js";
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-return
+jest.mock("fs", () => ({
+  ...jest.requireActual("fs"),
+  writeFileSync: jest.fn(),
+}));
+const writeFileSyncMock = writeFileSync as jest.Mock;
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-return
+jest.mock("./utils.js", () => ({
+  ...jest.requireActual("./utils.js"),
+  findFiles: jest.fn(),
+  getPagesDirectory: jest.fn(),
+}));
+const getPagesDirectoryMock = getPagesDirectory as jest.Mock;
+const findFilesMock = findFiles as jest.Mock;
+
+describe("nextRoutes", () => {
+  it("transforms windows paths", () => {
+    const pages = ["src\\pages\\[foo]\\bar\\index.ts"];
+    const { pathname } = nextRoutes(pages, "src\\pages")[0];
+    expect(pathname).toEqual("/[foo]/bar");
+  });
+});
+
+describe("route generation", () => {
+  it("no routes", () => {
+    getPagesDirectoryMock.mockReturnValueOnce("pages");
+    findFilesMock.mockReturnValueOnce([]);
+    cli();
+    expect(writeFileSyncMock.mock.calls).toMatchSnapshot();
+  });
+
+  it("typescript", () => {
+    getPagesDirectoryMock.mockReturnValueOnce("pages");
+    findFilesMock.mockReturnValueOnce([
+      "pages/404.ts",
+      "pages/[foo].ts",
+      "pages/[foo]/[bar]/[baz].ts",
+      "pages/[foo]/bar/[baz].ts",
+      "pages/[foo]/bar/[baz]/foo/[bar].ts",
+      "pages/[foo]/baz.ts",
+      "pages/_app.ts",
+      "pages/middleware.ts",
+      "pages/_debug.ts",
+      "pages/_debug/health-check.ts",
+      "pages/_document.ts",
+      "pages/_error.ts",
+      "pages/_error/index.ts",
+      "pages/api/[[...segments]].ts",
+      "pages/api/[...segments].ts",
+      "pages/api/bar.ts",
+      "pages/foo/[slug].ts",
+      "pages/index.ts",
+      "pages/not-found.ts",
+      "pages/settings/bars/[bar].ts",
+      "pages/settings/bars/[bar]/baz.ts",
+      "pages/settings/foo.ts",
+      "pages/settings/index.ts",
+    ]);
+    cli();
+    expect(writeFileSyncMock.mock.calls).toMatchSnapshot();
+  });
+
+  const consoleError = jest
+    .spyOn(console, "error")
+    .mockReturnValueOnce(undefined);
+
+  const processExit = jest
+    .spyOn(process, "exit")
+    .mockReturnValueOnce(undefined as never);
+
+  it("missing pages directory", () => {
+    getPagesDirectoryMock.mockReturnValueOnce(undefined);
+    cli();
+    expect(consoleError.mock.calls).toMatchInlineSnapshot(`
+      Array [
+        Array [
+          "[nextjs-routes] Could not find a Next.js pages directory. Expected to find either pages(1) or src/pages(2).
+
+        1. https://nextjs.org/docs/basic-features/pages
+        2. https://nextjs.org/docs/advanced-features/src-directory
+        ",
+        ],
+      ]
+    `);
+    expect(processExit).toHaveBeenCalledWith(1);
+  });
+});
diff --git a/src/utils.test.ts b/src/utils.test.ts
deleted file mode 100644
index f4c9a8d..0000000
--- a/src/utils.test.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { nextRoutes, generate } from "./utils.js";
-
-describe("nextRoutes", () => {
-  it("transforms windows paths", () => {
-    const pages = ["src\\pages\\[foo]\\bar\\index.ts"];
-    const { pathname } = nextRoutes(pages, "src\\pages")[0];
-    expect(pathname).toEqual("/[foo]/bar");
-  });
-});
-
-describe("route generation", () => {
-  it("no routes", () => {
-    expect(generate(nextRoutes([], "pages"))).toMatchSnapshot();
-  });
-
-  it("typescript", () => {
-    const pages = [
-      "pages/404.ts",
-      "pages/[foo].ts",
-      "pages/[foo]/[bar]/[baz].ts",
-      "pages/[foo]/bar/[baz].ts",
-      "pages/[foo]/bar/[baz]/foo/[bar].ts",
-      "pages/[foo]/baz.ts",
-      "pages/_app.ts",
-      "pages/middleware.ts",
-      "pages/_debug.ts",
-      "pages/_debug/health-check.ts",
-      "pages/_document.ts",
-      "pages/_error.ts",
-      "pages/_error/index.ts",
-      "pages/api/[[...segments]].ts",
-      "pages/api/[...segments].ts",
-      "pages/api/bar.ts",
-      "pages/foo/[slug].ts",
-      "pages/index.ts",
-      "pages/not-found.ts",
-      "pages/settings/bars/[bar].ts",
-      "pages/settings/bars/[bar]/baz.ts",
-      "pages/settings/foo.ts",
-      "pages/settings/index.ts",
-    ];
-    expect(generate(nextRoutes(pages, "pages"))).toMatchSnapshot();
-  });
-});