@rangojs/router 0.0.0-experimental.13 → 0.0.0-experimental.15

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.
@@ -0,0 +1,226 @@
1
+ ---
2
+ name: testing
3
+ description: Unit test route trees with buildRouteTree()
4
+ argument-hint:
5
+ ---
6
+
7
+ # Route Tree Unit Testing
8
+
9
+ Unit test route definitions by inspecting the route tree, segment IDs, middleware, intercepts, loaders, and pattern matching without running a dev server.
10
+
11
+ ## Setup
12
+
13
+ The `buildRouteTree` helper lives in `src/__tests__/helpers/route-tree.ts` (not shipped with npm). Import it in your test files:
14
+
15
+ ```typescript
16
+ import { buildRouteTree } from "./helpers/route-tree.js";
17
+ ```
18
+
19
+ ## buildRouteTree(urlPatterns)
20
+
21
+ Takes a `urls()` result and returns a `RouteTree` with inspection methods:
22
+
23
+ ```typescript
24
+ import { urls } from "@rangojs/router";
25
+ import { buildRouteTree } from "./helpers/route-tree.js";
26
+
27
+ const tree = buildRouteTree(
28
+ urls(({ path, layout, middleware, loader, intercept, when }) => [
29
+ layout(RootLayout, () => [
30
+ middleware(authMiddleware),
31
+ path("/", HomePage, { name: "home" }),
32
+ path("/blog/:slug", BlogPost, { name: "blog.post" }, () => [
33
+ loader(PostLoader),
34
+ ]),
35
+ ]),
36
+ ])
37
+ );
38
+ ```
39
+
40
+ ## RouteTree API
41
+
42
+ ### Route Patterns
43
+
44
+ ```typescript
45
+ tree.routes() // { home: "/", "blog.post": "/blog/:slug" }
46
+ tree.routeNames() // ["home", "blog.post"]
47
+ ```
48
+
49
+ ### URL Matching
50
+
51
+ ```typescript
52
+ const m = tree.match("/blog/hello");
53
+ m.routeKey // "blog.post"
54
+ m.params // { slug: "hello" }
55
+
56
+ tree.match("/nonexistent") // null
57
+ ```
58
+
59
+ ### Segment IDs
60
+
61
+ ```typescript
62
+ tree.segmentId("home") // "M0L0L0R0"
63
+ tree.segmentIds() // { home: "M0L0L0R0", "blog.post": "M0L0L0R1" }
64
+ tree.segmentPath("blog.post")
65
+ // [
66
+ // { id: "M0L0", type: "layout" }, // synthetic root
67
+ // { id: "M0L0L0", type: "layout" }, // RootLayout
68
+ // { id: "M0L0L0R1", type: "route", pattern: "/blog/:slug" },
69
+ // ]
70
+ ```
71
+
72
+ ### Entry Access
73
+
74
+ ```typescript
75
+ tree.entry("blog.post") // EntryData
76
+ tree.entry("blog.post")!.parent!.type // "layout"
77
+ tree.entryByPattern("/blog/:slug") // EntryData (lookup by URL pattern)
78
+ ```
79
+
80
+ ### Middleware
81
+
82
+ ```typescript
83
+ tree.hasMiddleware("home") // true (inherited from layout)
84
+ tree.middleware("home") // [authMiddleware] (direct only)
85
+ tree.middlewareChain("home")
86
+ // [{ segmentId: "M0L0L0", count: 1 }] // all middleware root-to-route
87
+ ```
88
+
89
+ ### Loaders
90
+
91
+ ```typescript
92
+ tree.hasLoaders("blog.post") // true
93
+ tree.loaders("blog.post") // [LoaderEntry { loader, revalidate, cache? }]
94
+ ```
95
+
96
+ ### Intercepts
97
+
98
+ ```typescript
99
+ tree.intercepts("home")
100
+ // [{ slotName: "@modal", routeName: "card", hasWhen: true, whenCount: 1, hasLoader: false, hasMiddleware: false }]
101
+ tree.interceptEntries("home") // raw InterceptEntry[]
102
+ ```
103
+
104
+ ### Parallel Slots
105
+
106
+ ```typescript
107
+ tree.parallelSlots("home") // EntryData[] of type="parallel"
108
+ tree.parallelSlotNames("home") // ["@sidebar", "@main"]
109
+ ```
110
+
111
+ ### Boundaries
112
+
113
+ ```typescript
114
+ tree.hasErrorBoundary("home") // boolean
115
+ tree.hasNotFoundBoundary("home") // boolean
116
+ ```
117
+
118
+ ### Cache & Loading
119
+
120
+ ```typescript
121
+ tree.hasCache("home") // boolean
122
+ tree.hasLoading("home") // boolean
123
+ ```
124
+
125
+ ### Debug
126
+
127
+ ```typescript
128
+ console.log(tree.debug());
129
+ // Route Tree:
130
+ // home: / [M0L0L0R0] (M0L0 > M0L0L0 > M0L0L0R0) {mw:1}
131
+ // blog.post: /blog/:slug [M0L0L0R1] (M0L0 > M0L0L0 > M0L0L0R1) {mw:1, ld:1}
132
+ ```
133
+
134
+ ## Segment ID Format
135
+
136
+ | Prefix | Meaning |
137
+ |--------|---------|
138
+ | `M0` | Mount index (router instance) |
139
+ | `L` | Layout |
140
+ | `R` | Route |
141
+ | `P` | Parallel slot |
142
+ | `D` | Loader (data) |
143
+ | `C` | Cache boundary |
144
+
145
+ Example: `M0L0L0R1` = mount 0, synthetic root layout, user layout, second route.
146
+
147
+ ## Examples
148
+
149
+ ### include() with prefix
150
+
151
+ ```typescript
152
+ const blogPatterns = urls(({ path }) => [
153
+ path("/", BlogIndex, { name: "index" }),
154
+ path("/:slug", BlogPost, { name: "post" }),
155
+ ]);
156
+
157
+ const tree = buildRouteTree(
158
+ urls(({ path, include }) => [
159
+ path("/", HomePage, { name: "home" }),
160
+ include("/blog", blogPatterns, { name: "blog" }),
161
+ ])
162
+ );
163
+
164
+ expect(tree.routes()).toEqual({
165
+ home: "/",
166
+ "blog.index": "/blog",
167
+ "blog.post": "/blog/:slug",
168
+ });
169
+ ```
170
+
171
+ ### Middleware chain
172
+
173
+ ```typescript
174
+ const authMw = async (ctx, next) => next();
175
+ const logMw = async (ctx, next) => next();
176
+
177
+ const tree = buildRouteTree(
178
+ urls(({ path, layout, middleware }) => [
179
+ layout(RootLayout, () => [
180
+ middleware(logMw),
181
+ layout(AuthLayout, () => [
182
+ middleware(authMw),
183
+ path("/dashboard", Dashboard, { name: "dashboard" }),
184
+ ]),
185
+ ]),
186
+ ])
187
+ );
188
+
189
+ expect(tree.middlewareChain("dashboard")).toEqual([
190
+ { segmentId: "M0L0L0", count: 1 }, // logMw on RootLayout
191
+ { segmentId: "M0L0L0L0", count: 1 }, // authMw on AuthLayout
192
+ ]);
193
+ ```
194
+
195
+ ### Intercepts with when()
196
+
197
+ ```typescript
198
+ const tree = buildRouteTree(
199
+ urls(({ path, layout, intercept, when }) => [
200
+ layout(ShopLayout, () => [
201
+ path("/products", ProductList, { name: "products" }),
202
+ path("/products/:id", ProductDetail, { name: "product.detail" }),
203
+ intercept("@modal", "product.detail", ProductModal, () => [
204
+ when((ctx) => ctx.from.pathname.startsWith("/products")),
205
+ ]),
206
+ ]),
207
+ ])
208
+ );
209
+
210
+ const intercepts = tree.intercepts("products");
211
+ // Note: intercepts are on the parent where intercept() is called
212
+ ```
213
+
214
+ ### Constrained parameters
215
+
216
+ ```typescript
217
+ const tree = buildRouteTree(
218
+ urls(({ path }) => [
219
+ path("/:locale(en|fr)?/about", AboutPage, { name: "about" }),
220
+ ])
221
+ );
222
+
223
+ expect(tree.match("/about")).not.toBeNull();
224
+ expect(tree.match("/fr/about")!.params).toEqual({ locale: "fr" });
225
+ expect(tree.match("/de/about")).toBeNull();
226
+ ```
@@ -45,22 +45,35 @@ export const urlpatterns = urls(({ path, layout }) => [
45
45
 
46
46
  ## Type-Safe href()
47
47
 
48
- ### Server: ctx.reverse + scopedReverse
48
+ ### Server: ctx.reverse with global route names
49
49
 
50
- In route handlers, use `scopedReverse()` for local route name autocomplete:
50
+ In route handlers, use `ctx.reverse()` with the global dotted route names
51
+ from `router.named-routes.gen.ts`:
51
52
 
52
53
  ```typescript
53
- import { scopedReverse } from "@rangojs/router";
54
-
55
- path("/product/:slug", (ctx) => {
56
- const reverse = scopedReverse<typeof shopPatterns>(ctx.reverse);
54
+ import type { Handler } from "@rangojs/router";
57
55
 
58
- reverse("cart"); // Type-safe local name
59
- reverse("product", { slug: "widget" }); // Type-safe with params
60
- reverse("blog.post"); // Absolute names always allowed
56
+ export const ProductHandler: Handler<"shop.product"> = (ctx) => {
57
+ ctx.reverse("shop.cart"); // Global name
58
+ ctx.reverse("shop.product", { slug: "widget" }); // With params
59
+ ctx.reverse("blog.post", { postId: "1" }); // Cross-module
61
60
 
62
61
  return <ProductPage slug={ctx.params.slug} />;
63
- }, { name: "product" })
62
+ };
63
+ ```
64
+
65
+ For opt-in per-module isolation (after running `npx rango generate urls/shop.tsx`),
66
+ use `scopedReverse()` with a local route map:
67
+
68
+ ```typescript
69
+ import { scopedReverse } from "@rangojs/router";
70
+ import type { routes } from "./shop.gen.js";
71
+
72
+ export const ProductHandler: Handler<"product", routes> = (ctx) => {
73
+ const reverse = scopedReverse<routes>(ctx.reverse);
74
+ reverse("cart"); // Local name
75
+ reverse("product", { slug: "widget" }); // Local with params
76
+ };
64
77
  ```
65
78
 
66
79
  ### Client: href + useHref
@@ -192,13 +205,14 @@ export const SearchPage: Handler<"search"> = (ctx) => {
192
205
  ```
193
206
 
194
207
  This avoids circular references because `Handler` defaults to `GeneratedRouteMap`
195
- (standalone gen file) instead of `RegisteredRoutes` (which depends on `router.tsx`).
208
+ (from `router.named-routes.gen.ts`) instead of `RegisteredRoutes` (which depends on `router.tsx`).
196
209
 
197
- You can also pass an explicit route map if needed:
210
+ You can also pass an explicit route map for per-module isolation (opt-in,
211
+ after running `npx rango generate`):
198
212
 
199
213
  ```typescript
200
214
  import type { Handler } from "@rangojs/router";
201
- import type { routes } from "../urls.gen.js";
215
+ import type { routes } from "./urls.gen.js";
202
216
 
203
217
  export const SearchPage: Handler<"search", routes> = (ctx) => { ... };
204
218
  ```
@@ -240,16 +254,15 @@ type P = RouteParams<"blogPost", routes>;
240
254
 
241
255
  ### Generated route types
242
256
 
243
- In the generated route types (`urls.gen.ts` and `GeneratedRouteMap`), routes with
244
- search schemas use `{ path, search }` objects:
257
+ In the generated `router.named-routes.gen.ts`, routes with search schemas
258
+ use `{ path, search }` objects:
245
259
 
246
260
  ```typescript
247
- // urls.gen.ts (auto-generated by `npx rango extract-names`)
248
- export const routes = {
249
- search: { path: "/search", search: { q: "string", page: "number?", sort: "string?" } },
250
- home: "/", // No search schema -> plain string
261
+ // router.named-routes.gen.ts (auto-generated)
262
+ export const NamedRoutes = {
263
+ "search.index": { path: "/search", search: { q: "string", page: "number?", sort: "string?" } },
264
+ "home.index": "/", // No search schema -> plain string
251
265
  } as const;
252
- export type routes = typeof routes;
253
266
  ```
254
267
 
255
268
  ## Loader Type Safety
@@ -426,9 +439,10 @@ global type declarations (like `RSCRouter.Env`).
426
439
  }
427
440
  ```
428
441
 
429
- The `files` array ensures `router.tsx` (which contains `declare global { namespace RSCRouter { ... } }`)
430
- is always included in the compilation even if nothing directly imports it. Each app gets its own
431
- typed environment without interfering with other apps.
442
+ The `files` array ensures `router.tsx` (which contains `declare global { namespace RSCRouter { interface Env } }`)
443
+ is always included in the compilation even if nothing directly imports it. Route types come from the
444
+ auto-generated `*.named-routes.gen.ts` file (via `rango generate`), not from manual declaration.
445
+ Each app gets its own typed environment without interfering with other apps.
432
446
 
433
447
  ## Complete Type-Safe Setup
434
448
 
@@ -450,21 +464,26 @@ export const urlpatterns = urls(({ path, layout, loader }) => [
450
464
  ]),
451
465
  ]);
452
466
 
453
- // 3. router.tsx - Registration
467
+ // 3. router.tsx - Create router and export reverse
454
468
  const router = createRouter<AppEnv>({
455
469
  document: Document,
456
- urls: urlpatterns,
457
- });
470
+ }).routes(urlpatterns);
458
471
 
