From 0d89722a1212a00ae59902b1e1e0d5d1580d1feb Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 27 Aug 2024 14:52:12 +0200 Subject: [PATCH] Refactor middleware (non-breaking) --- docs/pages/docs/routing/middleware.mdx | 4 +- packages/next-intl/src/middleware/config.tsx | 43 +---- .../getAlternateLinksHeaderValue.test.tsx | 108 ++++++------ .../getAlternateLinksHeaderValue.tsx | 107 +++++------ .../src/middleware/middleware.test.tsx | 7 +- .../next-intl/src/middleware/middleware.tsx | 76 ++++---- .../src/middleware/resolveLocale.tsx | 49 ++++-- .../src/navigation/shared/config.tsx | 9 +- packages/next-intl/src/routing/config.tsx | 48 ++++- .../src/routing/defineRouting.test.tsx | 166 ++++++++++++++++++ .../next-intl/src/routing/defineRouting.tsx | 9 + packages/next-intl/src/routing/index.tsx | 1 + 12 files changed, 415 insertions(+), 212 deletions(-) create mode 100644 packages/next-intl/src/routing/defineRouting.test.tsx create mode 100644 packages/next-intl/src/routing/defineRouting.tsx diff --git a/docs/pages/docs/routing/middleware.mdx b/docs/pages/docs/routing/middleware.mdx index 3f886cbc1..59d8d44c5 100644 --- a/docs/pages/docs/routing/middleware.mdx +++ b/docs/pages/docs/routing/middleware.mdx @@ -114,7 +114,7 @@ With this, your domain config for this particular domain will be used. Apart from the [`routing` configuration](/docs/routing#shared-configuration) that is shared with the [navigation APIs](/docs/routing/navigation), the middleware accepts a few additional options that can be used for customization. -### Turning off locale detection [#locale-detection-false] +### Turning off locale detection [#locale-detection] If you want to rely entirely on the URL to resolve the locale, you can set the `localeDetection` property to `false`. This will disable locale detection based on the `accept-language` header and a potentially existing cookie value from a previous visit. @@ -539,7 +539,7 @@ If you're using the [static export](https://nextjs.org/docs/app/building-your-ap **Static export limitations:** 1. There's no default locale that can be used without a prefix (same as [`localePrefix: 'always'`](/docs/routing#locale-prefix-always)) -2. The locale can't be negotiated at runtime (same as [`localeDetection: false`](#locale-detection-false)) +2. The locale can't be negotiated at runtime (same as [`localeDetection: false`](#locale-detection)) 3. You can't use [pathname localization](/docs/routing#pathnames) 4. This requires [static rendering](/docs/getting-started/app-router/with-i18n-routing#static-rendering) 5. You need to add a redirect for the root of the app diff --git a/packages/next-intl/src/middleware/config.tsx b/packages/next-intl/src/middleware/config.tsx index f2d005350..2d5b80cb0 100644 --- a/packages/next-intl/src/middleware/config.tsx +++ b/packages/next-intl/src/middleware/config.tsx @@ -1,48 +1,9 @@ -import { - RoutingBaseConfigInput, - receiveLocalePrefixConfig -} from '../routing/config'; -import {Locales, LocalePrefixConfigVerbose, Pathnames} from '../routing/types'; - -export type MiddlewareRoutingConfigInput< - AppLocales extends Locales, - AppPathnames extends Pathnames -> = RoutingBaseConfigInput & { - locales: AppLocales; - defaultLocale: AppLocales[number]; - +export type MiddlewareOptions = { /** Sets the `Link` response header to notify search engines about content in other languages (defaults to `true`). See https://developers.google.com/search/docs/specialty/international/localized-versions#http */ alternateLinks?: boolean; /** By setting this to `false`, the cookie as well as the `accept-language` header will no longer be used for locale detection. */ localeDetection?: boolean; - - /** Maps internal pathnames to external ones which can be localized per locale. */ - pathnames?: AppPathnames; -}; - -export type MiddlewareRoutingConfig< - AppLocales extends Locales, - AppPathnames extends Pathnames -> = Omit< - MiddlewareRoutingConfigInput, - 'alternateLinks' | 'localeDetection' | 'localePrefix' -> & { - alternateLinks: boolean; - localeDetection: boolean; - localePrefix: LocalePrefixConfigVerbose; }; -export function receiveConfig< - AppLocales extends Locales, - AppPathnames extends Pathnames ->( - input: MiddlewareRoutingConfigInput -): MiddlewareRoutingConfig { - return { - ...input, - alternateLinks: input?.alternateLinks ?? true, - localeDetection: input?.localeDetection ?? true, - localePrefix: receiveLocalePrefixConfig(input?.localePrefix) - }; -} +export type ResolvedMiddlewareOptions = Required; diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx index 5cc859525..fc3ca883c 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx @@ -3,7 +3,7 @@ import {NextRequest} from 'next/server'; import {it, expect, describe, beforeEach, afterEach} from 'vitest'; import {Pathnames} from '../routing'; -import {receiveConfig} from './config'; +import {receiveRoutingConfig} from '../routing/config'; import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue'; describe.each([{basePath: undefined}, {basePath: '/base'}])( @@ -29,7 +29,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( } it('works for prefixed routing (as-needed)', () => { - const config = receiveConfig({ + const routing = receiveRoutingConfig({ defaultLocale: 'en', locales: ['en', 'es'], localePrefix: 'as-needed' @@ -37,7 +37,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( expect( getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://example.com/'), resolvedLocale: 'en' }).split(', ') @@ -53,7 +53,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( expect( getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://example.com/about'), resolvedLocale: 'en' }).split(', ') @@ -65,7 +65,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( expect( getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://example.com/energy/es'), resolvedLocale: 'en' }).split(', ') @@ -77,7 +77,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it('works for prefixed routing (as-needed) with `pathnames`', () => { - const config = receiveConfig({ + const routing = receiveRoutingConfig({ defaultLocale: 'en', locales: ['en', 'de'], localePrefix: 'as-needed' @@ -100,7 +100,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( expect( getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://example.com/'), resolvedLocale: 'en', localizedPathnames: pathnames['/'] @@ -117,7 +117,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( expect( getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://example.com/about'), resolvedLocale: 'en', localizedPathnames: pathnames['/about'] @@ -130,7 +130,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( expect( getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://example.com/de/ueber'), resolvedLocale: 'de', localizedPathnames: pathnames['/about'] @@ -143,7 +143,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( expect( getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://example.com/users/2'), resolvedLocale: 'en', localizedPathnames: pathnames['/users/[userId]'] @@ -156,7 +156,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it('works for prefixed routing (always)', () => { - const config = receiveConfig({ + const routing = receiveRoutingConfig({ defaultLocale: 'en', locales: ['en', 'es'], localePrefix: 'always' @@ -164,7 +164,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( expect( getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://example.com/'), resolvedLocale: 'en' }).split(', ') @@ -178,7 +178,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( expect( getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://example.com/about'), resolvedLocale: 'en' }).split(', ') @@ -189,12 +189,10 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it("works for type domain with `localePrefix: 'as-needed'`", () => { - const config = receiveConfig({ + const routing = receiveRoutingConfig({ defaultLocale: 'en', locales: ['en', 'es', 'fr'], - alternateLinks: true, localePrefix: 'as-needed', - localeDetection: true, domains: [ { domain: 'example.com', @@ -216,12 +214,12 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( [ getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://example.com/'), resolvedLocale: 'en' }).split(', '), getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://example.es'), resolvedLocale: 'es' }).split(', ') @@ -244,7 +242,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( expect( getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://example.com/about'), resolvedLocale: 'en' }).split(', ') @@ -259,7 +257,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it("works for type domain with `localePrefix: 'always'`", () => { - const config = receiveConfig({ + const routing = receiveRoutingConfig({ defaultLocale: 'en', locales: ['en', 'es', 'fr'], domains: [ @@ -283,12 +281,12 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( [ getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://example.com/'), resolvedLocale: 'en' }).split(', '), getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://example.es'), resolvedLocale: 'es' }).split(', ') @@ -305,7 +303,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( expect( getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://example.com/about'), resolvedLocale: 'en' }).split(', ') @@ -320,7 +318,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it("works for type domain with `localePrefix: 'as-needed' with `pathnames``", () => { - const config = receiveConfig({ + const routing = receiveRoutingConfig({ localePrefix: 'as-needed', defaultLocale: 'en', locales: ['en', 'fr'], @@ -364,22 +362,22 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( [ getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://en.example.com/'), resolvedLocale: 'en' }), getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://ca.example.com'), resolvedLocale: 'en' }), getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://ca.example.com/fr'), resolvedLocale: 'fr' }), getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://fr.example.com'), resolvedLocale: 'fr' }) @@ -402,28 +400,28 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( [ getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://en.example.com/about'), resolvedLocale: 'en', - localizedPathnames: config.pathnames!['/about'] + localizedPathnames: routing.pathnames!['/about'] }), getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://ca.example.com/about'), resolvedLocale: 'en', - localizedPathnames: config.pathnames!['/about'] + localizedPathnames: routing.pathnames!['/about'] }), getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://ca.example.com/fr/a-propos'), resolvedLocale: 'fr', - localizedPathnames: config.pathnames!['/about'] + localizedPathnames: routing.pathnames!['/about'] }), getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://fr.example.com/a-propos'), resolvedLocale: 'fr', - localizedPathnames: config.pathnames!['/about'] + localizedPathnames: routing.pathnames!['/about'] }) ] .map((links) => links.split(', ')) @@ -438,28 +436,28 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( [ getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://en.example.com/users/42'), resolvedLocale: 'en', - localizedPathnames: config.pathnames!['/users/[userId]'] + localizedPathnames: routing.pathnames!['/users/[userId]'] }), getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://ca.example.com/users/42'), resolvedLocale: 'en', - localizedPathnames: config.pathnames!['/users/[userId]'] + localizedPathnames: routing.pathnames!['/users/[userId]'] }), getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://ca.example.com/fr/utilisateurs/42'), resolvedLocale: 'fr', - localizedPathnames: config.pathnames!['/users/[userId]'] + localizedPathnames: routing.pathnames!['/users/[userId]'] }), getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('https://fr.example.com/utilisateurs/42'), resolvedLocale: 'fr', - localizedPathnames: config.pathnames!['/users/[userId]'] + localizedPathnames: routing.pathnames!['/users/[userId]'] }) ] .map((links) => links.split(', ')) @@ -474,7 +472,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it('uses the external host name from headers instead of the url of the incoming request (relevant when running the app behind a proxy)', () => { - const config = receiveConfig({ + const routing = receiveRoutingConfig({ defaultLocale: 'en', locales: ['en', 'es'], localePrefix: 'as-needed' @@ -482,7 +480,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( expect( getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('http://127.0.0.1/about', { headers: { host: 'example.com', @@ -500,7 +498,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it('keeps the port of an external host if provided', () => { - const config = receiveConfig({ + const routing = receiveRoutingConfig({ defaultLocale: 'en', locales: ['en', 'es'], localePrefix: 'as-needed' @@ -508,7 +506,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( expect( getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('http://127.0.0.1/about', { headers: { host: 'example.com:3000', @@ -526,7 +524,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }); it('uses the external host name and the port from headers instead of the url with port of the incoming request (relevant when running the app behind a proxy)', () => { - const config = receiveConfig({ + const routing = receiveRoutingConfig({ defaultLocale: 'en', locales: ['en', 'es'], localePrefix: 'as-needed' @@ -534,7 +532,7 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( expect( getAlternateLinksHeaderValue({ - config, + routing, request: getMockRequest('http://127.0.0.1:3000/about', { headers: { host: 'example.com', @@ -562,7 +560,7 @@ describe('trailingSlash: true', () => { }); it('adds a trailing slash to pathnames', () => { - const config = receiveConfig({ + const routing = receiveRoutingConfig({ defaultLocale: 'en', locales: ['en', 'es'], localePrefix: 'as-needed' @@ -570,7 +568,7 @@ describe('trailingSlash: true', () => { expect( getAlternateLinksHeaderValue({ - config, + routing, request: new NextRequest(new URL('https://example.com/about')), resolvedLocale: 'en' }).split(', ') @@ -582,7 +580,7 @@ describe('trailingSlash: true', () => { }); describe('localized pathnames', () => { - const config = receiveConfig({ + const routing = receiveRoutingConfig({ defaultLocale: 'en', locales: ['en', 'es'], localePrefix: 'as-needed' @@ -599,7 +597,7 @@ describe('trailingSlash: true', () => { ['/about', '/about/'].forEach((pathname) => { expect( getAlternateLinksHeaderValue({ - config, + routing, request: new NextRequest(new URL('https://example.com' + pathname)), resolvedLocale: 'en', localizedPathnames: pathnames['/about'] @@ -616,7 +614,7 @@ describe('trailingSlash: true', () => { ['', '/'].forEach((pathname) => { expect( getAlternateLinksHeaderValue({ - config, + routing, request: new NextRequest(new URL('https://example.com' + pathname)), resolvedLocale: 'en', localizedPathnames: pathnames['/'] diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 8f8005124..f0e14420b 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -1,7 +1,7 @@ import {NextRequest} from 'next/server'; +import {ResolvedRoutingConfig} from '../routing/config'; import {Locales, Pathnames} from '../routing/types'; import {normalizeTrailingSlash} from '../shared/utils'; -import {MiddlewareRoutingConfig} from './config'; import { applyBasePath, formatTemplatePathname, @@ -18,12 +18,12 @@ export default function getAlternateLinksHeaderValue< AppLocales extends Locales, AppPathnames extends Pathnames >({ - config, localizedPathnames, request, - resolvedLocale + resolvedLocale, + routing }: { - config: MiddlewareRoutingConfig; + routing: ResolvedRoutingConfig; request: NextRequest; resolvedLocale: AppLocales[number]; localizedPathnames?: Pathnames[string]; @@ -40,8 +40,8 @@ export default function getAlternateLinksHeaderValue< normalizedUrl.pathname = getNormalizedPathname( normalizedUrl.pathname, - config.locales, - config.localePrefix + routing.locales, + routing.localePrefix ); function getAlternateEntry(url: URL, locale: string) { @@ -67,71 +67,72 @@ export default function getAlternateLinksHeaderValue< } } - const links = getLocalePrefixes(config.locales, config.localePrefix).flatMap( - ([locale, prefix]) => { - function prefixPathname(pathname: string) { - if (pathname === '/') { - return prefix; - } else { - return prefix + pathname; - } + const links = getLocalePrefixes( + routing.locales, + routing.localePrefix + ).flatMap(([locale, prefix]) => { + function prefixPathname(pathname: string) { + if (pathname === '/') { + return prefix; + } else { + return prefix + pathname; } + } - let url: URL; - - if (config.domains) { - const domainConfigs = - config.domains.filter((cur) => - isLocaleSupportedOnDomain(locale, cur) - ) || []; - - return domainConfigs.map((domainConfig) => { - url = new URL(normalizedUrl); - url.port = ''; - url.host = domainConfig.domain; + let url: URL; - // Important: Use `normalizedUrl` here, as `url` potentially uses - // a `basePath` that automatically gets applied to the pathname - url.pathname = getLocalizedPathname(normalizedUrl.pathname, locale); + if (routing.domains) { + const domainConfigs = + routing.domains.filter((cur) => + isLocaleSupportedOnDomain(locale, cur) + ) || []; - if ( - locale !== domainConfig.defaultLocale || - config.localePrefix.mode === 'always' - ) { - url.pathname = prefixPathname(url.pathname); - } + return domainConfigs.map((domainConfig) => { + url = new URL(normalizedUrl); + url.port = ''; + url.host = domainConfig.domain; - return getAlternateEntry(url, locale); - }); - } else { - let pathname: string; - if (localizedPathnames && typeof localizedPathnames === 'object') { - pathname = getLocalizedPathname(normalizedUrl.pathname, locale); - } else { - pathname = normalizedUrl.pathname; - } + // Important: Use `normalizedUrl` here, as `url` potentially uses + // a `basePath` that automatically gets applied to the pathname + url.pathname = getLocalizedPathname(normalizedUrl.pathname, locale); if ( - locale !== config.defaultLocale || - config.localePrefix.mode === 'always' + locale !== domainConfig.defaultLocale || + routing.localePrefix.mode === 'always' ) { - pathname = prefixPathname(pathname); + url.pathname = prefixPathname(url.pathname); } - url = new URL(pathname, normalizedUrl); + + return getAlternateEntry(url, locale); + }); + } else { + let pathname: string; + if (localizedPathnames && typeof localizedPathnames === 'object') { + pathname = getLocalizedPathname(normalizedUrl.pathname, locale); + } else { + pathname = normalizedUrl.pathname; } - return getAlternateEntry(url, locale); + if ( + locale !== routing.defaultLocale || + routing.localePrefix.mode === 'always' + ) { + pathname = prefixPathname(pathname); + } + url = new URL(pathname, normalizedUrl); } - ); + + return getAlternateEntry(url, locale); + }); // Add x-default entry const shouldAddXDefault = // For domain-based routing there is no reasonable x-default - !config.domains && - (config.localePrefix.mode !== 'always' || normalizedUrl.pathname === '/'); + !routing.domains && + (routing.localePrefix.mode !== 'always' || normalizedUrl.pathname === '/'); if (shouldAddXDefault) { const url = new URL( - getLocalizedPathname(normalizedUrl.pathname, config.defaultLocale), + getLocalizedPathname(normalizedUrl.pathname, routing.defaultLocale), normalizedUrl ); links.push(getAlternateEntry(url, 'x-default')); diff --git a/packages/next-intl/src/middleware/middleware.test.tsx b/packages/next-intl/src/middleware/middleware.test.tsx index 086608990..9caae4a14 100644 --- a/packages/next-intl/src/middleware/middleware.test.tsx +++ b/packages/next-intl/src/middleware/middleware.test.tsx @@ -5,7 +5,7 @@ import {NextRequest, NextResponse} from 'next/server'; import {pathToRegexp} from 'path-to-regexp'; import {it, describe, vi, beforeEach, expect, Mock, afterEach} from 'vitest'; import createMiddleware from '../middleware'; -import {Pathnames} from '../routing'; +import {defineRouting, Pathnames} from '../routing'; import {COOKIE_LOCALE_NAME} from '../shared/constants'; vi.mock('next/server', async (importActual) => { @@ -136,11 +136,12 @@ it('has docs that suggest a reasonable matcher', () => { describe('prefix-based routing', () => { describe('localePrefix: as-needed', () => { - const middleware = createMiddleware({ - defaultLocale: 'en', + const routing = defineRouting({ locales: ['en', 'de'], + defaultLocale: 'en', localePrefix: 'as-needed' }); + const middleware = createMiddleware(routing); it('rewrites requests for the default locale', () => { middleware(createMockRequest('/')); diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 14be3be21..17c13906c 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -1,4 +1,5 @@ import {NextRequest, NextResponse} from 'next/server'; +import {receiveRoutingConfig, RoutingConfig} from '../routing/config'; import {Locales, Pathnames} from '../routing/types'; import {HEADER_LOCALE_NAME} from '../shared/constants'; import { @@ -6,7 +7,7 @@ import { matchesPathname, normalizeTrailingSlash } from '../shared/utils'; -import {MiddlewareRoutingConfigInput, receiveConfig} from './config'; +import {MiddlewareOptions} from './config'; import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue'; import resolveLocale from './resolveLocale'; import syncCookie from './syncCookie'; @@ -26,8 +27,21 @@ import { export default function createMiddleware< AppLocales extends Locales, AppPathnames extends Pathnames ->(input: MiddlewareRoutingConfigInput) { - const config = receiveConfig(input); +>( + routing: RoutingConfig & { + /** @deprecated Should be passed as part of the second argument `options` now (see https://next-intl-docs.vercel.app/docs/routing/middleware#configuration) */ + alternateLinks?: MiddlewareOptions['alternateLinks']; + /** @deprecated Should be passed as part of the second argument `options` now (see https://next-intl-docs.vercel.app/docs/routing/middleware#configuration) */ + localeDetection?: MiddlewareOptions['localeDetection']; + }, + options?: MiddlewareOptions +) { + const resolvedRouting = receiveRoutingConfig(routing); + const resolvedOptions = { + alternateLinks: options?.alternateLinks ?? routing.alternateLinks ?? true, + localeDetection: + options?.localeDetection ?? routing?.localeDetection ?? true + }; return function middleware(request: NextRequest) { // Resolve potential foreign symbols (e.g. /ja/%E7%B4%84 → /ja/約)) @@ -38,7 +52,8 @@ export default function createMiddleware< const externalPathname = sanitizePathname(unsafeExternalPathname); const {domain, locale} = resolveLocale( - config, + resolvedRouting, + resolvedOptions, request.headers, request.cookies, externalPathname @@ -46,13 +61,13 @@ export default function createMiddleware< const hasMatchedDefaultLocale = domain ? domain.defaultLocale === locale - : locale === config.defaultLocale; + : locale === resolvedRouting.defaultLocale; const domainsConfig = - config.domains?.filter((curDomain) => + resolvedRouting.domains?.filter((curDomain) => isLocaleSupportedOnDomain(locale, curDomain) ) || []; - const hasUnknownHost = config.domains != null && !domain; + const hasUnknownHost = resolvedRouting.domains != null && !domain; function rewrite(url: string) { const urlObj = new URL(url, request.url); @@ -82,12 +97,12 @@ export default function createMiddleware< redirectDomain = bestMatchingDomain.domain; if ( bestMatchingDomain.defaultLocale === locale && - config.localePrefix.mode === 'as-needed' + resolvedRouting.localePrefix.mode === 'as-needed' ) { urlObj.pathname = getNormalizedPathname( urlObj.pathname, - config.locales, - config.localePrefix + resolvedRouting.locales, + resolvedRouting.localePrefix ); } } @@ -117,35 +132,36 @@ export default function createMiddleware< const unprefixedExternalPathname = getNormalizedPathname( externalPathname, - config.locales, - config.localePrefix + resolvedRouting.locales, + resolvedRouting.localePrefix ); const pathnameMatch = getPathnameMatch( externalPathname, - config.locales, - config.localePrefix + resolvedRouting.locales, + resolvedRouting.localePrefix ); const hasLocalePrefix = pathnameMatch != null; const isUnprefixedRouting = - config.localePrefix.mode === 'never' || - (hasMatchedDefaultLocale && config.localePrefix.mode === 'as-needed'); + resolvedRouting.localePrefix.mode === 'never' || + (hasMatchedDefaultLocale && + resolvedRouting.localePrefix.mode === 'as-needed'); let response; let internalTemplateName: keyof AppPathnames | undefined; let unprefixedInternalPathname = unprefixedExternalPathname; - if (config.pathnames) { + if (resolvedRouting.pathnames) { let resolvedTemplateLocale: AppLocales[number] | undefined; [resolvedTemplateLocale, internalTemplateName] = getInternalTemplate( - config.pathnames, + resolvedRouting.pathnames, unprefixedExternalPathname, locale ); if (internalTemplateName) { - const pathnameConfig = config.pathnames[internalTemplateName]; + const pathnameConfig = resolvedRouting.pathnames[internalTemplateName]; const localeTemplate: string = typeof pathnameConfig === 'string' ? pathnameConfig @@ -175,7 +191,7 @@ export default function createMiddleware< const localePrefix = isUnprefixedRouting ? undefined - : getLocalePrefix(locale, config.localePrefix); + : getLocalePrefix(locale, resolvedRouting.localePrefix); const template = formatTemplatePathname( unprefixedExternalPathname, @@ -204,7 +220,7 @@ export default function createMiddleware< response = redirect( formatPathname( unprefixedExternalPathname, - getLocalePrefix(locale, config.localePrefix), + getLocalePrefix(locale, resolvedRouting.localePrefix), request.nextUrl.search ) ); @@ -223,7 +239,7 @@ export default function createMiddleware< request.nextUrl.search ); - if (config.localePrefix.mode === 'never') { + if (resolvedRouting.localePrefix.mode === 'never') { response = redirect( formatPathname( unprefixedExternalPathname, @@ -241,7 +257,7 @@ export default function createMiddleware< ) ); } else { - if (config.domains) { + if (resolvedRouting.domains) { const pathDomain = getBestMatchingDomain( domain, pathnameMatch.locale, @@ -267,7 +283,7 @@ export default function createMiddleware< response = redirect( formatPathname( unprefixedExternalPathname, - getLocalePrefix(locale, config.localePrefix), + getLocalePrefix(locale, resolvedRouting.localePrefix), request.nextUrl.search ) ); @@ -276,22 +292,22 @@ export default function createMiddleware< } } - if (config.localeDetection) { + if (resolvedOptions.localeDetection) { syncCookie(request, response, locale); } if ( - config.localePrefix.mode !== 'never' && - config.alternateLinks && - config.locales.length > 1 + resolvedRouting.localePrefix.mode !== 'never' && + resolvedOptions.alternateLinks && + resolvedRouting.locales.length > 1 ) { response.headers.set( 'Link', getAlternateLinksHeaderValue({ - config, + routing: resolvedRouting, localizedPathnames: internalTemplateName != null - ? config.pathnames?.[internalTemplateName] + ? resolvedRouting.pathnames?.[internalTemplateName] : undefined, request, resolvedLocale: locale diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index 05b5b1bfb..f2a079b44 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -1,6 +1,7 @@ import {match} from '@formatjs/intl-localematcher'; import Negotiator from 'negotiator'; import {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; +import {ResolvedRoutingConfig} from '../routing/config'; import { Locales, Pathnames, @@ -8,7 +9,7 @@ import { DomainConfig } from '../routing/types'; import {COOKIE_LOCALE_NAME} from '../shared/constants'; -import {MiddlewareRoutingConfig} from './config'; +import {ResolvedMiddlewareOptions} from './config'; import {getHost, getPathnameMatch, isLocaleSupportedOnDomain} from './utils'; function findDomainFromHost( @@ -77,13 +78,10 @@ function resolveLocaleFromPrefix< >( { defaultLocale, - localeDetection, localePrefix, locales - }: Pick< - MiddlewareRoutingConfig, - 'defaultLocale' | 'localeDetection' | 'locales' | 'localePrefix' - >, + }: ResolvedRoutingConfig, + {localeDetection}: ResolvedMiddlewareOptions, requestHeaders: Headers, requestCookies: RequestCookies, pathname: string @@ -117,18 +115,21 @@ function resolveLocaleFromDomain< AppLocales extends Locales, AppPathnames extends Pathnames >( - config: MiddlewareRoutingConfig, + routing: Omit, 'domains'> & + Required, 'domains'>>, + options: ResolvedMiddlewareOptions, requestHeaders: Headers, requestCookies: RequestCookies, pathname: string ) { - const domains = config.domains!; + const domains = routing.domains; const domain = findDomainFromHost(requestHeaders, domains); if (!domain) { return { locale: resolveLocaleFromPrefix( - config, + routing, + options, requestHeaders, requestCookies, pathname @@ -142,8 +143,8 @@ function resolveLocaleFromDomain< if (pathname) { const prefixLocale = getPathnameMatch( pathname, - config.locales, - config.localePrefix + routing.locales, + routing.localePrefix )?.locale; if (prefixLocale) { if (isLocaleSupportedOnDomain(prefixLocale, domain)) { @@ -156,8 +157,8 @@ function resolveLocaleFromDomain< } // Prio 2: Use existing cookie - if (!locale && config.localeDetection && requestCookies) { - const cookieLocale = getLocaleFromCookie(requestCookies, config.locales); + if (!locale && options.localeDetection && requestCookies) { + const cookieLocale = getLocaleFromCookie(requestCookies, routing.locales); if (cookieLocale) { if (isLocaleSupportedOnDomain(cookieLocale, domain)) { locale = cookieLocale; @@ -168,10 +169,10 @@ function resolveLocaleFromDomain< } // Prio 3: Use the `accept-language` header - if (!locale && config.localeDetection && requestHeaders) { + if (!locale && options.localeDetection && requestHeaders) { const headerLocale = getAcceptLanguageLocale( requestHeaders, - domain.locales || config.locales, + domain.locales || routing.locales, domain.defaultLocale ); @@ -192,14 +193,23 @@ export default function resolveLocale< AppLocales extends Locales, AppPathnames extends Pathnames >( - config: MiddlewareRoutingConfig, + routing: ResolvedRoutingConfig, + options: ResolvedMiddlewareOptions, requestHeaders: Headers, requestCookies: RequestCookies, pathname: string ): {locale: AppLocales[number]; domain?: DomainConfig} { - if (config.domains) { + if (routing.domains) { + const routingWithDomains = routing as Omit< + ResolvedRoutingConfig, + 'domains' + > & + Required< + Pick, 'domains'> + >; return resolveLocaleFromDomain( - config, + routingWithDomains, + options, requestHeaders, requestCookies, pathname @@ -207,7 +217,8 @@ export default function resolveLocale< } else { return { locale: resolveLocaleFromPrefix( - config, + routing, + options, requestHeaders, requestCookies, pathname diff --git a/packages/next-intl/src/navigation/shared/config.tsx b/packages/next-intl/src/navigation/shared/config.tsx index 240fc6a0a..6fc928e9a 100644 --- a/packages/next-intl/src/navigation/shared/config.tsx +++ b/packages/next-intl/src/navigation/shared/config.tsx @@ -1,7 +1,4 @@ -import { - RoutingBaseConfigInput, - receiveLocalePrefixConfig -} from '../../routing/config'; +import {RoutingConfig, receiveLocalePrefixConfig} from '../../routing/config'; import { Locales, LocalePrefixConfigVerbose, @@ -13,7 +10,7 @@ import { */ export type SharedNavigationRoutingConfigInput = - RoutingBaseConfigInput & { + RoutingConfig & { locales?: AppLocales; }; @@ -40,7 +37,7 @@ export function receiveSharedNavigationRoutingConfig< export type LocalizedNavigationRoutingConfigInput< AppLocales extends Locales, AppPathnames extends Pathnames -> = RoutingBaseConfigInput & { +> = RoutingConfig & { locales: AppLocales; /** Maps internal pathnames to external ones which can be localized per locale. */ diff --git a/packages/next-intl/src/routing/config.tsx b/packages/next-intl/src/routing/config.tsx index 6065c2bf9..832ce8578 100644 --- a/packages/next-intl/src/routing/config.tsx +++ b/packages/next-intl/src/routing/config.tsx @@ -2,7 +2,8 @@ import { Locales, LocalePrefix, LocalePrefixConfigVerbose, - DomainsConfig + DomainsConfig, + Pathnames } from './types'; /** @@ -11,13 +12,54 @@ import { * different. This type declares the shared base config that is accepted by all * of them. Properties that are different are declared in consuming types. */ -export type RoutingBaseConfigInput = { - /** @see https://next-intl-docs.vercel.app/docs/routing#locale-prefix */ +export type RoutingConfig< + AppLocales extends Locales, + AppPathnames extends Pathnames +> = { + /** + * All available locales. + * @see https://next-intl-docs.vercel.app/docs/routing + */ + locales: AppLocales; + + /** + * Used when no locale matches. + * @see https://next-intl-docs.vercel.app/docs/routing + */ + defaultLocale: AppLocales[number]; + + /** + * Configures whether and which prefix is shown for a given locale. + * @see https://next-intl-docs.vercel.app/docs/routing#locale-prefix + **/ localePrefix?: LocalePrefix; + /** Can be used to change the locale handling per domain. */ domains?: DomainsConfig; + + /** A map of localized pathnames per locale. */ + pathnames?: AppPathnames; }; +export type ResolvedRoutingConfig< + AppLocales extends Locales, + AppPathnames extends Pathnames +> = Omit, 'localePrefix'> & { + localePrefix: LocalePrefixConfigVerbose; +}; + +export function receiveRoutingConfig< + AppLocales extends Locales, + AppPathnames extends Pathnames +>( + input: RoutingConfig +): ResolvedRoutingConfig { + return { + ...input, + localePrefix: receiveLocalePrefixConfig(input.localePrefix) + }; +} + export function receiveLocalePrefixConfig( localePrefix?: LocalePrefix ): LocalePrefixConfigVerbose { diff --git a/packages/next-intl/src/routing/defineRouting.test.tsx b/packages/next-intl/src/routing/defineRouting.test.tsx new file mode 100644 index 000000000..e41c16714 --- /dev/null +++ b/packages/next-intl/src/routing/defineRouting.test.tsx @@ -0,0 +1,166 @@ +import {describe, it} from 'vitest'; +import defineRouting from './defineRouting'; + +describe('defaultLocale', () => { + it('ensures the `defaultLocale` is within `locales`', () => { + defineRouting({ + locales: ['en'], + // @ts-expect-error + defaultLocale: 'es' + }); + + defineRouting({ + locales: ['en', 'de'], + defaultLocale: 'en' + }); + }); +}); + +describe('pathnames', () => { + it('accepts a `pathnames` config', () => { + defineRouting({ + locales: ['en', 'de'], + defaultLocale: 'en', + pathnames: { + '/': '/', + '/about': { + en: '/about', + de: '/ueber-uns' + } + } + }); + }); + + it('ensures all locales have a value', () => { + defineRouting({ + locales: ['en', 'de'], + defaultLocale: 'en', + pathnames: { + // @ts-expect-error -- Missing de + '/about': { + en: '/about' + } + } + }); + }); +}); + +describe('domains', () => { + it('accepts a `domains` config', () => { + defineRouting({ + locales: ['en'], + defaultLocale: 'en', + domains: [ + { + defaultLocale: 'en', + domain: 'example.com' + } + ] + }); + }); + + it('ensures `defaultLocale` is within `locales`', () => { + defineRouting({ + locales: ['en'], + defaultLocale: 'en', + domains: [ + { + // @ts-expect-error + defaultLocale: 'es', + domain: 'example.com' + } + ] + }); + }); + + it('ensures `locales` are within `locales`', () => { + defineRouting({ + locales: ['en'], + defaultLocale: 'en', + domains: [ + { + defaultLocale: 'en', + domain: 'example.com', + locales: ['en'] + } + ] + }); + + defineRouting({ + locales: ['en'], + defaultLocale: 'en', + domains: [ + { + defaultLocale: 'en', + domain: 'example.com', + // @ts-expect-error + locales: ['es'] + } + ] + }); + }); +}); + +describe('localePrefix', () => { + it('accepts a shorthand `localePrefix`', () => { + defineRouting({ + locales: ['en'], + defaultLocale: 'en', + localePrefix: 'always' + }); + + defineRouting({ + locales: ['en'], + defaultLocale: 'en', + localePrefix: 'never' + }); + }); + + it('accepts a verbose `localePrefix`', () => { + defineRouting({ + locales: ['en'], + defaultLocale: 'en', + localePrefix: { + mode: 'always' + } + }); + + defineRouting({ + locales: ['en-GB', 'en-US'], + defaultLocale: 'en-US', + localePrefix: { + mode: 'as-needed' + } + }); + }); + + describe('custom prefixes', () => { + it('accepts partial prefixes', () => { + defineRouting({ + locales: ['en-GB', 'en-US'], + defaultLocale: 'en-US', + localePrefix: { + mode: 'as-needed', + prefixes: { + 'en-US': '/us' + // (en-GB is used as-is) + } + } + }); + }); + + it('ensures locales used in prefixes are valid', () => { + defineRouting({ + locales: ['en'], + defaultLocale: 'en', + localePrefix: { + mode: 'as-needed', + prefixes: { + // @ts-expect-error + 'en-ES': '/es' + } + } + }); + }); + }); +}); diff --git a/packages/next-intl/src/routing/defineRouting.tsx b/packages/next-intl/src/routing/defineRouting.tsx new file mode 100644 index 000000000..dc8fbf261 --- /dev/null +++ b/packages/next-intl/src/routing/defineRouting.tsx @@ -0,0 +1,9 @@ +import {RoutingConfig} from './config'; +import {Locales, Pathnames} from './types'; + +export default function defineRouting< + const AppLocales extends Locales, + const AppPathnames extends Pathnames +>(config: RoutingConfig) { + return config; +} diff --git a/packages/next-intl/src/routing/index.tsx b/packages/next-intl/src/routing/index.tsx index 7de0af6d3..d0832e45d 100644 --- a/packages/next-intl/src/routing/index.tsx +++ b/packages/next-intl/src/routing/index.tsx @@ -1 +1,2 @@ export type {Pathnames, LocalePrefix, DomainsConfig} from './types'; +export {default as defineRouting} from './defineRouting';