this repo has no description
1import {
2 routes,
3 type Route,
4 type RouteParams,
5 type RoutesWithParams,
6 buildUrl,
7 parseRouteParams,
8 isValidRoute,
9} from "./types/routes";
10
11const APP_BASE = "/app";
12
13type Brand<T, B extends string> = T & { readonly __brand: B };
14type AppPath = Brand<string, "AppPath">;
15
16function asAppPath(path: string): AppPath {
17 const normalized = path.startsWith("/") ? path : "/" + path;
18 return normalized as AppPath;
19}
20
21function getAppPath(): AppPath {
22 const pathname = globalThis.location.pathname;
23 if (pathname.startsWith(APP_BASE)) {
24 const path = pathname.slice(APP_BASE.length) || "/";
25 return asAppPath(path);
26 }
27 return asAppPath("/");
28}
29
30function getSearchParams(): URLSearchParams {
31 return new URLSearchParams(globalThis.location.search);
32}
33
34interface RouterState {
35 readonly path: AppPath;
36 readonly searchParams: URLSearchParams;
37}
38
39const state = $state<{ current: RouterState }>({
40 current: {
41 path: getAppPath(),
42 searchParams: getSearchParams(),
43 },
44});
45
46function updateState(): void {
47 state.current = {
48 path: getAppPath(),
49 searchParams: getSearchParams(),
50 };
51}
52
53globalThis.addEventListener("popstate", updateState);
54
55export function navigate<R extends Route>(
56 route: R,
57 options?: {
58 params?: R extends RoutesWithParams ? RouteParams[R] : never;
59 replace?: boolean;
60 },
61): void {
62 const url = options?.params ? buildUrl(route, options.params) : route;
63 const fullPath = APP_BASE + (url.startsWith("/") ? url : "/" + url);
64
65 if (options?.replace) {
66 globalThis.history.replaceState(null, "", fullPath);
67 } else {
68 globalThis.history.pushState(null, "", fullPath);
69 }
70
71 updateState();
72}
73
74export function navigateTo(path: string, replace = false): void {
75 const normalizedPath = path.startsWith("/") ? path : "/" + path;
76 const fullPath = APP_BASE + normalizedPath;
77
78 if (replace) {
79 globalThis.history.replaceState(null, "", fullPath);
80 } else {
81 globalThis.history.pushState(null, "", fullPath);
82 }
83
84 updateState();
85}
86
87export function getCurrentPath(): AppPath {
88 return state.current.path;
89}
90
91export function getCurrentSearchParams(): URLSearchParams {
92 return state.current.searchParams;
93}
94
95export function getSearchParam(key: string): string | null {
96 return state.current.searchParams.get(key);
97}
98
99export function getFullUrl(path: string): string {
100 return APP_BASE + (path.startsWith("/") ? path : "/" + path);
101}
102
103export function matchRoute(path: AppPath): Route | null {
104 const pathWithoutQuery = path.split("?")[0];
105 if (isValidRoute(pathWithoutQuery)) {
106 return pathWithoutQuery;
107 }
108 return null;
109}
110
111export function isCurrentRoute(route: Route): boolean {
112 const pathWithoutQuery = state.current.path.split("?")[0];
113 return pathWithoutQuery === route;
114}
115
116export function getRouteParams<R extends RoutesWithParams>(
117 _route: R,
118): RouteParams[R] {
119 return parseRouteParams(_route);
120}
121
122export type RouteMatch =
123 | { readonly matched: true; readonly route: Route; readonly params: URLSearchParams }
124 | { readonly matched: false };
125
126export function match(): RouteMatch {
127 const route = matchRoute(state.current.path);
128 if (route) {
129 return {
130 matched: true,
131 route,
132 params: state.current.searchParams,
133 };
134 }
135 return { matched: false };
136}
137
138export { routes, type Route, type RouteParams, type RoutesWithParams };