140 lines
6.0 KiB
TypeScript
140 lines
6.0 KiB
TypeScript
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|.*\\..*).*)']
|