Added support for single localization

This commit is contained in:
2026-09-08 18:12:58 +02:00
parent e30ac71044
commit 419207ac12
16 changed files with 287 additions and 102 deletions
+21 -13
View File
@@ -1,16 +1,15 @@
import type { I18nConfig } from './types.js'
import type { I18nConfig } from '../i18n/index.js'
import { negotiateLocale, isValidLocale, LOCALE_COOKIE_NAME } from '../i18n/index.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 = {
nextUrl: { pathname: string; search: string; clone: () => URL }
cookies: { get: (name: string) => { value: string } | undefined }
headers: { get: (name: string) => null | string }
nextUrl: { clone: () => URL; pathname: string; search: string }
headers: { get: (name: string) => string | null }
url: string
}
@@ -20,11 +19,13 @@ type MiddlewareRequest = {
* `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: 'next'; cookie?: { name: string; value: string } }
| { type: 'redirect'; location: string; cookie?: { name: string; value: string } }
type CreateLocaleMiddlewareArgs = {
config: I18nConfig
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
cookieName?: string
/**
* Consent category that gates *persisting* the locale cookie. The locale is
* always detected (routing works regardless), but the choice is only written
@@ -32,11 +33,9 @@ type CreateLocaleMiddlewareArgs = {
* 'functional'. Pass 'necessary' to always persist (treat locale as strictly
* necessary), which restores the pre-consent behaviour.
*/
consentCategory?: 'functional' | 'necessary'
consentCategory?: 'necessary' | 'functional'
/** 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
}
/**
@@ -76,14 +75,14 @@ function firstSegment(pathname: string): string {
*/
export function createLocaleMiddleware({
config,
cookieName = LOCALE_COOKIE_NAME,
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}
if (consentCategory === 'necessary') return true
const consent = parseConsent(request.cookies.get(consentCookieName)?.value)
return consent?.[consentCategory] === true
}
@@ -91,6 +90,15 @@ export function createLocaleMiddleware({
return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {
const { pathname } = request.nextUrl
// Single-locale sites have no /pl, /en prefix and no negotiation — one
// language, no redirect. The middleware becomes a pass-through: paths stay
// as-is (/o-nas), nothing to detect or persist. (Projects that are truly
// single-locale usually don't even mount this middleware, but guarding here
// makes it safe if they do.)
if (config.locales.length === 1) {
return { type: 'next' }
}
// 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
@@ -108,9 +116,9 @@ export function createLocaleMiddleware({
// Resolve the locale to use
const locale = negotiateLocale({
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
acceptLanguage: request.headers.get('accept-language'),
config,
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
})
// Redirect to the locale-prefixed path, preserving the rest
+11 -3
View File
@@ -71,6 +71,13 @@ export function buildLocalizedPath({
return undefined
}
// Single-locale sites have no /pl, /en prefix — the language segment is
// dropped entirely (path is /o-nas, not /pl/o-nas). Detected automatically:
// one configured locale means one language, so no prefix is needed. The
// project's folder structure matches (app/[[...slug]] without [locale]).
const singleLocale = config.locales.length === 1
const localeSegment = singleLocale ? '' : `/${locale}`
if (prefix) {
const segment = prefix[locale]
// No archive slug in this locale means the entry is unreachable there —
@@ -79,14 +86,15 @@ export function buildLocalizedPath({
if (!segment) {
return undefined
}
return `/${locale}/${segment}/${slug}`
return `${localeSegment}/${segment}/${slug}`
}
if (slug === homeSlug) {
return `/${locale}`
// Home collapses to the root: '/' for single-locale, '/pl' otherwise.
return localeSegment || '/'
}
return `/${locale}/${slug}`
return `${localeSegment}/${slug}`
}
type SwitchLocaleArgs = {
+18 -19
View File
@@ -1,11 +1,12 @@
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'
import { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js'
import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'
type BuildHreflangArgs = {
/** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */
slugs: LocalizedSlugs
config: I18nConfig
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
baseUrl?: string
config: I18nConfig
/** Home slug that collapses to the locale root. Defaults to 'home'. */
homeSlug?: string
/**
@@ -14,46 +15,44 @@ type BuildHreflangArgs = {
* are omitted — an entry with no archive in that language has no URL there.
*/
prefix?: LocalizedSlugs
/** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */
slugs: LocalizedSlugs
}
/**
* Builds a map of locale → URL for hreflang alternate links, suitable for
* Next.js Metadata `alternates.languages`.
*
* Bridges SEO and i18n: for each configured locale that the document has a
* slug in, it produces the locale-aware path (via buildLocalizedPath),
* optionally prefixed with an absolute origin.
*
* Also emits `x-default` pointing at the default locale — the version Google
* serves when the user's language/region matches no hreflang, and the fallback
* when the root ('/') redirect is ambiguous (Googlebot with no/foreign
* Accept-Language).
*
* @example
* buildHreflangAlternates({
* slugs: { pl: 'o-nas', en: 'about' },
* config,
* baseUrl: 'https://example.com',
* })
* // → {
* // pl: 'https://example.com/pl/o-nas',
* // en: 'https://example.com/en/about',
* // 'x-default': 'https://example.com/pl/o-nas',
* // }
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
*/
export function buildHreflangAlternates({
baseUrl,
slugs,
config,
baseUrl,
homeSlug = 'home',
prefix,
slugs,
}: BuildHreflangArgs): Record<string, string> {
const origin = baseUrl?.replace(/\/$/, '') ?? ''
const alternates: Record<string, string> = {}
// Single-locale sites have no language alternatives — hreflang describes
// relationships BETWEEN language versions, and there's only one. Emitting
// hreflang (or x-default) here would be wrong, so return empty: the page keeps
// its canonical, but no alternate-language links.
if (config.locales.length === 1) {
return alternates
}
for (const locale of getLocaleCodes(config)) {
const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })
const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix })
if (path) {
alternates[locale] = `${origin}${path}`
}
@@ -64,7 +63,7 @@ export function buildHreflangAlternates({
// redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it
// at the default locale (the primary market) so search shows that version by
// default instead of guessing. Only set when the default locale has a URL.
const defaultLocalePath = alternates[getDefaultLocale(config)]
const defaultLocalePath = alternates[config.defaultLocale]
if (defaultLocalePath) {
alternates['x-default'] = defaultLocalePath
}