From c54c443192c753733923dd8a931a41b4125c2f1f Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 16 Oct 2024 15:05:16 +1100 Subject: [PATCH] Move `/dev/routes` API to `route-config` package --- .../helpers/vite-template/package.json | 1 + integration/vite-route-config-test.ts | 32 +-- jest.config.js | 1 + .../__tests__/validateRouteConfig-test.ts | 141 +++++++++++++ packages/remix-dev/config.ts | 4 +- packages/remix-dev/config/routes.ts | 192 +----------------- packages/remix-dev/index.ts | 5 + packages/remix-dev/rollup.config.js | 1 - packages/remix-dev/routes.ts | 10 - packages/remix-route-config/README.md | 13 ++ .../__tests__/route-config-test.ts | 110 +--------- packages/remix-route-config/index.ts | 13 ++ packages/remix-route-config/jest.config.js | 6 + packages/remix-route-config/package.json | 53 +++++ packages/remix-route-config/rollup.config.js | 45 ++++ packages/remix-route-config/routes.ts | 191 +++++++++++++++++ packages/remix-route-config/tsconfig.json | 19 ++ pnpm-lock.yaml | 24 ++- pnpm-workspace.yaml | 1 + scripts/publish.js | 1 + 20 files changed, 533 insertions(+), 330 deletions(-) create mode 100644 packages/remix-dev/__tests__/validateRouteConfig-test.ts delete mode 100644 packages/remix-dev/routes.ts create mode 100644 packages/remix-route-config/README.md rename packages/{remix-dev => remix-route-config}/__tests__/route-config-test.ts (78%) create mode 100644 packages/remix-route-config/index.ts create mode 100644 packages/remix-route-config/jest.config.js create mode 100644 packages/remix-route-config/package.json create mode 100644 packages/remix-route-config/rollup.config.js create mode 100644 packages/remix-route-config/routes.ts create mode 100644 packages/remix-route-config/tsconfig.json diff --git a/integration/helpers/vite-template/package.json b/integration/helpers/vite-template/package.json index 3d9a59396e3..b2b9b38c7cd 100644 --- a/integration/helpers/vite-template/package.json +++ b/integration/helpers/vite-template/package.json @@ -26,6 +26,7 @@ "devDependencies": { "@remix-run/dev": "workspace:*", "@remix-run/eslint-config": "workspace:*", + "@remix-run/route-config": "workspace:*", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", "eslint": "^8.38.0", diff --git a/integration/vite-route-config-test.ts b/integration/vite-route-config-test.ts index 6efddd412d8..d1ec6c3d5bd 100644 --- a/integration/vite-route-config-test.ts +++ b/integration/vite-route-config-test.ts @@ -47,7 +47,7 @@ test.describe("route config", () => { })] } `, - "app/routes.ts": `export default INVALID(`, + "app/routes.ts": `export const routes = [];`, }); let buildResult = viteBuild({ cwd }); expect(buildResult.status).toBe(1); @@ -70,7 +70,7 @@ test.describe("route config", () => { })] } `, - "app/routes.ts": `export default INVALID(`, + "app/routes.ts": `export const routes = [];`, }); let devError: Error | undefined; try { @@ -121,13 +121,10 @@ test.describe("route config", () => { let files: Files = async ({ port }) => ({ "vite.config.js": await viteConfig.basic({ port }), "app/routes.ts": js` - import { type RouteConfig } from "@react-router/dev/routes"; + import { type RouteConfig, index } from "@remix-run/route-config"; export const routes: RouteConfig = [ - { - file: "test-route-1.tsx", - index: true, - }, + index("test-route-1.tsx"), ]; `, "app/test-route-1.tsx": ` @@ -185,13 +182,10 @@ test.describe("route config", () => { export { routes } from "./actual-routes"; `, "app/actual-routes.ts": js` - import { type RouteConfig } from "@react-router/dev/routes"; + import { type RouteConfig, index } from "@remix-run/route-config"; export const routes: RouteConfig = [ - { - file: "test-route-1.tsx", - index: true, - }, + index("test-route-1.tsx"), ]; `, "app/test-route-1.tsx": ` @@ -246,13 +240,10 @@ test.describe("route config", () => { let files: Files = async ({ port }) => ({ "vite.config.js": await viteConfig.basic({ port }), "app/routes.ts": js` - import { type RouteConfig } from "@react-router/dev/routes"; + import { type RouteConfig, index } from "@remix-run/route-config"; export const routes: RouteConfig = [ - { - file: "test-route-1.tsx", - index: true, - }, + index("test-route-1.tsx"), ]; `, "app/test-route-1.tsx": ` @@ -325,13 +316,10 @@ test.describe("route config", () => { "vite.config.js": await viteConfig.basic({ port }), "app/routes.ts": js` import path from "node:path"; - import { type RouteConfig } from "@react-router/dev/routes"; + import { type RouteConfig, index } from "@remix-run/route-config"; export const routes: RouteConfig = [ - { - file: path.resolve(import.meta.dirname, "test-route.tsx"), - index: true, - }, + index(path.resolve(import.meta.dirname, "test-route.tsx")), ]; `, "app/test-route.tsx": ` diff --git a/jest.config.js b/jest.config.js index 5ffd3c56699..f2d8db3a278 100644 --- a/jest.config.js +++ b/jest.config.js @@ -19,6 +19,7 @@ module.exports = { "packages/remix-express", "packages/remix-node", "packages/remix-react", + "packages/remix-route-config", "packages/remix-serve", "packages/remix-server-runtime", "packages/remix-testing", diff --git a/packages/remix-dev/__tests__/validateRouteConfig-test.ts b/packages/remix-dev/__tests__/validateRouteConfig-test.ts new file mode 100644 index 00000000000..2bcf1440e6a --- /dev/null +++ b/packages/remix-dev/__tests__/validateRouteConfig-test.ts @@ -0,0 +1,141 @@ +import { validateRouteConfig } from "../config/routes"; + +describe("validateRouteConfig", () => { + it("validates a route config", () => { + expect( + validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + { + path: "parent", + file: "parent.tsx", + children: [ + { + path: "child", + file: "child.tsx", + }, + ], + }, + ], + }).valid + ).toBe(true); + }); + + it("is invalid when not an array", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: { path: "path", file: "file.tsx" }, + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot( + `"Route config in "routes.ts" must be an array."` + ); + }); + + it("is invalid when route is a promise", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + { + path: "parent", + file: "parent.tsx", + children: [Promise.resolve({})], + }, + ], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0 + Invalid type: Expected object but received a promise. Did you forget to await?" + `); + }); + + it("is invalid when file is missing", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + { + path: "parent", + file: "parent.tsx", + children: [ + { + id: "child", + }, + ], + }, + ], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0.file + Invalid type: Expected string but received undefined" + `); + }); + + it("is invalid when property is wrong type", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + { + path: "parent", + file: "parent.tsx", + children: [ + { + file: 123, + }, + ], + }, + ], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0.file + Invalid type: Expected string but received 123" + `); + }); + + it("shows multiple error messages", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + { + path: "parent", + file: "parent.tsx", + children: [ + { + id: "child", + }, + { + file: 123, + }, + Promise.resolve(), + ], + }, + ], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0.file + Invalid type: Expected string but received undefined + + Path: routes.0.children.1.file + Invalid type: Expected string but received 123 + + Path: routes.0.children.2 + Invalid type: Expected object but received a promise. Did you forget to await?" + `); + }); +}); diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index 99ec334f2be..0d2a219cdbb 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -12,7 +12,7 @@ import { type RouteManifest, type RouteConfig, type DefineRoutesFunction, - setAppDirectory, + setRouteConfigAppDirectory, validateRouteConfig, configRoutesToRouteManifest, defineRoutes, @@ -577,7 +577,7 @@ export async function resolveConfig( root: { path: "", id: "root", file: rootRouteFile }, }; - setAppDirectory(appDirectory); + setRouteConfigAppDirectory(appDirectory); let routeConfigFile = findEntry(appDirectory, "routes"); if (routesViteNodeContext && vite && routeConfigFile) { class FriendlyError extends Error {} diff --git a/packages/remix-dev/config/routes.ts b/packages/remix-dev/config/routes.ts index be5068e50e7..fd4dd22cbbf 100644 --- a/packages/remix-dev/config/routes.ts +++ b/packages/remix-dev/config/routes.ts @@ -1,22 +1,21 @@ -import { resolve, win32 } from "node:path"; +import { win32 } from "node:path"; import * as v from "valibot"; -import pick from "lodash/pick"; import invariant from "../invariant"; -let appDirectory: string; +let routeConfigAppDirectory: string; -export function setAppDirectory(directory: string) { - appDirectory = directory; +export function setRouteConfigAppDirectory(directory: string) { + routeConfigAppDirectory = directory; } /** * Provides the absolute path to the app directory, for use within `routes.ts`. * This is designed to support resolving file system routes. */ -export function getAppDirectory() { - invariant(appDirectory); - return appDirectory; +export function getRouteConfigAppDirectory() { + invariant(routeConfigAppDirectory); + return routeConfigAppDirectory; } export interface RouteManifestEntry { @@ -171,176 +170,6 @@ export function validateRouteConfig({ return { valid: true }; } -const createConfigRouteOptionKeys = [ - "id", - "index", - "caseSensitive", -] as const satisfies ReadonlyArray; -type CreateRouteOptions = Pick< - RouteConfigEntry, - typeof createConfigRouteOptionKeys[number] ->; -/** - * Helper function for creating a route config entry, for use within - * `routes.ts`. - */ -function route( - path: string | null | undefined, - file: string, - children?: RouteConfigEntry[] -): RouteConfigEntry; -function route( - path: string | null | undefined, - file: string, - options: CreateRouteOptions, - children?: RouteConfigEntry[] -): RouteConfigEntry; -function route( - path: string | null | undefined, - file: string, - optionsOrChildren: CreateRouteOptions | RouteConfigEntry[] | undefined, - children?: RouteConfigEntry[] -): RouteConfigEntry { - let options: CreateRouteOptions = {}; - - if (Array.isArray(optionsOrChildren) || !optionsOrChildren) { - children = optionsOrChildren; - } else { - options = optionsOrChildren; - } - - return { - file, - children, - path: path ?? undefined, - ...pick(options, createConfigRouteOptionKeys), - }; -} - -const createIndexOptionKeys = ["id"] as const satisfies ReadonlyArray< - keyof RouteConfigEntry ->; -type CreateIndexOptions = Pick< - RouteConfigEntry, - typeof createIndexOptionKeys[number] ->; -/** - * Helper function for creating a route config entry for an index route, for use - * within `routes.ts`. - */ -function index(file: string, options?: CreateIndexOptions): RouteConfigEntry { - return { - file, - index: true, - ...pick(options, createIndexOptionKeys), - }; -} - -const createLayoutOptionKeys = ["id"] as const satisfies ReadonlyArray< - keyof RouteConfigEntry ->; -type CreateLayoutOptions = Pick< - RouteConfigEntry, - typeof createLayoutOptionKeys[number] ->; -/** - * Helper function for creating a route config entry for a layout route, for use - * within `routes.ts`. - */ -function layout(file: string, children?: RouteConfigEntry[]): RouteConfigEntry; -function layout( - file: string, - options: CreateLayoutOptions, - children?: RouteConfigEntry[] -): RouteConfigEntry; -function layout( - file: string, - optionsOrChildren: CreateLayoutOptions | RouteConfigEntry[] | undefined, - children?: RouteConfigEntry[] -): RouteConfigEntry { - let options: CreateLayoutOptions = {}; - - if (Array.isArray(optionsOrChildren) || !optionsOrChildren) { - children = optionsOrChildren; - } else { - options = optionsOrChildren; - } - - return { - file, - children, - ...pick(options, createLayoutOptionKeys), - }; -} - -/** - * Helper function for adding a path prefix to a set of routes without needing - * to introduce a parent route file, for use within `routes.ts`. - */ -function prefix( - prefixPath: string, - routes: RouteConfigEntry[] -): RouteConfigEntry[] { - return routes.map((route) => { - if (route.index || typeof route.path === "string") { - return { - ...route, - path: route.path ? joinRoutePaths(prefixPath, route.path) : prefixPath, - children: route.children, - }; - } else if (route.children) { - return { - ...route, - children: prefix(prefixPath, route.children), - }; - } - return route; - }); -} - -const helpers = { route, index, layout, prefix }; -export { route, index, layout, prefix }; -/** - * Creates a set of route config helpers that resolve file paths relative to the - * given directory, for use within `routes.ts`. This is designed to support - * splitting route config into multiple files within different directories. - */ -export function relative(directory: string): typeof helpers { - return { - /** - * Helper function for creating a route config entry, for use within - * `routes.ts`. Note that this helper has been scoped, meaning that file - * path will be resolved relative to the directory provided to the - * `relative` call that created this helper. - */ - route: (path, file, ...rest) => { - return route(path, resolve(directory, file), ...(rest as any)); - }, - /** - * Helper function for creating a route config entry for an index route, for - * use within `routes.ts`. Note that this helper has been scoped, meaning - * that file path will be resolved relative to the directory provided to the - * `relative` call that created this helper. - */ - index: (file, ...rest) => { - return index(resolve(directory, file), ...(rest as any)); - }, - /** - * Helper function for creating a route config entry for a layout route, for - * use within `routes.ts`. Note that this helper has been scoped, meaning - * that file path will be resolved relative to the directory provided to the - * `relative` call that created this helper. - */ - layout: (file, ...rest) => { - return layout(resolve(directory, file), ...(rest as any)); - }, - - // Passthrough of helper functions that don't need relative scoping so that - // a complete API is still provided. - prefix, - }; -} - export function configRoutesToRouteManifest( routes: RouteConfigEntry[], rootId = "root" @@ -379,13 +208,6 @@ export function configRoutesToRouteManifest( return routeManifest; } -function joinRoutePaths(path1: string, path2: string): string { - return [ - path1.replace(/\/+$/, ""), // Remove trailing slashes - path2.replace(/^\/+/, ""), // Remove leading slashes - ].join("/"); -} - export interface DefineRouteOptions { /** * Should be `true` if the route `path` is case-sensitive. Defaults to diff --git a/packages/remix-dev/index.ts b/packages/remix-dev/index.ts index 1c28706e7c2..38581e081f7 100644 --- a/packages/remix-dev/index.ts +++ b/packages/remix-dev/index.ts @@ -5,6 +5,11 @@ export type { AppConfig, RemixConfig as ResolvedRemixConfig } from "./config"; export * as cli from "./cli/index"; export type { Manifest as AssetsManifest } from "./manifest"; +export type { + RouteConfig as UNSAFE_RouteConfig, + RouteConfigEntry as UNSAFE_RouteConfigEntry, +} from "./config/routes"; +export { getRouteConfigAppDirectory as UNSAFE_getRouteConfigAppDirectory } from "./config/routes"; export { getDependenciesToBundle } from "./dependencies"; export type { BuildManifest, diff --git a/packages/remix-dev/rollup.config.js b/packages/remix-dev/rollup.config.js index 83d36c525ec..b1d5fd10951 100644 --- a/packages/remix-dev/rollup.config.js +++ b/packages/remix-dev/rollup.config.js @@ -25,7 +25,6 @@ module.exports = function rollup() { }, input: [ `${sourceDir}/index.ts`, - `${sourceDir}/routes.ts`, // Since we're using a dynamic require for the Vite plugin, we // need to tell Rollup it's an entry point `${sourceDir}/vite/plugin.ts`, diff --git a/packages/remix-dev/routes.ts b/packages/remix-dev/routes.ts deleted file mode 100644 index c4ea5420ec9..00000000000 --- a/packages/remix-dev/routes.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type { RouteConfig, RouteConfigEntry } from "./config/routes"; - -export { - route, - index, - layout, - prefix, - relative, - getAppDirectory, -} from "./config/routes"; diff --git a/packages/remix-route-config/README.md b/packages/remix-route-config/README.md new file mode 100644 index 00000000000..40685a7476f --- /dev/null +++ b/packages/remix-route-config/README.md @@ -0,0 +1,13 @@ +# Welcome to Remix! + +[Remix](https://remix.run) is a web framework that helps you build better websites with React. + +To get started, open a new shell and run: + +```sh +npx create-remix@latest +``` + +Then follow the prompts you see in your terminal. + +For more information about Remix, [visit remix.run](https://remix.run)! diff --git a/packages/remix-dev/__tests__/route-config-test.ts b/packages/remix-route-config/__tests__/route-config-test.ts similarity index 78% rename from packages/remix-dev/__tests__/route-config-test.ts rename to packages/remix-route-config/__tests__/route-config-test.ts index 31714575895..9fea261db11 100644 --- a/packages/remix-dev/__tests__/route-config-test.ts +++ b/packages/remix-route-config/__tests__/route-config-test.ts @@ -1,14 +1,7 @@ import path from "node:path"; import { normalizePath } from "vite"; -import { - validateRouteConfig, - route, - layout, - index, - prefix, - relative, -} from "../config/routes"; +import { route, layout, index, prefix, relative } from "../routes"; function cleanPathsForSnapshot(obj: any): any { return JSON.parse( @@ -22,107 +15,6 @@ function cleanPathsForSnapshot(obj: any): any { } describe("route config", () => { - describe("validateRouteConfig", () => { - it("validates a route config", () => { - expect( - validateRouteConfig({ - routeConfigFile: "routes.ts", - routeConfig: prefix("prefix", [ - route("parent", "parent.tsx", [route("child", "child.tsx")]), - ]), - }).valid - ).toBe(true); - }); - - it("is invalid when not an array", () => { - let result = validateRouteConfig({ - routeConfigFile: "routes.ts", - routeConfig: route("path", "file.tsx"), - }); - - expect(result.valid).toBe(false); - expect(!result.valid && result.message).toMatchInlineSnapshot( - `"Route config in "routes.ts" must be an array."` - ); - }); - - it("is invalid when route is a promise", () => { - let result = validateRouteConfig({ - routeConfigFile: "routes.ts", - /* @ts-expect-error */ - routeConfig: [route("parent", "parent.tsx", [Promise.resolve({})])], - }); - - expect(result.valid).toBe(false); - expect(!result.valid && result.message).toMatchInlineSnapshot(` - "Route config in "routes.ts" is invalid. - - Path: routes.0.children.0 - Invalid type: Expected object but received a promise. Did you forget to await?" - `); - }); - - it("is invalid when file is missing", () => { - let result = validateRouteConfig({ - routeConfigFile: "routes.ts", - /* @ts-expect-error */ - routeConfig: [route("parent", "parent.tsx", [{ id: "child" }])], - }); - - expect(result.valid).toBe(false); - expect(!result.valid && result.message).toMatchInlineSnapshot(` - "Route config in "routes.ts" is invalid. - - Path: routes.0.children.0.file - Invalid type: Expected string but received undefined" - `); - }); - - it("is invalid when property is wrong type", () => { - let result = validateRouteConfig({ - routeConfigFile: "routes.ts", - /* @ts-expect-error */ - routeConfig: [route("parent", "parent.tsx", [{ file: 123 }])], - }); - - expect(result.valid).toBe(false); - expect(!result.valid && result.message).toMatchInlineSnapshot(` - "Route config in "routes.ts" is invalid. - - Path: routes.0.children.0.file - Invalid type: Expected string but received 123" - `); - }); - - it("shows multiple error messages", () => { - let result = validateRouteConfig({ - routeConfigFile: "routes.ts", - routeConfig: [ - /* @ts-expect-error */ - route("parent", "parent.tsx", [ - { id: "child" }, - { file: 123 }, - Promise.resolve(), - ]), - ], - }); - - expect(result.valid).toBe(false); - expect(!result.valid && result.message).toMatchInlineSnapshot(` - "Route config in "routes.ts" is invalid. - - Path: routes.0.children.0.file - Invalid type: Expected string but received undefined - - Path: routes.0.children.1.file - Invalid type: Expected string but received 123 - - Path: routes.0.children.2 - Invalid type: Expected object but received a promise. Did you forget to await?" - `); - }); - }); - describe("route helpers", () => { describe("route", () => { it("supports basic routes", () => { diff --git a/packages/remix-route-config/index.ts b/packages/remix-route-config/index.ts new file mode 100644 index 00000000000..3a82e4b4b70 --- /dev/null +++ b/packages/remix-route-config/index.ts @@ -0,0 +1,13 @@ +export type { + UNSAFE_RouteConfig as RouteConfig, + UNSAFE_RouteConfigEntry as RouteConfigEntry, +} from "@remix-run/dev"; + +export { + route, + index, + layout, + prefix, + relative, + getAppDirectory, +} from "./routes"; diff --git a/packages/remix-route-config/jest.config.js b/packages/remix-route-config/jest.config.js new file mode 100644 index 00000000000..620d75b4cca --- /dev/null +++ b/packages/remix-route-config/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('jest').Config} */ +module.exports = { + ...require("../../jest/jest.config.shared"), + displayName: "route-config", + setupFiles: [], +}; diff --git a/packages/remix-route-config/package.json b/packages/remix-route-config/package.json new file mode 100644 index 00000000000..574c8c042c0 --- /dev/null +++ b/packages/remix-route-config/package.json @@ -0,0 +1,53 @@ +{ + "name": "@remix-run/route-config", + "version": "2.13.1", + "description": "Config-based routing for Remix", + "bugs": { + "url": "https://github.com/remix-run/remix/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/remix-run/remix", + "directory": "packages/remix-fs-routes" + }, + "license": "MIT", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "tsc": "tsc" + }, + "dependencies": { + "lodash": "^4.17.21" + }, + "devDependencies": { + "@remix-run/dev": "workspace:*", + "@types/lodash": "^4.14.182", + "typescript": "^5.1.6", + "vite": "5.1.8" + }, + "peerDependencies": { + "@remix-run/dev": "workspace:^", + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "dist/", + "CHANGELOG.md", + "LICENSE.md", + "README.md" + ] +} diff --git a/packages/remix-route-config/rollup.config.js b/packages/remix-route-config/rollup.config.js new file mode 100644 index 00000000000..fde40570793 --- /dev/null +++ b/packages/remix-route-config/rollup.config.js @@ -0,0 +1,45 @@ +const path = require("node:path"); +const babel = require("@rollup/plugin-babel").default; +const nodeResolve = require("@rollup/plugin-node-resolve").default; +const copy = require("rollup-plugin-copy"); + +const { + copyToPlaygrounds, + createBanner, + getOutputDir, + isBareModuleId, +} = require("../../rollup.utils"); +const { name: packageName, version } = require("./package.json"); + +/** @returns {import("rollup").RollupOptions[]} */ +module.exports = function rollup() { + let sourceDir = "packages/remix-route-config"; + let outputDir = getOutputDir(packageName); + let outputDist = path.join(outputDir, "dist"); + + return [ + { + external: (id) => isBareModuleId(id), + input: `${sourceDir}/index.ts`, + output: { + banner: createBanner(packageName, version), + dir: outputDist, + format: "cjs", + preserveModules: true, + exports: "auto", + }, + plugins: [ + babel({ + babelHelpers: "bundled", + exclude: /node_modules/, + extensions: [".ts"], + }), + nodeResolve({ extensions: [".ts"] }), + copy({ + targets: [{ src: "LICENSE.md", dest: sourceDir }], + }), + copyToPlaygrounds(), + ], + }, + ]; +}; diff --git a/packages/remix-route-config/routes.ts b/packages/remix-route-config/routes.ts new file mode 100644 index 00000000000..d4a16790eb6 --- /dev/null +++ b/packages/remix-route-config/routes.ts @@ -0,0 +1,191 @@ +import { resolve } from "node:path"; +import pick from "lodash/pick"; +import { + type UNSAFE_RouteConfigEntry as RouteConfigEntry, + UNSAFE_getRouteConfigAppDirectory as getRouteConfigAppDirectory, +} from "@remix-run/dev"; + +/** + * Provides the absolute path to the app directory, for use within `routes.ts`. + * This is designed to support resolving file system routes. + */ +export function getAppDirectory() { + return getRouteConfigAppDirectory(); +} + +const createConfigRouteOptionKeys = [ + "id", + "index", + "caseSensitive", +] as const satisfies ReadonlyArray; +type CreateRouteOptions = Pick< + RouteConfigEntry, + typeof createConfigRouteOptionKeys[number] +>; +/** + * Helper function for creating a route config entry, for use within + * `routes.ts`. + */ +function route( + path: string | null | undefined, + file: string, + children?: RouteConfigEntry[] +): RouteConfigEntry; +function route( + path: string | null | undefined, + file: string, + options: CreateRouteOptions, + children?: RouteConfigEntry[] +): RouteConfigEntry; +function route( + path: string | null | undefined, + file: string, + optionsOrChildren: CreateRouteOptions | RouteConfigEntry[] | undefined, + children?: RouteConfigEntry[] +): RouteConfigEntry { + let options: CreateRouteOptions = {}; + + if (Array.isArray(optionsOrChildren) || !optionsOrChildren) { + children = optionsOrChildren; + } else { + options = optionsOrChildren; + } + + return { + file, + children, + path: path ?? undefined, + ...pick(options, createConfigRouteOptionKeys), + }; +} + +const createIndexOptionKeys = ["id"] as const satisfies ReadonlyArray< + keyof RouteConfigEntry +>; +type CreateIndexOptions = Pick< + RouteConfigEntry, + typeof createIndexOptionKeys[number] +>; +/** + * Helper function for creating a route config entry for an index route, for use + * within `routes.ts`. + */ +function index(file: string, options?: CreateIndexOptions): RouteConfigEntry { + return { + file, + index: true, + ...pick(options, createIndexOptionKeys), + }; +} + +const createLayoutOptionKeys = ["id"] as const satisfies ReadonlyArray< + keyof RouteConfigEntry +>; +type CreateLayoutOptions = Pick< + RouteConfigEntry, + typeof createLayoutOptionKeys[number] +>; +/** + * Helper function for creating a route config entry for a layout route, for use + * within `routes.ts`. + */ +function layout(file: string, children?: RouteConfigEntry[]): RouteConfigEntry; +function layout( + file: string, + options: CreateLayoutOptions, + children?: RouteConfigEntry[] +): RouteConfigEntry; +function layout( + file: string, + optionsOrChildren: CreateLayoutOptions | RouteConfigEntry[] | undefined, + children?: RouteConfigEntry[] +): RouteConfigEntry { + let options: CreateLayoutOptions = {}; + + if (Array.isArray(optionsOrChildren) || !optionsOrChildren) { + children = optionsOrChildren; + } else { + options = optionsOrChildren; + } + + return { + file, + children, + ...pick(options, createLayoutOptionKeys), + }; +} + +/** + * Helper function for adding a path prefix to a set of routes without needing + * to introduce a parent route file, for use within `routes.ts`. + */ +function prefix( + prefixPath: string, + routes: RouteConfigEntry[] +): RouteConfigEntry[] { + return routes.map((route) => { + if (route.index || typeof route.path === "string") { + return { + ...route, + path: route.path ? joinRoutePaths(prefixPath, route.path) : prefixPath, + children: route.children, + }; + } else if (route.children) { + return { + ...route, + children: prefix(prefixPath, route.children), + }; + } + return route; + }); +} + +const helpers = { route, index, layout, prefix }; +export { route, index, layout, prefix }; +/** + * Creates a set of route config helpers that resolve file paths relative to the + * given directory, for use within `routes.ts`. This is designed to support + * splitting route config into multiple files within different directories. + */ +export function relative(directory: string): typeof helpers { + return { + /** + * Helper function for creating a route config entry, for use within + * `routes.ts`. Note that this helper has been scoped, meaning that file + * path will be resolved relative to the directory provided to the + * `relative` call that created this helper. + */ + route: (path, file, ...rest) => { + return route(path, resolve(directory, file), ...(rest as any)); + }, + /** + * Helper function for creating a route config entry for an index route, for + * use within `routes.ts`. Note that this helper has been scoped, meaning + * that file path will be resolved relative to the directory provided to the + * `relative` call that created this helper. + */ + index: (file, ...rest) => { + return index(resolve(directory, file), ...(rest as any)); + }, + /** + * Helper function for creating a route config entry for a layout route, for + * use within `routes.ts`. Note that this helper has been scoped, meaning + * that file path will be resolved relative to the directory provided to the + * `relative` call that created this helper. + */ + layout: (file, ...rest) => { + return layout(resolve(directory, file), ...(rest as any)); + }, + + // Passthrough of helper functions that don't need relative scoping so that + // a complete API is still provided. + prefix, + }; +} + +function joinRoutePaths(path1: string, path2: string): string { + return [ + path1.replace(/\/+$/, ""), // Remove trailing slashes + path2.replace(/^\/+/, ""), // Remove leading slashes + ].join("/"); +} diff --git a/packages/remix-route-config/tsconfig.json b/packages/remix-route-config/tsconfig.json new file mode 100644 index 00000000000..2e85dccebf7 --- /dev/null +++ b/packages/remix-route-config/tsconfig.json @@ -0,0 +1,19 @@ +{ + "include": ["**/*.ts"], + "exclude": ["dist", "__tests__", "node_modules"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "target": "ES2022", + "module": "ES2022", + "skipLibCheck": true, + + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "jsx": "react", + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": ".", + "outDir": "./dist" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7edf9e59c7..964a4a76c3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -639,6 +639,9 @@ importers: '@remix-run/eslint-config': specifier: workspace:* version: link:../../../packages/remix-eslint-config + '@remix-run/route-config': + specifier: workspace:* + version: link:../../../packages/remix-route-config '@types/react': specifier: ^18.2.20 version: 18.2.20 @@ -1266,6 +1269,25 @@ importers: specifier: ^5.1.6 version: 5.1.6 + packages/remix-route-config: + dependencies: + lodash: + specifier: ^4.17.21 + version: 4.17.21 + devDependencies: + '@remix-run/dev': + specifier: workspace:* + version: link:../remix-dev + '@types/lodash': + specifier: ^4.14.182 + version: 4.14.182 + typescript: + specifier: ^5.1.6 + version: 5.1.6 + vite: + specifier: 5.1.8 + version: 5.1.8(@types/node@18.17.1) + packages/remix-serve: dependencies: '@remix-run/express': @@ -6911,7 +6933,7 @@ packages: object-keys: 1.1.1 object.assign: 4.1.5 regexp.prototype.flags: 1.5.2 - side-channel: 1.0.4 + side-channel: 1.0.6 which-boxed-primitive: 1.0.2 which-collection: 1.0.1 which-typed-array: 1.1.14 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 596042f9a74..799d60287e2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,6 +18,7 @@ packages: - "packages/remix-express" - "packages/remix-node" - "packages/remix-react" + - "packages/remix-route-config" - "packages/remix-serve" - "packages/remix-server-runtime" - "packages/remix-testing" diff --git a/scripts/publish.js b/scripts/publish.js index a50cdcaabab..f891c055e60 100644 --- a/scripts/publish.js +++ b/scripts/publish.js @@ -62,6 +62,7 @@ async function run() { "serve", "css-bundle", "testing", + "route-config", ]) { publish(path.join(buildDir, "@remix-run", name), tag); }