svelte5-spa-router
Version:
A simple, flexible, and lightweight SPA router specifically designed for Svelte 5 with runes support (dev/non-stable, not production ready)
390 lines (373 loc) • 11.5 kB
JavaScript
import { writable } from 'svelte/store';
const browser = typeof window !== 'undefined';
// --- Stores ---
export const locationStore = writable({
pathname: browser ? window.location.pathname : '/',
search: browser ? window.location.search : '',
hash: browser ? window.location.hash : ''
});
/**
* @typedef {Object} CurrentRoute
* @property {string} path
* @property {any} component
* @property {Record<string, string>} params
* @property {Array<{component: any, params: Record<string, string>}>} branch
*/
/** @type {import('svelte/store').Writable<CurrentRoute>} */
export const currentRoute = writable({ path: '/', component: null, params: {}, branch: [] });
export const routeParams = writable({});
export const queryParams = writable({});
export const hashFragment = writable('');
function updateLocationStore() {
locationStore.set({
pathname: window.location.pathname,
search: window.location.search,
hash: window.location.hash
});
}
// --- Router Class ---
class Router {
/**
* Find route match for a given path (flat route definitions)
* @param {string} path
* @returns {Array<{route: object, params: object}>}
*/
/**
* Find route match for a given path (flat route definitions)
* @param {string} path
* @returns {Array<{route: {path: string, component: any, beforeEnter?: (to: string, from: string) => boolean|Promise<boolean>}, params: Record<string, string>}>}
*/
findRouteBranch(path) {
for (const route of this.routes) {
const match = this.matchRoute(route.path, path);
if (match && match.params && typeof route.component !== 'undefined') {
return [{ route: route, params: match.params }];
}
}
return [];
}
constructor() {
/**
* @type {Array<{path: string, component: any, beforeEnter?: (to: string, from: string) => boolean|Promise<boolean>}>}
*/
this.routes = [];
/** @type {any} */
this.fallback = null;
this._lastPath = '/';
if (!browser) return;
window.addEventListener('popstate', () => {
this.navigateToCurrentUrl();
});
document.addEventListener('click', (/** @type {Event} */ e) => {
const target = /** @type {HTMLAnchorElement} */ (e.target);
if (target.tagName === 'A' && target.href && target.href.startsWith(window.location.origin)) {
e.preventDefault();
const path = target.getAttribute('href');
if (path) {
this.navigate(path);
}
}
});
}
/**
* @param {string} path
* @param {any} component
* @param {object} [options]
* @param {(to: string, from: string) => boolean|Promise<boolean>} [options.beforeEnter]
*/
/**
* @param {string} path
* @param {any} component
* @param {{ beforeEnter?: (to: string, from: string) => boolean|Promise<boolean> }} [options]
*/
/**
* Add a route (flat definition)
* @param {{path: string, component: any, beforeEnter?: (to: string, from: string) => boolean|Promise<boolean>}} routeObj - Route object, must have path and component
*/
addRoute(routeObj) {
if (!routeObj || typeof routeObj.path !== 'string' || !routeObj.component) {
throw new Error('Route object must have at least "path" (string) and "component" (Svelte component)');
}
this.routes.push(routeObj);
}
/**
* Clear all routes
*/
clearRoutes() {
this.routes = [];
}
/**
* @param {any} component
*/
setFallback(component) {
this.fallback = component;
}
/**
* @param {string} fullPath
* @returns {Promise<any>}
*/
async navigate(fullPath) {
if (!browser) return null;
const urlInfo = this.parseUrl(fullPath);
const branch = this.findRouteBranch(urlInfo.pathname);
// Route guard: beforeEnter (check all in branch)
for (const match of branch) {
if (match.route && typeof match.route.beforeEnter === 'function') {
const allow = await match.route.beforeEnter(urlInfo.pathname, this._lastPath);
if (!allow) {
return null;
}
}
}
window.history.pushState({}, '', fullPath);
updateLocationStore();
if (branch.length) {
// Leaf route is last in branch
const leaf = branch[branch.length - 1];
currentRoute.set({
path: urlInfo.pathname,
component: leaf.route.component || null,
params: leaf.params || {},
branch: branch.map(b => ({ component: b.route.component || null, params: b.params || {} }))
});
routeParams.set(leaf.params || {});
queryParams.set(urlInfo.queryParams);
hashFragment.set(urlInfo.hash);
this._lastPath = urlInfo.pathname;
return leaf.route.component || null;
} else if (this.fallback) {
currentRoute.set({
path: urlInfo.pathname,
component: this.fallback,
params: {},
branch: []
});
routeParams.set({});
queryParams.set(urlInfo.queryParams);
hashFragment.set(urlInfo.hash);
this._lastPath = urlInfo.pathname;
return this.fallback;
}
return null;
}
/**
* @returns {Promise<any>}
*/
async navigateToCurrentUrl() {
if (!browser) return null;
const urlInfo = this.parseUrl(window.location.href);
const branch = this.findRouteBranch(urlInfo.pathname);
for (const match of branch) {
if (match.route && typeof match.route.beforeEnter === 'function') {
const allow = await match.route.beforeEnter(urlInfo.pathname, this._lastPath);
if (!allow) {
return null;
}
}
}
updateLocationStore();
if (branch.length) {
const leaf = branch[branch.length - 1];
currentRoute.set({
path: urlInfo.pathname,
component: leaf.route.component || null,
params: leaf.params || {},
branch: branch.map(b => ({ component: b.route.component || null, params: b.params || {} }))
});
routeParams.set(leaf.params || {});
queryParams.set(urlInfo.queryParams);
hashFragment.set(urlInfo.hash);
this._lastPath = urlInfo.pathname;
return leaf.route.component || null;
} else if (this.fallback) {
currentRoute.set({
path: urlInfo.pathname,
component: this.fallback,
params: {},
branch: []
});
routeParams.set({});
queryParams.set(urlInfo.queryParams);
hashFragment.set(urlInfo.hash);
this._lastPath = urlInfo.pathname;
return this.fallback;
}
return null;
}
/**
* @param {string} url
* @returns {{ pathname: string, queryParams: Record<string, string>, hash: string }}
*/
parseUrl(url) {
if (!browser) {
return {
pathname: '/',
queryParams: {},
hash: ''
};
}
const urlObj = new URL(url, window.location.origin);
/** @type {Record<string, string>} */
const queryParams = {};
urlObj.searchParams.forEach((value, key) => {
queryParams[key] = value;
});
return {
pathname: urlObj.pathname,
queryParams,
hash: urlObj.hash.slice(1)
};
}
/**
* @param {string} path
* @returns {any}
*/
findRoute(path) {
for (const route of this.routes) {
const match = this.matchRoute(route.path, path);
if (match) {
return {
...route,
params: match.params
};
}
}
return null;
}
/**
* @param {string} routePath
* @param {string} currentPath
* @returns {{ params: Record<string, string> } | null}
*/
matchRoute(routePath, currentPath) {
/**
* @param {string} p
* @returns {string}
*/
const normalize = (p) => {
if (!p || p === '' || p === '/' || p === '//') return '/';
return p.endsWith('/') && p !== '/' ? p.slice(0, -1) : p;
};
routePath = normalize(routePath);
currentPath = normalize(currentPath);
if (routePath === '/' && currentPath === '/') {
return { params: {} };
}
if (routePath.endsWith('/*')) {
const basePath = routePath.slice(0, -2);
if (currentPath.startsWith(basePath)) {
return { params: { '*': currentPath.slice(basePath.length) } };
}
}
const routeParts = routePath.split('/');
const pathParts = currentPath.split('/');
let allowOptional = false;
if (routeParts.length - pathParts.length === 1) {
const lastRoutePart = routeParts[routeParts.length - 1];
if (lastRoutePart.startsWith(':') && lastRoutePart.endsWith('?')) {
allowOptional = true;
}
}
if (routeParts.length !== pathParts.length && !allowOptional) {
return null;
}
/** @type {Record<string, string>} */
const params = {};
for (let i = 0; i < routeParts.length; i++) {
const routePart = routeParts[i];
const pathPart = pathParts[i];
if (routePart.startsWith(':')) {
const paramName = routePart.slice(1);
if (paramName.endsWith('?')) {
const actualParamName = paramName.slice(0, -1);
if (typeof pathPart !== 'undefined' && pathPart !== '') {
params[actualParamName] = pathPart;
}
} else {
if (typeof pathPart === 'undefined') return null;
params[paramName] = pathPart;
}
} else {
if (typeof pathPart === 'undefined' && allowOptional && i === routeParts.length - 1) {
continue;
}
if (routePart !== pathPart) {
return null;
}
}
}
return { params };
}
/**
* @returns {any}
*/
getCurrentComponent() {
if (!browser) return null;
const path = window.location.pathname;
const route = this.findRoute(path);
if (route) {
return route.component;
} else if (this.fallback) {
return this.fallback;
}
return null;
}
}
// --- Singleton Router ---
export const router = new Router();
// --- Helpers ---
/**
* @param {string} path
* @param {Record<string, string>} [queryParams]
* @param {string} [hash]
*/
export function goto(path, queryParams = {}, hash = '') {
if (!browser) return;
let fullPath = path;
const searchParams = new URLSearchParams(queryParams);
if (searchParams.toString()) {
fullPath += '?' + searchParams.toString();
}
if (hash) {
fullPath += '#' + hash;
}
router.navigate(fullPath);
}
/**
* @param {string} key
* @param {string} [defaultValue]
* @returns {string}
*/
export function getQueryParam(key, defaultValue = '') {
/** @type {Record<string, string>} */
let currentQuery = {};
queryParams.subscribe(q => currentQuery = q)();
return currentQuery[key] || defaultValue;
}
/**
* @param {Record<string, string>} newParams
* @param {boolean} [replace]
*/
export function updateQueryParams(newParams, replace = false) {
if (!browser) return;
const url = new URL(window.location.href);
if (replace) {
url.search = '';
Object.entries(newParams).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== '') {
url.searchParams.set(key, value);
}
});
} else {
Object.entries(newParams).forEach(([key, value]) => {
if (value === null || value === undefined || value === '') {
url.searchParams.delete(key);
} else {
url.searchParams.set(key, value);
}
});
}
window.history.replaceState({}, '', url.toString());
const urlInfo = router.parseUrl(url.toString());
queryParams.set(urlInfo.queryParams);
}