@rangojs/router 0.0.0-experimental.96fbd4b7 → 0.0.0-experimental.98
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/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/i18n/SKILL.md +276 -0
- package/skills/rango/SKILL.md +1 -0
- package/skills/route/SKILL.md +20 -0
- package/skills/typesafety/SKILL.md +6 -0
- package/src/browser/react/use-params.ts +8 -5
- package/src/build/route-trie.ts +2 -1
- package/src/reverse.ts +4 -3
- package/src/router/handler-context.ts +4 -3
- package/src/router/middleware-types.ts +12 -3
- package/src/vite/utils/prerender-utils.ts +5 -4
package/dist/vite/index.js
CHANGED
|
@@ -2040,7 +2040,7 @@ import { resolve } from "node:path";
|
|
|
2040
2040
|
// package.json
|
|
2041
2041
|
var package_default = {
|
|
2042
2042
|
name: "@rangojs/router",
|
|
2043
|
-
version: "0.0.0-experimental.
|
|
2043
|
+
version: "0.0.0-experimental.98",
|
|
2044
2044
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
2045
2045
|
keywords: [
|
|
2046
2046
|
"react",
|
package/package.json
CHANGED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: i18n
|
|
3
|
+
description: Locale-aware routing with `include("/:locale?", ...)`, locale resolution chains, and react-intl integration
|
|
4
|
+
argument-hint: "[topic]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Internationalization (i18n) and Locale Routing
|
|
8
|
+
|
|
9
|
+
Rango doesn't ship an i18n module. The router gives you the URL primitives
|
|
10
|
+
(optional include prefixes, constraints, typed reverse) and you compose
|
|
11
|
+
them with whatever message library you use — `react-intl`, `lingui`,
|
|
12
|
+
`@formatjs/intl`, or hand-rolled.
|
|
13
|
+
|
|
14
|
+
This skill covers:
|
|
15
|
+
|
|
16
|
+
- Mounting routes under an optional locale prefix (`/`, `/en`, `/gb`)
|
|
17
|
+
- Constraining the prefix to a known locale set
|
|
18
|
+
- Resolving the active locale (URL → cookie → `Accept-Language` → default)
|
|
19
|
+
- Generating localized URLs via `reverse()` round-trip
|
|
20
|
+
- Wiring `react-intl` into an RSC route tree
|
|
21
|
+
|
|
22
|
+
## URL Shape: Optional Locale Prefix
|
|
23
|
+
|
|
24
|
+
Mount your localized routes under an optional include prefix so the
|
|
25
|
+
default locale lives at the bare URL and other locales get a prefix:
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
// urls.tsx
|
|
29
|
+
import { urls } from "@rangojs/router";
|
|
30
|
+
import { menuRoutes } from "./menu";
|
|
31
|
+
|
|
32
|
+
export const urlpatterns = urls(({ include }) => [
|
|
33
|
+
include("/:locale?", menuRoutes, { name: "menu" }),
|
|
34
|
+
]);
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
URLs that match:
|
|
38
|
+
|
|
39
|
+
| URL | Matched route | `ctx.params.locale` |
|
|
40
|
+
| -------------- | --------------- | ------------------- |
|
|
41
|
+
| `/` | `menu.index` | `undefined` |
|
|
42
|
+
| `/en` | `menu.index` | `"en"` |
|
|
43
|
+
| `/c/breads` | `menu.category` | `undefined` |
|
|
44
|
+
| `/en/c/breads` | `menu.category` | `"en"` |
|
|
45
|
+
|
|
46
|
+
> **Constrain to known locales** when you want unknown locales to fall
|
|
47
|
+
> through to other routes (or 404) instead of being treated as a slug:
|
|
48
|
+
>
|
|
49
|
+
> ```typescript
|
|
50
|
+
> include("/:locale(en|gb|fr)?", menuRoutes, { name: "menu" });
|
|
51
|
+
> ```
|
|
52
|
+
>
|
|
53
|
+
> `/de` now 404s (constraint rejects `de`), and `/c/breads` continues to
|
|
54
|
+
> match `menu.category` with `locale: undefined`. Without the constraint,
|
|
55
|
+
> `/de` would match `menu.index` with `locale: "de"`.
|
|
56
|
+
|
|
57
|
+
## Reading the Locale in Handlers
|
|
58
|
+
|
|
59
|
+
Absent optionals are `undefined` (not `""`), so `??` coalesces correctly:
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
import { Handler } from "@rangojs/router";
|
|
63
|
+
|
|
64
|
+
export const MenuIndex: Handler<"menu.index"> = (ctx) => {
|
|
65
|
+
// ctx.params.locale is `string | undefined`
|
|
66
|
+
const locale = resolveLocale(ctx);
|
|
67
|
+
return <Welcome locale={locale} />;
|
|
68
|
+
};
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The `resolveLocale` helper below implements a typical fallback chain.
|
|
72
|
+
|
|
73
|
+
## Locale Resolution
|
|
74
|
+
|
|
75
|
+
URL is the strongest signal but you usually want a fallback chain:
|
|
76
|
+
|
|
77
|
+
1. **URL prefix** — if the user navigates to `/gb/...`, honor it
|
|
78
|
+
2. **Cookie** — sticky preference set by a previous language switcher
|
|
79
|
+
3. **`Accept-Language`** — browser hint
|
|
80
|
+
4. **Default** — your app default
|
|
81
|
+
|
|
82
|
+
Put it in a small helper that every locale-aware handler calls:
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// lib/locale.ts
|
|
86
|
+
import { cookies, headers } from "@rangojs/router";
|
|
87
|
+
|
|
88
|
+
export const SUPPORTED_LOCALES = ["en", "gb", "fr"] as const;
|
|
89
|
+
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
|
90
|
+
const DEFAULT_LOCALE: Locale = "en";
|
|
91
|
+
|
|
92
|
+
const isSupported = (v: string): v is Locale =>
|
|
93
|
+
(SUPPORTED_LOCALES as readonly string[]).includes(v);
|
|
94
|
+
|
|
95
|
+
export function resolveLocale(ctx: {
|
|
96
|
+
params: Record<string, string | undefined>;
|
|
97
|
+
}): Locale {
|
|
98
|
+
const fromUrl = ctx.params.locale;
|
|
99
|
+
if (fromUrl && isSupported(fromUrl)) return fromUrl;
|
|
100
|
+
|
|
101
|
+
const fromCookie = cookies().get("locale")?.value;
|
|
102
|
+
if (fromCookie && isSupported(fromCookie)) return fromCookie;
|
|
103
|
+
|
|
104
|
+
const accept = headers().get("accept-language") ?? "";
|
|
105
|
+
for (const tag of accept.split(",")) {
|
|
106
|
+
const code = tag.split(";")[0].trim().split("-")[0];
|
|
107
|
+
if (isSupported(code)) return code as Locale;
|
|
108
|
+
}
|
|
109
|
+
return DEFAULT_LOCALE;
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
If you want to redirect to the canonical URL when the resolved locale
|
|
114
|
+
doesn't match the URL (e.g., user has `gb` cookie but visits `/`), do
|
|
115
|
+
that in a global middleware so it covers actions too:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
import { redirect } from "@rangojs/router";
|
|
119
|
+
|
|
120
|
+
router.use("/*", async (ctx, next) => {
|
|
121
|
+
const fromUrl = ctx.params.locale;
|
|
122
|
+
const resolved = resolveLocale(ctx);
|
|
123
|
+
if (resolved !== DEFAULT_LOCALE && !fromUrl) {
|
|
124
|
+
return redirect(`/${resolved}${ctx.url.pathname}`);
|
|
125
|
+
}
|
|
126
|
+
await next();
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Generating Localized URLs
|
|
131
|
+
|
|
132
|
+
`reverse()` treats `undefined` and `""` for an optional param as "absent"
|
|
133
|
+
and collapses the segment cleanly. The round-trip is symmetric with the
|
|
134
|
+
matcher:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
ctx.reverse("menu.index", { locale: "" }); // → "/"
|
|
138
|
+
ctx.reverse("menu.index", { locale: undefined }); // → "/"
|
|
139
|
+
ctx.reverse("menu.index", { locale: "en" }); // → "/en"
|
|
140
|
+
ctx.reverse("menu.category", { locale: "en", slug: "breads" }); // → "/en/c/breads"
|
|
141
|
+
ctx.reverse("menu.category", { slug: "breads" }); // → "/c/breads"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
If the active locale is the app default and your URL strategy hides it
|
|
145
|
+
(`"en"` → `/`, others → `/<locale>`), normalize before calling reverse:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
const normalized = locale === DEFAULT_LOCALE ? undefined : locale;
|
|
149
|
+
const href = ctx.reverse("menu.category", { locale: normalized, slug });
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## react-intl Integration
|
|
153
|
+
|
|
154
|
+
`react-intl` needs a `<IntlProvider>` wrapping the tree, with `locale`
|
|
155
|
+
and `messages` props. The cleanest split: load messages on the server
|
|
156
|
+
(handler or layout), pass them through to a client provider component.
|
|
157
|
+
|
|
158
|
+
### Messages loader
|
|
159
|
+
|
|
160
|
+
Load message bundles per locale. Keep them server-side so they stream
|
|
161
|
+
through the RSC payload and don't bloat the client bundle:
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
// lib/messages.ts
|
|
165
|
+
import type { Locale } from "./locale";
|
|
166
|
+
|
|
167
|
+
const loaders: Record<Locale, () => Promise<Record<string, string>>> = {
|
|
168
|
+
en: () => import("../messages/en.json").then((m) => m.default),
|
|
169
|
+
gb: () => import("../messages/gb.json").then((m) => m.default),
|
|
170
|
+
fr: () => import("../messages/fr.json").then((m) => m.default),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export async function loadMessages(locale: Locale) {
|
|
174
|
+
return loaders[locale]();
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Server layout: hand off to the client provider
|
|
179
|
+
|
|
180
|
+
```tsx
|
|
181
|
+
// layouts/intl-layout.tsx (server component)
|
|
182
|
+
import type { ReactNode } from "react";
|
|
183
|
+
import { resolveLocale } from "../lib/locale";
|
|
184
|
+
import { loadMessages } from "../lib/messages";
|
|
185
|
+
import { IntlClientProvider } from "../components/intl-client-provider";
|
|
186
|
+
|
|
187
|
+
export async function IntlLayout({
|
|
188
|
+
ctx,
|
|
189
|
+
children,
|
|
190
|
+
}: {
|
|
191
|
+
ctx: any;
|
|
192
|
+
children: ReactNode;
|
|
193
|
+
}) {
|
|
194
|
+
const locale = resolveLocale(ctx);
|
|
195
|
+
const messages = await loadMessages(locale);
|
|
196
|
+
return (
|
|
197
|
+
<IntlClientProvider locale={locale} messages={messages}>
|
|
198
|
+
{children}
|
|
199
|
+
</IntlClientProvider>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Client provider
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
207
|
+
// components/intl-client-provider.tsx
|
|
208
|
+
"use client";
|
|
209
|
+
|
|
210
|
+
import { IntlProvider } from "react-intl";
|
|
211
|
+
import type { ReactNode } from "react";
|
|
212
|
+
|
|
213
|
+
export function IntlClientProvider({
|
|
214
|
+
locale,
|
|
215
|
+
messages,
|
|
216
|
+
children,
|
|
217
|
+
}: {
|
|
218
|
+
locale: string;
|
|
219
|
+
messages: Record<string, string>;
|
|
220
|
+
children: ReactNode;
|
|
221
|
+
}) {
|
|
222
|
+
return (
|
|
223
|
+
<IntlProvider
|
|
224
|
+
locale={locale}
|
|
225
|
+
defaultLocale="en"
|
|
226
|
+
messages={messages}
|
|
227
|
+
onError={(err) => {
|
|
228
|
+
if (err.code === "MISSING_TRANSLATION") return; // common, log only
|
|
229
|
+
console.error(err);
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
{children}
|
|
233
|
+
</IntlProvider>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Mounting
|
|
239
|
+
|
|
240
|
+
Wrap your localized routes with the layout:
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
import { urls } from "@rangojs/router";
|
|
244
|
+
import { IntlLayout } from "./layouts/intl-layout";
|
|
245
|
+
import { menuRoutes } from "./menu";
|
|
246
|
+
|
|
247
|
+
export const urlpatterns = urls(({ layout, include }) => [
|
|
248
|
+
layout(IntlLayout, () => [
|
|
249
|
+
include("/:locale?", menuRoutes, { name: "menu" }),
|
|
250
|
+
]),
|
|
251
|
+
]);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
`<FormattedMessage>`, `useIntl()`, etc. work in any client component
|
|
255
|
+
under the layout. Server components can use `formatjs`'s `createIntl()`
|
|
256
|
+
directly with the same `messages` map for static text.
|
|
257
|
+
|
|
258
|
+
## Common Pitfalls
|
|
259
|
+
|
|
260
|
+
| Pitfall | Fix |
|
|
261
|
+
| ------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
262
|
+
| `ctx.params.locale === ""` returns `false` | Absent optionals are `undefined`, not `""`. Use `=== undefined` or `??`. |
|
|
263
|
+
| `ctx.params.locale ?? "en"` returns `""` | Pre-fix behavior. After the include-prefix fix this works correctly. |
|
|
264
|
+
| Bare `/` 404s when mounted via `include("/:locale?", routes)` | Requires the all-optional pattern fix in `compilePattern` (shipped). |
|
|
265
|
+
| Unknown locale (e.g. `/de`) matches as `locale: "de"` | Add a constraint: `:locale(en\|gb\|fr)?`. Unknown values now 404. |
|
|
266
|
+
| Reverse produces `//c/breads` for absent locale | `reverse()` collapses `undefined`/`""` segments — should not happen. File a bug. |
|
|
267
|
+
| Locale switcher loses search params | Read `ctx.url.search` and pass to `reverse(..., undefined, parsedSearch)`. |
|
|
268
|
+
| Action middleware can't read `ctx.params.locale` | Route middleware doesn't wrap action execution. Use global `router.use()` for actions. |
|
|
269
|
+
|
|
270
|
+
## Cross-references
|
|
271
|
+
|
|
272
|
+
- `/route` — optional URL param syntax and runtime contract
|
|
273
|
+
- `/typesafety` — `RouteParams<"name">` typing for optionals
|
|
274
|
+
- `/middleware` — global vs route middleware scope (matters for actions)
|
|
275
|
+
- `/server-actions` — actions and the global-vs-route middleware boundary
|
|
276
|
+
- `/links` — `ctx.reverse()` and locale-aware URL generation
|
package/skills/rango/SKILL.md
CHANGED
|
@@ -17,6 +17,7 @@ Django-inspired RSC router with composable URL patterns, type-safe href, and ser
|
|
|
17
17
|
| `/layout` | Layouts that wrap child routes |
|
|
18
18
|
| `/loader` | Data loaders with `createLoader()` |
|
|
19
19
|
| `/server-actions` | Mutations with `"use server"`, useActionState, validation, revalidation |
|
|
20
|
+
| `/i18n` | Locale routing with `:locale?`, resolution chains, react-intl integration |
|
|
20
21
|
| `/middleware` | Request processing and authentication |
|
|
21
22
|
| `/intercept` | Modal/slide-over patterns for soft navigation |
|
|
22
23
|
| `/parallel` | Multi-column layouts and sidebars |
|
package/skills/route/SKILL.md
CHANGED
|
@@ -33,6 +33,26 @@ urls(({ path }) => [
|
|
|
33
33
|
]);
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
### Optional URL params at runtime
|
|
37
|
+
|
|
38
|
+
Absent optional params are **omitted from `ctx.params`** — `ctx.params.<name>`
|
|
39
|
+
reads as `undefined`, matching the `RouteParams<"name">` type
|
|
40
|
+
(`{ query?: string }`). Use `??` to default and `=== undefined` to check
|
|
41
|
+
absence:
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
path("/search/:query?", (ctx) => {
|
|
45
|
+
const query = ctx.params.query ?? ""; // works — undefined coalesces
|
|
46
|
+
if (ctx.params.query === undefined) return <EmptySearch />;
|
|
47
|
+
return <Results query={ctx.params.query} />;
|
|
48
|
+
}, { name: "search" });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For the common pattern of an optional locale prefix
|
|
52
|
+
(`include("/:locale?", routes)`) and the wider react-intl integration —
|
|
53
|
+
locale detection, fallback chains, URL generation with absent locale —
|
|
54
|
+
see `/i18n`.
|
|
55
|
+
|
|
36
56
|
## Route Handler Patterns
|
|
37
57
|
|
|
38
58
|
### Component Function
|
|
@@ -287,6 +287,12 @@ type SP = RouteSearchParams<"search">;
|
|
|
287
287
|
type P = RouteParams<"blogPost">;
|
|
288
288
|
// { slug: string }
|
|
289
289
|
|
|
290
|
+
// Optional URL params (`:slug?`) resolve to `string | undefined`
|
|
291
|
+
// because absent segments are omitted from `ctx.params` at runtime.
|
|
292
|
+
type C = RouteParams<"checkout">;
|
|
293
|
+
// { step?: string }
|
|
294
|
+
// → ctx.params.step is `string | undefined`; use `?? "default"` to coalesce.
|
|
295
|
+
|
|
290
296
|
// Use in component props
|
|
291
297
|
interface SearchResultsProps {
|
|
292
298
|
params: RouteSearchParams<"search">;
|
|
@@ -27,16 +27,19 @@ import { shallowEqual } from "./shallow-equal.js";
|
|
|
27
27
|
// interface shapes pass the constraint — interfaces lack an implicit
|
|
28
28
|
// index signature and would otherwise be rejected. The generic is a
|
|
29
29
|
// shape annotation, not a runtime check; the body always returns the
|
|
30
|
-
// underlying params map unchanged.
|
|
30
|
+
// underlying params map unchanged. The default and selector input use
|
|
31
|
+
// `string | undefined` because absent optional params are omitted from
|
|
32
|
+
// the params record at runtime — the type must reflect that so callers
|
|
33
|
+
// don't write `p.locale.length` and crash when the segment is absent.
|
|
31
34
|
export function useParams<
|
|
32
|
-
T extends object = Record<string, string>,
|
|
35
|
+
T extends object = Record<string, string | undefined>,
|
|
33
36
|
>(): Readonly<T>;
|
|
34
37
|
export function useParams<T>(
|
|
35
|
-
selector: (params: Record<string, string>) => T,
|
|
38
|
+
selector: (params: Record<string, string | undefined>) => T,
|
|
36
39
|
): T;
|
|
37
40
|
export function useParams<T>(
|
|
38
|
-
selector?: (params: Record<string, string>) => T,
|
|
39
|
-
): T | Record<string, string> {
|
|
41
|
+
selector?: (params: Record<string, string | undefined>) => T,
|
|
42
|
+
): T | Record<string, string | undefined> {
|
|
40
43
|
const ctx = useContext(NavigationStoreContext);
|
|
41
44
|
|
|
42
45
|
const [value, setValue] = useState<T | Record<string, string>>(() => {
|
package/src/build/route-trie.ts
CHANGED
|
@@ -20,7 +20,8 @@ export interface TrieLeaf {
|
|
|
20
20
|
sp: string;
|
|
21
21
|
/** Ancestry shortCodes from root to route [M0L0, M0L0L0, M0L0L0R499] */
|
|
22
22
|
a: string[];
|
|
23
|
-
/** Optional param names
|
|
23
|
+
/** Optional param names declared on the route. Absent params are
|
|
24
|
+
* omitted from the matched params record (read as `undefined`). */
|
|
24
25
|
op?: string[];
|
|
25
26
|
/** Constraint validation: paramName -> allowed values */
|
|
26
27
|
cv?: Record<string, string[]>;
|
package/src/reverse.ts
CHANGED
|
@@ -312,9 +312,10 @@ export function createReverse<TRoutes extends Record<string, string>>(
|
|
|
312
312
|
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
|
|
313
313
|
(_, key, _constraint, optional) => {
|
|
314
314
|
const value = params[key];
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
315
|
+
// The matcher omits absent optional params (so `value` is
|
|
316
|
+
// `undefined` here), but caller-supplied params or `getParams()`
|
|
317
|
+
// shapes may still pass `""` explicitly. Treat both as the
|
|
318
|
+
// absent form so the segment collapses cleanly.
|
|
318
319
|
if (value === undefined || value === "") {
|
|
319
320
|
hadOmittedOptional = true;
|
|
320
321
|
return "";
|
|
@@ -176,9 +176,10 @@ export function createReverseFunction(
|
|
|
176
176
|
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
|
|
177
177
|
(_, key) => {
|
|
178
178
|
const value = effectiveParams[key];
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
179
|
+
// The matcher omits absent optional params (so `value` is
|
|
180
|
+
// `undefined` here), but caller-supplied params or `getParams()`
|
|
181
|
+
// shapes may still pass `""` explicitly. Treat both as the
|
|
182
|
+
// absent form so the segment collapses cleanly.
|
|
182
183
|
if (value === undefined || value === "") {
|
|
183
184
|
hadOmittedOptional = true;
|
|
184
185
|
return "";
|
|
@@ -53,11 +53,14 @@ export interface CookieOptions {
|
|
|
53
53
|
* Context passed to middleware
|
|
54
54
|
*
|
|
55
55
|
* @template TEnv - Environment type (bindings, variables) - defaults to any for internal flexibility
|
|
56
|
-
* @template TParams - URL params type (typed for route middleware,
|
|
56
|
+
* @template TParams - URL params type (typed for route middleware,
|
|
57
|
+
* `Record<string, string | undefined>` for global middleware — absent
|
|
58
|
+
* optional segments are omitted from the params record at runtime, so
|
|
59
|
+
* the index signature must include `undefined`)
|
|
57
60
|
*/
|
|
58
61
|
export interface MiddlewareContext<
|
|
59
62
|
TEnv = any,
|
|
60
|
-
TParams = Record<string, string>,
|
|
63
|
+
TParams = Record<string, string | undefined>,
|
|
61
64
|
> extends RequestScope<TEnv> {
|
|
62
65
|
/** URL params extracted from route/middleware pattern */
|
|
63
66
|
params: TParams;
|
|
@@ -149,7 +152,10 @@ export interface MiddlewareContext<
|
|
|
149
152
|
* router.use((ctx, next) => {...}) // ctx is typed from router's TEnv
|
|
150
153
|
* ```
|
|
151
154
|
*/
|
|
152
|
-
export type MiddlewareFn<
|
|
155
|
+
export type MiddlewareFn<
|
|
156
|
+
TEnv = any,
|
|
157
|
+
TParams = Record<string, string | undefined>,
|
|
158
|
+
> = (
|
|
153
159
|
ctx: MiddlewareContext<TEnv, TParams>,
|
|
154
160
|
next: () => Promise<Response>,
|
|
155
161
|
) => Response | void | Promise<Response | void>;
|
|
@@ -196,5 +202,8 @@ export interface MiddlewareCollectableEntry {
|
|
|
196
202
|
*/
|
|
197
203
|
export interface CollectedMiddleware {
|
|
198
204
|
handler: MiddlewareFn<any, any>;
|
|
205
|
+
// Internal shape only. The user-facing `MiddlewareContext.params` is
|
|
206
|
+
// typed `Record<string, string | undefined>` to reflect that absent
|
|
207
|
+
// optional segments are omitted from the params record at runtime.
|
|
199
208
|
params: Record<string, string>;
|
|
200
209
|
}
|
|
@@ -42,10 +42,11 @@ export function substituteRouteParams(
|
|
|
42
42
|
let hadOmittedOptional = false;
|
|
43
43
|
|
|
44
44
|
// First pass: substitute provided params.
|
|
45
|
-
// Empty string on an optional placeholder is treated as omitted
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
45
|
+
// Empty string on an optional placeholder is treated as omitted —
|
|
46
|
+
// caller-supplied params or `getParams()` shapes may pass `""` for an
|
|
47
|
+
// absent optional, so letting the second pass strip them keeps slash
|
|
48
|
+
// cleanup consistent. Empty string on required `:key` or wildcard
|
|
49
|
+
// `*key` still substitutes, matching prior behaviour.
|
|
49
50
|
for (const [key, value] of Object.entries(params)) {
|
|
50
51
|
const escaped = escapeRegExp(key);
|
|
51
52
|
if (value === "") {
|