diff --git a/.changeset/popular-humans-attend.md b/.changeset/popular-humans-attend.md new file mode 100644 index 00000000000..fd0fad3f579 --- /dev/null +++ b/.changeset/popular-humans-attend.md @@ -0,0 +1,29 @@ +--- +"@remix-run/dev": minor +--- + +Add support for `routes.ts` behind `future.v3_routeConfig` flag to assist with the migration to React Router v7. + +Config-based routing is the new default in React Router v7, configured via the `routes.ts` file in the app directory. Support for `routes.ts` and its related APIs in Remix are designed as a migration path to help minimize the number of changes required when moving your Remix project over to React Router v7. While some new packages have been introduced within the `@remix-run` scope, these new packages only exist to keep the code in `routes.ts` as similar as possible to the equivalent code for React Router v7. + +When the `v3_routeConfig` future flag is enabled, Remix's built-in file system routing will be disabled and your project will opted into React Router v7's config-based routing. + +To enable the flag, in your `vite.config.ts` file: + +```ts +remix({ + future: { + v3_routeConfig: true, + }, +}) +``` + +A minimal `routes.ts` file to support Remix's built-in file system routing looks like this: + +```ts +// app/routes.ts +import { flatRoutes } from "@remix-run/fs-routes"; +import type { RouteConfig } from "@remix-run/route-config"; + +export const routes: RouteConfig = flatRoutes(); +``` diff --git a/docs/start/future-flags.md b/docs/start/future-flags.md index 664388b602b..d0ed2ee3803 100644 --- a/docs/start/future-flags.md +++ b/docs/start/future-flags.md @@ -472,6 +472,153 @@ You shouldn't need to make any changes to your application code for this feature You may find some usage for the new [``][discover-prop] API if you wish to disable eager route discovery on certain links. +## v3_routeConfig + +Config-based routing is the new default in React Router v7, configured via the `routes.ts` file in the app directory. Support for `routes.ts` and its related APIs in Remix are designed as a migration path to help minimize the number of changes required when moving your Remix project over to React Router v7. While some new packages have been introduced within the `@remix-run` scope, these new packages only exist to keep the code in `routes.ts` as similar as possible to the equivalent code for React Router v7. + +When the `v3_routeConfig` future flag is enabled, Remix's built-in file system routing will be disabled and your project will opted into React Router v7's config-based routing. To opt back in to file system routing, this can be explicitly configured within `routes.ts` as we'll cover below. + +**Update your code** + +To migrate Remix's file system routing and route config to the equivalent setup in React Router v7, you can follow these steps: + +👉 **Enable the Flag** + +```ts filename=vite.config.ts +remix({ + future: { + v3_routeConfig: true, + }, +}); +``` + +👉 **Install `@remix-run/route-config`** + +This package matches the API of React Router v7's `@react-router/dev/routes`, making the React Router v7 migration as easy as possible. + +```shellscript nonumber +npm install --dev @remix-run/route-config +``` + +This provides the core `RouteConfig` type as well as a set of helpers for configuring routes in code. + +👉 **Add an `app/routes.ts` file without any configured routes** + +```shellscript nonumber +touch app/routes.ts +``` + +```ts filename=app/routes.ts +import type { RouteConfig } from "@remix-run/route-config"; + +export const routes: RouteConfig = []; +``` + +This is a good way to check that your new `routes.ts` file is being picked up successfully. Your app should now be rendering a blank page since there aren't any routes defined yet. + +👉 **Install `@remix-run/fs-routes` and use it in `routes.ts`** + +```shellscript nonumber +npm install --dev @remix-run/fs-routes +``` + +This package matches the API of React Router v7's `@react-router/fs-routes`, making the React Router v7 migration as easy as possible. + +> If you've configured `ignoredRouteFiles` to `["**/*"]`, you should skip this step since you're already opting out of Remix's file system routing. + +```ts filename=app/routes.ts +import { flatRoutes } from "@remix-run/fs-routes"; +import type { RouteConfig } from "@remix-run/route-config"; + +export const routes: RouteConfig = flatRoutes(); +``` + +👉 **If you used the `routes` config option, add `@remix-run/routes-option-adapter` and use it in `routes.ts`** + +Remix provides a mechanism for defining routes in code and plugging in alternative file system routing conventions, available via the `routes` option on the Vite plugin. + +To make migration easier, an adapter package is available that converts Remix's `routes` option into React Router's `RouteConfig` array. + +To get started, first install the adapter: + +```shellscript nonumber +npm install --dev @remix-run/routes-option-adapter +``` + +This package matches the API of React Router v7's `@react-router/remix-routes-option-adapter`, making the React Router v7 migration as easy as possible. + +Then, update your `routes.ts` file to use the adapter, passing the value of your `routes` option to the `remixRoutesOptionAdapter` function which will return an array of configured routes. + +For example, if you were using the `routes` option to use an alternative file system routing implementation like [remix-flat-routes]: + +```ts filename=app/routes.ts +import { type RouteConfig } from "@remix-run/route-config"; +import { remixRoutesOptionAdapter } from "@remix-run/routes-option-adapter"; +import { flatRoutes } from "remix-flat-routes"; + +export const routes: RouteConfig = remixRoutesOptionAdapter( + (defineRoutes) => flatRoutes("routes", defineRoutes) +); +``` + +Or, if you were using the `routes` option to define config-based routes: + +```ts filename=app/routes.ts +import { flatRoutes } from "@remix-run/fs-routes"; +import { type RouteConfig } from "@remix-run/route-config"; +import { remixRoutesOptionAdapter } from "@remix-run/routes-option-adapter"; + +export const routes: RouteConfig = remixRoutesOptionAdapter( + (defineRoutes) => { + return defineRoutes((route) => { + route("/", "home/route.tsx", { index: true }); + route("about", "about/route.tsx"); + route("", "concerts/layout.tsx", () => { + route("trending", "concerts/trending.tsx"); + route(":city", "concerts/city.tsx"); + }); + }); + } +); +``` + +If you're defining config-based routes in this way, you might want to consider migrating to the new route config API since it's more streamlined while still being very similar to the old API. For example, the routes above would look like this: + +```ts +import { + type RouteConfig, + route, + layout, + index, +} from "@remix-run/route-config"; + +export const routes: RouteConfig = [ + index("home/route.tsx"), + route("about", "about/route.tsx"), + layout("concerts/layout.tsx", [ + route("trending", "concerts/trending.tsx"), + route(":city", "concerts/city.tsx"), + ]), +]; +``` + +Note that if you need to mix and match different route config approaches, they can be merged together into a single array of routes. The `RouteConfig` type ensures that everything is still valid. + +```ts +import { flatRoutes } from "@remix-run/fs-routes"; +import type { RouteConfig } from "@remix-run/route-config"; +import { route } from "@remix-run/route-config"; +import { remixRoutesOptionAdapter } from "@remix-run/routes-option-adapter"; + +export const routes: RouteConfig = [ + ...(await flatRoutes({ rootDirectory: "fs-routes" })), + + ...(await remixRoutesOptionAdapter(/* ... */)), + + route("/hello", "routes/hello.tsx"), +]; +``` + ## unstable_optimizeDeps Opt into automatic [dependency optimization][dependency-optimization] during development. This flag will remain in an "unstable" state until React Router v7 so you do not need to adopt this in your Remix v2 app prior to upgrading to React Router v7. @@ -495,4 +642,5 @@ Opt into automatic [dependency optimization][dependency-optimization] during dev [vite-url-imports]: https://vitejs.dev/guide/assets.html#explicit-url-imports [mdx]: https://mdxjs.com [mdx-rollup-plugin]: https://mdxjs.com/packages/rollup +[remix-flat-routes]: https://github.com/kiliman/remix-flat-routes [dependency-optimization]: ../guides/dependency-optimization diff --git a/integration/helpers/node-template/package.json b/integration/helpers/node-template/package.json index f57ba9e27d5..2f477337507 100644 --- a/integration/helpers/node-template/package.json +++ b/integration/helpers/node-template/package.json @@ -23,6 +23,9 @@ }, "devDependencies": { "@remix-run/dev": "workspace:*", + "@remix-run/fs-routes": "workspace:*", + "@remix-run/route-config": "workspace:*", + "@remix-run/routes-option-adapter": "workspace:*", "@vanilla-extract/css": "^1.10.0", "@vanilla-extract/vite-plugin": "^3.9.2", "@types/react": "^18.2.20", diff --git a/integration/helpers/vite-template/package.json b/integration/helpers/vite-template/package.json index 3d9a59396e3..b2b9b38c7cd 100644 --- a/integration/helpers/vite-template/package.json +++ b/integration/helpers/vite-template/package.json @@ -26,6 +26,7 @@ "devDependencies": { "@remix-run/dev": "workspace:*", "@remix-run/eslint-config": "workspace:*", + "@remix-run/route-config": "workspace:*", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", "eslint": "^8.38.0", diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index 65c805288ff..cf6cc609dbf 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -32,13 +32,19 @@ export const viteConfig = { `; return text; }, - basic: async (args: { port: number; fsAllow?: string[] }) => { + basic: async (args: { + port: number; + fsAllow?: string[]; + routeConfig?: boolean; + }) => { return dedent` import { vitePlugin as remix } from "@remix-run/dev"; export default { ${await viteConfig.server(args)} - plugins: [remix()] + plugins: [remix(${ + args.routeConfig ? "{ future: { v3_routeConfig: true } }" : "" + })] } `; }, diff --git a/integration/vite-fs-routes-test.ts b/integration/vite-fs-routes-test.ts new file mode 100644 index 00000000000..09c6755681c --- /dev/null +++ b/integration/vite-fs-routes-test.ts @@ -0,0 +1,517 @@ +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({ + future: { v3_routeConfig: true }, + })], + }); + `, + "app/routes.ts": js` + import { type RouteConfig } from "@remix-run/route-config"; + import { flatRoutes } from "@remix-run/fs-routes"; + import { remixRoutesOptionAdapter } from "@remix-run/routes-option-adapter"; + + export const routes: RouteConfig = [ + ...await flatRoutes({ + rootDirectory: "fs-routes", + ignoredRouteFiles: ["**/ignored-route.*"], + }), + + // Ensure back compat layer works + ...await remixRoutesOptionAdapter(async (defineRoutes) => { + // Ensure async routes work + return defineRoutes((route) => { + route("/routes/option/adapter/route", "routes-option-adapter-route.tsx") + }); + }) + ]; + `, + "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/routes-option-adapter-route.tsx": js` + export default function () { + return

