From ae7c8d537cf0b156d5517deaca116ce2c98a3696 Mon Sep 17 00:00:00 2001 From: Robjam Date: Tue, 17 Oct 2023 19:02:53 +0900 Subject: [PATCH 1/6] feat: Add basePath to configuration --- .../middleware/NextIntlMiddlewareConfig.tsx | 4 + .../getAlternateLinksHeaderValue.tsx | 17 ++- .../next-intl/src/middleware/middleware.tsx | 18 ++- .../src/middleware/resolveLocale.tsx | 8 +- packages/next-intl/src/middleware/utils.tsx | 5 +- .../getAlternateLinksHeaderValue.test.tsx | 118 +++++++++-------- .../test/middleware/middleware.test.tsx | 122 ++++++++++-------- 7 files changed, 170 insertions(+), 122 deletions(-) diff --git a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx index 7c29d95be..96982dfac 100644 --- a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx +++ b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx @@ -1,6 +1,9 @@ type LocalePrefix = 'as-needed' | 'always' | 'never'; type RoutingBaseConfig = { + /** The base path of your application (optional). */ + basePath?: string; + /** A list of all locales that are supported. */ locales: Array; @@ -37,6 +40,7 @@ export type MiddlewareConfigWithDefaults = MiddlewareConfig & { alternateLinks: boolean; localePrefix: LocalePrefix; localeDetection: boolean; + basePath: string; }; export default MiddlewareConfig; diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 108daa0ff..6bd6a4ec3 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -6,6 +6,11 @@ import {getHost, isLocaleSupportedOnDomain} from './utils'; function getUnprefixedUrl(config: MiddlewareConfig, request: NextRequest) { const url = new URL(request.url); + + if (config.basePath && !url.pathname.startsWith(config.basePath)) { + url.pathname = `${config.basePath}${url.pathname}`; + } + const host = getHost(request.headers); if (host) { url.port = ''; @@ -45,10 +50,12 @@ export default function getAlternateLinksHeaderValue( const links = config.locales.flatMap((locale) => { function localizePathname(url: URL) { - if (url.pathname === '/') { - url.pathname = `/${locale}`; + if(url.pathname === config.basePath) { + url.pathname = `${url.pathname}/${locale}`; + } else if(config.basePath && url.pathname.startsWith(config.basePath)) { + url.pathname = url.pathname.replace(config.basePath, `${config.basePath}/${locale}`); } else { - url.pathname = `/${locale}${url.pathname}`; + url.pathname = `/${locale}`; } return url; } @@ -73,6 +80,10 @@ export default function getAlternateLinksHeaderValue( localizePathname(url); } + if(config.basePath && !url.pathname.startsWith(config.basePath)) { + url.pathname = `${config.basePath}${url.pathname}`; + } + return getAlternateEntry(url.toString(), locale); }); } else { diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index c4663156e..3c3e2c31f 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -18,7 +18,8 @@ function receiveConfig(config: MiddlewareConfig) { ...config, alternateLinks: config.alternateLinks ?? true, localePrefix: config.localePrefix ?? 'as-needed', - localeDetection: config.localeDetection ?? true + localeDetection: config.localeDetection ?? true, + basePath: config.basePath ?? '', }; return result; @@ -68,7 +69,10 @@ export default function createMiddleware(config: MiddlewareConfig) { } function rewrite(url: string) { - return NextResponse.rewrite(new URL(url, request.url), getResponseInit()); + if (configWithDefaults.basePath && !url.startsWith(configWithDefaults.basePath)) { + url = `${configWithDefaults.basePath}${url}`; + } + return NextResponse.rewrite(new URL(url,request.url), getResponseInit()); } function next() { @@ -93,7 +97,7 @@ export default function createMiddleware(config: MiddlewareConfig) { bestMatchingDomain.defaultLocale === locale && configWithDefaults.localePrefix === 'as-needed' ) { - urlObj.pathname = urlObj.pathname.replace(`/${locale}`, ''); + urlObj.pathname = urlObj.pathname.replace(`${configWithDefaults.basePath}/${locale}`, ''); } } } @@ -102,13 +106,15 @@ export default function createMiddleware(config: MiddlewareConfig) { if (host) { urlObj.host = host; } - + if (configWithDefaults.basePath && !urlObj.pathname.startsWith(configWithDefaults.basePath)) { + urlObj.pathname = `${configWithDefaults.basePath}${urlObj.pathname}`; + } return NextResponse.redirect(urlObj.toString()); } let response; if (isRoot) { - let pathWithSearch = `/${locale}`; + let pathWithSearch = `${configWithDefaults.basePath}/${locale}`; if (request.nextUrl.search) { pathWithSearch += request.nextUrl.search; } @@ -167,7 +173,7 @@ export default function createMiddleware(config: MiddlewareConfig) { } } } else { - response = redirect(`/${locale}${basePath}`); + response = redirect(`/${locale}`); } } else { if ( diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index 2698f2443..815f6bf2e 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -50,7 +50,7 @@ function getAcceptLanguageLocale( } function resolveLocaleFromPrefix( - {defaultLocale, localeDetection, locales}: MiddlewareConfigWithDefaults, + {defaultLocale, localeDetection, locales, basePath}: MiddlewareConfigWithDefaults, requestHeaders: Headers, requestCookies: RequestCookies, pathname: string @@ -59,7 +59,7 @@ function resolveLocaleFromPrefix( // Prio 1: Use route prefix if (pathname) { - const pathLocale = getLocaleFromPathname(pathname); + const pathLocale = getLocaleFromPathname(pathname, basePath); if (locales.includes(pathLocale)) { locale = pathLocale; } @@ -94,7 +94,7 @@ function resolveLocaleFromDomain( requestCookies: RequestCookies, pathname: string ) { - const {domains} = config; + const {domains, basePath} = config; const localeFromPrefixStrategy = resolveLocaleFromPrefix( config, @@ -107,7 +107,7 @@ function resolveLocaleFromDomain( if (domains) { const domain = findDomainFromHost(requestHeaders, domains); const hasLocalePrefix = - pathname && pathname.startsWith(`/${localeFromPrefixStrategy}`); + pathname && pathname.startsWith(`/${localeFromPrefixStrategy}`) || pathname.startsWith(`${basePath}/${localeFromPrefixStrategy}`); if (domain) { return { diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 33228ba35..1fdc6cdaf 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -1,6 +1,9 @@ import {DomainConfig} from './NextIntlMiddlewareConfig'; -export function getLocaleFromPathname(pathname: string) { +export function getLocaleFromPathname(pathname: string, basePath?: string) { + if (basePath && pathname.startsWith(basePath)) { + return pathname.split('/')[2]; + } return pathname.split('/')[1]; } diff --git a/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx index e29c88e71..ab386f1b2 100644 --- a/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx +++ b/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx @@ -1,17 +1,22 @@ // @vitest-environment edge-runtime import {NextRequest} from 'next/server'; -import {it, expect} from 'vitest'; +import {it, expect, describe} from 'vitest'; import {MiddlewareConfigWithDefaults} from '../../src/middleware/NextIntlMiddlewareConfig'; import getAlternateLinksHeaderValue from '../../src/middleware/getAlternateLinksHeaderValue'; +describe.each([ + { basePath: undefined }, + { basePath: '/sample' } +])('when basePath is $basePath', ({ basePath }: { basePath: string }) => { it('works for prefixed routing (as-needed)', () => { const config: MiddlewareConfigWithDefaults = { defaultLocale: 'en', locales: ['en', 'es'], alternateLinks: true, localePrefix: 'as-needed', - localeDetection: true + localeDetection: true, + basePath }; expect( @@ -20,9 +25,9 @@ it('works for prefixed routing (as-needed)', () => { new NextRequest('https://example.com/') ).split(', ') ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="x-default"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="x-default"` ]); expect( @@ -31,9 +36,9 @@ it('works for prefixed routing (as-needed)', () => { new NextRequest('https://example.com/about') ).split(', ') ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="x-default"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="x-default"`, ]); }); @@ -43,7 +48,8 @@ it('works for prefixed routing (always)', () => { locales: ['en', 'es'], alternateLinks: true, localePrefix: 'always', - localeDetection: true + localeDetection: true, + basePath, }; expect( @@ -52,9 +58,9 @@ it('works for prefixed routing (always)', () => { new NextRequest('https://example.com/') ).split(', ') ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="x-default"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="x-default"`, ]); expect( @@ -63,9 +69,9 @@ it('works for prefixed routing (always)', () => { new NextRequest('https://example.com/about') ).split(', ') ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="x-default"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="x-default"` ]); }); @@ -92,7 +98,8 @@ it("works for type domain with `localePrefix: 'as-needed'`", () => { defaultLocale: 'en', locales: ['en', 'fr'] } - ] + ], + basePath, }; [ @@ -106,12 +113,12 @@ it("works for type domain with `localePrefix: 'as-needed'`", () => { ).split(', ') ].forEach((links) => { expect(links).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="fr"`, + `; rel="alternate"; hreflang="fr"` ]); }); @@ -121,12 +128,12 @@ it("works for type domain with `localePrefix: 'as-needed'`", () => { new NextRequest('https://example.com/about') ).split(', ') ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="fr"`, + `; rel="alternate"; hreflang="fr"` ]); }); @@ -153,7 +160,8 @@ it("works for type domain with `localePrefix: 'always'`", () => { defaultLocale: 'en', locales: ['en', 'fr'] } - ] + ], + basePath, }; [ @@ -167,12 +175,12 @@ it("works for type domain with `localePrefix: 'always'`", () => { ).split(', ') ].forEach((links) => { expect(links).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="fr"`, + `; rel="alternate"; hreflang="fr"`, ]); }); @@ -182,12 +190,12 @@ it("works for type domain with `localePrefix: 'always'`", () => { new NextRequest('https://example.com/about') ).split(', ') ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="fr"`, + `; rel="alternate"; hreflang="fr"`, ]); }); @@ -197,7 +205,8 @@ it('uses the external host name from headers instead of the url of the incoming locales: ['en', 'es'], alternateLinks: true, localePrefix: 'as-needed', - localeDetection: true + localeDetection: true, + basePath, }; expect( @@ -212,9 +221,9 @@ it('uses the external host name from headers instead of the url of the incoming }) ).split(', ') ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="x-default"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="x-default"` ]); }); @@ -224,7 +233,8 @@ it('keeps the port of an external host if provided', () => { locales: ['en', 'es'], alternateLinks: true, localePrefix: 'as-needed', - localeDetection: true + localeDetection: true, + basePath, }; expect( @@ -239,9 +249,9 @@ it('keeps the port of an external host if provided', () => { }) ).split(', ') ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="x-default"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="x-default"` ]); }); @@ -251,7 +261,8 @@ it('uses the external host name and the port from headers instead of the url wit locales: ['en', 'es'], alternateLinks: true, localePrefix: 'as-needed', - localeDetection: true + localeDetection: true, + basePath, }; expect( @@ -266,8 +277,9 @@ it('uses the external host name and the port from headers instead of the url wit }) ).split(', ') ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="x-default"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="x-default"` ]); }); +}); diff --git a/packages/next-intl/test/middleware/middleware.test.tsx b/packages/next-intl/test/middleware/middleware.test.tsx index 4ece9ec5b..73c3e2528 100644 --- a/packages/next-intl/test/middleware/middleware.test.tsx +++ b/packages/next-intl/test/middleware/middleware.test.tsx @@ -124,11 +124,15 @@ it('has docs that suggest a reasonable matcher', () => { ).toEqual(test.map(([pathname, expected]) => pathname + ': ' + expected)); }); -describe('prefix-based routing', () => { +describe.each([ + { basePath: undefined}, + { basePath: '/sample'}, +])('prefix-based routing with basePath: $basePath', ({basePath}: { basePath: string | undefined}) => { describe('localePrefix: as-needed', () => { const middleware = createIntlMiddleware({ defaultLocale: 'en', - locales: ['en', 'de'] + locales: ['en', 'de'], + basePath, }); it('rewrites requests for the default locale', () => { @@ -136,7 +140,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en' + `http://localhost:3000${basePath ?? ''}/en` ); }); @@ -145,7 +149,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en?sort=asc' + `http://localhost:3000${basePath ?? ''}/en?sort=asc` ); }); @@ -154,7 +158,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en/list?sort=asc' + `http://localhost:3000${basePath ?? ''}/en/list?sort=asc` ); }); @@ -163,7 +167,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en/#asdf' + `http://localhost:3000${basePath ?? ''}/en/#asdf` ); }); @@ -172,7 +176,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/' + `http://localhost:3000${basePath ?? ''}/` ); }); @@ -181,7 +185,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/about' + `http://localhost:3000${basePath ?? ''}/about` ); }); @@ -190,7 +194,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/de' + `http://localhost:3000${basePath ?? ''}/de` ); }); @@ -199,7 +203,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/de' + `http://localhost:3000${basePath ?? ''}/de` ); }); @@ -269,9 +273,9 @@ describe('prefix-based routing', () => { const response = middleware(createMockRequest('/')); expect(response.headers.get('link')).toBe( [ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="de"`, + `; rel="alternate"; hreflang="x-default"`, ].join(', ') ); }); @@ -282,7 +286,8 @@ describe('prefix-based routing', () => { defaultLocale: 'en', locales: ['en', 'de'], localePrefix: 'as-needed', - localeDetection: false + localeDetection: false, + basePath, }); it('serves non-prefixed requests with the default locale and ignores the accept-language header', () => { @@ -290,7 +295,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en' + `http://localhost:3000${basePath ?? ''}/en` ); }); @@ -300,7 +305,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en' + `http://localhost:3000${basePath ?? ''}/en` ); }); }); @@ -309,7 +314,8 @@ describe('prefix-based routing', () => { const middleware = createIntlMiddleware({ defaultLocale: 'en', locales: ['en', 'de'], - localePrefix: 'always' + localePrefix: 'always', + basePath, }); it('redirects non-prefixed requests for the default locale', () => { @@ -317,7 +323,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en' + `http://localhost:3000${basePath ?? ''}/en` ); }); @@ -326,7 +332,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/de' + `http://localhost:3000${basePath ?? ''}/de` ); }); @@ -335,17 +341,17 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en/engage' + `http://localhost:3000${basePath ?? ''}/en/engage` ); middleware(createMockRequest('/engage?test')); expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( - 'http://localhost:3000/en/engage?test' + `http://localhost:3000${basePath ?? ''}/en/engage?test` ); middleware(createMockRequest('/engage/test')); expect(MockedNextResponse.redirect.mock.calls[2][0].toString()).toBe( - 'http://localhost:3000/en/engage/test' + `http://localhost:3000${basePath ?? ''}/en/engage/test` ); }); @@ -368,7 +374,8 @@ describe('prefix-based routing', () => { const middleware = createIntlMiddleware({ defaultLocale: 'en', locales: ['en', 'de'], - localePrefix: 'never' + localePrefix: 'never', + basePath, }); it('rewrites requests for the default locale', () => { @@ -376,7 +383,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en' + `http://localhost:3000${basePath ?? ''}/en` ); }); @@ -385,7 +392,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/de' + `http://localhost:3000${basePath ?? ''}/de` ); }); @@ -394,7 +401,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en/list' + `http://localhost:3000${basePath ?? ''}/en/list` ); }); @@ -403,7 +410,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/de/list' + `http://localhost:3000${basePath ?? ''}/de/list` ); }); @@ -412,7 +419,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/' + `http://localhost:3000${basePath ?? ''}/` ); }); @@ -421,7 +428,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/' + `http://localhost:3000${basePath ?? ''}/` ); }); @@ -430,7 +437,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/list' + `http://localhost:3000${basePath ?? ''}/list` ); }); @@ -439,7 +446,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/de' + `http://localhost:3000${basePath ?? ''}/de` ); }); @@ -448,7 +455,7 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en' + `http://localhost:3000${basePath ?? ''}/en` ); }); @@ -529,7 +536,10 @@ describe('prefix-based routing', () => { }); }); -describe('domain-based routing', () => { +describe.each([ + { basePath: undefined}, + { basePath: '/sample'}, +])('domain-based routing with basePath: $basePath', ({ basePath }: { basePath: string | undefined }) => { describe('localePrefix: as-needed', () => { const middleware = createIntlMiddleware({ defaultLocale: 'en', @@ -542,7 +552,8 @@ describe('domain-based routing', () => { locales: ['en', 'fr'] }, {defaultLocale: 'fr', domain: 'fr.example.com', locales: ['fr']} - ] + ], + basePath, }); it('serves requests for the default locale at the root', () => { @@ -550,7 +561,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://en.example.com/en' + `http://en.example.com${basePath ?? ''}/en` ); }); @@ -559,7 +570,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://en.example.com/en/about' + `http://en.example.com${basePath ?? ''}/en/about` ); }); @@ -568,7 +579,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en' + `http://localhost:3000${basePath ?? ''}/en` ); }); @@ -597,10 +608,10 @@ describe('domain-based routing', () => { const response = middleware(createMockRequest('/')); expect(response.headers.get('link')).toBe( [ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="fr"`, + `; rel="alternate"; hreflang="fr"`, ].join(', ') ); }); @@ -611,7 +622,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost/en' + `http://localhost${basePath ?? ''}/en` ); }); @@ -620,7 +631,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost/en/about' + `http://localhost${basePath ?? ''}/en/about` ); }); @@ -645,7 +656,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://ca.example.com/en' + `http://ca.example.com${basePath ?? ''}/en` ); }); @@ -654,7 +665,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://ca.example.com/en' + `http://ca.example.com${basePath ?? ''}/en` ); }); @@ -663,7 +674,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://fr.example.com/fr' + `http://fr.example.com${basePath ?? ''}/fr` ); }); @@ -672,7 +683,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://ca.example.com/en/about' + `http://ca.example.com${basePath ?? ''}/en/about` ); }); @@ -681,7 +692,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://fr.example.com/fr/about' + `http://fr.example.com${basePath ?? ''}/fr/about` ); }); @@ -710,7 +721,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://en.example.com/about' + `http://en.example.com${basePath ?? ''}/about` ); }); @@ -721,7 +732,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://en.example.com/about' + `http://en.example.com${basePath ?? ''}/about` ); }); @@ -732,7 +743,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://fr.example.com/about' + `http://fr.example.com${basePath ?? ''}/about` ); }); @@ -743,7 +754,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://fr.example.com/about' + `http://fr.example.com${basePath ?? ''}/about` ); }); }); @@ -761,7 +772,8 @@ describe('domain-based routing', () => { domain: 'ca.example.com', locales: ['en', 'fr'] } - ] + ], + basePath, }); it('redirects non-prefixed requests for the default locale', () => { @@ -769,7 +781,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://example.com/en' + `http://example.com${basePath ?? ''}/en` ); }); @@ -778,7 +790,7 @@ describe('domain-based routing', () => { expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://ca.example.com/fr' + `http://ca.example.com${basePath ?? ''}/fr` ); }); From 59f25d18a5ac155183bbd8c19c19fc78631f9572 Mon Sep 17 00:00:00 2001 From: Robjam Date: Tue, 31 Oct 2023 11:34:27 +0900 Subject: [PATCH 2/6] docs: Add `basePath` to createMiddleware config --- docs/pages/docs/routing/middleware.mdx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/pages/docs/routing/middleware.mdx b/docs/pages/docs/routing/middleware.mdx index c57e82acd..97250ee24 100644 --- a/docs/pages/docs/routing/middleware.mdx +++ b/docs/pages/docs/routing/middleware.mdx @@ -8,6 +8,11 @@ The middleware handles redirects and rewrites based on the detected user locale. import createMiddleware from 'next-intl/middleware'; export default createMiddleware({ + // Use `basepath` to deploy to a sub-path like `/blog`. + // Typically `basePath` is set to the same value in + // `next.config.js` options + basePath: '/blog', + // A list of all locales that are supported locales: ['en', 'de'], From 14db2e8369f8c1c51443cc00a307ff7b4e7ca320 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 6 Dec 2023 11:40:32 +0100 Subject: [PATCH 3/6] First draft --- .../middleware/NextIntlMiddlewareConfig.tsx | 4 - .../getAlternateLinksHeaderValue.tsx | 36 +- .../next-intl/src/middleware/middleware.tsx | 39 +- .../src/middleware/resolveLocale.tsx | 8 +- packages/next-intl/src/middleware/utils.tsx | 5 +- .../getAlternateLinksHeaderValue.test.tsx | 189 +- .../test/middleware/middleware.test.tsx | 3109 +++++++++-------- 7 files changed, 1743 insertions(+), 1647 deletions(-) diff --git a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx index 526bcd750..72396d3be 100644 --- a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx +++ b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx @@ -1,9 +1,6 @@ import {AllLocales, LocalePrefix, Pathnames} from '../shared/types'; type RoutingBaseConfig = { - /** The base path of your application (optional). */ - basePath?: string; // TODO: Remove - /** A list of all locales that are supported. */ locales: Locales; @@ -50,7 +47,6 @@ export type MiddlewareConfigWithDefaults = alternateLinks: boolean; localePrefix: LocalePrefix; localeDetection: boolean; - basePath: string; // TODO: Remove }; export default MiddlewareConfig; diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index e5e6a75ba..6261adfb3 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -8,10 +8,6 @@ import { isLocaleSupportedOnDomain } from './utils'; -function getAlternateEntry(url: string, locale: string) { - return `<${url}>; rel="alternate"; hreflang="${locale}"`; -} - /** * See https://developers.google.com/search/docs/specialty/international/localized-versions */ @@ -29,6 +25,7 @@ export default function getAlternateLinksHeaderValue< localizedPathnames?: Pathnames[string]; }) { const normalizedUrl = request.nextUrl.clone(); + const host = getHost(request.headers); if (host) { normalizedUrl.port = ''; @@ -37,11 +34,32 @@ export default function getAlternateLinksHeaderValue< normalizedUrl.protocol = request.headers.get('x-forwarded-proto') ?? normalizedUrl.protocol; + // Remove the base path and apply it later again to avoid + // confusing it with an actual pathname + if (request.nextUrl.basePath) { + normalizedUrl.pathname = normalizedUrl.pathname.replace( + new RegExp(`^${request.nextUrl.basePath}`), + '' + ); + } + normalizedUrl.pathname = getNormalizedPathname( normalizedUrl.pathname, config.locales ); + function getAlternateEntry(url: URL, locale: string) { + if (request.nextUrl.basePath) { + url = new URL(url); + url.pathname = `${request.nextUrl.basePath}${url.pathname}`; + if (url.pathname.endsWith('/')) { + url.pathname = url.pathname.slice(0, -1); + } + } + + return `<${url.toString()}>; rel="alternate"; hreflang="${locale}"`; + } + function getLocalizedPathname(pathname: string, locale: Locales[number]) { if (localizedPathnames && typeof localizedPathnames === 'object') { return formatTemplatePathname( @@ -84,11 +102,7 @@ export default function getAlternateLinksHeaderValue< url.pathname = prefixPathname(url.pathname); } - if (config.basePath && !url.pathname.startsWith(config.basePath)) { - url.pathname = `${config.basePath}${url.pathname}`; - } - - return getAlternateEntry(url.toString(), locale); + return getAlternateEntry(url, locale); }); } else { let pathname: string; @@ -104,7 +118,7 @@ export default function getAlternateLinksHeaderValue< url = new URL(pathname, normalizedUrl); } - return getAlternateEntry(url.toString(), locale); + return getAlternateEntry(url, locale); }); // Add x-default entry @@ -113,7 +127,7 @@ export default function getAlternateLinksHeaderValue< getLocalizedPathname(normalizedUrl.pathname, config.defaultLocale), normalizedUrl ); - links.push(getAlternateEntry(url.toString(), 'x-default')); + links.push(getAlternateEntry(url, 'x-default')); } else { // For domain-based routing there is no reasonable x-default } diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 2d43a73b5..9740ed97a 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -26,8 +26,7 @@ function receiveConfig( ...config, alternateLinks: config.alternateLinks ?? true, localePrefix: config.localePrefix ?? 'always', - localeDetection: config.localeDetection ?? true, - basePath: config.basePath ?? '' // TODO: Remove + localeDetection: config.localeDetection ?? true }; return result; @@ -67,12 +66,6 @@ export default function createMiddleware( } function rewrite(url: string) { - if ( - configWithDefaults.basePath && - !url.startsWith(configWithDefaults.basePath) - ) { - url = `${configWithDefaults.basePath}${url}`; - } return NextResponse.rewrite(new URL(url, request.url), getResponseInit()); } @@ -110,12 +103,14 @@ export default function createMiddleware( urlObj.port = ''; urlObj.host = redirectDomain; } - if ( - configWithDefaults.basePath && - !urlObj.pathname.startsWith(configWithDefaults.basePath) - ) { - urlObj.pathname = `${configWithDefaults.basePath}${urlObj.pathname}`; + + if (request.nextUrl.basePath) { + urlObj.pathname = request.nextUrl.basePath + urlObj.pathname; + if (urlObj.pathname.endsWith('/')) { + urlObj.pathname = urlObj.pathname.slice(0, -1); + } } + return NextResponse.redirect(urlObj.toString()); } @@ -199,19 +194,19 @@ export default function createMiddleware( ); if (hasLocalePrefix) { - const basePath = getNormalizedPathname( - getPathWithSearch(normalizedPathname, request.nextUrl.search), - configWithDefaults.locales + const normalizedPathnameWithSearch = getPathWithSearch( + normalizedPathname, + request.nextUrl.search ); if (configWithDefaults.localePrefix === 'never') { - response = redirect(basePath); + response = redirect(normalizedPathnameWithSearch); } else if (pathLocale === locale) { if ( hasMatchedDefaultLocale && configWithDefaults.localePrefix === 'as-needed' ) { - response = redirect(basePath); + response = redirect(normalizedPathnameWithSearch); } else { if (configWithDefaults.domains) { const pathDomain = getBestMatchingDomain( @@ -221,7 +216,10 @@ export default function createMiddleware( ); if (domain?.domain !== pathDomain?.domain && !hasUnknownHost) { - response = redirect(basePath, pathDomain?.domain); + response = redirect( + normalizedPathnameWithSearch, + pathDomain?.domain + ); } else { response = rewrite(internalPathWithSearch); } @@ -230,7 +228,7 @@ export default function createMiddleware( } } } else { - response = redirect(`/${locale}${basePath}`); + response = redirect(`/${locale}${normalizedPathnameWithSearch}`); } } else { if ( @@ -249,6 +247,7 @@ export default function createMiddleware( if (hasOutdatedCookie) { response.cookies.set(COOKIE_LOCALE_NAME, locale, { + path: request.nextUrl.basePath || '/', sameSite: 'strict', maxAge: 31536000 // 1 year }); diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index 2950b5374..04a12edc4 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -56,7 +56,6 @@ function getAcceptLanguageLocale( function resolveLocaleFromPrefix( { - basePath, defaultLocale, localeDetection, locales @@ -69,7 +68,7 @@ function resolveLocaleFromPrefix( // Prio 1: Use route prefix if (pathname) { - const pathLocale = getLocaleFromPathname(pathname, basePath); + const pathLocale = getLocaleFromPathname(pathname); if (locales.includes(pathLocale)) { locale = pathLocale; } @@ -104,7 +103,7 @@ function resolveLocaleFromDomain( requestCookies: RequestCookies, pathname: string ) { - const {basePath, domains} = config; + const {domains} = config; const localeFromPrefixStrategy = resolveLocaleFromPrefix( config, @@ -117,8 +116,7 @@ function resolveLocaleFromDomain( if (domains) { const domain = findDomainFromHost(requestHeaders, domains); const hasLocalePrefix = - (pathname && pathname.startsWith(`/${localeFromPrefixStrategy}`)) || - pathname.startsWith(`${basePath}/${localeFromPrefixStrategy}`); + pathname && pathname.startsWith(`/${localeFromPrefixStrategy}`); if (domain) { return { diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index a422c878f..2f3f72e5d 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -5,10 +5,7 @@ import { MiddlewareConfigWithDefaults } from './NextIntlMiddlewareConfig'; -export function getLocaleFromPathname(pathname: string, basePath?: string) { - if (basePath && pathname.startsWith(basePath)) { - return pathname.split('/')[2]; - } +export function getLocaleFromPathname(pathname: string) { return pathname.split('/')[1]; } diff --git a/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx index e07d5bca7..cf7823c87 100644 --- a/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx +++ b/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx @@ -6,35 +6,48 @@ import {MiddlewareConfigWithDefaults} from '../../src/middleware/NextIntlMiddlew import getAlternateLinksHeaderValue from '../../src/middleware/getAlternateLinksHeaderValue'; import {Pathnames} from '../../src/navigation/react-client'; -describe.each([{basePath: ''}, {basePath: '/sample'}])( - 'when basePath is $basePath', - ({basePath}: {basePath: string}) => { +describe.each([{basePath: undefined}, {basePath: '/base'}])( + 'basePath: $basePath', + ({basePath = ''}: {basePath?: string}) => { + function getMockRequest( + ...args: ConstructorParameters + ) { + const request = new NextRequest(...args); + if (basePath) { + request.nextUrl.basePath = basePath; + } + return request; + } + it('works for prefixed routing (as-needed)', () => { const config: MiddlewareConfigWithDefaults<['en', 'es']> = { defaultLocale: 'en', locales: ['en', 'es'], alternateLinks: true, localePrefix: 'as-needed', - localeDetection: true, - basePath + localeDetection: true }; expect( getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://example.com/'), + request: getMockRequest('https://example.com/'), resolvedLocale: 'en' }).split(', ') ).toEqual([ - `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="en"`, `; rel="alternate"; hreflang="es"`, - `; rel="alternate"; hreflang="x-default"` + `; rel="alternate"; hreflang="x-default"` ]); expect( getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://example.com/about'), + request: getMockRequest('https://example.com/about'), resolvedLocale: 'en' }).split(', ') ).toEqual([ @@ -46,13 +59,13 @@ describe.each([{basePath: ''}, {basePath: '/sample'}])( expect( getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://example.com/energy/es'), + request: getMockRequest('https://example.com/energy/es'), resolvedLocale: 'en' }).split(', ') ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="es"', - '; rel="alternate"; hreflang="x-default"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="x-default"` ]); }); @@ -62,8 +75,7 @@ describe.each([{basePath: ''}, {basePath: '/sample'}])( locales: ['en', 'de'], alternateLinks: true, localePrefix: 'as-needed', - localeDetection: true, - basePath + localeDetection: true }; const pathnames = { '/': '/', @@ -84,53 +96,57 @@ describe.each([{basePath: ''}, {basePath: '/sample'}])( expect( getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://example.com/'), + request: getMockRequest('https://example.com/'), resolvedLocale: 'en', localizedPathnames: pathnames['/'] }).split(', ') ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="de"`, + `; rel="alternate"; hreflang="x-default"` ]); expect( getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://example.com/about'), + request: getMockRequest('https://example.com/about'), resolvedLocale: 'en', localizedPathnames: pathnames['/about'] }).split(', ') ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="de"`, + `; rel="alternate"; hreflang="x-default"` ]); expect( getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://example.com/de/ueber'), + request: getMockRequest('https://example.com/de/ueber'), resolvedLocale: 'de', localizedPathnames: pathnames['/about'] }).split(', ') ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="de"`, + `; rel="alternate"; hreflang="x-default"` ]); expect( getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://example.com/users/2'), + request: getMockRequest('https://example.com/users/2'), resolvedLocale: 'en', localizedPathnames: pathnames['/users/[userId]'] }).split(', ') ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="de"`, + `; rel="alternate"; hreflang="x-default"` ]); }); @@ -140,26 +156,27 @@ describe.each([{basePath: ''}, {basePath: '/sample'}])( locales: ['en', 'es'], alternateLinks: true, localePrefix: 'always', - localeDetection: true, - basePath + localeDetection: true }; expect( getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://example.com/'), + request: getMockRequest('https://example.com/'), resolvedLocale: 'en' }).split(', ') ).toEqual([ `; rel="alternate"; hreflang="en"`, `; rel="alternate"; hreflang="es"`, - `; rel="alternate"; hreflang="x-default"` + `; rel="alternate"; hreflang="x-default"` ]); expect( getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://example.com/about'), + request: getMockRequest('https://example.com/about'), resolvedLocale: 'en' }).split(', ') ).toEqual([ @@ -192,27 +209,32 @@ describe.each([{basePath: ''}, {basePath: '/sample'}])( defaultLocale: 'en', locales: ['en', 'fr'] } - ], - basePath + ] }; [ getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://example.com/'), + request: getMockRequest('https://example.com/'), resolvedLocale: 'en' }).split(', '), getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://example.es'), + request: getMockRequest('https://example.es'), resolvedLocale: 'es' }).split(', ') ].forEach((links) => { expect(links).toEqual([ - `; rel="alternate"; hreflang="en"`, - `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="en"`, `; rel="alternate"; hreflang="es"`, - `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="es"`, `; rel="alternate"; hreflang="fr"`, `; rel="alternate"; hreflang="fr"` ]); @@ -221,7 +243,7 @@ describe.each([{basePath: ''}, {basePath: '/sample'}])( expect( getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://example.com/about'), + request: getMockRequest('https://example.com/about'), resolvedLocale: 'en' }).split(', ') ).toEqual([ @@ -257,19 +279,18 @@ describe.each([{basePath: ''}, {basePath: '/sample'}])( defaultLocale: 'en', locales: ['en', 'fr'] } - ], - basePath + ] }; [ getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://example.com/'), + request: getMockRequest('https://example.com/'), resolvedLocale: 'en' }).split(', '), getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://example.es'), + request: getMockRequest('https://example.es'), resolvedLocale: 'es' }).split(', ') ].forEach((links) => { @@ -286,7 +307,7 @@ describe.each([{basePath: ''}, {basePath: '/sample'}])( expect( getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://example.com/about'), + request: getMockRequest('https://example.com/about'), resolvedLocale: 'en' }).split(', ') ).toEqual([ @@ -306,7 +327,6 @@ describe.each([{basePath: ''}, {basePath: '/sample'}])( localeDetection: true, defaultLocale: 'en', locales: ['en', 'fr'], - basePath, domains: [ {defaultLocale: 'en', domain: 'en.example.com', locales: ['en']}, { @@ -348,57 +368,63 @@ describe.each([{basePath: ''}, {basePath: '/sample'}])( [ getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://en.example.com/'), + request: getMockRequest('https://en.example.com/'), resolvedLocale: 'en' }), getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://ca.example.com'), + request: getMockRequest('https://ca.example.com'), resolvedLocale: 'en' }), getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://ca.example.com/fr'), + request: getMockRequest('https://ca.example.com/fr'), resolvedLocale: 'fr' }), getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://fr.example.com'), + request: getMockRequest('https://fr.example.com'), resolvedLocale: 'fr' }) ] .map((links) => links.split(', ')) .forEach((links) => { expect(links).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="fr"`, + `; rel="alternate"; hreflang="fr"` ]); }); [ getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://en.example.com/about'), + request: getMockRequest('https://en.example.com/about'), resolvedLocale: 'en', localizedPathnames: config.pathnames!['/about'] }), getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://ca.example.com/about'), + request: getMockRequest('https://ca.example.com/about'), resolvedLocale: 'en', localizedPathnames: config.pathnames!['/about'] }), getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://ca.example.com/fr/a-propos'), + request: getMockRequest('https://ca.example.com/fr/a-propos'), resolvedLocale: 'fr', localizedPathnames: config.pathnames!['/about'] }), getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://fr.example.com/a-propos'), + request: getMockRequest('https://fr.example.com/a-propos'), resolvedLocale: 'fr', localizedPathnames: config.pathnames!['/about'] }) @@ -406,35 +432,35 @@ describe.each([{basePath: ''}, {basePath: '/sample'}])( .map((links) => links.split(', ')) .forEach((links) => { expect(links).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="fr"`, + `; rel="alternate"; hreflang="fr"` ]); }); [ getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://en.example.com/users/42'), + request: getMockRequest('https://en.example.com/users/42'), resolvedLocale: 'en', localizedPathnames: config.pathnames!['/users/[userId]'] }), getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://ca.example.com/users/42'), + request: getMockRequest('https://ca.example.com/users/42'), resolvedLocale: 'en', localizedPathnames: config.pathnames!['/users/[userId]'] }), getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://ca.example.com/fr/utilisateurs/42'), + request: getMockRequest('https://ca.example.com/fr/utilisateurs/42'), resolvedLocale: 'fr', localizedPathnames: config.pathnames!['/users/[userId]'] }), getAlternateLinksHeaderValue({ config, - request: new NextRequest('https://fr.example.com/utilisateurs/42'), + request: getMockRequest('https://fr.example.com/utilisateurs/42'), resolvedLocale: 'fr', localizedPathnames: config.pathnames!['/users/[userId]'] }) @@ -442,10 +468,10 @@ describe.each([{basePath: ''}, {basePath: '/sample'}])( .map((links) => links.split(', ')) .forEach((links) => { expect(links).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="fr"`, + `; rel="alternate"; hreflang="fr"` ]); }); }); @@ -456,14 +482,13 @@ describe.each([{basePath: ''}, {basePath: '/sample'}])( locales: ['en', 'es'], alternateLinks: true, localePrefix: 'as-needed', - localeDetection: true, - basePath + localeDetection: true }; expect( getAlternateLinksHeaderValue({ config, - request: new NextRequest('http://127.0.0.1/about', { + request: getMockRequest('http://127.0.0.1/about', { headers: { host: 'example.com', 'x-forwarded-host': 'example.com', @@ -485,14 +510,13 @@ describe.each([{basePath: ''}, {basePath: '/sample'}])( locales: ['en', 'es'], alternateLinks: true, localePrefix: 'as-needed', - localeDetection: true, - basePath + localeDetection: true }; expect( getAlternateLinksHeaderValue({ config, - request: new NextRequest('http://127.0.0.1/about', { + request: getMockRequest('http://127.0.0.1/about', { headers: { host: 'example.com:3000', 'x-forwarded-host': 'example.com:3000', @@ -514,14 +538,13 @@ describe.each([{basePath: ''}, {basePath: '/sample'}])( locales: ['en', 'es'], alternateLinks: true, localePrefix: 'as-needed', - localeDetection: true, - basePath + localeDetection: true }; expect( getAlternateLinksHeaderValue({ config, - request: new NextRequest('http://127.0.0.1:3000/about', { + request: getMockRequest('http://127.0.0.1:3000/about', { headers: { host: 'example.com', 'x-forwarded-host': 'example.com', diff --git a/packages/next-intl/test/middleware/middleware.test.tsx b/packages/next-intl/test/middleware/middleware.test.tsx index cd25025f9..bad7e3dd9 100644 --- a/packages/next-intl/test/middleware/middleware.test.tsx +++ b/packages/next-intl/test/middleware/middleware.test.tsx @@ -117,1119 +117,1421 @@ it('has docs that suggest a reasonable matcher', () => { ).toEqual(test.map(([pathname, expected]) => pathname + ': ' + expected)); }); -describe.each([{basePath: ''}, {basePath: '/sample'}])( - 'prefix-based routing with basePath: $basePath', - ({basePath}: {basePath: string}) => { - describe('localePrefix: as-needed', () => { - const middleware = createIntlMiddleware({ +describe('prefix-based routing', () => { + describe('localePrefix: as-needed', () => { + const middleware = createIntlMiddleware({ + defaultLocale: 'en', + locales: ['en', 'de'], + localePrefix: 'as-needed' + }); + + it('rewrites requests for the default locale', () => { + middleware(createMockRequest('/')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en' + ); + }); + + it('rewrites requests for the default locale with query params at the root', () => { + middleware(createMockRequest('/?sort=asc')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en?sort=asc' + ); + }); + + it('rewrites requests for the default locale with query params at a nested path', () => { + middleware(createMockRequest('/list?sort=asc')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en/list?sort=asc' + ); + }); + + it('redirects requests for the default locale when prefixed at the root', () => { + middleware(createMockRequest('/en')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/' + ); + }); + + it('redirects requests for the default locale when prefixed at the root with search params', () => { + middleware(createMockRequest('/en?search')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/?search' + ); + }); + + it('redirects requests for the default locale when prefixed at sub paths', () => { + middleware(createMockRequest('/en/about')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/about' + ); + }); + + it('keeps route segments intact that start with the same characters as the locale', () => { + middleware(createMockRequest('/en/energy/overview/entry')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/energy/overview/entry' + ); + }); + + it('redirects requests for other locales', () => { + middleware(createMockRequest('/', 'de')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de' + ); + }); + + it('redirects requests for the root if a cookie exists with a non-default locale', () => { + middleware(createMockRequest('/', 'en', 'http://localhost:3000', 'de')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de' + ); + }); + + it('serves requests for other locales when prefixed', () => { + middleware(createMockRequest('/de')); + 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/de' + ); + }); + + it('serves requests for other locales when prefixed with a trailing slash', () => { + middleware(createMockRequest('/de/')); + 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/de/' + ); + }); + + it('serves requests for other locales with query params at the root', () => { + middleware(createMockRequest('/de?sort=asc')); + 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/de?sort=asc' + ); + }); + + it('serves requests for other locales with query params at a nested path', () => { + middleware(createMockRequest('/de/list?sort=asc')); + 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/de/list?sort=asc' + ); + }); + + it('sets a cookie', () => { + const response = middleware(createMockRequest('/')); + expect(response.cookies.get('NEXT_LOCALE')).toEqual({ + name: 'NEXT_LOCALE', + value: 'en' + }); + }); + + it('retains request headers for the default locale', () => { + middleware( + createMockRequest('/', 'en', 'http://localhost:3000', undefined, { + 'x-test': 'test' + }) + ); + expect( + MockedNextResponse.rewrite.mock.calls[0][1]?.request?.headers?.get( + 'x-test' + ) + ).toBe('test'); + }); + + it('retains request headers for secondary locales', () => { + middleware( + createMockRequest('/de', 'de', 'http://localhost:3000', undefined, { + 'x-test': 'test' + }) + ); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect( + MockedNextResponse.rewrite.mock.calls[0][1]?.request?.headers?.get( + 'x-test' + ) + ).toBe('test'); + }); + + it('returns alternate links', () => { + const response = middleware(createMockRequest('/')); + expect(response.headers.get('link')).toBe( + [ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ].join(', ') + ); + }); + + it('always provides the locale via a request header, even if a cookie exists with the correct value (see https://github.com/amannn/next-intl/discussions/446)', () => { + middleware(createMockRequest('/', 'en', 'http://localhost:3000', 'en')); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect( + MockedNextResponse.rewrite.mock.calls[0][1]?.request?.headers?.get( + 'x-next-intl-locale' + ) + ).toBe('en'); + }); + + describe('base path', () => { + it('redirects correctly when removing the default locale at the root', () => { + const request = createMockRequest('/en'); + request.nextUrl.basePath = '/base'; + middleware(request); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/base' + ); + }); + + it('redirects correctly when removing the default locale at sub paths', () => { + const request = createMockRequest('/en/about'); + request.nextUrl.basePath = '/base'; + middleware(request); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/base/about' + ); + }); + + it('redirects correctly when adding a prefix for a non-default locale', () => { + const request = createMockRequest('/', 'de'); + request.nextUrl.basePath = '/base'; + middleware(request); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/base/de' + ); + }); + + it('returns alternate links', () => { + const request = createMockRequest('/'); + request.nextUrl.basePath = '/base'; + const response = middleware(request); + expect(response.headers.get('link')?.split(', ')).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + }); + }); + + describe('localized pathnames', () => { + const middlewareWithPathnames = createIntlMiddleware({ defaultLocale: 'en', locales: ['en', 'de'], - basePath, - localePrefix: 'as-needed' + localePrefix: 'as-needed', + pathnames: { + '/': '/', + '/about': { + en: '/about', + de: '/ueber' + }, + '/users': { + en: '/users', + de: '/benutzer' + }, + '/users/[userId]': { + en: '/users/[userId]', + de: '/benutzer/[userId]' + }, + '/news/[articleSlug]-[articleId]': { + en: '/news/[articleSlug]-[articleId]', + de: '/neuigkeiten/[articleSlug]-[articleId]' + }, + '/products/[...slug]': { + en: '/products/[...slug]', + de: '/produkte/[...slug]' + }, + '/categories/[[...slug]]': { + en: '/categories/[[...slug]]', + de: '/kategorien/[[...slug]]' + } + } satisfies Pathnames> }); - it('rewrites requests for the default locale', () => { - middleware(createMockRequest('/')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); + it('serves requests for the default locale at the root', () => { + middlewareWithPathnames(createMockRequest('/', 'en')); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/en` + 'http://localhost:3000/en' ); }); - it('rewrites requests for the default locale with query params at the root', () => { - middleware(createMockRequest('/?sort=asc')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); + it('serves requests for the default locale at nested paths', () => { + middlewareWithPathnames(createMockRequest('/about', 'en')); + middlewareWithPathnames(createMockRequest('/users', 'en')); + middlewareWithPathnames(createMockRequest('/users/1', 'en')); + middlewareWithPathnames( + createMockRequest('/news/happy-newyear-g5b116754', 'en') + ); + middlewareWithPathnames( + createMockRequest('/products/apparel/t-shirts', 'en') + ); + middlewareWithPathnames( + createMockRequest('/categories/women/t-shirts', 'en') + ); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(6); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/en?sort=asc` + 'http://localhost:3000/en/about' + ); + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/en/users' + ); + expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( + 'http://localhost:3000/en/users/1' + ); + expect(MockedNextResponse.rewrite.mock.calls[3][0].toString()).toBe( + 'http://localhost:3000/en/news/happy-newyear-g5b116754' + ); + expect(MockedNextResponse.rewrite.mock.calls[4][0].toString()).toBe( + 'http://localhost:3000/en/products/apparel/t-shirts' + ); + expect(MockedNextResponse.rewrite.mock.calls[5][0].toString()).toBe( + 'http://localhost:3000/en/categories/women/t-shirts' ); }); - it('rewrites requests for the default locale with query params at a nested path', () => { - middleware(createMockRequest('/list?sort=asc')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); + it('serves requests for a non-default locale at the root', () => { + middlewareWithPathnames(createMockRequest('/de', 'de')); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); // We rewrite just in case expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/en/list?sort=asc` + 'http://localhost:3000/de' ); }); - it('handles hashes for the default locale', () => { - middleware(createMockRequest('/#asdf')); + it('serves requests for a non-default locale at nested paths', () => { + middlewareWithPathnames(createMockRequest('/de/ueber', 'de')); + middlewareWithPathnames(createMockRequest('/de/benutzer', 'de')); + middlewareWithPathnames(createMockRequest('/de/benutzer/1', 'de')); + middlewareWithPathnames( + createMockRequest('/de/neuigkeiten/happy-newyear-g5b116754', 'de') + ); + middlewareWithPathnames( + createMockRequest('/de/produkte/kleidung/t-shirts', 'de') + ); + middlewareWithPathnames( + createMockRequest('/de/kategorien/frauen/t-shirts', 'de') + ); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(6); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/en/#asdf` + 'http://localhost:3000/de/about' + ); + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/de/users' + ); + expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( + 'http://localhost:3000/de/users/1' + ); + expect(MockedNextResponse.rewrite.mock.calls[3][0].toString()).toBe( + 'http://localhost:3000/de/news/happy-newyear-g5b116754' + ); + expect(MockedNextResponse.rewrite.mock.calls[4][0].toString()).toBe( + 'http://localhost:3000/de/products/kleidung/t-shirts' + ); + expect(MockedNextResponse.rewrite.mock.calls[5][0].toString()).toBe( + 'http://localhost:3000/de/categories/frauen/t-shirts' ); }); - it('redirects requests for the default locale when prefixed at the root', () => { - middleware(createMockRequest('/en')); + it('redirects a request for a localized route that is not associated with the requested locale', () => { + middlewareWithPathnames(createMockRequest('/ueber', 'en')); expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/` + 'http://localhost:3000/about' ); }); - it('redirects requests for the default locale when prefixed at the root with search params', () => { - middleware(createMockRequest('/en?search')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); + it('redirects when a pathname from the default locale ends up with a different locale', () => { + // Relevant to avoid duplicate content issues + middlewareWithPathnames(createMockRequest('/de/about', 'de')); + middlewareWithPathnames(createMockRequest('/de/users/2', 'de')); + middlewareWithPathnames(createMockRequest('/de/users/2?page=1', 'de')); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(3); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/?search' + 'http://localhost:3000/de/ueber' + ); + expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/de/benutzer/2' + ); + // + expect(MockedNextResponse.redirect.mock.calls[2][0].toString()).toBe( + 'http://localhost:3000/de/benutzer/2?page=1' ); }); - it('redirects requests for the default locale when prefixed at sub paths', () => { - middleware(createMockRequest('/en/about')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); + it('redirects a non-prefixed nested path to a localized alternative if another locale was detected', () => { + middlewareWithPathnames(createMockRequest('/about', 'de')); + middlewareWithPathnames(createMockRequest('/users/2', 'de')); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(2); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/about` + 'http://localhost:3000/de/ueber' + ); + expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/de/benutzer/2' + ); + }); + + it('sets alternate links', () => { + function getLinks(request: NextRequest) { + return middlewareWithPathnames(request) + .headers.get('link') + ?.split(', '); + } + + expect(getLinks(createMockRequest('/', 'en'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect(getLinks(createMockRequest('/de', 'de'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect(getLinks(createMockRequest('/about', 'en'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect(getLinks(createMockRequest('/de/ueber', 'de'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect(getLinks(createMockRequest('/users/1', 'en'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect(getLinks(createMockRequest('/de/benutzer/1', 'de'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect( + getLinks(createMockRequest('/products/apparel/t-shirts', 'en')) + ).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect( + getLinks(createMockRequest('/de/produkte/apparel/t-shirts', 'de')) + ).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect(getLinks(createMockRequest('/unknown', 'en'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect(getLinks(createMockRequest('/de/unknown', 'de'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + }); + + it('rewrites requests when the pathname is mapped for the default locale as well', () => { + const callMiddleware = createIntlMiddleware({ + defaultLocale: 'en', + locales: ['en', 'de'], + localePrefix: 'as-needed', + pathnames: { + '/a': { + en: '/one', + de: '/eins' + }, + '/b/[param]': { + en: '/two/[param]', + de: '/zwei/[param]' + } + } + }); + callMiddleware(createMockRequest('/one', 'en')); + callMiddleware(createMockRequest('/de/eins', 'de')); + callMiddleware(createMockRequest('/two/2', 'en')); + callMiddleware(createMockRequest('/de/zwei/2', 'de')); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(4); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en/a' + ); + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/de/a' + ); + expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( + 'http://localhost:3000/en/b/2' ); + expect(MockedNextResponse.rewrite.mock.calls[3][0].toString()).toBe( + 'http://localhost:3000/de/b/2' + ); + }); + }); + + describe('localized pathnames with different pathnames for internal and external pathnames for the default locale', () => { + const middlewareWithPathnames = createIntlMiddleware({ + defaultLocale: 'en', + locales: ['en', 'de'], + localePrefix: 'as-needed', + pathnames: { + '/internal': '/external' + } satisfies Pathnames> }); - it('keeps route segments intact that start with the same characters as the locale', () => { - middleware(createMockRequest('/en/energy/overview/entry')); + it('redirects a request for a localized route to remove the locale prefix while keeping search params at the root', () => { + middlewareWithPathnames(createMockRequest('/en?hello', 'en')); expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/energy/overview/entry' + 'http://localhost:3000/?hello' ); }); - it('redirects requests for other locales', () => { - middleware(createMockRequest('/', 'de')); + it('redirects a request for a localized route to remove the locale prefix while keeping search params', () => { + middlewareWithPathnames(createMockRequest('/en/external?hello', 'en')); expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/de` + 'http://localhost:3000/external?hello' ); }); + }); + }); - it('redirects requests for the root if a cookie exists with a non-default locale', () => { - middleware(createMockRequest('/', 'en', 'http://localhost:3000', 'de')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + describe('localePrefix: as-needed, localeDetection: false', () => { + const middleware = createIntlMiddleware({ + defaultLocale: 'en', + locales: ['en', 'de'], + localePrefix: 'as-needed', + localeDetection: false + }); + + it('serves non-prefixed requests with the default locale and ignores the accept-language header', () => { + middleware(createMockRequest('/', 'de')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en' + ); + }); + + it('serves non-prefixed requests with the default locale and ignores an existing cookie value', () => { + middleware(createMockRequest('/', 'de', 'http://localhost:3000', 'de')); + + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en' + ); + }); + + it("doesn't set a cookie", () => { + const response = middleware( + createMockRequest('/', 'de', 'http://localhost:3000', undefined) + ); + expect(response.cookies.getAll()).toEqual([]); + }); + }); + + describe('localePrefix: always', () => { + const middleware = createIntlMiddleware({ + defaultLocale: 'en', + locales: ['en', 'de'], + localePrefix: 'always' + }); + + it('redirects non-prefixed requests for the default locale', () => { + middleware(createMockRequest('/')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en' + ); + }); + + it('redirects requests for other locales', () => { + middleware(createMockRequest('/', 'de')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de' + ); + }); + + it('redirects when a pathname starts with the locale characters', () => { + middleware(createMockRequest('/engage')); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en/engage' + ); + + middleware(createMockRequest('/engage?test')); + expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/en/engage?test' + ); + + middleware(createMockRequest('/engage/test')); + expect(MockedNextResponse.redirect.mock.calls[2][0].toString()).toBe( + 'http://localhost:3000/en/engage/test' + ); + }); + + it('serves requests for the default locale', () => { + middleware(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 non-default locales', () => { + middleware(createMockRequest('/de')); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de' + ); + }); + + describe('base path', () => { + it('redirects non-prefixed requests for the default locale', () => { + const request = createMockRequest('/'); + request.nextUrl.basePath = '/base'; + middleware(request); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/de` + 'http://localhost:3000/base/en' ); }); + }); - it('serves requests for other locales when prefixed', () => { - middleware(createMockRequest('/de')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); + describe('localized pathnames', () => { + const middlewareWithPathnames = createIntlMiddleware({ + defaultLocale: 'en', + locales: ['en', 'de'], + localePrefix: 'always', + pathnames: { + '/': '/', + '/about': { + en: '/about', + de: '/ueber' + }, + '/users': { + en: '/users', + de: '/benutzer' + }, + '/users/[userId]': { + en: '/users/[userId]', + de: '/benutzer/[userId]' + }, + '/news/[articleSlug]-[articleId]': { + en: '/news/[articleSlug]-[articleId]', + de: '/neuigkeiten/[articleSlug]-[articleId]' + }, + '/products/[...slug]': { + en: '/products/[...slug]', + de: '/produkte/[...slug]' + } + } satisfies Pathnames> + }); + + it('serves requests for the default locale at the root', () => { + middlewareWithPathnames(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/de' + 'http://localhost:3000/en' ); }); - it('serves requests for other locales when prefixed with a trailing slash', () => { - middleware(createMockRequest('/de/')); - 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/de/' + it('serves requests for the default locale at nested paths', () => { + middlewareWithPathnames(createMockRequest('/en/about', 'en')); + middlewareWithPathnames(createMockRequest('/en/users', 'en')); + middlewareWithPathnames(createMockRequest('/en/users/1', 'en')); + middlewareWithPathnames( + createMockRequest('/en/news/happy-newyear-g5b116754', 'en') ); + + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(4); + expect( + MockedNextResponse.rewrite.mock.calls.map((call) => + call[0].toString() + ) + ).toEqual([ + 'http://localhost:3000/en/about', + 'http://localhost:3000/en/users', + 'http://localhost:3000/en/users/1', + 'http://localhost:3000/en/news/happy-newyear-g5b116754' + ]); }); - it('serves requests for other locales with query params at the root', () => { - middleware(createMockRequest('/de?sort=asc')); + it('serves requests for a non-default locale at the root', () => { + middlewareWithPathnames(createMockRequest('/de', 'de')); 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/de?sort=asc' + 'http://localhost:3000/de' ); }); - it('serves requests for other locales with query params at a nested path', () => { - middleware(createMockRequest('/de/list?sort=asc')); + it('serves requests for a non-default locale at nested paths', () => { + middlewareWithPathnames(createMockRequest('/de/ueber', 'de')); + middlewareWithPathnames(createMockRequest('/de/benutzer', 'de')); + middlewareWithPathnames(createMockRequest('/de/benutzer/1', 'de')); + middlewareWithPathnames( + createMockRequest('/de/neuigkeiten/gutes-neues-jahr-g5b116754', 'de') + ); + 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/de/list?sort=asc' + 'http://localhost:3000/de/about' ); - }); - - it('sets a cookie', () => { - const response = middleware(createMockRequest('/')); - expect(response.cookies.get('NEXT_LOCALE')).toEqual({ - name: 'NEXT_LOCALE', - value: 'en' - }); - }); - - it('retains request headers for the default locale', () => { - middleware( - createMockRequest('/', 'en', 'http://localhost:3000', undefined, { - 'x-test': 'test' - }) + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/de/users' + ); + expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( + 'http://localhost:3000/de/users/1' + ); + expect(MockedNextResponse.rewrite.mock.calls[3][0].toString()).toBe( + 'http://localhost:3000/de/news/gutes-neues-jahr-g5b116754' ); - expect( - MockedNextResponse.rewrite.mock.calls[0][1]?.request?.headers?.get( - 'x-test' - ) - ).toBe('test'); }); - it('retains request headers for secondary locales', () => { - middleware( - createMockRequest('/de', 'de', 'http://localhost:3000', undefined, { - 'x-test': 'test' - }) - ); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + it('redirects a request for a localized route that is not associated with the requested locale', () => { + // Relevant to avoid duplicate content issues + middlewareWithPathnames(createMockRequest('/en/ueber', 'en')); + middlewareWithPathnames(createMockRequest('/en/benutzer/12', 'en')); expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalled(); - expect( - MockedNextResponse.rewrite.mock.calls[0][1]?.request?.headers?.get( - 'x-test' - ) - ).toBe('test'); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(2); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en/about' + ); + expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/en/users/12' + ); }); - it('returns alternate links', () => { - const response = middleware(createMockRequest('/')); - expect(response.headers.get('link')).toBe( - [ - `; rel="alternate"; hreflang="en"`, - `; rel="alternate"; hreflang="de"`, - `; rel="alternate"; hreflang="x-default"` - ].join(', ') - ); - }); - - it('always provides the locale via a request header, even if a cookie exists with the correct value (see https://github.com/amannn/next-intl/discussions/446)', () => { - middleware(createMockRequest('/', 'en', 'http://localhost:3000', 'en')); - expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + it('sets alternate links', () => { + function getLinks(request: NextRequest) { + return middlewareWithPathnames(request) + .headers.get('link') + ?.split(', '); + } + + expect(getLinks(createMockRequest('/en', 'en'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect(getLinks(createMockRequest('/de', 'de'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect(getLinks(createMockRequest('/en/about', 'en'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect(getLinks(createMockRequest('/de/ueber', 'de'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect(getLinks(createMockRequest('/en/users/1', 'en'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect(getLinks(createMockRequest('/de/benutzer/1', 'de'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); expect( - MockedNextResponse.rewrite.mock.calls[0][1]?.request?.headers?.get( - 'x-next-intl-locale' - ) - ).toBe('en'); + getLinks(createMockRequest('/en/products/apparel/t-shirts', 'en')) + ).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect( + getLinks(createMockRequest('/de/produkte/apparel/t-shirts', 'de')) + ).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); + expect(getLinks(createMockRequest('/en/unknown', 'en'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' + ]); }); + }); + }); - describe('localized pathnames', () => { - const middlewareWithPathnames = createIntlMiddleware({ - defaultLocale: 'en', - locales: ['en', 'de'], - localePrefix: 'as-needed', - pathnames: { - '/': '/', - '/about': { - en: '/about', - de: '/ueber' - }, - '/users': { - en: '/users', - de: '/benutzer' - }, - '/users/[userId]': { - en: '/users/[userId]', - de: '/benutzer/[userId]' - }, - '/news/[articleSlug]-[articleId]': { - en: '/news/[articleSlug]-[articleId]', - de: '/neuigkeiten/[articleSlug]-[articleId]' - }, - '/products/[...slug]': { - en: '/products/[...slug]', - de: '/produkte/[...slug]' - }, - '/categories/[[...slug]]': { - en: '/categories/[[...slug]]', - de: '/kategorien/[[...slug]]' - } - } satisfies Pathnames> - }); + describe('localePrefix: never', () => { + const middleware = createIntlMiddleware({ + defaultLocale: 'en', + locales: ['en', 'de'], + localePrefix: 'never' + }); - it('serves requests for the default locale at the root', () => { - middlewareWithPathnames(createMockRequest('/', 'en')); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en' - ); - }); + it('rewrites requests for the default locale', () => { + middleware(createMockRequest('/')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en' + ); + }); - it('serves requests for the default locale at nested paths', () => { - middlewareWithPathnames(createMockRequest('/about', 'en')); - middlewareWithPathnames(createMockRequest('/users', 'en')); - middlewareWithPathnames(createMockRequest('/users/1', 'en')); - middlewareWithPathnames( - createMockRequest('/news/happy-newyear-g5b116754', 'en') - ); - middlewareWithPathnames( - createMockRequest('/products/apparel/t-shirts', 'en') - ); - middlewareWithPathnames( - createMockRequest('/categories/women/t-shirts', 'en') - ); + it('rewrites requests for other locales', () => { + middleware(createMockRequest('/', 'de')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de' + ); + }); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(6); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en/about' - ); - expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( - 'http://localhost:3000/en/users' - ); - expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( - 'http://localhost:3000/en/users/1' - ); - expect(MockedNextResponse.rewrite.mock.calls[3][0].toString()).toBe( - 'http://localhost:3000/en/news/happy-newyear-g5b116754' - ); - expect(MockedNextResponse.rewrite.mock.calls[4][0].toString()).toBe( - 'http://localhost:3000/en/products/apparel/t-shirts' - ); - expect(MockedNextResponse.rewrite.mock.calls[5][0].toString()).toBe( - 'http://localhost:3000/en/categories/women/t-shirts' - ); - }); + it('rewrites requests for the default locale at a nested path', () => { + middleware(createMockRequest('/list')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en/list' + ); + }); - it('serves requests for a non-default locale at the root', () => { - middlewareWithPathnames(createMockRequest('/de', 'de')); - expect(MockedNextResponse.rewrite).toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); // We rewrite just in case - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/de' - ); - }); + it('rewrites requests for other locales at a nested path', () => { + middleware(createMockRequest('/list', 'de')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de/list' + ); + }); - it('serves requests for a non-default locale at nested paths', () => { - middlewareWithPathnames(createMockRequest('/de/ueber', 'de')); - middlewareWithPathnames(createMockRequest('/de/benutzer', 'de')); - middlewareWithPathnames(createMockRequest('/de/benutzer/1', 'de')); - middlewareWithPathnames( - createMockRequest('/de/neuigkeiten/happy-newyear-g5b116754', 'de') - ); - middlewareWithPathnames( - createMockRequest('/de/produkte/kleidung/t-shirts', 'de') - ); - middlewareWithPathnames( - createMockRequest('/de/kategorien/frauen/t-shirts', 'de') - ); + it('redirects requests with default locale in the path', () => { + middleware(createMockRequest('/en')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/' + ); + }); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(6); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/de/about' - ); - expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( - 'http://localhost:3000/de/users' - ); - expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( - 'http://localhost:3000/de/users/1' - ); - expect(MockedNextResponse.rewrite.mock.calls[3][0].toString()).toBe( - 'http://localhost:3000/de/news/happy-newyear-g5b116754' - ); - expect(MockedNextResponse.rewrite.mock.calls[4][0].toString()).toBe( - 'http://localhost:3000/de/products/kleidung/t-shirts' - ); - expect(MockedNextResponse.rewrite.mock.calls[5][0].toString()).toBe( - 'http://localhost:3000/de/categories/frauen/t-shirts' - ); - }); + it('keeps search params when removing the locale via a redirect', () => { + middleware(createMockRequest('/en?test=1')); + middleware(createMockRequest('/en/about?test=1')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/?test=1' + ); + expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/about?test=1' + ); + }); - it('redirects a request for a localized route that is not associated with the requested locale', () => { - middlewareWithPathnames(createMockRequest('/ueber', 'en')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/about' - ); - }); + it('keeps route segments intact that start with the same characters as the default locale', () => { + middleware(createMockRequest('/en/energy/overview/entry')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/energy/overview/entry' + ); + }); - it('redirects when a pathname from the default locale ends up with a different locale', () => { - // Relevant to avoid duplicate content issues - middlewareWithPathnames(createMockRequest('/de/about', 'de')); - middlewareWithPathnames(createMockRequest('/de/users/2', 'de')); - middlewareWithPathnames( - createMockRequest('/de/users/2?page=1', 'de') - ); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(3); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/de/ueber' - ); - expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( - 'http://localhost:3000/de/benutzer/2' - ); - // - expect(MockedNextResponse.redirect.mock.calls[2][0].toString()).toBe( - 'http://localhost:3000/de/benutzer/2?page=1' - ); - }); + it('keeps route segments intact that start with the same characters as a non-default locale', () => { + middleware(createMockRequest('/de/dentist/overview/delete')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/dentist/overview/delete' + ); + }); - it('redirects a non-prefixed nested path to a localized alternative if another locale was detected', () => { - middlewareWithPathnames(createMockRequest('/about', 'de')); - middlewareWithPathnames(createMockRequest('/users/2', 'de')); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(2); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/de/ueber' - ); - expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( - 'http://localhost:3000/de/benutzer/2' - ); - }); + it('redirects requests with other locales in the path', () => { + middleware(createMockRequest('/de', 'de')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/' + ); + }); - it('sets alternate links', () => { - function getLinks(request: NextRequest) { - return middlewareWithPathnames(request) - .headers.get('link') - ?.split(', '); - } + it('redirects requests with default locale in a nested path', () => { + middleware(createMockRequest('/en/list')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/list' + ); + }); - expect(getLinks(createMockRequest('/', 'en'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect(getLinks(createMockRequest('/de', 'de'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect(getLinks(createMockRequest('/about', 'en'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect(getLinks(createMockRequest('/de/ueber', 'de'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect(getLinks(createMockRequest('/users/1', 'en'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect(getLinks(createMockRequest('/de/benutzer/1', 'de'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect( - getLinks(createMockRequest('/products/apparel/t-shirts', 'en')) - ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect( - getLinks(createMockRequest('/de/produkte/apparel/t-shirts', 'de')) - ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect(getLinks(createMockRequest('/unknown', 'en'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect(getLinks(createMockRequest('/de/unknown', 'de'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - }); + it('rewrites requests for the root if a cookie exists with a non-default locale', () => { + middleware(createMockRequest('/', 'en', 'http://localhost:3000', 'de')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de' + ); + }); - it('rewrites requests when the pathname is mapped for the default locale as well', () => { - const callMiddleware = createIntlMiddleware({ - defaultLocale: 'en', - locales: ['en', 'de'], - localePrefix: 'as-needed', - pathnames: { - '/a': { - en: '/one', - de: '/eins' - }, - '/b/[param]': { - en: '/two/[param]', - de: '/zwei/[param]' - } - } - }); - callMiddleware(createMockRequest('/one', 'en')); - callMiddleware(createMockRequest('/de/eins', 'de')); - callMiddleware(createMockRequest('/two/2', 'en')); - callMiddleware(createMockRequest('/de/zwei/2', 'de')); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(4); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en/a' - ); - expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( - 'http://localhost:3000/de/a' - ); - expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( - 'http://localhost:3000/en/b/2' - ); - expect(MockedNextResponse.rewrite.mock.calls[3][0].toString()).toBe( - 'http://localhost:3000/de/b/2' - ); - }); - }); + it('rewrites requests for the root if a cookie exists with the default locale', () => { + middleware(createMockRequest('/', 'de', 'http://localhost:3000', 'en')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en' + ); + }); - describe('localized pathnames with different pathnames for internal and external pathnames for the default locale', () => { - const middlewareWithPathnames = createIntlMiddleware({ - defaultLocale: 'en', - locales: ['en', 'de'], - localePrefix: 'as-needed', - pathnames: { - '/internal': '/external' - } satisfies Pathnames> - }); + it('sets a cookie', () => { + const response = middleware(createMockRequest('/')); + expect(response.cookies.get('NEXT_LOCALE')).toEqual({ + name: 'NEXT_LOCALE', + value: 'en' + }); + }); - it('redirects a request for a localized route to remove the locale prefix while keeping search params at the root', () => { - middlewareWithPathnames(createMockRequest('/en?hello', 'en')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/?hello' - ); - }); + it('sets a cookie based on accept-language header', () => { + const response = middleware(createMockRequest('/', 'de')); + expect(response.cookies.get('NEXT_LOCALE')).toEqual({ + name: 'NEXT_LOCALE', + value: 'de' + }); + }); - it('redirects a request for a localized route to remove the locale prefix while keeping search params', () => { - middlewareWithPathnames( - createMockRequest('/en/external?hello', 'en') - ); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/external?hello' - ); - }); + it('keeps a cookie if already set', () => { + const response = middleware( + createMockRequest('/', 'en', 'http://localhost:3000', 'de') + ); + expect(response.cookies.get('NEXT_LOCALE')).toEqual({ + name: 'NEXT_LOCALE', + value: 'de' }); }); - describe('localePrefix: as-needed, localeDetection: false', () => { - const middleware = createIntlMiddleware({ - defaultLocale: 'en', - locales: ['en', 'de'], - localePrefix: 'as-needed', - localeDetection: false, - basePath + it('sets a cookie with locale in the path', () => { + const response = middleware(createMockRequest('/de')); + expect(response.cookies.get('NEXT_LOCALE')).toEqual({ + name: 'NEXT_LOCALE', + value: 'de' }); + }); - it('serves non-prefixed requests with the default locale and ignores the accept-language header', () => { - middleware(createMockRequest('/', 'de')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/en` - ); + it('updates a cookie with locale in the path', () => { + const response = middleware( + createMockRequest('/de', 'en', 'http://localhost:3000', 'en') + ); + expect(response.cookies.get('NEXT_LOCALE')).toEqual({ + name: 'NEXT_LOCALE', + value: 'de' }); + }); - it('serves non-prefixed requests with the default locale and ignores an existing cookie value', () => { - middleware(createMockRequest('/', 'de', 'http://localhost:3000', 'de')); + it('retains request headers for the default locale', () => { + middleware( + createMockRequest('/', 'en', 'http://localhost:3000', undefined, { + 'x-test': 'test' + }) + ); + expect( + MockedNextResponse.rewrite.mock.calls[0][1]?.request?.headers?.get( + 'x-test' + ) + ).toBe('test'); + }); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/en` - ); - }); + it('retains request headers for secondary locales', () => { + middleware( + createMockRequest('/', 'de', 'http://localhost:3000', undefined, { + 'x-test': 'test' + }) + ); + expect( + MockedNextResponse.rewrite.mock.calls[0][1]?.request?.headers?.get( + 'x-test' + ) + ).toBe('test'); + }); + + it('disables the alternate links', () => { + const response = middleware(createMockRequest('/')); + expect(response.headers.get('link')).toBe(null); + }); - it("doesn't set a cookie", () => { - const response = middleware( - createMockRequest('/', 'de', 'http://localhost:3000', undefined) + describe('base path', () => { + it('redirects requests with default locale in the path', () => { + const request = createMockRequest('/en'); + request.nextUrl.basePath = '/base'; + middleware(request); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/base' ); - expect(response.cookies.getAll()).toEqual([]); }); }); - describe('localePrefix: always', () => { - const middleware = createIntlMiddleware({ + describe('localized pathnames', () => { + const middlewareWithPathnames = createIntlMiddleware({ defaultLocale: 'en', locales: ['en', 'de'], - localePrefix: 'always', - basePath + localePrefix: 'never', + pathnames: { + '/': '/', + '/about': { + en: '/about', + de: '/ueber' + }, + '/users': { + en: '/users', + de: '/benutzer' + }, + '/users/[userId]': { + en: '/users/[userId]', + de: '/benutzer/[userId]' + }, + '/news/[articleSlug]-[articleId]': { + en: '/news/[articleSlug]-[articleId]', + de: '/neuigkeiten/[articleSlug]-[articleId]' + }, + '/products/[...slug]': { + en: '/products/[...slug]', + de: '/produkte/[...slug]' + } + } satisfies Pathnames> }); - it('redirects non-prefixed requests for the default locale', () => { - middleware(createMockRequest('/')); + it('serves requests for the default locale at the root', () => { + middlewareWithPathnames(createMockRequest('/', 'en')); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/en` + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en' ); }); - it('redirects requests for other locales', () => { - middleware(createMockRequest('/', 'de')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/de` + it('serves requests for the default locale at nested paths', () => { + middlewareWithPathnames(createMockRequest('/about', 'en')); + middlewareWithPathnames(createMockRequest('/users', 'en')); + middlewareWithPathnames(createMockRequest('/users/1', 'en')); + middlewareWithPathnames( + createMockRequest('/news/happy-newyear-g5b116754', 'en') ); + + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(4); + expect( + MockedNextResponse.rewrite.mock.calls.map((call) => + call[0].toString() + ) + ).toEqual([ + 'http://localhost:3000/en/about', + 'http://localhost:3000/en/users', + 'http://localhost:3000/en/users/1', + 'http://localhost:3000/en/news/happy-newyear-g5b116754' + ]); }); - it('redirects when a pathname starts with the locale characters', () => { - middleware(createMockRequest('/engage')); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + it('serves requests for a non-default locale at the root', () => { + middlewareWithPathnames(createMockRequest('/', 'de')); expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/en/engage` + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de' ); + }); - middleware(createMockRequest('/engage?test')); - expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( - `http://localhost:3000${basePath}/en/engage?test` + it('serves requests for a non-default locale at nested paths', () => { + middlewareWithPathnames(createMockRequest('/ueber', 'de')); + middlewareWithPathnames(createMockRequest('/benutzer', 'de')); + middlewareWithPathnames(createMockRequest('/benutzer/1', 'de')); + middlewareWithPathnames( + createMockRequest('/neuigkeiten/gutes-neues-jahr-g5b116754', 'de') ); - middleware(createMockRequest('/engage/test')); - expect(MockedNextResponse.redirect.mock.calls[2][0].toString()).toBe( - `http://localhost:3000${basePath}/en/engage/test` + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de/about' + ); + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/de/users' + ); + expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( + 'http://localhost:3000/de/users/1' + ); + expect(MockedNextResponse.rewrite.mock.calls[3][0].toString()).toBe( + 'http://localhost:3000/de/news/gutes-neues-jahr-g5b116754' ); }); - it('serves requests for the default locale', () => { - middleware(createMockRequest('/en')); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + it('redirects a request for a localized route that is not associated with the requested locale', () => { + // Relevant to avoid duplicate content issues + middlewareWithPathnames(createMockRequest('/en/ueber', 'en')); + middlewareWithPathnames(createMockRequest('/en/benutzer/12', 'en')); expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en' + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(2); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en/about' + ); + expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/en/users/12' ); }); - it('serves requests for non-default locales', () => { - middleware(createMockRequest('/de')); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + it('redirects a request for a localized route to remove the locale prefix while keeping search params', () => { + middlewareWithPathnames(createMockRequest('/de/ueber?hello', 'de')); expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/de' + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/ueber?hello' ); }); + }); + }); +}); - describe('localized pathnames', () => { - const middlewareWithPathnames = createIntlMiddleware({ +describe('domain-based routing', () => { + describe('localePrefix: as-needed', () => { + const middleware = createIntlMiddleware({ + defaultLocale: 'en', + locales: ['en', 'fr'], + localePrefix: 'as-needed', + domains: [ + {defaultLocale: 'en', domain: 'en.example.com', locales: ['en']}, + { defaultLocale: 'en', - locales: ['en', 'de'], - localePrefix: 'always', - pathnames: { - '/': '/', - '/about': { - en: '/about', - de: '/ueber' - }, - '/users': { - en: '/users', - de: '/benutzer' - }, - '/users/[userId]': { - en: '/users/[userId]', - de: '/benutzer/[userId]' - }, - '/news/[articleSlug]-[articleId]': { - en: '/news/[articleSlug]-[articleId]', - de: '/neuigkeiten/[articleSlug]-[articleId]' - }, - '/products/[...slug]': { - en: '/products/[...slug]', - de: '/produkte/[...slug]' - } - } satisfies Pathnames> - }); - - it('serves requests for the default locale at the root', () => { - middlewareWithPathnames(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 the default locale at nested paths', () => { - middlewareWithPathnames(createMockRequest('/en/about', 'en')); - middlewareWithPathnames(createMockRequest('/en/users', 'en')); - middlewareWithPathnames(createMockRequest('/en/users/1', 'en')); - middlewareWithPathnames( - createMockRequest('/en/news/happy-newyear-g5b116754', 'en') - ); - - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(4); - expect( - MockedNextResponse.rewrite.mock.calls.map((call) => - call[0].toString() - ) - ).toEqual([ - 'http://localhost:3000/en/about', - 'http://localhost:3000/en/users', - 'http://localhost:3000/en/users/1', - 'http://localhost:3000/en/news/happy-newyear-g5b116754' - ]); - }); + domain: 'ca.example.com', + locales: ['en', 'fr'] + }, + {defaultLocale: 'fr', domain: 'fr.example.com', locales: ['fr']} + ] + }); - it('serves requests for a non-default locale at the root', () => { - middlewareWithPathnames(createMockRequest('/de', 'de')); - 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/de' - ); - }); + it('serves requests for the default locale at the root', () => { + middleware(createMockRequest('/', 'en', 'http://en.example.com')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://en.example.com/en' + ); + }); - it('serves requests for a non-default locale at nested paths', () => { - middlewareWithPathnames(createMockRequest('/de/ueber', 'de')); - middlewareWithPathnames(createMockRequest('/de/benutzer', 'de')); - middlewareWithPathnames(createMockRequest('/de/benutzer/1', 'de')); - middlewareWithPathnames( - createMockRequest( - '/de/neuigkeiten/gutes-neues-jahr-g5b116754', - 'de' - ) - ); + it('serves requests for the default locale at sub paths', () => { + middleware(createMockRequest('/about', 'en', 'http://en.example.com')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://en.example.com/en/about' + ); + }); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/de/about' - ); - expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( - 'http://localhost:3000/de/users' - ); - expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( - 'http://localhost:3000/de/users/1' - ); - expect(MockedNextResponse.rewrite.mock.calls[3][0].toString()).toBe( - 'http://localhost:3000/de/news/gutes-neues-jahr-g5b116754' - ); - }); + it('serves requests for the default locale at unknown hosts', () => { + middleware(createMockRequest('/', 'en', 'http://localhost:3000')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en' + ); + }); - it('redirects a request for a localized route that is not associated with the requested locale', () => { - // Relevant to avoid duplicate content issues - middlewareWithPathnames(createMockRequest('/en/ueber', 'en')); - middlewareWithPathnames(createMockRequest('/en/benutzer/12', 'en')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(2); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en/about' - ); - expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( - 'http://localhost:3000/en/users/12' - ); - }); + it('serves requests for non-default locales at the locale root', () => { + middleware(createMockRequest('/fr', 'fr', 'http://ca.example.com')); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://ca.example.com/fr' + ); + }); - it('sets alternate links', () => { - function getLinks(request: NextRequest) { - return middlewareWithPathnames(request) - .headers.get('link') - ?.split(', '); - } + it('serves requests for non-default locales at the locale root when the accept-language header points to the default locale', () => { + middleware(createMockRequest('/fr', 'en', 'http://ca.example.com')); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://ca.example.com/fr' + ); + }); - expect(getLinks(createMockRequest('/en', 'en'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect(getLinks(createMockRequest('/de', 'de'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect(getLinks(createMockRequest('/en/about', 'en'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect(getLinks(createMockRequest('/de/ueber', 'de'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect(getLinks(createMockRequest('/en/users/1', 'en'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect(getLinks(createMockRequest('/de/benutzer/1', 'de'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect( - getLinks(createMockRequest('/en/products/apparel/t-shirts', 'en')) - ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect( - getLinks(createMockRequest('/de/produkte/apparel/t-shirts', 'de')) - ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - expect(getLinks(createMockRequest('/en/unknown', 'en'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"', - '; rel="alternate"; hreflang="x-default"' - ]); - }); - }); + it('serves requests for non-default locales at sub paths', () => { + middleware(createMockRequest('/fr/about', 'fr', 'http://ca.example.com')); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://ca.example.com/fr/about' + ); }); - describe('localePrefix: never', () => { - const middleware = createIntlMiddleware({ - defaultLocale: 'en', - locales: ['en', 'de'], - localePrefix: 'never', - basePath - }); + it('returns alternate links', () => { + const response = middleware(createMockRequest('/')); + expect(response.headers.get('link')).toBe( + [ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="fr"', + '; rel="alternate"; hreflang="fr"' + ].join(', ') + ); + }); - it('rewrites requests for the default locale', () => { - middleware(createMockRequest('/')); + describe('unknown hosts', () => { + it('serves requests for unknown hosts at the root', () => { + middleware(createMockRequest('/', 'en', 'http://localhost')); expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/en` + 'http://localhost/en' ); }); - it('rewrites requests for other locales', () => { - middleware(createMockRequest('/', 'de')); + it('serves requests for unknown hosts at sub paths', () => { + middleware(createMockRequest('/about', 'en', 'http://localhost')); expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/de` + 'http://localhost/en/about' ); }); - it('rewrites requests for the default locale at a nested path', () => { - middleware(createMockRequest('/list')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); + it('serves requests for unknown hosts and non-default locales at the locale root', () => { + middleware(createMockRequest('/fr', 'fr', 'http://localhost')); 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${basePath}/en/list` + 'http://localhost/fr' ); }); - it('rewrites requests for other locales at a nested path', () => { - middleware(createMockRequest('/list', 'de')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); + it('serves requests for unknown hosts and non-default locales at sub paths', () => { + middleware(createMockRequest('/fr/about', 'fr', 'http://localhost')); 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${basePath}/de/list` + 'http://localhost/fr/about' ); }); + }); - it('redirects requests with default locale in the path', () => { - middleware(createMockRequest('/en')); + describe('locales-restricted domain', () => { + it('serves requests for the default locale at the root when the accept-language header matches', () => { + middleware(createMockRequest('/', 'en', 'http://ca.example.com')); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/` + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://ca.example.com/en' ); }); - it('keeps search params when removing the locale via a redirect', () => { - middleware(createMockRequest('/en?test=1')); - middleware(createMockRequest('/en/about?test=1')); + it('serves requests for the default locale at the root when the accept-language header matches the top-level locale', () => { + middleware(createMockRequest('/', 'en-CA', 'http://ca.example.com')); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/?test=1' - ); - expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( - 'http://localhost:3000/about?test=1' + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://ca.example.com/en' ); }); - it('keeps route segments intact that start with the same characters as the default locale', () => { - middleware(createMockRequest('/en/energy/overview/entry')); + it("serves requests for the default locale at the root when the accept-language header doesn't match", () => { + middleware(createMockRequest('/', 'en', 'http://fr.example.com')); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/energy/overview/entry' + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://fr.example.com/fr' ); }); - it('keeps route segments intact that start with the same characters as a non-default locale', () => { - middleware(createMockRequest('/de/dentist/overview/delete')); + it('serves requests for the default locale at sub paths when the accept-langauge header matches', () => { + middleware(createMockRequest('/about', 'en', 'http://ca.example.com')); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/dentist/overview/delete' + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://ca.example.com/en/about' ); }); - it('redirects requests with other locales in the path', () => { - middleware(createMockRequest('/de', 'de')); + it("serves requests for the default locale at sub paths when the accept-langauge header doesn't match", () => { + middleware(createMockRequest('/about', 'en', 'http://fr.example.com')); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/` + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://fr.example.com/fr/about' ); }); - it('redirects requests with default locale in a nested path', () => { - middleware(createMockRequest('/en/list')); + it('serves requests for non-default locales at the locale root', () => { + middleware(createMockRequest('/fr', 'fr', 'http://ca.example.com')); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/list` + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://ca.example.com/fr' ); }); - it('rewrites requests for the root if a cookie exists with a non-default locale', () => { - middleware(createMockRequest('/', 'en', 'http://localhost:3000', 'de')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); + it('serves requests for non-default locales at sub paths', () => { + middleware( + createMockRequest('/fr/about', 'fr', 'http://ca.example.com') + ); 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${basePath}/de` + 'http://ca.example.com/fr/about' ); }); + }); - it('rewrites requests for the root if a cookie exists with the default locale', () => { - middleware(createMockRequest('/', 'de', 'http://localhost:3000', 'en')); + describe('redirects for locale prefixes', () => { + it('redirects for the locale root when the locale matches', () => { + middleware( + createMockRequest('/en/about', 'en', 'http://en.example.com') + ); expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/en` + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://en.example.com/about' ); }); - it('sets a cookie', () => { - const response = middleware(createMockRequest('/')); - expect(response.cookies.get('NEXT_LOCALE')).toEqual({ - name: 'NEXT_LOCALE', - value: 'en' - }); - }); - - it('sets a cookie based on accept-language header', () => { - const response = middleware(createMockRequest('/', 'de')); - expect(response.cookies.get('NEXT_LOCALE')).toEqual({ - name: 'NEXT_LOCALE', - value: 'de' - }); - }); - - it('keeps a cookie if already set', () => { - const response = middleware( - createMockRequest('/', 'en', 'http://localhost:3000', 'de') + it('redirects for sub paths when the locale matches', () => { + middleware( + createMockRequest('/en/about', 'en', 'http://en.example.com') ); - expect(response.cookies.get('NEXT_LOCALE')).toEqual({ - name: 'NEXT_LOCALE', - value: 'de' - }); - }); - - it('sets a cookie with locale in the path', () => { - const response = middleware(createMockRequest('/de')); - expect(response.cookies.get('NEXT_LOCALE')).toEqual({ - name: 'NEXT_LOCALE', - value: 'de' - }); - }); - - it('updates a cookie with locale in the path', () => { - const response = middleware( - createMockRequest('/de', 'en', 'http://localhost:3000', 'en') + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://en.example.com/about' ); - expect(response.cookies.get('NEXT_LOCALE')).toEqual({ - name: 'NEXT_LOCALE', - value: 'de' - }); }); - it('retains request headers for the default locale', () => { + it("redirects to another domain for the locale root when the locale doesn't match", () => { middleware( - createMockRequest('/', 'en', 'http://localhost:3000', undefined, { - 'x-test': 'test' - }) + createMockRequest('/fr/about', 'fr', 'http://en.example.com') + ); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://fr.example.com/about' ); - expect( - MockedNextResponse.rewrite.mock.calls[0][1]?.request?.headers?.get( - 'x-test' - ) - ).toBe('test'); }); - it('retains request headers for secondary locales', () => { + it("redirects to another domain for sub paths when the locale doesn't match", () => { middleware( - createMockRequest('/', 'de', 'http://localhost:3000', undefined, { - 'x-test': 'test' - }) + createMockRequest('/fr/about', 'fr', 'http://en.example.com') + ); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://fr.example.com/about' ); - expect( - MockedNextResponse.rewrite.mock.calls[0][1]?.request?.headers?.get( - 'x-test' - ) - ).toBe('test'); - }); - - it('disables the alternate links', () => { - const response = middleware(createMockRequest('/')); - expect(response.headers.get('link')).toBe(null); }); - describe('localized pathnames', () => { - const middlewareWithPathnames = createIntlMiddleware({ - defaultLocale: 'en', - locales: ['en', 'de'], - localePrefix: 'never', - pathnames: { - '/': '/', - '/about': { - en: '/about', - de: '/ueber' - }, - '/users': { - en: '/users', - de: '/benutzer' - }, - '/users/[userId]': { - en: '/users/[userId]', - de: '/benutzer/[userId]' - }, - '/news/[articleSlug]-[articleId]': { - en: '/news/[articleSlug]-[articleId]', - de: '/neuigkeiten/[articleSlug]-[articleId]' - }, - '/products/[...slug]': { - en: '/products/[...slug]', - de: '/produkte/[...slug]' - } - } satisfies Pathnames> - }); - - it('serves requests for the default locale at the root', () => { - middlewareWithPathnames(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 the default locale at nested paths', () => { - middlewareWithPathnames(createMockRequest('/about', 'en')); - middlewareWithPathnames(createMockRequest('/users', 'en')); - middlewareWithPathnames(createMockRequest('/users/1', 'en')); - middlewareWithPathnames( - createMockRequest('/news/happy-newyear-g5b116754', 'en') - ); - - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(4); - expect( - MockedNextResponse.rewrite.mock.calls.map((call) => - call[0].toString() - ) - ).toEqual([ - 'http://localhost:3000/en/about', - 'http://localhost:3000/en/users', - 'http://localhost:3000/en/users/1', - 'http://localhost:3000/en/news/happy-newyear-g5b116754' - ]); - }); - - it('serves requests for a non-default locale at the root', () => { - middlewareWithPathnames(createMockRequest('/', 'de')); - 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/de' - ); - }); - - it('serves requests for a non-default locale at nested paths', () => { - middlewareWithPathnames(createMockRequest('/ueber', 'de')); - middlewareWithPathnames(createMockRequest('/benutzer', 'de')); - middlewareWithPathnames(createMockRequest('/benutzer/1', 'de')); - middlewareWithPathnames( - createMockRequest('/neuigkeiten/gutes-neues-jahr-g5b116754', 'de') - ); - - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/de/about' - ); - expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( - 'http://localhost:3000/de/users' + describe('base path', () => { + it('redirects requests with default locale in the path', () => { + const request = createMockRequest( + '/en/about', + 'en', + 'http://en.example.com' ); - expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( - 'http://localhost:3000/de/users/1' - ); - expect(MockedNextResponse.rewrite.mock.calls[3][0].toString()).toBe( - 'http://localhost:3000/de/news/gutes-neues-jahr-g5b116754' - ); - }); - - it('redirects a request for a localized route that is not associated with the requested locale', () => { - // Relevant to avoid duplicate content issues - middlewareWithPathnames(createMockRequest('/en/ueber', 'en')); - middlewareWithPathnames(createMockRequest('/en/benutzer/12', 'en')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(2); + request.nextUrl.basePath = '/base'; + middleware(request); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/en/about' - ); - expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( - 'http://localhost:3000/en/users/12' + 'http://en.example.com/base/about' ); }); - it('redirects a request for a localized route to remove the locale prefix while keeping search params', () => { - middlewareWithPathnames(createMockRequest('/de/ueber?hello', 'de')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://localhost:3000/ueber?hello' - ); + it('returns alternate links', () => { + const request = createMockRequest('/', 'en', 'http://en.example.com'); + request.nextUrl.basePath = '/base'; + const response = middleware(request); + expect(response.headers.get('link')?.split(', ')).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="fr"', + '; rel="alternate"; hreflang="fr"' + ]); }); }); }); - } -); -describe.each([{basePath: undefined}, {basePath: '/sample'}])( - 'domain-based routing with basePath: $basePath', - ({basePath}: {basePath: string | undefined}) => { - describe('localePrefix: as-needed', () => { - const middleware = createIntlMiddleware({ + describe('localized pathnames', () => { + const middlewareWithPathnames = createIntlMiddleware({ defaultLocale: 'en', locales: ['en', 'fr'], localePrefix: 'as-needed', @@ -1242,702 +1544,469 @@ describe.each([{basePath: undefined}, {basePath: '/sample'}])( }, {defaultLocale: 'fr', domain: 'fr.example.com', locales: ['fr']} ], - basePath + pathnames: { + '/': '/', + '/about': { + en: '/about', + fr: '/a-propos' + }, + '/users': { + en: '/users', + fr: '/utilisateurs' + }, + '/users/[userId]': { + en: '/users/[userId]', + fr: '/utilisateurs/[userId]' + }, + '/news/[articleSlug]-[articleId]': { + en: '/news/[articleSlug]-[articleId]', + fr: '/nouvelles/[articleSlug]-[articleId]' + }, + '/products/[...slug]': { + en: '/products/[...slug]', + fr: '/produits/[...slug]' + }, + '/categories/[[...slug]]': { + en: '/categories/[[...slug]]', + fr: '/categories/[[...slug]]' + } + } satisfies Pathnames> }); it('serves requests for the default locale at the root', () => { - middleware(createMockRequest('/', 'en', 'http://en.example.com')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://en.example.com${basePath}/en` + middlewareWithPathnames( + createMockRequest('/', 'en', 'http://en.example.com') + ); + middlewareWithPathnames( + createMockRequest('/', 'en', 'http://ca.example.com') ); - }); - - it('serves requests for the default locale at sub paths', () => { - middleware(createMockRequest('/about', 'en', 'http://en.example.com')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(2); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://en.example.com${basePath}/en/about` + 'http://en.example.com/en' + ); + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://ca.example.com/en' ); }); - it('serves requests for the default locale at unknown hosts', () => { - middleware(createMockRequest('/', 'en', 'http://localhost:3000')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://localhost:3000${basePath}/en` + it('serves requests for the default locale at nested paths', () => { + middlewareWithPathnames( + createMockRequest('/about', 'en', 'http://en.example.com') + ); + middlewareWithPathnames( + createMockRequest('/users', 'en', 'http://en.example.com') + ); + middlewareWithPathnames( + createMockRequest('/users/1', 'en', 'http://en.example.com') + ); + middlewareWithPathnames( + createMockRequest( + '/news/happy-newyear-g5b116754', + 'en', + 'http://en.example.com' + ) + ); + middlewareWithPathnames( + createMockRequest( + '/products/apparel/t-shirts', + 'en', + 'http://en.example.com' + ) + ); + middlewareWithPathnames( + createMockRequest( + '/categories/women/t-shirts', + 'en', + 'http://en.example.com' + ) ); - }); - it('serves requests for non-default locales at the locale root', () => { - middleware(createMockRequest('/fr', 'fr', 'http://ca.example.com')); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(6); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://ca.example.com/fr' + 'http://en.example.com/en/about' + ); + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://en.example.com/en/users' + ); + expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( + 'http://en.example.com/en/users/1' + ); + expect(MockedNextResponse.rewrite.mock.calls[3][0].toString()).toBe( + 'http://en.example.com/en/news/happy-newyear-g5b116754' + ); + expect(MockedNextResponse.rewrite.mock.calls[4][0].toString()).toBe( + 'http://en.example.com/en/products/apparel/t-shirts' + ); + expect(MockedNextResponse.rewrite.mock.calls[5][0].toString()).toBe( + 'http://en.example.com/en/categories/women/t-shirts' ); }); - it('serves requests for non-default locales at the locale root when the accept-language header points to the default locale', () => { - middleware(createMockRequest('/fr', 'en', 'http://ca.example.com')); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); + it('serves requests for a non-default locale at the root', () => { + middlewareWithPathnames( + createMockRequest('/fr', 'fr', 'http://ca.example.com') + ); expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); // We rewrite just in case + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( 'http://ca.example.com/fr' ); }); - it('serves requests for non-default locales at sub paths', () => { - middleware( - createMockRequest('/fr/about', 'fr', 'http://ca.example.com') + it('serves requests for a non-default locale at nested paths', () => { + middlewareWithPathnames( + createMockRequest('/fr/a-propos', 'fr', 'http://ca.example.com') ); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + middlewareWithPathnames( + createMockRequest('/fr/utilisateurs', 'fr', 'http://ca.example.com') + ); + middlewareWithPathnames( + createMockRequest('/fr/utilisateurs/1', 'fr', 'http://ca.example.com') + ); + middlewareWithPathnames( + createMockRequest( + '/fr/nouvelles/happy-newyear-g5b116754', + 'fr', + 'http://ca.example.com' + ) + ); + middlewareWithPathnames( + createMockRequest( + '/fr/produits/vetements/t-shirts', + 'fr', + 'http://ca.example.com' + ) + ); + middlewareWithPathnames( + createMockRequest( + '/fr/categories/femmes/t-shirts', + 'fr', + 'http://ca.example.com' + ) + ); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(6); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( 'http://ca.example.com/fr/about' ); + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://ca.example.com/fr/users' + ); + expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( + 'http://ca.example.com/fr/users/1' + ); + expect(MockedNextResponse.rewrite.mock.calls[3][0].toString()).toBe( + 'http://ca.example.com/fr/news/happy-newyear-g5b116754' + ); + expect(MockedNextResponse.rewrite.mock.calls[4][0].toString()).toBe( + 'http://ca.example.com/fr/products/vetements/t-shirts' + ); + expect(MockedNextResponse.rewrite.mock.calls[5][0].toString()).toBe( + 'http://ca.example.com/fr/categories/femmes/t-shirts' + ); }); - it('returns alternate links', () => { - const response = middleware(createMockRequest('/')); - expect(response.headers.get('link')).toBe( - [ - `; rel="alternate"; hreflang="en"`, - `; rel="alternate"; hreflang="en"`, - `; rel="alternate"; hreflang="fr"`, - `; rel="alternate"; hreflang="fr"` - ].join(', ') - ); - }); - - describe('unknown hosts', () => { - it('serves requests for unknown hosts at the root', () => { - middleware(createMockRequest('/', 'en', 'http://localhost')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://localhost${basePath}/en` - ); - }); - - it('serves requests for unknown hosts at sub paths', () => { - middleware(createMockRequest('/about', 'en', 'http://localhost')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://localhost${basePath}/en/about` - ); - }); - - it('serves requests for unknown hosts and non-default locales at the locale root', () => { - middleware(createMockRequest('/fr', 'fr', 'http://localhost')); - 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/fr' - ); - }); - - it('serves requests for unknown hosts and non-default locales at sub paths', () => { - middleware(createMockRequest('/fr/about', 'fr', 'http://localhost')); - 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/fr/about' - ); - }); + it('redirects a request for a localized route that is not associated with the requested locale', () => { + middlewareWithPathnames( + createMockRequest('/a-propos', 'en', 'http://en.example.com') + ); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://en.example.com/about' + ); }); - describe('locales-restricted domain', () => { - it('serves requests for the default locale at the root when the accept-language header matches', () => { - middleware(createMockRequest('/', 'en', 'http://ca.example.com')); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://ca.example.com${basePath}/en` - ); - }); - - it('serves requests for the default locale at the root when the accept-language header matches the top-level locale', () => { - middleware(createMockRequest('/', 'en-CA', 'http://ca.example.com')); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://ca.example.com${basePath}/en` - ); - }); - - it("serves requests for the default locale at the root when the accept-language header doesn't match", () => { - middleware(createMockRequest('/', 'en', 'http://fr.example.com')); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://fr.example.com${basePath}/fr` - ); - }); - - it('serves requests for the default locale at sub paths when the accept-langauge header matches', () => { - middleware( - createMockRequest('/about', 'en', 'http://ca.example.com') - ); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://ca.example.com${basePath}/en/about` - ); - }); - - it("serves requests for the default locale at sub paths when the accept-langauge header doesn't match", () => { - middleware( - createMockRequest('/about', 'en', 'http://fr.example.com') - ); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - `http://fr.example.com${basePath}/fr/about` - ); - }); - - it('serves requests for non-default locales at the locale root', () => { - middleware(createMockRequest('/fr', 'fr', 'http://ca.example.com')); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://ca.example.com/fr' - ); - }); - - it('serves requests for non-default locales at sub paths', () => { - middleware( - createMockRequest('/fr/about', 'fr', 'http://ca.example.com') - ); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://ca.example.com/fr/about' - ); - }); + it('redirects when a pathname from the default locale ends up with a different locale that is the default locale on the domain', () => { + // Relevant to avoid duplicate content issues + middlewareWithPathnames( + createMockRequest('/about', 'fr', 'http://fr.example.com') + ); + middlewareWithPathnames( + createMockRequest('/users/2', 'fr', 'http://fr.example.com') + ); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(2); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://fr.example.com/a-propos' + ); + expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( + 'http://fr.example.com/utilisateurs/2' + ); }); - describe('redirects for locale prefixes', () => { - it('redirects for the locale root when the locale matches', () => { - middleware( - createMockRequest('/en/about', 'en', 'http://en.example.com') - ); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://en.example.com${basePath}/about` - ); - }); - - it('redirects for sub paths when the locale matches', () => { - middleware( - createMockRequest('/en/about', 'en', 'http://en.example.com') - ); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://en.example.com${basePath}/about` - ); - }); - - it("redirects to another domain for the locale root when the locale doesn't match", () => { - middleware( - createMockRequest('/fr/about', 'fr', 'http://en.example.com') - ); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://fr.example.com${basePath}/about` - ); - }); - - it("redirects to another domain for sub paths when the locale doesn't match", () => { - middleware( - createMockRequest('/fr/about', 'fr', 'http://en.example.com') - ); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://fr.example.com${basePath}/about` - ); - }); + it('redirects when a pathname from the default locale ends up with a different locale that is a secondary locale on the domain', () => { + // Relevant to avoid duplicate content issues + middlewareWithPathnames( + createMockRequest('/about', 'fr', 'http://ca.example.com') + ); + middlewareWithPathnames( + createMockRequest('/users/2', 'fr', 'http://ca.example.com') + ); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(2); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://ca.example.com/fr/a-propos' + ); + expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( + 'http://ca.example.com/fr/utilisateurs/2' + ); }); - describe('localized pathnames', () => { - const middlewareWithPathnames = createIntlMiddleware({ - defaultLocale: 'en', - locales: ['en', 'fr'], - localePrefix: 'as-needed', - domains: [ - {defaultLocale: 'en', domain: 'en.example.com', locales: ['en']}, - { - defaultLocale: 'en', - domain: 'ca.example.com', - locales: ['en', 'fr'] - }, - {defaultLocale: 'fr', domain: 'fr.example.com', locales: ['fr']} - ], - pathnames: { - '/': '/', - '/about': { - en: '/about', - fr: '/a-propos' - }, - '/users': { - en: '/users', - fr: '/utilisateurs' - }, - '/users/[userId]': { - en: '/users/[userId]', - fr: '/utilisateurs/[userId]' - }, - '/news/[articleSlug]-[articleId]': { - en: '/news/[articleSlug]-[articleId]', - fr: '/nouvelles/[articleSlug]-[articleId]' - }, - '/products/[...slug]': { - en: '/products/[...slug]', - fr: '/produits/[...slug]' - }, - '/categories/[[...slug]]': { - en: '/categories/[[...slug]]', - fr: '/categories/[[...slug]]' - } - } satisfies Pathnames> - }); + it('redirects a non-prefixed nested path to a localized alternative if another locale was detected', () => { + middlewareWithPathnames( + createMockRequest('/about', 'fr', 'http://ca.example.com') + ); + middlewareWithPathnames( + createMockRequest('/users/2', 'fr', 'http://ca.example.com') + ); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(2); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://ca.example.com/fr/a-propos' + ); + expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( + 'http://ca.example.com/fr/utilisateurs/2' + ); + }); - it('serves requests for the default locale at the root', () => { - middlewareWithPathnames( - createMockRequest('/', 'en', 'http://en.example.com') - ); - middlewareWithPathnames( - createMockRequest('/', 'en', 'http://ca.example.com') - ); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(2); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://en.example.com/en' - ); - expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( - 'http://ca.example.com/en' - ); - }); + it('sets alternate links', () => { + function getLinks(request: NextRequest) { + return middlewareWithPathnames(request) + .headers.get('link') + ?.split(', '); + } - it('serves requests for the default locale at nested paths', () => { - middlewareWithPathnames( - createMockRequest('/about', 'en', 'http://en.example.com') - ); - middlewareWithPathnames( - createMockRequest('/users', 'en', 'http://en.example.com') - ); - middlewareWithPathnames( - createMockRequest('/users/1', 'en', 'http://en.example.com') - ); - middlewareWithPathnames( - createMockRequest( - '/news/happy-newyear-g5b116754', - 'en', - 'http://en.example.com' - ) - ); - middlewareWithPathnames( + expect( + getLinks(createMockRequest('/', 'en', 'http://en.example.com')) + ).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="fr"', + '; rel="alternate"; hreflang="fr"' + ]); + expect( + getLinks(createMockRequest('/fr', 'fr', 'http://ca.example.com')) + ).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="fr"', + '; rel="alternate"; hreflang="fr"' + ]); + expect( + getLinks(createMockRequest('/about', 'en', 'http://en.example.com')) + ).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="fr"', + '; rel="alternate"; hreflang="fr"' + ]); + expect( + getLinks( + createMockRequest('/a-propos', 'fr', 'http://ca.example.com') + ) + ).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="fr"', + '; rel="alternate"; hreflang="fr"' + ]); + expect( + getLinks(createMockRequest('/users/1', 'en', 'http://en.example.com')) + ).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="fr"', + '; rel="alternate"; hreflang="fr"' + ]); + expect( + getLinks( + createMockRequest('/utilisateurs/1', 'fr', 'http://fr.example.com') + ) + ).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="fr"', + '; rel="alternate"; hreflang="fr"' + ]); + expect( + getLinks( createMockRequest( '/products/apparel/t-shirts', 'en', 'http://en.example.com' ) - ); - middlewareWithPathnames( - createMockRequest( - '/categories/women/t-shirts', - 'en', - 'http://en.example.com' - ) - ); - - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(6); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://en.example.com/en/about' - ); - expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( - 'http://en.example.com/en/users' - ); - expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( - 'http://en.example.com/en/users/1' - ); - expect(MockedNextResponse.rewrite.mock.calls[3][0].toString()).toBe( - 'http://en.example.com/en/news/happy-newyear-g5b116754' - ); - expect(MockedNextResponse.rewrite.mock.calls[4][0].toString()).toBe( - 'http://en.example.com/en/products/apparel/t-shirts' - ); - expect(MockedNextResponse.rewrite.mock.calls[5][0].toString()).toBe( - 'http://en.example.com/en/categories/women/t-shirts' - ); - }); - - it('serves requests for a non-default locale at the root', () => { - middlewareWithPathnames( - createMockRequest('/fr', 'fr', 'http://ca.example.com') - ); - expect(MockedNextResponse.rewrite).toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); // We rewrite just in case - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://ca.example.com/fr' - ); - }); - - it('serves requests for a non-default locale at nested paths', () => { - middlewareWithPathnames( - createMockRequest('/fr/a-propos', 'fr', 'http://ca.example.com') - ); - middlewareWithPathnames( - createMockRequest('/fr/utilisateurs', 'fr', 'http://ca.example.com') - ); - middlewareWithPathnames( - createMockRequest( - '/fr/utilisateurs/1', - 'fr', - 'http://ca.example.com' - ) - ); - middlewareWithPathnames( - createMockRequest( - '/fr/nouvelles/happy-newyear-g5b116754', - 'fr', - 'http://ca.example.com' - ) - ); - middlewareWithPathnames( - createMockRequest( - '/fr/produits/vetements/t-shirts', - 'fr', - 'http://ca.example.com' - ) - ); - middlewareWithPathnames( + ) + ).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="fr"', + '; rel="alternate"; hreflang="fr"' + ]); + expect( + getLinks( createMockRequest( - '/fr/categories/femmes/t-shirts', + '/fr/produits/apparel/t-shirts', 'fr', - 'http://ca.example.com' + 'http://fr.example.com' ) - ); - - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(6); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://ca.example.com/fr/about' - ); - expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( - 'http://ca.example.com/fr/users' - ); - expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( - 'http://ca.example.com/fr/users/1' - ); - expect(MockedNextResponse.rewrite.mock.calls[3][0].toString()).toBe( - 'http://ca.example.com/fr/news/happy-newyear-g5b116754' - ); - expect(MockedNextResponse.rewrite.mock.calls[4][0].toString()).toBe( - 'http://ca.example.com/fr/products/vetements/t-shirts' - ); - expect(MockedNextResponse.rewrite.mock.calls[5][0].toString()).toBe( - 'http://ca.example.com/fr/categories/femmes/t-shirts' - ); - }); - - it('redirects a request for a localized route that is not associated with the requested locale', () => { - middlewareWithPathnames( - createMockRequest('/a-propos', 'en', 'http://en.example.com') - ); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://en.example.com/about' - ); - }); - - it('redirects when a pathname from the default locale ends up with a different locale that is the default locale on the domain', () => { - // Relevant to avoid duplicate content issues - middlewareWithPathnames( - createMockRequest('/about', 'fr', 'http://fr.example.com') - ); - middlewareWithPathnames( - createMockRequest('/users/2', 'fr', 'http://fr.example.com') - ); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(2); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://fr.example.com/a-propos' - ); - expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( - 'http://fr.example.com/utilisateurs/2' - ); - }); - - it('redirects when a pathname from the default locale ends up with a different locale that is a secondary locale on the domain', () => { - // Relevant to avoid duplicate content issues - middlewareWithPathnames( - createMockRequest('/about', 'fr', 'http://ca.example.com') - ); - middlewareWithPathnames( - createMockRequest('/users/2', 'fr', 'http://ca.example.com') - ); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(2); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://ca.example.com/fr/a-propos' - ); - expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( - 'http://ca.example.com/fr/utilisateurs/2' - ); - }); - - it('redirects a non-prefixed nested path to a localized alternative if another locale was detected', () => { - middlewareWithPathnames( - createMockRequest('/about', 'fr', 'http://ca.example.com') - ); - middlewareWithPathnames( - createMockRequest('/users/2', 'fr', 'http://ca.example.com') - ); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(2); + ) + ).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="fr"', + '; rel="alternate"; hreflang="fr"' + ]); + expect( + getLinks(createMockRequest('/unknown', 'en', 'http://en.example.com')) + ).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="fr"', + '; rel="alternate"; hreflang="fr"' + ]); + expect( + getLinks( + createMockRequest('/fr/unknown', 'fr', 'http://ca.example.com') + ) + ).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="fr"', + '; rel="alternate"; hreflang="fr"' + ]); + }); + + describe('base path', () => { + it('redirects requests with default locale in the path', () => { + const request = createMockRequest( + '/en/about', + 'en', + 'http://en.example.com' + ); + request.nextUrl.basePath = '/base'; + middlewareWithPathnames(request); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'http://ca.example.com/fr/a-propos' - ); - expect(MockedNextResponse.redirect.mock.calls[1][0].toString()).toBe( - 'http://ca.example.com/fr/utilisateurs/2' + 'http://en.example.com/base/about' ); }); - it('sets alternate links', () => { - function getLinks(request: NextRequest) { - return middlewareWithPathnames(request) - .headers.get('link') - ?.split(', '); - } - - expect( - getLinks(createMockRequest('/', 'en', 'http://en.example.com')) - ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' - ]); - expect( - getLinks(createMockRequest('/fr', 'fr', 'http://ca.example.com')) - ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' - ]); - expect( - getLinks(createMockRequest('/about', 'en', 'http://en.example.com')) - ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' - ]); - expect( - getLinks( - createMockRequest('/a-propos', 'fr', 'http://ca.example.com') - ) - ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' - ]); - expect( - getLinks( - createMockRequest('/users/1', 'en', 'http://en.example.com') - ) - ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' - ]); - expect( - getLinks( - createMockRequest( - '/utilisateurs/1', - 'fr', - 'http://fr.example.com' - ) - ) - ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' - ]); - expect( - getLinks( - createMockRequest( - '/products/apparel/t-shirts', - 'en', - 'http://en.example.com' - ) - ) - ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' - ]); - expect( - getLinks( - createMockRequest( - '/fr/produits/apparel/t-shirts', - 'fr', - 'http://fr.example.com' - ) - ) - ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' - ]); - expect( - getLinks( - createMockRequest('/unknown', 'en', 'http://en.example.com') - ) - ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' - ]); - expect( - getLinks( - createMockRequest('/fr/unknown', 'fr', 'http://ca.example.com') - ) - ).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="fr"', - '; rel="alternate"; hreflang="fr"' + it('returns alternate links', () => { + const request = createMockRequest('/', 'en', 'http://en.example.com'); + request.nextUrl.basePath = '/base'; + const response = middlewareWithPathnames(request); + expect(response.headers.get('link')?.split(', ')).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="fr"', + '; rel="alternate"; hreflang="fr"' ]); }); }); }); + }); - describe("localePrefix: 'always'", () => { - const middleware = createIntlMiddleware({ - defaultLocale: 'en', - locales: ['en', 'fr'], - localePrefix: 'always', - domains: [ - { - defaultLocale: 'en', - domain: 'example.com', - locales: ['en'] - }, - { - defaultLocale: 'en', - domain: 'ca.example.com', - locales: ['en', 'fr'] - } - ], - basePath - }); + describe("localePrefix: 'always'", () => { + const middleware = createIntlMiddleware({ + defaultLocale: 'en', + locales: ['en', 'fr'], + localePrefix: 'always', + domains: [ + { + defaultLocale: 'en', + domain: 'example.com', + locales: ['en'] + }, + { + defaultLocale: 'en', + domain: 'ca.example.com', + locales: ['en', 'fr'] + } + ] + }); - it('redirects non-prefixed requests for the default locale', () => { - middleware(createMockRequest('/', 'en', 'http://example.com')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://example.com${basePath}/en` - ); - }); + it('redirects non-prefixed requests for the default locale', () => { + middleware(createMockRequest('/', 'en', 'http://example.com')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://example.com/en' + ); + }); - it('uses the correct port and protocol when being called from an internal address', () => { - middleware( - createMockRequest('/', 'fr', 'http://192.168.0.1:3000', undefined, { - 'x-forwarded-host': 'ca.example.com', - 'x-forwarded-proto': 'https' - }) - ); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - 'https://ca.example.com/fr' - ); - }); + it('uses the correct port and protocol when being called from an internal address', () => { + middleware( + createMockRequest('/', 'fr', 'http://192.168.0.1:3000', undefined, { + 'x-forwarded-host': 'ca.example.com', + 'x-forwarded-proto': 'https' + }) + ); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'https://ca.example.com/fr' + ); + }); - it('redirects requests for other locales', () => { - middleware(createMockRequest('/', 'fr', 'http://ca.example.com')); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( - `http://ca.example.com${basePath}/fr` - ); - }); + it('redirects requests for other locales', () => { + middleware(createMockRequest('/', 'fr', 'http://ca.example.com')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://ca.example.com/fr' + ); + }); - it('serves requests for the default locale', () => { - middleware(createMockRequest('/en', 'en', 'http://ca.example.com')); - middleware( - createMockRequest('/en/about', 'en', 'http://ca.example.com') - ); + it('serves requests for the default locale', () => { + middleware(createMockRequest('/en', 'en', 'http://ca.example.com')); + middleware(createMockRequest('/en/about', 'en', 'http://ca.example.com')); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(2); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://ca.example.com/en' - ); - expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( - 'http://ca.example.com/en/about' - ); - }); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(2); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://ca.example.com/en' + ); + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://ca.example.com/en/about' + ); + }); - it('serves requests for non-default locales', () => { - middleware(createMockRequest('/fr', 'fr', 'http://ca.example.com')); - middleware( - createMockRequest('/fr/about', 'fr', 'http://ca.example.com') - ); + it('serves requests for non-default locales', () => { + middleware(createMockRequest('/fr', 'fr', 'http://ca.example.com')); + middleware(createMockRequest('/fr/about', 'fr', 'http://ca.example.com')); - expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); - expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(2); - expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( - 'http://ca.example.com/fr' - ); - expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( - 'http://ca.example.com/fr/about' + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(2); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://ca.example.com/fr' + ); + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://ca.example.com/fr/about' + ); + }); + + describe('base path', () => { + it('redirects non-prefixed requests for the default locale', () => { + const request = createMockRequest('/', 'en', 'http://example.com'); + request.nextUrl.basePath = '/base'; + middleware(request); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://example.com/base/en' ); }); }); - } -); + }); +}); From 69e3bc64a89f6ca3452bcdd45972d77dea95efb7 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 6 Dec 2023 13:52:32 +0100 Subject: [PATCH 4/6] Fixed --- packages/next-intl/package.json | 2 +- .../getAlternateLinksHeaderValue.tsx | 20 ++--- .../next-intl/src/middleware/middleware.tsx | 22 ++++-- packages/next-intl/src/middleware/utils.tsx | 22 ++++-- .../getAlternateLinksHeaderValue.test.tsx | 17 +++- .../test/middleware/middleware.test.tsx | 78 ++++++++++++------- 6 files changed, 101 insertions(+), 60 deletions(-) diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index c533af04c..781e9f210 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -134,7 +134,7 @@ }, { "path": "dist/production/middleware.js", - "limit": "5.72 KB" + "limit": "5.8 KB" } ] } diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 6261adfb3..c5448497e 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -2,6 +2,7 @@ import {NextRequest} from 'next/server'; import {AllLocales, Pathnames} from '../shared/types'; import {MiddlewareConfigWithDefaults} from './NextIntlMiddlewareConfig'; import { + applyBasePath, formatTemplatePathname, getHost, getNormalizedPathname, @@ -34,15 +35,6 @@ export default function getAlternateLinksHeaderValue< normalizedUrl.protocol = request.headers.get('x-forwarded-proto') ?? normalizedUrl.protocol; - // Remove the base path and apply it later again to avoid - // confusing it with an actual pathname - if (request.nextUrl.basePath) { - normalizedUrl.pathname = normalizedUrl.pathname.replace( - new RegExp(`^${request.nextUrl.basePath}`), - '' - ); - } - normalizedUrl.pathname = getNormalizedPathname( normalizedUrl.pathname, config.locales @@ -51,10 +43,7 @@ export default function getAlternateLinksHeaderValue< function getAlternateEntry(url: URL, locale: string) { if (request.nextUrl.basePath) { url = new URL(url); - url.pathname = `${request.nextUrl.basePath}${url.pathname}`; - if (url.pathname.endsWith('/')) { - url.pathname = url.pathname.slice(0, -1); - } + url.pathname = applyBasePath(url.pathname, request.nextUrl.basePath); } return `<${url.toString()}>; rel="alternate"; hreflang="${locale}"`; @@ -93,7 +82,10 @@ export default function getAlternateLinksHeaderValue< url = new URL(normalizedUrl); url.port = ''; url.host = domainConfig.domain; - url.pathname = getLocalizedPathname(url.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 || diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 9740ed97a..7f311565f 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -14,7 +14,8 @@ import { getKnownLocaleFromPathname, getNormalizedPathname, getPathWithSearch, - isLocaleSupportedOnDomain + isLocaleSupportedOnDomain, + applyBasePath } from './utils'; const ROOT_URL = '/'; @@ -66,7 +67,16 @@ export default function createMiddleware( } function rewrite(url: string) { - return NextResponse.rewrite(new URL(url, request.url), getResponseInit()); + const urlObj = new URL(url, request.url); + + if (request.nextUrl.basePath) { + urlObj.pathname = applyBasePath( + urlObj.pathname, + request.nextUrl.basePath + ); + } + + return NextResponse.rewrite(urlObj, getResponseInit()); } function redirect(url: string, redirectDomain?: string) { @@ -105,10 +115,10 @@ export default function createMiddleware( } if (request.nextUrl.basePath) { - urlObj.pathname = request.nextUrl.basePath + urlObj.pathname; - if (urlObj.pathname.endsWith('/')) { - urlObj.pathname = urlObj.pathname.slice(0, -1); - } + urlObj.pathname = applyBasePath( + urlObj.pathname, + request.nextUrl.basePath + ); } return NextResponse.redirect(urlObj.toString()); diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 2f3f72e5d..4fe51820c 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -51,11 +51,9 @@ export function formatTemplatePathname( if (localePrefix) { targetPathname = `/${localePrefix}`; } - targetPathname += formatPathname(targetTemplate, params); - if (targetPathname.endsWith('/')) { - targetPathname = targetPathname.slice(0, -1); - } + targetPathname += formatPathname(targetTemplate, params); + targetPathname = normalizeTrailingSlash(targetPathname); return targetPathname; } @@ -76,9 +74,8 @@ export function getNormalizedPathname( const match = pathname.match(`^/(${locales.join('|')})/(.*)`); let result = match ? '/' + match[2] : pathname; - // Remove trailing slash - if (result.endsWith('/') && result !== '/') { - result = result.slice(0, -1); + if (result !== '/') { + result = normalizeTrailingSlash(result); } return result; @@ -188,3 +185,14 @@ export function getBestMatchingDomain( return domainConfig; } + +export function applyBasePath(pathname: string, basePath: string) { + return normalizeTrailingSlash(basePath + pathname); +} + +function normalizeTrailingSlash(pathname: string) { + if (pathname.endsWith('/')) { + pathname = pathname.slice(0, -1); + } + return pathname; +} diff --git a/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx index cf7823c87..eff077a33 100644 --- a/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx +++ b/packages/next-intl/test/middleware/getAlternateLinksHeaderValue.test.tsx @@ -10,13 +10,22 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( 'basePath: $basePath', ({basePath = ''}: {basePath?: string}) => { function getMockRequest( - ...args: ConstructorParameters + href: string, + init?: ConstructorParameters[1] ) { - const request = new NextRequest(...args); + const url = new URL(href); if (basePath) { - request.nextUrl.basePath = basePath; + url.pathname = basePath + url.pathname; + if (url.pathname.endsWith('/')) { + url.pathname = url.pathname.slice(0, -1); + } } - return request; + + return new NextRequest(url, { + ...init, + headers: init?.headers, + nextConfig: {basePath: basePath || undefined} + }); } it('works for prefixed routing (as-needed)', () => { diff --git a/packages/next-intl/test/middleware/middleware.test.tsx b/packages/next-intl/test/middleware/middleware.test.tsx index bad7e3dd9..f2b86e7c0 100644 --- a/packages/next-intl/test/middleware/middleware.test.tsx +++ b/packages/next-intl/test/middleware/middleware.test.tsx @@ -58,6 +58,27 @@ const MockedNextResponse = NextResponse as unknown as { redirect: Mock>; }; +function withBasePath(request: NextRequest, basePath = '/base') { + const url = new URL(request.url); + + url.pathname = basePath + url.pathname; + if (url.pathname.endsWith('/')) { + url.pathname = url.pathname.slice(0, -1); + } + + if (url.pathname.endsWith('/')) { + url.pathname = url.pathname.slice(0, -1); + } + + const adapted = new NextRequest(url.toString(), { + ...request, + headers: request.headers, + nextConfig: {basePath} + }); + + return adapted; +} + beforeEach(() => { vi.clearAllMocks(); }); @@ -305,9 +326,16 @@ describe('prefix-based routing', () => { }); describe('base path', () => { + it('rewrites correctly for the default locale at the root', () => { + const request = withBasePath(createMockRequest('/')); + middleware(request); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/base/en' + ); + }); + it('redirects correctly when removing the default locale at the root', () => { - const request = createMockRequest('/en'); - request.nextUrl.basePath = '/base'; + const request = withBasePath(createMockRequest('/en')); middleware(request); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( 'http://localhost:3000/base' @@ -315,8 +343,7 @@ describe('prefix-based routing', () => { }); it('redirects correctly when removing the default locale at sub paths', () => { - const request = createMockRequest('/en/about'); - request.nextUrl.basePath = '/base'; + const request = withBasePath(createMockRequest('/en/about')); middleware(request); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( 'http://localhost:3000/base/about' @@ -324,17 +351,17 @@ describe('prefix-based routing', () => { }); it('redirects correctly when adding a prefix for a non-default locale', () => { - const request = createMockRequest('/', 'de'); - request.nextUrl.basePath = '/base'; + const request = withBasePath(createMockRequest('/', 'de')); middleware(request); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( 'http://localhost:3000/base/de' ); }); it('returns alternate links', () => { - const request = createMockRequest('/'); - request.nextUrl.basePath = '/base'; + const request = withBasePath(createMockRequest('/')); const response = middleware(request); expect(response.headers.get('link')?.split(', ')).toEqual([ '; rel="alternate"; hreflang="en"', @@ -749,8 +776,7 @@ describe('prefix-based routing', () => { describe('base path', () => { it('redirects non-prefixed requests for the default locale', () => { - const request = createMockRequest('/'); - request.nextUrl.basePath = '/base'; + const request = withBasePath(createMockRequest('/')); middleware(request); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( 'http://localhost:3000/base/en' @@ -1126,8 +1152,7 @@ describe('prefix-based routing', () => { describe('base path', () => { it('redirects requests with default locale in the path', () => { - const request = createMockRequest('/en'); - request.nextUrl.basePath = '/base'; + const request = withBasePath(createMockRequest('/en')); middleware(request); expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); @@ -1504,12 +1529,9 @@ describe('domain-based routing', () => { describe('base path', () => { it('redirects requests with default locale in the path', () => { - const request = createMockRequest( - '/en/about', - 'en', - 'http://en.example.com' + const request = withBasePath( + createMockRequest('/en/about', 'en', 'http://en.example.com') ); - request.nextUrl.basePath = '/base'; middleware(request); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( 'http://en.example.com/base/about' @@ -1517,8 +1539,9 @@ describe('domain-based routing', () => { }); it('returns alternate links', () => { - const request = createMockRequest('/', 'en', 'http://en.example.com'); - request.nextUrl.basePath = '/base'; + const request = withBasePath( + createMockRequest('/', 'en', 'http://en.example.com') + ); const response = middleware(request); expect(response.headers.get('link')?.split(', ')).toEqual([ '; rel="alternate"; hreflang="en"', @@ -1890,12 +1913,9 @@ describe('domain-based routing', () => { describe('base path', () => { it('redirects requests with default locale in the path', () => { - const request = createMockRequest( - '/en/about', - 'en', - 'http://en.example.com' + const request = withBasePath( + createMockRequest('/en/about', 'en', 'http://en.example.com') ); - request.nextUrl.basePath = '/base'; middlewareWithPathnames(request); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( 'http://en.example.com/base/about' @@ -1903,8 +1923,9 @@ describe('domain-based routing', () => { }); it('returns alternate links', () => { - const request = createMockRequest('/', 'en', 'http://en.example.com'); - request.nextUrl.basePath = '/base'; + const request = withBasePath( + createMockRequest('/', 'en', 'http://en.example.com') + ); const response = middlewareWithPathnames(request); expect(response.headers.get('link')?.split(', ')).toEqual([ '; rel="alternate"; hreflang="en"', @@ -2000,8 +2021,9 @@ describe('domain-based routing', () => { describe('base path', () => { it('redirects non-prefixed requests for the default locale', () => { - const request = createMockRequest('/', 'en', 'http://example.com'); - request.nextUrl.basePath = '/base'; + const request = withBasePath( + createMockRequest('/', 'en', 'http://example.com') + ); middleware(request); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( 'http://example.com/base/en' From 5a0c90d013f2171cb348de9aa5b4e1999259309c Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 6 Dec 2023 14:11:56 +0100 Subject: [PATCH 5/6] Docs --- docs/pages/docs/routing/middleware.mdx | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/pages/docs/routing/middleware.mdx b/docs/pages/docs/routing/middleware.mdx index 6d537023b..f9afacbdc 100644 --- a/docs/pages/docs/routing/middleware.mdx +++ b/docs/pages/docs/routing/middleware.mdx @@ -8,11 +8,6 @@ The middleware handles redirects and rewrites based on the detected user locale. import createMiddleware from 'next-intl/middleware'; export default createMiddleware({ - // Use `basepath` to deploy to a sub-path like `/blog`. - // Typically `basePath` is set to the same value in - // `next.config.js` options - basePath: '/blog', - // A list of all locales that are supported locales: ['en', 'de'], @@ -338,6 +333,26 @@ export const config = { 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. +### Base path + +The `next-intl` middleware as well as [the navigation APIs](/docs/routing/navigation) will automatically pick up a [`basePath`](https://nextjs.org/docs/app/api-reference/next-config-js/basePath) that you might have configured in your `next.config.js`. + +Note however that you should make sure that your [middleware `matcher`](#matcher-config) matches the root of your base path: + +```tsx filename="middleware.ts" +// ... + +export const config = { + matcher: [ + '/' // Make sure the root of your base path is matched + + // ... other matcher config + ] +}; +``` + +See also [`vercel/next.js#47085`](https://github.com/vercel/next.js/issues/47085). + ## Composing other middlewares By calling `createMiddleware`, you'll receive a function of the following type: From 9da9d82845202073875bcb1cc89373d43a901bd4 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 6 Dec 2023 14:22:10 +0100 Subject: [PATCH 6/6] Cleanup --- .../next-intl/src/middleware/middleware.tsx | 2 +- .../test/middleware/middleware.test.tsx | 44 +++++++------------ 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 7f311565f..e52533b59 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -257,7 +257,7 @@ export default function createMiddleware( if (hasOutdatedCookie) { response.cookies.set(COOKIE_LOCALE_NAME, locale, { - path: request.nextUrl.basePath || '/', + path: request.nextUrl.basePath || undefined, sameSite: 'strict', maxAge: 31536000 // 1 year }); diff --git a/packages/next-intl/test/middleware/middleware.test.tsx b/packages/next-intl/test/middleware/middleware.test.tsx index f2b86e7c0..b47af3867 100644 --- a/packages/next-intl/test/middleware/middleware.test.tsx +++ b/packages/next-intl/test/middleware/middleware.test.tsx @@ -66,10 +66,6 @@ function withBasePath(request: NextRequest, basePath = '/base') { url.pathname = url.pathname.slice(0, -1); } - if (url.pathname.endsWith('/')) { - url.pathname = url.pathname.slice(0, -1); - } - const adapted = new NextRequest(url.toString(), { ...request, headers: request.headers, @@ -327,42 +323,35 @@ describe('prefix-based routing', () => { describe('base path', () => { it('rewrites correctly for the default locale at the root', () => { - const request = withBasePath(createMockRequest('/')); - middleware(request); + middleware(withBasePath(createMockRequest('/'))); expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( 'http://localhost:3000/base/en' ); }); it('redirects correctly when removing the default locale at the root', () => { - const request = withBasePath(createMockRequest('/en')); - middleware(request); + middleware(withBasePath(createMockRequest('/en'))); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( 'http://localhost:3000/base' ); }); it('redirects correctly when removing the default locale at sub paths', () => { - const request = withBasePath(createMockRequest('/en/about')); - middleware(request); + middleware(withBasePath(createMockRequest('/en/about'))); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( 'http://localhost:3000/base/about' ); }); it('redirects correctly when adding a prefix for a non-default locale', () => { - const request = withBasePath(createMockRequest('/', 'de')); - middleware(request); - expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); - expect(MockedNextResponse.next).not.toHaveBeenCalled(); + middleware(withBasePath(createMockRequest('/', 'de'))); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( 'http://localhost:3000/base/de' ); }); it('returns alternate links', () => { - const request = withBasePath(createMockRequest('/')); - const response = middleware(request); + const response = middleware(withBasePath(createMockRequest('/'))); expect(response.headers.get('link')?.split(', ')).toEqual([ '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="de"', @@ -776,8 +765,7 @@ describe('prefix-based routing', () => { describe('base path', () => { it('redirects non-prefixed requests for the default locale', () => { - const request = withBasePath(createMockRequest('/')); - middleware(request); + middleware(withBasePath(createMockRequest('/'))); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( 'http://localhost:3000/base/en' ); @@ -1152,8 +1140,7 @@ describe('prefix-based routing', () => { describe('base path', () => { it('redirects requests with default locale in the path', () => { - const request = withBasePath(createMockRequest('/en')); - middleware(request); + middleware(withBasePath(createMockRequest('/en'))); expect(MockedNextResponse.next).not.toHaveBeenCalled(); expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( @@ -1529,20 +1516,20 @@ describe('domain-based routing', () => { describe('base path', () => { it('redirects requests with default locale in the path', () => { - const request = withBasePath( - createMockRequest('/en/about', 'en', 'http://en.example.com') + middleware( + withBasePath( + createMockRequest('/en/about', 'en', 'http://en.example.com') + ) ); - middleware(request); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( 'http://en.example.com/base/about' ); }); it('returns alternate links', () => { - const request = withBasePath( - createMockRequest('/', 'en', 'http://en.example.com') + const response = middleware( + withBasePath(createMockRequest('/', 'en', 'http://en.example.com')) ); - const response = middleware(request); expect(response.headers.get('link')?.split(', ')).toEqual([ '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="en"', @@ -2021,10 +2008,9 @@ describe('domain-based routing', () => { describe('base path', () => { it('redirects non-prefixed requests for the default locale', () => { - const request = withBasePath( - createMockRequest('/', 'en', 'http://example.com') + middleware( + withBasePath(createMockRequest('/', 'en', 'http://example.com')) ); - middleware(request); expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( 'http://example.com/base/en' );