UNPKG

rynex

Version:

A minimalist TypeScript framework for building reactive web applications with no virtual DOM

339 lines 9.98 kB
/** * Rynex 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