| Field | Value |
|---|---|
| Project team | project-shell |
| Repository role | Spectre client-side router |
| Package/artifact | @phcdevworks/spectre-shell-router |
| Current version/status | 1.2.0 |
- Read AGENTS.md, then the agent-specific guide for the task.
- Check TODO.md and ROADMAP.md for current scope.
- Make the smallest repo-local change that satisfies the task.
- Run
npm run checkwhen validation is required or practical. - Update docs and CHANGELOG.md only when behavior, public contracts, or release-relevant metadata changed.
| Guide | Path |
|---|---|
| Agent rules | AGENTS.md |
| Claude Code | CLAUDE.md |
| Codex | CODEX.md |
| Copilot | COPILOT.md |
| Jules | JULES.md |
| Roadmap | ROADMAP.md |
| Todo | TODO.md |
| Changelog | CHANGELOG.md |
| Security | SECURITY.md |
Minimal browser router for Spectre applications. It maps URL paths to lazy page modules, renders into a root element, and keeps navigation behavior framework-agnostic.
Part of the PHCDevworks Spectre shell ecosystem — composable, zero-dependency packages for client-side shell applications.
Contributing | Changelog | Roadmap | Security Policy
- You need a framework-agnostic client-side router with zero runtime dependencies
- You want
:paramURL matching, query string access, and lazy page loading via dynamicimport() - You want a clean
render/destroylifecycle with race-condition safety built in - You need history or hash-based routing with optional navigation guards and scroll restoration
- You are building on top of a Spectre shell or a similar minimal shell pattern
- You need a framework-integrated router (React Router, Vue Router, TanStack Router, etc.)
- You need server-side rendering or file-based routing
- You need application state management, persistent layouts, or design tokens
- Route matching with
:parampath segments. - Lazy page loading through route
loaderfunctions. - Browser History API integration for programmatic navigation and link interception.
- Optional hash-based routing mode.
- Route guards with cancellation and redirect support.
- Scroll restoration for push and browser back/forward navigation.
- Query string access through
URLSearchParams. - Page cleanup through optional
destroyhooks. - Named routes with
router.href()for safe path generation. - Per-route
metaand anafterNavigatehook for titles and a11y focus management. onNavigationStart/onNavigationEndhooks for loading state.router.subscribe()for reactive route state at the app layer.errorRouteandonErrorfor deterministic error handling instead of a silently cleared outlet.router.back(),router.forward(), androuter.replace()navigation history helpers.
- Node.js
^22.12.0 || >=24.0.0
npm install @phcdevworks/spectre-shell-routerimport { Router, type Route } from '@phcdevworks/spectre-shell-router'
const routes: Route[] = [
{
path: '/',
loader: async () => ({
render({ root }) {
root.textContent = 'Home'
},
}),
},
{
path: '/docs/:slug',
loader: async () => ({
render({ params, query, root }) {
root.textContent = `Doc: ${params.slug}; tab=${query.get('tab') ?? 'intro'}`
},
}),
},
]
const root = document.querySelector<HTMLElement>('#app')
if (!root) {
throw new Error('Missing #app root element.')
}
const router = new Router(routes, root)
router.navigate('/docs/getting-started?tab=api')
// Clean up when the shell unmounts (SPA teardown, hot reload, etc.)
// router.destroy()type Route = {
path: string
name?: string // optional; enables router.href() lookup
meta?: Record<string, unknown> // optional; available on RouteContext.meta
loader: () => Promise<PageModule>
}
type NavigationContext = {
from: string | null
to: string
}
type RouterOptions = {
mode?: 'history' | 'hash'
scrollRestoration?: boolean
errorRoute?: string
beforeNavigate?: (
context: NavigationContext,
next: (redirect?: string) => void
) => void | Promise<void>
onNavigationStart?: (context: NavigationContext) => void
onNavigationEnd?: (context: NavigationContext) => void
afterNavigate?: (context: RouteContext) => void
onError?: (error: unknown, context: NavigationContext) => void
}
type PageModule = {
render: (ctx: RouteContext) => void
destroy?: () => void
}
type RouteContext = {
path: string
params: Record<string, string>
query: URLSearchParams
root: HTMLElement
meta?: Record<string, unknown>
}
type Unsubscribe = () => voidclass Router {
constructor(routes: Route[], root: HTMLElement, options?: RouterOptions)
// Push a new path onto history and navigate to it.
navigate(path: string): void
// Navigate to path without adding a new history entry (uses replaceState).
replace(path: string): void
// Thin wrappers around history.back() / history.forward().
back(): void
forward(): void
// Build a path string from a named route and optional params.
// Throws if the name is unknown or a required :param segment has no matching key.
href(name: string, params?: Record<string, string>): string
// Subscribe to completed navigations. Fires with the current RouteContext
// after every render. Returns an unsubscribe function.
subscribe(callback: (context: RouteContext) => void): Unsubscribe
// Remove all event listeners, call destroy() on the current page, and release the root reference.
destroy(): void
}router.href() examples:
// Static route
const routes: Route[] = [
{ name: 'home', path: '/', loader: ... },
{ name: 'user', path: '/users/:id', loader: ... },
]
const router = new Router(routes, root)
router.href('home') // '/'
router.href('user', { id: '42' }) // '/users/42'Router options examples:
const router = new Router(routes, root, {
mode: 'hash',
scrollRestoration: true,
beforeNavigate({ to }, next) {
if (to === '/admin' && !sessionStorage.getItem('isAdmin')) {
next('/login')
return
}
next()
},
})Document titles and a11y focus with meta + afterNavigate:
const routes: Route[] = [
{
path: '/',
meta: { title: 'Home' },
loader: async () => ({
render({ root }) {
root.textContent = 'Home'
},
}),
},
{
path: '/docs/:slug',
meta: { title: 'Docs' },
loader: async () => ({
render({ params, root }) {
root.textContent = `Doc: ${params.slug}`
},
}),
},
]
const router = new Router(routes, root, {
afterNavigate(context) {
if (context.meta?.title) {
document.title = `${context.meta.title} — My App`
}
// a11y: move focus to the root after each navigation so screen readers
// announce the new page.
context.root.setAttribute('tabindex', '-1')
context.root.focus()
},
})Loading state with onNavigationStart / onNavigationEnd:
let navigating = false
const router = new Router(routes, root, {
onNavigationStart() {
navigating = true
// e.g. show a loading indicator
},
onNavigationEnd() {
navigating = false
// e.g. hide a loading indicator
},
})At the app layer, spectre-shell-signals can wrap these two callbacks to expose a
reactive navigating$ signal that spectre-ui loading indicators subscribe to.
Reactive route state with router.subscribe():
import { signal } from '@phcdevworks/spectre-shell-signals'
const currentRoute = signal<RouteContext | null>(null)
const unsubscribe = router.subscribe((context) => {
currentRoute.set(context)
})
// Later, when the shell unmounts:
// unsubscribe()router.subscribe() is the canonical bridge between the router and
spectre-shell-signals — wrap it once at the app layer to get a reactive
currentRoute signal that any component can read from.
Deterministic error handling with errorRoute / onError:
const routes: Route[] = [
{ path: '/', loader: async () => ({ render({ root }) { root.textContent = 'Home' } }) },
{
path: '/error',
loader: async () => ({ render({ root }) { root.textContent = 'Something went wrong.' } }),
},
]
const router = new Router(routes, root, {
errorRoute: '/error',
onError(error, context) {
console.error(`Navigation to "${context.to}" failed:`, error)
},
})When a loader throws or no route matches, the router navigates to errorRoute
(via replace(), so the failing URL is not pushed onto history) instead of
silently clearing the outlet. onError fires first regardless of whether
errorRoute is configured, so apps can log or report errors without a redirect.
errorRoute must match an existing route path or the constructor throws.
Navigation history helpers:
router.replace('/login') // navigate without adding a history entry
router.back() // wraps history.back()
router.forward() // wraps history.forward()destroy()on the current page is always called before the nextrender()runs.- If a
loaderthrows:onErrorfires (if configured), then the router navigates toerrorRouteif configured and the failing path is not alreadyerrorRoute; otherwise the root is cleared and the navigation is abandoned. - If a faster navigation supersedes a pending one, the stale result is discarded (race-safe via monotonic counter).
- If no route matches:
onErrorfires (if configured), then the router navigates toerrorRouteif configured and the unmatched path is not alreadyerrorRoute; otherwise the root is cleared anddestroy()is called on the current page. - If
beforeNavigatedoes not callnext(), navigation is cancelled and the URL reverts to the current route. - If
beforeNavigatecallsnext('/path'), the router redirects to that path. - Same-domain
<a>clicks are intercepted automatically; modified clicks (ctrl, meta, shift, alt) and external links pass through. - In hash mode, router paths are stored after
#/androuter.href()returns hash-prefixed URLs.
This package owns client-side routing only. It does not own application bootstrapping, reactive state, persistence, layouts, design tokens, or server routing.
npm install
npm run checkUseful scripts:
npm run typecheckvalidates TypeScript without emitting files.npm run lintruns ESLint.npm test -- --runruns the Vitest suite once.npm run buildemits declarations and JavaScript todist.npm run checkruns the full verification gate (typecheck + lint + build + test + check:ecosystem).
AI-agent coordination starts in AGENTS.md, with companion guidance in CLAUDE.md, CODEX.md, COPILOT.md, JULES.md, and .github/copilot-instructions.md.
| Problem | Likely cause | Fix |
|---|---|---|
npm run check fails on typecheck |
Type error in source or tests | Run npm run typecheck to isolate the error |
| Tests fail in CI but pass locally | Node version mismatch | CI runs Node 22 and 24; match your local version |
dist/ is missing after clone |
Build output is gitignored | Run npm run build |
| Link clicks are not intercepted | Link is cross-origin, has target, download, or rel="external" |
Expected behavior — only same-domain plain links are intercepted |
See CONTRIBUTING.md. The gate is npm run check — all of typecheck, lint, build, tests, and check:ecosystem must pass before opening a pull request.
See CHANGELOG.md.
MIT. See LICENSE.