1/**
2 * Simple Hash-Based SPA Router
3 *
4 * Features:
5 * - Route registration with path patterns
6 * - Dynamic route parameters (e.g., /user/:id)
7 * - Hash-based navigation (#/path)
8 * - 404 handling
9 * - Navigation guards (before/after hooks)
10 * - History API support
11 */
12
13class Router {
14 constructor() {
15 this.routes = {};
16 this.currentRoute = null;
17 this.notFoundHandler = null;
18 this.beforeHooks = [];
19 this.afterHooks = [];
20
21 // Listen for hash changes
22 window.addEventListener('hashchange', () => this.handleRoute());
23 window.addEventListener('load', () => this.handleRoute());
24 }
25
26 /**
27 * Register a route with its handler
28 * @param {string} path - Route path (e.g., '/', '/user/:id')
29 * @param {Function} handler - Function that returns HTML content
30 */
31 addRoute(path, handler) {
32 this.routes[path] = {
33 pattern: this.pathToRegex(path),
34 handler: handler,
35 path: path
36 };
37 }
38
39 /**
40 * Convert path pattern to regex for matching
41 * Supports dynamic parameters like :id, :name
42 */
43 pathToRegex(path) {
44 // Escape special regex characters except :
45 const pattern = path
46 .replace(/\//g, '\\/')
47 .replace(/:\w+/g, '([^/]+)');
48 return new RegExp(`^${pattern}$`);
49 }
50
51 /**
52 * Extract parameters from URL path
53 * @param {string} route - Route pattern with parameters
54 * @param {string} path - Actual path from URL
55 * @returns {Object} Parameters object
56 */
57 extractParams(route, path) {
58 const params = {};
59 const routeParts = route.split('/');
60 const pathParts = path.split('/');
61
62 routeParts.forEach((part, i) => {
63 if (part.startsWith(':')) {
64 const paramName = part.slice(1);
65 params[paramName] = decodeURIComponent(pathParts[i]);
66 }
67 });
68
69 return params;
70 }
71
72 /**
73 * Navigate to a new route programmatically
74 */
75 navigate(path) {
76 window.location.hash = path;
77 }
78
79 /**
80 * Handle route changes
81 */
82 async handleRoute() {
83 const path = window.location.hash.slice(1) || '/';
84
85 // Execute before hooks
86 for (const hook of this.beforeHooks) {
87 const result = await hook(path);
88 if (result === false) return; // Cancel navigation
89 }
90
91 // Find matching route
92 let matchedRoute = null;
93 let params = {};
94
95 for (const [routePath, route] of Object.entries(this.routes)) {
96 if (route.pattern.test(path)) {
97 matchedRoute = route;
98 params = this.extractParams(routePath, path);
99 break;
100 }
101 }
102
103 // Render content
104 const appElement = document.getElementById('app');
105
106 if (matchedRoute) {
107 this.currentRoute = { path, params };
108 appElement.innerHTML = await matchedRoute.handler(params);
109 this.updateActiveLinks(path);
110 } else if (this.notFoundHandler) {
111 appElement.innerHTML = await this.notFoundHandler();
112 } else {
113 appElement.innerHTML = '<h1>404 - Page Not Found</h1>';
114 }
115
116 // Add page transition animation
117 appElement.classList.remove('page-enter');
118 void appElement.offsetWidth; // Trigger reflow
119 appElement.classList.add('page-enter');
120
121 // Execute after hooks
122 for (const hook of this.afterHooks) {
123 await hook(path, params);
124 }
125 }
126
127 /**
128 * Update active state of navigation links
129 */
130 updateActiveLinks(currentPath) {
131 document.querySelectorAll('.nav-link').forEach(link => {
132 const href = link.getAttribute('href').slice(1); // Remove #
133 link.classList.toggle('active', href === currentPath);
134 });
135 }
136
137 /**
138 * Set 404 handler
139 */
140 setNotFound(handler) {
141 this.notFoundHandler = handler;
142 }
143
144 /**
145 * Add navigation guard (before route change)
146 */
147 beforeEach(hook) {
148 this.beforeHooks.push(hook);
149 }
150
151 /**
152 * Add hook after route change
153 */
154 afterEach(hook) {
155 this.afterHooks.push(hook);
156 }
157}
158
159// Export router instance
160export default new Router();