Přeskočit na obsah

Položky, pole a výpisy

Stránka v patru se skládá ze tří nosičů obsahu a šablona s každým nakládá jinak:

  1. Titulek a slug — holé hodnoty.
  2. Typovaná pole (data) — hodnoty vlastních polí typu obsahu, ke kterým chodí i jejich definice.
  3. Tělo (content) — buď formátovaný text, nebo tělo složené z bloků, nebo nic.
const entry = await client.getEntryBySlug(slug, locale)
// {
// id, slug, title,
// data: { … }, // hodnoty vlastních polí
// content: … , // tělo (podle `type.body`)
// type: { name, slug, fields, body } | null,
// terms: [{ … }], // kategorie a štítky
// bylines: [{ … }], // podpisy autorů
// }

Typy obsahu a jejich pole

Sekce “Typy obsahu a jejich pole”

Typ obsahu je šablona položky: „článek má perex, datum a obrázek“. Definice chodí s každou stránkou (entry.type.fields), takže si šablona nemusí schéma stahovat zvlášť ani ho znát dopředu.

entry.type je null u položky bez typu (starší obsah nebo smazaný typ). Není to chyba — znamená to jen, že není podle čeho vypisovat pole.

Každá definice pole nese key (klíč v data), label (popisek), kind (druh), required a dva přepínače: searchable (hodnota se přidá do fulltextového indexu) a showOnPage (hodnota se vypíše na veřejné stránce — past hned níž). Oba jsou volitelné a chybějící klíč u obou znamená „ne“.

Druhů polí je čtrnáct: text, textarea, number, boolean, date, select, multiselect, url, slug, reference, image, file, repeater, richtext.

⚠️ Pole se na webu zobrazí jen se zapnutým přepínačem

Sekce “⚠️ Pole se na webu zobrazí jen se zapnutým přepínačem”

Tohle je past, kterou musí návod pojmenovat, protože se projeví jako „chybějící obsah“ a hledá se pak úplně jinde.

O tom, jestli hodnota pole vyjde na veřejnou stránku, rozhoduje showOnPage na definici pole. Chybějící klíč znamená nezobrazovat.

Je to bezpečná strana omylu, ne omezení: pole, které mělo být vidět a není, si redaktor zapne jedním kliknutím v typu obsahu, kdežto hodnotu, která na web unikla, zpátky nikdo nevezme. Vzniklo to z konkrétní škody — dokud volba neexistovala, vypisovala se všechna pole typu, takže se návštěvníkovi naimportované stránky ukazovaly vnitřnosti cizího systému („Mec location id: 0“).

Prakticky: když v šabloně chybí hodnota, kterou redaktor vyplnil, nehledejte chybu v šabloně — podívejte se do typu obsahu, jestli má pole zapnuté zobrazení na stránce.

Přepínač se uplatňuje jen na první úrovni. Podpole opakující se skupiny (repeater) se vypisují proto, že se zobrazuje jejich řádek; druhý přepínač o úroveň níž by byl jen šum.

Buď si je vykreslíte sami z entry.data a entry.type.fields, nebo použijete hotovou komponentu:

---
import EntryFields from "@patro/render/EntryFields.astro"
---
{entry.type && entry.type.fields.length > 0 && (
<EntryFields
fields={entry.type.fields}
data={entry.data}
mediaBaseUrl={PUBLIC_API_BASE}
/>
)}

Vykreslí <dl> popisek → hodnota jako statické HTML, nula JS. Filtr podle showOnPage si dělá sama, takže se přes ni nic neveřejného neprotlačí.

mediaBaseUrl je adresa patra, ne vašeho webu — pole druhu image a file z ní skládají absolutní adresu souboru. Kořenově relativní cesta by mířila na váš web, kde soubor není. Prázdný výsledek nevykreslí ani prázdný <dl>.

entry.type.body říká, co je v entry.content, a jsou to tři hodnoty:

