[Intum Pomoc](https://intum.pl/pomoc.md) / [API](https://intum.pl/pomoc/cms/api.md)

# [Wielojęzyczność witryny](https://intum.pl/pomoc/cms/api/wielojezycznosc.md)

Dokumentacja mechanizmów wielojęzyczności w Intum CMS — Page, Paragraph, Layout, Site, Domain.

**Powiązane:** [CMS API](https://intum.intum.pl/kb/intum-kb/cms/api/witryny-i-strony) | [Wytyczne CMS](https://app.intum.pl/noe/prompt/cms_site_tips.md)

---

## Wprowadzenie

Intum CMS wspiera kilka strategii wielojęzyczności — od najprostszej (te same fields, różne lokale) po pełne tłumaczenia stron (osobne URL per język). Wybór strategii zależy od skali projektu i wymagań SEO.

**Obsługiwane locale:** `pl`, `en`, `fr`, `cs`, `sk`, `de`, `es`, `uk` (definiowane w `Intum::LOCALES`).

---

## TL;DR — 3 sposoby zrobienia wielojęzycznej strony (na przykładzie `/blog`)

Konkretny case: chcesz mieć stronę pod path `blog` w kilku językach. Masz 3 podejścia — różnią się liczbą rekordów w DB, sposobem zarządzania treścią i URL-ami.

### Sposób 1 — Jedna strona bez `locale`, tłumaczenia w `fields`

Tworzysz **jeden rekord** `cms_pages` z `path: "blog"` i `locale: nil`. Tłumaczenia trzymasz w `fields` jako locale-keyed hash:

```json
{
  "page": {
    "code": "blog",
    "path": "blog",
    "locale": null,
    "fields": {
      "pl": { "title": "Blog firmowy", "intro": "Najnowsze wpisy..." },
      "en": { "title": "Company Blog", "intro": "Latest posts..." }
    }
  }
}
```

Język wybierany w runtime przez Liquid wg `domain.locale` / `site.locale` / `?lang=`. URL **ten sam** pod każdą domeną (`firma.pl/blog`, `firma.com/blog`). Patrz Strategia 1 + 2.

- ✅ Jedna strona w DB, zero duplikacji, łatwe dodanie nowego języka (dopisujesz klucz w `fields`).
- ❌ Ten sam URL na każdej domenie — brak różnych ścieżek per język. Sensowne tylko gdy treść jest 1:1 tłumaczona i path nie musi się różnić.

### Sposób 2 — Dwie niezależne strony z tym samym `path` ale różnym `locale`

Tworzysz **dwa rekordy**: `path: "blog", locale: "pl"` i `path: "blog", locale: "en"`. URL **ten sam** (`/blog`), ale router wybiera stronę pasującą do `domain.locale` (lub `?lang=`). Patrz Strategia 3a (wariant z tym samym path).

```json
{ "page": { "code": "blog-pl", "path": "blog", "locale": "pl", "content": "<h1>Blog</h1>..." } }
{ "page": { "code": "blog-en", "path": "blog", "locale": "en", "content": "<h1>Blog</h1>..." } }
```

Wymaga `site.multilang = true` żeby router preferował match po locale (najpierw szuka strony z `path=blog, locale=domain.locale`, potem `locale=nil`). W obrębie `(path, site, account)` można mieć po jednym rekordzie per locale + opcjonalnie jeden bez locale.

- ✅ Pełna swoboda — różny `content`, `layout_id`, `html_*` per język. Każdy rekord żyje własnym życiem.
- ❌ Duplikacja struktury (layout/content) gdy strony są podobne — wszystko trzymasz dwa razy.

### Sposób 3 — Master + slave z `based_on_page_id` (dziedziczenie)

Tworzysz **mastera** (`path: "blog", locale: "pl"`) z pełnym contentem, i **slave'a** (`path: "blog", locale: "en"`) który dziedziczy puste pola od mastera przez `based_on_page_id`. Slave nadpisuje tylko to co inne (np. `fields`, `html_title`). Patrz Strategia 3b.

```json
{ "page": { "code": "blog-pl", "path": "blog", "locale": "pl",
    "content": "<h1>{{ title }}</h1><p>{{ intro }}</p>",
    "fields": { "title": "Blog firmowy", "intro": "Najnowsze wpisy..." },
    "html_title": "Blog — Firma" } }

{ "page": { "code": "blog-en", "path": "blog", "locale": "en",
    "based_on_page_code": "blog-pl",
    "fields": { "title": "Company Blog", "intro": "Latest posts..." },
    "html_title": "Blog — Company" } }
```

Slave ma pusty `content` → leci z mastera. `fields` mergowane per klucz. Layout, `html_description`, `html_keywords` → fallback do mastera gdy puste.

- ✅ Brak duplikacji struktury — edytujesz `content`/`layout` raz na masterze, slave dziedziczy. Tłumaczenia per pole.
- ❌ Dwa rekordy do utrzymania, fallback niewidoczny "gołym okiem" w slavie (puste pole = wartość mastera).

### Którą wybrać?

| Sytuacja | Sposób |
|---|---|
| Treść identyczna, tylko inne stringi/przyciski | **1** — jeden rekord, locale-keyed `fields` |
| Treść mocno różna per język, brak wspólnej struktury | **2** — niezależne strony |
| Wspólny layout/content, ale per język inne teksty/meta | **3** — master + slave |

Pełen opis każdej strategii i dodatkowe warianty (różne `path` per język, multi-domain, mixed routing) — w sekcjach "Strategia 1–4" poniżej.

---

## Wybór języka renderowania (lokale chain)

Efektywny locale strony wybierany jest wg priorytetu:

1. **`page.locale`** — własny locale strony (przypisany do niej w bazie). Gdy ustawiony, wygrywa nad wszystkim innym (strona z `locale=en` zawsze renderuje się jako EN, nawet pod `firma.pl` i z `?lang=de`).
2. **`?lang=en`** (URL parametr) — jawne wymuszenie języka dla strony bez `page.locale`. Działa tylko dla obsługiwanych kodów (`pl`, `en`, `fr`, `cs`, `sk`, `de`, `es`, `uk`); nieobsługiwany jest ignorowany.
3. **`domain.locale`** — locale aktualnej domeny z requestu (np. `firma.pl=pl`, `firma.com=en`).
4. **`site.locale`** — domyślny locale całego site'a.

### Resolve klucza w `fields`

Mając wybrany locale (np. `pl`), klucz w `fields` rozwiązywany jest osobno:

1. **`fields[locale][klucz]`** — jeśli klucz istnieje w wybranym locale, wygrywa
2. **`fields[klucz]`** (top-level) — fallback per-klucz, gdy klucza nie ma w `fields[locale]`

Fallback działa per-klucz — pojedyncza wartość może być tylko w locale, inna tylko top-level. Sub-hashe dla locale, których nie ma na liście obsługiwanych, są ignorowane.

> **Uwaga:** `?lang=` **nie nadpisuje `page.locale`**. Strona z `locale=en` zawsze renderuje EN. `?lang=` służy głównie do wyboru wariantu w obrębie strony bez ustawionego `page.locale` (np. strona główna z locale-keyed `fields`).

---

## Strategia 1: Per-domain locale (najprostsza, zalecana dla 2-3 języków)

**Use case:** ten sam content, różne domeny → różne języki. Np. `site.pl` po polsku, `site.com` po angielsku.

**Konfiguracja:**

1. Site ma podłączone dwie domeny: `site.pl` i `site.com`
2. W edycji każdej domeny (`/account/domains/{id}/edit`) ustawiasz pole **locale**: `pl` dla `site.pl`, `en` dla `site.com`
3. Page/Paragraph/Layout mają `fields` z locale-keyed strukturą (patrz niżej)

**Działanie:**

- Wejście `site.pl/contact` → `domain.locale=pl` → renderuje wartości z `fields.pl.*` z fallbackiem do top-level
- Wejście `site.com/contact` → `domain.locale=en` → renderuje wartości z `fields.en.*`

**Zalety:** zero duplikacji content, jeden rekord page/paragraph na język, pełen SEO per domena.

**Wady:** ten sam path URL pod oboma domenami (`/contact` jest pod `.pl` i pod `.com`). Jeśli chcesz różnych URLi per język (np. `/cennik` po polsku, `/pricing` po angielsku) — zobacz Strategia 3.

**Sitemap per domena:** `/sitemap.xml` automatycznie filtruje strony po `domain.locale` — pod `firma.pl/sitemap.xml` lecą tylko strony z `locale=pl` lub `locale` puste, pod `firma.com/sitemap.xml` tylko `locale=en` lub puste. Strony bez ustawionego locale są uniwersalne i pojawiają się w obu sitemapach. Gdy domena nie ma locale — sitemap pokazuje wszystkie strony (jak dotąd).

---

## Strategia 2: Locale-keyed fields (per-pole tłumaczenia w jednym rekordzie)

**Struktura `fields` z locale (działa w Page, Paragraph, Layout):**

```json
{
  "en": {
    "position": "CTO",
    "experience": "Expert in distributed systems."
  },
  "pl": {
    "position": "Dyrektor Techniczny",
    "experience": "Ekspert w systemach rozproszonych."
  },
  "default_field": "wartość bez locale"
}
```

**Działanie (przy `page.locale = nil`, `domain.locale = "pl"`):**

- bez `?lang=` → `{{ position }}` = "Dyrektor Techniczny" (z `pl` — chain: brak `page.locale` → `domain.locale=pl`)
- `?lang=en` → `{{ position }}` = "CTO" (z `en` — `?lang=` bije `domain.locale` w `current_locale`)
- `?lang=xx` → `{{ position }}` = "Dyrektor Techniczny" (nieobsługiwany kod ignorowany → fallback do `domain.locale=pl`)
- `{{ default_field }}` = "wartość bez locale" (top-level zawsze widoczny gdy klucz nie istnieje w wybranym locale)

**Gdy `page.locale = "pl"` jest ustawiony** — `?lang=en` **nie wybierze** wariantu `en`, bo `page.locale` ma priorytet (własny locale strony bije locale z requestu). Locale-keyed `fields` w połączeniu z jawnym `page.locale` ma sens głównie dla wzbogacenia tłumaczeniami stron specyficznych dla jednego języka.

**Layout również wspiera locale w fields** — dziedziczy kontekst (`page.locale` → `current_locale` → `site.locale`) z aktualnie renderowanej strony, więc `{{ layout.title }}` zwróci wariant językowy zgodnie z tymi samymi regułami fallback.

**Uwaga:** Struktura locale (`pl`, `en`, itp.) jest opcjonalna. Jeśli nie potrzebujesz wielojęzyczności, używaj zwykłych pól:

```json
{
  "position": "Developer",
  "experience": "10 lat"
}
```

---

## Strategia 3: Osobne strony per język (path per locale)

Dwie strony — jedna pl, druga en — z różnymi `path`. Każda z własnym `locale` ustawionym jawnie. Zalecane dla pełnej kontroli SEO i kiedy treść stron znacząco się różni między językami.

### Wariant 3a: Niezależne strony

Tworzysz dwie osobne strony — bez powiązania, każda z pełnym contentem:

```json
POST /cms/pages.json
{ "page": { "code": "about-pl", "path": "o-firmie", "locale": "pl", "content": "..." } }

POST /cms/pages.json
{ "page": { "code": "about-en", "path": "about", "locale": "en", "content": "..." } }
```

**Zalety:** pełna swoboda, niezależne content per język, łatwe w nawigacji.

**Wady:** duplikacja layoutu/struktury jeśli content jest podobny.

### Wariant 3b: Strony z dziedziczeniem (`based_on_page_id`)

Master + slave — slave dziedziczy puste pola od mastera. Pola które slave ma własne (niepuste) — nadpisują wartości mastera.

```json
// master (pl) - pełny content i fields
{ "page": { "code": "cennik-pl", "path": "cennik", "locale": "pl",
    "content": "full polish content z {{ price }}",
    "fields": {"title": "Cennik", "price": "100 zł"},
    "html_title": "Cennik usług" } }

// slave (en) - nadpisuje tylko to co inne, reszta z mastera
{ "page": { "code": "cennik-en", "path": "pricing", "locale": "en",
    "based_on_page_code": "cennik-pl",
    "fields": {"title": "Pricing", "price": "$25"},
    "html_title": "Service pricing" } }
```

**Działanie:** wszystkie puste pola slave'a (`content`, `layout_id`, `html_title`, `html_description`, `html_keywords`, `fields[k]`) lecą z mastera. Slave nadpisuje tylko to co ma własne.

**Pola dziedziczone z fallbackiem do mastera (`effective_*`):**

- `content` (treść strony)
- `layout_id` (szablon)
- `html_title`, `html_description`, `html_keywords` (meta tagi)
- `fields[*]` — merge per klucz (slave nadpisuje master, brakujące klucze lecą z mastera; locale-keyed sub-hashe też są merge'owane)

**Ograniczenia:**

- **Tylko 1 poziom dziedziczenia** — slave nie może być masterem dla innej strony. Walidacja blokuje ustawienie `based_on_page_id` na stronę, która sama już jest slave'em (ma `based_on_page_id` ≠ NULL).
- **Self-reference zakazane** — strona nie może dziedziczyć sama z siebie.
- **Same site only** — master i slave muszą należeć do tego samego site'a.
- **Po `destroy` mastera** — `based_on_page_id` slave'ów jest zerowane (`dependent: :nullify`), slave staje się standalone z własnymi (potencjalnie pustymi) polami.

**Zalety:** brak duplikacji, łatwa edycja "tylko tytułu i kilku stringów per język", prosty fallback (jeden hop do mastera).

**Wady:** dwa rekordy do utrzymania, magia inheritance niewidoczna w surowym JSON.

**UI:** w `/cms/pages/new` i `/cms/pages/{id}/edit` jest selector "Dziedziczy ze strony" z dostępnymi master-stronami (z tego samego site, bez stron które same dziedziczą). Na liście `/cms/pages` slave'y mają pod nazwą małą ikonkę z linkiem do mastera.

---

## Strategia 4: Mieszany routing (`path` per locale, jedna lub wiele domen)

**Use case:** chcesz wymieszać schematy URL — np. polski na własnej domenie, angielski i francuski pod tą samą `.com`, francuski jeszcze dodatkowo z prefiksem `/fr/`. Wszystko na jednym `site` z dziedziczeniem `based_on_page_id`.

### Jak to działa "out of the box"

Router CMS-a matchuje **dowolny** ciąg w polu `path` strony — łącznie ze slashami. Więc strona z `path: "fr/tariff"` jest naturalnie dostępna pod `domena.com/fr/tariff`. Nie ma żadnej dedykowanej obsługi prefiksów typu `/pl/` / `/en/` — to po prostu wynika z tego jak nazwiesz `path`.

### Przykład: master + 2 slaves z różnymi schematami URL

```
POST /cms/pages.json
{ "page": { "code": "cennik-pl", "path": "cennik", "locale": "pl",
    "site_code": "strona1", "kind": "text",
    "content": "<h1>{{ title }}</h1><p>{{ price }}</p>",
    "fields": { "title": "Cennik", "price": "499 zł" } } }

POST /cms/pages.json
{ "page": { "code": "cennik-en", "path": "pricing", "locale": "en",
    "site_code": "strona1", "based_on_page_code": "cennik-pl",
    "fields": { "title": "Pricing", "price": "$129" } } }

POST /cms/pages.json
{ "page": { "code": "cennik-fr", "path": "fr/tariff", "locale": "fr",
    "site_code": "strona1", "based_on_page_code": "cennik-pl",
    "fields": { "title": "Tarifs", "price": "119 €" } } }
```

Zmienna Liquid `{{ seo_alternates }}` na masterze zwróci wszystkie trzy warianty (+ `x-default`), więc po włączeniu `site.multilang` w layoucie automatycznie pojawią się tagi `<link rel="alternate" hreflang>` dla `pl`, `en`, `fr` i `x-default`.

### Konfiguracje domenowe — co działa, co ma haczyk

**A) Jedna domena, path-prefix per język** (`firma.com/cennik`, `firma.com/pricing`, `firma.com/fr/tariff`) — działa idealnie. `get_url` zwraca każdy URL pod tą samą domeną, hreflang i 301 są spójne. Najprostszy setup dla mieszanego routingu.

**B) Wiele domen, jedna na język** (`firma.pl` z `domain.locale=pl`, `firma.com` z `domain.locale=en`) — `domain.locale` służy jako fallback dla efektywnego locale, a 301 redirect przerzuca między wariantami w obrębie domeny. Przy `site.multilang = true` URL generowany dla strony automatycznie wybiera domenę pasującą do `page.locale` (jeśli istnieje), więc `seo_alternates`/`canonical_url` poprawnie wskazują:
- `cennik` (locale `pl`) → `https://firma.pl/cennik`
- `pricing` (locale `en`) → `https://firma.com/pricing`

Fallback: jeśli żadna domena site'a nie ma pasującego locale, wraca do `site.domain || site.domains.first` (jak poza trybem multilang).

**C) Mix A+B** (`firma.pl/cennik` + `firma.com/pricing` + `firma.com/fr/tariff`) — działa w obrębie jednego site'a: master `cennik` (locale `pl`) trafi pod `firma.pl`, slave `pricing` (locale `en`) i slave `fr/tariff` (locale `fr`) trafią pod `firma.com` (jeśli `firma.com` ma `domain.locale=en` jako default; FR korzysta z fallbacku do tej samej domeny, bo nie ma osobnej `domain.locale=fr`). Wystarczy ustawić `site.multilang = true` i nadać domenom odpowiednie locale.

