@valancex/router 0.12.0 → 0.13.1

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 CHANGED
@@ -26,4 +26,5 @@ await Web.run(app, { container, primitives, present: "mount", plugins: [router.p
26
26
  - `router.commands(state)` gives `navigate`, `go` and `replace` commands for the application's state.
27
27
  - Guards: `canMatch` (sync), `canActivate`, `canDeactivate`, and `beforeEach`; each answers a boolean, a `redirect(...)`, or a Promise/Effect of one. A refused Back/Forward is undone.
28
28
  - Redirects name a route, never a URL; `router.safeNext(address)` only accepts an address on the application.
29
- - Not yet: nested routes, a `beforeunload` guard, typed HTTP interceptors.
29
+ - `History`: the list of places visited and its moves (`History.start`, `push`, `replace`, `go`, `canGo`, `restore`), pure and serializable. A target with no history of its own (a window) keeps one in its state; `restore` drops entries that are no longer routes and starts again at the first when none is left. Guards are told why a move is made, including `"history"` for Back and Forward along such a list. `permit` runs a guard; one that throws or fails refuses. See `docs/application-model/NAVIGATION.md`.
30
+ - Not yet: nested routes, a `beforeunload` guard, a mobile adapter.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The list of places an application has been, and the position in it: the model every target's navigation is made of (docs/application-model/NAVIGATION.md). It is plain data and pure
3
+ * functions, so a target that owns its list (a window has none of its own) keeps it in the application's state, and one that does not (the web's session history, a phone's back stack)
4
+ * can still describe, validate and restore what it has with the same rules.
5
+ *
6
+ * An entry is whatever names a place as plain data: a page's address, or a `RouteState`.
7
+ */
8
+ export interface History<E> {
9
+ readonly entries: ReadonlyArray<E>;
10
+ /** The position of the current entry. */
11
+ readonly index: number;
12
+ }
13
+ /** A list that has only been to `entry`. */
14
+ export declare const start: <E>(entry: E) => History<E>;
15
+ /** The current entry. */
16
+ export declare const current: <E>(history: History<E>) => E;
17
+ /** Goes to `entry`: it comes after the current one, and whatever was ahead is dropped (as the web's `pushState` does). */
18
+ export declare const push: <E>(history: History<E>, entry: E) => History<E>;
19
+ /** Changes the current entry; nothing is added. */
20
+ export declare const replace: <E>(history: History<E>, entry: E) => History<E>;
21
+ /** Whether `distance` (-1 is back, +1 is forward) lands on an entry. A platform with no forward never offers a positive one. */
22
+ export declare const canGo: <E>(history: History<E>, distance: number) => boolean;
23
+ /** Moves along the list by `distance`. A move that would leave it changes nothing. */
24
+ export declare const go: <E>(history: History<E>, distance: number) => History<E>;
25
+ /**
26
+ * A list read back from somewhere (a store, the platform's restoration) is data from an earlier run, and the application may have changed since. Entries `valid` refuses are dropped; the
27
+ * position stays on the entry that was current, or the nearest one before it that is left (else the first); a list with nothing left starts again at `fallback`. Anything that is not a list
28
+ * of the right shape is `fallback` too, so a bad store is never a crash.
29
+ */
30
+ export declare const restore: <E>(saved: unknown, valid: (entry: unknown) => entry is E, fallback: E) => History<E>;
@@ -0,0 +1,37 @@
1
+ /** A list that has only been to `entry`. */
2
+ export const start = (entry) => ({ entries: [entry], index: 0 });
3
+ /** The current entry. */
4
+ export const current = (history) => history.entries[history.index];
5
+ /** Goes to `entry`: it comes after the current one, and whatever was ahead is dropped (as the web's `pushState` does). */
6
+ export const push = (history, entry) => ({ entries: [...history.entries.slice(0, history.index + 1), entry], index: history.index + 1 });
7
+ /** Changes the current entry; nothing is added. */
8
+ export const replace = (history, entry) => ({
9
+ entries: history.entries.map((each, at) => (at === history.index ? entry : each)),
10
+ index: history.index,
11
+ });
12
+ /** Whether `distance` (-1 is back, +1 is forward) lands on an entry. A platform with no forward never offers a positive one. */
13
+ export const canGo = (history, distance) => Number.isInteger(distance) && history.index + distance >= 0 && history.index + distance < history.entries.length;
14
+ /** Moves along the list by `distance`. A move that would leave it changes nothing. */
15
+ export const go = (history, distance) => (canGo(history, distance) ? { entries: history.entries, index: history.index + distance } : history);
16
+ /**
17
+ * A list read back from somewhere (a store, the platform's restoration) is data from an earlier run, and the application may have changed since. Entries `valid` refuses are dropped; the
18
+ * position stays on the entry that was current, or the nearest one before it that is left (else the first); a list with nothing left starts again at `fallback`. Anything that is not a list
19
+ * of the right shape is `fallback` too, so a bad store is never a crash.
20
+ */
21
+ export const restore = (saved, valid, fallback) => {
22
+ const shape = saved;
23
+ if (typeof shape !== "object" || shape === null || !Array.isArray(shape.entries) || typeof shape.index !== "number" || !Number.isInteger(shape.index)) {
24
+ return start(fallback);
25
+ }
26
+ const kept = [];
27
+ let position = -1;
28
+ shape.entries.forEach((entry, at) => {
29
+ if (valid(entry)) {
30
+ kept.push(entry);
31
+ if (at <= shape.index) {
32
+ position = kept.length - 1;
33
+ }
34
+ }
35
+ });
36
+ return kept.length === 0 ? start(fallback) : { entries: kept, index: Math.max(0, position) };
37
+ };
package/dist/index.d.ts CHANGED
@@ -2,7 +2,8 @@ export { createRouter } from "./router.js";
2
2
  export type { Router, RouterOptions } from "./router.js";
3
3
  export { route } from "./route.js";
4
4
  export type { Route, RouteConfig } from "./route.js";
5
- export { redirect } from "./guards.js";
5
+ export { redirect, runGuard as permit } from "./guards.js";
6
+ export * as History from "./history.js";
6
7
  export { RouteDefinitionError } from "./pattern.js";
7
8
  export { RouteState } from "./types.js";
8
9
  export type { Decision, Guard, GuardContext, GuardResult, NavigationKind, Redirect, TitleContext } from "./types.js";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { createRouter } from "./router.js";
2
2
  export { route } from "./route.js";
3
- export { redirect } from "./guards.js";
3
+ export { redirect, runGuard as permit } from "./guards.js";
4
+ export * as History from "./history.js";
4
5
  export { RouteDefinitionError } from "./pattern.js";
5
6
  export { RouteState } from "./types.js";
package/dist/router.d.ts CHANGED
@@ -35,6 +35,11 @@ export declare const createRouter: <S extends Record<string, any>, C = undefined
35
35
  readonly to: string;
36
36
  }, void, never, never>;
37
37
  };
38
+ moveTo: (state: Nexus.State.StateHandle<S>, to: string, options?: {
39
+ readonly replace?: boolean;
40
+ }) => Effect.Effect<boolean>;
41
+ mayLeave: (state: S) => Effect.Effect<boolean>;
42
+ enter: (address: string, state: S) => Effect.Effect<RouteState>;
38
43
  match: (address: string) => RouteState;
39
44
  href: <P, Q>(to: Route<P, Q, S, C> | string, params?: P | Readonly<Record<string, unknown>>, query?: Q | Readonly<Record<string, unknown>>) => string;
40
45
  hrefOf: (state: RouteState) => string;
package/dist/router.js CHANGED
@@ -367,7 +367,7 @@ export const createRouter = (options) => {
367
367
  * - `go` and `replace`: programmatic navigation to an address, `go({ to: "/inbox/42?tab=unread" })`; `replace` rewrites the current history entry instead of adding one. Each decides first (the guard
368
368
  * for leaving, then the one for entering), then sets the state; a refusal leaves it where it is. They take one argument so a template can call them: `on.click={go("/inbox")}`.
369
369
  */
370
- const moveTo = (state, to, replace) => Effect.gen(function* () {
370
+ const goTo = (state, to, replace) => Effect.gen(function* () {
371
371
  const target = resolve(to);
372
372
  if (target === undefined) {
373
373
  yield* Effect.logWarning("the address is not on this application");
@@ -381,10 +381,46 @@ export const createRouter = (options) => {
381
381
  const next = decision._tag === "Redirect" ? { ...decision.to, replace: true } : { ...decision.to, ...(replace ? { replace: true } : {}) };
382
382
  yield* state.update((latest) => Effect.succeed({ ...latest, [key]: next }));
383
383
  });
384
+ /**
385
+ * The route an application starts on, for an address it was opened at (a deep link, a saved session, the address the server rendered): the address's route after the entering guards have had their
386
+ * say. `match` alone does not ask them, so a link to a list that no longer exists would start the application on a screen with nothing to show. A redirect is followed; a refusal starts on the
387
+ * not-found route. Nothing is being left, so no `canDeactivate` runs. Give it the state the application will start with (guards read it).
388
+ */
389
+ const enter = (address, state) => Effect.gen(function* () {
390
+ const target = resolve(address) ?? notFoundState(new URL("/", FALLBACK_ORIGIN));
391
+ const decision = yield* decide({ from: undefined, to: target, state, kind: "initial" });
392
+ return decision._tag === "Refuse" ? notFoundState(new URL(address.startsWith("/") ? address : "/", FALLBACK_ORIGIN)) : strip(decision.to);
393
+ });
394
+ /**
395
+ * Goes to an address from inside a command: the same decision `go` makes (the guard for leaving, then the one for entering; a redirect is followed, a refusal leaves the application where it is),
396
+ * as an Effect a command can run after or before its own changes. It answers whether the application moved. A command that saves and then leaves should save first: the leaving guard sees
397
+ * the state as it is when this runs.
398
+ */
399
+ const moveTo = (state, to, options = {}) => Effect.gen(function* () {
400
+ const before = yield* state.get;
401
+ yield* goTo(state, to, options.replace === true);
402
+ const after = yield* state.get;
403
+ const was = routeOf(before);
404
+ const now = routeOf(after);
405
+ return was === undefined || now === undefined ? was !== now : !sameDestination(was, now);
406
+ });
407
+ /**
408
+ * May the application be left altogether (its window closing, its process ending)? Asks the current route's `canDeactivate` as a navigation of kind `"close"` whose destination is the route
409
+ * it is already on, since there is none. `true` when there is nothing to ask. This is what a target's close request should consult: a native window with unsaved edits is the same case as a
410
+ * page with a `beforeunload`.
411
+ */
412
+ const mayLeave = (state) => Effect.gen(function* () {
413
+ const current = routeOf(state);
414
+ const leaving = current === undefined ? undefined : routeNamed(current.name);
415
+ if (current === undefined || leaving?.config.canDeactivate === undefined) {
416
+ return true;
417
+ }
418
+ return (yield* runGuard(leaving.config.canDeactivate, { from: current, to: current, state, kind: "close", data: leaving.config.data ?? {} })) === true;
419
+ });
384
420
  const commands = (state) => ({
385
421
  navigate: Nexus.Command.define("navigate", RouteState, (next) => state.update((current) => Effect.succeed({ ...current, [key]: strip(next) })).pipe(Effect.asVoid)),
386
- go: Nexus.Command.define("go", Destination, ({ to }) => moveTo(state, to, false)),
387
- replace: Nexus.Command.define("replace", Destination, ({ to }) => moveTo(state, to, true)),
422
+ go: Nexus.Command.define("go", Destination, ({ to }) => goTo(state, to, false)),
423
+ replace: Nexus.Command.define("replace", Destination, ({ to }) => goTo(state, to, true)),
388
424
  });
389
- return { base, key, notFoundName, table, plugin, commands, match: stateFor, href, hrefOf, resolve, safeNext, decide, titleOf, route: routeNamed, paths };
425
+ return { base, key, notFoundName, table, plugin, commands, moveTo, mayLeave, enter, match: stateFor, href, hrefOf, resolve, safeNext, decide, titleOf, route: routeNamed, paths };
390
426
  };
package/dist/types.d.ts CHANGED
@@ -12,8 +12,11 @@ export interface RouteState {
12
12
  readonly replace?: boolean;
13
13
  }
14
14
  export declare const RouteState: Schema.Schema<RouteState>;
15
- /** Where a navigation came from. */
16
- export type NavigationKind = "link" | "popstate" | "programmatic";
15
+ /**
16
+ * Where a navigation came from. `popstate`: the browser moved through its history on its own. `history`: the application moves along a list it keeps itself (Back and Forward on a target that
17
+ * has no browser). `close`: the window or page is going away.
18
+ */
19
+ export type NavigationKind = "link" | "popstate" | "programmatic" | "history" | "initial" | "close";
17
20
  /** What a guard is told. `state` is the application's state now, before the navigation. */
18
21
  export interface GuardContext<S> {
19
22
  /** The route being left; `undefined` for the first navigation. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valancex/router",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "Routing for VALANCE applications: route definitions with typed path and query parameters, matching conditions, guards for entering and leaving a route, programmatic navigation, and a title per route. Plugs into Web.run as the URL policy.",
5
5
  "keywords": [
6
6
  "valance",
@@ -39,11 +39,17 @@
39
39
  "access": "public",
40
40
  "provenance": true
41
41
  },
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.json",
44
+ "typecheck": "tsc -p tsconfig.typecheck.json",
45
+ "test": "vitest run",
46
+ "prepack": "tsc -p tsconfig.json"
47
+ },
42
48
  "dependencies": {
43
49
  "@valancex/nexus": "^0.12.0"
44
50
  },
45
51
  "peerDependencies": {
46
- "@valancex/valance": "^0.12.0",
52
+ "@valancex/valance": "^0.13.0",
47
53
  "effect": "^3.10.0"
48
54
  },
49
55
  "devDependencies": {
@@ -51,16 +57,11 @@
51
57
  "@types/node": "^22.0.0",
52
58
  "@valancex/mesh-compiler": "^0.11.0",
53
59
  "@valancex/mesh-runtime": "^0.11.0",
54
- "@valancex/port-web": "^0.4.0",
60
+ "@valancex/port-web": "^0.6.1",
61
+ "@valancex/valance": "workspace:^",
55
62
  "effect": "^3.10.0",
56
63
  "jsdom": "^26.0.0",
57
64
  "typescript": "^5.6.0",
58
- "vitest": "^3.2.0",
59
- "@valancex/valance": "^0.12.0"
60
- },
61
- "scripts": {
62
- "build": "tsc -p tsconfig.json",
63
- "typecheck": "tsc -p tsconfig.typecheck.json",
64
- "test": "vitest run"
65
+ "vitest": "^3.2.0"
65
66
  }
66
- }
67
+ }