@k8ordo/router 0.1.0 → 1.1.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/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 };
@@ -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
- /** Applies it. Called inside a transition. */
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 the
49
- * identity an error boundary keys on: leaving the page that failed is what
50
- * should clear the failure, and the URL commits before the tree arrives, so
51
- * the pathname would clear it one render too early and let the old tree
52
- * fail again under the new key.
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 in a
57
- * transition, and resolve the platform's handler only once the new tree is on
58
- * screen — which is what makes `navigation.navigate().finished` mean "the
59
- * page is showing", and what `@k8ordo/state`'s `update().finished` inherits.
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
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { normalizePathname } from "./paths.mjs";
3
- import { createContext, startTransition, useEffect, useRef, useState } from "react";
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 the
46
- * identity an error boundary keys on: leaving the page that failed is what
47
- * should clear the failure, and the URL commits before the tree arrives, so
48
- * the pathname would clear it one render too early and let the old tree
49
- * fail again under the new key.
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 in a
54
- * transition, and resolve the platform's handler only once the new tree is on
55
- * screen — which is what makes `navigation.navigate().finished` mean "the
56
- * page is showing", and what `@k8ordo/state`'s `update().finished` inherits.
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 latest = useRef(handler);
64
- latest.current = handler;
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
- if (pathname === shown.current) {
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 (!latest.current.claim(url)) return;
140
+ if (!claim(url)) return;
83
141
  const id = count.current++;
84
- const scroll = scrollPlanFor(event.navigationType, url.hash);
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
- scroll: scroll === null ? "after-transition" : "manual",
149
+ ...inPlace ? { focusReset: "manual" } : {},
150
+ scroll: scroll === null && !inPlace ? "after-transition" : "manual",
87
151
  handler: async () => {
88
- const value = await latest.current.load(url, event.signal);
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
- startTransition(() => {
100
- shown.current = pathname;
101
- latest.current.apply(value);
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
- useEffect(() => {
114
- const entry = pending.current.get(applied);
183
+ useLayoutEffect(() => {
184
+ const entry = pending.current.get(onScreen);
115
185
  if (entry === void 0) return;
116
- pending.current.delete(applied);
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
- }, [applied]);
120
- return { generation: applied };
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 its place
7
- * in the app-wide route union.
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> : Record<never, never>;
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> : Record<never, never>;
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> : Record<never, never>;
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,17 @@ 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;
81
+ export type ParamValue = string | number | bigint | boolean;
82
+ export declare const buildHref: (pattern: string, params: Readonly<Record<string, unknown>> | undefined) => string;
66
83
  //#endregion
67
- export { Join, ParamValue, ParamsOf, ParamsSchemaFor, ParsedParams, ParsedParamsMap, PathFor, SchemaOutput, StandardSchemaLike, buildHref, isGroupKey, joinPattern, normalizePathname };
84
+ export {};
@@ -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
- /** Every pattern in the registered table; any `/`-pattern before Register. */
23
- type RegisteredPattern = Register extends {
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,78 @@ 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); a param no schema covers takes anything
40
- * with one spelling. Before `Register` is augmented — a client application
41
- * that has not, or the framework's generated file not yet written — the
42
- * same loose shape applies, so a link written for a schema still compiles.
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.
43
111
  */
44
- type RegisteredParams<P extends string> = RegisteredParamsMap extends null ? LooseParamsOf<P> : P extends keyof RegisteredParamsMap ? Omit<LooseParamsOf<P>, keyof RegisteredParamsMap[P]> & RegisteredParamsMap[P] : LooseParamsOf<P>;
112
+ export type RouteContext<P extends RegisteredPattern> = {
113
+ readonly request: Request;
114
+ readonly params: RegisteredPageParams<P>;
115
+ };
45
116
  //#endregion
46
- export { Register, RegisteredNavigablePattern, RegisteredParams, RegisteredPattern };
117
+ export {};
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
- /** The winning pattern and its params, untyped — `useParams` narrows. */
22
- declare function useRoute(): Pick<Match, 'pattern' | 'params'>;
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