@ivogt/rsc-router 0.0.0-experimental.1
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 +19 -0
- package/package.json +131 -0
- package/src/__mocks__/version.ts +6 -0
- package/src/__tests__/route-definition.test.ts +63 -0
- package/src/browser/event-controller.ts +876 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/link-interceptor.ts +121 -0
- package/src/browser/lru-cache.ts +69 -0
- package/src/browser/merge-segment-loaders.ts +126 -0
- package/src/browser/navigation-bridge.ts +891 -0
- package/src/browser/navigation-client.ts +155 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +545 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +228 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +53 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +120 -0
- package/src/browser/react/location-state.ts +62 -0
- package/src/browser/react/use-action.ts +240 -0
- package/src/browser/react/use-client-cache.ts +56 -0
- package/src/browser/react/use-handle.ts +178 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-navigation.ts +150 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +149 -0
- package/src/browser/rsc-router.tsx +310 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/server-action-bridge.ts +747 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +443 -0
- package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
- package/src/cache/__tests__/memory-store.test.ts +484 -0
- package/src/cache/cache-scope.ts +565 -0
- package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
- package/src/cache/cf/cf-cache-store.ts +274 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/index.ts +52 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +366 -0
- package/src/client.rsc.tsx +88 -0
- package/src/client.tsx +609 -0
- package/src/components/DefaultDocument.tsx +20 -0
- package/src/default-error-boundary.tsx +88 -0
- package/src/deps/browser.ts +8 -0
- package/src/deps/html-stream-client.ts +2 -0
- package/src/deps/html-stream-server.ts +2 -0
- package/src/deps/rsc.ts +10 -0
- package/src/deps/ssr.ts +2 -0
- package/src/errors.ts +259 -0
- package/src/handle.ts +120 -0
- package/src/handles/MetaTags.tsx +178 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/href-client.ts +128 -0
- package/src/href.ts +139 -0
- package/src/index.rsc.ts +69 -0
- package/src/index.ts +84 -0
- package/src/loader.rsc.ts +204 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +198 -0
- package/src/route-definition.ts +1333 -0
- package/src/route-map-builder.ts +140 -0
- package/src/route-types.ts +148 -0
- package/src/route-utils.ts +89 -0
- package/src/router/__tests__/match-context.test.ts +104 -0
- package/src/router/__tests__/match-pipelines.test.ts +537 -0
- package/src/router/__tests__/match-result.test.ts +566 -0
- package/src/router/__tests__/on-error.test.ts +935 -0
- package/src/router/__tests__/pattern-matching.test.ts +577 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +60 -0
- package/src/router/loader-resolution.ts +326 -0
- package/src/router/manifest.ts +116 -0
- package/src/router/match-context.ts +261 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +261 -0
- package/src/router/match-middleware/cache-store.ts +250 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +268 -0
- package/src/router/match-middleware/segment-resolution.ts +174 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +212 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.test.ts +1355 -0
- package/src/router/middleware.ts +748 -0
- package/src/router/pattern-matching.ts +271 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +299 -0
- package/src/router/types.ts +96 -0
- package/src/router.ts +3484 -0
- package/src/rsc/__tests__/helpers.test.ts +175 -0
- package/src/rsc/handler.ts +942 -0
- package/src/rsc/helpers.ts +64 -0
- package/src/rsc/index.ts +56 -0
- package/src/rsc/nonce.ts +18 -0
- package/src/rsc/types.ts +225 -0
- package/src/segment-system.tsx +405 -0
- package/src/server/__tests__/request-context.test.ts +171 -0
- package/src/server/context.ts +340 -0
- package/src/server/handle-store.ts +230 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +470 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +126 -0
- package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
- package/src/ssr/index.tsx +215 -0
- package/src/types.ts +1473 -0
- package/src/use-loader.tsx +346 -0
- package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
- package/src/vite/expose-action-id.ts +344 -0
- package/src/vite/expose-handle-id.ts +209 -0
- package/src/vite/expose-loader-id.ts +357 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/index.ts +608 -0
- package/src/vite/version.d.ts +12 -0
- package/src/vite/virtual-entries.ts +109 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { compilePattern, findMatch } from "../pattern-matching";
|
|
3
|
+
import type { RouteEntry, TrailingSlashMode } from "../../types";
|
|
4
|
+
|
|
5
|
+
// Helper to create route entries for testing
|
|
6
|
+
const createRouteEntry = (
|
|
7
|
+
prefix: string,
|
|
8
|
+
routes: Record<string, string>,
|
|
9
|
+
trailingSlash?: Record<string, TrailingSlashMode>
|
|
10
|
+
): RouteEntry => ({
|
|
11
|
+
prefix,
|
|
12
|
+
routes: routes as any,
|
|
13
|
+
trailingSlash,
|
|
14
|
+
handler: () => [],
|
|
15
|
+
mountIndex: 0,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("compilePattern", () => {
|
|
19
|
+
describe("static patterns", () => {
|
|
20
|
+
it("should match exact static path", () => {
|
|
21
|
+
const { regex, paramNames } = compilePattern("/");
|
|
22
|
+
expect(regex.test("/")).toBe(true);
|
|
23
|
+
expect(regex.test("/foo")).toBe(false);
|
|
24
|
+
expect(paramNames).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should match static path with segments", () => {
|
|
28
|
+
const { regex, paramNames } = compilePattern("/about");
|
|
29
|
+
expect(regex.test("/about")).toBe(true);
|
|
30
|
+
expect(regex.test("/")).toBe(false);
|
|
31
|
+
expect(regex.test("/about/foo")).toBe(false);
|
|
32
|
+
expect(paramNames).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should match nested static path", () => {
|
|
36
|
+
const { regex, paramNames } = compilePattern("/blog/posts");
|
|
37
|
+
expect(regex.test("/blog/posts")).toBe(true);
|
|
38
|
+
expect(regex.test("/blog")).toBe(false);
|
|
39
|
+
expect(regex.test("/blog/posts/1")).toBe(false);
|
|
40
|
+
expect(paramNames).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("dynamic parameters", () => {
|
|
45
|
+
it("should match single param", () => {
|
|
46
|
+
const { regex, paramNames } = compilePattern("/:id");
|
|
47
|
+
expect(regex.test("/123")).toBe(true);
|
|
48
|
+
expect(regex.test("/abc")).toBe(true);
|
|
49
|
+
expect(regex.test("/")).toBe(false);
|
|
50
|
+
expect(regex.test("/123/456")).toBe(false);
|
|
51
|
+
expect(paramNames).toEqual(["id"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should capture param value", () => {
|
|
55
|
+
const { regex, paramNames } = compilePattern("/:slug");
|
|
56
|
+
const match = regex.exec("/hello-world");
|
|
57
|
+
expect(match).not.toBeNull();
|
|
58
|
+
expect(match![1]).toBe("hello-world");
|
|
59
|
+
expect(paramNames).toEqual(["slug"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("should match param with prefix", () => {
|
|
63
|
+
const { regex, paramNames } = compilePattern("/blog/:slug");
|
|
64
|
+
expect(regex.test("/blog/my-post")).toBe(true);
|
|
65
|
+
expect(regex.test("/blog/")).toBe(false);
|
|
66
|
+
expect(regex.test("/blog")).toBe(false);
|
|
67
|
+
expect(paramNames).toEqual(["slug"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should match param with suffix", () => {
|
|
71
|
+
const { regex, paramNames } = compilePattern("/:id/edit");
|
|
72
|
+
expect(regex.test("/123/edit")).toBe(true);
|
|
73
|
+
expect(regex.test("/123")).toBe(false);
|
|
74
|
+
expect(regex.test("/123/view")).toBe(false);
|
|
75
|
+
expect(paramNames).toEqual(["id"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should match multiple params", () => {
|
|
79
|
+
const { regex, paramNames } = compilePattern("/blog/:slug/comments/:commentId");
|
|
80
|
+
expect(regex.test("/blog/my-post/comments/42")).toBe(true);
|
|
81
|
+
expect(regex.test("/blog/my-post/comments")).toBe(false);
|
|
82
|
+
expect(paramNames).toEqual(["slug", "commentId"]);
|
|
83
|
+
|
|
84
|
+
const match = regex.exec("/blog/my-post/comments/42");
|
|
85
|
+
expect(match![1]).toBe("my-post");
|
|
86
|
+
expect(match![2]).toBe("42");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should match consecutive params", () => {
|
|
90
|
+
const { regex, paramNames } = compilePattern("/:category/:id");
|
|
91
|
+
expect(regex.test("/electronics/123")).toBe(true);
|
|
92
|
+
expect(regex.test("/electronics")).toBe(false);
|
|
93
|
+
expect(paramNames).toEqual(["category", "id"]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("wildcard patterns", () => {
|
|
98
|
+
it("should match catch-all wildcard", () => {
|
|
99
|
+
const { regex, paramNames } = compilePattern("/*");
|
|
100
|
+
expect(regex.test("/")).toBe(true);
|
|
101
|
+
expect(regex.test("/foo")).toBe(true);
|
|
102
|
+
expect(regex.test("/foo/bar/baz")).toBe(true);
|
|
103
|
+
expect(paramNames).toEqual(["*"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should capture wildcard value", () => {
|
|
107
|
+
const { regex } = compilePattern("/files/*");
|
|
108
|
+
const match = regex.exec("/files/docs/readme.md");
|
|
109
|
+
expect(match).not.toBeNull();
|
|
110
|
+
expect(match![1]).toBe("docs/readme.md");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should match wildcard with prefix", () => {
|
|
114
|
+
const { regex, paramNames } = compilePattern("/api/*");
|
|
115
|
+
expect(regex.test("/api/users")).toBe(true);
|
|
116
|
+
expect(regex.test("/api/users/123/posts")).toBe(true);
|
|
117
|
+
expect(regex.test("/api/")).toBe(true);
|
|
118
|
+
expect(regex.test("/api")).toBe(false);
|
|
119
|
+
expect(paramNames).toEqual(["*"]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("findMatch", () => {
|
|
125
|
+
describe("basic matching", () => {
|
|
126
|
+
it("should match root route", () => {
|
|
127
|
+
const entries = [createRouteEntry("", { index: "/" })];
|
|
128
|
+
const result = findMatch("/", entries);
|
|
129
|
+
expect(result).not.toBeNull();
|
|
130
|
+
expect(result!.routeKey).toBe("index");
|
|
131
|
+
expect(result!.params).toEqual({});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should match static route", () => {
|
|
135
|
+
const entries = [
|
|
136
|
+
createRouteEntry("", {
|
|
137
|
+
index: "/",
|
|
138
|
+
about: "/about",
|
|
139
|
+
contact: "/contact",
|
|
140
|
+
}),
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
const result = findMatch("/about", entries);
|
|
144
|
+
expect(result).not.toBeNull();
|
|
145
|
+
expect(result!.routeKey).toBe("about");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("should return null for no match", () => {
|
|
149
|
+
const entries = [createRouteEntry("", { index: "/" })];
|
|
150
|
+
const result = findMatch("/not-found", entries);
|
|
151
|
+
expect(result).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("parameter extraction", () => {
|
|
156
|
+
it("should extract single param", () => {
|
|
157
|
+
const entries = [
|
|
158
|
+
createRouteEntry("", {
|
|
159
|
+
"products.detail": "/product/:slug",
|
|
160
|
+
}),
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
const result = findMatch("/product/my-product", entries);
|
|
164
|
+
expect(result).not.toBeNull();
|
|
165
|
+
expect(result!.routeKey).toBe("products.detail");
|
|
166
|
+
expect(result!.params).toEqual({ slug: "my-product" });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should extract multiple params", () => {
|
|
170
|
+
const entries = [
|
|
171
|
+
createRouteEntry("", {
|
|
172
|
+
"products.reviews.detail": "/product/:slug/reviews/:reviewId",
|
|
173
|
+
}),
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
const result = findMatch("/product/my-product/reviews/42", entries);
|
|
177
|
+
expect(result).not.toBeNull();
|
|
178
|
+
expect(result!.params).toEqual({ slug: "my-product", reviewId: "42" });
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("prefix handling", () => {
|
|
183
|
+
it("should match with prefix", () => {
|
|
184
|
+
const entries = [
|
|
185
|
+
createRouteEntry("/admin", {
|
|
186
|
+
dashboard: "/dashboard",
|
|
187
|
+
users: "/users",
|
|
188
|
+
}),
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
const result = findMatch("/admin/dashboard", entries);
|
|
192
|
+
expect(result).not.toBeNull();
|
|
193
|
+
expect(result!.routeKey).toBe("dashboard");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("should match prefix root", () => {
|
|
197
|
+
const entries = [
|
|
198
|
+
createRouteEntry("/shop", {
|
|
199
|
+
index: "/",
|
|
200
|
+
}),
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
const result = findMatch("/shop", entries);
|
|
204
|
+
expect(result).not.toBeNull();
|
|
205
|
+
expect(result!.routeKey).toBe("index");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("should handle empty prefix", () => {
|
|
209
|
+
const entries = [
|
|
210
|
+
createRouteEntry("", {
|
|
211
|
+
index: "/",
|
|
212
|
+
about: "/about",
|
|
213
|
+
}),
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
expect(findMatch("/", entries)!.routeKey).toBe("index");
|
|
217
|
+
expect(findMatch("/about", entries)!.routeKey).toBe("about");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("should handle slash prefix", () => {
|
|
221
|
+
const entries = [
|
|
222
|
+
createRouteEntry("/", {
|
|
223
|
+
index: "/",
|
|
224
|
+
about: "/about",
|
|
225
|
+
}),
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
expect(findMatch("/", entries)!.routeKey).toBe("index");
|
|
229
|
+
expect(findMatch("/about", entries)!.routeKey).toBe("about");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("multiple route entries", () => {
|
|
234
|
+
it("should match first matching entry", () => {
|
|
235
|
+
const entries = [
|
|
236
|
+
createRouteEntry("/api", { users: "/users" }),
|
|
237
|
+
createRouteEntry("", { home: "/" }),
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
expect(findMatch("/api/users", entries)!.routeKey).toBe("users");
|
|
241
|
+
expect(findMatch("/", entries)!.routeKey).toBe("home");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("should check entries in order", () => {
|
|
245
|
+
const entries = [
|
|
246
|
+
createRouteEntry("", { specific: "/blog/featured" }),
|
|
247
|
+
createRouteEntry("", { dynamic: "/blog/:slug" }),
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
const result = findMatch("/blog/featured", entries);
|
|
251
|
+
expect(result!.routeKey).toBe("specific");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("optional parameters", () => {
|
|
257
|
+
describe("compilePattern", () => {
|
|
258
|
+
it("should match pattern with optional param present: /:locale?/blog -> /en/blog", () => {
|
|
259
|
+
const { regex, paramNames, optionalParams } = compilePattern("/:locale?/blog");
|
|
260
|
+
expect(regex.test("/en/blog")).toBe(true);
|
|
261
|
+
expect(paramNames).toEqual(["locale"]);
|
|
262
|
+
expect(optionalParams.has("locale")).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("should match pattern with optional param absent: /:locale?/blog -> /blog", () => {
|
|
266
|
+
const { regex } = compilePattern("/:locale?/blog");
|
|
267
|
+
expect(regex.test("/blog")).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("should not match invalid paths for optional patterns", () => {
|
|
271
|
+
const { regex } = compilePattern("/:locale?/blog");
|
|
272
|
+
expect(regex.test("/en/gb/blog")).toBe(false);
|
|
273
|
+
expect(regex.test("/en/other")).toBe(false);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("should handle optional param at end: /blog/:page?", () => {
|
|
277
|
+
const { regex, paramNames, optionalParams } = compilePattern("/blog/:page?");
|
|
278
|
+
expect(regex.test("/blog")).toBe(true);
|
|
279
|
+
expect(regex.test("/blog/2")).toBe(true);
|
|
280
|
+
expect(regex.test("/blog/")).toBe(false);
|
|
281
|
+
expect(paramNames).toEqual(["page"]);
|
|
282
|
+
expect(optionalParams.has("page")).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("should handle multiple optional params", () => {
|
|
286
|
+
const { regex } = compilePattern("/:locale?/:region?/shop");
|
|
287
|
+
expect(regex.test("/shop")).toBe(true);
|
|
288
|
+
expect(regex.test("/en/shop")).toBe(true);
|
|
289
|
+
expect(regex.test("/en/us/shop")).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("should handle mix of required and optional params", () => {
|
|
293
|
+
const { regex, paramNames, optionalParams } = compilePattern("/:locale?/blog/:slug");
|
|
294
|
+
expect(regex.test("/blog/hello")).toBe(true);
|
|
295
|
+
expect(regex.test("/en/blog/hello")).toBe(true);
|
|
296
|
+
expect(regex.test("/blog")).toBe(false);
|
|
297
|
+
expect(paramNames).toEqual(["locale", "slug"]);
|
|
298
|
+
expect(optionalParams.has("locale")).toBe(true);
|
|
299
|
+
expect(optionalParams.has("slug")).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("findMatch param extraction", () => {
|
|
304
|
+
it("should extract optional param when present", () => {
|
|
305
|
+
const entries = [
|
|
306
|
+
createRouteEntry("", { "blog": "/:locale?/blog" }),
|
|
307
|
+
];
|
|
308
|
+
const result = findMatch("/en/blog", entries);
|
|
309
|
+
expect(result).not.toBeNull();
|
|
310
|
+
expect(result!.params).toEqual({ locale: "en" });
|
|
311
|
+
expect(result!.optionalParams.has("locale")).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("should return empty string for optional param when absent", () => {
|
|
315
|
+
const entries = [
|
|
316
|
+
createRouteEntry("", { "blog": "/:locale?/blog" }),
|
|
317
|
+
];
|
|
318
|
+
const result = findMatch("/blog", entries);
|
|
319
|
+
expect(result).not.toBeNull();
|
|
320
|
+
expect(result!.params).toEqual({ locale: "" });
|
|
321
|
+
expect(result!.optionalParams.has("locale")).toBe(true);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("should handle multiple optional params correctly", () => {
|
|
325
|
+
const entries = [
|
|
326
|
+
createRouteEntry("", { "shop": "/:locale?/:region?/shop" }),
|
|
327
|
+
];
|
|
328
|
+
|
|
329
|
+
expect(findMatch("/shop", entries)!.params).toEqual({
|
|
330
|
+
locale: "",
|
|
331
|
+
region: "",
|
|
332
|
+
});
|
|
333
|
+
expect(findMatch("/en/shop", entries)!.params).toEqual({
|
|
334
|
+
locale: "en",
|
|
335
|
+
region: "",
|
|
336
|
+
});
|
|
337
|
+
expect(findMatch("/en/us/shop", entries)!.params).toEqual({
|
|
338
|
+
locale: "en",
|
|
339
|
+
region: "us",
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe("constrained parameters", () => {
|
|
346
|
+
describe("compilePattern", () => {
|
|
347
|
+
it("should match constrained param with valid value", () => {
|
|
348
|
+
const { regex, paramNames } = compilePattern("/:locale(en|gb)/blog");
|
|
349
|
+
expect(regex.test("/en/blog")).toBe(true);
|
|
350
|
+
expect(regex.test("/gb/blog")).toBe(true);
|
|
351
|
+
expect(paramNames).toEqual(["locale"]);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("should not match constrained param with invalid value", () => {
|
|
355
|
+
const { regex } = compilePattern("/:locale(en|gb)/blog");
|
|
356
|
+
expect(regex.test("/de/blog")).toBe(false);
|
|
357
|
+
expect(regex.test("/us/blog")).toBe(false);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("should handle optional + constrained params", () => {
|
|
361
|
+
const { regex, optionalParams } = compilePattern("/:locale(en|gb)?/blog");
|
|
362
|
+
expect(regex.test("/blog")).toBe(true);
|
|
363
|
+
expect(regex.test("/en/blog")).toBe(true);
|
|
364
|
+
expect(regex.test("/gb/blog")).toBe(true);
|
|
365
|
+
expect(regex.test("/de/blog")).toBe(false);
|
|
366
|
+
expect(optionalParams.has("locale")).toBe(true);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("should handle multiple constrained values", () => {
|
|
370
|
+
const { regex } = compilePattern("/:type(post|page|comment)/edit");
|
|
371
|
+
expect(regex.test("/post/edit")).toBe(true);
|
|
372
|
+
expect(regex.test("/page/edit")).toBe(true);
|
|
373
|
+
expect(regex.test("/comment/edit")).toBe(true);
|
|
374
|
+
expect(regex.test("/user/edit")).toBe(false);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("findMatch param extraction", () => {
|
|
379
|
+
it("should extract constrained param value", () => {
|
|
380
|
+
const entries = [
|
|
381
|
+
createRouteEntry("", { "localized": "/:locale(en|gb)/blog" }),
|
|
382
|
+
];
|
|
383
|
+
const result = findMatch("/en/blog", entries);
|
|
384
|
+
expect(result).not.toBeNull();
|
|
385
|
+
expect(result!.params).toEqual({ locale: "en" });
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("should extract optional + constrained param when present", () => {
|
|
389
|
+
const entries = [
|
|
390
|
+
createRouteEntry("", { "blog": "/:locale(en|gb)?/blog" }),
|
|
391
|
+
];
|
|
392
|
+
const result = findMatch("/gb/blog", entries);
|
|
393
|
+
expect(result).not.toBeNull();
|
|
394
|
+
expect(result!.params).toEqual({ locale: "gb" });
|
|
395
|
+
expect(result!.optionalParams.has("locale")).toBe(true);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("should return empty string for optional + constrained param when absent", () => {
|
|
399
|
+
const entries = [
|
|
400
|
+
createRouteEntry("", { "blog": "/:locale(en|gb)?/blog" }),
|
|
401
|
+
];
|
|
402
|
+
const result = findMatch("/blog", entries);
|
|
403
|
+
expect(result).not.toBeNull();
|
|
404
|
+
expect(result!.params).toEqual({ locale: "" });
|
|
405
|
+
expect(result!.optionalParams.has("locale")).toBe(true);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
describe("trailing slash handling", () => {
|
|
411
|
+
describe("trailingSlash: ignore", () => {
|
|
412
|
+
it("should match without trailing slash, no redirect", () => {
|
|
413
|
+
const entries = [
|
|
414
|
+
createRouteEntry("", { api: "/api" }, { api: "ignore" }),
|
|
415
|
+
];
|
|
416
|
+
const result = findMatch("/api", entries);
|
|
417
|
+
expect(result).not.toBeNull();
|
|
418
|
+
expect(result!.routeKey).toBe("api");
|
|
419
|
+
expect(result!.redirectTo).toBeUndefined();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("should match with trailing slash, no redirect", () => {
|
|
423
|
+
const entries = [
|
|
424
|
+
createRouteEntry("", { api: "/api" }, { api: "ignore" }),
|
|
425
|
+
];
|
|
426
|
+
const result = findMatch("/api/", entries);
|
|
427
|
+
expect(result).not.toBeNull();
|
|
428
|
+
expect(result!.routeKey).toBe("api");
|
|
429
|
+
expect(result!.redirectTo).toBeUndefined();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("should work with dynamic params", () => {
|
|
433
|
+
const entries = [
|
|
434
|
+
createRouteEntry("", { "product": "/product/:id" }, { product: "ignore" }),
|
|
435
|
+
];
|
|
436
|
+
|
|
437
|
+
const withoutSlash = findMatch("/product/123", entries);
|
|
438
|
+
expect(withoutSlash).not.toBeNull();
|
|
439
|
+
expect(withoutSlash!.params).toEqual({ id: "123" });
|
|
440
|
+
expect(withoutSlash!.redirectTo).toBeUndefined();
|
|
441
|
+
|
|
442
|
+
const withSlash = findMatch("/product/123/", entries);
|
|
443
|
+
expect(withSlash).not.toBeNull();
|
|
444
|
+
expect(withSlash!.params).toEqual({ id: "123" });
|
|
445
|
+
expect(withSlash!.redirectTo).toBeUndefined();
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe("trailingSlash: never", () => {
|
|
450
|
+
it("should match without trailing slash, no redirect", () => {
|
|
451
|
+
const entries = [
|
|
452
|
+
createRouteEntry("", { blog: "/blog" }, { blog: "never" }),
|
|
453
|
+
];
|
|
454
|
+
const result = findMatch("/blog", entries);
|
|
455
|
+
expect(result).not.toBeNull();
|
|
456
|
+
expect(result!.routeKey).toBe("blog");
|
|
457
|
+
expect(result!.redirectTo).toBeUndefined();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("should match with trailing slash and redirect to without", () => {
|
|
461
|
+
const entries = [
|
|
462
|
+
createRouteEntry("", { blog: "/blog" }, { blog: "never" }),
|
|
463
|
+
];
|
|
464
|
+
const result = findMatch("/blog/", entries);
|
|
465
|
+
expect(result).not.toBeNull();
|
|
466
|
+
expect(result!.routeKey).toBe("blog");
|
|
467
|
+
expect(result!.redirectTo).toBe("/blog");
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("should redirect dynamic routes to no trailing slash", () => {
|
|
471
|
+
const entries = [
|
|
472
|
+
createRouteEntry("", { "post": "/blog/:slug" }, { post: "never" }),
|
|
473
|
+
];
|
|
474
|
+
const result = findMatch("/blog/hello-world/", entries);
|
|
475
|
+
expect(result).not.toBeNull();
|
|
476
|
+
expect(result!.params).toEqual({ slug: "hello-world" });
|
|
477
|
+
expect(result!.redirectTo).toBe("/blog/hello-world");
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
describe("trailingSlash: always", () => {
|
|
482
|
+
it("should match with trailing slash, no redirect", () => {
|
|
483
|
+
const entries = [
|
|
484
|
+
createRouteEntry("", { docs: "/docs" }, { docs: "always" }),
|
|
485
|
+
];
|
|
486
|
+
const result = findMatch("/docs/", entries);
|
|
487
|
+
expect(result).not.toBeNull();
|
|
488
|
+
expect(result!.routeKey).toBe("docs");
|
|
489
|
+
expect(result!.redirectTo).toBeUndefined();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("should match without trailing slash and redirect to with", () => {
|
|
493
|
+
const entries = [
|
|
494
|
+
createRouteEntry("", { docs: "/docs" }, { docs: "always" }),
|
|
495
|
+
];
|
|
496
|
+
const result = findMatch("/docs", entries);
|
|
497
|
+
expect(result).not.toBeNull();
|
|
498
|
+
expect(result!.routeKey).toBe("docs");
|
|
499
|
+
expect(result!.redirectTo).toBe("/docs/");
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("should redirect dynamic routes to with trailing slash", () => {
|
|
503
|
+
const entries = [
|
|
504
|
+
createRouteEntry("", { "category": "/shop/:cat" }, { category: "always" }),
|
|
505
|
+
];
|
|
506
|
+
const result = findMatch("/shop/electronics", entries);
|
|
507
|
+
expect(result).not.toBeNull();
|
|
508
|
+
expect(result!.params).toEqual({ cat: "electronics" });
|
|
509
|
+
expect(result!.redirectTo).toBe("/shop/electronics/");
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
describe("pattern-based fallback (no explicit config)", () => {
|
|
514
|
+
it("should redirect trailing slash to no trailing slash by default", () => {
|
|
515
|
+
const entries = [
|
|
516
|
+
createRouteEntry("", { about: "/about" }), // no trailingSlash config
|
|
517
|
+
];
|
|
518
|
+
const result = findMatch("/about/", entries);
|
|
519
|
+
expect(result).not.toBeNull();
|
|
520
|
+
expect(result!.routeKey).toBe("about");
|
|
521
|
+
expect(result!.redirectTo).toBe("/about");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("should match exact pattern without redirect", () => {
|
|
525
|
+
const entries = [
|
|
526
|
+
createRouteEntry("", { about: "/about" }),
|
|
527
|
+
];
|
|
528
|
+
const result = findMatch("/about", entries);
|
|
529
|
+
expect(result).not.toBeNull();
|
|
530
|
+
expect(result!.redirectTo).toBeUndefined();
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
describe("root path handling", () => {
|
|
535
|
+
it("should match root path without redirect", () => {
|
|
536
|
+
const entries = [
|
|
537
|
+
createRouteEntry("", { index: "/" }),
|
|
538
|
+
];
|
|
539
|
+
const result = findMatch("/", entries);
|
|
540
|
+
expect(result).not.toBeNull();
|
|
541
|
+
expect(result!.routeKey).toBe("index");
|
|
542
|
+
expect(result!.redirectTo).toBeUndefined();
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
describe("mixed routes with different configs", () => {
|
|
547
|
+
it("should apply correct config per route", () => {
|
|
548
|
+
const entries = [
|
|
549
|
+
createRouteEntry(
|
|
550
|
+
"",
|
|
551
|
+
{
|
|
552
|
+
api: "/api",
|
|
553
|
+
blog: "/blog",
|
|
554
|
+
docs: "/docs",
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
api: "ignore",
|
|
558
|
+
blog: "never",
|
|
559
|
+
docs: "always",
|
|
560
|
+
}
|
|
561
|
+
),
|
|
562
|
+
];
|
|
563
|
+
|
|
564
|
+
// api: ignore - both work, no redirect
|
|
565
|
+
expect(findMatch("/api", entries)!.redirectTo).toBeUndefined();
|
|
566
|
+
expect(findMatch("/api/", entries)!.redirectTo).toBeUndefined();
|
|
567
|
+
|
|
568
|
+
// blog: never - redirect trailing to no trailing
|
|
569
|
+
expect(findMatch("/blog", entries)!.redirectTo).toBeUndefined();
|
|
570
|
+
expect(findMatch("/blog/", entries)!.redirectTo).toBe("/blog");
|
|
571
|
+
|
|
572
|
+
// docs: always - redirect no trailing to trailing
|
|
573
|
+
expect(findMatch("/docs/", entries)!.redirectTo).toBeUndefined();
|
|
574
|
+
expect(findMatch("/docs", entries)!.redirectTo).toBe("/docs/");
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
});
|