diff --git a/.changeset/three-seals-play.md b/.changeset/three-seals-play.md new file mode 100644 index 0000000000..59ab4af832 --- /dev/null +++ b/.changeset/three-seals-play.md @@ -0,0 +1,5 @@ +--- +"@react-router/dev": minor +--- + +Add `prefix` route config helper to `@react-router/dev/routes` diff --git a/docs/start/routing.md b/docs/start/routing.md index 3a73e96129..0791835156 100644 --- a/docs/start/routing.md +++ b/docs/start/routing.md @@ -37,6 +37,7 @@ import { route, index, layout, + prefix, } from "@react-router/dev/routes"; export const routes: RouteConfig = [ @@ -48,7 +49,7 @@ export const routes: RouteConfig = [ route("register", "./auth/register.tsx"), ]), - route("concerts", [ + ...prefix("concerts", [ index("./concerts/home.tsx"), route(":city", "./concerts/city.tsx"), route("trending", "./concerts/trending.tsx"), @@ -136,12 +137,13 @@ Every route in `routes.ts` is nested inside the special `app/root.tsx` module. Using `layout`, layout routes create new nesting for their children, but they don't add any segments to the URL. It's like the root route but they can be added at any level. -```tsx filename=app/routes.ts lines=[9,15] +```tsx filename=app/routes.ts lines=[10,16] import { type RouteConfig, route, layout, index, + prefix, } from "@react-router/dev/routes"; export const routes: RouteConfig = [ @@ -149,7 +151,7 @@ export const routes: RouteConfig = [ index("./marketing/home.tsx"), route("contact", "./marketing/contact.tsx"), ]), - route("projects", [ + ...prefix("projects", [ index("./projects/home.tsx"), layout("./projects/project-layout.tsx", [ route(":pid", "./projects/project.tsx"), @@ -187,6 +189,34 @@ export const routes: RouteConfig = [ Note that index routes can't have children. +## Route Prefixes + +Using `prefix`, you can add a path prefix to a set of routes without needing to introduce a parent route file. + +```tsx filename=app/routes.ts lines=[14] +import { + type RouteConfig, + route, + layout, + index, + prefix, +} from "@react-router/dev/routes"; + +export const routes: RouteConfig = [ + layout("./marketing/layout.tsx", [ + index("./marketing/home.tsx"), + route("contact", "./marketing/contact.tsx"), + ]), + ...prefix("projects", [ + index("./projects/home.tsx"), + layout("./projects/project-layout.tsx", [ + route(":pid", "./projects/project.tsx"), + route(":pid/edit", "./projects/edit-project.tsx"), + ]), + ]), +]; +``` + ## Dynamic Segments If a path segment starts with `:` then it becomes a "dynamic segment". When the route matches the URL, the dynamic segment will be parsed from the URL and provided as `params` to other router APIs. diff --git a/packages/react-router-dev/__tests__/route-config-test.ts b/packages/react-router-dev/__tests__/route-config-test.ts index e8ed077b73..4fcdddbb4a 100644 --- a/packages/react-router-dev/__tests__/route-config-test.ts +++ b/packages/react-router-dev/__tests__/route-config-test.ts @@ -3,6 +3,7 @@ import { route, layout, index, + prefix, relative, } from "../config/routes"; @@ -12,9 +13,9 @@ describe("route config", () => { expect( validateRouteConfig({ routeConfigFile: "routes.ts", - routeConfig: [ + routeConfig: prefix("prefix", [ route("parent", "parent.tsx", [route("child", "child.tsx")]), - ], + ]), }).valid ).toBe(true); }); @@ -306,6 +307,157 @@ describe("route config", () => { }); }); + describe("prefix", () => { + it("adds a prefix to routes", () => { + expect(prefix("prefix", [route("route", "routes/route.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix/route", + }, + ] + `); + }); + + it("adds a prefix to routes with a blank path", () => { + expect(prefix("prefix", [route("", "routes/route.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix", + }, + ] + `); + }); + + it("adds a prefix with a trailing slash to routes", () => { + expect(prefix("prefix/", [route("route", "routes/route.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix/route", + }, + ] + `); + }); + + it("adds a prefix to routes with leading slash", () => { + expect(prefix("prefix", [route("/route", "routes/route.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix/route", + }, + ] + `); + }); + + it("adds a prefix with a trailing slash to routes with leading slash", () => { + expect(prefix("prefix/", [route("/route", "routes/route.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix/route", + }, + ] + `); + }); + + it("adds a prefix to index routes", () => { + expect(prefix("prefix", [index("routes/index.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/index.tsx", + "index": true, + "path": "prefix", + }, + ] + `); + }); + + it("adds a prefix to children of layout routes", () => { + expect( + prefix("prefix", [ + layout("routes/layout.tsx", [route("route", "routes/route.tsx")]), + ]) + ).toMatchInlineSnapshot(` + [ + { + "children": [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix/route", + }, + ], + "file": "routes/layout.tsx", + }, + ] + `); + }); + + it("adds a prefix to children of nested layout routes", () => { + expect( + prefix("prefix", [ + layout("routes/layout-1.tsx", [ + route("layout-1-child", "routes/layout-1-child.tsx"), + layout("routes/layout-2.tsx", [ + route("layout-2-child", "routes/layout-2-child.tsx"), + layout("routes/layout-3.tsx", [ + route("layout-3-child", "routes/layout-3-child.tsx"), + ]), + ]), + ]), + ]) + ).toMatchInlineSnapshot(` + [ + { + "children": [ + { + "children": undefined, + "file": "routes/layout-1-child.tsx", + "path": "prefix/layout-1-child", + }, + { + "children": [ + { + "children": undefined, + "file": "routes/layout-2-child.tsx", + "path": "prefix/layout-2-child", + }, + { + "children": [ + { + "children": undefined, + "file": "routes/layout-3-child.tsx", + "path": "prefix/layout-3-child", + }, + ], + "file": "routes/layout-3.tsx", + }, + ], + "file": "routes/layout-2.tsx", + }, + ], + "file": "routes/layout-1.tsx", + }, + ] + `); + }); + }); + describe("relative", () => { it("supports relative routes", () => { let { route } = relative("/path/to/dirname"); @@ -368,6 +520,11 @@ describe("route config", () => { } `); }); + + it("provides passthrough for non-relative APIs", () => { + let { prefix: relativePrefix } = relative("/path/to/dirname"); + expect(relativePrefix).toBe(prefix); + }); }); }); }); diff --git a/packages/react-router-dev/config/routes.ts b/packages/react-router-dev/config/routes.ts index 428f62081a..2595d4da7d 100644 --- a/packages/react-router-dev/config/routes.ts +++ b/packages/react-router-dev/config/routes.ts @@ -183,18 +183,18 @@ type CreateRouteOptions = Pick< * Helper function for creating a route config entry, for use within * `routes.ts`. */ -function createRoute( +function route( path: string | null | undefined, file: string, children?: RouteConfigEntry[] ): RouteConfigEntry; -function createRoute( +function route( path: string | null | undefined, file: string, options: CreateRouteOptions, children?: RouteConfigEntry[] ): RouteConfigEntry; -function createRoute( +function route( path: string | null | undefined, file: string, optionsOrChildren: CreateRouteOptions | RouteConfigEntry[] | undefined, @@ -227,10 +227,7 @@ type CreateIndexOptions = Pick< * Helper function for creating a route config entry for an index route, for use * within `routes.ts`. */ -function createIndex( - file: string, - options?: CreateIndexOptions -): RouteConfigEntry { +function index(file: string, options?: CreateIndexOptions): RouteConfigEntry { return { file, index: true, @@ -249,16 +246,13 @@ type CreateLayoutOptions = Pick< * Helper function for creating a route config entry for a layout route, for use * within `routes.ts`. */ -function createLayout( - file: string, - children?: RouteConfigEntry[] -): RouteConfigEntry; -function createLayout( +function layout(file: string, children?: RouteConfigEntry[]): RouteConfigEntry; +function layout( file: string, options: CreateLayoutOptions, children?: RouteConfigEntry[] ): RouteConfigEntry; -function createLayout( +function layout( file: string, optionsOrChildren: CreateLayoutOptions | RouteConfigEntry[] | undefined, children?: RouteConfigEntry[] @@ -278,19 +272,39 @@ function createLayout( }; } -export const route = createRoute; -export const index = createIndex; -export const layout = createLayout; +/** + * 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): { - route: typeof route; - index: typeof index; - layout: typeof layout; -} { +export function relative(directory: string): typeof helpers { return { /** * Helper function for creating a route config entry, for use within @@ -319,6 +333,10 @@ export function relative(directory: string): { 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, }; } @@ -371,3 +389,10 @@ function normalizeSlashes(file: string) { function stripFileExtension(file: string) { return file.replace(/\.[a-z0-9]+$/i, ""); } + +function joinRoutePaths(path1: string, path2: string): string { + return [ + path1.replace(/\/+$/, ""), // Remove trailing slashes + path2.replace(/^\/+/, ""), // Remove leading slashes + ].join("/"); +} diff --git a/packages/react-router-dev/routes.ts b/packages/react-router-dev/routes.ts index 96140d6f72..c4ea5420ec 100644 --- a/packages/react-router-dev/routes.ts +++ b/packages/react-router-dev/routes.ts @@ -4,6 +4,7 @@ export { route, index, layout, + prefix, relative, getAppDirectory, } from "./config/routes";