@weftui/router 0.21.0 → 0.23.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/client/index.d.ts +26 -4
- package/dist/client/index.js +225 -1
- package/dist/{compile-C0JShTTR.d.ts → compile-HOyeyWRy.d.ts} +47 -1
- package/dist/href-CSRbOQov.js +165 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -1
- package/dist/outlet-CXgDkheJ.js +467 -0
- package/dist/{outlet-BBEjKu3z.d.ts → outlet-DbqTgkXa.d.ts} +1 -1
- package/dist/server/index.d.ts +43 -8
- package/dist/server/index.js +212 -1
- package/package.json +5 -4
- package/dist/href-Dl30XcF4.js +0 -1
- package/dist/outlet-C7p8KXmO.js +0 -0
package/dist/client/index.d.ts
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
|
-
import { b as
|
|
2
|
-
import { n as outletNode, r as HrefArgs, t as RouterApp } from "../outlet-
|
|
1
|
+
import { S as Router, b as NavState, i as RouterDef, m as RouteNode, u as Fields, x as NavigateOptions } from "../compile-HOyeyWRy.js";
|
|
2
|
+
import { n as outletNode, r as HrefArgs, t as RouterApp } from "../outlet-DbqTgkXa.js";
|
|
3
3
|
import { Effect, Layer, Scope } from "effect";
|
|
4
4
|
import { AppRpcClientTag } from "@weftui/core";
|
|
5
5
|
import { RpcGroup } from "@effect/rpc";
|
|
6
6
|
|
|
7
7
|
//#region src/client/router-live.d.ts
|
|
8
|
+
/**
|
|
9
|
+
* The residual app services a caller must still provide through the {@link RouterLiveOptions.context}
|
|
10
|
+
* seam — a def's aggregate `R` minus the services `RouterLive` already threads
|
|
11
|
+
* (`Router`, `Router.Outlet`, `AppRpcClientTag`). The client mirror of
|
|
12
|
+
* `RouterServer.AppServices`; resolves to `never` for an app with no app-wide service.
|
|
13
|
+
*/
|
|
14
|
+
type AppServices<R> = Exclude<R, Router | Router.Outlet | AppRpcClientTag>;
|
|
15
|
+
/** True only for the exact `any` type — a loosely-typed `RouterDef<any, any>`. */
|
|
16
|
+
type IsAny<T> = 0 extends 1 & T ? true : false;
|
|
17
|
+
/**
|
|
18
|
+
* Conditionally shapes the `context` field: **required** when the def has statically
|
|
19
|
+
* known residual {@link AppServices}, **absent** when it has none, and **optional**
|
|
20
|
+
* for a loosely-typed `RouterDef<any, any>`. Client parity with the server seam (AC4)
|
|
21
|
+
* is thus a compile-time guarantee, and no-service / loosely-typed apps stay unchanged (AC3).
|
|
22
|
+
*/
|
|
23
|
+
type ContextOption<R> = [AppServices<R>] extends [never] ? {
|
|
24
|
+
readonly context?: undefined;
|
|
25
|
+
} : IsAny<AppServices<R>> extends true ? {
|
|
26
|
+
readonly context?: Layer.Layer<any, never, never>;
|
|
27
|
+
} : {
|
|
28
|
+
readonly context: Layer.Layer<AppServices<R>, never, never>;
|
|
29
|
+
};
|
|
8
30
|
/** Options for {@link RouterLive}. */
|
|
9
31
|
interface RouterLiveOptions {
|
|
10
32
|
/**
|
|
@@ -43,7 +65,7 @@ interface RouterLiveOptions {
|
|
|
43
65
|
* refetch and client-first mount) without depending on this package or
|
|
44
66
|
* `@effect/rpc`.
|
|
45
67
|
*/
|
|
46
|
-
declare function RouterLive(def: RouterDef, options?: RouterLiveOptions): Layer.Layer<Router | AppRpcClientTag
|
|
68
|
+
declare function RouterLive<R>(def: RouterDef<any, R>, options?: RouterLiveOptions & ContextOption<R>): Layer.Layer<Router | AppRpcClientTag | AppServices<R>>;
|
|
47
69
|
//#endregion
|
|
48
70
|
//#region src/client/link.d.ts
|
|
49
71
|
/**
|
|
@@ -108,4 +130,4 @@ declare const setQuery: (query: Record<string, unknown>, options?: NavigateOptio
|
|
|
108
130
|
*/
|
|
109
131
|
declare const patchQuery: (partial: Record<string, unknown>, options?: NavigateOptions) => Effect.Effect<void, never, Router>;
|
|
110
132
|
//#endregion
|
|
111
|
-
export { Router, RouterApp, RouterLive, outletNode as RouterOutlet, outletNode, back, forward, installLinkInterceptor, navigate, patchQuery, push, replace, setQuery };
|
|
133
|
+
export { type NavState, Router, RouterApp, RouterLive, outletNode as RouterOutlet, outletNode, back, forward, installLinkInterceptor, navigate, patchQuery, push, replace, setQuery };
|
package/dist/client/index.js
CHANGED
|
@@ -1 +1,225 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { i as getPreload, n as outletNode, r as Router, t as RouterApp } from "../outlet-CXgDkheJ.js";
|
|
2
|
+
import { r as match, t as href } from "../href-CSRbOQov.js";
|
|
3
|
+
import { FetchHttpClient, HttpApiClient } from "@effect/platform";
|
|
4
|
+
import { Context, Effect, Layer, Option, Runtime, Schema, Stream, Subscribable, SubscriptionRef } from "effect";
|
|
5
|
+
import { AppRpcClientTag } from "@weftui/core";
|
|
6
|
+
//#region src/client/link.ts
|
|
7
|
+
/**
|
|
8
|
+
* Installs a global, delegated click interceptor (in the `Router` layer scope)
|
|
9
|
+
* that turns plain same-origin `h.a({ href })` clicks into SPA navigation when
|
|
10
|
+
* the href resolves to a route (L1). Modified clicks, non-left buttons,
|
|
11
|
+
* `target=_blank`, `download`, external origins, same-document (hash-only or
|
|
12
|
+
* identical-URL) navigations, and non-matching hrefs fall through to the
|
|
13
|
+
* browser's native handling — the interceptor leaves `preventDefault` untouched
|
|
14
|
+
* in those cases (L2). The listener is removed on scope teardown (L3).
|
|
15
|
+
*
|
|
16
|
+
* @param def - The router definition, used to decide whether an href matches a route.
|
|
17
|
+
* @param navigate - The router's `navigate`, run via the captured runtime on a match.
|
|
18
|
+
*/
|
|
19
|
+
function installLinkInterceptor(def, navigate) {
|
|
20
|
+
return Effect.gen(function* () {
|
|
21
|
+
const runtime = yield* Effect.runtime();
|
|
22
|
+
const onClick = (event) => {
|
|
23
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
24
|
+
const target = event.target;
|
|
25
|
+
const anchor = target instanceof Element ? target.closest("a") : null;
|
|
26
|
+
if (anchor === null) return;
|
|
27
|
+
const targetAttr = anchor.getAttribute("target");
|
|
28
|
+
if (anchor.hasAttribute("download") || targetAttr !== null && targetAttr !== "_self" || anchor.getAttribute("rel") === "external") return;
|
|
29
|
+
const href = anchor.getAttribute("href");
|
|
30
|
+
if (href === null || href.length === 0) return;
|
|
31
|
+
let url;
|
|
32
|
+
try {
|
|
33
|
+
url = new URL(href, window.location.href);
|
|
34
|
+
} catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (url.origin !== window.location.origin) return;
|
|
38
|
+
const to = `${url.pathname}${url.search}`;
|
|
39
|
+
if (to === `${window.location.pathname}${window.location.search}`) return;
|
|
40
|
+
if (match(def, to)._tag !== "Matched") return;
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
Runtime.runFork(runtime)(navigate(to));
|
|
43
|
+
};
|
|
44
|
+
yield* Effect.acquireRelease(Effect.sync(() => document.addEventListener("click", onClick)), () => Effect.sync(() => document.removeEventListener("click", onClick)));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/client/router-live.ts
|
|
49
|
+
/** Path the client rpc protocol posts to; mirrors `RouterServer`'s server route. */
|
|
50
|
+
const RPC_PATH = "/_eui/rpc";
|
|
51
|
+
/** Reads the current location as a normalized `path + search` string. */
|
|
52
|
+
function locationUrl() {
|
|
53
|
+
return `${window.location.pathname}${window.location.search}`;
|
|
54
|
+
}
|
|
55
|
+
/** Normalizes a navigation target (absolute or relative) to `path + search`. */
|
|
56
|
+
function normalizeTo(to) {
|
|
57
|
+
const url = new URL(to, window.location.href);
|
|
58
|
+
return `${url.pathname}${url.search}`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The client `Router` layer, backed by the History API. Seeds a
|
|
62
|
+
* `SubscriptionRef` from `window.location`, listens for `popstate`, and exposes
|
|
63
|
+
* `currentMatch` as the ref mapped through the shared matcher. `navigate` pushes
|
|
64
|
+
* History state and updates the ref. Also installs the same-origin link click
|
|
65
|
+
* interceptor for the layer's lifetime.
|
|
66
|
+
*
|
|
67
|
+
* It additionally derives a real {@link RouterHttpApiClient} from `def.httpApi`
|
|
68
|
+
* (over `FetchHttpClient`, `baseUrl` default same-origin) and exposes it on the
|
|
69
|
+
* `Router` service for network work. SPA URL→leaf resolution stays local via the
|
|
70
|
+
* shared {@link match}er — both sides read the one `def.httpApi` definition.
|
|
71
|
+
*
|
|
72
|
+
* Alongside `Router` it provides the core {@link AppRpcClientTag} seam — a
|
|
73
|
+
* **network** flat rpc client (`RpcClient.make` over `layerProtocolHttp` →
|
|
74
|
+
* `POST /_eui/rpc`) — so `@weftui/dom` can resolve a `Boundary.rpc` (hydrated
|
|
75
|
+
* refetch and client-first mount) without depending on this package or
|
|
76
|
+
* `@effect/rpc`.
|
|
77
|
+
*/
|
|
78
|
+
function RouterLive(def, options = {}) {
|
|
79
|
+
const core = Layer.scopedContext(Effect.gen(function* () {
|
|
80
|
+
const urlRef = yield* SubscriptionRef.make(locationUrl());
|
|
81
|
+
const navRef = yield* SubscriptionRef.make({ _tag: "Idle" });
|
|
82
|
+
const runtime = yield* Effect.runtime();
|
|
83
|
+
const httpApiClient = yield* HttpApiClient.make(def.httpApi, { baseUrl: options.baseUrl ?? window.location.origin }).pipe(Effect.provide(FetchHttpClient.layer));
|
|
84
|
+
let latest = 0;
|
|
85
|
+
const collectPreloads = (m) => m._tag !== "Matched" ? [] : [m.leaf.component, ...m.leaf.layoutChain.map((l) => l.component)].map(getPreload).filter((p) => p !== void 0);
|
|
86
|
+
const commitUrl = (normalized, replace) => Effect.sync(() => {
|
|
87
|
+
if (replace) window.history.replaceState(null, "", normalized);
|
|
88
|
+
else window.history.pushState(null, "", normalized);
|
|
89
|
+
});
|
|
90
|
+
const commitTo = (normalized, pushUrl, replace) => Effect.gen(function* () {
|
|
91
|
+
const preloads = collectPreloads(match(def, normalized));
|
|
92
|
+
if (preloads.length === 0) {
|
|
93
|
+
if (pushUrl) yield* commitUrl(normalized, replace);
|
|
94
|
+
yield* SubscriptionRef.set(urlRef, normalized);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const token = ++latest;
|
|
98
|
+
yield* SubscriptionRef.set(navRef, {
|
|
99
|
+
_tag: "Navigating",
|
|
100
|
+
to: normalized
|
|
101
|
+
});
|
|
102
|
+
yield* Effect.promise(() => Promise.all(preloads.map((p) => p()))).pipe(Effect.onError(() => token === latest ? SubscriptionRef.set(navRef, { _tag: "Idle" }) : Effect.void));
|
|
103
|
+
if (token !== latest) return;
|
|
104
|
+
if (pushUrl) yield* commitUrl(normalized, replace);
|
|
105
|
+
yield* SubscriptionRef.set(urlRef, normalized);
|
|
106
|
+
yield* SubscriptionRef.set(navRef, { _tag: "Idle" });
|
|
107
|
+
});
|
|
108
|
+
const navigate = (to, options) => commitTo(normalizeTo(to), true, options?.replace === true);
|
|
109
|
+
const onPopState = () => {
|
|
110
|
+
Runtime.runFork(runtime)(commitTo(locationUrl(), false, false));
|
|
111
|
+
};
|
|
112
|
+
yield* Effect.acquireRelease(Effect.sync(() => window.addEventListener("popstate", onPopState)), () => Effect.sync(() => window.removeEventListener("popstate", onPopState)));
|
|
113
|
+
yield* installLinkInterceptor(def, navigate);
|
|
114
|
+
const currentMatch = Subscribable.make({
|
|
115
|
+
get: Effect.map(SubscriptionRef.get(urlRef), (url) => match(def, url)),
|
|
116
|
+
changes: Stream.map(urlRef.changes, (url) => match(def, url))
|
|
117
|
+
});
|
|
118
|
+
const rpc = options.rpc;
|
|
119
|
+
let appRpcClient;
|
|
120
|
+
if (rpc === void 0) appRpcClient = AppRpcClientTag.of({ call: (tag) => Effect.fail(/* @__PURE__ */ new Error(`Boundary.rpc "${tag}" cannot resolve: no \`rpc\` option was passed to RouterLive`)) });
|
|
121
|
+
else {
|
|
122
|
+
const { RpcClient, RpcSerialization } = yield* Effect.promise(() => import("@effect/rpc"));
|
|
123
|
+
const baseUrl = String(options.baseUrl ?? window.location.origin).replace(/\/$/, "");
|
|
124
|
+
const flatClient = yield* RpcClient.make(rpc.group, { flatten: true }).pipe(Effect.provide(RpcClient.layerProtocolHttp({ url: `${baseUrl}${RPC_PATH}` }).pipe(Layer.provide(Layer.mergeAll(FetchHttpClient.layer, RpcSerialization.layerJson)))));
|
|
125
|
+
appRpcClient = AppRpcClientTag.of({ call: (tag, payload) => flatClient(tag, payload) });
|
|
126
|
+
}
|
|
127
|
+
const router = Router.of({
|
|
128
|
+
currentMatch,
|
|
129
|
+
navigate,
|
|
130
|
+
httpApiClient: Option.some(httpApiClient),
|
|
131
|
+
navigating: navRef
|
|
132
|
+
});
|
|
133
|
+
return Context.make(Router, router).pipe(Context.add(AppRpcClientTag, appRpcClient));
|
|
134
|
+
}));
|
|
135
|
+
const context = options.context;
|
|
136
|
+
return context === void 0 ? core : Layer.merge(core, context);
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/client/navigation.ts
|
|
140
|
+
/**
|
|
141
|
+
* Programmatic, type-safe navigation helpers built on the `Router` service and the
|
|
142
|
+
* type-safe {@link href} builder. They mirror the History API the client `Router`
|
|
143
|
+
* layer (`RouterLive`) is backed by:
|
|
144
|
+
*
|
|
145
|
+
* - {@link navigate} — go to a leaf route reference with typed `{ path, query }`.
|
|
146
|
+
* - {@link push} / {@link replace} — go to a raw `path + search` string.
|
|
147
|
+
* - {@link back} / {@link forward} — step through History (`history.go`).
|
|
148
|
+
* - {@link setQuery} / {@link patchQuery} — change the current route's query in
|
|
149
|
+
* place, re-encoding through the matched leaf's `querySchema`.
|
|
150
|
+
*
|
|
151
|
+
* All but `back`/`forward` require the `Router` service (run them within the layer
|
|
152
|
+
* provided by `RouterLive`); `back`/`forward` only touch `window.history`.
|
|
153
|
+
*/
|
|
154
|
+
/**
|
|
155
|
+
* Navigates to a leaf route `ref` with typed `path`/`query` args, building the URL
|
|
156
|
+
* via {@link href} (so it round-trips with `match`) and pushing — or, with
|
|
157
|
+
* `options.replace`, replacing — the History entry. `path` is required when the
|
|
158
|
+
* route has path params; `query` is optional when every query field is optional
|
|
159
|
+
* (same requiredness rules as `href`).
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```ts
|
|
163
|
+
* yield* navigate(userRoute, { path: { id: 42 } });
|
|
164
|
+
* yield* navigate(userRoute, { path: { id: 42 }, query: { tab: "posts" } }, { replace: true });
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
function navigate(ref, ...args) {
|
|
168
|
+
const [hrefArgs, options] = args;
|
|
169
|
+
const to = href(ref, hrefArgs);
|
|
170
|
+
return Effect.flatMap(Router, (router) => router.navigate(to, options));
|
|
171
|
+
}
|
|
172
|
+
/** Navigates to a raw `path + search` string, pushing a new History entry. */
|
|
173
|
+
const push = (to) => Effect.flatMap(Router, (router) => router.navigate(to));
|
|
174
|
+
/** Navigates to a raw `path + search` string, replacing the current History entry. */
|
|
175
|
+
const replace = (to) => Effect.flatMap(Router, (router) => router.navigate(to, { replace: true }));
|
|
176
|
+
/** Steps one entry back in History (`history.go(-1)`); the `popstate` handler resyncs. */
|
|
177
|
+
const back = () => Effect.sync(() => window.history.go(-1));
|
|
178
|
+
/** Steps one entry forward in History (`history.go(1)`); the `popstate` handler resyncs. */
|
|
179
|
+
const forward = () => Effect.sync(() => window.history.go(1));
|
|
180
|
+
/** Encodes an already-encoded query record into a key-sorted search string (mirrors `href`). */
|
|
181
|
+
function encodeSearch(encoded) {
|
|
182
|
+
const params = new URLSearchParams();
|
|
183
|
+
for (const key of Object.keys(encoded).sort()) {
|
|
184
|
+
const value = encoded[key];
|
|
185
|
+
if (value !== void 0 && value !== null) params.append(key, String(value));
|
|
186
|
+
}
|
|
187
|
+
return params.toString();
|
|
188
|
+
}
|
|
189
|
+
/** The path portion (without the search) of a normalized match URL. */
|
|
190
|
+
function pathOf(url) {
|
|
191
|
+
const qIndex = url.indexOf("?");
|
|
192
|
+
return qIndex === -1 ? url : url.slice(0, qIndex);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Navigates within the current route, transforming its decoded query and
|
|
196
|
+
* re-encoding through the matched leaf's `querySchema`. The path is preserved (so
|
|
197
|
+
* the leaf stays mounted; reactive `Router.queryStream` readers update in place).
|
|
198
|
+
* A no-op when no route is currently matched.
|
|
199
|
+
*/
|
|
200
|
+
function applyQuery(transform, options) {
|
|
201
|
+
return Effect.gen(function* () {
|
|
202
|
+
const router = yield* Router;
|
|
203
|
+
const match = yield* router.currentMatch.get;
|
|
204
|
+
if (match._tag !== "Matched") return;
|
|
205
|
+
const next = transform(match.query);
|
|
206
|
+
const search = encodeSearch(Schema.encodeUnknownSync(match.leaf.querySchema)(next));
|
|
207
|
+
const path = pathOf(match.url);
|
|
208
|
+
yield* router.navigate(search.length > 0 ? `${path}?${search}` : path, options);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Replaces the current route's query entirely with `query` (re-encoded through the
|
|
213
|
+
* matched leaf's `querySchema`), keeping the path. Pass `{}` to clear the query.
|
|
214
|
+
*/
|
|
215
|
+
const setQuery = (query, options) => applyQuery(() => query, options);
|
|
216
|
+
/**
|
|
217
|
+
* Merges `partial` into the current route's decoded query (re-encoded through the
|
|
218
|
+
* matched leaf's `querySchema`), keeping the path and any unspecified query fields.
|
|
219
|
+
*/
|
|
220
|
+
const patchQuery = (partial, options) => applyQuery((current) => ({
|
|
221
|
+
...current,
|
|
222
|
+
...partial
|
|
223
|
+
}), options);
|
|
224
|
+
//#endregion
|
|
225
|
+
export { Router, RouterApp, RouterLive, outletNode as RouterOutlet, outletNode, back, forward, installLinkInterceptor, navigate, patchQuery, push, replace, setQuery };
|
|
@@ -121,6 +121,19 @@ declare function match(def: RouterDef, url: string): RouteMatch;
|
|
|
121
121
|
* (`RouterLive`), absent (`Option.none`) on the server, which is itself the origin.
|
|
122
122
|
*/
|
|
123
123
|
type RouterHttpApiClient = HttpApiClient.Client<any, any, never>;
|
|
124
|
+
/**
|
|
125
|
+
* The client navigation state exposed reactively as {@link Router.navigating}. A
|
|
126
|
+
* navigation to a `Router.lazy` route is **deferred-commit** (see
|
|
127
|
+
* `pending-navigation.specs.md`): while its chunk resolves the state is
|
|
128
|
+
* `Navigating` (carrying the target `to`), returning to `Idle` on commit. An
|
|
129
|
+
* eager-route navigation stays `Idle`. Always `Idle` on the server (buffered render).
|
|
130
|
+
*/
|
|
131
|
+
type NavState = {
|
|
132
|
+
readonly _tag: "Idle";
|
|
133
|
+
} | {
|
|
134
|
+
readonly _tag: "Navigating";
|
|
135
|
+
readonly to: string;
|
|
136
|
+
};
|
|
124
137
|
/** Options for a router {@link Router} `navigate` call. */
|
|
125
138
|
interface NavigateOptions {
|
|
126
139
|
/**
|
|
@@ -145,6 +158,13 @@ declare const Router_base: Context.TagClass<Router, "@weftui/router/Router", {
|
|
|
145
158
|
* it stays local via the shared matcher (see the refactor _Feasibility constraint_).
|
|
146
159
|
*/
|
|
147
160
|
readonly httpApiClient: Option.Option<RouterHttpApiClient>;
|
|
161
|
+
/**
|
|
162
|
+
* Reactive client navigation state (see {@link NavState}). `Navigating{to}` while
|
|
163
|
+
* a deferred-commit navigation resolves its lazy chunk(s), `Idle` otherwise. An
|
|
164
|
+
* app reads it via {@link Router.navigatingStream} to render pending UI (e.g. a
|
|
165
|
+
* top progress bar). Constant `Idle` on the server.
|
|
166
|
+
*/
|
|
167
|
+
readonly navigating: Subscribable.Subscribable<NavState>;
|
|
148
168
|
}>;
|
|
149
169
|
declare class Router extends Router_base {}
|
|
150
170
|
declare const OutletTag_base: Context.TagClass<OutletTag, "@weftui/router/Outlet", Node<never, never>>;
|
|
@@ -198,6 +218,8 @@ declare namespace Router {
|
|
|
198
218
|
const route: typeof makeRoute;
|
|
199
219
|
/** Declares a layout wrapping an injected outlet. See {@link makeLayout}. */
|
|
200
220
|
const layout: typeof makeLayout;
|
|
221
|
+
/** Wraps a dynamic-import loader as a lazy component slot. See {@link lazyComponent}. */
|
|
222
|
+
const lazy: typeof lazyComponent;
|
|
201
223
|
/** Seals a route tree into a `RouterDef`. See {@link makeRouter}. */
|
|
202
224
|
const router: typeof makeRouter;
|
|
203
225
|
/** The injected outlet service value (yieldable Tag). See {@link OutletTag}. */
|
|
@@ -212,6 +234,8 @@ declare namespace Router {
|
|
|
212
234
|
const paramsStream: typeof subscribeParams;
|
|
213
235
|
/** Reactive {@link Subscribable} of the live match's query. See {@link subscribeQuery}. */
|
|
214
236
|
const queryStream: typeof subscribeQuery;
|
|
237
|
+
/** Reactive {@link Subscribable} of the client navigation state. See {@link subscribeNavigating}. */
|
|
238
|
+
const navigatingStream: Effect.Effect<Subscribable.Subscribable<NavState, never, never>, never, Router>;
|
|
215
239
|
}
|
|
216
240
|
//#endregion
|
|
217
241
|
//#region src/route-tree.d.ts
|
|
@@ -375,6 +399,28 @@ declare function makeRoute<Path extends Fields = {}, Query extends Fields = {},
|
|
|
375
399
|
declare function makeLayout<C extends readonly TreeNode[], S extends ComponentSlot = ComponentSlot>(config: {
|
|
376
400
|
readonly component: S;
|
|
377
401
|
}, children: C): LayoutNode<Node.Error<SlotNode<S>> | SubtreeE<C>, Exclude<Node.Context<SlotNode<S>>, Router.Outlet> | SubtreeR<C>>;
|
|
402
|
+
/**
|
|
403
|
+
* Wraps a dynamic-import `load` as a lazy {@link ComponentSlot}: the route's descriptor
|
|
404
|
+
* (`segment`, `path`/`query`) stays eager and matchable, while the component — the render
|
|
405
|
+
* body and its module's deps — is split into the chunk `load` resolves. The router invokes
|
|
406
|
+
* the returned slot at render time; it awaits `load` then renders the resolved component,
|
|
407
|
+
* adopting the server DOM in place on hydration (flash-free) and fetching the chunk on
|
|
408
|
+
* client navigation. Exposed as {@link Router.lazy}. See `lazy-component.specs.md`.
|
|
409
|
+
*
|
|
410
|
+
* The resolved value is a component slot (`Component.gen` / `Component.make`, or a
|
|
411
|
+
* `() => Node` thunk) — the shape `component:` already accepts — so its `E`/`R` channels
|
|
412
|
+
* are recovered via {@link SlotNode} and propagate up the tree exactly as an eager
|
|
413
|
+
* component's do.
|
|
414
|
+
*
|
|
415
|
+
* @example
|
|
416
|
+
* ```ts
|
|
417
|
+
* Router.route("docs/:category/:slug", {
|
|
418
|
+
* path: { category: Schema.String, slug: Schema.String },
|
|
419
|
+
* component: Router.lazy(() => import("./doc-page").then((m) => m.DocPage)),
|
|
420
|
+
* });
|
|
421
|
+
* ```
|
|
422
|
+
*/
|
|
423
|
+
declare function lazyComponent<S extends ComponentSlot>(load: () => Promise<S>): () => Node<Node.Error<SlotNode<S>>, Node.Context<SlotNode<S>>>;
|
|
378
424
|
//#endregion
|
|
379
425
|
//#region src/compile.d.ts
|
|
380
426
|
/**
|
|
@@ -505,4 +551,4 @@ declare function buildHttpApi(leaves: readonly CompiledLeaf[]): HttpApi.HttpApi.
|
|
|
505
551
|
*/
|
|
506
552
|
declare function makeRouter<T extends TreeNode, NF extends Node<any, any> = Node>(root: T, options: RouterOptions<NF>): RouterDef<TreeE<T> | Node.Error<NF>, TreeR<T> | Node.Context<NF>>;
|
|
507
553
|
//#endregion
|
|
508
|
-
export {
|
|
554
|
+
export { notFound as A, RouterHttpApiClient as C, RouterNotFound as D, match as E, RouterParamsError as O, Router as S, compileMatchers as T, TreeE as _, RouterOptions as a, NavState as b, leafRegistry as c, FieldsType as d, LayoutNode as f, SubtreeR as g, SubtreeE as h, RouterDef as i, isRouterNotFound as k, ComponentSlot as l, RouteNode as m, CompiledLayout as n, buildHttpApi as o, RouteHandlerProps as p, CompiledLeaf as r, compile as s, Compiled as t, Fields as u, TreeNode as v, RouteMatch as w, NavigateOptions as x, TreeR as y };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { s as leafRegistry } from "./outlet-CXgDkheJ.js";
|
|
2
|
+
import { Either, Option, Schema } from "effect";
|
|
3
|
+
//#region src/matcher.ts
|
|
4
|
+
/** Escapes a literal path segment for inclusion in a `RegExp`. */
|
|
5
|
+
function escapeRegex(literal) {
|
|
6
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7
|
+
}
|
|
8
|
+
/** The `:name` placeholder names in a path template, in order. */
|
|
9
|
+
function paramNamesOf(pattern) {
|
|
10
|
+
return pattern.split("/").filter((s) => s.startsWith(":")).map((s) => s.slice(1));
|
|
11
|
+
}
|
|
12
|
+
/** Number of `:param` segments in a pattern; fewer params = more specific (M6). */
|
|
13
|
+
function paramCount(pattern) {
|
|
14
|
+
return pattern.split("/").filter((s) => s.startsWith(":")).length;
|
|
15
|
+
}
|
|
16
|
+
/** Builds a regex matching a full pattern, tolerating an optional trailing slash (M3). */
|
|
17
|
+
function patternToRegex(pattern) {
|
|
18
|
+
const body = pattern.split("/").filter((s) => s.length > 0).map((part) => part.startsWith(":") ? "([^/]+)" : escapeRegex(part)).join("/");
|
|
19
|
+
return new RegExp(body.length === 0 ? "^/?$" : `^/${body}/?$`);
|
|
20
|
+
}
|
|
21
|
+
/** Empty-struct fallback for an endpoint with no declared path/query schema. */
|
|
22
|
+
const emptySchema = Schema.Struct({});
|
|
23
|
+
/**
|
|
24
|
+
* Memoizes the compiled matcher entries per {@link RouterDef} so the regexes are
|
|
25
|
+
* built and sorted once, not on every `match()` call (which is on the hot path:
|
|
26
|
+
* every navigation, every link-interceptor click, and once per server request).
|
|
27
|
+
*/
|
|
28
|
+
const matchersCache = /* @__PURE__ */ new WeakMap();
|
|
29
|
+
/**
|
|
30
|
+
* Precompiles a {@link RouterDef} into ordered matcher entries (memoized per
|
|
31
|
+
* `RouterDef`). The patterns and path/query schemas are read from the authoritative
|
|
32
|
+
* `def.httpApi` `"pages"` endpoints — the single source of truth the server dispatch
|
|
33
|
+
* also reads — and each entry's render metadata leaf is resolved from `def.compiled`
|
|
34
|
+
* by endpoint id. Matching stays local (SPA URL→leaf); see the refactor plan's
|
|
35
|
+
* _Feasibility constraint_.
|
|
36
|
+
*
|
|
37
|
+
* Entries are sorted most-specific first (fewer params, then longer pattern) so a
|
|
38
|
+
* static segment wins over a param segment at the same position (M6).
|
|
39
|
+
*
|
|
40
|
+
* Note: the specificity order is a global heuristic (param count, then length).
|
|
41
|
+
* It resolves the common "static beats param at the same position" case, but two
|
|
42
|
+
* patterns with the same param count and length (e.g. `/a/:b/c` vs `/a/x/:d`)
|
|
43
|
+
* fall back to endpoint order.
|
|
44
|
+
*/
|
|
45
|
+
function compileMatchers(def) {
|
|
46
|
+
const cached = matchersCache.get(def);
|
|
47
|
+
if (cached !== void 0) return cached;
|
|
48
|
+
const leafById = /* @__PURE__ */ new Map();
|
|
49
|
+
for (const leaf of def.compiled.leaves) leafById.set(leaf.id, leaf);
|
|
50
|
+
const endpoints = def.httpApi.groups["pages"]?.endpoints ?? {};
|
|
51
|
+
const entries = [];
|
|
52
|
+
for (const endpoint of Object.values(endpoints)) {
|
|
53
|
+
const leaf = leafById.get(endpoint.name);
|
|
54
|
+
if (leaf === void 0) continue;
|
|
55
|
+
entries.push({
|
|
56
|
+
leaf,
|
|
57
|
+
regex: patternToRegex(endpoint.path),
|
|
58
|
+
paramNames: paramNamesOf(endpoint.path),
|
|
59
|
+
pathSchema: Option.getOrElse(endpoint.pathSchema, () => emptySchema),
|
|
60
|
+
querySchema: Option.getOrElse(endpoint.urlParamsSchema, () => emptySchema)
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
entries.sort((a, b) => {
|
|
64
|
+
const pc = paramCount(a.leaf.fullPathPattern) - paramCount(b.leaf.fullPathPattern);
|
|
65
|
+
if (pc !== 0) return pc;
|
|
66
|
+
return b.leaf.fullPathPattern.length - a.leaf.fullPathPattern.length;
|
|
67
|
+
});
|
|
68
|
+
matchersCache.set(def, entries);
|
|
69
|
+
return entries;
|
|
70
|
+
}
|
|
71
|
+
/** Splits a request URL into its normalized path and raw query string. */
|
|
72
|
+
function splitUrl(url) {
|
|
73
|
+
const hashIndex = url.indexOf("#");
|
|
74
|
+
const withoutHash = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
75
|
+
const qIndex = withoutHash.indexOf("?");
|
|
76
|
+
const rawPath = qIndex === -1 ? withoutHash : withoutHash.slice(0, qIndex);
|
|
77
|
+
const search = qIndex === -1 ? "" : withoutHash.slice(qIndex + 1);
|
|
78
|
+
let path = rawPath.length === 0 ? "/" : rawPath;
|
|
79
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
80
|
+
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
|
|
81
|
+
return {
|
|
82
|
+
path,
|
|
83
|
+
search
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Parses a raw query string into a flat record (last value wins on repeat). */
|
|
87
|
+
function parseQuery(search) {
|
|
88
|
+
const record = {};
|
|
89
|
+
if (search.length === 0) return record;
|
|
90
|
+
for (const [key, value] of new URLSearchParams(search)) record[key] = value;
|
|
91
|
+
return record;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Matches a request URL against a {@link RouterDef} (M1–M7). Returns the decoded
|
|
95
|
+
* `Matched` leaf, or `NotFound` when nothing matches or a path/query decode fails
|
|
96
|
+
* (decode failure is treated as no-match, not an error). Patterns and schemas come
|
|
97
|
+
* from `def.httpApi` via {@link compileMatchers}.
|
|
98
|
+
*/
|
|
99
|
+
function match(def, url) {
|
|
100
|
+
const entries = compileMatchers(def);
|
|
101
|
+
const { path, search } = splitUrl(url);
|
|
102
|
+
const normalizedUrl = search.length === 0 ? path : `${path}?${search}`;
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const m = entry.regex.exec(path);
|
|
105
|
+
if (m === null) continue;
|
|
106
|
+
const rawParams = {};
|
|
107
|
+
entry.paramNames.forEach((name, i) => {
|
|
108
|
+
const raw = m[i + 1];
|
|
109
|
+
if (raw !== void 0) rawParams[name] = decodeURIComponent(raw);
|
|
110
|
+
});
|
|
111
|
+
const decodedPath = Schema.decodeUnknownEither(entry.pathSchema)(rawParams);
|
|
112
|
+
if (Either.isLeft(decodedPath)) continue;
|
|
113
|
+
const decodedQuery = Schema.decodeUnknownEither(entry.querySchema)(parseQuery(search));
|
|
114
|
+
if (Either.isLeft(decodedQuery)) continue;
|
|
115
|
+
return {
|
|
116
|
+
_tag: "Matched",
|
|
117
|
+
leaf: entry.leaf,
|
|
118
|
+
path: decodedPath.right,
|
|
119
|
+
query: decodedQuery.right,
|
|
120
|
+
url: normalizedUrl
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
_tag: "NotFound",
|
|
125
|
+
url: normalizedUrl
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/href.ts
|
|
130
|
+
/**
|
|
131
|
+
* Builds a type-safe URL for a leaf route reference (the value returned by
|
|
132
|
+
* {@link route}). Path params are encoded into the pattern and query values are
|
|
133
|
+
* encoded into a key-sorted search string (H1–H4). Round-trips with `match`.
|
|
134
|
+
*
|
|
135
|
+
* The leaf must belong to a tree that has been sealed with `Router.router()`
|
|
136
|
+
* (which stamps the leaf registry); otherwise an error is thrown.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```ts
|
|
140
|
+
* const userRoute = Router.route("users/:id", {
|
|
141
|
+
* path: { id: Schema.NumberFromString },
|
|
142
|
+
* component: …,
|
|
143
|
+
* });
|
|
144
|
+
* Router.router(Router.layout({ component: … }, [userRoute]), { notFound });
|
|
145
|
+
* href(userRoute, { path: { id: 42 } }); // "/users/42"
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
function href(ref, ...args) {
|
|
149
|
+
const leaf = leafRegistry.get(ref);
|
|
150
|
+
if (leaf === void 0) throw new Error("href: route has not been compiled. Seal the tree with Router.router() before calling href().");
|
|
151
|
+
const { path = {}, query = {} } = args[0] ?? {};
|
|
152
|
+
const encodedPath = Schema.encodeUnknownSync(leaf.pathSchema)(path);
|
|
153
|
+
let url = leaf.fullPathPattern.replace(/:([A-Za-z0-9_]+)/g, (_match, name) => encodeURIComponent(String(encodedPath[name])));
|
|
154
|
+
const encodedQuery = Schema.encodeUnknownSync(leaf.querySchema)(query);
|
|
155
|
+
const params = new URLSearchParams();
|
|
156
|
+
for (const key of Object.keys(encodedQuery).sort()) {
|
|
157
|
+
const value = encodedQuery[key];
|
|
158
|
+
if (value !== void 0 && value !== null) params.append(key, String(value));
|
|
159
|
+
}
|
|
160
|
+
const search = params.toString();
|
|
161
|
+
if (search.length > 0) url = `${url}?${search}`;
|
|
162
|
+
return url;
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
165
|
+
export { compileMatchers as n, match as r, href as t };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { C as
|
|
2
|
-
import { i as href, n as outletNode, r as HrefArgs, t as RouterApp } from "./outlet-
|
|
3
|
-
export { type Compiled, type CompiledLayout, type CompiledLeaf, type ComponentSlot, type Fields, type FieldsType, type HrefArgs, type LayoutNode, type NavigateOptions, type RouteHandlerProps, type RouteMatch, type RouteNode, Router, RouterApp, type RouterDef, type RouterHttpApiClient, RouterNotFound, type RouterOptions, RouterParamsError, type SubtreeE, type SubtreeR, type TreeE, type TreeNode, type TreeR, buildHttpApi, compile, compileMatchers, href, isRouterNotFound, leafRegistry, match, notFound, outletNode };
|
|
1
|
+
import { A as notFound, C as RouterHttpApiClient, D as RouterNotFound, E as match, O as RouterParamsError, S as Router, T as compileMatchers, _ as TreeE, a as RouterOptions, b as NavState, c as leafRegistry, d as FieldsType, f as LayoutNode, g as SubtreeR, h as SubtreeE, i as RouterDef, k as isRouterNotFound, l as ComponentSlot, m as RouteNode, n as CompiledLayout, o as buildHttpApi, p as RouteHandlerProps, r as CompiledLeaf, s as compile, t as Compiled, u as Fields, v as TreeNode, w as RouteMatch, x as NavigateOptions, y as TreeR } from "./compile-HOyeyWRy.js";
|
|
2
|
+
import { i as href, n as outletNode, r as HrefArgs, t as RouterApp } from "./outlet-DbqTgkXa.js";
|
|
3
|
+
export { type Compiled, type CompiledLayout, type CompiledLeaf, type ComponentSlot, type Fields, type FieldsType, type HrefArgs, type LayoutNode, type NavState, type NavigateOptions, type RouteHandlerProps, type RouteMatch, type RouteNode, Router, RouterApp, type RouterDef, type RouterHttpApiClient, RouterNotFound, type RouterOptions, RouterParamsError, type SubtreeE, type SubtreeR, type TreeE, type TreeNode, type TreeR, buildHttpApi, compile, compileMatchers, href, isRouterNotFound, leafRegistry, match, notFound, outletNode };
|
package/dist/index.js
CHANGED
|
@@ -1 +1,3 @@
|
|
|
1
|
-
import{a as
|
|
1
|
+
import { a as buildHttpApi, c as RouterNotFound, d as notFound, l as RouterParamsError, n as outletNode, o as compile, r as Router, s as leafRegistry, t as RouterApp, u as isRouterNotFound } from "./outlet-CXgDkheJ.js";
|
|
2
|
+
import { n as compileMatchers, r as match, t as href } from "./href-CSRbOQov.js";
|
|
3
|
+
export { Router, RouterApp, RouterNotFound, RouterParamsError, buildHttpApi, compile, compileMatchers, href, isRouterNotFound, leafRegistry, match, notFound, outletNode };
|