@tanstack/router-core 0.0.1-beta.9 → 1.20.3-alpha.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.
Files changed (194) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -0
  3. package/dist/cjs/Matches.cjs +13 -0
  4. package/dist/cjs/Matches.cjs.map +1 -0
  5. package/dist/cjs/Matches.d.cts +109 -0
  6. package/dist/cjs/RouterProvider.d.cts +26 -0
  7. package/dist/cjs/defer.cjs +25 -0
  8. package/dist/cjs/defer.cjs.map +1 -0
  9. package/dist/cjs/defer.d.cts +20 -0
  10. package/dist/cjs/fileRoute.d.cts +23 -0
  11. package/dist/cjs/history.d.cts +8 -0
  12. package/dist/cjs/index.cjs +80 -0
  13. package/dist/cjs/index.cjs.map +1 -0
  14. package/dist/cjs/index.d.cts +41 -0
  15. package/dist/cjs/link.cjs +5 -0
  16. package/dist/cjs/link.cjs.map +1 -0
  17. package/dist/cjs/link.d.cts +200 -0
  18. package/dist/cjs/location.d.cts +12 -0
  19. package/dist/cjs/manifest.d.cts +24 -0
  20. package/dist/cjs/not-found.cjs +13 -0
  21. package/dist/cjs/not-found.cjs.map +1 -0
  22. package/dist/cjs/not-found.d.cts +20 -0
  23. package/dist/cjs/path.cjs +412 -0
  24. package/dist/cjs/path.cjs.map +1 -0
  25. package/dist/cjs/path.d.cts +56 -0
  26. package/dist/cjs/qss.cjs +38 -0
  27. package/dist/cjs/qss.cjs.map +1 -0
  28. package/dist/cjs/qss.d.cts +22 -0
  29. package/dist/cjs/redirect.cjs +34 -0
  30. package/dist/cjs/redirect.cjs.map +1 -0
  31. package/dist/cjs/redirect.d.cts +38 -0
  32. package/dist/cjs/root.cjs +5 -0
  33. package/dist/cjs/root.cjs.map +1 -0
  34. package/dist/cjs/root.d.cts +2 -0
  35. package/dist/cjs/route.cjs +119 -0
  36. package/dist/cjs/route.cjs.map +1 -0
  37. package/dist/cjs/route.d.cts +422 -0
  38. package/dist/cjs/routeInfo.d.cts +54 -0
  39. package/dist/cjs/router.cjs +1800 -0
  40. package/dist/cjs/router.cjs.map +1 -0
  41. package/dist/cjs/router.d.cts +630 -0
  42. package/dist/cjs/scroll-restoration.cjs +196 -0
  43. package/dist/cjs/scroll-restoration.cjs.map +1 -0
  44. package/dist/cjs/scroll-restoration.d.cts +38 -0
  45. package/dist/cjs/searchMiddleware.cjs +42 -0
  46. package/dist/cjs/searchMiddleware.cjs.map +1 -0
  47. package/dist/cjs/searchMiddleware.d.cts +5 -0
  48. package/dist/cjs/searchParams.cjs +61 -0
  49. package/dist/cjs/searchParams.cjs.map +1 -0
  50. package/dist/cjs/searchParams.d.cts +7 -0
  51. package/dist/cjs/serializer.d.cts +22 -0
  52. package/dist/cjs/structuralSharing.d.cts +4 -0
  53. package/dist/cjs/typePrimitives.d.cts +65 -0
  54. package/dist/cjs/useLoaderData.d.cts +5 -0
  55. package/dist/cjs/useLoaderDeps.d.cts +5 -0
  56. package/dist/cjs/useNavigate.d.cts +3 -0
  57. package/dist/cjs/useParams.d.cts +5 -0
  58. package/dist/cjs/useRouteContext.d.cts +9 -0
  59. package/dist/cjs/useSearch.d.cts +5 -0
  60. package/dist/cjs/utils.cjs +160 -0
  61. package/dist/cjs/utils.cjs.map +1 -0
  62. package/dist/cjs/utils.d.cts +105 -0
  63. package/dist/cjs/validators.d.cts +51 -0
  64. package/dist/esm/Matches.d.ts +109 -0
  65. package/dist/esm/Matches.js +13 -0
  66. package/dist/esm/Matches.js.map +1 -0
  67. package/dist/esm/RouterProvider.d.ts +26 -0
  68. package/dist/esm/defer.d.ts +20 -0
  69. package/dist/esm/defer.js +25 -0
  70. package/dist/esm/defer.js.map +1 -0
  71. package/dist/esm/fileRoute.d.ts +23 -0
  72. package/dist/esm/history.d.ts +8 -0
  73. package/dist/esm/index.d.ts +41 -0
  74. package/dist/esm/index.js +80 -0
  75. package/dist/esm/index.js.map +1 -0
  76. package/dist/esm/link.d.ts +200 -0
  77. package/dist/esm/link.js +5 -0
  78. package/dist/esm/link.js.map +1 -0
  79. package/dist/esm/location.d.ts +12 -0
  80. package/dist/esm/manifest.d.ts +24 -0
  81. package/dist/esm/not-found.d.ts +20 -0
  82. package/dist/esm/not-found.js +13 -0
  83. package/dist/esm/not-found.js.map +1 -0
  84. package/dist/esm/path.d.ts +56 -0
  85. package/dist/esm/path.js +412 -0
  86. package/dist/esm/path.js.map +1 -0
  87. package/dist/esm/qss.d.ts +22 -0
  88. package/dist/esm/qss.js +38 -0
  89. package/dist/esm/qss.js.map +1 -0
  90. package/dist/esm/redirect.d.ts +38 -0
  91. package/dist/esm/redirect.js +34 -0
  92. package/dist/esm/redirect.js.map +1 -0
  93. package/dist/esm/root.d.ts +2 -0
  94. package/dist/esm/root.js +5 -0
  95. package/dist/esm/root.js.map +1 -0
  96. package/dist/esm/route.d.ts +422 -0
  97. package/dist/esm/route.js +119 -0
  98. package/dist/esm/route.js.map +1 -0
  99. package/dist/esm/routeInfo.d.ts +54 -0
  100. package/dist/esm/router.d.ts +630 -0
  101. package/dist/esm/router.js +1800 -0
  102. package/dist/esm/router.js.map +1 -0
  103. package/dist/esm/scroll-restoration.d.ts +38 -0
  104. package/dist/esm/scroll-restoration.js +196 -0
  105. package/dist/esm/scroll-restoration.js.map +1 -0
  106. package/dist/esm/searchMiddleware.d.ts +5 -0
  107. package/dist/esm/searchMiddleware.js +42 -0
  108. package/dist/esm/searchMiddleware.js.map +1 -0
  109. package/dist/esm/searchParams.d.ts +7 -0
  110. package/dist/esm/searchParams.js +61 -0
  111. package/dist/esm/searchParams.js.map +1 -0
  112. package/dist/esm/serializer.d.ts +22 -0
  113. package/dist/esm/structuralSharing.d.ts +4 -0
  114. package/dist/esm/typePrimitives.d.ts +65 -0
  115. package/dist/esm/useLoaderData.d.ts +5 -0
  116. package/dist/esm/useLoaderDeps.d.ts +5 -0
  117. package/dist/esm/useNavigate.d.ts +3 -0
  118. package/dist/esm/useParams.d.ts +5 -0
  119. package/dist/esm/useRouteContext.d.ts +9 -0
  120. package/dist/esm/useSearch.d.ts +5 -0
  121. package/dist/esm/utils.d.ts +105 -0
  122. package/dist/esm/utils.js +160 -0
  123. package/dist/esm/utils.js.map +1 -0
  124. package/dist/esm/validators.d.ts +51 -0
  125. package/package.json +36 -32
  126. package/src/Matches.ts +239 -0
  127. package/src/RouterProvider.ts +50 -0
  128. package/src/defer.ts +52 -0
  129. package/src/fileRoute.ts +140 -0
  130. package/src/history.ts +9 -0
  131. package/src/index.ts +421 -19
  132. package/src/link.ts +580 -286
  133. package/src/location.ts +13 -0
  134. package/src/manifest.ts +32 -0
  135. package/src/not-found.ts +29 -0
  136. package/src/path.ts +425 -49
  137. package/src/qss.ts +70 -41
  138. package/src/redirect.ts +100 -0
  139. package/src/root.ts +2 -0
  140. package/src/route.ts +1682 -218
  141. package/src/routeInfo.ts +224 -217
  142. package/src/router.ts +3100 -1073
  143. package/src/scroll-restoration.ts +340 -0
  144. package/src/searchMiddleware.ts +54 -0
  145. package/src/searchParams.ts +43 -20
  146. package/src/serializer.ts +32 -0
  147. package/src/structuralSharing.ts +7 -0
  148. package/src/typePrimitives.ts +181 -0
  149. package/src/useLoaderData.ts +20 -0
  150. package/src/useLoaderDeps.ts +13 -0
  151. package/src/useNavigate.ts +13 -0
  152. package/src/useParams.ts +20 -0
  153. package/src/useRouteContext.ts +39 -0
  154. package/src/useSearch.ts +20 -0
  155. package/src/utils.ts +369 -75
  156. package/src/validators.ts +121 -0
  157. package/build/cjs/_virtual/_rollupPluginBabelHelpers.js +0 -33
  158. package/build/cjs/_virtual/_rollupPluginBabelHelpers.js.map +0 -1
  159. package/build/cjs/node_modules/@babel/runtime/helpers/esm/extends.js +0 -33
  160. package/build/cjs/node_modules/@babel/runtime/helpers/esm/extends.js.map +0 -1
  161. package/build/cjs/node_modules/history/index.js +0 -815
  162. package/build/cjs/node_modules/history/index.js.map +0 -1
  163. package/build/cjs/node_modules/tiny-invariant/dist/esm/tiny-invariant.js +0 -30
  164. package/build/cjs/node_modules/tiny-invariant/dist/esm/tiny-invariant.js.map +0 -1
  165. package/build/cjs/packages/router-core/src/index.js +0 -58
  166. package/build/cjs/packages/router-core/src/index.js.map +0 -1
  167. package/build/cjs/packages/router-core/src/path.js +0 -222
  168. package/build/cjs/packages/router-core/src/path.js.map +0 -1
  169. package/build/cjs/packages/router-core/src/qss.js +0 -71
  170. package/build/cjs/packages/router-core/src/qss.js.map +0 -1
  171. package/build/cjs/packages/router-core/src/route.js +0 -150
  172. package/build/cjs/packages/router-core/src/route.js.map +0 -1
  173. package/build/cjs/packages/router-core/src/routeConfig.js +0 -69
  174. package/build/cjs/packages/router-core/src/routeConfig.js.map +0 -1
  175. package/build/cjs/packages/router-core/src/routeMatch.js +0 -266
  176. package/build/cjs/packages/router-core/src/routeMatch.js.map +0 -1
  177. package/build/cjs/packages/router-core/src/router.js +0 -822
  178. package/build/cjs/packages/router-core/src/router.js.map +0 -1
  179. package/build/cjs/packages/router-core/src/searchParams.js +0 -70
  180. package/build/cjs/packages/router-core/src/searchParams.js.map +0 -1
  181. package/build/cjs/packages/router-core/src/utils.js +0 -125
  182. package/build/cjs/packages/router-core/src/utils.js.map +0 -1
  183. package/build/esm/index.js +0 -2481
  184. package/build/esm/index.js.map +0 -1
  185. package/build/stats-html.html +0 -4034
  186. package/build/stats-react.json +0 -493
  187. package/build/types/index.d.ts +0 -618
  188. package/build/umd/index.development.js +0 -2514
  189. package/build/umd/index.development.js.map +0 -1
  190. package/build/umd/index.production.js +0 -12
  191. package/build/umd/index.production.js.map +0 -1
  192. package/src/frameworks.ts +0 -12
  193. package/src/routeConfig.ts +0 -495
  194. package/src/routeMatch.ts +0 -374
