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

# [Witryny, strony i szablony](https://intum.pl/pomoc/cms/api/witryny-i-strony.md)

Dokumentacja API do tworzenia stron CMS przez HTTP.

**Autoryzacja:** Wszystkie requesty wymagają `Authorization: Bearer TOKEN`

**Ważne:** Zamiast `*_id` można używać `*_code` (np. `site_code` zamiast `site_id`)

**Zobacz też:**
- [Wytyczne tworzenia stron (responsywność, menu mobilne)](https://app.intum.pl/noe/prompt/cms_site_tips.md)
- [Wielojęzyczność CMS — strategie, locale chain, per-domain locale](https://intum.intum.pl/kb/intum-kb/cms/api/wielojezycznosc)
- [Paragrafy — rodzaje, szablony prezentacji, zdjęcia, boxy](https://intum.intum.pl/kb/intum-kb/cms/api/paragrafy)

---

## API Endpoints

### Layouts (Szablony)

| Metoda | Ścieżka | Opis |
|--------|---------|------|
| GET | `/cms/layouts.json` | Lista szablonów |
| GET | `/cms/layouts/:code.json` | Szczegóły (po code lub id) |
| POST | `/cms/layouts.json` | Utworzenie |
| PATCH | `/cms/layouts/:code.json` | Aktualizacja |
| PATCH | `/cms/layouts/:code/set_field.json` | Zapis jednego klucza `fields` szablonu (parametry jak w `set_field` strony, tylko wartości tekstowe) — wartość wspólna dla wszystkich stron z tym szablonem, w treści `{{ layout.klucz }}` |
| DELETE | `/cms/layouts/:code.json` | Usunięcie |

### Sites (Strony WWW)

| Metoda | Ścieżka | Opis |
|--------|---------|------|
| GET | `/cms/sites.json` | Lista site'ów |
| GET | `/cms/sites/:code.json` | Szczegóły |
| POST | `/cms/sites.json` | Utworzenie |
| PATCH | `/cms/sites/:code.json` | Aktualizacja |

### Pages (Strony)

| Metoda | Ścieżka | Opis |
|--------|---------|------|
| GET | `/cms/pages.json` | Lista stron |
| GET | `/cms/pages/:code.json` | Szczegóły |
| POST | `/cms/pages.json` | Utworzenie |
| PATCH | `/cms/pages/:code.json` | Aktualizacja |
| GET | `/cms/pages/:code/env.json` | Dane strony (env) |
| PATCH | `/cms/pages/:code/set_field.json` | Zapis jednego klucza `fields` (`key`, `value`, opcjonalnie `locale`; albo `values: { "": domyślna, "en": ... }` - kilka wersji językowych naraz; pusta wartość usuwa klucz). **Tylko wartości tekstowe** - `value` zapisuje się jako string, więc tablicy/obiektu (menu, cennik, FAQ) tak nie zmienisz, patrz "Edycja tablic w fields" |

### Paragraphs (Paragrafy)

| Metoda | Ścieżka | Opis |
|--------|---------|------|
| GET | `/cms/paragraphs.json` | Lista paragrafów |
| GET | `/cms/paragraphs/:code.json` | Szczegóły |
| POST | `/cms/paragraphs.json` | Utworzenie |
| PATCH | `/cms/paragraphs/:code.json` | Aktualizacja |
| PATCH | `/cms/paragraphs/reorder.json` | Nowa kolejność paragrafów w boxie — `{"ids": [12, 7, 30]}`, zapisuje `priority` 1..N |

### Assets (Pliki)

| Metoda | Ścieżka | Opis |
|--------|---------|------|
| GET | `/cms/assets.json` | Lista assetów |
| GET | `/cms/assets/:id.json` | Szczegóły |
| POST | `/cms/assets.json` | Utworzenie (obsługuje ZIP) |
| PATCH | `/cms/assets/:id.json` | Aktualizacja |
| DELETE | `/cms/assets/:id.json` | Usunięcie |

Adres pliku jest stały: `https://<host assetów>/<id konta>/cms/assets/<nazwa>` - podmiana pliku
pod tą samą nazwą nie zmienia URL-a (zmienia go zmiana `name` albo folderu, bo nazwa folderu
wchodzi do `name`). Pole `fields.cors` włącza pobieranie pliku z innych domen (nagłówek
`Access-Control-Allow-Origin`, potrzebny np. dla `.wasm` ładowanego przez aplikację); ustawić je
może wyłącznie administrator systemu - innym użytkownikom API wycina ten klucz z `fields`.

### Articles (Artykuły)

| Metoda | Ścieżka | Opis |
|--------|---------|------|
| GET | `/cms/articles.json` | Lista artykułów |
| GET | `/cms/articles/:id.json` | Szczegóły |
| POST | `/cms/articles.json` | Utworzenie |
| PATCH | `/cms/articles/:id.json` | Aktualizacja |
| DELETE | `/cms/articles/:id.json` | Usunięcie |

### Categories (Kategorie artykułów)

| Metoda | Ścieżka | Opis |
|--------|---------|------|
| GET | `/cms/categories.json` | Lista kategorii (filtry: `site_id`, `page_id`, `query`) |
| GET | `/cms/categories/:id.json` | Szczegóły |
| POST | `/cms/categories.json` | Utworzenie |
| PATCH | `/cms/categories/:id.json` | Aktualizacja |
| DELETE | `/cms/categories/:id.json` | Usunięcie (422, gdy kategoria jest główną jakiegoś artykułu) |

### Authors (Autorzy artykułów)

| Metoda | Ścieżka | Opis |
|--------|---------|------|
| GET | `/cms/authors.json` | Lista autorów (filtr `query` po nazwie i opisie; `user_id`) |
| GET | `/cms/authors/:id.json` | Szczegóły |
| POST | `/cms/authors.json` | Utworzenie (zwykle niepotrzebne - nowa nazwa w `author` artykułu zakłada autora sama) |
| PATCH | `/cms/authors/:id.json` | Aktualizacja (opis, zdjęcie, strona, podpięcie pod użytkownika) |
| DELETE | `/cms/authors/:id.json` | Usunięcie - artykuły zostają, tracą tylko powiązanie (`author_id`), nazwa w `author` zostaje |

### Preview (Podgląd)

| Metoda | Ścieżka | Opis |
|--------|---------|------|
| GET | `/w/:site_code` | Podgląd strony głównej |
| GET | `/w/:site_code/:path` | Podgląd strony |

---

## Pola modeli

### Layout

| Pole | Typ | Wymagane | Opis |
|------|-----|----------|------|
| `code` | string | tak | Unikalny kod (zalecany format: `{site_code}-layout`) |
| `name` | string | tak | Nazwa |
| `kind` | string | tak | `page` |
| `content` | string | tak | Szablon HTML z `{{ content }}` |
| `site_id` | integer | tak | ID site'a (zawsze podawaj przy tworzeniu layoutu) |
| `example_content` | string | nie | Przykładowa treść do podglądu layoutu |

### Site

| Pole | Typ | Wymagane | Opis |
|------|-----|----------|------|
| `code` | string | tak | Unikalny kod |
| `name` | string | tak | Nazwa |
| `kind` | string | tak | `www` lub `kb` |
| `layout_code` | string | nie | Code layoutu |
| `locale` | string | nie | Domyślny locale dla wszystkich stron site'a (np. `pl`, `en`, `de`, `fr`, `es`, `cs`, `sk`, `uk`). Ostatni fallback w chain — używany gdy strona nie ma własnego `page.locale` ani `?lang=` ani locale domeny. Steruje którym wariantem językowym renderują się pola z locale-keyed `fields` (Page, Paragraph, Layout) |
| `multilang` | boolean | nie | Włącza tryb SEO multilang — dodaje zmienne Liquid `{{ html_lang }}`, `{{ canonical_url }}`, `{{ seo_alternates }}`, `{{ seo_head }}` dostępne w layoucie, oraz 301 redirect path↔locale (patrz [CMS Wielojęzyczność](https://intum.intum.pl/kb/intum-kb/cms/api/wielojezycznosc)) |

### Page

| Pole | Typ | Wymagane | Opis |
|------|-----|----------|------|
| `code` | string | nie | Unikalny kod (zalecany format: `{site_code}-{path}`) |
| `name` | string | tak | Nazwa |
| `kind` | string | tak | `text` |
| `path` | string | tak | URL path (np. "" dla głównej, "about") |
| `site_code` | string | tak | Code site'a |
| `layout_code` | string | nie | Code layoutu |
| `no_layout` | boolean | nie | Strona bez szablonu — renderuje samą swoją treść, bez własnego layoutu i bez domyślnego layoutu witryny (dla stron z własnym pełnym HTML) |
| `content` | string | nie | Treść strony (Liquid) |
| `paragraph_codes` | array | nie | Lista kodów paragrafów |
| `priority` | decimal | nie | Priorytet sortowania |
| `in_menu` | boolean | nie | Czy strona widoczna w menu (domyślnie: true) |
| `menu_code` | string | nie | Kod grupy menu (np. "top_menu", "footer_menu") |
| `redirect_to` | string | nie | URL przekierowania |
| `in_sitemap` | boolean | nie | Czy strona ma być w sitemap.xml (domyślnie: true). Auto-ustawiane na `false` gdy ustawisz `redirect_to`, i z powrotem na `true` gdy `redirect_to` zostanie wyczyszczone. Można ręcznie nadpisać po stronie API jeśli potrzeba |
| `locale` | string | nie | Własny locale strony (np. `pl`, `en`). **Wygrywa nad `?lang=`, `domain.locale` i `site.locale`** — gdy ustawiony, strona zawsze renderuje się w tym języku. Decyduje którym wariantem językowym renderują się pola strony, paragrafów i layoutu z locale-keyed `fields`. Dla strony bez `page.locale` używane jest `?lang=` z URL, potem `domain.locale`, potem `site.locale` (patrz "Wielojęzyczne pola") |

### Paragraph

| Pole | Typ | Wymagane | Opis |
|------|-----|----------|------|
| `code` | string | tak | Unikalny kod (zalecany format: `{site_code}-{nazwa}`) |
| `name` | string | tak | Nazwa |
| `kind` | string | tak | `text` (treść) lub `articles` (lista artykułów) |
| `name` | string | nie | Tytuł paragrafu (opcjonalny). `text`: pokazuje się jako `<h3>` nad treścią (gdy paragraf nie ma layoutu). `articles`: nagłówek `<h2>` nad listą |
| `content` | string | nie | Treść (tylko `kind: text`) |
| `markup` | string | nie | Format treści: `html` lub `markdown` (auto-detect jeśli puste; tylko `kind: text`) |
| `site_code` | string | nie | Code site'a |
| `page_code` | string | nie | Code strony (podłącza paragraf do strony) |
| `box_code` | string | nie | Kod boxa (do grupowania paragrafów, np. "sidebar", "footer") |
| `page_id` | integer | nie | ID strony, do której paragraf należy. Puste = paragraf wspólny dla całej witryny (pokazuje się w swoim boxie na każdej stronie) |
| `priority` | decimal | nie | Priorytet sortowania. Nowy paragraf bez jawnego `priority` trafia na koniec swojego boxa |
| `system_template` | string | nie | Klucz gotowego szablonu prezentacji (nazwa analogiczna do Cms::Layout). `text`: `framed`/`highlight`/`hero`; `articles`: `default`/`cards`/`compact`. Nieznany/pusty nie jest zapisywany — prezentacja domyślna |
| `template_content` | string | nie | Własny markup Liquid prezentacji ("Dostosuj" w edytorze) — wygrywa z `system_template` i layoutem. Dla `articles` warianty w blokach `<list>...</list>` i `<show>...</show>`. Pusty lub identyczny z bazowym jest usuwany; błąd składni Liquid blokuje zapis (422) |
| `layout_id` | integer | nie | Layout paragrafu (Cms::Layout kind `paragraph`) — wielokrotnego użytku szablon prezentacji, patrz sekcja "Szablony prezentacji paragrafów" |

**`kind: articles` — lista artykułów.** Tytuł nad listą to pole `name` paragrafu (opcjonalne). Zamiast `content` paragraf ma konfigurację w `fields`:

| Pole w `fields` | Typ | Wymagane | Opis |
|-----------------|-----|----------|------|
| `category_codes` | array | nie | Kody kategorii (`code` z `Cms::Category`), z których brane są artykuły - artykuł wchodzi, gdy ma którąś z nich. Puste = wszystkie kategorie |
| `per_page` | integer | nie | Ile najnowszych artykułów pokazać. Niepodany nie jest zapisywany w `fields`, a lista pokazuje domyślne 15 |

Paragraf renderuje nagłówek `<h2>` z `name` i najnowsze opublikowane artykuły z podanych kategorii. Każdy szablon ma dwa warianty markupu (`Cms::Paragraph::ArticlesRenderer::TEMPLATES`): `list` (artykuł na liście) i `show` (szczegóły). Zmienne Liquid jak w `<cms type="article">`. Gdy URL wskazuje artykuł (`/strona/artykuł`), pierwszy paragraf `articles` na stronie renderuje szczegóły artykułu wariantem `show` zamiast listy — strona z samym paragrafem (bez tagu `<cms type="article">`) ma więc działający widok szczegółów.

Szablon prezentacji (dla `text` i `articles`) wybiera się kolumnami `system_template` / `template_content` / `layout_id` — patrz sekcja "Szablony prezentacji paragrafów".

### Asset

| Pole | Typ | Wymagane | Opis |
|------|-----|----------|------|
| `name` | string | tak | Nazwa pliku (unikalna w ramach konta) |
| `kind` | string | nie | `image`, `css`, `js`, `zip` (auto-detect z rozszerzenia) |
| `file` | file | nie | Plik do wgrania |
| `folder_id` | integer | nie | ID folderu |
| `site_id` | integer | nie | ID site'a |
| `layout_id` | integer | nie | ID layoutu |

**Import ZIP:** Wgranie pliku ZIP z `kind: "zip"` automatycznie rozpakuje archiwum zachowując strukturę folderów.

**URL pliku:** Po wgraniu asset dostępny jest pod `s3_url` (bezpośredni link S3). W treści stron i artykułów używaj **stabilnego permalinku** `/cms/assets/ID/view` — NIE `s3_url` (może wygasnąć) i NIE `{{ asset 'nazwa' }}` (nie działa jako tag Liquid).

### Article

| Pole | Typ | Wymagane | Opis |
|------|-----|----------|------|
| `title` | string | tak | Tytuł artykułu |
| `path` | string | auto | Ścieżka URL (auto-generowana z tytułu jeśli pusta) |
| `category_id` | integer | nie | ID **kategorii głównej** (kanonicznej) - decyduje o adresie kanonicznym artykułu. Można zdjąć (`null`). Artykuł **bez żadnej kategorii** pokazuje tylko strona z tagiem bez `category_code` (blog ogólny); gdy witryna takiej nie ma, artykuł publicznie daje 404, a podgląd w admince ostrzega i ma przycisk **Dodaj stronę /blog** (`POST /cms/articles/:id/ensure_listing_page`). Tekst zamiast liczby = nazwa nowej kategorii (tak działa opcja „Dodaj nową…" w formatce) - w API używaj `category_code` |
| `category_code` | string | nie | Kod kategorii głównej - wygodniejszy zamiennik `category_id`. Kod, którego nie ma, **zakłada nową kategorię na witrynie artykułu** (nazwa = kod). W odpowiedzi: kod głównej |
| `category_codes` | array | nie | Wszystkie kategorie artykułu (kody, główna pierwsza). W zapisie **zastępuje** zestaw; gdy artykuł nie ma jeszcze głównej, główną zostaje pierwsza z listy. Przyjmuje też string `"news,poradnik"` |
| `category_ids` | array | nie | To samo po ID (formatka: checkboxy **Kategorie**) |
| `locale` | string | nie | Język artykułu (`pl`, `en`, ...). Na witrynie z `multilang` lista pokazuje tylko język domeny i artykuły bez locale |
| `fields` | object | nie | Własne pola artykułu |
| `author` | string | nie | Nazwa autora. Nazwa, której **nie ma** w `/cms/authors.json`, **zakłada autora** przy zapisie (dopasowanie do istniejącego bez względu na wielkość liter - `"fakturownia"` trafia w `"Fakturownia"`). Puste = artykuł bez autora. W odpowiedzi także `author_id` (ID rekordu z `/cms/authors`). Opis, zdjęcie i stronę autora ustawiasz na jego rekordzie, nie na artykule |
| `abstract` | text | nie | Streszczenie/lead - zajawka na liście, fallback meta description, opis w OG i JSON-LD |
| `summary` | text | nie | „W skrócie" (answer-first) - box nad treścią artykułu. Puste = brak boxa. **Nie** trafia do meta description ani do domyślnej zajawki na liście (w szablonie `{{ summary }}` jest dostępne wszędzie) - patrz „Streszczenie, W skrócie i Opis HTML" |
| `content` | text | nie | Pełna treść |
| `markup` | string | nie | Format treści: `html` lub `markdown` (auto-detect jeśli puste) |
| `image_url` | string | nie | URL obrazka głównego |
| `published_at` | datetime | tak | Data publikacji (domyślnie: teraz) |
| `publish_to` | datetime | nie | Data zakończenia publikacji |
| `accepted` | boolean | nie | Zaakceptowany (domyślnie: true) |
| `published` | boolean | nie | Opublikowany (domyślnie: true) |
| `site_id` | integer | tak* | Witryna artykułu - jej strony go pokazują. *Gdy konto ma **dokładnie jedną** witrynę, można pominąć (dobiera się sama); przy kilku brak = 422 z błędem na `site_id`. Kategoria główna musi należeć do tej samej witryny |
| `html_title` | string | nie | Tytuł HTML (meta title) |
| `html_description` | text | nie | Opis HTML (meta description) |
| `html_keywords` | string | nie | Słowa kluczowe (meta keywords) |

**Streszczenie, W skrócie i Opis HTML:**

Trzy pola opisowe, które łatwo pomylić. Każde ma jednego odbiorcę - nie powielaj tej samej treści:

| Pole | Etykieta w formatce | Gdzie trafia |
|------|---------------------|--------------|
| `abstract` | Streszczenie | zajawka na liście artykułów, meta description (gdy `html_description` puste), OG/Twitter, `description` w JSON-LD BlogPosting |
| `summary` | W skrócie | **tylko** box `<div class="cms-article-summary">` nad treścią artykułu + `{{ summary }}` w szablonie; `description` w `llms.txt` (fallback na `abstract`, gdy puste); wyszukiwarka artykułów |
| `html_description` | Opis HTML | meta description wprost - wygrywa z `abstract` |

`summary` jest pisane pod odpowiedź (answer-first, dla wyszukiwarek i modeli), `abstract` pod
kliknięcie. Świadomie nie idzie do meta ani na listę - inaczej box na górze artykułu byłby kopią
meta opisu, a o to właśnie chodziło w rozdzieleniu tych pól.

Renderuje się markdownem/HTML-em jak treść artykułu (zależnie od `markup`). Opcjonalność załatwia
sama treść: puste pole = boxa nie ma, więc nie ma osobnego przełącznika „pokazuj/nie pokazuj".
Szablon, który chce własny wygląd, bierze `{{ summary }}` i renderuje po swojemu.

```
PATCH /cms/articles/123.json
{
  "article": { "summary": "Fakturę korygującą wystawisz w 3 krokach: ..." }
}
```

**Kategorie i adresy (zadanie #1378):**

Kategoria to rekord (`/cms/categories.json`), nie string. Artykuł ma **kilka kategorii** i co najwyżej
jedną **główną** (`category_id`) - opcjonalną (artykuły można pisać przed ustawieniem stron witryny;
bez kategorii pokazuje je tylko blog ogólny). Nie ma już osobnego „tematu" (`topic`,
`fields.category`): dawny temat to dziś dodatkowa kategoria z `path`.

| Wymiar | Pole | Co robi |
|--------|------|---------|
| kategoria główna | `category_id` / `category_code` | wyznacza **adres kanoniczny**: `/<strona kanoniczna kategorii>/<path kategorii?>/<slug>`. Gdy żadna strona witryny jej nie listuje, system **sam zakłada stronę `/blog`** z `<cms type="article"/>` (albo dopisuje ten tag do istniejącego `/blog`) i podpina ją jako kanoniczną |
| kategorie dodatkowe | `category_codes` / `category_ids` | artykuł pokazuje się na każdej stronie, której tag wymienia którąś z jego kategorii |
| `path` kategorii | pole kategorii | segment w adresie artykułu i podstrona listująca `/<strona>/<path>` (chipy `{{ topics }}`, sitemapa). Kategoria bez `path` = artykuł wprost pod stroną |
| język | `locale` | wersja językowa; filtrowana automatycznie na witrynie z `multilang` |

Który segment wchodzi do adresu: `category_code` tagu strony tylko **filtruje**, które artykuły się
pokazują - segment bierze się z kategorii **samego artykułu**: kategoria główna, gdy ma `path`, inaczej
pierwsza dodatkowa z `path` w kolejności `priority` (mniejszy pierwszy), remis po `id`. Żadna kategoria
artykułu nie ma `path` → adres wprost pod stroną. Dzięki temu jedna kategoria z `path` (np. `poradnik`)
daje prefiks wszystkim swoim artykułom pod każdą stroną, bez wymieniania jej w tagu. Przykład: strona
`/page1` z tagiem `category_code="news,poradnik"`, `news` ma `path: news`, `poradnik` ma `path: poradnik`:

| Artykuł | Kategorie | Adres |
|---------|-----------|-------|
| a1 | `news` (główna) | `/page1/news/a1` |
| a2 | `poradnik` (główna), `temat1` | `/page1/poradnik/a2` |
| a3 | `temat1` (główna, ma `path`), nielistowana przez `/page1` | `/blog/temat1/a3` (blog ogólny, tag bez `category_code`) |
| a4 | `blog` (główna, bez `path`) | `/blog/a4` |
| a5 | `blog` (główna, bez `path`), `poradnik` | `/blog/poradnik/a5` - segment z dodatkowej, choć `/blog` nie wymienia `poradnik` |

Adres z innym segmentem albo pod inną stroną listującą (np. `/blog/a1`) robi 301 na kanoniczny.
`?topic=<kod>` na stronie listującej: kategoria z `path` → 301 na `/<strona>/<path>`, bez `path` →
zawężenie listy w miejscu.

Nie koduj języka w kodzie kategorii (`blog-en`) - każdy kod to osobna sekcja.

```
PATCH /cms/articles/123.json
{
  "article": { "category_code": "blog", "category_codes": ["blog", "porady"], "locale": "pl" }
}
```

Zgodność wstecz: `category_code` w JSON nadal jest (kod głównej), a stare `?category=` /
`?topic=` w adresach witryny działają. Pole `topic` i `fields["category"]` zostały usunięte -
migracja zrobiła z każdego tematu kategorię z `path` równym kodowi.

**Logika publikacji:**
- Artykuł jest widoczny gdy: `published = true` AND `published_at` jest w przeszłości AND (`publish_to` jest null LUB `publish_to` jest w przyszłości)
- Gdy `accepted = false`, automatycznie ustawiane jest `published = false`

**Konwencja nazewnictwa:** Aby uniknąć konfliktów między site'ami, używaj prefiksu `{site_code}-` w kodach:
- Layout: `strona1-layout`
- Page: `strona1-home`, `strona1-about`
- Paragraph: `strona1-intro`, `strona1-footer`

---

### Category (kategoria artykułów)

| Pole | Typ | Wymagane | Opis |
|------|-----|----------|------|
| `name` | string | tak | Nazwa pokazywana na witrynie (`{{ c.name }}` w menu i chipach) |
| `code` | string | tak | Kod - unikalny w witrynie (ten sam kod może istnieć na dwu witrynach). Używany w `category_code` tagu i artykułu |
| `path` | string | nie | Segment adresu: artykuły dostają `/<strona>/<path>/<slug>`, a lista podstronę `/<strona>/<path>`. Unikalny w witrynie. Puste = kategoria nie zmienia adresów |
| `priority` | decimal | nie | Kolejność kategorii (domyślnie 1, mniejszy pierwszy, remis po `id`): w `{{ categories }}`/`{{ topics }}`, w liście kategorii artykułu i przy wyborze segmentu adresu, gdy główna nie ma `path`, a kilka dodatkowych ma |
| `site_id` | integer | tak* | Witryna - jej strony listują kategorię. *Gdy konto ma **dokładnie jedną** witrynę, można pominąć (dobiera się sama); przy kilku brak = 422 z błędem na `site_id`. Starsze kategorie mogą mieć puste `site_id` (sprzed wymagalności) - działają na każdej witrynie, ale ich edycja wymaga już wybrania witryny |
| `page_id` | integer | nie | Strona kanoniczna - pod nią mają adres kanoniczny artykuły z tą kategorią główną. Musi listować kategorię (tag bez `category_code` albo z jej kodem); puste = pierwsza strona witryny listująca kategorię. Wypełnia się samo przy automatycznym `/blog` |
| `description` | text | nie | Opis (`{{ c.description }}` w Liquid) |
| `fields` | object | nie | Własne pola |

JSON kategorii nie zawiera `account_id`. `DELETE` kategorii, która jest **główną** jakiegoś
artykułu, zwraca 422 - najpierw zmień tym artykułom kategorię główną; powiązania dodatkowe
kasują się same.

```
POST /cms/categories.json
{ "category": { "name": "Porady", "code": "porady", "path": "porady", "site_id": 5 } }
```

### Author (autor artykułów)

Centralna lista autorów konta (wspólna dla witryn). Nazwa jest unikalna w koncie bez względu
na wielkość liter. Autor **nie musi** być użytkownikiem Intuma (gość, agencja, redakcja), ale
**może** - wtedy puste `name`/`description`/`avatar_url` biorą się z profilu użytkownika,
a wpisane wprost nadpisują profil.

| Pole | Typ | Wymagane | Opis |
|------|-----|----------|------|
| `name` | string | tak | Nazwa - ta sama, którą wpisujesz w `author` artykułu. Unikalna w koncie (bez względu na wielkość liter) |
| `description` | text | nie | Opis publiczny pod artykułem i w schema `Person` |
| `avatar_url` | string | nie | URL zdjęcia (musi być http/https) |
| `url` | string | nie | Strona autora - profil, blog, LinkedIn (http/https); trafia do `sameAs` w schema |
| `user_id` | integer | nie | ID użytkownika Intuma - podpięcie pod profil (nazwa, opis, avatar z profilu, gdy własne puste) |
| `fields` | object | nie | Własne pola |

W odpowiedzi dodatkowo wyliczone `display_name`, `display_description`, `display_avatar_url`
(wartość własna albo z profilu użytkownika). JSON nie zawiera `account_id`.

**Nie zakładaj autora osobnym `POST`, gdy dodajesz artykuł** - wystarczy `author` w artykule,
rekord powstanie sam. `POST /cms/authors.json` ma sens, gdy od razu chcesz nadać opis/zdjęcie,
albo gdy autor ma być podpięty pod użytkownika:

```
POST /cms/authors.json
{ "author": { "name": "Jan Kowalski", "description": "Redaktor bloga", "user_id": 12 } }
```

Tak samo z kategoriami: `category_code` w artykule z kodem, którego nie ma, zakłada kategorię
na witrynie artykułu (nazwa = kod z wielkiej litery). `POST /cms/categories.json` jest potrzebny,
gdy kategoria ma mieć własny `path`, `page_id` lub opis albo ma powstać na innej witrynie.

## Liquid Templates

Strony używają szablonów Liquid. Dostępne zmienne:

| Zmienna | Opis |
|---------|------|
| `{{ name }}` | Nazwa strony |
| `{{ path }}` | Ścieżka strony z `/` (np. `/`, `/about`) |
| `{{ url }}` | Pełny URL strony |
| `{{ content }}` | Treść (w layoucie - miejsce na content strony) |
| `{{ paragraphs }}` | Lista paragrafów przypisanych do strony |
| `{{ pages }}` | Lista wszystkich stron w site (do budowy menu) |
| `{{ page }}` | Obiekt aktualnie wyświetlanej strony |
| `{{ site }}` | Obiekt site'a (`site.name`, `site.code`, `site.description`, `site.url`) |
| `{{ layout }}` | Obiekt layoutu (`layout.name`, `layout.code`, `layout.description`) |
| `{{ categories }}` | Kategorie artykułów witryny z opublikowanymi artykułami, w kolejności `priority`, potem `id` - do menu bloga (`code`, `name`, `path`, `description`, `url`, `current`) |
| `{{ topics }}` | Kategorie **z `path`** listowane przez tę stronę, w kolejności `priority`, potem `id` - do chipów nad listą (`code`, `name`, `path`, `url`, `current`) |
| `{{ year }}` | Aktualny rok |
| `{{ powered_by }}` | "Intum" |
| `{{ preview_mode }}` | `true` jeśli strona jest w trybie podglądu |
| `{{ base_path }}` | Prefiks do budowania linków (`{{ base_path }}/kontakt`): w preview `/w/<kod_witryny>`, na domenie klienta `path_prefix` witryny (zwykle pusty) |

### Właściwości obiektów

**Page** (`page`, elementy `pages`):
- `id` - ID strony
- `name` - nazwa strony
- `title` - tytuł strony (z fields lub name)
- `path` - ścieżka (w preview: `/w/site/path`, normalnie: `/path`)
- `raw_path` - surowa ścieżka z bazy, bez prefiksów (np. `rejestr-zmian`) - do porównań w Liquid ({% if page.raw_path == 'rejestr-zmian' %})
- `url` - pełny URL
- `site_id` - ID site'a
- `html_title` - tytuł HTML
- `html_description` - opis HTML (meta description)
- `html_keywords` - słowa kluczowe (meta keywords)
- `html_script` - skrypt HTML

**Site**:
- `name` - nazwa site'a
- `code` - kod site'a
- `description` - opis
- `url` - URL site'a

**Layout**:
- `name` - nazwa layoutu
- `code` - kod layoutu
- `description` - opis
- `content` - treść szablonu

### Własne pola (fields)

Każdy obiekt (Page, Site, Layout) może mieć własne pola w `fields`. Dostęp do nich w szablonach:

```liquid
{{ page.owner }}        → page.fields["owner"]
{{ page.html.title }}   → page.fields["html"]["title"] (zagnieżdżone)
{{ owner }}             → page.fields["owner"] (skrót - tylko dla page)
{{ html.title }}        → page.fields["html"]["title"] (skrót)

{{ site.company }}      → site.fields["company"]
{{ layout.version }}    → layout.fields["version"]
```

Nazwa pola w `content` musi być poprawną zmienną Liquida (`[A-Za-z_][\w-]*`). Tekst w klamrach (`{{ Nasz zespół }}`, całe zdanie) przy zapisie rekordu zamienia się na klucz z pierwszych słów (`{{ nasz_zespol }}`, w layoucie `{{ layout.nasz_zespol }}`), a tekst trafia do `fields[klucz]` jako wartość początkowa (istniejący klucz nie jest nadpisywany). Tworząc treść przez API pisz od razu `{{ klucz }}` + `fields`.

**Przykład użycia w layoucie:**

```liquid
<!DOCTYPE html>
<html>
<head>
  <title>{{ page.html.title }} - {{ site.name }}</title>
  <meta name="author" content="{{ site.company }}">
</head>
<body>
  <header>
    <h1>{{ site.name }}</h1>
    <p>Wersja szablonu: {{ layout.version }}</p>
  </header>
  <main>{{ content }}</main>
  <footer>© {{ year }} {{ page.owner }}</footer>
</body>
</html>
```

**Ustawianie fields przez API:**

```
PATCH /cms/pages/strona1-home.jsonContent-Type: application/json

{
  "page": {
    "fields": {
      "owner": "Jan Kowalski",
      "html": {
        "title": "Strona główna"
      }
    }
  }
}
```

### ANTYWZORZEC: cała strona w jednym polu (`content: "{{ body }}"`)

**Nigdy nie wrzucaj całego HTML-a strony do jednego pola `fields`** i nie zostawiaj
w `content` samego `{{ body }}`:

```json
// ŹLE - cała strona (struktura + teksty + linki) siedzi w jednym polu, per język
{
  "page": {
    "content": "{{ body }}",
    "fields": {
      "en": { "body": "<section class=\"wrap\"><h1>A tiling desktop</h1>...5 kB HTML..." },
      "pl": { "body": "<section class=\"wrap\"><h1>Kafelkowy pulpit</h1>...5 kB HTML..." }
    }
  }
}
```

Dlaczego to boli:

- **Duplikacja markupu** - każdy język to osobna kopia całego HTML-a. Zmiana klasy CSS
  albo dołożenie przycisku to ta sama poprawka zrobiona N razy, a przy N+1 języku rozjazd
  jest kwestią czasu
- **Edytor pola staje się edytorem HTML** - użytkownik, który chce poprawić jedno zdanie,
  dostaje kilka kilobajtów znaczników i łatwo zepsuje strukturę
- **Nie da się zmienić układu bez ruszania tłumaczeń** (i odwrotnie)
- **Nic nie jest adresowalne** - nie podmienisz przez API samego nagłówka czy adresu CTA,
  bo nie ma takiego klucza; trzeba przepisać całe pole

**Dobrze: `content` to szablon (HTML + Liquid), `fields` to wszystkie teksty, linki
i listy** - per język tylko to, co faktycznie się tłumaczy:

```json
{
  "page": {
    "content": "<section class=\"wrap\">\n  <h1>{{ hero_title }}</h1>\n  <p class=\"lead\">{{ hero_lead }}</p>\n  <a class=\"btn\" href=\"{{ demo_url }}\">{{ cta_demo }}</a>\n  {% for card in cards %}<div class=\"card\"><h3>{{ card.title }}</h3><p>{{ card.text }}</p></div>{% endfor %}\n</section>",
    "fields": {
      "demo_url": "/run",
      "en": {
        "hero_title": "A tiling desktop, in a browser tab",
        "hero_lead": "Tiles split the screen instead of overlapping.",
        "cta_demo": "Try the live demo",
        "cards": [{ "title": "BSP tiling", "text": "Tiles divide the screen." }]
      },
      "pl": {
        "hero_title": "Kafelkowy pulpit, w karcie przeglądarki",
        "hero_lead": "Kafelki dzielą ekran zamiast na siebie nachodzić.",
        "cta_demo": "Wypróbuj demo na żywo",
        "cards": [{ "title": "Układ BSP", "text": "Kafelki dzielą ekran." }]
      }
    }
  }
}
```

Zasady podziału:

- **W `content` tylko markup**: tagi, klasy, atrybuty, ikony SVG, `{% for %}`/`{% if %}`.
  Ani jednego zdania widocznego dla użytkownika
- **W `fields` teksty, adresy i listy**: nagłówki, akapity, napisy przycisków, `href`-y,
  identyfikatory osadzanych formularzy, tablice kart/FAQ/cennika
- **Wartość, która nie zależy od języka** (adres GitHuba, `/run`, kod formularza wspólny
  dla wszystkich wersji) idzie **top-level**, poza sub-hasze `pl`/`en` - działa wtedy jako
  fallback dla każdego locale i zapisujesz ją w jednym miejscu
- Krótki fragment HTML **wewnątrz** zdania (`<strong>`, `<a href>`, `<span class="kbd">`)
  zostaje w polu razem z tekstem - szyk zdania bywa inny w każdym języku, więc nie da się
  go rozbić na kawałki w szablonie
- Nazwa klucza musi być poprawną zmienną Liquida i mówić o roli, nie o wyglądzie
  (`hero_lead`, `cta_demo`, `signup_form_id` - nie `text1`, `bold_blue`)

Strony dziedziczące (`based_on_page_id`) korzystają z tego wprost: slave ma puste `content`
i puste `fields`, a bierze szablon i teksty od mastera wg swojego `locale`.

**Przerabianie istniejącej strony z `{{ body }}` na szablon + fields**: zrób kopię
aktualnego `content` i `fields`, wytnij teksty do kluczy, wyślij `content` i `fields`
jednym `PATCH`, a potem porównaj wyrenderowany HTML sprzed i po zmianie (pomijając białe
znaki) - powinien wyjść identyczny.

### Tablice w fields (zalecane dla list i cenników)

Dla dynamicznych treści jak cenniki, listy funkcji, FAQ - **zalecane jest używanie tablic w fields**. Ułatwia to edycję danych bez modyfikacji HTML.

**Przykład: cennik z planami:**

```
PATCH /cms/pages/cennik.jsonContent-Type: application/json

{
  "page": {
    "fields": {
      "plans": [
        { "name": "Micro", "price": "0", "users": "1", "popular": false },
        { "name": "Start", "price": "49", "users": "2", "popular": true },
        { "name": "Pro", "price": "149", "users": "5", "popular": false }
      ],
      "faq": [
        { "question": "Czy mogę zmienić plan?", "answer": "Tak, w dowolnym momencie." },
        { "question": "Czy jest okres próbny?", "answer": "Tak, 14 dni za darmo." }
      ]
    },
    "content": "... szablon Liquid używający plans i faq ..."
  }
}
```

**Szablon Liquid (content):**

```liquid
<h1>Cennik</h1>
<div class="plans">
{% for plan in plans %}
  <div class="plan {% if plan.popular %}popular{% endif %}">
    <h3>{{ plan.name }}</h3>
    <p>{{ plan.price }} zł/mies.</p>
    <p>{{ plan.users }} użytkowników</p>
  </div>
{% endfor %}
</div>

<h2>FAQ</h2>
{% for item in faq %}
  <div class="faq-item">
    <h4>{{ item.question }}</h4>
    <p>{{ item.answer }}</p>
  </div>
{% endfor %}
```

**Ważne:** Pola z `fields` są dostępne bezpośrednio w Liquid (np. `plans`, `faq`), **nie** przez `fields.plans`.

**Zagnieżdżone tablice (np. features w planie):**

```json
{
  "plans": [
    {
      "name": "Pro",
      "features": ["CRM", "Fakturowanie", "API", "Priorytetowe wsparcie"]
    }
  ]
}
```

```liquid
{% for plan in plans %}
  <h3>{{ plan.name }}</h3>
  <ul>
  {% for feature in plan.features %}
    <li>{{ feature }}</li>
  {% endfor %}
  </ul>
{% endfor %}
```

**Warunki w pętli:**

```liquid
{% for plan in plans %}
  {% if plan.popular %}
    <div class="plan highlighted">{{ plan.name }} - Popularny!</div>
  {% else %}
    <div class="plan">{{ plan.name }}</div>
  {% endif %}
{% endfor %}
```

**Zalety używania tablic w fields:**
- Łatwa edycja danych bez dotykania HTML
- Możliwość zmiany cen, nazw, opisów przez API
- Czytelna struktura danych
- Możliwość dynamicznego dodawania/usuwania elementów

**Edycja tablic w fields (dodanie pozycji menu, planu, pytania FAQ):**

- **Nie używaj `set_field`** - zapisuje `value` jako tekst, tablica zamieni się w string i pętla `for` przestanie działać
- Zrób GET rekordu, zmień tablicę lokalnie i wyślij **całe** `fields` przez zwykły PATCH: `PATCH /cms/pages/:code.json` z `{"page": {"fields": {...}}}` albo `PATCH /cms/layouts/:code.json` z `{"layout": {"fields": {...}}}`
- PATCH z `fields` **podmienia cały jsonb** - odeślij wszystkie klucze i wszystkie sub-hashe językowe (`pl`, `en`, ...), nie tylko ten zmieniany. Po zapisie porównaj `fields` z odpowiedzi z tym, co wysłałeś
- Na witrynie wielojęzycznej tablica zwykle jest osobno w każdym locale (`fields.pl.nav_items`, `fields.en.nav_items`) - zmieniaj tylko wersje językowe, w których dodawana strona istnieje

### Szablony paragrafów

Paragrafy mogą używać Liquid do dynamicznej treści. Dostępne zmienne:

| Zmienna | Opis |
|---------|------|
| `{{ name }}` | Nazwa paragrafu (z pola `name`, fallback do `fields["name"]`) |
| `{{ description }}` | Opis paragrafu (z pola `description`, fallback do `fields["description"]`) |
| `{{ dowolne_pole }}` | Wartość z `fields["dowolne_pole"]` |

**Przykład paragrafu z fields:**

```
POST /cms/paragraphs.json
{
  "paragraph": {
    "code": "strona1-team-jan",
    "name": "Jan Kowalski",
    "kind": "text",
    "content": "<div><h3>{{ name }}</h3><p>{{ position }}</p></div>",
    "site_code": "strona1",
    "box_code": "team",
    "fields": {
      "position": "Senior Developer"
    }
  }
}
```

### Szablony prezentacji paragrafów (system_template / template_content)

Prezentację paragrafu wybiera się i dostosowuje przez kolumny `system_template` (klucz gotowego szablonu, nazwa analogiczna do Cms::Layout), `template_content` (własny markup) i `layout_id` (layout wielokrotnego użytku) — to działa tak samo z API, jak z edytora WYSIWYG (zakładka Szablon). Pełny opis systemu paragrafów: [CMS Paragrafy](https://intum.intum.pl/kb/intum-kb/cms/api/paragrafy).

**Hierarchia nadpisań** (silniejszy wygrywa): `template_content` (własny markup) > `layout_id` (layout) > `system_template` (gotowy szablon) > prezentacja domyślna.

**Paragraf `text`** — treść można opakować gotowym szablonem albo własnym markupem Liquid:

| Pole | Opis |
|------|------|
| `system_template` | `framed` (ramka), `highlight` (wyróżnienie z paskiem), `hero` (baner, wyśrodkowany). Brak/nieznany = domyślna prezentacja: opcjonalny `<h3>` z `name` + treść + zdjęcie |
| `template_content` | Własny markup Liquid — wygrywa z `system_template` i layoutem |
| `fields.image_url` | Załącznik (zdjęcie) paragrafu — publiczny URL. Domyślna prezentacja i gotowe szablony renderują go pod treścią (`{% if image_url != blank %}`). Upload pliku: `POST /cms/assets.json` (multipart, `asset[kind]=image`) → do `fields.image_url` wpisz `s3_url` z odpowiedzi; formatka CRUD ma też pole `paragraph[image]` (plik), które robi to samo |

Szablon dostaje zmienne: `{{ content }}` (wyrenderowana treść paragrafu — po Liquid i konwersji markdown), `{{ name }}`, `{{ description }}`, `{{ image_url }}` oraz własne pola z `fields`. Markupy gotowych szablonów: `Cms::Paragraph::ParagraphRenderer::TEMPLATES`.

```
POST /cms/paragraphs.json
{
  "paragraph": {
    "code": "strona1-promo",
    "name": "Promocja",
    "kind": "text",
    "content": "Do końca miesiąca **-20%** na wszystko.",
    "markup": "markdown",
    "site_code": "strona1",
    "box_code": "main",
    "system_template": "highlight"
  }
}
```

Własny szablon (odpowiednik przycisku "Dostosuj"):

```
PATCH /cms/paragraphs/123.json
{
  "paragraph": {
    "template_content": "<aside class=\"promo\" style=\"background:#fef3c7;padding:16px;\">{% if name != blank %}<h3>{{ name }}</h3>{% endif %}{{ content }}</aside>"
  }
}
```

**Paragraf `articles`** — szablon ma dwa warianty: `list` (artykuł na liście) i `show` (widok szczegółów po kliknięciu):

| Pole | Opis |
|------|------|
| `system_template` | `default`, `cards` (z obrazkiem `image_url`), `compact` (data + link) |
| `template_content` | Własny markup Liquid z wariantami w blokach `<list>...</list>` i `<show>...</show>` (markup bez bloków = sam wariant listy) — wygrywa z `system_template` i layoutem |

Zmienne w obu wariantach (podzbiór `<cms type="article">`): `{{ id }}`, `{{ title }}`, `{{ path }}` (link do artykułu), `{{ author }}`, `{{ category_code }}`, `{{ abstract }}`, `{{ summary }}` („w skrócie"), `{{ content }}`, `{{ image_url }}`, `{{ published_at }}`, `{{ published_at_date }}`, `{{ published_at_iso }}`, `{{ updated_at_iso }}`, `{{ back_url }}` (powrót na stronę z listą), `{{ fields }}` (własne pola artykułu) oraz pozycja wpisu na liście: `{{ index }}` (od zera), `{{ position }}` (od jedynki), `{{ total }}`, `{{ first }}`, `{{ last }}`. Markupy gotowych szablonów: `Cms::Paragraph::ArticlesRenderer::TEMPLATES`.

```
POST /cms/paragraphs.json
{
  "paragraph": {
    "code": "strona1-aktualnosci",
    "name": "Aktualności",
    "kind": "articles",
    "site_code": "strona1",
    "page_code": "strona1-home",
    "box_code": "news",
    "system_template": "cards",
    "template_content": "<list>\n<div class=\"news-row\"><time>{{ published_at_date }}</time> <a href=\"{{ path }}\">{{ title }}</a></div>\n</list>",
    "fields": { "category_codes": ["aktualnosci"], "per_page": 5 }
  }
}
```

**Layout paragrafu jako szablon prezentacji.** Zamiast gotowego szablonu paragraf może wskazywać `layout_id` (Cms::Layout kind `paragraph`) — wielokrotnego użytku, wspólny dla wielu paragrafów:
- `text`: treść layoutu to pełny szablon — dostaje `{{ content }}` (wyrenderowaną treść paragrafu) i pola paragrafu; `system_template` wtedy nie gra,
- `articles`: layout może definiować warianty w blokach `<list>...</list>` i `<show>...</show>` — wygrywa z `system_template`, przegrywa z `template_content`,
- formatka `/cms/layouts` przy kind `paragraph` ma przyciski wstawiające markup gotowych szablonów; w edytorze WYSIWYG layouty są w tym samym selekcie co gotowe szablony.

**Zasady wspólne (normalizacja przy zapisie):**

- Pusty lub **identyczny z bazowym** własny markup (`template_content`) jest usuwany — zamrożona kopia nie dostawałaby przyszłych poprawek gotowego szablonu. Chcąc wrócić do gotowego szablonu, wyślij `"template_content": null`
- Nieznany klucz `system_template` jest usuwany — renderuje się domyślna prezentacja
- **Błąd składni Liquid we własnym szablonie blokuje zapis** (422, komunikat w `errors.template_content`). Na żywej stronie błąd Liquid degraduje do pustej treści, więc walidacja łapie go wcześniej
- Stare klucze `fields.template`/`fields.template_content` są przy zapisie przenoszone do kolumn, a `fields.template_list`/`template_show` usuwane
- Uwaga na cały jsonb: `PATCH` z `fields` **podmienia całość** — modyfikując jeden klucz (np. `category_codes`), odeślij też pozostałe (najpierw GET)

### Wielojęzyczne pola

Pola `fields` w **Page**, **Paragraph** i **Layout** wspierają zagnieżdżone hashe z kluczem locale (`pl`, `en`, ...). Site i Domain mają własne pole `locale` które wpływa na fallback chain.

**Pełna dokumentacja wielojęzyczności (strategie, chain fallback, per-domain locale, page inheritance):** [CMS Wielojęzyczność](https://intum.intum.pl/kb/intum-kb/cms/api/wielojezycznosc)

### Iteracja po paragrafach

```liquid
{% for p in paragraphs %}
  <div>
    <h3>{{ p.name }}</h3>
    <p>{{ p.content }}</p>
  </div>
{% endfor %}
```

### Menu nawigacyjne z podświetleniem aktualnej strony

```liquid
<nav>
  <ul class="flex gap-4">
    {% for p in pages %}
      {% if p.path == page.path %}
        <li><a href="{{ p.path }}" class="font-bold text-blue-600">{{ p.name }}</a></li>
      {% else %}
        <li><a href="{{ p.path }}" class="text-gray-600 hover:text-blue-600">{{ p.name }}</a></li>
      {% endif %}
    {% endfor %}
  </ul>
</nav>
```

**Uwaga:** W trybie podglądu (`/w/...`) linki automatycznie prowadzą do podglądu, a pod własną domeną - do normalnych ścieżek.

### Menu z grupami (menu_code)

Strony można przypisać do grup menu przez pole `menu_code`. Filtrowanie w Liquid wymaga użycia `assign` przed pętlą `for`:

```liquid
{% assign top_menu = pages | where: "menu_code", "top_menu" %}
<nav>
  {% for p in top_menu %}
    <a href="{{ p.path }}">{{ p.name }}</a>
  {% endfor %}
</nav>
```

**Przykład layoutu z wieloma menu:**

```liquid
<header>
  {% assign main_menu = pages | where: "menu_code", "main" %}
  {% for p in main_menu %}
    <a href="{{ p.path }}">{{ p.name }}</a>
  {% endfor %}
</header>

{{ content }}

<footer>
  {% assign footer_menu = pages | where: "menu_code", "footer" %}
  {% for p in footer_menu %}
    <a href="{{ p.path }}">{{ p.name }}</a>
  {% endfor %}
</footer>
```

**Uwaga:** Zmienna `pages` zawiera tylko strony z `in_menu = true`. Strony z `in_menu = false` nie pojawiają się w menu.

### Menu z tablicy w fields szablonu

Menu nie musi pochodzić z `pages` - wiele szablonów trzyma linki jako tablice w `fields` layoutu
(np. `nav_items`, `product_dropdown_items`, `footer_product_items`), per język:

```liquid
{% for p in layout.nav_items %}
  <a href="{{ p.path }}">{{ p.name }}</a>
{% endfor %}
```

**Zanim dodasz stronę do menu, sprawdź w `content` layoutu, skąd menu bierze linki:**

- pętla po `pages` → ustaw na stronie `in_menu` / `menu_code`
- pętla po `layout.<klucz>` → dopisz pozycję (`{"name": "...", "path": "/..."}`, plus pola, których używa pętla, np. `sub`) do tablicy w `fields` layoutu - jak w "Edycja tablic w fields". Samo `in_menu` na stronie nic wtedy nie zmieni

Ta sama tablica bywa użyta w kilku miejscach (menu desktopowe, mobilne, stopka) - dopisanie pozycji zmienia je wszystkie, więc przejrzyj każde użycie klucza w layoucie.

### Menu kategorii i chipy kategorii z `path`

`{{ categories }}` (kategorie artykułów witryny) i `{{ topics }}` (kategorie z `path`, które ta
strona listuje) zawierają tylko pozycje z opublikowanymi artykułami w języku domeny - żaden link
nie prowadzi na pustą listę. Każda pozycja ma `code`, `name`, `path`, `url` i `current`:

| Pole | Opis |
|------|------|
| `code` | Kod kategorii |
| `name` | Nazwa kategorii (pole **Nazwa** w `/cms/categories`) |
| `path` | Segment adresu kategorii (puste, gdy kategoria go nie ma) |
| `description` | Opis kategorii (tylko `categories`) |
| `url` | `categories`: adres listingu - strona, która listuje kategorię, plus `/<path>` gdy go ma (puste, gdy żadna strona jej nie listuje). `topics`: podstrona kategorii pod tą stroną (`/<strona-bloga>/<path>`) |
| `current` | `true` gdy to właśnie ta kategoria jest wyświetlana (z tagu strony albo z adresu) |

```liquid
<nav class="blog-menu">
  {% for c in categories %}
    <a href="{{ c.url }}" class="{% if c.current %}active{% endif %}">{{ c.name | escape }}</a>
  {% endfor %}
</nav>

<div class="chips">
  {% for t in topics %}
    <a href="{{ t.url }}" class="{% if t.current %}active{% endif %}">{{ t.name | escape }}</a>
  {% endfor %}
</div>
```

**Uwaga:** `name` i `description` to surowe wartości z bazy - przy wypisywaniu używaj filtra `escape`.

### Tag CMS box - renderowanie paragrafów z boxa

Tag `<cms type="box" id="X">` wstawia paragrafy z danego site'a, które mają `box_code = X`. Paragrafy są sortowane po `priority` rosnąco (niższy = wyżej), przy równych priorytetach po `id`.

**Składnia:**
```html
<cms type="box" id="sidebar"></cms>
<cms type="box" id="sidebar"/>
<cms type="box" id="banner" max="1"></cms>
<cms type="box" id="footer" scope="site"></cms>
```

| Atrybut | Opis |
|---------|------|
| `type` | Typ tagu (obecnie: `box`) |
| `id` | Wartość `box_code` paragrafów do wstawienia |
| `max` | Opcjonalnie: maksymalna liczba paragrafów (np. `max="1"` dla bannera). Pełny box nie pokazuje w trybie edycji przycisku dodania treści |
| `scope` | `page` (domyślnie) albo `site` — patrz niżej |

**`scope` — zasięg boxa**

Paragraf ma pole `page_id`: albo należy do konkretnej strony, albo jest wspólny dla całej witryny (`page_id` puste).

- `scope="page"` (domyślnie) — box pokazuje paragrafy **tej strony** (`page_id` = ta strona) oraz **wspólne** (`page_id` puste). Paragraf dodany w tym boxie w trybie edycji WYSIWYG dostaje `page_id` bieżącej strony, więc box o tym samym kodzie na innej stronie go nie pokaże.
- `scope="site"` — box wspólny dla całej witryny (stopka, nagłówek, belka cookies). Pokazuje **tylko** paragrafy bez `page_id`, i takie też zakłada w trybie edycji. Ten sam paragraf pojawia się wtedy na każdej stronie używającej tego boxa.

Boxy w layoucie, które mają wyglądać identycznie na wszystkich stronach, powinny mieć `scope="site"`.

**Przykład layoutu z sidebar i footer:**
```html
<!DOCTYPE html>
<html>
<head><title>{{ name }}</title></head>
<body>
  <nav>
    {% for p in pages %}
      <a href="{{ p.path }}" {% if p.path == page.path %}class="active"{% endif %}>{{ p.name }}</a>
    {% endfor %}
  </nav>

  <header>
    <cms type="box" id="banner" max="1"></cms>
  </header>

  <main>{{ content }}</main>

  <aside>
    <cms type="box" id="sidebar"></cms>
  </aside>

  <footer>
    <!-- stopka ta sama na każdej stronie - stąd scope="site" -->
    <cms type="box" id="footer" scope="site"></cms>
  </footer>
</body>
</html>
```

**Tworzenie paragrafów z box_code przez API:**
```
POST /cms/paragraphs.json
{
  "paragraph": {
    "code": "strona1-sidebar-promo",
    "name": "Promocja",
    "kind": "text",
    "content": "<div class='promo'>Sprawdź naszą ofertę!</div>",
    "site_code": "strona1",
    "box_code": "sidebar",
    "priority": 10
  }
}
```

### Tagi CMS changelog - metryczka i rejestr zmian (BIP)

```html
<cms type="changelog_page"></cms>
<cms type="changelog_site" per_page="50"></cms>
```

Oba tagi wymagają włączonego rejestru zmian witryny (`Cms::Site#change_log`) — bez niego znikają bez śladu. Działają w treści strony i w layoucie.

**`changelog_page` — metryczka BIP strony.** Podsumowanie z rejestru zmian (`Cms::ChangeLog`) strony i jej paragrafów: data wytworzenia, autor, data publikacji i osoba udostępniająca (pierwszy wpis rejestru), data ostatniej modyfikacji i osoba modyfikująca (ostatni wpis). Fallback do atrybutów strony, gdy rejestr włączono później. Markup: `section.cms-changelog-page`.

**`changelog_site` — pełny rejestr zmian witryny.** Publiczna tabela wpisów (`publicly_visible`): data, zdarzenie, tytuł, ścieżka, osoba; najnowsze pierwsze. Atrybut `per_page` (domyślnie 50, max 200). Markup: `section.cms-changelog-site`.

**Własny markup (inner template, Liquid — jak w `<cms type="article">`, ale bez podziału `<list>`/`<show>`):**

```html
<!-- per wpis rejestru; zmienne: created_at, event, title, path, user_name -->
<cms type="changelog_site" per_page="20">
  <div class="wpis">{{ created_at }} — {{ event }}: {{ title }} ({{ user_name }})</div>
</cms>

<!-- jednorazowo; zmienne: created_at, author, published_at, published_by, updated_at, updated_by -->
<cms type="changelog_page">
  <p>Autor: {{ author }}, ostatnia zmiana: {{ updated_at }} ({{ updated_by }})</p>
</cms>
```

Szablon systemowy `bip_prosty_1` ma `changelog_page` pod `{{ content }}`, a na stronie o ścieżce `rejestr-zmian` pokazuje `changelog_site` przed treścią (Liquid: `{% if page.path == 'rejestr-zmian' %}`).

### Tag CMS article - lista i widok artykułów

Tag `<cms type="article">` obsługuje zarówno wyświetlanie listy artykułów jak i pojedynczego artykułu. Automatycznie rozpoznaje czy pokazać listę czy szczegóły na podstawie ścieżki URL.

**Jak to działa:**
- `/blog` → wyświetla listę artykułów (sekcja `<list>`)
- `/blog/moj-artykul` → wyświetla pojedynczy artykuł (sekcja `<show>`)

**Składnia:**
```html
<cms type="article" category_code="news" per_page="10">
  <list>
    <!-- szablon dla każdego artykułu na liście -->
    <div class="news-item">
      <img src="{{ image_url }}" alt="{{ title }}">
      <h3><a href="{{ path }}">{{ title }}</a></h3>
      <span class="author">{{ author }}</span>
      <time>{{ published_at_date }}</time>
      <p>{{ abstract }}</p>
    </div>
  </list>
  <show>
    <!-- szablon dla widoku pojedynczego artykułu -->
    <article>
      <h1>{{ title }}</h1>
      <span class="author">{{ author }}</span>
      <time>{{ published_at }}</time>
      <div class="content">{{ content }}</div>
      <a href="{{ back_url }}">← Powrót do listy</a>
    </article>
  </show>
</cms>
```

| Atrybut | Opis |
|---------|------|
| `type` | `article` |
| `category_code` | Opcjonalnie: kody kategorii artykułów. Kilka kodów po przecinku (`category_code="blog,radar"`) łączy je w jedną chronologiczną listę; artykuł wchodzi, gdy ma którąś z nich. Atrybut tylko filtruje - segment `path` w adresie daje kategoria samego artykułu (główna, inaczej pierwsza dodatkowa z `path` wg `priority`), niezależnie od tego, co wymienia tag. Brak atrybutu = wszystkie kategorie |
| `topic` | Opcjonalnie: zawężenie na sztywno do jednej kategorii (kod albo `path`). `topic="any"` wyłącza zawężanie kategorią z URL-a (blok "najnowsze" obok chipów zostaje pełny) |
| `mode` | `list` - zawsze lista, `detail` - zawsze widok artykułu. Domyślnie zależnie od adresu |
| `per_page` | Opcjonalnie: artykułów na stronie (domyślnie: 10, max: 25) |
| `paginate` | `paginate="false"` - lista bez paginacji (sekcja "najnowsze" na stronie głównej) |
| `site` | Domyślnie lista pokazuje **tylko artykuły witryny, na której stoi strona**. `site="any"` pokazuje artykuły ze wszystkich witryn konta. Artykuł nie może być wspólny dla kilku witryn - ma jedną witrynę |

**Zawężanie kategorią z adresu:** lista bez atrybutu `topic` czyta kategorię ze ścieżki (`/blog/porady` -
podstrona kategorii z `path`) albo z parametru `?topic=porady` (stare linki `?category=porady` też
działają; kategoria z `path` robi z nich 301 na ścieżkę). Linki do chipów buduje zmienna `{{ topics }}`
(patrz "Menu kategorii i chipy kategorii z `path`").

**WAŻNE:**
- Artykuł renderuje się tylko na witrynie, do której należy (`site_id`) — artykuł innej witryny ani stary bez witryny nie pokaże się na liście, chyba że tag ma `site="any"`
- Zmienne Liquid w template `<list>` i `<show>` są **FLAT**: `{{ title }}`, `{{ path }}`, `{{ abstract }}`, `{{ summary }}`, `{{ content }}`, `{{ published_at }}`, `{{ image_url }}`, `{{ back_url }}`, `{{ author }}` — NIE `{{ article.title }}`
- Jeśli treść artykułu zawiera przykłady kodu Liquid (`{{ }}`, `{% %}`), owij ją w `...` — bez tego CMS zwróci 500
- Layout musi mieć ustawiony `site_id` — bez tego `<cms type="article">` może nie renderować artykułów

**Obrazki w treści:**
- Upload przez `POST /cms/assets.json` (multipart: `asset[file]=@plik`, `asset[site_id]=ID`)
- Referencja: `/cms/assets/ID/view` — **NIE** `{{ asset 'nazwa' }}` (ten tag nie działa)
- Unikaj URL-i `s3.eu-west-1.amazonaws.com/attachments.intum.net/public-files/` — wygasają (403)

**Paginacja:**

Gdy artykułów jest więcej niż `per_page`, automatycznie wyświetlana jest paginacja. Nawigacja między stronami przez parametr `?page=N`:
- `/blog` → strona 1
- `/blog?page=2` → strona 2
- `/blog?page=3` → strona 3

**Klasy CSS paginacji:**

| Klasa | Element |
|-------|---------|
| `cms-pagination` | Kontener `<nav>` |
| `cms-pagination-prev` | Link "Poprzednia" |
| `cms-pagination-next` | Link "Następna" |
| `cms-pagination-page` | Link do numeru strony |
| `cms-pagination-current` | Aktualny numer strony (span) |
| `cms-pagination-ellipsis` | Wielokropek między numerami |

**Przykład HTML paginacji:**
```html
<nav class="cms-pagination">
  <a href="/blog" class="cms-pagination-prev">« Poprzednia</a>
  <a href="/blog" class="cms-pagination-page">1</a>
  <span class="cms-pagination-current">2</span>
  <a href="/blog?page=3" class="cms-pagination-page">3</a>
  <span class="cms-pagination-ellipsis">…</span>
  <a href="/blog?page=10" class="cms-pagination-page">10</a>
  <a href="/blog?page=3" class="cms-pagination-next">Następna »</a>
</nav>
```

**SEO - meta tagi artykułu:**

Gdy wyświetlany jest widok szczegółowy artykułu (`/blog/moj-artykul`), zmienne `html_title`, `html_description`, `html_keywords` są automatycznie nadpisywane wartościami z artykułu. Dzięki temu layout może używać:

```html
<head>
  <title>{{ html_title }}</title>
  <meta name="description" content="{{ html_description }}">
  <meta name="keywords" content="{{ html_keywords }}">
</head>
```

I automatycznie będą wyświetlane:
- Na liście (`/blog`) → wartości ze strony
- Na artykule (`/blog/moj-artykul`) → wartości z artykułu (lub tytuł artykułu jeśli `html_title` puste)

Na tym samym widoku layout i body strony mają też cały artykuł pod `{{ article.* }}` -
np. `{{ article.summary }}`, `{{ article.abstract }}`, `{{ article.fields.pub_date }}`.
To jedyne miejsce z tym prefiksem: **wewnątrz** `<list>`/`<show>` te same zmienne są flat
(`{{ summary }}`), bo tam kontekstem jest już pojedynczy artykuł.

**Sekcje szablonu:**

| Sekcja | Opis |
|--------|------|
| `<list>...</list>` | Szablon dla każdego artykułu na liście |
| `<show>...</show>` | Szablon dla widoku pojedynczego artykułu |

Jeśli nie podasz własnych szablonów, używane są domyślne.

**Dostępne zmienne w szablonie:**

| Zmienna | Lista | Show | Opis |
|---------|-------|------|------|
| `{{ id }}` | ✓ | ✓ | ID artykułu |
| `{{ title }}` | ✓ | ✓ | Tytuł |
| `{{ path }}` | ✓ | - | Ścieżka URL (link do artykułu) |
| `{{ author }}` | ✓ | ✓ | Autor |
| `{{ category_code }}` | ✓ | ✓ | Kod kategorii głównej |
| `{{ abstract }}` | ✓ | ✓ | Streszczenie (zajawka, meta description, JSON-LD) |
| `{{ summary }}` | ✓ | ✓ | „W skrócie" - answer-first box nad treścią. Puste = pusty string, więc owijaj `{% if summary != blank %}` |
| `{{ content }}` | ✓ | ✓ | Pełna treść |
| `{{ image_url }}` | ✓ | ✓ | URL obrazka |
| `{{ published_at }}` | ✓ | ✓ | Data publikacji sformatowana |
| `{{ published_at_date }}` | ✓ | ✓ | Tylko data |
| `{{ back_url }}` | - | ✓ | URL powrotu do listy |
| `{{ category_name }}` | ✓ | ✓ | Nazwa kategorii głównej (do okruszków) |
| `{{ category_url }}` | ✓ | ✓ | Adres listingu kategorii głównej - strona, która ją listuje, plus `/<path>` gdy go ma (do okruszków) |
| `{{ categories }}` | ✓ | ✓ | Wszystkie kategorie artykułu, główna pierwsza: `code`, `name`, `path`, `url`, `main` (`{% for c in categories %}`) |
| `{{ index }}` / `{{ position }}` | ✓ | - | Pozycja wpisu na liście: od zera / od jedynki |
| `{{ total }}` | ✓ | - | Ile wpisów na tej stronie listy |
| `{{ first }}` / `{{ last }}` | ✓ | - | Czy to pierwszy / ostatni wpis - do układu „pierwszy inaczej niż reszta" |

**Domyślny HTML (lista):**
```html
<article class="cms-article-item">
  <h3><a href="blog/sciezka-artykulu">Tytuł artykułu</a></h3>
  <time datetime="2026-01-15T12:00:00Z">15 stycznia 2026</time>
  <p>Streszczenie artykułu...</p>
</article>
```

**Domyślny HTML (show):**
```html
<article class="cms-article-detail">
  <h1>Tytuł artykułu</h1>
  <span class="author">Jan Kowalski</span>
  <time datetime="2026-01-15T12:00:00Z">15 stycznia 2026</time>
  <div class="content">Pełna treść...</div>
  <a href="/blog">Powrót do listy</a>
</article>
```

**Przykład użycia - blog z listą i widokiem artykułu:**

Strona z `path: "blog"` będzie obsługiwać:
- `/blog` → lista artykułów
- `/blog/wprowadzenie-do-rails` → widok artykułu

```html
<cms type="article" category_code="tech">
  <list>
    <div class="article-card">
      {% if image_url %}<img src="{{ image_url }}" alt="{{ title }}">{% endif %}
      <h2><a href="{{ path }}">{{ title }}</a></h2>
      <div class="meta">{{ published_at_date }} | {{ author }}</div>
      <p>{{ abstract }}</p>
    </div>
  </list>
  <show>
    <article>
      {% if image_url %}<img src="{{ image_url }}" class="hero">{% endif %}
      <h1>{{ title }}</h1>
      <div class="meta">{{ published_at }} | {{ author }}</div>
      <div class="content">{{ content }}</div>
      <p><a href="{{ back_url }}">← Powrót do listy</a></p>
    </article>
  </show>
</cms>
```

**Tworzenie artykułu przez API:**
```
POST /cms/articles.json
{
  "article": {
    "title": "Nowy artykuł",
    "category_code": "tech",
    "abstract": "Krótkie streszczenie artykułu...",
    "summary": "Odpowiedź w 2-3 zdaniach - box \"w skrócie\" nad treścią.",
    "content": "Pełna treść artykułu...",
    "published_at": "2026-01-15T12:00:00Z",
    "site_id": 45
  }
}
```

---

## Kompletny przykład

Poniżej przykład tworzenia prostej strony CMS przez API.

### 1. Utwórz Layout

```
POST /cms/layouts.jsonContent-Type: application/json

{
  "layout": {
    "code": "strona1-layout",
    "name": "Prosty szablon",
    "kind": "page",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n  <title>{{ name }} - {{ site.name }}</title>\n  <style>\n    body { font-family: sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; }\n    header { background: #3b82f6; color: white; padding: 20px; margin-bottom: 20px; }\n    nav { margin-bottom: 20px; }\n    nav ul { display: flex; gap: 16px; list-style: none; padding: 0; }\n    nav a { color: #4b5563; text-decoration: none; }\n    nav a:hover { color: #3b82f6; }\n    nav a.active { font-weight: bold; color: #3b82f6; }\n    .paragraph { background: #f3f4f6; padding: 15px; margin: 10px 0; border-left: 4px solid #3b82f6; }\n  </style>\n</head>\n<body>\n  <header><h1>{{ site.name }}</h1></header>\n  <nav>\n    <ul>\n      {% for p in pages %}\n        <li><a href=\"{{ p.path }}\" {% if p.path == page.path %}class=\"active\"{% endif %}>{{ p.name }}</a></li>\n      {% endfor %}\n    </ul>\n  </nav>\n  {{ content }}\n</body>\n</html>"
  }
}
```

### 2. Utwórz Site

```
POST /cms/sites.jsonContent-Type: application/json

{
  "site": {
    "code": "strona1",
    "name": "Moja Strona",
    "kind": "www",
    "layout_code": "strona1-layout"
  }
}
```

### 3. Utwórz Stronę

```
POST /cms/pages.jsonContent-Type: application/json

{
  "page": {
    "code": "strona1-home",
    "name": "Strona Główna",
    "kind": "text",
    "path": "",
    "site_code": "strona1",
    "layout_code": "strona1-layout",
    "content": "<h2>{{ name }}</h2>\n\n<p>Witamy na naszej stronie!</p>\n\n{% for p in paragraphs %}\n  <div class=\"paragraph\">\n    <strong>{{ p.name }}</strong>\n    <p>{{ p.content }}</p>\n  </div>\n{% endfor %}"
  }
}
```

### 4. Utwórz Paragrafy (z podłączeniem do strony)

```
POST /cms/paragraphs.jsonContent-Type: application/json

{
  "paragraph": {
    "code": "strona1-intro",
    "name": "Wprowadzenie",
    "kind": "text",
    "content": "Witaj na mojej stronie! To jest przykładowa treść.",
    "site_code": "strona1",
    "page_code": "strona1-home",
    "priority": 10
  }
}
```

```
POST /cms/paragraphs.jsonContent-Type: application/json

{
  "paragraph": {
    "code": "strona1-about",
    "name": "O nas",
    "kind": "text",
    "content": "Jesteśmy firmą zajmującą się tworzeniem stron internetowych.",
    "site_code": "strona1",
    "page_code": "strona1-home",
    "priority": 5
  }
}
```

Paragraf z listą artykułów (kind `articles` — konfiguracja w `fields`, patrz sekcja "Szablony prezentacji paragrafów"):

```
POST /cms/paragraphs.jsonContent-Type: application/json

{
  "paragraph": {
    "code": "strona1-news",
    "name": "Aktualności",
    "kind": "articles",
    "site_code": "strona1",
    "page_code": "strona1-home",
    "box_code": "news",
    "system_template": "compact",
    "fields": { "category_codes": ["aktualnosci"], "per_page": 5 }
  }
}
```

### 5. Podgląd

Otwórz w przeglądarce:
```
/w/strona1
```

---

## Aktualizacja przez code

Możesz aktualizować obiekty używając ich `code` zamiast `id`:

```
PATCH /cms/layouts/simple-layout.jsonContent-Type: application/json

{
  "layout": {
    "name": "Zaktualizowany szablon"
  }
}
```

---

## Klonowanie Site

Możesz sklonować istniejący Site (stronę WWW) przez API. Przydatne gdy chcesz:
- Utworzyć kopię roboczą do testowania zmian
- Stworzyć nową wersję strony zachowując oryginał

### Klonowanie podstawowe (tylko site)

```
POST /cms/sites.jsonContent-Type: application/json

{
  "from_site_code": "strona1",
  "site": {
    "name": "Strona testowa",
    "code": "strona1-test"
  }
}
```

Tworzy nowy Site z kopiowanymi ustawieniami (kind, layout_id, description, path_prefix, fields), ale **bez** layoutów, stron i paragrafów.

### Klonowanie głębokie (deep clone)

```
POST /cms/sites.jsonContent-Type: application/json

{
  "from_site_code": "strona1",
  "deep_clone": "1",
  "site": {
    "name": "Strona testowa",
    "code": "strona1-test"
  }
}
```

Kopiuje site wraz ze wszystkimi powiązanymi elementami:
- **Layouty** - kopie z nową nazwą zawierającą nazwę site w nawiasie, np. "Layout (Strona testowa)"
- **Strony (pages)** - kopie z zachowaniem struktury parent-child i mapowaniem layout_id na nowe layouty
- **Paragrafy** - kopie z mapowaniem layout_id na nowe layouty

### Parametry

| Parametr | Typ | Opis |
|----------|-----|------|
| `from_site_code` | string | Code źródłowego site'a |
| `deep_clone` | string | `"1"` aby skopiować layouty, strony i paragrafy |

### Przykład: workflow edycji przez LLM

1. **Sklonuj aktualną stronę:**
```
POST /cms/sites.json{
  "from_site_code": "produkcja",
  "deep_clone": "1",
  "site": { "name": "Wersja robocza", "code": "produkcja-dev" }
}
```

2. **Wprowadź zmiany na kopii:**
```
PATCH /cms/pages/produkcja-dev-home.json{ "page": { "content": "... nowa treść ..." } }
```

3. **Podejrzyj zmiany:**
```
GET /w/produkcja-dev
```

4. **Po akceptacji - przenieś zmiany na produkcję lub usuń kopię**

---

## Wskazówki

1. **Placeholder w layoucie:** Layout musi zawierać `{{ content }}` w miejscu gdzie ma być wstawiona treść strony
2. **Sortowanie paragrafów:** Używaj `priority` (niższy = wyżej w boxie)
3. **Code vs ID:** Używaj `*_code` dla czytelności i przenośności
4. **Preview bez auth:** Endpoint `/w/` nie wymaga autoryzacji
5. **Liquid syntax:** Strony używają Liquid do dynamicznej treści
6. **Klonowanie do testów:** Użyj `deep_clone` aby stworzyć kopię roboczą przed wprowadzaniem zmian
7. **Layout + site_id:** Przy tworzeniu layoutu **zawsze podawaj `site_id`** — przypisuje layout do konkretnego site'a
8. **Markdown:** Paragrafy i artykuły obsługują pole `markup` (`html`/`markdown`). Jeśli pole jest puste, format jest wykrywany automatycznie przy zapisie. Treść markdown jest renderowana jako GitHub Flavored Markdown (nagłówki, listy, tabele, bloki kodu, linki)
9. **NIGDY nie testuj zapisu na produkcji** — nie wysyłaj testowych danych (np. `"content": "test"`) do API, bo zmiany są natychmiast widoczne na produkcji. Jeśli chcesz sprawdzić czy API działa, użyj GET, nie PATCH/PUT z danymi testowymi
10. **Line endings `\r\n`** — content z API używa `\r\n` (Windows line endings). Przy replace'ach w Pythonie używaj `\r\n` lub regex z `re.DOTALL`, nie `\n`
11. **Prezentacja paragrafów:** zamiast stylować treść inline, wybierz gotowy szablon (kolumna `system_template`), layout (`layout_id`) albo napisz własny markup Liquid (kolumna `template_content`; dla `articles` bloki `<list>`/`<show>`) — szczegóły i przykłady w sekcji "Szablony prezentacji paragrafów"
12. **Tytuł paragrafu = pole `name`:** `text` pokazuje go jako `<h3>` nad treścią (o ile paragraf nie ma layoutu ani szablonu prezentacji, który sam decyduje), `articles` jako `<h2>` nad listą. Nie dubluj tytułu w `content`

## Powiązane

- [common_api](https://app.intum.pl/noe/prompt/common_api.md) — wspólne zasady API (format, autoryzacja, odpowiedzi)
- [cms_paragraphs](https://intum.intum.pl/kb/intum-kb/cms/api/paragrafy) — paragrafy: rodzaje, szablony prezentacji, zdjęcia, boxy