@teardown/navigation 2.0.43
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/package.json +79 -0
- package/src/components/index.ts +14 -0
- package/src/hooks/index.ts +15 -0
- package/src/hooks/use-typed-navigation.ts +195 -0
- package/src/hooks/use-typed-params.ts +46 -0
- package/src/hooks/use-typed-route.ts +83 -0
- package/src/index.ts +70 -0
- package/src/primitives/create-param-schema.ts +243 -0
- package/src/primitives/define-layout.ts +191 -0
- package/src/primitives/define-screen.ts +123 -0
- package/src/primitives/index.ts +37 -0
- package/src/types/index.ts +28 -0
- package/src/types/route-types.ts +75 -0
- package/src/types/type-utils.test.ts +175 -0
- package/src/types/type-utils.ts +91 -0
- package/src/utils/build-url.test.ts +133 -0
- package/src/utils/build-url.ts +164 -0
- package/src/utils/index.ts +5 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { ExtractAllParams, ExtractParam, HasRequiredParams, InferParams, Simplify } from "./type-utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Type-level tests for template literal type utilities
|
|
6
|
+
* These tests verify that the type utilities correctly extract and infer route params
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Helper type for testing - checks if two types are exactly equal
|
|
10
|
+
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
11
|
+
|
|
12
|
+
// Type assertion helper
|
|
13
|
+
function assertType<T>(_value: T): void {}
|
|
14
|
+
|
|
15
|
+
describe("Type Utils", () => {
|
|
16
|
+
describe("ExtractParam", () => {
|
|
17
|
+
it("should extract required param from [param]", () => {
|
|
18
|
+
// Test: [userId] should produce { userId: string }
|
|
19
|
+
type Result = ExtractParam<"[userId]">;
|
|
20
|
+
type Expected = { userId: string };
|
|
21
|
+
|
|
22
|
+
// Type-level assertion
|
|
23
|
+
const isEqual: Equals<Result, Expected> = true;
|
|
24
|
+
expect(isEqual).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should extract optional param from [param]?", () => {
|
|
28
|
+
// Test: [section]? should produce { section?: string }
|
|
29
|
+
type Result = ExtractParam<"[section]?">;
|
|
30
|
+
type Expected = { section?: string };
|
|
31
|
+
|
|
32
|
+
const isEqual: Equals<Result, Expected> = true;
|
|
33
|
+
expect(isEqual).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("should extract catch-all param from [...slug]", () => {
|
|
37
|
+
// Test: [...slug] should produce { slug: string[] }
|
|
38
|
+
type Result = ExtractParam<"[...slug]">;
|
|
39
|
+
type Expected = { slug: string[] };
|
|
40
|
+
|
|
41
|
+
const isEqual: Equals<Result, Expected> = true;
|
|
42
|
+
expect(isEqual).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should return empty object for static segment", () => {
|
|
46
|
+
// Test: about should produce Record<string, never>
|
|
47
|
+
type Result = ExtractParam<"about">;
|
|
48
|
+
type Expected = Record<string, never>;
|
|
49
|
+
|
|
50
|
+
const isEqual: Equals<Result, Expected> = true;
|
|
51
|
+
expect(isEqual).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("ExtractAllParams", () => {
|
|
56
|
+
it("should extract single param from path", () => {
|
|
57
|
+
type Result = ExtractAllParams<"/users/[userId]">;
|
|
58
|
+
// Should have userId: string
|
|
59
|
+
assertType<Result>({ userId: "123" });
|
|
60
|
+
expect(true).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should extract multiple params from nested path", () => {
|
|
64
|
+
type Result = ExtractAllParams<"/users/[userId]/posts/[postId]">;
|
|
65
|
+
// Should have both userId and postId
|
|
66
|
+
assertType<Result>({ userId: "123", postId: "456" });
|
|
67
|
+
expect(true).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should handle mixed static and dynamic segments", () => {
|
|
71
|
+
type Result = ExtractAllParams<"/blog/[year]/[month]/[day]/[slug]">;
|
|
72
|
+
assertType<Result>({ year: "2025", month: "01", day: "28", slug: "hello" });
|
|
73
|
+
expect(true).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("InferParams", () => {
|
|
78
|
+
it("should return empty object for static path", () => {
|
|
79
|
+
type Result = InferParams<"/about">;
|
|
80
|
+
// For static paths, params should be essentially empty
|
|
81
|
+
const result: Result = {};
|
|
82
|
+
expect(Object.keys(result).length).toBe(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should infer required param", () => {
|
|
86
|
+
type Result = InferParams<"/users/[userId]">;
|
|
87
|
+
const result: Result = { userId: "123" };
|
|
88
|
+
expect(result.userId).toBe("123");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should infer optional param", () => {
|
|
92
|
+
type Result = InferParams<"/settings/[section]?">;
|
|
93
|
+
// Should allow undefined for optional params
|
|
94
|
+
const withParam: Result = { section: "profile" };
|
|
95
|
+
const withoutParam: Result = {};
|
|
96
|
+
expect(withParam.section).toBe("profile");
|
|
97
|
+
expect(withoutParam.section).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should infer catch-all param as string array", () => {
|
|
101
|
+
type Result = InferParams<"/docs/[...slug]">;
|
|
102
|
+
const result: Result = { slug: ["a", "b", "c"] };
|
|
103
|
+
expect(result.slug).toEqual(["a", "b", "c"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should infer multiple params from complex path", () => {
|
|
107
|
+
type Result = InferParams<"/users/[userId]/posts/[postId]">;
|
|
108
|
+
const result: Result = { userId: "123", postId: "456" };
|
|
109
|
+
expect(result.userId).toBe("123");
|
|
110
|
+
expect(result.postId).toBe("456");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("HasRequiredParams", () => {
|
|
115
|
+
it("should return false for undefined params", () => {
|
|
116
|
+
type Result = HasRequiredParams<undefined>;
|
|
117
|
+
const result: Result = false;
|
|
118
|
+
expect(result).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("should return false for empty object", () => {
|
|
122
|
+
type Result = HasRequiredParams<Record<string, never>>;
|
|
123
|
+
const result: Result = false;
|
|
124
|
+
expect(result).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should return true for object with required keys", () => {
|
|
128
|
+
type Result = HasRequiredParams<{ userId: string }>;
|
|
129
|
+
const result: Result = true;
|
|
130
|
+
expect(result).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("Simplify", () => {
|
|
135
|
+
it("should flatten intersection types", () => {
|
|
136
|
+
type Complex = { a: string } & { b: number };
|
|
137
|
+
type Result = Simplify<Complex>;
|
|
138
|
+
const result: Result = { a: "test", b: 42 };
|
|
139
|
+
expect(result.a).toBe("test");
|
|
140
|
+
expect(result.b).toBe(42);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("NavigateArgs", () => {
|
|
145
|
+
// Define a mock RouteParams for testing
|
|
146
|
+
type MockRouteParams = {
|
|
147
|
+
"/": undefined;
|
|
148
|
+
"/about": undefined;
|
|
149
|
+
"/users/[userId]": { userId: string };
|
|
150
|
+
"/users/[userId]/posts/[postId]": { userId: string; postId: string };
|
|
151
|
+
"/settings/[section]?": { section?: string };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
type MockRoutePath = keyof MockRouteParams;
|
|
155
|
+
type MockParamsFor<T extends MockRoutePath> = MockRouteParams[T];
|
|
156
|
+
|
|
157
|
+
// Test NavigateArgs type behavior
|
|
158
|
+
it("should allow calling without params for static routes", () => {
|
|
159
|
+
// For routes with undefined params, should be [path] or [path, undefined]
|
|
160
|
+
type Args = MockParamsFor<"/"> extends undefined ? [path: "/"] : [path: "/", params: MockParamsFor<"/">];
|
|
161
|
+
|
|
162
|
+
const args: Args = ["/"];
|
|
163
|
+
expect(args[0]).toBe("/");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("should require params for dynamic routes", () => {
|
|
167
|
+
// For routes with params, must include params object
|
|
168
|
+
type Args = [path: "/users/[userId]", params: MockParamsFor<"/users/[userId]">];
|
|
169
|
+
|
|
170
|
+
const args: Args = ["/users/[userId]", { userId: "123" }];
|
|
171
|
+
expect(args[0]).toBe("/users/[userId]");
|
|
172
|
+
expect(args[1].userId).toBe("123");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type utilities for extracting route parameters from path strings
|
|
3
|
+
* Uses template literal types to provide compile-time type inference
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Simplifies intersection types for better IDE display
|
|
8
|
+
* Converts { a: string } & { b: number } to { a: string; b: number }
|
|
9
|
+
*/
|
|
10
|
+
export type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extracts parameter from a single path segment
|
|
14
|
+
*
|
|
15
|
+
* - [userId] -> { userId: string }
|
|
16
|
+
* - [...slug] -> { slug: string[] }
|
|
17
|
+
* - [section]? -> { section?: string }
|
|
18
|
+
* - about -> Record<string, never>
|
|
19
|
+
*/
|
|
20
|
+
export type ExtractParam<Segment extends string> = Segment extends `[...${infer Rest}]`
|
|
21
|
+
? { [K in Rest]: string[] }
|
|
22
|
+
: Segment extends `[${infer Param}]?`
|
|
23
|
+
? { [K in Param]?: string }
|
|
24
|
+
: Segment extends `[${infer Param}]`
|
|
25
|
+
? { [K in Param]: string }
|
|
26
|
+
: Record<string, never>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Recursively extracts all params from a path
|
|
30
|
+
*
|
|
31
|
+
* /users/[userId]/posts/[postId] -> { userId: string; postId: string }
|
|
32
|
+
*/
|
|
33
|
+
export type ExtractAllParams<Path extends string> = Path extends `${infer Segment}/${infer Rest}`
|
|
34
|
+
? ExtractParam<Segment> & ExtractAllParams<Rest>
|
|
35
|
+
: ExtractParam<Path>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Final param extraction with simplification
|
|
39
|
+
* Cleans up intersection types for better IDE display
|
|
40
|
+
*/
|
|
41
|
+
export type InferParams<Path extends string> = Simplify<ExtractAllParams<Path>>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Checks if a type has required (non-optional) keys
|
|
45
|
+
* Returns true if the type has at least one required key
|
|
46
|
+
*/
|
|
47
|
+
export type HasRequiredParams<T> = T extends undefined
|
|
48
|
+
? false
|
|
49
|
+
: T extends Record<string, never>
|
|
50
|
+
? false
|
|
51
|
+
: keyof T extends never
|
|
52
|
+
? false
|
|
53
|
+
: true;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Navigation argument type - params required only when route has params
|
|
57
|
+
* Allows calling navigate('/about') without params
|
|
58
|
+
* But requires navigate('/users/[userId]', { userId: '123' })
|
|
59
|
+
*/
|
|
60
|
+
export type NavigateArgs<TPath extends string, TParams> = HasRequiredParams<TParams> extends true
|
|
61
|
+
? [path: TPath, params: TParams]
|
|
62
|
+
: [path: TPath] | [path: TPath, params?: undefined];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Helper type to check if a segment is dynamic
|
|
66
|
+
*/
|
|
67
|
+
export type IsDynamicSegment<Segment extends string> = Segment extends `[${string}]` ? true : false;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Helper type to check if a segment is optional
|
|
71
|
+
*/
|
|
72
|
+
export type IsOptionalSegment<Segment extends string> = Segment extends `[${string}]?` ? true : false;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Helper type to check if a segment is catch-all
|
|
76
|
+
*/
|
|
77
|
+
export type IsCatchAllSegment<Segment extends string> = Segment extends `[...${string}]` ? true : false;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extracts the parameter name from a segment
|
|
81
|
+
* [userId] -> "userId"
|
|
82
|
+
* [...slug] -> "slug"
|
|
83
|
+
* [section]? -> "section"
|
|
84
|
+
*/
|
|
85
|
+
export type ExtractParamName<Segment extends string> = Segment extends `[...${infer Name}]`
|
|
86
|
+
? Name
|
|
87
|
+
: Segment extends `[${infer Name}]?`
|
|
88
|
+
? Name
|
|
89
|
+
: Segment extends `[${infer Name}]`
|
|
90
|
+
? Name
|
|
91
|
+
: never;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { buildUrl, matchPath, parsePath, pathToScreenName } from "./build-url";
|
|
3
|
+
|
|
4
|
+
describe("URL Utilities", () => {
|
|
5
|
+
describe("buildUrl", () => {
|
|
6
|
+
it("should build static path without params", () => {
|
|
7
|
+
expect(buildUrl("/about")).toBe("/about");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("should build root path", () => {
|
|
11
|
+
expect(buildUrl("/")).toBe("/");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("should build path with single required param", () => {
|
|
15
|
+
expect(buildUrl("/users/:userId", { userId: "123" })).toBe("/users/123");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should build path with multiple params", () => {
|
|
19
|
+
expect(buildUrl("/users/:userId/posts/:postId", { userId: "123", postId: "456" })).toBe("/users/123/posts/456");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should handle optional param when provided", () => {
|
|
23
|
+
expect(buildUrl("/settings/:section?", { section: "profile" })).toBe("/settings/profile");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should handle optional param when omitted", () => {
|
|
27
|
+
expect(buildUrl("/settings/:section?", {})).toBe("/settings");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should handle catch-all param with array", () => {
|
|
31
|
+
expect(buildUrl("/docs/*", { slug: ["a", "b", "c"] })).toBe("/docs/a/b/c");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should remove route groups from URL", () => {
|
|
35
|
+
expect(buildUrl("/(tabs)/home")).toBe("/home");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should encode special characters in params", () => {
|
|
39
|
+
expect(buildUrl("/search/:query", { query: "hello world" })).toBe("/search/hello%20world");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should handle empty params object", () => {
|
|
43
|
+
expect(buildUrl("/about", {})).toBe("/about");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("parsePath", () => {
|
|
48
|
+
it("should parse static path", () => {
|
|
49
|
+
const result = parsePath("/about");
|
|
50
|
+
expect(result.segments).toEqual(["about"]);
|
|
51
|
+
expect(result.params).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should parse path with required param", () => {
|
|
55
|
+
const result = parsePath("/users/:userId");
|
|
56
|
+
expect(result.segments).toEqual(["users", ":userId"]);
|
|
57
|
+
expect(result.params).toEqual([{ name: "userId", optional: false, catchAll: false }]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should parse path with optional param", () => {
|
|
61
|
+
const result = parsePath("/settings/:section?");
|
|
62
|
+
expect(result.params).toEqual([{ name: "section", optional: true, catchAll: false }]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should parse path with catch-all param", () => {
|
|
66
|
+
const result = parsePath("/docs/*");
|
|
67
|
+
expect(result.params).toEqual([{ name: "slug", optional: false, catchAll: true }]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should parse complex path with multiple params", () => {
|
|
71
|
+
const result = parsePath("/users/:userId/posts/:postId");
|
|
72
|
+
expect(result.params.length).toBe(2);
|
|
73
|
+
expect(result.params[0].name).toBe("userId");
|
|
74
|
+
expect(result.params[1].name).toBe("postId");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("matchPath", () => {
|
|
79
|
+
it("should match static path", () => {
|
|
80
|
+
const result = matchPath("/about", "/about");
|
|
81
|
+
expect(result).toEqual({ matched: true, params: {} });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should not match different static paths", () => {
|
|
85
|
+
const result = matchPath("/about", "/home");
|
|
86
|
+
expect(result.matched).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should match path with param and extract value", () => {
|
|
90
|
+
const result = matchPath("/users/:userId", "/users/123");
|
|
91
|
+
expect(result).toEqual({ matched: true, params: { userId: "123" } });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should match path with multiple params", () => {
|
|
95
|
+
const result = matchPath("/users/:userId/posts/:postId", "/users/123/posts/456");
|
|
96
|
+
expect(result).toEqual({ matched: true, params: { userId: "123", postId: "456" } });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should match optional param when present", () => {
|
|
100
|
+
const result = matchPath("/settings/:section?", "/settings/profile");
|
|
101
|
+
expect(result).toEqual({ matched: true, params: { section: "profile" } });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should match optional param when absent", () => {
|
|
105
|
+
const result = matchPath("/settings/:section?", "/settings");
|
|
106
|
+
expect(result).toEqual({ matched: true, params: {} });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should match catch-all and extract array", () => {
|
|
110
|
+
const result = matchPath("/docs/*", "/docs/a/b/c");
|
|
111
|
+
expect(result).toEqual({ matched: true, params: { slug: ["a", "b", "c"] } });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("should not match when path is too short", () => {
|
|
115
|
+
const result = matchPath("/users/:userId/posts", "/users/123");
|
|
116
|
+
expect(result.matched).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("pathToScreenName", () => {
|
|
121
|
+
it("should convert path to screen name", () => {
|
|
122
|
+
expect(pathToScreenName("/users/:userId")).toBe("users/:userId");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("should handle root path", () => {
|
|
126
|
+
expect(pathToScreenName("/")).toBe("");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("should preserve params in screen name", () => {
|
|
130
|
+
expect(pathToScreenName("/users/:userId/posts/:postId")).toBe("users/:userId/posts/:postId");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL building and parsing utilities for @teardown/navigation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface ParsedPath {
|
|
6
|
+
segments: string[];
|
|
7
|
+
params: Array<{
|
|
8
|
+
name: string;
|
|
9
|
+
optional: boolean;
|
|
10
|
+
catchAll: boolean;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface MatchResult {
|
|
15
|
+
matched: boolean;
|
|
16
|
+
params: Record<string, string | string[]>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Builds a URL from a route path and params
|
|
21
|
+
* Handles required params, optional params, and catch-all routes
|
|
22
|
+
*/
|
|
23
|
+
export function buildUrl(path: string, params: Record<string, string | string[] | undefined> = {}): string {
|
|
24
|
+
// Remove route groups from path
|
|
25
|
+
let url = path.replace(/\/\([^)]+\)/g, "");
|
|
26
|
+
|
|
27
|
+
// Replace optional params :param? FIRST (before required params)
|
|
28
|
+
url = url.replace(/:(\w+)\?/g, (_, paramName) => {
|
|
29
|
+
const value = params[paramName];
|
|
30
|
+
if (value !== undefined && value !== "") {
|
|
31
|
+
return encodeURIComponent(String(value));
|
|
32
|
+
}
|
|
33
|
+
return "";
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Replace required params :param (now that optional ones are handled)
|
|
37
|
+
url = url.replace(/:(\w+)/g, (_, paramName) => {
|
|
38
|
+
const value = params[paramName];
|
|
39
|
+
if (value !== undefined) {
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
return value.map(encodeURIComponent).join("/");
|
|
42
|
+
}
|
|
43
|
+
return encodeURIComponent(String(value));
|
|
44
|
+
}
|
|
45
|
+
return "";
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Replace catch-all params *
|
|
49
|
+
url = url.replace(/\*/g, () => {
|
|
50
|
+
// Catch-all params are typically named 'slug' by convention
|
|
51
|
+
const value = params.slug;
|
|
52
|
+
if (value !== undefined) {
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
return value.map(encodeURIComponent).join("/");
|
|
55
|
+
}
|
|
56
|
+
return encodeURIComponent(String(value));
|
|
57
|
+
}
|
|
58
|
+
return "";
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Clean up double slashes and trailing slashes (except root)
|
|
62
|
+
url = url.replace(/\/+/g, "/").replace(/\/$/, "");
|
|
63
|
+
|
|
64
|
+
// Ensure leading slash
|
|
65
|
+
if (!url.startsWith("/")) {
|
|
66
|
+
url = `/${url}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return url || "/";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parses a route path into segments and param definitions
|
|
74
|
+
*/
|
|
75
|
+
export function parsePath(path: string): ParsedPath {
|
|
76
|
+
const segments = path.split("/").filter(Boolean);
|
|
77
|
+
const params: ParsedPath["params"] = [];
|
|
78
|
+
|
|
79
|
+
for (const segment of segments) {
|
|
80
|
+
if (segment === "*") {
|
|
81
|
+
params.push({ name: "slug", optional: false, catchAll: true });
|
|
82
|
+
} else if (segment.endsWith("?")) {
|
|
83
|
+
const name = segment.slice(1, -1); // Remove : and ?
|
|
84
|
+
params.push({ name, optional: true, catchAll: false });
|
|
85
|
+
} else if (segment.startsWith(":")) {
|
|
86
|
+
const name = segment.slice(1);
|
|
87
|
+
params.push({ name, optional: false, catchAll: false });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { segments, params };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Attempts to match a URL against a route pattern
|
|
96
|
+
* Returns the extracted params if matched
|
|
97
|
+
*/
|
|
98
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: path matching branches
|
|
99
|
+
export function matchPath(pattern: string, url: string): MatchResult {
|
|
100
|
+
const patternParts = pattern.split("/").filter(Boolean);
|
|
101
|
+
const urlParts = url.split("/").filter(Boolean);
|
|
102
|
+
|
|
103
|
+
const params: Record<string, string | string[]> = {};
|
|
104
|
+
|
|
105
|
+
let patternIndex = 0;
|
|
106
|
+
let urlIndex = 0;
|
|
107
|
+
|
|
108
|
+
while (patternIndex < patternParts.length) {
|
|
109
|
+
const patternPart = patternParts[patternIndex];
|
|
110
|
+
|
|
111
|
+
// Handle catch-all
|
|
112
|
+
if (patternPart === "*") {
|
|
113
|
+
// Consume all remaining URL parts
|
|
114
|
+
params.slug = urlParts.slice(urlIndex);
|
|
115
|
+
return { matched: true, params };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Handle optional param
|
|
119
|
+
if (patternPart.endsWith("?")) {
|
|
120
|
+
const paramName = patternPart.slice(1, -1);
|
|
121
|
+
if (urlIndex < urlParts.length) {
|
|
122
|
+
params[paramName] = urlParts[urlIndex];
|
|
123
|
+
urlIndex++;
|
|
124
|
+
}
|
|
125
|
+
patternIndex++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Handle required param
|
|
130
|
+
if (patternPart.startsWith(":")) {
|
|
131
|
+
if (urlIndex >= urlParts.length) {
|
|
132
|
+
return { matched: false, params: {} };
|
|
133
|
+
}
|
|
134
|
+
const paramName = patternPart.slice(1);
|
|
135
|
+
params[paramName] = urlParts[urlIndex];
|
|
136
|
+
urlIndex++;
|
|
137
|
+
patternIndex++;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Handle static segment
|
|
142
|
+
if (urlIndex >= urlParts.length || patternPart !== urlParts[urlIndex]) {
|
|
143
|
+
return { matched: false, params: {} };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
patternIndex++;
|
|
147
|
+
urlIndex++;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check if we consumed all URL parts (unless pattern has optional params)
|
|
151
|
+
if (urlIndex < urlParts.length) {
|
|
152
|
+
return { matched: false, params: {} };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { matched: true, params };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Converts a route path to a React Navigation screen name
|
|
160
|
+
*/
|
|
161
|
+
export function pathToScreenName(path: string): string {
|
|
162
|
+
// Remove leading slash
|
|
163
|
+
return path.replace(/^\//, "");
|
|
164
|
+
}
|