UNPKG

juris

Version:

JavaScript Unified Reactive Interface Solution - Browser-optimized version for script tags and CDN usage

684 lines (571 loc) 19.2 kB
// headless/juris-router.js const Router = (props = {}, context) => { const { getState, setState, subscribe } = context; const defaultConfig = { statePath: 'url', // Base path in state where URL data is stored stateStructure: { path: 'path', // Key for current path segments: 'segments', // Key for parsed segments params: 'params', // Key for URL parameters query: 'query', // Key for query string hash: 'hash' // Key for hash fragment }, // URL handling mode: 'hash', // 'hash' | 'history' | 'memory' basePath: '', // Base path for history mode caseSensitive: false, // Case sensitive route matching trailingSlash: 'ignore', // 'strict' | 'ignore' | 'redirect' // Route configuration routes: {}, // Route definitions with guards and metadata defaultRoute: '/', // Default route when none matches notFoundRoute: '/404', // Route for 404 handling // Route guards globalGuards: { beforeEnter: [], // Global guards before any route afterEnter: [], // Global guards after route change beforeLeave: [] // Global guards before leaving route }, // URL parsing parseQuery: true, // Parse query string into object parseParams: true, // Parse route parameters encodeParams: true, // URL encode/decode parameters // Segments parsing segmentParsing: { enabled: true, maxDepth: 10, // Maximum segment depth customKeys: ['base', 'sub', 'section', 'item'], // Custom segment names includeEmpty: false // Include empty segments }, // Event handling events: { beforeChange: null, // Callback before URL change afterChange: null, // Callback after URL change onError: null, // Error handling callback onGuardFail: null // Guard failure callback }, // Advanced options debounceMs: 0, // Debounce URL changes syncOnStateChange: false, // Sync URL when state changes preventDuplicates: true, // Prevent duplicate navigation preserveScrollPosition: false, // Restore scroll position disregardParams: ['__state', '__v'], // To disregard specific URL parameters for future features // Debug options debug: false, // Enable debug logging logPrefix: '🧭' // Prefix for log messages }; const config = mergeDeep(defaultConfig, props.config || {}); let currentUrl = ''; let isNavigating = false; let debounceTimer = null; let scrollPositions = new Map(); let unsubscribeStateSync = null; const urlHandler = createUrlHandler(config.mode); return { api: { navigate: (path, options = {}) => navigate(path, options), replace: (path, options = {}) => navigate(path, { ...options, replace: true }), back: () => urlHandler.back(), forward: () => urlHandler.forward(), go: (delta) => urlHandler.go(delta), // State access getCurrentPath: () => getState(`${config.statePath}.${config.stateStructure.path}`), getSegments: () => getState(`${config.statePath}.${config.stateStructure.segments}`), getParams: () => getState(`${config.statePath}.${config.stateStructure.params}`, {}), getQuery: () => getState(`${config.statePath}.${config.stateStructure.query}`, {}), // Route management addRoute: (path, routeConfig) => addRoute(path, routeConfig), removeRoute: (path) => removeRoute(path), hasRoute: (path) => hasRoute(path), matchRoute: (path) => matchRoute(path), // Guard management addGuard: (type, guard) => addGuard(type, guard), removeGuard: (type, guard) => removeGuard(type, guard), // Utilities buildUrl: (path, params, query) => buildUrl(path, params, query), parseUrl: (url) => parseUrl(url), isActive: (path, exact = false) => isActive(path, exact), // Configuration updateConfig: (newConfig) => updateConfig(newConfig), getConfig: () => ({ ...config }), // Debug getState: () => getUrlState(), getHistory: () => urlHandler.getHistory?.() || [] }, hooks: { onRegister: () => { debugLog('UrlStateSync initializing...'); // Initialize URL handler urlHandler.init(); // Set up initial state handleUrlChange(); // Set up event listeners setupEventListeners(); // Set up state synchronization if enabled if (config.syncOnStateChange) { setupStateSync(); } debugLog('UrlStateSync ready'); }, onUnregister: () => { debugLog('UrlStateSync cleaning up...'); // Clean up event listeners cleanupEventListeners(); // Clean up state sync if (unsubscribeStateSync) { unsubscribeStateSync(); unsubscribeStateSync = null; } // Clear timers if (debounceTimer) { clearTimeout(debounceTimer); } debugLog('UrlStateSync cleaned up'); } } }; // ==================== URL HANDLERS ==================== function createUrlHandler(mode) { switch (mode) { case 'history': return createHistoryHandler(); case 'memory': return createMemoryHandler(); case 'hash': default: return createHashHandler(); } } function createHashHandler() { return { init: () => { window.addEventListener('hashchange', handleUrlChange); window.addEventListener('popstate', handleUrlChange); }, cleanup: () => { window.removeEventListener('hashchange', handleUrlChange); window.removeEventListener('popstate', handleUrlChange); }, getCurrentPath: () => window.location.hash.substring(1) || config.defaultRoute, navigate: (path, replace = false) => { if (replace) { window.location.replace('#' + path); } else { window.location.hash = path; } }, back: () => window.history.back(), forward: () => window.history.forward(), go: (delta) => window.history.go(delta) }; } function createHistoryHandler() { return { init: () => { window.addEventListener('popstate', handleUrlChange); }, cleanup: () => { window.removeEventListener('popstate', handleUrlChange); }, getCurrentPath: () => { const path = window.location.pathname + window.location.search; return path.startsWith(config.basePath) ? path.substring(config.basePath.length) || config.defaultRoute : config.defaultRoute; }, navigate: (path, replace = false) => { const fullPath = config.basePath + path; if (replace) { window.history.replaceState(null, '', fullPath); } else { window.history.pushState(null, '', fullPath); } handleUrlChange(); }, back: () => window.history.back(), forward: () => window.history.forward(), go: (delta) => window.history.go(delta) }; } function createMemoryHandler() { let memoryHistory = [config.defaultRoute]; let currentIndex = 0; return { init: () => { }, cleanup: () => { }, getCurrentPath: () => memoryHistory[currentIndex] || config.defaultRoute, navigate: (path, replace = false) => { if (replace) { memoryHistory[currentIndex] = path; } else { currentIndex++; memoryHistory = memoryHistory.slice(0, currentIndex); memoryHistory.push(path); } handleUrlChange(); }, back: () => { if (currentIndex > 0) { currentIndex--; handleUrlChange(); } }, forward: () => { if (currentIndex < memoryHistory.length - 1) { currentIndex++; handleUrlChange(); } }, go: (delta) => { const newIndex = currentIndex + delta; if (newIndex >= 0 && newIndex < memoryHistory.length) { currentIndex = newIndex; handleUrlChange(); } }, getHistory: () => [...memoryHistory] }; } // ==================== URL CHANGE HANDLING ==================== function handleUrlChange() { if (isNavigating) return; const debouncedHandle = () => { const newUrl = urlHandler.getCurrentPath(); // Prevent duplicate navigation if (config.preventDuplicates && newUrl === currentUrl) { return; } debugLog('URL change detected:', newUrl); // Execute before change callback if (config.events.beforeChange) { try { const result = config.events.beforeChange(newUrl, currentUrl); if (result === false) { debugLog('URL change prevented by beforeChange callback'); return; } } catch (error) { handleError('beforeChange callback error', error); return; } } processUrlChange(newUrl); }; if (config.debounceMs > 0) { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(debouncedHandle, config.debounceMs); } else { debouncedHandle(); } } async function processUrlChange(newUrl) { isNavigating = true; try { // Save scroll position if (config.preserveScrollPosition && currentUrl) { scrollPositions.set(currentUrl, { x: window.scrollX, y: window.scrollY }); } // Match route and check guards const routeMatch = matchRoute(newUrl); const canNavigate = await checkAllGuards(newUrl, currentUrl, routeMatch); if (!canNavigate) { debugLog('Navigation blocked by guards'); if (config.events.onGuardFail) { config.events.onGuardFail(newUrl, currentUrl); } return; } // Parse URL components const urlData = parseUrl(newUrl); // Update state updateUrlState(urlData); // Update current URL currentUrl = newUrl; // Restore scroll position if (config.preserveScrollPosition) { setTimeout(() => { const savedPosition = scrollPositions.get(newUrl); if (savedPosition) { window.scrollTo(savedPosition.x, savedPosition.y); } }, 0); } // Execute after change callback if (config.events.afterChange) { try { config.events.afterChange(newUrl, currentUrl); } catch (error) { handleError('afterChange callback error', error); } } debugLog('URL change completed:', newUrl); } catch (error) { handleError('URL change processing error', error); } finally { isNavigating = false; } } // ==================== ROUTE MATCHING & GUARDS ==================== function matchRoute(path) { const normalizedPath = normalizePath(path); // Direct match if (config.routes[normalizedPath]) { return { path: normalizedPath, route: config.routes[normalizedPath], params: {}, exact: true }; } // Pattern matching for (const [routePath, routeConfig] of Object.entries(config.routes)) { const match = matchPattern(normalizedPath, routePath); if (match) { return { path: routePath, route: routeConfig, params: match.params, exact: match.exact }; } } return null; } function matchPattern(path, pattern) { const pathParts = path.split('/').filter(Boolean); const patternParts = pattern.split('/').filter(Boolean); if (pathParts.length !== patternParts.length) { return null; } const params = {}; for (let i = 0; i < patternParts.length; i++) { const patternPart = patternParts[i]; const pathPart = pathParts[i]; if (patternPart.startsWith(':')) { const paramName = patternPart.substring(1); params[paramName] = config.encodeParams ? decodeURIComponent(pathPart) : pathPart; } else if (patternPart !== pathPart) { if (!config.caseSensitive) { if (patternPart.toLowerCase() !== pathPart.toLowerCase()) { return null; } } else if (patternPart !== pathPart) { return null; } } } return { params, exact: true }; } async function checkAllGuards(newUrl, oldUrl, routeMatch) { try { for (const guard of config.globalGuards.beforeEnter) { const result = await executeGuard(guard, newUrl, oldUrl, routeMatch); if (result === false) return false; } if (routeMatch?.route?.guards) { for (const guard of routeMatch.route.guards) { const result = await executeGuard(guard, newUrl, oldUrl, routeMatch); if (result === false) return false; } } return true; } catch (error) { handleError('Guard execution error', error); return false; } } async function executeGuard(guard, newUrl, oldUrl, routeMatch) { if (typeof guard === 'function') { return await guard(newUrl, oldUrl, routeMatch); } else if (typeof guard === 'string') { // Named guard - could be resolved from a registry debugLog(`Named guard not implemented: ${guard}`); return true; } return true; } // ==================== URL PARSING ==================== function parseUrl(url) { const [pathAndQuery, hash] = url.split('#'); const [path, queryString] = pathAndQuery.split('?'); const normalizedPath = normalizePath(path); const segments = parseSegments(normalizedPath); const query = config.parseQuery ? parseQueryString(queryString) : queryString || ''; const params = config.parseParams ? extractParams(normalizedPath) : {}; return { [config.stateStructure.path]: normalizedPath, [config.stateStructure.segments]: segments, [config.stateStructure.params]: params, [config.stateStructure.query]: query, [config.stateStructure.hash]: hash || '' }; } function parseSegments(path) { if (!config.segmentParsing.enabled) { return { full: path }; } const parts = path.split('/').filter(part => config.segmentParsing.includeEmpty || part !== '' ).slice(0, config.segmentParsing.maxDepth); const segments = { full: path, parts: parts }; // Add custom segment keys config.segmentParsing.customKeys.forEach((key, index) => { segments[key] = parts[index] || ''; }); return segments; } function parseQueryString(queryString) { if (!queryString) return {}; const params = {}; const pairs = queryString.split('&'); for (const pair of pairs) { const [key, value] = pair.split('='); if (key) { const decodedKey = decodeURIComponent(key); if (config.disregardParams.includes(decodedKey)) { continue; } const decodedValue = value ? decodeURIComponent(value) : ''; params[decodedKey] = decodedValue; } } return params; } function extractParams(path) { // Extract parameters based on current route match const routeMatch = matchRoute(path); return routeMatch?.params || {}; } // ==================== STATE MANAGEMENT ==================== function updateUrlState(urlData) { Object.entries(urlData).forEach(([key, value]) => { setState(`${config.statePath}.${key}`, value); }); } function getUrlState() { return getState(config.statePath, {}); } function setupStateSync() { // Watch for state changes that should update the URL unsubscribeStateSync = subscribe(`${config.statePath}.${config.stateStructure.path}`, (newPath) => { if (newPath !== currentUrl) { navigate(newPath, { internal: true }); } }); } // ==================== PUBLIC API METHODS ==================== function navigate(path, options = {}) { if (options.internal) return; // Prevent infinite loops const normalizedPath = normalizePath(path); urlHandler.navigate(normalizedPath, options.replace); } function addRoute(path, routeConfig) { config.routes[normalizePath(path)] = routeConfig; debugLog('Route added:', path); } function removeRoute(path) { delete config.routes[normalizePath(path)]; debugLog('Route removed:', path); } function hasRoute(path) { return normalizePath(path) in config.routes; } function addGuard(type, guard) { if (config.globalGuards[type]) { config.globalGuards[type].push(guard); debugLog('Guard added:', type); } } function removeGuard(type, guard) { if (config.globalGuards[type]) { const index = config.globalGuards[type].indexOf(guard); if (index > -1) { config.globalGuards[type].splice(index, 1); debugLog('Guard removed:', type); } } } function buildUrl(path, params = {}, query = {}) { let url = normalizePath(path); // Replace parameters Object.entries(params).forEach(([key, value]) => { url = url.replace(`:${key}`, config.encodeParams ? encodeURIComponent(value) : value); }); // Add query string const queryString = Object.entries(query) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&'); if (queryString) { url += '?' + queryString; } return url; } function isActive(path, exact = false) { const normalizedPath = normalizePath(path); const currentPath = getState(`${config.statePath}.${config.stateStructure.path}`); if (exact) { return currentPath === normalizedPath; } else { return currentPath?.startsWith(normalizedPath); } } function updateConfig(newConfig) { Object.assign(config, newConfig); debugLog('Configuration updated'); } // ==================== UTILITY FUNCTIONS ==================== function normalizePath(path) { if (!path || path === '/') return '/'; // Handle trailing slash if (config.trailingSlash === 'strict') { return path; // Keep as is } else if (config.trailingSlash === 'redirect') { return path.endsWith('/') ? path.slice(0, -1) : path; } else { // ignore - normalize by removing trailing slash return path.endsWith('/') ? path.slice(0, -1) : path; } } function setupEventListeners() { // Additional event listeners can be added here } function cleanupEventListeners() { urlHandler.cleanup(); } function handleError(context, error) { debugLog(`Error in ${context}:`, error); if (config.events.onError) { config.events.onError(context, error); } } function debugLog(...args) { if (config.debug) { console.log(config.logPrefix, ...args); } } function mergeDeep(target, source) { const result = { ...target }; for (const key in source) { if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { result[key] = mergeDeep(target[key] || {}, source[key]); } else { result[key] = source[key]; } } return result; } }; if (typeof window !== 'undefined') { window.Router = Router; Object.freeze(window.Router); Object.freeze(window.Router.prototype); } if (typeof module !== 'undefined' && module.exports) { module.exports.Router = Router; module.exports.default = Router; }