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

Conversation

fkapsahili
Copy link
Contributor

@fkapsahili fkapsahili commented Feb 15, 2024

I noticed the same in one of my projects and thought I could look into it πŸ™‚ . This PR should implement a case-insensitive matching mechanism for the given locales. This means that the package should treat a request with an invalid case locale, such as de-at or DE, as valid and map it correctly to de-AT and de respectively; it should also rewrite the URL to reflect the correct case, for example by rewriting de-at to de-AT.

I have done a little bit research on this topic and I think case-insensitive matching for locales makes sense to enhance the UX and avoid people mixing up uppercase and lowercase when typing in locale codes. Also, it seems to be a common practice on a lot of popular websites. Maybe it would still make sense to be able to control the behavior via a property in the middleware. What do you think about this?

Also, in the Playwright test I added, I noticed that the matcher toHaveURL still expects /DE as the URL. In my browser, however, the redirect works as expected from /DE to /de. Just let me know if I have overlooked something in the implementation, or if I can improve something πŸ™‚.

Prerequisites

  • Use a meaningful title for the pull request
  • Test the changes you've made by adding or editing tests

Content

  • Added tests to the middleware that verify the pathLocale is converted correctly in a case-insensitive manner
  • Changed getNormalizedPathname to handle the locale in a case-insensitive manner
  • Changed getLocaleFromPathname to get a validated version of the locale with case-insensitive matching

Tests

  • Added tests to verify the redirect for invalid cased locales works as intended
  • Added a playwright test to the example-app-router-playground to make sure the redirect works

Closes #775

Copy link

vercel bot commented Feb 15, 2024

The latest updates on your projects. Learn more about Vercel for Git β†—οΈŽ

Name Status Preview Comments Updated (UTC)
next-intl-docs βœ… Ready (Inspect) Visit Preview πŸ’¬ Add feedback Feb 20, 2024 8:49am
next-intl-example-app-router βœ… Ready (Inspect) Visit Preview πŸ’¬ Add feedback Feb 20, 2024 8:49am

Copy link

vercel bot commented Feb 15, 2024

@fkapsahili is attempting to deploy a commit to the next-intl Team on Vercel.

A member of the Team first needs to authorize it.

Copy link
Owner

@amannn amannn left a comment

Choose a reason for hiding this comment

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

Another great PR, thank you so much @fkapsahili! πŸ™Œ

Let me know if my inline comment is helpful, it seems to me like there might have been a slight misunderstanding.

Maybe it would still make sense to be able to control the behavior via a property in the middleware. What do you think about this?

Let's make this non-configurable for the time being, I can't think of a good use case where you wouldn't want this and composing the middleware generally gives users more options for edge cases than what the next-intl middleware assumes as defaults.

