diff --git a/jest.config.js b/jest.config.js index 2b9642fbc50..f2d8db3a278 100644 --- a/jest.config.js +++ b/jest.config.js @@ -17,7 +17,6 @@ module.exports = { "packages/remix-dev", "packages/remix-eslint-config", "packages/remix-express", - "packages/remix-fs-routes", "packages/remix-node", "packages/remix-react", "packages/remix-route-config", diff --git a/packages/remix-dev/index.ts b/packages/remix-dev/index.ts index baabf348966..61590b494c6 100644 --- a/packages/remix-dev/index.ts +++ b/packages/remix-dev/index.ts @@ -17,6 +17,7 @@ export { routeManifestToRouteConfig as UNSAFE_routeManifestToRouteConfig, getRouteConfigAppDirectory as UNSAFE_getRouteConfigAppDirectory, } from "./config/routes"; +export { flatRoutes as UNSAFE_flatRoutes } from "./config/flat-routes"; export { getDependenciesToBundle } from "./dependencies"; export type { BuildManifest, diff --git a/packages/remix-fs-routes/__tests__/flatRoutes-test.ts b/packages/remix-fs-routes/__tests__/flatRoutes-test.ts deleted file mode 100644 index a668b88c542..00000000000 --- a/packages/remix-fs-routes/__tests__/flatRoutes-test.ts +++ /dev/null @@ -1,879 +0,0 @@ -import path from "node:path"; - -import type { RouteManifestEntry } from "../manifest"; -import { - flatRoutesUniversal, - getRoutePathConflictErrorMessage, - getRouteIdConflictErrorMessage, - getRouteSegments, -} from "../flatRoutes"; -import { normalizeSlashes } from "../normalizeSlashes"; - -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.$slug", "routes/:slug"], - ["routes.sub.$slug", "routes/sub/:slug"], - ["$", "*"], - ["flat.$", "flat/*"], - ["$slug", ":slug"], - ["nested/index", "nested"], - ["nested.$", "*"], - ["nested.$slug", ":slug"], - ["nested._layout.$param", ":param"], - - ["flat.$slug", "flat/:slug"], - ["flat.sub", "flat/sub"], - ["flat._index", "flat"], - ["_index", undefined], - ["_layout/index", undefined], - ["_layout.test", "test"], - ["_layout.$param", ":param"], - ["$slug[.]json", ":slug.json"], - ["sub.[sitemap.xml]", "sub/sitemap.xml"], - ["posts.$slug.[image.jpg]", "posts/:slug/image.jpg"], - ["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).($slug)", "routes?/: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.($user)", ":user?"], - ["(nested)._layout.($param)", "nested?/:param?"], - ["($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?/[?"], - ["(beef])", "beef]?"], - ["([index])", "index?"], - ["(test).(inde[x])", "test?/index?"], - ["([i]ndex).([[]).([[]])", "index?/[?/[]?"], - - // Opting out of parent layout - ["user_.projects.$id.roadmap", "user/projects/:id/roadmap"], - ["app.projects_.$id.roadmap", "app/projects/:id/roadmap"], - ["shop_.projects_.$id.roadmap", "shop/projects/:id/roadmap"], - ]; - - let manifest = flatRoutesUniversal( - APP_DIR, - tests.map((t) => path.join(APP_DIR, "routes", t[0] + ".tsx")) - ); - - for (let [input, expected] of tests) { - it(`"${input}" -> "${expected}"`, () => { - if (input.endsWith("/route") || input.endsWith("/index")) { - input = input.replace(/\/(route|index)$/, ""); - } - let routeInfo = manifest[path.posix.join("routes", input)]; - expect(routeInfo.path).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", - "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", - path: undefined, - }, - ], - [ - "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/index.tsx", - { - id: "routes/_landing", - parentId: "root", - path: undefined, - }, - ], - [ - "routes/_landing._index/index.tsx", - { - id: "routes/_landing._index", - parentId: "routes/_landing", - path: undefined, - index: true, - }, - ], - [ - "routes/_landing.index.tsx", - { - id: "routes/_landing.index", - parentId: "routes/_landing", - path: "index", - }, - ], - [ - "routes/_about.tsx", - { - id: "routes/_about", - parentId: "root", - path: undefined, - }, - ], - [ - "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", - }, - ], - [ - "routes/app._pathless.tsx", - { - id: "routes/app._pathless", - parentId: "routes/app", - path: undefined, - }, - ], - [ - "routes/app._pathless._index.tsx", - { - id: "routes/app._pathless._index", - parentId: "routes/app._pathless", - index: true, - path: undefined, - }, - ], - [ - "routes/app._pathless.child.tsx", - { - id: "routes/app._pathless.child", - parentId: "routes/app._pathless", - path: "child", - }, - ], - [ - "routes/folder/route.tsx", - { - id: "routes/folder", - parentId: "root", - path: "folder", - }, - ], - [ - "routes/[route].tsx", - { - id: "routes/[route]", - parentId: "root", - path: "route", - }, - ], - - // 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_._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: ".", - }, - ], - - // 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: "root", - path: "_index?/[?/[]?", - }, - ], - [ - "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/brand.tsx", - { - id: "routes/brand", - parentId: "root", - path: "brand", - }, - ], - [ - "routes/brand._index.tsx", - { - id: "routes/brand._index", - parentId: "routes/brand", - index: true, - }, - ], - [ - "routes/$.tsx", - { - id: "routes/$", - parentId: "root", - path: "*", - }, - ], - ]; - - let files: [string, RouteManifestEntry][] = testFiles.map( - ([file, route]) => { - return [file, { ...route, file }]; - } - ); - - let routeManifest = flatRoutesUniversal( - APP_DIR, - files.map(([file]) => path.join(APP_DIR, file)) - ); - let routes = Object.values(routeManifest); - - test("route per file", () => { - expect(routes).toHaveLength(files.length); - }); - - for (let [file, route] of files) { - test(`hierarchy for ${file} - ${route.path}`, () => { - expect(routes).toContainEqual(route); - }); - } - }); - - describe("doesn't warn when there's not a route collision", () => { - let consoleError = jest - .spyOn(global.console, "error") - .mockImplementation(() => {}); - - afterEach(consoleError.mockReset); - - test("same number of segments and the same dynamic segment index", () => { - let testFiles = [ - path.join(APP_DIR, "routes", "_user.$username.tsx"), - path.join(APP_DIR, "routes", "sneakers.$sneakerId.tsx"), - ]; - - let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - let routes = Object.values(routeManifest); - - expect(routes).toHaveLength(testFiles.length); - expect(consoleError).not.toHaveBeenCalled(); - }); - }); - - describe("warns when there's a route collision", () => { - let consoleError = jest - .spyOn(global.console, "error") - .mockImplementation(() => {}); - - afterEach(consoleError.mockReset); - - test("index files", () => { - let testFiles = [ - path.join("routes", "_dashboard._index.tsx"), - path.join("routes", "_landing._index.tsx"), - path.join("routes", "_index.tsx"), - ]; - - // route manifest uses the full path - let fullPaths = testFiles.map((file) => path.join(APP_DIR, file)); - - // this is for the expected error message, - // which uses the relative path from the app directory internally - let normalizedTestFiles = testFiles.map((file) => normalizeSlashes(file)); - - let routeManifest = flatRoutesUniversal(APP_DIR, fullPaths); - - let routes = Object.values(routeManifest); - - expect(routes).toHaveLength(1); - expect(consoleError).toHaveBeenCalledWith( - getRoutePathConflictErrorMessage("/", normalizedTestFiles) - ); - }); - - test("folder/route.tsx matching folder.tsx", () => { - let testFiles = [ - path.join("routes", "dashboard", "route.tsx"), - path.join("routes", "dashboard.tsx"), - ]; - - // route manifest uses the full path - let fullPaths = testFiles.map((file) => path.join(APP_DIR, file)); - - // this is for the expected error message, - // which uses the relative path from the app directory internally - let normalizedTestFiles = testFiles.map((file) => normalizeSlashes(file)); - - let routeManifest = flatRoutesUniversal(APP_DIR, fullPaths); - - let routes = Object.values(routeManifest); - - expect(routes).toHaveLength(1); - expect(consoleError).toHaveBeenCalledWith( - getRouteIdConflictErrorMessage( - path.posix.join("routes", "dashboard"), - normalizedTestFiles - ) - ); - }); - - test("pathless layouts should not collide", () => { - let testFiles = [ - path.join(APP_DIR, "routes", "_a.tsx"), - path.join(APP_DIR, "routes", "_a._index.tsx"), - path.join(APP_DIR, "routes", "_a.a.tsx"), - path.join(APP_DIR, "routes", "_b.tsx"), - path.join(APP_DIR, "routes", "_b.b.tsx"), - ]; - - let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - let routes = Object.values(routeManifest); - - expect(consoleError).not.toHaveBeenCalled(); - expect(routes).toHaveLength(5); - - // When using folders and route.tsx files - testFiles = [ - path.join(APP_DIR, "routes", "_a", "route.tsx"), - path.join(APP_DIR, "routes", "_a._index", "route.tsx"), - path.join(APP_DIR, "routes", "_a.a", "route.tsx"), - path.join(APP_DIR, "routes", "_b", "route.tsx"), - path.join(APP_DIR, "routes", "_b.b", "route.tsx"), - ]; - - routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - routes = Object.values(routeManifest); - - expect(consoleError).not.toHaveBeenCalled(); - expect(routes).toHaveLength(5); - }); - - test("nested pathless layouts should not collide", () => { - let testFiles = [ - path.join(APP_DIR, "routes", "nested._a.tsx"), - path.join(APP_DIR, "routes", "nested._a._index.tsx"), - path.join(APP_DIR, "routes", "nested._a.a.tsx"), - path.join(APP_DIR, "routes", "nested._b.tsx"), - path.join(APP_DIR, "routes", "nested._b.b.tsx"), - ]; - - let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - let routes = Object.values(routeManifest); - - expect(consoleError).not.toHaveBeenCalled(); - expect(routes).toHaveLength(5); - - // When using folders and route.tsx files - testFiles = [ - path.join(APP_DIR, "routes", "nested._a", "route.tsx"), - path.join(APP_DIR, "routes", "nested._a._index", "route.tsx"), - path.join(APP_DIR, "routes", "nested._a.a", "route.tsx"), - path.join(APP_DIR, "routes", "nested._b", "route.tsx"), - path.join(APP_DIR, "routes", "nested._b.b", "route.tsx"), - ]; - - routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - routes = Object.values(routeManifest); - - expect(consoleError).not.toHaveBeenCalled(); - expect(routes).toHaveLength(5); - }); - - test("legit collisions without nested pathless layouts should collide (paths)", () => { - let testFiles = [ - path.join(APP_DIR, "routes", "nested._a.tsx"), - path.join(APP_DIR, "routes", "nested._a.a.tsx"), - path.join(APP_DIR, "routes", "nested._b.tsx"), - path.join(APP_DIR, "routes", "nested._b.a.tsx"), - ]; - - let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - let routes = Object.values(routeManifest); - - expect(consoleError).toHaveBeenCalledWith( - getRoutePathConflictErrorMessage("/nested/a", [ - "routes/nested._a.a.tsx", - "routes/nested._b.a.tsx", - ]) - ); - expect(routes).toHaveLength(3); - - // When using folders and route.tsx files - consoleError.mockClear(); - testFiles = [ - path.join(APP_DIR, "routes", "nested._a", "route.tsx"), - path.join(APP_DIR, "routes", "nested._a.a", "route.tsx"), - path.join(APP_DIR, "routes", "nested._b", "route.tsx"), - path.join(APP_DIR, "routes", "nested._b.a", "route.tsx"), - ]; - - routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - routes = Object.values(routeManifest); - - expect(consoleError).toHaveBeenCalledWith( - getRoutePathConflictErrorMessage("/nested/a", [ - "routes/nested._a.a/route.tsx", - "routes/nested._b.a/route.tsx", - ]) - ); - expect(routes).toHaveLength(3); - }); - - test("legit collisions without nested pathless layouts should collide (index routes)", () => { - let testFiles = [ - path.join(APP_DIR, "routes", "nested._a.tsx"), - path.join(APP_DIR, "routes", "nested._a._index.tsx"), - path.join(APP_DIR, "routes", "nested._b.tsx"), - path.join(APP_DIR, "routes", "nested._b._index.tsx"), - ]; - - let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - let routes = Object.values(routeManifest); - - expect(consoleError).toHaveBeenCalledWith( - getRoutePathConflictErrorMessage("/nested", [ - "routes/nested._a._index.tsx", - "routes/nested._b._index.tsx", - ]) - ); - expect(routes).toHaveLength(3); - - // When using folders and route.tsx files - consoleError.mockClear(); - testFiles = [ - path.join(APP_DIR, "routes", "nested._a", "route.tsx"), - path.join(APP_DIR, "routes", "nested._a._index", "route.tsx"), - path.join(APP_DIR, "routes", "nested._b", "route.tsx"), - path.join(APP_DIR, "routes", "nested._b._index", "route.tsx"), - ]; - - routeManifest = flatRoutesUniversal(APP_DIR, testFiles); - - routes = Object.values(routeManifest); - - expect(consoleError).toHaveBeenCalledWith( - getRoutePathConflictErrorMessage("/nested", [ - "routes/nested._a._index/route.tsx", - "routes/nested._b._index/route.tsx", - ]) - ); - expect(routes).toHaveLength(3); - }); - }); -}); diff --git a/packages/remix-fs-routes/flatRoutes.ts b/packages/remix-fs-routes/flatRoutes.ts deleted file mode 100644 index 9d48763b9dc..00000000000 --- a/packages/remix-fs-routes/flatRoutes.ts +++ /dev/null @@ -1,566 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { makeRe } from "minimatch"; -import type { - UNSAFE_RouteManifest as RouteManifest, - UNSAFE_RouteManifestEntry as RouteManifestEntry, -} from "@remix-run/dev"; - -import { normalizeSlashes } from "./normalizeSlashes"; - -export const routeModuleExts = [".js", ".jsx", ".ts", ".tsx", ".md", ".mdx"]; - -export let paramPrefixChar = "$" as const; -export let escapeStart = "[" as const; -export let escapeEnd = "]" as const; - -export let optionalStart = "(" as const; -export let optionalEnd = ")" as const; - -const PrefixLookupTrieEndSymbol = Symbol("PrefixLookupTrieEndSymbol"); -type PrefixLookupNode = { - [key: string]: PrefixLookupNode; -} & Record; - -class PrefixLookupTrie { - root: PrefixLookupNode = { - [PrefixLookupTrieEndSymbol]: false, - }; - - add(value: string) { - if (!value) throw new Error("Cannot add empty string to PrefixLookupTrie"); - - let node = this.root; - for (let char of value) { - if (!node[char]) { - node[char] = { - [PrefixLookupTrieEndSymbol]: false, - }; - } - node = node[char]; - } - node[PrefixLookupTrieEndSymbol] = true; - } - - findAndRemove( - prefix: string, - filter: (nodeValue: string) => boolean - ): string[] { - let node = this.root; - for (let char of prefix) { - if (!node[char]) return []; - node = node[char]; - } - - return this.#findAndRemoveRecursive([], node, prefix, filter); - } - - #findAndRemoveRecursive( - values: string[], - node: PrefixLookupNode, - prefix: string, - filter: (nodeValue: string) => boolean - ): string[] { - for (let char of Object.keys(node)) { - this.#findAndRemoveRecursive(values, node[char], prefix + char, filter); - } - - if (node[PrefixLookupTrieEndSymbol] && filter(prefix)) { - node[PrefixLookupTrieEndSymbol] = false; - values.push(prefix); - } - - return values; - } -} - -export function flatRoutes( - appDirectory: string, - ignoredFilePatterns: string[] = [], - prefix = "routes" -) { - let ignoredFileRegex = Array.from(new Set(["**/.*", ...ignoredFilePatterns])) - .map((re) => makeRe(re)) - .filter((re: any): re is RegExp => !!re); - let routesDir = path.join(appDirectory, prefix); - - let rootRoute = findFile(appDirectory, "root", routeModuleExts); - - if (!rootRoute) { - throw new Error( - `Could not find a root route module in the app directory: ${appDirectory}` - ); - } - - if (!fs.existsSync(rootRoute)) { - throw new Error( - `Could not find the routes directory: ${routesDir}. Did you forget to create it?` - ); - } - - // Only read the routes directory - let entries = fs.readdirSync(routesDir, { - withFileTypes: true, - encoding: "utf-8", - }); - - let routes: string[] = []; - for (let entry of entries) { - let filepath = normalizeSlashes(path.join(routesDir, entry.name)); - - let route: string | null = null; - // If it's a directory, don't recurse into it, instead just look for a route module - if (entry.isDirectory()) { - route = findRouteModuleForFolder( - appDirectory, - filepath, - ignoredFileRegex - ); - } else if (entry.isFile()) { - route = findRouteModuleForFile(appDirectory, filepath, ignoredFileRegex); - } - - if (route) routes.push(route); - } - - let routeManifest = flatRoutesUniversal(appDirectory, routes, prefix); - return routeManifest; -} - -export function flatRoutesUniversal( - appDirectory: string, - routes: string[], - prefix: string = "routes" -): RouteManifest { - let urlConflicts = new Map(); - let routeManifest: RouteManifest = {}; - let prefixLookup = new PrefixLookupTrie(); - let uniqueRoutes = new Map(); - let routeIdConflicts = new Map(); - - // id -> file - let routeIds = new Map(); - - for (let file of routes) { - let normalizedFile = normalizeSlashes(file); - let routeExt = path.extname(normalizedFile); - let routeDir = path.dirname(normalizedFile); - let normalizedApp = normalizeSlashes(appDirectory); - let routeId = - routeDir === path.posix.join(normalizedApp, prefix) - ? path.posix - .relative(normalizedApp, normalizedFile) - .slice(0, -routeExt.length) - : path.posix.relative(normalizedApp, routeDir); - - let conflict = routeIds.get(routeId); - if (conflict) { - let currentConflicts = routeIdConflicts.get(routeId); - if (!currentConflicts) { - currentConflicts = [path.posix.relative(normalizedApp, conflict)]; - } - currentConflicts.push(path.posix.relative(normalizedApp, normalizedFile)); - routeIdConflicts.set(routeId, currentConflicts); - continue; - } - - routeIds.set(routeId, normalizedFile); - } - - let sortedRouteIds = Array.from(routeIds).sort( - ([a], [b]) => b.length - a.length - ); - - for (let [routeId, file] of sortedRouteIds) { - let index = routeId.endsWith("_index"); - let [segments, raw] = getRouteSegments(routeId.slice(prefix.length + 1)); - let pathname = createRoutePath(segments, raw, index); - - routeManifest[routeId] = { - file: file.slice(appDirectory.length + 1), - id: routeId, - path: pathname, - }; - if (index) routeManifest[routeId].index = true; - let childRouteIds = prefixLookup.findAndRemove(routeId, (value) => { - return [".", "/"].includes(value.slice(routeId.length).charAt(0)); - }); - prefixLookup.add(routeId); - - if (childRouteIds.length > 0) { - for (let childRouteId of childRouteIds) { - routeManifest[childRouteId].parentId = routeId; - } - } - } - - // path creation - let parentChildrenMap = new Map(); - for (let [routeId] of sortedRouteIds) { - let config = routeManifest[routeId]; - if (!config.parentId) continue; - let existingChildren = parentChildrenMap.get(config.parentId) || []; - existingChildren.push(config); - parentChildrenMap.set(config.parentId, existingChildren); - } - - for (let [routeId] of sortedRouteIds) { - let config = routeManifest[routeId]; - let originalPathname = config.path || ""; - let pathname = config.path; - let parentConfig = config.parentId ? routeManifest[config.parentId] : null; - if (parentConfig?.path && pathname) { - pathname = pathname - .slice(parentConfig.path.length) - .replace(/^\//, "") - .replace(/\/$/, ""); - } - - if (!config.parentId) config.parentId = "root"; - config.path = pathname || undefined; - - /** - * 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._private.tsx - * account._private.orders.tsx - * account._private.profile.tsx - * account._public.tsx - * account._public.login.tsx - * account._public.perks.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 - */ - let lastRouteSegment = config.id - .replace(new RegExp(`^${prefix}/`), "") - .split(".") - .pop(); - let isPathlessLayoutRoute = - lastRouteSegment && - lastRouteSegment.startsWith("_") && - lastRouteSegment !== "_index"; - if (isPathlessLayoutRoute) { - continue; - } - - let conflictRouteId = originalPathname + (config.index ? "?index" : ""); - let conflict = uniqueRoutes.get(conflictRouteId); - uniqueRoutes.set(conflictRouteId, config); - - if (conflict && (originalPathname || config.index)) { - let currentConflicts = urlConflicts.get(originalPathname); - if (!currentConflicts) currentConflicts = [conflict]; - currentConflicts.push(config); - urlConflicts.set(originalPathname, currentConflicts); - continue; - } - } - - if (routeIdConflicts.size > 0) { - for (let [routeId, files] of routeIdConflicts.entries()) { - console.error(getRouteIdConflictErrorMessage(routeId, files)); - } - } - - // report conflicts - if (urlConflicts.size > 0) { - for (let [path, routes] of urlConflicts.entries()) { - // delete all but the first route from the manifest - for (let i = 1; i < routes.length; i++) { - delete routeManifest[routes[i].id]; - } - let files = routes.map((r) => r.file); - console.error(getRoutePathConflictErrorMessage(path, files)); - } - } - - return routeManifest; -} - -function findRouteModuleForFile( - appDirectory: string, - filepath: string, - ignoredFileRegex: RegExp[] -): string | null { - let relativePath = normalizeSlashes(path.relative(appDirectory, filepath)); - let isIgnored = ignoredFileRegex.some((regex) => regex.test(relativePath)); - if (isIgnored) return null; - return filepath; -} - -function findRouteModuleForFolder( - appDirectory: string, - filepath: string, - ignoredFileRegex: RegExp[] -): string | null { - let relativePath = path.relative(appDirectory, filepath); - let isIgnored = ignoredFileRegex.some((regex) => regex.test(relativePath)); - if (isIgnored) return null; - - let routeRouteModule = findFile(filepath, "route", routeModuleExts); - let routeIndexModule = findFile(filepath, "index", routeModuleExts); - - // if both a route and index module exist, throw a conflict error - // preferring the route module over the index module - if (routeRouteModule && routeIndexModule) { - let [segments, raw] = getRouteSegments( - path.relative(appDirectory, filepath) - ); - let routePath = createRoutePath(segments, raw, false); - console.error( - getRoutePathConflictErrorMessage(routePath || "/", [ - routeRouteModule, - routeIndexModule, - ]) - ); - } - - return routeRouteModule || routeIndexModule || null; -} - -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 opt fional 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): [string[], string[]] { - let routeSegments: string[] = []; - let rawRouteSegments: string[] = []; - let index = 0; - let routeSegment = ""; - let rawRouteSegment = ""; - let state: State = "NORMAL"; - - let pushRouteSegment = (segment: string, rawSegment: string) => { - if (!segment) return; - - let notSupportedInRR = (segment: string, char: string) => { - throw new Error( - `Route segment "${segment}" for "${routeId}" cannot contain "${char}".\n` + - `If this is something you need, upvote this proposal for React Router https://github.com/remix-run/react-router/discussions/9822.` - ); - }; - - if (rawSegment.includes("*")) { - return notSupportedInRR(rawSegment, "*"); - } - - if (rawSegment.includes(":")) { - return notSupportedInRR(rawSegment, ":"); - } - - if (rawSegment.includes("/")) { - return notSupportedInRR(segment, "/"); - } - - routeSegments.push(segment); - rawRouteSegments.push(rawSegment); - }; - - while (index < routeId.length) { - let char = routeId[index]; - index++; //advance to next char - - switch (state) { - case "NORMAL": { - if (isSegmentSeparator(char)) { - pushRouteSegment(routeSegment, rawRouteSegment); - routeSegment = ""; - rawRouteSegment = ""; - state = "NORMAL"; - break; - } - if (char === escapeStart) { - state = "ESCAPE"; - rawRouteSegment += char; - break; - } - if (char === optionalStart) { - state = "OPTIONAL"; - rawRouteSegment += char; - 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"; - rawRouteSegment += char; - break; - } - - routeSegment += char; - rawRouteSegment += char; - break; - } - case "OPTIONAL": { - if (char === optionalEnd) { - routeSegment += "?"; - rawRouteSegment += char; - state = "NORMAL"; - break; - } - - if (char === escapeStart) { - state = "OPTIONAL_ESCAPE"; - rawRouteSegment += char; - 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"; - rawRouteSegment += char; - break; - } - - routeSegment += char; - rawRouteSegment += char; - break; - } - } - } - - // process remaining segment - pushRouteSegment(routeSegment, rawRouteSegment); - return [routeSegments, rawRouteSegments]; -} - -export function createRoutePath( - routeSegments: string[], - rawRouteSegments: string[], - isIndex?: boolean -) { - let result: string[] = []; - - if (isIndex) { - routeSegments = routeSegments.slice(0, -1); - } - - for (let index = 0; index < routeSegments.length; index++) { - let segment = routeSegments[index]; - let rawSegment = rawRouteSegments[index]; - - // skip pathless layout segments - if (segment.startsWith("_") && rawSegment.startsWith("_")) { - continue; - } - - // remove trailing slash - if (segment.endsWith("_") && rawSegment.endsWith("_")) { - segment = segment.slice(0, -1); - } - - result.push(segment); - } - - return result.length ? result.join("/") : undefined; -} - -export function getRoutePathConflictErrorMessage( - pathname: string, - routes: string[] -) { - let [taken, ...others] = routes; - - if (!pathname.startsWith("/")) { - pathname = "/" + pathname; - } - - return ( - `⚠️ Route Path Collision: "${pathname}"\n\n` + - `The following routes all define the same URL, only the first one will be used\n\n` + - `🟢 ${taken}\n` + - others.map((route) => `⭕️️ ${route}`).join("\n") + - "\n" - ); -} - -export function getRouteIdConflictErrorMessage( - routeId: string, - files: string[] -) { - let [taken, ...others] = files; - - return ( - `⚠️ Route ID Collision: "${routeId}"\n\n` + - `The following routes all define the same Route ID, only the first one will be used\n\n` + - `🟢 ${taken}\n` + - others.map((route) => `⭕️️ ${route}`).join("\n") + - "\n" - ); -} - -export function isSegmentSeparator(checkChar: string | undefined) { - if (!checkChar) return false; - return ["/", ".", path.win32.sep].includes(checkChar); -} - -function findFile( - dir: string, - basename: string, - extensions: string[] -): string | undefined { - for (let ext of extensions) { - let name = basename + ext; - let file = path.join(dir, name); - if (fs.existsSync(file)) return file; - } - - return undefined; -} diff --git a/packages/remix-fs-routes/index.ts b/packages/remix-fs-routes/index.ts index faf5ea44c63..04e61405570 100644 --- a/packages/remix-fs-routes/index.ts +++ b/packages/remix-fs-routes/index.ts @@ -1,12 +1,14 @@ import fs from "node:fs"; import path from "node:path"; -import { UNSAFE_routeManifestToRouteConfig as routeManifestToRouteConfig } from "@remix-run/dev"; +import { + UNSAFE_flatRoutes as flatRoutesImpl, + UNSAFE_routeManifestToRouteConfig as routeManifestToRouteConfig, +} from "@remix-run/dev"; import { type RouteConfigEntry, getAppDirectory, } from "@remix-run/route-config"; -import { flatRoutes as flatRoutesImpl } from "./flatRoutes"; import { normalizeSlashes } from "./normalizeSlashes"; /** diff --git a/packages/remix-fs-routes/jest.config.js b/packages/remix-fs-routes/jest.config.js deleted file mode 100644 index 47d93e75154..00000000000 --- a/packages/remix-fs-routes/jest.config.js +++ /dev/null @@ -1,6 +0,0 @@ -/** @type {import('jest').Config} */ -module.exports = { - ...require("../../jest/jest.config.shared"), - displayName: "fs-routes", - setupFiles: [], -}; diff --git a/packages/remix-fs-routes/package.json b/packages/remix-fs-routes/package.json index 75b82093267..fc174d16a55 100644 --- a/packages/remix-fs-routes/package.json +++ b/packages/remix-fs-routes/package.json @@ -23,9 +23,6 @@ "scripts": { "tsc": "tsc" }, - "dependencies": { - "minimatch": "^9.0.0" - }, "devDependencies": { "@remix-run/dev": "workspace:*", "@remix-run/route-config": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ee9624be80..2a5540bdb5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1199,10 +1199,6 @@ importers: version: 5.1.6 packages/remix-fs-routes: - dependencies: - minimatch: - specifier: ^9.0.0 - version: 9.0.3 devDependencies: '@remix-run/dev': specifier: workspace:*