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.855 KB"
}
]
}
4 changes: 2 additions & 2 deletions packages/next-intl/src/middleware/middleware.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
getInternalTemplate,
formatTemplatePathname,
getBestMatchingDomain,
getKnownLocaleFromPathname,
getPathnameLocale,
getNormalizedPathname,
getPathWithSearch,
isLocaleSupportedOnDomain,
Expand Down Expand Up @@ -134,7 +134,7 @@ export default function createMiddleware<Locales extends AllLocales>(
configWithDefaults.locales
);

const pathLocale = getKnownLocaleFromPathname(
const pathLocale = getPathnameLocale(
request.nextUrl.pathname,
configWithDefaults.locales
);
Expand Down
13 changes: 9 additions & 4 deletions packages/next-intl/src/middleware/resolveLocale.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
MiddlewareConfigWithDefaults
} from './NextIntlMiddlewareConfig';
import {
getLocaleFromPathname,
findCaseInsensitiveLocale,
getFirstPathnameSegment,
getHost,
isLocaleSupportedOnDomain
} from './utils';
Expand Down Expand Up @@ -68,9 +69,13 @@ function resolveLocaleFromPrefix<Locales extends AllLocales>(

// 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;
}
}

Expand Down
21 changes: 16 additions & 5 deletions packages/next-intl/src/middleware/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
MiddlewareConfigWithDefaults
} from './NextIntlMiddlewareConfig';

export function getLocaleFromPathname(pathname: string) {
export function getFirstPathnameSegment(pathname: string) {
return pathname.split('/')[1];
}

Expand Down 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 @@ -81,12 +83,21 @@ export function getNormalizedPathname<Locales extends AllLocales>(
return result;
}

export function getKnownLocaleFromPathname<Locales extends AllLocales>(
export function findCaseInsensitiveLocale<Locales extends AllLocales>(
candidate: string,
locales: Locales
) {
return locales.find(
(locale) => locale.toLowerCase() === candidate.toLowerCase()
);
}

export function getPathnameLocale<Locales extends AllLocales>(
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;
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', '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)
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