one
Version:
One is a new React Framework that makes Vite serve both native and web.
316 lines (264 loc) • 9.66 kB
text/typescript
/**
* Copyright © 2023 Tamagui LLC.
* Copyright © 2023 650 Industries.
* Copyright © 2023 Vercel, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* Based on https://github.com/vercel/next.js/blob/1df2686bc9964f1a86c444701fa5cbf178669833/packages/next/src/shared/lib/router/utils/route-regex.ts
*/
import { getContextKey, matchGroupName } from '../router/matchers'
import type { RouteNode } from '../router/Route'
import { sortRoutes } from '../router/sortRoutes'
import type { One, RouteInfo } from '../vite/types'
// TODO: Share these types across cli, server, router, etc.
export type OneRouterServerManifestV1Route<TRegex = string> = RouteInfo & {
generated?: boolean
}
export type OneRouterServerManifestV1<TRegex = string> = {
apiRoutes: OneRouterServerManifestV1Route<TRegex>[]
middlewareRoutes: OneRouterServerManifestV1Route<TRegex>[]
pageRoutes: OneRouterServerManifestV1Route<TRegex>[]
allRoutes: OneRouterServerManifestV1Route<TRegex>[]
}
export interface Group {
pos: number
repeat: boolean
optional: boolean
}
export interface RouteRegex {
groups: Record<string, Group>
re: RegExp
}
function isNotFoundRoute(route: RouteNode) {
return Boolean(route.dynamic && route.dynamic[route.dynamic.length - 1].notFound)
}
// Given a nested route tree, return a flattened array of all routes that can be matched.
export function getServerManifest(route: RouteNode): OneRouterServerManifestV1 {
function getFlatNodes(route: RouteNode, layouts?: RouteNode[]): [string, RouteNode][] {
if (route.children.length) {
return route.children.flatMap((child) => {
return getFlatNodes(child, [...(layouts || []), route])
})
}
// API Routes are handled differently to HTML routes because they have no nested behavior.
// An HTML route can be different based on parent segments due to layout routes, therefore multiple
// copies should be rendered. However, an API route is always the same regardless of parent segments.
let key: string
if (route.type === 'api') {
key = getContextKey(route.contextKey).replace(/\/index$/, '') || '/'
} else {
const parentSegments = layouts?.flatMap((route) => {
const key = getContextKey(route.route).replace(/\/index$/, '') || '/'
if (key === '/' || key.startsWith('/(')) {
return []
}
return [key]
})
key =
(parentSegments ? parentSegments.join('') : '') +
getContextKey(route.route).replace(/\/index$/, '') || '/'
}
return [[key, { ...route, layouts }]]
}
// TODO this could be a lot faster if not functional:
// Remove duplicates from the runtime manifest which expands array syntax.
const flat = getFlatNodes(route)
.sort(([, a], [, b]) => sortRoutes(b, a))
.reverse()
// warn on having multiple routes with the same path!
const pathToRoute: Record<string, RouteNode> = {}
for (const [path, route] of flat) {
if (pathToRoute[path]) {
console.warn(`\n[one] ❌ Duplicate routes error`)
console.warn(` Multiple routes at the same path! One route will always win over the other.`)
console.warn(` path: ${path}`)
console.warn(` first route: ${pathToRoute[path].contextKey}`)
console.warn(` second route: ${route.contextKey}\n`)
}
pathToRoute[path] = route
}
const apiRoutes: OneRouterServerManifestV1Route[] = []
const middlewareRoutes: OneRouterServerManifestV1Route[] = []
const pageRoutes: OneRouterServerManifestV1Route[] = []
const allRoutes: OneRouterServerManifestV1Route[] = []
const addedMiddlewares: Record<string, boolean> = {}
for (const [path, node] of flat) {
if (node.type === 'api') {
const route = getGeneratedNamedRouteRegex(path, node)
apiRoutes.push(route)
allRoutes.push(route)
continue
}
if (node.middlewares?.length) {
for (const middleware of node.middlewares) {
if (!addedMiddlewares[middleware.contextKey]) {
addedMiddlewares[middleware.contextKey] = true
middlewareRoutes.push(getGeneratedNamedRouteRegex(path, middleware))
}
}
}
const route = getGeneratedNamedRouteRegex(path, node)
pageRoutes.push(route)
allRoutes.push(route)
}
return {
apiRoutes,
middlewareRoutes,
pageRoutes,
allRoutes,
}
}
function getGeneratedNamedRouteRegex(
normalizedRoute: string,
node: RouteNode
): OneRouterServerManifestV1Route {
return {
...getRouteEntry(normalizedRoute, node),
generated: true,
isNotFound: isNotFoundRoute(node),
}
}
function getRouteEntry(normalizedRoute: string, node: RouteNode): OneRouterServerManifestV1Route {
const result = getPathMeta(normalizedRoute)
return {
file: node.contextKey,
page: getContextKey(node.route),
type: node.type,
namedRegex: result.namedRegex,
urlPath: result.urlPath,
urlCleanPath: result.urlCleanPath,
routeKeys: result.routeKeys,
layouts: node.layouts,
middlewares: node.middlewares,
}
}
/**
* Builds a function to generate a minimal routeKey using only a-z and minimal
* number of characters.
*/
function buildGetSafeRouteKey() {
let currentCharCode = 96 // Starting one before 'a' to make the increment logic simpler
let currentLength = 1
return () => {
let result = ''
let incrementNext = true
// Iterate from right to left to build the key
for (let i = 0; i < currentLength; i++) {
if (incrementNext) {
currentCharCode++
if (currentCharCode > 122) {
currentCharCode = 97 // Reset to 'a'
incrementNext = true // Continue to increment the next character
} else {
incrementNext = false
}
}
result = String.fromCharCode(currentCharCode) + result
}
// If all characters are 'z', increase the length of the key
if (incrementNext) {
currentLength++
currentCharCode = 96 // This will make the next key start with 'a'
}
return result
}
}
function removeTrailingSlash(route: string): string {
return route.replace(/\/$/, '') || '/'
}
function getPathMeta(route: string) {
const segments = removeTrailingSlash(route).slice(1).split('/')
const getSafeRouteKey = buildGetSafeRouteKey()
const routeKeys: Record<string, string> = {}
const urlPathParts: Array<{ content: string; type?: 'group' }> = []
const routeSegments = segments
.map((segment, index) => {
if (segment === '+not-found' && index === segments.length - 1) {
segment = '[...not-found]'
}
if (/^\[.*\]$/.test(segment)) {
const { name, optional, repeat } = parseParam(segment)
// replace non-word characters since they can break the named regex
let cleanedKey = name.replace(/\W/g, '')
let invalidKey = false
// check if the key is still invalid and fallback to using a known safe key
if (cleanedKey.length === 0 || cleanedKey.length > 30) {
invalidKey = true
}
if (!Number.isNaN(Number.parseInt(cleanedKey.slice(0, 1), 10))) {
invalidKey = true
}
// Prevent duplicates after sanitizing the key
if (cleanedKey in routeKeys) {
invalidKey = true
}
if (invalidKey) {
cleanedKey = getSafeRouteKey()
}
urlPathParts.push({ content: repeat ? '/*' : `/:${name}${optional ? '?' : ''}` })
routeKeys[cleanedKey] = name
return repeat
? optional
? `(?:/(?<${cleanedKey}>.+?))?`
: `/(?<${cleanedKey}>.+?)`
: `/(?<${cleanedKey}>[^/]+?)`
}
if (insideParensRegex.test(segment)) {
const groupName = matchGroupName(segment)!
.split(',')
.map((group) => group.trim())
.filter(Boolean)
urlPathParts.push({ content: `/:${groupName}?`, type: 'group' })
if (groupName.length > 1) {
const optionalSegment = `\\((?:${groupName.map(escapeStringRegexp).join('|')})\\)`
// Make section optional
return `(?:/${optionalSegment})?`
}
// Use simpler regex for single groups
return `(?:/${escapeStringRegexp(segment)})?`
}
urlPathParts.push({ content: `/${segment}` })
return `/${escapeStringRegexp(segment)}`
})
.join('')
const urlPath = urlPathParts.map((p) => p.content).join('')
const urlCleanPath = urlPathParts
.filter((p) => p.type !== 'group')
.map((p) => p.content)
.join('')
return {
namedRegex: `^${routeSegments}(?:/)?$`,
urlPath: urlPath === '' ? '/' : urlPath,
urlCleanPath: urlCleanPath === '' ? '/' : urlCleanPath,
routeKeys,
}
}
const insideBracketsRegex = /^\[.*\]$/
const insideParensRegex = /^\(.*\)$/
const tripleDotRegex = /^\.\.\./
const replaceRegex = /[|\\{}()[\]^$+*?.-]/g
// based on https://github.com/sindresorhus/escape-string-regexp
const hasRegExpRegex = /[|\\{}()[\]^$+*?.-]/
function escapeStringRegexp(str: string) {
// see also: https://github.com/lodash/lodash/blob/2da024c3b4f9947a48517639de7560457cd4ec6c/escapeRegExp.js#L23
if (hasRegExpRegex.test(str)) {
return str.replace(replaceRegex, '\\$&')
}
return str
}
export function parseParam(param: string) {
let repeat = false
let optional = false
let name = param
if (insideBracketsRegex.test(name)) {
optional = true
name = name.slice(1, -1)
}
if (tripleDotRegex.test(name)) {
repeat = true
name = name.slice(3)
}
return { name, repeat, optional }
}