@rangojs/router 0.0.0-experimental.30 → 0.0.0-experimental.32

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.
@@ -1745,7 +1745,7 @@ import { resolve } from "node:path";
1745
1745
  // package.json
1746
1746
  var package_default = {
1747
1747
  name: "@rangojs/router",
1748
- version: "0.0.0-experimental.30",
1748
+ version: "0.0.0-experimental.32",
1749
1749
  description: "Django-inspired RSC router with composable URL patterns",
1750
1750
  keywords: [
1751
1751
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.30",
3
+ "version": "0.0.0-experimental.32",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -0,0 +1,206 @@
1
+ ---
2
+ name: breadcrumbs
3
+ description: Built-in Breadcrumbs handle for accumulating breadcrumb navigation across route segments
4
+ argument-hint: [setup]
5
+ ---
6
+
7
+ # Breadcrumbs
8
+
9
+ Built-in handle for accumulating breadcrumb items across route segments.
10
+ Each layout/route pushes items via `ctx.use(Breadcrumbs)`, and they are
11
+ collected in parent-to-child order with automatic deduplication by `href`.
12
+
13
+ ## BreadcrumbItem Type
14
+
15
+ ```typescript
16
+ interface BreadcrumbItem {
17
+ label: string; // Display text
18
+ href: string; // URL the breadcrumb links to
19
+ content?: ReactNode | Promise<ReactNode>; // Optional extra content (sync or async)
20
+ }
21
+ ```
22
+
23
+ ## Pushing Breadcrumbs (Server)
24
+
25
+ Import `Breadcrumbs` from `@rangojs/router` in RSC/server context:
26
+
27
+ ```typescript
28
+ import { urls, Breadcrumbs } from "@rangojs/router";
29
+ import { Outlet } from "@rangojs/router/client";
30
+
31
+ export const urlpatterns = urls(({ path, layout }) => [
32
+ // Root layout pushes "Home"
33
+ layout((ctx) => {
34
+ const breadcrumb = ctx.use(Breadcrumbs);
35
+ breadcrumb({ label: "Home", href: "/" });
36
+ return <RootLayout />;
37
+ }, () => [
38
+ path("/", HomePage, { name: "home" }),
39
+
40
+ // Nested layout pushes "Blog"
41
+ layout((ctx) => {
42
+ const breadcrumb = ctx.use(Breadcrumbs);
43
+ breadcrumb({ label: "Blog", href: "/blog" });
44
+ return <BlogLayout />;
45
+ }, () => [
46
+ path("/blog", BlogIndex, { name: "blog.index" }),
47
+
48
+ // Route handler pushes post title
49
+ path("/blog/:slug", (ctx) => {
50
+ const breadcrumb = ctx.use(Breadcrumbs);
51
+ breadcrumb({ label: ctx.params.slug, href: `/blog/${ctx.params.slug}` });
52
+ return <BlogPost slug={ctx.params.slug} />;
53
+ }, { name: "blog.post" }),
54
+ ]),
55
+ ]),
56
+ ]);
57
+ ```
58
+
59
+ On `/blog/my-post`, breadcrumbs accumulate: `Home > Blog > my-post`.
60
+
61
+ ## Async Content
62
+
63
+ The `content` field supports `Promise<ReactNode>` for streaming:
64
+
65
+ ```typescript
66
+ path("/product/:id", async (ctx) => {
67
+ const breadcrumb = ctx.use(Breadcrumbs);
68
+ const productPromise = fetchProduct(ctx.params.id);
69
+
70
+ breadcrumb({
71
+ label: "Product",
72
+ href: `/product/${ctx.params.id}`,
73
+ content: productPromise.then((p) => <span>({p.category})</span>),
74
+ });
75
+
76
+ const product = await productPromise;
77
+ return <ProductPage product={product} />;
78
+ }, { name: "product" })
79
+ ```
80
+
81
+ Async content is a `Promise<ReactNode>`. Resolve it in your component
82
+ with React's `use()` hook wrapped in `<Suspense>`.
83
+
84
+ ## Consuming Breadcrumbs (Client)
85
+
86
+ Use `useHandle(Breadcrumbs)` in a client component to read the accumulated items:
87
+
88
+ ```tsx
89
+ "use client";
90
+ import { useHandle, Breadcrumbs, Link } from "@rangojs/router/client";
91
+
92
+ function BreadcrumbNav() {
93
+ const breadcrumbs = useHandle(Breadcrumbs);
94
+
95
+ if (!breadcrumbs.length) return null;
96
+
97
+ return (
98
+ <nav aria-label="Breadcrumb">
99
+ <ol>
100
+ {breadcrumbs.map((crumb, i) => (
101
+ <li key={crumb.href}>
102
+ {i === breadcrumbs.length - 1 ? (
103
+ <span aria-current="page">{crumb.label}</span>
104
+ ) : (
105
+ <Link to={crumb.href}>{crumb.label}</Link>
106
+ )}
107
+ </li>
108
+ ))}
109
+ </ol>
110
+ </nav>
111
+ );
112
+ }
113
+ ```
114
+
115
+ ### With Selector
116
+
117
+ Re-render only when the selected value changes:
118
+
119
+ ```tsx
120
+ // Only the last breadcrumb
121
+ const current = useHandle(Breadcrumbs, (data) => data.at(-1));
122
+
123
+ // Breadcrumb count
124
+ const count = useHandle(Breadcrumbs, (data) => data.length);
125
+ ```
126
+
127
+ ## Deduplication
128
+
129
+ The built-in collect function deduplicates by `href`. If multiple segments
130
+ push the same `href`, the last one wins. This prevents duplicates when
131
+ navigating between sibling routes that share a common breadcrumb.
132
+
133
+ ## Passing as Props
134
+
135
+ Breadcrumbs handle can be passed from server to client components:
136
+
137
+ ```tsx
138
+ // Server component
139
+ path("/dashboard", (ctx) => {
140
+ const breadcrumb = ctx.use(Breadcrumbs);
141
+ breadcrumb({ label: "Dashboard", href: "/dashboard" });
142
+ return <DashboardNav handle={Breadcrumbs} />;
143
+ });
144
+
145
+ // Client component
146
+ ("use client");
147
+ import { useHandle, type Breadcrumbs } from "@rangojs/router/client";
148
+
149
+ function DashboardNav({ handle }: { handle: typeof Breadcrumbs }) {
150
+ const crumbs = useHandle(handle);
151
+ return (
152
+ <nav>
153
+ {crumbs.map((c) => (
154
+ <a href={c.href}>{c.label}</a>
155
+ ))}
156
+ </nav>
157
+ );
158
+ }
159
+ ```
160
+
161
+ ## Complete Example
162
+
163
+ ```typescript
164
+ // urls.tsx
165
+ import { urls, Breadcrumbs, Meta } from "@rangojs/router";
166
+ import { Outlet, MetaTags } from "@rangojs/router/client";
167
+ import { BreadcrumbNav } from "./components/BreadcrumbNav";
168
+
169
+ function RootLayout() {
170
+ return (
171
+ <html lang="en">
172
+ <head><MetaTags /></head>
173
+ <body>
174
+ <BreadcrumbNav />
175
+ <main><Outlet /></main>
176
+ </body>
177
+ </html>
178
+ );
179
+ }
180
+
181
+ export const urlpatterns = urls(({ path, layout }) => [
182
+ layout((ctx) => {
183
+ ctx.use(Breadcrumbs)({ label: "Home", href: "/" });
184
+ ctx.use(Meta)({ title: "My App" });
185
+ return <RootLayout />;
186
+ }, () => [
187
+ path("/", () => <h1>Welcome</h1>, { name: "home" }),
188
+
189
+ layout((ctx) => {
190
+ ctx.use(Breadcrumbs)({ label: "Shop", href: "/shop" });
191
+ return <Outlet />;
192
+ }, () => [
193
+ path("/shop", () => <h1>Shop</h1>, { name: "shop" }),
194
+ path("/shop/:slug", (ctx) => {
195
+ ctx.use(Breadcrumbs)({
196
+ label: ctx.params.slug,
197
+ href: `/shop/${ctx.params.slug}`,
198
+ });
199
+ return <h1>Product: {ctx.params.slug}</h1>;
200
+ }, { name: "shop.product" }),
201
+ ]),
202
+ ]),
203
+ ]);
204
+ ```
205
+
206
+ Navigating to `/shop/widget` produces: `Home / Shop / widget`
@@ -247,8 +247,7 @@ Access accumulated handle data from route segments:
247
247
 
248
248
  ```tsx
249
249
  "use client";
250
- import { useHandle } from "@rangojs/router/client";
251
- import { Breadcrumbs } from "../handles/breadcrumbs";
250
+ import { useHandle, Breadcrumbs } from "@rangojs/router/client";
252
251
 
253
252
  function BreadcrumbNav() {
254
253
  const crumbs = useHandle(Breadcrumbs);
@@ -282,8 +281,7 @@ path("/dashboard", (ctx) => {
282
281
 
283
282
  // Client component — typeof infers the full Handle<T> type
284
283
  ("use client");
285
- import { useHandle } from "@rangojs/router/client";
286
- import type { Breadcrumbs } from "../handles";
284
+ import { useHandle, type Breadcrumbs } from "@rangojs/router/client";
287
285
 
288
286
  function DashboardNav({ handle }: { handle: typeof Breadcrumbs }) {
289
287
  const crumbs = useHandle(handle);
@@ -355,8 +355,7 @@ urls(({ path, layout }) => [
355
355
  ## Complete Example
356
356
 
357
357
  ```typescript
358
- import { urls } from "@rangojs/router";
359
- import { Breadcrumbs } from "./handles/breadcrumbs";
358
+ import { urls, Breadcrumbs } from "@rangojs/router";
360
359
 
361
360
  export const urlpatterns = urls(({ path, layout, loader, loading }) => [
362
361
  // Simple route
@@ -414,26 +414,30 @@ Both approaches coexist: `ctx.get("user")` (global via Vars) and
414
414
  Handles have typed data:
415
415
 
416
416
  ```typescript
417
- // handles/breadcrumbs.ts
418
- import { createHandle } from "@rangojs/router";
419
-
420
- // All export patterns work: export const, const + export { X }, export { X as Y }
421
- export const Breadcrumbs = createHandle<{ label: string; href: string }>();
422
-
423
- // In route definition - use handle() DSL
424
- import { urls } from "@rangojs/router";
425
-
426
- export const urlpatterns = urls(({ path, handle }) => [
427
- path("/shop/product/:slug", ProductPage, { name: "product" }, () => [
428
- handle(Breadcrumbs, { label: "Products", href: "/shop/products" }),
429
- ]),
430
- ]);
431
-
432
- // In client - typed array
417
+ // Built-in Breadcrumbs handle — import from "@rangojs/router"
418
+ import { Breadcrumbs } from "@rangojs/router";
419
+ // Type: Handle<BreadcrumbItem, BreadcrumbItem[]>
420
+ // BreadcrumbItem: { label: string; href: string; content?: ReactNode | Promise<ReactNode> }
421
+
422
+ // In route handler — push is fully typed
423
+ path("/shop/product/:slug", (ctx) => {
424
+ const breadcrumb = ctx.use(Breadcrumbs);
425
+ breadcrumb({ label: "Products", href: "/shop/products" });
426
+ return <ProductPage />;
427
+ }, { name: "product" });
428
+
429
+ // In client — typed array
430
+ import { useHandle, Breadcrumbs } from "@rangojs/router/client";
433
431
  function BreadcrumbNav() {
434
432
  const crumbs = useHandle(Breadcrumbs);
435
- // crumbs: Array<{ label: string; href: string }>
433
+ // crumbs: BreadcrumbItem[]
436
434
  }
435
+
436
+ // Custom handles also work the same way
437
+ import { createHandle } from "@rangojs/router";
438
+ export const PageTitle = createHandle<string, string>(
439
+ (segments) => segments.flat().at(-1) ?? "Default Title"
440
+ );
437
441
  ```
438
442
 
439
443
  ## Ref Prop Type Safety (Loaders & Handles)
@@ -447,14 +451,12 @@ export const ProductLoader = createLoader(async (ctx) => {
447
451
  return { product: await fetchProduct(ctx.params.slug) };
448
452
  });
449
453
 
450
- // handles.ts
451
- export const Breadcrumbs = createHandle<{ label: string; href: string }>();
454
+ // Built-in Breadcrumbs — or any custom handle created with createHandle()
452
455
 
453
456
  // Client component — typeof infers all generics
454
457
  ("use client");
455
- import { useLoader, useHandle } from "@rangojs/router/client";
458
+ import { useLoader, useHandle, type Breadcrumbs } from "@rangojs/router/client";
456
459
  import type { ProductLoader } from "../loaders";
457
- import type { Breadcrumbs } from "../handles";
458
460
 
459
461
  function MyComponent({
460
462
  loader,
@@ -63,6 +63,8 @@ export { Meta } from "./handles/meta.js";
63
63
  // MetaTags is a "use client" component that can be imported from RSC
64
64
  export { MetaTags } from "./handles/MetaTags.js";
65
65
  export type { MetaDescriptor, MetaDescriptorBase } from "./router/types.js";
66
+ // Breadcrumbs handle works in RSC context
67
+ export { Breadcrumbs, type BreadcrumbItem } from "./handles/breadcrumbs.js";
66
68
 
67
69
  // Location state - createLocationState works in RSC (just creates definition)
68
70
  // useLocationState is NOT exported here as it uses client hooks
package/src/client.tsx CHANGED
@@ -544,6 +544,7 @@ export { useHandle } from "./browser/react/use-handle.js";
544
544
  export { Meta } from "./handles/meta.js";
545
545
  export { MetaTags } from "./handles/MetaTags.js";
546
546
  export type { MetaDescriptor, MetaDescriptorBase } from "./router/types.js";
547
+ export { Breadcrumbs, type BreadcrumbItem } from "./handles/breadcrumbs.js";
547
548
 
548
549
  // Location state - type-safe navigation state
549
550
  export {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Built-in Breadcrumbs handle for accumulating breadcrumb items across route segments.
3
+ *
4
+ * Each layout/route pushes breadcrumb items via `ctx.use(Breadcrumbs)`.
5
+ * Items are collected in parent-to-child order with automatic deduplication
6
+ * by `href` (last item for each href wins).
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * // In route handler
11
+ * route("/blog/:slug", (ctx) => {
12
+ * const breadcrumb = ctx.use(Breadcrumbs);
13
+ * breadcrumb({ label: "Blog", href: "/blog" });
14
+ * breadcrumb({ label: post.title, href: `/blog/${ctx.params.slug}` });
15
+ * });
16
+ *
17
+ * // In client component (consume with useHandle)
18
+ * const crumbs = useHandle(Breadcrumbs);
19
+ * crumbs.map((c) => <a href={c.href}>{c.label}</a>);
20
+ * ```
21
+ */
22
+
23
+ import type { ReactNode } from "react";
24
+ import { createHandle, type Handle } from "../handle.js";
25
+
26
+ /**
27
+ * A single breadcrumb item.
28
+ *
29
+ * @property label - Display text for the breadcrumb
30
+ * @property href - URL the breadcrumb links to
31
+ * @property content - Optional extra content (sync or async) rendered alongside the label
32
+ */
33
+ export interface BreadcrumbItem {
34
+ label: string;
35
+ href: string;
36
+ content?: ReactNode | Promise<ReactNode>;
37
+ }
38
+
39
+ /**
40
+ * Collect function for Breadcrumbs handle.
41
+ * Flattens segments in parent-to-child order with deduplication by href
42
+ * (last item for each href wins).
43
+ */
44
+ function collectBreadcrumbs(segments: BreadcrumbItem[][]): BreadcrumbItem[] {
45
+ const all = segments.flat();
46
+ const seen = new Map<string, number>();
47
+
48
+ for (let i = 0; i < all.length; i++) {
49
+ seen.set(all[i].href, i);
50
+ }
51
+
52
+ // Return items in order, keeping only the last occurrence per href
53
+ return all.filter((item, index) => seen.get(item.href) === index);
54
+ }
55
+
56
+ /**
57
+ * Built-in handle for accumulating breadcrumb navigation items.
58
+ *
59
+ * Use `ctx.use(Breadcrumbs)` in route handlers to push breadcrumb items.
60
+ * Use `useHandle(Breadcrumbs)` in client components to consume them.
61
+ */
62
+ export const Breadcrumbs: Handle<BreadcrumbItem, BreadcrumbItem[]> =
63
+ createHandle<BreadcrumbItem, BreadcrumbItem[]>(
64
+ collectBreadcrumbs,
65
+ "__rsc_router_breadcrumbs__",
66
+ );
@@ -4,3 +4,4 @@
4
4
 
5
5
  export { Meta } from "./meta.ts";
6
6
  export { MetaTags } from "./MetaTags.tsx";
7
+ export { Breadcrumbs, type BreadcrumbItem } from "./breadcrumbs.ts";
package/src/index.rsc.ts CHANGED
@@ -171,6 +171,7 @@ export type { HandlerCacheConfig } from "./rsc/types.js";
171
171
 
172
172
  // Built-in handles (server-side)
173
173
  export { Meta } from "./handles/meta.js";
174
+ export { Breadcrumbs, type BreadcrumbItem } from "./handles/breadcrumbs.js";
174
175
 
175
176
  // Request context (for accessing request data in server actions/components).
176
177
  // Re-exported with a narrowed return type so that public consumers only see
package/src/index.ts CHANGED
@@ -259,6 +259,9 @@ export type {
259
259
  // Meta types
260
260
  export type { MetaDescriptor, MetaDescriptorBase } from "./router/types.js";
261
261
 
262
+ // Breadcrumb types
263
+ export type { BreadcrumbItem } from "./handles/breadcrumbs.js";
264
+
262
265
  // Reverse type utilities for type-safe URL generation (Django-style URL reversal)
263
266
  export type {
264
267
  ScopedReverseFunction,
@@ -591,7 +591,7 @@ export async function matchError<TEnv>(
591
591
 
592
592
  const reqCtx = getRequestContext();
593
593
  if (reqCtx) {
594
- reqCtx.setStatus(500);
594
+ reqCtx._setStatus(500);
595
595
  }
596
596
 
597
597
  const effectiveFallback = fallback || DefaultErrorFallback;
@@ -117,6 +117,7 @@ export async function matchForPrerender<TEnv = any>(
117
117
  deleteCookie: () => {},
118
118
  header: () => {},
119
119
  setStatus: () => {},
120
+ _setStatus: () => {},
120
121
  use: (() => {
121
122
  throw new Error("use() not available during pre-rendering");
122
123
  }) as any,
@@ -346,6 +347,7 @@ export async function renderStaticSegment<TEnv = any>(
346
347
  deleteCookie: () => {},
347
348
  header: () => {},
348
349
  setStatus: () => {},
350
+ _setStatus: () => {},
349
351
  use: (() => {
350
352
  throw new Error("use() not available during static pre-rendering");
351
353
  }) as any,
@@ -174,7 +174,7 @@ export function catchSegmentError<TEnv>(
174
174
  const setResponseStatus = (status: number) => {
175
175
  const reqCtx = getRequestContext();
176
176
  if (reqCtx) {
177
- reqCtx.setStatus(status);
177
+ reqCtx._setStatus(status);
178
178
  }
179
179
  };
180
180
 
@@ -95,6 +95,8 @@ export interface RequestContext<
95
95
  header(name: string, value: string): void;
96
96
  /** Set the response status code */
97
97
  setStatus(status: number): void;
98
+ /** @internal Set status bypassing cache-exec guard (for framework error handling) */
99
+ _setStatus(status: number): void;
98
100
 
99
101
  /**
100
102
  * Access loader data or push handle data.
@@ -301,6 +303,7 @@ export type PublicRequestContext<
301
303
  | "_reportBackgroundError"
302
304
  | "_debugPerformance"
303
305
  | "_metricsStore"
306
+ | "_setStatus"
304
307
  | "res"
305
308
  >;
306
309
 
@@ -629,8 +632,13 @@ export function createRequestContext<TEnv>(
629
632
 
630
633
  setStatus(status: number): void {
631
634
  assertNotInsideCacheExec(ctx, "setStatus");
632
- // Response.status is read-only, so we must create a new Response.
633
- // Headers are passed by reference — no cookie cache invalidation needed.
635
+ stubResponse = new Response(null, {
636
+ status,
637
+ headers: stubResponse.headers,
638
+ });
639
+ },
640
+
641
+ _setStatus(status: number): void {
634
642
  stubResponse = new Response(null, {
635
643
  status,
636
644
  headers: stubResponse.headers,