Add Router.svelte
This commit is contained in:
214
Router.svelte
Normal file
214
Router.svelte
Normal file
@@ -0,0 +1,214 @@
|
||||
<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}
|
||||
Reference in New Issue
Block a user