@solidrt/router 0.0.62
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/AGENTS.md +96 -0
- package/README.md +257 -0
- package/package.json +21 -0
- package/src/index.ts +17 -0
- package/src/route.ts +332 -0
- package/src/router.tsx +450 -0
- package/src/stack.ts +249 -0
package/src/route.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// The headless half of the router: the route tree, matching a path against
|
|
2
|
+
// it, building a path from a route and its params, and turning a link into a
|
|
3
|
+
// path. Plain functions over plain data, no rendering, no signals; the Solid
|
|
4
|
+
// binding (router.tsx) is the only consumer. Tested under bun without a
|
|
5
|
+
// client (tests/).
|
|
6
|
+
|
|
7
|
+
/** The raw string params a path carries, before a route's `parse`. */
|
|
8
|
+
export type RawParams = Record<string, string>
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A route in the tree. `P` is what this route's own `parse` produces: what
|
|
12
|
+
* `useParams(route)` is typed as (an ancestor's params ride along at run
|
|
13
|
+
* time, read them through the ancestor). A route is a plain value; it gets
|
|
14
|
+
* its parent when it is placed, in a `children` list or under a JSX
|
|
15
|
+
* `<Route>`. Created by createRootRoute / createRoute, never by hand.
|
|
16
|
+
*/
|
|
17
|
+
export interface Route<P = {}> {
|
|
18
|
+
readonly path: string
|
|
19
|
+
/** Set once, by placement. */
|
|
20
|
+
readonly parent: AnyRoute | null
|
|
21
|
+
readonly children: AnyRoute[]
|
|
22
|
+
readonly component: (() => any) | null
|
|
23
|
+
readonly parse: ((raw: RawParams) => unknown) | null
|
|
24
|
+
readonly segments: Segment[]
|
|
25
|
+
/** Each child is a tab with a stack of its own (see stack.ts). */
|
|
26
|
+
readonly tabs: boolean
|
|
27
|
+
// A phantom carrying the params type. Contravariant on purpose: a route
|
|
28
|
+
// with required params is not a `Route<{}>`, so navigate(route) without
|
|
29
|
+
// params is a type error for it while a param-less route passes bare.
|
|
30
|
+
readonly _params: (params: P) => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type AnyRoute = Route<any>
|
|
34
|
+
|
|
35
|
+
/** The params `useParams(R)` returns: what R's own `parse` produces. */
|
|
36
|
+
export type ParamsOf<R> = R extends Route<infer P> ? P : never
|
|
37
|
+
|
|
38
|
+
export interface RouteOptions<P> {
|
|
39
|
+
/**
|
|
40
|
+
* This route's own path, below its parent's: literal segments, `$name` for
|
|
41
|
+
* a param, `$name?` for an optional one (only at the end), `$` for the
|
|
42
|
+
* rest of the path (a catch-all, captured as `rest`). `/` adds nothing and
|
|
43
|
+
* makes an index route (or, with children, a pathless layout).
|
|
44
|
+
*/
|
|
45
|
+
path: string
|
|
46
|
+
/**
|
|
47
|
+
* The untrusted-input boundary: turns this route's raw string params into
|
|
48
|
+
* typed values, or throws, in which case the route does not match (the
|
|
49
|
+
* next sibling is tried, then a catch-all). Without it the raw strings are
|
|
50
|
+
* the params (typed RawParams).
|
|
51
|
+
*/
|
|
52
|
+
params?: { parse: (raw: RawParams) => P }
|
|
53
|
+
/**
|
|
54
|
+
* What renders at this route; a layout route renders its child through
|
|
55
|
+
* `<Outlet>`. Without one the route is an outlet alone.
|
|
56
|
+
*/
|
|
57
|
+
component?: () => any
|
|
58
|
+
/**
|
|
59
|
+
* The routes below this one, in matching order (put a catch-all `$`
|
|
60
|
+
* last). More can be placed later under a JSX `<Route route={...}>`.
|
|
61
|
+
*/
|
|
62
|
+
children?: AnyRoute[]
|
|
63
|
+
/**
|
|
64
|
+
* Makes each child a tab: a screen with a stack of its own that stays
|
|
65
|
+
* mounted while another tab shows. A tab's path is literal (the tab bar
|
|
66
|
+
* has no params to give it), the route and its ancestors take no params,
|
|
67
|
+
* and a tree holds one tabs route, not inside another. The component
|
|
68
|
+
* renders the tabs through `<Outlet>` and reaches them with `useTabs()`.
|
|
69
|
+
*/
|
|
70
|
+
tabs?: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// One piece of a route's path pattern.
|
|
74
|
+
export type Segment =
|
|
75
|
+
| { kind: "literal"; value: string }
|
|
76
|
+
| { kind: "param"; name: string; optional: boolean }
|
|
77
|
+
| { kind: "rest" }
|
|
78
|
+
|
|
79
|
+
export const REST_PARAM = "rest"
|
|
80
|
+
|
|
81
|
+
function parseSegments(path: string): Segment[] {
|
|
82
|
+
let out: Segment[] = []
|
|
83
|
+
for (let piece of path.split("/")) {
|
|
84
|
+
if (piece === "") continue
|
|
85
|
+
if (piece === "$") {
|
|
86
|
+
out.push({ kind: "rest" })
|
|
87
|
+
} else if (piece.startsWith("$")) {
|
|
88
|
+
let optional = piece.endsWith("?")
|
|
89
|
+
let name = piece.slice(1, optional ? -1 : undefined)
|
|
90
|
+
if (name === "") throw new Error(`Route path "${path}": a param needs a name`)
|
|
91
|
+
out.push({ kind: "param", name, optional })
|
|
92
|
+
} else {
|
|
93
|
+
out.push({ kind: "literal", value: piece })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// A rest or optional segment ends the pattern: nothing can follow what
|
|
97
|
+
// may consume everything or nothing.
|
|
98
|
+
for (let i = 0; i < out.length - 1; i++) {
|
|
99
|
+
let s = out[i]!
|
|
100
|
+
if (s.kind === "rest" || (s.kind === "param" && s.optional)) {
|
|
101
|
+
throw new Error(`Route path "${path}": "${s.kind === "rest" ? "$" : "$" + s.name + "?"}" must be the last segment`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The tree's root: matches the empty path and holds every other route. */
|
|
108
|
+
export function createRootRoute(options: { component?: () => any; children?: AnyRoute[] } = {}): Route<{}> {
|
|
109
|
+
let root: Route<{}> = {
|
|
110
|
+
path: "/",
|
|
111
|
+
parent: null,
|
|
112
|
+
children: [],
|
|
113
|
+
component: options.component ?? null,
|
|
114
|
+
parse: null,
|
|
115
|
+
segments: [],
|
|
116
|
+
tabs: false,
|
|
117
|
+
_params: () => {},
|
|
118
|
+
}
|
|
119
|
+
place(root, options.children ?? [])
|
|
120
|
+
return root
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A route: a value with no parent until it is placed. */
|
|
124
|
+
export function createRoute<P = RawParams>(options: RouteOptions<P>): Route<P> {
|
|
125
|
+
let route: Route<P> = {
|
|
126
|
+
path: options.path,
|
|
127
|
+
parent: null,
|
|
128
|
+
children: [],
|
|
129
|
+
component: options.component ?? null,
|
|
130
|
+
parse: options.params?.parse ?? null,
|
|
131
|
+
segments: parseSegments(options.path),
|
|
132
|
+
tabs: options.tabs ?? false,
|
|
133
|
+
_params: () => {},
|
|
134
|
+
}
|
|
135
|
+
place(route, options.children ?? [])
|
|
136
|
+
return route
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Puts `children` under `parent`, after any it already has. A route has one
|
|
141
|
+
* place in one tree: placing it again throws. The tabs rules are checked
|
|
142
|
+
* here too, over the whole subtree placed, since a value tree is built
|
|
143
|
+
* bottom-up and an ancestor only appears with the placement above.
|
|
144
|
+
*/
|
|
145
|
+
export function place(parent: AnyRoute, children: AnyRoute[]): void {
|
|
146
|
+
for (let child of children) {
|
|
147
|
+
if (child.parent) {
|
|
148
|
+
throw new Error(`Route "${formatPattern(child)}" is already placed; a route has one place in the tree`)
|
|
149
|
+
}
|
|
150
|
+
;(child as { parent: AnyRoute | null }).parent = parent
|
|
151
|
+
parent.children.push(child)
|
|
152
|
+
checkTabs(child)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The tabs rules for `route` and everything below it (see RouteOptions.tabs).
|
|
157
|
+
function checkTabs(route: AnyRoute): void {
|
|
158
|
+
if (route.parent?.tabs) {
|
|
159
|
+
if (route.segments.some((s) => s.kind !== "literal")) {
|
|
160
|
+
throw new Error(`Tab "${formatPattern(route)}": a tab's path is literal`)
|
|
161
|
+
}
|
|
162
|
+
let own = formatPattern(route)
|
|
163
|
+
for (let sibling of route.parent.children) {
|
|
164
|
+
if (sibling !== route && formatPattern(sibling) === own) {
|
|
165
|
+
throw new Error(`Tab "${own}" is declared twice`)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (route.tabs) {
|
|
170
|
+
for (let anc = route.parent; anc; anc = anc.parent) {
|
|
171
|
+
if (anc.tabs) throw new Error(`Route "${formatPattern(route)}": tabs inside a tab are not supported`)
|
|
172
|
+
if (anc.segments.some((s) => s.kind !== "literal")) {
|
|
173
|
+
throw new Error(`Route "${formatPattern(route)}": a tabs route takes no params above it`)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (let child of route.children) checkTabs(child)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The tabs routes in the tree; a router takes one. */
|
|
181
|
+
export function tabsRoutes(root: AnyRoute): AnyRoute[] {
|
|
182
|
+
let out: AnyRoute[] = []
|
|
183
|
+
let walk = (route: AnyRoute) => {
|
|
184
|
+
if (route.tabs) out.push(route)
|
|
185
|
+
for (let child of route.children) walk(child)
|
|
186
|
+
}
|
|
187
|
+
walk(root)
|
|
188
|
+
return out
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** One route of a matched path, with the params it parsed. */
|
|
192
|
+
export interface Match {
|
|
193
|
+
route: AnyRoute
|
|
194
|
+
params: Record<string, unknown>
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function splitPath(path: string): string[] {
|
|
198
|
+
return path
|
|
199
|
+
.split("/")
|
|
200
|
+
.filter((s) => s !== "")
|
|
201
|
+
.map((s) => {
|
|
202
|
+
try {
|
|
203
|
+
return decodeURIComponent(s)
|
|
204
|
+
} catch {
|
|
205
|
+
return s
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Consumes this route's own pattern from the front of `segments`; the raw
|
|
211
|
+
// params it captured and what is left, or null when the pattern does not fit.
|
|
212
|
+
function consume(route: AnyRoute, segments: string[]): { raw: RawParams; rest: string[] } | null {
|
|
213
|
+
let raw: RawParams = {}
|
|
214
|
+
let i = 0
|
|
215
|
+
for (let s of route.segments) {
|
|
216
|
+
if (s.kind === "rest") {
|
|
217
|
+
raw[REST_PARAM] = segments.slice(i).join("/")
|
|
218
|
+
return { raw, rest: [] }
|
|
219
|
+
}
|
|
220
|
+
let piece = segments[i]
|
|
221
|
+
if (piece === undefined) {
|
|
222
|
+
if (s.kind === "param" && s.optional) return { raw, rest: [] }
|
|
223
|
+
return null
|
|
224
|
+
}
|
|
225
|
+
if (s.kind === "literal") {
|
|
226
|
+
if (piece !== s.value) return null
|
|
227
|
+
} else {
|
|
228
|
+
raw[s.name] = piece
|
|
229
|
+
}
|
|
230
|
+
i++
|
|
231
|
+
}
|
|
232
|
+
return { raw, rest: segments.slice(i) }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Matches `route` and, through its children, the whole of `segments`; the
|
|
236
|
+
// chain root-first, or null. A route whose `parse` throws does not match.
|
|
237
|
+
function matchFrom(route: AnyRoute, segments: string[], inherited: Record<string, unknown>): Match[] | null {
|
|
238
|
+
let own = consume(route, segments)
|
|
239
|
+
if (!own) return null
|
|
240
|
+
let params: Record<string, unknown>
|
|
241
|
+
try {
|
|
242
|
+
params = { ...inherited, ...(route.parse ? (route.parse(own.raw) as Record<string, unknown>) : own.raw) }
|
|
243
|
+
} catch {
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
let here: Match = { route, params }
|
|
247
|
+
if (own.rest.length === 0) {
|
|
248
|
+
// An index child (`/`) takes an exact match over the layout alone.
|
|
249
|
+
for (let child of route.children) {
|
|
250
|
+
if (child.segments.length === 0) {
|
|
251
|
+
let below = matchFrom(child, [], params)
|
|
252
|
+
if (below) return [here, ...below]
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return [here]
|
|
256
|
+
}
|
|
257
|
+
for (let child of route.children) {
|
|
258
|
+
let below = matchFrom(child, own.rest, params)
|
|
259
|
+
if (below) return [here, ...below]
|
|
260
|
+
}
|
|
261
|
+
return null
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The routes `path` names, root-first, each with its params, or null when
|
|
266
|
+
* the tree has no route for it (and no catch-all).
|
|
267
|
+
*/
|
|
268
|
+
export function matchPath(root: AnyRoute, path: string): Match[] | null {
|
|
269
|
+
return matchFrom(root, splitPath(path), {})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The path of `route` with `params` filled in, root-first; the reverse of
|
|
274
|
+
* matchPath. An optional param that is missing is left out; the rest param
|
|
275
|
+
* is appended as given.
|
|
276
|
+
*/
|
|
277
|
+
export function formatPath(route: AnyRoute, params: Record<string, unknown> = {}): string {
|
|
278
|
+
let chain: AnyRoute[] = []
|
|
279
|
+
for (let r: AnyRoute | null = route; r; r = r.parent) chain.unshift(r)
|
|
280
|
+
let out: string[] = []
|
|
281
|
+
for (let r of chain) {
|
|
282
|
+
for (let s of r.segments) {
|
|
283
|
+
if (s.kind === "literal") {
|
|
284
|
+
out.push(s.value)
|
|
285
|
+
} else if (s.kind === "rest") {
|
|
286
|
+
let rest = params[REST_PARAM]
|
|
287
|
+
if (rest !== undefined && rest !== null && rest !== "") out.push(String(rest))
|
|
288
|
+
} else {
|
|
289
|
+
let value = params[s.name]
|
|
290
|
+
if (value === undefined || value === null) {
|
|
291
|
+
if (s.optional) continue
|
|
292
|
+
throw new Error(`Route "${formatPattern(route)}": missing param "${s.name}"`)
|
|
293
|
+
}
|
|
294
|
+
out.push(encodeURIComponent(String(value)))
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return "/" + out.join("/")
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The full pattern of `route`, root-first, for messages. */
|
|
302
|
+
export function formatPattern(route: AnyRoute): string {
|
|
303
|
+
let parts: string[] = []
|
|
304
|
+
for (let r: AnyRoute | null = route; r; r = r.parent) {
|
|
305
|
+
let own = r.path.replace(/^\/+|\/+$/g, "")
|
|
306
|
+
if (own) parts.unshift(own)
|
|
307
|
+
}
|
|
308
|
+
return "/" + parts.join("/")
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The path a link names. A custom-scheme link (`myapp://settings/theme`,
|
|
313
|
+
* `com.example.app:/item/42`) is the whole of what follows the scheme, as
|
|
314
|
+
* apps conventionally read them; an http(s) link drops its host. A query or
|
|
315
|
+
* fragment is dropped: locations are paths. A bare path passes through with
|
|
316
|
+
* a leading slash.
|
|
317
|
+
*/
|
|
318
|
+
export function linkToPath(link: string): string {
|
|
319
|
+
let m = /^([A-Za-z][A-Za-z0-9+.-]+):(.*)$/s.exec(link)
|
|
320
|
+
let rest = link
|
|
321
|
+
if (m) {
|
|
322
|
+
let scheme = m[1]!.toLowerCase()
|
|
323
|
+
rest = m[2]!
|
|
324
|
+
if (scheme === "http" || scheme === "https") {
|
|
325
|
+
rest = rest.replace(/^\/\/[^/]*/, "")
|
|
326
|
+
} else {
|
|
327
|
+
rest = rest.replace(/^\/\//, "")
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
rest = rest.replace(/[?#].*$/s, "")
|
|
331
|
+
return rest.startsWith("/") ? rest : "/" + rest
|
|
332
|
+
}
|