UNPKG

sly-svelte-location-router

Version:

A simple svelte router based on location API

327 lines (326 loc) 11.7 kB
import { match } from "path-to-regexp"; import { derived } from 'svelte/store'; import { resolvedRoute } from "./store"; export class Config { static routes; static currentPath; static isInitialized = false; } function isValidRoutePattern(pattern) { if (!pattern) return false; if (pattern === '/') return true; try { match(pattern, { decode: decodeURIComponent }); return true; } catch { return false; } } function validateRoutes(routes) { for (const [pattern] of Object.entries(routes)) { if (!isValidRoutePattern(pattern)) { throw new Error(`Invalid route pattern: ${pattern}`); } } } function convertRoutesToRouteData(routes, parentRoute) { const result = {}; let expressionCounter = 1; for (const [pattern, routeDefinition] of Object.entries(routes)) { if (typeof routeDefinition === 'string') { result[pattern] = routeDefinition; } else { const segments = pattern.split('/').filter(Boolean); let routeName; if (segments.length === 0) { routeName = 'index'; } else { const firstSegment = segments[0]; if (/^[a-zA-Z0-9_-]+$/.test(firstSegment)) { routeName = firstSegment; } else { routeName = `expression${expressionCounter++}`; } } if (typeof routeDefinition === 'function') { result[pattern] = { name: routeName, component: routeDefinition, parentRoute: parentRoute || '' }; } else { result[pattern] = { ...routeDefinition, name: routeDefinition.name || routeName, parentRoute: parentRoute || '' }; } } } return result; } function appendToConfigRoutes(processedRoutes, parentRoute) { if (!Config.routes) { Config.routes = processedRoutes; } else { for (const [pattern, routeData] of Object.entries(processedRoutes)) { if (typeof routeData !== 'string' && parentRoute) { routeData.parentRoute = parentRoute; } Config.routes[pattern] = routeData; } } } export function resolveRoute(segments, routes) { if (segments.length === 0) { if (routes['/']) { const routeData = routes['/']; if (typeof routeData === 'string') { const redirectSegments = routeData.split('/').filter(Boolean); return resolveRoute(redirectSegments, routes); } else if (typeof routeData === 'function') { return { matched: { params: {}, name: '/', component: routeData, }, remaining: [] }; } else if ("name" in routeData && "component" in routeData) { return { matched: { params: {}, name: routeData.name, component: routeData.component, }, remaining: [] }; } } return { matched: null, remaining: [] }; } const sortedRouteKeys = Object.keys(routes) .filter(isValidRoutePattern) .sort((a, b) => { if (a.length !== b.length) { return b.length - a.length; } const segmentsA = a.split('/').filter(Boolean); const segmentsB = b.split('/').filter(Boolean); if (segmentsA.length !== segmentsB.length) { return segmentsB.length - segmentsA.length; } for (let i = 0; i < Math.min(segmentsA.length, segmentsB.length); i++) { const isParamA = segmentsA[i].startsWith(':'); const isParamB = segmentsB[i].startsWith(':'); if (isParamA !== isParamB) { return isParamA ? 1 : -1; } } return 0; }); for (const routePath of sortedRouteKeys) { const routeSegments = routePath.split('/').filter(Boolean); if (routeSegments.length > segments.length) { continue; } const testPath = routeSegments.length === 0 ? '/' : '/' + segments.slice(0, routeSegments.length).join('/'); try { const matchFn = match(routePath, { decode: decodeURIComponent }); const result = matchFn(testPath); if (result) { const routeData = routes[routePath]; const remaining = segments.slice(routeSegments.length); if (typeof routeData === 'string') { const redirectSegments = routeData.split('/').filter(Boolean); return resolveRoute([...redirectSegments, ...remaining], routes); } else if (typeof routeData === 'function') { return { matched: { params: result.params, name: routePath, component: routeData, }, remaining }; } else if ("name" in routeData && "component" in routeData) { return { matched: { params: result.params, name: routeData.name, component: routeData.component, guard: routeData.guard, }, remaining }; } } } catch { continue; } } return { matched: null, remaining: segments }; } export function initRouter(parentRoute, routes) { if (typeof window === 'undefined') return; validateRoutes(routes); const processedRoutes = convertRoutesToRouteData(routes, parentRoute); appendToConfigRoutes(processedRoutes, parentRoute); if (Config.isInitialized) { return; } Config.isInitialized = true; window.addEventListener("popstate", (event) => { resolvedRoute.set({ path: window.location.pathname, segments: window.location.pathname.split('/').filter(Boolean), state: event.state }); }); document.body.addEventListener("click", (e) => { const target = e.target; if (target.tagName === "A" && target.href.startsWith(window.location.origin)) { e.preventDefault(); const url = new URL(target.href); resolvedRoute.set({ path: url.pathname, segments: url.pathname.split('/').filter(Boolean) }); history.pushState(null, "", target.href); } }); } export function createRouteResolver(resolveStore, routes, unresolvedStore) { return derived(resolveStore, (store, set) => { set({ component: null, props: null, name: '', loading: true }); if (!store) { return; } const result = resolveRoute(store.segments, routes); if (result.matched) { const { component, params, name, guard } = result.matched; (async () => { try { const guardResult = guard ? await guard() : null; if (guardResult) { if (typeof guardResult === 'string') { history.replaceState(null, "", guardResult); resolvedRoute.set({ path: guardResult, segments: guardResult.split('/').filter(Boolean) }); } else if (guardResult && typeof guardResult === 'object' && 'path' in guardResult) { history.replaceState(guardResult.state || null, "", guardResult.path); resolvedRoute.set({ path: guardResult.path, segments: guardResult.path.split('/').filter(Boolean), state: guardResult.state }); } return; } unresolvedStore?.set({ path: result.remaining.length > 0 ? '/' + result.remaining.join('/') : '/', segments: result.remaining, state: store.state }); const module = await component(); set({ component: module.default, props: { route: { params, state: store.state, } }, name, loading: false }); } catch { set({ component: null, props: null, name: '__error', loading: false }); } })(); } else { set({ component: null, props: null, name: '__not_found', loading: false }); } }, { component: null, props: null, name: '', loading: true }); } export function createErrorHandler(errorStore, fallback) { return derived(errorStore, (store, set) => { if (!store || !fallback) { set({ component: null, props: null, name: '', loading: false }); return; } set({ component: null, props: null, name: '__fallback', loading: true }); if (typeof fallback === 'function') { fallback().then((module) => { set({ component: module.default, props: { route: { error: store, } }, name: '__fallback', loading: false }); }); } else if (typeof fallback === 'string') { set({ component: null, props: null, name: '__redirect', loading: false }); } else if ("component" in fallback) { fallback.component().then((module) => { set({ component: module.default, props: { route: { error: store, } }, name: '__fallback', loading: false }); }); } }, { component: null, props: null, name: '', loading: false }); } export function navigate(path, state) { const url = new URL(path, window.location.origin); Config.currentPath = url.pathname; resolvedRoute.set({ path: url.pathname, segments: url.pathname.split('/').filter(Boolean), state }); history.pushState(state || null, "", path); }