Skip to content

Instantly share code, notes, and snippets.

@nishanbajracharya
Last active January 12, 2024 05:19
Show Gist options
  • Save nishanbajracharya/3d2254d7bd14651b214503ddd246fdd6 to your computer and use it in GitHub Desktop.
Save nishanbajracharya/3d2254d7bd14651b214503ddd246fdd6 to your computer and use it in GitHub Desktop.
Vanilla Router
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')!);
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