@llui/router

Router for LLui. Structured path matching with history and hash mode support.

pnpm add @llui/router

Usage

import { route, param, rest, createRouter, connectRouter } from '@llui/router'
import { div, a } from '@llui/dom'

// Define routes
const home = route([])
const search = route(['search'], (b) => b, ['q', 'page'])
const detail = route(['item', param('id')])
const docs = route(['docs', rest('path')])

// Create router
const router = createRouter({ home, search, detail, docs }, { mode: 'history' })

// Connect to effects system
const routing = connectRouter(router)

API

Route Definition

Function Description
route(segments, builder?, queryKeys?) Define a route with path segments and optional query keys
param(name) Named path parameter (e.g. /item/:id)
rest(name) Rest parameter capturing remaining path

Router

Function Description
createRouter(routes, config) Create router instance (history or hash mode)
connectRouter(router) Connect router to LLui effects, returns routing helpers

Routing Helpers (from connectRouter)

Method / Effect Description
.link(send, route, attrs, children) Render a navigation link with client-side routing
.listener(send) Popstate listener -- call in view() to react to URL changes
.handleEffect Effect handler plugin for navigate/push/replace effects
.push(route) Push navigation effect
.replace(route) Replace navigation effect
.back() Navigate back effect
.forward() Navigate forward effect
.scroll() Scroll restoration effect

Guards

Router guards let you block or redirect navigation. Pass beforeEnter and/or beforeLeave to connectRouter:

const routing = connectRouter(router, {
  // Called before entering a new route
  beforeEnter(to, from) {
    // Return void   -> allow
    // Return false  -> block
    // Return Route  -> redirect
  },
  // Called before leaving the current route
  beforeLeave(from, to) {
    // Return true  -> allow
    // Return false -> block
  },
})

Guards run in the effect handler and the popstate listener, keeping update() pure.

Auth guard

const routing = connectRouter(router, {
  beforeEnter(to) {
    if (to.page === 'admin' && !isLoggedIn()) {
      return { page: 'login' }
    }
  },
})

Unsaved changes guard

const routing = connectRouter(router, {
  beforeLeave(from) {
    if (from.page === 'editor' && hasUnsavedChanges()) {
      return confirm('Discard unsaved changes?')
    }
    return true
  },
})

Functions

param()

Named path parameter: matches one segment

function param(name: string): ParamSegment

rest()

Rest parameter: matches remaining segments

function rest(name: string): RestSegment

route()

Define a route with structured path segments. @example route(['article', param('slug')], ({ slug }) => ({ page: 'article', slug })) route(['search'], { query: ['q'] }, ({ q }) => ({ page: 'search', q: q ?? '' }))

function route<R = any>(segments: Segment[], buildOrOpts: ((params: Record<string, string>) => R) | RouteDefOptions, buildOrToPath?: ((params: Record<string, string>) => R) | { toPath: (route: R) => string }): RouteDef<R>

createRouter()

function createRouter<R>(defs: RouteDef<any>[], config?: RouterConfig<R>): Router<R>

connectRouter()

function connectRouter<R>(router: Router<R>, options?: ConnectOptions<R>): ConnectedRouter<R>

Types

Segment

export type Segment = string | ParamSegment | RestSegment

Interfaces

RouteDef

export interface RouteDef<R> {
  segments: Segment[]
  build: (params: Record<string, string>) => R
  queryKeys: string[]
  /** Optional manual toPath override */
  toPath?: (route: R) => string
}

RouterConfig

export interface RouterConfig<R> {
  mode?: 'hash' | 'history'
  fallback?: R
}

Router

export interface Router<R> {
  /** Match a pathname to a Route. Returns fallback if no match. */
  match(pathname: string): R
  /** Format a Route back to a pathname (without hash/history prefix). */
  toPath(route: R): string
  /** Format a Route to a full href (with # prefix in hash mode). */
  href(route: R): string
  /** The configured mode */
  mode: 'hash' | 'history'
  /** All route definitions (for iteration) */
  routes: ReadonlyArray<RouteDef<R>>
  /** The fallback route */
  fallback: R
}

