diff --git a/integration/helpers/node-template/package.json b/integration/helpers/node-template/package.json
index f57ba9e27d5..1d26acc8cfb 100644
--- a/integration/helpers/node-template/package.json
+++ b/integration/helpers/node-template/package.json
@@ -23,6 +23,8 @@
},
"devDependencies": {
"@remix-run/dev": "workspace:*",
+ "@remix-run/route-config": "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..e3709d99967
--- /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/route-config";
+ 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/route-config";
+ 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/route-config";
+ 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/route-config";
+ 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/jest.config.js b/jest.config.js
index f2d8db3a278..2b9642fbc50 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-route-config",
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..d4e9be71597
--- /dev/null
+++ b/packages/remix-fs-routes/__tests__/routeManifestToRouteConfig-test.ts
@@ -0,0 +1,100 @@
+import { route } from "@remix-run/route-config";
+
+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..a0ccc39fefd
--- /dev/null
+++ b/packages/remix-fs-routes/index.ts
@@ -0,0 +1,44 @@
+import fs from "node:fs";
+import path from "node:path";
+import {
+ type RouteConfigEntry,
+ getAppDirectory,
+} from "@remix-run/route-config";
+
+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..3b9ea7ae1d8
--- /dev/null
+++ b/packages/remix-fs-routes/manifest.ts
@@ -0,0 +1,53 @@
+import type { RouteConfigEntry } from "@remix-run/route-config";
+
+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..5f803ade36b
--- /dev/null
+++ b/packages/remix-fs-routes/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@remix-run/fs-routes",
+ "version": "2.13.1",
+ "description": "Config-based 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/route-config": "workspace:*",
+ "typescript": "^5.1.6"
+ },
+ "peerDependencies": {
+ "@remix-run/route-config": "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..d8bcb86a4b9
--- /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": "../../build/node_modules/@remix-run/fs-routes/dist"
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 964a4a76c3a..502368f25bd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -520,6 +520,12 @@ importers:
'@remix-run/dev':
specifier: workspace:*
version: link:../../../packages/remix-dev
+ '@remix-run/fs-routes':
+ specifier: workspace:*
+ version: link:../../../packages/remix-fs-routes
+ '@remix-run/route-config':
+ specifier: workspace:*
+ version: link:../../../packages/remix-route-config
'@types/react':
specifier: ^18.2.20
version: 18.2.20
@@ -1189,6 +1195,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/route-config':
+ specifier: workspace:*
+ version: link:../remix-route-config
+ typescript:
+ specifier: ^5.1.6
+ version: 5.1.6
+
packages/remix-node:
dependencies:
'@remix-run/server-runtime':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 799d60287e2..8671741fd4c 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-route-config"
diff --git a/scripts/publish.js b/scripts/publish.js
index f891c055e60..5300a854af8 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",
"route-config",