effect-start 0.9.0 → 0.10.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.
Files changed (44) hide show
  1. package/package.json +12 -13
  2. package/src/BundleHttp.test.ts +1 -1
  3. package/src/Commander.test.ts +15 -15
  4. package/src/Commander.ts +58 -88
  5. package/src/EncryptedCookies.test.ts +4 -4
  6. package/src/FileHttpRouter.test.ts +81 -12
  7. package/src/FileHttpRouter.ts +115 -26
  8. package/src/FileRouter.ts +60 -162
  9. package/src/FileRouterCodegen.test.ts +250 -64
  10. package/src/FileRouterCodegen.ts +13 -56
  11. package/src/FileRouterPattern.test.ts +116 -0
  12. package/src/FileRouterPattern.ts +59 -0
  13. package/src/FileRouter_path.test.ts +63 -102
  14. package/src/FileSystemExtra.test.ts +226 -0
  15. package/src/FileSystemExtra.ts +24 -60
  16. package/src/HttpUtils.test.ts +68 -0
  17. package/src/HttpUtils.ts +15 -0
  18. package/src/HyperHtml.ts +24 -5
  19. package/src/JsModule.test.ts +1 -1
  20. package/src/NodeFileSystem.ts +764 -0
  21. package/src/Random.ts +59 -0
  22. package/src/Route.test.ts +471 -0
  23. package/src/Route.ts +298 -153
  24. package/src/RouteRender.ts +38 -0
  25. package/src/Router.ts +11 -33
  26. package/src/RouterPattern.test.ts +629 -0
  27. package/src/RouterPattern.ts +391 -0
  28. package/src/Start.ts +14 -52
  29. package/src/bun/BunBundle.test.ts +0 -3
  30. package/src/bun/BunHttpServer.ts +246 -0
  31. package/src/bun/BunHttpServer_web.ts +384 -0
  32. package/src/bun/BunRoute.test.ts +341 -0
  33. package/src/bun/BunRoute.ts +326 -0
  34. package/src/bun/BunRoute_bundles.test.ts +218 -0
  35. package/src/bun/BunRuntime.ts +33 -0
  36. package/src/bun/BunTailwindPlugin.test.ts +1 -1
  37. package/src/bun/_empty.html +1 -0
  38. package/src/bun/index.ts +2 -1
  39. package/src/testing.ts +12 -3
  40. package/src/Datastar.test.ts +0 -267
  41. package/src/Datastar.ts +0 -68
  42. package/src/bun/BunFullstackServer.ts +0 -45
  43. package/src/bun/BunFullstackServer_httpServer.ts +0 -541
  44. package/src/jsx-datastar.d.ts +0 -63
package/src/Router.ts CHANGED
@@ -1,44 +1,26 @@
1
- import * as HttpApp from "@effect/platform/HttpApp"
2
- import * as HttpRouter from "@effect/platform/HttpRouter"
3
1
  import * as Context from "effect/Context"
4
2
  import * as Effect from "effect/Effect"
5
3
  import * as Function from "effect/Function"
6
4
  import * as Layer from "effect/Layer"
7
- import * as FileHttpRouter from "./FileHttpRouter.ts"
8
5
  import * as FileRouter from "./FileRouter.ts"
9
6
  import * as Route from "./Route"
10
7
 
11
- export const ServerMethods = [
12
- "GET",
13
- "POST",
14
- "PUT",
15
- "PATCH",
16
- "DELETE",
17
- "OPTIONS",
18
- "HEAD",
19
- ] as const
20
-
21
- export type ServerMethod = (typeof ServerMethods)[number]
22
-
23
8
  export type ServerModule = {
24
- default: Route.Route | Route.RouteSet.Default
9
+ default: Route.RouteSet.Default
25
10
  }
26
11
 
