diff --git a/README.md b/README.md index dd535c60cf5..a0a76505d6c 100644 --- a/README.md +++ b/README.md @@ -655,7 +655,7 @@ For full programmatic flexibility, you can define route functions. // /pages/admin.page.route.js // Route functions allow us to implement advanced routing such as route guards. -export default async ({ url, contextProps }) => { +export default ({ url, contextProps }) => { if (url==='/admin' && contextProps.user.isAdmin) { return { match: true } } @@ -1059,7 +1059,7 @@ Route functions give you full programmatic flexibility to define your routing lo ```js // /pages/film/admin.page.route.js -export default async ({ url, contextProps }) { +export default ({ url, contextProps }) { // Route functions allow us to implement advanced routing such as route guards. if (! contextProps.user.isAdmin) { return {match: false} diff --git a/examples/react/pages/hello/index.page.route.ts b/examples/react/pages/hello/index.page.route.ts index df389082a1d..6b0f57955d1 100644 --- a/examples/react/pages/hello/index.page.route.ts +++ b/examples/react/pages/hello/index.page.route.ts @@ -1 +1,9 @@ -export default "/hello/:name"; +// Route Functions give us full flexibility +// This is a route similar to `/hello/:name` but with details impossible to achieve with a route string. +export default ({ url }: { url: string }) => { + if (!url.startsWith("/hello")) { + return { match: false }; + } + const name = url.split("/")[2] || "anonymous"; + return { match: true, contextProps: { name } }; +}; diff --git a/examples/vue/pages/hello/index.page.route.ts b/examples/vue/pages/hello/index.page.route.ts index 7efb65330e1..3544f13fa13 100644 --- a/examples/vue/pages/hello/index.page.route.ts +++ b/examples/vue/pages/hello/index.page.route.ts @@ -1 +1,9 @@ -export default '/hello/:name' +// Route Functions give us full flexibility +// This is a route similar to `/hello/:name` but with details impossible to achieve with a route string. +export default ({ url }: { url: string }) => { + if (!url.startsWith('/hello')) { + return { match: false } + } + const name = url.split('/')[2] || 'anonymous' + return { match: true, contextProps: { name } } +} diff --git a/src/render.node.ts b/src/render.node.ts index 70cadfe70a6..af20661c968 100644 --- a/src/render.node.ts +++ b/src/render.node.ts @@ -32,7 +32,7 @@ async function render({ // written by the user and may contain errors. let routeResult try { - routeResult = await route(url) + routeResult = await route(url, contextProps) } catch (err) { return await renderErrorPage(err, contextProps, url) } @@ -41,8 +41,8 @@ async function render({ return null } - const { pageId, routeProps } = routeResult - Object.assign(contextProps, routeProps) + const { pageId, contextPropsAddendum } = routeResult + Object.assign(contextProps, contextPropsAddendum) // We use a try-catch because `renderPage()` executes `*.page.*` files which are // written by the user and may contain errors. diff --git a/src/route.node.ts b/src/route.node.ts index 9bd60760da7..c4d5d1f7bf5 100644 --- a/src/route.node.ts +++ b/src/route.node.ts @@ -4,11 +4,11 @@ import pathToRegexp from '@brillout/path-to-regexp' import { assert, assertUsage, - cast, isCallable, higherFirst, slice, - assertWarning + assertWarning, + hasProp } from './utils' import { getGlobal } from './global.node' @@ -16,14 +16,14 @@ export { route } export { getErrorPageId } type PageId = string -type RouteResult = { - matchValue: boolean | number - routeProps: Record -} async function route( - url: string -): Promise }> { + url: string, + contextProps: Record +): Promise +}> { const allPageIds = await getPageIds() assertUsage( allPageIds.length > 0, @@ -36,7 +36,7 @@ async function route( .map((pageId) => { // Route 404 if (is404Page(pageId)) { - return { pageId, matchValue: -Infinity, routeProps: {} } + return { pageId, matchValue: -Infinity, contextPropsAddendum: {} } } else { assertUsage( !isReservedPageId(pageId), @@ -47,25 +47,28 @@ async function route( // Route with filesystem if (!(pageId in pageRoutes)) { const matchValue = routeWith_filesystem(url, pageId, allPageIds) - return { pageId, matchValue, routeProps: {} } + return { pageId, matchValue, contextPropsAddendum: {} } } - const pageRoute = pageRoutes[pageId] + const { pageRoute, pageRouteFile } = pageRoutes[pageId] // Route with `.page.route.js` defined route string if (typeof pageRoute === 'string') { - const routeString: string = pageRoute - const { matchValue, routeProps } = routeWith_pathToRegexp( - url, - routeString + const { matchValue, contextPropsAddendum } = resolveRouteString( + pageRoute, + url ) - return { pageId, matchValue, routeProps } + return { pageId, matchValue, contextPropsAddendum } } // Route with `.page.route.js` defined route function if (isCallable(pageRoute)) { - const routeFunction = pageRoute - const { matchValue, routeProps } = routeFunction(url) - return { pageId, matchValue, routeProps } + const { matchValue, contextPropsAddendum } = resolveRouteFunction( + pageRoute, + url, + contextProps, + pageRouteFile + ) + return { pageId, matchValue, contextPropsAddendum } } assert(false) @@ -80,8 +83,8 @@ async function route( if (!winner) return null - const { pageId, routeProps } = winner - return { pageId, routeProps } + const { pageId, contextPropsAddendum } = winner + return { pageId, contextPropsAddendum } } function userHintNoPageFound(url: string, allPageIds: string[]) { @@ -121,9 +124,9 @@ async function getErrorPageId(): Promise { return null } -function pickWinner( - routeResults: (RouteResult & { pageId: PageId })[] -): RouteResult & { pageId: PageId } { +function pickWinner( + routeResults: T[] +): T { const candidates = routeResults .filter(({ matchValue }) => matchValue !== false) .sort( @@ -138,7 +141,10 @@ function pickWinner( return winner } -function routeWith_pathToRegexp(url: string, routeString: string): RouteResult { +function routeWith_pathToRegexp( + url: string, + routeString: string +): { matchValue: false | number; routeProps: Record } { const match = pathToRegexp(url, { path: routeString, exact: true }) if (!match) { return { matchValue: false, routeProps: {} } @@ -216,8 +222,65 @@ function isDefaultPageFile(filePath: string): boolean { return true } +function resolveRouteString(routeString: string, url: string) { + const { matchValue, routeProps } = routeWith_pathToRegexp(url, routeString) + const contextPropsAddendum = routeProps + return { matchValue, contextPropsAddendum } +} +function resolveRouteFunction( + routeFunction: Function, + url: string, + contextProps: Record, + routeFilePath: string +): { + matchValue: boolean | number + contextPropsAddendum: Record +} { + const result = routeFunction({ url, contextProps }) + assertUsage( + typeof result === 'object' && + result !== null && + result.constructor === Object, + `The Route Function ${routeFilePath} should return a plain JavaScript object, e.g. \`{ match: true }\`.` + ) + assertUsage( + hasProp(result, 'match'), + `The Route Function ${routeFilePath} should return a \`{ match }\` value.` + ) + assertUsage( + typeof result.match === 'boolean' || typeof result.match === 'number', + `The \`match\` value returned by the Route Function ${routeFilePath} should be a boolean or a number.` + ) + let contextPropsAddendum = {} + if (hasProp(result, 'contextProps')) { + assertUsage( + typeof result.contextProps === 'object' && + result.contextProps !== null && + result.contextProps.constructor === Object, + `The \`contextProps\` returned by the Route function ${routeFilePath} should be a plain JavaScript object.` + ) + contextPropsAddendum = result.contextProps + } + Object.keys(result).forEach((key) => { + assertUsage( + key === 'match' || key === 'contextProps', + `The Route Function ${routeFilePath} returned an object with an unknown key \`{ ${key} }\`. Allowed keys: ['match', 'contextProps'].` + ) + }) + return { + matchValue: result.match, + contextPropsAddendum + } +} + async function loadPageRoutes(): Promise< - Record RouteResult)> + Record< + PageId, + { + pageRouteFile: string + pageRoute: string | Function + } + > > { const userRouteFiles = await getUserFiles('.page.route') @@ -225,56 +288,26 @@ async function loadPageRoutes(): Promise< userRouteFiles.map(async ({ filePath, loadFile }) => { const fileExports = await loadFile() assertUsage( - typeof fileExports === 'object' && 'default' in fileExports, + hasProp(fileExports, 'default'), `${filePath} should have a default export.` ) - - let pageRoute - if (typeof fileExports.default === 'string') { - pageRoute = fileExports.default - } else if (isCallable(fileExports.default)) { - pageRoute = (url: string) => { - const result = fileExports.default(url) - const { match, params } = result - assertUsage( - typeof match === 'boolean' || typeof match === 'number', - `\`match\` returned by the \`route\` function in ${filePath} should be a boolean or a number.` - ) - assertUsage( - params?.constructor === Object, - `\`params\` returned by the \`route\` function in ${filePath} should be an object.` - ) - Object.entries(params).forEach(([key, val]) => { - assertUsage( - typeof val === 'string', - `\`params.${key}\` returned by the \`route\` function in ${filePath} should be a string.` - ) - }) - cast>(params) - assertUsage( - Object.keys(result).length === 2, - `The \`route\` function in ${filePath} should be return an object \`{match, params}\`.` - ) - return { - matchValue: match, - routeProps: params - } - } - } else { - assertUsage( - false, - `\`route\` defined in ${filePath} should be a string or a function.` - ) - } - + assertUsage( + typeof fileExports.default === 'string' || + isCallable(fileExports.default), + `The default export of ${filePath} should be a string or a function.` + ) + const pageRoute = fileExports.default const pageId = computePageId(filePath) - - return { pageId, pageRoute } + const pageRouteFile = filePath + return { pageId, pageRoute, pageRouteFile } }) ) const routeFiles = Object.fromEntries( - pageRoutes.map(({ pageId, pageRoute }) => [pageId, pageRoute]) + pageRoutes.map(({ pageId, pageRoute, pageRouteFile }) => [ + pageId, + { pageRoute, pageRouteFile } + ]) ) return routeFiles diff --git a/todo.md b/todo.md index 885dc9b908f..c1b1b2c9a88 100644 --- a/todo.md +++ b/todo.md @@ -1,6 +1,4 @@ - Test wrong usages DX -- Pass {url, contextProps} to route functions -- Rename {params} to {addContextProps} in route functions - Assert that `setPageProps` never returns a promise - Add assertUsage when not instantiating the `ssr` vite plugin - Add assertUsage contextProps to return a plain javascript object