Skip to content

Commit

Permalink
feat: Add redirects for case mismatches in locale prefixes (e.g. `/EN…
Browse files Browse the repository at this point in the history
…` → `/en`) (#861)


Closes #775

---------

Co-authored-by: Jan Amann <jan@amann.me>
  • Loading branch information
fkapsahili and amannn committed Feb 20, 2024
1 parent 5cc264d commit 3b2b446
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 21 deletions.
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

0 comments on commit 3b2b446

Please sign in to comment.