Files
svelte-router/Router.svelte
2025-05-20 20:06:03 +02:00

214 lines
6.6 KiB
Svelte

<script module lang="ts">
import { SvelteMap } from 'svelte/reactivity';
import { onMount, type Component, type Snippet } from 'svelte';
type RouteInfo = {
pattern: RegExp;
paramNames: string[];
component: Component<{}>;
};
const routes = new SvelteMap<string, RouteInfo>();
let currentUrl = $state.raw(new URL(window.location.href));
const currentRoute = $derived.by(() => {
const normalizedPath = normalizePath(currentUrl.pathname);
for (const [_, routeInfo] of routes) {
if (normalizedPath.match(routeInfo.pattern)) {
return routeInfo;
}
}
return null;
});
const currentParams = $derived.by(() => {
if (!currentRoute) {
return {};
}
const match = currentUrl.pathname.match(currentRoute.pattern);
if (!match) {
return {};
}
const result: Record<string, string> = {};
currentRoute.paramNames.forEach((name, index) => {
result[name] = match[index + 1];
});
return result;
});
function normalizePath(path: string): string {
return path.replace(/\/+$/, '');
}
/**
* Registers a new route with the router.
*/
export function route(path: string, component: Component<{}>): void {
if (routes.has(path)) {
console.warn(`Route already registered for path: '${path}'.`);
}
const params = Array.from(path.matchAll(/\[([^\]]+)\]/g).map((x) => x[1]));
const normalizedPath = normalizePath(path);
const escapedPath = normalizedPath.replace(/[.*+?^${}()|\\/]/g, '\\$&');
const pathPattern = escapedPath.replace(/\[([^\]]+)\]/g, '([^/]+)');
const regexPattern = `^${pathPattern}\\/?$`;
routes.set(path, {
pattern: new RegExp(regexPattern),
paramNames: params,
component: component,
});
}
export class RouteError extends Error {}
export class MissingRouteError extends RouteError {}
export class RouteFormatError extends RouteError {}
/**
* Gets the value of a route parameter.
*/
export function paramString(name: string): string {
const value = currentParams[name];
if (!value) {
throw new MissingRouteError(`Route does not have a parameter matching the name '${name}''`);
}
return value;
}
/**
* Gets the value of a route parameter as an integer.
*/
export function paramNumber(name: string): number {
const value = paramString(name);
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed)) {
throw new RouteFormatError(`parameter ${value} could not be parsed as an integer`);
}
return parsed;
}
/**
* Gets the value of a query parameter.
* If the query parameter is not present, the default value (if provided) will be used instead, otherwise, null.
*/
export function queryString(name: string): string | null;
export function queryString(name: string, defaultValue: string): string;
export function queryString(name: string, defaultValue?: string): string | null {
const value = currentUrl.searchParams.get(name);
if (value) {
return value;
}
return defaultValue ?? null;
}
/**
* Gets the value of a query parameter as an integer.
* If the query parameter cannot be parsed, the default value (if provided) will be used instead, otherwise, null.
*/
export function queryNumber(name: string): number | null;
export function queryNumber(name: string, defaultValue: number): number;
export function queryNumber(name: string, defaultValue?: number): number | null {
const value = currentUrl.searchParams.get(name);
if (value) {
const parsed = parseInt(value, 10);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
return defaultValue ?? null;
}
</script>
<script lang="ts">
interface Props {
notfound: Snippet;
}
let { notfound }: Props = $props();
onMount(() => {
if (window.navigation) {
function handleNavigate(event: NavigateEvent) {
event.intercept({
handler: async () => {
currentUrl = new URL(event.destination.url);
},
});
}
window.navigation.addEventListener('navigate', handleNavigate);
return () => {
window.navigation.removeEventListener('navigate', handleNavigate);
};
} else {
function refresh() {
currentUrl = new URL(window.location.href);
}
function makeProxy(target: any) {
return new Proxy(target, {
apply: (target, thisArg, argArray) => {
target.apply(thisArg, argArray);
refresh();
},
});
}
window.history.pushState = makeProxy(window.history.pushState);
window.history.replaceState = makeProxy(window.history.replaceState);
window.history.go = makeProxy(window.history.go);
window.history.forward = makeProxy(window.history.forward);
window.history.back = makeProxy(window.history.back);
function handleClick(e: MouseEvent) {
if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) {
return;
}
if (e.target instanceof HTMLAnchorElement) {
if (e.target.target === '_blank') {
return;
}
const targetUrl = new URL(e.target.href, document.baseURI);
const documentUrl = new URL(document.baseURI);
if (targetUrl.origin == documentUrl.origin) {
e.preventDefault();
history.pushState({}, '', targetUrl);
currentUrl = targetUrl;
}
}
}
window.addEventListener('popstate', refresh);
document.addEventListener('click', handleClick);
return () => {
window.removeEventListener('popstate', refresh);
document.removeEventListener('click', handleClick);
};
}
});
</script>
{#if currentRoute}
<currentRoute.component />
{:else}
{@render notfound()}
{/if}