### Kiedy wystarczy to co jest

- Jeden site, jedna domena, wszystko po path → ✅ działa.
- Jeden site, multi-domain z `domain.locale` + `site.multilang` → ✅ `get_url` wybiera domenę po `page.locale`, hreflang/canonical/301 spójne.
- Pełny mix multi-domain + multi-path → ✅ działa w obrębie jednego site'a (patrz wariant C).

---

## Przykład end-to-end: strona główna (multilang) + cennik z dziedziczeniem

Realny case: site `strona1` z dwoma domenami (`firma.pl` po polsku, `firma.com` po angielsku). Mamy:

- **strona główna `/`** — jeden rekord page, content ten sam, ale teksty w `fields` per locale (Strategia 1+2)
- **cennik** — `/cennik` (PL master) i `/pricing` (EN slave dziedziczący z mastera; różne URL, Strategia 3b)

### 1. Domeny

- `firma.pl` z `locale: "pl"`, podłączona do site `strona1`
- `firma.com` z `locale: "en"`, podłączona do site `strona1`

### 2. Strona główna — jeden rekord, ten sam URL pod oboma domenami

```
POST /cms/pages.json
{
  "page": {
    "code": "home",
    "name": "Home",
    "path": "",
    "kind": "text",
    "site_code": "strona1",
    "content": "<h1>{{ title }}</h1><p>{{ subtitle }}</p><a href=\"{{ cta_url }}\">{{ cta_label }}</a>",
    "fields": {
      "pl": {
        "title": "Witamy w Firma",
        "subtitle": "Dostarczamy najlepsze rozwiązania dla biznesu",
        "cta_label": "Zobacz cennik",
        "cta_url": "/cennik"
      },
      "en": {
        "title": "Welcome to Firma",
        "subtitle": "We deliver the best business solutions",
        "cta_label": "See pricing",
        "cta_url": "/pricing"
      }
    },
    "html_title": "Firma — strona główna"
  }
}
```

