From a8fb0e3d9d793b40676b78aac3bcf182df8bcc2e Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 20 Jun 2024 16:08:05 +0200 Subject: [PATCH 01/90] 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 02/90] 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 03/90] 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 04/90] 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 05/90] 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 06/90] 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 07/90] 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 08/90] 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; From 8b4c7c48ea1f22ace32e26560dd253e7d3d54a46 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 26 Aug 2024 15:27:47 +0200 Subject: [PATCH 09/90] feat!: Automatically inherit `formats` when `NextIntlClientProvider` is rendered from a Server Component (#1191) This should ease the transition from Server to Client Components, as you don't have to manually pass this prop anymore. If you've previously passed this prop manually, you can remove this assignment now. If this is not desired (e.g. because you have a large `formats` object that you don't want to pass to the client side), you can manually opt-out via `formats={{}}` on `NextIntlClientProvider` in order to not provide any formats on the client side. **BREAKING CHANGE:** There's a very rare chance where this can break existing behavior. If you're rendering `NextIntlClientProvider` in a Server Component, you rely on static rendering, but you're not using `unstable_setRequestLocale` (i.e. you're using hooks like `useTranslations` exclusively in Client Components), this can opt your page into dynamic rendering. If this affects you, please provide the `formats` prop explicitly to `NextIntlClientProvider`. --- .../environments/server-client-components.mdx | 2 +- docs/pages/docs/usage/configuration.mdx | 12 +++++--- packages/next-intl/.size-limit.ts | 2 +- .../NextIntlClientProviderServer.test.tsx | 28 +++++++++++++++++-- .../NextIntlClientProviderServer.tsx | 3 ++ .../src/server/react-server/getFormats.tsx | 10 +++++++ .../src/server/react-server/getLocale.tsx | 2 +- 7 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 packages/next-intl/src/server/react-server/getFormats.tsx diff --git a/docs/pages/docs/environments/server-client-components.mdx b/docs/pages/docs/environments/server-client-components.mdx index 24f29beb8..db380de8e 100644 --- a/docs/pages/docs/environments/server-client-components.mdx +++ b/docs/pages/docs/environments/server-client-components.mdx @@ -288,7 +288,7 @@ In particular, page and search params are often a great option because they offe ### Option 3: Providing individual messages -To reduce bundle size, `next-intl` doesn't automatically provide [messages](/docs/usage/configuration#messages) or [formats](/docs/usage/configuration#formats) to Client Components. +To reduce bundle size, `next-intl` doesn't automatically provide [messages](/docs/usage/configuration#messages) to Client Components. If you need to incorporate dynamic state into components that can not be moved to the server side, you can wrap these components with `NextIntlClientProvider` and provide the relevant messages. diff --git a/docs/pages/docs/usage/configuration.mdx b/docs/pages/docs/usage/configuration.mdx index 5e93245a5..6d355ec2c 100644 --- a/docs/pages/docs/usage/configuration.mdx +++ b/docs/pages/docs/usage/configuration.mdx @@ -91,13 +91,13 @@ These props are inherited if you're rendering `NextIntlClientProvider` from a Se 1. `locale` 2. `now` 3. `timeZone` +4. `formats` In contrast, these props can be provided as necessary: 1. `messages` (see [Internationalization in Client Components](/docs/environments/server-client-components#using-internationalization-in-client-components)) -2. `formats` -3. `defaultTranslationValues` -4. `onError` and `getMessageFallback` +2. `defaultTranslationValues` +3. `onError` and `getMessageFallback`
How can I provide non-serializable props like `onError` to `NextIntlClientProvider`? @@ -130,9 +130,9 @@ export default function IntlProvider({ locale={locale} now={now} timeZone={timeZone} + formats={formats} // Provide as necessary messages={messages} - formats={formats} /> ); } @@ -143,6 +143,7 @@ Once you have defined your client-side provider component, you can use it in a S ```tsx filename="layout.tsx" import IntlProvider from './IntlProvider'; import {getLocale, getNow, getTimeZone, getMessages} from 'next-intl/server'; +import formats from './formats'; export default async function RootLayout({children}) { const locale = await getLocale(); @@ -158,6 +159,7 @@ export default async function RootLayout({children}) { now={now} timeZone={timeZone} messages={messages} + formats={formats} > {children} @@ -506,6 +508,8 @@ function Component() { } ``` +Formats are automatically inherited from the server side if you wrap the relevant components in a `NextIntlClientProvider` that is rendered by a Server Component. + ## Default translation values To achieve consistent usage of translation values and reduce redundancy, you can define a set of global default values. This configuration can also be used to apply consistent styling of commonly used rich text elements. diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 22770b383..82df8e36b 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -7,7 +7,7 @@ const config: SizeLimitConfig = [ }, { path: 'dist/production/index.react-server.js', - limit: '14.665 KB' + limit: '14.675 KB' }, { path: 'dist/production/navigation.react-client.js', diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx index 6ce77d6b4..c01771f99 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx @@ -1,4 +1,5 @@ import {expect, it, vi} from 'vitest'; +import getFormats from '../server/react-server/getFormats'; import {getLocale, getNow, getTimeZone} from '../server.react-server'; import NextIntlClientProvider from '../shared/NextIntlClientProvider'; import NextIntlClientProviderServer from './NextIntlClientProviderServer'; @@ -9,6 +10,16 @@ vi.mock('../../src/server/react-server', async () => ({ getTimeZone: vi.fn(async () => 'America/New_York') })); +vi.mock('../../src/server/react-server/getFormats', () => ({ + default: vi.fn(async () => ({ + dateTime: { + short: { + day: 'numeric' + } + } + })) +})); + vi.mock('../../src/shared/NextIntlClientProvider', async () => ({ default: vi.fn(() => 'NextIntlClientProvider') })); @@ -18,7 +29,8 @@ it("doesn't read from headers if all relevant configuration is passed", async () children: null, locale: 'en-GB', now: new Date('2020-02-01T00:00:00.000Z'), - timeZone: 'Europe/London' + timeZone: 'Europe/London', + formats: {} }); expect(result.type).toBe(NextIntlClientProvider); @@ -26,12 +38,14 @@ it("doesn't read from headers if all relevant configuration is passed", async () children: null, locale: 'en-GB', now: new Date('2020-02-01T00:00:00.000Z'), - timeZone: 'Europe/London' + timeZone: 'Europe/London', + formats: {} }); expect(getLocale).not.toHaveBeenCalled(); expect(getNow).not.toHaveBeenCalled(); expect(getTimeZone).not.toHaveBeenCalled(); + expect(getFormats).not.toHaveBeenCalled(); }); it('reads missing configuration from getter functions', async () => { @@ -44,10 +58,18 @@ it('reads missing configuration from getter functions', async () => { children: null, locale: 'en-US', now: new Date('2020-01-01T00:00:00.000Z'), - timeZone: 'America/New_York' + timeZone: 'America/New_York', + formats: { + dateTime: { + short: { + day: 'numeric' + } + } + } }); expect(getLocale).toHaveBeenCalled(); expect(getNow).toHaveBeenCalled(); expect(getTimeZone).toHaveBeenCalled(); + expect(getFormats).toHaveBeenCalled(); }); diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx index 941187dcc..9e809efd7 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx @@ -1,10 +1,12 @@ import React, {ComponentProps} from 'react'; +import getFormats from '../server/react-server/getFormats'; import {getLocale, getNow, getTimeZone} from '../server.react-server'; import BaseNextIntlClientProvider from '../shared/NextIntlClientProvider'; type Props = ComponentProps; export default async function NextIntlClientProviderServer({ + formats, locale, now, timeZone, @@ -14,6 +16,7 @@ export default async function NextIntlClientProviderServer({ Date: Wed, 9 Oct 2024 19:48:56 +0200 Subject: [PATCH 10/90] feat!: Inherit context between providers (#1413) If you have nested providers, previously only the configuration of the innermost one would be applied. With this change, configuration is now passed from one provider to the next, while allowing to override individual props. **BREAKING CHANGE:** There's a very rare chance that this change affects your app, but in case you've previously relied on providers not inheriting from each other, you now have to reset props manually in case you want to retain the prev. behavior. --- docs/pages/docs/usage/configuration.mdx | 51 +++------ packages/next-intl/.size-limit.ts | 8 +- packages/use-intl/.size-limit.ts | 4 +- .../use-intl/src/react/IntlProvider.test.tsx | 106 +++++++++++++++++- packages/use-intl/src/react/IntlProvider.tsx | 31 ++--- 5 files changed, 144 insertions(+), 56 deletions(-) diff --git a/docs/pages/docs/usage/configuration.mdx b/docs/pages/docs/usage/configuration.mdx index 6d355ec2c..ff082fc11 100644 --- a/docs/pages/docs/usage/configuration.mdx +++ b/docs/pages/docs/usage/configuration.mdx @@ -99,41 +99,28 @@ In contrast, these props can be provided as necessary: 2. `defaultTranslationValues` 3. `onError` and `getMessageFallback` +Additionally, nested instances of `NextIntlClientProvider` will inherit configuration from their respective ancestors. Note however that individual props are treated as atomic, therefore e.g. `messages` need to be merged manually—if necessary. +
How can I provide non-serializable props like `onError` to `NextIntlClientProvider`? React limits the types of props that can be passed to Client Components to the ones that are [serializable](https://react.dev/reference/rsc/use-client#serializable-types). Since `onError`, `getMessageFallback` and `defaultTranslationValues` can receive functions, these configuration options can't be automatically inherited by the client side. -In order to define these values, you can wrap `NextIntlClientProvider` with another component that is marked with `'use client'` and defines the relevant props: +In order to define these values on the client side, you can add a provider that defines these props: -```tsx filename="IntlProvider.tsx" +```tsx filename="IntlErrorHandlingProvider.tsx" 'use client'; import {NextIntlClientProvider} from 'next-intl'; -export default function IntlProvider({ - locale, - now, - timeZone, - messages, - formats -}) { +export default function IntlErrorHandlingProvider({children}) { return ( {text} - }} onError={(error) => console.error(error)} getMessageFallback={({namespace, key}) => `${namespace}.${key}`} - // Make sure to forward these props to avoid markup mismatches - locale={locale} - now={now} - timeZone={timeZone} - formats={formats} - // Provide as necessary - messages={messages} - /> + > + {children} + ); } ``` @@ -141,27 +128,19 @@ export default function IntlProvider({ Once you have defined your client-side provider component, you can use it in a Server Component: ```tsx filename="layout.tsx" -import IntlProvider from './IntlProvider'; -import {getLocale, getNow, getTimeZone, getMessages} from 'next-intl/server'; -import formats from './formats'; +import {NextIntlClientProvider} from 'next-intl'; +import {getLocale, getMessages} from 'next-intl/server'; +import IntlErrorHandlingProvider from './IntlErrorHandlingProvider'; export default async function RootLayout({children}) { const locale = await getLocale(); - const now = await getNow(); - const timeZone = await getTimeZone(); const messages = await getMessages(); return ( - - {children} + + {children} @@ -171,7 +150,7 @@ export default async function RootLayout({children}) { By doing this, your provider component will already be part of the client-side bundle and can therefore define and pass functions as props. -**Important:** Be sure to pass explicit `locale`, `timeZone` and `now` props to `NextIntlClientProvider` in this case, since these aren't automatically inherited from a Server Component when you import `NextIntlClientProvider` from a Client Component. +Note that the inner `NextIntlClientProvider` inherits the configuration from the outer one, only the `onError` and `getMessageFallback` functions are added.
@@ -592,7 +571,7 @@ export default getRequestConfig(async ({locale}) => { }); ``` -Note that `onError` and `getMessageFallback` are not automatically inherited by Client Components. If you want to make this functionality available in Client Components, you should provide the same configuration to [`NextIntlClientProvider`](#nextintlclientprovider). +Note that `onError` and `getMessageFallback` are not automatically inherited by Client Components. If you want to make this functionality available in Client Components too, you can however create a [client-side provider](#nextintlclientprovider-non-serializable-props) that defines these props. diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 82df8e36b..e08a63315 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -3,11 +3,11 @@ import type {SizeLimitConfig} from 'size-limit'; const config: SizeLimitConfig = [ { path: 'dist/production/index.react-client.js', - limit: '14.095 KB' + limit: '14.125 KB' }, { path: 'dist/production/index.react-server.js', - limit: '14.675 KB' + limit: '14.765 KB' }, { path: 'dist/production/navigation.react-client.js', @@ -36,12 +36,12 @@ const config: SizeLimitConfig = [ { path: 'dist/esm/index.react-client.js', import: '*', - limit: '14.265 kB' + limit: '14.295 kB' }, { path: 'dist/esm/index.react-client.js', import: '{NextIntlClientProvider}', - limit: '1.425 kB' + limit: '1.55 kB' } ]; diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts index 78561a217..bc777f177 100644 --- a/packages/use-intl/.size-limit.ts +++ b/packages/use-intl/.size-limit.ts @@ -5,14 +5,14 @@ const config: SizeLimitConfig = [ name: './ (ESM)', import: '*', path: 'dist/esm/index.js', - limit: '14.085 kB' + limit: '14.195 kB' }, { name: './ (no useTranslations, ESM)', path: 'dist/esm/index.js', import: '{IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter}', - limit: '2.865 kB' + limit: '2.935 kB' }, { name: './ (CJS)', diff --git a/packages/use-intl/src/react/IntlProvider.test.tsx b/packages/use-intl/src/react/IntlProvider.test.tsx index 6d785d7e7..05b9d585d 100644 --- a/packages/use-intl/src/react/IntlProvider.test.tsx +++ b/packages/use-intl/src/react/IntlProvider.test.tsx @@ -1,7 +1,8 @@ import {fireEvent, render, screen} from '@testing-library/react'; import React, {memo, useState} from 'react'; -import {expect, it} from 'vitest'; +import {expect, it, vi} from 'vitest'; import IntlProvider from './IntlProvider'; +import useNow from './useNow'; import useTranslations from './useTranslations'; it("doesn't re-render context consumers unnecessarily", () => { @@ -43,3 +44,106 @@ it("doesn't re-render context consumers unnecessarily", () => { expect(numCounterRenders).toBe(2); expect(numStaticTextRenders).toBe(1); }); + +it('keeps a consistent context value that does not trigger unnecessary re-renders', () => { + const messages = {StaticText: {hello: 'Hello!'}}; + + let numCounterRenders = 0; + function Counter() { + const [count, setCount] = useState(0); + numCounterRenders++; + + return ( + <> + +

Count: {count}

+ + + + + ); + } + + let numStaticTextRenders = 0; + const StaticText = memo(() => { + const t = useTranslations('StaticText'); + numStaticTextRenders++; + return t('hello'); + }); + + render(); + screen.getByText('Count: 0'); + expect(numCounterRenders).toBe(1); + expect(numStaticTextRenders).toBe(1); + fireEvent.click(screen.getByText('Increment')); + screen.getByText('Count: 1'); + expect(numCounterRenders).toBe(2); + expect(numStaticTextRenders).toBe(1); +}); + +it('passes on configuration in nested providers', () => { + const onError = vi.fn(); + + function Component() { + const now = useNow(); + const t = useTranslations(); + t('unknown'); + return t('now', {now}); + } + + render( + + + + + + ); + + screen.getByText('Now: Jan 1, 2021, 1:00 AM'); + expect(onError.mock.calls.length).toBe(1); +}); + +it('does not merge messages in nested providers', () => { + // This is important because the locale can change + // and the messages from a previous locale should + // not leak into the new locale. + + const onError = vi.fn(); + + function Component() { + const t = useTranslations(); + return t('hello'); + } + + render( + + + + + + ); + + expect(onError.mock.calls.length).toBe(1); +}); diff --git a/packages/use-intl/src/react/IntlProvider.tsx b/packages/use-intl/src/react/IntlProvider.tsx index 4b5820c18..414a2bb3f 100644 --- a/packages/use-intl/src/react/IntlProvider.tsx +++ b/packages/use-intl/src/react/IntlProvider.tsx @@ -1,4 +1,4 @@ -import React, {ReactNode, useMemo} from 'react'; +import React, {ReactNode, useContext, useMemo} from 'react'; import IntlConfig from '../core/IntlConfig'; import { createCache, @@ -23,17 +23,19 @@ export default function IntlProvider({ onError, timeZone }: Props) { + const prevContext = useContext(IntlContext); + // The formatter cache is released when the locale changes. For // long-running apps with a persistent `IntlProvider` at the root, // this can reduce the memory footprint (e.g. in React Native). const cache = useMemo(() => { // eslint-disable-next-line no-unused-expressions locale; - return createCache(); - }, [locale]); + return prevContext?.cache || createCache(); + }, [locale, prevContext?.cache]); const formatters: Formatters = useMemo( - () => createIntlFormatters(cache), - [cache] + () => prevContext?.formatters || createIntlFormatters(cache), + [cache, prevContext?.formatters] ); // Memoizing this value helps to avoid triggering a re-render of all @@ -47,14 +49,16 @@ export default function IntlProvider({ const value = useMemo( () => ({ ...initializeConfig({ - locale, - defaultTranslationValues, - formats, - getMessageFallback, - messages, - now, - onError, - timeZone + locale, // (required by provider) + defaultTranslationValues: + defaultTranslationValues || prevContext?.defaultTranslationValues, + formats: formats || prevContext?.formats, + getMessageFallback: + getMessageFallback || prevContext?.getMessageFallback, + messages: messages || prevContext?.messages, + now: now || prevContext?.now, + onError: onError || prevContext?.onError, + timeZone: timeZone || prevContext?.timeZone }), formatters, cache @@ -69,6 +73,7 @@ export default function IntlProvider({ messages, now, onError, + prevContext, timeZone ] ); From ccaef97fdcefdf1713b24a8d950624ae48529d76 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 24 Oct 2024 15:49:44 +0200 Subject: [PATCH 11/90] fix lint --- packages/use-intl/src/react/IntlProvider.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/use-intl/src/react/IntlProvider.test.tsx b/packages/use-intl/src/react/IntlProvider.test.tsx index b57d6c676..424af1138 100644 --- a/packages/use-intl/src/react/IntlProvider.test.tsx +++ b/packages/use-intl/src/react/IntlProvider.test.tsx @@ -73,6 +73,7 @@ it('keeps a consistent context value that does not trigger unnecessary re-render numStaticTextRenders++; return t('hello'); }); + StaticText.displayName = 'StaticText'; render(); screen.getByText('Count: 0'); From 25a0dc84362a67809662931a83858133d789cd7c Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 24 Oct 2024 15:56:41 +0200 Subject: [PATCH 12/90] bump size --- packages/next-intl/.size-limit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 15566a22c..bcadf915d 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -71,7 +71,7 @@ const config: SizeLimitConfig = [ name: "import * from 'next-intl' (react-client, ESM)", path: 'dist/esm/index.react-client.js', import: '*', - limit: '14.245 kB' + limit: '14.295 kB' }, { name: "import {NextIntlProvider} from 'next-intl' (react-client, ESM)", From 9f4754cf374aa22734bcfbdbb794e7a78b3a1cdf Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 29 Oct 2024 10:22:08 +0100 Subject: [PATCH 13/90] feat!: Make package ESM-only, target modern browsers, use modern JSX transform (#1470) **Changes** 1. `next-intl` as well as its base library `use-intl` are now ESM-only. With this, the library is now ~7% smaller and provides full support for tree shaking. Most tools support ESM out-of-the-box, but some might require additional configuration (e.g. Jest might benefit from [`next/jest`](https://nextjs.org/docs/app/building-your-application/testing/jest#manual-setup)). The only exception is `next-intl/plugin` which is provided both as ESM as well as CommonJS due to `next.config.js` still being popular. 2. Syntax is compiled down to the Browserslist [`defaults`](https://github.com/browserslist/browserslist/blob/065c69b0e1e6ad679263d03e22c605830bbc52e5/index.js#L460) query, which is a shortcut for `> 0.5%, last 2 versions, Firefox ESR, not dead`. The `defaults` query was thoroughly designed by the Browserslist community and is considered a reasonable default for modern apps. If you target outdated browsers, you can use [`transpilePackages`](https://nextjs.org/docs/app/api-reference/next-config-js/transpilePackages) in combination with a [`browserslist`](https://nextjs.org/docs/architecture/supported-browsers) config in Next.js to compile `next-intl` for older browsers. 3. The minimum React version is 17 now (which supports the modern JSX transform) --- .../package.json | 2 +- .../next.config.js | 3 - .../next.config.mjs | 8 + .../package.json | 2 +- .../next.config.js | 3 - .../next.config.mjs | 8 + .../package.json | 2 +- examples/example-app-router/next.config.js | 8 - examples/example-app-router/next.config.mjs | 10 + examples/example-app-router/package.json | 2 +- .../example-app-router/src/i18n/request.ts | 7 +- .../config/babel.config.js | 5 - .../config/jest.json | 7 - .../jest.config.js | 9 + .../package.json | 2 +- examples/example-use-intl/vite.config.ts | 9 +- package.json | 11 - packages/next-intl/.size-limit.ts | 82 +- packages/next-intl/config.d.ts | 3 +- packages/next-intl/middleware.d.ts | 3 +- packages/next-intl/navigation.d.ts | 3 +- packages/next-intl/package.json | 75 +- packages/next-intl/plugin.d.cts | 7 + packages/next-intl/plugin.d.ts | 10 +- packages/next-intl/rollup.config.js | 50 + packages/next-intl/rollup.config.mjs | 49 - packages/next-intl/routing.d.ts | 3 +- packages/next-intl/server.d.ts | 3 +- packages/next-intl/src/index.react-client.tsx | 2 +- packages/next-intl/src/index.react-server.tsx | 2 +- packages/next-intl/src/middleware.tsx | 2 +- .../getAlternateLinksHeaderValue.test.tsx | 8 +- .../getAlternateLinksHeaderValue.tsx | 10 +- packages/next-intl/src/middleware/index.tsx | 2 +- .../src/middleware/middleware.test.tsx | 8 +- .../next-intl/src/middleware/middleware.tsx | 18 +- .../src/middleware/resolveLocale.test.tsx | 2 +- .../src/middleware/resolveLocale.tsx | 12 +- .../next-intl/src/middleware/syncCookie.tsx | 4 +- .../next-intl/src/middleware/utils.test.tsx | 2 +- packages/next-intl/src/middleware/utils.tsx | 4 +- .../next-intl/src/navigation.react-client.tsx | 2 +- .../next-intl/src/navigation.react-server.tsx | 2 +- ...reateLocalizedPathnamesNavigation.test.tsx | 21 +- .../src/navigation/createNavigation.test.tsx | 15 +- .../createSharedPathnamesNavigation.test.tsx | 21 +- .../react-client/ClientLink.test.tsx | 12 +- .../navigation/react-client/ClientLink.tsx | 10 +- ...reateLocalizedPathnamesNavigation.test.tsx | 12 +- .../createLocalizedPathnamesNavigation.tsx | 20 +- .../react-client/createNavigation.test.tsx | 24 +- .../react-client/createNavigation.tsx | 16 +- .../createSharedPathnamesNavigation.test.tsx | 10 +- .../createSharedPathnamesNavigation.tsx | 20 +- .../src/navigation/react-client/index.tsx | 8 +- .../src/navigation/react-client/redirects.tsx | 6 +- .../react-client/useBasePathname.test.tsx | 9 +- .../react-client/useBasePathname.tsx | 8 +- .../react-client/useBaseRouter.test.tsx | 29 +- .../navigation/react-client/useBaseRouter.tsx | 14 +- .../navigation/react-server/ServerLink.tsx | 10 +- .../createLocalizedPathnamesNavigation.tsx | 16 +- .../react-server/createNavigation.test.tsx | 2 +- .../react-server/createNavigation.tsx | 8 +- .../createSharedPathnamesNavigation.tsx | 16 +- .../react-server/getServerLocale.tsx | 2 +- .../src/navigation/react-server/index.tsx | 8 +- .../src/navigation/react-server/redirects.tsx | 6 +- .../src/navigation/shared/BaseLink.tsx | 26 +- .../src/navigation/shared/LegacyBaseLink.tsx | 24 +- .../shared/createSharedNavigationFns.tsx | 19 +- .../src/navigation/shared/redirects.test.tsx | 6 +- .../src/navigation/shared/redirects.tsx | 8 +- .../navigation/shared/syncLocaleCookie.tsx | 4 +- .../src/navigation/shared/utils.test.tsx | 2 +- .../next-intl/src/navigation/shared/utils.tsx | 8 +- packages/next-intl/src/plugin.tsx | 4 +- packages/next-intl/src/react-client/index.tsx | 4 +- .../src/react-client/useFormatter.test.tsx | 3 +- .../src/react-client/useLocale.test.tsx | 7 +- .../next-intl/src/react-client/useLocale.tsx | 7 +- .../src/react-client/useNow.test.tsx | 3 +- .../src/react-client/useTimeZone.test.tsx | 3 +- .../src/react-client/useTranslations.test.tsx | 3 +- .../NextIntlClientProviderServer.test.tsx | 8 +- .../NextIntlClientProviderServer.tsx | 8 +- .../next-intl/src/react-server/index.test.tsx | 7 +- packages/next-intl/src/react-server/index.tsx | 14 +- .../next-intl/src/react-server/testUtils.tsx | 2 +- .../next-intl/src/react-server/useConfig.tsx | 4 +- .../src/react-server/useFormatter.tsx | 9 +- .../next-intl/src/react-server/useLocale.tsx | 7 +- .../src/react-server/useMessages.tsx | 9 +- .../next-intl/src/react-server/useNow.tsx | 4 +- .../src/react-server/useTimeZone.tsx | 7 +- .../src/react-server/useTranslations.test.tsx | 6 +- .../src/react-server/useTranslations.tsx | 4 +- packages/next-intl/src/routing.tsx | 2 +- packages/next-intl/src/routing/config.tsx | 4 +- .../src/routing/defineRouting.test.tsx | 2 +- .../next-intl/src/routing/defineRouting.tsx | 4 +- packages/next-intl/src/routing/index.tsx | 4 +- packages/next-intl/src/routing/types.test.tsx | 2 +- .../next-intl/src/server.react-client.tsx | 2 +- .../next-intl/src/server.react-server.tsx | 2 +- .../src/server/react-client/index.test.tsx | 2 +- .../src/server/react-client/index.tsx | 2 +- .../src/server/react-server/RequestLocale.tsx | 6 +- .../react-server/RequestLocaleLegacy.tsx | 8 +- .../react-server/createRequestConfig.tsx | 5 +- .../src/server/react-server/getConfig.tsx | 10 +- .../src/server/react-server/getFormats.tsx | 2 +- .../src/server/react-server/getFormatter.tsx | 2 +- .../src/server/react-server/getLocale.tsx | 2 +- .../src/server/react-server/getMessages.tsx | 2 +- .../src/server/react-server/getNow.tsx | 2 +- .../src/server/react-server/getTimeZone.tsx | 2 +- .../server/react-server/getTranslations.tsx | 2 +- .../src/server/react-server/index.test.tsx | 6 +- .../src/server/react-server/index.tsx | 18 +- .../shared/NextIntlClientProvider.test.tsx | 6 +- .../src/shared/NextIntlClientProvider.tsx | 5 +- packages/next-intl/src/shared/use.tsx | 11 + packages/next-intl/src/shared/utils.test.tsx | 2 +- packages/next-intl/src/shared/utils.tsx | 7 +- packages/next-intl/tsconfig.build.json | 10 + packages/next-intl/tsconfig.json | 15 +- packages/use-intl/.size-limit.ts | 19 +- packages/use-intl/_IntlProvider.d.ts | 1 - packages/use-intl/_useLocale.d.ts | 1 - packages/use-intl/core.d.ts | 3 +- packages/use-intl/package.json | 42 +- packages/use-intl/react.d.ts | 3 +- packages/use-intl/rollup.config.js | 15 + packages/use-intl/rollup.config.mjs | 20 - packages/use-intl/src/_IntlProvider.tsx | 1 - packages/use-intl/src/_useLocale.tsx | 1 - packages/use-intl/src/core.tsx | 2 +- .../src/core/DateTimeFormatOptions.tsx | 2 +- packages/use-intl/src/core/Formats.tsx | 4 +- packages/use-intl/src/core/IntlConfig.tsx | 10 +- packages/use-intl/src/core/IntlError.tsx | 2 +- .../convertFormatsToIntlMessageFormat.tsx | 11 +- .../src/core/createBaseTranslator.tsx | 26 +- .../src/core/createFormatter.test.tsx | 2 +- .../use-intl/src/core/createFormatter.tsx | 16 +- .../src/core/createTranslator.test.tsx | 6 +- .../use-intl/src/core/createTranslator.tsx | 20 +- .../src/core/createTranslatorImpl.tsx | 12 +- packages/use-intl/src/core/defaults.tsx | 4 +- packages/use-intl/src/core/formatters.tsx | 4 +- packages/use-intl/src/core/index.tsx | 32 +- .../use-intl/src/core/initializeConfig.tsx | 6 +- .../use-intl/src/core/utils/MessageKeys.tsx | 2 +- .../use-intl/src/core/utils/NamespaceKeys.tsx | 2 +- .../use-intl/src/core/validateMessages.tsx | 6 +- packages/use-intl/src/index.tsx | 4 +- packages/use-intl/src/react.tsx | 2 +- packages/use-intl/src/react/IntlContext.tsx | 4 +- .../use-intl/src/react/IntlProvider.test.tsx | 8 +- packages/use-intl/src/react/IntlProvider.tsx | 10 +- packages/use-intl/src/react/index.test.tsx | 9 +- packages/use-intl/src/react/index.tsx | 14 +- .../use-intl/src/react/useFormatter.test.tsx | 8 +- packages/use-intl/src/react/useFormatter.tsx | 4 +- .../use-intl/src/react/useIntlContext.tsx | 2 +- .../use-intl/src/react/useLocale.test.tsx | 5 +- packages/use-intl/src/react/useLocale.tsx | 2 +- .../use-intl/src/react/useMessages.test.tsx | 5 +- packages/use-intl/src/react/useMessages.tsx | 4 +- packages/use-intl/src/react/useNow.test.tsx | 5 +- packages/use-intl/src/react/useNow.tsx | 2 +- .../use-intl/src/react/useTimeZone.test.tsx | 5 +- packages/use-intl/src/react/useTimeZone.tsx | 2 +- .../src/react/useTranslations.test.tsx | 18 +- .../use-intl/src/react/useTranslations.tsx | 16 +- .../src/react/useTranslationsImpl.tsx | 12 +- packages/use-intl/tsconfig.build.json | 10 + packages/use-intl/tsconfig.json | 20 +- pnpm-lock.yaml | 1154 ++++++----------- pnpm-workspace.yaml | 7 +- tools/eslint.config.mjs | 8 + tools/package.json | 28 + .../src/getBuildConfig.js | 86 +- tools/src/index.js | 1 + 185 files changed, 1275 insertions(+), 1613 deletions(-) delete mode 100644 examples/example-app-router-single-locale/next.config.js create mode 100644 examples/example-app-router-single-locale/next.config.mjs delete mode 100644 examples/example-app-router-without-i18n-routing/next.config.js create mode 100644 examples/example-app-router-without-i18n-routing/next.config.mjs delete mode 100644 examples/example-app-router/next.config.js create mode 100644 examples/example-app-router/next.config.mjs delete mode 100644 examples/example-pages-router-advanced/config/babel.config.js delete mode 100644 examples/example-pages-router-advanced/config/jest.json create mode 100644 examples/example-pages-router-advanced/jest.config.js create mode 100644 packages/next-intl/plugin.d.cts create mode 100644 packages/next-intl/rollup.config.js delete mode 100644 packages/next-intl/rollup.config.mjs create mode 100644 packages/next-intl/src/shared/use.tsx create mode 100644 packages/next-intl/tsconfig.build.json delete mode 100644 packages/use-intl/_IntlProvider.d.ts delete mode 100644 packages/use-intl/_useLocale.d.ts create mode 100644 packages/use-intl/rollup.config.js delete mode 100644 packages/use-intl/rollup.config.mjs delete mode 100644 packages/use-intl/src/_IntlProvider.tsx delete mode 100644 packages/use-intl/src/_useLocale.tsx create mode 100644 packages/use-intl/tsconfig.build.json create mode 100644 tools/eslint.config.mjs create mode 100644 tools/package.json rename scripts/getBuildConfig.mjs => tools/src/getBuildConfig.js (50%) create mode 100644 tools/src/index.js diff --git a/examples/example-app-router-mixed-routing/package.json b/examples/example-app-router-mixed-routing/package.json index a7f92ad64..2ad9172f8 100644 --- a/examples/example-app-router-mixed-routing/package.json +++ b/examples/example-app-router-mixed-routing/package.json @@ -2,7 +2,7 @@ "name": "example-app-router-mixed-routing", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --turbo", "lint": "eslint src && tsc && prettier src --check", "test": "playwright test", "build": "next build", diff --git a/examples/example-app-router-single-locale/next.config.js b/examples/example-app-router-single-locale/next.config.js deleted file mode 100644 index d9797bdf4..000000000 --- a/examples/example-app-router-single-locale/next.config.js +++ /dev/null @@ -1,3 +0,0 @@ -const withNextIntl = require('next-intl/plugin')(); - -module.exports = withNextIntl(); diff --git a/examples/example-app-router-single-locale/next.config.mjs b/examples/example-app-router-single-locale/next.config.mjs new file mode 100644 index 000000000..00dc8ae23 --- /dev/null +++ b/examples/example-app-router-single-locale/next.config.mjs @@ -0,0 +1,8 @@ +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin(); + +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default withNextIntl(nextConfig); diff --git a/examples/example-app-router-single-locale/package.json b/examples/example-app-router-single-locale/package.json index 244511749..3db1a9abf 100644 --- a/examples/example-app-router-single-locale/package.json +++ b/examples/example-app-router-single-locale/package.json @@ -2,7 +2,7 @@ "name": "example-app-router-single-locale", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --turbo", "lint": "eslint src && tsc && prettier src --check", "test": "playwright test", "build": "next build", diff --git a/examples/example-app-router-without-i18n-routing/next.config.js b/examples/example-app-router-without-i18n-routing/next.config.js deleted file mode 100644 index d9797bdf4..000000000 --- a/examples/example-app-router-without-i18n-routing/next.config.js +++ /dev/null @@ -1,3 +0,0 @@ -const withNextIntl = require('next-intl/plugin')(); - -module.exports = withNextIntl(); diff --git a/examples/example-app-router-without-i18n-routing/next.config.mjs b/examples/example-app-router-without-i18n-routing/next.config.mjs new file mode 100644 index 000000000..00dc8ae23 --- /dev/null +++ b/examples/example-app-router-without-i18n-routing/next.config.mjs @@ -0,0 +1,8 @@ +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin(); + +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default withNextIntl(nextConfig); diff --git a/examples/example-app-router-without-i18n-routing/package.json b/examples/example-app-router-without-i18n-routing/package.json index 317bf1a64..e076cf1f2 100644 --- a/examples/example-app-router-without-i18n-routing/package.json +++ b/examples/example-app-router-without-i18n-routing/package.json @@ -2,7 +2,7 @@ "name": "example-app-router-without-i18n-routing", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --turbo", "lint": "eslint src && tsc && prettier src --check", "test": "playwright test", "build": "next build", diff --git a/examples/example-app-router/next.config.js b/examples/example-app-router/next.config.js deleted file mode 100644 index 12b5a862d..000000000 --- a/examples/example-app-router/next.config.js +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-check - -const withNextIntl = require('next-intl/plugin')(); - -/** @type {import('next').NextConfig} */ -const config = {}; - -module.exports = withNextIntl(config); diff --git a/examples/example-app-router/next.config.mjs b/examples/example-app-router/next.config.mjs new file mode 100644 index 000000000..46841e0e7 --- /dev/null +++ b/examples/example-app-router/next.config.mjs @@ -0,0 +1,10 @@ +// @ts-check + +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin(); + +/** @type {import('next').NextConfig} */ +const config = {}; + +export default withNextIntl(config); diff --git a/examples/example-app-router/package.json b/examples/example-app-router/package.json index ae0d30e58..c6eeb30e1 100644 --- a/examples/example-app-router/package.json +++ b/examples/example-app-router/package.json @@ -2,7 +2,7 @@ "name": "example-app-router", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --turbo", "lint": "eslint src && tsc && prettier src --check", "test": "pnpm run test:playwright && pnpm run test:jest", "test:playwright": "playwright test", diff --git a/examples/example-app-router/src/i18n/request.ts b/examples/example-app-router/src/i18n/request.ts index df242f13d..2ba1dd2a6 100644 --- a/examples/example-app-router/src/i18n/request.ts +++ b/examples/example-app-router/src/i18n/request.ts @@ -12,11 +12,6 @@ export default getRequestConfig(async ({requestLocale}) => { return { locale, - messages: ( - await (locale === 'en' - ? // When using Turbopack, this will enable HMR for `en` - import('../../messages/en.json') - : import(`../../messages/${locale}.json`)) - ).default + messages: (await import(`../../messages/${locale}.json`)).default }; }); diff --git a/examples/example-pages-router-advanced/config/babel.config.js b/examples/example-pages-router-advanced/config/babel.config.js deleted file mode 100644 index 1066d32f7..000000000 --- a/examples/example-pages-router-advanced/config/babel.config.js +++ /dev/null @@ -1,5 +0,0 @@ -// Used by Jest - -module.exports = { - presets: ['next/babel'] -}; diff --git a/examples/example-pages-router-advanced/config/jest.json b/examples/example-pages-router-advanced/config/jest.json deleted file mode 100644 index 54ba3e92b..000000000 --- a/examples/example-pages-router-advanced/config/jest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "testEnvironment": "jsdom", - "rootDir": "../", - "transform": { - "\\.tsx$": ["babel-jest", {"configFile": "./config/babel.config.js"}] - } -} diff --git a/examples/example-pages-router-advanced/jest.config.js b/examples/example-pages-router-advanced/jest.config.js new file mode 100644 index 000000000..d9f09280e --- /dev/null +++ b/examples/example-pages-router-advanced/jest.config.js @@ -0,0 +1,9 @@ +/* eslint-env node */ +const nextJest = require('next/jest'); + +const createJestConfig = nextJest({dir: './'}); + +module.exports = createJestConfig({ + testEnvironment: 'jsdom', + rootDir: 'src' +}); diff --git a/examples/example-pages-router-advanced/package.json b/examples/example-pages-router-advanced/package.json index 1cd053951..dedef4b0c 100644 --- a/examples/example-pages-router-advanced/package.json +++ b/examples/example-pages-router-advanced/package.json @@ -4,7 +4,7 @@ "scripts": { "dev": "next dev", "lint": "eslint src && tsc && prettier src --check", - "test": "jest --config config/jest.json", + "test": "jest", "build": "next build", "start": "next start" }, diff --git a/examples/example-use-intl/vite.config.ts b/examples/example-use-intl/vite.config.ts index 0d4024d8f..d2e8b6716 100644 --- a/examples/example-use-intl/vite.config.ts +++ b/examples/example-use-intl/vite.config.ts @@ -1,11 +1,6 @@ -import {defineConfig} from 'vite'; import react from '@vitejs/plugin-react'; +import {defineConfig} from 'vite'; export default defineConfig({ - plugins: [react()], - - // TODO: Remove after use-intl has full ESM support - // https://vitejs.dev/guide/dep-pre-bundling#monorepos-and-linked-dependencies - optimizeDeps: {include: ['use-intl']}, - build: {commonjsOptions: {include: [/use-intl/, /node_modules/]}} + plugins: [react()] }); diff --git a/package.json b/package.json index ca8821447..86029cae1 100644 --- a/package.json +++ b/package.json @@ -6,20 +6,9 @@ "publish": "lerna publish" }, "devDependencies": { - "@babel/core": "^7.24.7", - "@babel/preset-env": "^7.24.7", - "@babel/preset-react": "^7.24.7", - "@babel/preset-typescript": "^7.24.7", "@lerna-lite/cli": "^3.9.0", "@lerna-lite/publish": "^3.9.0", - "@rollup/plugin-babel": "^6.0.3", - "@rollup/plugin-commonjs": "^26.0.1", - "@rollup/plugin-node-resolve": "^15.2.1", - "@rollup/plugin-replace": "^5.0.7", - "@rollup/plugin-terser": "^0.4.3", "conventional-changelog-conventionalcommits": "^7.0.0", - "execa": "^9.2.0", - "rollup": "^4.18.0", "turbo": "^2.2.3" }, "pnpm": { diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index bcadf915d..497863f29 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -2,82 +2,52 @@ import type {SizeLimitConfig} from 'size-limit'; const config: SizeLimitConfig = [ { - name: "import * from 'next-intl' (react-client)", - path: 'dist/production/index.react-client.js', - limit: '14.125 KB' + name: "import * from 'next-intl' (react-client, production)", + path: 'dist/esm/production/index.react-client.js', + limit: '13.205 KB' }, { - name: "import * from 'next-intl' (react-server)", - path: 'dist/production/index.react-server.js', - limit: '14.845 KB' - }, - { - name: "import {createSharedPathnamesNavigation} from 'next-intl/navigation' (react-client)", - path: 'dist/production/navigation.react-client.js', - import: '{createSharedPathnamesNavigation}', - limit: '4.045 KB' + name: "import {NextIntlClientProvider} from 'next-intl' (react-client, production)", + import: '{NextIntlClientProvider}', + path: 'dist/esm/production/index.react-client.js', + limit: '1.035 KB' }, { - name: "import {createLocalizedPathnamesNavigation} from 'next-intl/navigation' (react-client)", - path: 'dist/production/navigation.react-client.js', - import: '{createLocalizedPathnamesNavigation}', - limit: '4.045 KB' + name: "import * from 'next-intl' (react-server, production)", + path: 'dist/esm/production/index.react-server.js', + limit: '14.165 KB' }, { - name: "import {createNavigation} from 'next-intl/navigation' (react-client)", - path: 'dist/production/navigation.react-client.js', + name: "import {createNavigation} from 'next-intl/navigation' (react-client, production)", + path: 'dist/esm/production/navigation.react-client.js', import: '{createNavigation}', - limit: '4.055 KB' - }, - { - name: "import {createSharedPathnamesNavigation} from 'next-intl/navigation' (react-server)", - path: 'dist/production/navigation.react-server.js', - import: '{createSharedPathnamesNavigation}', - limit: '16.795 KB' - }, - { - name: "import {createLocalizedPathnamesNavigation} from 'next-intl/navigation' (react-server)", - path: 'dist/production/navigation.react-server.js', - import: '{createLocalizedPathnamesNavigation}', - limit: '16.785 KB' + limit: '2.525 KB' }, { - name: "import {createNavigation} from 'next-intl/navigation' (react-server)", - path: 'dist/production/navigation.react-server.js', + name: "import {createNavigation} from 'next-intl/navigation' (react-server, production)", + path: 'dist/esm/production/navigation.react-server.js', import: '{createNavigation}', - limit: '16.8 KB' + limit: '3.425 KB' }, { - name: "import * from 'next-intl/server' (react-client)", - path: 'dist/production/server.react-client.js', + name: "import * from 'next-intl/server' (react-client, production)", + path: 'dist/esm/production/server.react-client.js', limit: '1 KB' }, { - name: "import * from 'next-intl/server' (react-server)", - path: 'dist/production/server.react-server.js', - limit: '14.035 KB' + name: "import * from 'next-intl/server' (react-server, production)", + path: 'dist/esm/production/server.react-server.js', + limit: '13.385 KB' }, { - name: "import createMiddleware from 'next-intl/middleware'", - path: 'dist/production/middleware.js', - limit: '9.725 KB' + name: "import * from 'next-intl/middleware' (production)", + path: 'dist/esm/production/middleware.js', + limit: '9.295 KB' }, { - name: "import * from 'next-intl/routing'", - path: 'dist/production/routing.js', + name: "import * from 'next-intl/routing' (production)", + path: 'dist/esm/production/routing.js', limit: '1 KB' - }, - { - name: "import * from 'next-intl' (react-client, ESM)", - path: 'dist/esm/index.react-client.js', - import: '*', - limit: '14.295 kB' - }, - { - name: "import {NextIntlProvider} from 'next-intl' (react-client, ESM)", - path: 'dist/esm/index.react-client.js', - import: '{NextIntlClientProvider}', - limit: '1.55 kB' } ]; diff --git a/packages/next-intl/config.d.ts b/packages/next-intl/config.d.ts index 86c346051..fd48ba85d 100644 --- a/packages/next-intl/config.d.ts +++ b/packages/next-intl/config.d.ts @@ -1,3 +1,4 @@ -import config from './dist/types/src/config'; +// Needed for projects with `moduleResolution: 'node'` +import config from './dist/types/config'; export = config; diff --git a/packages/next-intl/middleware.d.ts b/packages/next-intl/middleware.d.ts index 41dddf9a1..2222782a3 100644 --- a/packages/next-intl/middleware.d.ts +++ b/packages/next-intl/middleware.d.ts @@ -1,3 +1,4 @@ -import createMiddleware from './dist/types/src/middleware'; +// Needed for projects with `moduleResolution: 'node'` +import createMiddleware from './dist/types/middleware'; export = createMiddleware; diff --git a/packages/next-intl/navigation.d.ts b/packages/next-intl/navigation.d.ts index 81ded918e..ea19b24e0 100644 --- a/packages/next-intl/navigation.d.ts +++ b/packages/next-intl/navigation.d.ts @@ -1 +1,2 @@ -export * from './dist/types/src/navigation.react-client'; +// Needed for projects with `moduleResolution: 'node'` +export * from './dist/types/navigation.react-client'; diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index cca47afce..15d5c1d74 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -21,46 +21,69 @@ "test": "TZ=Europe/Berlin vitest", "lint": "pnpm run lint:source && pnpm run lint:package", "lint:source": "eslint src test && tsc --noEmit && pnpm run lint:prettier", - "lint:package": "publint && attw --pack", + "lint:package": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm", "lint:prettier": "prettier src --check", "prepublishOnly": "turbo build && cp ../../README.md .", "postpublish": "git checkout . && rm ./README.md", "size": "size-limit" }, - "main": "./dist/index.react-client.js", - "module": "./dist/esm/index.react-client.js", - "typings": "./dist/types/src/index.react-client.d.ts", + "type": "module", + "main": "./dist/esm/production/index.react-client.js", + "typings": "./dist/types/index.react-client.d.ts", "exports": { ".": { - "types": "./dist/types/src/index.react-client.d.ts", - "react-server": "./dist/esm/index.react-server.js", - "default": "./dist/index.react-client.js" + "types": "./dist/types/index.react-client.d.ts", + "react-server": { + "development": "./dist/esm/development/index.react-server.js", + "default": "./dist/esm/production/index.react-server.js" + }, + "development": "./dist/esm/development/index.react-client.js", + "default": "./dist/esm/production/index.react-client.js" }, "./server": { - "types": "./server.d.ts", - "react-server": "./dist/esm/server.react-server.js", - "default": "./dist/server.react-client.js" + "types": "./dist/types/server.react-server.d.ts", + "react-server": { + "development": "./dist/esm/development/server.react-server.js", + "default": "./dist/esm/production/server.react-server.js" + }, + "development": "./dist/esm/development/server.react-client.js", + "default": "./dist/esm/production/server.react-client.js" }, "./config": { - "types": "./config.d.ts", - "default": "./dist/config.js" + "types": "./dist/types/config.d.ts", + "development": "./dist/esm/development/config.js", + "default": "./dist/esm/production/config.js" }, "./middleware": { - "types": "./middleware.d.ts", - "default": "./dist/middleware.js" + "types": "./dist/types/middleware.d.ts", + "development": "./dist/esm/development/middleware.js", + "default": "./dist/esm/production/middleware.js" }, "./navigation": { - "types": "./navigation.d.ts", - "react-server": "./dist/esm/navigation.react-server.js", - "default": "./dist/navigation.react-client.js" + "types": "./dist/types/navigation.react-client.d.ts", + "react-server": { + "development": "./dist/esm/development/navigation.react-server.js", + "default": "./dist/esm/production/navigation.react-server.js" + }, + "development": "./dist/esm/development/navigation.react-client.js", + "default": "./dist/esm/production/navigation.react-client.js" }, "./routing": { - "types": "./routing.d.ts", - "default": "./dist/routing.js" + "types": "./dist/types/routing.d.ts", + "development": "./dist/esm/development/routing.js", + "default": "./dist/esm/production/routing.js" }, "./plugin": { - "types": "./plugin.d.ts", - "default": "./dist/plugin.js" + "import": { + "types": "./dist/types/plugin.d.ts", + "development": "./dist/esm/development/plugin.js", + "default": "./dist/esm/production/plugin.js" + }, + "require": { + "types": "./plugin.d.cts", + "default": "./dist/cjs/development/plugin.cjs" + }, + "default": "./dist/esm/production/plugin.js" } }, "files": [ @@ -69,6 +92,7 @@ "navigation.d.ts", "middleware.d.ts", "plugin.d.ts", + "plugin.d.cts", "routing.d.ts", "config.d.ts" ], @@ -91,13 +115,13 @@ "use-intl": "workspace:^" }, "peerDependencies": { - "next": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "next": "^13.0.0 || ^14.0.0 || ^15.0.0", + "react": "^17.0.0 || ^18.0.0" }, "devDependencies": { - "@arethetypeswrong/cli": "^0.15.3", + "@arethetypeswrong/cli": "^0.16.4", "@edge-runtime/vm": "^3.2.0", - "@size-limit/preset-big-lib": "^11.1.4", + "@size-limit/preset-small-lib": "^11.1.4", "@testing-library/react": "^16.0.0", "@types/negotiator": "^0.6.3", "@types/node": "^20.14.5", @@ -115,6 +139,7 @@ "rollup": "^4.18.0", "rollup-plugin-preserve-directives": "0.4.0", "size-limit": "^11.1.4", + "tools": "workspace:^", "typescript": "^5.5.3", "vitest": "^2.0.2" }, diff --git a/packages/next-intl/plugin.d.cts b/packages/next-intl/plugin.d.cts new file mode 100644 index 000000000..46a5d3fe8 --- /dev/null +++ b/packages/next-intl/plugin.d.cts @@ -0,0 +1,7 @@ +import {NextConfig} from 'next'; + +function createNextIntlPlugin( + i18nPath?: string +): (config?: NextConfig) => NextConfig; + +export = createNextIntlPlugin; diff --git a/packages/next-intl/plugin.d.ts b/packages/next-intl/plugin.d.ts index 476ab78b5..8683332fa 100644 --- a/packages/next-intl/plugin.d.ts +++ b/packages/next-intl/plugin.d.ts @@ -1,8 +1,4 @@ -import {NextConfig} from 'next'; +// Needed for projects with `moduleResolution: 'node'` +import plugin from './dist/types/plugin'; -function createNextIntlPlugin( - i18nPath?: string -): (config?: NextConfig) => NextConfig; - -// Currently only available via CJS -export = createNextIntlPlugin; +export = plugin; diff --git a/packages/next-intl/rollup.config.js b/packages/next-intl/rollup.config.js new file mode 100644 index 000000000..ad96e6512 --- /dev/null +++ b/packages/next-intl/rollup.config.js @@ -0,0 +1,50 @@ +import preserveDirectives from 'rollup-plugin-preserve-directives'; +import {getBuildConfig} from 'tools'; +import pkg from './package.json' with {type: 'json'}; + +export default [ + ...getBuildConfig({ + input: { + 'index.react-client': 'src/index.react-client.tsx', + 'index.react-server': 'src/index.react-server.tsx', + + 'navigation.react-client': 'src/navigation.react-client.tsx', + 'navigation.react-server': 'src/navigation.react-server.tsx', + + 'server.react-client': 'src/server.react-client.tsx', + 'server.react-server': 'src/server.react-server.tsx', + + middleware: 'src/middleware.tsx', + routing: 'src/routing.tsx', + plugin: 'src/plugin.tsx', + config: 'src/config.tsx' + }, + external: [ + ...Object.keys(pkg.dependencies), + ...Object.keys(pkg.peerDependencies), + 'react/jsx-runtime', + 'next-intl/config', + 'use-intl/core', + 'use-intl/react' + ], + output: { + preserveModules: true + }, + onwarn(warning, warn) { + if (warning.code === 'MODULE_LEVEL_DIRECTIVE') return; + warn(warning); + }, + plugins: [preserveDirectives()] + }), + ...getBuildConfig({ + env: ['development'], + input: { + plugin: 'src/plugin.tsx' + }, + output: { + dir: 'dist/cjs/development', + format: 'cjs', + entryFileNames: '[name].cjs' + } + }) +]; diff --git a/packages/next-intl/rollup.config.mjs b/packages/next-intl/rollup.config.mjs deleted file mode 100644 index bb8702657..000000000 --- a/packages/next-intl/rollup.config.mjs +++ /dev/null @@ -1,49 +0,0 @@ -/* eslint-env node */ -import preserveDirectives from 'rollup-plugin-preserve-directives'; -import getBuildConfig from '../../scripts/getBuildConfig.mjs'; - -const config = { - input: { - 'index.react-client': 'src/index.react-client.tsx', - 'index.react-server': 'src/index.react-server.tsx', - - 'navigation.react-client': 'src/navigation.react-client.tsx', - 'navigation.react-server': 'src/navigation.react-server.tsx', - - 'server.react-client': 'src/server.react-client.tsx', - 'server.react-server': 'src/server.react-server.tsx', - - middleware: 'src/middleware.tsx', - routing: 'src/routing.tsx', - plugin: 'src/plugin.tsx', - config: 'src/config.tsx' - }, - external: ['next-intl/config', /use-intl/], - output: { - preserveModules: true - }, - onwarn(warning, warn) { - if (warning.code === 'MODULE_LEVEL_DIRECTIVE') return; - warn(warning); - }, - plugins: [preserveDirectives()] -}; - -export default [ - getBuildConfig({ - ...config, - env: 'development' - }), - getBuildConfig({ - ...config, - output: { - ...config.output, - format: 'es' - }, - env: 'esm' - }), - getBuildConfig({ - ...config, - env: 'production' - }) -]; diff --git a/packages/next-intl/routing.d.ts b/packages/next-intl/routing.d.ts index 13ee0d973..51815e313 100644 --- a/packages/next-intl/routing.d.ts +++ b/packages/next-intl/routing.d.ts @@ -1 +1,2 @@ -export * from './dist/types/src/routing'; +// Needed for projects with `moduleResolution: 'node'` +export * from './dist/types/routing'; diff --git a/packages/next-intl/server.d.ts b/packages/next-intl/server.d.ts index e53f54959..c0a7dc884 100644 --- a/packages/next-intl/server.d.ts +++ b/packages/next-intl/server.d.ts @@ -1 +1,2 @@ -export * from './dist/types/src/server/react-server'; +// Needed for projects with `moduleResolution: 'node'` +export * from './dist/types/server/react-server'; diff --git a/packages/next-intl/src/index.react-client.tsx b/packages/next-intl/src/index.react-client.tsx index 429480f8a..d1d057aec 100644 --- a/packages/next-intl/src/index.react-client.tsx +++ b/packages/next-intl/src/index.react-client.tsx @@ -6,4 +6,4 @@ * from `./react-server` instead. */ -export * from './react-client'; +export * from './react-client/index.tsx'; diff --git a/packages/next-intl/src/index.react-server.tsx b/packages/next-intl/src/index.react-server.tsx index 812d94f58..172bd7da0 100644 --- a/packages/next-intl/src/index.react-server.tsx +++ b/packages/next-intl/src/index.react-server.tsx @@ -1 +1 @@ -export * from './react-server'; +export * from './react-server/index.tsx'; diff --git a/packages/next-intl/src/middleware.tsx b/packages/next-intl/src/middleware.tsx index 0b94d4d5a..0943679ef 100644 --- a/packages/next-intl/src/middleware.tsx +++ b/packages/next-intl/src/middleware.tsx @@ -1 +1 @@ -export {default} from './middleware/index'; +export {default} from './middleware/index.tsx'; diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx index 2a0be8b3e..9c03cac00 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx @@ -1,10 +1,10 @@ // @vitest-environment edge-runtime -import {NextRequest} from 'next/server'; +import {NextRequest} from 'next/server.js'; import {afterEach, beforeEach, describe, expect, it} from 'vitest'; -import {Pathnames} from '../routing'; -import {receiveRoutingConfig} from '../routing/config'; -import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue'; +import {receiveRoutingConfig} from '../routing/config.tsx'; +import {Pathnames} from '../routing.tsx'; +import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue.tsx'; describe.each([{basePath: undefined}, {basePath: '/base'}])( 'basePath: $basePath', diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 4ce604ee2..4b42ec8fb 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -1,12 +1,12 @@ -import {NextRequest} from 'next/server'; -import {ResolvedRoutingConfig} from '../routing/config'; +import {NextRequest} from 'next/server.js'; +import {ResolvedRoutingConfig} from '../routing/config.tsx'; import { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../routing/types'; -import {normalizeTrailingSlash} from '../shared/utils'; +} from '../routing/types.tsx'; +import {normalizeTrailingSlash} from '../shared/utils.tsx'; import { applyBasePath, formatTemplatePathname, @@ -14,7 +14,7 @@ import { getLocalePrefixes, getNormalizedPathname, isLocaleSupportedOnDomain -} from './utils'; +} from './utils.tsx'; /** * See https://developers.google.com/search/docs/specialty/international/localized-versions diff --git a/packages/next-intl/src/middleware/index.tsx b/packages/next-intl/src/middleware/index.tsx index ccc76b9f6..0cb9c5e88 100644 --- a/packages/next-intl/src/middleware/index.tsx +++ b/packages/next-intl/src/middleware/index.tsx @@ -2,4 +2,4 @@ * The middleware, available as `next-intl/middleware`. */ -export {default} from './middleware'; +export {default} from './middleware.tsx'; diff --git a/packages/next-intl/src/middleware/middleware.test.tsx b/packages/next-intl/src/middleware/middleware.test.tsx index 14aa25c5e..e96da6804 100644 --- a/packages/next-intl/src/middleware/middleware.test.tsx +++ b/packages/next-intl/src/middleware/middleware.test.tsx @@ -1,15 +1,15 @@ // @vitest-environment edge-runtime import {RequestCookies} from 'next/dist/compiled/@edge-runtime/cookies'; -import {NextRequest, NextResponse} from 'next/server'; +import {NextRequest, NextResponse} from 'next/server.js'; import {pathToRegexp} from 'path-to-regexp'; import {Mock, afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import createMiddleware from '../middleware'; -import {Pathnames, defineRouting} from '../routing'; +import createMiddleware from '../middleware.tsx'; +import {Pathnames, defineRouting} from '../routing.tsx'; const COOKIE_LOCALE_NAME = 'NEXT_LOCALE'; -vi.mock('next/server', async (importActual) => { +vi.mock('next/server.js', async (importActual) => { const ActualNextServer = (await importActual()) as any; type MiddlewareResponseInit = Parameters<(typeof NextResponse)['next']>[0]; diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 6ed38e8e2..00030c9f1 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -1,20 +1,20 @@ -import {NextRequest, NextResponse} from 'next/server'; -import {RoutingConfig, receiveRoutingConfig} from '../routing/config'; +import {NextRequest, NextResponse} from 'next/server.js'; +import {RoutingConfig, receiveRoutingConfig} from '../routing/config.tsx'; import { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../routing/types'; -import {HEADER_LOCALE_NAME} from '../shared/constants'; +} from '../routing/types.tsx'; +import {HEADER_LOCALE_NAME} from '../shared/constants.tsx'; import { getLocalePrefix, matchesPathname, normalizeTrailingSlash -} from '../shared/utils'; -import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue'; -import resolveLocale from './resolveLocale'; -import syncCookie from './syncCookie'; +} from '../shared/utils.tsx'; +import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue.tsx'; +import resolveLocale from './resolveLocale.tsx'; +import syncCookie from './syncCookie.tsx'; import { applyBasePath, formatPathname, @@ -26,7 +26,7 @@ import { getPathnameMatch, isLocaleSupportedOnDomain, sanitizePathname -} from './utils'; +} from './utils.tsx'; export default function createMiddleware< AppLocales extends Locales, diff --git a/packages/next-intl/src/middleware/resolveLocale.test.tsx b/packages/next-intl/src/middleware/resolveLocale.test.tsx index c8117757c..93a7bdfd8 100644 --- a/packages/next-intl/src/middleware/resolveLocale.test.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.test.tsx @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {getAcceptLanguageLocale} from './resolveLocale'; +import {getAcceptLanguageLocale} from './resolveLocale.tsx'; describe('getAcceptLanguageLocale', () => { it('resolves a more specific locale to a generic one', () => { diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index 17939c6c4..3b6db2daf 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -1,15 +1,19 @@ import {match} from '@formatjs/intl-localematcher'; import Negotiator from 'negotiator'; -import {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; -import {ResolvedRoutingConfig} from '../routing/config'; +import {RequestCookies} from 'next/dist/server/web/spec-extension/cookies.js'; +import {ResolvedRoutingConfig} from '../routing/config.tsx'; import { DomainConfig, DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../routing/types'; -import {getHost, getPathnameMatch, isLocaleSupportedOnDomain} from './utils'; +} from '../routing/types.tsx'; +import { + getHost, + getPathnameMatch, + isLocaleSupportedOnDomain +} from './utils.tsx'; function findDomainFromHost( requestHeaders: Headers, diff --git a/packages/next-intl/src/middleware/syncCookie.tsx b/packages/next-intl/src/middleware/syncCookie.tsx index c924b104b..eabe62549 100644 --- a/packages/next-intl/src/middleware/syncCookie.tsx +++ b/packages/next-intl/src/middleware/syncCookie.tsx @@ -1,5 +1,5 @@ -import {NextRequest, NextResponse} from 'next/server'; -import {LocaleCookieConfig} from '../routing/config'; +import {NextRequest, NextResponse} from 'next/server.js'; +import {LocaleCookieConfig} from '../routing/config.tsx'; export default function syncCookie( request: NextRequest, diff --git a/packages/next-intl/src/middleware/utils.test.tsx b/packages/next-intl/src/middleware/utils.test.tsx index e322b5538..61a0a1ecd 100644 --- a/packages/next-intl/src/middleware/utils.test.tsx +++ b/packages/next-intl/src/middleware/utils.test.tsx @@ -5,7 +5,7 @@ import { getNormalizedPathname, getPathnameMatch, getRouteParams -} from './utils'; +} from './utils.tsx'; describe('getNormalizedPathname', () => { it('should return the normalized pathname', () => { diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index c5dfdaadc..7f14655da 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -5,7 +5,7 @@ import { LocalePrefixMode, Locales, Pathnames -} from '../routing/types'; +} from '../routing/types.tsx'; import { getLocalePrefix, getSortedPathnames, @@ -13,7 +13,7 @@ import { normalizeTrailingSlash, prefixPathname, templateToRegex -} from '../shared/utils'; +} from '../shared/utils.tsx'; export function getFirstPathnameSegment(pathname: string) { return pathname.split('/')[1]; diff --git a/packages/next-intl/src/navigation.react-client.tsx b/packages/next-intl/src/navigation.react-client.tsx index 773e086d1..1b752df45 100644 --- a/packages/next-intl/src/navigation.react-client.tsx +++ b/packages/next-intl/src/navigation.react-client.tsx @@ -1 +1 @@ -export * from './navigation/react-client/index'; +export * from './navigation/react-client/index.tsx'; diff --git a/packages/next-intl/src/navigation.react-server.tsx b/packages/next-intl/src/navigation.react-server.tsx index c207e942e..de1da08cd 100644 --- a/packages/next-intl/src/navigation.react-server.tsx +++ b/packages/next-intl/src/navigation.react-server.tsx @@ -1 +1 @@ -export * from './navigation/react-server'; +export * from './navigation/react-server/index.tsx'; diff --git a/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx index 1ac6359d8..5cc7a78b9 100644 --- a/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx @@ -5,19 +5,18 @@ import { redirect as nextRedirect, usePathname as useNextPathname, useParams -} from 'next/navigation'; -import React from 'react'; +} from 'next/navigation.js'; import {renderToString} from 'react-dom/server'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {Pathnames, defineRouting} from '../routing'; -import {getRequestLocale} from '../server/react-server/RequestLocaleLegacy'; -import {getLocalePrefix} from '../shared/utils'; -import createLocalizedPathnamesNavigationClient from './react-client/createLocalizedPathnamesNavigation'; -import createLocalizedPathnamesNavigationServer from './react-server/createLocalizedPathnamesNavigation'; -import LegacyBaseLink from './shared/LegacyBaseLink'; - -vi.mock('next/navigation', async () => { - const actual = await vi.importActual('next/navigation'); +import {Pathnames, defineRouting} from '../routing.tsx'; +import {getRequestLocale} from '../server/react-server/RequestLocaleLegacy.tsx'; +import {getLocalePrefix} from '../shared/utils.tsx'; +import createLocalizedPathnamesNavigationClient from './react-client/createLocalizedPathnamesNavigation.tsx'; +import createLocalizedPathnamesNavigationServer from './react-server/createLocalizedPathnamesNavigation.tsx'; +import LegacyBaseLink from './shared/LegacyBaseLink.tsx'; + +vi.mock('next/navigation.js', async () => { + const actual = await vi.importActual('next/navigation.js'); return { ...actual, usePathname: vi.fn(), diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index eacb29ea9..e8125d65e 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -4,18 +4,17 @@ import { permanentRedirect as nextPermanentRedirect, redirect as nextRedirect, useParams as nextUseParams -} from 'next/navigation'; -import React from 'react'; +} from 'next/navigation.js'; import {renderToString} from 'react-dom/server'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {DomainsConfig, Pathnames, defineRouting} from '../routing'; -import createNavigationClient from './react-client/createNavigation'; -import createNavigationServer from './react-server/createNavigation'; -import getServerLocale from './react-server/getServerLocale'; +import {DomainsConfig, Pathnames, defineRouting} from '../routing.tsx'; +import createNavigationClient from './react-client/createNavigation.tsx'; +import createNavigationServer from './react-server/createNavigation.tsx'; +import getServerLocale from './react-server/getServerLocale.tsx'; vi.mock('react'); -vi.mock('next/navigation', async () => { - const actual = await vi.importActual('next/navigation'); +vi.mock('next/navigation.js', async () => { + const actual = await vi.importActual('next/navigation.js'); return { ...actual, useParams: vi.fn(() => ({locale: 'en'})), diff --git a/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx index 57c9e74db..732027847 100644 --- a/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx @@ -5,19 +5,18 @@ import { redirect as nextRedirect, usePathname as useNextPathname, useParams -} from 'next/navigation'; -import React from 'react'; +} from 'next/navigation.js'; import {renderToString} from 'react-dom/server'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {defineRouting} from '../routing'; -import {getRequestLocale} from '../server/react-server/RequestLocaleLegacy'; -import {getLocalePrefix} from '../shared/utils'; -import createSharedPathnamesNavigationClient from './react-client/createSharedPathnamesNavigation'; -import createSharedPathnamesNavigationServer from './react-server/createSharedPathnamesNavigation'; -import LegacyBaseLink from './shared/LegacyBaseLink'; - -vi.mock('next/navigation', async () => { - const actual = await vi.importActual('next/navigation'); +import {defineRouting} from '../routing.tsx'; +import {getRequestLocale} from '../server/react-server/RequestLocaleLegacy.tsx'; +import {getLocalePrefix} from '../shared/utils.tsx'; +import createSharedPathnamesNavigationClient from './react-client/createSharedPathnamesNavigation.tsx'; +import createSharedPathnamesNavigationServer from './react-server/createSharedPathnamesNavigation.tsx'; +import LegacyBaseLink from './shared/LegacyBaseLink.tsx'; + +vi.mock('next/navigation.js', async () => { + const actual = await vi.importActual('next/navigation.js'); return { ...actual, useParams: vi.fn(() => ({locale: 'en'})), diff --git a/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx index 8510508e0..8905457e9 100644 --- a/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx +++ b/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx @@ -1,15 +1,15 @@ import {fireEvent, render, screen} from '@testing-library/react'; -import {useParams, usePathname} from 'next/navigation'; -import React, {ComponentProps, LegacyRef, forwardRef} from 'react'; +import {useParams, usePathname} from 'next/navigation.js'; +import {ComponentProps, LegacyRef, forwardRef} from 'react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {NextIntlClientProvider} from '../../index.react-client'; -import {LocalePrefixConfigVerbose} from '../../routing/types'; -import ClientLink from './ClientLink'; +import {NextIntlClientProvider} from '../../index.react-client.tsx'; +import {LocalePrefixConfigVerbose} from '../../routing/types.tsx'; +import ClientLink from './ClientLink.tsx'; // Note: Once we remove the legacy navigation APIs, this test suite can be // removed too. All relevant tests have been moved to the new navigation API. -vi.mock('next/navigation'); +vi.mock('next/navigation.js'); function mockLocation(pathname: string, basePath = '') { vi.mocked(usePathname).mockReturnValue(pathname); diff --git a/packages/next-intl/src/navigation/react-client/ClientLink.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.tsx index 607e89866..94f473aa6 100644 --- a/packages/next-intl/src/navigation/react-client/ClientLink.tsx +++ b/packages/next-intl/src/navigation/react-client/ClientLink.tsx @@ -1,12 +1,12 @@ -import React, {ComponentProps, ReactElement, forwardRef} from 'react'; -import useLocale from '../../react-client/useLocale'; +import {ComponentProps, ReactElement, forwardRef} from 'react'; +import useLocale from '../../react-client/useLocale.tsx'; import { LocalePrefixConfigVerbose, LocalePrefixMode, Locales -} from '../../routing/types'; -import {getLocalePrefix} from '../../shared/utils'; -import LegacyBaseLink from '../shared/LegacyBaseLink'; +} from '../../routing/types.tsx'; +import {getLocalePrefix} from '../../shared/utils.tsx'; +import LegacyBaseLink from '../shared/LegacyBaseLink.tsx'; type Props< AppLocales extends Locales, 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 cd80c08bb..5c6a0f32d 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx @@ -3,13 +3,13 @@ import { usePathname as useNextPathname, useRouter as useNextRouter, useParams -} from 'next/navigation'; -import React, {ComponentProps, useRef} from 'react'; +} from 'next/navigation.js'; +import {ComponentProps, createRef, useRef} from 'react'; import {Mock, afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {Pathnames} from '../../routing'; -import createLocalizedPathnamesNavigation from './createLocalizedPathnamesNavigation'; +import {Pathnames} from '../../routing.tsx'; +import createLocalizedPathnamesNavigation from './createLocalizedPathnamesNavigation.tsx'; -vi.mock('next/navigation'); +vi.mock('next/navigation.js'); const locales = ['en', 'de', 'ja'] as const; const pathnames = { @@ -64,7 +64,7 @@ describe("localePrefix: 'as-needed'", () => { describe('Link', () => { it('supports receiving a ref', () => { - const ref = React.createRef(); + const ref = createRef(); render(); expect(ref.current).not.toBe(null); }); diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx index b3e20d222..fbff09eb2 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx @@ -1,28 +1,28 @@ -import React, {ComponentProps, ReactElement, forwardRef, useMemo} from 'react'; -import useLocale from '../../react-client/useLocale'; +import {ComponentProps, ReactElement, forwardRef, useMemo} from 'react'; +import useLocale from '../../react-client/useLocale.tsx'; import { RoutingConfigLocalizedNavigation, receiveLocaleCookie, receiveRoutingConfig -} from '../../routing/config'; +} from '../../routing/config.tsx'; import { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types'; -import {ParametersExceptFirst} from '../../shared/types'; +} from '../../routing/types.tsx'; +import {ParametersExceptFirst} from '../../shared/types.tsx'; import { HrefOrHrefWithParams, HrefOrUrlObjectWithParams, compileLocalizedPathname, getRoute, normalizeNameOrNameWithParams -} from '../shared/utils'; -import ClientLink from './ClientLink'; -import {clientPermanentRedirect, clientRedirect} from './redirects'; -import useBasePathname from './useBasePathname'; -import useBaseRouter from './useBaseRouter'; +} from '../shared/utils.tsx'; +import ClientLink from './ClientLink.tsx'; +import {clientPermanentRedirect, clientRedirect} from './redirects.tsx'; +import useBasePathname from './useBasePathname.tsx'; +import useBaseRouter from './useBaseRouter.tsx'; /** * @deprecated Consider switching to `createNavigation` (see https://next-intl-docs.vercel.app/blog/next-intl-3-22#create-navigation) diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index e7db73269..754625dc8 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -1,17 +1,15 @@ import {fireEvent, render, screen} from '@testing-library/react'; -import {PrefetchKind} from 'next/dist/client/components/router-reducer/router-reducer-types'; import { usePathname as useNextPathname, useRouter as useNextRouter, useParams -} from 'next/navigation'; -import React from 'react'; +} from 'next/navigation.js'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {NextIntlClientProvider} from '../../react-client'; -import {DomainsConfig, Pathnames} from '../../routing'; -import createNavigation from './createNavigation'; +import {NextIntlClientProvider} from '../../index.react-client.tsx'; +import {DomainsConfig, Pathnames} from '../../routing.tsx'; +import createNavigation from './createNavigation.tsx'; -vi.mock('next/navigation'); +vi.mock('next/navigation.js'); function mockCurrentLocale(locale: string) { vi.mocked(useParams<{locale: string}>).mockImplementation(() => ({ @@ -228,7 +226,11 @@ describe("localePrefix: 'always'", () => { it('prefixes with a secondary locale', () => { invokeRouter((router) => - router.prefetch('/about', {locale: 'de', kind: PrefetchKind.FULL}) + router.prefetch('/about', { + locale: 'de', + // @ts-expect-error -- Somehow only works via the enum (which is not exported) + kind: 'full' + }) ); expect(useNextRouter().prefetch).toHaveBeenCalledWith('/de/about', { kind: 'full' @@ -747,7 +749,11 @@ describe("localePrefix: 'never'", () => { expect(document.cookie).toContain('NEXT_LOCALE=de'); invokeRouter((router) => - router.prefetch('/about', {locale: 'ja', kind: PrefetchKind.AUTO}) + router.prefetch('/about', { + locale: 'ja', + // @ts-expect-error -- Somehow only works via the enum (which is not exported) + kind: 'auto' + }) ); expect(document.cookie).toContain('NEXT_LOCALE=ja'); }); diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index 868f352e3..d9b4a451a 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -1,23 +1,23 @@ import { usePathname as useNextPathname, useRouter as useNextRouter -} from 'next/navigation'; +} from 'next/navigation.js'; import {useMemo} from 'react'; -import useLocale from '../../react-client/useLocale'; +import useLocale from '../../react-client/useLocale.tsx'; import { RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation -} from '../../routing/config'; +} from '../../routing/config.tsx'; import { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types'; -import createSharedNavigationFns from '../shared/createSharedNavigationFns'; -import syncLocaleCookie from '../shared/syncLocaleCookie'; -import {getRoute} from '../shared/utils'; -import useBasePathname from './useBasePathname'; +} from '../../routing/types.tsx'; +import createSharedNavigationFns from '../shared/createSharedNavigationFns.tsx'; +import syncLocaleCookie from '../shared/syncLocaleCookie.tsx'; +import {getRoute} from '../shared/utils.tsx'; +import useBasePathname from './useBasePathname.tsx'; export default function createNavigation< const AppLocales extends Locales, diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.test.tsx index 2de0bd005..bf63025ed 100644 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.test.tsx @@ -3,12 +3,12 @@ import { usePathname as useNextPathname, useRouter as useNextRouter, useParams -} from 'next/navigation'; -import React from 'react'; +} from 'next/navigation.js'; +import {createRef} from 'react'; import {Mock, beforeEach, describe, expect, it, vi} from 'vitest'; -import createSharedPathnamesNavigation from './createSharedPathnamesNavigation'; +import createSharedPathnamesNavigation from './createSharedPathnamesNavigation.tsx'; -vi.mock('next/navigation'); +vi.mock('next/navigation.js'); const locales = ['en', 'de'] as const; @@ -34,7 +34,7 @@ describe("localePrefix: 'as-needed'", () => { describe('Link', () => { it('supports receiving a ref', () => { - const ref = React.createRef(); + const ref = createRef(); render(); expect(ref.current).not.toBe(null); }); diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx index c3e9019e0..65c75fb2f 100644 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx @@ -1,15 +1,19 @@ -import React, {ComponentProps, ReactElement, forwardRef} from 'react'; +import {ComponentProps, ReactElement, forwardRef} from 'react'; import { RoutingConfigSharedNavigation, receiveLocaleCookie, receiveLocalePrefixConfig -} from '../../routing/config'; -import {DomainsConfig, LocalePrefixMode, Locales} from '../../routing/types'; -import {ParametersExceptFirst} from '../../shared/types'; -import ClientLink from './ClientLink'; -import {clientPermanentRedirect, clientRedirect} from './redirects'; -import useBasePathname from './useBasePathname'; -import useBaseRouter from './useBaseRouter'; +} from '../../routing/config.tsx'; +import { + DomainsConfig, + LocalePrefixMode, + Locales +} from '../../routing/types.tsx'; +import {ParametersExceptFirst} from '../../shared/types.tsx'; +import ClientLink from './ClientLink.tsx'; +import {clientPermanentRedirect, clientRedirect} from './redirects.tsx'; +import useBasePathname from './useBasePathname.tsx'; +import useBaseRouter from './useBaseRouter.tsx'; /** * @deprecated Consider switching to `createNavigation` (see https://next-intl-docs.vercel.app/blog/next-intl-3-22#create-navigation) diff --git a/packages/next-intl/src/navigation/react-client/index.tsx b/packages/next-intl/src/navigation/react-client/index.tsx index c4183b53b..391f917c6 100644 --- a/packages/next-intl/src/navigation/react-client/index.tsx +++ b/packages/next-intl/src/navigation/react-client/index.tsx @@ -1,11 +1,11 @@ -export {default as createSharedPathnamesNavigation} from './createSharedPathnamesNavigation'; -export {default as createLocalizedPathnamesNavigation} from './createLocalizedPathnamesNavigation'; -export {default as createNavigation} from './createNavigation'; +export {default as createSharedPathnamesNavigation} from './createSharedPathnamesNavigation.tsx'; +export {default as createLocalizedPathnamesNavigation} from './createLocalizedPathnamesNavigation.tsx'; +export {default as createNavigation} from './createNavigation.tsx'; import type { Locales, Pathnames as PathnamesDeprecatedExport -} from '../../routing/types'; +} from '../../routing/types.tsx'; /** @deprecated Please import from `next-intl/routing` instead. */ export type Pathnames = diff --git a/packages/next-intl/src/navigation/react-client/redirects.tsx b/packages/next-intl/src/navigation/react-client/redirects.tsx index a9d118ba1..f677c4ad3 100644 --- a/packages/next-intl/src/navigation/react-client/redirects.tsx +++ b/packages/next-intl/src/navigation/react-client/redirects.tsx @@ -1,6 +1,6 @@ -import useLocale from '../../react-client/useLocale'; -import {ParametersExceptFirst} from '../../shared/types'; -import {basePermanentRedirect, baseRedirect} from '../shared/redirects'; +import useLocale from '../../react-client/useLocale.tsx'; +import {ParametersExceptFirst} from '../../shared/types.tsx'; +import {basePermanentRedirect, baseRedirect} from '../shared/redirects.tsx'; function createRedirectFn(redirectFn: typeof baseRedirect) { return function clientRedirect( diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx index 4e8370fe4..4d4ea8839 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx @@ -1,11 +1,10 @@ import {render, screen} from '@testing-library/react'; -import {usePathname as useNextPathname, useParams} from 'next/navigation'; -import React from 'react'; +import {usePathname as useNextPathname, useParams} from 'next/navigation.js'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {NextIntlClientProvider} from '../../index.react-client'; -import useBasePathname from './useBasePathname'; +import {NextIntlClientProvider} from '../../index.react-client.tsx'; +import useBasePathname from './useBasePathname.tsx'; -vi.mock('next/navigation'); +vi.mock('next/navigation.js'); function mockPathname(pathname: string) { vi.mocked(useNextPathname).mockImplementation(() => pathname); diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx index 21562dcc2..9bca6df97 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx @@ -1,16 +1,16 @@ -import {usePathname as useNextPathname} from 'next/navigation'; +import {usePathname as useNextPathname} from 'next/navigation.js'; import {useMemo} from 'react'; -import useLocale from '../../react-client/useLocale'; +import useLocale from '../../react-client/useLocale.tsx'; import { LocalePrefixConfigVerbose, LocalePrefixMode, Locales -} from '../../routing/types'; +} from '../../routing/types.tsx'; import { getLocalePrefix, hasPathnamePrefixed, unprefixPathname -} from '../../shared/utils'; +} from '../../shared/utils.tsx'; export default function useBasePathname< AppLocales extends Locales, diff --git a/packages/next-intl/src/navigation/react-client/useBaseRouter.test.tsx b/packages/next-intl/src/navigation/react-client/useBaseRouter.test.tsx index da01c455e..cfba14fcd 100644 --- a/packages/next-intl/src/navigation/react-client/useBaseRouter.test.tsx +++ b/packages/next-intl/src/navigation/react-client/useBaseRouter.test.tsx @@ -1,16 +1,15 @@ import {render} from '@testing-library/react'; -import {PrefetchKind} from 'next/dist/client/components/router-reducer/router-reducer-types'; -import {AppRouterInstance} from 'next/dist/shared/lib/app-router-context.shared-runtime'; import { usePathname as useNextPathname, - useRouter as useNextRouter -} from 'next/navigation'; -import React, {useEffect} from 'react'; + useRouter as useNextRouter, + type useRouter +} from 'next/navigation.js'; +import {useEffect} from 'react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import useBaseRouter from './useBaseRouter'; +import useBaseRouter from './useBaseRouter.tsx'; -vi.mock('next/navigation', () => { - const router: AppRouterInstance = { +vi.mock('next/navigation.js', () => { + const router: ReturnType = { push: vi.fn(), replace: vi.fn(), prefetch: vi.fn(), @@ -108,10 +107,14 @@ describe('unprefixed routing', () => { it('can prefetch a new locale', () => { callRouter((router) => - router.prefetch('/about', {locale: 'es', kind: PrefetchKind.AUTO}) + router.prefetch('/about', { + locale: 'es', + // @ts-expect-error -- Somehow only works via the enum (which is not exported) + kind: 'auto' + }) ); expect(useNextRouter().prefetch).toHaveBeenCalledWith('/es/about', { - kind: PrefetchKind.AUTO + kind: 'auto' }); }); @@ -128,7 +131,11 @@ describe('unprefixed routing', () => { expect(document.cookie).toContain('NEXT_LOCALE=es'); callRouter((router) => - router.prefetch('/about', {locale: 'it', kind: PrefetchKind.AUTO}) + router.prefetch('/about', { + locale: 'it', + // @ts-expect-error -- Somehow only works via the enum (which is not exported) + kind: 'auto' + }) ); expect(document.cookie).toContain('NEXT_LOCALE=it'); }); diff --git a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx index 0cf263195..30778f591 100644 --- a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx +++ b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx @@ -1,15 +1,15 @@ -import {useRouter as useNextRouter, usePathname} from 'next/navigation'; +import {useRouter as useNextRouter, usePathname} from 'next/navigation.js'; import {useMemo} from 'react'; -import useLocale from '../../react-client/useLocale'; -import {InitializedLocaleCookieConfig} from '../../routing/config'; +import useLocale from '../../react-client/useLocale.tsx'; +import {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; import { LocalePrefixConfigVerbose, LocalePrefixMode, Locales -} from '../../routing/types'; -import {getLocalePrefix, localizeHref} from '../../shared/utils'; -import syncLocaleCookie from '../shared/syncLocaleCookie'; -import {getBasePath} from '../shared/utils'; +} from '../../routing/types.tsx'; +import {getLocalePrefix, localizeHref} from '../../shared/utils.tsx'; +import syncLocaleCookie from '../shared/syncLocaleCookie.tsx'; +import {getBasePath} from '../shared/utils.tsx'; type IntlNavigateOptions = { locale?: AppLocales[number]; diff --git a/packages/next-intl/src/navigation/react-server/ServerLink.tsx b/packages/next-intl/src/navigation/react-server/ServerLink.tsx index 852ef9309..76812ba47 100644 --- a/packages/next-intl/src/navigation/react-server/ServerLink.tsx +++ b/packages/next-intl/src/navigation/react-server/ServerLink.tsx @@ -1,12 +1,12 @@ -import React, {ComponentProps} from 'react'; +import {ComponentProps} from 'react'; import { LocalePrefixConfigVerbose, LocalePrefixMode, Locales -} from '../../routing/types'; -import {getLocale} from '../../server.react-server'; -import {getLocalePrefix} from '../../shared/utils'; -import LegacyBaseLink from '../shared/LegacyBaseLink'; +} from '../../routing/types.tsx'; +import {getLocale} from '../../server.react-server.tsx'; +import {getLocalePrefix} from '../../shared/utils.tsx'; +import LegacyBaseLink from '../shared/LegacyBaseLink.tsx'; // Only used by legacy navigation APIs, can be removed when they are removed diff --git a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx index 117648daf..008330715 100644 --- a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx @@ -1,24 +1,24 @@ -import React, {ComponentProps} from 'react'; +import {ComponentProps} from 'react'; import { RoutingConfigLocalizedNavigation, receiveRoutingConfig -} from '../../routing/config'; +} from '../../routing/config.tsx'; import { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types'; -import {getRequestLocale} from '../../server/react-server/RequestLocaleLegacy'; -import {ParametersExceptFirst} from '../../shared/types'; +} from '../../routing/types.tsx'; +import {getRequestLocale} from '../../server/react-server/RequestLocaleLegacy.tsx'; +import {ParametersExceptFirst} from '../../shared/types.tsx'; import { HrefOrHrefWithParams, HrefOrUrlObjectWithParams, compileLocalizedPathname, normalizeNameOrNameWithParams -} from '../shared/utils'; -import ServerLink from './ServerLink'; -import {serverPermanentRedirect, serverRedirect} from './redirects'; +} from '../shared/utils.tsx'; +import ServerLink from './ServerLink.tsx'; +import {serverPermanentRedirect, serverRedirect} from './redirects.tsx'; export default function createLocalizedPathnamesNavigation< AppLocales extends Locales, diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx index dc491b08b..73277434f 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from 'vitest'; -import createNavigation from './createNavigation'; +import createNavigation from './createNavigation.tsx'; vi.mock('react'); diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx index ba09f314c..c17732737 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -1,15 +1,15 @@ import { RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation -} from '../../routing/config'; +} from '../../routing/config.tsx'; import { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types'; -import createSharedNavigationFns from '../shared/createSharedNavigationFns'; -import getServerLocale from './getServerLocale'; +} from '../../routing/types.tsx'; +import createSharedNavigationFns from '../shared/createSharedNavigationFns.tsx'; +import getServerLocale from './getServerLocale.tsx'; export default function createNavigation< const AppLocales extends Locales, diff --git a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx index 2b7aa6f8a..255c91568 100644 --- a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx @@ -1,13 +1,17 @@ -import React, {ComponentProps} from 'react'; +import {ComponentProps} from 'react'; import { RoutingConfigSharedNavigation, receiveLocaleCookie, receiveLocalePrefixConfig -} from '../../routing/config'; -import {DomainsConfig, LocalePrefixMode, Locales} from '../../routing/types'; -import {ParametersExceptFirst} from '../../shared/types'; -import ServerLink from './ServerLink'; -import {serverPermanentRedirect, serverRedirect} from './redirects'; +} from '../../routing/config.tsx'; +import { + DomainsConfig, + LocalePrefixMode, + Locales +} from '../../routing/types.tsx'; +import {ParametersExceptFirst} from '../../shared/types.tsx'; +import ServerLink from './ServerLink.tsx'; +import {serverPermanentRedirect, serverRedirect} from './redirects.tsx'; export default function createSharedPathnamesNavigation< AppLocales extends Locales, diff --git a/packages/next-intl/src/navigation/react-server/getServerLocale.tsx b/packages/next-intl/src/navigation/react-server/getServerLocale.tsx index b153bdf4c..e6bd11965 100644 --- a/packages/next-intl/src/navigation/react-server/getServerLocale.tsx +++ b/packages/next-intl/src/navigation/react-server/getServerLocale.tsx @@ -1,4 +1,4 @@ -import getConfig from '../../server/react-server/getConfig'; +import getConfig from '../../server/react-server/getConfig.tsx'; /** * This is only moved to a separate module for easier mocking in diff --git a/packages/next-intl/src/navigation/react-server/index.tsx b/packages/next-intl/src/navigation/react-server/index.tsx index 88636a437..5541b366a 100644 --- a/packages/next-intl/src/navigation/react-server/index.tsx +++ b/packages/next-intl/src/navigation/react-server/index.tsx @@ -1,4 +1,4 @@ -export {default as createSharedPathnamesNavigation} from './createSharedPathnamesNavigation'; -export {default as createLocalizedPathnamesNavigation} from './createLocalizedPathnamesNavigation'; -export {default as createNavigation} from './createNavigation'; -export type {Pathnames} from '../../routing/types'; +export {default as createSharedPathnamesNavigation} from './createSharedPathnamesNavigation.tsx'; +export {default as createLocalizedPathnamesNavigation} from './createLocalizedPathnamesNavigation.tsx'; +export {default as createNavigation} from './createNavigation.tsx'; +export type {Pathnames} from '../../routing/types.tsx'; diff --git a/packages/next-intl/src/navigation/react-server/redirects.tsx b/packages/next-intl/src/navigation/react-server/redirects.tsx index c0370282e..43ea1659e 100644 --- a/packages/next-intl/src/navigation/react-server/redirects.tsx +++ b/packages/next-intl/src/navigation/react-server/redirects.tsx @@ -1,6 +1,6 @@ -import {getRequestLocale} from '../../server/react-server/RequestLocaleLegacy'; -import {ParametersExceptFirst} from '../../shared/types'; -import {basePermanentRedirect, baseRedirect} from '../shared/redirects'; +import {getRequestLocale} from '../../server/react-server/RequestLocaleLegacy.tsx'; +import {ParametersExceptFirst} from '../../shared/types.tsx'; +import {basePermanentRedirect, baseRedirect} from '../shared/redirects.tsx'; function createRedirectFn(redirectFn: typeof baseRedirect) { return function serverRedirect( diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index fe595688a..d5a63931b 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -1,19 +1,23 @@ 'use client'; -import NextLink from 'next/link'; -import {usePathname} from 'next/navigation'; -import React, { +import NextLink, {LinkProps} from 'next/link.js'; +import {usePathname} from 'next/navigation.js'; +import { ComponentProps, MouseEvent, + Ref, forwardRef, useEffect, useState } from 'react'; -import useLocale from '../../react-client/useLocale'; -import {InitializedLocaleCookieConfig} from '../../routing/config'; -import syncLocaleCookie from './syncLocaleCookie'; +import useLocale from '../../react-client/useLocale.tsx'; +import {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; +import syncLocaleCookie from './syncLocaleCookie.tsx'; -type Props = Omit, 'locale'> & { +type NextLinkProps = Omit, keyof LinkProps> & + Omit; + +type Props = NextLinkProps & { locale?: string; defaultLocale?: string; localeCookie: InitializedLocaleCookieConfig; @@ -35,7 +39,7 @@ function BaseLink( unprefixed, ...rest }: Props, - ref: ComponentProps['ref'] + ref: Ref ) { const curLocale = useLocale(); const isChangingLocale = locale != null && locale !== curLocale; @@ -76,8 +80,12 @@ function BaseLink( prefetch = false; } + // Somehow the types for `next/link` don't work as expected + // when `moduleResolution: "nodenext"` is used. + const Link = NextLink as unknown as (props: NextLinkProps) => JSX.Element; + return ( - , 'locale'> & { +type Props = Omit< + ComponentProps, + 'locale' | 'unprefixed' | 'defaultLocale' +> & { locale: string; prefix: string; localePrefixMode: LocalePrefixMode; diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index e32ab0f24..131e0270d 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -1,23 +1,24 @@ import { permanentRedirect as nextPermanentRedirect, redirect as nextRedirect -} from 'next/navigation'; -import React, {ComponentProps, forwardRef, use} from 'react'; +} from 'next/navigation.js'; +import {ComponentProps, forwardRef} from 'react'; import { RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation, receiveRoutingConfig -} from '../../routing/config'; +} from '../../routing/config.tsx'; import { DomainConfig, DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types'; -import {ParametersExceptFirst, Prettify} from '../../shared/types'; -import {isLocalizableHref} from '../../shared/utils'; -import BaseLink from './BaseLink'; +} from '../../routing/types.tsx'; +import {ParametersExceptFirst, Prettify} from '../../shared/types.tsx'; +import use from '../../shared/use.tsx'; +import {isLocalizableHref} from '../../shared/utils.tsx'; +import BaseLink from './BaseLink.tsx'; import { HrefOrHrefWithParams, HrefOrUrlObjectWithParams, @@ -27,7 +28,7 @@ import { normalizeNameOrNameWithParams, serializeSearchParams, validateReceivedConfig -} from './utils'; +} from './utils.tsx'; type PromiseOrValue = Type | Promise; type UnwrapPromiseOrValue = @@ -111,7 +112,7 @@ export default function createSharedNavigationFns< const isLocalizable = isLocalizableHref(href); const localePromiseOrValue = getLocale(); - const curLocale = + const curLocale: AppLocales extends never ? string : AppLocales[number] = localePromiseOrValue instanceof Promise ? use(localePromiseOrValue) : localePromiseOrValue; diff --git a/packages/next-intl/src/navigation/shared/redirects.test.tsx b/packages/next-intl/src/navigation/shared/redirects.test.tsx index e9a331ca2..6eaf2938f 100644 --- a/packages/next-intl/src/navigation/shared/redirects.test.tsx +++ b/packages/next-intl/src/navigation/shared/redirects.test.tsx @@ -1,11 +1,11 @@ import { permanentRedirect as nextPermanentRedirect, redirect as nextRedirect -} from 'next/navigation'; +} from 'next/navigation.js'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {basePermanentRedirect, baseRedirect} from './redirects'; +import {basePermanentRedirect, baseRedirect} from './redirects.tsx'; -vi.mock('next/navigation'); +vi.mock('next/navigation.js'); beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/next-intl/src/navigation/shared/redirects.tsx b/packages/next-intl/src/navigation/shared/redirects.tsx index cc6bd04db..28b19a686 100644 --- a/packages/next-intl/src/navigation/shared/redirects.tsx +++ b/packages/next-intl/src/navigation/shared/redirects.tsx @@ -1,18 +1,18 @@ import { permanentRedirect as nextPermanentRedirect, redirect as nextRedirect -} from 'next/navigation'; +} from 'next/navigation.js'; import { LocalePrefixConfigVerbose, LocalePrefixMode, Locales -} from '../../routing/types'; -import {ParametersExceptFirst} from '../../shared/types'; +} from '../../routing/types.tsx'; +import {ParametersExceptFirst} from '../../shared/types.tsx'; import { getLocalePrefix, isLocalizableHref, prefixPathname -} from '../../shared/utils'; +} from '../../shared/utils.tsx'; function createRedirectFn(redirectFn: typeof nextRedirect) { return function baseRedirect< diff --git a/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx b/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx index 6ebeb99dd..eabd9dc23 100644 --- a/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx +++ b/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx @@ -1,5 +1,5 @@ -import {InitializedLocaleCookieConfig} from '../../routing/config'; -import {getBasePath} from './utils'; +import {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; +import {getBasePath} from './utils.tsx'; /** * We have to keep the cookie value in sync as Next.js might diff --git a/packages/next-intl/src/navigation/shared/utils.test.tsx b/packages/next-intl/src/navigation/shared/utils.test.tsx index d20e951aa..1e99fe485 100644 --- a/packages/next-intl/src/navigation/shared/utils.test.tsx +++ b/packages/next-intl/src/navigation/shared/utils.test.tsx @@ -3,7 +3,7 @@ import { compileLocalizedPathname, getBasePath, serializeSearchParams -} from './utils'; +} from './utils.tsx'; describe('serializeSearchParams', () => { it('handles strings', () => { diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 789b78536..31c44def7 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,12 +1,12 @@ import type {ParsedUrlQueryInput} from 'node:querystring'; import type {UrlObject} from 'url'; -import {ResolvedRoutingConfig} from '../../routing/config'; +import {ResolvedRoutingConfig} from '../../routing/config.tsx'; import { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types'; +} from '../../routing/types.tsx'; import { getLocalePrefix, getSortedPathnames, @@ -14,8 +14,8 @@ import { matchesPathname, normalizeTrailingSlash, prefixPathname -} from '../../shared/utils'; -import StrictParams from './StrictParams'; +} from '../../shared/utils.tsx'; +import StrictParams from './StrictParams.tsx'; type SearchParamValue = ParsedUrlQueryInput[keyof ParsedUrlQueryInput]; diff --git a/packages/next-intl/src/plugin.tsx b/packages/next-intl/src/plugin.tsx index afa33423f..4ddc86375 100644 --- a/packages/next-intl/src/plugin.tsx +++ b/packages/next-intl/src/plugin.tsx @@ -132,8 +132,8 @@ function initPlugin(i18nPath?: string, nextConfig?: NextConfig): NextConfig { return Object.assign({}, nextConfig, nextIntlConfig); } -module.exports = function createNextIntlPlugin(i18nPath?: string) { +export default function createNextIntlPlugin(i18nPath?: string) { return function withNextIntl(nextConfig?: NextConfig) { return initPlugin(i18nPath, nextConfig); }; -}; +} diff --git a/packages/next-intl/src/react-client/index.tsx b/packages/next-intl/src/react-client/index.tsx index aca9e6df9..094b4f0b4 100644 --- a/packages/next-intl/src/react-client/index.tsx +++ b/packages/next-intl/src/react-client/index.tsx @@ -47,6 +47,6 @@ export const useFormatter = callHook( ) as typeof base_useFormatter; // Replace `useLocale` export from `use-intl` -export {default as useLocale} from './useLocale'; +export {default as useLocale} from './useLocale.tsx'; -export {default as NextIntlClientProvider} from '../shared/NextIntlClientProvider'; +export {default as NextIntlClientProvider} from '../shared/NextIntlClientProvider.tsx'; diff --git a/packages/next-intl/src/react-client/useFormatter.test.tsx b/packages/next-intl/src/react-client/useFormatter.test.tsx index a1ec1bff2..d77ad0825 100644 --- a/packages/next-intl/src/react-client/useFormatter.test.tsx +++ b/packages/next-intl/src/react-client/useFormatter.test.tsx @@ -1,7 +1,6 @@ import {render, screen} from '@testing-library/react'; -import React from 'react'; import {expect, it} from 'vitest'; -import {NextIntlClientProvider, useFormatter} from '.'; +import {NextIntlClientProvider, useFormatter} from './index.tsx'; function Component() { const format = useFormatter(); diff --git a/packages/next-intl/src/react-client/useLocale.test.tsx b/packages/next-intl/src/react-client/useLocale.test.tsx index 9093c0619..2062bd8fc 100644 --- a/packages/next-intl/src/react-client/useLocale.test.tsx +++ b/packages/next-intl/src/react-client/useLocale.test.tsx @@ -1,10 +1,9 @@ import {render, screen} from '@testing-library/react'; -import {useParams} from 'next/navigation'; -import React from 'react'; +import {useParams} from 'next/navigation.js'; import {expect, it, vi} from 'vitest'; -import {NextIntlClientProvider, useLocale} from '.'; +import {NextIntlClientProvider, useLocale} from './index.tsx'; -vi.mock('next/navigation', () => ({ +vi.mock('next/navigation.js', () => ({ useParams: vi.fn(() => ({locale: 'en'})) })); diff --git a/packages/next-intl/src/react-client/useLocale.tsx b/packages/next-intl/src/react-client/useLocale.tsx index 1b4f4f98f..66a8d9378 100644 --- a/packages/next-intl/src/react-client/useLocale.tsx +++ b/packages/next-intl/src/react-client/useLocale.tsx @@ -1,7 +1,6 @@ -import {useParams} from 'next/navigation'; -// Workaround for some bundle splitting until we have ESM -import {useLocale as useBaseLocale} from 'use-intl/_useLocale'; -import {LOCALE_SEGMENT_NAME} from '../shared/constants'; +import {useParams} from 'next/navigation.js'; +import {useLocale as useBaseLocale} from 'use-intl/react'; +import {LOCALE_SEGMENT_NAME} from '../shared/constants.tsx'; export default function useLocale(): string { // The types aren't entirely correct here. Outside of Next.js diff --git a/packages/next-intl/src/react-client/useNow.test.tsx b/packages/next-intl/src/react-client/useNow.test.tsx index d00490d69..82d134004 100644 --- a/packages/next-intl/src/react-client/useNow.test.tsx +++ b/packages/next-intl/src/react-client/useNow.test.tsx @@ -1,7 +1,6 @@ import {render, screen} from '@testing-library/react'; -import React from 'react'; import {it} from 'vitest'; -import {NextIntlClientProvider, useNow} from '.'; +import {NextIntlClientProvider, useNow} from './index.tsx'; function Component() { const now = useNow(); diff --git a/packages/next-intl/src/react-client/useTimeZone.test.tsx b/packages/next-intl/src/react-client/useTimeZone.test.tsx index e8fe3a792..bae67114c 100644 --- a/packages/next-intl/src/react-client/useTimeZone.test.tsx +++ b/packages/next-intl/src/react-client/useTimeZone.test.tsx @@ -1,7 +1,6 @@ import {render, screen} from '@testing-library/react'; -import React from 'react'; import {it} from 'vitest'; -import {NextIntlClientProvider, useTimeZone} from '.'; +import {NextIntlClientProvider, useTimeZone} from './index.tsx'; function Component() { const timeZone = useTimeZone(); diff --git a/packages/next-intl/src/react-client/useTranslations.test.tsx b/packages/next-intl/src/react-client/useTranslations.test.tsx index f9b7a2390..ac1264e8c 100644 --- a/packages/next-intl/src/react-client/useTranslations.test.tsx +++ b/packages/next-intl/src/react-client/useTranslations.test.tsx @@ -1,7 +1,6 @@ import {render, screen} from '@testing-library/react'; -import React from 'react'; import {expect, it, vi} from 'vitest'; -import {NextIntlClientProvider, useTranslations} from '.'; +import {NextIntlClientProvider, useTranslations} from './index.tsx'; function Component() { const t = useTranslations('Component'); diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx index c01771f99..fb40d7b1f 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx @@ -1,8 +1,8 @@ import {expect, it, vi} from 'vitest'; -import getFormats from '../server/react-server/getFormats'; -import {getLocale, getNow, getTimeZone} from '../server.react-server'; -import NextIntlClientProvider from '../shared/NextIntlClientProvider'; -import NextIntlClientProviderServer from './NextIntlClientProviderServer'; +import getFormats from '../server/react-server/getFormats.tsx'; +import {getLocale, getNow, getTimeZone} from '../server.react-server.tsx'; +import NextIntlClientProvider from '../shared/NextIntlClientProvider.tsx'; +import NextIntlClientProviderServer from './NextIntlClientProviderServer.tsx'; vi.mock('../../src/server/react-server', async () => ({ getLocale: vi.fn(async () => 'en-US'), diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx index 9e809efd7..a17ac2e62 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx @@ -1,7 +1,7 @@ -import React, {ComponentProps} from 'react'; -import getFormats from '../server/react-server/getFormats'; -import {getLocale, getNow, getTimeZone} from '../server.react-server'; -import BaseNextIntlClientProvider from '../shared/NextIntlClientProvider'; +import {ComponentProps} from 'react'; +import getFormats from '../server/react-server/getFormats.tsx'; +import {getLocale, getNow, getTimeZone} from '../server.react-server.tsx'; +import BaseNextIntlClientProvider from '../shared/NextIntlClientProvider.tsx'; type Props = ComponentProps; diff --git a/packages/next-intl/src/react-server/index.test.tsx b/packages/next-intl/src/react-server/index.test.tsx index ab84710ce..1bbe1511b 100644 --- a/packages/next-intl/src/react-server/index.test.tsx +++ b/packages/next-intl/src/react-server/index.test.tsx @@ -1,7 +1,5 @@ -import React from 'react'; import {describe, expect, it, vi} from 'vitest'; -import {getTranslations} from '../server.react-server'; -import {renderToStream} from './testUtils'; +import {getTranslations} from '../server.react-server.tsx'; import { _createCache, useFormatter, @@ -9,7 +7,8 @@ import { useMessages, useNow, useTranslations -} from '.'; +} from './index.tsx'; +import {renderToStream} from './testUtils.tsx'; vi.mock('react'); diff --git a/packages/next-intl/src/react-server/index.tsx b/packages/next-intl/src/react-server/index.tsx index 08d767c66..f7bc68c7d 100644 --- a/packages/next-intl/src/react-server/index.tsx +++ b/packages/next-intl/src/react-server/index.tsx @@ -7,13 +7,13 @@ */ // Replaced exports from the `react` package -export {default as useLocale} from './useLocale'; -export {default as useTranslations} from './useTranslations'; -export {default as useFormatter} from './useFormatter'; -export {default as useNow} from './useNow'; -export {default as useTimeZone} from './useTimeZone'; -export {default as useMessages} from './useMessages'; -export {default as NextIntlClientProvider} from './NextIntlClientProviderServer'; +export {default as useLocale} from './useLocale.tsx'; +export {default as useTranslations} from './useTranslations.tsx'; +export {default as useFormatter} from './useFormatter.tsx'; +export {default as useNow} from './useNow.tsx'; +export {default as useTimeZone} from './useTimeZone.tsx'; +export {default as useMessages} from './useMessages.tsx'; +export {default as NextIntlClientProvider} from './NextIntlClientProviderServer.tsx'; // Everything from `core` export * from 'use-intl/core'; diff --git a/packages/next-intl/src/react-server/testUtils.tsx b/packages/next-intl/src/react-server/testUtils.tsx index b6eca87a7..b588b6867 100644 --- a/packages/next-intl/src/react-server/testUtils.tsx +++ b/packages/next-intl/src/react-server/testUtils.tsx @@ -1,4 +1,4 @@ -import React, {ReactNode, Suspense} from 'react'; +import {ReactNode, Suspense} from 'react'; import {ReactDOMServerReadableStream} from 'react-dom/server'; // @ts-expect-error -- Not available in types import {renderToReadableStream as _renderToReadableStream} from 'react-dom/server.browser'; diff --git a/packages/next-intl/src/react-server/useConfig.tsx b/packages/next-intl/src/react-server/useConfig.tsx index 2731c730d..de51036b5 100644 --- a/packages/next-intl/src/react-server/useConfig.tsx +++ b/packages/next-intl/src/react-server/useConfig.tsx @@ -1,5 +1,5 @@ -import {use} from 'react'; -import getConfig from '../server/react-server/getConfig'; +import getConfig from '../server/react-server/getConfig.tsx'; +import use from '../shared/use.tsx'; function useHook(hookName: string, promise: Promise) { try { diff --git a/packages/next-intl/src/react-server/useFormatter.tsx b/packages/next-intl/src/react-server/useFormatter.tsx index 7aac95999..48be0f27e 100644 --- a/packages/next-intl/src/react-server/useFormatter.tsx +++ b/packages/next-intl/src/react-server/useFormatter.tsx @@ -1,14 +1,11 @@ import {cache} from 'react'; -import {type useFormatter as useFormatterType} from 'use-intl'; +import type {useFormatter as useFormatterType} from 'use-intl'; import {createFormatter} from 'use-intl/core'; -import useConfig from './useConfig'; +import useConfig from './useConfig.tsx'; const createFormatterCached = cache(createFormatter); -export default function useFormatter( - // eslint-disable-next-line no-empty-pattern - ...[]: Parameters -): ReturnType { +export default function useFormatter(): ReturnType { const config = useConfig('useFormatter'); return createFormatterCached(config); } diff --git a/packages/next-intl/src/react-server/useLocale.tsx b/packages/next-intl/src/react-server/useLocale.tsx index 3a4d281b7..a517f0afc 100644 --- a/packages/next-intl/src/react-server/useLocale.tsx +++ b/packages/next-intl/src/react-server/useLocale.tsx @@ -1,10 +1,7 @@ import type {useLocale as useLocaleType} from 'use-intl'; -import useConfig from './useConfig'; +import useConfig from './useConfig.tsx'; -export default function useLocale( - // eslint-disable-next-line no-empty-pattern - ...[]: Parameters -): ReturnType { +export default function useLocale(): ReturnType { const config = useConfig('useLocale'); return config.locale; } diff --git a/packages/next-intl/src/react-server/useMessages.tsx b/packages/next-intl/src/react-server/useMessages.tsx index 8dff17598..789304044 100644 --- a/packages/next-intl/src/react-server/useMessages.tsx +++ b/packages/next-intl/src/react-server/useMessages.tsx @@ -1,11 +1,8 @@ import type {useMessages as useMessagesType} from 'use-intl'; -import {getMessagesFromConfig} from '../server/react-server/getMessages'; -import useConfig from './useConfig'; +import {getMessagesFromConfig} from '../server/react-server/getMessages.tsx'; +import useConfig from './useConfig.tsx'; -export default function useMessages( - // eslint-disable-next-line no-empty-pattern - ...[]: Parameters -): ReturnType { +export default function useMessages(): ReturnType { const config = useConfig('useMessages'); return getMessagesFromConfig(config); } diff --git a/packages/next-intl/src/react-server/useNow.tsx b/packages/next-intl/src/react-server/useNow.tsx index 3b4c2411c..e5f210dc8 100644 --- a/packages/next-intl/src/react-server/useNow.tsx +++ b/packages/next-intl/src/react-server/useNow.tsx @@ -1,8 +1,8 @@ import type {useNow as useNowType} from 'use-intl'; -import useConfig from './useConfig'; +import useConfig from './useConfig.tsx'; export default function useNow( - ...[options]: Parameters + options?: Parameters[0] ): ReturnType { if (options?.updateInterval != null) { console.error( diff --git a/packages/next-intl/src/react-server/useTimeZone.tsx b/packages/next-intl/src/react-server/useTimeZone.tsx index 6b47cfe36..c527b20c1 100644 --- a/packages/next-intl/src/react-server/useTimeZone.tsx +++ b/packages/next-intl/src/react-server/useTimeZone.tsx @@ -1,10 +1,7 @@ import type {useTimeZone as useTimeZoneType} from 'use-intl'; -import useConfig from './useConfig'; +import useConfig from './useConfig.tsx'; -export default function useTimeZone( - // eslint-disable-next-line no-empty-pattern - ...[]: Parameters -): ReturnType { +export default function useTimeZone(): ReturnType { const config = useConfig('useTimeZone'); return config.timeZone; } diff --git a/packages/next-intl/src/react-server/useTranslations.test.tsx b/packages/next-intl/src/react-server/useTranslations.test.tsx index b9f2af5ef..1953d0ad5 100644 --- a/packages/next-intl/src/react-server/useTranslations.test.tsx +++ b/packages/next-intl/src/react-server/useTranslations.test.tsx @@ -1,7 +1,7 @@ -import React, {cache} from 'react'; +import {cache} from 'react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {renderToStream} from './testUtils'; -import {createTranslator, useTranslations} from '.'; +import {createTranslator, useTranslations} from './index.tsx'; +import {renderToStream} from './testUtils.tsx'; vi.mock('../../src/server/react-server/createRequestConfig', () => ({ default: async () => ({ diff --git a/packages/next-intl/src/react-server/useTranslations.tsx b/packages/next-intl/src/react-server/useTranslations.tsx index ebf7b613c..3968c5902 100644 --- a/packages/next-intl/src/react-server/useTranslations.tsx +++ b/packages/next-intl/src/react-server/useTranslations.tsx @@ -1,6 +1,6 @@ import type {useTranslations as useTranslationsType} from 'use-intl'; -import getBaseTranslator from './getTranslator'; -import useConfig from './useConfig'; +import getBaseTranslator from './getTranslator.tsx'; +import useConfig from './useConfig.tsx'; export default function useTranslations( ...[namespace]: Parameters diff --git a/packages/next-intl/src/routing.tsx b/packages/next-intl/src/routing.tsx index 445db87c8..3cb1cbc99 100644 --- a/packages/next-intl/src/routing.tsx +++ b/packages/next-intl/src/routing.tsx @@ -1 +1 @@ -export * from './routing/index'; +export * from './routing/index.tsx'; diff --git a/packages/next-intl/src/routing/config.tsx b/packages/next-intl/src/routing/config.tsx index 4e50c5ad9..7328bad1a 100644 --- a/packages/next-intl/src/routing/config.tsx +++ b/packages/next-intl/src/routing/config.tsx @@ -1,4 +1,4 @@ -import type {NextResponse} from 'next/server'; +import type {NextResponse} from 'next/server.js'; import { DomainsConfig, LocalePrefix, @@ -6,7 +6,7 @@ import { LocalePrefixMode, Locales, Pathnames -} from './types'; +} from './types.tsx'; type CookieAttributes = Pick< NonNullable['2']>, diff --git a/packages/next-intl/src/routing/defineRouting.test.tsx b/packages/next-intl/src/routing/defineRouting.test.tsx index 673ce77b8..d3ea1fa2a 100644 --- a/packages/next-intl/src/routing/defineRouting.test.tsx +++ b/packages/next-intl/src/routing/defineRouting.test.tsx @@ -1,5 +1,5 @@ import {describe, it} from 'vitest'; -import defineRouting from './defineRouting'; +import defineRouting from './defineRouting.tsx'; describe('defaultLocale', () => { it('ensures the `defaultLocale` is within `locales`', () => { diff --git a/packages/next-intl/src/routing/defineRouting.tsx b/packages/next-intl/src/routing/defineRouting.tsx index d470db867..f8cc60781 100644 --- a/packages/next-intl/src/routing/defineRouting.tsx +++ b/packages/next-intl/src/routing/defineRouting.tsx @@ -1,5 +1,5 @@ -import {RoutingConfig} from './config'; -import {DomainsConfig, LocalePrefixMode, Locales, Pathnames} from './types'; +import {RoutingConfig} from './config.tsx'; +import {DomainsConfig, LocalePrefixMode, Locales, Pathnames} from './types.tsx'; export default function defineRouting< const AppLocales extends Locales, diff --git a/packages/next-intl/src/routing/index.tsx b/packages/next-intl/src/routing/index.tsx index d0832e45d..29a793866 100644 --- a/packages/next-intl/src/routing/index.tsx +++ b/packages/next-intl/src/routing/index.tsx @@ -1,2 +1,2 @@ -export type {Pathnames, LocalePrefix, DomainsConfig} from './types'; -export {default as defineRouting} from './defineRouting'; +export type {Pathnames, LocalePrefix, DomainsConfig} from './types.tsx'; +export {default as defineRouting} from './defineRouting.tsx'; diff --git a/packages/next-intl/src/routing/types.test.tsx b/packages/next-intl/src/routing/types.test.tsx index 0cd4bc418..b5caa36cc 100644 --- a/packages/next-intl/src/routing/types.test.tsx +++ b/packages/next-intl/src/routing/types.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import {describe, it} from 'vitest'; -import {DomainConfig, LocalePrefix} from './types'; +import {DomainConfig, LocalePrefix} from './types.tsx'; describe('LocalePrefix', () => { it('does not require a type param for simple values', () => { diff --git a/packages/next-intl/src/server.react-client.tsx b/packages/next-intl/src/server.react-client.tsx index 5d866c5b5..4461b2caf 100644 --- a/packages/next-intl/src/server.react-client.tsx +++ b/packages/next-intl/src/server.react-client.tsx @@ -1 +1 @@ -export * from './server/react-client/index'; +export * from './server/react-client/index.tsx'; diff --git a/packages/next-intl/src/server.react-server.tsx b/packages/next-intl/src/server.react-server.tsx index 4e88250c9..d5cd6fcab 100644 --- a/packages/next-intl/src/server.react-server.tsx +++ b/packages/next-intl/src/server.react-server.tsx @@ -1 +1 @@ -export * from './server/react-server/index'; +export * from './server/react-server/index.tsx'; diff --git a/packages/next-intl/src/server/react-client/index.test.tsx b/packages/next-intl/src/server/react-client/index.test.tsx index de0a04679..7cc58a10e 100644 --- a/packages/next-intl/src/server/react-client/index.test.tsx +++ b/packages/next-intl/src/server/react-client/index.test.tsx @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {getRequestConfig} from '../../server.react-client'; +import {getRequestConfig} from '../../server.react-client.tsx'; describe('getRequestConfig', () => { it('can be called in the outer module closure', () => { diff --git a/packages/next-intl/src/server/react-client/index.tsx b/packages/next-intl/src/server/react-client/index.tsx index 4023f975c..c53c8ba60 100644 --- a/packages/next-intl/src/server/react-client/index.tsx +++ b/packages/next-intl/src/server/react-client/index.tsx @@ -7,7 +7,7 @@ import type { getTimeZone as getTimeZone_type, setRequestLocale as setRequestLocale_type, unstable_setRequestLocale as unstable_setRequestLocale_type -} from '../react-server'; +} from '../react-server/index.tsx'; /** * Allows to import `next-intl/server` in non-RSC environments. diff --git a/packages/next-intl/src/server/react-server/RequestLocale.tsx b/packages/next-intl/src/server/react-server/RequestLocale.tsx index bfadd880c..91ea93cb5 100644 --- a/packages/next-intl/src/server/react-server/RequestLocale.tsx +++ b/packages/next-intl/src/server/react-server/RequestLocale.tsx @@ -1,7 +1,7 @@ -import {headers} from 'next/headers'; +import {headers} from 'next/headers.js'; import {cache} from 'react'; -import {HEADER_LOCALE_NAME} from '../../shared/constants'; -import {getCachedRequestLocale} from './RequestLocaleCache'; +import {HEADER_LOCALE_NAME} from '../../shared/constants.tsx'; +import {getCachedRequestLocale} from './RequestLocaleCache.tsx'; async function getHeadersImpl(): Promise { const promiseOrValue = headers(); diff --git a/packages/next-intl/src/server/react-server/RequestLocaleLegacy.tsx b/packages/next-intl/src/server/react-server/RequestLocaleLegacy.tsx index 1a267f566..174a5c7c7 100644 --- a/packages/next-intl/src/server/react-server/RequestLocaleLegacy.tsx +++ b/packages/next-intl/src/server/react-server/RequestLocaleLegacy.tsx @@ -1,8 +1,8 @@ -import {headers} from 'next/headers'; -import {notFound} from 'next/navigation'; +import {headers} from 'next/headers.js'; +import {notFound} from 'next/navigation.js'; import {cache} from 'react'; -import {HEADER_LOCALE_NAME} from '../../shared/constants'; -import {getCachedRequestLocale} from './RequestLocaleCache'; +import {HEADER_LOCALE_NAME} from '../../shared/constants.tsx'; +import {getCachedRequestLocale} from './RequestLocaleCache.tsx'; // This was originally built for Next.js <14, where `headers()` was not async. // With https://github.com/vercel/next.js/pull/68812, the API became async. diff --git a/packages/next-intl/src/server/react-server/createRequestConfig.tsx b/packages/next-intl/src/server/react-server/createRequestConfig.tsx index 2614208d8..a433c1f13 100644 --- a/packages/next-intl/src/server/react-server/createRequestConfig.tsx +++ b/packages/next-intl/src/server/react-server/createRequestConfig.tsx @@ -1,5 +1,8 @@ import getRuntimeConfig from 'next-intl/config'; -import type {GetRequestConfigParams, RequestConfig} from './getRequestConfig'; +import type { + GetRequestConfigParams, + RequestConfig +} from './getRequestConfig.tsx'; export default getRuntimeConfig as unknown as ( params: GetRequestConfigParams diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index a5fbbe326..bc9c01cba 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -1,4 +1,4 @@ -import {notFound} from 'next/navigation'; +import {notFound} from 'next/navigation.js'; import {cache} from 'react'; import { IntlConfig, @@ -6,10 +6,10 @@ import { _createIntlFormatters, initializeConfig } from 'use-intl/core'; -import {getRequestLocale} from './RequestLocale'; -import {getRequestLocale as getRequestLocaleLegacy} from './RequestLocaleLegacy'; -import createRequestConfig from './createRequestConfig'; -import {GetRequestConfigParams} from './getRequestConfig'; +import {getRequestLocale} from './RequestLocale.tsx'; +import {getRequestLocale as getRequestLocaleLegacy} from './RequestLocaleLegacy.tsx'; +import createRequestConfig from './createRequestConfig.tsx'; +import {GetRequestConfigParams} from './getRequestConfig.tsx'; // Make sure `now` is consistent across the request in case none was configured function getDefaultNowImpl() { diff --git a/packages/next-intl/src/server/react-server/getFormats.tsx b/packages/next-intl/src/server/react-server/getFormats.tsx index cda139ca5..d3900eeb9 100644 --- a/packages/next-intl/src/server/react-server/getFormats.tsx +++ b/packages/next-intl/src/server/react-server/getFormats.tsx @@ -1,5 +1,5 @@ import {cache} from 'react'; -import getConfig from './getConfig'; +import getConfig from './getConfig.tsx'; async function getFormatsCachedImpl() { const config = await getConfig(); diff --git a/packages/next-intl/src/server/react-server/getFormatter.tsx b/packages/next-intl/src/server/react-server/getFormatter.tsx index 6199ca85a..830eb4595 100644 --- a/packages/next-intl/src/server/react-server/getFormatter.tsx +++ b/packages/next-intl/src/server/react-server/getFormatter.tsx @@ -1,6 +1,6 @@ import {cache} from 'react'; import {createFormatter} from 'use-intl/core'; -import getConfig from './getConfig'; +import getConfig from './getConfig.tsx'; async function getFormatterCachedImpl(locale?: string) { const config = await getConfig(locale); diff --git a/packages/next-intl/src/server/react-server/getLocale.tsx b/packages/next-intl/src/server/react-server/getLocale.tsx index 86890f466..e847b30cf 100644 --- a/packages/next-intl/src/server/react-server/getLocale.tsx +++ b/packages/next-intl/src/server/react-server/getLocale.tsx @@ -1,5 +1,5 @@ import {cache} from 'react'; -import getConfig from './getConfig'; +import getConfig from './getConfig.tsx'; async function getLocaleCachedImpl() { const config = await getConfig(); diff --git a/packages/next-intl/src/server/react-server/getMessages.tsx b/packages/next-intl/src/server/react-server/getMessages.tsx index 72d6ac118..4129b1b93 100644 --- a/packages/next-intl/src/server/react-server/getMessages.tsx +++ b/packages/next-intl/src/server/react-server/getMessages.tsx @@ -1,6 +1,6 @@ import {cache} from 'react'; import type {AbstractIntlMessages} from 'use-intl'; -import getConfig from './getConfig'; +import getConfig from './getConfig.tsx'; export function getMessagesFromConfig( config: Awaited> diff --git a/packages/next-intl/src/server/react-server/getNow.tsx b/packages/next-intl/src/server/react-server/getNow.tsx index 40373b1fe..d081c14f0 100644 --- a/packages/next-intl/src/server/react-server/getNow.tsx +++ b/packages/next-intl/src/server/react-server/getNow.tsx @@ -1,5 +1,5 @@ import {cache} from 'react'; -import getConfig from './getConfig'; +import getConfig from './getConfig.tsx'; async function getNowCachedImpl(locale?: string) { const config = await getConfig(locale); diff --git a/packages/next-intl/src/server/react-server/getTimeZone.tsx b/packages/next-intl/src/server/react-server/getTimeZone.tsx index a897137e0..d6a707f0e 100644 --- a/packages/next-intl/src/server/react-server/getTimeZone.tsx +++ b/packages/next-intl/src/server/react-server/getTimeZone.tsx @@ -1,5 +1,5 @@ import {cache} from 'react'; -import getConfig from './getConfig'; +import getConfig from './getConfig.tsx'; async function getTimeZoneCachedImpl(locale?: string) { const config = await getConfig(locale); diff --git a/packages/next-intl/src/server/react-server/getTranslations.tsx b/packages/next-intl/src/server/react-server/getTranslations.tsx index 81deeec13..3119c3ddf 100644 --- a/packages/next-intl/src/server/react-server/getTranslations.tsx +++ b/packages/next-intl/src/server/react-server/getTranslations.tsx @@ -10,7 +10,7 @@ import { TranslationValues, createTranslator } from 'use-intl/core'; -import getConfig from './getConfig'; +import getConfig from './getConfig.tsx'; // Maintainer note: `getTranslations` has two different call signatures. // We need to define these with function overloads, otherwise TypeScript diff --git a/packages/next-intl/src/server/react-server/index.test.tsx b/packages/next-intl/src/server/react-server/index.test.tsx index 9edb4d554..2f0d92f67 100644 --- a/packages/next-intl/src/server/react-server/index.test.tsx +++ b/packages/next-intl/src/server/react-server/index.test.tsx @@ -1,14 +1,14 @@ // @vitest-environment edge-runtime import {describe, expect, it, vi} from 'vitest'; -import {HEADER_LOCALE_NAME} from '../../shared/constants'; +import {HEADER_LOCALE_NAME} from '../../shared/constants.tsx'; import { getFormatter, getMessages, getNow, getTimeZone, getTranslations -} from '.'; +} from './index.tsx'; vi.mock('next-intl/config', () => ({ default: async () => @@ -28,7 +28,7 @@ vi.mock('next-intl/config', () => ({ }) })); -vi.mock('next/headers', () => ({ +vi.mock('next/headers.js', () => ({ headers: () => ({ get(name: string) { if (name === HEADER_LOCALE_NAME) { diff --git a/packages/next-intl/src/server/react-server/index.tsx b/packages/next-intl/src/server/react-server/index.tsx index 258707b90..cdad41cc5 100644 --- a/packages/next-intl/src/server/react-server/index.tsx +++ b/packages/next-intl/src/server/react-server/index.tsx @@ -2,17 +2,17 @@ * Server-only APIs available via `next-intl/server`. */ -export {default as getRequestConfig} from './getRequestConfig'; -export {default as getFormatter} from './getFormatter'; -export {default as getNow} from './getNow'; -export {default as getTimeZone} from './getTimeZone'; -export {default as getTranslations} from './getTranslations'; -export {default as getMessages} from './getMessages'; -export {default as getLocale} from './getLocale'; +export {default as getRequestConfig} from './getRequestConfig.tsx'; +export {default as getFormatter} from './getFormatter.tsx'; +export {default as getNow} from './getNow.tsx'; +export {default as getTimeZone} from './getTimeZone.tsx'; +export {default as getTranslations} from './getTranslations.tsx'; +export {default as getMessages} from './getMessages.tsx'; +export {default as getLocale} from './getLocale.tsx'; -export {setCachedRequestLocale as setRequestLocale} from './RequestLocaleCache'; +export {setCachedRequestLocale as setRequestLocale} from './RequestLocaleCache.tsx'; export { /** @deprecated Deprecated in favor of `setRequestLocale`. */ setCachedRequestLocale as unstable_setRequestLocale -} from './RequestLocaleCache'; +} from './RequestLocaleCache.tsx'; diff --git a/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx b/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx index f24b0c579..02de09c68 100644 --- a/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx +++ b/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx @@ -1,7 +1,9 @@ import {render, screen} from '@testing-library/react'; -import React from 'react'; import {it} from 'vitest'; -import {NextIntlClientProvider, useTranslations} from '../index.react-client'; +import { + NextIntlClientProvider, + useTranslations +} from '../index.react-client.tsx'; it('can use messages from the provider', () => { function Component() { diff --git a/packages/next-intl/src/shared/NextIntlClientProvider.tsx b/packages/next-intl/src/shared/NextIntlClientProvider.tsx index 2b0385ae4..4dead1359 100644 --- a/packages/next-intl/src/shared/NextIntlClientProvider.tsx +++ b/packages/next-intl/src/shared/NextIntlClientProvider.tsx @@ -1,8 +1,7 @@ 'use client'; -import React, {ComponentProps} from 'react'; -// Workaround for some bundle splitting until we have ESM -import {IntlProvider} from 'use-intl/_IntlProvider'; +import {ComponentProps} from 'react'; +import {IntlProvider} from 'use-intl/react'; type Props = Omit, 'locale'> & { /** This is automatically received when being rendered from a Server Component. In all other cases, e.g. when rendered from a Client Component, a unit test or with the Pages Router, you can pass this prop explicitly. */ diff --git a/packages/next-intl/src/shared/use.tsx b/packages/next-intl/src/shared/use.tsx new file mode 100644 index 000000000..e0f4be59d --- /dev/null +++ b/packages/next-intl/src/shared/use.tsx @@ -0,0 +1,11 @@ +import * as react from 'react'; + +// @ts-expect-error -- Ooof, Next.js doesn't make this easy. +// `use` is only available in React 19 canary, but we can +// use it in Next.js already as Next.js "vendors" a fixed +// version of React. However, if we'd simply put `use` in +// ESM code, then the build doesn't work since React does +// not export `use` officially. Therefore, we have to use +// something that is not statically analyzable. Once React +// 19 is out, we can remove this in the next major version. +export default react['use'.trim()] as typeof react.use; diff --git a/packages/next-intl/src/shared/utils.test.tsx b/packages/next-intl/src/shared/utils.test.tsx index 8f39a1ddc..b16504cb6 100644 --- a/packages/next-intl/src/shared/utils.test.tsx +++ b/packages/next-intl/src/shared/utils.test.tsx @@ -5,7 +5,7 @@ import { matchesPathname, prefixPathname, unprefixPathname -} from './utils'; +} from './utils.tsx'; describe('prefixPathname', () => { it("doesn't add trailing slashes for the root", () => { diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index a99ceed9e..68a4aa841 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -1,13 +1,12 @@ import {UrlObject} from 'url'; -import NextLink from 'next/link'; -import {ComponentProps} from 'react'; +import {LinkProps} from 'next/link.js'; import { LocalePrefixConfigVerbose, LocalePrefixMode, Locales -} from '../routing/types'; +} from '../routing/types.tsx'; -type Href = ComponentProps['href']; +type Href = LinkProps['href']; function isRelativeHref(href: Href) { const pathname = typeof href === 'object' ? href.pathname : href; diff --git a/packages/next-intl/tsconfig.build.json b/packages/next-intl/tsconfig.build.json new file mode 100644 index 000000000..8e330c446 --- /dev/null +++ b/packages/next-intl/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.tsx", "test", "__mocks__"], + "compilerOptions": { + "rootDir": "src", + "noEmit": false, + "outDir": "dist/types", + "emitDeclarationOnly": true + } +} diff --git a/packages/next-intl/tsconfig.json b/packages/next-intl/tsconfig.json index a8c3d4ed6..f32652031 100644 --- a/packages/next-intl/tsconfig.json +++ b/packages/next-intl/tsconfig.json @@ -2,20 +2,15 @@ "extends": "eslint-config-molindo/tsconfig.json", "include": ["src", "test", "__mocks__", "types", "next-env.d.ts"], "compilerOptions": { - "moduleDetection": "force", - "isolatedModules": true, - "module": "esnext", + "allowImportingTsExtensions": true, // For ESM "lib": ["dom", "esnext"], "target": "ES2020", - "importHelpers": false, "declaration": true, - "sourceMap": true, "rootDir": ".", - "moduleResolution": "Bundler", - "jsx": "react", - "esModuleInterop": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", "skipLibCheck": true, - "noEmit": false, - "outDir": "dist" + "noEmit": true } } diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts index 0372624fd..89837aee6 100644 --- a/packages/use-intl/.size-limit.ts +++ b/packages/use-intl/.size-limit.ts @@ -2,22 +2,23 @@ import type {SizeLimitConfig} from 'size-limit'; const config: SizeLimitConfig = [ { - name: "import * from 'use-intl' (ESM)", + name: "import * from 'use-intl' (production)", import: '*', - path: 'dist/esm/index.js', - limit: '14.195 kB' + path: 'dist/esm/production/index.js', + limit: '13.075 kB' }, { - name: "import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from 'use-intl' (ESM)", - path: 'dist/esm/index.js', + name: "import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from 'use-intl' (production)", + path: 'dist/esm/production/index.js', import: '{IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter}', - limit: '2.935 kB' + limit: '2.005 kB' }, { - name: "import * from 'use-intl' (CJS)", - path: 'dist/production/index.js', - limit: '13.695 kB' + name: "import * from 'use-intl' (development)", + import: '*', + path: 'dist/esm/development/index.js', + limit: '13.955 kB' } ]; diff --git a/packages/use-intl/_IntlProvider.d.ts b/packages/use-intl/_IntlProvider.d.ts deleted file mode 100644 index 638757cd7..000000000 --- a/packages/use-intl/_IntlProvider.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './dist/types/src/_IntlProvider'; diff --git a/packages/use-intl/_useLocale.d.ts b/packages/use-intl/_useLocale.d.ts deleted file mode 100644 index 6eefcd371..000000000 --- a/packages/use-intl/_useLocale.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './dist/types/src/_useLocale'; diff --git a/packages/use-intl/core.d.ts b/packages/use-intl/core.d.ts index 5df015235..b65fd68a9 100644 --- a/packages/use-intl/core.d.ts +++ b/packages/use-intl/core.d.ts @@ -1 +1,2 @@ -export * from './dist/types/src/core'; +// Needed for projects with `moduleResolution: 'node'` +export * from './dist/types/core.d.ts'; diff --git a/packages/use-intl/package.json b/packages/use-intl/package.json index efc410818..149803083 100644 --- a/packages/use-intl/package.json +++ b/packages/use-intl/package.json @@ -15,42 +15,35 @@ "test": "TZ=Europe/Berlin vitest", "lint": "pnpm run lint:source && pnpm run lint:package", "lint:source": "eslint src test && tsc --noEmit && pnpm run lint:prettier", - "lint:package": "publint && attw --pack", + "lint:package": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm", "lint:prettier": "prettier src --check", "prepublishOnly": "turbo build", "size": "size-limit" }, - "main": "./dist/index.js", - "module": "dist/esm/index.js", - "typings": "./dist/types/src/index.d.ts", + "type": "module", + "main": "./dist/esm/production/index.js", + "typings": "./dist/types/index.d.ts", "exports": { ".": { - "types": "./dist/types/src/index.d.ts", - "default": "./dist/index.js" + "types": "./dist/types/index.d.ts", + "development": "./dist/esm/development/index.js", + "default": "./dist/esm/production/index.js" }, "./core": { - "types": "./core.d.ts", - "default": "./dist/core.js" + "types": "./dist/types/core.d.ts", + "development": "./dist/esm/development/core.js", + "default": "./dist/esm/production/core.js" }, "./react": { - "types": "./react.d.ts", - "default": "./dist/react.js" - }, - "./_useLocale": { - "types": "./_useLocale.d.ts", - "default": "./dist/_useLocale.js" - }, - "./_IntlProvider": { - "types": "./_IntlProvider.d.ts", - "default": "./dist/_IntlProvider.js" + "types": "./dist/types/react.d.ts", + "development": "./dist/esm/development/react.js", + "default": "./dist/esm/production/react.js" } }, "files": [ "dist", "core.d.ts", - "react.d.ts", - "_useLocale.d.ts", - "_IntlProvider.d.ts" + "react.d.ts" ], "keywords": [ "react", @@ -68,11 +61,11 @@ "intl-messageformat": "^10.5.14" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^17.0.0 || ^18.0.0" }, "devDependencies": { - "@arethetypeswrong/cli": "^0.15.3", - "@size-limit/preset-big-lib": "^11.1.4", + "@arethetypeswrong/cli": "^0.16.4", + "@size-limit/preset-small-lib": "^11.1.4", "@testing-library/react": "^16.0.0", "@types/node": "^20.14.5", "@types/react": "^18.3.3", @@ -88,6 +81,7 @@ "rollup": "^4.18.0", "size-limit": "^11.1.4", "tinyspy": "^3.0.0", + "tools": "workspace:^", "typescript": "^5.5.3", "vitest": "^2.0.2" }, diff --git a/packages/use-intl/react.d.ts b/packages/use-intl/react.d.ts index db798174f..c861b28fb 100644 --- a/packages/use-intl/react.d.ts +++ b/packages/use-intl/react.d.ts @@ -1 +1,2 @@ -export * from './dist/types/src/react'; +// Needed for projects with `moduleResolution: 'node'` +export * from './dist/types/react.d.ts'; diff --git a/packages/use-intl/rollup.config.js b/packages/use-intl/rollup.config.js new file mode 100644 index 000000000..897a780d3 --- /dev/null +++ b/packages/use-intl/rollup.config.js @@ -0,0 +1,15 @@ +import {getBuildConfig} from 'tools'; +import pkg from './package.json' with {type: 'json'}; + +export default getBuildConfig({ + input: { + index: 'src/index.tsx', + core: 'src/core.tsx', + react: 'src/react.tsx' + }, + external: [ + ...Object.keys(pkg.dependencies), + ...Object.keys(pkg.peerDependencies), + 'react/jsx-runtime' + ] +}); diff --git a/packages/use-intl/rollup.config.mjs b/packages/use-intl/rollup.config.mjs deleted file mode 100644 index 0413155db..000000000 --- a/packages/use-intl/rollup.config.mjs +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-env node */ -import getBuildConfig from '../../scripts/getBuildConfig.mjs'; - -const input = { - index: 'src/index.tsx', - core: 'src/core.tsx', - react: 'src/react.tsx', - _useLocale: 'src/_useLocale.tsx', - _IntlProvider: 'src/_IntlProvider.tsx' -}; - -export default [ - getBuildConfig({input, env: 'development'}), - getBuildConfig({ - input, - env: 'esm', - output: {format: 'es'} - }), - getBuildConfig({input, env: 'production'}) -]; diff --git a/packages/use-intl/src/_IntlProvider.tsx b/packages/use-intl/src/_IntlProvider.tsx deleted file mode 100644 index c02594cc2..000000000 --- a/packages/use-intl/src/_IntlProvider.tsx +++ /dev/null @@ -1 +0,0 @@ -export {default as IntlProvider} from './react/IntlProvider'; diff --git a/packages/use-intl/src/_useLocale.tsx b/packages/use-intl/src/_useLocale.tsx deleted file mode 100644 index 162f5d506..000000000 --- a/packages/use-intl/src/_useLocale.tsx +++ /dev/null @@ -1 +0,0 @@ -export {default as useLocale} from './react/useLocale'; diff --git a/packages/use-intl/src/core.tsx b/packages/use-intl/src/core.tsx index 65c514e92..4a80db990 100644 --- a/packages/use-intl/src/core.tsx +++ b/packages/use-intl/src/core.tsx @@ -1 +1 @@ -export * from './core/index'; +export * from './core/index.tsx'; diff --git a/packages/use-intl/src/core/DateTimeFormatOptions.tsx b/packages/use-intl/src/core/DateTimeFormatOptions.tsx index 5d279bea8..1452b535a 100644 --- a/packages/use-intl/src/core/DateTimeFormatOptions.tsx +++ b/packages/use-intl/src/core/DateTimeFormatOptions.tsx @@ -1,6 +1,6 @@ // https://github.com/microsoft/TypeScript/issues/35865 -import TimeZone from './TimeZone'; +import TimeZone from './TimeZone.tsx'; /** * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat diff --git a/packages/use-intl/src/core/Formats.tsx b/packages/use-intl/src/core/Formats.tsx index 108da3103..f4fcf2f3d 100644 --- a/packages/use-intl/src/core/Formats.tsx +++ b/packages/use-intl/src/core/Formats.tsx @@ -1,5 +1,5 @@ -import DateTimeFormatOptions from './DateTimeFormatOptions'; -import NumberFormatOptions from './NumberFormatOptions'; +import DateTimeFormatOptions from './DateTimeFormatOptions.tsx'; +import NumberFormatOptions from './NumberFormatOptions.tsx'; type Formats = { number?: Record; diff --git a/packages/use-intl/src/core/IntlConfig.tsx b/packages/use-intl/src/core/IntlConfig.tsx index 487beff34..d827cf3c2 100644 --- a/packages/use-intl/src/core/IntlConfig.tsx +++ b/packages/use-intl/src/core/IntlConfig.tsx @@ -1,8 +1,8 @@ -import type AbstractIntlMessages from './AbstractIntlMessages'; -import type Formats from './Formats'; -import type IntlError from './IntlError'; -import type TimeZone from './TimeZone'; -import type {RichTranslationValues} from './TranslationValues'; +import type AbstractIntlMessages from './AbstractIntlMessages.tsx'; +import type Formats from './Formats.tsx'; +import type IntlError from './IntlError.tsx'; +import type TimeZone from './TimeZone.tsx'; +import type {RichTranslationValues} from './TranslationValues.tsx'; /** * Should be used for entry points that configure the library. diff --git a/packages/use-intl/src/core/IntlError.tsx b/packages/use-intl/src/core/IntlError.tsx index cfce8f64b..a04b3c584 100644 --- a/packages/use-intl/src/core/IntlError.tsx +++ b/packages/use-intl/src/core/IntlError.tsx @@ -1,4 +1,4 @@ -export enum IntlErrorCode { +export const enum IntlErrorCode { MISSING_MESSAGE = 'MISSING_MESSAGE', MISSING_FORMAT = 'MISSING_FORMAT', ENVIRONMENT_FALLBACK = 'ENVIRONMENT_FALLBACK', diff --git a/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx b/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx index 90e22f6f0..27cc8e96e 100644 --- a/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx +++ b/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx @@ -1,7 +1,10 @@ -import IntlMessageFormat, {Formats as IntlFormats} from 'intl-messageformat'; -import DateTimeFormatOptions from './DateTimeFormatOptions'; -import Formats from './Formats'; -import TimeZone from './TimeZone'; +import { + type Formats as IntlFormats, + IntlMessageFormat +} from 'intl-messageformat'; +import DateTimeFormatOptions from './DateTimeFormatOptions.tsx'; +import Formats from './Formats.tsx'; +import TimeZone from './TimeZone.tsx'; function setTimeZoneInFormats( formats: Record | undefined, diff --git a/packages/use-intl/src/core/createBaseTranslator.tsx b/packages/use-intl/src/core/createBaseTranslator.tsx index 41736e4f3..f878bb5ee 100644 --- a/packages/use-intl/src/core/createBaseTranslator.tsx +++ b/packages/use-intl/src/core/createBaseTranslator.tsx @@ -1,26 +1,26 @@ -import IntlMessageFormat from 'intl-messageformat'; +import {IntlMessageFormat} from 'intl-messageformat'; import {ReactNode, cloneElement, isValidElement} from 'react'; -import AbstractIntlMessages from './AbstractIntlMessages'; -import Formats from './Formats'; -import {InitializedIntlConfig} from './IntlConfig'; -import IntlError, {IntlErrorCode} from './IntlError'; +import AbstractIntlMessages from './AbstractIntlMessages.tsx'; +import Formats from './Formats.tsx'; +import {InitializedIntlConfig} from './IntlConfig.tsx'; +import IntlError, {IntlErrorCode} from './IntlError.tsx'; import TranslationValues, { MarkupTranslationValues, RichTranslationValues -} from './TranslationValues'; -import convertFormatsToIntlMessageFormat from './convertFormatsToIntlMessageFormat'; -import {defaultGetMessageFallback, defaultOnError} from './defaults'; +} from './TranslationValues.tsx'; +import convertFormatsToIntlMessageFormat from './convertFormatsToIntlMessageFormat.tsx'; +import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; import { Formatters, IntlCache, IntlFormatters, MessageFormatter, memoFn -} from './formatters'; -import joinPath from './joinPath'; -import MessageKeys from './utils/MessageKeys'; -import NestedKeyOf from './utils/NestedKeyOf'; -import NestedValueOf from './utils/NestedValueOf'; +} from './formatters.tsx'; +import joinPath from './joinPath.tsx'; +import MessageKeys from './utils/MessageKeys.tsx'; +import NestedKeyOf from './utils/NestedKeyOf.tsx'; +import NestedValueOf from './utils/NestedValueOf.tsx'; // Placed here for improved tree shaking. Somehow when this is placed in // `formatters.tsx`, then it can't be shaken off from `next-intl`. diff --git a/packages/use-intl/src/core/createFormatter.test.tsx b/packages/use-intl/src/core/createFormatter.test.tsx index 4bcfb696c..cd7014cc9 100644 --- a/packages/use-intl/src/core/createFormatter.test.tsx +++ b/packages/use-intl/src/core/createFormatter.test.tsx @@ -1,6 +1,6 @@ import {parseISO} from 'date-fns'; import {describe, expect, it} from 'vitest'; -import createFormatter from './createFormatter'; +import createFormatter from './createFormatter.tsx'; describe('dateTime', () => { it('formats a date and time', () => { diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index f5745f566..8549f315d 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -1,17 +1,17 @@ import {ReactElement} from 'react'; -import DateTimeFormatOptions from './DateTimeFormatOptions'; -import Formats from './Formats'; -import IntlError, {IntlErrorCode} from './IntlError'; -import NumberFormatOptions from './NumberFormatOptions'; -import RelativeTimeFormatOptions from './RelativeTimeFormatOptions'; -import TimeZone from './TimeZone'; -import {defaultOnError} from './defaults'; +import DateTimeFormatOptions from './DateTimeFormatOptions.tsx'; +import Formats from './Formats.tsx'; +import IntlError, {IntlErrorCode} from './IntlError.tsx'; +import NumberFormatOptions from './NumberFormatOptions.tsx'; +import RelativeTimeFormatOptions from './RelativeTimeFormatOptions.tsx'; +import TimeZone from './TimeZone.tsx'; +import {defaultOnError} from './defaults.tsx'; import { Formatters, IntlCache, createCache, createIntlFormatters -} from './formatters'; +} from './formatters.tsx'; const SECOND = 1; const MINUTE = SECOND * 60; diff --git a/packages/use-intl/src/core/createTranslator.test.tsx b/packages/use-intl/src/core/createTranslator.test.tsx index 159715758..ff9ed2923 100644 --- a/packages/use-intl/src/core/createTranslator.test.tsx +++ b/packages/use-intl/src/core/createTranslator.test.tsx @@ -1,8 +1,8 @@ -import React, {isValidElement} from 'react'; +import {isValidElement} from 'react'; import {renderToString} from 'react-dom/server'; import {describe, expect, it, vi} from 'vitest'; -import IntlError, {IntlErrorCode} from './IntlError'; -import createTranslator from './createTranslator'; +import IntlError, {IntlErrorCode} from './IntlError.tsx'; +import createTranslator from './createTranslator.tsx'; const messages = { Home: { diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index 92a25e9da..ec4e66c91 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -1,22 +1,22 @@ import {ReactNode} from 'react'; -import Formats from './Formats'; -import IntlConfig from './IntlConfig'; +import Formats from './Formats.tsx'; +import IntlConfig from './IntlConfig.tsx'; import TranslationValues, { MarkupTranslationValues, RichTranslationValues -} from './TranslationValues'; -import createTranslatorImpl from './createTranslatorImpl'; -import {defaultGetMessageFallback, defaultOnError} from './defaults'; +} from './TranslationValues.tsx'; +import createTranslatorImpl from './createTranslatorImpl.tsx'; +import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; import { Formatters, IntlCache, createCache, createIntlFormatters -} from './formatters'; -import MessageKeys from './utils/MessageKeys'; -import NamespaceKeys from './utils/NamespaceKeys'; -import NestedKeyOf from './utils/NestedKeyOf'; -import NestedValueOf from './utils/NestedValueOf'; +} from './formatters.tsx'; +import MessageKeys from './utils/MessageKeys.tsx'; +import NamespaceKeys from './utils/NamespaceKeys.tsx'; +import NestedKeyOf from './utils/NestedKeyOf.tsx'; +import NestedValueOf from './utils/NestedValueOf.tsx'; /** * Translates messages from the given namespace by using the ICU syntax. diff --git a/packages/use-intl/src/core/createTranslatorImpl.tsx b/packages/use-intl/src/core/createTranslatorImpl.tsx index 64dd9f2dd..23c9a8d50 100644 --- a/packages/use-intl/src/core/createTranslatorImpl.tsx +++ b/packages/use-intl/src/core/createTranslatorImpl.tsx @@ -1,9 +1,9 @@ -import AbstractIntlMessages from './AbstractIntlMessages'; -import {InitializedIntlConfig} from './IntlConfig'; -import createBaseTranslator from './createBaseTranslator'; -import {Formatters, IntlCache} from './formatters'; -import resolveNamespace from './resolveNamespace'; -import NestedKeyOf from './utils/NestedKeyOf'; +import AbstractIntlMessages from './AbstractIntlMessages.tsx'; +import {InitializedIntlConfig} from './IntlConfig.tsx'; +import createBaseTranslator from './createBaseTranslator.tsx'; +import {Formatters, IntlCache} from './formatters.tsx'; +import resolveNamespace from './resolveNamespace.tsx'; +import NestedKeyOf from './utils/NestedKeyOf.tsx'; export type CreateTranslatorImplProps = Omit< InitializedIntlConfig, diff --git a/packages/use-intl/src/core/defaults.tsx b/packages/use-intl/src/core/defaults.tsx index f957e40f6..df8caa020 100644 --- a/packages/use-intl/src/core/defaults.tsx +++ b/packages/use-intl/src/core/defaults.tsx @@ -1,5 +1,5 @@ -import IntlError from './IntlError'; -import joinPath from './joinPath'; +import IntlError from './IntlError.tsx'; +import joinPath from './joinPath.tsx'; /** * Contains defaults that are used for all entry points into the core. diff --git a/packages/use-intl/src/core/formatters.tsx b/packages/use-intl/src/core/formatters.tsx index 449809c64..2072d612c 100644 --- a/packages/use-intl/src/core/formatters.tsx +++ b/packages/use-intl/src/core/formatters.tsx @@ -1,5 +1,5 @@ -import {Cache, memoize, strategies} from '@formatjs/fast-memoize'; -import type IntlMessageFormat from 'intl-messageformat'; +import {type Cache, memoize, strategies} from '@formatjs/fast-memoize'; +import type {IntlMessageFormat} from 'intl-messageformat'; export type IntlCache = { dateTime: Record; diff --git a/packages/use-intl/src/core/index.tsx b/packages/use-intl/src/core/index.tsx index cfc3cc0e4..51a40f8a4 100644 --- a/packages/use-intl/src/core/index.tsx +++ b/packages/use-intl/src/core/index.tsx @@ -1,20 +1,20 @@ -export type {default as AbstractIntlMessages} from './AbstractIntlMessages'; +export type {default as AbstractIntlMessages} from './AbstractIntlMessages.tsx'; export type { default as TranslationValues, RichTranslationValues, MarkupTranslationValues -} from './TranslationValues'; -export type {default as Formats} from './Formats'; -export type {default as IntlConfig} from './IntlConfig'; -export type {default as DateTimeFormatOptions} from './DateTimeFormatOptions'; -export type {default as NumberFormatOptions} from './NumberFormatOptions'; -export {default as IntlError, IntlErrorCode} from './IntlError'; -export {default as createTranslator} from './createTranslator'; -export {default as createFormatter} from './createFormatter'; -export {default as initializeConfig} from './initializeConfig'; -export type {default as MessageKeys} from './utils/MessageKeys'; -export type {default as NamespaceKeys} from './utils/NamespaceKeys'; -export type {default as NestedKeyOf} from './utils/NestedKeyOf'; -export type {default as NestedValueOf} from './utils/NestedValueOf'; -export {createIntlFormatters as _createIntlFormatters} from './formatters'; -export {createCache as _createCache} from './formatters'; +} from './TranslationValues.tsx'; +export type {default as Formats} from './Formats.tsx'; +export type {default as IntlConfig} from './IntlConfig.tsx'; +export type {default as DateTimeFormatOptions} from './DateTimeFormatOptions.tsx'; +export type {default as NumberFormatOptions} from './NumberFormatOptions.tsx'; +export {default as IntlError, IntlErrorCode} from './IntlError.tsx'; +export {default as createTranslator} from './createTranslator.tsx'; +export {default as createFormatter} from './createFormatter.tsx'; +export {default as initializeConfig} from './initializeConfig.tsx'; +export type {default as MessageKeys} from './utils/MessageKeys.tsx'; +export type {default as NamespaceKeys} from './utils/NamespaceKeys.tsx'; +export type {default as NestedKeyOf} from './utils/NestedKeyOf.tsx'; +export type {default as NestedValueOf} from './utils/NestedValueOf.tsx'; +export {createIntlFormatters as _createIntlFormatters} from './formatters.tsx'; +export {createCache as _createCache} from './formatters.tsx'; diff --git a/packages/use-intl/src/core/initializeConfig.tsx b/packages/use-intl/src/core/initializeConfig.tsx index a784b2ce2..35a0679ff 100644 --- a/packages/use-intl/src/core/initializeConfig.tsx +++ b/packages/use-intl/src/core/initializeConfig.tsx @@ -1,6 +1,6 @@ -import IntlConfig from './IntlConfig'; -import {defaultGetMessageFallback, defaultOnError} from './defaults'; -import validateMessages from './validateMessages'; +import IntlConfig from './IntlConfig.tsx'; +import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; +import validateMessages from './validateMessages.tsx'; /** * Enhances the incoming props with defaults. diff --git a/packages/use-intl/src/core/utils/MessageKeys.tsx b/packages/use-intl/src/core/utils/MessageKeys.tsx index f2190a87d..af713473a 100644 --- a/packages/use-intl/src/core/utils/MessageKeys.tsx +++ b/packages/use-intl/src/core/utils/MessageKeys.tsx @@ -1,4 +1,4 @@ -import NestedValueOf from './NestedValueOf'; +import NestedValueOf from './NestedValueOf.tsx'; type MessageKeys = { [Property in Keys]: NestedValueOf extends string diff --git a/packages/use-intl/src/core/utils/NamespaceKeys.tsx b/packages/use-intl/src/core/utils/NamespaceKeys.tsx index e2ea489e1..d0e69f840 100644 --- a/packages/use-intl/src/core/utils/NamespaceKeys.tsx +++ b/packages/use-intl/src/core/utils/NamespaceKeys.tsx @@ -1,4 +1,4 @@ -import NestedValueOf from './NestedValueOf'; +import NestedValueOf from './NestedValueOf.tsx'; type NamespaceKeys = { [Property in Keys]: NestedValueOf extends string diff --git a/packages/use-intl/src/core/validateMessages.tsx b/packages/use-intl/src/core/validateMessages.tsx index ac3b651f2..5131a67d2 100644 --- a/packages/use-intl/src/core/validateMessages.tsx +++ b/packages/use-intl/src/core/validateMessages.tsx @@ -1,6 +1,6 @@ -import AbstractIntlMessages from './AbstractIntlMessages'; -import IntlError, {IntlErrorCode} from './IntlError'; -import joinPath from './joinPath'; +import AbstractIntlMessages from './AbstractIntlMessages.tsx'; +import IntlError, {IntlErrorCode} from './IntlError.tsx'; +import joinPath from './joinPath.tsx'; function validateMessagesSegment( messages: AbstractIntlMessages, diff --git a/packages/use-intl/src/index.tsx b/packages/use-intl/src/index.tsx index 78a29b8b5..a7f352cea 100644 --- a/packages/use-intl/src/index.tsx +++ b/packages/use-intl/src/index.tsx @@ -1,2 +1,2 @@ -export * from './core'; -export * from './react'; +export * from './core.tsx'; +export * from './react.tsx'; diff --git a/packages/use-intl/src/react.tsx b/packages/use-intl/src/react.tsx index f97dcae79..858a6b562 100644 --- a/packages/use-intl/src/react.tsx +++ b/packages/use-intl/src/react.tsx @@ -1 +1 @@ -export * from './react/index'; +export * from './react/index.tsx'; diff --git a/packages/use-intl/src/react/IntlContext.tsx b/packages/use-intl/src/react/IntlContext.tsx index 3606b83d6..1ba115907 100644 --- a/packages/use-intl/src/react/IntlContext.tsx +++ b/packages/use-intl/src/react/IntlContext.tsx @@ -1,6 +1,6 @@ import {createContext} from 'react'; -import type {InitializedIntlConfig} from '../core/IntlConfig'; -import type {Formatters, IntlCache} from '../core/formatters'; +import type {InitializedIntlConfig} from '../core/IntlConfig.tsx'; +import type {Formatters, IntlCache} from '../core/formatters.tsx'; export type IntlContextValue = InitializedIntlConfig & { formatters: Formatters; diff --git a/packages/use-intl/src/react/IntlProvider.test.tsx b/packages/use-intl/src/react/IntlProvider.test.tsx index 424af1138..fccc57726 100644 --- a/packages/use-intl/src/react/IntlProvider.test.tsx +++ b/packages/use-intl/src/react/IntlProvider.test.tsx @@ -1,9 +1,9 @@ import {fireEvent, render, screen} from '@testing-library/react'; -import React, {memo, useState} from 'react'; +import {memo, useState} from 'react'; import {expect, it, vi} from 'vitest'; -import IntlProvider from './IntlProvider'; -import useNow from './useNow'; -import useTranslations from './useTranslations'; +import IntlProvider from './IntlProvider.tsx'; +import useNow from './useNow.tsx'; +import useTranslations from './useTranslations.tsx'; it("doesn't re-render context consumers unnecessarily", () => { const messages = {StaticText: {hello: 'Hello!'}}; diff --git a/packages/use-intl/src/react/IntlProvider.tsx b/packages/use-intl/src/react/IntlProvider.tsx index 214b5db53..40ef9c374 100644 --- a/packages/use-intl/src/react/IntlProvider.tsx +++ b/packages/use-intl/src/react/IntlProvider.tsx @@ -1,12 +1,12 @@ -import React, {ReactNode, useContext, useMemo} from 'react'; -import IntlConfig from '../core/IntlConfig'; +import {ReactNode, useContext, useMemo} from 'react'; +import IntlConfig from '../core/IntlConfig.tsx'; import { Formatters, createCache, createIntlFormatters -} from '../core/formatters'; -import initializeConfig from '../core/initializeConfig'; -import IntlContext from './IntlContext'; +} from '../core/formatters.tsx'; +import initializeConfig from '../core/initializeConfig.tsx'; +import IntlContext from './IntlContext.tsx'; type Props = IntlConfig & { children: ReactNode; diff --git a/packages/use-intl/src/react/index.test.tsx b/packages/use-intl/src/react/index.test.tsx index 164018819..f66f596e0 100644 --- a/packages/use-intl/src/react/index.test.tsx +++ b/packages/use-intl/src/react/index.test.tsx @@ -1,11 +1,10 @@ import {render, screen} from '@testing-library/react'; import {parseISO} from 'date-fns'; -import React from 'react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import IntlProvider from './IntlProvider'; -import useFormatter from './useFormatter'; -import useNow from './useNow'; -import useTranslations from './useTranslations'; +import IntlProvider from './IntlProvider.tsx'; +import useFormatter from './useFormatter.tsx'; +import useNow from './useNow.tsx'; +import useTranslations from './useTranslations.tsx'; describe('performance', () => { beforeEach(() => { diff --git a/packages/use-intl/src/react/index.tsx b/packages/use-intl/src/react/index.tsx index 1747d2829..30a1e1a86 100644 --- a/packages/use-intl/src/react/index.tsx +++ b/packages/use-intl/src/react/index.tsx @@ -1,7 +1,7 @@ -export {default as IntlProvider} from './IntlProvider'; -export {default as useTranslations} from './useTranslations'; -export {default as useLocale} from './useLocale'; -export {default as useNow} from './useNow'; -export {default as useTimeZone} from './useTimeZone'; -export {default as useMessages} from './useMessages'; -export {default as useFormatter} from './useFormatter'; +export {default as IntlProvider} from './IntlProvider.tsx'; +export {default as useTranslations} from './useTranslations.tsx'; +export {default as useLocale} from './useLocale.tsx'; +export {default as useNow} from './useNow.tsx'; +export {default as useTimeZone} from './useTimeZone.tsx'; +export {default as useMessages} from './useMessages.tsx'; +export {default as useFormatter} from './useFormatter.tsx'; diff --git a/packages/use-intl/src/react/useFormatter.test.tsx b/packages/use-intl/src/react/useFormatter.test.tsx index 1ba1cc486..88707ba4d 100644 --- a/packages/use-intl/src/react/useFormatter.test.tsx +++ b/packages/use-intl/src/react/useFormatter.test.tsx @@ -1,11 +1,11 @@ import {render, screen} from '@testing-library/react'; import {parseISO} from 'date-fns'; -import React, {ComponentProps, ReactElement, ReactNode} from 'react'; +import {ComponentProps, ReactElement, ReactNode} from 'react'; import {SpyImpl, spyOn} from 'tinyspy'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {IntlError, IntlErrorCode} from '../core'; -import IntlProvider from './IntlProvider'; -import useFormatter from './useFormatter'; +import {IntlError, IntlErrorCode} from '../core.tsx'; +import IntlProvider from './IntlProvider.tsx'; +import useFormatter from './useFormatter.tsx'; function MockProvider( props: Partial> & {children: ReactNode} diff --git a/packages/use-intl/src/react/useFormatter.tsx b/packages/use-intl/src/react/useFormatter.tsx index abfa58e8e..c551c7333 100644 --- a/packages/use-intl/src/react/useFormatter.tsx +++ b/packages/use-intl/src/react/useFormatter.tsx @@ -1,6 +1,6 @@ import {useMemo} from 'react'; -import createFormatter from '../core/createFormatter'; -import useIntlContext from './useIntlContext'; +import createFormatter from '../core/createFormatter.tsx'; +import useIntlContext from './useIntlContext.tsx'; export default function useFormatter(): ReturnType { const { diff --git a/packages/use-intl/src/react/useIntlContext.tsx b/packages/use-intl/src/react/useIntlContext.tsx index 2df59c853..9185ecf26 100644 --- a/packages/use-intl/src/react/useIntlContext.tsx +++ b/packages/use-intl/src/react/useIntlContext.tsx @@ -1,5 +1,5 @@ import {useContext} from 'react'; -import IntlContext, {IntlContextValue} from './IntlContext'; +import IntlContext, {IntlContextValue} from './IntlContext.tsx'; export default function useIntlContext(): IntlContextValue { const context = useContext(IntlContext); diff --git a/packages/use-intl/src/react/useLocale.test.tsx b/packages/use-intl/src/react/useLocale.test.tsx index bd37a96b1..732aee4d7 100644 --- a/packages/use-intl/src/react/useLocale.test.tsx +++ b/packages/use-intl/src/react/useLocale.test.tsx @@ -1,8 +1,7 @@ import {render, screen} from '@testing-library/react'; -import React from 'react'; import {it} from 'vitest'; -import IntlProvider from './IntlProvider'; -import useLocale from './useLocale'; +import IntlProvider from './IntlProvider.tsx'; +import useLocale from './useLocale.tsx'; it('returns the current locale', () => { function Component() { diff --git a/packages/use-intl/src/react/useLocale.tsx b/packages/use-intl/src/react/useLocale.tsx index dc859f51a..826fd581c 100644 --- a/packages/use-intl/src/react/useLocale.tsx +++ b/packages/use-intl/src/react/useLocale.tsx @@ -1,4 +1,4 @@ -import useIntlContext from './useIntlContext'; +import useIntlContext from './useIntlContext.tsx'; export default function useLocale() { return useIntlContext().locale; diff --git a/packages/use-intl/src/react/useMessages.test.tsx b/packages/use-intl/src/react/useMessages.test.tsx index cab686764..d8563b0e1 100644 --- a/packages/use-intl/src/react/useMessages.test.tsx +++ b/packages/use-intl/src/react/useMessages.test.tsx @@ -1,8 +1,7 @@ import {render, screen} from '@testing-library/react'; -import React from 'react'; import {expect, it} from 'vitest'; -import IntlProvider from './IntlProvider'; -import useMessages from './useMessages'; +import IntlProvider from './IntlProvider.tsx'; +import useMessages from './useMessages.tsx'; function Component() { const messages = useMessages(); diff --git a/packages/use-intl/src/react/useMessages.tsx b/packages/use-intl/src/react/useMessages.tsx index 24a9d0481..31a4bf48a 100644 --- a/packages/use-intl/src/react/useMessages.tsx +++ b/packages/use-intl/src/react/useMessages.tsx @@ -1,5 +1,5 @@ -import {AbstractIntlMessages} from '../core'; -import useIntlContext from './useIntlContext'; +import {AbstractIntlMessages} from '../core.tsx'; +import useIntlContext from './useIntlContext.tsx'; export default function useMessages(): AbstractIntlMessages { const context = useIntlContext(); diff --git a/packages/use-intl/src/react/useNow.test.tsx b/packages/use-intl/src/react/useNow.test.tsx index 87995b2ff..0e43f2eed 100644 --- a/packages/use-intl/src/react/useNow.test.tsx +++ b/packages/use-intl/src/react/useNow.test.tsx @@ -1,9 +1,8 @@ import {render, waitFor} from '@testing-library/react'; import {parseISO} from 'date-fns'; -import React from 'react'; import {expect, it} from 'vitest'; -import IntlProvider from './IntlProvider'; -import useNow from './useNow'; +import IntlProvider from './IntlProvider.tsx'; +import useNow from './useNow.tsx'; it('returns the current time', () => { function Component() { diff --git a/packages/use-intl/src/react/useNow.tsx b/packages/use-intl/src/react/useNow.tsx index 3b4929f4c..0fb30d472 100644 --- a/packages/use-intl/src/react/useNow.tsx +++ b/packages/use-intl/src/react/useNow.tsx @@ -1,5 +1,5 @@ import {useEffect, useState} from 'react'; -import useIntlContext from './useIntlContext'; +import useIntlContext from './useIntlContext.tsx'; type Options = { updateInterval?: number; diff --git a/packages/use-intl/src/react/useTimeZone.test.tsx b/packages/use-intl/src/react/useTimeZone.test.tsx index b2ed1f4dc..57d4031e3 100644 --- a/packages/use-intl/src/react/useTimeZone.test.tsx +++ b/packages/use-intl/src/react/useTimeZone.test.tsx @@ -1,8 +1,7 @@ import {render, screen} from '@testing-library/react'; -import React from 'react'; import {expect, it} from 'vitest'; -import IntlProvider from './IntlProvider'; -import useTimeZone from './useTimeZone'; +import IntlProvider from './IntlProvider.tsx'; +import useTimeZone from './useTimeZone.tsx'; it('returns the time zone when it is configured', () => { function Component() { diff --git a/packages/use-intl/src/react/useTimeZone.tsx b/packages/use-intl/src/react/useTimeZone.tsx index 180901f64..f95f5b719 100644 --- a/packages/use-intl/src/react/useTimeZone.tsx +++ b/packages/use-intl/src/react/useTimeZone.tsx @@ -1,4 +1,4 @@ -import useIntlContext from './useIntlContext'; +import useIntlContext from './useIntlContext.tsx'; export default function useTimeZone() { return useIntlContext().timeZone; diff --git a/packages/use-intl/src/react/useTranslations.test.tsx b/packages/use-intl/src/react/useTranslations.test.tsx index 4d934035c..f20160ad8 100644 --- a/packages/use-intl/src/react/useTranslations.test.tsx +++ b/packages/use-intl/src/react/useTranslations.test.tsx @@ -1,7 +1,7 @@ import {render, renderHook, screen} from '@testing-library/react'; import {parseISO} from 'date-fns'; -import IntlMessageFormat from 'intl-messageformat'; -import React, {ComponentProps, PropsWithChildren, ReactNode} from 'react'; +import {IntlMessageFormat} from 'intl-messageformat'; +import {ComponentProps, PropsWithChildren, ReactNode} from 'react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import { Formats, @@ -9,19 +9,19 @@ import { IntlErrorCode, RichTranslationValues, TranslationValues -} from '../core'; -import IntlProvider from './IntlProvider'; -import useTranslations from './useTranslations'; +} from '../core.tsx'; +import IntlProvider from './IntlProvider.tsx'; +import useTranslations from './useTranslations.tsx'; // Wrap the library to include a counter for parse // invocations for the cache test below. vi.mock('intl-messageformat', async (importOriginal) => { const ActualIntlMessageFormat: typeof IntlMessageFormat = ( (await importOriginal()) as any - ).default; + ).IntlMessageFormat; return { - default: class MockIntlMessageFormat extends ActualIntlMessageFormat { + IntlMessageFormat: class MockIntlMessageFormat extends ActualIntlMessageFormat { public static invocationsByMessage: Record = {}; constructor( @@ -1081,10 +1081,10 @@ describe('performance', () => { vi.mock('intl-messageformat', async (original) => { const ActualIntlMessageFormat: typeof IntlMessageFormat = ( (await original()) as any - ).default; + ).IntlMessageFormat; return { - default: class MockIntlMessageFormatImpl extends ActualIntlMessageFormat { + IntlMessageFormat: class MockIntlMessageFormatImpl extends ActualIntlMessageFormat { public static invocationsByMessage: Record = {}; constructor( diff --git a/packages/use-intl/src/react/useTranslations.tsx b/packages/use-intl/src/react/useTranslations.tsx index 8bcc71f18..a3afd9b5b 100644 --- a/packages/use-intl/src/react/useTranslations.tsx +++ b/packages/use-intl/src/react/useTranslations.tsx @@ -1,15 +1,15 @@ import {ReactNode} from 'react'; -import Formats from '../core/Formats'; +import Formats from '../core/Formats.tsx'; import TranslationValues, { MarkupTranslationValues, RichTranslationValues -} from '../core/TranslationValues'; -import MessageKeys from '../core/utils/MessageKeys'; -import NamespaceKeys from '../core/utils/NamespaceKeys'; -import NestedKeyOf from '../core/utils/NestedKeyOf'; -import NestedValueOf from '../core/utils/NestedValueOf'; -import useIntlContext from './useIntlContext'; -import useTranslationsImpl from './useTranslationsImpl'; +} from '../core/TranslationValues.tsx'; +import MessageKeys from '../core/utils/MessageKeys.tsx'; +import NamespaceKeys from '../core/utils/NamespaceKeys.tsx'; +import NestedKeyOf from '../core/utils/NestedKeyOf.tsx'; +import NestedValueOf from '../core/utils/NestedValueOf.tsx'; +import useIntlContext from './useIntlContext.tsx'; +import useTranslationsImpl from './useTranslationsImpl.tsx'; /** * Translates messages from the given namespace by using the ICU syntax. diff --git a/packages/use-intl/src/react/useTranslationsImpl.tsx b/packages/use-intl/src/react/useTranslationsImpl.tsx index 6529d00b6..8a30f8c25 100644 --- a/packages/use-intl/src/react/useTranslationsImpl.tsx +++ b/packages/use-intl/src/react/useTranslationsImpl.tsx @@ -1,10 +1,10 @@ import {useMemo} from 'react'; -import {IntlError, IntlErrorCode} from '../core'; -import AbstractIntlMessages from '../core/AbstractIntlMessages'; -import createBaseTranslator from '../core/createBaseTranslator'; -import resolveNamespace from '../core/resolveNamespace'; -import NestedKeyOf from '../core/utils/NestedKeyOf'; -import useIntlContext from './useIntlContext'; +import AbstractIntlMessages from '../core/AbstractIntlMessages.tsx'; +import createBaseTranslator from '../core/createBaseTranslator.tsx'; +import resolveNamespace from '../core/resolveNamespace.tsx'; +import NestedKeyOf from '../core/utils/NestedKeyOf.tsx'; +import {IntlError, IntlErrorCode} from '../core.tsx'; +import useIntlContext from './useIntlContext.tsx'; let hasWarnedForMissingTimezone = false; const isServer = typeof window === 'undefined'; diff --git a/packages/use-intl/tsconfig.build.json b/packages/use-intl/tsconfig.build.json new file mode 100644 index 000000000..407c2ebcb --- /dev/null +++ b/packages/use-intl/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.tsx", "test"], + "compilerOptions": { + "rootDir": "src", + "noEmit": false, + "outDir": "dist/types", + "emitDeclarationOnly": true + } +} diff --git a/packages/use-intl/tsconfig.json b/packages/use-intl/tsconfig.json index 218e76ddf..1a0feb584 100644 --- a/packages/use-intl/tsconfig.json +++ b/packages/use-intl/tsconfig.json @@ -1,24 +1,16 @@ { + "extends": "eslint-config-molindo/tsconfig.json", "include": ["src", "test", "types"], "compilerOptions": { - "module": "esnext", + "allowImportingTsExtensions": true, // For ESM "lib": ["dom", "esnext"], "target": "ES2020", - "importHelpers": false, "declaration": true, - "sourceMap": true, "rootDir": ".", - "strict": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "moduleResolution": "node", - "jsx": "react", - "esModuleInterop": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "noEmit": false, - "outDir": "dist" + "noEmit": true } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 851f93f28..3a8edd04e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,48 +11,15 @@ importers: .: devDependencies: - '@babel/core': - specifier: ^7.24.7 - version: 7.25.9 - '@babel/preset-env': - specifier: ^7.24.7 - version: 7.25.9(@babel/core@7.25.9) - '@babel/preset-react': - specifier: ^7.24.7 - version: 7.25.9(@babel/core@7.25.9) - '@babel/preset-typescript': - specifier: ^7.24.7 - version: 7.25.9(@babel/core@7.25.9) '@lerna-lite/cli': specifier: ^3.9.0 version: 3.10.0(@lerna-lite/publish@3.10.0(@types/node@22.7.9)(typescript@5.6.3))(@lerna-lite/version@3.10.0(@lerna-lite/publish@3.10.0(@types/node@22.7.9)(typescript@5.6.3))(@types/node@22.7.9)(typescript@5.6.3))(@types/node@22.7.9)(typescript@5.6.3) '@lerna-lite/publish': specifier: ^3.9.0 version: 3.10.0(@types/node@22.7.9)(typescript@5.6.3) - '@rollup/plugin-babel': - specifier: ^6.0.3 - version: 6.0.4(@babel/core@7.25.9)(@types/babel__core@7.20.5)(rollup@4.24.0) - '@rollup/plugin-commonjs': - specifier: ^26.0.1 - version: 26.0.3(rollup@4.24.0) - '@rollup/plugin-node-resolve': - specifier: ^15.2.1 - version: 15.3.0(rollup@4.24.0) - '@rollup/plugin-replace': - specifier: ^5.0.7 - version: 5.0.7(rollup@4.24.0) - '@rollup/plugin-terser': - specifier: ^0.4.3 - version: 0.4.4(rollup@4.24.0) conventional-changelog-conventionalcommits: specifier: ^7.0.0 version: 7.0.2 - execa: - specifier: ^9.2.0 - version: 9.4.1 - rollup: - specifier: ^4.18.0 - version: 4.24.0 turbo: specifier: ^2.2.3 version: 2.2.3 @@ -73,13 +40,13 @@ importers: version: 2.1.5(react@18.3.1) '@vercel/analytics': specifier: 1.3.1 - version: 1.3.1(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 1.3.1(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@vercel/og': specifier: ^0.6.3 version: 0.6.3 '@vercel/speed-insights': specifier: ^1.0.12 - version: 1.0.13(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 1.0.13(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -91,10 +58,10 @@ importers: version: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nextra: specifier: ^3.1.0 - version: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) + version: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) nextra-theme-docs: specifier: ^3.1.0 - version: 3.1.0(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.1.0(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -131,7 +98,7 @@ importers: version: 15.11.0 next-sitemap: specifier: ^4.2.3 - version: 4.2.3(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 4.2.3(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) prettier: specifier: ^3.3.3 version: 3.3.3 @@ -296,7 +263,7 @@ importers: version: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-auth: specifier: ^4.24.7 - version: 4.24.8(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.24.8(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-intl: specifier: ^3.0.0 version: link:../../packages/next-intl @@ -644,7 +611,7 @@ importers: dependencies: next: specifier: ^12.0.0 - version: 12.3.4(@babel/core@7.25.9)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + version: 12.3.4(react-dom@17.0.2(react@17.0.2))(react@17.0.2) react: specifier: ^17.0.0 version: 17.0.2 @@ -798,12 +765,12 @@ importers: version: link:../use-intl devDependencies: '@arethetypeswrong/cli': - specifier: ^0.15.3 - version: 0.15.4 + specifier: ^0.16.4 + version: 0.16.4 '@edge-runtime/vm': specifier: ^3.2.0 version: 3.2.0 - '@size-limit/preset-big-lib': + '@size-limit/preset-small-lib': specifier: ^11.1.4 version: 11.1.6(size-limit@11.1.6) '@testing-library/react': @@ -857,6 +824,9 @@ importers: size-limit: specifier: ^11.1.4 version: 11.1.6 + tools: + specifier: workspace:^ + version: link:../../tools typescript: specifier: ^5.5.3 version: 5.6.3 @@ -874,9 +844,9 @@ importers: version: 10.7.1 devDependencies: '@arethetypeswrong/cli': - specifier: ^0.15.3 - version: 0.15.4 - '@size-limit/preset-big-lib': + specifier: ^0.16.4 + version: 0.16.4 + '@size-limit/preset-small-lib': specifier: ^11.1.4 version: 11.1.6(size-limit@11.1.6) '@testing-library/react': @@ -924,6 +894,9 @@ importers: tinyspy: specifier: ^3.0.0 version: 3.0.2 + tools: + specifier: workspace:^ + version: link:../../tools typescript: specifier: ^5.5.3 version: 5.6.3 @@ -931,6 +904,48 @@ importers: specifier: ^2.0.2 version: 2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0) + tools: + devDependencies: + '@babel/core': + specifier: ^7.24.7 + version: 7.25.9 + '@babel/preset-env': + specifier: ^7.24.7 + version: 7.25.9(@babel/core@7.25.9) + '@babel/preset-react': + specifier: ^7.24.7 + version: 7.25.9(@babel/core@7.25.9) + '@babel/preset-typescript': + specifier: ^7.24.7 + version: 7.25.9(@babel/core@7.25.9) + '@rollup/plugin-babel': + specifier: ^6.0.3 + version: 6.0.4(@babel/core@7.25.9)(@types/babel__core@7.20.5)(rollup@4.24.0) + '@rollup/plugin-node-resolve': + specifier: ^15.2.1 + version: 15.3.0(rollup@4.24.0) + '@rollup/plugin-replace': + specifier: ^5.0.7 + version: 5.0.7(rollup@4.24.0) + '@rollup/plugin-terser': + specifier: ^0.4.3 + version: 0.4.4(rollup@4.24.0) + eslint: + specifier: ^9.11.1 + version: 9.13.0(jiti@2.3.3) + eslint-config-molindo: + specifier: ^8.0.0 + version: 8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@22.7.9))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@22.7.9)(jsdom@25.0.1)(terser@5.36.0)) + execa: + specifier: ^9.2.0 + version: 9.4.1 + globals: + specifier: ^15.11.0 + version: 15.11.0 + rollup: + specifier: ^4.18.0 + version: 4.24.0 + packages: '@aashutoshrathi/word-wrap@1.2.6': @@ -1042,13 +1057,13 @@ packages: '@antfu/utils@0.7.10': resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} - '@arethetypeswrong/cli@0.15.4': - resolution: {integrity: sha512-YDbImAi1MGkouT7f2yAECpUMFhhA1J0EaXzIqoC5GGtK0xDgauLtcsZezm8tNq7d3wOFXH7OnY+IORYcG212rw==} + '@arethetypeswrong/cli@0.16.4': + resolution: {integrity: sha512-qMmdVlJon5FtA+ahn0c1oAVNxiq4xW5lqFiTZ21XHIeVwAVIQ+uRz4UEivqRMsjVV1grzRgJSKqaOrq1MvlVyQ==} engines: {node: '>=18'} hasBin: true - '@arethetypeswrong/core@0.15.1': - resolution: {integrity: sha512-FYp6GBAgsNz81BkfItRz8RLZO03w5+BaeiPma1uCfmxTnxbtuMrI/dbzGiOk8VghO108uFI0oJo0OkewdSHw7g==} + '@arethetypeswrong/core@0.16.4': + resolution: {integrity: sha512-RI3HXgSuKTfcBf1hSEg1P9/cOvmI0flsMm6/QL3L3wju4AlHDqd55JFPfXs4pzgEAgy5L9pul4/HPPz99x2GvA==} engines: {node: '>=18'} '@babel/code-frame@7.10.4': @@ -1078,10 +1093,6 @@ packages: resolution: {integrity: sha512-omlUGkr5EaoIJrhLf9CJ0TvjBRpd9+AXRG//0GEQ9THSo8wPiTlbpy1/Ow8ZTrbXpjd9FHXfbFQx32I04ht0FA==} engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.24.7': - resolution: {integrity: sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==} - engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.25.9': resolution: {integrity: sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==} engines: {node: '>=6.9.0'} @@ -1100,12 +1111,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-create-regexp-features-plugin@7.24.7': - resolution: {integrity: sha512-03TCmXy2FtXJEZfbXDTSqq1fRJArk7lX9DOFC/47VthYcxyIOx+eXQmdo6DOQvrbpIix+KfXwvuXdFDZHxt+rA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-create-regexp-features-plugin@7.25.9': resolution: {integrity: sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==} engines: {node: '>=6.9.0'} @@ -1133,10 +1138,6 @@ packages: resolution: {integrity: sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.22.5': - resolution: {integrity: sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.25.9': resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} engines: {node: '>=6.9.0'} @@ -1897,9 +1898,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/regjsgen@0.8.0': - resolution: {integrity: sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==} - '@babel/runtime@7.24.7': resolution: {integrity: sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==} engines: {node: '>=6.9.0'} @@ -2017,6 +2015,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.24.0': + resolution: {integrity: sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.17.6': resolution: {integrity: sha512-YnYSCceN/dUzUr5kdtUzB+wZprCafuD89Hs0Aqv9QSdwhYQybhXTaSTcrl6X/aWThn1a/j0eEpUBGOE7269REg==} engines: {node: '>=12'} @@ -2041,6 +2045,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.24.0': + resolution: {integrity: sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.17.6': resolution: {integrity: sha512-bSC9YVUjADDy1gae8RrioINU6e1lCkg3VGVwm0QQ2E1CWcC4gnMce9+B6RpxuSsrsXsk1yojn7sp1fnG8erE2g==} engines: {node: '>=12'} @@ -2065,6 +2075,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.24.0': + resolution: {integrity: sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.17.6': resolution: {integrity: sha512-MVcYcgSO7pfu/x34uX9u2QIZHmXAB7dEiLQC5bBl5Ryqtpj9lT2sg3gNDEsrPEmimSJW2FXIaxqSQ501YLDsZQ==} engines: {node: '>=12'} @@ -2089,6 +2105,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.24.0': + resolution: {integrity: sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.17.6': resolution: {integrity: sha512-bsDRvlbKMQMt6Wl08nHtFz++yoZHsyTOxnjfB2Q95gato+Yi4WnRl13oC2/PJJA9yLCoRv9gqT/EYX0/zDsyMA==} engines: {node: '>=12'} @@ -2113,6 +2135,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.24.0': + resolution: {integrity: sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.17.6': resolution: {integrity: sha512-xh2A5oPrYRfMFz74QXIQTQo8uA+hYzGWJFoeTE8EvoZGHb+idyV4ATaukaUvnnxJiauhs/fPx3vYhU4wiGfosg==} engines: {node: '>=12'} @@ -2137,6 +2165,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.24.0': + resolution: {integrity: sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.17.6': resolution: {integrity: sha512-EnUwjRc1inT4ccZh4pB3v1cIhohE2S4YXlt1OvI7sw/+pD+dIE4smwekZlEPIwY6PhU6oDWwITrQQm5S2/iZgg==} engines: {node: '>=12'} @@ -2161,6 +2195,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.24.0': + resolution: {integrity: sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.17.6': resolution: {integrity: sha512-Uh3HLWGzH6FwpviUcLMKPCbZUAFzv67Wj5MTwK6jn89b576SR2IbEp+tqUHTr8DIl0iDmBAf51MVaP7pw6PY5Q==} engines: {node: '>=12'} @@ -2185,6 +2225,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.24.0': + resolution: {integrity: sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.17.6': resolution: {integrity: sha512-bUR58IFOMJX523aDVozswnlp5yry7+0cRLCXDsxnUeQYJik1DukMY+apBsLOZJblpH+K7ox7YrKrHmJoWqVR9w==} engines: {node: '>=12'} @@ -2209,6 +2255,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.24.0': + resolution: {integrity: sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.17.6': resolution: {integrity: sha512-7YdGiurNt7lqO0Bf/U9/arrPWPqdPqcV6JCZda4LZgEn+PTQ5SMEI4MGR52Bfn3+d6bNEGcWFzlIxiQdS48YUw==} engines: {node: '>=12'} @@ -2233,6 +2285,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.24.0': + resolution: {integrity: sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.17.6': resolution: {integrity: sha512-ujp8uoQCM9FRcbDfkqECoARsLnLfCUhKARTP56TFPog8ie9JG83D5GVKjQ6yVrEVdMie1djH86fm98eY3quQkQ==} engines: {node: '>=12'} @@ -2257,6 +2315,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.24.0': + resolution: {integrity: sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.17.6': resolution: {integrity: sha512-y2NX1+X/Nt+izj9bLoiaYB9YXT/LoaQFYvCkVD77G/4F+/yuVXYCWz4SE9yr5CBMbOxOfBcy/xFL4LlOeNlzYQ==} engines: {node: '>=12'} @@ -2281,6 +2345,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.24.0': + resolution: {integrity: sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.17.6': resolution: {integrity: sha512-09AXKB1HDOzXD+j3FdXCiL/MWmZP0Ex9eR8DLMBVcHorrWJxWmY8Nms2Nm41iRM64WVx7bA/JVHMv081iP2kUA==} engines: {node: '>=12'} @@ -2305,6 +2375,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.24.0': + resolution: {integrity: sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.17.6': resolution: {integrity: sha512-AmLhMzkM8JuqTIOhxnX4ubh0XWJIznEynRnZAVdA2mMKE6FAfwT2TWKTwdqMG+qEaeyDPtfNoZRpJbD4ZBv0Tg==} engines: {node: '>=12'} @@ -2329,6 +2405,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.24.0': + resolution: {integrity: sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.17.6': resolution: {integrity: sha512-Y4Ri62PfavhLQhFbqucysHOmRamlTVK10zPWlqjNbj2XMea+BOs4w6ASKwQwAiqf9ZqcY9Ab7NOU4wIgpxwoSQ==} engines: {node: '>=12'} @@ -2353,6 +2435,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.24.0': + resolution: {integrity: sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.17.6': resolution: {integrity: sha512-SPUiz4fDbnNEm3JSdUW8pBJ/vkop3M1YwZAVwvdwlFLoJwKEZ9L98l3tzeyMzq27CyepDQ3Qgoba44StgbiN5Q==} engines: {node: '>=12'} @@ -2377,6 +2465,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.24.0': + resolution: {integrity: sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.17.6': resolution: {integrity: sha512-a3yHLmOodHrzuNgdpB7peFGPx1iJ2x6m+uDvhP2CKdr2CwOaqEFMeSqYAHU7hG+RjCq8r2NFujcd/YsEsFgTGw==} engines: {node: '>=12'} @@ -2401,6 +2495,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.24.0': + resolution: {integrity: sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-x64@0.17.6': resolution: {integrity: sha512-EanJqcU/4uZIBreTrnbnre2DXgXSa+Gjap7ifRfllpmyAU7YMvaXmljdArptTHmjrkkKm9BK6GH5D5Yo+p6y5A==} engines: {node: '>=12'} @@ -2425,12 +2525,24 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.24.0': + resolution: {integrity: sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.23.1': resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.24.0': + resolution: {integrity: sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.17.6': resolution: {integrity: sha512-xaxeSunhQRsTNGFanoOkkLtnmMn5QbA0qBhNet/XLVsc+OVkpIWPHcr3zTW2gxVU5YOHFbIHR9ODuaUdNza2Vw==} engines: {node: '>=12'} @@ -2455,6 +2567,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.24.0': + resolution: {integrity: sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/sunos-x64@0.17.6': resolution: {integrity: sha512-gnMnMPg5pfMkZvhHee21KbKdc6W3GR8/JuE0Da1kjwpK6oiFU3nqfHuVPgUX2rsOx9N2SadSQTIYV1CIjYG+xw==} engines: {node: '>=12'} @@ -2479,6 +2597,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.24.0': + resolution: {integrity: sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.17.6': resolution: {integrity: sha512-G95n7vP1UnGJPsVdKXllAJPtqjMvFYbN20e8RK8LVLhlTiSOH1sd7+Gt7rm70xiG+I5tM58nYgwWrLs6I1jHqg==} engines: {node: '>=12'} @@ -2503,6 +2627,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.24.0': + resolution: {integrity: sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.17.6': resolution: {integrity: sha512-96yEFzLhq5bv9jJo5JhTs1gI+1cKQ83cUpyxHuGqXVwQtY5Eq54ZEsKs8veKtiKwlrNimtckHEkj4mRh4pPjsg==} engines: {node: '>=12'} @@ -2527,6 +2657,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.24.0': + resolution: {integrity: sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.17.6': resolution: {integrity: sha512-n6d8MOyUrNp6G4VSpRcgjs5xj4A91svJSaiwLIDWVWEsZtpN5FA9NlBbZHDmAJc2e8e6SF4tkBD3HAvPF+7igA==} engines: {node: '>=12'} @@ -2551,6 +2687,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.24.0': + resolution: {integrity: sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.4.0': resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3030,9 +3172,6 @@ packages: resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.5': - resolution: {integrity: sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==} - '@jridgewell/source-map@0.3.6': resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} @@ -3567,16 +3706,6 @@ packages: webpack-plugin-serve: optional: true - '@puppeteer/browsers@2.2.2': - resolution: {integrity: sha512-hZ/JhxPIceWaGSEzUZp83/8M49CoxlkuThfTR7t4AoCu5+ZvJ3vktLm60Otww2TXeROB5igiZ8D9oPQh6ckBVg==} - engines: {node: '>=18'} - hasBin: true - - '@puppeteer/browsers@2.4.0': - resolution: {integrity: sha512-x8J1csfIygOwf6D6qUAZ0ASk3z63zPb7wkNeHRerCMh82qWKUrOgkuP005AJC8lDL6/evtXETGEJVcwykKT4/g==} - engines: {node: '>=18'} - hasBin: true - '@radix-ui/number@1.1.0': resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} @@ -4063,15 +4192,6 @@ packages: rollup: optional: true - '@rollup/plugin-commonjs@26.0.3': - resolution: {integrity: sha512-2BJcolt43MY+y5Tz47djHkodCC3c1VKVrBDKpVqHKpQ9z9S158kCCqB8NF6/gzxLdNlYW9abB3Ibh+kOWLp8KQ==} - engines: {node: '>=16.0.0 || 14 >= 14.17'} - peerDependencies: - rollup: ^2.68.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - '@rollup/plugin-node-resolve@15.3.0': resolution: {integrity: sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==} engines: {node: '>=14.0.0'} @@ -4099,15 +4219,6 @@ packages: rollup: optional: true - '@rollup/pluginutils@5.0.5': - resolution: {integrity: sha512-6aEYR910NyP73oHiJglti74iRyOwgFU4x3meH/H8OJx6Ry0j6cOVZ5X/wTvub7G7Ao6qaHBEaNsV3GLJkSsF+Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - '@rollup/pluginutils@5.1.0': resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==} engines: {node: '>=14.0.0'} @@ -4279,30 +4390,20 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - '@sitespeed.io/tracium@0.3.3': - resolution: {integrity: sha512-dNZafjM93Y+F+sfwTO5gTpsGXlnc/0Q+c2+62ViqP3gkMWvHEMSKkaEHgVJLcLg3i/g19GSIPziiKpgyne07Bw==} - engines: {node: '>=8'} - - '@size-limit/file@11.1.6': - resolution: {integrity: sha512-ojzzJMrTfcSECRnaTjGy0wNIolTCRdyqZTSWG9sG5XEoXG6PNgHXDDS6gf6YNxnqb+rWfCfVe93u6aKi3wEocQ==} + '@size-limit/esbuild@11.1.6': + resolution: {integrity: sha512-0nBKYSxeRjUVCVoCkWZbmGkGBwpm0HdwHedWgxksBGxTKU0PjOMSHc3XTjKOrXBKXQzw90Ue0mgOd4n6zct9SA==} engines: {node: ^18.0.0 || >=20.0.0} peerDependencies: size-limit: 11.1.6 - '@size-limit/preset-big-lib@11.1.6': - resolution: {integrity: sha512-GE93qIW9C3+8MXOsYgV0QcLfKv6B+Q8u/Jjb5rLfetDHBKoZV7HmedM/bv0vrbdcZlT8elk5P18Jo6L6yeV/8Q==} - peerDependencies: - size-limit: 11.1.6 - - '@size-limit/time@11.1.6': - resolution: {integrity: sha512-NIlJEPvUIxw87gHjriHpPhvd9fIC94S9wq7OW25K7Ctn14FZ2NlOTezPCfVViPmdlXjBYdi8vjsbc7kLCF1EpA==} + '@size-limit/file@11.1.6': + resolution: {integrity: sha512-ojzzJMrTfcSECRnaTjGy0wNIolTCRdyqZTSWG9sG5XEoXG6PNgHXDDS6gf6YNxnqb+rWfCfVe93u6aKi3wEocQ==} engines: {node: ^18.0.0 || >=20.0.0} peerDependencies: size-limit: 11.1.6 - '@size-limit/webpack@11.1.6': - resolution: {integrity: sha512-PTZCgwJsgdzdEj2wPFuLm0cCge8N2WbswMcKWNwMJibxQxPAmiF+sZ2F6GYBS7G7K3Fb4ovCliuN+wnnRACPNg==} - engines: {node: ^18.0.0 || >=20.0.0} + '@size-limit/preset-small-lib@11.1.6': + resolution: {integrity: sha512-hlmkBlOryJIsKlGpS61Ti7/EEZomygAzOabpo2htdxUbkCkvtVoUQpGWHUfWuxdhheDVF6rtZZ6lPGftMKlaQg==} peerDependencies: size-limit: 11.1.6 @@ -4474,9 +4575,6 @@ packages: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} - '@tootallnate/quickjs-emscripten@0.23.0': - resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - '@tufjs/canonical-json@2.0.0': resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} engines: {node: ^16.14.0 || >=18.0.0} @@ -4545,8 +4643,8 @@ packages: '@types/express-serve-static-core@4.19.6': resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} - '@types/express-serve-static-core@5.0.0': - resolution: {integrity: sha512-AbXMTZGt40T+KON9/Fdxx0B2WK5hsgxcfXJLr5bFpZ7b4JCex2WyQPTEKdXqfHiY5nKKBScZ7yCoO6Pvgxfvnw==} + '@types/express-serve-static-core@5.0.1': + resolution: {integrity: sha512-CRICJIl0N5cXDONAdlTv5ShATZ4HEwk6kDDIW2/w9qOWKg+NU/5F8wYRWCrONad0/UKkloNSmmyN/wX4rtpbVA==} '@types/express@4.17.21': resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} @@ -4635,6 +4733,9 @@ packages: '@types/node@20.17.0': resolution: {integrity: sha512-a7zRo0f0eLo9K5X9Wp5cAqTUNGzuFLDG2R7C4HY2BhcMAsxgSPuRvAC1ZB6QkuUQXf0YZAgfOX2ZyrBa2n4nHQ==} + '@types/node@20.17.1': + resolution: {integrity: sha512-j2VlPv1NnwPJbaCNv69FO/1z4lId0QmGvpT41YxitRtWlg96g/j8qcv2RKsLKe2F6OJgyXhupN1Xo17b2m139Q==} + '@types/node@22.7.9': resolution: {integrity: sha512-jrTfRC7FM6nChvU7X2KqcrgquofrWLFDeYC1hKfwNWomVvrn7JIksqf344WN2X/y8xrgqBd2dJATZV4GbatBfg==} @@ -4731,9 +4832,6 @@ packages: '@types/yargs@17.0.32': resolution: {integrity: sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==} - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript-eslint/eslint-plugin@8.11.0': resolution: {integrity: sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5450,10 +5548,6 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - ast-types@0.13.4: - resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} - engines: {node: '>=4'} - ast-types@0.15.2: resolution: {integrity: sha512-c27loCv9QkZinsa5ProX751khO9DJl/AcB5c2KNtA6NRvHKS0PgLfcftz72KVq504vB0Gku5s2kUZzDBvQWvHg==} engines: {node: '>=4'} @@ -5513,9 +5607,6 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} - b4a@1.6.7: - resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} - babel-core@7.0.0-bridge.0: resolution: {integrity: sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==} peerDependencies: @@ -5604,21 +5695,6 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - bare-events@2.5.0: - resolution: {integrity: sha512-/E8dDe9dsbLyh2qrZ64PEPadOQ0F4gbl1sUJOrmph7xOiIxfY8vwab/4bFLh4Y88/Hk/ujKcrQKc+ps0mv873A==} - - bare-fs@2.3.5: - resolution: {integrity: sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==} - - bare-os@2.4.4: - resolution: {integrity: sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==} - - bare-path@2.1.3: - resolution: {integrity: sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==} - - bare-stream@2.3.0: - resolution: {integrity: sha512-pVRWciewGUeCyKEuRxwv06M079r+fRjAQjBEK2P6OYGrO43O+Z0LrPZZEjlc4mB6C2RpZ9AxJ1s7NLEtOHO6eA==} - base64-js@0.0.8: resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} engines: {node: '>= 0.4'} @@ -5634,10 +5710,6 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} - basic-ftp@5.0.5: - resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} - engines: {node: '>=10.0.0'} - batch@0.6.1: resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} @@ -5783,9 +5855,6 @@ packages: buffer-alloc@1.2.0: resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==} - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - buffer-fill@1.0.0: resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} @@ -6008,11 +6077,6 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} - chromium-bidi@0.5.17: - resolution: {integrity: sha512-BqOuIWUgTPj8ayuBFJUYCCuwIcwjBsb3/614P7tt1bEPJ4i1M0kCdIl0Wi9xhtswBXnfO2bTpTMkHD71H8rJMg==} - peerDependencies: - devtools-protocol: '*' - ci-info@2.0.0: resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} @@ -6194,10 +6258,6 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} - commander@12.1.0: - resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} - engines: {node: '>=18'} - commander@2.13.0: resolution: {integrity: sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA==} @@ -6758,10 +6818,6 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} - data-uri-to-buffer@6.0.2: - resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} - engines: {node: '>= 14'} - data-urls@3.0.2: resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} engines: {node: '>=12'} @@ -6804,15 +6860,6 @@ packages: supports-color: optional: true - debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.3.5: resolution: {integrity: sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==} engines: {node: '>=6.0'} @@ -6937,10 +6984,6 @@ packages: resolution: {integrity: sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==} engines: {node: '>=0.10.0'} - degenerator@5.0.1: - resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} - engines: {node: '>= 14'} - del@4.1.1: resolution: {integrity: sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==} engines: {node: '>=6'} @@ -7004,9 +7047,6 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - devtools-protocol@0.0.1262051: - resolution: {integrity: sha512-YJe4CT5SA8on3Spa+UDtNhEqtuV6Epwz3OZ4HQVLhlRccpZ9/PAYk0/cy/oKxFKRrZPBUPyxympQci4yWNWZ9g==} - didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -7308,6 +7348,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.24.0: + resolution: {integrity: sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -7513,11 +7558,6 @@ packages: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} - estimo@3.0.3: - resolution: {integrity: sha512-qSibrDHo82yvmgeOW7onGgeOzS/nnqa8r2exQ8LyTSH8rAma10VBJE+hPSdukV1nQrqFvEz7BVe5puUK2LZJXg==} - engines: {node: '>=18'} - hasBin: true - estraverse@4.3.0: resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} engines: {node: '>=4.0'} @@ -7726,17 +7766,9 @@ packages: resolution: {integrity: sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==} engines: {node: '>=0.10.0'} - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-fifo@1.3.2: - resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} @@ -7782,9 +7814,6 @@ packages: fbjs@3.0.4: resolution: {integrity: sha512-ucV0tDODnGV3JCnnkmoszb5lf4bNpzjv80K41wd4k798Etq+UYD0y0TIfalLjZoKgjive6/adkRnszwapiDgBQ==} - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.4.0: resolution: {integrity: sha512-3oB133prH1o4j/L5lLW7uOCF1PlD+/It2L0eL/iAqWMB91RBbqTewABqxhj0ibBd90EEmWZq7ntIWzVaWcXTGQ==} peerDependencies: @@ -7871,10 +7900,6 @@ packages: resolution: {integrity: sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==} engines: {node: '>=14.16'} - find-chrome-bin@2.0.2: - resolution: {integrity: sha512-KlggCilbbvgETk/WEq9NG894U8yu4erIW0SjMm1sMPm2xihCHeNoybpzGoxEzHRthwF3XrKOgHYtfqgJzpCH2w==} - engines: {node: '>=18.0.0'} - find-up@3.0.0: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} engines: {node: '>=6'} @@ -8131,10 +8156,6 @@ packages: resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} engines: {node: '>=6'} - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -8154,10 +8175,6 @@ packages: get-tsconfig@4.7.5: resolution: {integrity: sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==} - get-uri@6.0.3: - resolution: {integrity: sha512-BzUrJBS9EcUb4cFol8r4W3v1cPsSyajLSthNkz5BxbpDcHN5tIrM10E2eNvfnvBn3DaT3DUgx0OpsBKkaOpanw==} - engines: {node: '>= 14'} - get-value@2.0.6: resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==} engines: {node: '>=0.10.0'} @@ -8874,9 +8891,6 @@ packages: is-color-stop@1.1.0: resolution: {integrity: sha512-H1U8Vz0cfXNujrJzEcvvwMDW9Ra+biSYA3ThdQvAnMLJkEHQXn6bWzLkxHtVYJ+Sdbx0b6finn3jZiaVe7MAHA==} - is-core-module@2.12.0: - resolution: {integrity: sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==} - is-core-module@2.13.1: resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} @@ -9063,9 +9077,6 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-reference@1.2.1: - resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} - is-reference@3.0.2: resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==} @@ -10389,9 +10400,6 @@ packages: resolution: {integrity: sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==} engines: {node: '>=4.0.0'} - mitt@3.0.1: - resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - mixin-deep@1.3.2: resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==} engines: {node: '>=0.10.0'} @@ -10516,10 +10524,6 @@ packages: nested-error-stacks@2.0.1: resolution: {integrity: sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==} - netmask@2.0.2: - resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} - engines: {node: '>= 0.4.0'} - new-github-release-url@2.0.0: resolution: {integrity: sha512-NHDDGYudnvRutt/VhKFlX26IotXe1w0cmkDm6JGquh5bz/bDTw0LufSmH/GxTjEdpHEO+bVKFTwdrcGa/9XlKQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -11052,14 +11056,6 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - pac-proxy-agent@7.0.2: - resolution: {integrity: sha512-BFi3vZnO9X5Qt6NRz7ZOaPja3ic0PhlsmCRYLOpN11+mWBCR6XJDqW5RF3j8jm4WGGQZtBA+bTfxYzeKW73eHg==} - engines: {node: '>= 14'} - - pac-resolver@7.0.1: - resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} - engines: {node: '>= 14'} - package-json-from-dist@1.0.0: resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} @@ -11246,9 +11242,6 @@ packages: peek-stream@1.1.3: resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - periscopic@3.1.0: resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} @@ -11761,13 +11754,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-agent@6.4.0: - resolution: {integrity: sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==} - engines: {node: '>= 14'} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} @@ -11807,10 +11793,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - puppeteer-core@22.6.5: - resolution: {integrity: sha512-s0/5XkAWe0/dWISiljdrybjwDCHhgN31Nu/wznOZPKeikgcJtZtbvPKBz0t802XWqfSQnQDt3L6xiAE5JLlfuw==} - engines: {node: '>=18'} - pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} @@ -11845,9 +11827,6 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - queue-tick@1.0.1: - resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} - queue@6.0.2: resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} @@ -12112,10 +12091,6 @@ packages: resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} engines: {node: '>= 0.4'} - regenerate-unicode-properties@10.1.0: - resolution: {integrity: sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==} - engines: {node: '>=4'} - regenerate-unicode-properties@10.2.0: resolution: {integrity: sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==} engines: {node: '>=4'} @@ -12150,10 +12125,6 @@ packages: resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} engines: {node: '>= 0.4'} - regexpu-core@5.3.2: - resolution: {integrity: sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==} - engines: {node: '>=4'} - regexpu-core@6.1.1: resolution: {integrity: sha512-k67Nb9jvwJcJmVpw0jPttR1/zVfnKf8Km0IPatrU/zJ5XeG3+Slx0xLXs9HByJSzXzrlz5EDvN6yLNMDc2qdnw==} engines: {node: '>=4'} @@ -12176,10 +12147,6 @@ packages: resolution: {integrity: sha512-1DHODs4B8p/mQHU9kr+jv8+wIC9mtG4eBHxWxIq5mhjE3D5oORhCc6deRKzTjs9DcfRFmj9BHSDguZklqCGFWQ==} hasBin: true - regjsparser@0.9.1: - resolution: {integrity: sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==} - hasBin: true - rehype-katex@7.0.1: resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} @@ -12328,10 +12295,6 @@ packages: resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==} engines: {node: '>=10'} - resolve@1.22.2: - resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} - hasBin: true - resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true @@ -12568,11 +12531,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.6.0: - resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} - engines: {node: '>=10'} - hasBin: true - semver@7.6.2: resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==} engines: {node: '>=10'} @@ -12602,9 +12560,6 @@ packages: serialize-javascript@4.0.0: resolution: {integrity: sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==} - serialize-javascript@6.0.1: - resolution: {integrity: sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==} - serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -12952,9 +12907,6 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} - streamx@2.20.1: - resolution: {integrity: sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==} - string-hash@1.1.3: resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} @@ -13230,19 +13182,10 @@ packages: tar-fs@2.1.1: resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} - tar-fs@3.0.5: - resolution: {integrity: sha512-JOgGAmZyMgbqpLwct7ZV8VzkEB6pxXFBVErLtb+XCOqzc6w1xiWKI9GVd6bwk68EX7eJ4DWmfXVmq8K2ziZTGg==} - - tar-fs@3.0.6: - resolution: {integrity: sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==} - tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tar-stream@3.1.7: - resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} - tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} @@ -13312,11 +13255,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - terser@5.18.2: - resolution: {integrity: sha512-Ah19JS86ypbJzTzvUCX7KOsEIhDaRONungA4aYBjEP3JZRf4ocuDzTg4QWZnPn9DEMiMYGJPiSOy7aykoCc70w==} - engines: {node: '>=10'} - hasBin: true - terser@5.36.0: resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==} engines: {node: '>=10'} @@ -13326,9 +13264,6 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} - text-decoder@1.2.0: - resolution: {integrity: sha512-n1yg1mOj9DNpk3NeZOx7T6jchTbyJS3i3cucbNN6FcdPriMZx7NsgrGpWWdWZZGxD7ES1XB+3uoqHMgOKaN+fg==} - text-extensions@2.4.0: resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==} engines: {node: '>=8'} @@ -13404,11 +13339,11 @@ packages: resolution: {integrity: sha512-TARUb7z1pGvlLxgPk++7wJ6aycXF3GJ0sNSBTAsTuJrQG5QuZlkUQP+zl+nbjAh4gMX9yDw9ZYklMd7vAfJKEw==} engines: {node: '>=0.10.0'} - tldts-core@6.1.54: - resolution: {integrity: sha512-5cc42+0G0EjYRDfIJHKraaT3I5kPm7j6or3Zh1T9sF+Ftj1T+isT4thicUyQQ1bwN7/xjHQIuY2fXCoXP8Haqg==} + tldts-core@6.1.55: + resolution: {integrity: sha512-BL+BuKHHaOpntE5BGI6naXjULU6aRlgaYdfDHR3T/hdbNTWkWUZ9yuc11wGnwgpvRwlyUiIK+QohYK3olaVU6Q==} - tldts@6.1.54: - resolution: {integrity: sha512-rDaL1t59gb/Lg0HPMUGdV1vAKLQcXwU74D26aMaYV4QW7mnMvShd1Vmkg3HYAPWx2JCTUmsrXt/Yl9eJ5UFBQw==} + tldts@6.1.55: + resolution: {integrity: sha512-HxQR/9roQ07Pwc8RyyrJMAxRz5/ssoF3qIPPUiIo3zUt6yMdmYZjM2OZIFMiZ3jHyz9jrGHEHuQZrUhoc1LkDw==} hasBin: true tmp@0.0.33: @@ -13500,9 +13435,6 @@ packages: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} - ts-expose-internals-conditionally@1.0.0-empty.0: - resolution: {integrity: sha512-F8m9NOF6ZhdOClDVdlM8gj3fDCav4ZIFSs/EI3ksQbAAXVSCN/Jh5OCJDDZWBuBy9psFc6jULGDlPwjMYMhJDw==} - ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -13668,8 +13600,8 @@ packages: typescript: optional: true - typescript@5.3.3: - resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} + typescript@5.6.1-rc: + resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} engines: {node: '>=14.17'} hasBin: true @@ -13701,9 +13633,6 @@ packages: unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} - unbzip2-stream@1.4.3: - resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} - undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} @@ -13723,10 +13652,6 @@ packages: resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} engines: {node: '>=4'} - unicode-match-property-value-ecmascript@2.1.0: - resolution: {integrity: sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==} - engines: {node: '>=4'} - unicode-match-property-value-ecmascript@2.2.0: resolution: {integrity: sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==} engines: {node: '>=4'} @@ -13924,9 +13849,6 @@ packages: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} - urlpattern-polyfill@10.0.0: - resolution: {integrity: sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==} - use-callback-ref@1.3.2: resolution: {integrity: sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==} engines: {node: '>=10'} @@ -14503,18 +14425,6 @@ packages: utf-8-validate: optional: true - ws@8.16.0: - resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.17.1: resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} engines: {node: '>=10.0.0'} @@ -14638,9 +14548,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -14666,9 +14573,6 @@ packages: peerDependencies: zod: ^3.18.0 - zod@3.22.4: - resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} - zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -14822,9 +14726,9 @@ snapshots: '@antfu/utils@0.7.10': {} - '@arethetypeswrong/cli@0.15.4': + '@arethetypeswrong/cli@0.16.4': dependencies: - '@arethetypeswrong/core': 0.15.1 + '@arethetypeswrong/core': 0.16.4 chalk: 4.1.2 cli-table3: 0.6.5 commander: 10.0.1 @@ -14832,13 +14736,14 @@ snapshots: marked-terminal: 7.1.0(marked@9.1.6) semver: 7.6.3 - '@arethetypeswrong/core@0.15.1': + '@arethetypeswrong/core@0.16.4': dependencies: '@andrewbranch/untar.js': 1.0.3 + cjs-module-lexer: 1.4.1 fflate: 0.8.2 + lru-cache: 10.4.3 semver: 7.6.3 - ts-expose-internals-conditionally: 1.0.0-empty.0 - typescript: 5.3.3 + typescript: 5.6.1-rc validate-npm-package-name: 5.0.1 '@babel/code-frame@7.10.4': @@ -14907,10 +14812,6 @@ snapshots: '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.0.2 - '@babel/helper-annotate-as-pure@7.24.7': - dependencies: - '@babel/types': 7.25.9 - '@babel/helper-annotate-as-pure@7.25.9': dependencies: '@babel/types': 7.25.9 @@ -14943,13 +14844,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-create-regexp-features-plugin@7.24.7(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-annotate-as-pure': 7.24.7 - regexpu-core: 5.3.2 - semver: 6.3.1 - '@babel/helper-create-regexp-features-plugin@7.25.9(@babel/core@7.25.9)': dependencies: '@babel/core': 7.25.9 @@ -14988,10 +14882,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.22.5': - dependencies: - '@babel/types': 7.25.9 - '@babel/helper-module-imports@7.25.9': dependencies: '@babel/traverse': 7.25.9 @@ -15344,7 +15234,7 @@ snapshots: '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.25.9)': dependencies: '@babel/core': 7.25.9 - '@babel/helper-create-regexp-features-plugin': 7.24.7(@babel/core@7.25.9) + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.25.9) '@babel/helper-plugin-utils': 7.25.9 '@babel/plugin-transform-arrow-functions@7.25.9(@babel/core@7.25.9)': @@ -15902,8 +15792,6 @@ snapshots: pirates: 4.0.6 source-map-support: 0.5.21 - '@babel/regjsgen@0.8.0': {} - '@babel/runtime@7.24.7': dependencies: regenerator-runtime: 0.14.1 @@ -16031,6 +15919,9 @@ snapshots: '@esbuild/aix-ppc64@0.23.1': optional: true + '@esbuild/aix-ppc64@0.24.0': + optional: true + '@esbuild/android-arm64@0.17.6': optional: true @@ -16043,6 +15934,9 @@ snapshots: '@esbuild/android-arm64@0.23.1': optional: true + '@esbuild/android-arm64@0.24.0': + optional: true + '@esbuild/android-arm@0.17.6': optional: true @@ -16055,6 +15949,9 @@ snapshots: '@esbuild/android-arm@0.23.1': optional: true + '@esbuild/android-arm@0.24.0': + optional: true + '@esbuild/android-x64@0.17.6': optional: true @@ -16067,6 +15964,9 @@ snapshots: '@esbuild/android-x64@0.23.1': optional: true + '@esbuild/android-x64@0.24.0': + optional: true + '@esbuild/darwin-arm64@0.17.6': optional: true @@ -16079,6 +15979,9 @@ snapshots: '@esbuild/darwin-arm64@0.23.1': optional: true + '@esbuild/darwin-arm64@0.24.0': + optional: true + '@esbuild/darwin-x64@0.17.6': optional: true @@ -16091,6 +15994,9 @@ snapshots: '@esbuild/darwin-x64@0.23.1': optional: true + '@esbuild/darwin-x64@0.24.0': + optional: true + '@esbuild/freebsd-arm64@0.17.6': optional: true @@ -16103,6 +16009,9 @@ snapshots: '@esbuild/freebsd-arm64@0.23.1': optional: true + '@esbuild/freebsd-arm64@0.24.0': + optional: true + '@esbuild/freebsd-x64@0.17.6': optional: true @@ -16115,6 +16024,9 @@ snapshots: '@esbuild/freebsd-x64@0.23.1': optional: true + '@esbuild/freebsd-x64@0.24.0': + optional: true + '@esbuild/linux-arm64@0.17.6': optional: true @@ -16127,6 +16039,9 @@ snapshots: '@esbuild/linux-arm64@0.23.1': optional: true + '@esbuild/linux-arm64@0.24.0': + optional: true + '@esbuild/linux-arm@0.17.6': optional: true @@ -16139,6 +16054,9 @@ snapshots: '@esbuild/linux-arm@0.23.1': optional: true + '@esbuild/linux-arm@0.24.0': + optional: true + '@esbuild/linux-ia32@0.17.6': optional: true @@ -16151,6 +16069,9 @@ snapshots: '@esbuild/linux-ia32@0.23.1': optional: true + '@esbuild/linux-ia32@0.24.0': + optional: true + '@esbuild/linux-loong64@0.17.6': optional: true @@ -16163,6 +16084,9 @@ snapshots: '@esbuild/linux-loong64@0.23.1': optional: true + '@esbuild/linux-loong64@0.24.0': + optional: true + '@esbuild/linux-mips64el@0.17.6': optional: true @@ -16175,6 +16099,9 @@ snapshots: '@esbuild/linux-mips64el@0.23.1': optional: true + '@esbuild/linux-mips64el@0.24.0': + optional: true + '@esbuild/linux-ppc64@0.17.6': optional: true @@ -16187,6 +16114,9 @@ snapshots: '@esbuild/linux-ppc64@0.23.1': optional: true + '@esbuild/linux-ppc64@0.24.0': + optional: true + '@esbuild/linux-riscv64@0.17.6': optional: true @@ -16199,6 +16129,9 @@ snapshots: '@esbuild/linux-riscv64@0.23.1': optional: true + '@esbuild/linux-riscv64@0.24.0': + optional: true + '@esbuild/linux-s390x@0.17.6': optional: true @@ -16211,6 +16144,9 @@ snapshots: '@esbuild/linux-s390x@0.23.1': optional: true + '@esbuild/linux-s390x@0.24.0': + optional: true + '@esbuild/linux-x64@0.17.6': optional: true @@ -16223,6 +16159,9 @@ snapshots: '@esbuild/linux-x64@0.23.1': optional: true + '@esbuild/linux-x64@0.24.0': + optional: true + '@esbuild/netbsd-x64@0.17.6': optional: true @@ -16235,9 +16174,15 @@ snapshots: '@esbuild/netbsd-x64@0.23.1': optional: true + '@esbuild/netbsd-x64@0.24.0': + optional: true + '@esbuild/openbsd-arm64@0.23.1': optional: true + '@esbuild/openbsd-arm64@0.24.0': + optional: true + '@esbuild/openbsd-x64@0.17.6': optional: true @@ -16250,6 +16195,9 @@ snapshots: '@esbuild/openbsd-x64@0.23.1': optional: true + '@esbuild/openbsd-x64@0.24.0': + optional: true + '@esbuild/sunos-x64@0.17.6': optional: true @@ -16262,6 +16210,9 @@ snapshots: '@esbuild/sunos-x64@0.23.1': optional: true + '@esbuild/sunos-x64@0.24.0': + optional: true + '@esbuild/win32-arm64@0.17.6': optional: true @@ -16274,6 +16225,9 @@ snapshots: '@esbuild/win32-arm64@0.23.1': optional: true + '@esbuild/win32-arm64@0.24.0': + optional: true + '@esbuild/win32-ia32@0.17.6': optional: true @@ -16286,6 +16240,9 @@ snapshots: '@esbuild/win32-ia32@0.23.1': optional: true + '@esbuild/win32-ia32@0.24.0': + optional: true + '@esbuild/win32-x64@0.17.6': optional: true @@ -16298,6 +16255,9 @@ snapshots: '@esbuild/win32-x64@0.23.1': optional: true + '@esbuild/win32-x64@0.24.0': + optional: true + '@eslint-community/eslint-utils@4.4.0(eslint@9.13.0(jiti@2.3.3))': dependencies: eslint: 9.13.0(jiti@2.3.3) @@ -17153,11 +17113,6 @@ snapshots: '@jridgewell/set-array@1.2.1': {} - '@jridgewell/source-map@0.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 - '@jridgewell/source-map@0.3.6': dependencies: '@jridgewell/gen-mapping': 0.3.5 @@ -17874,32 +17829,6 @@ snapshots: webpack-dev-server: 5.1.0(webpack@5.95.0(esbuild@0.23.1)) webpack-hot-middleware: 2.26.1 - '@puppeteer/browsers@2.2.2': - dependencies: - debug: 4.3.4 - extract-zip: 2.0.1 - progress: 2.0.3 - proxy-agent: 6.4.0 - semver: 7.6.0 - tar-fs: 3.0.5 - unbzip2-stream: 1.4.3 - yargs: 17.7.2 - transitivePeerDependencies: - - supports-color - - '@puppeteer/browsers@2.4.0': - dependencies: - debug: 4.3.7(supports-color@6.1.0) - extract-zip: 2.0.1 - progress: 2.0.3 - proxy-agent: 6.4.0 - semver: 7.6.3 - tar-fs: 3.0.6 - unbzip2-stream: 1.4.3 - yargs: 17.7.2 - transitivePeerDependencies: - - supports-color - '@radix-ui/number@1.1.0': {} '@radix-ui/primitive@1.1.0': {} @@ -18556,30 +18485,21 @@ snapshots: '@rollup/plugin-babel@6.0.4(@babel/core@7.25.9)(@types/babel__core@7.20.5)(rollup@4.24.0)': dependencies: '@babel/core': 7.25.9 - '@babel/helper-module-imports': 7.22.5 - '@rollup/pluginutils': 5.0.5(rollup@4.24.0) - optionalDependencies: - '@types/babel__core': 7.20.5 - rollup: 4.24.0 - - '@rollup/plugin-commonjs@26.0.3(rollup@4.24.0)': - dependencies: + '@babel/helper-module-imports': 7.25.9 '@rollup/pluginutils': 5.1.0(rollup@4.24.0) - commondir: 1.0.1 - estree-walker: 2.0.2 - glob: 10.4.5 - is-reference: 1.2.1 - magic-string: 0.30.12 optionalDependencies: + '@types/babel__core': 7.20.5 rollup: 4.24.0 + transitivePeerDependencies: + - supports-color '@rollup/plugin-node-resolve@15.3.0(rollup@4.24.0)': dependencies: - '@rollup/pluginutils': 5.0.5(rollup@4.24.0) + '@rollup/pluginutils': 5.1.0(rollup@4.24.0) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 - resolve: 1.22.2 + resolve: 1.22.8 optionalDependencies: rollup: 4.24.0 @@ -18592,17 +18512,9 @@ snapshots: '@rollup/plugin-terser@0.4.4(rollup@4.24.0)': dependencies: - serialize-javascript: 6.0.1 + serialize-javascript: 6.0.2 smob: 1.4.1 - terser: 5.18.2 - optionalDependencies: - rollup: 4.24.0 - - '@rollup/pluginutils@5.0.5(rollup@4.24.0)': - dependencies: - '@types/estree': 1.0.6 - estree-walker: 2.0.2 - picomatch: 2.3.1 + terser: 5.36.0 optionalDependencies: rollup: 4.24.0 @@ -18766,50 +18678,21 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@sitespeed.io/tracium@0.3.3': + '@size-limit/esbuild@11.1.6(size-limit@11.1.6)': dependencies: - debug: 4.3.7(supports-color@6.1.0) - transitivePeerDependencies: - - supports-color + esbuild: 0.24.0 + nanoid: 5.0.7 + size-limit: 11.1.6 '@size-limit/file@11.1.6(size-limit@11.1.6)': dependencies: size-limit: 11.1.6 - '@size-limit/preset-big-lib@11.1.6(size-limit@11.1.6)': + '@size-limit/preset-small-lib@11.1.6(size-limit@11.1.6)': dependencies: + '@size-limit/esbuild': 11.1.6(size-limit@11.1.6) '@size-limit/file': 11.1.6(size-limit@11.1.6) - '@size-limit/time': 11.1.6(size-limit@11.1.6) - '@size-limit/webpack': 11.1.6(size-limit@11.1.6) - size-limit: 11.1.6 - transitivePeerDependencies: - - '@swc/core' - - bufferutil - - esbuild - - supports-color - - uglify-js - - utf-8-validate - - webpack-cli - - '@size-limit/time@11.1.6(size-limit@11.1.6)': - dependencies: - estimo: 3.0.3 - size-limit: 11.1.6 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@size-limit/webpack@11.1.6(size-limit@11.1.6)': - dependencies: - nanoid: 5.0.7 size-limit: 11.1.6 - webpack: 5.95.0 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - - webpack-cli '@storybook/builder-webpack5@8.3.6(esbuild@0.23.1)(storybook@8.3.6)(typescript@5.6.3)': dependencies: @@ -19133,8 +19016,6 @@ snapshots: '@tootallnate/once@2.0.0': {} - '@tootallnate/quickjs-emscripten@0.23.0': {} - '@tufjs/canonical-json@2.0.0': {} '@tufjs/models@2.0.1': @@ -19178,13 +19059,13 @@ snapshots: '@types/bonjour@3.5.13': dependencies: - '@types/node': 20.17.0 + '@types/node': 20.17.1 optional: true '@types/connect-history-api-fallback@1.5.4': dependencies: - '@types/express-serve-static-core': 5.0.0 - '@types/node': 20.17.0 + '@types/express-serve-static-core': 5.0.1 + '@types/node': 20.17.1 optional: true '@types/connect@3.4.38': @@ -19220,9 +19101,9 @@ snapshots: '@types/range-parser': 1.2.7 '@types/send': 0.17.4 - '@types/express-serve-static-core@5.0.0': + '@types/express-serve-static-core@5.0.1': dependencies: - '@types/node': 20.17.0 + '@types/node': 20.17.1 '@types/qs': 6.9.16 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 @@ -19260,7 +19141,7 @@ snapshots: '@types/http-proxy@1.17.15': dependencies: - '@types/node': 20.17.0 + '@types/node': 20.17.1 optional: true '@types/istanbul-lib-coverage@2.0.6': {} @@ -19318,13 +19199,18 @@ snapshots: '@types/node-forge@1.3.11': dependencies: - '@types/node': 20.17.0 + '@types/node': 20.17.1 optional: true '@types/node@20.17.0': dependencies: undici-types: 6.19.8 + '@types/node@20.17.1': + dependencies: + undici-types: 6.19.8 + optional: true + '@types/node@22.7.9': dependencies: undici-types: 6.19.8 @@ -19377,7 +19263,7 @@ snapshots: '@types/sockjs@0.3.36': dependencies: - '@types/node': 20.17.0 + '@types/node': 20.17.1 optional: true '@types/source-list-map@0.1.6': {} @@ -19413,7 +19299,7 @@ snapshots: '@types/webpack@5.28.5(esbuild@0.23.1)': dependencies: - '@types/node': 20.17.0 + '@types/node': 20.17.1 tapable: 2.2.1 webpack: 5.95.0(esbuild@0.23.1) transitivePeerDependencies: @@ -19425,7 +19311,7 @@ snapshots: '@types/ws@8.5.12': dependencies: - '@types/node': 20.17.0 + '@types/node': 20.17.1 optional: true '@types/yargs-parser@21.0.3': {} @@ -19442,11 +19328,6 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 20.17.0 - optional: true - '@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3)': dependencies: '@eslint-community/regexpp': 4.11.1 @@ -19704,7 +19585,7 @@ snapshots: '@vanilla-extract/private@1.0.3': {} - '@vercel/analytics@1.3.1(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/analytics@1.3.1(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': dependencies: server-only: 0.0.1 optionalDependencies: @@ -19717,7 +19598,7 @@ snapshots: satori: 0.10.9 yoga-wasm-web: 0.3.3 - '@vercel/speed-insights@1.0.13(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/speed-insights@1.0.13(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': optionalDependencies: next: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -20365,10 +20246,6 @@ snapshots: ast-types-flow@0.0.8: {} - ast-types@0.13.4: - dependencies: - tslib: 2.8.0 - ast-types@0.15.2: dependencies: tslib: 2.8.0 @@ -20425,8 +20302,6 @@ snapshots: axobject-query@4.1.0: {} - b4a@1.6.7: {} - babel-core@7.0.0-bridge.0(@babel/core@7.25.9): dependencies: '@babel/core': 7.25.9 @@ -20605,30 +20480,6 @@ snapshots: balanced-match@1.0.2: {} - bare-events@2.5.0: - optional: true - - bare-fs@2.3.5: - dependencies: - bare-events: 2.5.0 - bare-path: 2.1.3 - bare-stream: 2.3.0 - optional: true - - bare-os@2.4.4: - optional: true - - bare-path@2.1.3: - dependencies: - bare-os: 2.4.4 - optional: true - - bare-stream@2.3.0: - dependencies: - b4a: 1.6.7 - streamx: 2.20.1 - optional: true - base64-js@0.0.8: {} base64-js@1.5.1: {} @@ -20647,8 +20498,6 @@ snapshots: dependencies: safe-buffer: 5.1.2 - basic-ftp@5.0.5: {} - batch@0.6.1: {} before-after-hook@3.0.2: {} @@ -20857,8 +20706,6 @@ snapshots: buffer-alloc-unsafe: 1.1.0 buffer-fill: 1.0.0 - buffer-crc32@0.2.13: {} - buffer-fill@1.0.0: {} buffer-from@1.1.2: {} @@ -21169,13 +21016,6 @@ snapshots: chrome-trace-event@1.0.4: {} - chromium-bidi@0.5.17(devtools-protocol@0.0.1262051): - dependencies: - devtools-protocol: 0.0.1262051 - mitt: 3.0.1 - urlpattern-polyfill: 10.0.0 - zod: 3.22.4 - ci-info@2.0.0: {} ci-info@3.9.0: {} @@ -21355,8 +21195,6 @@ snapshots: commander@10.0.1: {} - commander@12.1.0: {} - commander@2.13.0: {} commander@2.20.0: {} @@ -21548,7 +21386,7 @@ snapshots: core-js-compat@3.38.1: dependencies: - browserslist: 4.24.0 + browserslist: 4.24.2 core-js-pure@3.38.0: {} @@ -22045,8 +21883,6 @@ snapshots: data-uri-to-buffer@4.0.1: {} - data-uri-to-buffer@6.0.2: {} - data-urls@3.0.2: dependencies: abab: 2.0.6 @@ -22093,10 +21929,6 @@ snapshots: optionalDependencies: supports-color: 6.1.0 - debug@4.3.4: - dependencies: - ms: 2.1.2 - debug@4.3.5: dependencies: ms: 2.1.2 @@ -22218,12 +22050,6 @@ snapshots: is-descriptor: 1.0.3 isobject: 3.0.1 - degenerator@5.0.1: - dependencies: - ast-types: 0.13.4 - escodegen: 2.1.0 - esprima: 4.0.1 - del@4.1.1: dependencies: '@types/glob': 7.2.0 @@ -22287,8 +22113,6 @@ snapshots: dependencies: dequal: 2.0.3 - devtools-protocol@0.0.1262051: {} - didyoumean@1.2.2: {} diff-sequences@29.6.3: {} @@ -22744,6 +22568,33 @@ snapshots: '@esbuild/win32-ia32': 0.23.1 '@esbuild/win32-x64': 0.23.1 + esbuild@0.24.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.24.0 + '@esbuild/android-arm': 0.24.0 + '@esbuild/android-arm64': 0.24.0 + '@esbuild/android-x64': 0.24.0 + '@esbuild/darwin-arm64': 0.24.0 + '@esbuild/darwin-x64': 0.24.0 + '@esbuild/freebsd-arm64': 0.24.0 + '@esbuild/freebsd-x64': 0.24.0 + '@esbuild/linux-arm': 0.24.0 + '@esbuild/linux-arm64': 0.24.0 + '@esbuild/linux-ia32': 0.24.0 + '@esbuild/linux-loong64': 0.24.0 + '@esbuild/linux-mips64el': 0.24.0 + '@esbuild/linux-ppc64': 0.24.0 + '@esbuild/linux-riscv64': 0.24.0 + '@esbuild/linux-s390x': 0.24.0 + '@esbuild/linux-x64': 0.24.0 + '@esbuild/netbsd-x64': 0.24.0 + '@esbuild/openbsd-arm64': 0.24.0 + '@esbuild/openbsd-x64': 0.24.0 + '@esbuild/sunos-x64': 0.24.0 + '@esbuild/win32-arm64': 0.24.0 + '@esbuild/win32-ia32': 0.24.0 + '@esbuild/win32-x64': 0.24.0 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -23237,18 +23088,6 @@ snapshots: dependencies: estraverse: 5.3.0 - estimo@3.0.3: - dependencies: - '@sitespeed.io/tracium': 0.3.3 - commander: 12.1.0 - find-chrome-bin: 2.0.2 - nanoid: 5.0.7 - puppeteer-core: 22.6.5 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - estraverse@4.3.0: {} estraverse@5.3.0: {} @@ -23595,20 +23434,8 @@ snapshots: transitivePeerDependencies: - supports-color - extract-zip@2.0.1: - dependencies: - debug: 4.3.7(supports-color@6.1.0) - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - fast-deep-equal@3.1.3: {} - fast-fifo@1.3.2: {} - fast-glob@3.3.2: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -23667,10 +23494,6 @@ snapshots: transitivePeerDependencies: - encoding - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - fdir@6.4.0(picomatch@4.0.2): optionalDependencies: picomatch: 4.0.2 @@ -23770,12 +23593,6 @@ snapshots: common-path-prefix: 3.0.0 pkg-dir: 7.0.0 - find-chrome-bin@2.0.2: - dependencies: - '@puppeteer/browsers': 2.4.0 - transitivePeerDependencies: - - supports-color - find-up@3.0.0: dependencies: locate-path: 3.0.0 @@ -24046,10 +23863,6 @@ snapshots: dependencies: pump: 3.0.2 - get-stream@5.2.0: - dependencies: - pump: 3.0.2 - get-stream@6.0.1: {} get-stream@8.0.1: {} @@ -24069,15 +23882,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-uri@6.0.3: - dependencies: - basic-ftp: 5.0.5 - data-uri-to-buffer: 6.0.2 - debug: 4.3.7(supports-color@6.1.0) - fs-extra: 11.2.0 - transitivePeerDependencies: - - supports-color - get-value@2.0.6: {} getenv@1.0.0: {} @@ -24976,10 +24780,6 @@ snapshots: rgb-regex: 1.0.1 rgba-regex: 1.0.0 - is-core-module@2.12.0: - dependencies: - has: 1.0.3 - is-core-module@2.13.1: dependencies: hasown: 2.0.0 @@ -25124,10 +24924,6 @@ snapshots: is-potential-custom-element-name@1.0.1: {} - is-reference@1.2.1: - dependencies: - '@types/estree': 1.0.6 - is-reference@3.0.2: dependencies: '@types/estree': 1.0.6 @@ -27524,8 +27320,6 @@ snapshots: stream-each: 1.2.3 through2: 2.0.5 - mitt@3.0.1: {} - mixin-deep@1.3.2: dependencies: for-in: 1.0.2 @@ -27662,13 +27456,11 @@ snapshots: nested-error-stacks@2.0.1: {} - netmask@2.0.2: {} - new-github-release-url@2.0.0: dependencies: type-fest: 2.19.0 - next-auth@4.24.8(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-auth@4.24.8(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.24.7 '@panva/hkdf': 1.2.0 @@ -27683,7 +27475,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) uuid: 8.3.2 - next-sitemap@4.2.3(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): + next-sitemap@4.2.3(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.6 @@ -27696,7 +27488,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - next@12.3.4(@babel/core@7.25.9)(react-dom@17.0.2(react@17.0.2))(react@17.0.2): + next@12.3.4(react-dom@17.0.2(react@17.0.2))(react@17.0.2): dependencies: '@next/env': 12.3.4 '@swc/helpers': 0.4.11 @@ -27704,7 +27496,7 @@ snapshots: postcss: 8.4.14 react: 17.0.2 react-dom: 17.0.2(react@17.0.2) - styled-jsx: 5.0.7(@babel/core@7.25.9)(react@17.0.2) + styled-jsx: 5.0.7(react@17.0.2) use-sync-external-store: 1.2.0(react@17.0.2) optionalDependencies: '@next/swc-android-arm-eabi': 12.3.4 @@ -27750,7 +27542,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - nextra-theme-docs@3.1.0(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + nextra-theme-docs@3.1.0(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@headlessui/react': 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) clsx: 2.1.1 @@ -27758,13 +27550,13 @@ snapshots: flexsearch: 0.7.43 next: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - nextra: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) + nextra: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) scroll-into-view-if-needed: 3.1.0 zod: 3.23.8 - nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3): + nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3): dependencies: '@formatjs/intl-localematcher': 0.5.5 '@headlessui/react': 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -28347,24 +28139,6 @@ snapshots: p-try@2.2.0: {} - pac-proxy-agent@7.0.2: - dependencies: - '@tootallnate/quickjs-emscripten': 0.23.0 - agent-base: 7.1.1 - debug: 4.3.7(supports-color@6.1.0) - get-uri: 6.0.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.5 - pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.4 - transitivePeerDependencies: - - supports-color - - pac-resolver@7.0.1: - dependencies: - degenerator: 5.0.1 - netmask: 2.0.2 - package-json-from-dist@1.0.0: {} package-manager-detector@0.2.2: {} @@ -28581,8 +28355,6 @@ snapshots: duplexify: 3.7.1 through2: 2.0.5 - pend@1.2.0: {} - periscopic@3.1.0: dependencies: '@types/estree': 1.0.6 @@ -29152,21 +28924,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-agent@6.4.0: - dependencies: - agent-base: 7.1.1 - debug: 4.3.7(supports-color@6.1.0) - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.5 - lru-cache: 7.18.3 - pac-proxy-agent: 7.0.2 - proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.4 - transitivePeerDependencies: - - supports-color - - proxy-from-env@1.1.0: {} - prr@1.0.1: {} pseudomap@1.0.2: {} @@ -29215,18 +28972,6 @@ snapshots: punycode@2.3.1: {} - puppeteer-core@22.6.5: - dependencies: - '@puppeteer/browsers': 2.2.2 - chromium-bidi: 0.5.17(devtools-protocol@0.0.1262051) - debug: 4.3.4 - devtools-protocol: 0.0.1262051 - ws: 8.16.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - pure-rand@6.1.0: {} q@1.5.1: {} @@ -29245,8 +28990,6 @@ snapshots: queue-microtask@1.2.3: {} - queue-tick@1.0.1: {} - queue@6.0.2: dependencies: inherits: 2.0.4 @@ -29665,10 +29408,6 @@ snapshots: globalthis: 1.0.4 which-builtin-type: 1.1.3 - regenerate-unicode-properties@10.1.0: - dependencies: - regenerate: 1.4.2 - regenerate-unicode-properties@10.2.0: dependencies: regenerate: 1.4.2 @@ -29701,15 +29440,6 @@ snapshots: es-errors: 1.3.0 set-function-name: 2.0.2 - regexpu-core@5.3.2: - dependencies: - '@babel/regjsgen': 0.8.0 - regenerate: 1.4.2 - regenerate-unicode-properties: 10.1.0 - regjsparser: 0.9.1 - unicode-match-property-ecmascript: 2.0.0 - unicode-match-property-value-ecmascript: 2.1.0 - regexpu-core@6.1.1: dependencies: regenerate: 1.4.2 @@ -29738,10 +29468,6 @@ snapshots: dependencies: jsesc: 3.0.2 - regjsparser@0.9.1: - dependencies: - jsesc: 0.5.0 - rehype-katex@7.0.1: dependencies: '@types/hast': 3.0.4 @@ -29970,12 +29696,6 @@ snapshots: resolve.exports@2.0.2: {} - resolve@1.22.2: - dependencies: - is-core-module: 2.12.0 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - resolve@1.22.8: dependencies: is-core-module: 2.13.1 @@ -30244,10 +29964,6 @@ snapshots: semver@7.3.2: {} - semver@7.6.0: - dependencies: - lru-cache: 6.0.0 - semver@7.6.2: {} semver@7.6.3: {} @@ -30298,10 +30014,6 @@ snapshots: dependencies: randombytes: 2.1.0 - serialize-javascript@6.0.1: - dependencies: - randombytes: 2.1.0 - serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -30750,14 +30462,6 @@ snapshots: streamsearch@1.1.0: {} - streamx@2.20.1: - dependencies: - fast-fifo: 1.3.2 - queue-tick: 1.0.1 - text-decoder: 1.2.0 - optionalDependencies: - bare-events: 2.5.0 - string-hash@1.1.3: {} string-length@4.0.2: @@ -30921,11 +30625,9 @@ snapshots: dependencies: inline-style-parser: 0.2.4 - styled-jsx@5.0.7(@babel/core@7.25.9)(react@17.0.2): + styled-jsx@5.0.7(react@17.0.2): dependencies: react: 17.0.2 - optionalDependencies: - '@babel/core': 7.25.9 styled-jsx@5.1.1(@babel/core@7.25.9)(react@18.3.1): dependencies: @@ -31063,22 +30765,6 @@ snapshots: pump: 3.0.0 tar-stream: 2.2.0 - tar-fs@3.0.5: - dependencies: - pump: 3.0.2 - tar-stream: 3.1.7 - optionalDependencies: - bare-fs: 2.3.5 - bare-path: 2.1.3 - - tar-fs@3.0.6: - dependencies: - pump: 3.0.2 - tar-stream: 3.1.7 - optionalDependencies: - bare-fs: 2.3.5 - bare-path: 2.1.3 - tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -31087,12 +30773,6 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - tar-stream@3.1.7: - dependencies: - b4a: 1.6.7 - fast-fifo: 1.3.2 - streamx: 2.20.1 - tar@6.2.1: dependencies: chownr: 2.0.0 @@ -31175,15 +30855,6 @@ snapshots: optionalDependencies: esbuild: 0.23.1 - terser-webpack-plugin@5.3.10(webpack@5.95.0): - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - jest-worker: 27.5.1 - schema-utils: 3.3.0 - serialize-javascript: 6.0.2 - terser: 5.36.0 - webpack: 5.95.0 - terser@4.8.1: dependencies: acorn: 8.13.0 @@ -31191,13 +30862,6 @@ snapshots: source-map: 0.6.1 source-map-support: 0.5.21 - terser@5.18.2: - dependencies: - '@jridgewell/source-map': 0.3.5 - acorn: 8.12.0 - commander: 2.20.3 - source-map-support: 0.5.21 - terser@5.36.0: dependencies: '@jridgewell/source-map': 0.3.6 @@ -31211,10 +30875,6 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 - text-decoder@1.2.0: - dependencies: - b4a: 1.6.7 - text-extensions@2.4.0: {} text-table@0.2.0: {} @@ -31277,12 +30937,12 @@ snapshots: titleize@1.0.0: {} - tldts-core@6.1.54: + tldts-core@6.1.55: optional: true - tldts@6.1.54: + tldts@6.1.55: dependencies: - tldts-core: 6.1.54 + tldts-core: 6.1.55 optional: true tmp@0.0.33: @@ -31328,7 +30988,7 @@ snapshots: tough-cookie@5.0.0: dependencies: - tldts: 6.1.54 + tldts: 6.1.55 optional: true tr46@0.0.3: {} @@ -31367,8 +31027,6 @@ snapshots: ts-dedent@2.2.0: {} - ts-expose-internals-conditionally@1.0.0-empty.0: {} - ts-interface-checker@0.1.13: {} ts-pnp@1.2.0(typescript@5.6.3): @@ -31532,7 +31190,7 @@ snapshots: - eslint - supports-color - typescript@5.3.3: {} + typescript@5.6.1-rc: {} typescript@5.6.3: {} @@ -31557,11 +31215,6 @@ snapshots: has-symbols: 1.0.3 which-boxed-primitive: 1.0.2 - unbzip2-stream@1.4.3: - dependencies: - buffer: 5.7.1 - through: 2.3.8 - undici-types@6.19.8: {} undici@6.19.2: {} @@ -31575,8 +31228,6 @@ snapshots: unicode-canonical-property-names-ecmascript: 2.0.0 unicode-property-aliases-ecmascript: 2.1.0 - unicode-match-property-value-ecmascript@2.1.0: {} - unicode-match-property-value-ecmascript@2.2.0: {} unicode-property-aliases-ecmascript@2.1.0: {} @@ -31814,8 +31465,6 @@ snapshots: punycode: 1.4.1 qs: 6.13.0 - urlpattern-polyfill@10.0.0: {} - use-callback-ref@1.3.2(@types/react@18.3.12)(react@18.3.1): dependencies: react: 18.3.1 @@ -32398,36 +32047,6 @@ snapshots: transitivePeerDependencies: - supports-color - webpack@5.95.0: - dependencies: - '@types/estree': 1.0.6 - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/wasm-edit': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 - acorn: 8.13.0 - acorn-import-attributes: 1.9.5(acorn@8.13.0) - browserslist: 4.24.2 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.17.1 - es-module-lexer: 1.5.4 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 3.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(webpack@5.95.0) - watchpack: 2.4.2 - webpack-sources: 3.2.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - webpack@5.95.0(esbuild@0.23.1): dependencies: '@types/estree': 1.0.6 @@ -32644,8 +32263,6 @@ snapshots: ws@7.5.10: {} - ws@8.16.0: {} - ws@8.17.1: {} ws@8.18.0: {} @@ -32754,11 +32371,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - yocto-queue@0.1.0: {} yocto-queue@1.1.1: {} @@ -32773,8 +32385,6 @@ snapshots: dependencies: zod: 3.23.8 - zod@3.22.4: {} - zod@3.23.8: {} zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4bfe0befb..10da0f192 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: - - "packages/*" - - "examples/*" - - "docs" + - 'packages/*' + - 'examples/*' + - 'docs' + - 'tools' diff --git a/tools/eslint.config.mjs b/tools/eslint.config.mjs new file mode 100644 index 000000000..0a6518b8f --- /dev/null +++ b/tools/eslint.config.mjs @@ -0,0 +1,8 @@ +import {getPresets} from 'eslint-config-molindo'; +import globals from 'globals'; + +export default (await getPresets('javascript')).concat({ + languageOptions: { + globals: globals.node + } +}); diff --git a/tools/package.json b/tools/package.json new file mode 100644 index 000000000..5e20d0de4 --- /dev/null +++ b/tools/package.json @@ -0,0 +1,28 @@ +{ + "name": "tools", + "type": "module", + "version": "1.0.0", + "description": "Shared tools for the repo", + "main": "src/index.js", + "scripts": { + "lint": "eslint src" + }, + "keywords": [], + "author": "Jan Amann", + "license": "MIT", + "devDependencies": { + "@babel/core": "^7.24.7", + "@babel/preset-env": "^7.24.7", + "@babel/preset-react": "^7.24.7", + "@babel/preset-typescript": "^7.24.7", + "@rollup/plugin-babel": "^6.0.3", + "@rollup/plugin-node-resolve": "^15.2.1", + "@rollup/plugin-replace": "^5.0.7", + "@rollup/plugin-terser": "^0.4.3", + "eslint-config-molindo": "^8.0.0", + "eslint": "^9.11.1", + "execa": "^9.2.0", + "globals": "^15.11.0", + "rollup": "^4.18.0" + } +} diff --git a/scripts/getBuildConfig.mjs b/tools/src/getBuildConfig.js similarity index 50% rename from scripts/getBuildConfig.mjs rename to tools/src/getBuildConfig.js index be13c66f7..08e7fd226 100644 --- a/scripts/getBuildConfig.mjs +++ b/tools/src/getBuildConfig.js @@ -1,7 +1,4 @@ -/* eslint-env node */ -import fs from 'fs'; import {babel} from '@rollup/plugin-babel'; -import commonjs from '@rollup/plugin-commonjs'; import resolve, { DEFAULTS as resolveDefaults } from '@rollup/plugin-node-resolve'; @@ -13,36 +10,41 @@ const extensions = [...resolveDefaults.extensions, '.tsx']; const outDir = 'dist/'; -function writeEnvIndex(input) { - Object.keys(input).forEach((key) => { - fs.writeFileSync( - `./${outDir}${key}.js`, - `'use strict'; - -if (process.env.NODE_ENV === 'production') { - module.exports = require('./production/${key}.js'); -} else { - module.exports = require('./development/${key}.js'); -} -` - ); - }); -} - async function buildTypes() { - await execa( - 'tsc', - '--noEmit false --emitDeclarationOnly true --outDir dist/types'.split(' ') - ); + await execa('tsc', '-p tsconfig.build.json'.split(' ')); + // eslint-disable-next-line no-console console.log('\ncreated types'); } -export default function getConfig({ +function ignoreSideEffectImports(imports) { + // Rollup somehow leaves a few imports in the bundle that + // would only be relevant if they had side effects. + + const pattern = imports + .map((importName) => `import\\s*['"]${importName}['"];?`) + .join('|'); + const regex = new RegExp(pattern, 'g'); + + return { + name: 'ignore-side-effect-imports', + generateBundle(outputOptions, bundle) { + if (imports.length === 0) return; + + for (const [fileName, file] of Object.entries(bundle)) { + if (file.type === 'chunk' && fileName.endsWith('.js')) { + file.code = file.code.replace(regex, ''); + } + } + } + }; +} + +function getBundleConfig({ env, external = [], input, - output, + output = undefined, plugins = [], ...rest }) { @@ -51,12 +53,8 @@ export default function getConfig({ input, external: [/node_modules/, ...external], output: { - dir: outDir + env, - format: 'cjs', - interop: 'auto', - freeze: false, - esModule: true, - exports: 'named', + dir: outDir + 'esm/' + env, + format: 'es', ...output }, treeshake: { @@ -66,26 +64,21 @@ export default function getConfig({ }, plugins: [ resolve({extensions}), - commonjs(), babel({ babelHelpers: 'bundled', extensions, presets: [ '@babel/preset-typescript', - '@babel/preset-react', + ['@babel/preset-react', {runtime: 'automatic'}], [ '@babel/preset-env', { - targets: { - // Same as https://nextjs.org/docs/architecture/supported-browsers#browserslist - browsers: [ - 'chrome 64', - 'edge 79', - 'firefox 67', - 'opera 51', - 'safari 12' - ] - } + // > 0.5%, last 2 versions, Firefox ESR, not dead + targets: 'defaults', + + // Maybe a bug in browserslist? This is required for + // ios<16.3, but MDN says it's available from Safari 10 + exclude: ['transform-parameters'] } ] ] @@ -94,11 +87,11 @@ export default function getConfig({ 'process.env.NODE_ENV': JSON.stringify(env), preventAssignment: true }), + ignoreSideEffectImports(external), env !== 'development' && terser(), { buildEnd() { if (env === 'production') { - writeEnvIndex(input); buildTypes(); } } @@ -110,3 +103,8 @@ export default function getConfig({ return config; } + +export default function getConfig(config) { + const envNames = config.env || ['development', 'production']; + return envNames.map((env) => getBundleConfig({...config, env})); +} diff --git a/tools/src/index.js b/tools/src/index.js new file mode 100644 index 000000000..77141c6ed --- /dev/null +++ b/tools/src/index.js @@ -0,0 +1 @@ +export {default as getBuildConfig} from './getBuildConfig.js'; From 5b5f0e384c4278d4aae418d4e541fd4588b3c157 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 29 Oct 2024 11:27:19 +0100 Subject: [PATCH 14/90] feat!: Remove deprecated APIs (#1479) **Changes** - Removed deprecated `defaultTranslationValues` - Remove deprecated detection of `src/i18n.ts` (use `src/i18n/request.ts` instead, or specify a custom path in your Next.js config) - Removed deprecated export of `Pathnames` from `next-intl/navigation` (still available via `next-intl/routing`) - Remove deprecated legacy navigation APIs `createSharedPathnamesNavigation` and `createLocalizedPathnamesNavigation` --- .../environments/server-client-components.mdx | 1 - docs/src/pages/docs/routing/navigation.mdx | 11 - docs/src/pages/docs/usage/configuration.mdx | 53 +- .../src/i18n/routing.public.ts | 4 +- packages/next-intl/.size-limit.ts | 14 +- ...reateLocalizedPathnamesNavigation.test.tsx | 758 ------------------ .../createSharedPathnamesNavigation.test.tsx | 531 ------------ .../react-client/ClientLink.test.tsx | 336 -------- .../navigation/react-client/ClientLink.tsx | 73 -- ...reateLocalizedPathnamesNavigation.test.tsx | 538 ------------- .../createLocalizedPathnamesNavigation.tsx | 204 ----- .../createSharedPathnamesNavigation.test.tsx | 183 ----- .../createSharedPathnamesNavigation.tsx | 88 -- .../src/navigation/react-client/index.tsx | 11 - .../src/navigation/react-client/redirects.tsx | 28 - .../react-client/useBaseRouter.test.tsx | 238 ------ .../navigation/react-client/useBaseRouter.tsx | 105 --- .../navigation/react-server/ServerLink.tsx | 39 - .../createLocalizedPathnamesNavigation.tsx | 125 --- .../createSharedPathnamesNavigation.tsx | 74 -- .../src/navigation/react-server/index.tsx | 2 - .../src/navigation/react-server/redirects.tsx | 16 - .../src/navigation/shared/LegacyBaseLink.tsx | 72 -- .../src/navigation/shared/redirects.test.tsx | 83 -- .../src/navigation/shared/redirects.tsx | 44 - packages/next-intl/src/plugin.tsx | 21 - packages/next-intl/src/routing/config.tsx | 4 +- packages/next-intl/src/shared/utils.tsx | 56 -- packages/next-intl/types/index.d.ts | 6 + packages/use-intl/.size-limit.ts | 6 +- packages/use-intl/src/core/IntlConfig.tsx | 8 - .../src/core/createBaseTranslator.tsx | 18 +- .../use-intl/src/core/createTranslator.tsx | 2 +- packages/use-intl/src/react/IntlProvider.tsx | 4 - .../src/react/useTranslations.test.tsx | 83 -- .../src/react/useTranslationsImpl.tsx | 3 - packages/use-intl/types/index.d.ts | 6 + 37 files changed, 33 insertions(+), 3815 deletions(-) delete mode 100644 packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx delete mode 100644 packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx delete mode 100644 packages/next-intl/src/navigation/react-client/ClientLink.test.tsx delete mode 100644 packages/next-intl/src/navigation/react-client/ClientLink.tsx delete mode 100644 packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx delete mode 100644 packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx delete mode 100644 packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.test.tsx delete mode 100644 packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx delete mode 100644 packages/next-intl/src/navigation/react-client/redirects.tsx delete mode 100644 packages/next-intl/src/navigation/react-client/useBaseRouter.test.tsx delete mode 100644 packages/next-intl/src/navigation/react-client/useBaseRouter.tsx delete mode 100644 packages/next-intl/src/navigation/react-server/ServerLink.tsx delete mode 100644 packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx delete mode 100644 packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx delete mode 100644 packages/next-intl/src/navigation/react-server/redirects.tsx delete mode 100644 packages/next-intl/src/navigation/shared/LegacyBaseLink.tsx delete mode 100644 packages/next-intl/src/navigation/shared/redirects.test.tsx delete mode 100644 packages/next-intl/src/navigation/shared/redirects.tsx diff --git a/docs/src/pages/docs/environments/server-client-components.mdx b/docs/src/pages/docs/environments/server-client-components.mdx index 6881e02cb..cccba84e4 100644 --- a/docs/src/pages/docs/environments/server-client-components.mdx +++ b/docs/src/pages/docs/environments/server-client-components.mdx @@ -366,7 +366,6 @@ The component accepts the following props that are not serializable: 1. [`onError`](/docs/usage/configuration#error-handling) 2. [`getMessageFallback`](/docs/usage/configuration#error-handling) -3. Rich text elements for [`defaultTranslationValues`](/docs/usage/configuration#default-translation-values) To configure these, you can wrap `NextIntlClientProvider` with another component that is marked with `'use client'` and defines the relevant props. diff --git a/docs/src/pages/docs/routing/navigation.mdx b/docs/src/pages/docs/routing/navigation.mdx index c277ce2d6..87a9aabaa 100644 --- a/docs/src/pages/docs/routing/navigation.mdx +++ b/docs/src/pages/docs/routing/navigation.mdx @@ -373,14 +373,3 @@ const pathname = getPathname({ } }); ``` - -## Legacy APIs - -`next-intl@3.0.0` brought the first release of the navigation APIs with these functions: - -- `createSharedPathnamesNavigation` -- `createLocalizedPathnamesNavigation` - -As part of `next-intl@3.22.0`, these functions have been replaced by a single `createNavigation` function, which unifies the API for both use cases and also fixes a few quirks in the previous APIs. Going forward, `createNavigation` is recommended and the previous functions are marked as deprecated. - -While `createNavigation` is mostly API-compatible, there are some minor differences that should be noted. Please refer to the [3.22 announcement post](/blog/next-intl-3-22#create-navigation) for full details. diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx index 770089caa..dd2055702 100644 --- a/docs/src/pages/docs/usage/configuration.mdx +++ b/docs/src/pages/docs/usage/configuration.mdx @@ -126,15 +126,14 @@ These props are inherited if you're rendering `NextIntlClientProvider` from a Se In contrast, these props can be provided as necessary: 1. `messages` (see [Internationalization in Client Components](/docs/environments/server-client-components#using-internationalization-in-client-components)) -2. `defaultTranslationValues` -3. `onError` and `getMessageFallback` +2. `onError` and `getMessageFallback` Additionally, nested instances of `NextIntlClientProvider` will inherit configuration from their respective ancestors. Note however that individual props are treated as atomic, therefore e.g. `messages` need to be merged manually—if necessary.
How can I provide non-serializable props like `onError` to `NextIntlClientProvider`? -React limits the types of props that can be passed to Client Components to the ones that are [serializable](https://react.dev/reference/rsc/use-client#serializable-types). Since `onError`, `getMessageFallback` and `defaultTranslationValues` can receive functions, these configuration options can't be automatically inherited by the client side. +React limits the types of props that can be passed to Client Components to the ones that are [serializable](https://react.dev/reference/rsc/use-client#serializable-types). Since `onError` and `getMessageFallback` can receive functions, these configuration options can't be automatically inherited by the client side. In order to define these values on the client side, you can add a provider that defines these props: @@ -524,54 +523,6 @@ function Component() { Formats are automatically inherited from the server side if you wrap the relevant components in a `NextIntlClientProvider` that is rendered by a Server Component. -## Default translation values (deprecated) [#default-translation-values] - - - This feature is deprecated and will be removed in the next major version of `next-intl` ([alternative](/docs/usage/messages#rich-text-reuse-tags)). - - - -To achieve consistent usage of translation values and reduce redundancy, you can define a set of global default values. This configuration can also be used to apply consistent styling of commonly used rich text elements. - - - - -```tsx filename="i18n/request.tsx" -import {getRequestConfig} from 'next-intl/server'; - -export default getRequestConfig(async () => { - return { - defaultTranslationValues: { - important: (chunks) => {chunks}, - value: 123 - } - - // ... - }; -}); -``` - -Note that `defaultTranslationValues` are not automatically inherited by Client Components. If you want to make this available in Client Components, you should provide the same configuration to [`NextIntlClientProvider`](#nextintlclientprovider). - - - - -```tsx - {chunks}, - value: 123 - }} -> - ... - -``` - -Note that `NextIntlClientProvider` is a Client Component, therefore if you render it from a Server Component, the props need to be serializable across the server/client boundary (see: [How can I provide non-serializable props to `NextIntlClientProvider`](#nextintlclientprovider-non-serializable-props)). - - - - ## Error handling (`onError` & `getMessageFallback`) [#error-handling] By default, when a message fails to resolve or when the formatting failed, an error will be printed on the console. In this case `${namespace}.${key}` will be rendered instead to keep your app running. diff --git a/examples/example-app-router-mixed-routing/src/i18n/routing.public.ts b/examples/example-app-router-mixed-routing/src/i18n/routing.public.ts index 1a5bcafe9..f7042682b 100644 --- a/examples/example-app-router-mixed-routing/src/i18n/routing.public.ts +++ b/examples/example-app-router-mixed-routing/src/i18n/routing.public.ts @@ -1,4 +1,4 @@ -import {createSharedPathnamesNavigation} from 'next-intl/navigation'; +import {createNavigation} from 'next-intl/navigation'; import {defineRouting} from 'next-intl/routing'; import {defaultLocale, locales} from '../config'; @@ -8,4 +8,4 @@ export const routing = defineRouting({ }); // Should only be used on public routes in the `[locale]` segment -export const {Link, usePathname} = createSharedPathnamesNavigation(routing); +export const {Link, usePathname} = createNavigation(routing); diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 497863f29..3157b9a91 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -4,30 +4,30 @@ const config: SizeLimitConfig = [ { name: "import * from 'next-intl' (react-client, production)", path: 'dist/esm/production/index.react-client.js', - limit: '13.205 KB' + limit: '13.105 KB' }, { name: "import {NextIntlClientProvider} from 'next-intl' (react-client, production)", import: '{NextIntlClientProvider}', path: 'dist/esm/production/index.react-client.js', - limit: '1.035 KB' + limit: '1 KB' }, { name: "import * from 'next-intl' (react-server, production)", path: 'dist/esm/production/index.react-server.js', - limit: '14.165 KB' + limit: '14.075 KB' }, { name: "import {createNavigation} from 'next-intl/navigation' (react-client, production)", path: 'dist/esm/production/navigation.react-client.js', import: '{createNavigation}', - limit: '2.525 KB' + limit: '2.505 KB' }, { name: "import {createNavigation} from 'next-intl/navigation' (react-server, production)", path: 'dist/esm/production/navigation.react-server.js', import: '{createNavigation}', - limit: '3.425 KB' + limit: '3.385 KB' }, { name: "import * from 'next-intl/server' (react-client, production)", @@ -37,12 +37,12 @@ const config: SizeLimitConfig = [ { name: "import * from 'next-intl/server' (react-server, production)", path: 'dist/esm/production/server.react-server.js', - limit: '13.385 KB' + limit: '13.325 KB' }, { name: "import * from 'next-intl/middleware' (production)", path: 'dist/esm/production/middleware.js', - limit: '9.295 KB' + limit: '9.305 KB' }, { name: "import * from 'next-intl/routing' (production)", diff --git a/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx deleted file mode 100644 index 5cc7a78b9..000000000 --- a/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx +++ /dev/null @@ -1,758 +0,0 @@ -import {render, screen} from '@testing-library/react'; -import { - RedirectType, - permanentRedirect as nextPermanentRedirect, - redirect as nextRedirect, - usePathname as useNextPathname, - useParams -} from 'next/navigation.js'; -import {renderToString} from 'react-dom/server'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {Pathnames, defineRouting} from '../routing.tsx'; -import {getRequestLocale} from '../server/react-server/RequestLocaleLegacy.tsx'; -import {getLocalePrefix} from '../shared/utils.tsx'; -import createLocalizedPathnamesNavigationClient from './react-client/createLocalizedPathnamesNavigation.tsx'; -import createLocalizedPathnamesNavigationServer from './react-server/createLocalizedPathnamesNavigation.tsx'; -import LegacyBaseLink from './shared/LegacyBaseLink.tsx'; - -vi.mock('next/navigation.js', async () => { - const actual = await vi.importActual('next/navigation.js'); - return { - ...actual, - usePathname: vi.fn(), - useParams: vi.fn(), - redirect: vi.fn(), - permanentRedirect: vi.fn() - }; -}); -vi.mock('next-intl/config', () => ({ - default: async () => - ((await vi.importActual('../../src/server')) as any).getRequestConfig({ - locale: 'en' - }) -})); -vi.mock('react'); -// Avoids handling an async component (not supported by renderToString) -vi.mock('../../src/navigation/react-server/ServerLink', () => ({ - default({locale, localePrefix, ...rest}: any) { - const finalLocale = locale || 'en'; - const prefix = getLocalePrefix(finalLocale, localePrefix); - return ( - - ); - } -})); -vi.mock('../../src/server/react-server/RequestLocaleLegacy', () => ({ - getRequestLocale: vi.fn(() => 'en') -})); - -beforeEach(() => { - // usePathname from Next.js returns the pathname the user sees - // (i.e. the external one that might be localized) - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - vi.mocked(getRequestLocale).mockImplementation(() => 'en'); - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); -}); - -const locales = ['en', 'de', 'ja'] as const; - -const pathnames = { - '/': '/', - '/about': { - en: '/about', - de: '/ueber-uns', - ja: '/約' - }, - '/news/[articleSlug]-[articleId]': { - en: '/news/[articleSlug]-[articleId]', - de: '/neuigkeiten/[articleSlug]-[articleId]', - ja: '/ニュース/[articleSlug]-[articleId]' - }, - '/categories/[...parts]': { - en: '/categories/[...parts]', - de: '/kategorien/[...parts]', - ja: '/カテゴリ/[...parts]' - }, - '/catch-all/[[...parts]]': '/catch-all/[[...parts]]' -} satisfies Pathnames; - -describe.each([ - { - env: 'react-client', - implementation: createLocalizedPathnamesNavigationClient - }, - { - env: 'react-server', - implementation: createLocalizedPathnamesNavigationServer - } -])( - 'createLocalizedPathnamesNavigation ($env)', - ({implementation: createLocalizedPathnamesNavigation}) => { - describe("localePrefix: 'always'", () => { - const routing = defineRouting({ - locales, - defaultLocale: 'en', - pathnames, - localePrefix: 'always' - }); - const {Link} = createLocalizedPathnamesNavigation(routing); - describe('Link', () => { - it('renders a prefix for the default locale', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); - }); - - it('renders a prefix for a different locale', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/de/ueber-uns"'); - }); - - it('renders an object href', () => { - render( - About - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/about?foo=bar'); - }); - }); - }); - - describe("localePrefix: 'always', custom prefixes", () => { - const pathnamesCustomPrefixes = { - '/': '/', - '/about': { - en: '/about', - 'de-at': '/ueber-uns' - } - } as const; - const {Link, getPathname, redirect} = createLocalizedPathnamesNavigation({ - locales: ['en', 'de-at'] as const, - pathnames: pathnamesCustomPrefixes, - localePrefix: { - mode: 'always', - prefixes: { - 'de-at': '/de' - } - } as const - }); - - describe('Link', () => { - it('handles a locale without a custom prefix', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); - }); - - it('handles a locale with a custom prefix', () => { - const markup = renderToString( - - About - - ); - expect(markup).toContain('href="/de/ueber-uns"'); - }); - - it('handles a locale with a custom prefix on an object href', () => { - render( - - About - - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/de/ueber-uns?foo=bar'); - }); - }); - - describe('getPathname', () => { - it('resolves to the correct path', () => { - expect( - getPathname({ - locale: 'de-at', - href: '/about' - }) - ).toBe('/ueber-uns'); - }); - }); - - describe('redirect', () => { - function Component< - Pathname extends keyof typeof pathnamesCustomPrefixes - >({href}: {href: Parameters>[0]}) { - redirect(href); - return null; - } - - it('can redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en'); - }); - - it('can redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({ - locale: 'de-at' - })); - vi.mocked(getRequestLocale).mockImplementation(() => 'de-at'); - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/de'); - }); - }); - }); - - describe("localePrefix: 'as-needed'", () => { - const {Link, getPathname, permanentRedirect, redirect} = - createLocalizedPathnamesNavigation({ - locales, - pathnames, - localePrefix: 'as-needed' - }); - - describe('Link', () => { - it('renders a prefix for the default locale initially', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); - }); - - it("doesn't render a prefix for the default locale eventually", () => { - render(Über uns); - expect(screen.getByText('Über uns').getAttribute('href')).toBe( - '/about' - ); - }); - - it('adds a prefix when linking to a non-default locale', () => { - render( - - Über uns - - ); - expect( - screen.getByRole('link', {name: 'Über uns'}).getAttribute('href') - ).toBe('/de/ueber-uns'); - }); - - it('handles params', () => { - render( - - About - - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/de/neuigkeiten/launch-party-3'); - }); - - it('handles catch-all segments', () => { - render( - - Test - - ); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/categories/clothing/t-shirts'); - }); - - it('handles optional catch-all segments', () => { - render( - - Test - - ); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/catch-all/one/two'); - }); - - it('supports optional search params', () => { - render( - - Test - - ); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/about?foo=bar&bar=1&bar=2'); - }); - - it('handles unknown routes', () => { - // @ts-expect-error -- Unknown route - const {rerender} = render(Unknown); - expect( - screen.getByRole('link', {name: 'Unknown'}).getAttribute('href') - ).toBe('/unknown'); - - rerender( - // @ts-expect-error -- Unknown route - - Unknown - - ); - expect( - screen.getByRole('link', {name: 'Unknown'}).getAttribute('href') - ).toBe('/de/unknown'); - }); - }); - - describe('redirect', () => { - function Component({ - href - }: { - href: Parameters>[0]; - }) { - redirect(href); - return null; - } - - it('can redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en/about'); - - rerender( - - ); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/en/news/launch-party-3' - ); - }); - - it('can redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/de'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/de/ueber-uns'); - - rerender( - - ); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/de/neuigkeiten/launch-party-3' - ); - }); - - it('supports optional search params', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render( - - ); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/en?foo=bar&bar=1&bar=2' - ); - }); - - it('handles unknown routes', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - // @ts-expect-error -- Unknown route - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en/unknown'); - }); - - it('can supply a type', () => { - function Test() { - redirect('/', RedirectType.push); - return null; - } - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en', 'push'); - }); - }); - - describe('permanentRedirect', () => { - function Component({ - href - }: { - href: Parameters>[0]; - }) { - permanentRedirect(href); - return null; - } - - it('can permanently redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en/about'); - - rerender( - - ); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/en/news/launch-party-3' - ); - }); - - it('can permanently redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/de'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/de/ueber-uns' - ); - - rerender( - - ); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/de/neuigkeiten/launch-party-3' - ); - }); - - it('supports optional search params', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render( - - ); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/en?foo=bar&bar=1&bar=2' - ); - }); - - it('handles unknown routes', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - // @ts-expect-error -- Unknown route - render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en/unknown'); - }); - - it('can supply a type', () => { - function Test() { - permanentRedirect('/', RedirectType.push); - return null; - } - render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en', 'push'); - }); - }); - - describe('getPathname', () => { - it('resolves to the correct path', () => { - expect( - getPathname({ - locale: 'en', - href: { - pathname: '/categories/[...parts]', - params: {parts: ['clothing', 't-shirts']}, - query: {sort: 'price'} - } - }) - ).toBe('/categories/clothing/t-shirts?sort=price'); - }); - - it('handles foreign symbols', () => { - expect( - getPathname({ - locale: 'ja', - href: { - pathname: '/about', - query: {foo: 'bar'} - } - }) - ).toBe('/約?foo=bar'); - }); - }); - }); - - describe("localePrefix: 'never'", () => { - const {Link, permanentRedirect, redirect} = - createLocalizedPathnamesNavigation({ - pathnames, - locales, - localePrefix: 'never' - }); - - describe('Link', () => { - it("doesn't render a prefix for the default locale", () => { - const markup = renderToString(About); - expect(markup).toContain('href="/about"'); - }); - - it('renders a prefix for a different locale', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/de/ueber-uns"'); - }); - }); - - describe('redirect', () => { - function Component({ - href - }: { - href: Parameters>[0]; - }) { - redirect(href); - return null; - } - - it('can redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/about'); - - rerender( - - ); - expect(nextRedirect).toHaveBeenLastCalledWith('/news/launch-party-3'); - }); - - it('can redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/ueber-uns'); - - rerender( - - ); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/neuigkeiten/launch-party-3' - ); - }); - - it('supports optional search params', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render( - - ); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/?foo=bar&bar=1&bar=2' - ); - }); - - it('handles unknown routes', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - // @ts-expect-error -- Unknown route - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/unknown'); - }); - }); - - describe('permanentRedirect', () => { - function Component({ - href - }: { - href: Parameters>[0]; - }) { - permanentRedirect(href); - return null; - } - - it('can permanently redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/about'); - - rerender( - - ); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/news/launch-party-3' - ); - }); - - it('can permanently redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/ueber-uns'); - - rerender( - - ); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/neuigkeiten/launch-party-3' - ); - }); - - it('supports optional search params', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render( - - ); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/?foo=bar&bar=1&bar=2' - ); - }); - - it('handles unknown routes', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - // @ts-expect-error -- Unknown route - render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/unknown'); - }); - }); - }); - - describe('type tests', () => { - it('requires `pathnames`', () => { - // @ts-expect-error -- Missing pathnames - createLocalizedPathnamesNavigation({locales}); - }); - }); - } -); diff --git a/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx deleted file mode 100644 index 732027847..000000000 --- a/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx +++ /dev/null @@ -1,531 +0,0 @@ -import {render, screen} from '@testing-library/react'; -import { - RedirectType, - permanentRedirect as nextPermanentRedirect, - redirect as nextRedirect, - usePathname as useNextPathname, - useParams -} from 'next/navigation.js'; -import {renderToString} from 'react-dom/server'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {defineRouting} from '../routing.tsx'; -import {getRequestLocale} from '../server/react-server/RequestLocaleLegacy.tsx'; -import {getLocalePrefix} from '../shared/utils.tsx'; -import createSharedPathnamesNavigationClient from './react-client/createSharedPathnamesNavigation.tsx'; -import createSharedPathnamesNavigationServer from './react-server/createSharedPathnamesNavigation.tsx'; -import LegacyBaseLink from './shared/LegacyBaseLink.tsx'; - -vi.mock('next/navigation.js', async () => { - const actual = await vi.importActual('next/navigation.js'); - return { - ...actual, - useParams: vi.fn(() => ({locale: 'en'})), - usePathname: vi.fn(() => '/'), - redirect: vi.fn(), - permanentRedirect: vi.fn() - }; -}); -vi.mock('next-intl/config', () => ({ - default: async () => - ((await vi.importActual('../../src/server')) as any).getRequestConfig({ - locale: 'en' - }) -})); -vi.mock('react'); -// Avoids handling an async component (not supported by renderToString) -vi.mock('../../src/navigation/react-server/ServerLink', () => ({ - default({locale, localePrefix, ...rest}: any) { - const finalLocale = locale || 'en'; - const prefix = getLocalePrefix(finalLocale, localePrefix); - return ( - - ); - } -})); -vi.mock('../../src/server/react-server/RequestLocaleLegacy', () => ({ - getRequestLocale: vi.fn(() => 'en') -})); - -beforeEach(() => { - vi.mocked(getRequestLocale).mockImplementation(() => 'en'); - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); -}); - -const locales = ['en', 'de'] as const; -const localesWithCustomPrefixes = ['en', 'en-gb'] as const; -const customizedPrefixes = { - 'en-gb': '/uk' -}; - -describe.each([ - {env: 'react-client', implementation: createSharedPathnamesNavigationClient}, - {env: 'react-server', implementation: createSharedPathnamesNavigationServer} -])( - 'createSharedPathnamesNavigation ($env)', - ({implementation: createSharedPathnamesNavigation}) => { - describe("localePrefix: 'always'", () => { - const routing = defineRouting({ - locales, - defaultLocale: 'en', - localePrefix: 'always' - }); - const {Link} = createSharedPathnamesNavigation(routing); - - describe('Link', () => { - it('renders a prefix for the default locale', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); - }); - - it('renders a prefix for a different locale', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/de/about"'); - }); - - it('renders an object href', () => { - render( - About - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/about?foo=bar'); - }); - - it('handles params', () => { - render( - - About - - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/de/news/launch-party-3'); - }); - - it('handles relative links correctly on the initial render', () => { - const markup = renderToString(Test); - expect(markup).toContain('href="test"'); - }); - }); - }); - - describe("localePrefix: 'always', custom prefixes", () => { - const {Link, redirect} = createSharedPathnamesNavigation({ - locales: localesWithCustomPrefixes, - localePrefix: { - mode: 'always', - prefixes: customizedPrefixes - } - }); - - describe('Link', () => { - it('handles a locale without a custom prefix', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); - }); - - it('handles a locale with a custom prefix', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/uk/about"'); - }); - - it('handles a locale with a custom prefix on an object href', () => { - render( - - About - - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/uk/about?foo=bar'); - }); - }); - - describe('redirect', () => { - function Component({href}: {href: string}) { - redirect(href); - return null; - } - - it('can redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en'); - }); - - it('can redirect to a relative pathname', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/en/about'); - render(); - expect(nextRedirect).toHaveBeenCalledWith('test'); - }); - - it('can redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({ - locale: 'en-gb' - })); - vi.mocked(getRequestLocale).mockImplementation(() => 'en-gb'); - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/uk'); - }); - }); - }); - - describe("localePrefix: 'as-needed'", () => { - const {Link, permanentRedirect, redirect} = - createSharedPathnamesNavigation({ - locales, - localePrefix: 'as-needed' - }); - - describe('Link', () => { - it('renders a prefix for the default locale initially', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); - }); - - it("doesn't render a prefix for the default locale eventually", () => { - render(Über uns); - expect(screen.getByText('Über uns').getAttribute('href')).toBe( - '/about' - ); - }); - - it('renders a prefix for a different locale', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/de/about"'); - }); - }); - - describe('redirect', () => { - function Component({href}: {href: string}) { - redirect(href); - return null; - } - - it('can redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en/about'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/en/news/launch-party-3' - ); - }); - - it('can redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/de'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/de/about'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/de/news/launch-party-3' - ); - }); - - it('supports optional search params', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render(); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/en?foo=bar&bar=1&bar=2' - ); - }); - - it('can supply a type', () => { - function Test() { - redirect('/', RedirectType.push); - return null; - } - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en', 'push'); - }); - }); - - describe('permanentRedirect', () => { - function Component({href}: {href: string}) { - permanentRedirect(href); - return null; - } - - it('can permanently redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en/about'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/en/news/launch-party-3' - ); - }); - - it('can permanently redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/de'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/de/about'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/de/news/launch-party-3' - ); - }); - - it('supports optional search params', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/en?foo=bar&bar=1&bar=2' - ); - }); - - it('can supply a type', () => { - function Test() { - permanentRedirect('/', RedirectType.push); - return null; - } - render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en', 'push'); - }); - }); - }); - - describe("localePrefix: 'as-needed', custom prefixes", () => { - const {Link, permanentRedirect, redirect} = - createSharedPathnamesNavigation({ - locales: localesWithCustomPrefixes, - localePrefix: {mode: 'as-needed', prefixes: customizedPrefixes} - }); - - describe('Link', () => { - it('renders a prefix for a locale with a custom prefix', () => { - const markup = renderToString( - - About - - ); - expect(markup).toContain('href="/uk/about"'); - }); - }); - - describe('redirect', () => { - function Component({href}: {href: string}) { - redirect(href); - return null; - } - - it('can redirect for a locale with a custom prefix', () => { - vi.mocked(useParams).mockImplementation(() => ({ - locale: 'en-gb' - })); - vi.mocked(getRequestLocale).mockImplementation(() => 'en-gb'); - - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/uk'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/uk/about'); - }); - }); - - describe('permanentRedirect', () => { - function Component({href}: {href: string}) { - permanentRedirect(href); - return null; - } - - it('can permanently redirect for a locale with a custom prefix', () => { - vi.mocked(useParams).mockImplementation(() => ({ - locale: 'en-gb' - })); - vi.mocked(getRequestLocale).mockImplementation(() => 'en-gb'); - - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/uk'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/uk/about'); - }); - }); - }); - - describe("localePrefix: 'never'", () => { - const {Link, permanentRedirect, redirect} = - createSharedPathnamesNavigation({ - locales, - localePrefix: 'never' - }); - - describe('Link', () => { - it("doesn't render a prefix for the default locale", () => { - const markup = renderToString(About); - expect(markup).toContain('href="/about"'); - }); - - it('renders a prefix for a different locale', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/de/about"'); - }); - }); - - describe('redirect', () => { - function Component({href}: {href: string}) { - redirect(href); - return null; - } - - it('can redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/about'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/news/launch-party-3'); - }); - - it('can redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/about'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/news/launch-party-3'); - }); - }); - - describe('permanentRedirect', () => { - function Component({href}: {href: string}) { - permanentRedirect(href); - return null; - } - - it('can permanently redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/about'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/news/launch-party-3' - ); - }); - - it('can permanently redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/about'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/news/launch-party-3' - ); - }); - }); - }); - - describe('usage without statically known locales', () => { - const {Link} = createSharedPathnamesNavigation(); - - describe('Link', () => { - it('uses the default locale', () => { - expect(renderToString(About)).toContain( - 'href="/en/about"' - ); - }); - - it('can use a non-default locale', () => { - expect( - renderToString( - - About - - ) - ).toContain('href="/de/about"'); - expect( - renderToString( - - About - - ) - ).toContain('href="/en/about"'); - }); - }); - }); - - describe('type tests', () => { - it("doesn't accept `pathnames`", () => { - createSharedPathnamesNavigation({ - locales: ['en'], - defaultLocale: 'en', - // @ts-expect-error - pathnames: { - '/': '/' - } - }); - }); - }); - } -); diff --git a/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx deleted file mode 100644 index 8905457e9..000000000 --- a/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import {fireEvent, render, screen} from '@testing-library/react'; -import {useParams, usePathname} from 'next/navigation.js'; -import {ComponentProps, LegacyRef, forwardRef} from 'react'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {NextIntlClientProvider} from '../../index.react-client.tsx'; -import {LocalePrefixConfigVerbose} from '../../routing/types.tsx'; -import ClientLink from './ClientLink.tsx'; - -// Note: Once we remove the legacy navigation APIs, this test suite can be -// removed too. All relevant tests have been moved to the new navigation API. - -vi.mock('next/navigation.js'); - -function mockLocation(pathname: string, basePath = '') { - vi.mocked(usePathname).mockReturnValue(pathname); - - delete (global.window as any).location; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - global.window ??= Object.create(window); - (global.window as any).location = {pathname: basePath + pathname}; -} - -const MockClientLink = forwardRef( - ( - { - localePrefix = {mode: 'always'}, - ...rest - }: Omit< - ComponentProps, - 'localePrefix' | 'localeCookie' - > & { - localePrefix?: LocalePrefixConfigVerbose; - }, - ref - ) => ( - } - localeCookie={{ - name: 'NEXT_LOCALE', - maxAge: 31536000, - sameSite: 'lax' - }} - localePrefix={localePrefix} - {...rest} - /> - ) -); -MockClientLink.displayName = 'MockClientLink'; - -describe('unprefixed routing', () => { - beforeEach(() => { - vi.mocked(usePathname).mockImplementation(() => '/'); - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); - }); - - it('renders an href without a locale if the locale matches', () => { - render(Test); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/test' - ); - }); - - it('renders an href without a locale if the locale matches for an object href', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/test?foo=bar' - ); - }); - - it('renders an href with a locale if the locale changes', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/de/test' - ); - }); - - it('renders an href with a locale if the locale changes for an object href', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/de/test' - ); - }); - - it('works for external urls', () => { - render(Test); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - 'https://example.com' - ); - }); - - it('handles relative links', () => { - render(Test); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - 'test' - ); - }); - - it('works for external urls with an object href', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - 'https://example.com/test' - ); - }); - - it('can receive a ref', () => { - let ref; - - render( - { - ref = node; - }} - href="/test" - > - Test - - ); - - expect(ref).toBeDefined(); - }); - - it('sets an hreflang when changing the locale', () => { - render( - - Test - - ); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('hreflang') - ).toBe('de'); - }); - - it('updates the href when the query changes for localePrefix=never', () => { - const {rerender} = render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/' - ); - rerender( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/?foo=bar' - ); - }); - - describe('base path', () => { - beforeEach(() => { - mockLocation('/', '/base/path'); - }); - - it('renders an unprefixed href when staying on the same locale', () => { - render(Test); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/test'); - }); - - it('renders a prefixed href when switching the locale', () => { - render( - - Test - - ); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/de/test'); - }); - }); -}); - -describe('prefixed routing', () => { - beforeEach(() => { - vi.mocked(usePathname).mockImplementation(() => '/en'); - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); - }); - - it('renders an href with a locale if the locale matches', () => { - render(Test); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/en/test' - ); - }); - - it('renders an href without a locale if the locale matches for an object href', () => { - render(Test); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/en/test' - ); - }); - - it('renders an href with a locale if the locale changes', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/de/test' - ); - }); - - it('renders an href with a locale if the locale changes for an object href', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/de/test' - ); - }); - - it('works for external urls', () => { - render(Test); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - 'https://example.com' - ); - }); - - it('works for external urls with an object href', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - 'https://example.com/test' - ); - }); - - describe('base path', () => { - beforeEach(() => { - mockLocation('/en', '/base/path'); - }); - - it('renders an unprefixed href when staying on the same locale', () => { - render(Test); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/en/test'); - }); - - it('renders a prefixed href when switching the locale', () => { - render( - - Test - - ); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/de/test'); - }); - }); -}); - -describe('usage outside of Next.js', () => { - beforeEach(() => { - vi.mocked(useParams).mockImplementation((() => null) as any); - }); - - it('works with a provider', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/en/test' - ); - }); - - it('throws without a provider', () => { - expect(() => - render(Test) - ).toThrow('No intl context found. Have you configured the provider?'); - }); -}); - -describe('cookie sync', () => { - beforeEach(() => { - vi.mocked(usePathname).mockImplementation(() => '/en'); - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); - - mockLocation('/'); - - global.document.cookie = 'NEXT_LOCALE=en'; - }); - - it('keeps the cookie value in sync', () => { - render( - - Test - - ); - expect(document.cookie).toContain('NEXT_LOCALE=en'); - fireEvent.click(screen.getByRole('link', {name: 'Test'})); - expect(document.cookie).toContain('NEXT_LOCALE=de'); - }); -}); diff --git a/packages/next-intl/src/navigation/react-client/ClientLink.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.tsx deleted file mode 100644 index 94f473aa6..000000000 --- a/packages/next-intl/src/navigation/react-client/ClientLink.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import {ComponentProps, ReactElement, forwardRef} from 'react'; -import useLocale from '../../react-client/useLocale.tsx'; -import { - LocalePrefixConfigVerbose, - LocalePrefixMode, - Locales -} from '../../routing/types.tsx'; -import {getLocalePrefix} from '../../shared/utils.tsx'; -import LegacyBaseLink from '../shared/LegacyBaseLink.tsx'; - -type Props< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode -> = Omit< - ComponentProps, - 'locale' | 'prefix' | 'localePrefixMode' -> & { - locale?: AppLocales[number]; - localePrefix: LocalePrefixConfigVerbose; -}; - -function ClientLink< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode ->( - {locale, localePrefix, ...rest}: Props, - ref: Props['ref'] -) { - const defaultLocale = useLocale(); - const finalLocale = locale || defaultLocale; - const prefix = getLocalePrefix(finalLocale, localePrefix); - - return ( - - ); -} - -/** - * Wraps `next/link` and prefixes the `href` with the current locale if - * necessary. - * - * @example - * ```tsx - * import {Link} from 'next-intl'; - * - * // When the user is on `/en`, the link will point to `/en/about` - * About - * - * // You can override the `locale` to switch to another language - * Switch to German - * ``` - * - * Note that when a `locale` prop is passed to switch the locale, the `prefetch` - * prop is not supported. This is because Next.js would prefetch the page and - * the `set-cookie` response header would cause the locale cookie on the current - * page to be overwritten before the user even decides to change the locale. - */ -const ClientLinkWithRef = forwardRef(ClientLink) as < - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode ->( - props: Props & { - ref?: Props['ref']; - } -) => ReactElement; -(ClientLinkWithRef as any).displayName = 'ClientLink'; -export default ClientLinkWithRef; diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx deleted file mode 100644 index 5c6a0f32d..000000000 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx +++ /dev/null @@ -1,538 +0,0 @@ -import {render, screen} from '@testing-library/react'; -import { - usePathname as useNextPathname, - useRouter as useNextRouter, - useParams -} from 'next/navigation.js'; -import {ComponentProps, createRef, useRef} from 'react'; -import {Mock, afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {Pathnames} from '../../routing.tsx'; -import createLocalizedPathnamesNavigation from './createLocalizedPathnamesNavigation.tsx'; - -vi.mock('next/navigation.js'); - -const locales = ['en', 'de', 'ja'] as const; -const pathnames = { - '/': '/', - '/about': { - en: '/about', - de: '/ueber-uns', - ja: '/約' - }, - '/news/[articleSlug]-[articleId]': { - en: '/news/[articleSlug]-[articleId]', - de: '/neuigkeiten/[articleSlug]-[articleId]', - ja: '/ニュース/[articleSlug]-[articleId]' - }, - '/categories/[...parts]': { - en: '/categories/[...parts]', - de: '/kategorien/[...parts]', - ja: '/カテゴリ/[...parts]' - }, - '/categories/new': { - en: '/categories/new', - de: '/kategorien/neu', - ja: '/カテゴリ/新規' - }, - '/catch-all/[[...parts]]': '/catch-all/[[...parts]]' -} satisfies Pathnames; - -beforeEach(() => { - const router = { - push: vi.fn(), - replace: vi.fn(), - prefetch: vi.fn(), - back: vi.fn(), - forward: vi.fn(), - refresh: vi.fn() - }; - vi.mocked(useNextRouter).mockImplementation(() => router); - - // usePathname from Next.js returns the pathname the user sees - // (i.e. the external one that might be localized) - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); -}); - -describe("localePrefix: 'as-needed'", () => { - const {Link, redirect, usePathname, useRouter} = - createLocalizedPathnamesNavigation({ - locales, - pathnames - }); - - describe('Link', () => { - it('supports receiving a ref', () => { - const ref = createRef(); - render(); - expect(ref.current).not.toBe(null); - }); - }); - - describe('usePathname', () => { - it('returns the internal pathname for the default locale', () => { - function Component() { - const pathname = usePathname(); - return <>{pathname}; - } - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - screen.getByText('/'); - - vi.mocked(useNextPathname).mockImplementation(() => '/about'); - rerender(); - screen.getByText('/about'); - - vi.mocked(useNextPathname).mockImplementation( - () => '/news/launch-party-3' - ); - rerender(); - 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'})); - - function Component() { - const pathname = usePathname(); - return <>{pathname}; - } - vi.mocked(useNextPathname).mockImplementation(() => '/de'); - const {rerender} = render(); - screen.getByText('/'); - - vi.mocked(useNextPathname).mockImplementation(() => '/de/ueber-uns'); - rerender(); - screen.getByText('/about'); - - vi.mocked(useNextPathname).mockImplementation( - () => '/de/neuigkeiten/launch-party-3' - ); - rerender(); - screen.getByText('/news/[articleSlug]-[articleId]'); - }); - - it('handles unknown routes', () => { - function Component() { - const pathname = usePathname(); - return <>{pathname}; - } - vi.mocked(useNextPathname).mockImplementation(() => '/en/unknown'); - const {rerender} = render(); - screen.getByText('/unknown'); - - vi.mocked(useNextPathname).mockImplementation(() => '/de/unknown'); - 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() { - // eslint-disable-next-line react-compiler/react-compiler - 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', () => { - it('keeps a stable identity when possible', () => { - function Component() { - const router = useRouter(); - const initialRouter = useRef(router); - // eslint-disable-next-line react-compiler/react-compiler - return String(router === initialRouter.current); - } - const {rerender} = render(); - screen.getByText('true'); - - rerender(); - screen.getByText('true'); - }); - - describe('push', () => { - it('resolves to the correct path when passing another locale', () => { - function Component() { - const router = useRouter(); - router.push('/about', {locale: 'de'}); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/ueber-uns'); - }); - - it('supports optional search params', () => { - function Component() { - const router = useRouter(); - router.push( - { - pathname: '/about', - query: { - foo: 'bar', - bar: [1, 2] - } - }, - {locale: 'de'} - ); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/ueber-uns?foo=bar&bar=1&bar=2'); - }); - - it('passes through unknown options to the Next.js router', () => { - function Component() { - const router = useRouter(); - router.push('/about', {locale: 'de', scroll: false}); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/ueber-uns', {scroll: false}); - }); - }); - - it('handles unknown routes', () => { - function Component() { - const router = useRouter(); - // @ts-expect-error -- Unknown route - router.push('/unknown'); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/unknown'); - }); - }); - - /** - * Type tests - */ - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - function TypeTests() { - const router = useRouter(); - - // @ts-expect-error -- Unknown route - router.push('/unknown'); - - // Valid - router.push('/about'); - router.push('/about', {locale: 'de'}); - router.push({pathname: '/about'}); - router.push('/catch-all/[[...parts]]'); - - // @ts-expect-error -- Requires params - router.push({pathname: '/news/[articleSlug]-[articleId]'}); - - router.push({ - pathname: '/news/[articleSlug]-[articleId]', - // @ts-expect-error -- Missing param - params: { - articleId: 3 - } - }); - - // Valid - router.push({ - pathname: '/news/[articleSlug]-[articleId]', - params: { - articleId: 3, - articleSlug: 'launch-party' - } - }); - - // @ts-expect-error -- Doesn't accept params - router.push({pathname: '/about', params: {foo: 'bar'}}); - - // @ts-expect-error -- Unknown locale - - Über uns - ; - - // @ts-expect-error -- Unknown route - About; - - // @ts-expect-error -- Requires params - About; - // @ts-expect-error -- Requires params - About; - - // @ts-expect-error -- Params for different route - About; - - // @ts-expect-error -- Doesn't accept params - About; - - // @ts-expect-error -- Missing params - Über uns; - - // Valid - Über uns; - Über uns; - - Über uns - ; - Optional catch-all; - - // Link composition - function WrappedLink( - props: ComponentProps> - ) { - return ; - } - About; - - News - ; - - // @ts-expect-error -- Requires params - News; - - // Valid - redirect({pathname: '/about'}); - redirect('/catch-all/[[...parts]]'); - redirect({ - pathname: '/catch-all/[[...parts]]', - params: {parts: ['one', 'two']} - }); - - // @ts-expect-error -- Unknown route - redirect('/unknown'); - // @ts-expect-error -- Localized alternative - redirect('/ueber-uns'); - // @ts-expect-error -- Requires params - redirect('/news/[articleSlug]-[articleId]'); - redirect({ - pathname: '/news/[articleSlug]-[articleId]', - // @ts-expect-error -- Missing param - params: { - articleId: 3 - } - }); - - // Allow unknown routes - const { - Link: LinkWithUnknown, - redirect: redirectWithUnknown, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - usePathname: usePathnameWithUnkown, - useRouter: useRouterWithUnknown - } = createLocalizedPathnamesNavigation({ - locales, - pathnames: pathnames as typeof pathnames & Record - }); - Unknown; - redirectWithUnknown('/unknown'); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const pathnameWithUnknown: ReturnType = - '/unknown'; - useRouterWithUnknown().push('/unknown'); - } -}); - -describe("localePrefix: 'as-needed', custom prefix", () => { - const {usePathname, useRouter} = createLocalizedPathnamesNavigation({ - locales: ['en', 'de-at'] as const, - pathnames: { - '/': '/', - '/about': { - en: '/about', - 'de-at': '/ueber-uns' - } - }, - localePrefix: { - mode: 'always', - prefixes: { - 'de-at': '/de' - } - } - }); - - describe('useRouter', () => { - describe('push', () => { - it('resolves to the correct path when passing a locale with a custom prefix', () => { - function Component() { - const router = useRouter(); - router.push('/about', {locale: 'de-at'}); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/ueber-uns'); - }); - }); - }); - - describe('usePathname', () => { - it('returns the internal pathname for a locale with a custom prefix', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de-at'})); - function Component() { - const pathname = usePathname(); - return <>{pathname}; - } - vi.mocked(useNextPathname).mockImplementation(() => '/de'); - const {rerender} = render(); - screen.getByText('/'); - - vi.mocked(useNextPathname).mockImplementation(() => '/de/ueber-uns'); - rerender(); - screen.getByText('/about'); - }); - }); -}); - -describe("localePrefix: 'never'", () => { - const {useRouter} = createLocalizedPathnamesNavigation({ - pathnames, - locales, - localePrefix: 'never' - }); - - describe('useRouter', () => { - function Component({locale}: {locale?: (typeof locales)[number]}) { - const router = useRouter(); - router.push('/about', {locale}); - return null; - } - - describe('push', () => { - it('can push a pathname for the default locale', () => { - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/about'); - }); - - it('can push a pathname for a secondary locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/ueber-uns'); - }); - - it('resolves to the correct path when passing another locale', () => { - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/ueber-uns'); - }); - }); - }); -}); diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx deleted file mode 100644 index fbff09eb2..000000000 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import {ComponentProps, ReactElement, forwardRef, useMemo} from 'react'; -import useLocale from '../../react-client/useLocale.tsx'; -import { - RoutingConfigLocalizedNavigation, - receiveLocaleCookie, - receiveRoutingConfig -} from '../../routing/config.tsx'; -import { - DomainsConfig, - LocalePrefixMode, - Locales, - Pathnames -} from '../../routing/types.tsx'; -import {ParametersExceptFirst} from '../../shared/types.tsx'; -import { - HrefOrHrefWithParams, - HrefOrUrlObjectWithParams, - compileLocalizedPathname, - getRoute, - normalizeNameOrNameWithParams -} from '../shared/utils.tsx'; -import ClientLink from './ClientLink.tsx'; -import {clientPermanentRedirect, clientRedirect} from './redirects.tsx'; -import useBasePathname from './useBasePathname.tsx'; -import useBaseRouter from './useBaseRouter.tsx'; - -/** - * @deprecated Consider switching to `createNavigation` (see https://next-intl-docs.vercel.app/blog/next-intl-3-22#create-navigation) - **/ -export default function createLocalizedPathnamesNavigation< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode = 'always', - AppPathnames extends Pathnames = never, - AppDomains extends DomainsConfig = never ->( - routing: RoutingConfigLocalizedNavigation< - AppLocales, - AppLocalePrefixMode, - AppPathnames, - AppDomains - > -) { - const config = receiveRoutingConfig(routing); - const localeCookie = receiveLocaleCookie(routing.localeCookie); - - function useTypedLocale(): AppLocales[number] { - const locale = useLocale(); - const isValid = config.locales.includes(locale as any); - if (!isValid) { - throw new Error( - process.env.NODE_ENV !== 'production' - ? `Unknown locale encountered: "${locale}". Make sure to validate the locale in \`i18n.ts\`.` - : undefined - ); - } - return locale; - } - - type LinkProps = Omit< - ComponentProps, - 'href' | 'name' | 'localePrefix' | 'localeCookie' - > & { - href: HrefOrUrlObjectWithParams; - locale?: AppLocales[number]; - }; - function Link( - {href, locale, ...rest}: LinkProps, - ref?: ComponentProps['ref'] - ) { - const defaultLocale = useTypedLocale(); - const finalLocale = locale || defaultLocale; - - return ( - ({ - locale: finalLocale, - // @ts-expect-error -- This is ok - pathname: href, - // @ts-expect-error -- This is ok - params: typeof href === 'object' ? href.params : undefined, - pathnames: config.pathnames - })} - locale={locale} - localeCookie={localeCookie} - localePrefix={config.localePrefix} - {...rest} - /> - ); - } - const LinkWithRef = forwardRef(Link) as unknown as < - Pathname extends keyof AppPathnames - >( - props: LinkProps & { - ref?: ComponentProps['ref']; - } - ) => ReactElement; - (LinkWithRef as any).displayName = 'Link'; - - function redirect( - href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst - ) { - // eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since `redirect` should be called during render - const locale = useTypedLocale(); - const resolvedHref = getPathname({href, locale}); - return clientRedirect( - {pathname: resolvedHref, localePrefix: config.localePrefix}, - ...args - ); - } - - function permanentRedirect( - href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst - ) { - // eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since `redirect` should be called during render - const locale = useTypedLocale(); - const resolvedHref = getPathname({href, locale}); - return clientPermanentRedirect( - {pathname: resolvedHref, localePrefix: config.localePrefix}, - ...args - ); - } - - function useRouter() { - const baseRouter = useBaseRouter(config.localePrefix, localeCookie); - const defaultLocale = useTypedLocale(); - - return useMemo( - () => ({ - ...baseRouter, - push( - href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst - ) { - const resolvedHref = getPathname({ - href, - locale: args[0]?.locale || defaultLocale - }); - return baseRouter.push(resolvedHref, ...args); - }, - - replace( - href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst - ) { - const resolvedHref = getPathname({ - href, - locale: args[0]?.locale || defaultLocale - }); - return baseRouter.replace(resolvedHref, ...args); - }, - - prefetch( - href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst - ) { - const resolvedHref = getPathname({ - href, - locale: args[0]?.locale || defaultLocale - }); - return baseRouter.prefetch(resolvedHref, ...args); - } - }), - [baseRouter, defaultLocale] - ); - } - - function usePathname(): keyof AppPathnames { - const pathname = useBasePathname(config.localePrefix); - 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 useMemo( - () => - pathname ? getRoute(locale, pathname, config.pathnames) : pathname, - [locale, pathname] - ); - } - - function getPathname({ - href, - locale - }: { - locale: AppLocales[number]; - href: HrefOrHrefWithParams; - }) { - return compileLocalizedPathname({ - ...normalizeNameOrNameWithParams(href), - locale, - pathnames: config.pathnames - }); - } - - return { - Link: LinkWithRef, - redirect, - permanentRedirect, - usePathname, - useRouter, - getPathname - }; -} diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.test.tsx deleted file mode 100644 index bf63025ed..000000000 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.test.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import {render, screen} from '@testing-library/react'; -import { - usePathname as useNextPathname, - useRouter as useNextRouter, - useParams -} from 'next/navigation.js'; -import {createRef} from 'react'; -import {Mock, beforeEach, describe, expect, it, vi} from 'vitest'; -import createSharedPathnamesNavigation from './createSharedPathnamesNavigation.tsx'; - -vi.mock('next/navigation.js'); - -const locales = ['en', 'de'] as const; - -beforeEach(() => { - const router = { - push: vi.fn(), - replace: vi.fn(), - prefetch: vi.fn(), - back: vi.fn(), - forward: vi.fn(), - refresh: vi.fn() - }; - vi.mocked(useNextRouter).mockImplementation(() => router); - vi.mocked(useNextPathname).mockImplementation(() => '/'); - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); -}); - -describe("localePrefix: 'as-needed'", () => { - const {Link, useRouter} = createSharedPathnamesNavigation({ - locales, - localePrefix: 'as-needed' - }); - - describe('Link', () => { - it('supports receiving a ref', () => { - const ref = createRef(); - render(); - expect(ref.current).not.toBe(null); - }); - }); - - describe('useRouter', () => { - describe('push', () => { - it('resolves to the correct path when passing another locale', () => { - function Component() { - const router = useRouter(); - router.push('/about', {locale: 'de'}); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/about'); - }); - - it('passes through unknown options to the Next.js router', () => { - function Component() { - const router = useRouter(); - router.push('/about', {locale: 'de', scroll: false}); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/about', {scroll: false}); - }); - }); - }); - - /** - * Type tests - */ - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - function TypeTests() { - const router = useRouter(); - - // @ts-expect-error -- Only supports string paths - router.push({pathname: '/about'}); - - // Valid - router.push('/about'); - router.push('/about', {locale: 'de'}); - router.push('/unknown'); // No error since routes are unknown - - // @ts-expect-error -- Unknown locale - router.push('/about', {locale: 'unknown'}); - - // @ts-expect-error -- No params supported - - User - ; - - // @ts-expect-error -- Unknown locale - - User - ; - - // Valid - Über uns; - About; // No error since routes are unknown - } -}); - -describe("localePrefix: 'as-needed', custom prefix", () => { - const {usePathname, useRouter} = createSharedPathnamesNavigation({ - locales: ['en', 'en-gb'], - localePrefix: { - mode: 'as-needed', - prefixes: { - 'en-gb': '/uk' - } - } - }); - - describe('useRouter', () => { - describe('push', () => { - it('resolves to the correct path when passing a locale with a custom prefix', () => { - function Component() { - const router = useRouter(); - router.push('/about', {locale: 'en-gb'}); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/uk/about'); - }); - }); - }); - - describe('usePathname', () => { - it('returns the correct pathname for a custom locale prefix', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'en-gb'})); - vi.mocked(useNextPathname).mockImplementation(() => '/uk/about'); - function Component() { - return usePathname(); - } - render(); - screen.getByText('/about'); - }); - }); -}); - -describe("localePrefix: 'never'", () => { - const {useRouter} = createSharedPathnamesNavigation({ - locales, - localePrefix: 'never' - }); - - describe('useRouter', () => { - function Component({locale}: {locale?: (typeof locales)[number]}) { - const router = useRouter(); - router.push('/about', {locale}); - return null; - } - - describe('push', () => { - it('can push a pathname for the default locale', () => { - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/about'); - }); - - it('can push a pathname for a secondary locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/about'); - }); - - it('resolves to the correct path when passing another locale', () => { - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/about'); - }); - }); - }); -}); diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx deleted file mode 100644 index 65c75fb2f..000000000 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import {ComponentProps, ReactElement, forwardRef} from 'react'; -import { - RoutingConfigSharedNavigation, - receiveLocaleCookie, - receiveLocalePrefixConfig -} from '../../routing/config.tsx'; -import { - DomainsConfig, - LocalePrefixMode, - Locales -} from '../../routing/types.tsx'; -import {ParametersExceptFirst} from '../../shared/types.tsx'; -import ClientLink from './ClientLink.tsx'; -import {clientPermanentRedirect, clientRedirect} from './redirects.tsx'; -import useBasePathname from './useBasePathname.tsx'; -import useBaseRouter from './useBaseRouter.tsx'; - -/** - * @deprecated Consider switching to `createNavigation` (see https://next-intl-docs.vercel.app/blog/next-intl-3-22#create-navigation) - **/ -export default function createSharedPathnamesNavigation< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode, - AppDomains extends DomainsConfig = never ->( - routing?: RoutingConfigSharedNavigation< - AppLocales, - AppLocalePrefixMode, - AppDomains - > -) { - const localePrefix = receiveLocalePrefixConfig(routing?.localePrefix); - const localeCookie = receiveLocaleCookie(routing?.localeCookie); - - type LinkProps = Omit< - ComponentProps>, - 'localePrefix' | 'localeCookie' - >; - function Link(props: LinkProps, ref: LinkProps['ref']) { - return ( - - ref={ref} - localeCookie={localeCookie} - localePrefix={localePrefix} - {...props} - /> - ); - } - const LinkWithRef = forwardRef(Link) as unknown as ( - props: LinkProps & {ref?: LinkProps['ref']} - ) => ReactElement; - (LinkWithRef as any).displayName = 'Link'; - - function redirect( - pathname: string, - ...args: ParametersExceptFirst - ) { - return clientRedirect({pathname, localePrefix}, ...args); - } - - function permanentRedirect( - pathname: string, - ...args: ParametersExceptFirst - ) { - return clientPermanentRedirect({pathname, localePrefix}, ...args); - } - - function usePathname(): string { - const result = useBasePathname(localePrefix); - // @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 result; - } - - function useRouter() { - return useBaseRouter( - localePrefix, - localeCookie - ); - } - - return { - Link: LinkWithRef, - redirect, - permanentRedirect, - usePathname, - useRouter - }; -} diff --git a/packages/next-intl/src/navigation/react-client/index.tsx b/packages/next-intl/src/navigation/react-client/index.tsx index 391f917c6..fc99c6c65 100644 --- a/packages/next-intl/src/navigation/react-client/index.tsx +++ b/packages/next-intl/src/navigation/react-client/index.tsx @@ -1,12 +1 @@ -export {default as createSharedPathnamesNavigation} from './createSharedPathnamesNavigation.tsx'; -export {default as createLocalizedPathnamesNavigation} from './createLocalizedPathnamesNavigation.tsx'; export {default as createNavigation} from './createNavigation.tsx'; - -import type { - Locales, - Pathnames as PathnamesDeprecatedExport -} from '../../routing/types.tsx'; - -/** @deprecated Please import from `next-intl/routing` instead. */ -export type Pathnames = - PathnamesDeprecatedExport; diff --git a/packages/next-intl/src/navigation/react-client/redirects.tsx b/packages/next-intl/src/navigation/react-client/redirects.tsx deleted file mode 100644 index f677c4ad3..000000000 --- a/packages/next-intl/src/navigation/react-client/redirects.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import useLocale from '../../react-client/useLocale.tsx'; -import {ParametersExceptFirst} from '../../shared/types.tsx'; -import {basePermanentRedirect, baseRedirect} from '../shared/redirects.tsx'; - -function createRedirectFn(redirectFn: typeof baseRedirect) { - return function clientRedirect( - params: Omit[0], 'locale'>, - ...args: ParametersExceptFirst - ) { - let locale; - try { - // eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since `redirect` should be called during render - locale = useLocale(); - } catch (e) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - '`redirect()` and `permanentRedirect()` can only be called during render. To redirect in an event handler or similar, you can use `useRouter()` instead.' - ); - } - throw e; - } - - return redirectFn({...params, locale}, ...args); - }; -} - -export const clientRedirect = createRedirectFn(baseRedirect); -export const clientPermanentRedirect = createRedirectFn(basePermanentRedirect); diff --git a/packages/next-intl/src/navigation/react-client/useBaseRouter.test.tsx b/packages/next-intl/src/navigation/react-client/useBaseRouter.test.tsx deleted file mode 100644 index cfba14fcd..000000000 --- a/packages/next-intl/src/navigation/react-client/useBaseRouter.test.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import {render} from '@testing-library/react'; -import { - usePathname as useNextPathname, - useRouter as useNextRouter, - type useRouter -} from 'next/navigation.js'; -import {useEffect} from 'react'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import useBaseRouter from './useBaseRouter.tsx'; - -vi.mock('next/navigation.js', () => { - const router: ReturnType = { - push: vi.fn(), - replace: vi.fn(), - prefetch: vi.fn(), - back: vi.fn(), - forward: vi.fn(), - refresh: vi.fn() - }; - return { - useRouter: vi.fn(() => router), - useParams: vi.fn(() => ({locale: 'en'})), - usePathname: vi.fn(() => '/') - }; -}); - -function callRouter(cb: (router: ReturnType) => void) { - function Component() { - const router = useBaseRouter( - { - // The mode is not used, only the absence of - // `prefixes` is relevant for this test suite - mode: 'as-needed' - }, - { - name: 'NEXT_LOCALE', - maxAge: 31536000, - sameSite: 'lax' - } - ); - useEffect(() => { - cb(router); - }, [router]); - return null; - } - - render(); -} - -function mockLocation(pathname: string, basePath = '') { - vi.mocked(useNextPathname).mockReturnValue(pathname); - - delete (global.window as any).location; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - global.window ??= Object.create(window); - (global.window as any).location = {pathname: basePath + pathname}; -} - -function clearNextRouterMocks() { - ['push', 'replace', 'prefetch', 'back', 'forward', 'refresh'].forEach( - (fnName) => { - vi.mocked((useNextRouter() as any)[fnName]).mockClear(); - } - ); -} - -describe('unprefixed routing', () => { - beforeEach(() => { - mockLocation('/'); - clearNextRouterMocks(); - }); - - it('can push', () => { - callRouter((router) => router.push('/test')); - expect(useNextRouter().push).toHaveBeenCalledWith('/test'); - }); - - it('can replace', () => { - callRouter((router) => router.replace('/test')); - expect(useNextRouter().replace).toHaveBeenCalledWith('/test'); - }); - - it('can prefetch', () => { - callRouter((router) => router.prefetch('/test')); - expect(useNextRouter().prefetch).toHaveBeenCalledWith('/test'); - }); - - it('passes through absolute urls', () => { - callRouter((router) => router.push('https://example.com')); - expect(useNextRouter().push).toHaveBeenCalledWith('https://example.com'); - }); - - it('passes through relative urls', () => { - callRouter((router) => router.push('about')); - expect(useNextRouter().push).toHaveBeenCalledWith('about'); - }); - - it('can change the locale with `push`', () => { - callRouter((router) => router.push('/about', {locale: 'de'})); - expect(useNextRouter().push).toHaveBeenCalledWith('/de/about'); - }); - - it('can change the locale with `replace`', () => { - callRouter((router) => router.replace('/about', {locale: 'es'})); - expect(useNextRouter().replace).toHaveBeenCalledWith('/es/about'); - }); - - it('can prefetch a new locale', () => { - callRouter((router) => - router.prefetch('/about', { - locale: 'es', - // @ts-expect-error -- Somehow only works via the enum (which is not exported) - kind: 'auto' - }) - ); - expect(useNextRouter().prefetch).toHaveBeenCalledWith('/es/about', { - kind: 'auto' - }); - }); - - it('keeps the cookie value in sync', () => { - document.cookie = 'NEXT_LOCALE=en'; - - callRouter((router) => router.push('/about', {locale: 'de'})); - expect(document.cookie).toContain('NEXT_LOCALE=de'); - - callRouter((router) => router.push('/test')); - expect(document.cookie).toContain('NEXT_LOCALE=de'); - - callRouter((router) => router.replace('/about', {locale: 'es'})); - expect(document.cookie).toContain('NEXT_LOCALE=es'); - - callRouter((router) => - router.prefetch('/about', { - locale: 'it', - // @ts-expect-error -- Somehow only works via the enum (which is not exported) - kind: 'auto' - }) - ); - expect(document.cookie).toContain('NEXT_LOCALE=it'); - }); -}); - -describe('prefixed routing', () => { - beforeEach(() => { - mockLocation('/en'); - clearNextRouterMocks(); - }); - - it('can push', () => { - callRouter((router) => router.push('/test')); - expect(useNextRouter().push).toHaveBeenCalledWith('/en/test'); - }); - - it('can replace', () => { - callRouter((router) => router.replace('/test')); - expect(useNextRouter().replace).toHaveBeenCalledWith('/en/test'); - }); - - it('can prefetch', () => { - callRouter((router) => router.prefetch('/test')); - expect(useNextRouter().prefetch).toHaveBeenCalledWith('/en/test'); - }); - - it('passes through absolute urls', () => { - callRouter((router) => router.push('https://example.com')); - expect(useNextRouter().push).toHaveBeenCalledWith('https://example.com'); - }); - - it('passes through relative urls', () => { - callRouter((router) => router.push('about')); - expect(useNextRouter().push).toHaveBeenCalledWith('about'); - }); -}); - -describe('basePath unprefixed routing', () => { - beforeEach(() => { - mockLocation('/', '/base/path'); - clearNextRouterMocks(); - }); - - it('can push', () => { - callRouter((router) => router.push('/test')); - expect(useNextRouter().push).toHaveBeenCalledWith('/test'); - }); - - it('can replace', () => { - callRouter((router) => router.replace('/test')); - expect(useNextRouter().replace).toHaveBeenCalledWith('/test'); - }); - - it('can prefetch', () => { - callRouter((router) => router.prefetch('/test')); - expect(useNextRouter().prefetch).toHaveBeenCalledWith('/test'); - }); - - it('passes through absolute urls', () => { - callRouter((router) => router.push('https://example.com')); - expect(useNextRouter().push).toHaveBeenCalledWith('https://example.com'); - }); - - it('passes through relative urls', () => { - callRouter((router) => router.push('about')); - expect(useNextRouter().push).toHaveBeenCalledWith('about'); - }); -}); - -describe('basePath prefixed routing', () => { - beforeEach(() => { - mockLocation('/en', '/base/path'); - clearNextRouterMocks(); - }); - - it('can push', () => { - callRouter((router) => router.push('/test')); - expect(useNextRouter().push).toHaveBeenCalledWith('/en/test'); - }); - - it('can replace', () => { - callRouter((router) => router.replace('/test')); - expect(useNextRouter().replace).toHaveBeenCalledWith('/en/test'); - }); - - it('can prefetch', () => { - callRouter((router) => router.prefetch('/test')); - expect(useNextRouter().prefetch).toHaveBeenCalledWith('/en/test'); - }); - - it('passes through absolute urls', () => { - callRouter((router) => router.push('https://example.com')); - expect(useNextRouter().push).toHaveBeenCalledWith('https://example.com'); - }); - - it('passes through relative urls', () => { - callRouter((router) => router.push('about')); - expect(useNextRouter().push).toHaveBeenCalledWith('about'); - }); -}); diff --git a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx deleted file mode 100644 index 30778f591..000000000 --- a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import {useRouter as useNextRouter, usePathname} from 'next/navigation.js'; -import {useMemo} from 'react'; -import useLocale from '../../react-client/useLocale.tsx'; -import {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; -import { - LocalePrefixConfigVerbose, - LocalePrefixMode, - Locales -} from '../../routing/types.tsx'; -import {getLocalePrefix, localizeHref} from '../../shared/utils.tsx'; -import syncLocaleCookie from '../shared/syncLocaleCookie.tsx'; -import {getBasePath} from '../shared/utils.tsx'; - -type IntlNavigateOptions = { - locale?: AppLocales[number]; -}; - -/** - * Returns a wrapped instance of `useRouter` from `next/navigation` that - * will automatically localize the `href` parameters it receives. - * - * @example - * ```tsx - * 'use client'; - * - * import {useRouter} from 'next-intl/client'; - * - * const router = useRouter(); - * - * // When the user is on `/en`, the router will navigate to `/en/about` - * router.push('/about'); - * - * // Optionally, you can switch the locale by passing the second argument - * router.push('/about', {locale: 'de'}); - * ``` - */ -export default function useBaseRouter< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode ->( - localePrefix: LocalePrefixConfigVerbose, - localeCookie: InitializedLocaleCookieConfig -) { - const router = useNextRouter(); - const locale = useLocale(); - const pathname = usePathname(); - - return useMemo(() => { - function localize(href: string, nextLocale?: AppLocales[number]) { - let curPathname = window.location.pathname; - - const basePath = getBasePath(pathname); - if (basePath) curPathname = curPathname.replace(basePath, ''); - - const targetLocale = nextLocale || locale; - - // We generate a prefix in any case, but decide - // in `localizeHref` if we apply it or not - const prefix = getLocalePrefix(targetLocale, localePrefix); - - return localizeHref(href, targetLocale, locale, curPathname, prefix); - } - - function createHandler< - Options, - Fn extends (href: string, options?: Options) => void - >(fn: Fn) { - return function handler( - href: string, - options?: Options & IntlNavigateOptions - ): void { - const {locale: nextLocale, ...rest} = options || {}; - - syncLocaleCookie(localeCookie, pathname, locale, nextLocale); - - const args: [ - href: string, - options?: Parameters[1] - ] = [localize(href, nextLocale)]; - if (Object.keys(rest).length > 0) { - args.push(rest); - } - - // @ts-expect-error -- This is ok - return fn(...args); - }; - } - - return { - ...router, - push: createHandler< - Parameters[1], - typeof router.push - >(router.push), - replace: createHandler< - Parameters[1], - typeof router.replace - >(router.replace), - prefetch: createHandler< - Parameters[1], - typeof router.prefetch - >(router.prefetch) - }; - }, [locale, localeCookie, localePrefix, pathname, router]); -} diff --git a/packages/next-intl/src/navigation/react-server/ServerLink.tsx b/packages/next-intl/src/navigation/react-server/ServerLink.tsx deleted file mode 100644 index 76812ba47..000000000 --- a/packages/next-intl/src/navigation/react-server/ServerLink.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import {ComponentProps} from 'react'; -import { - LocalePrefixConfigVerbose, - LocalePrefixMode, - Locales -} from '../../routing/types.tsx'; -import {getLocale} from '../../server.react-server.tsx'; -import {getLocalePrefix} from '../../shared/utils.tsx'; -import LegacyBaseLink from '../shared/LegacyBaseLink.tsx'; - -// Only used by legacy navigation APIs, can be removed when they are removed - -type Props< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode -> = Omit< - ComponentProps, - 'locale' | 'prefix' | 'localePrefixMode' -> & { - locale?: AppLocales[number]; - localePrefix: LocalePrefixConfigVerbose; -}; - -export default async function ServerLink< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode ->({locale, localePrefix, ...rest}: Props) { - const finalLocale = locale || (await getLocale()); - const prefix = getLocalePrefix(finalLocale, localePrefix); - - return ( - - ); -} diff --git a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx deleted file mode 100644 index 008330715..000000000 --- a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import {ComponentProps} from 'react'; -import { - RoutingConfigLocalizedNavigation, - receiveRoutingConfig -} from '../../routing/config.tsx'; -import { - DomainsConfig, - LocalePrefixMode, - Locales, - Pathnames -} from '../../routing/types.tsx'; -import {getRequestLocale} from '../../server/react-server/RequestLocaleLegacy.tsx'; -import {ParametersExceptFirst} from '../../shared/types.tsx'; -import { - HrefOrHrefWithParams, - HrefOrUrlObjectWithParams, - compileLocalizedPathname, - normalizeNameOrNameWithParams -} from '../shared/utils.tsx'; -import ServerLink from './ServerLink.tsx'; -import {serverPermanentRedirect, serverRedirect} from './redirects.tsx'; - -export default function createLocalizedPathnamesNavigation< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode = 'always', - AppPathnames extends Pathnames = never, - AppDomains extends DomainsConfig = never ->( - routing: RoutingConfigLocalizedNavigation< - AppLocales, - AppLocalePrefixMode, - AppPathnames, - AppDomains - > -) { - const config = receiveRoutingConfig(routing); - - type LinkProps = Omit< - ComponentProps, - 'href' | 'name' | 'localePrefix' | 'localeCookie' - > & { - href: HrefOrUrlObjectWithParams; - locale?: AppLocales[number]; - }; - function Link({ - href, - locale, - ...rest - }: LinkProps) { - const defaultLocale = getRequestLocale() as (typeof config.locales)[number]; - const finalLocale = locale || defaultLocale; - - return ( - ({ - locale: finalLocale, - // @ts-expect-error -- This is ok - pathname: href, - // @ts-expect-error -- This is ok - params: typeof href === 'object' ? href.params : undefined, - pathnames: config.pathnames - })} - locale={locale} - localeCookie={config.localeCookie} - localePrefix={config.localePrefix} - {...rest} - /> - ); - } - - function redirect( - href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst - ) { - const locale = getRequestLocale(); - const pathname = getPathname({href, locale}); - return serverRedirect( - {localePrefix: config.localePrefix, pathname}, - ...args - ); - } - - function permanentRedirect( - href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst - ) { - const locale = getRequestLocale(); - const pathname = getPathname({href, locale}); - return serverPermanentRedirect( - {localePrefix: config.localePrefix, pathname}, - ...args - ); - } - - function getPathname({ - href, - locale - }: { - locale: AppLocales[number]; - href: HrefOrHrefWithParams; - }) { - return compileLocalizedPathname({ - ...normalizeNameOrNameWithParams(href), - locale, - pathnames: config.pathnames - }); - } - - function notSupported(hookName: string) { - return () => { - throw new Error( - `\`${hookName}\` is not supported in Server Components. You can use this hook if you convert the component to a Client Component.` - ); - }; - } - - return { - Link, - redirect, - permanentRedirect, - getPathname, - usePathname: notSupported('usePathname'), - useRouter: notSupported('useRouter') - }; -} diff --git a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx deleted file mode 100644 index 255c91568..000000000 --- a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import {ComponentProps} from 'react'; -import { - RoutingConfigSharedNavigation, - receiveLocaleCookie, - receiveLocalePrefixConfig -} from '../../routing/config.tsx'; -import { - DomainsConfig, - LocalePrefixMode, - Locales -} from '../../routing/types.tsx'; -import {ParametersExceptFirst} from '../../shared/types.tsx'; -import ServerLink from './ServerLink.tsx'; -import {serverPermanentRedirect, serverRedirect} from './redirects.tsx'; - -export default function createSharedPathnamesNavigation< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode, - AppDomains extends DomainsConfig = never ->( - routing?: RoutingConfigSharedNavigation< - AppLocales, - AppLocalePrefixMode, - AppDomains - > -) { - const localePrefix = receiveLocalePrefixConfig(routing?.localePrefix); - const localeCookie = receiveLocaleCookie(routing?.localeCookie); - - function notSupported(hookName: string) { - return () => { - throw new Error( - `\`${hookName}\` is not supported in Server Components. You can use this hook if you convert the component to a Client Component.` - ); - }; - } - - function Link( - props: Omit< - ComponentProps>, - 'localePrefix' | 'localeCookie' - > - ) { - return ( - - localeCookie={localeCookie} - localePrefix={localePrefix} - {...props} - /> - ); - } - - function redirect( - pathname: string, - ...args: ParametersExceptFirst - ) { - return serverRedirect({pathname, localePrefix}, ...args); - } - - function permanentRedirect( - pathname: string, - ...args: ParametersExceptFirst - ) { - return serverPermanentRedirect({pathname, localePrefix}, ...args); - } - - return { - Link, - redirect, - permanentRedirect, - usePathname: notSupported('usePathname'), - useRouter: notSupported('useRouter') - }; -} diff --git a/packages/next-intl/src/navigation/react-server/index.tsx b/packages/next-intl/src/navigation/react-server/index.tsx index 5541b366a..7bc2e12ed 100644 --- a/packages/next-intl/src/navigation/react-server/index.tsx +++ b/packages/next-intl/src/navigation/react-server/index.tsx @@ -1,4 +1,2 @@ -export {default as createSharedPathnamesNavigation} from './createSharedPathnamesNavigation.tsx'; -export {default as createLocalizedPathnamesNavigation} from './createLocalizedPathnamesNavigation.tsx'; export {default as createNavigation} from './createNavigation.tsx'; export type {Pathnames} from '../../routing/types.tsx'; diff --git a/packages/next-intl/src/navigation/react-server/redirects.tsx b/packages/next-intl/src/navigation/react-server/redirects.tsx deleted file mode 100644 index 43ea1659e..000000000 --- a/packages/next-intl/src/navigation/react-server/redirects.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import {getRequestLocale} from '../../server/react-server/RequestLocaleLegacy.tsx'; -import {ParametersExceptFirst} from '../../shared/types.tsx'; -import {basePermanentRedirect, baseRedirect} from '../shared/redirects.tsx'; - -function createRedirectFn(redirectFn: typeof baseRedirect) { - return function serverRedirect( - params: Omit[0], 'locale'>, - ...args: ParametersExceptFirst - ) { - const locale = getRequestLocale(); - return redirectFn({...params, locale}, ...args); - }; -} - -export const serverRedirect = createRedirectFn(baseRedirect); -export const serverPermanentRedirect = createRedirectFn(basePermanentRedirect); diff --git a/packages/next-intl/src/navigation/shared/LegacyBaseLink.tsx b/packages/next-intl/src/navigation/shared/LegacyBaseLink.tsx deleted file mode 100644 index 27d9ed4dc..000000000 --- a/packages/next-intl/src/navigation/shared/LegacyBaseLink.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client'; - -import {usePathname} from 'next/navigation.js'; -import {ComponentProps, forwardRef, useEffect, useState} from 'react'; -import useLocale from '../../react-client/useLocale.tsx'; -import {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; -import {LocalePrefixMode} from '../../routing/types.tsx'; -import { - isLocalizableHref, - localizeHref, - prefixHref -} from '../../shared/utils.tsx'; -import BaseLink from './BaseLink.tsx'; - -type Props = Omit< - ComponentProps, - 'locale' | 'unprefixed' | 'defaultLocale' -> & { - locale: string; - prefix: string; - localePrefixMode: LocalePrefixMode; - localeCookie: InitializedLocaleCookieConfig; -}; - -function LegacyBaseLink( - {href, locale, localeCookie, localePrefixMode, prefix, ...rest}: Props, - ref: Props['ref'] -) { - // The types aren't entirely correct here. Outside of Next.js - // `useParams` can be called, but the return type is `null`. - const pathname = usePathname() as ReturnType | null; - - const curLocale = useLocale(); - const isChangingLocale = locale !== curLocale; - - const [localizedHref, setLocalizedHref] = useState(() => - isLocalizableHref(href) && - (localePrefixMode !== 'never' || isChangingLocale) - ? // For the `localePrefix: 'as-needed' strategy, the href shouldn't - // be prefixed if the locale is the default locale. To determine this, we - // need a) the default locale and b) the information if we use prefixed - // routing. The default locale can vary by domain, therefore during the - // RSC as well as the SSR render, we can't determine the default locale - // statically. Therefore we always prefix the href since this will - // always result in a valid URL, even if it might cause a redirect. This - // is better than pointing to a non-localized href during the server - // render, which would potentially be wrong. The final href is - // determined in the effect below. - prefixHref(href, prefix) - : href - ); - - useEffect(() => { - if (!pathname) return; - - setLocalizedHref(localizeHref(href, locale, curLocale, pathname, prefix)); - }, [curLocale, href, locale, pathname, prefix]); - - return ( - - ); -} - -const LegacyBaseLinkWithRef = forwardRef(LegacyBaseLink); -(LegacyBaseLinkWithRef as any).displayName = 'ClientLink'; -export default LegacyBaseLinkWithRef; diff --git a/packages/next-intl/src/navigation/shared/redirects.test.tsx b/packages/next-intl/src/navigation/shared/redirects.test.tsx deleted file mode 100644 index 6eaf2938f..000000000 --- a/packages/next-intl/src/navigation/shared/redirects.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { - permanentRedirect as nextPermanentRedirect, - redirect as nextRedirect -} from 'next/navigation.js'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {basePermanentRedirect, baseRedirect} from './redirects.tsx'; - -vi.mock('next/navigation.js'); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe.each([ - [baseRedirect, nextRedirect], - [basePermanentRedirect, nextPermanentRedirect] -])('baseRedirect', (redirectFn, nextFn) => { - describe("localePrefix: 'always'", () => { - it('handles internal paths', () => { - redirectFn({ - pathname: '/test/path', - locale: 'en', - localePrefix: {mode: 'always'} - }); - expect(nextFn).toHaveBeenCalledTimes(1); - expect(nextFn).toHaveBeenCalledWith('/en/test/path'); - }); - - it('handles external paths', () => { - redirectFn({ - pathname: 'https://example.com', - locale: 'en', - localePrefix: {mode: 'always'} - }); - expect(nextFn).toHaveBeenCalledTimes(1); - expect(nextFn).toHaveBeenCalledWith('https://example.com'); - }); - }); - - describe("localePrefix: 'as-needed'", () => { - it('handles internal paths', () => { - redirectFn({ - pathname: '/test/path', - locale: 'en', - localePrefix: {mode: 'as-needed'} - }); - expect(nextFn).toHaveBeenCalledTimes(1); - expect(nextFn).toHaveBeenCalledWith('/en/test/path'); - }); - - it('handles external paths', () => { - redirectFn({ - pathname: 'https://example.com', - locale: 'en', - localePrefix: {mode: 'as-needed'} - }); - expect(nextFn).toHaveBeenCalledTimes(1); - expect(nextFn).toHaveBeenCalledWith('https://example.com'); - }); - }); - - describe("localePrefix: 'never'", () => { - it('handles internal paths', () => { - redirectFn({ - pathname: '/test/path', - locale: 'en', - localePrefix: {mode: 'never'} - }); - expect(nextFn).toHaveBeenCalledTimes(1); - expect(nextFn).toHaveBeenCalledWith('/test/path'); - }); - - it('handles external paths', () => { - redirectFn({ - pathname: 'https://example.com', - locale: 'en', - localePrefix: {mode: 'never'} - }); - expect(nextFn).toHaveBeenCalledTimes(1); - expect(nextFn).toHaveBeenCalledWith('https://example.com'); - }); - }); -}); diff --git a/packages/next-intl/src/navigation/shared/redirects.tsx b/packages/next-intl/src/navigation/shared/redirects.tsx deleted file mode 100644 index 28b19a686..000000000 --- a/packages/next-intl/src/navigation/shared/redirects.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { - permanentRedirect as nextPermanentRedirect, - redirect as nextRedirect -} from 'next/navigation.js'; -import { - LocalePrefixConfigVerbose, - LocalePrefixMode, - Locales -} from '../../routing/types.tsx'; -import {ParametersExceptFirst} from '../../shared/types.tsx'; -import { - getLocalePrefix, - isLocalizableHref, - prefixPathname -} from '../../shared/utils.tsx'; - -function createRedirectFn(redirectFn: typeof nextRedirect) { - return function baseRedirect< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode - >( - params: { - pathname: string; - locale: Locales[number]; - localePrefix: LocalePrefixConfigVerbose; - }, - ...args: ParametersExceptFirst - ) { - const prefix = getLocalePrefix(params.locale, params.localePrefix); - - // This logic is considered legacy and is replaced by `applyPathnamePrefix`. - // We keep it this way for now for backwards compatibility. - const localizedPathname = - params.localePrefix.mode === 'never' || - !isLocalizableHref(params.pathname) - ? params.pathname - : prefixPathname(prefix, params.pathname); - - return redirectFn(localizedPathname, ...args); - }; -} - -export const baseRedirect = createRedirectFn(nextRedirect); -export const basePermanentRedirect = createRedirectFn(nextPermanentRedirect); diff --git a/packages/next-intl/src/plugin.tsx b/packages/next-intl/src/plugin.tsx index 4ddc86375..a32d4f421 100644 --- a/packages/next-intl/src/plugin.tsx +++ b/packages/next-intl/src/plugin.tsx @@ -13,8 +13,6 @@ function withExtensions(localPath: string) { ]; } -let hasWarnedForDeprecatedI18nConfig = false; - function resolveI18nPath(providedPath?: string, cwd?: string) { function resolvePath(pathname: string) { const parts = []; @@ -44,25 +42,6 @@ function resolveI18nPath(providedPath?: string, cwd?: string) { } } - for (const candidate of [ - ...withExtensions('./i18n'), - ...withExtensions('./src/i18n') - ]) { - if (pathExists(candidate)) { - if (!hasWarnedForDeprecatedI18nConfig) { - console.warn( - `\n[next-intl] Reading request configuration from ${candidate} is deprecated, please see https://next-intl-docs.vercel.app/blog/next-intl-3-22#i18n-request — you can either move your configuration to ./i18n/request.ts or provide a custom path in your Next.js config: - -const withNextIntl = createNextIntlPlugin( - './path/to/i18n/request.tsx' -);\n` - ); - hasWarnedForDeprecatedI18nConfig = true; - } - return candidate; - } - } - throw new Error(`\n[next-intl] Could not locate request configuration module. This path is supported by default: ./(src/)i18n/request.{js,jsx,ts,tsx} diff --git a/packages/next-intl/src/routing/config.tsx b/packages/next-intl/src/routing/config.tsx index 7328bad1a..975a17841 100644 --- a/packages/next-intl/src/routing/config.tsx +++ b/packages/next-intl/src/routing/config.tsx @@ -151,7 +151,7 @@ export function receiveRoutingConfig< }; } -export function receiveLocaleCookie( +function receiveLocaleCookie( localeCookie?: boolean | CookieAttributes ): InitializedLocaleCookieConfig { return (localeCookie ?? true) @@ -175,7 +175,7 @@ export type LocaleCookieConfig = Omit< > & Required>; -export function receiveLocalePrefixConfig< +function receiveLocalePrefixConfig< AppLocales extends Locales, AppLocalePrefixMode extends LocalePrefixMode >(localePrefix?: LocalePrefix) { diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 68a4aa841..b2b4d219a 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -1,4 +1,3 @@ -import {UrlObject} from 'url'; import {LinkProps} from 'next/link.js'; import { LocalePrefixConfigVerbose, @@ -26,61 +25,6 @@ export function isLocalizableHref(href: Href) { return isLocalHref(href) && !isRelativeHref(href); } -export function localizeHref( - href: string, - locale: string, - curLocale: string, - curPathname: string, - prefix?: string -): string; -export function localizeHref( - href: UrlObject | string, - locale: string, - curLocale: string, - curPathname: string, - prefix?: string -): UrlObject | string; -export function localizeHref( - href: UrlObject | string, - locale: string, - curLocale: string = locale, - curPathname: string, - prefix?: string -) { - if (!isLocalizableHref(href)) { - return href; - } - - const isSwitchingLocale = locale !== curLocale; - const isPathnamePrefixed = hasPathnamePrefixed(prefix, curPathname); - const shouldPrefix = isSwitchingLocale || isPathnamePrefixed; - - if (shouldPrefix && prefix != null) { - return prefixHref(href, prefix); - } - - return href; -} - -export function prefixHref(href: string, prefix: string): string; -export function prefixHref( - href: UrlObject | string, - prefix: string -): UrlObject | string; -export function prefixHref(href: UrlObject | string, prefix: string) { - let prefixedHref; - if (typeof href === 'string') { - prefixedHref = prefixPathname(prefix, href); - } else { - prefixedHref = {...href}; - if (href.pathname) { - prefixedHref.pathname = prefixPathname(prefix, href.pathname); - } - } - - return prefixedHref; -} - export function unprefixPathname(pathname: string, prefix: string) { return pathname.replace(new RegExp(`^${prefix}`), '') || '/'; } diff --git a/packages/next-intl/types/index.d.ts b/packages/next-intl/types/index.d.ts index f94a7983c..7fc5571da 100644 --- a/packages/next-intl/types/index.d.ts +++ b/packages/next-intl/types/index.d.ts @@ -1,3 +1,9 @@ +declare namespace NodeJS { + interface ProcessEnv { + NODE_ENV: 'development' | 'production'; + } +} + declare interface IntlMessages extends Record {} // Temporarly copied here until the "es2020.intl" lib is published. diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts index 89837aee6..a21e2f62f 100644 --- a/packages/use-intl/.size-limit.ts +++ b/packages/use-intl/.size-limit.ts @@ -5,20 +5,20 @@ const config: SizeLimitConfig = [ name: "import * from 'use-intl' (production)", import: '*', path: 'dist/esm/production/index.js', - limit: '13.075 kB' + limit: '12.965 kB' }, { name: "import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from 'use-intl' (production)", path: 'dist/esm/production/index.js', import: '{IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter}', - limit: '2.005 kB' + limit: '1.975 kB' }, { name: "import * from 'use-intl' (development)", import: '*', path: 'dist/esm/development/index.js', - limit: '13.955 kB' + limit: '13.905 kB' } ]; diff --git a/packages/use-intl/src/core/IntlConfig.tsx b/packages/use-intl/src/core/IntlConfig.tsx index d827cf3c2..d6eaa2528 100644 --- a/packages/use-intl/src/core/IntlConfig.tsx +++ b/packages/use-intl/src/core/IntlConfig.tsx @@ -2,7 +2,6 @@ import type AbstractIntlMessages from './AbstractIntlMessages.tsx'; import type Formats from './Formats.tsx'; import type IntlError from './IntlError.tsx'; import type TimeZone from './TimeZone.tsx'; -import type {RichTranslationValues} from './TranslationValues.tsx'; /** * Should be used for entry points that configure the library. @@ -41,13 +40,6 @@ type IntlConfig = { now?: Date; /** All messages that will be available. */ messages?: Messages; - /** Global default values for translation values and rich text elements. - * Can be used for consistent usage or styling of rich text elements. - * Defaults will be overidden by locally provided values. - * - * @deprecated See https://next-intl-docs.vercel.app/docs/usage/messages#rich-text-reuse-tags - **/ - defaultTranslationValues?: RichTranslationValues; }; /** diff --git a/packages/use-intl/src/core/createBaseTranslator.tsx b/packages/use-intl/src/core/createBaseTranslator.tsx index f878bb5ee..28de377e8 100644 --- a/packages/use-intl/src/core/createBaseTranslator.tsx +++ b/packages/use-intl/src/core/createBaseTranslator.tsx @@ -77,8 +77,6 @@ function resolvePath( } function prepareTranslationValues(values: RichTranslationValues) { - if (Object.keys(values).length === 0) return undefined; - // Workaround for https://github.com/formatjs/formatjs/issues/1467 const transformedValues: RichTranslationValues = {}; Object.keys(values).forEach((key) => { @@ -146,7 +144,6 @@ function getMessagesOrError( export type CreateBaseTranslatorProps = InitializedIntlConfig & { cache: IntlCache; formatters: Formatters; - defaultTranslationValues?: RichTranslationValues; namespace?: string; messagesOrError: Messages | IntlError; }; @@ -190,7 +187,6 @@ function createBaseTranslatorImpl< NestedKey extends NestedKeyOf >({ cache, - defaultTranslationValues, formats: globalFormats, formatters, getMessageFallback = defaultGetMessageFallback, @@ -317,7 +313,7 @@ function createBaseTranslatorImpl< // for rich text elements since a recent minor update. This // needs to be evaluated in detail, possibly also in regards // to be able to format to parts. - prepareTranslationValues({...defaultTranslationValues, ...values}) + values ? prepareTranslationValues(values) : values ); if (formattedMessage == null) { @@ -393,23 +389,17 @@ function createBaseTranslatorImpl< formats ); - // When only string chunks are provided to the parser, only - // strings should be returned here. Note that we need a runtime - // check for this since rich text values could be accidentally - // inherited from `defaultTranslationValues`. - if (typeof result !== 'string') { + if (process.env.NODE_ENV !== 'production' && typeof result !== 'string') { const error = new IntlError( IntlErrorCode.FORMATTING_ERROR, - process.env.NODE_ENV !== 'production' - ? "`t.markup` only accepts functions for formatting that receive and return strings.\n\nE.g. t.markup('markup', {b: (chunks) => `${chunks}`})" - : undefined + "`t.markup` only accepts functions for formatting that receive and return strings.\n\nE.g. t.markup('markup', {b: (chunks) => `${chunks}`})" ); onError(error); return getMessageFallback({error, key, namespace}); } - return result; + return result as string; }; translateFn.raw = ( diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index ec4e66c91..6bc825486 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -39,7 +39,7 @@ export default function createTranslator< namespace, onError = defaultOnError, ...rest -}: Omit, 'defaultTranslationValues' | 'messages'> & { +}: Omit, 'messages'> & { messages?: IntlConfig['messages']; namespace?: NestedKey; /** @private */ diff --git a/packages/use-intl/src/react/IntlProvider.tsx b/packages/use-intl/src/react/IntlProvider.tsx index 40ef9c374..77b35362b 100644 --- a/packages/use-intl/src/react/IntlProvider.tsx +++ b/packages/use-intl/src/react/IntlProvider.tsx @@ -14,7 +14,6 @@ type Props = IntlConfig & { export default function IntlProvider({ children, - defaultTranslationValues, formats, getMessageFallback, locale, @@ -50,8 +49,6 @@ export default function IntlProvider({ () => ({ ...initializeConfig({ locale, // (required by provider) - defaultTranslationValues: - defaultTranslationValues || prevContext?.defaultTranslationValues, formats: formats || prevContext?.formats, getMessageFallback: getMessageFallback || prevContext?.getMessageFallback, @@ -65,7 +62,6 @@ export default function IntlProvider({ }), [ cache, - defaultTranslationValues, formats, formatters, getMessageFallback, diff --git a/packages/use-intl/src/react/useTranslations.test.tsx b/packages/use-intl/src/react/useTranslations.test.tsx index f20160ad8..1a5193b91 100644 --- a/packages/use-intl/src/react/useTranslations.test.tsx +++ b/packages/use-intl/src/react/useTranslations.test.tsx @@ -989,89 +989,6 @@ describe('global formats', () => { }); }); -describe('default translation values', () => { - function renderRichTextMessageWithDefault( - message: string, - values?: RichTranslationValues, - formats?: Formats - ) { - function Component() { - const t = useTranslations(); - return <>{t.rich('message', values, formats)}; - } - - return render( - {children} - }} - formats={{dateTime: {time: {hour: 'numeric', minute: '2-digit'}}}} - locale="en" - messages={{message}} - timeZone="Europe/London" - > - - - ); - } - - function renderMessageWithDefault( - message: string, - values?: TranslationValues, - formats?: Formats - ) { - function Component() { - const t = useTranslations(); - return <>{t('message', values, formats)}; - } - - return render( - - - - ); - } - - it('uses default rich text element', () => { - const {container} = renderRichTextMessageWithDefault( - 'This is important and this as well' - ); - expect(container.innerHTML).toBe( - 'This is important and this as well' - ); - }); - - it('overrides default rich text element', () => { - const {container} = renderRichTextMessageWithDefault( - 'This is important and this as well', - { - important: (children) => {children} - } - ); - expect(container.innerHTML).toBe( - 'This is important and this as well' - ); - }); - - it('uses default translation values', () => { - renderMessageWithDefault('Hello {value}'); - screen.getByText('Hello 123'); - }); - - it('overrides default translation values', () => { - renderMessageWithDefault('Hello {value}', {value: 234}); - screen.getByText('Hello 234'); - }); -}); - describe('performance', () => { const MockIntlMessageFormat: typeof IntlMessageFormat & { invocationsByMessage: Record; diff --git a/packages/use-intl/src/react/useTranslationsImpl.tsx b/packages/use-intl/src/react/useTranslationsImpl.tsx index 8a30f8c25..ada612785 100644 --- a/packages/use-intl/src/react/useTranslationsImpl.tsx +++ b/packages/use-intl/src/react/useTranslationsImpl.tsx @@ -19,7 +19,6 @@ export default function useTranslationsImpl< ) { const { cache, - defaultTranslationValues, formats: globalFormats, formatters, getMessageFallback, @@ -56,7 +55,6 @@ export default function useTranslationsImpl< formatters, getMessageFallback, messages: allMessages, - defaultTranslationValues, namespace, onError, formats: globalFormats, @@ -68,7 +66,6 @@ export default function useTranslationsImpl< formatters, getMessageFallback, allMessages, - defaultTranslationValues, namespace, onError, globalFormats, diff --git a/packages/use-intl/types/index.d.ts b/packages/use-intl/types/index.d.ts index d21023b4e..53e36206c 100644 --- a/packages/use-intl/types/index.d.ts +++ b/packages/use-intl/types/index.d.ts @@ -1,3 +1,9 @@ +declare namespace NodeJS { + interface ProcessEnv { + NODE_ENV: 'development' | 'production'; + } +} + // This type is intended to be overridden // by the consumer for optional type safety of messages declare interface IntlMessages extends Record {} From eb5bf09b5ddfbd4abd0ed5943399a2a0effd5ad6 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 29 Oct 2024 12:07:14 +0100 Subject: [PATCH 15/90] feat!: Bump minimum required `typescript` version to 5 for projects using TypeScript (#1481) If you don't use TypeScript, this change doesn't affect you. --- docs/src/pages/docs/workflows/typescript.mdx | 7 +++---- packages/next-intl/package.json | 8 +++++++- packages/next-intl/src/middleware/middleware.tsx | 8 ++++---- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/src/pages/docs/workflows/typescript.mdx b/docs/src/pages/docs/workflows/typescript.mdx index c7014a09f..3c6eae8c0 100644 --- a/docs/src/pages/docs/workflows/typescript.mdx +++ b/docs/src/pages/docs/workflows/typescript.mdx @@ -115,7 +115,6 @@ declare global { If you're encountering problems, double check that: 1. Your interface uses the correct name. -2. You're using TypeScript version 4 or later. -3. You're using correct paths for all modules you're importing into your global declaration file. -4. Your type declaration file is included in `tsconfig.json`. -5. Your editor has loaded the most recent type declarations. When in doubt, you can restart. +2. You're using correct paths for all modules you're importing into your global declaration file. +3. Your type declaration file is included in `tsconfig.json`. +4. Your editor has loaded the most recent type declarations. When in doubt, you can restart. diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 15d5c1d74..a5e055c11 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -116,7 +116,13 @@ }, "peerDependencies": { "next": "^13.0.0 || ^14.0.0 || ^15.0.0", - "react": "^17.0.0 || ^18.0.0" + "react": "^17.0.0 || ^18.0.0", + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } }, "devDependencies": { "@arethetypeswrong/cli": "^0.16.4", diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 00030c9f1..08cda6715 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -29,10 +29,10 @@ import { } from './utils.tsx'; export default function createMiddleware< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode = 'always', - AppPathnames extends Pathnames = never, - AppDomains extends DomainsConfig = never + const AppLocales extends Locales, + const AppLocalePrefixMode extends LocalePrefixMode = 'always', + const AppPathnames extends Pathnames = never, + const AppDomains extends DomainsConfig = never >( routing: RoutingConfig< AppLocales, From a6238f4be9c48b66b52441a262df2b17731f2460 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 29 Oct 2024 13:02:27 +0100 Subject: [PATCH 16/90] feat!: Remove deprecated APIs pt. 2 (#1482) **Changes** - Removed deprecated second argument of `createMiddleware` (use first argument instead, ideally via `defineRouting`) - Remove deprecated `({locale})` argument in `getRequestConfig` (use `({requestLocale})` instead) - Remove deprecated `unstable_setRequestLocale` (use `setRequestLocale` instead) --- .../src/middleware/middleware.test.tsx | 13 -------- .../next-intl/src/middleware/middleware.tsx | 33 ++----------------- .../src/server/react-client/index.test.tsx | 14 ++++---- .../src/server/react-client/index.tsx | 7 +--- .../src/server/react-server/getConfig.tsx | 5 --- .../server/react-server/getRequestConfig.tsx | 11 ------- .../src/server/react-server/index.tsx | 5 --- 7 files changed, 10 insertions(+), 78 deletions(-) diff --git a/packages/next-intl/src/middleware/middleware.test.tsx b/packages/next-intl/src/middleware/middleware.test.tsx index e96da6804..1d2ca8791 100644 --- a/packages/next-intl/src/middleware/middleware.test.tsx +++ b/packages/next-intl/src/middleware/middleware.test.tsx @@ -3315,16 +3315,3 @@ describe('domain-based routing', () => { }); }); }); - -describe('deprecated middleware options', () => { - it('still accepts them', () => { - createMiddleware( - {locales: ['en'], defaultLocale: 'en'}, - { - localeDetection: false, - alternateLinks: false, - localeCookie: false - } - ); - }); -}); diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 08cda6715..2669e8bc6 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -39,38 +39,9 @@ export default function createMiddleware< AppLocalePrefixMode, AppPathnames, AppDomains - >, - /** @deprecated Should be passed via the first parameter `routing` instead (ideally defined with `defineRouting`) */ - options?: { - /** @deprecated Should be passed via the first parameter `routing` instead (ideally defined with `defineRouting`) */ - localeCookie?: RoutingConfig< - AppLocales, - AppLocalePrefixMode, - AppPathnames, - AppDomains - >['localeCookie']; - /** @deprecated Should be passed via the first parameter `routing` instead (ideally defined with `defineRouting`) */ - localeDetection?: RoutingConfig< - AppLocales, - AppLocalePrefixMode, - AppPathnames, - AppDomains - >['localeDetection']; - /** @deprecated Should be passed via the first parameter `routing` instead (ideally defined with `defineRouting`) */ - alternateLinks?: RoutingConfig< - AppLocales, - AppLocalePrefixMode, - AppPathnames, - AppDomains - >['alternateLinks']; - } + > ) { - const resolvedRouting = receiveRoutingConfig({ - ...routing, - alternateLinks: options?.alternateLinks ?? routing.alternateLinks, - localeDetection: options?.localeDetection ?? routing.localeDetection, - localeCookie: options?.localeCookie ?? routing.localeCookie - }); + const resolvedRouting = receiveRoutingConfig(routing); return function middleware(request: NextRequest) { let unsafeExternalPathname: string; diff --git a/packages/next-intl/src/server/react-client/index.test.tsx b/packages/next-intl/src/server/react-client/index.test.tsx index 7cc58a10e..24ab36d9b 100644 --- a/packages/next-intl/src/server/react-client/index.test.tsx +++ b/packages/next-intl/src/server/react-client/index.test.tsx @@ -4,18 +4,18 @@ import {getRequestConfig} from '../../server.react-client.tsx'; describe('getRequestConfig', () => { it('can be called in the outer module closure', () => { expect( - getRequestConfig(({locale}) => ({ - messages: {hello: 'Hello ' + locale} + getRequestConfig(async ({requestLocale}) => ({ + messages: {hello: 'Hello ' + (await requestLocale)} })) ); }); it('can not call the returned function', () => { - const getConfig = getRequestConfig(({locale}) => ({ - messages: {hello: 'Hello ' + locale} + const getConfig = getRequestConfig(async ({requestLocale}) => ({ + messages: {hello: 'Hello ' + (await requestLocale)} })); - expect(() => - getConfig({locale: 'en', requestLocale: Promise.resolve('en')}) - ).toThrow('`getRequestConfig` is not supported in Client Components.'); + expect(() => getConfig({requestLocale: Promise.resolve('en')})).toThrow( + '`getRequestConfig` is not supported in Client Components.' + ); }); }); diff --git a/packages/next-intl/src/server/react-client/index.tsx b/packages/next-intl/src/server/react-client/index.tsx index c53c8ba60..5dbc371ec 100644 --- a/packages/next-intl/src/server/react-client/index.tsx +++ b/packages/next-intl/src/server/react-client/index.tsx @@ -5,8 +5,7 @@ import type { getNow as getNow_type, getRequestConfig as getRequestConfig_type, getTimeZone as getTimeZone_type, - setRequestLocale as setRequestLocale_type, - unstable_setRequestLocale as unstable_setRequestLocale_type + setRequestLocale as setRequestLocale_type } from '../react-server/index.tsx'; /** @@ -46,10 +45,6 @@ export const getLocale = notSupported('getLocale') as typeof getLocale_type; // anyway, therefore this is irrelevant. export const getTranslations = notSupported('getTranslations'); -export const unstable_setRequestLocale = notSupported( - 'unstable_setRequestLocale' -) as typeof unstable_setRequestLocale_type; - export const setRequestLocale = notSupported( 'setRequestLocale' ) as typeof setRequestLocale_type; diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index bc9c01cba..03f4add46 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -7,7 +7,6 @@ import { initializeConfig } from 'use-intl/core'; import {getRequestLocale} from './RequestLocale.tsx'; -import {getRequestLocale as getRequestLocaleLegacy} from './RequestLocaleLegacy.tsx'; import createRequestConfig from './createRequestConfig.tsx'; import {GetRequestConfigParams} from './getRequestConfig.tsx'; @@ -48,10 +47,6 @@ See also: https://next-intl-docs.vercel.app/docs/usage/configuration#i18n-reques // In case the consumer doesn't read `params.locale` and instead provides the // `locale` (either in a single-language workflow or because the locale is // read from the user settings), don't attempt to read the request locale. - get locale() { - return localeOverride || getRequestLocaleLegacy(); - }, - get requestLocale() { return localeOverride ? Promise.resolve(localeOverride) diff --git a/packages/next-intl/src/server/react-server/getRequestConfig.tsx b/packages/next-intl/src/server/react-server/getRequestConfig.tsx index 127656ff5..c4fbf1aa8 100644 --- a/packages/next-intl/src/server/react-server/getRequestConfig.tsx +++ b/packages/next-intl/src/server/react-server/getRequestConfig.tsx @@ -15,17 +15,6 @@ export type RequestConfig = Omit & { }; export type GetRequestConfigParams = { - /** - * Deprecated in favor of `requestLocale` (see https://next-intl-docs.vercel.app/blog/next-intl-3-22#await-request-locale). - * - * The locale that was matched by the `[locale]` path segment. Note however - * that this can be overridden in async APIs when the `locale` is explicitly - * passed (e.g. `getTranslations({locale: 'en'})`). - * - * @deprecated - */ - locale: string; - /** * Typically corresponds to the `[locale]` segment that was matched by the middleware. * diff --git a/packages/next-intl/src/server/react-server/index.tsx b/packages/next-intl/src/server/react-server/index.tsx index cdad41cc5..82c958050 100644 --- a/packages/next-intl/src/server/react-server/index.tsx +++ b/packages/next-intl/src/server/react-server/index.tsx @@ -11,8 +11,3 @@ export {default as getMessages} from './getMessages.tsx'; export {default as getLocale} from './getLocale.tsx'; export {setCachedRequestLocale as setRequestLocale} from './RequestLocaleCache.tsx'; - -export { - /** @deprecated Deprecated in favor of `setRequestLocale`. */ - setCachedRequestLocale as unstable_setRequestLocale -} from './RequestLocaleCache.tsx'; From 829998e653917844ae3dfe9f31a4dbc2e2536784 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 29 Oct 2024 13:04:04 +0100 Subject: [PATCH 17/90] feat!: Infer default `locale` for `NextIntlClientProvider` from `useParams` when rendered on the client side (#1483) Bumps the Next.js peer dependency to 13.3. --- packages/next-intl/.size-limit.ts | 6 +-- packages/next-intl/package.json | 2 +- .../next-intl/src/react-client/useLocale.tsx | 6 +-- .../shared/NextIntlClientProvider.test.tsx | 46 ++++++++++--------- .../src/shared/NextIntlClientProvider.tsx | 19 +++++--- packages/next-intl/src/shared/useParams.tsx | 7 +++ 6 files changed, 50 insertions(+), 36 deletions(-) create mode 100644 packages/next-intl/src/shared/useParams.tsx diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 3157b9a91..7070e0dc7 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -4,13 +4,13 @@ const config: SizeLimitConfig = [ { name: "import * from 'next-intl' (react-client, production)", path: 'dist/esm/production/index.react-client.js', - limit: '13.105 KB' + limit: '13.11 KB' }, { name: "import {NextIntlClientProvider} from 'next-intl' (react-client, production)", import: '{NextIntlClientProvider}', path: 'dist/esm/production/index.react-client.js', - limit: '1 KB' + limit: '1.055 KB' }, { name: "import * from 'next-intl' (react-server, production)", @@ -21,7 +21,7 @@ const config: SizeLimitConfig = [ name: "import {createNavigation} from 'next-intl/navigation' (react-client, production)", path: 'dist/esm/production/navigation.react-client.js', import: '{createNavigation}', - limit: '2.505 KB' + limit: '2.525 KB' }, { name: "import {createNavigation} from 'next-intl/navigation' (react-server, production)", diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index a5e055c11..57b69eb26 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -115,7 +115,7 @@ "use-intl": "workspace:^" }, "peerDependencies": { - "next": "^13.0.0 || ^14.0.0 || ^15.0.0", + "next": "^13.3.0 || ^14.0.0 || ^15.0.0", "react": "^17.0.0 || ^18.0.0", "typescript": "^5.0.0" }, diff --git a/packages/next-intl/src/react-client/useLocale.tsx b/packages/next-intl/src/react-client/useLocale.tsx index 66a8d9378..227342528 100644 --- a/packages/next-intl/src/react-client/useLocale.tsx +++ b/packages/next-intl/src/react-client/useLocale.tsx @@ -1,11 +1,9 @@ -import {useParams} from 'next/navigation.js'; import {useLocale as useBaseLocale} from 'use-intl/react'; import {LOCALE_SEGMENT_NAME} from '../shared/constants.tsx'; +import useParams from '../shared/useParams.tsx'; export default function useLocale(): string { - // The types aren't entirely correct here. Outside of Next.js - // `useParams` can be called, but the return type is `null`. - const params = useParams() as ReturnType | null; + const params = useParams(); let locale; diff --git a/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx b/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx index 02de09c68..2d3cc8cd8 100644 --- a/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx +++ b/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx @@ -1,39 +1,43 @@ import {render, screen} from '@testing-library/react'; -import {it} from 'vitest'; +import {it, vi} from 'vitest'; import { NextIntlClientProvider, useTranslations } from '../index.react-client.tsx'; -it('can use messages from the provider', () => { - function Component() { - const t = useTranslations(); - return <>{t('message')}; +vi.mock('next/navigation.js', () => ({ + useParams() { + return {locale: 'en'}; } +})); - render( - - - - ); - - screen.getByText('Hello'); -}); +function Component() { + const t = useTranslations(); + return <>{t('message', {price: 29000.5})}; +} -it('can override the locale from Next.js', () => { - function Component() { - const t = useTranslations(); - return <>{t('message', {price: 29000.5})}; - } - - render( +function TestProvider({locale}: {locale?: string}) { + return ( ); +} + +it('can use messages from the provider', () => { + render(); + screen.getByText('€29,000.50'); +}); +it('reads a default locale from params', () => { + render(); + screen.getByText('€29,000.50'); +}); + +it('can override the locale from Next.js', () => { + render(); screen.getByText('29.000,50 €'); }); diff --git a/packages/next-intl/src/shared/NextIntlClientProvider.tsx b/packages/next-intl/src/shared/NextIntlClientProvider.tsx index 4dead1359..395e84511 100644 --- a/packages/next-intl/src/shared/NextIntlClientProvider.tsx +++ b/packages/next-intl/src/shared/NextIntlClientProvider.tsx @@ -2,6 +2,8 @@ import {ComponentProps} from 'react'; import {IntlProvider} from 'use-intl/react'; +import {LOCALE_SEGMENT_NAME} from './constants.tsx'; +import useParams from './useParams.tsx'; type Props = Omit, 'locale'> & { /** This is automatically received when being rendered from a Server Component. In all other cases, e.g. when rendered from a Client Component, a unit test or with the Pages Router, you can pass this prop explicitly. */ @@ -9,15 +11,18 @@ type Props = Omit, 'locale'> & { }; export default function NextIntlClientProvider({locale, ...rest}: Props) { - // TODO: We could call `useParams` here to receive a default value - // for `locale`, but this would require dropping Next.js <13. + const paramsLocale = useParams()?.[LOCALE_SEGMENT_NAME]; if (!locale) { - throw new Error( - process.env.NODE_ENV !== 'production' - ? 'Failed to determine locale in `NextIntlClientProvider`, please provide the `locale` prop explicitly.\n\nSee https://next-intl-docs.vercel.app/docs/configuration#locale' - : undefined - ); + if (typeof paramsLocale === 'string') { + locale = paramsLocale; + } else { + throw new Error( + process.env.NODE_ENV !== 'production' + ? 'Failed to determine locale in `NextIntlClientProvider`, please provide the `locale` prop explicitly.\n\nSee https://next-intl-docs.vercel.app/docs/configuration#locale' + : undefined + ); + } } return ; diff --git a/packages/next-intl/src/shared/useParams.tsx b/packages/next-intl/src/shared/useParams.tsx new file mode 100644 index 000000000..7aed6c35a --- /dev/null +++ b/packages/next-intl/src/shared/useParams.tsx @@ -0,0 +1,7 @@ +import {useParams as useNextParams} from 'next/navigation.js'; + +export default function useParams() { + // The types aren't entirely correct here. Outside of Next.js + // `useParams` can be called, but the return type is `null`. + return useNextParams() as ReturnType | null; +} From a9c35dbd14bcb92a9c13893cd71ac62986697e0f Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 29 Oct 2024 13:17:35 +0100 Subject: [PATCH 18/90] add comment --- packages/next-intl/src/plugin.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/next-intl/src/plugin.tsx b/packages/next-intl/src/plugin.tsx index a32d4f421..b6d6e8616 100644 --- a/packages/next-intl/src/plugin.tsx +++ b/packages/next-intl/src/plugin.tsx @@ -74,6 +74,10 @@ function initPlugin(i18nPath?: string, nextConfig?: NextConfig): NextConfig { '\n' ); } + + // `NextConfig['turbo']` is stable in Next.js 15. In case the + // experimental feature is removed in the future, we should + // replace this accordingly in a future major version. nextIntlConfig.experimental = { ...nextConfig?.experimental, turbo: { From 5402851cc6205490d5ec8d9b43a641aa5ea26ae9 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 29 Oct 2024 14:38:10 +0100 Subject: [PATCH 19/90] feat!: Require `locale` to be returned from `getRequestConfig` (#1486) --- .../src/server/react-client/index.test.tsx | 6 ++++-- .../src/server/react-server/getConfig.tsx | 16 +++++----------- .../src/server/react-server/getRequestConfig.tsx | 11 ++--------- 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/packages/next-intl/src/server/react-client/index.test.tsx b/packages/next-intl/src/server/react-client/index.test.tsx index 24ab36d9b..9ad50db29 100644 --- a/packages/next-intl/src/server/react-client/index.test.tsx +++ b/packages/next-intl/src/server/react-client/index.test.tsx @@ -5,14 +5,16 @@ describe('getRequestConfig', () => { it('can be called in the outer module closure', () => { expect( getRequestConfig(async ({requestLocale}) => ({ - messages: {hello: 'Hello ' + (await requestLocale)} + locale: (await requestLocale) || 'en', + messages: {hello: 'Hello'} })) ); }); it('can not call the returned function', () => { const getConfig = getRequestConfig(async ({requestLocale}) => ({ - messages: {hello: 'Hello ' + (await requestLocale)} + locale: (await requestLocale) || 'en', + messages: {hello: 'Hello '} })); expect(() => getConfig({requestLocale: Promise.resolve('en')})).toThrow( '`getRequestConfig` is not supported in Client Components.' diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index 03f4add46..0661e1de0 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -1,4 +1,3 @@ -import {notFound} from 'next/navigation.js'; import {cache} from 'react'; import { IntlConfig, @@ -59,20 +58,15 @@ See also: https://next-intl-docs.vercel.app/docs/usage/configuration#i18n-reques result = await result; } - const locale = result.locale || (await params.requestLocale); - - if (!locale) { - if (process.env.NODE_ENV !== 'production') { - console.error( - `\nUnable to find \`next-intl\` locale because the middleware didn't run on this request and no \`locale\` was returned in \`getRequestConfig\`. See https://next-intl-docs.vercel.app/docs/routing/middleware#unable-to-find-locale. The \`notFound()\` function will be called as a result.\n` - ); - } - notFound(); + if (!result.locale) { + throw new Error( + 'No locale was returned from `getRequestConfig`.\n\nSee https://next-intl-docs.vercel.app/docs/usage/configuration#i18n-request' + ); } return { ...result, - locale, + locale: result.locale, now: result.now || getDefaultNow(), timeZone: result.timeZone || getDefaultTimeZone() }; diff --git a/packages/next-intl/src/server/react-server/getRequestConfig.tsx b/packages/next-intl/src/server/react-server/getRequestConfig.tsx index c4fbf1aa8..1e3c4af4b 100644 --- a/packages/next-intl/src/server/react-server/getRequestConfig.tsx +++ b/packages/next-intl/src/server/react-server/getRequestConfig.tsx @@ -2,16 +2,9 @@ import type {IntlConfig} from 'use-intl/core'; export type RequestConfig = Omit & { /** - * Instead of reading a `requestLocale` from the argument that's passed to the - * function within `getRequestConfig`, you can include a locale as part of the - * returned request configuration. - * - * This can be helpful for the following use cases: - * - Apps that only support a single language - * - Apps where the locale should be read from user settings instead of the pathname - * - Providing a fallback locale in case the locale was not matched by the middleware + * @see https://next-intl-docs.vercel.app/docs/usage/configuration#i18n-request **/ - locale?: IntlConfig['locale']; + locale: IntlConfig['locale']; }; export type GetRequestConfigParams = { From b4e4d1d0875cfae762c9f61750903d99d3d78394 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 30 Oct 2024 12:21:35 +0100 Subject: [PATCH 20/90] feat!: Decrease cookie expiration to 5 hours, only set cookie when necessary and only disable cookie if `localeCookie: false` is set (#1487) **Changes** 1. The `maxAge` attribute of the locale cookie is decreased from 1 year to 5 hours in order to be GDPR-compliant. 2. The locale cookie is now only set when the user's language doesn't match a requested locale. E.g. a user with `accept-language: 'en'` will cause a cookie to be set when `/de` is requested to remember the preference for `de`. 3. `localeDetection: false` previously ambiguously also disabled the cookie from being set. This is no longer the case. For consistency, you can now use the explicit [`localeCookie: false`](https://next-intl-docs.vercel.app/docs/routing#locale-cookie) option instead. If you want to increase the cookie expiration, you can use the [`maxAge`](https://next-intl-docs.vercel.app/docs/routing#locale-cookie) attribute to do so: ```tsx import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ // ... localeCookie: { // Expire in one year maxAge: 60 * 60 * 24 * 365 } }); ``` --- docs/src/pages/docs/routing.mdx | 25 ++++-- docs/src/pages/docs/routing/middleware.mdx | 7 +- .../tests/base-path.spec.ts | 4 +- .../tests/domains.spec.ts | 2 +- .../tests/locale-cookie-false.spec.ts | 2 +- .../tests/locale-prefix-never.spec.ts | 18 ++-- .../tests/main.spec.ts | 22 +++-- .../tests/trailing-slash.spec.ts | 2 +- .../tests/utils.ts | 18 ++-- .../example-app-router/tests/main.spec.ts | 24 ++++-- .../src/middleware/middleware.test.tsx | 82 ++++++++++++++----- .../next-intl/src/middleware/middleware.tsx | 4 +- .../next-intl/src/middleware/syncCookie.tsx | 48 +++++++++-- .../react-client/createNavigation.test.tsx | 2 +- packages/next-intl/src/routing/config.tsx | 2 +- 15 files changed, 179 insertions(+), 83 deletions(-) diff --git a/docs/src/pages/docs/routing.mdx b/docs/src/pages/docs/routing.mdx index 051729003..6dffa60c9 100644 --- a/docs/src/pages/docs/routing.mdx +++ b/docs/src/pages/docs/routing.mdx @@ -110,8 +110,8 @@ In this case, requests for all locales will be rewritten to have the locale only **Note that:** 1. If you use this strategy, you should adapt your matcher to detect [unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix). -2. If you don't use domain-based routing, the cookie is now the source of truth for determining the locale. Make sure that your hosting solution reliably returns the `set-cookie` header from the middleware (e.g. Vercel and Cloudflare are known to potentially [strip this header](https://developers.cloudflare.com/cache/concepts/cache-behavior/#interaction-of-set-cookie-response-header-with-cache) for cacheable requests). -3. [Alternate links](#alternate-links) are disabled in this mode since URLs might not be unique per locale. Due to this, consider including these yourself, or set up a [sitemap](/docs/environments/actions-metadata-route-handlers#sitemap) that links localized pages via `alternates`. +2. [Alternate links](#alternate-links) are disabled in this mode since URLs might not be unique per locale. Due to this, consider including these yourself, or set up a [sitemap](/docs/environments/actions-metadata-route-handlers#sitemap) that links localized pages via `alternates`. +3. You can consider increasing the [`maxAge`](#locale-cookie) attribute of the locale cookie to a longer duration to remember the user's preference across sessions. #### Custom prefixes [#locale-prefix-custom] @@ -472,11 +472,11 @@ In this case, only the locale prefix and a potentially [matching domain](#domain ### Locale cookie [#locale-cookie] -By default, the middleware will set a cookie called `NEXT_LOCALE` that contains the most recently detected locale. This is used to [remember the user's locale](/docs/routing/middleware#locale-detection) preference for future requests. +If a user changes the locale to a value that doesn't match the `accept-language` header, `next-intl` will set a cookie called `NEXT_LOCALE` that contains the most recently detected locale. This is used to [remember the user's locale](/docs/routing/middleware#locale-detection) preference for future requests. By default, the cookie will be configured with the following attributes: -1. [**`maxAge`**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#max-agenumber): This value is set to 1 year so that the preference of the user is kept as long as possible. +1. [**`maxAge`**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#max-agenumber): This value is set to 5 hours in order to be [GDPR-compliant](#locale-cookie-gdpr) out of the box. 2. [**`sameSite`**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value): This value is set to `lax` so that the cookie can be set when coming from an external site. 3. [**`path`**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#pathpath-value): This value is not set by default, but will use the value of your [`basePath`](#base-path) if configured. @@ -492,8 +492,8 @@ export const routing = defineRouting({ localeCookie: { // Custom cookie name name: 'USER_LOCALE', - // Expire in one day - maxAge: 60 * 60 * 24 + // Expire in one year + maxAge: 60 * 60 * 24 * 365 } }); ``` @@ -510,7 +510,18 @@ export const routing = defineRouting({ }); ``` -Note that the cookie is only set when the user switches the locale and is not updated on every request. + ### Alternate links [#alternate-links] diff --git a/docs/src/pages/docs/routing/middleware.mdx b/docs/src/pages/docs/routing/middleware.mdx index 248d3f371..bf26a6b3d 100644 --- a/docs/src/pages/docs/routing/middleware.mdx +++ b/docs/src/pages/docs/routing/middleware.mdx @@ -30,7 +30,7 @@ export const config = { ## Locale detection [#locale-detection] -The locale is negotiated based on your [`localePrefix`](/docs/routing#locale-prefix) and [`domains`](/docs/routing#domains) setting. Once a locale is detected, it will be remembered for future requests by being stored in the `NEXT_LOCALE` cookie. +The locale is negotiated based on your routing configuration, taking into account your settings for [`localePrefix`](/docs/routing#locale-prefix), [`domains`](/docs/routing#domains), [`localeDetection`](/docs/routing#locale-detection), and [`localeCookie`](/docs/routing#locale-cookie). ### Prefix-based routing (default) [#location-detection-prefix] @@ -48,10 +48,11 @@ To change the locale, users can visit a prefixed route. This will take precedenc **Example workflow:** 1. A user requests `/` and based on the `accept-language` header, the `en` locale is matched. -2. The `en` locale is saved in a cookie and the user is redirected to `/en`. +2. The user is redirected to `/en`. 3. The app renders `Switch to German` to allow the user to change the locale to `de`. 4. When the user clicks on the link, a request to `/de` is initiated. -5. The middleware will update the cookie value to `de`. +5. The middleware will add a cookie to remember the preference for the `de` locale. +6. The user later requests `/` again and the middleware will redirect to `/de` based on the cookie.
Which algorithm is used to match the accept-language header against the available locales? diff --git a/examples/example-app-router-playground/tests/base-path.spec.ts b/examples/example-app-router-playground/tests/base-path.spec.ts index 77e2fda9e..cc9c49a51 100644 --- a/examples/example-app-router-playground/tests/base-path.spec.ts +++ b/examples/example-app-router-playground/tests/base-path.spec.ts @@ -1,9 +1,9 @@ -import {test as it, expect} from '@playwright/test'; +import {expect, test as it} from '@playwright/test'; import {assertLocaleCookieValue} from './utils'; it('updates the cookie correctly', async ({page}) => { await page.goto('/base/path'); - await assertLocaleCookieValue(page, 'en', {path: '/base/path'}); + await assertLocaleCookieValue(page, undefined); await page.getByRole('button', {name: 'Go to nested page'}).click(); await expect(page).toHaveURL('/base/path/nested'); diff --git a/examples/example-app-router-playground/tests/domains.spec.ts b/examples/example-app-router-playground/tests/domains.spec.ts index f1aac49dd..f113e76ee 100644 --- a/examples/example-app-router-playground/tests/domains.spec.ts +++ b/examples/example-app-router-playground/tests/domains.spec.ts @@ -1,4 +1,4 @@ -import {test as it, expect, chromium} from '@playwright/test'; +import {chromium, expect, test as it} from '@playwright/test'; it('can use config based on the default locale on an unknown domain', async ({ page diff --git a/examples/example-app-router-playground/tests/locale-cookie-false.spec.ts b/examples/example-app-router-playground/tests/locale-cookie-false.spec.ts index 37ef14c27..cff9b8782 100644 --- a/examples/example-app-router-playground/tests/locale-cookie-false.spec.ts +++ b/examples/example-app-router-playground/tests/locale-cookie-false.spec.ts @@ -1,4 +1,4 @@ -import {test as it, expect} from '@playwright/test'; +import {expect, test as it} from '@playwright/test'; it('never sets a cookie', async ({page}) => { async function expectNoCookie() { diff --git a/examples/example-app-router-playground/tests/locale-prefix-never.spec.ts b/examples/example-app-router-playground/tests/locale-prefix-never.spec.ts index bb9fcea8a..48b66239a 100644 --- a/examples/example-app-router-playground/tests/locale-prefix-never.spec.ts +++ b/examples/example-app-router-playground/tests/locale-prefix-never.spec.ts @@ -1,4 +1,5 @@ -import {test as it, expect} from '@playwright/test'; +import {expect, test as it} from '@playwright/test'; +import {assertLocaleCookieValue} from './utils'; it('clears the router cache when changing the locale', async ({page}) => { await page.goto('/'); @@ -7,13 +8,6 @@ it('clears the router cache when changing the locale', async ({page}) => { await page.locator(`html[lang="${lang}"]`).waitFor(); } - async function assertCookie(locale: string) { - const cookies = await page.context().cookies(); - expect(cookies.find((cookie) => cookie.name === 'NEXT_LOCALE')?.value).toBe( - locale - ); - } - await expectDocumentLang('en'); await page.getByRole('link', {name: 'Client page'}).click(); @@ -22,16 +16,16 @@ it('clears the router cache when changing the locale', async ({page}) => { await expect( page.getByText('This page hydrates on the client side.') ).toBeAttached(); - await assertCookie('en'); + await assertLocaleCookieValue(page, undefined); await page.getByRole('link', {name: 'Go to home'}).click(); await expectDocumentLang('en'); await expect(page).toHaveURL('/'); - await assertCookie('en'); + await assertLocaleCookieValue(page, undefined); await page.getByRole('link', {name: 'Switch to German'}).click(); await expectDocumentLang('de'); - await assertCookie('de'); + await assertLocaleCookieValue(page, 'de'); await page.getByRole('link', {name: 'Client-Seite'}).click(); await expectDocumentLang('de'); @@ -39,5 +33,5 @@ it('clears the router cache when changing the locale', async ({page}) => { await expect( page.getByText('Dise Seite wird auf der Client-Seite initialisiert.') ).toBeAttached(); - await assertCookie('de'); + await assertLocaleCookieValue(page, 'de'); }); diff --git a/examples/example-app-router-playground/tests/main.spec.ts b/examples/example-app-router-playground/tests/main.spec.ts index fb5c746cd..7882adc7b 100644 --- a/examples/example-app-router-playground/tests/main.spec.ts +++ b/examples/example-app-router-playground/tests/main.spec.ts @@ -1,5 +1,5 @@ -import {test as it, expect, BrowserContext} from '@playwright/test'; -import {getAlternateLinks, assertLocaleCookieValue} from './utils'; +import {BrowserContext, expect, test as it} from '@playwright/test'; +import {assertLocaleCookieValue, getAlternateLinks} from './utils'; const describe = it.describe; @@ -300,17 +300,25 @@ it('keeps the locale cookie updated when changing the locale and uses soft navig const tracker = getPageLoadTracker(context); await page.goto('/'); - await assertLocaleCookieValue(page, 'en'); + await assertLocaleCookieValue(page, undefined); expect(tracker.numPageLoads).toBe(1); - const link = page.getByRole('link', {name: 'Switch to German'}); - await link.hover(); - await assertLocaleCookieValue(page, 'en'); - await link.click(); + const linkDe = page.getByRole('link', {name: 'Switch to German'}); + await linkDe.hover(); + await assertLocaleCookieValue(page, undefined); + await linkDe.click(); await expect(page).toHaveURL('/de'); await assertLocaleCookieValue(page, 'de'); + const linkEn = page.getByRole('link', {name: 'Zu Englisch wechseln'}); + await linkEn.hover(); + await assertLocaleCookieValue(page, 'de'); + await linkEn.click(); + + await expect(page).toHaveURL('/'); + await assertLocaleCookieValue(page, 'en'); + // Currently, a root layout outside of the `[locale]` // folder is required for this to work. expect(tracker.numPageLoads).toBe(1); diff --git a/examples/example-app-router-playground/tests/trailing-slash.spec.ts b/examples/example-app-router-playground/tests/trailing-slash.spec.ts index cdcea2132..30650ba7a 100644 --- a/examples/example-app-router-playground/tests/trailing-slash.spec.ts +++ b/examples/example-app-router-playground/tests/trailing-slash.spec.ts @@ -1,4 +1,4 @@ -import {test as it, expect} from '@playwright/test'; +import {expect, test as it} from '@playwright/test'; import {getAlternateLinks} from './utils'; it('redirects to a locale prefix correctly', async ({request}) => { diff --git a/examples/example-app-router-playground/tests/utils.ts b/examples/example-app-router-playground/tests/utils.ts index 259b175bf..c065d1f29 100644 --- a/examples/example-app-router-playground/tests/utils.ts +++ b/examples/example-app-router-playground/tests/utils.ts @@ -1,4 +1,4 @@ -import {APIResponse, expect, Page} from '@playwright/test'; +import {APIResponse, Page, expect} from '@playwright/test'; export async function getAlternateLinks(response: APIResponse) { return ( @@ -14,15 +14,19 @@ export async function getAlternateLinks(response: APIResponse) { export async function assertLocaleCookieValue( page: Page, - value: string, + value?: string, otherProps?: Record ) { const cookie = (await page.context().cookies()).find( (cur) => cur.name === 'NEXT_LOCALE' ); - expect(cookie).toMatchObject({ - name: 'NEXT_LOCALE', - value, - ...otherProps - }); + if (value) { + expect(cookie).toMatchObject({ + name: 'NEXT_LOCALE', + value, + ...otherProps + }); + } else { + expect(cookie).toBeUndefined(); + } } diff --git a/examples/example-app-router/tests/main.spec.ts b/examples/example-app-router/tests/main.spec.ts index 0456be01b..8fcb44cf8 100644 --- a/examples/example-app-router/tests/main.spec.ts +++ b/examples/example-app-router/tests/main.spec.ts @@ -1,4 +1,4 @@ -import {test as it, expect} from '@playwright/test'; +import {expect, test as it} from '@playwright/test'; it('handles i18n routing', async ({page}) => { await page.goto('/'); @@ -58,19 +58,13 @@ it('can be used to localize the page', async ({page}) => { page.getByRole('heading', {name: 'next-intl Beispiel'}); }); -it('sets a cookie', async ({page}) => { +it('sets a cookie when necessary', async ({page}) => { function getCookieValue() { return page.evaluate(() => document.cookie); } const response = await page.goto('/en'); - const value = await response?.headerValue('set-cookie'); - expect(value).toContain('NEXT_LOCALE=en;'); - expect(value).toContain('Path=/;'); - expect(value).toContain('SameSite=lax'); - expect(value).toContain('Max-Age=31536000;'); - expect(value).toContain('Expires='); - expect(await getCookieValue()).toBe('NEXT_LOCALE=en'); + expect(await response?.headerValue('set-cookie')).toBe(null); await page .getByRole('combobox', {name: 'Change language'}) @@ -93,6 +87,18 @@ it('sets a cookie', async ({page}) => { expect(await getCookieValue()).toBe('NEXT_LOCALE=de'); }); +it("sets a cookie when requesting a locale that doesn't match the `accept-language` header", async ({ + page +}) => { + const response = await page.goto('/de'); + const value = await response?.headerValue('set-cookie'); + expect(value).toContain('NEXT_LOCALE=de;'); + expect(value).toContain('Path=/;'); + expect(value).toContain('SameSite=lax'); + expect(value).toContain('Max-Age=18000;'); + expect(value).toContain('Expires='); +}); + it('serves a robots.txt', async ({page}) => { const response = await page.goto('/robots.txt'); const body = await response?.body(); diff --git a/packages/next-intl/src/middleware/middleware.test.tsx b/packages/next-intl/src/middleware/middleware.test.tsx index 1d2ca8791..d0923b474 100644 --- a/packages/next-intl/src/middleware/middleware.test.tsx +++ b/packages/next-intl/src/middleware/middleware.test.tsx @@ -287,14 +287,6 @@ describe('prefix-based routing', () => { ); }); - it('sets a cookie', () => { - const response = middleware(createMockRequest('/')); - expect(response.cookies.get('NEXT_LOCALE')).toEqual({ - name: 'NEXT_LOCALE', - value: 'en' - }); - }); - it('can turn off the cookie', () => { const response = createMiddleware({...routing, localeCookie: false})( createMockRequest('/') @@ -354,6 +346,13 @@ describe('prefix-based routing', () => { ); }); + it('sets a cookie when changing to the default locale', () => { + const response = middleware( + createMockRequest('/en', 'en', undefined, 'de') + ); + expect(response.cookies.get('NEXT_LOCALE')?.value).toBe('en'); + }); + it('always provides the locale via a request header, even if a cookie exists with the correct value (see https://github.com/amannn/next-intl/discussions/446)', () => { middleware(createMockRequest('/', 'en', 'http://localhost:3000', 'en')); expect(MockedNextResponse.rewrite).toHaveBeenCalled(); @@ -1022,13 +1021,6 @@ describe('prefix-based routing', () => { 'http://localhost:3000/en' ); }); - - it("doesn't set a cookie", () => { - const response = middleware( - createMockRequest('/', 'de', 'http://localhost:3000', undefined) - ); - expect(response.cookies.getAll()).toEqual([]); - }); }); describe('localePrefix: always', () => { @@ -1102,6 +1094,24 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).toHaveBeenCalledTimes(3); }); + it("does not set a cookie when the user's locale matches the prefix as well as the default locale", () => { + const response = middleware(createMockRequest('/en', 'en')); + expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined(); + }); + + it("does not set a cookie when the user's locale matches the prefix as well as a non-default locale", () => { + const response = middleware(createMockRequest('/de', 'de')); + expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined(); + }); + + it('sets a cookie when the user locale does not match the prefix', () => { + const response = middleware(createMockRequest('/en', 'de')); + expect(response.cookies.get('NEXT_LOCALE')).toEqual({ + name: 'NEXT_LOCALE', + value: 'en' + }); + }); + describe('base path', () => { it('redirects non-prefixed requests for the default locale', () => { middleware(withBasePath(createMockRequest('/'))); @@ -2011,22 +2021,26 @@ describe('prefix-based routing', () => { ); }); - it('sets a cookie', () => { + it('does not set a cookie by default', () => { const response = middleware(createMockRequest('/')); - expect(response.cookies.get('NEXT_LOCALE')).toEqual({ - name: 'NEXT_LOCALE', - value: 'en' - }); + expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined(); }); - it('sets a cookie based on accept-language header', () => { - const response = middleware(createMockRequest('/', 'de')); + it('sets a cookie if the user requests a different locale than what is configured in accept-language', () => { + const response = middleware(createMockRequest('/de', 'en')); expect(response.cookies.get('NEXT_LOCALE')).toEqual({ name: 'NEXT_LOCALE', value: 'de' }); }); + it('does not set a cookie if it is already set', () => { + const response = middleware( + createMockRequest('/de', 'en', undefined, 'de') + ); + expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined(); + }); + it('keeps a cookie if already set', () => { const response = middleware( createMockRequest('/', 'en', 'http://localhost:3000', 'de') @@ -2435,6 +2449,30 @@ describe('domain-based routing', () => { ); }); + it("doesn't set a cookie when on a domain that doesn't support the user's locale", () => { + const response = middleware( + createMockRequest('/', 'fr', 'http://en.example.com') + ); + expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined(); + }); + + it("doesn't set a cookie when on a domain that supports the user's locale", () => { + const response = middleware( + createMockRequest('/', 'fr', 'http://en.example.com') + ); + expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined(); + }); + + it("sets a cookie when on a domain that supports the user's locale and a different locale is requested", () => { + const response = middleware( + createMockRequest('/en', 'fr', 'http://ca.example.com') + ); + expect(response.cookies.get('NEXT_LOCALE')).toEqual({ + name: 'NEXT_LOCALE', + value: 'en' + }); + }); + describe('unknown hosts', () => { it('serves requests for unknown hosts at the root', () => { middleware(createMockRequest('/', 'en', 'http://localhost')); diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 2669e8bc6..0b7be26ab 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -299,9 +299,7 @@ export default function createMiddleware< } } - if (resolvedRouting.localeDetection && resolvedRouting.localeCookie) { - syncCookie(request, response, locale, resolvedRouting.localeCookie); - } + syncCookie(request, response, locale, resolvedRouting, domain); if ( resolvedRouting.localePrefix.mode !== 'never' && diff --git a/packages/next-intl/src/middleware/syncCookie.tsx b/packages/next-intl/src/middleware/syncCookie.tsx index eabe62549..ffd7ad46f 100644 --- a/packages/next-intl/src/middleware/syncCookie.tsx +++ b/packages/next-intl/src/middleware/syncCookie.tsx @@ -1,16 +1,52 @@ import {NextRequest, NextResponse} from 'next/server.js'; -import {LocaleCookieConfig} from '../routing/config.tsx'; +import { + InitializedLocaleCookieConfig, + ResolvedRoutingConfig +} from '../routing/config.tsx'; +import { + DomainConfig, + DomainsConfig, + LocalePrefixMode, + Locales, + Pathnames +} from '../routing/types.tsx'; +import {getAcceptLanguageLocale} from './resolveLocale.tsx'; -export default function syncCookie( +export default function syncCookie< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode, + AppPathnames extends Pathnames | undefined, + AppDomains extends DomainsConfig | undefined +>( request: NextRequest, response: NextResponse, locale: string, - localeCookie: LocaleCookieConfig + routing: Pick< + ResolvedRoutingConfig< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + >, + 'locales' | 'defaultLocale' + > & { + localeCookie: InitializedLocaleCookieConfig; + }, + domain?: DomainConfig ) { - const {name, ...rest} = localeCookie; - const hasOutdatedCookie = request.cookies.get(name)?.value !== locale; + if (!routing.localeCookie) return; - if (hasOutdatedCookie) { + const {name, ...rest} = routing.localeCookie; + const acceptLanguageLocale = getAcceptLanguageLocale( + request.headers, + domain?.locales || routing.locales, + routing.defaultLocale + ); + const hasLocaleCookie = request.cookies.has(name); + const hasOutdatedCookie = + hasLocaleCookie && request.cookies.get(name)?.value !== locale; + + if (hasLocaleCookie ? hasOutdatedCookie : acceptLanguageLocale !== locale) { response.cookies.set(name, locale, { path: request.nextUrl.basePath || undefined, ...rest diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index 754625dc8..551c7b3fc 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -365,7 +365,7 @@ describe("localePrefix: 'always', with `basePath`", () => { expect(cookieSpy).toHaveBeenCalledWith( [ 'NEXT_LOCALE=de', - 'max-age=31536000', + 'max-age=18000', 'sameSite=lax', 'path=/base/path' ].join(';') + ';' diff --git a/packages/next-intl/src/routing/config.tsx b/packages/next-intl/src/routing/config.tsx index 975a17841..3a8d018fd 100644 --- a/packages/next-intl/src/routing/config.tsx +++ b/packages/next-intl/src/routing/config.tsx @@ -157,7 +157,7 @@ function receiveLocaleCookie( return (localeCookie ?? true) ? { name: 'NEXT_LOCALE', - maxAge: 31536000, // 1 year + maxAge: 5 * 60 * 60, // 5 hours sameSite: 'lax', ...(typeof localeCookie === 'object' && localeCookie) From d83abc2950ef436482518fe2c99423552b792ca0 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 30 Oct 2024 16:43:12 +0100 Subject: [PATCH 21/90] feat: Return type-safe `IntlMessages` from `useMessages` & `getMessages` (#1489) If consumers use type-safe `IntlMessages`, these will now be used for `useMessages` and `getMessages`. Fixes https://github.com/amannn/next-intl/issues/1452 --- .github/workflows/main.yml | 2 +- .../src/app/[locale]/layout.tsx | 12 ++++++- .../src/components/MessagesTest.tsx | 31 +++++++++++++++++++ .../src/server/react-server/getMessages.tsx | 8 ++--- .../src/server/react-server/index.test.tsx | 2 +- .../src/core/AbstractIntlMessages.tsx | 5 +-- packages/use-intl/src/react/useMessages.tsx | 3 +- 7 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 examples/example-app-router-playground/src/components/MessagesTest.tsx diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d3f2d73f4..86d2cf7b7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,7 @@ on: jobs: build: name: Build, lint, and test - runs-on: ubuntu-latest + runs-on: macos-15 env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} diff --git a/examples/example-app-router-migration/src/app/[locale]/layout.tsx b/examples/example-app-router-migration/src/app/[locale]/layout.tsx index 5d3c87400..6864db0de 100644 --- a/examples/example-app-router-migration/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-migration/src/app/[locale]/layout.tsx @@ -1,4 +1,6 @@ import {notFound} from 'next/navigation'; +import {NextIntlClientProvider} from 'next-intl'; +import {getMessages} from 'next-intl/server'; import {ReactNode} from 'react'; import {routing} from '@/i18n/routing'; @@ -13,12 +15,20 @@ export default async function LocaleLayout({children, params}: Props) { notFound(); } + // Providing all messages to the client + // side is the easiest way to get started + const messages = await getMessages(); + return ( next-intl - {children} + + + {children} + + ); } diff --git a/examples/example-app-router-playground/src/components/MessagesTest.tsx b/examples/example-app-router-playground/src/components/MessagesTest.tsx new file mode 100644 index 000000000..88f725260 --- /dev/null +++ b/examples/example-app-router-playground/src/components/MessagesTest.tsx @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import {useMessages} from 'next-intl'; +import {getMessages} from 'next-intl/server'; + +export async function GetMessages() { + const messages = await getMessages(); + + // Valid + messages.Index; + messages.Index.title; + + // Invalid + // @ts-expect-error + messages.Unknown; + // @ts-expect-error + messages.Index.unknown; +} + +export function UseMessages() { + const messages = useMessages(); + + // Valid + messages.Index; + messages.Index.title; + + // Invalid + // @ts-expect-error + messages.Unknown; + // @ts-expect-error + messages.Index.unknown; +} diff --git a/packages/next-intl/src/server/react-server/getMessages.tsx b/packages/next-intl/src/server/react-server/getMessages.tsx index 4129b1b93..39ac4ca0d 100644 --- a/packages/next-intl/src/server/react-server/getMessages.tsx +++ b/packages/next-intl/src/server/react-server/getMessages.tsx @@ -1,10 +1,10 @@ import {cache} from 'react'; -import type {AbstractIntlMessages} from 'use-intl'; +import type {useMessages as useMessagesType} from 'use-intl'; import getConfig from './getConfig.tsx'; export function getMessagesFromConfig( config: Awaited> -): AbstractIntlMessages { +): ReturnType { if (!config.messages) { throw new Error( 'No messages found. Have you configured them correctly? See https://next-intl-docs.vercel.app/docs/configuration#messages' @@ -19,8 +19,6 @@ async function getMessagesCachedImpl(locale?: string) { } const getMessagesCached = cache(getMessagesCachedImpl); -export default async function getMessages(opts?: { - locale?: string; -}): Promise { +export default async function getMessages(opts?: {locale?: string}) { return getMessagesCached(opts?.locale); } diff --git a/packages/next-intl/src/server/react-server/index.test.tsx b/packages/next-intl/src/server/react-server/index.test.tsx index 2f0d92f67..8f6054387 100644 --- a/packages/next-intl/src/server/react-server/index.test.tsx +++ b/packages/next-intl/src/server/react-server/index.test.tsx @@ -150,7 +150,7 @@ describe('getMessages', () => { const messages = await getMessages(); // @ts-expect-error - messages.about(); + messages(); // Valid return messages.about; diff --git a/packages/use-intl/src/core/AbstractIntlMessages.tsx b/packages/use-intl/src/core/AbstractIntlMessages.tsx index 42f02a333..3a62dec89 100644 --- a/packages/use-intl/src/core/AbstractIntlMessages.tsx +++ b/packages/use-intl/src/core/AbstractIntlMessages.tsx @@ -1,6 +1,7 @@ -/** A generic type that describes the shape of messages. +/** + * A generic type that describes the shape of messages. * - * Optionally `IntlMessages` can be provided to get type safety for message + * Optionally, `IntlMessages` can be provided to get type safety for message * namespaces and keys. See https://next-intl-docs.vercel.app/docs/usage/typescript */ type AbstractIntlMessages = { diff --git a/packages/use-intl/src/react/useMessages.tsx b/packages/use-intl/src/react/useMessages.tsx index 31a4bf48a..e921c4a0e 100644 --- a/packages/use-intl/src/react/useMessages.tsx +++ b/packages/use-intl/src/react/useMessages.tsx @@ -1,7 +1,6 @@ -import {AbstractIntlMessages} from '../core.tsx'; import useIntlContext from './useIntlContext.tsx'; -export default function useMessages(): AbstractIntlMessages { +export default function useMessages(): IntlMessages { const context = useIntlContext(); if (!context.messages) { From a7aaf56e79bb25bf7c8626f89cb16bf3ee4d5ac6 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 1 Nov 2024 09:54:18 +0100 Subject: [PATCH 22/90] feat!: Revamp augmented types and add support for typed `Locale` (#1495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Changes** - Revamps the API to augment types by getting rid of the global `IntlMessages` and `IntlFormats` in favor of a more general `AppConfig` that is scoped to `next-intl`. - Adds support for strictly-typing the locale across `useLocale` as well as the navigation APIs. - Adds `import {Locale} from 'next-intl';` as a convenience API to be reused wherever a `locale` is passed around. - Add `hasLocale(locales, candidate)` API for simplified checking of whether a locale is available with TypeScript. - Adds a new `import {Messages} from 'next-intl;` type that corresponds to the `Messages` you've provided in `AppConfig` (probably rarely needed). **Example:** ```tsx // global.d.ts import {routing} from '@/i18n/routing'; import {formats} from '@/i18n/request'; import en from './messages/en.json'; declare module 'next-intl' { interface AppConfig { Locale: (typeof routing.locales)[number]; Formats: typeof formats; Messages: typeof en; } } ``` **→ [Proposed docs](https://next-intl-docs-git-feat-augmented-config-next-intl.vercel.app/docs/workflows/typescript)** Fixes https://github.com/amannn/next-intl/issues/1377 --- .../actions-metadata-route-handlers.mdx | 9 +- .../pages/docs/environments/error-files.mdx | 7 +- .../app-router/with-i18n-routing.mdx | 24 +- docs/src/pages/docs/routing.mdx | 3 +- docs/src/pages/docs/usage/configuration.mdx | 336 ++++++++++-------- docs/src/pages/docs/usage/messages.mdx | 2 +- docs/src/pages/docs/workflows.mdx | 4 +- docs/src/pages/docs/workflows/_meta.tsx | 2 +- .../workflows/localization-management.mdx | 2 +- docs/src/pages/docs/workflows/typescript.mdx | 175 +++++++-- .../src/app/[locale]/layout.tsx | 5 +- .../src/i18n/request.ts | 13 +- .../global.d.ts | 11 +- .../PublicNavigationLocaleSwitcher.tsx | 3 +- .../src/app/(public)/[locale]/about/page.tsx | 4 +- .../src/app/(public)/[locale]/layout.tsx | 12 +- .../src/app/(public)/[locale]/page.tsx | 4 +- .../app/app/AppNavigationLocaleSwitcher.tsx | 3 +- .../src/config.ts | 4 +- .../src/db.ts | 8 +- .../src/i18n/request.ts | 13 +- .../example-app-router-next-auth/global.d.ts | 11 +- .../src/app/[locale]/layout.tsx | 7 +- .../src/i18n/request.ts | 13 +- .../example-app-router-playground/global.d.ts | 18 +- .../src/app/[locale]/about/page.tsx | 4 +- .../src/app/[locale]/api/route.ts | 3 +- .../src/app/[locale]/client/ClientContent.tsx | 22 +- .../src/app/[locale]/layout.tsx | 8 +- .../app/[locale]/news/[articleId]/page.tsx | 4 +- .../src/app/[locale]/opengraph-image.tsx | 3 +- .../src/components/AsyncComponent.tsx | 20 +- .../src/components/UseFormatterTypeTests.tsx | 44 +++ .../src/components/UseLocaleTypeTests.tsx | 62 ++++ ...sagesTest.tsx => UseMessagesTypeTests.tsx} | 4 +- .../src/i18n/request.tsx | 19 +- .../src/i18n/routing.ts | 3 - examples/example-app-router/global.d.ts | 11 +- .../src/app/[locale]/layout.tsx | 6 +- .../src/app/[locale]/page.tsx | 4 +- .../src/app/[locale]/pathnames/page.tsx | 4 +- .../example-app-router/src/app/sitemap.ts | 3 +- .../src/components/LocaleSwitcherSelect.tsx | 3 +- .../example-app-router/src/i18n/request.ts | 13 +- .../example-app-router/src/i18n/routing.ts | 3 - .../example-pages-router-advanced/global.d.ts | 9 +- .../src/pages/_app.tsx | 4 +- examples/example-pages-router/global.d.ts | 9 +- examples/example-use-intl/global.d.ts | 10 + examples/example-use-intl/messages/en.json | 5 + examples/example-use-intl/src/config.tsx | 1 + examples/example-use-intl/src/main.tsx | 7 +- examples/example-use-intl/tsconfig.json | 2 +- packages/next-intl/.size-limit.ts | 2 +- .../getAlternateLinksHeaderValue.tsx | 2 +- .../src/middleware/resolveLocale.tsx | 10 +- .../next-intl/src/middleware/syncCookie.tsx | 3 +- packages/next-intl/src/middleware/utils.tsx | 5 +- .../src/navigation/createNavigation.test.tsx | 46 ++- .../react-client/createNavigation.test.tsx | 25 +- .../react-client/createNavigation.tsx | 15 +- .../react-server/createNavigation.tsx | 8 +- .../src/navigation/shared/BaseLink.tsx | 7 +- .../shared/createSharedNavigationFns.tsx | 18 +- .../navigation/shared/syncLocaleCookie.tsx | 5 +- .../next-intl/src/navigation/shared/utils.tsx | 3 +- .../next-intl/src/react-client/useLocale.tsx | 2 +- .../src/react-server/getTranslator.tsx | 26 +- packages/next-intl/src/routing/types.tsx | 2 + .../src/server/react-server/RequestLocale.tsx | 3 +- .../react-server/RequestLocaleCache.tsx | 5 +- .../src/server/react-server/getConfig.tsx | 5 +- .../src/server/react-server/getFormatter.tsx | 6 +- .../src/server/react-server/getLocale.tsx | 3 +- .../src/server/react-server/getMessages.tsx | 8 +- .../src/server/react-server/getNow.tsx | 5 +- .../src/server/react-server/getTimeZone.tsx | 7 +- .../server/react-server/getTranslations.tsx | 59 ++- .../shared/NextIntlClientProvider.test.tsx | 3 +- .../src/shared/NextIntlClientProvider.tsx | 3 +- packages/next-intl/types/index.d.ts | 2 - packages/use-intl/.size-limit.ts | 8 +- .../src/core/AbstractIntlMessages.tsx | 2 +- packages/use-intl/src/core/AppConfig.tsx | 37 ++ packages/use-intl/src/core/IntlConfig.tsx | 3 +- .../src/core/createBaseTranslator.tsx | 5 +- .../use-intl/src/core/createFormatter.tsx | 19 +- .../use-intl/src/core/createTranslator.tsx | 32 +- packages/use-intl/src/core/hasLocale.test.tsx | 95 +++++ packages/use-intl/src/core/hasLocale.tsx | 31 ++ packages/use-intl/src/core/index.tsx | 2 + packages/use-intl/src/react/index.test.tsx | 3 +- packages/use-intl/src/react/useLocale.tsx | 3 +- packages/use-intl/src/react/useMessages.tsx | 3 +- .../use-intl/src/react/useTranslations.tsx | 30 +- packages/use-intl/types/index.d.ts | 13 - 96 files changed, 979 insertions(+), 578 deletions(-) create mode 100644 examples/example-app-router-playground/src/components/UseFormatterTypeTests.tsx create mode 100644 examples/example-app-router-playground/src/components/UseLocaleTypeTests.tsx rename examples/example-app-router-playground/src/components/{MessagesTest.tsx => UseMessagesTypeTests.tsx} (87%) create mode 100644 examples/example-use-intl/global.d.ts create mode 100644 examples/example-use-intl/messages/en.json create mode 100644 examples/example-use-intl/src/config.tsx create mode 100644 packages/use-intl/src/core/AppConfig.tsx create mode 100644 packages/use-intl/src/core/hasLocale.test.tsx create mode 100644 packages/use-intl/src/core/hasLocale.tsx diff --git a/docs/src/pages/docs/environments/actions-metadata-route-handlers.mdx b/docs/src/pages/docs/environments/actions-metadata-route-handlers.mdx index 01f8eb579..16a926ac1 100644 --- a/docs/src/pages/docs/environments/actions-metadata-route-handlers.mdx +++ b/docs/src/pages/docs/environments/actions-metadata-route-handlers.mdx @@ -154,8 +154,9 @@ Note that by default, `next-intl` returns [the `link` response header](/docs/rou Next.js supports providing alternate URLs per language via the [`alternates` entry](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap#generate-a-localized-sitemap) as of version 14.2. You can use your default locale for the main URL and provide alternate URLs based on all locales that your app supports. Keep in mind that also the default locale should be included in the `alternates` object. -```tsx filename="app/sitemap.ts" {4-5,8-9} +```tsx filename="app/sitemap.ts" {5-6,9-10} import {MetadataRoute} from 'next'; +import {Locale} from 'next-intl'; import {routing, getPathname} from '@/i18n/routing'; // Adapt this as necessary @@ -179,7 +180,7 @@ function getEntry(href: Href) { }; } -function getUrl(href: Href, locale: (typeof routing.locales)[number]) { +function getUrl(href: Href, locale: Locale) { const pathname = getPathname({locale, href}); return host + pathname; } @@ -206,12 +207,16 @@ You can use `next-intl` in [Route Handlers](https://nextjs.org/docs/app/building ```tsx filename="app/api/hello/route.tsx" import {NextResponse} from 'next/server'; +import {hasLocale} from 'next-intl'; import {getTranslations} from 'next-intl/server'; export async function GET(request) { // Example: Receive the `locale` via a search param const {searchParams} = new URL(request.url); const locale = searchParams.get('locale'); + if (!hasLocale(locales, locale)) { + return NextResponse.json({error: 'Invalid locale'}, {status: 400}); + } const t = await getTranslations({locale, namespace: 'Hello'}); return NextResponse.json({title: t('title')}); diff --git a/docs/src/pages/docs/environments/error-files.mdx b/docs/src/pages/docs/environments/error-files.mdx index 90da28c7c..9ac7e5233 100644 --- a/docs/src/pages/docs/environments/error-files.mdx +++ b/docs/src/pages/docs/environments/error-files.mdx @@ -77,14 +77,15 @@ export default function RootLayout({children}) { } ``` -For the 404 page to render, we need to call the `notFound` function in the root layout when we detect an incoming `locale` param that isn't a valid locale. +For the 404 page to render, we need to call the `notFound` function in the root layout when we detect an incoming `locale` param that isn't valid. ```tsx filename="app/[locale]/layout.tsx" +import {hasLocale} from 'next-intl'; import {notFound} from 'next/navigation'; +import {routing} from '@/i18n/routing'; export default function LocaleLayout({children, params: {locale}}) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } diff --git a/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx b/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx index 6456eeb48..d2d8a4498 100644 --- a/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx +++ b/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx @@ -147,16 +147,15 @@ When using features from `next-intl` in Server Components, the relevant configur ```tsx filename="src/i18n/request.ts" import {getRequestConfig} from 'next-intl/server'; +import {hasLocale} from 'next-intl'; import {routing} from './routing'; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment - let locale = await requestLocale; - - // Ensure that a valid locale is used - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; return { locale, @@ -186,7 +185,7 @@ const withNextIntl = createNextIntlPlugin( The `locale` that was matched by the middleware is available via the `locale` param and can be used to configure the document language. Additionally, we can use this place to pass configuration from `i18n/request.ts` to Client Components via `NextIntlClientProvider`. ```tsx filename="app/[locale]/layout.tsx" -import {NextIntlClientProvider} from 'next-intl'; +import {NextIntlClientProvider, Locale, hasLocale} from 'next-intl'; import {getMessages} from 'next-intl/server'; import {notFound} from 'next/navigation'; import {routing} from '@/i18n/routing'; @@ -196,10 +195,9 @@ export default async function LocaleLayout({ params: {locale} }: { children: React.ReactNode; - params: {locale: string}; + params: {locale: Locale}; }) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } @@ -297,12 +295,12 @@ export function generateStaticParams() { ```tsx filename="app/[locale]/layout.tsx" import {setRequestLocale} from 'next-intl/server'; +import {hasLocale} from 'next-intl'; import {notFound} from 'next/navigation'; import {routing} from '@/i18n/routing'; export default async function LocaleLayout({children, params: {locale}}) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } diff --git a/docs/src/pages/docs/routing.mdx b/docs/src/pages/docs/routing.mdx index 6dffa60c9..b79c35a38 100644 --- a/docs/src/pages/docs/routing.mdx +++ b/docs/src/pages/docs/routing.mdx @@ -297,11 +297,12 @@ In case you're using a system like a CMS to configure localized pathnames, you'l ```tsx filename="page.tsx" import {notFound} from 'next'; +import {Locale} from 'next-intl'; import {fetchContent} from './cms'; type Props = { params: { - locale: string; + locale: Locale; slug: Array; }; }; diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx index dd2055702..574542e8a 100644 --- a/docs/src/pages/docs/usage/configuration.mdx +++ b/docs/src/pages/docs/usage/configuration.mdx @@ -15,51 +15,21 @@ Depending on if you handle [internationalization in Server- or Client Components `i18n/request.ts` can be used to provide configuration for **server-only** code, i.e. Server Components, Server Actions & friends. The configuration is provided via the `getRequestConfig` function and needs to be set up based on whether you're using [i18n routing](/docs/getting-started/app-router) or not. - - - - ```tsx filename="i18n/request.ts" import {getRequestConfig} from 'next-intl/server'; import {routing} from '@/i18n/routing'; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment. - let locale = await requestLocale; - - // Ensure that a valid locale is used - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // ... return { locale, - messages: (await import(`../../messages/${locale}.json`)).default - }; -}); -``` - - - - -```tsx filename="i18n/request.ts" -import {getRequestConfig} from 'next-intl/server'; - -export default getRequestConfig(async () => { - // Provide a static locale, fetch a user setting, - // read from `cookies()`, `headers()`, etc. - const locale = 'en'; - - return { - locale, - messages: (await import(`../../messages/${locale}.json`)).default + messages + // ... }; }); ``` - - - The configuration object is created once for each request by internally using React's [`cache`](https://react.dev/reference/react/cache). The first component to use internationalization will call the function defined with `getRequestConfig`. Since this function is executed during the Server Components render pass, you can call functions like [`cookies()`](https://nextjs.org/docs/app/api-reference/functions/cookies) and [`headers()`](https://nextjs.org/docs/app/api-reference/functions/headers) to return configuration that is request-specific. @@ -80,17 +50,6 @@ const withNextIntl = createNextIntlPlugin(
-
-Which values can the `requestLocale` parameter hold? - -While the `requestLocale` parameter typically corresponds to the `[locale]` segment that was matched by the middleware, there are three special cases to consider: - -1. **Overrides**: When an explicit `locale` is passed to [awaitable functions](/docs/environments/actions-metadata-route-handlers) like `getTranslations({locale: 'en'})`, then this value will be used instead of the segment. -1. **`undefined`**: The value can be `undefined` when a page outside of the `[locale]` segment renders (e.g. a language selection page at `app/page.tsx`). -1. **Invalid values**: Since the `[locale]` segment effectively acts like a catch-all for unknown routes (e.g. `/unknown.txt`), invalid values should be replaced with a valid locale. In addition to this, you might want to call `notFound()` in [the root layout](/docs/getting-started/app-router/with-i18n-routing#layout) to abort the render in this case. - -
- ### `NextIntlClientProvider` `NextIntlClientProvider` can be used to provide configuration for **Client Components**. @@ -100,9 +59,7 @@ import {NextIntlClientProvider} from 'next-intl'; import {getMessages} from 'next-intl/server'; export default async function RootLayout(/* ... */) { - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); + // ... return ( @@ -183,6 +140,137 @@ Note that the inner `NextIntlClientProvider` inherits the configuration from the
+## Locale + +The `locale` represents an identifier that contains the language and formatting preferences of users, optionally including regional information (e.g. `en-US`). Locales are specified as [IETF BCP 47 language tags](https://en.wikipedia.org/wiki/IETF_language_tag). + + + + +Depending on if you're using [i18n routing](/docs/getting-started/app-router), you can read the locale from the `requestLocale` parameter or provide a value on your own: + +**With i18n routing:** + +```tsx filename="i18n/request.ts" +export default getRequestConfig(async ({requestLocale}) => { + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; + + return { + locale + // ... + }; +}); +``` + +**Without i18n routing:** + +```tsx filename="i18n/request.ts" +export default getRequestConfig(async () => { + return { + locale: 'en' + // ... + }; +}); +``` + + + + +```tsx +... +``` + + + + +
+Which values can the `requestLocale` parameter hold? + +While the `requestLocale` parameter typically corresponds to the `[locale]` segment that was matched by the middleware, there are three special cases to consider: + +1. **Overrides**: When an explicit `locale` is passed to [awaitable functions](/docs/environments/actions-metadata-route-handlers) like `getTranslations({locale: 'en'})`, then this value will be used instead of the segment. +1. **`undefined`**: The value can be `undefined` when a page outside of the `[locale]` segment renders (e.g. a language selection page at `app/page.tsx`). +1. **Invalid values**: Since the `[locale]` segment effectively acts like a catch-all for unknown routes (e.g. `/unknown.txt`), invalid values should be replaced with a valid locale. In addition to this, you might want to call `notFound()` in [the root layout](/docs/getting-started/app-router/with-i18n-routing#layout) to abort the render in this case. + +
+ +### `useLocale` & `getLocale` [#use-locale] + +The current locale of your app is automatically incorporated into hooks like `useTranslations` & `useFormatter` and will affect the rendered output. + +In case you need to use this value in other places of your app, e.g. to implement a locale switcher or to pass it to API calls, you can read it via `useLocale` or `getLocale`: + +```tsx +// Regular components +import {useLocale} from 'next-intl'; +const locale = useLocale(); + +// Async Server Components +import {getLocale} from 'next-intl/server'; +const locale = await getLocale(); +``` + +When passing a `locale` to another function, you can use the `Locale` type for the receiving parameter: + +```tsx +import {Locale} from 'next-intl'; + +async function getPosts(locale: Locale) { + // ... +} +``` + + + By default, `Locale` is typed as `string`. However, you can optionally provide + a strict union based on your supported locales for this type by [augmenting + the `Locale` type](/docs/workflows/typescript#locale). + + +
+How can I change the locale? + +Depending on if you're using [i18n routing](/docs/getting-started/app-router), the locale can be changed as follows: + +1. **With i18n routing**: The locale is managed by the router and can be changed by using navigation APIs from `next-intl` like [`Link`](/docs/routing/navigation#link) or [`useRouter`](/docs/routing/navigation#userouter). +2. **Without i18n routing**: You can change the locale by updating the value where the locale is read from (e.g. a cookie, a user setting, etc.). If you're looking for inspiration, you can have a look at the [App Router without i18n routing example](/examples#app-router-without-i18n-routing) that manages the locale via a cookie. + +
+ +
+Which value is returned from `useLocale`? + +The returned value is resolved based on these priorities: + +1. **Server Components**: If you're using [i18n routing](/docs/getting-started/app-router), the returned locale is the one that you've either provided via [`setRequestLocale`](/docs/getting-started/app-router/with-i18n-routing#static-rendering) or alternatively the one in the `[locale]` segment that was matched by the middleware. If you're not using i18n routing, the returned locale is the one that you've provided via `getRequestConfig`. +2. **Client Components**: In this case, the locale is received from `NextIntlClientProvider` or alternatively `useParams().locale`. Note that `NextIntlClientProvider` automatically inherits the locale if the component is rendered by a Server Component. + +
+ +
+I'm using the Pages Router, how can I access the locale? + +If you use [internationalized routing with the Pages Router](https://nextjs.org/docs/pages/building-your-application/routing/internationalization), you can receive the locale from the router in order to pass it to `NextIntlClientProvider`: + +```tsx filename="_app.tsx" +import {useRouter} from 'next/router'; + +// ... + +const router = useRouter(); + +return ( + + ... + ; +); +``` + +
+ ## Messages The most crucial aspect of internationalization is providing labels based on the user's language. The recommended workflow is to store your messages in your repository along with the code. @@ -215,40 +303,6 @@ export default getRequestConfig(async () => { After messages are configured, they can be used via [`useTranslations`](/docs/usage/messages#rendering-messages-with-usetranslations). -In case you require access to messages in a component, you can read them via `useMessages()` or `getMessages()` from your configuration: - -```tsx -// Regular components -import {useMessages} from 'next-intl'; -const messages = useMessages(); - -// Async Server Components -import {getMessages} from 'next-intl/server'; -const messages = await getMessages(); -``` - - - - -```tsx -import {NextIntlClientProvider} from 'next-intl'; -import {getMessages} from 'next-intl/server'; - -async function Component({children}) { - // Read messages configured via `i18n/request.ts` - const messages = await getMessages(); - - return ( - - {children} - - ); -} -``` - - - -
How can I load messages from remote sources? @@ -298,6 +352,42 @@ Note that [the VSCode integration for `next-intl`](/docs/workflows/vscode-integr
+### `useMessages` & `getMessages` [#use-messages] + +In case you require access to messages in a component, you can read them via `useMessages()` or `getMessages()` from your configuration: + +```tsx +// Regular components +import {useMessages} from 'next-intl'; +const messages = useMessages(); + +// Async Server Components +import {getMessages} from 'next-intl/server'; +const messages = await getMessages(); +``` + + + + +```tsx +import {NextIntlClientProvider} from 'next-intl'; +import {getMessages} from 'next-intl/server'; + +async function Component({children}) { + // Read messages configured via `i18n/request.ts` + const messages = await getMessages(); + + return ( + + {children} + + ); +} +``` + + + + ## Time zone Specifying a time zone affects the rendering of dates and times. By default, the time zone of the server runtime will be used, but can be customized as necessary. @@ -337,6 +427,10 @@ const timeZone = 'Europe/Vienna'; The available time zone names can be looked up in [the tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). +The time zone in Client Components is automatically inherited from the server side if you wrap the relevant components in a `NextIntlClientProvider` that is rendered by a Server Component. For all other cases, you can specify the value explicitly on a wrapping `NextIntlClientProvider`. + +### `useTimeZone` & `getTimeZone` [#use-time-zone] + The configured time zone can be read via `useTimeZone` or `getTimeZone` in components: ```tsx @@ -349,11 +443,6 @@ import {getTimeZone} from 'next-intl/server'; const timeZone = await getTimeZone(); ``` -The time zone in Client Components is automatically inherited from the server -side if you wrap the relevant components in a `NextIntlClientProvider` that is -rendered by a Server Component. For all other cases, you can specify the value -explicitly on a wrapping `NextIntlClientProvider`. - ## Now value [#now] When formatting [relative dates and times](/docs/usage/dates-times#formatting-relative-time), `next-intl` will format times in relation to a reference point in time that is referred to as "now". By default, this is the time a component renders. @@ -391,6 +480,10 @@ const now = new Date('2020-11-20T10:36:01.516Z'); +Similarly to the `timeZone`, the `now` value in Client Components is automatically inherited from the server side if you wrap the relevant components in a `NextIntlClientProvider` that is rendered by a Server Component. + +### `useNow` & `getNow` [#use-now] + The configured `now` value can be read in components via `useNow` or `getNow`: ```tsx @@ -403,11 +496,6 @@ import {getNow} from 'next-intl/server'; const now = await getNow(); ``` -Similarly to the `timeZone`, the `now` value in Client Components is -automatically inherited from the server side if you wrap the relevant -components in a `NextIntlClientProvider` that is rendered by a Server -Component. - ## Formats To achieve consistent date, time, number and list formatting, you can define a set of global formats. @@ -446,8 +534,6 @@ export default getRequestConfig(async () => { }); ``` -Note that `formats` are not automatically inherited by Client Components. If you want to make this available in Client Components, you should provide the same configuration to [`NextIntlClientProvider`](#nextintlclientprovider). - @@ -496,9 +582,9 @@ function Component() { ``` - You can optionally [specify a global type for - `formats`](/docs/workflows/typescript#formats) to get autocompletion and type - safety. + By default, format names are loosely typed as `string`. However, you can + optionally use strict types by [augmenting the `Formats` + type](/docs/workflows/typescript#formats). Global formats for numbers, dates and times can be referenced in messages too: @@ -601,61 +687,3 @@ function getMessageFallback({namespace, key, error}) { - -## Locale - -The current locale of your app is automatically incorporated into hooks like `useTranslations` & `useFormatter` and will affect the rendered output. - -In case you need to use this value in other places of your app, e.g. to implement a locale switcher or to pass it to API calls, you can read it via `useLocale` or `getLocale`: - -```tsx -// Regular components -import {useLocale} from 'next-intl'; -const locale = useLocale(); - -// Async Server Components -import {getLocale} from 'next-intl/server'; -const locale = await getLocale(); -``` - -
-How can I change the locale? - -Depending on if you're using [i18n routing](/docs/getting-started/app-router), the locale can be changed as follows: - -1. **With i18n routing**: The locale is managed by the router and can be changed by using navigation APIs from `next-intl` like [`Link`](/docs/routing/navigation#link) or [`useRouter`](/docs/routing/navigation#userouter). -2. **Without i18n routing**: You can change the locale by updating the value where the locale is read from (e.g. a cookie, a user setting, etc.). If you're looking for inspiration, you can have a look at the [App Router without i18n routing example](/examples#app-router-without-i18n-routing) that manages the locale via a cookie. - -
- -
-Which value is returned from `useLocale`? - -The returned value is resolved based on these priorities: - -1. **Server Components**: If you're using [i18n routing](/docs/getting-started/app-router), the returned locale is the one that you've either provided via [`setRequestLocale`](/docs/getting-started/app-router/with-i18n-routing#static-rendering) or alternatively the one in the `[locale]` segment that was matched by the middleware. If you're not using i18n routing, the returned locale is the one that you've provided via `getRequestConfig`. -2. **Client Components**: In this case, the locale is received from `NextIntlClientProvider` or alternatively `useParams().locale`. Note that `NextIntlClientProvider` automatically inherits the locale if the component is rendered by a Server Component. For all other cases, you can specify the value - explicitly. - -
- -
-I'm using the Pages Router, how can I access the locale? - -If you use [internationalized routing with the Pages Router](https://nextjs.org/docs/pages/building-your-application/routing/internationalization), you can receive the locale from the router in order to pass it to `NextIntlClientProvider`: - -```tsx filename="_app.tsx" -import {useRouter} from 'next/router'; - -// ... - -const router = useRouter(); - -return ( - - ... - ; -); -``` - -
diff --git a/docs/src/pages/docs/usage/messages.mdx b/docs/src/pages/docs/usage/messages.mdx index f2d5233c6..e88421d9b 100644 --- a/docs/src/pages/docs/usage/messages.mdx +++ b/docs/src/pages/docs/usage/messages.mdx @@ -8,7 +8,7 @@ The main part of handling internationalization (typically referred to as _i18n_) ## Terminology -- **Locale**: We use this term to describe an identifier that contains the language and formatting preferences of users. Apart from the language, a locale can include optional regional information (e.g. `en-US`). Locales are specified as [IETF BCP 47 language tags](https://en.wikipedia.org/wiki/IETF_language_tag). +- **Locale**: We use this term to describe an identifier that contains the language and formatting preferences of users. Apart from the language, a locale can include optional regional information (e.g. `en-US`). - **Messages**: These are collections of namespace-label pairs that are grouped by locale (e.g. `en-US.json`). ## Structuring messages diff --git a/docs/src/pages/docs/workflows.mdx b/docs/src/pages/docs/workflows.mdx index 746ad13d5..89b597c1c 100644 --- a/docs/src/pages/docs/workflows.mdx +++ b/docs/src/pages/docs/workflows.mdx @@ -3,10 +3,10 @@ import Cards from '@/components/Cards'; # Workflows & integrations -To get the most out of `next-intl`, you can choose from these integrations to improve your workflow when developing and collaborating with translators. +To get the most out of `next-intl`, you can choose from these integrations to improve your workflow. - + - The [TypeScript integration of `next-intl`](/docs/workflows/typescript) can + The [TypeScript augmentation of `next-intl`](/docs/workflows/typescript) can help you to validate at compile time that your app is in sync with your translation bundles. diff --git a/docs/src/pages/docs/workflows/typescript.mdx b/docs/src/pages/docs/workflows/typescript.mdx index 3c6eae8c0..878d5003d 100644 --- a/docs/src/pages/docs/workflows/typescript.mdx +++ b/docs/src/pages/docs/workflows/typescript.mdx @@ -1,12 +1,28 @@ +import Details from '@/components/Details'; +import {Tabs} from 'nextra/components'; import Callout from '@/components/Callout'; -# TypeScript integration +# TypeScript augmentation `next-intl` integrates seamlessly with TypeScript right out of the box, requiring no additional setup. -However, you can optionally provide supplemental type definitions for your messages and formats to enable autocompletion and improve type safety. +However, you can optionally provide supplemental definitions to augment the types that `next-intl` works with, enabling improved autocompletion and type safety across your app. -## Messages +```tsx filename="global.d.ts" +declare module 'next-intl' { + interface AppConfig { + // ... + } +} +``` + +Type augmentation is available for: + +- [`Messages`](#messages) +- [`Formats`](#formats) +- [`Locale`](#locale) + +## `Messages` Messages can be strictly typed to ensure you're using valid keys. @@ -31,44 +47,44 @@ function About() { } ``` -To enable this validation, add a global type definition file in your project root (e.g. `global.d.ts`): +To enable this validation, you can adapt `AppConfig` as follows: ```ts filename="global.d.ts" import en from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + // ... + Messages: typeof en; + } } ``` -You can freely define the interface, but if you have your messages available locally, it can be helpful to automatically create the interface based on the messages from your default locale by importing it. +You can freely define the interface, but if you have your messages available locally, it can be helpful to automatically create the type based on the messages from your default locale. -## Formats +## `Formats` -[Global formats](/docs/usage/configuration#formats) that are referenced in calls like `format.dateTime` can be strictly typed to ensure you're using valid format names across your app. +If you're using [global formats](/docs/usage/configuration#formats), you can strictly type the format names that are referenced in calls to `format.dateTime`, `format.number` and `format.list`. ```tsx function Component() { const format = useFormatter(); - // ✅ Valid format - format.number(2, 'precise'); - - // ✅ Valid format - format.list(['HTML', 'CSS', 'JavaScript'], 'enumeration'); - // ✖️ Unknown format string format.dateTime(new Date(), 'unknown'); // ✅ Valid format format.dateTime(new Date(), 'short'); + + // ✅ Valid format + format.number(2, 'precise'); + + // ✅ Valid format + format.list(['HTML', 'CSS', 'JavaScript'], 'enumeration'); } ``` -To enable this validation, export the formats that you're using in your request configuration: +To enable this validation, export the formats that you're using e.g. from your request configuration: ```ts filename="i18n/request.ts" import {Formats} from 'next-intl'; @@ -97,16 +113,121 @@ export const formats = { // ... ``` -Now, a global type definition file in the root of your project can pick up the shape of your formats and use them for declaring the `IntlFormats` interface: +Now, you can include the `formats` in your `AppConfig`: ```ts filename="global.d.ts" -import {formats} from './src/i18n/request'; +import {formats} from '@/i18n/request'; + +declare module 'next-intl' { + interface AppConfig { + // ... + Formats: typeof formats; + } +} +``` + +## `Locale` + +Augmenting the `Locale` type will affect the return type of [`useLocale`](/docs/usage/configuration#locale), as well as all `locale` arguments that are accepted by APIs from `next-intl` (e.g. the `locale` prop of [``](/docs/routing/navigation#link)). + +```tsx +// ✅ 'en' | 'de' +const locale = useLocale(); +``` + +To enable this validation, you can adapt `AppConfig` as follows: + + + + +```tsx filename="global.d.ts" +import {routing} from '@/i18n/routing'; -type Formats = typeof formats; +declare module 'next-intl' { + interface AppConfig { + // ... + Locale: (typeof routing.locales)[number]; + } +} +``` + + + + +```tsx filename="global.d.ts" +// Potentially imported from a shared config +const locales = ['en', 'de'] as const; + +declare module 'next-intl' { + interface AppConfig { + // ... + Locale: (typeof locales)[number]; + } +} +``` + + + + +### Using the `Locale` type for arguments + +Once the `Locale` type is augmented, it can be used across your codebase if you need to pass the locale to functions outside of your components: + +```tsx {1,10} +import {Locale} from 'next-intl'; +import {getLocale} from 'next-intl/server'; + +async function BlogPosts() { + const locale = await getLocale(); + const posts = await getPosts(locale); + // ... +} + +async function getPosts(locale: Locale) { + // ... +} +``` + +### Using the `Locale` type for layout and page params [#locale-segment-params] + +You can also use the `Locale` type when working with the `[locale]` parameter in layouts and pages: + +```tsx filename="app/[locale]/page.tsx" +import {Locale} from 'next-intl'; + +type Props = { + params: { + locale: Locale; + }; +}; + +export default function Page(props: Props) { + // ... +} +``` + +However, keep in mind that this _assumes_ the locale to be valid in this place—Next.js doesn't validate the `[locale]` parameter automatically for you. Due to this, you can add your own validation logic in a central place like the root layout: + +```tsx filename="app/[locale]/layout.tsx" +import {hasLocale} from 'next-intl'; + +// Can be imported e.g. from `@/i18n/routing` +const locales = ['en', 'de'] as const; + +type Props = { + params: { + children: React.ReactNode; + locale: string; + }; +}; + +export default async function LocaleLayout({params: {locale}}: Props) { + if (!hasLocale(locales, locale)) { + notFound(); + } -declare global { - // Use type safe formats with `next-intl` - interface IntlFormats extends Formats {} + // ✅ 'en' | 'de' + console.log(locale); } ``` @@ -114,7 +235,7 @@ declare global { If you're encountering problems, double check that: -1. Your interface uses the correct name. +1. The interface uses the correct name `AppConfig`. 2. You're using correct paths for all modules you're importing into your global declaration file. 3. Your type declaration file is included in `tsconfig.json`. -4. Your editor has loaded the most recent type declarations. When in doubt, you can restart. +4. Your editor has loaded the latest types. When in doubt, restart your editor. diff --git a/examples/example-app-router-migration/src/app/[locale]/layout.tsx b/examples/example-app-router-migration/src/app/[locale]/layout.tsx index 6864db0de..5f131d8c9 100644 --- a/examples/example-app-router-migration/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-migration/src/app/[locale]/layout.tsx @@ -1,5 +1,5 @@ import {notFound} from 'next/navigation'; -import {NextIntlClientProvider} from 'next-intl'; +import {NextIntlClientProvider, hasLocale} from 'next-intl'; import {getMessages} from 'next-intl/server'; import {ReactNode} from 'react'; import {routing} from '@/i18n/routing'; @@ -10,8 +10,7 @@ type Props = { }; export default async function LocaleLayout({children, params}: Props) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(params.locale as any)) { + if (!hasLocale(routing.locales, params.locale)) { notFound(); } diff --git a/examples/example-app-router-migration/src/i18n/request.ts b/examples/example-app-router-migration/src/i18n/request.ts index 70066e964..370fc6d0c 100644 --- a/examples/example-app-router-migration/src/i18n/request.ts +++ b/examples/example-app-router-migration/src/i18n/request.ts @@ -1,14 +1,13 @@ +import {hasLocale} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import {routing} from './routing'; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment - let locale = await requestLocale; - - // Ensure that the incoming locale is valid - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; return { locale, diff --git a/examples/example-app-router-mixed-routing/global.d.ts b/examples/example-app-router-mixed-routing/global.d.ts index b749518b9..604dfbb40 100644 --- a/examples/example-app-router-mixed-routing/global.d.ts +++ b/examples/example-app-router-mixed-routing/global.d.ts @@ -1,8 +1,9 @@ +import {locales} from '@/config'; import en from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + Locale: (typeof locales)[number]; + Messages: typeof en; + } } diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/PublicNavigationLocaleSwitcher.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/PublicNavigationLocaleSwitcher.tsx index 45ce1900d..986e6e93d 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/PublicNavigationLocaleSwitcher.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/PublicNavigationLocaleSwitcher.tsx @@ -1,7 +1,6 @@ 'use client'; -import {useLocale} from 'next-intl'; -import {Locale} from '@/config'; +import {Locale, useLocale} from 'next-intl'; import {Link, usePathname} from '@/i18n/routing.public'; export default function PublicNavigationLocaleSwitcher() { diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx index 260a990c6..ffe4b971b 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx @@ -1,9 +1,9 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import PageTitle from '@/components/PageTitle'; type Props = { - params: {locale: string}; + params: {locale: Locale}; }; export default function About({params: {locale}}: Props) { diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx index 258c83e5f..cde039f11 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx @@ -1,6 +1,6 @@ import {Metadata} from 'next'; import {notFound} from 'next/navigation'; -import {NextIntlClientProvider} from 'next-intl'; +import {Locale, NextIntlClientProvider, hasLocale} from 'next-intl'; import {getMessages, setRequestLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import Document from '@/components/Document'; @@ -10,7 +10,7 @@ import PublicNavigationLocaleSwitcher from './PublicNavigationLocaleSwitcher'; type Props = { children: ReactNode; - params: {locale: string}; + params: {locale: Locale}; }; export function generateStaticParams() { @@ -25,14 +25,14 @@ export default async function LocaleLayout({ children, params: {locale} }: Props) { - // Enable static rendering - setRequestLocale(locale); - // Ensure that the incoming locale is valid - if (!locales.includes(locale as any)) { + if (!hasLocale(locales, locale)) { notFound(); } + // Enable static rendering + setRequestLocale(locale); + // Providing all messages to the client // side is the easiest way to get started const messages = await getMessages(); diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx index f3ee4cbf4..a12206760 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx @@ -1,9 +1,9 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import PageTitle from '@/components/PageTitle'; type Props = { - params: {locale: string}; + params: {locale: Locale}; }; export default function Index({params: {locale}}: Props) { diff --git a/examples/example-app-router-mixed-routing/src/app/app/AppNavigationLocaleSwitcher.tsx b/examples/example-app-router-mixed-routing/src/app/app/AppNavigationLocaleSwitcher.tsx index 9101443dc..f85bc3c10 100644 --- a/examples/example-app-router-mixed-routing/src/app/app/AppNavigationLocaleSwitcher.tsx +++ b/examples/example-app-router-mixed-routing/src/app/app/AppNavigationLocaleSwitcher.tsx @@ -1,8 +1,7 @@ 'use client'; import {useRouter} from 'next/navigation'; -import {useLocale} from 'next-intl'; -import {Locale} from '@/config'; +import {Locale, useLocale} from 'next-intl'; import updateLocale from './updateLocale'; export default function AppNavigationLocaleSwitcher() { diff --git a/examples/example-app-router-mixed-routing/src/config.ts b/examples/example-app-router-mixed-routing/src/config.ts index d71700814..e7b729aa0 100644 --- a/examples/example-app-router-mixed-routing/src/config.ts +++ b/examples/example-app-router-mixed-routing/src/config.ts @@ -1,5 +1,5 @@ +import {Locale} from 'next-intl'; + export const locales = ['en', 'de'] as const; export const defaultLocale: Locale = 'en'; - -export type Locale = (typeof locales)[number]; diff --git a/examples/example-app-router-mixed-routing/src/db.ts b/examples/example-app-router-mixed-routing/src/db.ts index 266e9e6a9..bd327e120 100644 --- a/examples/example-app-router-mixed-routing/src/db.ts +++ b/examples/example-app-router-mixed-routing/src/db.ts @@ -1,5 +1,6 @@ import {cookies} from 'next/headers'; -import {defaultLocale} from './config'; +import {Locale, hasLocale} from 'next-intl'; +import {defaultLocale, locales} from './config'; // This cookie name is used by `next-intl` on the public pages too. By // reading/writing to this locale, we can ensure that the user's locale @@ -8,8 +9,9 @@ import {defaultLocale} from './config'; // that instead when the user is logged in. const COOKIE_NAME = 'NEXT_LOCALE'; -export async function getUserLocale() { - return cookies().get(COOKIE_NAME)?.value || defaultLocale; +export async function getUserLocale(): Promise { + const candidate = cookies().get(COOKIE_NAME)?.value; + return hasLocale(locales, candidate) ? candidate : defaultLocale; } export async function setUserLocale(locale: string) { diff --git a/examples/example-app-router-mixed-routing/src/i18n/request.ts b/examples/example-app-router-mixed-routing/src/i18n/request.ts index ff3845b6c..15748b733 100644 --- a/examples/example-app-router-mixed-routing/src/i18n/request.ts +++ b/examples/example-app-router-mixed-routing/src/i18n/request.ts @@ -1,20 +1,17 @@ +import {hasLocale} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import {defaultLocale, locales} from '../config'; import {getUserLocale} from '../db'; export default getRequestConfig(async ({requestLocale}) => { // Read from potential `[locale]` segment - let locale = await requestLocale; + let candidate = await requestLocale; - if (!locale) { + if (!candidate) { // The user is logged in - locale = await getUserLocale(); - } - - // Ensure that the incoming locale is valid - if (!locales.includes(locale as any)) { - locale = defaultLocale; + candidate = await getUserLocale(); } + const locale = hasLocale(locales, candidate) ? candidate : defaultLocale; return { locale, diff --git a/examples/example-app-router-next-auth/global.d.ts b/examples/example-app-router-next-auth/global.d.ts index b749518b9..62bfc23e3 100644 --- a/examples/example-app-router-next-auth/global.d.ts +++ b/examples/example-app-router-next-auth/global.d.ts @@ -1,8 +1,9 @@ +import {routing} from '@/i18n/routing'; import en from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + Locale: (typeof routing.locales)[number]; + Messages: typeof en; + } } diff --git a/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx b/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx index 0acc48b28..c8df9eb83 100644 --- a/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx @@ -1,20 +1,19 @@ import {notFound} from 'next/navigation'; -import {NextIntlClientProvider} from 'next-intl'; +import {Locale, NextIntlClientProvider, hasLocale} from 'next-intl'; import {getMessages} from 'next-intl/server'; import {ReactNode} from 'react'; import {routing} from '@/i18n/routing'; type Props = { children: ReactNode; - params: {locale: string}; + params: {locale: Locale}; }; export default async function LocaleLayout({ children, params: {locale} }: Props) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } diff --git a/examples/example-app-router-next-auth/src/i18n/request.ts b/examples/example-app-router-next-auth/src/i18n/request.ts index 70066e964..370fc6d0c 100644 --- a/examples/example-app-router-next-auth/src/i18n/request.ts +++ b/examples/example-app-router-next-auth/src/i18n/request.ts @@ -1,14 +1,13 @@ +import {hasLocale} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import {routing} from './routing'; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment - let locale = await requestLocale; - - // Ensure that the incoming locale is valid - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; return { locale, diff --git a/examples/example-app-router-playground/global.d.ts b/examples/example-app-router-playground/global.d.ts index 15004afe0..277003def 100644 --- a/examples/example-app-router-playground/global.d.ts +++ b/examples/example-app-router-playground/global.d.ts @@ -1,13 +1,11 @@ +import {formats} from '@/i18n/request'; +import {routing} from '@/i18n/routing'; import en from './messages/en.json'; -import {formats} from './src/i18n/request'; -type Messages = typeof en; -type Formats = typeof formats; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} - - // Use type safe formats with `next-intl` - interface IntlFormats extends Formats {} +declare module 'next-intl' { + interface AppConfig { + Locale: (typeof routing.locales)[number]; + Formats: typeof formats; + Messages: typeof en; + } } diff --git a/examples/example-app-router-playground/src/app/[locale]/about/page.tsx b/examples/example-app-router-playground/src/app/[locale]/about/page.tsx index cf16681e1..5f9e2a5cb 100644 --- a/examples/example-app-router-playground/src/app/[locale]/about/page.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/about/page.tsx @@ -1,6 +1,8 @@ +import {Locale} from 'next-intl'; + type Props = { params: { - locale: string; + locale: Locale; }; }; diff --git a/examples/example-app-router-playground/src/app/[locale]/api/route.ts b/examples/example-app-router-playground/src/app/[locale]/api/route.ts index 921971072..890078add 100644 --- a/examples/example-app-router-playground/src/app/[locale]/api/route.ts +++ b/examples/example-app-router-playground/src/app/[locale]/api/route.ts @@ -1,9 +1,10 @@ import {NextRequest, NextResponse} from 'next/server'; +import {Locale} from 'next-intl'; import {getTranslations} from 'next-intl/server'; type Props = { params: { - locale: string; + locale: Locale; }; }; diff --git a/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx b/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx index fae9dea09..90c291ab3 100644 --- a/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx @@ -1,6 +1,6 @@ 'use client'; -import {useFormatter, useLocale, useNow, useTimeZone} from 'next-intl'; +import {useLocale, useNow, useTimeZone} from 'next-intl'; import {Link, usePathname} from '@/i18n/routing'; export default function ClientContent() { @@ -18,23 +18,3 @@ export default function ClientContent() { ); } - -export function TypeTest() { - const format = useFormatter(); - - format.dateTime(new Date(), 'medium'); - // @ts-expect-error - format.dateTime(new Date(), 'unknown'); - - format.dateTimeRange(new Date(), new Date(), 'medium'); - // @ts-expect-error - format.dateTimeRange(new Date(), new Date(), 'unknown'); - - format.number(420, 'precise'); - // @ts-expect-error - format.number(420, 'unknown'); - - format.list(['this', 'is', 'a', 'list'], 'enumeration'); - // @ts-expect-error - format.list(['this', 'is', 'a', 'list'], 'unknown'); -} diff --git a/examples/example-app-router-playground/src/app/[locale]/layout.tsx b/examples/example-app-router-playground/src/app/[locale]/layout.tsx index a46626c9d..d709cf78f 100644 --- a/examples/example-app-router-playground/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/layout.tsx @@ -1,5 +1,6 @@ import {Metadata} from 'next'; import {notFound} from 'next/navigation'; +import {Locale, hasLocale} from 'next-intl'; import { getFormatter, getNow, @@ -12,7 +13,7 @@ import Navigation from '../../components/Navigation'; type Props = { children: ReactNode; - params: {locale: string}; + params: {locale: Locale}; }; export async function generateMetadata({ @@ -29,14 +30,13 @@ export async function generateMetadata({ description: t('description'), other: { currentYear: formatter.dateTime(now, {year: 'numeric'}), - timeZone: timeZone || 'N/A' + timeZone } }; } export default function LocaleLayout({children, params: {locale}}: Props) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } diff --git a/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx b/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx index 7b080c397..010fd66e5 100644 --- a/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx @@ -1,6 +1,6 @@ import {Metadata} from 'next'; -import {useTranslations} from 'next-intl'; -import {Locale, getPathname} from '@/i18n/routing'; +import {Locale, useTranslations} from 'next-intl'; +import {getPathname} from '@/i18n/routing'; type Props = { params: { diff --git a/examples/example-app-router-playground/src/app/[locale]/opengraph-image.tsx b/examples/example-app-router-playground/src/app/[locale]/opengraph-image.tsx index 763972462..ffa13ed2e 100644 --- a/examples/example-app-router-playground/src/app/[locale]/opengraph-image.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/opengraph-image.tsx @@ -1,9 +1,10 @@ import {ImageResponse} from 'next/og'; +import {Locale} from 'next-intl'; import {getTranslations} from 'next-intl/server'; type Props = { params: { - locale: string; + locale: Locale; }; }; diff --git a/examples/example-app-router-playground/src/components/AsyncComponent.tsx b/examples/example-app-router-playground/src/components/AsyncComponent.tsx index 94a624238..aa4b17029 100644 --- a/examples/example-app-router-playground/src/components/AsyncComponent.tsx +++ b/examples/example-app-router-playground/src/components/AsyncComponent.tsx @@ -1,4 +1,4 @@ -import {getFormatter, getTranslations} from 'next-intl/server'; +import {getTranslations} from 'next-intl/server'; export default async function AsyncComponent() { const t = await getTranslations('AsyncComponent'); @@ -20,8 +20,6 @@ export default async function AsyncComponent() { export async function TypeTest() { const t = await getTranslations('AsyncComponent'); - const format = await getFormatter(); - // @ts-expect-error await getTranslations('Unknown'); @@ -36,20 +34,4 @@ export async function TypeTest() { // @ts-expect-error t.has('unknown'); - - format.dateTime(new Date(), 'medium'); - // @ts-expect-error - format.dateTime(new Date(), 'unknown'); - - format.dateTimeRange(new Date(), new Date(), 'medium'); - // @ts-expect-error - format.dateTimeRange(new Date(), new Date(), 'unknown'); - - format.number(420, 'precise'); - // @ts-expect-error - format.number(420, 'unknown'); - - format.list(['this', 'is', 'a', 'list'], 'enumeration'); - // @ts-expect-error - format.list(['this', 'is', 'a', 'list'], 'unknown'); } diff --git a/examples/example-app-router-playground/src/components/UseFormatterTypeTests.tsx b/examples/example-app-router-playground/src/components/UseFormatterTypeTests.tsx new file mode 100644 index 000000000..c81ebfb43 --- /dev/null +++ b/examples/example-app-router-playground/src/components/UseFormatterTypeTests.tsx @@ -0,0 +1,44 @@ +import {useFormatter} from 'next-intl'; +import {getFormatter} from 'next-intl/server'; + +export function RegularComponent() { + const format = useFormatter(); + + format.dateTime(new Date(), 'medium'); + format.dateTime(new Date(), 'long'); + // @ts-expect-error + format.dateTime(new Date(), 'unknown'); + + format.dateTimeRange(new Date(), new Date(), 'medium'); + // @ts-expect-error + format.dateTimeRange(new Date(), new Date(), 'unknown'); + + format.number(420, 'precise'); + // @ts-expect-error + format.number(420, 'unknown'); + + format.list(['this', 'is', 'a', 'list'], 'enumeration'); + // @ts-expect-error + format.list(['this', 'is', 'a', 'list'], 'unknown'); +} + +export async function AsyncComponent() { + const format = await getFormatter(); + + format.dateTime(new Date(), 'medium'); + format.dateTime(new Date(), 'long'); + // @ts-expect-error + format.dateTime(new Date(), 'unknown'); + + format.dateTimeRange(new Date(), new Date(), 'medium'); + // @ts-expect-error + format.dateTimeRange(new Date(), new Date(), 'unknown'); + + format.number(420, 'precise'); + // @ts-expect-error + format.number(420, 'unknown'); + + format.list(['this', 'is', 'a', 'list'], 'enumeration'); + // @ts-expect-error + format.list(['this', 'is', 'a', 'list'], 'unknown'); +} diff --git a/examples/example-app-router-playground/src/components/UseLocaleTypeTests.tsx b/examples/example-app-router-playground/src/components/UseLocaleTypeTests.tsx new file mode 100644 index 000000000..5030bb594 --- /dev/null +++ b/examples/example-app-router-playground/src/components/UseLocaleTypeTests.tsx @@ -0,0 +1,62 @@ +import {Locale, useLocale} from 'next-intl'; +import {getLocale} from 'next-intl/server'; +import {Link, getPathname, redirect, useRouter} from '@/i18n/routing'; + +export function RegularComponent() { + const locale = useLocale(); + + locale satisfies Locale; + 'en' satisfies typeof locale; + + // @ts-expect-error + 'fr' satisfies typeof locale; + // @ts-expect-error + 2 satisfies typeof locale; + + const router = useRouter(); + router.push('/', {locale}); + + return ( + <> + + Home + + {getPathname({ + href: '/', + locale + })} + {redirect({ + href: '/', + locale + })} + + ); +} + +export async function AsyncComponent() { + const locale = await getLocale(); + + locale satisfies Locale; + 'en' satisfies typeof locale; + + // @ts-expect-error + 'fr' satisfies typeof locale; + // @ts-expect-error + 2 satisfies typeof locale; + + return ( + <> + + Home + + {getPathname({ + href: '/', + locale + })} + {redirect({ + href: '/', + locale + })} + + ); +} diff --git a/examples/example-app-router-playground/src/components/MessagesTest.tsx b/examples/example-app-router-playground/src/components/UseMessagesTypeTests.tsx similarity index 87% rename from examples/example-app-router-playground/src/components/MessagesTest.tsx rename to examples/example-app-router-playground/src/components/UseMessagesTypeTests.tsx index 88f725260..fb9d850fa 100644 --- a/examples/example-app-router-playground/src/components/MessagesTest.tsx +++ b/examples/example-app-router-playground/src/components/UseMessagesTypeTests.tsx @@ -2,7 +2,7 @@ import {useMessages} from 'next-intl'; import {getMessages} from 'next-intl/server'; -export async function GetMessages() { +export async function AsyncComponent() { const messages = await getMessages(); // Valid @@ -16,7 +16,7 @@ export async function GetMessages() { messages.Index.unknown; } -export function UseMessages() { +export function RegularComponent() { const messages = useMessages(); // Valid diff --git a/examples/example-app-router-playground/src/i18n/request.tsx b/examples/example-app-router-playground/src/i18n/request.tsx index d132c19e2..db7a1b01d 100644 --- a/examples/example-app-router-playground/src/i18n/request.tsx +++ b/examples/example-app-router-playground/src/i18n/request.tsx @@ -1,5 +1,5 @@ import {headers} from 'next/headers'; -import {Formats} from 'next-intl'; +import {Formats, hasLocale} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import defaultMessages from '../../messages/en.json'; import {routing} from './routing'; @@ -10,6 +10,11 @@ export const formats = { dateStyle: 'medium', timeStyle: 'short', hour12: false + }, + long: { + dateStyle: 'full', + timeStyle: 'long', + hour12: false } }, number: { @@ -26,13 +31,11 @@ export const formats = { } satisfies Formats; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment - let locale = await requestLocale; - - // Ensure that the incoming locale is valid - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; const now = headers().get('x-now'); const timeZone = headers().get('x-time-zone') ?? 'Europe/Vienna'; diff --git a/examples/example-app-router-playground/src/i18n/routing.ts b/examples/example-app-router-playground/src/i18n/routing.ts index 4232fffa7..1da45abec 100644 --- a/examples/example-app-router-playground/src/i18n/routing.ts +++ b/examples/example-app-router-playground/src/i18n/routing.ts @@ -60,8 +60,5 @@ export const routing = defineRouting({ } }); -export type Pathnames = keyof typeof routing.pathnames; -export type Locale = (typeof routing.locales)[number]; - export const {Link, getPathname, redirect, usePathname, useRouter} = createNavigation(routing); diff --git a/examples/example-app-router/global.d.ts b/examples/example-app-router/global.d.ts index b749518b9..62bfc23e3 100644 --- a/examples/example-app-router/global.d.ts +++ b/examples/example-app-router/global.d.ts @@ -1,8 +1,9 @@ +import {routing} from '@/i18n/routing'; import en from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + Locale: (typeof routing.locales)[number]; + Messages: typeof en; + } } diff --git a/examples/example-app-router/src/app/[locale]/layout.tsx b/examples/example-app-router/src/app/[locale]/layout.tsx index 2a517d046..7dc3091ae 100644 --- a/examples/example-app-router/src/app/[locale]/layout.tsx +++ b/examples/example-app-router/src/app/[locale]/layout.tsx @@ -1,4 +1,5 @@ import {notFound} from 'next/navigation'; +import {Locale, hasLocale} from 'next-intl'; import {getTranslations, setRequestLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import BaseLayout from '@/components/BaseLayout'; @@ -6,7 +7,7 @@ import {routing} from '@/i18n/routing'; type Props = { children: ReactNode; - params: {locale: string}; + params: {locale: Locale}; }; export function generateStaticParams() { @@ -27,8 +28,7 @@ export default async function LocaleLayout({ children, params: {locale} }: Props) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } diff --git a/examples/example-app-router/src/app/[locale]/page.tsx b/examples/example-app-router/src/app/[locale]/page.tsx index a5b6ad8ef..ea963c340 100644 --- a/examples/example-app-router/src/app/[locale]/page.tsx +++ b/examples/example-app-router/src/app/[locale]/page.tsx @@ -1,9 +1,9 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import PageLayout from '@/components/PageLayout'; type Props = { - params: {locale: string}; + params: {locale: Locale}; }; export default function IndexPage({params: {locale}}: Props) { diff --git a/examples/example-app-router/src/app/[locale]/pathnames/page.tsx b/examples/example-app-router/src/app/[locale]/pathnames/page.tsx index 943615b11..b53ab8535 100644 --- a/examples/example-app-router/src/app/[locale]/pathnames/page.tsx +++ b/examples/example-app-router/src/app/[locale]/pathnames/page.tsx @@ -1,9 +1,9 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import PageLayout from '@/components/PageLayout'; type Props = { - params: {locale: string}; + params: {locale: Locale}; }; export default function PathnamesPage({params: {locale}}: Props) { diff --git a/examples/example-app-router/src/app/sitemap.ts b/examples/example-app-router/src/app/sitemap.ts index 0fefe33a2..7f021f249 100644 --- a/examples/example-app-router/src/app/sitemap.ts +++ b/examples/example-app-router/src/app/sitemap.ts @@ -1,6 +1,7 @@ import {MetadataRoute} from 'next'; +import {Locale} from 'next-intl'; import {host} from '@/config'; -import {Locale, getPathname, routing} from '@/i18n/routing'; +import {getPathname, routing} from '@/i18n/routing'; export default function sitemap(): MetadataRoute.Sitemap { return [getEntry('/'), getEntry('/pathnames')]; diff --git a/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx b/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx index 051a36f61..ca8b90fe3 100644 --- a/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx +++ b/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx @@ -2,8 +2,9 @@ import clsx from 'clsx'; import {useParams} from 'next/navigation'; +import {Locale} from 'next-intl'; import {ChangeEvent, ReactNode, useTransition} from 'react'; -import {Locale, usePathname, useRouter} from '@/i18n/routing'; +import {usePathname, useRouter} from '@/i18n/routing'; type Props = { children: ReactNode; diff --git a/examples/example-app-router/src/i18n/request.ts b/examples/example-app-router/src/i18n/request.ts index 2ba1dd2a6..370fc6d0c 100644 --- a/examples/example-app-router/src/i18n/request.ts +++ b/examples/example-app-router/src/i18n/request.ts @@ -1,14 +1,13 @@ +import {hasLocale} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import {routing} from './routing'; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment - let locale = await requestLocale; - - // Ensure that the incoming `locale` is valid - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; return { locale, diff --git a/examples/example-app-router/src/i18n/routing.ts b/examples/example-app-router/src/i18n/routing.ts index 001b856ff..9ad5090ad 100644 --- a/examples/example-app-router/src/i18n/routing.ts +++ b/examples/example-app-router/src/i18n/routing.ts @@ -13,8 +13,5 @@ export const routing = defineRouting({ } }); -export type Pathnames = keyof typeof routing.pathnames; -export type Locale = (typeof routing.locales)[number]; - export const {Link, getPathname, redirect, usePathname, useRouter} = createNavigation(routing); diff --git a/examples/example-pages-router-advanced/global.d.ts b/examples/example-pages-router-advanced/global.d.ts index b749518b9..02f24a1b3 100644 --- a/examples/example-pages-router-advanced/global.d.ts +++ b/examples/example-pages-router-advanced/global.d.ts @@ -1,8 +1,7 @@ import en from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + Messages: typeof en; + } } diff --git a/examples/example-pages-router-advanced/src/pages/_app.tsx b/examples/example-pages-router-advanced/src/pages/_app.tsx index 14f75d12d..165d5fcbb 100644 --- a/examples/example-pages-router-advanced/src/pages/_app.tsx +++ b/examples/example-pages-router-advanced/src/pages/_app.tsx @@ -1,9 +1,9 @@ import {AppProps} from 'next/app'; import {useRouter} from 'next/router'; -import {NextIntlClientProvider} from 'next-intl'; +import {Messages, NextIntlClientProvider} from 'next-intl'; type PageProps = { - messages: IntlMessages; + messages: Messages; now: number; }; diff --git a/examples/example-pages-router/global.d.ts b/examples/example-pages-router/global.d.ts index b749518b9..02f24a1b3 100644 --- a/examples/example-pages-router/global.d.ts +++ b/examples/example-pages-router/global.d.ts @@ -1,8 +1,7 @@ import en from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + Messages: typeof en; + } } diff --git a/examples/example-use-intl/global.d.ts b/examples/example-use-intl/global.d.ts new file mode 100644 index 000000000..5f39e9b68 --- /dev/null +++ b/examples/example-use-intl/global.d.ts @@ -0,0 +1,10 @@ +import 'use-intl'; +import en from './messages/en.json'; +import {locales} from './src/config'; + +declare module 'use-intl' { + interface AppConfig { + Locale: (typeof locales)[number]; + Messages: typeof en; + } +} diff --git a/examples/example-use-intl/messages/en.json b/examples/example-use-intl/messages/en.json new file mode 100644 index 000000000..51f26812a --- /dev/null +++ b/examples/example-use-intl/messages/en.json @@ -0,0 +1,5 @@ +{ + "App": { + "hello": "Hello {username}!" + } +} diff --git a/examples/example-use-intl/src/config.tsx b/examples/example-use-intl/src/config.tsx new file mode 100644 index 000000000..a8a68c781 --- /dev/null +++ b/examples/example-use-intl/src/config.tsx @@ -0,0 +1 @@ +export const locales = ['en'] as const; diff --git a/examples/example-use-intl/src/main.tsx b/examples/example-use-intl/src/main.tsx index dc772d8ab..d48504350 100644 --- a/examples/example-use-intl/src/main.tsx +++ b/examples/example-use-intl/src/main.tsx @@ -1,16 +1,13 @@ import {StrictMode} from 'react'; import ReactDOM from 'react-dom/client'; import {IntlProvider} from 'use-intl'; +import en from '../messages/en.json'; import App from './App.tsx'; // You can get the messages from anywhere you like. You can also // fetch them from within a component and then render the provider // along with your app once you have the messages. -const messages = { - App: { - hello: 'Hello {username}!' - } -}; +const messages = en; const node = document.getElementById('root'); diff --git a/examples/example-use-intl/tsconfig.json b/examples/example-use-intl/tsconfig.json index ba939e9e6..309ae7f00 100644 --- a/examples/example-use-intl/tsconfig.json +++ b/examples/example-use-intl/tsconfig.json @@ -13,6 +13,6 @@ "noEmit": true, "jsx": "react-jsx" }, - "include": ["src"], + "include": ["src", "global.d.ts"], "references": [{"path": "./tsconfig.node.json"}] } diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 7070e0dc7..d48a2124b 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -4,7 +4,7 @@ const config: SizeLimitConfig = [ { name: "import * from 'next-intl' (react-client, production)", path: 'dist/esm/production/index.react-client.js', - limit: '13.11 KB' + limit: '13.175 KB' }, { name: "import {NextIntlClientProvider} from 'next-intl' (react-client, production)", diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 4b42ec8fb..1d3f26f96 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -59,7 +59,7 @@ export default function getAlternateLinksHeaderValue< routing.localePrefix ); - function getAlternateEntry(url: URL, locale: string) { + function getAlternateEntry(url: URL, locale: AppLocales[number]) { url.pathname = normalizeTrailingSlash(url.pathname); if (request.nextUrl.basePath) { diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index 3b6db2daf..2719ee2cf 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -1,6 +1,7 @@ import {match} from '@formatjs/intl-localematcher'; import Negotiator from 'negotiator'; import {RequestCookies} from 'next/dist/server/web/spec-extension/cookies.js'; +import type {Locale} from 'use-intl'; import {ResolvedRoutingConfig} from '../routing/config.tsx'; import { DomainConfig, @@ -36,7 +37,7 @@ function orderLocales(locales: AppLocales) { export function getAcceptLanguageLocale( requestHeaders: Headers, locales: AppLocales, - defaultLocale: string + defaultLocale: Locale ) { let locale; @@ -47,12 +48,7 @@ export function getAcceptLanguageLocale( }).languages(); try { const orderedLocales = orderLocales(locales); - - locale = match( - languages, - orderedLocales as unknown as Array, - defaultLocale - ); + locale = match(languages, orderedLocales, defaultLocale); } catch { // Invalid language } diff --git a/packages/next-intl/src/middleware/syncCookie.tsx b/packages/next-intl/src/middleware/syncCookie.tsx index ffd7ad46f..751240c71 100644 --- a/packages/next-intl/src/middleware/syncCookie.tsx +++ b/packages/next-intl/src/middleware/syncCookie.tsx @@ -1,4 +1,5 @@ import {NextRequest, NextResponse} from 'next/server.js'; +import type {Locale} from 'use-intl'; import { InitializedLocaleCookieConfig, ResolvedRoutingConfig @@ -20,7 +21,7 @@ export default function syncCookie< >( request: NextRequest, response: NextResponse, - locale: string, + locale: Locale, routing: Pick< ResolvedRoutingConfig< AppLocales, diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 7f14655da..67013bd5b 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -1,3 +1,4 @@ +import type {Locale} from 'use-intl'; import { DomainConfig, DomainsConfig, @@ -254,7 +255,7 @@ export function getHost(requestHeaders: Headers) { } export function isLocaleSupportedOnDomain( - locale: string, + locale: Locale, domain: DomainConfig ) { return ( @@ -266,7 +267,7 @@ export function isLocaleSupportedOnDomain( export function getBestMatchingDomain( curHostDomain: DomainConfig | undefined, - locale: string, + locale: Locale, domainsConfig: DomainsConfig ) { let domainConfig; diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index e8125d65e..92f05556b 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -6,7 +6,9 @@ import { useParams as nextUseParams } from 'next/navigation.js'; import {renderToString} from 'react-dom/server'; +import {Locale} from 'use-intl'; import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {useLocale} from '../index.react-server.tsx'; import {DomainsConfig, Pathnames, defineRouting} from '../routing.tsx'; import createNavigationClient from './react-client/createNavigation.tsx'; import createNavigationServer from './react-server/createNavigation.tsx'; @@ -24,7 +26,7 @@ vi.mock('next/navigation.js', async () => { }); vi.mock('./react-server/getServerLocale'); -function mockCurrentLocale(locale: string) { +function mockCurrentLocale(locale: Locale) { // Enable synchronous rendering without having to suspend const value = locale; const promise = Promise.resolve(value); @@ -33,7 +35,7 @@ function mockCurrentLocale(locale: string) { vi.mocked(getServerLocale).mockImplementation(() => promise); - vi.mocked(nextUseParams<{locale: string}>).mockImplementation(() => ({ + vi.mocked(nextUseParams<{locale: Locale}>).mockImplementation(() => ({ locale })); } @@ -109,6 +111,19 @@ describe.each([ localePrefix: 'always' }); + describe('createNavigation', () => { + it('ensures `defaultLocale` is in `locales`', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => + createNavigation({ + locales, + // @ts-expect-error + defaultLocale: 'zh', + localePrefix: 'always' + }); + }); + }); + describe('Link', () => { it('renders a prefix when currently on the default locale', () => { const markup = renderToString(About); @@ -214,6 +229,14 @@ describe.each([ expect(markup).toContain('href="/en/about"'); expect(consoleSpy).not.toHaveBeenCalled(); }); + + it('can use a locale from `useLocale`', () => { + function Component() { + const locale = useLocale(); + return ; + } + render(); + }); }); describe('getPathname', () => { @@ -305,6 +328,17 @@ describe.each([ true ); }); + + it('can use a locale from `useLocale`', () => { + function Component() { + const locale = useLocale(); + return getPathname({ + locale, + href: '/about' + }); + } + render(); + }); }); describe.each([ @@ -353,6 +387,14 @@ describe.each([ // @ts-expect-error -- Missing locale redirectFn({pathname: '/about'}); }); + + it('can use a locale from `useLocale`', () => { + function Component() { + const locale = useLocale(); + return redirectFn({href: '/about', locale}); + } + render(); + }); }); }); diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index 551c7b3fc..ca9b0968b 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -4,15 +4,16 @@ import { useRouter as useNextRouter, useParams } from 'next/navigation.js'; +import type {Locale} from 'use-intl'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {NextIntlClientProvider} from '../../index.react-client.tsx'; +import {NextIntlClientProvider, useLocale} from '../../index.react-client.tsx'; import {DomainsConfig, Pathnames} from '../../routing.tsx'; import createNavigation from './createNavigation.tsx'; vi.mock('next/navigation.js'); -function mockCurrentLocale(locale: string) { - vi.mocked(useParams<{locale: string}>).mockImplementation(() => ({ +function mockCurrentLocale(locale: Locale) { + vi.mocked(useParams<{locale: Locale}>).mockImplementation(() => ({ locale })); } @@ -169,16 +170,20 @@ describe("localePrefix: 'always'", () => { }); it('prefixes with a secondary locale', () => { - // Being able to accept a string and not only a strictly typed locale is - // important in order to be able to use a result from `useLocale()`. - // This is less relevant for `Link`, but this should be in sync across - // al navigation APIs (see https://github.com/amannn/next-intl/issues/1377) - const locale = 'de' as string; - - invokeRouter((router) => router[method]('/about', {locale})); + invokeRouter((router) => router[method]('/about', {locale: 'de'})); expect(useNextRouter()[method]).toHaveBeenCalledWith('/de/about'); }); + it('can use a locale from `useLocale`', () => { + function Component() { + const locale = useLocale(); + const router = useRouter(); + router.push('/about', {locale}); + return null; + } + render(); + }); + it('passes through unknown options to the Next.js router', () => { invokeRouter((router) => router[method]('/about', {scroll: true})); expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/about', { diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index d9b4a451a..0cdcc4a56 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -3,6 +3,7 @@ import { useRouter as useNextRouter } from 'next/navigation.js'; import {useMemo} from 'react'; +import type {Locale} from 'use-intl'; import useLocale from '../../react-client/useLocale.tsx'; import { RoutingConfigLocalizedNavigation, @@ -40,14 +41,8 @@ export default function createNavigation< AppDomains > ) { - type Locale = AppLocales extends never ? string : AppLocales[number]; - - function useTypedLocale() { - return useLocale() as Locale; - } - const {Link, config, getPathname, ...redirects} = createSharedNavigationFns( - useTypedLocale, + useLocale, routing ); @@ -56,7 +51,7 @@ export default function createNavigation< ? string : keyof AppPathnames { const pathname = useBasePathname(config.localePrefix); - const locale = useTypedLocale(); + const locale = useLocale(); // @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 useMemo( @@ -77,7 +72,7 @@ export default function createNavigation< function useRouter() { const router = useNextRouter(); - const curLocale = useTypedLocale(); + const curLocale = useLocale(); const nextPathname = useNextPathname(); return useMemo(() => { @@ -87,7 +82,7 @@ export default function createNavigation< >(fn: Fn) { return function handler( href: Parameters[0]['href'], - options?: Partial & {locale?: string} + options?: Partial & {locale?: Locale} ): void { const {locale: nextLocale, ...rest} = options || {}; diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx index c17732737..90d7f8721 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -32,14 +32,8 @@ export default function createNavigation< AppDomains > ) { - type Locale = AppLocales extends never ? string : AppLocales[number]; - - function getLocale() { - return getServerLocale() as Promise; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const {config, ...fns} = createSharedNavigationFns(getLocale, routing); + const {config, ...fns} = createSharedNavigationFns(getServerLocale, routing); function notSupported(hookName: string) { return () => { diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index d5a63931b..2ae09aa87 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -10,6 +10,7 @@ import { useEffect, useState } from 'react'; +import type {Locale} from 'use-intl'; import useLocale from '../../react-client/useLocale.tsx'; import {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; import syncLocaleCookie from './syncLocaleCookie.tsx'; @@ -18,12 +19,12 @@ type NextLinkProps = Omit, keyof LinkProps> & Omit; type Props = NextLinkProps & { - locale?: string; - defaultLocale?: string; + locale?: Locale; + defaultLocale?: Locale; localeCookie: InitializedLocaleCookieConfig; /** Special case for `localePrefix: 'as-needed'` and `domains`. */ unprefixed?: { - domains: {[domain: string]: string}; + domains: {[domain: string]: Locale}; pathname: string; }; }; diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index 131e0270d..c2fe5a5fa 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -3,6 +3,7 @@ import { redirect as nextRedirect } from 'next/navigation.js'; import {ComponentProps, forwardRef} from 'react'; +import type {Locale} from 'use-intl'; import { RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation, @@ -31,8 +32,6 @@ import { } from './utils.tsx'; type PromiseOrValue = Type | Promise; -type UnwrapPromiseOrValue = - Type extends Promise ? Value : Type; /** * Shared implementations for `react-server` and `react-client` @@ -43,9 +42,7 @@ export default function createSharedNavigationFns< const AppLocalePrefixMode extends LocalePrefixMode = 'always', const AppDomains extends DomainsConfig = never >( - getLocale: () => PromiseOrValue< - AppLocales extends never ? string : AppLocales[number] - >, + getLocale: () => PromiseOrValue, routing?: [AppPathnames] extends [never] ? | RoutingConfigSharedNavigation< @@ -61,8 +58,6 @@ export default function createSharedNavigationFns< AppDomains > ) { - type Locale = UnwrapPromiseOrValue>; - const config = receiveRoutingConfig(routing || {}); if (process.env.NODE_ENV !== 'production') { validateReceivedConfig(config); @@ -92,7 +87,7 @@ export default function createSharedNavigationFns< ? ComponentProps['href'] : HrefOrUrlObjectWithParams; /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#link */ - locale?: string; + locale?: Locale; } >; function Link( @@ -112,7 +107,7 @@ export default function createSharedNavigationFns< const isLocalizable = isLocalizableHref(href); const localePromiseOrValue = getLocale(); - const curLocale: AppLocales extends never ? string : AppLocales[number] = + const curLocale = localePromiseOrValue instanceof Promise ? use(localePromiseOrValue) : localePromiseOrValue; @@ -148,10 +143,9 @@ export default function createSharedNavigationFns< ? { domains: (config as any).domains.reduce( ( - acc: Record, + acc: Record, domain: DomainConfig ) => { - // @ts-expect-error -- This is ok acc[domain.domain] = domain.defaultLocale; return acc; }, @@ -194,7 +188,7 @@ export default function createSharedNavigationFns< href: [AppPathnames] extends [never] ? string | {pathname: string; query?: QueryParams} : HrefOrHrefWithParams; - locale: string; + locale: Locale; } & DomainConfigForAsNeeded, /** @private Removed in types returned below */ _forcePrefix?: boolean diff --git a/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx b/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx index eabd9dc23..5b2d95722 100644 --- a/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx +++ b/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx @@ -1,3 +1,4 @@ +import type {Locale} from 'use-intl'; import {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; import {getBasePath} from './utils.tsx'; @@ -9,8 +10,8 @@ import {getBasePath} from './utils.tsx'; export default function syncLocaleCookie( localeCookie: InitializedLocaleCookieConfig, pathname: string | null, - locale: string, - nextLocale?: string + locale: Locale, + nextLocale?: Locale ) { const isSwitchingLocale = nextLocale !== locale && nextLocale != null; diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 31c44def7..3845920be 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,5 +1,6 @@ import type {ParsedUrlQueryInput} from 'node:querystring'; import type {UrlObject} from 'url'; +import type {Locale} from 'use-intl'; import {ResolvedRoutingConfig} from '../../routing/config.tsx'; import { DomainsConfig, @@ -49,7 +50,7 @@ export function normalizeNameOrNameWithParams( href: | HrefOrHrefWithParams | { - locale: string; + locale: Locale; href: HrefOrHrefWithParams; } ): { diff --git a/packages/next-intl/src/react-client/useLocale.tsx b/packages/next-intl/src/react-client/useLocale.tsx index 227342528..7274813d9 100644 --- a/packages/next-intl/src/react-client/useLocale.tsx +++ b/packages/next-intl/src/react-client/useLocale.tsx @@ -2,7 +2,7 @@ import {useLocale as useBaseLocale} from 'use-intl/react'; import {LOCALE_SEGMENT_NAME} from '../shared/constants.tsx'; import useParams from '../shared/useParams.tsx'; -export default function useLocale(): string { +export default function useLocale(): ReturnType { const params = useParams(); let locale; diff --git a/packages/next-intl/src/react-server/getTranslator.tsx b/packages/next-intl/src/react-server/getTranslator.tsx index 640170d09..7e462d8ce 100644 --- a/packages/next-intl/src/react-server/getTranslator.tsx +++ b/packages/next-intl/src/react-server/getTranslator.tsx @@ -3,6 +3,7 @@ import { Formats, MarkupTranslationValues, MessageKeys, + Messages, NamespaceKeys, NestedKeyOf, NestedValueOf, @@ -12,10 +13,7 @@ import { } from 'use-intl/core'; function getTranslatorImpl< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never + NestedKey extends NamespaceKeys> = never >( config: Parameters[0], namespace?: NestedKey @@ -25,12 +23,12 @@ function getTranslatorImpl< < TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -45,12 +43,12 @@ function getTranslatorImpl< rich< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -65,12 +63,12 @@ function getTranslatorImpl< markup< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -85,12 +83,12 @@ function getTranslatorImpl< raw< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -103,12 +101,12 @@ function getTranslatorImpl< has< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > diff --git a/packages/next-intl/src/routing/types.tsx b/packages/next-intl/src/routing/types.tsx index d47c5ee2e..48929e150 100644 --- a/packages/next-intl/src/routing/types.tsx +++ b/packages/next-intl/src/routing/types.tsx @@ -1,3 +1,5 @@ +// We intentionally don't use `Locale` here to avoid a circular reference +// when `routing` is used to initialize the `Locale` type. export type Locales = ReadonlyArray; export type LocalePrefixMode = 'always' | 'as-needed' | 'never'; diff --git a/packages/next-intl/src/server/react-server/RequestLocale.tsx b/packages/next-intl/src/server/react-server/RequestLocale.tsx index 91ea93cb5..12b18c627 100644 --- a/packages/next-intl/src/server/react-server/RequestLocale.tsx +++ b/packages/next-intl/src/server/react-server/RequestLocale.tsx @@ -1,5 +1,6 @@ import {headers} from 'next/headers.js'; import {cache} from 'react'; +import {Locale} from 'use-intl'; import {HEADER_LOCALE_NAME} from '../../shared/constants.tsx'; import {getCachedRequestLocale} from './RequestLocaleCache.tsx'; @@ -13,7 +14,7 @@ async function getHeadersImpl(): Promise { } const getHeaders = cache(getHeadersImpl); -async function getLocaleFromHeaderImpl(): Promise { +async function getLocaleFromHeaderImpl(): Promise { let locale; try { diff --git a/packages/next-intl/src/server/react-server/RequestLocaleCache.tsx b/packages/next-intl/src/server/react-server/RequestLocaleCache.tsx index a8bc80194..98219f2b2 100644 --- a/packages/next-intl/src/server/react-server/RequestLocaleCache.tsx +++ b/packages/next-intl/src/server/react-server/RequestLocaleCache.tsx @@ -1,8 +1,9 @@ import {cache} from 'react'; +import type {Locale} from 'use-intl'; // See https://github.com/vercel/next.js/discussions/58862 function getCacheImpl() { - const value: {locale?: string} = {locale: undefined}; + const value: {locale?: Locale} = {locale: undefined}; return value; } @@ -12,6 +13,6 @@ export function getCachedRequestLocale() { return getCache().locale; } -export function setCachedRequestLocale(locale: string) { +export function setCachedRequestLocale(locale: Locale) { getCache().locale = locale; } diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index 0661e1de0..a5c2a85bb 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -1,6 +1,7 @@ import {cache} from 'react'; import { IntlConfig, + type Locale, _createCache, _createIntlFormatters, initializeConfig @@ -24,7 +25,7 @@ const getDefaultTimeZone = cache(getDefaultTimeZoneImpl); async function receiveRuntimeConfigImpl( getConfig: typeof createRequestConfig, - localeOverride?: string + localeOverride?: Locale ) { if ( process.env.NODE_ENV !== 'production' && @@ -76,7 +77,7 @@ const receiveRuntimeConfig = cache(receiveRuntimeConfigImpl); const getFormatters = cache(_createIntlFormatters); const getCache = cache(_createCache); -async function getConfigImpl(localeOverride?: string): Promise< +async function getConfigImpl(localeOverride?: Locale): Promise< IntlConfig & { getMessageFallback: NonNullable; now: NonNullable; diff --git a/packages/next-intl/src/server/react-server/getFormatter.tsx b/packages/next-intl/src/server/react-server/getFormatter.tsx index 830eb4595..9e1909076 100644 --- a/packages/next-intl/src/server/react-server/getFormatter.tsx +++ b/packages/next-intl/src/server/react-server/getFormatter.tsx @@ -1,8 +1,8 @@ import {cache} from 'react'; -import {createFormatter} from 'use-intl/core'; +import {type Locale, createFormatter} from 'use-intl/core'; import getConfig from './getConfig.tsx'; -async function getFormatterCachedImpl(locale?: string) { +async function getFormatterCachedImpl(locale?: Locale) { const config = await getConfig(locale); return createFormatter(config); } @@ -15,7 +15,7 @@ const getFormatterCached = cache(getFormatterCachedImpl); * you can override it by passing in additional options. */ export default async function getFormatter(opts?: { - locale?: string; + locale?: Locale; }): Promise> { return getFormatterCached(opts?.locale); } diff --git a/packages/next-intl/src/server/react-server/getLocale.tsx b/packages/next-intl/src/server/react-server/getLocale.tsx index e847b30cf..caea190f8 100644 --- a/packages/next-intl/src/server/react-server/getLocale.tsx +++ b/packages/next-intl/src/server/react-server/getLocale.tsx @@ -1,7 +1,8 @@ import {cache} from 'react'; +import {Locale} from 'use-intl'; import getConfig from './getConfig.tsx'; -async function getLocaleCachedImpl() { +async function getLocaleCachedImpl(): Promise { const config = await getConfig(); return config.locale; } diff --git a/packages/next-intl/src/server/react-server/getMessages.tsx b/packages/next-intl/src/server/react-server/getMessages.tsx index 39ac4ca0d..95b521fde 100644 --- a/packages/next-intl/src/server/react-server/getMessages.tsx +++ b/packages/next-intl/src/server/react-server/getMessages.tsx @@ -1,5 +1,5 @@ import {cache} from 'react'; -import type {useMessages as useMessagesType} from 'use-intl'; +import type {Locale, useMessages as useMessagesType} from 'use-intl'; import getConfig from './getConfig.tsx'; export function getMessagesFromConfig( @@ -13,12 +13,14 @@ export function getMessagesFromConfig( return config.messages; } -async function getMessagesCachedImpl(locale?: string) { +async function getMessagesCachedImpl(locale?: Locale) { const config = await getConfig(locale); return getMessagesFromConfig(config); } const getMessagesCached = cache(getMessagesCachedImpl); -export default async function getMessages(opts?: {locale?: string}) { +export default async function getMessages(opts?: { + locale?: Locale; +}): Promise> { return getMessagesCached(opts?.locale); } diff --git a/packages/next-intl/src/server/react-server/getNow.tsx b/packages/next-intl/src/server/react-server/getNow.tsx index d081c14f0..ed39c17f9 100644 --- a/packages/next-intl/src/server/react-server/getNow.tsx +++ b/packages/next-intl/src/server/react-server/getNow.tsx @@ -1,12 +1,13 @@ import {cache} from 'react'; +import type {Locale} from 'use-intl'; import getConfig from './getConfig.tsx'; -async function getNowCachedImpl(locale?: string) { +async function getNowCachedImpl(locale?: Locale) { const config = await getConfig(locale); return config.now; } const getNowCached = cache(getNowCachedImpl); -export default async function getNow(opts?: {locale?: string}): Promise { +export default async function getNow(opts?: {locale?: Locale}): Promise { return getNowCached(opts?.locale); } diff --git a/packages/next-intl/src/server/react-server/getTimeZone.tsx b/packages/next-intl/src/server/react-server/getTimeZone.tsx index d6a707f0e..fca1601f9 100644 --- a/packages/next-intl/src/server/react-server/getTimeZone.tsx +++ b/packages/next-intl/src/server/react-server/getTimeZone.tsx @@ -1,14 +1,15 @@ import {cache} from 'react'; +import type {Locale} from 'use-intl'; import getConfig from './getConfig.tsx'; -async function getTimeZoneCachedImpl(locale?: string) { +async function getTimeZoneCachedImpl(locale?: Locale) { const config = await getConfig(locale); return config.timeZone; } const getTimeZoneCached = cache(getTimeZoneCachedImpl); export default async function getTimeZone(opts?: { - locale?: string; -}): Promise { + locale?: Locale; +}): Promise { return getTimeZoneCached(opts?.locale); } diff --git a/packages/next-intl/src/server/react-server/getTranslations.tsx b/packages/next-intl/src/server/react-server/getTranslations.tsx index 3119c3ddf..ce1129e97 100644 --- a/packages/next-intl/src/server/react-server/getTranslations.tsx +++ b/packages/next-intl/src/server/react-server/getTranslations.tsx @@ -1,8 +1,10 @@ import {ReactNode, cache} from 'react'; import { Formats, + Locale, MarkupTranslationValues, MessageKeys, + Messages, NamespaceKeys, NestedKeyOf, NestedValueOf, @@ -18,10 +20,7 @@ import getConfig from './getConfig.tsx'; // CALL SIGNATURE 1: `getTranslations(namespace)` function getTranslations< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never + NestedKey extends NamespaceKeys> = never >( namespace?: NestedKey ): // Explicitly defining the return type is necessary as TypeScript would get it wrong @@ -30,12 +29,12 @@ Promise<{ < TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -50,12 +49,12 @@ Promise<{ rich< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -70,12 +69,12 @@ Promise<{ markup< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -90,12 +89,12 @@ Promise<{ raw< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -108,12 +107,12 @@ Promise<{ has< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -124,12 +123,9 @@ Promise<{ }>; // CALL SIGNATURE 2: `getTranslations({locale, namespace})` function getTranslations< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never + NestedKey extends NamespaceKeys> = never >(opts?: { - locale: string; + locale: Locale; namespace?: NestedKey; }): // Explicitly defining the return type is necessary as TypeScript would get it wrong Promise<{ @@ -137,12 +133,12 @@ Promise<{ < TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -157,12 +153,12 @@ Promise<{ rich< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -177,12 +173,12 @@ Promise<{ markup< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -197,12 +193,12 @@ Promise<{ raw< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -213,13 +209,10 @@ Promise<{ }>; // IMPLEMENTATION async function getTranslations< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never ->(namespaceOrOpts?: NestedKey | {locale: string; namespace?: NestedKey}) { + NestedKey extends NamespaceKeys> = never +>(namespaceOrOpts?: NestedKey | {locale: Locale; namespace?: NestedKey}) { let namespace: NestedKey | undefined; - let locale: string | undefined; + let locale: Locale | undefined; if (typeof namespaceOrOpts === 'string') { namespace = namespaceOrOpts; diff --git a/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx b/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx index 2d3cc8cd8..7cf3b13c8 100644 --- a/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx +++ b/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx @@ -1,4 +1,5 @@ import {render, screen} from '@testing-library/react'; +import type {Locale} from 'use-intl'; import {it, vi} from 'vitest'; import { NextIntlClientProvider, @@ -16,7 +17,7 @@ function Component() { return <>{t('message', {price: 29000.5})}; } -function TestProvider({locale}: {locale?: string}) { +function TestProvider({locale}: {locale?: Locale}) { return ( , 'locale'> & { /** This is automatically received when being rendered from a Server Component. In all other cases, e.g. when rendered from a Client Component, a unit test or with the Pages Router, you can pass this prop explicitly. */ - locale?: string; + locale?: Locale; }; export default function NextIntlClientProvider({locale, ...rest}: Props) { diff --git a/packages/next-intl/types/index.d.ts b/packages/next-intl/types/index.d.ts index 7fc5571da..a6c395566 100644 --- a/packages/next-intl/types/index.d.ts +++ b/packages/next-intl/types/index.d.ts @@ -4,8 +4,6 @@ declare namespace NodeJS { } } -declare interface IntlMessages extends Record {} - // Temporarly copied here until the "es2020.intl" lib is published. declare namespace Intl { /** diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts index a21e2f62f..564bc6eaf 100644 --- a/packages/use-intl/.size-limit.ts +++ b/packages/use-intl/.size-limit.ts @@ -5,7 +5,7 @@ const config: SizeLimitConfig = [ name: "import * from 'use-intl' (production)", import: '*', path: 'dist/esm/production/index.js', - limit: '12.965 kB' + limit: '12.985 kB' }, { name: "import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from 'use-intl' (production)", @@ -13,12 +13,6 @@ const config: SizeLimitConfig = [ import: '{IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter}', limit: '1.975 kB' - }, - { - name: "import * from 'use-intl' (development)", - import: '*', - path: 'dist/esm/development/index.js', - limit: '13.905 kB' } ]; diff --git a/packages/use-intl/src/core/AbstractIntlMessages.tsx b/packages/use-intl/src/core/AbstractIntlMessages.tsx index 3a62dec89..93dae895e 100644 --- a/packages/use-intl/src/core/AbstractIntlMessages.tsx +++ b/packages/use-intl/src/core/AbstractIntlMessages.tsx @@ -1,7 +1,7 @@ /** * A generic type that describes the shape of messages. * - * Optionally, `IntlMessages` can be provided to get type safety for message + * Optionally, messages can be strictly-typed in order to get type safety for message * namespaces and keys. See https://next-intl-docs.vercel.app/docs/usage/typescript */ type AbstractIntlMessages = { diff --git a/packages/use-intl/src/core/AppConfig.tsx b/packages/use-intl/src/core/AppConfig.tsx new file mode 100644 index 000000000..1bd0dec03 --- /dev/null +++ b/packages/use-intl/src/core/AppConfig.tsx @@ -0,0 +1,37 @@ +export default interface AppConfig { + // Locale + // Formats + // Messages +} + +export type Locale = AppConfig extends { + Locale: infer AppLocale; +} + ? AppLocale + : string; + +export type FormatNames = AppConfig extends { + Formats: infer AppFormats; +} + ? { + dateTime: AppFormats extends {dateTime: infer AppDateTimeFormats} + ? keyof AppDateTimeFormats + : string; + number: AppFormats extends {number: infer AppNumberFormats} + ? keyof AppNumberFormats + : string; + list: AppFormats extends {list: infer AppListFormats} + ? keyof AppListFormats + : string; + } + : { + dateTime: string; + number: string; + list: string; + }; + +export type Messages = AppConfig extends { + Messages: infer AppMessages; +} + ? AppMessages + : Record; diff --git a/packages/use-intl/src/core/IntlConfig.tsx b/packages/use-intl/src/core/IntlConfig.tsx index d6eaa2528..e5b379a9d 100644 --- a/packages/use-intl/src/core/IntlConfig.tsx +++ b/packages/use-intl/src/core/IntlConfig.tsx @@ -1,4 +1,5 @@ import type AbstractIntlMessages from './AbstractIntlMessages.tsx'; +import type {Locale} from './AppConfig.tsx'; import type Formats from './Formats.tsx'; import type IntlError from './IntlError.tsx'; import type TimeZone from './TimeZone.tsx'; @@ -9,7 +10,7 @@ import type TimeZone from './TimeZone.tsx'; type IntlConfig = { /** A valid Unicode locale tag (e.g. "en" or "en-GB"). */ - locale: string; + locale: Locale; /** Global formats can be provided to achieve consistent * formatting across components. */ formats?: Formats; diff --git a/packages/use-intl/src/core/createBaseTranslator.tsx b/packages/use-intl/src/core/createBaseTranslator.tsx index 28de377e8..2806023be 100644 --- a/packages/use-intl/src/core/createBaseTranslator.tsx +++ b/packages/use-intl/src/core/createBaseTranslator.tsx @@ -1,6 +1,7 @@ import {IntlMessageFormat} from 'intl-messageformat'; import {ReactNode, cloneElement, isValidElement} from 'react'; import AbstractIntlMessages from './AbstractIntlMessages.tsx'; +import {Locale} from './AppConfig.tsx'; import Formats from './Formats.tsx'; import {InitializedIntlConfig} from './IntlConfig.tsx'; import IntlError, {IntlErrorCode} from './IntlError.tsx'; @@ -41,7 +42,7 @@ function createMessageFormatter( } function resolvePath( - locale: string, + locale: Locale, messages: AbstractIntlMessages | undefined, key: string, namespace?: string @@ -103,7 +104,7 @@ function prepareTranslationValues(values: RichTranslationValues) { } function getMessagesOrError( - locale: string, + locale: Locale, messages?: Messages, namespace?: string, onError: (error: IntlError) => void = defaultOnError diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index 8549f315d..02c38cf5a 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -1,4 +1,5 @@ import {ReactElement} from 'react'; +import {FormatNames, Locale} from './AppConfig.tsx'; import DateTimeFormatOptions from './DateTimeFormatOptions.tsx'; import Formats from './Formats.tsx'; import IntlError, {IntlErrorCode} from './IntlError.tsx'; @@ -70,7 +71,7 @@ function calculateRelativeTimeValue( } type Props = { - locale: string; + locale: Locale; timeZone?: TimeZone; onError?(error: IntlError): void; formats?: Formats; @@ -163,9 +164,7 @@ export default function createFormatter({ value: Date | number, /** If a time zone is supplied, the `value` is converted to that time zone. * Otherwise the user time zone will be used. */ - formatOrOptions?: - | Extract - | DateTimeFormatOptions + formatOrOptions?: FormatNames['dateTime'] | DateTimeFormatOptions ) { return getFormattedValue( formatOrOptions, @@ -185,9 +184,7 @@ export default function createFormatter({ end: Date | number, /** If a time zone is supplied, the values are converted to that time zone. * Otherwise the user time zone will be used. */ - formatOrOptions?: - | Extract - | DateTimeFormatOptions + formatOrOptions?: FormatNames['dateTime'] | DateTimeFormatOptions ) { return getFormattedValue( formatOrOptions, @@ -204,9 +201,7 @@ export default function createFormatter({ function number( value: number | bigint, - formatOrOptions?: - | Extract - | NumberFormatOptions + formatOrOptions?: FormatNames['number'] | NumberFormatOptions ) { return getFormattedValue( formatOrOptions, @@ -290,9 +285,7 @@ export default function createFormatter({ type FormattableListValue = string | ReactElement; function list( value: Iterable, - formatOrOptions?: - | Extract - | Intl.ListFormatOptions + formatOrOptions?: FormatNames['list'] | Intl.ListFormatOptions ): Value extends string ? string : Iterable { const serializedValue: Array = []; const richValues = new Map(); diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index 6bc825486..cf51904fb 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -1,4 +1,5 @@ import {ReactNode} from 'react'; +import {Messages} from './AppConfig.tsx'; import Formats from './Formats.tsx'; import IntlConfig from './IntlConfig.tsx'; import TranslationValues, { @@ -27,10 +28,7 @@ import NestedValueOf from './utils/NestedValueOf.tsx'; * (e.g. `namespace.Component`). */ export default function createTranslator< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never + NestedKey extends NamespaceKeys> = never >({ _cache = createCache(), _formatters = createIntlFormatters(_cache), @@ -39,8 +37,8 @@ export default function createTranslator< namespace, onError = defaultOnError, ...rest -}: Omit, 'messages'> & { - messages?: IntlConfig['messages']; +}: Omit, 'messages'> & { + messages?: IntlConfig['messages']; namespace?: NestedKey; /** @private */ _formatters?: Formatters; @@ -52,12 +50,12 @@ export default function createTranslator< < TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -72,12 +70,12 @@ export default function createTranslator< rich< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -92,12 +90,12 @@ export default function createTranslator< markup< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -112,12 +110,12 @@ export default function createTranslator< raw< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -130,12 +128,12 @@ export default function createTranslator< has< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -148,7 +146,7 @@ export default function createTranslator< // namespace works correctly. See https://stackoverflow.com/a/71529575/343045 // The prefix ("!") is arbitrary. return createTranslatorImpl< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >( { diff --git a/packages/use-intl/src/core/hasLocale.test.tsx b/packages/use-intl/src/core/hasLocale.test.tsx new file mode 100644 index 000000000..19a6dfdcb --- /dev/null +++ b/packages/use-intl/src/core/hasLocale.test.tsx @@ -0,0 +1,95 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import hasLocale from './hasLocale.tsx'; + +it('narrows down the type', () => { + const locales = ['en-US', 'en-GB'] as const; + const candidate = 'en-US' as string; + if (hasLocale(locales, candidate)) { + candidate satisfies (typeof locales)[number]; + } +}); + +it('can be called with a matching narrow candidate', () => { + const locales = ['en-US', 'en-GB'] as const; + const candidate = 'en-US' as const; + if (hasLocale(locales, candidate)) { + candidate satisfies (typeof locales)[number]; + } +}); + +it('can be called with a non-matching narrow candidate', () => { + const locales = ['en-US', 'en-GB'] as const; + const candidate = 'de' as const; + if (hasLocale(locales, candidate)) { + candidate satisfies never; + } +}); + +describe('accepts valid formats', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it.each([ + 'en', + 'en-US', + 'EN-US', + 'en-us', + 'en-GB', + 'zh-Hans-CN', + 'es-419', + 'en-Latn', + 'zh-Hans', + 'en-US-u-ca-buddhist', + 'en-x-private1', + 'en-US-u-nu-thai', + 'ar-u-nu-arab', + 'en-t-m0-true', + 'zh-Hans-CN-x-private1-private2', + 'en-US-u-ca-gregory-nu-latn', + 'en-US-x-usd', + + // Somehow tolerated by Intl.Locale + 'english' + ])('accepts: %s', (locale) => { + expect(hasLocale([locale] as const, locale)).toBe(true); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +}); + +describe('warns for invalid formats', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it.each([ + 'en_US', + 'en-', + 'e-US', + 'en-USA', + 'und', + '123', + '-en', + 'en--US', + 'toolongstring', + 'en-US-', + '@#$', + 'en US', + 'en.US' + ])('rejects: %s', (locale) => { + hasLocale([locale] as const, locale); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/use-intl/src/core/hasLocale.tsx b/packages/use-intl/src/core/hasLocale.tsx new file mode 100644 index 000000000..6094cfd01 --- /dev/null +++ b/packages/use-intl/src/core/hasLocale.tsx @@ -0,0 +1,31 @@ +import type {Locale} from './AppConfig.tsx'; + +/** + * Checks if a locale exists in a list of locales. + * + * Additionally, in development, the provided locales are validated to + * ensure they follow the Unicode language identifier standard. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale + */ +export default function hasLocale( + locales: ReadonlyArray, + candidate?: string | null +): candidate is LocaleType { + if (process.env.NODE_ENV !== 'production') { + for (const locale of locales) { + try { + const constructed = new Intl.Locale(locale); + if (!constructed.language) { + throw new Error('Language is required'); + } + } catch { + console.error( + `Found invalid locale within provided \`locales\`: "${locale}"\nPlease ensure you're using a valid Unicode locale identifier (e.g. "en-US").` + ); + } + } + } + + return locales.includes(candidate as LocaleType); +} diff --git a/packages/use-intl/src/core/index.tsx b/packages/use-intl/src/core/index.tsx index 51a40f8a4..55459b66e 100644 --- a/packages/use-intl/src/core/index.tsx +++ b/packages/use-intl/src/core/index.tsx @@ -18,3 +18,5 @@ export type {default as NestedKeyOf} from './utils/NestedKeyOf.tsx'; export type {default as NestedValueOf} from './utils/NestedValueOf.tsx'; export {createIntlFormatters as _createIntlFormatters} from './formatters.tsx'; export {createCache as _createCache} from './formatters.tsx'; +export type {default as AppConfig, Locale, Messages} from './AppConfig.tsx'; +export {default as hasLocale} from './hasLocale.tsx'; diff --git a/packages/use-intl/src/react/index.test.tsx b/packages/use-intl/src/react/index.test.tsx index f66f596e0..d8f9de37e 100644 --- a/packages/use-intl/src/react/index.test.tsx +++ b/packages/use-intl/src/react/index.test.tsx @@ -1,6 +1,7 @@ import {render, screen} from '@testing-library/react'; import {parseISO} from 'date-fns'; import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {Locale} from '../core.tsx'; import IntlProvider from './IntlProvider.tsx'; import useFormatter from './useFormatter.tsx'; import useNow from './useNow.tsx'; @@ -24,7 +25,7 @@ describe('performance', () => { ); } - function App({locale}: {locale: string}) { + function App({locale}: {locale: Locale}) { return ( - > = never + NestedKey extends NamespaceKeys> = never >( namespace?: NestedKey ): // Explicitly defining the return type is necessary as TypeScript would get it wrong @@ -32,12 +30,12 @@ export default function useTranslations< < TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -52,12 +50,12 @@ export default function useTranslations< rich< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -72,12 +70,12 @@ export default function useTranslations< markup< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -92,12 +90,12 @@ export default function useTranslations< raw< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -110,12 +108,12 @@ export default function useTranslations< has< TargetKey extends MessageKeys< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >, NestedKeyOf< NestedValueOf< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` > > @@ -125,13 +123,13 @@ export default function useTranslations< ): boolean; } { const context = useIntlContext(); - const messages = context.messages as IntlMessages; + const messages = context.messages as Messages; // We have to wrap the actual hook so the type inference for the optional // namespace works correctly. See https://stackoverflow.com/a/71529575/343045 // The prefix ("!") is arbitrary. return useTranslationsImpl< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >( {'!': messages}, diff --git a/packages/use-intl/types/index.d.ts b/packages/use-intl/types/index.d.ts index 53e36206c..a6c395566 100644 --- a/packages/use-intl/types/index.d.ts +++ b/packages/use-intl/types/index.d.ts @@ -4,20 +4,7 @@ declare namespace NodeJS { } } -// This type is intended to be overridden -// by the consumer for optional type safety of messages -declare interface IntlMessages extends Record {} - -// This type is intended to be overridden -// by the consumer for optional type safety of formats -declare interface IntlFormats { - dateTime: any; - number: any; - list: any; -} - // Temporarly copied here until the "es2020.intl" lib is published. - declare namespace Intl { /** * [BCP 47 language tag](http://tools.ietf.org/html/rfc5646) definition. From 2388c9b97cff804750053831e69f3dcb4fe8cb8d Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 1 Nov 2024 09:55:41 +0100 Subject: [PATCH 23/90] remove unused file --- .../react-server/RequestLocaleLegacy.tsx | 48 ------------------- 1 file changed, 48 deletions(-) delete mode 100644 packages/next-intl/src/server/react-server/RequestLocaleLegacy.tsx diff --git a/packages/next-intl/src/server/react-server/RequestLocaleLegacy.tsx b/packages/next-intl/src/server/react-server/RequestLocaleLegacy.tsx deleted file mode 100644 index 174a5c7c7..000000000 --- a/packages/next-intl/src/server/react-server/RequestLocaleLegacy.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import {headers} from 'next/headers.js'; -import {notFound} from 'next/navigation.js'; -import {cache} from 'react'; -import {HEADER_LOCALE_NAME} from '../../shared/constants.tsx'; -import {getCachedRequestLocale} from './RequestLocaleCache.tsx'; - -// This was originally built for Next.js <14, where `headers()` was not async. -// With https://github.com/vercel/next.js/pull/68812, the API became async. -// This file can be removed once we remove the legacy navigation APIs. -function getHeaders() { - return headers() as unknown as Awaited>; -} - -function getLocaleFromHeaderImpl() { - let locale; - - try { - locale = getHeaders().get(HEADER_LOCALE_NAME); - } catch (error) { - if ( - error instanceof Error && - (error as any).digest === 'DYNAMIC_SERVER_USAGE' - ) { - throw new Error( - 'Usage of next-intl APIs in Server Components currently opts into dynamic rendering. This limitation will eventually be lifted, but as a stopgap solution, you can use the `setRequestLocale` API to enable static rendering, see https://next-intl-docs.vercel.app/docs/getting-started/app-router/with-i18n-routing#static-rendering', - {cause: error} - ); - } else { - throw error; - } - } - - if (!locale) { - if (process.env.NODE_ENV !== 'production') { - console.error( - `\nUnable to find \`next-intl\` locale because the middleware didn't run on this request. See https://next-intl-docs.vercel.app/docs/routing/middleware#unable-to-find-locale. The \`notFound()\` function will be called as a result.\n` - ); - } - notFound(); - } - - return locale; -} -const getLocaleFromHeader = cache(getLocaleFromHeaderImpl); - -export function getRequestLocale(): string { - return getCachedRequestLocale() || getLocaleFromHeader(); -} From 01268f67b468c16c498891a64458f13e6b7b5bc9 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 7 Nov 2024 15:21:53 +0100 Subject: [PATCH 24/90] feat: Type-safe ICU arguments (#1499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ```json { "UserProfile": { "title": "Hello {firstName}" } } ``` ```tsx function UserProfile({user}) { const t = useTranslations('UserProfile'); // ✖️ Missing argument t('title'); // ✅ Argument is provided t('title', {firstName: user.firstName}); } ``` **Changes** - ICU arguments can now be validated with TypeScript (however, currently this is [opt-in](https://next-intl-docs-git-feat-type-safe-params-next-intl.vercel.app/docs/workflows/typescript#messages-arguments)) - `undefined` and `null` are no longer accepted as values for calls to `t`—please only provide valid values. Fixes https://github.com/amannn/next-intl/issues/410 --- docs/src/pages/docs/usage/configuration.mdx | 8 +- docs/src/pages/docs/workflows/typescript.mdx | 245 +++++---- .../global.d.ts | 4 +- .../example-app-router-next-auth/global.d.ts | 4 +- .../src/app/[locale]/Index.tsx | 4 +- .../example-app-router-playground/.gitignore | 1 + .../eslint.config.mjs | 7 +- .../example-app-router-playground/global.d.ts | 4 +- .../next.config.mjs | 7 +- .../package.json | 1 + .../components/UseTranslationsTypeTests.tsx | 44 ++ .../tsconfig.json | 1 + examples/example-app-router/.gitignore | 1 + examples/example-app-router/global.d.ts | 4 +- examples/example-app-router/next.config.mjs | 6 +- examples/example-app-router/tsconfig.json | 1 + .../example-pages-router-advanced/global.d.ts | 4 +- .../src/components/Navigation.tsx | 2 +- .../src/pages/index.tsx | 2 +- examples/example-pages-router/global.d.ts | 4 +- .../src/components/LocaleSwitcher.tsx | 2 +- examples/example-use-intl/global.d.ts | 4 +- packages/next-intl/package.json | 1 + packages/next-intl/plugin.d.cts | 6 +- packages/next-intl/src/plugin.tsx | 123 +---- .../src/plugin/createMessagesDeclaration.tsx | 84 ++++ .../src/plugin/createNextIntlPlugin.tsx | 36 ++ .../next-intl/src/plugin/getNextConfig.tsx | 110 ++++ packages/next-intl/src/plugin/index.tsx | 1 + packages/next-intl/src/plugin/types.tsx | 6 + packages/next-intl/src/plugin/utils.tsx | 11 + .../src/react-server/getTranslator.tsx | 107 +--- .../server/react-server/getTranslations.tsx | 188 +------ packages/use-intl/src/core/ICUArgs.tsx | 181 +++++++ packages/use-intl/src/core/ICUTags.tsx | 8 + packages/use-intl/src/core/MessageKeys.tsx | 36 ++ .../use-intl/src/core/TranslationValues.tsx | 27 +- .../src/core/createBaseTranslator.tsx | 4 +- .../src/core/createTranslator.test.tsx | 469 +++++++++++++++++- .../use-intl/src/core/createTranslator.tsx | 132 +++-- .../src/core/createTranslatorImpl.tsx | 2 +- packages/use-intl/src/core/index.tsx | 10 +- packages/use-intl/src/core/types.tsx | 6 + .../use-intl/src/core/utils/MessageKeys.tsx | 9 - .../use-intl/src/core/utils/NamespaceKeys.tsx | 9 - .../use-intl/src/core/utils/NestedKeyOf.tsx | 9 - .../use-intl/src/core/utils/NestedValueOf.tsx | 12 - .../use-intl/src/react/useTranslations.tsx | 112 +---- .../src/react/useTranslationsImpl.tsx | 2 +- pnpm-lock.yaml | 82 ++- 50 files changed, 1328 insertions(+), 815 deletions(-) create mode 100644 examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx create mode 100644 packages/next-intl/src/plugin/createMessagesDeclaration.tsx create mode 100644 packages/next-intl/src/plugin/createNextIntlPlugin.tsx create mode 100644 packages/next-intl/src/plugin/getNextConfig.tsx create mode 100644 packages/next-intl/src/plugin/index.tsx create mode 100644 packages/next-intl/src/plugin/types.tsx create mode 100644 packages/next-intl/src/plugin/utils.tsx create mode 100644 packages/use-intl/src/core/ICUArgs.tsx create mode 100644 packages/use-intl/src/core/ICUTags.tsx create mode 100644 packages/use-intl/src/core/MessageKeys.tsx create mode 100644 packages/use-intl/src/core/types.tsx delete mode 100644 packages/use-intl/src/core/utils/MessageKeys.tsx delete mode 100644 packages/use-intl/src/core/utils/NamespaceKeys.tsx delete mode 100644 packages/use-intl/src/core/utils/NestedKeyOf.tsx delete mode 100644 packages/use-intl/src/core/utils/NestedValueOf.tsx diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx index 574542e8a..ba8f1b760 100644 --- a/docs/src/pages/docs/usage/configuration.mdx +++ b/docs/src/pages/docs/usage/configuration.mdx @@ -170,8 +170,12 @@ export default getRequestConfig(async ({requestLocale}) => { ```tsx filename="i18n/request.ts" export default getRequestConfig(async () => { + // Provide a static locale, fetch a user setting, + // read from `cookies()`, `headers()`, etc. + const locale = 'en'; + return { - locale: 'en' + locale // ... }; }); @@ -214,6 +218,8 @@ import {getLocale} from 'next-intl/server'; const locale = await getLocale(); ``` +### `Locale` [#locale-type] + When passing a `locale` to another function, you can use the `Locale` type for the receiving parameter: ```tsx diff --git a/docs/src/pages/docs/workflows/typescript.mdx b/docs/src/pages/docs/workflows/typescript.mdx index 878d5003d..e48ded0e9 100644 --- a/docs/src/pages/docs/workflows/typescript.mdx +++ b/docs/src/pages/docs/workflows/typescript.mdx @@ -9,18 +9,78 @@ import Callout from '@/components/Callout'; However, you can optionally provide supplemental definitions to augment the types that `next-intl` works with, enabling improved autocompletion and type safety across your app. ```tsx filename="global.d.ts" +import {routing} from '@/i18n/routing'; +import {formats} from '@/i18n/request'; +import messages from './messages/en.json'; + declare module 'next-intl' { interface AppConfig { - // ... + Locale: (typeof routing.locales)[number]; + Messages: typeof messages; + Formats: typeof formats; } } ``` Type augmentation is available for: +- [`Locale`](#locale) - [`Messages`](#messages) - [`Formats`](#formats) -- [`Locale`](#locale) + +## `Locale` + +Augmenting the `Locale` type will affect all APIs from `next-intl` that either return or receive a locale: + +```tsx +import {useLocale} from 'next-intl'; + +// ✅ 'en' | 'de' +const locale = useLocale(); +``` + +```tsx +import {Link} from '@/i18n/routing'; + +// ✅ Passes the validation +; +``` + +Additionally, `next-intl` provides a [`Locale`](/docs/usage/configuration#locale-type) type that can be used when passing the locale as an argument. + +To enable this validation, you can adapt `AppConfig` as follows: + + + + +```tsx filename="global.d.ts" +import {routing} from '@/i18n/routing'; + +declare module 'next-intl' { + interface AppConfig { + // ... + Locale: (typeof routing.locales)[number]; + } +} +``` + + + + +```tsx filename="global.d.ts" +// Potentially imported from a shared config +const locales = ['en', 'de'] as const; + +declare module 'next-intl' { + interface AppConfig { + // ... + Locale: (typeof locales)[number]; + } +} +``` + + + ## `Messages` @@ -50,18 +110,90 @@ function About() { To enable this validation, you can adapt `AppConfig` as follows: ```ts filename="global.d.ts" -import en from './messages/en.json'; +import messages from './messages/en.json'; declare module 'next-intl' { interface AppConfig { // ... - Messages: typeof en; + Messages: typeof messages; } } ``` You can freely define the interface, but if you have your messages available locally, it can be helpful to automatically create the type based on the messages from your default locale. +### Strict arguments [#messages-arguments] + +Apart from strictly typing message keys, you can also ensure type safety for message arguments: + +```json filename="messages/en.json" +{ + "UserProfile": { + "title": "Hello {firstName}" + } +} +``` + +```tsx +function UserProfile({user}) { + const t = useTranslations('UserProfile'); + + // ✖️ Missing argument + t('title'); + + // ✅ Argument is provided + t('title', {firstName: user.firstName}); +} +``` + +TypeScript currently has a [limitation](https://github.com/microsoft/TypeScript/issues/32063) where it infers the types of an imported JSON module as rather wide. Due to this, `next-intl` provides a stopgap solution that allows you to generate an accompanying `.d.json.ts` file for the messages that you're assigning to your `AppConfig`. + +**Usage:** + +1. Enable the `createMessagesDeclaration` setting in your Next.js config: + +```tsx filename="next.config.mjs" +import {createNextIntlPlugin} from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin({ + experimental: { + // Use the path to the messages that you're using in `AppConfig` + createMessagesDeclaration: './messages/en.json' + } + // ... +}); + +// ... +``` + +2. Add support for JSON type declarations in your `tsconfig.json`: + +```json filename="tsconfig.json" +{ + "compilerOptions": { + // ... + "allowArbitraryExtensions": true + } +} +``` + +With this setup in place, you'll see a new declaration file generated in your `messages` directory once you run `next dev` or `next build`: + +```diff + messages/en.json ++ messages/en.d.json.ts +``` + +This declaration file will provide the exact types for the messages that you're using in `AppConfig`, enabling type safety for message arguments. + +To keep your code base tidy, you can ignore this file in Git: + +```text filename=".gitignore" +messages/*.d.json.ts +``` + +Please consider upvoting [`TypeScript#32063`](https://github.com/microsoft/TypeScript/issues/32063) to potentially remove this workaround in the future. + ## `Formats` If you're using [global formats](/docs/usage/configuration#formats), you can strictly type the format names that are referenced in calls to `format.dateTime`, `format.number` and `format.list`. @@ -126,111 +258,6 @@ declare module 'next-intl' { } ``` -## `Locale` - -Augmenting the `Locale` type will affect the return type of [`useLocale`](/docs/usage/configuration#locale), as well as all `locale` arguments that are accepted by APIs from `next-intl` (e.g. the `locale` prop of [``](/docs/routing/navigation#link)). - -```tsx -// ✅ 'en' | 'de' -const locale = useLocale(); -``` - -To enable this validation, you can adapt `AppConfig` as follows: - - - - -```tsx filename="global.d.ts" -import {routing} from '@/i18n/routing'; - -declare module 'next-intl' { - interface AppConfig { - // ... - Locale: (typeof routing.locales)[number]; - } -} -``` - - - - -```tsx filename="global.d.ts" -// Potentially imported from a shared config -const locales = ['en', 'de'] as const; - -declare module 'next-intl' { - interface AppConfig { - // ... - Locale: (typeof locales)[number]; - } -} -``` - - - - -### Using the `Locale` type for arguments - -Once the `Locale` type is augmented, it can be used across your codebase if you need to pass the locale to functions outside of your components: - -```tsx {1,10} -import {Locale} from 'next-intl'; -import {getLocale} from 'next-intl/server'; - -async function BlogPosts() { - const locale = await getLocale(); - const posts = await getPosts(locale); - // ... -} - -async function getPosts(locale: Locale) { - // ... -} -``` - -### Using the `Locale` type for layout and page params [#locale-segment-params] - -You can also use the `Locale` type when working with the `[locale]` parameter in layouts and pages: - -```tsx filename="app/[locale]/page.tsx" -import {Locale} from 'next-intl'; - -type Props = { - params: { - locale: Locale; - }; -}; - -export default function Page(props: Props) { - // ... -} -``` - -However, keep in mind that this _assumes_ the locale to be valid in this place—Next.js doesn't validate the `[locale]` parameter automatically for you. Due to this, you can add your own validation logic in a central place like the root layout: - -```tsx filename="app/[locale]/layout.tsx" -import {hasLocale} from 'next-intl'; - -// Can be imported e.g. from `@/i18n/routing` -const locales = ['en', 'de'] as const; - -type Props = { - params: { - children: React.ReactNode; - locale: string; - }; -}; - -export default async function LocaleLayout({params: {locale}}: Props) { - if (!hasLocale(locales, locale)) { - notFound(); - } - - // ✅ 'en' | 'de' - console.log(locale); -} -``` - ## Troubleshooting If you're encountering problems, double check that: diff --git a/examples/example-app-router-mixed-routing/global.d.ts b/examples/example-app-router-mixed-routing/global.d.ts index 604dfbb40..98a911d1a 100644 --- a/examples/example-app-router-mixed-routing/global.d.ts +++ b/examples/example-app-router-mixed-routing/global.d.ts @@ -1,9 +1,9 @@ import {locales} from '@/config'; -import en from './messages/en.json'; +import messages from './messages/en.json'; declare module 'next-intl' { interface AppConfig { Locale: (typeof locales)[number]; - Messages: typeof en; + Messages: typeof messages; } } diff --git a/examples/example-app-router-next-auth/global.d.ts b/examples/example-app-router-next-auth/global.d.ts index 62bfc23e3..6cb8e005a 100644 --- a/examples/example-app-router-next-auth/global.d.ts +++ b/examples/example-app-router-next-auth/global.d.ts @@ -1,9 +1,9 @@ import {routing} from '@/i18n/routing'; -import en from './messages/en.json'; +import messages from './messages/en.json'; declare module 'next-intl' { interface AppConfig { Locale: (typeof routing.locales)[number]; - Messages: typeof en; + Messages: typeof messages; } } diff --git a/examples/example-app-router-next-auth/src/app/[locale]/Index.tsx b/examples/example-app-router-next-auth/src/app/[locale]/Index.tsx index f7c6d90c2..324a5cecd 100644 --- a/examples/example-app-router-next-auth/src/app/[locale]/Index.tsx +++ b/examples/example-app-router-next-auth/src/app/[locale]/Index.tsx @@ -20,9 +20,9 @@ export default function Index({session}: Props) { return ( - {session ? ( + {session?.user?.name ? ( <> -

{t('loggedIn', {username: session.user?.name})}

+

{t('loggedIn', {username: session.user.name})}

{t('secret')}

diff --git a/examples/example-app-router-playground/.gitignore b/examples/example-app-router-playground/.gitignore index d61873784..080da4308 100644 --- a/examples/example-app-router-playground/.gitignore +++ b/examples/example-app-router-playground/.gitignore @@ -5,3 +5,4 @@ tsconfig.tsbuildinfo *storybook.log storybook-static test-results +messages/*.d.json.ts diff --git a/examples/example-app-router-playground/eslint.config.mjs b/examples/example-app-router-playground/eslint.config.mjs index 8a4bf6954..8ae9b8ef2 100644 --- a/examples/example-app-router-playground/eslint.config.mjs +++ b/examples/example-app-router-playground/eslint.config.mjs @@ -1,3 +1,8 @@ import {getPresets} from 'eslint-config-molindo'; +import globals from 'globals'; -export default await getPresets('typescript', 'react', 'jest'); +export default (await getPresets('typescript', 'react', 'jest')).concat({ + languageOptions: { + globals: globals.node + } +}); diff --git a/examples/example-app-router-playground/global.d.ts b/examples/example-app-router-playground/global.d.ts index 277003def..85c56e020 100644 --- a/examples/example-app-router-playground/global.d.ts +++ b/examples/example-app-router-playground/global.d.ts @@ -1,11 +1,11 @@ import {formats} from '@/i18n/request'; import {routing} from '@/i18n/routing'; -import en from './messages/en.json'; +import messages from './messages/en.json'; declare module 'next-intl' { interface AppConfig { Locale: (typeof routing.locales)[number]; Formats: typeof formats; - Messages: typeof en; + Messages: typeof messages; } } diff --git a/examples/example-app-router-playground/next.config.mjs b/examples/example-app-router-playground/next.config.mjs index f49d4d53e..eb3878299 100644 --- a/examples/example-app-router-playground/next.config.mjs +++ b/examples/example-app-router-playground/next.config.mjs @@ -3,7 +3,12 @@ import mdxPlugin from '@next/mdx'; import createNextIntlPlugin from 'next-intl/plugin'; -const withNextIntl = createNextIntlPlugin('./src/i18n/request.tsx'); +const withNextIntl = createNextIntlPlugin({ + requestConfig: './src/i18n/request.tsx', + experimental: { + createMessagesDeclaration: './messages/en.json' + } +}); const withMdx = mdxPlugin(); export default withMdx( diff --git a/examples/example-app-router-playground/package.json b/examples/example-app-router-playground/package.json index 916883681..941e94bc8 100644 --- a/examples/example-app-router-playground/package.json +++ b/examples/example-app-router-playground/package.json @@ -40,6 +40,7 @@ "css-loader": "^6.8.1", "eslint": "^9.11.1", "eslint-config-molindo": "^8.0.0", + "globals": "^15.11.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "prettier": "^3.3.3", diff --git a/examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx b/examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx new file mode 100644 index 000000000..51a26c5ef --- /dev/null +++ b/examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx @@ -0,0 +1,44 @@ +import { + createTranslator, + useLocale, + useMessages, + useTranslations +} from 'next-intl'; +import {getTranslations} from 'next-intl/server'; + +export function RegularComponent() { + const t = useTranslations('ClientCounter'); + t('count', {count: 1}); + + // @ts-expect-error + t('count'); + // @ts-expect-error + t('count', {num: 1}); +} + +export function CreateTranslator() { + const messages = useMessages(); + const locale = useLocale(); + const t = createTranslator({ + locale, + messages, + namespace: 'ClientCounter' + }); + + t('count', {count: 1}); + + // @ts-expect-error + t('count'); + // @ts-expect-error + t('count', {num: 1}); +} + +export async function AsyncComponent() { + const t = await getTranslations('ClientCounter'); + t('count', {count: 1}); + + // @ts-expect-error + t('count'); + // @ts-expect-error + t('count', {num: 1}); +} diff --git a/examples/example-app-router-playground/tsconfig.json b/examples/example-app-router-playground/tsconfig.json index 8d6bca754..dc45b2e97 100644 --- a/examples/example-app-router-playground/tsconfig.json +++ b/examples/example-app-router-playground/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "eslint-config-molindo/tsconfig.json", "compilerOptions": { + "allowArbitraryExtensions": true, "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, diff --git a/examples/example-app-router/.gitignore b/examples/example-app-router/.gitignore index 85549a55b..8b567be68 100644 --- a/examples/example-app-router/.gitignore +++ b/examples/example-app-router/.gitignore @@ -6,3 +6,4 @@ tsconfig.tsbuildinfo /playwright-report/ /playwright/.cache/ out +messages/en.d.json.ts diff --git a/examples/example-app-router/global.d.ts b/examples/example-app-router/global.d.ts index 62bfc23e3..6cb8e005a 100644 --- a/examples/example-app-router/global.d.ts +++ b/examples/example-app-router/global.d.ts @@ -1,9 +1,9 @@ import {routing} from '@/i18n/routing'; -import en from './messages/en.json'; +import messages from './messages/en.json'; declare module 'next-intl' { interface AppConfig { Locale: (typeof routing.locales)[number]; - Messages: typeof en; + Messages: typeof messages; } } diff --git a/examples/example-app-router/next.config.mjs b/examples/example-app-router/next.config.mjs index 46841e0e7..1751fe61a 100644 --- a/examples/example-app-router/next.config.mjs +++ b/examples/example-app-router/next.config.mjs @@ -2,7 +2,11 @@ import createNextIntlPlugin from 'next-intl/plugin'; -const withNextIntl = createNextIntlPlugin(); +const withNextIntl = createNextIntlPlugin({ + experimental: { + createMessagesDeclaration: './messages/en.json' + } +}); /** @type {import('next').NextConfig} */ const config = {}; diff --git a/examples/example-app-router/tsconfig.json b/examples/example-app-router/tsconfig.json index 49aa1ee30..a4ea571af 100644 --- a/examples/example-app-router/tsconfig.json +++ b/examples/example-app-router/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "eslint-config-molindo/tsconfig.json", "compilerOptions": { + "allowArbitraryExtensions": true, "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, diff --git a/examples/example-pages-router-advanced/global.d.ts b/examples/example-pages-router-advanced/global.d.ts index 02f24a1b3..bc828b1cf 100644 --- a/examples/example-pages-router-advanced/global.d.ts +++ b/examples/example-pages-router-advanced/global.d.ts @@ -1,7 +1,7 @@ -import en from './messages/en.json'; +import messages from './messages/en.json'; declare module 'next-intl' { interface AppConfig { - Messages: typeof en; + Messages: typeof messages; } } diff --git a/examples/example-pages-router-advanced/src/components/Navigation.tsx b/examples/example-pages-router-advanced/src/components/Navigation.tsx index b09a77870..664f501ac 100644 --- a/examples/example-pages-router-advanced/src/components/Navigation.tsx +++ b/examples/example-pages-router-advanced/src/components/Navigation.tsx @@ -6,7 +6,7 @@ export default function Navigation() { const t = useTranslations('Navigation'); const {locale, locales, route} = useRouter(); - const otherLocale = locales?.find((cur) => cur !== locale); + const otherLocale = locales?.find((cur) => cur !== locale) as string; return (
diff --git a/examples/example-pages-router-advanced/src/pages/index.tsx b/examples/example-pages-router-advanced/src/pages/index.tsx index 51505127f..2f5ddd32e 100644 --- a/examples/example-pages-router-advanced/src/pages/index.tsx +++ b/examples/example-pages-router-advanced/src/pages/index.tsx @@ -13,7 +13,7 @@ export default function Index() {
{t.rich('description', { - locale, + locale: locale!, p: (children) =>

{children}

, code: (children) => {children} })} diff --git a/examples/example-pages-router/global.d.ts b/examples/example-pages-router/global.d.ts index 02f24a1b3..bc828b1cf 100644 --- a/examples/example-pages-router/global.d.ts +++ b/examples/example-pages-router/global.d.ts @@ -1,7 +1,7 @@ -import en from './messages/en.json'; +import messages from './messages/en.json'; declare module 'next-intl' { interface AppConfig { - Messages: typeof en; + Messages: typeof messages; } } diff --git a/examples/example-pages-router/src/components/LocaleSwitcher.tsx b/examples/example-pages-router/src/components/LocaleSwitcher.tsx index b76d34fc1..565931671 100644 --- a/examples/example-pages-router/src/components/LocaleSwitcher.tsx +++ b/examples/example-pages-router/src/components/LocaleSwitcher.tsx @@ -6,7 +6,7 @@ export default function LocaleSwitcher() { const t = useTranslations('LocaleSwitcher'); const {locale, locales, route} = useRouter(); - const otherLocale = locales?.find((cur) => cur !== locale); + const otherLocale = locales?.find((cur) => cur !== locale) as string; return ( diff --git a/examples/example-use-intl/global.d.ts b/examples/example-use-intl/global.d.ts index 5f39e9b68..9db98bbdf 100644 --- a/examples/example-use-intl/global.d.ts +++ b/examples/example-use-intl/global.d.ts @@ -1,10 +1,10 @@ import 'use-intl'; -import en from './messages/en.json'; +import messages from './messages/en.json'; import {locales} from './src/config'; declare module 'use-intl' { interface AppConfig { Locale: (typeof locales)[number]; - Messages: typeof en; + Messages: typeof messages; } } diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 57b69eb26..b591b7ed6 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -111,6 +111,7 @@ ], "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", + "chokidar": "^4.0.1", "negotiator": "^1.0.0", "use-intl": "workspace:^" }, diff --git a/packages/next-intl/plugin.d.cts b/packages/next-intl/plugin.d.cts index 46a5d3fe8..266baeabc 100644 --- a/packages/next-intl/plugin.d.cts +++ b/packages/next-intl/plugin.d.cts @@ -1,7 +1,3 @@ -import {NextConfig} from 'next'; - -function createNextIntlPlugin( - i18nPath?: string -): (config?: NextConfig) => NextConfig; +import createNextIntlPlugin from './dist/types/plugin.ts'; export = createNextIntlPlugin; diff --git a/packages/next-intl/src/plugin.tsx b/packages/next-intl/src/plugin.tsx index b6d6e8616..ef53890a0 100644 --- a/packages/next-intl/src/plugin.tsx +++ b/packages/next-intl/src/plugin.tsx @@ -1,122 +1 @@ -/* eslint-env node */ - -import fs from 'fs'; -import path from 'path'; -import type {NextConfig} from 'next'; - -function withExtensions(localPath: string) { - return [ - `${localPath}.ts`, - `${localPath}.tsx`, - `${localPath}.js`, - `${localPath}.jsx` - ]; -} - -function resolveI18nPath(providedPath?: string, cwd?: string) { - function resolvePath(pathname: string) { - const parts = []; - if (cwd) parts.push(cwd); - parts.push(pathname); - return path.resolve(...parts); - } - - function pathExists(pathname: string) { - return fs.existsSync(resolvePath(pathname)); - } - - if (providedPath) { - if (!pathExists(providedPath)) { - throw new Error( - `[next-intl] Could not find i18n config at ${providedPath}, please provide a valid path.` - ); - } - return providedPath; - } else { - for (const candidate of [ - ...withExtensions('./i18n/request'), - ...withExtensions('./src/i18n/request') - ]) { - if (pathExists(candidate)) { - return candidate; - } - } - - throw new Error(`\n[next-intl] Could not locate request configuration module. - -This path is supported by default: ./(src/)i18n/request.{js,jsx,ts,tsx} - -Alternatively, you can specify a custom location in your Next.js config: - -const withNextIntl = createNextIntlPlugin( - './path/to/i18n/request.tsx' -);\n`); - } -} - -function initPlugin(i18nPath?: string, nextConfig?: NextConfig): NextConfig { - if (nextConfig?.i18n != null) { - console.warn( - "\n[next-intl] An `i18n` property was found in your Next.js config. 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 Router, you can refer to this example: https://next-intl-docs.vercel.app/examples#app-router-migration\n" - ); - } - - const useTurbo = process.env.TURBOPACK != null; - - const nextIntlConfig: Partial = {}; - - // Assign alias for `next-intl/config` - if (useTurbo) { - if (i18nPath?.startsWith('/')) { - throw new Error( - "[next-intl] Turbopack support for next-intl currently does not support absolute paths, please provide a relative one (e.g. './src/i18n/config.ts').\n\nFound: " + - i18nPath + - '\n' - ); - } - - // `NextConfig['turbo']` is stable in Next.js 15. In case the - // experimental feature is removed in the future, we should - // replace this accordingly in a future major version. - 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 = 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); -} - -export default function createNextIntlPlugin(i18nPath?: string) { - return function withNextIntl(nextConfig?: NextConfig) { - return initPlugin(i18nPath, nextConfig); - }; -} +export {default} from './plugin/index.tsx'; diff --git a/packages/next-intl/src/plugin/createMessagesDeclaration.tsx b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx new file mode 100644 index 000000000..fc1162727 --- /dev/null +++ b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx @@ -0,0 +1,84 @@ +import fs from 'fs'; +import path from 'path'; +import {watch} from 'chokidar'; +import {throwError} from './utils.tsx'; + +function runOnce(fn: () => void) { + if (process.env._NEXT_INTL_COMPILE_MESSAGES === '1') { + return; + } + process.env._NEXT_INTL_COMPILE_MESSAGES = '1'; + fn(); +} + +export default function createMessagesDeclaration(messagesPath: string) { + const fullPath = path.resolve(messagesPath); + + if (!fs.existsSync(fullPath)) { + throwError( + `\`createMessagesDeclaration\` points to a non-existent file: ${fullPath}` + ); + } + if (!fullPath.endsWith('.json')) { + throwError( + `\`createMessagesDeclaration\` needs to point to a JSON file. Received: ${fullPath}` + ); + } + + const isDev = process.argv.includes('dev'); + const isBuild = process.argv.includes('build'); + + if (!isDev && !isBuild) { + return; + } + + // Next.js can call the Next.js config multiple + // times - ensure we only run once. + runOnce(() => { + compileDeclaration(messagesPath); + + if (isDev) { + startWatching(messagesPath); + } + }); +} + +function startWatching(messagesPath: string) { + const watcher = watch(messagesPath); + + watcher.on('change', () => { + compileDeclaration(messagesPath, true); + }); + + process.on('exit', () => { + void watcher.close(); + }); +} + +function compileDeclaration(messagesPath: string, async: true): Promise; +function compileDeclaration(messagesPath: string, async?: false): void; +function compileDeclaration( + messagesPath: string, + async = false +): void | Promise { + const declarationPath = messagesPath.replace(/\.json$/, '.d.json.ts'); + + function createDeclaration(content: string) { + return `// This file is auto-generated by next-intl, do not edit directly. +// See: https://next-intl-docs.vercel.app/docs/workflows/typescript#messages-arguments + +declare const messages: ${content.trim()}; +export default messages;`; + } + + if (async) { + return fs.promises + .readFile(messagesPath, 'utf-8') + .then((content) => + fs.promises.writeFile(declarationPath, createDeclaration(content)) + ); + } + + const content = fs.readFileSync(messagesPath, 'utf-8'); + fs.writeFileSync(declarationPath, createDeclaration(content)); +} diff --git a/packages/next-intl/src/plugin/createNextIntlPlugin.tsx b/packages/next-intl/src/plugin/createNextIntlPlugin.tsx new file mode 100644 index 000000000..87d81d85a --- /dev/null +++ b/packages/next-intl/src/plugin/createNextIntlPlugin.tsx @@ -0,0 +1,36 @@ +import type {NextConfig} from 'next'; +import createMessagesDeclaration from './createMessagesDeclaration.tsx'; +import getNextConfig from './getNextConfig.tsx'; +import type {PluginConfig} from './types.tsx'; +import {warn} from './utils.tsx'; + +function initPlugin( + pluginConfig: PluginConfig, + nextConfig?: NextConfig +): NextConfig { + if (nextConfig?.i18n != null) { + warn( + "\n[next-intl] An `i18n` property was found in your Next.js config. 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 Router, you can refer to this example: https://next-intl-docs.vercel.app/examples#app-router-migration\n" + ); + } + + if (pluginConfig.experimental?.createMessagesDeclaration) { + createMessagesDeclaration( + pluginConfig.experimental.createMessagesDeclaration + ); + } + + return getNextConfig(pluginConfig, nextConfig); +} + +export default function createNextIntlPlugin( + i18nPathOrConfig: string | PluginConfig = {} +) { + const config = + typeof i18nPathOrConfig === 'string' + ? {requestConfig: i18nPathOrConfig} + : i18nPathOrConfig; + return function withNextIntl(nextConfig?: NextConfig) { + return initPlugin(config, nextConfig); + }; +} diff --git a/packages/next-intl/src/plugin/getNextConfig.tsx b/packages/next-intl/src/plugin/getNextConfig.tsx new file mode 100644 index 000000000..c77f23f45 --- /dev/null +++ b/packages/next-intl/src/plugin/getNextConfig.tsx @@ -0,0 +1,110 @@ +import fs from 'fs'; +import path from 'path'; +import {NextConfig} from 'next'; +import {PluginConfig} from './types.tsx'; +import {throwError} from './utils.tsx'; + +function withExtensions(localPath: string) { + return [ + `${localPath}.ts`, + `${localPath}.tsx`, + `${localPath}.js`, + `${localPath}.jsx` + ]; +} + +function resolveI18nPath(providedPath?: string, cwd?: string) { + function resolvePath(pathname: string) { + const parts = []; + if (cwd) parts.push(cwd); + parts.push(pathname); + return path.resolve(...parts); + } + + function pathExists(pathname: string) { + return fs.existsSync(resolvePath(pathname)); + } + + if (providedPath) { + if (!pathExists(providedPath)) { + throwError( + `Could not find i18n config at ${providedPath}, please provide a valid path.` + ); + } + return providedPath; + } else { + for (const candidate of [ + ...withExtensions('./i18n/request'), + ...withExtensions('./src/i18n/request') + ]) { + if (pathExists(candidate)) { + return candidate; + } + } + + throwError( + `Could not locate request configuration module.\n\nThis path is supported by default: ./(src/)i18n/request.{js,jsx,ts,tsx}\n\nAlternatively, you can specify a custom location in your Next.js config:\n\nconst withNextIntl = createNextIntlPlugin( + +Alternatively, you can specify a custom location in your Next.js config: + +const withNextIntl = createNextIntlPlugin( + './path/to/i18n/request.tsx' +);` + ); + } +} +export default function getNextConfig( + pluginConfig: PluginConfig, + nextConfig?: NextConfig +) { + const useTurbo = process.env.TURBOPACK != null; + const nextIntlConfig: Partial = {}; + + // Assign alias for `next-intl/config` + if (useTurbo) { + if (pluginConfig.requestConfig?.startsWith('/')) { + throwError( + "Turbopack support for next-intl currently does not support absolute paths, please provide a relative one (e.g. './src/i18n/config.ts').\n\nFound: " + + pluginConfig.requestConfig + ); + } + + // `NextConfig['turbo']` is stable in Next.js 15. In case the + // experimental feature is removed in the future, we should + // replace this accordingly in a future major version. + 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(pluginConfig.requestConfig) + } + } + }; + } else { + nextIntlConfig.webpack = function webpack( + ...[config, options]: Parameters> + ) { + // Webpack requires absolute paths + config.resolve.alias['next-intl/config'] = path.resolve( + config.context, + resolveI18nPath(pluginConfig.requestConfig, 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/plugin/index.tsx b/packages/next-intl/src/plugin/index.tsx new file mode 100644 index 000000000..d3403203e --- /dev/null +++ b/packages/next-intl/src/plugin/index.tsx @@ -0,0 +1 @@ +export {default} from './createNextIntlPlugin.tsx'; diff --git a/packages/next-intl/src/plugin/types.tsx b/packages/next-intl/src/plugin/types.tsx new file mode 100644 index 000000000..915461238 --- /dev/null +++ b/packages/next-intl/src/plugin/types.tsx @@ -0,0 +1,6 @@ +export type PluginConfig = { + requestConfig?: string; + experimental?: { + createMessagesDeclaration?: string; + }; +}; diff --git a/packages/next-intl/src/plugin/utils.tsx b/packages/next-intl/src/plugin/utils.tsx new file mode 100644 index 000000000..c4906c141 --- /dev/null +++ b/packages/next-intl/src/plugin/utils.tsx @@ -0,0 +1,11 @@ +function formatMessage(message: string) { + return `\n[next-intl] ${message}\n`; +} + +export function throwError(message: string): never { + throw new Error(formatMessage(message)); +} + +export function warn(message: string) { + console.warn(formatMessage(message)); +} diff --git a/packages/next-intl/src/react-server/getTranslator.tsx b/packages/next-intl/src/react-server/getTranslator.tsx index 7e462d8ce..3b4d7f50c 100644 --- a/packages/next-intl/src/react-server/getTranslator.tsx +++ b/packages/next-intl/src/react-server/getTranslator.tsx @@ -1,14 +1,8 @@ -import {ReactNode, cache} from 'react'; +import {cache} from 'react'; import { - Formats, - MarkupTranslationValues, - MessageKeys, Messages, NamespaceKeys, NestedKeyOf, - NestedValueOf, - RichTranslationValues, - TranslationValues, createTranslator } from 'use-intl/core'; @@ -17,104 +11,7 @@ function getTranslatorImpl< >( config: Parameters[0], namespace?: NestedKey -): // Explicitly defining the return type is necessary as TypeScript would get it wrong -{ - // Default invocation - < - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: TranslationValues, - formats?: Formats - ): string; - - // `rich` - rich< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: RichTranslationValues, - formats?: Formats - ): ReactNode; - - // `markup` - markup< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: MarkupTranslationValues, - formats?: Formats - ): string; - - // `raw` - raw< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey - ): any; - - // `has` - has< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey - ): boolean; -} { +): ReturnType> { return createTranslator({ ...config, namespace diff --git a/packages/next-intl/src/server/react-server/getTranslations.tsx b/packages/next-intl/src/server/react-server/getTranslations.tsx index ce1129e97..e3d5d8c68 100644 --- a/packages/next-intl/src/server/react-server/getTranslations.tsx +++ b/packages/next-intl/src/server/react-server/getTranslations.tsx @@ -1,15 +1,9 @@ -import {ReactNode, cache} from 'react'; +import {cache} from 'react'; import { - Formats, Locale, - MarkupTranslationValues, - MessageKeys, Messages, NamespaceKeys, NestedKeyOf, - NestedValueOf, - RichTranslationValues, - TranslationValues, createTranslator } from 'use-intl/core'; import getConfig from './getConfig.tsx'; @@ -23,190 +17,14 @@ function getTranslations< NestedKey extends NamespaceKeys> = never >( namespace?: NestedKey -): // Explicitly defining the return type is necessary as TypeScript would get it wrong -Promise<{ - // Default invocation - < - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey, - values?: TranslationValues, - formats?: Formats - ): string; - - // `rich` - rich< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey, - values?: RichTranslationValues, - formats?: Formats - ): ReactNode; - - // `markup` - markup< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey, - values?: MarkupTranslationValues, - formats?: Formats - ): string; - - // `raw` - raw< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey - ): any; - - // `has` - has< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey - ): boolean; -}>; +): Promise>>; // CALL SIGNATURE 2: `getTranslations({locale, namespace})` function getTranslations< NestedKey extends NamespaceKeys> = never >(opts?: { locale: Locale; namespace?: NestedKey; -}): // Explicitly defining the return type is necessary as TypeScript would get it wrong -Promise<{ - // Default invocation - < - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: TranslationValues, - formats?: Formats - ): string; - - // `rich` - rich< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: RichTranslationValues, - formats?: Formats - ): ReactNode; - - // `markup` - markup< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: MarkupTranslationValues, - formats?: Formats - ): string; - - // `raw` - raw< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey - ): any; -}>; +}): Promise>>; // IMPLEMENTATION async function getTranslations< NestedKey extends NamespaceKeys> = never diff --git a/packages/use-intl/src/core/ICUArgs.tsx b/packages/use-intl/src/core/ICUArgs.tsx new file mode 100644 index 000000000..72e6ddc57 --- /dev/null +++ b/packages/use-intl/src/core/ICUArgs.tsx @@ -0,0 +1,181 @@ +// See https://github.com/schummar/schummar-translate/issues/28 + +export type Flatten = T extends object + ? { + [P in keyof T]: T[P]; + } + : T; + +type OtherString = string & {__type: 'other'}; + +type Whitespace = ' ' | '\t' | '\n' | '\r'; + +/** Remove leading and tailing whitespace */ +type Trim = T extends `${Whitespace}${infer Rest}` + ? Trim + : T extends `${infer Rest}${Whitespace}` + ? Trim + : T extends string + ? T + : never; + +/** Returns an array of top level blocks */ +type FindBlocks = Text extends `${string}{${infer Right}` // find first { + ? ReadBlock<'', Right, ''> extends [infer Block, infer Tail] + ? [Block, ...FindBlocks] // read block and find next block for tail + : [{}] + : []; // no {, return empty result + +/** Find blocks for each tuple entry */ +type TupleFindBlocks = T extends readonly [infer First, ...infer Rest] + ? [...FindBlocks, ...TupleFindBlocks] + : []; + +/** Read tail until the currently open block is closed. Return the block content and rest of tail */ +type ReadBlock< + Block extends string, + Tail extends string, + Depth extends string +> = Tail extends `${infer L1}}${infer R1}` // find first } + ? L1 extends `${infer L2}{${infer R2}` // if preceeded by {, this opens a nested block + ? ReadBlock<`${Block}${L2}{`, `${R2}}${R1}`, `${Depth}+`> // then continue search right of this { + : Depth extends `+${infer Rest}` // else if depth > 0 + ? ReadBlock<`${Block}${L1}}`, R1, Rest> // then finished nested block, continue search right of first } + : [`${Block}${L1}`, R1] // else return full block and search for next + : []; // no }, return emptry result + +/** Parse block, return variables with types and recursively find nested blocks within */ +type ParseBlock = + Block extends `${infer Name},${infer Format},${infer Rest}` + ? Trim extends 'select' + ? SelectOptions< + Trim, + Trim, + ICUArgument, + ICUNumberArgument, + ICUDateArgument + > + : { + [K in Trim]: VariableType< + Trim, + ICUArgument, + ICUNumberArgument, + ICUDateArgument + >; + } & TupleParseBlock< + TupleFindBlocks>, + ICUArgument, + ICUNumberArgument, + ICUDateArgument + > + : Block extends `${infer Name},${infer Format}` + ? { + [K in Trim]: VariableType< + Trim, + ICUArgument, + ICUNumberArgument, + ICUDateArgument + >; + } + : {[K in Trim]: ICUArgument}; + +/** Parse block for each tuple entry */ +type TupleParseBlock = + T extends readonly [infer First, ...infer Rest] + ? ParseBlock & + TupleParseBlock + : {}; + +type VariableType< + T extends string, + ICUArgument, + ICUNumberArgument, + ICUDateArgument +> = T extends 'number' | 'plural' | 'selectordinal' + ? ICUNumberArgument + : T extends 'date' | 'time' + ? ICUDateArgument + : ICUArgument; + +// Select ////////////////////////////////////////////////////////////////////// + +type SelectOptions< + Name extends string, + Rest, + ICUArgument, + ICUNumberArgument, + ICUDateArgument +> = KeepAndMerge< + ParseSelectBlock +>; + +type ParseSelectBlock< + Name extends string, + Rest, + ICUArgument, + ICUNumberArgument, + ICUDateArgument +> = Rest extends `${infer Left}{${infer Right}` + ? ReadBlock<'', Right, ''> extends [infer Block, infer Tail] + ? + | ({[K in Name]: HandleOther>} & TupleParseBlock< + FindBlocks, + ICUArgument, + ICUNumberArgument, + ICUDateArgument + >) + | ParseSelectBlock< + Name, + Tail, + ICUArgument, + ICUNumberArgument, + ICUDateArgument + > + : never + : never; + +type HandleOther = 'other' extends T ? Exclude | OtherString : T; + +type KeepAndMerge = T | MergeTypeUnion; + +type KeysFromUnion = T extends T ? keyof T : never; + +type SimpleTypeMerge = T extends {[k in K]?: any} + ? T[K] extends OtherString + ? string & {} + : T[K] + : never; + +type MergeTypeUnion = { + [k in KeysFromUnion]: SimpleTypeMerge; +}; + +// Escapes ///////////////////////////////////////////////////////////////////// + +type EscapeLike = `'${'{' | '}' | '<' | '>'}`; +type StripEscapes = T extends `${infer Left}''${infer Right}` + ? `${Left}${Right}` + : T extends `${infer Start}${EscapeLike}${string}'${infer End}` + ? `${Start}${StripEscapes}` + : T extends `${infer Start}${EscapeLike}${string}` + ? Start + : T; + +// Export ////////////////////////////////////////////////////////////////////// + +/** Calculates an object type with all variables and their types in the given ICU format string */ +type ICUArgs< + T extends string, + ICUArgument, + ICUNumberArgument, + ICUDateArgument +> = Flatten< + TupleParseBlock< + FindBlocks>, + ICUArgument, + ICUNumberArgument, + ICUDateArgument + > +>; + +export default ICUArgs; diff --git a/packages/use-intl/src/core/ICUTags.tsx b/packages/use-intl/src/core/ICUTags.tsx new file mode 100644 index 000000000..a4531331e --- /dev/null +++ b/packages/use-intl/src/core/ICUTags.tsx @@ -0,0 +1,8 @@ +type ICUTags< + MessageString extends string, + TagsFn +> = MessageString extends `${infer Prefix}<${infer TagName}>${infer Content}${infer Tail}` + ? Record & ICUTags<`${Prefix}${Content}${Tail}`, TagsFn> + : {}; + +export default ICUTags; diff --git a/packages/use-intl/src/core/MessageKeys.tsx b/packages/use-intl/src/core/MessageKeys.tsx new file mode 100644 index 000000000..40667bbab --- /dev/null +++ b/packages/use-intl/src/core/MessageKeys.tsx @@ -0,0 +1,36 @@ +export type NestedKeyOf = ObjectType extends object + ? { + [Property in keyof ObjectType]: + | `${Property & string}` + | `${Property & string}.${NestedKeyOf}`; + }[keyof ObjectType] + : never; + +export type NestedValueOf< + ObjectType, + Path extends string +> = Path extends `${infer Cur}.${infer Rest}` + ? Cur extends keyof ObjectType + ? NestedValueOf + : never + : Path extends keyof ObjectType + ? ObjectType[Path] + : never; + +export type NamespaceKeys = { + [PropertyPath in AllKeys]: NestedValueOf< + ObjectType, + PropertyPath + > extends string + ? never + : PropertyPath; +}[AllKeys]; + +export type MessageKeys = { + [PropertyPath in AllKeys]: NestedValueOf< + ObjectType, + PropertyPath + > extends string + ? PropertyPath + : never; +}[AllKeys]; diff --git a/packages/use-intl/src/core/TranslationValues.tsx b/packages/use-intl/src/core/TranslationValues.tsx index 3d4106f67..591bad7f3 100644 --- a/packages/use-intl/src/core/TranslationValues.tsx +++ b/packages/use-intl/src/core/TranslationValues.tsx @@ -1,27 +1,20 @@ import {ReactNode} from 'react'; -// From IntlMessageFormat#format -export type TranslationValue = - | string - | number - | boolean - | Date - | null - | undefined; +// These type names are shown to consumers in autocomplete +export type ICUArg = string | number | boolean | Date; +export type ICUNumber = number; +export type ICUDate = Date | number | string; -type TranslationValues = Record; +type TranslationValues = Record; + +export type RichTextFunction = (chunks: ReactNode) => ReactNode; +export type MarkupFunction = (chunks: string) => string; // We could consider renaming this to `ReactRichTranslationValues` and defining // it in the `react` namespace if the core becomes useful to other frameworks. // It would be a breaking change though, so let's wait for now. -export type RichTranslationValues = Record< - string, - TranslationValue | ((chunks: ReactNode) => ReactNode) ->; +export type RichTranslationValues = Record; -export type MarkupTranslationValues = Record< - string, - TranslationValue | ((chunks: string) => string) ->; +export type MarkupTranslationValues = Record; export default TranslationValues; diff --git a/packages/use-intl/src/core/createBaseTranslator.tsx b/packages/use-intl/src/core/createBaseTranslator.tsx index 2806023be..fce3eef24 100644 --- a/packages/use-intl/src/core/createBaseTranslator.tsx +++ b/packages/use-intl/src/core/createBaseTranslator.tsx @@ -5,6 +5,7 @@ import {Locale} from './AppConfig.tsx'; import Formats from './Formats.tsx'; import {InitializedIntlConfig} from './IntlConfig.tsx'; import IntlError, {IntlErrorCode} from './IntlError.tsx'; +import {MessageKeys, NestedKeyOf, NestedValueOf} from './MessageKeys.tsx'; import TranslationValues, { MarkupTranslationValues, RichTranslationValues @@ -19,9 +20,6 @@ import { memoFn } from './formatters.tsx'; import joinPath from './joinPath.tsx'; -import MessageKeys from './utils/MessageKeys.tsx'; -import NestedKeyOf from './utils/NestedKeyOf.tsx'; -import NestedValueOf from './utils/NestedValueOf.tsx'; // Placed here for improved tree shaking. Somehow when this is placed in // `formatters.tsx`, then it can't be shaken off from `next-intl`. diff --git a/packages/use-intl/src/core/createTranslator.test.tsx b/packages/use-intl/src/core/createTranslator.test.tsx index ff9ed2923..8b01fe927 100644 --- a/packages/use-intl/src/core/createTranslator.test.tsx +++ b/packages/use-intl/src/core/createTranslator.test.tsx @@ -1,6 +1,7 @@ import {isValidElement} from 'react'; import {renderToString} from 'react-dom/server'; import {describe, expect, it, vi} from 'vitest'; +import {Messages} from './AppConfig.tsx'; import IntlError, {IntlErrorCode} from './IntlError.tsx'; import createTranslator from './createTranslator.tsx'; @@ -10,7 +11,7 @@ const messages = { rich: 'Hello {name}!', markup: 'Hello {name}!' } -}; +} as const; it('can translate a message within a namespace', () => { const t = createTranslator({ @@ -39,6 +40,7 @@ it('handles formatting errors', () => { onError }); + // @ts-expect-error const result = t('price'); const error: IntlError = onError.mock.calls[0][0]; @@ -74,6 +76,471 @@ it('throws an error for non-alphanumeric value names', () => { expect(error.code).toBe('INVALID_MESSAGE'); }); +it('can handle nested blocks in selects', () => { + const t = createTranslator({ + locale: 'en', + messages: { + label: + '{foo, select, one {One: {one}} two {Two: {two}} other {Other: {other}}}' + } + }); + expect( + t('label', { + foo: 'one', + one: 'One', + two: 'Two', + other: 'Other' + }) + ).toBe('One: One'); +}); + +it('can handle nested blocks in plurals', () => { + const t = createTranslator({ + locale: 'en', + messages: { + label: '{count, plural, one {One: {one}} other {Other: {other}}}' + } + }); + expect(t('label', {count: 1, one: 'One', other: 'Other'})).toBe('One: One'); +}); + +describe('type safety', () => { + describe('keys, strictly-typed', () => { + it('allows valid namespaces', () => { + createTranslator({ + locale: 'en', + messages, + namespace: 'Home' + }); + }); + + it('allows valid keys', () => { + const t = createTranslator({ + locale: 'en', + messages, + namespace: 'Home' + }); + + t('title'); + t.has('title'); + t.markup('title'); + t.rich('title'); + }); + + it('allows an undefined namespace with a valid key', () => { + const t = createTranslator({ + locale: 'en', + messages + }); + t('Home.title'); + }); + + it('disallows an undefined namespace with an invalid key', () => { + const t = createTranslator({ + locale: 'en', + messages + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('unknown'); + // @ts-expect-error + t.has('unknown'); + // @ts-expect-error + t.markup('unknown'); + // @ts-expect-error + t.rich('unknown'); + }; + }); + + it('disallows invalid namespaces', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + createTranslator({ + locale: 'en', + messages, + // @ts-expect-error + namespace: 'unknown' + }); + }; + }); + + it('disallows invalid keys', () => { + const t = createTranslator({ + locale: 'en', + messages, + namespace: 'Home' + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('unknown'); + // @ts-expect-error + t.has('unknown'); + // @ts-expect-error + t.markup('unknown'); + // @ts-expect-error + t.rich('unknown'); + }; + }); + }); + + describe('keys, untyped', () => { + it('allows any namespace', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + createTranslator({ + locale: 'en', + messages: messages as Messages, + namespace: 'unknown' + }); + }; + }); + + it('allows any key', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + const t = createTranslator({ + locale: 'en', + messages: messages as Messages + }); + t('unknown'); + }; + }); + }); + + describe('params, strictly-typed', () => { + function translateMessage(msg: T) { + return createTranslator({ + locale: 'en', + messages: {msg} + }); + } + + it('validates plain params', () => { + const t = translateMessage('Hello {name}'); + + t('msg', {name: 'Jane'}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {unknown: 'Jane'}); + // @ts-expect-error + t('msg'); + }; + }); + + it('can handle undefined values', () => { + const t = translateMessage('Hello {name}'); + + const obj = { + name: 'Jane', + age: undefined + }; + t('msg', obj); + }); + + it('validates cardinal plurals', () => { + const t = translateMessage( + 'You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}.' + ); + + t('msg', {count: 0}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {unknown: 1.5}); + // @ts-expect-error + t('msg'); + }; + }); + + it('validates ordinal plurals', () => { + const t = translateMessage( + "It's your {year, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} birthday!" + ); + + t('msg', {year: 1}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {unknown: 1}); + // @ts-expect-error + t('msg'); + }; + }); + + it('validates selects', () => { + const t = translateMessage( + '{gender, select, female {She} male {He} other {They}} is online.' + ); + + t('msg', {gender: 'female'}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {unknown: 'female'}); + // @ts-expect-error + t('msg'); + }; + }); + + it('validates nested selects', () => { + const t = translateMessage( + '{foo, select, one {One: {one}} two {Two: {two}} other {Other: {other}}}' + ); + + t('msg', { + foo: 'one', + one: 'One', + two: 'Two', + other: 'Other' + }); + t('msg', {foo: 'one', one: 'One'}); // Only `one` is required + t('msg', {foo: 'one', one: 'One', two: 'Two'}); // …but `two` is also allowed + t('msg', {foo: 'two', two: 'Two'}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {foo: 'unknown' as string, other: 'Other'}); + // @ts-expect-error + t('msg', {unknown: 'one'}); + // @ts-expect-error + t('msg'); + }; + }); + + it('validates escaped', () => { + const t = translateMessage( + "Escape curly braces with single quotes (e.g. '{name')" + ); + + t('msg'); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {name: 'Jane'}); + }; + }); + + it('validates simple rich text', () => { + const t = translateMessage( + 'Please refer to the guidelines.' + ); + + t.rich('msg', {guidelines: (chunks) =>

{chunks}

}); + t.markup('msg', {guidelines: (chunks) => `

${chunks}

`}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t.rich('msg', {guidelines: 'test'}); + // @ts-expect-error + t.rich('msg', {unknown: (chunks) =>

{chunks}

}); + // @ts-expect-error + t.rich('msg', {unknown: 'test'}); + // @ts-expect-error + t.rich('msg'); + }; + }); + + it('validates nested rich text', () => { + const t = translateMessage( + 'This is very important' + ); + + t.rich('msg', { + important: (chunks) => {chunks}, + very: (chunks) => {chunks} + }); + t.markup('msg', { + important: (chunks) => `${chunks}`, + very: (chunks) => `${chunks}` + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t.rich('msg', {important: (chunks) =>

{chunks}

}); + // @ts-expect-error + t.rich('msg', {important: 'test', very: 'test'}); + // @ts-expect-error + t.rich('msg', {unknown: 'test'}); + // @ts-expect-error + t.rich('msg'); + }; + }); + + it('validates a complex message', () => { + const t = translateMessage( + 'Hello {name}, you have {count, plural, =0 {no followers} =1 {one follower} other {# followers ({count})}}.' + ); + + t.rich('msg', { + name: 'Jane', + count: 2, + user: (chunks) =>

{chunks}

+ }); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t.rich('msg', { + name: 'Jane', + user: (chunks) =>

{chunks}

+ }); + t.rich('msg', { + // @ts-expect-error + user: 'Jane', + // @ts-expect-error + name: (chunks) =>

{chunks}

, + count: 2 + }); + }; + }); + + describe('disallowed params', () => { + const t = createTranslator({ + locale: 'en', + messages: { + simpleParam: 'Hello {name}', + pluralMessage: + 'You have {count, plural, =0 {no followers} =1 {one follower} other {# followers}}.', + ordinalMessage: + "It's your {year, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} birthday!", + selectMessage: + '{gender, select, female {She} male {He} other {They}} is online.', + escapedParam: + "Escape curly braces with single quotes (e.g. '{name'})", + simpleRichText: + 'Please refer to the guidelines.', + nestedRichText: + 'This is very important' + } + }); + + it("doesn't allow params for `has`", () => { + t.has('simpleParam'); + t.has('pluralMessage'); + t.has('ordinalMessage'); + t.has('selectMessage'); + t.has('escapedParam'); + t.has('simpleRichText'); + t.has('nestedRichText'); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t.has('simpleParam', {name: 'Jane'}); + // @ts-expect-error + t.has('pluralMessage', {count: 0}); + // @ts-expect-error + t.has('ordinalMessage', {year: 1}); + // @ts-expect-error + t.has('selectMessage', {gender: 'female'}); + // @ts-expect-error + t.has('simpleRichText', {guidelines: (chunks) =>

{chunks}

}); + // @ts-expect-error + t.has('nestedRichText', { + important: (chunks: any) => {chunks} + }); + }; + }); + + it("doesn't allow params for `raw`", () => { + t.raw('simpleParam'); + t.raw('pluralMessage'); + t.raw('ordinalMessage'); + t.raw('selectMessage'); + t.raw('escapedParam'); + t.raw('simpleRichText'); + t.raw('nestedRichText'); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t.raw('simpleParam', {name: 'Jane'}); + // @ts-expect-error + t.raw('pluralMessage', {count: 0}); + // @ts-expect-error + t.raw('ordinalMessage', {year: 1}); + // @ts-expect-error + t.raw('selectMessage', {gender: 'female'}); + // @ts-expect-error + t.raw('simpleRichText', {guidelines: (chunks) =>

{chunks}

}); + // @ts-expect-error + t.raw('nestedRichText', { + important: (chunks: any) => {chunks} + }); + }; + }); + }); + }); + + describe('params, untyped', () => { + it('allows passing no values', () => { + const t = createTranslator({ + locale: 'en', + messages: messages as Messages + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + t('param'); + t.rich('param'); + t.markup('param'); + t.raw('param'); + t.has('param'); + }; + }); + + it('allows passing any values', () => { + const t = createTranslator({ + locale: 'en', + messages: messages as Messages + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + t('param', {unknown: 'Jane'}); + t.rich('param', {unknown: 'Jane', p: (chunks) =>

{chunks}

}); + t.markup('param', {unknown: 'Jane', p: (chunks) => `

${chunks}

`}); + }; + }); + + it('limits values where relevant', () => { + const t = createTranslator({ + locale: 'en', + messages: messages as Messages + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('param', {p: (chunks) =>

{chunks}

}); + // @ts-expect-error + t('param', {p: (chunks) => `

${chunks}

`}); + + // @ts-expect-error + t.markup('param', {unknown: 'Jane', p: (chunks) =>

{chunks}

}); + + // @ts-expect-error + t.raw('param', {unknown: 'Jane'}); + // @ts-expect-error + t.has('param', {unknown: 'Jane'}); + }; + }); + }); +}); + describe('dates in messages', () => { it.each([ ['G', '7/9/2024 AD'], // 🤔 Includes date diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index cf51904fb..d9735fe11 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -1,10 +1,21 @@ import {ReactNode} from 'react'; import {Messages} from './AppConfig.tsx'; import Formats from './Formats.tsx'; +import ICUArgs from './ICUArgs.tsx'; +import ICUTags from './ICUTags.tsx'; import IntlConfig from './IntlConfig.tsx'; -import TranslationValues, { - MarkupTranslationValues, - RichTranslationValues +import { + MessageKeys, + NamespaceKeys, + NestedKeyOf, + NestedValueOf +} from './MessageKeys.tsx'; +import { + ICUArg, + ICUDate, + ICUNumber, + MarkupFunction, + RichTextFunction } from './TranslationValues.tsx'; import createTranslatorImpl from './createTranslatorImpl.tsx'; import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; @@ -14,10 +25,31 @@ import { createCache, createIntlFormatters } from './formatters.tsx'; -import MessageKeys from './utils/MessageKeys.tsx'; -import NamespaceKeys from './utils/NamespaceKeys.tsx'; -import NestedKeyOf from './utils/NestedKeyOf.tsx'; -import NestedValueOf from './utils/NestedValueOf.tsx'; +import {OnlyOptional, Prettify} from './types.tsx'; + +type ICUArgsWithTags< + MessageString extends string, + TagsFn extends RichTextFunction | MarkupFunction = never +> = ICUArgs & + ([TagsFn] extends [never] ? {} : ICUTags); + +type TranslateArgs< + Value extends string, + Formats, + TagsFn extends RichTextFunction | MarkupFunction = never +> = + // If an unknown string is passed, allow any values + string extends Value + ? [values?: Record, formats?: Formats] + : ( + Value extends any + ? (key: ICUArgsWithTags) => void + : never + ) extends (key: infer Args) => void + ? OnlyOptional extends true + ? [values?: undefined, formats?: Formats] + : [values: Prettify, formats?: Formats] + : never; /** * Translates messages from the given namespace by using the ICU syntax. @@ -28,7 +60,11 @@ import NestedValueOf from './utils/NestedValueOf.tsx'; * (e.g. `namespace.Component`). */ export default function createTranslator< - NestedKey extends NamespaceKeys> = never + const TranslatorMessages extends Messages = Messages, + const Namespace extends NamespaceKeys< + TranslatorMessages, + NestedKeyOf + > = never >({ _cache = createCache(), _formatters = createIntlFormatters(_cache), @@ -37,9 +73,9 @@ export default function createTranslator< namespace, onError = defaultOnError, ...rest -}: Omit, 'messages'> & { - messages?: IntlConfig['messages']; - namespace?: NestedKey; +}: Omit, 'messages'> & { + messages?: TranslatorMessages; + namespace?: Namespace; /** @private */ _formatters?: Formatters; /** @private */ @@ -50,73 +86,90 @@ export default function createTranslator< < TargetKey extends MessageKeys< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` >, NestedKeyOf< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` > > > >( key: TargetKey, - values?: TranslationValues, - formats?: Formats + ...args: TranslateArgs< + NestedValueOf< + TranslatorMessages, + [Namespace] extends [never] ? TargetKey : `${Namespace}.${TargetKey}` + >, + Formats + > ): string; // `rich` rich< TargetKey extends MessageKeys< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` >, NestedKeyOf< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` > > > >( key: TargetKey, - values?: RichTranslationValues, - formats?: Formats + ...args: TranslateArgs< + NestedValueOf< + TranslatorMessages, + [Namespace] extends [never] ? TargetKey : `${Namespace}.${TargetKey}` + >, + Formats, + RichTextFunction + > ): ReactNode; // `markup` markup< TargetKey extends MessageKeys< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` >, NestedKeyOf< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` > > > >( key: TargetKey, - values?: MarkupTranslationValues, - formats?: Formats + ...args: TranslateArgs< + NestedValueOf< + TranslatorMessages, + [Namespace] extends [never] ? TargetKey : `${Namespace}.${TargetKey}` + >, + Formats, + MarkupFunction + > ): string; // `raw` raw< TargetKey extends MessageKeys< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` >, NestedKeyOf< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` > > > @@ -128,13 +181,13 @@ export default function createTranslator< has< TargetKey extends MessageKeys< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` >, NestedKeyOf< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` > > > @@ -145,9 +198,10 @@ export default function createTranslator< // We have to wrap the actual function so the type inference for the optional // namespace works correctly. See https://stackoverflow.com/a/71529575/343045 // The prefix ("!") is arbitrary. + // @ts-expect-error Use the explicit annotation instead return createTranslatorImpl< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` >( { ...rest, diff --git a/packages/use-intl/src/core/createTranslatorImpl.tsx b/packages/use-intl/src/core/createTranslatorImpl.tsx index 23c9a8d50..3f2897fb4 100644 --- a/packages/use-intl/src/core/createTranslatorImpl.tsx +++ b/packages/use-intl/src/core/createTranslatorImpl.tsx @@ -1,9 +1,9 @@ import AbstractIntlMessages from './AbstractIntlMessages.tsx'; import {InitializedIntlConfig} from './IntlConfig.tsx'; +import {NestedKeyOf} from './MessageKeys.tsx'; import createBaseTranslator from './createBaseTranslator.tsx'; import {Formatters, IntlCache} from './formatters.tsx'; import resolveNamespace from './resolveNamespace.tsx'; -import NestedKeyOf from './utils/NestedKeyOf.tsx'; export type CreateTranslatorImplProps = Omit< InitializedIntlConfig, diff --git a/packages/use-intl/src/core/index.tsx b/packages/use-intl/src/core/index.tsx index 55459b66e..ed9bd2067 100644 --- a/packages/use-intl/src/core/index.tsx +++ b/packages/use-intl/src/core/index.tsx @@ -12,10 +12,12 @@ export {default as IntlError, IntlErrorCode} from './IntlError.tsx'; export {default as createTranslator} from './createTranslator.tsx'; export {default as createFormatter} from './createFormatter.tsx'; export {default as initializeConfig} from './initializeConfig.tsx'; -export type {default as MessageKeys} from './utils/MessageKeys.tsx'; -export type {default as NamespaceKeys} from './utils/NamespaceKeys.tsx'; -export type {default as NestedKeyOf} from './utils/NestedKeyOf.tsx'; -export type {default as NestedValueOf} from './utils/NestedValueOf.tsx'; +export type { + MessageKeys, + NamespaceKeys, + NestedKeyOf, + NestedValueOf +} from './MessageKeys.tsx'; export {createIntlFormatters as _createIntlFormatters} from './formatters.tsx'; export {createCache as _createCache} from './formatters.tsx'; export type {default as AppConfig, Locale, Messages} from './AppConfig.tsx'; diff --git a/packages/use-intl/src/core/types.tsx b/packages/use-intl/src/core/types.tsx new file mode 100644 index 000000000..343dfa422 --- /dev/null +++ b/packages/use-intl/src/core/types.tsx @@ -0,0 +1,6 @@ +export type OnlyOptional = Partial extends T ? true : false; + +// https://www.totaltypescript.com/concepts/the-prettify-helper +export type Prettify = { + [K in keyof T]: T[K]; +} & {}; diff --git a/packages/use-intl/src/core/utils/MessageKeys.tsx b/packages/use-intl/src/core/utils/MessageKeys.tsx deleted file mode 100644 index af713473a..000000000 --- a/packages/use-intl/src/core/utils/MessageKeys.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import NestedValueOf from './NestedValueOf.tsx'; - -type MessageKeys = { - [Property in Keys]: NestedValueOf extends string - ? Property - : never; -}[Keys]; - -export default MessageKeys; diff --git a/packages/use-intl/src/core/utils/NamespaceKeys.tsx b/packages/use-intl/src/core/utils/NamespaceKeys.tsx deleted file mode 100644 index d0e69f840..000000000 --- a/packages/use-intl/src/core/utils/NamespaceKeys.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import NestedValueOf from './NestedValueOf.tsx'; - -type NamespaceKeys = { - [Property in Keys]: NestedValueOf extends string - ? never - : Property; -}[Keys]; - -export default NamespaceKeys; diff --git a/packages/use-intl/src/core/utils/NestedKeyOf.tsx b/packages/use-intl/src/core/utils/NestedKeyOf.tsx deleted file mode 100644 index 3735df10e..000000000 --- a/packages/use-intl/src/core/utils/NestedKeyOf.tsx +++ /dev/null @@ -1,9 +0,0 @@ -type NestedKeyOf = ObjectType extends object - ? { - [Key in keyof ObjectType]: - | `${Key & string}` - | `${Key & string}.${NestedKeyOf}`; - }[keyof ObjectType] - : never; - -export default NestedKeyOf; diff --git a/packages/use-intl/src/core/utils/NestedValueOf.tsx b/packages/use-intl/src/core/utils/NestedValueOf.tsx deleted file mode 100644 index 4d396f4a3..000000000 --- a/packages/use-intl/src/core/utils/NestedValueOf.tsx +++ /dev/null @@ -1,12 +0,0 @@ -type NestedValueOf< - ObjectType, - Property extends string -> = Property extends `${infer Key}.${infer Rest}` - ? Key extends keyof ObjectType - ? NestedValueOf - : never - : Property extends keyof ObjectType - ? ObjectType[Property] - : never; - -export default NestedValueOf; diff --git a/packages/use-intl/src/react/useTranslations.tsx b/packages/use-intl/src/react/useTranslations.tsx index 1428779f2..0a5b7266f 100644 --- a/packages/use-intl/src/react/useTranslations.tsx +++ b/packages/use-intl/src/react/useTranslations.tsx @@ -1,14 +1,6 @@ -import {ReactNode} from 'react'; import {Messages} from '../core/AppConfig.tsx'; -import Formats from '../core/Formats.tsx'; -import TranslationValues, { - MarkupTranslationValues, - RichTranslationValues -} from '../core/TranslationValues.tsx'; -import MessageKeys from '../core/utils/MessageKeys.tsx'; -import NamespaceKeys from '../core/utils/NamespaceKeys.tsx'; -import NestedKeyOf from '../core/utils/NestedKeyOf.tsx'; -import NestedValueOf from '../core/utils/NestedValueOf.tsx'; +import {NamespaceKeys, NestedKeyOf} from '../core/MessageKeys.tsx'; +import type createTranslator from '../core/createTranslator.tsx'; import useIntlContext from './useIntlContext.tsx'; import useTranslationsImpl from './useTranslationsImpl.tsx'; @@ -24,110 +16,14 @@ export default function useTranslations< NestedKey extends NamespaceKeys> = never >( namespace?: NestedKey -): // Explicitly defining the return type is necessary as TypeScript would get it wrong -{ - // Default invocation - < - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: TranslationValues, - formats?: Formats - ): string; - - // `rich` - rich< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: RichTranslationValues, - formats?: Formats - ): ReactNode; - - // `markup` - markup< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: MarkupTranslationValues, - formats?: Formats - ): string; - - // `raw` - raw< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey - ): any; - - // `has` - has< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey - ): boolean; -} { +): ReturnType> { const context = useIntlContext(); const messages = context.messages as Messages; // We have to wrap the actual hook so the type inference for the optional // namespace works correctly. See https://stackoverflow.com/a/71529575/343045 // The prefix ("!") is arbitrary. + // @ts-expect-error Use the explicit annotation instead return useTranslationsImpl< {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` diff --git a/packages/use-intl/src/react/useTranslationsImpl.tsx b/packages/use-intl/src/react/useTranslationsImpl.tsx index ada612785..742dad2a7 100644 --- a/packages/use-intl/src/react/useTranslationsImpl.tsx +++ b/packages/use-intl/src/react/useTranslationsImpl.tsx @@ -1,8 +1,8 @@ import {useMemo} from 'react'; import AbstractIntlMessages from '../core/AbstractIntlMessages.tsx'; +import {NestedKeyOf} from '../core/MessageKeys.tsx'; import createBaseTranslator from '../core/createBaseTranslator.tsx'; import resolveNamespace from '../core/resolveNamespace.tsx'; -import NestedKeyOf from '../core/utils/NestedKeyOf.tsx'; import {IntlError, IntlErrorCode} from '../core.tsx'; import useIntlContext from './useIntlContext.tsx'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a8edd04e..611533286 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,13 +40,13 @@ importers: version: 2.1.5(react@18.3.1) '@vercel/analytics': specifier: 1.3.1 - version: 1.3.1(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 1.3.1(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@vercel/og': specifier: ^0.6.3 version: 0.6.3 '@vercel/speed-insights': specifier: ^1.0.12 - version: 1.0.13(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 1.0.13(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -58,10 +58,10 @@ importers: version: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nextra: specifier: ^3.1.0 - version: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) + version: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) nextra-theme-docs: specifier: ^3.1.0 - version: 3.1.0(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.1.0(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -92,13 +92,13 @@ importers: version: 9.13.0(jiti@2.3.3) eslint-config-molindo: specifier: ^8.0.0 - version: 8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) + version: 8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) globals: specifier: ^15.11.0 version: 15.11.0 next-sitemap: specifier: ^4.2.3 - version: 4.2.3(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 4.2.3(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) prettier: specifier: ^3.3.3 version: 3.3.3 @@ -263,7 +263,7 @@ importers: version: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-auth: specifier: ^4.24.7 - version: 4.24.8(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.24.8(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-intl: specifier: ^3.0.0 version: link:../../packages/next-intl @@ -379,7 +379,10 @@ importers: version: 9.13.0(jiti@2.3.3) eslint-config-molindo: specifier: ^8.0.0 - version: 8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) + version: 8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) + globals: + specifier: ^15.11.0 + version: 15.11.0 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@20.17.0) @@ -611,7 +614,7 @@ importers: dependencies: next: specifier: ^12.0.0 - version: 12.3.4(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + version: 12.3.4(@babel/core@7.25.9)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) react: specifier: ^17.0.0 version: 17.0.2 @@ -757,6 +760,9 @@ importers: '@formatjs/intl-localematcher': specifier: ^0.5.4 version: 0.5.5 + chokidar: + specifier: ^4.0.1 + version: 4.0.1 negotiator: specifier: ^1.0.0 version: 1.0.0 @@ -2737,7 +2743,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.4.11': resolution: {integrity: sha512-L9Ci9RBh0aPFEDF1AjDYPk54OgeUJIKzxF3lRgITm+lQpI3IEKjAc9LaYeQeO1mlZMUQmPkHArF8iyz1eOeVoQ==} @@ -5796,10 +5802,6 @@ packages: resolution: {integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==} engines: {node: '>=0.10.0'} - braces@3.0.2: - resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} - engines: {node: '>=8'} - braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -6054,10 +6056,6 @@ packages: chokidar@2.1.8: resolution: {integrity: sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==} - chokidar@3.5.3: - resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} - engines: {node: '>= 8.10.0'} - chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -7864,10 +7862,6 @@ packages: resolution: {integrity: sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==} engines: {node: '>=0.10.0'} - fill-range@7.0.1: - resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} - engines: {node: '>=8'} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -19585,7 +19579,7 @@ snapshots: '@vanilla-extract/private@1.0.3': {} - '@vercel/analytics@1.3.1(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/analytics@1.3.1(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': dependencies: server-only: 0.0.1 optionalDependencies: @@ -19598,7 +19592,7 @@ snapshots: satori: 0.10.9 yoga-wasm-web: 0.3.3 - '@vercel/speed-insights@1.0.13(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/speed-insights@1.0.13(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': optionalDependencies: next: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -20615,10 +20609,6 @@ snapshots: transitivePeerDependencies: - supports-color - braces@3.0.2: - dependencies: - fill-range: 7.0.1 - braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -20959,7 +20949,7 @@ snapshots: chokidar-cli@3.0.0: dependencies: - chokidar: 3.5.3 + chokidar: 3.6.0 lodash.debounce: 4.0.8 lodash.throttle: 4.1.1 yargs: 13.3.2 @@ -20982,18 +20972,6 @@ snapshots: transitivePeerDependencies: - supports-color - chokidar@3.5.3: - dependencies: - anymatch: 3.1.3 - braces: 3.0.2 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -23537,10 +23515,6 @@ snapshots: repeat-string: 1.6.1 to-regex-range: 2.1.1 - fill-range@7.0.1: - dependencies: - to-regex-range: 5.0.1 - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -27460,7 +27434,7 @@ snapshots: dependencies: type-fest: 2.19.0 - next-auth@4.24.8(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-auth@4.24.8(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.24.7 '@panva/hkdf': 1.2.0 @@ -27475,7 +27449,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) uuid: 8.3.2 - next-sitemap@4.2.3(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): + next-sitemap@4.2.3(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.6 @@ -27488,7 +27462,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - next@12.3.4(react-dom@17.0.2(react@17.0.2))(react@17.0.2): + next@12.3.4(@babel/core@7.25.9)(react-dom@17.0.2(react@17.0.2))(react@17.0.2): dependencies: '@next/env': 12.3.4 '@swc/helpers': 0.4.11 @@ -27496,7 +27470,7 @@ snapshots: postcss: 8.4.14 react: 17.0.2 react-dom: 17.0.2(react@17.0.2) - styled-jsx: 5.0.7(react@17.0.2) + styled-jsx: 5.0.7(@babel/core@7.25.9)(react@17.0.2) use-sync-external-store: 1.2.0(react@17.0.2) optionalDependencies: '@next/swc-android-arm-eabi': 12.3.4 @@ -27542,7 +27516,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - nextra-theme-docs@3.1.0(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + nextra-theme-docs@3.1.0(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@headlessui/react': 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) clsx: 2.1.1 @@ -27550,13 +27524,13 @@ snapshots: flexsearch: 0.7.43 next: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - nextra: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) + nextra: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) scroll-into-view-if-needed: 3.1.0 zod: 3.23.8 - nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3): + nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3): dependencies: '@formatjs/intl-localematcher': 0.5.5 '@headlessui/react': 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -30625,9 +30599,11 @@ snapshots: dependencies: inline-style-parser: 0.2.4 - styled-jsx@5.0.7(react@17.0.2): + styled-jsx@5.0.7(@babel/core@7.25.9)(react@17.0.2): dependencies: react: 17.0.2 + optionalDependencies: + '@babel/core': 7.25.9 styled-jsx@5.1.1(@babel/core@7.25.9)(react@18.3.1): dependencies: From 0bb75c5f74b112e43e60bb7f43eda7b00f8d54d1 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 7 Nov 2024 15:38:49 +0100 Subject: [PATCH 25/90] decrease size by making plain message error handling dev-only --- packages/use-intl/.size-limit.ts | 4 +-- .../src/core/createBaseTranslator.tsx | 28 ++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts index 564bc6eaf..c6dae5c70 100644 --- a/packages/use-intl/.size-limit.ts +++ b/packages/use-intl/.size-limit.ts @@ -5,14 +5,14 @@ const config: SizeLimitConfig = [ name: "import * from 'use-intl' (production)", import: '*', path: 'dist/esm/production/index.js', - limit: '12.985 kB' + limit: '12.945 kB' }, { name: "import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from 'use-intl' (production)", path: 'dist/esm/production/index.js', import: '{IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter}', - limit: '1.975 kB' + limit: '1.98 kB' } ]; diff --git a/packages/use-intl/src/core/createBaseTranslator.tsx b/packages/use-intl/src/core/createBaseTranslator.tsx index fce3eef24..428cf419f 100644 --- a/packages/use-intl/src/core/createBaseTranslator.tsx +++ b/packages/use-intl/src/core/createBaseTranslator.tsx @@ -148,20 +148,22 @@ export type CreateBaseTranslatorProps = InitializedIntlConfig & { }; function getPlainMessage(candidate: string, values?: unknown) { - if (values) return undefined; - - const unescapedMessage = candidate.replace(/'([{}])/gi, '$1'); - - // Placeholders can be in the message if there are default values, - // or if the user has forgotten to provide values. In the latter - // case we need to compile the message to receive an error. - const hasPlaceholders = /<|{/.test(unescapedMessage); - - if (!hasPlaceholders) { - return unescapedMessage; + if (process.env.NODE_ENV !== 'production') { + // Keep fast path in development + if (values) return undefined; + + // Despite potentially no values being available, there can still be + // placeholders in the message if the user has forgotten to provide + // values. In this case we compile the message to receive an error. + const unescapedMessage = candidate.replace(/'([{}])/gi, '$1'); + const hasPlaceholders = /<|{/.test(unescapedMessage); + + if (!hasPlaceholders) { + return unescapedMessage; + } + } else { + return values ? undefined : candidate; } - - return undefined; } export default function createBaseTranslator< From dd28ac17a2092638b5a2236e313792ae9625c475 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 7 Nov 2024 16:47:21 +0100 Subject: [PATCH 26/90] fix: Allow to merge inline formats with global formats in `t` (#1523) See https://github.com/amannn/next-intl/pull/1523/files#r1832900948 --- packages/next-intl/.size-limit.ts | 4 +- .../convertFormatsToIntlMessageFormat.tsx | 78 +++++++++---------- .../src/core/createBaseTranslator.tsx | 5 +- .../src/core/createTranslator.test.tsx | 34 ++++++++ 4 files changed, 75 insertions(+), 46 deletions(-) diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index d48a2124b..033e7acda 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -15,7 +15,7 @@ const config: SizeLimitConfig = [ { name: "import * from 'next-intl' (react-server, production)", path: 'dist/esm/production/index.react-server.js', - limit: '14.075 KB' + limit: '14.085 KB' }, { name: "import {createNavigation} from 'next-intl/navigation' (react-client, production)", @@ -37,7 +37,7 @@ const config: SizeLimitConfig = [ { name: "import * from 'next-intl/server' (react-server, production)", path: 'dist/esm/production/server.react-server.js', - limit: '13.325 KB' + limit: '13.365 KB' }, { name: "import * from 'next-intl/middleware' (production)", diff --git a/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx b/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx index 27cc8e96e..b42df9339 100644 --- a/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx +++ b/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx @@ -2,30 +2,9 @@ import { type Formats as IntlFormats, IntlMessageFormat } from 'intl-messageformat'; -import DateTimeFormatOptions from './DateTimeFormatOptions.tsx'; import Formats from './Formats.tsx'; import TimeZone from './TimeZone.tsx'; -function setTimeZoneInFormats( - formats: Record | undefined, - timeZone: TimeZone -) { - if (!formats) return formats; - - // The only way to set a time zone with `intl-messageformat` is to merge it into the formats - // https://github.com/formatjs/formatjs/blob/8256c5271505cf2606e48e3c97ecdd16ede4f1b5/packages/intl/src/message.ts#L15 - return Object.keys(formats).reduce( - (acc: Record, key) => { - acc[key] = { - timeZone, - ...formats[key] - }; - return acc; - }, - {} - ); -} - /** * `intl-messageformat` uses separate keys for `date` and `time`, but there's * only one native API: `Intl.DateTimeFormat`. Additionally you might want to @@ -34,32 +13,51 @@ function setTimeZoneInFormats( * to convert the format before `intl-messageformat` can be used. */ export default function convertFormatsToIntlMessageFormat( - formats: Formats, + globalFormats?: Formats, + inlineFormats?: Formats, timeZone?: TimeZone ): Partial { - const formatsWithTimeZone = timeZone - ? {...formats, dateTime: setTimeZoneInFormats(formats.dateTime, timeZone)} - : formats; - - const mfDateDefaults = IntlMessageFormat.formats.date as Formats['dateTime']; - const defaultDateFormats = timeZone - ? setTimeZoneInFormats(mfDateDefaults, timeZone) - : mfDateDefaults; + const mfDateDefaults = IntlMessageFormat.formats.date as NonNullable< + Formats['dateTime'] + >; + const mfTimeDefaults = IntlMessageFormat.formats.time as NonNullable< + Formats['dateTime'] + >; - const mfTimeDefaults = IntlMessageFormat.formats.time as Formats['dateTime']; - const defaultTimeFormats = timeZone - ? setTimeZoneInFormats(mfTimeDefaults, timeZone) - : mfTimeDefaults; + const dateTimeFormats = { + ...globalFormats?.dateTime, + ...inlineFormats?.dateTime + }; - return { - ...formatsWithTimeZone, + const allFormats = { date: { - ...defaultDateFormats, - ...formatsWithTimeZone.dateTime + ...mfDateDefaults, + ...dateTimeFormats }, time: { - ...defaultTimeFormats, - ...formatsWithTimeZone.dateTime + ...mfTimeDefaults, + ...dateTimeFormats + }, + number: { + ...globalFormats?.number, + ...inlineFormats?.number } + // (list is not supported in ICU messages) }; + + if (timeZone) { + // The only way to set a time zone with `intl-messageformat` is to merge it into the formats + // https://github.com/formatjs/formatjs/blob/8256c5271505cf2606e48e3c97ecdd16ede4f1b5/packages/intl/src/message.ts#L15 + ['date', 'time'].forEach((property) => { + const formats = allFormats[property as keyof typeof allFormats]; + for (const [key, value] of Object.entries(formats)) { + formats[key] = { + timeZone, + ...value + }; + } + }); + } + + return allFormats; } diff --git a/packages/use-intl/src/core/createBaseTranslator.tsx b/packages/use-intl/src/core/createBaseTranslator.tsx index 428cf419f..f3e16937b 100644 --- a/packages/use-intl/src/core/createBaseTranslator.tsx +++ b/packages/use-intl/src/core/createBaseTranslator.tsx @@ -277,10 +277,7 @@ function createBaseTranslatorImpl< messageFormat = formatters.getMessageFormat( message, locale, - convertFormatsToIntlMessageFormat( - {...globalFormats, ...formats}, - timeZone - ), + convertFormatsToIntlMessageFormat(globalFormats, formats, timeZone), { formatters: { ...formatters, diff --git a/packages/use-intl/src/core/createTranslator.test.tsx b/packages/use-intl/src/core/createTranslator.test.tsx index 8b01fe927..abd81361f 100644 --- a/packages/use-intl/src/core/createTranslator.test.tsx +++ b/packages/use-intl/src/core/createTranslator.test.tsx @@ -541,6 +541,29 @@ describe('type safety', () => { }); }); +describe('numbers in messages', () => { + it('can pass an inline format', () => { + const t = createTranslator({ + locale: 'en', + messages: {label: '{count, number, precise}'} + }); + expect( + t('label', {count: 1.5}, {number: {precise: {minimumFractionDigits: 5}}}) + ).toBe('1.50000'); + }); + + it('can merge an inline format with global formats', () => { + const t = createTranslator({ + locale: 'en', + messages: {label: '{count, number, precise} {count, number, integer}'}, + formats: {number: {precise: {minimumFractionDigits: 5}}} + }); + expect( + t('label', {count: 1.5}, {number: {integer: {minimumFractionDigits: 0}}}) + ).toBe('1.50000 2'); + }); +}); + describe('dates in messages', () => { it.each([ ['G', '7/9/2024 AD'], // 🤔 Includes date @@ -638,6 +661,17 @@ describe('dates in messages', () => { }); expect(t('date', {date})).toBe(expected); }); + + it('can set a time zone in a built-in default format', () => { + const t = createTranslator({ + locale: 'en', + messages: {date: `{date, time, full}`}, + timeZone: 'Asia/Kolkata' + }); + expect(t('date', {date: new Date('2023-12-31T18:30:00.000Z')})).toBe( + '12:00:00 AM GMT+5:30' + ); + }); }); describe('t.rich', () => { From c154409ce98e82f203e83adcbdf5ebb9e5cdb8e9 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 8 Nov 2024 11:39:14 +0100 Subject: [PATCH 27/90] move/rename file --- .../TypePortabilityTest.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename examples/example-app-router-playground/src/{type-portability-test.ts => components/TypePortabilityTest.ts} (100%) diff --git a/examples/example-app-router-playground/src/type-portability-test.ts b/examples/example-app-router-playground/src/components/TypePortabilityTest.ts similarity index 100% rename from examples/example-app-router-playground/src/type-portability-test.ts rename to examples/example-app-router-playground/src/components/TypePortabilityTest.ts From 887034d47d84782f10a57082454f5595425823f9 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 8 Nov 2024 15:20:12 +0100 Subject: [PATCH 28/90] simplify signature --- packages/use-intl/src/core/createBaseTranslator.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/use-intl/src/core/createBaseTranslator.tsx b/packages/use-intl/src/core/createBaseTranslator.tsx index f3e16937b..c98ea299e 100644 --- a/packages/use-intl/src/core/createBaseTranslator.tsx +++ b/packages/use-intl/src/core/createBaseTranslator.tsx @@ -425,7 +425,7 @@ function createBaseTranslatorImpl< } }; - translateFn.has = (key: Parameters[0]): boolean => { + translateFn.has = (key: string): boolean => { if (hasMessagesError) { return false; } From ab22a927fdd7da4c080e1ba61eb086657c4c63de Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 8 Nov 2024 15:43:26 +0100 Subject: [PATCH 29/90] temporarily disable exports that cause OOM --- .../src/components/TypePortabilityTest.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/examples/example-app-router-playground/src/components/TypePortabilityTest.ts b/examples/example-app-router-playground/src/components/TypePortabilityTest.ts index 18facf87d..09ae5d747 100644 --- a/examples/example-app-router-playground/src/components/TypePortabilityTest.ts +++ b/examples/example-app-router-playground/src/components/TypePortabilityTest.ts @@ -5,14 +5,14 @@ import { createFormatter, - createTranslator, initializeConfig, useFormatter, useLocale, useMessages, useNow, - useTimeZone, - useTranslations + useTimeZone + // createTranslator, + // useTranslations } from 'next-intl'; import createNextIntlPlugin from 'next-intl/plugin'; import { @@ -20,8 +20,8 @@ import { getLocale, getMessages, getNow, - getTimeZone, - getTranslations + getTimeZone + // getTranslations } from 'next-intl/server'; export function useExports() { @@ -30,15 +30,15 @@ export function useExports() { const locale = useLocale(); const timezone = useTimeZone(); const formatter = useFormatter(); - const translations = useTranslations(); + // const translations = useTranslations(); return { messages, now, locale, timezone, - formatter, - translations + formatter + // translations }; } @@ -48,21 +48,21 @@ export async function asyncApis() { const locale = await getLocale(); const timezone = await getTimeZone(); const formatter = await getFormatter(); - const translations = await getTranslations(); + // const translations = await getTranslations(); return { messages, now, locale, timezone, - formatter, - translations + formatter + // translations }; } export const withNextIntl = createNextIntlPlugin(); export const config = initializeConfig({locale: 'en'}); -export const translator = createTranslator({locale: 'en'}); +// export const translator = createTranslator({locale: 'en'}); export const formatter = createFormatter({ locale: 'en', now: new Date(2022, 10, 6, 20, 20, 0, 0) From b9c515e2d616d518fb94947b84469d70c5b313f9 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 8 Nov 2024 16:22:09 +0100 Subject: [PATCH 30/90] simplify types for `createTranslator` --- .../use-intl/src/core/createTranslator.tsx | 123 ++++++------------ 1 file changed, 39 insertions(+), 84 deletions(-) diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index d9735fe11..165eeaa79 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -35,7 +35,6 @@ type ICUArgsWithTags< type TranslateArgs< Value extends string, - Formats, TagsFn extends RichTextFunction | MarkupFunction = never > = // If an unknown string is passed, allow any values @@ -51,6 +50,37 @@ type TranslateArgs< : [values: Prettify, formats?: Formats] : never; +type NamespacedMessageKeys< + TranslatorMessages extends Messages, + Namespace extends NamespaceKeys< + TranslatorMessages, + NestedKeyOf + > = never +> = MessageKeys< + NestedValueOf< + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` + >, + NestedKeyOf< + NestedValueOf< + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` + > + > +>; + +type NamespacedValue< + TranslatorMessages extends Messages, + Namespace extends NamespaceKeys< + TranslatorMessages, + NestedKeyOf + >, + TargetKey extends NamespacedMessageKeys +> = NestedValueOf< + TranslatorMessages, + [Namespace] extends [never] ? TargetKey : `${Namespace}.${TargetKey}` +>; + /** * Translates messages from the given namespace by using the ICU syntax. * See https://formatjs.io/docs/core-concepts/icu-syntax. @@ -83,115 +113,40 @@ export default function createTranslator< }): // Explicitly defining the return type is necessary as TypeScript would get it wrong { // Default invocation - < - TargetKey extends MessageKeys< - NestedValueOf< - {'!': TranslatorMessages}, - [Namespace] extends [never] ? '!' : `!.${Namespace}` - >, - NestedKeyOf< - NestedValueOf< - {'!': TranslatorMessages}, - [Namespace] extends [never] ? '!' : `!.${Namespace}` - > - > - > - >( + >( key: TargetKey, ...args: TranslateArgs< - NestedValueOf< - TranslatorMessages, - [Namespace] extends [never] ? TargetKey : `${Namespace}.${TargetKey}` - >, - Formats + NamespacedValue > ): string; // `rich` - rich< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': TranslatorMessages}, - [Namespace] extends [never] ? '!' : `!.${Namespace}` - >, - NestedKeyOf< - NestedValueOf< - {'!': TranslatorMessages}, - [Namespace] extends [never] ? '!' : `!.${Namespace}` - > - > - > - >( + rich>( key: TargetKey, ...args: TranslateArgs< - NestedValueOf< - TranslatorMessages, - [Namespace] extends [never] ? TargetKey : `${Namespace}.${TargetKey}` - >, - Formats, + NamespacedValue, RichTextFunction > ): ReactNode; // `markup` markup< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': TranslatorMessages}, - [Namespace] extends [never] ? '!' : `!.${Namespace}` - >, - NestedKeyOf< - NestedValueOf< - {'!': TranslatorMessages}, - [Namespace] extends [never] ? '!' : `!.${Namespace}` - > - > - > + TargetKey extends NamespacedMessageKeys >( key: TargetKey, ...args: TranslateArgs< - NestedValueOf< - TranslatorMessages, - [Namespace] extends [never] ? TargetKey : `${Namespace}.${TargetKey}` - >, - Formats, + NamespacedValue, MarkupFunction > ): string; // `raw` - raw< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': TranslatorMessages}, - [Namespace] extends [never] ? '!' : `!.${Namespace}` - >, - NestedKeyOf< - NestedValueOf< - {'!': TranslatorMessages}, - [Namespace] extends [never] ? '!' : `!.${Namespace}` - > - > - > - >( + raw>( key: TargetKey ): any; // `has` - has< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': TranslatorMessages}, - [Namespace] extends [never] ? '!' : `!.${Namespace}` - >, - NestedKeyOf< - NestedValueOf< - {'!': TranslatorMessages}, - [Namespace] extends [never] ? '!' : `!.${Namespace}` - > - > - > - >( + has>( key: TargetKey ): boolean; } { From 8ac72060582b9fcd201da63aab4b3337569bfdf6 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 8 Nov 2024 17:34:25 +0100 Subject: [PATCH 31/90] fix: Fix recursion bug in typed ICU args (#1527) --- .../src/components/TypePortabilityTest.ts | 21 ++++++++--------- packages/use-intl/src/core/ICUArgs.tsx | 23 +++++++++++-------- .../use-intl/src/core/TranslationValues.tsx | 11 +++++---- .../use-intl/src/core/createTranslator.tsx | 16 +++++++------ packages/use-intl/src/core/index.tsx | 9 +++++++- packages/use-intl/src/core/types.tsx | 2 -- 6 files changed, 48 insertions(+), 34 deletions(-) diff --git a/examples/example-app-router-playground/src/components/TypePortabilityTest.ts b/examples/example-app-router-playground/src/components/TypePortabilityTest.ts index 09ae5d747..af644fd62 100644 --- a/examples/example-app-router-playground/src/components/TypePortabilityTest.ts +++ b/examples/example-app-router-playground/src/components/TypePortabilityTest.ts @@ -10,9 +10,8 @@ import { useLocale, useMessages, useNow, - useTimeZone - // createTranslator, - // useTranslations + useTimeZone, + useTranslations } from 'next-intl'; import createNextIntlPlugin from 'next-intl/plugin'; import { @@ -20,8 +19,8 @@ import { getLocale, getMessages, getNow, - getTimeZone - // getTranslations + getTimeZone, + getTranslations } from 'next-intl/server'; export function useExports() { @@ -30,15 +29,15 @@ export function useExports() { const locale = useLocale(); const timezone = useTimeZone(); const formatter = useFormatter(); - // const translations = useTranslations(); + const translations = useTranslations(); return { messages, now, locale, timezone, - formatter - // translations + formatter, + translations }; } @@ -48,15 +47,15 @@ export async function asyncApis() { const locale = await getLocale(); const timezone = await getTimeZone(); const formatter = await getFormatter(); - // const translations = await getTranslations(); + const translations = await getTranslations(); return { messages, now, locale, timezone, - formatter - // translations + formatter, + translations }; } diff --git a/packages/use-intl/src/core/ICUArgs.tsx b/packages/use-intl/src/core/ICUArgs.tsx index 72e6ddc57..7d55c7b42 100644 --- a/packages/use-intl/src/core/ICUArgs.tsx +++ b/packages/use-intl/src/core/ICUArgs.tsx @@ -165,17 +165,22 @@ type StripEscapes = T extends `${infer Left}''${infer Right}` /** Calculates an object type with all variables and their types in the given ICU format string */ type ICUArgs< - T extends string, + Message extends string, ICUArgument, ICUNumberArgument, ICUDateArgument -> = Flatten< - TupleParseBlock< - FindBlocks>, - ICUArgument, - ICUNumberArgument, - ICUDateArgument - > ->; +> = + // This is important when `t` is returned from a function and there's no + // known `Message` yet. Otherwise, we'd run into an infinite loop. + string extends Message + ? {} + : Flatten< + TupleParseBlock< + FindBlocks>, + ICUArgument, + ICUNumberArgument, + ICUDateArgument + > + >; export default ICUArgs; diff --git a/packages/use-intl/src/core/TranslationValues.tsx b/packages/use-intl/src/core/TranslationValues.tsx index 591bad7f3..340124343 100644 --- a/packages/use-intl/src/core/TranslationValues.tsx +++ b/packages/use-intl/src/core/TranslationValues.tsx @@ -7,14 +7,17 @@ export type ICUDate = Date | number | string; type TranslationValues = Record; -export type RichTextFunction = (chunks: ReactNode) => ReactNode; -export type MarkupFunction = (chunks: string) => string; +export type RichTagsFunction = (chunks: ReactNode) => ReactNode; +export type MarkupTagsFunction = (chunks: string) => string; // We could consider renaming this to `ReactRichTranslationValues` and defining // it in the `react` namespace if the core becomes useful to other frameworks. // It would be a breaking change though, so let's wait for now. -export type RichTranslationValues = Record; +export type RichTranslationValues = Record; -export type MarkupTranslationValues = Record; +export type MarkupTranslationValues = Record< + string, + ICUArg | MarkupTagsFunction +>; export default TranslationValues; diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index 165eeaa79..9a4b00722 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -14,8 +14,8 @@ import { ICUArg, ICUDate, ICUNumber, - MarkupFunction, - RichTextFunction + MarkupTagsFunction, + RichTagsFunction } from './TranslationValues.tsx'; import createTranslatorImpl from './createTranslatorImpl.tsx'; import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; @@ -25,17 +25,19 @@ import { createCache, createIntlFormatters } from './formatters.tsx'; -import {OnlyOptional, Prettify} from './types.tsx'; +import {Prettify} from './types.tsx'; type ICUArgsWithTags< MessageString extends string, - TagsFn extends RichTextFunction | MarkupFunction = never + TagsFn extends RichTagsFunction | MarkupTagsFunction = never > = ICUArgs & ([TagsFn] extends [never] ? {} : ICUTags); +type OnlyOptional = Partial extends T ? true : false; + type TranslateArgs< Value extends string, - TagsFn extends RichTextFunction | MarkupFunction = never + TagsFn extends RichTagsFunction | MarkupTagsFunction = never > = // If an unknown string is passed, allow any values string extends Value @@ -125,7 +127,7 @@ export default function createTranslator< key: TargetKey, ...args: TranslateArgs< NamespacedValue, - RichTextFunction + RichTagsFunction > ): ReactNode; @@ -136,7 +138,7 @@ export default function createTranslator< key: TargetKey, ...args: TranslateArgs< NamespacedValue, - MarkupFunction + MarkupTagsFunction > ): string; diff --git a/packages/use-intl/src/core/index.tsx b/packages/use-intl/src/core/index.tsx index 8296f1199..f9d4fc902 100644 --- a/packages/use-intl/src/core/index.tsx +++ b/packages/use-intl/src/core/index.tsx @@ -2,7 +2,12 @@ export type {default as AbstractIntlMessages} from './AbstractIntlMessages.tsx'; export type { default as TranslationValues, RichTranslationValues, - MarkupTranslationValues + MarkupTranslationValues, + ICUArg, + ICUNumber, + ICUDate, + RichTagsFunction, + MarkupTagsFunction } from './TranslationValues.tsx'; export type {default as Formats} from './Formats.tsx'; export type {default as IntlConfig} from './IntlConfig.tsx'; @@ -24,3 +29,5 @@ export type {default as AppConfig, Locale, Messages} from './AppConfig.tsx'; export {default as hasLocale} from './hasLocale.tsx'; export type {default as RelativeTimeFormatOptions} from './RelativeTimeFormatOptions.tsx'; export type {default as Timezone} from './TimeZone.tsx'; +export type {default as ICUArgs} from './ICUArgs.tsx'; +export type {default as ICUTags} from './ICUTags.tsx'; diff --git a/packages/use-intl/src/core/types.tsx b/packages/use-intl/src/core/types.tsx index 343dfa422..654654c8a 100644 --- a/packages/use-intl/src/core/types.tsx +++ b/packages/use-intl/src/core/types.tsx @@ -1,5 +1,3 @@ -export type OnlyOptional = Partial extends T ? true : false; - // https://www.totaltypescript.com/concepts/the-prettify-helper export type Prettify = { [K in keyof T]: T[K]; From 21f2dac469f33cd1d36a3fa48bcd9d74697934ad Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 12 Nov 2024 11:24:05 +0100 Subject: [PATCH 32/90] remove outdated constant --- packages/next-intl/src/shared/constants.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/next-intl/src/shared/constants.tsx b/packages/next-intl/src/shared/constants.tsx index a87f8d203..ac19219d6 100644 --- a/packages/next-intl/src/shared/constants.tsx +++ b/packages/next-intl/src/shared/constants.tsx @@ -1,6 +1,4 @@ -export const COOKIE_BASE_PATH_NAME = 'NEXT_INTL_BASE_PATH'; - -// Should take precedence over the cookie +// Used to read the locale from the middleware export const HEADER_LOCALE_NAME = 'X-NEXT-INTL-LOCALE'; // In a URL like "/en-US/about", the locale segment is "en-US" From 2a92f17bb752044f16fb71745c8477a65c5df21c Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 14 Nov 2024 14:54:00 +0100 Subject: [PATCH 33/90] feat!: Remove default of `now={new Date()}` from `NextIntlClientProvider` for usage with `format.relativeTime` (preparation for `dynamicIO`) (#1536) In preparation for [`dynamicIO`](https://nextjs.org/docs/canary/app/api-reference/config/next-config-js/dynamicIO) and [`ppr`](https://nextjs.org/docs/app/api-reference/next-config-js/ppr), the default of `now={new Date()}` on `NextIntlClientProvider` has been removed for usage in Client Components. While you can restore this behavior by adding [`now: new Date()`](https://next-intl-docs.vercel.app/docs/usage/configuration#now) in your `i18n/request.ts` config, the [relative time formatting docs](https://next-intl-docs-git-feat-dio-now-next-intl.vercel.app/docs/usage/dates-times#relative-times) now suggest a slightly adapted pattern with some benefits: 1. Always pass an explicit `now` argument to `format.relativeTime(date, now)` instead of relying on a global default 2. `now` can conveniently be retrieved from `useNow()`, optionally using a global default (if configured) 3. Use [`suppressHydrationWarning`](https://react.dev/reference/react-dom/client/hydrateRoot#suppressing-unavoidable-hydration-mismatch-errors) when using `format.relativeTime` in Client Components During a lengthy exploration of the upcoming `dynamicIO` rendering mode, this approach has proven beneficial: 1. Use granular caching when reading `now` in Server Components 2. Render up-to-date relative times on the client side instead of relying on caching --- docs/src/pages/docs/usage/configuration.mdx | 18 ++--- docs/src/pages/docs/usage/dates-times.mdx | 80 +++++++++++++++---- .../next.config.mjs | 3 + .../src/i18n/request.tsx | 5 +- .../NextIntlClientProviderServer.test.tsx | 12 ++- .../NextIntlClientProviderServer.tsx | 8 +- .../src/react-server/useFormatter.test.tsx | 43 ++++++++++ .../src/react-server/useFormatter.tsx | 7 +- .../next-intl/src/react-server/useNow.tsx | 3 +- .../src/react-server/useTranslations.test.tsx | 14 ++++ .../src/react-server/useTranslations.tsx | 4 +- .../src/server/react-server/getConfig.tsx | 17 +--- .../src/server/react-server/getConfigNow.tsx | 11 +++ .../src/server/react-server/getDefaultNow.tsx | 10 +++ .../src/server/react-server/getFormats.tsx | 4 +- .../server/react-server/getFormatter.test.tsx | 35 ++++++++ .../src/server/react-server/getFormatter.tsx | 3 +- .../src/server/react-server/getNow.tsx | 12 +-- .../react-server/getServerFormatter.tsx | 21 +++++ .../react-server/getServerTranslator.tsx} | 4 +- .../react-server/getTranslations.test.tsx | 29 +++++++ .../server/react-server/getTranslations.tsx | 8 +- packages/use-intl/.size-limit.ts | 2 +- .../use-intl/src/core/createFormatter.tsx | 29 ++++--- .../use-intl/src/react/useFormatter.test.tsx | 8 +- packages/use-intl/src/react/useNow.tsx | 17 +--- turbo.json | 3 + 27 files changed, 299 insertions(+), 111 deletions(-) create mode 100644 packages/next-intl/src/react-server/useFormatter.test.tsx create mode 100644 packages/next-intl/src/server/react-server/getConfigNow.tsx create mode 100644 packages/next-intl/src/server/react-server/getDefaultNow.tsx create mode 100644 packages/next-intl/src/server/react-server/getFormatter.test.tsx create mode 100644 packages/next-intl/src/server/react-server/getServerFormatter.tsx rename packages/next-intl/src/{react-server/getTranslator.tsx => server/react-server/getServerTranslator.tsx} (83%) create mode 100644 packages/next-intl/src/server/react-server/getTranslations.test.tsx diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx index 993ad6af7..72cad6d65 100644 --- a/docs/src/pages/docs/usage/configuration.mdx +++ b/docs/src/pages/docs/usage/configuration.mdx @@ -218,7 +218,7 @@ import {getLocale} from 'next-intl/server'; const locale = await getLocale(); ``` -### `Locale` [#locale-type] +### `Locale` type [#locale-type] When passing a `locale` to another function, you can use the `Locale` type for the receiving parameter: @@ -451,9 +451,7 @@ const timeZone = await getTimeZone(); ## Now value [#now] -When formatting [relative dates and times](/docs/usage/dates-times#relative-times), `next-intl` will format times in relation to a reference point in time that is referred to as "now". By default, this is the time a component renders. - -If you prefer to override the default, you can provide an explicit value for `now`: +When formatting [relative dates and times](/docs/usage/dates-times#relative-times), `next-intl` will format times in relation to a reference point in time that is referred to as "now". While it can be beneficial in terms of caching to [provide this value](/docs/usage/dates-times#relative-times-usenow) where necessary, you can provide a global value for `now`, e.g. to ensure consistency when running tests. @@ -463,11 +461,7 @@ import {getRequestConfig} from 'next-intl/server'; export default getRequestConfig(async () => { return { - // This is the default, a single date instance will be - // used by all Server Components to ensure consistency. - // Tip: This value can be mocked to a constant value - // for consistent results in end-to-end-tests. - now: new Date() + now: new Date('2024-11-14T10:36:01.516Z') // ... }; @@ -478,7 +472,7 @@ export default getRequestConfig(async () => { ```tsx -const now = new Date('2020-11-20T10:36:01.516Z'); +const now = new Date('2024-11-14T10:36:01.516Z'); ...; ``` @@ -486,7 +480,7 @@ const now = new Date('2020-11-20T10:36:01.516Z'); -Similarly to the `timeZone`, the `now` value in Client Components is automatically inherited from the server side if you wrap the relevant components in a `NextIntlClientProvider` that is rendered by a Server Component. +If a `now` value is provided in `i18n/request.ts`, this will automatically be inherited by Client Components if you wrap them in a `NextIntlClientProvider` that is rendered by a Server Component. ### `useNow` & `getNow` [#use-now] @@ -502,6 +496,8 @@ import {getNow} from 'next-intl/server'; const now = await getNow(); ``` +Note that the returned value defaults to the current date and time, therefore making this hook useful when [providing `now`](/docs/usage/dates-times#relative-times-usenow) for `format.relativeTime` even when you haven't configured a global `now` value. + ## Formats To achieve consistent date, time, number and list formatting, you can define a set of global formats. diff --git a/docs/src/pages/docs/usage/dates-times.mdx b/docs/src/pages/docs/usage/dates-times.mdx index a8627c853..8ad9ceccc 100644 --- a/docs/src/pages/docs/usage/dates-times.mdx +++ b/docs/src/pages/docs/usage/dates-times.mdx @@ -67,32 +67,82 @@ function Component() { const format = useFormatter(); const dateTime = new Date('2020-11-20T08:30:00.000Z'); - // At 2020-11-20T10:36:00.000Z, - // this will render "2 hours ago" - format.relativeTime(dateTime); + // A reference point in time + const now = new Date('2020-11-20T10:36:00.000Z'); + + // This will render "2 hours ago" + format.relativeTime(dateTime, now); } ``` Note that values are rounded, so e.g. if 126 minutes have passed, "2 hours ago" will be returned. -### Supplying `now` +### `useNow` [#relative-times-usenow] -By default, `relativeTime` will use [the global value for `now`](/docs/usage/configuration#now). If you want to use a different value, you can explicitly pass this as the second parameter. +Since providing `now` is a common pattern, `next-intl` provides a convenience hook that can be used to retrieve the current date and time: -```js -import {useFormatter} from 'next-intl'; +```tsx {4} +import {useNow, useFormatter} from 'next-intl'; -function Component() { +function FormattedDate({date}) { + const now = useNow(); const format = useFormatter(); - const dateTime = new Date('2020-11-20T08:30:00.000Z'); - const now = new Date('2020-11-20T10:36:00.000Z'); - // Renders "2 hours ago" - format.relativeTime(dateTime, now); + format.relativeTime(date, now); } ``` -If you want the relative time value to update over time, you can do so with [the `useNow` hook](/docs/usage/configuration#now): +In contrast to simply calling `new Date()` in your component, `useNow` has some benefits: + +1. The returned value is consistent across re-renders. +2. The value can optionally be [updated continuously](#relative-times-update) based on an interval. +3. The value can optionally be initialized from a [global value](/docs/usage/configuration#now), e.g. allowing you to use a static `now` value to ensure consistency when running tests. + +
+How can I avoid hydration errors with `useNow`? + +If you're using `useNow` in a component that renders both on the server as well as the client, you can consider using [`suppressHydrationWarning`](https://react.dev/reference/react-dom/client/hydrateRoot#suppressing-unavoidable-hydration-mismatch-errors) to tell React that this particular text is expected to potentially be updated on the client side: + +```tsx {7} +import {useNow, useFormatter} from 'next-intl'; + +function FormattedDate({date}) { + const now = useNow(); + const format = useFormatter(); + + return {format.relativeTime(date, now)}; +} +``` + +While this prop has a somewhat intimidating name, it's an escape hatch that was purposefully designed for cases like this. + +
+ +
+How can I use `now` in Server Components with `dynamicIO`? + +If you're using [`dynamicIO`](https://nextjs.org/docs/canary/app/api-reference/config/next-config-js/dynamicIO), Next.js may prompt you to specify a cache expiration in case you're using `useNow` in a Server Component. + +You can do so by annotating your component with the `'use cache'` directive, while converting it to an async function: + +```tsx +import {getNow, getFormatter} from 'next-intl/server'; + +async function FormattedDate({date}) { + 'use cache'; + + const now = await getNow(); + const format = await getFormatter(); + + return format.relativeTime(date, now); +} +``` + +
+ +### `updateInterval` [#relative-times-update] + +In case you want a relative time value to update over time, you can do so with [the `useNow` hook](/docs/usage/configuration#use-now): ```js import {useNow, useFormatter} from 'next-intl'; @@ -112,9 +162,9 @@ function Component() { } ``` -### Customizing the unit +### Customizing the unit [#relative-times-unit] -By default, `relativeTime` will pick a unit based on the difference between the passed date and `now` (e.g. 3 seconds, 40 minutes, 4 days, etc.). +By default, `relativeTime` will pick a unit based on the difference between the passed date and `now` like "3 seconds" or "5 days". If you want to use a specific unit, you can provide options via the second argument: diff --git a/examples/example-app-router-playground/next.config.mjs b/examples/example-app-router-playground/next.config.mjs index eb3878299..8c0332c69 100644 --- a/examples/example-app-router-playground/next.config.mjs +++ b/examples/example-app-router-playground/next.config.mjs @@ -13,6 +13,9 @@ const withMdx = mdxPlugin(); export default withMdx( withNextIntl({ + eslint: { + ignoreDuringBuilds: true + }, trailingSlash: process.env.NEXT_PUBLIC_USE_CASE === 'trailing-slash', basePath: process.env.NEXT_PUBLIC_USE_CASE === 'base-path' diff --git a/examples/example-app-router-playground/src/i18n/request.tsx b/examples/example-app-router-playground/src/i18n/request.tsx index db7a1b01d..2f329e1a9 100644 --- a/examples/example-app-router-playground/src/i18n/request.tsx +++ b/examples/example-app-router-playground/src/i18n/request.tsx @@ -45,7 +45,10 @@ export default getRequestConfig(async ({requestLocale}) => { return { locale, - now: now ? new Date(now) : undefined, + now: now + ? new Date(now) + : // Ensure a consistent value for a render + new Date(), timeZone, messages, formats, diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx index fb40d7b1f..013d5e0fd 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx @@ -1,12 +1,12 @@ import {expect, it, vi} from 'vitest'; +import getConfigNow from '../server/react-server/getConfigNow.tsx'; import getFormats from '../server/react-server/getFormats.tsx'; -import {getLocale, getNow, getTimeZone} from '../server.react-server.tsx'; +import {getLocale, getTimeZone} from '../server.react-server.tsx'; import NextIntlClientProvider from '../shared/NextIntlClientProvider.tsx'; import NextIntlClientProviderServer from './NextIntlClientProviderServer.tsx'; vi.mock('../../src/server/react-server', async () => ({ getLocale: vi.fn(async () => 'en-US'), - getNow: vi.fn(async () => new Date('2020-01-01T00:00:00.000Z')), getTimeZone: vi.fn(async () => 'America/New_York') })); @@ -20,6 +20,10 @@ vi.mock('../../src/server/react-server/getFormats', () => ({ })) })); +vi.mock('../../src/server/react-server/getConfigNow', () => ({ + default: vi.fn(async () => new Date('2020-01-01T00:00:00.000Z')) +})); + vi.mock('../../src/shared/NextIntlClientProvider', async () => ({ default: vi.fn(() => 'NextIntlClientProvider') })); @@ -43,7 +47,7 @@ it("doesn't read from headers if all relevant configuration is passed", async () }); expect(getLocale).not.toHaveBeenCalled(); - expect(getNow).not.toHaveBeenCalled(); + expect(getConfigNow).not.toHaveBeenCalled(); expect(getTimeZone).not.toHaveBeenCalled(); expect(getFormats).not.toHaveBeenCalled(); }); @@ -69,7 +73,7 @@ it('reads missing configuration from getter functions', async () => { }); expect(getLocale).toHaveBeenCalled(); - expect(getNow).toHaveBeenCalled(); + expect(getConfigNow).toHaveBeenCalled(); expect(getTimeZone).toHaveBeenCalled(); expect(getFormats).toHaveBeenCalled(); }); diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx index a17ac2e62..d3da7c102 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx @@ -1,6 +1,7 @@ import {ComponentProps} from 'react'; +import getConfigNow from '../server/react-server/getConfigNow.tsx'; import getFormats from '../server/react-server/getFormats.tsx'; -import {getLocale, getNow, getTimeZone} from '../server.react-server.tsx'; +import {getLocale, getTimeZone} from '../server.react-server.tsx'; import BaseNextIntlClientProvider from '../shared/NextIntlClientProvider.tsx'; type Props = ComponentProps; @@ -18,7 +19,10 @@ export default async function NextIntlClientProviderServer({ // See https://github.com/amannn/next-intl/issues/631 formats={formats === undefined ? await getFormats() : formats} locale={locale ?? (await getLocale())} - now={now ?? (await getNow())} + // Note that we don't assign a default for `now` here, + // we only read one from the request config - if any. + // Otherwise this would cause a `dynamicIO` error. + now={now ?? (await getConfigNow())} timeZone={timeZone ?? (await getTimeZone())} {...rest} /> diff --git a/packages/next-intl/src/react-server/useFormatter.test.tsx b/packages/next-intl/src/react-server/useFormatter.test.tsx new file mode 100644 index 000000000..e6b5a3bee --- /dev/null +++ b/packages/next-intl/src/react-server/useFormatter.test.tsx @@ -0,0 +1,43 @@ +import {describe, expect, it, vi} from 'vitest'; +import getDefaultNow from '../server/react-server/getDefaultNow.tsx'; +import {renderToStream} from './testUtils.tsx'; +import useFormatter from './useFormatter.tsx'; + +vi.mock('react'); +vi.mock('../server/react-server/getDefaultNow.tsx', () => ({ + default: vi.fn(() => new Date()) +})); + +vi.mock('../../src/server/react-server/createRequestConfig', () => ({ + default: async () => ({ + locale: 'en' + }) +})); + +describe('dynamicIO', () => { + it('should not include `now` in the translator config', async () => { + function TestComponent() { + const format = useFormatter(); + format.dateTime(new Date()); + format.number(1); + format.dateTimeRange(new Date(), new Date()); + format.list(['a', 'b']); + format.relativeTime(new Date(), new Date()); + return null; + } + + await renderToStream(); + expect(getDefaultNow).not.toHaveBeenCalled(); + }); + + it('should read `now` for `relativeTime` if relying on a global `now`', async () => { + function TestComponent() { + const format = useFormatter(); + format.relativeTime(new Date()); + return null; + } + + await renderToStream(); + expect(getDefaultNow).toHaveBeenCalled(); + }); +}); diff --git a/packages/next-intl/src/react-server/useFormatter.tsx b/packages/next-intl/src/react-server/useFormatter.tsx index 48be0f27e..7e41b86b6 100644 --- a/packages/next-intl/src/react-server/useFormatter.tsx +++ b/packages/next-intl/src/react-server/useFormatter.tsx @@ -1,11 +1,8 @@ -import {cache} from 'react'; import type {useFormatter as useFormatterType} from 'use-intl'; -import {createFormatter} from 'use-intl/core'; +import getServerFormatter from '../server/react-server/getServerFormatter.tsx'; import useConfig from './useConfig.tsx'; -const createFormatterCached = cache(createFormatter); - export default function useFormatter(): ReturnType { const config = useConfig('useFormatter'); - return createFormatterCached(config); + return getServerFormatter(config); } diff --git a/packages/next-intl/src/react-server/useNow.tsx b/packages/next-intl/src/react-server/useNow.tsx index e5f210dc8..3f103db46 100644 --- a/packages/next-intl/src/react-server/useNow.tsx +++ b/packages/next-intl/src/react-server/useNow.tsx @@ -1,4 +1,5 @@ import type {useNow as useNowType} from 'use-intl'; +import getDefaultNow from '../server/react-server/getDefaultNow.tsx'; import useConfig from './useConfig.tsx'; export default function useNow( @@ -11,5 +12,5 @@ export default function useNow( } const config = useConfig('useNow'); - return config.now; + return config.now ?? getDefaultNow(); } diff --git a/packages/next-intl/src/react-server/useTranslations.test.tsx b/packages/next-intl/src/react-server/useTranslations.test.tsx index 1953d0ad5..6631d3943 100644 --- a/packages/next-intl/src/react-server/useTranslations.test.tsx +++ b/packages/next-intl/src/react-server/useTranslations.test.tsx @@ -31,6 +31,20 @@ vi.mock('use-intl/core', async (importActual) => { }; }); +describe('dynamicIO', () => { + it('should not include `now` in the translator config', async () => { + function TestComponent() { + useTranslations('A'); + return null; + } + + await renderToStream(); + expect(createTranslator).toHaveBeenCalledWith( + expect.not.objectContaining({now: expect.anything()}) + ); + }); +}); + describe('performance', () => { let attemptedRenders: Record; let finishedRenders: Record; diff --git a/packages/next-intl/src/react-server/useTranslations.tsx b/packages/next-intl/src/react-server/useTranslations.tsx index 3968c5902..836b415bf 100644 --- a/packages/next-intl/src/react-server/useTranslations.tsx +++ b/packages/next-intl/src/react-server/useTranslations.tsx @@ -1,10 +1,10 @@ import type {useTranslations as useTranslationsType} from 'use-intl'; -import getBaseTranslator from './getTranslator.tsx'; +import getServerTranslator from '../server/react-server/getServerTranslator.tsx'; import useConfig from './useConfig.tsx'; export default function useTranslations( ...[namespace]: Parameters ): ReturnType { const config = useConfig('useTranslations'); - return getBaseTranslator(config, namespace); + return getServerTranslator(config, namespace); } diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index a5c2a85bb..495e5423d 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -10,12 +10,6 @@ import {getRequestLocale} from './RequestLocale.tsx'; import createRequestConfig from './createRequestConfig.tsx'; import {GetRequestConfigParams} from './getRequestConfig.tsx'; -// Make sure `now` is consistent across the request in case none was configured -function getDefaultNowImpl() { - return new Date(); -} -const getDefaultNow = cache(getDefaultNowImpl); - // This is automatically inherited by `NextIntlClientProvider` if // the component is rendered from a Server Component function getDefaultTimeZoneImpl() { @@ -65,12 +59,7 @@ See also: https://next-intl-docs.vercel.app/docs/usage/configuration#i18n-reques ); } - return { - ...result, - locale: result.locale, - now: result.now || getDefaultNow(), - timeZone: result.timeZone || getDefaultTimeZone() - }; + return result; } const receiveRuntimeConfig = cache(receiveRuntimeConfigImpl); @@ -80,7 +69,6 @@ const getCache = cache(_createCache); async function getConfigImpl(localeOverride?: Locale): Promise< IntlConfig & { getMessageFallback: NonNullable; - now: NonNullable; onError: NonNullable; timeZone: NonNullable; _formatters: ReturnType; @@ -92,7 +80,8 @@ async function getConfigImpl(localeOverride?: Locale): Promise< ); return { ...initializeConfig(runtimeConfig), - _formatters: getFormatters(getCache()) + _formatters: getFormatters(getCache()), + timeZone: runtimeConfig.timeZone || getDefaultTimeZone() }; } const getConfig = cache(getConfigImpl); diff --git a/packages/next-intl/src/server/react-server/getConfigNow.tsx b/packages/next-intl/src/server/react-server/getConfigNow.tsx new file mode 100644 index 000000000..1a0750dc9 --- /dev/null +++ b/packages/next-intl/src/server/react-server/getConfigNow.tsx @@ -0,0 +1,11 @@ +import {cache} from 'react'; +import type {Locale} from 'use-intl'; +import getConfig from './getConfig.tsx'; + +async function getConfigNowImpl(locale?: Locale) { + const config = await getConfig(locale); + return config.now; +} +const getConfigNow = cache(getConfigNowImpl); + +export default getConfigNow; diff --git a/packages/next-intl/src/server/react-server/getDefaultNow.tsx b/packages/next-intl/src/server/react-server/getDefaultNow.tsx new file mode 100644 index 000000000..8f77baa47 --- /dev/null +++ b/packages/next-intl/src/server/react-server/getDefaultNow.tsx @@ -0,0 +1,10 @@ +import {cache} from 'react'; + +function defaultNow() { + // See https://next-intl-docs.vercel.app/docs/usage/dates-times#relative-times-server + return new Date(); +} + +const getDefaultNow = cache(defaultNow); + +export default getDefaultNow; diff --git a/packages/next-intl/src/server/react-server/getFormats.tsx b/packages/next-intl/src/server/react-server/getFormats.tsx index d3900eeb9..4532b90ec 100644 --- a/packages/next-intl/src/server/react-server/getFormats.tsx +++ b/packages/next-intl/src/server/react-server/getFormats.tsx @@ -5,6 +5,6 @@ async function getFormatsCachedImpl() { const config = await getConfig(); return config.formats; } -const getFormatsCached = cache(getFormatsCachedImpl); +const getFormats = cache(getFormatsCachedImpl); -export default getFormatsCached; +export default getFormats; diff --git a/packages/next-intl/src/server/react-server/getFormatter.test.tsx b/packages/next-intl/src/server/react-server/getFormatter.test.tsx new file mode 100644 index 000000000..2499706da --- /dev/null +++ b/packages/next-intl/src/server/react-server/getFormatter.test.tsx @@ -0,0 +1,35 @@ +import {describe, expect, it, vi} from 'vitest'; +import getDefaultNow from './getDefaultNow.tsx'; +import getFormatter from './getFormatter.tsx'; + +vi.mock('react'); +vi.mock('./getDefaultNow.tsx', () => ({ + default: vi.fn(() => new Date()) +})); + +vi.mock('next-intl/config', () => ({ + default: async () => + ( + (await vi.importActual('../../../src/server/react-server')) as any + ).getRequestConfig({ + locale: 'en' + }) +})); + +describe('dynamicIO', () => { + it('should not read `now` unnecessarily', async () => { + const format = await getFormatter(); + format.dateTime(new Date()); + format.number(1); + format.dateTimeRange(new Date(), new Date()); + format.list(['a', 'b']); + format.relativeTime(new Date(), new Date()); + expect(getDefaultNow).not.toHaveBeenCalled(); + }); + + it('should read `now` for `relativeTime` if relying on a global `now`', async () => { + const format = await getFormatter(); + format.relativeTime(new Date()); + expect(getDefaultNow).toHaveBeenCalled(); + }); +}); diff --git a/packages/next-intl/src/server/react-server/getFormatter.tsx b/packages/next-intl/src/server/react-server/getFormatter.tsx index 9e1909076..1ba57e878 100644 --- a/packages/next-intl/src/server/react-server/getFormatter.tsx +++ b/packages/next-intl/src/server/react-server/getFormatter.tsx @@ -1,10 +1,11 @@ import {cache} from 'react'; import {type Locale, createFormatter} from 'use-intl/core'; import getConfig from './getConfig.tsx'; +import getServerFormatter from './getServerFormatter.tsx'; async function getFormatterCachedImpl(locale?: Locale) { const config = await getConfig(locale); - return createFormatter(config); + return getServerFormatter(config); } const getFormatterCached = cache(getFormatterCachedImpl); diff --git a/packages/next-intl/src/server/react-server/getNow.tsx b/packages/next-intl/src/server/react-server/getNow.tsx index ed39c17f9..8daf796e8 100644 --- a/packages/next-intl/src/server/react-server/getNow.tsx +++ b/packages/next-intl/src/server/react-server/getNow.tsx @@ -1,13 +1,7 @@ -import {cache} from 'react'; import type {Locale} from 'use-intl'; -import getConfig from './getConfig.tsx'; - -async function getNowCachedImpl(locale?: Locale) { - const config = await getConfig(locale); - return config.now; -} -const getNowCached = cache(getNowCachedImpl); +import getConfigNow from './getConfigNow.tsx'; +import getDefaultNow from './getDefaultNow.tsx'; export default async function getNow(opts?: {locale?: Locale}): Promise { - return getNowCached(opts?.locale); + return (await getConfigNow(opts?.locale)) ?? getDefaultNow(); } diff --git a/packages/next-intl/src/server/react-server/getServerFormatter.tsx b/packages/next-intl/src/server/react-server/getServerFormatter.tsx new file mode 100644 index 000000000..5beeb0820 --- /dev/null +++ b/packages/next-intl/src/server/react-server/getServerFormatter.tsx @@ -0,0 +1,21 @@ +import {cache} from 'react'; +import {createFormatter} from 'use-intl/core'; +import getDefaultNow from './getDefaultNow.tsx'; + +function getFormatterCachedImpl(config: Parameters[0]) { + // same here? + // also add a test + // also for getTranslations/useTranslations + // add a test with a getter maybe, don't mock + return createFormatter({ + ...config, + // Only init when necessary to avoid triggering a `dynamicIO` error + // unnecessarily (`now` is only needed for `format.relativeTime`) + get now() { + return config.now ?? getDefaultNow(); + } + }); +} +const getFormatterCached = cache(getFormatterCachedImpl); + +export default getFormatterCached; diff --git a/packages/next-intl/src/react-server/getTranslator.tsx b/packages/next-intl/src/server/react-server/getServerTranslator.tsx similarity index 83% rename from packages/next-intl/src/react-server/getTranslator.tsx rename to packages/next-intl/src/server/react-server/getServerTranslator.tsx index 3b4d7f50c..aec9d4a38 100644 --- a/packages/next-intl/src/react-server/getTranslator.tsx +++ b/packages/next-intl/src/server/react-server/getServerTranslator.tsx @@ -6,7 +6,7 @@ import { createTranslator } from 'use-intl/core'; -function getTranslatorImpl< +function getServerTranslatorImpl< NestedKey extends NamespaceKeys> = never >( config: Parameters[0], @@ -18,4 +18,4 @@ function getTranslatorImpl< }); } -export default cache(getTranslatorImpl); +export default cache(getServerTranslatorImpl); diff --git a/packages/next-intl/src/server/react-server/getTranslations.test.tsx b/packages/next-intl/src/server/react-server/getTranslations.test.tsx new file mode 100644 index 000000000..5a19977c5 --- /dev/null +++ b/packages/next-intl/src/server/react-server/getTranslations.test.tsx @@ -0,0 +1,29 @@ +import {createTranslator} from 'use-intl/core'; +import {expect, it, vi} from 'vitest'; +import getTranslations from './getTranslations.tsx'; + +vi.mock('react'); +vi.mock('use-intl/core'); + +vi.mock('next-intl/config', () => ({ + default: async () => + ( + (await vi.importActual('../../../src/server/react-server')) as any + ).getRequestConfig({ + locale: 'en', + timeZone: 'Europe/London', + messages: { + title: 'Hello' + } + }) +})); + +it('should not include `now` in the translator config', async () => { + await getTranslations(); + + expect(createTranslator).toHaveBeenCalledWith( + expect.not.objectContaining({ + now: expect.anything() + }) + ); +}); diff --git a/packages/next-intl/src/server/react-server/getTranslations.tsx b/packages/next-intl/src/server/react-server/getTranslations.tsx index e3d5d8c68..5615c2e46 100644 --- a/packages/next-intl/src/server/react-server/getTranslations.tsx +++ b/packages/next-intl/src/server/react-server/getTranslations.tsx @@ -7,6 +7,7 @@ import { createTranslator } from 'use-intl/core'; import getConfig from './getConfig.tsx'; +import getServerTranslator from './getServerTranslator.tsx'; // Maintainer note: `getTranslations` has two different call signatures. // We need to define these with function overloads, otherwise TypeScript @@ -40,12 +41,7 @@ async function getTranslations< } const config = await getConfig(locale); - - return createTranslator({ - ...config, - namespace, - messages: config.messages - }); + return getServerTranslator(config, namespace); } export default cache(getTranslations); diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts index c6dae5c70..4267cf64d 100644 --- a/packages/use-intl/.size-limit.ts +++ b/packages/use-intl/.size-limit.ts @@ -5,7 +5,7 @@ const config: SizeLimitConfig = [ name: "import * from 'use-intl' (production)", import: '*', path: 'dist/esm/production/index.js', - limit: '12.945 kB' + limit: '12.965 kB' }, { name: "import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from 'use-intl' (production)", diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index 02c38cf5a..0990a7df6 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -82,15 +82,16 @@ type Props = { _cache?: IntlCache; }; -export default function createFormatter({ - _cache: cache = createCache(), - _formatters: formatters = createIntlFormatters(cache), - formats, - locale, - now: globalNow, - onError = defaultOnError, - timeZone: globalTimeZone -}: Props) { +export default function createFormatter(props: Props) { + const { + _cache: cache = createCache(), + _formatters: formatters = createIntlFormatters(cache), + formats, + locale, + onError = defaultOnError, + timeZone: globalTimeZone + } = props; + function applyTimeZone(options?: DateTimeFormatOptions) { if (!options?.timeZone) { if (globalTimeZone) { @@ -212,14 +213,16 @@ export default function createFormatter({ } function getGlobalNow() { - if (globalNow) { - return globalNow; + // Only read when necessary to avoid triggering a `dynamicIO` error + // unnecessarily (`now` is only needed for `format.relativeTime`) + if (props.now) { + return props.now; } else { onError( new IntlError( IntlErrorCode.ENVIRONMENT_FALLBACK, process.env.NODE_ENV !== 'production' - ? `The \`now\` parameter wasn't provided and there is no global default configured. Consider adding a global default to avoid markup mismatches caused by environment differences. Learn more: https://next-intl-docs.vercel.app/docs/configuration#now` + ? `The \`now\` parameter wasn't provided and there is no global default configured, therefore the current time will be used as a fallback. To avoid markup mismatches caused by environment differences, either provide the \`now\` parameter or configure a global default. Learn more: https://next-intl-docs.vercel.app/docs/configuration#now` : undefined ) ); @@ -230,7 +233,7 @@ export default function createFormatter({ function relativeTime( /** The date time that needs to be formatted. */ date: number | Date, - /** The reference point in time to which `date` will be formatted in relation to. */ + /** The reference point in time to which `date` will be formatted in relation to. If this value is absent, a globally configured `now` value or alternatively the current time will be used. */ nowOrOptions?: RelativeTimeFormatOptions['now'] | RelativeTimeFormatOptions ) { try { diff --git a/packages/use-intl/src/react/useFormatter.test.tsx b/packages/use-intl/src/react/useFormatter.test.tsx index 88707ba4d..26b7b8a76 100644 --- a/packages/use-intl/src/react/useFormatter.test.tsx +++ b/packages/use-intl/src/react/useFormatter.test.tsx @@ -279,9 +279,7 @@ describe('dateTime', () => { ); const error: IntlError = onError.mock.calls[0][0]; - expect(error.message).toMatch( - "ENVIRONMENT_FALLBACK: The `timeZone` parameter wasn't provided and there is no global default configured." - ); + expect(error.message).toMatch(/^ENVIRONMENT_FALLBACK/); expect(error.code).toBe(IntlErrorCode.ENVIRONMENT_FALLBACK); expect(container.textContent).toBe('11/20/2020'); }); @@ -622,9 +620,7 @@ describe('relativeTime', () => { ); const error: IntlError = onError.mock.calls[0][0]; - expect(error.message).toMatch( - "ENVIRONMENT_FALLBACK: The `now` parameter wasn't provided and there is no global default configured." - ); + expect(error.message).toMatch(/^ENVIRONMENT_FALLBACK/); expect(error.code).toBe(IntlErrorCode.ENVIRONMENT_FALLBACK); }); }); diff --git a/packages/use-intl/src/react/useNow.tsx b/packages/use-intl/src/react/useNow.tsx index 0fb30d472..21716b655 100644 --- a/packages/use-intl/src/react/useNow.tsx +++ b/packages/use-intl/src/react/useNow.tsx @@ -10,22 +10,7 @@ function getNow() { } /** - * Reading the current date via `new Date()` in components should be avoided, as - * it causes components to be impure and can lead to flaky tests. Instead, this - * hook can be used. - * - * By default, it returns the time when the component mounts. If `updateInterval` - * is specified, the value will be updated based on the interval. - * - * You can however also return a static value from this hook, if you - * configure the `now` parameter on the context provider. Note however, - * that if `updateInterval` is configured in this case, the component - * will initialize with the global value, but will afterwards update - * continuously based on the interval. - * - * For unit tests, this can be mocked to a constant value. For end-to-end - * testing, an environment parameter can be passed to the `now` parameter - * of the provider to mock this to a static value. + * @see https://next-intl-docs.vercel.app/docs/usage/dates-times#relative-times-usenow */ export default function useNow(options?: Options) { const updateInterval = options?.updateInterval; diff --git a/turbo.json b/turbo.json index 49eabfe80..c1c687086 100644 --- a/turbo.json +++ b/turbo.json @@ -8,6 +8,9 @@ "lint": { "dependsOn": ["^build"] }, + "example-app-router-playground#lint": { + "dependsOn": ["example-app-router-playground#build"] + }, "test": { "dependsOn": ["build"] }, From dc36097752a6015c83692e7b574cfc843fa717c8 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 14 Nov 2024 15:11:19 +0100 Subject: [PATCH 34/90] feat!: Don't read a default for `useLocale` from `useParams.locale` on the client side, but rely on `NextIntlClientProvider` being used (preparation for `dynamicIO`) (#1541) Previously, `useParams.locale` was consulted when reading from `useLocale()` on the client side, allowing to use this API even when no `NextIntlClientProvider` is used. This behavior has now been removed because: 1. Reading from `useParams().locale` doesn't apply if you're using an [App Router setup](https://next-intl-docs.vercel.app/docs/getting-started/app-router) without i18n routing. 2. Reading from `useParams()` might require additional work from the developer in the future to work with the upcoming [`dynamicIO`](https://nextjs.org/docs/canary/app/api-reference/config/next-config-js/dynamicIO) rendering mode like adding `'use cache'` or a `` boundary. Therefore, if you use any features from `next-intl` on the client side, you should now add a `NextIntlClientProvider` in the root layout and wrap all relevant components: ```tsx import {NextIntlClientProvider} from 'next-intl'; export default async function LocaleLayout(/* ... */) { // ... return ( {children} ); } ``` Note that also navigation APIs like `Link` rely on `useLocale` internally. --- .../environments/server-client-components.mdx | 5 +- docs/src/pages/docs/usage/configuration.mdx | 76 ++++++++++--------- .../src/app/[locale]/layout.tsx | 8 +- packages/next-intl/eslint.config.mjs | 17 ++++- .../src/navigation/createNavigation.test.tsx | 28 +++---- .../react-client/createNavigation.test.tsx | 36 ++------- .../react-client/createNavigation.tsx | 3 +- .../react-client/useBasePathname.test.tsx | 17 ++--- .../react-client/useBasePathname.tsx | 2 +- .../src/navigation/shared/BaseLink.tsx | 3 +- packages/next-intl/src/react-client/index.tsx | 3 - .../src/react-client/useLocale.test.tsx | 33 -------- .../next-intl/src/react-client/useLocale.tsx | 23 ------ .../shared/NextIntlClientProvider.test.tsx | 44 ----------- .../src/shared/NextIntlClientProvider.tsx | 20 ++--- packages/next-intl/src/shared/constants.tsx | 3 - packages/next-intl/src/shared/useParams.tsx | 7 -- .../use-intl/src/react/useIntlContext.tsx | 2 +- 18 files changed, 100 insertions(+), 230 deletions(-) delete mode 100644 packages/next-intl/src/react-client/useLocale.test.tsx delete mode 100644 packages/next-intl/src/react-client/useLocale.tsx delete mode 100644 packages/next-intl/src/shared/NextIntlClientProvider.test.tsx delete mode 100644 packages/next-intl/src/shared/useParams.tsx diff --git a/docs/src/pages/docs/environments/server-client-components.mdx b/docs/src/pages/docs/environments/server-client-components.mdx index cccba84e4..b7c743ef4 100644 --- a/docs/src/pages/docs/environments/server-client-components.mdx +++ b/docs/src/pages/docs/environments/server-client-components.mdx @@ -69,7 +69,7 @@ These functions are available: Components that aren't declared with the `async` keyword and don't use interactive features like `useState`, are referred to as [shared components](https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md#sharing-code-between-server-and-client). These can render either as a Server or Client Component, depending on where they are imported from. -In Next.js, Server Components are the default, and therefore shared components will typically execute as Server Components. +In Next.js, Server Components are the default, and therefore shared components will typically execute as Server Components: ```tsx filename="UserDetails.tsx" import {useTranslations} from 'next-intl'; @@ -77,6 +77,9 @@ import {useTranslations} from 'next-intl'; export default function UserDetails({user}) { const t = useTranslations('UserProfile'); + // This component will execute as a Server Component by default. + // However, if it is imported from a Client Component, it will + // execute as a Client Component. return (

{t('title')}

diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx index 72cad6d65..3e04da37b 100644 --- a/docs/src/pages/docs/usage/configuration.mdx +++ b/docs/src/pages/docs/usage/configuration.mdx @@ -181,6 +181,17 @@ export default getRequestConfig(async () => { }); ``` +
+Which values can the `requestLocale` parameter hold? + +While the `requestLocale` parameter typically corresponds to the `[locale]` segment that was matched by the middleware, there are three special cases to consider: + +1. **Overrides**: When an explicit `locale` is passed to [awaitable functions](/docs/environments/actions-metadata-route-handlers) like `getTranslations({locale: 'en'})`, then this value will be used instead of the segment. +1. **`undefined`**: The value can be `undefined` when a page outside of the `[locale]` segment renders (e.g. a language selection page at `app/page.tsx`). +1. **Invalid values**: Since the `[locale]` segment effectively acts like a catch-all for unknown routes (e.g. `/unknown.txt`), invalid values should be replaced with a valid locale. In addition to this, you might want to call `notFound()` in [the root layout](/docs/getting-started/app-router/with-i18n-routing#layout) to abort the render in this case. + +
+ @@ -191,14 +202,13 @@ export default getRequestConfig(async () => { -
-Which values can the `requestLocale` parameter hold? +
+How can I change the locale? -While the `requestLocale` parameter typically corresponds to the `[locale]` segment that was matched by the middleware, there are three special cases to consider: +Depending on if you're using [i18n routing](/docs/getting-started/app-router), the locale can be changed as follows: -1. **Overrides**: When an explicit `locale` is passed to [awaitable functions](/docs/environments/actions-metadata-route-handlers) like `getTranslations({locale: 'en'})`, then this value will be used instead of the segment. -1. **`undefined`**: The value can be `undefined` when a page outside of the `[locale]` segment renders (e.g. a language selection page at `app/page.tsx`). -1. **Invalid values**: Since the `[locale]` segment effectively acts like a catch-all for unknown routes (e.g. `/unknown.txt`), invalid values should be replaced with a valid locale. In addition to this, you might want to call `notFound()` in [the root layout](/docs/getting-started/app-router/with-i18n-routing#layout) to abort the render in this case. +1. **With i18n routing**: The locale is managed by the router and can be changed by using navigation APIs from `next-intl` like [`Link`](/docs/routing/navigation#link) or [`useRouter`](/docs/routing/navigation#userouter). +2. **Without i18n routing**: You can change the locale by updating the value where the locale is read from (e.g. a cookie, a user setting, etc.). If you're looking for inspiration, you can have a look at the [App Router without i18n routing example](/examples#app-router-without-i18n-routing) that manages the locale via a cookie.
@@ -218,41 +228,15 @@ import {getLocale} from 'next-intl/server'; const locale = await getLocale(); ``` -### `Locale` type [#locale-type] - -When passing a `locale` to another function, you can use the `Locale` type for the receiving parameter: - -```tsx -import {Locale} from 'next-intl'; - -async function getPosts(locale: Locale) { - // ... -} -``` - - - By default, `Locale` is typed as `string`. However, you can optionally provide - a strict union based on your supported locales for this type by [augmenting - the `Locale` type](/docs/workflows/typescript#locale). - - -
-How can I change the locale? - -Depending on if you're using [i18n routing](/docs/getting-started/app-router), the locale can be changed as follows: - -1. **With i18n routing**: The locale is managed by the router and can be changed by using navigation APIs from `next-intl` like [`Link`](/docs/routing/navigation#link) or [`useRouter`](/docs/routing/navigation#userouter). -2. **Without i18n routing**: You can change the locale by updating the value where the locale is read from (e.g. a cookie, a user setting, etc.). If you're looking for inspiration, you can have a look at the [App Router without i18n routing example](/examples#app-router-without-i18n-routing) that manages the locale via a cookie. - -
-
Which value is returned from `useLocale`? -The returned value is resolved based on these priorities: +Depending on how a component renders, the returned locale corresponds to: -1. **Server Components**: If you're using [i18n routing](/docs/getting-started/app-router), the returned locale is the one that you've either provided via [`setRequestLocale`](/docs/getting-started/app-router/with-i18n-routing#static-rendering) or alternatively the one in the `[locale]` segment that was matched by the middleware. If you're not using i18n routing, the returned locale is the one that you've provided via `getRequestConfig`. -2. **Client Components**: In this case, the locale is received from `NextIntlClientProvider` or alternatively `useParams().locale`. Note that `NextIntlClientProvider` automatically inherits the locale if the component is rendered by a Server Component. +1. **Server Components**: The locale represents the value returned in [`i18n/request.ts`](#i18n-request). +2. **Client Components**: The locale is received from [`NextIntlClientProvider`](#nextintlclientprovider). + +Note that `NextIntlClientProvider` automatically inherits the locale if it is rendered by a Server Component, therefore you rarely need to pass a locale to `NextIntlClientProvider` yourself.
@@ -277,6 +261,24 @@ return (
+### `Locale` type [#locale-type] + +When passing a `locale` to another function, you can use the `Locale` type for the receiving parameter: + +```tsx +import {Locale} from 'next-intl'; + +async function getPosts(locale: Locale) { + // ... +} +``` + + + By default, `Locale` is typed as `string`. However, you can optionally provide + a strict union based on your supported locales for this type by [augmenting + the `Locale` type](/docs/workflows/typescript#locale). + + ## Messages The most crucial aspect of internationalization is providing labels based on the user's language. The recommended workflow is to store your messages in your repository along with the code. diff --git a/examples/example-app-router-playground/src/app/[locale]/layout.tsx b/examples/example-app-router-playground/src/app/[locale]/layout.tsx index d709cf78f..2a9b487b4 100644 --- a/examples/example-app-router-playground/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/layout.tsx @@ -1,6 +1,6 @@ import {Metadata} from 'next'; import {notFound} from 'next/navigation'; -import {Locale, hasLocale} from 'next-intl'; +import {Locale, NextIntlClientProvider, hasLocale} from 'next-intl'; import { getFormatter, getNow, @@ -50,8 +50,10 @@ export default function LocaleLayout({children, params: {locale}}: Props) { lineHeight: 1.5 }} > - - {children} + + + {children} +
diff --git a/packages/next-intl/eslint.config.mjs b/packages/next-intl/eslint.config.mjs index 92ba53b1f..7ac3af172 100644 --- a/packages/next-intl/eslint.config.mjs +++ b/packages/next-intl/eslint.config.mjs @@ -6,6 +6,21 @@ export default (await getPresets('typescript', 'react', 'vitest')).concat({ 'react-compiler': reactCompilerPlugin }, rules: { - 'react-compiler/react-compiler': 'error' + 'react-compiler/react-compiler': 'error', + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + // Because: + // - Avoid hardcoding the `locale` param + // - Prepare for a new API in Next.js to read params deeply + // - Avoid issues with `dynamicIO` + name: 'next/navigation.js', + importNames: ['useParams'] + } + ] + } + ] } }); diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 92f05556b..c8118d154 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -2,29 +2,27 @@ import {render, screen} from '@testing-library/react'; import { RedirectType, permanentRedirect as nextPermanentRedirect, - redirect as nextRedirect, - useParams as nextUseParams + redirect as nextRedirect } from 'next/navigation.js'; import {renderToString} from 'react-dom/server'; -import {Locale} from 'use-intl'; +import {Locale, useLocale} from 'use-intl'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {useLocale} from '../index.react-server.tsx'; import {DomainsConfig, Pathnames, defineRouting} from '../routing.tsx'; import createNavigationClient from './react-client/createNavigation.tsx'; import createNavigationServer from './react-server/createNavigation.tsx'; import getServerLocale from './react-server/getServerLocale.tsx'; vi.mock('react'); -vi.mock('next/navigation.js', async () => { - const actual = await vi.importActual('next/navigation.js'); - return { - ...actual, - useParams: vi.fn(() => ({locale: 'en'})), - redirect: vi.fn(), - permanentRedirect: vi.fn() - }; -}); +vi.mock('next/navigation.js', async () => ({ + ...(await vi.importActual('next/navigation.js')), + redirect: vi.fn(), + permanentRedirect: vi.fn() +})); vi.mock('./react-server/getServerLocale'); +vi.mock('use-intl', async () => ({ + ...(await vi.importActual('use-intl')), + useLocale: vi.fn(() => 'en') +})); function mockCurrentLocale(locale: Locale) { // Enable synchronous rendering without having to suspend @@ -35,9 +33,7 @@ function mockCurrentLocale(locale: Locale) { vi.mocked(getServerLocale).mockImplementation(() => promise); - vi.mocked(nextUseParams<{locale: Locale}>).mockImplementation(() => ({ - locale - })); + vi.mocked(useLocale).mockImplementation(() => locale); } function mockLocation(location: Partial) { diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index ca9b0968b..ef57b97c7 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -1,21 +1,22 @@ import {fireEvent, render, screen} from '@testing-library/react'; import { usePathname as useNextPathname, - useRouter as useNextRouter, - useParams + useRouter as useNextRouter } from 'next/navigation.js'; import type {Locale} from 'use-intl'; +import {useLocale} from 'use-intl'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {NextIntlClientProvider, useLocale} from '../../index.react-client.tsx'; import {DomainsConfig, Pathnames} from '../../routing.tsx'; import createNavigation from './createNavigation.tsx'; vi.mock('next/navigation.js'); +vi.mock('use-intl', async () => ({ + ...(await vi.importActual('use-intl')), + useLocale: vi.fn(() => 'en') +})); function mockCurrentLocale(locale: Locale) { - vi.mocked(useParams<{locale: Locale}>).mockImplementation(() => ({ - locale - })); + vi.mocked(useLocale).mockImplementation(() => locale); } function mockLocation( @@ -112,29 +113,6 @@ describe("localePrefix: 'always'", () => { }); describe('Link', () => { - describe('usage outside of Next.js', () => { - beforeEach(() => { - vi.mocked(useParams).mockImplementation((() => null) as any); - }); - - it('works with a provider', () => { - render( - - Test - - ); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/en/test'); - }); - - it('throws without a provider', () => { - expect(() => render(Test)).toThrow( - 'No intl context found. Have you configured the provider?' - ); - }); - }); - it('can receive a ref', () => { let ref; diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index 0cdcc4a56..904eab475 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -3,8 +3,7 @@ import { useRouter as useNextRouter } from 'next/navigation.js'; import {useMemo} from 'react'; -import type {Locale} from 'use-intl'; -import useLocale from '../../react-client/useLocale.tsx'; +import {type Locale, useLocale} from 'use-intl'; import { RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx index 4d4ea8839..28fcfc738 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx @@ -1,14 +1,18 @@ import {render, screen} from '@testing-library/react'; -import {usePathname as useNextPathname, useParams} from 'next/navigation.js'; +import {usePathname as useNextPathname} from 'next/navigation.js'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {NextIntlClientProvider} from '../../index.react-client.tsx'; +import {NextIntlClientProvider, useLocale} from '../../index.react-client.tsx'; import useBasePathname from './useBasePathname.tsx'; vi.mock('next/navigation.js'); +vi.mock('use-intl', async () => ({ + ...(await vi.importActual('use-intl')), + useLocale: vi.fn(() => 'en') +})); function mockPathname(pathname: string) { vi.mocked(useNextPathname).mockImplementation(() => pathname); - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); + vi.mocked(useLocale).mockImplementation(() => 'en'); } function Component() { @@ -51,7 +55,6 @@ describe('prefixed routing', () => { describe('usage outside of Next.js', () => { beforeEach(() => { vi.mocked(useNextPathname).mockImplementation((() => null) as any); - vi.mocked(useParams).mockImplementation((() => null) as any); }); it('returns `null` when used within a provider', () => { @@ -62,10 +65,4 @@ describe('usage outside of Next.js', () => { ); expect(container.innerHTML).toBe(''); }); - - it('throws without a provider', () => { - expect(() => render()).toThrow( - 'No intl context found. Have you configured the provider?' - ); - }); }); diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx index 9bca6df97..c5eaa3c7a 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx @@ -1,6 +1,6 @@ import {usePathname as useNextPathname} from 'next/navigation.js'; import {useMemo} from 'react'; -import useLocale from '../../react-client/useLocale.tsx'; +import {useLocale} from 'use-intl'; import { LocalePrefixConfigVerbose, LocalePrefixMode, diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index 2ae09aa87..bd531725e 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -10,8 +10,7 @@ import { useEffect, useState } from 'react'; -import type {Locale} from 'use-intl'; -import useLocale from '../../react-client/useLocale.tsx'; +import {type Locale, useLocale} from 'use-intl'; import {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; import syncLocaleCookie from './syncLocaleCookie.tsx'; diff --git a/packages/next-intl/src/react-client/index.tsx b/packages/next-intl/src/react-client/index.tsx index 094b4f0b4..e8ac8b466 100644 --- a/packages/next-intl/src/react-client/index.tsx +++ b/packages/next-intl/src/react-client/index.tsx @@ -46,7 +46,4 @@ export const useFormatter = callHook( base_useFormatter ) as typeof base_useFormatter; -// Replace `useLocale` export from `use-intl` -export {default as useLocale} from './useLocale.tsx'; - export {default as NextIntlClientProvider} from '../shared/NextIntlClientProvider.tsx'; diff --git a/packages/next-intl/src/react-client/useLocale.test.tsx b/packages/next-intl/src/react-client/useLocale.test.tsx deleted file mode 100644 index 2062bd8fc..000000000 --- a/packages/next-intl/src/react-client/useLocale.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import {render, screen} from '@testing-library/react'; -import {useParams} from 'next/navigation.js'; -import {expect, it, vi} from 'vitest'; -import {NextIntlClientProvider, useLocale} from './index.tsx'; - -vi.mock('next/navigation.js', () => ({ - useParams: vi.fn(() => ({locale: 'en'})) -})); - -function Component() { - return <>{useLocale()}; -} - -it('returns a locale from `useParams` without a provider', () => { - render(); - screen.getByText('en'); -}); - -it('prioritizes the locale from the provider', () => { - render( - - - - ); - screen.getByText('de'); -}); - -it('throws if neither a locale from the provider or useParams is available', () => { - vi.mocked(useParams).mockImplementation(() => ({})); - expect(() => render()).toThrow( - 'No intl context found. Have you configured the provider?' - ); -}); diff --git a/packages/next-intl/src/react-client/useLocale.tsx b/packages/next-intl/src/react-client/useLocale.tsx deleted file mode 100644 index 7274813d9..000000000 --- a/packages/next-intl/src/react-client/useLocale.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import {useLocale as useBaseLocale} from 'use-intl/react'; -import {LOCALE_SEGMENT_NAME} from '../shared/constants.tsx'; -import useParams from '../shared/useParams.tsx'; - -export default function useLocale(): ReturnType { - const params = useParams(); - - let locale; - - try { - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/rules-of-hooks, react-compiler/react-compiler -- False positive - locale = useBaseLocale(); - } catch (error) { - if (typeof params?.[LOCALE_SEGMENT_NAME] === 'string') { - locale = params[LOCALE_SEGMENT_NAME]; - } else { - throw error; - } - } - - return locale; -} diff --git a/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx b/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx deleted file mode 100644 index 7cf3b13c8..000000000 --- a/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import {render, screen} from '@testing-library/react'; -import type {Locale} from 'use-intl'; -import {it, vi} from 'vitest'; -import { - NextIntlClientProvider, - useTranslations -} from '../index.react-client.tsx'; - -vi.mock('next/navigation.js', () => ({ - useParams() { - return {locale: 'en'}; - } -})); - -function Component() { - const t = useTranslations(); - return <>{t('message', {price: 29000.5})}; -} - -function TestProvider({locale}: {locale?: Locale}) { - return ( - - - - ); -} - -it('can use messages from the provider', () => { - render(); - screen.getByText('€29,000.50'); -}); - -it('reads a default locale from params', () => { - render(); - screen.getByText('€29,000.50'); -}); - -it('can override the locale from Next.js', () => { - render(); - screen.getByText('29.000,50 €'); -}); diff --git a/packages/next-intl/src/shared/NextIntlClientProvider.tsx b/packages/next-intl/src/shared/NextIntlClientProvider.tsx index dc18bf15d..d079dd428 100644 --- a/packages/next-intl/src/shared/NextIntlClientProvider.tsx +++ b/packages/next-intl/src/shared/NextIntlClientProvider.tsx @@ -1,10 +1,8 @@ 'use client'; import {ComponentProps} from 'react'; -import type {Locale} from 'use-intl'; +import {type Locale} from 'use-intl'; import {IntlProvider} from 'use-intl/react'; -import {LOCALE_SEGMENT_NAME} from './constants.tsx'; -import useParams from './useParams.tsx'; type Props = Omit, 'locale'> & { /** This is automatically received when being rendered from a Server Component. In all other cases, e.g. when rendered from a Client Component, a unit test or with the Pages Router, you can pass this prop explicitly. */ @@ -12,18 +10,12 @@ type Props = Omit, 'locale'> & { }; export default function NextIntlClientProvider({locale, ...rest}: Props) { - const paramsLocale = useParams()?.[LOCALE_SEGMENT_NAME]; - if (!locale) { - if (typeof paramsLocale === 'string') { - locale = paramsLocale; - } else { - throw new Error( - process.env.NODE_ENV !== 'production' - ? 'Failed to determine locale in `NextIntlClientProvider`, please provide the `locale` prop explicitly.\n\nSee https://next-intl-docs.vercel.app/docs/configuration#locale' - : undefined - ); - } + throw new Error( + process.env.NODE_ENV !== 'production' + ? "Couldn't infer the `locale` prop in `NextIntlClientProvider`, please provide it explicitly.\n\nSee https://next-intl-docs.vercel.app/docs/configuration#locale" + : undefined + ); } return ; diff --git a/packages/next-intl/src/shared/constants.tsx b/packages/next-intl/src/shared/constants.tsx index ac19219d6..d86ae9878 100644 --- a/packages/next-intl/src/shared/constants.tsx +++ b/packages/next-intl/src/shared/constants.tsx @@ -1,5 +1,2 @@ // Used to read the locale from the middleware export const HEADER_LOCALE_NAME = 'X-NEXT-INTL-LOCALE'; - -// In a URL like "/en-US/about", the locale segment is "en-US" -export const LOCALE_SEGMENT_NAME = 'locale'; diff --git a/packages/next-intl/src/shared/useParams.tsx b/packages/next-intl/src/shared/useParams.tsx deleted file mode 100644 index 7aed6c35a..000000000 --- a/packages/next-intl/src/shared/useParams.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import {useParams as useNextParams} from 'next/navigation.js'; - -export default function useParams() { - // The types aren't entirely correct here. Outside of Next.js - // `useParams` can be called, but the return type is `null`. - return useNextParams() as ReturnType | null; -} diff --git a/packages/use-intl/src/react/useIntlContext.tsx b/packages/use-intl/src/react/useIntlContext.tsx index 9185ecf26..aa1312800 100644 --- a/packages/use-intl/src/react/useIntlContext.tsx +++ b/packages/use-intl/src/react/useIntlContext.tsx @@ -7,7 +7,7 @@ export default function useIntlContext(): IntlContextValue { if (!context) { throw new Error( process.env.NODE_ENV !== 'production' - ? 'No intl context found. Have you configured the provider? See https://next-intl-docs.vercel.app/docs/usage/configuration#client-server-components' + ? 'No intl context found. Have you configured the provider? See https://next-intl-docs.vercel.app/docs/usage/configuration#server-client-components' : undefined ); } From 3ff1923db20bfe54637b40e8193299d49b9d695c Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 14 Nov 2024 16:36:21 +0100 Subject: [PATCH 35/90] decrease size, relax next.js peer dependency --- packages/next-intl/.size-limit.ts | 14 +++++++------- packages/next-intl/package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 033e7acda..e03e1910a 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -4,30 +4,30 @@ const config: SizeLimitConfig = [ { name: "import * from 'next-intl' (react-client, production)", path: 'dist/esm/production/index.react-client.js', - limit: '13.175 KB' + limit: '13.065 KB' }, { name: "import {NextIntlClientProvider} from 'next-intl' (react-client, production)", import: '{NextIntlClientProvider}', path: 'dist/esm/production/index.react-client.js', - limit: '1.055 KB' + limit: '1 KB' }, { name: "import * from 'next-intl' (react-server, production)", path: 'dist/esm/production/index.react-server.js', - limit: '14.085 KB' + limit: '14.005 KB' }, { name: "import {createNavigation} from 'next-intl/navigation' (react-client, production)", path: 'dist/esm/production/navigation.react-client.js', import: '{createNavigation}', - limit: '2.525 KB' + limit: '2.445 KB' }, { name: "import {createNavigation} from 'next-intl/navigation' (react-server, production)", path: 'dist/esm/production/navigation.react-server.js', import: '{createNavigation}', - limit: '3.385 KB' + limit: '3.245 KB' }, { name: "import * from 'next-intl/server' (react-client, production)", @@ -37,12 +37,12 @@ const config: SizeLimitConfig = [ { name: "import * from 'next-intl/server' (react-server, production)", path: 'dist/esm/production/server.react-server.js', - limit: '13.365 KB' + limit: '13.335 KB' }, { name: "import * from 'next-intl/middleware' (production)", path: 'dist/esm/production/middleware.js', - limit: '9.305 KB' + limit: '9.265 KB' }, { name: "import * from 'next-intl/routing' (production)", diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index bd6b5a06b..a30ef79f7 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -116,7 +116,7 @@ "use-intl": "workspace:^" }, "peerDependencies": { - "next": "^13.3.0 || ^14.0.0 || ^15.0.0", + "next": "^13.0.0 || ^14.0.0 || ^15.0.0", "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0", "typescript": "^5.0.0" }, From f44ae15afc349cf7955d9f56369a681f288c9ba7 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 14 Nov 2024 17:30:59 +0100 Subject: [PATCH 36/90] feat: Adopt shared ICU type parser (#1549) --- packages/use-intl/package.json | 1 + packages/use-intl/src/core/ICUArgs.tsx | 186 +----------------- .../use-intl/src/core/createTranslator.tsx | 9 +- pnpm-lock.yaml | 9 + 4 files changed, 23 insertions(+), 182 deletions(-) diff --git a/packages/use-intl/package.json b/packages/use-intl/package.json index 3decb76ba..642232968 100644 --- a/packages/use-intl/package.json +++ b/packages/use-intl/package.json @@ -58,6 +58,7 @@ ], "dependencies": { "@formatjs/fast-memoize": "^2.2.0", + "@schummar/icu-type-parser": "1.21.5", "intl-messageformat": "^10.5.14" }, "peerDependencies": { diff --git a/packages/use-intl/src/core/ICUArgs.tsx b/packages/use-intl/src/core/ICUArgs.tsx index 7d55c7b42..096fcef1a 100644 --- a/packages/use-intl/src/core/ICUArgs.tsx +++ b/packages/use-intl/src/core/ICUArgs.tsx @@ -1,186 +1,10 @@ -// See https://github.com/schummar/schummar-translate/issues/28 +// schummar is the best, he published his ICU type parser for next-intl: +// https://github.com/schummar/schummar-translate/issues/28 +import {GetICUArgs, GetICUArgsOptions} from '@schummar/icu-type-parser'; -export type Flatten = T extends object - ? { - [P in keyof T]: T[P]; - } - : T; - -type OtherString = string & {__type: 'other'}; - -type Whitespace = ' ' | '\t' | '\n' | '\r'; - -/** Remove leading and tailing whitespace */ -type Trim = T extends `${Whitespace}${infer Rest}` - ? Trim - : T extends `${infer Rest}${Whitespace}` - ? Trim - : T extends string - ? T - : never; - -/** Returns an array of top level blocks */ -type FindBlocks = Text extends `${string}{${infer Right}` // find first { - ? ReadBlock<'', Right, ''> extends [infer Block, infer Tail] - ? [Block, ...FindBlocks] // read block and find next block for tail - : [{}] - : []; // no {, return empty result - -/** Find blocks for each tuple entry */ -type TupleFindBlocks = T extends readonly [infer First, ...infer Rest] - ? [...FindBlocks, ...TupleFindBlocks] - : []; - -/** Read tail until the currently open block is closed. Return the block content and rest of tail */ -type ReadBlock< - Block extends string, - Tail extends string, - Depth extends string -> = Tail extends `${infer L1}}${infer R1}` // find first } - ? L1 extends `${infer L2}{${infer R2}` // if preceeded by {, this opens a nested block - ? ReadBlock<`${Block}${L2}{`, `${R2}}${R1}`, `${Depth}+`> // then continue search right of this { - : Depth extends `+${infer Rest}` // else if depth > 0 - ? ReadBlock<`${Block}${L1}}`, R1, Rest> // then finished nested block, continue search right of first } - : [`${Block}${L1}`, R1] // else return full block and search for next - : []; // no }, return emptry result - -/** Parse block, return variables with types and recursively find nested blocks within */ -type ParseBlock = - Block extends `${infer Name},${infer Format},${infer Rest}` - ? Trim extends 'select' - ? SelectOptions< - Trim, - Trim, - ICUArgument, - ICUNumberArgument, - ICUDateArgument - > - : { - [K in Trim]: VariableType< - Trim, - ICUArgument, - ICUNumberArgument, - ICUDateArgument - >; - } & TupleParseBlock< - TupleFindBlocks>, - ICUArgument, - ICUNumberArgument, - ICUDateArgument - > - : Block extends `${infer Name},${infer Format}` - ? { - [K in Trim]: VariableType< - Trim, - ICUArgument, - ICUNumberArgument, - ICUDateArgument - >; - } - : {[K in Trim]: ICUArgument}; - -/** Parse block for each tuple entry */ -type TupleParseBlock = - T extends readonly [infer First, ...infer Rest] - ? ParseBlock & - TupleParseBlock - : {}; - -type VariableType< - T extends string, - ICUArgument, - ICUNumberArgument, - ICUDateArgument -> = T extends 'number' | 'plural' | 'selectordinal' - ? ICUNumberArgument - : T extends 'date' | 'time' - ? ICUDateArgument - : ICUArgument; - -// Select ////////////////////////////////////////////////////////////////////// - -type SelectOptions< - Name extends string, - Rest, - ICUArgument, - ICUNumberArgument, - ICUDateArgument -> = KeepAndMerge< - ParseSelectBlock ->; - -type ParseSelectBlock< - Name extends string, - Rest, - ICUArgument, - ICUNumberArgument, - ICUDateArgument -> = Rest extends `${infer Left}{${infer Right}` - ? ReadBlock<'', Right, ''> extends [infer Block, infer Tail] - ? - | ({[K in Name]: HandleOther>} & TupleParseBlock< - FindBlocks, - ICUArgument, - ICUNumberArgument, - ICUDateArgument - >) - | ParseSelectBlock< - Name, - Tail, - ICUArgument, - ICUNumberArgument, - ICUDateArgument - > - : never - : never; - -type HandleOther = 'other' extends T ? Exclude | OtherString : T; - -type KeepAndMerge = T | MergeTypeUnion; - -type KeysFromUnion = T extends T ? keyof T : never; - -type SimpleTypeMerge = T extends {[k in K]?: any} - ? T[K] extends OtherString - ? string & {} - : T[K] - : never; - -type MergeTypeUnion = { - [k in KeysFromUnion]: SimpleTypeMerge; -}; - -// Escapes ///////////////////////////////////////////////////////////////////// - -type EscapeLike = `'${'{' | '}' | '<' | '>'}`; -type StripEscapes = T extends `${infer Left}''${infer Right}` - ? `${Left}${Right}` - : T extends `${infer Start}${EscapeLike}${string}'${infer End}` - ? `${Start}${StripEscapes}` - : T extends `${infer Start}${EscapeLike}${string}` - ? Start - : T; - -// Export ////////////////////////////////////////////////////////////////////// - -/** Calculates an object type with all variables and their types in the given ICU format string */ -type ICUArgs< - Message extends string, - ICUArgument, - ICUNumberArgument, - ICUDateArgument -> = +type ICUArgs = // This is important when `t` is returned from a function and there's no // known `Message` yet. Otherwise, we'd run into an infinite loop. - string extends Message - ? {} - : Flatten< - TupleParseBlock< - FindBlocks>, - ICUArgument, - ICUNumberArgument, - ICUDateArgument - > - >; + string extends Message ? {} : GetICUArgs; export default ICUArgs; diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index 9a4b00722..b40994d5b 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -30,7 +30,14 @@ import {Prettify} from './types.tsx'; type ICUArgsWithTags< MessageString extends string, TagsFn extends RichTagsFunction | MarkupTagsFunction = never -> = ICUArgs & +> = ICUArgs< + MessageString, + { + ICUArgument: ICUArg; + ICUNumberArgument: ICUNumber; + ICUDateArgument: ICUDate; + } +> & ([TagsFn] extends [never] ? {} : ICUTags); type OnlyOptional = Partial extends T ? true : false; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95bf8025b..acc223139 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -851,6 +851,9 @@ importers: '@formatjs/fast-memoize': specifier: ^2.2.0 version: 2.2.1 + '@schummar/icu-type-parser': + specifier: 1.21.5 + version: 1.21.5 intl-messageformat: specifier: ^10.5.14 version: 10.7.1 @@ -4323,6 +4326,9 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@schummar/icu-type-parser@1.21.5': + resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -11804,6 +11810,7 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. + (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qrcode-terminal@0.11.0: @@ -18581,6 +18588,8 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@schummar/icu-type-parser@1.21.5': {} + '@sec-ant/readable-stream@0.4.1': {} '@segment/loosely-validate-event@2.0.0': From 7d89d54ff10d510344a8d901bfdd2e6d4f1449ea Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 14 Nov 2024 18:08:55 +0100 Subject: [PATCH 37/90] chore: Strict type imports with ESLint (#1524) --- packages/next-intl/eslint.config.mjs | 8 +++++- .../getAlternateLinksHeaderValue.test.tsx | 2 +- .../getAlternateLinksHeaderValue.tsx | 6 ++-- .../src/middleware/middleware.test.tsx | 12 ++++++-- .../next-intl/src/middleware/middleware.tsx | 6 ++-- .../src/middleware/resolveLocale.tsx | 6 ++-- .../next-intl/src/middleware/syncCookie.tsx | 6 ++-- packages/next-intl/src/middleware/utils.tsx | 2 +- .../src/navigation/createNavigation.test.tsx | 8 ++++-- .../react-client/createNavigation.test.tsx | 5 ++-- .../react-client/createNavigation.tsx | 4 +-- .../react-client/useBasePathname.tsx | 2 +- .../react-server/createNavigation.tsx | 4 +-- .../src/navigation/shared/BaseLink.tsx | 10 +++---- .../shared/createSharedNavigationFns.tsx | 16 +++++------ .../navigation/shared/syncLocaleCookie.tsx | 2 +- .../next-intl/src/navigation/shared/utils.tsx | 6 ++-- .../next-intl/src/plugin/getNextConfig.tsx | 4 +-- .../NextIntlClientProviderServer.tsx | 2 +- .../next-intl/src/react-server/testUtils.tsx | 5 ++-- packages/next-intl/src/routing/config.tsx | 2 +- .../next-intl/src/routing/defineRouting.tsx | 9 ++++-- packages/next-intl/src/routing/types.test.tsx | 2 +- .../src/server/react-server/RequestLocale.tsx | 2 +- .../src/server/react-server/getConfig.tsx | 4 +-- .../src/server/react-server/getFormatter.tsx | 2 +- .../src/server/react-server/getLocale.tsx | 2 +- .../react-server/getServerTranslator.tsx | 6 ++-- .../server/react-server/getTranslations.tsx | 2 +- .../src/shared/NextIntlClientProvider.tsx | 4 +-- packages/next-intl/src/shared/utils.tsx | 4 +-- packages/use-intl/eslint.config.mjs | 8 +++++- .../src/core/DateTimeFormatOptions.tsx | 2 +- packages/use-intl/src/core/Formats.tsx | 4 +-- packages/use-intl/src/core/ICUArgs.tsx | 2 +- packages/use-intl/src/core/IntlError.tsx | 10 +------ packages/use-intl/src/core/IntlErrorCode.tsx | 11 ++++++++ .../use-intl/src/core/TranslationValues.tsx | 6 ++-- .../convertFormatsToIntlMessageFormat.tsx | 4 +-- .../src/core/createBaseTranslator.tsx | 28 ++++++++++--------- .../use-intl/src/core/createFormatter.tsx | 21 +++++++------- .../src/core/createTranslator.test.tsx | 5 ++-- .../use-intl/src/core/createTranslator.tsx | 22 +++++++-------- .../src/core/createTranslatorImpl.tsx | 8 +++--- packages/use-intl/src/core/defaults.tsx | 2 +- packages/use-intl/src/core/index.tsx | 5 ++-- .../use-intl/src/core/initializeConfig.tsx | 2 +- .../use-intl/src/core/validateMessages.tsx | 5 ++-- packages/use-intl/src/react/IntlProvider.tsx | 6 ++-- packages/use-intl/src/react/index.test.tsx | 2 +- .../use-intl/src/react/useFormatter.test.tsx | 6 ++-- .../use-intl/src/react/useIntlContext.tsx | 2 +- packages/use-intl/src/react/useLocale.tsx | 2 +- packages/use-intl/src/react/useMessages.tsx | 2 +- .../src/react/useTranslations.test.tsx | 10 +++---- .../use-intl/src/react/useTranslations.tsx | 4 +-- .../src/react/useTranslationsImpl.tsx | 4 +-- 57 files changed, 187 insertions(+), 151 deletions(-) create mode 100644 packages/use-intl/src/core/IntlErrorCode.tsx diff --git a/packages/next-intl/eslint.config.mjs b/packages/next-intl/eslint.config.mjs index 7ac3af172..a2d92e6aa 100644 --- a/packages/next-intl/eslint.config.mjs +++ b/packages/next-intl/eslint.config.mjs @@ -21,6 +21,12 @@ export default (await getPresets('typescript', 'react', 'vitest')).concat({ } ] } - ] + ], + + // Strict type imports to avoid side effects + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/consistent-type-exports': 'error', + '@typescript-eslint/no-import-type-side-effects': 'error', + 'import/no-duplicates': ['error', {'prefer-inline': true}] } }); diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx index 9c03cac00..562331ec8 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx @@ -3,7 +3,7 @@ import {NextRequest} from 'next/server.js'; import {afterEach, beforeEach, describe, expect, it} from 'vitest'; import {receiveRoutingConfig} from '../routing/config.tsx'; -import {Pathnames} from '../routing.tsx'; +import type {Pathnames} from '../routing.tsx'; import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue.tsx'; describe.each([{basePath: undefined}, {basePath: '/base'}])( diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 1d3f26f96..52f627ba3 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -1,6 +1,6 @@ -import {NextRequest} from 'next/server.js'; -import {ResolvedRoutingConfig} from '../routing/config.tsx'; -import { +import type {NextRequest} from 'next/server.js'; +import type {ResolvedRoutingConfig} from '../routing/config.tsx'; +import type { DomainsConfig, LocalePrefixMode, Locales, diff --git a/packages/next-intl/src/middleware/middleware.test.tsx b/packages/next-intl/src/middleware/middleware.test.tsx index d0923b474..e6ec99ea3 100644 --- a/packages/next-intl/src/middleware/middleware.test.tsx +++ b/packages/next-intl/src/middleware/middleware.test.tsx @@ -3,9 +3,17 @@ import {RequestCookies} from 'next/dist/compiled/@edge-runtime/cookies'; import {NextRequest, NextResponse} from 'next/server.js'; import {pathToRegexp} from 'path-to-regexp'; -import {Mock, afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + type Mock, + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; import createMiddleware from '../middleware.tsx'; -import {Pathnames, defineRouting} from '../routing.tsx'; +import {type Pathnames, defineRouting} from '../routing.tsx'; const COOKIE_LOCALE_NAME = 'NEXT_LOCALE'; diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 0b7be26ab..14662740c 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -1,6 +1,6 @@ -import {NextRequest, NextResponse} from 'next/server.js'; -import {RoutingConfig, receiveRoutingConfig} from '../routing/config.tsx'; -import { +import {type NextRequest, NextResponse} from 'next/server.js'; +import {type RoutingConfig, receiveRoutingConfig} from '../routing/config.tsx'; +import type { DomainsConfig, LocalePrefixMode, Locales, diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index 2719ee2cf..ee4abdea0 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -1,9 +1,9 @@ import {match} from '@formatjs/intl-localematcher'; import Negotiator from 'negotiator'; -import {RequestCookies} from 'next/dist/server/web/spec-extension/cookies.js'; +import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies.js'; import type {Locale} from 'use-intl'; -import {ResolvedRoutingConfig} from '../routing/config.tsx'; -import { +import type {ResolvedRoutingConfig} from '../routing/config.tsx'; +import type { DomainConfig, DomainsConfig, LocalePrefixMode, diff --git a/packages/next-intl/src/middleware/syncCookie.tsx b/packages/next-intl/src/middleware/syncCookie.tsx index 751240c71..b37015cd4 100644 --- a/packages/next-intl/src/middleware/syncCookie.tsx +++ b/packages/next-intl/src/middleware/syncCookie.tsx @@ -1,10 +1,10 @@ -import {NextRequest, NextResponse} from 'next/server.js'; +import type {NextRequest, NextResponse} from 'next/server.js'; import type {Locale} from 'use-intl'; -import { +import type { InitializedLocaleCookieConfig, ResolvedRoutingConfig } from '../routing/config.tsx'; -import { +import type { DomainConfig, DomainsConfig, LocalePrefixMode, diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 67013bd5b..996368ac9 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -1,5 +1,5 @@ import type {Locale} from 'use-intl'; -import { +import type { DomainConfig, DomainsConfig, LocalePrefixConfigVerbose, diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index c8118d154..a6ea4c698 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -5,9 +5,13 @@ import { redirect as nextRedirect } from 'next/navigation.js'; import {renderToString} from 'react-dom/server'; -import {Locale, useLocale} from 'use-intl'; +import {type Locale, useLocale} from 'use-intl'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {DomainsConfig, Pathnames, defineRouting} from '../routing.tsx'; +import { + type DomainsConfig, + type Pathnames, + defineRouting +} from '../routing.tsx'; import createNavigationClient from './react-client/createNavigation.tsx'; import createNavigationServer from './react-server/createNavigation.tsx'; import getServerLocale from './react-server/getServerLocale.tsx'; diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index ef57b97c7..1b51ae378 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -3,10 +3,9 @@ import { usePathname as useNextPathname, useRouter as useNextRouter } from 'next/navigation.js'; -import type {Locale} from 'use-intl'; -import {useLocale} from 'use-intl'; +import {type Locale, useLocale} from 'use-intl'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {DomainsConfig, Pathnames} from '../../routing.tsx'; +import type {DomainsConfig, Pathnames} from '../../routing.tsx'; import createNavigation from './createNavigation.tsx'; vi.mock('next/navigation.js'); diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index 904eab475..7c4fca172 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -4,11 +4,11 @@ import { } from 'next/navigation.js'; import {useMemo} from 'react'; import {type Locale, useLocale} from 'use-intl'; -import { +import type { RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation } from '../../routing/config.tsx'; -import { +import type { DomainsConfig, LocalePrefixMode, Locales, diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx index c5eaa3c7a..caa595041 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx @@ -1,7 +1,7 @@ import {usePathname as useNextPathname} from 'next/navigation.js'; import {useMemo} from 'react'; import {useLocale} from 'use-intl'; -import { +import type { LocalePrefixConfigVerbose, LocalePrefixMode, Locales diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx index 90d7f8721..96c921c58 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -1,8 +1,8 @@ -import { +import type { RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation } from '../../routing/config.tsx'; -import { +import type { DomainsConfig, LocalePrefixMode, Locales, diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index bd531725e..10aa07c93 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -1,17 +1,17 @@ 'use client'; -import NextLink, {LinkProps} from 'next/link.js'; +import NextLink, {type LinkProps} from 'next/link.js'; import {usePathname} from 'next/navigation.js'; import { - ComponentProps, - MouseEvent, - Ref, + type ComponentProps, + type MouseEvent, + type Ref, forwardRef, useEffect, useState } from 'react'; import {type Locale, useLocale} from 'use-intl'; -import {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; +import type {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; import syncLocaleCookie from './syncLocaleCookie.tsx'; type NextLinkProps = Omit, keyof LinkProps> & diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index c2fe5a5fa..f198b1d36 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -2,28 +2,28 @@ import { permanentRedirect as nextPermanentRedirect, redirect as nextRedirect } from 'next/navigation.js'; -import {ComponentProps, forwardRef} from 'react'; +import {type ComponentProps, forwardRef} from 'react'; import type {Locale} from 'use-intl'; import { - RoutingConfigLocalizedNavigation, - RoutingConfigSharedNavigation, + type RoutingConfigLocalizedNavigation, + type RoutingConfigSharedNavigation, receiveRoutingConfig } from '../../routing/config.tsx'; -import { +import type { DomainConfig, DomainsConfig, LocalePrefixMode, Locales, Pathnames } from '../../routing/types.tsx'; -import {ParametersExceptFirst, Prettify} from '../../shared/types.tsx'; +import type {ParametersExceptFirst, Prettify} from '../../shared/types.tsx'; import use from '../../shared/use.tsx'; import {isLocalizableHref} from '../../shared/utils.tsx'; import BaseLink from './BaseLink.tsx'; import { - HrefOrHrefWithParams, - HrefOrUrlObjectWithParams, - QueryParams, + type HrefOrHrefWithParams, + type HrefOrUrlObjectWithParams, + type QueryParams, applyPathnamePrefix, compileLocalizedPathname, normalizeNameOrNameWithParams, diff --git a/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx b/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx index 5b2d95722..41b42531a 100644 --- a/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx +++ b/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx @@ -1,5 +1,5 @@ import type {Locale} from 'use-intl'; -import {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; +import type {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; import {getBasePath} from './utils.tsx'; /** diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 3845920be..777ed2806 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,8 +1,8 @@ import type {ParsedUrlQueryInput} from 'node:querystring'; import type {UrlObject} from 'url'; import type {Locale} from 'use-intl'; -import {ResolvedRoutingConfig} from '../../routing/config.tsx'; -import { +import type {ResolvedRoutingConfig} from '../../routing/config.tsx'; +import type { DomainsConfig, LocalePrefixMode, Locales, @@ -16,7 +16,7 @@ import { normalizeTrailingSlash, prefixPathname } from '../../shared/utils.tsx'; -import StrictParams from './StrictParams.tsx'; +import type StrictParams from './StrictParams.tsx'; type SearchParamValue = ParsedUrlQueryInput[keyof ParsedUrlQueryInput]; diff --git a/packages/next-intl/src/plugin/getNextConfig.tsx b/packages/next-intl/src/plugin/getNextConfig.tsx index c77f23f45..f417a0028 100644 --- a/packages/next-intl/src/plugin/getNextConfig.tsx +++ b/packages/next-intl/src/plugin/getNextConfig.tsx @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; -import {NextConfig} from 'next'; -import {PluginConfig} from './types.tsx'; +import type {NextConfig} from 'next'; +import type {PluginConfig} from './types.tsx'; import {throwError} from './utils.tsx'; function withExtensions(localPath: string) { diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx index d3da7c102..abf19ee30 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx @@ -1,4 +1,4 @@ -import {ComponentProps} from 'react'; +import type {ComponentProps} from 'react'; import getConfigNow from '../server/react-server/getConfigNow.tsx'; import getFormats from '../server/react-server/getFormats.tsx'; import {getLocale, getTimeZone} from '../server.react-server.tsx'; diff --git a/packages/next-intl/src/react-server/testUtils.tsx b/packages/next-intl/src/react-server/testUtils.tsx index b588b6867..09ea87257 100644 --- a/packages/next-intl/src/react-server/testUtils.tsx +++ b/packages/next-intl/src/react-server/testUtils.tsx @@ -1,8 +1,9 @@ -import {ReactNode, Suspense} from 'react'; -import {ReactDOMServerReadableStream} from 'react-dom/server'; +import {type ReactNode, Suspense} from 'react'; +import type {ReactDOMServerReadableStream} from 'react-dom/server'; // @ts-expect-error -- Not available in types import {renderToReadableStream as _renderToReadableStream} from 'react-dom/server.browser'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports const renderToReadableStream: typeof import('react-dom/server').renderToReadableStream = _renderToReadableStream; diff --git a/packages/next-intl/src/routing/config.tsx b/packages/next-intl/src/routing/config.tsx index 3a8d018fd..89f9e22a2 100644 --- a/packages/next-intl/src/routing/config.tsx +++ b/packages/next-intl/src/routing/config.tsx @@ -1,5 +1,5 @@ import type {NextResponse} from 'next/server.js'; -import { +import type { DomainsConfig, LocalePrefix, LocalePrefixConfigVerbose, diff --git a/packages/next-intl/src/routing/defineRouting.tsx b/packages/next-intl/src/routing/defineRouting.tsx index f8cc60781..b975a519f 100644 --- a/packages/next-intl/src/routing/defineRouting.tsx +++ b/packages/next-intl/src/routing/defineRouting.tsx @@ -1,5 +1,10 @@ -import {RoutingConfig} from './config.tsx'; -import {DomainsConfig, LocalePrefixMode, Locales, Pathnames} from './types.tsx'; +import type {RoutingConfig} from './config.tsx'; +import type { + DomainsConfig, + LocalePrefixMode, + Locales, + Pathnames +} from './types.tsx'; export default function defineRouting< const AppLocales extends Locales, diff --git a/packages/next-intl/src/routing/types.test.tsx b/packages/next-intl/src/routing/types.test.tsx index b5caa36cc..5b93b4deb 100644 --- a/packages/next-intl/src/routing/types.test.tsx +++ b/packages/next-intl/src/routing/types.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import {describe, it} from 'vitest'; -import {DomainConfig, LocalePrefix} from './types.tsx'; +import type {DomainConfig, LocalePrefix} from './types.tsx'; describe('LocalePrefix', () => { it('does not require a type param for simple values', () => { diff --git a/packages/next-intl/src/server/react-server/RequestLocale.tsx b/packages/next-intl/src/server/react-server/RequestLocale.tsx index 12b18c627..c045a275b 100644 --- a/packages/next-intl/src/server/react-server/RequestLocale.tsx +++ b/packages/next-intl/src/server/react-server/RequestLocale.tsx @@ -1,6 +1,6 @@ import {headers} from 'next/headers.js'; import {cache} from 'react'; -import {Locale} from 'use-intl'; +import type {Locale} from 'use-intl'; import {HEADER_LOCALE_NAME} from '../../shared/constants.tsx'; import {getCachedRequestLocale} from './RequestLocaleCache.tsx'; diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index 495e5423d..f1be1c66e 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -1,6 +1,6 @@ import {cache} from 'react'; import { - IntlConfig, + type IntlConfig, type Locale, _createCache, _createIntlFormatters, @@ -8,7 +8,7 @@ import { } from 'use-intl/core'; import {getRequestLocale} from './RequestLocale.tsx'; import createRequestConfig from './createRequestConfig.tsx'; -import {GetRequestConfigParams} from './getRequestConfig.tsx'; +import type {GetRequestConfigParams} from './getRequestConfig.tsx'; // This is automatically inherited by `NextIntlClientProvider` if // the component is rendered from a Server Component diff --git a/packages/next-intl/src/server/react-server/getFormatter.tsx b/packages/next-intl/src/server/react-server/getFormatter.tsx index 1ba57e878..f90c99ea8 100644 --- a/packages/next-intl/src/server/react-server/getFormatter.tsx +++ b/packages/next-intl/src/server/react-server/getFormatter.tsx @@ -1,5 +1,5 @@ import {cache} from 'react'; -import {type Locale, createFormatter} from 'use-intl/core'; +import type {Locale, createFormatter} from 'use-intl/core'; import getConfig from './getConfig.tsx'; import getServerFormatter from './getServerFormatter.tsx'; diff --git a/packages/next-intl/src/server/react-server/getLocale.tsx b/packages/next-intl/src/server/react-server/getLocale.tsx index caea190f8..4911306c1 100644 --- a/packages/next-intl/src/server/react-server/getLocale.tsx +++ b/packages/next-intl/src/server/react-server/getLocale.tsx @@ -1,5 +1,5 @@ import {cache} from 'react'; -import {Locale} from 'use-intl'; +import type {Locale} from 'use-intl'; import getConfig from './getConfig.tsx'; async function getLocaleCachedImpl(): Promise { diff --git a/packages/next-intl/src/server/react-server/getServerTranslator.tsx b/packages/next-intl/src/server/react-server/getServerTranslator.tsx index aec9d4a38..2c72d673f 100644 --- a/packages/next-intl/src/server/react-server/getServerTranslator.tsx +++ b/packages/next-intl/src/server/react-server/getServerTranslator.tsx @@ -1,8 +1,8 @@ import {cache} from 'react'; import { - Messages, - NamespaceKeys, - NestedKeyOf, + type Messages, + type NamespaceKeys, + type NestedKeyOf, createTranslator } from 'use-intl/core'; diff --git a/packages/next-intl/src/server/react-server/getTranslations.tsx b/packages/next-intl/src/server/react-server/getTranslations.tsx index 5615c2e46..42049bf45 100644 --- a/packages/next-intl/src/server/react-server/getTranslations.tsx +++ b/packages/next-intl/src/server/react-server/getTranslations.tsx @@ -1,5 +1,5 @@ import {cache} from 'react'; -import { +import type { Locale, Messages, NamespaceKeys, diff --git a/packages/next-intl/src/shared/NextIntlClientProvider.tsx b/packages/next-intl/src/shared/NextIntlClientProvider.tsx index d079dd428..6fde4ab3d 100644 --- a/packages/next-intl/src/shared/NextIntlClientProvider.tsx +++ b/packages/next-intl/src/shared/NextIntlClientProvider.tsx @@ -1,7 +1,7 @@ 'use client'; -import {ComponentProps} from 'react'; -import {type Locale} from 'use-intl'; +import type {ComponentProps} from 'react'; +import type {Locale} from 'use-intl'; import {IntlProvider} from 'use-intl/react'; type Props = Omit, 'locale'> & { diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index b2b4d219a..9331941e0 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -1,5 +1,5 @@ -import {LinkProps} from 'next/link.js'; -import { +import type {LinkProps} from 'next/link.js'; +import type { LocalePrefixConfigVerbose, LocalePrefixMode, Locales diff --git a/packages/use-intl/eslint.config.mjs b/packages/use-intl/eslint.config.mjs index 92ba53b1f..48b5300b4 100644 --- a/packages/use-intl/eslint.config.mjs +++ b/packages/use-intl/eslint.config.mjs @@ -6,6 +6,12 @@ export default (await getPresets('typescript', 'react', 'vitest')).concat({ 'react-compiler': reactCompilerPlugin }, rules: { - 'react-compiler/react-compiler': 'error' + 'react-compiler/react-compiler': 'error', + + // Strict type imports to avoid side effects + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/consistent-type-exports': 'error', + '@typescript-eslint/no-import-type-side-effects': 'error', + 'import/no-duplicates': ['error', {'prefer-inline': true}] } }); diff --git a/packages/use-intl/src/core/DateTimeFormatOptions.tsx b/packages/use-intl/src/core/DateTimeFormatOptions.tsx index 1452b535a..6fca63f22 100644 --- a/packages/use-intl/src/core/DateTimeFormatOptions.tsx +++ b/packages/use-intl/src/core/DateTimeFormatOptions.tsx @@ -1,6 +1,6 @@ // https://github.com/microsoft/TypeScript/issues/35865 -import TimeZone from './TimeZone.tsx'; +import type TimeZone from './TimeZone.tsx'; /** * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat diff --git a/packages/use-intl/src/core/Formats.tsx b/packages/use-intl/src/core/Formats.tsx index f4fcf2f3d..96eb1e6be 100644 --- a/packages/use-intl/src/core/Formats.tsx +++ b/packages/use-intl/src/core/Formats.tsx @@ -1,5 +1,5 @@ -import DateTimeFormatOptions from './DateTimeFormatOptions.tsx'; -import NumberFormatOptions from './NumberFormatOptions.tsx'; +import type DateTimeFormatOptions from './DateTimeFormatOptions.tsx'; +import type NumberFormatOptions from './NumberFormatOptions.tsx'; type Formats = { number?: Record; diff --git a/packages/use-intl/src/core/ICUArgs.tsx b/packages/use-intl/src/core/ICUArgs.tsx index 096fcef1a..336806a23 100644 --- a/packages/use-intl/src/core/ICUArgs.tsx +++ b/packages/use-intl/src/core/ICUArgs.tsx @@ -1,6 +1,6 @@ // schummar is the best, he published his ICU type parser for next-intl: // https://github.com/schummar/schummar-translate/issues/28 -import {GetICUArgs, GetICUArgsOptions} from '@schummar/icu-type-parser'; +import type {GetICUArgs, GetICUArgsOptions} from '@schummar/icu-type-parser'; type ICUArgs = // This is important when `t` is returned from a function and there's no diff --git a/packages/use-intl/src/core/IntlError.tsx b/packages/use-intl/src/core/IntlError.tsx index a04b3c584..aa25d0385 100644 --- a/packages/use-intl/src/core/IntlError.tsx +++ b/packages/use-intl/src/core/IntlError.tsx @@ -1,12 +1,4 @@ -export const enum IntlErrorCode { - MISSING_MESSAGE = 'MISSING_MESSAGE', - MISSING_FORMAT = 'MISSING_FORMAT', - ENVIRONMENT_FALLBACK = 'ENVIRONMENT_FALLBACK', - INSUFFICIENT_PATH = 'INSUFFICIENT_PATH', - INVALID_MESSAGE = 'INVALID_MESSAGE', - INVALID_KEY = 'INVALID_KEY', - FORMATTING_ERROR = 'FORMATTING_ERROR' -} +import type IntlErrorCode from './IntlErrorCode.tsx'; export default class IntlError extends Error { public readonly code: IntlErrorCode; diff --git a/packages/use-intl/src/core/IntlErrorCode.tsx b/packages/use-intl/src/core/IntlErrorCode.tsx new file mode 100644 index 000000000..a3f23a5b3 --- /dev/null +++ b/packages/use-intl/src/core/IntlErrorCode.tsx @@ -0,0 +1,11 @@ +const enum IntlErrorCode { + MISSING_MESSAGE = 'MISSING_MESSAGE', + MISSING_FORMAT = 'MISSING_FORMAT', + ENVIRONMENT_FALLBACK = 'ENVIRONMENT_FALLBACK', + INSUFFICIENT_PATH = 'INSUFFICIENT_PATH', + INVALID_MESSAGE = 'INVALID_MESSAGE', + INVALID_KEY = 'INVALID_KEY', + FORMATTING_ERROR = 'FORMATTING_ERROR' +} + +export default IntlErrorCode; diff --git a/packages/use-intl/src/core/TranslationValues.tsx b/packages/use-intl/src/core/TranslationValues.tsx index 340124343..3c290a897 100644 --- a/packages/use-intl/src/core/TranslationValues.tsx +++ b/packages/use-intl/src/core/TranslationValues.tsx @@ -1,11 +1,11 @@ -import {ReactNode} from 'react'; +import type {ReactNode} from 'react'; // These type names are shown to consumers in autocomplete export type ICUArg = string | number | boolean | Date; export type ICUNumber = number; export type ICUDate = Date | number | string; -type TranslationValues = Record; +export type TranslationValues = Record; export type RichTagsFunction = (chunks: ReactNode) => ReactNode; export type MarkupTagsFunction = (chunks: string) => string; @@ -19,5 +19,3 @@ export type MarkupTranslationValues = Record< string, ICUArg | MarkupTagsFunction >; - -export default TranslationValues; diff --git a/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx b/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx index b42df9339..71c76e64b 100644 --- a/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx +++ b/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx @@ -2,8 +2,8 @@ import { type Formats as IntlFormats, IntlMessageFormat } from 'intl-messageformat'; -import Formats from './Formats.tsx'; -import TimeZone from './TimeZone.tsx'; +import type Formats from './Formats.tsx'; +import type TimeZone from './TimeZone.tsx'; /** * `intl-messageformat` uses separate keys for `date` and `time`, but there's diff --git a/packages/use-intl/src/core/createBaseTranslator.tsx b/packages/use-intl/src/core/createBaseTranslator.tsx index c98ea299e..313e3c39e 100644 --- a/packages/use-intl/src/core/createBaseTranslator.tsx +++ b/packages/use-intl/src/core/createBaseTranslator.tsx @@ -1,22 +1,24 @@ import {IntlMessageFormat} from 'intl-messageformat'; -import {ReactNode, cloneElement, isValidElement} from 'react'; -import AbstractIntlMessages from './AbstractIntlMessages.tsx'; -import {Locale} from './AppConfig.tsx'; -import Formats from './Formats.tsx'; -import {InitializedIntlConfig} from './IntlConfig.tsx'; -import IntlError, {IntlErrorCode} from './IntlError.tsx'; -import {MessageKeys, NestedKeyOf, NestedValueOf} from './MessageKeys.tsx'; -import TranslationValues, { +import {type ReactNode, cloneElement, isValidElement} from 'react'; +import type AbstractIntlMessages from './AbstractIntlMessages.tsx'; +import type {Locale} from './AppConfig.tsx'; +import type Formats from './Formats.tsx'; +import type {InitializedIntlConfig} from './IntlConfig.tsx'; +import IntlError from './IntlError.tsx'; +import IntlErrorCode from './IntlErrorCode.tsx'; +import type {MessageKeys, NestedKeyOf, NestedValueOf} from './MessageKeys.tsx'; +import type { MarkupTranslationValues, - RichTranslationValues + RichTranslationValues, + TranslationValues } from './TranslationValues.tsx'; import convertFormatsToIntlMessageFormat from './convertFormatsToIntlMessageFormat.tsx'; import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; import { - Formatters, - IntlCache, - IntlFormatters, - MessageFormatter, + type Formatters, + type IntlCache, + type IntlFormatters, + type MessageFormatter, memoFn } from './formatters.tsx'; import joinPath from './joinPath.tsx'; diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index 0990a7df6..958ed1d43 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -1,15 +1,16 @@ -import {ReactElement} from 'react'; -import {FormatNames, Locale} from './AppConfig.tsx'; -import DateTimeFormatOptions from './DateTimeFormatOptions.tsx'; -import Formats from './Formats.tsx'; -import IntlError, {IntlErrorCode} from './IntlError.tsx'; -import NumberFormatOptions from './NumberFormatOptions.tsx'; -import RelativeTimeFormatOptions from './RelativeTimeFormatOptions.tsx'; -import TimeZone from './TimeZone.tsx'; +import type {ReactElement} from 'react'; +import type {FormatNames, Locale} from './AppConfig.tsx'; +import type DateTimeFormatOptions from './DateTimeFormatOptions.tsx'; +import type Formats from './Formats.tsx'; +import IntlError from './IntlError.tsx'; +import IntlErrorCode from './IntlErrorCode.tsx'; +import type NumberFormatOptions from './NumberFormatOptions.tsx'; +import type RelativeTimeFormatOptions from './RelativeTimeFormatOptions.tsx'; +import type TimeZone from './TimeZone.tsx'; import {defaultOnError} from './defaults.tsx'; import { - Formatters, - IntlCache, + type Formatters, + type IntlCache, createCache, createIntlFormatters } from './formatters.tsx'; diff --git a/packages/use-intl/src/core/createTranslator.test.tsx b/packages/use-intl/src/core/createTranslator.test.tsx index abd81361f..1271fbbc6 100644 --- a/packages/use-intl/src/core/createTranslator.test.tsx +++ b/packages/use-intl/src/core/createTranslator.test.tsx @@ -1,8 +1,9 @@ import {isValidElement} from 'react'; import {renderToString} from 'react-dom/server'; import {describe, expect, it, vi} from 'vitest'; -import {Messages} from './AppConfig.tsx'; -import IntlError, {IntlErrorCode} from './IntlError.tsx'; +import type {Messages} from './AppConfig.tsx'; +import type IntlError from './IntlError.tsx'; +import IntlErrorCode from './IntlErrorCode.tsx'; import createTranslator from './createTranslator.tsx'; const messages = { diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index b40994d5b..05fe5620a 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -1,16 +1,16 @@ -import {ReactNode} from 'react'; -import {Messages} from './AppConfig.tsx'; -import Formats from './Formats.tsx'; -import ICUArgs from './ICUArgs.tsx'; -import ICUTags from './ICUTags.tsx'; -import IntlConfig from './IntlConfig.tsx'; -import { +import type {ReactNode} from 'react'; +import type {Messages} from './AppConfig.tsx'; +import type Formats from './Formats.tsx'; +import type ICUArgs from './ICUArgs.tsx'; +import type ICUTags from './ICUTags.tsx'; +import type IntlConfig from './IntlConfig.tsx'; +import type { MessageKeys, NamespaceKeys, NestedKeyOf, NestedValueOf } from './MessageKeys.tsx'; -import { +import type { ICUArg, ICUDate, ICUNumber, @@ -20,12 +20,12 @@ import { import createTranslatorImpl from './createTranslatorImpl.tsx'; import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; import { - Formatters, - IntlCache, + type Formatters, + type IntlCache, createCache, createIntlFormatters } from './formatters.tsx'; -import {Prettify} from './types.tsx'; +import type {Prettify} from './types.tsx'; type ICUArgsWithTags< MessageString extends string, diff --git a/packages/use-intl/src/core/createTranslatorImpl.tsx b/packages/use-intl/src/core/createTranslatorImpl.tsx index 3f2897fb4..a87097776 100644 --- a/packages/use-intl/src/core/createTranslatorImpl.tsx +++ b/packages/use-intl/src/core/createTranslatorImpl.tsx @@ -1,8 +1,8 @@ -import AbstractIntlMessages from './AbstractIntlMessages.tsx'; -import {InitializedIntlConfig} from './IntlConfig.tsx'; -import {NestedKeyOf} from './MessageKeys.tsx'; +import type AbstractIntlMessages from './AbstractIntlMessages.tsx'; +import type {InitializedIntlConfig} from './IntlConfig.tsx'; +import type {NestedKeyOf} from './MessageKeys.tsx'; import createBaseTranslator from './createBaseTranslator.tsx'; -import {Formatters, IntlCache} from './formatters.tsx'; +import type {Formatters, IntlCache} from './formatters.tsx'; import resolveNamespace from './resolveNamespace.tsx'; export type CreateTranslatorImplProps = Omit< diff --git a/packages/use-intl/src/core/defaults.tsx b/packages/use-intl/src/core/defaults.tsx index df8caa020..f86619763 100644 --- a/packages/use-intl/src/core/defaults.tsx +++ b/packages/use-intl/src/core/defaults.tsx @@ -1,4 +1,4 @@ -import IntlError from './IntlError.tsx'; +import type IntlError from './IntlError.tsx'; import joinPath from './joinPath.tsx'; /** diff --git a/packages/use-intl/src/core/index.tsx b/packages/use-intl/src/core/index.tsx index f9d4fc902..97627d2c5 100644 --- a/packages/use-intl/src/core/index.tsx +++ b/packages/use-intl/src/core/index.tsx @@ -1,6 +1,6 @@ export type {default as AbstractIntlMessages} from './AbstractIntlMessages.tsx'; export type { - default as TranslationValues, + TranslationValues, RichTranslationValues, MarkupTranslationValues, ICUArg, @@ -13,7 +13,8 @@ export type {default as Formats} from './Formats.tsx'; export type {default as IntlConfig} from './IntlConfig.tsx'; export type {default as DateTimeFormatOptions} from './DateTimeFormatOptions.tsx'; export type {default as NumberFormatOptions} from './NumberFormatOptions.tsx'; -export {default as IntlError, IntlErrorCode} from './IntlError.tsx'; +export {default as IntlError} from './IntlError.tsx'; +export {default as IntlErrorCode} from './IntlErrorCode.tsx'; export {default as createTranslator} from './createTranslator.tsx'; export {default as createFormatter} from './createFormatter.tsx'; export {default as initializeConfig} from './initializeConfig.tsx'; diff --git a/packages/use-intl/src/core/initializeConfig.tsx b/packages/use-intl/src/core/initializeConfig.tsx index 35a0679ff..3a52d6c6b 100644 --- a/packages/use-intl/src/core/initializeConfig.tsx +++ b/packages/use-intl/src/core/initializeConfig.tsx @@ -1,4 +1,4 @@ -import IntlConfig from './IntlConfig.tsx'; +import type IntlConfig from './IntlConfig.tsx'; import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; import validateMessages from './validateMessages.tsx'; diff --git a/packages/use-intl/src/core/validateMessages.tsx b/packages/use-intl/src/core/validateMessages.tsx index 5131a67d2..d8f4b31f0 100644 --- a/packages/use-intl/src/core/validateMessages.tsx +++ b/packages/use-intl/src/core/validateMessages.tsx @@ -1,5 +1,6 @@ -import AbstractIntlMessages from './AbstractIntlMessages.tsx'; -import IntlError, {IntlErrorCode} from './IntlError.tsx'; +import type AbstractIntlMessages from './AbstractIntlMessages.tsx'; +import IntlError from './IntlError.tsx'; +import IntlErrorCode from './IntlErrorCode.tsx'; import joinPath from './joinPath.tsx'; function validateMessagesSegment( diff --git a/packages/use-intl/src/react/IntlProvider.tsx b/packages/use-intl/src/react/IntlProvider.tsx index 77b35362b..58e5e1abc 100644 --- a/packages/use-intl/src/react/IntlProvider.tsx +++ b/packages/use-intl/src/react/IntlProvider.tsx @@ -1,7 +1,7 @@ -import {ReactNode, useContext, useMemo} from 'react'; -import IntlConfig from '../core/IntlConfig.tsx'; +import {type ReactNode, useContext, useMemo} from 'react'; +import type IntlConfig from '../core/IntlConfig.tsx'; import { - Formatters, + type Formatters, createCache, createIntlFormatters } from '../core/formatters.tsx'; diff --git a/packages/use-intl/src/react/index.test.tsx b/packages/use-intl/src/react/index.test.tsx index d8f9de37e..225aa933b 100644 --- a/packages/use-intl/src/react/index.test.tsx +++ b/packages/use-intl/src/react/index.test.tsx @@ -1,7 +1,7 @@ import {render, screen} from '@testing-library/react'; import {parseISO} from 'date-fns'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {Locale} from '../core.tsx'; +import type {Locale} from '../core.tsx'; import IntlProvider from './IntlProvider.tsx'; import useFormatter from './useFormatter.tsx'; import useNow from './useNow.tsx'; diff --git a/packages/use-intl/src/react/useFormatter.test.tsx b/packages/use-intl/src/react/useFormatter.test.tsx index 26b7b8a76..df0c69fbc 100644 --- a/packages/use-intl/src/react/useFormatter.test.tsx +++ b/packages/use-intl/src/react/useFormatter.test.tsx @@ -1,9 +1,9 @@ import {render, screen} from '@testing-library/react'; import {parseISO} from 'date-fns'; -import {ComponentProps, ReactElement, ReactNode} from 'react'; -import {SpyImpl, spyOn} from 'tinyspy'; +import type {ComponentProps, ReactElement, ReactNode} from 'react'; +import {type SpyImpl, spyOn} from 'tinyspy'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {IntlError, IntlErrorCode} from '../core.tsx'; +import {type IntlError, IntlErrorCode} from '../core.tsx'; import IntlProvider from './IntlProvider.tsx'; import useFormatter from './useFormatter.tsx'; diff --git a/packages/use-intl/src/react/useIntlContext.tsx b/packages/use-intl/src/react/useIntlContext.tsx index aa1312800..8fc27c395 100644 --- a/packages/use-intl/src/react/useIntlContext.tsx +++ b/packages/use-intl/src/react/useIntlContext.tsx @@ -1,5 +1,5 @@ import {useContext} from 'react'; -import IntlContext, {IntlContextValue} from './IntlContext.tsx'; +import IntlContext, {type IntlContextValue} from './IntlContext.tsx'; export default function useIntlContext(): IntlContextValue { const context = useContext(IntlContext); diff --git a/packages/use-intl/src/react/useLocale.tsx b/packages/use-intl/src/react/useLocale.tsx index e6f4817d4..621b18847 100644 --- a/packages/use-intl/src/react/useLocale.tsx +++ b/packages/use-intl/src/react/useLocale.tsx @@ -1,4 +1,4 @@ -import {Locale} from '../core.tsx'; +import type {Locale} from '../core.tsx'; import useIntlContext from './useIntlContext.tsx'; export default function useLocale(): Locale { diff --git a/packages/use-intl/src/react/useMessages.tsx b/packages/use-intl/src/react/useMessages.tsx index deea71101..4812fc49e 100644 --- a/packages/use-intl/src/react/useMessages.tsx +++ b/packages/use-intl/src/react/useMessages.tsx @@ -1,4 +1,4 @@ -import {Messages} from '../core/AppConfig.tsx'; +import type {Messages} from '../core/AppConfig.tsx'; import useIntlContext from './useIntlContext.tsx'; export default function useMessages(): Messages { diff --git a/packages/use-intl/src/react/useTranslations.test.tsx b/packages/use-intl/src/react/useTranslations.test.tsx index 1a5193b91..30c7aca2b 100644 --- a/packages/use-intl/src/react/useTranslations.test.tsx +++ b/packages/use-intl/src/react/useTranslations.test.tsx @@ -1,14 +1,14 @@ import {render, renderHook, screen} from '@testing-library/react'; import {parseISO} from 'date-fns'; import {IntlMessageFormat} from 'intl-messageformat'; -import {ComponentProps, PropsWithChildren, ReactNode} from 'react'; +import type {ComponentProps, PropsWithChildren, ReactNode} from 'react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import { - Formats, - IntlError, + type Formats, + type IntlError, IntlErrorCode, - RichTranslationValues, - TranslationValues + type RichTranslationValues, + type TranslationValues } from '../core.tsx'; import IntlProvider from './IntlProvider.tsx'; import useTranslations from './useTranslations.tsx'; diff --git a/packages/use-intl/src/react/useTranslations.tsx b/packages/use-intl/src/react/useTranslations.tsx index 0a5b7266f..57d42b11b 100644 --- a/packages/use-intl/src/react/useTranslations.tsx +++ b/packages/use-intl/src/react/useTranslations.tsx @@ -1,5 +1,5 @@ -import {Messages} from '../core/AppConfig.tsx'; -import {NamespaceKeys, NestedKeyOf} from '../core/MessageKeys.tsx'; +import type {Messages} from '../core/AppConfig.tsx'; +import type {NamespaceKeys, NestedKeyOf} from '../core/MessageKeys.tsx'; import type createTranslator from '../core/createTranslator.tsx'; import useIntlContext from './useIntlContext.tsx'; import useTranslationsImpl from './useTranslationsImpl.tsx'; diff --git a/packages/use-intl/src/react/useTranslationsImpl.tsx b/packages/use-intl/src/react/useTranslationsImpl.tsx index 742dad2a7..32a11eb56 100644 --- a/packages/use-intl/src/react/useTranslationsImpl.tsx +++ b/packages/use-intl/src/react/useTranslationsImpl.tsx @@ -1,6 +1,6 @@ import {useMemo} from 'react'; -import AbstractIntlMessages from '../core/AbstractIntlMessages.tsx'; -import {NestedKeyOf} from '../core/MessageKeys.tsx'; +import type AbstractIntlMessages from '../core/AbstractIntlMessages.tsx'; +import type {NestedKeyOf} from '../core/MessageKeys.tsx'; import createBaseTranslator from '../core/createBaseTranslator.tsx'; import resolveNamespace from '../core/resolveNamespace.tsx'; import {IntlError, IntlErrorCode} from '../core.tsx'; From 00a79d480819bd7ced7c9ddb359c39affba6447e Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 15 Nov 2024 11:36:50 +0100 Subject: [PATCH 38/90] feat: publish v4 prerelease --- .github/workflows/prerelease-v4.yml | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/prerelease-v4.yml diff --git a/.github/workflows/prerelease-v4.yml b/.github/workflows/prerelease-v4.yml new file mode 100644 index 000000000..9a29b05d6 --- /dev/null +++ b/.github/workflows/prerelease-v4.yml @@ -0,0 +1,33 @@ +name: prerelease + +on: + push: + branches: + - v4 + +jobs: + main: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + registry-url: 'https://registry.npmjs.org' + node-version: 20.x + cache: 'pnpm' + - run: pnpm install + - run: | + git config --global user.name "${{ github.actor }}" + git config --global user.email "${{ github.actor }}@users.noreply.github.com" + - run: | + sed -i 's/"use-intl": "workspace:\^"/"use-intl": "workspace:"/' ./packages/next-intl/package.json && git commit -am "use fixed version" + - run: pnpm lerna publish 4.0.0-beta-${GITHUB_SHA::7} --no-git-reset --dist-tag v4 --no-push --yes + if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ')}}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true From ba180785ecb94c53bfdd1c92f075ad84801f72dd Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 15 Nov 2024 11:37:08 +0100 Subject: [PATCH 39/90] rename canary workflow --- .github/workflows/{prerelease.yml => prerelease-canary.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{prerelease.yml => prerelease-canary.yml} (100%) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease-canary.yml similarity index 100% rename from .github/workflows/prerelease.yml rename to .github/workflows/prerelease-canary.yml From 6f1a39dda320d47555bfe8e8e9f104bbce265d4e Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 15 Nov 2024 11:37:44 +0100 Subject: [PATCH 40/90] unique workflow names [skip ci] --- .github/workflows/prerelease-canary.yml | 2 +- .github/workflows/prerelease-v4.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/prerelease-canary.yml b/.github/workflows/prerelease-canary.yml index 19a49df2a..1de7720a4 100644 --- a/.github/workflows/prerelease-canary.yml +++ b/.github/workflows/prerelease-canary.yml @@ -1,4 +1,4 @@ -name: prerelease +name: prerelease (canary) on: push: diff --git a/.github/workflows/prerelease-v4.yml b/.github/workflows/prerelease-v4.yml index 9a29b05d6..af06aaf11 100644 --- a/.github/workflows/prerelease-v4.yml +++ b/.github/workflows/prerelease-v4.yml @@ -1,4 +1,4 @@ -name: prerelease +name: prerelease (v4) on: push: From 486a7bdc7e4668ef66499e68d71670861ee6b9d3 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 15 Nov 2024 14:22:15 +0100 Subject: [PATCH 41/90] docs: minor fixes --- docs/src/pages/docs/usage/dates-times.mdx | 2 +- docs/src/pages/docs/workflows/typescript.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/pages/docs/usage/dates-times.mdx b/docs/src/pages/docs/usage/dates-times.mdx index 8ad9ceccc..71c90acaf 100644 --- a/docs/src/pages/docs/usage/dates-times.mdx +++ b/docs/src/pages/docs/usage/dates-times.mdx @@ -94,7 +94,7 @@ function FormattedDate({date}) { In contrast to simply calling `new Date()` in your component, `useNow` has some benefits: -1. The returned value is consistent across re-renders. +1. The returned value is consistent across re-renders on the client side. 2. The value can optionally be [updated continuously](#relative-times-update) based on an interval. 3. The value can optionally be initialized from a [global value](/docs/usage/configuration#now), e.g. allowing you to use a static `now` value to ensure consistency when running tests. diff --git a/docs/src/pages/docs/workflows/typescript.mdx b/docs/src/pages/docs/workflows/typescript.mdx index e48ded0e9..088abf28b 100644 --- a/docs/src/pages/docs/workflows/typescript.mdx +++ b/docs/src/pages/docs/workflows/typescript.mdx @@ -157,7 +157,7 @@ import {createNextIntlPlugin} from 'next-intl/plugin'; const withNextIntl = createNextIntlPlugin({ experimental: { - // Use the path to the messages that you're using in `AppConfig` + // Provide the path to the messages that you're using in `AppConfig` createMessagesDeclaration: './messages/en.json' } // ... From dfa5ea0ed8471ec638ebab62715ba42a4e170109 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 18 Nov 2024 16:46:57 +0100 Subject: [PATCH 42/90] docs: blog post draft --- docs/src/pages/blog/index.mdx | 6 + docs/src/pages/blog/next-intl-4-0.mdx | 234 +++++++++++++++++++ docs/src/pages/docs/workflows/typescript.mdx | 28 +-- 3 files changed, 254 insertions(+), 14 deletions(-) create mode 100644 docs/src/pages/blog/next-intl-4-0.mdx diff --git a/docs/src/pages/blog/index.mdx b/docs/src/pages/blog/index.mdx index 9640e7360..84d1c6da5 100644 --- a/docs/src/pages/blog/index.mdx +++ b/docs/src/pages/blog/index.mdx @@ -4,6 +4,12 @@ import StayUpdated from '@/components/StayUpdated.mdx'; # next-intl blog
+ Dec XX, 2024 · by Jan Amann + +(this post is still a draft) + +After a year of feature development, this release mostly aims to clean up the API surface to ensure `next-intl` remains lean—there should be no big surprises. Many significant improvements have already been shipped in [minor versions](/blog/next-intl-3-22) previously. + +However, this release also comes with a series of improvements that you might find useful. + +Here's what's new in `next-intl@4.0`: + +1. [**Modernized build output**](#modernized-build-output) +2. [**Revamped augmented types**](#revamped-augmented-types) +3. [**Strictly-typed locale**](#strictly-typed-locale) +4. [**Strictly-typed ICU arguments**](#strictly-typed-icu-arguments) +5. [**GDPR compliance**](#gdpr-compliance) +6. [**Preparation for upcoming Next.js features**](#nextjs-future) + +## Modernized build output + +The build output of `next-intl` has been modernized and now leverages the following optimizations: + +1. **ESM-only:** To enable enhanced tree-shaking and align with the modern JavaScript ecosystem, `next-intl` is now ESM-only. The only exception is `next-intl/plugin` which is published both as CommonJS as well as ESM, due to `next.config.js` still being popular. +2. **Modern JSX transform:** The peer dependency for React has been bumped to v17 in order to use the more efficient, modern JSX transform. +3. **Modern syntax:** Syntax is now compiled down to the Browserslist `defaults` query, which is a shortcut for `>0.5%, last 2 versions, Firefox ESR, not dead`—a baseline that is considered a reasonable target for modern apps. + +With these changes, the bundle size of `next-intl` has been reduced by ~7% ([all details](https://github.com/amannn/next-intl/pull/1470)). + +## Revamped augmented types + +After type-safe [`Formats`](/docs/usage/configuration#formats) was added in `next-intl@3.20`, it became clear that a new API was needed that centralizes the registration of augmented types. + +With `next-intl@4.0`, both `Messages` as well as `Formats` can now be registered under a single type that is scoped to `next-intl` and no longer affects the global scope: + +```tsx +// global.d.ts + +import {formats} from '@/i18n/request'; +import en from './messages/en.json'; + +declare module 'next-intl' { + interface AppConfig { + Formats: typeof formats; + Messages: typeof en; + } +} +``` + +See the updated [TypeScript augmentation](/docs/workflows/typescript) guide. + +## Strictly-typed locale + +Building on the new type augmentation mechanism, `next-intl@4.0` now allows you to strictly type locales across your app: + +```tsx +// global.d.ts + +import {routing} from '@/i18n/routing'; + +declare module 'next-intl' { + interface AppConfig { + // ... + Locale: (typeof routing.locales)[number]; + } +} +``` + +By doing so, APIs like `useLocale()` or `` that either return or receive a `locale` will now pick up your app-specific `Locale` type, improving type safety across your app. + +To simplify narrowing of `string`-based locales, a `hasLocale` function has been added. This can for example be used in [`i18n/request.ts`](/docs/getting-started/app-router/with-i18n-routing#i18n-request) to return a valid locale: + +```tsx +import {getRequestConfig} from 'next-intl/server'; +import {hasLocale} from 'next-intl'; +import {routing} from './routing'; + +export default getRequestConfig(async ({requestLocale}) => { + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; + + return { + locale, + messages: (await import(`../../messages/${locale}.json`)).default + }; +}); +``` + +Furthermore, the `Locale` type can be imported into your app code in case you're passing a locale to another function and want to ensure type safety: + +```tsx +import {Locale} from 'next-intl'; + +async function getPosts(locale: Locale) { + // ... +} +``` + +Note that strictly-typing the `Locale` is optional and can be used as desired in case you wish to have additional guardrails in your app. + +## Strictly-typed ICU arguments + +How type-safe can your app be? + +The quest to bring type safety to the last corner of `next-intl` has led me down a rabbit hole with the discovery of an ICU parser by [Marco Schumacher](https://github.com/schummar)—written entirely in types. Marco kindly published his implementation for usage in `next-intl` with me only adding support for rich tags on top. + +Check it out: + +```tsx +// "Hello {name}" +t('message'); +// ^? Expected 2 arguments + +// "Hello {name}" +t('message', {}); +// ^? {name: string} + +// "It's {today, date, long}" +t('message', {}); +// ^? {today: Date} + +// "Market share: {value, number, percent}" +t('message', {}); +// ^? {value: number} + +// "You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}." +t('message', {}); +// ^? {count: number} + +// "Country: {country, select, US {United States} CA {Canada} other {Other}}" +t('message', {}); +// ^? {country: 'US' | 'CA' | (string & {})} + +// "Please refer to the guidelines." +t('message', {}); +// ^? {guidelines: (chunks: ReactNode) => ReactNode} +``` + +(the types in these examples are slightly simplified, e.g. a date can also be provided as a timestamp) + +With this type inference in place, you can now use autocompletion in your IDE to get suggestions for the available arguments of a given ICU message and catch potential errors early. + +Due to a current limitation in TypeScript, this feature is opt-in for now. Please refer to the [strict arguments](/docs/workflows/typescript#messages-arguments) docs to learn how to enable it. + +## GDPR compliance + +In order to comply with the current GDPR regulations, the following changes have been made: + +1. The locale cookie expiration has been decreased to 5 hours. +2. The locale cookie is now only set when a user switches to a locale that doesn't match the `accept-language` header. + +If you want to increase the cookie expiration, e.g. because you're informing users about the usage of cookies or if GDPR doesn't apply to your app, you can use the `maxAge` attribute to do so: + +```tsx +// i18n/routing.tsx + +import {defineRouting} from 'next-intl/routing'; + +export const routing = defineRouting({ + // ... + + localeCookie: { + // Expire in one year + maxAge: 60 * 60 * 24 * 365 + } +}); +``` + +As part of this change, disabling a cookie now requires you to set [`localeCookie: false`](/docs/routing#locale-cookie) in your routing configuration. Previously, `localeDetection: false` ambiguously also disabled the cookie from being set, but since a separate `localeCookie` option was introduced recently, this should now be used instead. + +Learn more in the [locale cookie](/docs/routing#locale-cookie) docs. + +## Preparation for upcoming Next.js features + +To ensure that the sails of `next-intl` are set for a steady course in the upcoming future, I've investigated the implications of upcoming Next.js features like [Partial Prerendering](https://nextjs.org/docs/app/api-reference/next-config-js/ppr) and [`dynamicIO`](https://nextjs.org/docs/canary/app/api-reference/config/next-config-js/dynamicIO) for `next-intl`. + +This led to two minor changes: + +1. If you don't already have a `NextIntlClientProvider` in your app that wraps all Client Components, you now have to add one (see [PR #1541](https://github.com/amannn/next-intl/pull/1541) for details). +2. If you're using `format.relativeTime` in Client Components, you may need to provide the `now` argument explicitly now (see [PR #1536](https://github.com/amannn/next-intl/pull/1536) for details). + +While the mentioned Next.js features are still under development and may change, these two changes seem reasonable to me in any case—and ideally will be all that's necessary to adapt for `next-intl` to get the most out of these upcoming capabilities. + +As a closing note for this section, it seems like another feature is on its way to Next.js: [`rootParams`](https://github.com/vercel/next.js/pull/72837). + +```tsx +import {unstable_rootParams as rootParams} from 'next/server'; + +async function Component() { + // The ability to read params deeply in + // Server Components ... finally! + const {locale} = await rootParams(); +} +``` + +If things go well, I think this will finally fill in the [missing piece](https://github.com/vercel/next.js/discussions/58862) that enables apps with i18n routing to support static rendering without workarounds like `setRequestLocale`. + +## Other breaking changes + +1. Return type-safe messages from `useMessages` and `getMessages` (see [PR #1489](https://github.com/amannn/next-intl/pull/1489)) +2. Inherit context in case nested `NextIntlClientProvider` instances are present (see [PR #1413](https://github.com/amannn/next-intl/pull/1413)) +3. Automatically inherit formats when `NextIntlClientProvider` is rendered from a Server Component (see [PR #1191](https://github.com/amannn/next-intl/pull/1191)) +4. Require locale to be returned from `getRequestConfig` (see [PR #1486](https://github.com/amannn/next-intl/pull/1486)) +5. Bump minimum required typescript version to 5 for projects using TypeScript (see [PR #1481](https://github.com/amannn/next-intl/pull/1481)) +6. Remove deprecated APIs (see [PR #1479](https://github.com/amannn/next-intl/pull/1479)) +7. Remove deprecated APIs pt. 2 (see [PR #1482](https://github.com/amannn/next-intl/pull/1482)) + +## Upgrade now + +For a smooth upgrade, please initially upgrade to the latest v3.x version and check for deprecation warnings. + +Once all warnings are resolved, you can upgrade by running: + +``` +npm install next-intl@v4 +``` + +## Thank you + +I want to sincerely thank everyone who has helped to make `next-intl` what it is today. A special thank you goes to Crowdin, the primary sponsor of `next-intl`, enabling me to regularly dedicate time for this project. + +—Jan + + diff --git a/docs/src/pages/docs/workflows/typescript.mdx b/docs/src/pages/docs/workflows/typescript.mdx index 088abf28b..30bd45637 100644 --- a/docs/src/pages/docs/workflows/typescript.mdx +++ b/docs/src/pages/docs/workflows/typescript.mdx @@ -146,11 +146,22 @@ function UserProfile({user}) { } ``` -TypeScript currently has a [limitation](https://github.com/microsoft/TypeScript/issues/32063) where it infers the types of an imported JSON module as rather wide. Due to this, `next-intl` provides a stopgap solution that allows you to generate an accompanying `.d.json.ts` file for the messages that you're assigning to your `AppConfig`. +TypeScript currently has a [limitation](https://github.com/microsoft/TypeScript/issues/32063) where it infers values of imported JSON modules as loose types like `string` instead of the actual value. To bridge this gap for the time being, `next-intl` can generate an accompanying `.d.json.ts` file for the messages that you're assigning to your `AppConfig`. **Usage:** -1. Enable the `createMessagesDeclaration` setting in your Next.js config: +1. Add support for JSON type declarations in your `tsconfig.json`: + +```json filename="tsconfig.json" +{ + "compilerOptions": { + // ... + "allowArbitraryExtensions": true + } +} +``` + +2. Enable the `createMessagesDeclaration` setting in your Next.js config: ```tsx filename="next.config.mjs" import {createNextIntlPlugin} from 'next-intl/plugin'; @@ -166,17 +177,6 @@ const withNextIntl = createNextIntlPlugin({ // ... ``` -2. Add support for JSON type declarations in your `tsconfig.json`: - -```json filename="tsconfig.json" -{ - "compilerOptions": { - // ... - "allowArbitraryExtensions": true - } -} -``` - With this setup in place, you'll see a new declaration file generated in your `messages` directory once you run `next dev` or `next build`: ```diff @@ -184,7 +184,7 @@ With this setup in place, you'll see a new declaration file generated in your `m + messages/en.d.json.ts ``` -This declaration file will provide the exact types for the messages that you're using in `AppConfig`, enabling type safety for message arguments. +This declaration file will provide the exact types for the JSON messages that you're importing and assigning to `AppConfig`, enabling type safety for message arguments. To keep your code base tidy, you can ignore this file in Git: From 9ea117c656386e085b54e080a2c2dce1e892f615 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 18 Nov 2024 17:35:02 +0100 Subject: [PATCH 43/90] fix: Disallow string dates, improve autocomplete (#1557) --- .../use-intl/src/core/TranslationValues.tsx | 4 +--- .../src/core/createTranslator.test.tsx | 22 +++++++++++++++++++ .../use-intl/src/core/createTranslator.tsx | 11 +++++----- packages/use-intl/src/core/index.tsx | 4 +--- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/use-intl/src/core/TranslationValues.tsx b/packages/use-intl/src/core/TranslationValues.tsx index 3c290a897..43e9bd1f7 100644 --- a/packages/use-intl/src/core/TranslationValues.tsx +++ b/packages/use-intl/src/core/TranslationValues.tsx @@ -1,9 +1,7 @@ import type {ReactNode} from 'react'; -// These type names are shown to consumers in autocomplete export type ICUArg = string | number | boolean | Date; -export type ICUNumber = number; -export type ICUDate = Date | number | string; +// ^ Keep this in sync with `ICUArgument` in `createTranslator.tsx` export type TranslationValues = Record; diff --git a/packages/use-intl/src/core/createTranslator.test.tsx b/packages/use-intl/src/core/createTranslator.test.tsx index 1271fbbc6..218c1f236 100644 --- a/packages/use-intl/src/core/createTranslator.test.tsx +++ b/packages/use-intl/src/core/createTranslator.test.tsx @@ -244,6 +244,28 @@ describe('type safety', () => { t('msg', obj); }); + it('validates numbers', () => { + const t = translateMessage('Percentage: {value, number, percent}'); + t('msg', {value: 1.5}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {value: 'test'}); + }; + }); + + it('validates dates', () => { + const t = translateMessage('Date: {date, date, full}'); + t('msg', {date: new Date('2024-07-09T07:06:03.320Z')}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {date: '2024-07-09T07:06:03.320Z'}); + }; + }); + it('validates cardinal plurals', () => { const t = translateMessage( 'You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}.' diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index 05fe5620a..e535fbf7b 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -12,8 +12,6 @@ import type { } from './MessageKeys.tsx'; import type { ICUArg, - ICUDate, - ICUNumber, MarkupTagsFunction, RichTagsFunction } from './TranslationValues.tsx'; @@ -33,9 +31,12 @@ type ICUArgsWithTags< > = ICUArgs< MessageString, { - ICUArgument: ICUArg; - ICUNumberArgument: ICUNumber; - ICUDateArgument: ICUDate; + // Provide types inline instead of an alias so the + // consumer can see the types instead of the alias + ICUArgument: string | number | boolean | Date; + // ^ Keep this in sync with `ICUArg` in `TranslationValues.tsx` + ICUNumberArgument: number; + ICUDateArgument: Date | number; } > & ([TagsFn] extends [never] ? {} : ICUTags); diff --git a/packages/use-intl/src/core/index.tsx b/packages/use-intl/src/core/index.tsx index 97627d2c5..775ab964c 100644 --- a/packages/use-intl/src/core/index.tsx +++ b/packages/use-intl/src/core/index.tsx @@ -1,11 +1,9 @@ export type {default as AbstractIntlMessages} from './AbstractIntlMessages.tsx'; export type { TranslationValues, + ICUArg, RichTranslationValues, MarkupTranslationValues, - ICUArg, - ICUNumber, - ICUDate, RichTagsFunction, MarkupTagsFunction } from './TranslationValues.tsx'; From c8eb4adfa43237e42b3153049d0cccee757d9789 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 19 Nov 2024 09:16:22 +0100 Subject: [PATCH 44/90] fix: Use `fs.watch` instead of `chokidar` --- examples/example-app-router/messages/de.json | 1 - packages/next-intl/package.json | 1 - .../src/plugin/createMessagesDeclaration.tsx | 11 +++++------ pnpm-lock.yaml | 3 --- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/examples/example-app-router/messages/de.json b/examples/example-app-router/messages/de.json index 7533cf4eb..b96e77e7c 100644 --- a/examples/example-app-router/messages/de.json +++ b/examples/example-app-router/messages/de.json @@ -15,7 +15,6 @@ "locale": "{locale, select, de {🇩🇪 Deutsch} en {🇺🇸 English} other {Unbekannt}}" }, "Manifest": { - "description": "", "name": "" }, "Navigation": { diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 90e13d691..12965a07e 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -111,7 +111,6 @@ ], "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", - "chokidar": "^4.0.1", "negotiator": "^1.0.0", "use-intl": "workspace:^" }, diff --git a/packages/next-intl/src/plugin/createMessagesDeclaration.tsx b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx index fc1162727..eaa7468ca 100644 --- a/packages/next-intl/src/plugin/createMessagesDeclaration.tsx +++ b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx @@ -1,6 +1,5 @@ import fs from 'fs'; import path from 'path'; -import {watch} from 'chokidar'; import {throwError} from './utils.tsx'; function runOnce(fn: () => void) { @@ -44,14 +43,14 @@ export default function createMessagesDeclaration(messagesPath: string) { } function startWatching(messagesPath: string) { - const watcher = watch(messagesPath); - - watcher.on('change', () => { - compileDeclaration(messagesPath, true); + const watcher = fs.watch(messagesPath, (eventType) => { + if (eventType === 'change') { + compileDeclaration(messagesPath, true); + } }); process.on('exit', () => { - void watcher.close(); + watcher.close(); }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acc223139..babc4dc6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -766,9 +766,6 @@ importers: '@formatjs/intl-localematcher': specifier: ^0.5.4 version: 0.5.5 - chokidar: - specifier: ^4.0.1 - version: 4.0.1 negotiator: specifier: ^1.0.0 version: 1.0.0 From 5a7f7be1d6f93c614d6891034974ac8d1c3779f6 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 19 Nov 2024 10:40:44 +0100 Subject: [PATCH 45/90] fix: Move locale validation to `defineRouting` (#1560) --- .../next-intl/src/routing/defineRouting.tsx | 4 ++ .../src/routing/validateLocales.test.tsx | 71 +++++++++++++++++++ .../next-intl/src/routing/validateLocales.tsx | 16 +++++ packages/use-intl/src/core/hasLocale.test.tsx | 71 +------------------ packages/use-intl/src/core/hasLocale.tsx | 18 ----- 5 files changed, 92 insertions(+), 88 deletions(-) create mode 100644 packages/next-intl/src/routing/validateLocales.test.tsx create mode 100644 packages/next-intl/src/routing/validateLocales.tsx diff --git a/packages/next-intl/src/routing/defineRouting.tsx b/packages/next-intl/src/routing/defineRouting.tsx index b975a519f..1b34187ce 100644 --- a/packages/next-intl/src/routing/defineRouting.tsx +++ b/packages/next-intl/src/routing/defineRouting.tsx @@ -5,6 +5,7 @@ import type { Locales, Pathnames } from './types.tsx'; +import validateLocales from './validateLocales.tsx'; export default function defineRouting< const AppLocales extends Locales, @@ -19,5 +20,8 @@ export default function defineRouting< AppDomains > ) { + if (process.env.NODE_ENV !== 'production') { + validateLocales(config.locales); + } return config; } diff --git a/packages/next-intl/src/routing/validateLocales.test.tsx b/packages/next-intl/src/routing/validateLocales.test.tsx new file mode 100644 index 000000000..5e6d0f851 --- /dev/null +++ b/packages/next-intl/src/routing/validateLocales.test.tsx @@ -0,0 +1,71 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import validateLocales from './validateLocales.tsx'; + +describe('accepts valid formats', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it.each([ + 'en', + 'en-US', + 'EN-US', + 'en-us', + 'en-GB', + 'zh-Hans-CN', + 'es-419', + 'en-Latn', + 'zh-Hans', + 'en-US-u-ca-buddhist', + 'en-x-private1', + 'en-US-u-nu-thai', + 'ar-u-nu-arab', + 'en-t-m0-true', + 'zh-Hans-CN-x-private1-private2', + 'en-US-u-ca-gregory-nu-latn', + 'en-US-x-usd', + + // Somehow tolerated by Intl.Locale + 'english' + ])('accepts: %s', (locale) => { + validateLocales([locale]); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +}); + +describe('warns for invalid formats', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it.each([ + 'en_US', + 'en-', + 'e-US', + 'en-USA', + 'und', + '123', + '-en', + 'en--US', + 'toolongstring', + 'en-US-', + '@#$', + 'en US', + 'en.US' + ])('rejects: %s', (locale) => { + validateLocales([locale]); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/next-intl/src/routing/validateLocales.tsx b/packages/next-intl/src/routing/validateLocales.tsx new file mode 100644 index 000000000..ba09f20b7 --- /dev/null +++ b/packages/next-intl/src/routing/validateLocales.tsx @@ -0,0 +1,16 @@ +import type {Locales} from './types.tsx'; + +export default function validateLocales(locales: Locales) { + for (const locale of locales) { + try { + const constructed = new Intl.Locale(locale); + if (!constructed.language) { + throw new Error('Language is required'); + } + } catch { + console.error( + `Found invalid locale within provided \`locales\`: "${locale}"\nPlease ensure you're using a valid Unicode locale identifier (e.g. "en-US").` + ); + } + } +} diff --git a/packages/use-intl/src/core/hasLocale.test.tsx b/packages/use-intl/src/core/hasLocale.test.tsx index 19a6dfdcb..713260179 100644 --- a/packages/use-intl/src/core/hasLocale.test.tsx +++ b/packages/use-intl/src/core/hasLocale.test.tsx @@ -1,4 +1,4 @@ -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {it} from 'vitest'; import hasLocale from './hasLocale.tsx'; it('narrows down the type', () => { @@ -24,72 +24,3 @@ it('can be called with a non-matching narrow candidate', () => { candidate satisfies never; } }); - -describe('accepts valid formats', () => { - let consoleErrorSpy: ReturnType; - - beforeEach(() => { - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - consoleErrorSpy.mockRestore(); - }); - - it.each([ - 'en', - 'en-US', - 'EN-US', - 'en-us', - 'en-GB', - 'zh-Hans-CN', - 'es-419', - 'en-Latn', - 'zh-Hans', - 'en-US-u-ca-buddhist', - 'en-x-private1', - 'en-US-u-nu-thai', - 'ar-u-nu-arab', - 'en-t-m0-true', - 'zh-Hans-CN-x-private1-private2', - 'en-US-u-ca-gregory-nu-latn', - 'en-US-x-usd', - - // Somehow tolerated by Intl.Locale - 'english' - ])('accepts: %s', (locale) => { - expect(hasLocale([locale] as const, locale)).toBe(true); - expect(consoleErrorSpy).not.toHaveBeenCalled(); - }); -}); - -describe('warns for invalid formats', () => { - let consoleErrorSpy: ReturnType; - - beforeEach(() => { - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - consoleErrorSpy.mockRestore(); - }); - - it.each([ - 'en_US', - 'en-', - 'e-US', - 'en-USA', - 'und', - '123', - '-en', - 'en--US', - 'toolongstring', - 'en-US-', - '@#$', - 'en US', - 'en.US' - ])('rejects: %s', (locale) => { - hasLocale([locale] as const, locale); - expect(consoleErrorSpy).toHaveBeenCalled(); - }); -}); diff --git a/packages/use-intl/src/core/hasLocale.tsx b/packages/use-intl/src/core/hasLocale.tsx index 6094cfd01..5bb68714c 100644 --- a/packages/use-intl/src/core/hasLocale.tsx +++ b/packages/use-intl/src/core/hasLocale.tsx @@ -3,29 +3,11 @@ import type {Locale} from './AppConfig.tsx'; /** * Checks if a locale exists in a list of locales. * - * Additionally, in development, the provided locales are validated to - * ensure they follow the Unicode language identifier standard. - * * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale */ export default function hasLocale( locales: ReadonlyArray, candidate?: string | null ): candidate is LocaleType { - if (process.env.NODE_ENV !== 'production') { - for (const locale of locales) { - try { - const constructed = new Intl.Locale(locale); - if (!constructed.language) { - throw new Error('Language is required'); - } - } catch { - console.error( - `Found invalid locale within provided \`locales\`: "${locale}"\nPlease ensure you're using a valid Unicode locale identifier (e.g. "en-US").` - ); - } - } - } - return locales.includes(candidate as LocaleType); } From bfcd2517742b54f0f0e7def60db0dd1eb03b11e7 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 19 Nov 2024 13:13:46 +0100 Subject: [PATCH 46/90] docs: Proofread blog post --- docs/src/pages/blog/next-intl-4-0.mdx | 44 +++++++++++++-------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/src/pages/blog/next-intl-4-0.mdx b/docs/src/pages/blog/next-intl-4-0.mdx index d2610cc9a..e741e7412 100644 --- a/docs/src/pages/blog/next-intl-4-0.mdx +++ b/docs/src/pages/blog/next-intl-4-0.mdx @@ -11,28 +11,18 @@ import StayUpdated from '@/components/StayUpdated.mdx'; (this post is still a draft) -After a year of feature development, this release mostly aims to clean up the API surface to ensure `next-intl` remains lean—there should be no big surprises. Many significant improvements have already been shipped in [minor versions](/blog/next-intl-3-22) previously. - -However, this release also comes with a series of improvements that you might find useful. +After a year of feature development, this release mostly aims to clean up the API surface to ensure `next-intl` remains lean—there should be no big surprises. Many significant improvements have already been shipped in [minor versions](/blog/next-intl-3-22) previously. However, this release also comes with a series of improvements that you might find useful. Here's what's new in `next-intl@4.0`: -1. [**Modernized build output**](#modernized-build-output) -2. [**Revamped augmented types**](#revamped-augmented-types) -3. [**Strictly-typed locale**](#strictly-typed-locale) -4. [**Strictly-typed ICU arguments**](#strictly-typed-icu-arguments) -5. [**GDPR compliance**](#gdpr-compliance) +1. [**Revamped augmented types**](#revamped-augmented-types) +2. [**Strictly-typed locale**](#strictly-typed-locale) +3. [**Strictly-typed ICU arguments**](#strictly-typed-icu-arguments) +4. [**GDPR compliance**](#gdpr-compliance) +5. [**Modernized build output**](#modernized-build-output) 6. [**Preparation for upcoming Next.js features**](#nextjs-future) -## Modernized build output - -The build output of `next-intl` has been modernized and now leverages the following optimizations: - -1. **ESM-only:** To enable enhanced tree-shaking and align with the modern JavaScript ecosystem, `next-intl` is now ESM-only. The only exception is `next-intl/plugin` which is published both as CommonJS as well as ESM, due to `next.config.js` still being popular. -2. **Modern JSX transform:** The peer dependency for React has been bumped to v17 in order to use the more efficient, modern JSX transform. -3. **Modern syntax:** Syntax is now compiled down to the Browserslist `defaults` query, which is a shortcut for `>0.5%, last 2 versions, Firefox ESR, not dead`—a baseline that is considered a reasonable target for modern apps. - -With these changes, the bundle size of `next-intl` has been reduced by ~7% ([all details](https://github.com/amannn/next-intl/pull/1470)). +Please also have a look at the [other breaking changes](#other-breaking-changes) before you [upgrade](#upgrade-now). ## Revamped augmented types @@ -112,7 +102,7 @@ Note that strictly-typing the `Locale` is optional and can be used as desired in How type-safe can your app be? -The quest to bring type safety to the last corner of `next-intl` has led me down a rabbit hole with the discovery of an ICU parser by [Marco Schumacher](https://github.com/schummar)—written entirely in types. Marco kindly published his implementation for usage in `next-intl` with me only adding support for rich tags on top. +The quest to bring type safety to the last corner of `next-intl` has led me down a rabbit hole with the discovery of an ICU parser by [Marco Schumacher](https://github.com/schummar)—written entirely in types. Marco kindly published his implementation for usage in `next-intl`, with me only adding support for rich tags on top. Check it out: @@ -180,7 +170,17 @@ As part of this change, disabling a cookie now requires you to set [`localeCooki Learn more in the [locale cookie](/docs/routing#locale-cookie) docs. -## Preparation for upcoming Next.js features +## Modernized build output + +The build output of `next-intl` has been modernized and now leverages the following optimizations: + +1. **ESM-only:** To enable enhanced tree-shaking and align with the modern JavaScript ecosystem, `next-intl` is now ESM-only. The only exception is `next-intl/plugin` which is published both as CommonJS as well as ESM, due to `next.config.js` still being popular. +2. **Modern JSX transform:** The peer dependency for React has been bumped to v17 in order to use the more efficient, modern JSX transform. +3. **Modern syntax:** Syntax is now compiled down to the Browserslist `defaults` query, which is a shortcut for `>0.5%, last 2 versions, Firefox ESR, not dead`—a baseline that is considered a reasonable target for modern apps. + +With these changes, the bundle size of `next-intl` has been reduced by ~7% ([all details](https://github.com/amannn/next-intl/pull/1470)). + +## Preparation for upcoming Next.js features [#nextjs-future] To ensure that the sails of `next-intl` are set for a steady course in the upcoming future, I've investigated the implications of upcoming Next.js features like [Partial Prerendering](https://nextjs.org/docs/app/api-reference/next-config-js/ppr) and [`dynamicIO`](https://nextjs.org/docs/canary/app/api-reference/config/next-config-js/dynamicIO) for `next-intl`. @@ -203,7 +203,7 @@ async function Component() { } ``` -If things go well, I think this will finally fill in the [missing piece](https://github.com/vercel/next.js/discussions/58862) that enables apps with i18n routing to support static rendering without workarounds like `setRequestLocale`. +If things go well, I think this will finally fill in the [missing piece](https://github.com/vercel/next.js/discussions/58862) that enables apps with i18n routing to support static rendering without workarounds like `setRequestLocale`. I hope to have more to share on this soon! ## Other breaking changes @@ -219,7 +219,7 @@ If things go well, I think this will finally fill in the [missing piece](https:/ For a smooth upgrade, please initially upgrade to the latest v3.x version and check for deprecation warnings. -Once all warnings are resolved, you can upgrade by running: +Afterwards, you can upgrade by running: ``` npm install next-intl@v4 @@ -227,7 +227,7 @@ npm install next-intl@v4 ## Thank you -I want to sincerely thank everyone who has helped to make `next-intl` what it is today. A special thank you goes to Crowdin, the primary sponsor of `next-intl`, enabling me to regularly dedicate time for this project. +I want to sincerely thank everyone who has helped to make `next-intl` what it is today. A special thank you goes to Crowdin, the primary sponsor of `next-intl`, enabling me to regularly work on this project. —Jan From 7b755e9ef21a6882d554a1b3821b41868a3ee883 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 19 Nov 2024 14:48:17 +0100 Subject: [PATCH 47/90] fix: update warning for now fallback in `createFormatter` --- packages/use-intl/src/core/createFormatter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index 958ed1d43..14e3d610e 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -223,7 +223,7 @@ export default function createFormatter(props: Props) { new IntlError( IntlErrorCode.ENVIRONMENT_FALLBACK, process.env.NODE_ENV !== 'production' - ? `The \`now\` parameter wasn't provided and there is no global default configured, therefore the current time will be used as a fallback. To avoid markup mismatches caused by environment differences, either provide the \`now\` parameter or configure a global default. Learn more: https://next-intl-docs.vercel.app/docs/configuration#now` + ? `The \`now\` parameter wasn't provided to \`relativeTime\` and there is no global default configured, therefore the current time will be used as a fallback. See https://next-intl-docs.vercel.app/docs/usage/dates-times#relative-times-usenow` : undefined ) ); From 18156c4166ac3921d4458ddad554141b9387c682 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 19 Nov 2024 17:33:28 +0100 Subject: [PATCH 48/90] fix: use v4-beta tag instead --- .github/workflows/prerelease-v4.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/prerelease-v4.yml b/.github/workflows/prerelease-v4.yml index af06aaf11..b54f44279 100644 --- a/.github/workflows/prerelease-v4.yml +++ b/.github/workflows/prerelease-v4.yml @@ -25,7 +25,7 @@ jobs: git config --global user.email "${{ github.actor }}@users.noreply.github.com" - run: | sed -i 's/"use-intl": "workspace:\^"/"use-intl": "workspace:"/' ./packages/next-intl/package.json && git commit -am "use fixed version" - - run: pnpm lerna publish 4.0.0-beta-${GITHUB_SHA::7} --no-git-reset --dist-tag v4 --no-push --yes + - run: pnpm lerna publish 4.0.0-beta-${GITHUB_SHA::7} --no-git-reset --dist-tag v4-beta --no-push --yes if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ')}}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 0b2c951824c1a803c501590b75fe4bdaa48ab1ef Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 19 Nov 2024 17:34:07 +0100 Subject: [PATCH 49/90] docs: reference new tag --- docs/src/pages/blog/next-intl-4-0.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/pages/blog/next-intl-4-0.mdx b/docs/src/pages/blog/next-intl-4-0.mdx index e741e7412..82eaa4835 100644 --- a/docs/src/pages/blog/next-intl-4-0.mdx +++ b/docs/src/pages/blog/next-intl-4-0.mdx @@ -222,7 +222,7 @@ For a smooth upgrade, please initially upgrade to the latest v3.x version and ch Afterwards, you can upgrade by running: ``` -npm install next-intl@v4 +npm install next-intl@v4-beta ``` ## Thank you From 62750301bb6b47cca2ee4735211e20da4d212782 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 20 Nov 2024 09:04:04 +0100 Subject: [PATCH 50/90] feat!: Disallow passing `null`, `undefined` or `boolean` as an ICU argument (#1561) These are errors now: ```tsx t('message', {value: null}); t('message', {value: undefined}); t('message', {value: false}); ``` If you really want to put a raw boolean value in a message, you can cast it to a string first: ```tsx const value = true; t('message', {value: String(value)}); ``` --- docs/src/pages/blog/next-intl-4-0.mdx | 31 ++++++--- .../src/app/[locale]/actions/ListItem.tsx | 2 +- .../app/[locale]/actions/ListItemAsync.tsx | 2 +- .../app/[locale]/actions/ListItemClient.tsx | 2 +- .../src/components/Navigation.tsx | 2 +- .../components/UseTranslationsTypeTests.tsx | 12 ++-- .../ClientCounter.tsx | 2 +- .../use-intl/src/core/TranslationValues.tsx | 18 +++-- .../src/core/createTranslator.test.tsx | 66 ++++++++++++++++++- .../use-intl/src/core/createTranslator.tsx | 18 ++--- packages/use-intl/src/core/index.tsx | 1 - 11 files changed, 120 insertions(+), 36 deletions(-) diff --git a/docs/src/pages/blog/next-intl-4-0.mdx b/docs/src/pages/blog/next-intl-4-0.mdx index 82eaa4835..cba157a96 100644 --- a/docs/src/pages/blog/next-intl-4-0.mdx +++ b/docs/src/pages/blog/next-intl-4-0.mdx @@ -119,9 +119,9 @@ t('message', {}); t('message', {}); // ^? {today: Date} -// "Market share: {value, number, percent}" +// "Page {page, number} out of {total, number}" t('message', {}); -// ^? {value: number} +// ^? {page: number, total: number} // "You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}." t('message', {}); @@ -131,15 +131,27 @@ t('message', {}); t('message', {}); // ^? {country: 'US' | 'CA' | (string & {})} -// "Please refer to the guidelines." +// "Please refer to the guidelines." t('message', {}); -// ^? {guidelines: (chunks: ReactNode) => ReactNode} +// ^? {link: (chunks: ReactNode) => ReactNode} ``` -(the types in these examples are slightly simplified, e.g. a date can also be provided as a timestamp) - With this type inference in place, you can now use autocompletion in your IDE to get suggestions for the available arguments of a given ICU message and catch potential errors early. +This also addresses one of my favorite pet peeves: + +```tsx +t('followers', {count: 30000}); +``` + +```json +// ✖️ Would be: "30000 followers" +"{count} followers" + +// ✅ Valid: "30,000 followers" +"{count, number} followers" +``` + Due to a current limitation in TypeScript, this feature is opt-in for now. Please refer to the [strict arguments](/docs/workflows/typescript#messages-arguments) docs to learn how to enable it. ## GDPR compliance @@ -211,9 +223,10 @@ If things go well, I think this will finally fill in the [missing piece](https:/ 2. Inherit context in case nested `NextIntlClientProvider` instances are present (see [PR #1413](https://github.com/amannn/next-intl/pull/1413)) 3. Automatically inherit formats when `NextIntlClientProvider` is rendered from a Server Component (see [PR #1191](https://github.com/amannn/next-intl/pull/1191)) 4. Require locale to be returned from `getRequestConfig` (see [PR #1486](https://github.com/amannn/next-intl/pull/1486)) -5. Bump minimum required typescript version to 5 for projects using TypeScript (see [PR #1481](https://github.com/amannn/next-intl/pull/1481)) -6. Remove deprecated APIs (see [PR #1479](https://github.com/amannn/next-intl/pull/1479)) -7. Remove deprecated APIs pt. 2 (see [PR #1482](https://github.com/amannn/next-intl/pull/1482)) +5. Disallow passing `null`, `undefined` or `boolean` as an ICU argument (see [PR #1561](https://github.com/amannn/next-intl/pull/1561)) +6. Bump minimum required typescript version to 5 for projects using TypeScript (see [PR #1481](https://github.com/amannn/next-intl/pull/1481)) +7. Remove deprecated APIs (see [PR #1479](https://github.com/amannn/next-intl/pull/1479)) +8. Remove deprecated APIs pt. 2 (see [PR #1482](https://github.com/amannn/next-intl/pull/1482)) ## Upgrade now diff --git a/examples/example-app-router-playground/src/app/[locale]/actions/ListItem.tsx b/examples/example-app-router-playground/src/app/[locale]/actions/ListItem.tsx index df1a9e5b7..ece94e2a3 100644 --- a/examples/example-app-router-playground/src/app/[locale]/actions/ListItem.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/actions/ListItem.tsx @@ -2,5 +2,5 @@ import {useTranslations} from 'next-intl'; export default function ListItem({id}: {id: number}) { const t = useTranslations('ServerActions'); - return t('item', {id}); + return t('item', {id: String(id)}); } diff --git a/examples/example-app-router-playground/src/app/[locale]/actions/ListItemAsync.tsx b/examples/example-app-router-playground/src/app/[locale]/actions/ListItemAsync.tsx index 801919ba3..b25696f1b 100644 --- a/examples/example-app-router-playground/src/app/[locale]/actions/ListItemAsync.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/actions/ListItemAsync.tsx @@ -2,5 +2,5 @@ import {getTranslations} from 'next-intl/server'; export default async function ListItemAsync({id}: {id: number}) { const t = await getTranslations('ServerActions'); - return t('item', {id}); + return t('item', {id: String(id)}); } diff --git a/examples/example-app-router-playground/src/app/[locale]/actions/ListItemClient.tsx b/examples/example-app-router-playground/src/app/[locale]/actions/ListItemClient.tsx index b94cf8749..62d4796e1 100644 --- a/examples/example-app-router-playground/src/app/[locale]/actions/ListItemClient.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/actions/ListItemClient.tsx @@ -4,5 +4,5 @@ import {useTranslations} from 'next-intl'; export default function ListItemClient({id}: {id: number}) { const t = useTranslations('ServerActions'); - return t('item', {id}); + return t('item', {id: String(id)}); } diff --git a/examples/example-app-router-playground/src/components/Navigation.tsx b/examples/example-app-router-playground/src/components/Navigation.tsx index b4daa3660..6ad41a1ab 100644 --- a/examples/example-app-router-playground/src/components/Navigation.tsx +++ b/examples/example-app-router-playground/src/components/Navigation.tsx @@ -13,7 +13,7 @@ export default function Navigation() { - {t('newsArticle', {articleId: 3})} + {t('newsArticle', {articleId: String(3)})} ); diff --git a/examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx b/examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx index 51a26c5ef..e9d0bc574 100644 --- a/examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx +++ b/examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx @@ -8,12 +8,12 @@ import {getTranslations} from 'next-intl/server'; export function RegularComponent() { const t = useTranslations('ClientCounter'); - t('count', {count: 1}); + t('count', {count: String(1)}); // @ts-expect-error t('count'); // @ts-expect-error - t('count', {num: 1}); + t('count', {num: String(1)}); } export function CreateTranslator() { @@ -25,20 +25,20 @@ export function CreateTranslator() { namespace: 'ClientCounter' }); - t('count', {count: 1}); + t('count', {count: String(1)}); // @ts-expect-error t('count'); // @ts-expect-error - t('count', {num: 1}); + t('count', {num: String(1)}); } export async function AsyncComponent() { const t = await getTranslations('ClientCounter'); - t('count', {count: 1}); + t('count', {count: String(1)}); // @ts-expect-error t('count'); // @ts-expect-error - t('count', {num: 1}); + t('count', {num: String(1)}); } diff --git a/examples/example-app-router-playground/src/components/client/02-MessagesOnClientCounter/ClientCounter.tsx b/examples/example-app-router-playground/src/components/client/02-MessagesOnClientCounter/ClientCounter.tsx index e7a013de6..a0a75f327 100644 --- a/examples/example-app-router-playground/src/components/client/02-MessagesOnClientCounter/ClientCounter.tsx +++ b/examples/example-app-router-playground/src/components/client/02-MessagesOnClientCounter/ClientCounter.tsx @@ -13,7 +13,7 @@ export default function ClientCounter() { return (
-

{t('count', {count})}

+

{t('count', {count: String(count)})}

diff --git a/packages/use-intl/src/core/TranslationValues.tsx b/packages/use-intl/src/core/TranslationValues.tsx index 43e9bd1f7..063d3374e 100644 --- a/packages/use-intl/src/core/TranslationValues.tsx +++ b/packages/use-intl/src/core/TranslationValues.tsx @@ -1,9 +1,12 @@ import type {ReactNode} from 'react'; -export type ICUArg = string | number | boolean | Date; -// ^ Keep this in sync with `ICUArgument` in `createTranslator.tsx` - -export type TranslationValues = Record; +export type TranslationValues = Record< + string, + // All params that are allowed for basic params as well as operators like + // `plural`, `select`, `number` and `date`. Note that `Date` is not supported + // for plain params, but this requires type information from the ICU parser. + string | number | Date +>; export type RichTagsFunction = (chunks: ReactNode) => ReactNode; export type MarkupTagsFunction = (chunks: string) => string; @@ -11,9 +14,12 @@ export type MarkupTagsFunction = (chunks: string) => string; // We could consider renaming this to `ReactRichTranslationValues` and defining // it in the `react` namespace if the core becomes useful to other frameworks. // It would be a breaking change though, so let's wait for now. -export type RichTranslationValues = Record; +export type RichTranslationValues = Record< + string, + TranslationValues[string] | RichTagsFunction +>; export type MarkupTranslationValues = Record< string, - ICUArg | MarkupTagsFunction + TranslationValues[string] | MarkupTagsFunction >; diff --git a/packages/use-intl/src/core/createTranslator.test.tsx b/packages/use-intl/src/core/createTranslator.test.tsx index 218c1f236..1a4a4c9bd 100644 --- a/packages/use-intl/src/core/createTranslator.test.tsx +++ b/packages/use-intl/src/core/createTranslator.test.tsx @@ -9,6 +9,7 @@ import createTranslator from './createTranslator.tsx'; const messages = { Home: { title: 'Hello world!', + param: 'Hello {param}', rich: 'Hello {name}!', markup: 'Hello {name}!' } @@ -53,6 +54,21 @@ it('handles formatting errors', () => { expect(result).toBe('price'); }); +it('restricts boolean and date values as plain params', () => { + const onError = vi.fn(); + const t = createTranslator({ + locale: 'en', + namespace: 'Home', + messages: messages as any, + onError + }); + + t('param', {param: new Date()}); + // @ts-expect-error + t('param', {param: true}); + expect(onError.mock.calls.length).toBe(2); +}); + it('supports alphanumeric value names', () => { const t = createTranslator({ locale: 'en', @@ -234,6 +250,22 @@ describe('type safety', () => { }; }); + it('restricts non-string values', () => { + const t = translateMessage('{param}'); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error -- should use {param, number} instead + t('msg', {param: 1.5}); + + // @ts-expect-error + t('msg', {param: new Date()}); + + // @ts-expect-error + t('msg', {param: true}); + }; + }); + it('can handle undefined values', () => { const t = translateMessage('Hello {name}'); @@ -266,6 +298,16 @@ describe('type safety', () => { }; }); + it('restricts numbers in dates', () => { + const t = translateMessage('Date: {date, date, full}'); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {date: 1.5}); + }; + }); + it('validates cardinal plurals', () => { const t = translateMessage( 'You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}.' @@ -340,6 +382,28 @@ describe('type safety', () => { }; }); + it('restricts numbers in selects', () => { + const t = translateMessage( + '{count, select, 0 {zero} 1 {one} other {other}}' + ); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {count: 1.5}); + }; + }); + + it('restricts booleans in selects', () => { + const t = translateMessage('{bool, select, true {true} false {false}}'); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {bool: true}); + }; + }); + it('validates escaped', () => { const t = translateMessage( "Escape curly braces with single quotes (e.g. '{name')" @@ -404,7 +468,7 @@ describe('type safety', () => { it('validates a complex message', () => { const t = translateMessage( - 'Hello {name}, you have {count, plural, =0 {no followers} =1 {one follower} other {# followers ({count})}}.' + 'Hello {name}, you have {count, plural, =0 {no followers} =1 {one follower} other {# followers ({count, number})}}.' ); t.rich('msg', { diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index e535fbf7b..eef02b5da 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -11,9 +11,9 @@ import type { NestedValueOf } from './MessageKeys.tsx'; import type { - ICUArg, MarkupTagsFunction, - RichTagsFunction + RichTagsFunction, + TranslationValues } from './TranslationValues.tsx'; import createTranslatorImpl from './createTranslatorImpl.tsx'; import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; @@ -31,12 +31,11 @@ type ICUArgsWithTags< > = ICUArgs< MessageString, { - // Provide types inline instead of an alias so the - // consumer can see the types instead of the alias - ICUArgument: string | number | boolean | Date; - // ^ Keep this in sync with `ICUArg` in `TranslationValues.tsx` + // Numbers and dates should use the corresponding operators + ICUArgument: string; + ICUNumberArgument: number; - ICUDateArgument: Date | number; + ICUDateArgument: Date; } > & ([TagsFn] extends [never] ? {} : ICUTags); @@ -49,7 +48,10 @@ type TranslateArgs< > = // If an unknown string is passed, allow any values string extends Value - ? [values?: Record, formats?: Formats] + ? [ + values?: Record, + formats?: Formats + ] : ( Value extends any ? (key: ICUArgsWithTags) => void diff --git a/packages/use-intl/src/core/index.tsx b/packages/use-intl/src/core/index.tsx index 775ab964c..4f5c6e503 100644 --- a/packages/use-intl/src/core/index.tsx +++ b/packages/use-intl/src/core/index.tsx @@ -1,7 +1,6 @@ export type {default as AbstractIntlMessages} from './AbstractIntlMessages.tsx'; export type { TranslationValues, - ICUArg, RichTranslationValues, MarkupTranslationValues, RichTagsFunction, From c323050498d911aa2d345f2477daee1d96b180e3 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 20 Nov 2024 10:53:47 +0100 Subject: [PATCH 51/90] fix: release for "feat!: " as well --- .github/workflows/prerelease-canary.yml | 2 +- .github/workflows/prerelease-v4.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/prerelease-canary.yml b/.github/workflows/prerelease-canary.yml index 1de7720a4..4faefca9d 100644 --- a/.github/workflows/prerelease-canary.yml +++ b/.github/workflows/prerelease-canary.yml @@ -26,7 +26,7 @@ jobs: - run: | sed -i 's/"use-intl": "workspace:\^"/"use-intl": "workspace:"/' ./packages/next-intl/package.json && git commit -am "use fixed version" - run: pnpm lerna publish 0.0.0-canary-${GITHUB_SHA::7} --no-git-reset --dist-tag canary --no-push --yes - if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ')}}" + if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ')}} || ${{startsWith(github.event.head_commit.message, 'feat!: ')}}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/prerelease-v4.yml b/.github/workflows/prerelease-v4.yml index b54f44279..e9d3e0ae7 100644 --- a/.github/workflows/prerelease-v4.yml +++ b/.github/workflows/prerelease-v4.yml @@ -26,7 +26,7 @@ jobs: - run: | sed -i 's/"use-intl": "workspace:\^"/"use-intl": "workspace:"/' ./packages/next-intl/package.json && git commit -am "use fixed version" - run: pnpm lerna publish 4.0.0-beta-${GITHUB_SHA::7} --no-git-reset --dist-tag v4-beta --no-push --yes - if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ')}}" + if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ')}} || ${{startsWith(github.event.head_commit.message, 'feat!: ')}}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6473ff40..cdc6080e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: git config --global user.name "${{ github.actor }}" git config --global user.email "${{ github.actor }}@users.noreply.github.com" - run: pnpm run publish - if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ')}}" + if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ')}} || ${{startsWith(github.event.head_commit.message, 'feat!: ')}}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From 8cce53e2f1465ca3d241895adfbb3892558a4930 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 20 Nov 2024 16:45:25 +0100 Subject: [PATCH 52/90] docs: performance notes of messages augmentation --- docs/src/pages/docs/workflows/typescript.mdx | 44 +++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/docs/src/pages/docs/workflows/typescript.mdx b/docs/src/pages/docs/workflows/typescript.mdx index 30bd45637..f2d5e9917 100644 --- a/docs/src/pages/docs/workflows/typescript.mdx +++ b/docs/src/pages/docs/workflows/typescript.mdx @@ -122,7 +122,49 @@ declare module 'next-intl' { You can freely define the interface, but if you have your messages available locally, it can be helpful to automatically create the type based on the messages from your default locale. -### Strict arguments [#messages-arguments] +
+Does this affect the performance of type checking? + +While the size of your messages file can have an effect on the time it takes to run the TypeScript compiler on your project, the overhead of augmenting `Messages` should be reasonably fast. + +Here's a benchmark from a sample project with 340 messages: + +- No type augmentation for messages: ~2.20s +- Type-safe keys: ~2.82s +- Type-safe arguments: ~2.85s + +This was observed on a MacBook Pro 2019 (Intel). + +--- + +If you experience performance issues on larger projects, you can consider: + +1. Using type augmentation of messages only on your continuous integration pipeline as a safety net +2. Splitting your project into multiple packages in a monorepo, allowing you to work with separate messages per package + +
+ +
+Does this affect the performance of my editor? + +Generally, type augmentation for `Messages` should be [reasonably fast](#messages-performance-tsc). + +In case you notice your editor performance related to saving files to be impacted, it might be caused by running ESLint on save when using [type-aware](https://typescript-eslint.io/troubleshooting/typed-linting/performance/) rules from `@typescript-eslint`. + +To ensure your editor performance is optimal, you can consider running expensive, type-aware rules only on your continuous integration pipeline: + +```tsx filename="eslint.config.js" +// ... + + // Run expensive, type-aware linting only on CI + '@typescript-eslint/no-misused-promises': process.env.CI + ? 'error' + : 'off' +``` + +
+ +### Type-safe arguments [#messages-arguments] Apart from strictly typing message keys, you can also ensure type safety for message arguments: From 6d94a9e40b46ee8bb8b80a46844fc9f56e7086a7 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 28 Nov 2024 16:55:46 +0100 Subject: [PATCH 53/90] docs: fix toc, note in gdpr release notes [skip ci] --- docs/src/pages/blog/_meta.tsx | 4 ++++ docs/src/pages/blog/next-intl-4-0.mdx | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/src/pages/blog/_meta.tsx b/docs/src/pages/blog/_meta.tsx index bd0c17456..badbdc76f 100644 --- a/docs/src/pages/blog/_meta.tsx +++ b/docs/src/pages/blog/_meta.tsx @@ -2,6 +2,10 @@ export default { index: { title: 'Overview' }, + 'next-intl-4-0': { + title: 'next-intl 4.0', + display: 'hidden' + }, 'next-intl-3-22': { title: 'next-intl 3.22', display: 'hidden' diff --git a/docs/src/pages/blog/next-intl-4-0.mdx b/docs/src/pages/blog/next-intl-4-0.mdx index cba157a96..8783bb5ee 100644 --- a/docs/src/pages/blog/next-intl-4-0.mdx +++ b/docs/src/pages/blog/next-intl-4-0.mdx @@ -154,9 +154,9 @@ t('followers', {count: 30000}); Due to a current limitation in TypeScript, this feature is opt-in for now. Please refer to the [strict arguments](/docs/workflows/typescript#messages-arguments) docs to learn how to enable it. -## GDPR compliance +## GDPR compliance [#gdpr-compliance] -In order to comply with the current GDPR regulations, the following changes have been made: +In order to comply with the current GDPR regulations, the following changes have been made if you're using the `next-intl` middleware for i18n routing: 1. The locale cookie expiration has been decreased to 5 hours. 2. The locale cookie is now only set when a user switches to a locale that doesn't match the `accept-language` header. @@ -178,6 +178,8 @@ export const routing = defineRouting({ }); ``` +Since the cookie is now only available after a locale switch, make sure to not rely on it always being present. E.g. if you need access to the user's locale in a [Route Handler](/docs/environments/actions-metadata-route-handlers#route-handlers), a reliable option is to provide the locale as a search param (e.g. `/api/posts/12?locale=en`). + As part of this change, disabling a cookie now requires you to set [`localeCookie: false`](/docs/routing#locale-cookie) in your routing configuration. Previously, `localeDetection: false` ambiguously also disabled the cookie from being set, but since a separate `localeCookie` option was introduced recently, this should now be used instead. Learn more in the [locale cookie](/docs/routing#locale-cookie) docs. @@ -188,7 +190,7 @@ The build output of `next-intl` has been modernized and now leverages the follow 1. **ESM-only:** To enable enhanced tree-shaking and align with the modern JavaScript ecosystem, `next-intl` is now ESM-only. The only exception is `next-intl/plugin` which is published both as CommonJS as well as ESM, due to `next.config.js` still being popular. 2. **Modern JSX transform:** The peer dependency for React has been bumped to v17 in order to use the more efficient, modern JSX transform. -3. **Modern syntax:** Syntax is now compiled down to the Browserslist `defaults` query, which is a shortcut for `>0.5%, last 2 versions, Firefox ESR, not dead`—a baseline that is considered a reasonable target for modern apps. +3. **Modern syntax:** Syntax is now compiled down to the Browserslist `defaults` query, which is a shortcut for ">0.5%, last 2 versions, Firefox ESR, not dead"—a baseline that is considered a reasonable target for modern apps. With these changes, the bundle size of `next-intl` has been reduced by ~7% ([all details](https://github.com/amannn/next-intl/pull/1470)). From db95243ded9a841f440361b4c4a438b23e49694a Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 28 Nov 2024 17:17:31 +0100 Subject: [PATCH 54/90] docs: Update legacy example to use `next-intl` instead of `use-intl` (#1583) Related to https://github.com/amannn/next-intl/issues/1282 --- docs/src/pages/docs/getting-started/pages-router.mdx | 6 ------ examples/example-pages-router-legacy/package.json | 2 +- examples/example-pages-router-legacy/src/pages/_app.js | 6 +++--- examples/example-pages-router-legacy/src/pages/index.js | 2 +- packages/next-intl/package.json | 2 +- pnpm-lock.yaml | 6 +++--- 6 files changed, 9 insertions(+), 15 deletions(-) diff --git a/docs/src/pages/docs/getting-started/pages-router.mdx b/docs/src/pages/docs/getting-started/pages-router.mdx index 4c7a5a056..9261760b2 100644 --- a/docs/src/pages/docs/getting-started/pages-router.mdx +++ b/docs/src/pages/docs/getting-started/pages-router.mdx @@ -109,9 +109,3 @@ export async function getStaticProps() { - -## Support for legacy Next.js versions - -Next.js version 10, 11 and 12 are still supported. Note however that instead of installing `next-intl`, you'll have to import functionality like `useTranslations` from [`use-intl`](/docs/environments/core-library#react-apps). - -See the [legacy example](https://github.com/amannn/next-intl/tree/main/examples/example-pages-router-legacy). diff --git a/examples/example-pages-router-legacy/package.json b/examples/example-pages-router-legacy/package.json index 4907a1bf4..03da96bce 100644 --- a/examples/example-pages-router-legacy/package.json +++ b/examples/example-pages-router-legacy/package.json @@ -11,7 +11,7 @@ "next": "^12.0.0", "react": "^17.0.0", "react-dom": "^17.0.0", - "use-intl": "^3.0.0" + "next-intl": "^3.0.0" }, "devDependencies": { "eslint": "^9.11.1", diff --git a/examples/example-pages-router-legacy/src/pages/_app.js b/examples/example-pages-router-legacy/src/pages/_app.js index f5cb63e91..d9a176e0c 100644 --- a/examples/example-pages-router-legacy/src/pages/_app.js +++ b/examples/example-pages-router-legacy/src/pages/_app.js @@ -1,13 +1,13 @@ import Head from 'next/head'; import {useRouter} from 'next/router'; -import {IntlProvider} from 'use-intl'; +import {NextIntlClientProvider} from 'next-intl'; export default function App({Component, pageProps}) { const router = useRouter(); const {messages, now, ...rest} = pageProps; return ( - example-pages-router-legacy - + ); } diff --git a/examples/example-pages-router-legacy/src/pages/index.js b/examples/example-pages-router-legacy/src/pages/index.js index db4395258..2b33e42ba 100644 --- a/examples/example-pages-router-legacy/src/pages/index.js +++ b/examples/example-pages-router-legacy/src/pages/index.js @@ -1,4 +1,4 @@ -import {useFormatter, useNow, useTranslations} from 'use-intl'; +import {useFormatter, useNow, useTranslations} from 'next-intl'; import PageLayout from '../components/PageLayout'; export default function Index() { diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 12965a07e..a6c024ac8 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -115,7 +115,7 @@ "use-intl": "workspace:^" }, "peerDependencies": { - "next": "^13.0.0 || ^14.0.0 || ^15.0.0", + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0", "typescript": "^5.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index babc4dc6c..9283e7422 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -621,15 +621,15 @@ importers: next: specifier: ^12.0.0 version: 12.3.4(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + next-intl: + specifier: ^3.0.0 + version: link:../../packages/next-intl react: specifier: ^17.0.0 version: 17.0.2 react-dom: specifier: ^17.0.0 version: 17.0.2(react@17.0.2) - use-intl: - specifier: ^3.0.0 - version: link:../../packages/use-intl devDependencies: eslint: specifier: ^9.11.1 From 21b882a17cb079527a452e2ad3feb6da177d706b Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 28 Nov 2024 17:31:57 +0100 Subject: [PATCH 55/90] docs: improve relative time formatting docs (also fix release conditions) --- .github/workflows/prerelease-canary.yml | 2 +- .github/workflows/prerelease-v4.yml | 2 +- .github/workflows/release.yml | 2 +- docs/src/pages/docs/usage/dates-times.mdx | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/prerelease-canary.yml b/.github/workflows/prerelease-canary.yml index 4faefca9d..5468e5145 100644 --- a/.github/workflows/prerelease-canary.yml +++ b/.github/workflows/prerelease-canary.yml @@ -26,7 +26,7 @@ jobs: - run: | sed -i 's/"use-intl": "workspace:\^"/"use-intl": "workspace:"/' ./packages/next-intl/package.json && git commit -am "use fixed version" - run: pnpm lerna publish 0.0.0-canary-${GITHUB_SHA::7} --no-git-reset --dist-tag canary --no-push --yes - if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ')}} || ${{startsWith(github.event.head_commit.message, 'feat!: ')}}" + if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ') || startsWith(github.event.head_commit.message, 'feat!: ')}}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/prerelease-v4.yml b/.github/workflows/prerelease-v4.yml index e9d3e0ae7..adfba9583 100644 --- a/.github/workflows/prerelease-v4.yml +++ b/.github/workflows/prerelease-v4.yml @@ -26,7 +26,7 @@ jobs: - run: | sed -i 's/"use-intl": "workspace:\^"/"use-intl": "workspace:"/' ./packages/next-intl/package.json && git commit -am "use fixed version" - run: pnpm lerna publish 4.0.0-beta-${GITHUB_SHA::7} --no-git-reset --dist-tag v4-beta --no-push --yes - if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ')}} || ${{startsWith(github.event.head_commit.message, 'feat!: ')}}" + if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ') || startsWith(github.event.head_commit.message, 'feat!: ')}}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cdc6080e2..9e251c2d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: git config --global user.name "${{ github.actor }}" git config --global user.email "${{ github.actor }}@users.noreply.github.com" - run: pnpm run publish - if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ')}} || ${{startsWith(github.event.head_commit.message, 'feat!: ')}}" + if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ') || startsWith(github.event.head_commit.message, 'feat!: ')}}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/docs/src/pages/docs/usage/dates-times.mdx b/docs/src/pages/docs/usage/dates-times.mdx index 71c90acaf..7155919ae 100644 --- a/docs/src/pages/docs/usage/dates-times.mdx +++ b/docs/src/pages/docs/usage/dates-times.mdx @@ -96,12 +96,12 @@ In contrast to simply calling `new Date()` in your component, `useNow` has some 1. The returned value is consistent across re-renders on the client side. 2. The value can optionally be [updated continuously](#relative-times-update) based on an interval. -3. The value can optionally be initialized from a [global value](/docs/usage/configuration#now), e.g. allowing you to use a static `now` value to ensure consistency when running tests. +3. The value can optionally be initialized from a [global value](/docs/usage/configuration#now), e.g. allowing you to use a static `now` value to ensure consistency when running tests. If a global value is not provided, `useNow` will use the current time.
How can I avoid hydration errors with `useNow`? -If you're using `useNow` in a component that renders both on the server as well as the client, you can consider using [`suppressHydrationWarning`](https://react.dev/reference/react-dom/client/hydrateRoot#suppressing-unavoidable-hydration-mismatch-errors) to tell React that this particular text is expected to potentially be updated on the client side: +If you're using `useNow` in a component that renders both on the server as well as the client and you're not using a global `now` value, you can consider using [`suppressHydrationWarning`](https://react.dev/reference/react-dom/client/hydrateRoot#suppressing-unavoidable-hydration-mismatch-errors) to tell React that this particular text is expected to potentially be updated on the client side: ```tsx {7} import {useNow, useFormatter} from 'next-intl'; From 8f37883e325094daa9decda3909ca32062104c4f Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 2 Dec 2024 14:25:45 +0100 Subject: [PATCH 56/90] fix: Change error messages to not mention provider (since there might be none) (#1589) --- packages/use-intl/src/core/createBaseTranslator.tsx | 2 +- packages/use-intl/src/core/createFormatter.tsx | 2 +- packages/use-intl/src/react/useFormatter.test.tsx | 6 +++--- packages/use-intl/src/react/useTranslations.test.tsx | 4 +--- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/use-intl/src/core/createBaseTranslator.tsx b/packages/use-intl/src/core/createBaseTranslator.tsx index 313e3c39e..fceee040b 100644 --- a/packages/use-intl/src/core/createBaseTranslator.tsx +++ b/packages/use-intl/src/core/createBaseTranslator.tsx @@ -113,7 +113,7 @@ function getMessagesOrError( if (!messages) { throw new Error( process.env.NODE_ENV !== 'production' - ? `No messages were configured on the provider.` + ? `No messages were configured.` : undefined ); } diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index 14e3d610e..80500650a 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -125,7 +125,7 @@ export default function createFormatter(props: Props) { const error = new IntlError( IntlErrorCode.MISSING_FORMAT, process.env.NODE_ENV !== 'production' - ? `Format \`${formatName}\` is not available. You can configure it on the provider or provide custom options.` + ? `Format \`${formatName}\` is not available.` : undefined ); onError(error); diff --git a/packages/use-intl/src/react/useFormatter.test.tsx b/packages/use-intl/src/react/useFormatter.test.tsx index df0c69fbc..1e0735af4 100644 --- a/packages/use-intl/src/react/useFormatter.test.tsx +++ b/packages/use-intl/src/react/useFormatter.test.tsx @@ -207,7 +207,7 @@ describe('dateTime', () => { const error: IntlError = onError.mock.calls[0][0]; expect(error.message).toBe( - 'MISSING_FORMAT: Format `onlyYear` is not available. You can configure it on the provider or provide custom options.' + 'MISSING_FORMAT: Format `onlyYear` is not available.' ); expect(error.code).toBe(IntlErrorCode.MISSING_FORMAT); expect(container.textContent).toMatch(/Nov 20 2020/); @@ -234,7 +234,7 @@ describe('dateTime', () => { const error: IntlError = onError.mock.calls[0][0]; expect(error.message).toBe( - 'MISSING_FORMAT: Format `medium` is not available. You can configure it on the provider or provide custom options.' + 'MISSING_FORMAT: Format `medium` is not available.' ); expect(error.code).toBe(IntlErrorCode.MISSING_FORMAT); expect(container.textContent).toMatch(/Nov 20 2020/); @@ -397,7 +397,7 @@ describe('number', () => { const error: IntlError = onError.mock.calls[0][0]; expect(error.message).toBe( - 'MISSING_FORMAT: Format `missing` is not available. You can configure it on the provider or provide custom options.' + 'MISSING_FORMAT: Format `missing` is not available.' ); expect(error.code).toBe(IntlErrorCode.MISSING_FORMAT); expect(container.textContent).toBe('10000'); diff --git a/packages/use-intl/src/react/useTranslations.test.tsx b/packages/use-intl/src/react/useTranslations.test.tsx index 30c7aca2b..2a500d97e 100644 --- a/packages/use-intl/src/react/useTranslations.test.tsx +++ b/packages/use-intl/src/react/useTranslations.test.tsx @@ -776,9 +776,7 @@ describe('error handling', () => { expect(onError).toHaveBeenCalledTimes(1); const error: IntlError = onError.mock.calls[0][0]; expect(error.code).toBe(IntlErrorCode.MISSING_MESSAGE); - expect(error.message).toBe( - 'MISSING_MESSAGE: No messages were configured on the provider.' - ); + expect(error.message).toBe('MISSING_MESSAGE: No messages were configured.'); screen.getByText('Component.test'); }); From 7e7011df2f7cfc53ee249cbb3a247dd548efeaaf Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 18 Dec 2024 17:01:39 +0100 Subject: [PATCH 57/90] fix: Fix hard reload when using `` in Next.js 15 (#1620) --- packages/next-intl/rollup.config.js | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/next-intl/rollup.config.js b/packages/next-intl/rollup.config.js index ad96e6512..7a2543390 100644 --- a/packages/next-intl/rollup.config.js +++ b/packages/next-intl/rollup.config.js @@ -2,6 +2,19 @@ import preserveDirectives from 'rollup-plugin-preserve-directives'; import {getBuildConfig} from 'tools'; import pkg from './package.json' with {type: 'json'}; +function rewriteBundle(regex, replaceFn) { + return { + name: 'rewrite-bundle', + generateBundle(options, bundle) { + for (const fileName of Object.keys(bundle)) { + const chunk = bundle[fileName]; + const updatedCode = chunk.code.replace(regex, replaceFn); + chunk.code = updatedCode; + } + } + }; +} + export default [ ...getBuildConfig({ input: { @@ -34,7 +47,20 @@ export default [ if (warning.code === 'MODULE_LEVEL_DIRECTIVE') return; warn(warning); }, - plugins: [preserveDirectives()] + plugins: [ + preserveDirectives(), + + // Since we're writing our code with ESM, we have to import e.g. from + // `next/link.js`. While this can be used in production, since Next.js 15 + // this somehow causes hard reloads when `next/link.js` is imported and + // used to link to another page. There might be some optimizations + // happening in the background that we can't control. Due to this, it + // seems safer to update imports to a version that doesn't have `.js` + // suffix and let the bundler optimize them. + rewriteBundle(/['"]next\/(\w+)\.js['"]/g, (match, p1) => + match.replace(`next/${p1}.js`, `next/${p1}`) + ) + ] }), ...getBuildConfig({ env: ['development'], From 9f3a4b0404d76aba1f79a6da526d7fd2e5e344c0 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 19 Dec 2024 13:37:41 +0100 Subject: [PATCH 58/90] docs: Improve blog post wording --- docs/src/pages/blog/next-intl-4-0.mdx | 18 ++++++++++++------ docs/src/pages/docs/workflows/typescript.mdx | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/src/pages/blog/next-intl-4-0.mdx b/docs/src/pages/blog/next-intl-4-0.mdx index 8783bb5ee..f7ff31ca9 100644 --- a/docs/src/pages/blog/next-intl-4-0.mdx +++ b/docs/src/pages/blog/next-intl-4-0.mdx @@ -11,7 +11,7 @@ import StayUpdated from '@/components/StayUpdated.mdx'; (this post is still a draft) -After a year of feature development, this release mostly aims to clean up the API surface to ensure `next-intl` remains lean—there should be no big surprises. Many significant improvements have already been shipped in [minor versions](/blog/next-intl-3-22) previously. However, this release also comes with a series of improvements that you might find useful. +After a year of feature development, this release focuses on streamlining the API surface while maintaining the lean core architecture of `next-intl`. While many major improvements were already released in [previous minor versions](/blog/next-intl-3-22), this update introduces several valuable enhancements that will improve your development experience and make working with internationalization even more seamless. Here's what's new in `next-intl@4.0`: @@ -20,7 +20,7 @@ Here's what's new in `next-intl@4.0`: 3. [**Strictly-typed ICU arguments**](#strictly-typed-icu-arguments) 4. [**GDPR compliance**](#gdpr-compliance) 5. [**Modernized build output**](#modernized-build-output) -6. [**Preparation for upcoming Next.js features**](#nextjs-future) +6. [**Preparation for upcoming Next.js features**](#nextjs-ppr-dynamicio) Please also have a look at the [other breaking changes](#other-breaking-changes) before you [upgrade](#upgrade-now). @@ -38,8 +38,8 @@ import en from './messages/en.json'; declare module 'next-intl' { interface AppConfig { - Formats: typeof formats; Messages: typeof en; + Formats: typeof formats; } } ``` @@ -194,7 +194,7 @@ The build output of `next-intl` has been modernized and now leverages the follow With these changes, the bundle size of `next-intl` has been reduced by ~7% ([all details](https://github.com/amannn/next-intl/pull/1470)). -## Preparation for upcoming Next.js features [#nextjs-future] +## Preparation for `ppr` and `dynamicIO` [#nextjs-ppr-dynamicio] To ensure that the sails of `next-intl` are set for a steady course in the upcoming future, I've investigated the implications of upcoming Next.js features like [Partial Prerendering](https://nextjs.org/docs/app/api-reference/next-config-js/ppr) and [`dynamicIO`](https://nextjs.org/docs/canary/app/api-reference/config/next-config-js/dynamicIO) for `next-intl`. @@ -240,10 +240,16 @@ Afterwards, you can upgrade by running: npm install next-intl@v4-beta ``` -## Thank you +If you need help, you can refer to the [examples](/examples) which have all been updated. + +## Thank you! -I want to sincerely thank everyone who has helped to make `next-intl` what it is today. A special thank you goes to Crowdin, the primary sponsor of `next-intl`, enabling me to regularly work on this project. +I want to sincerely thank everyone who has helped to make `next-intl` what it is today. + +A special thank you goes to Crowdin, the primary sponsor of `next-intl`, enabling me to regularly work on this project and provide it as a free and open-source library for everyone. —Jan +PS: Have you heard that [learn.next-intl.dev](https://learn.next-intl.dev) is coming? + diff --git a/docs/src/pages/docs/workflows/typescript.mdx b/docs/src/pages/docs/workflows/typescript.mdx index f2d5e9917..e7626f25e 100644 --- a/docs/src/pages/docs/workflows/typescript.mdx +++ b/docs/src/pages/docs/workflows/typescript.mdx @@ -203,7 +203,7 @@ TypeScript currently has a [limitation](https://github.com/microsoft/TypeScript/ } ``` -2. Enable the `createMessagesDeclaration` setting in your Next.js config: +2. Configure the `createMessagesDeclaration` setting in your Next.js config: ```tsx filename="next.config.mjs" import {createNextIntlPlugin} from 'next-intl/plugin'; From dac018aa5f1cacb91fd535026d44ec8940ad77e8 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 19 Dec 2024 16:15:52 +0100 Subject: [PATCH 59/90] docs: blog post wording --- docs/src/components/StayUpdated.mdx | 1 - docs/src/pages/blog/next-intl-4-0.mdx | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/src/components/StayUpdated.mdx b/docs/src/components/StayUpdated.mdx index 436d0c69d..5fc1a2720 100644 --- a/docs/src/components/StayUpdated.mdx +++ b/docs/src/components/StayUpdated.mdx @@ -2,6 +2,5 @@ **Let's keep in touch:** -- [GitHub releases](https://github.com/amannn/next-intl/releases) - [Bluesky (Jan Amann)](https://bsky.app/profile/amann.work) - [X (Jan Amann)](https://x.com/jamannnnnn) diff --git a/docs/src/pages/blog/next-intl-4-0.mdx b/docs/src/pages/blog/next-intl-4-0.mdx index f7ff31ca9..38fe60152 100644 --- a/docs/src/pages/blog/next-intl-4-0.mdx +++ b/docs/src/pages/blog/next-intl-4-0.mdx @@ -20,7 +20,7 @@ Here's what's new in `next-intl@4.0`: 3. [**Strictly-typed ICU arguments**](#strictly-typed-icu-arguments) 4. [**GDPR compliance**](#gdpr-compliance) 5. [**Modernized build output**](#modernized-build-output) -6. [**Preparation for upcoming Next.js features**](#nextjs-ppr-dynamicio) +6. [**Preparation for upcoming Next.js features**](#nextjs-future) Please also have a look at the [other breaking changes](#other-breaking-changes) before you [upgrade](#upgrade-now). @@ -194,7 +194,7 @@ The build output of `next-intl` has been modernized and now leverages the follow With these changes, the bundle size of `next-intl` has been reduced by ~7% ([all details](https://github.com/amannn/next-intl/pull/1470)). -## Preparation for `ppr` and `dynamicIO` [#nextjs-ppr-dynamicio] +## Preparation for upcoming Next.js features [#nextjs-future] To ensure that the sails of `next-intl` are set for a steady course in the upcoming future, I've investigated the implications of upcoming Next.js features like [Partial Prerendering](https://nextjs.org/docs/app/api-reference/next-config-js/ppr) and [`dynamicIO`](https://nextjs.org/docs/canary/app/api-reference/config/next-config-js/dynamicIO) for `next-intl`. From c224d1c247c455dc9a389f33a21b2789f1768e14 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 20 Dec 2024 09:27:29 +0100 Subject: [PATCH 60/90] docs: Remove blog post --- docs/src/pages/blog/_meta.tsx | 4 - docs/src/pages/blog/index.mdx | 6 - docs/src/pages/blog/next-intl-4-0.mdx | 255 -------------------------- 3 files changed, 265 deletions(-) delete mode 100644 docs/src/pages/blog/next-intl-4-0.mdx diff --git a/docs/src/pages/blog/_meta.tsx b/docs/src/pages/blog/_meta.tsx index badbdc76f..bd0c17456 100644 --- a/docs/src/pages/blog/_meta.tsx +++ b/docs/src/pages/blog/_meta.tsx @@ -2,10 +2,6 @@ export default { index: { title: 'Overview' }, - 'next-intl-4-0': { - title: 'next-intl 4.0', - display: 'hidden' - }, 'next-intl-3-22': { title: 'next-intl 3.22', display: 'hidden' diff --git a/docs/src/pages/blog/index.mdx b/docs/src/pages/blog/index.mdx index 84d1c6da5..9640e7360 100644 --- a/docs/src/pages/blog/index.mdx +++ b/docs/src/pages/blog/index.mdx @@ -4,12 +4,6 @@ import StayUpdated from '@/components/StayUpdated.mdx'; # next-intl blog
- Dec XX, 2024 · by Jan Amann - -(this post is still a draft) - -After a year of feature development, this release focuses on streamlining the API surface while maintaining the lean core architecture of `next-intl`. While many major improvements were already released in [previous minor versions](/blog/next-intl-3-22), this update introduces several valuable enhancements that will improve your development experience and make working with internationalization even more seamless. - -Here's what's new in `next-intl@4.0`: - -1. [**Revamped augmented types**](#revamped-augmented-types) -2. [**Strictly-typed locale**](#strictly-typed-locale) -3. [**Strictly-typed ICU arguments**](#strictly-typed-icu-arguments) -4. [**GDPR compliance**](#gdpr-compliance) -5. [**Modernized build output**](#modernized-build-output) -6. [**Preparation for upcoming Next.js features**](#nextjs-future) - -Please also have a look at the [other breaking changes](#other-breaking-changes) before you [upgrade](#upgrade-now). - -## Revamped augmented types - -After type-safe [`Formats`](/docs/usage/configuration#formats) was added in `next-intl@3.20`, it became clear that a new API was needed that centralizes the registration of augmented types. - -With `next-intl@4.0`, both `Messages` as well as `Formats` can now be registered under a single type that is scoped to `next-intl` and no longer affects the global scope: - -```tsx -// global.d.ts - -import {formats} from '@/i18n/request'; -import en from './messages/en.json'; - -declare module 'next-intl' { - interface AppConfig { - Messages: typeof en; - Formats: typeof formats; - } -} -``` - -See the updated [TypeScript augmentation](/docs/workflows/typescript) guide. - -## Strictly-typed locale - -Building on the new type augmentation mechanism, `next-intl@4.0` now allows you to strictly type locales across your app: - -```tsx -// global.d.ts - -import {routing} from '@/i18n/routing'; - -declare module 'next-intl' { - interface AppConfig { - // ... - Locale: (typeof routing.locales)[number]; - } -} -``` - -By doing so, APIs like `useLocale()` or `` that either return or receive a `locale` will now pick up your app-specific `Locale` type, improving type safety across your app. - -To simplify narrowing of `string`-based locales, a `hasLocale` function has been added. This can for example be used in [`i18n/request.ts`](/docs/getting-started/app-router/with-i18n-routing#i18n-request) to return a valid locale: - -```tsx -import {getRequestConfig} from 'next-intl/server'; -import {hasLocale} from 'next-intl'; -import {routing} from './routing'; - -export default getRequestConfig(async ({requestLocale}) => { - // Typically corresponds to the `[locale]` segment - const requested = await requestLocale; - const locale = hasLocale(routing.locales, requested) - ? requested - : routing.defaultLocale; - - return { - locale, - messages: (await import(`../../messages/${locale}.json`)).default - }; -}); -``` - -Furthermore, the `Locale` type can be imported into your app code in case you're passing a locale to another function and want to ensure type safety: - -```tsx -import {Locale} from 'next-intl'; - -async function getPosts(locale: Locale) { - // ... -} -``` - -Note that strictly-typing the `Locale` is optional and can be used as desired in case you wish to have additional guardrails in your app. - -## Strictly-typed ICU arguments - -How type-safe can your app be? - -The quest to bring type safety to the last corner of `next-intl` has led me down a rabbit hole with the discovery of an ICU parser by [Marco Schumacher](https://github.com/schummar)—written entirely in types. Marco kindly published his implementation for usage in `next-intl`, with me only adding support for rich tags on top. - -Check it out: - -```tsx -// "Hello {name}" -t('message'); -// ^? Expected 2 arguments - -// "Hello {name}" -t('message', {}); -// ^? {name: string} - -// "It's {today, date, long}" -t('message', {}); -// ^? {today: Date} - -// "Page {page, number} out of {total, number}" -t('message', {}); -// ^? {page: number, total: number} - -// "You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}." -t('message', {}); -// ^? {count: number} - -// "Country: {country, select, US {United States} CA {Canada} other {Other}}" -t('message', {}); -// ^? {country: 'US' | 'CA' | (string & {})} - -// "Please refer to the guidelines." -t('message', {}); -// ^? {link: (chunks: ReactNode) => ReactNode} -``` - -With this type inference in place, you can now use autocompletion in your IDE to get suggestions for the available arguments of a given ICU message and catch potential errors early. - -This also addresses one of my favorite pet peeves: - -```tsx -t('followers', {count: 30000}); -``` - -```json -// ✖️ Would be: "30000 followers" -"{count} followers" - -// ✅ Valid: "30,000 followers" -"{count, number} followers" -``` - -Due to a current limitation in TypeScript, this feature is opt-in for now. Please refer to the [strict arguments](/docs/workflows/typescript#messages-arguments) docs to learn how to enable it. - -## GDPR compliance [#gdpr-compliance] - -In order to comply with the current GDPR regulations, the following changes have been made if you're using the `next-intl` middleware for i18n routing: - -1. The locale cookie expiration has been decreased to 5 hours. -2. The locale cookie is now only set when a user switches to a locale that doesn't match the `accept-language` header. - -If you want to increase the cookie expiration, e.g. because you're informing users about the usage of cookies or if GDPR doesn't apply to your app, you can use the `maxAge` attribute to do so: - -```tsx -// i18n/routing.tsx - -import {defineRouting} from 'next-intl/routing'; - -export const routing = defineRouting({ - // ... - - localeCookie: { - // Expire in one year - maxAge: 60 * 60 * 24 * 365 - } -}); -``` - -Since the cookie is now only available after a locale switch, make sure to not rely on it always being present. E.g. if you need access to the user's locale in a [Route Handler](/docs/environments/actions-metadata-route-handlers#route-handlers), a reliable option is to provide the locale as a search param (e.g. `/api/posts/12?locale=en`). - -As part of this change, disabling a cookie now requires you to set [`localeCookie: false`](/docs/routing#locale-cookie) in your routing configuration. Previously, `localeDetection: false` ambiguously also disabled the cookie from being set, but since a separate `localeCookie` option was introduced recently, this should now be used instead. - -Learn more in the [locale cookie](/docs/routing#locale-cookie) docs. - -## Modernized build output - -The build output of `next-intl` has been modernized and now leverages the following optimizations: - -1. **ESM-only:** To enable enhanced tree-shaking and align with the modern JavaScript ecosystem, `next-intl` is now ESM-only. The only exception is `next-intl/plugin` which is published both as CommonJS as well as ESM, due to `next.config.js` still being popular. -2. **Modern JSX transform:** The peer dependency for React has been bumped to v17 in order to use the more efficient, modern JSX transform. -3. **Modern syntax:** Syntax is now compiled down to the Browserslist `defaults` query, which is a shortcut for ">0.5%, last 2 versions, Firefox ESR, not dead"—a baseline that is considered a reasonable target for modern apps. - -With these changes, the bundle size of `next-intl` has been reduced by ~7% ([all details](https://github.com/amannn/next-intl/pull/1470)). - -## Preparation for upcoming Next.js features [#nextjs-future] - -To ensure that the sails of `next-intl` are set for a steady course in the upcoming future, I've investigated the implications of upcoming Next.js features like [Partial Prerendering](https://nextjs.org/docs/app/api-reference/next-config-js/ppr) and [`dynamicIO`](https://nextjs.org/docs/canary/app/api-reference/config/next-config-js/dynamicIO) for `next-intl`. - -This led to two minor changes: - -1. If you don't already have a `NextIntlClientProvider` in your app that wraps all Client Components, you now have to add one (see [PR #1541](https://github.com/amannn/next-intl/pull/1541) for details). -2. If you're using `format.relativeTime` in Client Components, you may need to provide the `now` argument explicitly now (see [PR #1536](https://github.com/amannn/next-intl/pull/1536) for details). - -While the mentioned Next.js features are still under development and may change, these two changes seem reasonable to me in any case—and ideally will be all that's necessary to adapt for `next-intl` to get the most out of these upcoming capabilities. - -As a closing note for this section, it seems like another feature is on its way to Next.js: [`rootParams`](https://github.com/vercel/next.js/pull/72837). - -```tsx -import {unstable_rootParams as rootParams} from 'next/server'; - -async function Component() { - // The ability to read params deeply in - // Server Components ... finally! - const {locale} = await rootParams(); -} -``` - -If things go well, I think this will finally fill in the [missing piece](https://github.com/vercel/next.js/discussions/58862) that enables apps with i18n routing to support static rendering without workarounds like `setRequestLocale`. I hope to have more to share on this soon! - -## Other breaking changes - -1. Return type-safe messages from `useMessages` and `getMessages` (see [PR #1489](https://github.com/amannn/next-intl/pull/1489)) -2. Inherit context in case nested `NextIntlClientProvider` instances are present (see [PR #1413](https://github.com/amannn/next-intl/pull/1413)) -3. Automatically inherit formats when `NextIntlClientProvider` is rendered from a Server Component (see [PR #1191](https://github.com/amannn/next-intl/pull/1191)) -4. Require locale to be returned from `getRequestConfig` (see [PR #1486](https://github.com/amannn/next-intl/pull/1486)) -5. Disallow passing `null`, `undefined` or `boolean` as an ICU argument (see [PR #1561](https://github.com/amannn/next-intl/pull/1561)) -6. Bump minimum required typescript version to 5 for projects using TypeScript (see [PR #1481](https://github.com/amannn/next-intl/pull/1481)) -7. Remove deprecated APIs (see [PR #1479](https://github.com/amannn/next-intl/pull/1479)) -8. Remove deprecated APIs pt. 2 (see [PR #1482](https://github.com/amannn/next-intl/pull/1482)) - -## Upgrade now - -For a smooth upgrade, please initially upgrade to the latest v3.x version and check for deprecation warnings. - -Afterwards, you can upgrade by running: - -``` -npm install next-intl@v4-beta -``` - -If you need help, you can refer to the [examples](/examples) which have all been updated. - -## Thank you! - -I want to sincerely thank everyone who has helped to make `next-intl` what it is today. - -A special thank you goes to Crowdin, the primary sponsor of `next-intl`, enabling me to regularly work on this project and provide it as a free and open-source library for everyone. - -—Jan - -PS: Have you heard that [learn.next-intl.dev](https://learn.next-intl.dev) is coming? - - From 0825f0858a8452fce5d133b89a91023299975a39 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 20 Dec 2024 11:35:58 +0100 Subject: [PATCH 61/90] feat: Re-introduce `locale` argument for `getRequestConfig` to be used for overriding the locale (#1625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **tldr;** — Do you use i18n routing and have you already switched to [`await requestLocale`](https://next-intl.dev/blog/next-intl-3-22#await-request-locale) in `getRequestConfig`? If yes, you can skip this. --- ### Deprecation of `locale` in favor of `await requestLocale` In [next-intl 3.22](https://next-intl.dev/blog/next-intl-3-22), the `locale` argument that was passed to `getRequestConfig` was deprecated in favor of [`await requestLocale`](https://next-intl.dev/blog/next-intl-3-22#await-request-locale): ```diff // i18n/request.ts export default function getRequestConfig(async ({ - locale + requestLocale }) => { + const locale = await requestLocale; // ... })); ``` This change was done in preparation for Next.js 15 where reading from headers [became async](https://nextjs.org/blog/next-15#async-request-apis-breaking-change). If you're using i18n routing, please upgrade to `requestLocale` now. ### Preview: `rootParams` are coming to Next.js Now, with [`rootParams`](https://github.com/vercel/next.js/pull/72837) being on the horizon, this API will allow you to read a locale without receiving any param passed to `getRequestConfig`: ```tsx // i18n/request.ts import {unstable_rootParams as rootParams} from 'next/server'; import {getRequestConfig} from 'next-intl/server'; import {hasLocale} from 'next-intl'; import {routing} from './routing'; export default getRequestConfig(async () => { const params = await rootParams(); const locale = hasLocale(routing.locales, params.locale) ? params.locale : routing.defaultLocale; // ... }); ``` Among other simplifications, this allows to remove manual overrides like this that were merely done for enabling static rendering: ```diff - type Props = { - params: Promise<{locale: string}>; - }; export async function generateMetadata( - {params}: Props ) { - const {locale} = await params; - const t = await getTranslations({locale, namespace: 'HomePage'}); + const t = await getTranslations('HomePage'); // ... } ``` However, in some rare cases, you might want to render messages from multiple locales on the same page: ```tsx // Use messages from 'en', regardless of what the current user locale is const t = getTranslations({locale: 'en'}); ``` If you're using this pattern, you'll be able to accept the overridden locale in `getRequestConfig` as follows: ```tsx // i18n/request.ts import {unstable_rootParams as rootParams} from 'next/server'; import {getRequestConfig} from 'next-intl/server'; import {hasLocale} from 'next-intl'; import {routing} from './routing'; export default getRequestConfig(async ({locale}) => { // Use a locale based on these priorities: // 1. An override passed to the function // 2. A locale from the `[locale]` segment // 3. A default locale if (!locale) { const params = await rootParams(); locale = hasLocale(routing.locales, params.locale) ? params.locale : routing.defaultLocale; } // ... }); ``` This is quite an edge case, but this use case will remain supported via the re-introduced `locale` argument. Note that `await requestLocale` considers a potential locale override, therefore the `locale` argument will only be relevant once `rootParams` are a thing. I hope to have more to share on this in the future! --- .../next-intl/src/server/react-server/getConfig.tsx | 2 ++ .../src/server/react-server/getRequestConfig.tsx | 9 ++++++++- packages/use-intl/src/core/hasLocale.test.tsx | 11 ++++++++++- packages/use-intl/src/core/hasLocale.tsx | 2 +- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index d3daa45d8..4b07ff97b 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -38,6 +38,8 @@ See also: https://next-intl.dev/docs/usage/configuration#i18n-request } const params: GetRequestConfigParams = { + locale: localeOverride, + // In case the consumer doesn't read `params.locale` and instead provides the // `locale` (either in a single-language workflow or because the locale is // read from the user settings), don't attempt to read the request locale. diff --git a/packages/next-intl/src/server/react-server/getRequestConfig.tsx b/packages/next-intl/src/server/react-server/getRequestConfig.tsx index d52612e1c..d96097c18 100644 --- a/packages/next-intl/src/server/react-server/getRequestConfig.tsx +++ b/packages/next-intl/src/server/react-server/getRequestConfig.tsx @@ -1,4 +1,4 @@ -import type {IntlConfig} from 'use-intl/core'; +import type {IntlConfig, Locale} from 'use-intl/core'; export type RequestConfig = Omit & { /** @@ -8,6 +8,13 @@ export type RequestConfig = Omit & { }; export type GetRequestConfigParams = { + /** + * If you provide an explicit locale to an async server-side function like + * `getTranslations({locale: 'en'})`, it will be passed via `locale` to + * `getRequestConfig` so you can use it instead of the segment value. + */ + locale?: Locale; + /** * Typically corresponds to the `[locale]` segment that was matched by the middleware. * diff --git a/packages/use-intl/src/core/hasLocale.test.tsx b/packages/use-intl/src/core/hasLocale.test.tsx index 713260179..36c611f3b 100644 --- a/packages/use-intl/src/core/hasLocale.test.tsx +++ b/packages/use-intl/src/core/hasLocale.test.tsx @@ -1,4 +1,4 @@ -import {it} from 'vitest'; +import {expect, it} from 'vitest'; import hasLocale from './hasLocale.tsx'; it('narrows down the type', () => { @@ -24,3 +24,12 @@ it('can be called with a non-matching narrow candidate', () => { candidate satisfies never; } }); + +it('can be called with any candidate', () => { + const locales = ['en-US', 'en-GB'] as const; + expect(hasLocale(locales, 'unknown')).toBe(false); + expect(hasLocale(locales, undefined)).toBe(false); + + // Relevant since `ParamValue` in Next.js includes `string[]` + expect(hasLocale(locales, ['de'])).toBe(false); +}); diff --git a/packages/use-intl/src/core/hasLocale.tsx b/packages/use-intl/src/core/hasLocale.tsx index 5bb68714c..576ee3289 100644 --- a/packages/use-intl/src/core/hasLocale.tsx +++ b/packages/use-intl/src/core/hasLocale.tsx @@ -7,7 +7,7 @@ import type {Locale} from './AppConfig.tsx'; */ export default function hasLocale( locales: ReadonlyArray, - candidate?: string | null + candidate: unknown ): candidate is LocaleType { return locales.includes(candidate as LocaleType); } From 56f59a7659f4e96edd329e8b44b8a15ba7976178 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 20 Dec 2024 13:11:50 +0100 Subject: [PATCH 62/90] docs: v4 banner --- docs/src/theme.config.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/docs/src/theme.config.tsx b/docs/src/theme.config.tsx index 63bf9b886..769a9158e 100644 --- a/docs/src/theme.config.tsx +++ b/docs/src/theme.config.tsx @@ -27,21 +27,12 @@ export default { pre: Pre }, banner: { - key: 'banner-learn-next-intl', content: (
- Announcing{' '} - - learn.next-intl.dev - - ! + You‘re viewing the next-intl 4 beta docs
- ) + ), + dismissible: false }, footer: { component: Footer From ddd5ae5ec50747ad563bb1c9f4cbf1ab258b4271 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 23 Dec 2024 09:40:01 +0100 Subject: [PATCH 63/90] fix: Make locale cookie a session cookie (#1634) Initially, I planned to add an expiration of 5 hours to the locale cookie for `next-intl@4` to comply with GDPR regulations. However, this has the implication that if the browser remains open for longer than 5 hours, the cookie can be reset in the middle of a session. Due to this, it seems more reasonable to not set an expiration at all, turning the cookie into a session cookie. Session cookies expiry only when a browser is closed. On mobile, this can be even more beneficial, as browsers are rarely closed (the browser can clear cookies though if memory is constrained). --- docs/src/pages/docs/routing.mdx | 7 +++---- examples/example-app-router/tests/main.spec.ts | 2 -- .../navigation/react-client/createNavigation.test.tsx | 11 +++-------- packages/next-intl/src/routing/config.tsx | 3 +-- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/docs/src/pages/docs/routing.mdx b/docs/src/pages/docs/routing.mdx index acf996ce8..376d15816 100644 --- a/docs/src/pages/docs/routing.mdx +++ b/docs/src/pages/docs/routing.mdx @@ -473,11 +473,10 @@ In this case, only the locale prefix and a potentially [matching domain](#domain ### Locale cookie [#locale-cookie] -If a user changes the locale to a value that doesn't match the `accept-language` header, `next-intl` will set a cookie called `NEXT_LOCALE` that contains the most recently detected locale. This is used to [remember the user's locale](/docs/routing/middleware#locale-detection) preference for future requests. +If a user changes the locale to a value that doesn't match the `accept-language` header, `next-intl` will set a session cookie called `NEXT_LOCALE` that contains the most recently detected locale. This is used to [remember the user's locale](/docs/routing/middleware#locale-detection) preference for subsequent requests. By default, the cookie will be configured with the following attributes: -1. [**`maxAge`**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#max-agenumber): This value is set to 5 hours in order to be [GDPR-compliant](#locale-cookie-gdpr) out of the box. 2. [**`sameSite`**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value): This value is set to `lax` so that the cookie can be set when coming from an external site. 3. [**`path`**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#pathpath-value): This value is not set by default, but will use the value of your [`basePath`](#base-path) if configured. @@ -514,9 +513,9 @@ export const routing = defineRouting({ ### `updateInterval` [#relative-times-update] From f10dbbaeb79e4fe4b339fe0e495c37ea11fece37 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 10 Jan 2025 09:39:20 +0100 Subject: [PATCH 66/90] feat: Allow overriding typed `Messages` when using `createTranslator` (#1655) Ref: https://x.com/ddunderfelt/status/1874403640515121642 --- packages/use-intl/src/core/IntlConfig.tsx | 80 ++++++++++--------- .../use-intl/src/core/createTranslator.tsx | 11 ++- 2 files changed, 48 insertions(+), 43 deletions(-) diff --git a/packages/use-intl/src/core/IntlConfig.tsx b/packages/use-intl/src/core/IntlConfig.tsx index e5b379a9d..7a4f85208 100644 --- a/packages/use-intl/src/core/IntlConfig.tsx +++ b/packages/use-intl/src/core/IntlConfig.tsx @@ -8,49 +8,51 @@ import type TimeZone from './TimeZone.tsx'; * Should be used for entry points that configure the library. */ -type IntlConfig = { - /** A valid Unicode locale tag (e.g. "en" or "en-GB"). */ - locale: Locale; - /** Global formats can be provided to achieve consistent - * formatting across components. */ - formats?: Formats; - /** A time zone as defined in [the tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) which will be applied when formatting dates and times. If this is absent, the user time zone will be used. You can override this by supplying an explicit time zone to `formatDateTime`. */ - timeZone?: TimeZone; - /** This callback will be invoked when an error is encountered during - * resolving a message or formatting it. This defaults to `console.error` to - * keep your app running. You can customize the handling by taking - * `error.code` into account. */ - onError?(error: IntlError): void; - /** Will be called when a message couldn't be resolved or formatting it led to - * an error. This defaults to `${namespace}.${key}` You can use this to - * customize what will be rendered in this case. */ - getMessageFallback?(info: { - error: IntlError; - key: string; - namespace?: string; - }): string; - /** - * Providing this value will have two effects: - * 1. It will be used as the default for the `now` argument of - * `useFormatter().formatRelativeTime` if no explicit value is provided. - * 2. It will be returned as a static value from the `useNow` hook. Note - * however that when `updateInterval` is configured on the `useNow` hook, - * the global `now` value will only be used for the initial render, but - * afterwards the current date will be returned continuously. - */ - now?: Date; - /** All messages that will be available. */ - messages?: Messages; -}; +type IntlConfig = + { + /** A valid Unicode locale tag (e.g. "en" or "en-GB"). */ + locale: Locale; + /** Global formats can be provided to achieve consistent + * formatting across components. */ + formats?: Formats; + /** A time zone as defined in [the tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) which will be applied when formatting dates and times. If this is absent, the user time zone will be used. You can override this by supplying an explicit time zone to `formatDateTime`. */ + timeZone?: TimeZone; + /** This callback will be invoked when an error is encountered during + * resolving a message or formatting it. This defaults to `console.error` to + * keep your app running. You can customize the handling by taking + * `error.code` into account. */ + onError?(error: IntlError): void; + /** Will be called when a message couldn't be resolved or formatting it led to + * an error. This defaults to `${namespace}.${key}` You can use this to + * customize what will be rendered in this case. */ + getMessageFallback?(info: { + error: IntlError; + key: string; + namespace?: string; + }): string; + /** + * Providing this value will have two effects: + * 1. It will be used as the default for the `now` argument of + * `useFormatter().formatRelativeTime` if no explicit value is provided. + * 2. It will be returned as a static value from the `useNow` hook. Note + * however that when `updateInterval` is configured on the `useNow` hook, + * the global `now` value will only be used for the initial render, but + * afterwards the current date will be returned continuously. + */ + now?: Date; + /** All messages that will be available. */ + messages?: Messages; + }; /** * A stricter set of the configuration that should be used internally * once defaults are assigned to `IntlConfiguration`. */ -export type InitializedIntlConfig = - IntlConfig & { - onError: NonNullable['onError']>; - getMessageFallback: NonNullable['getMessageFallback']>; - }; +export type InitializedIntlConfig< + Messages extends AbstractIntlMessages = AbstractIntlMessages +> = IntlConfig & { + onError: NonNullable['onError']>; + getMessageFallback: NonNullable['getMessageFallback']>; +}; export default IntlConfig; diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index eef02b5da..4b08ae315 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -1,5 +1,4 @@ import type {ReactNode} from 'react'; -import type {Messages} from './AppConfig.tsx'; import type Formats from './Formats.tsx'; import type ICUArgs from './ICUArgs.tsx'; import type ICUTags from './ICUTags.tsx'; @@ -62,8 +61,12 @@ type TranslateArgs< : [values: Prettify, formats?: Formats] : never; +// This type is slightly more loose than `AbstractIntlMessages` +// in order to avoid a type error. +type IntlMessages = Record; + type NamespacedMessageKeys< - TranslatorMessages extends Messages, + TranslatorMessages extends IntlMessages, Namespace extends NamespaceKeys< TranslatorMessages, NestedKeyOf @@ -82,7 +85,7 @@ type NamespacedMessageKeys< >; type NamespacedValue< - TranslatorMessages extends Messages, + TranslatorMessages extends IntlMessages, Namespace extends NamespaceKeys< TranslatorMessages, NestedKeyOf @@ -102,7 +105,7 @@ type NamespacedValue< * (e.g. `namespace.Component`). */ export default function createTranslator< - const TranslatorMessages extends Messages = Messages, + const TranslatorMessages extends IntlMessages, const Namespace extends NamespaceKeys< TranslatorMessages, NestedKeyOf From 32fc4e39391b282b5f9d033474969d316e7f893c Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 17 Jan 2025 12:33:49 +0100 Subject: [PATCH 67/90] fix: Retain index files in dist output to match declarations (#1665) --- packages/next-intl/package.json | 1 + packages/next-intl/rollup.config.js | 10 +- pnpm-lock.yaml | 150 ++++++++++++++-------------- 3 files changed, 85 insertions(+), 76 deletions(-) diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index e9fef23b6..6763a2a9b 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -136,6 +136,7 @@ "eslint": "^9.11.1", "eslint-config-molindo": "^8.0.0", "eslint-plugin-react-compiler": "0.0.0-experimental-8e3b87c-20240822", + "glob": "^11.0.1", "next": "^14.2.4", "path-to-regexp": "^6.2.2", "prettier": "^3.3.3", diff --git a/packages/next-intl/rollup.config.js b/packages/next-intl/rollup.config.js index 7a2543390..11af518f1 100644 --- a/packages/next-intl/rollup.config.js +++ b/packages/next-intl/rollup.config.js @@ -1,6 +1,7 @@ import preserveDirectives from 'rollup-plugin-preserve-directives'; import {getBuildConfig} from 'tools'; import pkg from './package.json' with {type: 'json'}; +import {glob} from 'glob'; function rewriteBundle(regex, replaceFn) { return { @@ -30,7 +31,14 @@ export default [ middleware: 'src/middleware.tsx', routing: 'src/routing.tsx', plugin: 'src/plugin.tsx', - config: 'src/config.tsx' + config: 'src/config.tsx', + + // Workaround for https://github.com/rollup/rollup/issues/3916 + // See https://x.com/jamannnnnn/status/1880199734280732841 + ...glob.sync('src/**/index.tsx').reduce((acc, file) => { + acc[file.replace('src/', '').replace('/index.tsx', '/index')] = file; + return acc; + }, {}) }, external: [ ...Object.keys(pkg.dependencies), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec944187c..3ecea6a87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,10 +40,10 @@ importers: version: 2.1.5(react@18.3.1) '@vercel/analytics': specifier: 1.3.1 - version: 1.3.1(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 1.3.1(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@vercel/speed-insights': specifier: ^1.0.12 - version: 1.0.13(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 1.0.13(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -55,10 +55,10 @@ importers: version: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nextra: specifier: ^3.1.0 - version: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) + version: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) nextra-theme-docs: specifier: ^3.1.0 - version: 3.1.0(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.1.0(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -89,7 +89,7 @@ importers: version: 9.13.0(jiti@2.3.3) eslint-config-molindo: specifier: ^8.0.0 - version: 8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) + version: 8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) fast-glob: specifier: ^3.3.2 version: 3.3.2 @@ -98,7 +98,7 @@ importers: version: 15.11.0 next-sitemap: specifier: ^4.2.3 - version: 4.2.3(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 4.2.3(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) next-validate-link: specifier: ^1.3.0 version: 1.3.0 @@ -266,7 +266,7 @@ importers: version: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-auth: specifier: ^4.24.7 - version: 4.24.8(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.24.8(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-intl: specifier: ^3.0.0 version: link:../../packages/next-intl @@ -617,7 +617,7 @@ importers: dependencies: next: specifier: ^12.0.0 - version: 12.3.4(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + version: 12.3.4(@babel/core@7.25.9)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) next-intl: specifier: ^3.0.0 version: link:../../packages/next-intl @@ -799,10 +799,13 @@ importers: version: 9.13.0(jiti@2.3.3) eslint-config-molindo: specifier: ^8.0.0 - version: 8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@3.2.0)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) + version: 8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@3.2.0)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) eslint-plugin-react-compiler: specifier: 0.0.0-experimental-8e3b87c-20240822 version: 0.0.0-experimental-8e3b87c-20240822(eslint@9.13.0(jiti@2.3.3)) + glob: + specifier: ^11.0.1 + version: 11.0.1 next: specifier: ^14.2.4 version: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2746,7 +2749,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.4.11': resolution: {integrity: sha512-L9Ci9RBh0aPFEDF1AjDYPk54OgeUJIKzxF3lRgITm+lQpI3IEKjAc9LaYeQeO1mlZMUQmPkHArF8iyz1eOeVoQ==} @@ -5179,6 +5182,7 @@ packages: '@xmldom/xmldom@0.7.13': resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==} engines: {node: '>=10.0.0'} + deprecated: this version is no longer supported, please update to at least 0.8.* '@xmldom/xmldom@0.8.10': resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==} @@ -8180,15 +8184,15 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - glob@10.4.2: - resolution: {integrity: sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w==} - engines: {node: '>=16 || 14 >=14.18'} - hasBin: true - glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true + glob@11.0.1: + resolution: {integrity: sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==} + engines: {node: 20 || >=22} + hasBin: true + glob@6.0.4: resolution: {integrity: sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==} deprecated: Glob versions prior to v9 are no longer supported @@ -9182,13 +9186,13 @@ packages: iterator.prototype@1.1.2: resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} - jackspeak@3.4.0: - resolution: {integrity: sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==} - engines: {node: '>=14'} - jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.0.2: + resolution: {integrity: sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==} + engines: {node: 20 || >=22} + javascript-stringify@2.1.0: resolution: {integrity: sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==} @@ -9711,6 +9715,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.0.2: + resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==} + engines: {node: 20 || >=22} + lru-cache@4.1.5: resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} @@ -10288,6 +10296,10 @@ packages: minimalistic-crypto-utils@1.0.1: resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + minimatch@10.0.1: + resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} + engines: {node: 20 || >=22} + minimatch@3.0.4: resolution: {integrity: sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==} @@ -11175,6 +11187,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + path-to-regexp@0.1.10: resolution: {integrity: sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==} @@ -11758,6 +11774,7 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. + (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qrcode-terminal@0.11.0: @@ -13062,12 +13079,15 @@ packages: sudo-prompt@8.2.5: resolution: {integrity: sha512-rlBo3HU/1zAJUrkY6jNxDOC9eVYliG6nS4JA8u8KAshITd07tafMc/Br7xQwCSseXwJ2iCcHCE8SNWX3q8Z+kw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. sudo-prompt@9.1.1: resolution: {integrity: sha512-es33J1g2HjMpyAhz8lOR+ICmXXAqTuKbuXuUWLhOLew20oN9oUCgCJx615U/v7aioZg7IX5lIh9x34vwneu4pA==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. sudo-prompt@9.2.1: resolution: {integrity: sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. supports-color@4.5.0: resolution: {integrity: sha512-ycQR/UbvI9xIlEdQT1TQqwoXtEldExbCEAJgRo5YXlmSKjv6ThHnP9/vwGa1gr19Gfw+LkFd7KqYMhzrRC5JYw==} @@ -17635,7 +17655,7 @@ snapshots: '@npmcli/package-json@4.0.1': dependencies: '@npmcli/git': 4.1.0 - glob: 10.4.2 + glob: 10.4.5 hosted-git-info: 6.1.1 json-parse-even-better-errors: 3.0.2 normalize-package-data: 5.0.0 @@ -19523,14 +19543,14 @@ snapshots: '@vanilla-extract/private@1.0.3': {} - '@vercel/analytics@1.3.1(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/analytics@1.3.1(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': dependencies: server-only: 0.0.1 optionalDependencies: next: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - '@vercel/speed-insights@1.0.13(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/speed-insights@1.0.13(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': optionalDependencies: next: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -20726,7 +20746,7 @@ snapshots: dependencies: '@npmcli/fs': 3.1.1 fs-minipass: 3.0.3 - glob: 10.4.2 + glob: 10.4.5 lru-cache: 7.18.3 minipass: 7.1.2 minipass-collect: 1.0.2 @@ -22515,37 +22535,6 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-molindo@8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@3.2.0)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)): - dependencies: - '@eslint/js': 9.12.0 - '@typescript-eslint/utils': 8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) - '@vitest/eslint-plugin': 1.1.7(@typescript-eslint/utils@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@3.2.0)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) - confusing-browser-globals: 1.0.11 - eslint: 9.13.0(jiti@2.3.3) - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint-plugin-import@2.31.0)(eslint@9.13.0(jiti@2.3.3)) - eslint-plugin-css-modules: 2.11.0(eslint@9.13.0(jiti@2.3.3)) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@9.13.0(jiti@2.3.3)) - eslint-plugin-jest: 28.8.3(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(typescript@5.6.3) - eslint-plugin-jsx-a11y: 6.10.0(eslint@9.13.0(jiti@2.3.3)) - eslint-plugin-react: 7.37.1(eslint@9.13.0(jiti@2.3.3)) - eslint-plugin-react-hooks: 5.0.0(eslint@9.13.0(jiti@2.3.3)) - eslint-plugin-sort-destructure-keys: 2.0.0(eslint@9.13.0(jiti@2.3.3)) - eslint-plugin-tailwindcss: 3.13.0(tailwindcss@3.4.14) - eslint-plugin-unicorn: 56.0.0(eslint@9.13.0(jiti@2.3.3)) - eslint-plugin-unused-imports: 4.1.4(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3)) - typescript-eslint: 8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) - transitivePeerDependencies: - - '@typescript-eslint/eslint-plugin' - - '@typescript-eslint/parser' - - eslint-import-resolver-node - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - jest - - supports-color - - tailwindcss - - typescript - - vitest - eslint-config-molindo@8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)): dependencies: '@eslint/js': 9.12.0 @@ -22608,11 +22597,11 @@ snapshots: - typescript - vitest - eslint-config-molindo@8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)): + eslint-config-molindo@8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@3.2.0)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)): dependencies: '@eslint/js': 9.12.0 '@typescript-eslint/utils': 8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) - '@vitest/eslint-plugin': 1.1.7(@typescript-eslint/utils@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) + '@vitest/eslint-plugin': 1.1.7(@typescript-eslint/utils@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@3.2.0)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) confusing-browser-globals: 1.0.11 eslint: 9.13.0(jiti@2.3.3) eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint-plugin-import@2.31.0)(eslint@9.13.0(jiti@2.3.3)) @@ -23817,23 +23806,23 @@ snapshots: glob-to-regexp@0.4.1: {} - glob@10.4.2: + glob@10.4.5: dependencies: foreground-child: 3.2.1 - jackspeak: 3.4.0 - minimatch: 9.0.4 + jackspeak: 3.4.3 + minimatch: 9.0.5 minipass: 7.1.2 package-json-from-dist: 1.0.0 path-scurry: 1.11.1 - glob@10.4.5: + glob@11.0.1: dependencies: foreground-child: 3.2.1 - jackspeak: 3.4.3 - minimatch: 9.0.5 + jackspeak: 4.0.2 + minimatch: 10.0.1 minipass: 7.1.2 package-json-from-dist: 1.0.0 - path-scurry: 1.11.1 + path-scurry: 2.0.0 glob@6.0.4: dependencies: @@ -24960,17 +24949,15 @@ snapshots: reflect.getprototypeof: 1.0.6 set-function-name: 2.0.2 - jackspeak@3.4.0: + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jackspeak@3.4.3: + jackspeak@4.0.2: dependencies: '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 javascript-stringify@2.1.0: {} @@ -25791,6 +25778,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.0.2: {} + lru-cache@4.1.5: dependencies: pseudomap: 1.0.2 @@ -27127,6 +27116,10 @@ snapshots: minimalistic-crypto-utils@1.0.1: {} + minimatch@10.0.1: + dependencies: + brace-expansion: 2.0.1 + minimatch@3.0.4: dependencies: brace-expansion: 1.1.11 @@ -27347,7 +27340,7 @@ snapshots: dependencies: type-fest: 2.19.0 - next-auth@4.24.8(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-auth@4.24.8(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.24.7 '@panva/hkdf': 1.2.0 @@ -27362,7 +27355,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) uuid: 8.3.2 - next-sitemap@4.2.3(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): + next-sitemap@4.2.3(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.6 @@ -27386,7 +27379,7 @@ snapshots: transitivePeerDependencies: - supports-color - next@12.3.4(react-dom@17.0.2(react@17.0.2))(react@17.0.2): + next@12.3.4(@babel/core@7.25.9)(react-dom@17.0.2(react@17.0.2))(react@17.0.2): dependencies: '@next/env': 12.3.4 '@swc/helpers': 0.4.11 @@ -27394,7 +27387,7 @@ snapshots: postcss: 8.4.14 react: 17.0.2 react-dom: 17.0.2(react@17.0.2) - styled-jsx: 5.0.7(react@17.0.2) + styled-jsx: 5.0.7(@babel/core@7.25.9)(react@17.0.2) use-sync-external-store: 1.2.0(react@17.0.2) optionalDependencies: '@next/swc-android-arm-eabi': 12.3.4 @@ -27440,7 +27433,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - nextra-theme-docs@3.1.0(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + nextra-theme-docs@3.1.0(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@headlessui/react': 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) clsx: 2.1.1 @@ -27448,13 +27441,13 @@ snapshots: flexsearch: 0.7.43 next: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - nextra: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) + nextra: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) scroll-into-view-if-needed: 3.1.0 zod: 3.23.8 - nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3): + nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3): dependencies: '@formatjs/intl-localematcher': 0.5.5 '@headlessui/react': 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -28224,6 +28217,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-scurry@2.0.0: + dependencies: + lru-cache: 11.0.2 + minipass: 7.1.2 + path-to-regexp@0.1.10: {} path-to-regexp@6.3.0: {} @@ -30512,9 +30510,11 @@ snapshots: dependencies: inline-style-parser: 0.2.4 - styled-jsx@5.0.7(react@17.0.2): + styled-jsx@5.0.7(@babel/core@7.25.9)(react@17.0.2): dependencies: react: 17.0.2 + optionalDependencies: + '@babel/core': 7.25.9 styled-jsx@5.1.1(@babel/core@7.25.9)(react@18.3.1): dependencies: From bb49880bf97922829e94d8f7623c5172b90c0eff Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 17 Jan 2025 13:18:19 +0100 Subject: [PATCH 68/90] Revert "fix: Retain index files in dist output to match declarations (#1665)" This reverts commit 32fc4e39391b282b5f9d033474969d316e7f893c. --- packages/next-intl/package.json | 1 - packages/next-intl/rollup.config.js | 10 +- pnpm-lock.yaml | 150 ++++++++++++++-------------- 3 files changed, 76 insertions(+), 85 deletions(-) diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 6763a2a9b..e9fef23b6 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -136,7 +136,6 @@ "eslint": "^9.11.1", "eslint-config-molindo": "^8.0.0", "eslint-plugin-react-compiler": "0.0.0-experimental-8e3b87c-20240822", - "glob": "^11.0.1", "next": "^14.2.4", "path-to-regexp": "^6.2.2", "prettier": "^3.3.3", diff --git a/packages/next-intl/rollup.config.js b/packages/next-intl/rollup.config.js index 11af518f1..7a2543390 100644 --- a/packages/next-intl/rollup.config.js +++ b/packages/next-intl/rollup.config.js @@ -1,7 +1,6 @@ import preserveDirectives from 'rollup-plugin-preserve-directives'; import {getBuildConfig} from 'tools'; import pkg from './package.json' with {type: 'json'}; -import {glob} from 'glob'; function rewriteBundle(regex, replaceFn) { return { @@ -31,14 +30,7 @@ export default [ middleware: 'src/middleware.tsx', routing: 'src/routing.tsx', plugin: 'src/plugin.tsx', - config: 'src/config.tsx', - - // Workaround for https://github.com/rollup/rollup/issues/3916 - // See https://x.com/jamannnnnn/status/1880199734280732841 - ...glob.sync('src/**/index.tsx').reduce((acc, file) => { - acc[file.replace('src/', '').replace('/index.tsx', '/index')] = file; - return acc; - }, {}) + config: 'src/config.tsx' }, external: [ ...Object.keys(pkg.dependencies), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ecea6a87..ec944187c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,10 +40,10 @@ importers: version: 2.1.5(react@18.3.1) '@vercel/analytics': specifier: 1.3.1 - version: 1.3.1(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 1.3.1(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@vercel/speed-insights': specifier: ^1.0.12 - version: 1.0.13(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 1.0.13(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -55,10 +55,10 @@ importers: version: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nextra: specifier: ^3.1.0 - version: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) + version: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) nextra-theme-docs: specifier: ^3.1.0 - version: 3.1.0(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.1.0(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -89,7 +89,7 @@ importers: version: 9.13.0(jiti@2.3.3) eslint-config-molindo: specifier: ^8.0.0 - version: 8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) + version: 8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) fast-glob: specifier: ^3.3.2 version: 3.3.2 @@ -98,7 +98,7 @@ importers: version: 15.11.0 next-sitemap: specifier: ^4.2.3 - version: 4.2.3(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 4.2.3(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) next-validate-link: specifier: ^1.3.0 version: 1.3.0 @@ -266,7 +266,7 @@ importers: version: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-auth: specifier: ^4.24.7 - version: 4.24.8(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.24.8(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-intl: specifier: ^3.0.0 version: link:../../packages/next-intl @@ -617,7 +617,7 @@ importers: dependencies: next: specifier: ^12.0.0 - version: 12.3.4(@babel/core@7.25.9)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + version: 12.3.4(react-dom@17.0.2(react@17.0.2))(react@17.0.2) next-intl: specifier: ^3.0.0 version: link:../../packages/next-intl @@ -799,13 +799,10 @@ importers: version: 9.13.0(jiti@2.3.3) eslint-config-molindo: specifier: ^8.0.0 - version: 8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@3.2.0)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) + version: 8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@3.2.0)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) eslint-plugin-react-compiler: specifier: 0.0.0-experimental-8e3b87c-20240822 version: 0.0.0-experimental-8e3b87c-20240822(eslint@9.13.0(jiti@2.3.3)) - glob: - specifier: ^11.0.1 - version: 11.0.1 next: specifier: ^14.2.4 version: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2749,7 +2746,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.4.11': resolution: {integrity: sha512-L9Ci9RBh0aPFEDF1AjDYPk54OgeUJIKzxF3lRgITm+lQpI3IEKjAc9LaYeQeO1mlZMUQmPkHArF8iyz1eOeVoQ==} @@ -5182,7 +5179,6 @@ packages: '@xmldom/xmldom@0.7.13': resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==} engines: {node: '>=10.0.0'} - deprecated: this version is no longer supported, please update to at least 0.8.* '@xmldom/xmldom@0.8.10': resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==} @@ -8184,13 +8180,13 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + glob@10.4.2: + resolution: {integrity: sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w==} + engines: {node: '>=16 || 14 >=14.18'} hasBin: true - glob@11.0.1: - resolution: {integrity: sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==} - engines: {node: 20 || >=22} + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true glob@6.0.4: @@ -9186,13 +9182,13 @@ packages: iterator.prototype@1.1.2: resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} + jackspeak@3.4.0: + resolution: {integrity: sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==} + engines: {node: '>=14'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jackspeak@4.0.2: - resolution: {integrity: sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==} - engines: {node: 20 || >=22} - javascript-stringify@2.1.0: resolution: {integrity: sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==} @@ -9715,10 +9711,6 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.0.2: - resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==} - engines: {node: 20 || >=22} - lru-cache@4.1.5: resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} @@ -10296,10 +10288,6 @@ packages: minimalistic-crypto-utils@1.0.1: resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} - minimatch@10.0.1: - resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} - engines: {node: 20 || >=22} - minimatch@3.0.4: resolution: {integrity: sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==} @@ -11187,10 +11175,6 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.0: - resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} - engines: {node: 20 || >=22} - path-to-regexp@0.1.10: resolution: {integrity: sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==} @@ -11774,7 +11758,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qrcode-terminal@0.11.0: @@ -13079,15 +13062,12 @@ packages: sudo-prompt@8.2.5: resolution: {integrity: sha512-rlBo3HU/1zAJUrkY6jNxDOC9eVYliG6nS4JA8u8KAshITd07tafMc/Br7xQwCSseXwJ2iCcHCE8SNWX3q8Z+kw==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. sudo-prompt@9.1.1: resolution: {integrity: sha512-es33J1g2HjMpyAhz8lOR+ICmXXAqTuKbuXuUWLhOLew20oN9oUCgCJx615U/v7aioZg7IX5lIh9x34vwneu4pA==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. sudo-prompt@9.2.1: resolution: {integrity: sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. supports-color@4.5.0: resolution: {integrity: sha512-ycQR/UbvI9xIlEdQT1TQqwoXtEldExbCEAJgRo5YXlmSKjv6ThHnP9/vwGa1gr19Gfw+LkFd7KqYMhzrRC5JYw==} @@ -17655,7 +17635,7 @@ snapshots: '@npmcli/package-json@4.0.1': dependencies: '@npmcli/git': 4.1.0 - glob: 10.4.5 + glob: 10.4.2 hosted-git-info: 6.1.1 json-parse-even-better-errors: 3.0.2 normalize-package-data: 5.0.0 @@ -19543,14 +19523,14 @@ snapshots: '@vanilla-extract/private@1.0.3': {} - '@vercel/analytics@1.3.1(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/analytics@1.3.1(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': dependencies: server-only: 0.0.1 optionalDependencies: next: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - '@vercel/speed-insights@1.0.13(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/speed-insights@1.0.13(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': optionalDependencies: next: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -20746,7 +20726,7 @@ snapshots: dependencies: '@npmcli/fs': 3.1.1 fs-minipass: 3.0.3 - glob: 10.4.5 + glob: 10.4.2 lru-cache: 7.18.3 minipass: 7.1.2 minipass-collect: 1.0.2 @@ -22535,6 +22515,37 @@ snapshots: optionalDependencies: source-map: 0.6.1 + eslint-config-molindo@8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@3.2.0)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)): + dependencies: + '@eslint/js': 9.12.0 + '@typescript-eslint/utils': 8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + '@vitest/eslint-plugin': 1.1.7(@typescript-eslint/utils@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@3.2.0)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) + confusing-browser-globals: 1.0.11 + eslint: 9.13.0(jiti@2.3.3) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint-plugin-import@2.31.0)(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-css-modules: 2.11.0(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-jest: 28.8.3(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(typescript@5.6.3) + eslint-plugin-jsx-a11y: 6.10.0(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-react: 7.37.1(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-react-hooks: 5.0.0(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-sort-destructure-keys: 2.0.0(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-tailwindcss: 3.13.0(tailwindcss@3.4.14) + eslint-plugin-unicorn: 56.0.0(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-unused-imports: 4.1.4(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3)) + typescript-eslint: 8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + transitivePeerDependencies: + - '@typescript-eslint/eslint-plugin' + - '@typescript-eslint/parser' + - eslint-import-resolver-node + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - jest + - supports-color + - tailwindcss + - typescript + - vitest + eslint-config-molindo@8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)): dependencies: '@eslint/js': 9.12.0 @@ -22597,11 +22608,11 @@ snapshots: - typescript - vitest - eslint-config-molindo@8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@3.2.0)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)): + eslint-config-molindo@8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)): dependencies: '@eslint/js': 9.12.0 '@typescript-eslint/utils': 8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) - '@vitest/eslint-plugin': 1.1.7(@typescript-eslint/utils@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@3.2.0)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) + '@vitest/eslint-plugin': 1.1.7(@typescript-eslint/utils@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.0)) confusing-browser-globals: 1.0.11 eslint: 9.13.0(jiti@2.3.3) eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint-plugin-import@2.31.0)(eslint@9.13.0(jiti@2.3.3)) @@ -23806,23 +23817,23 @@ snapshots: glob-to-regexp@0.4.1: {} - glob@10.4.5: + glob@10.4.2: dependencies: foreground-child: 3.2.1 - jackspeak: 3.4.3 - minimatch: 9.0.5 + jackspeak: 3.4.0 + minimatch: 9.0.4 minipass: 7.1.2 package-json-from-dist: 1.0.0 path-scurry: 1.11.1 - glob@11.0.1: + glob@10.4.5: dependencies: foreground-child: 3.2.1 - jackspeak: 4.0.2 - minimatch: 10.0.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 minipass: 7.1.2 package-json-from-dist: 1.0.0 - path-scurry: 2.0.0 + path-scurry: 1.11.1 glob@6.0.4: dependencies: @@ -24949,15 +24960,17 @@ snapshots: reflect.getprototypeof: 1.0.6 set-function-name: 2.0.2 - jackspeak@3.4.3: + jackspeak@3.4.0: dependencies: '@isaacs/cliui': 8.0.2 optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jackspeak@4.0.2: + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 javascript-stringify@2.1.0: {} @@ -25778,8 +25791,6 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.0.2: {} - lru-cache@4.1.5: dependencies: pseudomap: 1.0.2 @@ -27116,10 +27127,6 @@ snapshots: minimalistic-crypto-utils@1.0.1: {} - minimatch@10.0.1: - dependencies: - brace-expansion: 2.0.1 - minimatch@3.0.4: dependencies: brace-expansion: 1.1.11 @@ -27340,7 +27347,7 @@ snapshots: dependencies: type-fest: 2.19.0 - next-auth@4.24.8(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-auth@4.24.8(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.24.7 '@panva/hkdf': 1.2.0 @@ -27355,7 +27362,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) uuid: 8.3.2 - next-sitemap@4.2.3(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): + next-sitemap@4.2.3(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.6 @@ -27379,7 +27386,7 @@ snapshots: transitivePeerDependencies: - supports-color - next@12.3.4(@babel/core@7.25.9)(react-dom@17.0.2(react@17.0.2))(react@17.0.2): + next@12.3.4(react-dom@17.0.2(react@17.0.2))(react@17.0.2): dependencies: '@next/env': 12.3.4 '@swc/helpers': 0.4.11 @@ -27387,7 +27394,7 @@ snapshots: postcss: 8.4.14 react: 17.0.2 react-dom: 17.0.2(react@17.0.2) - styled-jsx: 5.0.7(@babel/core@7.25.9)(react@17.0.2) + styled-jsx: 5.0.7(react@17.0.2) use-sync-external-store: 1.2.0(react@17.0.2) optionalDependencies: '@next/swc-android-arm-eabi': 12.3.4 @@ -27433,7 +27440,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - nextra-theme-docs@3.1.0(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + nextra-theme-docs@3.1.0(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@headlessui/react': 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) clsx: 2.1.1 @@ -27441,13 +27448,13 @@ snapshots: flexsearch: 0.7.43 next: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - nextra: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) + nextra: 3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) scroll-into-view-if-needed: 3.1.0 zod: 3.23.8 - nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3): + nextra@3.1.0(@types/react@18.3.12)(acorn@8.13.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3): dependencies: '@formatjs/intl-localematcher': 0.5.5 '@headlessui/react': 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -28217,11 +28224,6 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 - path-scurry@2.0.0: - dependencies: - lru-cache: 11.0.2 - minipass: 7.1.2 - path-to-regexp@0.1.10: {} path-to-regexp@6.3.0: {} @@ -30510,11 +30512,9 @@ snapshots: dependencies: inline-style-parser: 0.2.4 - styled-jsx@5.0.7(@babel/core@7.25.9)(react@17.0.2): + styled-jsx@5.0.7(react@17.0.2): dependencies: react: 17.0.2 - optionalDependencies: - '@babel/core': 7.25.9 styled-jsx@5.1.1(@babel/core@7.25.9)(react@18.3.1): dependencies: From f51179744dc099364f9273d77abb75d39788fb6c Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 17 Jan 2025 13:18:53 +0100 Subject: [PATCH 69/90] fix: Remove scripts from published packages --- lerna.json | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/lerna.json b/lerna.json index abe6d8a11..5a71133f6 100644 --- a/lerna.json +++ b/lerna.json @@ -1,17 +1,12 @@ { "$schema": "node_modules/@lerna-lite/cli/schemas/lerna-schema.json", "version": "3.26.2", - "packages": [ - "packages/*" - ], + "packages": ["packages/*"], "npmClient": "pnpm", "changelogPreset": "conventional-changelog-conventionalcommits", "command": { "publish": { - "removePackageFields": [ - "devDependencies", - "prettier" - ], + "removePackageFields": ["scripts", "devDependencies", "prettier"], "yes": true }, "version": { From 5ec7f45e65b42ac76099efb1bef25f27f8e891bd Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 17 Jan 2025 13:25:09 +0100 Subject: [PATCH 70/90] fix: Remove scripts from ignored pkg fields --- lerna.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lerna.json b/lerna.json index 5a71133f6..6ae7844c8 100644 --- a/lerna.json +++ b/lerna.json @@ -6,7 +6,7 @@ "changelogPreset": "conventional-changelog-conventionalcommits", "command": { "publish": { - "removePackageFields": ["scripts", "devDependencies", "prettier"], + "removePackageFields": ["devDependencies", "prettier"], "yes": true }, "version": { From 5b218d4123697a6cc3efe26a74675451a33c3fd6 Mon Sep 17 00:00:00 2001 From: Thomas Wiringa Date: Fri, 24 Jan 2025 11:14:26 +0100 Subject: [PATCH 71/90] fix: Change imports to use `.js` to make them compatible with `eslint-plugin-import` (#1667) @amannn Following our conversation on X, these are the changes that I've made that appear to be fixing the imports when the `import/named` ESLint rule is used. As mentioned before, I also took the liberty of adding the ESLint rule `'import/extensions': 'error'` to ensure the right extensions are used in the import/export statements. For context, when compiling to ESM modules, TS copies over the import/export statements as-is, so during development, runtime compatible file extensions must be used. For local testing I ran a build and then manually copied over the `dist` folder into `node_modules/next-intl` in my other project. --- packages/next-intl/eslint.config.mjs | 3 +- packages/next-intl/src/index.react-client.tsx | 2 +- packages/next-intl/src/index.react-server.tsx | 2 +- packages/next-intl/src/middleware.tsx | 2 +- .../getAlternateLinksHeaderValue.test.tsx | 6 +-- .../getAlternateLinksHeaderValue.tsx | 8 ++-- packages/next-intl/src/middleware/index.tsx | 2 +- .../src/middleware/middleware.test.tsx | 4 +- .../next-intl/src/middleware/middleware.tsx | 16 ++++---- .../src/middleware/resolveLocale.test.tsx | 2 +- .../src/middleware/resolveLocale.tsx | 10 ++--- .../next-intl/src/middleware/syncCookie.tsx | 6 +-- .../next-intl/src/middleware/utils.test.tsx | 2 +- packages/next-intl/src/middleware/utils.tsx | 4 +- .../next-intl/src/navigation.react-client.tsx | 2 +- .../next-intl/src/navigation.react-server.tsx | 2 +- .../src/navigation/createNavigation.test.tsx | 12 ++---- .../react-client/createNavigation.test.tsx | 4 +- .../react-client/createNavigation.tsx | 12 +++--- .../src/navigation/react-client/index.tsx | 4 +- .../react-client/useBasePathname.test.tsx | 4 +- .../react-client/useBasePathname.tsx | 4 +- .../react-server/createNavigation.test.tsx | 2 +- .../react-server/createNavigation.tsx | 8 ++-- .../react-server/getServerLocale.tsx | 2 +- .../src/navigation/react-server/index.tsx | 4 +- .../src/navigation/shared/BaseLink.tsx | 4 +- .../shared/createSharedNavigationFns.tsx | 14 +++---- .../navigation/shared/syncLocaleCookie.tsx | 4 +- .../src/navigation/shared/utils.test.tsx | 2 +- .../next-intl/src/navigation/shared/utils.tsx | 8 ++-- packages/next-intl/src/plugin.tsx | 2 +- .../src/plugin/createMessagesDeclaration.tsx | 2 +- .../src/plugin/createNextIntlPlugin.tsx | 8 ++-- .../next-intl/src/plugin/getNextConfig.tsx | 4 +- packages/next-intl/src/plugin/index.tsx | 2 +- packages/next-intl/src/react-client/index.tsx | 2 +- .../src/react-client/useFormatter.test.tsx | 2 +- .../src/react-client/useNow.test.tsx | 2 +- .../src/react-client/useTimeZone.test.tsx | 2 +- .../src/react-client/useTranslations.test.tsx | 2 +- .../NextIntlClientProviderServer.test.tsx | 10 ++--- .../NextIntlClientProviderServer.tsx | 8 ++-- .../next-intl/src/react-server/index.test.tsx | 6 +-- packages/next-intl/src/react-server/index.tsx | 14 +++---- .../next-intl/src/react-server/useConfig.tsx | 4 +- .../src/react-server/useFormatter.test.tsx | 6 +-- .../src/react-server/useFormatter.tsx | 4 +- .../next-intl/src/react-server/useLocale.tsx | 2 +- .../src/react-server/useMessages.tsx | 4 +- .../next-intl/src/react-server/useNow.tsx | 4 +- .../src/react-server/useTimeZone.tsx | 2 +- .../src/react-server/useTranslations.test.tsx | 4 +- .../src/react-server/useTranslations.tsx | 4 +- packages/next-intl/src/routing.tsx | 2 +- packages/next-intl/src/routing/config.tsx | 2 +- .../src/routing/defineRouting.test.tsx | 2 +- .../next-intl/src/routing/defineRouting.tsx | 6 +-- packages/next-intl/src/routing/index.tsx | 6 +-- packages/next-intl/src/routing/types.test.tsx | 2 +- .../src/routing/validateLocales.test.tsx | 2 +- .../next-intl/src/routing/validateLocales.tsx | 2 +- .../next-intl/src/server.react-client.tsx | 2 +- .../next-intl/src/server.react-server.tsx | 2 +- .../src/server/react-client/index.test.tsx | 2 +- .../src/server/react-client/index.tsx | 2 +- .../src/server/react-server/RequestLocale.tsx | 4 +- .../react-server/createRequestConfig.tsx | 2 +- .../src/server/react-server/getConfig.tsx | 6 +-- .../src/server/react-server/getConfigNow.tsx | 2 +- .../src/server/react-server/getFormats.tsx | 2 +- .../server/react-server/getFormatter.test.tsx | 4 +- .../src/server/react-server/getFormatter.tsx | 4 +- .../src/server/react-server/getLocale.tsx | 2 +- .../src/server/react-server/getMessages.tsx | 2 +- .../src/server/react-server/getNow.tsx | 4 +- .../react-server/getServerFormatter.tsx | 2 +- .../src/server/react-server/getTimeZone.tsx | 2 +- .../react-server/getTranslations.test.tsx | 2 +- .../server/react-server/getTranslations.tsx | 4 +- .../src/server/react-server/index.test.tsx | 4 +- .../src/server/react-server/index.tsx | 16 ++++---- packages/next-intl/src/shared/utils.test.tsx | 2 +- packages/next-intl/src/shared/utils.tsx | 2 +- packages/next-intl/tsconfig.json | 1 - packages/use-intl/eslint.config.mjs | 3 +- packages/use-intl/src/core.tsx | 2 +- .../src/core/DateTimeFormatOptions.tsx | 2 +- packages/use-intl/src/core/Formats.tsx | 4 +- packages/use-intl/src/core/IntlConfig.tsx | 10 ++--- packages/use-intl/src/core/IntlError.tsx | 2 +- .../convertFormatsToIntlMessageFormat.tsx | 4 +- .../src/core/createBaseTranslator.tsx | 24 +++++------ .../src/core/createFormatter.test.tsx | 2 +- .../use-intl/src/core/createFormatter.tsx | 20 +++++----- .../src/core/createTranslator.test.tsx | 8 ++-- .../use-intl/src/core/createTranslator.tsx | 20 +++++----- .../src/core/createTranslatorImpl.tsx | 12 +++--- packages/use-intl/src/core/defaults.tsx | 4 +- packages/use-intl/src/core/hasLocale.test.tsx | 2 +- packages/use-intl/src/core/hasLocale.tsx | 2 +- packages/use-intl/src/core/index.tsx | 40 +++++++++---------- .../use-intl/src/core/initializeConfig.tsx | 6 +-- .../use-intl/src/core/validateMessages.tsx | 8 ++-- packages/use-intl/src/index.tsx | 4 +- packages/use-intl/src/react.tsx | 2 +- packages/use-intl/src/react/IntlContext.tsx | 4 +- .../use-intl/src/react/IntlProvider.test.tsx | 6 +-- packages/use-intl/src/react/IntlProvider.tsx | 8 ++-- packages/use-intl/src/react/index.test.tsx | 10 ++--- packages/use-intl/src/react/index.tsx | 14 +++---- .../use-intl/src/react/useFormatter.test.tsx | 6 +-- packages/use-intl/src/react/useFormatter.tsx | 4 +- .../use-intl/src/react/useIntlContext.tsx | 2 +- .../use-intl/src/react/useLocale.test.tsx | 4 +- packages/use-intl/src/react/useLocale.tsx | 4 +- .../use-intl/src/react/useMessages.test.tsx | 4 +- packages/use-intl/src/react/useMessages.tsx | 4 +- packages/use-intl/src/react/useNow.test.tsx | 4 +- packages/use-intl/src/react/useNow.tsx | 2 +- .../use-intl/src/react/useTimeZone.test.tsx | 4 +- packages/use-intl/src/react/useTimeZone.tsx | 2 +- .../src/react/useTranslations.test.tsx | 6 +-- .../use-intl/src/react/useTranslations.tsx | 10 ++--- .../src/react/useTranslationsImpl.tsx | 12 +++--- packages/use-intl/tsconfig.json | 1 - 126 files changed, 322 insertions(+), 330 deletions(-) diff --git a/packages/next-intl/eslint.config.mjs b/packages/next-intl/eslint.config.mjs index a2d92e6aa..9308ce875 100644 --- a/packages/next-intl/eslint.config.mjs +++ b/packages/next-intl/eslint.config.mjs @@ -27,6 +27,7 @@ export default (await getPresets('typescript', 'react', 'vitest')).concat({ '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/consistent-type-exports': 'error', '@typescript-eslint/no-import-type-side-effects': 'error', - 'import/no-duplicates': ['error', {'prefer-inline': true}] + 'import/no-duplicates': ['error', {'prefer-inline': true}], + 'import/extensions': 'error' } }); diff --git a/packages/next-intl/src/index.react-client.tsx b/packages/next-intl/src/index.react-client.tsx index d1d057aec..ccc872cea 100644 --- a/packages/next-intl/src/index.react-client.tsx +++ b/packages/next-intl/src/index.react-client.tsx @@ -6,4 +6,4 @@ * from `./react-server` instead. */ -export * from './react-client/index.tsx'; +export * from './react-client/index.js'; diff --git a/packages/next-intl/src/index.react-server.tsx b/packages/next-intl/src/index.react-server.tsx index 172bd7da0..b80740326 100644 --- a/packages/next-intl/src/index.react-server.tsx +++ b/packages/next-intl/src/index.react-server.tsx @@ -1 +1 @@ -export * from './react-server/index.tsx'; +export * from './react-server/index.js'; diff --git a/packages/next-intl/src/middleware.tsx b/packages/next-intl/src/middleware.tsx index 0943679ef..d02f285ee 100644 --- a/packages/next-intl/src/middleware.tsx +++ b/packages/next-intl/src/middleware.tsx @@ -1 +1 @@ -export {default} from './middleware/index.tsx'; +export {default} from './middleware/index.js'; diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx index 562331ec8..5ef8dd183 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx @@ -2,9 +2,9 @@ import {NextRequest} from 'next/server.js'; import {afterEach, beforeEach, describe, expect, it} from 'vitest'; -import {receiveRoutingConfig} from '../routing/config.tsx'; -import type {Pathnames} from '../routing.tsx'; -import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue.tsx'; +import {receiveRoutingConfig} from '../routing/config.js'; +import type {Pathnames} from '../routing.js'; +import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue.js'; describe.each([{basePath: undefined}, {basePath: '/base'}])( 'basePath: $basePath', diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 52f627ba3..fbe9e44b6 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -1,12 +1,12 @@ import type {NextRequest} from 'next/server.js'; -import type {ResolvedRoutingConfig} from '../routing/config.tsx'; +import type {ResolvedRoutingConfig} from '../routing/config.js'; import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../routing/types.tsx'; -import {normalizeTrailingSlash} from '../shared/utils.tsx'; +} from '../routing/types.js'; +import {normalizeTrailingSlash} from '../shared/utils.js'; import { applyBasePath, formatTemplatePathname, @@ -14,7 +14,7 @@ import { getLocalePrefixes, getNormalizedPathname, isLocaleSupportedOnDomain -} from './utils.tsx'; +} from './utils.js'; /** * See https://developers.google.com/search/docs/specialty/international/localized-versions diff --git a/packages/next-intl/src/middleware/index.tsx b/packages/next-intl/src/middleware/index.tsx index 0cb9c5e88..b5a6b6397 100644 --- a/packages/next-intl/src/middleware/index.tsx +++ b/packages/next-intl/src/middleware/index.tsx @@ -2,4 +2,4 @@ * The middleware, available as `next-intl/middleware`. */ -export {default} from './middleware.tsx'; +export {default} from './middleware.js'; diff --git a/packages/next-intl/src/middleware/middleware.test.tsx b/packages/next-intl/src/middleware/middleware.test.tsx index ea4fd43f8..6801f7baf 100644 --- a/packages/next-intl/src/middleware/middleware.test.tsx +++ b/packages/next-intl/src/middleware/middleware.test.tsx @@ -12,8 +12,8 @@ import { it, vi } from 'vitest'; -import createMiddleware from '../middleware.tsx'; -import {type Pathnames, defineRouting} from '../routing.tsx'; +import createMiddleware from '../middleware.js'; +import {type Pathnames, defineRouting} from '../routing.js'; const COOKIE_LOCALE_NAME = 'NEXT_LOCALE'; diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index c41100d4e..066c488a3 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -1,20 +1,20 @@ import {type NextRequest, NextResponse} from 'next/server.js'; -import {type RoutingConfig, receiveRoutingConfig} from '../routing/config.tsx'; +import {type RoutingConfig, receiveRoutingConfig} from '../routing/config.js'; import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../routing/types.tsx'; -import {HEADER_LOCALE_NAME} from '../shared/constants.tsx'; +} from '../routing/types.js'; +import {HEADER_LOCALE_NAME} from '../shared/constants.js'; import { getLocalePrefix, matchesPathname, normalizeTrailingSlash -} from '../shared/utils.tsx'; -import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue.tsx'; -import resolveLocale from './resolveLocale.tsx'; -import syncCookie from './syncCookie.tsx'; +} from '../shared/utils.js'; +import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue.js'; +import resolveLocale from './resolveLocale.js'; +import syncCookie from './syncCookie.js'; import { applyBasePath, formatPathname, @@ -26,7 +26,7 @@ import { getPathnameMatch, isLocaleSupportedOnDomain, sanitizePathname -} from './utils.tsx'; +} from './utils.js'; export default function createMiddleware< const AppLocales extends Locales, diff --git a/packages/next-intl/src/middleware/resolveLocale.test.tsx b/packages/next-intl/src/middleware/resolveLocale.test.tsx index 93a7bdfd8..338536132 100644 --- a/packages/next-intl/src/middleware/resolveLocale.test.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.test.tsx @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {getAcceptLanguageLocale} from './resolveLocale.tsx'; +import {getAcceptLanguageLocale} from './resolveLocale.js'; describe('getAcceptLanguageLocale', () => { it('resolves a more specific locale to a generic one', () => { diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index ee4abdea0..71653e072 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -2,19 +2,15 @@ import {match} from '@formatjs/intl-localematcher'; import Negotiator from 'negotiator'; import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies.js'; import type {Locale} from 'use-intl'; -import type {ResolvedRoutingConfig} from '../routing/config.tsx'; +import type {ResolvedRoutingConfig} from '../routing/config.js'; import type { DomainConfig, DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../routing/types.tsx'; -import { - getHost, - getPathnameMatch, - isLocaleSupportedOnDomain -} from './utils.tsx'; +} from '../routing/types.js'; +import {getHost, getPathnameMatch, isLocaleSupportedOnDomain} from './utils.js'; function findDomainFromHost( requestHeaders: Headers, diff --git a/packages/next-intl/src/middleware/syncCookie.tsx b/packages/next-intl/src/middleware/syncCookie.tsx index b37015cd4..42619882c 100644 --- a/packages/next-intl/src/middleware/syncCookie.tsx +++ b/packages/next-intl/src/middleware/syncCookie.tsx @@ -3,15 +3,15 @@ import type {Locale} from 'use-intl'; import type { InitializedLocaleCookieConfig, ResolvedRoutingConfig -} from '../routing/config.tsx'; +} from '../routing/config.js'; import type { DomainConfig, DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../routing/types.tsx'; -import {getAcceptLanguageLocale} from './resolveLocale.tsx'; +} from '../routing/types.js'; +import {getAcceptLanguageLocale} from './resolveLocale.js'; export default function syncCookie< AppLocales extends Locales, diff --git a/packages/next-intl/src/middleware/utils.test.tsx b/packages/next-intl/src/middleware/utils.test.tsx index 61a0a1ecd..44e554566 100644 --- a/packages/next-intl/src/middleware/utils.test.tsx +++ b/packages/next-intl/src/middleware/utils.test.tsx @@ -5,7 +5,7 @@ import { getNormalizedPathname, getPathnameMatch, getRouteParams -} from './utils.tsx'; +} from './utils.js'; describe('getNormalizedPathname', () => { it('should return the normalized pathname', () => { diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 996368ac9..b075ae15d 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -6,7 +6,7 @@ import type { LocalePrefixMode, Locales, Pathnames -} from '../routing/types.tsx'; +} from '../routing/types.js'; import { getLocalePrefix, getSortedPathnames, @@ -14,7 +14,7 @@ import { normalizeTrailingSlash, prefixPathname, templateToRegex -} from '../shared/utils.tsx'; +} from '../shared/utils.js'; export function getFirstPathnameSegment(pathname: string) { return pathname.split('/')[1]; diff --git a/packages/next-intl/src/navigation.react-client.tsx b/packages/next-intl/src/navigation.react-client.tsx index 1b752df45..690c40fe3 100644 --- a/packages/next-intl/src/navigation.react-client.tsx +++ b/packages/next-intl/src/navigation.react-client.tsx @@ -1 +1 @@ -export * from './navigation/react-client/index.tsx'; +export * from './navigation/react-client/index.js'; diff --git a/packages/next-intl/src/navigation.react-server.tsx b/packages/next-intl/src/navigation.react-server.tsx index de1da08cd..62fcb6ec3 100644 --- a/packages/next-intl/src/navigation.react-server.tsx +++ b/packages/next-intl/src/navigation.react-server.tsx @@ -1 +1 @@ -export * from './navigation/react-server/index.tsx'; +export * from './navigation/react-server/index.js'; diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index a6ea4c698..abe1b113a 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -7,14 +7,10 @@ import { import {renderToString} from 'react-dom/server'; import {type Locale, useLocale} from 'use-intl'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import { - type DomainsConfig, - type Pathnames, - defineRouting -} from '../routing.tsx'; -import createNavigationClient from './react-client/createNavigation.tsx'; -import createNavigationServer from './react-server/createNavigation.tsx'; -import getServerLocale from './react-server/getServerLocale.tsx'; +import {type DomainsConfig, type Pathnames, defineRouting} from '../routing.js'; +import createNavigationClient from './react-client/createNavigation.js'; +import createNavigationServer from './react-server/createNavigation.js'; +import getServerLocale from './react-server/getServerLocale.js'; vi.mock('react'); vi.mock('next/navigation.js', async () => ({ diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index 4ff993686..238aaad2d 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -5,8 +5,8 @@ import { } from 'next/navigation.js'; import {type Locale, useLocale} from 'use-intl'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import type {DomainsConfig, Pathnames} from '../../routing.tsx'; -import createNavigation from './createNavigation.tsx'; +import type {DomainsConfig, Pathnames} from '../../routing.js'; +import createNavigation from './createNavigation.js'; vi.mock('next/navigation.js'); vi.mock('use-intl', async () => ({ diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index 68f97e3b9..a1e712295 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -7,17 +7,17 @@ import {type Locale, useLocale} from 'use-intl'; import type { RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation -} from '../../routing/config.tsx'; +} from '../../routing/config.js'; import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types.tsx'; -import createSharedNavigationFns from '../shared/createSharedNavigationFns.tsx'; -import syncLocaleCookie from '../shared/syncLocaleCookie.tsx'; -import {getRoute} from '../shared/utils.tsx'; -import useBasePathname from './useBasePathname.tsx'; +} from '../../routing/types.js'; +import createSharedNavigationFns from '../shared/createSharedNavigationFns.js'; +import syncLocaleCookie from '../shared/syncLocaleCookie.js'; +import {getRoute} from '../shared/utils.js'; +import useBasePathname from './useBasePathname.js'; export default function createNavigation< const AppLocales extends Locales, diff --git a/packages/next-intl/src/navigation/react-client/index.tsx b/packages/next-intl/src/navigation/react-client/index.tsx index c3cb1a965..c2f39bbc0 100644 --- a/packages/next-intl/src/navigation/react-client/index.tsx +++ b/packages/next-intl/src/navigation/react-client/index.tsx @@ -1,2 +1,2 @@ -export {default as createNavigation} from './createNavigation.tsx'; -export type {QueryParams} from '../shared/utils.tsx'; +export {default as createNavigation} from './createNavigation.js'; +export type {QueryParams} from '../shared/utils.js'; diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx index 715256f4e..f12a60977 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx @@ -1,8 +1,8 @@ import {render, screen} from '@testing-library/react'; import {usePathname as useNextPathname} from 'next/navigation.js'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {NextIntlClientProvider, useLocale} from '../../index.react-client.tsx'; -import useBasePathname from './useBasePathname.tsx'; +import {NextIntlClientProvider, useLocale} from '../../index.react-client.js'; +import useBasePathname from './useBasePathname.js'; vi.mock('next/navigation.js'); vi.mock('use-intl', async () => ({ diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx index 5e283ff17..e7ced2828 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx @@ -5,13 +5,13 @@ import type { LocalePrefixConfigVerbose, LocalePrefixMode, Locales -} from '../../routing/types.tsx'; +} from '../../routing/types.js'; import { getLocaleAsPrefix, getLocalePrefix, hasPathnamePrefixed, unprefixPathname -} from '../../shared/utils.tsx'; +} from '../../shared/utils.js'; export default function useBasePathname< AppLocales extends Locales, diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx index 73277434f..18f48d8b3 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from 'vitest'; -import createNavigation from './createNavigation.tsx'; +import createNavigation from './createNavigation.js'; vi.mock('react'); diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx index 96c921c58..4ccccc12e 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -1,15 +1,15 @@ import type { RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation -} from '../../routing/config.tsx'; +} from '../../routing/config.js'; import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types.tsx'; -import createSharedNavigationFns from '../shared/createSharedNavigationFns.tsx'; -import getServerLocale from './getServerLocale.tsx'; +} from '../../routing/types.js'; +import createSharedNavigationFns from '../shared/createSharedNavigationFns.js'; +import getServerLocale from './getServerLocale.js'; export default function createNavigation< const AppLocales extends Locales, diff --git a/packages/next-intl/src/navigation/react-server/getServerLocale.tsx b/packages/next-intl/src/navigation/react-server/getServerLocale.tsx index e6bd11965..ba8529277 100644 --- a/packages/next-intl/src/navigation/react-server/getServerLocale.tsx +++ b/packages/next-intl/src/navigation/react-server/getServerLocale.tsx @@ -1,4 +1,4 @@ -import getConfig from '../../server/react-server/getConfig.tsx'; +import getConfig from '../../server/react-server/getConfig.js'; /** * This is only moved to a separate module for easier mocking in diff --git a/packages/next-intl/src/navigation/react-server/index.tsx b/packages/next-intl/src/navigation/react-server/index.tsx index 7bc2e12ed..e1f566961 100644 --- a/packages/next-intl/src/navigation/react-server/index.tsx +++ b/packages/next-intl/src/navigation/react-server/index.tsx @@ -1,2 +1,2 @@ -export {default as createNavigation} from './createNavigation.tsx'; -export type {Pathnames} from '../../routing/types.tsx'; +export {default as createNavigation} from './createNavigation.js'; +export type {Pathnames} from '../../routing/types.js'; diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index 10aa07c93..0012cfe2a 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -11,8 +11,8 @@ import { useState } from 'react'; import {type Locale, useLocale} from 'use-intl'; -import type {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; -import syncLocaleCookie from './syncLocaleCookie.tsx'; +import type {InitializedLocaleCookieConfig} from '../../routing/config.js'; +import syncLocaleCookie from './syncLocaleCookie.js'; type NextLinkProps = Omit, keyof LinkProps> & Omit; diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index 9d973300d..2cae2c352 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -8,18 +8,18 @@ import { type RoutingConfigLocalizedNavigation, type RoutingConfigSharedNavigation, receiveRoutingConfig -} from '../../routing/config.tsx'; +} from '../../routing/config.js'; import type { DomainConfig, DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types.tsx'; -import type {ParametersExceptFirst, Prettify} from '../../shared/types.tsx'; -import use from '../../shared/use.tsx'; -import {isLocalizableHref} from '../../shared/utils.tsx'; -import BaseLink from './BaseLink.tsx'; +} from '../../routing/types.js'; +import type {ParametersExceptFirst, Prettify} from '../../shared/types.js'; +import use from '../../shared/use.js'; +import {isLocalizableHref} from '../../shared/utils.js'; +import BaseLink from './BaseLink.js'; import { type HrefOrHrefWithParams, type HrefOrUrlObjectWithParams, @@ -29,7 +29,7 @@ import { normalizeNameOrNameWithParams, serializeSearchParams, validateReceivedConfig -} from './utils.tsx'; +} from './utils.js'; type PromiseOrValue = Type | Promise; diff --git a/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx b/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx index 41b42531a..af826e352 100644 --- a/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx +++ b/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx @@ -1,6 +1,6 @@ import type {Locale} from 'use-intl'; -import type {InitializedLocaleCookieConfig} from '../../routing/config.tsx'; -import {getBasePath} from './utils.tsx'; +import type {InitializedLocaleCookieConfig} from '../../routing/config.js'; +import {getBasePath} from './utils.js'; /** * We have to keep the cookie value in sync as Next.js might diff --git a/packages/next-intl/src/navigation/shared/utils.test.tsx b/packages/next-intl/src/navigation/shared/utils.test.tsx index 1e99fe485..1e59939e8 100644 --- a/packages/next-intl/src/navigation/shared/utils.test.tsx +++ b/packages/next-intl/src/navigation/shared/utils.test.tsx @@ -3,7 +3,7 @@ import { compileLocalizedPathname, getBasePath, serializeSearchParams -} from './utils.tsx'; +} from './utils.js'; describe('serializeSearchParams', () => { it('handles strings', () => { diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 26f6372f0..ac92fa530 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,13 +1,13 @@ import type {ParsedUrlQueryInput} from 'node:querystring'; import type {UrlObject} from 'url'; import type {Locale} from 'use-intl'; -import type {ResolvedRoutingConfig} from '../../routing/config.tsx'; +import type {ResolvedRoutingConfig} from '../../routing/config.js'; import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types.tsx'; +} from '../../routing/types.js'; import { getLocalePrefix, getSortedPathnames, @@ -15,8 +15,8 @@ import { matchesPathname, normalizeTrailingSlash, prefixPathname -} from '../../shared/utils.tsx'; -import type StrictParams from './StrictParams.tsx'; +} from '../../shared/utils.js'; +import type StrictParams from './StrictParams.js'; type SearchParamValue = ParsedUrlQueryInput[keyof ParsedUrlQueryInput]; diff --git a/packages/next-intl/src/plugin.tsx b/packages/next-intl/src/plugin.tsx index ef53890a0..3963d42d9 100644 --- a/packages/next-intl/src/plugin.tsx +++ b/packages/next-intl/src/plugin.tsx @@ -1 +1 @@ -export {default} from './plugin/index.tsx'; +export {default} from './plugin/index.js'; diff --git a/packages/next-intl/src/plugin/createMessagesDeclaration.tsx b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx index 1fc6b2427..ee40aafd7 100644 --- a/packages/next-intl/src/plugin/createMessagesDeclaration.tsx +++ b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import {throwError} from './utils.tsx'; +import {throwError} from './utils.js'; function runOnce(fn: () => void) { if (process.env._NEXT_INTL_COMPILE_MESSAGES === '1') { diff --git a/packages/next-intl/src/plugin/createNextIntlPlugin.tsx b/packages/next-intl/src/plugin/createNextIntlPlugin.tsx index c6464a960..bc60f83fb 100644 --- a/packages/next-intl/src/plugin/createNextIntlPlugin.tsx +++ b/packages/next-intl/src/plugin/createNextIntlPlugin.tsx @@ -1,8 +1,8 @@ import type {NextConfig} from 'next'; -import createMessagesDeclaration from './createMessagesDeclaration.tsx'; -import getNextConfig from './getNextConfig.tsx'; -import type {PluginConfig} from './types.tsx'; -import {warn} from './utils.tsx'; +import createMessagesDeclaration from './createMessagesDeclaration.js'; +import getNextConfig from './getNextConfig.js'; +import type {PluginConfig} from './types.js'; +import {warn} from './utils.js'; function initPlugin( pluginConfig: PluginConfig, diff --git a/packages/next-intl/src/plugin/getNextConfig.tsx b/packages/next-intl/src/plugin/getNextConfig.tsx index f417a0028..5b7f8d277 100644 --- a/packages/next-intl/src/plugin/getNextConfig.tsx +++ b/packages/next-intl/src/plugin/getNextConfig.tsx @@ -1,8 +1,8 @@ import fs from 'fs'; import path from 'path'; import type {NextConfig} from 'next'; -import type {PluginConfig} from './types.tsx'; -import {throwError} from './utils.tsx'; +import type {PluginConfig} from './types.js'; +import {throwError} from './utils.js'; function withExtensions(localPath: string) { return [ diff --git a/packages/next-intl/src/plugin/index.tsx b/packages/next-intl/src/plugin/index.tsx index d3403203e..06c26f724 100644 --- a/packages/next-intl/src/plugin/index.tsx +++ b/packages/next-intl/src/plugin/index.tsx @@ -1 +1 @@ -export {default} from './createNextIntlPlugin.tsx'; +export {default} from './createNextIntlPlugin.js'; diff --git a/packages/next-intl/src/react-client/index.tsx b/packages/next-intl/src/react-client/index.tsx index dbfac9656..0e7fe3d0e 100644 --- a/packages/next-intl/src/react-client/index.tsx +++ b/packages/next-intl/src/react-client/index.tsx @@ -46,4 +46,4 @@ export const useFormatter = callHook( base_useFormatter ) as typeof base_useFormatter; -export {default as NextIntlClientProvider} from '../shared/NextIntlClientProvider.tsx'; +export {default as NextIntlClientProvider} from '../shared/NextIntlClientProvider.js'; diff --git a/packages/next-intl/src/react-client/useFormatter.test.tsx b/packages/next-intl/src/react-client/useFormatter.test.tsx index d77ad0825..123f39fc5 100644 --- a/packages/next-intl/src/react-client/useFormatter.test.tsx +++ b/packages/next-intl/src/react-client/useFormatter.test.tsx @@ -1,6 +1,6 @@ import {render, screen} from '@testing-library/react'; import {expect, it} from 'vitest'; -import {NextIntlClientProvider, useFormatter} from './index.tsx'; +import {NextIntlClientProvider, useFormatter} from './index.js'; function Component() { const format = useFormatter(); diff --git a/packages/next-intl/src/react-client/useNow.test.tsx b/packages/next-intl/src/react-client/useNow.test.tsx index 82d134004..6c2e783b7 100644 --- a/packages/next-intl/src/react-client/useNow.test.tsx +++ b/packages/next-intl/src/react-client/useNow.test.tsx @@ -1,6 +1,6 @@ import {render, screen} from '@testing-library/react'; import {it} from 'vitest'; -import {NextIntlClientProvider, useNow} from './index.tsx'; +import {NextIntlClientProvider, useNow} from './index.js'; function Component() { const now = useNow(); diff --git a/packages/next-intl/src/react-client/useTimeZone.test.tsx b/packages/next-intl/src/react-client/useTimeZone.test.tsx index bae67114c..3e2b6709f 100644 --- a/packages/next-intl/src/react-client/useTimeZone.test.tsx +++ b/packages/next-intl/src/react-client/useTimeZone.test.tsx @@ -1,6 +1,6 @@ import {render, screen} from '@testing-library/react'; import {it} from 'vitest'; -import {NextIntlClientProvider, useTimeZone} from './index.tsx'; +import {NextIntlClientProvider, useTimeZone} from './index.js'; function Component() { const timeZone = useTimeZone(); diff --git a/packages/next-intl/src/react-client/useTranslations.test.tsx b/packages/next-intl/src/react-client/useTranslations.test.tsx index ac1264e8c..e74e9fc56 100644 --- a/packages/next-intl/src/react-client/useTranslations.test.tsx +++ b/packages/next-intl/src/react-client/useTranslations.test.tsx @@ -1,6 +1,6 @@ import {render, screen} from '@testing-library/react'; import {expect, it, vi} from 'vitest'; -import {NextIntlClientProvider, useTranslations} from './index.tsx'; +import {NextIntlClientProvider, useTranslations} from './index.js'; function Component() { const t = useTranslations('Component'); diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx index 013d5e0fd..9680df759 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx @@ -1,9 +1,9 @@ import {expect, it, vi} from 'vitest'; -import getConfigNow from '../server/react-server/getConfigNow.tsx'; -import getFormats from '../server/react-server/getFormats.tsx'; -import {getLocale, getTimeZone} from '../server.react-server.tsx'; -import NextIntlClientProvider from '../shared/NextIntlClientProvider.tsx'; -import NextIntlClientProviderServer from './NextIntlClientProviderServer.tsx'; +import getConfigNow from '../server/react-server/getConfigNow.js'; +import getFormats from '../server/react-server/getFormats.js'; +import {getLocale, getTimeZone} from '../server.react-server.js'; +import NextIntlClientProvider from '../shared/NextIntlClientProvider.js'; +import NextIntlClientProviderServer from './NextIntlClientProviderServer.js'; vi.mock('../../src/server/react-server', async () => ({ getLocale: vi.fn(async () => 'en-US'), diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx index abf19ee30..fc03de3d9 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx @@ -1,8 +1,8 @@ import type {ComponentProps} from 'react'; -import getConfigNow from '../server/react-server/getConfigNow.tsx'; -import getFormats from '../server/react-server/getFormats.tsx'; -import {getLocale, getTimeZone} from '../server.react-server.tsx'; -import BaseNextIntlClientProvider from '../shared/NextIntlClientProvider.tsx'; +import getConfigNow from '../server/react-server/getConfigNow.js'; +import getFormats from '../server/react-server/getFormats.js'; +import {getLocale, getTimeZone} from '../server.react-server.js'; +import BaseNextIntlClientProvider from '../shared/NextIntlClientProvider.js'; type Props = ComponentProps; diff --git a/packages/next-intl/src/react-server/index.test.tsx b/packages/next-intl/src/react-server/index.test.tsx index 1bbe1511b..9864cf6c5 100644 --- a/packages/next-intl/src/react-server/index.test.tsx +++ b/packages/next-intl/src/react-server/index.test.tsx @@ -1,5 +1,6 @@ import {describe, expect, it, vi} from 'vitest'; -import {getTranslations} from '../server.react-server.tsx'; +import {getTranslations} from '../server.react-server.js'; +import {renderToStream} from './testUtils.js'; import { _createCache, useFormatter, @@ -7,8 +8,7 @@ import { useMessages, useNow, useTranslations -} from './index.tsx'; -import {renderToStream} from './testUtils.tsx'; +} from './index.js'; vi.mock('react'); diff --git a/packages/next-intl/src/react-server/index.tsx b/packages/next-intl/src/react-server/index.tsx index f7bc68c7d..52c25e38d 100644 --- a/packages/next-intl/src/react-server/index.tsx +++ b/packages/next-intl/src/react-server/index.tsx @@ -7,13 +7,13 @@ */ // Replaced exports from the `react` package -export {default as useLocale} from './useLocale.tsx'; -export {default as useTranslations} from './useTranslations.tsx'; -export {default as useFormatter} from './useFormatter.tsx'; -export {default as useNow} from './useNow.tsx'; -export {default as useTimeZone} from './useTimeZone.tsx'; -export {default as useMessages} from './useMessages.tsx'; -export {default as NextIntlClientProvider} from './NextIntlClientProviderServer.tsx'; +export {default as useLocale} from './useLocale.js'; +export {default as useTranslations} from './useTranslations.js'; +export {default as useFormatter} from './useFormatter.js'; +export {default as useNow} from './useNow.js'; +export {default as useTimeZone} from './useTimeZone.js'; +export {default as useMessages} from './useMessages.js'; +export {default as NextIntlClientProvider} from './NextIntlClientProviderServer.js'; // Everything from `core` export * from 'use-intl/core'; diff --git a/packages/next-intl/src/react-server/useConfig.tsx b/packages/next-intl/src/react-server/useConfig.tsx index fcba08ca5..75d099e80 100644 --- a/packages/next-intl/src/react-server/useConfig.tsx +++ b/packages/next-intl/src/react-server/useConfig.tsx @@ -1,5 +1,5 @@ -import getConfig from '../server/react-server/getConfig.tsx'; -import use from '../shared/use.tsx'; +import getConfig from '../server/react-server/getConfig.js'; +import use from '../shared/use.js'; function useHook(hookName: string, promise: Promise) { try { diff --git a/packages/next-intl/src/react-server/useFormatter.test.tsx b/packages/next-intl/src/react-server/useFormatter.test.tsx index e6b5a3bee..b371c2ddc 100644 --- a/packages/next-intl/src/react-server/useFormatter.test.tsx +++ b/packages/next-intl/src/react-server/useFormatter.test.tsx @@ -1,7 +1,7 @@ import {describe, expect, it, vi} from 'vitest'; -import getDefaultNow from '../server/react-server/getDefaultNow.tsx'; -import {renderToStream} from './testUtils.tsx'; -import useFormatter from './useFormatter.tsx'; +import getDefaultNow from '../server/react-server/getDefaultNow.js'; +import {renderToStream} from './testUtils.js'; +import useFormatter from './useFormatter.js'; vi.mock('react'); vi.mock('../server/react-server/getDefaultNow.tsx', () => ({ diff --git a/packages/next-intl/src/react-server/useFormatter.tsx b/packages/next-intl/src/react-server/useFormatter.tsx index 7e41b86b6..b5f03ed1c 100644 --- a/packages/next-intl/src/react-server/useFormatter.tsx +++ b/packages/next-intl/src/react-server/useFormatter.tsx @@ -1,6 +1,6 @@ import type {useFormatter as useFormatterType} from 'use-intl'; -import getServerFormatter from '../server/react-server/getServerFormatter.tsx'; -import useConfig from './useConfig.tsx'; +import getServerFormatter from '../server/react-server/getServerFormatter.js'; +import useConfig from './useConfig.js'; export default function useFormatter(): ReturnType { const config = useConfig('useFormatter'); diff --git a/packages/next-intl/src/react-server/useLocale.tsx b/packages/next-intl/src/react-server/useLocale.tsx index a517f0afc..88b638dc1 100644 --- a/packages/next-intl/src/react-server/useLocale.tsx +++ b/packages/next-intl/src/react-server/useLocale.tsx @@ -1,5 +1,5 @@ import type {useLocale as useLocaleType} from 'use-intl'; -import useConfig from './useConfig.tsx'; +import useConfig from './useConfig.js'; export default function useLocale(): ReturnType { const config = useConfig('useLocale'); diff --git a/packages/next-intl/src/react-server/useMessages.tsx b/packages/next-intl/src/react-server/useMessages.tsx index 789304044..00016d160 100644 --- a/packages/next-intl/src/react-server/useMessages.tsx +++ b/packages/next-intl/src/react-server/useMessages.tsx @@ -1,6 +1,6 @@ import type {useMessages as useMessagesType} from 'use-intl'; -import {getMessagesFromConfig} from '../server/react-server/getMessages.tsx'; -import useConfig from './useConfig.tsx'; +import {getMessagesFromConfig} from '../server/react-server/getMessages.js'; +import useConfig from './useConfig.js'; export default function useMessages(): ReturnType { const config = useConfig('useMessages'); diff --git a/packages/next-intl/src/react-server/useNow.tsx b/packages/next-intl/src/react-server/useNow.tsx index 3f103db46..6580e6de4 100644 --- a/packages/next-intl/src/react-server/useNow.tsx +++ b/packages/next-intl/src/react-server/useNow.tsx @@ -1,6 +1,6 @@ import type {useNow as useNowType} from 'use-intl'; -import getDefaultNow from '../server/react-server/getDefaultNow.tsx'; -import useConfig from './useConfig.tsx'; +import getDefaultNow from '../server/react-server/getDefaultNow.js'; +import useConfig from './useConfig.js'; export default function useNow( options?: Parameters[0] diff --git a/packages/next-intl/src/react-server/useTimeZone.tsx b/packages/next-intl/src/react-server/useTimeZone.tsx index c527b20c1..a1184a29a 100644 --- a/packages/next-intl/src/react-server/useTimeZone.tsx +++ b/packages/next-intl/src/react-server/useTimeZone.tsx @@ -1,5 +1,5 @@ import type {useTimeZone as useTimeZoneType} from 'use-intl'; -import useConfig from './useConfig.tsx'; +import useConfig from './useConfig.js'; export default function useTimeZone(): ReturnType { const config = useConfig('useTimeZone'); diff --git a/packages/next-intl/src/react-server/useTranslations.test.tsx b/packages/next-intl/src/react-server/useTranslations.test.tsx index 6631d3943..df0a36ab6 100644 --- a/packages/next-intl/src/react-server/useTranslations.test.tsx +++ b/packages/next-intl/src/react-server/useTranslations.test.tsx @@ -1,7 +1,7 @@ import {cache} from 'react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {createTranslator, useTranslations} from './index.tsx'; -import {renderToStream} from './testUtils.tsx'; +import {renderToStream} from './testUtils.js'; +import {createTranslator, useTranslations} from './index.js'; vi.mock('../../src/server/react-server/createRequestConfig', () => ({ default: async () => ({ diff --git a/packages/next-intl/src/react-server/useTranslations.tsx b/packages/next-intl/src/react-server/useTranslations.tsx index 836b415bf..cbb6293cc 100644 --- a/packages/next-intl/src/react-server/useTranslations.tsx +++ b/packages/next-intl/src/react-server/useTranslations.tsx @@ -1,6 +1,6 @@ import type {useTranslations as useTranslationsType} from 'use-intl'; -import getServerTranslator from '../server/react-server/getServerTranslator.tsx'; -import useConfig from './useConfig.tsx'; +import getServerTranslator from '../server/react-server/getServerTranslator.js'; +import useConfig from './useConfig.js'; export default function useTranslations( ...[namespace]: Parameters diff --git a/packages/next-intl/src/routing.tsx b/packages/next-intl/src/routing.tsx index 3cb1cbc99..692879791 100644 --- a/packages/next-intl/src/routing.tsx +++ b/packages/next-intl/src/routing.tsx @@ -1 +1 @@ -export * from './routing/index.tsx'; +export * from './routing/index.js'; diff --git a/packages/next-intl/src/routing/config.tsx b/packages/next-intl/src/routing/config.tsx index 728b46663..3fc6320dc 100644 --- a/packages/next-intl/src/routing/config.tsx +++ b/packages/next-intl/src/routing/config.tsx @@ -6,7 +6,7 @@ import type { LocalePrefixMode, Locales, Pathnames -} from './types.tsx'; +} from './types.js'; type CookieAttributes = Pick< NonNullable['2']>, diff --git a/packages/next-intl/src/routing/defineRouting.test.tsx b/packages/next-intl/src/routing/defineRouting.test.tsx index d3ea1fa2a..40c4ac0dd 100644 --- a/packages/next-intl/src/routing/defineRouting.test.tsx +++ b/packages/next-intl/src/routing/defineRouting.test.tsx @@ -1,5 +1,5 @@ import {describe, it} from 'vitest'; -import defineRouting from './defineRouting.tsx'; +import defineRouting from './defineRouting.js'; describe('defaultLocale', () => { it('ensures the `defaultLocale` is within `locales`', () => { diff --git a/packages/next-intl/src/routing/defineRouting.tsx b/packages/next-intl/src/routing/defineRouting.tsx index 1b34187ce..ca087ddb3 100644 --- a/packages/next-intl/src/routing/defineRouting.tsx +++ b/packages/next-intl/src/routing/defineRouting.tsx @@ -1,11 +1,11 @@ -import type {RoutingConfig} from './config.tsx'; +import type {RoutingConfig} from './config.js'; import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from './types.tsx'; -import validateLocales from './validateLocales.tsx'; +} from './types.js'; +import validateLocales from './validateLocales.js'; export default function defineRouting< const AppLocales extends Locales, diff --git a/packages/next-intl/src/routing/index.tsx b/packages/next-intl/src/routing/index.tsx index 6a8aa9cd7..db9f16bb7 100644 --- a/packages/next-intl/src/routing/index.tsx +++ b/packages/next-intl/src/routing/index.tsx @@ -3,6 +3,6 @@ export type { LocalePrefix, DomainsConfig, LocalePrefixMode -} from './types.tsx'; -export {default as defineRouting} from './defineRouting.tsx'; -export type {RoutingConfig} from './config.tsx'; +} from './types.js'; +export {default as defineRouting} from './defineRouting.js'; +export type {RoutingConfig} from './config.js'; diff --git a/packages/next-intl/src/routing/types.test.tsx b/packages/next-intl/src/routing/types.test.tsx index 5b93b4deb..5ea870f51 100644 --- a/packages/next-intl/src/routing/types.test.tsx +++ b/packages/next-intl/src/routing/types.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import {describe, it} from 'vitest'; -import type {DomainConfig, LocalePrefix} from './types.tsx'; +import type {DomainConfig, LocalePrefix} from './types.js'; describe('LocalePrefix', () => { it('does not require a type param for simple values', () => { diff --git a/packages/next-intl/src/routing/validateLocales.test.tsx b/packages/next-intl/src/routing/validateLocales.test.tsx index 5e6d0f851..3f04b3407 100644 --- a/packages/next-intl/src/routing/validateLocales.test.tsx +++ b/packages/next-intl/src/routing/validateLocales.test.tsx @@ -1,5 +1,5 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import validateLocales from './validateLocales.tsx'; +import validateLocales from './validateLocales.js'; describe('accepts valid formats', () => { let consoleErrorSpy: ReturnType; diff --git a/packages/next-intl/src/routing/validateLocales.tsx b/packages/next-intl/src/routing/validateLocales.tsx index ba09f20b7..fea457f43 100644 --- a/packages/next-intl/src/routing/validateLocales.tsx +++ b/packages/next-intl/src/routing/validateLocales.tsx @@ -1,4 +1,4 @@ -import type {Locales} from './types.tsx'; +import type {Locales} from './types.js'; export default function validateLocales(locales: Locales) { for (const locale of locales) { diff --git a/packages/next-intl/src/server.react-client.tsx b/packages/next-intl/src/server.react-client.tsx index 4461b2caf..cf320e6f1 100644 --- a/packages/next-intl/src/server.react-client.tsx +++ b/packages/next-intl/src/server.react-client.tsx @@ -1 +1 @@ -export * from './server/react-client/index.tsx'; +export * from './server/react-client/index.js'; diff --git a/packages/next-intl/src/server.react-server.tsx b/packages/next-intl/src/server.react-server.tsx index d5cd6fcab..9324dd03a 100644 --- a/packages/next-intl/src/server.react-server.tsx +++ b/packages/next-intl/src/server.react-server.tsx @@ -1 +1 @@ -export * from './server/react-server/index.tsx'; +export * from './server/react-server/index.js'; diff --git a/packages/next-intl/src/server/react-client/index.test.tsx b/packages/next-intl/src/server/react-client/index.test.tsx index 9ad50db29..74eab6d42 100644 --- a/packages/next-intl/src/server/react-client/index.test.tsx +++ b/packages/next-intl/src/server/react-client/index.test.tsx @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {getRequestConfig} from '../../server.react-client.tsx'; +import {getRequestConfig} from '../../server.react-client.js'; describe('getRequestConfig', () => { it('can be called in the outer module closure', () => { diff --git a/packages/next-intl/src/server/react-client/index.tsx b/packages/next-intl/src/server/react-client/index.tsx index 5dbc371ec..f5bc34a81 100644 --- a/packages/next-intl/src/server/react-client/index.tsx +++ b/packages/next-intl/src/server/react-client/index.tsx @@ -6,7 +6,7 @@ import type { getRequestConfig as getRequestConfig_type, getTimeZone as getTimeZone_type, setRequestLocale as setRequestLocale_type -} from '../react-server/index.tsx'; +} from '../react-server/index.js'; /** * Allows to import `next-intl/server` in non-RSC environments. diff --git a/packages/next-intl/src/server/react-server/RequestLocale.tsx b/packages/next-intl/src/server/react-server/RequestLocale.tsx index 2d9d5492f..fd028ddac 100644 --- a/packages/next-intl/src/server/react-server/RequestLocale.tsx +++ b/packages/next-intl/src/server/react-server/RequestLocale.tsx @@ -1,8 +1,8 @@ import {headers} from 'next/headers.js'; import {cache} from 'react'; import type {Locale} from 'use-intl'; -import {HEADER_LOCALE_NAME} from '../../shared/constants.tsx'; -import {getCachedRequestLocale} from './RequestLocaleCache.tsx'; +import {HEADER_LOCALE_NAME} from '../../shared/constants.js'; +import {getCachedRequestLocale} from './RequestLocaleCache.js'; async function getHeadersImpl(): Promise { const promiseOrValue = headers(); diff --git a/packages/next-intl/src/server/react-server/createRequestConfig.tsx b/packages/next-intl/src/server/react-server/createRequestConfig.tsx index a433c1f13..fbe1831ee 100644 --- a/packages/next-intl/src/server/react-server/createRequestConfig.tsx +++ b/packages/next-intl/src/server/react-server/createRequestConfig.tsx @@ -2,7 +2,7 @@ import getRuntimeConfig from 'next-intl/config'; import type { GetRequestConfigParams, RequestConfig -} from './getRequestConfig.tsx'; +} from './getRequestConfig.js'; export default getRuntimeConfig as unknown as ( params: GetRequestConfigParams diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index 4b07ff97b..8372249a9 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -6,9 +6,9 @@ import { _createIntlFormatters, initializeConfig } from 'use-intl/core'; -import {getRequestLocale} from './RequestLocale.tsx'; -import createRequestConfig from './createRequestConfig.tsx'; -import type {GetRequestConfigParams} from './getRequestConfig.tsx'; +import {getRequestLocale} from './RequestLocale.js'; +import createRequestConfig from './createRequestConfig.js'; +import type {GetRequestConfigParams} from './getRequestConfig.js'; // This is automatically inherited by `NextIntlClientProvider` if // the component is rendered from a Server Component diff --git a/packages/next-intl/src/server/react-server/getConfigNow.tsx b/packages/next-intl/src/server/react-server/getConfigNow.tsx index 1a0750dc9..70088b327 100644 --- a/packages/next-intl/src/server/react-server/getConfigNow.tsx +++ b/packages/next-intl/src/server/react-server/getConfigNow.tsx @@ -1,6 +1,6 @@ import {cache} from 'react'; import type {Locale} from 'use-intl'; -import getConfig from './getConfig.tsx'; +import getConfig from './getConfig.js'; async function getConfigNowImpl(locale?: Locale) { const config = await getConfig(locale); diff --git a/packages/next-intl/src/server/react-server/getFormats.tsx b/packages/next-intl/src/server/react-server/getFormats.tsx index 4532b90ec..363a25efe 100644 --- a/packages/next-intl/src/server/react-server/getFormats.tsx +++ b/packages/next-intl/src/server/react-server/getFormats.tsx @@ -1,5 +1,5 @@ import {cache} from 'react'; -import getConfig from './getConfig.tsx'; +import getConfig from './getConfig.js'; async function getFormatsCachedImpl() { const config = await getConfig(); diff --git a/packages/next-intl/src/server/react-server/getFormatter.test.tsx b/packages/next-intl/src/server/react-server/getFormatter.test.tsx index 2499706da..b9a505dcb 100644 --- a/packages/next-intl/src/server/react-server/getFormatter.test.tsx +++ b/packages/next-intl/src/server/react-server/getFormatter.test.tsx @@ -1,6 +1,6 @@ import {describe, expect, it, vi} from 'vitest'; -import getDefaultNow from './getDefaultNow.tsx'; -import getFormatter from './getFormatter.tsx'; +import getDefaultNow from './getDefaultNow.js'; +import getFormatter from './getFormatter.js'; vi.mock('react'); vi.mock('./getDefaultNow.tsx', () => ({ diff --git a/packages/next-intl/src/server/react-server/getFormatter.tsx b/packages/next-intl/src/server/react-server/getFormatter.tsx index f90c99ea8..55591a5c3 100644 --- a/packages/next-intl/src/server/react-server/getFormatter.tsx +++ b/packages/next-intl/src/server/react-server/getFormatter.tsx @@ -1,7 +1,7 @@ import {cache} from 'react'; import type {Locale, createFormatter} from 'use-intl/core'; -import getConfig from './getConfig.tsx'; -import getServerFormatter from './getServerFormatter.tsx'; +import getConfig from './getConfig.js'; +import getServerFormatter from './getServerFormatter.js'; async function getFormatterCachedImpl(locale?: Locale) { const config = await getConfig(locale); diff --git a/packages/next-intl/src/server/react-server/getLocale.tsx b/packages/next-intl/src/server/react-server/getLocale.tsx index 4911306c1..61a8deb31 100644 --- a/packages/next-intl/src/server/react-server/getLocale.tsx +++ b/packages/next-intl/src/server/react-server/getLocale.tsx @@ -1,6 +1,6 @@ import {cache} from 'react'; import type {Locale} from 'use-intl'; -import getConfig from './getConfig.tsx'; +import getConfig from './getConfig.js'; async function getLocaleCachedImpl(): Promise { const config = await getConfig(); diff --git a/packages/next-intl/src/server/react-server/getMessages.tsx b/packages/next-intl/src/server/react-server/getMessages.tsx index b752a99b6..90495438d 100644 --- a/packages/next-intl/src/server/react-server/getMessages.tsx +++ b/packages/next-intl/src/server/react-server/getMessages.tsx @@ -1,6 +1,6 @@ import {cache} from 'react'; import type {Locale, useMessages as useMessagesType} from 'use-intl'; -import getConfig from './getConfig.tsx'; +import getConfig from './getConfig.js'; export function getMessagesFromConfig( config: Awaited> diff --git a/packages/next-intl/src/server/react-server/getNow.tsx b/packages/next-intl/src/server/react-server/getNow.tsx index 8daf796e8..767338158 100644 --- a/packages/next-intl/src/server/react-server/getNow.tsx +++ b/packages/next-intl/src/server/react-server/getNow.tsx @@ -1,6 +1,6 @@ import type {Locale} from 'use-intl'; -import getConfigNow from './getConfigNow.tsx'; -import getDefaultNow from './getDefaultNow.tsx'; +import getConfigNow from './getConfigNow.js'; +import getDefaultNow from './getDefaultNow.js'; export default async function getNow(opts?: {locale?: Locale}): Promise { return (await getConfigNow(opts?.locale)) ?? getDefaultNow(); diff --git a/packages/next-intl/src/server/react-server/getServerFormatter.tsx b/packages/next-intl/src/server/react-server/getServerFormatter.tsx index 5beeb0820..33654204e 100644 --- a/packages/next-intl/src/server/react-server/getServerFormatter.tsx +++ b/packages/next-intl/src/server/react-server/getServerFormatter.tsx @@ -1,6 +1,6 @@ import {cache} from 'react'; import {createFormatter} from 'use-intl/core'; -import getDefaultNow from './getDefaultNow.tsx'; +import getDefaultNow from './getDefaultNow.js'; function getFormatterCachedImpl(config: Parameters[0]) { // same here? diff --git a/packages/next-intl/src/server/react-server/getTimeZone.tsx b/packages/next-intl/src/server/react-server/getTimeZone.tsx index fca1601f9..feba75f95 100644 --- a/packages/next-intl/src/server/react-server/getTimeZone.tsx +++ b/packages/next-intl/src/server/react-server/getTimeZone.tsx @@ -1,6 +1,6 @@ import {cache} from 'react'; import type {Locale} from 'use-intl'; -import getConfig from './getConfig.tsx'; +import getConfig from './getConfig.js'; async function getTimeZoneCachedImpl(locale?: Locale) { const config = await getConfig(locale); diff --git a/packages/next-intl/src/server/react-server/getTranslations.test.tsx b/packages/next-intl/src/server/react-server/getTranslations.test.tsx index 5a19977c5..e2b9c2350 100644 --- a/packages/next-intl/src/server/react-server/getTranslations.test.tsx +++ b/packages/next-intl/src/server/react-server/getTranslations.test.tsx @@ -1,6 +1,6 @@ import {createTranslator} from 'use-intl/core'; import {expect, it, vi} from 'vitest'; -import getTranslations from './getTranslations.tsx'; +import getTranslations from './getTranslations.js'; vi.mock('react'); vi.mock('use-intl/core'); diff --git a/packages/next-intl/src/server/react-server/getTranslations.tsx b/packages/next-intl/src/server/react-server/getTranslations.tsx index 51690d083..2cd909052 100644 --- a/packages/next-intl/src/server/react-server/getTranslations.tsx +++ b/packages/next-intl/src/server/react-server/getTranslations.tsx @@ -6,8 +6,8 @@ import type { NestedKeyOf, createTranslator } from 'use-intl/core'; -import getConfig from './getConfig.tsx'; -import getServerTranslator from './getServerTranslator.tsx'; +import getConfig from './getConfig.js'; +import getServerTranslator from './getServerTranslator.js'; // Maintainer note: `getTranslations` has two different call signatures. // We need to define these with function overloads, otherwise TypeScript diff --git a/packages/next-intl/src/server/react-server/index.test.tsx b/packages/next-intl/src/server/react-server/index.test.tsx index 8f6054387..77878e1bc 100644 --- a/packages/next-intl/src/server/react-server/index.test.tsx +++ b/packages/next-intl/src/server/react-server/index.test.tsx @@ -1,14 +1,14 @@ // @vitest-environment edge-runtime import {describe, expect, it, vi} from 'vitest'; -import {HEADER_LOCALE_NAME} from '../../shared/constants.tsx'; +import {HEADER_LOCALE_NAME} from '../../shared/constants.js'; import { getFormatter, getMessages, getNow, getTimeZone, getTranslations -} from './index.tsx'; +} from './index.js'; vi.mock('next-intl/config', () => ({ default: async () => diff --git a/packages/next-intl/src/server/react-server/index.tsx b/packages/next-intl/src/server/react-server/index.tsx index 524ae9fa0..846ff4599 100644 --- a/packages/next-intl/src/server/react-server/index.tsx +++ b/packages/next-intl/src/server/react-server/index.tsx @@ -6,12 +6,12 @@ export { default as getRequestConfig, type GetRequestConfigParams, type RequestConfig -} from './getRequestConfig.tsx'; -export {default as getFormatter} from './getFormatter.tsx'; -export {default as getNow} from './getNow.tsx'; -export {default as getTimeZone} from './getTimeZone.tsx'; -export {default as getTranslations} from './getTranslations.tsx'; -export {default as getMessages} from './getMessages.tsx'; -export {default as getLocale} from './getLocale.tsx'; +} from './getRequestConfig.js'; +export {default as getFormatter} from './getFormatter.js'; +export {default as getNow} from './getNow.js'; +export {default as getTimeZone} from './getTimeZone.js'; +export {default as getTranslations} from './getTranslations.js'; +export {default as getMessages} from './getMessages.js'; +export {default as getLocale} from './getLocale.js'; -export {setCachedRequestLocale as setRequestLocale} from './RequestLocaleCache.tsx'; +export {setCachedRequestLocale as setRequestLocale} from './RequestLocaleCache.js'; diff --git a/packages/next-intl/src/shared/utils.test.tsx b/packages/next-intl/src/shared/utils.test.tsx index b16504cb6..c9456ac70 100644 --- a/packages/next-intl/src/shared/utils.test.tsx +++ b/packages/next-intl/src/shared/utils.test.tsx @@ -5,7 +5,7 @@ import { matchesPathname, prefixPathname, unprefixPathname -} from './utils.tsx'; +} from './utils.js'; describe('prefixPathname', () => { it("doesn't add trailing slashes for the root", () => { diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 02c6960f1..28e6451fe 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -3,7 +3,7 @@ import type { LocalePrefixConfigVerbose, LocalePrefixMode, Locales -} from '../routing/types.tsx'; +} from '../routing/types.js'; type Href = LinkProps['href']; diff --git a/packages/next-intl/tsconfig.json b/packages/next-intl/tsconfig.json index f32652031..9dcc99414 100644 --- a/packages/next-intl/tsconfig.json +++ b/packages/next-intl/tsconfig.json @@ -2,7 +2,6 @@ "extends": "eslint-config-molindo/tsconfig.json", "include": ["src", "test", "__mocks__", "types", "next-env.d.ts"], "compilerOptions": { - "allowImportingTsExtensions": true, // For ESM "lib": ["dom", "esnext"], "target": "ES2020", "declaration": true, diff --git a/packages/use-intl/eslint.config.mjs b/packages/use-intl/eslint.config.mjs index 48b5300b4..e92ad614a 100644 --- a/packages/use-intl/eslint.config.mjs +++ b/packages/use-intl/eslint.config.mjs @@ -12,6 +12,7 @@ export default (await getPresets('typescript', 'react', 'vitest')).concat({ '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/consistent-type-exports': 'error', '@typescript-eslint/no-import-type-side-effects': 'error', - 'import/no-duplicates': ['error', {'prefer-inline': true}] + 'import/no-duplicates': ['error', {'prefer-inline': true}], + 'import/extensions': 'error' } }); diff --git a/packages/use-intl/src/core.tsx b/packages/use-intl/src/core.tsx index 4a80db990..17f45946d 100644 --- a/packages/use-intl/src/core.tsx +++ b/packages/use-intl/src/core.tsx @@ -1 +1 @@ -export * from './core/index.tsx'; +export * from './core/index.js'; diff --git a/packages/use-intl/src/core/DateTimeFormatOptions.tsx b/packages/use-intl/src/core/DateTimeFormatOptions.tsx index 6fca63f22..0cd45ee08 100644 --- a/packages/use-intl/src/core/DateTimeFormatOptions.tsx +++ b/packages/use-intl/src/core/DateTimeFormatOptions.tsx @@ -1,6 +1,6 @@ // https://github.com/microsoft/TypeScript/issues/35865 -import type TimeZone from './TimeZone.tsx'; +import type TimeZone from './TimeZone.js'; /** * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat diff --git a/packages/use-intl/src/core/Formats.tsx b/packages/use-intl/src/core/Formats.tsx index 96eb1e6be..05702cc82 100644 --- a/packages/use-intl/src/core/Formats.tsx +++ b/packages/use-intl/src/core/Formats.tsx @@ -1,5 +1,5 @@ -import type DateTimeFormatOptions from './DateTimeFormatOptions.tsx'; -import type NumberFormatOptions from './NumberFormatOptions.tsx'; +import type DateTimeFormatOptions from './DateTimeFormatOptions.js'; +import type NumberFormatOptions from './NumberFormatOptions.js'; type Formats = { number?: Record; diff --git a/packages/use-intl/src/core/IntlConfig.tsx b/packages/use-intl/src/core/IntlConfig.tsx index 7a4f85208..76caded15 100644 --- a/packages/use-intl/src/core/IntlConfig.tsx +++ b/packages/use-intl/src/core/IntlConfig.tsx @@ -1,8 +1,8 @@ -import type AbstractIntlMessages from './AbstractIntlMessages.tsx'; -import type {Locale} from './AppConfig.tsx'; -import type Formats from './Formats.tsx'; -import type IntlError from './IntlError.tsx'; -import type TimeZone from './TimeZone.tsx'; +import type AbstractIntlMessages from './AbstractIntlMessages.js'; +import type {Locale} from './AppConfig.js'; +import type Formats from './Formats.js'; +import type IntlError from './IntlError.js'; +import type TimeZone from './TimeZone.js'; /** * Should be used for entry points that configure the library. diff --git a/packages/use-intl/src/core/IntlError.tsx b/packages/use-intl/src/core/IntlError.tsx index aa25d0385..25177b820 100644 --- a/packages/use-intl/src/core/IntlError.tsx +++ b/packages/use-intl/src/core/IntlError.tsx @@ -1,4 +1,4 @@ -import type IntlErrorCode from './IntlErrorCode.tsx'; +import type IntlErrorCode from './IntlErrorCode.js'; export default class IntlError extends Error { public readonly code: IntlErrorCode; diff --git a/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx b/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx index 71c76e64b..ca5d32dd4 100644 --- a/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx +++ b/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx @@ -2,8 +2,8 @@ import { type Formats as IntlFormats, IntlMessageFormat } from 'intl-messageformat'; -import type Formats from './Formats.tsx'; -import type TimeZone from './TimeZone.tsx'; +import type Formats from './Formats.js'; +import type TimeZone from './TimeZone.js'; /** * `intl-messageformat` uses separate keys for `date` and `time`, but there's diff --git a/packages/use-intl/src/core/createBaseTranslator.tsx b/packages/use-intl/src/core/createBaseTranslator.tsx index c4ac43ec0..d961fec63 100644 --- a/packages/use-intl/src/core/createBaseTranslator.tsx +++ b/packages/use-intl/src/core/createBaseTranslator.tsx @@ -1,27 +1,27 @@ import {IntlMessageFormat} from 'intl-messageformat'; import {type ReactNode, cloneElement, isValidElement} from 'react'; -import type AbstractIntlMessages from './AbstractIntlMessages.tsx'; -import type {Locale} from './AppConfig.tsx'; -import type Formats from './Formats.tsx'; -import type {InitializedIntlConfig} from './IntlConfig.tsx'; -import IntlError from './IntlError.tsx'; -import IntlErrorCode from './IntlErrorCode.tsx'; -import type {MessageKeys, NestedKeyOf, NestedValueOf} from './MessageKeys.tsx'; +import type AbstractIntlMessages from './AbstractIntlMessages.js'; +import type {Locale} from './AppConfig.js'; +import type Formats from './Formats.js'; +import type {InitializedIntlConfig} from './IntlConfig.js'; +import IntlError from './IntlError.js'; +import IntlErrorCode from './IntlErrorCode.js'; +import type {MessageKeys, NestedKeyOf, NestedValueOf} from './MessageKeys.js'; import type { MarkupTranslationValues, RichTranslationValues, TranslationValues -} from './TranslationValues.tsx'; -import convertFormatsToIntlMessageFormat from './convertFormatsToIntlMessageFormat.tsx'; -import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; +} from './TranslationValues.js'; +import convertFormatsToIntlMessageFormat from './convertFormatsToIntlMessageFormat.js'; +import {defaultGetMessageFallback, defaultOnError} from './defaults.js'; import { type Formatters, type IntlCache, type IntlFormatters, type MessageFormatter, memoFn -} from './formatters.tsx'; -import joinPath from './joinPath.tsx'; +} from './formatters.js'; +import joinPath from './joinPath.js'; // Placed here for improved tree shaking. Somehow when this is placed in // `formatters.tsx`, then it can't be shaken off from `next-intl`. diff --git a/packages/use-intl/src/core/createFormatter.test.tsx b/packages/use-intl/src/core/createFormatter.test.tsx index cd7014cc9..a45eebef5 100644 --- a/packages/use-intl/src/core/createFormatter.test.tsx +++ b/packages/use-intl/src/core/createFormatter.test.tsx @@ -1,6 +1,6 @@ import {parseISO} from 'date-fns'; import {describe, expect, it} from 'vitest'; -import createFormatter from './createFormatter.tsx'; +import createFormatter from './createFormatter.js'; describe('dateTime', () => { it('formats a date and time', () => { diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index 2d92573e5..c3d31a3d9 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -1,19 +1,19 @@ import type {ReactElement} from 'react'; -import type {FormatNames, Locale} from './AppConfig.tsx'; -import type DateTimeFormatOptions from './DateTimeFormatOptions.tsx'; -import type Formats from './Formats.tsx'; -import IntlError from './IntlError.tsx'; -import IntlErrorCode from './IntlErrorCode.tsx'; -import type NumberFormatOptions from './NumberFormatOptions.tsx'; -import type RelativeTimeFormatOptions from './RelativeTimeFormatOptions.tsx'; -import type TimeZone from './TimeZone.tsx'; -import {defaultOnError} from './defaults.tsx'; +import type {FormatNames, Locale} from './AppConfig.js'; +import type DateTimeFormatOptions from './DateTimeFormatOptions.js'; +import type Formats from './Formats.js'; +import IntlError from './IntlError.js'; +import IntlErrorCode from './IntlErrorCode.js'; +import type NumberFormatOptions from './NumberFormatOptions.js'; +import type RelativeTimeFormatOptions from './RelativeTimeFormatOptions.js'; +import type TimeZone from './TimeZone.js'; +import {defaultOnError} from './defaults.js'; import { type Formatters, type IntlCache, createCache, createIntlFormatters -} from './formatters.tsx'; +} from './formatters.js'; const SECOND = 1; const MINUTE = SECOND * 60; diff --git a/packages/use-intl/src/core/createTranslator.test.tsx b/packages/use-intl/src/core/createTranslator.test.tsx index 1a4a4c9bd..ea4d4e7c2 100644 --- a/packages/use-intl/src/core/createTranslator.test.tsx +++ b/packages/use-intl/src/core/createTranslator.test.tsx @@ -1,10 +1,10 @@ import {isValidElement} from 'react'; import {renderToString} from 'react-dom/server'; import {describe, expect, it, vi} from 'vitest'; -import type {Messages} from './AppConfig.tsx'; -import type IntlError from './IntlError.tsx'; -import IntlErrorCode from './IntlErrorCode.tsx'; -import createTranslator from './createTranslator.tsx'; +import type {Messages} from './AppConfig.js'; +import type IntlError from './IntlError.js'; +import IntlErrorCode from './IntlErrorCode.js'; +import createTranslator from './createTranslator.js'; const messages = { Home: { diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index 4b08ae315..18a37f6b1 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -1,28 +1,28 @@ import type {ReactNode} from 'react'; -import type Formats from './Formats.tsx'; -import type ICUArgs from './ICUArgs.tsx'; -import type ICUTags from './ICUTags.tsx'; -import type IntlConfig from './IntlConfig.tsx'; +import type Formats from './Formats.js'; +import type ICUArgs from './ICUArgs.js'; +import type ICUTags from './ICUTags.js'; +import type IntlConfig from './IntlConfig.js'; import type { MessageKeys, NamespaceKeys, NestedKeyOf, NestedValueOf -} from './MessageKeys.tsx'; +} from './MessageKeys.js'; import type { MarkupTagsFunction, RichTagsFunction, TranslationValues -} from './TranslationValues.tsx'; -import createTranslatorImpl from './createTranslatorImpl.tsx'; -import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; +} from './TranslationValues.js'; +import createTranslatorImpl from './createTranslatorImpl.js'; +import {defaultGetMessageFallback, defaultOnError} from './defaults.js'; import { type Formatters, type IntlCache, createCache, createIntlFormatters -} from './formatters.tsx'; -import type {Prettify} from './types.tsx'; +} from './formatters.js'; +import type {Prettify} from './types.js'; type ICUArgsWithTags< MessageString extends string, diff --git a/packages/use-intl/src/core/createTranslatorImpl.tsx b/packages/use-intl/src/core/createTranslatorImpl.tsx index a87097776..3f3fdb614 100644 --- a/packages/use-intl/src/core/createTranslatorImpl.tsx +++ b/packages/use-intl/src/core/createTranslatorImpl.tsx @@ -1,9 +1,9 @@ -import type AbstractIntlMessages from './AbstractIntlMessages.tsx'; -import type {InitializedIntlConfig} from './IntlConfig.tsx'; -import type {NestedKeyOf} from './MessageKeys.tsx'; -import createBaseTranslator from './createBaseTranslator.tsx'; -import type {Formatters, IntlCache} from './formatters.tsx'; -import resolveNamespace from './resolveNamespace.tsx'; +import type AbstractIntlMessages from './AbstractIntlMessages.js'; +import type {InitializedIntlConfig} from './IntlConfig.js'; +import type {NestedKeyOf} from './MessageKeys.js'; +import createBaseTranslator from './createBaseTranslator.js'; +import type {Formatters, IntlCache} from './formatters.js'; +import resolveNamespace from './resolveNamespace.js'; export type CreateTranslatorImplProps = Omit< InitializedIntlConfig, diff --git a/packages/use-intl/src/core/defaults.tsx b/packages/use-intl/src/core/defaults.tsx index f86619763..a35403f0f 100644 --- a/packages/use-intl/src/core/defaults.tsx +++ b/packages/use-intl/src/core/defaults.tsx @@ -1,5 +1,5 @@ -import type IntlError from './IntlError.tsx'; -import joinPath from './joinPath.tsx'; +import type IntlError from './IntlError.js'; +import joinPath from './joinPath.js'; /** * Contains defaults that are used for all entry points into the core. diff --git a/packages/use-intl/src/core/hasLocale.test.tsx b/packages/use-intl/src/core/hasLocale.test.tsx index 36c611f3b..b77d4b5de 100644 --- a/packages/use-intl/src/core/hasLocale.test.tsx +++ b/packages/use-intl/src/core/hasLocale.test.tsx @@ -1,5 +1,5 @@ import {expect, it} from 'vitest'; -import hasLocale from './hasLocale.tsx'; +import hasLocale from './hasLocale.js'; it('narrows down the type', () => { const locales = ['en-US', 'en-GB'] as const; diff --git a/packages/use-intl/src/core/hasLocale.tsx b/packages/use-intl/src/core/hasLocale.tsx index 576ee3289..e8177f1db 100644 --- a/packages/use-intl/src/core/hasLocale.tsx +++ b/packages/use-intl/src/core/hasLocale.tsx @@ -1,4 +1,4 @@ -import type {Locale} from './AppConfig.tsx'; +import type {Locale} from './AppConfig.js'; /** * Checks if a locale exists in a list of locales. diff --git a/packages/use-intl/src/core/index.tsx b/packages/use-intl/src/core/index.tsx index 4f5c6e503..a80ad92b7 100644 --- a/packages/use-intl/src/core/index.tsx +++ b/packages/use-intl/src/core/index.tsx @@ -1,31 +1,31 @@ -export type {default as AbstractIntlMessages} from './AbstractIntlMessages.tsx'; +export type {default as AbstractIntlMessages} from './AbstractIntlMessages.js'; export type { TranslationValues, RichTranslationValues, MarkupTranslationValues, RichTagsFunction, MarkupTagsFunction -} from './TranslationValues.tsx'; -export type {default as Formats} from './Formats.tsx'; -export type {default as IntlConfig} from './IntlConfig.tsx'; -export type {default as DateTimeFormatOptions} from './DateTimeFormatOptions.tsx'; -export type {default as NumberFormatOptions} from './NumberFormatOptions.tsx'; -export {default as IntlError} from './IntlError.tsx'; -export {default as IntlErrorCode} from './IntlErrorCode.tsx'; -export {default as createTranslator} from './createTranslator.tsx'; -export {default as createFormatter} from './createFormatter.tsx'; -export {default as initializeConfig} from './initializeConfig.tsx'; +} from './TranslationValues.js'; +export type {default as Formats} from './Formats.js'; +export type {default as IntlConfig} from './IntlConfig.js'; +export type {default as DateTimeFormatOptions} from './DateTimeFormatOptions.js'; +export type {default as NumberFormatOptions} from './NumberFormatOptions.js'; +export {default as IntlError} from './IntlError.js'; +export {default as IntlErrorCode} from './IntlErrorCode.js'; +export {default as createTranslator} from './createTranslator.js'; +export {default as createFormatter} from './createFormatter.js'; +export {default as initializeConfig} from './initializeConfig.js'; export type { MessageKeys, NamespaceKeys, NestedKeyOf, NestedValueOf -} from './MessageKeys.tsx'; -export {createIntlFormatters as _createIntlFormatters} from './formatters.tsx'; -export {createCache as _createCache} from './formatters.tsx'; -export type {default as AppConfig, Locale, Messages} from './AppConfig.tsx'; -export {default as hasLocale} from './hasLocale.tsx'; -export type {default as RelativeTimeFormatOptions} from './RelativeTimeFormatOptions.tsx'; -export type {default as Timezone} from './TimeZone.tsx'; -export type {default as ICUArgs} from './ICUArgs.tsx'; -export type {default as ICUTags} from './ICUTags.tsx'; +} from './MessageKeys.js'; +export {createIntlFormatters as _createIntlFormatters} from './formatters.js'; +export {createCache as _createCache} from './formatters.js'; +export type {default as AppConfig, Locale, Messages} from './AppConfig.js'; +export {default as hasLocale} from './hasLocale.js'; +export type {default as RelativeTimeFormatOptions} from './RelativeTimeFormatOptions.js'; +export type {default as Timezone} from './TimeZone.js'; +export type {default as ICUArgs} from './ICUArgs.js'; +export type {default as ICUTags} from './ICUTags.js'; diff --git a/packages/use-intl/src/core/initializeConfig.tsx b/packages/use-intl/src/core/initializeConfig.tsx index 3a52d6c6b..1bce4c21c 100644 --- a/packages/use-intl/src/core/initializeConfig.tsx +++ b/packages/use-intl/src/core/initializeConfig.tsx @@ -1,6 +1,6 @@ -import type IntlConfig from './IntlConfig.tsx'; -import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; -import validateMessages from './validateMessages.tsx'; +import type IntlConfig from './IntlConfig.js'; +import {defaultGetMessageFallback, defaultOnError} from './defaults.js'; +import validateMessages from './validateMessages.js'; /** * Enhances the incoming props with defaults. diff --git a/packages/use-intl/src/core/validateMessages.tsx b/packages/use-intl/src/core/validateMessages.tsx index d8f4b31f0..f5b2fd7e5 100644 --- a/packages/use-intl/src/core/validateMessages.tsx +++ b/packages/use-intl/src/core/validateMessages.tsx @@ -1,7 +1,7 @@ -import type AbstractIntlMessages from './AbstractIntlMessages.tsx'; -import IntlError from './IntlError.tsx'; -import IntlErrorCode from './IntlErrorCode.tsx'; -import joinPath from './joinPath.tsx'; +import type AbstractIntlMessages from './AbstractIntlMessages.js'; +import IntlError from './IntlError.js'; +import IntlErrorCode from './IntlErrorCode.js'; +import joinPath from './joinPath.js'; function validateMessagesSegment( messages: AbstractIntlMessages, diff --git a/packages/use-intl/src/index.tsx b/packages/use-intl/src/index.tsx index a7f352cea..03330e7ba 100644 --- a/packages/use-intl/src/index.tsx +++ b/packages/use-intl/src/index.tsx @@ -1,2 +1,2 @@ -export * from './core.tsx'; -export * from './react.tsx'; +export * from './core.js'; +export * from './react.js'; diff --git a/packages/use-intl/src/react.tsx b/packages/use-intl/src/react.tsx index 858a6b562..a6189cdec 100644 --- a/packages/use-intl/src/react.tsx +++ b/packages/use-intl/src/react.tsx @@ -1 +1 @@ -export * from './react/index.tsx'; +export * from './react/index.js'; diff --git a/packages/use-intl/src/react/IntlContext.tsx b/packages/use-intl/src/react/IntlContext.tsx index 1ba115907..dbe190bcd 100644 --- a/packages/use-intl/src/react/IntlContext.tsx +++ b/packages/use-intl/src/react/IntlContext.tsx @@ -1,6 +1,6 @@ import {createContext} from 'react'; -import type {InitializedIntlConfig} from '../core/IntlConfig.tsx'; -import type {Formatters, IntlCache} from '../core/formatters.tsx'; +import type {InitializedIntlConfig} from '../core/IntlConfig.js'; +import type {Formatters, IntlCache} from '../core/formatters.js'; export type IntlContextValue = InitializedIntlConfig & { formatters: Formatters; diff --git a/packages/use-intl/src/react/IntlProvider.test.tsx b/packages/use-intl/src/react/IntlProvider.test.tsx index fccc57726..af2564910 100644 --- a/packages/use-intl/src/react/IntlProvider.test.tsx +++ b/packages/use-intl/src/react/IntlProvider.test.tsx @@ -1,9 +1,9 @@ import {fireEvent, render, screen} from '@testing-library/react'; import {memo, useState} from 'react'; import {expect, it, vi} from 'vitest'; -import IntlProvider from './IntlProvider.tsx'; -import useNow from './useNow.tsx'; -import useTranslations from './useTranslations.tsx'; +import IntlProvider from './IntlProvider.js'; +import useNow from './useNow.js'; +import useTranslations from './useTranslations.js'; it("doesn't re-render context consumers unnecessarily", () => { const messages = {StaticText: {hello: 'Hello!'}}; diff --git a/packages/use-intl/src/react/IntlProvider.tsx b/packages/use-intl/src/react/IntlProvider.tsx index 58e5e1abc..81746ac34 100644 --- a/packages/use-intl/src/react/IntlProvider.tsx +++ b/packages/use-intl/src/react/IntlProvider.tsx @@ -1,12 +1,12 @@ import {type ReactNode, useContext, useMemo} from 'react'; -import type IntlConfig from '../core/IntlConfig.tsx'; +import type IntlConfig from '../core/IntlConfig.js'; import { type Formatters, createCache, createIntlFormatters -} from '../core/formatters.tsx'; -import initializeConfig from '../core/initializeConfig.tsx'; -import IntlContext from './IntlContext.tsx'; +} from '../core/formatters.js'; +import initializeConfig from '../core/initializeConfig.js'; +import IntlContext from './IntlContext.js'; type Props = IntlConfig & { children: ReactNode; diff --git a/packages/use-intl/src/react/index.test.tsx b/packages/use-intl/src/react/index.test.tsx index 225aa933b..8c211de88 100644 --- a/packages/use-intl/src/react/index.test.tsx +++ b/packages/use-intl/src/react/index.test.tsx @@ -1,11 +1,11 @@ import {render, screen} from '@testing-library/react'; import {parseISO} from 'date-fns'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import type {Locale} from '../core.tsx'; -import IntlProvider from './IntlProvider.tsx'; -import useFormatter from './useFormatter.tsx'; -import useNow from './useNow.tsx'; -import useTranslations from './useTranslations.tsx'; +import type {Locale} from '../core.js'; +import IntlProvider from './IntlProvider.js'; +import useFormatter from './useFormatter.js'; +import useNow from './useNow.js'; +import useTranslations from './useTranslations.js'; describe('performance', () => { beforeEach(() => { diff --git a/packages/use-intl/src/react/index.tsx b/packages/use-intl/src/react/index.tsx index 30a1e1a86..265427525 100644 --- a/packages/use-intl/src/react/index.tsx +++ b/packages/use-intl/src/react/index.tsx @@ -1,7 +1,7 @@ -export {default as IntlProvider} from './IntlProvider.tsx'; -export {default as useTranslations} from './useTranslations.tsx'; -export {default as useLocale} from './useLocale.tsx'; -export {default as useNow} from './useNow.tsx'; -export {default as useTimeZone} from './useTimeZone.tsx'; -export {default as useMessages} from './useMessages.tsx'; -export {default as useFormatter} from './useFormatter.tsx'; +export {default as IntlProvider} from './IntlProvider.js'; +export {default as useTranslations} from './useTranslations.js'; +export {default as useLocale} from './useLocale.js'; +export {default as useNow} from './useNow.js'; +export {default as useTimeZone} from './useTimeZone.js'; +export {default as useMessages} from './useMessages.js'; +export {default as useFormatter} from './useFormatter.js'; diff --git a/packages/use-intl/src/react/useFormatter.test.tsx b/packages/use-intl/src/react/useFormatter.test.tsx index 1e0735af4..fc9e42184 100644 --- a/packages/use-intl/src/react/useFormatter.test.tsx +++ b/packages/use-intl/src/react/useFormatter.test.tsx @@ -3,9 +3,9 @@ import {parseISO} from 'date-fns'; import type {ComponentProps, ReactElement, ReactNode} from 'react'; import {type SpyImpl, spyOn} from 'tinyspy'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {type IntlError, IntlErrorCode} from '../core.tsx'; -import IntlProvider from './IntlProvider.tsx'; -import useFormatter from './useFormatter.tsx'; +import {type IntlError, IntlErrorCode} from '../core.js'; +import IntlProvider from './IntlProvider.js'; +import useFormatter from './useFormatter.js'; function MockProvider( props: Partial> & {children: ReactNode} diff --git a/packages/use-intl/src/react/useFormatter.tsx b/packages/use-intl/src/react/useFormatter.tsx index c551c7333..8a2221b6e 100644 --- a/packages/use-intl/src/react/useFormatter.tsx +++ b/packages/use-intl/src/react/useFormatter.tsx @@ -1,6 +1,6 @@ import {useMemo} from 'react'; -import createFormatter from '../core/createFormatter.tsx'; -import useIntlContext from './useIntlContext.tsx'; +import createFormatter from '../core/createFormatter.js'; +import useIntlContext from './useIntlContext.js'; export default function useFormatter(): ReturnType { const { diff --git a/packages/use-intl/src/react/useIntlContext.tsx b/packages/use-intl/src/react/useIntlContext.tsx index ec688ca75..d588105db 100644 --- a/packages/use-intl/src/react/useIntlContext.tsx +++ b/packages/use-intl/src/react/useIntlContext.tsx @@ -1,5 +1,5 @@ import {useContext} from 'react'; -import IntlContext, {type IntlContextValue} from './IntlContext.tsx'; +import IntlContext, {type IntlContextValue} from './IntlContext.js'; export default function useIntlContext(): IntlContextValue { const context = useContext(IntlContext); diff --git a/packages/use-intl/src/react/useLocale.test.tsx b/packages/use-intl/src/react/useLocale.test.tsx index 732aee4d7..3f644b5c1 100644 --- a/packages/use-intl/src/react/useLocale.test.tsx +++ b/packages/use-intl/src/react/useLocale.test.tsx @@ -1,7 +1,7 @@ import {render, screen} from '@testing-library/react'; import {it} from 'vitest'; -import IntlProvider from './IntlProvider.tsx'; -import useLocale from './useLocale.tsx'; +import IntlProvider from './IntlProvider.js'; +import useLocale from './useLocale.js'; it('returns the current locale', () => { function Component() { diff --git a/packages/use-intl/src/react/useLocale.tsx b/packages/use-intl/src/react/useLocale.tsx index 621b18847..bcac7e82f 100644 --- a/packages/use-intl/src/react/useLocale.tsx +++ b/packages/use-intl/src/react/useLocale.tsx @@ -1,5 +1,5 @@ -import type {Locale} from '../core.tsx'; -import useIntlContext from './useIntlContext.tsx'; +import type {Locale} from '../core.js'; +import useIntlContext from './useIntlContext.js'; export default function useLocale(): Locale { return useIntlContext().locale; diff --git a/packages/use-intl/src/react/useMessages.test.tsx b/packages/use-intl/src/react/useMessages.test.tsx index d8563b0e1..9e6ac6c54 100644 --- a/packages/use-intl/src/react/useMessages.test.tsx +++ b/packages/use-intl/src/react/useMessages.test.tsx @@ -1,7 +1,7 @@ import {render, screen} from '@testing-library/react'; import {expect, it} from 'vitest'; -import IntlProvider from './IntlProvider.tsx'; -import useMessages from './useMessages.tsx'; +import IntlProvider from './IntlProvider.js'; +import useMessages from './useMessages.js'; function Component() { const messages = useMessages(); diff --git a/packages/use-intl/src/react/useMessages.tsx b/packages/use-intl/src/react/useMessages.tsx index 987637ccf..c67f3d106 100644 --- a/packages/use-intl/src/react/useMessages.tsx +++ b/packages/use-intl/src/react/useMessages.tsx @@ -1,5 +1,5 @@ -import type {Messages} from '../core/AppConfig.tsx'; -import useIntlContext from './useIntlContext.tsx'; +import type {Messages} from '../core/AppConfig.js'; +import useIntlContext from './useIntlContext.js'; export default function useMessages(): Messages { const context = useIntlContext(); diff --git a/packages/use-intl/src/react/useNow.test.tsx b/packages/use-intl/src/react/useNow.test.tsx index 0e43f2eed..fa88bc8df 100644 --- a/packages/use-intl/src/react/useNow.test.tsx +++ b/packages/use-intl/src/react/useNow.test.tsx @@ -1,8 +1,8 @@ import {render, waitFor} from '@testing-library/react'; import {parseISO} from 'date-fns'; import {expect, it} from 'vitest'; -import IntlProvider from './IntlProvider.tsx'; -import useNow from './useNow.tsx'; +import IntlProvider from './IntlProvider.js'; +import useNow from './useNow.js'; it('returns the current time', () => { function Component() { diff --git a/packages/use-intl/src/react/useNow.tsx b/packages/use-intl/src/react/useNow.tsx index 3c0b054c4..efa17a754 100644 --- a/packages/use-intl/src/react/useNow.tsx +++ b/packages/use-intl/src/react/useNow.tsx @@ -1,5 +1,5 @@ import {useEffect, useState} from 'react'; -import useIntlContext from './useIntlContext.tsx'; +import useIntlContext from './useIntlContext.js'; type Options = { updateInterval?: number; diff --git a/packages/use-intl/src/react/useTimeZone.test.tsx b/packages/use-intl/src/react/useTimeZone.test.tsx index 57d4031e3..8a7319dff 100644 --- a/packages/use-intl/src/react/useTimeZone.test.tsx +++ b/packages/use-intl/src/react/useTimeZone.test.tsx @@ -1,7 +1,7 @@ import {render, screen} from '@testing-library/react'; import {expect, it} from 'vitest'; -import IntlProvider from './IntlProvider.tsx'; -import useTimeZone from './useTimeZone.tsx'; +import IntlProvider from './IntlProvider.js'; +import useTimeZone from './useTimeZone.js'; it('returns the time zone when it is configured', () => { function Component() { diff --git a/packages/use-intl/src/react/useTimeZone.tsx b/packages/use-intl/src/react/useTimeZone.tsx index f95f5b719..6709deee5 100644 --- a/packages/use-intl/src/react/useTimeZone.tsx +++ b/packages/use-intl/src/react/useTimeZone.tsx @@ -1,4 +1,4 @@ -import useIntlContext from './useIntlContext.tsx'; +import useIntlContext from './useIntlContext.js'; export default function useTimeZone() { return useIntlContext().timeZone; diff --git a/packages/use-intl/src/react/useTranslations.test.tsx b/packages/use-intl/src/react/useTranslations.test.tsx index cabcc94ab..184aca7e3 100644 --- a/packages/use-intl/src/react/useTranslations.test.tsx +++ b/packages/use-intl/src/react/useTranslations.test.tsx @@ -9,9 +9,9 @@ import { IntlErrorCode, type RichTranslationValues, type TranslationValues -} from '../core.tsx'; -import IntlProvider from './IntlProvider.tsx'; -import useTranslations from './useTranslations.tsx'; +} from '../core.js'; +import IntlProvider from './IntlProvider.js'; +import useTranslations from './useTranslations.js'; // Wrap the library to include a counter for parse // invocations for the cache test below. diff --git a/packages/use-intl/src/react/useTranslations.tsx b/packages/use-intl/src/react/useTranslations.tsx index 57d42b11b..b1f9860c0 100644 --- a/packages/use-intl/src/react/useTranslations.tsx +++ b/packages/use-intl/src/react/useTranslations.tsx @@ -1,8 +1,8 @@ -import type {Messages} from '../core/AppConfig.tsx'; -import type {NamespaceKeys, NestedKeyOf} from '../core/MessageKeys.tsx'; -import type createTranslator from '../core/createTranslator.tsx'; -import useIntlContext from './useIntlContext.tsx'; -import useTranslationsImpl from './useTranslationsImpl.tsx'; +import type {Messages} from '../core/AppConfig.js'; +import type {NamespaceKeys, NestedKeyOf} from '../core/MessageKeys.js'; +import type createTranslator from '../core/createTranslator.js'; +import useIntlContext from './useIntlContext.js'; +import useTranslationsImpl from './useTranslationsImpl.js'; /** * Translates messages from the given namespace by using the ICU syntax. diff --git a/packages/use-intl/src/react/useTranslationsImpl.tsx b/packages/use-intl/src/react/useTranslationsImpl.tsx index 243397100..bd190d0f0 100644 --- a/packages/use-intl/src/react/useTranslationsImpl.tsx +++ b/packages/use-intl/src/react/useTranslationsImpl.tsx @@ -1,10 +1,10 @@ import {useMemo} from 'react'; -import type AbstractIntlMessages from '../core/AbstractIntlMessages.tsx'; -import type {NestedKeyOf} from '../core/MessageKeys.tsx'; -import createBaseTranslator from '../core/createBaseTranslator.tsx'; -import resolveNamespace from '../core/resolveNamespace.tsx'; -import {IntlError, IntlErrorCode} from '../core.tsx'; -import useIntlContext from './useIntlContext.tsx'; +import type AbstractIntlMessages from '../core/AbstractIntlMessages.js'; +import type {NestedKeyOf} from '../core/MessageKeys.js'; +import createBaseTranslator from '../core/createBaseTranslator.js'; +import resolveNamespace from '../core/resolveNamespace.js'; +import {IntlError, IntlErrorCode} from '../core.js'; +import useIntlContext from './useIntlContext.js'; let hasWarnedForMissingTimezone = false; const isServer = typeof window === 'undefined'; diff --git a/packages/use-intl/tsconfig.json b/packages/use-intl/tsconfig.json index 1a0feb584..29d340f67 100644 --- a/packages/use-intl/tsconfig.json +++ b/packages/use-intl/tsconfig.json @@ -2,7 +2,6 @@ "extends": "eslint-config-molindo/tsconfig.json", "include": ["src", "test", "types"], "compilerOptions": { - "allowImportingTsExtensions": true, // For ESM "lib": ["dom", "esnext"], "target": "ES2020", "declaration": true, From 67507cc80aab6cdd4358f247c817126c2d943660 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 24 Jan 2025 12:10:02 +0100 Subject: [PATCH 72/90] fix: Accept a deep partial of `Messages` for `messages` prop of provider (#1679) ([ref](https://github.com/amannn/next-intl/discussions/1631#discussioncomment-11751429)) --- .../src/components/TypePortabilityTest.ts | 9 +- packages/use-intl/src/core/IntlConfig.tsx | 82 +++++++++---------- .../use-intl/src/core/createTranslator.tsx | 2 +- packages/use-intl/src/core/types.tsx | 6 ++ .../src/react/useTranslations.test.tsx | 10 +-- 5 files changed, 54 insertions(+), 55 deletions(-) diff --git a/examples/example-app-router-playground/src/components/TypePortabilityTest.ts b/examples/example-app-router-playground/src/components/TypePortabilityTest.ts index af644fd62..f1e5df1ab 100644 --- a/examples/example-app-router-playground/src/components/TypePortabilityTest.ts +++ b/examples/example-app-router-playground/src/components/TypePortabilityTest.ts @@ -5,7 +5,7 @@ import { createFormatter, - initializeConfig, + createTranslator, useFormatter, useLocale, useMessages, @@ -60,9 +60,12 @@ export async function asyncApis() { } export const withNextIntl = createNextIntlPlugin(); -export const config = initializeConfig({locale: 'en'}); -// export const translator = createTranslator({locale: 'en'}); + export const formatter = createFormatter({ locale: 'en', now: new Date(2022, 10, 6, 20, 20, 0, 0) }); + +export const translator = createTranslator({ + locale: 'en' +}); diff --git a/packages/use-intl/src/core/IntlConfig.tsx b/packages/use-intl/src/core/IntlConfig.tsx index 76caded15..20ab1876a 100644 --- a/packages/use-intl/src/core/IntlConfig.tsx +++ b/packages/use-intl/src/core/IntlConfig.tsx @@ -1,58 +1,56 @@ -import type AbstractIntlMessages from './AbstractIntlMessages.js'; -import type {Locale} from './AppConfig.js'; +import type {Locale, Messages} from './AppConfig.js'; import type Formats from './Formats.js'; import type IntlError from './IntlError.js'; import type TimeZone from './TimeZone.js'; +import type {DeepPartial} from './types.js'; /** * Should be used for entry points that configure the library. */ -type IntlConfig = - { - /** A valid Unicode locale tag (e.g. "en" or "en-GB"). */ - locale: Locale; - /** Global formats can be provided to achieve consistent - * formatting across components. */ - formats?: Formats; - /** A time zone as defined in [the tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) which will be applied when formatting dates and times. If this is absent, the user time zone will be used. You can override this by supplying an explicit time zone to `formatDateTime`. */ - timeZone?: TimeZone; - /** This callback will be invoked when an error is encountered during - * resolving a message or formatting it. This defaults to `console.error` to - * keep your app running. You can customize the handling by taking - * `error.code` into account. */ - onError?(error: IntlError): void; - /** Will be called when a message couldn't be resolved or formatting it led to - * an error. This defaults to `${namespace}.${key}` You can use this to - * customize what will be rendered in this case. */ - getMessageFallback?(info: { - error: IntlError; - key: string; - namespace?: string; - }): string; - /** - * Providing this value will have two effects: - * 1. It will be used as the default for the `now` argument of - * `useFormatter().formatRelativeTime` if no explicit value is provided. - * 2. It will be returned as a static value from the `useNow` hook. Note - * however that when `updateInterval` is configured on the `useNow` hook, - * the global `now` value will only be used for the initial render, but - * afterwards the current date will be returned continuously. - */ - now?: Date; - /** All messages that will be available. */ - messages?: Messages; - }; +type IntlConfig = { + /** A valid Unicode locale tag (e.g. "en" or "en-GB"). */ + locale: Locale; + /** Global formats can be provided to achieve consistent + * formatting across components. */ + formats?: Formats; + /** A time zone as defined in [the tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) which will be applied when formatting dates and times. If this is absent, the user time zone will be used. You can override this by supplying an explicit time zone to `formatDateTime`. */ + timeZone?: TimeZone; + /** This callback will be invoked when an error is encountered during + * resolving a message or formatting it. This defaults to `console.error` to + * keep your app running. You can customize the handling by taking + * `error.code` into account. */ + onError?(error: IntlError): void; + /** Will be called when a message couldn't be resolved or formatting it led to + * an error. This defaults to `${namespace}.${key}` You can use this to + * customize what will be rendered in this case. */ + getMessageFallback?(info: { + error: IntlError; + key: string; + namespace?: string; + }): string; + /** + * Providing this value will have two effects: + * 1. It will be used as the default for the `now` argument of + * `useFormatter().formatRelativeTime` if no explicit value is provided. + * 2. It will be returned as a static value from the `useNow` hook. Note + * however that when `updateInterval` is configured on the `useNow` hook, + * the global `now` value will only be used for the initial render, but + * afterwards the current date will be returned continuously. + */ + now?: Date; + /** All messages that will be available. */ + messages?: DeepPartial; +}; +/** /** * A stricter set of the configuration that should be used internally * once defaults are assigned to `IntlConfiguration`. */ -export type InitializedIntlConfig< - Messages extends AbstractIntlMessages = AbstractIntlMessages -> = IntlConfig & { - onError: NonNullable['onError']>; - getMessageFallback: NonNullable['getMessageFallback']>; +export type InitializedIntlConfig = IntlConfig & { + onError: NonNullable; + getMessageFallback: NonNullable; }; export default IntlConfig; diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index 18a37f6b1..905fd2341 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -118,7 +118,7 @@ export default function createTranslator< namespace, onError = defaultOnError, ...rest -}: Omit, 'messages'> & { +}: Omit & { messages?: TranslatorMessages; namespace?: Namespace; /** @private */ diff --git a/packages/use-intl/src/core/types.tsx b/packages/use-intl/src/core/types.tsx index 654654c8a..140ae5c1b 100644 --- a/packages/use-intl/src/core/types.tsx +++ b/packages/use-intl/src/core/types.tsx @@ -2,3 +2,9 @@ export type Prettify = { [K in keyof T]: T[K]; } & {}; + +export type DeepPartial = { + [Key in keyof Type]?: Type[Key] extends object + ? DeepPartial + : Type[Key]; +}; diff --git a/packages/use-intl/src/react/useTranslations.test.tsx b/packages/use-intl/src/react/useTranslations.test.tsx index 184aca7e3..ab1a0b65b 100644 --- a/packages/use-intl/src/react/useTranslations.test.tsx +++ b/packages/use-intl/src/react/useTranslations.test.tsx @@ -745,13 +745,7 @@ describe('error handling', () => { const onError = vi.fn(); render( - + ); @@ -878,7 +872,6 @@ describe('error handling', () => { render( @@ -904,7 +897,6 @@ describe('error handling', () => { render( From c40c5c94fe81794ebd9fdf9b90c5eb67a4801a81 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 27 Jan 2025 09:49:59 +0100 Subject: [PATCH 73/90] feat: Inherit messages by default in `NextIntlClientProvider` (#1682) This makes it easier to get started. If you don't want this behavior, you can still opt-out via `messages={null}`. **TODO** - [ ] Merge https://github.com/amannn/next-intl/pull/1684 along with this --- .../environments/server-client-components.mdx | 18 +++++---------- .../app-router/with-i18n-routing.mdx | 11 +-------- .../app-router/without-i18n-routing.mdx | 12 ++-------- docs/src/pages/docs/usage/configuration.mdx | 23 +++++++++---------- .../src/app/[locale]/layout.tsx | 9 +------- .../src/app/(public)/[locale]/layout.tsx | 8 ++----- .../src/app/app/layout.tsx | 8 ++----- .../src/app/layout.tsx | 10 ++------ .../src/app/layout.tsx | 10 ++------ .../src/components/BaseLayout.tsx | 7 +----- packages/next-intl/.size-limit.ts | 2 +- .../NextIntlClientProviderServer.test.tsx | 12 +++++++--- .../NextIntlClientProviderServer.tsx | 4 +++- .../src/server/react-server/getConfig.tsx | 18 ++++++++------- .../react-server/getServerFormatter.tsx | 4 ---- packages/use-intl/.size-limit.ts | 4 ++-- packages/use-intl/src/core/IntlConfig.tsx | 11 ++++++--- .../use-intl/src/core/initializeConfig.tsx | 9 ++++++-- .../use-intl/src/react/IntlProvider.test.tsx | 22 ++++++++++++++++++ packages/use-intl/src/react/IntlProvider.tsx | 4 ++-- 20 files changed, 94 insertions(+), 112 deletions(-) diff --git a/docs/src/pages/docs/environments/server-client-components.mdx b/docs/src/pages/docs/environments/server-client-components.mdx index b7c743ef4..865e5201b 100644 --- a/docs/src/pages/docs/environments/server-client-components.mdx +++ b/docs/src/pages/docs/environments/server-client-components.mdx @@ -115,11 +115,13 @@ Regarding performance, async functions and hooks can be used interchangeably. Th ## Using internationalization in Client Components -Depending on your situation, you may need to handle internationalization in Client Components. While providing all messages to the client side is typically the easiest way to [get started](/docs/getting-started/app-router#layout) and a reasonable approach for many apps, you can be more selective about which messages are passed to the client side if you're interested in optimizing the performance of your app. +Depending on your situation, you may need to handle internationalization in Client Components. Providing all messages to the client side is the easiest way to get started, therefore `next-intl` automatically does this when you render [`NextIntlClientProvider`](/docs/usage/configuration#nextintlclientprovider). This is a reasonable approach for many apps. + +However, you can be more selective about which messages are passed to the client side if you're interested in optimizing the performance of your app. There are several options for using translations from `next-intl` in Client Components, listed here in order of enabling the best performance: -### Option 1: Passing translations to Client Components +### Option 1: Passing translated labels to Client Components The preferred approach is to pass the processed labels as props or `children` from a Server Component. @@ -278,8 +280,6 @@ In particular, page and search params are often a great option because they offe ### Option 3: Providing individual messages -To reduce bundle size, `next-intl` doesn't automatically provide [messages](/docs/usage/configuration#messages) to Client Components. - If you need to incorporate dynamic state into components that can not be moved to the server side, you can wrap these components with `NextIntlClientProvider` and provide the relevant messages. ```tsx filename="Counter.tsx" @@ -315,22 +315,16 @@ An automatic, compiler-driven approach is being evaluated in [`next-intl#1`](htt ### Option 4: Providing all messages -If you're building a highly dynamic app where most components use React's interactive features, you may prefer to make all messages available to Client Components. +If you're building a highly dynamic app where most components use React's interactive features, you may prefer to make all messages available to Client Components—this is the default behavior of `next-intl`. ```tsx filename="layout.tsx" /NextIntlClientProvider/ import {NextIntlClientProvider} from 'next-intl'; -import {getMessages} from 'next-intl/server'; export default async function RootLayout(/* ... */) { - // Receive messages provided in `i18n/request.ts` - const messages = await getMessages(); - return ( - - {children} - + {children} ); diff --git a/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx b/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx index 58f3e7b83..878c240c0 100644 --- a/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx +++ b/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx @@ -186,7 +186,6 @@ The `locale` that was matched by the middleware is available via the `locale` pa ```tsx filename="app/[locale]/layout.tsx" import {NextIntlClientProvider, Locale, hasLocale} from 'next-intl'; -import {getMessages} from 'next-intl/server'; import {notFound} from 'next/navigation'; import {routing} from '@/i18n/routing'; @@ -201,24 +200,16 @@ export default async function LocaleLayout({ notFound(); } - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( - - {children} - + {children} ); } ``` -Note that `NextIntlClientProvider` automatically inherits configuration from `i18n/request.ts` here, but `messages` need to be passed explicitly. - ### `src/app/[locale]/page.tsx` [#page] And that's it! diff --git a/docs/src/pages/docs/getting-started/app-router/without-i18n-routing.mdx b/docs/src/pages/docs/getting-started/app-router/without-i18n-routing.mdx index 3b43b6bc3..db9b2a3fa 100644 --- a/docs/src/pages/docs/getting-started/app-router/without-i18n-routing.mdx +++ b/docs/src/pages/docs/getting-started/app-router/without-i18n-routing.mdx @@ -129,7 +129,7 @@ The `locale` that was provided in `i18n/request.ts` is available via `getLocale` ```tsx filename="app/layout.tsx" import {NextIntlClientProvider} from 'next-intl'; -import {getLocale, getMessages} from 'next-intl/server'; +import {getLocale} from 'next-intl/server'; export default async function RootLayout({ children @@ -138,24 +138,16 @@ export default async function RootLayout({ }) { const locale = await getLocale(); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( - - {children} - + {children} ); } ``` -Note that `NextIntlClientProvider` automatically inherits configuration from `i18n/request.ts` here, but `messages` need to be passed explicitly. - ### `app/page.tsx` [#page] Use translations in your page components or anywhere else! diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx index d17394040..9236ba871 100644 --- a/docs/src/pages/docs/usage/configuration.mdx +++ b/docs/src/pages/docs/usage/configuration.mdx @@ -64,9 +64,7 @@ export default async function RootLayout(/* ... */) { return ( - - {children} - + {children} ); @@ -76,14 +74,15 @@ export default async function RootLayout(/* ... */) { These props are inherited if you're rendering `NextIntlClientProvider` from a Server Component: 1. `locale` -2. `now` -3. `timeZone` -4. `formats` +2. `messages` +3. `now` +4. `timeZone` +5. `formats` In contrast, these props can be provided as necessary: -1. `messages` (see [Internationalization in Client Components](/docs/environments/server-client-components#using-internationalization-in-client-components)) -2. `onError` and `getMessageFallback` +1. `onError` +2. `getMessageFallback` Additionally, nested instances of `NextIntlClientProvider` will inherit configuration from their respective ancestors. Note however that individual props are treated as atomic, therefore e.g. `messages` need to be merged manually—if necessary. @@ -115,17 +114,16 @@ Once you have defined your client-side provider component, you can use it in a S ```tsx filename="layout.tsx" import {NextIntlClientProvider} from 'next-intl'; -import {getLocale, getMessages} from 'next-intl/server'; +import {getLocale} from 'next-intl/server'; import IntlErrorHandlingProvider from './IntlErrorHandlingProvider'; export default async function RootLayout({children}) { const locale = await getLocale(); - const messages = await getMessages(); return ( - + {children} @@ -380,13 +378,14 @@ const messages = await getMessages(); ```tsx import {NextIntlClientProvider} from 'next-intl'; import {getMessages} from 'next-intl/server'; +import pick from 'lodash/pick'; async function Component({children}) { // Read messages configured via `i18n/request.ts` const messages = await getMessages(); return ( - + {children} ); diff --git a/examples/example-app-router-migration/src/app/[locale]/layout.tsx b/examples/example-app-router-migration/src/app/[locale]/layout.tsx index 5f131d8c9..400b13f71 100644 --- a/examples/example-app-router-migration/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-migration/src/app/[locale]/layout.tsx @@ -1,6 +1,5 @@ import {notFound} from 'next/navigation'; import {NextIntlClientProvider, hasLocale} from 'next-intl'; -import {getMessages} from 'next-intl/server'; import {ReactNode} from 'react'; import {routing} from '@/i18n/routing'; @@ -14,19 +13,13 @@ export default async function LocaleLayout({children, params}: Props) { notFound(); } - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( next-intl - - {children} - + {children} ); diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx index cde039f11..4fe3ae935 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx @@ -1,7 +1,7 @@ import {Metadata} from 'next'; import {notFound} from 'next/navigation'; import {Locale, NextIntlClientProvider, hasLocale} from 'next-intl'; -import {getMessages, setRequestLocale} from 'next-intl/server'; +import {setRequestLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import Document from '@/components/Document'; import {locales} from '@/config'; @@ -33,13 +33,9 @@ export default async function LocaleLayout({ // Enable static rendering setRequestLocale(locale); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( - +
{children}
diff --git a/examples/example-app-router-mixed-routing/src/app/app/layout.tsx b/examples/example-app-router-mixed-routing/src/app/app/layout.tsx index cbd693a89..48ece5398 100644 --- a/examples/example-app-router-mixed-routing/src/app/app/layout.tsx +++ b/examples/example-app-router-mixed-routing/src/app/app/layout.tsx @@ -1,6 +1,6 @@ import {Metadata} from 'next'; import {NextIntlClientProvider} from 'next-intl'; -import {getLocale, getMessages} from 'next-intl/server'; +import {getLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import Document from '@/components/Document'; import AppNavigation from './AppNavigation'; @@ -18,13 +18,9 @@ export const metadata: Metadata = { export default async function LocaleLayout({children}: Props) { const locale = await getLocale(); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( - +
diff --git a/examples/example-app-router-single-locale/src/app/layout.tsx b/examples/example-app-router-single-locale/src/app/layout.tsx index dd542a179..99c0dd7d7 100644 --- a/examples/example-app-router-single-locale/src/app/layout.tsx +++ b/examples/example-app-router-single-locale/src/app/layout.tsx @@ -1,5 +1,5 @@ import {NextIntlClientProvider} from 'next-intl'; -import {getLocale, getMessages} from 'next-intl/server'; +import {getLocale} from 'next-intl/server'; import {ReactNode} from 'react'; type Props = { @@ -9,19 +9,13 @@ type Props = { export default async function LocaleLayout({children}: Props) { const locale = await getLocale(); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( next-intl - - {children} - + {children} ); diff --git a/examples/example-app-router-without-i18n-routing/src/app/layout.tsx b/examples/example-app-router-without-i18n-routing/src/app/layout.tsx index c9efefe2f..587b115f9 100644 --- a/examples/example-app-router-without-i18n-routing/src/app/layout.tsx +++ b/examples/example-app-router-without-i18n-routing/src/app/layout.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx'; import {Inter} from 'next/font/google'; import {NextIntlClientProvider} from 'next-intl'; -import {getLocale, getMessages} from 'next-intl/server'; +import {getLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import './globals.css'; @@ -14,10 +14,6 @@ type Props = { export default async function LocaleLayout({children}: Props) { const locale = await getLocale(); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( @@ -29,9 +25,7 @@ export default async function LocaleLayout({children}: Props) { inter.className )} > - - {children} - + {children} ); diff --git a/examples/example-app-router/src/components/BaseLayout.tsx b/examples/example-app-router/src/components/BaseLayout.tsx index 8907d4e9b..89358fbb8 100644 --- a/examples/example-app-router/src/components/BaseLayout.tsx +++ b/examples/example-app-router/src/components/BaseLayout.tsx @@ -1,7 +1,6 @@ import {clsx} from 'clsx'; import {Inter} from 'next/font/google'; import {NextIntlClientProvider} from 'next-intl'; -import {getMessages} from 'next-intl/server'; import {ReactNode} from 'react'; import Navigation from '@/components/Navigation'; @@ -13,14 +12,10 @@ type Props = { }; export default async function BaseLayout({children, locale}: Props) { - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( - + {children} diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index e00f8f64a..2eaf0b8a8 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -10,7 +10,7 @@ const config: SizeLimitConfig = [ name: "import {NextIntlClientProvider} from 'next-intl' (react-client)", import: '{NextIntlClientProvider}', path: 'dist/esm/production/index.react-client.js', - limit: '1 KB' + limit: '1.005 KB' }, { name: "import * from 'next-intl' (react-server)", diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx index 9680df759..2e5cb37e1 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx @@ -1,12 +1,13 @@ import {expect, it, vi} from 'vitest'; import getConfigNow from '../server/react-server/getConfigNow.js'; import getFormats from '../server/react-server/getFormats.js'; -import {getLocale, getTimeZone} from '../server.react-server.js'; +import {getLocale, getMessages, getTimeZone} from '../server.react-server.js'; import NextIntlClientProvider from '../shared/NextIntlClientProvider.js'; import NextIntlClientProviderServer from './NextIntlClientProviderServer.js'; vi.mock('../../src/server/react-server', async () => ({ getLocale: vi.fn(async () => 'en-US'), + getMessages: vi.fn(async () => ({})), getTimeZone: vi.fn(async () => 'America/New_York') })); @@ -34,7 +35,8 @@ it("doesn't read from headers if all relevant configuration is passed", async () locale: 'en-GB', now: new Date('2020-02-01T00:00:00.000Z'), timeZone: 'Europe/London', - formats: {} + formats: {}, + messages: {} }); expect(result.type).toBe(NextIntlClientProvider); @@ -43,13 +45,15 @@ it("doesn't read from headers if all relevant configuration is passed", async () locale: 'en-GB', now: new Date('2020-02-01T00:00:00.000Z'), timeZone: 'Europe/London', - formats: {} + formats: {}, + messages: {} }); expect(getLocale).not.toHaveBeenCalled(); expect(getConfigNow).not.toHaveBeenCalled(); expect(getTimeZone).not.toHaveBeenCalled(); expect(getFormats).not.toHaveBeenCalled(); + expect(getMessages).not.toHaveBeenCalled(); }); it('reads missing configuration from getter functions', async () => { @@ -63,6 +67,7 @@ it('reads missing configuration from getter functions', async () => { locale: 'en-US', now: new Date('2020-01-01T00:00:00.000Z'), timeZone: 'America/New_York', + messages: {}, formats: { dateTime: { short: { @@ -76,4 +81,5 @@ it('reads missing configuration from getter functions', async () => { expect(getConfigNow).toHaveBeenCalled(); expect(getTimeZone).toHaveBeenCalled(); expect(getFormats).toHaveBeenCalled(); + expect(getMessages).toHaveBeenCalled(); }); diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx index fc03de3d9..f468f1149 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx @@ -1,7 +1,7 @@ import type {ComponentProps} from 'react'; import getConfigNow from '../server/react-server/getConfigNow.js'; import getFormats from '../server/react-server/getFormats.js'; -import {getLocale, getTimeZone} from '../server.react-server.js'; +import {getLocale, getMessages, getTimeZone} from '../server.react-server.js'; import BaseNextIntlClientProvider from '../shared/NextIntlClientProvider.js'; type Props = ComponentProps; @@ -9,6 +9,7 @@ type Props = ComponentProps; export default async function NextIntlClientProviderServer({ formats, locale, + messages, now, timeZone, ...rest @@ -19,6 +20,7 @@ export default async function NextIntlClientProviderServer({ // See https://github.com/amannn/next-intl/issues/631 formats={formats === undefined ? await getFormats() : formats} locale={locale ?? (await getLocale())} + messages={messages === undefined ? await getMessages() : messages} // Note that we don't assign a default for `now` here, // we only read one from the request config - if any. // Otherwise this would cause a `dynamicIO` error. diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index 8372249a9..8f58b7217 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -68,14 +68,16 @@ const receiveRuntimeConfig = cache(receiveRuntimeConfigImpl); const getFormatters = cache(_createIntlFormatters); const getCache = cache(_createCache); -async function getConfigImpl(localeOverride?: Locale): Promise< - IntlConfig & { - getMessageFallback: NonNullable; - onError: NonNullable; - timeZone: NonNullable; - _formatters: ReturnType; - } -> { +async function getConfigImpl(localeOverride?: Locale): Promise<{ + locale: IntlConfig['locale']; + formats?: NonNullable; + timeZone: NonNullable; + onError: NonNullable; + getMessageFallback: NonNullable; + messages?: NonNullable; + now?: NonNullable; + _formatters: ReturnType; +}> { const runtimeConfig = await receiveRuntimeConfig( createRequestConfig, localeOverride diff --git a/packages/next-intl/src/server/react-server/getServerFormatter.tsx b/packages/next-intl/src/server/react-server/getServerFormatter.tsx index 33654204e..d1b6d282f 100644 --- a/packages/next-intl/src/server/react-server/getServerFormatter.tsx +++ b/packages/next-intl/src/server/react-server/getServerFormatter.tsx @@ -3,10 +3,6 @@ import {createFormatter} from 'use-intl/core'; import getDefaultNow from './getDefaultNow.js'; function getFormatterCachedImpl(config: Parameters[0]) { - // same here? - // also add a test - // also for getTranslations/useTranslations - // add a test with a getter maybe, don't mock return createFormatter({ ...config, // Only init when necessary to avoid triggering a `dynamicIO` error diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts index c6dae5c70..e1bf25098 100644 --- a/packages/use-intl/.size-limit.ts +++ b/packages/use-intl/.size-limit.ts @@ -5,14 +5,14 @@ const config: SizeLimitConfig = [ name: "import * from 'use-intl' (production)", import: '*', path: 'dist/esm/production/index.js', - limit: '12.945 kB' + limit: '12.955 kB' }, { name: "import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from 'use-intl' (production)", path: 'dist/esm/production/index.js', import: '{IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter}', - limit: '1.98 kB' + limit: '1.995 kB' } ]; diff --git a/packages/use-intl/src/core/IntlConfig.tsx b/packages/use-intl/src/core/IntlConfig.tsx index 20ab1876a..68da27a1e 100644 --- a/packages/use-intl/src/core/IntlConfig.tsx +++ b/packages/use-intl/src/core/IntlConfig.tsx @@ -13,7 +13,7 @@ type IntlConfig = { locale: Locale; /** Global formats can be provided to achieve consistent * formatting across components. */ - formats?: Formats; + formats?: Formats | null; /** A time zone as defined in [the tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) which will be applied when formatting dates and times. If this is absent, the user time zone will be used. You can override this by supplying an explicit time zone to `formatDateTime`. */ timeZone?: TimeZone; /** This callback will be invoked when an error is encountered during @@ -40,7 +40,7 @@ type IntlConfig = { */ now?: Date; /** All messages that will be available. */ - messages?: DeepPartial; + messages?: DeepPartial | null; }; /** @@ -48,7 +48,12 @@ type IntlConfig = { * A stricter set of the configuration that should be used internally * once defaults are assigned to `IntlConfiguration`. */ -export type InitializedIntlConfig = IntlConfig & { +export type InitializedIntlConfig = Omit< + IntlConfig, + 'formats' | 'messages' | 'onError' | 'getMessageFallback' +> & { + formats?: NonNullable; + messages?: NonNullable; onError: NonNullable; getMessageFallback: NonNullable; }; diff --git a/packages/use-intl/src/core/initializeConfig.tsx b/packages/use-intl/src/core/initializeConfig.tsx index 1bce4c21c..8bace8296 100644 --- a/packages/use-intl/src/core/initializeConfig.tsx +++ b/packages/use-intl/src/core/initializeConfig.tsx @@ -9,7 +9,7 @@ export default function initializeConfig< // This is a generic to allow for stricter typing. E.g. // the RSC integration always provides a `now` value. Props extends IntlConfig ->({getMessageFallback, messages, onError, ...rest}: Props) { +>({formats, getMessageFallback, messages, onError, ...rest}: Props) { const finalOnError = onError || defaultOnError; const finalGetMessageFallback = getMessageFallback || defaultGetMessageFallback; @@ -22,7 +22,12 @@ export default function initializeConfig< return { ...rest, - messages, + formats: (formats || undefined) as + | NonNullable + | undefined, + messages: (messages || undefined) as + | NonNullable + | undefined, onError: finalOnError, getMessageFallback: finalGetMessageFallback }; diff --git a/packages/use-intl/src/react/IntlProvider.test.tsx b/packages/use-intl/src/react/IntlProvider.test.tsx index af2564910..fbe8604a1 100644 --- a/packages/use-intl/src/react/IntlProvider.test.tsx +++ b/packages/use-intl/src/react/IntlProvider.test.tsx @@ -149,3 +149,25 @@ it('does not merge messages in nested providers', () => { expect(onError.mock.calls.length).toBe(1); }); + +it('can opt-out of messages inheritance', () => { + const onError = vi.fn(); + + function Component() { + const t = useTranslations(); + return {t('hello')}; + } + + render( + + + + + + + ); + + screen.getByText('Hey!'); + screen.getByText('hello'); + expect(onError.mock.calls.length).toBe(1); +}); diff --git a/packages/use-intl/src/react/IntlProvider.tsx b/packages/use-intl/src/react/IntlProvider.tsx index 81746ac34..6a67d24c1 100644 --- a/packages/use-intl/src/react/IntlProvider.tsx +++ b/packages/use-intl/src/react/IntlProvider.tsx @@ -49,10 +49,10 @@ export default function IntlProvider({ () => ({ ...initializeConfig({ locale, // (required by provider) - formats: formats || prevContext?.formats, + formats: formats === undefined ? prevContext?.formats : formats, getMessageFallback: getMessageFallback || prevContext?.getMessageFallback, - messages: messages || prevContext?.messages, + messages: messages === undefined ? prevContext?.messages : messages, now: now || prevContext?.now, onError: onError || prevContext?.onError, timeZone: timeZone || prevContext?.timeZone From 40d535a7063d5eae499508d3522d535fe919ad7d Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 28 Jan 2025 16:08:21 +0100 Subject: [PATCH 74/90] fix: Start watcher during a hot reload of `next.config.ts` (#1690) --- .../src/plugin/createMessagesDeclaration.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/next-intl/src/plugin/createMessagesDeclaration.tsx b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx index ee40aafd7..c273a2b13 100644 --- a/packages/next-intl/src/plugin/createMessagesDeclaration.tsx +++ b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx @@ -24,19 +24,16 @@ export default function createMessagesDeclaration(messagesPath: string) { ); } - const isDev = process.argv.includes('dev'); - const isBuild = process.argv.includes('build'); - - if (!isDev && !isBuild) { - return; - } + // Keep this as a runtime check and don't replace + // this with a constant during the build process + const env = process.env['NODE_ENV'.trim()]; // Next.js can call the Next.js config multiple // times - ensure we only run once. runOnce(() => { compileDeclaration(messagesPath); - if (isDev) { + if (env === 'development') { startWatching(messagesPath); } }); From 77949efb254abff4d0575a5f0733b34e1c02a148 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 3 Feb 2025 10:45:01 +0100 Subject: [PATCH 75/90] feat: Allow to override options of global formats (#1693) **Example:** ```tsx formatter.dateTime(date, 'short', { timeZone: 'America/New_York' }) ``` --- docs/src/pages/docs/usage/dates-times.mdx | 10 ++- docs/src/pages/docs/usage/numbers.mdx | 4 + packages/next-intl/.size-limit.ts | 2 +- packages/use-intl/.size-limit.ts | 4 +- .../src/core/createFormatter.test.tsx | 82 +++++++++++++++++++ .../use-intl/src/core/createFormatter.tsx | 71 +++++++++++++--- .../use-intl/src/react/useFormatter.test.tsx | 16 ++-- 7 files changed, 168 insertions(+), 21 deletions(-) diff --git a/docs/src/pages/docs/usage/dates-times.mdx b/docs/src/pages/docs/usage/dates-times.mdx index 8739d33b7..62f8c5fe8 100644 --- a/docs/src/pages/docs/usage/dates-times.mdx +++ b/docs/src/pages/docs/usage/dates-times.mdx @@ -34,7 +34,11 @@ See [the MDN docs about `DateTimeFormat`](https://developer.mozilla.org/en-US/do If you have [global formats](/docs/usage/configuration#formats) configured, you can reference them by passing a name as the second argument: ```js +// Use a global format format.dateTime(dateTime, 'short'); + +// Optionally override some options +format.dateTime(dateTime, 'short', {year: 'numeric'}); ```
@@ -204,10 +208,14 @@ function Component() { } ``` -If you have [global formats](/docs/usage/configuration#formats) configured, you can reference them by passing a name as the trailing argument: +If you have [global formats](/docs/usage/configuration#formats) configured, you can reference them by passing a name as the third argument: ```js +// Use a global format format.dateTimeRange(dateTimeA, dateTimeB, 'short'); + +// Optionally override some options +format.dateTimeRange(dateTimeA, dateTimeB, 'short', {year: 'numeric'}); ``` ## Dates and times within messages diff --git a/docs/src/pages/docs/usage/numbers.mdx b/docs/src/pages/docs/usage/numbers.mdx index fb8dbb287..26c2e4370 100644 --- a/docs/src/pages/docs/usage/numbers.mdx +++ b/docs/src/pages/docs/usage/numbers.mdx @@ -31,7 +31,11 @@ See [the MDN docs about `NumberFormat`](https://developer.mozilla.org/en-US/docs If you have [global formats](/docs/usage/configuration#formats) configured, you can reference them by passing a name as the second argument: ```js +// Use a global format format.number(499.9, 'precise'); + +// Optionally override some options +format.number(499.9, 'price', {currency: 'USD'}); ``` ## Numbers within messages diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 2eaf0b8a8..3211fae99 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -4,7 +4,7 @@ const config: SizeLimitConfig = [ { name: "import * from 'next-intl' (react-client)", path: 'dist/esm/production/index.react-client.js', - limit: '13.065 KB' + limit: '13.125 KB' }, { name: "import {NextIntlClientProvider} from 'next-intl' (react-client)", diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts index e1bf25098..a0b6ed26f 100644 --- a/packages/use-intl/.size-limit.ts +++ b/packages/use-intl/.size-limit.ts @@ -5,14 +5,14 @@ const config: SizeLimitConfig = [ name: "import * from 'use-intl' (production)", import: '*', path: 'dist/esm/production/index.js', - limit: '12.955 kB' + limit: '13.015 kB' }, { name: "import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from 'use-intl' (production)", path: 'dist/esm/production/index.js', import: '{IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter}', - limit: '1.995 kB' + limit: '2.005 kB' } ]; diff --git a/packages/use-intl/src/core/createFormatter.test.tsx b/packages/use-intl/src/core/createFormatter.test.tsx index a45eebef5..647d247dc 100644 --- a/packages/use-intl/src/core/createFormatter.test.tsx +++ b/packages/use-intl/src/core/createFormatter.test.tsx @@ -28,6 +28,26 @@ describe('dateTime', () => { }) ).toBe('Nov 20, 2020, 5:36:01 AM'); }); + + it('can combine a global format with an override', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin', + formats: { + dateTime: { + short: { + dateStyle: 'short', + timeStyle: 'short' + } + } + } + }); + expect( + formatter.dateTime(parseISO('2020-11-20T10:36:01.516Z'), 'short', { + timeZone: 'America/New_York' + }) + ).toBe('11/20/20, 5:36 AM'); + }); }); describe('number', () => { @@ -71,6 +91,25 @@ describe('number', () => { }) ).toBe('$123,456,789,123,456,789.00'); }); + + it('can combine a global format with an override', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin', + formats: { + number: { + price: { + style: 'currency', + minimumFractionDigits: 2, + maximumFractionDigits: 2 + } + } + } + }); + expect(formatter.number(123456.789, 'price', {currency: 'EUR'})).toBe( + '€123,456.79' + ); + }); }); describe('relativeTime', () => { @@ -349,6 +388,31 @@ describe('dateTimeRange', () => { ) ).toBe('Jan 10, 2007, 4:00:00 AM – Jan 10, 2008, 5:00:00 AM'); }); + + it('can combine a global format with an override', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin', + formats: { + dateTime: { + short: { + dateStyle: 'short', + timeStyle: 'short' + } + } + } + }); + expect( + formatter.dateTimeRange( + new Date(2007, 0, 10, 10, 0, 0), + new Date(2008, 0, 10, 11, 0, 0), + 'short', + { + timeZone: 'America/New_York' + } + ) + ).toBe('1/10/07, 4:00 AM – 1/10/08, 5:00 AM'); + }); }); describe('list', () => { @@ -373,4 +437,22 @@ describe('list', () => { }) ).toBe('apple, banana, or orange'); }); + + it('can combine a global format with an override', () => { + const formatter = createFormatter({ + locale: 'en', + formats: { + list: { + short: { + type: 'disjunction' + } + } + } + }); + expect( + formatter.list(['apple', 'banana', 'orange'], 'short', { + type: 'conjunction' + }) + ).toBe('apple, banana, and orange'); + }); }); diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index c3d31a3d9..bcbdb2268 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -114,7 +114,8 @@ export default function createFormatter(props: Props) { function resolveFormatOrOptions( typeFormats: Record | undefined, - formatOrOptions?: string | Options + formatOrOptions?: string | Options, + overrides?: Options ) { let options; if (typeof formatOrOptions === 'string') { @@ -135,18 +136,23 @@ export default function createFormatter(props: Props) { options = formatOrOptions; } + if (overrides) { + options = {...options, ...overrides}; + } + return options; } function getFormattedValue( formatOrOptions: string | Options | undefined, + overrides: Options | undefined, typeFormats: Record | undefined, formatter: (options?: Options) => Output, getFallback: () => Output ) { let options; try { - options = resolveFormatOrOptions(typeFormats, formatOrOptions); + options = resolveFormatOrOptions(typeFormats, formatOrOptions, overrides); } catch { return getFallback(); } @@ -164,12 +170,22 @@ export default function createFormatter(props: Props) { function dateTime( /** If a number is supplied, this is interpreted as a UTC timestamp. */ value: Date | number, - /** If a time zone is supplied, the `value` is converted to that time zone. - * Otherwise the user time zone will be used. */ - formatOrOptions?: FormatNames['dateTime'] | DateTimeFormatOptions + options?: DateTimeFormatOptions + ): string; + function dateTime( + /** If a number is supplied, this is interpreted as a UTC timestamp. */ + value: Date | number, + format?: FormatNames['dateTime'], + options?: DateTimeFormatOptions + ): string; + function dateTime( + value: Date | number, + formatOrOptions?: FormatNames['dateTime'] | DateTimeFormatOptions, + overrides?: DateTimeFormatOptions ) { return getFormattedValue( formatOrOptions, + overrides, formats?.dateTime, (options) => { options = applyTimeZone(options); @@ -184,12 +200,25 @@ export default function createFormatter(props: Props) { start: Date | number, /** If a number is supplied, this is interpreted as a UTC timestamp. */ end: Date | number, - /** If a time zone is supplied, the values are converted to that time zone. - * Otherwise the user time zone will be used. */ - formatOrOptions?: FormatNames['dateTime'] | DateTimeFormatOptions + options?: DateTimeFormatOptions + ): string; + function dateTimeRange( + /** If a number is supplied, this is interpreted as a UTC timestamp. */ + start: Date | number, + /** If a number is supplied, this is interpreted as a UTC timestamp. */ + end: Date | number, + format?: FormatNames['dateTime'], + options?: DateTimeFormatOptions + ): string; + function dateTimeRange( + start: Date | number, + end: Date | number, + formatOrOptions?: FormatNames['dateTime'] | DateTimeFormatOptions, + overrides?: DateTimeFormatOptions ) { return getFormattedValue( formatOrOptions, + overrides, formats?.dateTime, (options) => { options = applyTimeZone(options); @@ -203,10 +232,21 @@ export default function createFormatter(props: Props) { function number( value: number | bigint, - formatOrOptions?: FormatNames['number'] | NumberFormatOptions + options?: NumberFormatOptions + ): string; + function number( + value: number | bigint, + format?: FormatNames['number'], + options?: NumberFormatOptions + ): string; + function number( + value: number | bigint, + formatOrOptions?: FormatNames['number'] | NumberFormatOptions, + overrides?: NumberFormatOptions ) { return getFormattedValue( formatOrOptions, + overrides, formats?.number, (options) => formatters.getNumberFormat(locale, options).format(value), () => String(value) @@ -289,7 +329,17 @@ export default function createFormatter(props: Props) { type FormattableListValue = string | ReactElement; function list( value: Iterable, - formatOrOptions?: FormatNames['list'] | Intl.ListFormatOptions + options?: Intl.ListFormatOptions + ): Value extends string ? string : Iterable; + function list( + value: Iterable, + format?: FormatNames['list'], + options?: Intl.ListFormatOptions + ): Value extends string ? string : Iterable; + function list( + value: Iterable, + formatOrOptions?: FormatNames['list'] | Intl.ListFormatOptions, + overrides?: Intl.ListFormatOptions ): Value extends string ? string : Iterable { const serializedValue: Array = []; const richValues = new Map(); @@ -315,6 +365,7 @@ export default function createFormatter(props: Props) { Value extends string ? string : Iterable >( formatOrOptions, + overrides, formats?.list, // @ts-expect-error -- `richValues.size` is used to determine the return type, but TypeScript can't infer the meaning of this correctly (options) => { diff --git a/packages/use-intl/src/react/useFormatter.test.tsx b/packages/use-intl/src/react/useFormatter.test.tsx index fc9e42184..d26c01dac 100644 --- a/packages/use-intl/src/react/useFormatter.test.tsx +++ b/packages/use-intl/src/react/useFormatter.test.tsx @@ -3,7 +3,12 @@ import {parseISO} from 'date-fns'; import type {ComponentProps, ReactElement, ReactNode} from 'react'; import {type SpyImpl, spyOn} from 'tinyspy'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {type IntlError, IntlErrorCode} from '../core.js'; +import { + type DateTimeFormatOptions, + type IntlError, + IntlErrorCode, + type NumberFormatOptions +} from '../core.js'; import IntlProvider from './IntlProvider.js'; import useFormatter from './useFormatter.js'; @@ -25,7 +30,7 @@ describe('dateTime', () => { function renderDateTime( value: Date | number, - options?: Parameters['dateTime']>['1'] + options?: DateTimeFormatOptions ) { function Component() { const format = useFormatter(); @@ -287,10 +292,7 @@ describe('dateTime', () => { }); describe('number', () => { - function renderNumber( - value: number | bigint, - options?: Parameters['number']>['1'] - ) { + function renderNumber(value: number | bigint, options?: NumberFormatOptions) { function Component() { const format = useFormatter(); return <>{format.number(value, options)}; @@ -629,7 +631,7 @@ describe('relativeTime', () => { describe('list', () => { function renderList( value: Iterable, - options?: Parameters['list']>['1'] + options?: Intl.ListFormatOptions ) { function Component() { const format = useFormatter(); From bae1131bd0c9fbf5e197f24e49930666f6455289 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 3 Feb 2025 16:18:50 +0100 Subject: [PATCH 76/90] feat: Use overloads for `relativeTime` --- packages/use-intl/src/core/createFormatter.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index bcbdb2268..a14ecaa55 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -275,6 +275,15 @@ export default function createFormatter(props: Props) { /** The date time that needs to be formatted. */ date: number | Date, /** The reference point in time to which `date` will be formatted in relation to. If this value is absent, a globally configured `now` value or alternatively the current time will be used. */ + now?: RelativeTimeFormatOptions['now'] + ): string; + function relativeTime( + /** The date time that needs to be formatted. */ + date: number | Date, + options?: RelativeTimeFormatOptions + ): string; + function relativeTime( + date: number | Date, nowOrOptions?: RelativeTimeFormatOptions['now'] | RelativeTimeFormatOptions ) { try { From 9e73cbea044329caa550f6d21c227f5a7bcedebf Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 4 Feb 2025 10:09:21 +0100 Subject: [PATCH 77/90] feat: Validate locale returned from `i18n/request.ts` (#1695) --- packages/next-intl/src/routing/defineRouting.tsx | 4 ---- .../next-intl/src/routing/validateLocales.tsx | 16 ---------------- .../src/server/react-server/getConfig.tsx | 4 ++++ .../react-server/validateLocale.test.tsx} | 6 +++--- .../src/server/react-server/validateLocale.tsx | 12 ++++++++++++ 5 files changed, 19 insertions(+), 23 deletions(-) delete mode 100644 packages/next-intl/src/routing/validateLocales.tsx rename packages/next-intl/src/{routing/validateLocales.test.tsx => server/react-server/validateLocale.test.tsx} (92%) create mode 100644 packages/next-intl/src/server/react-server/validateLocale.tsx diff --git a/packages/next-intl/src/routing/defineRouting.tsx b/packages/next-intl/src/routing/defineRouting.tsx index ca087ddb3..4f0f8cf4a 100644 --- a/packages/next-intl/src/routing/defineRouting.tsx +++ b/packages/next-intl/src/routing/defineRouting.tsx @@ -5,7 +5,6 @@ import type { Locales, Pathnames } from './types.js'; -import validateLocales from './validateLocales.js'; export default function defineRouting< const AppLocales extends Locales, @@ -20,8 +19,5 @@ export default function defineRouting< AppDomains > ) { - if (process.env.NODE_ENV !== 'production') { - validateLocales(config.locales); - } return config; } diff --git a/packages/next-intl/src/routing/validateLocales.tsx b/packages/next-intl/src/routing/validateLocales.tsx deleted file mode 100644 index fea457f43..000000000 --- a/packages/next-intl/src/routing/validateLocales.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type {Locales} from './types.js'; - -export default function validateLocales(locales: Locales) { - for (const locale of locales) { - try { - const constructed = new Intl.Locale(locale); - if (!constructed.language) { - throw new Error('Language is required'); - } - } catch { - console.error( - `Found invalid locale within provided \`locales\`: "${locale}"\nPlease ensure you're using a valid Unicode locale identifier (e.g. "en-US").` - ); - } - } -} diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index 8f58b7217..3747d8c4d 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -9,6 +9,7 @@ import { import {getRequestLocale} from './RequestLocale.js'; import createRequestConfig from './createRequestConfig.js'; import type {GetRequestConfigParams} from './getRequestConfig.js'; +import validateLocale from './validateLocale.js'; // This is automatically inherited by `NextIntlClientProvider` if // the component is rendered from a Server Component @@ -60,6 +61,9 @@ See also: https://next-intl.dev/docs/usage/configuration#i18n-request 'No locale was returned from `getRequestConfig`.\n\nSee https://next-intl.dev/docs/usage/configuration#i18n-request' ); } + if (process.env.NODE_ENV !== 'production') { + validateLocale(result.locale); + } return result; } diff --git a/packages/next-intl/src/routing/validateLocales.test.tsx b/packages/next-intl/src/server/react-server/validateLocale.test.tsx similarity index 92% rename from packages/next-intl/src/routing/validateLocales.test.tsx rename to packages/next-intl/src/server/react-server/validateLocale.test.tsx index 3f04b3407..4a4ba3b0b 100644 --- a/packages/next-intl/src/routing/validateLocales.test.tsx +++ b/packages/next-intl/src/server/react-server/validateLocale.test.tsx @@ -1,5 +1,5 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import validateLocales from './validateLocales.js'; +import validateLocale from './validateLocale.js'; describe('accepts valid formats', () => { let consoleErrorSpy: ReturnType; @@ -34,7 +34,7 @@ describe('accepts valid formats', () => { // Somehow tolerated by Intl.Locale 'english' ])('accepts: %s', (locale) => { - validateLocales([locale]); + validateLocale(locale); expect(consoleErrorSpy).not.toHaveBeenCalled(); }); }); @@ -65,7 +65,7 @@ describe('warns for invalid formats', () => { 'en US', 'en.US' ])('rejects: %s', (locale) => { - validateLocales([locale]); + validateLocale(locale); expect(consoleErrorSpy).toHaveBeenCalled(); }); }); diff --git a/packages/next-intl/src/server/react-server/validateLocale.tsx b/packages/next-intl/src/server/react-server/validateLocale.tsx new file mode 100644 index 000000000..25bb4f7b9 --- /dev/null +++ b/packages/next-intl/src/server/react-server/validateLocale.tsx @@ -0,0 +1,12 @@ +export default function validateLocale(locale: string) { + try { + const constructed = new Intl.Locale(locale); + if (!constructed.language) { + throw new Error('Language is required'); + } + } catch { + console.error( + `An invalid locale was provided: "${locale}"\nPlease ensure you're using a valid Unicode locale identifier (e.g. "en-US").` + ); + } +} From c4c59862292de45407fb8711216c655f576aee1c Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 4 Feb 2025 10:22:22 +0100 Subject: [PATCH 78/90] fix: Handle watch events on macOS caused by git (#1696) --- .../src/plugin/createMessagesDeclaration.tsx | 7 +++---- packages/next-intl/src/plugin/watchFile.tsx | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 packages/next-intl/src/plugin/watchFile.tsx diff --git a/packages/next-intl/src/plugin/createMessagesDeclaration.tsx b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx index c273a2b13..692097da9 100644 --- a/packages/next-intl/src/plugin/createMessagesDeclaration.tsx +++ b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import {throwError} from './utils.js'; +import watchFile from './watchFile.js'; function runOnce(fn: () => void) { if (process.env._NEXT_INTL_COMPILE_MESSAGES === '1') { @@ -40,10 +41,8 @@ export default function createMessagesDeclaration(messagesPath: string) { } function startWatching(messagesPath: string) { - const watcher = fs.watch(messagesPath, (eventType) => { - if (eventType === 'change') { - compileDeclaration(messagesPath, true); - } + const watcher = watchFile(messagesPath, () => { + compileDeclaration(messagesPath, true); }); process.on('exit', () => { diff --git a/packages/next-intl/src/plugin/watchFile.tsx b/packages/next-intl/src/plugin/watchFile.tsx new file mode 100644 index 000000000..744430f89 --- /dev/null +++ b/packages/next-intl/src/plugin/watchFile.tsx @@ -0,0 +1,21 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * Wrapper around `fs.watch` that provides a workaround + * for https://github.com/nodejs/node/issues/5039. + */ +export default function watchFile(filepath: string, callback: () => void) { + const directory = path.dirname(filepath); + const filename = path.basename(filepath); + + return fs.watch( + directory, + {persistent: false, recursive: false}, + (event, changedFilename) => { + if (changedFilename === filename) { + callback(); + } + } + ); +} From ca3fcd58dcea038da4441c881883f59168a1c1d8 Mon Sep 17 00:00:00 2001 From: felix-quotez Date: Wed, 5 Feb 2025 17:54:15 +0100 Subject: [PATCH 79/90] feat: change createMessagesDeclaration to also accept array for monorepo support (#1700) Resolves: https://github.com/amannn/next-intl/issues/1699 This change renames "createMessagesDeclaration" to "createMessagesDeclarations" and changes the type to an array. Each entry of the array will be used to create message declaration files. Supporting multiple message files helps to use next-intl in monorepo setups. Since v4 is in beta, I renamed the property and removed support for a single string. To me, clean types lead to cleaner code. If compatibility is a more of a concern during the beta phase, I could not change the property name and keep the support for single strings. Please let me know. --------- Co-authored-by: Jan Amann --- .../src/plugin/createMessagesDeclaration.tsx | 46 ++++++++++--------- .../src/plugin/createNextIntlPlugin.tsx | 8 +++- packages/next-intl/src/plugin/types.tsx | 3 +- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/packages/next-intl/src/plugin/createMessagesDeclaration.tsx b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx index 692097da9..386db0b35 100644 --- a/packages/next-intl/src/plugin/createMessagesDeclaration.tsx +++ b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx @@ -11,31 +11,35 @@ function runOnce(fn: () => void) { fn(); } -export default function createMessagesDeclaration(messagesPath: string) { - const fullPath = path.resolve(messagesPath); - - if (!fs.existsSync(fullPath)) { - throwError( - `\`createMessagesDeclaration\` points to a non-existent file: ${fullPath}` - ); - } - if (!fullPath.endsWith('.json')) { - throwError( - `\`createMessagesDeclaration\` needs to point to a JSON file. Received: ${fullPath}` - ); - } - - // Keep this as a runtime check and don't replace - // this with a constant during the build process - const env = process.env['NODE_ENV'.trim()]; - +export default function createMessagesDeclaration( + messagesPaths: Array +) { // Next.js can call the Next.js config multiple // times - ensure we only run once. runOnce(() => { - compileDeclaration(messagesPath); + for (const messagesPath of messagesPaths) { + const fullPath = path.resolve(messagesPath); + + if (!fs.existsSync(fullPath)) { + throwError( + `\`createMessagesDeclaration\` points to a non-existent file: ${fullPath}` + ); + } + if (!fullPath.endsWith('.json')) { + throwError( + `\`createMessagesDeclaration\` needs to point to a JSON file. Received: ${fullPath}` + ); + } + + // Keep this as a runtime check and don't replace + // this with a constant during the build process + const env = process.env['NODE_ENV'.trim()]; + + compileDeclaration(messagesPath); - if (env === 'development') { - startWatching(messagesPath); + if (env === 'development') { + startWatching(messagesPath); + } } }); } diff --git a/packages/next-intl/src/plugin/createNextIntlPlugin.tsx b/packages/next-intl/src/plugin/createNextIntlPlugin.tsx index bc60f83fb..62a6af878 100644 --- a/packages/next-intl/src/plugin/createNextIntlPlugin.tsx +++ b/packages/next-intl/src/plugin/createNextIntlPlugin.tsx @@ -14,9 +14,13 @@ function initPlugin( ); } - if (pluginConfig.experimental?.createMessagesDeclaration) { + const messagesPathOrPaths = + pluginConfig.experimental?.createMessagesDeclaration; + if (messagesPathOrPaths) { createMessagesDeclaration( - pluginConfig.experimental.createMessagesDeclaration + typeof messagesPathOrPaths === 'string' + ? [messagesPathOrPaths] + : messagesPathOrPaths ); } diff --git a/packages/next-intl/src/plugin/types.tsx b/packages/next-intl/src/plugin/types.tsx index 915461238..24e5cd7c8 100644 --- a/packages/next-intl/src/plugin/types.tsx +++ b/packages/next-intl/src/plugin/types.tsx @@ -1,6 +1,7 @@ export type PluginConfig = { requestConfig?: string; experimental?: { - createMessagesDeclaration?: string; + /** A path to the messages file that you'd like to create a declaration for. In case you want to consider multiple files, you can pass an array of paths. */ + createMessagesDeclaration?: string | Array; }; }; From d17baf9a7c81e3a7dd14226b0a08610477cb225a Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 17 Feb 2025 14:13:15 +0100 Subject: [PATCH 80/90] Fix lint in test --- packages/use-intl/src/react/useFormatter.test.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/use-intl/src/react/useFormatter.test.tsx b/packages/use-intl/src/react/useFormatter.test.tsx index d26c01dac..6d69dea3a 100644 --- a/packages/use-intl/src/react/useFormatter.test.tsx +++ b/packages/use-intl/src/react/useFormatter.test.tsx @@ -7,7 +7,8 @@ import { type DateTimeFormatOptions, type IntlError, IntlErrorCode, - type NumberFormatOptions + type NumberFormatOptions, + type RelativeTimeFormatOptions } from '../core.js'; import IntlProvider from './IntlProvider.js'; import useFormatter from './useFormatter.js'; @@ -432,13 +433,15 @@ describe('number', () => { describe('relativeTime', () => { function renderRelativeTime( date: Date | number, - nowOrOptions: Parameters< - ReturnType['relativeTime'] - >['1'] + nowOrOptions: Date | number | RelativeTimeFormatOptions ) { function Component() { const format = useFormatter(); - return <>{format.relativeTime(date, nowOrOptions)}; + if (nowOrOptions instanceof Date || typeof nowOrOptions === 'number') { + return format.relativeTime(date, nowOrOptions); + } else { + return format.relativeTime(date, nowOrOptions); + } } render( From 15c826b785415469e2173c223a047b4554d9c699 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 17 Feb 2025 17:45:01 +0100 Subject: [PATCH 81/90] fix!: Return `x-default` alternate link also for sub pages when using `localePrefix: 'always'` and update middleware matcher suggestion in docs (#1720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, we suggested a middleware matcher that looked like this: ```tsx // middleware.ts export const config = { // Match only internationalized pathnames matcher: ['/', '/(de|en)/:path*'] }; ``` Even though the hardcoded locales need to be updated when new locales are added, this was suggested in light of providing an error-free getting started experience. However, based on the apps I've seen over time, it seems like this choice was unpopular and users typically go for a matcher that looks like this: ```tsx export const config = { // Match all pathnames except for // - … if they start with `/api`, `/_next` or `/_vercel` // - … the ones containing a dot (e.g. `favicon.ico`) matcher: '/((?!api|_next|_vercel|.*\\..*).*)' }; ``` While this avoids hardcoding locales, it requires extra care to [match pathnames that contain a dot](https://next-intl.dev/docs/routing/middleware#matcher-config) (e.g. `/users/jane.doe`). To align better with user expectations, we now suggest the negative lookahead in the getting started docs and point out the case with pathnames containing dots. As an extra benefit, it makes it significantly easier to switch between routing strategies and add custom prefixes. With the new matcher in place, the middleware now also returns an `x-default` [alternate link](https://next-intl.dev/docs/routing#alternate-links-details) for non-root pathnames (previously only one for `/` was returned when using `localePrefix: 'always'`). Due to this, please update your middleware matcher as shown in the [getting started docs](https://next-intl.dev/docs/getting-started/app-router/with-i18n-routing#middleware) if you're using alternate links. **Related discussions:** - https://github.com/amannn/next-intl/issues/1136 - https://github.com/amannn/next-intl/pull/505 - https://github.com/amannn/next-intl/issues/504 --- .../app-router/with-i18n-routing.mdx | 37 ++++++++++-- docs/src/pages/docs/routing.mdx | 21 ++----- docs/src/pages/docs/routing/middleware.mdx | 59 ++----------------- .../src/middleware.ts | 6 +- .../src/middleware.ts | 6 +- .../tests/main.spec.ts | 2 +- .../src/middleware.ts | 6 +- examples/example-app-router/src/middleware.ts | 16 ++--- .../getAlternateLinksHeaderValue.test.tsx | 3 +- .../getAlternateLinksHeaderValue.tsx | 3 +- .../src/middleware/middleware.test.tsx | 33 +++++++---- 11 files changed, 84 insertions(+), 108 deletions(-) diff --git a/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx b/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx index 878c240c0..612c4b941 100644 --- a/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx +++ b/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx @@ -61,7 +61,7 @@ The simplest option is to add JSON files in your local project folder: ### `next.config.mjs` [#next-config] -Now, set up the plugin which creates an alias to provide a request-specific i18n configuration to Server Components—more on this in the following steps. +Now, set up the plugin which creates an alias to provide a request-specific i18n configuration like your messages to Server Components—more on this in the following steps. @@ -117,8 +117,8 @@ export const routing = defineRouting({ defaultLocale: 'en' }); -// Lightweight wrappers around Next.js' navigation APIs -// that will consider the routing configuration +// Lightweight wrappers around Next.js' navigation +// APIs that consider the routing configuration export const {Link, redirect, usePathname, useRouter, getPathname} = createNavigation(routing); ``` @@ -136,11 +136,38 @@ import {routing} from './i18n/routing'; export default createMiddleware(routing); export const config = { - // Match only internationalized pathnames - matcher: ['/', '/(de|en)/:path*'] + // Match all pathnames except for + // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel` + // - … the ones containing a dot (e.g. `favicon.ico`) + matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)' }; ``` +
+ How can I match pathnames that contain dots like `/users/jane.doe`? + +If you have pathnames where dots are expected, you can match them with explicit entries: + +```tsx filename="src/middleware.ts" {10,11} +// ... + +export const config = { + matcher: [ + // Match all pathnames except for + // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel` + // - … the ones containing a dot (e.g. `favicon.ico`) + '/((?!api|trpc|_next|_vercel|.*\\..*).*)' + + // Match all pathnames within `{/:locale}/users` + '/([\\w-]+)?/users/(.+)' + ]; +} +``` + +This will match e.g. `/users/jane.doe`, also optionally with a locale prefix. + +
+ ### `src/i18n/request.ts` [#i18n-request] When using features from `next-intl` in Server Components, the relevant configuration is read from a central module that is located at `i18n/request.ts` by convention. This configuration is scoped to the current request and can be used to provide messages and other options based on the user's locale. diff --git a/docs/src/pages/docs/routing.mdx b/docs/src/pages/docs/routing.mdx index ce289ed79..098449ea9 100644 --- a/docs/src/pages/docs/routing.mdx +++ b/docs/src/pages/docs/routing.mdx @@ -63,13 +63,6 @@ export const routing = defineRouting({ }); ``` -
-How can I redirect unprefixed pathnames? - -If you want to redirect unprefixed pathnames like `/about` to a prefixed alternative like `/en/about`, you can adjust your middleware matcher to [match unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix) too. - -
- #### Don't use a locale prefix for the default locale [#locale-prefix-as-needed] If you want to use no prefix for the default locale (e.g. `/about`), you can configure your routing accordingly: @@ -83,9 +76,10 @@ export const routing = defineRouting({ }); ``` -**Important**: For this routing strategy to work as expected, you should additionally adapt your middleware matcher to detect [unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix). +**Note that:** -Note that if a superfluous locale prefix like `/en/about` is requested, the middleware will automatically redirect to the unprefixed version `/about`. This can be helpful in case you're redirecting from another locale and you want to update a potential cookie value first (e.g. [``](/docs/routing/navigation#link) relies on this mechanism). +1. If you use this routing strategy, make sure that your [middleware matcher](/docs/routing/middleware#matcher-config) detects unprefixed pathnames. +2. If a superfluous locale prefix like `/en/about` is requested, the middleware will automatically redirect to the unprefixed version `/about`. This can be helpful in case you're redirecting from another locale and you want to update a potential cookie value first (e.g. [``](/docs/routing/navigation#link) relies on this mechanism). #### Never use a locale prefix [#locale-prefix-never] @@ -109,7 +103,7 @@ In this case, requests for all locales will be rewritten to have the locale only **Note that:** -1. If you use this strategy, you should adapt your matcher to detect [unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix). +1. If you use this routing strategy, make sure that your [middleware matcher](/docs/routing/middleware#matcher-config) detects unprefixed pathnames. 2. [Alternate links](#alternate-links) are disabled in this mode since URLs might not be unique per locale. Due to this, consider including these yourself, or set up a [sitemap](/docs/environments/actions-metadata-route-handlers#sitemap) that links localized pages via `alternates`. 3. You can consider increasing the [`maxAge`](#locale-cookie) attribute of the locale cookie to a longer duration to remember the user's preference across sessions. @@ -561,12 +555,7 @@ link: ; rel="alternate"; hreflang="en", The [`x-default`](https://developers.google.com/search/docs/specialty/international/localized-versions#xdefault) entry is included to point to a variant that can be used if no other language matches the user's browser setting. This special entry is reserved for language selection & detection, in our case issuing a 307 redirect to the best matching locale. -Note that middleware configuration is automatically incorporated with the following special cases: - -1. **`localePrefix: 'always'` (default)**: The `x-default` entry is only included for `/`, not for nested pathnames like `/about`. The reason is that the default [matcher](#matcher-config) doesn't handle unprefixed pathnames apart from `/`, therefore these URLs could be 404s. Note that this only applies to the optional `x-default` entry, locale-specific URLs are always included. -2. **`localePrefix: 'never'`**: Alternate links are entirely turned off since there might not be unique URLs per locale. - -Other configuration options like `domains`, `pathnames` and `basePath` are automatically considered. +Your middleware configuration, including options like `domains`, `pathnames` and `basePath`, is automatically incorporated.
diff --git a/docs/src/pages/docs/routing/middleware.mdx b/docs/src/pages/docs/routing/middleware.mdx index 6069007f1..95364dacf 100644 --- a/docs/src/pages/docs/routing/middleware.mdx +++ b/docs/src/pages/docs/routing/middleware.mdx @@ -23,8 +23,10 @@ import {routing} from './i18n/routing'; export default createMiddleware(routing); export const config = { - // Match only internationalized pathnames - matcher: ['/', '/(de|en)/:path*'] + // Match all pathnames except for + // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel` + // - … the ones containing a dot (e.g. `favicon.ico`) + matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)' }; ``` @@ -106,58 +108,6 @@ The bestmatching domain is detected based on these priorities: The middleware is intended to only run on pages, not on arbitrary files that you serve independently of the user locale (e.g. `/favicon.ico`). -Because of this, the following config is generally recommended: - -```tsx filename="middleware.ts" -export const config = { - // Match only internationalized pathnames - matcher: ['/', '/(de|en)/:path*'] -}; -``` - -This enables: - -1. A redirect at `/` to a suitable locale -2. Internationalization of all pathnames starting with a locale (e.g. `/en/about`) - -
-Can I avoid hardcoding the locales in the `matcher` config? - -A [Next.js `matcher`](https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher) needs to be statically analyzable, therefore you can't use variables to generate this value. However, you can alternatively implement a programmatic condition in the middleware: - -```tsx filename="middleware.ts" -import {NextRequest} from 'next/server'; -import createMiddleware from 'next-intl/middleware'; -import {routing} from './i18n/routing'; - -const handleI18nRouting = createMiddleware(routing); - -export default function middleware(request: NextRequest) { - const {pathname} = request.nextUrl; - - // Matches '/', as well as all paths that start with a locale like '/en' - const shouldHandle = - pathname === '/' || - new RegExp(`^/(${locales.join('|')})(/.*)?$`).test( - request.nextUrl.pathname - ); - if (!shouldHandle) return; - - return handleI18nRouting(request); -} -``` - -
- -### Pathnames without a locale prefix [#matcher-no-prefix] - -There are two use cases where you might want to match pathnames without a locale prefix: - -1. You're using a config for [`localePrefix`](/docs/routing#locale-prefix) other than [`always`](/docs/routing#locale-prefix-always) -2. You want to enable redirects that add a locale for unprefixed pathnames (e.g. `/about` → `/en/about`) - -For these cases, the middleware should run on requests for pathnames without a locale prefix as well. - A popular strategy is to match all routes that don't start with certain segments (e.g. `/_next`) and also none that include a dot (`.`) since these typically indicate static files. However, if you have some routes where a dot is expected (e.g. `/users/jane.doe`), you should explicitly provide a matcher for these. ```tsx filename="middleware.ts" @@ -169,6 +119,7 @@ export const config = { // - … if they start with `/api`, `/_next` or `/_vercel` // - … the ones containing a dot (e.g. `favicon.ico`) '/((?!api|_next|_vercel|.*\\..*).*)', + // However, match all pathnames within `/users`, optionally with a locale prefix '/([\\w-]+)?/users/(.+)' ] diff --git a/examples/example-app-router-migration/src/middleware.ts b/examples/example-app-router-migration/src/middleware.ts index 5cc4efb87..9fce69220 100644 --- a/examples/example-app-router-migration/src/middleware.ts +++ b/examples/example-app-router-migration/src/middleware.ts @@ -4,6 +4,8 @@ import {routing} from './i18n/routing'; export default createMiddleware(routing); export const config = { - // Skip all paths that should not be internationalized - matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'] + // Match all pathnames except for + // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel` + // - … the ones containing a dot (e.g. `favicon.ico`) + matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)' }; diff --git a/examples/example-app-router-mixed-routing/src/middleware.ts b/examples/example-app-router-mixed-routing/src/middleware.ts index f413e76bc..53a13aea5 100644 --- a/examples/example-app-router-mixed-routing/src/middleware.ts +++ b/examples/example-app-router-mixed-routing/src/middleware.ts @@ -4,6 +4,8 @@ import {routing} from './i18n/routing.public'; export default createMiddleware(routing); export const config = { - // Match only public pathnames - matcher: ['/', '/(de|en)/:path*'] + // Match all pathnames except for + // - … if they start with `/app`, `/_next` or `/_vercel` + // - … the ones containing a dot (e.g. `favicon.ico`) + matcher: '/((?!app|_next|_vercel|.*\\..*).*)' }; diff --git a/examples/example-app-router-mixed-routing/tests/main.spec.ts b/examples/example-app-router-mixed-routing/tests/main.spec.ts index d3e719d97..b5e48d974 100644 --- a/examples/example-app-router-mixed-routing/tests/main.spec.ts +++ b/examples/example-app-router-mixed-routing/tests/main.spec.ts @@ -1,4 +1,4 @@ -import {test as it, expect} from '@playwright/test'; +import {expect, test as it} from '@playwright/test'; it('syncs the locale across the public and private pages', async ({page}) => { await page.goto('/'); diff --git a/examples/example-app-router-next-auth/src/middleware.ts b/examples/example-app-router-next-auth/src/middleware.ts index 500a469ee..a45f9fbab 100644 --- a/examples/example-app-router-next-auth/src/middleware.ts +++ b/examples/example-app-router-next-auth/src/middleware.ts @@ -43,6 +43,8 @@ export default function middleware(req: NextRequest) { } export const config = { - // Skip all paths that should not be internationalized - matcher: ['/((?!api|_next|.*\\..*).*)'] + // Match all pathnames except for + // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel` + // - … the ones containing a dot (e.g. `favicon.ico`) + matcher: ['/((?!api|trpc|_next|_vercel|.*\\..*).*)'] }; diff --git a/examples/example-app-router/src/middleware.ts b/examples/example-app-router/src/middleware.ts index b25094067..9fce69220 100644 --- a/examples/example-app-router/src/middleware.ts +++ b/examples/example-app-router/src/middleware.ts @@ -4,16 +4,8 @@ import {routing} from './i18n/routing'; export default createMiddleware(routing); export const config = { - matcher: [ - // Enable a redirect to a matching locale at the root - '/', - - // Set a cookie to remember the previous locale for - // all requests that have a locale prefix - '/(de|en)/:path*', - - // Enable redirects that add missing locales - // (e.g. `/pathnames` -> `/en/pathnames`) - '/((?!_next|_vercel|.*\\..*).*)' - ] + // Match all pathnames except for + // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel` + // - … the ones containing a dot (e.g. `favicon.ico`) + matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)' }; diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx index 5ef8dd183..fa78aea8b 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx @@ -184,7 +184,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }).split(', ') ).toEqual([ `; rel="alternate"; hreflang="en"`, - `; rel="alternate"; hreflang="es"` + `; 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 fbe9e44b6..738c76d9d 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -143,8 +143,7 @@ export default function getAlternateLinksHeaderValue< // Add x-default entry const shouldAddXDefault = // For domain-based routing there is no reasonable x-default - !routing.domains && - (routing.localePrefix.mode !== 'always' || normalizedUrl.pathname === '/'); + !routing.domains || routing.domains.length === 0; if (shouldAddXDefault) { const url = new URL( getLocalizedPathname(normalizedUrl.pathname, routing.defaultLocale), diff --git a/packages/next-intl/src/middleware/middleware.test.tsx b/packages/next-intl/src/middleware/middleware.test.tsx index 6801f7baf..dfaf19f01 100644 --- a/packages/next-intl/src/middleware/middleware.test.tsx +++ b/packages/next-intl/src/middleware/middleware.test.tsx @@ -1400,35 +1400,42 @@ describe('prefix-based routing', () => { ]); expect(getLinks(createMockRequest('/en/about', 'en'))).toEqual([ '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"' + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' ]); expect(getLinks(createMockRequest('/de/ueber', 'de'))).toEqual([ '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"' + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' ]); expect(getLinks(createMockRequest('/en/users/1', 'en'))).toEqual([ '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"' + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' ]); expect(getLinks(createMockRequest('/de/benutzer/1', 'de'))).toEqual([ '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"' + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' ]); expect( getLinks(createMockRequest('/en/products/apparel/t-shirts', 'en')) ).toEqual([ '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"' + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' ]); expect( getLinks(createMockRequest('/de/produkte/apparel/t-shirts', 'de')) ).toEqual([ '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"' + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' ]); expect(getLinks(createMockRequest('/en/unknown', 'en'))).toEqual([ '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"' + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' ]); }); @@ -1747,7 +1754,8 @@ describe('prefix-based routing', () => { '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="en-gb"', '; rel="alternate"; hreflang="de-at"', - '; rel="alternate"; hreflang="pt"' + '; rel="alternate"; hreflang="pt"', + '; rel="alternate"; hreflang="x-default"' ]); }); @@ -1755,7 +1763,8 @@ describe('prefix-based routing', () => { '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="en-gb"', '; rel="alternate"; hreflang="de-at"', - '; rel="alternate"; hreflang="pt"' + '; rel="alternate"; hreflang="pt"', + '; rel="alternate"; hreflang="x-default"' ]); }); }); @@ -1880,7 +1889,8 @@ describe('prefix-based routing', () => { '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="en-gb"', '; rel="alternate"; hreflang="de-at"', - '; rel="alternate"; hreflang="pt"' + '; rel="alternate"; hreflang="pt"', + '; rel="alternate"; hreflang="x-default"' ]); }); @@ -1888,7 +1898,8 @@ describe('prefix-based routing', () => { '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="en-gb"', '; rel="alternate"; hreflang="de-at"', - '; rel="alternate"; hreflang="pt"' + '; rel="alternate"; hreflang="pt"', + '; rel="alternate"; hreflang="x-default"' ]); }); }); From 22cf1cd5576123be7b7384cd3b9850c7fb4b5ee9 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 18 Feb 2025 12:39:58 +0100 Subject: [PATCH 82/90] fix: Formatting --- packages/next-intl/__mocks__/react.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next-intl/__mocks__/react.tsx b/packages/next-intl/__mocks__/react.tsx index c70f628f1..906d0ead2 100644 --- a/packages/next-intl/__mocks__/react.tsx +++ b/packages/next-intl/__mocks__/react.tsx @@ -1,4 +1,4 @@ -import {isPromise} from '../src/shared/utils'; +import {isPromise} from '../src/shared/utils.js'; // @ts-expect-error -- React uses CJS export * from 'react'; From 1a5e0ec6a93eae802399c6a1a27d4454b1d71e03 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 20 Feb 2025 19:12:30 +0100 Subject: [PATCH 83/90] fix: Don't attach alternate links for redirects and consider port of a domain that is being redirected to (#1729) --- packages/next-intl/.size-limit.ts | 2 +- .../src/middleware/middleware.test.tsx | 51 +++++++++++++++++-- .../next-intl/src/middleware/middleware.tsx | 9 +++- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 12397d788..d98b6aa73 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -42,7 +42,7 @@ const config: SizeLimitConfig = [ { name: "import * from 'next-intl/middleware'", path: 'dist/esm/production/middleware.js', - limit: '9.305 KB' + limit: '9.315 KB' }, { name: "import * from 'next-intl/routing'", diff --git a/packages/next-intl/src/middleware/middleware.test.tsx b/packages/next-intl/src/middleware/middleware.test.tsx index dfaf19f01..cbb0c9a23 100644 --- a/packages/next-intl/src/middleware/middleware.test.tsx +++ b/packages/next-intl/src/middleware/middleware.test.tsx @@ -354,6 +354,14 @@ describe('prefix-based routing', () => { ); }); + it('does not return alternate links when redirecting', () => { + const response = middleware( + createMockRequest('/en', 'en', 'http://localhost:3000', 'de') + ); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(response.headers.get('link')).toBe(null); + }); + it('sets a cookie when changing to the default locale', () => { const response = middleware( createMockRequest('/en', 'en', undefined, 'de') @@ -3002,7 +3010,7 @@ describe('domain-based routing', () => { ]); expect( getLinks( - createMockRequest('/a-propos', 'fr', 'http://ca.example.com') + createMockRequest('/fr/a-propos', 'fr', 'http://ca.example.com') ) ).toEqual([ '; rel="alternate"; hreflang="en"', @@ -3045,7 +3053,7 @@ describe('domain-based routing', () => { expect( getLinks( createMockRequest( - '/fr/produits/apparel/t-shirts', + '/produits/apparel/t-shirts', 'fr', 'http://fr.example.com' ) @@ -3246,7 +3254,7 @@ describe('domain-based routing', () => { ?.split(', '); } - ['/en', '/uk'].forEach((pathname) => { + ['/', '/uk'].forEach((pathname) => { expect(getLinks(createMockRequest(pathname))).toEqual([ '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="en-gb"', @@ -3254,7 +3262,7 @@ describe('domain-based routing', () => { ]); }); - ['/en/about', '/uk/about'].forEach((pathname) => { + ['/about', '/uk/about'].forEach((pathname) => { expect(getLinks(createMockRequest(pathname))).toEqual([ '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="en-gb"', @@ -3262,7 +3270,7 @@ describe('domain-based routing', () => { ]); }); - expect(getLinks(createMockRequest('/en/unknown'))).toEqual([ + expect(getLinks(createMockRequest('/unknown'))).toEqual([ '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="en-gb"', '; rel="alternate"; hreflang="x-default"' @@ -3374,6 +3382,39 @@ describe('domain-based routing', () => { ); }); + it('keeps the port when there is a x-forwarded-host', () => { + createMiddleware({ + defaultLocale: 'en', + locales: ['en', 'es'], + domains: [ + { + domain: 'localhost:3000', + defaultLocale: 'en', + locales: ['en'] + }, + { + domain: 'localhost:3001', + defaultLocale: 'es', + locales: ['es'] + } + ] + })( + createMockRequest( + '/en', + undefined, + 'http://localhost:3001', + undefined, + { + 'x-forwarded-host': 'localhost:3001' + } + ) + ); + + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en' + ); + }); + describe('base path', () => { it('redirects non-prefixed requests for the default locale', () => { middleware( diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 066c488a3..49d5ff1f2 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -124,7 +124,11 @@ export default function createMiddleware< request.headers.get('x-forwarded-proto') ?? request.nextUrl.protocol; - urlObj.port = request.headers.get('x-forwarded-port') ?? ''; + const redirectDomainPort = redirectDomain.split(':')[1] as + | string + | undefined; + urlObj.port = + redirectDomainPort ?? request.headers.get('x-forwarded-port') ?? ''; } } @@ -135,6 +139,7 @@ export default function createMiddleware< ); } + hasRedirected = true; return NextResponse.redirect(urlObj.toString()); } @@ -158,6 +163,7 @@ export default function createMiddleware< let response; let internalTemplateName: string | undefined; + let hasRedirected: boolean | undefined; let unprefixedInternalPathname = unprefixedExternalPathname; const pathnames = (resolvedRouting as any).pathnames as @@ -304,6 +310,7 @@ export default function createMiddleware< syncCookie(request, response, locale, resolvedRouting, domain); if ( + !hasRedirected && resolvedRouting.localePrefix.mode !== 'never' && resolvedRouting.alternateLinks && resolvedRouting.locales.length > 1 From 021e874f8474f84428eeef3e0438557621653263 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 21 Feb 2025 19:06:20 +0100 Subject: [PATCH 84/90] feat!: Stricter config for `domains` to improve handling of `localePrefix: 'as-needed'` (#1734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit So far, when using [`domains`](https://next-intl.dev/docs/routing#domains) in combination with `localePrefix: 'as-needed'`, `next-intl` had to make some [tradeoffs](https://next-intl-docs-6wwcmwb9a-next-intl.vercel.app/docs/routing#domains-localeprefix-asneeded). Now, `next-intl` comes with stricter requirements when `domains` is used: 1. A locale can now only be used for a single domain 2. Each domain now must specify its `locales` By introducing these constraints, the mentioned tradeoffs now can be removed altogether, resulting in a simplified model. If you previously used locales across multiple domains, you now have to be more specific—typically by introducing a regional variant for a base language. You can additionally customize the prefixes if desired. **Before** ```tsx import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ locales: ['sv', 'en', 'no', 'fr'], defaultLocale: 'en', localePrefix: 'as-needed', domains: [ { domain: 'domain.se', defaultLocale: 'sv', locales: ['sv', 'en'] }, { domain: 'domain.no', defaultLocale: 'no', locales: ['no', 'en'] }, { domain: 'domain.com', defaultLocale: 'en', locales: ['en', 'fr'] }, ] }); ``` **After** ```tsx import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ locales: ['sv-SE', 'en-SE', 'no-NO', 'en-NO', 'fr-FR', 'en-US'], defaultLocale: 'en-US', localePrefix: { mode: 'as-needed', prefixes: { 'en-SE': '/en', 'en-NO': '/en', 'en-US': '/en', 'fr-FR': '/fr' } }, domains: [ { domain: 'domain.se', defaultLocale: 'sv-SE', locales: ['sv-SE', 'en-SE'] }, { domain: 'domain.no', defaultLocale: 'no-NO', locales: ['no-NO', 'en-NO'] }, { domain: 'domain.com', defaultLocale: 'en-US', locales: ['en-US', 'fr-FR'] }, ] }); ``` Learn more in the updated docs for [`domains`](https://v4.next-intl.dev/docs/routing#domains). Resolves https://github.com/amannn/next-intl/issues/1733 **TODO** - [x] Docs - [x] Implementation - [x] Real world test and make sure we don't have any problems with prefixes - [ ] When users have more locales, https://github.com/amannn/next-intl/issues/990 would be really handy - [ ] Back-port warning to v3 (also that locales are becoming unique per domain) - [ ] Update blog post --- docs/src/pages/docs/routing.mdx | 84 ++++++----------- docs/src/pages/docs/routing/middleware.mdx | 7 +- docs/src/pages/docs/usage/configuration.mdx | 24 +++++ .../src/i18n/routing.ts | 6 +- .../tests/domains.spec.ts | 3 +- packages/next-intl/.size-limit.ts | 6 +- .../getAlternateLinksHeaderValue.test.tsx | 8 +- .../src/middleware/middleware.test.tsx | 91 ++++++++++++++++--- .../next-intl/src/middleware/middleware.tsx | 3 +- .../src/middleware/resolveLocale.tsx | 5 +- packages/next-intl/src/middleware/utils.tsx | 38 ++++---- .../src/navigation/createNavigation.test.tsx | 61 ++++--------- .../react-client/createNavigation.test.tsx | 57 +++--------- .../react-client/createNavigation.tsx | 4 +- .../src/navigation/shared/BaseLink.tsx | 51 +---------- .../shared/createSharedNavigationFns.tsx | 83 ++--------------- .../next-intl/src/navigation/shared/utils.tsx | 29 +----- .../src/routing/defineRouting.test.tsx | 75 ++++++++++++++- .../next-intl/src/routing/defineRouting.tsx | 33 +++++++ packages/next-intl/src/routing/types.test.tsx | 9 +- packages/next-intl/src/routing/types.tsx | 4 +- 21 files changed, 328 insertions(+), 353 deletions(-) diff --git a/docs/src/pages/docs/routing.mdx b/docs/src/pages/docs/routing.mdx index 098449ea9..f5b750f7b 100644 --- a/docs/src/pages/docs/routing.mdx +++ b/docs/src/pages/docs/routing.mdx @@ -321,37 +321,49 @@ If you want to serve your localized content based on different domains, you can **Examples:** -- `us.example.com/en` -- `ca.example.com/en` -- `ca.example.com/fr` +- `us.example.com`: `en-US` +- `ca.example.com`: `en-CA` +- `ca.example.com/fr`: `fr-CA` +- `fr.example.com`: `fr-FR` + +In many cases, `domains` are combined with a [`localePrefix`](#locale-prefix) setting to achieve results as shown above. Also [custom prefixes](#locale-prefix-custom) can be used to customize the user-facing prefix per locale. ```tsx filename="routing.ts" import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ - locales: ['en', 'fr'], - defaultLocale: 'en', + locales: ['en-US', 'en-CA', 'fr-CA', 'fr-FR'], + defaultLocale: 'en-US', domains: [ { domain: 'us.example.com', - defaultLocale: 'en', - // Optionally restrict the locales available on this domain - locales: ['en'] + defaultLocale: 'en-US', + locales: ['en-US'] }, { domain: 'ca.example.com', - defaultLocale: 'en' - // If there are no `locales` specified on a domain, - // all available locales will be supported here + defaultLocale: 'en-CA', + locales: ['en-CA', 'fr-CA'] + }, + { + domain: 'fr.example.com', + defaultLocale: 'fr-FR', + locales: ['fr-FR'] } - ] + ], + localePrefix: { + mode: 'as-needed', + prefixes: { + // Cleaner prefix for `ca.example.com/fr` + 'fr-CA': '/fr' + } + } }); ``` -**Note that:** +Locales are required to be unique across domains, therefore regional variants are typically used to avoid conflicts. Note however that you don't necessarily need to [provide messages for each locale](/docs/usage/configuration#messages-per-locale) if the overall language is sufficient for your use case. -1. You can optionally remove the locale prefix in pathnames by changing the [`localePrefix`](#locale-prefix) setting. E.g. [`localePrefix: 'never'`](#locale-prefix-never) can be helpful in case you have unique domains per locale. -2. If no domain matches, the middleware will fall back to the [`defaultLocale`](/docs/routing/middleware#default-locale) (e.g. on `localhost`). +If no domain matches, the middleware will fall back to the general [`defaultLocale`](/docs/routing/middleware#default-locale) (e.g. on `localhost`).
How can I locally test if my setup is working? @@ -393,9 +405,7 @@ PORT=3001 npm run dev
Can I use a different `localePrefix` setting per domain? -Since such a configuration would require reading the domain at runtime, this would prevent the ability to render pages statically. Due to this, `next-intl` doesn't support this configuration out of the box. - -However, you can still achieve this by building the app for each domain separately, while injecting diverging routing configuration via an environment variable. +While this is currently not supported out of the box, you can still achieve this by building the app for each domain separately while injecting diverging routing configuration via an environment variable. **Example:** @@ -406,48 +416,14 @@ const isUsDomain = process.env.VERCEL_PROJECT_PRODUCTION_URL === 'us.example.com'; export const routing = defineRouting({ - locales: isUsDomain ? ['en'] : ['en', 'fr'], - defaultLocale: 'en', + locales: isUsDomain ? ['en-US'] : ['en-CA', 'fr-CA'], + defaultLocale: isUsDomain ? 'en-US' : 'en-CA', localePrefix: isUsDomain ? 'never' : 'always' }); ```
-
-Special case: Using `domains` with `localePrefix: 'as-needed'` - -Since domains can have different default locales, this combination requires some tradeoffs that apply to the [navigation APIs](/docs/routing/navigation) in order for `next-intl` to avoid reading the current host on the server side (which would prevent the usage of static rendering). - -1. [``](/docs/routing/navigation#link): This component will always render a locale prefix on the server side, even for the default locale of a given domain. However, during hydration on the client side, the prefix is potentially removed, if the default locale of the current domain is used. Note that the temporarily prefixed pathname will always be valid, however the middleware will potentially clean up a superfluous prefix via a redirect if the user clicks on a link before hydration. -2. [`redirect`](/docs/routing/navigation#redirect): When calling this function, a locale prefix is always added, regardless of the provided locale. However, similar to the handling with ``, the middleware will potentially clean up a superfluous prefix. -3. [`getPathname`](/docs/routing/navigation#getpathname): This function requires that a `domain` is passed as part of the arguments in order to avoid ambiguity. This can either be provided statically (e.g. when used in a sitemap), or read from a header like [`x-forwarded-host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host). - -```tsx -import {getPathname} from '@/i18n/routing'; -import {headers} from 'next/headers'; - -// Case 1: Statically known domain -const domain = 'ca.example.com'; - -// Case 2: Read at runtime (dynamic rendering) -const domain = headers().get('x-forwarded-host'); - -// Assuming the current domain is `ca.example.com`, -// the returned pathname will be `/about` -const pathname = getPathname({ - href: '/about', - locale: 'en', - domain -}); -``` - -A `domain` can optionally also be passed to `redirect` in the same manner to ensure that a prefix is only added when necessary. Alternatively, you can also consider redirecting in the middleware or via [`useRouter`](/docs/routing/navigation#usrouter) on the client side. - -If you need to avoid these tradeoffs, you can consider building the same app for each domain separately, while injecting diverging routing configuration via an [environment variable](#domains-localeprefix-individual). - -
- ### Turning off locale detection [#locale-detection] The middleware will [detect a matching locale](/docs/routing/middleware#locale-detection) based on your routing configuration and the incoming request. diff --git a/docs/src/pages/docs/routing/middleware.mdx b/docs/src/pages/docs/routing/middleware.mdx index 95364dacf..b669723a4 100644 --- a/docs/src/pages/docs/routing/middleware.mdx +++ b/docs/src/pages/docs/routing/middleware.mdx @@ -93,14 +93,13 @@ Since the middleware is aware of all your domains, if a domain receives a reques 4. The middleware recognizes that the user wants to switch to another domain and responds with a redirect to `ca.example.com/fr`.
-How is the best matching domain for a given locale detected? +How is the best-matching domain for a given locale detected? -The bestmatching domain is detected based on these priorities: +The best-matching domain is detected based on these priorities: 1. Stay on the current domain if the locale is supported here 2. Use an alternative domain where the locale is configured as the `defaultLocale` -3. Use an alternative domain where the available `locales` are restricted and the locale is supported -4. Use an alternative domain that supports all locales +3. Use an alternative domain that supports the locale
diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx index 9236ba871..7bb212ddf 100644 --- a/docs/src/pages/docs/usage/configuration.mdx +++ b/docs/src/pages/docs/usage/configuration.mdx @@ -358,6 +358,30 @@ Note that [the VSCode integration for `next-intl`](/docs/workflows/vscode-integr
+
+Do I need separate messages for each locale that my app supports? + +Since you have full control over how messages are loaded, you can choose to load messages for example merely based on the overall language, ignoring any regional variants: + +```tsx +import {getRequestConfig} from 'next-intl/server'; + +export default getRequestConfig(async () => { + // E.g. "en-US", "en-CA", … + const locale = 'en-US'; + + // E.g. "en" + const language = new Intl.Locale(locale).language; + + // Load messages based on the language + const messages = (await import(`../../messages/${language}.json`)).default; + + // ... +}); +``` + +
+ ### `useMessages` & `getMessages` [#use-messages] In case you require access to messages in a component, you can read them via `useMessages()` or `getMessages()` from your configuration: diff --git a/examples/example-app-router-playground/src/i18n/routing.ts b/examples/example-app-router-playground/src/i18n/routing.ts index 1da45abec..ac5aebcc6 100644 --- a/examples/example-app-router-playground/src/i18n/routing.ts +++ b/examples/example-app-router-playground/src/i18n/routing.ts @@ -18,11 +18,13 @@ export const routing = defineRouting({ ? [ { domain: 'example.com', - defaultLocale: 'en' + defaultLocale: 'en', + locales: ['en', 'es', 'ja'] }, { domain: 'example.de', - defaultLocale: 'de' + defaultLocale: 'de', + locales: ['de'] } ] : undefined, diff --git a/examples/example-app-router-playground/tests/domains.spec.ts b/examples/example-app-router-playground/tests/domains.spec.ts index f113e76ee..557e46dfd 100644 --- a/examples/example-app-router-playground/tests/domains.spec.ts +++ b/examples/example-app-router-playground/tests/domains.spec.ts @@ -37,6 +37,5 @@ it('can use a secondary locale unprefixed if the domain has specified it as the await page.getByRole('link', {name: 'Start'}).click(); await expect(page).toHaveURL('http://example.de'); await page.getByRole('link', {name: 'Zu Englisch wechseln'}).click(); - await expect(page).toHaveURL('http://example.de/en'); - await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible(); + await expect(page).toHaveURL('http://example.com/en'); }); diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index f2abdf34c..8f94d1fbc 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -21,13 +21,13 @@ const config: SizeLimitConfig = [ name: "import {createNavigation} from 'next-intl/navigation' (react-client)", path: 'dist/esm/production/navigation.react-client.js', import: '{createNavigation}', - limit: '2.485 KB' + limit: '2.285 KB' }, { name: "import {createNavigation} from 'next-intl/navigation' (react-server)", path: 'dist/esm/production/navigation.react-server.js', import: '{createNavigation}', - limit: '3.275 KB' + limit: '3.055 KB' }, { name: "import * from 'next-intl/server' (react-client)", @@ -42,7 +42,7 @@ const config: SizeLimitConfig = [ { name: "import * from 'next-intl/middleware'", path: 'dist/esm/production/middleware.js', - limit: '9.315 KB' + limit: '9.355 KB' }, { name: "import * from 'next-intl/routing'", diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx index fa78aea8b..66a512d0f 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx @@ -197,8 +197,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( domains: [ { domain: 'example.com', - defaultLocale: 'en' - // (supports all locales) + defaultLocale: 'en', + locales: ['en', 'es', 'fr'] }, { domain: 'example.es', @@ -264,8 +264,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( domains: [ { domain: 'example.com', - defaultLocale: 'en' - // (supports all locales) + defaultLocale: 'en', + locales: ['en', 'es', 'fr'] }, { domain: 'example.es', diff --git a/packages/next-intl/src/middleware/middleware.test.tsx b/packages/next-intl/src/middleware/middleware.test.tsx index cbb0c9a23..56f1e9ada 100644 --- a/packages/next-intl/src/middleware/middleware.test.tsx +++ b/packages/next-intl/src/middleware/middleware.test.tsx @@ -2480,7 +2480,8 @@ describe('domain-based routing', () => { domains: [ { defaultLocale: 'fr', - domain: 'ca.example.com' + domain: 'ca.example.com', + locales: ['en', 'fr'] } ] }); @@ -3193,20 +3194,73 @@ describe('domain-based routing', () => { describe('custom prefixes with pathnames', () => { const middlewareWithPrefixes = createMiddleware({ defaultLocale: 'en', - locales: ['en', 'en-gb'], + locales: ['en', 'en-gb', 'sv-SE', 'en-SE', 'no-NO', 'en-NO'], localePrefix: { mode: 'as-needed', prefixes: { - 'en-gb': '/uk' + 'en-gb': '/uk', + 'en-SE': '/en', + 'en-NO': '/en' } }, pathnames: { '/': '/', '/about': { en: '/about', - 'en-gb': '/about' + 'en-gb': '/about', + 'en-SE': '/about', + 'en-NO': '/about', + 'sv-SE': '/about', + 'no-NO': '/about' + } + } satisfies Pathnames< + ReadonlyArray<'en' | 'en-gb' | 'sv-SE' | 'en-SE' | 'no-NO' | 'en-NO'> + >, + domains: [ + { + defaultLocale: 'en-gb', + domain: 'example.co.uk', + locales: ['en-gb'] + }, + { + defaultLocale: 'sv-SE', + domain: 'example.se', + locales: ['sv-SE', 'en-SE'] + }, + { + defaultLocale: 'no-NO', + domain: 'example.no', + locales: ['no-NO', 'en-NO'] + }, + { + defaultLocale: 'en', + domain: 'example.com', + locales: ['en'] } - } satisfies Pathnames> + ] + }); + + it('serves requests for overlapping prefixes', () => { + middlewareWithPrefixes( + createMockRequest('/', undefined, 'http://example.com') + ); + middlewareWithPrefixes( + createMockRequest('/en', undefined, 'http://example.no') + ); + middlewareWithPrefixes( + createMockRequest('/en', undefined, 'http://example.se') + ); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(3); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://example.com/en' + ); + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://example.no/en-NO' + ); + expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( + 'http://example.se/en-SE' + ); }); it('serves requests for the default locale at the root', () => { @@ -3256,24 +3310,33 @@ describe('domain-based routing', () => { ['/', '/uk'].forEach((pathname) => { expect(getLinks(createMockRequest(pathname))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en-gb"', - '; rel="alternate"; hreflang="x-default"' + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en-gb"', + '; rel="alternate"; hreflang="sv-SE"', + '; rel="alternate"; hreflang="en-SE"', + '; rel="alternate"; hreflang="no-NO"', + '; rel="alternate"; hreflang="en-NO"' ]); }); ['/about', '/uk/about'].forEach((pathname) => { expect(getLinks(createMockRequest(pathname))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en-gb"', - '; rel="alternate"; hreflang="x-default"' + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en-gb"', + '; rel="alternate"; hreflang="sv-SE"', + '; rel="alternate"; hreflang="en-SE"', + '; rel="alternate"; hreflang="no-NO"', + '; rel="alternate"; hreflang="en-NO"' ]); }); expect(getLinks(createMockRequest('/unknown'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en-gb"', - '; rel="alternate"; hreflang="x-default"' + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en-gb"', + '; rel="alternate"; hreflang="sv-SE"', + '; rel="alternate"; hreflang="en-SE"', + '; rel="alternate"; hreflang="no-NO"', + '; rel="alternate"; hreflang="en-NO"' ]); }); }); diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 49d5ff1f2..77d9debad 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -152,7 +152,8 @@ export default function createMiddleware< const pathnameMatch = getPathnameMatch( externalPathname, resolvedRouting.locales, - resolvedRouting.localePrefix + resolvedRouting.localePrefix, + domain ); const hasLocalePrefix = pathnameMatch != null; diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index 71653e072..05f51eb17 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -169,7 +169,8 @@ function resolveLocaleFromDomain< const prefixLocale = getPathnameMatch( pathname, routing.locales, - routing.localePrefix + routing.localePrefix, + domain )?.locale; if (prefixLocale) { if (isLocaleSupportedOnDomain(prefixLocale, domain)) { @@ -197,7 +198,7 @@ function resolveLocaleFromDomain< if (!locale && routing.localeDetection) { const headerLocale = getAcceptLanguageLocale( requestHeaders, - domain.locales || routing.locales, + domain.locales, domain.defaultLocale ); diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index b075ae15d..e643d8689 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -159,7 +159,8 @@ export function getPathnameMatch< >( pathname: string, locales: AppLocales, - localePrefix: LocalePrefixConfigVerbose + localePrefix: LocalePrefixConfigVerbose, + domain?: DomainConfig ): | { locale: AppLocales[number]; @@ -170,6 +171,21 @@ export function getPathnameMatch< | undefined { const localePrefixes = getLocalePrefixes(locales, localePrefix); + // Sort to prioritize domain locales + if (domain) { + localePrefixes.sort(([localeA], [localeB]) => { + if (localeA === domain.defaultLocale) return -1; + if (localeB === domain.defaultLocale) return 1; + + const isLocaleAInDomain = domain.locales.includes(localeA); + const isLocaleBInDomain = domain.locales.includes(localeB); + if (isLocaleAInDomain && !isLocaleBInDomain) return -1; + if (!isLocaleAInDomain && isLocaleBInDomain) return 1; + + return 0; + }); + } + for (const [locale, prefix] of localePrefixes) { let exact, matches; if (pathname === prefix || pathname.startsWith(prefix + '/')) { @@ -258,11 +274,7 @@ export function isLocaleSupportedOnDomain( locale: Locale, domain: DomainConfig ) { - return ( - domain.defaultLocale === locale || - !domain.locales || - domain.locales.includes(locale) - ); + return domain.defaultLocale === locale || domain.locales.includes(locale); } export function getBestMatchingDomain( @@ -282,19 +294,9 @@ export function getBestMatchingDomain( domainConfig = domainsConfig.find((cur) => cur.defaultLocale === locale); } - // Prio 3: Use alternative domain with restricted matching locale - if (!domainConfig) { - domainConfig = domainsConfig.find((cur) => cur.locales?.includes(locale)); - } - - // Prio 4: Stay on the current domain if it supports all locales - if (!domainConfig && curHostDomain?.locales == null) { - domainConfig = curHostDomain; - } - - // Prio 5: Use alternative domain that supports all locales + // Prio 3: Use alternative domain that supports the locale if (!domainConfig) { - domainConfig = domainsConfig.find((cur) => !cur.locales); + domainConfig = domainsConfig.find((cur) => cur.locales.includes(locale)); } return domainConfig; diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index c380cc02c..35a8d9da1 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -53,12 +53,13 @@ const defaultLocale = 'en' as const; const domains = [ { defaultLocale: 'en', - domain: 'example.com' + domain: 'example.com', + locales: ['en'] }, { defaultLocale: 'de', domain: 'example.de', - locales: ['de', 'en'] + locales: ['de', 'ja'] } ] satisfies DomainsConfig; @@ -907,10 +908,9 @@ describe.each([ }); describe('Link', () => { - it('renders a prefix during SSR even for the default locale', () => { - // (see comment in source for reasoning) + it('renders no prefix during SSR for the default locale', () => { const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); + expect(markup).toContain('href="/about"'); }); it('does not render a prefix eventually on the client side for the default locale of the given domain', () => { @@ -935,11 +935,11 @@ describe.each([ it('renders a prefix when currently on a secondary locale', () => { mockLocation({host: 'example.de'}); - mockCurrentLocale('en'); + mockCurrentLocale('ja'); render(About); expect( screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/en/about'); + ).toBe('/ja/about'); }); it('does not render a prefix when currently on a domain with a different default locale', () => { @@ -973,30 +973,9 @@ describe.each([ }); describe('getPathname', () => { - it('does not add a prefix for the default locale', () => { - expect( - getPathname({locale: 'en', href: '/about', domain: 'example.com'}) - ).toBe('/about'); - expect( - getPathname({locale: 'de', href: '/about', domain: 'example.de'}) - ).toBe('/about'); - }); - - it('adds a prefix for a secondary locale', () => { - expect( - getPathname({locale: 'de', href: '/about', domain: 'example.com'}) - ).toBe('/de/about'); - expect( - getPathname({locale: 'en', href: '/about', domain: 'example.de'}) - ).toBe('/en/about'); - }); - - it('prints a warning when no domain is provided', () => { - const consoleSpy = vi.spyOn(console, 'error'); - // @ts-expect-error -- Domain is not provided - getPathname({locale: 'de', href: '/about'}); - expect(consoleSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); + it('does not add a prefix for a default locale', () => { + expect(getPathname({locale: 'en', href: '/about'})).toBe('/about'); + expect(getPathname({locale: 'de', href: '/about'})).toBe('/about'); }); }); @@ -1005,21 +984,19 @@ describe.each([ ['permanentRedirect', permanentRedirect, nextPermanentRedirect] ])('%s', (_, redirectFn, nextRedirectFn) => { it('adds a prefix even for the default locale', () => { - // (see comment in source for reasoning) + // There's one edge case that is not handled here: If `localePrefix: + // 'as-needed'` is used and the user redirects from a non-default locale + // to the default locale, no cookie will be updated and therefore the user + // redirected back to the original locale. Typically, redirect is not used + // for language switching though and uses the current locale of the user, + // therefore this is currently neglected. runInRender(() => redirectFn({href: '/', locale: 'en'})); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); - }); - - it('does not add a prefix when domain is provided for the default locale', () => { - runInRender(() => - redirectFn({href: '/', locale: 'en', domain: 'example.com'}) - ); expect(nextRedirectFn).toHaveBeenLastCalledWith('/'); }); it('adds a prefix for a secondary locale', () => { - runInRender(() => redirectFn({href: '/', locale: 'de'})); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/de'); + runInRender(() => redirectFn({href: '/', locale: 'ja'})); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/ja'); }); }); }); @@ -1052,7 +1029,7 @@ describe.each([ runInRender(() => redirectFn({href: {pathname: '/', query: {foo: 'bar'}}, locale: 'en'}) ); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/en?foo=bar'); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/?foo=bar'); }); }); }); diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index 238aaad2d..d88bb4a1d 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -55,12 +55,13 @@ const defaultLocale = 'en' as const; const domains: DomainsConfig = [ { defaultLocale: 'en', - domain: 'example.com' + domain: 'example.com', + locales: ['en'] }, { defaultLocale: 'de', domain: 'example.de', - locales: ['de', 'en'] + locales: ['de', 'ja'] } ]; @@ -563,43 +564,20 @@ describe("localePrefix: 'as-needed', with `basePath` and `domains`", () => { describe('useRouter', () => { const invokeRouter = getInvokeRouter(useRouter); - describe('example.com, defaultLocale: "en"', () => { - beforeEach(() => { - mockLocation( - {pathname: '/base/path/about', host: 'example.com'}, - '/base/path' - ); - }); - - it('can compute the correct pathname when the default locale on the current domain matches the current locale', () => { - invokeRouter((router) => router.push('/test')); - expect(useNextRouter().push).toHaveBeenCalledWith('/test'); - }); - - it('can compute the correct pathname when the default locale on the current domain does not match the current locale', () => { - invokeRouter((router) => router.push('/test', {locale: 'de'})); - expect(useNextRouter().push).toHaveBeenCalledWith('/de/test'); - }); + it('can compute the correct pathname when on the default locale and not supplying a locale', () => { + invokeRouter((router) => router.push('/test')); + expect(useNextRouter().push).toHaveBeenCalledWith('/test'); }); - describe('example.de, defaultLocale: "de"', () => { - beforeEach(() => { - mockCurrentLocale('de'); - mockLocation( - {pathname: '/base/path/about', host: 'example.de'}, - '/base/path' - ); - }); - - it('can compute the correct pathname when the default locale on the current domain matches the current locale', () => { - invokeRouter((router) => router.push('/test')); - expect(useNextRouter().push).toHaveBeenCalledWith('/test'); - }); + it('can compute the correct pathname when on the default locale and supplying a secondary locale', () => { + invokeRouter((router) => router.push('/test', {locale: 'ja'})); + expect(useNextRouter().push).toHaveBeenCalledWith('/ja/test'); + }); - it('can compute the correct pathname when the default locale on the current domain does not match the current locale', () => { - invokeRouter((router) => router.push('/test', {locale: 'en'})); - expect(useNextRouter().push).toHaveBeenCalledWith('/en/test'); - }); + it('can compute the correct pathname when on a secondary locale and navigating to the default locale', () => { + mockCurrentLocale('ja'); + invokeRouter((router) => router.push('/test', {locale: 'en'})); + expect(useNextRouter().push).toHaveBeenCalledWith('/test'); }); }); }); @@ -639,13 +617,6 @@ describe("localePrefix: 'as-needed', with `domains`", () => { expect(consoleSpy).not.toHaveBeenCalled(); consoleSpy.mockRestore(); }); - - it('prefixes the default locale when on a domain with a different defaultLocale', () => { - mockCurrentLocale('de'); - mockLocation({pathname: '/about', host: 'example.de'}); - invokeRouter((router) => router[method]('/about', {locale: 'en'})); - expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/about'); - }); }); }); diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index a1e712295..6dc99839f 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -85,11 +85,9 @@ export default function createNavigation< ): void { const {locale: nextLocale, ...rest} = options || {}; - // @ts-expect-error -- We're passing a domain here just in case const pathname = getPathname({ href, - locale: nextLocale || curLocale, - domain: window.location.host + locale: nextLocale || curLocale }); const args: [href: string, options?: Options] = [pathname]; diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index 0012cfe2a..a1e0fb781 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -6,9 +6,7 @@ import { type ComponentProps, type MouseEvent, type Ref, - forwardRef, - useEffect, - useState + forwardRef } from 'react'; import {type Locale, useLocale} from 'use-intl'; import type {InitializedLocaleCookieConfig} from '../../routing/config.js'; @@ -19,48 +17,15 @@ type NextLinkProps = Omit, keyof LinkProps> & type Props = NextLinkProps & { locale?: Locale; - defaultLocale?: Locale; localeCookie: InitializedLocaleCookieConfig; - /** Special case for `localePrefix: 'as-needed'` and `domains`. */ - unprefixed?: { - domains: {[domain: string]: Locale}; - pathname: string; - }; }; function BaseLink( - { - defaultLocale, - href, - locale, - localeCookie, - onClick, - prefetch, - unprefixed, - ...rest - }: Props, + {href, locale, localeCookie, onClick, prefetch, ...rest}: Props, ref: Ref ) { const curLocale = useLocale(); const isChangingLocale = locale != null && locale !== curLocale; - const linkLocale = locale || curLocale; - const host = useHost(); - - const finalHref = - // Only after hydration (to avoid mismatches) - host && - // If there is an `unprefixed` prop, the - // `defaultLocale` might differ by domain - unprefixed && - // Unprefix the pathname if a domain matches - (unprefixed.domains[host] === linkLocale || - // … and handle unknown domains by applying the - // global `defaultLocale` (e.g. on localhost) - (!Object.keys(unprefixed.domains).includes(host) && - curLocale === defaultLocale && - !locale)) - ? unprefixed.pathname - : href; // The types aren't entirely correct here. Outside of Next.js // `useParams` can be called, but the return type is `null`. @@ -87,7 +52,7 @@ function BaseLink( return ( (); - - useEffect(() => { - setHost(window.location.host); - }, []); - - return host; -} - export default forwardRef(BaseLink); diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index 1e5ba7109..44b3e39c2 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -10,7 +10,6 @@ import { receiveRoutingConfig } from '../../routing/config.js'; import type { - DomainConfig, DomainsConfig, LocalePrefixMode, Locales, @@ -67,16 +66,6 @@ export default function createSharedNavigationFns< ? undefined : AppPathnames; - // This combination requires that the current host is known in order to - // compute a correct pathname. Since that can only be achieved by reading from - // headers, this would break static rendering. Therefore, as a workaround we - // always add a prefix in this case to be on the safe side. The downside is - // that the user might get redirected again if the middleware detects that the - // prefix is not needed. - const forcePrefixSsr = - (config.localePrefix.mode === 'as-needed' && (config as any).domains) || - undefined; - type LinkProps = Prettify< Omit< ComponentProps, @@ -94,10 +83,9 @@ export default function createSharedNavigationFns< {href, locale, ...rest}: LinkProps, ref: ComponentProps['ref'] ) { - let pathname, params, query; + let pathname, params; if (typeof href === 'object') { pathname = href.pathname; - query = href.query; // @ts-expect-error -- This is ok params = href.params; } else { @@ -114,20 +102,18 @@ export default function createSharedNavigationFns< const finalPathname = isLocalizable ? getPathname( - // @ts-expect-error -- This is ok { locale: locale || curLocale, + // @ts-expect-error -- This is ok href: pathnames == null ? pathname : {pathname, params} }, - locale != null || forcePrefixSsr || undefined + locale != null || undefined ) : pathname; return ( , - domain: DomainConfig - ) => { - acc[domain.domain] = domain.defaultLocale; - return acc; - }, - {} - ), - pathname: getPathname( - // @ts-expect-error -- This is ok - { - locale: curLocale, - href: - pathnames == null - ? {pathname, query} - : {pathname, query, params} - }, - false - ) - } - : undefined - } {...rest} /> ); } const LinkWithRef = forwardRef(Link); - type DomainConfigForAsNeeded = typeof routing extends undefined - ? {} - : AppLocalePrefixMode extends 'as-needed' - ? [AppDomains] extends [never] - ? {} - : { - /** - * In case you're using `localePrefix: 'as-needed'` in combination with `domains`, the `defaultLocale` can differ by domain and therefore the locales that need to be prefixed can differ as well. For this particular case, this parameter should be provided in order to compute the correct pathname. Note that the actual domain is not part of the result, but only the pathname is returned. - * @see https://next-intl.dev/docs/routing/navigation#getpathname - */ - domain: AppDomains[number]['domain']; - } - : {}; - function getPathname( args: { /** @see https://next-intl.dev/docs/routing/navigation#getpathname */ @@ -192,7 +135,7 @@ export default function createSharedNavigationFns< ? string | {pathname: string; query?: QueryParams} : HrefOrHrefWithParams; locale: Locale; - } & DomainConfigForAsNeeded, + }, /** @private Removed in types returned below */ _forcePrefix?: boolean ) { @@ -218,14 +161,7 @@ export default function createSharedNavigationFns< }); } - return applyPathnamePrefix( - pathname, - locale, - config, - // @ts-expect-error -- This is ok - args.domain, - _forcePrefix - ); + return applyPathnamePrefix(pathname, locale, config, _forcePrefix); } function getRedirectFn( @@ -233,15 +169,10 @@ export default function createSharedNavigationFns< ) { /** @see https://next-intl.dev/docs/routing/navigation#redirect */ return function redirectFn( - args: Omit[0], 'domain'> & - Partial, + args: Omit[0], 'domain'>, ...rest: ParametersExceptFirst ) { - return fn( - // @ts-expect-error -- We're forcing the prefix when no domain is provided - getPathname(args, args.domain ? undefined : forcePrefixSsr), - ...rest - ); + return fn(getPathname(args), ...rest); }; } diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index ac92fa530..f24b8c59c 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -251,7 +251,6 @@ export function applyPathnamePrefix< 'defaultLocale' > >, - domain?: string, force?: boolean ): string { const {mode} = routing.localePrefix; @@ -263,29 +262,11 @@ export function applyPathnamePrefix< if (mode === 'always') { shouldPrefix = true; } else if (mode === 'as-needed') { - let defaultLocale: AppLocales[number] | undefined = routing.defaultLocale; - - if (routing.domains) { - const domainConfig = routing.domains.find( - (cur) => cur.domain === domain - ); - if (domainConfig) { - defaultLocale = domainConfig.defaultLocale; - } else if (process.env.NODE_ENV !== 'production') { - if (!domain) { - console.error( - "You're using a routing configuration with `localePrefix: 'as-needed'` in combination with `domains`. In order to compute a correct pathname, you need to provide a `domain` parameter.\n\nSee: https://next-intl.dev/docs/routing#domains-localeprefix-asneeded" - ); - } else { - // If a domain was provided, but it wasn't found in the routing - // configuration, this can be an indicator that the user is on - // localhost. In this case, we can simply use the domain-agnostic - // default locale. - } - } - } - - shouldPrefix = defaultLocale !== locale; + shouldPrefix = routing.domains + ? // Since locales are unique per domain, any locale that is a + // default locale of a domain doesn't require a prefix + !routing.domains.some((cur) => cur.defaultLocale === locale) + : locale !== routing.defaultLocale; } } diff --git a/packages/next-intl/src/routing/defineRouting.test.tsx b/packages/next-intl/src/routing/defineRouting.test.tsx index 40c4ac0dd..63d796c9a 100644 --- a/packages/next-intl/src/routing/defineRouting.test.tsx +++ b/packages/next-intl/src/routing/defineRouting.test.tsx @@ -1,4 +1,4 @@ -import {describe, it} from 'vitest'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import defineRouting from './defineRouting.js'; describe('defaultLocale', () => { @@ -57,7 +57,8 @@ describe('domains', () => { domains: [ { defaultLocale: 'en', - domain: 'example.com' + domain: 'example.com', + locales: ['en'] } ] }); @@ -71,7 +72,8 @@ describe('domains', () => { { // @ts-expect-error defaultLocale: 'es', - domain: 'example.com' + domain: 'example.com', + locales: ['en'] } ] }); @@ -103,6 +105,73 @@ describe('domains', () => { ] }); }); + + describe('validation', () => { + beforeEach(() => { + const originalConsoleWarn = console.warn; + console.warn = vi.fn(); + return () => { + console.warn = originalConsoleWarn; + }; + }); + + it('does not warn if locales are unique per domain', () => { + defineRouting({ + locales: ['en-US', 'en-CA', 'fr-CA', 'fr-FR'], + defaultLocale: 'en-US', + domains: [ + { + domain: 'us.example.com', + defaultLocale: 'en-US', + locales: ['en-US'] + }, + { + domain: 'ca.example.com', + defaultLocale: 'en-CA', + locales: ['en-CA', 'fr-CA'] + }, + { + domain: 'fr.example.com', + defaultLocale: 'fr-FR', + locales: ['fr-FR'] + } + ] + }); + + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('should warn if locales are not unique per domain', () => { + defineRouting({ + locales: ['en', 'fr'], + defaultLocale: 'en', + domains: [ + { + domain: 'us.example.com', + defaultLocale: 'en', + locales: ['en'] + }, + { + domain: 'ca.example.com', + defaultLocale: 'en', + locales: ['en', 'fr'] + }, + { + domain: 'fr.example.com', + defaultLocale: 'fr', + locales: ['fr'] + } + ] + }); + + expect(console.warn).toHaveBeenCalledWith( + 'Locales are expected to be unique per domain, but found overlap:\n' + + '- "en" is used by: us.example.com, ca.example.com\n' + + '- "fr" is used by: ca.example.com, fr.example.com\n' + + 'Please see https://next-intl.dev/docs/routing#domains' + ); + }); + }); }); describe('localePrefix', () => { diff --git a/packages/next-intl/src/routing/defineRouting.tsx b/packages/next-intl/src/routing/defineRouting.tsx index 4f0f8cf4a..181d921f1 100644 --- a/packages/next-intl/src/routing/defineRouting.tsx +++ b/packages/next-intl/src/routing/defineRouting.tsx @@ -19,5 +19,38 @@ export default function defineRouting< AppDomains > ) { + if (process.env.NODE_ENV !== 'production' && config.domains) { + validateUniqueLocalesPerDomain(config.domains); + } return config; } + +function validateUniqueLocalesPerDomain< + AppLocales extends Locales, + AppDomains extends DomainsConfig +>(domains: AppDomains) { + const domainsByLocale = new Map>(); + + for (const {domain, locales} of domains) { + for (const locale of locales) { + const localeDomains = domainsByLocale.get(locale) || new Set(); + localeDomains.add(domain); + domainsByLocale.set(locale, localeDomains); + } + } + + const duplicateLocaleMessages = Array.from(domainsByLocale.entries()) + .filter(([, localeDomains]) => localeDomains.size > 1) + .map( + ([locale, localeDomains]) => + `- "${locale}" is used by: ${Array.from(localeDomains).join(', ')}` + ); + + if (duplicateLocaleMessages.length > 0) { + console.warn( + 'Locales are expected to be unique per domain, but found overlap:\n' + + duplicateLocaleMessages.join('\n') + + '\nPlease see https://next-intl.dev/docs/routing#domains' + ); + } +} diff --git a/packages/next-intl/src/routing/types.test.tsx b/packages/next-intl/src/routing/types.test.tsx index 5ea870f51..c3ac97bc9 100644 --- a/packages/next-intl/src/routing/types.test.tsx +++ b/packages/next-intl/src/routing/types.test.tsx @@ -42,14 +42,7 @@ describe('LocalePrefix', () => { }); describe('DomainConfig', () => { - it('allows to handle all locales', () => { - const config: DomainConfig<['en', 'de']> = { - defaultLocale: 'en', - domain: 'example.com' - }; - }); - - it('allows to restrict locales', () => { + it('allows to define locales', () => { const config: DomainConfig<['en', 'de']> = { defaultLocale: 'en', domain: 'example.com', diff --git a/packages/next-intl/src/routing/types.tsx b/packages/next-intl/src/routing/types.tsx index 48929e150..107854779 100644 --- a/packages/next-intl/src/routing/types.tsx +++ b/packages/next-intl/src/routing/types.tsx @@ -46,8 +46,8 @@ export type DomainConfig = { /** The domain name (e.g. "example.com", "www.example.com" or "fr.example.com"). Note that the `x-forwarded-host` or alternatively the `host` header will be used to determine the requested domain. */ domain: string; - /** Optionally restrict which locales are available on this domain. */ - locales?: Array; + /** The locales that are available on this domain. */ + locales: Array; }; export type DomainsConfig = Array< From dea867bb6c5463758b4da7361cc4ec91cb4f9e08 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 24 Feb 2025 17:50:40 +0100 Subject: [PATCH 85/90] feat: Support partial `pathnames` (#1743) [`pathnames`](https://next-intl.dev/docs/routing#pathnames) can now be declared partially for convenience: ```tsx import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ locales: ['en-US', 'en-GB', 'de'], defaultLocale: 'en-US', pathnames: { '/': '/', '/about': { // ("/about" is used for en-US and en-UK) de: '/ueber-uns' } } }); ``` Resolves https://github.com/amannn/next-intl/issues/990 --- docs/src/pages/docs/routing.mdx | 16 ++--- docs/src/pages/docs/routing/navigation.mdx | 4 +- .../example-app-router/src/i18n/routing.ts | 1 - packages/next-intl/.size-limit.ts | 6 +- .../getAlternateLinksHeaderValue.test.tsx | 71 +++++++++++++++---- .../getAlternateLinksHeaderValue.tsx | 19 +++-- .../src/middleware/middleware.test.tsx | 40 ++++++++--- .../next-intl/src/middleware/middleware.tsx | 20 +++--- packages/next-intl/src/middleware/utils.tsx | 10 ++- .../next-intl/src/navigation/shared/utils.tsx | 26 ++++--- .../src/routing/defineRouting.test.tsx | 5 +- packages/next-intl/src/routing/types.tsx | 2 +- packages/next-intl/src/shared/utils.tsx | 13 +++- 13 files changed, 163 insertions(+), 70 deletions(-) diff --git a/docs/src/pages/docs/routing.mdx b/docs/src/pages/docs/routing.mdx index f5b750f7b..a5693fba4 100644 --- a/docs/src/pages/docs/routing.mdx +++ b/docs/src/pages/docs/routing.mdx @@ -171,8 +171,8 @@ Since you typically want to define these routes only once internally, you can us import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ - locales: ['en', 'de'], - defaultLocale: 'en', + locales: ['en-US', 'en-UK', 'de'], + defaultLocale: 'en-US', // The `pathnames` object holds pairs of internal and // external paths. Based on the locale, the external @@ -183,29 +183,25 @@ export const routing = defineRouting({ '/': '/', '/blog': '/blog', - // If locales use different paths, you can - // specify each external path per locale + // If some locales use different paths, you can + // specify the relevant external pathnames '/about': { - en: '/about', de: '/ueber-uns' }, // Dynamic params are supported via square brackets - '/news/[articleSlug]-[articleId]': { - en: '/news/[articleSlug]-[articleId]', - de: '/neuigkeiten/[articleSlug]-[articleId]' + '/news/[articleSlug]': { + de: '/neuigkeiten/[articleSlug]' }, // Static pathnames that overlap with dynamic segments // will be prioritized over the dynamic segment '/news/just-in': { - en: '/news/just-in', de: '/neuigkeiten/aktuell' }, // Also (optional) catch-all segments are supported '/categories/[...slug]': { - en: '/categories/[...slug]', de: '/kategorien/[...slug]' } } diff --git a/docs/src/pages/docs/routing/navigation.mdx b/docs/src/pages/docs/routing/navigation.mdx index a9c30ef58..ffa971e58 100644 --- a/docs/src/pages/docs/routing/navigation.mdx +++ b/docs/src/pages/docs/routing/navigation.mdx @@ -262,8 +262,8 @@ Note that if you're using the [`pathnames`](/docs/routing#pathnames) setting, th // When the user is on `/de/ueber-uns`, this will be `/about` const pathname = usePathname(); -// When the user is on `/de/neuigkeiten/produktneuheit-94812`, -// this will be `/news/[articleSlug]-[articleId]` +// When the user is on `/de/neuigkeiten/produktneuheit`, +// this will be `/news/[articleSlug]` const pathname = usePathname(); ``` diff --git a/examples/example-app-router/src/i18n/routing.ts b/examples/example-app-router/src/i18n/routing.ts index 9ad5090ad..a9e843435 100644 --- a/examples/example-app-router/src/i18n/routing.ts +++ b/examples/example-app-router/src/i18n/routing.ts @@ -7,7 +7,6 @@ export const routing = defineRouting({ pathnames: { '/': '/', '/pathnames': { - en: '/pathnames', de: '/pfadnamen' } } diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 8f94d1fbc..7ef60c18b 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -21,13 +21,13 @@ const config: SizeLimitConfig = [ name: "import {createNavigation} from 'next-intl/navigation' (react-client)", path: 'dist/esm/production/navigation.react-client.js', import: '{createNavigation}', - limit: '2.285 KB' + limit: '2.305 KB' }, { name: "import {createNavigation} from 'next-intl/navigation' (react-server)", path: 'dist/esm/production/navigation.react-server.js', import: '{createNavigation}', - limit: '3.055 KB' + limit: '3.075 KB' }, { name: "import * from 'next-intl/server' (react-client)", @@ -42,7 +42,7 @@ const config: SizeLimitConfig = [ { name: "import * from 'next-intl/middleware'", path: 'dist/esm/production/middleware.js', - limit: '9.355 KB' + limit: '9.505 KB' }, { name: "import * from 'next-intl/routing'", diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx index 66a512d0f..80fbaf1d0 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx @@ -103,7 +103,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( routing, request: getMockRequest('https://example.com/'), resolvedLocale: 'en', - localizedPathnames: pathnames['/'] + localizedPathnames: pathnames['/'], + internalTemplateName: '/' }).split(', ') ).toEqual([ `; rel="alternate"; hreflang="en"`, @@ -133,7 +135,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( routing, request: getMockRequest('https://example.com/de/ueber'), resolvedLocale: 'de', - localizedPathnames: pathnames['/about'] + localizedPathnames: pathnames['/about'], + internalTemplateName: '/about' }).split(', ') ).toEqual([ `; rel="alternate"; hreflang="en"`, @@ -146,7 +149,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( routing, request: getMockRequest('https://example.com/users/2'), resolvedLocale: 'en', - localizedPathnames: pathnames['/users/[userId]'] + localizedPathnames: pathnames['/users/[userId]'], + internalTemplateName: '/users/[userId]' }).split(', ') ).toEqual([ `; rel="alternate"; hreflang="en"`, @@ -155,6 +159,35 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( ]); }); + it('works for partial pathnames with undefined entries', () => { + const routing = receiveRoutingConfig({ + defaultLocale: 'en', + locales: ['en', 'de', 'ja'], + localePrefix: 'as-needed' + }); + const pathnames = { + '/': '/', + '/about': { + de: '/ueber' + } + }; + + expect( + getAlternateLinksHeaderValue({ + routing, + request: getMockRequest('https://example.com/about'), + resolvedLocale: 'en', + localizedPathnames: pathnames['/about'], + internalTemplateName: '/about' + }).split(', ') + ).toEqual([ + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="de"`, + `; rel="alternate"; hreflang="ja"`, + `; rel="alternate"; hreflang="x-default"` + ]); + }); + it('works for prefixed routing (always)', () => { const routing = receiveRoutingConfig({ defaultLocale: 'en', @@ -404,25 +437,29 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( routing, request: getMockRequest('https://en.example.com/about'), resolvedLocale: 'en', - localizedPathnames: routing.pathnames!['/about'] + localizedPathnames: routing.pathnames!['/about'], + internalTemplateName: '/about' }), getAlternateLinksHeaderValue({ routing, request: getMockRequest('https://ca.example.com/about'), resolvedLocale: 'en', - localizedPathnames: routing.pathnames!['/about'] + localizedPathnames: routing.pathnames!['/about'], + internalTemplateName: '/about' }), getAlternateLinksHeaderValue({ routing, request: getMockRequest('https://ca.example.com/fr/a-propos'), resolvedLocale: 'fr', - localizedPathnames: routing.pathnames!['/about'] + localizedPathnames: routing.pathnames!['/about'], + internalTemplateName: '/about' }), getAlternateLinksHeaderValue({ routing, request: getMockRequest('https://fr.example.com/a-propos'), resolvedLocale: 'fr', - localizedPathnames: routing.pathnames!['/about'] + localizedPathnames: routing.pathnames!['/about'], + internalTemplateName: '/about' }) ] .map((links) => links.split(', ')) @@ -440,25 +477,29 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( routing, request: getMockRequest('https://en.example.com/users/42'), resolvedLocale: 'en', - localizedPathnames: routing.pathnames!['/users/[userId]'] + localizedPathnames: routing.pathnames!['/users/[userId]'], + internalTemplateName: '/users/[userId]' }), getAlternateLinksHeaderValue({ routing, request: getMockRequest('https://ca.example.com/users/42'), resolvedLocale: 'en', - localizedPathnames: routing.pathnames!['/users/[userId]'] + localizedPathnames: routing.pathnames!['/users/[userId]'], + internalTemplateName: '/users/[userId]' }), getAlternateLinksHeaderValue({ routing, request: getMockRequest('https://ca.example.com/fr/utilisateurs/42'), resolvedLocale: 'fr', - localizedPathnames: routing.pathnames!['/users/[userId]'] + localizedPathnames: routing.pathnames!['/users/[userId]'], + internalTemplateName: '/users/[userId]' }), getAlternateLinksHeaderValue({ routing, request: getMockRequest('https://fr.example.com/utilisateurs/42'), resolvedLocale: 'fr', - localizedPathnames: routing.pathnames!['/users/[userId]'] + localizedPathnames: routing.pathnames!['/users/[userId]'], + internalTemplateName: '/users/[userId]' }) ] .map((links) => links.split(', ')) @@ -601,7 +642,8 @@ describe('trailingSlash: true', () => { routing, request: new NextRequest(new URL('https://example.com' + pathname)), resolvedLocale: 'en', - localizedPathnames: pathnames['/about'] + localizedPathnames: pathnames['/about'], + internalTemplateName: '/about' }).split(', ') ).toEqual([ `; rel="alternate"; hreflang="en"`, @@ -618,7 +660,8 @@ describe('trailingSlash: true', () => { routing, request: new NextRequest(new URL('https://example.com' + pathname)), resolvedLocale: 'en', - localizedPathnames: pathnames['/'] + localizedPathnames: pathnames['/'], + internalTemplateName: '/' }).split(', ') ).toEqual([ `; rel="alternate"; hreflang="en"`, diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 738c76d9d..94b259ba6 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -25,6 +25,7 @@ export default function getAlternateLinksHeaderValue< AppPathnames extends Pathnames | undefined, AppDomains extends DomainsConfig | undefined >({ + internalTemplateName, localizedPathnames, request, resolvedLocale, @@ -42,6 +43,7 @@ export default function getAlternateLinksHeaderValue< request: NextRequest; resolvedLocale: AppLocales[number]; localizedPathnames?: Pathnames[string]; + internalTemplateName?: string; }) { const normalizedUrl = request.nextUrl.clone(); @@ -72,10 +74,12 @@ export default function getAlternateLinksHeaderValue< function getLocalizedPathname(pathname: string, locale: AppLocales[number]) { if (localizedPathnames && typeof localizedPathnames === 'object') { + const sourceTemplate = localizedPathnames[resolvedLocale]; + return formatTemplatePathname( pathname, - localizedPathnames[resolvedLocale], - localizedPathnames[locale] + sourceTemplate ?? internalTemplateName!, + localizedPathnames[locale] ?? internalTemplateName! ); } else { return pathname; @@ -145,11 +149,14 @@ export default function getAlternateLinksHeaderValue< // For domain-based routing there is no reasonable x-default !routing.domains || routing.domains.length === 0; if (shouldAddXDefault) { - const url = new URL( - getLocalizedPathname(normalizedUrl.pathname, routing.defaultLocale), - normalizedUrl + const localizedPathname = getLocalizedPathname( + normalizedUrl.pathname, + routing.defaultLocale ); - links.push(getAlternateEntry(url, 'x-default')); + if (localizedPathname) { + const url = new URL(localizedPathname, normalizedUrl); + links.push(getAlternateEntry(url, 'x-default')); + } } return links.join(', '); diff --git a/packages/next-intl/src/middleware/middleware.test.tsx b/packages/next-intl/src/middleware/middleware.test.tsx index 56f1e9ada..de123481d 100644 --- a/packages/next-intl/src/middleware/middleware.test.tsx +++ b/packages/next-intl/src/middleware/middleware.test.tsx @@ -426,74 +426,92 @@ describe('prefix-based routing', () => { pathnames: { '/': '/', '/about': { - en: '/about', de: '/ueber', 'de-AT': '/ueber', ja: '/約' }, '/users': { - en: '/users', de: '/benutzer', 'de-AT': '/benutzer', ja: '/ユーザー' }, '/users/[userId]': { - en: '/users/[userId]', de: '/benutzer/[userId]', 'de-AT': '/benutzer/[userId]', ja: '/ユーザー/[userId]' }, '/news/[articleSlug]-[articleId]': { - en: '/news/[articleSlug]-[articleId]', de: '/neuigkeiten/[articleSlug]-[articleId]', 'de-AT': '/neuigkeiten/[articleSlug]-[articleId]', ja: '/ニュース/[articleSlug]-[articleId]' }, '/articles/[category]/[articleSlug]': { - en: '/articles/[category]/[articleSlug]', de: '/artikel/[category]/[articleSlug]', 'de-AT': '/artikel/[category]/[articleSlug]', ja: '/記事/[category]/[articleSlug]' }, '/articles/[category]/just-in': { - en: '/articles/[category]/just-in', de: '/artikel/[category]/aktuell', 'de-AT': '/artikel/[category]/aktuell', ja: '/記事/[category]/最新' }, '/products/[...slug]': { - en: '/products/[...slug]', de: '/produkte/[...slug]', 'de-AT': '/produkte/[...slug]', ja: '/製品/[...slug]' }, '/products/[slug]': { - en: '/products/[slug]', de: '/produkte/[slug]', 'de-AT': '/produkte/[slug]', ja: '/製品/[slug]' }, '/products/add': { - en: '/products/add', de: '/produkte/hinzufuegen', 'de-AT': '/produkte/hinzufuegen', ja: '/製品/追加' }, '/categories/[[...slug]]': { - en: '/categories/[[...slug]]', de: '/kategorien/[[...slug]]', 'de-AT': '/kategorien/[[...slug]]', ja: '/カテゴリー/[[...slug]]' }, '/categories/new': { - en: '/categories/new', de: '/kategorien/neu', 'de-AT': '/kategorien/neu', ja: '/カテゴリー/新着' + }, + '/partially-available': { + de: '/teilweise-verfuegbar', + 'de-AT': '/teilweise-verfuegbar' + // (ja inherits en) } } satisfies Pathnames> }); + describe('partially available locales', () => { + it('serves requests for available locales', () => { + middlewareWithPathnames( + createMockRequest('/de/teilweise-verfuegbar', 'de') + ); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de/partially-available' + ); + }); + + it('uses the internal default for undefined entries', () => { + middlewareWithPathnames(createMockRequest('/partially-available')); + middlewareWithPathnames(createMockRequest('/ja/partially-available')); + expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(2); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en/partially-available' + ); + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/ja/partially-available' + ); + }); + }); + it('serves requests for the default locale at the root', () => { middlewareWithPathnames(createMockRequest('/', 'en')); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 77d9debad..cbb0608ae 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -9,6 +9,7 @@ import type { import {HEADER_LOCALE_NAME} from '../shared/constants.js'; import { getLocalePrefix, + getLocalizedTemplate, matchesPathname, normalizeTrailingSlash } from '../shared/utils.js'; @@ -180,10 +181,11 @@ export default function createMiddleware< if (internalTemplateName) { const pathnameConfig = pathnames[internalTemplateName]; - const localeTemplate: string = - typeof pathnameConfig === 'string' - ? pathnameConfig - : pathnameConfig[locale]; + const localeTemplate: string = getLocalizedTemplate( + pathnameConfig, + locale, + internalTemplateName + ); if (matchesPathname(localeTemplate, unprefixedExternalPathname)) { unprefixedInternalPathname = formatTemplatePathname( @@ -195,10 +197,11 @@ export default function createMiddleware< let sourceTemplate: string; if (resolvedTemplateLocale) { // A localized pathname from another locale has matched - sourceTemplate = - typeof pathnameConfig === 'string' - ? pathnameConfig - : pathnameConfig[resolvedTemplateLocale]; + sourceTemplate = getLocalizedTemplate( + pathnameConfig, + resolvedTemplateLocale, + internalTemplateName + ); } else { // An internal pathname has matched that // doesn't have a localized pathname @@ -320,6 +323,7 @@ export default function createMiddleware< 'Link', getAlternateLinksHeaderValue({ routing: resolvedRouting, + internalTemplateName, localizedPathnames: internalTemplateName != null && pathnames ? pathnames[internalTemplateName] diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index e643d8689..26650dd06 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -9,6 +9,7 @@ import type { } from '../routing/types.js'; import { getLocalePrefix, + getLocalizedTemplate, getSortedPathnames, matchesPathname, normalizeTrailingSlash, @@ -49,8 +50,13 @@ export function getInternalTemplate< sortedEntries.unshift(sortedEntries.splice(curLocaleIndex, 1)[0]); } - for (const [entryLocale, entryPathname] of sortedEntries) { - if (matchesPathname(entryPathname as string, pathname)) { + for (const [entryLocale] of sortedEntries) { + const localizedTemplate = getLocalizedTemplate( + pathnames[internalPathname], + entryLocale, + internalPathname + ); + if (matchesPathname(localizedTemplate, pathname)) { return [entryLocale, internalPathname]; } } diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index f24b8c59c..6ff74f470 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -10,6 +10,7 @@ import type { } from '../../routing/types.js'; import { getLocalePrefix, + getLocalizedTemplate, getSortedPathnames, isLocalizableHref, matchesPathname, @@ -132,10 +133,10 @@ export function compileLocalizedPathname({ } function compilePath( - namedPath: Pathnames[keyof Pathnames] + namedPath: Pathnames[keyof Pathnames], + internalPathname: string ) { - const template = - typeof namedPath === 'string' ? namedPath : namedPath[locale]; + const template = getLocalizedTemplate(namedPath, locale, internalPathname); let compiled = template; if (params) { @@ -176,12 +177,12 @@ export function compileLocalizedPathname({ if (typeof pathname === 'string') { const namedPath = getNamedPath(pathname); - const compiled = compilePath(namedPath); + const compiled = compilePath(namedPath, pathname); return compiled; } else { - const {pathname: href, ...rest} = pathname; - const namedPath = getNamedPath(href); - const compiled = compilePath(namedPath); + const {pathname: internalPathname, ...rest} = pathname; + const namedPath = getNamedPath(internalPathname); + const compiled = compilePath(namedPath, internalPathname); const result: UrlObject = {...rest, pathname: compiled}; return result; } @@ -203,7 +204,16 @@ export function getRoute( return internalPathname; } } else { - if (matchesPathname(localizedPathnamesOrPathname[locale], decoded)) { + if ( + matchesPathname( + getLocalizedTemplate( + localizedPathnamesOrPathname, + locale, + internalPathname + ), + decoded + ) + ) { return internalPathname; } } diff --git a/packages/next-intl/src/routing/defineRouting.test.tsx b/packages/next-intl/src/routing/defineRouting.test.tsx index 63d796c9a..1ad7c541d 100644 --- a/packages/next-intl/src/routing/defineRouting.test.tsx +++ b/packages/next-intl/src/routing/defineRouting.test.tsx @@ -35,14 +35,13 @@ describe('pathnames', () => { routing.pathnames['/about'].en; }); - it('ensures all locales have a value', () => { + it('accepts a partial config for only some locales', () => { defineRouting({ locales: ['en', 'de'], defaultLocale: 'en', pathnames: { - // @ts-expect-error -- Missing de '/about': { - en: '/about' + de: '/ueber-uns' } } }); diff --git a/packages/next-intl/src/routing/types.tsx b/packages/next-intl/src/routing/types.tsx index 107854779..9250db844 100644 --- a/packages/next-intl/src/routing/types.tsx +++ b/packages/next-intl/src/routing/types.tsx @@ -36,7 +36,7 @@ export type LocalePrefix< export type Pathnames = Record< Pathname, - Record | Pathname + Partial> | Pathname >; export type DomainConfig = { diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index ce891826f..082fd32a0 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -2,7 +2,8 @@ import type {LinkProps} from 'next/link.js'; import type { LocalePrefixConfigVerbose, LocalePrefixMode, - Locales + Locales, + Pathnames } from '../routing/types.js'; type Href = LinkProps['href']; @@ -58,6 +59,16 @@ function hasTrailingSlash() { } } +export function getLocalizedTemplate( + pathnameConfig: Pathnames[keyof Pathnames], + locale: AppLocales[number], + internalTemplate: string +) { + return typeof pathnameConfig === 'string' + ? pathnameConfig + : pathnameConfig[locale] || internalTemplate; +} + export function normalizeTrailingSlash(pathname: string) { const trailingSlash = hasTrailingSlash(); From 6c12292695b5667fab8944573a8e7116bf1e602a Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 28 Feb 2025 10:58:21 +0100 Subject: [PATCH 86/90] docs: Fix mdx in playground --- examples/example-app-router-playground/next.config.mjs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/examples/example-app-router-playground/next.config.mjs b/examples/example-app-router-playground/next.config.mjs index 28a35085e..b5b245ab7 100644 --- a/examples/example-app-router-playground/next.config.mjs +++ b/examples/example-app-router-playground/next.config.mjs @@ -8,7 +8,7 @@ const withNextIntl = createNextIntlPlugin({ createMessagesDeclaration: './messages/en.json' } }); -const withMdx = createMDX(); +const withMdx = createMDX({}); const withBundleAnalyzer = createBundleAnalyzer({ enabled: process.env.ANALYZE === 'true' }); @@ -21,13 +21,7 @@ const nextConfig = { trailingSlash: process.env.NEXT_PUBLIC_USE_CASE === 'trailing-slash', basePath: process.env.NEXT_PUBLIC_USE_CASE === 'base-path' ? '/base/path' : undefined, - experimental: { - staleTimes: { - // Next.js 14.2 broke `locale-prefix-never.spec.ts`. - // This is a workaround for the time being. - dynamic: 0 - } - } + pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'] }; export default withNextIntl(withMdx(withBundleAnalyzer(nextConfig))); From 7decc4cedfa99a62d516f02b7a60737da9f0961b Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 28 Feb 2025 13:04:51 +0100 Subject: [PATCH 87/90] docs: Delete redundant config in app router example --- examples/example-app-router/next.config.mjs | 14 -------------- examples/example-app-router/next.config.ts | 6 +++++- 2 files changed, 5 insertions(+), 15 deletions(-) delete mode 100644 examples/example-app-router/next.config.mjs diff --git a/examples/example-app-router/next.config.mjs b/examples/example-app-router/next.config.mjs deleted file mode 100644 index 1751fe61a..000000000 --- a/examples/example-app-router/next.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-check - -import createNextIntlPlugin from 'next-intl/plugin'; - -const withNextIntl = createNextIntlPlugin({ - experimental: { - createMessagesDeclaration: './messages/en.json' - } -}); - -/** @type {import('next').NextConfig} */ -const config = {}; - -export default withNextIntl(config); diff --git a/examples/example-app-router/next.config.ts b/examples/example-app-router/next.config.ts index a33fd0c8f..3a75d5282 100644 --- a/examples/example-app-router/next.config.ts +++ b/examples/example-app-router/next.config.ts @@ -1,7 +1,11 @@ import {NextConfig} from 'next'; import createNextIntlPlugin from 'next-intl/plugin'; -const withNextIntl = createNextIntlPlugin(); +const withNextIntl = createNextIntlPlugin({ + experimental: { + createMessagesDeclaration: './messages/en.json' + } +}); const config: NextConfig = {}; From bbb5b08e6b689c602790e311b746aceff8491063 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 11 Mar 2025 18:58:12 +0100 Subject: [PATCH 88/90] docs: Update blog post --- docs/src/pages/blog/_meta.tsx | 2 +- docs/src/pages/blog/index.mdx | 4 ++-- docs/src/pages/blog/next-intl-4-0.mdx | 28 +++++++++++++-------------- docs/src/pages/index.mdx | 4 ++++ docs/src/theme.config.tsx | 15 +++++++++++--- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/docs/src/pages/blog/_meta.tsx b/docs/src/pages/blog/_meta.tsx index d2289e251..badbdc76f 100644 --- a/docs/src/pages/blog/_meta.tsx +++ b/docs/src/pages/blog/_meta.tsx @@ -3,7 +3,7 @@ export default { title: 'Overview' }, 'next-intl-4-0': { - title: 'next-intl 4.0 beta', + title: 'next-intl 4.0', display: 'hidden' }, 'next-intl-3-22': { diff --git a/docs/src/pages/blog/index.mdx b/docs/src/pages/blog/index.mdx index 5bbe075db..736da7080 100644 --- a/docs/src/pages/blog/index.mdx +++ b/docs/src/pages/blog/index.mdx @@ -6,8 +6,8 @@ import StayUpdated from '@/components/StayUpdated.mdx';
Dec 23, 2024 · by Jan Amann +Mar 12, 2025 · by Jan Amann After a year of feature development, this release focuses on streamlining the API surface while maintaining the core architecture of `next-intl`. With many improvements already released in [previous minor versions](/blog/next-intl-3-22), this update introduces several enhancements that will improve your development experience and make working with internationalization even more seamless. @@ -44,7 +44,7 @@ declare module 'next-intl' { } ``` -See the updated [TypeScript augmentation](https://v4.next-intl.dev/docs/workflows/typescript) guide. +See the updated [TypeScript augmentation](/docs/workflows/typescript) guide. ## Strictly-typed locale @@ -65,7 +65,7 @@ declare module 'next-intl' { By doing so, APIs like `useLocale()` or `` that either return or receive a `locale` will now pick up your app-specific `Locale` type, improving type safety across your app. -To simplify narrowing of `string`-based locales, a `hasLocale` function has been added. This can for example be used in [`i18n/request.ts`](https://v4.next-intl.dev/docs/getting-started/app-router/with-i18n-routing#i18n-request) to return a valid locale: +To simplify narrowing of `string`-based locales, a `hasLocale` function has been added. This can for example be used in [`i18n/request.ts`](/docs/getting-started/app-router/with-i18n-routing#i18n-request) to return a valid locale: ```tsx import {getRequestConfig} from 'next-intl/server'; @@ -148,13 +148,13 @@ t('followers', {count: 30000}); "{count, number} followers" ``` -Due to a current limitation in TypeScript, this feature is opt-in for now. Please refer to the [strict arguments](https://v4.next-intl.dev/docs/workflows/typescript#messages-arguments) docs to learn how to enable it. +Due to a current limitation in TypeScript, this feature is opt-in for now. Please refer to the [strict arguments](/docs/workflows/typescript#messages-arguments) docs to learn how to enable it. ## GDPR compliance [#gdpr-compliance] In order to comply with the current GDPR regulations, the following changes have been made and are relevant to you if you're using the `next-intl` middleware for i18n routing: -1. The locale cookie now defaults to a session cookie that expires when a browser is closed. +1. The locale cookie now defaults to a session cookie that expires when the browser is closed. 2. The locale cookie is now only set when a user switches to a locale that doesn't match the `accept-language` header. If you want to increase the cookie expiration, e.g. because you're informing users about the usage of cookies or if GDPR doesn't apply to your app, you can use the `maxAge` attribute to do so: @@ -174,11 +174,11 @@ export const routing = defineRouting({ }); ``` -Since the cookie is now only available after a locale switch, make sure to not rely on it always being present. E.g. if you need access to the user's locale in a [Route Handler](https://v4.next-intl.dev/docs/environments/actions-metadata-route-handlers#route-handlers), a reliable option is to provide the locale as a search param (e.g. `/api/posts/12?locale=en`). +Since the cookie is now only available after a locale switch, make sure to not rely on it always being present. E.g. if you need access to the user's locale in a [Route Handler](/docs/environments/actions-metadata-route-handlers#route-handlers), a reliable option is to provide the locale as a search param (e.g. `/api/posts/12?locale=en`). -As part of this change, disabling a cookie now requires you to set [`localeCookie: false`](https://v4.next-intl.dev/docs/routing#locale-cookie) in your routing configuration. Previously, `localeDetection: false` ambiguously also disabled the cookie from being set, but since a separate `localeCookie` option was introduced recently, this should now be used instead. +As part of this change, disabling a cookie now requires you to set [`localeCookie: false`](/docs/routing#locale-cookie) in your routing configuration. Previously, `localeDetection: false` ambiguously also disabled the cookie from being set, but since a separate `localeCookie` option was introduced recently, this should now be used instead. -Learn more in the [locale cookie](https://v4.next-intl.dev/docs/routing#locale-cookie) docs. +Learn more in the [locale cookie](/docs/routing#locale-cookie) docs. ## Modernized build output @@ -266,7 +266,7 @@ This will create the following structure: - `example.no`: `no-NO` - `example.no/en`: `en-NO` -Learn more in the updated docs for [`domains`](https://v4.next-intl.dev/docs/routing#domains). +Learn more in the updated docs for [`domains`](/docs/routing#domains). ## Preparation for upcoming Next.js features [#nextjs-future] @@ -300,11 +300,9 @@ For a smooth upgrade, please initially upgrade to the latest v3.x version and ch Afterwards, you can upgrade by running: ``` -npm install next-intl@v4-beta +npm install next-intl@4 ``` -The beta docs are available here: [v4.next-intl.dev](https://v4.next-intl.dev) - I'd love to hear about your experiences with `next-intl@4.0`! Join the conversation in the [discussions](https://github.com/amannn/next-intl/discussions/1631). ## Thank you! @@ -315,6 +313,8 @@ A special thank you goes to Crow —Jan +(this post has been updated from an initial announcement for the 3.0 release candidate) + PS: Have you heard that [learn.next-intl.dev](https://learn.next-intl.dev) is coming? diff --git a/docs/src/pages/index.mdx b/docs/src/pages/index.mdx index d219d0aaf..9aee3b5d6 100644 --- a/docs/src/pages/index.mdx +++ b/docs/src/pages/index.mdx @@ -20,6 +20,10 @@ import GetStartedBackground from '@/components/GetStartedBackground'; description="Support multiple languages, with your app code becoming simpler instead of more complex." getStarted="Get started" viewExample="View an example" + announcement={{ + href: '/blog/next-intl-4-0', + label: 'next-intl 4.0 is out now!' + }} /> diff --git a/docs/src/theme.config.tsx b/docs/src/theme.config.tsx index 769a9158e..63bf9b886 100644 --- a/docs/src/theme.config.tsx +++ b/docs/src/theme.config.tsx @@ -27,12 +27,21 @@ export default { pre: Pre }, banner: { + key: 'banner-learn-next-intl', content: (
- You‘re viewing the next-intl 4 beta docs + Announcing{' '} + + learn.next-intl.dev + + !
- ), - dismissible: false + ) }, footer: { component: Footer From 6e3952f58af47c2ff6ad59c2127573a0c575eb24 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 12 Mar 2025 11:45:01 +0100 Subject: [PATCH 89/90] docs: Version selector --- docs/src/components/Footer.tsx | 3 +++ docs/src/components/FooterVersionSelector.tsx | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 docs/src/components/FooterVersionSelector.tsx diff --git a/docs/src/components/Footer.tsx b/docs/src/components/Footer.tsx index 81b28f852..8e51c832a 100644 --- a/docs/src/components/Footer.tsx +++ b/docs/src/components/Footer.tsx @@ -2,6 +2,7 @@ import {useRouter} from 'next/router'; import config from '@/config'; import FooterLink from './FooterLink'; import FooterSeparator from './FooterSeparator'; +import FooterVersionSelector from './FooterVersionSelector'; export default function Footer() { const router = useRouter(); @@ -19,6 +20,8 @@ export default function Footer() { Examples Blog + +
diff --git a/docs/src/components/FooterVersionSelector.tsx b/docs/src/components/FooterVersionSelector.tsx new file mode 100644 index 000000000..ee21de8c8 --- /dev/null +++ b/docs/src/components/FooterVersionSelector.tsx @@ -0,0 +1,19 @@ +import {ChangeEvent} from 'react'; + +export default function FooterVersionSelector() { + function onChange(event: ChangeEvent) { + const version = event.target.value; + window.location.href = `https://${version}.next-intl.dev`; + } + + return ( + + ); +} From 7bfd643a2a98d79bf8368f752057179fd9683608 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 12 Mar 2025 12:21:17 +0100 Subject: [PATCH 90/90] chore: Remove v4 prerelease workflow --- .github/workflows/prerelease-v4.yml | 33 ----------------------------- CONTRIBUTORS.md | 2 +- 2 files changed, 1 insertion(+), 34 deletions(-) delete mode 100644 .github/workflows/prerelease-v4.yml diff --git a/.github/workflows/prerelease-v4.yml b/.github/workflows/prerelease-v4.yml deleted file mode 100644 index adfba9583..000000000 --- a/.github/workflows/prerelease-v4.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: prerelease (v4) - -on: - push: - branches: - - v4 - -jobs: - main: - runs-on: ubuntu-latest - permissions: - contents: write - id-token: write - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - registry-url: 'https://registry.npmjs.org' - node-version: 20.x - cache: 'pnpm' - - run: pnpm install - - run: | - git config --global user.name "${{ github.actor }}" - git config --global user.email "${{ github.actor }}@users.noreply.github.com" - - run: | - sed -i 's/"use-intl": "workspace:\^"/"use-intl": "workspace:"/' ./packages/next-intl/package.json && git commit -am "use fixed version" - - run: pnpm lerna publish 4.0.0-beta-${GITHUB_SHA::7} --no-git-reset --dist-tag v4-beta --no-push --yes - if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ') || startsWith(github.event.head_commit.message, 'feat!: ')}}" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_CONFIG_PROVENANCE: true diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 8a14edf8d..924523876 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -86,7 +86,7 @@ Note that the exclamation mark syntax (`!`) for indicating breaking changes is c Other prefixes that are allowed and will _not_ create a release are the following: -1. `docs`: Documentation-only changes +1. `docs`: Documentation-only changes and updated examples 2. `test`: Missing tests were added or existing ones corrected 3. `ci`: Changes to CI configuration files and scripts 4. `build`: Changes that affect the build system or external dependencies