# i18n Lokalizacja: konfiguracja locali dla Payload, negocjacja języka (cookie / Accept-Language / default), budowanie ścieżek locale-aware, przełączanie języka bez 404. ## Config (payload.config.ts) ```ts ipalKit({ i18n: { defaultLocale: 'pl', locales: [ { code: 'pl', label: 'Polski' }, { code: 'en', label: 'English' }, // { code: 'ar', label: 'العربية', rtl: true }, ], // fallback: true, // domyślnie true }, }) ``` Plugin ustawia `config.localization` z tego. Walidacja jest eager (fail-fast przy starcie): pusta lista, duplikaty kodów, `defaultLocale` spoza listy, zły format kodu → błąd `[ipal] i18n: ...`. ## Front — helpery Wszystkie helpery są czyste (przyjmują config jako argument). Trzymaj swój `i18nConfig` w jednym miejscu i importuj gdzie trzeba. ```ts import { getLocaleCodes, getDefaultLocale, isValidLocale, getLocaleDefinition, negotiateLocale, buildLocalizedPath, switchLocalePath, getLocalizedSlugs, LOCALE_COOKIE_NAME, } from '@intecion/ipal-kit' const config = { defaultLocale: 'pl', locales: [{code:'pl',label:'Polski'},{code:'en',label:'English'}] } getLocaleCodes(config) // ['pl', 'en'] isValidLocale('de', config) // false ``` ### Budowanie ścieżek ```ts // dokument pobrany z locale:'all' → slug to mapa { pl, en } const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' }) const slugs = getLocalizedSlugs({ slugField: doc.slug, config }) // { pl: 'o-nas', en: 'about' } buildLocalizedPath({ slugs, locale: 'en', config }) // '/en/about' // home slug ('home') zwija się do roota: buildLocalizedPath({ slugs: { en: 'home' }, locale: 'en', config }) // '/en' ``` > **Pułapka typu (TypeScript):** przy `locale: 'all'` Payload w RUNTIME zwraca > zlokalizowane pole jako obiekt `{ pl, en }`, ale wygenerowane typy Payloada > deklarują `doc.slug` jako `string` (typ nie odróżnia trybu `all`). `tsc` > zgłosi więc niezgodność. Rozwiązanie — czyste rzutowanie na oczekiwany przez > helper typ: > ```ts > const slugs = getLocalizedSlugs({ > slugField: doc.slug as unknown as Record, > config, > }) > ``` > To nie hack — to pomost między statycznym typem (string) a rzeczywistym > kształtem runtime (obiekt), którego generator typów Payloada nie modeluje. > `as unknown as` jest tu poprawne, bo TS nie zna trybu `all`. ### Przełącznik języka (bez 404) ```ts // /pl/strona-glowna → klik EN → /en/home (albo /en jeśli brak tłumaczenia) const href = switchLocalePath({ slugs, targetLocale: 'en', config }) router.push(href) ``` `switchLocalePath` nigdy nie zwraca undefined — brak slug w danym locale → fallback na `/{locale}` (root), zamiast dead-endu na 404. ## Middleware — patrz osobno Negocjacja locale + redirect na wejściu (`domena.com` → `/pl`) jest w `@intecion/ipal-kit/next/middleware`. Zobacz [proxy w tej sekcji](#proxy-dawniej-middleware) niżej. ## Proxy (dawniej middleware) > **Next 16:** plik nazywa się teraz `proxy.ts`, funkcja `proxy`. Import z > pluginu (`@intecion/ipal-kit/next/middleware`) bez zmian — to nazwa subpath > eksportu, niezależna od nazwy pliku projektu. Migracja: > `npx @next/codemod@canary middleware-to-proxy .` ```ts // proxy.ts (projekt klienta) — jedyna logika to podłączenie import { NextResponse } from 'next/server' import { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from '@intecion/ipal-kit/next/middleware' const i18nConfig = { defaultLocale: 'pl', locales: [{ code: 'pl', label: 'Polski' }, { code: 'en', label: 'English' }], } const localeMiddleware = createLocaleMiddleware({ config: i18nConfig }) export function proxy(req) { const r = localeMiddleware(req) if (r.type === 'next') return NextResponse.next() const res = NextResponse.redirect(r.location) if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value) return res } export const config = { matcher: DEFAULT_MIDDLEWARE_MATCHER } ``` Zachowanie: - ścieżka z locale (`/pl/...`) → przepuść - root albo ścieżka bez locale (`/`, `/o-nas`) → redirect na `/{locale}...`, locale z: cookie → Accept-Language → default - wybrany locale zapisany w cookie (`LOCALE_COOKIE_NAME`) `DEFAULT_MIDDLEWARE_MATCHER` wyklucza `api`, `admin`, `_next`, pliki statyczne. ## Cookie locale a zgoda (RODO) Wybór języka zapisywany jest w cookie **`NEXT_LOCALE`** (konwencja Next.js — kompatybilna z innymi bibliotekami i18n, które czytają aktywny locale). Ale zapis podlega zgodzie: to cookie kategorii **functional**, więc: - **Zapis TYLKO za zgodą.** Middleware zapisuje `NEXT_LOCALE` jedynie, gdy użytkownik zgodził się na kategorię functional (`mayPersistLocale` sprawdza zgodę). Bez zgody język działa per żądanie (negocjacja z Accept-Language), ale nie jest utrwalany. - **Sprzątanie po cofnięciu zgody.** Gdy użytkownik cofnie zgodę na functional, cookie `NEXT_LOCALE` jest usuwane automatycznie (consent zna tę cookie przez `DEFAULT_COOKIE_MAP` — patrz [consent.md](./consent.md)). Nazwa cookie to jedna stała `LOCALE_COOKIE_NAME` (`modules/i18n/negotiateLocale`), propagująca do middleware i sprzątania consent. Można nadpisać w `createLocaleMiddleware({ cookieName })`, ale domyślnie `NEXT_LOCALE` jest zalecane (interop). ### Kolejność negocjacji locale 1. Cookie `NEXT_LOCALE` (jeśli jest — czyli był wybór za zgodą) 2. Nagłówek `Accept-Language` (preferencje przeglądarki) 3. `defaultLocale` z konfiguracji Wejście na `/` → negocjacja → redirect na `/pl` (albo wynik negocjacji). Zmiana języka (URL `/en` różny od cookie) → zapis nowego wyboru (za zgodą). > **Migracja ze starej nazwy:** wcześniej cookie nazywało się `ipal-locale`. > Po zmianie na `NEXT_LOCALE` użytkownicy ze starą cookie przejdą raz ponowną > negocjację (stara cookie ignorowana). Jednorazowe, bez wpływu na nowe projekty. ## Strona jednojęzyczna (bez prefiksu /pl) Gdy projekt ma JEDEN język, adresy nie mają prefiksu locale: `/o-nas`, nie `/pl/o-nas`. Plugin wykrywa to automatycznie — **jeden locale w config = tryb jednojęzyczny**. Helpery (buildLocalizedPath, hreflang, middleware) dostosowują się same: - **buildLocalizedPath** → `/o-nas` (bez `/pl`), home → `/` - **buildHreflangAlternates** → pusto (jeden język = brak alternatyw językowych) - **localeMiddleware** → pass-through (brak przekierowania `/` → `/pl`, brak negocjacji) - **canonical** → `https://klient.pl/o-nas` (bez prefiksu) ### Config — jeden locale ```ts // i18n.config.ts export const i18nConfig = { locales: [{ code: 'pl', label: 'Polski' }], // JEDEN locale defaultLocale: 'pl', } ``` ### Struktura katalogów — BEZ [locale] To kluczowa różnica. Projekt jednojęzyczny NIE ma folderu `[locale]`: ``` # JEDNOJĘZYCZNY (bez [locale]) app/(frontend)/ layout.tsx # locale stałe z config, nie z params not-found.tsx [[...slug]]/page.tsx # /o-nas, /kontakt # WIELOJĘZYCZNY (z [locale]) — dla porównania app/(frontend)/[locale]/ layout.tsx # locale z params [[...slug]]/page.tsx # /pl/o-nas, /en/about ``` ### Layout jednojęzyczny — locale z config ```tsx // app/(frontend)/layout.tsx (bez [locale]) import { i18nConfig } from '@/i18n.config' export default async function Layout({ children }: { children: React.ReactNode }) { const locale = i18nConfig.defaultLocale // stałe, nie z params const settings = await getSettings(locale) // ...reszta jak zwykle, ale locale jest stałe return ... } ``` ### Strony jednojęzyczne — locale z config ```tsx // app/(frontend)/[[...slug]]/page.tsx (bez [locale]) import { i18nConfig } from '@/i18n.config' export async function generateMetadata({ params }) { const { slug } = await params // TYLKO slug, nie locale const locale = i18nConfig.defaultLocale // stałe return pageMetadata({ payload: await getCachedPayload(), locale, slug }) } export default async function Page({ params }) { const { slug } = await params const locale = i18nConfig.defaultLocale // stałe const route = await resolveRoute(locale, slug ?? [], pageNum) // ... } ``` resolveRoute i inne helpery działają bez zmian — dostają stałe locale z config zamiast z URL. Cała różnica to: brak `[locale]` w strukturze, locale z config. ### Middleware/proxy — jednojęzyczny prawie go nie potrzebuje Dla jednego locale middleware jest pass-through (nic nie przekierowuje). Możesz go pominąć albo zostawić — plugin i tak wykryje 1 locale i przepuści. Bez przełącznika języka (jeden język), bez cookie NEXT_LOCALE (nie ma co pamiętać). ### Przejście jedno- → wielojęzyczny (later) Jeśli klient później doda drugi język, to PRZEBUDOWA, nie przełącznik: - dodaj locale do config - przenieś strukturę do `[locale]/` - layout/strony czytają locale z params - wróci prefiks `/pl`, `/en` + hreflang Warto to przewidzieć na starcie: jeśli jest szansa na drugi język, rozważ od razu strukturę wielojęzyczną (z [locale]), nawet dla jednego locale — wtedy prefiks `/pl` jest, ale dodanie języka to tylko config, nie przebudowa struktury.