Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/esroute-lit/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@esroute/lit",
"version": "0.11.1",
"version": "0.12.0",
"description": "A small efficient client-side routing library for lit, written in TypeScript.",
"main": "dist/index.js",
"license": "MIT",
Expand All @@ -9,6 +9,7 @@
"homepage": "https://github.com/sv2dev/esroute/tree/main/packages/esroute-lit",
"scripts": {
"build": "tsc -b",
"typecheck": "tsc --noEmit -p tsconfig.check.json",
"prepublishOnly": "npm run build"
},
"keywords": [
Expand All @@ -20,7 +21,7 @@
"typescript": "^5.4.5"
},
"dependencies": {
"esroute": "^0.11.1",
"esroute": "^0.12.0",
"lit": "^3.1.1"
}
}
4 changes: 2 additions & 2 deletions packages/esroute-lit/src/render-routes-directive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { renderRoutes } from "./render-routes-directive";

const router = createRouter<any>({
routes: {
"": async ({}, next) => next ?? html`test`,
"": async ({}, next: any) => next ?? html`test`,
foo: async () => html`foo`,
bar: {
"": async ({}, next) => `bar ${next}`,
"": async ({}, next: any) => `bar ${next}`,
baz: async () => `baz`,
},
},
Expand Down
10 changes: 10 additions & 0 deletions packages/esroute-lit/tsconfig.check.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"noEmit": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../esroute" }]
}
44 changes: 42 additions & 2 deletions packages/esroute/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Those features may be the ones you are looking for.
- [🌈 Framework agnostic](#-framework-agnostic)
- [🧭 Concise navigation API](#-concise-navigation-api)
- [🕹 Simple configuration](#-simple-configuration)
- [✅ Typesafe value resolution](#-typesafe-value-resolution)
- [✅ Typesafe navigation](#-typesafe-navigation)
- [🏎 Fast startup and runtime](#-fast-startup-and-runtime)
- [🛡 Route guards](#-route-guards)
- [🦄 Virtual routes](#-virtual-routes)
Expand Down Expand Up @@ -90,7 +90,11 @@ const router = createRouter({
});
```

### ✅ Typesafe value resolution
### ✅ Typesafe navigation

The router provides end-to-end type safety for route navigation.

#### Value resolution

The router can be restricted to allow only certain resolution types.

Expand All @@ -104,6 +108,42 @@ const router = createRouter<string>({
});
```

#### Route paths

When you pass your routes using `satisfies Routes<T>`, the router infers all valid paths and restricts `go()` to only those paths, giving you autocomplete and catching typos at compile time:

```ts
const routes = {
"": () => "index",
foo: () => "foo",
bar: () => "bar",
} satisfies Routes<string>;

const router = createRouter({ routes });

router.go("/foo"); // ✓
router.go("/baz"); // TS Error: Argument of type '"/baz"' is not assignable
```

#### Typed state

Route handlers can declare the history state type they require by typing the `NavOpts` parameter. When navigating to such a route, TypeScript enforces that you provide the correct state — and inside the handler, `state` is typed directly without null checks:

```ts
const routes = {
profile: (navOpts: NavOpts<{ userId: string }>) =>
loadProfile(navOpts.state.userId),
} satisfies Routes<string>;

const router = createRouter({ routes });

router.go("/profile"); // TS Error: state required
router.go("/profile", { state: { userId: "123" } }); // ✓
router.go("/profile", { state: { userId: 42 } }); // TS Error: number is not string
```

State defaults to `null` when not provided, consistent with the History API. Routes without an explicit state type leave it as `null`.

### 🏎 Fast startup and runtime

esroute comes with no dependencies and is quite small.
Expand Down
3 changes: 2 additions & 1 deletion packages/esroute/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "esroute",
"version": "0.11.1",
"version": "0.12.0",
"description": "A small efficient framework-agnostic client-side routing library, written in TypeScript.",
"types": "dist/index.d.ts",
"main": "dist/index.js",
Expand All @@ -10,6 +10,7 @@
"homepage": "https://github.com/sv2dev/esroute/tree/main/packages/esroute",
"scripts": {
"build": "tsc -b",
"typecheck": "tsc --noEmit -p tsconfig.check.json",
"prepublishOnly": "npm run build"
},
"keywords": [
Expand Down
6 changes: 3 additions & 3 deletions packages/esroute/src/nav-opts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe("NavOpts", () => {
const href = "/foo/bar?a=b";
const opts = new NavOpts(href, {});

expect("state" in opts).toBeFalsy();
expect(opts.state).toBeNull();
expect("replace" in opts).toBeFalsy();
});

Expand Down Expand Up @@ -116,11 +116,11 @@ describe("NavOpts", () => {

expect(opts2.replace).toBe(true);
expect(opts2.search).toEqual({});
expect(opts2.state).toBeUndefined();
expect(opts2.state).toBeNull();
});

it("should set new options", () => {
const opts1 = new NavOpts(["a", "b"], {
const opts1 = new NavOpts<number>(["a", "b"], {
replace: true,
});
const opts2 = opts1.go("/a", {
Expand Down
22 changes: 11 additions & 11 deletions packages/esroute/src/nav-opts.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
export type PathOrHref = string | string[];

export interface NavMeta {
export interface NavMeta<S = any> {
/** The search query object. */
search?: Record<string, string>;
/** The state to push. */
state?: any;
state?: S | null;
/** The location hash. */
hash?: string;
/** Whethe the history state shall be replaced. */
Expand All @@ -19,7 +19,7 @@ export interface NavMeta {
skipRender?: boolean;
}

export type StrictNavMeta = NavMeta &
export type StrictNavMeta<S = any> = NavMeta<S> &
(
| {
path: string[];
Expand All @@ -29,8 +29,8 @@ export type StrictNavMeta = NavMeta &
}
);

export class NavOpts implements NavMeta {
readonly state?: any;
export class NavOpts<S = null> implements NavMeta<S> {
readonly state: S;
readonly params: string[] = [];
readonly hash?: string;
readonly replace?: boolean;
Expand All @@ -40,9 +40,9 @@ export class NavOpts implements NavMeta {
readonly pop?: boolean;
private _h?: string;

constructor(target: StrictNavMeta);
constructor(target: PathOrHref, opts?: NavMeta);
constructor(target: PathOrHref | StrictNavMeta, opts: NavMeta = {}) {
constructor(target: StrictNavMeta<S>);
constructor(target: PathOrHref, opts?: NavMeta<S>);
constructor(target: PathOrHref | StrictNavMeta<S>, opts: NavMeta<S> = {}) {
let { path, href, hash, pop, replace, search, state, skipRender } =
typeof target === "string" || Array.isArray(target) ? opts : target;
if (path) this.path = path;
Expand All @@ -63,7 +63,7 @@ export class NavOpts implements NavMeta {
if (hash != null) this.hash = hash;
if (pop != null) this.pop = pop;
if (search != null) this.search = search;
if (state != null) this.state = state;
this.state = (state ?? null) as S;
if (replace != null) this.replace = replace;
if (skipRender != null) this.skipRender = skipRender;
this.search ??= {};
Expand All @@ -79,8 +79,8 @@ export class NavOpts implements NavMeta {
}

get go() {
return (path: PathOrHref, opts: NavMeta = {}) =>
new NavOpts(path, {
return (path: PathOrHref, opts: NavMeta<S> = {}) =>
new NavOpts<S>(path, {
search: opts.search,
state: opts.state,
replace: opts.replace ?? this.replace,
Expand Down
4 changes: 2 additions & 2 deletions packages/esroute/src/route-resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,11 @@ describe("Resolver", () => {
});

it("should fail on too many redirects", async () => {
const navOpts = new NavOpts("/foo", { state: 1 });
const navOpts = new NavOpts<number>("/foo", { state: 1 });

await expect(
resolve(
{ foo: ({ go, state }) => go("/foo", { state: state + 1 }) },
{ foo: ({ go, state }) => go("/foo", { state: (state ?? 0) + 1 }) },
navOpts,
notFound
)
Expand Down
32 changes: 16 additions & 16 deletions packages/esroute/src/route-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
import { NavOpts } from "./nav-opts";
import { Resolve, Routes } from "./routes";

export interface Resolved<T> {
export interface Resolved<T, S = any> {
/** The resolved value of the route. */
value: T;
/** The final navigation options after all redirecting. */
opts: NavOpts;
opts: NavOpts<S>;
}

export type RouteResolver<T> = (
routes: Routes<T>,
opts: NavOpts,
notFound: Resolve<T>
) => Promise<Resolved<T>>;
export type RouteResolver<T, S = any> = (
routes: Routes<T, S>,
opts: NavOpts<S>,
notFound: Resolve<T, S>
) => Promise<Resolved<T, S>>;

const MAX_REDIRECTS = 10;

export const resolve = async <T>(
routes: Routes<T>,
opts: NavOpts,
notFound: Resolve<T>
): Promise<Resolved<T>> => {
let value: NavOpts | T = opts;
const navPath = new Array<NavOpts>();
export const resolve = async <T, S = any>(
routes: Routes<T, S>,
opts: NavOpts<S>,
notFound: Resolve<T, S>
): Promise<Resolved<T, S>> => {
let value: NavOpts<S> | T = opts;
const navPath = new Array<NavOpts<S>>();
while (value instanceof NavOpts && navPath.length <= MAX_REDIRECTS) {
opts = value;
navPath.push(opts);
Expand All @@ -45,7 +45,7 @@ export const resolve = async <T>(

const getResolves = async (
root: Routes,
opts: NavOpts
opts: NavOpts<any>
): Promise<Resolve[] | null> => {
const { path, params } = opts;
const resolves: Resolve[] = [];
Expand Down Expand Up @@ -81,7 +81,7 @@ const getResolves = async (
return null;
};

const checkGuard = async (routes: Routes, opts: NavOpts) => {
const checkGuard = async (routes: Routes, opts: NavOpts<any>) => {
if (typeof routes["?"] === "function") {
const guardResult = await routes["?"](opts);
if (guardResult instanceof NavOpts) return guardResult;
Expand Down
Loading
Loading