diff --git a/packages/remix/package.json b/packages/remix/package.json index 10a0025e4..d60f54eb3 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -11,8 +11,9 @@ }, "scripts": { "build": "node build.js", - "test-e2e": "pnpm test test/integration.test.ts", - "test": "jest --env node --verbose --bail --runInBand" + "test": "jest --env node --verbose --bail --runInBand", + "test-unit": "pnpm test test/unit.*test.*", + "test-e2e": "pnpm test test/integration.test.ts" }, "files": [ "dist", diff --git a/packages/remix/src/build.ts b/packages/remix/src/build.ts index 68d031ba0..805ca7c13 100644 --- a/packages/remix/src/build.ts +++ b/packages/remix/src/build.ts @@ -1,7 +1,6 @@ import { Project } from 'ts-morph'; import { promises as fs } from 'fs'; import { basename, dirname, extname, join, relative, sep } from 'path'; -import { pathToRegexp, Key } from 'path-to-regexp'; import { debug, download, @@ -30,7 +29,12 @@ import type { BuildResultV2Typical, } from '@vercel/build-utils'; import type { ConfigRoute } from '@remix-run/dev/dist/config/routes'; -import { findConfig } from './utils'; +import { + findConfig, + getPathFromRoute, + getRegExpFromPath, + isLayoutRoute, +} from './utils'; const _require: typeof require = eval('require'); @@ -173,12 +177,13 @@ module.exports = config;`; } } - const { serverBuildPath, routes: remixRoutes } = remixConfig; + const { serverBuildPath } = remixConfig; + const remixRoutes = Object.values(remixConfig.routes); // Figure out which pages should be edge functions const edgePages = new Set(); const project = new Project(); - for (const route of Object.values(remixRoutes)) { + for (const route of remixRoutes) { const routePath = join(remixConfig.appDirectory, route.file); const staticConfig = getConfig(project, routePath); const isEdge = @@ -228,27 +233,11 @@ module.exports = config;`; }, ]; - for (const route of Object.values(remixRoutes)) { + for (const route of remixRoutes) { // Layout routes don't get a function / route added - const isLayoutRoute = Object.values(remixRoutes).some( - r => r.parentId === route.id - ); - if (isLayoutRoute) continue; - - // Build up the full request path - let currentRoute: ConfigRoute | undefined = route; - const pathParts: string[] = []; - do { - if (currentRoute.index) pathParts.push('index'); - if (currentRoute.path) pathParts.push(currentRoute.path); - if (currentRoute.parentId) { - currentRoute = remixRoutes[currentRoute.parentId]; - } else { - currentRoute = undefined; - } - } while (currentRoute); - const path = join(...pathParts.reverse()); + if (isLayoutRoute(route.id, remixRoutes)) continue; + const path = getPathFromRoute(route, remixConfig.routes); const isEdge = edgePages.has(route); const fn = isEdge && edgeFunction @@ -264,13 +253,8 @@ module.exports = config;`; output[path] = fn; // If this is a dynamic route then add a Vercel route - const keys: Key[] = []; - // Replace "/*" at the end to handle "splat routes" - const splatPath = '/:params+'; - const rePath = - path === '*' ? splatPath : `/${path.replace(/\/\*$/, splatPath)}`; - const re = pathToRegexp(rePath, keys); - if (keys.length > 0) { + const re = getRegExpFromPath(path); + if (re) { routes.push({ src: re.source, dest: path, diff --git a/packages/remix/src/utils.ts b/packages/remix/src/utils.ts index 3c0d63756..196aad24e 100644 --- a/packages/remix/src/utils.ts +++ b/packages/remix/src/utils.ts @@ -1,5 +1,10 @@ -import { existsSync } from 'fs'; import { join } from 'path'; +import { existsSync } from 'fs'; +import { pathToRegexp, Key } from 'path-to-regexp'; +import type { + ConfigRoute, + RouteManifest, +} from '@remix-run/dev/dist/config/routes'; const configExts = ['.js', '.cjs', '.mjs']; @@ -12,3 +17,39 @@ export function findConfig(dir: string, basename: string): string | undefined { return undefined; } + +export function isLayoutRoute( + routeId: string, + routes: Pick[] +): boolean { + return routes.some(r => r.parentId === routeId); +} + +export function getPathFromRoute( + route: ConfigRoute, + routes: RouteManifest +): string { + let currentRoute: ConfigRoute | undefined = route; + const pathParts: string[] = []; + do { + if (currentRoute.index) pathParts.push('index'); + if (currentRoute.path) pathParts.push(currentRoute.path); + if (currentRoute.parentId) { + currentRoute = routes[currentRoute.parentId]; + } else { + currentRoute = undefined; + } + } while (currentRoute); + const path = pathParts.reverse().join('/'); + return path; +} + +export function getRegExpFromPath(path: string): RegExp | false { + const keys: Key[] = []; + // Replace "/*" at the end to handle "splat routes" + const splatPath = '/:params+'; + const rePath = + path === '*' ? splatPath : `/${path.replace(/\/\*$/, splatPath)}`; + const re = pathToRegexp(rePath, keys); + return keys.length > 0 ? re : false; +} diff --git a/packages/remix/test/unit.find-config.test.ts b/packages/remix/test/unit.find-config.test.ts new file mode 100644 index 000000000..8b1312a05 --- /dev/null +++ b/packages/remix/test/unit.find-config.test.ts @@ -0,0 +1,17 @@ +import { join } from 'path'; +import { findConfig } from '../src/utils'; + +const fixture = (name: string) => join(__dirname, 'fixtures', name); + +describe('findConfig()', () => { + it.each([ + { name: '01-remix-basics', config: 'remix.config.js' }, + { name: '02-remix-basics-mjs', config: 'remix.config.mjs' }, + { name: '03-with-pnpm', config: 'remix.config.js' }, + { name: '04-with-npm9-linked', config: 'remix.config.js' }, + ])('should find `$config` from "$name"', ({ name, config }) => { + const dir = fixture(name); + const resolved = findConfig(dir, 'remix.config'); + expect(resolved).toEqual(join(dir, config)); + }); +}); diff --git a/packages/remix/test/unit.get-path-from-route.test.ts b/packages/remix/test/unit.get-path-from-route.test.ts new file mode 100644 index 000000000..5eb5b720d --- /dev/null +++ b/packages/remix/test/unit.get-path-from-route.test.ts @@ -0,0 +1,73 @@ +import { getPathFromRoute } from '../src/utils'; +import type { RouteManifest } from '@remix-run/dev/dist/config/routes'; + +describe('getPathFromRoute()', () => { + const routes: RouteManifest = { + root: { path: '', id: 'root', file: 'root.tsx' }, + 'routes/$foo.$bar.$baz': { + path: ':foo/:bar/:baz', + id: 'routes/$foo.$bar.$baz', + parentId: 'root', + file: 'routes/$foo.$bar.$baz.tsx', + }, + 'routes/api.hello': { + path: 'api/hello', + id: 'routes/api.hello', + parentId: 'root', + file: 'routes/api.hello.tsx', + }, + 'routes/projects': { + path: 'projects', + id: 'routes/projects', + parentId: 'root', + file: 'routes/projects.tsx', + }, + 'routes/projects/index': { + path: undefined, + index: true, + id: 'routes/projects/indexx', + parentId: 'routes/projects', + file: 'routes/projects/indexx.tsx', + }, + 'routes/projects/$': { + path: '*', + id: 'routes/projects/$', + parentId: 'routes/projects', + file: 'routes/projects/$.tsx', + }, + 'routes/index': { + path: undefined, + index: true, + id: 'routes/index', + parentId: 'root', + file: 'routes/index.tsx', + }, + 'routes/node': { + path: 'node', + id: 'routes/node', + parentId: 'root', + file: 'routes/node.tsx', + }, + 'routes/$': { + path: '*', + id: 'routes/$', + parentId: 'root', + file: 'routes/$.tsx', + }, + }; + + it.each([ + { id: 'root', expected: '' }, + { id: 'routes/index', expected: 'index' }, + { id: 'routes/api.hello', expected: 'api/hello' }, + { id: 'routes/projects', expected: 'projects' }, + { id: 'routes/projects/index', expected: 'projects/index' }, + { id: 'routes/projects/$', expected: 'projects/*' }, + { id: 'routes/$foo.$bar.$baz', expected: ':foo/:bar/:baz' }, + { id: 'routes/node', expected: 'node' }, + { id: 'routes/$', expected: '*' }, + ])('should return `$expected` for "$id" route', ({ id, expected }) => { + const route = routes[id]; + expect(getPathFromRoute(route, routes)).toEqual(expected); + }); +}); diff --git a/packages/remix/test/unit.get-regexp-from-path.test.ts b/packages/remix/test/unit.get-regexp-from-path.test.ts new file mode 100644 index 000000000..a8a344fee --- /dev/null +++ b/packages/remix/test/unit.get-regexp-from-path.test.ts @@ -0,0 +1,124 @@ +import { getRegExpFromPath } from '../src/utils'; + +describe('getRegExpFromPath()', () => { + describe('paths without parameters', () => { + it.each([{ path: 'index' }, { path: 'api/hello' }, { path: 'projects' }])( + 'should return `false` for "$path"', + ({ path }) => { + expect(getRegExpFromPath(path)).toEqual(false); + } + ); + }); + + describe.each([ + { + path: '*', + urls: [ + { + url: '/', + expected: false, + }, + { + url: '/foo', + expected: true, + }, + { + url: '/projects/foo', + expected: true, + }, + { + url: '/projects/another', + expected: true, + }, + { + url: '/to/infinity/and/beyond', + expected: true, + }, + ], + }, + { + path: 'projects/*', + urls: [ + { + url: '/', + expected: false, + }, + { + url: '/foo', + expected: false, + }, + { + url: '/projects/foo', + expected: true, + }, + { + url: '/projects/another', + expected: true, + }, + ], + }, + { + path: ':foo', + urls: [ + { + url: '/', + expected: false, + }, + { + url: '/foo', + expected: true, + }, + { + url: '/projects/foo', + expected: false, + }, + { + url: '/projects/another', + expected: false, + }, + ], + }, + { + path: 'blog/:id/edit', + urls: [ + { + url: '/', + expected: false, + }, + { + url: '/foo', + expected: false, + }, + { + url: '/blog/123/edit', + expected: true, + }, + { + url: '/blog/456/edit', + expected: true, + }, + { + url: '/blog/123/456/edit', + expected: false, + }, + { + url: '/blog/123/another', + expected: false, + }, + ], + }, + ])('with path "$path"', ({ path, urls }) => { + const re = getRegExpFromPath(path) as RegExp; + + it('should return RegExp', () => { + expect(re).toBeInstanceOf(RegExp); + }); + + it.each(urls)( + 'should match URL "$url" - $expected', + ({ url, expected }) => { + expect(re.test(url)).toEqual(expected); + } + ); + }); +}); diff --git a/packages/remix/test/unit.is-layout-route.test.ts b/packages/remix/test/unit.is-layout-route.test.ts new file mode 100644 index 000000000..787d7d7b5 --- /dev/null +++ b/packages/remix/test/unit.is-layout-route.test.ts @@ -0,0 +1,21 @@ +import { isLayoutRoute } from '../src/utils'; + +describe('isLayoutRoute()', () => { + const routes = [ + { id: 'root' }, + { id: 'routes/auth', parentId: 'root' }, + { id: 'routes/login', parentId: 'routes/auth' }, + { id: 'routes/logout', parentId: 'routes/auth' }, + { id: 'routes/index', parentId: 'root' }, + ]; + + it.each([ + { id: 'root', expected: true }, + { id: 'routes/auth', expected: true }, + { id: 'routes/index', expected: false }, + { id: 'routes/login', expected: false }, + { id: 'routes/logout', expected: false }, + ])('should return `$expected` for "$id" route', ({ id, expected }) => { + expect(isLayoutRoute(id, routes)).toEqual(expected); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa998dc92..bfad38fc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1203,6 +1203,7 @@ packages: semver: 6.3.0 transitivePeerDependencies: - supports-color + dev: true /@babel/core/7.5.0: resolution: {integrity: sha512-6Isr4X98pwXqHvtigw71CKgmhL1etZjPs5A67jL/w0TkLM9eqmFR40YrnJvEc1WnMZFsskjsmid8bHZyxKEAnw==} @@ -1268,7 +1269,7 @@ packages: '@babel/core': ^7.0.0 dependencies: '@babel/compat-data': 7.20.10 - '@babel/core': 7.20.12_supports-color@7.2.0 + '@babel/core': 7.20.12 '@babel/helper-validator-option': 7.18.6 browserslist: 4.21.4 lru-cache: 5.1.1 @@ -1298,7 +1299,7 @@ packages: peerDependencies: '@babel/core': ^7.0.0 dependencies: - '@babel/core': 7.20.12_supports-color@7.2.0 + '@babel/core': 7.20.12 '@babel/helper-annotate-as-pure': 7.18.6 regexpu-core: 5.2.2 @@ -1381,6 +1382,7 @@ packages: '@babel/types': 7.20.7 transitivePeerDependencies: - supports-color + dev: true /@babel/helper-optimise-call-expression/7.18.6: resolution: {integrity: sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==} @@ -1506,6 +1508,7 @@ packages: '@babel/types': 7.20.7 transitivePeerDependencies: - supports-color + dev: true /@babel/highlight/7.18.6: resolution: {integrity: sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==} @@ -1675,7 +1678,7 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.20.12_supports-color@7.2.0 + '@babel/core': 7.20.12 '@babel/helper-plugin-utils': 7.20.2 '@babel/plugin-syntax-optional-catch-binding': 7.8.3_@babel+core@7.20.12 @@ -1731,7 +1734,7 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.20.12_supports-color@7.2.0 + '@babel/core': 7.20.12 '@babel/helper-plugin-utils': 7.20.2 /@babel/plugin-syntax-bigint/7.8.3_@babel+core@7.20.12: @@ -1850,7 +1853,7 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.20.12_supports-color@7.2.0 + '@babel/core': 7.20.12 '@babel/helper-plugin-utils': 7.20.2 /@babel/plugin-syntax-optional-catch-binding/7.8.3_@babel+core@7.20.12: @@ -1858,7 +1861,7 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.20.12_supports-color@7.2.0 + '@babel/core': 7.20.12 '@babel/helper-plugin-utils': 7.20.2 /@babel/plugin-syntax-optional-chaining/7.8.3_@babel+core@7.20.12: @@ -1980,7 +1983,7 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.20.12_supports-color@7.2.0 + '@babel/core': 7.20.12 '@babel/helper-create-regexp-features-plugin': 7.20.5_@babel+core@7.20.12 '@babel/helper-plugin-utils': 7.20.2 @@ -2517,6 +2520,7 @@ packages: globals: 11.12.0 transitivePeerDependencies: - supports-color + dev: true /@babel/types/7.20.7: resolution: {integrity: sha512-69OnhBxSSgK0OzTJai4kyPDiKTIe3j+ctaHdIGVbRahTLAT7L3R9oeXHC2aVSuGYt3cVnoAMDmOCgJ2yaiLMvg==} @@ -10243,6 +10247,7 @@ packages: dependencies: ms: 2.1.2 supports-color: 7.2.0 + dev: true /debuglog/1.0.1: resolution: {integrity: sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw==}