diff --git a/contributors.yml b/contributors.yml index 54afe2ba94..8e876e73e2 100644 --- a/contributors.yml +++ b/contributors.yml @@ -268,6 +268,7 @@ - nowells - Nurai1 - Obi-Dann +- okalil - OlegDev1 - omahs - omar-moquete diff --git a/packages/react-router-fs-routes/__tests__/nestedRoutes-test.ts b/packages/react-router-fs-routes/__tests__/nestedRoutes-test.ts new file mode 100644 index 0000000000..3445bce6ed --- /dev/null +++ b/packages/react-router-fs-routes/__tests__/nestedRoutes-test.ts @@ -0,0 +1,104 @@ +import path from "node:path"; +import { nestedRoutes, createRoutePath } from "../nestedRoutes"; + +describe("nestedRoutes", () => { + describe("creates proper route paths", () => { + let tests: [string, string | undefined][] = [ + ["routes/$", "routes/*"], + ["routes/sub/$", "routes/sub/*"], + ["routes.sub/$", "routes/sub/*"], + ["routes/$slug", "routes/:slug"], + ["routes/sub/$slug", "routes/sub/:slug"], + ["routes.sub/$slug", "routes/sub/:slug"], + ["$", "*"], + ["nested/$", "nested/*"], + ["flat.$", "flat/*"], + ["$slug", ":slug"], + ["nested/$slug", "nested/:slug"], + ["flat.$slug", "flat/:slug"], + ["flat.sub", "flat/sub"], + ["nested/index", "nested"], + ["flat.index", "flat"], + ["index", undefined], + ["__layout/index", undefined], + ["__layout/test", "test"], + ["__layout.test", "test"], + ["__layout/$slug", ":slug"], + ["nested/__layout/$slug", "nested/:slug"], + ["$slug[.]json", ":slug.json"], + ["sub/[sitemap.xml]", "sub/sitemap.xml"], + ["posts/$slug/[image.jpg]", "posts/:slug/image.jpg"], + ["$[$dollabills].[.]lol[/]what/[$].$", ":$dollabills/.lol/what/$/*"], + ["sub.[[]", "sub/["], + ["sub.]", "sub/]"], + ["sub.[[]]", "sub/[]"], + ["sub.[[]", "sub/["], + ["beef]", "beef]"], + ["[index]", "index"], + ["test/inde[x]", "test/index"], + ["[i]ndex/[[].[[]]", "index/[/[]"], + + // Optional segment routes + ["(routes)/$", "routes?/*"], + ["(routes)/(sub)/$", "routes?/sub?/*"], + ["(routes).(sub)/$", "routes?/sub?/*"], + ["(routes)/($slug)", "routes?/:slug?"], + ["(routes)/sub/($slug)", "routes?/sub/:slug?"], + ["(routes).sub/($slug)", "routes?/sub/:slug?"], + ["(nested)/$", "nested?/*"], + ["(flat).$", "flat?/*"], + ["($slug)", ":slug?"], + ["(nested)/($slug)", "nested?/:slug?"], + ["(flat).($slug)", "flat?/:slug?"], + ["flat.(sub)", "flat/sub?"], + ["__layout/(test)", "test?"], + ["__layout.(test)", "test?"], + ["__layout/($slug)", ":slug?"], + ["(nested)/__layout/($slug)", "nested?/:slug?"], + ["($slug[.]json)", ":slug.json?"], + ["(sub)/([sitemap.xml])", "sub?/sitemap.xml?"], + ["(sub)/[(sitemap.xml)]", "sub?/(sitemap.xml)"], + ["(posts)/($slug)/([image.jpg])", "posts?/:slug?/image.jpg?"], + [ + "($[$dollabills]).([.]lol)[/](what)/([$]).$", + ":$dollabills?/.lol)/(what?/$?/*", + ], + [ + "($[$dollabills]).([.]lol)/(what)/([$]).($up)", + ":$dollabills?/.lol?/what?/$?/:up?", + ], + ["(sub).([[])", "sub?/[?"], + ["(sub).(])", "sub?/]?"], + ["(sub).([[]])", "sub?/[]?"], + ["(sub).([[])", "sub?/[?"], + ["(beef])", "beef]?"], + ["([index])", "index?"], + ["(test)/(inde[x])", "test?/index?"], + ["([i]ndex)/([[]).([[]])", "index?/[?/[]?"], + ]; + + for (let [input, expected] of tests) { + it(`"${input}" -> "${expected}"`, () => { + expect(createRoutePath(input)).toBe(expected); + }); + } + + describe("optional segments", () => { + it("will only work when starting and ending a segment with parenthesis", () => { + let [input, expected] = ["(routes.sub)/$", "(routes/sub)/*"]; + expect(createRoutePath(input)).toBe(expected); + }); + + it("throws error on optional to splat routes", () => { + expect(() => createRoutePath("(routes)/($)")).toThrow("Splat"); + expect(() => createRoutePath("($)")).toThrow("Splat"); + }); + + it("throws errors on optional index without brackets routes", () => { + expect(() => createRoutePath("(nested)/(index)")).toThrow("index"); + expect(() => createRoutePath("(flat).(index)")).toThrow("index"); + expect(() => createRoutePath("(index)")).toThrow("index"); + }); + }); + }); +}); diff --git a/packages/react-router-fs-routes/index.ts b/packages/react-router-fs-routes/index.ts index 3b046529d6..c75306d8c2 100644 --- a/packages/react-router-fs-routes/index.ts +++ b/packages/react-router-fs-routes/index.ts @@ -7,8 +7,23 @@ import { import { routeManifestToRouteConfig } from "./manifest"; import { flatRoutes as flatRoutesImpl } from "./flatRoutes"; +import { nestedRoutes as nestedRoutesImpl } from "./nestedRoutes"; import { normalizeSlashes } from "./normalizeSlashes"; +interface FileSystemRoutesOptions { + /** + * An array of [minimatch](https://www.npmjs.com/package/minimatch) globs that match files to ignore. + * Defaults to `[]`. + */ + ignoredRouteFiles?: string[]; + + /** + * The directory containing file system routes, relative to the app directory. + * Defaults to `"./routes"`. + */ + rootDirectory?: string; +} + /** * Creates route config from the file system using a convention that matches * [Remix v2's route file @@ -16,19 +31,7 @@ import { normalizeSlashes } from "./normalizeSlashes"; * within `routes.ts`. */ export async function flatRoutes( - options: { - /** - * An array of [minimatch](https://www.npmjs.com/package/minimatch) globs that match files to ignore. - * Defaults to `[]`. - */ - ignoredRouteFiles?: string[]; - - /** - * The directory containing file system routes, relative to the app directory. - * Defaults to `"./routes"`. - */ - rootDirectory?: string; - } = {} + options: FileSystemRoutesOptions = {} ): Promise { let { ignoredRouteFiles = [], rootDirectory: userRootDirectory = "routes" } = options; @@ -43,3 +46,19 @@ export async function flatRoutes( return routeManifestToRouteConfig(routes); } + +/** + * Creates route config from the file system using a convention that matches + * [Remix v1's route file + * naming](https://remix.run/docs/en/v1/file-conventions/routes-files), for use + * within `routes.ts`. + */ +export async function nestedRoutes( + options: FileSystemRoutesOptions = {} +): Promise { + let { ignoredRouteFiles = [], rootDirectory: userRootDirectory = "routes" } = + options; + let appDirectory = getAppDirectory(); + let rootDirectory = path.resolve(appDirectory, userRootDirectory); + return nestedRoutesImpl(appDirectory, ignoredRouteFiles, rootDirectory); +} diff --git a/packages/react-router-fs-routes/nestedRoutes.ts b/packages/react-router-fs-routes/nestedRoutes.ts new file mode 100644 index 0000000000..f15f9d3e71 --- /dev/null +++ b/packages/react-router-fs-routes/nestedRoutes.ts @@ -0,0 +1,343 @@ +import type { RouteConfigEntry } from "@react-router/dev/routes"; +import { minimatch } from "minimatch"; + +import fs from "node:fs"; +import path from "node:path"; + +const paramPrefixChar = "$" as const; +const escapeStart = "[" as const; +const escapeEnd = "]" as const; +const optionalStart = "(" as const; +const optionalEnd = ")" as const; + +const routeModuleExts = [".js", ".jsx", ".ts", ".tsx", ".md", ".mdx"]; + +function isRouteModuleFile(filename: string): boolean { + return routeModuleExts.includes(path.extname(filename)); +} + +/** + * Defines routes using the filesystem convention in `app/routes`. The rules are: + * + * - Route paths are derived from the file path. A `.` in the filename indicates + * a `/` in the URL (a "nested" URL, but no route nesting). A `$` in the + * filename indicates a dynamic URL segment. + * - Subdirectories are used for nested routes. + * + * For example, a file named `app/routes/gists/$username.tsx` creates a route + * with a path of `gists/:username`. + */ +export function nestedRoutes( + appRoutesDirectory: string, + ignoredFilePatterns: string[] = [], + routesDirectory: string +): RouteConfigEntry[] { + const files: { [routeId: string]: string } = {}; + + // First, find all route modules in app/routes + visitFiles(appRoutesDirectory, (file) => { + if ( + ignoredFilePatterns.length > 0 && + ignoredFilePatterns.some((pattern) => minimatch(file, pattern)) + ) { + return; + } + + if (isRouteModuleFile(file)) { + const relativePath = path.join(routesDirectory, file); + const routeId = relativePath.replace( + new RegExp( + `(${routeModuleExts + .map((ext) => ext.replace(".", "\\.")) + .join("|")})$` + ), + "" + ); + files[routeId] = relativePath; + return; + } + + throw new Error( + `Invalid route module file: ${path.join(appRoutesDirectory, file)}` + ); + }); + + const routeIds = Object.keys(files).sort(byLongestFirst); + const parentRouteIds = getParentRouteIds(routeIds); + const uniqueRoutes = new Map(); + + function defineNestedRoutes(parentId?: string): RouteConfigEntry[] { + const childRouteIds = routeIds.filter((id) => { + return parentRouteIds[id] === parentId; + }); + + const nestedRoutes = []; + for (const routeId of childRouteIds) { + const routePath: string | undefined = createRoutePath( + routeId.slice((parentId || routesDirectory).length + 1) + ); + + const isIndexRoute = routeId.endsWith("/index"); + const fullPath = createRoutePath( + routeId.slice(routesDirectory.length + 1) + ); + const uniqueRouteId = (fullPath || "") + (isIndexRoute ? "?index" : ""); + const isPathlessLayoutRoute = + routeId.split("/").pop()?.startsWith("__") === true; + + /** + * We do not try to detect path collisions for pathless layout route + * files because, by definition, they create the potential for route + * collisions _at that level in the tree_. + * + * Consider example where a user may want multiple pathless layout routes + * for different subfolders + * + * routes/ + * account.tsx + * account/ + * __public/ + * login.tsx + * perks.tsx + * __private/ + * orders.tsx + * profile.tsx + * __public.tsx + * __private.tsx + * + * In order to support both a public and private layout for `/account/*` + * URLs, we are creating a mutually exclusive set of URLs beneath 2 + * separate pathless layout routes. In this case, the route paths for + * both account/__public.tsx and account/__private.tsx is the same + * (/account), but we're again not expecting to match at that level. + * + * By only ignoring this check when the final portion of the filename is + * pathless, we will still detect path collisions such as: + * + * routes/parent/__pathless/foo.tsx + * routes/parent/__pathless2/foo.tsx + * + * and + * + * routes/parent/__pathless/index.tsx + * routes/parent/__pathless2/index.tsx + */ + if (uniqueRouteId && !isPathlessLayoutRoute) { + if (uniqueRoutes.has(uniqueRouteId)) { + throw new Error( + `Path ${JSON.stringify(fullPath || "/")} defined by route ` + + `${JSON.stringify(routeId)} conflicts with route ` + + `${JSON.stringify(uniqueRoutes.get(uniqueRouteId))}` + ); + } else { + uniqueRoutes.set(uniqueRouteId, routeId); + } + } + + if (isIndexRoute) { + const invalidChildRoutes = routeIds.filter( + (id) => parentRouteIds[id] === routeId + ); + + if (invalidChildRoutes.length > 0) { + throw new Error( + `Child routes are not allowed in index routes. Please remove child routes of ${routeId}` + ); + } + + nestedRoutes.push({ + path: routePath, + file: files[routeId], + index: true, + id: routeId, + }); + } else { + nestedRoutes.push({ + path: routePath, + file: files[routeId], + id: routeId, + children: defineNestedRoutes(routeId), + }); + } + } + return nestedRoutes; + } + + return defineNestedRoutes(); +} + +// TODO: Cleanup and write some tests for this function +export function createRoutePath(partialRouteId: string): string | undefined { + let result = ""; + let rawSegmentBuffer = ""; + + let inEscapeSequence = 0; + let inOptionalSegment = 0; + let optionalSegmentIndex = null; + let skipSegment = false; + for (let i = 0; i < partialRouteId.length; i++) { + const char = partialRouteId.charAt(i); + const prevChar = i > 0 ? partialRouteId.charAt(i - 1) : undefined; + const nextChar = + i < partialRouteId.length - 1 ? partialRouteId.charAt(i + 1) : undefined; + + function isNewEscapeSequence() { + return ( + !inEscapeSequence && char === escapeStart && prevChar !== escapeStart + ); + } + + function isCloseEscapeSequence() { + return inEscapeSequence && char === escapeEnd && nextChar !== escapeEnd; + } + + function isStartOfLayoutSegment() { + return char === "_" && nextChar === "_" && !rawSegmentBuffer; + } + + function isNewOptionalSegment() { + return ( + char === optionalStart && + prevChar !== optionalStart && + (isSegmentSeparator(prevChar) || prevChar === undefined) && + !inOptionalSegment && + !inEscapeSequence + ); + } + + function isCloseOptionalSegment() { + return ( + char === optionalEnd && + nextChar !== optionalEnd && + (isSegmentSeparator(nextChar) || nextChar === undefined) && + inOptionalSegment && + !inEscapeSequence + ); + } + + if (skipSegment) { + if (isSegmentSeparator(char)) { + skipSegment = false; + } + continue; + } + + if (isNewEscapeSequence()) { + inEscapeSequence++; + continue; + } + + if (isCloseEscapeSequence()) { + inEscapeSequence--; + continue; + } + + if (isNewOptionalSegment()) { + inOptionalSegment++; + optionalSegmentIndex = result.length; + result += optionalStart; + continue; + } + + if (isCloseOptionalSegment()) { + if (optionalSegmentIndex !== null) { + result = + result.slice(0, optionalSegmentIndex) + + result.slice(optionalSegmentIndex + 1); + } + optionalSegmentIndex = null; + inOptionalSegment--; + result += "?"; + continue; + } + + if (inEscapeSequence) { + result += char; + continue; + } + + if (isSegmentSeparator(char)) { + if (rawSegmentBuffer === "index" && result.endsWith("index")) { + result = result.replace(/\/?index$/, ""); + } else { + result += "/"; + } + + rawSegmentBuffer = ""; + inOptionalSegment = 0; + optionalSegmentIndex = null; + continue; + } + + if (isStartOfLayoutSegment()) { + skipSegment = true; + continue; + } + + rawSegmentBuffer += char; + + if (char === paramPrefixChar) { + if (nextChar === optionalEnd) { + throw new Error( + `Invalid route path: ${partialRouteId}. Splat route $ is already optional` + ); + } + result += typeof nextChar === "undefined" ? "*" : ":"; + continue; + } + + result += char; + } + + if (rawSegmentBuffer === "index" && result.endsWith("index")) { + result = result.replace(/\/?index$/, ""); + } else { + result = result.replace(/\/$/, ""); + } + + if (rawSegmentBuffer === "index" && result.endsWith("index?")) { + throw new Error( + `Invalid route path: ${partialRouteId}. Make index route optional by using (index)` + ); + } + + return result || undefined; +} + +function isSegmentSeparator(checkChar: string | undefined) { + if (!checkChar) return false; + return ["/", ".", path.win32.sep].includes(checkChar); +} + +function getParentRouteIds( + routeIds: string[] +): Record { + return routeIds.reduce>( + (parentRouteIds, childRouteId) => ({ + ...parentRouteIds, + [childRouteId]: routeIds.find((id) => childRouteId.startsWith(`${id}/`)), + }), + {} + ); +} + +function byLongestFirst(a: string, b: string): number { + return b.length - a.length; +} + +function visitFiles( + dir: string, + visitor: (file: string) => void, + baseDir = dir +): void { + for (const filename of fs.readdirSync(dir)) { + const file = path.resolve(dir, filename); + const stat = fs.lstatSync(file); + + if (stat.isDirectory()) { + visitFiles(file, visitor, baseDir); + } else if (stat.isFile()) { + visitor(path.relative(baseDir, file)); + } + } +}