**Działanie:**

- `firma.pl/` → `domain.locale=pl` → "Witamy w Firma", link CTA do `/cennik`
- `firma.com/` → `domain.locale=en` → "Welcome to Firma", link CTA do `/pricing`
- `firma.pl/?lang=en` → wymuszenie EN mimo polskiej domeny → "Welcome to Firma"

### 3. Cennik — master (PL) + slave (EN) z dziedziczeniem

**Master `/cennik` (PL):** pełen content i `fields`.

```
POST /cms/pages.json
{
  "page": {
    "code": "cennik-pl",
    "name": "Cennik",
    "path": "cennik",
    "locale": "pl",
    "kind": "text",
    "site_code": "strona1",
    "content": "<h1>{{ title }}</h1><div class=\"price\">{{ price }} {{ currency }}</div><p>{{ description }}</p>",
    "fields": {
      "title": "Cennik usług",
      "price": "499",
      "currency": "zł / mies.",
      "description": "Pełen pakiet usług w jednej cenie."
    },
    "html_title": "Cennik — Firma"
  }
}
```

**Slave `/pricing` (EN):** wskazuje na master przez `based_on_page_code`, nadpisuje tylko zmienne pola.

```
POST /cms/pages.json
{
  "page": {
    "code": "cennik-en",
    "name": "Pricing",
    "path": "pricing",
    "locale": "en",
    "kind": "text",
    "site_code": "strona1",
    "based_on_page_code": "cennik-pl",
    "fields": {
      "title": "Service pricing",
      "currency": "USD / month",
      "description": "Full service package at a flat rate."
    },
    "html_title": "Pricing — Firma"
  }
}
```

