Added support for single localization
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user