251 lines
9.0 KiB
Markdown
251 lines
9.0 KiB
Markdown
# 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<string, unknown>,
|
|
> 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 <html lang={locale}>...</html>
|
|
}
|
|
```
|
|
|
|
### 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. |