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

fix: When using domain-based routing, use defaultLocale of a domain instead of the top-level one in case no other locale matches better on the domain #1000

Merged
merged 2 commits into from
Apr 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions docs/pages/docs/routing/middleware.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,14 @@ export default createMiddleware({
{
domain: 'us.example.com',
defaultLocale: 'en',
// Optionally restrict the locales managed by this domain. If this
// domain receives requests for another locale (e.g. us.example.com/fr),
// then the middleware will redirect to a domain that supports it.
// Optionally restrict the locales available on this domain
locales: ['en']
},
{
domain: 'ca.example.com',
defaultLocale: 'en'
// If there are no `locales` specified on a domain,
// all global locales will be supported here.
// all available locales will be supported here
}
]
});
Expand All @@ -110,11 +108,12 @@ To match the request against the available domains, the host is read from the `x

The locale is detected based on these priorities:

1. A locale prefix is present in the pathname and the domain supports it (e.g. `ca.example.com/fr`)
2. If the host of the request is configured in `domains`, the `defaultLocale` of the domain is used
3. As a fallback, the [locale detection of prefix-based routing](#locale-detection) applies
1. A locale prefix is present in the pathname (e.g. `ca.example.com/fr`)
2. A locale is stored in a cookie and is supported on the domain
3. A locale that the domain supports is matched based on the [`accept-language` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language)
4. As a fallback, the `defaultLocale` of the domain is used

Since the middleware is aware of all your domains, the domain will automatically be switched when the user requests to change the locale.
Since the middleware is aware of all your domains, if a domain receives a request for a locale that is not supported (e.g. `en.example.com/fr`), it will redirect to an alternative domain that does support the locale.

**Example workflow:**

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.95 KB"
"limit": "6 KB"
}
]
}
126 changes: 83 additions & 43 deletions packages/next-intl/src/middleware/resolveLocale.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,35 @@ export function getAcceptLanguageLocale<Locales extends AllLocales>(
return locale;
}

function getLocaleFromPrefix<Locales extends AllLocales>(
pathname: string,
locales: Locales
) {
const pathLocaleCandidate = getFirstPathnameSegment(pathname);
return findCaseInsensitiveLocale(pathLocaleCandidate, locales);
}

function getLocaleFromCookie<Locales extends AllLocales>(
requestCookies: RequestCookies,
locales: Locales
) {
if (requestCookies.has(COOKIE_LOCALE_NAME)) {
const value = requestCookies.get(COOKIE_LOCALE_NAME)?.value;
if (value && locales.includes(value)) {
return value;
}
}
}

function resolveLocaleFromPrefix<Locales extends AllLocales>(
{
defaultLocale,
localeDetection,
locales
}: MiddlewareConfigWithDefaults<Locales>,
}: Pick<
MiddlewareConfigWithDefaults<Locales>,
'defaultLocale' | 'localeDetection' | 'locales'
>,
requestHeaders: Headers,
requestCookies: RequestCookies,
pathname: string
Expand All @@ -69,24 +92,12 @@ function resolveLocaleFromPrefix<Locales extends AllLocales>(

// Prio 1: Use route prefix
if (pathname) {
const pathLocaleCandidate = getFirstPathnameSegment(pathname);
const matchedLocale = findCaseInsensitiveLocale(
pathLocaleCandidate,
locales
);
if (matchedLocale) {
locale = matchedLocale;
}
locale = getLocaleFromPrefix(pathname, locales);
}

// Prio 2: Use existing cookie
if (!locale && localeDetection && requestCookies) {
if (requestCookies.has(COOKIE_LOCALE_NAME)) {
const value = requestCookies.get(COOKIE_LOCALE_NAME)?.value;
if (value && locales.includes(value)) {
locale = value;
}
}
locale = getLocaleFromCookie(requestCookies, locales);
}

// Prio 3: Use the `accept-language` header
Expand All @@ -108,37 +119,66 @@ function resolveLocaleFromDomain<Locales extends AllLocales>(
requestCookies: RequestCookies,
pathname: string
) {
const {domains} = config;

const localeFromPrefixStrategy = resolveLocaleFromPrefix(
config,
requestHeaders,
requestCookies,
pathname
);

// Prio 1: Use a domain
if (domains) {
const domain = findDomainFromHost(requestHeaders, domains);
const hasLocalePrefix =
pathname && pathname.startsWith(`/${localeFromPrefixStrategy}`);

if (domain) {
return {
locale:
isLocaleSupportedOnDomain<Locales>(
localeFromPrefixStrategy,
domain
) || hasLocalePrefix
? localeFromPrefixStrategy
: domain.defaultLocale,
domain
};
const domains = config.domains!;
const domain = findDomainFromHost(requestHeaders, domains);

if (!domain) {
return {
locale: resolveLocaleFromPrefix(
config,
requestHeaders,
requestCookies,
pathname
)
};
}

let locale;

// Prio 1: Use route prefix
if (pathname) {
const prefixLocale = getLocaleFromPrefix(pathname, config.locales);
if (prefixLocale) {
if (isLocaleSupportedOnDomain(prefixLocale, domain)) {
locale = prefixLocale;
} else {
// Causes a redirect to a domain that supports the locale
return {locale: prefixLocale, domain};
}
}
}

// Prio 2: Use existing cookie
if (!locale && config.localeDetection && requestCookies) {
const cookieLocale = getLocaleFromCookie(requestCookies, config.locales);
if (cookieLocale) {
if (isLocaleSupportedOnDomain(cookieLocale, domain)) {
locale = cookieLocale;
} else {
// Ignore
}
}
}

// Prio 2: Use prefix strategy
return {locale: localeFromPrefixStrategy};
// Prio 3: Use the `accept-language` header
if (!locale && config.localeDetection && requestHeaders) {
const headerLocale = getAcceptLanguageLocale(
requestHeaders,
domain.locales || config.locales,
domain.defaultLocale
);

if (headerLocale) {
locale = headerLocale;
}
}

// Prio 4: Use default locale
if (!locale) {
locale = domain.defaultLocale;
}

return {locale, domain};
}

export default function resolveLocale<Locales extends AllLocales>(
Expand Down
19 changes: 18 additions & 1 deletion packages/next-intl/test/middleware/middleware.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function createMockRequest(
customHeaders?: HeadersInit
) {
const headers = new Headers({
'accept-language': `${acceptLanguageLocale};q=0.9,en;q=0.8`,
'accept-language': `${acceptLanguageLocale};q=0.9`,
host: new URL(host).host,
...(localeCookieValue && {
cookie: `${COOKIE_LOCALE_NAME}=${localeCookieValue}`
Expand Down Expand Up @@ -1765,6 +1765,23 @@ describe('domain-based routing', () => {
);
});

it('prioritizes the default locale of a domain', () => {
const m = createIntlMiddleware({
defaultLocale: 'en',
locales: ['en', 'fr'],
domains: [
{
defaultLocale: 'fr',
domain: 'ca.example.com'
}
]
});
m(createMockRequest('/', 'de', 'http://ca.example.com'));
expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
'http://ca.example.com/fr'
);
});

describe('unknown hosts', () => {
it('serves requests for unknown hosts at the root', () => {
middleware(createMockRequest('/', 'en', 'http://localhost'));
Expand Down
Loading