**Co dzieje się w slave:**

- `content` puste → bierze z mastera (`<h1>{{ title }}</h1>...`)
- `fields.title` własne → "Service pricing"
- `fields.price` brak w slave → bierze z mastera ("499")
- `fields.currency` własne → "USD / month"
- `fields.description` własne → "Full service package..."
- `html_title` własne → "Pricing — Firma"
- `layout_id` puste → layout mastera (lub site fallback)

**Działanie pod domenami:**

- `firma.pl/cennik` → master, locale `pl` → "Cennik usług, 499 zł / mies., Pełen pakiet..."
- `firma.com/pricing` → slave, locale `en` → "Service pricing, 499 USD / month, Full service package..."
  - `price` = "499" leci z mastera, reszta ze slave'a
- `firma.com/cennik` → master pod EN domeną → wciąż content mastera, ale `domain.locale=en` próbuje znaleźć w `fields.en.*` (brak — fallback do top-level master) → "Cennik usług" (wartości master z top-level)

> **Uwaga SEO:** w wariancie 3b każda strona ma swój własny URL (`/cennik` i `/pricing`), a tagi `<link rel="alternate" hreflang="...">` generują się automatycznie po włączeniu `site.multilang` (patrz "SEO multilang — zmienne Liquid" niżej).

---

