npm package: @marianmeres/simple-router
Description: [![NPM version](https://img.shields.io/npm/v/@marianmeres/simple-router)](https://www.npmjs.com/package/@marianmeres/simple-router) [![JSR version](https://jsr.io/badges/@marianmeres/simple-router)](https://jsr.io/@marianmeres/simple-router)
Author: Marian Meres
Latest version: 3.1.0

# @marianmeres/simple-router

[![NPM version](https://img.shields.io/npm/v/@marianmeres/simple-router)](https://www.npmjs.com/package/@marianmeres/simple-router)
[![JSR version](https://jsr.io/badges/@marianmeres/simple-router)](https://jsr.io/@marianmeres/simple-router)

A lightweight, framework-agnostic string pattern matcher and router with support for dynamic parameters, wildcards, query strings, and reactive subscriptions.

Can match any string identifiers - URLs, file paths, command names, or custom patterns. Originally inspired by [Sapper-like regex routes](https://sapper.svelte.dev/docs#Regexes_in_routes). Primarily designed for client-side SPA routing, but flexible enough for any pattern matching needs.

## Features

- ✨ Dynamic route parameters with optional regex constraints
- 🎯 Wildcard and catch-all routes
- 📦 Spread parameters for multi-segment matching
- 🔍 Query string parsing (with option to disable per route)
- 🔄 Reactive subscriptions (Svelte store contract compatible)
- 🪶 Zero dependencies (except `@marianmeres/pubsub` for subscriptions)
- 📘 Full TypeScript support
- 🎨 Framework-agnostic

## Installation

```shell
deno add "jsr:@marianmeres/simple-router"
```

```shell
npm install @marianmeres/simple-router
```

```ts
import { SimpleRouter } from "@marianmeres/simple-router";
```

## Quick Example

```ts
import { SimpleRouter } from "@marianmeres/simple-router";

// Routes can be defined via constructor config
const router = new SimpleRouter({
	"/": () => HomePage,
	"/about": () => AboutPage,
	"*": () => NotFoundPage, // catch-all fallback
});

// Or via the "on" API
router.on("/user/[id([0-9]+)]", (params) => {
	console.log("User ID:", params?.id);
	return UserPage(params?.id);
});

router.on("/article/[id]/[slug]", ({ id, slug }) => {
	return ArticlePage(id, slug);
});

// Execute route matching
const component = router.exec("/user/123");

// Use with hash routing
window.onhashchange = () => {
	const component = router.exec(location.hash.slice(1));
	render(component);
};
```

## Route Patterns

### Basic Segments

- `exact` - Matches exactly "exact"
- `[name]` - Matches any segment, captured as `{ name: "value" }`
- `[name(regex)]` - Matches if regex test passes
- `[name]?` - Optional segment
- `[...name]` - Spread params (matches multiple segments)
- `*` - Wildcard (matches zero or more segments)

### Separators

The default separator is `/`. Multiple separators are normalized to single, and separators are trimmed from both ends before matching.

## Pattern Examples

| Route Pattern                 | URL Input          | Params Result                    |
| ----------------------------- | ------------------ | -------------------------------- |
| `/foo`                        | `/bar`             | `null` (no match)                |
| `/foo`                        | (empty)            | `null`                           |
| `/foo/[bar]`                  | `/foo`             | `null`                           |
| `/`                           | (empty)            | `{}`                             |
| `foo`                         | `foo`              | `{}`                             |
| `//foo///bar.baz/`            | `foo/bar.baz`      | `{}`                             |
| `/[foo]`                      | `/bar`             | `{ foo: "bar" }`                 |
| `#/[foo]/[bar]`               | `#/baz/bat`        | `{ foo: "baz", bar: "bat" }`     |
| `/[id([0-9]+)]`               | `/123`             | `{ id: "123" }`                  |
| `/[id([0-9]+)]`               | `/foo`             | `null` (regex fails)             |
| `/foo/[bar]/[id([0-9]+)]`     | `/foo/baz/123`     | `{ bar: "baz", id: "123" }`      |
| `/foo/[bar]?`                 | `/foo`             | `{}`                             |
| `/foo/[bar]?`                 | `/foo/bar`         | `{ bar: "bar" }`                 |
| `/foo/[bar]?/baz`             | `/foo/bar/baz`     | `{ bar: "bar" }`                 |
| `/[...path]/[file]`           | `/foo/bar/baz.js`  | `{ path: "foo/bar", file: "baz.js" }` |
| `/foo/*`                      | `/foo/bar/baz.js`  | `{}`                             |
| `/[foo]/*`                    | `/foo/bar`         | `{ foo: "foo" }`                 |

## API Reference

For the complete API documentation with all methods, types, and detailed examples, see [API.md](API.md).

### SimpleRouter

#### Constructor

```ts
// Simple config (backwards compatible)
const router = new SimpleRouter({
	"/": () => HomePage,
	"/about": () => AboutPage
});

// With options object (for logger support)
const router = new SimpleRouter({
	routes: {
		"/": () => HomePage,
		"/about": () => AboutPage
	},
	logger: myLogger // optional, compatible with @marianmeres/clog
});
```

- `config` - Either a `RouterConfig` object mapping route patterns to callbacks, or a `RouterOptions` object with `routes` and `logger` properties

#### Methods

##### `on(routes, callback, options?)`

Register one or more route patterns with a callback.

```ts
router.on("/users", () => UsersPage);

// Multiple routes to same handler
router.on(["/", "/home", "/index.html"], () => HomePage);

// With dynamic params
router.on("/user/[id]", (params) => UserPage(params?.id));

// With regex constraint
router.on("/post/[id([0-9]+)]", (params) => PostPage(params?.id));

// With label for debugging
router.on("/admin", () => AdminPage, { label: "admin-dashboard" });

// Disable query param parsing for this route
router.on("/raw", (params) => RawPage, { allowQueryParams: false });
```

**Options:**
- `label` - Optional label for debugging (visible via `info()`)
- `allowQueryParams` - Whether to parse query parameters (default: `true`)

**Important:** Routes are matched in registration order. First match wins!

##### `exec(url, fallbackFn?)`

Execute route matching against a URL.

```ts
const result = router.exec("/users");

// With fallback
router.exec("/unknown", () => console.log("Not found"));

// With query params
router.exec("/search?q=hello");
```

Returns the value returned by the matched callback, or `false` if no match.

##### `subscribe(callback)`

Subscribe to router state changes. Follows the [Svelte store contract](https://svelte.dev/docs#Store_contract).

```ts
const unsubscribe = router.subscribe((state) => {
	console.log("Route:", state.route);
	console.log("Params:", state.params);
	console.log("Label:", state.label);
});

// Later
unsubscribe();
```

Returns an unsubscribe function directly. The callback is called immediately with the current state, then on every route change.

##### `reset()`

Clears all registered routes (except catch-all).

```ts
router.reset().on("/new-route", () => NewPage);
```

##### `info()`

Returns a map of registered routes to their labels (for debugging).

```ts
router.on("/users", () => {}, { label: "users-list" });
console.log(router.info()); // { "/users": "users-list" }
```

#### Properties

##### `current`

Gets the current router state (readonly).

```ts
router.exec("/user/123");
console.log(router.current);
// { route: "/user/[id]", params: { id: "123" }, label: null }
```

##### `static debug`

Enable/disable debug logging. When enabled, uses the logger instance (if provided) or falls back to `console.log`.

```ts
SimpleRouter.debug = true;
router.exec("/test"); // Logs matching details to console (or custom logger)
```

### SimpleRoute

Low-level route parser. Usually you don't need to use this directly.

```ts
import { SimpleRoute } from "@marianmeres/simple-router";

const route = new SimpleRoute("/user/[id([0-9]+)]");
const params = route.parse("/user/123");
console.log(params); // { id: "123" }
```

#### Static Methods

##### `parseQueryString(str)`

Parse a query string into an object.

```ts
SimpleRoute.parseQueryString("foo=bar&baz=123");
// Returns: { foo: "bar", baz: "123" }
```

## TypeScript

Full TypeScript support with generic types for type-safe route callbacks and `exec()` return values.

### Generic Router

`SimpleRouter<T>` is generic over `T`, the return type of route callbacks:

```ts
// Typed router - all callbacks must return Component, exec() returns Component | false
const router = new SimpleRouter<Component>({
	"/": () => HomePage,
	"/about": () => AboutPage,
	"*": () => NotFoundPage,
});

const result = router.exec("/about"); // Component | false
if (result !== false) {
	render(result); // result is Component
}

// Without explicit type - T is inferred from callbacks (or defaults to unknown)
const router2 = new SimpleRouter({
	"/": () => "home",
	"/about": () => "about",
});
```

### Exported Types

```ts
import type {
	Logger,
	RouteParams,
	RouteCallback,     // RouteCallback<T = unknown>
	RouterConfig,      // RouterConfig<T = unknown>
	RouterCurrent,
	RouterOnOptions,
	RouterOptions,     // RouterOptions<T = unknown>
	RouterSubscriber,
	RouterUnsubscribe,
} from "@marianmeres/simple-router";
```

## Advanced Examples

### SPA with Hash Routing

```ts
// Type-safe router with Component return type
const router = new SimpleRouter<Component>({
	"/": () => HomePage,
	"/about": () => AboutPage,
	"/user/[id]": (params) => UserPage(params?.id),
	"*": () => NotFoundPage,
});

function render(component: Component) {
	document.getElementById("app").innerHTML = component.render();
}

window.addEventListener("hashchange", () => {
	const path = location.hash.slice(1) || "/";
	const component = router.exec(path); // Component | false
	if (component !== false) {
		render(component);
	}
});

// Trigger initial render
window.dispatchEvent(new HashChangeEvent("hashchange"));
```

### Route Priority

Routes are matched in registration order (first match wins):

```ts
const router = new SimpleRouter();

// Register generic route first
router.on("/user/[id]", (params) => {
	console.log("Generic:", params?.id); // This will match
});

// Specific route registered second (won't match "/user/admin")
router.on("/user/admin", () => {
	console.log("Admin"); // This won't be reached
});

router.exec("/user/admin"); // Logs: "Generic: admin"
```

To fix, register more specific routes first:

```ts
router.on("/user/admin", () => console.log("Admin"));
router.on("/user/[id]", (params) => console.log("User:", params?.id));
```

### Beyond URLs: General Pattern Matching

The router can match any string patterns, not just URLs:

```ts
// File path routing
const fileRouter = new SimpleRouter({
	"src/[module]/[file].ts": ({ module, file }) =>
		console.log(`Module: ${module}, File: ${file}`),
	"assets/images/[...path]": ({ path }) =>
		console.log(`Image path: ${path}`),
});

fileRouter.exec("src/components/Button.ts");
// Logs: "Module: components, File: Button"

fileRouter.exec("assets/images/icons/user.png");
// Logs: "Image path: icons/user.png"

// Command routing
const cmdRouter = new SimpleRouter({
	"user:create": () => createUser(),
	"user:delete:[id([0-9]+)]": ({ id }) => deleteUser(id),
	"cache:clear:[type(redis|memcached)]?": ({ type = "all" }) =>
		clearCache(type),
});

cmdRouter.exec("user:delete:123");
cmdRouter.exec("cache:clear:redis");
cmdRouter.exec("cache:clear"); // type defaults to "all"

// Custom separator (anything that's not a special regex char works)
const dotRouter = new SimpleRouter({
	"app.settings.theme": () => "Theme settings",
	"app.settings.[section]": ({ section }) => `Settings: ${section}`,
});

dotRouter.exec("app.settings.profile");
// Returns: "Settings: profile"
```