@k8ordo/router 0.1.0 → 1.0.0
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/README.md +18 -9
- package/dist/base.d.mts +16 -0
- package/dist/base.mjs +45 -0
- package/dist/boundary.d.mts +13 -10
- package/dist/boundary.mjs +19 -6
- package/dist/define-routes.d.mts +22 -18
- package/dist/define-routes.mjs +15 -2
- package/dist/index.d.mts +9 -7
- package/dist/index.mjs +6 -4
- package/dist/links.d.mts +42 -13
- package/dist/links.mjs +44 -10
- package/dist/location.d.mts +22 -6
- package/dist/location.mjs +29 -6
- package/dist/match.d.mts +13 -5
- package/dist/match.mjs +8 -3
- package/dist/navigation.d.mts +61 -19
- package/dist/navigation.mjs +107 -29
- package/dist/not-found.d.mts +11 -0
- package/dist/not-found.mjs +28 -0
- package/dist/paths.d.mts +35 -19
- package/dist/register.d.mts +84 -14
- package/dist/router.d.mts +14 -8
- package/dist/router.mjs +29 -14
- package/docs/GUIDE.md +300 -58
- package/docs/llms.txt +74 -18
- package/package.json +17 -14
package/dist/match.mjs
CHANGED
|
@@ -15,7 +15,12 @@ const decode = (value) => {
|
|
|
15
15
|
* is whatever the caller holds — which is why it works where no table is
|
|
16
16
|
* mounted, under the framework included.
|
|
17
17
|
*/
|
|
18
|
-
const matchPath = (pattern, pathname) => {
|
|
18
|
+
const matchPath = (pattern, pathname, options) => {
|
|
19
|
+
if (options?.inclusive === true && pattern.endsWith("/*")) {
|
|
20
|
+
const own = pattern.slice(0, -2);
|
|
21
|
+
const index = matchPath(own === "" ? "/" : own, pathname);
|
|
22
|
+
if (index !== null) return index;
|
|
23
|
+
}
|
|
19
24
|
const result = new URLPattern({ pathname: pattern }).exec({ pathname: normalizePathname(pathname) });
|
|
20
25
|
if (result === null) return null;
|
|
21
26
|
const params = {};
|
|
@@ -28,8 +33,8 @@ const matchPath = (pattern, pathname) => {
|
|
|
28
33
|
* sidebar can ask "is a page under `/docs/*` showing" without a table in the
|
|
29
34
|
* browser, which is the question `useRoute` cannot answer under the framework.
|
|
30
35
|
*/
|
|
31
|
-
function useMatch(pattern) {
|
|
32
|
-
return matchPath(pattern, usePathname());
|
|
36
|
+
function useMatch(pattern, options) {
|
|
37
|
+
return matchPath(pattern, usePathname(), options);
|
|
33
38
|
}
|
|
34
39
|
//#endregion
|
|
35
40
|
export { matchPath, useMatch };
|
package/dist/navigation.d.mts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* is not constructible — and this decision is the whole contract with the
|
|
6
6
|
* platform.
|
|
7
7
|
*/
|
|
8
|
-
type NavigationFacts = Pick<NavigateEvent, 'canIntercept' | 'hashChange' | 'downloadRequest' | 'navigationType'> & {
|
|
8
|
+
export type NavigationFacts = Pick<NavigateEvent, 'canIntercept' | 'hashChange' | 'downloadRequest' | 'navigationType'> & {
|
|
9
9
|
readonly formData: FormData | null;
|
|
10
10
|
};
|
|
11
11
|
/**
|
|
@@ -17,8 +17,8 @@ type NavigationFacts = Pick<NavigateEvent, 'canIntercept' | 'hashChange' | 'down
|
|
|
17
17
|
* has no `formData`, so the search-shaped navigations `@k8ordo/state` builds
|
|
18
18
|
* still come through here.
|
|
19
19
|
*/
|
|
20
|
-
declare const isOurs: (event: NavigationFacts) => boolean;
|
|
21
|
-
type NavigationHandler<T> = {
|
|
20
|
+
export declare const isOurs: (event: NavigationFacts) => boolean;
|
|
21
|
+
export type NavigationHandler<T> = {
|
|
22
22
|
/**
|
|
23
23
|
* Whether this navigation is the application's to handle. Answered
|
|
24
24
|
* synchronously, because that is the only moment the platform lets anyone
|
|
@@ -28,42 +28,84 @@ type NavigationHandler<T> = {
|
|
|
28
28
|
claim: (url: URL) => boolean;
|
|
29
29
|
/** Produces whatever the application renders for this URL. */
|
|
30
30
|
load: (url: URL, signal: AbortSignal) => T | Promise<T>;
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Applies it, as an ordinary update and not inside a transition. Render
|
|
33
|
+
* what it sets through `useDeferredValue`: the new page then renders in the
|
|
34
|
+
* background, the previous one stays on screen while it suspends, and a
|
|
35
|
+
* `<ViewTransition>` animates the swap.
|
|
36
|
+
*/
|
|
32
37
|
apply: (value: T) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Whether a navigation that keeps the pathname on screen still has to
|
|
40
|
+
* load — the page showing reads the search, and the search moved. Without
|
|
41
|
+
* one, such a navigation is a state change: nothing loads. With one, it
|
|
42
|
+
* loads and applies like a page change, but as a state change otherwise:
|
|
43
|
+
* no scroll, no focus reset, no transition types.
|
|
44
|
+
*/
|
|
45
|
+
refresh?: (url: URL) => boolean;
|
|
33
46
|
};
|
|
34
47
|
/**
|
|
35
48
|
* Where the viewport goes once the new page is on screen. A traversal is the
|
|
36
49
|
* browser's to restore, so it carries no instruction; a fragment scrolls to
|
|
37
50
|
* its target; anything else starts at the top, the way a document load does.
|
|
38
51
|
*/
|
|
39
|
-
type ScrollPlan = {
|
|
52
|
+
export type ScrollPlan = {
|
|
40
53
|
kind: 'top';
|
|
41
54
|
} | {
|
|
42
55
|
kind: 'fragment';
|
|
43
56
|
id: string;
|
|
44
57
|
};
|
|
45
|
-
declare const scrollPlanFor: (navigationType: NavigationType, hash: string) => ScrollPlan | null;
|
|
58
|
+
export declare const scrollPlanFor: (navigationType: NavigationType, hash: string) => ScrollPlan | null;
|
|
59
|
+
/**
|
|
60
|
+
* What a `<ViewTransition>` in the application learns about the navigation
|
|
61
|
+
* that changed the tree: `navigation` names any page change, and the second
|
|
62
|
+
* tag carries the kind the platform reported — `push`, `replace` or
|
|
63
|
+
* `traverse` — so a back button can animate the other way from a link. The
|
|
64
|
+
* router is the one place these can be said, because it is the one holding
|
|
65
|
+
* the event when the tree is applied.
|
|
66
|
+
*/
|
|
67
|
+
export declare const transitionTypesFor: (navigationType: NavigationType) => readonly string[];
|
|
68
|
+
/**
|
|
69
|
+
* Where a page change in progress is going, as a pathname in the table's
|
|
70
|
+
* terms, or `null` when none is. The URL commits before the new page
|
|
71
|
+
* arrives — `usePathname` already names the destination — and the previous
|
|
72
|
+
* page stays on screen while the next one loads; this is what tells the two
|
|
73
|
+
* apart, for a link that marks itself as loading or a bar across the top. It
|
|
74
|
+
* clears when the new page is on screen, and when the navigation is given up.
|
|
75
|
+
* A state change (the search, the entry) is not a page change and sets
|
|
76
|
+
* nothing. A server render has no navigation in progress: `null`.
|
|
77
|
+
*/
|
|
78
|
+
export declare function usePendingPathname(): string | null;
|
|
46
79
|
/**
|
|
47
80
|
* Which navigation put the tree on screen — a number that changes exactly
|
|
48
|
-
* when a new tree is applied, and not when only the URL moved. It is
|
|
49
|
-
*
|
|
50
|
-
* should clear
|
|
51
|
-
*
|
|
52
|
-
*
|
|
81
|
+
* when a new tree is applied, and not when only the URL moved. It is what an
|
|
82
|
+
* error boundary clears its failure on: leaving the page that failed is what
|
|
83
|
+
* should clear it, and the URL commits before the tree arrives, so the
|
|
84
|
+
* pathname would clear it one render too early and let the old tree fail
|
|
85
|
+
* again.
|
|
53
86
|
*/
|
|
54
|
-
declare const NavigationGeneration: import("react").Context<number>;
|
|
87
|
+
export declare const NavigationGeneration: import("react").Context<number>;
|
|
55
88
|
/**
|
|
56
|
-
* The navigation half of the router, on its own: intercept, load, apply
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
89
|
+
* The navigation half of the router, on its own: intercept, load, apply, and
|
|
90
|
+
* resolve the platform's handler only once the new tree is on screen — which
|
|
91
|
+
* is what makes `navigation.navigate().finished` mean "the page is showing".
|
|
92
|
+
* A navigation that keeps the pathname on screen — `@k8ordo/state`'s
|
|
93
|
+
* `update()` — is intercepted with no handler, so its `finished` settles as
|
|
94
|
+
* soon as the navigation commits.
|
|
95
|
+
*
|
|
96
|
+
* The new tree renders at the lane `useDeferredValue` gives it, not in a
|
|
97
|
+
* transition. While any async action is pending, React holds every
|
|
98
|
+
* transition until that action ends — so an action awaiting `finished` would
|
|
99
|
+
* be waiting on itself, and a page change started beside an unrelated action
|
|
100
|
+
* would wait for it. A deferred render keeps what a transition gave (the old
|
|
101
|
+
* page stays while the new one suspends, and `<ViewTransition>` animates it)
|
|
102
|
+
* without joining the action.
|
|
60
103
|
*
|
|
61
104
|
* What gets loaded is the caller's business: a route table match for a plain
|
|
62
105
|
* client app, an RSC payload under the framework. Neither has to teach this
|
|
63
106
|
* hook anything about the other.
|
|
64
107
|
*/
|
|
65
|
-
declare function useInterceptedNavigation<T>(handler: NavigationHandler<T>): {
|
|
108
|
+
export declare function useInterceptedNavigation<T>(handler: NavigationHandler<T>): {
|
|
66
109
|
readonly generation: number;
|
|
67
110
|
};
|
|
68
|
-
//#endregion
|
|
69
|
-
export { NavigationFacts, NavigationGeneration, NavigationHandler, ScrollPlan, isOurs, scrollPlanFor, useInterceptedNavigation };
|
|
111
|
+
//#endregion
|
package/dist/navigation.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { normalizePathname } from "./paths.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { withoutBase } from "./base.mjs";
|
|
4
|
+
import { addTransitionType, createContext, startTransition, useDeferredValue, useEffect, useEffectEvent, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
5
|
//#region src/navigation.ts
|
|
5
6
|
/**
|
|
6
7
|
* Whether this navigation is the application's to handle at all.
|
|
@@ -26,6 +27,15 @@ const scrollPlanFor = (navigationType, hash) => {
|
|
|
26
27
|
}
|
|
27
28
|
return { kind: "top" };
|
|
28
29
|
};
|
|
30
|
+
/**
|
|
31
|
+
* What a `<ViewTransition>` in the application learns about the navigation
|
|
32
|
+
* that changed the tree: `navigation` names any page change, and the second
|
|
33
|
+
* tag carries the kind the platform reported — `push`, `replace` or
|
|
34
|
+
* `traverse` — so a back button can animate the other way from a link. The
|
|
35
|
+
* router is the one place these can be said, because it is the one holding
|
|
36
|
+
* the event when the tree is applied.
|
|
37
|
+
*/
|
|
38
|
+
const transitionTypesFor = (navigationType) => ["navigation", `navigation-${navigationType}`];
|
|
29
39
|
const applyScroll = (plan) => {
|
|
30
40
|
if (plan.kind === "fragment") {
|
|
31
41
|
const selector = `#${CSS.escape(plan.id)}, [name=${JSON.stringify(plan.id)}]`;
|
|
@@ -41,66 +51,126 @@ const applyScroll = (plan) => {
|
|
|
41
51
|
});
|
|
42
52
|
};
|
|
43
53
|
/**
|
|
54
|
+
* The pathname a page change is loading, in the table's terms — `null` when
|
|
55
|
+
* none is. One per document, like the navigation it follows: there is one
|
|
56
|
+
* intercepting host, and a pending indicator anywhere reads the same answer.
|
|
57
|
+
*/
|
|
58
|
+
let pendingPathname = null;
|
|
59
|
+
const pendingListeners = /* @__PURE__ */ new Set();
|
|
60
|
+
const setPendingPathname = (value) => {
|
|
61
|
+
if (pendingPathname === value) return;
|
|
62
|
+
pendingPathname = value;
|
|
63
|
+
for (const listener of pendingListeners) listener();
|
|
64
|
+
};
|
|
65
|
+
const subscribePending = (onChange) => {
|
|
66
|
+
pendingListeners.add(onChange);
|
|
67
|
+
return () => {
|
|
68
|
+
pendingListeners.delete(onChange);
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Where a page change in progress is going, as a pathname in the table's
|
|
73
|
+
* terms, or `null` when none is. The URL commits before the new page
|
|
74
|
+
* arrives — `usePathname` already names the destination — and the previous
|
|
75
|
+
* page stays on screen while the next one loads; this is what tells the two
|
|
76
|
+
* apart, for a link that marks itself as loading or a bar across the top. It
|
|
77
|
+
* clears when the new page is on screen, and when the navigation is given up.
|
|
78
|
+
* A state change (the search, the entry) is not a page change and sets
|
|
79
|
+
* nothing. A server render has no navigation in progress: `null`.
|
|
80
|
+
*/
|
|
81
|
+
function usePendingPathname() {
|
|
82
|
+
return useSyncExternalStore(subscribePending, () => pendingPathname, () => null);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
44
85
|
* Which navigation put the tree on screen — a number that changes exactly
|
|
45
|
-
* when a new tree is applied, and not when only the URL moved. It is
|
|
46
|
-
*
|
|
47
|
-
* should clear
|
|
48
|
-
*
|
|
49
|
-
*
|
|
86
|
+
* when a new tree is applied, and not when only the URL moved. It is what an
|
|
87
|
+
* error boundary clears its failure on: leaving the page that failed is what
|
|
88
|
+
* should clear it, and the URL commits before the tree arrives, so the
|
|
89
|
+
* pathname would clear it one render too early and let the old tree fail
|
|
90
|
+
* again.
|
|
50
91
|
*/
|
|
51
92
|
const NavigationGeneration = createContext(-1);
|
|
52
93
|
/**
|
|
53
|
-
* The navigation half of the router, on its own: intercept, load, apply
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
94
|
+
* The navigation half of the router, on its own: intercept, load, apply, and
|
|
95
|
+
* resolve the platform's handler only once the new tree is on screen — which
|
|
96
|
+
* is what makes `navigation.navigate().finished` mean "the page is showing".
|
|
97
|
+
* A navigation that keeps the pathname on screen — `@k8ordo/state`'s
|
|
98
|
+
* `update()` — is intercepted with no handler, so its `finished` settles as
|
|
99
|
+
* soon as the navigation commits.
|
|
100
|
+
*
|
|
101
|
+
* The new tree renders at the lane `useDeferredValue` gives it, not in a
|
|
102
|
+
* transition. While any async action is pending, React holds every
|
|
103
|
+
* transition until that action ends — so an action awaiting `finished` would
|
|
104
|
+
* be waiting on itself, and a page change started beside an unrelated action
|
|
105
|
+
* would wait for it. A deferred render keeps what a transition gave (the old
|
|
106
|
+
* page stays while the new one suspends, and `<ViewTransition>` animates it)
|
|
107
|
+
* without joining the action.
|
|
57
108
|
*
|
|
58
109
|
* What gets loaded is the caller's business: a route table match for a plain
|
|
59
110
|
* client app, an RSC payload under the framework. Neither has to teach this
|
|
60
111
|
* hook anything about the other.
|
|
61
112
|
*/
|
|
62
113
|
function useInterceptedNavigation(handler) {
|
|
63
|
-
const
|
|
64
|
-
|
|
114
|
+
const claim = useEffectEvent((url) => handler.claim(url));
|
|
115
|
+
const refresh = useEffectEvent((url) => handler.refresh?.(url) ?? false);
|
|
116
|
+
const load = useEffectEvent((url, signal) => handler.load(url, signal));
|
|
117
|
+
const apply = useEffectEvent((value) => {
|
|
118
|
+
handler.apply(value);
|
|
119
|
+
});
|
|
65
120
|
const pending = useRef(/* @__PURE__ */ new Map());
|
|
66
121
|
const count = useRef(0);
|
|
67
122
|
const [applied, setApplied] = useState(-1);
|
|
123
|
+
const onScreen = useDeferredValue(applied);
|
|
68
124
|
const shown = useRef(null);
|
|
125
|
+
const latest = useRef(-1);
|
|
69
126
|
useEffect(() => {
|
|
70
127
|
shown.current = normalizePathname(location.pathname);
|
|
71
128
|
const onNavigate = (event) => {
|
|
72
129
|
if (!isOurs(event)) return;
|
|
73
130
|
const url = new URL(event.destination.url);
|
|
74
131
|
const pathname = normalizePathname(url.pathname);
|
|
75
|
-
|
|
132
|
+
const inPlace = pathname === shown.current;
|
|
133
|
+
if (inPlace && !refresh(url)) {
|
|
76
134
|
event.intercept({
|
|
77
135
|
scroll: "manual",
|
|
78
136
|
focusReset: "manual"
|
|
79
137
|
});
|
|
80
138
|
return;
|
|
81
139
|
}
|
|
82
|
-
if (!
|
|
140
|
+
if (!claim(url)) return;
|
|
83
141
|
const id = count.current++;
|
|
84
|
-
|
|
142
|
+
latest.current = id;
|
|
143
|
+
setPendingPathname(normalizePathname(withoutBase(url.pathname) ?? url.pathname));
|
|
144
|
+
const settled = () => {
|
|
145
|
+
if (latest.current === id) setPendingPathname(null);
|
|
146
|
+
};
|
|
147
|
+
const scroll = inPlace ? null : scrollPlanFor(event.navigationType, url.hash);
|
|
85
148
|
event.intercept({
|
|
86
|
-
|
|
149
|
+
...inPlace ? { focusReset: "manual" } : {},
|
|
150
|
+
scroll: scroll === null && !inPlace ? "after-transition" : "manual",
|
|
87
151
|
handler: async () => {
|
|
88
|
-
|
|
152
|
+
event.signal.addEventListener("abort", settled, { once: true });
|
|
153
|
+
let value;
|
|
154
|
+
try {
|
|
155
|
+
value = await load(url, event.signal);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
settled();
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
89
160
|
if (event.signal.aborted) throw event.signal.reason;
|
|
90
161
|
await new Promise((resolve, reject) => {
|
|
91
162
|
pending.current.set(id, {
|
|
92
163
|
resolve,
|
|
93
|
-
scroll
|
|
164
|
+
scroll,
|
|
165
|
+
types: inPlace ? [] : transitionTypesFor(event.navigationType)
|
|
94
166
|
});
|
|
95
167
|
event.signal.addEventListener("abort", () => {
|
|
96
168
|
pending.current.delete(id);
|
|
97
169
|
reject(event.signal.reason);
|
|
98
170
|
}, { once: true });
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
setApplied(id);
|
|
103
|
-
});
|
|
171
|
+
shown.current = pathname;
|
|
172
|
+
apply(value);
|
|
173
|
+
setApplied(id);
|
|
104
174
|
});
|
|
105
175
|
}
|
|
106
176
|
});
|
|
@@ -110,14 +180,22 @@ function useInterceptedNavigation(handler) {
|
|
|
110
180
|
navigation.removeEventListener("navigate", onNavigate);
|
|
111
181
|
};
|
|
112
182
|
}, []);
|
|
113
|
-
|
|
114
|
-
const entry = pending.current.get(
|
|
183
|
+
useLayoutEffect(() => {
|
|
184
|
+
const entry = pending.current.get(onScreen);
|
|
115
185
|
if (entry === void 0) return;
|
|
116
|
-
pending.current.delete(
|
|
186
|
+
pending.current.delete(onScreen);
|
|
117
187
|
if (entry.scroll !== null) applyScroll(entry.scroll);
|
|
188
|
+
if (latest.current === onScreen) setPendingPathname(null);
|
|
118
189
|
entry.resolve();
|
|
119
|
-
}, [
|
|
120
|
-
|
|
190
|
+
}, [onScreen]);
|
|
191
|
+
useLayoutEffect(() => {
|
|
192
|
+
if (applied === onScreen) return;
|
|
193
|
+
const types = pending.current.get(applied)?.types ?? [];
|
|
194
|
+
startTransition(() => {
|
|
195
|
+
for (const type of types) addTransitionType(type);
|
|
196
|
+
});
|
|
197
|
+
}, [applied, onScreen]);
|
|
198
|
+
return { generation: onScreen };
|
|
121
199
|
}
|
|
122
200
|
//#endregion
|
|
123
|
-
export { NavigationGeneration, isOurs, scrollPlanFor, useInterceptedNavigation };
|
|
201
|
+
export { NavigationGeneration, isOurs, scrollPlanFor, transitionTypesFor, useInterceptedNavigation, usePendingPathname };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/not-found.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Says, from a page, that its pathname is not a page after all — the
|
|
4
|
+
* product the id names does not exist. Thrown, so the lines after it never
|
|
5
|
+
* run. Under `@k8ordo/framework` the nearest
|
|
6
|
+
* `not-found.tsx` answers instead, under a 404.
|
|
7
|
+
*/
|
|
8
|
+
export declare function notFound(): never;
|
|
9
|
+
/** Whether a thrown value is what `notFound()` throws. */
|
|
10
|
+
export declare const isNotFound: (value: unknown) => boolean;
|
|
11
|
+
//#endregion
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/not-found.ts
|
|
2
|
+
/**
|
|
3
|
+
* Branded with a registry symbol rather than recognised by class: the page
|
|
4
|
+
* that throws it and the framework that catches it may hold two copies of
|
|
5
|
+
* this package — the framework bundles what it runs — and two copies of a
|
|
6
|
+
* class are two classes. `Symbol.for` is the one identity both share.
|
|
7
|
+
*/
|
|
8
|
+
const BRAND = Symbol.for("k8ordo.not-found");
|
|
9
|
+
var NotFound = class extends Error {
|
|
10
|
+
[BRAND] = true;
|
|
11
|
+
constructor() {
|
|
12
|
+
super("not found");
|
|
13
|
+
this.name = "NotFound";
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Says, from a page, that its pathname is not a page after all — the
|
|
18
|
+
* product the id names does not exist. Thrown, so the lines after it never
|
|
19
|
+
* run. Under `@k8ordo/framework` the nearest
|
|
20
|
+
* `not-found.tsx` answers instead, under a 404.
|
|
21
|
+
*/
|
|
22
|
+
function notFound() {
|
|
23
|
+
throw new NotFound();
|
|
24
|
+
}
|
|
25
|
+
/** Whether a thrown value is what `notFound()` throws. */
|
|
26
|
+
const isNotFound = (value) => typeof value === "object" && value !== null && value[BRAND] === true;
|
|
27
|
+
//#endregion
|
|
28
|
+
export { isNotFound, notFound };
|
package/dist/paths.d.mts
CHANGED
|
@@ -3,20 +3,20 @@
|
|
|
3
3
|
* The type half of the route table: everything here is derived from the
|
|
4
4
|
* pattern strings by inference alone — no code generation. A pattern like
|
|
5
5
|
* `'/:locale/products/:id'` yields its params (`{ locale, id }`), its
|
|
6
|
-
* concrete-path type (`` `/${string}/products/${string}` ``), and
|
|
7
|
-
*
|
|
6
|
+
* concrete-path type (`` `/${string}/products/${string}` ``), and whether a
|
|
7
|
+
* path handed in fits it.
|
|
8
8
|
*/
|
|
9
|
-
type SegParam<Segment extends string> = Segment extends `:${infer Name}` ? Record<Name, string> :
|
|
9
|
+
type SegParam<Segment extends string> = Segment extends `:${infer Name}` ? Record<Name, string> : object;
|
|
10
10
|
type SegListParams<Rest extends string> = Rest extends `${infer Segment}/${infer Tail}` ? SegParam<Segment> & SegListParams<Tail> : SegParam<Rest>;
|
|
11
11
|
/** `'/:locale/products/:id'` → `{ locale: string; id: string }` */
|
|
12
|
-
type ParamsOf<Pattern extends string> = Pattern extends `/${infer Rest}` ? SegListParams<Rest> :
|
|
12
|
+
export type ParamsOf<Pattern extends string> = Pattern extends `/${infer Rest}` ? SegListParams<Rest> : object;
|
|
13
13
|
/**
|
|
14
14
|
* The shape every validation library agrees on (Standard Schema): a value
|
|
15
15
|
* under `~standard` whose `types` carry the input and output. Only the
|
|
16
16
|
* output is read here — the router itself never runs a schema; the framework
|
|
17
17
|
* does, and this package only has to type what comes out.
|
|
18
18
|
*/
|
|
19
|
-
type StandardSchemaLike<Output = unknown> = {
|
|
19
|
+
export type StandardSchemaLike<Output = unknown> = {
|
|
20
20
|
readonly '~standard': {
|
|
21
21
|
readonly types?: {
|
|
22
22
|
readonly output: Output;
|
|
@@ -24,26 +24,43 @@ type StandardSchemaLike<Output = unknown> = {
|
|
|
24
24
|
};
|
|
25
25
|
};
|
|
26
26
|
/** What a schema produces. */
|
|
27
|
-
type SchemaOutput<Schema> = Schema extends StandardSchemaLike<infer Output> ? Output : never;
|
|
27
|
+
export type SchemaOutput<Schema> = Schema extends StandardSchemaLike<infer Output> ? Output : never;
|
|
28
28
|
/**
|
|
29
29
|
* A schema a route file may declare for the params its pattern names: its
|
|
30
30
|
* output is an object whose keys are a subset of those params — a key the
|
|
31
31
|
* pattern does not name has nothing to validate.
|
|
32
32
|
*/
|
|
33
|
-
type ParamsSchemaFor<Pattern extends string> = StandardSchemaLike<Partial<Record<keyof ParamsOf<Pattern> & string, unknown>>>;
|
|
34
|
-
type MergedOutput<Schemas extends readonly unknown[]> = Schemas extends readonly [infer Head, ...infer Tail] ? Omit<MergedOutput<Tail>, keyof SchemaOutput<Head>> & SchemaOutput<Head> :
|
|
33
|
+
export type ParamsSchemaFor<Pattern extends string> = StandardSchemaLike<Partial<Record<keyof ParamsOf<Pattern> & string, unknown>>>;
|
|
34
|
+
type MergedOutput<Schemas extends readonly unknown[]> = Schemas extends readonly [infer Head, ...infer Tail] ? Omit<MergedOutput<Tail>, keyof SchemaOutput<Head>> & SchemaOutput<Head> : object;
|
|
35
35
|
/**
|
|
36
36
|
* A pattern's params after the schemas along its stack have run — outer
|
|
37
37
|
* layouts first, the page last — each replacing the strings it names with
|
|
38
38
|
* what it produced. Params no schema names stay strings.
|
|
39
39
|
*/
|
|
40
|
-
type ParsedParams<Pattern extends string, Schemas extends readonly unknown[]> = Omit<ParamsOf<Pattern>, keyof MergedOutput<Schemas>> & MergedOutput<Schemas>;
|
|
40
|
+
export type ParsedParams<Pattern extends string, Schemas extends readonly unknown[]> = Omit<ParamsOf<Pattern>, keyof MergedOutput<Schemas>> & MergedOutput<Schemas>;
|
|
41
41
|
/** `{ '/products/:id': [schema, …] }` → `{ '/products/:id': { id: number } }` */
|
|
42
|
-
type ParsedParamsMap<Schemas extends Record<string, readonly unknown[]>> = { [Pattern in keyof Schemas & string]: ParsedParams<Pattern, Schemas[Pattern]>; };
|
|
42
|
+
export type ParsedParamsMap<Schemas extends Record<string, readonly unknown[]>> = { [Pattern in keyof Schemas & string]: ParsedParams<Pattern, Schemas[Pattern]>; };
|
|
43
43
|
type SegPath<Segment extends string> = Segment extends `:${string}` ? string : Segment;
|
|
44
44
|
type SegListPath<Rest extends string> = Rest extends `${infer Segment}/${infer Tail}` ? `${SegPath<Segment>}/${SegListPath<Tail>}` : SegPath<Rest>;
|
|
45
45
|
/** `'/:locale/products/:id'` → `` `/${string}/products/${string}` `` */
|
|
46
|
-
type PathFor<Pattern extends string> = Pattern extends `/${infer Rest}` ? `/${SegListPath<Rest>}` : never;
|
|
46
|
+
export type PathFor<Pattern extends string> = Pattern extends `/${infer Rest}` ? `/${SegListPath<Rest>}` : never;
|
|
47
|
+
/**
|
|
48
|
+
* One segment of a path against one segment of a pattern, the way URLPattern
|
|
49
|
+
* reads a `:param`: any single segment that is not empty. A `${string}` in
|
|
50
|
+
* the path — a param built into a template literal — counts as one segment.
|
|
51
|
+
*/
|
|
52
|
+
type SegmentFits<Segment extends string, PatternSegment extends string> = PatternSegment extends `:${string}` ? Segment extends '' ? false : true : Segment extends PatternSegment ? true : false;
|
|
53
|
+
type SegmentsFit<Rest extends string, PatternRest extends string> = Rest extends `${infer Segment}/${infer Tail}` ? PatternRest extends `${infer PatternSegment}/${infer PatternTail}` ? SegmentFits<Segment, PatternSegment> extends true ? SegmentsFit<Tail, PatternTail> : false : false : PatternRest extends `${string}/${string}` ? false : SegmentFits<Rest, PatternRest>;
|
|
54
|
+
type FitsAny<Path extends string, Pattern extends string> = Pattern extends `/${infer PatternRest}` ? Path extends `/${infer Rest}` ? SegmentsFit<Rest, PatternRest> : false : never;
|
|
55
|
+
/**
|
|
56
|
+
* `Path` when one of `Patterns` matches it segment by segment, `never` when
|
|
57
|
+
* none does. A union of paths keeps only the members that match.
|
|
58
|
+
*
|
|
59
|
+
* The path is checked against the patterns, never collected into a union of
|
|
60
|
+
* `PathFor` each pattern: `/:locale` would put `/${string}` in that union,
|
|
61
|
+
* and `/${string}` takes every path there is.
|
|
62
|
+
*/
|
|
63
|
+
export type PathMatching<Path extends string, Patterns extends string> = Path extends string ? true extends FitsAny<Path, Patterns> ? Path : never : never;
|
|
47
64
|
/**
|
|
48
65
|
* Keys that address the parent's own place instead of a place below it:
|
|
49
66
|
* `'/'` (the branch's index) and `'/(name)'` (a group, which structures the
|
|
@@ -51,17 +68,16 @@ type PathFor<Pattern extends string> = Pattern extends `/${infer Rest}` ? `/${Se
|
|
|
51
68
|
*/
|
|
52
69
|
type Transparent<Key extends string> = Key extends '/' ? true : Key extends `/(${string})` ? true : false;
|
|
53
70
|
/** Composes a parent prefix with a child key. */
|
|
54
|
-
type Join<Prefix extends string, Key extends string> = Transparent<Key> extends true ? Prefix extends '' ? '/' : Prefix : `${Prefix}${Key}`;
|
|
55
|
-
declare const isGroupKey: (key: string) => boolean;
|
|
56
|
-
declare const joinPattern: (prefix: string, key: string) => string;
|
|
71
|
+
export type Join<Prefix extends string, Key extends string> = Transparent<Key> extends true ? Prefix extends '' ? '/' : Prefix : `${Prefix}${Key}`;
|
|
72
|
+
export declare const isGroupKey: (key: string) => boolean;
|
|
73
|
+
export declare const joinPattern: (prefix: string, key: string) => string;
|
|
57
74
|
/**
|
|
58
75
|
* URLPattern treats `/products` and `/products/` as different pathnames; the
|
|
59
76
|
* router does not. Everything is matched against the slashless canonical
|
|
60
77
|
* form, root excepted.
|
|
61
78
|
*/
|
|
62
|
-
declare const normalizePathname: (pathname: string) => string;
|
|
79
|
+
export declare const normalizePathname: (pathname: string) => string;
|
|
63
80
|
/** What a param value may be on the way into a link: anything with one spelling. */
|
|
64
|
-
type ParamValue = string | number | bigint | boolean;
|
|
65
|
-
declare const buildHref: (pattern: string, params: Readonly<Record<string, unknown>> | undefined) => string;
|
|
66
|
-
//#endregion
|
|
67
|
-
export { Join, ParamValue, ParamsOf, ParamsSchemaFor, ParsedParams, ParsedParamsMap, PathFor, SchemaOutput, StandardSchemaLike, buildHref, isGroupKey, joinPattern, normalizePathname };
|
|
81
|
+
export type ParamValue = string | number | bigint | boolean;
|
|
82
|
+
export declare const buildHref: (pattern: string, params: Readonly<Record<string, unknown>> | undefined) => string;
|
|
83
|
+
//#endregion
|
package/dist/register.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ParamValue, ParamsOf } from "./paths.mjs";
|
|
2
2
|
import { NavigablePatternOf, PatternOf, Routes, RoutesRecord } from "./define-routes.mjs";
|
|
3
|
+
import { ReactNode } from "react";
|
|
3
4
|
//#region src/register.d.ts
|
|
4
5
|
/**
|
|
5
6
|
* The app-side hook for the route table's type. An application augments this
|
|
@@ -18,15 +19,18 @@ import { NavigablePatternOf, PatternOf, Routes, RoutesRecord } from "./define-ro
|
|
|
18
19
|
* forms. Declared as an interface — the one exception to the repository's
|
|
19
20
|
* type-only rule — because declaration merging is the entire mechanism.
|
|
20
21
|
*/
|
|
21
|
-
interface Register {}
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
export interface Register {}
|
|
23
|
+
/**
|
|
24
|
+
* Every leaf pattern in the registered table — each page and each `/*`, not a
|
|
25
|
+
* prefix with no page of its own; any `/`-pattern before Register.
|
|
26
|
+
*/
|
|
27
|
+
export type RegisteredPattern = Register extends {
|
|
24
28
|
routes: Routes<infer R extends RoutesRecord>;
|
|
25
|
-
} ? PatternOf<R> : `/${string}`;
|
|
29
|
+
} ? { [K in PatternOf<R>]: K; }[PatternOf<R>] : `/${string}`;
|
|
26
30
|
/** Linkable patterns of the registered table (wildcards excluded). */
|
|
27
|
-
type RegisteredNavigablePattern = Register extends {
|
|
31
|
+
export type RegisteredNavigablePattern = Register extends {
|
|
28
32
|
routes: Routes<infer R extends RoutesRecord>;
|
|
29
|
-
} ? NavigablePatternOf<R> : `/${string}`;
|
|
33
|
+
} ? { [K in NavigablePatternOf<R>]: K; }[NavigablePatternOf<R>] : `/${string}`;
|
|
30
34
|
type RegisteredParamsMap = Register extends {
|
|
31
35
|
params: infer M;
|
|
32
36
|
} ? M : null;
|
|
@@ -36,11 +40,77 @@ type LooseParamsOf<P extends string> = { [K in keyof ParamsOf<P>]: ParamValue; }
|
|
|
36
40
|
* A pattern's params as a link takes them: where the registered `params`
|
|
37
41
|
* map — written by the framework from the schemas the route files declared
|
|
38
42
|
* — says a page receives something, a link takes that same value (a number
|
|
39
|
-
* in, a number's one spelling out)
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
export
|
|
43
|
+
* in, a number's one spelling out), and a param those schemas leave alone is
|
|
44
|
+
* the string the page receives. A pattern no schema covers at all takes
|
|
45
|
+
* anything with one spelling in every param. Before `Register` is augmented
|
|
46
|
+
* — a client application that has not, or the framework's generated file
|
|
47
|
+
* not yet written — the same loose shape applies, so a link written for a
|
|
48
|
+
* schema still compiles.
|
|
49
|
+
*/
|
|
50
|
+
export type RegisteredParams<P extends string> = RegisteredParamsMap extends null ? LooseParamsOf<P> : P extends keyof RegisteredParamsMap ? Omit<LooseParamsOf<P>, keyof RegisteredParamsMap[P]> & RegisteredParamsMap[P] : LooseParamsOf<P>;
|
|
51
|
+
/**
|
|
52
|
+
* What a page under this pattern receives as `params`: the schemas' output
|
|
53
|
+
* where the framework ran one (the registered `params` map, written by the
|
|
54
|
+
* generator), and the strings the URL carried elsewhere.
|
|
55
|
+
*/
|
|
56
|
+
export type RegisteredPageParams<P extends string> = Register extends {
|
|
57
|
+
params: infer M;
|
|
58
|
+
} ? P extends keyof M ? M[P] : ParamsOf<P> : ParamsOf<P>;
|
|
59
|
+
/**
|
|
60
|
+
* The request a route file receives — under the framework's server mode only, where the
|
|
61
|
+
* generated `Register` says so. A build into files has none, and a page that
|
|
62
|
+
* reads it fails to type-check there rather than at run time.
|
|
63
|
+
*/
|
|
64
|
+
type RequestProps = Register extends {
|
|
65
|
+
request: infer R;
|
|
66
|
+
} ? {
|
|
67
|
+
readonly request: R;
|
|
68
|
+
} : object;
|
|
69
|
+
/**
|
|
70
|
+
* The search a page receives — only a page that declared what it reads, by
|
|
71
|
+
* exporting `search` (the generated `Register` says which, and what it
|
|
72
|
+
* reads into). Every other page never sees the search.
|
|
73
|
+
*/
|
|
74
|
+
type SearchProps<P extends string> = P extends keyof RegisteredSearch ? {
|
|
75
|
+
readonly search: RegisteredSearch[P];
|
|
76
|
+
} : object;
|
|
77
|
+
type RegisteredSearch = Register extends {
|
|
78
|
+
search: infer M;
|
|
79
|
+
} ? M : object;
|
|
80
|
+
/**
|
|
81
|
+
* The props a `page.tsx` receives under the framework, by the pattern its
|
|
82
|
+
* directory puts it under: `params` typed by the schemas along its stack,
|
|
83
|
+
* the `pathname` this render is for, in server mode the `request`,
|
|
84
|
+
* and for a page that exports `search`, the `search` it reads. The generated
|
|
85
|
+
* table checks the same thing at the import, so a page may equally declare
|
|
86
|
+
* its props inline; this is the spelling that names the pattern once and
|
|
87
|
+
* lets the schema say the rest.
|
|
88
|
+
*/
|
|
89
|
+
export type PageProps<P extends RegisteredPattern> = {
|
|
90
|
+
readonly params: RegisteredPageParams<P>;
|
|
91
|
+
readonly pathname: string;
|
|
92
|
+
} & RequestProps & SearchProps<P>;
|
|
93
|
+
/**
|
|
94
|
+
* The props a `layout.tsx` receives, by the prefix every route below it
|
|
95
|
+
* shares — only where the table also has a page at that prefix, since the
|
|
96
|
+
* constraint is a page pattern; a layout with no page of its own declares
|
|
97
|
+
* its props inline. Its params are strings whatever it declared: a
|
|
98
|
+
* `not-found.tsx` renders under it whether or not its schemas accepted, and
|
|
99
|
+
* a typed value there would be a lie.
|
|
100
|
+
*/
|
|
101
|
+
export type LayoutProps<P extends RegisteredPattern> = {
|
|
102
|
+
readonly params: ParamsOf<P>;
|
|
103
|
+
readonly pathname: string;
|
|
104
|
+
readonly children: ReactNode;
|
|
105
|
+
} & RequestProps;
|
|
106
|
+
/**
|
|
107
|
+
* What a `route.ts` method export receives under the framework, by the
|
|
108
|
+
* pattern its directory puts it under: the request, and `params` typed by
|
|
109
|
+
* the schemas along its stack — the same as a page receives. Mode-free: a
|
|
110
|
+
* build into files calls `GET` with a request of its own.
|
|
111
|
+
*/
|
|
112
|
+
export type RouteContext<P extends RegisteredPattern> = {
|
|
113
|
+
readonly request: Request;
|
|
114
|
+
readonly params: RegisteredPageParams<P>;
|
|
115
|
+
};
|
|
116
|
+
//#endregion
|
package/dist/router.d.mts
CHANGED
|
@@ -4,7 +4,7 @@ import { RegisteredPattern } from "./register.mjs";
|
|
|
4
4
|
import { FC, ReactNode } from "react";
|
|
5
5
|
//#region src/router.d.ts
|
|
6
6
|
/** Renders the next element of the matched stack — a layout's hole. */
|
|
7
|
-
declare const Outlet: FC;
|
|
7
|
+
export declare const Outlet: FC;
|
|
8
8
|
/**
|
|
9
9
|
* Mounts a route table on the Navigation API: every same-origin navigation
|
|
10
10
|
* that the table claims is handled in the client, and the rest is left to the
|
|
@@ -15,18 +15,24 @@ declare const Outlet: FC;
|
|
|
15
15
|
* shared is `useInterceptedNavigation` and the `<PathnameProvider>` this
|
|
16
16
|
* mounts.
|
|
17
17
|
*/
|
|
18
|
-
declare function Router({ routes }: {
|
|
18
|
+
export declare function Router({ routes }: {
|
|
19
19
|
routes: Routes;
|
|
20
20
|
}): ReactNode;
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The winning pattern and its params, untyped — `useParams` narrows. It reads
|
|
23
|
+
* the match `<Router>` provides, so under `@k8ordo/framework`, which mounts
|
|
24
|
+
* no `<Router>`, a page receives `params` as a prop and a component below it
|
|
25
|
+
* reads them with `useMatch`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function useRoute(): Pick<Match, 'pattern' | 'params'>;
|
|
23
28
|
/**
|
|
24
29
|
* Typed params for the pattern this component believes it renders under —
|
|
25
30
|
* inferred from the pattern literal, verified against `Register`, and read
|
|
26
31
|
* from context at runtime, so no component imports the route table. The
|
|
27
32
|
* belief is checked: rendering under any other pattern throws instead of
|
|
28
|
-
* silently returning params of the wrong shape.
|
|
33
|
+
* silently returning params of the wrong shape. Like `useRoute`, it needs a
|
|
34
|
+
* `<Router>` above it: under `@k8ordo/framework` a page receives `params` as
|
|
35
|
+
* a prop, and a component below it reads them with `useMatch`.
|
|
29
36
|
*/
|
|
30
|
-
declare function useParams<P extends RegisteredPattern>(pattern: P): ParamsOf<P>;
|
|
31
|
-
//#endregion
|
|
32
|
-
export { Outlet, Router, useParams, useRoute };
|
|
37
|
+
export declare function useParams<P extends RegisteredPattern>(pattern: P): ParamsOf<P>;
|
|
38
|
+
//#endregion
|