Last active
January 2, 2023 08:18
-
-
Save Nightbr/c41a0390454ea36d0bd6caf96823396a to your computer and use it in GitHub Desktop.
Create type safe links functions without React deps (From Chicane router)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* /!\ Since Chicane https://github.com/swan-io/chicane has a hard dependence to React, | |
* We cannot share it with our backend. That why we reuse some part of the code without React | |
* to generate links for the backend. | |
* See our issue https://github.com/swan-io/chicane/issues/33 | |
*/ | |
import { createPath, parsePath } from 'history'; | |
// From https://github.com/swan-io/chicane/blob/main/src/types.ts | |
type Search = Record<string, string | string[]>; | |
type Params = Record<string, string | string[] | undefined>; | |
type ParsedRoute = Readonly<{ | |
path: string; | |
search: string; | |
hash: string; | |
}>; | |
type EnsureSlashPrefix<Value extends string> = Value extends `/${string}` | |
? Value | |
: `/${Value}`; | |
type ConcatPaths< | |
PathA extends string, | |
PathB extends string, | |
FixedPathA extends string = EnsureSlashPrefix<PathA>, | |
FixedPathB extends string = EnsureSlashPrefix<PathB> | |
> = FixedPathA extends '/' | |
? FixedPathB | |
: FixedPathB extends '/' | |
? FixedPathA | |
: `${FixedPathA}${FixedPathB}`; | |
type ParseRoutes<Routes extends Record<string, string>> = { | |
[K in keyof Routes]: ParseRoute<Routes[K]>; | |
}; | |
type ParseRoute<Route extends string> = | |
Route extends `${infer Path}?${infer Search}#${infer Hash}` | |
? { path: Path; search: Search; hash: Hash } | |
: Route extends `${infer Path}?${infer Search}` | |
? { path: Path; search: Search; hash: '' } | |
: Route extends `${infer Path}#${infer Hash}` | |
? { path: Path; search: ''; hash: Hash } | |
: { path: Route; search: ''; hash: '' }; | |
type Matcher = { | |
isArea: boolean; | |
name: string; | |
ranking: number; | |
path: (string | { name: string })[]; | |
search: Record<string, 'unique' | 'multiple'> | undefined; | |
hash: string | undefined; | |
}; | |
type PrependBasePath< | |
BasePath extends string, | |
Routes extends Record<string, ParsedRoute> | |
> = { | |
[K in keyof Routes]: { | |
path: ConcatPaths<BasePath, Routes[K]['path']>; | |
search: Routes[K]['search']; | |
hash: Routes[K]['hash']; | |
}; | |
}; | |
type NonEmptySplit< | |
Value extends string, | |
Separator extends string | |
> = Value extends `${infer Head}${Separator}${infer Tail}` | |
? Head extends '' | |
? NonEmptySplit<Tail, Separator> | |
: [Head, ...NonEmptySplit<Tail, Separator>] | |
: Value extends '' | |
? [] | |
: [Value]; | |
type GetPathParams< | |
Path extends string, | |
Parts = NonEmptySplit<Path, '/'> | |
> = Parts extends [infer Head, ...infer Tail] | |
? Head extends `:${infer Name}` | |
? { [K in Name]: string } & GetPathParams<Path, Tail> | |
: GetPathParams<Path, Tail> | |
: {}; // eslint-disable-line @typescript-eslint/ban-types | |
type GetSearchParams< | |
Search extends string, | |
Parts = NonEmptySplit<Search, '&'> | |
> = Parts extends [infer Head, ...infer Tail] | |
? Head extends `:${infer Name}[]` | |
? { [K in Name]?: string[] | undefined } & GetSearchParams<Search, Tail> | |
: Head extends `:${infer Name}` | |
? { [K in Name]?: string | undefined } & GetSearchParams<Search, Tail> | |
: GetSearchParams<Search, Tail> | |
: {}; // eslint-disable-line @typescript-eslint/ban-types | |
export type GetHashParams<Value extends string> = Value extends `:${infer Name}` | |
? { [K in Name]?: string | undefined } | |
: {}; // eslint-disable-line @typescript-eslint/ban-types | |
type GetAreaRoutes<Routes extends Record<string, ParsedRoute>> = { | |
[K in keyof Routes as Routes[K]['path'] extends `${string}/*` | |
? K | |
: never]: Routes[K]['path'] extends `${infer Rest}/*` | |
? { path: Rest; search: Routes[K]['search']; hash: Routes[K]['hash'] } | |
: never; | |
}; | |
type GetRoutesParams<Routes extends Record<string, ParsedRoute>> = { | |
[K in keyof Routes]: GetPathParams<Routes[K]['path']> & | |
GetSearchParams<Routes[K]['search']> & | |
GetHashParams<Routes[K]['hash']>; | |
}; | |
type EmptyRecord = Record<string | number | symbol, never>; | |
type NonOptionalProperties<T> = Exclude< | |
{ [K in keyof T]: T extends Record<K, T[K]> ? K : never }[keyof T], | |
undefined | |
>; | |
type ParamsArg<Params> = Params extends EmptyRecord | |
? [] | |
: NonOptionalProperties<Params> extends never | |
? [params?: { [K in keyof Params]: Params[K] }] | |
: [params: { [K in keyof Params]: Params[K] }]; | |
// From https://github.com/swan-io/chicane/blob/main/src/helpers.ts | |
const isNonEmpty = (value: string): boolean => value !== ''; | |
const isParam = (value: string): boolean => value.startsWith(':'); | |
const isMultipleParam = (value: string): boolean => | |
value.startsWith(':') && value.endsWith('[]'); | |
// From https://github.com/swan-io/chicane/blob/main/src/search.ts | |
const appendParam = (acc: string, key: string, value: string): string => { | |
const output = acc + (acc !== '' ? '&' : '') + encodeURIComponent(key); | |
return value !== '' ? `${output}=${encodeURIComponent(value)}` : output; | |
}; | |
const encodeSearch = (search: Search): string => { | |
const keys = Object.keys(search); | |
if (keys.length === 0) { | |
return ''; | |
} | |
let output = ''; | |
keys.sort(); // keys are sorted in place | |
for (const key of keys) { | |
const value = search[key]; | |
if (value == null) { | |
continue; | |
} | |
if (typeof value === 'string') { | |
output = appendParam(output, key, value); | |
} else { | |
for (const item of value) { | |
output = appendParam(output, key, item); | |
} | |
} | |
} | |
if (output === '') { | |
return ''; // params are empty arrays | |
} | |
return `?${output}`; | |
}; | |
// From https://github.com/swan-io/chicane/blob/main/src/matcher.ts | |
const extractFromPathname = (pathname: string) => { | |
const parts = pathname.split('/').filter(isNonEmpty); | |
const path: Matcher['path'] = []; | |
let ranking = parts.length > 0 ? parts.length * 4 : 5; | |
for (const part of parts) { | |
const param = isParam(part); | |
ranking += param ? 2 : 3; | |
path.push(param ? { name: part.substring(1) } : encodeURIComponent(part)); | |
} | |
return { ranking, path }; | |
}; | |
const getMatcher = (name: string, route: string): Matcher => { | |
const { pathname = '/', search, hash } = parsePath(route); | |
const isArea = pathname.endsWith('/*'); | |
const { ranking, path } = extractFromPathname( | |
isArea ? pathname.slice(0, -2) : pathname | |
); | |
const matcher: Matcher = { | |
isArea, | |
name, | |
// penality due to wildcard | |
ranking: isArea ? ranking - 1 : ranking, | |
path, | |
search: undefined, | |
hash: undefined, | |
}; | |
if (search != null) { | |
matcher.search = {}; | |
const params = new URLSearchParams(search.substring(1)); | |
for (const [key] of params) { | |
if (isMultipleParam(key)) { | |
matcher.search[key.substring(1, key.length - 2)] = 'multiple'; | |
} else if (isParam(key)) { | |
matcher.search[key.substring(1, key.length)] = 'unique'; | |
} | |
} | |
} | |
if (hash != null && isParam(hash.substring(1))) { | |
matcher.hash = hash.substring(2); | |
} | |
return matcher; | |
}; | |
const matchToHistoryPath = (matcher: Matcher, params: Params = {}) => { | |
const pathname = `/${matcher.path | |
.map((part) => | |
encodeURIComponent( | |
typeof part === 'string' ? part : String(params[part.name]) | |
) | |
) | |
.join('/')}`; | |
// https://github.com/remix-run/history/issues/859 | |
let search = ''; | |
let hash = ''; | |
if (matcher.search != null) { | |
const object: Search = {}; | |
for (const key in params) { | |
const value = params[key]; | |
if ( | |
Object.prototype.hasOwnProperty.call(params, key) && | |
Object.prototype.hasOwnProperty.call(matcher.search, key) && | |
value != null | |
) { | |
object[key] = value; | |
} | |
} | |
search = encodeSearch(object); | |
} | |
if (matcher.hash != null) { | |
const value = params[matcher.hash]; | |
if (typeof value === 'string') { | |
hash = `#${encodeURIComponent(value)}`; | |
} | |
} | |
return { pathname, search, hash }; | |
}; | |
// From https://github.com/swan-io/chicane/blob/main/src/concatRoutes.ts | |
const addPrefixOnNonEmpty = (value: string, prefix: string): string => | |
value === '' ? value : prefix + value; | |
const ensureSlashPrefix = (value: string): string => | |
value[0] === '/' ? value : `/${value}`; | |
const parseRoute = (route: string): ParsedRoute => { | |
const { pathname: path = '', search = '', hash = '' } = parsePath(route); | |
return { path, search: search.substring(1), hash: hash.substring(1) }; | |
}; | |
const concatRoutes = (routeA: ParsedRoute, routeB: ParsedRoute): string => { | |
const fixedPathA = ensureSlashPrefix(routeA['path']); | |
const fixedPathB = ensureSlashPrefix(routeB['path']); | |
const path = | |
fixedPathA === '/' | |
? fixedPathB | |
: fixedPathB === '/' | |
? fixedPathA | |
: fixedPathA + fixedPathB; | |
const search = | |
routeA['search'] === '' | |
? routeB['search'] | |
: routeA['search'] + addPrefixOnNonEmpty(routeB['search'], '&'); | |
const hash = routeB['hash'] === '' ? routeA['hash'] : routeB['hash']; | |
return ( | |
path + addPrefixOnNonEmpty(search, '?') + addPrefixOnNonEmpty(hash, '#') | |
); | |
}; | |
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types | |
export const createLinks = < | |
Routes extends Record<string, string>, | |
BasePath extends string = '' | |
>( | |
routes: Readonly<Routes>, | |
options: { | |
basePath?: BasePath; | |
} = {} | |
) => { | |
type CleanBasePath = ParseRoute<BasePath>['path']; | |
type RoutesWithBasePath = PrependBasePath<CleanBasePath, ParseRoutes<Routes>>; | |
type AreaRoutes = GetAreaRoutes<RoutesWithBasePath>; | |
type FiniteRoutes = Omit<RoutesWithBasePath, keyof AreaRoutes>; | |
type FiniteRoutesParams = GetRoutesParams<FiniteRoutes>; | |
const { basePath = '' } = options; | |
const basePathObject: ParsedRoute = { | |
path: parseRoute(basePath).path, | |
search: '', // search and hash are not supported in basePath | |
hash: '', | |
}; | |
const matchers = {} as Record<keyof Routes, Matcher>; | |
const rankedMatchers: Matcher[] = []; // higher to lower | |
for (const routeName in routes) { | |
if (Object.prototype.hasOwnProperty.call(routes, routeName)) { | |
const matcher = getMatcher( | |
routeName, | |
basePath !== '' | |
? concatRoutes(basePathObject, parseRoute(routes[routeName])) | |
: routes[routeName] | |
); | |
matchers[routeName] = matcher; | |
rankedMatchers.push(matcher); | |
} | |
} | |
rankedMatchers.sort( | |
(matcherA, matcherB) => matcherB.ranking - matcherA.ranking | |
); | |
const createURLFunctions = {} as { | |
[RouteName in keyof FiniteRoutes]: ( | |
...args: ParamsArg<FiniteRoutesParams[RouteName]> | |
) => string; | |
}; | |
for (let index = 0; index < rankedMatchers.length; index++) { | |
const matcher = rankedMatchers[index]; | |
if (matcher != null && !matcher.isArea) { | |
const routeName = matcher.name as keyof FiniteRoutes; | |
createURLFunctions[routeName] = (params?: Params) => | |
createPath(matchToHistoryPath(matchers[routeName], params)); | |
} | |
} | |
return { | |
routes, | |
...createURLFunctions, | |
}; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { createLinks } from './create-links'; | |
// Search params constants | |
export const TOKEN_SEARCH_PARAMS = 'token'; | |
// Here we list all our application pages | |
export const routes = { | |
AppArea: '/*', | |
AppRoot: '/', | |
// Auth | |
Login: '/login?:redirect', | |
ForgetPassword: '/forget-password', | |
ResetPassword: `/reset-password?:${TOKEN_SEARCH_PARAMS}`, | |
Register: '/register?:redirect', | |
RedeemInvite: `/redeem-invite?:${TOKEN_SEARCH_PARAMS}`, | |
} as const; | |
export const Links = createLinks(routes); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment