Bricks
Single and collection, all field types, routes, visibility, order, and tasks.
Single and collection
A brick describes one type of content. There are two kinds:
singleis a single: exactly one entry. Examples: opening hours, imprint, contact details.collectionis a collection: many entries with the same fields. Examples: dishes, events, notices.
Both go under bricks in defineSite. The key is the name of the brick. It starts with a lowercase letter and contains only letters and digits, for example menu.
import { collection, defineSite, f, single } from '@lupinum/ginko'
export default defineSite({
site: 'restaurant',
name: 'Gasthaus zur Linde',
url: 'https://ginko-demo-restaurant.vercel.app',
timeZone: 'Europe/Vienna',
locales: ['de', 'en'],
currency: 'EUR',
pages: { home: { de: '/', en: '/en' }, menu: { de: '/speisekarte', en: '/en/menu' } },
bricks: {
contact: single({
label: 'Address and contact',
fields: {
street: f.text('Street and number', { max: 80, translate: false }),
phone: f.text('Phone', { format: 'tel' }),
},
}),
menu: collection({
label: 'Menu',
fields: {
name: f.text('Name', { max: 120 }),
price: f.money('Price'),
},
max: 150,
order: 'manual',
}),
},
})Both functions take these settings:
| Setting | Applies to | Meaning |
|---|---|---|
label | both | Name of the brick in the editor. Required. |
describe | both | Short explanation in the editor, a text or a text per language like label. Bricks and components say what they are for with describe; a field has hint for the help text under it. |
fields | both | The fields of the brick. Required. |
source | both | 'files' reads the brick from Markdown files. Without it, the brick comes from the platform. |
tasks | both | Tasks for the editor. See below. |
layout | both | 'tabs' shows field groups as tabs (see Groups and tabs). |
max | collection | Largest number of entries, 1 to 500. Required. |
order | collection | Order of the entries. Required. |
route | collection | Address of the detail pages, per locale. |
visible | collection | When an entry is visible. |
untranslated | collection | What happens when a translation is missing: 'hide' (default with route) or 'fallback' (default without route). See Languages. |
plans | collection | Entries plan weeks of a single brick ahead. |
status | both | One status line on the Studio's Today page: { next: 'when' } (next entry by a date field), { count: true } (entries that end within 7 days; needs visible), { missing: 'image' } (entries without an optional field), { age: 'date' } (newest entry and its age), or on a single { hours: true } (today's opening hours). A line that needs doing is mailed with the weekly reminder. |
Limits of one site: at most 30 bricks, and at most 60 fields per brick.
Field types
You build fields with f.<type>('Label', settings). The label is the name in the editor. The settings are an object.
These settings work for every type:
optional: truemakes the field optional. Without it, a field is required.hintis a help text in the editor. Like the label, it is a text or a text per language:{ de: '…', en: '…' }.showWhenshows the field in the editor only while another field next to it has a value:{ field: 'closed', is: true }or{ field: 'closed', not: true }. A missing value counts as "not". The stored value and the checks stay; only the editor hides the field. A required field withshowWhenis required only while it shows:price: f.money('Amount', { showWhen: { field: 'pricing', is: 'price' } })needs an amount for a fixed price and none otherwise. Its type in the data is therefore optional.askAgainWhenon achoicesfield names the text field next to it that the choice answers for (a field of the same brick, or a column of the same list row). When that text changes in the main language and the choice does not, the Studio asks whether it is still right, and Save waits for the answer:allergens: f.choices('Allergens', { options, askAgainWhen: 'dish' }).defaultis the start value, written like the field's value: a text, a number,{ amount: 1250 }for money, an option value for a choice. For a translatable field, a plain text is the text of the main locale, and{ de: 'Hallo', en: 'Hello' }sets one text per language; Ginko keeps only the site's locales. If no text in the main locale is left, the default is left out (for example, a preset written for other languages).f.markdown,f.imageandf.filehave no default.translate: falseturns translation off for a field.translate: 'manual'allows no automatic translation suggestions.
The column "Bundle value" shows what useGinko returns in the site.
| Field | Settings | Bundle value | Translatable |
|---|---|---|---|
f.text | max (up to 500, or 2000 with format: 'url', default 200), format: 'url', 'email', or 'tel' | string | yes, no with format |
f.longtext | max (up to 5000, default 2000) | string | yes |
f.markdown | profile: 'inline', 'basic', 'rich' or 'page', max (default 50000). No default | Markdown tree | yes |
f.number | min, max, step (all three required), unit | number | no |
f.money | min, max (in minor units, e.g. cents; no negative amounts), currency | { amount, currency } | no |
f.boolean | none | true or false | no |
f.date | none | '2026-10-10' | no |
f.time | after: another time field of the brick that this time must be later than | '18:30' | no |
f.daterange | none | { from, to }, both as dates | no |
f.choice | options (list of options, up to 50) | the value of the option | no |
f.choices | options, max | list of values | no |
f.list | itemLabel, fields, max (1 to 50, required), min, unique | list of rows, each with _id | per column |
f.image | aspect | { url, alt, width, height, focus?, aspect? } | only alt |
f.file | accept: ['pdf'], maxBytes (up to 10 MB, 10 * 1024 * 1024) | { url, name, size } | no |
f.slug | from (required) | string | yes |
f.ref | to: a collection brick (required) | the ID of one entry | no |
f.refs | to (required), max | list of entry IDs | no |
Notes on single types:
- Markdown profiles set what a text may contain.
inline: one paragraph with bold, italic, strikethrough, code and links.basic: also headings h2–h4, lists and task lists (- [ ] item), quotes, dividers and footnotes.rich: also tables, images, code blocks, math and mermaid diagrams.page: also h1, h5, h6 and components (::callout). Raw HTML always stays text. Attributes in curly braces taketitle,lang,dirandaria-*(**neu**{lang="de"}), plus what the element needs, such astarget="_blank"on a link orwidthandheighton an image. A component takes the props its registry declares. Anything else, such asclass,data-*,style,idoronclick, is refused. Only Markdown and its plugins set classes (task lists, footnotes, code): with a class of its own, a text could take a CSS rule of the site and, for example, lay an invisible link over the whole page. - Money is stored in minor units, e.g. whole cents (a currency without them, such as JPY, uses whole units). The amount 12.50 EUR is
{ amount: 1250, currency: 'EUR' }. Amounts cannot be negative. Withoutcurrency, the field uses the currency of the site. UseformatMoneyto show the value;formatMoney(price, language, { wholeWithoutMinor: true })leaves out the cents of a whole amount (€390instead of€390.00). - Date and time are strings. A date is
YYYY-MM-DD, a time isHH:MM. A date counts in the time zone of the site. Withf.time('Ends at', { after: 'from' })the end must be later than the startfrom; the platform also refuses an equal time. - Country and public holidays:
countryin the site config is the site's country (ISO 3166-1, e.g.'AT'). It is the country of JSON-LD addresses that have none, and it picks the public holidays of the opening hours: Ginko knowsAT,DE(nationwide days only) andSG; another country counts no public holidays (ginko checkwarns).calendaroverrides it:'AT','DE','SG','none', or your own function(year) => holidays. The schema stores the result asholidays, so the Studio and the reminder mail count the same holidays as the site. Pages readcountrywithuseRuntimeConfig().public.ginko.country. - Choices:
f.choiceandf.choicestake the options asoptionsin the second argument, for examplef.choice(label, { options: ['a', 'b'], default: 'a' }). An option is a string or{ value, label }. The bundle holds only the value. The label is editor text: one text, or one per locale like everyLabel. It is not in the bundle, so show your own texts per locale in the site. - Lists:
f.list(label, { itemLabel, fields: columns, ...settings }).itemLabelnames one row, for example "Day".minis the smallest number of rows.uniquenames a required column whose value must differ in every row. A list cannot contain a list. - Images and files are uploaded in the editor on the platform. The bundle holds the URL. For images, Ginko also makes WebP copies 480, 960 and 1600 px wide (only those smaller than the image), at upload on the platform and at build for files.
<GinkoImage>offers them to the browser withsrcset, so phones load small files. - Image description (
alt) is required in the main locale. A field can have its own text; without one, the description from the Files library applies. The bundle holds the result. When the owner set a focus point, the image value also hasfocus: { x, y }(0 to 1), and<GinkoImage>keeps that point in the crop. - Frame (
aspect) says in which shape the website shows the photo: one ratio (f.image('Photo', { aspect: '3:4' })), or named frames when the photo appears in several places ({ aspect: { card: '3:2', header: '21:9' } }). The Studio shows the photo in every frame, and the owner sets the focus point right in the field. It warns when a frame cuts off much of the photo or the photo is too small for it.<GinkoImage>crops the same way (frame="card"picks a named frame). A frame changes no stored value: it may come, change or go in any release. - Slug is the address of an entry. See below.
- Platform-managed properties. The stored types
FieldandBrickalso haveposandretired. The platform sets them;f.*,singleandcollectiondo not take them. To retire a field, useretiredin the site config (see Platform and CLI).
An example with a list and a choice:
const weekdays = [
{ value: 'mo', label: 'Monday' },
{ value: 'tu', label: 'Tuesday' },
{ value: 'we', label: 'Wednesday' },
] as const
const lunch = single({
label: 'Lunch menu',
fields: {
weekOf: f.date('Week of'),
days: f.list('Days', {
itemLabel: 'Day',
fields: {
day: f.choice('Day', { options: weekdays }),
dish: f.text('Dish', { max: 200 }),
price: f.money('Price', { optional: true }),
},
min: 3, max: 3, unique: 'day',
}),
},
})In the site, the field day has the type 'mo' | 'tu' | 'we' | UnknownChoice. UnknownChoice stands for a value that a later release adds. Handle it with a fallback text.
Groups and tabs
A long form is easier to fill in parts. Give fields a group; the Studio shows each group under its heading. With layout: 'tabs' on the brick, the groups become tabs on wide screens. Phones always show headings. Fields without a group come first. Groups only change the form: the data stays the same.
legal: single({
label: 'Legal',
layout: 'tabs',
fields: {
imprint: f.markdown('Imprint', { group: 'Imprint' }),
privacy: f.markdown('Privacy', { group: 'Privacy' }),
},
}),After a refused save, a tab with an error shows a red dot and opens first.
Routes and slugs
A collection gets detail pages when it has a route. The route names one path per locale, with [slug]:
events: collection({
label: 'Events',
fields: {
title: f.text('Title', { max: 120 }),
slug: f.slug('Address', { from: 'title' }),
when: f.daterange('When'),
},
max: 30,
order: { field: 'when', dir: 'asc' },
route: { de: '/veranstaltungen/[slug]', en: '/en/events/[slug]' },
})These rules apply to a route:
- You must give a path for every locale in
locales. Each path starts with/and contains[slug]. - The path already includes the locale prefix, for example
/en/events/[slug]. - The brick needs an
f.slugfield. Its optionfromnames the text field that the slug comes from. - Only collections have slugs. A single with a slug field is an error.
The slug is made once, when an entry is created. Ginko writes ä, ö, ü, and ß as ae, oe, ue, and ss, uses lowercase letters, and joins words with -. A duplicate slug gets -2, -3, and so on. After that, the slug stays fixed. Each locale has its own slug. For file bricks, the slug comes from the frontmatter or the file name (see "Content from files").
You write the page for the route yourself. The page "Reading content" shows how it reads the entry. The page "Languages" shows how the paths fit together per locale.
Visibility
Use visible to show and hide entries by date:
{ mode: 'always' }shows everything. This is the default.{ mode: 'until', field: 'until' }shows an entry until the date in the fielduntilhas passed. The field is adateor adaterange; for adaterangethe end date (to) counts. An empty field always shows the entry. Withdays: 6it stays six days longer, for example a week from its first day.
A date counts for the whole day, in the time zone of the site. useGinko filters by this rule. The detail page of an entry that is not visible returns 404. This entry is also missing from the sitemap.
With deleteExpired: true in visible, the platform deletes entries that are not visible when a new entry needs room. A daily job also deletes entries that have been invisible for 30 days. Deleted entries can be restored for 30 days.
With plans: { brick: 'lunch', field: 'weekOf' }, the entries of a collection plan weeks of the single brick lunch ahead: weekOf (a date field of the collection) holds the first day of the week, and a planned week counts as entered for the weekly reminder.
Order
order sets the order of the entries in the bundle:
'manual': owners arrange the entries by hand on the platform. For files, the number in the file name sets the order.{ field: 'when', dir: 'asc' }sorts by a field, ascending ('asc') or descending ('desc'). Adaterangefield sorts byfrom, then byto.
Tasks
Tasks (tasks) are shortcuts in the editor for a frequent chore. A task does not change what is in the data. It sets which fields a staff member sees and may change.
tasks: [
{ id: 'closedToday', label: 'Closed today', kind: 'edit', fields: ['closedOn', 'closedNote'], prefill: { closedOn: '@today' } },
{ id: 'add', label: 'Add a dish', kind: 'create', fields: ['name', 'price'] },
{ id: 'remove', label: 'Remove a dish', kind: 'remove' },
]kind: 'edit'changes the fields infields. Withpick: true, the person first picks an entry.kind: 'create'adds a new entry. Withfieldsthe task asks for and writes only those fields (plus itsprefilland fields with a default); every required field without a default must be infieldsorprefill. A field with a default that the task does not list takes only its default value. The release check refuses the slug, a planned next/from field and a retired field infields. Withoutfieldsit asks for every field except the slug and the plan of a planned change.kind: 'remove'deletes an entry.needsApproval: truemakes a staff change through this task go live only after an owner or admin approves it.prefillfills fields of the brick in the form in advance. The platform resolves the tokens'@today','@workWeek', and'@nextWorkWeek'in the time zone of the site. A token needs adatefield; any other value is written like the field's value and checked like it, so amarkdownfield takes Markdown text.'@workWeek'is the first day of the work week,'@nextWorkWeek'the first day of the week after it. The first day of the week comes from the main language of the site (languages, for example Monday forde-AT, Sunday foren-US) and you can override it withfirstDayOfWeek(1 to 7, 1 = Monday) in the site config. At the weekend (the days after the last workday) the work week is already the coming week, so a weekly plan made on Saturday is for the week ahead.- A date field that holds a week start (the
fieldofplans, else the field a task presets to'@workWeek'or'@nextWorkWeek') takes only the first day of a week. Theedittask that presets a field to'@workWeek'(the weekly task) always covers the current week: the platform refuses another week there, also when a proposal from an earlier week is approved.
Staff members (role staff) may only do what matches a task that the owner has granted to them. You can change tasks at any time. They are not part of the data contract.
source: 'files', because nobody edits them in the editor.Planned change
With planned, a brick plans a change of one field ahead, for example a price that goes up on 1 November:
planned: { field: 'price', next: 'nextPrice', from: 'priceFrom' }From the date in priceFrom on, the value in nextPrice applies instead of price. next has the type of field, from is a date field, and both are optional: without a plan they stay empty. ginko check reports a task that changes field but does not show next and from. So nobody changes a price without seeing the planned one.
The platform stores a plan only with its value and its date together. From the date on, a task with these fields opens on the new value, and saving it removes the spent plan. A task that creates a new entry does not ask for a plan. Whoever saves only the plan reads the day it applies from in the message, not “in about a minute”. Your site reads which value applies on a day with valueOn(brick, data, 'price', today) (in the gastro preset with priceOn); a fact (see below) applies the plan itself.
Facts in texts
A price, a phone number or opening times often appear in running text too. So they never go stale there, facts names the fields of single bricks that a text may quote:
facts: ['praxis.price', 'praxis.times', 'site.phone']Ginko adds the inline component fact for them. The text holds :fact{name="praxis.price"}, the Studio shows a chip with the field's name ("Price per session"). The page, the search, the Markdown twin and llms-full.txt show the value in effect today: a planned change from its date on, money as "€ 98", a date in long form, multiple choices and one-column lists as a list ("11:00, 12:15 and 13:30"), an email address or phone number (format: 'email' or 'tel') as a link.
Allowed are text, number (with unit), money, date, time, choice, choices and lists with exactly one column. ginko check reports a path that does not name such a field of a single brick. In your own code, useGinkoFact('praxis.price') reads the same text; a site that shows fact differently gives <GinkoMarkdown> its own component under components.