@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/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
+ }