vjsrouter
Version:
A modern, file-system based router for vanilla JavaScript with SSR support.
224 lines (206 loc) • 8.29 kB
JavaScript
// 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;
}
}