one
Version:
One is a new React Framework that makes Vite serve both native and web.
393 lines (343 loc) • 12.1 kB
text/typescript
/**
* This file exports things that will be used to modify the forked code in `getStateFromPath.ts`.
*
* The purpose of keeping things in this separated file is to keep changes to the copied code as little as possible, making merging upstream updates easier.
*/
import escape_ from 'escape-string-regexp'
import { matchGroupName, stripGroupSegmentsFromPath } from '../router/matchers'
import type { RouteConfig, ParsedRoute, InitialRouteConfig } from './getStateFromPath'
export type AdditionalRouteConfig = {
type: 'static' | 'dynamic' | 'layout'
userReadableName: string
isIndex: boolean
isInitial?: boolean
hasChildren: boolean
expandedRouteNames: string[]
parts: string[]
}
export function getUrlWithReactNavigationConcessions(
path: string,
baseUrl: string | undefined = process.env.EXPO_BASE_URL
) {
let parsed: URL
try {
parsed = new URL(path, 'https://phony.example')
} catch {
// Do nothing with invalid URLs.
return {
path,
cleanUrl: '',
nonstandardPathname: '',
url: new URL('https://phony.example'),
}
}
const pathname = parsed.pathname
const withoutBaseUrl = stripBaseUrl(pathname, baseUrl)
const pathWithoutGroups = stripGroupSegmentsFromPath(stripBaseUrl(path, baseUrl))
// Make sure there is a trailing slash
return {
// The slashes are at the end, not the beginning
path,
nonstandardPathname: withoutBaseUrl.replace(/^\/+/g, '').replace(/\/+$/g, '') + '/',
url: parsed,
pathWithoutGroups,
}
}
export function matchForEmptyPath(configs: RouteConfig[]) {
// We need to add special handling of empty path so navigation to empty path also works
// When handling empty path, we should only look at the root level config
// NOTE: We only care about matching leaf nodes.
const leafNodes = configs
.filter((config) => !config.hasChildren)
.map((value) => {
return {
...value,
// Collapse all levels of group segments before testing.
// This enables `app/(one)/(two)/index.js` to be matched.
path: stripGroupSegmentsFromPath(value.path),
}
})
const match =
leafNodes.find(
(config) =>
// NOTE: Test leaf node index routes that either don't have a regex or match an empty string.
config.path === '' && (!config.regex || config.regex.test(''))
) ??
leafNodes.find(
(config) =>
// NOTE: Test leaf node dynamic routes that match an empty string.
config.path.startsWith(':') && config.regex!.test('')
) ??
// NOTE: Test leaf node deep dynamic routes that match a slash.
// This should be done last to enable dynamic routes having a higher priority.
leafNodes.find((config) => config.path.startsWith('*') && config.regex!.test('/'))
return match
}
export function appendIsInitial(initialRoutes: InitialRouteConfig[]) {
const resolvedInitialPatterns = initialRoutes.map((route) =>
joinPaths(...route.parentScreens, route.initialRouteName)
)
return (config: RouteConfig) => {
// TODO: Probably a safer way to do this
// Mark initial routes to give them potential priority over other routes that match.
config.isInitial = resolvedInitialPatterns.includes(config.routeNames.join('/'))
return config
}
}
const joinPaths = (...paths: string[]): string =>
([] as string[])
.concat(...paths.map((p) => p.split('/')))
.filter(Boolean)
.join('/')
export function getRouteConfigSorter(previousSegments: string[] = []) {
return function sortConfigs(a: RouteConfig, b: RouteConfig) {
// Sort config so that:
// - the most exhaustive ones are always at the beginning
// - patterns with wildcard are always at the end
// If 2 patterns are same, move the one with less route names up
// This is an error state, so it's only useful for consistent error messages
if (a.pattern === b.pattern) {
return b.routeNames.join('>').localeCompare(a.routeNames.join('>'))
}
/*
* If one of the patterns starts with the other, it is earlier in the config sorting.
* However, configs are a mix of route configs and layout configs
* e.g There will be a config for `/(group)`, but maybe there isn't a `/(group)/index.tsx`
*
* This is because you can navigate to a directory and its navigator will determine the route
* These routes should be later in the config sorting, as their patterns are very open
* and will prevent routes from being matched
*
* Therefore before we compare segment parts, we force these layout configs later in the sorting
*
* NOTE: Is this a feature we want? I'm unsure if this is a gimmick or a feature.
*/
if (a.pattern.startsWith(b.pattern) && !b.isIndex) {
return -1
}
if (b.pattern.startsWith(a.pattern) && !a.isIndex) {
return 1
}
/*
* Static routes should always be higher than dynamic and layout routes.
*/
if (a.type === 'static' && b.type !== 'static') {
return -1
}
if (a.type !== 'static' && b.type === 'static') {
return 1
}
/*
* If both are static/dynamic or a layout file, then we check group similarity
*/
const similarToPreviousA = previousSegments.filter((value, index) => {
return value === a.expandedRouteNames[index] && value.startsWith('(') && value.endsWith(')')
})
const similarToPreviousB = previousSegments.filter((value, index) => {
return value === b.expandedRouteNames[index] && value.startsWith('(') && value.endsWith(')')
})
if (
(similarToPreviousA.length > 0 || similarToPreviousB.length > 0) &&
similarToPreviousA.length !== similarToPreviousB.length
) {
// One matches more than the other, so pick the one that matches more
return similarToPreviousB.length - similarToPreviousA.length
}
/*
* If there is not difference in similarity, then each non-group segment is compared against each other
*/
for (let i = 0; i < Math.max(a.parts.length, b.parts.length); i++) {
// if b is longer, b get higher priority
if (a.parts[i] == null) {
return 1
}
// if a is longer, a get higher priority
if (b.parts[i] == null) {
return -1
}
const aWildCard = a.parts[i].startsWith('*')
const bWildCard = b.parts[i].startsWith('*')
// if both are wildcard we compare next component
if (aWildCard && bWildCard) {
const aNotFound = a.parts[i].match(/^[*]not-found$/)
const bNotFound = b.parts[i].match(/^[*]not-found$/)
if (aNotFound && bNotFound) {
continue
}
if (aNotFound) {
return 1
}
if (bNotFound) {
return -1
}
continue
}
// if only a is wild card, b get higher priority
if (aWildCard) {
return 1
}
// if only b is wild card, a get higher priority
if (bWildCard) {
return -1
}
const aSlug = a.parts[i].startsWith(':')
const bSlug = b.parts[i].startsWith(':')
// if both are wildcard we compare next component
if (aSlug && bSlug) {
const aNotFound = a.parts[i].match(/^[*]not-found$/)
const bNotFound = b.parts[i].match(/^[*]not-found$/)
if (aNotFound && bNotFound) {
continue
}
if (aNotFound) {
return 1
}
if (bNotFound) {
return -1
}
continue
}
// if only a is wild card, b get higher priority
if (aSlug) {
return 1
}
// if only b is wild card, a get higher priority
if (bSlug) {
return -1
}
}
/*
* Both configs are identical in specificity and segments count/type
* Try and sort by initial instead.
*
* TODO: We don't differentiate between the default initialRoute and group specific default routes
*
* const unstable_settings = {
* "group": {
* initialRouteName: "article"
* }
* }
*
* "article" will be ranked higher because its an initialRoute for a group - even if not your not currently in
* that group. The current work around is to ways provide initialRouteName for all groups
*/
if (a.isInitial && !b.isInitial) {
return -1
}
if (!a.isInitial && b.isInitial) {
return 1
}
return b.parts.length - a.parts.length
}
}
export function formatRegexPattern(it: string): string {
// Allow spaces in file path names.
it = it.replace(' ', '%20')
if (it.startsWith(':')) {
// TODO: Remove unused match group
return `(([^/]+\\/)${it.endsWith('?') ? '?' : ''})`
}
if (it.startsWith('*')) {
return `((.*\\/)${it.endsWith('?') ? '?' : ''})`
}
// Strip groups from the matcher
if (matchGroupName(it) != null) {
// Groups are optional segments
// this enables us to match `/bar` and `/(foo)/bar` for the same route
// NOTE(EvanBacon): Ignore this match in the regex to avoid capturing the group
return `(?:${escape(it)}\\/)?`
}
return escape_(it) + `\\/`
}
export function decodeURIComponentSafe(str: string) {
try {
return decodeURIComponent(str)
} catch {
return str
}
}
/**
* In One, the params are available at all levels of the routing config
*/
export function populateParams(routes?: ParsedRoute[], params?: Record<string, any>) {
if (!routes || !params || Object.keys(params).length === 0) return
for (const route of routes) {
Object.assign(route, { params })
}
return routes
}
export function createConfigItemAdditionalProperties(
screen: string,
pattern: string,
routeNames: string[],
config: Record<string, any> = {}
): Omit<AdditionalRouteConfig, 'isInitial'> {
const parts: string[] = []
let isDynamic = false
const isIndex = screen === 'index' || screen.endsWith('/index')
for (const part of pattern.split('/')) {
if (part) {
// If any part is dynamic, then the route is dynamic
isDynamic ||= part.startsWith(':') || part.startsWith('*') || part.includes('*not-found')
if (!matchGroupName(part)) {
parts.push(part)
}
}
}
const hasChildren = config.screens ? !!Object.keys(config.screens)?.length : false
const type = hasChildren ? 'layout' : isDynamic ? 'dynamic' : 'static'
if (isIndex) {
parts.push('index')
}
return {
type,
isIndex,
hasChildren,
parts,
userReadableName: [...routeNames.slice(0, -1), config.path || screen].join('/'),
expandedRouteNames: routeNames.flatMap((name) => {
return name.split('/')
}),
}
}
export function parseQueryParamsExtended(
path: string,
route: ParsedRoute,
parseConfig?: Record<string, (value: string) => any>,
hash?: string
) {
const searchParams = new URL(path, 'https://phony.example').searchParams
const params: Record<string, string | string[]> = Object.create(null)
if (hash) {
params['#'] = hash.slice(1)
}
for (const name of searchParams.keys()) {
if (route.params?.[name]) {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`Route '/${route.name}' with param '${name}' was specified both in the path and as a param, removing from path`
)
}
} else {
// biome-ignore lint/suspicious/noPrototypeBuiltins: <explanation>
const values = parseConfig?.hasOwnProperty(name)
? searchParams.getAll(name).map((value) => parseConfig[name](value))
: searchParams.getAll(name)
// searchParams.getAll returns an array.
// if we only have a single value, and its not an array param, we need to extract the value
params[name] = values.length === 1 ? values[0] : values
}
}
return Object.keys(params).length ? params : undefined
}
export function stripBaseUrl(
path: string,
baseUrl: string | undefined = process.env.EXPO_BASE_URL
) {
if (process.env.NODE_ENV !== 'development') {
if (baseUrl) {
return path.replace(/^\/+/g, '/').replace(new RegExp(`^\\/?${escape(baseUrl)}`, 'g'), '')
}
}
return path
}