import type { I18nConfig } from './types.js' import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js' import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js' /** * Minimal request shape the middleware reads. Kept structural so the plugin * doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it. */ type MiddlewareRequest = { cookies: { get: (name: string) => { value: string } | undefined } headers: { get: (name: string) => null | string } nextUrl: { clone: () => URL; pathname: string; search: string } url: string } /** * What the factory returns — the caller (in Next middleware.ts) decides how to * act: `redirect` means send a 307 to `location` and set the locale cookie; * `next` means let the request pass through untouched. */ export type LocaleMiddlewareResult = | { cookie?: { name: string; value: string }; location: string; type: 'redirect' } | { cookie?: { name: string; value: string }; type: 'next' } type CreateLocaleMiddlewareArgs = { config: I18nConfig /** * Consent category that gates *persisting* the locale cookie. The locale is * always detected (routing works regardless), but the choice is only written * to a cookie once the visitor has consented to this category. Defaults to * 'functional'. Pass 'necessary' to always persist (treat locale as strictly * necessary), which restores the pre-consent behaviour. */ consentCategory?: 'functional' | 'necessary' /** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */ consentCookieName?: string /** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */ cookieName?: string } /** * First path segment of a URL pathname, or '' for root. * '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''. */ function firstSegment(pathname: string): string { return pathname.split('/').filter(Boolean)[0] ?? '' } /** * Builds locale-routing logic for Next.js middleware. * * Behavior: * - path already starts with a valid locale (/pl/...) → pass through * - any other path (/, /o-nas) → redirect to /{locale}{path}, where locale * comes from negotiateLocale (cookie → Accept-Language → default) * - the chosen locale is written to a cookie so the next visit is stable * * The plugin returns a decision; the thin middleware.ts in the client project * turns it into a NextResponse. This keeps all logic in the plugin while * respecting that middleware.ts must physically live in the client app. * * @example * // middleware.ts (client project) — one wiring file, no logic: * import { NextResponse } from 'next/server' * import { localeMiddleware } from './ipal.middleware' // created from this factory * export function proxy(req) { * const r = localeMiddleware(req) * // Both results may carry an optional cookie — 'next' when the visitor * // switched language (URL locale differs from the stored one) and consented, * // 'redirect' on the initial locale negotiation. Set it whenever present. * const res = r.type === 'next' ? NextResponse.next() : NextResponse.redirect(r.location) * if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value) * return res * } */ export function createLocaleMiddleware({ config, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE, cookieName = LOCALE_COOKIE_NAME, }: CreateLocaleMiddlewareArgs) { // Whether the locale cookie may be written: 'necessary' is always granted; // 'functional' (default) requires the visitor to have consented. function mayPersistLocale(request: MiddlewareRequest): boolean { if (consentCategory === 'necessary') {return true} const consent = parseConsent(request.cookies.get(consentCookieName)?.value) return consent?.[consentCategory] === true } return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult { const { pathname } = request.nextUrl // Already locale-prefixed (e.g. the visitor switched language by // navigating to /en). Routing is fine — but if the URL's locale differs // from the stored cookie, the visitor is *choosing* a language, and we // should remember it — provided they consented to the gating category. // Without consent we leave the cookie untouched: the switch works for this // visit but isn't persisted, which is exactly the functional-cookie rule. const urlLocale = firstSegment(pathname) if (isValidLocale(urlLocale, config)) { const currentCookie = request.cookies.get(cookieName)?.value ?? null if (currentCookie !== urlLocale && mayPersistLocale(request)) { return { type: 'next', cookie: { name: cookieName, value: urlLocale } } } return { type: 'next' } } // Resolve the locale to use const locale = negotiateLocale({ acceptLanguage: request.headers.get('accept-language'), config, cookieLocale: request.cookies.get(cookieName)?.value ?? null, }) // Redirect to the locale-prefixed path, preserving the rest const url = request.nextUrl.clone() url.pathname = `/${locale}${pathname === '/' ? '' : pathname}` // Persist the locale choice ONLY if the visitor consented to the gating // category. 'necessary' is always granted, so passing consentCategory: // 'necessary' always persists. For 'functional' (default), we read the // consent cookie and only write the locale cookie when functional is true. // Without consent the locale is still detected each request (routing works), // it just isn't remembered across visits — which is the whole point of // gating a functional cookie behind consent. return { type: 'redirect', location: url.toString(), ...(mayPersistLocale(request) ? { cookie: { name: cookieName, value: locale } } : {}), } } } /** * Default Next.js middleware matcher: run on everything except API routes, the * admin panel, Next internals, and files with an extension (static assets). */ export const DEFAULT_MIDDLEWARE_MATCHER = ['/((?!api|admin|_next|.*\\..*).*)']