Last active
May 16, 2025 18:01
-
-
Save abegehr/8eec812cf95a8f6c9c5f5db9eb5d3ba6 to your computer and use it in GitHub Desktop.
Simple AppRouter with nested and guarded route definitions for Svelte SPA with browser history support in <200 lines backed by nanostores.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script module> | |
/* | |
Sample usage: | |
```svelte | |
<script lang="ts> | |
import AppRouter, { type Router } from "./AppRouter.svelte"; | |
import Login from "./pages/Login.svelte"; | |
import Home from "./pages/Home.svelte"; | |
import Messages from "./pages/Messages.svelte"; | |
import Profile from "./pages/Profile.svelte"; | |
const router: Router = [ | |
{ | |
path: "/login", | |
component: Login, | |
}, | |
{ | |
path: "/", | |
component: Home, | |
guard: async () => { | |
if (await isLoggedOut()) return "/login"; // redirect to /login, if logged out | |
}, | |
children: [ | |
{ | |
path: "/messages/:id", | |
component: Messages, | |
}, | |
{ | |
path: "/profile", | |
component: Profile, | |
}, | |
], | |
}, | |
]; | |
</script> | |
<AppRouter {router} /> | |
``` | |
*/ | |
import { atom } from "nanostores"; | |
import type { Component } from "svelte"; | |
// types | |
export interface Route { | |
path: string; | |
component?: Component<any>; | |
children?: Route[]; | |
guard?: () => Promise<void | string>; | |
} | |
export type Router = Route[]; | |
// state | |
const pathname = atom<string | undefined>(); | |
// actions | |
export function nav(p: string) { | |
console.debug(`nav(${p})`); | |
window.history.pushState({}, "", p); | |
pathname.set(p); | |
} | |
export function pop() { | |
console.debug("pop()"); | |
history.back(); | |
} | |
// logic | |
// Build the current route state based on the pathname by traversing the router. | |
function buildRoute(pathname: string | undefined, router: Router) { | |
if (!pathname) return undefined; | |
// recursively traverse router to find path matching pathname | |
const traverse = ( | |
pathname: string, | |
router: Route[], | |
path: Route[] = [], | |
) => { | |
const segments = pathname.split("/").filter(Boolean); | |
for (const route of router) { | |
const routeSegments = route.path.split("/").filter(Boolean); | |
const isMatch = routeSegments.every((seg, i) => | |
seg.startsWith(":") ? true : seg === segments[i], | |
); | |
if (isMatch) { | |
const rest = segments.slice(routeSegments.length).join("/"); | |
if (!rest) return [...path, route]; | |
else return traverse(rest, route.children ?? [], [...path, route]); | |
} | |
} | |
return null; // no match | |
}; | |
const path = traverse(pathname, router); | |
if (!path) return null; | |
// build full pattern from path | |
const pattern = "/".concat( | |
path | |
.flatMap((r) => r.path.split("/")) | |
.filter(Boolean) | |
.join("/"), | |
); | |
// extract params from pathname | |
const params: Record<string, string> = {}; | |
const segments = pathname.split("/"); | |
pattern.split("/").forEach((seg, index) => { | |
if (seg.startsWith(":")) { | |
const paramName = seg.slice(1); | |
params[paramName] = segments[index]; | |
} | |
}); | |
// guards are used to check if a route is accessible | |
// if any guard returns a path, the user is redirected to that path | |
async function guard() { | |
if (!path) return; | |
for (const route of path) { | |
if (route.guard) { | |
const redirect = await route.guard(); | |
if (redirect) { | |
nav(redirect); | |
return false; | |
} | |
} | |
} | |
return true; | |
} | |
return { | |
pathname, | |
path, | |
route: path[path.length - 1], | |
pattern, | |
params, | |
guard, | |
}; | |
} | |
</script> | |
<script lang="ts"> | |
import { onMount } from "svelte"; | |
import Loading from "./components/common/Loading.svelte"; | |
interface Props { | |
router: Router; | |
} | |
let { router }: Props = $props(); | |
// update path on mount and popstate | |
function update() { | |
console.debug("->", window.location.pathname); | |
pathname.set(window.location.pathname); | |
} | |
onMount(() => { | |
update(); | |
window.addEventListener("popstate", update); | |
return () => window.removeEventListener("popstate", update); | |
}); | |
// build | |
const s = $derived(buildRoute($pathname, router)); | |
</script> | |
{#if s === undefined} | |
<Loading /> | |
{:else if s === null || !s.route} | |
<p>Not found: {$pathname}</p> | |
{:else} | |
{#await s.guard()} | |
<Loading /> | |
{:then} | |
<s.route.component params={s.params} /> | |
{/await} | |
{/if} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment