From 06c43e2e14e09047468b3bf8934b3ff9fb93e972 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 23 May 2024 11:30:55 +0200 Subject: [PATCH 01/45] Add support in middleware --- .../middleware/NextIntlMiddlewareConfig.tsx | 12 +- .../getAlternateLinksHeaderValue.tsx | 18 +- .../next-intl/src/middleware/middleware.tsx | 21 +- .../src/middleware/resolveLocale.tsx | 41 ++-- packages/next-intl/src/middleware/utils.tsx | 111 +++++++--- .../test/middleware/middleware.test.tsx | 203 +++++++++++++++++- 6 files changed, 340 insertions(+), 66 deletions(-) diff --git a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx index 72396d3be..b8939598d 100644 --- a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx +++ b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx @@ -1,8 +1,18 @@ import {AllLocales, LocalePrefix, Pathnames} from '../shared/types'; +export type RoutingLocales = Array< + | Locales[number] + | { + /** The locale code available internally (e.g. `/en-gb`) */ + locale: Locales[number]; + /** The prefix this locale should be available at (e.g. `/uk`) */ + prefix: string; + } +>; + type RoutingBaseConfig = { /** A list of all locales that are supported. */ - locales: Locales; + locales: RoutingLocales; /* Used by default if none of the defined locales match. */ defaultLocale: Locales[number]; diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index f0ce5aa2d..0254711b3 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -5,7 +5,9 @@ import { applyBasePath, formatTemplatePathname, getHost, + getLocale, getNormalizedPathname, + getPrefix, isLocaleSupportedOnDomain } from './utils'; @@ -61,12 +63,15 @@ export default function getAlternateLinksHeaderValue< } } - const links = config.locales.flatMap((locale) => { + const links = config.locales.flatMap((routingLocale) => { + const prefix = getPrefix(routingLocale); + const locale = getLocale(routingLocale); + function prefixPathname(pathname: string) { if (pathname === '/') { - return `/${locale}`; + return prefix; } else { - return `/${locale}${pathname}`; + return prefix + pathname; } } @@ -88,7 +93,7 @@ export default function getAlternateLinksHeaderValue< url.pathname = getLocalizedPathname(normalizedUrl.pathname, locale); if ( - locale !== domainConfig.defaultLocale || + routingLocale !== domainConfig.defaultLocale || config.localePrefix === 'always' ) { url.pathname = prefixPathname(url.pathname); @@ -104,7 +109,10 @@ export default function getAlternateLinksHeaderValue< pathname = normalizedUrl.pathname; } - if (locale !== config.defaultLocale || config.localePrefix === 'always') { + if ( + routingLocale !== config.defaultLocale || + config.localePrefix === 'always' + ) { pathname = prefixPathname(pathname); } url = new URL(pathname, normalizedUrl); diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index ce96b53df..34c89832f 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -16,11 +16,12 @@ import { getInternalTemplate, formatTemplatePathname, getBestMatchingDomain, - getPathnameLocale, + getPathnameMatch, getNormalizedPathname, getPathWithSearch, isLocaleSupportedOnDomain, - applyBasePath + applyBasePath, + normalizeTrailingSlash } from './utils'; const ROOT_URL = '/'; @@ -88,7 +89,7 @@ export default function createMiddleware( } function redirect(url: string, redirectDomain?: string) { - const urlObj = new URL(url, request.url); + const urlObj = new URL(normalizeTrailingSlash(url), request.url); if (domainConfigs.length > 0) { if (!redirectDomain) { @@ -142,11 +143,11 @@ export default function createMiddleware( configWithDefaults.locales ); - const pathLocale = getPathnameLocale( + const pathnameMatch = getPathnameMatch( nextPathname, configWithDefaults.locales ); - const hasLocalePrefix = pathLocale != null; + const hasLocalePrefix = pathnameMatch != null; let response; let internalTemplateName: string | undefined; @@ -173,7 +174,7 @@ export default function createMiddleware( normalizedPathname, localeTemplate, internalTemplateName, - pathLocale + pathnameMatch?.locale ); } else { let sourceTemplate; @@ -240,7 +241,7 @@ export default function createMiddleware( if (configWithDefaults.localePrefix === 'never') { response = redirect(normalizedPathnameWithSearch); - } else if (pathLocale === locale) { + } else if (pathnameMatch.exact) { if ( hasMatchedDefaultLocale && configWithDefaults.localePrefix === 'as-needed' @@ -250,7 +251,7 @@ export default function createMiddleware( if (configWithDefaults.domains) { const pathDomain = getBestMatchingDomain( domain, - pathLocale, + pathnameMatch.locale, domainConfigs ); @@ -267,7 +268,9 @@ export default function createMiddleware( } } } else { - response = redirect(`/${locale}${normalizedPathnameWithSearch}`); + response = redirect( + pathnameMatch.prefix + normalizedPathnameWithSearch + ); } } else { if ( diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index f96b59dff..b9f9d2e11 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -5,12 +5,13 @@ import {COOKIE_LOCALE_NAME} from '../shared/constants'; import {AllLocales} from '../shared/types'; import { DomainConfig, - MiddlewareConfigWithDefaults + MiddlewareConfigWithDefaults, + RoutingLocales } from './NextIntlMiddlewareConfig'; import { - findCaseInsensitiveLocale, - getFirstPathnameSegment, getHost, + getLocales, + getPathnameMatch, isLocaleSupportedOnDomain } from './utils'; @@ -32,7 +33,7 @@ function findDomainFromHost( export function getAcceptLanguageLocale( requestHeaders: Headers, - locales: Locales, + routingLocales: RoutingLocales, defaultLocale: string ) { let locale; @@ -43,11 +44,8 @@ export function getAcceptLanguageLocale( } }).languages(); try { - locale = match( - languages, - locales as unknown as Array, - defaultLocale - ); + const locales = getLocales(routingLocales); + locale = match(languages, locales, defaultLocale); } catch (e) { // Invalid language } @@ -55,20 +53,13 @@ export function getAcceptLanguageLocale( return locale; } -function getLocaleFromPrefix( - pathname: string, - locales: Locales -) { - const pathLocaleCandidate = getFirstPathnameSegment(pathname); - return findCaseInsensitiveLocale(pathLocaleCandidate, locales); -} - function getLocaleFromCookie( requestCookies: RequestCookies, - locales: Locales + routingLocales: RoutingLocales ) { if (requestCookies.has(COOKIE_LOCALE_NAME)) { const value = requestCookies.get(COOKIE_LOCALE_NAME)?.value; + const locales = getLocales(routingLocales); if (value && locales.includes(value)) { return value; } @@ -79,7 +70,7 @@ function resolveLocaleFromPrefix( { defaultLocale, localeDetection, - locales + locales: routingLocales }: Pick< MiddlewareConfigWithDefaults, 'defaultLocale' | 'localeDetection' | 'locales' @@ -92,17 +83,21 @@ function resolveLocaleFromPrefix( // Prio 1: Use route prefix if (pathname) { - locale = getLocaleFromPrefix(pathname, locales); + locale = getPathnameMatch(pathname, routingLocales)?.locale; } // Prio 2: Use existing cookie if (!locale && localeDetection && requestCookies) { - locale = getLocaleFromCookie(requestCookies, locales); + locale = getLocaleFromCookie(requestCookies, routingLocales); } // Prio 3: Use the `accept-language` header if (!locale && localeDetection && requestHeaders) { - locale = getAcceptLanguageLocale(requestHeaders, locales, defaultLocale); + locale = getAcceptLanguageLocale( + requestHeaders, + routingLocales, + defaultLocale + ); } // Prio 4: Use default locale @@ -137,7 +132,7 @@ function resolveLocaleFromDomain( // Prio 1: Use route prefix if (pathname) { - const prefixLocale = getLocaleFromPrefix(pathname, config.locales); + const prefixLocale = getPathnameMatch(pathname, config.locales)?.locale; if (prefixLocale) { if (isLocaleSupportedOnDomain(prefixLocale, domain)) { locale = prefixLocale; diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 9db77f053..6204902ed 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -2,7 +2,8 @@ import {AllLocales} from '../shared/types'; import {matchesPathname, templateToRegex} from '../shared/utils'; import { DomainConfig, - MiddlewareConfigWithDefaults + MiddlewareConfigWithDefaults, + RoutingLocales } from './NextIntlMiddlewareConfig'; export function getFirstPathnameSegment(pathname: string) { @@ -123,12 +124,12 @@ export function formatTemplatePathname( sourcePathname: string, sourceTemplate: string, targetTemplate: string, - localePrefix?: string + prefix?: string ) { const params = getRouteParams(sourceTemplate, sourcePathname); let targetPathname = ''; - if (localePrefix) { - targetPathname = `/${localePrefix}`; + if (prefix) { + targetPathname = `/${prefix}`; } targetPathname += formatPathname(targetTemplate, params); @@ -137,12 +138,40 @@ export function formatTemplatePathname( return targetPathname; } +export function getPrefix( + routingLocale: RoutingLocales[number] +) { + return typeof routingLocale === 'string' + ? '/' + routingLocale + : routingLocale.prefix; +} + +export function getPrefixes( + routingLocales: RoutingLocales +) { + return routingLocales.map((routingLocale) => getPrefix(routingLocale)); +} + +export function getLocale( + routingLocale: RoutingLocales[number] +) { + return typeof routingLocale === 'string' + ? routingLocale + : routingLocale.locale; +} + +export function getLocales( + routingLocales: RoutingLocales +) { + return routingLocales.map((routingLocale) => getLocale(routingLocale)); +} + /** - * Removes potential locales from the pathname. + * Removes potential prefixes from the pathname. */ export function getNormalizedPathname( pathname: string, - locales: Locales + locales: RoutingLocales ) { // Add trailing slash for consistent handling // both for the root as well as nested paths @@ -150,11 +179,16 @@ export function getNormalizedPathname( pathname += '/'; } - const match = pathname.match( - new RegExp(`^/(${locales.join('|')})/(.*)`, 'i') + const prefixes = getPrefixes(locales); + const regex = new RegExp( + `^(${prefixes + .map((prefix) => prefix.replaceAll('/', '\\/')) + .join('|')})/(.*)`, + 'i' ); - let result = match ? '/' + match[2] : pathname; + const match = pathname.match(regex); + let result = match ? '/' + match[2] : pathname; if (result !== '/') { result = normalizeTrailingSlash(result); } @@ -162,24 +196,51 @@ export function getNormalizedPathname( return result; } -export function findCaseInsensitiveLocale( +export function findCaseInsensitiveString( candidate: string, - locales: Locales + strings: Array ) { - return locales.find( - (locale) => locale.toLowerCase() === candidate.toLowerCase() - ); + return strings.find((cur) => cur.toLowerCase() === candidate.toLowerCase()); } -export function getPathnameLocale( +export function getPathnameMatch( pathname: string, - locales: Locales -): Locales[number] | undefined { - const pathLocaleCandidate = getFirstPathnameSegment(pathname); - const pathLocale = findCaseInsensitiveLocale(pathLocaleCandidate, locales) - ? pathLocaleCandidate - : undefined; - return pathLocale; + routingLocales: RoutingLocales +): + | { + locale: Locales[number]; + prefix: string; + matchedPrefix: string; + exact?: boolean; + } + | undefined { + for (const routingLocale of routingLocales) { + const prefix = getPrefix(routingLocale); + + let exact, matches; + if (pathname === prefix || pathname.startsWith(prefix + '/')) { + exact = matches = true; + } else { + const normalizedPathname = pathname.toLowerCase(); + const normalizedPrefix = prefix.toLowerCase(); + if ( + normalizedPathname === normalizedPrefix || + normalizedPathname.startsWith(normalizedPrefix + '/') + ) { + exact = false; + matches = true; + } + } + + if (matches) { + return { + locale: getLocale(routingLocale), + prefix, + matchedPrefix: pathname.slice(0, prefix.length), + exact + }; + } + } } export function getRouteParams(template: string, pathname: string) { @@ -235,7 +296,7 @@ export function isLocaleSupportedOnDomain( return ( domain.defaultLocale === locale || !domain.locales || - domain.locales.includes(locale) + getLocales(domain.locales).includes(locale) ); } @@ -280,8 +341,8 @@ export function applyBasePath(pathname: string, basePath: string) { return normalizeTrailingSlash(basePath + pathname); } -function normalizeTrailingSlash(pathname: string) { - if (pathname.endsWith('/')) { +export function normalizeTrailingSlash(pathname: string) { + if (pathname !== '/' && pathname.endsWith('/')) { pathname = pathname.slice(0, -1); } return pathname; diff --git a/packages/next-intl/test/middleware/middleware.test.tsx b/packages/next-intl/test/middleware/middleware.test.tsx index 4a6ad696b..7264c10e0 100644 --- a/packages/next-intl/test/middleware/middleware.test.tsx +++ b/packages/next-intl/test/middleware/middleware.test.tsx @@ -618,7 +618,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en/' + 'http://localhost:3000/en' ); }); @@ -638,7 +638,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/de-AT/' + 'http://localhost:3000/de-AT' ); }); @@ -658,7 +658,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/de-AT/' + 'http://localhost:3000/de-AT' ); }); @@ -1390,6 +1390,120 @@ describe('prefix-based routing', () => { ); }); }); + + describe('custom prefixes', () => { + const middlewareWithPrefixes = createIntlMiddleware({ + defaultLocale: 'en', + locales: [ + 'en', + {locale: 'en-gb', prefix: '/uk'}, + {locale: 'de-at', prefix: '/de/at'}, + {locale: 'pt', prefix: '/br'} + ], + localePrefix: 'always', + pathnames: { + '/': '/', + '/about': { + en: '/about', + 'de-at': '/ueber', + 'en-gb': '/about', + pt: '/sobre' + } + } satisfies Pathnames> + }); + + it('serves requests for the default locale at the root', () => { + middlewareWithPrefixes(createMockRequest('/en', 'en')); + 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/en' + ); + }); + + it('serves requests for a prefixed locale at the root', () => { + middlewareWithPrefixes(createMockRequest('/uk')); + middlewareWithPrefixes(createMockRequest('/de/at')); + 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/en-gb' + ); + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/de-at' + ); + }); + + it('serves requests for the default locale at nested paths', () => { + middlewareWithPrefixes(createMockRequest('/en/about')); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en/about' + ); + }); + + it('serves requests for a prefixed locale at nested paths', () => { + middlewareWithPrefixes(createMockRequest('/uk')); + middlewareWithPrefixes(createMockRequest('/de/at')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en-gb' + ); + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/de-at' + ); + }); + + it('redirects requests for a case mismatch of a custom prefix', () => { + middlewareWithPrefixes(createMockRequest('/UK')); + middlewareWithPrefixes(createMockRequest('/de/AT')); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/uk' + ); + expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/de/at' + ); + }); + + it('sets alternate links', () => { + function getLinks(request: NextRequest) { + return middlewareWithPrefixes(request) + .headers.get('link') + ?.split(', '); + } + + ['/en', '/uk', '/de/at'].forEach((pathname) => { + expect(getLinks(createMockRequest(pathname))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en-gb"', + '; rel="alternate"; hreflang="de-at"', + '; rel="alternate"; hreflang="pt"', + '; rel="alternate"; hreflang="x-default"' + ]); + }); + + ['/en/about', '/uk/about', '/de/at/ueber'].forEach((pathname) => { + expect(getLinks(createMockRequest(pathname))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en-gb"', + '; rel="alternate"; hreflang="de-at"', + '; rel="alternate"; hreflang="pt"' + ]); + }); + + expect(getLinks(createMockRequest('/en/unknown'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en-gb"', + '; rel="alternate"; hreflang="de-at"', + '; rel="alternate"; hreflang="pt"' + ]); + }); + }); }); describe('localePrefix: never', () => { @@ -2602,6 +2716,89 @@ describe('domain-based routing', () => { ); }); }); + + describe('custom prefixes', () => { + const middlewareWithPrefixes = createIntlMiddleware({ + defaultLocale: 'en', + locales: ['en', {locale: 'en-gb', prefix: '/uk'}], + localePrefix: 'as-needed', + pathnames: { + '/': '/', + '/about': { + en: '/about', + 'en-gb': '/about' + } + } satisfies Pathnames> + }); + + it('serves requests for the default locale at the root', () => { + middlewareWithPrefixes(createMockRequest('/', 'en')); + 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/en' + ); + }); + + it('serves requests for a prefixed locale at the root', () => { + middlewareWithPrefixes(createMockRequest('/uk')); + 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/en-gb' + ); + }); + + it('serves requests for the default locale at nested paths', () => { + middlewareWithPrefixes(createMockRequest('/about', 'en')); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en/about' + ); + }); + + it('serves requests for a prefixed locale at nested paths', () => { + middlewareWithPrefixes(createMockRequest('/uk')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en-gb' + ); + }); + + it('sets alternate links', () => { + function getLinks(request: NextRequest) { + return middlewareWithPrefixes(request) + .headers.get('link') + ?.split(', '); + } + + ['/en', '/uk'].forEach((pathname) => { + expect(getLinks(createMockRequest(pathname))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en-gb"', + '; rel="alternate"; hreflang="x-default"' + ]); + }); + + ['/en/about', '/uk/about'].forEach((pathname) => { + expect(getLinks(createMockRequest(pathname))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en-gb"', + '; rel="alternate"; hreflang="x-default"' + ]); + }); + + expect(getLinks(createMockRequest('/en/unknown'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en-gb"', + '; rel="alternate"; hreflang="x-default"' + ]); + }); + }); }); describe("localePrefix: 'always'", () => { From 477bdda130bafe8dfb347c41d3a9a450ddc13410 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 23 May 2024 11:59:35 +0200 Subject: [PATCH 02/45] Fix types --- packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx index b8939598d..b627bd9e2 100644 --- a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx +++ b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx @@ -1,6 +1,6 @@ import {AllLocales, LocalePrefix, Pathnames} from '../shared/types'; -export type RoutingLocales = Array< +export type RoutingLocales = ReadonlyArray< | Locales[number] | { /** The locale code available internally (e.g. `/en-gb`) */ From 67aedf3e86e7b3ee43f82e07f37ae8ce84d7a955 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 23 May 2024 15:08:24 +0200 Subject: [PATCH 03/45] First implementation for shared pathnames nav, consolidate redirect implementations --- packages/next-intl/package.json | 2 +- .../middleware/NextIntlMiddlewareConfig.tsx | 17 ++-- .../src/middleware/resolveLocale.tsx | 5 +- packages/next-intl/src/middleware/utils.tsx | 5 +- .../navigation/react-client/ClientLink.tsx | 5 +- .../react-client/clientPermanentRedirect.tsx | 22 ----- .../react-client/clientRedirect.tsx | 22 ----- .../createLocalizedPathnamesNavigation.tsx | 5 +- .../createSharedPathnamesNavigation.tsx | 25 +++-- .../src/navigation/react-client/redirects.tsx | 28 ++++++ .../react-client/useBasePathname.tsx | 11 ++- .../navigation/react-client/useBaseRouter.tsx | 15 ++- .../navigation/react-server/ServerLink.tsx | 5 +- .../createLocalizedPathnamesNavigation.tsx | 3 +- .../createSharedPathnamesNavigation.tsx | 23 +++-- .../src/navigation/react-server/redirects.tsx | 16 +++ .../react-server/serverPermanentRedirect.tsx | 11 --- .../react-server/serverRedirect.tsx | 11 --- .../src/navigation/shared/BaseLink.tsx | 40 ++++++-- .../shared/basePermanentRedirect.tsx | 22 ----- .../src/navigation/shared/baseRedirect.tsx | 22 ----- .../src/navigation/shared/redirects.tsx | 34 +++++++ .../next-intl/src/navigation/shared/utils.tsx | 17 +++- packages/next-intl/src/shared/types.tsx | 10 ++ packages/next-intl/src/shared/utils.tsx | 33 ++++--- .../createSharedPathnamesNavigation.test.tsx | 99 +++++++++++++++++++ .../createSharedPathnamesNavigation.test.tsx | 23 +++++ .../react-client/useBasePathname.test.tsx | 3 +- .../shared/basePermanentRedirect.test.tsx | 81 --------------- .../navigation/shared/baseRedirect.test.tsx | 81 --------------- .../test/navigation/shared/redirects.test.tsx | 86 ++++++++++++++++ packages/next-intl/test/shared/utils.test.tsx | 24 ++--- 32 files changed, 445 insertions(+), 361 deletions(-) delete mode 100644 packages/next-intl/src/navigation/react-client/clientPermanentRedirect.tsx delete mode 100644 packages/next-intl/src/navigation/react-client/clientRedirect.tsx create mode 100644 packages/next-intl/src/navigation/react-client/redirects.tsx create mode 100644 packages/next-intl/src/navigation/react-server/redirects.tsx delete mode 100644 packages/next-intl/src/navigation/react-server/serverPermanentRedirect.tsx delete mode 100644 packages/next-intl/src/navigation/react-server/serverRedirect.tsx delete mode 100644 packages/next-intl/src/navigation/shared/basePermanentRedirect.tsx delete mode 100644 packages/next-intl/src/navigation/shared/baseRedirect.tsx create mode 100644 packages/next-intl/src/navigation/shared/redirects.tsx delete mode 100644 packages/next-intl/test/navigation/shared/basePermanentRedirect.test.tsx delete mode 100644 packages/next-intl/test/navigation/shared/baseRedirect.test.tsx create mode 100644 packages/next-intl/test/navigation/shared/redirects.test.tsx diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 032d90136..9973924e5 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -138,7 +138,7 @@ }, { "path": "dist/production/middleware.js", - "limit": "6.19 KB" + "limit": "6.34 KB" } ] } diff --git a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx index b627bd9e2..819b7a879 100644 --- a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx +++ b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx @@ -1,14 +1,9 @@ -import {AllLocales, LocalePrefix, Pathnames} from '../shared/types'; - -export type RoutingLocales = ReadonlyArray< - | Locales[number] - | { - /** The locale code available internally (e.g. `/en-gb`) */ - locale: Locales[number]; - /** The prefix this locale should be available at (e.g. `/uk`) */ - prefix: string; - } ->; +import { + AllLocales, + LocalePrefix, + Pathnames, + RoutingLocales +} from '../shared/types'; type RoutingBaseConfig = { /** A list of all locales that are supported. */ diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index b9f9d2e11..d2f6a301e 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -2,11 +2,10 @@ import {match} from '@formatjs/intl-localematcher'; import Negotiator from 'negotiator'; import {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; import {COOKIE_LOCALE_NAME} from '../shared/constants'; -import {AllLocales} from '../shared/types'; +import {AllLocales, RoutingLocales} from '../shared/types'; import { DomainConfig, - MiddlewareConfigWithDefaults, - RoutingLocales + MiddlewareConfigWithDefaults } from './NextIntlMiddlewareConfig'; import { getHost, diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 6204902ed..d85ce610a 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -1,9 +1,8 @@ -import {AllLocales} from '../shared/types'; +import {AllLocales, RoutingLocales} from '../shared/types'; import {matchesPathname, templateToRegex} from '../shared/utils'; import { DomainConfig, - MiddlewareConfigWithDefaults, - RoutingLocales + MiddlewareConfigWithDefaults } from './NextIntlMiddlewareConfig'; export function getFirstPathnameSegment(pathname: string) { diff --git a/packages/next-intl/src/navigation/react-client/ClientLink.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.tsx index 56ac7fbe4..0d5e9862a 100644 --- a/packages/next-intl/src/navigation/react-client/ClientLink.tsx +++ b/packages/next-intl/src/navigation/react-client/ClientLink.tsx @@ -1,6 +1,6 @@ import React, {ComponentProps, ReactElement, forwardRef} from 'react'; import useLocale from '../../react-client/useLocale'; -import {AllLocales} from '../../shared/types'; +import {AllLocales, RoutingLocales} from '../../shared/types'; import BaseLink from '../shared/BaseLink'; type Props = Omit< @@ -8,6 +8,7 @@ type Props = Omit< 'locale' > & { locale?: Locales[number]; + locales?: RoutingLocales; }; function ClientLink( @@ -16,7 +17,7 @@ function ClientLink( ) { const defaultLocale = useLocale(); const linkLocale = locale || defaultLocale; - return ; + return ref={ref} locale={linkLocale} {...rest} />; } /** diff --git a/packages/next-intl/src/navigation/react-client/clientPermanentRedirect.tsx b/packages/next-intl/src/navigation/react-client/clientPermanentRedirect.tsx deleted file mode 100644 index 417e74376..000000000 --- a/packages/next-intl/src/navigation/react-client/clientPermanentRedirect.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import useLocale from '../../react-client/useLocale'; -import {LocalePrefix, ParametersExceptFirst} from '../../shared/types'; -import basePermanentRedirect from '../shared/basePermanentRedirect'; - -export default function clientPermanentRedirect( - params: {localePrefix?: LocalePrefix; pathname: string}, - ...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) { - throw new Error( - process.env.NODE_ENV !== 'production' - ? '`permanentRedirect()` can only be called during render. To redirect in an event handler or similar, you can use `useRouter()` instead.' - : undefined - ); - } - - return basePermanentRedirect({...params, locale}, ...args); -} diff --git a/packages/next-intl/src/navigation/react-client/clientRedirect.tsx b/packages/next-intl/src/navigation/react-client/clientRedirect.tsx deleted file mode 100644 index da6fdbd4e..000000000 --- a/packages/next-intl/src/navigation/react-client/clientRedirect.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import useLocale from '../../react-client/useLocale'; -import {LocalePrefix, ParametersExceptFirst} from '../../shared/types'; -import baseRedirect from '../shared/baseRedirect'; - -export default function clientRedirect( - params: {localePrefix?: LocalePrefix; pathname: string}, - ...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) { - throw new Error( - process.env.NODE_ENV !== 'production' - ? '`redirect()` can only be called during render. To redirect in an event handler or similar, you can use `useRouter()` instead.' - : undefined - ); - } - - return baseRedirect({...params, locale}, ...args); -} diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx index 9d6156b87..4383d0b26 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx @@ -14,8 +14,7 @@ import { HrefOrUrlObjectWithParams } from '../shared/utils'; import ClientLink from './ClientLink'; -import clientPermanentRedirect from './clientPermanentRedirect'; -import clientRedirect from './clientRedirect'; +import {clientRedirect, clientPermanentRedirect} from './redirects'; import useBasePathname from './useBasePathname'; import useBaseRouter from './useBaseRouter'; @@ -142,7 +141,7 @@ export default function createLocalizedPathnamesNavigation< } function usePathname(): keyof PathnamesConfig { - const pathname = useBasePathname(); + const pathname = useBasePathname(opts.locales); 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 diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx index 76f1cba7b..5ed0d9411 100644 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx @@ -2,17 +2,17 @@ import React, {ComponentProps, ReactElement, forwardRef} from 'react'; import { AllLocales, LocalePrefix, - ParametersExceptFirst + ParametersExceptFirst, + RoutingLocales } from '../../shared/types'; import ClientLink from './ClientLink'; -import clientPermanentRedirect from './clientPermanentRedirect'; -import clientRedirect from './clientRedirect'; +import {clientRedirect, clientPermanentRedirect} from './redirects'; import useBasePathname from './useBasePathname'; import useBaseRouter from './useBaseRouter'; export default function createSharedPathnamesNavigation< Locales extends AllLocales ->(opts?: {locales?: Locales; localePrefix?: LocalePrefix}) { +>(opts?: {locales?: RoutingLocales; localePrefix?: LocalePrefix}) { type LinkProps = Omit< ComponentProps>, 'localePrefix' @@ -22,6 +22,7 @@ export default function createSharedPathnamesNavigation< ref={ref} localePrefix={opts?.localePrefix} + locales={opts?.locales} {...props} /> ); @@ -35,19 +36,27 @@ export default function createSharedPathnamesNavigation< pathname: string, ...args: ParametersExceptFirst ) { - return clientRedirect({...opts, pathname}, ...args); + return clientRedirect({...opts, pathname, locales: opts?.locales}, ...args); } function permanentRedirect( pathname: string, ...args: ParametersExceptFirst ) { - return clientPermanentRedirect({...opts, pathname}, ...args); + return clientPermanentRedirect( + {...opts, pathname, locales: opts?.locales}, + ...args + ); } function usePathname(): string { + const result = useBasePathname(opts?.locales); // @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 useBasePathname(); + return result; + } + + function useRouter() { + return useBaseRouter(opts?.locales); } return { @@ -55,6 +64,6 @@ export default function createSharedPathnamesNavigation< redirect, permanentRedirect, usePathname, - useRouter: useBaseRouter + useRouter }; } diff --git a/packages/next-intl/src/navigation/react-client/redirects.tsx b/packages/next-intl/src/navigation/react-client/redirects.tsx new file mode 100644 index 000000000..de2d29c87 --- /dev/null +++ b/packages/next-intl/src/navigation/react-client/redirects.tsx @@ -0,0 +1,28 @@ +import useLocale from '../../react-client/useLocale'; +import {ParametersExceptFirst} from '../../shared/types'; +import {baseRedirect, basePermanentRedirect} from '../shared/redirects'; + +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/useBasePathname.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx index f585b5efd..e0908fa32 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx @@ -3,7 +3,9 @@ import {usePathname as useNextPathname} from 'next/navigation'; import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; +import {AllLocales, RoutingLocales} from '../../shared/types'; import {hasPathnamePrefixed, unlocalizePathname} from '../../shared/utils'; +import {getLocalePrefix} from '../shared/utils'; /** * Returns the pathname without a potential locale prefix. @@ -18,7 +20,9 @@ import {hasPathnamePrefixed, unlocalizePathname} from '../../shared/utils'; * const pathname = usePathname(); * ``` */ -export default function useBasePathname(): string | null { +export default function useBasePathname( + locales?: RoutingLocales +): string | null { // The types aren't entirely correct here. Outside of Next.js // `useParams` can be called, but the return type is `null`. const pathname = useNextPathname() as ReturnType< @@ -29,11 +33,12 @@ export default function useBasePathname(): string | null { return useMemo(() => { if (!pathname) return pathname; - const isPathnamePrefixed = hasPathnamePrefixed(locale, pathname); + const prefix = getLocalePrefix(locale, locales); + const isPathnamePrefixed = hasPathnamePrefixed(prefix, pathname); const unlocalizedPathname = isPathnamePrefixed ? unlocalizePathname(pathname, locale) : pathname; return unlocalizedPathname; - }, [locale, pathname]); + }, [locale, locales, pathname]); } diff --git a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx index 3b2b50861..b5860f76d 100644 --- a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx +++ b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx @@ -1,10 +1,10 @@ import {useRouter as useNextRouter, usePathname} from 'next/navigation'; import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; -import {AllLocales} from '../../shared/types'; +import {AllLocales, RoutingLocales} from '../../shared/types'; import {localizeHref} from '../../shared/utils'; import syncLocaleCookie from '../shared/syncLocaleCookie'; -import {getBasePath} from '../shared/utils'; +import {getBasePath, getLocalePrefix} from '../shared/utils'; type IntlNavigateOptions = { locale?: Locales[number]; @@ -29,7 +29,9 @@ type IntlNavigateOptions = { * router.push('/about', {locale: 'de'}); * ``` */ -export default function useBaseRouter() { +export default function useBaseRouter( + locales?: RoutingLocales +) { const router = useNextRouter(); const locale = useLocale(); const pathname = usePathname(); @@ -41,7 +43,10 @@ export default function useBaseRouter() { const basePath = getBasePath(pathname); if (basePath) curPathname = curPathname.replace(basePath, ''); - return localizeHref(href, nextLocale || locale, locale, curPathname); + const targetLocale = nextLocale || locale; + const prefix = getLocalePrefix(targetLocale, locales); + + return localizeHref(href, targetLocale, locale, curPathname, prefix); } function createHandler< @@ -84,5 +89,5 @@ export default function useBaseRouter() { typeof router.prefetch >(router.prefetch) }; - }, [locale, pathname, router]); + }, [locale, locales, pathname, router]); } diff --git a/packages/next-intl/src/navigation/react-server/ServerLink.tsx b/packages/next-intl/src/navigation/react-server/ServerLink.tsx index d9c16a27a..9cdd824f4 100644 --- a/packages/next-intl/src/navigation/react-server/ServerLink.tsx +++ b/packages/next-intl/src/navigation/react-server/ServerLink.tsx @@ -1,6 +1,6 @@ import React, {ComponentProps} from 'react'; import {getLocale} from '../../server.react-server'; -import {AllLocales} from '../../shared/types'; +import {AllLocales, RoutingLocales} from '../../shared/types'; import BaseLink from '../shared/BaseLink'; type Props = Omit< @@ -8,11 +8,12 @@ type Props = Omit< 'locale' > & { locale?: Locales[number]; + locales?: RoutingLocales; }; export default async function ServerLink({ locale, ...rest }: Props) { - return ; + return locale={locale || (await getLocale())} {...rest} />; } diff --git a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx index 6e830d48b..b6ea5bcd5 100644 --- a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx @@ -13,8 +13,7 @@ import { normalizeNameOrNameWithParams } from '../shared/utils'; import ServerLink from './ServerLink'; -import serverPermanentRedirect from './serverPermanentRedirect'; -import serverRedirect from './serverRedirect'; +import {serverPermanentRedirect, serverRedirect} from './redirects'; export default function createLocalizedPathnamesNavigation< Locales extends AllLocales, diff --git a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx index 6d58c754b..585caf18d 100644 --- a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx @@ -2,15 +2,15 @@ import React, {ComponentProps} from 'react'; import { AllLocales, LocalePrefix, - ParametersExceptFirst + ParametersExceptFirst, + RoutingLocales } from '../../shared/types'; import ServerLink from './ServerLink'; -import serverPermanentRedirect from './serverPermanentRedirect'; -import serverRedirect from './serverRedirect'; +import {serverPermanentRedirect, serverRedirect} from './redirects'; export default function createSharedPathnamesNavigation< Locales extends AllLocales ->(opts?: {locales?: Locales; localePrefix?: LocalePrefix}) { +>(opts?: {locales?: RoutingLocales; localePrefix?: LocalePrefix}) { function notSupported(hookName: string) { return () => { throw new Error( @@ -20,21 +20,30 @@ export default function createSharedPathnamesNavigation< } function Link(props: ComponentProps>) { - return localePrefix={opts?.localePrefix} {...props} />; + return ( + + localePrefix={opts?.localePrefix} + locales={opts?.locales} + {...props} + /> + ); } function redirect( pathname: string, ...args: ParametersExceptFirst ) { - return serverRedirect({...opts, pathname}, ...args); + return serverRedirect({...opts, pathname, locales: opts?.locales}, ...args); } function permanentRedirect( pathname: string, ...args: ParametersExceptFirst ) { - return serverPermanentRedirect({...opts, pathname}, ...args); + return serverPermanentRedirect( + {...opts, pathname, locales: opts?.locales}, + ...args + ); } return { diff --git a/packages/next-intl/src/navigation/react-server/redirects.tsx b/packages/next-intl/src/navigation/react-server/redirects.tsx new file mode 100644 index 000000000..435e5dfa9 --- /dev/null +++ b/packages/next-intl/src/navigation/react-server/redirects.tsx @@ -0,0 +1,16 @@ +import {getRequestLocale} from '../../server/react-server/RequestLocale'; +import {ParametersExceptFirst} from '../../shared/types'; +import {baseRedirect, basePermanentRedirect} from '../shared/redirects'; + +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/react-server/serverPermanentRedirect.tsx b/packages/next-intl/src/navigation/react-server/serverPermanentRedirect.tsx deleted file mode 100644 index 32386b0a6..000000000 --- a/packages/next-intl/src/navigation/react-server/serverPermanentRedirect.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {getRequestLocale} from '../../server/react-server/RequestLocale'; -import {LocalePrefix, ParametersExceptFirst} from '../../shared/types'; -import basePermanentRedirect from '../shared/basePermanentRedirect'; - -export default function serverPermanentRedirect( - params: {pathname: string; localePrefix?: LocalePrefix}, - ...args: ParametersExceptFirst -) { - const locale = getRequestLocale(); - return basePermanentRedirect({...params, locale}, ...args); -} diff --git a/packages/next-intl/src/navigation/react-server/serverRedirect.tsx b/packages/next-intl/src/navigation/react-server/serverRedirect.tsx deleted file mode 100644 index d636a40dd..000000000 --- a/packages/next-intl/src/navigation/react-server/serverRedirect.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {getRequestLocale} from '../../server/react-server/RequestLocale'; -import {LocalePrefix, ParametersExceptFirst} from '../../shared/types'; -import baseRedirect from '../shared/baseRedirect'; - -export default function serverRedirect( - params: {pathname: string; localePrefix?: LocalePrefix}, - ...args: ParametersExceptFirst -) { - const locale = getRequestLocale(); - return baseRedirect({...params, locale}, ...args); -} diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index 4e37557f2..1ba52851c 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -5,24 +5,44 @@ import {usePathname} from 'next/navigation'; import React, { ComponentProps, MouseEvent, + ReactElement, forwardRef, useEffect, + useMemo, useState } from 'react'; import useLocale from '../../react-client/useLocale'; -import {LocalePrefix} from '../../shared/types'; +import {AllLocales, LocalePrefix, RoutingLocales} from '../../shared/types'; import {isLocalHref, localizeHref, prefixHref} from '../../shared/utils'; import syncLocaleCookie from './syncLocaleCookie'; +import {getLocalePrefix} from './utils'; -type Props = Omit, 'locale'> & { +type Props = Omit< + ComponentProps, + 'locale' +> & { locale: string; + locales?: RoutingLocales; localePrefix?: LocalePrefix; }; -function BaseLink( - {href, locale, localePrefix, onClick, prefetch, ...rest}: Props, - ref: Props['ref'] +function BaseLink( + { + href, + locale, + localePrefix, + locales, + onClick, + prefetch, + ...rest + }: Props, + ref: Props['ref'] ) { + const prefix = useMemo( + () => getLocalePrefix(locale, locales), + [locale, locales] + ); + // 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; @@ -42,7 +62,7 @@ function BaseLink( // 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, locale) + prefixHref(href, prefix) : href ); @@ -54,8 +74,8 @@ function BaseLink( useEffect(() => { if (!pathname) return; - setLocalizedHref(localizeHref(href, locale, curLocale, pathname)); - }, [curLocale, href, locale, pathname]); + setLocalizedHref(localizeHref(href, locale, curLocale, pathname, prefix)); + }, [curLocale, href, locale, pathname, prefix]); if (isChangingLocale) { if (prefetch && process.env.NODE_ENV !== 'production') { @@ -78,6 +98,8 @@ function BaseLink( ); } -const BaseLinkWithRef = forwardRef(BaseLink); +const BaseLinkWithRef = forwardRef(BaseLink) as ( + props: Props & {ref?: Props['ref']} +) => ReactElement; (BaseLinkWithRef as any).displayName = 'ClientLink'; export default BaseLinkWithRef; diff --git a/packages/next-intl/src/navigation/shared/basePermanentRedirect.tsx b/packages/next-intl/src/navigation/shared/basePermanentRedirect.tsx deleted file mode 100644 index 830b72e4f..000000000 --- a/packages/next-intl/src/navigation/shared/basePermanentRedirect.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import {permanentRedirect as nextPermanentRedirect} from 'next/navigation'; -import { - AllLocales, - LocalePrefix, - ParametersExceptFirst -} from '../../shared/types'; -import {isLocalHref, prefixPathname} from '../../shared/utils'; - -export default function basePermanentRedirect( - params: { - pathname: string; - locale: AllLocales[number]; - localePrefix?: LocalePrefix; - }, - ...args: ParametersExceptFirst -) { - const localizedPathname = - params.localePrefix === 'never' || !isLocalHref(params.pathname) - ? params.pathname - : prefixPathname(params.locale, params.pathname); - return nextPermanentRedirect(localizedPathname, ...args); -} diff --git a/packages/next-intl/src/navigation/shared/baseRedirect.tsx b/packages/next-intl/src/navigation/shared/baseRedirect.tsx deleted file mode 100644 index c2a83467d..000000000 --- a/packages/next-intl/src/navigation/shared/baseRedirect.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import {redirect as nextRedirect} from 'next/navigation'; -import { - AllLocales, - LocalePrefix, - ParametersExceptFirst -} from '../../shared/types'; -import {isLocalHref, prefixPathname} from '../../shared/utils'; - -export default function baseRedirect( - params: { - pathname: string; - locale: AllLocales[number]; - localePrefix?: LocalePrefix; - }, - ...args: ParametersExceptFirst -) { - const localizedPathname = - params.localePrefix === 'never' || !isLocalHref(params.pathname) - ? params.pathname - : prefixPathname(params.locale, params.pathname); - return nextRedirect(localizedPathname, ...args); -} diff --git a/packages/next-intl/src/navigation/shared/redirects.tsx b/packages/next-intl/src/navigation/shared/redirects.tsx new file mode 100644 index 000000000..ffb71010d --- /dev/null +++ b/packages/next-intl/src/navigation/shared/redirects.tsx @@ -0,0 +1,34 @@ +import { + permanentRedirect as nextPermanentRedirect, + redirect as nextRedirect +} from 'next/navigation'; +import { + AllLocales, + LocalePrefix, + ParametersExceptFirst, + RoutingLocales +} from '../../shared/types'; +import {isLocalHref, prefixPathname} from '../../shared/utils'; +import {getLocalePrefix} from './utils'; + +function createRedirectFn(redirectFn: typeof nextRedirect) { + return function baseRedirect( + params: { + pathname: string; + locale: AllLocales[number]; + localePrefix?: LocalePrefix; + locales?: RoutingLocales; + }, + ...args: ParametersExceptFirst + ) { + const prefix = getLocalePrefix(params.locale, params.locales); + const localizedPathname = + params.localePrefix === 'never' || !isLocalHref(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/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 8ac8aa5c2..9bd489152 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,6 +1,6 @@ import type {ParsedUrlQueryInput} from 'node:querystring'; import type {UrlObject} from 'url'; -import {AllLocales, Pathnames} from '../../shared/types'; +import {AllLocales, Pathnames, RoutingLocales} from '../../shared/types'; import {matchesPathname, unlocalizePathname} from '../../shared/utils'; import StrictParams from './StrictParams'; @@ -190,3 +190,18 @@ export function getBasePath( return windowPathname.replace(pathname, ''); } } + +export function getLocalePrefix( + locale: string, + locales?: RoutingLocales +) { + if (locales) { + for (const cur of locales) { + if (typeof cur !== 'string' && cur.locale === locale) { + return cur.prefix; + } + } + } + + return '/' + locale; +} diff --git a/packages/next-intl/src/shared/types.tsx b/packages/next-intl/src/shared/types.tsx index 590d4d1fb..1e4d18b00 100644 --- a/packages/next-intl/src/shared/types.tsx +++ b/packages/next-intl/src/shared/types.tsx @@ -7,6 +7,16 @@ export type Pathnames = Record< {[Key in Locales[number]]: string} | string >; +export type RoutingLocales = ReadonlyArray< + | Locales[number] + | { + /** The locale code available internally (e.g. `/en-gb`) */ + locale: Locales[number]; + /** The prefix this locale should be available at (e.g. `/uk`) */ + prefix: string; + } +>; + export type ParametersExceptFirst = Fn extends ( arg0: any, ...rest: infer R diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 3d2ba841e..64d36996c 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -22,49 +22,51 @@ export function localizeHref( href: string, locale: string, curLocale: string, - curPathname: string + curPathname: string, + prefix: string ): string; export function localizeHref( href: UrlObject | string, locale: string, curLocale: string, - curPathname: string + curPathname: string, + prefix: string ): UrlObject | string; export function localizeHref( href: UrlObject | string, locale: string, curLocale: string = locale, - curPathname: string + curPathname: string, + prefix: string ) { if (!isLocalHref(href) || isRelativeHref(href)) { return href; } const isSwitchingLocale = locale !== curLocale; - const isPathnamePrefixed = - locale == null || hasPathnamePrefixed(locale, curPathname); + const isPathnamePrefixed = hasPathnamePrefixed(prefix, curPathname); const shouldPrefix = isSwitchingLocale || isPathnamePrefixed; - if (shouldPrefix && locale != null) { - return prefixHref(href, locale); + if (shouldPrefix && prefix != null) { + return prefixHref(href, prefix); } return href; } -export function prefixHref(href: string, locale: string): string; +export function prefixHref(href: string, prefix: string): string; export function prefixHref( href: UrlObject | string, - locale: string + prefix: string ): UrlObject | string; -export function prefixHref(href: UrlObject | string, locale: string) { +export function prefixHref(href: UrlObject | string, prefix: string) { let prefixedHref; if (typeof href === 'string') { - prefixedHref = prefixPathname(locale, href); + prefixedHref = prefixPathname(prefix, href); } else { prefixedHref = {...href}; if (href.pathname) { - prefixedHref.pathname = prefixPathname(locale, href.pathname); + prefixedHref.pathname = prefixPathname(prefix, href.pathname); } } @@ -75,8 +77,8 @@ export function unlocalizePathname(pathname: string, locale: string) { return pathname.replace(new RegExp(`^/${locale}`), '') || '/'; } -export function prefixPathname(locale: string, pathname: string) { - let localizedHref = '/' + locale; +export function prefixPathname(prefix: string, pathname: string) { + let localizedHref = prefix; // Avoid trailing slashes if (/^\/(\?.*)?$/.test(pathname)) { @@ -88,8 +90,7 @@ export function prefixPathname(locale: string, pathname: string) { return localizedHref; } -export function hasPathnamePrefixed(locale: string, pathname: string) { - const prefix = `/${locale}`; +export function hasPathnamePrefixed(prefix: string, pathname: string) { return pathname === prefix || pathname.startsWith(`${prefix}/`); } diff --git a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx index 48d96037a..1d66b5d88 100644 --- a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx @@ -48,6 +48,11 @@ beforeEach(() => { const locales = ['en', 'de'] as const; +const localesWithCustomPrefixes = [ + 'en', + {locale: 'en-gb', prefix: '/uk'} +] as const; + describe.each([ {env: 'react-client', implementation: createSharedPathnamesNavigationClient}, {env: 'react-server', implementation: createSharedPathnamesNavigationServer} @@ -97,6 +102,43 @@ describe.each([ }); }); + describe("localePrefix: 'always', custom prefixes", () => { + const {Link} = createSharedPathnamesNavigation({ + locales: localesWithCustomPrefixes, + localePrefix: 'always' + }); + + 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("localePrefix: 'as-needed'", () => { const {Link, permanentRedirect, redirect} = createSharedPathnamesNavigation({ @@ -238,6 +280,63 @@ describe.each([ }); }); + describe("localePrefix: 'as-needed', custom prefixes", () => { + const {Link, permanentRedirect, redirect} = + createSharedPathnamesNavigation({ + locales: localesWithCustomPrefixes, + localePrefix: 'as-needed' + }); + + 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({ diff --git a/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx index 4665e8b4f..c1f543f0b 100644 --- a/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx @@ -100,6 +100,29 @@ describe("localePrefix: 'as-needed'", () => { } }); +describe("localePrefix: 'as-needed', custom prefix", () => { + const {useRouter} = createSharedPathnamesNavigation({ + locales: ['en', {locale: 'en-gb', prefix: '/uk'}], + localePrefix: 'as-needed' + }); + + 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("localePrefix: 'never'", () => { const {useRouter} = createSharedPathnamesNavigation({ locales, diff --git a/packages/next-intl/test/navigation/react-client/useBasePathname.test.tsx b/packages/next-intl/test/navigation/react-client/useBasePathname.test.tsx index ba8f74223..fed96e443 100644 --- a/packages/next-intl/test/navigation/react-client/useBasePathname.test.tsx +++ b/packages/next-intl/test/navigation/react-client/useBasePathname.test.tsx @@ -13,7 +13,8 @@ function mockPathname(pathname: string) { } function Component() { - return <>{useBasePathname()}; + const locales = ['en']; + return <>{useBasePathname(locales)}; } describe('unprefixed routing', () => { diff --git a/packages/next-intl/test/navigation/shared/basePermanentRedirect.test.tsx b/packages/next-intl/test/navigation/shared/basePermanentRedirect.test.tsx deleted file mode 100644 index 313b19071..000000000 --- a/packages/next-intl/test/navigation/shared/basePermanentRedirect.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import {permanentRedirect as nextPermanentRedirect} from 'next/navigation'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import basePermanentRedirect from '../../../src/navigation/shared/basePermanentRedirect'; - -vi.mock('next/navigation'); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("localePrefix: 'always'", () => { - describe('basePermanentRedirect', () => { - it('handles internal paths', () => { - basePermanentRedirect({ - pathname: '/test/path', - locale: 'en', - localePrefix: 'always' - }); - expect(nextPermanentRedirect).toHaveBeenCalledTimes(1); - expect(nextPermanentRedirect).toHaveBeenCalledWith('/en/test/path'); - }); - - it('handles external paths', () => { - basePermanentRedirect({ - pathname: 'https://example.com', - locale: 'en', - localePrefix: 'always' - }); - expect(nextPermanentRedirect).toHaveBeenCalledTimes(1); - expect(nextPermanentRedirect).toHaveBeenCalledWith('https://example.com'); - }); - }); -}); - -describe("localePrefix: 'as-needed'", () => { - describe('basePermanentRedirect', () => { - it('handles internal paths', () => { - basePermanentRedirect({ - pathname: '/test/path', - locale: 'en', - localePrefix: 'as-needed' - }); - expect(nextPermanentRedirect).toHaveBeenCalledTimes(1); - expect(nextPermanentRedirect).toHaveBeenCalledWith('/en/test/path'); - }); - - it('handles external paths', () => { - basePermanentRedirect({ - pathname: 'https://example.com', - locale: 'en', - localePrefix: 'as-needed' - }); - expect(nextPermanentRedirect).toHaveBeenCalledTimes(1); - expect(nextPermanentRedirect).toHaveBeenCalledWith('https://example.com'); - }); - }); -}); - -describe("localePrefix: 'never'", () => { - describe('basePermanentRedirect', () => { - it('handles internal paths', () => { - basePermanentRedirect({ - pathname: '/test/path', - locale: 'en', - localePrefix: 'never' - }); - expect(nextPermanentRedirect).toHaveBeenCalledTimes(1); - expect(nextPermanentRedirect).toHaveBeenCalledWith('/test/path'); - }); - - it('handles external paths', () => { - basePermanentRedirect({ - pathname: 'https://example.com', - locale: 'en', - localePrefix: 'never' - }); - expect(nextPermanentRedirect).toHaveBeenCalledTimes(1); - expect(nextPermanentRedirect).toHaveBeenCalledWith('https://example.com'); - }); - }); -}); diff --git a/packages/next-intl/test/navigation/shared/baseRedirect.test.tsx b/packages/next-intl/test/navigation/shared/baseRedirect.test.tsx deleted file mode 100644 index c0c842cde..000000000 --- a/packages/next-intl/test/navigation/shared/baseRedirect.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import {redirect as nextRedirect} from 'next/navigation'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import baseRedirect from '../../../src/navigation/shared/baseRedirect'; - -vi.mock('next/navigation'); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("localePrefix: 'always'", () => { - describe('basePermanentRedirect', () => { - it('handles internal paths', () => { - baseRedirect({ - pathname: '/test/path', - locale: 'en', - localePrefix: 'always' - }); - expect(nextRedirect).toHaveBeenCalledTimes(1); - expect(nextRedirect).toHaveBeenCalledWith('/en/test/path'); - }); - - it('handles external paths', () => { - baseRedirect({ - pathname: 'https://example.com', - locale: 'en', - localePrefix: 'always' - }); - expect(nextRedirect).toHaveBeenCalledTimes(1); - expect(nextRedirect).toHaveBeenCalledWith('https://example.com'); - }); - }); -}); - -describe("localePrefix: 'as-needed'", () => { - describe('baseRedirect', () => { - it('handles internal paths', () => { - baseRedirect({ - pathname: '/test/path', - locale: 'en', - localePrefix: 'as-needed' - }); - expect(nextRedirect).toHaveBeenCalledTimes(1); - expect(nextRedirect).toHaveBeenCalledWith('/en/test/path'); - }); - - it('handles external paths', () => { - baseRedirect({ - pathname: 'https://example.com', - locale: 'en', - localePrefix: 'as-needed' - }); - expect(nextRedirect).toHaveBeenCalledTimes(1); - expect(nextRedirect).toHaveBeenCalledWith('https://example.com'); - }); - }); -}); - -describe("localePrefix: 'never'", () => { - describe('baseRedirect', () => { - it('handles internal paths', () => { - baseRedirect({ - pathname: '/test/path', - locale: 'en', - localePrefix: 'never' - }); - expect(nextRedirect).toHaveBeenCalledTimes(1); - expect(nextRedirect).toHaveBeenCalledWith('/test/path'); - }); - - it('handles external paths', () => { - baseRedirect({ - pathname: 'https://example.com', - locale: 'en', - localePrefix: 'never' - }); - expect(nextRedirect).toHaveBeenCalledTimes(1); - expect(nextRedirect).toHaveBeenCalledWith('https://example.com'); - }); - }); -}); diff --git a/packages/next-intl/test/navigation/shared/redirects.test.tsx b/packages/next-intl/test/navigation/shared/redirects.test.tsx new file mode 100644 index 000000000..83663940a --- /dev/null +++ b/packages/next-intl/test/navigation/shared/redirects.test.tsx @@ -0,0 +1,86 @@ +import { + permanentRedirect as nextPermanentRedirect, + redirect as nextRedirect +} from 'next/navigation'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import { + baseRedirect, + basePermanentRedirect +} from '../../../src/navigation/shared/redirects'; + +vi.mock('next/navigation'); + +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: 'always' + }); + expect(nextFn).toHaveBeenCalledTimes(1); + expect(nextFn).toHaveBeenCalledWith('/en/test/path'); + }); + + it('handles external paths', () => { + redirectFn({ + pathname: 'https://example.com', + locale: 'en', + localePrefix: '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: 'as-needed' + }); + expect(nextFn).toHaveBeenCalledTimes(1); + expect(nextFn).toHaveBeenCalledWith('/en/test/path'); + }); + + it('handles external paths', () => { + redirectFn({ + pathname: 'https://example.com', + locale: 'en', + localePrefix: '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: 'never' + }); + expect(nextFn).toHaveBeenCalledTimes(1); + expect(nextFn).toHaveBeenCalledWith('/test/path'); + }); + + it('handles external paths', () => { + redirectFn({ + pathname: 'https://example.com', + locale: 'en', + localePrefix: 'never' + }); + expect(nextFn).toHaveBeenCalledTimes(1); + expect(nextFn).toHaveBeenCalledWith('https://example.com'); + }); + }); +}); diff --git a/packages/next-intl/test/shared/utils.test.tsx b/packages/next-intl/test/shared/utils.test.tsx index fed72bac9..1a7826f6a 100644 --- a/packages/next-intl/test/shared/utils.test.tsx +++ b/packages/next-intl/test/shared/utils.test.tsx @@ -8,32 +8,32 @@ import { describe('prefixPathname', () => { it("doesn't add trailing slashes for the root", () => { - expect(prefixPathname('en', '/')).toEqual('/en'); + expect(prefixPathname('/en', '/')).toEqual('/en'); }); it("doesn't add trailing slashes for search params", () => { - expect(prefixPathname('en', '/?foo=bar')).toEqual('/en?foo=bar'); + expect(prefixPathname('/en', '/?foo=bar')).toEqual('/en?foo=bar'); }); it('localizes nested paths', () => { - expect(prefixPathname('en', '/nested')).toEqual('/en/nested'); + expect(prefixPathname('/en', '/nested')).toEqual('/en/nested'); }); }); describe('hasPathnamePrefixed', () => { it('detects prefixed pathnames', () => { - expect(hasPathnamePrefixed('en', '/en')).toEqual(true); - expect(hasPathnamePrefixed('en', '/en/')).toEqual(true); - expect(hasPathnamePrefixed('en', '/en/client')).toEqual(true); - expect(hasPathnamePrefixed('en', '/en/client/')).toEqual(true); - expect(hasPathnamePrefixed('en', '/en/client/test')).toEqual(true); + expect(hasPathnamePrefixed('/en', '/en')).toEqual(true); + expect(hasPathnamePrefixed('/en', '/en/')).toEqual(true); + expect(hasPathnamePrefixed('/en', '/en/client')).toEqual(true); + expect(hasPathnamePrefixed('/en', '/en/client/')).toEqual(true); + expect(hasPathnamePrefixed('/en', '/en/client/test')).toEqual(true); }); it('detects non-prefixed pathnames', () => { - expect(hasPathnamePrefixed('en', '/')).toEqual(false); - expect(hasPathnamePrefixed('en', '/client')).toEqual(false); - expect(hasPathnamePrefixed('en', '/client/')).toEqual(false); - expect(hasPathnamePrefixed('en', '/client/test')).toEqual(false); + expect(hasPathnamePrefixed('/en', '/')).toEqual(false); + expect(hasPathnamePrefixed('/en', '/client')).toEqual(false); + expect(hasPathnamePrefixed('/en', '/client/')).toEqual(false); + expect(hasPathnamePrefixed('/en', '/client/test')).toEqual(false); }); }); From 9a9a896e0114618669997d1f3e264d4154fa664a Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 23 May 2024 15:29:12 +0200 Subject: [PATCH 04/45] First tests for localized pathnames --- .../createLocalizedPathnamesNavigation.tsx | 10 ++-- ...reateLocalizedPathnamesNavigation.test.tsx | 56 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx index b6ea5bcd5..fe3350982 100644 --- a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx @@ -4,7 +4,8 @@ import { AllLocales, LocalePrefix, ParametersExceptFirst, - Pathnames + Pathnames, + RoutingLocales } from '../../shared/types'; import { HrefOrHrefWithParams, @@ -23,7 +24,7 @@ export default function createLocalizedPathnamesNavigation< locales, pathnames }: { - locales: Locales; + locales: RoutingLocales; pathnames: Pathnames; localePrefix?: LocalePrefix; }) { @@ -54,6 +55,7 @@ export default function createLocalizedPathnamesNavigation< })} locale={locale} localePrefix={localePrefix} + locales={locales} {...rest} /> ); @@ -65,7 +67,7 @@ export default function createLocalizedPathnamesNavigation< ) { const locale = getRequestLocale(); const pathname = getPathname({href, locale}); - return serverRedirect({localePrefix, pathname}, ...args); + return serverRedirect({localePrefix, pathname, locales}, ...args); } function permanentRedirect( @@ -74,7 +76,7 @@ export default function createLocalizedPathnamesNavigation< ) { const locale = getRequestLocale(); const pathname = getPathname({href, locale}); - return serverPermanentRedirect({localePrefix, pathname}, ...args); + return serverPermanentRedirect({localePrefix, pathname, locales}, ...args); } function getPathname({ diff --git a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx index b66bc3d0b..1f77f0c82 100644 --- a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx @@ -52,6 +52,7 @@ beforeEach(() => { }); const locales = ['en', 'de', 'ja'] as const; + const pathnames = { '/': '/', '/about': { @@ -116,6 +117,61 @@ describe.each([ }); }); + describe("localePrefix: 'always', custom prefixes", () => { + const {Link, getPathname} = createLocalizedPathnamesNavigation({ + locales: ['en', {locale: 'de-at', prefix: '/de'}] as const, + pathnames: { + '/': '/', + '/about': { + en: '/about', + 'de-at': '/ueber-uns' + } + }, + localePrefix: 'always' + }); + + 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("localePrefix: 'as-needed'", () => { const {Link, getPathname, permanentRedirect, redirect} = createLocalizedPathnamesNavigation({ From 446bfc26c920b529ba99c1b1e8c3f38934376154 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 23 May 2024 16:18:27 +0200 Subject: [PATCH 05/45] More tests, more progress --- .../getAlternateLinksHeaderValue.tsx | 2 +- .../src/middleware/resolveLocale.tsx | 8 +--- packages/next-intl/src/middleware/utils.tsx | 21 +++------- .../createLocalizedPathnamesNavigation.tsx | 23 +++++++---- .../createSharedPathnamesNavigation.tsx | 2 +- .../react-client/useBasePathname.tsx | 14 +++---- packages/next-intl/src/shared/utils.tsx | 15 +++++++ ...reateLocalizedPathnamesNavigation.test.tsx | 41 +++++++++++++++---- .../createSharedPathnamesNavigation.test.tsx | 24 ++++++++++- ...reateLocalizedPathnamesNavigation.test.tsx | 30 ++++++++++++++ .../createSharedPathnamesNavigation.test.tsx | 20 +++++++-- .../react-client/useBasePathname.test.tsx | 3 +- 12 files changed, 151 insertions(+), 52 deletions(-) diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 0254711b3..816d260df 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -1,11 +1,11 @@ import {NextRequest} from 'next/server'; import {AllLocales, Pathnames} from '../shared/types'; +import {getLocale} from '../shared/utils'; import {MiddlewareConfigWithDefaults} from './NextIntlMiddlewareConfig'; import { applyBasePath, formatTemplatePathname, getHost, - getLocale, getNormalizedPathname, getPrefix, isLocaleSupportedOnDomain diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index d2f6a301e..ea28eab53 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -3,16 +3,12 @@ import Negotiator from 'negotiator'; import {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; import {COOKIE_LOCALE_NAME} from '../shared/constants'; import {AllLocales, RoutingLocales} from '../shared/types'; +import {getLocales} from '../shared/utils'; import { DomainConfig, MiddlewareConfigWithDefaults } from './NextIntlMiddlewareConfig'; -import { - getHost, - getLocales, - getPathnameMatch, - isLocaleSupportedOnDomain -} from './utils'; +import {getHost, getPathnameMatch, isLocaleSupportedOnDomain} from './utils'; function findDomainFromHost( requestHeaders: Headers, diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index d85ce610a..4f1c55b24 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -1,5 +1,10 @@ import {AllLocales, RoutingLocales} from '../shared/types'; -import {matchesPathname, templateToRegex} from '../shared/utils'; +import { + getLocale, + getLocales, + matchesPathname, + templateToRegex +} from '../shared/utils'; import { DomainConfig, MiddlewareConfigWithDefaults @@ -151,20 +156,6 @@ export function getPrefixes( return routingLocales.map((routingLocale) => getPrefix(routingLocale)); } -export function getLocale( - routingLocale: RoutingLocales[number] -) { - return typeof routingLocale === 'string' - ? routingLocale - : routingLocale.locale; -} - -export function getLocales( - routingLocales: RoutingLocales -) { - return routingLocales.map((routingLocale) => getLocale(routingLocale)); -} - /** * Removes potential prefixes from the pathname. */ diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx index 4383d0b26..35d893b00 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx @@ -4,8 +4,10 @@ import { AllLocales, LocalePrefix, ParametersExceptFirst, - Pathnames + Pathnames, + RoutingLocales } from '../../shared/types'; +import {getLocales} from '../../shared/utils'; import { compileLocalizedPathname, getRoute, @@ -22,13 +24,13 @@ export default function createLocalizedPathnamesNavigation< Locales extends AllLocales, PathnamesConfig extends Pathnames >(opts: { - locales: Locales; + locales: RoutingLocales; pathnames: PathnamesConfig; localePrefix?: LocalePrefix; }) { function useTypedLocale(): (typeof opts.locales)[number] { const locale = useLocale(); - const isValid = opts.locales.includes(locale as any); + const isValid = getLocales(opts.locales).includes(locale as any); if (!isValid) { throw new Error( process.env.NODE_ENV !== 'production' @@ -66,6 +68,7 @@ export default function createLocalizedPathnamesNavigation< })} locale={locale} localePrefix={opts.localePrefix} + locales={opts.locales} {...rest} /> ); @@ -86,7 +89,10 @@ export default function createLocalizedPathnamesNavigation< // 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({...opts, pathname: resolvedHref}, ...args); + return clientRedirect( + {...opts, pathname: resolvedHref, locales: opts.locales}, + ...args + ); } function permanentRedirect( @@ -96,11 +102,14 @@ export default function createLocalizedPathnamesNavigation< // 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({...opts, pathname: resolvedHref}, ...args); + return clientPermanentRedirect( + {...opts, pathname: resolvedHref, locales: opts.locales}, + ...args + ); } function useRouter() { - const baseRouter = useBaseRouter(); + const baseRouter = useBaseRouter(opts.locales); const defaultLocale = useTypedLocale(); return { @@ -141,7 +150,7 @@ export default function createLocalizedPathnamesNavigation< } function usePathname(): keyof PathnamesConfig { - const pathname = useBasePathname(opts.locales); + const pathname = useBasePathname(); 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 diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx index 5ed0d9411..3f99a0c3b 100644 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx @@ -50,7 +50,7 @@ export default function createSharedPathnamesNavigation< } function usePathname(): string { - const result = useBasePathname(opts?.locales); + const result = useBasePathname(); // @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; } diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx index e0908fa32..d2574c949 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx @@ -3,9 +3,7 @@ import {usePathname as useNextPathname} from 'next/navigation'; import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; -import {AllLocales, RoutingLocales} from '../../shared/types'; import {hasPathnamePrefixed, unlocalizePathname} from '../../shared/utils'; -import {getLocalePrefix} from '../shared/utils'; /** * Returns the pathname without a potential locale prefix. @@ -20,9 +18,7 @@ import {getLocalePrefix} from '../shared/utils'; * const pathname = usePathname(); * ``` */ -export default function useBasePathname( - locales?: RoutingLocales -): string | null { +export default function useBasePathname(): string | null { // The types aren't entirely correct here. Outside of Next.js // `useParams` can be called, but the return type is `null`. const pathname = useNextPathname() as ReturnType< @@ -33,12 +29,16 @@ export default function useBasePathname( return useMemo(() => { if (!pathname) return pathname; - const prefix = getLocalePrefix(locale, locales); + + // Note that `usePathname` returns the internal path, not + // taking into account potential rewrites from the middleware + const prefix = '/' + locale; + const isPathnamePrefixed = hasPathnamePrefixed(prefix, pathname); const unlocalizedPathname = isPathnamePrefixed ? unlocalizePathname(pathname, locale) : pathname; return unlocalizedPathname; - }, [locale, locales, pathname]); + }, [locale, pathname]); } diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 64d36996c..2289b5702 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -1,6 +1,7 @@ import {UrlObject} from 'url'; import NextLink from 'next/link'; import {ComponentProps} from 'react'; +import {AllLocales, RoutingLocales} from './types'; type Href = ComponentProps['href']; @@ -115,3 +116,17 @@ export function templateToRegex(template: string): RegExp { return new RegExp(`^${regexPattern}$`); } + +export function getLocale( + routingLocale: RoutingLocales[number] +) { + return typeof routingLocale === 'string' + ? routingLocale + : routingLocale.locale; +} + +export function getLocales( + routingLocales: RoutingLocales +) { + return routingLocales.map((routingLocale) => getLocale(routingLocale)); +} diff --git a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx index 1f77f0c82..e15b317c1 100644 --- a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx @@ -118,15 +118,16 @@ describe.each([ }); describe("localePrefix: 'always', custom prefixes", () => { - const {Link, getPathname} = createLocalizedPathnamesNavigation({ + const pathnamesCustomPrefixes = { + '/': '/', + '/about': { + en: '/about', + 'de-at': '/ueber-uns' + } + } as const; + const {Link, getPathname, redirect} = createLocalizedPathnamesNavigation({ locales: ['en', {locale: 'de-at', prefix: '/de'}] as const, - pathnames: { - '/': '/', - '/about': { - en: '/about', - 'de-at': '/ueber-uns' - } - }, + pathnames: pathnamesCustomPrefixes, localePrefix: 'always' }); @@ -170,6 +171,30 @@ describe.each([ ).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'", () => { diff --git a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx index 1d66b5d88..b0d9c88fb 100644 --- a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx @@ -103,7 +103,7 @@ describe.each([ }); describe("localePrefix: 'always', custom prefixes", () => { - const {Link} = createSharedPathnamesNavigation({ + const {Link, redirect} = createSharedPathnamesNavigation({ locales: localesWithCustomPrefixes, localePrefix: 'always' }); @@ -137,6 +137,28 @@ describe.each([ ).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 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'", () => { 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 1bff634aa..d20281666 100644 --- a/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx @@ -324,6 +324,36 @@ describe("localePrefix: 'as-needed'", () => { } }); +describe("localePrefix: 'as-needed', custom prefix", () => { + const {useRouter} = createLocalizedPathnamesNavigation({ + locales: ['en', {locale: 'de-at', prefix: '/de'}] as const, + pathnames: { + '/': '/', + '/about': { + en: '/about', + 'de-at': '/ueber-uns' + } + }, + localePrefix: 'always' + }); + + 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("localePrefix: 'never'", () => { const {useRouter} = createLocalizedPathnamesNavigation({ pathnames, diff --git a/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx index c1f543f0b..4ce6dbfd6 100644 --- a/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx @@ -1,6 +1,6 @@ -import {render} from '@testing-library/react'; +import {getByText, render, screen} from '@testing-library/react'; import { - usePathname, + usePathname as useNextPathname, useParams, useRouter as useNextRouter } from 'next/navigation'; @@ -22,7 +22,7 @@ beforeEach(() => { refresh: vi.fn() }; vi.mocked(useNextRouter).mockImplementation(() => router); - vi.mocked(usePathname).mockImplementation(() => '/'); + vi.mocked(useNextPathname).mockImplementation(() => '/'); vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); }); @@ -101,7 +101,7 @@ describe("localePrefix: 'as-needed'", () => { }); describe("localePrefix: 'as-needed', custom prefix", () => { - const {useRouter} = createSharedPathnamesNavigation({ + const {usePathname, useRouter} = createSharedPathnamesNavigation({ locales: ['en', {locale: 'en-gb', prefix: '/uk'}], localePrefix: 'as-needed' }); @@ -121,6 +121,18 @@ describe("localePrefix: 'as-needed', custom prefix", () => { }); }); }); + + describe('usePathname', () => { + it('returns the correct pathname for a custom locale prefix', () => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'en-gb'})); + vi.mocked(useNextPathname).mockImplementation(() => '/en-gb/about'); + function Component() { + return usePathname(); + } + render(); + screen.getByText('/about'); + }); + }); }); describe("localePrefix: 'never'", () => { diff --git a/packages/next-intl/test/navigation/react-client/useBasePathname.test.tsx b/packages/next-intl/test/navigation/react-client/useBasePathname.test.tsx index fed96e443..ba8f74223 100644 --- a/packages/next-intl/test/navigation/react-client/useBasePathname.test.tsx +++ b/packages/next-intl/test/navigation/react-client/useBasePathname.test.tsx @@ -13,8 +13,7 @@ function mockPathname(pathname: string) { } function Component() { - const locales = ['en']; - return <>{useBasePathname(locales)}; + return <>{useBasePathname()}; } describe('unprefixed routing', () => { From a0a7b8d311d8ee69fb1925f4f9719228f96f3da2 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 24 May 2024 12:50:09 +0200 Subject: [PATCH 06/45] Minor perf optimization: Don't pass locales to client when not necessary --- .../navigation/react-client/ClientLink.tsx | 16 ++++++--- .../createLocalizedPathnamesNavigation.tsx | 2 +- .../navigation/react-client/useBaseRouter.tsx | 2 +- .../navigation/react-server/ServerLink.tsx | 9 +++-- .../src/navigation/shared/BaseLink.tsx | 34 +++++-------------- ...reateLocalizedPathnamesNavigation.test.tsx | 7 ++-- .../createSharedPathnamesNavigation.test.tsx | 7 ++-- 7 files changed, 38 insertions(+), 39 deletions(-) diff --git a/packages/next-intl/src/navigation/react-client/ClientLink.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.tsx index 0d5e9862a..a2c390eaa 100644 --- a/packages/next-intl/src/navigation/react-client/ClientLink.tsx +++ b/packages/next-intl/src/navigation/react-client/ClientLink.tsx @@ -1,23 +1,29 @@ -import React, {ComponentProps, ReactElement, forwardRef} from 'react'; +import React, {ComponentProps, ReactElement, forwardRef, useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; import {AllLocales, RoutingLocales} from '../../shared/types'; import BaseLink from '../shared/BaseLink'; +import {getLocalePrefix} from '../shared/utils'; type Props = Omit< ComponentProps, - 'locale' + 'locale' | 'prefix' > & { locale?: Locales[number]; locales?: RoutingLocales; }; function ClientLink( - {locale, ...rest}: Props, + {locale, locales, ...rest}: Props, ref: Props['ref'] ) { const defaultLocale = useLocale(); - const linkLocale = locale || defaultLocale; - return ref={ref} locale={linkLocale} {...rest} />; + const finalLocale = locale || defaultLocale; + const prefix = useMemo( + () => getLocalePrefix(finalLocale, locales), + [finalLocale, locales] + ); + + return ; } /** diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx index 35d893b00..62f3072b4 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx @@ -28,7 +28,7 @@ export default function createLocalizedPathnamesNavigation< pathnames: PathnamesConfig; localePrefix?: LocalePrefix; }) { - function useTypedLocale(): (typeof opts.locales)[number] { + function useTypedLocale(): Locales[number] { const locale = useLocale(); const isValid = getLocales(opts.locales).includes(locale as any); if (!isValid) { diff --git a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx index b5860f76d..375c89020 100644 --- a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx +++ b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx @@ -37,7 +37,7 @@ export default function useBaseRouter( const pathname = usePathname(); return useMemo(() => { - function localize(href: string, nextLocale?: string) { + function localize(href: string, nextLocale?: Locales[number]) { let curPathname = window.location.pathname; const basePath = getBasePath(pathname); diff --git a/packages/next-intl/src/navigation/react-server/ServerLink.tsx b/packages/next-intl/src/navigation/react-server/ServerLink.tsx index 9cdd824f4..d41c36017 100644 --- a/packages/next-intl/src/navigation/react-server/ServerLink.tsx +++ b/packages/next-intl/src/navigation/react-server/ServerLink.tsx @@ -2,10 +2,11 @@ import React, {ComponentProps} from 'react'; import {getLocale} from '../../server.react-server'; import {AllLocales, RoutingLocales} from '../../shared/types'; import BaseLink from '../shared/BaseLink'; +import {getLocalePrefix} from '../shared/utils'; type Props = Omit< ComponentProps, - 'locale' + 'locale' | 'prefix' > & { locale?: Locales[number]; locales?: RoutingLocales; @@ -13,7 +14,11 @@ type Props = Omit< export default async function ServerLink({ locale, + locales, ...rest }: Props) { - return locale={locale || (await getLocale())} {...rest} />; + const finalLocale = locale || (await getLocale()); + const prefix = getLocalePrefix(finalLocale, locales); + + return ; } diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index 1ba52851c..7df008fe7 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -8,41 +8,23 @@ import React, { ReactElement, forwardRef, useEffect, - useMemo, useState } from 'react'; import useLocale from '../../react-client/useLocale'; -import {AllLocales, LocalePrefix, RoutingLocales} from '../../shared/types'; +import {LocalePrefix} from '../../shared/types'; import {isLocalHref, localizeHref, prefixHref} from '../../shared/utils'; import syncLocaleCookie from './syncLocaleCookie'; -import {getLocalePrefix} from './utils'; -type Props = Omit< - ComponentProps, - 'locale' -> & { +type Props = Omit, 'locale'> & { locale: string; - locales?: RoutingLocales; + prefix: string; localePrefix?: LocalePrefix; }; -function BaseLink( - { - href, - locale, - localePrefix, - locales, - onClick, - prefetch, - ...rest - }: Props, - ref: Props['ref'] +function BaseLink( + {href, locale, localePrefix, onClick, prefetch, prefix, ...rest}: Props, + ref: Props['ref'] ) { - const prefix = useMemo( - () => getLocalePrefix(locale, locales), - [locale, locales] - ); - // 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; @@ -98,8 +80,8 @@ function BaseLink( ); } -const BaseLinkWithRef = forwardRef(BaseLink) as ( - props: Props & {ref?: Props['ref']} +const BaseLinkWithRef = forwardRef(BaseLink) as ( + props: Props & {ref?: Props['ref']} ) => ReactElement; (BaseLinkWithRef as any).displayName = 'ClientLink'; export default BaseLinkWithRef; diff --git a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx index e15b317c1..569062536 100644 --- a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx @@ -12,6 +12,7 @@ import {it, describe, vi, expect, beforeEach} from 'vitest'; import createLocalizedPathnamesNavigationClient from '../../src/navigation/react-client/createLocalizedPathnamesNavigation'; import createLocalizedPathnamesNavigationServer from '../../src/navigation/react-server/createLocalizedPathnamesNavigation'; import BaseLink from '../../src/navigation/shared/BaseLink'; +import {getLocalePrefix} from '../../src/navigation/shared/utils'; import {Pathnames} from '../../src/navigation.react-client'; import {getRequestLocale} from '../../src/server/react-server/RequestLocale'; @@ -34,8 +35,10 @@ vi.mock('next-intl/config', () => ({ vi.mock('react'); // Avoids handling an async component (not supported by renderToString) vi.mock('../../src/navigation/react-server/ServerLink', () => ({ - default({locale, ...rest}: any) { - return ; + default({locale, locales, ...rest}: any) { + const finalLocale = locale || 'en'; + const prefix = getLocalePrefix(finalLocale, locales); + return ; } })); vi.mock('../../src/server/react-server/RequestLocale', () => ({ diff --git a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx index b0d9c88fb..f261feecd 100644 --- a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx @@ -12,6 +12,7 @@ import {it, describe, vi, expect, beforeEach} from 'vitest'; import createSharedPathnamesNavigationClient from '../../src/navigation/react-client/createSharedPathnamesNavigation'; import createSharedPathnamesNavigationServer from '../../src/navigation/react-server/createSharedPathnamesNavigation'; import BaseLink from '../../src/navigation/shared/BaseLink'; +import {getLocalePrefix} from '../../src/navigation/shared/utils'; import {getRequestLocale} from '../../src/server/react-server/RequestLocale'; vi.mock('next/navigation', async () => { @@ -33,8 +34,10 @@ vi.mock('next-intl/config', () => ({ vi.mock('react'); // Avoids handling an async component (not supported by renderToString) vi.mock('../../src/navigation/react-server/ServerLink', () => ({ - default({locale, ...rest}: any) { - return ; + default({locale, locales, ...rest}: any) { + const finalLocale = locale || 'en'; + const prefix = getLocalePrefix(finalLocale, locales); + return ; } })); vi.mock('../../src/server/react-server/RequestLocale', () => ({ From c6ad80c546e538c7b865ade38a97eaaff91fd100 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 24 May 2024 13:34:04 +0200 Subject: [PATCH 07/45] Fix initial render when passing a relative href to --- packages/next-intl/src/navigation/shared/BaseLink.tsx | 4 ++-- packages/next-intl/src/shared/utils.tsx | 6 +++++- .../navigation/createSharedPathnamesNavigation.test.tsx | 5 +++++ .../test/navigation/react-client/ClientLink.test.tsx | 7 +++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index 7df008fe7..7f73a19f2 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -12,7 +12,7 @@ import React, { } from 'react'; import useLocale from '../../react-client/useLocale'; import {LocalePrefix} from '../../shared/types'; -import {isLocalHref, localizeHref, prefixHref} from '../../shared/utils'; +import {isLocalizableHref, localizeHref, prefixHref} from '../../shared/utils'; import syncLocaleCookie from './syncLocaleCookie'; type Props = Omit, 'locale'> & { @@ -33,7 +33,7 @@ function BaseLink( const isChangingLocale = locale !== curLocale; const [localizedHref, setLocalizedHref] = useState(() => - isLocalHref(href) && (localePrefix !== 'never' || isChangingLocale) + isLocalizableHref(href) && (localePrefix !== '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 diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 2289b5702..f21a8af94 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -19,6 +19,10 @@ export function isLocalHref(href: Href) { } } +export function isLocalizableHref(href: Href) { + return isLocalHref(href) && !isRelativeHref(href); +} + export function localizeHref( href: string, locale: string, @@ -40,7 +44,7 @@ export function localizeHref( curPathname: string, prefix: string ) { - if (!isLocalHref(href) || isRelativeHref(href)) { + if (!isLocalizableHref(href)) { return href; } diff --git a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx index f261feecd..de2a7d733 100644 --- a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx @@ -102,6 +102,11 @@ describe.each([ 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"'); + }); }); }); diff --git a/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx b/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx index 6f5b5319b..d3a40791c 100644 --- a/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx +++ b/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx @@ -68,6 +68,13 @@ describe('unprefixed routing', () => { ); }); + 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( Date: Fri, 24 May 2024 14:34:20 +0200 Subject: [PATCH 08/45] Refactor middleware --- .../middleware/NextIntlMiddlewareConfig.tsx | 27 +++--- .../getAlternateLinksHeaderValue.tsx | 97 +++++++++---------- .../next-intl/src/middleware/middleware.tsx | 34 ++++--- .../src/middleware/resolveLocale.tsx | 36 +++---- packages/next-intl/src/middleware/utils.tsx | 59 ++++++----- packages/next-intl/src/shared/types.tsx | 30 ++++-- packages/next-intl/src/shared/utils.tsx | 15 --- .../test/middleware/middleware.test.tsx | 26 +++-- 8 files changed, 169 insertions(+), 155 deletions(-) diff --git a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx index 819b7a879..7e2e04240 100644 --- a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx +++ b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx @@ -1,20 +1,19 @@ import { AllLocales, - LocalePrefix, - Pathnames, - RoutingLocales + LocalePrefixConfig, + LocalePrefixConfigWithPrefixes, + Pathnames } from '../shared/types'; type RoutingBaseConfig = { /** A list of all locales that are supported. */ - locales: RoutingLocales; + locales: Locales; /* Used by default if none of the defined locales match. */ defaultLocale: Locales[number]; - /** The default locale can be used without a prefix (e.g. `/about`). If you prefer to have a prefix for the default locale as well (e.g. `/en/about`), you can switch this option to `always`. - */ - localePrefix?: LocalePrefix; + /** @see https://next-intl-docs.vercel.app/docs/routing/middleware#locale-prefix */ + localePrefix?: LocalePrefixConfig; }; export type DomainConfig = Omit< @@ -47,11 +46,13 @@ type MiddlewareConfig = // b) The middleware can be used in a standalone fashion }; -export type MiddlewareConfigWithDefaults = - MiddlewareConfig & { - alternateLinks: boolean; - localePrefix: LocalePrefix; - localeDetection: boolean; - }; +export type MiddlewareConfigWithDefaults = Omit< + MiddlewareConfig, + 'alternateLinks' | 'localePrefix' | 'localeDetection' +> & { + alternateLinks: boolean; + localePrefix: LocalePrefixConfigWithPrefixes; + localeDetection: boolean; +}; export default MiddlewareConfig; diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 816d260df..709956144 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -1,13 +1,12 @@ import {NextRequest} from 'next/server'; import {AllLocales, Pathnames} from '../shared/types'; -import {getLocale} from '../shared/utils'; import {MiddlewareConfigWithDefaults} from './NextIntlMiddlewareConfig'; import { applyBasePath, formatTemplatePathname, getHost, + getLocalePrefixes, getNormalizedPathname, - getPrefix, isLocaleSupportedOnDomain } from './utils'; @@ -39,7 +38,8 @@ export default function getAlternateLinksHeaderValue< normalizedUrl.pathname = getNormalizedPathname( normalizedUrl.pathname, - config.locales + config.locales, + config.localePrefix ); function getAlternateEntry(url: URL, locale: string) { @@ -63,69 +63,68 @@ export default function getAlternateLinksHeaderValue< } } - const links = config.locales.flatMap((routingLocale) => { - const prefix = getPrefix(routingLocale); - const locale = getLocale(routingLocale); - - function prefixPathname(pathname: string) { - if (pathname === '/') { - return prefix; - } else { - return prefix + pathname; + const links = getLocalePrefixes(config.locales, config.localePrefix).flatMap( + ([locale, prefix]) => { + function prefixPathname(pathname: string) { + if (pathname === '/') { + return prefix; + } else { + return prefix + pathname; + } } - } - let url: URL; + let url: URL; - if (config.domains) { - const domainConfigs = - config.domains.filter((cur) => - isLocaleSupportedOnDomain(locale, cur) - ) || []; + if (config.domains) { + const domainConfigs = + config.domains.filter((cur) => + isLocaleSupportedOnDomain(locale, cur) + ) || []; - return domainConfigs.map((domainConfig) => { - url = new URL(normalizedUrl); - url.port = ''; - url.host = domainConfig.domain; + return domainConfigs.map((domainConfig) => { + url = new URL(normalizedUrl); + url.port = ''; + url.host = domainConfig.domain; - // Important: Use `normalizedUrl` here, as `url` potentially uses - // a `basePath` that automatically gets applied to the pathname - url.pathname = getLocalizedPathname(normalizedUrl.pathname, locale); + // Important: Use `normalizedUrl` here, as `url` potentially uses + // a `basePath` that automatically gets applied to the pathname + url.pathname = getLocalizedPathname(normalizedUrl.pathname, locale); + + if ( + locale !== domainConfig.defaultLocale || + config.localePrefix.mode === 'always' + ) { + url.pathname = prefixPathname(url.pathname); + } + + return getAlternateEntry(url, locale); + }); + } else { + let pathname: string; + if (localizedPathnames && typeof localizedPathnames === 'object') { + pathname = getLocalizedPathname(normalizedUrl.pathname, locale); + } else { + pathname = normalizedUrl.pathname; + } if ( - routingLocale !== domainConfig.defaultLocale || - config.localePrefix === 'always' + locale !== config.defaultLocale || + config.localePrefix.mode === 'always' ) { - url.pathname = prefixPathname(url.pathname); + pathname = prefixPathname(pathname); } - - return getAlternateEntry(url, locale); - }); - } else { - let pathname: string; - if (localizedPathnames && typeof localizedPathnames === 'object') { - pathname = getLocalizedPathname(normalizedUrl.pathname, locale); - } else { - pathname = normalizedUrl.pathname; + url = new URL(pathname, normalizedUrl); } - if ( - routingLocale !== config.defaultLocale || - config.localePrefix === 'always' - ) { - pathname = prefixPathname(pathname); - } - url = new URL(pathname, normalizedUrl); + return getAlternateEntry(url, locale); } - - return getAlternateEntry(url, locale); - }); + ); // Add x-default entry const shouldAddXDefault = // For domain-based routing there is no reasonable x-default !config.domains && - (config.localePrefix !== 'always' || normalizedUrl.pathname === '/'); + (config.localePrefix.mode !== 'always' || normalizedUrl.pathname === '/'); if (shouldAddXDefault) { const url = new URL( getLocalizedPathname(normalizedUrl.pathname, config.defaultLocale), diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 34c89832f..d9c26fbb8 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -29,10 +29,15 @@ const ROOT_URL = '/'; function receiveConfig( config: MiddlewareConfig ): MiddlewareConfigWithDefaults { + const localePrefix = + typeof config.localePrefix === 'object' + ? config.localePrefix + : {mode: config.localePrefix || 'always'}; + const result: MiddlewareConfigWithDefaults = { ...config, alternateLinks: config.alternateLinks ?? true, - localePrefix: config.localePrefix ?? 'always', + localePrefix, localeDetection: config.localeDetection ?? true }; @@ -104,12 +109,13 @@ export default function createMiddleware( if ( bestMatchingDomain.defaultLocale === locale && - configWithDefaults.localePrefix === 'as-needed' && + configWithDefaults.localePrefix.mode === 'as-needed' && urlObj.pathname.startsWith(`/${locale}`) ) { urlObj.pathname = getNormalizedPathname( urlObj.pathname, - configWithDefaults.locales + configWithDefaults.locales, + configWithDefaults.localePrefix ); } } @@ -140,12 +146,14 @@ export default function createMiddleware( const normalizedPathname = getNormalizedPathname( nextPathname, - configWithDefaults.locales + configWithDefaults.locales, + configWithDefaults.localePrefix ); const pathnameMatch = getPathnameMatch( nextPathname, - configWithDefaults.locales + configWithDefaults.locales, + configWithDefaults.localePrefix ); const hasLocalePrefix = pathnameMatch != null; @@ -192,7 +200,7 @@ export default function createMiddleware( const localePrefix = (hasLocalePrefix || !hasMatchedDefaultLocale) && - configWithDefaults.localePrefix !== 'never' + configWithDefaults.localePrefix.mode !== 'never' ? locale : undefined; @@ -219,9 +227,9 @@ export default function createMiddleware( ); if ( - configWithDefaults.localePrefix === 'never' || + configWithDefaults.localePrefix.mode === 'never' || (hasMatchedDefaultLocale && - configWithDefaults.localePrefix === 'as-needed') + configWithDefaults.localePrefix.mode === 'as-needed') ) { response = rewrite(pathWithSearch); } else { @@ -239,12 +247,12 @@ export default function createMiddleware( request.nextUrl.search ); - if (configWithDefaults.localePrefix === 'never') { + if (configWithDefaults.localePrefix.mode === 'never') { response = redirect(normalizedPathnameWithSearch); } else if (pathnameMatch.exact) { if ( hasMatchedDefaultLocale && - configWithDefaults.localePrefix === 'as-needed' + configWithDefaults.localePrefix.mode === 'as-needed' ) { response = redirect(normalizedPathnameWithSearch); } else { @@ -274,9 +282,9 @@ export default function createMiddleware( } } else { if ( - configWithDefaults.localePrefix === 'never' || + configWithDefaults.localePrefix.mode === 'never' || (hasMatchedDefaultLocale && - (configWithDefaults.localePrefix === 'as-needed' || + (configWithDefaults.localePrefix.mode === 'as-needed' || configWithDefaults.domains)) ) { response = rewrite(`/${locale}${internalPathWithSearch}`); @@ -296,7 +304,7 @@ export default function createMiddleware( } if ( - configWithDefaults.localePrefix !== 'never' && + configWithDefaults.localePrefix.mode !== 'never' && configWithDefaults.alternateLinks && configWithDefaults.locales.length > 1 ) { diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index ea28eab53..541e0869f 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -2,8 +2,7 @@ import {match} from '@formatjs/intl-localematcher'; import Negotiator from 'negotiator'; import {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; import {COOKIE_LOCALE_NAME} from '../shared/constants'; -import {AllLocales, RoutingLocales} from '../shared/types'; -import {getLocales} from '../shared/utils'; +import {AllLocales} from '../shared/types'; import { DomainConfig, MiddlewareConfigWithDefaults @@ -28,7 +27,7 @@ function findDomainFromHost( export function getAcceptLanguageLocale( requestHeaders: Headers, - routingLocales: RoutingLocales, + locales: Locales, defaultLocale: string ) { let locale; @@ -39,8 +38,11 @@ export function getAcceptLanguageLocale( } }).languages(); try { - const locales = getLocales(routingLocales); - locale = match(languages, locales, defaultLocale); + locale = match( + languages, + locales as unknown as Array, + defaultLocale + ); } catch (e) { // Invalid language } @@ -50,11 +52,10 @@ export function getAcceptLanguageLocale( function getLocaleFromCookie( requestCookies: RequestCookies, - routingLocales: RoutingLocales + locales: Locales ) { if (requestCookies.has(COOKIE_LOCALE_NAME)) { const value = requestCookies.get(COOKIE_LOCALE_NAME)?.value; - const locales = getLocales(routingLocales); if (value && locales.includes(value)) { return value; } @@ -65,10 +66,11 @@ function resolveLocaleFromPrefix( { defaultLocale, localeDetection, - locales: routingLocales + localePrefix, + locales }: Pick< MiddlewareConfigWithDefaults, - 'defaultLocale' | 'localeDetection' | 'locales' + 'defaultLocale' | 'localeDetection' | 'locales' | 'localePrefix' >, requestHeaders: Headers, requestCookies: RequestCookies, @@ -78,21 +80,17 @@ function resolveLocaleFromPrefix( // Prio 1: Use route prefix if (pathname) { - locale = getPathnameMatch(pathname, routingLocales)?.locale; + locale = getPathnameMatch(pathname, locales, localePrefix)?.locale; } // Prio 2: Use existing cookie if (!locale && localeDetection && requestCookies) { - locale = getLocaleFromCookie(requestCookies, routingLocales); + locale = getLocaleFromCookie(requestCookies, locales); } // Prio 3: Use the `accept-language` header if (!locale && localeDetection && requestHeaders) { - locale = getAcceptLanguageLocale( - requestHeaders, - routingLocales, - defaultLocale - ); + locale = getAcceptLanguageLocale(requestHeaders, locales, defaultLocale); } // Prio 4: Use default locale @@ -127,7 +125,11 @@ function resolveLocaleFromDomain( // Prio 1: Use route prefix if (pathname) { - const prefixLocale = getPathnameMatch(pathname, config.locales)?.locale; + const prefixLocale = getPathnameMatch( + pathname, + config.locales, + config.localePrefix + )?.locale; if (prefixLocale) { if (isLocaleSupportedOnDomain(prefixLocale, domain)) { locale = prefixLocale; diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 4f1c55b24..679eb8b4d 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -1,10 +1,5 @@ -import {AllLocales, RoutingLocales} from '../shared/types'; -import { - getLocale, - getLocales, - matchesPathname, - templateToRegex -} from '../shared/utils'; +import {AllLocales, LocalePrefixConfig, LocalePrefixes} from '../shared/types'; +import {matchesPathname, templateToRegex} from '../shared/utils'; import { DomainConfig, MiddlewareConfigWithDefaults @@ -142,26 +137,13 @@ export function formatTemplatePathname( return targetPathname; } -export function getPrefix( - routingLocale: RoutingLocales[number] -) { - return typeof routingLocale === 'string' - ? '/' + routingLocale - : routingLocale.prefix; -} - -export function getPrefixes( - routingLocales: RoutingLocales -) { - return routingLocales.map((routingLocale) => getPrefix(routingLocale)); -} - /** * Removes potential prefixes from the pathname. */ export function getNormalizedPathname( pathname: string, - locales: RoutingLocales + locales: Locales, + localePrefix: LocalePrefixConfig ) { // Add trailing slash for consistent handling // both for the root as well as nested paths @@ -169,10 +151,10 @@ export function getNormalizedPathname( pathname += '/'; } - const prefixes = getPrefixes(locales); + const localePrefixes = getLocalePrefixes(locales, localePrefix); const regex = new RegExp( - `^(${prefixes - .map((prefix) => prefix.replaceAll('/', '\\/')) + `^(${localePrefixes + .map(([, prefix]) => prefix.replaceAll('/', '\\/')) .join('|')})/(.*)`, 'i' ); @@ -193,9 +175,26 @@ export function findCaseInsensitiveString( return strings.find((cur) => cur.toLowerCase() === candidate.toLowerCase()); } +export function getLocalePrefixes( + locales: Locales, + localePrefix: LocalePrefixConfig +): Array<[Locales[number], string]> { + const prefixesConfig = + (typeof localePrefix === 'object' && + localePrefix.mode !== 'never' && + localePrefix.prefixes) || + ({} as LocalePrefixes); + + return locales.map((locale) => [ + locale as Locales[number], + prefixesConfig[locale as Locales[number]] ?? '/' + locale + ]); +} + export function getPathnameMatch( pathname: string, - routingLocales: RoutingLocales + locales: Locales, + localePrefix: LocalePrefixConfig ): | { locale: Locales[number]; @@ -204,9 +203,9 @@ export function getPathnameMatch( exact?: boolean; } | undefined { - for (const routingLocale of routingLocales) { - const prefix = getPrefix(routingLocale); + const localePrefixes = getLocalePrefixes(locales, localePrefix); + for (const [locale, prefix] of localePrefixes) { let exact, matches; if (pathname === prefix || pathname.startsWith(prefix + '/')) { exact = matches = true; @@ -224,7 +223,7 @@ export function getPathnameMatch( if (matches) { return { - locale: getLocale(routingLocale), + locale, prefix, matchedPrefix: pathname.slice(0, prefix.length), exact @@ -286,7 +285,7 @@ export function isLocaleSupportedOnDomain( return ( domain.defaultLocale === locale || !domain.locales || - getLocales(domain.locales).includes(locale) + domain.locales.includes(locale) ); } diff --git a/packages/next-intl/src/shared/types.tsx b/packages/next-intl/src/shared/types.tsx index 1e4d18b00..cf39a034a 100644 --- a/packages/next-intl/src/shared/types.tsx +++ b/packages/next-intl/src/shared/types.tsx @@ -2,19 +2,31 @@ export type AllLocales = ReadonlyArray; export type LocalePrefix = 'as-needed' | 'always' | 'never'; -export type Pathnames = Record< - string, - {[Key in Locales[number]]: string} | string +export type LocalePrefixes = Record< + Locales[number], + string >; -export type RoutingLocales = ReadonlyArray< - | Locales[number] +export type LocalePrefixConfigWithPrefixes = | { - /** The locale code available internally (e.g. `/en-gb`) */ - locale: Locales[number]; - /** The prefix this locale should be available at (e.g. `/uk`) */ - prefix: string; + mode: 'always'; + prefixes?: LocalePrefixes; } + | { + mode: 'as-needed'; + prefixes?: LocalePrefixes; + } + | { + mode: 'never'; + }; + +export type LocalePrefixConfig = + | LocalePrefix + | LocalePrefixConfigWithPrefixes; + +export type Pathnames = Record< + string, + {[Key in Locales[number]]: string} | string >; export type ParametersExceptFirst = Fn extends ( diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index f21a8af94..0bd120162 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -1,7 +1,6 @@ import {UrlObject} from 'url'; import NextLink from 'next/link'; import {ComponentProps} from 'react'; -import {AllLocales, RoutingLocales} from './types'; type Href = ComponentProps['href']; @@ -120,17 +119,3 @@ export function templateToRegex(template: string): RegExp { return new RegExp(`^${regexPattern}$`); } - -export function getLocale( - routingLocale: RoutingLocales[number] -) { - return typeof routingLocale === 'string' - ? routingLocale - : routingLocale.locale; -} - -export function getLocales( - routingLocales: RoutingLocales -) { - return routingLocales.map((routingLocale) => getLocale(routingLocale)); -} diff --git a/packages/next-intl/test/middleware/middleware.test.tsx b/packages/next-intl/test/middleware/middleware.test.tsx index 7264c10e0..535046a2c 100644 --- a/packages/next-intl/test/middleware/middleware.test.tsx +++ b/packages/next-intl/test/middleware/middleware.test.tsx @@ -1394,13 +1394,16 @@ describe('prefix-based routing', () => { describe('custom prefixes', () => { const middlewareWithPrefixes = createIntlMiddleware({ defaultLocale: 'en', - locales: [ - 'en', - {locale: 'en-gb', prefix: '/uk'}, - {locale: 'de-at', prefix: '/de/at'}, - {locale: 'pt', prefix: '/br'} - ], - localePrefix: 'always', + locales: ['en', 'en-gb', 'de-at', 'pt'], + localePrefix: { + mode: 'always', + prefixes: { + // en (defaults to /en) + 'en-gb': '/uk', + 'de-at': '/de/at', + pt: '/br' + } + }, pathnames: { '/': '/', '/about': { @@ -2720,8 +2723,13 @@ describe('domain-based routing', () => { describe('custom prefixes', () => { const middlewareWithPrefixes = createIntlMiddleware({ defaultLocale: 'en', - locales: ['en', {locale: 'en-gb', prefix: '/uk'}], - localePrefix: 'as-needed', + locales: ['en', 'en-gb'], + localePrefix: { + mode: 'as-needed', + prefixes: { + 'en-gb': '/uk' + } + }, pathnames: { '/': '/', '/about': { From efb993ba389343fa5633689691ed613abceae703 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 24 May 2024 14:37:34 +0200 Subject: [PATCH 09/45] Fix types in middleware tests --- .../getAlternateLinksHeaderValue.test.tsx | 18 +++++++++--------- .../next-intl/test/middleware/utils.test.tsx | 18 +++++++++++------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx index ad1bbe314..e01e94a9c 100644 --- a/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx +++ b/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx @@ -33,7 +33,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( defaultLocale: 'en', locales: ['en', 'es'], alternateLinks: true, - localePrefix: 'as-needed', + localePrefix: {mode: 'as-needed'}, localeDetection: true }; @@ -83,7 +83,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( defaultLocale: 'en', locales: ['en', 'de'], alternateLinks: true, - localePrefix: 'as-needed', + localePrefix: {mode: 'as-needed'}, localeDetection: true }; const pathnames = { @@ -164,7 +164,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( defaultLocale: 'en', locales: ['en', 'es'], alternateLinks: true, - localePrefix: 'always', + localePrefix: {mode: 'always'}, localeDetection: true }; @@ -199,7 +199,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( defaultLocale: 'en', locales: ['en', 'es', 'fr'], alternateLinks: true, - localePrefix: 'as-needed', + localePrefix: {mode: 'as-needed'}, localeDetection: true, domains: [ { @@ -269,7 +269,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( defaultLocale: 'en', locales: ['en', 'es', 'fr'], alternateLinks: true, - localePrefix: 'always', + localePrefix: {mode: 'always'}, localeDetection: true, domains: [ { @@ -331,7 +331,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( it("works for type domain with `localePrefix: 'as-needed' with `pathnames``", () => { const config: MiddlewareConfigWithDefaults<['en', 'fr']> = { alternateLinks: true, - localePrefix: 'as-needed', + localePrefix: {mode: 'as-needed'}, localeDetection: true, defaultLocale: 'en', locales: ['en', 'fr'], @@ -489,7 +489,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( defaultLocale: 'en', locales: ['en', 'es'], alternateLinks: true, - localePrefix: 'as-needed', + localePrefix: {mode: 'as-needed'}, localeDetection: true }; @@ -517,7 +517,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( defaultLocale: 'en', locales: ['en', 'es'], alternateLinks: true, - localePrefix: 'as-needed', + localePrefix: {mode: 'as-needed'}, localeDetection: true }; @@ -545,7 +545,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( defaultLocale: 'en', locales: ['en', 'es'], alternateLinks: true, - localePrefix: 'as-needed', + localePrefix: {mode: 'as-needed'}, localeDetection: true }; diff --git a/packages/next-intl/test/middleware/utils.test.tsx b/packages/next-intl/test/middleware/utils.test.tsx index 1a6285b21..116f1656f 100644 --- a/packages/next-intl/test/middleware/utils.test.tsx +++ b/packages/next-intl/test/middleware/utils.test.tsx @@ -9,13 +9,17 @@ import { describe('getNormalizedPathname', () => { it('should return the normalized pathname', () => { - expect(getNormalizedPathname('/en/about', ['en', 'de'])).toBe('/about'); - expect(getNormalizedPathname('/en/energy', ['en', 'de'])).toBe('/energy'); - expect(getNormalizedPathname('/energy', ['en'])).toBe('/energy'); - expect(getNormalizedPathname('/de/about', ['en', 'de'])).toBe('/about'); - expect(getNormalizedPathname('/about', ['en', 'de'])).toBe('/about'); - expect(getNormalizedPathname('/', ['en', 'de'])).toBe('/'); - expect(getNormalizedPathname('/es', ['en', 'de'])).toBe('/es'); + function getResult(pathname: string) { + return getNormalizedPathname(pathname, ['en', 'de'], 'always'); + } + + expect(getResult('/en/about')).toBe('/about'); + expect(getResult('/en/energy')).toBe('/energy'); + expect(getResult('/energy')).toBe('/energy'); + expect(getResult('/de/about')).toBe('/about'); + expect(getResult('/about')).toBe('/about'); + expect(getResult('/')).toBe('/'); + expect(getResult('/es')).toBe('/es'); }); }); From 068bec446e19fb4af99e04a75acca38b431727e6 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 27 May 2024 12:12:53 +0200 Subject: [PATCH 10/45] Refactor navigation APIs --- packages/next-intl/package.json | 6 +- .../middleware/NextIntlMiddlewareConfig.tsx | 4 +- .../next-intl/src/middleware/middleware.tsx | 9 +- packages/next-intl/src/middleware/utils.tsx | 22 ++-- .../navigation/react-client/ClientLink.tsx | 27 +++-- .../createLocalizedPathnamesNavigation.tsx | 26 ++--- .../createSharedPathnamesNavigation.tsx | 19 ++-- .../navigation/react-client/useBaseRouter.tsx | 15 ++- .../navigation/react-server/ServerLink.tsx | 21 ++-- .../createLocalizedPathnamesNavigation.tsx | 36 +++--- .../createSharedPathnamesNavigation.tsx | 32 +++--- .../src/navigation/shared/BaseLink.tsx | 9 +- .../src/navigation/shared/redirects.tsx | 15 +-- .../next-intl/src/navigation/shared/utils.tsx | 17 +-- packages/next-intl/src/shared/types.tsx | 13 +-- packages/next-intl/src/shared/utils.tsx | 25 +++++ .../next-intl/test/middleware/utils.test.tsx | 2 +- ...reateLocalizedPathnamesNavigation.test.tsx | 24 +++- .../createSharedPathnamesNavigation.test.tsx | 31 ++++-- .../react-client/ClientLink.test.tsx | 103 +++++++++++------- ...reateLocalizedPathnamesNavigation.test.tsx | 11 +- .../createSharedPathnamesNavigation.test.tsx | 16 ++- .../react-client/useBaseRouter.test.tsx | 6 +- .../test/navigation/shared/redirects.test.tsx | 12 +- 24 files changed, 289 insertions(+), 212 deletions(-) diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 9973924e5..5a184cfe2 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -122,11 +122,11 @@ }, { "path": "dist/production/navigation.react-client.js", - "limit": "2.965 KB" + "limit": "3.19 KB" }, { "path": "dist/production/navigation.react-server.js", - "limit": "17.64 KB" + "limit": "17.78 KB" }, { "path": "dist/production/server.react-client.js", @@ -138,7 +138,7 @@ }, { "path": "dist/production/middleware.js", - "limit": "6.34 KB" + "limit": "6.37 KB" } ] } diff --git a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx index 7e2e04240..2695a20f2 100644 --- a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx +++ b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx @@ -1,7 +1,7 @@ import { AllLocales, LocalePrefixConfig, - LocalePrefixConfigWithPrefixes, + LocalePrefixConfigVerbose, Pathnames } from '../shared/types'; @@ -51,7 +51,7 @@ export type MiddlewareConfigWithDefaults = Omit< 'alternateLinks' | 'localePrefix' | 'localeDetection' > & { alternateLinks: boolean; - localePrefix: LocalePrefixConfigWithPrefixes; + localePrefix: LocalePrefixConfigVerbose; localeDetection: boolean; }; diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index d9c26fbb8..5eb9ad102 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -6,7 +6,7 @@ import { HEADER_LOCALE_NAME } from '../shared/constants'; import {AllLocales} from '../shared/types'; -import {matchesPathname} from '../shared/utils'; +import {matchesPathname, receiveLocalePrefixConfig} from '../shared/utils'; import MiddlewareConfig, { MiddlewareConfigWithDefaults } from './NextIntlMiddlewareConfig'; @@ -29,15 +29,10 @@ const ROOT_URL = '/'; function receiveConfig( config: MiddlewareConfig ): MiddlewareConfigWithDefaults { - const localePrefix = - typeof config.localePrefix === 'object' - ? config.localePrefix - : {mode: config.localePrefix || 'always'}; - const result: MiddlewareConfigWithDefaults = { ...config, alternateLinks: config.alternateLinks ?? true, - localePrefix, + localePrefix: receiveLocalePrefixConfig(config.localePrefix), localeDetection: config.localeDetection ?? true }; diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 679eb8b4d..521274f90 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -1,5 +1,9 @@ -import {AllLocales, LocalePrefixConfig, LocalePrefixes} from '../shared/types'; -import {matchesPathname, templateToRegex} from '../shared/utils'; +import {AllLocales, LocalePrefixConfigVerbose} from '../shared/types'; +import { + getLocalePrefix, + matchesPathname, + templateToRegex +} from '../shared/utils'; import { DomainConfig, MiddlewareConfigWithDefaults @@ -143,7 +147,7 @@ export function formatTemplatePathname( export function getNormalizedPathname( pathname: string, locales: Locales, - localePrefix: LocalePrefixConfig + localePrefix: LocalePrefixConfigVerbose ) { // Add trailing slash for consistent handling // both for the root as well as nested paths @@ -177,24 +181,18 @@ export function findCaseInsensitiveString( export function getLocalePrefixes( locales: Locales, - localePrefix: LocalePrefixConfig + localePrefix: LocalePrefixConfigVerbose ): Array<[Locales[number], string]> { - const prefixesConfig = - (typeof localePrefix === 'object' && - localePrefix.mode !== 'never' && - localePrefix.prefixes) || - ({} as LocalePrefixes); - return locales.map((locale) => [ locale as Locales[number], - prefixesConfig[locale as Locales[number]] ?? '/' + locale + getLocalePrefix(locale, localePrefix) ]); } export function getPathnameMatch( pathname: string, locales: Locales, - localePrefix: LocalePrefixConfig + localePrefix: LocalePrefixConfigVerbose ): | { locale: Locales[number]; diff --git a/packages/next-intl/src/navigation/react-client/ClientLink.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.tsx index a2c390eaa..6486a43ca 100644 --- a/packages/next-intl/src/navigation/react-client/ClientLink.tsx +++ b/packages/next-intl/src/navigation/react-client/ClientLink.tsx @@ -1,29 +1,34 @@ -import React, {ComponentProps, ReactElement, forwardRef, useMemo} from 'react'; +import React, {ComponentProps, ReactElement, forwardRef} from 'react'; import useLocale from '../../react-client/useLocale'; -import {AllLocales, RoutingLocales} from '../../shared/types'; +import {AllLocales, LocalePrefixConfigVerbose} from '../../shared/types'; +import {getLocalePrefix} from '../../shared/utils'; import BaseLink from '../shared/BaseLink'; -import {getLocalePrefix} from '../shared/utils'; type Props = Omit< ComponentProps, - 'locale' | 'prefix' + 'locale' | 'prefix' | 'localePrefixMode' > & { locale?: Locales[number]; - locales?: RoutingLocales; + localePrefix: LocalePrefixConfigVerbose; }; function ClientLink( - {locale, locales, ...rest}: Props, + {locale, localePrefix, ...rest}: Props, ref: Props['ref'] ) { const defaultLocale = useLocale(); const finalLocale = locale || defaultLocale; - const prefix = useMemo( - () => getLocalePrefix(finalLocale, locales), - [finalLocale, locales] - ); + const prefix = getLocalePrefix(finalLocale, localePrefix); - return ; + return ( + + ); } /** diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx index 62f3072b4..e8106bc67 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx @@ -2,12 +2,11 @@ import React, {ComponentProps, ReactElement, forwardRef} from 'react'; import useLocale from '../../react-client/useLocale'; import { AllLocales, - LocalePrefix, + LocalePrefixConfig, ParametersExceptFirst, - Pathnames, - RoutingLocales + Pathnames } from '../../shared/types'; -import {getLocales} from '../../shared/utils'; +import {receiveLocalePrefixConfig} from '../../shared/utils'; import { compileLocalizedPathname, getRoute, @@ -24,13 +23,15 @@ export default function createLocalizedPathnamesNavigation< Locales extends AllLocales, PathnamesConfig extends Pathnames >(opts: { - locales: RoutingLocales; + locales: Locales; pathnames: PathnamesConfig; - localePrefix?: LocalePrefix; + localePrefix?: LocalePrefixConfig; }) { + const finalLocalePrefix = receiveLocalePrefixConfig(opts?.localePrefix); + function useTypedLocale(): Locales[number] { const locale = useLocale(); - const isValid = getLocales(opts.locales).includes(locale as any); + const isValid = opts.locales.includes(locale as any); if (!isValid) { throw new Error( process.env.NODE_ENV !== 'production' @@ -43,7 +44,7 @@ export default function createLocalizedPathnamesNavigation< type LinkProps = Omit< ComponentProps, - 'href' | 'name' + 'href' | 'name' | 'localePrefix' > & { href: HrefOrUrlObjectWithParams; locale?: Locales[number]; @@ -67,8 +68,7 @@ export default function createLocalizedPathnamesNavigation< pathnames: opts.pathnames })} locale={locale} - localePrefix={opts.localePrefix} - locales={opts.locales} + localePrefix={finalLocalePrefix} {...rest} /> ); @@ -90,7 +90,7 @@ export default function createLocalizedPathnamesNavigation< const locale = useTypedLocale(); const resolvedHref = getPathname({href, locale}); return clientRedirect( - {...opts, pathname: resolvedHref, locales: opts.locales}, + {pathname: resolvedHref, localePrefix: finalLocalePrefix}, ...args ); } @@ -103,13 +103,13 @@ export default function createLocalizedPathnamesNavigation< const locale = useTypedLocale(); const resolvedHref = getPathname({href, locale}); return clientPermanentRedirect( - {...opts, pathname: resolvedHref, locales: opts.locales}, + {pathname: resolvedHref, localePrefix: finalLocalePrefix}, ...args ); } function useRouter() { - const baseRouter = useBaseRouter(opts.locales); + const baseRouter = useBaseRouter(finalLocalePrefix); const defaultLocale = useTypedLocale(); return { diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx index 3f99a0c3b..a18292b7f 100644 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx @@ -1,10 +1,10 @@ import React, {ComponentProps, ReactElement, forwardRef} from 'react'; import { AllLocales, - LocalePrefix, - ParametersExceptFirst, - RoutingLocales + LocalePrefixConfig, + ParametersExceptFirst } from '../../shared/types'; +import {receiveLocalePrefixConfig} from '../../shared/utils'; import ClientLink from './ClientLink'; import {clientRedirect, clientPermanentRedirect} from './redirects'; import useBasePathname from './useBasePathname'; @@ -12,7 +12,9 @@ import useBaseRouter from './useBaseRouter'; export default function createSharedPathnamesNavigation< Locales extends AllLocales ->(opts?: {locales?: RoutingLocales; localePrefix?: LocalePrefix}) { +>(opts?: {locales?: Locales; localePrefix?: LocalePrefixConfig}) { + const finalLocalePrefix = receiveLocalePrefixConfig(opts?.localePrefix); + type LinkProps = Omit< ComponentProps>, 'localePrefix' @@ -21,8 +23,7 @@ export default function createSharedPathnamesNavigation< return ( ref={ref} - localePrefix={opts?.localePrefix} - locales={opts?.locales} + localePrefix={finalLocalePrefix} {...props} /> ); @@ -36,7 +37,7 @@ export default function createSharedPathnamesNavigation< pathname: string, ...args: ParametersExceptFirst ) { - return clientRedirect({...opts, pathname, locales: opts?.locales}, ...args); + return clientRedirect({pathname, localePrefix: finalLocalePrefix}, ...args); } function permanentRedirect( @@ -44,7 +45,7 @@ export default function createSharedPathnamesNavigation< ...args: ParametersExceptFirst ) { return clientPermanentRedirect( - {...opts, pathname, locales: opts?.locales}, + {pathname, localePrefix: finalLocalePrefix}, ...args ); } @@ -56,7 +57,7 @@ export default function createSharedPathnamesNavigation< } function useRouter() { - return useBaseRouter(opts?.locales); + return useBaseRouter(finalLocalePrefix); } return { diff --git a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx index 375c89020..efa1cee20 100644 --- a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx +++ b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx @@ -1,10 +1,10 @@ import {useRouter as useNextRouter, usePathname} from 'next/navigation'; import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; -import {AllLocales, RoutingLocales} from '../../shared/types'; -import {localizeHref} from '../../shared/utils'; +import {AllLocales, LocalePrefixConfigVerbose} from '../../shared/types'; +import {getLocalePrefix, localizeHref} from '../../shared/utils'; import syncLocaleCookie from '../shared/syncLocaleCookie'; -import {getBasePath, getLocalePrefix} from '../shared/utils'; +import {getBasePath} from '../shared/utils'; type IntlNavigateOptions = { locale?: Locales[number]; @@ -30,7 +30,7 @@ type IntlNavigateOptions = { * ``` */ export default function useBaseRouter( - locales?: RoutingLocales + localePrefix: LocalePrefixConfigVerbose ) { const router = useNextRouter(); const locale = useLocale(); @@ -44,7 +44,10 @@ export default function useBaseRouter( if (basePath) curPathname = curPathname.replace(basePath, ''); const targetLocale = nextLocale || locale; - const prefix = getLocalePrefix(targetLocale, locales); + + // 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); } @@ -89,5 +92,5 @@ export default function useBaseRouter( typeof router.prefetch >(router.prefetch) }; - }, [locale, locales, pathname, router]); + }, [locale, 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 index d41c36017..426e231e6 100644 --- a/packages/next-intl/src/navigation/react-server/ServerLink.tsx +++ b/packages/next-intl/src/navigation/react-server/ServerLink.tsx @@ -1,24 +1,31 @@ import React, {ComponentProps} from 'react'; import {getLocale} from '../../server.react-server'; -import {AllLocales, RoutingLocales} from '../../shared/types'; +import {AllLocales, LocalePrefixConfigVerbose} from '../../shared/types'; +import {getLocalePrefix} from '../../shared/utils'; import BaseLink from '../shared/BaseLink'; -import {getLocalePrefix} from '../shared/utils'; type Props = Omit< ComponentProps, - 'locale' | 'prefix' + 'locale' | 'prefix' | 'localePrefixMode' > & { locale?: Locales[number]; - locales?: RoutingLocales; + localePrefix: LocalePrefixConfigVerbose; }; export default async function ServerLink({ locale, - locales, + localePrefix, ...rest }: Props) { const finalLocale = locale || (await getLocale()); - const prefix = getLocalePrefix(finalLocale, locales); + const prefix = getLocalePrefix(finalLocale, localePrefix); - return ; + return ( + + ); } diff --git a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx index fe3350982..745112b9e 100644 --- a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx @@ -2,11 +2,11 @@ import React, {ComponentProps} from 'react'; import {getRequestLocale} from '../../server/react-server/RequestLocale'; import { AllLocales, - LocalePrefix, + LocalePrefixConfig, ParametersExceptFirst, - Pathnames, - RoutingLocales + Pathnames } from '../../shared/types'; +import {receiveLocalePrefixConfig} from '../../shared/utils'; import { HrefOrHrefWithParams, HrefOrUrlObjectWithParams, @@ -19,18 +19,16 @@ import {serverPermanentRedirect, serverRedirect} from './redirects'; export default function createLocalizedPathnamesNavigation< Locales extends AllLocales, PathnamesConfig extends Pathnames ->({ - localePrefix, - locales, - pathnames -}: { - locales: RoutingLocales; +>(opts: { + locales: Locales; pathnames: Pathnames; - localePrefix?: LocalePrefix; + localePrefix?: LocalePrefixConfig; }) { + const finalLocalePrefix = receiveLocalePrefixConfig(opts?.localePrefix); + type LinkProps = Omit< ComponentProps, - 'href' | 'name' + 'href' | 'name' | 'localePrefix' > & { href: HrefOrUrlObjectWithParams; locale?: Locales[number]; @@ -40,7 +38,7 @@ export default function createLocalizedPathnamesNavigation< locale, ...rest }: LinkProps) { - const defaultLocale = getRequestLocale() as (typeof locales)[number]; + const defaultLocale = getRequestLocale() as typeof opts.locales[number]; const finalLocale = locale || defaultLocale; return ( @@ -51,11 +49,10 @@ export default function createLocalizedPathnamesNavigation< pathname: href, // @ts-expect-error -- This is ok params: typeof href === 'object' ? href.params : undefined, - pathnames + pathnames: opts.pathnames })} locale={locale} - localePrefix={localePrefix} - locales={locales} + localePrefix={finalLocalePrefix} {...rest} /> ); @@ -67,7 +64,7 @@ export default function createLocalizedPathnamesNavigation< ) { const locale = getRequestLocale(); const pathname = getPathname({href, locale}); - return serverRedirect({localePrefix, pathname, locales}, ...args); + return serverRedirect({localePrefix: finalLocalePrefix, pathname}, ...args); } function permanentRedirect( @@ -76,7 +73,10 @@ export default function createLocalizedPathnamesNavigation< ) { const locale = getRequestLocale(); const pathname = getPathname({href, locale}); - return serverPermanentRedirect({localePrefix, pathname, locales}, ...args); + return serverPermanentRedirect( + {localePrefix: finalLocalePrefix, pathname}, + ...args + ); } function getPathname({ @@ -89,7 +89,7 @@ export default function createLocalizedPathnamesNavigation< return compileLocalizedPathname({ ...normalizeNameOrNameWithParams(href), locale, - pathnames + pathnames: opts.pathnames }); } diff --git a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx index 585caf18d..d3550a197 100644 --- a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx @@ -1,16 +1,18 @@ import React, {ComponentProps} from 'react'; import { AllLocales, - LocalePrefix, - ParametersExceptFirst, - RoutingLocales + LocalePrefixConfig, + ParametersExceptFirst } from '../../shared/types'; +import {receiveLocalePrefixConfig} from '../../shared/utils'; import ServerLink from './ServerLink'; import {serverPermanentRedirect, serverRedirect} from './redirects'; export default function createSharedPathnamesNavigation< Locales extends AllLocales ->(opts?: {locales?: RoutingLocales; localePrefix?: LocalePrefix}) { +>(opts?: {locales?: Locales; localePrefix?: LocalePrefixConfig}) { + const finalLocalePrefix = receiveLocalePrefixConfig(opts?.localePrefix); + function notSupported(hookName: string) { return () => { throw new Error( @@ -19,21 +21,23 @@ export default function createSharedPathnamesNavigation< }; } - function Link(props: ComponentProps>) { - return ( - - localePrefix={opts?.localePrefix} - locales={opts?.locales} - {...props} - /> - ); + function Link( + props: Omit< + ComponentProps>, + 'localePrefix' | 'locales' + > + ) { + return localePrefix={finalLocalePrefix} {...props} />; } function redirect( pathname: string, ...args: ParametersExceptFirst ) { - return serverRedirect({...opts, pathname, locales: opts?.locales}, ...args); + return serverRedirect( + {...opts, pathname, localePrefix: finalLocalePrefix}, + ...args + ); } function permanentRedirect( @@ -41,7 +45,7 @@ export default function createSharedPathnamesNavigation< ...args: ParametersExceptFirst ) { return serverPermanentRedirect( - {...opts, pathname, locales: opts?.locales}, + {...opts, pathname, localePrefix: finalLocalePrefix}, ...args ); } diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index 7f73a19f2..427264292 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -11,18 +11,18 @@ import React, { useState } from 'react'; import useLocale from '../../react-client/useLocale'; -import {LocalePrefix} from '../../shared/types'; +import {LocalePrefixMode} from '../../shared/types'; import {isLocalizableHref, localizeHref, prefixHref} from '../../shared/utils'; import syncLocaleCookie from './syncLocaleCookie'; type Props = Omit, 'locale'> & { locale: string; prefix: string; - localePrefix?: LocalePrefix; + localePrefixMode: LocalePrefixMode; }; function BaseLink( - {href, locale, localePrefix, onClick, prefetch, prefix, ...rest}: Props, + {href, locale, localePrefixMode, onClick, prefetch, prefix, ...rest}: Props, ref: Props['ref'] ) { // The types aren't entirely correct here. Outside of Next.js @@ -33,7 +33,8 @@ function BaseLink( const isChangingLocale = locale !== curLocale; const [localizedHref, setLocalizedHref] = useState(() => - isLocalizableHref(href) && (localePrefix !== 'never' || isChangingLocale) + 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 diff --git a/packages/next-intl/src/navigation/shared/redirects.tsx b/packages/next-intl/src/navigation/shared/redirects.tsx index ffb71010d..a1ab4a86c 100644 --- a/packages/next-intl/src/navigation/shared/redirects.tsx +++ b/packages/next-intl/src/navigation/shared/redirects.tsx @@ -4,26 +4,23 @@ import { } from 'next/navigation'; import { AllLocales, - LocalePrefix, - ParametersExceptFirst, - RoutingLocales + LocalePrefixConfigVerbose, + ParametersExceptFirst } from '../../shared/types'; -import {isLocalHref, prefixPathname} from '../../shared/utils'; -import {getLocalePrefix} from './utils'; +import {getLocalePrefix, isLocalHref, prefixPathname} from '../../shared/utils'; function createRedirectFn(redirectFn: typeof nextRedirect) { return function baseRedirect( params: { pathname: string; locale: AllLocales[number]; - localePrefix?: LocalePrefix; - locales?: RoutingLocales; + localePrefix: LocalePrefixConfigVerbose; }, ...args: ParametersExceptFirst ) { - const prefix = getLocalePrefix(params.locale, params.locales); + const prefix = getLocalePrefix(params.locale, params.localePrefix); const localizedPathname = - params.localePrefix === 'never' || !isLocalHref(params.pathname) + params.localePrefix.mode === 'never' || !isLocalHref(params.pathname) ? params.pathname : prefixPathname(prefix, params.pathname); return redirectFn(localizedPathname, ...args); diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 9bd489152..8ac8aa5c2 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,6 +1,6 @@ import type {ParsedUrlQueryInput} from 'node:querystring'; import type {UrlObject} from 'url'; -import {AllLocales, Pathnames, RoutingLocales} from '../../shared/types'; +import {AllLocales, Pathnames} from '../../shared/types'; import {matchesPathname, unlocalizePathname} from '../../shared/utils'; import StrictParams from './StrictParams'; @@ -190,18 +190,3 @@ export function getBasePath( return windowPathname.replace(pathname, ''); } } - -export function getLocalePrefix( - locale: string, - locales?: RoutingLocales -) { - if (locales) { - for (const cur of locales) { - if (typeof cur !== 'string' && cur.locale === locale) { - return cur.prefix; - } - } - } - - return '/' + locale; -} diff --git a/packages/next-intl/src/shared/types.tsx b/packages/next-intl/src/shared/types.tsx index cf39a034a..8d37e7ca4 100644 --- a/packages/next-intl/src/shared/types.tsx +++ b/packages/next-intl/src/shared/types.tsx @@ -1,13 +1,12 @@ export type AllLocales = ReadonlyArray; -export type LocalePrefix = 'as-needed' | 'always' | 'never'; +export type LocalePrefixMode = 'as-needed' | 'always' | 'never'; -export type LocalePrefixes = Record< - Locales[number], - string +export type LocalePrefixes = Partial< + Record >; -export type LocalePrefixConfigWithPrefixes = +export type LocalePrefixConfigVerbose = | { mode: 'always'; prefixes?: LocalePrefixes; @@ -21,8 +20,8 @@ export type LocalePrefixConfigWithPrefixes = }; export type LocalePrefixConfig = - | LocalePrefix - | LocalePrefixConfigWithPrefixes; + | LocalePrefixMode + | LocalePrefixConfigVerbose; export type Pathnames = Record< string, diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 0bd120162..083f712fe 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -1,6 +1,11 @@ import {UrlObject} from 'url'; import NextLink from 'next/link'; import {ComponentProps} from 'react'; +import { + AllLocales, + LocalePrefixConfig, + LocalePrefixConfigVerbose +} from './types'; type Href = ComponentProps['href']; @@ -108,6 +113,26 @@ export function matchesPathname( return regex.test(pathname); } +export function getLocalePrefix( + locale: Locales[number], + localePrefix: LocalePrefixConfigVerbose +) { + return ( + (localePrefix.mode !== 'never' && localePrefix.prefixes?.[locale]) || + // We return a prefix even if `mode: 'never'`. It's up to the consumer + // to decide to use it or not. + '/' + locale + ); +} + +export function receiveLocalePrefixConfig( + localePrefix?: LocalePrefixConfig +): LocalePrefixConfigVerbose { + return typeof localePrefix === 'object' + ? localePrefix + : {mode: localePrefix || 'always'}; +} + export function templateToRegex(template: string): RegExp { const regexPattern = template // Replace optional catchall ('[[...slug]]') diff --git a/packages/next-intl/test/middleware/utils.test.tsx b/packages/next-intl/test/middleware/utils.test.tsx index 116f1656f..4799e7b57 100644 --- a/packages/next-intl/test/middleware/utils.test.tsx +++ b/packages/next-intl/test/middleware/utils.test.tsx @@ -10,7 +10,7 @@ import { describe('getNormalizedPathname', () => { it('should return the normalized pathname', () => { function getResult(pathname: string) { - return getNormalizedPathname(pathname, ['en', 'de'], 'always'); + return getNormalizedPathname(pathname, ['en', 'de'], {mode: 'always'}); } expect(getResult('/en/about')).toBe('/about'); diff --git a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx index 569062536..e43cadc56 100644 --- a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx @@ -12,9 +12,9 @@ import {it, describe, vi, expect, beforeEach} from 'vitest'; import createLocalizedPathnamesNavigationClient from '../../src/navigation/react-client/createLocalizedPathnamesNavigation'; import createLocalizedPathnamesNavigationServer from '../../src/navigation/react-server/createLocalizedPathnamesNavigation'; import BaseLink from '../../src/navigation/shared/BaseLink'; -import {getLocalePrefix} from '../../src/navigation/shared/utils'; import {Pathnames} from '../../src/navigation.react-client'; import {getRequestLocale} from '../../src/server/react-server/RequestLocale'; +import {getLocalePrefix} from '../../src/shared/utils'; vi.mock('next/navigation', async () => { const actual = await vi.importActual('next/navigation'); @@ -35,10 +35,17 @@ vi.mock('next-intl/config', () => ({ vi.mock('react'); // Avoids handling an async component (not supported by renderToString) vi.mock('../../src/navigation/react-server/ServerLink', () => ({ - default({locale, locales, ...rest}: any) { + default({locale, localePrefix, ...rest}: any) { const finalLocale = locale || 'en'; - const prefix = getLocalePrefix(finalLocale, locales); - return ; + const prefix = getLocalePrefix(finalLocale, localePrefix); + return ( + + ); } })); vi.mock('../../src/server/react-server/RequestLocale', () => ({ @@ -129,9 +136,14 @@ describe.each([ } } as const; const {Link, getPathname, redirect} = createLocalizedPathnamesNavigation({ - locales: ['en', {locale: 'de-at', prefix: '/de'}] as const, + locales: ['en', 'de-at'] as const, pathnames: pathnamesCustomPrefixes, - localePrefix: 'always' + localePrefix: { + mode: 'always', + prefixes: { + 'de-at': '/de' + } + } as const }); describe('Link', () => { diff --git a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx index de2a7d733..a21bb9ff8 100644 --- a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx @@ -12,8 +12,8 @@ import {it, describe, vi, expect, beforeEach} from 'vitest'; import createSharedPathnamesNavigationClient from '../../src/navigation/react-client/createSharedPathnamesNavigation'; import createSharedPathnamesNavigationServer from '../../src/navigation/react-server/createSharedPathnamesNavigation'; import BaseLink from '../../src/navigation/shared/BaseLink'; -import {getLocalePrefix} from '../../src/navigation/shared/utils'; import {getRequestLocale} from '../../src/server/react-server/RequestLocale'; +import {getLocalePrefix} from '../../src/shared/utils'; vi.mock('next/navigation', async () => { const actual = await vi.importActual('next/navigation'); @@ -34,10 +34,17 @@ vi.mock('next-intl/config', () => ({ vi.mock('react'); // Avoids handling an async component (not supported by renderToString) vi.mock('../../src/navigation/react-server/ServerLink', () => ({ - default({locale, locales, ...rest}: any) { + default({locale, localePrefix, ...rest}: any) { const finalLocale = locale || 'en'; - const prefix = getLocalePrefix(finalLocale, locales); - return ; + const prefix = getLocalePrefix(finalLocale, localePrefix); + return ( + + ); } })); vi.mock('../../src/server/react-server/RequestLocale', () => ({ @@ -50,11 +57,10 @@ beforeEach(() => { }); const locales = ['en', 'de'] as const; - -const localesWithCustomPrefixes = [ - 'en', - {locale: 'en-gb', prefix: '/uk'} -] as const; +const localesWithCustomPrefixes = ['en', 'en-gb'] as const; +const customizedPrefixes = { + 'en-gb': '/uk' +}; describe.each([ {env: 'react-client', implementation: createSharedPathnamesNavigationClient}, @@ -113,7 +119,10 @@ describe.each([ describe("localePrefix: 'always', custom prefixes", () => { const {Link, redirect} = createSharedPathnamesNavigation({ locales: localesWithCustomPrefixes, - localePrefix: 'always' + localePrefix: { + mode: 'always', + prefixes: customizedPrefixes + } }); describe('Link', () => { @@ -314,7 +323,7 @@ describe.each([ const {Link, permanentRedirect, redirect} = createSharedPathnamesNavigation({ locales: localesWithCustomPrefixes, - localePrefix: 'as-needed' + localePrefix: {mode: 'as-needed', prefixes: customizedPrefixes} }); describe('Link', () => { diff --git a/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx b/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx index d3a40791c..1710ceddb 100644 --- a/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx +++ b/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx @@ -1,9 +1,10 @@ import {fireEvent, render, screen} from '@testing-library/react'; import {usePathname, useParams} from 'next/navigation'; -import React from 'react'; +import React, {ComponentProps, LegacyRef, forwardRef} from 'react'; import {it, describe, vi, beforeEach, expect} from 'vitest'; import {NextIntlClientProvider} from '../../../src/index.react-client'; import ClientLink from '../../../src/navigation/react-client/ClientLink'; +import {LocalePrefixConfigVerbose} from '../../../src/shared/types'; vi.mock('next/navigation'); @@ -15,6 +16,24 @@ function mockLocation(pathname: string, basePath = '') { (global.window as any).location = {pathname: basePath + pathname}; } +const MockClientLink = forwardRef( + ( + { + localePrefix = {mode: 'always'}, + ...rest + }: Omit, 'localePrefix'> & { + localePrefix?: LocalePrefixConfigVerbose; + }, + ref + ) => ( + } + localePrefix={localePrefix} + {...rest} + /> + ) +); + describe('unprefixed routing', () => { beforeEach(() => { vi.mocked(usePathname).mockImplementation(() => '/'); @@ -22,7 +41,7 @@ describe('unprefixed routing', () => { }); it('renders an href without a locale if the locale matches', () => { - render(Test); + render(Test); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( '/test' ); @@ -30,9 +49,9 @@ describe('unprefixed routing', () => { 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' @@ -41,9 +60,9 @@ describe('unprefixed routing', () => { it('renders an href with a locale if the locale changes', () => { render( - + Test - + ); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( '/de/test' @@ -52,9 +71,9 @@ describe('unprefixed routing', () => { 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' @@ -62,14 +81,14 @@ describe('unprefixed routing', () => { }); it('works for external urls', () => { - render(Test); + render(Test); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( 'https://example.com' ); }); it('handles relative links', () => { - render(Test); + render(Test); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( 'test' ); @@ -77,7 +96,7 @@ describe('unprefixed routing', () => { it('works for external urls with an object href', () => { render( - { }} > Test - + ); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( 'https://example.com/test' @@ -96,14 +115,14 @@ describe('unprefixed routing', () => { let ref; render( - { ref = node; }} href="/test" > Test - + ); expect(ref).toBeDefined(); @@ -111,9 +130,9 @@ describe('unprefixed routing', () => { it('sets an hreflang when changing the locale', () => { render( - + Test - + ); expect( screen.getByRole('link', {name: 'Test'}).getAttribute('hreflang') @@ -122,20 +141,20 @@ describe('unprefixed routing', () => { 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' @@ -148,7 +167,7 @@ describe('unprefixed routing', () => { }); it('renders an unprefixed href when staying on the same locale', () => { - render(Test); + render(Test); expect( screen.getByRole('link', {name: 'Test'}).getAttribute('href') ).toBe('/test'); @@ -156,9 +175,9 @@ describe('unprefixed routing', () => { it('renders a prefixed href when switching the locale', () => { render( - + Test - + ); expect( screen.getByRole('link', {name: 'Test'}).getAttribute('href') @@ -174,14 +193,14 @@ describe('prefixed routing', () => { }); it('renders an href with a locale if the locale matches', () => { - render(Test); + 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); + render(Test); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( '/en/test' ); @@ -189,9 +208,9 @@ describe('prefixed routing', () => { it('renders an href with a locale if the locale changes', () => { render( - + Test - + ); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( '/de/test' @@ -200,9 +219,9 @@ describe('prefixed routing', () => { 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' @@ -210,7 +229,7 @@ describe('prefixed routing', () => { }); it('works for external urls', () => { - render(Test); + render(Test); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( 'https://example.com' ); @@ -218,7 +237,7 @@ describe('prefixed routing', () => { it('works for external urls with an object href', () => { render( - { }} > Test - + ); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( 'https://example.com/test' @@ -239,7 +258,7 @@ describe('prefixed routing', () => { }); it('renders an unprefixed href when staying on the same locale', () => { - render(Test); + render(Test); expect( screen.getByRole('link', {name: 'Test'}).getAttribute('href') ).toBe('/en/test'); @@ -247,9 +266,9 @@ describe('prefixed routing', () => { it('renders a prefixed href when switching the locale', () => { render( - + Test - + ); expect( screen.getByRole('link', {name: 'Test'}).getAttribute('href') @@ -266,7 +285,7 @@ describe('usage outside of Next.js', () => { it('works with a provider', () => { render( - Test + Test ); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( @@ -275,9 +294,9 @@ describe('usage outside of Next.js', () => { }); it('throws without a provider', () => { - expect(() => render(Test)).toThrow( - 'No intl context found. Have you configured the provider?' - ); + expect(() => + render(Test) + ).toThrow('No intl context found. Have you configured the provider?'); }); }); @@ -293,9 +312,9 @@ describe('cookie sync', () => { it('keeps the cookie value in sync', () => { render( - + Test - + ); expect(document.cookie).toContain('NEXT_LOCALE=en'); fireEvent.click(screen.getByRole('link', {name: 'Test'})); 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 d20281666..80500d1d5 100644 --- a/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx @@ -326,7 +326,7 @@ describe("localePrefix: 'as-needed'", () => { describe("localePrefix: 'as-needed', custom prefix", () => { const {useRouter} = createLocalizedPathnamesNavigation({ - locales: ['en', {locale: 'de-at', prefix: '/de'}] as const, + locales: ['en', 'de-at'] as const, pathnames: { '/': '/', '/about': { @@ -334,7 +334,12 @@ describe("localePrefix: 'as-needed', custom prefix", () => { 'de-at': '/ueber-uns' } }, - localePrefix: 'always' + localePrefix: { + mode: 'always', + prefixes: { + 'de-at': '/de' + } + } }); describe('useRouter', () => { @@ -362,7 +367,7 @@ describe("localePrefix: 'never'", () => { }); describe('useRouter', () => { - function Component({locale}: {locale?: string}) { + function Component({locale}: {locale?: (typeof locales)[number]}) { const router = useRouter(); router.push('/about', {locale}); return null; diff --git a/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx index 4ce6dbfd6..18cd74720 100644 --- a/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx @@ -1,4 +1,4 @@ -import {getByText, render, screen} from '@testing-library/react'; +import {render, screen} from '@testing-library/react'; import { usePathname as useNextPathname, useParams, @@ -84,6 +84,9 @@ describe("localePrefix: 'as-needed'", () => { 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 @@ -102,8 +105,13 @@ describe("localePrefix: 'as-needed'", () => { describe("localePrefix: 'as-needed', custom prefix", () => { const {usePathname, useRouter} = createSharedPathnamesNavigation({ - locales: ['en', {locale: 'en-gb', prefix: '/uk'}], - localePrefix: 'as-needed' + locales: ['en', 'en-gb'], + localePrefix: { + mode: 'as-needed', + prefixes: { + 'en-gb': '/uk' + } + } }); describe('useRouter', () => { @@ -142,7 +150,7 @@ describe("localePrefix: 'never'", () => { }); describe('useRouter', () => { - function Component({locale}: {locale?: string}) { + function Component({locale}: {locale?: (typeof locales)[number]}) { const router = useRouter(); router.push('/about', {locale}); return null; diff --git a/packages/next-intl/test/navigation/react-client/useBaseRouter.test.tsx b/packages/next-intl/test/navigation/react-client/useBaseRouter.test.tsx index 1bc603bc9..d74944531 100644 --- a/packages/next-intl/test/navigation/react-client/useBaseRouter.test.tsx +++ b/packages/next-intl/test/navigation/react-client/useBaseRouter.test.tsx @@ -27,7 +27,11 @@ vi.mock('next/navigation', () => { function callRouter(cb: (router: ReturnType) => void) { function Component() { - const router = useBaseRouter(); + const router = useBaseRouter({ + // The mode is not used, only the absence of + // `prefixes` is relevant for this test suite + mode: 'always' + }); useEffect(() => { cb(router); }, [router]); diff --git a/packages/next-intl/test/navigation/shared/redirects.test.tsx b/packages/next-intl/test/navigation/shared/redirects.test.tsx index 83663940a..100078a2b 100644 --- a/packages/next-intl/test/navigation/shared/redirects.test.tsx +++ b/packages/next-intl/test/navigation/shared/redirects.test.tsx @@ -23,7 +23,7 @@ describe.each([ redirectFn({ pathname: '/test/path', locale: 'en', - localePrefix: 'always' + localePrefix: {mode: 'always'} }); expect(nextFn).toHaveBeenCalledTimes(1); expect(nextFn).toHaveBeenCalledWith('/en/test/path'); @@ -33,7 +33,7 @@ describe.each([ redirectFn({ pathname: 'https://example.com', locale: 'en', - localePrefix: 'always' + localePrefix: {mode: 'always'} }); expect(nextFn).toHaveBeenCalledTimes(1); expect(nextFn).toHaveBeenCalledWith('https://example.com'); @@ -45,7 +45,7 @@ describe.each([ redirectFn({ pathname: '/test/path', locale: 'en', - localePrefix: 'as-needed' + localePrefix: {mode: 'as-needed'} }); expect(nextFn).toHaveBeenCalledTimes(1); expect(nextFn).toHaveBeenCalledWith('/en/test/path'); @@ -55,7 +55,7 @@ describe.each([ redirectFn({ pathname: 'https://example.com', locale: 'en', - localePrefix: 'as-needed' + localePrefix: {mode: 'as-needed'} }); expect(nextFn).toHaveBeenCalledTimes(1); expect(nextFn).toHaveBeenCalledWith('https://example.com'); @@ -67,7 +67,7 @@ describe.each([ redirectFn({ pathname: '/test/path', locale: 'en', - localePrefix: 'never' + localePrefix: {mode: 'never'} }); expect(nextFn).toHaveBeenCalledTimes(1); expect(nextFn).toHaveBeenCalledWith('/test/path'); @@ -77,7 +77,7 @@ describe.each([ redirectFn({ pathname: 'https://example.com', locale: 'en', - localePrefix: 'never' + localePrefix: {mode: 'never'} }); expect(nextFn).toHaveBeenCalledTimes(1); expect(nextFn).toHaveBeenCalledWith('https://example.com'); From e7d84d340ef8b906212e7c1855cb2d23a2470352 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 27 May 2024 15:04:11 +0200 Subject: [PATCH 11/45] Seems to be working? --- packages/next-intl/package.json | 4 +-- .../next-intl/src/middleware/middleware.tsx | 17 ++++++++++--- .../createLocalizedPathnamesNavigation.tsx | 3 ++- .../createSharedPathnamesNavigation.tsx | 2 +- .../react-client/useBasePathname.tsx | 24 ++++++++++++++---- .../next-intl/src/navigation/shared/utils.tsx | 9 +++---- packages/next-intl/src/shared/utils.tsx | 4 +-- .../test/middleware/middleware.test.tsx | 25 +++++++++++++++++++ ...reateLocalizedPathnamesNavigation.test.tsx | 19 +++++++++++++- .../createSharedPathnamesNavigation.test.tsx | 2 +- .../react-client/useBasePathname.test.tsx | 7 +++++- .../react-client/useBaseRouter.test.tsx | 2 +- packages/next-intl/test/shared/utils.test.tsx | 12 ++++++--- 13 files changed, 102 insertions(+), 28 deletions(-) diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 5a184cfe2..422576054 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -122,7 +122,7 @@ }, { "path": "dist/production/navigation.react-client.js", - "limit": "3.19 KB" + "limit": "3.195 KB" }, { "path": "dist/production/navigation.react-server.js", @@ -138,7 +138,7 @@ }, { "path": "dist/production/middleware.js", - "limit": "6.37 KB" + "limit": "6.38 KB" } ] } diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 5eb9ad102..2cda5a7cd 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -6,7 +6,11 @@ import { HEADER_LOCALE_NAME } from '../shared/constants'; import {AllLocales} from '../shared/types'; -import {matchesPathname, receiveLocalePrefixConfig} from '../shared/utils'; +import { + getLocalePrefix, + matchesPathname, + receiveLocalePrefixConfig +} from '../shared/utils'; import MiddlewareConfig, { MiddlewareConfigWithDefaults } from './NextIntlMiddlewareConfig'; @@ -105,7 +109,9 @@ export default function createMiddleware( if ( bestMatchingDomain.defaultLocale === locale && configWithDefaults.localePrefix.mode === 'as-needed' && - urlObj.pathname.startsWith(`/${locale}`) + urlObj.pathname.startsWith( + getLocalePrefix(locale, configWithDefaults.localePrefix) + ) ) { urlObj.pathname = getNormalizedPathname( urlObj.pathname, @@ -217,7 +223,7 @@ export default function createMiddleware( if (!response) { if (pathname === ROOT_URL) { const pathWithSearch = getPathWithSearch( - `/${locale}`, + getLocalePrefix(locale, configWithDefaults.localePrefix), request.nextUrl.search ); @@ -284,7 +290,10 @@ export default function createMiddleware( ) { response = rewrite(`/${locale}${internalPathWithSearch}`); } else { - response = redirect(`/${locale}${internalPathWithSearch}`); + response = redirect( + getLocalePrefix(locale, configWithDefaults.localePrefix) + + internalPathWithSearch + ); } } } diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx index e8106bc67..f6d245a67 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx @@ -150,8 +150,9 @@ export default function createLocalizedPathnamesNavigation< } function usePathname(): keyof PathnamesConfig { - const pathname = useBasePathname(); + const pathname = useBasePathname(finalLocalePrefix); 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: opts.pathnames}) diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx index a18292b7f..29ed6d83a 100644 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx @@ -51,7 +51,7 @@ export default function createSharedPathnamesNavigation< } function usePathname(): string { - const result = useBasePathname(); + const result = useBasePathname(finalLocalePrefix); // @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; } diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx index d2574c949..0553f2807 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx @@ -3,7 +3,12 @@ import {usePathname as useNextPathname} from 'next/navigation'; import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; -import {hasPathnamePrefixed, unlocalizePathname} from '../../shared/utils'; +import {AllLocales, LocalePrefixConfigVerbose} from '../../shared/types'; +import { + getLocalePrefix, + hasPathnamePrefixed, + unprefixPathname +} from '../../shared/utils'; /** * Returns the pathname without a potential locale prefix. @@ -18,9 +23,18 @@ import {hasPathnamePrefixed, unlocalizePathname} from '../../shared/utils'; * const pathname = usePathname(); * ``` */ -export default function useBasePathname(): string | null { +export default function useBasePathname( + localePrefix: LocalePrefixConfigVerbose +) { // The types aren't entirely correct here. Outside of Next.js // `useParams` can be called, but the return type is `null`. + + // Notes on `useNextPathname`: + // - Types aren't entirely correct. Outside of Next.js the + // hook will return `null` (e.g. unit tests) + // - A base path is stripped from the result + // - Rewrites *are* taken into account (i.e. the pathname + // that the user sees in the browser is returned) const pathname = useNextPathname() as ReturnType< typeof useNextPathname > | null; @@ -32,13 +46,13 @@ export default function useBasePathname(): string | null { // Note that `usePathname` returns the internal path, not // taking into account potential rewrites from the middleware - const prefix = '/' + locale; + const prefix = getLocalePrefix(locale, localePrefix); const isPathnamePrefixed = hasPathnamePrefixed(prefix, pathname); const unlocalizedPathname = isPathnamePrefixed - ? unlocalizePathname(pathname, locale) + ? unprefixPathname(pathname, prefix) : pathname; return unlocalizedPathname; - }, [locale, pathname]); + }, [locale, localePrefix, pathname]); } diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 8ac8aa5c2..d28e12f8f 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 {AllLocales, Pathnames} from '../../shared/types'; -import {matchesPathname, unlocalizePathname} from '../../shared/utils'; +import {matchesPathname, unprefixPathname} from '../../shared/utils'; import StrictParams from './StrictParams'; type SearchParamValue = ParsedUrlQueryInput[keyof ParsedUrlQueryInput]; @@ -161,10 +161,9 @@ export function getRoute({ pathname: string; pathnames: Pathnames; }) { - const unlocalizedPathname = unlocalizePathname( - // Potentially handle foreign symbols - decodeURI(pathname), - locale + const unlocalizedPathname = unprefixPathname( + decodeURI(pathname), // Potentially handle foreign symbols + '/' + locale ); let template = Object.entries(pathnames).find(([, routePath]) => { diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 083f712fe..70702f9ac 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -82,8 +82,8 @@ export function prefixHref(href: UrlObject | string, prefix: string) { return prefixedHref; } -export function unlocalizePathname(pathname: string, locale: string) { - return pathname.replace(new RegExp(`^/${locale}`), '') || '/'; +export function unprefixPathname(pathname: string, prefix: string) { + return pathname.replace(new RegExp(`^${prefix}`), '') || '/'; } export function prefixPathname(prefix: string, pathname: string) { diff --git a/packages/next-intl/test/middleware/middleware.test.tsx b/packages/next-intl/test/middleware/middleware.test.tsx index 535046a2c..36479a56b 100644 --- a/packages/next-intl/test/middleware/middleware.test.tsx +++ b/packages/next-intl/test/middleware/middleware.test.tsx @@ -1461,6 +1461,22 @@ describe('prefix-based routing', () => { ); }); + it('redirects requests at the root to a custom prefix', () => { + middlewareWithPrefixes(createMockRequest('/', 'de-at')); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de/at' + ); + }); + + it("redirects requests to add a locale prefix if it's missing", () => { + middlewareWithPrefixes(createMockRequest('/about', 'en-gb')); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/uk/about' + ); + }); + it('redirects requests for a case mismatch of a custom prefix', () => { middlewareWithPrefixes(createMockRequest('/UK')); middlewareWithPrefixes(createMockRequest('/de/AT')); @@ -2217,6 +2233,15 @@ describe('domain-based routing', () => { ); }); + it("redirects to another domain when the locale isn't supported on the current domain", () => { + middleware( + createMockRequest('/en/about', 'en', 'http://fr.example.com/en') + ); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://en.example.com/en/about' + ); + }); + describe('base path', () => { it('redirects requests with default locale in the path', () => { middleware( 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 80500d1d5..8a47caa0d 100644 --- a/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx @@ -325,7 +325,7 @@ describe("localePrefix: 'as-needed'", () => { }); describe("localePrefix: 'as-needed', custom prefix", () => { - const {useRouter} = createLocalizedPathnamesNavigation({ + const {usePathname, useRouter} = createLocalizedPathnamesNavigation({ locales: ['en', 'de-at'] as const, pathnames: { '/': '/', @@ -357,6 +357,23 @@ describe("localePrefix: 'as-needed', custom prefix", () => { }); }); }); + + 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'", () => { diff --git a/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx index 18cd74720..a7415e26c 100644 --- a/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx @@ -133,7 +133,7 @@ describe("localePrefix: 'as-needed', custom prefix", () => { describe('usePathname', () => { it('returns the correct pathname for a custom locale prefix', () => { vi.mocked(useParams).mockImplementation(() => ({locale: 'en-gb'})); - vi.mocked(useNextPathname).mockImplementation(() => '/en-gb/about'); + vi.mocked(useNextPathname).mockImplementation(() => '/uk/about'); function Component() { return usePathname(); } diff --git a/packages/next-intl/test/navigation/react-client/useBasePathname.test.tsx b/packages/next-intl/test/navigation/react-client/useBasePathname.test.tsx index ba8f74223..ed88d8db0 100644 --- a/packages/next-intl/test/navigation/react-client/useBasePathname.test.tsx +++ b/packages/next-intl/test/navigation/react-client/useBasePathname.test.tsx @@ -13,7 +13,12 @@ function mockPathname(pathname: string) { } function Component() { - return <>{useBasePathname()}; + const pathname = useBasePathname({ + // The mode is not used, only the absence of + // `prefixes` is relevant for this test suite + mode: 'as-needed' + }); + return <>{pathname}; } describe('unprefixed routing', () => { diff --git a/packages/next-intl/test/navigation/react-client/useBaseRouter.test.tsx b/packages/next-intl/test/navigation/react-client/useBaseRouter.test.tsx index d74944531..aba277818 100644 --- a/packages/next-intl/test/navigation/react-client/useBaseRouter.test.tsx +++ b/packages/next-intl/test/navigation/react-client/useBaseRouter.test.tsx @@ -30,7 +30,7 @@ function callRouter(cb: (router: ReturnType) => void) { const router = useBaseRouter({ // The mode is not used, only the absence of // `prefixes` is relevant for this test suite - mode: 'always' + mode: 'as-needed' }); useEffect(() => { cb(router); diff --git a/packages/next-intl/test/shared/utils.test.tsx b/packages/next-intl/test/shared/utils.test.tsx index 1a7826f6a..284ba65a3 100644 --- a/packages/next-intl/test/shared/utils.test.tsx +++ b/packages/next-intl/test/shared/utils.test.tsx @@ -1,7 +1,7 @@ import {it, describe, expect} from 'vitest'; import { hasPathnamePrefixed, - unlocalizePathname, + unprefixPathname, matchesPathname, prefixPathname } from '../../src/shared/utils'; @@ -39,15 +39,19 @@ describe('hasPathnamePrefixed', () => { describe('unlocalizePathname', () => { it('works for the root', () => { - expect(unlocalizePathname('/en', 'en')).toEqual('/'); + expect(unprefixPathname('/en', '/en')).toEqual('/'); }); it('works for nested pages', () => { - expect(unlocalizePathname('/en/nested', 'en')).toEqual('/nested'); + expect(unprefixPathname('/en/nested', '/en')).toEqual('/nested'); }); it('works with sub-tags', () => { - expect(unlocalizePathname('/en-UK/nested', 'en-UK')).toEqual('/nested'); + expect(unprefixPathname('/en-GB/nested', '/en-GB')).toEqual('/nested'); + }); + + it('works for custom prefixes', () => { + expect(unprefixPathname('/uk/nested', '/uk')).toEqual('/nested'); }); }); From fa9701859ee79b89d166df3a314ebf915df219aa Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 27 May 2024 15:10:29 +0200 Subject: [PATCH 12/45] Some cleanup --- .../src/navigation/react-client/useBasePathname.tsx | 10 +++++----- packages/next-intl/src/navigation/shared/utils.tsx | 9 +++------ packages/next-intl/src/shared/utils.tsx | 4 ---- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx index 0553f2807..acde3d9a7 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx @@ -4,11 +4,7 @@ import {usePathname as useNextPathname} from 'next/navigation'; import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; import {AllLocales, LocalePrefixConfigVerbose} from '../../shared/types'; -import { - getLocalePrefix, - hasPathnamePrefixed, - unprefixPathname -} from '../../shared/utils'; +import {getLocalePrefix, hasPathnamePrefixed} from '../../shared/utils'; /** * Returns the pathname without a potential locale prefix. @@ -56,3 +52,7 @@ export default function useBasePathname( return unlocalizedPathname; }, [locale, localePrefix, pathname]); } + +function unprefixPathname(pathname: string, prefix: string) { + return pathname.replace(new RegExp(`^${prefix}`), '') || '/'; +} diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index d28e12f8f..d674e62be 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 {AllLocales, Pathnames} from '../../shared/types'; -import {matchesPathname, unprefixPathname} from '../../shared/utils'; +import {matchesPathname} from '../../shared/utils'; import StrictParams from './StrictParams'; type SearchParamValue = ParsedUrlQueryInput[keyof ParsedUrlQueryInput]; @@ -161,15 +161,12 @@ export function getRoute({ pathname: string; pathnames: Pathnames; }) { - const unlocalizedPathname = unprefixPathname( - decodeURI(pathname), // Potentially handle foreign symbols - '/' + locale - ); + const decoded = decodeURI(pathname); let template = Object.entries(pathnames).find(([, routePath]) => { const routePathname = typeof routePath !== 'string' ? routePath[locale] : routePath; - return matchesPathname(routePathname, unlocalizedPathname); + return matchesPathname(routePathname, decoded); })?.[0]; if (!template) { diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 70702f9ac..017946960 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -82,10 +82,6 @@ export function prefixHref(href: UrlObject | string, prefix: string) { return prefixedHref; } -export function unprefixPathname(pathname: string, prefix: string) { - return pathname.replace(new RegExp(`^${prefix}`), '') || '/'; -} - export function prefixPathname(prefix: string, pathname: string) { let localizedHref = prefix; From e4cd5fefedced8e7a57b10abe853f741f19f821e Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 27 May 2024 15:12:11 +0200 Subject: [PATCH 13/45] Some cleanup --- .../src/navigation/react-client/useBasePathname.tsx | 10 +++++----- packages/next-intl/src/shared/utils.tsx | 4 ++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx index acde3d9a7..0553f2807 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx @@ -4,7 +4,11 @@ import {usePathname as useNextPathname} from 'next/navigation'; import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; import {AllLocales, LocalePrefixConfigVerbose} from '../../shared/types'; -import {getLocalePrefix, hasPathnamePrefixed} from '../../shared/utils'; +import { + getLocalePrefix, + hasPathnamePrefixed, + unprefixPathname +} from '../../shared/utils'; /** * Returns the pathname without a potential locale prefix. @@ -52,7 +56,3 @@ export default function useBasePathname( return unlocalizedPathname; }, [locale, localePrefix, pathname]); } - -function unprefixPathname(pathname: string, prefix: string) { - return pathname.replace(new RegExp(`^${prefix}`), '') || '/'; -} diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 017946960..70702f9ac 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -82,6 +82,10 @@ export function prefixHref(href: UrlObject | string, prefix: string) { return prefixedHref; } +export function unprefixPathname(pathname: string, prefix: string) { + return pathname.replace(new RegExp(`^${prefix}`), '') || '/'; +} + export function prefixPathname(prefix: string, pathname: string) { let localizedHref = prefix; From d4f71f03b9a819577e953a30ba349caaba652d3e Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 27 May 2024 15:20:23 +0200 Subject: [PATCH 14/45] Fix lint --- .../react-server/createLocalizedPathnamesNavigation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx index 745112b9e..15bc4ce14 100644 --- a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx @@ -38,7 +38,7 @@ export default function createLocalizedPathnamesNavigation< locale, ...rest }: LinkProps) { - const defaultLocale = getRequestLocale() as typeof opts.locales[number]; + const defaultLocale = getRequestLocale() as (typeof opts.locales)[number]; const finalLocale = locale || defaultLocale; return ( From cbaf908acdc55305d27996965625935554b66383 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 3 Jun 2024 16:32:22 +0200 Subject: [PATCH 15/45] Clean up receiving config --- .../middleware/NextIntlMiddlewareConfig.tsx | 58 --------- packages/next-intl/src/middleware/config.tsx | 52 ++++++++ .../getAlternateLinksHeaderValue.tsx | 9 +- .../next-intl/src/middleware/middleware.tsx | 113 +++++++----------- .../src/middleware/resolveLocale.tsx | 28 +++-- packages/next-intl/src/middleware/utils.tsx | 19 ++- .../navigation/react-client/ClientLink.tsx | 2 +- .../createLocalizedPathnamesNavigation.tsx | 59 +++++---- .../createSharedPathnamesNavigation.tsx | 29 +++-- .../src/navigation/react-client/index.tsx | 2 +- .../react-client/useBasePathname.tsx | 2 +- .../navigation/react-client/useBaseRouter.tsx | 2 +- .../navigation/react-server/ServerLink.tsx | 2 +- .../createLocalizedPathnamesNavigation.tsx | 46 ++++--- .../createSharedPathnamesNavigation.tsx | 22 ++-- .../src/navigation/react-server/index.tsx | 2 +- .../src/navigation/shared/BaseLink.tsx | 2 +- .../src/navigation/shared/config.tsx | 67 +++++++++++ .../src/navigation/shared/redirects.tsx | 7 +- .../next-intl/src/navigation/shared/utils.tsx | 2 +- packages/next-intl/src/routing/config.tsx | 27 +++++ packages/next-intl/src/routing/index.tsx | 1 + packages/next-intl/src/routing/types.tsx | 40 +++++++ packages/next-intl/src/shared/types.tsx | 30 ----- packages/next-intl/src/shared/utils.tsx | 14 +-- .../getAlternateLinksHeaderValue.test.tsx | 67 ++++------- .../react-client/ClientLink.test.tsx | 2 +- 27 files changed, 378 insertions(+), 328 deletions(-) delete mode 100644 packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx create mode 100644 packages/next-intl/src/middleware/config.tsx create mode 100644 packages/next-intl/src/navigation/shared/config.tsx create mode 100644 packages/next-intl/src/routing/config.tsx create mode 100644 packages/next-intl/src/routing/index.tsx create mode 100644 packages/next-intl/src/routing/types.tsx diff --git a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx deleted file mode 100644 index 2695a20f2..000000000 --- a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { - AllLocales, - LocalePrefixConfig, - LocalePrefixConfigVerbose, - Pathnames -} from '../shared/types'; - -type RoutingBaseConfig = { - /** A list of all locales that are supported. */ - locales: Locales; - - /* Used by default if none of the defined locales match. */ - defaultLocale: Locales[number]; - - /** @see https://next-intl-docs.vercel.app/docs/routing/middleware#locale-prefix */ - localePrefix?: LocalePrefixConfig; -}; - -export type DomainConfig = Omit< - RoutingBaseConfig, - 'locales' | 'localePrefix' -> & { - /** 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; - - /** The locales availabe on this particular domain. */ - locales?: RoutingBaseConfig>['locales']; -}; - -type MiddlewareConfig = - RoutingBaseConfig & { - /** Can be used to change the locale handling per domain. */ - domains?: Array>; - - /** Sets the `Link` response header to notify search engines about content in other languages (defaults to `true`). See https://developers.google.com/search/docs/specialty/international/localized-versions#http */ - alternateLinks?: boolean; - - /** By setting this to `false`, the cookie as well as the `accept-language` header will no longer be used for locale detection. */ - localeDetection?: boolean; - - /** Maps internal pathnames to external ones which can be localized per locale. */ - pathnames?: Pathnames; - // Internal note: We want to accept this explicitly instead - // of inferring it from `next-intl/config` so that: - // a) The user gets TypeScript errors when there's a mismatch - // b) The middleware can be used in a standalone fashion - }; - -export type MiddlewareConfigWithDefaults = Omit< - MiddlewareConfig, - 'alternateLinks' | 'localePrefix' | 'localeDetection' -> & { - alternateLinks: boolean; - localePrefix: LocalePrefixConfigVerbose; - localeDetection: boolean; -}; - -export default MiddlewareConfig; diff --git a/packages/next-intl/src/middleware/config.tsx b/packages/next-intl/src/middleware/config.tsx new file mode 100644 index 000000000..90db3857c --- /dev/null +++ b/packages/next-intl/src/middleware/config.tsx @@ -0,0 +1,52 @@ +import { + RoutingBaseConfigInput, + receiveLocalePrefixConfig +} from '../routing/config'; +import { + AllLocales, + LocalePrefixConfigVerbose, + Pathnames +} from '../routing/types'; + +export type MiddlewareRoutingConfigInput< + Locales extends AllLocales, + AppPathnames extends Pathnames +> = RoutingBaseConfigInput & { + locales: Locales; + defaultLocale: Locales[number]; + + /** Sets the `Link` response header to notify search engines about content in other languages (defaults to `true`). See https://developers.google.com/search/docs/specialty/international/localized-versions#http */ + alternateLinks?: boolean; + + /** By setting this to `false`, the cookie as well as the `accept-language` header will no longer be used for locale detection. */ + localeDetection?: boolean; + + /** Maps internal pathnames to external ones which can be localized per locale. */ + pathnames?: AppPathnames; +}; + +export type MiddlewareRoutingConfig< + Locales extends AllLocales, + AppPathnames extends Pathnames +> = Omit< + MiddlewareRoutingConfigInput, + 'alternateLinks' | 'localeDetection' | 'localePrefix' +> & { + alternateLinks: boolean; + localeDetection: boolean; + localePrefix: LocalePrefixConfigVerbose; +}; + +export function receiveConfig< + Locales extends AllLocales, + AppPathnames extends Pathnames +>( + input: MiddlewareRoutingConfigInput +): MiddlewareRoutingConfig { + return { + ...input, + alternateLinks: input?.alternateLinks ?? true, + localeDetection: input?.localeDetection ?? true, + localePrefix: receiveLocalePrefixConfig(input?.localePrefix) + }; +} diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 709956144..5c2f385b2 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'; -import {AllLocales, Pathnames} from '../shared/types'; -import {MiddlewareConfigWithDefaults} from './NextIntlMiddlewareConfig'; +import {AllLocales, Pathnames} from '../routing/types'; +import {MiddlewareRoutingConfig} from './config'; import { applyBasePath, formatTemplatePathname, @@ -14,14 +14,15 @@ import { * See https://developers.google.com/search/docs/specialty/international/localized-versions */ export default function getAlternateLinksHeaderValue< - Locales extends AllLocales + Locales extends AllLocales, + AppPathnames extends Pathnames >({ config, localizedPathnames, request, resolvedLocale }: { - config: MiddlewareConfigWithDefaults; + config: MiddlewareRoutingConfig; request: NextRequest; resolvedLocale: Locales[number]; localizedPathnames?: Pathnames[string]; diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 2cda5a7cd..c731a1709 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -1,19 +1,13 @@ import {NextRequest, NextResponse} from 'next/server'; +import {AllLocales, Pathnames} from '../routing/types'; import { COOKIE_LOCALE_NAME, COOKIE_MAX_AGE, COOKIE_SAME_SITE, HEADER_LOCALE_NAME } from '../shared/constants'; -import {AllLocales} from '../shared/types'; -import { - getLocalePrefix, - matchesPathname, - receiveLocalePrefixConfig -} from '../shared/utils'; -import MiddlewareConfig, { - MiddlewareConfigWithDefaults -} from './NextIntlMiddlewareConfig'; +import {getLocalePrefix, matchesPathname} from '../shared/utils'; +import {MiddlewareRoutingConfigInput, receiveConfig} from './config'; import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue'; import resolveLocale from './resolveLocale'; import { @@ -28,50 +22,36 @@ import { normalizeTrailingSlash } from './utils'; -const ROOT_URL = '/'; - -function receiveConfig( - config: MiddlewareConfig -): MiddlewareConfigWithDefaults { - const result: MiddlewareConfigWithDefaults = { - ...config, - alternateLinks: config.alternateLinks ?? true, - localePrefix: receiveLocalePrefixConfig(config.localePrefix), - localeDetection: config.localeDetection ?? true - }; - - return result; -} - -export default function createMiddleware( - config: MiddlewareConfig -) { - const configWithDefaults = receiveConfig(config); +export default function createMiddleware< + Locales extends AllLocales, + AppPathnames extends Pathnames +>(input: MiddlewareRoutingConfigInput) { + const config = receiveConfig(input); return function middleware(request: NextRequest) { // Resolve potential foreign symbols (e.g. /ja/%E7%B4%84 → /ja/約)) const nextPathname = decodeURI(request.nextUrl.pathname); const {domain, locale} = resolveLocale( - configWithDefaults, + config, request.headers, request.cookies, nextPathname ); const hasOutdatedCookie = - configWithDefaults.localeDetection && + config.localeDetection && request.cookies.get(COOKIE_LOCALE_NAME)?.value !== locale; const hasMatchedDefaultLocale = domain ? domain.defaultLocale === locale - : locale === configWithDefaults.defaultLocale; + : locale === config.defaultLocale; const domainConfigs = - configWithDefaults.domains?.filter((curDomain) => + config.domains?.filter((curDomain) => isLocaleSupportedOnDomain(locale, curDomain) ) || []; - const hasUnknownHost = configWithDefaults.domains != null && !domain; + const hasUnknownHost = config.domains != null && !domain; function getResponseInit() { const headers = new Headers(request.headers); @@ -108,15 +88,15 @@ export default function createMiddleware( if ( bestMatchingDomain.defaultLocale === locale && - configWithDefaults.localePrefix.mode === 'as-needed' && + config.localePrefix.mode === 'as-needed' && urlObj.pathname.startsWith( - getLocalePrefix(locale, configWithDefaults.localePrefix) + getLocalePrefix(locale, config.localePrefix) ) ) { urlObj.pathname = getNormalizedPathname( urlObj.pathname, - configWithDefaults.locales, - configWithDefaults.localePrefix + config.locales, + config.localePrefix ); } } @@ -147,42 +127,42 @@ export default function createMiddleware( const normalizedPathname = getNormalizedPathname( nextPathname, - configWithDefaults.locales, - configWithDefaults.localePrefix + config.locales, + config.localePrefix ); const pathnameMatch = getPathnameMatch( nextPathname, - configWithDefaults.locales, - configWithDefaults.localePrefix + config.locales, + config.localePrefix ); const hasLocalePrefix = pathnameMatch != null; let response; - let internalTemplateName: string | undefined; + let internalTemplateName: keyof AppPathnames | undefined; let pathname = nextPathname; - if (configWithDefaults.pathnames) { + if (config.pathnames) { let resolvedTemplateLocale: Locales[number] | undefined; [resolvedTemplateLocale, internalTemplateName] = getInternalTemplate( - configWithDefaults.pathnames, + config.pathnames, normalizedPathname, locale ); if (internalTemplateName) { - const pathnameConfig = - configWithDefaults.pathnames[internalTemplateName]; + const pathnameConfig = config.pathnames[internalTemplateName]; const localeTemplate: string = typeof pathnameConfig === 'string' ? pathnameConfig - : pathnameConfig[locale]; + : // @ts-expect-error This is ok + pathnameConfig[locale]; if (matchesPathname(localeTemplate, normalizedPathname)) { pathname = formatTemplatePathname( normalizedPathname, localeTemplate, - internalTemplateName, + internalTemplateName as string, pathnameMatch?.locale ); } else { @@ -192,7 +172,8 @@ export default function createMiddleware( sourceTemplate = typeof pathnameConfig === 'string' ? pathnameConfig - : pathnameConfig[resolvedTemplateLocale]; + : // @ts-expect-error This is ok + pathnameConfig[resolvedTemplateLocale]; } else { // An internal pathname has matched that // doesn't have a localized pathname @@ -201,7 +182,7 @@ export default function createMiddleware( const localePrefix = (hasLocalePrefix || !hasMatchedDefaultLocale) && - configWithDefaults.localePrefix.mode !== 'never' + config.localePrefix.mode !== 'never' ? locale : undefined; @@ -221,16 +202,15 @@ export default function createMiddleware( } if (!response) { - if (pathname === ROOT_URL) { + if (pathname === '/') { const pathWithSearch = getPathWithSearch( - getLocalePrefix(locale, configWithDefaults.localePrefix), + getLocalePrefix(locale, config.localePrefix), request.nextUrl.search ); if ( - configWithDefaults.localePrefix.mode === 'never' || - (hasMatchedDefaultLocale && - configWithDefaults.localePrefix.mode === 'as-needed') + config.localePrefix.mode === 'never' || + (hasMatchedDefaultLocale && config.localePrefix.mode === 'as-needed') ) { response = rewrite(pathWithSearch); } else { @@ -248,16 +228,16 @@ export default function createMiddleware( request.nextUrl.search ); - if (configWithDefaults.localePrefix.mode === 'never') { + if (config.localePrefix.mode === 'never') { response = redirect(normalizedPathnameWithSearch); } else if (pathnameMatch.exact) { if ( hasMatchedDefaultLocale && - configWithDefaults.localePrefix.mode === 'as-needed' + config.localePrefix.mode === 'as-needed' ) { response = redirect(normalizedPathnameWithSearch); } else { - if (configWithDefaults.domains) { + if (config.domains) { const pathDomain = getBestMatchingDomain( domain, pathnameMatch.locale, @@ -283,15 +263,14 @@ export default function createMiddleware( } } else { if ( - configWithDefaults.localePrefix.mode === 'never' || + config.localePrefix.mode === 'never' || (hasMatchedDefaultLocale && - (configWithDefaults.localePrefix.mode === 'as-needed' || - configWithDefaults.domains)) + (config.localePrefix.mode === 'as-needed' || config.domains)) ) { response = rewrite(`/${locale}${internalPathWithSearch}`); } else { response = redirect( - getLocalePrefix(locale, configWithDefaults.localePrefix) + + getLocalePrefix(locale, config.localePrefix) + internalPathWithSearch ); } @@ -308,17 +287,17 @@ export default function createMiddleware( } if ( - configWithDefaults.localePrefix.mode !== 'never' && - configWithDefaults.alternateLinks && - configWithDefaults.locales.length > 1 + config.localePrefix.mode !== 'never' && + config.alternateLinks && + config.locales.length > 1 ) { response.headers.set( 'Link', getAlternateLinksHeaderValue({ - config: configWithDefaults, + config, localizedPathnames: internalTemplateName != null - ? configWithDefaults.pathnames?.[internalTemplateName] + ? config.pathnames?.[internalTemplateName] : undefined, request, resolvedLocale: locale diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index 541e0869f..cf35dd7a2 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -1,12 +1,9 @@ import {match} from '@formatjs/intl-localematcher'; import Negotiator from 'negotiator'; import {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; +import {AllLocales, DomainConfig, Pathnames} from '../routing/types'; import {COOKIE_LOCALE_NAME} from '../shared/constants'; -import {AllLocales} from '../shared/types'; -import { - DomainConfig, - MiddlewareConfigWithDefaults -} from './NextIntlMiddlewareConfig'; +import {MiddlewareRoutingConfig} from './config'; import {getHost, getPathnameMatch, isLocaleSupportedOnDomain} from './utils'; function findDomainFromHost( @@ -62,14 +59,17 @@ function getLocaleFromCookie( } } -function resolveLocaleFromPrefix( +function resolveLocaleFromPrefix< + Locales extends AllLocales, + AppPathnames extends Pathnames +>( { defaultLocale, localeDetection, localePrefix, locales }: Pick< - MiddlewareConfigWithDefaults, + MiddlewareRoutingConfig, 'defaultLocale' | 'localeDetection' | 'locales' | 'localePrefix' >, requestHeaders: Headers, @@ -101,8 +101,11 @@ function resolveLocaleFromPrefix( return locale; } -function resolveLocaleFromDomain( - config: MiddlewareConfigWithDefaults, +function resolveLocaleFromDomain< + Locales extends AllLocales, + AppPathnames extends Pathnames +>( + config: MiddlewareRoutingConfig, requestHeaders: Headers, requestCookies: RequestCookies, pathname: string @@ -173,8 +176,11 @@ function resolveLocaleFromDomain( return {locale, domain}; } -export default function resolveLocale( - config: MiddlewareConfigWithDefaults, +export default function resolveLocale< + Locales extends AllLocales, + AppPathnames extends Pathnames +>( + config: MiddlewareRoutingConfig, requestHeaders: Headers, requestCookies: RequestCookies, pathname: string diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 521274f90..4970aaa83 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -1,13 +1,14 @@ -import {AllLocales, LocalePrefixConfigVerbose} from '../shared/types'; +import { + AllLocales, + LocalePrefixConfigVerbose, + DomainConfig, + Pathnames +} from '../routing/types'; import { getLocalePrefix, matchesPathname, templateToRegex } from '../shared/utils'; -import { - DomainConfig, - MiddlewareConfigWithDefaults -} from './NextIntlMiddlewareConfig'; export function getFirstPathnameSegment(pathname: string) { return pathname.split('/')[1]; @@ -74,14 +75,12 @@ export function getSortedPathnames(pathnames: Array) { export function getInternalTemplate< Locales extends AllLocales, - Pathnames extends NonNullable< - MiddlewareConfigWithDefaults['pathnames'] - > + AppPathnames extends Pathnames >( - pathnames: Pathnames, + pathnames: AppPathnames, pathname: string, locale: Locales[number] -): [Locales[number] | undefined, keyof Pathnames | undefined] { +): [Locales[number] | undefined, keyof AppPathnames | undefined] { const sortedPathnames = getSortedPathnames(Object.keys(pathnames)); // Try to find a localized pathname that matches diff --git a/packages/next-intl/src/navigation/react-client/ClientLink.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.tsx index 6486a43ca..65acda4eb 100644 --- a/packages/next-intl/src/navigation/react-client/ClientLink.tsx +++ b/packages/next-intl/src/navigation/react-client/ClientLink.tsx @@ -1,6 +1,6 @@ import React, {ComponentProps, ReactElement, forwardRef} from 'react'; import useLocale from '../../react-client/useLocale'; -import {AllLocales, LocalePrefixConfigVerbose} from '../../shared/types'; +import {AllLocales, LocalePrefixConfigVerbose} from '../../routing/types'; import {getLocalePrefix} from '../../shared/utils'; import BaseLink from '../shared/BaseLink'; diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx index f6d245a67..3b7c3eb45 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx @@ -1,12 +1,11 @@ import React, {ComponentProps, ReactElement, forwardRef} from 'react'; import useLocale from '../../react-client/useLocale'; +import {AllLocales, Pathnames} from '../../routing/types'; +import {ParametersExceptFirst} from '../../shared/types'; import { - AllLocales, - LocalePrefixConfig, - ParametersExceptFirst, - Pathnames -} from '../../shared/types'; -import {receiveLocalePrefixConfig} from '../../shared/utils'; + LocalizedNavigationRoutingConfigInput, + receiveLocalizedNavigationRoutingConfig +} from '../shared/config'; import { compileLocalizedPathname, getRoute, @@ -21,17 +20,13 @@ import useBaseRouter from './useBaseRouter'; export default function createLocalizedPathnamesNavigation< Locales extends AllLocales, - PathnamesConfig extends Pathnames ->(opts: { - locales: Locales; - pathnames: PathnamesConfig; - localePrefix?: LocalePrefixConfig; -}) { - const finalLocalePrefix = receiveLocalePrefixConfig(opts?.localePrefix); + AppPathnames extends Pathnames +>(input: LocalizedNavigationRoutingConfigInput) { + const config = receiveLocalizedNavigationRoutingConfig(input); function useTypedLocale(): Locales[number] { const locale = useLocale(); - const isValid = opts.locales.includes(locale as any); + const isValid = config.locales.includes(locale as any); if (!isValid) { throw new Error( process.env.NODE_ENV !== 'production' @@ -42,14 +37,14 @@ export default function createLocalizedPathnamesNavigation< return locale; } - type LinkProps = Omit< + type LinkProps = Omit< ComponentProps, 'href' | 'name' | 'localePrefix' > & { href: HrefOrUrlObjectWithParams; locale?: Locales[number]; }; - function Link( + function Link( {href, locale, ...rest}: LinkProps, ref?: ComponentProps['ref'] ) { @@ -65,16 +60,16 @@ export default function createLocalizedPathnamesNavigation< pathname: href, // @ts-expect-error -- This is ok params: typeof href === 'object' ? href.params : undefined, - pathnames: opts.pathnames + pathnames: config.pathnames })} locale={locale} - localePrefix={finalLocalePrefix} + localePrefix={config.localePrefix} {...rest} /> ); } const LinkWithRef = forwardRef(Link) as unknown as < - Pathname extends keyof PathnamesConfig + Pathname extends keyof AppPathnames >( props: LinkProps & { ref?: ComponentProps['ref']; @@ -82,7 +77,7 @@ export default function createLocalizedPathnamesNavigation< ) => ReactElement; (LinkWithRef as any).displayName = 'Link'; - function redirect( + function redirect( href: HrefOrHrefWithParams, ...args: ParametersExceptFirst ) { @@ -90,12 +85,12 @@ export default function createLocalizedPathnamesNavigation< const locale = useTypedLocale(); const resolvedHref = getPathname({href, locale}); return clientRedirect( - {pathname: resolvedHref, localePrefix: finalLocalePrefix}, + {pathname: resolvedHref, localePrefix: config.localePrefix}, ...args ); } - function permanentRedirect( + function permanentRedirect( href: HrefOrHrefWithParams, ...args: ParametersExceptFirst ) { @@ -103,18 +98,18 @@ export default function createLocalizedPathnamesNavigation< const locale = useTypedLocale(); const resolvedHref = getPathname({href, locale}); return clientPermanentRedirect( - {pathname: resolvedHref, localePrefix: finalLocalePrefix}, + {pathname: resolvedHref, localePrefix: config.localePrefix}, ...args ); } function useRouter() { - const baseRouter = useBaseRouter(finalLocalePrefix); + const baseRouter = useBaseRouter(config.localePrefix); const defaultLocale = useTypedLocale(); return { ...baseRouter, - push( + push( href: HrefOrHrefWithParams, ...args: ParametersExceptFirst ) { @@ -125,7 +120,7 @@ export default function createLocalizedPathnamesNavigation< return baseRouter.push(resolvedHref, ...args); }, - replace( + replace( href: HrefOrHrefWithParams, ...args: ParametersExceptFirst ) { @@ -136,7 +131,7 @@ export default function createLocalizedPathnamesNavigation< return baseRouter.replace(resolvedHref, ...args); }, - prefetch( + prefetch( href: HrefOrHrefWithParams, ...args: ParametersExceptFirst ) { @@ -149,13 +144,13 @@ export default function createLocalizedPathnamesNavigation< }; } - function usePathname(): keyof PathnamesConfig { - const pathname = useBasePathname(finalLocalePrefix); + 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 pathname - ? getRoute({pathname, locale, pathnames: opts.pathnames}) + ? getRoute({pathname, locale, pathnames: config.pathnames}) : pathname; } @@ -164,12 +159,12 @@ export default function createLocalizedPathnamesNavigation< locale }: { locale: Locales[number]; - href: HrefOrHrefWithParams; + href: HrefOrHrefWithParams; }) { return compileLocalizedPathname({ ...normalizeNameOrNameWithParams(href), locale, - pathnames: opts.pathnames + pathnames: config.pathnames }); } diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx index 29ed6d83a..e27703216 100644 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx @@ -1,19 +1,19 @@ import React, {ComponentProps, ReactElement, forwardRef} from 'react'; +import {AllLocales} from '../../routing/types'; +import {ParametersExceptFirst} from '../../shared/types'; import { - AllLocales, - LocalePrefixConfig, - ParametersExceptFirst -} from '../../shared/types'; -import {receiveLocalePrefixConfig} from '../../shared/utils'; + SharedNavigationRoutingConfigInput, + receiveSharedNavigationRoutingConfig +} from '../shared/config'; import ClientLink from './ClientLink'; import {clientRedirect, clientPermanentRedirect} from './redirects'; import useBasePathname from './useBasePathname'; import useBaseRouter from './useBaseRouter'; export default function createSharedPathnamesNavigation< - Locales extends AllLocales ->(opts?: {locales?: Locales; localePrefix?: LocalePrefixConfig}) { - const finalLocalePrefix = receiveLocalePrefixConfig(opts?.localePrefix); + const Locales extends AllLocales +>(input?: SharedNavigationRoutingConfigInput) { + const config = receiveSharedNavigationRoutingConfig(input); type LinkProps = Omit< ComponentProps>, @@ -23,7 +23,7 @@ export default function createSharedPathnamesNavigation< return ( ref={ref} - localePrefix={finalLocalePrefix} + localePrefix={config.localePrefix} {...props} /> ); @@ -37,7 +37,10 @@ export default function createSharedPathnamesNavigation< pathname: string, ...args: ParametersExceptFirst ) { - return clientRedirect({pathname, localePrefix: finalLocalePrefix}, ...args); + return clientRedirect( + {pathname, localePrefix: config.localePrefix}, + ...args + ); } function permanentRedirect( @@ -45,19 +48,19 @@ export default function createSharedPathnamesNavigation< ...args: ParametersExceptFirst ) { return clientPermanentRedirect( - {pathname, localePrefix: finalLocalePrefix}, + {pathname, localePrefix: config.localePrefix}, ...args ); } function usePathname(): string { - const result = useBasePathname(finalLocalePrefix); + const result = useBasePathname(config.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(finalLocalePrefix); + return useBaseRouter(config.localePrefix); } return { diff --git a/packages/next-intl/src/navigation/react-client/index.tsx b/packages/next-intl/src/navigation/react-client/index.tsx index ebfc28f20..fc1e780c1 100644 --- a/packages/next-intl/src/navigation/react-client/index.tsx +++ b/packages/next-intl/src/navigation/react-client/index.tsx @@ -1,3 +1,3 @@ export {default as createSharedPathnamesNavigation} from './createSharedPathnamesNavigation'; export {default as createLocalizedPathnamesNavigation} from './createLocalizedPathnamesNavigation'; -export type {Pathnames} from '../../shared/types'; +export type {Pathnames} from '../../routing/types'; diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx index 0553f2807..7ea4bb406 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx @@ -3,7 +3,7 @@ import {usePathname as useNextPathname} from 'next/navigation'; import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; -import {AllLocales, LocalePrefixConfigVerbose} from '../../shared/types'; +import {AllLocales, LocalePrefixConfigVerbose} from '../../routing/types'; import { getLocalePrefix, hasPathnamePrefixed, diff --git a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx index efa1cee20..0398908d8 100644 --- a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx +++ b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx @@ -1,7 +1,7 @@ import {useRouter as useNextRouter, usePathname} from 'next/navigation'; import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; -import {AllLocales, LocalePrefixConfigVerbose} from '../../shared/types'; +import {AllLocales, LocalePrefixConfigVerbose} from '../../routing/types'; import {getLocalePrefix, localizeHref} from '../../shared/utils'; import syncLocaleCookie from '../shared/syncLocaleCookie'; import {getBasePath} from '../shared/utils'; diff --git a/packages/next-intl/src/navigation/react-server/ServerLink.tsx b/packages/next-intl/src/navigation/react-server/ServerLink.tsx index 426e231e6..5080fdcdd 100644 --- a/packages/next-intl/src/navigation/react-server/ServerLink.tsx +++ b/packages/next-intl/src/navigation/react-server/ServerLink.tsx @@ -1,6 +1,6 @@ import React, {ComponentProps} from 'react'; +import {AllLocales, LocalePrefixConfigVerbose} from '../../routing/types'; import {getLocale} from '../../server.react-server'; -import {AllLocales, LocalePrefixConfigVerbose} from '../../shared/types'; import {getLocalePrefix} from '../../shared/utils'; import BaseLink from '../shared/BaseLink'; diff --git a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx index 15bc4ce14..2c20b443f 100644 --- a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx @@ -1,12 +1,11 @@ import React, {ComponentProps} from 'react'; +import {AllLocales, Pathnames} from '../../routing/types'; import {getRequestLocale} from '../../server/react-server/RequestLocale'; +import {ParametersExceptFirst} from '../../shared/types'; import { - AllLocales, - LocalePrefixConfig, - ParametersExceptFirst, - Pathnames -} from '../../shared/types'; -import {receiveLocalePrefixConfig} from '../../shared/utils'; + LocalizedNavigationRoutingConfigInput, + receiveLocalizedNavigationRoutingConfig +} from '../shared/config'; import { HrefOrHrefWithParams, HrefOrUrlObjectWithParams, @@ -18,27 +17,23 @@ import {serverPermanentRedirect, serverRedirect} from './redirects'; export default function createLocalizedPathnamesNavigation< Locales extends AllLocales, - PathnamesConfig extends Pathnames ->(opts: { - locales: Locales; - pathnames: Pathnames; - localePrefix?: LocalePrefixConfig; -}) { - const finalLocalePrefix = receiveLocalePrefixConfig(opts?.localePrefix); + AppPathnames extends Pathnames +>(input: LocalizedNavigationRoutingConfigInput) { + const config = receiveLocalizedNavigationRoutingConfig(input); - type LinkProps = Omit< + type LinkProps = Omit< ComponentProps, 'href' | 'name' | 'localePrefix' > & { href: HrefOrUrlObjectWithParams; locale?: Locales[number]; }; - function Link({ + function Link({ href, locale, ...rest }: LinkProps) { - const defaultLocale = getRequestLocale() as (typeof opts.locales)[number]; + const defaultLocale = getRequestLocale() as (typeof config.locales)[number]; const finalLocale = locale || defaultLocale; return ( @@ -49,32 +44,35 @@ export default function createLocalizedPathnamesNavigation< pathname: href, // @ts-expect-error -- This is ok params: typeof href === 'object' ? href.params : undefined, - pathnames: opts.pathnames + pathnames: config.pathnames })} locale={locale} - localePrefix={finalLocalePrefix} + localePrefix={config.localePrefix} {...rest} /> ); } - function redirect( + function redirect( href: HrefOrHrefWithParams, ...args: ParametersExceptFirst ) { const locale = getRequestLocale(); const pathname = getPathname({href, locale}); - return serverRedirect({localePrefix: finalLocalePrefix, pathname}, ...args); + return serverRedirect( + {localePrefix: config.localePrefix, pathname}, + ...args + ); } - function permanentRedirect( + function permanentRedirect( href: HrefOrHrefWithParams, ...args: ParametersExceptFirst ) { const locale = getRequestLocale(); const pathname = getPathname({href, locale}); return serverPermanentRedirect( - {localePrefix: finalLocalePrefix, pathname}, + {localePrefix: config.localePrefix, pathname}, ...args ); } @@ -84,12 +82,12 @@ export default function createLocalizedPathnamesNavigation< locale }: { locale: Locales[number]; - href: HrefOrHrefWithParams; + href: HrefOrHrefWithParams; }) { return compileLocalizedPathname({ ...normalizeNameOrNameWithParams(href), locale, - pathnames: opts.pathnames + pathnames: config.pathnames }); } diff --git a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx index d3550a197..0ad2023b3 100644 --- a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx @@ -1,17 +1,17 @@ import React, {ComponentProps} from 'react'; +import {AllLocales} from '../../routing/types'; +import {ParametersExceptFirst} from '../../shared/types'; import { - AllLocales, - LocalePrefixConfig, - ParametersExceptFirst -} from '../../shared/types'; -import {receiveLocalePrefixConfig} from '../../shared/utils'; + SharedNavigationRoutingConfigInput, + receiveSharedNavigationRoutingConfig +} from '../shared/config'; import ServerLink from './ServerLink'; import {serverPermanentRedirect, serverRedirect} from './redirects'; export default function createSharedPathnamesNavigation< Locales extends AllLocales ->(opts?: {locales?: Locales; localePrefix?: LocalePrefixConfig}) { - const finalLocalePrefix = receiveLocalePrefixConfig(opts?.localePrefix); +>(input?: SharedNavigationRoutingConfigInput) { + const config = receiveSharedNavigationRoutingConfig(input); function notSupported(hookName: string) { return () => { @@ -27,7 +27,9 @@ export default function createSharedPathnamesNavigation< 'localePrefix' | 'locales' > ) { - return localePrefix={finalLocalePrefix} {...props} />; + return ( + localePrefix={config.localePrefix} {...props} /> + ); } function redirect( @@ -35,7 +37,7 @@ export default function createSharedPathnamesNavigation< ...args: ParametersExceptFirst ) { return serverRedirect( - {...opts, pathname, localePrefix: finalLocalePrefix}, + {pathname, localePrefix: config.localePrefix}, ...args ); } @@ -45,7 +47,7 @@ export default function createSharedPathnamesNavigation< ...args: ParametersExceptFirst ) { return serverPermanentRedirect( - {...opts, pathname, localePrefix: finalLocalePrefix}, + {pathname, localePrefix: config.localePrefix}, ...args ); } diff --git a/packages/next-intl/src/navigation/react-server/index.tsx b/packages/next-intl/src/navigation/react-server/index.tsx index ebfc28f20..fc1e780c1 100644 --- a/packages/next-intl/src/navigation/react-server/index.tsx +++ b/packages/next-intl/src/navigation/react-server/index.tsx @@ -1,3 +1,3 @@ export {default as createSharedPathnamesNavigation} from './createSharedPathnamesNavigation'; export {default as createLocalizedPathnamesNavigation} from './createLocalizedPathnamesNavigation'; -export type {Pathnames} from '../../shared/types'; +export type {Pathnames} from '../../routing/types'; diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index 427264292..a4c44b7c1 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -11,7 +11,7 @@ import React, { useState } from 'react'; import useLocale from '../../react-client/useLocale'; -import {LocalePrefixMode} from '../../shared/types'; +import {LocalePrefixMode} from '../../routing/types'; import {isLocalizableHref, localizeHref, prefixHref} from '../../shared/utils'; import syncLocaleCookie from './syncLocaleCookie'; diff --git a/packages/next-intl/src/navigation/shared/config.tsx b/packages/next-intl/src/navigation/shared/config.tsx new file mode 100644 index 000000000..14cb962d1 --- /dev/null +++ b/packages/next-intl/src/navigation/shared/config.tsx @@ -0,0 +1,67 @@ +import { + RoutingBaseConfigInput, + receiveLocalePrefixConfig +} from '../../routing/config'; +import { + AllLocales, + LocalePrefixConfigVerbose, + Pathnames +} from '../../routing/types'; + +/** + * Shared pathnames + */ + +export type SharedNavigationRoutingConfigInput = + RoutingBaseConfigInput & { + locales?: Locales; + }; + +export type SharedNavigationRoutingConfig = + SharedNavigationRoutingConfigInput & { + localePrefix: LocalePrefixConfigVerbose; + }; + +export function receiveSharedNavigationRoutingConfig< + Locales extends AllLocales +>( + input?: SharedNavigationRoutingConfigInput +): SharedNavigationRoutingConfig { + return { + ...input, + localePrefix: receiveLocalePrefixConfig(input?.localePrefix) + }; +} + +/** + * Localized pathnames + */ + +export type LocalizedNavigationRoutingConfigInput< + Locales extends AllLocales, + AppPathnames extends Pathnames +> = RoutingBaseConfigInput & { + locales: Locales; + + /** Maps internal pathnames to external ones which can be localized per locale. */ + pathnames: AppPathnames; +}; + +export type LocalizedNavigationRoutingConfig< + Locales extends AllLocales, + AppPathnames extends Pathnames +> = LocalizedNavigationRoutingConfigInput & { + localePrefix: LocalePrefixConfigVerbose; +}; + +export function receiveLocalizedNavigationRoutingConfig< + Locales extends AllLocales, + AppPathnames extends Pathnames +>( + input: LocalizedNavigationRoutingConfigInput +): LocalizedNavigationRoutingConfig { + return { + ...input, + localePrefix: receiveLocalePrefixConfig(input?.localePrefix) + }; +} diff --git a/packages/next-intl/src/navigation/shared/redirects.tsx b/packages/next-intl/src/navigation/shared/redirects.tsx index a1ab4a86c..35eb86bd2 100644 --- a/packages/next-intl/src/navigation/shared/redirects.tsx +++ b/packages/next-intl/src/navigation/shared/redirects.tsx @@ -2,11 +2,8 @@ import { permanentRedirect as nextPermanentRedirect, redirect as nextRedirect } from 'next/navigation'; -import { - AllLocales, - LocalePrefixConfigVerbose, - ParametersExceptFirst -} from '../../shared/types'; +import {AllLocales, LocalePrefixConfigVerbose} from '../../routing/types'; +import {ParametersExceptFirst} from '../../shared/types'; import {getLocalePrefix, isLocalHref, prefixPathname} from '../../shared/utils'; function createRedirectFn(redirectFn: typeof nextRedirect) { diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index d674e62be..d241afcea 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,6 +1,6 @@ import type {ParsedUrlQueryInput} from 'node:querystring'; import type {UrlObject} from 'url'; -import {AllLocales, Pathnames} from '../../shared/types'; +import {AllLocales, Pathnames} from '../../routing/types'; import {matchesPathname} from '../../shared/utils'; import StrictParams from './StrictParams'; diff --git a/packages/next-intl/src/routing/config.tsx b/packages/next-intl/src/routing/config.tsx new file mode 100644 index 000000000..7c5583096 --- /dev/null +++ b/packages/next-intl/src/routing/config.tsx @@ -0,0 +1,27 @@ +import { + AllLocales, + DomainConfig, + LocalePrefixConfig, + LocalePrefixConfigVerbose +} from './types'; + +/** + * Maintainer note: The config that is accepted by the middleware, the shared + * and the localized pathnames navigation factory function is slightly + * different. This type declares the shared base config that is accepted by all + * of them. Properties that are different are declared in consuming types. + */ +export type RoutingBaseConfigInput = { + /** @see https://next-intl-docs.vercel.app/docs/routing/middleware#locale-prefix */ + localePrefix?: LocalePrefixConfig; + /** Can be used to change the locale handling per domain. */ + domains?: Array>; +}; + +export function receiveLocalePrefixConfig( + localePrefix?: LocalePrefixConfig +): LocalePrefixConfigVerbose { + return typeof localePrefix === 'object' + ? localePrefix + : {mode: localePrefix || 'always'}; +} diff --git a/packages/next-intl/src/routing/index.tsx b/packages/next-intl/src/routing/index.tsx new file mode 100644 index 000000000..470f2f1b0 --- /dev/null +++ b/packages/next-intl/src/routing/index.tsx @@ -0,0 +1 @@ +// No public API yet diff --git a/packages/next-intl/src/routing/types.tsx b/packages/next-intl/src/routing/types.tsx new file mode 100644 index 000000000..96671aa18 --- /dev/null +++ b/packages/next-intl/src/routing/types.tsx @@ -0,0 +1,40 @@ +export type AllLocales = ReadonlyArray; + +export type LocalePrefixMode = 'always' | 'as-needed' | 'never'; + +export type LocalePrefixes = Partial< + Record +>; + +export type LocalePrefixConfigVerbose = + | { + mode: 'always'; + prefixes?: LocalePrefixes; + } + | { + mode: 'as-needed'; + prefixes?: LocalePrefixes; + } + | { + mode: 'never'; + }; + +export type LocalePrefixConfig = + | LocalePrefixMode + | LocalePrefixConfigVerbose; + +export type Pathnames = Record< + string, + Record | string +>; + +export type DomainConfig = { + /* Used by default if none of the defined locales match. */ + defaultLocale: Locales[number]; + + /** 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?: Locales; +}; diff --git a/packages/next-intl/src/shared/types.tsx b/packages/next-intl/src/shared/types.tsx index 8d37e7ca4..2efd9d1a2 100644 --- a/packages/next-intl/src/shared/types.tsx +++ b/packages/next-intl/src/shared/types.tsx @@ -1,33 +1,3 @@ -export type AllLocales = ReadonlyArray; - -export type LocalePrefixMode = 'as-needed' | 'always' | 'never'; - -export type LocalePrefixes = Partial< - Record ->; - -export type LocalePrefixConfigVerbose = - | { - mode: 'always'; - prefixes?: LocalePrefixes; - } - | { - mode: 'as-needed'; - prefixes?: LocalePrefixes; - } - | { - mode: 'never'; - }; - -export type LocalePrefixConfig = - | LocalePrefixMode - | LocalePrefixConfigVerbose; - -export type Pathnames = Record< - string, - {[Key in Locales[number]]: string} | string ->; - export type ParametersExceptFirst = Fn extends ( arg0: any, ...rest: infer R diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 70702f9ac..91ceda462 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -1,11 +1,7 @@ import {UrlObject} from 'url'; import NextLink from 'next/link'; import {ComponentProps} from 'react'; -import { - AllLocales, - LocalePrefixConfig, - LocalePrefixConfigVerbose -} from './types'; +import {AllLocales, LocalePrefixConfigVerbose} from '../routing/types'; type Href = ComponentProps['href']; @@ -125,14 +121,6 @@ export function getLocalePrefix( ); } -export function receiveLocalePrefixConfig( - localePrefix?: LocalePrefixConfig -): LocalePrefixConfigVerbose { - return typeof localePrefix === 'object' - ? localePrefix - : {mode: localePrefix || 'always'}; -} - export function templateToRegex(template: string): RegExp { const regexPattern = template // Replace optional catchall ('[[...slug]]') diff --git a/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx index e01e94a9c..04a55952e 100644 --- a/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx +++ b/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx @@ -2,7 +2,7 @@ import {NextRequest} from 'next/server'; import {it, expect, describe} from 'vitest'; -import {MiddlewareConfigWithDefaults} from '../../src/middleware/NextIntlMiddlewareConfig'; +import {receiveConfig} from '../../src/middleware/config'; import getAlternateLinksHeaderValue from '../../src/middleware/getAlternateLinksHeaderValue'; import {Pathnames} from '../../src/navigation/react-client'; @@ -29,13 +29,11 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( } it('works for prefixed routing (as-needed)', () => { - const config: MiddlewareConfigWithDefaults<['en', 'es']> = { + const config = receiveConfig({ defaultLocale: 'en', locales: ['en', 'es'], - alternateLinks: true, - localePrefix: {mode: 'as-needed'}, - localeDetection: true - }; + localePrefix: 'as-needed' + }); expect( getAlternateLinksHeaderValue({ @@ -79,13 +77,11 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it('works for prefixed routing (as-needed) with `pathnames`', () => { - const config: MiddlewareConfigWithDefaults<['en', 'de']> = { + const config = receiveConfig({ defaultLocale: 'en', locales: ['en', 'de'], - alternateLinks: true, - localePrefix: {mode: 'as-needed'}, - localeDetection: true - }; + localePrefix: 'as-needed' + }); const pathnames = { '/': '/', '/about': { @@ -160,13 +156,11 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it('works for prefixed routing (always)', () => { - const config: MiddlewareConfigWithDefaults<['en', 'es']> = { + const config = receiveConfig({ defaultLocale: 'en', locales: ['en', 'es'], - alternateLinks: true, - localePrefix: {mode: 'always'}, - localeDetection: true - }; + localePrefix: 'always' + }); expect( getAlternateLinksHeaderValue({ @@ -195,7 +189,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it("works for type domain with `localePrefix: 'as-needed'`", () => { - const config: MiddlewareConfigWithDefaults<['en', 'es', 'fr']> = { + const config = receiveConfig({ defaultLocale: 'en', locales: ['en', 'es', 'fr'], alternateLinks: true, @@ -218,7 +212,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( locales: ['en', 'fr'] } ] - }; + }); [ getAlternateLinksHeaderValue({ @@ -265,12 +259,9 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it("works for type domain with `localePrefix: 'always'`", () => { - const config: MiddlewareConfigWithDefaults<['en', 'es', 'fr']> = { + const config = receiveConfig({ defaultLocale: 'en', locales: ['en', 'es', 'fr'], - alternateLinks: true, - localePrefix: {mode: 'always'}, - localeDetection: true, domains: [ { domain: 'example.com', @@ -288,7 +279,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( locales: ['en', 'fr'] } ] - }; + }); [ getAlternateLinksHeaderValue({ @@ -329,10 +320,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it("works for type domain with `localePrefix: 'as-needed' with `pathnames``", () => { - const config: MiddlewareConfigWithDefaults<['en', 'fr']> = { - alternateLinks: true, + const config = receiveConfig({ localePrefix: {mode: 'as-needed'}, - localeDetection: true, defaultLocale: 'en', locales: ['en', 'fr'], domains: [ @@ -371,7 +360,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( fr: '/categories/[[...slug]]' } } satisfies Pathnames> - }; + }); [ getAlternateLinksHeaderValue({ @@ -485,13 +474,11 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it('uses the external host name from headers instead of the url of the incoming request (relevant when running the app behind a proxy)', () => { - const config: MiddlewareConfigWithDefaults<['en', 'es']> = { + const config = receiveConfig({ defaultLocale: 'en', locales: ['en', 'es'], - alternateLinks: true, - localePrefix: {mode: 'as-needed'}, - localeDetection: true - }; + localePrefix: 'as-needed' + }); expect( getAlternateLinksHeaderValue({ @@ -513,13 +500,11 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it('keeps the port of an external host if provided', () => { - const config: MiddlewareConfigWithDefaults<['en', 'es']> = { + const config = receiveConfig({ defaultLocale: 'en', locales: ['en', 'es'], - alternateLinks: true, - localePrefix: {mode: 'as-needed'}, - localeDetection: true - }; + localePrefix: 'as-needed' + }); expect( getAlternateLinksHeaderValue({ @@ -541,13 +526,11 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it('uses the external host name and the port from headers instead of the url with port of the incoming request (relevant when running the app behind a proxy)', () => { - const config: MiddlewareConfigWithDefaults<['en', 'es']> = { + const config = receiveConfig({ defaultLocale: 'en', locales: ['en', 'es'], - alternateLinks: true, - localePrefix: {mode: 'as-needed'}, - localeDetection: true - }; + localePrefix: 'as-needed' + }); expect( getAlternateLinksHeaderValue({ diff --git a/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx b/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx index 1710ceddb..c1bfff4e7 100644 --- a/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx +++ b/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx @@ -4,7 +4,7 @@ import React, {ComponentProps, LegacyRef, forwardRef} from 'react'; import {it, describe, vi, beforeEach, expect} from 'vitest'; import {NextIntlClientProvider} from '../../../src/index.react-client'; import ClientLink from '../../../src/navigation/react-client/ClientLink'; -import {LocalePrefixConfigVerbose} from '../../../src/shared/types'; +import {LocalePrefixConfigVerbose} from '../../../src/routing/types'; vi.mock('next/navigation'); From 301f1dc1334fe09e98020c5ae6504f38095648a4 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 3 Jun 2024 16:46:22 +0200 Subject: [PATCH 16/45] Refactor Locales > AppLocales, AllLocales > Locales --- packages/next-intl/src/middleware/config.tsx | 32 ++++++-------- .../getAlternateLinksHeaderValue.tsx | 14 +++--- .../next-intl/src/middleware/middleware.tsx | 10 ++--- .../src/middleware/resolveLocale.tsx | 34 +++++++------- packages/next-intl/src/middleware/utils.tsx | 44 +++++++++---------- .../navigation/react-client/ClientLink.tsx | 18 ++++---- .../createLocalizedPathnamesNavigation.tsx | 16 +++---- .../createSharedPathnamesNavigation.tsx | 12 ++--- .../react-client/useBasePathname.tsx | 6 +-- .../navigation/react-client/useBaseRouter.tsx | 14 +++--- .../navigation/react-server/ServerLink.tsx | 12 ++--- .../createLocalizedPathnamesNavigation.tsx | 14 +++--- .../createSharedPathnamesNavigation.tsx | 10 ++--- .../src/navigation/shared/config.tsx | 44 +++++++++---------- .../src/navigation/shared/redirects.tsx | 8 ++-- .../next-intl/src/navigation/shared/utils.tsx | 30 ++++++------- packages/next-intl/src/routing/config.tsx | 14 +++--- packages/next-intl/src/routing/types.tsx | 26 +++++------ packages/next-intl/src/shared/utils.tsx | 8 ++-- 19 files changed, 181 insertions(+), 185 deletions(-) diff --git a/packages/next-intl/src/middleware/config.tsx b/packages/next-intl/src/middleware/config.tsx index 90db3857c..f2d005350 100644 --- a/packages/next-intl/src/middleware/config.tsx +++ b/packages/next-intl/src/middleware/config.tsx @@ -2,18 +2,14 @@ import { RoutingBaseConfigInput, receiveLocalePrefixConfig } from '../routing/config'; -import { - AllLocales, - LocalePrefixConfigVerbose, - Pathnames -} from '../routing/types'; +import {Locales, LocalePrefixConfigVerbose, Pathnames} from '../routing/types'; export type MiddlewareRoutingConfigInput< - Locales extends AllLocales, - AppPathnames extends Pathnames -> = RoutingBaseConfigInput & { - locales: Locales; - defaultLocale: Locales[number]; + AppLocales extends Locales, + AppPathnames extends Pathnames +> = RoutingBaseConfigInput & { + locales: AppLocales; + defaultLocale: AppLocales[number]; /** Sets the `Link` response header to notify search engines about content in other languages (defaults to `true`). See https://developers.google.com/search/docs/specialty/international/localized-versions#http */ alternateLinks?: boolean; @@ -26,23 +22,23 @@ export type MiddlewareRoutingConfigInput< }; export type MiddlewareRoutingConfig< - Locales extends AllLocales, - AppPathnames extends Pathnames + AppLocales extends Locales, + AppPathnames extends Pathnames > = Omit< - MiddlewareRoutingConfigInput, + MiddlewareRoutingConfigInput, 'alternateLinks' | 'localeDetection' | 'localePrefix' > & { alternateLinks: boolean; localeDetection: boolean; - localePrefix: LocalePrefixConfigVerbose; + localePrefix: LocalePrefixConfigVerbose; }; export function receiveConfig< - Locales extends AllLocales, - AppPathnames extends Pathnames + AppLocales extends Locales, + AppPathnames extends Pathnames >( - input: MiddlewareRoutingConfigInput -): MiddlewareRoutingConfig { + input: MiddlewareRoutingConfigInput +): MiddlewareRoutingConfig { return { ...input, alternateLinks: input?.alternateLinks ?? true, diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 5c2f385b2..8eb22e412 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -1,5 +1,5 @@ import {NextRequest} from 'next/server'; -import {AllLocales, Pathnames} from '../routing/types'; +import {Locales, Pathnames} from '../routing/types'; import {MiddlewareRoutingConfig} from './config'; import { applyBasePath, @@ -14,18 +14,18 @@ import { * See https://developers.google.com/search/docs/specialty/international/localized-versions */ export default function getAlternateLinksHeaderValue< - Locales extends AllLocales, - AppPathnames extends Pathnames + AppLocales extends Locales, + AppPathnames extends Pathnames >({ config, localizedPathnames, request, resolvedLocale }: { - config: MiddlewareRoutingConfig; + config: MiddlewareRoutingConfig; request: NextRequest; - resolvedLocale: Locales[number]; - localizedPathnames?: Pathnames[string]; + resolvedLocale: AppLocales[number]; + localizedPathnames?: Pathnames[string]; }) { const normalizedUrl = request.nextUrl.clone(); @@ -52,7 +52,7 @@ export default function getAlternateLinksHeaderValue< return `<${url.toString()}>; rel="alternate"; hreflang="${locale}"`; } - function getLocalizedPathname(pathname: string, locale: Locales[number]) { + function getLocalizedPathname(pathname: string, locale: AppLocales[number]) { if (localizedPathnames && typeof localizedPathnames === 'object') { return formatTemplatePathname( pathname, diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index c731a1709..930198e90 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -1,5 +1,5 @@ import {NextRequest, NextResponse} from 'next/server'; -import {AllLocales, Pathnames} from '../routing/types'; +import {Locales, Pathnames} from '../routing/types'; import { COOKIE_LOCALE_NAME, COOKIE_MAX_AGE, @@ -23,9 +23,9 @@ import { } from './utils'; export default function createMiddleware< - Locales extends AllLocales, - AppPathnames extends Pathnames ->(input: MiddlewareRoutingConfigInput) { + AppLocales extends Locales, + AppPathnames extends Pathnames +>(input: MiddlewareRoutingConfigInput) { const config = receiveConfig(input); return function middleware(request: NextRequest) { @@ -143,7 +143,7 @@ export default function createMiddleware< let pathname = nextPathname; if (config.pathnames) { - let resolvedTemplateLocale: Locales[number] | undefined; + let resolvedTemplateLocale: AppLocales[number] | undefined; [resolvedTemplateLocale, internalTemplateName] = getInternalTemplate( config.pathnames, normalizedPathname, diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index cf35dd7a2..bcd0e14c9 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -1,14 +1,14 @@ import {match} from '@formatjs/intl-localematcher'; import Negotiator from 'negotiator'; import {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; -import {AllLocales, DomainConfig, Pathnames} from '../routing/types'; +import {Locales, DomainConfig, Pathnames} from '../routing/types'; import {COOKIE_LOCALE_NAME} from '../shared/constants'; import {MiddlewareRoutingConfig} from './config'; import {getHost, getPathnameMatch, isLocaleSupportedOnDomain} from './utils'; -function findDomainFromHost( +function findDomainFromHost( requestHeaders: Headers, - domains: Array> + domains: Array> ) { let host = getHost(requestHeaders); @@ -22,9 +22,9 @@ function findDomainFromHost( return undefined; } -export function getAcceptLanguageLocale( +export function getAcceptLanguageLocale( requestHeaders: Headers, - locales: Locales, + locales: AppLocales, defaultLocale: string ) { let locale; @@ -47,9 +47,9 @@ export function getAcceptLanguageLocale( return locale; } -function getLocaleFromCookie( +function getLocaleFromCookie( requestCookies: RequestCookies, - locales: Locales + locales: AppLocales ) { if (requestCookies.has(COOKIE_LOCALE_NAME)) { const value = requestCookies.get(COOKIE_LOCALE_NAME)?.value; @@ -60,8 +60,8 @@ function getLocaleFromCookie( } function resolveLocaleFromPrefix< - Locales extends AllLocales, - AppPathnames extends Pathnames + AppLocales extends Locales, + AppPathnames extends Pathnames >( { defaultLocale, @@ -69,7 +69,7 @@ function resolveLocaleFromPrefix< localePrefix, locales }: Pick< - MiddlewareRoutingConfig, + MiddlewareRoutingConfig, 'defaultLocale' | 'localeDetection' | 'locales' | 'localePrefix' >, requestHeaders: Headers, @@ -102,10 +102,10 @@ function resolveLocaleFromPrefix< } function resolveLocaleFromDomain< - Locales extends AllLocales, - AppPathnames extends Pathnames + AppLocales extends Locales, + AppPathnames extends Pathnames >( - config: MiddlewareRoutingConfig, + config: MiddlewareRoutingConfig, requestHeaders: Headers, requestCookies: RequestCookies, pathname: string @@ -177,14 +177,14 @@ function resolveLocaleFromDomain< } export default function resolveLocale< - Locales extends AllLocales, - AppPathnames extends Pathnames + AppLocales extends Locales, + AppPathnames extends Pathnames >( - config: MiddlewareRoutingConfig, + config: MiddlewareRoutingConfig, requestHeaders: Headers, requestCookies: RequestCookies, pathname: string -): {locale: Locales[number]; domain?: DomainConfig} { +): {locale: AppLocales[number]; domain?: DomainConfig} { if (config.domains) { return resolveLocaleFromDomain( config, diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 4970aaa83..ddf379054 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -1,5 +1,5 @@ import { - AllLocales, + Locales, LocalePrefixConfigVerbose, DomainConfig, Pathnames @@ -74,13 +74,13 @@ export function getSortedPathnames(pathnames: Array) { } export function getInternalTemplate< - Locales extends AllLocales, - AppPathnames extends Pathnames + AppLocales extends Locales, + AppPathnames extends Pathnames >( pathnames: AppPathnames, pathname: string, - locale: Locales[number] -): [Locales[number] | undefined, keyof AppPathnames | undefined] { + locale: AppLocales[number] +): [AppLocales[number] | undefined, keyof AppPathnames | undefined] { const sortedPathnames = getSortedPathnames(Object.keys(pathnames)); // Try to find a localized pathname that matches @@ -143,10 +143,10 @@ export function formatTemplatePathname( /** * Removes potential prefixes from the pathname. */ -export function getNormalizedPathname( +export function getNormalizedPathname( pathname: string, - locales: Locales, - localePrefix: LocalePrefixConfigVerbose + locales: AppLocales, + localePrefix: LocalePrefixConfigVerbose ) { // Add trailing slash for consistent handling // both for the root as well as nested paths @@ -178,23 +178,23 @@ export function findCaseInsensitiveString( return strings.find((cur) => cur.toLowerCase() === candidate.toLowerCase()); } -export function getLocalePrefixes( - locales: Locales, - localePrefix: LocalePrefixConfigVerbose -): Array<[Locales[number], string]> { +export function getLocalePrefixes( + locales: AppLocales, + localePrefix: LocalePrefixConfigVerbose +): Array<[AppLocales[number], string]> { return locales.map((locale) => [ - locale as Locales[number], + locale as AppLocales[number], getLocalePrefix(locale, localePrefix) ]); } -export function getPathnameMatch( +export function getPathnameMatch( pathname: string, - locales: Locales, - localePrefix: LocalePrefixConfigVerbose + locales: AppLocales, + localePrefix: LocalePrefixConfigVerbose ): | { - locale: Locales[number]; + locale: AppLocales[number]; prefix: string; matchedPrefix: string; exact?: boolean; @@ -275,9 +275,9 @@ export function getHost(requestHeaders: Headers) { ); } -export function isLocaleSupportedOnDomain( +export function isLocaleSupportedOnDomain( locale: string, - domain: DomainConfig + domain: DomainConfig ) { return ( domain.defaultLocale === locale || @@ -286,10 +286,10 @@ export function isLocaleSupportedOnDomain( ); } -export function getBestMatchingDomain( - curHostDomain: DomainConfig | undefined, +export function getBestMatchingDomain( + curHostDomain: DomainConfig | undefined, locale: string, - domainConfigs: Array> + domainConfigs: Array> ) { let domainConfig; diff --git a/packages/next-intl/src/navigation/react-client/ClientLink.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.tsx index 65acda4eb..199b207df 100644 --- a/packages/next-intl/src/navigation/react-client/ClientLink.tsx +++ b/packages/next-intl/src/navigation/react-client/ClientLink.tsx @@ -1,20 +1,20 @@ import React, {ComponentProps, ReactElement, forwardRef} from 'react'; import useLocale from '../../react-client/useLocale'; -import {AllLocales, LocalePrefixConfigVerbose} from '../../routing/types'; +import {Locales, LocalePrefixConfigVerbose} from '../../routing/types'; import {getLocalePrefix} from '../../shared/utils'; import BaseLink from '../shared/BaseLink'; -type Props = Omit< +type Props = Omit< ComponentProps, 'locale' | 'prefix' | 'localePrefixMode' > & { - locale?: Locales[number]; - localePrefix: LocalePrefixConfigVerbose; + locale?: AppLocales[number]; + localePrefix: LocalePrefixConfigVerbose; }; -function ClientLink( - {locale, localePrefix, ...rest}: Props, - ref: Props['ref'] +function ClientLink( + {locale, localePrefix, ...rest}: Props, + ref: Props['ref'] ) { const defaultLocale = useLocale(); const finalLocale = locale || defaultLocale; @@ -52,9 +52,9 @@ function ClientLink( * page to be overwritten before the user even decides to change the locale. */ const ClientLinkWithRef = forwardRef(ClientLink) as < - Locales extends AllLocales + AppLocales extends Locales >( - props: Props & {ref?: Props['ref']} + 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.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx index 3b7c3eb45..ad05b8626 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx @@ -1,6 +1,6 @@ import React, {ComponentProps, ReactElement, forwardRef} from 'react'; import useLocale from '../../react-client/useLocale'; -import {AllLocales, Pathnames} from '../../routing/types'; +import {Locales, Pathnames} from '../../routing/types'; import {ParametersExceptFirst} from '../../shared/types'; import { LocalizedNavigationRoutingConfigInput, @@ -19,12 +19,12 @@ import useBasePathname from './useBasePathname'; import useBaseRouter from './useBaseRouter'; export default function createLocalizedPathnamesNavigation< - Locales extends AllLocales, - AppPathnames extends Pathnames ->(input: LocalizedNavigationRoutingConfigInput) { + AppLocales extends Locales, + AppPathnames extends Pathnames +>(input: LocalizedNavigationRoutingConfigInput) { const config = receiveLocalizedNavigationRoutingConfig(input); - function useTypedLocale(): Locales[number] { + function useTypedLocale(): AppLocales[number] { const locale = useLocale(); const isValid = config.locales.includes(locale as any); if (!isValid) { @@ -42,7 +42,7 @@ export default function createLocalizedPathnamesNavigation< 'href' | 'name' | 'localePrefix' > & { href: HrefOrUrlObjectWithParams; - locale?: Locales[number]; + locale?: AppLocales[number]; }; function Link( {href, locale, ...rest}: LinkProps, @@ -54,7 +54,7 @@ export default function createLocalizedPathnamesNavigation< return ( ({ + href={compileLocalizedPathname({ locale: finalLocale, // @ts-expect-error -- This is ok pathname: href, @@ -158,7 +158,7 @@ export default function createLocalizedPathnamesNavigation< href, locale }: { - locale: Locales[number]; + locale: AppLocales[number]; href: HrefOrHrefWithParams; }) { return compileLocalizedPathname({ diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx index e27703216..c55f689eb 100644 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx @@ -1,5 +1,5 @@ import React, {ComponentProps, ReactElement, forwardRef} from 'react'; -import {AllLocales} from '../../routing/types'; +import {Locales} from '../../routing/types'; import {ParametersExceptFirst} from '../../shared/types'; import { SharedNavigationRoutingConfigInput, @@ -11,17 +11,17 @@ import useBasePathname from './useBasePathname'; import useBaseRouter from './useBaseRouter'; export default function createSharedPathnamesNavigation< - const Locales extends AllLocales ->(input?: SharedNavigationRoutingConfigInput) { + const AppLocales extends Locales +>(input?: SharedNavigationRoutingConfigInput) { const config = receiveSharedNavigationRoutingConfig(input); type LinkProps = Omit< - ComponentProps>, + ComponentProps>, 'localePrefix' >; function Link(props: LinkProps, ref: LinkProps['ref']) { return ( - + ref={ref} localePrefix={config.localePrefix} {...props} @@ -60,7 +60,7 @@ export default function createSharedPathnamesNavigation< } function useRouter() { - return useBaseRouter(config.localePrefix); + return useBaseRouter(config.localePrefix); } return { diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx index 7ea4bb406..397ae26d2 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx @@ -3,7 +3,7 @@ import {usePathname as useNextPathname} from 'next/navigation'; import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; -import {AllLocales, LocalePrefixConfigVerbose} from '../../routing/types'; +import {Locales, LocalePrefixConfigVerbose} from '../../routing/types'; import { getLocalePrefix, hasPathnamePrefixed, @@ -23,8 +23,8 @@ import { * const pathname = usePathname(); * ``` */ -export default function useBasePathname( - localePrefix: LocalePrefixConfigVerbose +export default function useBasePathname( + localePrefix: LocalePrefixConfigVerbose ) { // The types aren't entirely correct here. Outside of Next.js // `useParams` can be called, but the return type is `null`. diff --git a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx index 0398908d8..9be7e2f49 100644 --- a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx +++ b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx @@ -1,13 +1,13 @@ import {useRouter as useNextRouter, usePathname} from 'next/navigation'; import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; -import {AllLocales, LocalePrefixConfigVerbose} from '../../routing/types'; +import {Locales, LocalePrefixConfigVerbose} from '../../routing/types'; import {getLocalePrefix, localizeHref} from '../../shared/utils'; import syncLocaleCookie from '../shared/syncLocaleCookie'; import {getBasePath} from '../shared/utils'; -type IntlNavigateOptions = { - locale?: Locales[number]; +type IntlNavigateOptions = { + locale?: AppLocales[number]; }; /** @@ -29,15 +29,15 @@ type IntlNavigateOptions = { * router.push('/about', {locale: 'de'}); * ``` */ -export default function useBaseRouter( - localePrefix: LocalePrefixConfigVerbose +export default function useBaseRouter( + localePrefix: LocalePrefixConfigVerbose ) { const router = useNextRouter(); const locale = useLocale(); const pathname = usePathname(); return useMemo(() => { - function localize(href: string, nextLocale?: Locales[number]) { + function localize(href: string, nextLocale?: AppLocales[number]) { let curPathname = window.location.pathname; const basePath = getBasePath(pathname); @@ -58,7 +58,7 @@ export default function useBaseRouter( >(fn: Fn) { return function handler( href: string, - options?: Options & IntlNavigateOptions + options?: Options & IntlNavigateOptions ): void { const {locale: nextLocale, ...rest} = options || {}; diff --git a/packages/next-intl/src/navigation/react-server/ServerLink.tsx b/packages/next-intl/src/navigation/react-server/ServerLink.tsx index 5080fdcdd..c254a6c39 100644 --- a/packages/next-intl/src/navigation/react-server/ServerLink.tsx +++ b/packages/next-intl/src/navigation/react-server/ServerLink.tsx @@ -1,22 +1,22 @@ import React, {ComponentProps} from 'react'; -import {AllLocales, LocalePrefixConfigVerbose} from '../../routing/types'; +import {Locales, LocalePrefixConfigVerbose} from '../../routing/types'; import {getLocale} from '../../server.react-server'; import {getLocalePrefix} from '../../shared/utils'; import BaseLink from '../shared/BaseLink'; -type Props = Omit< +type Props = Omit< ComponentProps, 'locale' | 'prefix' | 'localePrefixMode' > & { - locale?: Locales[number]; - localePrefix: LocalePrefixConfigVerbose; + locale?: AppLocales[number]; + localePrefix: LocalePrefixConfigVerbose; }; -export default async function ServerLink({ +export default async function ServerLink({ locale, localePrefix, ...rest -}: Props) { +}: Props) { const finalLocale = locale || (await getLocale()); const prefix = getLocalePrefix(finalLocale, localePrefix); diff --git a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx index 2c20b443f..b34c72a34 100644 --- a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx @@ -1,5 +1,5 @@ import React, {ComponentProps} from 'react'; -import {AllLocales, Pathnames} from '../../routing/types'; +import {Locales, Pathnames} from '../../routing/types'; import {getRequestLocale} from '../../server/react-server/RequestLocale'; import {ParametersExceptFirst} from '../../shared/types'; import { @@ -16,9 +16,9 @@ import ServerLink from './ServerLink'; import {serverPermanentRedirect, serverRedirect} from './redirects'; export default function createLocalizedPathnamesNavigation< - Locales extends AllLocales, - AppPathnames extends Pathnames ->(input: LocalizedNavigationRoutingConfigInput) { + AppLocales extends Locales, + AppPathnames extends Pathnames +>(input: LocalizedNavigationRoutingConfigInput) { const config = receiveLocalizedNavigationRoutingConfig(input); type LinkProps = Omit< @@ -26,7 +26,7 @@ export default function createLocalizedPathnamesNavigation< 'href' | 'name' | 'localePrefix' > & { href: HrefOrUrlObjectWithParams; - locale?: Locales[number]; + locale?: AppLocales[number]; }; function Link({ href, @@ -38,7 +38,7 @@ export default function createLocalizedPathnamesNavigation< return ( ({ + href={compileLocalizedPathname({ locale: finalLocale, // @ts-expect-error -- This is ok pathname: href, @@ -81,7 +81,7 @@ export default function createLocalizedPathnamesNavigation< href, locale }: { - locale: Locales[number]; + locale: AppLocales[number]; href: HrefOrHrefWithParams; }) { return compileLocalizedPathname({ diff --git a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx index 0ad2023b3..83084ecf1 100644 --- a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx @@ -1,5 +1,5 @@ import React, {ComponentProps} from 'react'; -import {AllLocales} from '../../routing/types'; +import {Locales} from '../../routing/types'; import {ParametersExceptFirst} from '../../shared/types'; import { SharedNavigationRoutingConfigInput, @@ -9,8 +9,8 @@ import ServerLink from './ServerLink'; import {serverPermanentRedirect, serverRedirect} from './redirects'; export default function createSharedPathnamesNavigation< - Locales extends AllLocales ->(input?: SharedNavigationRoutingConfigInput) { + AppLocales extends Locales +>(input?: SharedNavigationRoutingConfigInput) { const config = receiveSharedNavigationRoutingConfig(input); function notSupported(hookName: string) { @@ -23,12 +23,12 @@ export default function createSharedPathnamesNavigation< function Link( props: Omit< - ComponentProps>, + ComponentProps>, 'localePrefix' | 'locales' > ) { return ( - localePrefix={config.localePrefix} {...props} /> + localePrefix={config.localePrefix} {...props} /> ); } diff --git a/packages/next-intl/src/navigation/shared/config.tsx b/packages/next-intl/src/navigation/shared/config.tsx index 14cb962d1..240fc6a0a 100644 --- a/packages/next-intl/src/navigation/shared/config.tsx +++ b/packages/next-intl/src/navigation/shared/config.tsx @@ -3,7 +3,7 @@ import { receiveLocalePrefixConfig } from '../../routing/config'; import { - AllLocales, + Locales, LocalePrefixConfigVerbose, Pathnames } from '../../routing/types'; @@ -12,21 +12,21 @@ import { * Shared pathnames */ -export type SharedNavigationRoutingConfigInput = - RoutingBaseConfigInput & { - locales?: Locales; +export type SharedNavigationRoutingConfigInput = + RoutingBaseConfigInput & { + locales?: AppLocales; }; -export type SharedNavigationRoutingConfig = - SharedNavigationRoutingConfigInput & { - localePrefix: LocalePrefixConfigVerbose; +export type SharedNavigationRoutingConfig = + SharedNavigationRoutingConfigInput & { + localePrefix: LocalePrefixConfigVerbose; }; export function receiveSharedNavigationRoutingConfig< - Locales extends AllLocales + AppLocales extends Locales >( - input?: SharedNavigationRoutingConfigInput -): SharedNavigationRoutingConfig { + input?: SharedNavigationRoutingConfigInput +): SharedNavigationRoutingConfig { return { ...input, localePrefix: receiveLocalePrefixConfig(input?.localePrefix) @@ -38,28 +38,28 @@ export function receiveSharedNavigationRoutingConfig< */ export type LocalizedNavigationRoutingConfigInput< - Locales extends AllLocales, - AppPathnames extends Pathnames -> = RoutingBaseConfigInput & { - locales: Locales; + AppLocales extends Locales, + AppPathnames extends Pathnames +> = RoutingBaseConfigInput & { + locales: AppLocales; /** Maps internal pathnames to external ones which can be localized per locale. */ pathnames: AppPathnames; }; export type LocalizedNavigationRoutingConfig< - Locales extends AllLocales, - AppPathnames extends Pathnames -> = LocalizedNavigationRoutingConfigInput & { - localePrefix: LocalePrefixConfigVerbose; + AppLocales extends Locales, + AppPathnames extends Pathnames +> = LocalizedNavigationRoutingConfigInput & { + localePrefix: LocalePrefixConfigVerbose; }; export function receiveLocalizedNavigationRoutingConfig< - Locales extends AllLocales, - AppPathnames extends Pathnames + AppLocales extends Locales, + AppPathnames extends Pathnames >( - input: LocalizedNavigationRoutingConfigInput -): LocalizedNavigationRoutingConfig { + input: LocalizedNavigationRoutingConfigInput +): LocalizedNavigationRoutingConfig { return { ...input, localePrefix: receiveLocalePrefixConfig(input?.localePrefix) diff --git a/packages/next-intl/src/navigation/shared/redirects.tsx b/packages/next-intl/src/navigation/shared/redirects.tsx index 35eb86bd2..dc199e081 100644 --- a/packages/next-intl/src/navigation/shared/redirects.tsx +++ b/packages/next-intl/src/navigation/shared/redirects.tsx @@ -2,16 +2,16 @@ import { permanentRedirect as nextPermanentRedirect, redirect as nextRedirect } from 'next/navigation'; -import {AllLocales, LocalePrefixConfigVerbose} from '../../routing/types'; +import {Locales, LocalePrefixConfigVerbose} from '../../routing/types'; import {ParametersExceptFirst} from '../../shared/types'; import {getLocalePrefix, isLocalHref, prefixPathname} from '../../shared/utils'; function createRedirectFn(redirectFn: typeof nextRedirect) { - return function baseRedirect( + return function baseRedirect( params: { pathname: string; - locale: AllLocales[number]; - localePrefix: LocalePrefixConfigVerbose; + locale: Locales[number]; + localePrefix: LocalePrefixConfigVerbose; }, ...args: ParametersExceptFirst ) { diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index d241afcea..ecbf98a26 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,6 +1,6 @@ import type {ParsedUrlQueryInput} from 'node:querystring'; import type {UrlObject} from 'url'; -import {AllLocales, Pathnames} from '../../routing/types'; +import {Locales, Pathnames} from '../../routing/types'; import {matchesPathname} from '../../shared/utils'; import StrictParams from './StrictParams'; @@ -63,36 +63,36 @@ type StrictUrlObject = Omit & { }; export function compileLocalizedPathname< - Locales extends AllLocales, + AppLocales extends Locales, Pathname >(opts: { - locale: Locales[number]; + locale: AppLocales[number]; pathname: Pathname; params?: StrictParams; - pathnames: Pathnames; + pathnames: Pathnames; query?: Record; }): string; export function compileLocalizedPathname< - Locales extends AllLocales, + AppLocales extends Locales, Pathname >(opts: { - locale: Locales[number]; + locale: AppLocales[number]; pathname: StrictUrlObject; params?: StrictParams; - pathnames: Pathnames; + pathnames: Pathnames; query?: Record; }): UrlObject; -export function compileLocalizedPathname({ +export function compileLocalizedPathname({ pathname, locale, params, pathnames, query }: { - locale: Locales[number]; + locale: AppLocales[number]; pathname: keyof typeof pathnames | StrictUrlObject; params?: StrictParams; - pathnames: Pathnames; + pathnames: Pathnames; query?: Record; }) { function getNamedPath(value: keyof typeof pathnames) { @@ -104,7 +104,7 @@ export function compileLocalizedPathname({ } function compilePath( - namedPath: Pathnames[keyof Pathnames] + namedPath: Pathnames[keyof Pathnames] ) { const template = typeof namedPath === 'string' ? namedPath : namedPath[locale]; @@ -152,14 +152,14 @@ export function compileLocalizedPathname({ } } -export function getRoute({ +export function getRoute({ locale, pathname, pathnames }: { - locale: Locales[number]; + locale: AppLocales[number]; pathname: string; - pathnames: Pathnames; + pathnames: Pathnames; }) { const decoded = decodeURI(pathname); @@ -173,7 +173,7 @@ export function getRoute({ template = pathname; } - return template as keyof Pathnames; + return template as keyof Pathnames; } export function getBasePath( diff --git a/packages/next-intl/src/routing/config.tsx b/packages/next-intl/src/routing/config.tsx index 7c5583096..5637f598c 100644 --- a/packages/next-intl/src/routing/config.tsx +++ b/packages/next-intl/src/routing/config.tsx @@ -1,5 +1,5 @@ import { - AllLocales, + Locales, DomainConfig, LocalePrefixConfig, LocalePrefixConfigVerbose @@ -11,16 +11,16 @@ import { * different. This type declares the shared base config that is accepted by all * of them. Properties that are different are declared in consuming types. */ -export type RoutingBaseConfigInput = { +export type RoutingBaseConfigInput = { /** @see https://next-intl-docs.vercel.app/docs/routing/middleware#locale-prefix */ - localePrefix?: LocalePrefixConfig; + localePrefix?: LocalePrefixConfig; /** Can be used to change the locale handling per domain. */ - domains?: Array>; + domains?: Array>; }; -export function receiveLocalePrefixConfig( - localePrefix?: LocalePrefixConfig -): LocalePrefixConfigVerbose { +export function receiveLocalePrefixConfig( + localePrefix?: LocalePrefixConfig +): LocalePrefixConfigVerbose { return typeof localePrefix === 'object' ? localePrefix : {mode: localePrefix || 'always'}; diff --git a/packages/next-intl/src/routing/types.tsx b/packages/next-intl/src/routing/types.tsx index 96671aa18..00c68c05a 100644 --- a/packages/next-intl/src/routing/types.tsx +++ b/packages/next-intl/src/routing/types.tsx @@ -1,40 +1,40 @@ -export type AllLocales = ReadonlyArray; +export type Locales = ReadonlyArray; export type LocalePrefixMode = 'always' | 'as-needed' | 'never'; -export type LocalePrefixes = Partial< - Record +export type LocalePrefixes = Partial< + Record >; -export type LocalePrefixConfigVerbose = +export type LocalePrefixConfigVerbose = | { mode: 'always'; - prefixes?: LocalePrefixes; + prefixes?: LocalePrefixes; } | { mode: 'as-needed'; - prefixes?: LocalePrefixes; + prefixes?: LocalePrefixes; } | { mode: 'never'; }; -export type LocalePrefixConfig = +export type LocalePrefixConfig = | LocalePrefixMode - | LocalePrefixConfigVerbose; + | LocalePrefixConfigVerbose; -export type Pathnames = Record< +export type Pathnames = Record< string, - Record | string + Record | string >; -export type DomainConfig = { +export type DomainConfig = { /* Used by default if none of the defined locales match. */ - defaultLocale: Locales[number]; + defaultLocale: AppLocales[number]; /** 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?: Locales; + locales?: AppLocales; }; diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 91ceda462..ba362dd40 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -1,7 +1,7 @@ import {UrlObject} from 'url'; import NextLink from 'next/link'; import {ComponentProps} from 'react'; -import {AllLocales, LocalePrefixConfigVerbose} from '../routing/types'; +import {Locales, LocalePrefixConfigVerbose} from '../routing/types'; type Href = ComponentProps['href']; @@ -109,9 +109,9 @@ export function matchesPathname( return regex.test(pathname); } -export function getLocalePrefix( - locale: Locales[number], - localePrefix: LocalePrefixConfigVerbose +export function getLocalePrefix( + locale: AppLocales[number], + localePrefix: LocalePrefixConfigVerbose ) { return ( (localePrefix.mode !== 'never' && localePrefix.prefixes?.[locale]) || From 137d22a7527509969e1a2476c163f2e28089e9db Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 3 Jun 2024 16:57:12 +0200 Subject: [PATCH 17/45] Sizes --- packages/next-intl/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 422576054..5fb670b7e 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -122,11 +122,11 @@ }, { "path": "dist/production/navigation.react-client.js", - "limit": "3.195 KB" + "limit": "3.235 KB" }, { "path": "dist/production/navigation.react-server.js", - "limit": "17.78 KB" + "limit": "17.815 KB" }, { "path": "dist/production/server.react-client.js", @@ -138,7 +138,7 @@ }, { "path": "dist/production/middleware.js", - "limit": "6.38 KB" + "limit": "6.415 KB" } ] } From f3b10df5da8e56022c16ac79a59aa1ae6ea92882 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 3 Jun 2024 20:03:37 +0200 Subject: [PATCH 18/45] First draft of docs --- docs/pages/docs/routing.mdx | 307 ++++++++++++++++++++++++- docs/pages/docs/routing/middleware.mdx | 291 ++--------------------- docs/pages/docs/routing/navigation.mdx | 116 ++-------- 3 files changed, 333 insertions(+), 381 deletions(-) diff --git a/docs/pages/docs/routing.mdx b/docs/pages/docs/routing.mdx index 0eb655259..06e10122d 100644 --- a/docs/pages/docs/routing.mdx +++ b/docs/pages/docs/routing.mdx @@ -1,3 +1,5 @@ +import {Tabs, Tab} from 'nextra-theme-docs'; +import Details from 'components/Details'; import Card from 'components/Card'; import Cards from 'components/Cards'; import Callout from 'components/Callout'; @@ -9,11 +11,304 @@ import Callout from 'components/Callout'; routing](/docs/getting-started/app-router). -When you provide content in multiple languages, you want to make your pages available under distinct pathnames (e.g. `/en/about`). `next-intl` provides the building blocks to set up internationalized routing as well as the navigation APIs to enable you to link between pages. +`next-intl` integrates with the routing system of Next.js in two places: - - - - +1. [**Middleware**](/docs/routing/middleware): Negotiates the locale and internal pathname +2. [**Navigation APIs**](/docs/routing/navigation): Provides APIs to navigate between pages -Note that these features are only relevant if you use the App Router. If you're using [`next-intl` with the Pages Router](/docs/getting-started/pages-router), you can use the [built-in capabilities from Next.js](https://nextjs.org/docs/pages/building-your-application/routing/internationalization). +This enables you to express your app in terms of APIs like ``, while aspects like the locale and user-facing pathnames are automatically handled behind the scenes (e.g. `/de/ueber-uns`). + +## Shared configuration + +While the middleware provides a few more options than the navigation APIs, the majority of the configuration is shared between the two and should be used in coordination. + +``` +src +├── config.ts +├── middleware.ts +└── navigation.ts +``` + + + + +```tsx filename="config.ts" +// A list of all locales that are supported +export const locales = ['en', 'de'] as const; +``` + + + + +```tsx filename="middleware.ts" +import createMiddleware from 'next-intl/middleware'; +import {locales} from './config'; + +export default createMiddleware({ + locales: locales, + + // Used when no locale matches + defaultLocale: 'en' +}); + +export const config = { + // Match only internationalized pathnames + matcher: ['/', '/(de|en)/:path*'] +}; +``` + + + + +```tsx filename="src/navigation.ts" +import {createSharedPathnamesNavigation} from 'next-intl/navigation'; +import {locales} from './config'; + +export const {Link, redirect, usePathname, useRouter} = + createSharedPathnamesNavigation({locales}); +``` + + + + +### Locale prefix + +By default, the pathnames of your app will be available under a prefix that matches your directory structure (e.g. `src/app/[locale]/about/page.tsx` → `/en/about`). You can however adapt the routing to optionally remove the prefix or customize it per locale. + +#### Always use a locale prefix (default) [#locale-prefix-always] + +By default, pathnames always start with the locale (e.g. `/en/about`). + +```tsx filename="config.ts" +import {LocalePrefixConfig} from 'next-intl/routing'; + +export const localePrefix: LocalePrefixConfig = 'always'; +``` + +
+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 only want to include a locale prefix for non-default locales, you can configure your routing accordingly: + +```tsx filename="config.ts" +import {LocalePrefixConfig} from 'next-intl/routing'; + +export const localePrefix: LocalePrefixConfig = 'as-needed'; +``` + +In this case, requests where the locale prefix matches the default locale will be redirected (e.g. `/en/about` to `/about`). This will affect both prefix-based as well as domain-based routing. + +**Note that:** + +1. If you use this strategy, you should make sure that [your middleware matcher detects unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix). +2. If you use [the `Link` component](/docs/routing/navigation#link), the initial render will point to the prefixed version but will be patched immediately on the client once the component detects that the default locale has rendered. The prefixed version is still valid, but SEO tools might report a hint that the link points to a redirect. + +#### Never use a locale prefix [#locale-prefix-never] + +If you'd like to provide a locale to `next-intl`, e.g. based on user settings, you can consider setting up `next-intl` [without i18n routing](/docs/getting-started/app-router/without-i18n-routing). This way, you don't need to use the middleware in the first place. + +However, you can also configure the middleware to never show a locale prefix in the URL, which can be helpful in the following cases: + +1. You're using [domain-based routing](#domains) and you support only a single locale per domain +2. You're using a cookie to determine the locale but would like to enable static rendering + +```tsx filename="config.ts" +import {LocalePrefixConfig} from 'next-intl/routing'; + +export const localePrefix: LocalePrefixConfig = 'never'; +``` + +In this case, requests for all locales will be rewritten to have the locale only prefixed internally. You still need to place all your pages inside a `[locale]` folder for the routes to be able to receive the `locale` param. + +**Note that:** + +1. If you use this strategy, you should make sure that [your matcher detects 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 in the middleware. 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](/docs/routing/middleware#alternate-links) are disabled in this mode since URLs might not be unique per locale. + +#### Custom prefixes [#locale-prefix-custom] + +If you'd like to customize the user-facing prefix, you can provide a locale-based mapping: + +```tsx filename="config.ts" +import {LocalePrefixConfig} from 'next-intl/routing'; + +export const locales = ['en-US', 'en-GB', 'de-DE', 'zh'] as const; + +export const localePrefix: LocalePrefixConfig = { + mode: 'always', + prefixes: { + 'en-US': '/us', + 'en-GB': '/uk', + 'de-DE': '/eu/de' + // (/zh will be used as-is) + } +}; +``` + +**Note that:** + +1. The custom prefixes are only visible to the user and rewritten internally to the corresponding locale. Therefore the `[locale]` segment will correspond to the locale, not the prefix. +2. You might have to adapt your [middleware matcher](/docs/routing/middleware#matcher-custom-prefix) to match the custom prefixes. + +### Domains + +If you want to serve your localized content based on different domains, you can provide a list of mappings between domains and locales to the middleware. + +**Examples:** + +- `us.example.com/en` +- `ca.example.com/en` +- `ca.example.com/fr` + +```tsx filename="config.ts" +export const locales = ['en', 'fr'] as const; + +export const domains: DomainsConfig = [ + { + domain: 'us.example.com', + defaultLocale: 'en', + // Optionally restrict the locales available on this domain + locales: ['en'] + }, + { + domain: 'ca.example.com', + defaultLocale: 'en' + // If there are no `locales` specified on a domain, + // all available locales will be supported here + } +]; +``` + +**Note that:** + +1. You can optionally remove the locale prefix in pathnames by changing the [`localePrefix`](#locale-prefix) setting. +2. If no domain matches, the middleware will fall back to the [`defaultLocale`](/docs/routing/middleware#default-locale) (e.g. on `localhost`). + +### Localized pathnames [#pathnames] + +Many apps choose to localize pathnames, especially when search engine optimization is relevant, e.g.: + +- `/en/about` +- `/de/ueber-uns` + +Since you want to define these routes only once internally, you can use the `next-intl` middleware to [rewrite](https://nextjs.org/docs/api-reference/next.config.js/rewrites) such incoming requests to shared pathnames. + +```tsx filename="config.ts" +export const locales = ['en', 'de'] as const; + +// The `pathnames` object holds pairs of internal and +// external paths. Based on the locale, the external +// paths are rewritten to the shared, internal ones. +export const pathnames: Pathnames = { + // If all locales use the same pathname, a single + // external path can be used for all locales. + '/': '/', + '/blog': '/blog', + + // If locales use different paths, you can + // specify each external path per locale. + '/about': { + en: '/about', + de: '/ueber-uns' + }, + + // Dynamic params are supported via square brackets + '/news/[articleSlug]-[articleId]': { + en: '/news/[articleSlug]-[articleId]', + de: '/neuigkeiten/[articleSlug]-[articleId]' + }, + + // 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]' + } +}; +``` + +**Note:** Localized pathnames map to a single internal pathname that is created via the file-system based routing in Next.js. If you're using an external system like a CMS to localize pathnames, you'll typically implement this with a catch-all route like `[locale]/[[...slug]]`. + + + If you're using localized pathnames, you should use + [`createLocalizedPathnamesNavigation`](/docs/routing/navigation#localized-pathnames) + instead of `createSharedPathnamesNavigation` for your navigation APIs. + + +
+How can I revalidate localized pathnames? + +Depending on if a route is generated statically (at build time) or dynamically (at runtime), [`revalidatePath`](https://nextjs.org/docs/app/api-reference/functions/revalidatePath) needs to be called either for the localized or the internal pathname. + +Consider this example: + +``` +app +└── [locale] + └── news + └── [slug] +``` + +… with this middleware configuration: + +```tsx filename="middleware.ts" +import createMiddleware from 'next-intl/middleware'; + +export default createMiddleware({ + defaultLocale: 'en', + locales: ['en', 'fr'], + pathnames: { + '/news/[slug]': { + en: '/news/[slug]', + fr: '/infos/[slug]' + } + } +}); +``` + +Depending on whether `some-article` was included in [`generateStaticParams`](https://nextjs.org/docs/app/api-reference/functions/generate-static-params) or not, you can revalidate the route like this: + +```tsx +// Statically generated at build time +revalidatePath('/fr/news/some-article'); + +// Dynamically generated at runtime: +revalidatePath('/fr/infos/some-article'); +``` + +When in doubt, you can revalidate both paths to be on the safe side. + +See also: [`vercel/next.js#59825`](https://github.com/vercel/next.js/issues/59825) + +
+ +
+How can I localize dynamic segments? + +If you have a route like `/news/[articleSlug]-[articleId]`, you may want to localize the `articleSlug` part in the pathname like this: + +``` +/en/news/launch-of-new-product-94812 +/de/neuigkeiten/produktneuheit-94812 +``` + +In this case, the localized slug can either be provided by the backend or generated in the frontend by slugifying the localized article title. + +A good practice is to include the ID in the URL, allowing you to retrieve the article based on this information from the backend. The ID can be further used to implement [self-healing URLs](https://mikebifulco.com/posts/self-healing-urls-nextjs-seo), where a redirect is added if the `articleSlug` doesn't match. + +If you localize the values for dynamic segments, you might want to turn off [alternate links](#alternate-links) and provide your own implementation that considers localized values for dynamic segments. + +
diff --git a/docs/pages/docs/routing/middleware.mdx b/docs/pages/docs/routing/middleware.mdx index edb18b7c0..48149ad67 100644 --- a/docs/pages/docs/routing/middleware.mdx +++ b/docs/pages/docs/routing/middleware.mdx @@ -12,10 +12,11 @@ The middleware handles redirects and rewrites based on the detected user locale. ```tsx filename="middleware.ts" import createMiddleware from 'next-intl/middleware'; +import {locales} from './config'; export default createMiddleware({ // A list of all locales that are supported - locales: ['en', 'de'], + locales: locales, // Used when no locale matches defaultLocale: 'en' @@ -29,22 +30,15 @@ export const config = { In addition to handling i18n routing, the middleware sets the `link` header to inform search engines that your content is available in different languages (see [alternate links](#alternate-links)). -## Strategies - -There are two strategies for detecting the locale: - -1. [Prefix-based routing (default)](#prefix-based-routing) -2. [Domain-based routing](#domain-based-routing) +## Locale detection -Once a locale is detected, it will be saved in the `NEXT_LOCALE` cookie. +The locale is detected 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. -### Strategy 1: Prefix-based routing (default) [#prefix-based-routing] +### Prefix-based routing (default) [#prefix-based-routing] -Since your pages are nested within a `[locale]` folder, all routes are by default prefixed with one of your supported locales (e.g. `/en/about`). +By default, [prefix-based routing](/docs/routing#locale-prefix) is used to determine the locale of a request. -#### Locale detection [#prefix-locale-detection] - -The locale is detected based on these priorities: +In this case, the locale is detected based on these priorities: 1. A locale prefix is present in the pathname (e.g. `/en/about`) 2. A cookie is present that contains a previously detected locale @@ -63,7 +57,7 @@ To change the locale, users can visit a prefixed route. This will take precedenc You can optionally remove the locale prefix in pathnames by changing the - [`localePrefix`](#locale-prefix) setting. + [`localePrefix`](/docs/routing#locale-prefix) setting.
@@ -82,51 +76,9 @@ In contrast, the "best fit" algorithm compares the _distance_ between the user's
-### Strategy 2: Domain-based routing [#domain-based-routing] - -If you want to serve your localized content based on different domains, you can provide a list of mappings between domains and locales to the middleware. - -**Examples:** - -- `us.example.com/en` -- `ca.example.com/en` -- `ca.example.com/fr` - -```tsx filename="middleware.ts" -import createMiddleware from 'next-intl/middleware'; +### Domain-based routing [#domain-based-routing] -export default createMiddleware({ - // All locales across all domains - locales: ['en', 'fr'], - - // Used when no domain matches (e.g. on localhost) - defaultLocale: 'en', - - domains: [ - { - domain: 'us.example.com', - defaultLocale: 'en', - // Optionally restrict the locales available on this domain - locales: ['en'] - }, - { - domain: 'ca.example.com', - defaultLocale: 'en' - // If there are no `locales` specified on a domain, - // all available locales will be supported here - } - ] -}); -``` - - - You can optionally remove the locale prefix in pathnames by changing the - [`localePrefix`](#locale-prefix) setting. - - -#### Locale detection [#domain-locale-detection] - -To match the request against the available domains, the host is read from the `x-forwarded-host` header, with a fallback to `host`. +If you're using [domain-based routing](/docs/routing#domains), the middleware will match the request against the available domains to determine the best-matching locale. To retrieve the domain, the host is read from the `x-forwarded-host` header, with a fallback to `host`. The locale is detected based on these priorities: @@ -156,91 +108,9 @@ The bestmatching domain is detected based on these priorities: -## Further configuration - -### Locale prefix - -By default, the pathnames of your app will be available under a prefix that matches your directory structure (e.g. `src/app/[locale]/about/page.tsx` → `/en/about`). You can however customize the routing to optionally remove the prefix. - -Note that if you're using [the navigation APIs from `next-intl`](/docs/routing/navigation), you want to make sure your `localePrefix` setting matches your middleware configuration. - -#### Always use a locale prefix (default) [#locale-prefix-always] - -By default, pathnames always start with the locale (e.g. `/en/about`). - -```tsx filename="middleware.ts" {6} -import createMiddleware from 'next-intl/middleware'; - -export default createMiddleware({ - // ... other config - - localePrefix: 'always' // This is the default -}); -``` - -
-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](#matcher-no-prefix) too. - -
- -#### Don't use a locale prefix for the default locale [#locale-prefix-as-needed] - -If you don't want to include a locale prefix for the default locale, but only for non-default locales, you can configure the middleware accordingly: - -```tsx filename="middleware.ts" {6} -import createMiddleware from 'next-intl/middleware'; - -export default createMiddleware({ - // ... other config - - localePrefix: 'as-needed' -}); -``` - -In this case, requests where the locale prefix matches the default locale will be redirected (e.g. `/en/about` to `/about`). This will affect both prefix-based as well as domain-based routing. - -**Note that:** - -1. If you use this strategy, you should make sure that [your matcher detects unprefixed pathnames](#matcher-no-prefix). -2. If you use [the `Link` component](/docs/routing/navigation#link), the initial render will point to the prefixed version but will be patched immediately on the client once the component detects that the default locale has rendered. The prefixed version is still valid, but SEO tools might report a hint that the link points to a redirect. - -#### Never use a locale prefix [#locale-prefix-never] - - - If you'd like to provide a locale to `next-intl`, e.g. based on user settings, - you can consider setting up `next-intl` [without i18n - routing](/docs/getting-started/app-router/without-i18n-routing). This way, you - don't need to use the middleware in the first place. - - -In case you're using the middleware, but you don't want your pathnames to be prefixed with a locale, you can configure the middleware to never show a locale prefix in the URL. - -This can be useful in the following cases: - -1. You're using [domain-based routing](#domain-based-routing) and you support only a single locale per domain -2. You're using a cookie to determine the locale but would like to enable static rendering - -```tsx filename="middleware.ts" {6} -import createMiddleware from 'next-intl/middleware'; - -export default createMiddleware({ - // ... other config - - localePrefix: 'never' -}); -``` - -In this case, requests for all locales will be rewritten to have the locale only prefixed internally. You still need to place all your pages inside a `[locale]` folder for the routes to be able to receive the `locale` param. - -**Note that:** - -1. If you use this strategy, you should make sure that [your matcher detects unprefixed pathnames](#matcher-no-prefix). -2. If you don't use domain-based routing, the cookie is now the source of truth for determining the locale in the middleware. 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 there might not be distinct URLs per locale. +## Configuration -### Locale detection [#locale-detection] +### Turning off locale detection [#locale-detection] If you want to rely entirely on the URL to resolve the locale, you can disable locale detection based on the `accept-language` header and a potentially existing cookie value from a previous visit. @@ -324,131 +194,6 @@ export default async function middleware(request: NextRequest) { -### Localizing pathnames - -Many apps choose to localize pathnames, especially when search engine optimization is relevant, e.g.: - -- `/en/about` -- `/de/ueber-uns` - -Since you want to define these routes only once internally, you can use the `next-intl` middleware to [rewrite](https://nextjs.org/docs/api-reference/next.config.js/rewrites) such incoming requests to shared pathnames. - -```tsx filename="middleware.ts" -import createMiddleware from 'next-intl/middleware'; - -export default createMiddleware({ - defaultLocale: 'en', - locales: ['en', 'de'], - - // The `pathnames` object holds pairs of internal and - // external paths. Based on the locale, the external - // paths are rewritten to the shared, internal ones. - pathnames: { - // If all locales use the same pathname, a single - // external path can be used for all locales. - '/': '/', - '/blog': '/blog', - - // If locales use different paths, you can - // specify each external path per locale. - '/about': { - en: '/about', - de: '/ueber-uns' - }, - - // Dynamic params are supported via square brackets - '/news/[articleSlug]-[articleId]': { - en: '/news/[articleSlug]-[articleId]', - de: '/neuigkeiten/[articleSlug]-[articleId]' - }, - - // 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]' - } - } -}); -``` - - - If you have pathname localization set up in the middleware, you likely want to - use the [localized navigation - APIs](/docs/routing/navigation#localized-pathnames) in your components. - - -
-How can I revalidate localized pathnames? - -Depending on if a route is generated statically (at build time) or dynamically (at runtime), [`revalidatePath`](https://nextjs.org/docs/app/api-reference/functions/revalidatePath) needs to be called either for the localized or the internal pathname. - -Consider this example: - -``` -app -└── [locale] - └── news - └── [slug] -``` - -… with this middleware configuration: - -```tsx filename="middleware.ts" -import createMiddleware from 'next-intl/middleware'; - -export default createMiddleware({ - defaultLocale: 'en', - locales: ['en', 'fr'], - pathnames: { - '/news/[slug]': { - en: '/news/[slug]', - fr: '/infos/[slug]' - } - } -}); -``` - -Depending on whether `some-article` was included in [`generateStaticParams`](https://nextjs.org/docs/app/api-reference/functions/generate-static-params) or not, you can revalidate the route like this: - -```tsx -// Statically generated at build time -revalidatePath('/fr/news/some-article'); - -// Dynamically generated at runtime: -revalidatePath('/fr/infos/some-article'); -``` - -When in doubt, you can revalidate both paths to be on the safe side. - -See also: [`vercel/next.js#59825`](https://github.com/vercel/next.js/issues/59825) - -
- -
-How can I localize dynamic segments? - -If you have a route like `/news/[articleSlug]-[articleId]`, you may want to localize the `articleSlug` part in the pathname like this: - -``` -/en/news/launch-of-new-product-94812 -/de/neuigkeiten/produktneuheit-94812 -``` - -In this case, the localized slug can either be provided by the backend or generated in the frontend by slugifying the localized article title. - -A good practice is to include the ID in the URL, allowing you to retrieve the article based on this information from the backend. The ID can be further used to implement [self-healing URLs](https://mikebifulco.com/posts/self-healing-urls-nextjs-seo), where a redirect is added if the `articleSlug` doesn't match. - -If you localize the values for dynamic segments, you might want to turn off [alternate links](#alternate-links) and provide your own implementation that considers localized values for dynamic segments. - -
- ### Matcher config 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`). @@ -470,17 +215,13 @@ This enables:
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 dynamically. However, in case you're self-hosting Next.js via a Node.js server, you can implement the matcher dynamically instead: +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 dynamically. However, you can implement the matcher dynamically instead: ```tsx filename="middleware.ts" import {NextRequest} from 'next/server'; import createIntlMiddleware from 'next-intl/middleware'; -// Can be imported from a shared module -const locales = ['en', 'de']; - const intlMiddleware = createIntlMiddleware({ - locales // ... }); @@ -528,7 +269,7 @@ export const config = { {/* Keep this in sync with `packages/next-intl/test/middleware/middleware.test.tsx` */} -Note that some third-party providers like [Vercel Analytics](https://vercel.com/analytics) and [umami](https://umami.is/docs/running-on-vercel) typically use internal endpoints that are then rewritten to an external URL (e.g. `/_vercel/insights/view`). Make sure to exclude such requests from your middleware matcher so they aren't accidentally rewritten. +Note that some third-party providers like [Vercel Analytics](https://vercel.com/analytics) and [umami](https://umami.is/docs/running-on-vercel) typically use internal endpoints that are then rewritten to an external URL (e.g. `/_vercel/insights/view`). Make sure to exclude such requests from your middleware matcher so they aren't rewritten by accident. ### Base path @@ -712,12 +453,12 @@ For pathnames specified in [the `pages` object](https://next-auth.js.org/configu import {withAuth} from 'next-auth/middleware'; import createIntlMiddleware from 'next-intl/middleware'; import {NextRequest} from 'next/server'; +import {locales} from './config'; -const locales = ['en', 'de']; const publicPages = ['/', '/login']; const intlMiddleware = createIntlMiddleware({ - locales, + locales: locales, localePrefix: 'as-needed', defaultLocale: 'en' }); diff --git a/docs/pages/docs/routing/navigation.mdx b/docs/pages/docs/routing/navigation.mdx index 491c67e1d..19ab50a7f 100644 --- a/docs/pages/docs/routing/navigation.mdx +++ b/docs/pages/docs/routing/navigation.mdx @@ -11,59 +11,24 @@ import Details from 'components/Details'; `next-intl` provides drop-in replacements for common Next.js navigation APIs that automatically handle the user locale behind the scenes. -## Strategies +Depending on if you're using localized pathnames (i.e. the [`pathnames`](/docs/routing#pathnames) setting), you can pick from one of these functions to create the corresponding navigation APIs: -There are two strategies that you can use based on your needs. +- `createSharedPathnamesNavigation`: Pathnames are shared across all locales (default) +- `createLocalizedPathnamesNavigation`: Pathnames are provided per locale -**Shared pathnames:** The simplest case is when your app uses the same pathnames, regardless of the locale. +These functions are typically called in a central module like `src/navigation.ts` in order to provide easy access to navigation APIs in your components. -For example: +**Tip:** To ensure consistent usage in your app, you can consider [linting for usage of these APIs](/docs/workflows/linting#consistent-usage-of-navigation-apis). -- `/en/about` -- `/de/about` - -**Localized pathnames:** Many apps choose to localize pathnames, especially when search engine optimization is relevant. In this case, you'll provide distinct pathnames based on the user locale. - -For example: - -- `/en/about` -- `/de/ueber-uns` - -**Note:** The terms "shared" and "localized" pathnames are used to refer to pathnames that are created via the file-system based routing in Next.js. If you're using an external system like a CMS to localize pathnames, you'll typically implement this with a catch-all route like `[locale]/[[...slug]]`. - ---- - -Each strategy will provide you with corresponding [navigation APIs](#apis) that you'll typically provide in a central module to easily access them in components (e.g. `src/navigation.ts`). To ensure consistent usage in your app, you can consider [linting for usage of these APIs](/docs/workflows/linting#consistent-usage-of-navigation-apis). - -### Strategy 1: Shared pathnames [#shared-pathnames] - -With this strategy, the pathnames of your app are identical for all locales. This is the simplest case, because the routes you define in Next.js will map directly to the pathnames that a user can request. - -To create [navigation APIs](#apis) for this strategy, use the `createSharedPathnamesNavigation` function: + + ```tsx filename="navigation.ts" import {createSharedPathnamesNavigation} from 'next-intl/navigation'; - -export const locales = ['en', 'de'] as const; -export const localePrefix = 'always'; // Default +import {locales, /* ... */} from './config'; export const {Link, redirect, usePathname, useRouter} = - createSharedPathnamesNavigation({locales, localePrefix}); -``` - -The `locales` as well as the `localePrefix` argument is identical to the configuration that you pass to the middleware. You might want to share these values via a central configuration to keep them in sync. - -```tsx filename="middleware.ts" -import createMiddleware from 'next-intl/middleware'; -import {locales, localePrefix} from './navigation'; - -export default createMiddleware({ - defaultLocale: 'en', - localePrefix, - locales -}); - -// ... + createSharedPathnamesNavigation({locales, /* ... */}); ```
@@ -75,73 +40,24 @@ Note however that the `locales` argument for the middleware is mandatory. You ca
-### Strategy 2: Localized pathnames [#localized-pathnames] - -When using this strategy, you have to provide distinct pathnames for every locale that your app supports. However, the localized variants will be handled by a single route internally, therefore a mapping needs to be provided that is also [consumed by the middleware](/docs/routing/middleware#localizing-pathnames). - -You can use the `createLocalizedPathnamesNavigation` function to create corresponding [navigation APIs](#apis): +
+ ```tsx filename="navigation.ts" -import { - createLocalizedPathnamesNavigation, - Pathnames -} from 'next-intl/navigation'; - -export const locales = ['en', 'de'] as const; -export const localePrefix = 'always'; // Default - -// The `pathnames` object holds pairs of internal -// and external paths, separated by locale. -export const pathnames = { - // If all locales use the same pathname, a - // single external path can be provided. - '/': '/', - '/blog': '/blog', - - // If locales use different paths, you can - // specify each external path per locale. - '/about': { - en: '/about', - de: '/ueber-uns' - }, - - // Dynamic params are supported via square brackets - '/news/[articleSlug]-[articleId]': { - en: '/news/[articleSlug]-[articleId]', - de: '/neuigkeiten/[articleSlug]-[articleId]' - }, - - // Also (optional) catch-all segments are supported - '/categories/[...slug]': { - en: '/categories/[...slug]', - de: '/kategorien/[...slug]' - } -} satisfies Pathnames; +import {createLocalizedPathnamesNavigation} from 'next-intl/navigation'; +import {locales, pathnames, /* ... */} from './config'; export const {Link, redirect, usePathname, useRouter, getPathname} = - createLocalizedPathnamesNavigation({locales, localePrefix, pathnames}); + createLocalizedPathnamesNavigation({locales, pathnames, /* ... */}); ``` -The arguments `locales`, `localePrefix` as well as `pathnames` are identical to the configuration that you pass to the middleware. You might want to share these values via a central configuration to make sure they stay in sync. - -```tsx filename="middleware.ts" -import createMiddleware from 'next-intl/middleware'; -import {locales, localePrefix, pathnames} from './navigation'; - -export default createMiddleware({ - defaultLocale: 'en', - localePrefix, - locales, - pathnames -}); - -// ... -``` + Have a look at the [App Router example](/examples#app-router) to explore a working implementation of localized pathnames. +
## APIs From 8d9c144325d9e0f9cec628b6556b85951ff66dfa Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 3 Jun 2024 22:23:41 +0200 Subject: [PATCH 19/45] Remove legacy dts file --- packages/next-intl/dts.config.js | 26 -------------------------- packages/use-intl/dts.config.js | 14 -------------- 2 files changed, 40 deletions(-) delete mode 100644 packages/next-intl/dts.config.js delete mode 100644 packages/use-intl/dts.config.js diff --git a/packages/next-intl/dts.config.js b/packages/next-intl/dts.config.js deleted file mode 100644 index 60121cc1d..000000000 --- a/packages/next-intl/dts.config.js +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable import/no-extraneous-dependencies */ -/* eslint-env node */ - -const preserveDirectives = require('rollup-plugin-preserve-directives').default; - -/** - * @type {import('dts-cli').DtsOptions} - */ -module.exports = { - rollup(config) { - // 'use client' support - config.output.preserveModules = true; - config.plugins.push(preserveDirectives()); - config.onwarn = function onwarn(warning, warn) { - if (warning.code !== 'MODULE_LEVEL_DIRECTIVE') { - warn(warning); - } - }; - - // Otherwise rollup will insert code like `require('next/link')`, - // which will break the RSC render due to usage of `createContext` - config.treeshake.moduleSideEffects = false; - - return config; - } -}; diff --git a/packages/use-intl/dts.config.js b/packages/use-intl/dts.config.js deleted file mode 100644 index 0d711aa6f..000000000 --- a/packages/use-intl/dts.config.js +++ /dev/null @@ -1,14 +0,0 @@ -/* global module */ - -/** - * @type {import('dts-cli').DtsOptions} - */ -module.exports = { - rollup(config) { - // Enable tree shaking detection in rollup / Bundlephobia - if (config.output.format === 'esm') { - config.output.preserveModules = true; - } - return config; - } -}; From 13c29538026236e80b328327fd8c39712a00f0be Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 3 Jun 2024 22:36:20 +0200 Subject: [PATCH 20/45] Export routing, deprecate pathnames in navigation --- examples/example-app-router/src/config.ts | 17 +++++++++-------- examples/example-app-router/src/middleware.ts | 6 +++--- packages/next-intl/package.json | 9 +++++++++ packages/next-intl/rollup.config.js | 1 + packages/next-intl/routing.d.ts | 1 + .../src/navigation/react-client/index.tsx | 10 +++++++++- packages/next-intl/src/routing.tsx | 1 + packages/next-intl/src/routing/index.tsx | 3 ++- packages/next-intl/src/routing/types.tsx | 8 +++++--- .../test/middleware/middleware.test.tsx | 2 +- 10 files changed, 41 insertions(+), 17 deletions(-) create mode 100644 packages/next-intl/routing.d.ts create mode 100644 packages/next-intl/src/routing.tsx diff --git a/examples/example-app-router/src/config.ts b/examples/example-app-router/src/config.ts index 9ce4e703e..12c289463 100644 --- a/examples/example-app-router/src/config.ts +++ b/examples/example-app-router/src/config.ts @@ -1,22 +1,23 @@ -import {Pathnames} from 'next-intl/navigation'; - -export const port = process.env.PORT || 3000; -export const host = process.env.VERCEL_URL - ? `https://${process.env.VERCEL_URL}` - : `http://localhost:${port}`; +import {Pathnames, LocalePrefixConfig} from 'next-intl/routing'; export const defaultLocale = 'en' as const; export const locales = ['en', 'de'] as const; -export const pathnames = { +export const pathnames: Pathnames = { '/': '/', '/pathnames': { en: '/pathnames', de: '/pfadnamen' } -} satisfies Pathnames; +}; // Use the default: `always` export const localePrefix = undefined; +export const localePrefix: LocalePrefixConfig = 'always'; export type AppPathnames = keyof typeof pathnames; + +export const port = process.env.PORT || 3000; +export const host = process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : `http://localhost:${port}`; diff --git a/examples/example-app-router/src/middleware.ts b/examples/example-app-router/src/middleware.ts index b1389fc8e..81f210434 100644 --- a/examples/example-app-router/src/middleware.ts +++ b/examples/example-app-router/src/middleware.ts @@ -1,11 +1,11 @@ import createMiddleware from 'next-intl/middleware'; -import {pathnames, locales, localePrefix, defaultLocale} from './config'; +import {localePrefix, defaultLocale, locales, pathnames} from './config'; export default createMiddleware({ defaultLocale, locales, - pathnames, - localePrefix + localePrefix, + pathnames }); export const config = { diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 1b9b10372..706e4300f 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -53,6 +53,10 @@ "react-server": "./dist/esm/navigation.react-server.js", "default": "./dist/navigation.react-client.js" }, + "./routing": { + "types": "./routing.d.ts", + "default": "./dist/routing.js" + }, "./plugin": { "types": "./plugin.d.ts", "default": "./dist/plugin.js" @@ -64,6 +68,7 @@ "navigation.d.ts", "middleware.d.ts", "plugin.d.ts", + "routing.d.ts", "config.d.ts" ], "keywords": [ @@ -140,6 +145,10 @@ { "path": "dist/production/middleware.js", "limit": "6.415 KB" + }, + { + "path": "dist/production/routing.js", + "limit": "0 KB" } ] } diff --git a/packages/next-intl/rollup.config.js b/packages/next-intl/rollup.config.js index e13032b0e..d99f0a2b6 100644 --- a/packages/next-intl/rollup.config.js +++ b/packages/next-intl/rollup.config.js @@ -14,6 +14,7 @@ const config = { 'server.react-server': 'src/server.react-server.tsx', middleware: 'src/middleware.tsx', + routing: 'src/routing.tsx', plugin: 'src/plugin.tsx', config: 'src/config.tsx' }, diff --git a/packages/next-intl/routing.d.ts b/packages/next-intl/routing.d.ts new file mode 100644 index 000000000..13ee0d973 --- /dev/null +++ b/packages/next-intl/routing.d.ts @@ -0,0 +1 @@ +export * from './dist/types/src/routing'; diff --git a/packages/next-intl/src/navigation/react-client/index.tsx b/packages/next-intl/src/navigation/react-client/index.tsx index fc1e780c1..f814e8378 100644 --- a/packages/next-intl/src/navigation/react-client/index.tsx +++ b/packages/next-intl/src/navigation/react-client/index.tsx @@ -1,3 +1,11 @@ export {default as createSharedPathnamesNavigation} from './createSharedPathnamesNavigation'; export {default as createLocalizedPathnamesNavigation} from './createLocalizedPathnamesNavigation'; -export type {Pathnames} from '../../routing/types'; + +import type { + Pathnames as PathnamesDeprecatedExport, + Locales +} from '../../routing/types'; + +/** @deprecated Please import from `next-intl/routing` instead. */ +export type Pathnames = + PathnamesDeprecatedExport; diff --git a/packages/next-intl/src/routing.tsx b/packages/next-intl/src/routing.tsx new file mode 100644 index 000000000..445db87c8 --- /dev/null +++ b/packages/next-intl/src/routing.tsx @@ -0,0 +1 @@ +export * from './routing/index'; diff --git a/packages/next-intl/src/routing/index.tsx b/packages/next-intl/src/routing/index.tsx index 470f2f1b0..ca4f167d1 100644 --- a/packages/next-intl/src/routing/index.tsx +++ b/packages/next-intl/src/routing/index.tsx @@ -1 +1,2 @@ -// No public API yet +export type {LocalePrefixConfig} from './types'; +export type {Pathnames} from './types'; diff --git a/packages/next-intl/src/routing/types.tsx b/packages/next-intl/src/routing/types.tsx index 00c68c05a..616618e77 100644 --- a/packages/next-intl/src/routing/types.tsx +++ b/packages/next-intl/src/routing/types.tsx @@ -2,8 +2,10 @@ export type Locales = ReadonlyArray; export type LocalePrefixMode = 'always' | 'as-needed' | 'never'; +type Pathname = `/${string}`; + export type LocalePrefixes = Partial< - Record + Record >; export type LocalePrefixConfigVerbose = @@ -24,8 +26,8 @@ export type LocalePrefixConfig = | LocalePrefixConfigVerbose; export type Pathnames = Record< - string, - Record | string + Pathname, + Record | Pathname >; export type DomainConfig = { diff --git a/packages/next-intl/test/middleware/middleware.test.tsx b/packages/next-intl/test/middleware/middleware.test.tsx index 36479a56b..4055b3236 100644 --- a/packages/next-intl/test/middleware/middleware.test.tsx +++ b/packages/next-intl/test/middleware/middleware.test.tsx @@ -5,7 +5,7 @@ import {NextRequest, NextResponse} from 'next/server'; import {pathToRegexp} from 'path-to-regexp'; import {it, describe, vi, beforeEach, expect, Mock} from 'vitest'; import createIntlMiddleware from '../../src/middleware'; -import {Pathnames} from '../../src/navigation/react-client'; +import {Pathnames} from '../../src/routing'; import {COOKIE_LOCALE_NAME} from '../../src/shared/constants'; vi.mock('next/server', async (importActual) => { From b0bf9fb74207fe3c233bdde1d03d89987a5d99e8 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 3 Jun 2024 22:39:45 +0200 Subject: [PATCH 21/45] Revert breaking type --- packages/next-intl/src/routing/types.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/next-intl/src/routing/types.tsx b/packages/next-intl/src/routing/types.tsx index 616618e77..cd1d03e93 100644 --- a/packages/next-intl/src/routing/types.tsx +++ b/packages/next-intl/src/routing/types.tsx @@ -2,7 +2,8 @@ export type Locales = ReadonlyArray; export type LocalePrefixMode = 'always' | 'as-needed' | 'never'; -type Pathname = `/${string}`; +// Change to `/${string}` in next major +type Pathname = string; export type LocalePrefixes = Partial< Record From d53334710647c1b780a504920e143e1cb43047cb Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 3 Jun 2024 22:58:46 +0200 Subject: [PATCH 22/45] Fix build --- examples/example-app-router/src/config.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/example-app-router/src/config.ts b/examples/example-app-router/src/config.ts index 12c289463..74134e99a 100644 --- a/examples/example-app-router/src/config.ts +++ b/examples/example-app-router/src/config.ts @@ -11,8 +11,6 @@ export const pathnames: Pathnames = { } }; -// Use the default: `always` -export const localePrefix = undefined; export const localePrefix: LocalePrefixConfig = 'always'; export type AppPathnames = keyof typeof pathnames; From acbe01c18a58df4e16e68773a65d8b2a5bd28188 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 3 Jun 2024 23:00:15 +0200 Subject: [PATCH 23/45] Some cleanup --- .../example-app-router/src/components/NavigationLink.tsx | 9 ++++----- examples/example-app-router/src/config.ts | 2 -- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/examples/example-app-router/src/components/NavigationLink.tsx b/examples/example-app-router/src/components/NavigationLink.tsx index 1eeb440fa..91c7234b0 100644 --- a/examples/example-app-router/src/components/NavigationLink.tsx +++ b/examples/example-app-router/src/components/NavigationLink.tsx @@ -3,13 +3,12 @@ import clsx from 'clsx'; import {useSelectedLayoutSegment} from 'next/navigation'; import {ComponentProps} from 'react'; -import type {AppPathnames} from '@/config'; +import type {pathnames} from '@/config'; import {Link} from '@/navigation'; -export default function NavigationLink({ - href, - ...rest -}: ComponentProps>) { +export default function NavigationLink< + Pathname extends keyof typeof pathnames +>({href, ...rest}: ComponentProps>) { const selectedLayoutSegment = useSelectedLayoutSegment(); const pathname = selectedLayoutSegment ? `/${selectedLayoutSegment}` : '/'; const isActive = pathname === href; diff --git a/examples/example-app-router/src/config.ts b/examples/example-app-router/src/config.ts index 74134e99a..e68f562dd 100644 --- a/examples/example-app-router/src/config.ts +++ b/examples/example-app-router/src/config.ts @@ -13,8 +13,6 @@ export const pathnames: Pathnames = { export const localePrefix: LocalePrefixConfig = 'always'; -export type AppPathnames = keyof typeof pathnames; - export const port = process.env.PORT || 3000; export const host = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` From 9589a96c9da50de9785a39f1239364a1a8d9628a Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 3 Jun 2024 23:07:47 +0200 Subject: [PATCH 24/45] Fix lint --- .../test/middleware/getAlternateLinksHeaderValue.test.tsx | 2 +- .../navigation/createLocalizedPathnamesNavigation.test.tsx | 2 +- .../createLocalizedPathnamesNavigation.test.tsx | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx index 04a55952e..b15bba871 100644 --- a/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx +++ b/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx @@ -4,7 +4,7 @@ import {NextRequest} from 'next/server'; import {it, expect, describe} from 'vitest'; import {receiveConfig} from '../../src/middleware/config'; import getAlternateLinksHeaderValue from '../../src/middleware/getAlternateLinksHeaderValue'; -import {Pathnames} from '../../src/navigation/react-client'; +import {Pathnames} from '../../src/routing'; describe.each([{basePath: undefined}, {basePath: '/base'}])( 'basePath: $basePath', diff --git a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx index e43cadc56..7372aaac3 100644 --- a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx @@ -12,7 +12,7 @@ import {it, describe, vi, expect, beforeEach} from 'vitest'; import createLocalizedPathnamesNavigationClient from '../../src/navigation/react-client/createLocalizedPathnamesNavigation'; import createLocalizedPathnamesNavigationServer from '../../src/navigation/react-server/createLocalizedPathnamesNavigation'; import BaseLink from '../../src/navigation/shared/BaseLink'; -import {Pathnames} from '../../src/navigation.react-client'; +import {Pathnames} from '../../src/routing'; import {getRequestLocale} from '../../src/server/react-server/RequestLocale'; import {getLocalePrefix} from '../../src/shared/utils'; 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 8a47caa0d..57806bbc2 100644 --- a/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx @@ -6,10 +6,8 @@ import { } from 'next/navigation'; import React, {ComponentProps} from 'react'; import {it, describe, vi, beforeEach, expect, Mock} from 'vitest'; -import { - Pathnames, - createLocalizedPathnamesNavigation -} from '../../../src/navigation/react-client'; +import {createLocalizedPathnamesNavigation} from '../../../src/navigation/react-client'; +import {Pathnames} from '../../../src/routing'; vi.mock('next/navigation'); From 6f54bfba15a202a88f1e514b8d8c667802a8d76f Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 4 Jun 2024 12:02:36 +0200 Subject: [PATCH 25/45] Docs fixes, optional locales argument for locale prefix config --- docs/pages/docs/routing.mdx | 97 ++++++++++--------- docs/pages/docs/routing/middleware.mdx | 52 +++++----- docs/pages/docs/routing/navigation.mdx | 31 +++--- packages/next-intl/src/config.tsx | 2 +- packages/next-intl/src/routing/config.tsx | 2 +- packages/next-intl/src/routing/types.tsx | 2 +- .../src/server/react-server/RequestLocale.tsx | 2 +- .../next-intl/test/routing/types.test.tsx | 40 ++++++++ 8 files changed, 136 insertions(+), 92 deletions(-) create mode 100644 packages/next-intl/test/routing/types.test.tsx diff --git a/docs/pages/docs/routing.mdx b/docs/pages/docs/routing.mdx index 06e10122d..5d4f08a09 100644 --- a/docs/pages/docs/routing.mdx +++ b/docs/pages/docs/routing.mdx @@ -20,7 +20,7 @@ This enables you to express your app in terms of APIs like ` ## Shared configuration -While the middleware provides a few more options than the navigation APIs, the majority of the configuration is shared between the two and should be used in coordination. +While the middleware provides a few more options than the navigation APIs, the majority of the configuration is shared between the two and should be used in coordination. Typically, this can be achieved by moving the shared configuration into a separate file like `src/config.ts`: ``` src @@ -35,6 +35,8 @@ src ```tsx filename="config.ts" // A list of all locales that are supported export const locales = ['en', 'de'] as const; + +// ... ``` @@ -42,10 +44,11 @@ export const locales = ['en', 'de'] as const; ```tsx filename="middleware.ts" import createMiddleware from 'next-intl/middleware'; -import {locales} from './config'; +import {locales, /* ... */} from './config'; export default createMiddleware({ - locales: locales, + locales, + // ... // Used when no locale matches defaultLocale: 'en' @@ -62,10 +65,10 @@ export const config = { ```tsx filename="src/navigation.ts" import {createSharedPathnamesNavigation} from 'next-intl/navigation'; -import {locales} from './config'; +import {locales, /* ... */} from './config'; export const {Link, redirect, usePathname, useRouter} = - createSharedPathnamesNavigation({locales}); + createSharedPathnamesNavigation({locales, /* ... */}); ``` @@ -73,7 +76,7 @@ export const {Link, redirect, usePathname, useRouter} = ### Locale prefix -By default, the pathnames of your app will be available under a prefix that matches your directory structure (e.g. `src/app/[locale]/about/page.tsx` → `/en/about`). You can however adapt the routing to optionally remove the prefix or customize it per locale. +By default, the pathnames of your app will be available under a prefix that matches your directory structure (e.g. `app/[locale]/about/page.tsx` → `/en/about`). You can however adapt the routing to optionally remove the prefix or customize it per locale by configuring the `localePrefix` setting. #### Always use a locale prefix (default) [#locale-prefix-always] @@ -106,7 +109,7 @@ In this case, requests where the locale prefix matches the default locale will b **Note that:** -1. If you use this strategy, you should make sure that [your middleware matcher detects unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix). +1. If you use this strategy, you should make sure that your middleware matcher detects [unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix). 2. If you use [the `Link` component](/docs/routing/navigation#link), the initial render will point to the prefixed version but will be patched immediately on the client once the component detects that the default locale has rendered. The prefixed version is still valid, but SEO tools might report a hint that the link points to a redirect. #### Never use a locale prefix [#locale-prefix-never] @@ -128,7 +131,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 make sure that [your matcher detects unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix). +1. If you use this strategy, you should make sure that your matcher detects [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 in the middleware. 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](/docs/routing/middleware#alternate-links) are disabled in this mode since URLs might not be unique per locale. @@ -155,41 +158,7 @@ export const localePrefix: LocalePrefixConfig = { **Note that:** 1. The custom prefixes are only visible to the user and rewritten internally to the corresponding locale. Therefore the `[locale]` segment will correspond to the locale, not the prefix. -2. You might have to adapt your [middleware matcher](/docs/routing/middleware#matcher-custom-prefix) to match the custom prefixes. - -### Domains - -If you want to serve your localized content based on different domains, you can provide a list of mappings between domains and locales to the middleware. - -**Examples:** - -- `us.example.com/en` -- `ca.example.com/en` -- `ca.example.com/fr` - -```tsx filename="config.ts" -export const locales = ['en', 'fr'] as const; - -export const domains: DomainsConfig = [ - { - domain: 'us.example.com', - defaultLocale: 'en', - // Optionally restrict the locales available on this domain - locales: ['en'] - }, - { - domain: 'ca.example.com', - defaultLocale: 'en' - // If there are no `locales` specified on a domain, - // all available locales will be supported here - } -]; -``` - -**Note that:** - -1. You can optionally remove the locale prefix in pathnames by changing the [`localePrefix`](#locale-prefix) setting. -2. If no domain matches, the middleware will fall back to the [`defaultLocale`](/docs/routing/middleware#default-locale) (e.g. on `localhost`). +2. You might have to adapt your [middleware matcher](/docs/routing/middleware#matcher-config) to match the custom prefixes. ### Localized pathnames [#pathnames] @@ -208,12 +177,12 @@ export const locales = ['en', 'de'] as const; // paths are rewritten to the shared, internal ones. export const pathnames: Pathnames = { // If all locales use the same pathname, a single - // external path can be used for all locales. + // external path can be used for all locales '/': '/', '/blog': '/blog', // If locales use different paths, you can - // specify each external path per locale. + // specify each external path per locale '/about': { en: '/about', de: '/ueber-uns' @@ -243,9 +212,7 @@ export const pathnames: Pathnames = { **Note:** Localized pathnames map to a single internal pathname that is created via the file-system based routing in Next.js. If you're using an external system like a CMS to localize pathnames, you'll typically implement this with a catch-all route like `[locale]/[[...slug]]`. - If you're using localized pathnames, you should use - [`createLocalizedPathnamesNavigation`](/docs/routing/navigation#localized-pathnames) - instead of `createSharedPathnamesNavigation` for your navigation APIs. + If you're using localized pathnames, you should use `createLocalizedPathnamesNavigation` instead of `createSharedPathnamesNavigation` for your [navigation APIs](/docs/routing/navigation).
@@ -312,3 +279,37 @@ A good practice is to include the ID in the URL, allowing you to retrieve the ar If you localize the values for dynamic segments, you might want to turn off [alternate links](#alternate-links) and provide your own implementation that considers localized values for dynamic segments.
+ +### Domains + +If you want to serve your localized content based on different domains, you can provide a list of mappings between domains and locales via the `domains` setting. + +**Examples:** + +- `us.example.com/en` +- `ca.example.com/en` +- `ca.example.com/fr` + +```tsx filename="config.ts" +export const locales = ['en', 'fr'] as const; + +export const domains: DomainsConfig = [ + { + domain: 'us.example.com', + defaultLocale: 'en', + // Optionally restrict the locales available on this domain + locales: ['en'] + }, + { + domain: 'ca.example.com', + defaultLocale: 'en' + // If there are no `locales` specified on a domain, + // all available locales will be supported here + } +]; +``` + +**Note that:** + +1. You can optionally remove the locale prefix in pathnames by changing the [`localePrefix`](#locale-prefix) setting. +2. If no domain matches, the middleware will fall back to the [`defaultLocale`](/docs/routing/middleware#default-locale) (e.g. on `localhost`). \ No newline at end of file diff --git a/docs/pages/docs/routing/middleware.mdx b/docs/pages/docs/routing/middleware.mdx index 48149ad67..2558c73d7 100644 --- a/docs/pages/docs/routing/middleware.mdx +++ b/docs/pages/docs/routing/middleware.mdx @@ -16,7 +16,7 @@ import {locales} from './config'; export default createMiddleware({ // A list of all locales that are supported - locales: locales, + locales, // Used when no locale matches defaultLocale: 'en' @@ -34,7 +34,7 @@ In addition to handling i18n routing, the middleware sets the `link` header to i The locale is detected 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. -### Prefix-based routing (default) [#prefix-based-routing] +### Prefix-based routing (default) [#location-detection-prefix] By default, [prefix-based routing](/docs/routing#locale-prefix) is used to determine the locale of a request. @@ -55,11 +55,6 @@ To change the locale, users can visit a prefixed route. This will take precedenc 4. When the user clicks on the link, a request to `/de` is initiated. 5. The middleware will update the cookie value to `de`. - - You can optionally remove the locale prefix in pathnames by changing the - [`localePrefix`](/docs/routing#locale-prefix) setting. - -
Which algorithm is used to match the accept-language header against the available locales? @@ -76,7 +71,7 @@ In contrast, the "best fit" algorithm compares the _distance_ between the user's
-### Domain-based routing [#domain-based-routing] +### Domain-based routing [#location-detection-domain] If you're using [domain-based routing](/docs/routing#domains), the middleware will match the request against the available domains to determine the best-matching locale. To retrieve the domain, the host is read from the `x-forwarded-host` header, with a fallback to `host`. @@ -110,7 +105,9 @@ The bestmatching domain is detected based on these priorities: ## Configuration -### Turning off locale detection [#locale-detection] +The middleware accepts a number of configuration options that are [shared](/docs/routing#shared-configuration) with the [navigation APIs](/docs/routing/navigation). This list contains all options that are specific to the middleware. + +### Turning off locale detection [#locale-detection-false] If you want to rely entirely on the URL to resolve the locale, you can disable locale detection based on the `accept-language` header and a potentially existing cookie value from a previous visit. @@ -245,7 +242,7 @@ export default function middleware(request: NextRequest) { There are two use cases where you might want to match pathnames without a locale prefix: -1. You're using a config for [`localePrefix`](#locale-prefix) other than [`always`](#locale-prefix-always) +1. You're using a config for [`localePrefix`](/docs/routing#locale-prefix) other than [`always`](/docs/routing#locale-prefix-always) 2. You want to implement 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. @@ -278,8 +275,6 @@ The `next-intl` middleware as well as [the navigation APIs](/docs/routing/naviga Note however that you should make sure that your [middleware `matcher`](#matcher-config) handles the root of your base path: ```tsx filename="middleware.ts" -// ... - export const config = { // The `matcher` is relative to the `basePath` matcher: [ @@ -305,6 +300,7 @@ If you need to incorporate additional behavior, you can either modify the reques ```tsx filename="middleware.ts" import createIntlMiddleware from 'next-intl/middleware'; import {NextRequest} from 'next/server'; +import {locales} from './config'; export default async function middleware(request: NextRequest) { // Step 1: Use the incoming request (example) @@ -312,7 +308,7 @@ export default async function middleware(request: NextRequest) { // Step 2: Create and call the next-intl middleware (example) const handleI18nRouting = createIntlMiddleware({ - locales: ['en', 'de'], + locales, defaultLocale }); const response = handleI18nRouting(request); @@ -338,6 +334,7 @@ This example rewrites requests for `/[locale]/profile` to `/[locale]/profile/new ```tsx filename="middleware.ts" import createIntlMiddleware from 'next-intl/middleware'; import {NextRequest} from 'next/server'; +import {locales} from './config'; export default async function middleware(request: NextRequest) { const [, locale, ...segments] = request.nextUrl.pathname.split('/'); @@ -352,7 +349,7 @@ export default async function middleware(request: NextRequest) { } const handleI18nRouting = createIntlMiddleware({ - locales: ['en', 'de'], + locales, defaultLocale: 'en' }); const response = handleI18nRouting(request); @@ -364,7 +361,7 @@ export const config = { }; ``` -Note that if you use a [`localePrefix`](#locale-prefix) other than `always`, you need to adapt the handling appropriately to handle unprefixed pathnames too. +Note that if you use a [`localePrefix`](/docs/routing#locale-prefix) other than `always`, you need to adapt the handling appropriately to handle unprefixed pathnames too. ### Example: Integrating with Clerk @@ -373,9 +370,10 @@ Note that if you use a [`localePrefix`](#locale-prefix) other than `always`, you ```tsx filename="middleware.ts" import {clerkMiddleware, createRouteMatcher} from '@clerk/nextjs/server'; import createMiddleware from 'next-intl/middleware'; +import {locales} from './config'; const intlMiddleware = createMiddleware({ - locales: ['en', 'de'], + locales, defaultLocale: 'en' }); @@ -403,9 +401,10 @@ You can do so by following the [setup guide from Supabase](https://supabase.com/ import {type NextRequest} from 'next/server'; import {createServerClient, type CookieOptions} from '@supabase/ssr'; import createIntlMiddleware from 'next-intl/middleware'; +import {locales} from './config'; const handleI18nRouting = createIntlMiddleware({ - locales: ['en', 'de'], + locales, defaultLocale: 'en' }); @@ -458,8 +457,7 @@ import {locales} from './config'; const publicPages = ['/', '/login']; const intlMiddleware = createIntlMiddleware({ - locales: locales, - localePrefix: 'as-needed', + locales, defaultLocale: 'en' }); @@ -509,13 +507,13 @@ There's a working [example that combines `next-intl` with Auth.js](/examples#app ## Usage without middleware (static export) -If you're using the [static export feature from Next.js](https://nextjs.org/docs/app/building-your-application/deploying/static-exports) (`output: 'export'`), the middleware will not run. You can use [prefix-based routing](#prefix-based-routing) nontheless to internationalize your app, but a few tradeoffs apply. +If you're using the [static export](https://nextjs.org/docs/app/building-your-application/deploying/static-exports) feature from Next.js (`output: 'export'`), the middleware will not run. You can use [prefix-based routing](/docs/routing#locale-prefix) nontheless to internationalize your app, but a few tradeoffs apply. **Static export limitations:** -1. There's no default locale that can be used without a prefix (same as [`localePrefix: 'always'`](#locale-prefix-always)) -2. The locale can't be negotiated at runtime (same as [`localeDetection: false`](#locale-detection)) -3. You can't use [pathname localization](#localizing-pathnames) +1. There's no default locale that can be used without a prefix (same as [`localePrefix: 'always'`](/docs/routing#locale-prefix-always)) +2. The locale can't be negotiated at runtime (same as [`localeDetection: false`](#locale-detection-false)) +3. You can't use [pathname localization](/docs/routing#pathnames) 4. This requires [static rendering](/docs/getting-started/app-router/with-i18n-routing#static-rendering) 5. You need to add a redirect for the root of the app @@ -545,12 +543,12 @@ This can happen either because: To recover from this error, please make sure that: 1. You're consistently using a setup with or without [i18n routing](/docs/getting-started/app-router) (i.e. with or without the [routing APIs](/docs/routing)). -2. If you're not using i18n routing: - 1. You don't read the `locale` param in `getRequestConfig` but instead return it. -3. If you're using i18n routing: +2. If you're using a setup _with_ i18n routing: 1. You're using APIs from `next-intl` (including [the navigation APIs](/docs/routing/navigation)) exclusively within the `[locale]` segment. 2. Your [middleware matcher](#matcher-config) matches all routes of your application, including dynamic segments with potentially unexpected characters like dots (e.g. `/users/jane.doe`). - 3. If you're using [`localePrefix: 'as-needed'`](#locale-prefix-as-needed), the `locale` segment effectively acts like a catch-all for all unknown routes. You should make sure that the `locale` is [validated](/docs/usage/configuration#i18nts) before it's used by any APIs from `next-intl`. + 3. If you're using [`localePrefix: 'as-needed'`](/docs/routing#locale-prefix-as-needed), the `locale` segment effectively acts like a catch-all for all unknown routes. You should make sure that the `locale` is [validated](/docs/usage/configuration#i18nts) before it's used by any APIs from `next-intl`. 4. To implement static rendering, make sure to [provide a static locale](/docs/getting-started/app-router/with-i18n-routing#static-rendering) to `next-intl` instead of using `force-static`. +3. If you're using using a setup _without_ i18n routing: + 1. You don't read the `locale` param in `getRequestConfig` but instead return it. Note that `next-intl` will invoke the `notFound()` function to abort the render if the locale can't be found. You should consider adding [a `not-found` page](/docs/environments/error-files#not-foundjs) due to this. diff --git a/docs/pages/docs/routing/navigation.mdx b/docs/pages/docs/routing/navigation.mdx index 19ab50a7f..c06c1a758 100644 --- a/docs/pages/docs/routing/navigation.mdx +++ b/docs/pages/docs/routing/navigation.mdx @@ -9,16 +9,14 @@ import Details from 'components/Details'; routing](/docs/getting-started/app-router). -`next-intl` provides drop-in replacements for common Next.js navigation APIs that automatically handle the user locale behind the scenes. +`next-intl` provides drop-in replacements for common Next.js navigation APIs that automatically handle the user locale and pathnames behind the scenes. Depending on if you're using localized pathnames (i.e. the [`pathnames`](/docs/routing#pathnames) setting), you can pick from one of these functions to create the corresponding navigation APIs: - `createSharedPathnamesNavigation`: Pathnames are shared across all locales (default) -- `createLocalizedPathnamesNavigation`: Pathnames are provided per locale +- `createLocalizedPathnamesNavigation`: Pathnames are provided per locale (use with `pathnames`) -These functions are typically called in a central module like `src/navigation.ts` in order to provide easy access to navigation APIs in your components. - -**Tip:** To ensure consistent usage in your app, you can consider [linting for usage of these APIs](/docs/workflows/linting#consistent-usage-of-navigation-apis). +These functions are typically called in a central module like `src/navigation.ts` in order to provide easy access to navigation APIs in your components and should receive configuration options that are [shared](/docs/routing#shared-configuration) with the middleware. @@ -51,14 +49,21 @@ export const {Link, redirect, usePathname, useRouter, getPathname} = createLocalizedPathnamesNavigation({locales, pathnames, /* ... */}); ``` - - Have a look at the [App Router example](/examples#app-router) to explore a working implementation of localized pathnames. + + +
+How can I ensure consistent usage of navigation APIs? + +To ensure consistent usage in your app, you can consider [linting for usage of these APIs](/docs/workflows/linting#consistent-usage-of-navigation-apis). + +
+ ## APIs ### `Link` @@ -126,7 +131,7 @@ See also the Next.js docs on [creating an active link component](https://nextjs. -When using [localized pathnames](#localized-pathnames), the `href` prop corresponds to an internal pathname, but will be mapped to a locale-specific pathname. +When using [localized pathnames](/docs/routing#pathnames), the `href` prop corresponds to an internal pathname, but will be mapped to a locale-specific pathname. ```tsx import {Link} from '../navigation'; @@ -224,7 +229,7 @@ See also the Next.js docs on [creating an active link component](https://nextjs.