@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.
@@ -1,10 +1,32 @@
1
- import { b as NavigateOptions, i as RouterDef, m as RouteNode, u as Fields, x as Router } from "../compile-C0JShTTR.js";
2
- import { n as outletNode, r as HrefArgs, t as RouterApp } from "../outlet-BBEjKu3z.js";
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 };
@@ -1 +1,225 @@
1
- import{n as e,r as t,t as n}from"../outlet-C7p8KXmO.js";import{r,t as i}from"../href-Dl30XcF4.js";import{FetchHttpClient as a,HttpApiClient as o}from"@effect/platform";import{Context as s,Effect as c,Layer as l,Option as u,Runtime as d,Schema as f,Stream as p,Subscribable as m,SubscriptionRef as h}from"effect";import{AppRpcClientTag as g}from"@weftui/core";import{RpcClient as _,RpcSerialization as v}from"@effect/rpc";function y(e,t){return c.gen(function*(){let n=yield*c.runtime(),i=i=>{if(i.defaultPrevented||i.button!==0||i.metaKey||i.ctrlKey||i.shiftKey||i.altKey)return;let a=i.target,o=a instanceof Element?a.closest(`a`):null;if(o===null)return;let s=o.getAttribute(`target`);if(o.hasAttribute(`download`)||s!==null&&s!==`_self`||o.getAttribute(`rel`)===`external`)return;let c=o.getAttribute(`href`);if(c===null||c.length===0)return;let l;try{l=new URL(c,window.location.href)}catch{return}if(l.origin!==window.location.origin)return;let u=`${l.pathname}${l.search}`;u!==`${window.location.pathname}${window.location.search}`&&r(e,u)._tag===`Matched`&&(i.preventDefault(),d.runFork(n)(t(u)))};yield*c.acquireRelease(c.sync(()=>document.addEventListener(`click`,i)),()=>c.sync(()=>document.removeEventListener(`click`,i)))})}function b(){return`${window.location.pathname}${window.location.search}`}function x(e){let t=new URL(e,window.location.href);return`${t.pathname}${t.search}`}function S(e,n={}){return l.scopedContext(c.gen(function*(){let i=yield*h.make(b()),f=yield*c.runtime(),S=yield*o.make(e.httpApi,{baseUrl:n.baseUrl??window.location.origin}).pipe(c.provide(a.layer)),C=()=>{d.runFork(f)(h.set(i,b()))};yield*c.acquireRelease(c.sync(()=>window.addEventListener(`popstate`,C)),()=>c.sync(()=>window.removeEventListener(`popstate`,C)));let w=(e,t)=>c.gen(function*(){let n=x(e);yield*c.sync(()=>{t?.replace===!0?window.history.replaceState(null,``,n):window.history.pushState(null,``,n)}),yield*h.set(i,n)});yield*y(e,w);let T=m.make({get:c.map(h.get(i),t=>r(e,t)),changes:p.map(i.changes,t=>r(e,t))}),E=n.rpc,D;if(E===void 0)D=g.of({call:e=>c.fail(Error(`Boundary.rpc "${e}" cannot resolve: no \`rpc\` option was passed to RouterLive`))});else{let e=String(n.baseUrl??window.location.origin).replace(/\/$/,``),t=yield*_.make(E.group,{flatten:!0}).pipe(c.provide(_.layerProtocolHttp({url:`${e}/_eui/rpc`}).pipe(l.provide(l.mergeAll(a.layer,v.layerJson)))));D=g.of({call:(e,n)=>t(e,n)})}let O=t.of({currentMatch:T,navigate:w,httpApiClient:u.some(S)});return s.make(t,O).pipe(s.add(g,D))}))}function C(e,...n){let[r,a]=n,o=i(e,r);return c.flatMap(t,e=>e.navigate(o,a))}const w=e=>c.flatMap(t,t=>t.navigate(e)),T=e=>c.flatMap(t,t=>t.navigate(e,{replace:!0})),E=()=>c.sync(()=>window.history.go(-1)),D=()=>c.sync(()=>window.history.go(1));function O(e){let t=new URLSearchParams;for(let n of Object.keys(e).sort()){let r=e[n];r!=null&&t.append(n,String(r))}return t.toString()}function k(e){let t=e.indexOf(`?`);return t===-1?e:e.slice(0,t)}function A(e,n){return c.gen(function*(){let r=yield*t,i=yield*r.currentMatch.get;if(i._tag!==`Matched`)return;let a=e(i.query),o=O(f.encodeUnknownSync(i.leaf.querySchema)(a)),s=k(i.url);yield*r.navigate(o.length>0?`${s}?${o}`:s,n)})}const j=(e,t)=>A(()=>e,t),M=(e,t)=>A(t=>({...t,...e}),t);export{t as Router,n as RouterApp,S as RouterLive,e as RouterOutlet,e as outletNode,E as back,D as forward,y as installLinkInterceptor,C as navigate,M as patchQuery,w as push,T as replace,j as setQuery};
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 { RouteMatch as C, RouterParamsError as D, RouterNotFound as E, isRouterNotFound as O, RouterHttpApiClient as S, match as T, TreeE as _, RouterOptions as a, NavigateOptions as b, leafRegistry as c, FieldsType as d, LayoutNode as f, SubtreeR as g, SubtreeE as h, RouterDef as i, notFound 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, compileMatchers as w, Router as x, TreeR as y };
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 RouteMatch, D as RouterParamsError, E as RouterNotFound, O as isRouterNotFound, S as RouterHttpApiClient, T as match, _ as TreeE, a as RouterOptions, b as NavigateOptions, c as leafRegistry, d as FieldsType, f as LayoutNode, g as SubtreeR, h as SubtreeE, i as RouterDef, k as notFound, 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 compileMatchers, x as Router, y as TreeR } from "./compile-C0JShTTR.js";
2
- import { i as href, n as outletNode, r as HrefArgs, t as RouterApp } from "./outlet-BBEjKu3z.js";
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 e,c as t,i as n,l as r,n as i,o as a,r as o,s,t as c,u as l}from"./outlet-C7p8KXmO.js";import{n as u,r as d,t as f}from"./href-Dl30XcF4.js";export{o as Router,c as RouterApp,s as RouterNotFound,t as RouterParamsError,n as buildHttpApi,e as compile,u as compileMatchers,f as href,r as isRouterNotFound,a as leafRegistry,d as match,l as notFound,i as outletNode};
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 };