react-routes-forge 1.2.0 → 1.4.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
  ---
@@ -22,15 +24,23 @@ One source of truth for your routes — templates for `<Route path={...} />` and
22
24
  - [`build(template, params, query?, options?)`](#buildtemplate-params-query-options)
23
25
  - [`isActivePath(currentPath, template, options?)`](#isactivepathcurrentpath-template-options)
24
26
  - [`extractParamsFromPath(template, resolvedPath)`](#extractparamsfrompathtemplate-resolvedpath)
25
- - [`matchPath(template)`](#matchpathtemplate)
27
+ - [`matchPath(template, options?)`](#matchpathtemplate-options)
26
28
  - [`joinPaths(...segments)`](#joinpathssegments)
27
29
  - [`getParamNames(template)`](#getparamnamestemplate)
28
30
  - [`flattenRoutes(routes)`](#flattenroutesroutes)
29
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)
30
36
  - [React hooks](#react-hooks)
31
37
  - [`useRouteParams<T>()`](#useroutparamst)
32
38
  - [`useNavigateTo()`](#usenavigateto)
33
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)
34
44
  - [Query string support](#query-string-support)
35
45
  - [Hash fragment support](#hash-fragment-support)
36
46
  - [Strict mode](#strict-mode)
@@ -81,7 +91,7 @@ You get:
81
91
  - **Single source of truth** — no duplicate template/builder pairs to keep in sync
82
92
  - **Compile-time param safety** — `.build()` is typed from the path string itself; missing or misspelled params are TypeScript errors
83
93
  - **Query string support** — built into `.build()`, no manual `URLSearchParams` wrangling
84
- - **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
85
95
  - **Deep nesting supported out of the box** — organize routes into as many nested groups as your app needs
86
96
 
87
97
  ---
@@ -98,19 +108,7 @@ yarn add react-routes-forge
98
108
  bun add react-routes-forge
99
109
  ```
100
110
 
101
- React Router is an **optional** peer dependency only needed if you use the bundled hooks (`useRouteParams`, `useNavigateTo`, `useResolvedPath`).
102
-
103
- ```bash
104
- npm install react-router-dom # only if you're using the hooks
105
- # or
106
- pnpm add react-router-dom
107
- # or
108
- yarn add react-router-dom
109
- # or
110
- bun add react-router-dom
111
- ```
112
-
113
- > **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.
114
112
 
115
113
  ---
116
114
 
@@ -167,12 +165,17 @@ That's the entire API surface you need for most apps. Everything below covers th
167
165
 
168
166
  ### Route types
169
167
 
170
- | Route type | Example | Behaves as | Gains |
171
- | ----------- | ----------------------- | --------------------------------------- | ---------------------------------------------------- |
172
- | **Static** | `HOME: '/'` | Plain string primitive | Nothing extrause it directly |
173
- | **Dynamic** | `DETAILS: '/users/:id'` | String-like (coercible to its template) | `.build(params, query?, options?)` and `.paramNames` |
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` |
173
+
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`.
175
+
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.
174
177
 
175
- `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.
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).
176
179
 
177
180
  ---
178
181
 
@@ -182,32 +185,39 @@ Quick reference for everything the package exports — grouped by kind. Click th
182
185
 
183
186
  ### Route definition
184
187
 
185
- | Export | Purpose |
186
- | ---------------------------------------------------- | ------------------------------------------------------ |
187
- | [`defineRoutes(routeMap)`](#defineroutesroutemap) | Builds the typed `PATHS` object from a nested route map |
188
- | `.build(params, query?, options?)` | On every dynamic route — resolves to a concrete URL |
189
- | `.paramNames` | On every dynamic route — the param names it expects |
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 |
190
194
 
191
195
  ### Utilities
192
196
 
193
- | Export | Purpose |
194
- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
195
- | [`build(template, params, query?, options?)`](#buildtemplate-params-query-options) | Resolve a template into a URL without `defineRoutes` |
196
- | [`isActivePath(currentPath, template, options?)`](#isactivepathcurrentpath-template-options)| Check if a path matches a template (nav-highlighting) |
197
- | [`extractParamsFromPath(template, resolvedPath)`](#extractparamsfrompathtemplate-resolvedpath)| Pull param values back out of a resolved URL |
198
- | [`matchPath(template)`](#matchpathtemplate) | Convert a route template into an anchored `RegExp` |
199
- | [`joinPaths(...segments)`](#joinpathssegments) | Join and normalize path segments |
200
- | [`getParamNames(template)`](#getparamnamestemplate) | List the `:param` names in a template |
201
- | [`flattenRoutes(routes)`](#flattenroutesroutes) | Flatten a `PATHS` tree for sitemaps / duplicate detection |
202
- | [`getBreadcrumbs(routes, currentPath, options?)`](#getbreadcrumbsroutes-currentpath-options)| Build a breadcrumb trail from a route tree and current URL |
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) |
203
211
 
204
212
  ### React hooks
205
213
 
206
- | Export | Purpose |
207
- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
208
- | [`useRouteParams<T>()`](#useroutparamst) | Typed wrapper around React Router's `useParams` |
209
- | [`useNavigateTo()`](#usenavigateto) | Typed wrapper around React Router's `useNavigate` |
210
- | [`useResolvedPath(...)`](#useresolvedpathtemplate-params-query-options) | Resolve a template to a string without navigating |
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 |
211
221
 
212
222
  ---
213
223
 
@@ -217,10 +227,11 @@ Quick reference for everything the package exports — grouped by kind. Click th
217
227
 
218
228
  Creates a fully typed route object from a nested plain object.
219
229
 
220
- - Static paths are returned as-is — use them directly anywhere a string is expected (e.g. `<Route path={...} />`).
221
- - 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:
222
233
  - **`.build(params, query?, options?)`** — resolves the template into a concrete URL
223
- - **`.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 `['*']`)
224
235
 
225
236
  Nesting is unlimited — organize routes into as many groups and sub-groups as your app needs.
226
237
 
@@ -228,17 +239,21 @@ Nesting is unlimited — organize routes into as many groups and sub-groups as y
228
239
  const PATHS = defineRoutes({
229
240
  SERVICES: {
230
241
  ROOT: "/services",
231
- BENEFICIARY_CARE_CENTER: {
232
- DETAILS: "/services/beneficiary-care-center/:id",
233
- EDIT: "/services/beneficiary-care-center/edit/:id",
242
+ SUPPORT_CENTER: {
243
+ DETAILS: "/services/support-center/:id",
244
+ EDIT: "/services/support-center/edit/:id",
234
245
  },
235
246
  },
236
247
  } as const);
237
248
 
238
249
  PATHS.SERVICES.ROOT; // '/services'
239
- PATHS.SERVICES.BENEFICIARY_CARE_CENTER.EDIT.build({ id: 7 }); // '/services/beneficiary-care-center/edit/7'
240
- PATHS.SERVICES.BENEFICIARY_CARE_CENTER.EDIT.build({ id: 7 }, { tab: "info" }, { hash: "details" }); // → '/services/beneficiary-care-center/edit/7?tab=info#details'
241
- 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']
242
257
  ```
243
258
 
244
259
  > Always pass `as const` to `defineRoutes()` — it preserves the literal string types that power `.build()`'s compile-time param checking.
@@ -247,7 +262,7 @@ PATHS.SERVICES.BENEFICIARY_CARE_CENTER.EDIT.paramNames; // ['id']
247
262
 
248
263
  ### `build(template, params, query?, options?)`
249
264
 
250
- 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).
251
266
 
252
267
  ```ts
253
268
  import { build } from "react-routes-forge";
@@ -270,11 +285,32 @@ build("/page", {}, undefined, { hash: "section" });
270
285
  // → '/page#section'
271
286
  ```
272
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'
301
+ ```
302
+
303
+ See [Splat (`/*`) segments](#splat--segments) for details.
304
+
273
305
  ---
274
306
 
275
307
  ### `isActivePath(currentPath, template, options?)`
276
308
 
277
- 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).
278
314
 
279
315
  ```ts
280
316
  import { isActivePath } from "react-routes-forge";
@@ -283,6 +319,9 @@ isActivePath("/users/42", "/users/:id"); // true
283
319
  isActivePath("/users/42/posts", "/users/:id"); // false (exact match by default)
284
320
  isActivePath("/users/42/posts", "/users/:id", { exact: false }); // true (prefix match)
285
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)
286
325
  ```
287
326
 
288
327
  A common real-world use — highlighting the active nav link:
@@ -318,7 +357,7 @@ extractParamsFromPath("/a/:x/b/:y", "/a/foo/b/bar");
318
357
 
319
358
  ---
320
359
 
321
- ### `matchPath(template)`
360
+ ### `matchPath(template, options?)`
322
361
 
323
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.
324
363
 
@@ -326,9 +365,19 @@ Converts a route template string into an anchored `RegExp` — useful when you n
326
365
  import { matchPath } from "react-routes-forge";
327
366
 
328
367
  const re = matchPath("/users/:id");
329
- re.test("/users/42"); // true
330
- re.exec("/users/42"); // ['/users/42', '42']
331
- re.test("/users/42/posts"); // false (exact match only)
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
332
381
  ```
333
382
 
334
383
  This is the building block used internally by `isActivePath` and `extractParamsFromPath`.
@@ -356,6 +405,7 @@ Returns the list of param names present in a template string.
356
405
  import { getParamNames } from "react-routes-forge";
357
406
 
358
407
  getParamNames("/users/:id/posts/:postId"); // → ['id', 'postId']
408
+ getParamNames("/files/*"); // → ['*'] (the splat param)
359
409
  getParamNames("/users"); // → []
360
410
  ```
361
411
 
@@ -413,6 +463,7 @@ it("has no duplicate route paths", () => {
413
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.
414
464
 
415
465
  Each breadcrumb entry contains:
466
+
416
467
  - **`key`** — the dot-joined key from the route tree (e.g. `"USERS.EDIT"`)
417
468
  - **`label`** — a human-readable label derived from the key (e.g. `"USERS.ROOT"` → `"Users"`, `"USERS.EDIT"` → `"Edit"`)
418
469
  - **`path`** — the resolved breadcrumb path with params filled in (e.g. `"/users/edit/42"`)
@@ -447,11 +498,21 @@ getBreadcrumbs(PATHS, "/users/edit/42");
447
498
 
448
499
  ```ts
449
500
  getBreadcrumbs(PATHS, "/users/edit/42", {
450
- labelResolver: (key) => key.split(".").pop()!.replace(/_/g, " ").toUpperCase(),
501
+ labelResolver: (key) =>
502
+ key.split(".").pop()!.replace(/_/g, " ").toUpperCase(),
451
503
  });
452
504
  // → [{ label: "HOME" }, { label: "ROOT" }, { label: "EDIT" }]
453
505
  ```
454
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
+
455
516
  **Pre-flattened input** — pass a cached `flattenRoutes()` result instead of the tree:
456
517
 
457
518
  ```ts
@@ -461,16 +522,84 @@ getBreadcrumbs(flat, "/users/edit/42"); // same result as passing the tree
461
522
 
462
523
  ---
463
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
+
464
593
  ## React hooks
465
594
 
466
- 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`. The hooks work identically with **`react-router-dom`** (v6/v7) and **`react-router`** (v6/v7) — they're implemented on top of hooks that both packages export, so you get the same behaviour no matter which one your app imports from.
467
596
 
468
597
  ### `useRouteParams<T>()`
469
598
 
470
- 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:
471
600
 
472
601
  ```tsx
473
- import { useRouteParams } from "react-routes-forge";
602
+ import { useRouteParams } from "react-routes-forge/hooks";
474
603
 
475
604
  // Route: '/users/edit/:id'
476
605
  function EditUser() {
@@ -485,6 +614,13 @@ function Comment() {
485
614
  useRouteParams<"/posts/:postId/comments/:commentId">();
486
615
  // ...
487
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
+ }
488
624
  ```
489
625
 
490
626
  ---
@@ -494,7 +630,7 @@ function Comment() {
494
630
  Thin, typed wrapper around `useNavigate()` that accepts a resolved path (the output of `.build()`) along with the usual navigation options.
495
631
 
496
632
  ```tsx
497
- import { useNavigateTo } from "react-routes-forge";
633
+ import { useNavigateTo } from "react-routes-forge/hooks";
498
634
  import { PATHS } from "./paths";
499
635
 
500
636
  function Component() {
@@ -515,10 +651,10 @@ navigateTo(PATHS.USERS.ROOT, { state: { from: "settings" } });
515
651
 
516
652
  ### `useResolvedPath(template, params, query?, options?)`
517
653
 
518
- 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).
519
655
 
520
656
  ```tsx
521
- import { useResolvedPath } from "react-routes-forge";
657
+ import { useResolvedPath } from "react-routes-forge/hooks";
522
658
 
523
659
  const path = useResolvedPath("/users/:id", { id: 42 });
524
660
  // → '/users/42'
@@ -526,6 +662,10 @@ const path = useResolvedPath("/users/:id", { id: 42 });
526
662
  const path = useResolvedPath("/users/:id", { id: 42 }, { tab: "info" });
527
663
  // → '/users/42?tab=info'
528
664
 
665
+ // Splat segments are preserved
666
+ const path = useResolvedPath("/files/*", { "*": "a/b/c" });
667
+ // → '/files/a/b/c'
668
+
529
669
  // Strict mode — throws RangeError instead of warning on missing params
530
670
  const path = useResolvedPath("/users/:id", {}, undefined, { strict: true });
531
671
 
@@ -536,6 +676,107 @@ const path = useResolvedPath("/page", {}, undefined, { hash: "section" });
536
676
 
537
677
  ---
538
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 search-params API (built on `useLocation` + `useNavigate`, which both `react-router-dom` and `react-router` export in v6 and v7). 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
+ });
776
+ ```
777
+
778
+ ---
779
+
539
780
  ## Query string support
540
781
 
541
782
  Every path-resolving function — `.build()`, `build()`, and `useResolvedPath()` — accepts an optional query object as its second-to-last argument.
@@ -554,6 +795,13 @@ build("/search", {}, { tags: ["admin", "moderator"] });
554
795
  // → '/search?tags=admin&tags=moderator'
555
796
  ```
556
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
+
557
805
  **`null` and `undefined` values are dropped**, so you can pass optional filters without conditionally building the object:
558
806
 
559
807
  ```ts
@@ -561,15 +809,20 @@ build("/users", {}, { sort: "asc", filter: undefined });
561
809
  // → '/users?sort=asc'
562
810
  ```
563
811
 
564
- 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:
565
813
 
566
814
  ```ts
567
- import { build } from "react-routes-forge";
815
+ PATHS.USERS.ROOT.build({ sort: "asc", page: 2 });
816
+ // → '/users?sort=asc&page=2'
568
817
 
818
+ // The standalone build() util works the same way for raw templates:
819
+ import { build } from "react-routes-forge";
569
820
  build(PATHS.USERS.ROOT, {}, { sort: "asc", page: 2 });
570
821
  // → '/users?sort=asc&page=2'
571
822
  ```
572
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
+
573
826
  ---
574
827
 
575
828
  ## Hash fragment support
@@ -590,7 +843,12 @@ build("/page", {}, undefined, { hash: "section" });
590
843
  // → '/page#section'
591
844
 
592
845
  // Via useResolvedPath
593
- useResolvedPath("/users/:id", { id: 5 }, { tab: "billing" }, { hash: "invoice" });
846
+ useResolvedPath(
847
+ "/users/:id",
848
+ { id: 5 },
849
+ { tab: "billing" },
850
+ { hash: "invoice" },
851
+ );
594
852
  // → '/users/5?tab=billing#invoice'
595
853
  ```
596
854
 
@@ -672,30 +930,30 @@ Everywhere the template string itself was used (e.g. `<Route path={PATHS.SERVICE
672
930
 
673
931
  ## Known behaviours & gotchas
674
932
 
675
- ### Dynamic routes are `String` objects, not primitives
933
+ ### Routes are `String` objects, not primitives
676
934
 
677
- `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:
678
936
 
679
937
  ```ts
680
938
  // ✓ These all work as expected
681
- String(PATHS.USERS.EDIT); // '/users/edit/:id'
939
+ String(PATHS.HOME); // '/'
682
940
  `${PATHS.USERS.EDIT}`; // '/users/edit/:id'
683
941
  PATHS.USERS.EDIT == "/users/edit/:id"; // true (loose equality)
684
942
 
685
943
  // ✗ Watch out for these
686
- typeof PATHS.USERS.EDIT; // 'object' ← not 'string'
687
- PATHS.USERS.EDIT === "/users/edit/:id"; // false ← strict equality fails
944
+ typeof PATHS.HOME; // 'object' ← not 'string'
945
+ PATHS.HOME === "/"; // false ← strict equality fails
688
946
  ```
689
947
 
690
- 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.
691
949
 
692
950
  ### `useResolvedPath` vs. the library's own `buildPath`
693
951
 
694
- `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).
695
953
 
696
- ### ESM-only package
954
+ ### ESM + CommonJS builds
697
955
 
698
- 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.
699
957
 
700
958
  ---
701
959
 
@@ -711,15 +969,29 @@ PATHS.USERS.EDIT.build({ userId: 42 }); // ✗ compile error — 'id' expected,
711
969
 
712
970
  `.paramNames` is similarly typed as a literal array of the exact param names in the template, not a generic `string[]`.
713
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
+
714
985
  ---
715
986
 
716
987
  ## Testing
717
988
 
718
- 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.
719
990
 
720
991
  ```bash
721
- npm test # run the full suite once
722
- 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)
723
995
 
724
996
  # equivalent with other package managers
725
997
  pnpm test / pnpm test:watch
@@ -727,6 +999,8 @@ yarn test / yarn test:watch
727
999
  bun test / bun test:watch
728
1000
  ```
729
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
+
730
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.
731
1005
 
732
1006
  ---
@@ -734,7 +1008,7 @@ If you're contributing, new behaviour should come with a matching test — the e
734
1008
  ## Requirements
735
1009
 
736
1010
  - **React** ≥ 17 (peer dependency)
737
- - **react-router-dom** ≥ 6 (optional peer dependency — required only for the bundled hooks)
1011
+ - **react-router-dom** ≥ 6 **or** **react-router** ≥ 6 (both optional peer dependenciesone of them is required only for the `react-routes-forge/hooks` entry. The hooks behave identically with either package and across v6 and v7, so `react-router-dom`-based apps and `react-router`-only v7 apps both work with no extra setup)
738
1012
  - **Node.js** ≥ 18
739
1013
  - **TypeScript** ≥ 5 recommended for full type inference (the package works with plain JavaScript too, just without compile-time param checking)
740
1014