214 lines
6.6 KiB
Svelte
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} |