27
- export type ServerRoute = {
12
+ export type LazyRoute = {
28
13
  path: `/${string}`
29
- segments: readonly FileRouter.Segment[]
30
14
  load: () => Promise<ServerModule>
15
+ layers?: ReadonlyArray<() => Promise<unknown>>
31
16
  }
32
17
 
33
- export type RouteManifest = {
34
- modules: readonly FileRouter.RouteModule[]
18
+ export type RouterManifest = {
19
+ routes: readonly LazyRoute[]
20
+ layers?: any[]
35
21
  }
36
22
 
37
- export type RouterContext =
38
- & RouteManifest
39
- & {
40
- httpRouter: HttpRouter.HttpRouter
41
- }
23
+ export type RouterContext = RouterManifest
42
24
 
43
25
  export class Router extends Context.Tag("effect-start/Router")<
44
26
  Router,
@@ -46,26 +28,22 @@ export class Router extends Context.Tag("effect-start/Router")<
46
28
  >() {}
47
29
 
48
30
  export function layer(
49
- manifest: RouteManifest,
31
+ manifest: RouterManifest,
50
32
  ): Layer.Layer<Router, never, never> {
51
33
  return Layer.effect(
52
34
  Router,
53
35
  Effect.gen(function*() {
54
- const serverRoutes = manifest.modules.map((mod) => ({
55
- path: mod.path,
56
- load: mod.load,
57
- }))
58
- const httpRouter = yield* FileHttpRouter.make(serverRoutes)
59
36
  return {
60
37
  ...manifest,
61
- httpRouter,
62
38
  }
63
39
  }),
64
40
  )
65
41
  }
66
42
 
43
+ export const layerFiles = FileRouter.layer
44
+
67
45
  export function layerPromise(
68
- load: () => Promise<RouteManifest>,
46
+ load: () => Promise<RouterManifest>,
69
47
  ): Layer.Layer<Router, never, never> {
70
48
  return Layer.unwrapEffect(
71
49
  Effect.gen(function*() {
@@ -0,0 +1,629 @@
1
+ import * as t from "bun:test"
2
+ import { Types } from "effect"
3
+ import * as RouterPattern from "./RouterPattern.ts"
4
+
5
+ type Assert<_T extends true> = void
6
+
7
+ t.describe("Segments", () => {
8
+ t.test("literal path", () => {
9
+ type _1 = Assert<Types.Equals<RouterPattern.Segments<"/">, []>>
10
+ type _2 = Assert<
11
+ Types.Equals<
12
+ RouterPattern.Segments<"/about">,
13
+ [RouterPattern.LiteralSegment<"about">]
14
+ >
15
+ >
16
+ type _3 = Assert<
17
+ Types.Equals<
18
+ RouterPattern.Segments<"/users/profile">,
19
+ [
20
+ RouterPattern.LiteralSegment<"users">,
21
+ RouterPattern.LiteralSegment<"profile">,
22
+ ]
23
+ >
24
+ >
25
+ })
26
+
27
+ t.test("simple param [param]", () => {
28
+ type _1 = Assert<
29
+ Types.Equals<
30
+ RouterPattern.Segments<"/users/[id]">,
31
+ [
32
+ RouterPattern.LiteralSegment<"users">,
33
+ RouterPattern.ParamSegment<"id", false>,
34
+ ]
35
+ >
36
+ >
37
+ type _2 = Assert<
38
+ Types.Equals<
39
+ RouterPattern.Segments<"/[category]/[product]">,
40
+ [
41
+ RouterPattern.ParamSegment<"category", false>,
42
+ RouterPattern.ParamSegment<"product", false>,
43
+ ]
44
+ >
45
+ >
46
+ })
47
+
48
+ t.test("optional param [[param]]", () => {
49
+ type _ = Assert<
50
+ Types.Equals<
51
+ RouterPattern.Segments<"/users/[[id]]">,
52
+ [
53
+ RouterPattern.LiteralSegment<"users">,
54
+ RouterPattern.ParamSegment<"id", true>,
55
+ ]
56
+ >
57
+ >
58
+ })
59
+
60
+ t.test("rest param [...param]", () => {
61
+ type _ = Assert<
62
+ Types.Equals<
63
+ RouterPattern.Segments<"/docs/[...path]">,
64
+ [
65
+ RouterPattern.LiteralSegment<"docs">,
66
+ RouterPattern.RestSegment<"path", false>,
67
+ ]
68
+ >
69
+ >
70
+ })
71
+
72
+ t.test("optional rest param [[...param]]", () => {
73
+ type _1 = Assert<
74
+ Types.Equals<
75
+ RouterPattern.Segments<"/[[...frontend]]">,
76
+ [RouterPattern.RestSegment<"frontend", true>]
77
+ >
78
+ >
79
+ type _2 = Assert<
80
+ Types.Equals<
81
+ RouterPattern.Segments<"/app/[[...slug]]">,
82
+ [
83
+ RouterPattern.LiteralSegment<"app">,
84
+ RouterPattern.RestSegment<"slug", true>,
85
+ ]
86
+ >
87
+ >
88
+ })
89
+
90
+ t.test("param with prefix pk_[id]", () => {
91
+ type _ = Assert<
92
+ Types.Equals<
93
+ RouterPattern.Segments<"/keys/pk_[id]">,
94
+ [
95
+ RouterPattern.LiteralSegment<"keys">,
96
+ RouterPattern.ParamSegment<"id", false, "pk_">,
97
+ ]
98
+ >
99
+ >
100
+ })
101
+
102
+ t.test("param with suffix [id].json", () => {
103
+ type _ = Assert<
104
+ Types.Equals<
105
+ RouterPattern.Segments<"/api/[id].json">,
106
+ [
107
+ RouterPattern.LiteralSegment<"api">,
108
+ RouterPattern.ParamSegment<"id", false, "", ".json">,
109
+ ]
110
+ >
111
+ >
112
+ })
113
+
114
+ t.test("param with prefix and suffix file_[id].txt", () => {
115
+ type _ = Assert<
116
+ Types.Equals<
117
+ RouterPattern.Segments<"/files/file_[id].txt">,
118
+ [
119
+ RouterPattern.LiteralSegment<"files">,
120
+ RouterPattern.ParamSegment<"id", false, "file_", ".txt">,
121
+ ]
122
+ >
123
+ >
124
+ })
125
+
126
+ t.test("param with prefix and suffix prefix_[id]_suffix", () => {
127
+ type _ = Assert<
128
+ Types.Equals<
129
+ RouterPattern.Segments<"/prefix_[id]_suffix">,
130
+ [RouterPattern.ParamSegment<"id", false, "prefix_", "_suffix">]
131
+ >
132
+ >
133
+ })
134
+
135
+ t.test("malformed segment pk_[id]foo → undefined (suffix without delimiter)", () => {
136
+ type _ = Assert<
137
+ Types.Equals<RouterPattern.Segments<"/pk_[id]foo">, [undefined]>
138
+ >
139
+ })
140
+
141
+ t.test("no delimiter prefix/suffix → Literal", () => {
142
+ type _ = Assert<
143
+ Types.Equals<
144
+ RouterPattern.Segments<"/pk[id]foo">,
145
+ [RouterPattern.LiteralSegment<"pk[id]foo">]
146
+ >
147
+ >
148
+ })
149
+ })
150
+
151
+ t.describe(`${RouterPattern.toColon.name}`, () => {
152
+ t.test("literal path unchanged", () => {
153
+ t.expect(RouterPattern.toColon("/")).toEqual(["/"])
154
+ t.expect(RouterPattern.toColon("/about")).toEqual(["/about"])
155
+ t.expect(RouterPattern.toColon("/users/profile")).toEqual([
156
+ "/users/profile",
157
+ ])
158
+ })
159
+
160
+ t.test("param [param] -> :param", () => {
161
+ t.expect(RouterPattern.toColon("/users/[id]")).toEqual(["/users/:id"])
162
+ t.expect(RouterPattern.toColon("/[category]/[product]")).toEqual([
163
+ "/:category/:product",
164
+ ])
165
+ })
166
+
167
+ t.test("optional param [[param]] -> :param?", () => {
168
+ t.expect(RouterPattern.toColon("/users/[[id]]")).toEqual([
169
+ "/users/:id?",
170
+ ])
171
+ t.expect(RouterPattern.toColon("/[[lang]]/about")).toEqual([
172
+ "/:lang?/about",
173
+ ])
174
+ })
175
+
176
+ t.test("rest [...param] -> *", () => {
177
+ t.expect(RouterPattern.toColon("/docs/[...path]")).toEqual(["/docs/*"])
178
+ t.expect(RouterPattern.toColon("/files/[...rest]")).toEqual([
179
+ "/files/*",
180
+ ])
181
+ })
182
+
183
+ t.test("optional rest [[...param]] -> two routes", () => {
184
+ t.expect(RouterPattern.toColon("/[[...frontend]]")).toEqual([
185
+ "/",
186
+ "/*",
187
+ ])
188
+ t.expect(RouterPattern.toColon("/app/[[...slug]]")).toEqual([
189
+ "/app",
190
+ "/app/*",
191
+ ])
192
+ t.expect(RouterPattern.toColon("/docs/[[...path]]")).toEqual([
193
+ "/docs",
194
+ "/docs/*",
195
+ ])
196
+ })
197
+
198
+ t.test("param with prefix pk_[id] -> pk_:id", () => {
199
+ t.expect(RouterPattern.toColon("/keys/pk_[id]")).toEqual([
200
+ "/keys/pk_:id",
201
+ ])
202
+ t.expect(RouterPattern.toColon("/sk_[key]")).toEqual(["/sk_:key"])
203
+ })
204
+
205
+ t.test("param with suffix [name].json -> :name.json", () => {
206
+ t.expect(RouterPattern.toColon("/api/[id].json")).toEqual([
207
+ "/api/:id.json",
208
+ ])
209
+ })
210
+
211
+ t.test("param with prefix and suffix", () => {
212
+ t.expect(RouterPattern.toColon("/files/file_[id].txt")).toEqual([
213
+ "/files/file_:id.txt",
214
+ ])
215
+ })
216
+
217
+ t.test("toHono and toBun are aliases", () => {
218
+ t.expect(RouterPattern.toHono).toBe(RouterPattern.toColon)
219
+ t.expect(RouterPattern.toBun).toBe(RouterPattern.toColon)
220
+ })
221
+ })
222
+
223
+ t.describe(`${RouterPattern.toExpress.name}`, () => {
224
+ t.test("literal path unchanged", () => {
225
+ t.expect(RouterPattern.toExpress("/")).toEqual(["/"])
226
+ t.expect(RouterPattern.toExpress("/about")).toEqual(["/about"])
227
+ })
228
+
229
+ t.test("param [param] -> :param", () => {
230
+ t.expect(RouterPattern.toExpress("/users/[id]")).toEqual([
231
+ "/users/:id",
232
+ ])
233
+ })
234
+
235
+ t.test("optional param [[param]] -> {/:param}", () => {
236
+ t.expect(RouterPattern.toExpress("/users/[[id]]")).toEqual([
237
+ "/users{/:id}",
238
+ ])
239
+ t.expect(RouterPattern.toExpress("/[[lang]]/about")).toEqual([
240
+ "/{/:lang}/about",
241
+ ])
242
+ })
243
+
244
+ t.test("rest [...param] -> /*param", () => {
245
+ t.expect(RouterPattern.toExpress("/docs/[...path]")).toEqual([
246
+ "/docs/*path",
247
+ ])
248
+ t.expect(RouterPattern.toExpress("/files/[...rest]")).toEqual([
249
+ "/files/*rest",
250
+ ])
251
+ })
252
+
253
+ t.test("optional rest [[...param]] -> two routes", () => {
254
+ t.expect(RouterPattern.toExpress("/[[...frontend]]")).toEqual([
255
+ "/",
256
+ "/*frontend",
257
+ ])
258
+ t.expect(RouterPattern.toExpress("/app/[[...slug]]")).toEqual([
259
+ "/app",
260
+ "/app/*slug",
261
+ ])
262
+ })
263
+
264
+ t.test("param with prefix pk_[id] -> pk_:id", () => {
265
+ t.expect(RouterPattern.toExpress("/keys/pk_[id]")).toEqual([
266
+ "/keys/pk_:id",
267
+ ])
268
+ })
269
+ })
270
+
271
+ t.describe(`${RouterPattern.toEffect.name}`, () => {
272
+ t.test("literal path unchanged", () => {
273
+ t.expect(RouterPattern.toEffect("/")).toEqual(["/"])
274
+ t.expect(RouterPattern.toEffect("/about")).toEqual([
275
+ "/about",
276
+ ])
277
+ })
278
+
279
+ t.test("param [param] -> :param", () => {
280
+ t.expect(RouterPattern.toEffect("/users/[id]")).toEqual([
281
+ "/users/:id",
282
+ ])
283
+ })
284
+
285
+ t.test("optional param [[param]] -> :param?", () => {
286
+ t.expect(RouterPattern.toEffect("/users/[[id]]")).toEqual([
287
+ "/users/:id?",
288
+ ])
289
+ })
290
+
291
+ t.test("rest [...param] -> *", () => {
292
+ t.expect(RouterPattern.toEffect("/docs/[...path]")).toEqual(
293
+ [
294
+ "/docs/*",
295
+ ],
296
+ )
297
+ })
298
+
299
+ t.test("optional rest [[...param]] -> two routes", () => {
300
+ t
301
+ .expect(RouterPattern.toEffect("/[[...frontend]]"))
302
+ .toEqual(
303
+ ["/", "/*"],
304
+ )
305
+ t
306
+ .expect(RouterPattern.toEffect("/app/[[...slug]]"))
307
+ .toEqual(
308
+ ["/app", "/app/*"],
309
+ )
310
+ })
311
+
312
+ t.test("param with prefix pk_[id] -> pk_:id", () => {
313
+ t.expect(RouterPattern.toEffect("/keys/pk_[id]")).toEqual([
314
+ "/keys/pk_:id",
315
+ ])
316
+ })
317
+ })
318
+
319
+ t.describe(`${RouterPattern.toURLPattern.name}`, () => {
320
+ t.test("literal path unchanged", () => {
321
+ t.expect(RouterPattern.toURLPattern("/")).toEqual(["/"])
322
+ t.expect(RouterPattern.toURLPattern("/about")).toEqual(["/about"])
323
+ })
324
+
325
+ t.test("param [param] -> :param", () => {
326
+ t.expect(RouterPattern.toURLPattern("/users/[id]")).toEqual([
327
+ "/users/:id",
328
+ ])
329
+ })
330
+
331
+ t.test("optional param [[param]] -> :param?", () => {
332
+ t.expect(RouterPattern.toURLPattern("/users/[[id]]")).toEqual([
333
+ "/users/:id?",
334
+ ])
335
+ })
336
+
337
+ t.test("rest [...param] -> :param+", () => {
338
+ t.expect(RouterPattern.toURLPattern("/docs/[...path]")).toEqual([
339
+ "/docs/:path+",
340
+ ])
341
+ })
342
+
343
+ t.test("optional rest [[...param]] -> :param*", () => {
344
+ t.expect(RouterPattern.toURLPattern("/[[...frontend]]")).toEqual([
345
+ "/:frontend*",
346
+ ])
347
+ t.expect(RouterPattern.toURLPattern("/app/[[...slug]]")).toEqual([
348
+ "/app/:slug*",
349
+ ])
350
+ })
351
+
352
+ t.test("param with prefix pk_[id] -> pk_:id", () => {
353
+ t.expect(RouterPattern.toURLPattern("/keys/pk_[id]")).toEqual([
354
+ "/keys/pk_:id",
355
+ ])
356
+ })
357
+ })
358
+
359
+ t.describe(`${RouterPattern.toRemix.name}`, () => {
360
+ t.test("literal path unchanged", () => {
361
+ t.expect(RouterPattern.toRemix("/")).toEqual(["/"])
362
+ t.expect(RouterPattern.toRemix("/about")).toEqual(["/about"])
363
+ })
364
+
365
+ t.test("param [param] -> $param", () => {
366
+ t.expect(RouterPattern.toRemix("/users/[id]")).toEqual([
367
+ "/users/$id",
368
+ ])
369
+ })
370
+
371
+ t.test("optional param [[param]] -> ($param)", () => {
372
+ t.expect(RouterPattern.toRemix("/users/[[id]]")).toEqual([
373
+ "/users/($id)",
374
+ ])
375
+ })
376
+
377
+ t.test("rest [...param] -> $", () => {
378
+ t.expect(RouterPattern.toRemix("/docs/[...path]")).toEqual([
379
+ "/docs/$",
380
+ ])
381
+ })
382
+
383
+ t.test("optional rest [[...param]] -> two routes", () => {
384
+ t.expect(RouterPattern.toRemix("/[[...frontend]]")).toEqual([
385
+ "/",
386
+ "$",
387
+ ])
388
+ t.expect(RouterPattern.toRemix("/app/[[...slug]]")).toEqual([
389
+ "/app",
390
+ "/app/$",
391
+ ])
392
+ })
393
+
394
+ t.test("param with prefix pk_[id] -> pk_$id (not officially supported)", () => {
395
+ t.expect(RouterPattern.toRemix("/keys/pk_[id]")).toEqual([
396
+ "/keys/pk_$id",
397
+ ])
398
+ })
399
+ })
400
+
401
+ t.describe(`${RouterPattern.format.name}`, () => {
402
+ t.test("empty segments", () => {
403
+ t.expect(RouterPattern.format([])).toBe("/")
404
+ })
405
+
406
+ t.test("literal segments", () => {
407
+ t
408
+ .expect(
409
+ RouterPattern.format([{ _tag: "LiteralSegment", value: "users" }]),
410
+ )
411
+ .toBe(
412
+ "/users",
413
+ )
414
+ t
415
+ .expect(
416
+ RouterPattern.format([
417
+ { _tag: "LiteralSegment", value: "users" },
418
+ { _tag: "LiteralSegment", value: "profile" },
419
+ ]),
420
+ )
421
+ .toBe("/users/profile")
422
+ })
423
+
424
+ t.test("param segments", () => {
425
+ t.expect(RouterPattern.format([{ _tag: "ParamSegment", name: "id" }])).toBe(
426
+ "/[id]",
427
+ )
428
+ t
429
+ .expect(
430
+ RouterPattern.format([{
431
+ _tag: "ParamSegment",
432
+ name: "id",
433
+ optional: true,
434
+ }]),
435
+ )
436
+ .toBe("/[[id]]")
437
+ })
438
+
439
+ t.test("param with prefix/suffix", () => {
440
+ t
441
+ .expect(
442
+ RouterPattern.format([{
443
+ _tag: "ParamSegment",
444
+ name: "id",
445
+ prefix: "pk_",
446
+ }]),
447
+ )
448
+ .toBe("/pk_[id]")
449
+ t
450
+ .expect(
451
+ RouterPattern.format([{
452
+ _tag: "ParamSegment",
453
+ name: "id",
454
+ suffix: ".json",
455
+ }]),
456
+ )
457
+ .toBe("/[id].json")
458
+ t
459
+ .expect(
460
+ RouterPattern.format([
461
+ { _tag: "ParamSegment", name: "id", prefix: "file_", suffix: ".txt" },
462
+ ]),
463
+ )
464
+ .toBe("/file_[id].txt")
465
+ })
466
+
467
+ t.test("rest segments", () => {
468
+ t
469
+ .expect(RouterPattern.format([{ _tag: "RestSegment", name: "path" }]))
470
+ .toBe(
471
+ "/[...path]",
472
+ )
473
+ t
474
+ .expect(
475
+ RouterPattern.format([{
476
+ _tag: "RestSegment",
477
+ name: "path",
478
+ optional: true,
479
+ }]),
480
+ )
481
+ .toBe("/[[...path]]")
482
+ })
483
+
484
+ t.test("mixed segments", () => {
485
+ t
486
+ .expect(
487
+ RouterPattern.format([
488
+ { _tag: "LiteralSegment", value: "users" },
489
+ { _tag: "ParamSegment", name: "id" },
490
+ { _tag: "LiteralSegment", value: "posts" },
491
+ ]),
492
+ )
493
+ .toBe("/users/[id]/posts")
494
+ })
495
+ })
496
+
497
+ t.describe("parseSegment", () => {
498
+ t.test("parses literal segments", () => {
499
+ t.expect(RouterPattern.parseSegment("users")).toEqual({
500
+ _tag: "LiteralSegment",
501
+ value: "users",
502
+ })
503
+ })
504
+
505
+ t.test("parses param segments", () => {
506
+ t.expect(RouterPattern.parseSegment("[id]")).toEqual({
507
+ _tag: "ParamSegment",
508
+ name: "id",
509
+ })
510
+ })
511
+
512
+ t.test("parses optional param segments", () => {
513
+ t.expect(RouterPattern.parseSegment("[[id]]")).toEqual({
514
+ _tag: "ParamSegment",
515
+ name: "id",
516
+ optional: true,
517
+ })
518
+ })
519
+
520
+ t.test("parses rest segments", () => {
521
+ t.expect(RouterPattern.parseSegment("[...path]")).toEqual({
522
+ _tag: "RestSegment",
523
+ name: "path",
524
+ })
525
+ })
526
+
527
+ t.test("parses optional rest segments", () => {
528
+ t.expect(RouterPattern.parseSegment("[[...path]]")).toEqual({
529
+ _tag: "RestSegment",
530
+ name: "path",
531
+ optional: true,
532
+ })
533
+ })
534
+
535
+ t.test("parses param with prefix", () => {
536
+ t.expect(RouterPattern.parseSegment("pk_[id]")).toEqual({
537
+ _tag: "ParamSegment",
538
+ name: "id",
539
+ prefix: "pk_",
540
+ })
541
+ })
542
+
543
+ t.test("parses param with suffix", () => {
544
+ t.expect(RouterPattern.parseSegment("[id].json")).toEqual({
545
+ _tag: "ParamSegment",
546
+ name: "id",
547
+ suffix: ".json",
548
+ })
549
+ })
550
+
551
+ t.test("accepts Unicode literals", () => {
552
+ t.expect(RouterPattern.parseSegment("café")).toEqual({
553
+ _tag: "LiteralSegment",
554
+ value: "café",
555
+ })
556
+ t.expect(RouterPattern.parseSegment("日本語")).toEqual({
557
+ _tag: "LiteralSegment",
558
+ value: "日本語",
559
+ })
560
+ t.expect(RouterPattern.parseSegment("москва")).toEqual({
561
+ _tag: "LiteralSegment",
562
+ value: "москва",
563
+ })
564
+ })
565
+
566
+ t.test("accepts safe punctuation in literals", () => {
567
+ t.expect(RouterPattern.parseSegment("file.txt")).toEqual({
568
+ _tag: "LiteralSegment",
569
+ value: "file.txt",
570
+ })
571
+ t.expect(RouterPattern.parseSegment("my-file")).toEqual({
572
+ _tag: "LiteralSegment",
573
+ value: "my-file",
574
+ })
575
+ t.expect(RouterPattern.parseSegment("my_file")).toEqual({
576
+ _tag: "LiteralSegment",
577
+ value: "my_file",
578
+ })
579
+ t.expect(RouterPattern.parseSegment("file~1")).toEqual({
580
+ _tag: "LiteralSegment",
581
+ value: "file~1",
582
+ })
583
+ })
584
+
585
+ t.test("rejects invalid literal segments", () => {
586
+ t.expect(RouterPattern.parseSegment("invalid$char")).toBe(null)
587
+ t.expect(RouterPattern.parseSegment("has%20spaces")).toBe(null)
588
+ t.expect(RouterPattern.parseSegment("special@char")).toBe(null)
589
+ t.expect(RouterPattern.parseSegment("bad#hash")).toBe(null)
590
+ t.expect(RouterPattern.parseSegment("with spaces")).toBe(null)
591
+ t.expect(RouterPattern.parseSegment("")).toBe(null)
592
+ })
593
+ })
594
+
595
+ t.describe("parse", () => {
596
+ t.test("parses simple paths", () => {
597
+ t.expect(RouterPattern.parse("/users")).toEqual([
598
+ { _tag: "LiteralSegment", value: "users" },
599
+ ])
600
+ t.expect(RouterPattern.parse("/users/profile")).toEqual([
601
+ { _tag: "LiteralSegment", value: "users" },
602
+ { _tag: "LiteralSegment", value: "profile" },
603
+ ])
604
+ })
605
+
606
+ t.test("parses paths with params", () => {
607
+ t.expect(RouterPattern.parse("/users/[id]")).toEqual([
608
+ { _tag: "LiteralSegment", value: "users" },
609
+ { _tag: "ParamSegment", name: "id" },
610
+ ])
611
+ })
612
+
613
+ t.test("parses Unicode paths", () => {
614
+ t.expect(RouterPattern.parse("/café/日本語/москва")).toEqual([
615
+ { _tag: "LiteralSegment", value: "café" },
616
+ { _tag: "LiteralSegment", value: "日本語" },
617
+ { _tag: "LiteralSegment", value: "москва" },
618
+ ])
619
+ })
620
+
621
+ t.test("throws on invalid segments", () => {
622
+ t.expect(() => RouterPattern.parse("/users/$invalid")).toThrow(
623
+ /Invalid path segment.*contains invalid characters/,
624
+ )
625
+ t.expect(() => RouterPattern.parse("/path%20encoded")).toThrow()
626
+ t.expect(() => RouterPattern.parse("/special@char")).toThrow()
627
+ t.expect(() => RouterPattern.parse("/has spaces")).toThrow()
628
+ })
629
+ })