Routes Option Adapter Route

; + } + `, + + "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 (routes option adapter)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/routes/option/adapter/route"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Routes Option Adapter Route

+
`); + }); + + 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({ + future: { v3_routeConfig: true }, + })], + }); + `, + "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({ + future: { v3_routeConfig: true }, + })], + }); + `, + "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({ + future: { v3_routeConfig: true }, + })], + }); + `, + "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/integration/vite-route-config-test.ts b/integration/vite-route-config-test.ts new file mode 100644 index 00000000000..28c96e9f34b --- /dev/null +++ b/integration/vite-route-config-test.ts @@ -0,0 +1,372 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { expect, type BrowserContext, type Page } from "@playwright/test"; + +import { + type Files, + createProject, + viteBuild, + test, + viteConfig, + createEditor, +} from "./helpers/vite.js"; + +const js = String.raw; + +// This is a workaround for caching issues in WebKit +async function reloadPage({ + browserName, + page, + context, +}: { + browserName: string; + page: Page; + context: BrowserContext; +}): Promise { + if (browserName === "webkit") { + let newPage = await context.newPage(); + let url = page.url(); + await page.close(); + await newPage.goto(url, { waitUntil: "networkidle" }); + return newPage; + } + + await page.reload(); + return page; +} + +test.describe("route config", () => { + test("fails the build if route config is missing", async () => { + let cwd = await createProject({ + "vite.config.js": ` + import { vitePlugin as remix } from "@remix-run/dev"; + + export default { + plugins: [remix({ + future: { v3_routeConfig: true }, + })] + } + `, + }); + // Ensure file is missing in case it's ever added to test fixture + await fs.rm(path.join(cwd, "app/routes.ts"), { force: true }); + let buildResult = viteBuild({ cwd }); + expect(buildResult.status).toBe(1); + expect(buildResult.stderr.toString()).toContain( + 'Route config file not found at "app/routes.ts"' + ); + }); + + test("fails the build if routes option is used", async () => { + let cwd = await createProject({ + "vite.config.js": ` + import { vitePlugin as remix } from "@remix-run/dev"; + + export default { + plugins: [remix({ + future: { v3_routeConfig: true }, + routes: () => {}, + })] + } + `, + "app/routes.ts": `export const routes = [];`, + }); + let buildResult = viteBuild({ cwd }); + expect(buildResult.status).toBe(1); + expect(buildResult.stderr.toString()).toContain( + 'The "routes" config option is not supported when a "routes.ts" file is present. You should migrate these routes into "routes.ts".' + ); + }); + + test("fails the dev process if routes option is used", async ({ + viteDev, + }) => { + let files: Files = async ({ port }) => ({ + "vite.config.js": ` + import { vitePlugin as remix } from "@remix-run/dev"; + + export default { + ${await viteConfig.server({ port })} + plugins: [remix({ + future: { v3_routeConfig: true }, + routes: () => {}, + })] + } + `, + "app/routes.ts": `export const routes = [];`, + }); + let devError: Error | undefined; + try { + await viteDev(files); + } catch (error: any) { + devError = error; + } + expect(devError?.toString()).toContain( + 'The "routes" config option is not supported when a "routes.ts" file is present. You should migrate these routes into "routes.ts".' + ); + }); + + test("fails the build if route config is invalid", async () => { + let cwd = await createProject({ + "vite.config.js": ` + import { vitePlugin as remix } from "@remix-run/dev"; + + export default { + plugins: [remix({ + future: { v3_routeConfig: true }, + })] + } + `, + "app/routes.ts": `export default INVALID(`, + }); + let buildResult = viteBuild({ cwd }); + expect(buildResult.status).toBe(1); + expect(buildResult.stderr.toString()).toContain( + 'Route config in "routes.ts" is invalid.' + ); + }); + + test("fails the dev process if route config is initially invalid", async ({ + viteDev, + }) => { + let files: Files = async ({ port }) => ({ + "vite.config.js": await viteConfig.basic({ + routeConfig: true, + port, + }), + "app/routes.ts": `export default INVALID(`, + }); + let devError: Error | undefined; + try { + await viteDev(files); + } catch (error: any) { + devError = error; + } + expect(devError?.toString()).toContain( + 'Route config in "routes.ts" is invalid.' + ); + }); + + test("supports correcting an invalid route config", async ({ + browserName, + page, + context, + viteDev, + }) => { + let files: Files = async ({ port }) => ({ + "vite.config.js": await viteConfig.basic({ + routeConfig: true, + port, + }), + "app/routes.ts": js` + import { type RouteConfig, index } from "@remix-run/route-config"; + + export const routes: RouteConfig = [ + index("test-route-1.tsx"), + ]; + `, + "app/test-route-1.tsx": ` + export default function TestRoute1() { + return
Test route 1
+ } + `, + "app/test-route-2.tsx": ` + export default function TestRoute2() { + return
Test route 2
+ } + `, + }); + let { cwd, port } = await viteDev(files); + + await page.goto(`http://localhost:${port}/`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-test-route]")).toHaveText("Test route 1"); + + let edit = createEditor(cwd); + + // Make config invalid + await edit("app/routes.ts", (contents) => contents + "INVALID"); + + // Ensure dev server is still running with old config + HMR + await edit("app/test-route-1.tsx", (contents) => + contents.replace("Test route 1", "Test route 1 updated") + ); + await expect(page.locator("[data-test-route]")).toHaveText( + "Test route 1 updated" + ); + + // Fix config with new route + await edit("app/routes.ts", (contents) => + contents.replace("INVALID", "").replace("test-route-1", "test-route-2") + ); + + await expect(async () => { + // Reload to pick up new route for current path + page = await reloadPage({ browserName, page, context }); + await expect(page.locator("[data-test-route]")).toHaveText( + "Test route 2" + ); + }).toPass(); + }); + + test("supports correcting an invalid route config module graph", async ({ + page, + context, + browserName, + viteDev, + }) => { + let files: Files = async ({ port }) => ({ + "vite.config.js": await viteConfig.basic({ + routeConfig: true, + port, + }), + "app/routes.ts": js` + export { routes } from "./actual-routes"; + `, + "app/actual-routes.ts": js` + import { type RouteConfig, index } from "@remix-run/route-config"; + + export const routes: RouteConfig = [ + index("test-route-1.tsx"), + ]; + `, + "app/test-route-1.tsx": ` + export default function TestRoute1() { + return
Test route 1
+ } + `, + "app/test-route-2.tsx": ` + export default function TestRoute2() { + return
Test route 2
+ } + `, + }); + let { cwd, port } = await viteDev(files); + + await page.goto(`http://localhost:${port}/`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-test-route]")).toHaveText("Test route 1"); + + let edit = createEditor(cwd); + + // Make config invalid + await edit("app/actual-routes.ts", (contents) => contents + "INVALID"); + + // Ensure dev server is still running with old config + HMR + await edit("app/test-route-1.tsx", (contents) => + contents.replace("Test route 1", "Test route 1 updated") + ); + await expect(page.locator("[data-test-route]")).toHaveText( + "Test route 1 updated" + ); + + // Fix config with new route + await edit("app/actual-routes.ts", (contents) => + contents.replace("INVALID", "").replace("test-route-1", "test-route-2") + ); + + await expect(async () => { + // Reload to pick up new route for current path + page = await reloadPage({ browserName, page, context }); + await expect(page.locator("[data-test-route]")).toHaveText( + "Test route 2" + ); + }).toPass(); + }); + + test("supports correcting a missing route config", async ({ + browserName, + page, + context, + viteDev, + }) => { + let files: Files = async ({ port }) => ({ + "vite.config.js": await viteConfig.basic({ + routeConfig: true, + port, + }), + "app/routes.ts": js` + import { type RouteConfig, index } from "@remix-run/route-config"; + + export const routes: RouteConfig = [ + index("test-route-1.tsx"), + ]; + `, + "app/test-route-1.tsx": ` + export default function TestRoute1() { + return
Test route 1
+ } + `, + "app/test-route-2.tsx": ` + export default function TestRoute2() { + return
Test route 2
+ } + `, + }); + let { cwd, port } = await viteDev(files); + + await page.goto(`http://localhost:${port}/`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-test-route]")).toHaveText("Test route 1"); + + let edit = createEditor(cwd); + + let INVALID_FILENAME = "app/routes.ts.oops"; + + // Rename config to make it missing + await fs.rename( + path.join(cwd, "app/routes.ts"), + path.join(cwd, INVALID_FILENAME) + ); + + // Ensure dev server is still running with old config + HMR + await edit("app/test-route-1.tsx", (contents) => + contents.replace("Test route 1", "Test route 1 updated") + ); + await expect(page.locator("[data-test-route]")).toHaveText( + "Test route 1 updated" + ); + + // Add new route + await edit(INVALID_FILENAME, (contents) => + contents.replace("test-route-1", "test-route-2") + ); + + // Rename config to bring it back + await fs.rename( + path.join(cwd, INVALID_FILENAME), + path.join(cwd, "app/routes.ts") + ); + + await expect(async () => { + // Reload to pick up new route for current path + page = await reloadPage({ browserName, page, context }); + await expect(page.locator("[data-test-route]")).toHaveText( + "Test route 2" + ); + }).toPass(); + }); + + test("supports absolute route file paths", async ({ page, viteDev }) => { + let files: Files = async ({ port }) => ({ + "vite.config.js": await viteConfig.basic({ + routeConfig: true, + port, + }), + "app/routes.ts": js` + import path from "node:path"; + import { type RouteConfig, index } from "@remix-run/route-config"; + + export const routes: RouteConfig = [ + index(path.resolve(import.meta.dirname, "test-route.tsx")), + ]; + `, + "app/test-route.tsx": ` + export default function TestRoute() { + return
Test route
+ } + `, + }); + let { port } = await viteDev(files); + + await page.goto(`http://localhost:${port}/`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-test-route]")).toHaveText("Test route"); + }); +}); diff --git a/jest.config.js b/jest.config.js index 5ffd3c56699..f2d8db3a278 100644 --- a/jest.config.js +++ b/jest.config.js @@ -19,6 +19,7 @@ module.exports = { "packages/remix-express", "packages/remix-node", "packages/remix-react", + "packages/remix-route-config", "packages/remix-serve", "packages/remix-server-runtime", "packages/remix-testing", diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts index f528c16f15e..f24e37ecf5e 100644 --- a/packages/remix-dev/__tests__/readConfig-test.ts +++ b/packages/remix-dev/__tests__/readConfig-test.ts @@ -40,6 +40,7 @@ describe("readConfig", () => { "v3_fetcherPersist": false, "v3_lazyRouteDiscovery": false, "v3_relativeSplatPath": false, + "v3_routeConfig": false, "v3_singleFetch": false, "v3_throwAbortReason": false, }, diff --git a/packages/remix-dev/__tests__/routeManifestToRouteConfig-test.ts b/packages/remix-dev/__tests__/routeManifestToRouteConfig-test.ts new file mode 100644 index 00000000000..a236c022ada --- /dev/null +++ b/packages/remix-dev/__tests__/routeManifestToRouteConfig-test.ts @@ -0,0 +1,111 @@ +import { type RouteConfig, routeManifestToRouteConfig } from "../config/routes"; + +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: RouteConfig = [ + { + path: "/", + file: "routes/home.js", + }, + { + path: "inbox", + file: "routes/inbox.js", + children: [ + { + path: "/", + file: "routes/inbox/index.js", + index: true, + }, + { + path: ":messageId", + file: "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-dev/__tests__/validateRouteConfig-test.ts b/packages/remix-dev/__tests__/validateRouteConfig-test.ts new file mode 100644 index 00000000000..2bcf1440e6a --- /dev/null +++ b/packages/remix-dev/__tests__/validateRouteConfig-test.ts @@ -0,0 +1,141 @@ +import { validateRouteConfig } from "../config/routes"; + +describe("validateRouteConfig", () => { + it("validates a route config", () => { + expect( + validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + { + path: "parent", + file: "parent.tsx", + children: [ + { + path: "child", + file: "child.tsx", + }, + ], + }, + ], + }).valid + ).toBe(true); + }); + + it("is invalid when not an array", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: { path: "path", file: "file.tsx" }, + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot( + `"Route config in "routes.ts" must be an array."` + ); + }); + + it("is invalid when route is a promise", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + { + path: "parent", + file: "parent.tsx", + children: [Promise.resolve({})], + }, + ], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0 + Invalid type: Expected object but received a promise. Did you forget to await?" + `); + }); + + it("is invalid when file is missing", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + { + path: "parent", + file: "parent.tsx", + children: [ + { + id: "child", + }, + ], + }, + ], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0.file + Invalid type: Expected string but received undefined" + `); + }); + + it("is invalid when property is wrong type", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + { + path: "parent", + file: "parent.tsx", + children: [ + { + file: 123, + }, + ], + }, + ], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0.file + Invalid type: Expected string but received 123" + `); + }); + + it("shows multiple error messages", () => { + let result = validateRouteConfig({ + routeConfigFile: "routes.ts", + routeConfig: [ + { + path: "parent", + file: "parent.tsx", + children: [ + { + id: "child", + }, + { + file: 123, + }, + Promise.resolve(), + ], + }, + ], + }); + + expect(result.valid).toBe(false); + expect(!result.valid && result.message).toMatchInlineSnapshot(` + "Route config in "routes.ts" is invalid. + + Path: routes.0.children.0.file + Invalid type: Expected string but received undefined + + Path: routes.0.children.1.file + Invalid type: Expected string but received 123 + + Path: routes.0.children.2 + Invalid type: Expected object but received a promise. Did you forget to await?" + `); + }); +}); diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index 79171f318c5..2c0d9a65d57 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -1,17 +1,28 @@ +import type * as Vite from "vite"; import { execSync } from "node:child_process"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import colors from "picocolors"; import fse from "fs-extra"; import PackageJson from "@npmcli/package-json"; import type { NodePolyfillsOptions as EsbuildPluginsNodeModulesPolyfillOptions } from "esbuild-plugins-node-modules-polyfill"; -import type { RouteManifest, DefineRoutesFunction } from "./config/routes"; -import { defineRoutes } from "./config/routes"; +import type * as ViteNode from "./vite/vite-node"; +import { + type RouteManifest, + type RouteConfig, + type DefineRoutesFunction, + setRouteConfigAppDirectory, + validateRouteConfig, + configRoutesToRouteManifest, + defineRoutes, +} from "./config/routes"; import { ServerMode, isValidServerMode } from "./config/serverModes"; import { serverBuildVirtualModule } from "./compiler/server/virtualModules"; import { flatRoutes } from "./config/flat-routes"; import { detectPackageManager } from "./cli/detectPackageManager"; import { logger } from "./tux"; +import invariant from "./invariant"; export interface RemixMdxConfig { rehypePlugins?: any[]; @@ -39,6 +50,7 @@ interface FutureConfig { v3_throwAbortReason: boolean; v3_singleFetch: boolean; v3_lazyRouteDiscovery: boolean; + v3_routeConfig: boolean; unstable_optimizeDeps: boolean; } @@ -409,16 +421,27 @@ export async function readConfig( }); } +let isFirstLoad = true; +let lastValidRoutes: RouteManifest = {}; + export async function resolveConfig( appConfig: AppConfig, { rootDirectory, serverMode = ServerMode.Production, isSpaMode = false, + routeConfigChanged = false, + vite, + viteUserConfig, + routesViteNodeContext, }: { rootDirectory: string; serverMode?: ServerMode; isSpaMode?: boolean; + routeConfigChanged?: boolean; + vite?: typeof Vite; + viteUserConfig?: Vite.UserConfig; + routesViteNodeContext?: ViteNode.Context; } ): Promise { if (!isValidServerMode(serverMode)) { @@ -556,10 +579,103 @@ export async function resolveConfig( root: { path: "", id: "root", file: rootRouteFile }, }; - if (fse.existsSync(path.resolve(appDirectory, "routes"))) { - let fileRoutes = flatRoutes(appDirectory, appConfig.ignoredRouteFiles); - for (let route of Object.values(fileRoutes)) { - routes[route.id] = { ...route, parentId: route.parentId || "root" }; + if (appConfig.future?.v3_routeConfig) { + invariant(routesViteNodeContext); + invariant(vite); + + let routeConfigFile = findEntry(appDirectory, "routes"); + + class FriendlyError extends Error {} + + let logger = vite.createLogger(viteUserConfig?.logLevel, { + prefix: "[remix]", + }); + + try { + if (appConfig.routes) { + throw new FriendlyError( + 'The "routes" config option is not supported when a "routes.ts" file is present. You should migrate these routes into "routes.ts".' + ); + } + + if (!routeConfigFile) { + let routeConfigDisplayPath = vite.normalizePath( + path.relative(rootDirectory, path.join(appDirectory, "routes.ts")) + ); + throw new FriendlyError( + `Route config file not found at "${routeConfigDisplayPath}".` + ); + } + + setRouteConfigAppDirectory(appDirectory); + let routeConfigExport: RouteConfig = ( + await routesViteNodeContext.runner.executeFile( + path.join(appDirectory, routeConfigFile) + ) + ).routes; + + let routeConfig = await routeConfigExport; + + let result = validateRouteConfig({ + routeConfigFile, + routeConfig, + }); + + if (!result.valid) { + throw new FriendlyError(result.message); + } + + routes = { ...routes, ...configRoutesToRouteManifest(routeConfig) }; + + lastValidRoutes = routes; + + if (routeConfigChanged) { + logger.info(colors.green("Route config changed."), { + clear: true, + timestamp: true, + }); + } + } catch (error: any) { + logger.error( + error instanceof FriendlyError + ? colors.red(error.message) + : [ + colors.red(`Route config in "${routeConfigFile}" is invalid.`), + "", + error.loc?.file && error.loc?.column && error.frame + ? [ + path.relative(appDirectory, error.loc.file) + + ":" + + error.loc.line + + ":" + + error.loc.column, + error.frame.trim?.(), + ] + : error.stack, + ] + .flat() + .join("\n") + "\n", + { + error, + clear: !isFirstLoad, + timestamp: !isFirstLoad, + } + ); + + // Bail if this is the first time loading config, otherwise keep the dev server running + if (isFirstLoad) { + process.exit(1); + } + + // Keep dev server running with the last valid routes to allow for correction + routes = lastValidRoutes; + } + } else { + if (fse.existsSync(path.resolve(appDirectory, "routes"))) { + let fileRoutes = flatRoutes(appDirectory, appConfig.ignoredRouteFiles); + for (let route of Object.values(fileRoutes)) { + routes[route.id] = { ...route, parentId: route.parentId || "root" }; + } } } if (appConfig.routes) { @@ -605,6 +721,7 @@ export async function resolveConfig( v3_throwAbortReason: appConfig.future?.v3_throwAbortReason === true, v3_singleFetch: appConfig.future?.v3_singleFetch === true, v3_lazyRouteDiscovery: appConfig.future?.v3_lazyRouteDiscovery === true, + v3_routeConfig: appConfig.future?.v3_routeConfig === true, unstable_optimizeDeps: appConfig.future?.unstable_optimizeDeps === true, }; @@ -648,6 +765,8 @@ export async function resolveConfig( logFutureFlagWarnings(future); + isFirstLoad = false; + return { appDirectory, cacheDirectory, diff --git a/packages/remix-dev/config/flat-routes.ts b/packages/remix-dev/config/flat-routes.ts index 27518daada9..0aed11cd8f6 100644 --- a/packages/remix-dev/config/flat-routes.ts +++ b/packages/remix-dev/config/flat-routes.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { makeRe } from "minimatch"; -import type { ConfigRoute, RouteManifest } from "./routes"; +import type { RouteManifestEntry, RouteManifest } from "./routes"; import { normalizeSlashes } from "./routes"; import { findConfig } from "../config"; @@ -130,10 +130,10 @@ export function flatRoutesUniversal( routes: string[], prefix: string = "routes" ): RouteManifest { - let urlConflicts = new Map(); + let urlConflicts = new Map(); let routeManifest: RouteManifest = {}; let prefixLookup = new PrefixLookupTrie(); - let uniqueRoutes = new Map(); + let uniqueRoutes = new Map(); let routeIdConflicts = new Map(); // id -> file @@ -193,7 +193,7 @@ export function flatRoutesUniversal( } // path creation - let parentChildrenMap = new Map(); + let parentChildrenMap = new Map(); for (let [routeId] of sortedRouteIds) { let config = routeManifest[routeId]; if (!config.parentId) continue; diff --git a/packages/remix-dev/config/routes.ts b/packages/remix-dev/config/routes.ts index c793e3bade6..cbf90b5f9d3 100644 --- a/packages/remix-dev/config/routes.ts +++ b/packages/remix-dev/config/routes.ts @@ -1,10 +1,24 @@ import * as path from "node:path"; +import * as v from "valibot"; + +import invariant from "../invariant"; + +let routeConfigAppDirectory: string; + +export function setRouteConfigAppDirectory(directory: string) { + routeConfigAppDirectory = directory; +} /** - * A route that was created using `defineRoutes` or created conventionally from - * looking at the files on the filesystem. + * Provides the absolute path to the app directory, for use within `routes.ts`. + * This is designed to support resolving file system routes. */ -export interface ConfigRoute { +export function getRouteConfigAppDirectory() { + invariant(routeConfigAppDirectory); + return routeConfigAppDirectory; +} + +export interface RouteManifestEntry { /** * The path this route uses to match on the URL pathname. */ @@ -40,7 +54,197 @@ export interface ConfigRoute { } export interface RouteManifest { - [routeId: string]: ConfigRoute; + [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; +} + +/** + * Configuration for an individual route, for use within `routes.ts`. As a + * convenience, route config entries can be created with the {@link route}, + * {@link index} and {@link layout} helper functions. + */ +export interface RouteConfigEntry { + /** + * The unique id for this route. + */ + id?: string; + + /** + * The path this route uses to match on the URL pathname. + */ + path?: string; + + /** + * Should be `true` if it is an index route. This disallows child routes. + */ + index?: boolean; + + /** + * Should be `true` if the `path` is case-sensitive. Defaults to `false`. + */ + caseSensitive?: boolean; + + /** + * The path to the entry point for this route, relative to + * `config.appDirectory`. + */ + file: string; + + /** + * The child routes. + */ + children?: RouteConfigEntry[]; +} + +export const routeConfigEntrySchema: v.BaseSchema< + RouteConfigEntry, + any, + v.BaseIssue +> = v.pipe( + v.custom((value) => { + return !( + typeof value === "object" && + value !== null && + "then" in value && + "catch" in value + ); + }, "Invalid type: Expected object but received a promise. Did you forget to await?"), + v.object({ + id: v.optional(v.string()), + path: v.optional(v.string()), + index: v.optional(v.boolean()), + caseSensitive: v.optional(v.boolean()), + file: v.string(), + children: v.optional(v.array(v.lazy(() => routeConfigEntrySchema))), + }) +); + +export const resolvedRouteConfigSchema = v.array(routeConfigEntrySchema); +type ResolvedRouteConfig = v.InferInput; + +/** + * Route config to be exported via the `routes` export within `routes.ts`. + */ +export type RouteConfig = ResolvedRouteConfig | Promise; + +export function validateRouteConfig({ + routeConfigFile, + routeConfig, +}: { + routeConfigFile: string; + routeConfig: unknown; +}): { valid: false; message: string } | { valid: true } { + if (!routeConfig) { + return { + valid: false, + message: `No "routes" export defined in "${routeConfigFile}.`, + }; + } + + if (!Array.isArray(routeConfig)) { + return { + valid: false, + message: `Route config in "${routeConfigFile}" must be an array.`, + }; + } + + let { issues } = v.safeParse(resolvedRouteConfigSchema, routeConfig); + + if (issues?.length) { + let { root, nested } = v.flatten(issues); + return { + valid: false, + message: [ + `Route config in "${routeConfigFile}" is invalid.`, + root ? `${root}` : [], + nested + ? Object.entries(nested).map( + ([path, message]) => `Path: routes.${path}\n${message}` + ) + : [], + ] + .flat() + .join("\n\n"), + }; + } + + return { valid: true }; +} + +export function configRoutesToRouteManifest( + routes: RouteConfigEntry[], + rootId = "root" +): RouteManifest { + let routeManifest: RouteManifest = {}; + + function walk(route: RouteConfigEntry, parentId: string) { + let id = route.id || createRouteId(route.file); + let manifestItem: RouteManifestEntry = { + id, + parentId, + file: route.file, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + }; + + if (routeManifest.hasOwnProperty(id)) { + throw new Error( + `Unable to define routes with duplicate route id: "${id}"` + ); + } + routeManifest[id] = manifestItem; + + if (route.children) { + for (let child of route.children) { + walk(child, id); + } + } + } + + for (let route of routes) { + walk(route, rootId); + } + + return routeManifest; } export interface DefineRouteOptions { @@ -115,7 +319,7 @@ export function defineRoutes( callback: (defineRoute: DefineRouteFunction) => void ): RouteManifest { let routes: RouteManifest = Object.create(null); - let parentRoutes: ConfigRoute[] = []; + let parentRoutes: RouteManifestEntry[] = []; let alreadyReturned = false; let defineRoute: DefineRouteFunction = ( @@ -143,7 +347,7 @@ export function defineRoutes( options = optionsOrChildren || {}; } - let route: ConfigRoute = { + let route: RouteManifestEntry = { path: path ? path : undefined, index: options.index ? true : undefined, caseSensitive: options.caseSensitive ? true : undefined, diff --git a/packages/remix-dev/index.ts b/packages/remix-dev/index.ts index 1c28706e7c2..61590b494c6 100644 --- a/packages/remix-dev/index.ts +++ b/packages/remix-dev/index.ts @@ -5,6 +5,19 @@ export type { AppConfig, RemixConfig as ResolvedRemixConfig } from "./config"; export * as cli from "./cli/index"; export type { Manifest as AssetsManifest } from "./manifest"; +export type { + DefineRoutesFunction as UNSAFE_DefineRoutesFunction, + RouteManifest as UNSAFE_RouteManifest, + RouteManifestEntry as UNSAFE_RouteManifestEntry, + RouteConfig as UNSAFE_RouteConfig, + RouteConfigEntry as UNSAFE_RouteConfigEntry, +} from "./config/routes"; +export { + defineRoutes as UNSAFE_defineRoutes, + routeManifestToRouteConfig as UNSAFE_routeManifestToRouteConfig, + getRouteConfigAppDirectory as UNSAFE_getRouteConfigAppDirectory, +} from "./config/routes"; +export { flatRoutes as UNSAFE_flatRoutes } from "./config/flat-routes"; export { getDependenciesToBundle } from "./dependencies"; export type { BuildManifest, diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index 6bba3c50b3a..c285e9141c2 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -73,6 +73,8 @@ "set-cookie-parser": "^2.6.0", "tar-fs": "^2.1.1", "tsconfig-paths": "^4.0.0", + "valibot": "^0.41.0", + "vite-node": "^1.6.0", "ws": "^7.5.10" }, "devDependencies": { diff --git a/packages/remix-dev/vite/build.ts b/packages/remix-dev/vite/build.ts index 682f3cfefac..e42ad634d81 100644 --- a/packages/remix-dev/vite/build.ts +++ b/packages/remix-dev/vite/build.ts @@ -13,11 +13,11 @@ import { configRouteToBranchRoute, getServerBuildDirectory, } from "./plugin"; -import type { ConfigRoute, RouteManifest } from "../config/routes"; +import type { RouteManifestEntry, RouteManifest } from "../config/routes"; import invariant from "../invariant"; import { preloadViteEsm } from "./import-vite-esm-sync"; -function getAddressableRoutes(routes: RouteManifest): ConfigRoute[] { +function getAddressableRoutes(routes: RouteManifest): RouteManifestEntry[] { let nonAddressableIds = new Set(); for (let id in routes) { @@ -44,11 +44,11 @@ function getAddressableRoutes(routes: RouteManifest): ConfigRoute[] { } function getRouteBranch(routes: RouteManifest, routeId: string) { - let branch: ConfigRoute[] = []; + let branch: RouteManifestEntry[] = []; let currentRouteId: string | undefined = routeId; while (currentRouteId) { - let route: ConfigRoute = routes[currentRouteId]; + let route: RouteManifestEntry = routes[currentRouteId]; invariant(route, `Missing route for ${currentRouteId}`); branch.push(route); currentRouteId = route.parentId; diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index 53ce6400b57..e18bf788568 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -20,11 +20,11 @@ import pick from "lodash/pick"; import omit from "lodash/omit"; import colors from "picocolors"; -import { type ConfigRoute, type RouteManifest } from "../config/routes"; +import { type RouteManifestEntry, type RouteManifest } from "../config/routes"; import { type AppConfig as RemixEsbuildUserConfig, type RemixConfig as ResolvedRemixEsbuildConfig, - resolveConfig as resolveRemixEsbuildConfig, + resolveConfig as resolveCommonConfig, findConfig, } from "../config"; import { type Manifest as RemixManifest } from "../manifest"; @@ -40,6 +40,7 @@ import { resolveFileUrl } from "./resolve-file-url"; import { combineURLs } from "./combine-urls"; import { removeExports } from "./remove-exports"; import { importViteEsmSync, preloadViteEsm } from "./import-vite-esm-sync"; +import * as ViteNode from "./vite-node"; export async function resolveViteConfig({ configFile, @@ -143,11 +144,14 @@ const branchRouteProperties = [ "path", "file", "index", -] as const satisfies ReadonlyArray; -type BranchRoute = Pick; +] as const satisfies ReadonlyArray; +type BranchRoute = Pick< + RouteManifestEntry, + typeof branchRouteProperties[number] +>; export const configRouteToBranchRoute = ( - configRoute: ConfigRoute + configRoute: RouteManifestEntry ): BranchRoute => pick(configRoute, branchRouteProperties); export type ServerBundlesFunction = (args: { @@ -293,7 +297,7 @@ let hmrRuntimeId = VirtualModule.id("hmr-runtime"); let injectHmrRuntimeId = VirtualModule.id("inject-hmr-runtime"); const resolveRelativeRouteFilePath = ( - route: ConfigRoute, + route: RouteManifestEntry, remixConfig: ResolvedVitePluginConfig ) => { let vite = importViteEsmSync(); @@ -611,6 +615,28 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { let viteConfig: Vite.ResolvedConfig | undefined; let cssModulesManifest: Record = {}; let viteChildCompiler: Vite.ViteDevServer | null = null; + let routesViteNodeContext: ViteNode.Context | null = null; + + let ssrExternals = isInRemixMonorepo() + ? [ + // This is only needed within the Remix repo because these + // packages are linked to a directory outside of node_modules + // so Vite treats them as internal code by default. + "@remix-run/architect", + "@remix-run/cloudflare-pages", + "@remix-run/cloudflare-workers", + "@remix-run/cloudflare", + "@remix-run/css-bundle", + "@remix-run/deno", + "@remix-run/dev", + "@remix-run/express", + "@remix-run/netlify", + "@remix-run/node", + "@remix-run/react", + "@remix-run/serve", + "@remix-run/server-runtime", + ] + : undefined; // This is initialized by `updateRemixPluginContext` during Vite's `config` // hook, so most of the code can assume this defined without null check. @@ -619,7 +645,11 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { let ctx: RemixPluginContext; /** Mutates `ctx` as a side-effect */ - let updateRemixPluginContext = async (): Promise => { + let updateRemixPluginContext = async ({ + routeConfigChanged = false, + }: { + routeConfigChanged?: boolean; + } = {}): Promise => { let remixConfigPresets: VitePluginConfig[] = ( await Promise.all( (remixUserConfig.presets ?? []).map(async (preset) => { @@ -665,6 +695,7 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { let isSpaMode = !ssr; // Only select the Remix esbuild config options that the Vite plugin uses + invariant(routesViteNodeContext); let { appDirectory, entryClientFilePath, @@ -672,9 +703,16 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { future, routes, serverModuleFormat, - } = await resolveRemixEsbuildConfig( + } = await resolveCommonConfig( pick(resolvedRemixUserConfig, supportedRemixEsbuildConfigKeys), - { rootDirectory, isSpaMode } + { + rootDirectory, + isSpaMode, + vite: importViteEsmSync(), + routeConfigChanged, + viteUserConfig, + routesViteNodeContext, + } ); let buildDirectory = path.resolve( @@ -1008,6 +1046,17 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { viteConfigEnv = _viteConfigEnv; viteCommand = viteConfigEnv.command; + routesViteNodeContext = await ViteNode.createContext({ + root: viteUserConfig.root, + mode: viteConfigEnv.mode, + server: { + watch: viteCommand === "build" ? null : undefined, + }, + ssr: { + external: ssrExternals, + }, + }); + await updateRemixPluginContext(); Object.assign( @@ -1053,26 +1102,7 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { : "custom", ssr: { - external: isInRemixMonorepo() - ? [ - // This is only needed within the Remix repo because these - // packages are linked to a directory outside of node_modules - // so Vite treats them as internal code by default. - "@remix-run/architect", - "@remix-run/cloudflare-pages", - "@remix-run/cloudflare-workers", - "@remix-run/cloudflare", - "@remix-run/css-bundle", - "@remix-run/deno", - "@remix-run/dev", - "@remix-run/express", - "@remix-run/netlify", - "@remix-run/node", - "@remix-run/react", - "@remix-run/serve", - "@remix-run/server-runtime", - ] - : undefined, + external: ssrExternals, }, optimizeDeps: { entries: ctx.remixConfig.future.unstable_optimizeDeps @@ -1342,24 +1372,38 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { }); // Invalidate virtual modules and update cached plugin config via file watcher - viteDevServer.watcher.on("all", async (eventName, filepath) => { + viteDevServer.watcher.on("all", async (eventName, rawFilepath) => { let { normalizePath } = importViteEsmSync(); + let filepath = normalizePath(rawFilepath); let appFileAddedOrRemoved = (eventName === "add" || eventName === "unlink") && - normalizePath(filepath).startsWith( - normalizePath(ctx.remixConfig.appDirectory) - ); + filepath.startsWith(normalizePath(ctx.remixConfig.appDirectory)); invariant(viteConfig?.configFile); let viteConfigChanged = eventName === "change" && - normalizePath(filepath) === normalizePath(viteConfig.configFile); + filepath === normalizePath(viteConfig.configFile); + + let routeConfigChanged = Boolean( + routesViteNodeContext?.devServer?.moduleGraph.getModuleById( + filepath + ) + ); + + if (routeConfigChanged || appFileAddedOrRemoved) { + routesViteNodeContext?.devServer?.moduleGraph.invalidateAll(); + routesViteNodeContext?.runner?.moduleCache.clear(); + } - if (appFileAddedOrRemoved || viteConfigChanged) { + if ( + appFileAddedOrRemoved || + viteConfigChanged || + routeConfigChanged + ) { let lastRemixConfig = ctx.remixConfig; - await updateRemixPluginContext(); + await updateRemixPluginContext({ routeConfigChanged }); if (!isEqualJson(lastRemixConfig, ctx.remixConfig)) { invalidateVirtualModules(viteDevServer); @@ -1849,7 +1893,7 @@ if (import.meta.hot && !inWebWorker) { function getRoute( pluginConfig: ResolvedVitePluginConfig, file: string -): ConfigRoute | undefined { +): RouteManifestEntry | undefined { let vite = importViteEsmSync(); let routePath = vite.normalizePath( path.relative(pluginConfig.appDirectory, file) @@ -1863,7 +1907,7 @@ function getRoute( async function getRouteMetadata( ctx: RemixPluginContext, viteChildCompiler: Vite.ViteDevServer | null, - route: ConfigRoute, + route: RouteManifestEntry, readRouteFile?: () => string | Promise ) { let sourceExports = await getRouteModuleExports( diff --git a/packages/remix-dev/vite/vite-node.ts b/packages/remix-dev/vite/vite-node.ts new file mode 100644 index 00000000000..a7c25c26633 --- /dev/null +++ b/packages/remix-dev/vite/vite-node.ts @@ -0,0 +1,57 @@ +import { ViteNodeServer } from "vite-node/server"; +import { ViteNodeRunner } from "vite-node/client"; +import { installSourcemapsSupport } from "vite-node/source-map"; +import type * as Vite from "vite"; + +import { importViteEsmSync, preloadViteEsm } from "./import-vite-esm-sync"; + +export type Context = { + devServer: Vite.ViteDevServer; + server: ViteNodeServer; + runner: ViteNodeRunner; +}; + +export async function createContext( + viteConfig: Vite.InlineConfig = {} +): Promise { + await preloadViteEsm(); + let vite = importViteEsmSync(); + + let devServer = await vite.createServer( + vite.mergeConfig( + { + server: { + preTransformRequests: false, + hmr: false, + }, + optimizeDeps: { + noDiscovery: true, + }, + configFile: false, + envFile: false, + plugins: [], + }, + viteConfig + ) + ); + await devServer.pluginContainer.buildStart({}); + + let server = new ViteNodeServer(devServer); + + installSourcemapsSupport({ + getSourceMap: (source) => server.getSourceMap(source), + }); + + let runner = new ViteNodeRunner({ + root: devServer.config.root, + base: devServer.config.base, + fetchModule(id) { + return server.fetchModule(id); + }, + resolveId(id, importer) { + return server.resolveId(id, importer); + }, + }); + + return { devServer, server, runner }; +} 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/index.ts b/packages/remix-fs-routes/index.ts new file mode 100644 index 00000000000..1ad52e53d2d --- /dev/null +++ b/packages/remix-fs-routes/index.ts @@ -0,0 +1,48 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + UNSAFE_flatRoutes as flatRoutesImpl, + UNSAFE_routeManifestToRouteConfig as routeManifestToRouteConfig, +} from "@remix-run/dev"; +import { + type RouteConfigEntry, + getAppDirectory, +} from "@remix-run/route-config"; + +/** + * 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); +} + +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..f26bbfd7e00 --- /dev/null +++ b/packages/remix-fs-routes/package.json @@ -0,0 +1,43 @@ +{ + "name": "@remix-run/fs-routes", + "version": "2.13.1", + "description": "Config-based file system routing conventions, for use within routes.ts", + "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", + "scripts": { + "tsc": "tsc" + }, + "devDependencies": { + "@remix-run/dev": "workspace:*", + "@remix-run/route-config": "workspace:*", + "typescript": "^5.1.6" + }, + "peerDependencies": { + "@remix-run/dev": "workspace:*", + "@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/packages/remix-route-config/README.md b/packages/remix-route-config/README.md new file mode 100644 index 00000000000..40685a7476f --- /dev/null +++ b/packages/remix-route-config/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-route-config/__tests__/route-config-test.ts b/packages/remix-route-config/__tests__/route-config-test.ts new file mode 100644 index 00000000000..9fea261db11 --- /dev/null +++ b/packages/remix-route-config/__tests__/route-config-test.ts @@ -0,0 +1,440 @@ +import path from "node:path"; +import { normalizePath } from "vite"; + +import { route, layout, index, prefix, relative } from "../routes"; + +function cleanPathsForSnapshot(obj: any): any { + return JSON.parse( + JSON.stringify(obj, (key, value) => { + if (typeof value === "string" && path.isAbsolute(value)) { + return normalizePath(value.replace(process.cwd(), "{{CWD}}")); + } + return value; + }) + ); +} + +describe("route config", () => { + describe("route helpers", () => { + describe("route", () => { + it("supports basic routes", () => { + expect(route("path", "file.tsx")).toMatchInlineSnapshot(` + { + "children": undefined, + "file": "file.tsx", + "path": "path", + } + `); + }); + + it("supports children", () => { + expect(route("parent", "parent.tsx", [route("child", "child.tsx")])) + .toMatchInlineSnapshot(` + { + "children": [ + { + "children": undefined, + "file": "child.tsx", + "path": "child", + }, + ], + "file": "parent.tsx", + "path": "parent", + } + `); + }); + + it("supports custom IDs", () => { + expect(route("path", "file.tsx", { id: "custom-id" })) + .toMatchInlineSnapshot(` + { + "children": undefined, + "file": "file.tsx", + "id": "custom-id", + "path": "path", + } + `); + }); + + it("supports custom IDs with children", () => { + expect( + route("parent", "parent.tsx", { id: "custom-id" }, [ + route("child", "child.tsx"), + ]) + ).toMatchInlineSnapshot(` + { + "children": [ + { + "children": undefined, + "file": "child.tsx", + "path": "child", + }, + ], + "file": "parent.tsx", + "id": "custom-id", + "path": "parent", + } + `); + }); + + it("supports case sensitive routes", () => { + expect(route("path", "file.tsx", { caseSensitive: true })) + .toMatchInlineSnapshot(` + { + "caseSensitive": true, + "children": undefined, + "file": "file.tsx", + "path": "path", + } + `); + }); + + it("supports pathless index", () => { + expect(route(null, "file.tsx", { index: true })).toMatchInlineSnapshot(` + { + "children": undefined, + "file": "file.tsx", + "index": true, + "path": undefined, + } + `); + }); + + it("ignores unsupported options", () => { + expect( + // @ts-expect-error unsupportedOption + route(null, "file.tsx", { + index: true, + unsupportedOption: 123, + }) + ).toMatchInlineSnapshot(` + { + "children": undefined, + "file": "file.tsx", + "index": true, + "path": undefined, + } + `); + }); + }); + + describe("index", () => { + it("supports basic routes", () => { + expect(index("file.tsx")).toMatchInlineSnapshot(` + { + "file": "file.tsx", + "index": true, + } + `); + }); + + it("supports custom IDs", () => { + expect(index("file.tsx", { id: "custom-id" })).toMatchInlineSnapshot(` + { + "file": "file.tsx", + "id": "custom-id", + "index": true, + } + `); + }); + + it("ignores unsupported options", () => { + expect( + index("file.tsx", { + id: "custom-id", + // @ts-expect-error + unsupportedOption: 123, + }) + ).toMatchInlineSnapshot(` + { + "file": "file.tsx", + "id": "custom-id", + "index": true, + } + `); + }); + }); + + describe("layout", () => { + it("supports basic routes", () => { + expect(layout("layout.tsx")).toMatchInlineSnapshot(` + { + "children": undefined, + "file": "layout.tsx", + } + `); + }); + + it("supports children", () => { + expect(layout("layout.tsx", [route("child", "child.tsx")])) + .toMatchInlineSnapshot(` + { + "children": [ + { + "children": undefined, + "file": "child.tsx", + "path": "child", + }, + ], + "file": "layout.tsx", + } + `); + }); + + it("supports custom IDs", () => { + expect(layout("layout.tsx", { id: "custom-id" })) + .toMatchInlineSnapshot(` + { + "children": undefined, + "file": "layout.tsx", + "id": "custom-id", + } + `); + }); + + it("supports custom IDs with children", () => { + expect( + layout("layout.tsx", { id: "custom-id" }, [ + route("child", "child.tsx"), + ]) + ).toMatchInlineSnapshot(` + { + "children": [ + { + "children": undefined, + "file": "child.tsx", + "path": "child", + }, + ], + "file": "layout.tsx", + "id": "custom-id", + } + `); + }); + }); + + describe("prefix", () => { + it("adds a prefix to routes", () => { + expect(prefix("prefix", [route("route", "routes/route.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix/route", + }, + ] + `); + }); + + it("adds a prefix to routes with a blank path", () => { + expect(prefix("prefix", [route("", "routes/route.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix", + }, + ] + `); + }); + + it("adds a prefix with a trailing slash to routes", () => { + expect(prefix("prefix/", [route("route", "routes/route.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix/route", + }, + ] + `); + }); + + it("adds a prefix to routes with leading slash", () => { + expect(prefix("prefix", [route("/route", "routes/route.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix/route", + }, + ] + `); + }); + + it("adds a prefix with a trailing slash to routes with leading slash", () => { + expect(prefix("prefix/", [route("/route", "routes/route.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix/route", + }, + ] + `); + }); + + it("adds a prefix to index routes", () => { + expect(prefix("prefix", [index("routes/index.tsx")])) + .toMatchInlineSnapshot(` + [ + { + "children": undefined, + "file": "routes/index.tsx", + "index": true, + "path": "prefix", + }, + ] + `); + }); + + it("adds a prefix to children of layout routes", () => { + expect( + prefix("prefix", [ + layout("routes/layout.tsx", [route("route", "routes/route.tsx")]), + ]) + ).toMatchInlineSnapshot(` + [ + { + "children": [ + { + "children": undefined, + "file": "routes/route.tsx", + "path": "prefix/route", + }, + ], + "file": "routes/layout.tsx", + }, + ] + `); + }); + + it("adds a prefix to children of nested layout routes", () => { + expect( + prefix("prefix", [ + layout("routes/layout-1.tsx", [ + route("layout-1-child", "routes/layout-1-child.tsx"), + layout("routes/layout-2.tsx", [ + route("layout-2-child", "routes/layout-2-child.tsx"), + layout("routes/layout-3.tsx", [ + route("layout-3-child", "routes/layout-3-child.tsx"), + ]), + ]), + ]), + ]) + ).toMatchInlineSnapshot(` + [ + { + "children": [ + { + "children": undefined, + "file": "routes/layout-1-child.tsx", + "path": "prefix/layout-1-child", + }, + { + "children": [ + { + "children": undefined, + "file": "routes/layout-2-child.tsx", + "path": "prefix/layout-2-child", + }, + { + "children": [ + { + "children": undefined, + "file": "routes/layout-3-child.tsx", + "path": "prefix/layout-3-child", + }, + ], + "file": "routes/layout-3.tsx", + }, + ], + "file": "routes/layout-2.tsx", + }, + ], + "file": "routes/layout-1.tsx", + }, + ] + `); + }); + }); + + describe("relative", () => { + it("supports relative routes", () => { + let { route } = relative(path.join(process.cwd(), "/path/to/dirname")); + expect( + cleanPathsForSnapshot( + route("parent", "nested/parent.tsx", [ + route("child", "nested/child.tsx", { id: "child" }), + ]) + ) + ).toMatchInlineSnapshot(` + { + "children": [ + { + "file": "{{CWD}}/path/to/dirname/nested/child.tsx", + "id": "child", + "path": "child", + }, + ], + "file": "{{CWD}}/path/to/dirname/nested/parent.tsx", + "path": "parent", + } + `); + }); + + it("supports relative index routes", () => { + let { index } = relative(path.join(process.cwd(), "/path/to/dirname")); + expect( + cleanPathsForSnapshot([ + index("nested/without-options.tsx"), + index("nested/with-options.tsx", { id: "with-options" }), + ]) + ).toMatchInlineSnapshot(` + [ + { + "file": "{{CWD}}/path/to/dirname/nested/without-options.tsx", + "index": true, + }, + { + "file": "{{CWD}}/path/to/dirname/nested/with-options.tsx", + "id": "with-options", + "index": true, + }, + ] + `); + }); + + it("supports relative layout routes", () => { + let { layout } = relative(path.join(process.cwd(), "/path/to/dirname")); + expect( + cleanPathsForSnapshot( + layout("nested/parent.tsx", [ + layout("nested/child.tsx", { id: "child" }), + ]) + ) + ).toMatchInlineSnapshot(` + { + "children": [ + { + "file": "{{CWD}}/path/to/dirname/nested/child.tsx", + "id": "child", + }, + ], + "file": "{{CWD}}/path/to/dirname/nested/parent.tsx", + } + `); + }); + + it("provides passthrough for non-relative APIs", () => { + let { prefix: relativePrefix } = relative("/path/to/dirname"); + expect(relativePrefix).toBe(prefix); + }); + }); + }); +}); diff --git a/packages/remix-route-config/index.ts b/packages/remix-route-config/index.ts new file mode 100644 index 00000000000..3a82e4b4b70 --- /dev/null +++ b/packages/remix-route-config/index.ts @@ -0,0 +1,13 @@ +export type { + UNSAFE_RouteConfig as RouteConfig, + UNSAFE_RouteConfigEntry as RouteConfigEntry, +} from "@remix-run/dev"; + +export { + route, + index, + layout, + prefix, + relative, + getAppDirectory, +} from "./routes"; diff --git a/packages/remix-route-config/jest.config.js b/packages/remix-route-config/jest.config.js new file mode 100644 index 00000000000..620d75b4cca --- /dev/null +++ b/packages/remix-route-config/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('jest').Config} */ +module.exports = { + ...require("../../jest/jest.config.shared"), + displayName: "route-config", + setupFiles: [], +}; diff --git a/packages/remix-route-config/package.json b/packages/remix-route-config/package.json new file mode 100644 index 00000000000..65c09e0bd5d --- /dev/null +++ b/packages/remix-route-config/package.json @@ -0,0 +1,46 @@ +{ + "name": "@remix-run/route-config", + "version": "2.13.1", + "description": "Config-based routing utilities, for use within routes.ts", + "bugs": { + "url": "https://github.com/remix-run/remix/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/remix-run/remix", + "directory": "packages/remix-route-config" + }, + "license": "MIT", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "scripts": { + "tsc": "tsc" + }, + "dependencies": { + "lodash": "^4.17.21" + }, + "devDependencies": { + "@remix-run/dev": "workspace:*", + "@types/lodash": "^4.14.182", + "typescript": "^5.1.6", + "vite": "5.1.8" + }, + "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-route-config/rollup.config.js b/packages/remix-route-config/rollup.config.js new file mode 100644 index 00000000000..fde40570793 --- /dev/null +++ b/packages/remix-route-config/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-route-config"; + 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-route-config/routes.ts b/packages/remix-route-config/routes.ts new file mode 100644 index 00000000000..b595c00b3c8 --- /dev/null +++ b/packages/remix-route-config/routes.ts @@ -0,0 +1,182 @@ +import { resolve } from "node:path"; +import pick from "lodash/pick"; +import { + type UNSAFE_RouteConfigEntry as RouteConfigEntry, + UNSAFE_getRouteConfigAppDirectory as getRouteConfigAppDirectory, +} from "@remix-run/dev"; + +/** + * Provides the absolute path to the app directory, for use within `routes.ts`. + * This is designed to support resolving file system routes. + */ +export function getAppDirectory() { + return getRouteConfigAppDirectory(); +} + +const routeOptionKeys = [ + "id", + "index", + "caseSensitive", +] as const satisfies ReadonlyArray; +type RouteOptions = Pick; +/** + * Helper function for creating a route config entry, for use within + * `routes.ts`. + */ +function route( + path: string | null | undefined, + file: string, + children?: RouteConfigEntry[] +): RouteConfigEntry; +function route( + path: string | null | undefined, + file: string, + options: RouteOptions, + children?: RouteConfigEntry[] +): RouteConfigEntry; +function route( + path: string | null | undefined, + file: string, + optionsOrChildren: RouteOptions | RouteConfigEntry[] | undefined, + children?: RouteConfigEntry[] +): RouteConfigEntry { + let options: RouteOptions = {}; + + if (Array.isArray(optionsOrChildren) || !optionsOrChildren) { + children = optionsOrChildren; + } else { + options = optionsOrChildren; + } + + return { + file, + children, + path: path ?? undefined, + ...pick(options, routeOptionKeys), + }; +} + +const indexOptionKeys = ["id"] as const satisfies ReadonlyArray< + keyof RouteConfigEntry +>; +type IndexOptions = Pick; +/** + * Helper function for creating a route config entry for an index route, for use + * within `routes.ts`. + */ +function index(file: string, options?: IndexOptions): RouteConfigEntry { + return { + file, + index: true, + ...pick(options, indexOptionKeys), + }; +} + +const layoutOptionKeys = ["id"] as const satisfies ReadonlyArray< + keyof RouteConfigEntry +>; +type LayoutOptions = Pick; +/** + * Helper function for creating a route config entry for a layout route, for use + * within `routes.ts`. + */ +function layout(file: string, children?: RouteConfigEntry[]): RouteConfigEntry; +function layout( + file: string, + options: LayoutOptions, + children?: RouteConfigEntry[] +): RouteConfigEntry; +function layout( + file: string, + optionsOrChildren: LayoutOptions | RouteConfigEntry[] | undefined, + children?: RouteConfigEntry[] +): RouteConfigEntry { + let options: LayoutOptions = {}; + + if (Array.isArray(optionsOrChildren) || !optionsOrChildren) { + children = optionsOrChildren; + } else { + options = optionsOrChildren; + } + + return { + file, + children, + ...pick(options, layoutOptionKeys), + }; +} + +/** + * Helper function for adding a path prefix to a set of routes without needing + * to introduce a parent route file, for use within `routes.ts`. + */ +function prefix( + prefixPath: string, + routes: RouteConfigEntry[] +): RouteConfigEntry[] { + return routes.map((route) => { + if (route.index || typeof route.path === "string") { + return { + ...route, + path: route.path ? joinRoutePaths(prefixPath, route.path) : prefixPath, + children: route.children, + }; + } else if (route.children) { + return { + ...route, + children: prefix(prefixPath, route.children), + }; + } + return route; + }); +} + +const helpers = { route, index, layout, prefix }; +export { route, index, layout, prefix }; +/** + * Creates a set of route config helpers that resolve file paths relative to the + * given directory, for use within `routes.ts`. This is designed to support + * splitting route config into multiple files within different directories. + */ +export function relative(directory: string): typeof helpers { + return { + /** + * Helper function for creating a route config entry, for use within + * `routes.ts`. Note that this helper has been scoped, meaning that file + * path will be resolved relative to the directory provided to the + * `relative` call that created this helper. + */ + route: (path, file, ...rest) => { + return route(path, resolve(directory, file), ...(rest as any)); + }, + /** + * Helper function for creating a route config entry for an index route, for + * use within `routes.ts`. Note that this helper has been scoped, meaning + * that file path will be resolved relative to the directory provided to the + * `relative` call that created this helper. + */ + index: (file, ...rest) => { + return index(resolve(directory, file), ...(rest as any)); + }, + /** + * Helper function for creating a route config entry for a layout route, for + * use within `routes.ts`. Note that this helper has been scoped, meaning + * that file path will be resolved relative to the directory provided to the + * `relative` call that created this helper. + */ + layout: (file, ...rest) => { + return layout(resolve(directory, file), ...(rest as any)); + }, + + // Passthrough of helper functions that don't need relative scoping so that + // a complete API is still provided. + prefix, + }; +} + +function joinRoutePaths(path1: string, path2: string): string { + return [ + path1.replace(/\/+$/, ""), // Remove trailing slashes + path2.replace(/^\/+/, ""), // Remove leading slashes + ].join("/"); +} diff --git a/packages/remix-route-config/tsconfig.json b/packages/remix-route-config/tsconfig.json new file mode 100644 index 00000000000..20e4f5cb27e --- /dev/null +++ b/packages/remix-route-config/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/route-config/dist" + } +} diff --git a/packages/remix-routes-option-adapter/README.md b/packages/remix-routes-option-adapter/README.md new file mode 100644 index 00000000000..40685a7476f --- /dev/null +++ b/packages/remix-routes-option-adapter/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-routes-option-adapter/index.ts b/packages/remix-routes-option-adapter/index.ts new file mode 100644 index 00000000000..77e8ee59b37 --- /dev/null +++ b/packages/remix-routes-option-adapter/index.ts @@ -0,0 +1,24 @@ +import { + type UNSAFE_DefineRoutesFunction as DefineRoutesFunction, + UNSAFE_defineRoutes as defineRoutes, + UNSAFE_routeManifestToRouteConfig as routeManifestToRouteConfig, +} from "@remix-run/dev"; +import { type RouteConfigEntry } from "@remix-run/route-config"; + +export type { DefineRoutesFunction }; + +/** + * Adapter for [Remix's `routes` config + * option](https://remix.run/docs/en/v2/file-conventions/vite-config#routes), + * for use within `routes.ts`. + */ +export async function remixRoutesOptionAdapter( + routes: ( + defineRoutes: DefineRoutesFunction + ) => + | ReturnType + | Promise> +): Promise { + let routeManifest = await routes(defineRoutes); + return routeManifestToRouteConfig(routeManifest); +} diff --git a/packages/remix-routes-option-adapter/package.json b/packages/remix-routes-option-adapter/package.json new file mode 100644 index 00000000000..8353e06c4ab --- /dev/null +++ b/packages/remix-routes-option-adapter/package.json @@ -0,0 +1,43 @@ +{ + "name": "@remix-run/routes-option-adapter", + "version": "2.13.1", + "description": "Adapter for Remix's \"routes\" config option, for use within routes.ts", + "bugs": { + "url": "https://github.com/remix-run/remix/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/remix-run/remix", + "directory": "packages/remix-routes-option-adapter" + }, + "license": "MIT", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "scripts": { + "tsc": "tsc" + }, + "devDependencies": { + "@remix-run/dev": "workspace:*", + "@remix-run/route-config": "workspace:*", + "typescript": "^5.1.6" + }, + "peerDependencies": { + "@remix-run/dev": "workspace:*", + "@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-routes-option-adapter/rollup.config.js b/packages/remix-routes-option-adapter/rollup.config.js new file mode 100644 index 00000000000..59e7189afe3 --- /dev/null +++ b/packages/remix-routes-option-adapter/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-routes-option-adapter"; + 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-routes-option-adapter/tsconfig.json b/packages/remix-routes-option-adapter/tsconfig.json new file mode 100644 index 00000000000..897cf7dd585 --- /dev/null +++ b/packages/remix-routes-option-adapter/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/routes-option-adapter/dist" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c47a59527d..ddc56d5b390 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,7 +351,7 @@ importers: version: 5.1.1 express: specifier: ^4.20.0 - version: 4.20.0 + version: 4.21.1 fs-extra: specifier: ^10.0.0 version: 10.1.0 @@ -506,7 +506,7 @@ importers: version: link:../../../packages/remix-server-runtime express: specifier: ^4.20.0 - version: 4.20.0 + version: 4.21.1 isbot: specifier: ^4.1.0 version: 4.4.0 @@ -520,6 +520,15 @@ 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 + '@remix-run/routes-option-adapter': + specifier: workspace:* + version: link:../../../packages/remix-routes-option-adapter '@types/react': specifier: ^18.2.20 version: 18.2.20 @@ -619,7 +628,7 @@ importers: version: 3.9.5(vite@5.1.8) express: specifier: ^4.20.0 - version: 4.20.0 + version: 4.21.1 isbot: specifier: ^4.1.0 version: 4.4.0 @@ -639,6 +648,9 @@ importers: '@remix-run/eslint-config': specifier: workspace:* version: link:../../../packages/remix-eslint-config + '@remix-run/route-config': + specifier: workspace:* + version: link:../../../packages/remix-route-config '@types/react': specifier: ^18.2.20 version: 18.2.20 @@ -917,7 +929,7 @@ importers: version: 2.2.1 express: specifier: ^4.20.0 - version: 4.20.0 + version: 4.21.1 fs-extra: specifier: ^10.0.0 version: 10.1.0 @@ -996,6 +1008,12 @@ importers: typescript: specifier: ^5.1.0 version: 5.1.6 + valibot: + specifier: ^0.41.0 + version: 0.41.0(typescript@5.1.6) + vite-node: + specifier: ^1.6.0 + version: 1.6.0(@types/node@18.17.1) ws: specifier: ^7.5.10 version: 7.5.10 @@ -1169,7 +1187,7 @@ importers: version: 2.0.16 express: specifier: ^4.20.0 - version: 4.20.0 + version: 4.21.1 node-mocks-http: specifier: ^1.10.1 version: 1.14.1 @@ -1180,6 +1198,18 @@ importers: specifier: ^5.1.6 version: 5.1.6 + packages/remix-fs-routes: + devDependencies: + '@remix-run/dev': + specifier: workspace:* + version: link:../remix-dev + '@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': @@ -1260,6 +1290,37 @@ importers: specifier: ^5.1.6 version: 5.1.6 + packages/remix-route-config: + dependencies: + lodash: + specifier: ^4.17.21 + version: 4.17.21 + devDependencies: + '@remix-run/dev': + specifier: workspace:* + version: link:../remix-dev + '@types/lodash': + specifier: ^4.14.182 + version: 4.14.182 + typescript: + specifier: ^5.1.6 + version: 5.1.6 + vite: + specifier: 5.1.8 + version: 5.1.8(@types/node@18.17.1) + + packages/remix-routes-option-adapter: + devDependencies: + '@remix-run/dev': + specifier: workspace:* + version: link:../remix-dev + '@remix-run/route-config': + specifier: workspace:* + version: link:../remix-route-config + typescript: + specifier: ^5.1.6 + version: 5.1.6 + packages/remix-serve: dependencies: '@remix-run/express': @@ -1276,7 +1337,7 @@ importers: version: 1.7.4 express: specifier: ^4.20.0 - version: 4.20.0 + version: 4.21.1 get-port: specifier: 5.1.1 version: 5.1.1 @@ -5246,7 +5307,7 @@ packages: mlly: 1.5.0 outdent: 0.8.0 vite: 5.1.8(@types/node@18.17.1) - vite-node: 1.2.2(@types/node@18.17.1) + vite-node: 1.6.0(@types/node@18.17.1) transitivePeerDependencies: - '@types/node' - less @@ -6606,6 +6667,11 @@ packages: /cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} + dev: false + + /cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} /cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} @@ -8040,8 +8106,8 @@ packages: jest-message-util: 29.7.0 jest-util: 29.7.0 - /express@4.20.0: - resolution: {integrity: sha512-pLdae7I6QqShF5PnNTCVn4hI91Dx0Grkn2+IAsMTgMIKuQVte2dN9PeGSSAME2FR8anOhVA62QDIUaWVfEXVLw==} + /express@4.21.1: + resolution: {integrity: sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==} engines: {node: '>= 0.10.0'} dependencies: accepts: 1.3.8 @@ -8049,14 +8115,14 @@ packages: body-parser: 1.20.3 content-disposition: 0.5.4 content-type: 1.0.5 - cookie: 0.6.0 + cookie: 0.7.1 cookie-signature: 1.0.6 debug: 2.6.9 depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 1.2.0 + finalhandler: 1.3.1 fresh: 0.5.2 http-errors: 2.0.0 merge-descriptors: 1.0.3 @@ -8065,11 +8131,11 @@ packages: parseurl: 1.3.3 path-to-regexp: 0.1.10 proxy-addr: 2.0.7 - qs: 6.11.0 + qs: 6.13.0 range-parser: 1.2.1 safe-buffer: 5.2.1 send: 0.19.0 - serve-static: 1.16.0 + serve-static: 1.16.2 setprototypeof: 1.2.0 statuses: 2.0.1 type-is: 1.6.18 @@ -8193,12 +8259,12 @@ packages: dependencies: to-regex-range: 5.0.1 - /finalhandler@1.2.0: - resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==} + /finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} engines: {node: '>= 0.8'} dependencies: debug: 2.6.9 - encodeurl: 1.0.2 + encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 @@ -8309,7 +8375,7 @@ packages: dezalgo: 1.0.4 hexoid: 1.0.0 once: 1.4.0 - qs: 6.13.0 + qs: 6.11.2 dev: true /forwarded@0.2.0: @@ -9000,7 +9066,7 @@ packages: dependencies: es-errors: 1.3.0 hasown: 2.0.1 - side-channel: 1.0.6 + side-channel: 1.0.4 dev: false /interpret@1.4.0: @@ -12692,11 +12758,12 @@ packages: /pure-rand@6.0.2: resolution: {integrity: sha512-6Yg0ekpKICSjPswYOuC5sku/TSWaRYlA0qsXqJgM/d/4pLPHPuTxK7Nbf7jFKzAeedUhR8C7K9Uv63FBsSo8xQ==} - /qs@6.11.0: - resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} + /qs@6.11.2: + resolution: {integrity: sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==} engines: {node: '>=0.6'} dependencies: - side-channel: 1.0.6 + side-channel: 1.0.4 + dev: true /qs@6.13.0: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} @@ -13366,26 +13433,6 @@ packages: dependencies: lru-cache: 6.0.0 - /send@0.18.0: - resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} - engines: {node: '>= 0.8.0'} - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 1.0.2 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.0 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.1 - transitivePeerDependencies: - - supports-color - /send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -13412,14 +13459,14 @@ packages: randombytes: 2.1.0 dev: false - /serve-static@1.16.0: - resolution: {integrity: sha512-pDLK8zwl2eKaYrs8mrPZBJua4hMplRWJ1tIFksVC3FtBEBnl8dxgeHtsaMS8DhS9i4fLObaon6ABoc4/hQGdPA==} + /serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} engines: {node: '>= 0.8.0'} dependencies: - encodeurl: 1.0.2 + encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 0.18.0 + send: 0.19.0 transitivePeerDependencies: - supports-color @@ -13518,6 +13565,13 @@ packages: rechoir: 0.6.2 dev: false + /side-channel@1.0.4: + resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + object-inspect: 1.13.1 + /side-channel@1.0.6: resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} engines: {node: '>= 0.4'} @@ -13846,7 +13900,7 @@ packages: internal-slot: 1.0.7 regexp.prototype.flags: 1.5.2 set-function-name: 2.0.1 - side-channel: 1.0.6 + side-channel: 1.0.4 dev: false /string.prototype.padend@3.1.5: @@ -13985,7 +14039,7 @@ packages: formidable: 2.1.2 methods: 1.1.2 mime: 2.6.0 - qs: 6.13.0 + qs: 6.11.2 semver: 7.5.4 transitivePeerDependencies: - supports-color @@ -14749,6 +14803,17 @@ packages: '@types/istanbul-lib-coverage': 2.0.3 convert-source-map: 1.8.0 + /valibot@0.41.0(typescript@5.1.6): + resolution: {integrity: sha512-igDBb8CTYr8YTQlOKgaN9nSS0Be7z+WRuaeYqGf3Cjz3aKmSnqEmYnkfVjzIuumGqfHpa3fLIvMEAfhrpqN8ng==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + typescript: 5.1.6 + dev: false + /validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} dependencies: @@ -14828,8 +14893,8 @@ packages: - supports-color dev: true - /vite-node@1.2.2(@types/node@18.17.1): - resolution: {integrity: sha512-1as4rDTgVWJO3n1uHmUYqq7nsFgINQ9u+mRcXpjeOMJUmviqNKjcZB7UfRZrlM7MjYXMKpuWp5oGkjaFLnjawg==} + /vite-node@1.6.0(@types/node@18.17.1): + resolution: {integrity: sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 596042f9a74..e8c91c3e82c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,8 +16,11 @@ 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" + - "packages/remix-routes-option-adapter" - "packages/remix-serve" - "packages/remix-server-runtime" - "packages/remix-testing" diff --git a/scripts/publish.js b/scripts/publish.js index a50cdcaabab..37fa8f7f37b 100644 --- a/scripts/publish.js +++ b/scripts/publish.js @@ -60,8 +60,11 @@ async function run() { "express", // publish express before serve "react", "serve", + "fs-routes", "css-bundle", "testing", + "route-config", + "routes-option-adapter", ]) { publish(path.join(buildDir, "@remix-run", name), tag); }