RouterEffect

export interface RouterEffect {
  type: '__router'
  action: 'push' | 'replace' | 'navigate' | 'back' | 'forward' | 'scroll'
  path?: string
  x?: number
  y?: number
}

ConnectOptions

export interface ConnectOptions<R> {
  /**
   * Called before entering a new route. Return:
   * - `void` / `undefined` → allow navigation
   * - `false` → block navigation (stay on current route)
   * - a different `Route` → redirect to that route
   */
  beforeEnter?: (to: R, from: R | null) => R | false | void
  /**
   * Called before leaving the current route. Return:
   * - `true` → allow navigation
   * - `false` → block (e.g. unsaved changes prompt)
   */
  beforeLeave?: (from: R, to: R) => boolean
}

ConnectedRouter

export interface ConnectedRouter<R> {
  /**
   * Effect: push a new history entry — URL only.
   *
   * Use when the reducer that emitted the effect has already updated
   * `state.route` itself (e.g. a `Router/Navigate` handler that bundles
   * state changes inline before delegating URL work). For
   * navigate-and-let-the-app-react flows from anywhere else, prefer
   * `navigate()` — it dispatches the listener-captured navigate
   * message after pushState so `state.route` and route-side-effects
   * stay in sync without each reducer re-implementing the delegation.
   */
  push(route: R): RouterEffect
  /**
   * Effect: replace the current history entry — URL only. Same
   * URL-only contract as `push()`. For replace-and-react flows, see
   * `navigate()` (push semantics) — there's no `replaceAndDispatch`
   * variant yet because the use case hasn't surfaced; if it does,
   * model it the same way.
   */
  replace(route: R): RouterEffect
  /**
   * Effect: push history AND dispatch the listener-captured navigate
   * message so the reducer can update `state.route` and run any
   * route-side-effects (data fetches, page-meta resets, analytics).
   *
   * Resolves the asymmetry where `link()` did pushState + send while
   * `push()` did pushState only — apps that wanted programmatic
   * navigation from arbitrary reducers had to either re-implement the
   * delegation or live with desynced `state.route`.
   *
   * Requires that the app has mounted `listener()` (typically inside
   * the shell view) — the navigate effect uses the send/factory
   * captured there. If `navigate()` runs before `listener()` mounts,
   * the URL still updates but no message is dispatched and a
   * `console.warn` surfaces the gap. After listener unmount the same
   * fallback applies.
   */
  navigate(route: R): RouterEffect
  /** Effect: go back */
  back(): RouterEffect
  /** Effect: go forward */
  forward(): RouterEffect
  /** Effect: scroll to position */
  scroll(x: number, y: number): RouterEffect

  /** Plugin for handleEffects().use() — handles RouterEffect */
  handleEffect: (ctx: { effect: { type: string }; send: unknown; signal: AbortSignal }) => boolean

  /**
   * View helper: attach URL change listener via onMount.
   * Returns an empty comment node. Sends { type: 'navigate', route } on URL change.
   */
  listener<M>(send: (msg: M) => void, msgFactory?: (route: R) => M): Node[]

  /**
   * View helper: render a navigation link.
   * Generates <a> with proper href and click handler that sends navigate message.
   */
  link<M>(
    send: (msg: M) => void,
    route: R,
    attrs: Record<string, unknown>,
    children: Node[],
    msgFactory?: (route: R) => M,
  ): HTMLElement

  /**
   * Create an update handler for mergeHandlers.
   * Returns [newState, Effect[]] for navigate messages, null for others.
   */
  createHandler<S, M, E>(config: {
    /** Message type to handle (default: 'navigate') */
    message?: string
    /** Extract route from message */
    getRoute: (msg: M) => R
    /** Optional guard — can redirect */
    guard?: (route: R, state: S) => R
    /** Build new state + effects for the route */
    onNavigate: (state: S, route: R) => [S, E[]]
  }): (state: S, msg: M) => [S, E[]] | null
}