From 6b76180bcb0a0bef870a2257373cdb09a73a918b Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Tue, 15 Oct 2024 14:40:03 +1100 Subject: [PATCH] Add fs-routes package --- .../helpers/node-template/package.json | 1 + integration/vite-fs-routes-test.ts | 481 ++++++++++ integration/vite-route-config-test.ts | 8 +- jest.config.js | 1 + packages/remix-dev/package.json | 11 + packages/remix-fs-routes/README.md | 13 + .../__tests__/flatRoutes-test.ts | 879 ++++++++++++++++++ .../routeManifestToRouteConfig-test.ts | 100 ++ packages/remix-fs-routes/flatRoutes.ts | 563 +++++++++++ packages/remix-fs-routes/index.ts | 41 + packages/remix-fs-routes/jest.config.js | 6 + packages/remix-fs-routes/manifest.ts | 53 ++ packages/remix-fs-routes/normalizeSlashes.ts | 5 + packages/remix-fs-routes/package.json | 51 + packages/remix-fs-routes/rollup.config.js | 45 + packages/remix-fs-routes/tsconfig.json | 19 + pnpm-lock.yaml | 18 +- pnpm-workspace.yaml | 1 + scripts/publish.js | 1 + 19 files changed, 2292 insertions(+), 5 deletions(-) create mode 100644 integration/vite-fs-routes-test.ts create mode 100644 packages/remix-fs-routes/README.md create mode 100644 packages/remix-fs-routes/__tests__/flatRoutes-test.ts create mode 100644 packages/remix-fs-routes/__tests__/routeManifestToRouteConfig-test.ts create mode 100644 packages/remix-fs-routes/flatRoutes.ts create mode 100644 packages/remix-fs-routes/index.ts create mode 100644 packages/remix-fs-routes/jest.config.js create mode 100644 packages/remix-fs-routes/manifest.ts create mode 100644 packages/remix-fs-routes/normalizeSlashes.ts create mode 100644 packages/remix-fs-routes/package.json create mode 100644 packages/remix-fs-routes/rollup.config.js create mode 100644 packages/remix-fs-routes/tsconfig.json diff --git a/integration/helpers/node-template/package.json b/integration/helpers/node-template/package.json index f57ba9e27d5..f7828992f9e 100644 --- a/integration/helpers/node-template/package.json +++ b/integration/helpers/node-template/package.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@remix-run/dev": "workspace:*", + "@remix-run/fs-routes": "workspace:*", "@vanilla-extract/css": "^1.10.0", "@vanilla-extract/vite-plugin": "^3.9.2", "@types/react": "^18.2.20", diff --git a/integration/vite-fs-routes-test.ts b/integration/vite-fs-routes-test.ts new file mode 100644 index 00000000000..b0f7ca1241f --- /dev/null +++ b/integration/vite-fs-routes-test.ts @@ -0,0 +1,481 @@ +import { PassThrough } from "node:stream"; +import { test, expect } from "@playwright/test"; + +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { createFixtureProject } from "./helpers/create-fixture.js"; +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; + +let fixture: Fixture; +let appFixture: AppFixture; + +test.describe("fs-routes", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + compiler: "vite", + files: { + "vite.config.js": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix()], + }); + `, + "app/routes.ts": js` + import { type RouteConfig } from "@remix-run/dev/routes"; + import { flatRoutes } from "@remix-run/fs-routes"; + + export const routes: RouteConfig = flatRoutes({ + rootDirectory: "fs-routes", + ignoredRouteFiles: ["**/ignored-route.*"], + }); + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+

Root

