Skip to content

Instantly share code, notes, and snippets.

@codebutler
Last active August 21, 2023 22:54
Show Gist options
  • Save codebutler/c0b8e03219d96dd5a5f3f54f1211f79a to your computer and use it in GitHub Desktop.
Save codebutler/c0b8e03219d96dd5a5f3f54f1211f79a to your computer and use it in GitHub Desktop.
/*
Generates typesafe-routes code for all routes in the app.
See the typesafe-routes docs for information on how to use it:
https://github.com/kruschid/typesafe-routes
Usage:
pnpm vite-node buildSrc/typesafe-routes-gen.ts
Authors:
Eric Butler <eric@codebutler.com>
Any copyright is dedicated to the Public Domain.
https://creativecommons.org/publicdomain/zero/1.0/
*/
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import type { AgnosticDataRouteObject } from "@remix-run/router/dist/utils";
import { ESLint } from "eslint";
import fs from "fs/promises";
import { camelCase } from "lodash";
import path from "path";
import prettier from "prettier";
import * as ts from "typescript";
// Keep this if your code expects a global window object.
GlobalRegistrator.register();
// Update to your root route.
// This must be imported after the global window object is registered.
const { appRouter } = await import("app/AppRouter");
const cleanId = (id: string) => camelCase(id.replace(/\W/g, " "));
const stripParentId = (id: string, parentId: string) =>
id.startsWith(parentId) ? id.slice(parentId.length) : id;
export const cleanPath = (path: string) =>
path === "/" ? path : path.replace(/\/*\*$/, "").replace(/(\/$)/, "");
export const extractPathParams = (path: string) => {
const pathParams = path.match(/:(\w+)/g);
return pathParams ? pathParams.map((p) => p.slice(1)) : [];
};
const visitRoutes = (
routes: AgnosticDataRouteObject[],
parentId = "",
): ts.PropertyAssignment[] =>
routes.flatMap((route) =>
// Flatten non-leaf routes without path
(route.path && route.path !== "*") || route.index || !route.children?.length
? [
ts.factory.createPropertyAssignment(
ts.factory.createStringLiteral(
cleanId(stripParentId(cleanId(route.id), parentId)),
),
visitRoute(route, cleanId(route.id)),
),
]
: visitRoutes(route.children ?? [], parentId),
);
const visitRoute = (
route: AgnosticDataRouteObject,
parentId = "",
): ts.CallExpression =>
ts.factory.createCallExpression(
ts.factory.createIdentifier("route"),
undefined,
[
ts.factory.createStringLiteral(cleanPath(route.path ?? "")),
ts.factory.createObjectLiteralExpression(
extractPathParams(route.path ?? "").map((param) =>
ts.factory.createPropertyAssignment(
ts.factory.createStringLiteral(param),
ts.factory.createIdentifier("stringParser"),
),
),
true,
),
ts.factory.createObjectLiteralExpression(
visitRoutes(
route.children ?? [],
parentId,
),
true,
),
],
);
const generateImports = () =>
ts.factory.createImportDeclaration(
undefined,
ts.factory.createImportClause(
false,
undefined,
ts.factory.createNamedImports([
ts.factory.createImportSpecifier(
false,
undefined,
ts.factory.createIdentifier("route"),
),
ts.factory.createImportSpecifier(
false,
undefined,
ts.factory.createIdentifier("stringParser"),
),
]),
),
ts.factory.createStringLiteral("typesafe-routes"),
undefined,
);
const generateRoutes = () =>
ts.factory.createVariableStatement(
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
ts.factory.createVariableDeclarationList(
[
ts.factory.createVariableDeclaration(
ts.factory.createIdentifier("rootRoute"),
undefined,
undefined,
visitRoute({
id: "root",
path: "/",
children: appRouter.routes,
}),
),
],
ts.NodeFlags.Const,
),
);
const getAllRouteIds = (routes: AgnosticDataRouteObject[]): string[] =>
routes.flatMap((route) => [
route.id,
...(route.children?.length ? getAllRouteIds(route.children) : []),
]);
const generateRouteIds = () =>
ts.factory.createVariableStatement(
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
ts.factory.createVariableDeclarationList(
[
ts.factory.createVariableDeclaration(
ts.factory.createIdentifier("RouteIds"),
undefined,
undefined,
ts.factory.createAsExpression(
ts.factory.createArrayLiteralExpression(
getAllRouteIds(appRouter.routes).map((routeId) =>
ts.factory.createStringLiteral(routeId),
),
true,
),
ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier("const"),
undefined,
),
),
),
],
ts.NodeFlags.Const,
),
);
const generateRouteIdAlias = () =>
ts.factory.createTypeAliasDeclaration(
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
ts.factory.createIdentifier("RouteId"),
undefined,
ts.factory.createIndexedAccessTypeNode(
ts.factory.createTypeQueryNode(
ts.factory.createIdentifier("RouteIds"),
undefined,
),
ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword),
),
);
const generateCode = () =>
ts
.createPrinter()
.printFile(
ts.factory.createSourceFile(
[
generateImports(),
generateRouteIds(),
generateRouteIdAlias(),
generateRoutes(),
],
ts.factory.createToken(ts.SyntaxKind.EndOfFileToken),
ts.NodeFlags.None,
),
);
const filePath = path.resolve(__dirname, "../src/gen/routes.ts");
const code = `
/**
* This file was auto-generated, do not edit manually.
*
* Run \`pnpm gen:routes\` to re-generate this file.
*/
${generateCode()}
`;
let formattedCode;
formattedCode = await prettier.format(code, { parser: "typescript" });
const eslint = new ESLint({ fix: true });
const result = await eslint.lintText(formattedCode, {
filePath,
warnIgnored: true,
});
if (result[0].messages.length) {
// eslint-disable-next-line no-console
console.error(result[0].messages);
process.exit(1);
}
formattedCode = result[0].output!;
await fs.writeFile(filePath, formattedCode, "utf-8");
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment