diff --git a/examples/app/app/[locale]/[...notFound]/page.tsx b/examples/app/app/[locale]/[...notFound]/page.tsx index 5cc2a45..2c4bcd6 100644 --- a/examples/app/app/[locale]/[...notFound]/page.tsx +++ b/examples/app/app/[locale]/[...notFound]/page.tsx @@ -1,5 +1,10 @@ -import { notFound } from 'next/navigation'; +import React from 'react'; export default function NotFound() { - notFound(); + return ( +
+

Custom Not Found page

+

Could not find requested resource

+
+ ); } diff --git a/examples/app/app/[locale]/layout.tsx b/examples/app/app/[locale]/layout.tsx index 5e20df1..d2bdb57 100644 --- a/examples/app/app/[locale]/layout.tsx +++ b/examples/app/app/[locale]/layout.tsx @@ -13,5 +13,11 @@ export default function RootLayout({ children: React.ReactNode; params: { locale: string }; }>) { - return {children}; + return ( + + + {children} + + + ); } diff --git a/examples/app/app/layout.tsx b/examples/app/app/layout.tsx deleted file mode 100644 index 668746b..0000000 --- a/examples/app/app/layout.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - {children} - - ); -} diff --git a/examples/app/app/not-found.tsx b/examples/app/app/not-found.tsx deleted file mode 100644 index 2c4bcd6..0000000 --- a/examples/app/app/not-found.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; - -export default function NotFound() { - return ( -
-

Custom Not Found page

-

Could not find requested resource

-
- ); -} diff --git a/examples/app/middleware.ts b/examples/app/middleware.ts index ebbf7ed..4be91f1 100644 --- a/examples/app/middleware.ts +++ b/examples/app/middleware.ts @@ -1,10 +1,18 @@ import { NextResponse } from 'next/server'; import { createI18nMiddleware } from 'next-international/middleware'; -export const middleware = createI18nMiddleware(request => { - return NextResponse.next(); -}); +export const middleware = createI18nMiddleware( + request => { + console.log('User middleware:', request.url); + return NextResponse.next(); + }, + { + locales: ['en', 'fr'], + defaultLocale: 'en', + // urlMappingStrategy: 'rewrite', + }, +); export const config = { - matcher: ['/', '/:locale'], + matcher: ['/((?!api|static|.*\\..*|_next|favicon.ico|robots.txt).*)'], }; diff --git a/packages/next-international/src/app/app/client.ts b/packages/next-international/src/app/app/client.ts index ff28b8a..c469955 100644 --- a/packages/next-international/src/app/app/client.ts +++ b/packages/next-international/src/app/app/client.ts @@ -57,8 +57,11 @@ export function createI18n { + const finalLocale = locale as string; + document.cookie = `locale=${finalLocale};`; + // TODO: preserve URL & search params - router.push(`/${locale as string}`); + router.push(`/${finalLocale}`); }; }; diff --git a/packages/next-international/src/app/app/server.ts b/packages/next-international/src/app/app/server.ts index 7bba45c..8ab9895 100644 --- a/packages/next-international/src/app/app/server.ts +++ b/packages/next-international/src/app/app/server.ts @@ -22,7 +22,22 @@ const useLocaleCache = cache(() => { throw new Error('Invariant: urlPathname is not a string: ' + JSON.stringify(store, null, 2)); } - return url.split('/')[1].split('?')[0]; + let locale = url.split('/')[1].split('?')[0]; + + if (locale === '') { + const cookie = (store?.incrementalCache?.requestHeaders?.['cookie'] as string) + ?.split(';') + .find(c => c.trim().startsWith('locale=')) + ?.split('=')[1]; + + if (!cookie) { + throw new Error('Invariant: locale cookie not found'); + } + + locale = cookie; + } + + return locale; }); export function createI18n>( diff --git a/packages/next-international/src/app/middleware/index.ts b/packages/next-international/src/app/middleware/index.ts index 996ca69..7990032 100644 --- a/packages/next-international/src/app/middleware/index.ts +++ b/packages/next-international/src/app/middleware/index.ts @@ -1,122 +1,88 @@ -// import type { NextRequest } from 'next/server'; -// import { NextResponse } from 'next/server'; -// -// import { LOCALE_COOKIE, LOCALE_HEADER } from '../../common/constants'; -// import { warn } from '../../helpers/log'; -// import type { I18nMiddlewareConfig } from '../../types'; -// -// const DEFAULT_STRATEGY: NonNullable['urlMappingStrategy']> = 'redirect'; -// -// export function createI18nMiddleware(config: I18nMiddlewareConfig) { -// return function I18nMiddleware(request: NextRequest) { -// const locale = localeFromRequest(config.locales, request, config.resolveLocaleFromRequest) ?? config.defaultLocale; -// const nextUrl = request.nextUrl; -// -// // If the locale from the request is not an handled locale, then redirect to the same URL with the default locale -// if (noLocalePrefix(config.locales, nextUrl.pathname)) { -// nextUrl.pathname = `/${locale}${nextUrl.pathname}`; -// -// const strategy = config.urlMappingStrategy ?? DEFAULT_STRATEGY; -// if (strategy === 'rewrite' || (strategy === 'rewriteDefault' && locale === config.defaultLocale)) { -// const response = NextResponse.rewrite(nextUrl); -// return addLocaleToResponse(request, response, locale); -// } else { -// if (!['redirect', 'rewriteDefault'].includes(strategy)) { -// warn(`Invalid urlMappingStrategy: ${strategy}. Defaulting to redirect.`); -// } -// -// const response = NextResponse.redirect(nextUrl); -// return addLocaleToResponse(request, response, locale); -// } -// } -// -// let response = NextResponse.next(); -// const pathnameLocale = nextUrl.pathname.split('/', 2)?.[1]; -// -// if (!pathnameLocale || config.locales.includes(pathnameLocale)) { -// // If the URL mapping strategy is set to 'rewrite' and the locale from the request doesn't match the locale in the pathname, -// // or if the URL mapping strategy is set to 'rewriteDefault' and the locale from the request doesn't match the locale in the pathname -// // or is the same as the default locale, then proceed with the following logic -// if ( -// (config.urlMappingStrategy === 'rewrite' && pathnameLocale !== locale) || -// (config.urlMappingStrategy === 'rewriteDefault' && -// (pathnameLocale !== locale || pathnameLocale === config.defaultLocale)) -// ) { -// // Remove the locale from the pathname -// const pathnameWithoutLocale = nextUrl.pathname.slice(pathnameLocale.length + 1); -// -// // Create a new URL without the locale in the pathname -// const newUrl = new URL(pathnameWithoutLocale || '/', request.url); -// -// // Preserve the original search parameters -// newUrl.search = nextUrl.search; -// response = NextResponse.redirect(newUrl); -// } -// -// return addLocaleToResponse(request, response, pathnameLocale ?? config.defaultLocale); -// } -// -// return response; -// }; -// } -// -// /** -// * Retrieve `Next-Locale` header from request -// * and check if it is an handled locale. -// */ -// function localeFromRequest( -// locales: Locales, -// request: NextRequest, -// resolveLocaleFromRequest: NonNullable< -// I18nMiddlewareConfig['resolveLocaleFromRequest'] -// > = defaultResolveLocaleFromRequest, -// ) { -// const locale = request.cookies.get(LOCALE_COOKIE)?.value ?? resolveLocaleFromRequest(request); -// -// if (!locale || !locales.includes(locale)) { -// return null; -// } -// -// return locale; -// } -// -// /** -// * Default implementation of the `resolveLocaleFromRequest` function for the I18nMiddlewareConfig. -// * This function extracts the locale from the 'Accept-Language' header of the request. -// */ -// const defaultResolveLocaleFromRequest: NonNullable['resolveLocaleFromRequest']> = request => { -// const header = request.headers.get('Accept-Language'); -// const locale = header?.split(',', 1)?.[0]?.split('-', 1)?.[0]; -// return locale ?? null; -// }; -// -// /** -// * Returns `true` if the pathname does not start with an handled locale -// */ -// function noLocalePrefix(locales: readonly string[], pathname: string) { -// return locales.every(locale => { -// return !(pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)); -// }); -// } -// -// /** -// * Add `X-Next-Locale` header and `Next-Locale` cookie to response -// * -// * **NOTE:** The cookie is only set if the locale is different from the one in the cookie -// */ -// function addLocaleToResponse(request: NextRequest, response: NextResponse, locale: string) { -// response.headers.set(LOCALE_HEADER, locale); -// -// if (request.cookies.get(LOCALE_COOKIE)?.value !== locale) { -// response.cookies.set(LOCALE_COOKIE, locale, { sameSite: 'strict' }); -// } -// return response; -// } - -import type { NextMiddleware } from 'next/server'; - -export function createI18nMiddleware(middleware: NextMiddleware): NextMiddleware { - return (request, event) => { +import { NextResponse } from 'next/server'; +import type { NextMiddleware, NextRequest } from 'next/server'; + +type I18nMiddlewareConfig = { + locales: Locales; + defaultLocale: Locales[number]; + resolveLocaleFromRequest?: (request: NextRequest) => string | null; + urlMappingStrategy?: 'redirect' | 'rewrite' | 'rewriteDefault'; +}; + +function getLocaleFromRequest( + request: NextRequest, + config: I18nMiddlewareConfig, +) { + if (config.resolveLocaleFromRequest) { + return config.resolveLocaleFromRequest(request); + } + + const locale = request.cookies.get('locale')?.value; + + if (!locale || !config.locales.includes(locale)) { + return null; + } + + return locale; +} + +function noLocalePrefix(locales: readonly string[], pathname: string) { + return locales.every(locale => { + return !(pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)); + }); +} + +export function createI18nMiddleware( + middleware: NextMiddleware, + config: I18nMiddlewareConfig, +): NextMiddleware { + return async (request, event) => { + let currentLocale = getLocaleFromRequest(request, config); + + if (currentLocale === null) { + currentLocale = config.defaultLocale; + const response = await middleware(request, event); + + if (response instanceof NextResponse) { + response.cookies.set('locale', currentLocale, { sameSite: 'strict' }); + } else if (response instanceof Response) { + const cookies = response.headers.get('set-cookie') ?? ''; + response.headers.set('set-cookie', `${cookies}; locale=${currentLocale}; SameSite=Strict`); + } + + return response; + } + + if (!config.urlMappingStrategy || config.urlMappingStrategy === 'redirect') { + const nextUrl = request.nextUrl; + const pathname = new URL(request.url).pathname; + + if (noLocalePrefix(config.locales, pathname)) { + nextUrl.pathname = `/${currentLocale}${pathname}`; + const response = NextResponse.redirect(nextUrl); + return response; + } + } + + if ( + (config.urlMappingStrategy === 'rewriteDefault' && currentLocale === config.defaultLocale) || + config.urlMappingStrategy === 'rewrite' + ) { + const nextUrl = request.nextUrl; + + if (noLocalePrefix(config.locales, nextUrl.pathname)) { + nextUrl.pathname = `/${currentLocale}${nextUrl.pathname}`; + const response = NextResponse.rewrite(nextUrl); + return response; + } + + const urlWithoutLocale = nextUrl.pathname.slice(currentLocale.length + 1); + const newUrl = new URL(urlWithoutLocale || '/', request.url); + newUrl.search = nextUrl.search; + + const response = NextResponse.redirect(newUrl); + return response; + } + return middleware(request, event); }; }