| Field | Value |
|---|---|
| Project team | project-shell |
| Repository role | Spectre client-side router |
| Package/artifact | @phcdevworks/spectre-shell-router |
| Current version/status | 1.1.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.
- 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
beforeNavigate?: (
context: NavigationContext,
next: (redirect?: string) => void
) => void | Promise<void>
onNavigationStart?: (context: NavigationContext) => void
onNavigationEnd?: (context: NavigationContext) => void
afterNavigate?: (context: RouteContext) => 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
// 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.
destroy()on the current page is always called before the nextrender()runs.- If a
loaderthrows, the root is cleared and the navigation is silently abandoned. - If a faster navigation supersedes a pending one, the stale result is discarded (race-safe via monotonic counter).
- If no route matches, the root is cleared and
destroy()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.