diff --git a/examples/example-app-router-playground/tests/main.spec.ts b/examples/example-app-router-playground/tests/main.spec.ts index bcf8edfea..6185b0bd0 100644 --- a/examples/example-app-router-playground/tests/main.spec.ts +++ b/examples/example-app-router-playground/tests/main.spec.ts @@ -57,6 +57,50 @@ it('redirects to a matched locale at the root for non-default locales', async ({ page.getByRole('heading', {name: 'Start'}); }); +it('redirects to a matched locale for an invalid cased non-default locale', async ({ + browser + }) => { + const context = await browser.newContext({locale: 'de'}); + const page = await context.newPage(); + + await page.goto('/DE'); + await expect(page).toHaveURL('/de'); + page.getByRole('heading', {name: 'Start'}); +}); + +it('redirects to a matched locale for an invalid cased non-default locale in a nested path', async ({ + browser + }) => { + const context = await browser.newContext({locale: 'de'}); + const page = await context.newPage(); + + await page.goto('/DE/verschachtelt'); + await expect(page).toHaveURL('/de/verschachtelt'); + page.getByRole('heading', {name: 'Verschachtelt'}); +}); + +it('redirects to a matched locale for an invalid cased default locale', async ({ + browser + }) => { + const context = await browser.newContext({locale: 'en'}); + const page = await context.newPage(); + + await page.goto('/EN'); + await expect(page).toHaveURL('/'); + page.getByRole('heading', {name: 'Home'}); +}); + +it('redirects to a matched locale for an invalid cased default locale in a nested path', async ({ + browser + }) => { + const context = await browser.newContext({locale: 'en'}); + const page = await context.newPage(); + + await page.goto('/EN/nested'); + await expect(page).toHaveURL('/nested'); + page.getByRole('heading', {name: 'Nested'}); +}); + it('redirects a prefixed pathname for the default locale to the unprefixed version', async ({ request }) => { diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index a2276e8d2..6d20685e5 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -138,7 +138,7 @@ }, { "path": "dist/production/middleware.js", - "limit": "5.81 KB" + "limit": "5.855 KB" } ] } diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 99efc529f..d6308bd42 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -16,7 +16,7 @@ import { getInternalTemplate, formatTemplatePathname, getBestMatchingDomain, - getKnownLocaleFromPathname, + getPathnameLocale, getNormalizedPathname, getPathWithSearch, isLocaleSupportedOnDomain, @@ -134,7 +134,7 @@ export default function createMiddleware( configWithDefaults.locales ); - const pathLocale = getKnownLocaleFromPathname( + const pathLocale = getPathnameLocale( request.nextUrl.pathname, configWithDefaults.locales ); diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index 04a12edc4..56bac61d2 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -8,7 +8,8 @@ import { MiddlewareConfigWithDefaults } from './NextIntlMiddlewareConfig'; import { - getLocaleFromPathname, + findCaseInsensitiveLocale, + getFirstPathnameSegment, getHost, isLocaleSupportedOnDomain } from './utils'; @@ -68,9 +69,13 @@ function resolveLocaleFromPrefix( // Prio 1: Use route prefix if (pathname) { - const pathLocale = getLocaleFromPathname(pathname); - if (locales.includes(pathLocale)) { - locale = pathLocale; + const pathLocaleCandidate = getFirstPathnameSegment(pathname); + const matchedLocale = findCaseInsensitiveLocale( + pathLocaleCandidate, + locales + ); + if (matchedLocale) { + locale = matchedLocale; } } diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 4fe51820c..ea0ed5aca 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -5,7 +5,7 @@ import { MiddlewareConfigWithDefaults } from './NextIntlMiddlewareConfig'; -export function getLocaleFromPathname(pathname: string) { +export function getFirstPathnameSegment(pathname: string) { return pathname.split('/')[1]; } @@ -71,7 +71,9 @@ export function getNormalizedPathname( pathname += '/'; } - const match = pathname.match(`^/(${locales.join('|')})/(.*)`); + const match = pathname.match( + new RegExp(`^/(${locales.join('|')})/(.*)`, 'i') + ); let result = match ? '/' + match[2] : pathname; if (result !== '/') { @@ -81,12 +83,21 @@ export function getNormalizedPathname( return result; } -export function getKnownLocaleFromPathname( +export function findCaseInsensitiveLocale( + candidate: string, + locales: Locales +) { + return locales.find( + (locale) => locale.toLowerCase() === candidate.toLowerCase() + ); +} + +export function getPathnameLocale( pathname: string, locales: Locales ): Locales[number] | undefined { - const pathLocaleCandidate = getLocaleFromPathname(pathname); - const pathLocale = locales.includes(pathLocaleCandidate) + const pathLocaleCandidate = getFirstPathnameSegment(pathname); + const pathLocale = findCaseInsensitiveLocale(pathLocaleCandidate, locales) ? pathLocaleCandidate : undefined; return pathLocale; diff --git a/packages/next-intl/test/middleware/middleware.test.tsx b/packages/next-intl/test/middleware/middleware.test.tsx index dfc93397e..7f5331f1f 100644 --- a/packages/next-intl/test/middleware/middleware.test.tsx +++ b/packages/next-intl/test/middleware/middleware.test.tsx @@ -363,35 +363,41 @@ describe('prefix-based routing', () => { describe('localized pathnames', () => { const middlewareWithPathnames = createIntlMiddleware({ defaultLocale: 'en', - locales: ['en', 'de'], + locales: ['en', 'de', 'de-AT'], localePrefix: 'as-needed', pathnames: { '/': '/', '/about': { en: '/about', - de: '/ueber' + de: '/ueber', + 'de-AT': '/ueber' }, '/users': { en: '/users', - de: '/benutzer' + de: '/benutzer', + 'de-AT': '/benutzer' }, '/users/[userId]': { en: '/users/[userId]', - de: '/benutzer/[userId]' + de: '/benutzer/[userId]', + 'de-AT': '/benutzer/[userId]' }, '/news/[articleSlug]-[articleId]': { en: '/news/[articleSlug]-[articleId]', - de: '/neuigkeiten/[articleSlug]-[articleId]' + de: '/neuigkeiten/[articleSlug]-[articleId]', + 'de-AT': '/neuigkeiten/[articleSlug]-[articleId]' }, '/products/[...slug]': { en: '/products/[...slug]', - de: '/produkte/[...slug]' + de: '/produkte/[...slug]', + 'de-AT': '/produkte/[...slug]' }, '/categories/[[...slug]]': { en: '/categories/[[...slug]]', - de: '/kategorien/[[...slug]]' + de: '/kategorien/[[...slug]]', + 'de-AT': '/kategorien/[[...slug]]' } - } satisfies Pathnames> + } satisfies Pathnames> }); it('serves requests for the default locale at the root', () => { @@ -531,6 +537,66 @@ describe('prefix-based routing', () => { ); }); + it('redirects uppercase locale requests to case-sensitive defaults at the root', () => { + middlewareWithPathnames(createMockRequest('/EN', 'de')); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en/' + ); + }); + + it('redirects uppercase locale requests to case-sensitive defaults for nested paths', () => { + middlewareWithPathnames(createMockRequest('/EN/about', 'de')); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en/about' + ); + }); + + it('redirects uppercase locale requests for non-default locales at the root', () => { + middlewareWithPathnames(createMockRequest('/DE-AT', 'de-AT')); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de-AT/' + ); + }); + + it('redirects uppercase locale requests for non-default locales and nested paths', () => { + middlewareWithPathnames(createMockRequest('/DE-AT/ueber', 'de-AT')); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de-AT/ueber' + ); + }); + + it('redirects lowercase locale requests for non-default locales to case-sensitive format at the root', () => { + middlewareWithPathnames(createMockRequest('/de-at', 'de-AT')); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de-AT/' + ); + }); + + it('redirects lowercase locale requests for non-default locales to case-sensitive format for nested paths', () => { + middlewareWithPathnames(createMockRequest('/de-at/ueber', 'de-AT')); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de-AT/ueber' + ); + }); + it('sets alternate links', () => { function getLinks(request: NextRequest) { return middlewareWithPathnames(request) @@ -541,31 +607,37 @@ describe('prefix-based routing', () => { expect(getLinks(createMockRequest('/', 'en'))).toEqual([ '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="de-AT"', '; rel="alternate"; hreflang="x-default"' ]); expect(getLinks(createMockRequest('/de', 'de'))).toEqual([ '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="de-AT"', '; rel="alternate"; hreflang="x-default"' ]); expect(getLinks(createMockRequest('/about', 'en'))).toEqual([ '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="de-AT"', '; rel="alternate"; hreflang="x-default"' ]); expect(getLinks(createMockRequest('/de/ueber', 'de'))).toEqual([ '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="de-AT"', '; rel="alternate"; hreflang="x-default"' ]); expect(getLinks(createMockRequest('/users/1', 'en'))).toEqual([ '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="de-AT"', '; rel="alternate"; hreflang="x-default"' ]); expect(getLinks(createMockRequest('/de/benutzer/1', 'de'))).toEqual([ '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="de-AT"', '; rel="alternate"; hreflang="x-default"' ]); expect( @@ -573,6 +645,7 @@ describe('prefix-based routing', () => { ).toEqual([ '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="de-AT"', '; rel="alternate"; hreflang="x-default"' ]); expect( @@ -580,16 +653,19 @@ describe('prefix-based routing', () => { ).toEqual([ '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="de-AT"', '; rel="alternate"; hreflang="x-default"' ]); expect(getLinks(createMockRequest('/unknown', 'en'))).toEqual([ '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="de-AT"', '; rel="alternate"; hreflang="x-default"' ]); expect(getLinks(createMockRequest('/de/unknown', 'de'))).toEqual([ '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="de-AT"', '; rel="alternate"; hreflang="x-default"' ]); }); @@ -940,7 +1016,7 @@ describe('prefix-based routing', () => { describe('localePrefix: never', () => { const middleware = createIntlMiddleware({ defaultLocale: 'en', - locales: ['en', 'de'], + locales: ['en', 'de', 'de-AT'], localePrefix: 'never' }); @@ -1038,6 +1114,36 @@ describe('prefix-based routing', () => { ); }); + it('redirects requests with uppercase default locale in a nested path', () => { + middleware(createMockRequest('/EN/list')); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/list' + ); + }); + + it('redirects requests with uppercase non-default locale in a nested path', () => { + middleware(createMockRequest('/DE-AT/list')); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/list' + ); + }); + + it('redirects requests with lowercase non-default locale in a nested path', () => { + middleware(createMockRequest('/de-at/list')); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/list' + ); + }); + 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();