diff --git a/.changeset/thirty-papayas-do.md b/.changeset/thirty-papayas-do.md new file mode 100644 index 0000000000..65d0b6b4fb --- /dev/null +++ b/.changeset/thirty-papayas-do.md @@ -0,0 +1,5 @@ +--- +'nextra': patch +--- + +support `nextConfig.basePath` with i18n diff --git a/examples/swr-site/next.config.js b/examples/swr-site/next.config.js index bdc4a50c09..5e62b9c586 100644 --- a/examples/swr-site/next.config.js +++ b/examples/swr-site/next.config.js @@ -13,55 +13,54 @@ module.exports = withNextra({ locales: ["en-US", "es-ES", "ja", "ko", "ru", "zh-CN"], defaultLocale: "en-US", }, + // basePath: "/some-base-path", distDir: "./.next", // Nextra supports custom `nextConfig.distDir` - redirects: () => { - return [ - // { - // source: "/docs.([a-zA-Z-]+)", - // destination: "/docs/getting-started", - // statusCode: 301, - // }, - // { - // source: "/advanced/performance", - // destination: "/docs/advanced/performance", - // statusCode: 301, - // }, - // { - // source: "/advanced/cache", - // destination: "/docs/advanced/cache", - // statusCode: 301, - // }, - // { - // source: "/docs/cache", - // destination: "/docs/advanced/cache", - // statusCode: 301, - // }, - { - source: "/change-log", - destination: "/docs/change-log", - statusCode: 301, - }, - { - source: "/blog/swr-1", - destination: "/blog/swr-v1", - statusCode: 301, - }, - { - source: "/docs.([a-zA-Z-]+)", - destination: "/docs/getting-started", - statusCode: 302, - }, - { - source: "/docs", - destination: "/docs/getting-started", - statusCode: 302, - }, - { - source: "/examples", - destination: "/examples/basic", - statusCode: 302, - }, - ]; - }, + redirects: () => [ + // { + // source: "/docs.([a-zA-Z-]+)", + // destination: "/docs/getting-started", + // statusCode: 301, + // }, + // { + // source: "/advanced/performance", + // destination: "/docs/advanced/performance", + // statusCode: 301, + // }, + // { + // source: "/advanced/cache", + // destination: "/docs/advanced/cache", + // statusCode: 301, + // }, + // { + // source: "/docs/cache", + // destination: "/docs/advanced/cache", + // statusCode: 301, + // }, + { + source: "/change-log", + destination: "/docs/change-log", + statusCode: 301, + }, + { + source: "/blog/swr-1", + destination: "/blog/swr-v1", + statusCode: 301, + }, + { + source: "/docs.([a-zA-Z-]+)", + destination: "/docs/getting-started", + statusCode: 302, + }, + { + source: "/docs", + destination: "/docs/getting-started", + statusCode: 302, + }, + { + source: "/examples", + destination: "/examples/basic", + statusCode: 302, + }, + ], reactStrictMode: true, }); diff --git a/packages/nextra/__test__/locale-with-base-path.test.ts b/packages/nextra/__test__/locale-with-base-path.test.ts new file mode 100644 index 0000000000..7680f0790c --- /dev/null +++ b/packages/nextra/__test__/locale-with-base-path.test.ts @@ -0,0 +1,61 @@ +// Next.js' `addBasePath`, `hasBasePath` and `removeBasePath` functions depend +// on `__NEXT_ROUTER_BASEPATH` environment variable that must be set before +// importing middleware +process.env.__NEXT_ROUTER_BASEPATH = '/testBasePath' +import { locales as originalLocales } from '../src/locales' +import { describe, expect, it, vi } from 'vitest' +import { NextRequest } from 'next/server' + +vi.mock('next/server', async () => { + const mod = await vi.importActual('next/server') + return { + ...mod, + NextResponse: { + redirect: (url: string) => ({ url, type: 'redirect' }), + rewrite: (url: string) => ({ url, type: 'rewrite' }) + } + } +}) + +const locales = originalLocales as unknown as (req: NextRequest) => { + type: string + url: URL +} + +const createRequest = (url: string, localeCookie = '') => { + return new NextRequest(url, { + headers: { cookie: localeCookie }, + nextConfig: { + i18n: { + locales: ['en-US', 'zh-CN'], + defaultLocale: 'en-US' + } + } + }) +} + +/** + * @vitest-environment edge-runtime + */ +describe('basePath', () => { + // Next.js' `addLocale` and `removeLocale` functions depend on `__NEXT_I18N_SUPPORT` + process.env.__NEXT_I18N_SUPPORT = 'true' + + it('should rewrite basePath', () => { + const request = createRequest( + 'http://localhost:3000/zh-CN/docs/getting-started' + ) + const result = locales(request) + expect(result.type).toBe('rewrite') + expect(result.url.href).toBe( + 'http://localhost:3000/testBasePath/zh-CN/docs/getting-started.zh-CN' + ) + }) + + it('should redirect with basePath', () => { + const request = createRequest('http://localhost:3000', 'NEXT_LOCALE=zh-CN') + const result = locales(request) + expect(result.type).toBe('redirect') + expect(result.url.href).toBe('http://localhost:3000/testBasePath/zh-CN') + }) +}) diff --git a/packages/nextra/__test__/locale.test.ts b/packages/nextra/__test__/locale.test.ts index 6c0aa39db8..ea9f497d72 100644 --- a/packages/nextra/__test__/locale.test.ts +++ b/packages/nextra/__test__/locale.test.ts @@ -2,28 +2,13 @@ import { describe, it, expect, vi } from 'vitest' import { NextRequest } from 'next/server' import { locales as originalLocales } from '../src/locales' -const i18n = { - locales: ['en-US', 'zh-CN'], - defaultLocale: 'en-US' -} - vi.mock('next/server', async () => { - const mod = await vi.importActual('next/server') + const mod = await vi.importActual('next/server') return { - ...(mod as any), + ...mod, NextResponse: { - redirect(url: string) { - return { - type: 'redirect', - url - } - }, - rewrite(url: string) { - return { - type: 'rewrite', - url - } - } + redirect: (url: string) => ({ url, type: 'redirect' }), + rewrite: (url: string) => ({ url, type: 'rewrite' }) } } }) @@ -36,7 +21,12 @@ const locales = originalLocales as unknown as (req: NextRequest) => { const createRequest = (url: string, localeCookie = '') => { return new NextRequest(url, { headers: { cookie: localeCookie }, - nextConfig: { i18n } + nextConfig: { + i18n: { + locales: ['en-US', 'zh-CN'], + defaultLocale: 'en-US' + } + } }) } @@ -44,63 +34,66 @@ const createRequest = (url: string, localeCookie = '') => { * @vitest-environment edge-runtime */ describe('locale process', () => { + // Next.js' `addLocale` and `removeLocale` functions depend on `__NEXT_I18N_SUPPORT` + process.env.__NEXT_I18N_SUPPORT = 'true' + it('root url without locale', () => { const request = createRequest('http://localhost:3000') const result = locales(request) expect(result.type).toBe('rewrite') - expect(result.url?.href).toBe('http://localhost:3000/en-US/index.en-US') + expect(result.url.href).toBe('http://localhost:3000/index.en-US') }) it('slash root url without locale', () => { const request = createRequest('http://localhost:3000/') const result = locales(request) expect(result.type).toBe('rewrite') - expect(result.url?.href).toBe('http://localhost:3000/en-US/index.en-US') + expect(result.url.href).toBe('http://localhost:3000/index.en-US') }) it('root url with locale cookie', () => { const request = createRequest('http://localhost:3000', 'NEXT_LOCALE=zh-CN') const result = locales(request) expect(result.type).toBe('redirect') - expect(result.url?.href).toBe('http://localhost:3000/zh-CN/') + expect(result.url.href).toBe('http://localhost:3000/zh-CN') }) it('slash root url with locale cookie', () => { const request = createRequest('http://localhost:3000/', 'NEXT_LOCALE=zh-CN') const result = locales(request) expect(result.type).toBe('redirect') - expect(result.url?.href).toBe('http://localhost:3000/zh-CN/') + expect(result.url.href).toBe('http://localhost:3000/zh-CN') }) it('root url with locale', () => { const request = createRequest('http://localhost:3000/zh-CN') const result = locales(request) expect(result.type).toBe('rewrite') - expect(result?.url?.href).toBe('http://localhost:3000/zh-CN/index.zh-CN') + expect(result?.url.href).toBe('http://localhost:3000/zh-CN/index.zh-CN') }) it('slash root url with locale', () => { const request = createRequest('http://localhost:3000/zh-CN/') const result = locales(request) - expect(result?.type).toBe('rewrite') - expect(result?.url?.href).toBe('http://localhost:3000/zh-CN/index.zh-CN') + expect(result.type).toBe('rewrite') + expect(result.url.href).toBe('http://localhost:3000/zh-CN/index.zh-CN') }) it('url without locale', () => { const request = createRequest('http://localhost:3000/docs/getting-started') const result = locales(request) - expect(result?.type).toBe('rewrite') - expect(result?.url?.href).toBe( - 'http://localhost:3000/en-US/docs/getting-started.en-US' + expect(result.type).toBe('rewrite') + expect(result.url.href).toBe( + 'http://localhost:3000/docs/getting-started.en-US' ) }) it('slash url without locale', () => { const request = createRequest('http://localhost:3000/docs/getting-started/') const result = locales(request) - expect(result?.type).toBe('rewrite') - expect(result?.url?.href).toBe( - 'http://localhost:3000/en-US/docs/getting-started.en-US' + expect(result.type).toBe('rewrite') + expect(result.url.href).toBe( + 'http://localhost:3000/docs/getting-started.en-US' ) }) it('url with locale cookie', () => { const request = createRequest('http://localhost:3000', 'NEXT_LOCALE=zh-CN') const result = locales(request) - expect(result?.type).toBe('redirect') - expect(result?.url?.href).toBe('http://localhost:3000/zh-CN/') + expect(result.type).toBe('redirect') + expect(result.url.href).toBe('http://localhost:3000/zh-CN') }) it('slash url with locale cookie', () => { const request = createRequest( @@ -108,9 +101,9 @@ describe('locale process', () => { 'NEXT_LOCALE=zh-CN' ) const result = locales(request) - expect(result?.type).toBe('redirect') - expect(result?.url?.href).toBe( - 'http://localhost:3000/zh-CN/docs/getting-started/' + expect(result.type).toBe('redirect') + expect(result.url.href).toBe( + 'http://localhost:3000/zh-CN/docs/getting-started' ) }) it('url with locale', () => { @@ -119,7 +112,7 @@ describe('locale process', () => { ) const result = locales(request) expect(result.type).toBe('rewrite') - expect(result.url?.href).toBe( + expect(result.url.href).toBe( 'http://localhost:3000/zh-CN/docs/getting-started.zh-CN' ) }) @@ -129,7 +122,7 @@ describe('locale process', () => { ) const result = locales(request) expect(result.type).toBe('rewrite') - expect(result.url?.href).toBe( + expect(result.url.href).toBe( 'http://localhost:3000/zh-CN/docs/getting-started.zh-CN' ) }) diff --git a/packages/nextra/src/locales.ts b/packages/nextra/src/locales.ts index 159478a89e..ee3fff2f7b 100644 --- a/packages/nextra/src/locales.ts +++ b/packages/nextra/src/locales.ts @@ -1,4 +1,9 @@ import { NextResponse, NextRequest } from 'next/server' +import { removeBasePath } from 'next/dist/client/remove-base-path' +import { addBasePath } from 'next/dist/client/add-base-path' +import { addLocale } from 'next/dist/client/add-locale' +import { removeLocale } from 'next/dist/client/remove-locale' +import { hasBasePath } from 'next/dist/client/has-base-path' type LegacyMiddlewareCookies = Record type StableMiddlewareCookies = Map @@ -34,20 +39,17 @@ export function locales(request: NextRequest) { if (!shouldHandleLocale) return // The locale code prefixed in the current URL, which can be empty. - const fullUrl = nextUrl.toString() - let localeInPath = fullUrl - // remove host and first slash from url - .slice(fullUrl.indexOf('//' + nextUrl.host) + nextUrl.host.length + 2) + const locale = nextUrl.locale === nextUrl.defaultLocale ? '' : nextUrl.locale - // remove pathname, search, and extra slashes from url - localeInPath = localeInPath - .replace(nextUrl.pathname + nextUrl.search, '') - .replace('/', '') + // pathname for default locale doesn't contain basePath and locale segment + nextUrl.pathname = hasBasePath(nextUrl.pathname) + ? removeLocale(removeBasePath(nextUrl.pathname), nextUrl.locale) + : nextUrl.pathname let finalLocale - if (localeInPath) { + if (locale) { // If a locale is explicitly set, we don't do any modifications. - finalLocale = localeInPath + finalLocale = locale } else { // If there is a locale cookie, we try to use it. If it doesn't exist, or // it's invalid, `nextUrl.locale` will be automatically figured out by Next @@ -67,12 +69,14 @@ export function locales(request: NextRequest) { // to prefix the URL with that locale since it's missing. Only the default // locale can be missing from there for consistency. if (finalLocale !== nextUrl.defaultLocale) { - return NextResponse.redirect( - new URL( - '/' + finalLocale + nextUrl.pathname + nextUrl.search, - request.url + const url = addBasePath( + addLocale( + `${nextUrl.pathname}${nextUrl.search}`, + finalLocale, + nextUrl.defaultLocale ) ) + return NextResponse.redirect(new URL(url, request.url)) } } let pathname = nextUrl.pathname || '/' @@ -81,12 +85,14 @@ export function locales(request: NextRequest) { // If we are not showing the correct localed page, rewrite the current request. if (!pathname.endsWith('.' + finalLocale)) { - return NextResponse.rewrite( - new URL( - '/' + finalLocale + pathname + '.' + finalLocale + nextUrl.search, - request.url + const url = addBasePath( + addLocale( + `${pathname}.${finalLocale}${nextUrl.search}`, + finalLocale, + nextUrl.defaultLocale ) ) + return NextResponse.rewrite(new URL(url, request.url)) } }