From a8fb0e3d9d793b40676b78aac3bcf182df8bcc2e Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 20 Jun 2024 16:08:05 +0200 Subject: [PATCH 1/8] fix: Don't commit canary releases --- .github/workflows/prerelease.yml | 2 +- CONTRIBUTORS.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 2bddd0d2e..1a5012b13 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -23,7 +23,7 @@ jobs: - run: | git config --global user.name "${{ github.actor }}" git config --global user.email "${{ github.actor }}@users.noreply.github.com" - - run: pnpm run publish --conventional-prerelease --preid canary --dist-tag canary + - run: pnpm run publish --conventional-prerelease --preid canary --dist-tag canary --no-push if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ')}}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index cf6dd89af..5980eff05 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -80,7 +80,7 @@ This repository uses [action-semantic-pull-request](https://github.com/amannn/ac ## Releases -Releases are automated via Lerna. To determine the next version, [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) is used. +Releases are automated via [`lerna-light`](https://github.com/lerna-lite/lerna-lite). To determine the next version, [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) is used. ### Stable releases @@ -89,7 +89,7 @@ Every commit on `main` with the following prefixes will trigger a release: 1. `fix: `: Patch release 2. `feat: `: Minor release -Due to this, it's important to clean up commit messages of merged PRs since the commit title will appear in the changelog. Note that the PR title and description should be cleaned up by the person who initiates the merge since the PR is linked to from the changelog and should contain relevant details. We give credits to PR authors by linking them via `@{username}` in the commit title, which in turn creates a link in the changelog entry. +Due to this, it's important to clean up commit messages of merged PRs since the commit title will appear in the changelog. Note that the PR title and description should be cleaned up by the person who initiates the merge since the PR is linked to from the changelog & release and should contain relevant details. Note that the exclamation mark syntax (`!`) for indicating breaking changes is currently [not supported by Lerna](https://github.com/lerna/lerna/issues/2668#issuecomment-1467902595). Instead, a block like `BREAKING CHANGE: Dropped support for Node.js 12` should be added to the body of the commit message. @@ -104,3 +104,5 @@ Other prefixes that are allowed and will *not* create a release are the followin ### Prereleases Canary versions are automatically published on the `canary` branch. The prerelease version is determined based on the commit prefix (`fix`/`feat`). + +When merging from `canary` to `main`, a release will be triggered if new commits are added that use one of the release prefixes mentioned above. From cf3f8b0e79023a466e4641bae6116204a660d6de Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 20 Jun 2024 16:15:06 +0200 Subject: [PATCH 2/8] Improve docs [skip ci] --- CONTRIBUTORS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 5980eff05..b92a3ca88 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -105,4 +105,4 @@ Other prefixes that are allowed and will *not* create a release are the followin Canary versions are automatically published on the `canary` branch. The prerelease version is determined based on the commit prefix (`fix`/`feat`). -When merging from `canary` to `main`, a release will be triggered if new commits are added that use one of the release prefixes mentioned above. +When merging from `canary` to `main`, a release will be triggered if new commits are added that use one of the release prefixes mentioned above. Note that the `canary` branch should also be merged to `main` via a pull request for documentation and review purposes. From 4fd9b20bff0b6f79506e3c0053a8672644a387cf Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 20 Jun 2024 16:24:33 +0200 Subject: [PATCH 3/8] fix: Try out updated credits --- .github/workflows/prerelease.yml | 2 +- lerna.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 1a5012b13..2bddd0d2e 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -23,7 +23,7 @@ jobs: - run: | git config --global user.name "${{ github.actor }}" git config --global user.email "${{ github.actor }}@users.noreply.github.com" - - run: pnpm run publish --conventional-prerelease --preid canary --dist-tag canary --no-push + - run: pnpm run publish --conventional-prerelease --preid canary --dist-tag canary if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ')}}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/lerna.json b/lerna.json index ac77ae441..56414074b 100644 --- a/lerna.json +++ b/lerna.json @@ -15,7 +15,7 @@ "yes": true }, "version": { - "changelogIncludeCommitsClientLogin": " – by [@%l](https://github.com/%l)", + "changelogIncludeCommitsClientLogin": " – by @%l", "conventionalCommits": true, "createRelease": "github", "syncWorkspaceLock": true From 47239f9d36290a8a027892fe929785acd65844be Mon Sep 17 00:00:00 2001 From: amannn Date: Thu, 20 Jun 2024 14:25:33 +0000 Subject: [PATCH 4/8] v3.15.3-canary.0 --- CHANGELOG.md | 6 ++++++ lerna.json | 2 +- packages/next-intl/CHANGELOG.md | 6 ++++++ packages/next-intl/package.json | 2 +- packages/use-intl/CHANGELOG.md | 6 ++++++ packages/use-intl/package.json | 2 +- 6 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dad76d8a8..7603d0e1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 3.15.3-canary.0 (2024-06-20) + +### Bug Fixes + +* Try out updated credits ([4fd9b20](https://github.com/amannn/next-intl/commit/4fd9b20bff0b6f79506e3c0053a8672644a387cf)) – by @amannn + ## 3.15.2 (2024-06-19) **Note:** Version bump only for package root diff --git a/lerna.json b/lerna.json index 56414074b..3c08aa9ed 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/@lerna-lite/cli/schemas/lerna-schema.json", - "version": "3.15.2", + "version": "3.15.3-canary.0", "packages": [ "packages/*" ], diff --git a/packages/next-intl/CHANGELOG.md b/packages/next-intl/CHANGELOG.md index ac7de486a..9a3e6c6c0 100644 --- a/packages/next-intl/CHANGELOG.md +++ b/packages/next-intl/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 3.15.3-canary.0 (2024-06-20) + +### Bug Fixes + +* Try out updated credits ([4fd9b20](https://github.com/amannn/next-intl/commit/4fd9b20bff0b6f79506e3c0053a8672644a387cf)) – by @amannn + ## 3.15.2 (2024-06-19) **Note:** Version bump only for package next-intl diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 90e625614..0da600edd 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -1,6 +1,6 @@ { "name": "next-intl", - "version": "3.15.2", + "version": "3.15.3-canary.0", "sideEffects": false, "author": "Jan Amann ", "funding": [ diff --git a/packages/use-intl/CHANGELOG.md b/packages/use-intl/CHANGELOG.md index e92ab5cfb..9fe002bbd 100644 --- a/packages/use-intl/CHANGELOG.md +++ b/packages/use-intl/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 3.15.3-canary.0 (2024-06-20) + +### Bug Fixes + +* Try out updated credits ([4fd9b20](https://github.com/amannn/next-intl/commit/4fd9b20bff0b6f79506e3c0053a8672644a387cf)) – by @amannn + ## 3.15.2 (2024-06-19) **Note:** Version bump only for package use-intl diff --git a/packages/use-intl/package.json b/packages/use-intl/package.json index 2f0b3adc1..d305a5170 100644 --- a/packages/use-intl/package.json +++ b/packages/use-intl/package.json @@ -1,6 +1,6 @@ { "name": "use-intl", - "version": "3.15.2", + "version": "3.15.3-canary.0", "sideEffects": false, "author": "Jan Amann ", "description": "Internationalization (i18n) for React", From 0d60a722a9bf2a0905e79f920ce50809bd3b7e72 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 20 Jun 2024 16:27:10 +0200 Subject: [PATCH 5/8] Revert "v3.15.3-canary.0" This reverts commit 47239f9d36290a8a027892fe929785acd65844be. --- CHANGELOG.md | 6 ------ lerna.json | 2 +- packages/next-intl/CHANGELOG.md | 6 ------ packages/next-intl/package.json | 2 +- packages/use-intl/CHANGELOG.md | 6 ------ packages/use-intl/package.json | 2 +- 6 files changed, 3 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7603d0e1d..dad76d8a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,6 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -## 3.15.3-canary.0 (2024-06-20) - -### Bug Fixes - -* Try out updated credits ([4fd9b20](https://github.com/amannn/next-intl/commit/4fd9b20bff0b6f79506e3c0053a8672644a387cf)) – by @amannn - ## 3.15.2 (2024-06-19) **Note:** Version bump only for package root diff --git a/lerna.json b/lerna.json index 3c08aa9ed..56414074b 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/@lerna-lite/cli/schemas/lerna-schema.json", - "version": "3.15.3-canary.0", + "version": "3.15.2", "packages": [ "packages/*" ], diff --git a/packages/next-intl/CHANGELOG.md b/packages/next-intl/CHANGELOG.md index 9a3e6c6c0..ac7de486a 100644 --- a/packages/next-intl/CHANGELOG.md +++ b/packages/next-intl/CHANGELOG.md @@ -3,12 +3,6 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -## 3.15.3-canary.0 (2024-06-20) - -### Bug Fixes - -* Try out updated credits ([4fd9b20](https://github.com/amannn/next-intl/commit/4fd9b20bff0b6f79506e3c0053a8672644a387cf)) – by @amannn - ## 3.15.2 (2024-06-19) **Note:** Version bump only for package next-intl diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 0da600edd..90e625614 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -1,6 +1,6 @@ { "name": "next-intl", - "version": "3.15.3-canary.0", + "version": "3.15.2", "sideEffects": false, "author": "Jan Amann ", "funding": [ diff --git a/packages/use-intl/CHANGELOG.md b/packages/use-intl/CHANGELOG.md index 9fe002bbd..e92ab5cfb 100644 --- a/packages/use-intl/CHANGELOG.md +++ b/packages/use-intl/CHANGELOG.md @@ -3,12 +3,6 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -## 3.15.3-canary.0 (2024-06-20) - -### Bug Fixes - -* Try out updated credits ([4fd9b20](https://github.com/amannn/next-intl/commit/4fd9b20bff0b6f79506e3c0053a8672644a387cf)) – by @amannn - ## 3.15.2 (2024-06-19) **Note:** Version bump only for package use-intl diff --git a/packages/use-intl/package.json b/packages/use-intl/package.json index d305a5170..2f0b3adc1 100644 --- a/packages/use-intl/package.json +++ b/packages/use-intl/package.json @@ -1,6 +1,6 @@ { "name": "use-intl", - "version": "3.15.3-canary.0", + "version": "3.15.2", "sideEffects": false, "author": "Jan Amann ", "description": "Internationalization (i18n) for React", From 8d3ab38bbd46f814d7f5a924fd7e6d517070621e Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 26 Jun 2024 21:17:43 +0200 Subject: [PATCH 6/8] fix: Prefer more specific routes in `usePathname` when detecting the currently active pathname for localized pathnames --- packages/next-intl/package.json | 4 +- packages/next-intl/src/middleware/utils.tsx | 62 +------------------ .../createLocalizedPathnamesNavigation.tsx | 4 +- .../next-intl/src/navigation/shared/utils.tsx | 39 ++++++------ packages/next-intl/src/shared/utils.tsx | 58 +++++++++++++++++ .../next-intl/test/middleware/utils.test.tsx | 61 +----------------- ...reateLocalizedPathnamesNavigation.test.tsx | 38 ++++++++++-- packages/next-intl/test/shared/utils.test.tsx | 61 +++++++++++++++++- 8 files changed, 178 insertions(+), 149 deletions(-) diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 90e625614..ddc634c3f 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -128,11 +128,11 @@ }, { "path": "dist/production/navigation.react-client.js", - "limit": "3.235 KB" + "limit": "3.355 KB" }, { "path": "dist/production/navigation.react-server.js", - "limit": "17.84 KB" + "limit": "17.975 KB" }, { "path": "dist/production/server.react-client.js", diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 829cefec7..2ac28b46c 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -6,6 +6,7 @@ import { } from '../routing/types'; import { getLocalePrefix, + getSortedPathnames, matchesPathname, prefixPathname, templateToRegex @@ -15,65 +16,6 @@ export function getFirstPathnameSegment(pathname: string) { return pathname.split('/')[1]; } -function isOptionalCatchAllSegment(pathname: string) { - return pathname.includes('[[...'); -} - -function isCatchAllSegment(pathname: string) { - return pathname.includes('[...'); -} - -function isDynamicSegment(pathname: string) { - return pathname.includes('['); -} - -export function comparePathnamePairs(a: string, b: string): number { - const pathA = a.split('/'); - const pathB = b.split('/'); - - const maxLength = Math.max(pathA.length, pathB.length); - for (let i = 0; i < maxLength; i++) { - const segmentA = pathA[i]; - const segmentB = pathB[i]; - - // If one of the paths ends, prioritize the shorter path - if (!segmentA && segmentB) return -1; - if (segmentA && !segmentB) return 1; - - // Prioritize static segments over dynamic segments - if (!isDynamicSegment(segmentA) && isDynamicSegment(segmentB)) return -1; - if (isDynamicSegment(segmentA) && !isDynamicSegment(segmentB)) return 1; - - // Prioritize non-catch-all segments over catch-all segments - if (!isCatchAllSegment(segmentA) && isCatchAllSegment(segmentB)) return -1; - if (isCatchAllSegment(segmentA) && !isCatchAllSegment(segmentB)) return 1; - - // Prioritize non-optional catch-all segments over optional catch-all segments - if ( - !isOptionalCatchAllSegment(segmentA) && - isOptionalCatchAllSegment(segmentB) - ) { - return -1; - } - if ( - isOptionalCatchAllSegment(segmentA) && - !isOptionalCatchAllSegment(segmentB) - ) { - return 1; - } - - if (segmentA === segmentB) continue; - } - - // Both pathnames are completely static - return 0; -} - -export function getSortedPathnames(pathnames: Array) { - const sortedPathnames = pathnames.sort(comparePathnamePairs); - return sortedPathnames; -} - export function getInternalTemplate< AppLocales extends Locales, AppPathnames extends Pathnames @@ -112,7 +54,7 @@ export function getInternalTemplate< } // Try to find an internal pathname that matches (this can be the case - // if all localized pathnames are different from the internal pathnames). + // if all localized pathnames are different from the internal pathnames) for (const internalPathname of Object.keys(pathnames)) { if (matchesPathname(internalPathname, pathname)) { return [undefined, internalPathname]; diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx index ad05b8626..ff452f253 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx @@ -149,9 +149,7 @@ export default function createLocalizedPathnamesNavigation< const locale = useTypedLocale(); // @ts-expect-error -- Mirror the behavior from Next.js, where `null` is returned when `usePathname` is used outside of Next, but the types indicate that a string is always returned. - return pathname - ? getRoute({pathname, locale, pathnames: config.pathnames}) - : pathname; + return pathname ? getRoute(locale, pathname, config.pathnames) : pathname; } function getPathname({ diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index ecbf98a26..d862ca16f 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,7 +1,7 @@ import type {ParsedUrlQueryInput} from 'node:querystring'; import type {UrlObject} from 'url'; import {Locales, Pathnames} from '../../routing/types'; -import {matchesPathname} from '../../shared/utils'; +import {matchesPathname, getSortedPathnames} from '../../shared/utils'; import StrictParams from './StrictParams'; type SearchParamValue = ParsedUrlQueryInput[keyof ParsedUrlQueryInput]; @@ -152,28 +152,29 @@ export function compileLocalizedPathname({ } } -export function getRoute({ - locale, - pathname, - pathnames -}: { - locale: AppLocales[number]; - pathname: string; - pathnames: Pathnames; -}) { +export function getRoute( + locale: AppLocales[number], + pathname: string, + pathnames: Pathnames +): keyof Pathnames { + const sortedPathnames = getSortedPathnames(Object.keys(pathnames)); const decoded = decodeURI(pathname); - let template = Object.entries(pathnames).find(([, routePath]) => { - const routePathname = - typeof routePath !== 'string' ? routePath[locale] : routePath; - return matchesPathname(routePathname, decoded); - })?.[0]; - - if (!template) { - template = pathname; + for (const internalPathname of sortedPathnames) { + const localizedPathnamesOrPathname = pathnames[internalPathname]; + if (typeof localizedPathnamesOrPathname === 'string') { + const localizedPathname = localizedPathnamesOrPathname; + if (matchesPathname(localizedPathname, decoded)) { + return internalPathname; + } + } else { + if (matchesPathname(localizedPathnamesOrPathname[locale], decoded)) { + return internalPathname; + } + } } - return template as keyof Pathnames; + return pathname as keyof Pathnames; } export function getBasePath( diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index ba362dd40..589e60457 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -132,3 +132,61 @@ export function templateToRegex(template: string): RegExp { return new RegExp(`^${regexPattern}$`); } + +function isOptionalCatchAllSegment(pathname: string) { + return pathname.includes('[[...'); +} + +function isCatchAllSegment(pathname: string) { + return pathname.includes('[...'); +} + +function isDynamicSegment(pathname: string) { + return pathname.includes('['); +} + +function comparePathnamePairs(a: string, b: string): number { + const pathA = a.split('/'); + const pathB = b.split('/'); + + const maxLength = Math.max(pathA.length, pathB.length); + for (let i = 0; i < maxLength; i++) { + const segmentA = pathA[i]; + const segmentB = pathB[i]; + + // If one of the paths ends, prioritize the shorter path + if (!segmentA && segmentB) return -1; + if (segmentA && !segmentB) return 1; + + // Prioritize static segments over dynamic segments + if (!isDynamicSegment(segmentA) && isDynamicSegment(segmentB)) return -1; + if (isDynamicSegment(segmentA) && !isDynamicSegment(segmentB)) return 1; + + // Prioritize non-catch-all segments over catch-all segments + if (!isCatchAllSegment(segmentA) && isCatchAllSegment(segmentB)) return -1; + if (isCatchAllSegment(segmentA) && !isCatchAllSegment(segmentB)) return 1; + + // Prioritize non-optional catch-all segments over optional catch-all segments + if ( + !isOptionalCatchAllSegment(segmentA) && + isOptionalCatchAllSegment(segmentB) + ) { + return -1; + } + if ( + isOptionalCatchAllSegment(segmentA) && + !isOptionalCatchAllSegment(segmentB) + ) { + return 1; + } + + if (segmentA === segmentB) continue; + } + + // Both pathnames are completely static + return 0; +} + +export function getSortedPathnames(pathnames: Array) { + return pathnames.sort(comparePathnamePairs); +} diff --git a/packages/next-intl/test/middleware/utils.test.tsx b/packages/next-intl/test/middleware/utils.test.tsx index 41b4c1aa6..a11ba349e 100644 --- a/packages/next-intl/test/middleware/utils.test.tsx +++ b/packages/next-intl/test/middleware/utils.test.tsx @@ -3,8 +3,7 @@ import { formatPathnameTemplate, getInternalTemplate, getNormalizedPathname, - getRouteParams, - getSortedPathnames + getRouteParams } from '../../src/middleware/utils'; describe('getNormalizedPathname', () => { @@ -169,61 +168,3 @@ describe('getInternalTemplate', () => { ]); }); }); - -describe('getSortedPathnames', () => { - it('works for static routes that include the root', () => { - expect(getSortedPathnames(['/', '/foo', '/test'])).toEqual([ - '/', - '/foo', - '/test' - ]); - }); - - it('should prioritize non-catch-all routes over catch-all routes', () => { - expect( - getSortedPathnames(['/categories/[...slug]', '/categories/new']) - ).toEqual(['/categories/new', '/categories/[...slug]']); - }); - - it('should prioritize static routes over optional catch-all routes', () => { - expect( - getSortedPathnames(['/categories/[[...slug]]', '/categories']) - ).toEqual(['/categories', '/categories/[[...slug]]']); - }); - - it('should prioritize more specific routes over dynamic routes', () => { - expect( - getSortedPathnames(['/categories/[slug]', '/categories/new']) - ).toEqual(['/categories/new', '/categories/[slug]']); - }); - - it('should prioritize dynamic routes over catch-all routes', () => { - expect( - getSortedPathnames(['/categories/[...slug]', '/categories/[slug]']) - ).toEqual(['/categories/[slug]', '/categories/[...slug]']); - }); - - it('should prioritize more specific nested routes over dynamic routes', () => { - expect( - getSortedPathnames([ - '/articles/[category]/[articleSlug]', - '/articles/[category]/new' - ]) - ).toEqual([ - '/articles/[category]/new', - '/articles/[category]/[articleSlug]' - ]); - }); - - it('should prioritize more specific nested routes over catch-all routes', () => { - expect( - getSortedPathnames([ - '/articles/[category]/[...articleSlug]', - '/articles/[category]/new' - ]) - ).toEqual([ - '/articles/[category]/new', - '/articles/[category]/[...articleSlug]' - ]); - }); -}); diff --git a/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx index 57806bbc2..41d46435d 100644 --- a/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx @@ -11,20 +11,28 @@ import {Pathnames} from '../../../src/routing'; vi.mock('next/navigation'); -const locales = ['en', 'de'] as const; +const locales = ['en', 'de', 'ja'] as const; const pathnames = { '/': '/', '/about': { en: '/about', - de: '/ueber-uns' + de: '/ueber-uns', + ja: '/約' }, '/news/[articleSlug]-[articleId]': { en: '/news/[articleSlug]-[articleId]', - de: '/neuigkeiten/[articleSlug]-[articleId]' + de: '/neuigkeiten/[articleSlug]-[articleId]', + ja: '/ニュース/[articleSlug]-[articleId]' }, '/categories/[...parts]': { en: '/categories/[...parts]', - de: '/kategorien/[...parts]' + de: '/kategorien/[...parts]', + ja: '/カテゴリ/[...parts]' + }, + '/categories/new': { + en: '/categories/new', + de: '/kategorien/neu', + ja: '/カテゴリ/新規' }, '/catch-all/[[...parts]]': '/catch-all/[[...parts]]' } satisfies Pathnames; @@ -83,6 +91,28 @@ describe("localePrefix: 'as-needed'", () => { screen.getByText('/news/[articleSlug]-[articleId]'); }); + it('returns the internal pathname for a more specific pathname that overlaps with another pathname', () => { + function Component() { + const pathname = usePathname(); + return <>{pathname}; + } + + vi.mocked(useNextPathname).mockImplementation(() => '/en/categories/new'); + render(); + screen.getByText('/categories/new'); + }); + + it('returns an encoded pathname correctly', () => { + function Component() { + const pathname = usePathname(); + return <>{pathname}; + } + vi.mocked(useParams).mockImplementation(() => ({locale: 'ja'})); + vi.mocked(useNextPathname).mockImplementation(() => '/ja/%E7%B4%84'); + render(); + screen.getByText('/about'); + }); + it('returns the internal pathname a non-default locale', () => { vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); diff --git a/packages/next-intl/test/shared/utils.test.tsx b/packages/next-intl/test/shared/utils.test.tsx index 284ba65a3..0d5b2e7bc 100644 --- a/packages/next-intl/test/shared/utils.test.tsx +++ b/packages/next-intl/test/shared/utils.test.tsx @@ -3,7 +3,8 @@ import { hasPathnamePrefixed, unprefixPathname, matchesPathname, - prefixPathname + prefixPathname, + getSortedPathnames } from '../../src/shared/utils'; describe('prefixPathname', () => { @@ -114,3 +115,61 @@ describe('matchesPathname', () => { ).toBe(false); }); }); + +describe('getSortedPathnames', () => { + it('works for static routes that include the root', () => { + expect(getSortedPathnames(['/', '/foo', '/test'])).toEqual([ + '/', + '/foo', + '/test' + ]); + }); + + it('should prioritize non-catch-all routes over catch-all routes', () => { + expect( + getSortedPathnames(['/categories/[...slug]', '/categories/new']) + ).toEqual(['/categories/new', '/categories/[...slug]']); + }); + + it('should prioritize static routes over optional catch-all routes', () => { + expect( + getSortedPathnames(['/categories/[[...slug]]', '/categories']) + ).toEqual(['/categories', '/categories/[[...slug]]']); + }); + + it('should prioritize more specific routes over dynamic routes', () => { + expect( + getSortedPathnames(['/categories/[slug]', '/categories/new']) + ).toEqual(['/categories/new', '/categories/[slug]']); + }); + + it('should prioritize dynamic routes over catch-all routes', () => { + expect( + getSortedPathnames(['/categories/[...slug]', '/categories/[slug]']) + ).toEqual(['/categories/[slug]', '/categories/[...slug]']); + }); + + it('should prioritize more specific nested routes over dynamic routes', () => { + expect( + getSortedPathnames([ + '/articles/[category]/[articleSlug]', + '/articles/[category]/new' + ]) + ).toEqual([ + '/articles/[category]/new', + '/articles/[category]/[articleSlug]' + ]); + }); + + it('should prioritize more specific nested routes over catch-all routes', () => { + expect( + getSortedPathnames([ + '/articles/[category]/[...articleSlug]', + '/articles/[category]/new' + ]) + ).toEqual([ + '/articles/[category]/new', + '/articles/[category]/[...articleSlug]' + ]); + }); +}); From 7976376eb0ffc1d4295b5126abef431006683f93 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 11 Jul 2024 11:35:02 +0200 Subject: [PATCH 7/8] feat: Support `trailingSlash: true` in Next.js config (#1188) Ref https://github.com/amannn/next-intl/issues/1184 Ref https://github.com/amannn/next-intl/issues/668 --- docs/pages/docs/routing/middleware.mdx | 6 + .../next.config.mjs | 1 + .../package.json | 7 +- .../playwright.config.ts | 5 +- .../tests/getAlternateLinks.ts | 13 ++ .../tests/main.spec.ts | 11 +- .../tests/trailing-slash.spec.ts | 81 ++++++++++++ packages/next-intl/package.json | 6 +- .../getAlternateLinksHeaderValue.test.tsx | 80 +++++++++++- .../getAlternateLinksHeaderValue.tsx | 3 + .../src/middleware/middleware.test.tsx | 120 +++++++++++++++++- .../next-intl/src/middleware/middleware.tsx | 7 +- packages/next-intl/src/middleware/utils.tsx | 23 ++-- ...reateLocalizedPathnamesNavigation.test.tsx | 80 +++++++++++- packages/next-intl/src/plugin.tsx | 59 ++++----- packages/next-intl/src/shared/utils.tsx | 32 ++++- 16 files changed, 469 insertions(+), 65 deletions(-) create mode 100644 examples/example-app-router-playground/tests/getAlternateLinks.ts create mode 100644 examples/example-app-router-playground/tests/trailing-slash.spec.ts diff --git a/docs/pages/docs/routing/middleware.mdx b/docs/pages/docs/routing/middleware.mdx index b205b8a70..98715b8c7 100644 --- a/docs/pages/docs/routing/middleware.mdx +++ b/docs/pages/docs/routing/middleware.mdx @@ -301,6 +301,12 @@ export const config = { }; ``` +### Trailing slash + +If you have [`trailingSlash`](https://nextjs.org/docs/app/api-reference/next-config-js/trailingSlash) set to `true` in your Next.js config, this setting will be taken into account when the middleware generates pathnames, e.g. for redirects. + +Note that if you're using [localized pathnames](/docs/routing#pathnames), your internal and external pathnames can be defined either with or without a trailing slash as they will be normalized internally. + ## Composing other middlewares By calling `createMiddleware`, you'll receive a function of the following type: diff --git a/examples/example-app-router-playground/next.config.mjs b/examples/example-app-router-playground/next.config.mjs index 0789d88c5..6a24c89b9 100644 --- a/examples/example-app-router-playground/next.config.mjs +++ b/examples/example-app-router-playground/next.config.mjs @@ -4,6 +4,7 @@ import createNextIntlPlugin from 'next-intl/plugin'; const withNextIntl = createNextIntlPlugin('./src/i18n.tsx'); export default withNextIntl({ + trailingSlash: process.env.TRAILING_SLASH === 'true', experimental: { staleTimes: { // Next.js 14.2 broke `locale-prefix-never.spec.ts`. diff --git a/examples/example-app-router-playground/package.json b/examples/example-app-router-playground/package.json index 1efd8be5b..464804d7c 100644 --- a/examples/example-app-router-playground/package.json +++ b/examples/example-app-router-playground/package.json @@ -4,9 +4,10 @@ "scripts": { "dev": "next dev", "lint": "eslint src && tsc", - "test": "pnpm run test:playwright && pnpm run test:playwright:locale-prefix-never && pnpm run test:jest", - "test:playwright": "playwright test", - "test:playwright:locale-prefix-never": "NEXT_PUBLIC_LOCALE_PREFIX=never pnpm build && NEXT_PUBLIC_LOCALE_PREFIX=never playwright test", + "test": "pnpm run test:playwright:main && pnpm run test:playwright:locale-prefix-never && pnpm run test:playwright:trailing-slash && pnpm run test:jest", + "test:playwright:main": "TEST_MATCH=main.spec.ts playwright test", + "test:playwright:locale-prefix-never": "NEXT_PUBLIC_LOCALE_PREFIX=never pnpm build && TEST_MATCH=locale-prefix-never.spec.ts playwright test", + "test:playwright:trailing-slash": "TRAILING_SLASH=true pnpm build && TEST_MATCH=trailing-slash.spec.ts playwright test", "test:jest": "jest", "build": "next build", "start": "next start" diff --git a/examples/example-app-router-playground/playwright.config.ts b/examples/example-app-router-playground/playwright.config.ts index 39a40b287..cdd5c90ba 100644 --- a/examples/example-app-router-playground/playwright.config.ts +++ b/examples/example-app-router-playground/playwright.config.ts @@ -7,10 +7,7 @@ const PORT = process.env.CI ? 3004 : 3000; const config: PlaywrightTestConfig = { retries: process.env.CI ? 1 : 0, - testMatch: - process.env.NEXT_PUBLIC_LOCALE_PREFIX === 'never' - ? 'locale-prefix-never.spec.ts' - : 'main.spec.ts', + testMatch: process.env.TEST_MATCH || 'main.spec.ts', testDir: './tests', projects: [ { diff --git a/examples/example-app-router-playground/tests/getAlternateLinks.ts b/examples/example-app-router-playground/tests/getAlternateLinks.ts new file mode 100644 index 000000000..0b9c58d37 --- /dev/null +++ b/examples/example-app-router-playground/tests/getAlternateLinks.ts @@ -0,0 +1,13 @@ +import {APIResponse} from '@playwright/test'; + +export default async function getAlternateLinks(response: APIResponse) { + return ( + response + .headers() + .link.split(', ') + // On CI, Playwright uses a different host somehow + .map((cur) => cur.replace(/0\.0\.0\.0/g, 'localhost')) + // Normalize ports + .map((cur) => cur.replace(/localhost:\d{4}/g, 'localhost:3000')) + ); +} diff --git a/examples/example-app-router-playground/tests/main.spec.ts b/examples/example-app-router-playground/tests/main.spec.ts index 68685f621..566d5f92e 100644 --- a/examples/example-app-router-playground/tests/main.spec.ts +++ b/examples/example-app-router-playground/tests/main.spec.ts @@ -1,4 +1,5 @@ import {test as it, expect, Page, BrowserContext} from '@playwright/test'; +import getAlternateLinks from './getAlternateLinks'; const describe = it.describe; @@ -541,15 +542,7 @@ it('keeps search params for redirects', async ({browser}) => { it('sets alternate links', async ({request}) => { async function getLinks(pathname: string) { - return ( - (await request.get(pathname)) - .headers() - .link.split(', ') - // On CI, Playwright uses a different host somehow - .map((cur) => cur.replace(/0\.0\.0\.0/g, 'localhost')) - // Normalize ports - .map((cur) => cur.replace(/localhost:\d{4}/g, 'localhost:3000')) - ); + return getAlternateLinks(await request.get(pathname)); } for (const pathname of ['/', '/en', '/de']) { diff --git a/examples/example-app-router-playground/tests/trailing-slash.spec.ts b/examples/example-app-router-playground/tests/trailing-slash.spec.ts new file mode 100644 index 000000000..f1cc5458b --- /dev/null +++ b/examples/example-app-router-playground/tests/trailing-slash.spec.ts @@ -0,0 +1,81 @@ +import {test as it, expect} from '@playwright/test'; +import getAlternateLinks from './getAlternateLinks'; + +it('redirects to a locale prefix correctly', async ({request}) => { + const response = await request.get('/', { + maxRedirects: 0, + headers: { + 'Accept-Language': 'de' + } + }); + expect(response.status()).toBe(307); + expect(response.headers().location).toBe('/de/'); +}); + +it('redirects a localized pathname correctly', async ({request}) => { + const response = await request.get('/de/nested/', {maxRedirects: 0}); + expect(response.status()).toBe(307); + expect(response.headers().location).toBe('/de/verschachtelt/'); +}); + +it('redirects a page with a missing trailing slash', async ({request}) => { + expect((await request.get('/de', {maxRedirects: 0})).headers().location).toBe( + '/de/' + ); + expect( + (await request.get('/de/client', {maxRedirects: 0})).headers().location + ).toBe('/de/client/'); +}); + +it('renders page content', async ({page}) => { + await page.goto('/'); + await page.getByRole('heading', {name: 'Home'}).waitFor(); + + await page.goto('/de/'); + await page.getByRole('heading', {name: 'Start'}).waitFor(); +}); + +it('renders links correctly', async ({page}) => { + await page.goto('/de/'); + await expect(page.getByRole('link', {name: 'Client-Seite'})).toHaveAttribute( + 'href', + '/de/client/' + ); + await expect( + page.getByRole('link', {name: 'Verschachtelte Seite'}) + ).toHaveAttribute('href', '/de/verschachtelt/'); +}); + +it('returns alternate links correctly', async ({request}) => { + async function getLinks(pathname: string) { + return getAlternateLinks(await request.get(pathname)); + } + + for (const pathname of ['/', '/en', '/de']) { + expect(await getLinks(pathname)).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="es"', + '; rel="alternate"; hreflang="ja"', + '; rel="alternate"; hreflang="x-default"' + ]); + } + + for (const pathname of ['/nested', '/en/nested', '/de/nested']) { + expect(await getLinks(pathname)).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="es"', + '; rel="alternate"; hreflang="ja"', + '; rel="alternate"; hreflang="x-default"' + ]); + } +}); + +it('can handle dynamic params', async ({page}) => { + await page.goto('/news/3'); + await page.getByRole('heading', {name: 'News article #3'}).waitFor(); + + await page.goto('/de/neuigkeiten/3'); + await page.getByRole('heading', {name: 'News-Artikel #3'}).waitFor(); +}); diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 4d9b8e450..0d77ee97c 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -128,11 +128,11 @@ }, { "path": "dist/production/navigation.react-client.js", - "limit": "3.355 KB" + "limit": "3.465 KB" }, { "path": "dist/production/navigation.react-server.js", - "limit": "17.975 KB" + "limit": "18.075 KB" }, { "path": "dist/production/server.react-client.js", @@ -144,7 +144,7 @@ }, { "path": "dist/production/middleware.js", - "limit": "6.42 KB" + "limit": "6.485 KB" }, { "path": "dist/production/routing.js", diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx index 64390eb3e..5cc859525 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment edge-runtime import {NextRequest} from 'next/server'; -import {it, expect, describe} from 'vitest'; +import {it, expect, describe, beforeEach, afterEach} from 'vitest'; import {Pathnames} from '../routing'; import {receiveConfig} from './config'; import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue'; @@ -552,3 +552,81 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); } ); + +describe('trailingSlash: true', () => { + beforeEach(() => { + process.env._next_intl_trailing_slash = 'true'; + }); + afterEach(() => { + delete process.env._next_intl_trailing_slash; + }); + + it('adds a trailing slash to pathnames', () => { + const config = receiveConfig({ + defaultLocale: 'en', + locales: ['en', 'es'], + localePrefix: 'as-needed' + }); + + expect( + getAlternateLinksHeaderValue({ + config, + request: new NextRequest(new URL('https://example.com/about')), + resolvedLocale: 'en' + }).split(', ') + ).toEqual([ + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="x-default"` + ]); + }); + + describe('localized pathnames', () => { + const config = receiveConfig({ + defaultLocale: 'en', + locales: ['en', 'es'], + localePrefix: 'as-needed' + }); + const pathnames = { + '/': '/', + '/about': { + en: '/about', + es: '/acerca' + } + }; + + it('adds a trailing slash to nested pathnames when localized pathnames are used', () => { + ['/about', '/about/'].forEach((pathname) => { + expect( + getAlternateLinksHeaderValue({ + config, + request: new NextRequest(new URL('https://example.com' + pathname)), + resolvedLocale: 'en', + localizedPathnames: pathnames['/about'] + }).split(', ') + ).toEqual([ + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="x-default"` + ]); + }); + }); + + it('adds a trailing slash to the root pathname when localized pathnames are used', () => { + ['', '/'].forEach((pathname) => { + expect( + getAlternateLinksHeaderValue({ + config, + request: new NextRequest(new URL('https://example.com' + pathname)), + resolvedLocale: 'en', + localizedPathnames: pathnames['/'] + }).split(', ') + ).toEqual([ + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="x-default"` + ]); + }); + }); + }); +}); diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 8eb22e412..8f8005124 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -1,5 +1,6 @@ import {NextRequest} from 'next/server'; import {Locales, Pathnames} from '../routing/types'; +import {normalizeTrailingSlash} from '../shared/utils'; import {MiddlewareRoutingConfig} from './config'; import { applyBasePath, @@ -44,6 +45,8 @@ export default function getAlternateLinksHeaderValue< ); function getAlternateEntry(url: URL, locale: string) { + url.pathname = normalizeTrailingSlash(url.pathname); + if (request.nextUrl.basePath) { url = new URL(url); url.pathname = applyBasePath(url.pathname, request.nextUrl.basePath); diff --git a/packages/next-intl/src/middleware/middleware.test.tsx b/packages/next-intl/src/middleware/middleware.test.tsx index e4573b953..699921fc9 100644 --- a/packages/next-intl/src/middleware/middleware.test.tsx +++ b/packages/next-intl/src/middleware/middleware.test.tsx @@ -3,7 +3,7 @@ import {RequestCookies} from 'next/dist/compiled/@edge-runtime/cookies'; import {NextRequest, NextResponse} from 'next/server'; import {pathToRegexp} from 'path-to-regexp'; -import {it, describe, vi, beforeEach, expect, Mock} from 'vitest'; +import {it, describe, vi, beforeEach, expect, Mock, afterEach} from 'vitest'; import createMiddleware from '../middleware'; import {Pathnames} from '../routing'; import {COOKIE_LOCALE_NAME} from '../shared/constants'; @@ -1061,6 +1061,22 @@ describe('prefix-based routing', () => { }); }); + describe('trailingSlash: true', () => { + beforeEach(() => { + process.env._next_intl_trailing_slash = 'true'; + }); + afterEach(() => { + delete process.env._next_intl_trailing_slash; + }); + + it('applies a trailing slash when redirecting to a locale', () => { + middleware(createMockRequest('/')); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en/' + ); + }); + }); + describe('localized pathnames', () => { const middlewareWithPathnames = createMiddleware({ defaultLocale: 'en', @@ -1421,6 +1437,108 @@ describe('prefix-based routing', () => { 'http://localhost:3000/en/about' ); }); + + describe('trailingSlash: true', () => { + beforeEach(() => { + process.env._next_intl_trailing_slash = 'true'; + }); + afterEach(() => { + delete process.env._next_intl_trailing_slash; + }); + + it.each(['/de/ueber/', '/de/ueber'])( + 'renders a localized pathname where the internal pathname was defined without a trailing slash', + (pathname) => { + middlewareWithPathnames(createMockRequest(pathname)); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de/about/' + ); + } + ); + + it.each(['/de/about/', '/de/about'])( + 'redirects a localized pathname where the internal pathname was defined without a trailing slash', + (pathname) => { + middlewareWithPathnames(createMockRequest(pathname)); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect( + MockedNextResponse.redirect.mock.calls[0][0].toString() + ).toBe('http://localhost:3000/de/ueber/'); + } + ); + + it.each(['/de/ueber/', '/de/ueber'])( + 'renders a localized pathname where the internal pathname was defined with a trailing slash', + (pathname) => { + createMiddleware({ + defaultLocale: 'en', + locales: ['de'], + localePrefix: 'always', + pathnames: { + '/about/': {de: '/ueber/'} + } + })(createMockRequest(pathname)); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de/about/' + ); + } + ); + + it.each(['/de/about/', '/de/about'])( + 'redirects a localized pathname where the internal pathname was defined with a trailing slash', + (pathname) => { + createMiddleware({ + defaultLocale: 'en', + locales: ['de'], + localePrefix: 'always', + pathnames: { + '/about/': {de: '/ueber/'} + } + })(createMockRequest(pathname)); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect( + MockedNextResponse.redirect.mock.calls[0][0].toString() + ).toBe('http://localhost:3000/de/ueber/'); + } + ); + + it.each([ + [ + '/en/products/t-shirts', + 'http://localhost:3000/en/products/t-shirts/' + ], + [ + '/en/products/t-shirts/', + 'http://localhost:3000/en/products/t-shirts/' + ], + [ + '/de/produkte/t-shirts', + 'http://localhost:3000/de/products/t-shirts/' + ], + [ + '/de/produkte/t-shirts/', + 'http://localhost:3000/de/products/t-shirts/' + ] + ])('renders pages with dynamic params', (pathname, rewrite) => { + middlewareWithPathnames(createMockRequest(pathname)); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + rewrite + ); + }); + }); }); describe('custom prefixes', () => { diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 14a6a411e..555941dd6 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -1,7 +1,11 @@ import {NextRequest, NextResponse} from 'next/server'; import {Locales, Pathnames} from '../routing/types'; import {HEADER_LOCALE_NAME} from '../shared/constants'; -import {getLocalePrefix, matchesPathname} from '../shared/utils'; +import { + getLocalePrefix, + matchesPathname, + normalizeTrailingSlash +} from '../shared/utils'; import {MiddlewareRoutingConfigInput, receiveConfig} from './config'; import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue'; import resolveLocale from './resolveLocale'; @@ -14,7 +18,6 @@ import { getNormalizedPathname, isLocaleSupportedOnDomain, applyBasePath, - normalizeTrailingSlash, formatPathname, getLocaleAsPrefix } from './utils'; diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 1e15e8a43..55c84e508 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -9,6 +9,7 @@ import { getLocalePrefix, getSortedPathnames, matchesPathname, + normalizeTrailingSlash, prefixPathname, templateToRegex } from '../shared/utils'; @@ -79,6 +80,10 @@ export function formatTemplatePathname( } targetPathname += formatPathnameTemplate(targetTemplate, params); + + // A pathname with an optional catchall like `/categories/[[...slug]]` + // should be normalized to `/categories` if the catchall is not present + // and no trailing slash is configured targetPathname = normalizeTrailingSlash(targetPathname); return targetPathname; @@ -174,12 +179,17 @@ export function getPathnameMatch( } export function getRouteParams(template: string, pathname: string) { - const regex = templateToRegex(template); - const match = regex.exec(pathname); + const normalizedPathname = normalizeTrailingSlash(pathname); + const normalizedTemplate = normalizeTrailingSlash(template); + + const regex = templateToRegex(normalizedTemplate); + const match = regex.exec(normalizedPathname); if (!match) return undefined; const params: Record = {}; for (let i = 1; i < match.length; i++) { - const key = template.match(/\[([^\]]+)\]/g)?.[i - 1].replace(/[[\]]/g, ''); + const key = normalizedTemplate + .match(/\[([^\]]+)\]/g) + ?.[i - 1].replace(/[[\]]/g, ''); if (key) params[key] = match[i]; } return params; @@ -277,13 +287,6 @@ export function applyBasePath(pathname: string, basePath: string) { return normalizeTrailingSlash(basePath + pathname); } -export function normalizeTrailingSlash(pathname: string) { - if (pathname !== '/' && pathname.endsWith('/')) { - pathname = pathname.slice(0, -1); - } - return pathname; -} - export function getLocaleAsPrefix( locale: AppLocales[number] ) { diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx index 7f6d2e6af..3858e1bba 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx @@ -5,7 +5,7 @@ import { useRouter as useNextRouter } from 'next/navigation'; import React, {ComponentProps} from 'react'; -import {it, describe, vi, beforeEach, expect, Mock} from 'vitest'; +import {it, describe, vi, beforeEach, expect, Mock, afterEach} from 'vitest'; import {Pathnames} from '../../routing'; import createLocalizedPathnamesNavigation from './createLocalizedPathnamesNavigation'; @@ -148,6 +148,84 @@ describe("localePrefix: 'as-needed'", () => { rerender(); screen.getByText('/de/unknown'); }); + + describe('trailingSlash: true', () => { + beforeEach(() => { + process.env._next_intl_trailing_slash = 'true'; + }); + afterEach(() => { + delete process.env._next_intl_trailing_slash; + }); + + function Component() { + const pathname = createLocalizedPathnamesNavigation({ + locales, + pathnames: { + '/': '/', + // (w) + '/about/': { + en: '/about/', // (w) + de: '/ueber-uns', // (wo) + ja: '/約/' // (w) + }, + // (wo) + '/news': { + en: '/news', // (wo) + de: '/neuigkeiten/', // (w) + ja: '/ニュース' // (wo) + } + } + }).usePathname(); + return <>{pathname}; + } + + it('returns the root', () => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); + vi.mocked(useNextPathname).mockImplementation(() => '/'); + render(); + screen.getByText('/'); + }); + + it.each(['/news', '/news/'])( + 'can return an internal pathname without a trailing slash for the default locale (%s)', + (pathname) => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); + vi.mocked(useNextPathname).mockImplementation(() => pathname); + render(); + screen.getByText('/news'); + } + ); + + it.each(['/de/neuigkeiten/', '/de/neuigkeiten'])( + 'can return an internal pathname without a trailing slash for a secondary locale (%s)', + (pathname) => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); + vi.mocked(useNextPathname).mockImplementation(() => pathname); + render(); + screen.getByText('/news'); + } + ); + + it.each(['/about', '/about/'])( + 'can return an internal pathname with a trailing slash for the default locale (%s)', + (pathname) => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); + vi.mocked(useNextPathname).mockImplementation(() => pathname); + render(); + screen.getByText('/about/'); + } + ); + + it.each(['/de/ueber-uns/', '/de/ueber-uns'])( + 'can return an internal pathname with a trailing slash for a secondary locale (%s)', + (pathname) => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); + vi.mocked(useNextPathname).mockImplementation(() => pathname); + render(); + screen.getByText('/about/'); + } + ); + }); }); describe('useRouter', () => { diff --git a/packages/next-intl/src/plugin.tsx b/packages/next-intl/src/plugin.tsx index fac8cb5e3..594da1f8a 100644 --- a/packages/next-intl/src/plugin.tsx +++ b/packages/next-intl/src/plugin.tsx @@ -54,13 +54,15 @@ module.exports = withNextIntl({ function initPlugin(i18nPath?: string, nextConfig?: NextConfig): NextConfig { if (nextConfig?.i18n != null) { console.warn( - "\nnext-intl has found an `i18n` config in your next.config.js. This likely causes conflicts and should therefore be removed if you use the App Router.\n\nIf you're in progress of migrating from the `pages` folder, you can refer to this example: https://github.com/amannn/next-intl/tree/main/examples/example-app-router-migration\n" + "\nnext-intl has found an `i18n` config in your next.config.js. This likely causes conflicts and should therefore be removed if you use the App Router.\n\nIf you're in progress of migrating from the `pages` folder, you can refer to this example: https://next-intl-docs.vercel.app/examples#app-router-migration\n" ); } const useTurbo = process.env.TURBOPACK != null; - let nextIntlConfig; + const nextIntlConfig: Partial = {}; + + // Assign alias for `next-intl/config` if (useTurbo) { if (i18nPath && i18nPath.startsWith('/')) { throw new Error( @@ -69,41 +71,40 @@ function initPlugin(i18nPath?: string, nextConfig?: NextConfig): NextConfig { '\n' ); } - - nextIntlConfig = { - experimental: { - ...nextConfig?.experimental, - turbo: { - ...nextConfig?.experimental?.turbo, - resolveAlias: { - ...nextConfig?.experimental?.turbo?.resolveAlias, - // Turbo aliases don't work with absolute - // paths (see error handling above) - 'next-intl/config': resolveI18nPath(i18nPath) - } + nextIntlConfig.experimental = { + ...nextConfig?.experimental, + turbo: { + ...nextConfig?.experimental?.turbo, + resolveAlias: { + ...nextConfig?.experimental?.turbo?.resolveAlias, + // Turbo aliases don't work with absolute + // paths (see error handling above) + 'next-intl/config': resolveI18nPath(i18nPath) } } }; } else { - nextIntlConfig = { - webpack( - ...[config, options]: Parameters> - ) { - // Webpack requires absolute paths - config.resolve.alias['next-intl/config'] = path.resolve( - config.context, - resolveI18nPath(i18nPath, config.context) - ); - - if (typeof nextConfig?.webpack === 'function') { - return nextConfig.webpack(config, options); - } - - return config; + nextIntlConfig.webpack = function webpack( + ...[config, options]: Parameters> + ) { + // Webpack requires absolute paths + config.resolve.alias['next-intl/config'] = path.resolve( + config.context, + resolveI18nPath(i18nPath, config.context) + ); + if (typeof nextConfig?.webpack === 'function') { + return nextConfig.webpack(config, options); } + return config; }; } + // Forward config + nextIntlConfig.env = { + ...nextConfig?.env, + _next_intl_trailing_slash: nextConfig?.trailingSlash ? 'true' : undefined + }; + return Object.assign({}, nextConfig, nextIntlConfig); } diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 30484b521..3466be4b6 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -99,14 +99,40 @@ export function hasPathnamePrefixed(prefix: string, pathname: string) { return pathname === prefix || pathname.startsWith(`${prefix}/`); } +function hasTrailingSlash() { + try { + return process.env._next_intl_trailing_slash === 'true'; + } catch (e) { + return false; + } +} + +export function normalizeTrailingSlash(pathname: string) { + const trailingSlash = hasTrailingSlash(); + + if (pathname !== '/') { + const pathnameEndsWithSlash = pathname.endsWith('/'); + if (trailingSlash && !pathnameEndsWithSlash) { + pathname += '/'; + } else if (!trailingSlash && pathnameEndsWithSlash) { + pathname = pathname.slice(0, -1); + } + } + + return pathname; +} + export function matchesPathname( /** E.g. `/users/[userId]-[userName]` */ template: string, /** E.g. `/users/23-jane` */ pathname: string ) { - const regex = templateToRegex(template); - return regex.test(pathname); + const normalizedTemplate = normalizeTrailingSlash(template); + const normalizedPathname = normalizeTrailingSlash(pathname); + + const regex = templateToRegex(normalizedTemplate); + return regex.test(normalizedPathname); } export function getLocalePrefix( @@ -158,6 +184,8 @@ function comparePathnamePairs(a: string, b: string): number { if (!segmentA && segmentB) return -1; if (segmentA && !segmentB) return 1; + if (!segmentA && !segmentB) continue; + // Prioritize static segments over dynamic segments if (!isDynamicSegment(segmentA) && isDynamicSegment(segmentB)) return -1; if (isDynamicSegment(segmentA) && !isDynamicSegment(segmentB)) return 1; From b352c103bbf25aed2e946f707fcc0b1e634727af Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 11 Jul 2024 11:39:33 +0200 Subject: [PATCH 8/8] fix: Release please? --- packages/next-intl/src/shared/utils.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 3466be4b6..68adac5b0 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -101,6 +101,7 @@ export function hasPathnamePrefixed(prefix: string, pathname: string) { function hasTrailingSlash() { try { + // Provided via `env` setting in `next.config.js` via the plugin return process.env._next_intl_trailing_slash === 'true'; } catch (e) { return false;