@@ -0,0 +1,13 @@
1
+ import type { ParsedHistoryState } from '@tanstack/history'
2
+ import type { AnySchema } from './validators'
3
+
4
+ export interface ParsedLocation<TSearchObj extends AnySchema = {}> {
5
+ href: string
6
+ pathname: string
7
+ search: TSearchObj
8
+ searchStr: string
9
+ state: ParsedHistoryState
10
+ hash: string
11
+ maskedLocation?: ParsedLocation<TSearchObj>
12
+ unmaskOnReload?: boolean
13
+ }
@@ -0,0 +1,32 @@
1
+ export type Manifest = {
2
+ routes: Record<
3
+ string,
4
+ {
5
+ filePath?: string
6
+ preloads?: Array<string>
7
+ assets?: Array<RouterManagedTag>
8
+ }
9
+ >
10
+ }
11
+
12
+ export type RouterManagedTag =
13
+ | {
14
+ tag: 'title'
15
+ attrs?: Record<string, any>
16
+ children: string
17
+ }
18
+ | {
19
+ tag: 'meta' | 'link'
20
+ attrs?: Record<string, any>
21
+ children?: never
22
+ }
23
+ | {
24
+ tag: 'script'
25
+ attrs?: Record<string, any>
26
+ children?: string
27
+ }
28
+ | {
29
+ tag: 'style'
30
+ attrs?: Record<string, any>
31
+ children?: string
32
+ }
@@ -0,0 +1,29 @@
1
+ import type { RouteIds } from './routeInfo'
2
+ import type { RegisteredRouter } from './router'
3
+
4
+ export type NotFoundError = {
5
+ /**
6
+ @deprecated
7
+ Use `routeId: rootRouteId` instead
8
+ */
9
+ global?: boolean
10
+ /**
11
+ @private
12
+ Do not use this. It's used internally to indicate a path matching error
13
+ */
14
+ _global?: boolean
15
+ data?: any
16
+ throw?: boolean
17
+ routeId?: RouteIds<RegisteredRouter['routeTree']>
18
+ headers?: HeadersInit
19
+ }
20
+
21
+ export function notFound(options: NotFoundError = {}) {
22
+ ;(options as any).isNotFound = true
23
+ if (options.throw) throw options
24
+ return options
25
+ }
26
+
27
+ export function isNotFound(obj: any): obj is NotFoundError {
28
+ return !!obj?.isNotFound
29
+ }
package/src/path.ts CHANGED
@@ -1,14 +1,23 @@
1
- import { AnyPathParams } from './routeConfig'
2
- import { MatchLocation } from './router'
3
1
  import { last } from './utils'
