Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add redirects for case mismatches in locale prefixes (e.g. /EN β†’ /en) #861

Merged
merged 9 commits into from
Feb 20, 2024
44 changes: 44 additions & 0 deletions examples/example-app-router-playground/tests/main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/next-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
},
{
"path": "dist/production/middleware.js",
"limit": "5.81 KB"
"limit": "5.84 KB"
}
]
}
8 changes: 6 additions & 2 deletions packages/next-intl/src/middleware/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ export function getNormalizedPathname<Locales extends AllLocales>(
pathname += '/';
}

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

if (result !== '/') {
Expand All @@ -86,7 +88,9 @@ export function getKnownLocaleFromPathname<Locales extends AllLocales>(
locales: Locales
): Locales[number] | undefined {
const pathLocaleCandidate = getLocaleFromPathname(pathname);
const pathLocale = locales.includes(pathLocaleCandidate)
const pathLocale = locales.find(
(locale) => locale.toLowerCase() === pathLocaleCandidate.toLowerCase()
)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The value returned from this function is assigned to a variable here:

const pathLocale = getKnownLocaleFromPathname(

… and since it doesn't equal the detected locale at

} else if (pathLocale === locale) {

… a redirect is invoked:

response = redirect(`/${locale}${normalizedPathnameWithSearch}`);

That is correct.

However, the locale that is returned from resolveLocale here:

const {domain, locale} = resolveLocale(

… doesn't pick up the prefix since it used to be case senstive.

Since we redirect based on locale (and not pathLocale), I noticed that that request like createMockRequest('/EN', 'de') in the test would redirect to /de.

I've added a small fix in f05395b that uses the locale from the pathname instead in this caseβ€”hope this is ok for you!

? pathLocaleCandidate
: undefined;
return pathLocale;
Expand Down
124 changes: 115 additions & 9 deletions packages/next-intl/test/middleware/middleware.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadonlyArray<'en' | 'de'>>
} satisfies Pathnames<ReadonlyArray<'en' | 'de' | 'de-AT'>>
});

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

it('redirects uppercase locale requests to case-sensitive defaults at the root', () => {
middlewareWithPathnames(createMockRequest('/EN', 'en'));
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', 'en'));
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)
Expand All @@ -541,55 +607,65 @@ describe('prefix-based routing', () => {
expect(getLinks(createMockRequest('/', 'en'))).toEqual([
'<http://localhost:3000/>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/de-AT>; rel="alternate"; hreflang="de-AT"',
'<http://localhost:3000/>; rel="alternate"; hreflang="x-default"'
]);
expect(getLinks(createMockRequest('/de', 'de'))).toEqual([
'<http://localhost:3000/>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/de-AT>; rel="alternate"; hreflang="de-AT"',
'<http://localhost:3000/>; rel="alternate"; hreflang="x-default"'
]);
expect(getLinks(createMockRequest('/about', 'en'))).toEqual([
'<http://localhost:3000/about>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/ueber>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/de-AT/ueber>; rel="alternate"; hreflang="de-AT"',
'<http://localhost:3000/about>; rel="alternate"; hreflang="x-default"'
]);
expect(getLinks(createMockRequest('/de/ueber', 'de'))).toEqual([
'<http://localhost:3000/about>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/ueber>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/de-AT/ueber>; rel="alternate"; hreflang="de-AT"',
'<http://localhost:3000/about>; rel="alternate"; hreflang="x-default"'
]);
expect(getLinks(createMockRequest('/users/1', 'en'))).toEqual([
'<http://localhost:3000/users/1>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/benutzer/1>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/de-AT/benutzer/1>; rel="alternate"; hreflang="de-AT"',
'<http://localhost:3000/users/1>; rel="alternate"; hreflang="x-default"'
]);
expect(getLinks(createMockRequest('/de/benutzer/1', 'de'))).toEqual([
'<http://localhost:3000/users/1>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/benutzer/1>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/de-AT/benutzer/1>; rel="alternate"; hreflang="de-AT"',
'<http://localhost:3000/users/1>; rel="alternate"; hreflang="x-default"'
]);
expect(
getLinks(createMockRequest('/products/apparel/t-shirts', 'en'))
).toEqual([
'<http://localhost:3000/products/apparel/t-shirts>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/de-AT/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de-AT"',
'<http://localhost:3000/products/apparel/t-shirts>; rel="alternate"; hreflang="x-default"'
]);
expect(
getLinks(createMockRequest('/de/produkte/apparel/t-shirts', 'de'))
).toEqual([
'<http://localhost:3000/products/apparel/t-shirts>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/de-AT/produkte/apparel/t-shirts>; rel="alternate"; hreflang="de-AT"',
'<http://localhost:3000/products/apparel/t-shirts>; rel="alternate"; hreflang="x-default"'
]);
expect(getLinks(createMockRequest('/unknown', 'en'))).toEqual([
'<http://localhost:3000/unknown>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/unknown>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/de-AT/unknown>; rel="alternate"; hreflang="de-AT"',
'<http://localhost:3000/unknown>; rel="alternate"; hreflang="x-default"'
]);
expect(getLinks(createMockRequest('/de/unknown', 'de'))).toEqual([
'<http://localhost:3000/unknown>; rel="alternate"; hreflang="en"',
'<http://localhost:3000/de/unknown>; rel="alternate"; hreflang="de"',
'<http://localhost:3000/de-AT/unknown>; rel="alternate"; hreflang="de-AT"',
'<http://localhost:3000/unknown>; rel="alternate"; hreflang="x-default"'
]);
});
Expand Down Expand Up @@ -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'
});

Expand Down Expand Up @@ -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();
Expand Down
Loading