Skip to content

Commit

Permalink
Added support for regular expressions in paths
Browse files Browse the repository at this point in the history
  • Loading branch information
JonahPlusPlus committed May 28, 2024
1 parent e106a9d commit d0e1a9a
Show file tree
Hide file tree
Showing 7 changed files with 107 additions and 19 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"packages/wouter-preact"
],
"scripts": {
"fix:p": "prettier --write './**/*.(js|ts){x,}'",
"fix:p": "prettier --write \"./**/*.(js|ts){x,}\"",
"test": "vitest",
"size": "size-limit",
"build": "npm run build -ws",
Expand Down
28 changes: 23 additions & 5 deletions packages/wouter/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,21 +79,39 @@ export const useSearch = () => {
};

const matchRoute = (parser, route, path, loose) => {
// if the route is a regex, `loose` is ignored and

// when parser is in "loose" mode, `$base` is equal to the
// first part of the route that matches the pattern
// (e.g. for pattern `/a/:b` and path `/a/1/2/3` the `$base` is `a/1`)
// we use this for route nesting
const { pattern, keys } = parser(route || "*", loose);
const [$base, ...matches] = pattern.exec(path) || [];
// array destructuring loses keys, so this is done in two steps
const result = pattern.exec(path) || [];
const [$base, ...matches] = result;

return $base !== undefined
? [
true,

// an object with parameters matched, e.g. { foo: "bar" } for "/:foo"
// we "zip" two arrays here to construct the object
// ["foo"], ["bar"] → { foo: "bar" }
Object.fromEntries(keys.map((key, i) => [key, matches[i]])),
(() => {
/// for regex paths, `keys` will always be false
if (keys !== false) {
// an object with parameters matched, e.g. { foo: "bar" } for "/:foo"
// we "zip" two arrays here to construct the object
// ["foo"], ["bar"] → { foo: "bar" }
return Object.fromEntries(keys.map((key, i) => [key, matches[i]]));
}

// convert the array to an instance of object
// this makes it easier to integrate with the existing param implementation
let obj = { ...matches };

// merge named capture groups with matches array
result.groups && Object.assign(obj, result.groups);

return obj;
})(),

// the third value if only present when parser is in "loose" mode,
// so that we can extract the base path for nested routes
Expand Down
4 changes: 2 additions & 2 deletions packages/wouter/test/route.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ describe("`path` prop", () => {
assertType(<Route />);
});

it("should be a string", () => {
it("should be a string or RegExp", () => {
let a: ComponentProps<typeof Route>["path"];
expectTypeOf(a).toMatchTypeOf<string | undefined>();
expectTypeOf(a).toMatchTypeOf<string | RegExp | undefined>();
});
});

Expand Down
44 changes: 44 additions & 0 deletions packages/wouter/test/route.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,47 @@ it("supports `base` routers with relative path", () => {

unmount();
});

it("supports `path` prop with regex", () => {
const result = testRouteRender(
"/foo",
<Route path={/[/]foo/}>
<h1>Hello!</h1>
</Route>
);

expect(result.findByType("h1").props.children).toBe("Hello!");
});

it("supports regex path named params", () => {
const result = testRouteRender(
"/users/alex",
<Route path={/[/]users[/](?<name>[a-z]+)/}>
{(params) => <h1>{params.name}</h1>}
</Route>
);

expect(result.findByType("h1").props.children).toBe("alex");
});

it("supports regex path anonymous params", () => {
const result = testRouteRender(
"/users/alex",
<Route path={/[/]users[/]([a-z]+)/}>
{(params) => <h1>{params[0]}</h1>}
</Route>
);

expect(result.findByType("h1").props.children).toBe("alex");
});

it("rejects when a path does not match the regex", () => {
const result = testRouteRender(
"/users/1234",
<Route path={/[/]users[/](?<name>[a-z]+)/}>
{(params) => <h1>{params.name}</h1>}
</Route>
);

expect(() => result.findByType("h1")).toThrow();
});
12 changes: 10 additions & 2 deletions packages/wouter/test/use-route.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { renderHook, act } from "@testing-library/react";
import { useRoute, Match, Router } from "wouter";
import { useRoute, Match, Router, RegexRouteParams } from "wouter";
import { it, expect } from "vitest";
import { memoryLocation } from "wouter/memory-location";

Expand Down Expand Up @@ -108,6 +108,14 @@ it("ignores escaped slashes", () => {
});
});

it("supports regex patterns", () => {
assertRoute(/[/]foo/, "/foo", {});
assertRoute(/[/]([a-z]+)/, "/bar", { 0: "bar" });
assertRoute(/[/]([a-z]+)/, "/123", false);
assertRoute(/[/](?<param>[a-z]+)/, "/bar", { 0: "bar", param: "bar" });
assertRoute(/[/](?<param>[a-z]+)/, "/123", false);
});

it("reacts to pattern updates", () => {
const { result, rerender } = renderHook(
({ pattern }: { pattern: string }) => useRoute(pattern),
Expand Down Expand Up @@ -170,7 +178,7 @@ it("reacts to location updates", () => {
*/

const assertRoute = (
pattern: string,
pattern: string | RegExp,
location: string,
rhs: false | Match | Record<string, string | undefined>
) => {
Expand Down
32 changes: 25 additions & 7 deletions packages/wouter/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@ export * from "./router.js";

import { RouteParams } from "regexparam";

export type RegexRouteParams = { [key: string | number]: string | undefined };

/**
* Route patterns and parameters
*/
export interface DefaultParams {
readonly [paramName: string]: string | undefined;
readonly [paramName: string | number]: string | undefined;
}

export type Params<T extends DefaultParams = DefaultParams> = T;
Expand All @@ -61,23 +63,33 @@ export interface RouteComponentProps<T extends DefaultParams = DefaultParams> {

export interface RouteProps<
T extends DefaultParams | undefined = undefined,
RoutePath extends Path = Path
RoutePath extends Path | RegExp = Path | RegExp
> {
children?:
| ((
params: T extends DefaultParams ? T : RouteParams<RoutePath>
params: T extends DefaultParams
? T
: RoutePath extends string
? RouteParams<RoutePath>
: RegexRouteParams
) => ReactNode)
| ReactNode;
path?: RoutePath;
component?: ComponentType<
RouteComponentProps<T extends DefaultParams ? T : RouteParams<RoutePath>>
RouteComponentProps<
T extends DefaultParams
? T
: RoutePath extends string
? RouteParams<RoutePath>
: RegexRouteParams
>
>;
nest?: boolean;
}

export function Route<
T extends DefaultParams | undefined = undefined,
RoutePath extends Path = Path
RoutePath extends Path | RegExp = Path | RegExp
>(props: RouteProps<T, RoutePath>): ReturnType<FunctionComponent>;

/*
Expand Down Expand Up @@ -150,10 +162,16 @@ export function useRouter(): RouterObject;

export function useRoute<
T extends DefaultParams | undefined = undefined,
RoutePath extends Path = Path
RoutePath extends Path | RegExp = Path | RegExp
>(
pattern: RoutePath
): Match<T extends DefaultParams ? T : RouteParams<RoutePath>>;
): Match<
T extends DefaultParams
? T
: RoutePath extends string
? RouteParams<RoutePath>
: RegexRouteParams
>;

export function useLocation<
H extends BaseLocationHook = BrowserLocationHook
Expand Down

0 comments on commit d0e1a9a

Please sign in to comment.