body Co je v content Čím vykreslit
"richtext" Dokument formátovaného textu RichTextBody.astro
"puck" Tělo složené z bloků PuckPage.astro
"none" Nic — typ tělo nemá nevykreslovat
---
import RichTextBody from "@patro/render/RichTextBody.astro"
import PuckPage from "@patro/render/PuckPage.astro"
const bodyKind = entry.type?.body
const isRichBody = bodyKind === "richtext"
// Chybějící typ = starší obsah složený z bloků.
const isPuckBody = bodyKind === undefined || bodyKind === "puck"
---
{isRichBody ? <RichTextBody data={entry.content} />
: isPuckBody ? <PuckPage data={entry.content as PuckData | null} />
: null}

entry.content je v kontraktu svazek obou tvarů těla, takže do PuckPage jde se zúžením na PuckData. Není to obcházení typu naslepo: do typu s tělem z bloků server richtext dokument nepustí, o to se stará zápisová strana.

"none" nesmí spadnout do větve s bloky. Kdyby ano, tělo uložené před přepnutím typu na „bez těla“ by na webu svítilo dál — a redaktor by ho neměl jak smazat, protože administrace mu ho už neukazuje.

HTML z richtextu nevzniká v patru, ale u vás. Patro pošle strom uzlů a RichTextBody (přesněji renderRichText z @patro/render) z něj složí HTML až během vykreslování vaší stránky, nad seznamem povolených prvků. Uložená hodnota se tedy do stránky nikdy nedostane přímo — ale ta záruka stojí na tom balíku, ne na serveru. Vlastní serializátor psaný „protože je to jen JSON“ je přesně ta cesta, jak si do stránky pustit cizí značky.

client.listEntries(filter) je jedna cesta pro všechny výpisové stránky — index, výpis termu, výpis typu, archiv i stránky autora. Je to schválně: dvě cesty by se dřív nebo později rozešly v tom, co je vlastně vidět.

// výpis kategorie
await client.listEntries({ locale, taxonomy: "kategorie", term: "aktuality", page })
// výpis typu
await client.listEntries({ locale, type: "clanek", page })
// archiv měsíce
await client.listEntries({ locale, year: 2026, month: 8, page })
// stránky jednoho autora
await client.listEntries({ locale, byline: "jan-novak", page })

Položka výpisu (EntrySummary) nese id, slug, title, updatedAt a termsžádné tělo. Kategorie chodí rovnou v seznamu, takže se na ně nemusíte doptávat po jedné.

Jak se pozná, že adresa je výpis

Sekce “Jak se pozná, že adresa je výpis”

/kategorie/aktuality a /sluzby/uklid mají identický tvar. Rozhoduje mezi nimi jedině slovník adres, takže se na něj musíte zeptat dřív, než víte, na co se díváte:

const vocabulary = await client.getVocabulary()
const [prvni] = cesta.split("/")
const jeTaxonomie = vocabulary.taxonomies.some((t) => t.name === prvni)
const jeTyp = vocabulary.types.some((t) => t.slug === prvni)

Úsek adresy autorů a hledání ve slovníku není — ten chodí z konfigurace webu (tenant.bylineRouteSlug, tenant.searchRouteSlug). Je to nastavení webu vedle jazyků a pásma, ne obsah.

Dávka má pevnou velikost (LISTING_PAGE_SIZE, dnes 20) a stránkuje se od 1. Celkový počet u výpisů nechodí — server ho vědomě neposílá, byl by to agregační dotaz navíc. „Je toho víc“ se pozná z toho, že dávka přišla plná:

---
import {
LISTING_PAGE_SIZE,
pageFromParams,
pagedHref,
} from "@patro/shared"
const page = pageFromParams(Astro.url.searchParams)
const entries = await client.listEntries({ locale, page })
const maDalsi = entries.length === LISTING_PAGE_SIZE
---
{maDalsi && <a href={pagedHref("/clanky", page + 1)}>Další</a>}

Klíč v adrese je strana a neskládejte ho ručně. pagedHref ho přilepí přes URLSearchParams, takže se neztratí dotaz už v adrese přítomný (?q=), a první stránce ho nepřidá vůbec — jinak by týž výpis ležel na dvou adresách. pageFromParams je druhá strana téhož a ošetří i nesmysl v adrese.

U hledání je to jinak a je to jediná výjimka: client.searchEntries total vrací, takže se poslední stránka spočítat dá. Odsazení do dotazu se počítá pageOffset(page).