+ +
+ + + + ); + } + `, + + "app/fs-routes/_index.tsx": js` + export default function () { + return

Index

; + } + `, + + "app/fs-routes/folder/route.tsx": js` + export default function () { + return

Folder (Route.jsx)

; + } + `, + + "app/fs-routes/folder2/index.tsx": js` + export default function () { + return

Folder (Index.jsx)

; + } + `, + + "app/fs-routes/flat.file.tsx": js` + export default function () { + return

Flat File

; + } + `, + + "app/fs-routes/.dotfile": ` + DOTFILE SHOULD BE IGNORED + `, + + "app/fs-routes/.route-with-unescaped-leading-dot.tsx": js` + throw new Error("This file should be ignored as a route"); + `, + + "app/fs-routes/[.]route-with-escaped-leading-dot.tsx": js` + export default function () { + return

Route With Escaped Leading Dot

; + } + `, + + "app/fs-routes/dashboard/route.tsx": js` + import { Outlet } from "@remix-run/react"; + + export default function () { + return ( + <> +

Dashboard Layout

+ + + ) + } + `, + + "app/fs-routes/dashboard._index/route.tsx": js` + export default function () { + return

Dashboard Index

; + } + `, + + [`app/fs-routes/ignored-route.jsx`]: js` + export default function () { + return

i should 404

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runTests(); + }); + + test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); + runTests(); + }); + + function runTests() { + test("renders matching routes (index)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Index

+
`); + }); + + test("renders matching routes (folder route.jsx)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/folder"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Folder (Route.jsx)

+
`); + }); + + test("renders matching routes (folder index.jsx)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/folder2"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Folder (Index.jsx)

+
`); + }); + + test("renders matching routes (flat file)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/flat/file"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Flat File

+
`); + }); + + test("renders matching routes (route with escaped leading dot)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/.route-with-escaped-leading-dot"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Route With Escaped Leading Dot

+
`); + }); + + test("renders matching routes (nested)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/dashboard"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Dashboard Layout

+

Dashboard Index

+
`); + }); + } + + test("allows ignoredRouteFiles to be configured", async () => { + let response = await fixture.requestDocument("/ignored-route"); + + expect(response.status).toBe(404); + }); +}); + +test.describe("emits warnings for route conflicts", async () => { + let buildStdio = new PassThrough(); + let buildOutput: string; + + let originalConsoleLog = console.log; + let originalConsoleWarn = console.warn; + let originalConsoleError = console.error; + + test.beforeAll(async () => { + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + await createFixtureProject({ + compiler: "vite", + buildStdio, + files: { + "vite.config.js": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix()], + }); + `, + "app/routes.ts": js` + import { type RouteConfig } from "@remix-run/dev/routes"; + import { flatRoutes } from "@remix-run/fs-routes"; + + export const routes: RouteConfig = flatRoutes({ + rootDirectory: "fs-routes", + }); + `, + "fs-routes/_dashboard._index.tsx": js` + export default function () { + return

routes/_dashboard._index

; + } + `, + "app/fs-routes/_index.tsx": js` + export default function () { + return

routes._index

; + } + `, + "app/fs-routes/_landing._index.tsx": js` + export default function () { + return

routes/_landing._index

; + } + `, + }, + }); + + let chunks: Buffer[] = []; + buildOutput = await new Promise((resolve, reject) => { + buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + buildStdio.on("error", (err) => reject(err)); + buildStdio.on("end", () => + resolve(Buffer.concat(chunks).toString("utf8")) + ); + }); + }); + + test.afterAll(() => { + console.log = originalConsoleLog; + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + }); + + test("warns about conflicting routes", () => { + console.log(buildOutput); + expect(buildOutput).toContain(`⚠️ Route Path Collision: "/"`); + }); +}); + +test.describe("", () => { + let buildStdio = new PassThrough(); + let buildOutput: string; + + let originalConsoleLog = console.log; + let originalConsoleWarn = console.warn; + let originalConsoleError = console.error; + + test.beforeAll(async () => { + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + await createFixtureProject({ + compiler: "vite", + buildStdio, + files: { + "vite.config.js": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix()], + }); + `, + "app/routes.ts": js` + import { type RouteConfig } from "@remix-run/dev/routes"; + import { flatRoutes } from "@remix-run/fs-routes"; + + export const routes: RouteConfig = flatRoutes({ + rootDirectory: "fs-routes", + }); + `, + "app/fs-routes/_index/route.tsx": js``, + "app/fs-routes/_index/utils.ts": js``, + }, + }); + + let chunks: Buffer[] = []; + buildOutput = await new Promise((resolve, reject) => { + buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + buildStdio.on("error", (err) => reject(err)); + buildStdio.on("end", () => + resolve(Buffer.concat(chunks).toString("utf8")) + ); + }); + }); + + test.afterAll(() => { + console.log = originalConsoleLog; + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + }); + + test("doesn't emit a warning for nested index files with co-located files", () => { + expect(buildOutput).not.toContain(`Route Path Collision`); + }); +}); + +test.describe("pathless routes and route collisions", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + compiler: "vite", + files: { + "vite.config.js": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix()], + }); + `, + "app/routes.ts": js` + import { type RouteConfig } from "@remix-run/dev/routes"; + import { flatRoutes } from "@remix-run/fs-routes"; + + export const routes: RouteConfig = flatRoutes({ + rootDirectory: "fs-routes", + }); + `, + "app/root.tsx": js` + import { Link, Outlet, Scripts, useMatches } from "@remix-run/react"; + + export default function App() { + let matches = 'Number of matches: ' + useMatches().length; + return ( + + + +

{matches}

+ + + + + ); + } + `, + "app/fs-routes/nested._index.tsx": js` + export default function Index() { + return

Index

; + } + `, + "app/fs-routes/nested._pathless.tsx": js` + import { Outlet } from "@remix-run/react"; + + export default function Layout() { + return ( + <> +
Pathless Layout
+ + + ); + } + `, + "app/fs-routes/nested._pathless.foo.tsx": js` + export default function Foo() { + return

Foo

; + } + `, + "app/fs-routes/nested._pathless2.tsx": js` + import { Outlet } from "@remix-run/react"; + + export default function Layout() { + return ( + <> +
Pathless 2 Layout
+ + + ); + } + `, + "app/fs-routes/nested._pathless2.bar.tsx": js` + export default function Bar() { + return

Bar

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(async () => appFixture.close()); + + test.describe("with JavaScript", () => { + runTests(); + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runTests(); + }); + + /** + * Routes for this test look like this, for reference for the matches assertions: + * + * + * + * + * + * + * + * + * + * + */ + + function runTests() { + test("displays index page and not pathless layout page", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/nested"); + expect(await app.getHtml()).toMatch("Index"); + expect(await app.getHtml()).not.toMatch("Pathless Layout"); + expect(await app.getHtml()).toMatch("Number of matches: 2"); + }); + + test("displays page inside of pathless layout", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/nested/foo"); + expect(await app.getHtml()).not.toMatch("Index"); + expect(await app.getHtml()).toMatch("Pathless Layout"); + expect(await app.getHtml()).toMatch("Foo"); + expect(await app.getHtml()).toMatch("Number of matches: 3"); + }); + + // This also asserts that we support multiple sibling pathless route layouts + test("displays page inside of second pathless layout", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/nested/bar"); + expect(await app.getHtml()).not.toMatch("Index"); + expect(await app.getHtml()).toMatch("Pathless 2 Layout"); + expect(await app.getHtml()).toMatch("Bar"); + expect(await app.getHtml()).toMatch("Number of matches: 3"); + }); + } +}); diff --git a/integration/vite-route-config-test.ts b/integration/vite-route-config-test.ts index 6efddd412d8..6bab3cc24fe 100644 --- a/integration/vite-route-config-test.ts +++ b/integration/vite-route-config-test.ts @@ -121,7 +121,7 @@ test.describe("route config", () => { let files: Files = async ({ port }) => ({ "vite.config.js": await viteConfig.basic({ port }), "app/routes.ts": js` - import { type RouteConfig } from "@react-router/dev/routes"; + import { type RouteConfig } from "@remix-run/dev/routes"; export const routes: RouteConfig = [ { @@ -185,7 +185,7 @@ test.describe("route config", () => { export { routes } from "./actual-routes"; `, "app/actual-routes.ts": js` - import { type RouteConfig } from "@react-router/dev/routes"; + import { type RouteConfig } from "@rem/dev/routes"; export const routes: RouteConfig = [ { @@ -246,7 +246,7 @@ test.describe("route config", () => { let files: Files = async ({ port }) => ({ "vite.config.js": await viteConfig.basic({ port }), "app/routes.ts": js` - import { type RouteConfig } from "@react-router/dev/routes"; + import { type RouteConfig } from "@rem/dev/routes"; export const routes: RouteConfig = [ { @@ -325,7 +325,7 @@ test.describe("route config", () => { "vite.config.js": await viteConfig.basic({ port }), "app/routes.ts": js` import path from "node:path"; - import { type RouteConfig } from "@react-router/dev/routes"; + import { type RouteConfig } from "@rem/dev/routes"; export const routes: RouteConfig = [ { diff --git a/jest.config.js b/jest.config.js index 5ffd3c56699..d8b7338f522 100644 --- a/jest.config.js +++ b/jest.config.js @@ -17,6 +17,7 @@ module.exports = { "packages/remix-dev", "packages/remix-eslint-config", "packages/remix-express", + "packages/remix-fs-routes", "packages/remix-node", "packages/remix-react", "packages/remix-serve", diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index c285e9141c2..3a9e59b94bd 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -14,6 +14,17 @@ "license": "MIT", "main": "dist/index.js", "typings": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./routes": { + "types": "./dist/routes.d.ts", + "default": "./dist/routes.js" + }, + "./*": "./*" + }, "bin": { "remix": "dist/cli.js" }, diff --git a/packages/remix-fs-routes/README.md b/packages/remix-fs-routes/README.md new file mode 100644 index 00000000000..40685a7476f --- /dev/null +++ b/packages/remix-fs-routes/README.md @@ -0,0 +1,13 @@ +# Welcome to Remix! + +[Remix](https://remix.run) is a web framework that helps you build better websites with React. + +To get started, open a new shell and run: + +```sh +npx create-remix@latest +``` + +Then follow the prompts you see in your terminal. + +For more information about Remix, [visit remix.run](https://remix.run)! diff --git a/packages/remix-fs-routes/__tests__/flatRoutes-test.ts b/packages/remix-fs-routes/__tests__/flatRoutes-test.ts new file mode 100644 index 00000000000..a668b88c542 --- /dev/null +++ b/packages/remix-fs-routes/__tests__/flatRoutes-test.ts @@ -0,0 +1,879 @@ +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/__tests__/routeManifestToRouteConfig-test.ts b/packages/remix-fs-routes/__tests__/routeManifestToRouteConfig-test.ts new file mode 100644 index 00000000000..7831511b20a --- /dev/null +++ b/packages/remix-fs-routes/__tests__/routeManifestToRouteConfig-test.ts @@ -0,0 +1,100 @@ +import { route } from "@remix-run/dev/routes"; + +import { routeManifestToRouteConfig } from "../manifest"; + +const clean = (obj: any) => cleanUndefined(cleanIds(obj)); + +const cleanUndefined = (obj: any) => JSON.parse(JSON.stringify(obj)); + +const cleanIds = (obj: any) => + JSON.parse( + JSON.stringify(obj, function replacer(key, value) { + return key === "id" ? undefined : value; + }) + ); + +describe("routeManifestToRouteConfig", () => { + test("creates route config", () => { + let routeManifestConfig = routeManifestToRouteConfig({ + "routes/home": { + id: "routes/home", + parentId: "root", + path: "/", + file: "routes/home.js", + }, + "routes/inbox": { + id: "routes/inbox", + parentId: "root", + path: "inbox", + file: "routes/inbox.js", + }, + "routes/inbox/index": { + id: "routes/inbox/index", + parentId: "routes/inbox", + path: "/", + file: "routes/inbox/index.js", + index: true, + }, + "routes/inbox/$messageId": { + id: "routes/inbox/$messageId", + parentId: "routes/inbox", + path: ":messageId", + file: "routes/inbox/$messageId.js", + caseSensitive: true, + }, + }); + let routeConfig = [ + route("/", "routes/home.js"), + route("inbox", "routes/inbox.js", [ + route("/", "routes/inbox/index.js", { index: true }), + route(":messageId", "routes/inbox/$messageId.js", { + caseSensitive: true, + }), + ]), + ]; + + expect(clean(routeManifestConfig)).toEqual(clean(routeConfig)); + + expect(cleanUndefined(routeManifestConfig)).toMatchInlineSnapshot(` + [ + { + "file": "routes/home.js", + "id": "routes/home", + "path": "/", + }, + { + "children": [ + { + "file": "routes/inbox/index.js", + "id": "routes/inbox/index", + "index": true, + "path": "/", + }, + { + "caseSensitive": true, + "file": "routes/inbox/$messageId.js", + "id": "routes/inbox/$messageId", + "path": ":messageId", + }, + ], + "file": "routes/inbox.js", + "id": "routes/inbox", + "path": "inbox", + }, + ] + `); + }); + + test("creates route config with IDs", () => { + let routeConfig = routeManifestToRouteConfig({ + home: { + path: "/", + id: "home", + parentId: "root", + file: "routes/home.js", + }, + }); + + expect(routeConfig[0].id).toEqual("home"); + }); +}); diff --git a/packages/remix-fs-routes/flatRoutes.ts b/packages/remix-fs-routes/flatRoutes.ts new file mode 100644 index 00000000000..4195f735a4c --- /dev/null +++ b/packages/remix-fs-routes/flatRoutes.ts @@ -0,0 +1,563 @@ +import fs from "node:fs"; +import path from "node:path"; +import { makeRe } from "minimatch"; + +import type { RouteManifest, RouteManifestEntry } from "./manifest"; +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 new file mode 100644 index 00000000000..64161752af3 --- /dev/null +++ b/packages/remix-fs-routes/index.ts @@ -0,0 +1,41 @@ +import fs from "node:fs"; +import path from "node:path"; +import { type RouteConfigEntry, getAppDirectory } from "@remix-run/dev/routes"; + +import { routeManifestToRouteConfig } from "./manifest"; +import { flatRoutes as flatRoutesImpl } from "./flatRoutes"; +import { normalizeSlashes } from "./normalizeSlashes"; + +/** + * Creates route config from the file system that matches [Remix's default file + * conventions](https://remix.run/docs/en/v2/file-conventions/routes), for + * use 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; + } = {} +): Promise { + let { ignoredRouteFiles = [], rootDirectory: userRootDirectory = "routes" } = + options; + let appDirectory = getAppDirectory(); + let rootDirectory = path.resolve(appDirectory, userRootDirectory); + let relativeRootDirectory = path.relative(appDirectory, rootDirectory); + let prefix = normalizeSlashes(relativeRootDirectory); + + let routes = fs.existsSync(rootDirectory) + ? flatRoutesImpl(appDirectory, ignoredRouteFiles, prefix) + : {}; + + return routeManifestToRouteConfig(routes); +} diff --git a/packages/remix-fs-routes/jest.config.js b/packages/remix-fs-routes/jest.config.js new file mode 100644 index 00000000000..47d93e75154 --- /dev/null +++ b/packages/remix-fs-routes/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('jest').Config} */ +module.exports = { + ...require("../../jest/jest.config.shared"), + displayName: "fs-routes", + setupFiles: [], +}; diff --git a/packages/remix-fs-routes/manifest.ts b/packages/remix-fs-routes/manifest.ts new file mode 100644 index 00000000000..540997f0de6 --- /dev/null +++ b/packages/remix-fs-routes/manifest.ts @@ -0,0 +1,53 @@ +import type { RouteConfigEntry } from "@remix-run/dev/routes"; + +export interface RouteManifestEntry { + path?: string; + index?: boolean; + caseSensitive?: boolean; + id: string; + parentId?: string; + file: string; +} + +export interface RouteManifest { + [routeId: string]: RouteManifestEntry; +} + +export function routeManifestToRouteConfig( + routeManifest: RouteManifest, + rootId = "root" +): RouteConfigEntry[] { + let routeConfigById: { + [id: string]: Omit & + Required>; + } = {}; + + for (let id in routeManifest) { + let route = routeManifest[id]; + routeConfigById[id] = { + id: route.id, + file: route.file, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + }; + } + + let routeConfig: RouteConfigEntry[] = []; + + for (let id in routeConfigById) { + let route = routeConfigById[id]; + let parentId = routeManifest[route.id].parentId; + if (parentId === rootId) { + routeConfig.push(route); + } else { + let parentRoute = parentId && routeConfigById[parentId]; + if (parentRoute) { + parentRoute.children = parentRoute.children || []; + parentRoute.children.push(route); + } + } + } + + return routeConfig; +} diff --git a/packages/remix-fs-routes/normalizeSlashes.ts b/packages/remix-fs-routes/normalizeSlashes.ts new file mode 100644 index 00000000000..3d16e5041e8 --- /dev/null +++ b/packages/remix-fs-routes/normalizeSlashes.ts @@ -0,0 +1,5 @@ +import path from "node:path"; + +export function normalizeSlashes(file: string) { + return file.split(path.win32.sep).join("/"); +} diff --git a/packages/remix-fs-routes/package.json b/packages/remix-fs-routes/package.json new file mode 100644 index 00000000000..4187038cbb4 --- /dev/null +++ b/packages/remix-fs-routes/package.json @@ -0,0 +1,51 @@ +{ + "name": "@remix-run/fs-routes", + "version": "2.13.1", + "description": "File system routing conventions for Remix", + "bugs": { + "url": "https://github.com/remix-run/remix/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/remix-run/remix", + "directory": "packages/remix-fs-routes" + }, + "license": "MIT", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "tsc": "tsc" + }, + "dependencies": { + "minimatch": "^9.0.0" + }, + "devDependencies": { + "@remix-run/dev": "workspace:*", + "typescript": "^5.1.6" + }, + "peerDependencies": { + "@remix-run/dev": "workspace:^", + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "dist/", + "CHANGELOG.md", + "LICENSE.md", + "README.md" + ] +} diff --git a/packages/remix-fs-routes/rollup.config.js b/packages/remix-fs-routes/rollup.config.js new file mode 100644 index 00000000000..b9293d2cdcf --- /dev/null +++ b/packages/remix-fs-routes/rollup.config.js @@ -0,0 +1,45 @@ +const path = require("node:path"); +const babel = require("@rollup/plugin-babel").default; +const nodeResolve = require("@rollup/plugin-node-resolve").default; +const copy = require("rollup-plugin-copy"); + +const { + copyToPlaygrounds, + createBanner, + getOutputDir, + isBareModuleId, +} = require("../../rollup.utils"); +const { name: packageName, version } = require("./package.json"); + +/** @returns {import("rollup").RollupOptions[]} */ +module.exports = function rollup() { + let sourceDir = "packages/remix-fs-routes"; + let outputDir = getOutputDir(packageName); + let outputDist = path.join(outputDir, "dist"); + + return [ + { + external: (id) => isBareModuleId(id), + input: `${sourceDir}/index.ts`, + output: { + banner: createBanner(packageName, version), + dir: outputDist, + format: "cjs", + preserveModules: true, + exports: "auto", + }, + plugins: [ + babel({ + babelHelpers: "bundled", + exclude: /node_modules/, + extensions: [".ts"], + }), + nodeResolve({ extensions: [".ts"] }), + copy({ + targets: [{ src: "LICENSE.md", dest: sourceDir }], + }), + copyToPlaygrounds(), + ], + }, + ]; +}; diff --git a/packages/remix-fs-routes/tsconfig.json b/packages/remix-fs-routes/tsconfig.json new file mode 100644 index 00000000000..2e85dccebf7 --- /dev/null +++ b/packages/remix-fs-routes/tsconfig.json @@ -0,0 +1,19 @@ +{ + "include": ["**/*.ts"], + "exclude": ["dist", "__tests__", "node_modules"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "target": "ES2022", + "module": "ES2022", + "skipLibCheck": true, + + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "jsx": "react", + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": ".", + "outDir": "./dist" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7edf9e59c7..542e7306741 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -520,6 +520,9 @@ importers: '@remix-run/dev': specifier: workspace:* version: link:../../../packages/remix-dev + '@remix-run/fs-routes': + specifier: workspace:* + version: link:../../../packages/remix-fs-routes '@types/react': specifier: ^18.2.20 version: 18.2.20 @@ -1186,6 +1189,19 @@ importers: specifier: ^5.1.6 version: 5.1.6 + packages/remix-fs-routes: + dependencies: + minimatch: + specifier: ^9.0.0 + version: 9.0.3 + devDependencies: + '@remix-run/dev': + specifier: workspace:* + version: link:../remix-dev + typescript: + specifier: ^5.1.6 + version: 5.1.6 + packages/remix-node: dependencies: '@remix-run/server-runtime': @@ -6911,7 +6927,7 @@ packages: object-keys: 1.1.1 object.assign: 4.1.5 regexp.prototype.flags: 1.5.2 - side-channel: 1.0.4 + side-channel: 1.0.6 which-boxed-primitive: 1.0.2 which-collection: 1.0.1 which-typed-array: 1.1.14 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 596042f9a74..77fea10e18e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,7 @@ packages: - "packages/remix-dev" - "packages/remix-eslint-config" - "packages/remix-express" + - "packages/remix-fs-routes" - "packages/remix-node" - "packages/remix-react" - "packages/remix-serve" diff --git a/scripts/publish.js b/scripts/publish.js index a50cdcaabab..7283e41ec73 100644 --- a/scripts/publish.js +++ b/scripts/publish.js @@ -60,6 +60,7 @@ async function run() { "express", // publish express before serve "react", "serve", + "fs-routes", "css-bundle", "testing", ]) {