From e29bd49d877065b4fb89a58f5e446049b244e28a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 17 Mar 2023 12:48:09 -0600 Subject: [PATCH] i18n Improvements (#47174) This serves to correct a specific issue related to multiple locales being specified in the pathname as well as some general i18n improvements. - Multiple locales are now parsed correctly (only taking the first locale, treating the rest of the string as the pathname) - `LocaleRouteNormalizer` has been split into `I18NProvider` and `LocaleRouteNormalizer` (tests added) - Adjusted the `I18NProvider.analyze` method (previously `LocaleRouteNormalizer.match`) to require the `defaultLocale: string | undefined` to ensure consistent behaviour - Added more comments around i18n --- packages/next/src/server/base-server.ts | 108 ++++++--------- .../next/src/server/dev/next-dev-server.ts | 14 +- .../future/helpers/i18n-provider.test.ts | 63 +++++++++ .../server/future/helpers/i18n-provider.ts | 126 ++++++++++++++++++ .../normalizers/locale-route-normalizer.ts | 70 +++------- .../pages-api-route-matcher-provider.ts | 13 +- .../pages-route-matcher-provider.test.ts | 6 +- .../pages-route-matcher-provider.ts | 17 ++- .../route-matchers/locale-route-matcher.ts | 37 ++++- .../future/route-matchers/route-matcher.ts | 6 +- packages/next/src/server/next-server.ts | 54 ++++---- packages/next/src/server/request-meta.ts | 10 ++ packages/next/src/server/router.ts | 75 ++++++----- packages/next/src/server/web-server.ts | 21 ++- packages/next/src/server/web/next-url.ts | 35 +++-- .../shared/lib/i18n/detect-domain-locale.ts | 31 ++--- .../src/shared/lib/router/utils/add-locale.ts | 22 +-- .../router/utils/get-next-pathname-info.ts | 23 +++- .../i18n-disallow-multiple-locales.test.ts | 51 +++++++ .../next.config.js | 6 + .../pages/index.jsx | 11 ++ 21 files changed, 546 insertions(+), 253 deletions(-) create mode 100644 packages/next/src/server/future/helpers/i18n-provider.test.ts create mode 100644 packages/next/src/server/future/helpers/i18n-provider.ts create mode 100644 test/e2e/i18n-disallow-multiple-locales/i18n-disallow-multiple-locales.test.ts create mode 100644 test/e2e/i18n-disallow-multiple-locales/next.config.js create mode 100644 test/e2e/i18n-disallow-multiple-locales/pages/index.jsx diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index f84d26b6e96a1..21518094e1298 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -57,9 +57,7 @@ import { isBot } from '../shared/lib/router/utils/is-bot' import RenderResult from './render-result' import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash' import { denormalizePagePath } from '../shared/lib/page-path/denormalize-page-path' -import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import * as Log from '../build/output/log' -import { detectDomainLocale } from '../shared/lib/i18n/detect-domain-locale' import escapePathDelimiters from '../shared/lib/router/utils/escape-path-delimiters' import { getUtils } from '../build/webpack/loaders/next-serverless-loader/utils' import isError, { getProperError } from '../lib/is-error' @@ -95,6 +93,7 @@ import { ServerManifestLoader } from './future/route-matcher-providers/helpers/m import { getTracer } from './lib/trace/tracer' import { BaseServerSpan } from './lib/trace/constants' import { sendResponse } from './future/route-handlers/app-route-route-handler' +import { I18NProvider } from './future/helpers/i18n-provider' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -330,6 +329,7 @@ export default abstract class Server { // TODO-APP(@wyattjoh): Make protected again. Used for turbopack in route-resolver.ts right now. public readonly matchers: RouteMatcherManager protected readonly handlers: RouteHandlerManager + protected readonly i18nProvider?: I18NProvider protected readonly localeNormalizer?: LocaleRouteNormalizer public constructor(options: ServerOptions) { @@ -363,14 +363,14 @@ export default abstract class Server { this.publicDir = this.getPublicDir() this.hasStaticDir = !minimalMode && this.getHasStaticDir() + this.i18nProvider = this.nextConfig.i18n?.locales + ? new I18NProvider(this.nextConfig.i18n) + : undefined + // Configure the locale normalizer, it's used for routes inside `pages/`. - this.localeNormalizer = - this.nextConfig.i18n?.locales && this.nextConfig.i18n.defaultLocale - ? new LocaleRouteNormalizer( - this.nextConfig.i18n.locales, - this.nextConfig.i18n.defaultLocale - ) - : undefined + this.localeNormalizer = this.i18nProvider + ? new LocaleRouteNormalizer(this.i18nProvider) + : undefined // Only serverRuntimeConfig needs the default // publicRuntimeConfig gets it's default in client/index.js @@ -481,7 +481,7 @@ export default abstract class Server { new PagesRouteMatcherProvider( this.distDir, manifestLoader, - this.localeNormalizer + this.i18nProvider ) ) @@ -490,7 +490,7 @@ export default abstract class Server { new PagesAPIRouteMatcherProvider( this.distDir, manifestLoader, - this.localeNormalizer + this.i18nProvider ) ) @@ -600,19 +600,19 @@ export default abstract class Server { this.attachRequestMeta(req, parsedUrl) - const domainLocale = detectDomainLocale( - this.nextConfig.i18n?.domains, + const domainLocale = this.i18nProvider?.detectDomainLocale( getHostname(parsedUrl, req.headers) ) const defaultLocale = domainLocale?.defaultLocale || this.nextConfig.i18n?.defaultLocale + parsedUrl.query.__nextDefaultLocale = defaultLocale const url = parseUrlUtil(req.url.replace(/^\/+/, '/')) const pathnameInfo = getNextPathnameInfo(url.pathname, { nextConfig: this.nextConfig, + i18nProvider: this.i18nProvider, }) - url.pathname = pathnameInfo.pathname if (pathnameInfo.basePath) { @@ -655,7 +655,9 @@ export default abstract class Server { // Perform locale detection and normalization. const options: MatchOptions = { - i18n: this.localeNormalizer?.match(matchedPath), + i18n: this.i18nProvider?.analyze(matchedPath, { + defaultLocale: undefined, + }), } if (options.i18n?.detectedLocale) { parsedUrl.query.__nextLocale = options.i18n.detectedLocale @@ -683,11 +685,13 @@ export default abstract class Server { basePath: this.nextConfig.basePath, rewrites: this.customRoutes.rewrites, }) - // ensure parsedUrl.pathname includes URL before processing - // rewrites or they won't match correctly + + // Ensure parsedUrl.pathname includes locale before processing + // rewrites or they won't match correctly. if (defaultLocale && !pathnameInfo.locale) { parsedUrl.pathname = `/${defaultLocale}${parsedUrl.pathname}` } + const pathnameBeforeRewrite = parsedUrl.pathname const rewriteParams = utils.handleRewrites(req, parsedUrl) const rewriteParamKeys = Object.keys(rewriteParams) @@ -793,7 +797,6 @@ export default abstract class Server { addRequestMeta(req, '__nextHadTrailingSlash', pathnameInfo.trailingSlash) addRequestMeta(req, '__nextIsLocaleDomain', Boolean(domainLocale)) - parsedUrl.query.__nextDefaultLocale = defaultLocale if (pathnameInfo.locale) { req.url = formatUrl(url) @@ -951,12 +954,7 @@ export default abstract class Server { private async pipe( fn: (ctx: RequestContext) => Promise, - partialContext: { - req: BaseNextRequest - res: BaseNextResponse - pathname: string - query: NextParsedUrlQuery - } + partialContext: Omit ): Promise { return getTracer().trace(BaseServerSpan.pipe, async () => this.pipeImpl(fn, partialContext) @@ -965,22 +963,17 @@ export default abstract class Server { private async pipeImpl( fn: (ctx: RequestContext) => Promise, - partialContext: { - req: BaseNextRequest - res: BaseNextResponse - pathname: string - query: NextParsedUrlQuery - } + partialContext: Omit ): Promise { const isBotRequest = isBot(partialContext.req.headers['user-agent'] || '') - const ctx = { + const ctx: RequestContext = { ...partialContext, renderOpts: { ...this.renderOpts, supportsDynamicHTML: !isBotRequest, isBot: !!isBotRequest, }, - } as const + } const payload = await fn(ctx) if (payload === null) { return @@ -1005,20 +998,16 @@ export default abstract class Server { private async getStaticHTML( fn: (ctx: RequestContext) => Promise, - partialContext: { - req: BaseNextRequest - res: BaseNextResponse - pathname: string - query: ParsedUrlQuery - } + partialContext: Omit ): Promise { - const payload = await fn({ + const ctx: RequestContext = { ...partialContext, renderOpts: { ...this.renderOpts, supportsDynamicHTML: false, }, - }) + } + const payload = await fn(ctx) if (payload === null) { return null } @@ -1352,10 +1341,10 @@ export default abstract class Server { } urlPathname = removeTrailingSlash(urlPathname) - resolvedUrlPathname = normalizeLocalePath( - removeTrailingSlash(resolvedUrlPathname), - this.nextConfig.i18n?.locales - ).pathname + resolvedUrlPathname = removeTrailingSlash(resolvedUrlPathname) + if (this.localeNormalizer) { + resolvedUrlPathname = this.localeNormalizer.normalize(resolvedUrlPathname) + } const handleRedirect = (pageData: any) => { const redirect = { @@ -1787,15 +1776,7 @@ export default abstract class Server { if (this.renderOpts.dev) { query.__nextNotFoundSrcPage = pathname } - await this.render404( - req, - res, - { - pathname, - query, - } as UrlWithParsedQuery, - false - ) + await this.render404(req, res, { pathname, query }, false) return null } } else if (cachedData.kind === 'REDIRECT') { @@ -1864,9 +1845,8 @@ export default abstract class Server { path = denormalizePagePath(splitPath.replace(/\.json$/, '')) } - if (this.nextConfig.i18n && stripLocale) { - const { locales } = this.nextConfig.i18n - return normalizeLocalePath(path, locales).pathname + if (this.localeNormalizer && stripLocale) { + return this.localeNormalizer.normalize(path) } return path } @@ -1941,7 +1921,9 @@ export default abstract class Server { delete query._nextBubbleNoFallback const options: MatchOptions = { - i18n: this.localeNormalizer?.match(pathname), + i18n: this.i18nProvider?.analyze(pathname, { + defaultLocale: undefined, + }), } try { @@ -2305,18 +2287,14 @@ export default abstract class Server { public async render404( req: BaseNextRequest, res: BaseNextResponse, - parsedUrl?: NextUrlWithParsedQuery, + parsedUrl?: Pick, setHeaders = true ): Promise { - const { pathname, query }: NextUrlWithParsedQuery = parsedUrl - ? parsedUrl - : parseUrl(req.url!, true) + const { pathname, query } = parsedUrl ? parsedUrl : parseUrl(req.url!, true) if (this.nextConfig.i18n) { - query.__nextLocale = - query.__nextLocale || this.nextConfig.i18n.defaultLocale - query.__nextDefaultLocale = - query.__nextDefaultLocale || this.nextConfig.i18n.defaultLocale + query.__nextLocale ||= this.nextConfig.i18n.defaultLocale + query.__nextDefaultLocale ||= this.nextConfig.i18n.defaultLocale } res.statusCode = 404 diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 4de6dd4d489e0..3b48a16a5f5ff 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -322,7 +322,9 @@ export default class DevServer extends Server { distDir: this.distDir, buildId: this.buildId, } - ) // In development we can't give a default path mapping + ) + + // In development we can't give a default path mapping for (const path in exportPathMap) { const { page, query = {} } = exportPathMap[path] @@ -1159,6 +1161,7 @@ export default class DevServer extends Server { const { basePath } = this.nextConfig let originalPathname: string | null = null + // TODO: see if we can remove this in the future if (basePath && pathHasPrefix(parsedUrl.pathname || '/', basePath)) { // strip basePath before handling dev bundles // If replace ends up replacing the full url it'll be `undefined`, meaning we have to default it to `/` @@ -1174,15 +1177,14 @@ export default class DevServer extends Server { } } - const { finished = false } = - (await this.hotReloader?.run( + if (this.hotReloader) { + const { finished = false } = await this.hotReloader.run( req.originalRequest, res.originalResponse, parsedUrl - )) || {} + ) - if (finished) { - return + if (finished) return } if (originalPathname) { diff --git a/packages/next/src/server/future/helpers/i18n-provider.test.ts b/packages/next/src/server/future/helpers/i18n-provider.test.ts new file mode 100644 index 0000000000000..633d42e6bf48f --- /dev/null +++ b/packages/next/src/server/future/helpers/i18n-provider.test.ts @@ -0,0 +1,63 @@ +import { I18NProvider } from './i18n-provider' + +describe('I18NProvider', () => { + const provider = new I18NProvider({ + defaultLocale: 'en', + locales: ['en', 'fr', 'en-CA'], + domains: [ + { + domain: 'example.com', + defaultLocale: 'en', + locales: ['en-CA'], + }, + { + domain: 'example.fr', + defaultLocale: 'fr', + }, + ], + }) + + it('should detect the correct domain locale', () => { + expect(provider.detectDomainLocale('example.com')).toEqual({ + domain: 'example.com', + defaultLocale: 'en', + locales: ['en-CA'], + }) + expect(provider.detectDomainLocale('example.fr')).toEqual({ + domain: 'example.fr', + defaultLocale: 'fr', + }) + expect(provider.detectDomainLocale('example.de')).toBeUndefined() + }) + + describe('analyze', () => { + it('should detect the correct default locale', () => { + expect(provider.analyze('/fr', { defaultLocale: undefined })).toEqual({ + pathname: '/', + detectedLocale: 'fr', + }) + expect( + provider.analyze('/fr/another/page', { defaultLocale: undefined }) + ).toEqual({ + pathname: '/another/page', + detectedLocale: 'fr', + }) + expect( + provider.analyze('/another/page', { + defaultLocale: 'en-CA', + }) + ).toEqual({ + pathname: '/another/page', + detectedLocale: 'en-CA', + }) + expect( + provider.analyze('/en/another/page', { + defaultLocale: 'en-CA', + }) + ).toEqual({ + pathname: '/another/page', + detectedLocale: 'en', + }) + }) + }) +}) diff --git a/packages/next/src/server/future/helpers/i18n-provider.ts b/packages/next/src/server/future/helpers/i18n-provider.ts new file mode 100644 index 0000000000000..6ba5a94a8e086 --- /dev/null +++ b/packages/next/src/server/future/helpers/i18n-provider.ts @@ -0,0 +1,126 @@ +import { DomainLocale, I18NConfig } from '../../config-shared' + +/** + * The result of matching a locale aware route. + */ +interface LocaleAnalysisResult { + /** + * The pathname without the locale prefix (if any). + */ + pathname: string + + /** + * The detected locale. If no locale was detected, this will be `undefined`. + */ + detectedLocale?: string +} + +type LocaleAnalysisOptions = { + /** + * When provided, it will be used as the default locale if the locale + * cannot be inferred from the pathname. + */ + defaultLocale: string | undefined +} + +/** + * The I18NProvider is used to match locale aware routes, detect the locale from + * the pathname and hostname and normalize the pathname by removing the locale + * prefix. + */ +export class I18NProvider { + private readonly lowerCaseLocales: ReadonlyArray + private readonly lowerCaseDomains?: ReadonlyArray< + DomainLocale & { + // The configuration references a domain with an optional port, but the + // hostname is always the domain without the port and is used for + // matching. + hostname: string + } + > + + constructor(public readonly config: I18NConfig) { + if (!config.locales.length) { + throw new Error('Invariant: No locales provided') + } + + this.lowerCaseLocales = config.locales.map((locale) => locale.toLowerCase()) + this.lowerCaseDomains = config.domains?.map((domainLocale) => { + const domain = domainLocale.domain.toLowerCase() + return { + defaultLocale: domainLocale.defaultLocale.toLowerCase(), + hostname: domain.split(':')[0], + domain, + locales: domainLocale.locales?.map((locale) => locale.toLowerCase()), + http: domainLocale.http, + } + }) + } + + /** + * Detects the domain locale from the hostname and the detected locale if + * provided. + * + * @param hostname The hostname to detect the domain locale from, this must be lowercased. + * @param detectedLocale The detected locale to use if the hostname does not match. + * @returns The domain locale if found, `undefined` otherwise. + */ + public detectDomainLocale( + hostname?: string, + detectedLocale?: string + ): DomainLocale | undefined { + if (!hostname || !this.lowerCaseDomains || !this.config.domains) return + + if (detectedLocale) detectedLocale = detectedLocale.toLowerCase() + + for (let i = 0; i < this.lowerCaseDomains.length; i++) { + const domainLocale = this.lowerCaseDomains[i] + if ( + // We assume that the hostname is already lowercased. + domainLocale.hostname === hostname || + // Configuration validation ensures that the locale is not repeated in + // other domains locales. + domainLocale.locales?.some((locale) => locale === detectedLocale) + ) { + return this.config.domains[i] + } + } + + return + } + + /** + * Analyzes the pathname for a locale and returns the pathname without it. + * + * @param pathname The pathname that could contain a locale prefix. + * @param options The options to use when matching the locale. + * @returns The matched locale and the pathname without the locale prefix + * (if any). + */ + public analyze( + pathname: string, + options: LocaleAnalysisOptions + ): LocaleAnalysisResult { + let detectedLocale: string | undefined = options.defaultLocale + + // The first segment will be empty, because it has a leading `/`. If + // there is no further segment, there is no locale. + const segments = pathname.split('/') + if (!segments[1]) return { detectedLocale, pathname } + + // The second segment will contain the locale part if any. + const segment = segments[1].toLowerCase() + + // See if the segment matches one of the locales. + const index = this.lowerCaseLocales.indexOf(segment) + if (index < 0) return { detectedLocale, pathname } + + // Return the case-sensitive locale. + detectedLocale = this.config.locales[index] + + // Remove the `/${locale}` part of the pathname. + pathname = pathname.slice(detectedLocale.length + 1) || '/' + + return { detectedLocale, pathname } + } +} diff --git a/packages/next/src/server/future/normalizers/locale-route-normalizer.ts b/packages/next/src/server/future/normalizers/locale-route-normalizer.ts index 323247501b04e..14bdee821978d 100644 --- a/packages/next/src/server/future/normalizers/locale-route-normalizer.ts +++ b/packages/next/src/server/future/normalizers/locale-route-normalizer.ts @@ -1,59 +1,25 @@ +import { I18NProvider } from '../helpers/i18n-provider' import { Normalizer } from './normalizer' -export interface LocaleRouteNormalizer extends Normalizer { - readonly locales: ReadonlyArray - readonly defaultLocale: string - match( - pathname: string, - options?: { inferDefaultLocale: boolean } - ): { detectedLocale?: string; pathname: string } -} - +/** + * Normalizes the pathname by removing the locale prefix if any. + */ export class LocaleRouteNormalizer implements Normalizer { - private readonly lowerCase: ReadonlyArray - - constructor( - public readonly locales: ReadonlyArray, - public readonly defaultLocale: string - ) { - this.lowerCase = locales.map((locale) => locale.toLowerCase()) - } - - public match( - pathname: string, - options?: { inferDefaultLocale: boolean } - ): { - pathname: string - detectedLocale?: string - } { - let detectedLocale: string | undefined = options?.inferDefaultLocale - ? this.defaultLocale - : undefined - if (this.locales.length === 0) return { detectedLocale, pathname } - - // The first segment will be empty, because it has a leading `/`. If - // there is no further segment, there is no locale. - const segments = pathname.split('/') - if (!segments[1]) return { detectedLocale, pathname } - - // The second segment will contain the locale part if any. - const segment = segments[1].toLowerCase() - - // See if the segment matches one of the locales. - const index = this.lowerCase.indexOf(segment) - if (index < 0) return { detectedLocale, pathname } - - // Return the case-sensitive locale. - detectedLocale = this.locales[index] - - // Remove the `/${locale}` part of the pathname. - pathname = pathname.slice(detectedLocale.length + 1) || '/' - - return { detectedLocale, pathname } - } - + constructor(private readonly provider: I18NProvider) {} + + /** + * Normalizes the pathname by removing the locale prefix if any. + * + * @param pathname The pathname to normalize. + * @returns The pathname without the locale prefix (if any). + */ public normalize(pathname: string): string { - const match = this.match(pathname) + const match = this.provider.analyze(pathname, { + // We aren't using the detected locale, so we can pass `undefined` because + // we don't need to infer the default locale. + defaultLocale: undefined, + }) + return match.pathname } } diff --git a/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts index 0f736116d0eaf..82c5dffae6a89 100644 --- a/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts @@ -12,13 +12,13 @@ import { ManifestLoader, } from './helpers/manifest-loaders/manifest-loader' import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider' -import { LocaleRouteNormalizer } from '../normalizers/locale-route-normalizer' +import { I18NProvider } from '../helpers/i18n-provider' export class PagesAPIRouteMatcherProvider extends ManifestRouteMatcherProvider { constructor( private readonly distDir: string, manifestLoader: ManifestLoader, - private readonly localeNormalizer?: LocaleRouteNormalizer + private readonly i18nProvider?: I18NProvider ) { super(PAGES_MANIFEST, manifestLoader) } @@ -34,9 +34,14 @@ export class PagesAPIRouteMatcherProvider extends ManifestRouteMatcherProvider

= [] for (const page of pathnames) { - if (this.localeNormalizer) { + if (this.i18nProvider) { // Match the locale on the page name, or default to the default locale. - const { detectedLocale, pathname } = this.localeNormalizer.match(page) + const { detectedLocale, pathname } = this.i18nProvider.analyze(page, { + // We don't need to assume a default locale here, since we're + // generating the routes which either should support a specific locale + // or any locale. + defaultLocale: undefined, + }) matchers.push( new PagesAPILocaleRouteMatcher({ diff --git a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts index fd45776ac730e..49ab3fca32b5d 100644 --- a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts @@ -1,5 +1,5 @@ import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../../shared/lib/constants' -import { LocaleRouteNormalizer } from '../normalizers/locale-route-normalizer' +import { I18NProvider } from '../helpers/i18n-provider' import { PagesRouteDefinition } from '../route-definitions/pages-route-definition' import { RouteKind } from '../route-kind' import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' @@ -17,7 +17,7 @@ describe('PagesRouteMatcherProvider', () => { describe.each<{ manifest: Record routes: ReadonlyArray - i18n: { locales: ReadonlyArray; defaultLocale: string } + i18n: { locales: Array; defaultLocale: string } }>([ { manifest: { @@ -116,7 +116,7 @@ describe('PagesRouteMatcherProvider', () => { const provider = new PagesRouteMatcherProvider( '', loader, - new LocaleRouteNormalizer(i18n.locales, i18n.defaultLocale) + new I18NProvider(i18n) ) const matchers = await provider.matchers() diff --git a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts index 061a53ba41d66..176de4901c38a 100644 --- a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts @@ -6,7 +6,6 @@ import { SERVER_DIRECTORY, } from '../../../shared/lib/constants' import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path' -import { LocaleRouteNormalizer } from '../normalizers/locale-route-normalizer' import { RouteKind } from '../route-kind' import { PagesLocaleRouteMatcher, @@ -17,12 +16,13 @@ import { ManifestLoader, } from './helpers/manifest-loaders/manifest-loader' import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider' +import { I18NProvider } from '../helpers/i18n-provider' export class PagesRouteMatcherProvider extends ManifestRouteMatcherProvider { constructor( private readonly distDir: string, manifestLoader: ManifestLoader, - private readonly localeNormalizer?: LocaleRouteNormalizer + private readonly i18nProvider?: I18NProvider ) { super(PAGES_MANIFEST, manifestLoader) } @@ -38,7 +38,9 @@ export class PagesRouteMatcherProvider extends ManifestRouteMatcherProvider { const normalized = - this.localeNormalizer?.normalize(pathname) ?? pathname + this.i18nProvider?.analyze(pathname, { + defaultLocale: undefined, + }).pathname ?? pathname // Skip any blocked pages. if (BLOCKED_PAGES.includes(normalized)) return false @@ -48,9 +50,14 @@ export class PagesRouteMatcherProvider extends ManifestRouteMatcherProvider = [] for (const page of pathnames) { - if (this.localeNormalizer) { + if (this.i18nProvider) { // Match the locale on the page name, or default to the default locale. - const { detectedLocale, pathname } = this.localeNormalizer.match(page) + const { detectedLocale, pathname } = this.i18nProvider.analyze(page, { + // We don't need to assume a default locale here, since we're + // generating the routes which either should support a specific locale + // or any locale. + defaultLocale: undefined, + }) matchers.push( new PagesLocaleRouteMatcher({ diff --git a/packages/next/src/server/future/route-matchers/locale-route-matcher.ts b/packages/next/src/server/future/route-matchers/locale-route-matcher.ts index dc72787a0f79e..96dec97ede1ea 100644 --- a/packages/next/src/server/future/route-matchers/locale-route-matcher.ts +++ b/packages/next/src/server/future/route-matchers/locale-route-matcher.ts @@ -10,13 +10,21 @@ export type LocaleMatcherMatchOptions = { */ i18n?: { /** - * The locale that was detected on the incoming route. If undefined it means - * that the locale should be considered to be the default one. + * The locale that was detected on the incoming route. If `undefined` it + * means that the locale should be considered to be the default one. + * + * For example, if the default locale is `en` and the incoming route is + * `/about`, then this would be `undefined`. If the incoming route was + * `/en/about`, then this would be `en`. If the incoming route was + * `/fr/about` then this would be `fr`. */ detectedLocale?: string /** - * The pathname that has had it's locale information stripped from. + * The pathname that has had it's locale information stripped from it. + * + * For example, if the pathname previous was `/en/about` and the locale was + * `en`, then this would be `/about`. */ pathname: string } @@ -34,6 +42,14 @@ export class LocaleRouteMatcher< return `${this.definition.pathname}?__nextLocale=${this.definition.i18n?.locale}` } + /** + * Match will attempt to match the given pathname against this route while + * also taking into account the locale information. + * + * @param pathname The pathname to match against. + * @param options The options to use when matching. + * @returns The match result, or `null` if there was no match. + */ public match( pathname: string, options?: LocaleMatcherMatchOptions @@ -47,12 +63,23 @@ export class LocaleRouteMatcher< definition: this.definition, params: result.params, detectedLocale: + // If the options have a detected locale, then use that, otherwise use + // the route's locale. options?.i18n?.detectedLocale ?? this.definition.i18n?.locale, } } + /** + * Test will attempt to match the given pathname against this route while + * also taking into account the locale information. + * + * @param pathname The pathname to match against. + * @param options The options to use when matching. + * @returns The match result, or `null` if there was no match. + */ public test(pathname: string, options?: LocaleMatcherMatchOptions) { - // If this route has locale information... + // If this route has locale information and we have detected a locale, then + // we need to compare the detected locale to the route's locale. if (this.definition.i18n && options?.i18n) { // If we have detected a locale and it does not match this route's locale, // then this isn't a match! @@ -69,6 +96,8 @@ export class LocaleRouteMatcher< return super.test(options.i18n.pathname) } + // If we don't have locale information, then we can just perform regular + // matching. return super.test(pathname) } } diff --git a/packages/next/src/server/future/route-matchers/route-matcher.ts b/packages/next/src/server/future/route-matchers/route-matcher.ts index 8be68356d5277..3936d38154518 100644 --- a/packages/next/src/server/future/route-matchers/route-matcher.ts +++ b/packages/next/src/server/future/route-matchers/route-matcher.ts @@ -8,6 +8,10 @@ import { getRouteRegex } from '../../../shared/lib/router/utils/route-regex' import { RouteDefinition } from '../route-definitions/route-definition' import { RouteMatch } from '../route-matches/route-match' +type RouteMatchResult = { + params?: Params +} + export class RouteMatcher { private readonly dynamic?: RouteMatchFn @@ -44,7 +48,7 @@ export class RouteMatcher { return { definition: this.definition, params: result.params } } - public test(pathname: string): { params?: Params } | null { + public test(pathname: string): RouteMatchResult | null { if (this.dynamic) { const params = this.dynamic(pathname) if (!params) return null diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index ea84ee0d6ef51..3eb23a86b45e7 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -47,9 +47,6 @@ import { format as formatUrl, UrlWithParsedQuery } from 'url' import { getPathMatch } from '../shared/lib/router/utils/path-match' import { createHeaderRoute, createRedirectRoute } from './server-route-utils' import getRouteFromAssetPath from '../shared/lib/router/utils/get-route-from-asset-path' - -import { detectDomainLocale } from '../shared/lib/i18n/detect-domain-locale' - import { NodeNextRequest, NodeNextResponse } from './base-http/node' import { sendRenderResult } from './send-payload' import { getExtension, serveStatic } from './serve-static' @@ -76,7 +73,6 @@ import { FontManifest } from './font-utils' import { splitCookiesString, toNodeHeaders } from './web/utils' import { relativizeURL } from '../shared/lib/router/utils/relativize-url' import { prepareDestination } from '../shared/lib/router/utils/prepare-destination' -import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import { getMiddlewareRouteMatcher } from '../shared/lib/router/utils/middleware-route-matcher' import { loadEnvConfig } from '@next/env' import { getCustomRoute, stringifyQuery } from './server-route-utils' @@ -1136,23 +1132,22 @@ export default class NextNodeServer extends BaseServer { const { host } = req?.headers || {} // remove port from host and remove port if present const hostname = host?.split(':')[0].toLowerCase() - const localePathResult = normalizeLocalePath( - pathname, - this.nextConfig.i18n.locales - ) - const { defaultLocale } = - detectDomainLocale(this.nextConfig.i18n.domains, hostname) || {} + + const domainLocale = this.i18nProvider?.detectDomainLocale(hostname) + const localePathResult = this.i18nProvider?.analyze(pathname, { + defaultLocale: undefined, + }) let detectedLocale = '' - if (localePathResult.detectedLocale) { + if (localePathResult?.detectedLocale) { pathname = localePathResult.pathname detectedLocale = localePathResult.detectedLocale } _parsedUrl.query.__nextLocale = detectedLocale _parsedUrl.query.__nextDefaultLocale = - defaultLocale || this.nextConfig.i18n.defaultLocale + domainLocale?.defaultLocale ?? this.nextConfig.i18n.defaultLocale if (!detectedLocale && !this.router.catchAllMiddleware[0]) { _parsedUrl.query.__nextLocale = @@ -1217,25 +1212,29 @@ export default class NextNodeServer extends BaseServer { throw new Error('pathname is undefined') } + const bubbleNoFallback = Boolean(query._nextBubbleNoFallback) + // next.js core assumes page path without trailing slash pathname = removeTrailingSlash(pathname) const options: MatchOptions = { - i18n: this.localeNormalizer?.match(pathname), + i18n: this.i18nProvider?.analyze(pathname, { + defaultLocale: undefined, + }), } + if (options.i18n?.detectedLocale) { parsedUrl.query.__nextLocale = options.i18n.detectedLocale } - const bubbleNoFallback = !!query._nextBubbleNoFallback const match = await this.matchers.match(pathname, options) + // Try to handle the given route with the configured handlers. if (match) { + // Add the match to the request so we don't have to re-run the matcher + // for the same request. addRequestMeta(req, '_nextMatch', match) - } - // Try to handle the given route with the configured handlers. - if (match) { // TODO-APP: move this to a route handler const edgeFunctionsPages = this.getEdgeFunctionsPages() for (const edgeFunctionsPage of edgeFunctionsPages) { @@ -1320,7 +1319,7 @@ export default class NextNodeServer extends BaseServer { useFileSystemPublicRoutes, matchers: this.matchers, nextConfig: this.nextConfig, - localeNormalizer: this.localeNormalizer, + i18nProvider: this.i18nProvider, } } @@ -1761,7 +1760,9 @@ export default class NextNodeServer extends BaseServer { let url: string const options: MatchOptions = { - i18n: this.localeNormalizer?.match(normalizedPathname), + i18n: this.i18nProvider?.analyze(normalizedPathname, { + defaultLocale: undefined, + }), } if (this.nextConfig.skipMiddlewareUrlNormalize) { url = getRequestMeta(params.request, '__NEXT_INIT_URL')! @@ -1890,6 +1891,7 @@ export default class NextNodeServer extends BaseServer { const parsedUrl = parseUrl(initUrl) const pathnameInfo = getNextPathnameInfo(parsedUrl.pathname, { nextConfig: this.nextConfig, + i18nProvider: this.i18nProvider, }) parsedUrl.pathname = pathnameInfo.pathname @@ -2040,14 +2042,12 @@ export default class NextNodeServer extends BaseServer { ) } - if (this.nextConfig.i18n) { - const localePathResult = normalizeLocalePath( - newUrl, - this.nextConfig.i18n.locales - ) - if (localePathResult.detectedLocale) { - parsedDestination.query.__nextLocale = - localePathResult.detectedLocale + if (this.i18nProvider) { + const { detectedLocale } = this.i18nProvider.analyze(newUrl, { + defaultLocale: undefined, + }) + if (detectedLocale) { + parsedDestination.query.__nextLocale = detectedLocale } } diff --git a/packages/next/src/server/request-meta.ts b/packages/next/src/server/request-meta.ts index 1bee6fefa9478..b9bd9e086f5f6 100644 --- a/packages/next/src/server/request-meta.ts +++ b/packages/next/src/server/request-meta.ts @@ -17,7 +17,17 @@ export interface RequestMeta { __NEXT_INIT_URL?: string __NEXT_CLONABLE_BODY?: CloneableBody __nextHadTrailingSlash?: boolean + + /** + * True when the request matched a locale domain that was configured in the + * next.config.js file. + */ __nextIsLocaleDomain?: boolean + + /** + * True when the request had locale information stripped from the pathname + * part of the URL. + */ __nextStrippedLocale?: boolean _nextDidRewrite?: boolean _nextHadBasePath?: boolean diff --git a/packages/next/src/server/router.ts b/packages/next/src/server/router.ts index 150657673405f..0d500e7334b7d 100644 --- a/packages/next/src/server/router.ts +++ b/packages/next/src/server/router.ts @@ -24,7 +24,7 @@ import { RouteMatcherManager, } from './future/route-matcher-managers/route-matcher-manager' import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash' -import { LocaleRouteNormalizer } from './future/normalizers/locale-route-normalizer' +import type { I18NProvider } from './future/helpers/i18n-provider' import { getTracer } from './lib/trace/tracer' import { RouterSpan } from './lib/trace/constants' @@ -34,6 +34,14 @@ type RouteResult = { query?: ParsedUrlQuery } +type RouteFn = ( + req: BaseNextRequest, + res: BaseNextResponse, + params: Params, + parsedUrl: NextUrlWithParsedQuery, + upgradeHead?: Buffer +) => Promise | RouteResult + export type Route = { match: RouteMatchFn has?: RouteHas[] @@ -47,13 +55,7 @@ export type Route = { matchesLocaleAPIRoutes?: true matchesTrailingSlash?: true internal?: true - fn: ( - req: BaseNextRequest, - res: BaseNextResponse, - params: Params, - parsedUrl: NextUrlWithParsedQuery, - upgradeHead?: Buffer - ) => Promise | RouteResult + fn: RouteFn } export type RouterOptions = { @@ -70,7 +72,7 @@ export type RouterOptions = { matchers: RouteMatcherManager useFileSystemPublicRoutes: boolean nextConfig: NextConfig - localeNormalizer?: LocaleRouteNormalizer + i18nProvider?: I18NProvider } export type PageChecker = (pathname: string) => Promise @@ -87,10 +89,10 @@ export default class Router { fallback: ReadonlyArray } private readonly catchAllRoute: Route - private readonly matchers: Pick + private readonly matchers: RouteMatcherManager private readonly useFileSystemPublicRoutes: boolean private readonly nextConfig: NextConfig - private readonly localeNormalizer?: LocaleRouteNormalizer + private readonly i18nProvider?: I18NProvider private compiledRoutes: ReadonlyArray private needsRecompilation: boolean @@ -108,7 +110,7 @@ export default class Router { matchers, useFileSystemPublicRoutes, nextConfig, - localeNormalizer, + i18nProvider, }: RouterOptions) { this.nextConfig = nextConfig this.headers = headers @@ -119,7 +121,7 @@ export default class Router { this.catchAllMiddleware = catchAllMiddleware this.matchers = matchers this.useFileSystemPublicRoutes = useFileSystemPublicRoutes - this.localeNormalizer = localeNormalizer + this.i18nProvider = i18nProvider // Perform the initial route compilation. this.compiledRoutes = this.compileRoutes() @@ -176,6 +178,7 @@ export default class Router { ? [ { type: 'route', + matchesLocale: true, name: 'page checker', match: getPathMatch('/:path*'), fn: async (req, res, params, parsedUrl, upgradeHead) => { @@ -188,15 +191,17 @@ export default class Router { // step we're processing the afterFiles rewrites which must // not include dynamic matches. skipDynamic: true, - i18n: this.localeNormalizer?.match(pathname, { - // TODO: verify changing the default locale - inferDefaultLocale: true, + i18n: this.i18nProvider?.analyze(pathname, { + defaultLocale: undefined, }), } - const match = await this.matchers.test(pathname, options) + const match = await this.matchers.match(pathname, options) if (!match) return { finished: false } + // Add the match so we can get it later. + addRequestMeta(req, '_nextMatch', match) + return this.catchAllRoute.fn( req, res, @@ -275,9 +280,8 @@ export default class Router { // Normalize and detect the locale on the pathname. const options: MatchOptions = { - i18n: this.localeNormalizer?.match(fsPathname, { - // TODO: verify changing the default locale - inferDefaultLocale: true, + i18n: this.i18nProvider?.analyze(fsPathname, { + defaultLocale: undefined, }), } @@ -351,23 +355,27 @@ export default class Router { continue } + // Update the `basePath` if the request had a `basePath`. if (getRequestMeta(req, '_nextHadBasePath')) { pathnameInfo.basePath = this.basePath } + // Create a copy of the `basePath` so we can modify it for the next + // request if the route doesn't match with the `basePath`. const basePath = pathnameInfo.basePath if (!route.matchesBasePath) { pathnameInfo.basePath = '' } - if ( - route.matchesLocale && - parsedUrlUpdated.query.__nextLocale && - !pathnameInfo.locale - ) { - pathnameInfo.locale = parsedUrlUpdated.query.__nextLocale + // Add the locale to the information if the route supports matching + // locales and the locale is not present in the info. + const locale = parsedUrlUpdated.query.__nextLocale + if (route.matchesLocale && locale && !pathnameInfo.locale) { + pathnameInfo.locale = locale } + // If the route doesn't support matching locales and the locale is the + // default locale then remove it from the info. if ( !route.matchesLocale && pathnameInfo.locale === this.nextConfig.i18n?.defaultLocale && @@ -376,6 +384,8 @@ export default class Router { pathnameInfo.locale = undefined } + // If the route doesn't support trailing slashes and the request had a + // trailing slash then remove it from the info. if ( route.matchesTrailingSlash && getRequestMeta(req, '__nextHadTrailingSlash') @@ -383,6 +393,7 @@ export default class Router { pathnameInfo.trailingSlash = true } + // Construct a new pathname based on the info. const matchPathname = formatNextPathnameInfo({ ignorePrefix: true, ...pathnameInfo, @@ -403,11 +414,9 @@ export default class Router { } } - /** - * If it is a matcher that doesn't match the basePath (like the public - * directory) but Next.js is configured to use a basePath that was - * never there, we consider this an invalid match and keep routing. - */ + // If it is a matcher that doesn't match the basePath (like the public + // directory) but Next.js is configured to use a basePath that was + // never there, we consider this an invalid match and keep routing. if ( params && this.basePath && @@ -440,6 +449,8 @@ export default class Router { return true } + // If the result includes a pathname then we need to update the + // parsed url pathname to continue routing. if (result.pathname) { parsedUrlUpdated.pathname = result.pathname } else { @@ -448,6 +459,8 @@ export default class Router { parsedUrlUpdated.pathname = originalPathname } + // Copy over only internal query parameters from the original query and + // merge with the result query. if (result.query) { parsedUrlUpdated.query = { ...getNextInternalQuery(parsedUrlUpdated.query), diff --git a/packages/next/src/server/web-server.ts b/packages/next/src/server/web-server.ts index 918f99d641533..d0edc6286a1a7 100644 --- a/packages/next/src/server/web-server.ts +++ b/packages/next/src/server/web-server.ts @@ -17,7 +17,6 @@ import WebResponseCache from './response-cache/web' import { isAPIRoute } from '../lib/is-api-route' import { getPathMatch } from '../shared/lib/router/utils/path-match' import getRouteFromAssetPath from '../shared/lib/router/utils/get-route-from-asset-path' -import { detectDomainLocale } from '../shared/lib/i18n/detect-domain-locale' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash' import { isDynamicRoute } from '../shared/lib/router/utils' @@ -235,8 +234,7 @@ export default class NextWebServer extends BaseServer { pathname, this.nextConfig.i18n.locales ) - const { defaultLocale } = - detectDomainLocale(this.nextConfig.i18n.domains, hostname) || {} + const domainLocale = this.i18nProvider?.detectDomainLocale(hostname) let detectedLocale = '' @@ -247,7 +245,7 @@ export default class NextWebServer extends BaseServer { _parsedUrl.query.__nextLocale = detectedLocale _parsedUrl.query.__nextDefaultLocale = - defaultLocale || this.nextConfig.i18n.defaultLocale + domainLocale?.defaultLocale || this.nextConfig.i18n.defaultLocale if (!detectedLocale && !this.router.catchAllMiddleware[0]) { _parsedUrl.query.__nextLocale = @@ -310,16 +308,15 @@ export default class NextWebServer extends BaseServer { // next.js core assumes page path without trailing slash pathname = removeTrailingSlash(pathname) - if (this.nextConfig.i18n) { - const localePathResult = normalizeLocalePath( - pathname, - this.nextConfig.i18n?.locales - ) - - if (localePathResult.detectedLocale) { - parsedUrl.query.__nextLocale = localePathResult.detectedLocale + if (this.i18nProvider) { + const { detectedLocale } = await this.i18nProvider.analyze(pathname, { + defaultLocale: undefined, + }) + if (detectedLocale) { + parsedUrl.query.__nextLocale = detectedLocale } } + const bubbleNoFallback = !!query._nextBubbleNoFallback if (isAPIRoute(pathname)) { diff --git a/packages/next/src/server/web/next-url.ts b/packages/next/src/server/web/next-url.ts index 8bef9c57ea9a3..d74dc1d58065d 100644 --- a/packages/next/src/server/web/next-url.ts +++ b/packages/next/src/server/web/next-url.ts @@ -3,6 +3,7 @@ import { detectDomainLocale } from '../../shared/lib/i18n/detect-domain-locale' import { formatNextPathnameInfo } from '../../shared/lib/router/utils/format-next-pathname-info' import { getHostname } from '../../shared/lib/get-hostname' import { getNextPathnameInfo } from '../../shared/lib/router/utils/get-next-pathname-info' +import type { I18NProvider } from '../future/helpers/i18n-provider' interface Options { base?: string | URL @@ -13,6 +14,7 @@ interface Options { i18n?: I18NConfig | null trailingSlash?: boolean } + i18nProvider?: I18NProvider } const REGEX_LOCALHOST_HOSTNAME = @@ -28,7 +30,7 @@ function parseURL(url: string | URL, base?: string | URL) { const Internal = Symbol('NextURLInternal') export class NextURL { - [Internal]: { + private [Internal]: { basePath: string buildId?: string flightSearchParameters?: Record @@ -66,30 +68,37 @@ export class NextURL { basePath: '', } - this.analyzeUrl() + this.analyze() } - private analyzeUrl() { - const pathnameInfo = getNextPathnameInfo(this[Internal].url.pathname, { + private analyze() { + const info = getNextPathnameInfo(this[Internal].url.pathname, { nextConfig: this[Internal].options.nextConfig, parseData: !process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE, + i18nProvider: this[Internal].options.i18nProvider, }) - this[Internal].domainLocale = detectDomainLocale( - this[Internal].options.nextConfig?.i18n?.domains, - getHostname(this[Internal].url, this[Internal].options.headers) + const hostname = getHostname( + this[Internal].url, + this[Internal].options.headers ) + this[Internal].domainLocale = this[Internal].options.i18nProvider + ? this[Internal].options.i18nProvider.detectDomainLocale(hostname) + : detectDomainLocale( + this[Internal].options.nextConfig?.i18n?.domains, + hostname + ) const defaultLocale = this[Internal].domainLocale?.defaultLocale || this[Internal].options.nextConfig?.i18n?.defaultLocale - this[Internal].url.pathname = pathnameInfo.pathname + this[Internal].url.pathname = info.pathname this[Internal].defaultLocale = defaultLocale - this[Internal].basePath = pathnameInfo.basePath ?? '' - this[Internal].buildId = pathnameInfo.buildId - this[Internal].locale = pathnameInfo.locale ?? defaultLocale - this[Internal].trailingSlash = pathnameInfo.trailingSlash + this[Internal].basePath = info.basePath ?? '' + this[Internal].buildId = info.buildId + this[Internal].locale = info.locale ?? defaultLocale + this[Internal].trailingSlash = info.trailingSlash } private formatPathname() { @@ -186,7 +195,7 @@ export class NextURL { set href(url: string) { this[Internal].url = parseURL(url) - this.analyzeUrl() + this.analyze() } get origin() { diff --git a/packages/next/src/shared/lib/i18n/detect-domain-locale.ts b/packages/next/src/shared/lib/i18n/detect-domain-locale.ts index 0c8b9167f3ee1..069e632f480aa 100644 --- a/packages/next/src/shared/lib/i18n/detect-domain-locale.ts +++ b/packages/next/src/shared/lib/i18n/detect-domain-locale.ts @@ -5,26 +5,21 @@ export function detectDomainLocale( hostname?: string, detectedLocale?: string ) { - let domainItem: DomainLocale | undefined + if (!domainItems) return - if (domainItems) { - if (detectedLocale) { - detectedLocale = detectedLocale.toLowerCase() - } + if (detectedLocale) { + detectedLocale = detectedLocale.toLowerCase() + } - for (const item of domainItems) { - // remove port if present - const domainHostname = item.domain?.split(':')[0].toLowerCase() - if ( - hostname === domainHostname || - detectedLocale === item.defaultLocale.toLowerCase() || - item.locales?.some((locale) => locale.toLowerCase() === detectedLocale) - ) { - domainItem = item - break - } + for (const item of domainItems) { + // remove port if present + const domainHostname = item.domain?.split(':')[0].toLowerCase() + if ( + hostname === domainHostname || + detectedLocale === item.defaultLocale.toLowerCase() || + item.locales?.some((locale) => locale.toLowerCase() === detectedLocale) + ) { + return item } } - - return domainItem } diff --git a/packages/next/src/shared/lib/router/utils/add-locale.ts b/packages/next/src/shared/lib/router/utils/add-locale.ts index 1365d468cff90..64b63bdf595ba 100644 --- a/packages/next/src/shared/lib/router/utils/add-locale.ts +++ b/packages/next/src/shared/lib/router/utils/add-locale.ts @@ -12,15 +12,19 @@ export function addLocale( defaultLocale?: string, ignorePrefix?: boolean ) { - if ( - locale && - locale !== defaultLocale && - (ignorePrefix || - (!pathHasPrefix(path.toLowerCase(), `/${locale.toLowerCase()}`) && - !pathHasPrefix(path.toLowerCase(), '/api'))) - ) { - return addPathPrefix(path, `/${locale}`) + // If no locale was given or the locale is the default locale, we don't need + // to prefix the path. + if (!locale || locale === defaultLocale) return path + + const lower = path.toLowerCase() + + // If the path is an API path or the path already has the locale prefix, we + // don't need to prefix the path. + if (!ignorePrefix) { + if (pathHasPrefix(lower, '/api')) return path + if (pathHasPrefix(lower, `/${locale.toLowerCase()}`)) return path } - return path + // Add the locale prefix to the path. + return addPathPrefix(path, `/${locale}`) } diff --git a/packages/next/src/shared/lib/router/utils/get-next-pathname-info.ts b/packages/next/src/shared/lib/router/utils/get-next-pathname-info.ts index c78e344b7ce10..836c34e2517b6 100644 --- a/packages/next/src/shared/lib/router/utils/get-next-pathname-info.ts +++ b/packages/next/src/shared/lib/router/utils/get-next-pathname-info.ts @@ -1,6 +1,7 @@ import { normalizeLocalePath } from '../../i18n/normalize-locale-path' import { removePathPrefix } from './remove-path-prefix' import { pathHasPrefix } from './path-has-prefix' +import { I18NProvider } from '../../../../server/future/helpers/i18n-provider' export interface NextPathnameInfo { /** @@ -41,6 +42,12 @@ interface Options { i18n?: { locales?: string[] } | null trailingSlash?: boolean } + + /** + * If provided, this normalizer will be used to detect the locale instead of + * the default locale detection. + */ + i18nProvider?: I18NProvider } export function getNextPathnameInfo(pathname: string, options: Options) { @@ -70,10 +77,20 @@ export function getNextPathnameInfo(pathname: string, options: Options) { info.buildId = buildId } - if (i18n) { + // If provided, use the locale route normalizer to detect the locale instead + // of the function below. + if (options.i18nProvider) { + const result = options.i18nProvider.analyze(info.pathname, { + // We set this to undefined because the default locale detection is + // completed out of this function. + defaultLocale: undefined, + }) + info.locale = result.detectedLocale + info.pathname = result.pathname ?? info.pathname + } else if (i18n) { const pathLocale = normalizeLocalePath(info.pathname, i18n.locales) - info.locale = pathLocale?.detectedLocale - info.pathname = pathLocale?.pathname || info.pathname + info.locale = pathLocale.detectedLocale + info.pathname = pathLocale.pathname ?? info.pathname } return info diff --git a/test/e2e/i18n-disallow-multiple-locales/i18n-disallow-multiple-locales.test.ts b/test/e2e/i18n-disallow-multiple-locales/i18n-disallow-multiple-locales.test.ts new file mode 100644 index 0000000000000..254afcde3c7e1 --- /dev/null +++ b/test/e2e/i18n-disallow-multiple-locales/i18n-disallow-multiple-locales.test.ts @@ -0,0 +1,51 @@ +import { createNextDescribe } from 'e2e-utils' +import cheerio from 'cheerio' + +const config = require('./next.config') + +async function verify(res, locale) { + expect(res.status).toBe(200) + + // Verify that we loaded the right page and the locale is correct. + const html = await res.text() + const $ = cheerio.load(html) + expect($('#page').text()).toBe('index page') + expect($('#router-locale').text()).toBe(locale) +} + +createNextDescribe( + 'i18n-disallow-multiple-locales', + { + files: __dirname, + }, + ({ next }) => { + it('should verify the default locale works', async () => { + const res = await next.fetch('/', { redirect: 'manual' }) + + await verify(res, config.i18n.defaultLocale) + }) + + it.each(config.i18n.locales)('/%s should 200', async (locale) => { + const res = await next.fetch(`/${locale}`, { redirect: 'manual' }) + + await verify(res, locale) + }) + + it.each( + config.i18n.locales.reduce((locales, firstLocale) => { + for (const secondLocale of config.i18n.locales) { + locales.push([firstLocale, secondLocale]) + } + + return locales + }, []) + )('/%s/%s should 404', async (firstLocale, secondLocale) => { + // Ensure that the double locale does not work. + const res = await next.fetch(`/${firstLocale}/${secondLocale}`, { + redirect: 'manual', + }) + + expect(res.status).toBe(404) + }) + } +) diff --git a/test/e2e/i18n-disallow-multiple-locales/next.config.js b/test/e2e/i18n-disallow-multiple-locales/next.config.js new file mode 100644 index 0000000000000..a09ad7eec8220 --- /dev/null +++ b/test/e2e/i18n-disallow-multiple-locales/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + i18n: { + locales: ['en-US', 'en', 'nl-NL', 'nl-BE', 'nl', 'fr-BE', 'fr'], + defaultLocale: 'en-US', + }, +} diff --git a/test/e2e/i18n-disallow-multiple-locales/pages/index.jsx b/test/e2e/i18n-disallow-multiple-locales/pages/index.jsx new file mode 100644 index 0000000000000..5486462a8e6d8 --- /dev/null +++ b/test/e2e/i18n-disallow-multiple-locales/pages/index.jsx @@ -0,0 +1,11 @@ +import { useRouter } from 'next/router' + +export default function Page() { + const router = useRouter() + return ( +

+

index page

+

{router.locale}

+
+ ) +}