UNPKG

vjsrouter

Version:

A modern, file-system based router for vanilla JavaScript with SSR support.

224 lines (206 loc) 8.29 kB
// File: src/core/VJSRouter.js import { logger } from '../utils/Logger.js'; import { DOMUtils } from '../utils/DOMUtils.js'; import { HistoryManager } from './HistoryManager.js'; export class VJSRouter { #appElement; #historyManager; #routesManifestPath; #routes = []; #notFoundRoute = null; #activeLinkClass = 'active'; #transitionsEnabled = false; #isTransitioning = false; #isHydrated = false; constructor(options = {}) { const source = 'VJSRouter.constructor'; logger.info(source, 'Initializing vjsrouter...'); const appSelector = options.appSelector || '#app'; this.#appElement = document.querySelector(appSelector); this.#routesManifestPath = options.routesManifestPath || '/routes.json'; this.#activeLinkClass = options.activeLinkClass || 'active'; this.#transitionsEnabled = options.transitions === true; if (!this.#appElement) { const errorMessage = `Initialization failed: The app container with selector "${appSelector}" was not found in the DOM.`; logger.error(source, errorMessage); throw new Error(errorMessage); } this.#historyManager = new HistoryManager(); } async start() { const source = 'VJSRouter.start'; logger.info(source, 'Starting router...'); try { await this.#loadAndProcessRoutes(); this.#historyManager.onURLChange((path) => this._navigateTo(path)); this.#historyManager.start(); this.#setupNavigationListener(); const initialPath = this.#historyManager.getCurrentPath(); logger.info(source, `Hydrating on initial path: "${initialPath}"`); this.#updateActiveLinks(initialPath); this.#isHydrated = true; } catch (error) { logger.error(source, 'A critical error occurred during router startup.', error); this.#renderErrorPage(error); } } async #loadAndProcessRoutes() { const source = 'VJSRouter.#loadAndProcessRoutes'; const response = await fetch(this.#routesManifestPath); if (!response.ok) throw new Error(`Failed to fetch routes manifest: ${response.statusText}`); const manifest = await response.json(); if (!manifest || !Array.isArray(manifest.routes)) throw new Error('Invalid routes manifest format.'); const regularRoutes = manifest.routes.filter(route => { if (route.path === '/404') { this.#notFoundRoute = route; return false; } return true; }); this.#routes = regularRoutes.map(route => { const paramNames = []; const regexPath = route.path.replace(/:([^/]+)/g, (_, paramName) => { paramNames.push(paramName); return '([^/]+)'; }); return { ...route, paramNames, regex: new RegExp(`^${regexPath}$`) }; }); } #findRoute(path) { for (const route of this.#routes) { const match = path.match(route.regex); if (match) { const params = {}; for (let i = 0; i < route.paramNames.length; i++) { params[route.paramNames[i]] = match[i + 1]; } return { route, params }; } } return null; } navigate(path) { if (this.#isTransitioning) return; if (typeof path !== 'string' || !path.startsWith('/')) return; this.#historyManager.push(path); this._navigateTo(path); } async _navigateTo(path) { if (this.#isHydrated) { this.#isHydrated = false; return; } if (this.#isTransitioning) return; document.body.classList.add('router-loading'); const match = this.#findRoute(path); let newPageElement; try { if (!match) { newPageElement = await this.#renderNotFoundPage(); } else { const { route, params } = match; newPageElement = await this.#renderPageWithLayouts(route, params); } } catch (error) { newPageElement = this.#renderErrorPage(error); } const oldPageElement = this.#appElement.firstElementChild; if (!this.#transitionsEnabled || !oldPageElement) { DOMUtils.clearElement(this.#appElement); DOMUtils.appendChild(this.#appElement, newPageElement); } else { this.#isTransitioning = true; newPageElement.classList.add('page-enter'); DOMUtils.appendChild(this.#appElement, newPageElement); setTimeout(() => { oldPageElement.classList.add('page-exit', 'page-exit-active'); newPageElement.classList.add('page-enter-active'); oldPageElement.addEventListener('transitionend', () => { oldPageElement.remove(); this.#isTransitioning = false; }, { once: true }); newPageElement.addEventListener('transitionend', () => { newPageElement.classList.remove('page-enter', 'page-enter-active'); }, { once: true }); }, 10); } this.#updateActiveLinks(path); document.body.classList.remove('router-loading'); } async #renderPageWithLayouts(route, params) { const pageModule = await import(route.file); const PageComponent = pageModule.default; if (typeof PageComponent !== 'function') throw new Error(`Default export for ${route.file} is not a function.`); let loadedData = {}; let loadError = null; if (window.__INITIAL_DATA__) { loadedData = window.__INITIAL_DATA__.data; loadError = window.__INITIAL_DATA__.error; delete window.__INITIAL_DATA__; } else if (typeof pageModule.load === 'function') { try { loadedData = await pageModule.load(params); } catch (error) { loadError = error; } } const props = { data: loadedData, params: params, error: loadError }; let finalElement = PageComponent(props); if (!(finalElement instanceof HTMLElement)) throw new Error(`Component for ${route.path} did not return an HTMLElement.`); if (route.layouts && route.layouts.length > 0) { for (let i = route.layouts.length - 1; i >= 0; i--) { const layoutPath = route.layouts[i]; const layoutModule = await import(layoutPath); const LayoutComponent = layoutModule.default; if (typeof LayoutComponent !== 'function') throw new Error(`Default export for layout ${layoutPath} is not a function.`); finalElement = LayoutComponent(finalElement); if (!(finalElement instanceof HTMLElement)) throw new Error(`Layout ${layoutPath} did not return an HTMLElement.`); } } return finalElement; } #setupNavigationListener() { document.body.addEventListener('click', event => { const linkElement = DOMUtils.findClosestAncestor(event.target, 'a[href]'); if (linkElement && linkElement.getAttribute('href').startsWith('/')) { event.preventDefault(); this.navigate(linkElement.getAttribute('href')); } }); } #updateActiveLinks(currentPath) { const links = document.querySelectorAll('a[href]'); links.forEach(link => { if (link.getAttribute('href') === currentPath) { link.classList.add(this.#activeLinkClass); } else { link.classList.remove(this.#activeLinkClass); } }); } async #renderNotFoundPage() { if (this.#notFoundRoute) { try { return await this.#renderPageWithLayouts(this.#notFoundRoute, {}); } catch (error) { logger.error('VJSRouter.#renderNotFound', 'The custom 404 page failed to load. Using fallback.', error); return this.#renderErrorPage(error); } } return this.#createErrorDisplay('404 - Page Not Found', 'The page you are looking for does not exist.'); } #renderErrorPage(error) { const errorMessage = error instanceof Error ? error.message : 'A problem occurred while loading this page.'; const errorElement = this.#createErrorDisplay('Application Error', errorMessage); // In case of a render error, we must forcefully replace the content. DOMUtils.clearElement(this.#appElement); DOMUtils.appendChild(this.#appElement, errorElement); return errorElement; } #createErrorDisplay(title, message) { const container = document.createElement('div'); container.style.cssText = 'padding: 40px; text-align: center; font-family: sans-serif; color: #333;'; container.innerHTML = `<h1 style="color: #c0392b; margin-bottom: 10px;">${title}</h1><p>${message}</p>`; return container; } }