waymark 0.1.0 → 0.1.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.
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -2
- package/dist/react/components.d.ts +20 -0
- package/dist/react/components.js +107 -0
- package/dist/react/contexts.d.ts +4 -0
- package/dist/react/contexts.js +3 -0
- package/dist/react/hooks.d.ts +13 -0
- package/dist/react/hooks.js +54 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.js +3 -0
- package/dist/route.d.ts +27 -0
- package/dist/route.js +57 -0
- package/dist/router/browser-history.d.ts +11 -0
- package/dist/router/browser-history.js +48 -0
- package/dist/router/index.d.ts +3 -0
- package/dist/router/index.js +3 -0
- package/dist/router/memory-history.d.ts +18 -0
- package/dist/router/memory-history.js +39 -0
- package/dist/router/router.d.ts +31 -0
- package/dist/router/router.js +64 -0
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.js +5 -0
- package/dist/utils/misc.d.ts +17 -0
- package/dist/utils/misc.js +27 -0
- package/dist/utils/path.d.ts +9 -0
- package/dist/utils/path.js +19 -0
- package/dist/utils/react.d.ts +10 -0
- package/dist/utils/react.js +50 -0
- package/dist/utils/router.d.ts +37 -0
- package/dist/utils/router.js +1 -0
- package/dist/utils/search.d.ts +3 -0
- package/dist/utils/search.js +31 -0
- package/package.json +7 -1
- package/src/index.ts +0 -2
- package/tsconfig.json +0 -19
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ReactNode, type AnchorHTMLAttributes, type CSSProperties, type RefAttributes } from "react";
|
|
2
|
+
import { Router, type RouterOptions } from "../router";
|
|
3
|
+
import { type Patterns, type NavigateOptions } from "../utils";
|
|
4
|
+
export type RouterRootProps = RouterOptions | {
|
|
5
|
+
router: Router;
|
|
6
|
+
};
|
|
7
|
+
export declare function RouterRoot(props: RouterRootProps): ReactNode;
|
|
8
|
+
export declare function Outlet(): ReactNode;
|
|
9
|
+
export type NavigateProps<P extends Patterns> = NavigateOptions<P>;
|
|
10
|
+
export declare function Navigate<P extends Patterns>(props: NavigateProps<P>): null;
|
|
11
|
+
export type LinkProps<P extends Patterns> = NavigateOptions<P> & LinkOptions & AnchorHTMLAttributes<HTMLAnchorElement> & RefAttributes<HTMLAnchorElement> & {
|
|
12
|
+
asChild?: boolean;
|
|
13
|
+
};
|
|
14
|
+
export interface LinkOptions {
|
|
15
|
+
preload?: "intent" | "render" | "viewport" | false;
|
|
16
|
+
active?: (currentPath: string, targetPath: string) => boolean;
|
|
17
|
+
activeStyle?: CSSProperties;
|
|
18
|
+
activeClassName?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function Link<P extends Patterns>(props: LinkProps<P>): ReactNode;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useMemo, useState, useLayoutEffect, useRef, useEffect, createElement, isValidElement, cloneElement } from "react";
|
|
2
|
+
import { routerContext, outletContext } from "./contexts";
|
|
3
|
+
import { useRouter, useOutlet, _useSubscribe } from "./hooks";
|
|
4
|
+
import { Router } from "../router";
|
|
5
|
+
import { getHref, mergeRefs, defaultLinkActive } from "../utils";
|
|
6
|
+
export function RouterRoot(props) {
|
|
7
|
+
const [router] = useState(() => "router" in props ? props.router : new Router(props));
|
|
8
|
+
const path = _useSubscribe(router, router.history.getPath);
|
|
9
|
+
const route = useMemo(() => router.matchPath(path), [router, path]);
|
|
10
|
+
if (!route) {
|
|
11
|
+
console.error("[Waymark] No route found for path:", path);
|
|
12
|
+
}
|
|
13
|
+
return useMemo(() => {
|
|
14
|
+
return createElement(routerContext.Provider, { value: router }, route?._.components.reduceRight((acc, comp) => createElement(outletContext.Provider, { value: acc }, createElement(comp)), null));
|
|
15
|
+
}, [router, route]);
|
|
16
|
+
}
|
|
17
|
+
// Outlet
|
|
18
|
+
export function Outlet() {
|
|
19
|
+
return useOutlet();
|
|
20
|
+
}
|
|
21
|
+
export function Navigate(props) {
|
|
22
|
+
const router = useRouter();
|
|
23
|
+
useLayoutEffect(() => router.navigate(props), []);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
export function Link(props) {
|
|
27
|
+
const ref = useRef(null);
|
|
28
|
+
const router = useRouter();
|
|
29
|
+
const { path, search } = router.composePath(props);
|
|
30
|
+
const currentPath = _useSubscribe(router, router.history.getPath);
|
|
31
|
+
const route = useMemo(() => router.matchPath(path), [router, path]);
|
|
32
|
+
const { to, replace, state, params, search: search_, preload, active, activeStyle, activeClassName, asChild, style, className, children, ...rest } = {
|
|
33
|
+
active: defaultLinkActive,
|
|
34
|
+
...router.defaultLinkOptions,
|
|
35
|
+
...props
|
|
36
|
+
};
|
|
37
|
+
const activeProps = useMemo(() => {
|
|
38
|
+
const isActive = active(currentPath, path);
|
|
39
|
+
return {
|
|
40
|
+
["data-active"]: isActive,
|
|
41
|
+
style: { ...style, ...(isActive && activeStyle) },
|
|
42
|
+
className: [className, isActive && activeClassName].filter(Boolean).join(" ") ||
|
|
43
|
+
undefined
|
|
44
|
+
};
|
|
45
|
+
}, [
|
|
46
|
+
active,
|
|
47
|
+
path,
|
|
48
|
+
currentPath,
|
|
49
|
+
style,
|
|
50
|
+
className,
|
|
51
|
+
activeStyle,
|
|
52
|
+
activeClassName
|
|
53
|
+
]);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (preload === "render") {
|
|
56
|
+
route?.preload();
|
|
57
|
+
}
|
|
58
|
+
else if (preload === "viewport" && ref.current) {
|
|
59
|
+
const observer = new IntersectionObserver(entries => {
|
|
60
|
+
entries.forEach(entry => {
|
|
61
|
+
if (entry.isIntersecting) {
|
|
62
|
+
route?.preload();
|
|
63
|
+
observer.disconnect();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
observer.observe(ref.current);
|
|
68
|
+
return () => observer.disconnect();
|
|
69
|
+
}
|
|
70
|
+
}, [preload, route]);
|
|
71
|
+
const onClick = (event) => {
|
|
72
|
+
rest.onClick?.(event);
|
|
73
|
+
if (event.ctrlKey ||
|
|
74
|
+
event.metaKey ||
|
|
75
|
+
event.shiftKey ||
|
|
76
|
+
event.altKey ||
|
|
77
|
+
event.button !== 0 ||
|
|
78
|
+
event.defaultPrevented)
|
|
79
|
+
return;
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
router.history.push({ path, search, replace, state });
|
|
82
|
+
};
|
|
83
|
+
const onFocus = (event) => {
|
|
84
|
+
rest.onFocus?.(event);
|
|
85
|
+
if (preload === "intent" && !event.defaultPrevented) {
|
|
86
|
+
route?.preload();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const onPointerEnter = (event) => {
|
|
90
|
+
rest.onPointerEnter?.(event);
|
|
91
|
+
if (preload === "intent" && !event.defaultPrevented) {
|
|
92
|
+
route?.preload();
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const anchorProps = {
|
|
96
|
+
...rest,
|
|
97
|
+
...activeProps,
|
|
98
|
+
ref: mergeRefs(rest.ref, ref),
|
|
99
|
+
href: getHref(path, search),
|
|
100
|
+
onClick,
|
|
101
|
+
onFocus,
|
|
102
|
+
onPointerEnter
|
|
103
|
+
};
|
|
104
|
+
return asChild && isValidElement(children)
|
|
105
|
+
? cloneElement(children, anchorProps)
|
|
106
|
+
: createElement("a", { ...anchorProps, children });
|
|
107
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Router } from "../router";
|
|
2
|
+
import type { Routes, RouteSearch, Updater } from "../utils";
|
|
3
|
+
export declare function useRouter(): Router;
|
|
4
|
+
export declare function useOutlet(): import("react").ReactNode;
|
|
5
|
+
export declare function useLocation(): {
|
|
6
|
+
path: string;
|
|
7
|
+
search: URLSearchParams;
|
|
8
|
+
state: any;
|
|
9
|
+
};
|
|
10
|
+
export declare function useNavigate(): <P extends import("..").Patterns>(options: number | import("..").NavigateOptions<P>) => void;
|
|
11
|
+
export declare function useParams<R extends Routes>(route: R): import("..").RouteParams<R>;
|
|
12
|
+
export declare function useSearch<R extends Routes>(route: R): readonly [RouteSearch<R>, (update: Updater<RouteSearch<R>>, replace?: boolean) => void];
|
|
13
|
+
export declare function _useSubscribe<T>(router: Router, getSnapshot: () => T): T;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useCallback, useContext, useMemo, useSyncExternalStore } from "react";
|
|
2
|
+
import { outletContext, routerContext } from "./contexts";
|
|
3
|
+
// useRouter
|
|
4
|
+
export function useRouter() {
|
|
5
|
+
const router = useContext(routerContext);
|
|
6
|
+
if (!router) {
|
|
7
|
+
throw new Error("[Waymark] useRouter must be used within a router context");
|
|
8
|
+
}
|
|
9
|
+
return router;
|
|
10
|
+
}
|
|
11
|
+
// useOutlet
|
|
12
|
+
export function useOutlet() {
|
|
13
|
+
return useContext(outletContext);
|
|
14
|
+
}
|
|
15
|
+
// useLocation
|
|
16
|
+
export function useLocation() {
|
|
17
|
+
const router = useRouter();
|
|
18
|
+
const path = _useSubscribe(router, router.history.getPath);
|
|
19
|
+
const search = _useSubscribe(router, router.history.getSearch);
|
|
20
|
+
const state = _useSubscribe(router, router.history.getState);
|
|
21
|
+
return useMemo(() => ({ path, search: new URLSearchParams(search), state }), [path, search, state]);
|
|
22
|
+
}
|
|
23
|
+
// useNavigate
|
|
24
|
+
export function useNavigate() {
|
|
25
|
+
const router = useRouter();
|
|
26
|
+
return useMemo(() => router.navigate.bind(router), [router]);
|
|
27
|
+
}
|
|
28
|
+
// useParams
|
|
29
|
+
export function useParams(route) {
|
|
30
|
+
const router = useRouter();
|
|
31
|
+
const path = _useSubscribe(router, router.history.getPath);
|
|
32
|
+
return useMemo(() => router.decomposePath(route, path, router.history.getSearch()).params, [router, route, path]);
|
|
33
|
+
}
|
|
34
|
+
// useSearch
|
|
35
|
+
export function useSearch(route) {
|
|
36
|
+
const router = useRouter();
|
|
37
|
+
const search = _useSubscribe(router, router.history.getSearch);
|
|
38
|
+
const parsed = useMemo(() => router.decomposePath(route, router.history.getPath(), search).search, [router, route, search]);
|
|
39
|
+
const setSearch = useCallback((update, replace) => {
|
|
40
|
+
const { params, search } = router.decomposePath(route, router.history.getPath(), router.history.getSearch());
|
|
41
|
+
update = typeof update === "function" ? update(search) : update;
|
|
42
|
+
router.navigate({
|
|
43
|
+
to: route._.pattern,
|
|
44
|
+
params,
|
|
45
|
+
search: { ...search, ...update },
|
|
46
|
+
replace
|
|
47
|
+
});
|
|
48
|
+
}, [router, route]);
|
|
49
|
+
return [parsed, setSearch];
|
|
50
|
+
}
|
|
51
|
+
// _useSubscribe
|
|
52
|
+
export function _useSubscribe(router, getSnapshot) {
|
|
53
|
+
return useSyncExternalStore(router.history.subscribe, getSnapshot, getSnapshot);
|
|
54
|
+
}
|
package/dist/route.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ComponentType } from "react";
|
|
2
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
3
|
+
import { type NormalizePath, type ComponentLoader } from "./utils";
|
|
4
|
+
export declare class Route<P extends string, Ps extends {}, S extends {}> {
|
|
5
|
+
_: {
|
|
6
|
+
pattern: P;
|
|
7
|
+
_params?: Ps;
|
|
8
|
+
_search?: S;
|
|
9
|
+
keys: string[];
|
|
10
|
+
regex: RegExp;
|
|
11
|
+
looseRegex: RegExp;
|
|
12
|
+
mapSearch: (search: Record<string, unknown>) => S;
|
|
13
|
+
components: ComponentType[];
|
|
14
|
+
preloaded: boolean;
|
|
15
|
+
preloaders: (() => Promise<any>)[];
|
|
16
|
+
};
|
|
17
|
+
constructor(pattern: P, mapSearch: (search: Record<string, unknown>) => S, components: ComponentType[], preloaders: (() => Promise<any>)[]);
|
|
18
|
+
route<P2 extends string>(subPattern: P2): Route<NormalizePath<`${P}/${P2}`>, import("regexparam").RouteParams<NormalizePath<`${P}/${P2}`>> extends infer T ? { [KeyType in keyof T]: T[KeyType]; } : never, S>;
|
|
19
|
+
search<S2 extends {}>(mapper: ((search: S & Record<string, unknown>) => S2) | StandardSchemaV1<S & Record<string, unknown>, S2>): Route<P, Ps, (import("type-fest").PickIndexSignature<S> extends infer T_1 ? { [Key in keyof T_1 as Key extends keyof import("type-fest").PickIndexSignature<{ [K in keyof S2 as undefined extends S2[K] ? never : K]: S2[K]; } & { [K_1 in keyof S2 as undefined extends S2[K_1] ? K_1 : never]?: S2[K_1] | undefined; } extends infer T_2 ? { [KeyType_1 in keyof T_2]: T_2[KeyType_1]; } : never> ? never : Key]: T_1[Key]; } : never) & import("type-fest").PickIndexSignature<{ [K in keyof S2 as undefined extends S2[K] ? never : K]: S2[K]; } & { [K_1 in keyof S2 as undefined extends S2[K_1] ? K_1 : never]?: S2[K_1] | undefined; } extends infer T_2 ? { [KeyType_1 in keyof T_2]: T_2[KeyType_1]; } : never> & (import("type-fest").OmitIndexSignature<S> extends infer T_3 ? { [Key_1 in keyof T_3 as Key_1 extends keyof import("type-fest").OmitIndexSignature<{ [K in keyof S2 as undefined extends S2[K] ? never : K]: S2[K]; } & { [K_1 in keyof S2 as undefined extends S2[K_1] ? K_1 : never]?: S2[K_1] | undefined; } extends infer T_4 ? { [KeyType_1 in keyof T_4]: T_4[KeyType_1]; } : never> ? never : Key_1]: T_3[Key_1]; } : never) & import("type-fest").OmitIndexSignature<{ [K in keyof S2 as undefined extends S2[K] ? never : K]: S2[K]; } & { [K_1 in keyof S2 as undefined extends S2[K_1] ? K_1 : never]?: S2[K_1] | undefined; } extends infer T_4 ? { [KeyType_1 in keyof T_4]: T_4[KeyType_1]; } : never> extends infer T ? { [KeyType in keyof T]: T[KeyType]; } : never>;
|
|
20
|
+
component(component: ComponentType): Route<P, Ps, S>;
|
|
21
|
+
lazy(loader: ComponentLoader): Route<P, Ps, S>;
|
|
22
|
+
error(component: ComponentType<{
|
|
23
|
+
error: unknown;
|
|
24
|
+
}>): Route<P, Ps, S>;
|
|
25
|
+
preload(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export declare function route<P extends string>(pattern: P): Route<NormalizePath<P>, import("regexparam").RouteParams<NormalizePath<P>> extends infer T ? { [KeyType in keyof T]: T[KeyType]; } : never, {}>;
|
package/dist/route.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { lazy } from "react";
|
|
2
|
+
import { parse } from "regexparam";
|
|
3
|
+
import { normalizePath, validator, errorBoundary } from "./utils";
|
|
4
|
+
export class Route {
|
|
5
|
+
_;
|
|
6
|
+
constructor(pattern, mapSearch, components, preloaders) {
|
|
7
|
+
const { keys, pattern: regex } = parse(pattern);
|
|
8
|
+
const looseRegex = parse(pattern, true).pattern;
|
|
9
|
+
this._ = {
|
|
10
|
+
pattern,
|
|
11
|
+
keys,
|
|
12
|
+
regex,
|
|
13
|
+
looseRegex,
|
|
14
|
+
mapSearch,
|
|
15
|
+
components,
|
|
16
|
+
preloaded: false,
|
|
17
|
+
preloaders
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
route(subPattern) {
|
|
21
|
+
const { pattern, mapSearch, components, preloaders } = this._;
|
|
22
|
+
return new Route(normalizePath(`${pattern}/${subPattern}`), mapSearch, components, preloaders);
|
|
23
|
+
}
|
|
24
|
+
search(mapper) {
|
|
25
|
+
const { pattern, mapSearch, components, preloaders } = this._;
|
|
26
|
+
mapper = validator(mapper);
|
|
27
|
+
return new Route(pattern, search => {
|
|
28
|
+
const mapped = mapSearch(search);
|
|
29
|
+
return { ...mapped, ...mapper(mapped) };
|
|
30
|
+
}, components, preloaders);
|
|
31
|
+
}
|
|
32
|
+
component(component) {
|
|
33
|
+
const { pattern, mapSearch, components, preloaders } = this._;
|
|
34
|
+
return new Route(pattern, mapSearch, [...components, component], preloaders);
|
|
35
|
+
}
|
|
36
|
+
lazy(loader) {
|
|
37
|
+
const { pattern, mapSearch, components, preloaders } = this._;
|
|
38
|
+
const lazyLoader = async () => {
|
|
39
|
+
const result = await loader();
|
|
40
|
+
return "default" in result ? result : { default: result };
|
|
41
|
+
};
|
|
42
|
+
return new Route(pattern, mapSearch, [...components, lazy(lazyLoader)], [...preloaders, loader]);
|
|
43
|
+
}
|
|
44
|
+
error(component) {
|
|
45
|
+
return this.component(errorBoundary(component));
|
|
46
|
+
}
|
|
47
|
+
async preload() {
|
|
48
|
+
const { preloaded, preloaders } = this._;
|
|
49
|
+
if (preloaded)
|
|
50
|
+
return;
|
|
51
|
+
this._.preloaded = true;
|
|
52
|
+
await Promise.all(preloaders.map(loader => loader()));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function route(pattern) {
|
|
56
|
+
return new Route(normalizePath(pattern), search => search, [], []);
|
|
57
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type HistoryLike, type HistoryPushOptions } from "../utils";
|
|
2
|
+
export declare class BrowserHistory implements HistoryLike {
|
|
3
|
+
private static patchKey;
|
|
4
|
+
constructor();
|
|
5
|
+
getPath: () => string;
|
|
6
|
+
getSearch: () => string;
|
|
7
|
+
getState: () => any;
|
|
8
|
+
go: (delta: number) => void;
|
|
9
|
+
push: (options: HistoryPushOptions) => void;
|
|
10
|
+
subscribe: (listener: () => void) => () => void;
|
|
11
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getHref, normalizeSearch } from "../utils";
|
|
2
|
+
export class BrowserHistory {
|
|
3
|
+
static patchKey = Symbol.for("waymark_history_patch_v01");
|
|
4
|
+
constructor() {
|
|
5
|
+
if (typeof history !== "undefined" &&
|
|
6
|
+
!Object.hasOwn(window, BrowserHistory.patchKey)) {
|
|
7
|
+
for (const type of [pushStateEvent, replaceStateEvent]) {
|
|
8
|
+
const original = history[type];
|
|
9
|
+
history[type] = function (...args) {
|
|
10
|
+
const result = original.apply(this, args);
|
|
11
|
+
const event = new Event(type);
|
|
12
|
+
event.arguments = args;
|
|
13
|
+
dispatchEvent(event);
|
|
14
|
+
return result;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
Object.assign(window, {
|
|
18
|
+
[BrowserHistory.patchKey]: true
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
getPath = () => location.pathname;
|
|
23
|
+
getSearch = () => normalizeSearch(location.search);
|
|
24
|
+
getState = () => history.state;
|
|
25
|
+
go = (delta) => history.go(delta);
|
|
26
|
+
push = (options) => {
|
|
27
|
+
const { path, search, replace, state } = options;
|
|
28
|
+
const href = getHref(path, search);
|
|
29
|
+
history[replace ? replaceStateEvent : pushStateEvent](state, "", href);
|
|
30
|
+
};
|
|
31
|
+
subscribe = (listener) => {
|
|
32
|
+
events.forEach(event => window.addEventListener(event, listener));
|
|
33
|
+
return () => {
|
|
34
|
+
events.forEach(event => window.removeEventListener(event, listener));
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// Events
|
|
39
|
+
const popStateEvent = "popstate";
|
|
40
|
+
const pushStateEvent = "pushState";
|
|
41
|
+
const replaceStateEvent = "replaceState";
|
|
42
|
+
const hashChangeEvent = "hashchange";
|
|
43
|
+
const events = [
|
|
44
|
+
popStateEvent,
|
|
45
|
+
pushStateEvent,
|
|
46
|
+
replaceStateEvent,
|
|
47
|
+
hashChangeEvent
|
|
48
|
+
];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type HistoryLike, type HistoryPushOptions } from "../utils";
|
|
2
|
+
export interface MemoryLocation {
|
|
3
|
+
path: string;
|
|
4
|
+
search: string;
|
|
5
|
+
state: any;
|
|
6
|
+
}
|
|
7
|
+
export declare class MemoryHistory implements HistoryLike {
|
|
8
|
+
private stack;
|
|
9
|
+
private index;
|
|
10
|
+
private listeners;
|
|
11
|
+
constructor(initial?: Partial<MemoryLocation>);
|
|
12
|
+
getPath: () => string;
|
|
13
|
+
getSearch: () => string;
|
|
14
|
+
getState: () => any;
|
|
15
|
+
go: (delta: number) => void;
|
|
16
|
+
push: (options: HistoryPushOptions) => void;
|
|
17
|
+
subscribe: (listener: () => void) => () => void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { clamp, normalizeSearch } from "../utils";
|
|
2
|
+
export class MemoryHistory {
|
|
3
|
+
stack = [];
|
|
4
|
+
index = 0;
|
|
5
|
+
listeners = new Set();
|
|
6
|
+
constructor(initial = {}) {
|
|
7
|
+
const { path = "/", search = "", state } = initial;
|
|
8
|
+
this.stack.push({ path, search: normalizeSearch(search), state });
|
|
9
|
+
}
|
|
10
|
+
getPath = () => this.stack[this.index].path;
|
|
11
|
+
getSearch = () => this.stack[this.index].search;
|
|
12
|
+
getState = () => this.stack[this.index].state;
|
|
13
|
+
go = (delta) => {
|
|
14
|
+
this.index = clamp(this.index + delta, 0, this.stack.length - 1);
|
|
15
|
+
this.listeners.forEach(listener => listener());
|
|
16
|
+
};
|
|
17
|
+
push = (options) => {
|
|
18
|
+
const { path, search = "", replace, state } = options;
|
|
19
|
+
const location = {
|
|
20
|
+
path,
|
|
21
|
+
search: normalizeSearch(search),
|
|
22
|
+
state
|
|
23
|
+
};
|
|
24
|
+
this.stack = this.stack.slice(0, this.index + 1);
|
|
25
|
+
if (replace) {
|
|
26
|
+
this.stack[this.index] = location;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
this.index = this.stack.push(location) - 1;
|
|
30
|
+
}
|
|
31
|
+
this.listeners.forEach(listener => listener());
|
|
32
|
+
};
|
|
33
|
+
subscribe = (listener) => {
|
|
34
|
+
this.listeners.add(listener);
|
|
35
|
+
return () => {
|
|
36
|
+
this.listeners.delete(listener);
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { LinkOptions } from "../react";
|
|
2
|
+
import { type Routes, type PatternRoute, type RouteList, type Patterns, type NavigateOptions, type HistoryLike, type RouteParams, type RouteSearch } from "../utils";
|
|
3
|
+
export interface RouterOptions {
|
|
4
|
+
history?: HistoryLike;
|
|
5
|
+
basePath?: string;
|
|
6
|
+
routes: RouteList;
|
|
7
|
+
defaultLinkOptions?: LinkOptions;
|
|
8
|
+
}
|
|
9
|
+
export declare class Router {
|
|
10
|
+
history: HistoryLike;
|
|
11
|
+
basePath: string;
|
|
12
|
+
routes: RouteList;
|
|
13
|
+
defaultLinkOptions?: LinkOptions;
|
|
14
|
+
_: {
|
|
15
|
+
routeMap: Map<string, Routes>;
|
|
16
|
+
};
|
|
17
|
+
constructor(options: RouterOptions);
|
|
18
|
+
getPath(cpath: string): string;
|
|
19
|
+
getCanonicalPath(path: string): string;
|
|
20
|
+
matchPath(path: string): Routes | undefined;
|
|
21
|
+
getRoute<P extends Patterns>(pattern: P): PatternRoute<P>;
|
|
22
|
+
composePath<P extends Patterns>(options: NavigateOptions<P>): {
|
|
23
|
+
path: string;
|
|
24
|
+
search: string;
|
|
25
|
+
};
|
|
26
|
+
decomposePath<R extends Routes>(route: R, path: string, search: string): {
|
|
27
|
+
params: RouteParams<R>;
|
|
28
|
+
search: RouteSearch<R>;
|
|
29
|
+
};
|
|
30
|
+
navigate<P extends Patterns>(options: NavigateOptions<P> | number): void;
|
|
31
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { inject } from "regexparam";
|
|
2
|
+
import { BrowserHistory } from "./browser-history";
|
|
3
|
+
import { normalizePath, extract, stringifySearch, parseSearch } from "../utils";
|
|
4
|
+
export class Router {
|
|
5
|
+
history;
|
|
6
|
+
basePath;
|
|
7
|
+
routes;
|
|
8
|
+
defaultLinkOptions;
|
|
9
|
+
_;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.history = options.history ?? new BrowserHistory();
|
|
12
|
+
this.basePath = normalizePath(options.basePath ?? "/");
|
|
13
|
+
this.routes = options.routes;
|
|
14
|
+
this.defaultLinkOptions = options.defaultLinkOptions;
|
|
15
|
+
this._ = {
|
|
16
|
+
routeMap: new Map(options.routes.map(route => [route._.pattern, route]))
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
getPath(cpath) {
|
|
20
|
+
return normalizePath(`${this.basePath}/${cpath}`);
|
|
21
|
+
}
|
|
22
|
+
getCanonicalPath(path) {
|
|
23
|
+
if (path === this.basePath || path.startsWith(`${this.basePath}/`)) {
|
|
24
|
+
path = path.slice(this.basePath.length) || "/";
|
|
25
|
+
}
|
|
26
|
+
return path;
|
|
27
|
+
}
|
|
28
|
+
matchPath(path) {
|
|
29
|
+
const cpath = this.getCanonicalPath(path);
|
|
30
|
+
return this.routes.find(route => route._.regex.test(cpath));
|
|
31
|
+
}
|
|
32
|
+
getRoute(pattern) {
|
|
33
|
+
const route = this._.routeMap.get(pattern);
|
|
34
|
+
if (!route) {
|
|
35
|
+
throw new Error(`[Waymark] Route not found for pattern: ${pattern}`);
|
|
36
|
+
}
|
|
37
|
+
return route;
|
|
38
|
+
}
|
|
39
|
+
composePath(options) {
|
|
40
|
+
const { to, params, search } = options;
|
|
41
|
+
return {
|
|
42
|
+
path: this.getPath(params ? inject(to, params) : to),
|
|
43
|
+
search: search ? stringifySearch(search) : ""
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
decomposePath(route, path, search) {
|
|
47
|
+
const { keys, looseRegex, mapSearch } = route._;
|
|
48
|
+
const cpath = this.getCanonicalPath(path);
|
|
49
|
+
return {
|
|
50
|
+
params: extract(cpath, looseRegex, keys),
|
|
51
|
+
search: mapSearch(parseSearch(search))
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
navigate(options) {
|
|
55
|
+
if (typeof options === "number") {
|
|
56
|
+
this.history.go(options);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const { path, search } = this.composePath(options);
|
|
60
|
+
const { replace, state } = options;
|
|
61
|
+
this.history.push({ path, search, replace, state });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Simplify, EmptyObject } from "type-fest";
|
|
2
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
3
|
+
export type MaybeKey<K extends string, T> = T extends EmptyObject ? {
|
|
4
|
+
[P in K]?: undefined;
|
|
5
|
+
} : {} extends T ? {
|
|
6
|
+
[P in K]?: T;
|
|
7
|
+
} : {
|
|
8
|
+
[P in K]: T;
|
|
9
|
+
};
|
|
10
|
+
export type OptionalOnUndefined<T extends object> = Simplify<{
|
|
11
|
+
[K in keyof T as undefined extends T[K] ? never : K]: T[K];
|
|
12
|
+
} & {
|
|
13
|
+
[K in keyof T as undefined extends T[K] ? K : never]?: T[K];
|
|
14
|
+
}>;
|
|
15
|
+
export declare function getHref(path: string, search?: string): string;
|
|
16
|
+
export declare function clamp(value: number, min: number, max: number): number;
|
|
17
|
+
export declare function validator<Input, Output>(validate: ((input: Input) => Output) | StandardSchemaV1<Input, Output>): (input: Input) => Output;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function getHref(path, search) {
|
|
2
|
+
return `${path}${search ? `?${search}` : ""}`;
|
|
3
|
+
}
|
|
4
|
+
export function clamp(value, min, max) {
|
|
5
|
+
return Math.max(min, Math.min(value, max));
|
|
6
|
+
}
|
|
7
|
+
export function validator(validate) {
|
|
8
|
+
if (typeof validate === "function") {
|
|
9
|
+
return validate;
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
return (input) => {
|
|
13
|
+
const result = validate["~standard"].validate(input);
|
|
14
|
+
if (result instanceof Promise) {
|
|
15
|
+
throw new Error("[Waymark] Validation must be synchronous");
|
|
16
|
+
}
|
|
17
|
+
else if (result.issues) {
|
|
18
|
+
throw new Error("[Waymark] Validation failed", {
|
|
19
|
+
cause: result.issues
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
return result.value;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RouteParams } from "regexparam";
|
|
2
|
+
import type { Simplify } from "type-fest";
|
|
3
|
+
export type ParsePattern<P extends string> = Simplify<RouteParams<P>>;
|
|
4
|
+
export type NormalizePath<P extends string> = RemoveTrailingSlash<DedupSlashes<`/${P}`>>;
|
|
5
|
+
type DedupSlashes<P extends string> = P extends `${infer Prefix}//${infer Rest}` ? `${Prefix}${DedupSlashes<`/${Rest}`>}` : P;
|
|
6
|
+
type RemoveTrailingSlash<P extends string> = P extends `${infer Prefix}/` ? Prefix extends "" ? "/" : Prefix : P;
|
|
7
|
+
export declare function normalizePath<P extends string>(path: P): NormalizePath<P>;
|
|
8
|
+
export declare function extract(cpath: string, looseRegex: RegExp, keys: string[]): Record<string, string>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function normalizePath(path) {
|
|
2
|
+
const normalized = `/${path}`
|
|
3
|
+
.replaceAll(/\/+/g, "/")
|
|
4
|
+
.replace(/(.+)\/$/, "$1");
|
|
5
|
+
return normalized;
|
|
6
|
+
}
|
|
7
|
+
export function extract(cpath, looseRegex, keys) {
|
|
8
|
+
const out = {};
|
|
9
|
+
const matches = looseRegex.exec(cpath);
|
|
10
|
+
if (matches) {
|
|
11
|
+
keys.forEach((key, i) => {
|
|
12
|
+
const match = matches[i + 1];
|
|
13
|
+
if (match) {
|
|
14
|
+
out[key] = match;
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Ref, type ComponentType } from "react";
|
|
2
|
+
export type Updater<T extends object> = Partial<T> | ((prev: T) => Partial<T>);
|
|
3
|
+
export type ComponentLoader = () => Promise<ComponentType | {
|
|
4
|
+
default: ComponentType;
|
|
5
|
+
}>;
|
|
6
|
+
export declare function defaultLinkActive(currentPath: string, targetPath: string): boolean;
|
|
7
|
+
export declare function mergeRefs<T>(...inputRefs: (Ref<T> | undefined)[]): Ref<T>;
|
|
8
|
+
export declare function errorBoundary(component: ComponentType<{
|
|
9
|
+
error: unknown;
|
|
10
|
+
}>): ComponentType;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createElement, Component } from "react";
|
|
2
|
+
import { useOutlet } from "../react";
|
|
3
|
+
export function defaultLinkActive(currentPath, targetPath) {
|
|
4
|
+
return currentPath.startsWith(targetPath);
|
|
5
|
+
}
|
|
6
|
+
export function mergeRefs(...inputRefs) {
|
|
7
|
+
const filtered = inputRefs.filter(r => !!r);
|
|
8
|
+
if (filtered.length <= 1) {
|
|
9
|
+
return filtered[0] ?? null;
|
|
10
|
+
}
|
|
11
|
+
return value => {
|
|
12
|
+
const cleanups = [];
|
|
13
|
+
for (const ref of filtered) {
|
|
14
|
+
const cleanup = assignRef(ref, value);
|
|
15
|
+
cleanups.push(cleanup ?? (() => assignRef(ref, null)));
|
|
16
|
+
}
|
|
17
|
+
return () => cleanups.forEach(cleanup => cleanup());
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function assignRef(ref, value) {
|
|
21
|
+
if (typeof ref === "function") {
|
|
22
|
+
return ref(value);
|
|
23
|
+
}
|
|
24
|
+
else if (ref) {
|
|
25
|
+
ref.current = value;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function errorBoundary(component) {
|
|
29
|
+
class Catch extends Component {
|
|
30
|
+
constructor(props) {
|
|
31
|
+
super(props);
|
|
32
|
+
this.state = { children: props.children, error: null };
|
|
33
|
+
}
|
|
34
|
+
static getDerivedStateFromError(error) {
|
|
35
|
+
return { error: [error] };
|
|
36
|
+
}
|
|
37
|
+
static getDerivedStateFromProps(props, state) {
|
|
38
|
+
if (props.children !== state.children) {
|
|
39
|
+
return { children: props.children, error: null };
|
|
40
|
+
}
|
|
41
|
+
return state;
|
|
42
|
+
}
|
|
43
|
+
render() {
|
|
44
|
+
return this.state.error
|
|
45
|
+
? createElement(component, { error: this.state.error[0] })
|
|
46
|
+
: this.props.children;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return () => createElement(Catch, { children: useOutlet() });
|
|
50
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Route } from "../route";
|
|
2
|
+
import type { MaybeKey } from "./misc";
|
|
3
|
+
export interface RegisterRoutes {
|
|
4
|
+
}
|
|
5
|
+
export type RouteList = RegisterRoutes extends {
|
|
6
|
+
routes: infer RouteList extends ReadonlyArray<Route<string, any, any>>;
|
|
7
|
+
} ? RouteList : ReadonlyArray<Route<string, any, any>>;
|
|
8
|
+
export type Routes = RouteList[number];
|
|
9
|
+
export type Patterns = Routes["_"]["pattern"];
|
|
10
|
+
export type RouteParams<R extends Routes> = NonNullable<R["_"]["_params"]>;
|
|
11
|
+
export type RouteSearch<R extends Routes> = NonNullable<R["_"]["_search"]>;
|
|
12
|
+
export type PatternParams<P extends Patterns> = RouteParams<PatternRoute<P>>;
|
|
13
|
+
export type PatternSearch<P extends Patterns> = RouteSearch<PatternRoute<P>>;
|
|
14
|
+
export type PatternRoute<P extends Patterns> = Extract<Routes, {
|
|
15
|
+
_: {
|
|
16
|
+
pattern: P;
|
|
17
|
+
};
|
|
18
|
+
}>;
|
|
19
|
+
export type NavigateOptions<P extends Patterns> = {
|
|
20
|
+
to: P;
|
|
21
|
+
replace?: boolean;
|
|
22
|
+
state?: any;
|
|
23
|
+
} & MaybeKey<"params", PatternParams<P>> & MaybeKey<"search", PatternSearch<P>>;
|
|
24
|
+
export interface HistoryPushOptions {
|
|
25
|
+
path: string;
|
|
26
|
+
search?: string;
|
|
27
|
+
replace?: boolean;
|
|
28
|
+
state?: any;
|
|
29
|
+
}
|
|
30
|
+
export interface HistoryLike {
|
|
31
|
+
getPath: () => string;
|
|
32
|
+
getSearch: () => string;
|
|
33
|
+
getState: () => any;
|
|
34
|
+
go: (delta: number) => void;
|
|
35
|
+
push: (options: HistoryPushOptions) => void;
|
|
36
|
+
subscribe: (listener: () => void) => () => void;
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function normalizeSearch(search) {
|
|
2
|
+
return search.startsWith("?") ? search.slice(1) : search;
|
|
3
|
+
}
|
|
4
|
+
export function stringifySearch(search) {
|
|
5
|
+
return Object.entries(search)
|
|
6
|
+
.filter(([_, value]) => value !== undefined)
|
|
7
|
+
.map(([key, value]) => `${key}=${encodeURIComponent(toValueString(value))}`)
|
|
8
|
+
.join("&");
|
|
9
|
+
}
|
|
10
|
+
export function parseSearch(search) {
|
|
11
|
+
const urlSearch = new URLSearchParams(search);
|
|
12
|
+
return Object.fromEntries([...urlSearch.entries()].map(([key, value]) => {
|
|
13
|
+
value = decodeURIComponent(value);
|
|
14
|
+
return [key, isJSONString(value) ? JSON.parse(value) : value];
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
function toValueString(value) {
|
|
18
|
+
if (typeof value === "string" && !isJSONString(value)) {
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
return JSON.stringify(value);
|
|
22
|
+
}
|
|
23
|
+
function isJSONString(value) {
|
|
24
|
+
try {
|
|
25
|
+
JSON.parse(value);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "waymark",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Simplest strongly typed router for React",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -33,10 +33,16 @@
|
|
|
33
33
|
"prepublishOnly": "bun run build"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
+
"@standard-schema/spec": "^1.1.0",
|
|
36
37
|
"@types/bun": "latest",
|
|
38
|
+
"@types/react": "^19.2.8",
|
|
39
|
+
"type-fest": "^5.4.1",
|
|
37
40
|
"typescript": "^5.9.3"
|
|
38
41
|
},
|
|
39
42
|
"peerDependencies": {
|
|
40
43
|
"react": ">=18"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"regexparam": "^3.0.0"
|
|
41
47
|
}
|
|
42
48
|
}
|
package/src/index.ts
DELETED
package/tsconfig.json
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"lib": ["ESNext", "DOM"],
|
|
5
|
-
"module": "ESNext",
|
|
6
|
-
"moduleResolution": "Bundler",
|
|
7
|
-
"declaration": true,
|
|
8
|
-
"strict": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"skipLibCheck": true,
|
|
11
|
-
"forceConsistentCasingInFileNames": true,
|
|
12
|
-
"noUnusedLocals": true,
|
|
13
|
-
"noUnusedParameters": true,
|
|
14
|
-
"noFallthroughCasesInSwitch": true,
|
|
15
|
-
"outDir": "./dist"
|
|
16
|
-
},
|
|
17
|
-
"include": ["src"],
|
|
18
|
-
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
19
|
-
}
|