2
+ import type { MatchLocation } from './RouterProvider'
3
+ import type { AnyPathParams } from './route'
4
4
 
5
5
  export interface Segment {
6
6
  type: 'pathname' | 'param' | 'wildcard'
7
7
  value: string
8
+ // Add a new property to store the static segment if present
9
+ prefixSegment?: string
10
+ suffixSegment?: string
8
11
  }
9
12
 
10
- export function joinPaths(paths: (string | undefined)[]) {
11
- return cleanPath(paths.filter(Boolean).join('/'))
13
+ export function joinPaths(paths: Array<string | undefined>) {
14
+ return cleanPath(
15
+ paths
16
+ .filter((val) => {
17
+ return val !== undefined
18
+ })
19
+ .join('/'),
20
+ )
12
21
  }
13
22
 
14
23
  export function cleanPath(path: string) {
@@ -28,13 +37,79 @@ export function trimPath(path: string) {
28
37
  return trimPathRight(trimPathLeft(path))
29
38
  }
30
39
 
31
- export function resolvePath(basepath: string, base: string, to: string) {
32
- base = base.replace(new RegExp(`^${basepath}`), '/')
33
- to = to.replace(new RegExp(`^${basepath}`), '/')
40
+ export function removeTrailingSlash(value: string, basepath: string): string {
41
+ if (value?.endsWith('/') && value !== '/' && value !== `${basepath}/`) {
42
+ return value.slice(0, -1)
43
+ }
44
+ return value
45
+ }
46
+
47
+ // intended to only compare path name
48
+ // see the usage in the isActive under useLinkProps
49
+ // /sample/path1 = /sample/path1/
50
+ // /sample/path1/some <> /sample/path1
51
+ export function exactPathTest(
52
+ pathName1: string,
53
+ pathName2: string,
54
+ basepath: string,
55
+ ): boolean {
56
+ return (
57
+ removeTrailingSlash(pathName1, basepath) ===
58
+ removeTrailingSlash(pathName2, basepath)
59
+ )
60
+ }
61
+
62
+ // When resolving relative paths, we treat all paths as if they are trailing slash
63
+ // documents. All trailing slashes are removed after the path is resolved.
64
+ // Here are a few examples:
65
+ //
66
+ // /a/b/c + ./d = /a/b/c/d
67
+ // /a/b/c + ../d = /a/b/d
68
+ // /a/b/c + ./d/ = /a/b/c/d
69
+ // /a/b/c + ../d/ = /a/b/d
70
+ // /a/b/c + ./ = /a/b/c
71
+ //
72
+ // Absolute paths that start with `/` short circuit the resolution process to the root
73
+ // path.
74
+ //
75
+ // Here are some examples:
76
+ //
77
+ // /a/b/c + /d = /d
78
+ // /a/b/c + /d/ = /d
79
+ // /a/b/c + / = /
80
+ //
81
+ // Non-.-prefixed paths are still treated as relative paths, resolved like `./`
82
+ //
83
+ // Here are some examples:
84
+ //
85
+ // /a/b/c + d = /a/b/c/d
86
+ // /a/b/c + d/ = /a/b/c/d
87
+ // /a/b/c + d/e = /a/b/c/d/e
88
+ interface ResolvePathOptions {
89
+ basepath: string
90
+ base: string
91
+ to: string
92
+ trailingSlash?: 'always' | 'never' | 'preserve'
93
+ caseSensitive?: boolean
94
+ }
95
+
96
+ export function resolvePath({
97
+ basepath,
98
+ base,
99
+ to,
100
+ trailingSlash = 'never',
101
+ caseSensitive,
102
+ }: ResolvePathOptions) {
103
+ base = removeBasepath(basepath, base, caseSensitive)
104
+ to = removeBasepath(basepath, to, caseSensitive)
34
105
 
35
106
  let baseSegments = parsePathname(base)
36
107
  const toSegments = parsePathname(to)
37
108
 
109
+ if (baseSegments.length > 1 && last(baseSegments)?.value === '/') {
110
+ baseSegments.pop()
111
+ }
112
+
38
113
  toSegments.forEach((toSegment, index) => {
39
114
  if (toSegment.value === '/') {
40
115
  if (!index) {
@@ -47,31 +122,78 @@ export function resolvePath(basepath: string, base: string, to: string) {
47
122
  // ignore inter-slashes
48
123
  }
49
124
  } else if (toSegment.value === '..') {
50
- // Extra trailing slash? pop it off
51
- if (baseSegments.length > 1 && last(baseSegments)?.value === '/') {
52
- baseSegments.pop()
53
- }
54
125
  baseSegments.pop()
55
126
  } else if (toSegment.value === '.') {
56
- return
127
+ // ignore
57
128
  } else {
58
129
  baseSegments.push(toSegment)
59
130
  }
60
131
  })
61
132
 
62
- const joined = joinPaths([basepath, ...baseSegments.map((d) => d.value)])
133
+ if (baseSegments.length > 1) {
134
+ if (last(baseSegments)?.value === '/') {
135
+ if (trailingSlash === 'never') {
136
+ baseSegments.pop()
137
+ }
138
+ } else if (trailingSlash === 'always') {
139
+ baseSegments.push({ type: 'pathname', value: '/' })
140
+ }
141
+ }
63
142
 
143
+ const segmentValues = baseSegments.map((segment) => {
144
+ if (segment.type === 'param') {
145
+ const param = segment.value.substring(1)
146
+ if (segment.prefixSegment && segment.suffixSegment) {
147
+ return `${segment.prefixSegment}{$${param}}${segment.suffixSegment}`
148
+ } else if (segment.prefixSegment) {
149
+ return `${segment.prefixSegment}{$${param}}`
150
+ } else if (segment.suffixSegment) {
151
+ return `{$${param}}${segment.suffixSegment}`
152
+ }
153
+ }
154
+ if (segment.type === 'wildcard') {
155
+ if (segment.prefixSegment && segment.suffixSegment) {
156
+ return `${segment.prefixSegment}{$}${segment.suffixSegment}`
157
+ } else if (segment.prefixSegment) {
158
+ return `${segment.prefixSegment}{$}`
159
+ } else if (segment.suffixSegment) {
160
+ return `{$}${segment.suffixSegment}`
161
+ }
162
+ }
163
+ return segment.value
164
+ })
165
+ const joined = joinPaths([basepath, ...segmentValues])
64
166
  return cleanPath(joined)
65
167
  }
66
168
 
67
- export function parsePathname(pathname?: string): Segment[] {
169
+ const PARAM_RE = /^\$.{1,}$/ // $paramName
170
+ const PARAM_W_CURLY_BRACES_RE = /^(.*?)\{(\$[a-zA-Z_$][a-zA-Z0-9_$]*)\}(.*)$/ // prefix{$paramName}suffix
171
+ const WILDCARD_RE = /^\$$/ // $
172
+ const WILDCARD_W_CURLY_BRACES_RE = /^(.*?)\{\$\}(.*)$/ // prefix{$}suffix
173
+
174
+ /**
175
+ * Required: `/foo/$bar` ✅
176
+ * Prefix and Suffix: `/foo/prefix${bar}suffix` ✅
177
+ * Wildcard: `/foo/$` ✅
178
+ * Wildcard with Prefix and Suffix: `/foo/prefix{$}suffix` ✅
179
+ *
180
+ * Future:
181
+ * Optional: `/foo/{-bar}`
182
+ * Optional named segment: `/foo/{bar}`
183
+ * Optional named segment with Prefix and Suffix: `/foo/prefix{-bar}suffix`
184
+ * Escape special characters:
185
+ * - `/foo/[$]` - Static route
186
+ * - `/foo/[$]{$foo} - Dynamic route with a static prefix of `$`
187
+ * - `/foo/{$foo}[$]` - Dynamic route with a static suffix of `$`
188
+ */
189
+ export function parsePathname(pathname?: string): Array<Segment> {
68
190
  if (!pathname) {
69
191
  return []
70
192
  }
71
193
 
72
194
  pathname = cleanPath(pathname)
73
195
 
74
- const segments: Segment[] = []
196
+ const segments: Array<Segment> = []
75
197
 
76
198
  if (pathname.slice(0, 1) === '/') {
77
199
  pathname = pathname.substring(1)
@@ -90,23 +212,63 @@ export function parsePathname(pathname?: string): Segment[] {
90
212
 
91
213
  segments.push(
92
214
  ...split.map((part): Segment => {
93
- if (part.startsWith('*')) {
215
+ // Check for wildcard with curly braces: prefix{$}suffix
216
+ const wildcardBracesMatch = part.match(WILDCARD_W_CURLY_BRACES_RE)
217
+ if (wildcardBracesMatch) {
218
+ const prefix = wildcardBracesMatch[1]
219
+ const suffix = wildcardBracesMatch[2]
94
220
  return {
95
221
  type: 'wildcard',
96
- value: part,
222
+ value: '$',
223
+ prefixSegment: prefix || undefined,
224
+ suffixSegment: suffix || undefined,
225
+ }
226
+ }
227
+
228
+ // Check for the new parameter format: prefix{$paramName}suffix
229
+ const paramBracesMatch = part.match(PARAM_W_CURLY_BRACES_RE)
230
+ if (paramBracesMatch) {
231
+ const prefix = paramBracesMatch[1]
232
+ const paramName = paramBracesMatch[2]
233
+ const suffix = paramBracesMatch[3]
234
+ return {
235
+ type: 'param',
236
+ value: '' + paramName,
237
+ prefixSegment: prefix || undefined,
238
+ suffixSegment: suffix || undefined,
97
239
  }
98
240
  }
99
241
 
100
- if (part.charAt(0) === ':') {
242
+ // Check for bare parameter format: $paramName (without curly braces)
243
+ if (PARAM_RE.test(part)) {
244
+ const paramName = part.substring(1)
101
245
  return {
102
246
  type: 'param',
103
- value: part,
247
+ value: '$' + paramName,
248
+ prefixSegment: undefined,
249
+ suffixSegment: undefined,
250
+ }
251
+ }
252
+
253
+ // Check for bare wildcard: $ (without curly braces)
254
+ if (WILDCARD_RE.test(part)) {
255
+ return {
256
+ type: 'wildcard',
257
+ value: '$',
258
+ prefixSegment: undefined,
259
+ suffixSegment: undefined,
104
260
  }
105
261
  }
106
262
 
263
+ // Handle regular pathname segment
107
264
  return {
108
265
  type: 'pathname',
109
- value: part,
266
+ value: part.includes('%25')
267
+ ? part
268
+ .split('%25')
269
+ .map((segment) => decodeURI(segment))
270
+ .join('%25')
271
+ : decodeURI(part),
110
272
  }
111
273
  }),
112
274
  )
@@ -122,56 +284,186 @@ export function parsePathname(pathname?: string): Segment[] {
122
284
  return segments
123
285
  }
124
286
 
125
- export function interpolatePath(
126
- path: string | undefined,
127
- params: any,
128
- leaveWildcard?: boolean,
129
- ) {
287
+ interface InterpolatePathOptions {
288
+ path?: string
289
+ params: Record<string, unknown>
290
+ leaveWildcards?: boolean
291
+ leaveParams?: boolean
292
+ // Map of encoded chars to decoded chars (e.g. '%40' -> '@') that should remain decoded in path params
293
+ decodeCharMap?: Map<string, string>
294
+ }
295
+
296
+ type InterPolatePathResult = {
297
+ interpolatedPath: string
298
+ usedParams: Record<string, unknown>
299
+ isMissingParams: boolean // true if any params were not available when being looked up in the params object
300
+ }
301
+ export function interpolatePath({
302
+ path,
303
+ params,
304
+ leaveWildcards,
305
+ leaveParams,
306
+ decodeCharMap,
307
+ }: InterpolatePathOptions): InterPolatePathResult {
130
308
  const interpolatedPathSegments = parsePathname(path)
131
309
 
132
- return joinPaths(
310
+ function encodeParam(key: string): any {
311
+ const value = params[key]
312
+ const isValueString = typeof value === 'string'
313
+
314
+ if (['*', '_splat'].includes(key)) {
315
+ // the splat/catch-all routes shouldn't have the '/' encoded out
316
+ return isValueString ? encodeURI(value) : value
317
+ } else {
318
+ return isValueString ? encodePathParam(value, decodeCharMap) : value
319
+ }
320
+ }
321
+
322
+ // Tracking if any params are missing in the `params` object
323
+ // when interpolating the path
324
+ let isMissingParams = false
325
+
326
+ const usedParams: Record<string, unknown> = {}
327
+ const interpolatedPath = joinPaths(
133
328
  interpolatedPathSegments.map((segment) => {
134
- if (segment.value === '*' && !leaveWildcard) {
135
- return ''
329
+ if (segment.type === 'wildcard') {
330
+ usedParams._splat = params._splat
331
+ const segmentPrefix = segment.prefixSegment || ''
332
+ const segmentSuffix = segment.suffixSegment || ''
333
+ const value = encodeParam('_splat')
334
+ if (leaveWildcards) {
335
+ return `${segmentPrefix}${segment.value}${value ?? ''}${segmentSuffix}`
336
+ }
337
+ return `${segmentPrefix}${value}${segmentSuffix}`
136
338
  }
137
339
 
138
340
  if (segment.type === 'param') {
139
- return params![segment.value.substring(1)] ?? ''
341
+ const key = segment.value.substring(1)
342
+ if (!isMissingParams && !(key in params)) {
343
+ isMissingParams = true
344
+ }
345
+ usedParams[key] = params[key]
346
+
347
+ const segmentPrefix = segment.prefixSegment || ''
348
+ const segmentSuffix = segment.suffixSegment || ''
349
+ if (leaveParams) {
350
+ const value = encodeParam(segment.value)
351
+ return `${segmentPrefix}${segment.value}${value ?? ''}${segmentSuffix}`
352
+ }
353
+ return `${segmentPrefix}${encodeParam(key) ?? 'undefined'}${segmentSuffix}`
140
354
  }
141
355
 
142
356
  return segment.value
143
357
  }),
144
358
  )
359
+ return { usedParams, interpolatedPath, isMissingParams }
360
+ }
361
+
362
+ function encodePathParam(value: string, decodeCharMap?: Map<string, string>) {
363
+ let encoded = encodeURIComponent(value)
364
+ if (decodeCharMap) {
365
+ for (const [encodedChar, char] of decodeCharMap) {
366
+ encoded = encoded.replaceAll(encodedChar, char)
367
+ }
368
+ }
369
+ return encoded
145
370
  }
146
371
 
147
372
  export function matchPathname(
373
+ basepath: string,
148
374
  currentPathname: string,
149
375
  matchLocation: Pick<MatchLocation, 'to' | 'fuzzy' | 'caseSensitive'>,
150
376
  ): AnyPathParams | undefined {
151
- const pathParams = matchByPath(currentPathname, matchLocation)
152
- // const searchMatched = matchBySearch(currentLocation.search, matchLocation)
377
+ const pathParams = matchByPath(basepath, currentPathname, matchLocation)
378
+ // const searchMatched = matchBySearch(location.search, matchLocation)
153
379
 
154
380
  if (matchLocation.to && !pathParams) {
155
381
  return
156
382
  }
157
383
 
158
- // if (matchLocation.search && !searchMatched) {
159
- // return
160
- // }
161
-
162
384
  return pathParams ?? {}
163
385
  }
164
386
 
387
+ export function removeBasepath(
388
+ basepath: string,
389
+ pathname: string,
390
+ caseSensitive: boolean = false,
391
+ ) {
392
+ // normalize basepath and pathname for case-insensitive comparison if needed
393
+ const normalizedBasepath = caseSensitive ? basepath : basepath.toLowerCase()
394
+ const normalizedPathname = caseSensitive ? pathname : pathname.toLowerCase()
395
+
396
+ switch (true) {
397
+ // default behaviour is to serve app from the root - pathname
398
+ // left untouched
399
+ case normalizedBasepath === '/':
400
+ return pathname
401
+
402
+ // shortcut for removing the basepath if it matches the pathname
403
+ case normalizedPathname === normalizedBasepath:
404
+ return ''
405
+
406
+ // in case pathname is shorter than basepath - there is
407
+ // nothing to remove
408
+ case pathname.length < basepath.length:
409
+ return pathname
410
+
411
+ // avoid matching partial segments - strict equality handled
412
+ // earlier, otherwise, basepath separated from pathname with
413
+ // separator, therefore lack of separator means partial
414
+ // segment match (`/app` should not match `/application`)
415
+ case normalizedPathname[normalizedBasepath.length] !== '/':
416
+ return pathname
417
+
418
+ // remove the basepath from the pathname if it starts with it
419
+ case normalizedPathname.startsWith(normalizedBasepath):
420
+ return pathname.slice(basepath.length)
421
+
422
+ // otherwise, return the pathname as is
423
+ default:
424
+ return pathname
425
+ }
426
+ }
427
+
165
428
  export function matchByPath(
429
+ basepath: string,
166
430
  from: string,
167
431
  matchLocation: Pick<MatchLocation, 'to' | 'caseSensitive' | 'fuzzy'>,
168
432
  ): Record<string, string> | undefined {
433
+ // check basepath first
434
+ if (basepath !== '/' && !from.startsWith(basepath)) {
435
+ return undefined
436
+ }
437
+ // Remove the base path from the pathname
438
+ from = removeBasepath(basepath, from, matchLocation.caseSensitive)
439
+ // Default to to $ (wildcard)
440
+ const to = removeBasepath(
441
+ basepath,
442
+ `${matchLocation.to ?? '$'}`,
443
+ matchLocation.caseSensitive,
444
+ )
445
+
446
+ // Parse the from and to
169
447
  const baseSegments = parsePathname(from)
170
- const routeSegments = parsePathname(`${matchLocation.to ?? '*'}`)
448
+ const routeSegments = parsePathname(to)
449
+
450
+ if (!from.startsWith('/')) {
451
+ baseSegments.unshift({
452
+ type: 'pathname',
453
+ value: '/',
454
+ })
455
+ }
456
+
457
+ if (!to.startsWith('/')) {
458
+ routeSegments.unshift({
459
+ type: 'pathname',
460
+ value: '/',
461
+ })
462
+ }
171
463
 
172
464
  const params: Record<string, string> = {}
173
465
 
174
- let isMatch = (() => {
466
+ const isMatch = (() => {
175
467
  for (
176
468
  let i = 0;
177
469
  i < Math.max(baseSegments.length, routeSegments.length);
@@ -180,16 +472,66 @@ export function matchByPath(
180
472
  const baseSegment = baseSegments[i]
181
473
  const routeSegment = routeSegments[i]
182
474
 
183
- const isLastRouteSegment = i === routeSegments.length - 1
184
- const isLastBaseSegment = i === baseSegments.length - 1
475
+ const isLastBaseSegment = i >= baseSegments.length - 1
476
+ const isLastRouteSegment = i >= routeSegments.length - 1
185
477
 
186
478
  if (routeSegment) {
187
479
  if (routeSegment.type === 'wildcard') {
188
- if (baseSegment?.value) {
189
- params['*'] = joinPaths(baseSegments.slice(i).map((d) => d.value))
190
- return true
480
+ // Capture all remaining segments for a wildcard
481
+ const remainingBaseSegments = baseSegments.slice(i)
482
+
483
+ let _splat: string
484
+
485
+ // If this is a wildcard with prefix/suffix, we need to handle the first segment specially
486
+ if (routeSegment.prefixSegment || routeSegment.suffixSegment) {
487
+ if (!baseSegment) return false
488
+
489
+ const prefix = routeSegment.prefixSegment || ''
490
+ const suffix = routeSegment.suffixSegment || ''
491
+
492
+ // Check if the base segment starts with prefix and ends with suffix
493
+ const baseValue = baseSegment.value
494
+ if ('prefixSegment' in routeSegment) {
495
+ if (!baseValue.startsWith(prefix)) {
496
+ return false
497
+ }
498
+ }
499
+ if ('suffixSegment' in routeSegment) {
500
+ if (
501
+ !baseSegments[baseSegments.length - 1]?.value.endsWith(suffix)
502
+ ) {
503
+ return false
504
+ }
505
+ }
506
+
507
+ let rejoinedSplat = decodeURI(
508
+ joinPaths(remainingBaseSegments.map((d) => d.value)),
509
+ )
510
+
511
+ // Remove the prefix and suffix from the rejoined splat
512
+ if (prefix && rejoinedSplat.startsWith(prefix)) {
513
+ rejoinedSplat = rejoinedSplat.slice(prefix.length)
514
+ }
515
+
516
+ if (suffix && rejoinedSplat.endsWith(suffix)) {
517
+ rejoinedSplat = rejoinedSplat.slice(
518
+ 0,
519
+ rejoinedSplat.length - suffix.length,
520
+ )
521
+ }
522
+
523
+ _splat = rejoinedSplat
524
+ } else {
525
+ // If no prefix/suffix, just rejoin the remaining segments
526
+ _splat = decodeURI(
527
+ joinPaths(remainingBaseSegments.map((d) => d.value)),
528
+ )
191
529
  }
192
- return false
530
+
531
+ // TODO: Deprecate *
532
+ params['*'] = _splat
533
+ params['_splat'] = _splat
534
+ return true
193
535
  }
194
536
 
195
537
  if (routeSegment.type === 'pathname') {
@@ -216,21 +558,55 @@ export function matchByPath(
216
558
  }
217
559
 
218
560
  if (routeSegment.type === 'param') {
219
- if (baseSegment?.value === '/') {
561
+ if (baseSegment.value === '/') {
220
562
  return false
221
563
  }
222
- if (!baseSegment.value.startsWith(':')) {
223
- params[routeSegment.value.substring(1)] = baseSegment.value
564
+
565
+ let _paramValue: string
566
+
567
+ // If this param has prefix/suffix, we need to extract the actual parameter value
568
+ if (routeSegment.prefixSegment || routeSegment.suffixSegment) {
569
+ const prefix = routeSegment.prefixSegment || ''
570
+ const suffix = routeSegment.suffixSegment || ''
571
+
572
+ // Check if the base segment starts with prefix and ends with suffix
573
+ const baseValue = baseSegment.value
574
+ if (prefix && !baseValue.startsWith(prefix)) {
575
+ return false
576
+ }
577
+ if (suffix && !baseValue.endsWith(suffix)) {
578
+ return false
579
+ }
580
+
581
+ let paramValue = baseValue
582
+ if (prefix && paramValue.startsWith(prefix)) {
583
+ paramValue = paramValue.slice(prefix.length)
584
+ }
585
+ if (suffix && paramValue.endsWith(suffix)) {
586
+ paramValue = paramValue.slice(
587
+ 0,
588
+ paramValue.length - suffix.length,
589
+ )
590
+ }
591
+
592
+ _paramValue = decodeURIComponent(paramValue)
593
+ } else {
594
+ // If no prefix/suffix, just decode the base segment value
595
+ _paramValue = decodeURIComponent(baseSegment.value)
224
596
  }
597
+
598
+ params[routeSegment.value.substring(1)] = _paramValue
225
599
  }
226
600
  }
227
601
 
228
- if (isLastRouteSegment && !isLastBaseSegment) {
229
- return !!matchLocation.fuzzy
602
+ if (!isLastBaseSegment && isLastRouteSegment) {
603
+ params['**'] = joinPaths(baseSegments.slice(i + 1).map((d) => d.value))
604
+ return !!matchLocation.fuzzy && routeSegment?.value !== '/'
230
605
  }
231
606
  }
607
+
232
608
  return true
233
609
  })()
234
610
 
235
- return isMatch ? (params as Record<string, string>) : undefined
611
+ return isMatch ? params : undefined
236
612
  }