## Pola locale per model

### Site
- `locale` — domyślny locale dla wszystkich stron site'a. Ostatni fallback w chain (po `page.locale` i `current_locale` z requestu).

### Page
- `locale` — własny locale strony (przypisany do niej w bazie). **Wygrywa nad locale z requestu** (`?lang=`, `domain.locale`) — jeśli strona ma `locale=en`, zawsze renderuje się jako EN niezależnie od domeny i `?lang=`.

### Paragraph
- nie ma własnego pola `locale` — używa locale z kontekstu strony, w której jest renderowany.

### Layout
- nie ma własnego pola `locale` — używa locale z kontekstu strony, w której jest renderowany.

### Domain (`/account/domains/{id}/edit`)
- `locale` — locale aktualnej domeny. Wchodzi do `current_locale` w kontrolerze (po `?lang=`, przed `site.locale`). Pozwala na strategię "per-domain locale" — `site.pl=pl`, `site.com=en`. **Nie nadpisuje `page.locale`** — strony z ustawionym `locale` renderują się we własnym języku.

---

## Liquid — dostęp do localized fields

Wszystkie odwołania w Liquid automatycznie używają zlokalizowanych wartości:

```liquid
{{ title }}             — z page.fields, według locale chain
{{ page.title }}        — to samo (page = drop)
{{ paragraph.position }} — z paragraph.fields, według locale chain
{{ layout.footer_text }} — z layout.fields, według locale chain
```

