Skip to content

Commit 3b2b446

Browse files
fkapsahiliamannn
andauthored
feat: Add redirects for case mismatches in locale prefixes (e.g. /EN/en) (#861)
Closes #775 --------- Co-authored-by: Jan Amann <jan@amann.me>
1 parent 5cc264d commit 3b2b446

File tree

6 files changed

+187
-21
lines changed

6 files changed

+187
-21
lines changed

examples/example-app-router-playground/tests/main.spec.ts

+44
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,50 @@ it('redirects to a matched locale at the root for non-default locales', async ({
5757
page.getByRole('heading', {name: 'Start'});
5858
});
5959

60+
it('redirects to a matched locale for an invalid cased non-default locale', async ({
61+
browser
62+
}) => {
63+
const context = await browser.newContext({locale: 'de'});
64+
const page = await context.newPage();
65+
66+
await page.goto('/DE');
67+
await expect(page).toHaveURL('/de');
68+
page.getByRole('heading', {name: 'Start'});
69+
});
70+
71+
it('redirects to a matched locale for an invalid cased non-default locale in a nested path', async ({
72+
browser
73+
}) => {
74+
const context = await browser.newContext({locale: 'de'});
75+
const page = await context.newPage();
76+
77+
await page.goto('/DE/verschachtelt');
78+
await expect(page).toHaveURL('/de/verschachtelt');
79+
page.getByRole('heading', {name: 'Verschachtelt'});
80+
});
81+
82+
it('redirects to a matched locale for an invalid cased default locale', async ({
83+
browser
84+
}) => {
85+
const context = await browser.newContext({locale: 'en'});
86+
const page = await context.newPage();
87+
88+
await page.goto('/EN');
89+
await expect(page).toHaveURL('/');
90+
page.getByRole('heading', {name: 'Home'});
91+
});
92+
93+
it('redirects to a matched locale for an invalid cased default locale in a nested path', async ({
94+
browser
95+
}) => {
96+
const context = await browser.newContext({locale: 'en'});
97+
const page = await context.newPage();
98+
99+
await page.goto('/EN/nested');
100+
await expect(page).toHaveURL('/nested');
101+
page.getByRole('heading', {name: 'Nested'});
102+
});
103+
60104
it('redirects a prefixed pathname for the default locale to the unprefixed version', async ({
61105
request
62106
}) => {

packages/next-intl/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@
138138
},
139139
{
140140
"path": "dist/production/middleware.js",
141-
"limit": "5.81 KB"
141+
"limit": "5.855 KB"
142142
}
143143
]
144144
}

packages/next-intl/src/middleware/middleware.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
getInternalTemplate,
1717
formatTemplatePathname,
1818
getBestMatchingDomain,
19-
getKnownLocaleFromPathname,
19+
getPathnameLocale,
2020
getNormalizedPathname,
2121
getPathWithSearch,
2222
isLocaleSupportedOnDomain,
@@ -134,7 +134,7 @@ export default function createMiddleware<Locales extends AllLocales>(
134134
configWithDefaults.locales
135135
);
136136

137-
const pathLocale = getKnownLocaleFromPathname(
137+
const pathLocale = getPathnameLocale(
138138
request.nextUrl.pathname,
139139
configWithDefaults.locales
140140
);

packages/next-intl/src/middleware/resolveLocale.tsx

+9-4
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
MiddlewareConfigWithDefaults
99
} from './NextIntlMiddlewareConfig';
1010
import {
11-
getLocaleFromPathname,
11+
findCaseInsensitiveLocale,
12+
getFirstPathnameSegment,
1213
getHost,
1314
isLocaleSupportedOnDomain
1415
} from './utils';
@@ -68,9 +69,13 @@ function resolveLocaleFromPrefix<Locales extends AllLocales>(
6869

6970
// Prio 1: Use route prefix
7071
if (pathname) {
71-
const pathLocale = getLocaleFromPathname(pathname);
72-
if (locales.includes(pathLocale)) {
73-
locale = pathLocale;
72+
const pathLocaleCandidate = getFirstPathnameSegment(pathname);
73+
const matchedLocale = findCaseInsensitiveLocale(
74+
pathLocaleCandidate,
75+
locales
76+
);
77+
if (matchedLocale) {
78+
locale = matchedLocale;
7479
}
7580
}
7681

packages/next-intl/src/middleware/utils.tsx

+16-5
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
MiddlewareConfigWithDefaults
66
} from './NextIntlMiddlewareConfig';
77

8-
export function getLocaleFromPathname(pathname: string) {
8+
export function getFirstPathnameSegment(pathname: string) {
99
return pathname.split('/')[1];
1010
}
1111

@@ -71,7 +71,9 @@ export function getNormalizedPathname<Locales extends AllLocales>(
7171
pathname += '/';
7272
}
7373

74-
const match = pathname.match(`^/(${locales.join('|')})/(.*)`);
74+
const match = pathname.match(
75+
new RegExp(`^/(${locales.join('|')})/(.*)`, 'i')
76+
);
7577
let result = match ? '/' + match[2] : pathname;
7678

7779
if (result !== '/') {
@@ -81,12 +83,21 @@ export function getNormalizedPathname<Locales extends AllLocales>(
8183
return result;
8284
}
8385

84-
export function getKnownLocaleFromPathname<Locales extends AllLocales>(
86+
export function findCaseInsensitiveLocale<Locales extends AllLocales>(
87+
candidate: string,
88+
locales: Locales
89+
) {
90+
return locales.find(
91+
(locale) => locale.toLowerCase() === candidate.toLowerCase()
92+
);
93+
}
94+
95+
export function getPathnameLocale<Locales extends AllLocales>(
8596
pathname: string,
8697
locales: Locales
8798
): Locales[number] | undefined {
88-
const pathLocaleCandidate = getLocaleFromPathname(pathname);
89-
const pathLocale = locales.includes(pathLocaleCandidate)
99+
const pathLocaleCandidate = getFirstPathnameSegment(pathname);
100+
const pathLocale = findCaseInsensitiveLocale(pathLocaleCandidate, locales)
90101
? pathLocaleCandidate
91102
: undefined;
92103
return pathLocale;

packages/next-intl/test/middleware/middleware.test.tsx

+115-9
Original file line numberDiff line numberDiff line change
@@ -363,35 +363,41 @@ describe('prefix-based routing', () => {
363363
describe('localized pathnames', () => {
364364
const middlewareWithPathnames = createIntlMiddleware({
365365
defaultLocale: 'en',
366-
locales: ['en', 'de'],
366+
locales: ['en', 'de', 'de-AT'],
367367
localePrefix: 'as-needed',
368368
pathnames: {
369369
'/': '/',
370370
'/about': {
371371
en: '/about',
372-
de: '/ueber'
372+
de: '/ueber',
373+
'de-AT': '/ueber'
373374
},
374375
'/users': {
375376
en: '/users',
376-
de: '/benutzer'
377+
de: '/benutzer',
378+
'de-AT': '/benutzer'
377379
},
378380
'/users/[userId]': {
379381
en: '/users/[userId]',
380-
de: '/benutzer/[userId]'
382+
de: '/benutzer/[userId]',
383+
'de-AT': '/benutzer/[userId]'
381384
},
382385
'/news/[articleSlug]-[articleId]': {
383386
en: '/news/[articleSlug]-[articleId]',
384-
de: '/neuigkeiten/[articleSlug]-[articleId]'
387+
de: '/neuigkeiten/[articleSlug]-[articleId]',
388+
'de-AT': '/neuigkeiten/[articleSlug]-[articleId]'
385389
},
386390
'/products/[...slug]': {
387391
en: '/products/[...slug]',
388-
de: '/produkte/[...slug]'
392+
de: '/produkte/[...slug]',
393+
'de-AT': '/produkte/[...slug]'
389394
},
390395
'/categories/[[...slug]]': {
391396
en: '/categories/[[...slug]]',
392-
de: '/kategorien/[[...slug]]'
397+
de: '/kategorien/[[...slug]]',
398+
'de-AT': '/kategorien/[[...slug]]'
393399
}
394-
} satisfies Pathnames<ReadonlyArray<'en' | 'de'>>
400+
} satisfies Pathnames<ReadonlyArray<'en' | 'de' | 'de-AT'>>
395401
});
396402

397403
it('serves requests for the default locale at the root', () => {
@@ -531,6 +537,66 @@ describe('prefix-based routing', () => {
531537
);
532538
});
533539

540+
it('redirects uppercase locale requests to case-sensitive defaults at the root', () => {
541+
middlewareWithPathnames(createMockRequest('/EN', 'de'));
542+
expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
543+
expect(MockedNextResponse.next).not.toHaveBeenCalled();
544+
expect(MockedNextResponse.redirect).toHaveBeenCalled();
545+
expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
546+
'http://localhost:3000/en/'
547+
);
548+
});
549+
550+
it('redirects uppercase locale requests to case-sensitive defaults for nested paths', () => {
551+
middlewareWithPathnames(createMockRequest('/EN/about', 'de'));
552+
expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
553+
expect(MockedNextResponse.next).not.toHaveBeenCalled();
554+
expect(MockedNextResponse.redirect).toHaveBeenCalled();
555+
expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
556+
'http://localhost:3000/en/about'
557+
);
558+
});
559+
560+
it('redirects uppercase locale requests for non-default locales at the root', () => {
561+
middlewareWithPathnames(createMockRequest('/DE-AT', 'de-AT'));
562+
expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
563+
expect(MockedNextResponse.next).not.toHaveBeenCalled();
564+
expect(MockedNextResponse.redirect).toHaveBeenCalled();
565+
expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
566+
'http://localhost:3000/de-AT/'
567+
);
568+
});
569+
570+
it('redirects uppercase locale requests for non-default locales and nested paths', () => {
571+
middlewareWithPathnames(createMockRequest('/DE-AT/ueber', 'de-AT'));
572+
expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
573+
expect(MockedNextResponse.next).not.toHaveBeenCalled();
574+
expect(MockedNextResponse.redirect).toHaveBeenCalled();
575+
expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
576+
'http://localhost:3000/de-AT/ueber'
577+
);
578+
});
579+
580+
it('redirects lowercase locale requests for non-default locales to case-sensitive format at the root', () => {
581+
middlewareWithPathnames(createMockRequest('/de-at', 'de-AT'));
582+
expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
583+
expect(MockedNextResponse.next).not.toHaveBeenCalled();
584+
expect(MockedNextResponse.redirect).toHaveBeenCalled();
585+
expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
586+
'http://localhost:3000/de-AT/'
587+
);
588+
});
589+
590+
it('redirects lowercase locale requests for non-default locales to case-sensitive format for nested paths', () => {
591+
middlewareWithPathnames(createMockRequest('/de-at/ueber', 'de-AT'));
592+
expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
593+
expect(MockedNextResponse.next).not.toHaveBeenCalled();
594+
expect(MockedNextResponse.redirect).toHaveBeenCalled();
595+
expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
596+
'http://localhost:3000/de-AT/ueber'
597+
);
598+
});
599+
534600
it('sets alternate links', () => {
535601
function getLinks(request: NextRequest) {
536602
return middlewareWithPathnames(request)
@@ -541,55 +607,65 @@ describe('prefix-based routing', () => {
541607
expect(getLinks(createMockRequest('/', 'en'))).toEqual([
542608
'<http://localhost:3000/>; rel="alternate"; hreflang="en"',
543609
'<http://localhost:3000/de>; rel="alternate"; hreflang="de"',
610+
'<http://localhost:3000/de-AT>; rel="alternate"; hreflang="de-AT"',
544611
'<http://localhost:3000/>; rel="alternate"; hreflang="x-default"'
545612
]);
546613
expect(getLinks(createMockRequest('/de', 'de'))).toEqual([
547614
'<http://localhost:3000/>; rel="alternate"; hreflang="en"',
548615
'<http://localhost:3000/de>; rel="alternate"; hreflang="de"',
616+
'<http://localhost:3000/de-AT>; rel="alternate"; hreflang="de-AT"',
549617
'<http://localhost:3000/>; rel="alternate"; hreflang="x-default"'
550618
]);
551619
expect(getLinks(createMockRequest('/about', 'en'))).toEqual([
552620
'<http://localhost:3000/about>; rel="alternate"; hreflang="en"',
553621
'<http://localhost:3000/de/ueber>; rel="alternate"; hreflang="de"',
622+
'<http://localhost:3000/de-AT/ueber>; rel="alternate"; hreflang="de-AT"',
554623
'<http://localhost:3000/about>; rel="alternate"; hreflang="x-default"'
555624
]);
556625
expect(getLinks(createMockRequest('/de/ueber', 'de'))).toEqual([
557626
'<http://localhost:3000/about>; rel="alternate"; hreflang="en"',
558627
'<http://localhost:3000/de/ueber>; rel="alternate"; hreflang="de"',
628+
'<http://localhost:3000/de-AT/ueber>; rel="alternate"; hreflang="de-AT"',
559629
'<http://localhost:3000/about>; rel="alternate"; hreflang="x-default"'
560630
]);
561631
expect(getLinks(createMockRequest('/users/1', 'en'))).toEqual([
562632
'<http://localhost:3000/users/1>; rel="alternate"; hreflang="en"',
563633
'<http://localhost:3000/de/benutzer/1>; rel="alternate"; hreflang="de"',
634+
'<http://localhost:3000/de-AT/benutzer/1>; rel="alternate"; hreflang="de-AT"',
564635
'<http://localhost:3000/users/1>; rel="alternate"; hreflang="x-default"'
565636
]);
566637
expect(getLinks(createMockRequest('/de/benutzer/1', 'de'))).toEqual([
567638
'<http://localhost:3000/users/1>; rel="alternate"; hreflang="en"',
568639
'<http://localhost:3000/de/benutzer/1>; rel="alternate"; hreflang="de"',
640+
'<http://localhost:3000/de-AT/benutzer/1>; rel="alternate"; hreflang="de-AT"',
569641
'<http://localhost:3000/users/1>; rel="alternate"; hreflang="x-default"'
570642
]);
571643
expect(
572644
getLinks(createMockRequest('/products/apparel/t-shirts', 'en'))
573645
).toEqual([
574646
'<http://localhost:3000/products/apparel/t-shirts>; rel="alternate"; hreflang="en"',
575647
'<http://localhost:3000/de/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de"',
648+
'<http://localhost:3000/de-AT/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de-AT"',
576649
'<http://localhost:3000/products/apparel/t-shirts>; rel="alternate"; hreflang="x-default"'
577650
]);
578651
expect(
579652
getLinks(createMockRequest('/de/produkte/apparel/t-shirts', 'de'))
580653
).toEqual([
581654
'<http://localhost:3000/products/apparel/t-shirts>; rel="alternate"; hreflang="en"',
582655
'<http://localhost:3000/de/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de"',
656+
'<http://localhost:3000/de-AT/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de-AT"',
583657
'<http://localhost:3000/products/apparel/t-shirts>; rel="alternate"; hreflang="x-default"'
584658
]);
585659
expect(getLinks(createMockRequest('/unknown', 'en'))).toEqual([
586660
'<http://localhost:3000/unknown>; rel="alternate"; hreflang="en"',
587661
'<http://localhost:3000/de/unknown>; rel="alternate"; hreflang="de"',
662+
'<http://localhost:3000/de-AT/unknown>; rel="alternate"; hreflang="de-AT"',
588663
'<http://localhost:3000/unknown>; rel="alternate"; hreflang="x-default"'
589664
]);
590665
expect(getLinks(createMockRequest('/de/unknown', 'de'))).toEqual([
591666
'<http://localhost:3000/unknown>; rel="alternate"; hreflang="en"',
592667
'<http://localhost:3000/de/unknown>; rel="alternate"; hreflang="de"',
668+
'<http://localhost:3000/de-AT/unknown>; rel="alternate"; hreflang="de-AT"',
593669
'<http://localhost:3000/unknown>; rel="alternate"; hreflang="x-default"'
594670
]);
595671
});
@@ -940,7 +1016,7 @@ describe('prefix-based routing', () => {
9401016
describe('localePrefix: never', () => {
9411017
const middleware = createIntlMiddleware({
9421018
defaultLocale: 'en',
943-
locales: ['en', 'de'],
1019+
locales: ['en', 'de', 'de-AT'],
9441020
localePrefix: 'never'
9451021
});
9461022

@@ -1038,6 +1114,36 @@ describe('prefix-based routing', () => {
10381114
);
10391115
});
10401116

1117+
it('redirects requests with uppercase default locale in a nested path', () => {
1118+
middleware(createMockRequest('/EN/list'));
1119+
expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
1120+
expect(MockedNextResponse.next).not.toHaveBeenCalled();
1121+
expect(MockedNextResponse.redirect).toHaveBeenCalled();
1122+
expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
1123+
'http://localhost:3000/list'
1124+
);
1125+
});
1126+
1127+
it('redirects requests with uppercase non-default locale in a nested path', () => {
1128+
middleware(createMockRequest('/DE-AT/list'));
1129+
expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
1130+
expect(MockedNextResponse.next).not.toHaveBeenCalled();
1131+
expect(MockedNextResponse.redirect).toHaveBeenCalled();
1132+
expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
1133+
'http://localhost:3000/list'
1134+
);
1135+
});
1136+
1137+
it('redirects requests with lowercase non-default locale in a nested path', () => {
1138+
middleware(createMockRequest('/de-at/list'));
1139+
expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
1140+
expect(MockedNextResponse.next).not.toHaveBeenCalled();
1141+
expect(MockedNextResponse.redirect).toHaveBeenCalled();
1142+
expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
1143+
'http://localhost:3000/list'
1144+
);
1145+
});
1146+
10411147
it('rewrites requests for the root if a cookie exists with a non-default locale', () => {
10421148
middleware(createMockRequest('/', 'en', 'http://localhost:3000', 'de'));
10431149
expect(MockedNextResponse.next).not.toHaveBeenCalled();

0 commit comments

Comments
 (0)