Created
February 17, 2025 06:14
-
-
Save andyjessop/006cf7fbedfd9692ccd7c3b33c253266 to your computer and use it in GitHub Desktop.
Router.ts
This file contains hidden or 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
type RouteParams = Record<string, string>; | |
type SearchParams = Record<string, string>; | |
interface RouteDefinition { | |
path: string; | |
} | |
export type RouterConfig = Record<string, RouteDefinition>; | |
export interface FullRoute<T extends RouterConfig> { | |
name: keyof T & string; | |
params: RouteParams; | |
search: SearchParams; | |
hash: string; | |
} | |
export type Route<T extends RouterConfig = any> = Partial<FullRoute<T>> & { | |
name: keyof T & string; | |
}; | |
export class Router<T extends RouterConfig> { | |
#config: T & { notFound: RouteDefinition }; | |
#currentRoute: FullRoute<T>; | |
#onNavigateCallback?: (route: FullRoute<T>) => void; | |
constructor(initial: T, onNavigate?: (route: FullRoute<T>) => void) { | |
validateRouterConfig(initial); | |
this.#config = { notFound: { path: "/404" }, ...initial }; | |
this.#currentRoute = this.#getRouteFromUrl(window.location.href) ?? { | |
hash: "", | |
name: "notFound" as keyof T & string, | |
params: {}, | |
search: {}, | |
}; | |
this.#onNavigateCallback = onNavigate; | |
addEventListener("popstate", this.#onPopstate); | |
this.#onPopstate(); | |
} | |
get route() { | |
return this.#currentRoute; | |
} | |
destroy() { | |
removeEventListener("popstate", this.#onPopstate); | |
} | |
navigate = (route: Route<T> | null): void => { | |
if (!route) { | |
this.navigate({ name: "notFound" as keyof T & string }); | |
return; | |
} | |
const { hash, name, search, params } = route; | |
const url = this.#getUrlFromRoute(name, params, search, hash); | |
window.history.pushState({}, "", url); | |
this.#currentRoute = { | |
hash: hash ?? "", | |
name, | |
params: params ?? {}, | |
search: search ?? {}, | |
}; | |
this.#onNavigateCallback?.(this.#currentRoute); | |
}; | |
#onPopstate = () => { | |
const route = this.#getRouteFromUrl(window.location.href); | |
if ( | |
window.location.hash.length === 0 && | |
window.location.href.endsWith("#") | |
) { | |
// Remove the disgusting hash from the URL without reloading the page | |
// @ts-ignore | |
window.history.replaceState(null, null, window.location.pathname); | |
} | |
if (route) { | |
this.#currentRoute = route; | |
this.#onNavigateCallback?.(this.#currentRoute); | |
} | |
}; | |
#getUrlFromRoute( | |
name: keyof T & string, | |
params?: RouteParams, | |
search?: SearchParams, | |
hash?: string, | |
): string { | |
let pathname = this.#config[name].path; | |
if (!pathname) { | |
throw new Error(`Route "${String(name)}" not found in config`); | |
} | |
if (pathname.includes(":") && !params) { | |
throw new Error( | |
`Route "${String(name)}" requires params but none were provided`, | |
); | |
} | |
for (const [key, value] of Object.entries(params || {})) { | |
pathname = pathname.replace(`:${key}`, value); | |
} | |
const searchStr = search | |
? Object.entries(search) | |
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`) | |
.join("&") | |
: ""; | |
return `${window.location.origin}${pathname}${searchStr ? `?${searchStr}` : ""}${ | |
hash ? `#${hash}` : "" | |
}`; | |
} | |
#getRouteFromUrl(fullUrl: string): FullRoute<T> | null { | |
const { hash, pathname, searchParams } = new URL(fullUrl); | |
const params: RouteParams = {}; | |
const normalisedPathname = | |
pathname.endsWith("/") && pathname !== "/" | |
? pathname.slice(0, -1) | |
: pathname; | |
const entry = Object.entries(this.#config).find(([_, route]) => { | |
const routeTokens = route.path.split("/"); | |
const pathTokens = normalisedPathname.split("/"); | |
if (routeTokens.length !== pathTokens.length) return false; | |
return routeTokens.every((token, index) => { | |
if (token.startsWith(":")) { | |
params[token.slice(1)] = pathTokens[index]; | |
return true; | |
} | |
return token === pathTokens[index]; | |
}); | |
}); | |
if (!entry) return null; | |
const [name] = entry; | |
return { | |
hash: hash.slice(1), | |
name: name as keyof T & string, | |
params, | |
search: Object.fromEntries(searchParams.entries()), | |
}; | |
} | |
} | |
/** | |
* Validates that all route paths in the provided configuration start with '/'. | |
* Throws an error if any route is found that does not conform to this requirement. | |
*/ | |
function validateRouterConfig<T extends RouterConfig>(config: T): void { | |
for (const [routeName, routeDef] of Object.entries(config)) { | |
if (!routeDef.path.startsWith("/")) { | |
throw new Error( | |
`Route "${routeName}" has a path "${routeDef.path}" that does not start with "/".`, | |
); | |
} | |
} | |
} |
This file contains hidden or 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 { | |
type ComponentType, | |
type LazyExoticComponent, | |
type ReactElement, | |
Suspense, | |
} from "react"; | |
interface ComponentRoute { | |
component: LazyExoticComponent<ComponentType<any>>; | |
parent?: string; | |
path: string; | |
} | |
export interface ComponentRoutes { | |
[key: string]: ComponentRoute; | |
} | |
export function createRenderRoute(routes: ComponentRoutes) { | |
/** | |
* Render the component tree for a given route. | |
*/ | |
return function RenderRoute(routeKey?: string): ReactElement | null { | |
if (!routeKey) { | |
return null; | |
} | |
const routeChain = findRouteChain(routeKey, routes); | |
if (routeChain.length === 0) { | |
return null; | |
} | |
return routeChain.reduceRight( | |
(children, currentKey) => { | |
const Component = routes[currentKey].component; | |
return ( | |
<Suspense key={currentKey}> | |
<Component key={currentKey}>{children}</Component> | |
</Suspense> | |
); | |
}, | |
null as ReactElement | null, | |
); | |
}; | |
function findRouteChain(routeKey: string, routes: ComponentRoutes): string[] { | |
const chain: string[] = []; | |
let currentKey: string | undefined = routeKey; | |
while (currentKey) { | |
chain.unshift(currentKey); | |
currentKey = routes[currentKey].parent; | |
} | |
return chain; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment