Last active
January 12, 2024 05:19
-
-
Save nishanbajracharya/3d2254d7bd14651b214503ddd246fdd6 to your computer and use it in GitHub Desktop.
Vanilla 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
import './style.css'; | |
import Home from './views/Home'; | |
import Todo from './views/Todo'; | |
import TodoEdit from './views/TodoEdit'; | |
import NotFound from './views/NotFound'; | |
import Router, { RouteErrorProps, RouteProps, Routes } from './lib/router'; | |
function handleProtected(props: RouteProps): boolean { | |
return false; | |
} | |
function handleRouteFail(props: RouteErrorProps) { | |
console.log('[ERROR]', props); | |
router.to('/'); | |
} | |
const routes: Routes = [ | |
{ path: '/', component: Home }, | |
{ path: '/todo', component: Todo }, | |
{ | |
path: '/todo/:id', | |
component: Todo, | |
onBeforeRoute: handleProtected, | |
onRouteFail: handleRouteFail, | |
}, | |
{ path: '/todo/:id/edit', component: TodoEdit }, | |
{ path: '*', component: NotFound }, | |
]; | |
const router = new Router(routes, document.getElementById('app')!); |
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
export type Route<T = HTMLElement, F = RouteErrorProps> = { | |
path: string; | |
onRouteFail?: (error: F) => void | Promise<void>; | |
component: (props: RouteProps) => T | Promise<T>; | |
onBeforeRoute?: (props: RouteProps) => boolean | Promise<boolean>; | |
}; | |
export type Routes<T = HTMLElement, F = RouteErrorProps> = Route<T, F>[]; | |
export type RouteProps<T = unknown> = { | |
state: T; | |
route: string; | |
router: Router; | |
query: Record<string, string>; | |
params: Record<string, string>; | |
}; | |
export type RouteErrorProps<T = unknown> = RouteProps<T> & { | |
error: { | |
message: string; | |
}; | |
}; | |
const defaultNotFoundRoute: Route = { | |
path: '*', | |
component: () => { | |
return document.createElement('div'); | |
}, | |
}; | |
class Router { | |
private _routes: Routes; | |
private _root: HTMLElement; | |
constructor(routes: Routes, root: HTMLElement = document.body) { | |
this._root = root; | |
this._routes = routes; | |
this._toCurrent(); | |
window.addEventListener('popstate', () => { | |
this._toCurrent(); | |
}); | |
} | |
private _matchRoute( | |
url: string, | |
route: Route | |
): { params: Record<string, string>; route: Route } | null { | |
const pattern: string = route.path; | |
const urlParts = url.split('?')[0].split('/').filter(Boolean); | |
const patternParts = pattern.split('/').filter(Boolean); | |
if (urlParts.length !== patternParts.length) { | |
return null; | |
} | |
const params: Record<string, string> = {}; | |
for (let i = 0; i < urlParts.length; i++) { | |
const patternPart = patternParts[i]; | |
const urlPart = urlParts[i]; | |
if (patternPart.startsWith(':')) { | |
const paramName = patternPart.slice(1); | |
params[paramName] = urlPart; | |
} else if (patternPart !== urlPart) { | |
return null; | |
} | |
} | |
return { params, route }; | |
} | |
private async _toCurrent<T = unknown>(state?: T) { | |
await this.linkTo(window.location.pathname, state); | |
} | |
private async _saveState<T = unknown>(path: string, state?: T) { | |
try { | |
history.pushState(state, '', path); | |
} catch (_) {} | |
} | |
private async _toNotFound<T = unknown>() { | |
const notFound = | |
this._routes.find((route) => route.path === '*') || defaultNotFoundRoute; | |
await this._runComponent<T>(notFound); | |
} | |
private _parseQueryParams = (queryString: string): Record<string, string> => { | |
const params: Record<string, string> = {}; | |
if (queryString.length === 0) { | |
return params; | |
} | |
// Remove leading "?" if present | |
if (queryString.startsWith('?')) { | |
queryString = queryString.slice(1); | |
} | |
const pairs = queryString.split('&'); | |
for (const pair of pairs) { | |
const [key, value] = pair.split('='); | |
const decodedKey = decodeURIComponent(key); | |
const decodedValue = decodeURIComponent(value || ''); // Handle case where value is undefined | |
params[decodedKey] = decodedValue; | |
} | |
return params; | |
}; | |
private async _runComponent<T = unknown>( | |
route: Route, | |
params: Record<string, string> = {}, | |
state?: T | |
) { | |
const routeProps: RouteProps = { | |
route: route.path, | |
router: this, | |
state: state || history.state, | |
query: this._parseQueryParams(window.location.search), | |
params, | |
}; | |
let allowRouting = true; | |
if (route.onBeforeRoute) { | |
allowRouting = await route.onBeforeRoute(routeProps); | |
} | |
if (allowRouting) { | |
const view = await route.component(routeProps); | |
this._root.innerHTML = ''; | |
this._root.appendChild(view); | |
} else { | |
await route.onRouteFail?.({ | |
...routeProps, | |
error: { | |
message: 'Route Failed', | |
}, | |
}); | |
} | |
} | |
updateRoot = (root: HTMLElement = document.body) => { | |
this._root = root; | |
}; | |
updateRoutes = (routes: Routes) => { | |
this._routes = routes; | |
}; | |
async to<T = unknown>(path: string, state?: T) { | |
this._saveState<T>(path, state); | |
await this.linkTo<T>(path, state); | |
} | |
async linkTo<T = unknown>(path: string, state?: T) { | |
for (const route of this._routes) { | |
let match = this._matchRoute(path, route); | |
if (match) { | |
await this._runComponent<T>(match.route, match.params, state); | |
return; | |
} | |
} | |
this._toNotFound(); | |
} | |
} | |
export type LinkProps<T = unknown> = { | |
state?: T; | |
href: string; | |
router: Router; | |
type?: 'a' | 'button'; | |
}; | |
export function Link<T = unknown>(props: LinkProps<T>) { | |
const el = document.createElement(props.type || 'a'); | |
el.setAttribute('href', props.href); | |
el.addEventListener('click', (event) => { | |
event.preventDefault(); | |
props.router.to<T>(props.href, props.state); | |
}); | |
return el; | |
} | |
export default Router; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment