npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@koppajs/koppajs-router

v0.1.7

Published

Reusable History API router primitives for KoppaJS applications.

Readme


Purpose

The package exists to provide a deterministic, route-config-driven router runtime for KoppaJS-style applications.

It is intentionally narrow:

  • the package owns path normalization, route matching, redirect following, browser history updates, outlet rendering, metadata updates, active-link state, and scroll restoration
  • the consuming application owns route content, custom element registration, page copy, analytics, and business-specific navigation rules

Repository Classification

  • repo_type: reusable package
  • runtime_responsibility: browser router runtime
  • build_time_responsibility: TypeScript build, linting, unit tests, tarball smoke testing, and release validation
  • ui_surface: none inside the package
  • maturity_level: pre-1.0, contract-stabilizing

Installation

pnpm add @koppajs/koppajs-router
npm install @koppajs/koppajs-router

Consumer requirements:

  • a browser environment with the DOM and History API
  • an ESM-capable runtime or bundler
  • consumer-owned registration of every referenced componentTag
  • title, description, and componentTag on every final renderable route

Local repository requirements:

  • Node.js >= 22
  • pnpm >= 10.17.1

Public Contract

The public surface is deliberately small.

Main runtime surface:

  • KoppajsRouter
  • RouteDefinition, ResolvedRoute, RedirectedRoute, RouteNavigationContext, RouteBeforeNavigateContext, RouteNavigationReason, RouteBeforeNavigateHook, RouteAfterNavigateHook, and related route types
  • normalizePath(), normalizeHash(), normalizeBasePath()
  • toHref() and fromLocationPathname()
  • resolveRoute() and resolveRouteByName()
  • setActiveRouteLinks() and setDocumentDescription()
  • DEFAULT_ROUTE_LINK_SELECTOR
  • DEFAULT_ROUTE_CHANGE_EVENT_NAME
  • KOPPAJS_ROUTE_CHANGE_EVENT

Runtime instance helpers:

  • router.resolve() / router.resolveByName()
  • router.navigate() / router.navigateByName()
  • router.onBeforeNavigate() / router.onAfterNavigate() (each returns an unsubscribe function)
  • router.hrefFor()
  • router.getCurrentPath() / router.getCurrentRoute()

Contract constraints:

  • flat and nested route definitions are both supported
  • route names must be unique
  • unmatched paths throw unless the consumer declares an explicit path: "*" route
  • static segments outrank dynamic segments, and dynamic segments outrank *
  • active-link matching defaults to path-based comparison ignoring query and hash; optional isLinkActive replaces that comparison with a consumer-owned rule
  • route records are treated as immutable input data

Ownership Boundaries

Package-owned concerns:

  • route registry compilation and matching
  • named-route resolution and param interpolation
  • base-path translation between app paths and browser hrefs
  • redirect following with bounded depth
  • outlet rendering, title updates, description updates, and route-change events
  • active-link synchronization and scroll handling

Consumer-owned concerns:

  • route definitions and route metadata
  • custom element implementations referenced by componentTag
  • application copy and page composition
  • analytics, business rules, and link exclusion policy
  • browser-level end-to-end coverage in real application shells

Usage

import { KoppajsRouter, type RouteDefinition } from "@koppajs/koppajs-router";

const routes = [
  {
    path: "/",
    name: "home",
    title: "Home",
    description: "Landing page",
    componentTag: "home-page",
  },
  {
    path: "/services",
    name: "services",
    title: "Services",
    description: "Services overview",
    componentTag: "services-page",
    children: [
      {
        path: ":slug",
        name: "service-detail",
        title: "Service detail",
        description: "Service detail page",
        componentTag: "service-detail-page",
      },
    ],
  },
  {
    path: "/guides",
    name: "guides",
    redirectTo: {
      name: "guides-introduction",
    },
    children: [
      {
        path: "introduction",
        name: "guides-introduction",
        title: "Introduction",
        description: "Guide introduction",
        componentTag: "guides-introduction-page",
      },
    ],
  },
  {
    path: "*",
    name: "not-found",
    title: "Not found",
    description: "Missing page",
    componentTag: "not-found-page",
  },
] satisfies readonly RouteDefinition[];

