diff --git a/packages/hydrogen/src/entry-server.tsx b/packages/hydrogen/src/entry-server.tsx index 1f43d92391..9f2a78de01 100644 --- a/packages/hydrogen/src/entry-server.tsx +++ b/packages/hydrogen/src/entry-server.tsx @@ -14,7 +14,6 @@ import type { RunSsrParams, RunRscParams, ResolvedHydrogenConfig, - ResolvedHydrogenRoutes, RequestHandler, } from './types.js'; import type {RequestHandlerOptions} from './shared-types.js'; @@ -27,11 +26,7 @@ import { } from './foundation/ServerRequestProvider/index.js'; import type {ServerResponse} from 'http'; import type {PassThrough as PassThroughType} from 'stream'; -import { - getApiRouteFromURL, - renderApiRoute, - getApiRoutes, -} from './utilities/apiRoutes.js'; +import {getApiRouteFromURL, renderApiRoute} from './utilities/apiRoutes.js'; import {ServerPropsProvider} from './foundation/ServerPropsProvider/index.js'; import {isBotUA} from './utilities/bot-ua.js'; import {getCache, setCache} from './foundation/runtime.js'; @@ -58,6 +53,7 @@ import { import {CacheShort, NO_STORE} from './foundation/Cache/strategies/index.js'; import {getBuiltInRoute} from './foundation/BuiltInRoutes/BuiltInRoutes.js'; import {FORM_REDIRECT_COOKIE} from './constants.js'; +import {findRouteMatches, createRoutes} from './utilities/routes.js'; declare global { // This is provided by a Vite plugin @@ -94,7 +90,7 @@ export const renderHydrogen = (App: any) => { const hydrogenConfig: ResolvedHydrogenConfig = { ...inlineHydrogenConfig, - routes: hydrogenRoutes, + routes: createRoutes(hydrogenRoutes), }; request.ctx.hydrogenConfig = hydrogenConfig; @@ -136,7 +132,6 @@ export const renderHydrogen = (App: any) => { { resource: builtInRouteResource, params: {}, - hasServerComponent: false, }, hydrogenConfig, { @@ -236,18 +231,28 @@ async function processRequest( const log = getLoggerWithContext(request); const isRSCRequest = request.isRscRequest(); - const apiRoute = !isRSCRequest && getApiRoute(url, hydrogenConfig.routes); + const decodedPathname = decodeURIComponent( + new URL(request.normalizedUrl).pathname + ); + const matchedRoutes = findRouteMatches( + hydrogenConfig.routes, + decodedPathname + ); + + // These matched routes are used later in component + request.ctx.matchedRoutes = matchedRoutes; + + const apiRoute = + !isRSCRequest && getApiRouteFromURL(matchedRoutes, request.method); // The API Route might have a default export, making it also a server component // If it does, only render the API route if the request method is GET - if (apiRoute && (!apiRoute.hasServerComponent || request.method !== 'GET')) { + if (apiRoute) { const apiResponse = await renderApiRoute( request, apiRoute, hydrogenConfig, - { - session: sessionApi, - } + {session: sessionApi} ); return apiResponse instanceof Request @@ -262,7 +267,7 @@ async function processRequest( const state: Record = isRSCRequest ? parseJSON(decodeURIComponent(url.searchParams.get('state') || '{}')) : { - pathname: decodeURIComponent(url.pathname), + pathname: decodedPathname, search: decodeURIComponent(url.search), }; @@ -316,11 +321,6 @@ async function getTemplate( return template; } -function getApiRoute(url: URL, routes: ResolvedHydrogenRoutes) { - const apiRoutes = getApiRoutes(routes); - return getApiRouteFromURL(url, apiRoutes); -} - function assembleHtml({ ssrHtml, rscPayload, diff --git a/packages/hydrogen/src/foundation/FileRoutes/FileRoutes.server.tsx b/packages/hydrogen/src/foundation/FileRoutes/FileRoutes.server.tsx index afad1bd5e4..d0990486c0 100644 --- a/packages/hydrogen/src/foundation/FileRoutes/FileRoutes.server.tsx +++ b/packages/hydrogen/src/foundation/FileRoutes/FileRoutes.server.tsx @@ -1,11 +1,9 @@ import React, {useMemo} from 'react'; -import {matchPath} from '../../utilities/matchPath.js'; -import {log} from '../../utilities/log/index.js'; -import {extractPathFromRoutesKey} from '../../utilities/apiRoutes.js'; import {useServerRequest} from '../ServerRequestProvider/index.js'; import type {ImportGlobEagerOutput} from '../../types.js'; import {RouteParamsProvider} from '../useRouteParams/RouteParamsProvider.client.js'; +import {createRoutes, findRouteMatches} from '../../utilities/routes.js'; interface FileRoutesProps { /** The routes defined by Vite's [import.meta.globEager](https://vitejs.dev/guide/features.html#glob-import) method. */ @@ -13,7 +11,7 @@ interface FileRoutesProps { /** A path that's prepended to all file routes. You can modify `basePath` if you want to prefix all file routes. For example, you can prefix all file routes with a locale. */ basePath?: string; /** The portion of the file route path that shouldn't be a part of the URL. You need to modify this if you want to import routes from a location other than the default `src/routes`. */ - dirPrefix?: string | RegExp; + dirPrefix?: string; } /** @@ -27,94 +25,29 @@ export function FileRoutes({routes, basePath, dirPrefix}: FileRoutesProps) { if (routeRendered) return null; - if (!routes) { - const fileRoutes = request.ctx.hydrogenConfig!.routes; - routes = fileRoutes.files; - dirPrefix ??= fileRoutes.dirPrefix; - basePath ??= fileRoutes.basePath; - } - - basePath ??= '/'; - - const pageRoutes = useMemo( - () => createPageRoutes(routes!, basePath, dirPrefix), - [routes, basePath, dirPrefix] + const {match: route, details} = routes + ? useMemo( + () => + findRouteMatches( + createRoutes({files: routes, basePath, dirPrefix}), + serverProps.pathname + ), + [routes, basePath, dirPrefix, serverProps.pathname] + ) + : request.ctx.matchedRoutes!; + + if (!route || !details || !route.resource.default) return null; + + request.ctx.router.routeRendered = true; + request.ctx.router.routeParams = details.params; + const ServerComponent = route.resource.default; + + return ( + + + ); - - let foundRoute, foundRouteDetails; - - for (let i = 0; i < pageRoutes.length; i++) { - foundRouteDetails = matchPath(serverProps.pathname, pageRoutes[i]); - - if (foundRouteDetails) { - foundRoute = pageRoutes[i]; - break; - } - } - - if (foundRoute) { - request.ctx.router.routeRendered = true; - request.ctx.router.routeParams = foundRouteDetails.params; - return ( - - - - ); - } - - return null; -} - -interface HydrogenRoute { - component: any; - path: string; - exact: boolean; -} - -export function createPageRoutes( - pages: ImportGlobEagerOutput, - topLevelPath = '*', - dirPrefix: string | RegExp = '' -): HydrogenRoute[] { - const topLevelPrefix = topLevelPath.replace('*', '').replace(/\/$/, ''); - - const keys = Object.keys(pages); - - const routes = keys - .map((key) => { - const path = extractPathFromRoutesKey(key, dirPrefix); - - /** - * Catch-all routes [...handle].jsx don't need an exact match - * https://reactrouter.com/core/api/Route/exact-bool - */ - const exact = !/\[(?:[.]{3})(\w+?)\]/.test(key); - - if (!pages[key].default && !pages[key].api) { - log?.warn( - `${key} doesn't export a default React component or an API function` - ); - } - - return { - path: topLevelPrefix + path, - component: pages[key].default, - exact, - }; - }) - .filter((route) => route.component); - - /** - * Place static paths BEFORE dynamic paths to grant priority. - */ - return [ - ...routes.filter((route) => !route.path.includes(':')), - ...routes.filter((route) => route.path.includes(':')), - ]; } diff --git a/packages/hydrogen/src/foundation/HydrogenRequest/HydrogenRequest.server.ts b/packages/hydrogen/src/foundation/HydrogenRequest/HydrogenRequest.server.ts index dce9513f01..e93e89c159 100644 --- a/packages/hydrogen/src/foundation/HydrogenRequest/HydrogenRequest.server.ts +++ b/packages/hydrogen/src/foundation/HydrogenRequest/HydrogenRequest.server.ts @@ -16,6 +16,7 @@ import {HelmetData as HeadData} from 'react-helmet-async'; import {RSC_PATHNAME} from '../../constants.js'; import type {SessionSyncApi} from '../session/session-types.js'; import {parseJSON} from '../../utilities/parse.js'; +import type {RouteMatches} from '../../utilities/routes.js'; export type PreloadQueryEntry = { key: QueryKey; @@ -81,6 +82,7 @@ export class HydrogenRequest extends Request { runtime?: RuntimeContext; scopes: Map>; localization?: LocalizationContextValue; + matchedRoutes?: RouteMatches; [key: string]: any; }; diff --git a/packages/hydrogen/src/foundation/Router/tests/FileRoutes.test.tsx b/packages/hydrogen/src/foundation/Router/tests/FileRoutes.test.tsx deleted file mode 100644 index f1ec65eec1..0000000000 --- a/packages/hydrogen/src/foundation/Router/tests/FileRoutes.test.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import {ImportGlobEagerOutput} from '../../../types.js'; -import {createPageRoutes} from '../../FileRoutes/FileRoutes.server.js'; - -const STUB_MODULE = {default: {}, api: null}; - -it('converts normal pages to routes', () => { - const pages: ImportGlobEagerOutput = { - './routes/contact.server.jsx': STUB_MODULE, - }; - - const routes = createPageRoutes(pages, '*', './routes'); - - expect(routes).toEqual([ - { - path: '/contact', - component: STUB_MODULE.default, - exact: true, - }, - ]); -}); - -it('handles index pages', () => { - const pages: ImportGlobEagerOutput = { - './routes/contact.server.jsx': STUB_MODULE, - './routes/index.server.jsx': STUB_MODULE, - }; - - const routes = createPageRoutes(pages, '*', './routes'); - - expect(routes).toEqual([ - { - path: '/contact', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/', - component: STUB_MODULE.default, - exact: true, - }, - ]); -}); - -it('handles nested index pages', () => { - const pages: ImportGlobEagerOutput = { - './routes/products/index.server.jsx': STUB_MODULE, - './routes/products/[handle].server.jsx': STUB_MODULE, - './routes/blogs/index.server.jsx': STUB_MODULE, - './routes/products/snowboards/fastones/index.server.jsx': STUB_MODULE, - './routes/articles/index.server.jsx': STUB_MODULE, - './routes/articles/[...handle].server.jsx': STUB_MODULE, - }; - - const routes = createPageRoutes(pages, '*', './routes'); - - expect(routes).toEqual([ - { - path: '/products', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/blogs', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/products/snowboards/fastones', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/articles', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/products/:handle', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/articles/:handle', - component: STUB_MODULE.default, - exact: false, - }, - ]); -}); - -it('handles dynamic paths', () => { - const pages: ImportGlobEagerOutput = { - './routes/contact.server.jsx': STUB_MODULE, - './routes/index.server.jsx': STUB_MODULE, - './routes/products/[handle].server.jsx': STUB_MODULE, - }; - - const routes = createPageRoutes(pages, '*', './routes'); - expect(routes).toEqual([ - { - path: '/contact', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/products/:handle', - component: STUB_MODULE.default, - exact: true, - }, - ]); -}); - -it('handles catch all routes', () => { - const pages: ImportGlobEagerOutput = { - './routes/contact.server.jsx': STUB_MODULE, - './routes/index.server.jsx': STUB_MODULE, - './routes/products/[...handle].server.jsx': STUB_MODULE, - }; - - const routes = createPageRoutes(pages, '*', './routes'); - expect(routes).toEqual([ - { - path: '/contact', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/products/:handle', - component: STUB_MODULE.default, - exact: false, - }, - ]); -}); - -it('handles nested dynamic paths', () => { - const pages: ImportGlobEagerOutput = { - './routes/contact.server.jsx': STUB_MODULE, - './routes/index.server.jsx': STUB_MODULE, - './routes/products/[handle].server.jsx': STUB_MODULE, - './routes/blogs/[handle]/[articleHandle].server.jsx': STUB_MODULE, - './routes/blogs/[handle]/[...articleHandle].server.jsx': STUB_MODULE, - }; - - const routes = createPageRoutes(pages, '*', './routes'); - - expect(routes).toEqual([ - { - path: '/contact', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/products/:handle', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/blogs/:handle/:articleHandle', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/blogs/:handle/:articleHandle', - component: STUB_MODULE.default, - exact: false, - }, - ]); -}); - -it('prioritizes overrides next to dynamic paths', () => { - const pages: ImportGlobEagerOutput = { - './routes/contact.server.jsx': STUB_MODULE, - './routes/index.server.jsx': STUB_MODULE, - './routes/products/[handle].server.jsx': STUB_MODULE, - // Alphabetically, `hoodie` will likely come after `[handle]` - './routes/products/hoodie.server.jsx': STUB_MODULE, - './routes/blogs/[handle]/[articleHandle].server.jsx': STUB_MODULE, - }; - - const routes = createPageRoutes(pages, '*', './routes'); - - expect(routes).toEqual([ - { - path: '/contact', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/', - component: STUB_MODULE.default, - exact: true, - }, - // But in the routes, it needs to come first! - { - path: '/products/hoodie', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/products/:handle', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/blogs/:handle/:articleHandle', - component: STUB_MODULE.default, - exact: true, - }, - ]); -}); - -it('handles typescript paths', () => { - const pages: ImportGlobEagerOutput = { - './routes/contact.server.tsx': STUB_MODULE, - './routes/index.server.jsx': STUB_MODULE, - }; - - const routes = createPageRoutes(pages, '*', './routes'); - - expect(routes).toEqual([ - { - path: '/contact', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/', - component: STUB_MODULE.default, - exact: true, - }, - ]); -}); - -it('lowercases routes', () => { - const pages: ImportGlobEagerOutput = { - './routes/Contact.server.jsx': STUB_MODULE, - './routes/index.server.jsx': STUB_MODULE, - }; - - const routes = createPageRoutes(pages, '*', './routes'); - - expect(routes).toEqual([ - { - path: '/contact', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/', - component: STUB_MODULE.default, - exact: true, - }, - ]); -}); - -it('factors in the top-level path prefix', () => { - const pages: ImportGlobEagerOutput = { - './routes/contact.server.jsx': STUB_MODULE, - './routes/index.server.jsx': STUB_MODULE, - }; - - const routes = createPageRoutes(pages, '/foo/*', './routes'); - - expect(routes).toEqual([ - { - path: '/foo/contact', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/foo/', - component: STUB_MODULE.default, - exact: true, - }, - ]); -}); - -it('uses a custom file directory path', () => { - const pages: ImportGlobEagerOutput = { - './custom/contact.server.jsx': STUB_MODULE, - './custom/index.server.jsx': STUB_MODULE, - }; - - const routes = createPageRoutes(pages, '*', './custom'); - - expect(routes).toEqual([ - { - path: '/contact', - component: STUB_MODULE.default, - exact: true, - }, - { - path: '/', - component: STUB_MODULE.default, - exact: true, - }, - ]); -}); diff --git a/packages/hydrogen/src/shared-types.ts b/packages/hydrogen/src/shared-types.ts index 69b627e96c..c1b66aea25 100644 --- a/packages/hydrogen/src/shared-types.ts +++ b/packages/hydrogen/src/shared-types.ts @@ -37,3 +37,10 @@ export type ShopifyConfig = { storefrontApiVersion: string; multipassSecret?: string; }; + +export type InlineHydrogenRoutes = + | string + | { + files: string; + basePath?: string; + }; diff --git a/packages/hydrogen/src/types.ts b/packages/hydrogen/src/types.ts index 03cabfb8fa..36aa3dc298 100644 --- a/packages/hydrogen/src/types.ts +++ b/packages/hydrogen/src/types.ts @@ -1,13 +1,13 @@ export * from './shared-types.js'; -import {ShopifyConfig} from './shared-types.js'; - import type {ServerResponse} from 'http'; +import type {InlineHydrogenRoutes, ShopifyConfig} from './shared-types.js'; import type {Logger, LoggerConfig} from './utilities/log/index.js'; import type {HydrogenRequest} from './foundation/HydrogenRequest/HydrogenRequest.server.js'; import type {HydrogenResponse} from './foundation/HydrogenResponse/HydrogenResponse.server.js'; import type {Metafield} from './storefront-api-types.js'; import type {SessionStorageAdapter} from './foundation/session/session-types.js'; import type {PartialDeep, JsonValue} from 'type-fest'; +import type {ResolvedHydrogenRoute} from './utilities/routes.js'; export type AssembleHtmlParams = { ssrHtml: string; @@ -48,19 +48,6 @@ export type ImportGlobEagerOutput = Record< Record<'default' | 'api', any> >; -export type InlineHydrogenRoutes = - | string - | { - files: string; - basePath?: string; - }; - -export type ResolvedHydrogenRoutes = { - files: ImportGlobEagerOutput; - dirPrefix: string; - basePath: string; -}; - type ConfigFetcher = (request: HydrogenRequest) => T | Promise; export type ShopifyConfigFetcher = ConfigFetcher; @@ -86,7 +73,7 @@ export type InlineHydrogenConfig = ClientConfig & { }; export type ResolvedHydrogenConfig = Omit & { - routes: ResolvedHydrogenRoutes; + routes: ResolvedHydrogenRoute[]; }; export type ClientConfig = { diff --git a/packages/hydrogen/src/utilities/apiRoutes.ts b/packages/hydrogen/src/utilities/apiRoutes.ts index 81c35c917e..74d2366856 100644 --- a/packages/hydrogen/src/utilities/apiRoutes.ts +++ b/packages/hydrogen/src/utilities/apiRoutes.ts @@ -1,9 +1,4 @@ -import { - ResolvedHydrogenConfig, - ResolvedHydrogenRoutes, - ImportGlobEagerOutput, -} from '../types.js'; -import {matchPath} from './matchPath.js'; +import type {ResolvedHydrogenConfig} from '../types.js'; import { getLoggerWithContext, logServerResponse, @@ -18,9 +13,7 @@ import type { import {emptySessionImplementation} from '../foundation/session/session.js'; import {UseShopQueryResponse} from '../hooks/useShopQuery/hooks.js'; import {FORM_REDIRECT_COOKIE, RSC_PATHNAME} from '../constants.js'; - -let memoizedApiRoutes: Array = []; -let memoizedRawRoutes: ImportGlobEagerOutput = {}; +import type {RouteMatches} from './routes.js'; type RouteParams = Record; export type RequestOptions = { @@ -34,107 +27,28 @@ export type ResourceGetter = ( requestOptions: RequestOptions ) => Promise; -interface HydrogenApiRoute { - path: string; - resource: ResourceGetter; - hasServerComponent: boolean; -} - export type ApiRouteMatch = { resource: ResourceGetter; - hasServerComponent: boolean; params: RouteParams; }; -export function extractPathFromRoutesKey( - routesKey: string, - dirPrefix: string | RegExp -) { - let path = routesKey - .replace(dirPrefix, '') - .replace(/\.server\.(t|j)sx?$/, '') - /** - * Replace /index with / - */ - .replace(/\/index$/i, '/') - /** - * Only lowercase the first letter. This allows the developer to use camelCase - * dynamic paths while ensuring their standard routes are normalized to lowercase. - */ - .replace(/\b[A-Z]/, (firstLetter) => firstLetter.toLowerCase()) - /** - * Convert /[handle].jsx and /[...handle].jsx to /:handle.jsx for react-router-dom - */ - .replace(/\[(?:[.]{3})?(\w+?)\]/g, (_match, param: string) => `:${param}`); - - if (path.endsWith('/') && path !== '/') { - path = path.substring(0, path.length - 1); - } - - return path; -} - -export function getApiRoutes({ - files: routes, - basePath: topLevelPath = '', - dirPrefix = '', -}: Partial): Array { - if (!routes || memoizedRawRoutes === routes) return memoizedApiRoutes; - - const topLevelPrefix = topLevelPath.replace('*', '').replace(/\/$/, ''); - - const keys = Object.keys(routes); - - const apiRoutes = keys - .filter((key) => routes[key].api) - .map((key) => { - const path = extractPathFromRoutesKey(key, dirPrefix); - - /** - * Catch-all routes [...handle].jsx don't need an exact match - * https://reactrouter.com/core/api/Route/exact-bool - */ - const exact = !/\[(?:[.]{3})(\w+?)\]/.test(key); - - return { - path: topLevelPrefix + path, - resource: routes[key].api, - hasServerComponent: !!routes[key].default, - exact, - }; - }); - - memoizedApiRoutes = [ - ...apiRoutes.filter((route) => !route.path.includes(':')), - ...apiRoutes.filter((route) => route.path.includes(':')), - ]; - - memoizedRawRoutes = routes; - - return memoizedApiRoutes; -} - export function getApiRouteFromURL( - url: URL, - routes: Array + {match: route, details}: RouteMatches, + method: string ): ApiRouteMatch | null { - let foundRoute, foundRouteDetails; - - for (let i = 0; i < routes.length; i++) { - foundRouteDetails = matchPath(url.pathname, routes[i]); - - if (foundRouteDetails) { - foundRoute = routes[i]; - break; - } + if ( + !route || + !details || + !route.resource.api || + // Prioritize server components for GET requests + (method === 'GET' && !!route.resource.default) + ) { + return null; } - if (!foundRoute) return null; - return { - resource: foundRoute.resource, - params: foundRouteDetails.params, - hasServerComponent: foundRoute.hasServerComponent, + resource: route.resource.api as ResourceGetter, + params: details.params, }; } diff --git a/packages/hydrogen/src/utilities/matchPath.ts b/packages/hydrogen/src/utilities/matchPath.ts index 70c8a71a0f..3f1f0e24d0 100644 --- a/packages/hydrogen/src/utilities/matchPath.ts +++ b/packages/hydrogen/src/utilities/matchPath.ts @@ -33,6 +33,13 @@ function compilePath( return result; } +export type RouteMatchDetails = { + path: string; + url: string; + isExact: boolean; + params: Record; +}; + /** * Public API for matching a URL pathname to a path. */ @@ -66,7 +73,7 @@ export function matchPath(pathname: string, options: MatchPathOptions = {}) { params: keys.reduce((memo: any, key, index) => { memo[key.name] = values[index]; return memo; - }, {}), + }, {} as Record), }; - }, null); + }, null as null | RouteMatchDetails); } diff --git a/packages/hydrogen/src/utilities/routes.ts b/packages/hydrogen/src/utilities/routes.ts new file mode 100644 index 0000000000..a4ef18468b --- /dev/null +++ b/packages/hydrogen/src/utilities/routes.ts @@ -0,0 +1,129 @@ +import type {ImportGlobEagerOutput} from '../types.js'; +import {log} from './log/log.js'; +import {matchPath, RouteMatchDetails} from './matchPath.js'; + +export function extractPathFromRoutesKey( + routesKey: string, + dirPrefix: string | RegExp +) { + let path = routesKey + .replace(dirPrefix, '') + .replace(/\.server\.(t|j)sx?$/, '') + /** + * Replace /index with / + */ + .replace(/\/index$/i, '/') + /** + * Only lowercase the first letter. This allows the developer to use camelCase + * dynamic paths while ensuring their standard routes are normalized to lowercase. + */ + .replace(/\b[A-Z]/, (firstLetter) => firstLetter.toLowerCase()) + /** + * Convert /[handle].jsx and /[...handle].jsx to /:handle.jsx for react-router-dom + */ + .replace(/\[(?:[.]{3})?(\w+?)\]/g, (_match, param: string) => `:${param}`); + + if (path.endsWith('/') && path !== '/') { + path = path.substring(0, path.length - 1); + } + + return path; +} + +export type ResolvedHydrogenRoute = { + path: string; + basePath: string; + resource: Record; + exact: boolean; +}; + +type CreateRoutesParams = { + files: ImportGlobEagerOutput; + dirPrefix?: string; + basePath?: string; +}; + +const allMemoizedRoutes = new WeakMap< + ImportGlobEagerOutput, + ResolvedHydrogenRoute[] +>(); + +export function createRoutes({ + files, + basePath = '', + dirPrefix = '', +}: CreateRoutesParams): ResolvedHydrogenRoute[] { + if (!__HYDROGEN_DEV__) { + const memoizedRoutes = allMemoizedRoutes.get(files); + if (memoizedRoutes) return memoizedRoutes; + } + + if (!basePath.startsWith('/')) basePath = '/' + basePath; + const topLevelPrefix = basePath.replace('*', '').replace(/\/$/, ''); + + const keys = Object.keys(files); + + const allRoutes = keys.map((key) => { + const path = extractPathFromRoutesKey(key, dirPrefix); + + /** + * Catch-all routes [...handle].jsx don't need an exact match + * https://reactrouter.com/core/api/Route/exact-bool + */ + const exact = !/\[(?:[.]{3})(\w+?)\]/.test(key); + + if (!files[key].default && !files[key].api) { + log?.warn( + `${key} doesn't export a default React component or an API function` + ); + } + + const result = { + path: topLevelPrefix + path, + basePath: topLevelPrefix, + resource: files[key], + exact, + } as ResolvedHydrogenRoute; + + return result; + }); + + const exactRoutes = []; + const dynamicRoutes = []; + + for (const route of allRoutes) { + if (route.path.includes(':')) { + dynamicRoutes.push(route); + } else { + exactRoutes.push(route); + } + } + + const sortedRoutes = [...exactRoutes, ...dynamicRoutes]; + allMemoizedRoutes.set(files, sortedRoutes); + + return sortedRoutes; +} + +export type RouteMatches = { + match?: ResolvedHydrogenRoute; + details?: RouteMatchDetails; +}; + +export function findRouteMatches( + routes: ResolvedHydrogenRoute[], + pathname: string +): RouteMatches { + let details, match; + + for (const route of routes) { + const matchDetails = matchPath(pathname, route); + if (matchDetails) { + details = matchDetails; + match = route; + break; + } + } + + return {match, details}; +} diff --git a/packages/hydrogen/src/utilities/tests/apiRoutes.vitest.ts b/packages/hydrogen/src/utilities/tests/apiRoutes.vitest.ts deleted file mode 100644 index c1cb4bf255..0000000000 --- a/packages/hydrogen/src/utilities/tests/apiRoutes.vitest.ts +++ /dev/null @@ -1,333 +0,0 @@ -//getAPI -import {ImportGlobEagerOutput} from '../../types.js'; -import {getApiRoutes} from '../apiRoutes.js'; - -const STUB_MODULE = {default: null, api: {}}; - -it('converts API functions to routes', () => { - const files: ImportGlobEagerOutput = { - './routes/contact.server.jsx': STUB_MODULE, - }; - - const routes = getApiRoutes({files, dirPrefix: './routes'}); - - expect(routes).toEqual([ - { - path: '/contact', - exact: true, - hasServerComponent: false, - resource: {}, - }, - ]); -}); - -it('handles index API routes', () => { - const files: ImportGlobEagerOutput = { - './routes/index.server.jsx': STUB_MODULE, - './routes/contact.server.jsx': STUB_MODULE, - './routes/api/index.server.jsx': STUB_MODULE, - }; - - const routes = getApiRoutes({files, dirPrefix: './routes'}); - - expect(routes).toEqual([ - { - path: '/', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/contact', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/api', - exact: true, - hasServerComponent: false, - resource: {}, - }, - ]); -}); - -it('handles nested index API routes', () => { - const files: ImportGlobEagerOutput = { - './routes/products/index.server.jsx': STUB_MODULE, - './routes/products/[handle].server.jsx': STUB_MODULE, - './routes/blogs/index.server.jsx': STUB_MODULE, - './routes/products/snowboards/fastones/index.server.jsx': STUB_MODULE, - './routes/articles/index.server.jsx': STUB_MODULE, - './routes/articles/[...handle].server.jsx': STUB_MODULE, - }; - - const routes = getApiRoutes({files, dirPrefix: './routes'}); - - expect(routes).toEqual([ - { - path: '/products', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/blogs', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/products/snowboards/fastones', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/articles', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/products/:handle', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/articles/:handle', - exact: false, - hasServerComponent: false, - resource: {}, - }, - ]); -}); - -it('handles dynamic paths', () => { - const files: ImportGlobEagerOutput = { - './routes/contact.server.jsx': STUB_MODULE, - './routes/index.server.jsx': STUB_MODULE, - './routes/products/[handle].server.jsx': STUB_MODULE, - }; - - const routes = getApiRoutes({files, dirPrefix: './routes'}); - expect(routes).toEqual([ - { - path: '/contact', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/products/:handle', - exact: true, - hasServerComponent: false, - resource: {}, - }, - ]); -}); - -it('handles catch all routes', () => { - const files: ImportGlobEagerOutput = { - './routes/contact.server.jsx': STUB_MODULE, - './routes/index.server.jsx': STUB_MODULE, - './routes/products/[...handle].server.jsx': STUB_MODULE, - }; - - const routes = getApiRoutes({files, dirPrefix: './routes'}); - expect(routes).toEqual([ - { - path: '/contact', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/products/:handle', - exact: false, - hasServerComponent: false, - resource: {}, - }, - ]); -}); - -it('handles nested dynamic paths', () => { - const files: ImportGlobEagerOutput = { - './routes/contact.server.jsx': STUB_MODULE, - './routes/index.server.jsx': STUB_MODULE, - './routes/products/[handle].server.jsx': STUB_MODULE, - './routes/blogs/[handle]/[articleHandle].server.jsx': STUB_MODULE, - './routes/blogs/[handle]/[...articleHandle].server.jsx': STUB_MODULE, - }; - - const routes = getApiRoutes({files, dirPrefix: './routes'}); - - expect(routes).toEqual([ - { - path: '/contact', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/products/:handle', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/blogs/:handle/:articleHandle', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/blogs/:handle/:articleHandle', - exact: false, - hasServerComponent: false, - resource: {}, - }, - ]); -}); - -it('prioritizes overrides next to dynamic paths', () => { - const files: ImportGlobEagerOutput = { - './routes/contact.server.jsx': STUB_MODULE, - './routes/index.server.jsx': STUB_MODULE, - './routes/products/[handle].server.jsx': STUB_MODULE, - // Alphabetically, `hoodie` will likely come after `[handle]` - './routes/products/hoodie.server.jsx': STUB_MODULE, - './routes/blogs/[handle]/[articleHandle].server.jsx': STUB_MODULE, - }; - - const routes = getApiRoutes({files, dirPrefix: './routes'}); - - expect(routes).toEqual([ - { - path: '/contact', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/', - exact: true, - hasServerComponent: false, - resource: {}, - }, - // But in the routes, it needs to come first! - { - path: '/products/hoodie', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/products/:handle', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/blogs/:handle/:articleHandle', - exact: true, - hasServerComponent: false, - resource: {}, - }, - ]); -}); - -it('handles typescript paths', () => { - const files: ImportGlobEagerOutput = { - './routes/contact.server.tsx': STUB_MODULE, - './routes/index.server.jsx': STUB_MODULE, - }; - - const routes = getApiRoutes({files, dirPrefix: './routes'}); - - expect(routes).toEqual([ - { - path: '/contact', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/', - exact: true, - hasServerComponent: false, - resource: {}, - }, - ]); -}); - -it('lowercases routes', () => { - const files: ImportGlobEagerOutput = { - './routes/Contact.server.jsx': STUB_MODULE, - './routes/index.server.jsx': STUB_MODULE, - }; - - const routes = getApiRoutes({files, dirPrefix: './routes'}); - - expect(routes).toEqual([ - { - path: '/contact', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/', - exact: true, - hasServerComponent: false, - resource: {}, - }, - ]); -}); - -it('factors in the top-level path prefix', () => { - const files: ImportGlobEagerOutput = { - './routes/contact.server.jsx': STUB_MODULE, - './routes/index.server.jsx': STUB_MODULE, - }; - - const routes = getApiRoutes({ - files, - basePath: '/foo/*', - dirPrefix: './routes', - }); - - expect(routes).toEqual([ - { - path: '/foo/contact', - exact: true, - hasServerComponent: false, - resource: {}, - }, - { - path: '/foo/', - exact: true, - hasServerComponent: false, - resource: {}, - }, - ]); -}); diff --git a/packages/hydrogen/src/utilities/tests/routes.vitest.ts b/packages/hydrogen/src/utilities/tests/routes.vitest.ts new file mode 100644 index 0000000000..d5c34d27e3 --- /dev/null +++ b/packages/hydrogen/src/utilities/tests/routes.vitest.ts @@ -0,0 +1,694 @@ +import type {ImportGlobEagerOutput} from '../../types.js'; +import {createRoutes} from '../routes.js'; + +const STUB_MODULE_COMPONENT_ONLY = {default: {}, api: null}; +const STUB_MODULE_API_ONLY = {default: null, api: {}}; + +describe('Page routes', () => { + it('converts normal pages to routes', () => { + const files: ImportGlobEagerOutput = { + './routes/contact.server.jsx': STUB_MODULE_COMPONENT_ONLY, + }; + + const routes = createRoutes({ + files, + basePath: '*', + dirPrefix: './routes', + }); + + expect(routes).toEqual([ + { + path: '/contact', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + ]); + }); + + it('handles index pages', () => { + const files: ImportGlobEagerOutput = { + './routes/contact.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/index.server.jsx': STUB_MODULE_COMPONENT_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + + expect(routes).toEqual([ + { + path: '/contact', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + ]); + }); + + it('handles nested index pages', () => { + const files: ImportGlobEagerOutput = { + './routes/products/index.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/products/[handle].server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/blogs/index.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/products/snowboards/fastones/index.server.jsx': + STUB_MODULE_COMPONENT_ONLY, + './routes/articles/index.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/articles/[...handle].server.jsx': STUB_MODULE_COMPONENT_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + + expect(routes).toEqual([ + { + path: '/products', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/blogs', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/products/snowboards/fastones', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/articles', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/products/:handle', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/articles/:handle', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: false, + }, + ]); + }); + + it('handles dynamic paths', () => { + const files: ImportGlobEagerOutput = { + './routes/contact.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/index.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/products/[handle].server.jsx': STUB_MODULE_COMPONENT_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + expect(routes).toEqual([ + { + path: '/contact', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/products/:handle', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + ]); + }); + + it('handles catch all routes', () => { + const files: ImportGlobEagerOutput = { + './routes/contact.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/index.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/products/[...handle].server.jsx': STUB_MODULE_COMPONENT_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + expect(routes).toEqual([ + { + path: '/contact', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/products/:handle', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: false, + }, + ]); + }); + + it('handles nested dynamic paths', () => { + const files: ImportGlobEagerOutput = { + './routes/contact.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/index.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/products/[handle].server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/blogs/[handle]/[articleHandle].server.jsx': + STUB_MODULE_COMPONENT_ONLY, + './routes/blogs/[handle]/[...articleHandle].server.jsx': + STUB_MODULE_COMPONENT_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + + expect(routes).toEqual([ + { + path: '/contact', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/products/:handle', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/blogs/:handle/:articleHandle', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/blogs/:handle/:articleHandle', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: false, + }, + ]); + }); + + it('prioritizes overrides next to dynamic paths', () => { + const files: ImportGlobEagerOutput = { + './routes/contact.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/index.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/products/[handle].server.jsx': STUB_MODULE_COMPONENT_ONLY, + // Alphabetically, `hoodie` will likely come after `[handle]` + './routes/products/hoodie.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/blogs/[handle]/[articleHandle].server.jsx': + STUB_MODULE_COMPONENT_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + + expect(routes).toEqual([ + { + path: '/contact', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + // But in the routes, it needs to come first! + { + path: '/products/hoodie', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/products/:handle', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/blogs/:handle/:articleHandle', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + ]); + }); + + it('handles typescript paths', () => { + const files: ImportGlobEagerOutput = { + './routes/contact.server.tsx': STUB_MODULE_COMPONENT_ONLY, + './routes/index.server.jsx': STUB_MODULE_COMPONENT_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + + expect(routes).toEqual([ + { + path: '/contact', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + ]); + }); + + it('lowercases routes', () => { + const files: ImportGlobEagerOutput = { + './routes/Contact.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/index.server.jsx': STUB_MODULE_COMPONENT_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + + expect(routes).toEqual([ + { + path: '/contact', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + ]); + }); + + it('factors in the top-level path prefix', () => { + const files: ImportGlobEagerOutput = { + './routes/contact.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './routes/index.server.jsx': STUB_MODULE_COMPONENT_ONLY, + }; + + const routes = createRoutes({ + files, + basePath: '/foo/*', + dirPrefix: './routes', + }); + + expect(routes).toEqual([ + { + path: '/foo/contact', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '/foo', + exact: true, + }, + { + path: '/foo/', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '/foo', + exact: true, + }, + ]); + }); + + it('uses a custom file directory path', () => { + const files: ImportGlobEagerOutput = { + './custom/contact.server.jsx': STUB_MODULE_COMPONENT_ONLY, + './custom/index.server.jsx': STUB_MODULE_COMPONENT_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './custom'}); + + expect(routes).toEqual([ + { + path: '/contact', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + { + path: '/', + resource: STUB_MODULE_COMPONENT_ONLY, + basePath: '', + exact: true, + }, + ]); + }); +}); + +describe('API routes', () => { + it('converts API functions to routes', () => { + const files: ImportGlobEagerOutput = { + './routes/contact.server.jsx': STUB_MODULE_API_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + + expect(routes).toEqual([ + { + path: '/contact', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + ]); + }); + + it('handles index API routes', () => { + const files: ImportGlobEagerOutput = { + './routes/index.server.jsx': STUB_MODULE_API_ONLY, + './routes/contact.server.jsx': STUB_MODULE_API_ONLY, + './routes/api/index.server.jsx': STUB_MODULE_API_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + + expect(routes).toEqual([ + { + path: '/', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/contact', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/api', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + ]); + }); + + it('handles nested index API routes', () => { + const files: ImportGlobEagerOutput = { + './routes/products/index.server.jsx': STUB_MODULE_API_ONLY, + './routes/products/[handle].server.jsx': STUB_MODULE_API_ONLY, + './routes/blogs/index.server.jsx': STUB_MODULE_API_ONLY, + './routes/products/snowboards/fastones/index.server.jsx': + STUB_MODULE_API_ONLY, + './routes/articles/index.server.jsx': STUB_MODULE_API_ONLY, + './routes/articles/[...handle].server.jsx': STUB_MODULE_API_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + + expect(routes).toEqual([ + { + path: '/products', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/blogs', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/products/snowboards/fastones', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/articles', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/products/:handle', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/articles/:handle', + exact: false, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + ]); + }); + + it('handles dynamic paths', () => { + const files: ImportGlobEagerOutput = { + './routes/contact.server.jsx': STUB_MODULE_API_ONLY, + './routes/index.server.jsx': STUB_MODULE_API_ONLY, + './routes/products/[handle].server.jsx': STUB_MODULE_API_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + expect(routes).toEqual([ + { + path: '/contact', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/products/:handle', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + ]); + }); + + it('handles catch all routes', () => { + const files: ImportGlobEagerOutput = { + './routes/contact.server.jsx': STUB_MODULE_API_ONLY, + './routes/index.server.jsx': STUB_MODULE_API_ONLY, + './routes/products/[...handle].server.jsx': STUB_MODULE_API_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + expect(routes).toEqual([ + { + path: '/contact', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/products/:handle', + exact: false, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + ]); + }); + + it('handles nested dynamic paths', () => { + const files: ImportGlobEagerOutput = { + './routes/contact.server.jsx': STUB_MODULE_API_ONLY, + './routes/index.server.jsx': STUB_MODULE_API_ONLY, + './routes/products/[handle].server.jsx': STUB_MODULE_API_ONLY, + './routes/blogs/[handle]/[articleHandle].server.jsx': + STUB_MODULE_API_ONLY, + './routes/blogs/[handle]/[...articleHandle].server.jsx': + STUB_MODULE_API_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + + expect(routes).toEqual([ + { + path: '/contact', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/products/:handle', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/blogs/:handle/:articleHandle', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/blogs/:handle/:articleHandle', + exact: false, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + ]); + }); + + it('prioritizes overrides next to dynamic paths', () => { + const files: ImportGlobEagerOutput = { + './routes/contact.server.jsx': STUB_MODULE_API_ONLY, + './routes/index.server.jsx': STUB_MODULE_API_ONLY, + './routes/products/[handle].server.jsx': STUB_MODULE_API_ONLY, + // Alphabetically, `hoodie` will likely come after `[handle]` + './routes/products/hoodie.server.jsx': STUB_MODULE_API_ONLY, + './routes/blogs/[handle]/[articleHandle].server.jsx': + STUB_MODULE_API_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + + expect(routes).toEqual([ + { + path: '/contact', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + // But in the routes, it needs to come first! + { + path: '/products/hoodie', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/products/:handle', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/blogs/:handle/:articleHandle', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + ]); + }); + + it('handles typescript paths', () => { + const files: ImportGlobEagerOutput = { + './routes/contact.server.tsx': STUB_MODULE_API_ONLY, + './routes/index.server.jsx': STUB_MODULE_API_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + + expect(routes).toEqual([ + { + path: '/contact', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + ]); + }); + + it('lowercases routes', () => { + const files: ImportGlobEagerOutput = { + './routes/Contact.server.jsx': STUB_MODULE_API_ONLY, + './routes/index.server.jsx': STUB_MODULE_API_ONLY, + }; + + const routes = createRoutes({files, dirPrefix: './routes'}); + + expect(routes).toEqual([ + { + path: '/contact', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + { + path: '/', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '', + }, + ]); + }); + + it('factors in the top-level path prefix', () => { + const files: ImportGlobEagerOutput = { + './routes/contact.server.jsx': STUB_MODULE_API_ONLY, + './routes/index.server.jsx': STUB_MODULE_API_ONLY, + }; + + const routes = createRoutes({ + files, + basePath: '/foo/*', + dirPrefix: './routes', + }); + + expect(routes).toEqual([ + { + path: '/foo/contact', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '/foo', + }, + { + path: '/foo/', + exact: true, + resource: STUB_MODULE_API_ONLY, + basePath: '/foo', + }, + ]); + }); +});