Top-level klucze w `fields` służą jako fallback gdy nie ma wpisu w aktualnym locale.

---

## SEO multilang — zmienne Liquid

Włącz checkbox **Wielojęzyczność (SEO)** na site (`site.multilang = true`). Wtedy w renderowaniu stron tego site'u dostępne są dodatkowe zmienne Liquid, które używasz w **layoucie** żeby wyemitować poprawne tagi SEO i 301 redirecty:

| Zmienna | Typ | Opis |
|---|---|---|
| `{{ html_lang }}` | string | Effective locale: `page.locale` → `site.locale` → `domain.locale`. Do `<html lang="...">`. |
| `{{ canonical_url }}` | string | URL kanoniczny strony. Przy multilang wybiera domenę z `domain.locale = page.locale` (fallback: `site.domain` / pierwsza domena). |
| `{{ seo_alternates }}` | array | Lista `{ locale, url }` wariantów językowych (master + slaves z `based_on_page_id`) + `{ locale: "x-default", url: <master> }`. URL-e per locale-matching domain. |
| `{{ seo_head }}` | HTML | Pre-renderowany blok `<link rel="canonical">` + `<link rel="alternate" hreflang>` — drop-in do `<head>`. |

**301 redirect path↔locale** działa automatycznie, gdy `site.multilang = true`: wejście na `firma.com/cennik` (PL master pod EN domeną) → 301 do `firma.com/pricing` (slave EN w grupie `based_on_page`).