const outlet = document.querySelector<HTMLElement>("#app-outlet");

if (!outlet) {
  throw new Error("App outlet not found.");
}

const router = new KoppajsRouter({
  routes,
  outlet,
  root: document,
  basePath: import.meta.env.BASE_URL,
  shouldSetActiveState: (link) => !link.classList.contains("nav-link--cta"),
});

router.init();

router.navigate({
  name: "service-detail",
  params: { slug: "accessibility-audit" },
  query: { ref: "nav" },
  hash: "contact-entry",
});

Runtime Behavior

Startup flow:

  • the router builds a route registry from the supplied route definitions
  • init() seeds a router-specific history-state key, attaches delegated click, popstate, and pagehide listeners, and starts route-link observation
  • the current browser location is resolved and rendered into the outlet
  • if the current history entry has a persisted scroll position, it is restored after render

Navigation flow:

  • a direct path or named target is resolved into a normalized ResolvedRoute
  • the browser URL is translated through the configured basePath
  • the current entry's scroll position is saved, then history state, outlet content, metadata, active links, and the route-change event are updated together
  • scroll behavior is applied after render, including anchor navigation and saved-history restoration from memory or persisted history state

Navigation Hooks

Register hooks on the router instance before init() if an after hook should observe the initial render. Hooks are synchronous and belong to the router runtime; the application owns policies such as authorization and analytics.

const stopGuard = router.onBeforeNavigate(({ from, to }) => {
  // consumerCanVisit is an application-owned synchronous function.
  if (!consumerCanVisit(to)) return false;
  console.log("Leaving", from?.path ?? "initial location");
});

const stopTracking = router.onAfterNavigate(({ from, to, reason }) => {
  trackRoute({ from: from?.fullPath ?? null, to: to.fullPath, reason });
});

router.init();

// When one integration is no longer needed, remove only its registration:
stopTracking();
// When replacing the entire router, remove its browser listeners and all hooks:
router.destroy();
// stopGuard() is now harmless, because destroy() cleared the registration.

onBeforeNavigate() runs only for navigate() / navigateByName() and intercepted route-link clicks, once the final target has been resolved (including redirects) and checked for renderability but before URL/history, scroll snapshot, or rendering changes. Returning exactly false cancels the attempt; returning nothing allows it. The navigate*() methods still return void. A canceled intercepted click remains intercepted; it does not trigger native navigation. Pure resolve*() and hrefFor() calls do not invoke hooks.

onAfterNavigate() runs after a successful render and the existing koppajs-route-change event, before deferred scroll work. Its reason is "load", "navigate", or "popstate". There is no cancellable before phase for load or browser back/forward: the browser URL has already changed. The existing event name and RouteChangeDetail payload are unchanged; event users do not need to migrate.

Each registration returns its own idempotent unsubscribe function. Hooks run in registration order; registrations made during dispatch wait until the next dispatch, while unsubscribe takes effect immediately. destroy() clears all hooks, so register again if the same instance is initialized again. Do not mutate the observed resolved route or its source record. Navigating inside a hook throws; navigation from an existing route-change event can supersede an older route's after hook.

Async hooks are not supported: promise-like return values throw a synchronous error. A thrown before hook leaves the navigation uncommitted; a thrown after hook stops later after hooks but cannot undo the already changed URL, rendered content, or emitted event. Errors from route matching and rendering remain errors, not cancellations.


Runtime Options

Browser seam options:

  • root, document, window

Routing and URL options:

  • basePath
  • routeChangeEventName
  • scrollBehavior

Active-link options:

  • linkSelector
  • activeClassName
  • activeAttributeName
  • shouldSetActiveState
  • isLinkActive(link, currentRoute)

