diff --git a/dist/modules/i18n/localeMiddleware.d.ts b/dist/modules/i18n/localeMiddleware.d.ts index 5e92043..bfb561b 100644 --- a/dist/modules/i18n/localeMiddleware.d.ts +++ b/dist/modules/i18n/localeMiddleware.d.ts @@ -32,6 +32,10 @@ export type LocaleMiddlewareResult = { location: string; type: 'redirect'; } | { + cookie?: { + name: string; + value: string; + }; type: 'next'; }; type CreateLocaleMiddlewareArgs = { @@ -68,10 +72,10 @@ type CreateLocaleMiddlewareArgs = { * import { localeMiddleware } from './ipal.middleware' // created from this factory * export function proxy(req) { * const r = localeMiddleware(req) - * if (r.type === 'next') return NextResponse.next() - * const res = NextResponse.redirect(r.location) - * // cookie is optional: only present when the visitor consented to the - * // gating category (functional by default). Guard before setting. + * // 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 * } diff --git a/dist/modules/i18n/localeMiddleware.js b/dist/modules/i18n/localeMiddleware.js index 3ef4b5c..d13d3c1 100644 --- a/dist/modules/i18n/localeMiddleware.js +++ b/dist/modules/i18n/localeMiddleware.js @@ -25,18 +25,43 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde * import { localeMiddleware } from './ipal.middleware' // created from this factory * export function proxy(req) { * const r = localeMiddleware(req) - * if (r.type === 'next') return NextResponse.next() - * const res = NextResponse.redirect(r.location) - * // cookie is optional: only present when the visitor consented to the - * // gating category (functional by default). Guard before setting. + * // 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 }) { + // Whether the locale cookie may be written: 'necessary' is always granted; + // 'functional' (default) requires the visitor to have consented. + function mayPersistLocale(request) { + if (consentCategory === 'necessary') { + return true; + } + const consent = parseConsent(request.cookies.get(consentCookieName)?.value); + return consent?.[consentCategory] === true; + } return function localeMiddleware(request) { const { pathname } = request.nextUrl; - // Already locale-prefixed → nothing to do - if (isValidLocale(firstSegment(pathname), config)) { + // 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' }; @@ -57,15 +82,10 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde // 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. - let mayPersist = consentCategory === 'necessary'; - if (!mayPersist) { - const consent = parseConsent(request.cookies.get(consentCookieName)?.value); - mayPersist = consent?.[consentCategory] === true; - } return { type: 'redirect', location: url.toString(), - ...mayPersist ? { + ...mayPersistLocale(request) ? { cookie: { name: cookieName, value: locale diff --git a/dist/modules/i18n/localeMiddleware.js.map b/dist/modules/i18n/localeMiddleware.js.map index b87332a..11a4b37 100644 --- a/dist/modules/i18n/localeMiddleware.js.map +++ b/dist/modules/i18n/localeMiddleware.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/i18n/localeMiddleware.ts"],"sourcesContent":["import type { I18nConfig } from './types.js'\n\nimport { CONSENT_COOKIE, parseConsent } from '../consent/storage.js'\nimport { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js'\n\n/**\n * Minimal request shape the middleware reads. Kept structural so the plugin\n * doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.\n */\ntype MiddlewareRequest = {\n cookies: { get: (name: string) => { value: string } | undefined }\n headers: { get: (name: string) => null | string }\n nextUrl: { clone: () => URL; pathname: string; search: string }\n url: string\n}\n\n/**\n * What the factory returns — the caller (in Next middleware.ts) decides how to\n * act: `redirect` means send a 307 to `location` and set the locale cookie;\n * `next` means let the request pass through untouched.\n */\nexport type LocaleMiddlewareResult =\n | { cookie?: { name: string; value: string }; location: string; type: 'redirect' }\n | { type: 'next' }\n\ntype CreateLocaleMiddlewareArgs = {\n config: I18nConfig\n /**\n * Consent category that gates *persisting* the locale cookie. The locale is\n * always detected (routing works regardless), but the choice is only written\n * to a cookie once the visitor has consented to this category. Defaults to\n * 'functional'. Pass 'necessary' to always persist (treat locale as strictly\n * necessary), which restores the pre-consent behaviour.\n */\n consentCategory?: 'functional' | 'necessary'\n /** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */\n consentCookieName?: string\n /** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */\n cookieName?: string\n}\n\n/**\n * First path segment of a URL pathname, or '' for root.\n * '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''.\n */\nfunction firstSegment(pathname: string): string {\n return pathname.split('/').filter(Boolean)[0] ?? ''\n}\n\n/**\n * Builds locale-routing logic for Next.js middleware.\n *\n * Behavior:\n * - path already starts with a valid locale (/pl/...) → pass through\n * - any other path (/, /o-nas) → redirect to /{locale}{path}, where locale\n * comes from negotiateLocale (cookie → Accept-Language → default)\n * - the chosen locale is written to a cookie so the next visit is stable\n *\n * The plugin returns a decision; the thin middleware.ts in the client project\n * turns it into a NextResponse. This keeps all logic in the plugin while\n * respecting that middleware.ts must physically live in the client app.\n *\n * @example\n * // middleware.ts (client project) — one wiring file, no logic:\n * import { NextResponse } from 'next/server'\n * import { localeMiddleware } from './ipal.middleware' // created from this factory\n * export function proxy(req) {\n * const r = localeMiddleware(req)\n * if (r.type === 'next') return NextResponse.next()\n * const res = NextResponse.redirect(r.location)\n * // cookie is optional: only present when the visitor consented to the\n * // gating category (functional by default). Guard before setting.\n * if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)\n * return res\n * }\n */\nexport function createLocaleMiddleware({\n config,\n consentCategory = 'functional',\n consentCookieName = CONSENT_COOKIE,\n cookieName = LOCALE_COOKIE_NAME,\n}: CreateLocaleMiddlewareArgs) {\n return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {\n const { pathname } = request.nextUrl\n\n // Already locale-prefixed → nothing to do\n if (isValidLocale(firstSegment(pathname), config)) {\n return { type: 'next' }\n }\n\n // Resolve the locale to use\n const locale = negotiateLocale({\n acceptLanguage: request.headers.get('accept-language'),\n config,\n cookieLocale: request.cookies.get(cookieName)?.value ?? null,\n })\n\n // Redirect to the locale-prefixed path, preserving the rest\n const url = request.nextUrl.clone()\n url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`\n\n // Persist the locale choice ONLY if the visitor consented to the gating\n // category. 'necessary' is always granted, so passing consentCategory:\n // 'necessary' always persists. For 'functional' (default), we read the\n // consent cookie and only write the locale cookie when functional is true.\n // Without consent the locale is still detected each request (routing works),\n // it just isn't remembered across visits — which is the whole point of\n // gating a functional cookie behind consent.\n let mayPersist = consentCategory === 'necessary'\n if (!mayPersist) {\n const consent = parseConsent(request.cookies.get(consentCookieName)?.value)\n mayPersist = consent?.[consentCategory] === true\n }\n\n return {\n type: 'redirect',\n location: url.toString(),\n ...(mayPersist ? { cookie: { name: cookieName, value: locale } } : {}),\n }\n }\n}\n\n/**\n * Default Next.js middleware matcher: run on everything except API routes, the\n * admin panel, Next internals, and files with an extension (static assets).\n */\nexport const DEFAULT_MIDDLEWARE_MATCHER = ['/((?!api|admin|_next|.*\\\\..*).*)']\n"],"names":["CONSENT_COOKIE","parseConsent","isValidLocale","LOCALE_COOKIE_NAME","negotiateLocale","firstSegment","pathname","split","filter","Boolean","createLocaleMiddleware","config","consentCategory","consentCookieName","cookieName","localeMiddleware","request","nextUrl","type","locale","acceptLanguage","headers","get","cookieLocale","cookies","value","url","clone","mayPersist","consent","location","toString","cookie","name","DEFAULT_MIDDLEWARE_MATCHER"],"mappings":"AAEA,SAASA,cAAc,EAAEC,YAAY,QAAQ,wBAAuB;AACpE,SAASC,aAAa,EAAEC,kBAAkB,EAAEC,eAAe,QAAQ,mBAAkB;AAsCrF;;;CAGC,GACD,SAASC,aAAaC,QAAgB;IACpC,OAAOA,SAASC,KAAK,CAAC,KAAKC,MAAM,CAACC,QAAQ,CAAC,EAAE,IAAI;AACnD;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,SAASC,uBAAuB,EACrCC,MAAM,EACNC,kBAAkB,YAAY,EAC9BC,oBAAoBb,cAAc,EAClCc,aAAaX,kBAAkB,EACJ;IAC3B,OAAO,SAASY,iBAAiBC,OAA0B;QACzD,MAAM,EAAEV,QAAQ,EAAE,GAAGU,QAAQC,OAAO;QAEpC,0CAA0C;QAC1C,IAAIf,cAAcG,aAAaC,WAAWK,SAAS;YACjD,OAAO;gBAAEO,MAAM;YAAO;QACxB;QAEA,4BAA4B;QAC5B,MAAMC,SAASf,gBAAgB;YAC7BgB,gBAAgBJ,QAAQK,OAAO,CAACC,GAAG,CAAC;YACpCX;YACAY,cAAcP,QAAQQ,OAAO,CAACF,GAAG,CAACR,aAAaW,SAAS;QAC1D;QAEA,4DAA4D;QAC5D,MAAMC,MAAMV,QAAQC,OAAO,CAACU,KAAK;QACjCD,IAAIpB,QAAQ,GAAG,CAAC,CAAC,EAAEa,SAASb,aAAa,MAAM,KAAKA,UAAU;QAE9D,wEAAwE;QACxE,uEAAuE;QACvE,uEAAuE;QACvE,2EAA2E;QAC3E,6EAA6E;QAC7E,uEAAuE;QACvE,6CAA6C;QAC7C,IAAIsB,aAAahB,oBAAoB;QACrC,IAAI,CAACgB,YAAY;YACf,MAAMC,UAAU5B,aAAae,QAAQQ,OAAO,CAACF,GAAG,CAACT,oBAAoBY;YACrEG,aAAaC,SAAS,CAACjB,gBAAgB,KAAK;QAC9C;QAEA,OAAO;YACLM,MAAM;YACNY,UAAUJ,IAAIK,QAAQ;YACtB,GAAIH,aAAa;gBAAEI,QAAQ;oBAAEC,MAAMnB;oBAAYW,OAAON;gBAAO;YAAE,IAAI,CAAC,CAAC;QACvE;IACF;AACF;AAEA;;;CAGC,GACD,OAAO,MAAMe,6BAA6B;IAAC;CAAmC,CAAA"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/i18n/localeMiddleware.ts"],"sourcesContent":["import type { I18nConfig } from './types.js'\n\nimport { CONSENT_COOKIE, parseConsent } from '../consent/storage.js'\nimport { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js'\n\n/**\n * Minimal request shape the middleware reads. Kept structural so the plugin\n * doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.\n */\ntype MiddlewareRequest = {\n cookies: { get: (name: string) => { value: string } | undefined }\n headers: { get: (name: string) => null | string }\n nextUrl: { clone: () => URL; pathname: string; search: string }\n url: string\n}\n\n/**\n * What the factory returns — the caller (in Next middleware.ts) decides how to\n * act: `redirect` means send a 307 to `location` and set the locale cookie;\n * `next` means let the request pass through untouched.\n */\nexport type LocaleMiddlewareResult =\n | { cookie?: { name: string; value: string }; location: string; type: 'redirect' }\n | { cookie?: { name: string; value: string }; type: 'next' }\n\ntype CreateLocaleMiddlewareArgs = {\n config: I18nConfig\n /**\n * Consent category that gates *persisting* the locale cookie. The locale is\n * always detected (routing works regardless), but the choice is only written\n * to a cookie once the visitor has consented to this category. Defaults to\n * 'functional'. Pass 'necessary' to always persist (treat locale as strictly\n * necessary), which restores the pre-consent behaviour.\n */\n consentCategory?: 'functional' | 'necessary'\n /** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */\n consentCookieName?: string\n /** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */\n cookieName?: string\n}\n\n/**\n * First path segment of a URL pathname, or '' for root.\n * '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''.\n */\nfunction firstSegment(pathname: string): string {\n return pathname.split('/').filter(Boolean)[0] ?? ''\n}\n\n/**\n * Builds locale-routing logic for Next.js middleware.\n *\n * Behavior:\n * - path already starts with a valid locale (/pl/...) → pass through\n * - any other path (/, /o-nas) → redirect to /{locale}{path}, where locale\n * comes from negotiateLocale (cookie → Accept-Language → default)\n * - the chosen locale is written to a cookie so the next visit is stable\n *\n * The plugin returns a decision; the thin middleware.ts in the client project\n * turns it into a NextResponse. This keeps all logic in the plugin while\n * respecting that middleware.ts must physically live in the client app.\n *\n * @example\n * // middleware.ts (client project) — one wiring file, no logic:\n * import { NextResponse } from 'next/server'\n * import { localeMiddleware } from './ipal.middleware' // created from this factory\n * export function proxy(req) {\n * const r = localeMiddleware(req)\n * // Both results may carry an optional cookie — 'next' when the visitor\n * // switched language (URL locale differs from the stored one) and consented,\n * // 'redirect' on the initial locale negotiation. Set it whenever present.\n * const res = r.type === 'next' ? NextResponse.next() : NextResponse.redirect(r.location)\n * if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)\n * return res\n * }\n */\nexport function createLocaleMiddleware({\n config,\n consentCategory = 'functional',\n consentCookieName = CONSENT_COOKIE,\n cookieName = LOCALE_COOKIE_NAME,\n}: CreateLocaleMiddlewareArgs) {\n // Whether the locale cookie may be written: 'necessary' is always granted;\n // 'functional' (default) requires the visitor to have consented.\n function mayPersistLocale(request: MiddlewareRequest): boolean {\n if (consentCategory === 'necessary') {return true}\n const consent = parseConsent(request.cookies.get(consentCookieName)?.value)\n return consent?.[consentCategory] === true\n }\n\n return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {\n const { pathname } = request.nextUrl\n\n // Already locale-prefixed (e.g. the visitor switched language by\n // navigating to /en). Routing is fine — but if the URL's locale differs\n // from the stored cookie, the visitor is *choosing* a language, and we\n // should remember it — provided they consented to the gating category.\n // Without consent we leave the cookie untouched: the switch works for this\n // visit but isn't persisted, which is exactly the functional-cookie rule.\n const urlLocale = firstSegment(pathname)\n if (isValidLocale(urlLocale, config)) {\n const currentCookie = request.cookies.get(cookieName)?.value ?? null\n if (currentCookie !== urlLocale && mayPersistLocale(request)) {\n return { type: 'next', cookie: { name: cookieName, value: urlLocale } }\n }\n return { type: 'next' }\n }\n\n // Resolve the locale to use\n const locale = negotiateLocale({\n acceptLanguage: request.headers.get('accept-language'),\n config,\n cookieLocale: request.cookies.get(cookieName)?.value ?? null,\n })\n\n // Redirect to the locale-prefixed path, preserving the rest\n const url = request.nextUrl.clone()\n url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`\n\n // Persist the locale choice ONLY if the visitor consented to the gating\n // category. 'necessary' is always granted, so passing consentCategory:\n // 'necessary' always persists. For 'functional' (default), we read the\n // consent cookie and only write the locale cookie when functional is true.\n // Without consent the locale is still detected each request (routing works),\n // it just isn't remembered across visits — which is the whole point of\n // gating a functional cookie behind consent.\n return {\n type: 'redirect',\n location: url.toString(),\n ...(mayPersistLocale(request) ? { cookie: { name: cookieName, value: locale } } : {}),\n }\n }\n}\n\n/**\n * Default Next.js middleware matcher: run on everything except API routes, the\n * admin panel, Next internals, and files with an extension (static assets).\n */\nexport const DEFAULT_MIDDLEWARE_MATCHER = ['/((?!api|admin|_next|.*\\\\..*).*)']\n"],"names":["CONSENT_COOKIE","parseConsent","isValidLocale","LOCALE_COOKIE_NAME","negotiateLocale","firstSegment","pathname","split","filter","Boolean","createLocaleMiddleware","config","consentCategory","consentCookieName","cookieName","mayPersistLocale","request","consent","cookies","get","value","localeMiddleware","nextUrl","urlLocale","currentCookie","type","cookie","name","locale","acceptLanguage","headers","cookieLocale","url","clone","location","toString","DEFAULT_MIDDLEWARE_MATCHER"],"mappings":"AAEA,SAASA,cAAc,EAAEC,YAAY,QAAQ,wBAAuB;AACpE,SAASC,aAAa,EAAEC,kBAAkB,EAAEC,eAAe,QAAQ,mBAAkB;AAsCrF;;;CAGC,GACD,SAASC,aAAaC,QAAgB;IACpC,OAAOA,SAASC,KAAK,CAAC,KAAKC,MAAM,CAACC,QAAQ,CAAC,EAAE,IAAI;AACnD;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,SAASC,uBAAuB,EACrCC,MAAM,EACNC,kBAAkB,YAAY,EAC9BC,oBAAoBb,cAAc,EAClCc,aAAaX,kBAAkB,EACJ;IAC3B,2EAA2E;IAC3E,iEAAiE;IACjE,SAASY,iBAAiBC,OAA0B;QAClD,IAAIJ,oBAAoB,aAAa;YAAC,OAAO;QAAI;QACjD,MAAMK,UAAUhB,aAAae,QAAQE,OAAO,CAACC,GAAG,CAACN,oBAAoBO;QACrE,OAAOH,SAAS,CAACL,gBAAgB,KAAK;IACxC;IAEA,OAAO,SAASS,iBAAiBL,OAA0B;QACzD,MAAM,EAAEV,QAAQ,EAAE,GAAGU,QAAQM,OAAO;QAEpC,iEAAiE;QACjE,wEAAwE;QACxE,uEAAuE;QACvE,uEAAuE;QACvE,2EAA2E;QAC3E,0EAA0E;QAC1E,MAAMC,YAAYlB,aAAaC;QAC/B,IAAIJ,cAAcqB,WAAWZ,SAAS;YACpC,MAAMa,gBAAgBR,QAAQE,OAAO,CAACC,GAAG,CAACL,aAAaM,SAAS;YAChE,IAAII,kBAAkBD,aAAaR,iBAAiBC,UAAU;gBAC5D,OAAO;oBAAES,MAAM;oBAAQC,QAAQ;wBAAEC,MAAMb;wBAAYM,OAAOG;oBAAU;gBAAE;YACxE;YACA,OAAO;gBAAEE,MAAM;YAAO;QACxB;QAEA,4BAA4B;QAC5B,MAAMG,SAASxB,gBAAgB;YAC7ByB,gBAAgBb,QAAQc,OAAO,CAACX,GAAG,CAAC;YACpCR;YACAoB,cAAcf,QAAQE,OAAO,CAACC,GAAG,CAACL,aAAaM,SAAS;QAC1D;QAEA,4DAA4D;QAC5D,MAAMY,MAAMhB,QAAQM,OAAO,CAACW,KAAK;QACjCD,IAAI1B,QAAQ,GAAG,CAAC,CAAC,EAAEsB,SAAStB,aAAa,MAAM,KAAKA,UAAU;QAE9D,wEAAwE;QACxE,uEAAuE;QACvE,uEAAuE;QACvE,2EAA2E;QAC3E,6EAA6E;QAC7E,uEAAuE;QACvE,6CAA6C;QAC7C,OAAO;YACLmB,MAAM;YACNS,UAAUF,IAAIG,QAAQ;YACtB,GAAIpB,iBAAiBC,WAAW;gBAAEU,QAAQ;oBAAEC,MAAMb;oBAAYM,OAAOQ;gBAAO;YAAE,IAAI,CAAC,CAAC;QACtF;IACF;AACF;AAEA;;;CAGC,GACD,OAAO,MAAMQ,6BAA6B;IAAC;CAAmC,CAAA"} \ No newline at end of file diff --git a/src/modules/i18n/localeMiddleware.ts b/src/modules/i18n/localeMiddleware.ts index 01b77b2..774cac6 100644 --- a/src/modules/i18n/localeMiddleware.ts +++ b/src/modules/i18n/localeMiddleware.ts @@ -21,7 +21,7 @@ type MiddlewareRequest = { */ export type LocaleMiddlewareResult = | { cookie?: { name: string; value: string }; location: string; type: 'redirect' } - | { type: 'next' } + | { cookie?: { name: string; value: string }; type: 'next' } type CreateLocaleMiddlewareArgs = { config: I18nConfig @@ -66,10 +66,10 @@ function firstSegment(pathname: string): string { * import { localeMiddleware } from './ipal.middleware' // created from this factory * export function proxy(req) { * const r = localeMiddleware(req) - * if (r.type === 'next') return NextResponse.next() - * const res = NextResponse.redirect(r.location) - * // cookie is optional: only present when the visitor consented to the - * // gating category (functional by default). Guard before setting. + * // 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 * } @@ -80,11 +80,29 @@ export function createLocaleMiddleware({ 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 → nothing to do - if (isValidLocale(firstSegment(pathname), config)) { + // 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' } } @@ -106,16 +124,10 @@ export function createLocaleMiddleware({ // 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. - let mayPersist = consentCategory === 'necessary' - if (!mayPersist) { - const consent = parseConsent(request.cookies.get(consentCookieName)?.value) - mayPersist = consent?.[consentCategory] === true - } - return { type: 'redirect', location: url.toString(), - ...(mayPersist ? { cookie: { name: cookieName, value: locale } } : {}), + ...(mayPersistLocale(request) ? { cookie: { name: cookieName, value: locale } } : {}), } } }