diff --git a/.changeset/fuzzy-parrots-glow.md b/.changeset/fuzzy-parrots-glow.md new file mode 100644 index 0000000..78ff803 --- /dev/null +++ b/.changeset/fuzzy-parrots-glow.md @@ -0,0 +1,7 @@ +--- +"next-safe-navigation": minor +--- + +Add support for `experimental.typedRoutes` + +You may now enable `experimental.typedRoutes` in `next.config.js` to have a better and safer experience with autocomplete when defining your routes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ba7c60..b8a94e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,14 +25,11 @@ jobs: - name: โ™ป๏ธ Cache node_modules uses: actions/cache@v4 - id: bun-cache + id: cache with: path: "**/node_modules" key: ${{ runner.os }}-node-${{ hashFiles('**/bun.lockb') }} - restore-keys: | - ${{ runner.os }}-node- - if: steps.bun-cache.outputs.cache-hit != 'true' - run: bun install --frozen-lockfile lint-package: @@ -62,7 +59,7 @@ jobs: key: ${{ matrix.os }}-eslint-${{ hashFiles('**/*.ts', 'package.json', 'tsconfig.json') }} - name: ๐Ÿ“ฆ Install dependencies - if: steps.bun-cache.outputs.cache-hit != 'true' + if: steps.cache.outputs.cache-hit != 'true' run: bun install --frozen-lockfile - name: ๐Ÿšจ Lint files @@ -88,7 +85,7 @@ jobs: key: ${{ runner.os }}-node-${{ hashFiles('**/bun.lockb') }} - name: ๐Ÿ“ฆ Install dependencies - if: steps.bun-cache.outputs.cache-hit != 'true' + if: steps.cache.outputs.cache-hit != 'true' run: bun install --frozen-lockfile - name: ๐Ÿงช Run tests @@ -119,7 +116,7 @@ jobs: key: ${{ runner.os }}-node-${{ hashFiles('**/bun.lockb') }} - name: ๐Ÿ“ฆ Install dependencies - if: steps.bun-cache.outputs.cache-hit != 'true' + if: steps.cache.outputs.cache-hit != 'true' run: bun install --frozen-lockfile - name: ๐Ÿ—๏ธ Build package diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 647c51c..b83721f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,14 +30,14 @@ jobs: - name: โ™ป๏ธ Cache node_modules uses: actions/cache@v4 - id: bun-cache + id: cache with: path: "**/node_modules" key: ${{ runner.os }}-node-${{ hashFiles('**/bun.lockb') }} restore-keys: | ${{ runner.os }}-node- - if: steps.bun-cache.outputs.cache-hit != 'true' + if: steps.cache.outputs.cache-hit != 'true' - run: bun install --frozen-lockfile build: @@ -54,7 +54,7 @@ jobs: bun-version: latest - name: ๐Ÿ“ฆ Install dependencies - if: steps.bun-cache.outputs.cache-hit != 'true' + if: steps.cache.outputs.cache-hit != 'true' run: bun install --frozen-lockfile - name: ๐Ÿšจ Check for errors @@ -159,7 +159,7 @@ jobs: registry-url: 'https://npm.pkg.github.com' - name: ๐Ÿ“ฆ Install dependencies - if: steps.bun-cache.outputs.cache-hit != 'true' + if: steps.cache.outputs.cache-hit != 'true' run: bun install --frozen-lockfile - name: ๐Ÿท๏ธ Overwrite package name with user scope diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 33819b0..981ad54 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,14 +20,14 @@ jobs: - name: โ™ป๏ธ Cache node_modules uses: actions/cache@v4 - id: bun-cache + id: cache with: path: "**/node_modules" key: ${{ runner.os }}-node-${{ hashFiles('**/bun.lockb') }} restore-keys: | ${{ runner.os }}-node- - if: steps.bun-cache.outputs.cache-hit != 'true' + if: steps.cache.outputs.cache-hit != 'true' - run: bun install --frozen-lockfile tests: @@ -50,7 +50,7 @@ jobs: key: ${{ runner.os }}-node-${{ hashFiles('**/bun.lockb') }} - name: ๐Ÿ“ฆ Install dependencies - if: steps.bun-cache.outputs.cache-hit != 'true' + if: steps.cache.outputs.cache-hit != 'true' run: bun install --frozen-lockfile - name: ๐Ÿงช Run tests @@ -81,7 +81,7 @@ jobs: key: ${{ runner.os }}-node-${{ hashFiles('**/bun.lockb') }} - name: ๐Ÿ“ฆ Install dependencies - if: steps.bun-cache.outputs.cache-hit != 'true' + if: steps.cache.outputs.cache-hit != 'true' run: bun install --frozen-lockfile - name: ๐Ÿ—๏ธ Build package diff --git a/README.md b/README.md index deaa289..ad4305e 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ npm install next-safe-navigation ## โšก Quick start -> [!WARNING] -> Ensure `experimental.typedRoutes` is disabled in `next.config.js` +> [!TIP] +> Enable `experimental.typedRoutes` in `next.config.js` for a better and safer experience with autocomplete when defining your routes ### Declare your application routes and parameters in a single place ```ts diff --git a/bun.lockb b/bun.lockb index f6b5d3b..4bfbbf5 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index e068ffd..623e4a5 100644 --- a/package.json +++ b/package.json @@ -59,19 +59,19 @@ "@changesets/changelog-github": "^0.5.0", "@changesets/cli": "^2.27.1", "@lukemorales/prettier-config": "^1.1.0", - "@testing-library/react": "^14.2.0", + "@testing-library/react": "^14.2.2", "@types/bun": "latest", "@vitejs/plugin-react": "^4.2.1", - "@vitest/coverage-v8": "^1.2.2", - "@vitest/ui": "^1.2.2", - "eslint-config-lukemorales": "^0.3.0", + "@vitest/coverage-v8": "^1.4.0", + "@vitest/ui": "^1.4.0", + "eslint-config-lukemorales": "^0.4.1", "jsdom": "^24.0.0", - "next": "^14.1.0", + "next": "^14.1.4", "npm-run-all": "^4.1.5", - "prettier": "^3.2.4", - "tsup": "^8.0.1", + "prettier": "^3.2.5", + "tsup": "^8.0.2", "typescript": "^4.8.2", - "vitest": "^1.2.2", + "vitest": "^1.4.0", "zod": "^3.22.4" }, "peerDependencies": { diff --git a/src/convert-url-search-params-to-object.ts b/src/convert-url-search-params-to-object.ts index 47626eb..c01b793 100644 --- a/src/convert-url-search-params-to-object.ts +++ b/src/convert-url-search-params-to-object.ts @@ -12,6 +12,7 @@ export function convertURLSearchParamsToObject( const values = params.getAll(key); acc[key] = values.length > 1 ? values : value; + return acc; }, {}, diff --git a/src/create-navigation-config.ts b/src/create-navigation-config.ts index 1be5bd0..8f278cd 100644 --- a/src/create-navigation-config.ts +++ b/src/create-navigation-config.ts @@ -10,67 +10,77 @@ import { makeRouteBuilder, type RouteBuilder } from './make-route-builder'; import type { Prettify } from './types'; type AnyRouteBuilder = - | RouteBuilder - | RouteBuilder - | RouteBuilder - | RouteBuilder; + | RouteBuilder + | RouteBuilder + | RouteBuilder + | RouteBuilder; type NavigationConfig = Record; -type SafeRootRoute = () => string; +type SafeRootRoute = () => Path; -type SafeRouteWithParams = { - (options: z.input): string; +type SafeRouteWithParams = { + (options: z.input): Path; $parseParams: (params: unknown) => z.output; }; -type SafeRouteWithSearch = { - (options?: { search?: z.input }): string; +type SafeRouteWithSearch = { + (options?: { search?: z.input }): Path; $parseSearchParams: (searchParams: unknown) => z.output; }; -type SafeRouteWithRequiredSearch = { - (options: { search: z.input }): string; +type SafeRouteWithRequiredSearch< + Path extends string, + Search extends z.ZodSchema, +> = { + (options: { search: z.input }): Path; $parseSearchParams: (searchParams: unknown) => z.output; }; type SafeRouteWithParamsAndSearch< + Path extends string, Params extends z.ZodSchema, Search extends z.ZodSchema, Options = z.input & { search?: z.input }, > = { - (options: Prettify): string; + (options: Prettify): Path; $parseParams: (params: unknown) => z.output; $parseSearchParams: (searchParams: unknown) => z.output; }; type SafeRouteWithParamsAndRequiredSearch< + Path extends string, Params extends z.ZodSchema, Search extends z.ZodSchema, Options = z.input & { search: z.input }, > = { - (options: Prettify): string; + (options: Prettify): Path; $parseParams: (params: unknown) => z.output; $parseSearchParams: (searchParams: unknown) => z.output; }; -type SafeRoute = - [Params, Search] extends [never, never] ? SafeRootRoute - : [Params, Search] extends [z.ZodSchema, never] ? SafeRouteWithParams +type SafeRoute< + Path extends string, + Params extends z.ZodSchema, + Search extends z.ZodSchema, +> = + [Params, Search] extends [never, never] ? SafeRootRoute + : [Params, Search] extends [z.ZodSchema, never] ? + SafeRouteWithParams : [Params, Search] extends [never, z.ZodSchema] ? undefined extends z.input ? - SafeRouteWithSearch - : SafeRouteWithRequiredSearch + SafeRouteWithSearch + : SafeRouteWithRequiredSearch : [Params, Search] extends [z.ZodSchema, z.ZodSchema] ? undefined extends z.input ? - SafeRouteWithParamsAndSearch - : SafeRouteWithParamsAndRequiredSearch + SafeRouteWithParamsAndSearch + : SafeRouteWithParamsAndRequiredSearch : never; type RouteWithParams = { [Route in keyof Config & string]: Config[Route] extends ( - | RouteBuilder - | RouteBuilder + | RouteBuilder + | RouteBuilder ) ? Params extends z.ZodSchema ? Route @@ -80,8 +90,8 @@ type RouteWithParams = { type RouteWithSearchParams = { [Route in keyof Config & string]: Config[Route] extends ( - | RouteBuilder - | RouteBuilder + | RouteBuilder + | RouteBuilder ) ? Search extends z.ZodSchema ? Route @@ -92,11 +102,12 @@ type RouteWithSearchParams = { type SafeNavigation = { [Route in keyof Config]: Config[Route] extends ( RouteBuilder< + infer Path extends string, infer Params extends z.ZodSchema, infer Search extends z.ZodSchema > ) ? - SafeRoute + SafeRoute : never; }; @@ -108,8 +119,8 @@ type ValidatedRouteParams< > = Route extends keyof Pick ? Router[Route] extends ( - | SafeRoute - | SafeRoute + | SafeRoute + | SafeRoute ) ? z.output : never @@ -123,8 +134,8 @@ type ValidatedRouteSearchParams< > = Route extends keyof Pick ? Router[Route] extends ( - | SafeRoute - | SafeRoute + | SafeRoute + | SafeRoute ) ? z.output : never diff --git a/src/make-route-builder.spec.ts b/src/make-route-builder.spec.ts index 3ceb152..8992265 100644 --- a/src/make-route-builder.spec.ts +++ b/src/make-route-builder.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-secrets/no-secrets */ import { z } from 'zod'; import { makeRouteBuilder } from './make-route-builder'; diff --git a/src/make-route-builder.ts b/src/make-route-builder.ts index c465938..891ef5f 100644 --- a/src/make-route-builder.ts +++ b/src/make-route-builder.ts @@ -1,3 +1,5 @@ +import { type Route } from 'next'; + import { type z } from 'zod'; import { convertObjectToURLSearchParams } from './convert-object-to-url-search-params'; @@ -5,6 +7,20 @@ import type { ExcludeAny } from './types'; type PathBlueprint = `/${string}`; +type Suffix = `?${string}`; + +/** + * When `experimental.typeRoutes` is disabled, + * `Route` is `string & {}`, therefore `string extends Route` is a truthy condition. + * If this is the case, we simply use the `Path` value to infer the literal string. + * + * If `experimental.typeRoutes` is enabled, + * `Route` will be a union of string literals, therefore `string extends Route` is a falsy condition. + * If this is the case, we use `Route` so that we have auto-complete on the available routes + * generated by NextJS and validation check against dynamic routes (that are checked by passing the string generic). + */ +type SafePath = string extends Route ? Path : Route; + type ExtractPathParams = T extends `${string}[[...${infer Param}]]${infer Rest}` ? Param | ExtractPathParams @@ -15,40 +31,45 @@ type ExtractPathParams = : never; export type RouteBuilder< + Path extends string, Params extends z.ZodSchema, Search extends z.ZodSchema, > = [Params, Search] extends [never, never] ? - { (): string; getSchemas: () => { params: never; search: never } } + { (): Path; getSchemas: () => { params: never; search: never } } : [Params, Search] extends [z.ZodSchema, never] ? { - (options: z.input): string; + (options: z.input): Path; getSchemas: () => { params: Params; search: never }; } : [Params, Search] extends [never, z.ZodSchema] ? undefined extends z.input ? { - (options?: { search?: z.input }): string; + (options?: { search?: z.input }): Path | `${Path}${Suffix}`; getSchemas: () => { params: never; search: Search }; } : { - (options: { search: z.input }): string; + (options: { search: z.input }): `${Path}${Suffix}`; getSchemas: () => { params: never; search: Search }; } : [Params, Search] extends [z.ZodSchema, z.ZodSchema] ? undefined extends z.input ? { - (options: z.input & { search?: z.input }): string; + ( + options: z.input & { search?: z.input }, + ): Path | `${Path}${Suffix}`; getSchemas: () => { params: Params; search: Search }; } : { - (options: z.input & { search: z.input }): string; + ( + options: z.input & { search: z.input }, + ): `${Path}${Suffix}`; getSchemas: () => { params: Params; search: Search }; } : never; type EnsurePathWithNoParams = - ExtractPathParams extends never ? Path + ExtractPathParams extends never ? SafePath : `[ERROR]: Missing validation for path params`; /** @@ -64,15 +85,17 @@ type StrictParams = : never; type RouteBuilderResult< + Path extends string, PathParams extends string, Params extends z.ZodObject, Search extends z.ZodSchema, > = - [PathParams, Search] extends [string, never] ? RouteBuilder + [PathParams, Search] extends [string, never] ? + RouteBuilder : [PathParams, Search] extends [never, z.ZodSchema] ? - RouteBuilder + RouteBuilder : [PathParams, Search] extends [string, z.ZodSchema] ? - RouteBuilder + RouteBuilder : never; const PATH_PARAM_REGEX = /\[{1,2}([^[\]]+)]{1,2}/g; @@ -95,7 +118,7 @@ const REMOVE_PARAM_NOTATION_REGEX = /[^[.].+[^\]]/; // the compiler complains about EnsurePathWithNoParams, but it is fine export function makeRouteBuilder( path: EnsurePathWithNoParams, -): RouteBuilder; +): RouteBuilder; export function makeRouteBuilder< Path extends PathBlueprint, @@ -104,7 +127,7 @@ export function makeRouteBuilder< }>, Search extends z.ZodSchema = never, >( - path: Path, + path: SafePath, schemas: ExtractPathParams extends never ? { search: Search | z.ZodOptional } : { @@ -112,6 +135,7 @@ export function makeRouteBuilder< search?: Search | z.ZodOptional; }, ): RouteBuilderResult< + Path, ExtractPathParams, ExcludeAny, ExcludeAny @@ -132,7 +156,7 @@ export function makeRouteBuilder( throw new Error(`Validation missing for path params: "${path}"`); } - const routeBuilder: RouteBuilder = (options) => { + const routeBuilder: RouteBuilder = (options) => { const { search = {}, ...params } = options ?? {}; const basePath = path.replace(PATH_PARAM_REGEX, (match, param: string) => { diff --git a/src/test-utils.ts b/src/test-utils.ts index 966b1d0..9636621 100644 --- a/src/test-utils.ts +++ b/src/test-utils.ts @@ -11,5 +11,5 @@ export function suppressConsoleErrors(fn: () => void | Promise): void { }); } - return mockRestore(); + mockRestore(); }