W preview (`/w/<site>/...`) zmienne `canonical_url`, `seo_alternates`, `seo_head` nie są ustawiane (preview URL nie powinno trafić do indeksów).

### Przykład — minimalny layout SEO (drop-in)

```liquid
<!DOCTYPE html>
<html lang="{{ html_lang }}">
<head>
  <meta charset="UTF-8">
  <title>{{ html_title }}</title>
  <meta name="description" content="{{ html_description }}">
  {{ seo_head }}
</head>
<body>
  {{ content }}
</body>
</html>
```

### Przykład — manualne renderowanie hreflang

Gdy chcesz pełną kontrolę nad markupem (np. dodać własne atrybuty, zmienić kolejność):

```liquid
<!DOCTYPE html>
<html lang="{{ html_lang }}">
<head>
  <title>{{ html_title }}</title>

  {% if canonical_url %}
    <link rel="canonical" href="{{ canonical_url }}" />
  {% endif %}

  {% for alt in seo_alternates %}
    <link rel="alternate" hreflang="{{ alt.locale }}" href="{{ alt.url }}" />
  {% endfor %}
</head>
<body>{{ content }}</body>
</html>
```

Dla mastera `/cennik` (PL) z slave'em `/pricing` (EN) wygeneruje to:

```html
<html lang="pl">
<head>
  <link rel="canonical" href="https://firma.pl/cennik" />
  <link rel="alternate" hreflang="pl" href="https://firma.pl/cennik" />
  <link rel="alternate" hreflang="en" href="https://firma.com/pricing" />
  <link rel="alternate" hreflang="x-default" href="https://firma.pl/cennik" />
</head>
```

---

## Zalecenia

1. **2-3 języki, ten sam URL** → Strategia 1 (per-domain locale) + Strategia 2 (locale-keyed fields)
2. **Różne URL per język, prosta struktura** → Strategia 3a (niezależne strony)
3. **Różne URL, dużo wspólnego content** → Strategia 3b (`based_on_page_id`)
4. **Mieszany routing (path-prefix + multi-domain)** → Strategia 4 (najlepiej path-prefix pod jedną domeną, lub osobne sites na język)

---

## Blog multilang: jeden `category_code`, język w `locale`

Tag `<cms type="article">` filtruje artykuły po języku sam: gdy site ma `multilang: true`,
lista pokazuje tylko artykuły z `locale` domeny i artykuły bez `locale`. Język wpisujemy więc
w pole `locale` artykułu, a `category_code` (kategoria główna) zostaje ten sam dla wszystkich języków.

```html
<cms type="article" category_code="blog" per_page="12">
  <list><!-- karty --></list>
  <show><!-- artykuł --></show>
</cms>
```

```
PATCH /cms/articles/123.json
{ "article": { "category_code": "blog", "locale": "en" } }
```

**Nie dziel bloga na `blog-pl`, `blog-en`, `blog-es`:**

- każdy kod to osobna kategoria (`Cms::Category`) i osobna sekcja witryny, więc każda potrzebuje
  strony z tagiem `<cms type="article">` - bez niej system sam założy stronę `/blog`
  z tagiem „wszystkie kategorie" i tam trafią artykuły,
