Skip to content

Commit

Permalink
feat: Add better support for catch-all segments (#14)
Browse files Browse the repository at this point in the history
* add better support for catch-all segments

* add comment to document regex

* fix ci script to use `bun`
  • Loading branch information
lukemorales committed Mar 27, 2024
1 parent a5194b3 commit fc55e1d
Show file tree
Hide file tree
Showing 5 changed files with 94 additions and 8 deletions.
5 changes: 5 additions & 0 deletions .changeset/funny-geckos-destroy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"next-safe-navigation": minor
---

Add better support for Catch-all Segments
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ jobs:
run: bun install --frozen-lockfile

- name: 🧪 Run tests
run: pnpm run test:ci
run: bun run test:ci

- name: ⬆️ Upload coverage reports to Codecov
uses: codecov/codecov-action@v3
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ export const { routes, useSafeParams, useSafeSearchParams } = createNavigationCo
invoiceId: z.string(),
}),
}),
shop: defineRoute('/support/[...tickets]', {
params: z.object({
tickets: z.array(z.string()),
}),
}),
shop: defineRoute('/shop/[[...slug]]', {
params: z.object({
// ⚠️ Remember to always set your optional catch-all segments
// as optional values, or add a default value to them
slug: z.array(z.string()).optional(),
}),
}),
}),
);
```
Expand Down
44 changes: 44 additions & 0 deletions src/make-route-builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,50 @@ describe('makeRouteBuilder', () => {
expect(builder({ orgId: 'org_789' })).toBe('/organizations/org_789');
});
});

describe('when path has catch-all params', () => {
it('creates a builder that replaces the path param with its value', () => {
const builder = makeRouteBuilder('/[...catch_all]', {
params: z.object({
catch_all: z.array(z.string()),
}),
});

// @ts-expect-error no searchParams validation was defined
builder({ catch_all: ['channels'], search: {} });

expect(builder({ catch_all: ['channels', 'channel_123'] })).toBe(
'/channels/channel_123',
);

expect(builder.getSchemas()).toEqual({
params: expect.any(Object),
search: undefined,
});
});
});

describe('when path has optional catch-all params', () => {
it('creates a builder that replaces the path param with its value', () => {
const builder = makeRouteBuilder('/[[...catch_all]]', {
params: z.object({
catch_all: z.array(z.string()).default([]),
}),
});

// @ts-expect-error no searchParams validation was defined
builder({ catch_all: ['channels'], search: {} });

expect(builder({ catch_all: ['channels', 'channel_123'] })).toBe(
'/channels/channel_123',
);

expect(builder.getSchemas()).toEqual({
params: expect.any(Object),
search: undefined,
});
});
});
});

describe('for a path with route params and searchParams', () => {
Expand Down
39 changes: 32 additions & 7 deletions src/make-route-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import type { ExcludeAny } from './types';
type PathBlueprint = `/${string}`;

type ExtractPathParams<T extends string> =
T extends `${string}[${infer Param}]${infer Rest}` ?
T extends `${string}[[...${infer Param}]]${infer Rest}` ?
Param | ExtractPathParams<Rest>
: T extends `${string}[...${infer Param}]${infer Rest}` ?
Param | ExtractPathParams<Rest>
: T extends `${string}[${infer Param}]${infer Rest}` ?
Param | ExtractPathParams<Rest>
: never;

Expand Down Expand Up @@ -71,7 +75,21 @@ type RouteBuilderResult<
RouteBuilder<Params, Search>
: never;

const PATH_PARAM_REGEX = /\[([^[\]]+)]/g;
const PATH_PARAM_REGEX = /\[{1,2}([^[\]]+)]{1,2}/g;

/**
* Remove param notation from string to only get the param name when it is a catch-all segment
*
* @example
* ```ts
* '/shop/[[...slug]]'.replace(PATH_PARAM_REGEX, (match, param) => {
* // ^? '[[...slug]]'
* const [sanitizedParam] = REMOVE_PARAM_NOTATION_REGEX.exec(param)
* // ^? 'slug'
* })
* ```
*/
const REMOVE_PARAM_NOTATION_REGEX = /[^[.].+[^\]]/;

// @ts-expect-error overload signature does match the implementation,
// the compiler complains about EnsurePathWithNoParams, but it is fine
Expand Down Expand Up @@ -107,7 +125,7 @@ export function makeRouteBuilder(
path = `/${path}`;
}

const hasParamsInPath = /\[\w+\]/g.test(path);
const hasParamsInPath = PATH_PARAM_REGEX.test(path);
const isMissingParamsValidation = hasParamsInPath && !schemas?.params;

if (isMissingParamsValidation) {
Expand All @@ -117,10 +135,17 @@ export function makeRouteBuilder(
const routeBuilder: RouteBuilder<any, any> = (options) => {
const { search = {}, ...params } = options ?? {};

const basePath = path.replace(
PATH_PARAM_REGEX,
(match, param) => params[param] ?? match,
);
const basePath = path.replace(PATH_PARAM_REGEX, (match, param: string) => {
const sanitizedParam = REMOVE_PARAM_NOTATION_REGEX.exec(param)?.[0];

const value = params[sanitizedParam ?? param];

if (Array.isArray(value)) {
return value.join('/');
}

return value ?? match;
});

const urlSearchParams = convertObjectToURLSearchParams(search);

Expand Down

0 comments on commit fc55e1d

Please sign in to comment.