Comment on lines 542 to 546
expect(MockedNextResponse.rewrite).toHaveBeenCalled();
expect(MockedNextResponse.next).not.toHaveBeenCalled();
expect(MockedNextResponse.redirect).not.toHaveBeenCalled();
expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe(
'http://localhost:3000/de-AT'
Copy link
Owner

Choose a reason for hiding this comment

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

I'm not sure if we're on the same page, but this test doesn't seem to assert for what the description says.

Let's clear up the terminology:

  • Redirect: A status code in the 3xx range is returned that points the user to another location.
  • Rewrite: The incoming request is mapped to another internal path.

In the context of next-intl, we e.g. have a redirect for the root / to the default locale (e.g. /en). On the other hand, we use rewrites e.g. for localized pathnames to handle them via a single internal pathname.

The way I'd imagine this feature and how I've observed it e.g. on MDN is that a redirect is returned when there's a case mismatch.

Due to this, the test should read like this:

Suggested change
expect(MockedNextResponse.rewrite).toHaveBeenCalled();
expect(MockedNextResponse.next).not.toHaveBeenCalled();
expect(MockedNextResponse.redirect).not.toHaveBeenCalled();
expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe(
'http://localhost:3000/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'

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah yes, that makes perfect sense. I also noticed during my research that a redirect is returned if there is a mismatch in the locale - I got something mixed up when writing the tests πŸ˜„. I have now adjusted this according to your suggestions and the tests failed as expected, but after I undid the changes in resolveLocale, everything works. And as a result, only very few changes are necessary in the middleware πŸ™πŸΌ.

const page = await context.newPage();

await page.goto('/DE');
await expect(page).toHaveURL('/DE');
Copy link
Owner

Choose a reason for hiding this comment

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

Also, in the Playwright test I added, I noticed that the matcher toHaveURL still expects /DE as the URL.

See the comment below, that should help to fix this test too.

Comment on lines 551 to 597
middlewareWithPathnames(createMockRequest('/de-at', 'de-AT'));
expect(MockedNextResponse.rewrite).toHaveBeenCalled();
expect(MockedNextResponse.next).not.toHaveBeenCalled();
expect(MockedNextResponse.redirect).not.toHaveBeenCalled();
expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe(
'http://localhost:3000/de-AT'
);
Copy link
Owner

Choose a reason for hiding this comment

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

Suggested change
middlewareWithPathnames(createMockRequest('/de-at', 'de-AT'));
expect(MockedNextResponse.rewrite).toHaveBeenCalled();
expect(MockedNextResponse.next).not.toHaveBeenCalled();
expect(MockedNextResponse.redirect).not.toHaveBeenCalled();
expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe(
'http://localhost:3000/de-AT'
);
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'
);

@fkapsahili
Copy link
Contributor Author

Let me know if there is anything to improve or if I have overlooked something! πŸ™‚

Copy link
Owner

@amannn amannn left a comment

Choose a reason for hiding this comment

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

Thanks for the update, this already looks pretty good!

only very few changes are necessary in the middleware

These are the best PRs, a few new tests and a minimal change!

Would you mind adding these tests too:

  • Redirects for nested paths (e.g. /about) for the default and secondary locales
  • Redirects for localePrefix: 'as-necessary' for a secondary locale
  • Redirects for nested paths when using non-localized pathnames (default and secondary locales)

Btw. my apologies if the middleware code is a bit hard to follow, I'd really like to clean it up into more grouped code paths at some point. I think for now it's important to have a comprehensive test suite to rely on, this would enable a potential refactor at some point.

I undid the changes in resolveLocale

I really appreciate how careful you are with changes in PRs πŸ™

@fkapsahili
Copy link
Contributor Author

Thanks for the review! Also thanks for your tip, I noticed that nested paths were still rewritten for the default locale - I have now fixed this with a small change in getKnownLocaleFromPathname so that the redirect works in all cases.
I also added a few more Playwright tests to make sure that all redirects work correctly in the browser. The test cases you mentioned should also all be covered now. I assumed that by as-necessary you meant the as-needed strategy in the middleware πŸ™‚.

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!

Copy link
Owner

@amannn amannn left a comment

Choose a reason for hiding this comment

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

Thanks for adding more tests, this looks really solid to me now! πŸ’―

I'll just wait for the tests to pass …

I assumed that by as-necessary you meant the as-needed strategy in the middleware πŸ™‚.

Yeah, definitelyβ€”I keep confusing these two πŸ€¦β€β™‚οΈ. In retrospect, maybe it should have been called non-default πŸ™‚

Many thanks again for this super clean PR! πŸ‘πŸ‘

@amannn amannn changed the title feat: Make locales case-insensitive and allow automatic resolving to the URL with the casing provided in the locales property feat: Add redirects for case mismatches in locale prefixes (e.g. /en-us β†’ /en-US) Feb 20, 2024
@amannn amannn changed the title feat: Add redirects for case mismatches in locale prefixes (e.g. /en-us β†’ /en-US) feat: Add redirects for case mismatches in locale prefixes (e.g. /EN β†’ /en) Feb 20, 2024
@amannn amannn merged commit 3b2b446 into amannn:main Feb 20, 2024
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
2 participants