472
+ // Optional: register environment type globally for implicit typing
459
473
  declare global {
460
474
  namespace RSCRouter {
461
475
  interface Env extends AppEnv {}
462
476
  }
463
477
  }
464
478
 
479
+ export const reverse = router.reverse;
465
480
  export default router;
466
481
 
467
- // 4. loaders/*.ts - Type-safe loaders
482
+ // 4. Run `npx rango generate src/router.tsx` to generate
483
+ // router.named-routes.gen.ts (auto-registers GeneratedRouteMap globally).
484
+ // No manual RegisteredRoutes declaration needed.
485
+
486
+ // 5. loaders/*.ts - Type-safe loaders
468
487
  export const ProductLoader = createLoader("product", async (ctx) => {
469
488
  // ctx.params: { slug: string }
470
489
  // ctx.env.Variables.user: User | undefined
@@ -472,13 +491,12 @@ export const ProductLoader = createLoader("product", async (ctx) => {
472
491
  return { product: await fetchProduct(ctx.params.slug) };
473
492
  });
474
493
 
475
- // 5. Server: ctx.reverse for named routes
494
+ // 6. Server: ctx.reverse for named routes
476
495
  path("/product/:slug", (ctx) => {
477
- const reverse = scopedReverse<typeof urlpatterns>(ctx.reverse);
478
- return <Link to={reverse("shop")}>Back to Shop</Link>;
496
+ return <Link to={ctx.reverse("shop")}>Back to Shop</Link>;
479
497
  }, { name: "product" })
480
498
 
481
- // 6. Client: useHref for mounted paths, href for absolute
499
+ // 7. Client: useHref for mounted paths, href for absolute
482
500
  "use client";
483
501
  import { useHref, href, Link } from "@rangojs/router/client";
484
502
  <Link to={href("/shop/product/widget")}>Widget</Link>
package/src/bin/rango.ts CHANGED
@@ -1,24 +1,75 @@
1
- import { resolve } from "node:path";
2
- import { findTsFiles, writePerModuleRouteTypesForFile } from "../build/generate-route-types.ts";
1
+ import { resolve, dirname, extname } from "node:path";
2
+ import { readFileSync, statSync } from "node:fs";
3
+ import { findTsFiles, writePerModuleRouteTypesForFile, writeCombinedRouteTypes } from "../build/generate-route-types.ts";
3
4
 
4
5
  const [command, ...args] = process.argv.slice(2);
5
6
 
6
- if (command === "extract-names") {
7
- const dir = args[0] ?? "./src";
8
- const resolvedDir = resolve(dir);
9
- console.log(`[rango] Scanning ${resolvedDir} for url modules...`);
7
+ if (command === "generate") {
8
+ if (args.length === 0) {
9
+ console.error("[rango] Usage: rango generate <file|dir> [file2 ...]");
10
+ process.exit(1);
11
+ }
12
+
13
+ // Expand args: files are used directly, directories are scanned
14
+ const files: string[] = [];
15
+ for (const arg of args) {
16
+ const resolved = resolve(arg);
17
+ try {
18
+ if (statSync(resolved).isDirectory()) {
19
+ files.push(...findTsFiles(resolved));
20
+ } else {
21
+ files.push(resolved);
22
+ }
23
+ } catch {
24
+ console.warn(`[rango] Skipping ${arg}: not found`);
25
+ }
26
+ }
27
+
28
+ if (files.length === 0) {
29
+ console.log("[rango] No files to process");
30
+ process.exit(0);
31
+ }
32
+
33
+ const routerFiles: string[] = [];
10
34
 
11
- const files = findTsFiles(resolvedDir);
12
35
  for (const filePath of files) {
13
- writePerModuleRouteTypesForFile(filePath);
36
+ try {
37
+ const source = readFileSync(filePath, "utf-8");
38
+
39
+ // Detect file type and generate accordingly
40
+ const isRouter = /\bcreateRouter\s*[<(]/.test(source);
41
+ const isUrls = source.includes("urls(");
42
+
43
+ if (isRouter) {
44
+ routerFiles.push(filePath);
45
+ }
46
+
47
+ if (isUrls) {
48
+ writePerModuleRouteTypesForFile(filePath);
49
+ }
50
+ } catch (err) {
51
+ console.warn(`[rango] Failed to process ${filePath}: ${(err as Error).message}`);
52
+ }
53
+ }
54
+
55
+ // Generate named-routes for any detected router files
56
+ for (const routerFile of routerFiles) {
57
+ writeCombinedRouteTypes(dirname(routerFile), [routerFile]);
14
58
  }
15
59
 
16
- console.log(`[rango] Scanned ${files.length} file(s)`);
60
+ console.log(`[rango] Processed ${files.length} file(s)${routerFiles.length ? ` (${routerFiles.length} router)` : ""}`);
17
61
  process.exit(0);
18
62
  } else {
19
- console.log(`Usage: rango <command>
63
+ console.log(`Usage: rango generate <file|dir> [file2 ...]
64
+
65
+ Auto-detects file type (createRouter, urls) and generates
66
+ the appropriate .gen.ts route type files.
67
+
68
+ Pass files, directories, or a mix of both.
20
69
 
21
- Commands:
22
- extract-names [dir] Extract route names from url modules (default: ./src)`);
70
+ Examples:
71
+ rango generate src/urls.tsx
72
+ rango generate src/router.tsx src/urls.tsx
73
+ rango generate src/`);
23
74
  process.exit(command ? 1 : 0);
24
75
  }