Important nuance:

  • the default delegated-link selector is a[data-route]
  • active-link and click handling derive the route target from data-route when present and otherwise fall back to href
  • if you want href-only links to participate in delegated handling, provide a selector such as a[data-nav] or a[href^="/"] explicitly

Custom active-link matching

Without isLinkActive, links keep the existing normalized path comparison, ignoring query and hash. Supply a synchronous boolean callback to replace that comparison for every eligible link:

const router = new KoppajsRouter({
  routes,
  outlet,
  isLinkActive: (link, currentRoute) => {
    const target = new URL(
      link.dataset.route ?? link.getAttribute("href") ?? "/",
      window.location.origin,
    );
    return target.pathname === currentRoute.path && target.hash === currentRoute.hash;
  },
});

This example expects app-relative data-route paths (without the deployment base path). currentRoute is the read-only resolved app route with typed metadata, params, query, and hash. Return true for active and false for inactive; false does not fall back to default matching. Do not mutate DOM or route state, navigate, or return a Promise from the callback. Matcher errors are not swallowed: synchronous sync errors propagate to the caller, while deferred sync errors surface through the microtask. Matching is not transactional and does not roll back an already committed navigation.

shouldSetActiveState remains the eligibility gate: excluded links have the configured active class and attribute removed and never reach isLinkActive. The router still owns those DOM writes; custom matching only supplies the rule.

The same rule runs synchronously before the route-change event and after hooks, and again in a microtask when route links are added or removed. Each deferred sync uses the latest committed route. After hooks are not a final DOM-settled notification: manually overriding the same active class or attribute can be undone by a later sync. Put durable custom rules in isLinkActive instead of a timeout or an after-hook class override.

Attribute-only edits, native hashchange, and scrolling do not trigger this observer. For hash-sensitive rules, navigate through the router; this option does not implement scrollspy. destroy() disconnects observation and invalidates already queued link work, including across a subsequent init().

For standalone use, setActiveRouteLinks(root, path, options) keeps supporting default matching. For custom matching, pass the resolved route instead: setActiveRouteLinks(root, router.getCurrentRoute(), { isLinkActive }). A custom matcher with only a path string throws because the route context is missing.


Route Matching Rules

  • static path segments win over dynamic :param segments, even if declared later
  • dynamic :param segments win over * catch-all routes
  • catch-all routes preserve the unmatched browser path in path and fullPath
  • unmatched paths never fall back to the first route silently
  • redirects inherit params, query, and hash unless the redirect target replaces them

Build And Distribution

  • source lives in src/
  • pnpm run build emits the publishable package to dist/
  • the package manifest exports dist/index.js and dist/index.d.ts
  • pnpm run check:package verifies that manifest entrypoints and build output stay aligned
  • pnpm run test:package packs the tarball, installs it into a clean temporary consumer, and imports the published entrypoint
  • pnpm run check is the main local quality gate
  • pnpm run validate is the CI and release validation contract; it runs check plus the tarball-consumer smoke test

Local verification commands:

pnpm install
pnpm run check
pnpm run validate

Ecosystem Fit

This package is the routing runtime layer in the KoppaJS ecosystem.

  • koppajs-core and application shells can depend on it without inheriting website-specific route content
  • consumer applications keep ownership of route tables, component registration, and integration-level browser behavior
  • this repository intentionally does not include a demo app, Playwright suite, or UI surface of its own

Architecture & Governance

Project intent, contributor rules, and documentation contracts live in the local repo meta layer:

The file-shape contract for README.md, CHANGELOG.md, CODE_OF_CONDUCT.md, and CONTRIBUTING.md is defined in docs/specs/repository-documentation-contract.md.

Run the local document guard before committing:

pnpm run check:docs

Community & Contribution

Issues and pull requests are welcome:

https://github.com/koppajs/koppajs-router/issues

Contributor workflow details live in CONTRIBUTING.md.

Community expectations live in CODE_OF_CONDUCT.md.


License

Apache License 2.0 — © 2026 KoppaJS, Bastian Bensch