@phcdevworks/spectre-shell-router
v1.2.0
Published
Minimal, framework-agnostic client-side router for Spectre-based applications.
Maintainers
Readme
@phcdevworks/spectre-shell-router
Repository Snapshot
| Field | Value |
|-------|-------|
| Project team | project-shell |
| Repository role | Spectre client-side router |
| Package/artifact | @phcdevworks/spectre-shell-router |
| Current version/status | 1.2.0 |
Standard Workflow
- 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.
Documentation Map
| 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
When to use this package
- 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
When not to use this package
- 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
Capabilities
- 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.
Requirements
- Node.js
^22.12.0 || >=24.0.0
Install
npm install @phcdevworks/spectre-shell-routerQuick Start
import { 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()API
Types
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 = () => voidRouter class
class 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()
},
})Ecosystem integration patterns
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()Behavior
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.
Boundaries
This package owns client-side routing only. It does not own application bootstrapping, reactive state, persistence, layouts, design tokens, or server routing.
Development
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.
Troubleshooting
| 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 |
Contributing
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.
Release Notes
See CHANGELOG.md.
License
MIT. See LICENSE.
