@razen-core/zenweb
Version:
A minimalist TypeScript framework for building reactive web applications with no virtual DOM
339 lines • 9.98 kB
JavaScript
/**
* ZenWeb Router
* Client-side routing with dynamic routes, middleware, and lazy loading
* Inspired by Express.js and Next.js routing patterns
*/
import { state } from './state.js';
/**
* Router class - manages client-side routing
*/
export class Router {
constructor() {
this.routes = [];
this.currentRoute = null;
this.container = null;
this.globalMiddleware = [];
this.notFoundHandler = null;
this.errorHandler = null;
// Reactive state for current route
this.routeState = state({
path: window.location.pathname,
params: {},
query: this.parseQuery(window.location.search),
hash: window.location.hash
});
// Listen to popstate for browser back/forward
window.addEventListener('popstate', () => {
this.handleNavigation(window.location.pathname + window.location.search + window.location.hash);
});
// Intercept link clicks
document.addEventListener('click', (e) => {
const target = e.target.closest('a');
if (target && target.href && target.origin === window.location.origin) {
const href = target.getAttribute('href');
if (href && !href.startsWith('http') && !target.hasAttribute('data-external')) {
e.preventDefault();
this.push(href);
}
}
});
}
/**
* Add a route to the router
*/
addRoute(config) {
const compiled = this.compileRoute(config);
this.routes.push(compiled);
}
/**
* Add multiple routes
*/
addRoutes(configs) {
configs.forEach(config => this.addRoute(config));
}
/**
* Add global middleware
*/
use(middleware) {
this.globalMiddleware.push(middleware);
}
/**
* Set 404 handler
*/
setNotFound(handler) {
this.notFoundHandler = handler;
}
/**
* Set error handler
*/
setErrorHandler(handler) {
this.errorHandler = handler;
}
/**
* Mount router to a container element
*/
mount(container) {
this.container = container;
this.handleNavigation(window.location.pathname + window.location.search + window.location.hash);
}
/**
* Navigate to a new route (push state)
*/
async push(path, options = {}) {
if (!options.replace) {
window.history.pushState(options.state || {}, '', path);
}
else {
window.history.replaceState(options.state || {}, '', path);
}
await this.handleNavigation(path, options.scroll !== false);
}
/**
* Replace current route
*/
async replace(path, options = {}) {
return this.push(path, { ...options, replace: true });
}
/**
* Go back in history
*/
back() {
window.history.back();
}
/**
* Go forward in history
*/
forward() {
window.history.forward();
}
/**
* Go to specific history entry
*/
go(delta) {
window.history.go(delta);
}
/**
* Get current route context
*/
getCurrentRoute() {
return this.currentRoute;
}
/**
* Compile route pattern to regex
*/
compileRoute(config) {
const keys = [];
let pattern = config.path;
// Handle dynamic segments: /user/:id -> /user/([^/]+)
pattern = pattern.replace(/:(\w+)/g, (_, key) => {
keys.push(key);
return '([^/]+)';
});
// Handle wildcard: /docs/* -> /docs/(.*)
pattern = pattern.replace(/\*/g, '(.*)');
// Handle optional segments: /user/:id? -> /user(?:/([^/]+))?
pattern = pattern.replace(/\/:(\w+)\?/g, (_, key) => {
keys.push(key);
return '(?:/([^/]+))?';
});
// Exact match
const regex = new RegExp(`^${pattern}$`);
return { pattern: regex, keys, config };
}
/**
* Match a path against routes
*/
matchRoute(path) {
for (const route of this.routes) {
const match = path.match(route.pattern);
if (match) {
const params = {};
route.keys.forEach((key, index) => {
params[key] = match[index + 1];
});
return { route, params };
}
}
return null;
}
/**
* Parse query string
*/
parseQuery(search) {
const query = {};
const params = new URLSearchParams(search);
params.forEach((value, key) => {
if (query[key]) {
if (Array.isArray(query[key])) {
query[key].push(value);
}
else {
query[key] = [query[key], value];
}
}
else {
query[key] = value;
}
});
return query;
}
/**
* Handle navigation
*/
async handleNavigation(fullPath, scroll = true) {
try {
// Parse URL
const [pathWithQuery, hash = ''] = fullPath.split('#');
const [path, search = ''] = pathWithQuery.split('?');
// Create route context
const ctx = {
path,
params: {},
query: this.parseQuery(search ? `?${search}` : ''),
hash: hash ? `#${hash}` : ''
};
// Match route
const matched = this.matchRoute(path);
if (!matched) {
// No route matched - show 404
if (this.notFoundHandler) {
ctx.params = {};
await this.renderRoute(ctx, this.notFoundHandler);
}
else {
console.error(`No route matched for path: ${path}`);
}
return;
}
// Update context with params
ctx.params = matched.params;
this.currentRoute = ctx;
// Update reactive state
Object.assign(this.routeState, ctx);
// Run guards
if (matched.route.config.guards) {
for (const guard of matched.route.config.guards) {
const canActivate = await guard(ctx);
if (!canActivate) {
console.warn(`Route guard blocked navigation to ${path}`);
return;
}
}
}
// Run middleware
const allMiddleware = [...this.globalMiddleware, ...(matched.route.config.middleware || [])];
await this.runMiddleware(ctx, allMiddleware);
// Load component
let component;
if (matched.route.config.lazy) {
// Lazy load component
const module = await matched.route.config.lazy();
component = module.default;
}
else if (matched.route.config.component) {
component = matched.route.config.component;
}
else {
console.error(`No component defined for route: ${path}`);
return;
}
// Render component
await this.renderRoute(ctx, component);
// Scroll to top or hash
if (scroll) {
if (ctx.hash) {
const element = document.querySelector(ctx.hash);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
}
catch (error) {
console.error('Navigation error:', error);
if (this.errorHandler && this.currentRoute) {
this.errorHandler(error, this.currentRoute);
}
}
}
/**
* Run middleware chain
*/
async runMiddleware(ctx, middleware) {
let index = 0;
const next = async () => {
if (index >= middleware.length)
return;
const mw = middleware[index++];
await mw(ctx, next);
};
await next();
}
/**
* Render route component
*/
async renderRoute(ctx, component) {
if (!this.container) {
console.error('Router not mounted to a container');
return;
}
// Clear container
this.container.innerHTML = '';
// Render component
const element = await component(ctx);
this.container.appendChild(element);
}
}
/**
* Create a new router instance
*/
export function createRouter(routes) {
const router = new Router();
if (routes) {
router.addRoutes(routes);
}
return router;
}
/**
* Link component helper
*/
export function createLink(href, text, options = {}) {
const link = document.createElement('a');
link.href = href;
link.textContent = text;
if (options.class) {
link.className = options.class;
}
if (options.style) {
Object.assign(link.style, options.style);
}
return link;
}
/**
* Route params hook
*/
export function useParams(router) {
return router.routeState.params;
}
/**
* Route query hook
*/
export function useQuery(router) {
return router.routeState.query;
}
/**
* Navigation hook
*/
export function useNavigate(router) {
return {
push: (path, options) => router.push(path, options),
replace: (path, options) => router.replace(path, options),
back: () => router.back(),
forward: () => router.forward(),
go: (delta) => router.go(delta)
};
}
//# sourceMappingURL=router.js.map