- treść `<list>` i `<show>` trzeba wtedy duplikować w każdym branchu `{% if html_lang %}`,
- artykuł bez `locale` (np. wspólna informacja) nie pojawi się na żadnej z takich list.

Gdy blog jest już podzielony per język: ustaw `locale` na artykułach, zamień wszystkie kody
na jeden (`blog`) i zostaw jedną stronę listingową. Przejściowo lista może połączyć stare kody:
`category_code="blog-pl,blog-en,blog-es"` (kilka kodów po przecinku w jednym tagu).

**WAŻNE (gdy mimo wszystko używasz `{% if %}` wokół tagów):** każdy branch MUSI mieć KOMPLETNY
`<cms>...</cms>` blok (open + list + show + close). NIE WOLNO dzielić `<cms>` między if/else —
CMS parsuje WSZYSTKIE tagi `<cms>` niezależnie od Liquid i dostaje 500.

**Zasady:**
- `<cms>` tagi przetwarzane PRZED Liquid — nie można użyć `{{ }}` w atrybutach `<cms>` (np. `category_code="{{ html_lang }}"` NIE działa)
- Blog listing (master) może mieć slave'y (np. `blog-en`) — są PUSTE, dziedziczą content z mastera
- Podział tematyczny wewnątrz bloga (chipy "Aktualności / Porady") robi się **dodatkowymi kategoriami z `path`** (`category_codes: ["blog", "porady"]`), nie kolejnymi kodami głównymi ani językiem w kodzie — patrz [CMS API](https://intum.intum.pl/kb/intum-kb/cms/api/witryny-i-strony)

---

## Migracja stron na multilang (procedura)

### Krok 1: Przygotuj hreflang mapę
Pobierz sitemapy ze starych domen. Stwórz mapę PL_path → {en: EN_path, es: ES_path}.

### Krok 2: Stwórz PL mastery
- Zamień sufiksy `{{ field_en }}` → `{{ field }}` (regex: `\{\{(\s*)(\w+?)_(en|es|pl)(\s*)\}\}` → `{{\1\2\4}}`)
- Stwórz PL master z locale-keyed fields
- Path = **polska ścieżka** (nie angielska!)

### Krok 3: Zamień EN/ES na slave'y
```
PATCH /cms/pages/{id}.json
{ "page": { "path": "pricing", "locale": "en", "based_on_page_code": "cennik-pl", "content": "", "fields": {} } }
```

### Krok 4: Ustaw site
```
PATCH /cms/sites/{code}.json
{ "site": { "multilang": true, "locale": "pl" } }
```

### Krok 5: Podepnij domeny z locale
Każda domena z odpowiednim locale (np. `firma.pl` → pl, `firma.com` → en).

### Krok 6: Layout
- `<html lang="{{ html_lang | default: 'pl' }}">`
- `{{ seo_head }}` przed `</head>`
- Używaj `{{ layout.nazwa }}` dla layout fields (nie `{{ nazwa }}` — to resolves z page fields)

---

## Częste błędy multilang

1. **PL master z angielskim path** — np. `pricing` zamiast `cennik`. Slave EN nie może mieć path `pricing` bo koliduje z masterem
2. **Sufiksy w template** — `{{ hero_title_en }}` nie działa, musi być `{{ hero_title }}`. CMS resolves z locale-keyed fields automatycznie
3. **`{{ locale }}` w layoucie** — używaj `{{ html_lang }}` (effective locale), nie `{{ locale }}` (pole page)
4. **Homepage** — path="" jest 1 per site. Homepage nie ma slave'a, tłumaczenia przez locale-keyed fields
5. **html_title/html_description na slave** — to kolumny, nie fields. Slave zachowuje swoje meta (nie czyść ich)
6. **Path slave'a z prefixem locale** — domyślnie BEZ prefixu (`pricing` nie `en/pricing`). Prefix tylko gdy path koliduje z masterem PL
7. **`?lang=` na stronie z `page.locale`** — `page.locale` wygrywa, więc `?lang=` nic nie zmieni. Aby URL parametr działał, strona musi mieć `locale=nil` (np. homepage z locale-keyed `fields`)

---

**Powrót:** [CMS API](https://intum.intum.pl/kb/intum-kb/cms/api/witryny-i-strony)