react-routes-forge 1.1.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # react-routes-forge
2
2
 
3
- **Type-safe route definitions with automatic path builders for React apps.**
3
+ **Type-safe route definitions, automatic path builders, query parameter handling, and active route matching for React applications with zero duplication.**
4
4
 
5
- One source of truth for your routes templates for `<Route path={...} />` and typed builders for navigation — with no duplication and no manual string concatenation.
5
+ 📖 **[Documentation](https://mhsmustafa84.github.io/react-routes-forge)** | 🚀 **[Live Demo (POC)](https://mhsmustafa84.github.io/react-routes-forge-poc)**
6
6
 
7
+ [![Documentation](https://img.shields.io/badge/Documentation-VitePress-646cff.svg)](https://mhsmustafa84.github.io/react-routes-forge)
8
+ [![Live Demo](https://img.shields.io/badge/Live%20Demo-POC-brightgreen.svg)](https://mhsmustafa84.github.io/react-routes-forge-poc)
7
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](#license)
8
10
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue.svg)](#)
9
- [![Node.js 24+](https://img.shields.io/badge/Node.js-24+-green.svg)](#requirements)
11
+ [![Node.js 18+](https://img.shields.io/badge/Node.js-18+-green.svg)](#requirements)
10
12
  [![Combined CI/CD](https://github.com/mhsmustafa84/react-routes-forge/actions/workflows/ci-security.yml/badge.svg)](https://github.com/mhsmustafa84/react-routes-forge/actions/workflows/ci-security.yml)
11
13
 
12
14
  ---
@@ -17,20 +19,30 @@ One source of truth for your routes — templates for `<Route path={...} />` and
17
19
  - [Installation](#installation)
18
20
  - [Quick start](#quick-start)
19
21
  - [Cheat sheet](#cheat-sheet)
20
- - [Core concepts](#core-concepts)
21
22
  - [API reference](#api-reference)
22
23
  - [`defineRoutes(routeMap)`](#defineroutesroutemap)
23
24
  - [`build(template, params, query?, options?)`](#buildtemplate-params-query-options)
24
25
  - [`isActivePath(currentPath, template, options?)`](#isactivepathcurrentpath-template-options)
25
26
  - [`extractParamsFromPath(template, resolvedPath)`](#extractparamsfrompathtemplate-resolvedpath)
27
+ - [`matchPath(template, options?)`](#matchpathtemplate-options)
26
28
  - [`joinPaths(...segments)`](#joinpathssegments)
27
29
  - [`getParamNames(template)`](#getparamnamestemplate)
28
30
  - [`flattenRoutes(routes)`](#flattenroutesroutes)
31
+ - [`getBreadcrumbs(routes, currentPath, options?)`](#getbreadcrumbsroutes-currentpath-options)
32
+ - [`appendQuery(path, query?, hash?)`](#appendquerypath-query-hash)
33
+ - [`extractQueryFromPath(path, options?)`](#extractqueryfrompathpath-options)
34
+ - [`devWarn(message)`](#devwarnmessage)
35
+ - [`clearPathCache()`](#clearpathcache)
29
36
  - [React hooks](#react-hooks)
30
37
  - [`useRouteParams<T>()`](#useroutparamst)
31
38
  - [`useNavigateTo()`](#usenavigateto)
32
39
  - [`useResolvedPath(template, params, query?, options?)`](#useresolvedpathtemplate-params-query-options)
40
+ - [`useActivePath(template, options?)`](#useactivepathtemplate-options)
41
+ - [`useTypedSearchParams(options?)`](#usetypedsearchparamsoptions)
42
+ - [Splat (`/*`) segments](#splat--segments)
43
+ - [Route validation](#route-validation)
33
44
  - [Query string support](#query-string-support)
45
+ - [Hash fragment support](#hash-fragment-support)
34
46
  - [Strict mode](#strict-mode)
35
47
  - [Migrating from the old pattern](#migrating-from-the-old-pattern)
36
48
  - [Known behaviours & gotchas](#known-behaviours--gotchas)
@@ -79,7 +91,7 @@ You get:
79
91
  - **Single source of truth** — no duplicate template/builder pairs to keep in sync
80
92
  - **Compile-time param safety** — `.build()` is typed from the path string itself; missing or misspelled params are TypeScript errors
81
93
  - **Query string support** — built into `.build()`, no manual `URLSearchParams` wrangling
82
- - **Zero runtime dependencies** for the core API — React Router is an optional peer dependency, only required if you use the hooks
94
+ - **Zero runtime dependencies** for the core API
83
95
  - **Deep nesting supported out of the box** — organize routes into as many nested groups as your app needs
84
96
 
85
97
  ---
@@ -96,19 +108,7 @@ yarn add react-routes-forge
96
108
  bun add react-routes-forge
97
109
  ```
98
110
 
99
- React Router is an **optional** peer dependency only needed if you use the bundled hooks (`useRouteParams`, `useNavigateTo`, `useResolvedPath`).
100
-
101
- ```bash
102
- npm install react-router-dom # only if you're using the hooks
103
- # or
104
- pnpm add react-router-dom
105
- # or
106
- yarn add react-router-dom
107
- # or
108
- bun add react-router-dom
109
- ```
110
-
111
- > **Note:** this package ships ESM-only. See [Known behaviours & gotchas](#known-behaviours--gotchas) for details.
111
+ > **Note:** this package ships dual **ESM + CommonJS** builds. See [Known behaviours & gotchas](#known-behaviours--gotchas) for details.
112
112
 
113
113
  ---
114
114
 
@@ -133,6 +133,8 @@ export const PATHS = defineRoutes({
133
133
  } as const);
134
134
  ```
135
135
 
136
+ > **Always pass `as const`** — it preserves the literal string types that power `.build()`'s compile-time param checking. Without it, TypeScript widens your path strings to generic `string` and you lose type safety.
137
+
136
138
  ```tsx
137
139
  // App.tsx — static paths and dynamic templates both work directly as strings
138
140
  import { Routes, Route } from "react-router-dom";
@@ -146,46 +148,76 @@ import { PATHS } from "./paths";
146
148
  </Routes>;
147
149
  ```
148
150
 
149
- ```ts
151
+ ```tsx
150
152
  // Navigating — call .build() to resolve a dynamic path into a real URL
151
- navigate(PATHS.USERS.EDIT.build({ id: 42 })); // '/users/edit/42'
152
- navigate(PATHS.ROLES.PERMISSIONS.build({ name: "admin" })); // '/roles/permissions/admin'
153
- navigate(PATHS.HOME); // '/'
153
+ import { useNavigate } from "react-router-dom";
154
+
155
+ function MyComponent() {
156
+ const navigate = useNavigate();
157
+ // ↓ Param type-checked from the template ":id"
158
+ navigate(PATHS.USERS.EDIT.build({ id: 42 })); // → '/users/edit/42'
159
+ navigate(PATHS.ROLES.PERMISSIONS.build({ name: "admin" })); // → '/roles/permissions/admin'
160
+ navigate(PATHS.HOME); // → '/' (static paths work directly)
161
+ }
154
162
  ```
155
163
 
156
164
  That's the entire API surface you need for most apps. Everything below covers the rest of the toolkit.
157
165
 
158
- ---
166
+ ### Route types
159
167
 
160
- ## Cheat sheet
168
+ | Route type | Example | Behaves as | Gains |
169
+ | ----------- | ----------------------- | --------------------------------------- | --------------------------------------------------------------------- |
170
+ | **Static** | `HOME: '/'` | String-like (coercible to its template) | `.build(query?, options?)` — attach query/hash, no params to fill |
171
+ | **Dynamic** | `DETAILS: '/users/:id'` | String-like (coercible to its template) | `.build(params, query?, options?)` and `.paramNames` |
172
+ | **Splat** | `FILES: '/files/*'` | String-like (coercible to its template) | `.build(params, query?, options?)` and `.paramNames` |
161
173
 
162
- Quick reference for everything the package exports. Click through to the full section for details and examples.
174
+ `defineRoutes()` walks your route object recursively, wrapping every path in a string-coercible object and attaching a `.build()` helper so both static and dynamic routes can carry a query string or hash. Dynamic paths (containing a `:param` segment or a trailing `/*` splat) additionally gain `.paramNames`.
163
175
 
164
- | Export | Kind | Purpose |
165
- | ---------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------- |
166
- | [`defineRoutes(routeMap)`](#defineroutesroutemap) | function | Builds the typed `PATHS` object from a nested route map |
167
- | [`build(template, params, query?, options?)`](#buildtemplate-params-query-options) | function | Resolve a template into a URL without `defineRoutes` |
168
- | [`isActivePath(currentPath, template, options?)`](#isactivepathcurrentpath-template-options) | function | Check if a path matches a template (nav-highlighting) |
169
- | [`extractParamsFromPath(template, resolvedPath)`](#extractparamsfrompathtemplate-resolvedpath) | function | Pull param values back out of a resolved URL |
170
- | [`joinPaths(...segments)`](#joinpathssegments) | function | Join and normalize path segments |
171
- | [`getParamNames(template)`](#getparamnamestemplate) | function | List the `:param` names in a template |
172
- | [`flattenRoutes(routes)`](#flattenroutesroutes) | function | Flatten a `PATHS` tree for sitemaps / duplicate detection |
173
- | [`useRouteParams<T>()`](#useroutparamst) | hook | Typed wrapper around React Router's `useParams` |
174
- | [`useNavigateTo()`](#usenavigateto) | hook | Typed wrapper around React Router's `useNavigate` |
175
- | [`useResolvedPath(...)`](#useresolvedpathtemplate-params-query-options) | hook | Resolve a template to a string without navigating |
176
- | `.build(params, query?, options?)` | method | On every dynamic route — resolves to a concrete URL |
177
- | `.paramNames` | property | On every dynamic route — the param names it expects |
176
+ Param names are `[A-Za-z0-9_]` only (matching React Router), so a static suffix after a param stays literal — `/files/:name.json` builds `{ name: "report" }` → `/files/report.json`, and `:name.json` is **not** treated as a single param name.
178
177
 
179
- ---
178
+ > `defineRoutes()` also validates your templates in development — missing leading `/`, non-trailing `*`, and duplicate path templates all produce a `console.warn`. See [Route validation](#route-validation).
180
179
 
181
- ## Core concepts
180
+ ---
182
181
 
183
- | Route type | Example | Behaves as | Gains |
184
- | ----------- | ----------------------- | --------------------------------------- | ---------------------------------------------------- |
185
- | **Static** | `HOME: '/'` | Plain string primitive | Nothing extra — use it directly |
186
- | **Dynamic** | `DETAILS: '/users/:id'` | String-like (coercible to its template) | `.build(params, query?, options?)` and `.paramNames` |
182
+ ## Cheat sheet
187
183
 
188
- `defineRoutes()` walks your route object recursively, leaving static paths untouched and wrapping any path containing a `:param` segment so it can carry a builder alongside its template string.
184
+ Quick reference for everything the package exports grouped by kind. Click through to the full section for details and examples.
185
+
186
+ ### Route definition
187
+
188
+ | Export | Purpose |
189
+ | ------------------------------------------------- | ------------------------------------------------------- |
190
+ | [`defineRoutes(routeMap)`](#defineroutesroutemap) | Builds the typed `PATHS` object from a nested route map |
191
+ | `.build(query?, options?)` | On every **static** route — attach query string / hash |
192
+ | `.build(params, query?, options?)` | On every **dynamic** route — resolves to a concrete URL |
193
+ | `.paramNames` | On every dynamic route — the param names it expects |
194
+
195
+ ### Utilities
196
+
197
+ | Export | Purpose |
198
+ | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
199
+ | [`build(template, params, query?, options?)`](#buildtemplate-params-query-options) | Resolve a template into a URL without `defineRoutes` |
200
+ | [`isActivePath(currentPath, template, options?)`](#isactivepathcurrentpath-template-options) | Check if a path matches a template (nav-highlighting) |
201
+ | [`extractParamsFromPath(template, resolvedPath)`](#extractparamsfrompathtemplate-resolvedpath) | Pull param values back out of a resolved URL |
202
+ | [`matchPath(template, options?)`](#matchpathtemplate-options) | Convert a route template into an anchored `RegExp` |
203
+ | [`joinPaths(...segments)`](#joinpathssegments) | Join and normalize path segments |
204
+ | [`getParamNames(template)`](#getparamnamestemplate) | List the `:param` names in a template |
205
+ | [`flattenRoutes(routes)`](#flattenroutesroutes) | Flatten a `PATHS` tree for sitemaps / duplicate detection |
206
+ | [`getBreadcrumbs(routes, currentPath, options?)`](#getbreadcrumbsroutes-currentpath-options) | Build a breadcrumb trail from a route tree and current URL |
207
+ | [`appendQuery(path, query?, hash?)`](#appendquerypath-query-hash) | Append query params / hash to an existing path |
208
+ | [`extractQueryFromPath(path, options?)`](#extractqueryfrompathpath-options) | Parse a query string back into an object |
209
+ | [`devWarn(message)`](#devwarnmessage) | Emit a `console.warn` in non-production builds |
210
+ | [`clearPathCache()`](#clearpathcache) | Reset internal regex caches (mainly for tests) |
211
+
212
+ ### React hooks
213
+
214
+ | Export | Purpose |
215
+ | ----------------------------------------------------------------------- | ------------------------------------------------------------- |
216
+ | [`useRouteParams<T>()`](#useroutparamst) | Typed wrapper around React Router's `useParams` |
217
+ | [`useNavigateTo()`](#usenavigateto) | Typed wrapper around React Router's `useNavigate` |
218
+ | [`useResolvedPath(...)`](#useresolvedpathtemplate-params-query-options) | Resolve a template to a string without navigating |
219
+ | [`useActivePath(template, options?)`](#useactivepathtemplate-options) | Check if the current location matches a route template |
220
+ | [`useTypedSearchParams(options?)`](#usetypedsearchparamsoptions) | Typed `useSearchParams` with boolean/number coercion |
189
221
 
190
222
  ---
191
223
 
@@ -195,10 +227,11 @@ Quick reference for everything the package exports. Click through to the full se
195
227
 
196
228
  Creates a fully typed route object from a nested plain object.
197
229
 
198
- - Static paths are returned as-is — use them directly anywhere a string is expected (e.g. `<Route path={...} />`).
199
- - Dynamic paths (containing `:param`) gain:
230
+ - Every path is string-coercible — use it directly anywhere a string is expected (e.g. `<Route path={...} />`).
231
+ - Static paths gain **`.build(query?, options?)`** — attach a query string and/or hash fragment without params.
232
+ - Dynamic paths (containing `:param`) and splat paths (trailing `/*`) gain:
200
233
  - **`.build(params, query?, options?)`** — resolves the template into a concrete URL
201
- - **`.paramNames`** — array of the param names extracted from the template, e.g. `['id']`
234
+ - **`.paramNames`** — array of the param names extracted from the template, e.g. `['id']` (a splat is reported as `['*']`)
202
235
 
203
236
  Nesting is unlimited — organize routes into as many groups and sub-groups as your app needs.
204
237
 
@@ -206,16 +239,21 @@ Nesting is unlimited — organize routes into as many groups and sub-groups as y
206
239
  const PATHS = defineRoutes({
207
240
  SERVICES: {
208
241
  ROOT: "/services",
209
- BENEFICIARY_CARE_CENTER: {
210
- DETAILS: "/services/beneficiary-care-center/:id",
211
- EDIT: "/services/beneficiary-care-center/edit/:id",
242
+ SUPPORT_CENTER: {
243
+ DETAILS: "/services/support-center/:id",
244
+ EDIT: "/services/support-center/edit/:id",
212
245
  },
213
246
  },
214
247
  } as const);
215
248
 
216
249
  PATHS.SERVICES.ROOT; // '/services'
217
- PATHS.SERVICES.BENEFICIARY_CARE_CENTER.EDIT.build({ id: 7 }); // '/services/beneficiary-care-center/edit/7'
218
- PATHS.SERVICES.BENEFICIARY_CARE_CENTER.EDIT.paramNames; // ['id']
250
+ PATHS.SERVICES.SUPPORT_CENTER.EDIT.build({ id: 7 }); // '/services/support-center/edit/7'
251
+ PATHS.SERVICES.SUPPORT_CENTER.EDIT.build(
252
+ { id: 7 },
253
+ { tab: "info" },
254
+ { hash: "details" },
255
+ ); // → '/services/support-center/edit/7?tab=info#details'
256
+ PATHS.SERVICES.SUPPORT_CENTER.EDIT.paramNames; // ['id']
219
257
  ```
220
258
 
221
259
  > Always pass `as const` to `defineRoutes()` — it preserves the literal string types that power `.build()`'s compile-time param checking.
@@ -224,7 +262,7 @@ PATHS.SERVICES.BENEFICIARY_CARE_CENTER.EDIT.paramNames; // ['id']
224
262
 
225
263
  ### `build(template, params, query?, options?)`
226
264
 
227
- Standalone path resolver — for building a URL without going through `defineRoutes`, or for adding a query string to a **static** path (which has no `.build()` of its own).
265
+ Standalone path resolver — for building a URL without going through `defineRoutes`, or for resolving a raw template string (rather than a route from a `PATHS` tree).
228
266
 
229
267
  ```ts
230
268
  import { build } from "react-routes-forge";
@@ -239,13 +277,40 @@ build("/users", {}, { sort: "asc" });
239
277
  // Strict mode — throw instead of warn when a param is missing
240
278
  build("/users/:id", {}, undefined, { strict: true });
241
279
  // ✗ throws RangeError: [route-forge] Missing required param(s) ":id" in template "/users/:id".
280
+
281
+ // Hash fragment — appended after the query string
282
+ build("/users/:id", { id: 42 }, { tab: "info" }, { hash: "details" });
283
+ // → '/users/42?tab=info#details'
284
+ build("/page", {}, undefined, { hash: "section" });
285
+ // → '/page#section'
286
+ ```
287
+
288
+ **Param values are URL-encoded by default** (`encodeURIComponent`), so characters like `/`, `?`, `#`, or `%` in a value can't break the URL structure:
289
+
290
+ ```ts
291
+ build("/search/:query", { query: "a/b" });
292
+ // → '/search/a%2Fb'
293
+
294
+ // Pass { encode: false } if a value is already encoded
295
+ build("/search/:query", { query: "a%2Fb" }, undefined, { encode: false });
296
+ // → '/search/a%2Fb'
297
+
298
+ // Splat segments capture a path-like remainder, preserving `/` separators
299
+ build("/files/*", { "*": "reports/2026/q1" });
300
+ // → '/files/reports/2026/q1'
242
301
  ```
243
302
 
303
+ See [Splat (`/*`) segments](#splat--segments) for details.
304
+
244
305
  ---
245
306
 
246
307
  ### `isActivePath(currentPath, template, options?)`
247
308
 
248
- Checks whether a resolved path matches a route template — the building block for nav-highlighting ("is this link active?"). Query strings on `currentPath` are ignored automatically.
309
+ Checks whether a resolved path matches a route template — the building block for nav-highlighting ("is this link active?"). Query strings on `currentPath` are ignored automatically. It mirrors React Router's `NavLink` matching semantics:
310
+
311
+ - **Case-insensitive by default** — pass `{ caseSensitive: true }` to opt out.
312
+ - **Trailing slashes are tolerated** — `/users/` matches `/users`.
313
+ - **`exact: true` (default)** requires a full match; **`exact: false`** matches any path that starts with the template (so `/` matches every path as a prefix).
249
314
 
250
315
  ```ts
251
316
  import { isActivePath } from "react-routes-forge";
@@ -254,6 +319,9 @@ isActivePath("/users/42", "/users/:id"); // true
254
319
  isActivePath("/users/42/posts", "/users/:id"); // false (exact match by default)
255
320
  isActivePath("/users/42/posts", "/users/:id", { exact: false }); // true (prefix match)
256
321
  isActivePath("/users/42?tab=profile", "/users/:id"); // true (query string ignored)
322
+ isActivePath("/Users/42", "/users/:id"); // true (case-insensitive by default)
323
+ isActivePath("/Users/42", "/users/:id", { caseSensitive: true }); // false
324
+ isActivePath("/users/42/", "/users/:id"); // true (trailing slash tolerated)
257
325
  ```
258
326
 
259
327
  A common real-world use — highlighting the active nav link:
@@ -289,6 +357,33 @@ extractParamsFromPath("/a/:x/b/:y", "/a/foo/b/bar");
289
357
 
290
358
  ---
291
359
 
360
+ ### `matchPath(template, options?)`
361
+
362
+ Converts a route template string into an anchored `RegExp` — useful when you need custom matching logic beyond [`isActivePath`](#isactivepathcurrentpath-template-options) or [`extractParamsFromPath`](#extractparamsfrompathtemplate-resolvedpath). Query strings are **not** stripped; split on `"?"` first if needed.
363
+
364
+ ```ts
365
+ import { matchPath } from "react-routes-forge";
366
+
367
+ const re = matchPath("/users/:id");
368
+ re.test("/users/42"); // true
369
+ re.exec("/users/42"); // ['/users/42', '42']
370
+ re.test("/users/42/posts"); // false (exact match only)
371
+ ```
372
+
373
+ **Options:**
374
+
375
+ - **`end?: boolean`** (default `true`) — anchor the pattern to the end of the path. Pass `false` to match a prefix at a segment boundary (`/users` matches `/users/42` but not `/usersettings`).
376
+ - **`caseSensitive?: boolean`** (default `false`) — match case-insensitively by default; pass `true` to opt out.
377
+
378
+ ```ts
379
+ matchPath("/users/:id", { end: false }).test("/users/42/posts"); // true
380
+ matchPath("/Users/42", { caseSensitive: true }).test("/users/42"); // false
381
+ ```
382
+
383
+ This is the building block used internally by `isActivePath` and `extractParamsFromPath`.
384
+
385
+ ---
386
+
292
387
  ### `joinPaths(...segments)`
293
388
 
294
389
  Safely joins path segments, normalizing duplicate/missing slashes.
@@ -310,6 +405,7 @@ Returns the list of param names present in a template string.
310
405
  import { getParamNames } from "react-routes-forge";
311
406
 
312
407
  getParamNames("/users/:id/posts/:postId"); // → ['id', 'postId']
408
+ getParamNames("/files/*"); // → ['*'] (the splat param)
313
409
  getParamNames("/users"); // → []
314
410
  ```
315
411
 
@@ -362,16 +458,148 @@ it("has no duplicate route paths", () => {
362
458
 
363
459
  ---
364
460
 
461
+ ### `getBreadcrumbs(routes, currentPath, options?)`
462
+
463
+ Walks a route tree (or a pre-flattened array from `flattenRoutes()`) and returns every route that is an ancestor of (or an exact match to) the current URL. Dynamic params in ancestor paths are automatically resolved from the matched portion of the URL. Query strings on `currentPath` are ignored.
464
+
465
+ Each breadcrumb entry contains:
466
+
467
+ - **`key`** — the dot-joined key from the route tree (e.g. `"USERS.EDIT"`)
468
+ - **`label`** — a human-readable label derived from the key (e.g. `"USERS.ROOT"` → `"Users"`, `"USERS.EDIT"` → `"Edit"`)
469
+ - **`path`** — the resolved breadcrumb path with params filled in (e.g. `"/users/edit/42"`)
470
+ - **`isCurrent`** — `true` only for the deepest (exact) match
471
+
472
+ ```ts
473
+ import { defineRoutes, getBreadcrumbs } from "react-routes-forge";
474
+
475
+ const PATHS = defineRoutes({
476
+ HOME: "/",
477
+ USERS: {
478
+ ROOT: "/users",
479
+ EDIT: "/users/edit/:id",
480
+ },
481
+ SERVICES: {
482
+ BCC: {
483
+ EDIT: "/services/bcc/edit/:id",
484
+ },
485
+ },
486
+ } as const);
487
+
488
+ getBreadcrumbs(PATHS, "/users/edit/42");
489
+ // →
490
+ // [
491
+ // { key: "HOME", label: "Home", path: "/", isCurrent: false },
492
+ // { key: "USERS.ROOT", label: "Users", path: "/users", isCurrent: false },
493
+ // { key: "USERS.EDIT", label: "Edit", path: "/users/edit/42", isCurrent: true },
494
+ // ]
495
+ ```
496
+
497
+ **Custom label resolver** — override the default key-to-label conversion:
498
+
499
+ ```ts
500
+ getBreadcrumbs(PATHS, "/users/edit/42", {
501
+ labelResolver: (key) =>
502
+ key.split(".").pop()!.replace(/_/g, " ").toUpperCase(),
503
+ });
504
+ // → [{ label: "HOME" }, { label: "ROOT" }, { label: "EDIT" }]
505
+ ```
506
+
507
+ **Label map** — the ergonomic alternative for a handful of overrides. Keys are dot-joined route keys; matching keys take precedence over `labelResolver`:
508
+
509
+ ```ts
510
+ getBreadcrumbs(PATHS, "/users/edit/42", {
511
+ labels: { "USERS.ROOT": "Members", "USERS.EDIT": "Edit member" },
512
+ });
513
+ // → [{ label: "Home" }, { label: "Members" }, { label: "Edit member" }]
514
+ ```
515
+
516
+ **Pre-flattened input** — pass a cached `flattenRoutes()` result instead of the tree:
517
+
518
+ ```ts
519
+ const flat = flattenRoutes(PATHS);
520
+ getBreadcrumbs(flat, "/users/edit/42"); // same result as passing the tree
521
+ ```
522
+
523
+ ---
524
+
525
+ ### `appendQuery(path, query?, hash?)`
526
+
527
+ Appends a query string and/or hash fragment to a path that may already contain a query or hash. Existing query pairs are preserved, the query is inserted before any hash, and an existing hash is kept unless a new one is given.
528
+
529
+ ```ts
530
+ import { appendQuery } from "react-routes-forge";
531
+
532
+ appendQuery("/users?tab=list", { page: 2 }); // → '/users?tab=list&page=2'
533
+ appendQuery("/users#top", { tab: "list" }); // → '/users?tab=list#top'
534
+ appendQuery("/users", { active: true }); // → '/users?active=true'
535
+ appendQuery("/users", { tag: ["a", "b"] }); // → '/users?tag=a&tag=b'
536
+ ```
537
+
538
+ This is the same helper every path-resolving function uses internally.
539
+
540
+ ---
541
+
542
+ ### `extractQueryFromPath(path, options?)`
543
+
544
+ Parses the query string out of a path (or bare query string) back into a plain object. Repeated keys become arrays; single keys are scalar strings.
545
+
546
+ **Options:**
547
+
548
+ - **`coerceBooleans?: boolean`** — convert the strings `"true"`/`"false"` to real booleans.
549
+ - **`coerceNumbers?: boolean`** — convert numeric strings (`"42"`, `"3.14"`) to real numbers.
550
+
551
+ ```ts
552
+ import { extractQueryFromPath } from "react-routes-forge";
553
+
554
+ extractQueryFromPath("/users/42?tab=profile&tag=a&tag=b");
555
+ // → { tab: "profile", tag: ["a", "b"] }
556
+
557
+ extractQueryFromPath("/search?active=true", { coerceBooleans: true });
558
+ // → { active: true }
559
+
560
+ extractQueryFromPath("/search?page=2&limit=10", { coerceNumbers: true });
561
+ // → { page: 2, limit: 10 }
562
+ ```
563
+
564
+ ---
565
+
566
+ ### `devWarn(message)`
567
+
568
+ Emits a `console.warn` in non-production environments. Shared by the core utilities and `defineRoutes()` so the production check lives in one place.
569
+
570
+ ```ts
571
+ import { devWarn } from "react-routes-forge";
572
+
573
+ devWarn("[route-forge] Something looks wrong.");
574
+ // → console.warn in dev/test, silent in production bundles
575
+ ```
576
+
577
+ ---
578
+
579
+ ### `clearPathCache()`
580
+
581
+ Clears the internal regex caches used by `matchPath()` / prefix matching. Primarily useful in test suites to prevent cached patterns from leaking across test cases.
582
+
583
+ ```ts
584
+ import { clearPathCache } from "react-routes-forge";
585
+
586
+ beforeEach(() => {
587
+ clearPathCache();
588
+ });
589
+ ```
590
+
591
+ ---
592
+
365
593
  ## React hooks
366
594
 
367
- Import these only if you're using React Router — they're tree-shakeable and won't be bundled unless imported.
595
+ Import these only if you're using React Router — they live in a separate `react-routes-forge/hooks` entry, so the core package never pulls in `react-router-dom`.
368
596
 
369
597
  ### `useRouteParams<T>()`
370
598
 
371
- Typed wrapper around React Router's `useParams`. Pass the route's template string as a generic to get a correctly typed params object back — no casting, and it works for any number of `:param` segments.
599
+ Typed wrapper around React Router's `useParams`. Pass the route's template string as a generic to get a correctly typed params object back — no casting, and it works for any number of `:param` segments. Alternatively, pass a **dynamic route value from your `PATHS` tree** and the params are inferred from it automatically:
372
600
 
373
601
  ```tsx
374
- import { useRouteParams } from "react-routes-forge";
602
+ import { useRouteParams } from "react-routes-forge/hooks";
375
603
 
376
604
  // Route: '/users/edit/:id'
377
605
  function EditUser() {
@@ -386,6 +614,13 @@ function Comment() {
386
614
  useRouteParams<"/posts/:postId/comments/:commentId">();
387
615
  // ...
388
616
  }
617
+
618
+ // Or pass a route from your PATHS tree — types are inferred:
619
+ const PATHS = defineRoutes({ USERS: { EDIT: "/users/edit/:id" } } as const);
620
+ function EditUserInferred() {
621
+ const { id } = useRouteParams(PATHS.USERS.EDIT);
622
+ // ...
623
+ }
389
624
  ```
390
625
 
391
626
  ---
@@ -395,7 +630,7 @@ function Comment() {
395
630
  Thin, typed wrapper around `useNavigate()` that accepts a resolved path (the output of `.build()`) along with the usual navigation options.
396
631
 
397
632
  ```tsx
398
- import { useNavigateTo } from "react-routes-forge";
633
+ import { useNavigateTo } from "react-routes-forge/hooks";
399
634
  import { PATHS } from "./paths";
400
635
 
401
636
  function Component() {
@@ -416,10 +651,10 @@ navigateTo(PATHS.USERS.ROOT, { state: { from: "settings" } });
416
651
 
417
652
  ### `useResolvedPath(template, params, query?, options?)`
418
653
 
419
- Resolves a path template to a concrete URL string without navigating — useful for `<Link to={...} />`, preloading, or building a URL for something other than `navigate()`. Backed by React Router's `generatePath`, so it correctly supports splat (`*`) and optional (`:param?`) segments. Accepts the same `query` and `options` as [`build()`](#buildtemplate-params-query-options).
654
+ Resolves a path template to a concrete URL string without navigating — useful for `<Link to={...} />`, preloading, or building a URL for something other than `navigate()`. It mirrors the library's own [`build()`](#buildtemplate-params-query-options), so splat (`*`) and optional (`:param?`) segments work identically to the core API — and the encoding/`strict` behaviour is consistent across React Router v6 and v7. Accepts the same `query` and `options` as [`build()`](#buildtemplate-params-query-options).
420
655
 
421
656
  ```tsx
422
- import { useResolvedPath } from "react-routes-forge";
657
+ import { useResolvedPath } from "react-routes-forge/hooks";
423
658
 
424
659
  const path = useResolvedPath("/users/:id", { id: 42 });
425
660
  // → '/users/42'
@@ -427,8 +662,117 @@ const path = useResolvedPath("/users/:id", { id: 42 });
427
662
  const path = useResolvedPath("/users/:id", { id: 42 }, { tab: "info" });
428
663
  // → '/users/42?tab=info'
429
664
 
665
+ // Splat segments are preserved
666
+ const path = useResolvedPath("/files/*", { "*": "a/b/c" });
667
+ // → '/files/a/b/c'
668
+
430
669
  // Strict mode — throws RangeError instead of warning on missing params
431
670
  const path = useResolvedPath("/users/:id", {}, undefined, { strict: true });
671
+
672
+ // With hash fragment
673
+ const path = useResolvedPath("/page", {}, undefined, { hash: "section" });
674
+ // → '/page#section'
675
+ ```
676
+
677
+ ---
678
+
679
+ ### `useActivePath(template, options?)`
680
+
681
+ A hook that checks whether the current location matches a route template or path — a thin wrapper around [`isActivePath()`](#isactivepathcurrentpath-template-options) that reads the location from the router. Same matching semantics: case-insensitive by default, trailing slashes tolerated, `exact: true` by default.
682
+
683
+ ```tsx
684
+ import { useActivePath } from "react-routes-forge/hooks";
685
+
686
+ function Nav() {
687
+ const isUsersActive = useActivePath(PATHS.USERS.ROOT, { exact: false });
688
+ const isProfileActive = useActivePath("/users/:id", { caseSensitive: true });
689
+
690
+ return (
691
+ <Link className={isUsersActive ? "active" : ""} to={PATHS.USERS.ROOT}>
692
+ Users
693
+ </Link>
694
+ );
695
+ }
696
+ ```
697
+
698
+ ---
699
+
700
+ ### `useTypedSearchParams(options?)`
701
+
702
+ A typed wrapper around React Router's `useSearchParams`. Returns a parsed query params object (using [`extractQueryFromPath()`](#extractqueryfrompathpath-options)) and a setter that updates the query string. The same coercion options are supported: `{ coerceBooleans: true }` and `{ coerceNumbers: true }`.
703
+
704
+ ```tsx
705
+ import { useTypedSearchParams } from "react-routes-forge/hooks";
706
+
707
+ function Filters() {
708
+ const [query, setQuery] = useTypedSearchParams({
709
+ coerceBooleans: true,
710
+ coerceNumbers: true,
711
+ });
712
+
713
+ // query.page is a number when the URL is '/search?page=2'
714
+ const nextPage = (query.page ?? 0) + 1;
715
+ setQuery({ ...query, page: nextPage });
716
+
717
+ // Clear a filter by omitting it (or pass null/undefined)
718
+ setQuery({ page: 1, sort: "asc" });
719
+ }
720
+ ```
721
+
722
+ ---
723
+
724
+ ## Splat (`/*`) segments
725
+
726
+ Splat routes (`/files/*`) capture the rest of the path — including slashes — into a single `*` param, matching React Router semantics. Supported across the entire core API, not just the hooks.
727
+
728
+ ```ts
729
+ import { defineRoutes, build, isActivePath, extractParamsFromPath } from "react-routes-forge";
730
+
731
+ const PATHS = defineRoutes({
732
+ FILES: "/files/*",
733
+ } as const);
734
+
735
+ PATHS.FILES.build({ "*": "reports/2026/q1" }); // → '/files/reports/2026/q1'
736
+ String(PATHS.FILES); // → '/files/*'
737
+ PATHS.FILES.paramNames; // → ['*']
738
+ ```
739
+
740
+ Behaviour notes:
741
+
742
+ - **Slashes in the value are preserved** (they're path separators); other special characters are still URL-encoded — `"/files/*"` with `"a b/c?d"` → `"/files/a%20b/c%3Fd"`.
743
+ - **A missing splat value drops the `/*` suffix** — `/files/*` resolves to `/files` (matching React Router, where the splat route also matches the base path).
744
+ - `isActivePath("/files/a/b", "/files/*")` → `true`; `extractParamsFromPath("/files/*", "/files/a/b")` → `{ "*": "a/b" }`.
745
+ - A splat must be **trailing** (`/files/*`). A `*` in the middle of a path is invalid and produces a dev warning (see below).
746
+
747
+ ---
748
+
749
+ ## Route validation
750
+
751
+ `defineRoutes()` validates every template in development (no-op in production) and warns via `console.warn` about likely mistakes:
752
+
753
+ | Problem | Example | Warning |
754
+ | ------- | ------- | ------- |
755
+ | Missing leading `/` | `"users/:id"` | `does not start with "/"` |
756
+ | Non-trailing splat | `"/files/*/extra"` | `*` outside a trailing `"/*"` |
757
+ | Duplicate path template | `FOO: "/foo"` and `BAR: "/foo"` | `Duplicate route path "/foo"` (names both keys) |
758
+ | Static route shadowed by a dynamic route above it | `DETAILS: "/users/:id"` defined before `ME: "/users/me"` | `"ME" is shadowed by dynamic route "DETAILS"` |
759
+
760
+ ```ts
761
+ // duplicate route paths are caught at startup instead of as a routing bug later
762
+ defineRoutes({
763
+ A: { FOO: "/foo" },
764
+ B: { FOO: "/foo" },
765
+ } as const);
766
+ // ⚠ console.warn: [route-forge] Duplicate route path "/foo" for "A.FOO" and "B.FOO". Only one of them will be reachable.
767
+ ```
768
+
769
+ These are warnings, not errors — invalid routes still build, so a broken definition can't crash your app at import time. Run a stricter check once in tests if you want duplicates to fail the build:
770
+
771
+ ```ts
772
+ it("has no duplicate route paths", () => {
773
+ const paths = flattenRoutes(PATHS).map((r) => r.path);
774
+ expect(paths).toEqual([...new Set(paths)]);
775
+ });
432
776
  ```
433
777
 
434
778
  ---
@@ -451,6 +795,13 @@ build("/search", {}, { tags: ["admin", "moderator"] });
451
795
  // → '/search?tags=admin&tags=moderator'
452
796
  ```
453
797
 
798
+ **Boolean values** serialize to `"true"`/`"false"`:
799
+
800
+ ```ts
801
+ build("/search", {}, { active: true, draft: false });
802
+ // → '/search?active=true&draft=false'
803
+ ```
804
+
454
805
  **`null` and `undefined` values are dropped**, so you can pass optional filters without conditionally building the object:
455
806
 
456
807
  ```ts
@@ -458,15 +809,51 @@ build("/users", {}, { sort: "asc", filter: undefined });
458
809
  // → '/users?sort=asc'
459
810
  ```
460
811
 
461
- Static routes don't have a fluent `.build()` (there's nothing to interpolate), so use the standalone `build()` util to attach a query string to them:
812
+ Static routes have a `.build()` too — there are no params to interpolate, but you can still attach a query string or hash:
462
813
 
463
814
  ```ts
464
- import { build } from "react-routes-forge";
815
+ PATHS.USERS.ROOT.build({ sort: "asc", page: 2 });
816
+ // → '/users?sort=asc&page=2'
465
817
 
818
+ // The standalone build() util works the same way for raw templates:
819
+ import { build } from "react-routes-forge";
466
820
  build(PATHS.USERS.ROOT, {}, { sort: "asc", page: 2 });
467
821
  // → '/users?sort=asc&page=2'
468
822
  ```
469
823
 
824
+ Reading query params back out is handled by [`extractQueryFromPath(path, options?)`](#extractqueryfrompathpath-options), and appending to an existing URL (e.g. a link with pre-set filters) by [`appendQuery(path, query?, hash?)`](#appendquerypath-query-hash).
825
+
826
+ ---
827
+
828
+ ## Hash fragment support
829
+
830
+ URL hash fragments (`#section`) are supported in every path-resolving function — `.build()`, `build()`, and `useResolvedPath()` — via the `hash` option. The hash is appended after the query string, if any.
831
+
832
+ ```ts
833
+ // Via fluent .build() on a dynamic route
834
+ PATHS.USERS.DETAILS.build({ id: 42 }, undefined, { hash: "profile" });
835
+ // → '/users/42#profile'
836
+
837
+ // With query + hash
838
+ PATHS.USERS.DETAILS.build({ id: 42 }, { tab: "info" }, { hash: "details" });
839
+ // → '/users/42?tab=info#details'
840
+
841
+ // Via standalone build()
842
+ build("/page", {}, undefined, { hash: "section" });
843
+ // → '/page#section'
844
+
845
+ // Via useResolvedPath
846
+ useResolvedPath(
847
+ "/users/:id",
848
+ { id: 5 },
849
+ { tab: "billing" },
850
+ { hash: "invoice" },
851
+ );
852
+ // → '/users/5?tab=billing#invoice'
853
+ ```
854
+
855
+ The leading `#` is added automatically — pass just the fragment name (e.g. `"details"`, not `"#details"`).
856
+
470
857
  ---
471
858
 
472
859
  ## Strict mode
@@ -543,30 +930,30 @@ Everywhere the template string itself was used (e.g. `<Route path={PATHS.SERVICE
543
930
 
544
931
  ## Known behaviours & gotchas
545
932
 
546
- ### Dynamic routes are `String` objects, not primitives
933
+ ### Routes are `String` objects, not primitives
547
934
 
548
- `defineRoutes` wraps dynamic paths in [`String` objects](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/String) so that `.build()` and `.paramNames` can be attached as properties. This means:
935
+ `defineRoutes` wraps **every** path — static, dynamic, and splat — in [`String` objects](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/String) so that `.build()` (and `.paramNames` on dynamic routes) can be attached as properties. This means:
549
936
 
550
937
  ```ts
551
938
  // ✓ These all work as expected
552
- String(PATHS.USERS.EDIT); // '/users/edit/:id'
939
+ String(PATHS.HOME); // '/'
553
940
  `${PATHS.USERS.EDIT}`; // '/users/edit/:id'
554
941
  PATHS.USERS.EDIT == "/users/edit/:id"; // true (loose equality)
555
942
 
556
943
  // ✗ Watch out for these
557
- typeof PATHS.USERS.EDIT; // 'object' ← not 'string'
558
- PATHS.USERS.EDIT === "/users/edit/:id"; // false ← strict equality fails
944
+ typeof PATHS.HOME; // 'object' ← not 'string'
945
+ PATHS.HOME === "/"; // false ← strict equality fails
559
946
  ```
560
947
 
561
- Prefer template literals or explicit `String()` coercion when comparing dynamic route values, and avoid using them as plain object/`Map` keys. Static routes (`HOME`, `USERS.ROOT`, …) remain genuine string primitives and are unaffected.
948
+ Prefer template literals or explicit `String()` coercion when comparing route values, and avoid using them as plain object/`Map` keys.
562
949
 
563
950
  ### `useResolvedPath` vs. the library's own `buildPath`
564
951
 
565
- `useResolvedPath` delegates to React Router's `generatePath` when all params are present, which correctly handles splat (`*`) and optional (`:param?`) segments that the library's own regex-based substitution does not. If params are missing, it falls back to the same `buildPath`/strict-mode behaviour described above — so failure modes stay consistent, but full splat/optional support is only guaranteed via the hook, not via `.build()`.
952
+ `useResolvedPath` is a thin wrapper around the library's own `buildPath`, so splat (`*`), optional (`:param?`) and encoding behaviour are identical across every entry point and consistent across React Router v6 and v7 (v7's `generatePath` URL-encodes values itself, which would otherwise double-encode).
566
953
 
567
- ### ESM-only package
954
+ ### ESM + CommonJS builds
568
955
 
569
- This package ships **ESM only** (`"type": "module"`, no `require` export condition). Consumers on a plain CommonJS setup (`require('react-routes-forge')`) are not supported. All modern bundlers (Vite, Webpack ≥ 5, esbuild, Rollup) handle ESM packages transparently. If you're in a CJS-only environment, you'll need a bundler transform or a compatibility shim.
956
+ This package ships both **ESM and CommonJS** bundles (`dist/index.js` for ESM, `dist/index.cjs` for CJS), with `exports` conditions routing each environment to the right format. Modern bundlers use the ESM build; Node.js `require()` gets the CommonJS build automatically. Consumers on plain CommonJS are fully supported.
570
957
 
571
958
  ---
572
959
 
@@ -582,15 +969,29 @@ PATHS.USERS.EDIT.build({ userId: 42 }); // ✗ compile error — 'id' expected,
582
969
 
583
970
  `.paramNames` is similarly typed as a literal array of the exact param names in the template, not a generic `string[]`.
584
971
 
972
+ To annotate a plain route object (e.g. a shared constant used by `defineRoutes()`), import the `RouteTree` type:
973
+
974
+ ```ts
975
+ import type { RouteTree } from "react-routes-forge";
976
+
977
+ const routes: RouteTree = {
978
+ HOME: "/",
979
+ USERS: { ROOT: "/users", EDIT: "/users/edit/:id" },
980
+ };
981
+ ```
982
+
983
+ The result of `defineRoutes()` is typed as `ResolvedRoutes`, and individual leaves are `StaticRoute<T>` (static paths, with `.build(query?, options?)`) or `DynamicRoute<T>` (dynamic/splat paths, with `.build(params, ...)` and `.paramNames`) — all exported as types if you need to reference them. `MatchPathOptions` types the [`matchPath()`](#matchpathtemplate-options) options bag.
984
+
585
985
  ---
586
986
 
587
987
  ## Testing
588
988
 
589
- The package ships with a full test suite covering the core builder/utility functions and the React hooks, including strict-mode behaviour, query string edge cases (arrays, `null`/`undefined` filtering), nested route groups, and duplicate-path detection via `flattenRoutes`.
989
+ The package ships with a full test suite covering the core builder/utility functions and the React hooks, including strict-mode behaviour, query string edge cases (arrays, booleans, `null`/`undefined` filtering), splat segments, route validation, nested route groups, and duplicate-path detection.
590
990
 
591
991
  ```bash
592
- npm test # run the full suite once
593
- npm run test:watch # watch mode
992
+ npm test # run the full suite once (bun test)
993
+ npm run test:watch # watch mode
994
+ npm run test:coverage # run with coverage (vitest + v8, outputs lcov.info)
594
995
 
595
996
  # equivalent with other package managers
596
997
  pnpm test / pnpm test:watch
@@ -598,6 +999,8 @@ yarn test / yarn test:watch
598
999
  bun test / bun test:watch
599
1000
  ```
600
1001
 
1002
+ CI runs the suite across a **matrix of Node.js versions (18 / 20 / 22 / 24)** and **React Router v6 and v7**, plus lint, a production build, and a coverage job that uploads `lcov.info` as a build artifact (see `.github/workflows/ci-security.yml`).
1003
+
601
1004
  If you're contributing, new behaviour should come with a matching test — the existing suite is organized by function/hook, so add cases alongside the relevant `describe` block rather than starting a new file.
602
1005
 
603
1006
  ---
@@ -605,7 +1008,7 @@ If you're contributing, new behaviour should come with a matching test — the e
605
1008
  ## Requirements
606
1009
 
607
1010
  - **React** ≥ 17 (peer dependency)
608
- - **react-router-dom** ≥ 6 (optional peer dependency — required only for the bundled hooks)
1011
+ - **react-router-dom** ≥ 6 (optional peer dependency — required only for the `react-routes-forge/hooks` entry)
609
1012
  - **Node.js** ≥ 18
610
1013
  - **TypeScript** ≥ 5 recommended for full type inference (the package works with plain JavaScript too, just without compile-time param checking)
611
1014