@unitflow/router 0.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/README.md +154 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/public.d.ts +11 -0
- package/dist/public.d.ts.map +1 -0
- package/dist/public.js +21 -0
- package/dist/public.js.map +1 -0
- package/dist/react.d.ts +125 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +150 -0
- package/dist/react.js.map +1 -0
- package/dist/router-group.d.ts +2 -0
- package/dist/router-group.d.ts.map +1 -0
- package/dist/router-group.js +2 -0
- package/dist/router-group.js.map +1 -0
- package/dist/router.d.ts +469 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +870 -0
- package/dist/router.js.map +1 -0
- package/package.json +46 -0
package/dist/router.js
ADDED
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
import { Event, Model, Store } from "@unitflow/core";
|
|
2
|
+
import { Registry } from "@unitflow/core/registry";
|
|
3
|
+
import * as Context from "effect/Context";
|
|
4
|
+
import * as Cause from "effect/Cause";
|
|
5
|
+
import * as Data from "effect/Data";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as Exit from "effect/Exit";
|
|
8
|
+
import * as Layer from "effect/Layer";
|
|
9
|
+
import * as Option from "effect/Option";
|
|
10
|
+
import { pipeArguments } from "effect/Pipeable";
|
|
11
|
+
import * as Schema from "effect/Schema";
|
|
12
|
+
const RouteTypeId = Symbol.for("@unitflow/router/Route");
|
|
13
|
+
const RouteGroupTypeId = Symbol.for("@unitflow/router/RouteGroup");
|
|
14
|
+
const PipeableProto = {
|
|
15
|
+
pipe() {
|
|
16
|
+
return pipeArguments(this, arguments);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
export const search = (decode, options) => ({
|
|
20
|
+
...PipeableProto,
|
|
21
|
+
decode,
|
|
22
|
+
...(options?.encode === undefined ? {} : { encode: options.encode }),
|
|
23
|
+
"~input": undefined,
|
|
24
|
+
"~output": undefined,
|
|
25
|
+
"~error": undefined,
|
|
26
|
+
"~services": undefined,
|
|
27
|
+
});
|
|
28
|
+
export const schemaSearch = (schema, options) => search((raw) => Schema.decodeUnknownEffect(schema)(raw, options));
|
|
29
|
+
export const Middleware = () => (id) => () => {
|
|
30
|
+
const Service = Context.Service()(id);
|
|
31
|
+
return class extends Service {
|
|
32
|
+
static make = (handler) => Layer.effect(Service, Effect.map(Effect.context(), (services) => (context) =>
|
|
33
|
+
// The captured context covers exactly `R`; the cast erases
|
|
34
|
+
// the generic TypeScript cannot discharge here.
|
|
35
|
+
// eslint-disable-next-line revizo/no-type-assertion
|
|
36
|
+
Effect.provide(handler(context), services)));
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
export const isRoute = (value) => typeof value === "object" && value !== null && RouteTypeId in value;
|
|
40
|
+
export const route = (id, options) => ({
|
|
41
|
+
...PipeableProto,
|
|
42
|
+
[RouteTypeId]: RouteTypeId,
|
|
43
|
+
id,
|
|
44
|
+
path: normalizePath(options.path),
|
|
45
|
+
options: { ...options, path: normalizePath(options.path) },
|
|
46
|
+
middlewares: [],
|
|
47
|
+
"~types": undefined,
|
|
48
|
+
});
|
|
49
|
+
const prefixRoute = (current, prefix) => {
|
|
50
|
+
const path = joinPaths(prefix, current.path);
|
|
51
|
+
// Spread (not `route(...)`) so `middlewares` and the pipe method survive.
|
|
52
|
+
return {
|
|
53
|
+
...current,
|
|
54
|
+
path,
|
|
55
|
+
options: { ...current.options, path },
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
const RouteGroupProto = {
|
|
59
|
+
add(...added) {
|
|
60
|
+
return group(...this.routes, ...added);
|
|
61
|
+
},
|
|
62
|
+
merge(...groups) {
|
|
63
|
+
return group(...this.routes, ...groups.flatMap((current) => current.routes));
|
|
64
|
+
},
|
|
65
|
+
prefix(prefix) {
|
|
66
|
+
return group(...this.routes.map((current) => prefixRoute(current, prefix)));
|
|
67
|
+
},
|
|
68
|
+
middleware(middleware) {
|
|
69
|
+
return group(...this.routes.map((current) => ({
|
|
70
|
+
...current,
|
|
71
|
+
middlewares: [...current.middlewares, middleware],
|
|
72
|
+
})));
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
export const group = (...routes) => Object.assign(Object.create(PipeableProto), RouteGroupProto, {
|
|
76
|
+
[RouteGroupTypeId]: RouteGroupTypeId,
|
|
77
|
+
routes,
|
|
78
|
+
});
|
|
79
|
+
export const makeGroup = group;
|
|
80
|
+
export const add = (...routes) => group(...routes);
|
|
81
|
+
export const merge = (...groups) => group(...groups.flatMap((current) => current.routes));
|
|
82
|
+
export const prefix = (routeGroup, path) => routeGroup.prefix(path);
|
|
83
|
+
export const routes = (routeGroup) => routeGroup.routes;
|
|
84
|
+
/**
|
|
85
|
+
* The history capability, provided as a LAYER — `Router.make` declares
|
|
86
|
+
* routes only, the environment decides how locations are read and written:
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* AppRouter.layer.pipe(Layer.provideMerge(Router.browserHistoryLayer))
|
|
90
|
+
* // tests:
|
|
91
|
+
* AppRouter.layer.pipe(Layer.provideMerge(Router.memoryHistoryLayer({ initialEntries: ["/"] })))
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export class History extends Context.Service()("@unitflow/router/History") {
|
|
95
|
+
}
|
|
96
|
+
export const browserHistoryLayer = Layer.succeed(History, History.of({ make: (options) => createBrowserHistory(options) }));
|
|
97
|
+
export const hashHistoryLayer = Layer.succeed(History, History.of({ make: (options) => createHashHistory(options) }));
|
|
98
|
+
export const memoryHistoryLayer = (options) => Layer.succeed(History, History.of({
|
|
99
|
+
make: ({ parseSearch }) => createMemoryHistory(options?.initialEntries === undefined
|
|
100
|
+
? { parseSearch }
|
|
101
|
+
: { initialEntries: options.initialEntries, parseSearch }),
|
|
102
|
+
}));
|
|
103
|
+
/** A middleware/codec verdict, NOT a crash: it travels the typed error
|
|
104
|
+
* channel purely as the short-circuit — `navigate` catches it and follows
|
|
105
|
+
* the redirect before anything commits. */
|
|
106
|
+
export class RedirectError extends Data.TaggedError("RedirectError") {
|
|
107
|
+
}
|
|
108
|
+
export class NotFoundError extends Data.TaggedError("NotFoundError") {
|
|
109
|
+
}
|
|
110
|
+
export const isRedirectError = (value) => value instanceof RedirectError;
|
|
111
|
+
export const isNotFoundError = (value) => value instanceof NotFoundError;
|
|
112
|
+
export const make = (id, routeGroup, options = {}) => {
|
|
113
|
+
// The `routes` model's per-key claim — `Model.get(router.routes, K)`
|
|
114
|
+
// returns THE route with id K — is only sound if ids are unique: the unit
|
|
115
|
+
// resolves its route by `find(route.id === key)`. Enforce the
|
|
116
|
+
// precondition here instead of silently matching the first duplicate.
|
|
117
|
+
const seenIds = new Set();
|
|
118
|
+
for (const current of routeGroup.routes) {
|
|
119
|
+
if (seenIds.has(current.id)) {
|
|
120
|
+
throw new Error(`Unitflow router "${id}" has duplicate route id "${current.id}".`);
|
|
121
|
+
}
|
|
122
|
+
seenIds.add(current.id);
|
|
123
|
+
}
|
|
124
|
+
const service = Model.Service()(id)({
|
|
125
|
+
lifetime: "keepAlive",
|
|
126
|
+
make: () => makeShape(routeGroup, options),
|
|
127
|
+
});
|
|
128
|
+
const router = Object.assign(service, {
|
|
129
|
+
group: routeGroup,
|
|
130
|
+
options,
|
|
131
|
+
routerType: undefined,
|
|
132
|
+
});
|
|
133
|
+
// The per-key shape map is carried by the RouteModel interface itself
|
|
134
|
+
// (the cast below) rather than the builder's `Shapes` argument: with
|
|
135
|
+
// `Group` still generic TypeScript cannot verify the mapped-type
|
|
136
|
+
// constraint and overload resolution falls apart.
|
|
137
|
+
const routesService = Model.Service()(`${id}/routes`)()({
|
|
138
|
+
make: (routeId) => Effect.gen(function* () {
|
|
139
|
+
const ports = yield* Model.get(router);
|
|
140
|
+
const match = Store.combine([ports.outputs.matches], (matches) => Option.fromNullishOr(matches.find((current) => current.route.id === routeId)), { name: `router.routes.${routeId}.match` });
|
|
141
|
+
const opened = Store.combine([match], Option.isSome, {
|
|
142
|
+
name: `router.routes.${routeId}.opened`,
|
|
143
|
+
});
|
|
144
|
+
const params = Store.combine([match], Option.map((current) => current.params), {
|
|
145
|
+
name: `router.routes.${routeId}.params`,
|
|
146
|
+
});
|
|
147
|
+
const search = Store.combine([match], Option.map((current) => current.search), {
|
|
148
|
+
name: `router.routes.${routeId}.search`,
|
|
149
|
+
});
|
|
150
|
+
const provided = Store.combine([match], Option.map((current) => current.provided), {
|
|
151
|
+
name: `router.routes.${routeId}.provided`,
|
|
152
|
+
});
|
|
153
|
+
return {
|
|
154
|
+
inputs: {},
|
|
155
|
+
outputs: { opened, params, search, provided },
|
|
156
|
+
ui: { opened, params, search, provided },
|
|
157
|
+
};
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
const routes = routesService;
|
|
161
|
+
const model = Object.assign(router, {
|
|
162
|
+
buildLocation: (options) => Effect.flatMap(getController(router), (api) => api.buildLocationEffect(options)),
|
|
163
|
+
buildHref: (options) => Effect.flatMap(getController(router), (api) => api.buildHrefEffect(options)),
|
|
164
|
+
});
|
|
165
|
+
return { NavigationModel: model, RouteModel: routes };
|
|
166
|
+
};
|
|
167
|
+
/** INTERNAL (used by RouterView): the pages model — one singleton owning
|
|
168
|
+
* the router and every mapped page model, the view tree's root. */
|
|
169
|
+
export const makePages = (model, pageMap) => {
|
|
170
|
+
const pagesService = Model.Service()(`${model.modelKey}/pages`)({
|
|
171
|
+
make: () => Effect.gen(function* () {
|
|
172
|
+
// The engine is a singleton (void key); the erased generic hides that.
|
|
173
|
+
// eslint-disable-next-line revizo/no-type-assertion
|
|
174
|
+
const routerPorts = yield* Model.get(model);
|
|
175
|
+
const ui = { router: routerPorts };
|
|
176
|
+
for (const [routeId, pageModel] of Object.entries(pageMap)) {
|
|
177
|
+
if (pageModel !== undefined) {
|
|
178
|
+
ui[routeId] = yield* Model.get(pageModel);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { inputs: {}, outputs: {}, ui };
|
|
182
|
+
}),
|
|
183
|
+
});
|
|
184
|
+
return Object.assign(pagesService, { router: model });
|
|
185
|
+
};
|
|
186
|
+
const makeShape = (routeGroup, options) => Effect.gen(function* () {
|
|
187
|
+
const parseSearch = options.parseSearch ?? defaultParseSearch;
|
|
188
|
+
const stringifySearch = options.stringifySearch ?? defaultStringifySearch;
|
|
189
|
+
const history = (yield* History).make({ parseSearch });
|
|
190
|
+
const compiled = routeGroup.routes.map(compileRoute).sort((a, b) => b.score - a.score);
|
|
191
|
+
const blockers = new Set();
|
|
192
|
+
let disposed = false;
|
|
193
|
+
let ignoreNextHistory = false;
|
|
194
|
+
let currentState = {
|
|
195
|
+
status: "idle",
|
|
196
|
+
location: history.location,
|
|
197
|
+
resolvedLocation: history.location,
|
|
198
|
+
matches: [],
|
|
199
|
+
};
|
|
200
|
+
const state = Store.make(currentState, {
|
|
201
|
+
name: "router.state",
|
|
202
|
+
});
|
|
203
|
+
const setState = (next) => Effect.flatMap(Effect.sync(() => {
|
|
204
|
+
currentState = next;
|
|
205
|
+
}), () => Store.set(state, next));
|
|
206
|
+
const runMaybe = (evaluate) => Effect.flatMap(Effect.try({
|
|
207
|
+
try: evaluate,
|
|
208
|
+
catch: (error) => error,
|
|
209
|
+
}), (value) => Effect.isEffect(value)
|
|
210
|
+
? value
|
|
211
|
+
: Effect.succeed(value));
|
|
212
|
+
const decodeSearch = (current, raw) => {
|
|
213
|
+
const definition = current.options.search;
|
|
214
|
+
if (definition === undefined)
|
|
215
|
+
return Effect.succeed({});
|
|
216
|
+
if (isSchemaCodec(definition)) {
|
|
217
|
+
return Schema.decodeUnknownEffect(definition)(raw);
|
|
218
|
+
}
|
|
219
|
+
return runMaybe(() => definition.decode(raw));
|
|
220
|
+
};
|
|
221
|
+
const parseParams = (current, raw) => {
|
|
222
|
+
const schema = current.options.params;
|
|
223
|
+
if (schema !== undefined) {
|
|
224
|
+
return Schema.decodeUnknownEffect(schema)(raw);
|
|
225
|
+
}
|
|
226
|
+
const parse = current.options.parseParams;
|
|
227
|
+
if (parse === undefined)
|
|
228
|
+
return Effect.succeed(raw);
|
|
229
|
+
return runMaybe(() => parse(raw));
|
|
230
|
+
};
|
|
231
|
+
const resolveMatches = (location) => Effect.gen(function* () {
|
|
232
|
+
const pathname = stripBasepath(location.pathname, options.basepath);
|
|
233
|
+
const exactMatches = compiled
|
|
234
|
+
.map((compiledRoute) => ({ compiledRoute, match: compiledRoute.exact.exec(pathname) }))
|
|
235
|
+
.filter((item) => item.match !== null)
|
|
236
|
+
.sort((a, b) => b.compiledRoute.score - a.compiledRoute.score);
|
|
237
|
+
const leaf = exactMatches[0];
|
|
238
|
+
if (leaf === undefined)
|
|
239
|
+
return yield* Effect.fail(new NotFoundError({}));
|
|
240
|
+
const branch = compiled
|
|
241
|
+
.map((compiledRoute) => ({ compiledRoute, match: compiledRoute.prefix.exec(pathname) }))
|
|
242
|
+
.filter((item) => item.match !== null)
|
|
243
|
+
.sort((a, b) => a.compiledRoute.length - b.compiledRoute.length ||
|
|
244
|
+
a.compiledRoute.score - b.compiledRoute.score);
|
|
245
|
+
const matches = [];
|
|
246
|
+
// Guard results accumulate parent-first down the branch; a guard
|
|
247
|
+
// shared by several routes in the branch runs once per navigation.
|
|
248
|
+
let provided = {};
|
|
249
|
+
const ranGuards = new Set();
|
|
250
|
+
for (const item of branch) {
|
|
251
|
+
const rawParams = extractParams(item.compiledRoute, item.match);
|
|
252
|
+
const params = yield* parseParams(item.compiledRoute.route, rawParams);
|
|
253
|
+
if (params === false)
|
|
254
|
+
continue;
|
|
255
|
+
const routeSearch = yield* decodeSearch(item.compiledRoute.route, location.search);
|
|
256
|
+
const routeContext = {
|
|
257
|
+
route: item.compiledRoute.route,
|
|
258
|
+
location,
|
|
259
|
+
params,
|
|
260
|
+
search: routeSearch,
|
|
261
|
+
path: item.compiledRoute.route.path,
|
|
262
|
+
};
|
|
263
|
+
// Guards run parent-first, before anything commits: a failure here
|
|
264
|
+
// (RedirectError/NotFoundError) aborts the whole navigation.
|
|
265
|
+
for (const middleware of item.compiledRoute.route.middlewares) {
|
|
266
|
+
if (ranGuards.has(middleware))
|
|
267
|
+
continue;
|
|
268
|
+
ranGuards.add(middleware);
|
|
269
|
+
const handler = yield* middleware;
|
|
270
|
+
const value = yield* handler(routeContext);
|
|
271
|
+
if (value !== undefined && value !== null && typeof value === "object") {
|
|
272
|
+
provided = { ...provided, ...value };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const meta = item.compiledRoute.route.options.meta?.(routeContext) ?? [];
|
|
276
|
+
const links = item.compiledRoute.route.options.links?.(routeContext) ?? [];
|
|
277
|
+
matches.push({
|
|
278
|
+
id: item.compiledRoute.route.id,
|
|
279
|
+
route: item.compiledRoute.route,
|
|
280
|
+
pathname: item.match[0] === "" ? "/" : item.match[0],
|
|
281
|
+
params,
|
|
282
|
+
search: routeSearch,
|
|
283
|
+
provided,
|
|
284
|
+
staticData: item.compiledRoute.route.options.staticData ?? {},
|
|
285
|
+
meta,
|
|
286
|
+
links,
|
|
287
|
+
status: "success",
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (!matches.some((match) => match.route === leaf.compiledRoute.route)) {
|
|
291
|
+
return yield* Effect.fail(new NotFoundError({}));
|
|
292
|
+
}
|
|
293
|
+
return matches;
|
|
294
|
+
});
|
|
295
|
+
const load = (location, displayLocation) => Effect.gen(function* () {
|
|
296
|
+
yield* setState({
|
|
297
|
+
...currentState,
|
|
298
|
+
status: "pending",
|
|
299
|
+
pendingLocation: displayLocation,
|
|
300
|
+
});
|
|
301
|
+
return yield* resolveMatches(location);
|
|
302
|
+
});
|
|
303
|
+
const encodeParams = (targetRoute, params) => {
|
|
304
|
+
const source = params === true || params === undefined ? {} : params;
|
|
305
|
+
const schema = targetRoute.options.params;
|
|
306
|
+
if (schema !== undefined) {
|
|
307
|
+
return Effect.map(Schema.encodeUnknownEffect(schema)(source), (encoded) => toPathParams(encoded));
|
|
308
|
+
}
|
|
309
|
+
if (targetRoute.options.stringifyParams !== undefined) {
|
|
310
|
+
return runMaybe(() => targetRoute.options.stringifyParams?.(source) ?? {});
|
|
311
|
+
}
|
|
312
|
+
return Effect.succeed(toPathParams(source));
|
|
313
|
+
};
|
|
314
|
+
const encodeSearch = (targetRoute, searchInput) => {
|
|
315
|
+
if (searchInput === true)
|
|
316
|
+
return Effect.succeed(currentState.location.search);
|
|
317
|
+
const resolvedSearch = resolveSearchInput(searchInput, currentState.location.search);
|
|
318
|
+
const definition = targetRoute.options.search;
|
|
319
|
+
if (definition === undefined)
|
|
320
|
+
return Effect.succeed(toRawSearch(resolvedSearch));
|
|
321
|
+
if (isSchemaCodec(definition)) {
|
|
322
|
+
return Effect.map(Schema.encodeUnknownEffect(definition)(resolvedSearch), (encoded) => toRawSearch(encoded));
|
|
323
|
+
}
|
|
324
|
+
if (definition.encode === undefined)
|
|
325
|
+
return Effect.succeed(toRawSearch(resolvedSearch));
|
|
326
|
+
return Effect.succeed(definition.encode(resolvedSearch));
|
|
327
|
+
};
|
|
328
|
+
const buildLocationEffect = (toOptions) => Effect.gen(function* () {
|
|
329
|
+
const foundRoute = routeGroup.routes.find((current) => current.path === toOptions.to);
|
|
330
|
+
if (foundRoute === undefined) {
|
|
331
|
+
return yield* Effect.fail(new Error(`Unitflow router cannot build unknown route "${String(toOptions.to)}".`));
|
|
332
|
+
}
|
|
333
|
+
const targetRoute = foundRoute;
|
|
334
|
+
const routeParams = yield* encodeParams(targetRoute, toOptions.params);
|
|
335
|
+
const pathname = yield* Effect.try({
|
|
336
|
+
try: () => addBasepath(interpolatePath(targetRoute.path, routeParams), options.basepath),
|
|
337
|
+
catch: (error) => error,
|
|
338
|
+
});
|
|
339
|
+
const rawSearch = yield* encodeSearch(targetRoute, toOptions.search);
|
|
340
|
+
const searchString = stringifySearch(rawSearch);
|
|
341
|
+
const hash = toOptions.hash === true ? currentState.location.hash : toOptions.hash ?? "";
|
|
342
|
+
return makeLocation(`${pathname}${searchString}${hash === "" ? "" : `#${hash}`}`, parseSearch, toOptions.state);
|
|
343
|
+
});
|
|
344
|
+
const buildLocation = (toOptions) => {
|
|
345
|
+
const exit = Effect.runSyncExit(buildLocationEffect(toOptions));
|
|
346
|
+
if (Exit.isSuccess(exit))
|
|
347
|
+
return exit.value;
|
|
348
|
+
throw exitToError(exit);
|
|
349
|
+
};
|
|
350
|
+
const buildHref = (toOptions) => buildLocation(toOptions).href;
|
|
351
|
+
const buildHrefEffect = (toOptions) => Effect.map(buildLocationEffect(toOptions), (location) => location.href);
|
|
352
|
+
const shouldBlock = (to, ignoreBlocker) => Effect.gen(function* () {
|
|
353
|
+
if (ignoreBlocker === true)
|
|
354
|
+
return false;
|
|
355
|
+
for (const blocker of blockers) {
|
|
356
|
+
const blocked = yield* Effect.tryPromise({
|
|
357
|
+
try: () => Promise.resolve(blocker({ from: currentState.location, to })),
|
|
358
|
+
catch: (error) => error,
|
|
359
|
+
});
|
|
360
|
+
if (blocked)
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
return false;
|
|
364
|
+
});
|
|
365
|
+
const handleError = (error, location, resolvedLocation) => setState({
|
|
366
|
+
status: isNotFoundError(error) ? "not-found" : "error",
|
|
367
|
+
location: location ?? currentState.location,
|
|
368
|
+
resolvedLocation: resolvedLocation ?? currentState.resolvedLocation,
|
|
369
|
+
matches: location === undefined ? currentState.matches : [],
|
|
370
|
+
error,
|
|
371
|
+
});
|
|
372
|
+
const commitLocation = (location) => Effect.exit(load(location, location)).pipe(Effect.flatMap((exit) => {
|
|
373
|
+
if (Exit.isSuccess(exit)) {
|
|
374
|
+
return setState({
|
|
375
|
+
status: "success",
|
|
376
|
+
location,
|
|
377
|
+
resolvedLocation: location,
|
|
378
|
+
matches: exit.value,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
const error = exitToError(exit);
|
|
382
|
+
// A middleware redirect on the initial load or a history pop
|
|
383
|
+
// follows through instead of surfacing as an error state.
|
|
384
|
+
if (isRedirectError(error)) {
|
|
385
|
+
return navigate({ ...error.options, replace: true });
|
|
386
|
+
}
|
|
387
|
+
return handleError(error, location, location);
|
|
388
|
+
}));
|
|
389
|
+
const navigate = (navigateOptions) => Effect.gen(function* () {
|
|
390
|
+
const resolvedLocationExit = yield* Effect.exit(buildLocationEffect(navigateOptions));
|
|
391
|
+
if (Exit.isFailure(resolvedLocationExit)) {
|
|
392
|
+
yield* handleError(exitToError(resolvedLocationExit));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const resolvedLocation = resolvedLocationExit.value;
|
|
396
|
+
const displayLocationExit = navigateOptions.mask === undefined
|
|
397
|
+
? Exit.succeed(resolvedLocation)
|
|
398
|
+
: yield* Effect.exit(buildLocationEffect(navigateOptions.mask));
|
|
399
|
+
if (Exit.isFailure(displayLocationExit)) {
|
|
400
|
+
yield* handleError(exitToError(displayLocationExit));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const displayLocation = displayLocationExit.value;
|
|
404
|
+
const blockedExit = yield* Effect.exit(shouldBlock(displayLocation, navigateOptions.ignoreBlocker));
|
|
405
|
+
if (Exit.isFailure(blockedExit)) {
|
|
406
|
+
yield* handleError(exitToError(blockedExit));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (blockedExit.value)
|
|
410
|
+
return;
|
|
411
|
+
if (navigateOptions.reloadDocument === true && typeof window !== "undefined") {
|
|
412
|
+
window.location.assign(displayLocation.href);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const exit = yield* Effect.exit(load(resolvedLocation, displayLocation));
|
|
416
|
+
if (Exit.isSuccess(exit)) {
|
|
417
|
+
ignoreNextHistory = true;
|
|
418
|
+
if (navigateOptions.replace === true)
|
|
419
|
+
history.replace(displayLocation.href, displayLocation.state);
|
|
420
|
+
else
|
|
421
|
+
history.push(displayLocation.href, displayLocation.state);
|
|
422
|
+
yield* setState({
|
|
423
|
+
status: "success",
|
|
424
|
+
location: displayLocation,
|
|
425
|
+
resolvedLocation,
|
|
426
|
+
matches: exit.value,
|
|
427
|
+
});
|
|
428
|
+
if (navigateOptions.resetScroll !== false && typeof window !== "undefined") {
|
|
429
|
+
window.scrollTo(0, 0);
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const error = exitToError(exit);
|
|
434
|
+
if (isRedirectError(error)) {
|
|
435
|
+
yield* navigate({ ...error.options, replace: true });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
yield* handleError(error);
|
|
439
|
+
});
|
|
440
|
+
const matchRoute = (matchOptions) => {
|
|
441
|
+
const location = buildLocation(matchOptions);
|
|
442
|
+
const current = currentState.resolvedLocation;
|
|
443
|
+
const pathMatches = matchOptions.exact === true
|
|
444
|
+
? current.pathname === location.pathname
|
|
445
|
+
: current.pathname === location.pathname ||
|
|
446
|
+
current.pathname.startsWith(`${trimRight(location.pathname)}/`);
|
|
447
|
+
if (!pathMatches)
|
|
448
|
+
return false;
|
|
449
|
+
if (matchOptions.includeHash === true && current.hash !== location.hash)
|
|
450
|
+
return false;
|
|
451
|
+
if (matchOptions.includeSearch === true && !searchEquals(current.search, location.search))
|
|
452
|
+
return false;
|
|
453
|
+
return true;
|
|
454
|
+
};
|
|
455
|
+
const addBlocker = (blocker) => Effect.sync(() => {
|
|
456
|
+
blockers.add(blocker);
|
|
457
|
+
});
|
|
458
|
+
const removeBlocker = (blocker) => Effect.sync(() => {
|
|
459
|
+
blockers.delete(blocker);
|
|
460
|
+
});
|
|
461
|
+
const dispose = () => Effect.sync(() => {
|
|
462
|
+
disposed = true;
|
|
463
|
+
blockers.clear();
|
|
464
|
+
});
|
|
465
|
+
const controller = {
|
|
466
|
+
group: routeGroup,
|
|
467
|
+
routes: routeGroup.routes,
|
|
468
|
+
options,
|
|
469
|
+
history,
|
|
470
|
+
buildLocation,
|
|
471
|
+
buildHref,
|
|
472
|
+
buildLocationEffect,
|
|
473
|
+
buildHrefEffect,
|
|
474
|
+
matchRoute,
|
|
475
|
+
};
|
|
476
|
+
const navigateEvent = yield* Event.make({ name: "router.navigate" }).pipe(Event.handler((payload) => navigate(payload)));
|
|
477
|
+
const addBlockerEvent = yield* Event.make({ name: "router.addBlocker" }).pipe(Event.handler((blocker) => addBlocker(blocker)));
|
|
478
|
+
const removeBlockerEvent = yield* Event.make({ name: "router.removeBlocker" }).pipe(Event.handler((blocker) => removeBlocker(blocker)));
|
|
479
|
+
const location = Store.combine([state], (value) => value.location, {
|
|
480
|
+
name: "router.location",
|
|
481
|
+
});
|
|
482
|
+
const matches = Store.combine([state], (value) => value.matches, {
|
|
483
|
+
name: "router.matches",
|
|
484
|
+
});
|
|
485
|
+
const api = Store.make(controller, { name: "router.api" });
|
|
486
|
+
const historyChanged = yield* Event.make({ name: "router.historyChanged" }).pipe(Event.handler((nextLocation) => (disposed ? Effect.void : commitLocation(nextLocation))));
|
|
487
|
+
const registry = yield* Registry;
|
|
488
|
+
const historyChannel = yield* Event.pubsub(historyChanged);
|
|
489
|
+
const unsubscribeHistory = history.subscribe((nextLocation) => {
|
|
490
|
+
if (ignoreNextHistory) {
|
|
491
|
+
ignoreNextHistory = false;
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
// The FULL dispatch step: counting, pubsub AND handler delivery. A
|
|
495
|
+
// bare trackPublish+publishUnsafe pair feeds subscribers but never the
|
|
496
|
+
// attached handler — history-driven navigation (back/forward, manual
|
|
497
|
+
// URL) would silently do nothing.
|
|
498
|
+
Event.dispatchUnsafe(registry, historyChannel, historyChanged, nextLocation);
|
|
499
|
+
});
|
|
500
|
+
yield* Effect.addFinalizer(() => Effect.flatMap(Effect.sync(() => {
|
|
501
|
+
unsubscribeHistory();
|
|
502
|
+
}), () => dispose()));
|
|
503
|
+
yield* commitLocation(history.location);
|
|
504
|
+
return {
|
|
505
|
+
inputs: {
|
|
506
|
+
navigate: navigateEvent,
|
|
507
|
+
addBlocker: addBlockerEvent,
|
|
508
|
+
removeBlocker: removeBlockerEvent,
|
|
509
|
+
},
|
|
510
|
+
outputs: {
|
|
511
|
+
state,
|
|
512
|
+
location,
|
|
513
|
+
matches,
|
|
514
|
+
api,
|
|
515
|
+
},
|
|
516
|
+
ui: {
|
|
517
|
+
state,
|
|
518
|
+
location,
|
|
519
|
+
matches,
|
|
520
|
+
navigate: navigateEvent,
|
|
521
|
+
api,
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
});
|
|
525
|
+
const exitToError = (exit) => {
|
|
526
|
+
if (Exit.isSuccess(exit))
|
|
527
|
+
return undefined;
|
|
528
|
+
const failure = exit.cause.reasons.find(Cause.isFailReason);
|
|
529
|
+
return failure === undefined ? exit.cause : failure.error;
|
|
530
|
+
};
|
|
531
|
+
const getController = (router) => Effect.gen(function* () {
|
|
532
|
+
const ports = yield* Model.get(router);
|
|
533
|
+
return yield* Store.get(ports.outputs.api);
|
|
534
|
+
});
|
|
535
|
+
export const createMemoryHistory = (options) => {
|
|
536
|
+
const parseSearch = options?.parseSearch ?? defaultParseSearch;
|
|
537
|
+
const entries = options?.initialEntries?.length ? [...options.initialEntries] : ["/"];
|
|
538
|
+
let index = 0;
|
|
539
|
+
let current = makeLocation(entries[index] ?? "/", parseSearch);
|
|
540
|
+
const listeners = new Set();
|
|
541
|
+
const notify = () => {
|
|
542
|
+
for (const listener of listeners)
|
|
543
|
+
listener(current);
|
|
544
|
+
};
|
|
545
|
+
return {
|
|
546
|
+
get location() {
|
|
547
|
+
return current;
|
|
548
|
+
},
|
|
549
|
+
push(href, state) {
|
|
550
|
+
entries.splice(index + 1, entries.length - index - 1, href);
|
|
551
|
+
index += 1;
|
|
552
|
+
current = makeLocation(href, parseSearch, state);
|
|
553
|
+
notify();
|
|
554
|
+
},
|
|
555
|
+
replace(href, state) {
|
|
556
|
+
entries[index] = href;
|
|
557
|
+
current = makeLocation(href, parseSearch, state);
|
|
558
|
+
notify();
|
|
559
|
+
},
|
|
560
|
+
subscribe(listener) {
|
|
561
|
+
listeners.add(listener);
|
|
562
|
+
return () => {
|
|
563
|
+
listeners.delete(listener);
|
|
564
|
+
};
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
};
|
|
568
|
+
export const createBrowserHistory = (options) => {
|
|
569
|
+
if (typeof window === "undefined")
|
|
570
|
+
return createMemoryHistory(options);
|
|
571
|
+
const parseSearch = options?.parseSearch ?? defaultParseSearch;
|
|
572
|
+
let current = makeLocation(`${window.location.pathname}${window.location.search}${window.location.hash}`, parseSearch, window.history.state);
|
|
573
|
+
const listeners = new Set();
|
|
574
|
+
const notify = () => {
|
|
575
|
+
for (const listener of listeners)
|
|
576
|
+
listener(current);
|
|
577
|
+
};
|
|
578
|
+
const onPopState = (event) => {
|
|
579
|
+
current = makeLocation(`${window.location.pathname}${window.location.search}${window.location.hash}`, parseSearch, event.state);
|
|
580
|
+
notify();
|
|
581
|
+
};
|
|
582
|
+
window.addEventListener("popstate", onPopState);
|
|
583
|
+
return {
|
|
584
|
+
get location() {
|
|
585
|
+
return current;
|
|
586
|
+
},
|
|
587
|
+
push(href, state) {
|
|
588
|
+
window.history.pushState(state, "", href);
|
|
589
|
+
current = makeLocation(href, parseSearch, state);
|
|
590
|
+
notify();
|
|
591
|
+
},
|
|
592
|
+
replace(href, state) {
|
|
593
|
+
window.history.replaceState(state, "", href);
|
|
594
|
+
current = makeLocation(href, parseSearch, state);
|
|
595
|
+
notify();
|
|
596
|
+
},
|
|
597
|
+
subscribe(listener) {
|
|
598
|
+
listeners.add(listener);
|
|
599
|
+
return () => {
|
|
600
|
+
listeners.delete(listener);
|
|
601
|
+
if (listeners.size === 0)
|
|
602
|
+
window.removeEventListener("popstate", onPopState);
|
|
603
|
+
};
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
};
|
|
607
|
+
export const createHashHistory = (options) => {
|
|
608
|
+
if (typeof window === "undefined")
|
|
609
|
+
return createMemoryHistory(options);
|
|
610
|
+
const parseSearch = options?.parseSearch ?? defaultParseSearch;
|
|
611
|
+
const read = () => {
|
|
612
|
+
const hash = window.location.hash.startsWith("#") ? window.location.hash.slice(1) : window.location.hash;
|
|
613
|
+
return makeLocation(hash === "" ? "/" : hash, parseSearch, window.history.state);
|
|
614
|
+
};
|
|
615
|
+
let current = read();
|
|
616
|
+
const listeners = new Set();
|
|
617
|
+
const notify = () => {
|
|
618
|
+
for (const listener of listeners)
|
|
619
|
+
listener(current);
|
|
620
|
+
};
|
|
621
|
+
const onHashChange = () => {
|
|
622
|
+
current = read();
|
|
623
|
+
notify();
|
|
624
|
+
};
|
|
625
|
+
window.addEventListener("hashchange", onHashChange);
|
|
626
|
+
return {
|
|
627
|
+
get location() {
|
|
628
|
+
return current;
|
|
629
|
+
},
|
|
630
|
+
push(href, state) {
|
|
631
|
+
window.history.pushState(state, "", `#${href}`);
|
|
632
|
+
current = makeLocation(href, parseSearch, state);
|
|
633
|
+
notify();
|
|
634
|
+
},
|
|
635
|
+
replace(href, state) {
|
|
636
|
+
window.history.replaceState(state, "", `#${href}`);
|
|
637
|
+
current = makeLocation(href, parseSearch, state);
|
|
638
|
+
notify();
|
|
639
|
+
},
|
|
640
|
+
subscribe(listener) {
|
|
641
|
+
listeners.add(listener);
|
|
642
|
+
return () => {
|
|
643
|
+
listeners.delete(listener);
|
|
644
|
+
if (listeners.size === 0)
|
|
645
|
+
window.removeEventListener("hashchange", onHashChange);
|
|
646
|
+
};
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
};
|
|
650
|
+
export const defaultParseSearch = (searchString) => {
|
|
651
|
+
const params = new URLSearchParams(searchString.startsWith("?") ? searchString.slice(1) : searchString);
|
|
652
|
+
const out = {};
|
|
653
|
+
for (const [key, value] of params) {
|
|
654
|
+
const decoded = decodeSearchValue(value);
|
|
655
|
+
const existing = out[key];
|
|
656
|
+
if (existing === undefined)
|
|
657
|
+
out[key] = decoded;
|
|
658
|
+
else
|
|
659
|
+
out[key] = Array.isArray(existing) ? [...existing, decoded] : [existing, decoded];
|
|
660
|
+
}
|
|
661
|
+
return out;
|
|
662
|
+
};
|
|
663
|
+
export const defaultStringifySearch = (searchRecord) => {
|
|
664
|
+
const params = new URLSearchParams();
|
|
665
|
+
const append = (key, value) => {
|
|
666
|
+
if (value === undefined)
|
|
667
|
+
return;
|
|
668
|
+
if (Array.isArray(value)) {
|
|
669
|
+
for (const item of value)
|
|
670
|
+
append(key, item);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
params.append(key, encodeSearchValue(value));
|
|
674
|
+
};
|
|
675
|
+
for (const key of Object.keys(searchRecord).sort())
|
|
676
|
+
append(key, searchRecord[key]);
|
|
677
|
+
const stringified = params.toString();
|
|
678
|
+
return stringified === "" ? "" : `?${stringified}`;
|
|
679
|
+
};
|
|
680
|
+
const isSchemaCodec = (value) => typeof value === "object" && value !== null && "ast" in value && !("decode" in value);
|
|
681
|
+
const toRawSearch = (value) => {
|
|
682
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
683
|
+
return {};
|
|
684
|
+
const record = value;
|
|
685
|
+
const out = {};
|
|
686
|
+
for (const [key, item] of Object.entries(record)) {
|
|
687
|
+
if (item === undefined) {
|
|
688
|
+
out[key] = undefined;
|
|
689
|
+
}
|
|
690
|
+
else if (Array.isArray(item)) {
|
|
691
|
+
out[key] = item.map((entry) => String(entry));
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
out[key] = String(item);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return out;
|
|
698
|
+
};
|
|
699
|
+
const toPathParams = (value) => {
|
|
700
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
701
|
+
return {};
|
|
702
|
+
const record = value;
|
|
703
|
+
const out = {};
|
|
704
|
+
for (const [key, item] of Object.entries(record)) {
|
|
705
|
+
if (item !== undefined && item !== null)
|
|
706
|
+
out[key] = String(item);
|
|
707
|
+
}
|
|
708
|
+
return out;
|
|
709
|
+
};
|
|
710
|
+
const decodeSearchValue = (value) => {
|
|
711
|
+
try {
|
|
712
|
+
const parsed = JSON.parse(value);
|
|
713
|
+
return typeof parsed === "string" ? parsed : value;
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
return value;
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
const encodeSearchValue = (value) => {
|
|
720
|
+
if (typeof value === "string")
|
|
721
|
+
return value;
|
|
722
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null) {
|
|
723
|
+
return JSON.stringify(value);
|
|
724
|
+
}
|
|
725
|
+
return JSON.stringify(value);
|
|
726
|
+
};
|
|
727
|
+
const makeLocation = (href, parseSearch, state) => {
|
|
728
|
+
const url = new URL(href, "http://unitflow.local");
|
|
729
|
+
const pathname = normalizePath(url.pathname);
|
|
730
|
+
const searchString = url.search;
|
|
731
|
+
const hash = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
|
|
732
|
+
return {
|
|
733
|
+
pathname,
|
|
734
|
+
search: parseSearch(searchString),
|
|
735
|
+
searchString,
|
|
736
|
+
hash,
|
|
737
|
+
href: `${pathname}${searchString}${hash === "" ? "" : `#${hash}`}`,
|
|
738
|
+
state,
|
|
739
|
+
};
|
|
740
|
+
};
|
|
741
|
+
const normalizePath = (path) => {
|
|
742
|
+
if (path === "")
|
|
743
|
+
return "/";
|
|
744
|
+
const withSlash = path.startsWith("/") ? path : `/${path}`;
|
|
745
|
+
return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
|
|
746
|
+
};
|
|
747
|
+
const trimRight = (path) => path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
|
748
|
+
const joinPaths = (prefixPath, path) => {
|
|
749
|
+
const left = normalizePath(prefixPath);
|
|
750
|
+
const right = normalizePath(path);
|
|
751
|
+
if (left === "/")
|
|
752
|
+
return right;
|
|
753
|
+
if (right === "/")
|
|
754
|
+
return left;
|
|
755
|
+
return `${trimRight(left)}${right}`;
|
|
756
|
+
};
|
|
757
|
+
const addBasepath = (path, basepath) => basepath === undefined || basepath === "/" ? path : joinPaths(basepath, path);
|
|
758
|
+
const stripBasepath = (path, basepath) => {
|
|
759
|
+
if (basepath === undefined || basepath === "/")
|
|
760
|
+
return path;
|
|
761
|
+
const normalized = normalizePath(basepath);
|
|
762
|
+
return path === normalized ? "/" : path.startsWith(`${normalized}/`) ? path.slice(normalized.length) : path;
|
|
763
|
+
};
|
|
764
|
+
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
765
|
+
const compileRoute = (current) => {
|
|
766
|
+
const path = normalizePath(current.path);
|
|
767
|
+
if (path === "/") {
|
|
768
|
+
return {
|
|
769
|
+
route: current,
|
|
770
|
+
exact: /^\/?$/,
|
|
771
|
+
prefix: /^\//,
|
|
772
|
+
paramNames: [],
|
|
773
|
+
score: 0,
|
|
774
|
+
length: 0,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
const segments = path.slice(1).split("/");
|
|
778
|
+
const paramNames = [];
|
|
779
|
+
let pattern = "^";
|
|
780
|
+
let score = 0;
|
|
781
|
+
for (const segment of segments) {
|
|
782
|
+
if (segment.startsWith(":")) {
|
|
783
|
+
const optional = segment.endsWith("?");
|
|
784
|
+
const name = segment.slice(1, optional ? -1 : undefined);
|
|
785
|
+
paramNames.push({ name, optional });
|
|
786
|
+
pattern += optional ? "(?:/([^/]+))?" : "/([^/]+)";
|
|
787
|
+
score += optional ? 3 : 5;
|
|
788
|
+
}
|
|
789
|
+
else if (segment.startsWith("*")) {
|
|
790
|
+
const name = segment.slice(1) || "_splat";
|
|
791
|
+
paramNames.push({ name, optional: false });
|
|
792
|
+
pattern += "/(.*)";
|
|
793
|
+
score += 1;
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
pattern += `/${escapeRegex(segment)}`;
|
|
797
|
+
score += 10;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const flags = current.options.caseSensitive === true ? "" : "i";
|
|
801
|
+
return {
|
|
802
|
+
route: current,
|
|
803
|
+
exact: new RegExp(`${pattern}/?$`, flags),
|
|
804
|
+
prefix: new RegExp(`${pattern}(?=/|$)`, flags),
|
|
805
|
+
paramNames,
|
|
806
|
+
score: score + segments.length,
|
|
807
|
+
length: segments.length,
|
|
808
|
+
};
|
|
809
|
+
};
|
|
810
|
+
const extractParams = (compiled, match) => {
|
|
811
|
+
const params = {};
|
|
812
|
+
compiled.paramNames.forEach((param, index) => {
|
|
813
|
+
const value = match[index + 1];
|
|
814
|
+
if (value !== undefined)
|
|
815
|
+
params[param.name] = decodeURIComponent(value);
|
|
816
|
+
});
|
|
817
|
+
return params;
|
|
818
|
+
};
|
|
819
|
+
const interpolatePath = (path, params) => {
|
|
820
|
+
const source = (params ?? {});
|
|
821
|
+
const segments = normalizePath(path).split("/");
|
|
822
|
+
const out = [];
|
|
823
|
+
for (const segment of segments) {
|
|
824
|
+
if (segment === "")
|
|
825
|
+
continue;
|
|
826
|
+
if (segment.startsWith(":")) {
|
|
827
|
+
const optional = segment.endsWith("?");
|
|
828
|
+
const name = segment.slice(1, optional ? -1 : undefined);
|
|
829
|
+
const value = source[name];
|
|
830
|
+
if (value === undefined || value === null) {
|
|
831
|
+
if (optional)
|
|
832
|
+
continue;
|
|
833
|
+
throw new Error(`Unitflow router missing path param "${name}" for "${path}".`);
|
|
834
|
+
}
|
|
835
|
+
out.push(encodeURIComponent(String(value)));
|
|
836
|
+
}
|
|
837
|
+
else if (segment.startsWith("*")) {
|
|
838
|
+
const name = segment.slice(1) || "_splat";
|
|
839
|
+
const value = source[name];
|
|
840
|
+
if (value === undefined || value === null) {
|
|
841
|
+
throw new Error(`Unitflow router missing splat param "${name}" for "${path}".`);
|
|
842
|
+
}
|
|
843
|
+
out.push(String(value).split("/").map(encodeURIComponent).join("/"));
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
out.push(segment);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return out.length === 0 ? "/" : `/${out.join("/")}`;
|
|
850
|
+
};
|
|
851
|
+
const resolveSearchInput = (searchInput, current) => {
|
|
852
|
+
if (searchInput === undefined)
|
|
853
|
+
return {};
|
|
854
|
+
if (searchInput === true)
|
|
855
|
+
return current;
|
|
856
|
+
return typeof searchInput === "function" ? searchInput(current) : searchInput;
|
|
857
|
+
};
|
|
858
|
+
const stableStringify = (value) => {
|
|
859
|
+
if (value === null || typeof value !== "object")
|
|
860
|
+
return JSON.stringify(value);
|
|
861
|
+
if (Array.isArray(value))
|
|
862
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
863
|
+
const record = value;
|
|
864
|
+
return `{${Object.keys(record)
|
|
865
|
+
.sort()
|
|
866
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
|
|
867
|
+
.join(",")}}`;
|
|
868
|
+
};
|
|
869
|
+
const searchEquals = (a, b) => stableStringify(a) === stableStringify(b);
|
|
870
|
+
//# sourceMappingURL=router.js.map
|