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)
- Wielojęzyczność CMS — strategie, locale chain, per-domain locale
- Paragrafy — rodzaje, szablony prezentacji, zdjęcia, boxy
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ść) |
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 = trueANDpublished_atjest w przeszłości AND (publish_tojest null LUBpublish_tojest w przyszłości) - Gdy
accepted = false, automatycznie ustawiane jestpublished = 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:
{{ 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:
<!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 }}")
content: "{{ body }}")Nigdy nie wrzucaj całego HTML-a strony do jednego pola fields i nie zostawiaj
w content samego {{ body }}:
// Ź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:
{
"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
contenttylko markup: tagi, klasy, atrybuty, ikony SVG,{% for %}/{% if %}. Ani jednego zdania widocznego dla użytkownika -
W
fieldsteksty, 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-haszepl/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- nietext1,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):
<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):
{
"plans": [
{
"name": "Pro",
"features": ["CRM", "Fakturowanie", "API", "Priorytetowe wsparcie"]
}
]
}
{% for plan in plans %}
<h3>{{ plan.name }}</h3>
<ul>
{% for feature in plan.features %}
<li>{{ feature }}</li>
{% endfor %}
</ul>
{% endfor %}
Warunki w pętli:
{% 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- zapisujevaluejako tekst, tablica zamieni się w string i pętlaforprzestanie działać - Zrób GET rekordu, zmień tablicę lokalnie i wyślij całe
fieldsprzez zwykły PATCH:PATCH /cms/pages/:code.jsonz{"page": {"fields": {...}}}alboPATCH /cms/layouts/:code.jsonz{"layout": {"fields": {...}}} - PATCH z
fieldspodmienia cały jsonb - odeślij wszystkie klucze i wszystkie sub-hashe językowe (pl,en, …), nie tylko ten zmieniany. Po zapisie porównajfieldsz 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.
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_templatewtedy nie gra, -
articles: layout może definiować warianty w blokach<list>...</list>i<show>...</show>— wygrywa zsystem_template, przegrywa ztemplate_content, - formatka
/cms/layoutsprzy kindparagraphma 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_templatejest 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_contentsą przy zapisie przenoszone do kolumn, afields.template_list/template_showusuwane - Uwaga na cały jsonb:
PATCHzfieldspodmienia 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ść
Iteracja po paragrafach
{% for p in paragraphs %}
<div>
<h3>{{ p.name }}</h3>
<p>{{ p.content }}</p>
</div>
{% endfor %}
Menu nawigacyjne z podświetleniem aktualnej strony
<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:
{% 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:
<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:
{% 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 stroniein_menu/menu_code - pętla po
layout.<klucz>→ dopisz pozycję ({"name": "...", "path": "/..."}, plus pola, których używa pętla, np.sub) do tablicy wfieldslayoutu - jak w “Edycja tablic w fields”. Samoin_menuna 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) |
<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:
<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_idpuste). Paragraf dodany w tym boxie w trybie edycji WYSIWYG dostajepage_idbieżą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 bezpage_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:
<!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)
<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>):
<!-- 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:
<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 masite="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:
<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ć:
<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ślihtml_titlepuste)
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):
<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):
<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
<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
-
Sklonuj aktualną stronę:
POST /cms/sites.json{ "from_site_code": "produkcja", "deep_clone": "1", "site": { "name": "Wersja robocza", "code": "produkcja-dev" } } -
Wprowadź zmiany na kopii:
PATCH /cms/pages/produkcja-dev-home.json{ "page": { "content": "... nowa treść ..." } } -
Podejrzyj zmiany:
GET /w/produkcja-dev - Po akceptacji - przenieś zmiany na produkcję lub usuń kopię
Wskazówki
-
Placeholder w layoucie: Layout musi zawierać
{{ content }}w miejscu gdzie ma być wstawiona treść strony -
Sortowanie paragrafów: Używaj
priority(niższy = wyżej w boxie) -
Code vs ID: Używaj
*_codedla czytelności i przenośności -
Preview bez auth: Endpoint
/w/nie wymaga autoryzacji - Liquid syntax: Strony używają Liquid do dynamicznej treści
-
Klonowanie do testów: Użyj
deep_cloneaby stworzyć kopię roboczą przed wprowadzaniem zmian -
Layout + site_id: Przy tworzeniu layoutu zawsze podawaj
site_id— przypisuje layout do konkretnego site’a -
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) -
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 -
Line endings
\r\n— content z API używa\r\n(Windows line endings). Przy replace’ach w Pythonie używaj\r\nlub regex zre.DOTALL, nie\n -
Prezentacja paragrafów: zamiast stylować treść inline, wybierz gotowy szablon (kolumna
system_template), layout (layout_id) albo napisz własny markup Liquid (kolumnatemplate_content; dlaarticlesbloki<list>/<show>) — szczegóły i przykłady w sekcji “Szablony prezentacji paragrafów” -
Tytuł paragrafu = pole
name:textpokazuje go jako<h3>nad treścią (o ile paragraf nie ma layoutu ani szablonu prezentacji, który sam decyduje),articlesjako<h2>nad listą. Nie dubluj tytułu wcontent
Powiązane
- common_api — wspólne zasady API (format, autoryzacja, odpowiedzi)
- cms_paragraphs — paragrafy: rodzaje, szablony prezentacji, zdjęcia, boxy