-
-
Notifications
You must be signed in to change notification settings - Fork 253
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
Conversation
The latest updates on your projects. Learn more about Vercel for Git βοΈ
|
@fkapsahili is attempting to deploy a commit to the next-intl Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this 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.
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' |
There was a problem hiding this comment.
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:
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' |
There was a problem hiding this comment.
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'); |
There was a problem hiding this comment.
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.
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' | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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' | |
); |
Let me know if there is anything to improve or if I have overlooked something! π |
There was a problem hiding this 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 π
4680973
to
02d7dbe
Compare
c707400
to
9542deb
Compare
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 |
const pathLocale = locales.includes(pathLocaleCandidate) | ||
const pathLocale = locales.find( | ||
(locale) => locale.toLowerCase() === pathLocaleCandidate.toLowerCase() | ||
) |
There was a problem hiding this comment.
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!
There was a problem hiding this 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 theas-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! ππ
locales
case-insensitive and allow automatic resolving to the URL with the casing provided in the locales property/en-us
β /en-US
)
/en-us
β /en-US
)/EN
β /en
)
β¦` β `/en`) (amannn#861) Closes amannn#775 --------- Co-authored-by: Jan Amann <[email protected]>
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
orDE
, as valid and map it correctly tode-AT
andde
respectively; it should also rewrite the URL to reflect the correct case, for example by rewritingde-at
tode-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
Content
middleware
that verify thepathLocale
is converted correctly in a case-insensitive mannergetNormalizedPathname
to handle the locale in a case-insensitive mannergetLocaleFromPathname
to get a validated version of the locale with case-insensitive matchingTests
example-app-router-playground
to make sure the redirect worksCloses #775