diff --git a/.changeset/eight-forks-collect.md b/.changeset/eight-forks-collect.md new file mode 100644 index 00000000000..7ab4e8abc7b --- /dev/null +++ b/.changeset/eight-forks-collect.md @@ -0,0 +1,5 @@ +--- +"@remix-run/dev": patch +--- + +Add flat routes convention diff --git a/.prettierignore b/.prettierignore index 8b09e0301d3..deeb6036e61 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1 @@ -.changeset/*.md \ No newline at end of file +.changeset/*.md diff --git a/packages/remix-dev/__tests__/defineRoutes-test.ts b/packages/remix-dev/__tests__/defineRoutes-test.ts index dbd73350dfa..903641b852a 100644 --- a/packages/remix-dev/__tests__/defineRoutes-test.ts +++ b/packages/remix-dev/__tests__/defineRoutes-test.ts @@ -18,7 +18,7 @@ describe("defineRoutes", () => { "file": "routes/home.js", "id": "routes/home", "index": undefined, - "parentId": undefined, + "parentId": "root", "path": "/", }, "routes/inbox": Object { @@ -26,7 +26,7 @@ describe("defineRoutes", () => { "file": "routes/inbox.js", "id": "routes/inbox", "index": undefined, - "parentId": undefined, + "parentId": "root", "path": "inbox", }, "routes/inbox/$messageId": Object { @@ -73,7 +73,7 @@ describe("defineRoutes", () => { "file": "one.md", "id": "one", "index": undefined, - "parentId": undefined, + "parentId": "root", "path": "one", }, "two": Object { @@ -81,7 +81,7 @@ describe("defineRoutes", () => { "file": "two.md", "id": "two", "index": undefined, - "parentId": undefined, + "parentId": "root", "path": "two", }, } @@ -102,7 +102,7 @@ describe("defineRoutes", () => { "file": "routes/other-route.tsx", "id": "routes/other-route", "index": undefined, - "parentId": undefined, + "parentId": "root", "path": "/other", }, "user": Object { @@ -110,7 +110,7 @@ describe("defineRoutes", () => { "file": "routes/index.tsx", "id": "user", "index": undefined, - "parentId": undefined, + "parentId": "root", "path": "/user", }, "user-by-id": Object { @@ -118,7 +118,7 @@ describe("defineRoutes", () => { "file": "routes/index.tsx", "id": "user-by-id", "index": undefined, - "parentId": undefined, + "parentId": "root", "path": "/user/:id", }, } diff --git a/packages/remix-dev/__tests__/flat-routes-test.ts b/packages/remix-dev/__tests__/flat-routes-test.ts new file mode 100644 index 00000000000..5b9bb523a85 --- /dev/null +++ b/packages/remix-dev/__tests__/flat-routes-test.ts @@ -0,0 +1,616 @@ +import path from "node:path"; + +import { + createRoutePath, + flatRoutesUniversal, + getRouteSegments, +} from "../config/flat-routes"; +import type { ConfigRoute } from "../config/routes"; + +let APP_DIR = path.join("test", "root", "app"); + +describe("flatRoutes", () => { + 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"], + ["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)/([$]).($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?/[?/[]?"], + + // Opting out of parent layout + ["app_.projects/$id.roadmap", "/app/projects/:id/roadmap"], + ["app.projects_/$id.roadmap", "/app/projects/:id/roadmap"], + ["app_.projects_/$id.roadmap", "/app/projects/:id/roadmap"], + ]; + + for (let [input, expected] of tests) { + it(`"${input}" -> "${expected}"`, () => { + let routeSegments = getRouteSegments(input); + expect(createRoutePath(routeSegments)).toBe(expected); + }); + } + + let invalidSlashFiles = [ + "($[$dollabills]).([.]lol)[/](what)/([$]).$", + "$[$dollabills].[.]lol[/]what/[$].$", + ]; + + for (let invalid of invalidSlashFiles) { + test("should error when using `/` in a route segment", () => { + let regex = new RegExp( + /Route segment (".*?") for (".*?") cannot contain "\/"/ + ); + expect(() => getRouteSegments(invalid)).toThrow(regex); + }); + } + + let invalidSplatFiles: string[] = [ + "routes/about.[*].tsx", + "routes/about.*.tsx", + ]; + + for (let invalid of invalidSplatFiles) { + test("should error when using `*` in a route segment", () => { + let regex = new RegExp( + /Route segment (".*?") for (".*?") cannot contain "\*"/ + ); + expect(() => getRouteSegments(invalid)).toThrow(regex); + }); + } + + let invalidParamFiles: string[] = [ + "routes/about.[:name].tsx", + "routes/about.:name.tsx", + ]; + + for (let invalid of invalidParamFiles) { + test("should error when using `:` in a route segment", () => { + let regex = new RegExp( + /Route segment (".*?") for (".*?") cannot contain ":"/ + ); + expect(() => getRouteSegments(invalid)).toThrow(regex); + }); + } + }); + + describe("should return the correct route hierarchy", () => { + // we'll add file manually before running the tests + let testFiles: [string, Omit][] = [ + [ + "routes/_auth.tsx", + { + id: "routes/_auth", + parentId: "root", + }, + ], + [ + "routes/_auth.forgot-password.tsx", + { + id: "routes/_auth.forgot-password", + parentId: "routes/_auth", + path: "forgot-password", + }, + ], + [ + "routes/_auth.login.tsx", + { + id: "routes/_auth.login", + parentId: "routes/_auth", + path: "login", + }, + ], + [ + "routes/_auth.reset-password.tsx", + { + id: "routes/_auth.reset-password", + parentId: "routes/_auth", + path: "reset-password", + }, + ], + [ + "routes/_auth.signup.tsx", + { + id: "routes/_auth.signup", + parentId: "routes/_auth", + path: "signup", + }, + ], + [ + "routes/_landing.tsx", + { + id: "routes/_landing", + parentId: "root", + }, + ], + [ + "routes/_landing._index.tsx", + { + id: "routes/_landing._index", + index: true, + parentId: "routes/_landing", + }, + ], + [ + "routes/_landing.index.tsx", + { + id: "routes/_landing.index", + parentId: "routes/_landing", + path: "index", + }, + ], + [ + "routes/about.tsx", + { + id: "routes/about", + parentId: "root", + path: "about", + }, + ], + [ + "routes/about._index.tsx", + { + id: "routes/about._index", + index: true, + parentId: "routes/about", + }, + ], + [ + "routes/about.$.tsx", + { + id: "routes/about.$", + parentId: "routes/about", + path: "*", + }, + ], + [ + "routes/about.faq.tsx", + { + id: "routes/about.faq", + parentId: "routes/about", + path: "faq", + }, + ], + [ + "routes/about.$splat.tsx", + { + id: "routes/about.$splat", + parentId: "routes/about", + path: ":splat", + }, + ], + [ + "routes/app.tsx", + { + id: "routes/app", + parentId: "root", + path: "app", + }, + ], + [ + "routes/app.calendar.$day.tsx", + { + id: "routes/app.calendar.$day", + parentId: "routes/app", + path: "calendar/:day", + }, + ], + [ + "routes/app.calendar._index.tsx", + { + id: "routes/app.calendar._index", + index: true, + parentId: "routes/app", + path: "calendar", + }, + ], + [ + "routes/app.projects.tsx", + { + id: "routes/app.projects", + parentId: "routes/app", + path: "projects", + }, + ], + [ + "routes/app.projects.$id.tsx", + { + id: "routes/app.projects.$id", + parentId: "routes/app.projects", + path: ":id", + }, + ], + + // Opt out of parent layout + [ + "routes/app_.projects.$id.roadmap[.pdf].tsx", + { + id: "routes/app_.projects.$id.roadmap[.pdf]", + parentId: "root", + path: "app/projects/:id/roadmap.pdf", + }, + ], + [ + "routes/app_.projects/$id.roadmap.tsx", + { + id: "routes/app_.projects/$id.roadmap", + parentId: "root", + path: "app/projects/:id/roadmap", + }, + ], + + [ + "routes/app.skip.tsx", + { + id: "routes/app.skip", + parentId: "routes/app", + path: "skip", + }, + ], + [ + "routes/app.skip_/layout.tsx", + { + id: "routes/app.skip_/layout", + index: undefined, + parentId: "routes/app", + path: "skip/layout", + }, + ], + + [ + "routes/app.skipall.tsx", + { + id: "routes/app.skipall", + parentId: "routes/app", + path: "skipall", + }, + ], + [ + "routes/app_.skipall_/_index.tsx", + { + id: "routes/app_.skipall_/_index", + index: true, + parentId: "root", + path: "app/skipall", + }, + ], + + // Escaping route segments + [ + "routes/about.[$splat].tsx", + { + id: "routes/about.[$splat]", + parentId: "routes/about", + path: "$splat", + }, + ], + [ + "routes/about.[[].tsx", + { + id: "routes/about.[[]", + parentId: "routes/about", + path: "[", + }, + ], + [ + "routes/about.[]].tsx", + { + id: "routes/about.[]]", + parentId: "routes/about", + path: "]", + }, + ], + [ + "routes/about.[.].tsx", + { + id: "routes/about.[.]", + parentId: "routes/about", + path: ".", + }, + ], + [ + "routes/about.[.[.*].].tsx", + { + id: "routes/about.[.[.*].]", + parentId: "routes/about", + path: ".[.*/]", + }, + ], + + // Optional route segments + [ + "routes/(nested)/_layout/($slug).tsx", + { + id: "routes/(nested)/_layout/($slug)", + parentId: "root", + path: "nested?/:slug?", + }, + ], + [ + "routes/(routes)/$.tsx", + { + id: "routes/(routes)/$", + parentId: "root", + path: "routes?/*", + }, + ], + [ + "routes/(routes).(sub)/$.tsx", + { + id: "routes/(routes).(sub)/$", + parentId: "root", + path: "routes?/sub?/*", + }, + ], + [ + "routes/(routes)/($slug).tsx", + { + id: "routes/(routes)/($slug)", + parentId: "root", + path: "routes?/:slug?", + }, + ], + [ + "routes/(routes).sub/($slug).tsx", + { + id: "routes/(routes).sub/($slug)", + parentId: "root", + path: "routes?/sub/:slug?", + }, + ], + [ + "routes/(nested)/$.tsx", + { + id: "routes/(nested)/$", + parentId: "root", + path: "nested?/*", + }, + ], + [ + "routes/(flat).$.tsx", + { + id: "routes/(flat).$", + parentId: "root", + path: "flat?/*", + }, + ], + [ + "routes/(flat).($slug).tsx", + { + id: "routes/(flat).($slug)", + parentId: "root", + path: "flat?/:slug?", + }, + ], + [ + "routes/flat.(sub).tsx", + { + id: "routes/flat.(sub)", + parentId: "root", + path: "flat/sub?", + }, + ], + [ + "routes/_layout.tsx", + { + id: "routes/_layout", + parentId: "root", + path: undefined, + }, + ], + [ + "routes/_layout.(test).tsx", + { + id: "routes/_layout.(test)", + parentId: "routes/_layout", + path: "test?", + }, + ], + [ + "routes/_layout/($slug).tsx", + { + id: "routes/_layout/($slug)", + parentId: "routes/_layout", + path: ":slug?", + }, + ], + + // Optional + escaped route segments + [ + "routes/([index]).tsx", + { + id: "routes/([index])", + parentId: "root", + path: "index?", + }, + ], + [ + "routes/([i]ndex)/([[]).([[]]).tsx", + { + id: "routes/([i]ndex)/([[]).([[]])", + parentId: "routes/([index])", + path: "[?/[]?", + }, + ], + [ + "routes/(sub).([[]).tsx", + { + id: "routes/(sub).([[])", + parentId: "root", + path: "sub?/[?", + }, + ], + [ + "routes/(sub).(]).tsx", + { + id: "routes/(sub).(])", + parentId: "root", + path: "sub?/]?", + }, + ], + [ + "routes/(sub).([[]]).tsx", + { + id: "routes/(sub).([[]])", + parentId: "root", + path: "sub?/[]?", + }, + ], + [ + "routes/(beef]).tsx", + { + id: "routes/(beef])", + parentId: "root", + path: "beef]?", + }, + ], + [ + "routes/(test)/(inde[x]).tsx", + { + id: "routes/(test)/(inde[x])", + parentId: "root", + path: "test?/index?", + }, + ], + [ + "routes/($[$dollabills]).([.]lol)/(what)/([$]).($up).tsx", + { + id: "routes/($[$dollabills]).([.]lol)/(what)/([$]).($up)", + parentId: "root", + path: ":$dollabills?/.lol?/what?/$?/:up?", + }, + ], + [ + "routes/(posts)/($slug)/([image.jpg]).tsx", + { + id: "routes/(posts)/($slug)/([image.jpg])", + parentId: "root", + path: "posts?/:slug?/image.jpg?", + }, + ], + [ + "routes/(sub)/([sitemap.xml]).tsx", + { + id: "routes/(sub)/([sitemap.xml])", + parentId: "root", + path: "sub?/sitemap.xml?", + }, + ], + [ + "routes/(sub)/[(sitemap.xml)].tsx", + { + id: "routes/(sub)/[(sitemap.xml)]", + parentId: "root", + path: "sub?/(sitemap.xml)", + }, + ], + [ + "routes/($slug[.]json).tsx", + { + id: "routes/($slug[.]json)", + parentId: "root", + path: ":slug.json?", + }, + ], + + [ + "routes/[]otherstuff].tsx", + { + id: "routes/[]otherstuff]", + parentId: "root", + path: "otherstuff]", + }, + ], + [ + "routes/$.tsx", + { + id: "routes/$", + parentId: "root", + path: "*", + }, + ], + ]; + + let files: [string, Omit][] = testFiles.map( + ([file, route]) => { + let filepath = file.split("/").join(path.sep); + return [filepath, { ...route, file: filepath }]; + } + ); + + let routeManifest = flatRoutesUniversal( + APP_DIR, + files.map(([file]) => path.join(APP_DIR, file)) + ); + let routes = Object.values(routeManifest); + + expect(routes).toHaveLength(files.length); + + for (let [file, route] of files) { + test(`hierarchy for ${file} - ${route.path}`, () => { + expect(routes).toContainEqual(route); + }); + } + }); +}); diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts index a6cdea1f44e..a6287eb7559 100644 --- a/packages/remix-dev/__tests__/readConfig-test.ts +++ b/packages/remix-dev/__tests__/readConfig-test.ts @@ -24,6 +24,7 @@ describe("readConfig", () => { tsconfigPath: expect.any(String), future: { v2_meta: expect.any(Boolean), + v2_routeConvention: expect.any(Boolean), }, }, ` @@ -37,6 +38,7 @@ describe("readConfig", () => { "entryServerFile": "entry.server.tsx", "future": Object { "v2_meta": Any, + "v2_routeConvention": Any, }, "mdx": undefined, "publicPath": "/build/", diff --git a/packages/remix-dev/__tests__/routesConvention-test.ts b/packages/remix-dev/__tests__/routesConvention-test.ts index a3513fc1025..15e77963def 100644 --- a/packages/remix-dev/__tests__/routesConvention-test.ts +++ b/packages/remix-dev/__tests__/routesConvention-test.ts @@ -113,8 +113,8 @@ describe("defineConventionalRoutes", () => { path.join(__dirname, "fixtures/replace-remix-magic-imports/app") ); let keys = Object.keys(routes); - expect(keys.length).toBe(14); - expect(keys.filter((key) => routes[key].parentId).length).toBe(5); + expect(keys).toHaveLength(14); + expect(keys.filter((key) => routes[key].parentId).length).toBe(14); expect(keys.filter((key) => routes[key].index).length).toBe(4); }); }); diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index 922a6247641..4a7e4455df7 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -9,6 +9,7 @@ import { defineConventionalRoutes } from "./config/routesConvention"; import { ServerMode, isValidServerMode } from "./config/serverModes"; import { serverBuildVirtualModule } from "./compiler/virtualModules"; import { writeConfigDefaults } from "./compiler/utils/tsconfig/write-config-defaults"; +import { flatRoutes } from "./config/flat-routes"; export interface RemixMdxConfig { rehypePlugins?: any[]; @@ -33,6 +34,7 @@ export type ServerPlatform = "node" | "neutral"; interface FutureConfig { v2_meta: boolean; + v2_routeConvention: boolean; } /** @@ -426,8 +428,13 @@ export async function readConfig( let routes: RouteManifest = { root: { path: "", id: "root", file: rootRouteFile }, }; + + let routesConvention = appConfig.future?.v2_routeConvention + ? flatRoutes + : defineConventionalRoutes; + if (fse.existsSync(path.resolve(appDirectory, "routes"))) { - let conventionalRoutes = defineConventionalRoutes( + let conventionalRoutes = routesConvention( appDirectory, appConfig.ignoredRouteFiles ); @@ -482,6 +489,7 @@ export async function readConfig( let future = { v2_meta: appConfig.future?.v2_meta === true, + v2_routeConvention: appConfig.future?.v2_routeConvention === true, }; return { diff --git a/packages/remix-dev/config/flat-routes.ts b/packages/remix-dev/config/flat-routes.ts new file mode 100644 index 00000000000..089f62ac91e --- /dev/null +++ b/packages/remix-dev/config/flat-routes.ts @@ -0,0 +1,317 @@ +import path from "node:path"; +import fg from "fast-glob"; + +import type { ConfigRoute, DefineRouteFunction, RouteManifest } from "./routes"; +import { createRouteId, defineRoutes } from "./routes"; +import { + escapeEnd, + escapeStart, + isSegmentSeparator, + optionalEnd, + optionalStart, + paramPrefixChar, + routeModuleExts, +} from "./routesConvention"; + +export function flatRoutes( + appDirectory: string, + ignoredFilePatterns?: string[] +): RouteManifest { + let extensions = routeModuleExts.join(","); + + let routePaths = fg.sync(`**/*{${extensions}}`, { + absolute: true, + cwd: path.join(appDirectory, "routes"), + ignore: ignoredFilePatterns, + }); + + return flatRoutesUniversal(appDirectory, routePaths); +} + +interface RouteInfo extends ConfigRoute { + name: string; + segments: string[]; +} + +/** + * Create route configs from a list of routes using the flat routes conventions. + * @param {string} appDirectory - The absolute root directory the routes were looked up from. + * @param {string[]} routePaths - The absolute route paths. + * @param {string} [prefix=routes] - The prefix to strip off of the routes. + */ +export function flatRoutesUniversal( + appDirectory: string, + routePaths: string[], + prefix: string = "routes" +): RouteManifest { + let routeMap = getRouteMap(appDirectory, routePaths, prefix); + + let uniqueRoutes = new Map(); + + function defineNestedRoutes( + defineRoute: DefineRouteFunction, + parentId?: string + ): void { + let childRoutes = Array.from(routeMap.values()).filter( + (routeInfo) => routeInfo.parentId === parentId + ); + let parentRoute = parentId ? routeMap.get(parentId) : undefined; + let parentRoutePath = parentRoute?.path ?? "/"; + for (let childRoute of childRoutes) { + let routePath = childRoute.path?.slice(parentRoutePath.length) ?? ""; + // remove leading slash + routePath = routePath.replace(/^\//, ""); + let index = childRoute.index; + let fullPath = childRoute.path; + + let uniqueRouteId = (fullPath || "") + (index ? "?index" : ""); + if (uniqueRouteId) { + let conflict = uniqueRoutes.get(uniqueRouteId); + if (conflict) { + throw new Error( + `Path ${JSON.stringify(fullPath)} defined by route ${JSON.stringify( + childRoute.id + )} conflicts with route ${JSON.stringify(conflict)}` + ); + } + uniqueRoutes.set(uniqueRouteId, childRoute.id); + } + if (index) { + let invalidChildRoutes = Object.values(routeMap).filter( + (routeInfo) => routeInfo.parentId === childRoute.id + ); + + if (invalidChildRoutes.length > 0) { + throw new Error( + `Child routes are not allowed in index routes. Please remove child routes of ${childRoute.id}` + ); + } + + defineRoute(routePath, routeMap.get(childRoute.id!)!.file, { + index: true, + }); + } else { + defineRoute(routePath, routeMap.get(childRoute.id!)!.file, () => { + defineNestedRoutes(defineRoute, childRoute.id); + }); + } + } + } + + let routes = defineRoutes(defineNestedRoutes); + + return routes; +} + +function isIndexRoute(routeId: string) { + return routeId.endsWith("_index"); +} + +type State = + | // normal path segment normal character concatenation until we hit a special character or the end of the segment (i.e. `/`, `.`, '\') + "NORMAL" + // we hit a `[` and are now in an escape sequence until we hit a `]` - take characters literally and skip isSegmentSeparator checks + | "ESCAPE" + // we hit a `(` and are now in an optional segment until we hit a `)` or an escape sequence + | "OPTIONAL" + // we previously were in a optional segment and hit a `[` and are now in an escape sequence until we hit a `]` - take characters literally and skip isSegmentSeparator checks - afterwards go back to OPTIONAL state + | "OPTIONAL_ESCAPE"; + +export function getRouteSegments(routeId: string) { + let routeSegments: string[] = []; + let index = 0; + let routeSegment = ""; + let rawRouteSegment = ""; + let state: State = "NORMAL"; + let pushRouteSegment = (routeSegment: string) => { + if (!routeSegment) return; + + if (rawRouteSegment === "*") { + throw new Error( + `Route segment "${rawRouteSegment}" for "${routeId}" cannot contain "*"` + ); + } + + if (rawRouteSegment.includes(":")) { + throw new Error( + `Route segment "${rawRouteSegment}" for "${routeId}" cannot contain ":"` + ); + } + + if (rawRouteSegment.includes("/")) { + throw new Error( + `Route segment "${routeSegment}" for "${routeId}" cannot contain "/"` + ); + } + routeSegments.push(routeSegment); + }; + + while (index < routeId.length) { + let char = routeId[index]; + index++; //advance to next char + + switch (state) { + case "NORMAL": { + if (isSegmentSeparator(char)) { + pushRouteSegment(routeSegment); + routeSegment = ""; + rawRouteSegment = ""; + state = "NORMAL"; + break; + } + if (char === escapeStart) { + state = "ESCAPE"; + break; + } + if (char === optionalStart) { + state = "OPTIONAL"; + break; + } + if (!routeSegment && char == paramPrefixChar) { + if (index === routeId.length) { + routeSegment += "*"; + rawRouteSegment += char; + } else { + routeSegment += ":"; + rawRouteSegment += char; + } + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + case "ESCAPE": { + if (char === escapeEnd) { + state = "NORMAL"; + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + case "OPTIONAL": { + if (char === optionalEnd) { + routeSegment += "?"; + rawRouteSegment += "?"; + state = "NORMAL"; + break; + } + + if (char === escapeStart) { + state = "OPTIONAL_ESCAPE"; + break; + } + + if (!routeSegment && char === paramPrefixChar) { + if (index === routeId.length) { + routeSegment += "*"; + rawRouteSegment += char; + } else { + routeSegment += ":"; + rawRouteSegment += char; + } + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + case "OPTIONAL_ESCAPE": { + if (char === escapeEnd) { + state = "OPTIONAL"; + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + } + } + + // process remaining segment + pushRouteSegment(routeSegment); + return routeSegments; +} + +function findParentRouteId( + routeInfo: RouteInfo, + nameMap: Map +) { + let parentName = routeInfo.segments.slice(0, -1).join("/"); + while (parentName) { + let parentRoute = nameMap.get(parentName); + if (parentRoute) return parentRoute.id; + parentName = parentName.substring(0, parentName.lastIndexOf("/")); + } + return undefined; +} + +function getRouteInfo( + appDirectory: string, + routeDirectory: string, + filePath: string +): RouteInfo { + let filePathWithoutApp = filePath.slice(appDirectory.length + 1); + let routeId = createRouteId(filePathWithoutApp); + let routeIdWithoutRoutes = routeId.slice(routeDirectory.length + 1); + let index = isIndexRoute(routeIdWithoutRoutes); + let routeSegments = getRouteSegments(routeIdWithoutRoutes); + let routePath = createRoutePath(routeSegments); + + return { + id: routeIdWithoutRoutes, + path: routePath, + file: filePathWithoutApp, + name: routeSegments.join("/"), + segments: routeSegments, + index, + }; +} + +export function createRoutePath(routeSegments: string[]) { + let result = ""; + + for (let segment of routeSegments) { + // skip pathless layout segments + if (segment.startsWith("_")) { + continue; + } + + // remove trailing slash + if (segment.endsWith("_")) { + segment = segment.slice(0, -1); + } + + result += `/${segment}`; + } + + return result || undefined; +} + +function getRouteMap( + appDirectory: string, + routePaths: string[], + prefix: string = "routes" +) { + let routeMap = new Map(); + let nameMap = new Map(); + + for (let routePath of routePaths) { + let routeInfo = getRouteInfo(appDirectory, prefix, routePath); + routeMap.set(routeInfo.id, routeInfo); + nameMap.set(routeInfo.name, routeInfo); + } + + // update parentIds for all routes + for (let routeInfo of routeMap.values()) { + let parentId = findParentRouteId(routeInfo, nameMap); + routeInfo.parentId = parentId; + } + + return routeMap; +} diff --git a/packages/remix-dev/config/routes.ts b/packages/remix-dev/config/routes.ts index 7f1e5696ef3..d6e70e44f60 100644 --- a/packages/remix-dev/config/routes.ts +++ b/packages/remix-dev/config/routes.ts @@ -151,7 +151,7 @@ export function defineRoutes( parentId: parentRoutes.length > 0 ? parentRoutes[parentRoutes.length - 1].id - : undefined, + : "root", file, }; diff --git a/packages/remix-dev/config/routesConvention.ts b/packages/remix-dev/config/routesConvention.ts index 60ea589ef2e..b9bbce99d29 100644 --- a/packages/remix-dev/config/routesConvention.ts +++ b/packages/remix-dev/config/routesConvention.ts @@ -5,7 +5,7 @@ import minimatch from "minimatch"; import type { RouteManifest, DefineRouteFunction } from "./routes"; import { defineRoutes, createRouteId } from "./routes"; -const routeModuleExts = [".js", ".jsx", ".ts", ".tsx", ".md", ".mdx"]; +export const routeModuleExts = [".js", ".jsx", ".ts", ".tsx", ".md", ".mdx"]; export function isRouteModuleFile(filename: string): boolean { return routeModuleExts.includes(path.extname(filename)); @@ -110,11 +110,12 @@ export function defineConventionalRoutes( return defineRoutes(defineNestedRoutes); } -let escapeStart = "["; -let escapeEnd = "]"; +export let paramPrefixChar = "$" as const; +export let escapeStart = "[" as const; +export let escapeEnd = "]" as const; -let optionalStart = "("; -let optionalEnd = ")"; +export let optionalStart = "(" as const; +export let optionalEnd = ")" as const; // TODO: Cleanup and write some tests for this function export function createRoutePath(partialRouteId: string): string | undefined { @@ -127,13 +128,13 @@ export function createRoutePath(partialRouteId: string): string | undefined { let skipSegment = false; for (let i = 0; i < partialRouteId.length; i++) { let char = partialRouteId.charAt(i); - let lastChar = i > 0 ? partialRouteId.charAt(i - 1) : undefined; + let prevChar = i > 0 ? partialRouteId.charAt(i - 1) : undefined; let nextChar = i < partialRouteId.length - 1 ? partialRouteId.charAt(i + 1) : undefined; function isNewEscapeSequence() { return ( - !inEscapeSequence && char === escapeStart && lastChar !== escapeStart + !inEscapeSequence && char === escapeStart && prevChar !== escapeStart ); } @@ -145,17 +146,11 @@ export function createRoutePath(partialRouteId: string): string | undefined { return char === "_" && nextChar === "_" && !rawSegmentBuffer; } - function isSegmentSeparator(checkChar = char) { - return ( - checkChar === "/" || checkChar === "." || checkChar === path.win32.sep - ); - } - function isNewOptionalSegment() { return ( char === optionalStart && - lastChar !== optionalStart && - (isSegmentSeparator(lastChar) || lastChar === undefined) && + prevChar !== optionalStart && + (isSegmentSeparator(prevChar) || prevChar === undefined) && !inOptionalSegment && !inEscapeSequence ); @@ -172,7 +167,7 @@ export function createRoutePath(partialRouteId: string): string | undefined { } if (skipSegment) { - if (isSegmentSeparator()) { + if (isSegmentSeparator(char)) { skipSegment = false; } continue; @@ -191,7 +186,7 @@ export function createRoutePath(partialRouteId: string): string | undefined { if (isNewOptionalSegment()) { inOptionalSegment++; optionalSegmentIndex = result.length; - result += "("; + result += optionalStart; continue; } @@ -212,7 +207,7 @@ export function createRoutePath(partialRouteId: string): string | undefined { continue; } - if (isSegmentSeparator()) { + if (isSegmentSeparator(char)) { if (rawSegmentBuffer === "index" && result.endsWith("index")) { result = result.replace(/\/?index$/, ""); } else { @@ -232,8 +227,8 @@ export function createRoutePath(partialRouteId: string): string | undefined { rawSegmentBuffer += char; - if (char === "$") { - if (nextChar === ")") { + if (char === paramPrefixChar) { + if (nextChar === optionalEnd) { throw new Error( `Invalid route path: ${partialRouteId}. Splat route $ is already optional` ); @@ -251,13 +246,18 @@ export function createRoutePath(partialRouteId: string): string | undefined { if (rawSegmentBuffer === "index" && result.endsWith("index?")) { throw new Error( - `Invalid route path: ${partialRouteId}. Make index route optional by using [index]` + `Invalid route path: ${partialRouteId}. Make index route optional by using (index)` ); } return result || undefined; } +export function isSegmentSeparator(checkChar: string | undefined) { + if (!checkChar) return false; + return ["/", ".", path.win32.sep].includes(checkChar); +} + function getParentRouteIds( routeIds: string[] ): Record {